Enhance file sharing functionality: infer org_id when not provided, update share link responses to include shareUrl

This commit is contained in:
Leon Bösche
2026-01-24 23:16:51 +01:00
parent 421e95d83b
commit 82eba17a82
4 changed files with 154 additions and 97 deletions

View File

@@ -4,6 +4,7 @@ import 'package:web/web.dart' as web;
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import '../services/api_client.dart'; import '../services/api_client.dart';
import '../injection.dart'; import '../injection.dart';
import '../widgets/audio_player_bar.dart';
class PublicFileViewer extends StatefulWidget { class PublicFileViewer extends StatefulWidget {
final String token; final String token;
@@ -117,12 +118,23 @@ class _PublicFileViewerState extends State<PublicFileViewer> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// 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( Icon(
Icons.insert_drive_file, Icons.insert_drive_file,
size: 64, size: 64,
color: AppTheme.primaryText, color: AppTheme.primaryText,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
],
Text( Text(
_fileData!['fileName'] ?? 'Unknown file', _fileData!['fileName'] ?? 'Unknown file',
style: TextStyle( style: TextStyle(

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../services/api_client.dart'; import '../services/api_client.dart';
import '../models/api_error.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import '../theme/modern_glass_button.dart'; import '../theme/modern_glass_button.dart';
import '../injection.dart'; import '../injection.dart';
@@ -40,23 +41,28 @@ class _ShareFileDialogState extends State<ShareFileDialog> {
try { try {
final apiClient = getIt<ApiClient>(); final apiClient = getIt<ApiClient>();
final path = widget.orgId.isEmpty final path = widget.orgId.isEmpty || widget.orgId == 'personal'
? '/orgs/files/${widget.fileId}/share' ? '/orgs/files/${widget.fileId}/share'
: '/orgs/${widget.orgId}/files/${widget.fileId}/share'; : '/orgs/${widget.orgId}/files/${widget.fileId}/share';
final response = await apiClient.getRaw(path); final response = await apiClient.getRaw(path);
if (response['exists'] == true) {
setState(() { setState(() {
_shareUrl = response['url']; _shareUrl = response['shareUrl'];
_isLoading = false; _isLoading = false;
}); });
} else {
// Auto-create share link
await _createShareLink();
}
} catch (e) { } catch (e) {
// Try to create share link anyway // If 404 -> no share link yet. Otherwise surface error.
await _createShareLink(); if (e is ApiError && e.status == 404) {
setState(() {
_shareUrl = null;
_isLoading = false;
});
return;
}
setState(() {
_error = 'Failed to load share link';
_isLoading = false;
});
} }
} }
@@ -68,18 +74,18 @@ class _ShareFileDialogState extends State<ShareFileDialog> {
try { try {
final apiClient = getIt<ApiClient>(); final apiClient = getIt<ApiClient>();
final path = widget.orgId.isEmpty final path = widget.orgId.isEmpty || widget.orgId == 'personal'
? '/orgs/files/${widget.fileId}/share' ? '/orgs/files/${widget.fileId}/share'
: '/orgs/${widget.orgId}/files/${widget.fileId}/share'; : '/orgs/${widget.orgId}/files/${widget.fileId}/share';
final response = await apiClient.postRaw(path, data: {}); final response = await apiClient.postRaw(path, data: {});
setState(() { setState(() {
_shareUrl = response['url']; _shareUrl = response['shareUrl'];
_isLoading = false; _isLoading = false;
}); });
} catch (e) { } catch (e) {
setState(() { setState(() {
_error = 'Failed to create share link'; _error = e is ApiError ? e.message : 'Failed to create share link';
_isLoading = false; _isLoading = false;
}); });
} }
@@ -115,7 +121,7 @@ class _ShareFileDialogState extends State<ShareFileDialog> {
Clipboard.setData(ClipboardData(text: _shareUrl!)); Clipboard.setData(ClipboardData(text: _shareUrl!));
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
).showSnackBar(const SnackBar(content: Text('Link copied to clipboard'))); ).showSnackBar(const SnackBar(content: Text('Copied!')));
} }
} }
@@ -189,6 +195,7 @@ class _ShareFileDialogState extends State<ShareFileDialog> {
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (_shareUrl != null) ...[
Text( Text(
'Share link created. Anyone with this link can view and download the file.', 'Share link created. Anyone with this link can view and download the file.',
style: TextStyle(color: AppTheme.secondaryText), style: TextStyle(color: AppTheme.secondaryText),
@@ -256,6 +263,38 @@ class _ShareFileDialogState extends State<ShareFileDialog> {
), ),
], ],
), ),
] 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<Color>(
AppTheme.accentColor,
),
),
)
: const Text('Create link'),
),
const Spacer(),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'),
),
],
),
],
], ],
), ),
], ],

