diff --git a/b0esche_cloud/lib/pages/document_viewer.dart b/b0esche_cloud/lib/pages/document_viewer.dart index e73457a..bdbd58d 100644 --- a/b0esche_cloud/lib/pages/document_viewer.dart +++ b/b0esche_cloud/lib/pages/document_viewer.dart @@ -9,6 +9,200 @@ import '../injection.dart'; import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; import 'package:go_router/go_router.dart'; +// Modal version for overlay display +class DocumentViewerModal extends StatefulWidget { + final String orgId; + final String fileId; + final VoidCallback onClose; + final VoidCallback onEdit; + + const DocumentViewerModal({ + super.key, + required this.orgId, + required this.fileId, + required this.onClose, + required this.onEdit, + }); + + @override + State createState() => _DocumentViewerModalState(); +} + +class _DocumentViewerModalState extends State { + late DocumentViewerBloc _viewerBloc; + + @override + void initState() { + super.initState(); + _viewerBloc = DocumentViewerBloc(getIt()); + _viewerBloc.add(DocumentOpened(orgId: widget.orgId, fileId: widget.fileId)); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _viewerBloc, + child: Column( + children: [ + // Custom AppBar + Container( + height: 56, + decoration: BoxDecoration( + color: AppTheme.primaryBackground.withValues(alpha: 0.9), + border: Border( + bottom: BorderSide( + color: AppTheme.accentColor.withValues(alpha: 0.3), + ), + ), + ), + child: Row( + children: [ + const SizedBox(width: 16), + const Text( + 'Document Viewer', + style: TextStyle( + color: AppTheme.primaryText, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + BlocBuilder( + builder: (context, state) { + if (state is DocumentViewerReady && state.caps.canEdit) { + return IconButton( + icon: const Icon( + Icons.edit, + color: AppTheme.primaryText, + ), + onPressed: widget.onEdit, + ); + } + return const SizedBox.shrink(); + }, + ), + IconButton( + icon: const Icon(Icons.refresh, color: AppTheme.primaryText), + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + onPressed: () { + _viewerBloc.add(DocumentReloaded()); + }, + ), + IconButton( + icon: const Icon(Icons.close, color: AppTheme.primaryText), + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + onPressed: () { + _viewerBloc.add(DocumentClosed()); + widget.onClose(); + }, + ), + const SizedBox(width: 8), + ], + ), + ), + // Meta info bar + BlocBuilder( + builder: (context, state) { + if (state is DocumentViewerReady) { + return Container( + height: 30, + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: AppTheme.primaryBackground.withValues(alpha: 0.3), + ), + child: const Text( + 'Last modified: Unknown by Unknown (v1)', + style: TextStyle( + fontSize: 12, + color: AppTheme.secondaryText, + ), + ), + ); + } + return const SizedBox.shrink(); + }, + ), + // Document content + Expanded( + child: BlocBuilder( + builder: (context, state) { + if (state is DocumentViewerLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (state is DocumentViewerError) { + return Center( + child: Text( + 'Error: ${state.message}', + style: const TextStyle(color: AppTheme.primaryText), + ), + ); + } + if (state is DocumentViewerSessionExpired) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'Your viewing session expired. Click to reopen.', + style: TextStyle(color: AppTheme.primaryText), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + _viewerBloc.add( + DocumentOpened( + orgId: widget.orgId, + fileId: widget.fileId, + ), + ); + }, + child: const Text('Reload'), + ), + ], + ), + ); + } + if (state is DocumentViewerReady) { + if (state.caps.isPdf) { + return SfPdfViewer.network(state.viewUrl.toString()); + } else { + return Container( + color: AppTheme.secondaryText, + child: Center( + child: Text( + 'Office Document Viewer\\n(URL: ${state.viewUrl})', + textAlign: TextAlign.center, + style: const TextStyle(color: AppTheme.primaryText), + ), + ), + ); + } + } + return const Center( + child: Text( + 'No document loaded', + style: TextStyle(color: AppTheme.primaryText), + ), + ); + }, + ), + ), + ], + ), + ); + } + + @override + void dispose() { + _viewerBloc.close(); + super.dispose(); + } +} + +// Original page version (for routing if needed) class DocumentViewer extends StatefulWidget { final String orgId; final String fileId; diff --git a/b0esche_cloud/lib/pages/editor_page.dart b/b0esche_cloud/lib/pages/editor_page.dart index 8253a0b..2772c53 100644 --- a/b0esche_cloud/lib/pages/editor_page.dart +++ b/b0esche_cloud/lib/pages/editor_page.dart @@ -8,6 +8,161 @@ import '../services/file_service.dart'; import '../injection.dart'; import 'package:go_router/go_router.dart'; +// Modal version for overlay display +class EditorPageModal extends StatefulWidget { + final String orgId; + final String fileId; + final VoidCallback onClose; + + const EditorPageModal({ + super.key, + required this.orgId, + required this.fileId, + required this.onClose, + }); + + @override + State createState() => _EditorPageModalState(); +} + +class _EditorPageModalState extends State { + late EditorSessionBloc _editorBloc; + + @override + void initState() { + super.initState(); + _editorBloc = EditorSessionBloc(getIt()); + _editorBloc.add( + EditorSessionStarted(orgId: widget.orgId, fileId: widget.fileId), + ); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _editorBloc, + child: Column( + children: [ + // Custom AppBar + Container( + height: 56, + decoration: BoxDecoration( + color: AppTheme.primaryBackground.withValues(alpha: 0.9), + border: Border( + bottom: BorderSide( + color: AppTheme.accentColor.withValues(alpha: 0.3), + ), + ), + ), + child: Row( + children: [ + const SizedBox(width: 16), + const Text( + 'Document Editor', + style: TextStyle( + color: AppTheme.primaryText, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, color: AppTheme.primaryText), + onPressed: () { + _editorBloc.add(EditorSessionEnded()); + widget.onClose(); + }, + ), + const SizedBox(width: 8), + ], + ), + ), + // Editor content + Expanded( + child: BlocBuilder( + builder: (context, state) { + if (state is EditorSessionStarting) { + return const Center(child: CircularProgressIndicator()); + } + if (state is EditorSessionFailed) { + return Center( + child: Text( + 'Error: ${state.message}', + style: const TextStyle(color: AppTheme.primaryText), + ), + ); + } + if (state is EditorSessionActive) { + return Container( + color: AppTheme.secondaryText, + child: Center( + child: Text( + 'Collabora Editor Active\\n(URL: ${state.editUrl})', + textAlign: TextAlign.center, + style: const TextStyle(color: AppTheme.primaryText), + ), + ), + ); + } + if (state is EditorSessionReadOnly) { + return Container( + color: AppTheme.secondaryText, + child: Center( + child: Text( + 'Read Only Mode\\n(URL: ${state.viewUrl})', + textAlign: TextAlign.center, + style: const TextStyle(color: AppTheme.primaryText), + ), + ), + ); + } + if (state is EditorSessionExpired) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'Editing session expired.', + style: TextStyle(color: AppTheme.primaryText), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + _editorBloc.add( + EditorSessionStarted( + orgId: widget.orgId, + fileId: widget.fileId, + ), + ); + }, + child: const Text('Reopen'), + ), + ], + ), + ); + } + return const Center( + child: Text( + 'No editor session', + style: TextStyle(color: AppTheme.primaryText), + ), + ); + }, + ), + ), + ], + ), + ); + } + + @override + void dispose() { + _editorBloc.close(); + super.dispose(); + } +} + +// Original page version (for routing if needed) class EditorPage extends StatefulWidget { final String orgId; final String fileId; diff --git a/b0esche_cloud/lib/pages/file_explorer.dart b/b0esche_cloud/lib/pages/file_explorer.dart index 7a70ec7..5c4948b 100644 --- a/b0esche_cloud/lib/pages/file_explorer.dart +++ b/b0esche_cloud/lib/pages/file_explorer.dart @@ -1,8 +1,9 @@ +import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:file_picker/file_picker.dart' hide FileType; -import 'package:go_router/go_router.dart'; import 'package:path/path.dart' as p; +import 'dart:html' as html; import '../blocs/file_browser/file_browser_bloc.dart'; import '../blocs/file_browser/file_browser_event.dart'; import '../blocs/file_browser/file_browser_state.dart'; @@ -14,6 +15,10 @@ import '../blocs/upload/upload_event.dart'; import '../models/file_item.dart'; import '../theme/app_theme.dart'; import '../theme/modern_glass_button.dart'; +import 'document_viewer.dart'; +import 'editor_page.dart'; +import '../injection.dart'; +import '../services/file_service.dart'; class FileExplorer extends StatefulWidget { final String orgId; @@ -258,10 +263,35 @@ class _FileExplorerState extends State { ); } - void _downloadFile(FileItem file) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Download ${file.name}'))); + void _downloadFile(FileItem file) async { + try { + final fileService = getIt(); + final downloadUrl = await fileService.getDownloadUrl( + widget.orgId, + file.path, + ); + + // For web, use the download URL with the base URL + final baseUrl = 'https://go.b0esche.cloud'; // Should come from config + final fullUrl = '$baseUrl$downloadUrl'; + + // Trigger download via anchor element + html.AnchorElement(href: fullUrl) + ..setAttribute('download', file.name) + ..click(); + + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Downloading ${file.name}'))); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Download failed: $e'))); + } + } } void _sendFile(FileItem file) { @@ -533,7 +563,7 @@ class _FileExplorerState extends State { final fileId = file.path.startsWith('/') ? file.path.substring(1) : file.path; - context.go('/viewer/${widget.orgId}/$fileId'); + _showDocumentViewer(widget.orgId, fileId); } }, child: Container( @@ -1018,4 +1048,100 @@ class _FileExplorerState extends State { }, ); } + + void _showDocumentViewer(String orgId, String fileId) { + showDialog( + context: context, + barrierDismissible: false, + barrierColor: Colors.transparent, + builder: (BuildContext context) { + return BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Dialog( + backgroundColor: Colors.transparent, + insetPadding: const EdgeInsets.all(16), + child: Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.9, + maxHeight: MediaQuery.of(context).size.height * 0.9, + ), + decoration: BoxDecoration( + color: AppTheme.primaryBackground, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: AppTheme.accentColor.withValues(alpha: 0.3), + width: 2, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.5), + blurRadius: 20, + spreadRadius: 5, + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: DocumentViewerModal( + orgId: orgId, + fileId: fileId, + onEdit: () { + Navigator.of(context).pop(); + _showDocumentEditor(orgId, fileId); + }, + onClose: () => Navigator.of(context).pop(), + ), + ), + ), + ), + ); + }, + ); + } + + void _showDocumentEditor(String orgId, String fileId) { + showDialog( + context: context, + barrierDismissible: false, + barrierColor: Colors.transparent, + builder: (BuildContext context) { + return BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Dialog( + backgroundColor: Colors.transparent, + insetPadding: const EdgeInsets.all(16), + child: Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.9, + maxHeight: MediaQuery.of(context).size.height * 0.9, + ), + decoration: BoxDecoration( + color: AppTheme.primaryBackground, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: AppTheme.accentColor.withValues(alpha: 0.3), + width: 2, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.5), + blurRadius: 20, + spreadRadius: 5, + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: EditorPageModal( + orgId: orgId, + fileId: fileId, + onClose: () => Navigator.of(context).pop(), + ), + ), + ), + ), + ); + }, + ); + } } diff --git a/b0esche_cloud/lib/services/file_service.dart b/b0esche_cloud/lib/services/file_service.dart index f4bedf8..8711428 100644 --- a/b0esche_cloud/lib/services/file_service.dart +++ b/b0esche_cloud/lib/services/file_service.dart @@ -112,6 +112,14 @@ class FileService { ); } + Future getDownloadUrl(String orgId, String path) async { + // Return the download URL for the file + if (orgId.isEmpty) { + return '/user/files/download?path=${Uri.encodeComponent(path)}'; + } + return '/orgs/$orgId/files/download?path=${Uri.encodeComponent(path)}'; + } + Future createFolder( String orgId, String parentPath, diff --git a/go_cloud/api b/go_cloud/api index aa383ce..adf1425 100755 Binary files a/go_cloud/api and b/go_cloud/api differ diff --git a/go_cloud/internal/auth/passkey.go b/go_cloud/internal/auth/passkey.go index 5576ce2..6f3709f 100644 --- a/go_cloud/internal/auth/passkey.go +++ b/go_cloud/internal/auth/passkey.go @@ -6,9 +6,11 @@ import ( "encoding/base64" "encoding/json" "fmt" + "strings" "github.com/google/uuid" "go.b0esche.cloud/backend/internal/database" + "golang.org/x/crypto/argon2" "golang.org/x/crypto/bcrypt" ) @@ -17,6 +19,12 @@ const ( RPID = "b0esche.cloud" RPName = "b0esche Cloud" Origin = "https://b0esche.cloud" + + // Argon2id parameters (OWASP recommendations) + Argon2Time = 2 // iterations + Argon2Memory = 19 * 1024 // 19 MB + Argon2Threads = 1 + Argon2KeyLen = 32 ) type Service struct { @@ -284,19 +292,76 @@ func byteArraysEqual(a, b []byte) bool { return true } -// HashPassword hashes a password using bcrypt +// HashPassword hashes a password using Argon2id (quantum-resistant) +// Format: $argon2id$v=19$m=19456,t=2,p=1$$ func (s *Service) HashPassword(password string) (string, error) { - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - return "", fmt.Errorf("failed to hash password: %w", err) + // Generate 16-byte random salt + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("failed to generate salt: %w", err) } - return string(hash), nil + + // Hash with Argon2id + hash := argon2.IDKey([]byte(password), salt, Argon2Time, Argon2Memory, Argon2Threads, Argon2KeyLen) + + // Encode in PHC string format + b64Salt := base64.RawStdEncoding.EncodeToString(salt) + b64Hash := base64.RawStdEncoding.EncodeToString(hash) + + return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", + Argon2Memory, Argon2Time, Argon2Threads, b64Salt, b64Hash), nil } // VerifyPassword checks if a password matches its hash +// Supports both Argon2id (new) and bcrypt (legacy) for backward compatibility func (s *Service) VerifyPassword(passwordHash string, password string) bool { - err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)) - return err == nil + // Detect hash format + if strings.HasPrefix(passwordHash, "$argon2id$") { + return s.verifyArgon2(passwordHash, password) + } else if strings.HasPrefix(passwordHash, "$2") { + // Legacy bcrypt hash + err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)) + return err == nil + } + return false +} + +func (s *Service) verifyArgon2(encodedHash string, password string) bool { + // Parse PHC format: $argon2id$v=19$m=19456,t=2,p=1$$ + parts := strings.Split(encodedHash, "$") + if len(parts) != 6 { + return false + } + + var memory, time uint32 + var threads uint8 + _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads) + if err != nil { + return false + } + + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false + } + + hash, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return false + } + + // Compute hash with same parameters + computedHash := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(hash))) + + // Constant-time comparison + if len(hash) != len(computedHash) { + return false + } + var diff byte + for i := 0; i < len(hash); i++ { + diff |= hash[i] ^ computedHash[i] + } + return diff == 0 } // VerifyPasswordLogin verifies username and password credentials diff --git a/go_cloud/internal/http/routes.go b/go_cloud/internal/http/routes.go index a1313ae..506625a 100644 --- a/go_cloud/internal/http/routes.go +++ b/go_cloud/internal/http/routes.go @@ -78,16 +78,20 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut r.Get("/user/files", func(w http.ResponseWriter, req *http.Request) { userFilesHandler(w, req, db) }) + // 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) + 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) + deleteUserFilePostHandler(w, req, db, auditLogger, storageClient) }) // Org routes @@ -106,6 +110,10 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut 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) { @@ -113,12 +121,12 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut }) // 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) + 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) + 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) { @@ -991,7 +999,7 @@ func createOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.D } // deleteOrgFileHandler deletes a file/folder in org workspace by path -func deleteOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) { +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, _ := r.Context().Value("user").(string) userID, _ := uuid.Parse(userIDStr) @@ -1004,6 +1012,16 @@ func deleteOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.D 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) @@ -1022,8 +1040,8 @@ func deleteOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.D } // 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) { - deleteOrgFileHandler(w, r, db, auditLogger) +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. @@ -1154,12 +1172,12 @@ func createUserFileHandler(w http.ResponseWriter, r *http.Request, db *database. } // Also accept POST /user/files/delete -func deleteUserFilePostHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) { - deleteUserFileHandler(w, r, db, auditLogger) +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) { +func deleteUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, storageClient *storage.WebDAVClient) { userIDStr, ok := r.Context().Value("user").(string) if !ok || userIDStr == "" { errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized) @@ -1175,6 +1193,16 @@ func deleteUserFileHandler(w http.ResponseWriter, r *http.Request, db *database. return } + // Delete from Nextcloud if configured + if storageClient != nil { + rel := strings.TrimPrefix(req.Path, "/") + remotePath := path.Join("/user", 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) @@ -1190,3 +1218,86 @@ func deleteUserFileHandler(w http.ResponseWriter, r *http.Request, db *database. 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 + fileName := path.Base(filePath) + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName)) + w.Header().Set("Content-Type", "application/octet-stream") + 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 storage (if implemented) + errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound) +} + +// 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 := r.Context().Value("user").(string) + 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 + } + + // Try to download from Nextcloud first + if storageClient != nil { + rel := strings.TrimPrefix(filePath, "/") + remotePath := path.Join("/user", userID.String(), rel) + + reader, size, err := storageClient.Download(r.Context(), remotePath) + if err == nil { + defer reader.Close() + + // Set appropriate headers + fileName := path.Base(filePath) + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName)) + w.Header().Set("Content-Type", "application/octet-stream") + 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 storage (if implemented) + errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound) +} diff --git a/go_cloud/internal/storage/webdav.go b/go_cloud/internal/storage/webdav.go index 6630817..1a52c4a 100644 --- a/go_cloud/internal/storage/webdav.go +++ b/go_cloud/internal/storage/webdav.go @@ -111,3 +111,80 @@ func (c *WebDAVClient) Upload(ctx context.Context, remotePath string, r io.Reade body, _ := io.ReadAll(resp.Body) return fmt.Errorf("webdav upload failed: %d %s", resp.StatusCode, string(body)) } + +// Download retrieves a file from the remotePath using HTTP GET (WebDAV). +func (c *WebDAVClient) Download(ctx context.Context, remotePath string) (io.ReadCloser, int64, error) { + if c == nil { + return nil, 0, fmt.Errorf("no webdav client configured") + } + + rel := strings.TrimLeft(remotePath, "/") + u := c.basePrefix + if u == "/" || u == "" { + u = "/" + } + full := fmt.Sprintf("%s%s/%s", c.baseURL, u, url.PathEscape(rel)) + full = strings.ReplaceAll(full, "%2F", "/") + + req, err := http.NewRequestWithContext(ctx, "GET", full, nil) + if err != nil { + return nil, 0, err + } + if c.user != "" { + req.SetBasicAuth(c.user, c.pass) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, 0, err + } + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return resp.Body, resp.ContentLength, nil + } + + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return nil, 0, fmt.Errorf("webdav download failed: %d %s", resp.StatusCode, string(body)) +} + +// Delete removes a file or collection from the remotePath using HTTP DELETE (WebDAV). +func (c *WebDAVClient) Delete(ctx context.Context, remotePath string) error { + if c == nil { + return fmt.Errorf("no webdav client configured") + } + + rel := strings.TrimLeft(remotePath, "/") + u := c.basePrefix + if u == "/" || u == "" { + u = "/" + } + full := fmt.Sprintf("%s%s/%s", c.baseURL, u, url.PathEscape(rel)) + full = strings.ReplaceAll(full, "%2F", "/") + + req, err := http.NewRequestWithContext(ctx, "DELETE", full, nil) + if err != nil { + return err + } + if c.user != "" { + req.SetBasicAuth(c.user, c.pass) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + + // 404 means already deleted, consider it success + if resp.StatusCode == 404 { + return nil + } + + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("webdav delete failed: %d %s", resp.StatusCode, string(body)) +}