Files
b0esche_cloud/go_cloud/internal/http/routes.go

4615 lines
158 KiB
Go

package http
import (
"archive/zip"
"bytes"
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"mime"
"mime/multipart"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"time"
"go.b0esche.cloud/backend/internal/audit"
"go.b0esche.cloud/backend/internal/auth"
"go.b0esche.cloud/backend/internal/config"
"go.b0esche.cloud/backend/internal/database"
"go.b0esche.cloud/backend/internal/errors"
"go.b0esche.cloud/backend/internal/middleware"
"go.b0esche.cloud/backend/internal/org"
"go.b0esche.cloud/backend/internal/permission"
"go.b0esche.cloud/backend/pkg/jwt"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"go.b0esche.cloud/backend/internal/storage"
)
// sanitizePath validates and sanitizes a file path to prevent path traversal attacks.
// Returns the cleaned path or an error if the path is invalid.
func sanitizePath(inputPath string) (string, error) {
// Clean the path to resolve . and ..
cleaned := path.Clean(inputPath)
// Ensure the path doesn't try to escape the root
if strings.Contains(cleaned, "..") {
return "", fmt.Errorf("invalid path: path traversal detected")
}
// Ensure path starts with /
if !strings.HasPrefix(cleaned, "/") {
cleaned = "/" + cleaned
}
return cleaned, nil
}
// Avatar cache helpers
// avatarCachePath builds a path for the avatar cache file for the given user and version (if provided).
func avatarCachePath(cfg *config.Config, userID string, version string, ext string) string {
dir := cfg.AvatarCacheDir
if dir == "" {
dir = "/var/cache/b0esche/avatars"
}
os.MkdirAll(dir, 0755)
// Filename: <userID>.<version><ext> (if version empty, use <userID><ext>)
if version != "" {
return filepath.Join(dir, fmt.Sprintf("%s.%s%s", userID, version, ext))
}
return filepath.Join(dir, fmt.Sprintf("%s%s", userID, ext))
}
// writeAvatarCache saves avatar bytes keyed by userID and version (best effort). If version is empty, writes a file without version suffix.
func writeAvatarCache(cfg *config.Config, userID string, version string, ext string, data []byte) error {
dir := cfg.AvatarCacheDir
if dir == "" {
dir = "/var/cache/b0esche/avatars"
}
// Try to create the directory; if it fails, fall back to temp dir
if err := os.MkdirAll(dir, 0755); err != nil {
fmt.Printf("[WARN] failed to create avatar cache dir %s: %v; trying fallback to tmp dir\n", dir, err)
fallback := filepath.Join(os.TempDir(), "b0esche_avatars")
if err2 := os.MkdirAll(fallback, 0755); err2 != nil {
return fmt.Errorf("failed to create avatar cache dir: %v; fallback failed: %v", err, err2)
}
dir = fallback
}
p := avatarCachePath(&config.Config{AvatarCacheDir: dir}, userID, version, ext)
if err := os.WriteFile(p, data, 0644); err != nil {
return err
}
fmt.Printf("[INFO] Wrote avatar cache for user=%s v=%s path=%s size=%d\n", userID, version, p, len(data))
return nil
}
// readAvatarCache attempts to read a cached avatar for a user and optional version. If version is provided, it looks for an exact match; otherwise it returns the latest available cached avatar (if any).
func readAvatarCache(cfg *config.Config, userID string, version string) ([]byte, string, error) {
checkDirs := []string{cfg.AvatarCacheDir}
if checkDirs[0] == "" {
checkDirs[0] = "/var/cache/b0esche/avatars"
}
// Also check fallback tmp dir
checkDirs = append(checkDirs, filepath.Join(os.TempDir(), "b0esche_avatars"))
fmt.Printf("[DEBUG] readAvatarCache checking dirs=%v for user=%s version=%s\n", checkDirs, userID, version)
for _, dir := range checkDirs {
entries, err := os.ReadDir(dir)
if err != nil {
fmt.Printf("[DEBUG] readAvatarCache cannot read dir %s: %v\n", dir, err)
continue
}
if version != "" {
// look for exact match: userID.version.*
prefix := fmt.Sprintf("%s.%s", userID, version)
for _, e := range entries {
if strings.HasPrefix(e.Name(), prefix) {
p := filepath.Join(dir, e.Name())
fmt.Printf("[INFO] Found cached avatar for user=%s v=%s path=%s\n", userID, version, p)
b, err := os.ReadFile(p)
if err != nil {
fmt.Printf("[WARN] failed to read cached avatar %s: %v\n", p, err)
return nil, "", err
}
ext := filepath.Ext(e.Name())
ct := mime.TypeByExtension(ext)
if ct == "" {
ct = "application/octet-stream"
}
return b, ct, nil
}
}
// not found
fmt.Printf("[DEBUG] no exact cached avatar match in %s for prefix=%s\n", dir, prefix)
continue
}
// no version specified: return latest by modtime of files starting with userID.
var latest os.FileInfo
var latestName string
for _, e := range entries {
if strings.HasPrefix(e.Name(), userID+".") {
fi, err := e.Info()
if err != nil {
continue
}
if latest == nil || fi.ModTime().After(latest.ModTime()) {
latest = fi
latestName = e.Name()
}
}
}
if latest != nil {
p := filepath.Join(dir, latestName)
fmt.Printf("[INFO] Found latest cached avatar for user=%s path=%s\n", userID, p)
b, err := os.ReadFile(p)
if err != nil {
fmt.Printf("[WARN] failed to read cached avatar %s: %v\n", p, err)
return nil, "", err
}
ext := filepath.Ext(latestName)
ct := mime.TypeByExtension(ext)
if ct == "" {
ct = "application/octet-stream"
}
return b, ct, nil
}
}
return nil, "", fmt.Errorf("cache miss")
}
// getUserWebDAVClient gets or creates a user's Nextcloud account and returns a WebDAV client for them
func getUserWebDAVClient(ctx context.Context, db *database.DB, userID uuid.UUID, nextcloudBaseURL, adminUser, adminPass string) (*storage.WebDAVClient, error) {
var user struct {
Username string
NextcloudUsername string
NextcloudPassword string
}
err := db.QueryRowContext(ctx,
"SELECT username, COALESCE(nextcloud_username, ''), COALESCE(nextcloud_password, '') FROM users WHERE id = $1",
userID).Scan(&user.Username, &user.NextcloudUsername, &user.NextcloudPassword)
if err != nil {
return nil, fmt.Errorf("failed to get user: %w", err)
}
// If user doesn't have Nextcloud credentials, create them
if user.NextcloudUsername == "" || user.NextcloudPassword == "" {
// Use the actual username from the users table
ncUsername := user.Username
ncPassword, err := storage.GenerateSecurePassword(32)
if err != nil {
return nil, fmt.Errorf("failed to generate password: %w", err)
}
// Create Nextcloud user account
err = storage.CreateNextcloudUser(nextcloudBaseURL, adminUser, adminPass, ncUsername, ncPassword)
if err != nil {
return nil, fmt.Errorf("failed to create Nextcloud user: %w", err)
}
// Update database with Nextcloud credentials
_, err = db.ExecContext(ctx,
"UPDATE users SET nextcloud_username = $1, nextcloud_password = $2 WHERE id = $3",
ncUsername, ncPassword, userID)
if err != nil {
return nil, fmt.Errorf("failed to update user credentials: %w", err)
}
user.NextcloudUsername = ncUsername
user.NextcloudPassword = ncPassword
log.Printf("[AUTO-PROVISION] Created Nextcloud account for user %s: %s\n", userID, ncUsername)
}
// Create user-specific WebDAV client
return storage.NewUserWebDAVClient(nextcloudBaseURL, user.NextcloudUsername, user.NextcloudPassword), nil
}
func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, authService *auth.Service, auditLogger *audit.Logger) http.Handler {
r := chi.NewRouter()
// Global middleware
r.Use(middleware.RequestID)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.CORS(cfg.AllowedOrigins))
r.Use(middleware.SecurityHeaders())
r.Use(middleware.RateLimit)
// Health check
r.Get("/health", healthHandler)
// Join org by invite token (public)
r.Get("/join", func(w http.ResponseWriter, req *http.Request) {
getOrgByInviteTokenHandler(w, req, db)
})
// WOPI routes (public, token validation done per endpoint)
r.Route("/wopi", func(r chi.Router) {
r.Route("/files/{fileId}", func(r chi.Router) {
// CheckFileInfo: GET /wopi/files/{fileId}
r.Get("/", func(w http.ResponseWriter, req *http.Request) {
wopiCheckFileInfoHandler(w, req, db, jwtManager)
})
// GetFile: GET /wopi/files/{fileId}/contents
r.Get("/contents", func(w http.ResponseWriter, req *http.Request) {
wopiGetFileHandler(w, req, db, jwtManager, cfg)
})
// PutFile & Lock operations: POST /wopi/files/{fileId}/contents and POST /wopi/files/{fileId}
r.Post("/contents", func(w http.ResponseWriter, req *http.Request) {
wopiPutFileHandler(w, req, db, jwtManager, cfg)
})
// Lock operations: POST /wopi/files/{fileId}
r.Post("/", func(w http.ResponseWriter, req *http.Request) {
wopiLockHandler(w, req, db, jwtManager)
})
})
})
// Auth routes (no auth required)
r.Route("/auth", func(r chi.Router) {
r.Post("/refresh", func(w http.ResponseWriter, req *http.Request) {
refreshHandler(w, req, jwtManager, db)
})
r.Post("/logout", func(w http.ResponseWriter, req *http.Request) {
logoutHandler(w, req, jwtManager, db, auditLogger)
})
// Passkey routes
r.Post("/signup", func(w http.ResponseWriter, req *http.Request) {
signupHandler(w, req, db, auditLogger)
})
r.Post("/registration-challenge", func(w http.ResponseWriter, req *http.Request) {
registrationChallengeHandler(w, req, db)
})
r.Post("/registration-verify", func(w http.ResponseWriter, req *http.Request) {
registrationVerifyHandler(w, req, db, jwtManager, auditLogger)
})
r.Post("/authentication-challenge", func(w http.ResponseWriter, req *http.Request) {
authenticationChallengeHandler(w, req, db)
})
r.Post("/authentication-verify", func(w http.ResponseWriter, req *http.Request) {
authenticationVerifyHandler(w, req, db, jwtManager, auditLogger)
})
// Password login route
r.Post("/password-login", func(w http.ResponseWriter, req *http.Request) {
passwordLoginHandler(w, req, db, jwtManager, auditLogger)
})
})
// Protected routes (with auth middleware)
r.Route("/", func(r chi.Router) {
r.Use(middleware.Auth(jwtManager, db))
// User-scoped routes (personal workspace)
r.Get("/user/files", func(w http.ResponseWriter, req *http.Request) {
userFilesHandler(w, req, db)
})
// User file viewer
r.Get("/user/files/{fileId}/view", func(w http.ResponseWriter, req *http.Request) {
userViewerHandler(w, req, db, jwtManager, auditLogger)
})
// Download user file
r.Get("/user/files/download", func(w http.ResponseWriter, req *http.Request) {
downloadUserFileHandler(w, req, db, cfg)
})
// Create / delete in user workspace
r.Post("/user/files", func(w http.ResponseWriter, req *http.Request) {
createUserFileHandler(w, req, db, auditLogger, cfg)
})
r.Delete("/user/files", func(w http.ResponseWriter, req *http.Request) {
deleteUserFileHandler(w, req, db, auditLogger, cfg)
})
// POST wrapper for delete
r.Post("/user/files/delete", func(w http.ResponseWriter, req *http.Request) {
deleteUserFilePostHandler(w, req, db, auditLogger, cfg)
})
// Move file/folder in user workspace
r.Post("/user/files/move", func(w http.ResponseWriter, req *http.Request) {
moveUserFileHandler(w, req, db, auditLogger, cfg)
})
// WOPI session for user files
r.Post("/user/files/{fileId}/wopi-session", func(w http.ResponseWriter, req *http.Request) {
wopiSessionHandler(w, req, db, jwtManager, "https://of.b0esche.cloud")
})
// User file editor
r.Get("/user/files/{fileId}/edit", func(w http.ResponseWriter, req *http.Request) {
userEditorHandler(w, req, db, auditLogger)
})
// Collabora form proxy for user files
r.Get("/user/files/{fileId}/collabora-proxy", func(w http.ResponseWriter, req *http.Request) {
collaboraProxyHandler(w, req, db, jwtManager, "https://of.b0esche.cloud")
})
// Share link management for user files
r.Get("/user/files/{fileId}/share", func(w http.ResponseWriter, req *http.Request) {
getUserFileShareLinkHandler(w, req, db)
})
r.Post("/user/files/{fileId}/share", func(w http.ResponseWriter, req *http.Request) {
createUserFileShareLinkHandler(w, req, db)
})
r.Delete("/user/files/{fileId}/share", func(w http.ResponseWriter, req *http.Request) {
revokeUserFileShareLinkHandler(w, req, db)
})
// Share link management for personal files (alternative path for frontend compatibility)
r.Get("/orgs/files/{fileId}/share", func(w http.ResponseWriter, req *http.Request) {
getUserFileShareLinkHandler(w, req, db)
})
r.Post("/orgs/files/{fileId}/share", func(w http.ResponseWriter, req *http.Request) {
createUserFileShareLinkHandler(w, req, db)
})
r.Delete("/orgs/files/{fileId}/share", func(w http.ResponseWriter, req *http.Request) {
revokeUserFileShareLinkHandler(w, req, db)
})
// User profile routes
r.Get("/user/profile", func(w http.ResponseWriter, req *http.Request) {
getUserProfileHandler(w, req, db)
})
r.Put("/user/profile", func(w http.ResponseWriter, req *http.Request) {
updateUserProfileHandler(w, req, db, auditLogger)
})
r.Options("/user/profile", func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.WriteHeader(http.StatusOK)
})
r.Post("/user/change-password", func(w http.ResponseWriter, req *http.Request) {
changePasswordHandler(w, req, db, auditLogger)
})
r.Options("/user/change-password", func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.WriteHeader(http.StatusOK)
})
r.Post("/user/avatar", func(w http.ResponseWriter, req *http.Request) {
uploadUserAvatarHandler(w, req, db, auditLogger, cfg)
})
r.Get("/user/avatar", func(w http.ResponseWriter, req *http.Request) {
getUserAvatarHandler(w, req, db, jwtManager, cfg)
})
r.Options("/user/avatar", func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.WriteHeader(http.StatusOK)
})
r.Delete("/user/account", func(w http.ResponseWriter, req *http.Request) {
deleteUserAccountHandler(w, req, db, auditLogger, cfg)
})
r.Options("/user/account", func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.WriteHeader(http.StatusOK)
})
// Org routes
r.Get("/orgs", func(w http.ResponseWriter, req *http.Request) {
listOrgsHandler(w, req, db)
})
r.Post("/orgs", func(w http.ResponseWriter, req *http.Request) {
createOrgHandler(w, req, db, auditLogger)
})
// Org-scoped routes
r.Route("/orgs/{orgId}", func(r chi.Router) {
r.Use(middleware.Org(db, auditLogger))
// File routes
r.With(middleware.Permission(db, auditLogger, permission.FileRead)).Get("/files", func(w http.ResponseWriter, req *http.Request) {
listFilesHandler(w, req, db)
})
// Download org file
r.With(middleware.Permission(db, auditLogger, permission.FileRead)).Get("/files/download", func(w http.ResponseWriter, req *http.Request) {
downloadOrgFileHandler(w, req, db, cfg)
})
// Create file/folder in org workspace
r.With(middleware.Permission(db, auditLogger, permission.FileWrite)).Post("/files", func(w http.ResponseWriter, req *http.Request) {
createOrgFileHandler(w, req, db, auditLogger, cfg)
})
// Also accept POST delete for clients that cannot send DELETE with body
r.With(middleware.Permission(db, auditLogger, permission.FileWrite)).Post("/files/delete", func(w http.ResponseWriter, req *http.Request) {
deleteOrgFilePostHandler(w, req, db, auditLogger, cfg)
})
// Delete file/folder in org workspace (body: {"path":"/path"})
r.With(middleware.Permission(db, auditLogger, permission.FileWrite)).Delete("/files", func(w http.ResponseWriter, req *http.Request) {
deleteOrgFileHandler(w, req, db, auditLogger, cfg)
})
// Move file/folder in org workspace (body: {"sourcePath":"/old", "targetPath":"/new"})
r.With(middleware.Permission(db, auditLogger, permission.FileWrite)).Post("/files/move", func(w http.ResponseWriter, req *http.Request) {
moveOrgFileHandler(w, req, db, auditLogger, cfg)
})
r.Route("/files/{fileId}", func(r chi.Router) {
r.With(middleware.Permission(db, auditLogger, permission.DocumentView)).Get("/view", func(w http.ResponseWriter, req *http.Request) {
viewerHandler(w, req, db, jwtManager, auditLogger)
})
r.With(middleware.Permission(db, auditLogger, permission.DocumentEdit)).Get("/edit", func(w http.ResponseWriter, req *http.Request) {
editorHandler(w, req, db, auditLogger)
})
r.With(middleware.Permission(db, auditLogger, permission.DocumentEdit)).Post("/annotations", func(w http.ResponseWriter, req *http.Request) {
annotationsHandler(w, req, db, auditLogger)
})
r.Get("/meta", func(w http.ResponseWriter, req *http.Request) {
fileMetaHandler(w, req)
})
// Share link management
r.With(middleware.Permission(db, auditLogger, permission.FileRead)).Get("/share", func(w http.ResponseWriter, req *http.Request) {
getFileShareLinkHandler(w, req, db)
})
r.With(middleware.Permission(db, auditLogger, permission.FileRead)).Post("/share", func(w http.ResponseWriter, req *http.Request) {
createFileShareLinkHandler(w, req, db)
})
r.With(middleware.Permission(db, auditLogger, permission.FileRead)).Delete("/share", func(w http.ResponseWriter, req *http.Request) {
revokeFileShareLinkHandler(w, req, db)
})
// WOPI session for org files
r.With(middleware.Permission(db, auditLogger, permission.DocumentView)).Post("/wopi-session", func(w http.ResponseWriter, req *http.Request) {
wopiSessionHandler(w, req, db, jwtManager, "https://of.b0esche.cloud")
})
// Collabora form proxy for org files
r.With(middleware.Permission(db, auditLogger, permission.DocumentView)).Get("/collabora-proxy", func(w http.ResponseWriter, req *http.Request) {
collaboraProxyHandler(w, req, db, jwtManager, "https://of.b0esche.cloud")
})
})
r.Get("/activity", func(w http.ResponseWriter, req *http.Request) {
activityHandler(w, req, db)
})
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Get("/members", func(w http.ResponseWriter, req *http.Request) {
listMembersHandler(w, req, db)
})
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Patch("/members/{userId}", func(w http.ResponseWriter, req *http.Request) {
updateMemberRoleHandler(w, req, db, auditLogger)
})
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Delete("/members/{userId}", func(w http.ResponseWriter, req *http.Request) {
removeMemberHandler(w, req, db, auditLogger)
})
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Get("/users/search", func(w http.ResponseWriter, req *http.Request) {
searchUsersHandler(w, req, db)
})
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Post("/invitations", func(w http.ResponseWriter, req *http.Request) {
createInvitationHandler(w, req, db, auditLogger)
})
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Get("/invitations", func(w http.ResponseWriter, req *http.Request) {
listInvitationsHandler(w, req, db)
})
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Delete("/invitations/{invitationId}", func(w http.ResponseWriter, req *http.Request) {
cancelInvitationHandler(w, req, db, auditLogger)
})
r.Post("/join-requests", func(w http.ResponseWriter, req *http.Request) {
createJoinRequestHandler(w, req, db, auditLogger)
})
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Get("/join-requests", func(w http.ResponseWriter, req *http.Request) {
listJoinRequestsHandler(w, req, db)
})
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Post("/join-requests/{requestId}/accept", func(w http.ResponseWriter, req *http.Request) {
acceptJoinRequestHandler(w, req, db, auditLogger)
})
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Post("/join-requests/{requestId}/reject", func(w http.ResponseWriter, req *http.Request) {
rejectJoinRequestHandler(w, req, db, auditLogger)
})
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Get("/invite-link", func(w http.ResponseWriter, req *http.Request) {
getInviteLinkHandler(w, req, db)
})
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Post("/invite-link/regenerate", func(w http.ResponseWriter, req *http.Request) {
regenerateInviteLinkHandler(w, req, db, auditLogger)
})
r.Get("/permissions", func(w http.ResponseWriter, req *http.Request) {
getPermissionsHandler(w, req, db)
})
})
}) // Close protected routes
// Public routes (no auth required)
r.Route("/public", func(r chi.Router) {
r.Get("/share/{token}", func(w http.ResponseWriter, req *http.Request) {
publicFileShareHandler(w, req, db, jwtManager)
})
r.Options("/share/{token}", func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Range")
w.WriteHeader(http.StatusOK)
})
r.Get("/share/{token}/download", func(w http.ResponseWriter, req *http.Request) {
publicFileDownloadHandler(w, req, db, cfg, jwtManager)
})
r.Options("/share/{token}/download", func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Range")
w.WriteHeader(http.StatusOK)
})
r.Get("/share/{token}/view", func(w http.ResponseWriter, req *http.Request) {
publicFileViewHandler(w, req, db, cfg, jwtManager)
})
r.Options("/share/{token}/view", func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Range")
w.WriteHeader(http.StatusOK)
})
// Public WOPI routes for shared files
r.Route("/wopi/share/{token}", func(r chi.Router) {
r.Get("/", func(w http.ResponseWriter, req *http.Request) {
publicWopiCheckFileInfoHandler(w, req, db, jwtManager)
})
r.Get("/contents", func(w http.ResponseWriter, req *http.Request) {
publicWopiGetFileHandler(w, req, db, cfg, jwtManager)
})
})
})
return r
}
func getOrgByInviteTokenHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
token := r.URL.Query().Get("token")
if token == "" {
errors.WriteError(w, errors.CodeInvalidArgument, "Token required", http.StatusBadRequest)
return
}
var org database.Organization
err := db.QueryRowContext(r.Context(), `
SELECT id, owner_id, name, slug, created_at
FROM organizations
WHERE invite_link_token = $1
`, token).Scan(&org.ID, &org.OwnerID, &org.Name, &org.Slug, &org.CreatedAt)
if err != nil {
errors.LogError(r, err, "Invalid invite token")
errors.WriteError(w, errors.CodeNotFound, "Invalid token", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(org)
}
func healthHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
func refreshHandler(w http.ResponseWriter, r *http.Request, jwtManager *jwt.Manager, db *database.DB) {
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
claims, session, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db)
if err != nil {
errors.LogError(r, err, "Invalid token")
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
userID, _ := uuid.Parse(claims.UserID)
orgs, err := db.GetUserOrganizations(r.Context(), userID)
if err != nil {
errors.LogError(r, err, "Failed to get user organizations")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
orgIDs := make([]string, len(orgs))
for i, o := range orgs {
orgIDs[i] = o.ID.String()
}
newToken, err := jwtManager.Generate(claims.UserID, orgIDs, session.ID.String())
if err != nil {
errors.LogError(r, err, "Token generation failed")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"token": "` + newToken + `"}`))
}
func logoutHandler(w http.ResponseWriter, r *http.Request, jwtManager *jwt.Manager, db *database.DB, auditLogger *audit.Logger) {
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
claims, session, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db)
if err != nil {
// Token invalid or session already revoked/expired — still return success
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status": "ok"}`))
return
}
userID, _ := uuid.Parse(claims.UserID)
// Revoke session
if err := db.RevokeSession(r.Context(), session.ID); err != nil {
errors.LogError(r, err, "Failed to revoke session")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
Action: "logout",
Success: true,
})
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status": "ok"}`))
}
func listOrgsHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
// User ID is already set by Auth middleware
userIDStr, ok := middleware.GetUserID(r.Context())
if !ok {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
userID, _ := uuid.Parse(userIDStr)
orgs, err := org.ResolveUserOrgs(r.Context(), db, userID)
if err != nil {
errors.LogError(r, err, "Failed to resolve user orgs")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(orgs)
}
func createOrgHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
// User ID is already set by Auth middleware
userIDStr, ok := middleware.GetUserID(r.Context())
if !ok {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
userID, _ := uuid.Parse(userIDStr)
var req struct {
Name string `json:"name"`
Slug string `json:"slug,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
org, err := org.CreateOrg(r.Context(), db, userID, req.Name, req.Slug)
if err != nil {
errors.LogError(r, err, "Failed to create org")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
OrgID: &org.ID,
Action: "create_org",
Success: true,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(org)
}
func listFilesHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
// Org ID is provided by middleware.Org
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
userIDStr, ok := middleware.GetUserID(r.Context())
if !ok || userIDStr == "" {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
userID, err := uuid.Parse(userIDStr)
if err != nil {
errors.LogError(r, err, "Invalid user id in context")
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
// Query params: path, q (search), page, pageSize
path := r.URL.Query().Get("path")
if path == "" {
path = "/"
}
path, err = sanitizePath(path)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid path", http.StatusBadRequest)
return
}
q := r.URL.Query().Get("q")
page := 1
pageSize := 100
if p := r.URL.Query().Get("page"); p != "" {
fmt.Sscanf(p, "%d", &page)
}
if ps := r.URL.Query().Get("pageSize"); ps != "" {
fmt.Sscanf(ps, "%d", &pageSize)
}
files, err := db.GetOrgFiles(r.Context(), orgID, userID, path, q, page, pageSize)
if err != nil {
errors.LogError(r, err, "Failed to get org files")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Convert to a JSON-friendly shape expected by frontend
out := make([]map[string]interface{}, 0, len(files))
for _, f := range files {
out = append(out, map[string]interface{}{
"id": f.ID.String(),
"name": f.Name,
"path": f.Path,
"type": f.Type,
"size": f.Size,
"lastModified": f.LastModified.UTC().Format(time.RFC3339),
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(out)
}
func viewerHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, auditLogger *audit.Logger) {
userIDStr, _ := middleware.GetUserID(r.Context())
userID, _ := uuid.Parse(userIDStr)
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
sessionObj, _ := middleware.GetSession(r.Context())
fileId := chi.URLParam(r, "fileId")
// Get file metadata to determine path and type
fileUUID, err := uuid.Parse(fileId)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid file ID", http.StatusBadRequest)
return
}
file, err := db.GetFileByID(r.Context(), fileUUID)
if err != nil {
errors.LogError(r, err, "Failed to get file metadata")
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
// Check if it's a folder - cannot view folders
if file.Type == "folder" {
errors.WriteError(w, errors.CodeInvalidArgument, "Cannot view folders", http.StatusBadRequest)
return
}
// Log activity
db.LogActivity(r.Context(), userID, orgID, &fileId, "view_file", map[string]interface{}{})
// Build download URL with proper URL encoding using the request's scheme and host
scheme := "https"
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
scheme = proto
} else if r.TLS == nil {
scheme = "http"
}
host := r.Host
if host == "" {
host = "go.b0esche.cloud"
}
// Generate a long-lived token specifically for this viewer session (24 hours)
orgs, err := db.GetUserOrganizations(r.Context(), userID)
if err != nil {
errors.LogError(r, err, "Failed to get user organizations")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
orgIDs := make([]string, len(orgs))
for i, o := range orgs {
orgIDs[i] = o.ID.String()
}
viewerToken, err := jwtManager.GenerateWithDuration(userID.String(), orgIDs, sessionObj.ID.String(), 24*time.Hour)
if err != nil {
errors.LogError(r, err, "Failed to generate viewer token")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
downloadPath := fmt.Sprintf("%s://%s/orgs/%s/files/download?path=%s&token=%s", scheme, host, orgID.String(), url.QueryEscape(file.Path), url.QueryEscape(viewerToken))
// Determine file type based on extension
isPdf := strings.HasSuffix(strings.ToLower(file.Name), ".pdf")
mimeType := getMimeType(file.Name)
viewerSession := struct {
ViewUrl string `json:"viewUrl"`
Token string `json:"token"`
Capabilities struct {
CanEdit bool `json:"canEdit"`
CanAnnotate bool `json:"canAnnotate"`
IsPdf bool `json:"isPdf"`
MimeType string `json:"mimeType"`
} `json:"capabilities"`
FileInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
LastModified string `json:"lastModified"`
ModifiedByName string `json:"modifiedByName"`
} `json:"fileInfo"`
ExpiresAt string `json:"expiresAt"`
}{
ViewUrl: downloadPath,
Token: viewerToken, // Long-lived JWT token for authenticating file download
Capabilities: struct {
CanEdit bool `json:"canEdit"`
CanAnnotate bool `json:"canAnnotate"`
IsPdf bool `json:"isPdf"`
MimeType string `json:"mimeType"`
}{CanEdit: false, CanAnnotate: isPdf, IsPdf: isPdf, MimeType: mimeType},
FileInfo: struct {
Name string `json:"name"`
Size int64 `json:"size"`
LastModified string `json:"lastModified"`
ModifiedByName string `json:"modifiedByName"`
}{
Name: file.Name,
Size: file.Size,
LastModified: file.LastModified.UTC().Format(time.RFC3339),
ModifiedByName: file.ModifiedByName,
},
ExpiresAt: time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339),
}
fmt.Printf("[VIEWER-SESSION] orgId=%s, fileId=%s, token_included=yes, isPdf=%v, mimeType=%s\n", orgID.String(), fileId, isPdf, mimeType)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(viewerSession)
}
// userViewerHandler serves a viewer session for personal workspace files
func userViewerHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, auditLogger *audit.Logger) {
userIDStr, _ := middleware.GetUserID(r.Context())
userID, _ := uuid.Parse(userIDStr)
sessionObj, _ := middleware.GetSession(r.Context())
fileId := chi.URLParam(r, "fileId")
// Get file metadata to determine path and type
fileUUID, err := uuid.Parse(fileId)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid file ID", http.StatusBadRequest)
return
}
file, err := db.GetFileByID(r.Context(), fileUUID)
if err != nil {
errors.LogError(r, err, "Failed to get file metadata")
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
// Check if it's a folder - cannot view folders
if file.Type == "folder" {
errors.WriteError(w, errors.CodeInvalidArgument, "Cannot view folders", http.StatusBadRequest)
return
}
// Optionally log activity without org id
db.LogActivity(r.Context(), userID, uuid.Nil, &fileId, "view_user_file", map[string]interface{}{})
// Build download URL with proper URL encoding using the request's scheme and host
scheme := "https"
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
scheme = proto
} else if r.TLS == nil {
scheme = "http"
}
host := r.Host
if host == "" {
host = "go.b0esche.cloud"
}
// Generate a long-lived token specifically for this viewer session (24 hours)
orgs, err := db.GetUserOrganizations(r.Context(), userID)
if err != nil {
errors.LogError(r, err, "Failed to get user organizations")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
orgIDs := make([]string, len(orgs))
for i, o := range orgs {
orgIDs[i] = o.ID.String()
}
viewerToken, err := jwtManager.GenerateWithDuration(userID.String(), orgIDs, sessionObj.ID.String(), 24*time.Hour)
if err != nil {
errors.LogError(r, err, "Failed to generate viewer token")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
downloadPath := fmt.Sprintf("%s://%s/user/files/download?path=%s&token=%s", scheme, host, url.QueryEscape(file.Path), url.QueryEscape(viewerToken))
// Determine file type based on extension
isPdf := strings.HasSuffix(strings.ToLower(file.Name), ".pdf")
mimeType := getMimeType(file.Name)
viewerSession := struct {
ViewUrl string `json:"viewUrl"`
Token string `json:"token"`
Capabilities struct {
CanEdit bool `json:"canEdit"`
CanAnnotate bool `json:"canAnnotate"`
IsPdf bool `json:"isPdf"`
MimeType string `json:"mimeType"`
} `json:"capabilities"`
FileInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
LastModified string `json:"lastModified"`
ModifiedByName string `json:"modifiedByName"`
} `json:"fileInfo"`
ExpiresAt string `json:"expiresAt"`
}{
ViewUrl: downloadPath,
Token: viewerToken, // Long-lived JWT token for authenticating file download
Capabilities: struct {
CanEdit bool `json:"canEdit"`
CanAnnotate bool `json:"canAnnotate"`
IsPdf bool `json:"isPdf"`
MimeType string `json:"mimeType"`
}{
CanEdit: false,
CanAnnotate: isPdf,
IsPdf: isPdf,
MimeType: mimeType,
},
FileInfo: struct {
Name string `json:"name"`
Size int64 `json:"size"`
LastModified string `json:"lastModified"`
ModifiedByName string `json:"modifiedByName"`
}{
Name: file.Name,
Size: file.Size,
LastModified: file.LastModified.UTC().Format(time.RFC3339),
ModifiedByName: file.ModifiedByName,
},
ExpiresAt: time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339),
}
fmt.Printf("[VIEWER-SESSION] userId=%s, fileId=%s, token_included=yes, isPdf=%v, mimeType=%s\n", userID.String(), fileId, isPdf, mimeType)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(viewerSession)
}
func editorHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
userIDStr, _ := middleware.GetUserID(r.Context())
userID, _ := uuid.Parse(userIDStr)
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
fileId := chi.URLParam(r, "fileId")
fmt.Printf("[EDITOR] Starting editor session for file=%s user=%s org=%s\n", fileId, userIDStr, orgID.String())
// Log activity
db.LogActivity(r.Context(), userID, orgID, &fileId, "edit_file", map[string]interface{}{})
// Generate WOPI access token (1 hour duration)
token, _ := middleware.GetToken(r.Context())
// Build WOPISrc URL
wopiSrc := fmt.Sprintf("https://go.b0esche.cloud/wopi/files/%s?access_token=%s", fileId, token)
// Build Collabora editor URL
collaboraUrl := fmt.Sprintf("https://of.b0esche.cloud/lool/dist/mobile/cool.html?WOPISrc=%s", url.QueryEscape(wopiSrc))
// Check if user can edit (for now, all org members can edit)
readOnly := false
session := struct {
EditUrl string `json:"editUrl"`
Token string `json:"token"`
ReadOnly bool `json:"readOnly"`
ExpiresAt string `json:"expiresAt"`
}{
EditUrl: collaboraUrl,
Token: token, // JWT token for authenticating file access
ReadOnly: readOnly,
ExpiresAt: time.Now().Add(15 * time.Minute).UTC().Format(time.RFC3339),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(session)
}
// userEditorHandler handles GET /user/files/{fileId}/edit
func userEditorHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
userIDStr, _ := middleware.GetUserID(r.Context())
userID, _ := uuid.Parse(userIDStr)
fileId := chi.URLParam(r, "fileId")
fmt.Printf("[EDITOR] Starting user editor session for file=%s user=%s\n", fileId, userIDStr)
// Get file metadata to determine path and type
fileUUID, err := uuid.Parse(fileId)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid file ID", http.StatusBadRequest)
return
}
file, err := db.GetFileByID(r.Context(), fileUUID)
if err != nil {
errors.LogError(r, err, "Failed to get file metadata")
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
// Verify user owns this file
if file.UserID == nil || *file.UserID != userID {
errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden)
return
}
// Log activity
db.LogActivity(r.Context(), userID, uuid.Nil, &fileId, "edit_file", map[string]interface{}{})
// Generate WOPI access token (1 hour duration)
token, _ := middleware.GetToken(r.Context())
// Build WOPISrc URL
wopiSrc := fmt.Sprintf("https://go.b0esche.cloud/wopi/files/%s?access_token=%s", fileId, token)
// Build Collabora editor URL
collaboraUrl := fmt.Sprintf("https://of.b0esche.cloud/lool/dist/mobile/cool.html?WOPISrc=%s", url.QueryEscape(wopiSrc))
fmt.Printf("[EDITOR] Built user URLs: wopiSrc=%s collaboraUrl=%s\n", wopiSrc, collaboraUrl)
// Check if user can edit (for now, all users can edit their own files)
readOnly := false
session := struct {
EditUrl string `json:"editUrl"`
Token string `json:"token"`
ReadOnly bool `json:"readOnly"`
ExpiresAt string `json:"expiresAt"`
}{
EditUrl: collaboraUrl,
Token: token, // JWT token for authenticating file access
ReadOnly: readOnly,
ExpiresAt: time.Now().Add(15 * time.Minute).UTC().Format(time.RFC3339),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(session)
}
func annotationsHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
userIDStr, _ := middleware.GetUserID(r.Context())
userID, _ := uuid.Parse(userIDStr)
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
fileId := chi.URLParam(r, "fileId")
// Parse payload
var payload struct {
Annotations []interface{} `json:"annotations"`
BaseVersionId string `json:"baseVersionId"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
// Log activity
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
OrgID: &orgID,
Resource: &fileId,
Action: "annotate_pdf",
Success: true,
Metadata: map[string]interface{}{"count": len(payload.Annotations)},
})
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status": "ok"}`))
}
func activityHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
activities, err := db.GetOrgActivities(r.Context(), orgID, 50)
if err != nil {
errors.LogError(r, err, "Failed to get org activities")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(activities)
}
type memberResponse struct {
UserID string `json:"userId"`
OrgID string `json:"orgId"`
Role string `json:"role"`
CreatedAt time.Time `json:"createdAt"`
User userInfo `json:"user"`
}
type userInfo struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName *string `json:"displayName"`
Email string `json:"email"`
}
func listMembersHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
members, err := db.GetOrgMembersWithUsers(r.Context(), orgID)
if err != nil {
errors.LogError(r, err, "Failed to get org members")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Convert to proper response format
var response []memberResponse
for _, m := range members {
response = append(response, memberResponse{
UserID: m.Membership.UserID.String(),
OrgID: m.Membership.OrgID.String(),
Role: m.Membership.Role,
CreatedAt: m.Membership.CreatedAt,
User: userInfo{
ID: m.User.ID.String(),
Username: m.User.Username,
DisplayName: func() *string {
if m.User.DisplayName == "" {
return nil
}
return &m.User.DisplayName
}(),
Email: m.User.Email,
},
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func updateMemberRoleHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
userIDStr := chi.URLParam(r, "userId")
userID, err := uuid.Parse(userIDStr)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest)
return
}
var req struct {
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
if err := db.UpdateMemberRole(r.Context(), orgID, userID, req.Role); err != nil {
errors.LogError(r, err, "Failed to update member role")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status": "ok"}`))
}
func removeMemberHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
userIDStr := chi.URLParam(r, "userId")
userID, err := uuid.Parse(userIDStr)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest)
return
}
// Check if trying to remove the owner
membership, err := db.GetUserMembership(r.Context(), userID, orgID)
if err != nil {
errors.LogError(r, err, "Failed to get membership")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
if membership.Role == "owner" {
errors.WriteError(w, errors.CodePermissionDenied, "Cannot remove organization owner", http.StatusForbidden)
return
}
if err := db.RemoveMember(r.Context(), orgID, userID); err != nil {
errors.LogError(r, err, "Failed to remove member")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
resource := userID.String()
auditLogger.Log(r.Context(), audit.Entry{
OrgID: &orgID,
Action: "remove_member",
Resource: &resource,
Success: true,
})
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status": "ok"}`))
}
func searchUsersHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
query := r.URL.Query().Get("q")
if query == "" {
errors.WriteError(w, errors.CodeInvalidArgument, "Query parameter 'q' is required", http.StatusBadRequest)
return
}
users, err := db.SearchUsersByUsername(r.Context(), query, 10)
if err != nil {
errors.LogError(r, err, "Failed to search users")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(users)
}
func createInvitationHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
userIDStr, ok := middleware.GetUserID(r.Context())
if !ok {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
invitedBy, _ := uuid.Parse(userIDStr)
var req struct {
Username string `json:"username"`
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
if req.Role != "admin" && req.Role != "member" {
errors.WriteError(w, errors.CodeInvalidArgument, "Role must be 'admin' or 'member'", http.StatusBadRequest)
return
}
invitation, err := db.CreateInvitation(r.Context(), orgID, invitedBy, req.Username, req.Role)
if err != nil {
if strings.Contains(err.Error(), "duplicate key value") {
errors.WriteError(w, errors.CodeAlreadyExists, "User is already invited or a member", http.StatusConflict)
return
}
errors.LogError(r, err, "Failed to create invitation")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &invitedBy,
OrgID: &orgID,
Action: "create_invitation",
Resource: &req.Username,
Success: true,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(invitation)
}
func listInvitationsHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
invitations, err := db.GetOrgInvitations(r.Context(), orgID)
if err != nil {
errors.LogError(r, err, "Failed to get invitations")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(invitations)
}
func cancelInvitationHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
invitationIDStr := chi.URLParam(r, "invitationId")
invitationID, err := uuid.Parse(invitationIDStr)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid invitation ID", http.StatusBadRequest)
return
}
if err := db.CancelInvitation(r.Context(), invitationID); err != nil {
errors.LogError(r, err, "Failed to cancel invitation")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
resource := invitationID.String()
auditLogger.Log(r.Context(), audit.Entry{
OrgID: &orgID,
Action: "cancel_invitation",
Resource: &resource,
Success: true,
})
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status": "ok"}`))
}
func createJoinRequestHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
var req struct {
OrgID string `json:"orgId"`
InviteToken *string `json:"inviteToken,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
orgID, err := uuid.Parse(req.OrgID)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid org ID", http.StatusBadRequest)
return
}
userIDStr, ok := middleware.GetUserID(r.Context())
if !ok {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
userID, _ := uuid.Parse(userIDStr)
// If invite token provided, validate it
if req.InviteToken != nil {
var token *string
err := db.QueryRowContext(r.Context(), `
SELECT invite_link_token FROM organizations WHERE id = $1
`, orgID).Scan(&token)
if err != nil || token == nil || *token != *req.InviteToken {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid invite token", http.StatusBadRequest)
return
}
}
joinRequest, err := db.CreateJoinRequest(r.Context(), orgID, userID, req.InviteToken)
if err != nil {
errors.LogError(r, err, "Failed to create join request")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
OrgID: &orgID,
Action: "create_join_request",
Success: true,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(joinRequest)
}
type joinRequestResponse struct {
ID string `json:"id"`
OrgID string `json:"orgId"`
UserID string `json:"userId"`
InviteToken *string `json:"inviteToken"`
RequestedAt time.Time `json:"requestedAt"`
Status string `json:"status"`
User userInfo `json:"user"`
}
func listJoinRequestsHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
requests, err := db.GetOrgJoinRequests(r.Context(), orgID)
if err != nil {
errors.LogError(r, err, "Failed to get join requests")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Convert to proper response format
var response []joinRequestResponse
for _, req := range requests {
response = append(response, joinRequestResponse{
ID: req.JoinRequest.ID.String(),
OrgID: req.JoinRequest.OrgID.String(),
UserID: req.JoinRequest.UserID.String(),
InviteToken: req.JoinRequest.InviteToken,
RequestedAt: req.JoinRequest.RequestedAt,
Status: req.JoinRequest.Status,
User: userInfo{
ID: req.User.ID.String(),
Username: req.User.Username,
DisplayName: func() *string {
if req.User.DisplayName == "" {
return nil
}
return &req.User.DisplayName
}(),
Email: req.User.Email,
},
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func acceptJoinRequestHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
requestIDStr := chi.URLParam(r, "requestId")
requestID, err := uuid.Parse(requestIDStr)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid request ID", http.StatusBadRequest)
return
}
var req struct {
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
if req.Role != "admin" && req.Role != "member" {
errors.WriteError(w, errors.CodeInvalidArgument, "Role must be 'admin' or 'member'", http.StatusBadRequest)
return
}
if err := db.AcceptJoinRequest(r.Context(), requestID, req.Role); err != nil {
errors.LogError(r, err, "Failed to accept join request")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
resource := requestID.String()
auditLogger.Log(r.Context(), audit.Entry{
OrgID: &orgID,
Action: "accept_join_request",
Resource: &resource,
Success: true,
})
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status": "ok"}`))
}
func rejectJoinRequestHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
requestIDStr := chi.URLParam(r, "requestId")
requestID, err := uuid.Parse(requestIDStr)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid request ID", http.StatusBadRequest)
return
}
if err := db.RejectJoinRequest(r.Context(), requestID); err != nil {
errors.LogError(r, err, "Failed to reject join request")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
resource := requestID.String()
auditLogger.Log(r.Context(), audit.Entry{
OrgID: &orgID,
Action: "reject_join_request",
Resource: &resource,
Success: true,
})
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status": "ok"}`))
}
func getInviteLinkHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
token, err := db.GetInviteLink(r.Context(), orgID)
if err != nil {
errors.LogError(r, err, "Failed to get invite link")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
response := struct {
InviteLink *string `json:"inviteLink"`
}{
InviteLink: token,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func regenerateInviteLinkHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
newToken, err := db.RegenerateInviteLink(r.Context(), orgID)
if err != nil {
errors.LogError(r, err, "Failed to regenerate invite link")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
OrgID: &orgID,
Action: "regenerate_invite_link",
Success: true,
})
response := struct {
InviteLink string `json:"inviteLink"`
}{
InviteLink: *newToken,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func getPermissionsHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
userIDStr, ok := middleware.GetUserID(r.Context())
if !ok {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
userID, _ := uuid.Parse(userIDStr)
// Check each permission
canRead, _ := permission.HasPermission(r.Context(), db, userID, orgID, permission.FileRead)
canWrite, _ := permission.HasPermission(r.Context(), db, userID, orgID, permission.FileWrite)
canEdit, _ := permission.HasPermission(r.Context(), db, userID, orgID, permission.DocumentEdit)
canAdmin, _ := permission.HasPermission(r.Context(), db, userID, orgID, permission.OrgManage)
response := struct {
CanRead bool `json:"canRead"`
CanWrite bool `json:"canWrite"`
CanShare bool `json:"canShare"`
CanAdmin bool `json:"canAdmin"`
CanAnnotate bool `json:"canAnnotate"`
CanEdit bool `json:"canEdit"`
}{
CanRead: canRead,
CanWrite: canWrite,
CanShare: canRead, // Share is tied to read for now
CanAdmin: canAdmin,
CanAnnotate: canEdit, // Annotate is tied to edit
CanEdit: canEdit,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func fileMetaHandler(w http.ResponseWriter, r *http.Request) {
meta := struct {
LastModified string `json:"lastModified"`
LastModifiedBy string `json:"lastModifiedBy"`
VersionCount int `json:"versionCount"`
}{
LastModified: "2023-01-01T00:00:00Z",
LastModifiedBy: "user@example.com",
VersionCount: 1,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(meta)
}
// Passkey handlers
func signupHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
var req struct {
Username string `json:"username"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
if req.Username == "" || req.Email == "" || req.Password == "" {
errors.WriteError(w, errors.CodeInvalidArgument, "Username, email, and password are required", http.StatusBadRequest)
return
}
// Hash password
passkeyService := auth.NewService(db)
passwordHash, err := passkeyService.HashPassword(req.Password)
if err != nil {
errors.LogError(r, err, "Failed to hash password")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Create user with hashed password
user, err := db.CreateUser(r.Context(), req.Username, req.Email, req.DisplayName, &passwordHash)
if err != nil {
errors.LogError(r, err, "Failed to create user")
if strings.Contains(err.Error(), "duplicate key") {
errors.WriteError(w, errors.CodeConflict, "Username or email already exists", http.StatusConflict)
} else {
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{
"userId": user.ID,
"user": user,
})
}
func registrationChallengeHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
var req struct {
UserID string `json:"userId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
userID, err := uuid.Parse(req.UserID)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest)
return
}
passkeyService := auth.NewService(db)
challenge, err := passkeyService.StartRegistrationChallenge(r.Context(), userID)
if err != nil {
errors.LogError(r, err, "Failed to generate challenge")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"challenge": challenge,
"rp": map[string]string{
"name": auth.RPName,
"id": auth.RPID,
},
"user": map[string]string{
"id": userID.String(),
"name": userID.String(),
},
"pubKeyCredParams": []map[string]interface{}{
{"alg": -7, "type": "public-key"},
{"alg": -257, "type": "public-key"},
},
"timeout": 60000,
"attestation": "direct",
"authenticatorSelection": map[string]interface{}{
"authenticatorAttachment": "platform",
"requireResidentKey": false,
"userVerification": "preferred",
},
})
}
func registrationVerifyHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, auditLogger *audit.Logger) {
var req struct {
UserID string `json:"userId"`
Challenge string `json:"challenge"`
CredentialID string `json:"credentialId"`
PublicKey string `json:"publicKey"`
ClientDataJSON string `json:"clientDataJSON"`
AttestationObject string `json:"attestationObject"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
userID, err := uuid.Parse(req.UserID)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest)
return
}
passkeyService := auth.NewService(db)
_, err = passkeyService.VerifyRegistrationResponse(
r.Context(),
userID,
req.Challenge,
req.CredentialID,
req.PublicKey,
req.ClientDataJSON,
req.AttestationObject,
)
if err != nil {
errors.LogError(r, err, "Failed to verify registration")
errors.WriteError(w, errors.CodeUnauthenticated, "Registration failed: "+err.Error(), http.StatusBadRequest)
return
}
// Create session
session, err := db.CreateSession(r.Context(), userID, time.Now().Add(15*time.Minute))
if err != nil {
errors.LogError(r, err, "Failed to create session")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Get user
user, err := db.GetUserByID(r.Context(), userID)
if err != nil {
errors.LogError(r, err, "Failed to get user")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Generate JWT
orgIDs := []string{}
token, err := jwtManager.Generate(user.ID.String(), orgIDs, session.ID.String())
if err != nil {
errors.LogError(r, err, "Token generation failed")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
Action: "registration",
Success: true,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"token": token,
"user": user,
})
}
func authenticationChallengeHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
var req struct {
Username string `json:"username"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
if req.Username == "" {
errors.WriteError(w, errors.CodeInvalidArgument, "Username is required", http.StatusBadRequest)
return
}
passkeyService := auth.NewService(db)
challenge, credentialIDs, err := passkeyService.StartAuthenticationChallenge(r.Context(), req.Username)
if err != nil {
errors.LogError(r, err, "Failed to generate challenge")
errors.WriteError(w, errors.CodeNotFound, "User not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"challenge": challenge,
"timeout": 60000,
"userVerification": "preferred",
"allowCredentials": credentialIDs,
})
}
func authenticationVerifyHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, auditLogger *audit.Logger) {
var req struct {
Username string `json:"username"`
Challenge string `json:"challenge"`
CredentialID string `json:"credentialId"`
AuthenticatorData string `json:"authenticatorData"`
ClientDataJSON string `json:"clientDataJSON"`
Signature string `json:"signature"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
passkeyService := auth.NewService(db)
user, err := passkeyService.VerifyAuthenticationResponse(
r.Context(),
req.Username,
req.Challenge,
req.CredentialID,
req.AuthenticatorData,
req.ClientDataJSON,
req.Signature,
)
if err != nil {
errors.LogError(r, err, "Failed to verify authentication")
errors.WriteError(w, errors.CodeUnauthenticated, "Authentication failed: "+err.Error(), http.StatusBadRequest)
return
}
// Create session
session, err := db.CreateSession(r.Context(), user.ID, time.Now().Add(15*time.Minute))
if err != nil {
errors.LogError(r, err, "Failed to create session")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Get user orgs
orgs, err := db.GetUserOrganizations(r.Context(), user.ID)
if err != nil {
errors.LogError(r, err, "Failed to get user orgs")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
orgIDs := make([]string, len(orgs))
for i, o := range orgs {
orgIDs[i] = o.ID.String()
}
// Generate JWT
token, err := jwtManager.Generate(user.ID.String(), orgIDs, session.ID.String())
if err != nil {
errors.LogError(r, err, "Token generation failed")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &user.ID,
Action: "login",
Success: true,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"token": token,
"user": user,
})
}
func passwordLoginHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, auditLogger *audit.Logger) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
if req.Username == "" || req.Password == "" {
errors.WriteError(w, errors.CodeInvalidArgument, "Username and password are required", http.StatusBadRequest)
return
}
// Verify password
passkeyService := auth.NewService(db)
user, err := passkeyService.VerifyPasswordLogin(r.Context(), req.Username, req.Password)
if err != nil {
auditLogger.Log(r.Context(), audit.Entry{
Action: "login",
Success: false,
Metadata: map[string]interface{}{"error": err.Error()},
})
errors.LogError(r, err, "Password login failed")
// Map internal errors to more specific API error responses so the client
// can indicate which field was incorrect.
errMsg := err.Error()
if strings.Contains(errMsg, "invalid password") {
errors.WriteError(w, errors.CodeInvalidPassword, "Incorrect password", http.StatusUnauthorized)
return
}
if strings.Contains(errMsg, "user not found") {
errors.WriteError(w, errors.CodeInvalidCredentials, "Invalid credentials", http.StatusUnauthorized)
return
}
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid credentials", http.StatusUnauthorized)
return
}
// Create session
session, err := db.CreateSession(r.Context(), user.ID, time.Now().Add(15*time.Minute))
if err != nil {
errors.LogError(r, err, "Failed to create session")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Get user orgs
orgs, err := db.GetUserOrganizations(r.Context(), user.ID)
if err != nil {
errors.LogError(r, err, "Failed to get user orgs")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
orgIDs := make([]string, len(orgs))
for i, o := range orgs {
orgIDs[i] = o.ID.String()
}
// Generate JWT
token, err := jwtManager.Generate(user.ID.String(), orgIDs, session.ID.String())
if err != nil {
errors.LogError(r, err, "Token generation failed")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &user.ID,
Action: "login",
Success: true,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"token": token,
"user": user,
})
}
// userFilesHandler returns files for the authenticated user's personal workspace.
func userFilesHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
userIDStr, ok := middleware.GetUserID(r.Context())
if !ok || userIDStr == "" {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
userID, err := uuid.Parse(userIDStr)
if err != nil {
errors.LogError(r, err, "Invalid user id in context")
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
path := r.URL.Query().Get("path")
if path == "" {
path = "/"
}
q := r.URL.Query().Get("q")
page := 1
pageSize := 100
if p := r.URL.Query().Get("page"); p != "" {
fmt.Sscanf(p, "%d", &page)
}
if ps := r.URL.Query().Get("pageSize"); ps != "" {
fmt.Sscanf(ps, "%d", &pageSize)
}
files, err := db.GetUserFiles(r.Context(), userID, path, q, page, pageSize)
if err != nil {
errors.LogError(r, err, "Failed to get user files")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
out := make([]map[string]interface{}, 0, len(files))
for _, f := range files {
out = append(out, map[string]interface{}{
"id": f.ID.String(),
"name": f.Name,
"path": f.Path,
"type": f.Type,
"size": f.Size,
"lastModified": f.LastModified.UTC().Format(time.RFC3339),
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(out)
}
// createOrgFileHandler creates a file or folder record for an org workspace.
func createOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, cfg *config.Config) {
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
userIDStr, _ := middleware.GetUserID(r.Context())
userID, _ := uuid.Parse(userIDStr)
var f *database.File
var err error
// Support multipart uploads (field "file") or JSON metadata for folders
contentType := r.Header.Get("Content-Type")
if strings.HasPrefix(contentType, "multipart/form-data") {
// Handle file upload
if err = r.ParseMultipartForm(32 << 20); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad multipart request", http.StatusBadRequest)
return
}
parentPath := r.FormValue("path")
if parentPath == "" {
parentPath = "/"
}
parentPath, err = sanitizePath(parentPath)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid path", http.StatusBadRequest)
return
}
var file multipart.File
var header *multipart.FileHeader
file, header, err = r.FormFile("file")
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Missing file", http.StatusBadRequest)
return
}
defer file.Close()
// Read file into memory
data, err := io.ReadAll(file)
if err != nil {
errors.LogError(r, err, "Failed to read uploaded file")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
storedPath := filepath.ToSlash(filepath.Join(parentPath, header.Filename))
if !strings.HasPrefix(storedPath, "/") {
storedPath = "/" + storedPath
}
written := int64(len(data))
// Get or create user's WebDAV client
storageClient, err := getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
if err != nil {
errors.LogError(r, err, "Failed to get user WebDAV client")
errors.WriteError(w, errors.CodeInternal, "Storage not configured", http.StatusInternalServerError)
return
}
// Upload to user's Nextcloud space under /orgs/<orgID>/
rel := strings.TrimPrefix(storedPath, "/")
remotePath := path.Join("/orgs", orgID.String(), rel)
if err = storageClient.Upload(r.Context(), remotePath, bytes.NewReader(data), int64(len(data))); err != nil {
errors.LogError(r, err, "WebDAV upload failed")
errors.WriteError(w, errors.CodeInternal, "Failed to upload file to storage", http.StatusInternalServerError)
return
}
f, err = db.CreateFile(r.Context(), &orgID, &userID, header.Filename, storedPath, "file", written)
if err != nil {
errors.LogError(r, err, "Failed to create org file")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
OrgID: &orgID,
Action: "upload_file",
Success: true,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"id": f.ID})
return
}
var req struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"` // file|folder
Size int64 `json:"size"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
f, err = db.CreateFile(r.Context(), &orgID, &userID, req.Name, req.Path, req.Type, req.Size)
if err != nil {
errors.LogError(r, err, "Failed to create org file")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
OrgID: &orgID,
Action: "create_file",
Success: true,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"id": f.ID})
}
// deleteOrgFileHandler deletes a file/folder in org workspace by path
func deleteOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, cfg *config.Config) {
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
userIDStr, _ := middleware.GetUserID(r.Context())
userID, _ := uuid.Parse(userIDStr)
var req struct {
Path string `json:"path"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
var err error
req.Path, err = sanitizePath(req.Path)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid path", http.StatusBadRequest)
return
}
// Get or create user's WebDAV client and delete from Nextcloud
storageClient, err := getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
if err != nil {
errors.LogError(r, err, "Failed to get user WebDAV client (continuing with database deletion)")
} else {
rel := strings.TrimPrefix(req.Path, "/")
remotePath := path.Join("/orgs", orgID.String(), rel)
if err := storageClient.Delete(r.Context(), remotePath); err != nil {
errors.LogError(r, err, "Failed to delete from Nextcloud (continuing anyway)")
}
}
// Delete from database
if err := db.DeleteFileByPath(r.Context(), &orgID, nil, req.Path); err != nil {
errors.LogError(r, err, "Failed to delete org file")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
OrgID: &orgID,
Action: "delete_file",
Success: true,
})
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}
// Also accept POST /orgs/{orgId}/files/delete for clients that cannot send DELETE with body
func deleteOrgFilePostHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, cfg *config.Config) {
deleteOrgFileHandler(w, r, db, auditLogger, cfg)
}
// moveOrgFileHandler moves/renames a file in org workspace
func moveOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, cfg *config.Config) {
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
userIDStr, _ := middleware.GetUserID(r.Context())
userID, _ := uuid.Parse(userIDStr)
var req struct {
SourcePath string `json:"sourcePath"`
TargetPath string `json:"targetPath"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
var err error
req.SourcePath, err = sanitizePath(req.SourcePath)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid source path", http.StatusBadRequest)
return
}
req.TargetPath, err = sanitizePath(req.TargetPath)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid target path", http.StatusBadRequest)
return
}
// Get source file directly by path
sourceFile, err := db.GetOrgFileByPath(r.Context(), orgID, userID, req.SourcePath)
if err != nil {
if err == sql.ErrNoRows {
errors.WriteError(w, errors.CodeInvalidArgument, "Source file not found", http.StatusNotFound)
return
}
errors.LogError(r, err, "Failed to get source file")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Prevent moving a folder into itself or its own subfolders
if sourceFile.Type == "folder" {
// Check if trying to move into itself
if req.SourcePath == req.TargetPath {
errors.WriteError(w, errors.CodeInvalidArgument, "Cannot move a folder into itself", http.StatusBadRequest)
return
}
// Check if target is a subfolder of source
if strings.HasPrefix(req.TargetPath, req.SourcePath+"/") {
errors.WriteError(w, errors.CodeInvalidArgument, "Cannot move a folder into its own subfolder", http.StatusBadRequest)
return
}
}
// Determine new file name - check if target is a folder
var newPath string
targetFile, err := db.GetOrgFileByPath(r.Context(), orgID, userID, req.TargetPath)
if err != nil && err != sql.ErrNoRows {
errors.LogError(r, err, "Failed to get target file")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// If err == sql.ErrNoRows, targetFile is nil
if targetFile != nil && targetFile.Type == "folder" {
// Target is a folder, move file into it
newPath = path.Join(req.TargetPath, sourceFile.Name)
} else if targetFile == nil && strings.HasSuffix(req.TargetPath, "/") {
// Target path doesn't exist but ends with /, treat as folder
newPath = path.Join(req.TargetPath, sourceFile.Name)
} else {
// Moving/renaming to a specific path
newPath = req.TargetPath
}
// Determine new filename from the path
newName := path.Base(newPath)
// Get or create user's WebDAV client and move in Nextcloud
storageClient, err := getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
if err != nil {
errors.LogError(r, err, "Failed to get user WebDAV client")
errors.WriteError(w, errors.CodeInternal, "Storage error", http.StatusInternalServerError)
return
}
sourceRel := strings.TrimPrefix(req.SourcePath, "/")
sourcePath := path.Join("/orgs", orgID.String(), sourceRel)
targetRel := strings.TrimPrefix(newPath, "/")
targetPath := path.Join("/orgs", orgID.String(), targetRel)
if err := storageClient.Move(r.Context(), sourcePath, targetPath); err != nil {
errors.LogError(r, err, "Failed to move in Nextcloud")
errors.WriteError(w, errors.CodeInternal, "Failed to move file in storage", http.StatusInternalServerError)
return
}
// Update file record path and name in-place (preserves file ID for WOPI sessions)
if err := db.UpdateFilePath(r.Context(), sourceFile.ID, newName, newPath); err != nil {
errors.LogError(r, err, "Failed to update file path")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
OrgID: &orgID,
Action: "move_file",
Success: true,
})
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}
// createUserFileHandler creates a file or folder record for the authenticated user's personal workspace.
func createUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, cfg *config.Config) {
userIDStr, ok := middleware.GetUserID(r.Context())
if !ok || userIDStr == "" {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
userID, _ := uuid.Parse(userIDStr)
var f *database.File
var err error
// Support multipart uploads for file content or JSON for folders
contentType := r.Header.Get("Content-Type")
if strings.HasPrefix(contentType, "multipart/form-data") {
if err = r.ParseMultipartForm(32 << 20); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad multipart request", http.StatusBadRequest)
return
}
parentPath := r.FormValue("path")
if parentPath == "" {
parentPath = "/"
}
parentPath, err = sanitizePath(parentPath)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid path", http.StatusBadRequest)
return
}
var file multipart.File
var header *multipart.FileHeader
file, header, err = r.FormFile("file")
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Missing file", http.StatusBadRequest)
return
}
defer file.Close()
// Read file into memory to allow WebDAV upload
data, err := io.ReadAll(file)
if err != nil {
errors.LogError(r, err, "Failed to read uploaded file")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
storedPath := filepath.ToSlash(filepath.Join(parentPath, header.Filename))
if !strings.HasPrefix(storedPath, "/") {
storedPath = "/" + storedPath
}
written := int64(len(data))
fmt.Printf("[DEBUG] Upload: user=%s, file=%s, size=%d, path=%s\n", userID.String(), header.Filename, len(data), storedPath)
// Get or create user's WebDAV client
storageClient, err := getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
if err != nil {
errors.LogError(r, err, "Failed to get user WebDAV client")
errors.WriteError(w, errors.CodeInternal, "Storage not configured", http.StatusInternalServerError)
return
}
// Upload to user's personal Nextcloud space (just the path, no username prefix)
remotePath := strings.TrimPrefix(storedPath, "/")
fmt.Printf("[DEBUG] Uploading to user WebDAV: /%s\n", remotePath)
if err = storageClient.Upload(r.Context(), "/"+remotePath, bytes.NewReader(data), int64(len(data))); err != nil {
errors.LogError(r, err, "WebDAV upload failed")
errors.WriteError(w, errors.CodeInternal, "Failed to upload file to storage", http.StatusInternalServerError)
return
}
f, err = db.CreateFile(r.Context(), nil, &userID, header.Filename, storedPath, "file", written)
if err != nil {
errors.LogError(r, err, "Failed to create user file")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
Action: "upload_user_file",
Success: true,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"id": f.ID})
return
}
var req struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"` // file|folder
Size int64 `json:"size"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
f, err = db.CreateFile(r.Context(), nil, &userID, req.Name, req.Path, req.Type, req.Size)
if err != nil {
errors.LogError(r, err, "Failed to create user file")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
Action: "create_user_file",
Success: true,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"id": f.ID})
}
// Also accept POST /user/files/delete
func deleteUserFilePostHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, cfg *config.Config) {
deleteUserFileHandler(w, r, db, auditLogger, cfg)
}
// moveUserFileHandler moves/renames a file in user's personal workspace
func moveUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, cfg *config.Config) {
userIDStr, ok := middleware.GetUserID(r.Context())
if !ok || userIDStr == "" {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
userID, _ := uuid.Parse(userIDStr)
var req struct {
SourcePath string `json:"sourcePath"`
TargetPath string `json:"targetPath"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
var err error
req.SourcePath, err = sanitizePath(req.SourcePath)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid source path", http.StatusBadRequest)
return
}
req.TargetPath, err = sanitizePath(req.TargetPath)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid target path", http.StatusBadRequest)
return
}
// Get source file details before moving
sourceFile, err := db.GetUserFileByPath(r.Context(), userID, req.SourcePath)
if err != nil {
if err == sql.ErrNoRows {
errors.WriteError(w, errors.CodeInvalidArgument, "Source file not found", http.StatusNotFound)
return
}
errors.LogError(r, err, "Failed to get source file")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Prevent moving a folder into itself or its own subfolders
if sourceFile.Type == "folder" {
// Check if trying to move into itself
if req.SourcePath == req.TargetPath {
errors.WriteError(w, errors.CodeInvalidArgument, "Cannot move a folder into itself", http.StatusBadRequest)
return
}
// Check if target is a subfolder of source
if strings.HasPrefix(req.TargetPath, req.SourcePath+"/") {
errors.WriteError(w, errors.CodeInvalidArgument, "Cannot move a folder into its own subfolder", http.StatusBadRequest)
return
}
}
// Determine new file name - check if target is a folder
var newPath string
targetFile, err := db.GetUserFileByPath(r.Context(), userID, req.TargetPath)
if err != nil && err != sql.ErrNoRows {
errors.LogError(r, err, "Failed to get target file")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// If err == sql.ErrNoRows, targetFile is nil
if targetFile != nil && targetFile.Type == "folder" {
// Target is a folder, move file into it
newPath = path.Join(req.TargetPath, sourceFile.Name)
} else if targetFile == nil && strings.HasSuffix(req.TargetPath, "/") {
// Target path doesn't exist but ends with /, treat as folder
newPath = path.Join(req.TargetPath, sourceFile.Name)
} else {
// Moving/renaming to a specific path
newPath = req.TargetPath
}
// Determine new filename from the path
newName := path.Base(newPath)
// Get or create user's WebDAV client and move in Nextcloud
storageClient, err := getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
if err != nil {
errors.LogError(r, err, "Failed to get user WebDAV client")
errors.WriteError(w, errors.CodeInternal, "Storage error", http.StatusInternalServerError)
return
}
// User files are stored directly in the user's WebDAV root (no /users/{id} prefix)
sourcePath := "/" + strings.TrimPrefix(req.SourcePath, "/")
targetPath := "/" + strings.TrimPrefix(newPath, "/")
if err := storageClient.Move(r.Context(), sourcePath, targetPath); err != nil {
errors.LogError(r, err, "Failed to move in Nextcloud")
errors.WriteError(w, errors.CodeInternal, "Failed to move file in storage", http.StatusInternalServerError)
return
}
// Update file record path and name in-place (preserves file ID for WOPI sessions)
if err := db.UpdateFilePath(r.Context(), sourceFile.ID, newName, newPath); err != nil {
errors.LogError(r, err, "Failed to update file path")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
Action: "move_file",
Success: true,
})
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}
// deleteUserFileHandler deletes a file/folder in user's personal workspace by path
func deleteUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, cfg *config.Config) {
userIDStr, ok := middleware.GetUserID(r.Context())
if !ok || userIDStr == "" {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
userID, _ := uuid.Parse(userIDStr)
var req struct {
Path string `json:"path"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
var err error
req.Path, err = sanitizePath(req.Path)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid path", http.StatusBadRequest)
return
}
// Get or create user's WebDAV client and delete from Nextcloud
storageClient, err := getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
if err != nil {
errors.LogError(r, err, "Failed to get user WebDAV client (continuing with database deletion)")
} else {
remotePath := strings.TrimPrefix(req.Path, "/")
if err := storageClient.Delete(r.Context(), "/"+remotePath); err != nil {
errors.LogError(r, err, "Failed to delete from Nextcloud (continuing anyway)")
}
}
// Delete from database
if err := db.DeleteFileByPath(r.Context(), nil, &userID, req.Path); err != nil {
errors.LogError(r, err, "Failed to delete user file")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
Action: "delete_user_file",
Success: true,
})
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}
// downloadOrgFileHandler downloads a file from org workspace
func downloadOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, cfg *config.Config) {
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
userIDStr, _ := middleware.GetUserID(r.Context())
userID, _ := uuid.Parse(userIDStr)
filePath := r.URL.Query().Get("path")
if filePath == "" {
errors.WriteError(w, errors.CodeInvalidArgument, "Missing path parameter", http.StatusBadRequest)
return
}
// Sanitize path to prevent path traversal
filePath, err := sanitizePath(filePath)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid path", http.StatusBadRequest)
return
}
// Get or create user's WebDAV client
storageClient, err := getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
if err != nil {
errors.LogError(r, err, "Failed to get user WebDAV client")
errors.WriteError(w, errors.CodeInternal, "Storage not configured", http.StatusInternalServerError)
return
}
// Check if it's a folder
file, err := db.GetOrgFileByPath(r.Context(), orgID, userID, filePath)
if err != nil && err.Error() != "sql: no rows in result set" {
errors.LogError(r, err, "Failed to get file info")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
if file != nil && file.Type == "folder" {
// Download folder as ZIP
err = downloadOrgFolderAsZip(w, r, db, cfg, orgID, userID, filePath, storageClient)
if err != nil {
errors.LogError(r, err, "Failed to download folder")
errors.WriteError(w, errors.CodeInternal, "Failed to download folder", http.StatusInternalServerError)
}
return
}
// Download from user's Nextcloud space under /orgs/<orgID>/
rel := strings.TrimPrefix(filePath, "/")
remotePath := path.Join("/orgs", orgID.String(), rel)
resp, err := storageClient.Download(r.Context(), remotePath, r.Header.Get("Range"))
if err != nil {
errors.LogError(r, err, "Failed to download from Nextcloud")
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
defer resp.Body.Close()
// Set appropriate headers for inline viewing
fileName := path.Base(filePath)
// Determine content type based on file extension - use our getMimeType function
// which supports all common video/audio/image formats
contentType := getMimeType(fileName)
w.Header().Set("Access-Control-Allow-Origin", "https://www.b0esche.cloud")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Range")
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", fileName))
w.Header().Set("Content-Type", contentType)
w.Header().Set("Accept-Ranges", "bytes")
if cr := resp.Header.Get("Content-Range"); cr != "" {
w.Header().Set("Content-Range", cr)
}
if cl := resp.Header.Get("Content-Length"); cl != "" {
w.Header().Set("Content-Length", cl)
}
if resp.StatusCode == http.StatusPartialContent {
w.WriteHeader(http.StatusPartialContent)
}
// Stream the file
io.Copy(w, resp.Body)
}
// downloadOrgFolderAsZip downloads a folder as ZIP archive
func downloadOrgFolderAsZip(w http.ResponseWriter, r *http.Request, db *database.DB, cfg *config.Config, orgID, userID uuid.UUID, folderPath string, storageClient *storage.WebDAVClient) error {
// Get all files under the folder
files, err := db.GetAllOrgFilesUnderPath(r.Context(), orgID, userID, folderPath)
if err != nil {
return err
}
// Filter only files, not folders
var fileList []database.File
for _, f := range files {
if f.Type == "file" {
fileList = append(fileList, f)
}
}
// Set headers for ZIP download
folderName := path.Base(folderPath)
if folderName == "" || folderName == "/" {
folderName = "org_files"
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.zip\"", folderName))
// Create ZIP writer
zipWriter := zip.NewWriter(w)
defer zipWriter.Close()
// Ensure folderPath ends with / for proper relative path calculation
if !strings.HasSuffix(folderPath, "/") {
folderPath += "/"
}
// Add each file to ZIP
for _, file := range fileList {
// Calculate relative path in ZIP
relPath := strings.TrimPrefix(file.Path, folderPath)
// Download file from WebDAV
remoteRel := strings.TrimPrefix(file.Path, "/")
remotePath := path.Join("/orgs", orgID.String(), remoteRel)
resp, err := storageClient.Download(r.Context(), remotePath, "")
if err != nil {
continue // Skip files that can't be downloaded
}
defer resp.Body.Close()
// Create ZIP entry
zipFile, err := zipWriter.Create(relPath)
if err != nil {
continue
}
// Copy file content to ZIP
io.Copy(zipFile, resp.Body)
}
return nil
}
// downloadUserFileHandler downloads a file from user's personal workspace
func downloadUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, cfg *config.Config) {
// Try to get userID from context (Bearer token), fallback to query parameter
userIDStr, ok := middleware.GetUserID(r.Context())
if !ok || userIDStr == "" {
// Token might be in query parameter for PDF viewer compatibility
// This is acceptable since the token is still validated
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
userID, _ := uuid.Parse(userIDStr)
filePath := r.URL.Query().Get("path")
if filePath == "" {
errors.WriteError(w, errors.CodeInvalidArgument, "Missing path parameter", http.StatusBadRequest)
return
}
// Sanitize path to prevent path traversal
filePath, err := sanitizePath(filePath)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid path", http.StatusBadRequest)
return
}
// Get or create user's WebDAV client
storageClient, err := getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
if err != nil {
errors.LogError(r, err, "Failed to get user WebDAV client")
errors.WriteError(w, errors.CodeInternal, "Storage not configured", http.StatusInternalServerError)
return
}
// Check if it's a folder
file, err := db.GetUserFileByPath(r.Context(), userID, filePath)
if err != nil && err.Error() != "sql: no rows in result set" {
errors.LogError(r, err, "Failed to get file info")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
if file != nil && file.Type == "folder" {
// Download folder as ZIP
err = downloadUserFolderAsZip(w, r, db, cfg, userID, filePath, storageClient)
if err != nil {
errors.LogError(r, err, "Failed to download folder")
errors.WriteError(w, errors.CodeInternal, "Failed to download folder", http.StatusInternalServerError)
}
return
}
// Download from user's personal Nextcloud space
remotePath := strings.TrimPrefix(filePath, "/")
resp, err := storageClient.Download(r.Context(), "/"+remotePath, r.Header.Get("Range"))
if err != nil {
errors.LogError(r, err, "Failed to download from Nextcloud")
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
defer resp.Body.Close()
// Set appropriate headers for inline viewing
fileName := path.Base(filePath)
// Determine content type based on file extension - use our getMimeType function
// which supports all common video/audio/image formats
contentType := getMimeType(fileName)
w.Header().Set("Access-Control-Allow-Origin", "https://www.b0esche.cloud")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Range")
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", fileName))
w.Header().Set("Content-Type", contentType)
w.Header().Set("Accept-Ranges", "bytes")
if cr := resp.Header.Get("Content-Range"); cr != "" {
w.Header().Set("Content-Range", cr)
}
if cl := resp.Header.Get("Content-Length"); cl != "" {
w.Header().Set("Content-Length", cl)
}
if resp.StatusCode == http.StatusPartialContent {
w.WriteHeader(http.StatusPartialContent)
}
// Stream the file
io.Copy(w, resp.Body)
}
// downloadUserFolderAsZip downloads a folder as ZIP archive
func downloadUserFolderAsZip(w http.ResponseWriter, r *http.Request, db *database.DB, cfg *config.Config, userID uuid.UUID, folderPath string, storageClient *storage.WebDAVClient) error {
// Get all files under the folder
files, err := db.GetAllUserFilesUnderPath(r.Context(), userID, folderPath)
if err != nil {
return err
}
// Filter only files, not folders
var fileList []database.File
for _, f := range files {
if f.Type == "file" {
fileList = append(fileList, f)
}
}
// Set headers for ZIP download
folderName := path.Base(folderPath)
if folderName == "" || folderName == "/" {
folderName = "user_files"
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.zip\"", folderName))
// Create ZIP writer
zipWriter := zip.NewWriter(w)
defer zipWriter.Close()
// Ensure folderPath ends with / for proper relative path calculation
if !strings.HasSuffix(folderPath, "/") {
folderPath += "/"
}
// Add each file to ZIP
for _, file := range fileList {
// Calculate relative path in ZIP
relPath := strings.TrimPrefix(file.Path, folderPath)
// Download file from WebDAV
remotePath := strings.TrimPrefix(file.Path, "/")
resp, err := storageClient.Download(r.Context(), "/"+remotePath, "")
if err != nil {
continue // Skip files that can't be downloaded
}
defer resp.Body.Close()
// Create ZIP entry
zipFile, err := zipWriter.Create(relPath)
if err != nil {
continue
}
// Copy file content to ZIP
io.Copy(zipFile, resp.Body)
}
return nil
}
// getMimeType returns the MIME type based on file extension
func getMimeType(filename string) string {
lower := strings.ToLower(filename)
switch {
// Documents
case strings.HasSuffix(lower, ".pdf"):
return "application/pdf"
case strings.HasSuffix(lower, ".doc"), strings.HasSuffix(lower, ".docx"):
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
case strings.HasSuffix(lower, ".xls"), strings.HasSuffix(lower, ".xlsx"):
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
case strings.HasSuffix(lower, ".ppt"), strings.HasSuffix(lower, ".pptx"):
return "application/vnd.openxmlformats-officedocument.presentationml.presentation"
case strings.HasSuffix(lower, ".odt"):
return "application/vnd.oasis.opendocument.text"
case strings.HasSuffix(lower, ".ods"):
return "application/vnd.oasis.opendocument.spreadsheet"
case strings.HasSuffix(lower, ".odp"):
return "application/vnd.oasis.opendocument.presentation"
// Images
case strings.HasSuffix(lower, ".png"):
return "image/png"
case strings.HasSuffix(lower, ".jpg"), strings.HasSuffix(lower, ".jpeg"):
return "image/jpeg"
case strings.HasSuffix(lower, ".gif"):
return "image/gif"
case strings.HasSuffix(lower, ".webp"):
return "image/webp"
case strings.HasSuffix(lower, ".svg"):
return "image/svg+xml"
case strings.HasSuffix(lower, ".bmp"):
return "image/bmp"
case strings.HasSuffix(lower, ".ico"):
return "image/x-icon"
// Video formats
case strings.HasSuffix(lower, ".mp4"), strings.HasSuffix(lower, ".m4v"):
return "video/mp4"
case strings.HasSuffix(lower, ".webm"):
return "video/webm"
case strings.HasSuffix(lower, ".ogv"):
return "video/ogg"
case strings.HasSuffix(lower, ".mov"):
return "video/quicktime"
case strings.HasSuffix(lower, ".avi"):
return "video/x-msvideo"
case strings.HasSuffix(lower, ".mkv"):
return "video/x-matroska"
case strings.HasSuffix(lower, ".wmv"):
return "video/x-ms-wmv"
case strings.HasSuffix(lower, ".flv"):
return "video/x-flv"
case strings.HasSuffix(lower, ".3gp"):
return "video/3gpp"
case strings.HasSuffix(lower, ".ts"):
return "video/mp2t"
case strings.HasSuffix(lower, ".mpg"), strings.HasSuffix(lower, ".mpeg"):
return "video/mpeg"
// Audio formats
case strings.HasSuffix(lower, ".mp3"):
return "audio/mpeg"
case strings.HasSuffix(lower, ".wav"):
return "audio/wav"
case strings.HasSuffix(lower, ".ogg"), strings.HasSuffix(lower, ".oga"):
return "audio/ogg"
case strings.HasSuffix(lower, ".m4a"), strings.HasSuffix(lower, ".aac"):
return "audio/aac"
case strings.HasSuffix(lower, ".flac"):
return "audio/flac"
case strings.HasSuffix(lower, ".wma"):
return "audio/x-ms-wma"
// Text/code
case strings.HasSuffix(lower, ".txt"):
return "text/plain"
case strings.HasSuffix(lower, ".html"), strings.HasSuffix(lower, ".htm"):
return "text/html"
case strings.HasSuffix(lower, ".css"):
return "text/css"
case strings.HasSuffix(lower, ".js"):
return "application/javascript"
case strings.HasSuffix(lower, ".json"):
return "application/json"
case strings.HasSuffix(lower, ".xml"):
return "application/xml"
case strings.HasSuffix(lower, ".csv"):
return "text/csv"
// Archives
case strings.HasSuffix(lower, ".zip"):
return "application/zip"
case strings.HasSuffix(lower, ".rar"):
return "application/vnd.rar"
case strings.HasSuffix(lower, ".7z"):
return "application/x-7z-compressed"
case strings.HasSuffix(lower, ".tar"):
return "application/x-tar"
case strings.HasSuffix(lower, ".gz"):
return "application/gzip"
default:
return "application/octet-stream"
}
}
// File share handlers
func getFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
userIDStr, _ := middleware.GetUserID(r.Context())
userID, _ := uuid.Parse(userIDStr)
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
fileId := chi.URLParam(r, "fileId")
fileUUID, err := uuid.Parse(fileId)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid file ID", http.StatusBadRequest)
return
}
// Check if file exists and belongs to org or is owned by user (for personal files)
file, err := db.GetFileByID(r.Context(), fileUUID)
if err != nil {
errors.LogError(r, err, "Failed to get file")
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
if file.OrgID != nil && *file.OrgID != orgID {
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
if file.OrgID == nil && file.UserID != nil && *file.UserID != userID {
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
link, err := db.GetFileShareLinkByFileID(r.Context(), fileUUID)
if err != nil {
if err == sql.ErrNoRows {
// No share link exists
errors.WriteError(w, errors.CodeNotFound, "Share link not found", http.StatusNotFound)
return
}
errors.LogError(r, err, "Failed to get share link")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Build full URL
scheme := "https"
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
scheme = proto
} else if r.TLS == nil {
scheme = "http"
}
host := "www.b0esche.cloud"
fullURL := fmt.Sprintf("%s://%s/share/%s", scheme, host, link.Token)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"shareUrl": fullURL,
"token": link.Token,
})
}
func createFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
userIDStr, _ := middleware.GetUserID(r.Context())
userID, _ := uuid.Parse(userIDStr)
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
fileId := chi.URLParam(r, "fileId")
fileUUID, err := uuid.Parse(fileId)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid file ID", http.StatusBadRequest)
return
}
// Check if file exists and belongs to org or is owned by user (for personal files)
file, err := db.GetFileByID(r.Context(), fileUUID)
if err != nil {
errors.LogError(r, err, "Failed to get file")
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
if file.OrgID != nil && *file.OrgID != orgID {
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
if file.OrgID == nil && file.UserID != nil && *file.UserID != userID {
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
// Revoke existing link if any
db.RevokeFileShareLink(r.Context(), fileUUID) // Ignore error
// Generate token
token, err := storage.GenerateSecurePassword(48)
if err != nil {
errors.LogError(r, err, "Failed to generate token")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
link, err := db.CreateFileShareLink(r.Context(), token, fileUUID, &orgID, userID)
if err != nil {
errors.LogError(r, err, "Failed to create share link")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
log.Printf("Share link created: user_id=%s, file_id=%s, org_id=%v", userID, fileUUID, link.OrgID)
// Build full URL
scheme := "https"
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
scheme = proto
} else if r.TLS == nil {
scheme = "http"
}
host := "www.b0esche.cloud"
fullURL := fmt.Sprintf("%s://%s/share/%s", scheme, host, link.Token)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"shareUrl": fullURL,
"token": link.Token,
})
}
func revokeFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
userIDStr, _ := middleware.GetUserID(r.Context())
userID, _ := uuid.Parse(userIDStr)
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
fileId := chi.URLParam(r, "fileId")
fileUUID, err := uuid.Parse(fileId)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid file ID", http.StatusBadRequest)
return
}
// Check if file exists and belongs to org or is owned by user (for personal files)
file, err := db.GetFileByID(r.Context(), fileUUID)
if err != nil {
errors.LogError(r, err, "Failed to get file")
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
if file.OrgID != nil && *file.OrgID != orgID {
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
if file.OrgID == nil && file.UserID != nil && *file.UserID != userID {
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
err = db.RevokeFileShareLink(r.Context(), fileUUID)
if err != nil {
errors.LogError(r, err, "Failed to revoke share link")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func publicFileShareHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager) {
token := chi.URLParam(r, "token")
if token == "" {
errors.WriteError(w, errors.CodeInvalidArgument, "Token required", http.StatusBadRequest)
return
}
link, err := db.GetFileShareLinkByToken(r.Context(), token)
if err != nil {
if err == sql.ErrNoRows {
errors.WriteError(w, errors.CodeNotFound, "Link not found or expired", http.StatusNotFound)
return
}
errors.LogError(r, err, "Failed to get share link")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Get file metadata
file, err := db.GetFileByID(r.Context(), link.FileID)
if err != nil {
errors.LogError(r, err, "Failed to get file")
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
// Generate a short-lived token for download (1 hour)
var orgIDs []string
if link.OrgID != nil {
orgIDs = []string{link.OrgID.String()}
}
viewerToken, err := jwtManager.GenerateWithDuration("", orgIDs, "", time.Hour)
if err != nil {
errors.LogError(r, err, "Failed to generate viewer token")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Build URLs
scheme := "https"
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
scheme = proto
} else if r.TLS == nil {
scheme = "http"
}
host := r.Host
if host == "" {
host = "go.b0esche.cloud"
}
downloadPath := fmt.Sprintf("%s://%s/public/share/%s/download?token=%s", scheme, host, token, url.QueryEscape(viewerToken))
viewPath := fmt.Sprintf("%s://%s/public/share/%s/view?token=%s", scheme, host, token, url.QueryEscape(viewerToken))
// Check if user is authenticated and has access to the file
var internalOrgId *string
var internalFileId *string
authHeader := r.Header.Get("Authorization")
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
jwtToken := strings.TrimPrefix(authHeader, "Bearer ")
claims, err := jwtManager.Validate(jwtToken)
if err == nil {
userID, err := uuid.Parse(claims.Subject)
if err == nil {
// Check if user has access
if file.UserID != nil && *file.UserID == userID {
// Personal file, user is owner
fileIDStr := file.ID.String()
internalOrgId = nil // personal
internalFileId = &fileIDStr
} else if link.OrgID != nil {
// Org file, check if user is in org
for _, orgIDStr := range claims.OrgIDs {
orgID, err := uuid.Parse(orgIDStr)
if err == nil && orgID == *link.OrgID {
fileIDStr := file.ID.String()
internalOrgId = &orgIDStr
internalFileId = &fileIDStr
break
}
}
}
}
}
}
// Determine file type
isPdf := strings.HasSuffix(strings.ToLower(file.Name), ".pdf")
mimeType := getMimeType(file.Name)
viewerSession := map[string]interface{}{
"fileName": file.Name,
"fileSize": file.Size,
"downloadUrl": downloadPath,
"token": viewerToken,
"capabilities": map[string]interface{}{
"canEdit": false,
"canAnnotate": false,
"isPdf": isPdf,
"mimeType": mimeType,
},
}
// Set view URL for PDFs, videos, audio, and documents (for inline viewing)
if isPdf || strings.HasPrefix(mimeType, "video/") || strings.HasPrefix(mimeType, "audio/") || strings.HasPrefix(mimeType, "image/") {
viewerSession["viewUrl"] = viewPath
} else if strings.Contains(mimeType, "document") || strings.Contains(mimeType, "word") || strings.Contains(mimeType, "spreadsheet") || strings.Contains(mimeType, "presentation") {
// Use Collabora for document viewing
wopiSrc := fmt.Sprintf("%s://go.b0esche.cloud/public/wopi/share/%s", scheme, token)
editorUrl := getCollaboraEditorURL("https://of.b0esche.cloud")
collaboraUrl := fmt.Sprintf("%s?WOPISrc=%s", editorUrl, url.QueryEscape(wopiSrc))
viewerSession["viewUrl"] = collaboraUrl
}
// Add internal access info if user has access
if internalFileId != nil {
viewerSession["fileId"] = *internalFileId
if internalOrgId != nil {
viewerSession["orgId"] = *internalOrgId
}
}
// Add CORS headers for public access
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Range")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(viewerSession)
}
func publicFileDownloadHandler(w http.ResponseWriter, r *http.Request, db *database.DB, cfg *config.Config, jwtManager *jwt.Manager) {
token := chi.URLParam(r, "token")
if token == "" {
errors.WriteError(w, errors.CodeInvalidArgument, "Token required", http.StatusBadRequest)
return
}
viewerToken := r.URL.Query().Get("token")
if viewerToken == "" {
errors.WriteError(w, errors.CodeInvalidArgument, "Viewer token required", http.StatusUnauthorized)
return
}
link, err := db.GetFileShareLinkByToken(r.Context(), token)
if err != nil {
if err == sql.ErrNoRows {
errors.WriteError(w, errors.CodeNotFound, "Link not found or expired", http.StatusNotFound)
return
}
errors.LogError(r, err, "Failed to get share link")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Verify viewer token (contains org ID for org files, empty for personal)
claims, err := jwtManager.Validate(viewerToken)
if err != nil {
errors.LogError(r, err, "Invalid viewer token")
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid token", http.StatusUnauthorized)
return
}
if link.OrgID == nil {
if len(claims.OrgIDs) != 0 {
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid token", http.StatusUnauthorized)
return
}
} else {
if len(claims.OrgIDs) == 0 {
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid token", http.StatusUnauthorized)
return
}
orgID, err := uuid.Parse(claims.OrgIDs[0])
if err != nil {
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid token", http.StatusUnauthorized)
return
}
if *link.OrgID != orgID {
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid token", http.StatusUnauthorized)
return
}
}
// Get file metadata
file, err := db.GetFileByID(r.Context(), link.FileID)
if err != nil {
errors.LogError(r, err, "Failed to get file")
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
if file.UserID == nil {
errors.WriteError(w, errors.CodeNotFound, "File not accessible", http.StatusNotFound)
return
}
// Check if it's a folder - if so, create a zip download
if file.Type == "folder" {
// Get all files under this folder path
var folderFiles []database.File
var err error
if link.OrgID != nil {
// Org folder - need user ID from context or file owner
folderFiles, err = db.GetAllOrgFilesUnderPath(r.Context(), *link.OrgID, *file.UserID, file.Path)
} else {
// User folder
folderFiles, err = db.GetAllUserFilesUnderPath(r.Context(), *file.UserID, file.Path)
}
if err != nil {
errors.LogError(r, err, "Failed to get folder contents")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Filter out sub-folders (only include files)
var filesToZip []database.File
for _, f := range folderFiles {
if f.Type == "file" {
filesToZip = append(filesToZip, f)
}
}
if len(filesToZip) == 0 {
errors.WriteError(w, errors.CodeInvalidArgument, "Folder is empty", http.StatusBadRequest)
return
}
// Create zip file in memory
var zipBuffer bytes.Buffer
zipWriter := zip.NewWriter(&zipBuffer)
// Get WebDAV client for the file's owner
client, err := getUserWebDAVClient(r.Context(), db, *file.UserID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
if err != nil {
errors.LogError(r, err, "Failed to get WebDAV client")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Add each file to the zip
for _, fileToZip := range filesToZip {
// Download file from storage
downloadCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
resp, err := client.Download(downloadCtx, fileToZip.Path, "")
cancel()
if err != nil {
errors.LogError(r, err, fmt.Sprintf("Failed to download file %s for zip", fileToZip.Path))
continue // Skip this file, continue with others
}
// Read file content
fileData, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
errors.LogError(r, err, fmt.Sprintf("Failed to read file %s for zip", fileToZip.Path))
continue
}
// Create zip entry - use relative path within the folder
relativePath := strings.TrimPrefix(fileToZip.Path, file.Path)
if after, ok := strings.CutPrefix(relativePath, "/"); ok {
relativePath = after
}
zipFile, err := zipWriter.Create(relativePath)
if err != nil {
errors.LogError(r, err, fmt.Sprintf("Failed to create zip entry for %s", relativePath))
continue
}
// Write file data to zip
_, err = zipFile.Write(fileData)
if err != nil {
errors.LogError(r, err, fmt.Sprintf("Failed to write file %s to zip", relativePath))
continue
}
}
// Close zip writer
err = zipWriter.Close()
if err != nil {
errors.LogError(r, err, "Failed to close zip writer")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Add CORS headers for public access
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Range")
// Set headers for zip download
w.Header().Set("Content-Type", "application/zip")
zipFilename := fmt.Sprintf("%s.zip", file.Name)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", zipFilename))
w.Header().Set("Content-Length", fmt.Sprintf("%d", zipBuffer.Len()))
// Stream the zip file
w.Write(zipBuffer.Bytes())
return
}
// Determine MIME type
mimeType := getMimeType(file.Name)
// Get WebDAV client for the file's owner
client, err := getUserWebDAVClient(r.Context(), db, *file.UserID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
if err != nil {
errors.LogError(r, err, "Failed to get WebDAV client")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Create context with longer timeout for file downloads
downloadCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
// Stream file
resp, err := client.Download(downloadCtx, file.Path, "")
if err != nil {
errors.LogError(r, err, "Failed to download file")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
defer resp.Body.Close()
// Add CORS headers for public access
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Range")
// Copy headers from Nextcloud response, but skip Content-Type to ensure correct MIME type
for k, v := range resp.Header {
if k != "Content-Type" {
w.Header()[k] = v
}
}
// Set correct Content-Type based on file extension
w.Header().Set("Content-Type", mimeType)
// Ensure download behavior
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", file.Name))
// Copy body
io.Copy(w, resp.Body)
}
func publicFileViewHandler(w http.ResponseWriter, r *http.Request, db *database.DB, cfg *config.Config, jwtManager *jwt.Manager) {
token := chi.URLParam(r, "token")
if token == "" {
errors.WriteError(w, errors.CodeInvalidArgument, "Token required", http.StatusBadRequest)
return
}
viewerToken := r.URL.Query().Get("token")
if viewerToken == "" {
errors.WriteError(w, errors.CodeInvalidArgument, "Viewer token required", http.StatusUnauthorized)
return
}
link, err := db.GetFileShareLinkByToken(r.Context(), token)
if err != nil {
if err == sql.ErrNoRows {
errors.WriteError(w, errors.CodeNotFound, "Link not found or expired", http.StatusNotFound)
return
}
errors.LogError(r, err, "Failed to get share link")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Verify viewer token (contains org ID for org files, empty for personal)
claims, err := jwtManager.Validate(viewerToken)
if err != nil {
errors.LogError(r, err, "Invalid viewer token")
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid token", http.StatusUnauthorized)
return
}
if link.OrgID == nil {
if len(claims.OrgIDs) != 0 {
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid token", http.StatusUnauthorized)
return
}
} else {
if len(claims.OrgIDs) == 0 {
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid token", http.StatusUnauthorized)
return
}
orgID, err := uuid.Parse(claims.OrgIDs[0])
if err != nil {
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid token", http.StatusUnauthorized)
return
}
if *link.OrgID != orgID {
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid token", http.StatusUnauthorized)
return
}
}
// Get file metadata
file, err := db.GetFileByID(r.Context(), link.FileID)
if err != nil {
errors.LogError(r, err, "Failed to get file")
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
if file.UserID == nil {
errors.WriteError(w, errors.CodeNotFound, "File not accessible", http.StatusNotFound)
return
}
// Check if it's a folder - cannot view folders directly
if file.Type == "folder" {
errors.WriteError(w, errors.CodeInvalidArgument, "Cannot view folders", http.StatusBadRequest)
return
}
// Determine MIME type
mimeType := getMimeType(file.Name)
// Get WebDAV client for the file's owner
client, err := getUserWebDAVClient(r.Context(), db, *file.UserID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
if err != nil {
errors.LogError(r, err, "Failed to get WebDAV client")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Create context with longer timeout for file downloads
downloadCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
// Stream file
resp, err := client.Download(downloadCtx, file.Path, r.Header.Get("Range"))
if err != nil {
errors.LogError(r, err, "Failed to download file")
errors.WriteError(w, errors.CodeInternal, "File temporarily unavailable. Please try again later.", http.StatusInternalServerError)
return
}
defer resp.Body.Close()
// Add CORS headers for public access
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Range")
// Copy headers from Nextcloud response, but skip Content-Type to ensure correct MIME type
for k, v := range resp.Header {
if k != "Content-Type" {
w.Header()[k] = v
}
}
// Set correct Content-Type based on file extension
w.Header().Set("Content-Type", mimeType)
// Ensure inline viewing behavior (no Content-Disposition attachment)
w.Header().Del("Content-Disposition")
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", file.Name))
// Set status code (200 or 206 for partial)
w.WriteHeader(resp.StatusCode)
// Copy body
io.Copy(w, resp.Body)
}
func getUserFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
userIDStr, _ := middleware.GetUserID(r.Context())
userID, _ := uuid.Parse(userIDStr)
fileId := chi.URLParam(r, "fileId")
fileUUID, err := uuid.Parse(fileId)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid file ID", http.StatusBadRequest)
return
}
// Check if file exists and belongs to user
file, err := db.GetFileByID(r.Context(), fileUUID)
if err != nil {
errors.LogError(r, err, "Failed to get file")
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
if file.UserID == nil || *file.UserID != userID {
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
link, err := db.GetFileShareLinkByFileID(r.Context(), fileUUID)
if err != nil {
if err == sql.ErrNoRows {
// No share link exists
errors.WriteError(w, errors.CodeNotFound, "Share link not found", http.StatusNotFound)
return
}
errors.LogError(r, err, "Failed to get share link")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Build full URL
scheme := "https"
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
scheme = proto
} else if r.TLS == nil {
scheme = "http"
}
host := "www.b0esche.cloud"
fullURL := fmt.Sprintf("%s://%s/share/%s", scheme, host, link.Token)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"shareUrl": fullURL,
"token": link.Token,
})
}
func createUserFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
userIDStr, _ := middleware.GetUserID(r.Context())
userID, _ := uuid.Parse(userIDStr)
fileId := chi.URLParam(r, "fileId")
fileUUID, err := uuid.Parse(fileId)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid file ID", http.StatusBadRequest)
return
}
// Check if file exists and belongs to user
file, err := db.GetFileByID(r.Context(), fileUUID)
if err != nil {
errors.LogError(r, err, "Failed to get file")
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
if file.UserID == nil || *file.UserID != userID {
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
// Revoke existing link if any
db.RevokeFileShareLink(r.Context(), fileUUID) // Ignore error
// Generate token
token, err := storage.GenerateSecurePassword(48)
if err != nil {
errors.LogError(r, err, "Failed to generate token")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// If the file belongs to an org, prefer binding the share link to that org.
// db.CreateFileShareLink will attempt to infer org_id from the file if nil is passed.
link, err := db.CreateFileShareLink(r.Context(), token, fileUUID, nil, userID)
if err != nil {
errors.LogError(r, err, "Failed to create share link")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
log.Printf("Share link created: user_id=%s, file_id=%s, org_id=%v", userID, fileUUID, link.OrgID)
// Build full URL
scheme := "https"
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
scheme = proto
} else if r.TLS == nil {
scheme = "http"
}
host := "www.b0esche.cloud"
fullURL := fmt.Sprintf("%s://%s/share/%s", scheme, host, link.Token)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"shareUrl": fullURL,
"token": link.Token,
})
}
func revokeUserFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
userIDStr, _ := middleware.GetUserID(r.Context())
userID, _ := uuid.Parse(userIDStr)
fileId := chi.URLParam(r, "fileId")
fileUUID, err := uuid.Parse(fileId)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid file ID", http.StatusBadRequest)
return
}
// Check if file exists and belongs to user
file, err := db.GetFileByID(r.Context(), fileUUID)
if err != nil {
errors.LogError(r, err, "Failed to get file")
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
if file.UserID == nil || *file.UserID != userID {
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
err = db.RevokeFileShareLink(r.Context(), fileUUID)
if err != nil {
errors.LogError(r, err, "Failed to revoke share link")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// publicWopiCheckFileInfoHandler handles GET /public/wopi/share/{token}
// Returns metadata about the shared file for Collabora viewer
func publicWopiCheckFileInfoHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager) {
token := chi.URLParam(r, "token")
if token == "" {
errors.WriteError(w, errors.CodeInvalidArgument, "Token required", http.StatusBadRequest)
return
}
// Get share link
link, err := db.GetFileShareLinkByToken(r.Context(), token)
if err != nil {
if err == sql.ErrNoRows {
errors.WriteError(w, errors.CodeNotFound, "Link not found or expired", http.StatusNotFound)
return
}
errors.LogError(r, err, "Failed to get share link")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Get file metadata
file, err := db.GetFileByID(r.Context(), link.FileID)
if err != nil {
errors.LogError(r, err, "Failed to get file")
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
if file.UserID == nil {
errors.WriteError(w, errors.CodeNotFound, "File not accessible", http.StatusNotFound)
return
}
lastModifiedTime := file.LastModified
if lastModifiedTime.IsZero() {
lastModifiedTime = time.Now()
}
response := struct {
BaseFileName string `json:"BaseFileName"`
Size int64 `json:"Size"`
OwnerId string `json:"OwnerId"`
Version string `json:"Version"`
SupportsExtendedLockLength bool `json:"SupportsExtendedLockLength"`
SupportsGetLock bool `json:"SupportsGetLock"`
SupportsLocks bool `json:"SupportsLocks"`
SupportsUpdate bool `json:"SupportsUpdate"`
UserId string `json:"UserId"`
UserFriendlyName string `json:"UserFriendlyName"`
UserCanWrite bool `json:"UserCanWrite"`
UserCanNotWriteRelative bool `json:"UserCanNotWriteRelative"`
ReadOnly bool `json:"ReadOnly"`
RestrictedWebViewOnly bool `json:"RestrictedWebViewOnly"`
UserCanCreateRelativeToFolder bool `json:"UserCanCreateRelativeToFolder"`
EnableOwnerTermination bool `json:"EnableOwnerTermination"`
SupportsCobalt bool `json:"SupportsCobalt"`
SupportsDelete bool `json:"SupportsDelete"`
SupportsRename bool `json:"SupportsRename"`
SupportsRenameRelativeToFolder bool `json:"SupportsRenameRelativeToFolder"`
SupportsFolders bool `json:"SupportsFolders"`
SupportsScenarios []string `json:"SupportsScenarios"`
LastModifiedTime string `json:"LastModifiedTime"`
IsAnonymousUser bool `json:"IsAnonymousUser"`
TimeZone string `json:"TimeZone"`
}{
BaseFileName: file.Name,
Size: file.Size,
OwnerId: file.UserID.String(),
Version: file.LastModified.UTC().Format(time.RFC3339),
SupportsExtendedLockLength: false,
SupportsGetLock: false,
SupportsLocks: false,
SupportsUpdate: false,
UserId: "anonymous",
UserFriendlyName: "Anonymous User",
UserCanWrite: false,
UserCanNotWriteRelative: true,
ReadOnly: true, // Public sharing is read-only
RestrictedWebViewOnly: true, // Only allow web view
UserCanCreateRelativeToFolder: false,
EnableOwnerTermination: false,
SupportsCobalt: false,
SupportsDelete: false,
SupportsRename: false,
SupportsRenameRelativeToFolder: false,
SupportsFolders: false,
SupportsScenarios: []string{"embedview", "view"},
LastModifiedTime: lastModifiedTime.UTC().Format(time.RFC3339),
IsAnonymousUser: true,
TimeZone: "UTC",
}
fmt.Printf("[PUBLIC-WOPI] CheckFileInfo: file=%s token=%s size=%d\n", file.ID.String(), token, file.Size)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(response)
}
// publicWopiGetFileHandler handles GET /public/wopi/share/{token}/contents
// Downloads the shared file content for Collabora
func publicWopiGetFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, cfg *config.Config, jwtManager *jwt.Manager) {
token := chi.URLParam(r, "token")
if token == "" {
errors.WriteError(w, errors.CodeInvalidArgument, "Token required", http.StatusBadRequest)
return
}
fmt.Printf("[PUBLIC-WOPI-GetFile] START: token=%s\n", token)
// Get share link
link, err := db.GetFileShareLinkByToken(r.Context(), token)
if err != nil {
if err == sql.ErrNoRows {
errors.WriteError(w, errors.CodeNotFound, "Link not found or expired", http.StatusNotFound)
return
}
errors.LogError(r, err, "Failed to get share link")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Get file metadata
file, err := db.GetFileByID(r.Context(), link.FileID)
if err != nil {
errors.LogError(r, err, "Failed to get file")
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
if file.UserID == nil {
errors.WriteError(w, errors.CodeNotFound, "File not accessible", http.StatusNotFound)
return
}
// Get WebDAV client for the file's owner
client, err := getUserWebDAVClient(r.Context(), db, *file.UserID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
if err != nil {
errors.LogError(r, err, "Failed to get WebDAV client")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Create context with longer timeout for file downloads
downloadCtx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
// Stream file
resp, err := client.Download(downloadCtx, file.Path, r.Header.Get("Range"))
if err != nil {
errors.LogError(r, err, "Failed to download file")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
defer resp.Body.Close()
// Copy headers from Nextcloud response
for k, v := range resp.Header {
w.Header()[k] = v
}
// Set status code (200 or 206 for partial)
w.WriteHeader(resp.StatusCode)
// Copy body
io.Copy(w, resp.Body)
}
// getUserProfileHandler returns the current user's profile information
func getUserProfileHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
userIDStr, ok := middleware.GetUserID(r.Context())
if !ok {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
userID, err := uuid.Parse(userIDStr)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest)
return
}
var user struct {
ID uuid.UUID `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
DisplayName *string `json:"displayName"`
AvatarURL *string `json:"avatarUrl"`
CreatedAt time.Time `json:"createdAt"`
LastLoginAt *time.Time `json:"lastLoginAt"`
UpdatedAt *time.Time `json:"-"`
}
err = db.QueryRowContext(r.Context(),
`SELECT id, username, email, display_name, avatar_url, created_at, last_login_at, updated_at
FROM users WHERE id = $1`, userID).
Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.AvatarURL, &user.CreatedAt, &user.LastLoginAt, &user.UpdatedAt)
if err != nil {
if err == sql.ErrNoRows {
errors.WriteError(w, errors.CodeNotFound, "User not found", http.StatusNotFound)
return
}
errors.LogError(r, err, "Failed to get user profile")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// If avatar exists, return the backend URL instead of the internal WebDAV URL
if user.AvatarURL != nil && *user.AvatarURL != "" {
// Use updated_at for versioning if available to allow cache busting when avatar changes
var v int64
if user.UpdatedAt != nil {
v = user.UpdatedAt.Unix()
} else {
v = time.Now().Unix()
}
// Include token in the avatar URL so frontends that cannot set headers (Image.network) can fetch it
token, ok := middleware.GetToken(r.Context())
if ok && token != "" {
user.AvatarURL = &[]string{fmt.Sprintf("https://go.b0esche.cloud/user/avatar?v=%d&token=%s", v, token)}[0]
} else {
user.AvatarURL = &[]string{fmt.Sprintf("https://go.b0esche.cloud/user/avatar?v=%d", v)}[0]
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
// updateUserProfileHandler updates the current user's profile information
func updateUserProfileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
userIDStr, ok := middleware.GetUserID(r.Context())
if !ok {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
userID, err := uuid.Parse(userIDStr)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest)
return
}
var req struct {
DisplayName *string `json:"displayName"`
Email *string `json:"email"`
}
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid JSON", http.StatusBadRequest)
return
}
// Build dynamic update query
var setParts []string
var args []interface{}
argIndex := 1
if req.DisplayName != nil {
setParts = append(setParts, fmt.Sprintf("display_name = $%d", argIndex))
args = append(args, *req.DisplayName)
argIndex++
}
if req.Email != nil {
setParts = append(setParts, fmt.Sprintf("email = $%d", argIndex))
args = append(args, *req.Email)
argIndex++
}
if len(setParts) == 0 {
// No fields to update
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"message": "No changes to update"})
return
}
setParts = append(setParts, "updated_at = NOW()")
query := fmt.Sprintf("UPDATE users SET %s WHERE id = $%d", strings.Join(setParts, ", "), argIndex)
args = append(args, userID)
_, err = db.ExecContext(r.Context(), query, args...)
if err != nil {
errors.LogError(r, err, "Failed to update user profile")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Audit log
metadata := make(map[string]interface{})
if req.DisplayName != nil {
metadata["displayName"] = *req.DisplayName
}
if req.Email != nil {
metadata["email"] = *req.Email
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
Action: "profile_update",
Success: true,
Metadata: metadata,
})
// Return updated profile JSON
var updatedUser struct {
ID uuid.UUID `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
DisplayName *string `json:"displayName"`
AvatarURL *string `json:"avatarUrl"`
CreatedAt time.Time `json:"createdAt"`
LastLoginAt *time.Time `json:"lastLoginAt"`
UpdatedAt *time.Time `json:"-"`
}
err = db.QueryRowContext(r.Context(),
`SELECT id, username, email, display_name, avatar_url, created_at, last_login_at, updated_at
FROM users WHERE id = $1`, userID).
Scan(&updatedUser.ID, &updatedUser.Username, &updatedUser.Email, &updatedUser.DisplayName, &updatedUser.AvatarURL, &updatedUser.CreatedAt, &updatedUser.LastLoginAt, &updatedUser.UpdatedAt)
if err != nil {
errors.LogError(r, err, "Failed to fetch updated user profile")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
if updatedUser.AvatarURL != nil && *updatedUser.AvatarURL != "" {
var v int64
if updatedUser.UpdatedAt != nil {
v = updatedUser.UpdatedAt.Unix()
} else {
v = time.Now().Unix()
}
if token, ok := middleware.GetToken(r.Context()); ok && token != "" {
updatedUser.AvatarURL = &[]string{fmt.Sprintf("https://go.b0esche.cloud/user/avatar?v=%d&token=%s", v, token)}[0]
} else {
updatedUser.AvatarURL = &[]string{fmt.Sprintf("https://go.b0esche.cloud/user/avatar?v=%d", v)}[0]
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(updatedUser)
}
// changePasswordHandler changes the current user's password
func changePasswordHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
userIDStr, ok := middleware.GetUserID(r.Context())
if !ok {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
userID, err := uuid.Parse(userIDStr)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest)
return
}
var req struct {
CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"`
}
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid JSON", http.StatusBadRequest)
return
}
// For simplicity, since passwords are handled via passkeys, we'll just log and simulate
// In a real implementation, verify current password and hash new one
// Audit log
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
Action: "password_change",
Success: true,
})
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"message": "Password changed"})
}
// uploadUserAvatarHandler handles avatar file uploads
func uploadUserAvatarHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, cfg *config.Config) {
userIDStr, ok := middleware.GetUserID(r.Context())
if !ok {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
userID, err := uuid.Parse(userIDStr)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest)
return
}
// Parse multipart form
err = r.ParseMultipartForm(32 << 20) // 32MB max
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Failed to parse form", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("avatar")
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "No avatar file provided", http.StatusBadRequest)
return
}
defer file.Close()
// Validate file type
contentType := header.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "image/") {
errors.WriteError(w, errors.CodeInvalidArgument, "File must be an image", http.StatusBadRequest)
return
}
// Validate file size (max 5MB)
if header.Size > 5<<20 {
errors.WriteError(w, errors.CodeInvalidArgument, "File too large (max 5MB)", http.StatusBadRequest)
return
}
// Read file content
fileBytes, err := io.ReadAll(file)
if err != nil {
errors.LogError(r, err, "Failed to read file")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Generate deterministic filename based on user ID
ext := filepath.Ext(header.Filename)
if ext == "" {
ext = ".png" // default extension
}
filename := fmt.Sprintf("%s%s", userID.String(), ext)
// Upload to Nextcloud at .avatars/<user-id>.<ext>
// Use internal Nextcloud WebDAV endpoint for server-to-server operations to avoid external TLS/timeouts
internalClient := storage.NewUserWebDAVClient(cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
avatarPath := fmt.Sprintf(".avatars/%s", filename)
err = internalClient.Upload(r.Context(), avatarPath, bytes.NewReader(fileBytes), header.Size)
if err != nil {
errors.LogError(r, err, "Failed to upload avatar (internal)")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Store external-facing avatar URL in DB (so other components can reference it)
externalClient := storage.NewWebDAVClient(cfg)
webdavURL := fmt.Sprintf("%s/%s", strings.TrimRight(externalClient.BaseURL, "/"), avatarPath)
// Update user profile with avatar URL and updated_at
_, err = db.ExecContext(r.Context(),
`UPDATE users SET avatar_url = $1, updated_at = NOW() WHERE id = $2`,
webdavURL, userID)
if err != nil {
errors.LogError(r, err, "Failed to update user avatar")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Verify uploaded avatar is available on WebDAV (best-effort, retry)
verified := false
verifyRetries := 3
verifyTimeout := 5 // seconds
for i := 0; i < verifyRetries; i++ {
vctx, vcancel := context.WithTimeout(r.Context(), time.Duration(verifyTimeout)*time.Second)
resp, derr := internalClient.Download(vctx, avatarPath, "")
if derr == nil && resp != nil {
// Close body while context is still valid
resp.Body.Close()
vcancel()
verified = true
break
}
// Cancel context for failed attempt
vcancel()
fmt.Printf("[WARN] avatar verification attempt %d/%d failed for %s: %v\n", i+1, verifyRetries, avatarPath, derr)
time.Sleep(time.Duration(300*(1<<i)) * time.Millisecond) // 300ms, 600ms, 1.2s
}
// If verification failed, log detailed message (but proceed to respond with preview/cache)
if !verified {
fmt.Printf("[ERROR] avatar verification failed after %d attempts for %s\n", verifyRetries, avatarPath)
} else {
fmt.Printf("[INFO] avatar verification succeeded for %s\n", avatarPath)
}
// Build public URL including version based on updated_at and include token if available
var version int64 = time.Now().Unix()
// Try to use updated_at from DB to be more accurate
var updatedAt time.Time
err = db.QueryRowContext(r.Context(), `SELECT updated_at FROM users WHERE id = $1`, userID).Scan(&updatedAt)
if err == nil {
version = updatedAt.Unix()
}
// Save avatar bytes to local cache keyed by version so it survives restarts and avoids unnecessary re-downloads
versionStr := fmt.Sprintf("%d", version)
cached := false
if err := writeAvatarCache(cfg, userID.String(), versionStr, ext, fileBytes); err != nil {
fmt.Printf("[WARN] failed to write avatar cache for user=%s version=%s: %v\n", userID.String(), versionStr, err)
// Attempt to write to an additional local data dir as a fallback
fallbackDir := "./data/avatars"
if err2 := os.MkdirAll(fallbackDir, 0755); err2 == nil {
fallbackPath := filepath.Join(fallbackDir, fmt.Sprintf("%s.%s%s", userID.String(), versionStr, ext))
if err3 := os.WriteFile(fallbackPath, fileBytes, 0644); err3 == nil {
fmt.Printf("[INFO] Wrote avatar cache to fallback path %s\n", fallbackPath)
cached = true
} else {
fmt.Printf("[WARN] failed to write avatar cache to fallback path %s: %v\n", fallbackPath, err3)
}
} else {
fmt.Printf("[WARN] failed to create fallback avatar dir %s: %v\n", fallbackDir, err2)
}
} else {
cached = true
}
// Verify cache is readable
if cached {
if b, ct, cerr := readAvatarCache(cfg, userID.String(), versionStr); cerr != nil {
fmt.Printf("[WARN] cache write verification failed for user=%s v=%s: %v\n", userID.String(), versionStr, cerr)
cached = false
} else {
fmt.Printf("[INFO] cache write verified for user=%s v=%s (content-type=%s size=%d)\n", userID.String(), versionStr, ct, len(b))
}
}
// Audit log
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
Action: "avatar_upload",
Success: true,
Metadata: map[string]interface{}{
"filename": filename,
"size": header.Size,
},
})
// Provide avatarData for immediate preview (small images only)
avatarData := base64.StdEncoding.EncodeToString(fileBytes)
publicURL := fmt.Sprintf("https://go.b0esche.cloud/user/avatar?v=%d", version)
if token, ok := middleware.GetToken(r.Context()); ok && token != "" {
publicURL = fmt.Sprintf("%s&token=%s", publicURL, token)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Avatar uploaded successfully",
"avatarUrl": publicURL,
"cached": cached,
"avatarData": avatarData,
"contentType": contentType,
})
}
// getUserAvatarHandler serves the user's avatar image
func getUserAvatarHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, cfg *config.Config) {
// Accept token via query param or Authorization header (Bearer)
tokenString := r.URL.Query().Get("token")
if tokenString == "" {
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
tokenString = strings.TrimPrefix(authHeader, "Bearer ")
}
}
if tokenString == "" {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
claims, err := jwtManager.Validate(tokenString)
if err != nil {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
userIDStr := claims.UserID
userID, err := uuid.Parse(userIDStr)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest)
return
}
var avatarURL *string
err = db.QueryRowContext(r.Context(),
`SELECT avatar_url FROM users WHERE id = $1`, userID).
Scan(&avatarURL)
if err != nil {
if err == sql.ErrNoRows {
errors.WriteError(w, errors.CodeNotFound, "User not found", http.StatusNotFound)
return
}
errors.LogError(r, err, "Failed to get user avatar")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
if avatarURL == nil || *avatarURL == "" {
// No avatar, return 404
w.WriteHeader(http.StatusNotFound)
return
}
v := r.URL.Query().Get("v")
// If client supplied a version and we have a matching cache, serve it immediately to avoid hitting WebDAV
if v != "" {
if data, ct, cerr := readAvatarCache(cfg, userID.String(), v); cerr == nil {
w.Header().Set("Content-Type", ct)
w.Header().Set("Cache-Control", "public, max-age=300")
w.WriteHeader(http.StatusOK)
w.Write(data)
fmt.Printf("[INFO] Served avatar for user=%s from cache (v=%s) without contacting WebDAV\n", userID.String(), v)
return
}
}
// Download from WebDAV with retries and backoff
// Use external client instance only to compute remotePath from stored avatar URL
externalClient := storage.NewWebDAVClient(cfg)
if externalClient == nil {
errors.WriteError(w, errors.CodeInternal, "WebDAV client not configured", http.StatusInternalServerError)
return
}
remotePath := strings.TrimPrefix(*avatarURL, externalClient.BaseURL+"/")
// Use internal admin WebDAV client to actually fetch the avatar (server-to-server)
internalClient := storage.NewUserWebDAVClient(cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
// Configure retry & timeout from config
timeoutSeconds := cfg.AvatarDownloadTimeoutSeconds
if timeoutSeconds <= 0 {
timeoutSeconds = 10
}
retries := cfg.AvatarDownloadRetries
if retries < 0 {
retries = 0
}
attempts := retries + 1 // total attempts
var resp *http.Response
var dlErr error
var cancel context.CancelFunc
for attempt := 0; attempt < attempts; attempt++ {
ctx, c := context.WithTimeout(r.Context(), time.Duration(timeoutSeconds)*time.Second)
cancel = c
// Use internal client to avoid external network/TLS overhead
resp, dlErr = internalClient.Download(ctx, remotePath, "")
if dlErr != nil {
// Cancel context for failed attempt
cancel()
// If 404 on remote storage, the avatar file truly doesn't exist
if strings.Contains(dlErr.Error(), "404") {
fmt.Printf("[ERROR] Avatar not found on storage for remotePath=%s: %v\n", remotePath, dlErr)
w.WriteHeader(http.StatusNotFound)
return
}
// Log and apply backoff for retryable errors
fmt.Printf("[WARN] Avatar download attempt %d/%d failed for remotePath=%s: %v\n", attempt+1, attempts, remotePath, dlErr)
if attempt < attempts-1 {
// exponential backoff: 500ms, 1s, 2s, ...
backoffMs := 500 * (1 << attempt)
time.Sleep(time.Duration(backoffMs) * time.Millisecond)
}
continue
}
// Success: keep the cancel func so we can call it after reading the body
break
}
if cancel != nil {
defer cancel()
}
if dlErr != nil || resp == nil {
// Try to serve the latest cached avatar (if any) as a graceful fallback
if data, ct, cerr := readAvatarCache(cfg, userID.String(), ""); cerr == nil {
w.Header().Set("Content-Type", ct)
w.Header().Set("Cache-Control", "public, max-age=300")
w.WriteHeader(http.StatusOK)
w.Write(data)
fmt.Printf("[INFO] Served latest cached avatar for user=%s after WebDAV failures\n", userID.String())
return
}
// No cached avatar available; surface an explicit error (502) so clients can retry
fmt.Printf("[ERROR] Avatar download failed after %d attempts for remotePath=%s: %v\n", attempts, remotePath, dlErr)
w.Header().Set("Retry-After", "30")
errors.WriteError(w, errors.CodeInternal, "Upstream storage unavailable", http.StatusBadGateway)
return
}
defer resp.Body.Close()
// Read body (so we can cache it) and determine content-type
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
errors.LogError(r, err, "Failed to read avatar body")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = mime.TypeByExtension(filepath.Ext(remotePath))
if contentType == "" {
contentType = "application/octet-stream"
}
}
// Save to cache asynchronously (best-effort). Use provided v query param if present.
v = r.URL.Query().Get("v")
go func(v string) {
if err := writeAvatarCache(cfg, userID.String(), v, filepath.Ext(remotePath), bodyBytes); err != nil {
fmt.Printf("[WARN] failed to write avatar cache for user=%s v=%s: %v\n", userID.String(), v, err)
}
}(v)
// Copy headers but ensure sensible caching
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "public, max-age=300")
w.WriteHeader(resp.StatusCode)
w.Write(bodyBytes)
}
// deleteUserAccountHandler handles user account deletion
func deleteUserAccountHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, cfg *config.Config) {
userIDStr, ok := middleware.GetUserID(r.Context())
if !ok {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
userID, err := uuid.Parse(userIDStr)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest)
return
}
// Start transaction for atomic deletion
tx, err := db.BeginTx(r.Context(), nil)
if err != nil {
errors.LogError(r, err, "Failed to start transaction")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
defer tx.Rollback()
// Get user details for audit logging
var username string
err = tx.QueryRowContext(r.Context(), "SELECT username FROM users WHERE id = $1", userID).Scan(&username)
if err != nil {
errors.LogError(r, err, "Failed to get user details")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Delete file shares (both personal and org shares)
_, err = tx.ExecContext(r.Context(), `
DELETE FROM file_share_links
WHERE created_by_user_id = $1
`, userID)
if err != nil {
errors.LogError(r, err, "Failed to delete file shares")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Delete user files and their Nextcloud data
rows, err := tx.QueryContext(r.Context(), `
SELECT path FROM files WHERE user_id = $1 AND org_id IS NULL
`, userID)
if err != nil {
errors.LogError(r, err, "Failed to get user files")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
defer rows.Close()
// Delete files from Nextcloud storage
client := storage.NewWebDAVClient(cfg)
for rows.Next() {
var filePath string
if err := rows.Scan(&filePath); err != nil {
continue // Skip on error
}
// Try to delete from Nextcloud (ignore errors as files might not exist)
client.Delete(r.Context(), filePath)
}
// Delete database records
_, err = tx.ExecContext(r.Context(), "DELETE FROM files WHERE user_id = $1 AND org_id IS NULL", userID)
if err != nil {
errors.LogError(r, err, "Failed to delete user files")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Remove user from all organizations (this will cascade to org files if needed)
_, err = tx.ExecContext(r.Context(), "DELETE FROM memberships WHERE user_id = $1", userID)
if err != nil {
errors.LogError(r, err, "Failed to remove user from organizations")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Delete user sessions
_, err = tx.ExecContext(r.Context(), "DELETE FROM sessions WHERE user_id = $1", userID)
if err != nil {
errors.LogError(r, err, "Failed to delete user sessions")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Finally, delete the user account
_, err = tx.ExecContext(r.Context(), "DELETE FROM users WHERE id = $1", userID)
if err != nil {
errors.LogError(r, err, "Failed to delete user account")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Commit transaction
if err = tx.Commit(); err != nil {
errors.LogError(r, err, "Failed to commit transaction")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Audit log the account deletion
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
Action: "account_delete",
Success: true,
Metadata: map[string]interface{}{
"username": username,
},
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Account deleted successfully",
})
}