Implement file sharing functionality with public share links and associated API endpoints

This commit is contained in:
Leon Bösche
2026-01-24 21:06:18 +01:00
parent 4770380e38
commit 6bbdc157cb
12 changed files with 883 additions and 7 deletions

View File

@@ -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']!),
),
],
);

View File

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

View 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(),
),
);
}
}

View File

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

View 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'),
),
],
),
],
),
],
),
),
),
);
}
}

Binary file not shown.

View File

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

View File

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

View 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"`
}

View 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);

View 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);

View File

@@ -0,0 +1,3 @@
-- Drop file_share_links table
DROP TABLE IF EXISTS file_share_links;