Reorder PublicFileViewer header: download button left, close button right, add 4px top padding

This commit is contained in:
Leon Bösche
2026-01-25 00:57:55 +01:00
parent c29bd89a0a
commit 8fd0ded519
2 changed files with 147 additions and 28 deletions

View File

@@ -59,15 +59,22 @@ class _PublicFileViewerState extends State<PublicFileViewer> {
} }
Future<void> _initializeVideoPlayer() async { Future<void> _initializeVideoPlayer() async {
if (_fileData?['downloadUrl'] != null) { final url = _fileData?['viewUrl'] ?? _fileData?['downloadUrl'];
_videoController = VideoPlayerController.networkUrl( if (url != null) {
Uri.parse(_fileData!['downloadUrl']), _videoController = VideoPlayerController.networkUrl(Uri.parse(url));
);
await _videoController!.initialize(); await _videoController!.initialize();
setState(() {}); setState(() {});
} }
} }
String? _getViewUrl() {
return _fileData?['viewUrl'] ?? _fileData?['downloadUrl'];
}
String? _getDownloadUrl() {
return _fileData?['downloadUrl'];
}
bool _isVideoFile() { bool _isVideoFile() {
final mimeType = _fileData?['capabilities']?['mimeType'] ?? ''; final mimeType = _fileData?['capabilities']?['mimeType'] ?? '';
return mimeType.toString().startsWith('video/'); return mimeType.toString().startsWith('video/');
@@ -93,20 +100,24 @@ class _PublicFileViewerState extends State<PublicFileViewer> {
} }
void _downloadFile() { void _downloadFile() {
if (_fileData != null && _fileData!['downloadUrl'] != null) { final downloadUrl = _getDownloadUrl();
if (downloadUrl != null) {
// Trigger download directly in browser // Trigger download directly in browser
final anchor = web.HTMLAnchorElement() final anchor = web.HTMLAnchorElement()
..href = _fileData!['downloadUrl'] ..href = downloadUrl
..download = _fileData!['fileName'] ?? 'download'; ..download = _fileData!['fileName'] ?? 'download';
anchor.click(); anchor.click();
} }
} }
Widget _buildFilePreview() { Widget _buildFilePreview() {
final viewUrl = _getViewUrl();
if (viewUrl == null) return const SizedBox();
if (_isPdfFile()) { if (_isPdfFile()) {
return Expanded( return Expanded(
child: SfPdfViewer.network( child: SfPdfViewer.network(
_fileData!['downloadUrl'], viewUrl,
canShowScrollHead: false, canShowScrollHead: false,
canShowScrollStatus: false, canShowScrollStatus: false,
enableDoubleTapZooming: true, enableDoubleTapZooming: true,
@@ -123,7 +134,7 @@ class _PublicFileViewerState extends State<PublicFileViewer> {
} else if (_isAudioFile()) { } else if (_isAudioFile()) {
return AudioPlayerBar( return AudioPlayerBar(
fileName: _fileData!['fileName'] ?? 'Audio', fileName: _fileData!['fileName'] ?? 'Audio',
fileUrl: _fileData!['downloadUrl'], fileUrl: viewUrl,
); );
} else if (_isDocumentFile()) { } else if (_isDocumentFile()) {
return Expanded( return Expanded(
@@ -195,30 +206,31 @@ class _PublicFileViewerState extends State<PublicFileViewer> {
appBar: AppBar( appBar: AppBar(
backgroundColor: AppTheme.primaryBackground, backgroundColor: AppTheme.primaryBackground,
elevation: 0, elevation: 0,
leading: IconButton( leading: _fileData != null
icon: const Icon(Icons.close, color: AppTheme.primaryText), ? Padding(
onPressed: () => context.go('/'), padding: const EdgeInsets.only(left: 16, top: 4),
), child: ModernGlassButton(
onPressed: _downloadFile,
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.download, size: 18),
SizedBox(width: 8),
Text('Download'),
],
),
),
)
: null,
title: Text( title: Text(
_fileData?['fileName'] ?? 'Shared File', _fileData?['fileName'] ?? 'Shared File',
style: TextStyle(color: AppTheme.primaryText), style: TextStyle(color: AppTheme.primaryText),
), ),
actions: [ actions: [
if (_fileData != null) IconButton(
Padding( icon: const Icon(Icons.close, color: AppTheme.primaryText),
padding: const EdgeInsets.only(right: 8), onPressed: () => context.go('/'),
child: ModernGlassButton( ),
onPressed: _downloadFile,
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.download, size: 18),
SizedBox(width: 8),
Text('Download'),
],
),
),
),
], ],
), ),
body: _isLoading body: _isLoading

View File

@@ -359,6 +359,9 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut
r.Get("/share/{token}/download", func(w http.ResponseWriter, req *http.Request) { r.Get("/share/{token}/download", func(w http.ResponseWriter, req *http.Request) {
publicFileDownloadHandler(w, req, db, cfg, jwtManager) publicFileDownloadHandler(w, req, db, cfg, jwtManager)
}) })
r.Get("/share/{token}/view", func(w http.ResponseWriter, req *http.Request) {
publicFileViewHandler(w, req, db, cfg, jwtManager)
})
}) })
return r return r
@@ -2915,7 +2918,7 @@ func publicFileShareHandler(w http.ResponseWriter, r *http.Request, db *database
return return
} }
// Build download URL // Build URLs
scheme := "https" scheme := "https"
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" { if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
scheme = proto scheme = proto
@@ -2924,6 +2927,7 @@ func publicFileShareHandler(w http.ResponseWriter, r *http.Request, db *database
} }
host := "www.b0esche.cloud" host := "www.b0esche.cloud"
downloadPath := fmt.Sprintf("%s://%s/public/share/%s/download?token=%s", scheme, host, token, url.QueryEscape(viewerToken)) downloadPath := fmt.Sprintf("%s://%s/public/share/%s/download?token=%s", scheme, host, token, url.QueryEscape(viewerToken))
viewPath := fmt.Sprintf("%s://%s/public/share/%s/view?token=%s", scheme, host, token, url.QueryEscape(viewerToken))
// Determine file type // Determine file type
isPdf := strings.HasSuffix(strings.ToLower(file.Name), ".pdf") isPdf := strings.HasSuffix(strings.ToLower(file.Name), ".pdf")
@@ -2933,6 +2937,7 @@ func publicFileShareHandler(w http.ResponseWriter, r *http.Request, db *database
FileName string `json:"fileName"` FileName string `json:"fileName"`
FileSize int64 `json:"fileSize"` FileSize int64 `json:"fileSize"`
DownloadUrl string `json:"downloadUrl"` DownloadUrl string `json:"downloadUrl"`
ViewUrl string `json:"viewUrl,omitempty"`
Token string `json:"token"` Token string `json:"token"`
Capabilities struct { Capabilities struct {
CanEdit bool `json:"canEdit"` CanEdit bool `json:"canEdit"`
@@ -2946,6 +2951,12 @@ func publicFileShareHandler(w http.ResponseWriter, r *http.Request, db *database
DownloadUrl: downloadPath, DownloadUrl: downloadPath,
Token: viewerToken, Token: viewerToken,
} }
// Set view URL for PDFs and videos (for inline viewing)
if isPdf || strings.HasPrefix(mimeType, "video/") {
viewerSession.ViewUrl = viewPath
}
viewerSession.Capabilities.CanEdit = false viewerSession.Capabilities.CanEdit = false
viewerSession.Capabilities.CanAnnotate = false viewerSession.Capabilities.CanAnnotate = false
viewerSession.Capabilities.IsPdf = isPdf viewerSession.Capabilities.IsPdf = isPdf
@@ -3050,6 +3061,102 @@ func publicFileDownloadHandler(w http.ResponseWriter, r *http.Request, db *datab
io.Copy(w, resp.Body) io.Copy(w, resp.Body)
} }
func publicFileViewHandler(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
}
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
}
// Verify viewer token (contains org ID for org files, empty for personal)
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 link.OrgID == nil {
if len(claims.OrgIDs) != 0 {
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid token", http.StatusUnauthorized)
return
}
} else {
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
}
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
}
if file.UserID == nil {
errors.WriteError(w, errors.CodeNotFound, "File not accessible", http.StatusNotFound)
return
}
// Get WebDAV client for the file's owner
client, err := getUserWebDAVClient(r.Context(), db, *file.UserID, cfg.NextcloudURL, 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
}
// Ensure inline viewing behavior (no Content-Disposition attachment)
w.Header().Del("Content-Disposition")
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", file.Name))
// Copy body
io.Copy(w, resp.Body)
}
func getUserFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *database.DB) { func getUserFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
userIDStr, _ := middleware.GetUserID(r.Context()) userIDStr, _ := middleware.GetUserID(r.Context())
userID, _ := uuid.Parse(userIDStr) userID, _ := uuid.Parse(userIDStr)