Implement file sharing functionality with public share links and associated API endpoints
This commit is contained in:
@@ -12,6 +12,7 @@ import 'pages/file_explorer.dart';
|
||||
import 'pages/document_viewer.dart';
|
||||
import 'pages/editor_page.dart';
|
||||
import 'pages/join_page.dart';
|
||||
import 'pages/public_file_viewer.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
import 'injection.dart';
|
||||
|
||||
@@ -42,6 +43,11 @@ final GoRouter _router = GoRouter(
|
||||
builder: (context, state) =>
|
||||
JoinPage(token: state.uri.queryParameters['token'] ?? ''),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/share/:token',
|
||||
builder: (context, state) =>
|
||||
PublicFileViewer(token: state.pathParameters['token']!),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import 'document_viewer.dart';
|
||||
import 'video_viewer.dart';
|
||||
import '../injection.dart';
|
||||
import '../services/file_service.dart';
|
||||
import '../widgets/share_file_dialog.dart';
|
||||
|
||||
typedef AudioFileSelectedCallback =
|
||||
void Function(String fileName, String fileUrl);
|
||||
@@ -815,10 +816,15 @@ class _FileExplorerState extends State<FileExplorer>
|
||||
}
|
||||
}
|
||||
|
||||
void _sendFile(FileItem file) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Send ${file.name}')));
|
||||
void _shareFile(FileItem file) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) => ShareFileDialog(
|
||||
orgId: widget.orgId,
|
||||
fileId: file.id!,
|
||||
fileName: file.name,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteFile(FileItem file) async {
|
||||
@@ -1304,10 +1310,10 @@ class _FileExplorerState extends State<FileExplorer>
|
||||
onPressed: () => _downloadFile(file),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.send, color: AppTheme.secondaryText),
|
||||
icon: const Icon(Icons.share, color: AppTheme.secondaryText),
|
||||
splashColor: Colors.transparent,
|
||||
highlightColor: Colors.transparent,
|
||||
onPressed: () => _sendFile(file),
|
||||
onPressed: () => _shareFile(file),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, color: AppTheme.secondaryText),
|
||||
|
||||
168
b0esche_cloud/lib/pages/public_file_viewer.dart
Normal file
168
b0esche_cloud/lib/pages/public_file_viewer.dart
Normal file
@@ -0,0 +1,168 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'dart:js_interop';
|
||||
import 'package:web/web.dart' as web;
|
||||
import 'dart:typed_data';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../services/api_client.dart';
|
||||
import '../injection.dart';
|
||||
|
||||
class PublicFileViewer extends StatefulWidget {
|
||||
final String token;
|
||||
|
||||
const PublicFileViewer({super.key, required this.token});
|
||||
|
||||
@override
|
||||
State<PublicFileViewer> createState() => _PublicFileViewerState();
|
||||
}
|
||||
|
||||
class _PublicFileViewerState extends State<PublicFileViewer> {
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
Map<String, dynamic>? _fileData;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadFileData();
|
||||
}
|
||||
|
||||
Future<void> _loadFileData() async {
|
||||
try {
|
||||
final apiClient = getIt<ApiClient>();
|
||||
final response = await apiClient.getRaw('/public/share/${widget.token}');
|
||||
|
||||
setState(() {
|
||||
_fileData = response;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'This link is invalid or has expired.';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _downloadFile() {
|
||||
if (_fileData != null && _fileData!['downloadUrl'] != null) {
|
||||
// Use http package to download
|
||||
http.get(Uri.parse(_fileData!['downloadUrl'])).then((response) {
|
||||
if (response.statusCode == 200) {
|
||||
// Trigger download in web
|
||||
final uint8List = Uint8List.fromList(response.bodyBytes);
|
||||
final jsUint8Array = JSUint8Array(
|
||||
uint8List.buffer.toJS,
|
||||
uint8List.offsetInBytes,
|
||||
uint8List.length,
|
||||
);
|
||||
final jsArray = JSArray<JSAny>.withLength(1);
|
||||
jsArray[0] = jsUint8Array;
|
||||
final blob = web.Blob(jsArray);
|
||||
final url = web.URL.createObjectURL(blob);
|
||||
final anchor = web.HTMLAnchorElement()
|
||||
..href = url
|
||||
..download = _fileData!['fileName'] ?? 'download';
|
||||
anchor.click();
|
||||
web.URL.revokeObjectURL(url);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.primaryBackground,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppTheme.primaryBackground,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close, color: AppTheme.primaryText),
|
||||
onPressed: () => context.go('/'),
|
||||
),
|
||||
title: Text(
|
||||
_fileData?['fileName'] ?? 'Shared File',
|
||||
style: TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
actions: [
|
||||
if (_fileData != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.download, color: AppTheme.primaryText),
|
||||
onPressed: _downloadFile,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Center(
|
||||
child: _isLoading
|
||||
? const CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.accentColor),
|
||||
)
|
||||
: _error != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.link_off, size: 64, color: Colors.red[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_error!,
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryText,
|
||||
fontSize: 18,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: _fileData != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.insert_drive_file,
|
||||
size: 64,
|
||||
color: AppTheme.primaryText,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_fileData!['fileName'] ?? 'Unknown file',
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryText,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Size: ${(_fileData!['fileSize'] ?? 0) ~/ 1024} KB',
|
||||
style: TextStyle(color: AppTheme.secondaryText),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _downloadFile,
|
||||
icon: const Icon(Icons.download),
|
||||
label: const Text('Download File'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.accentColor,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,15 @@ class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> postRaw(String path, {dynamic data}) async {
|
||||
try {
|
||||
final response = await _dio.post(path, data: data);
|
||||
return response.data;
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<T> patch<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
|
||||
239
b0esche_cloud/lib/widgets/share_file_dialog.dart
Normal file
239
b0esche_cloud/lib/widgets/share_file_dialog.dart
Normal file
@@ -0,0 +1,239 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../services/api_client.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../injection.dart';
|
||||
|
||||
class ShareFileDialog extends StatefulWidget {
|
||||
final String orgId;
|
||||
final String fileId;
|
||||
final String fileName;
|
||||
|
||||
const ShareFileDialog({
|
||||
super.key,
|
||||
required this.orgId,
|
||||
required this.fileId,
|
||||
required this.fileName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ShareFileDialog> createState() => _ShareFileDialogState();
|
||||
}
|
||||
|
||||
class _ShareFileDialogState extends State<ShareFileDialog> {
|
||||
bool _isLoading = true;
|
||||
String? _shareUrl;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadShareLink();
|
||||
}
|
||||
|
||||
Future<void> _loadShareLink() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final apiClient = getIt<ApiClient>();
|
||||
final response = await apiClient.getRaw(
|
||||
'/orgs/${widget.orgId}/files/${widget.fileId}/share',
|
||||
);
|
||||
|
||||
if (response['exists'] == true) {
|
||||
setState(() {
|
||||
_shareUrl = response['url'];
|
||||
_isLoading = false;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_shareUrl = null;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'Failed to load share link';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createShareLink() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final apiClient = getIt<ApiClient>();
|
||||
final response = await apiClient.postRaw(
|
||||
'/orgs/${widget.orgId}/files/${widget.fileId}/share',
|
||||
data: {},
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_shareUrl = response['url'];
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'Failed to create share link';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _revokeShareLink() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final apiClient = getIt<ApiClient>();
|
||||
await apiClient.delete(
|
||||
'/orgs/${widget.orgId}/files/${widget.fileId}/share',
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_shareUrl = null;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'Failed to revoke share link';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _copyToClipboard() {
|
||||
if (_shareUrl != null) {
|
||||
Clipboard.setData(ClipboardData(text: _shareUrl!));
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Link copied to clipboard')));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 500),
|
||||
child: Container(
|
||||
decoration: AppTheme.glassDecoration,
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Share "${widget.fileName}"',
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryText,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_isLoading)
|
||||
const Center(
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
AppTheme.accentColor,
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (_error != null)
|
||||
Text(_error!, style: TextStyle(color: Colors.red[400]))
|
||||
else if (_shareUrl == null)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'No share link exists for this file.',
|
||||
style: TextStyle(color: AppTheme.secondaryText),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _createShareLink,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.accentColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('Create Share Link'),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
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),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_shareUrl!,
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryText,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.copy,
|
||||
color: AppTheme.accentColor,
|
||||
),
|
||||
onPressed: _copyToClipboard,
|
||||
tooltip: 'Copy link',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _revokeShareLink,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.red[400],
|
||||
),
|
||||
child: const Text('Revoke Link'),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
BIN
go_cloud/api
BIN
go_cloud/api
Binary file not shown.
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.b0esche.cloud/backend/internal/models"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
@@ -1149,4 +1150,69 @@ func (db *DB) MarkChallengeUsed(ctx context.Context, challenge []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateFileSize updates the size and last_modified timestamp of a file
|
||||
// FileShareLink methods
|
||||
|
||||
// CreateFileShareLink creates a new share link for a file
|
||||
func (db *DB) CreateFileShareLink(ctx context.Context, token string, fileID, orgID, createdByUserID uuid.UUID) (*models.FileShareLink, error) {
|
||||
var link models.FileShareLink
|
||||
err := db.QueryRowContext(ctx, `
|
||||
INSERT INTO file_share_links (token, file_id, org_id, created_by_user_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, token, file_id, org_id, created_by_user_id, created_at, updated_at, expires_at, is_revoked
|
||||
`, token, fileID, orgID, createdByUserID).Scan(
|
||||
&link.ID, &link.Token, &link.FileID, &link.OrgID, &link.CreatedByUserID,
|
||||
&link.CreatedAt, &link.UpdatedAt, &link.ExpiresAt, &link.IsRevoked)
|
||||
return &link, err
|
||||
}
|
||||
|
||||
// GetFileShareLinkByFileID gets the active share link for a file
|
||||
func (db *DB) GetFileShareLinkByFileID(ctx context.Context, fileID uuid.UUID) (*models.FileShareLink, error) {
|
||||
var link models.FileShareLink
|
||||
var expiresAtNull sql.NullTime
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT id, token, file_id, org_id, created_by_user_id, created_at, updated_at, expires_at, is_revoked
|
||||
FROM file_share_links
|
||||
WHERE file_id = $1 AND is_revoked = FALSE AND (expires_at IS NULL OR expires_at > NOW())
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`, fileID).Scan(
|
||||
&link.ID, &link.Token, &link.FileID, &link.OrgID, &link.CreatedByUserID,
|
||||
&link.CreatedAt, &link.UpdatedAt, &expiresAtNull, &link.IsRevoked)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAtNull.Valid {
|
||||
link.ExpiresAt = &expiresAtNull.Time
|
||||
}
|
||||
return &link, nil
|
||||
}
|
||||
|
||||
// GetFileShareLinkByToken gets a share link by token
|
||||
func (db *DB) GetFileShareLinkByToken(ctx context.Context, token string) (*models.FileShareLink, error) {
|
||||
var link models.FileShareLink
|
||||
var expiresAtNull sql.NullTime
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT id, token, file_id, org_id, created_by_user_id, created_at, updated_at, expires_at, is_revoked
|
||||
FROM file_share_links
|
||||
WHERE token = $1 AND is_revoked = FALSE AND (expires_at IS NULL OR expires_at > NOW())
|
||||
`, token).Scan(
|
||||
&link.ID, &link.Token, &link.FileID, &link.OrgID, &link.CreatedByUserID,
|
||||
&link.CreatedAt, &link.UpdatedAt, &expiresAtNull, &link.IsRevoked)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAtNull.Valid {
|
||||
link.ExpiresAt = &expiresAtNull.Time
|
||||
}
|
||||
return &link, nil
|
||||
}
|
||||
|
||||
// RevokeFileShareLink revokes a share link
|
||||
func (db *DB) RevokeFileShareLink(ctx context.Context, fileID uuid.UUID) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
UPDATE file_share_links
|
||||
SET is_revoked = TRUE, updated_at = NOW()
|
||||
WHERE file_id = $1 AND is_revoked = FALSE
|
||||
`, fileID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -262,6 +262,16 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut
|
||||
r.Get("/meta", func(w http.ResponseWriter, req *http.Request) {
|
||||
fileMetaHandler(w, req)
|
||||
})
|
||||
// Share link management
|
||||
r.With(middleware.Permission(db, auditLogger, permission.FileRead)).Get("/share", func(w http.ResponseWriter, req *http.Request) {
|
||||
getFileShareLinkHandler(w, req, db)
|
||||
})
|
||||
r.With(middleware.Permission(db, auditLogger, permission.FileWrite)).Post("/share", func(w http.ResponseWriter, req *http.Request) {
|
||||
createFileShareLinkHandler(w, req, db)
|
||||
})
|
||||
r.With(middleware.Permission(db, auditLogger, permission.FileWrite)).Delete("/share", func(w http.ResponseWriter, req *http.Request) {
|
||||
revokeFileShareLinkHandler(w, req, db)
|
||||
})
|
||||
// WOPI session for org files
|
||||
r.With(middleware.Permission(db, auditLogger, permission.DocumentView)).Post("/wopi-session", func(w http.ResponseWriter, req *http.Request) {
|
||||
wopiSessionHandler(w, req, db, jwtManager, "https://of.b0esche.cloud")
|
||||
@@ -319,6 +329,16 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut
|
||||
})
|
||||
}) // Close protected routes
|
||||
|
||||
// Public routes (no auth required)
|
||||
r.Route("/public", func(r chi.Router) {
|
||||
r.Get("/share/{token}", func(w http.ResponseWriter, req *http.Request) {
|
||||
publicFileShareHandler(w, req, db, jwtManager)
|
||||
})
|
||||
r.Get("/share/{token}/download", func(w http.ResponseWriter, req *http.Request) {
|
||||
publicFileDownloadHandler(w, req, db, cfg, jwtManager)
|
||||
})
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -2672,3 +2692,315 @@ func getMimeType(filename string) string {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
// File share handlers
|
||||
|
||||
func getFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
||||
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
|
||||
fileId := chi.URLParam(r, "fileId")
|
||||
|
||||
fileUUID, err := uuid.Parse(fileId)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid file ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if file exists and belongs to org
|
||||
file, err := db.GetFileByID(r.Context(), fileUUID)
|
||||
if err != nil {
|
||||
errors.LogError(r, err, "Failed to get file")
|
||||
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if file.OrgID == nil || *file.OrgID != orgID {
|
||||
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
link, err := db.GetFileShareLinkByFileID(r.Context(), fileUUID)
|
||||
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,
|
||||
})
|
||||
return
|
||||
}
|
||||
errors.LogError(r, err, "Failed to get share link")
|
||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Build full URL
|
||||
scheme := "https"
|
||||
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
|
||||
scheme = proto
|
||||
} else if r.TLS == nil {
|
||||
scheme = "http"
|
||||
}
|
||||
host := r.Host
|
||||
if host == "" {
|
||||
host = "go.b0esche.cloud"
|
||||
}
|
||||
fullURL := fmt.Sprintf("%s://%s/public/share/%s", scheme, host, link.Token)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"exists": true,
|
||||
"url": fullURL,
|
||||
"token": link.Token,
|
||||
})
|
||||
}
|
||||
|
||||
func createFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
||||
userIDStr, _ := middleware.GetUserID(r.Context())
|
||||
userID, _ := uuid.Parse(userIDStr)
|
||||
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
|
||||
fileId := chi.URLParam(r, "fileId")
|
||||
|
||||
fileUUID, err := uuid.Parse(fileId)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid file ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if file exists and belongs to org
|
||||
file, err := db.GetFileByID(r.Context(), fileUUID)
|
||||
if err != nil {
|
||||
errors.LogError(r, err, "Failed to get file")
|
||||
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if file.OrgID == nil || *file.OrgID != orgID {
|
||||
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Revoke existing link if any
|
||||
db.RevokeFileShareLink(r.Context(), fileUUID) // Ignore error
|
||||
|
||||
// Generate token
|
||||
token, err := storage.GenerateSecurePassword(32)
|
||||
if err != nil {
|
||||
errors.LogError(r, err, "Failed to generate token")
|
||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
link, err := db.CreateFileShareLink(r.Context(), token, fileUUID, orgID, userID)
|
||||
if err != nil {
|
||||
errors.LogError(r, err, "Failed to create share link")
|
||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Build full URL
|
||||
scheme := "https"
|
||||
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
|
||||
scheme = proto
|
||||
} else if r.TLS == nil {
|
||||
scheme = "http"
|
||||
}
|
||||
host := r.Host
|
||||
if host == "" {
|
||||
host = "go.b0esche.cloud"
|
||||
}
|
||||
fullURL := fmt.Sprintf("%s://%s/public/share/%s", scheme, host, link.Token)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"url": fullURL,
|
||||
"token": link.Token,
|
||||
})
|
||||
}
|
||||
|
||||
func revokeFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
||||
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
|
||||
fileId := chi.URLParam(r, "fileId")
|
||||
|
||||
fileUUID, err := uuid.Parse(fileId)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid file ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if file exists and belongs to org
|
||||
file, err := db.GetFileByID(r.Context(), fileUUID)
|
||||
if err != nil {
|
||||
errors.LogError(r, err, "Failed to get file")
|
||||
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if file.OrgID == nil || *file.OrgID != orgID {
|
||||
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
err = db.RevokeFileShareLink(r.Context(), fileUUID)
|
||||
if err != nil {
|
||||
errors.LogError(r, err, "Failed to revoke share link")
|
||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func publicFileShareHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager) {
|
||||
token := chi.URLParam(r, "token")
|
||||
if token == "" {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Token required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
link, err := db.GetFileShareLinkByToken(r.Context(), token)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
errors.WriteError(w, errors.CodeNotFound, "Link not found or expired", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
errors.LogError(r, err, "Failed to get share link")
|
||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Get file metadata
|
||||
file, err := db.GetFileByID(r.Context(), link.FileID)
|
||||
if err != nil {
|
||||
errors.LogError(r, err, "Failed to get file")
|
||||
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate a short-lived token for download (1 hour)
|
||||
viewerToken, err := jwtManager.GenerateWithDuration("", []string{link.OrgID.String()}, "", time.Hour)
|
||||
if err != nil {
|
||||
errors.LogError(r, err, "Failed to generate viewer token")
|
||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Build download URL
|
||||
scheme := "https"
|
||||
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
|
||||
scheme = proto
|
||||
} else if r.TLS == nil {
|
||||
scheme = "http"
|
||||
}
|
||||
host := r.Host
|
||||
if host == "" {
|
||||
host = "go.b0esche.cloud"
|
||||
}
|
||||
downloadPath := fmt.Sprintf("%s://%s/public/share/%s/download?token=%s", scheme, host, token, url.QueryEscape(viewerToken))
|
||||
|
||||
// Determine file type
|
||||
isPdf := strings.HasSuffix(strings.ToLower(file.Name), ".pdf")
|
||||
mimeType := getMimeType(file.Name)
|
||||
|
||||
viewerSession := struct {
|
||||
FileName string `json:"fileName"`
|
||||
FileSize int64 `json:"fileSize"`
|
||||
DownloadUrl string `json:"downloadUrl"`
|
||||
Token string `json:"token"`
|
||||
Capabilities struct {
|
||||
CanEdit bool `json:"canEdit"`
|
||||
CanAnnotate bool `json:"canAnnotate"`
|
||||
IsPdf bool `json:"isPdf"`
|
||||
MimeType string `json:"mimeType"`
|
||||
} `json:"capabilities"`
|
||||
}{
|
||||
FileName: file.Name,
|
||||
FileSize: file.Size,
|
||||
DownloadUrl: downloadPath,
|
||||
Token: viewerToken,
|
||||
}
|
||||
viewerSession.Capabilities.CanEdit = false
|
||||
viewerSession.Capabilities.CanAnnotate = false
|
||||
viewerSession.Capabilities.IsPdf = isPdf
|
||||
viewerSession.Capabilities.MimeType = mimeType
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(viewerSession)
|
||||
}
|
||||
|
||||
func publicFileDownloadHandler(w http.ResponseWriter, r *http.Request, db *database.DB, cfg *config.Config, jwtManager *jwt.Manager) {
|
||||
token := chi.URLParam(r, "token")
|
||||
if token == "" {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Token required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
viewerToken := r.URL.Query().Get("token")
|
||||
if viewerToken == "" {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Viewer token required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify viewer token (contains org ID)
|
||||
claims, err := jwtManager.Validate(viewerToken)
|
||||
if err != nil {
|
||||
errors.LogError(r, err, "Invalid viewer token")
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if len(claims.OrgIDs) == 0 {
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
orgID, err := uuid.Parse(claims.OrgIDs[0])
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
link, err := db.GetFileShareLinkByToken(r.Context(), token)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
errors.WriteError(w, errors.CodeNotFound, "Link not found or expired", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
errors.LogError(r, err, "Failed to get share link")
|
||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if link.OrgID != orgID {
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Get file metadata
|
||||
file, err := db.GetFileByID(r.Context(), link.FileID)
|
||||
if err != nil {
|
||||
errors.LogError(r, err, "Failed to get file")
|
||||
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Get WebDAV client for org
|
||||
client, err := getUserWebDAVClient(r.Context(), db, uuid.Nil, "https://of.b0esche.cloud", cfg.NextcloudUser, cfg.NextcloudPass)
|
||||
if err != nil {
|
||||
errors.LogError(r, err, "Failed to get WebDAV client")
|
||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Stream file
|
||||
resp, err := client.Download(r.Context(), file.Path, "")
|
||||
if err != nil {
|
||||
errors.LogError(r, err, "Failed to download file")
|
||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Copy headers
|
||||
for k, v := range resp.Header {
|
||||
w.Header()[k] = v
|
||||
}
|
||||
|
||||
// Copy body
|
||||
io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
20
go_cloud/internal/models/file_share_link.go
Normal file
20
go_cloud/internal/models/file_share_link.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// FileShareLink represents a public share link for a file
|
||||
type FileShareLink struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
Token string `json:"token" db:"token"`
|
||||
FileID uuid.UUID `json:"file_id" db:"file_id"`
|
||||
OrgID uuid.UUID `json:"org_id" db:"org_id"`
|
||||
CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty" db:"expires_at"`
|
||||
IsRevoked bool `json:"is_revoked" db:"is_revoked"`
|
||||
}
|
||||
17
go_cloud/migrations/0007_file_share_links.sql
Normal file
17
go_cloud/migrations/0007_file_share_links.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Create file_share_links table
|
||||
|
||||
CREATE TABLE file_share_links (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
||||
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
created_by_user_id UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
expires_at TIMESTAMP WITH TIME ZONE,
|
||||
is_revoked BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_file_share_links_token ON file_share_links(token);
|
||||
CREATE INDEX idx_file_share_links_file_id ON file_share_links(file_id);
|
||||
CREATE INDEX idx_file_share_links_org_id ON file_share_links(org_id);
|
||||
10
go_cloud/migrations/0007_file_share_links_down.sql
Normal file
10
go_cloud/migrations/0007_file_share_links_down.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- Drop file_share_links table
|
||||
|
||||
DROP TABLE IF EXISTS file_share_links;
|
||||
expires_at TIMESTAMP WITH TIME ZONE,
|
||||
is_revoked BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_file_share_links_token ON file_share_links(token);
|
||||
CREATE INDEX idx_file_share_links_file_id ON file_share_links(file_id);
|
||||
CREATE INDEX idx_file_share_links_org_id ON file_share_links(org_id);
|
||||
3
go_cloud/migrations/0007_file_share_links_down.sql.bak
Normal file
3
go_cloud/migrations/0007_file_share_links_down.sql.bak
Normal file
@@ -0,0 +1,3 @@
|
||||
-- Drop file_share_links table
|
||||
|
||||
DROP TABLE IF EXISTS file_share_links;
|
||||
Reference in New Issue
Block a user