package http import ( "bytes" "encoding/json" "fmt" "io" "mime/multipart" "net/http" "net/url" "os" "path" "path/filepath" "strings" "time" "go.b0esche.cloud/backend/internal/audit" "go.b0esche.cloud/backend/internal/auth" "go.b0esche.cloud/backend/internal/config" "go.b0esche.cloud/backend/internal/database" "go.b0esche.cloud/backend/internal/errors" "go.b0esche.cloud/backend/internal/middleware" "go.b0esche.cloud/backend/internal/org" "go.b0esche.cloud/backend/internal/permission" "go.b0esche.cloud/backend/pkg/jwt" "github.com/go-chi/chi/v5" "github.com/google/uuid" "go.b0esche.cloud/backend/internal/storage" ) func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, authService *auth.Service, auditLogger *audit.Logger) http.Handler { r := chi.NewRouter() // optional WebDAV/Nextcloud client storageClient := storage.NewWebDAVClient(cfg) // Global middleware r.Use(middleware.RequestID) r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Use(middleware.CORS(cfg.AllowedOrigins)) r.Use(middleware.RateLimit) // Health check r.Get("/health", healthHandler) // 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, auditLogger) }) // Download user file r.Get("/user/files/download", func(w http.ResponseWriter, req *http.Request) { downloadUserFileHandler(w, req, db, storageClient) }) // Create / delete in user workspace r.Post("/user/files", func(w http.ResponseWriter, req *http.Request) { createUserFileHandler(w, req, db, auditLogger, storageClient) }) r.Delete("/user/files", func(w http.ResponseWriter, req *http.Request) { deleteUserFileHandler(w, req, db, auditLogger, storageClient) }) // POST wrapper for delete r.Post("/user/files/delete", func(w http.ResponseWriter, req *http.Request) { deleteUserFilePostHandler(w, req, db, auditLogger, storageClient) }) // 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, storageClient) }) // 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, storageClient) }) // 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, storageClient) }) // 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, storageClient) }) 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, 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) }) }) 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) }) }) }) // Close protected routes return r } 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("org").(uuid.UUID) // Query params: path, q (search), page, pageSize 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.GetOrgFiles(r.Context(), orgID, 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, auditLogger *audit.Logger) { userIDStr, _ := middleware.GetUserID(r.Context()) userID, _ := uuid.Parse(userIDStr) orgID := r.Context().Value("org").(uuid.UUID) 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 } // 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" } downloadPath := fmt.Sprintf("%s://%s/orgs/%s/files/download?path=%s", scheme, host, orgID.String(), url.QueryEscape(file.Path)) // Determine if it's a PDF based on file extension isPdf := strings.HasSuffix(strings.ToLower(file.Name), ".pdf") // Get JWT token from context token, _ := middleware.GetToken(r.Context()) session := struct { ViewUrl string `json:"viewUrl"` Token string `json:"token"` Capabilities struct { CanEdit bool `json:"canEdit"` CanAnnotate bool `json:"canAnnotate"` IsPdf bool `json:"isPdf"` } `json:"capabilities"` ExpiresAt string `json:"expiresAt"` }{ ViewUrl: downloadPath, Token: token, // JWT token for authenticating file download Capabilities: struct { CanEdit bool `json:"canEdit"` CanAnnotate bool `json:"canAnnotate"` IsPdf bool `json:"isPdf"` }{CanEdit: false, CanAnnotate: isPdf, IsPdf: isPdf}, ExpiresAt: time.Now().Add(15 * time.Minute).UTC().Format(time.RFC3339), } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(session) } // userViewerHandler serves a viewer session for personal workspace files func userViewerHandler(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") // 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 } // 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" } downloadPath := fmt.Sprintf("%s://%s/user/files/download?path=%s", scheme, host, url.QueryEscape(file.Path)) // Determine if it's a PDF based on file extension isPdf := strings.HasSuffix(strings.ToLower(file.Name), ".pdf") // Get JWT token from context token, _ := middleware.GetToken(r.Context()) session := struct { ViewUrl string `json:"viewUrl"` Token string `json:"token"` Capabilities struct { CanEdit bool `json:"canEdit"` CanAnnotate bool `json:"canAnnotate"` IsPdf bool `json:"isPdf"` } `json:"capabilities"` ExpiresAt string `json:"expiresAt"` }{ ViewUrl: downloadPath, Token: token, // JWT token for authenticating file download Capabilities: struct { CanEdit bool `json:"canEdit"` CanAnnotate bool `json:"canAnnotate"` IsPdf bool `json:"isPdf"` }{ CanEdit: false, CanAnnotate: isPdf, IsPdf: isPdf, }, ExpiresAt: time.Now().Add(15 * time.Minute).UTC().Format(time.RFC3339), } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(session) } 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("org").(uuid.UUID) 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 } // Log activity db.LogActivity(r.Context(), userID, orgID, &fileId, "edit_file", map[string]interface{}{}) // Build Collabora editor URL - Collabora needs the file download URL as the WOPISrc parameter editUrl := fmt.Sprintf("https://go.b0esche.cloud/orgs/%s/files/download?path=%s", orgID.String(), url.QueryEscape(file.Path)) collaboraUrl := fmt.Sprintf("https://of.b0esche.cloud/lool/dist/mobile/cool.html?WOPISrc=%s", url.QueryEscape(editUrl)) // Check if user can edit (for now, all org members can edit) readOnly := false // Get JWT token from context token, _ := middleware.GetToken(r.Context()) 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("org").(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("org").(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) } func listMembersHandler(w http.ResponseWriter, r *http.Request, db *database.DB) { orgID := r.Context().Value("org").(uuid.UUID) members, err := db.GetOrgMembers(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 } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(members) } func updateMemberRoleHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) { orgID := r.Context().Value("org").(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 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, storageClient *storage.WebDAVClient) { orgID := r.Context().Value("org").(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 = "/" } 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 (so we can attempt WebDAV upload and fallback to disk) 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 } // Attempt WebDAV upload when configured storedPath := filepath.ToSlash(filepath.Join(parentPath, header.Filename)) if !strings.HasPrefix(storedPath, "/") { storedPath = "/" + storedPath } written := int64(len(data)) if storageClient != nil { // Build remote path 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 { // Log and fallback to local disk errors.LogError(r, err, "WebDAV upload failed, falling back to local disk") } else { // success -> persist metadata and 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 } } // Fallback: Save to temp directory (WebDAV should be the primary storage) baseDir := filepath.Join("/tmp", "uploads", "orgs", orgID.String()) targetDir := filepath.Join(baseDir, parentPath) if err = os.MkdirAll(targetDir, 0o755); err != nil { errors.LogError(r, err, "Failed to create target dir in /tmp") errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) return } outPath := filepath.Join(targetDir, header.Filename) if err = os.WriteFile(outPath, data, 0o644); err != nil { errors.LogError(r, err, "Failed to write file to /tmp") errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) return } // Store metadata in DB; store path relative to workspace root storedPath = filepath.ToSlash(filepath.Join(parentPath, header.Filename)) if !strings.HasPrefix(storedPath, "/") { storedPath = "/" + storedPath } 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, storageClient *storage.WebDAVClient) { orgID := r.Context().Value("org").(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 } // Delete from Nextcloud if configured if storageClient != nil { 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, storageClient *storage.WebDAVClient) { deleteOrgFileHandler(w, r, db, auditLogger, storageClient) } // 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, storageClient *storage.WebDAVClient) { 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 = "/" } 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 and disk fallback 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) if storageClient != nil { rel := strings.TrimPrefix(storedPath, "/") remotePath := path.Join("/users", userID.String(), rel) fmt.Printf("[DEBUG] Uploading to 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, falling back to local disk") } else { 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 } } // Fallback: write to temp directory (WebDAV should be the primary storage) fmt.Printf("[DEBUG] WebDAV is nil or failed, using local storage fallback\n") baseDir := filepath.Join("/tmp", "uploads", "users", userID.String()) targetDir := filepath.Join(baseDir, parentPath) fmt.Printf("[DEBUG] Creating directory: %s\n", targetDir) if err = os.MkdirAll(targetDir, 0o755); err != nil { errors.LogError(r, err, "Failed to create target dir in /tmp") errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) return } outPath := filepath.Join(targetDir, header.Filename) fmt.Printf("[DEBUG] Writing file to: %s\n", outPath) if err = os.WriteFile(outPath, data, 0o644); err != nil { fmt.Printf("[DEBUG] Failed to write file: %v\n", err) errors.LogError(r, err, "Failed to write file to /tmp") errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) return } fmt.Printf("[DEBUG] File written successfully to local storage: %s\n", outPath) 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, storageClient *storage.WebDAVClient) { deleteUserFileHandler(w, r, db, auditLogger, storageClient) } // 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, storageClient *storage.WebDAVClient) { 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 } // Delete from Nextcloud if configured if storageClient != nil { rel := strings.TrimPrefix(req.Path, "/") // Keep remote user workspace path consistent with uploads: "/users//" remotePath := path.Join("/users", userID.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(), 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, storageClient *storage.WebDAVClient) { orgID := r.Context().Value("org").(uuid.UUID) filePath := r.URL.Query().Get("path") if filePath == "" { errors.WriteError(w, errors.CodeInvalidArgument, "Missing path parameter", http.StatusBadRequest) return } // Try to download from Nextcloud first if storageClient != nil { rel := strings.TrimPrefix(filePath, "/") remotePath := path.Join("/orgs", orgID.String(), rel) reader, size, err := storageClient.Download(r.Context(), remotePath) if err == nil { defer reader.Close() // Set appropriate headers for inline viewing fileName := path.Base(filePath) // Determine content type based on file extension contentType := "application/octet-stream" if strings.HasSuffix(strings.ToLower(fileName), ".pdf") { contentType = "application/pdf" } else if strings.HasSuffix(strings.ToLower(fileName), ".png") { contentType = "image/png" } else if strings.HasSuffix(strings.ToLower(fileName), ".jpg") || strings.HasSuffix(strings.ToLower(fileName), ".jpeg") { contentType = "image/jpeg" } w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", fileName)) w.Header().Set("Content-Type", contentType) if size > 0 { w.Header().Set("Content-Length", fmt.Sprintf("%d", size)) } // Stream the file io.Copy(w, reader) return } errors.LogError(r, err, "Failed to download from Nextcloud, trying local storage") } // Fallback to local disk (used when WebDAV is not configured) baseDir := filepath.Clean(filepath.Join("/tmp/uploads/orgs", orgID.String())) rel := strings.TrimPrefix(filePath, "/") localPath := filepath.Join(baseDir, rel) // Prevent path traversal escaping the baseDir if !strings.HasPrefix(localPath, baseDir+string(os.PathSeparator)) && localPath != baseDir { errors.WriteError(w, errors.CodeInvalidArgument, "Invalid path", http.StatusBadRequest) return } f, err := os.Open(localPath) if err != nil { errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound) return } defer f.Close() info, _ := f.Stat() fileName := path.Base(filePath) contentType := "application/octet-stream" if strings.HasSuffix(strings.ToLower(fileName), ".pdf") { contentType = "application/pdf" } else if strings.HasSuffix(strings.ToLower(fileName), ".png") { contentType = "image/png" } else if strings.HasSuffix(strings.ToLower(fileName), ".jpg") || strings.HasSuffix(strings.ToLower(fileName), ".jpeg") { contentType = "image/jpeg" } w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", fileName)) w.Header().Set("Content-Type", contentType) if info != nil { w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size())) } io.Copy(w, f) } // downloadUserFileHandler downloads a file from user's personal workspace func downloadUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, storageClient *storage.WebDAVClient) { userIDStr, ok := middleware.GetUserID(r.Context()) if !ok || userIDStr == "" { 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 } // Log the requested file path for debugging fmt.Printf("[DEBUG] Download request - User: %s, Path: %s\n", userID.String(), filePath) // Try to download from Nextcloud first if storageClient != nil { rel := strings.TrimPrefix(filePath, "/") // Keep remote user workspace path consistent with uploads: "/users//" remotePath := path.Join("/users", userID.String(), rel) fmt.Printf("[DEBUG] Trying WebDAV path: %s\n", remotePath) reader, size, err := storageClient.Download(r.Context(), remotePath) if err == nil { defer reader.Close() // Set appropriate headers for inline viewing fileName := path.Base(filePath) // Determine content type based on file extension contentType := "application/octet-stream" if strings.HasSuffix(strings.ToLower(fileName), ".pdf") { contentType = "application/pdf" } else if strings.HasSuffix(strings.ToLower(fileName), ".png") { contentType = "image/png" } else if strings.HasSuffix(strings.ToLower(fileName), ".jpg") || strings.HasSuffix(strings.ToLower(fileName), ".jpeg") { contentType = "image/jpeg" } w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", fileName)) w.Header().Set("Content-Type", contentType) if size > 0 { w.Header().Set("Content-Length", fmt.Sprintf("%d", size)) } // Stream the file io.Copy(w, reader) return } errors.LogError(r, err, "Failed to download from Nextcloud, trying local storage") } // Fallback to local disk (used when WebDAV is not configured) baseDir := filepath.Clean(filepath.Join("/tmp/uploads/users", userID.String())) rel := strings.TrimPrefix(filePath, "/") localPath := filepath.Join(baseDir, rel) fmt.Printf("[DEBUG] Trying local path: %s\n", localPath) // Prevent path traversal escaping the baseDir if !strings.HasPrefix(localPath, baseDir+string(os.PathSeparator)) && localPath != baseDir { errors.WriteError(w, errors.CodeInvalidArgument, "Invalid path", http.StatusBadRequest) return } f, err := os.Open(localPath) if err != nil { errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound) return } defer f.Close() info, _ := f.Stat() fileName := path.Base(filePath) contentType := "application/octet-stream" if strings.HasSuffix(strings.ToLower(fileName), ".pdf") { contentType = "application/pdf" } else if strings.HasSuffix(strings.ToLower(fileName), ".png") { contentType = "image/png" } else if strings.HasSuffix(strings.ToLower(fileName), ".jpg") || strings.HasSuffix(strings.ToLower(fileName), ".jpeg") { contentType = "image/jpeg" } w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", fileName)) w.Header().Set("Content-Type", contentType) if info != nil { w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size())) } io.Copy(w, f) }