package http import ( "archive/zip" "bytes" "context" "database/sql" "encoding/json" "fmt" "io" "log" "mime/multipart" "net/http" "net/url" "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 } // 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.Post("/user/change-password", func(w http.ResponseWriter, req *http.Request) { changePasswordHandler(w, req, db, auditLogger) }) r.Post("/user/avatar", func(w http.ResponseWriter, req *http.Request) { uploadUserAvatarHandler(w, req, db, auditLogger, cfg) }) // 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") 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// 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 details before moving sourceFiles, err := db.GetOrgFiles(r.Context(), orgID, userID, "/", "", 0, 1000) if err != nil { errors.LogError(r, err, "Failed to get org files") errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) return } var sourceFile *database.File for i := range sourceFiles { if sourceFiles[i].Path == req.SourcePath { sourceFile = &sourceFiles[i] break } } if sourceFile == nil { errors.WriteError(w, errors.CodeInvalidArgument, "Source file not found", http.StatusNotFound) 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 var targetFile *database.File for i := range sourceFiles { if sourceFiles[i].Path == req.TargetPath { targetFile = &sourceFiles[i] break } } 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 } // 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 (continuing with database operation)") } else { 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 (continuing with database operation)") } } // Delete old file record if err := db.DeleteFileByPath(r.Context(), &orgID, nil, req.SourcePath); err != nil { errors.LogError(r, err, "Failed to delete old file record") } // Create new file record at the new location if _, err := db.CreateFile(r.Context(), &orgID, nil, sourceFile.Name, newPath, sourceFile.Type, sourceFile.Size); err != nil { errors.LogError(r, err, "Failed to create new file record at destination") } 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 } // 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 (continuing with database operation)") } else { // 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 (continuing with database operation)") } } // Delete old file record if err := db.DeleteFileByPath(r.Context(), nil, &userID, req.SourcePath); err != nil { errors.LogError(r, err, "Failed to delete old file record") } // Create new file record at the new location if _, err := db.CreateFile(r.Context(), nil, &userID, sourceFile.Name, newPath, sourceFile.Type, sourceFile.Size); err != nil { errors.LogError(r, err, "Failed to create new file record at destination") } 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// 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 - cannot download folders if file.Type == "folder" { errors.WriteError(w, errors.CodeInvalidArgument, "Cannot download 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, "") 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"` BlurHash *string `json:"blurHash"` CreatedAt time.Time `json:"createdAt"` LastLoginAt *time.Time `json:"lastLoginAt"` } err = db.QueryRowContext(r.Context(), `SELECT id, username, email, display_name, avatar_url, blur_hash, created_at, last_login_at FROM users WHERE id = $1`, userID). Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.AvatarURL, &user.BlurHash, &user.CreatedAt, &user.LastLoginAt) 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 } 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"` AvatarURL *string `json:"avatarUrl"` BlurHash *string `json:"blurHash"` } 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 req.AvatarURL != nil { setParts = append(setParts, fmt.Sprintf("avatar_url = $%d", argIndex)) args = append(args, *req.AvatarURL) argIndex++ } if req.BlurHash != nil { setParts = append(setParts, fmt.Sprintf("blur_hash = $%d", argIndex)) args = append(args, *req.BlurHash) 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 } if req.AvatarURL != nil { metadata["avatarUrl"] = *req.AvatarURL } if req.BlurHash != nil { metadata["blurHash"] = *req.BlurHash } auditLogger.Log(r.Context(), audit.Entry{ UserID: &userID, Action: "profile_update", Success: true, Metadata: metadata, }) w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]string{"message": "Profile updated"}) } // 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 unique filename ext := filepath.Ext(header.Filename) if ext == "" { ext = ".jpg" // default extension } filename := fmt.Sprintf("%s%s", uuid.New().String(), ext) // Upload to Nextcloud client := storage.NewWebDAVClient(cfg) avatarPath := fmt.Sprintf("avatars/%s", filename) err = client.Upload(r.Context(), avatarPath, bytes.NewReader(fileBytes), header.Size) if err != nil { errors.LogError(r, err, "Failed to upload avatar") errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) return } // Get public URL - for now, construct it manually since Nextcloud doesn't provide direct public URLs // In a real setup, you'd configure Nextcloud to serve public URLs or use a CDN baseURL := cfg.NextcloudURL if !strings.HasSuffix(baseURL, "/") { baseURL += "/" } publicURL := fmt.Sprintf("%sindex.php/apps/files_sharing/public.php/webdav/avatars/%s", baseURL, filename) // Update user profile with avatar URL _, err = db.ExecContext(r.Context(), `UPDATE users SET avatar_url = $1, updated_at = NOW() WHERE id = $2`, publicURL, userID) if err != nil { errors.LogError(r, err, "Failed to update user avatar") errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) return } // Audit log auditLogger.Log(r.Context(), audit.Entry{ UserID: &userID, Action: "avatar_upload", Success: true, Metadata: map[string]interface{}{ "filename": filename, "size": header.Size, }, }) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Avatar uploaded successfully", "avatarUrl": publicURL, }) }