From 82eba17a82a178a0ef89ba76c865fff75ead2f85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leon=20B=C3=B6sche?= Date: Sat, 24 Jan 2026 23:16:51 +0100 Subject: [PATCH] Enhance file sharing functionality: infer org_id when not provided, update share link responses to include shareUrl --- .../lib/pages/public_file_viewer.dart | 24 ++- .../lib/widgets/share_file_dialog.dart | 181 +++++++++++------- go_cloud/internal/database/db.go | 12 ++ go_cloud/internal/http/routes.go | 34 ++-- 4 files changed, 154 insertions(+), 97 deletions(-) diff --git a/b0esche_cloud/lib/pages/public_file_viewer.dart b/b0esche_cloud/lib/pages/public_file_viewer.dart index 874919b..47b3b81 100644 --- a/b0esche_cloud/lib/pages/public_file_viewer.dart +++ b/b0esche_cloud/lib/pages/public_file_viewer.dart @@ -4,6 +4,7 @@ import 'package:web/web.dart' as web; import '../theme/app_theme.dart'; import '../services/api_client.dart'; import '../injection.dart'; +import '../widgets/audio_player_bar.dart'; class PublicFileViewer extends StatefulWidget { final String token; @@ -117,12 +118,23 @@ class _PublicFileViewerState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.insert_drive_file, - size: 64, - color: AppTheme.primaryText, - ), - const SizedBox(height: 16), + // If this is an audio file, show the audio player + if ((_fileData!['capabilities']?['mimeType'] ?? '') + .toString() + .startsWith('audio/')) ...[ + AudioPlayerBar( + fileName: _fileData!['fileName'] ?? 'Audio', + fileUrl: _fileData!['downloadUrl'], + ), + const SizedBox(height: 16), + ] else ...[ + Icon( + Icons.insert_drive_file, + size: 64, + color: AppTheme.primaryText, + ), + const SizedBox(height: 16), + ], Text( _fileData!['fileName'] ?? 'Unknown file', style: TextStyle( diff --git a/b0esche_cloud/lib/widgets/share_file_dialog.dart b/b0esche_cloud/lib/widgets/share_file_dialog.dart index 41b091c..5d774e7 100644 --- a/b0esche_cloud/lib/widgets/share_file_dialog.dart +++ b/b0esche_cloud/lib/widgets/share_file_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../services/api_client.dart'; +import '../models/api_error.dart'; import '../theme/app_theme.dart'; import '../theme/modern_glass_button.dart'; import '../injection.dart'; @@ -40,23 +41,28 @@ class _ShareFileDialogState extends State { try { final apiClient = getIt(); - final path = widget.orgId.isEmpty + final path = widget.orgId.isEmpty || widget.orgId == 'personal' ? '/orgs/files/${widget.fileId}/share' : '/orgs/${widget.orgId}/files/${widget.fileId}/share'; final response = await apiClient.getRaw(path); - if (response['exists'] == true) { + setState(() { + _shareUrl = response['shareUrl']; + _isLoading = false; + }); + } catch (e) { + // If 404 -> no share link yet. Otherwise surface error. + if (e is ApiError && e.status == 404) { setState(() { - _shareUrl = response['url']; + _shareUrl = null; _isLoading = false; }); - } else { - // Auto-create share link - await _createShareLink(); + return; } - } catch (e) { - // Try to create share link anyway - await _createShareLink(); + setState(() { + _error = 'Failed to load share link'; + _isLoading = false; + }); } } @@ -68,18 +74,18 @@ class _ShareFileDialogState extends State { try { final apiClient = getIt(); - final path = widget.orgId.isEmpty + final path = widget.orgId.isEmpty || widget.orgId == 'personal' ? '/orgs/files/${widget.fileId}/share' : '/orgs/${widget.orgId}/files/${widget.fileId}/share'; final response = await apiClient.postRaw(path, data: {}); setState(() { - _shareUrl = response['url']; + _shareUrl = response['shareUrl']; _isLoading = false; }); } catch (e) { setState(() { - _error = 'Failed to create share link'; + _error = e is ApiError ? e.message : 'Failed to create share link'; _isLoading = false; }); } @@ -115,7 +121,7 @@ class _ShareFileDialogState extends State { Clipboard.setData(ClipboardData(text: _shareUrl!)); ScaffoldMessenger.of( context, - ).showSnackBar(const SnackBar(content: Text('Link copied to clipboard'))); + ).showSnackBar(const SnackBar(content: Text('Copied!'))); } } @@ -189,73 +195,106 @@ class _ShareFileDialogState extends State { 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), - Row( - children: [ - Expanded( - child: TextField( - controller: TextEditingController(text: _shareUrl), - readOnly: true, - maxLines: 2, - style: TextStyle(color: AppTheme.primaryText), - decoration: InputDecoration( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: AppTheme.secondaryText.withValues( - alpha: 0.3, + if (_shareUrl != null) ...[ + Text( + 'Share link created. Anyone with this link can view and download the file.', + style: TextStyle(color: AppTheme.secondaryText), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextField( + controller: TextEditingController(text: _shareUrl), + readOnly: true, + maxLines: 2, + style: TextStyle(color: AppTheme.primaryText), + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: AppTheme.secondaryText.withValues( + alpha: 0.3, + ), ), ), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: AppTheme.secondaryText.withValues( - alpha: 0.3, + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: AppTheme.secondaryText.withValues( + alpha: 0.3, + ), ), ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: AppTheme.accentColor, + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: AppTheme.accentColor, + ), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, ), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, ), ), ), - ), - const SizedBox(width: 16), - ModernGlassButton( - onPressed: _copyToClipboard, - child: const Icon(Icons.content_copy), - ), - ], - ), - const SizedBox(height: 16), - Row( - children: [ - ModernGlassButton( - onPressed: _revokeShareLink, - child: Text( - 'Revoke Link', - style: TextStyle(color: AppTheme.errorColor), + const SizedBox(width: 16), + ModernGlassButton( + onPressed: _copyToClipboard, + child: const Icon(Icons.content_copy), ), - ), - const Spacer(), - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Close'), - ), - ], - ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + ModernGlassButton( + onPressed: _revokeShareLink, + child: Text( + 'Revoke Link', + style: TextStyle(color: AppTheme.errorColor), + ), + ), + const Spacer(), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ] else ...[ + Text( + 'No share link yet. Create a public, read-only link for this file.', + style: TextStyle(color: AppTheme.secondaryText), + ), + const SizedBox(height: 16), + Row( + children: [ + ModernGlassButton( + onPressed: _createShareLink, + isLoading: _isLoading, + child: _isLoading + ? const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + AppTheme.accentColor, + ), + ), + ) + : const Text('Create link'), + ), + const Spacer(), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ], ], ), ], diff --git a/go_cloud/internal/database/db.go b/go_cloud/internal/database/db.go index 7c417eb..7efb9a5 100644 --- a/go_cloud/internal/database/db.go +++ b/go_cloud/internal/database/db.go @@ -1157,6 +1157,18 @@ func (db *DB) CreateFileShareLink(ctx context.Context, token string, fileID uuid var link models.FileShareLink var expiresAtNull sql.NullTime var orgIDNull sql.NullString + // If caller didn't provide an orgID, try to infer it from the file record + if orgID == nil { + var fileOrgNull sql.NullString + fileErr := db.QueryRowContext(ctx, `SELECT org_id::text FROM files WHERE id = $1`, fileID).Scan(&fileOrgNull) + if fileErr == nil && fileOrgNull.Valid { + if parsed, perr := uuid.Parse(fileOrgNull.String); perr == nil { + orgID = &parsed + } + } + // If the file lookup failed or org_id is not set, orgID remains nil + } + err := db.QueryRowContext(ctx, ` INSERT INTO file_share_links (token, file_id, org_id, created_by_user_id) VALUES ($1, $2, $3, $4) diff --git a/go_cloud/internal/http/routes.go b/go_cloud/internal/http/routes.go index 00ccab3..7b89ead 100644 --- a/go_cloud/internal/http/routes.go +++ b/go_cloud/internal/http/routes.go @@ -2748,10 +2748,7 @@ func getFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *databas 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, - }) + errors.WriteError(w, errors.CodeNotFound, "Share link not found", http.StatusNotFound) return } errors.LogError(r, err, "Failed to get share link") @@ -2774,9 +2771,8 @@ func getFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *databas w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ - "exists": true, - "url": fullURL, - "token": link.Token, + "shareUrl": fullURL, + "token": link.Token, }) } @@ -2812,7 +2808,7 @@ func createFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *data db.RevokeFileShareLink(r.Context(), fileUUID) // Ignore error // Generate token - token, err := storage.GenerateSecurePassword(32) + token, err := storage.GenerateSecurePassword(48) if err != nil { errors.LogError(r, err, "Failed to generate token") errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) @@ -2841,8 +2837,8 @@ func createFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *data w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ - "url": fullURL, - "token": link.Token, + "shareUrl": fullURL, + "token": link.Token, }) } @@ -3087,10 +3083,7 @@ func getUserFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *dat 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, - }) + errors.WriteError(w, errors.CodeNotFound, "Share link not found", http.StatusNotFound) return } errors.LogError(r, err, "Failed to get share link") @@ -3113,9 +3106,8 @@ func getUserFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *dat w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ - "exists": true, - "url": fullURL, - "token": link.Token, + "shareUrl": fullURL, + "token": link.Token, }) } @@ -3146,13 +3138,15 @@ func createUserFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db * db.RevokeFileShareLink(r.Context(), fileUUID) // Ignore error // Generate token - token, err := storage.GenerateSecurePassword(32) + token, err := storage.GenerateSecurePassword(48) if err != nil { errors.LogError(r, err, "Failed to generate token") errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) return } + // If the file belongs to an org, prefer binding the share link to that org. + // db.CreateFileShareLink will attempt to infer org_id from the file if nil is passed. link, err := db.CreateFileShareLink(r.Context(), token, fileUUID, nil, userID) if err != nil { errors.LogError(r, err, "Failed to create share link") @@ -3175,8 +3169,8 @@ func createUserFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db * w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ - "url": fullURL, - "token": link.Token, + "shareUrl": fullURL, + "token": link.Token, }) }