View File

@@ -1157,6 +1157,18 @@ func (db *DB) CreateFileShareLink(ctx context.Context, token string, fileID uuid
var link models.FileShareLink var link models.FileShareLink
var expiresAtNull sql.NullTime var expiresAtNull sql.NullTime
var orgIDNull sql.NullString 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, ` err := db.QueryRowContext(ctx, `
INSERT INTO file_share_links (token, file_id, org_id, created_by_user_id) INSERT INTO file_share_links (token, file_id, org_id, created_by_user_id)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4)

View File

@@ -2748,10 +2748,7 @@ func getFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *databas
if err != nil { if err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
// No share link exists // No share link exists
w.Header().Set("Content-Type", "application/json") errors.WriteError(w, errors.CodeNotFound, "Share link not found", http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]interface{}{
"exists": false,
})
return return
} }
errors.LogError(r, err, "Failed to get share link") errors.LogError(r, err, "Failed to get share link")
@@ -2774,8 +2771,7 @@ func getFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *databas
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]interface{}{
"exists": true, "shareUrl": fullURL,
"url": fullURL,
"token": link.Token, "token": link.Token,
}) })
} }
@@ -2812,7 +2808,7 @@ func createFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *data
db.RevokeFileShareLink(r.Context(), fileUUID) // Ignore error db.RevokeFileShareLink(r.Context(), fileUUID) // Ignore error
// Generate token // Generate token
token, err := storage.GenerateSecurePassword(32) token, err := storage.GenerateSecurePassword(48)
if err != nil { if err != nil {
errors.LogError(r, err, "Failed to generate token") errors.LogError(r, err, "Failed to generate token")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
@@ -2841,7 +2837,7 @@ func createFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *data
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]interface{}{
"url": fullURL, "shareUrl": fullURL,
"token": link.Token, "token": link.Token,
}) })
} }
@@ -3087,10 +3083,7 @@ func getUserFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *dat
if err != nil { if err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
// No share link exists // No share link exists
w.Header().Set("Content-Type", "application/json") errors.WriteError(w, errors.CodeNotFound, "Share link not found", http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]interface{}{
"exists": false,
})
return return
} }
errors.LogError(r, err, "Failed to get share link") errors.LogError(r, err, "Failed to get share link")
@@ -3113,8 +3106,7 @@ func getUserFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *dat
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]interface{}{
"exists": true, "shareUrl": fullURL,
"url": fullURL,
"token": link.Token, "token": link.Token,
}) })
} }
@@ -3146,13 +3138,15 @@ func createUserFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *
db.RevokeFileShareLink(r.Context(), fileUUID) // Ignore error db.RevokeFileShareLink(r.Context(), fileUUID) // Ignore error
// Generate token // Generate token
token, err := storage.GenerateSecurePassword(32) token, err := storage.GenerateSecurePassword(48)
if err != nil { if err != nil {
errors.LogError(r, err, "Failed to generate token") errors.LogError(r, err, "Failed to generate token")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return 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) link, err := db.CreateFileShareLink(r.Context(), token, fileUUID, nil, userID)
if err != nil { if err != nil {
errors.LogError(r, err, "Failed to create share link") errors.LogError(r, err, "Failed to create share link")
@@ -3175,7 +3169,7 @@ func createUserFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]interface{}{
"url": fullURL, "shareUrl": fullURL,
"token": link.Token, "token": link.Token,
}) })
} }