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 '../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(
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user