diff --git a/b0esche_cloud/lib/main.dart b/b0esche_cloud/lib/main.dart index ba2961d..828abb3 100644 --- a/b0esche_cloud/lib/main.dart +++ b/b0esche_cloud/lib/main.dart @@ -12,6 +12,7 @@ import 'pages/file_explorer.dart'; import 'pages/document_viewer.dart'; import 'pages/editor_page.dart'; import 'pages/join_page.dart'; +import 'pages/public_file_viewer.dart'; import 'theme/app_theme.dart'; import 'injection.dart'; @@ -42,6 +43,11 @@ final GoRouter _router = GoRouter( builder: (context, state) => JoinPage(token: state.uri.queryParameters['token'] ?? ''), ), + GoRoute( + path: '/share/:token', + builder: (context, state) => + PublicFileViewer(token: state.pathParameters['token']!), + ), ], ); diff --git a/b0esche_cloud/lib/pages/file_explorer.dart b/b0esche_cloud/lib/pages/file_explorer.dart index 2fbb496..57b8970 100644 --- a/b0esche_cloud/lib/pages/file_explorer.dart +++ b/b0esche_cloud/lib/pages/file_explorer.dart @@ -26,6 +26,7 @@ import 'document_viewer.dart'; import 'video_viewer.dart'; import '../injection.dart'; import '../services/file_service.dart'; +import '../widgets/share_file_dialog.dart'; typedef AudioFileSelectedCallback = void Function(String fileName, String fileUrl); @@ -815,10 +816,15 @@ class _FileExplorerState extends State } } - void _sendFile(FileItem file) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Send ${file.name}'))); + void _shareFile(FileItem file) { + showDialog( + context: context, + builder: (BuildContext context) => ShareFileDialog( + orgId: widget.orgId, + fileId: file.id!, + fileName: file.name, + ), + ); } Future _deleteFile(FileItem file) async { @@ -1304,10 +1310,10 @@ class _FileExplorerState extends State onPressed: () => _downloadFile(file), ), IconButton( - icon: const Icon(Icons.send, color: AppTheme.secondaryText), + icon: const Icon(Icons.share, color: AppTheme.secondaryText), splashColor: Colors.transparent, highlightColor: Colors.transparent, - onPressed: () => _sendFile(file), + onPressed: () => _shareFile(file), ), IconButton( icon: const Icon(Icons.delete, color: AppTheme.secondaryText), diff --git a/b0esche_cloud/lib/pages/public_file_viewer.dart b/b0esche_cloud/lib/pages/public_file_viewer.dart new file mode 100644 index 0000000..1db6078 --- /dev/null +++ b/b0esche_cloud/lib/pages/public_file_viewer.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:http/http.dart' as http; +import 'dart:js_interop'; +import 'package:web/web.dart' as web; +import 'dart:typed_data'; +import '../theme/app_theme.dart'; +import '../services/api_client.dart'; +import '../injection.dart'; + +class PublicFileViewer extends StatefulWidget { + final String token; + + const PublicFileViewer({super.key, required this.token}); + + @override + State createState() => _PublicFileViewerState(); +} + +class _PublicFileViewerState extends State { + bool _isLoading = true; + String? _error; + Map? _fileData; + + @override + void initState() { + super.initState(); + _loadFileData(); + } + + Future _loadFileData() async { + try { + final apiClient = getIt(); + final response = await apiClient.getRaw('/public/share/${widget.token}'); + + setState(() { + _fileData = response; + _isLoading = false; + }); + } catch (e) { + setState(() { + _error = 'This link is invalid or has expired.'; + _isLoading = false; + }); + } + } + + void _downloadFile() { + if (_fileData != null && _fileData!['downloadUrl'] != null) { + // Use http package to download + http.get(Uri.parse(_fileData!['downloadUrl'])).then((response) { + if (response.statusCode == 200) { + // Trigger download in web + final uint8List = Uint8List.fromList(response.bodyBytes); + final jsUint8Array = JSUint8Array( + uint8List.buffer.toJS, + uint8List.offsetInBytes, + uint8List.length, + ); + final jsArray = JSArray.withLength(1); + jsArray[0] = jsUint8Array; + final blob = web.Blob(jsArray); + final url = web.URL.createObjectURL(blob); + final anchor = web.HTMLAnchorElement() + ..href = url + ..download = _fileData!['fileName'] ?? 'download'; + anchor.click(); + web.URL.revokeObjectURL(url); + } + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.primaryBackground, + appBar: AppBar( + backgroundColor: AppTheme.primaryBackground, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.close, color: AppTheme.primaryText), + onPressed: () => context.go('/'), + ), + title: Text( + _fileData?['fileName'] ?? 'Shared File', + style: TextStyle(color: AppTheme.primaryText), + ), + actions: [ + if (_fileData != null) + IconButton( + icon: const Icon(Icons.download, color: AppTheme.primaryText), + onPressed: _downloadFile, + ), + ], + ), + body: Center( + child: _isLoading + ? const CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(AppTheme.accentColor), + ) + : _error != null + ? Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.link_off, size: 64, color: Colors.red[400]), + const SizedBox(height: 16), + Text( + _error!, + style: TextStyle( + color: AppTheme.primaryText, + fontSize: 18, + ), + textAlign: TextAlign.center, + ), + ], + ), + ) + : _fileData != null + ? Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.insert_drive_file, + size: 64, + color: AppTheme.primaryText, + ), + const SizedBox(height: 16), + Text( + _fileData!['fileName'] ?? 'Unknown file', + style: TextStyle( + color: AppTheme.primaryText, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Size: ${(_fileData!['fileSize'] ?? 0) ~/ 1024} KB', + style: TextStyle(color: AppTheme.secondaryText), + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: _downloadFile, + icon: const Icon(Icons.download), + label: const Text('Download File'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.accentColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + ), + ), + ], + ), + ) + : const SizedBox(), + ), + ); + } +} diff --git a/b0esche_cloud/lib/services/api_client.dart b/b0esche_cloud/lib/services/api_client.dart index b5fe31b..6fcacae 100644 --- a/b0esche_cloud/lib/services/api_client.dart +++ b/b0esche_cloud/lib/services/api_client.dart @@ -97,6 +97,15 @@ class ApiClient { } } + Future> postRaw(String path, {dynamic data}) async { + try { + final response = await _dio.post(path, data: data); + return response.data; + } on DioException catch (e) { + throw _handleError(e); + } + } + Future patch( String path, { dynamic data, diff --git a/b0esche_cloud/lib/widgets/share_file_dialog.dart b/b0esche_cloud/lib/widgets/share_file_dialog.dart new file mode 100644 index 0000000..211501c --- /dev/null +++ b/b0esche_cloud/lib/widgets/share_file_dialog.dart @@ -0,0 +1,239 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../services/api_client.dart'; +import '../theme/app_theme.dart'; +import '../injection.dart'; + +class ShareFileDialog extends StatefulWidget { + final String orgId; + final String fileId; + final String fileName; + + const ShareFileDialog({ + super.key, + required this.orgId, + required this.fileId, + required this.fileName, + }); + + @override + State createState() => _ShareFileDialogState(); +} + +class _ShareFileDialogState extends State { + bool _isLoading = true; + String? _shareUrl; + String? _error; + + @override + void initState() { + super.initState(); + _loadShareLink(); + } + + Future _loadShareLink() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final apiClient = getIt(); + final response = await apiClient.getRaw( + '/orgs/${widget.orgId}/files/${widget.fileId}/share', + ); + + if (response['exists'] == true) { + setState(() { + _shareUrl = response['url']; + _isLoading = false; + }); + } else { + setState(() { + _shareUrl = null; + _isLoading = false; + }); + } + } catch (e) { + setState(() { + _error = 'Failed to load share link'; + _isLoading = false; + }); + } + } + + Future _createShareLink() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final apiClient = getIt(); + final response = await apiClient.postRaw( + '/orgs/${widget.orgId}/files/${widget.fileId}/share', + data: {}, + ); + + setState(() { + _shareUrl = response['url']; + _isLoading = false; + }); + } catch (e) { + setState(() { + _error = 'Failed to create share link'; + _isLoading = false; + }); + } + } + + Future _revokeShareLink() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final apiClient = getIt(); + await apiClient.delete( + '/orgs/${widget.orgId}/files/${widget.fileId}/share', + ); + + setState(() { + _shareUrl = null; + _isLoading = false; + }); + } catch (e) { + setState(() { + _error = 'Failed to revoke share link'; + _isLoading = false; + }); + } + } + + void _copyToClipboard() { + if (_shareUrl != null) { + Clipboard.setData(ClipboardData(text: _shareUrl!)); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Link copied to clipboard'))); + } + } + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: Colors.transparent, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), + child: Container( + decoration: AppTheme.glassDecoration, + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Share "${widget.fileName}"', + style: TextStyle( + color: AppTheme.primaryText, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + if (_isLoading) + const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + AppTheme.accentColor, + ), + ), + ) + else if (_error != null) + Text(_error!, style: TextStyle(color: Colors.red[400])) + else if (_shareUrl == null) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'No share link exists for this file.', + style: TextStyle(color: AppTheme.secondaryText), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _createShareLink, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.accentColor, + foregroundColor: Colors.white, + ), + child: const Text('Create Share Link'), + ), + ], + ) + else + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Share link created. Anyone with this link can view and download the file.', + style: TextStyle(color: AppTheme.secondaryText), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Expanded( + child: Text( + _shareUrl!, + style: TextStyle( + color: AppTheme.primaryText, + fontFamily: 'monospace', + fontSize: 14, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon( + Icons.copy, + color: AppTheme.accentColor, + ), + onPressed: _copyToClipboard, + tooltip: 'Copy link', + ), + ], + ), + ), + const SizedBox(height: 16), + Row( + children: [ + TextButton( + onPressed: _revokeShareLink, + style: TextButton.styleFrom( + foregroundColor: Colors.red[400], + ), + child: const Text('Revoke Link'), + ), + const Spacer(), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/go_cloud/api b/go_cloud/api index 44ce4d2..4ae50de 100755 Binary files a/go_cloud/api and b/go_cloud/api differ diff --git a/go_cloud/internal/database/db.go b/go_cloud/internal/database/db.go index 1f61280..5828878 100644 --- a/go_cloud/internal/database/db.go +++ b/go_cloud/internal/database/db.go @@ -9,6 +9,7 @@ import ( "time" "github.com/google/uuid" + "go.b0esche.cloud/backend/internal/models" ) type DB struct { @@ -1149,4 +1150,69 @@ func (db *DB) MarkChallengeUsed(ctx context.Context, challenge []byte) error { return err } -// UpdateFileSize updates the size and last_modified timestamp of a file +// FileShareLink methods + +// CreateFileShareLink creates a new share link for a file +func (db *DB) CreateFileShareLink(ctx context.Context, token string, fileID, orgID, createdByUserID uuid.UUID) (*models.FileShareLink, error) { + var link models.FileShareLink + err := db.QueryRowContext(ctx, ` + INSERT INTO file_share_links (token, file_id, org_id, created_by_user_id) + VALUES ($1, $2, $3, $4) + RETURNING id, token, file_id, org_id, created_by_user_id, created_at, updated_at, expires_at, is_revoked + `, token, fileID, orgID, createdByUserID).Scan( + &link.ID, &link.Token, &link.FileID, &link.OrgID, &link.CreatedByUserID, + &link.CreatedAt, &link.UpdatedAt, &link.ExpiresAt, &link.IsRevoked) + return &link, err +} + +// GetFileShareLinkByFileID gets the active share link for a file +func (db *DB) GetFileShareLinkByFileID(ctx context.Context, fileID uuid.UUID) (*models.FileShareLink, error) { + var link models.FileShareLink + var expiresAtNull sql.NullTime + err := db.QueryRowContext(ctx, ` + SELECT id, token, file_id, org_id, created_by_user_id, created_at, updated_at, expires_at, is_revoked + FROM file_share_links + WHERE file_id = $1 AND is_revoked = FALSE AND (expires_at IS NULL OR expires_at > NOW()) + ORDER BY created_at DESC + LIMIT 1 + `, fileID).Scan( + &link.ID, &link.Token, &link.FileID, &link.OrgID, &link.CreatedByUserID, + &link.CreatedAt, &link.UpdatedAt, &expiresAtNull, &link.IsRevoked) + if err != nil { + return nil, err + } + if expiresAtNull.Valid { + link.ExpiresAt = &expiresAtNull.Time + } + return &link, nil +} + +// GetFileShareLinkByToken gets a share link by token +func (db *DB) GetFileShareLinkByToken(ctx context.Context, token string) (*models.FileShareLink, error) { + var link models.FileShareLink + var expiresAtNull sql.NullTime + err := db.QueryRowContext(ctx, ` + SELECT id, token, file_id, org_id, created_by_user_id, created_at, updated_at, expires_at, is_revoked + FROM file_share_links + WHERE token = $1 AND is_revoked = FALSE AND (expires_at IS NULL OR expires_at > NOW()) + `, token).Scan( + &link.ID, &link.Token, &link.FileID, &link.OrgID, &link.CreatedByUserID, + &link.CreatedAt, &link.UpdatedAt, &expiresAtNull, &link.IsRevoked) + if err != nil { + return nil, err + } + if expiresAtNull.Valid { + link.ExpiresAt = &expiresAtNull.Time + } + return &link, nil +} + +// RevokeFileShareLink revokes a share link +func (db *DB) RevokeFileShareLink(ctx context.Context, fileID uuid.UUID) error { + _, err := db.ExecContext(ctx, ` + UPDATE file_share_links + SET is_revoked = TRUE, updated_at = NOW() + WHERE file_id = $1 AND is_revoked = FALSE + `, fileID) + return err +} diff --git a/go_cloud/internal/http/routes.go b/go_cloud/internal/http/routes.go index 0978841..cdb97ee 100644 --- a/go_cloud/internal/http/routes.go +++ b/go_cloud/internal/http/routes.go @@ -262,6 +262,16 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut 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.FileWrite)).Post("/share", func(w http.ResponseWriter, req *http.Request) { + createFileShareLinkHandler(w, req, db) + }) + r.With(middleware.Permission(db, auditLogger, permission.FileWrite)).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") @@ -319,6 +329,16 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut }) }) // 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.Get("/share/{token}/download", func(w http.ResponseWriter, req *http.Request) { + publicFileDownloadHandler(w, req, db, cfg, jwtManager) + }) + }) + return r } @@ -2672,3 +2692,315 @@ func getMimeType(filename string) string { return "application/octet-stream" } } + +// File share handlers + +func getFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *database.DB) { + 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 + 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 + } + + link, err := db.GetFileShareLinkByFileID(r.Context(), fileUUID) + if err != nil { + if err == sql.ErrNoRows { + // No share link exists + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "exists": false, + }) + 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 := r.Host + if host == "" { + host = "go.b0esche.cloud" + } + fullURL := fmt.Sprintf("%s://%s/public/share/%s", scheme, host, link.Token) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "exists": true, + "url": 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 + 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 + } + + // Revoke existing link if any + db.RevokeFileShareLink(r.Context(), fileUUID) // Ignore error + + // Generate token + token, err := storage.GenerateSecurePassword(32) + 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 + } + + // Build full URL + 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" + } + fullURL := fmt.Sprintf("%s://%s/public/share/%s", scheme, host, link.Token) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "url": fullURL, + "token": link.Token, + }) +} + +func revokeFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *database.DB) { + 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 + 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 + } + + 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) + viewerToken, err := jwtManager.GenerateWithDuration("", []string{link.OrgID.String()}, "", 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 download URL + 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)) + + // Determine file type + isPdf := strings.HasSuffix(strings.ToLower(file.Name), ".pdf") + mimeType := getMimeType(file.Name) + + viewerSession := struct { + FileName string `json:"fileName"` + FileSize int64 `json:"fileSize"` + DownloadUrl string `json:"downloadUrl"` + Token string `json:"token"` + Capabilities struct { + CanEdit bool `json:"canEdit"` + CanAnnotate bool `json:"canAnnotate"` + IsPdf bool `json:"isPdf"` + MimeType string `json:"mimeType"` + } `json:"capabilities"` + }{ + FileName: file.Name, + FileSize: file.Size, + DownloadUrl: downloadPath, + Token: viewerToken, + } + viewerSession.Capabilities.CanEdit = false + viewerSession.Capabilities.CanAnnotate = false + viewerSession.Capabilities.IsPdf = isPdf + viewerSession.Capabilities.MimeType = mimeType + + 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 + } + + // Verify viewer token (contains org ID) + 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 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 + } + + 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 + } + + 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 + } + + // Get WebDAV client for org + client, err := getUserWebDAVClient(r.Context(), db, uuid.Nil, "https://of.b0esche.cloud", 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 + } + + // Stream file + resp, err := client.Download(r.Context(), 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() + + // Copy headers + for k, v := range resp.Header { + w.Header()[k] = v + } + + // Copy body + io.Copy(w, resp.Body) +} diff --git a/go_cloud/internal/models/file_share_link.go b/go_cloud/internal/models/file_share_link.go new file mode 100644 index 0000000..31e3e7f --- /dev/null +++ b/go_cloud/internal/models/file_share_link.go @@ -0,0 +1,20 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// FileShareLink represents a public share link for a file +type FileShareLink struct { + ID uuid.UUID `json:"id" db:"id"` + Token string `json:"token" db:"token"` + FileID uuid.UUID `json:"file_id" db:"file_id"` + OrgID uuid.UUID `json:"org_id" db:"org_id"` + CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + ExpiresAt *time.Time `json:"expires_at,omitempty" db:"expires_at"` + IsRevoked bool `json:"is_revoked" db:"is_revoked"` +} diff --git a/go_cloud/migrations/0007_file_share_links.sql b/go_cloud/migrations/0007_file_share_links.sql new file mode 100644 index 0000000..c6e7054 --- /dev/null +++ b/go_cloud/migrations/0007_file_share_links.sql @@ -0,0 +1,17 @@ +-- Create file_share_links table + +CREATE TABLE file_share_links ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + token TEXT NOT NULL UNIQUE, + file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE, + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + created_by_user_id UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + expires_at TIMESTAMP WITH TIME ZONE, + is_revoked BOOLEAN DEFAULT FALSE +); + +CREATE INDEX idx_file_share_links_token ON file_share_links(token); +CREATE INDEX idx_file_share_links_file_id ON file_share_links(file_id); +CREATE INDEX idx_file_share_links_org_id ON file_share_links(org_id); \ No newline at end of file diff --git a/go_cloud/migrations/0007_file_share_links_down.sql b/go_cloud/migrations/0007_file_share_links_down.sql new file mode 100644 index 0000000..4ca521f --- /dev/null +++ b/go_cloud/migrations/0007_file_share_links_down.sql @@ -0,0 +1,10 @@ +-- Drop file_share_links table + +DROP TABLE IF EXISTS file_share_links; + expires_at TIMESTAMP WITH TIME ZONE, + is_revoked BOOLEAN DEFAULT FALSE +); + +CREATE INDEX idx_file_share_links_token ON file_share_links(token); +CREATE INDEX idx_file_share_links_file_id ON file_share_links(file_id); +CREATE INDEX idx_file_share_links_org_id ON file_share_links(org_id); \ No newline at end of file diff --git a/go_cloud/migrations/0007_file_share_links_down.sql.bak b/go_cloud/migrations/0007_file_share_links_down.sql.bak new file mode 100644 index 0000000..86c7cf6 --- /dev/null +++ b/go_cloud/migrations/0007_file_share_links_down.sql.bak @@ -0,0 +1,3 @@ +-- Drop file_share_links table + +DROP TABLE IF EXISTS file_share_links; \ No newline at end of file