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 '../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<PublicFileViewer> {
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(

View File

@@ -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<ShareFileDialog> {
try {
final apiClient = getIt<ApiClient>();
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<ShareFileDialog> {
try {
final apiClient = getIt<ApiClient>();
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<ShareFileDialog> {
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<ShareFileDialog> {
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<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 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)

View File

@@ -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,
})
}