Enhance file sharing functionality: infer org_id when not provided, update share link responses to include shareUrl
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user