diff --git a/b0esche_cloud/lib/pages/public_file_viewer.dart b/b0esche_cloud/lib/pages/public_file_viewer.dart index a73e9dc..2e65917 100644 --- a/b0esche_cloud/lib/pages/public_file_viewer.dart +++ b/b0esche_cloud/lib/pages/public_file_viewer.dart @@ -55,15 +55,6 @@ class _PublicFileViewerState extends State { if (_isVideoFile()) { await _initializeVideoPlayer(); } - - // Register DOCX viewer if it's a document file - if (_isDocumentFile()) { - final url = _fileData?['viewUrl'] ?? _fileData?['downloadUrl']; - if (url != null) { - _docxViewType = 'public-docx-viewer-${widget.token.hashCode}'; - _registerDocxViewFactory(url); - } - } } catch (e) { setState(() { _error = 'This link is invalid or has expired.'; @@ -118,29 +109,6 @@ class _PublicFileViewerState extends State { }); } - void _registerDocxViewFactory(String docxUrl) { - ui_web.platformViewRegistry.registerViewFactory(_docxViewType!, ( - int viewId, - ) { - final iframeElement = web.HTMLIFrameElement() - ..src = - 'https://docs.google.com/viewer?url=${Uri.encodeComponent(docxUrl)}&embedded=true' - ..style.width = '100%' - ..style.height = '100%' - ..style.border = 'none'; - - iframeElement.onError.listen((event) { - if (mounted) { - setState(() { - _error = 'Document could not be loaded. Please download the file.'; - }); - } - }); - - return iframeElement; - }); - } - String? _getViewUrl() { return _fileData?['viewUrl'] ?? _fileData?['downloadUrl']; } @@ -262,8 +230,30 @@ class _PublicFileViewerState extends State { ), ); } else if (_isDocumentFile()) { - if (kIsWeb && _docxViewType != null) { - // Use Google Docs viewer for web + if (kIsWeb) { + // Use Collabora viewer for web + _docxViewType ??= 'public-docx-viewer-${widget.token.hashCode}'; + ui_web.platformViewRegistry.registerViewFactory(_docxViewType!, ( + int viewId, + ) { + final iframeElement = web.HTMLIFrameElement() + ..src = viewUrl + ..style.width = '100%' + ..style.height = '100%' + ..style.border = 'none'; + + iframeElement.onError.listen((event) { + if (mounted) { + setState(() { + _error = + 'Document could not be loaded. Please download the file.'; + }); + } + }); + + return iframeElement; + }); + return Expanded( child: Container( color: Colors.white, diff --git a/go_cloud/api b/go_cloud/api index 280ec01..9fc1859 100755 Binary files a/go_cloud/api and b/go_cloud/api differ diff --git a/go_cloud/internal/http/routes.go b/go_cloud/internal/http/routes.go index 63fc004..fd67207 100644 --- a/go_cloud/internal/http/routes.go +++ b/go_cloud/internal/http/routes.go @@ -380,6 +380,15 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut w.Header().Set("Access-Control-Allow-Headers", "Range") w.WriteHeader(http.StatusOK) }) + // Public WOPI routes for shared files + r.Route("/wopi/share/{token}", func(r chi.Router) { + r.Get("/", func(w http.ResponseWriter, req *http.Request) { + publicWopiCheckFileInfoHandler(w, req, db, jwtManager) + }) + r.Get("/contents", func(w http.ResponseWriter, req *http.Request) { + publicWopiGetFileHandler(w, req, db, cfg, jwtManager) + }) + }) }) return r @@ -2970,9 +2979,14 @@ func publicFileShareHandler(w http.ResponseWriter, r *http.Request, db *database Token: viewerToken, } - // Set view URL for PDFs, videos, and audio (for inline viewing) + // Set view URL for PDFs, videos, audio, and documents (for inline viewing) if isPdf || strings.HasPrefix(mimeType, "video/") || strings.HasPrefix(mimeType, "audio/") { viewerSession.ViewUrl = viewPath + } else if strings.Contains(mimeType, "document") || strings.Contains(mimeType, "word") || strings.Contains(mimeType, "spreadsheet") || strings.Contains(mimeType, "presentation") { + // Use Collabora for document viewing + wopiSrc := fmt.Sprintf("%s://%s/public/wopi/share/%s", scheme, host, token) + collaboraUrl := fmt.Sprintf("https://of.b0esche.cloud/lool/dist/mobile/cool.html?WOPISrc=%s", url.QueryEscape(wopiSrc)) + viewerSession.ViewUrl = collaboraUrl } viewerSession.Capabilities.CanEdit = false @@ -3359,3 +3373,172 @@ func revokeUserFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db * w.WriteHeader(http.StatusNoContent) } + +// publicWopiCheckFileInfoHandler handles GET /public/wopi/share/{token} +// Returns metadata about the shared file for Collabora viewer +func publicWopiCheckFileInfoHandler(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 + } + + // Get share link + 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 + } + + if file.UserID == nil { + errors.WriteError(w, errors.CodeNotFound, "File not accessible", http.StatusNotFound) + return + } + + lastModifiedTime := file.LastModified + if lastModifiedTime.IsZero() { + lastModifiedTime = time.Now() + } + + response := struct { + BaseFileName string `json:"BaseFileName"` + Size int64 `json:"Size"` + OwnerId string `json:"OwnerId"` + Version string `json:"Version"` + SupportsExtendedLockLength bool `json:"SupportsExtendedLockLength"` + SupportsGetLock bool `json:"SupportsGetLock"` + SupportsLocks bool `json:"SupportsLocks"` + SupportsUpdate bool `json:"SupportsUpdate"` + UserId string `json:"UserId"` + UserFriendlyName string `json:"UserFriendlyName"` + UserCanWrite bool `json:"UserCanWrite"` + UserCanNotWriteRelative bool `json:"UserCanNotWriteRelative"` + ReadOnly bool `json:"ReadOnly"` + RestrictedWebViewOnly bool `json:"RestrictedWebViewOnly"` + UserCanCreateRelativeToFolder bool `json:"UserCanCreateRelativeToFolder"` + EnableOwnerTermination bool `json:"EnableOwnerTermination"` + SupportsCobalt bool `json:"SupportsCobalt"` + SupportsDelete bool `json:"SupportsDelete"` + SupportsRename bool `json:"SupportsRename"` + SupportsRenameRelativeToFolder bool `json:"SupportsRenameRelativeToFolder"` + SupportsFolders bool `json:"SupportsFolders"` + SupportsScenarios []string `json:"SupportsScenarios"` + LastModifiedTime string `json:"LastModifiedTime"` + IsAnonymousUser bool `json:"IsAnonymousUser"` + TimeZone string `json:"TimeZone"` + }{ + BaseFileName: file.Name, + Size: file.Size, + OwnerId: file.UserID.String(), + Version: file.LastModified.UTC().Format(time.RFC3339), + SupportsExtendedLockLength: false, + SupportsGetLock: false, + SupportsLocks: false, + SupportsUpdate: false, + UserId: "anonymous", + UserFriendlyName: "Anonymous User", + UserCanWrite: false, + UserCanNotWriteRelative: true, + ReadOnly: true, // Public sharing is read-only + RestrictedWebViewOnly: true, // Only allow web view + UserCanCreateRelativeToFolder: false, + EnableOwnerTermination: false, + SupportsCobalt: false, + SupportsDelete: false, + SupportsRename: false, + SupportsRenameRelativeToFolder: false, + SupportsFolders: false, + SupportsScenarios: []string{"embedview", "view"}, + LastModifiedTime: lastModifiedTime.UTC().Format(time.RFC3339), + IsAnonymousUser: true, + TimeZone: "UTC", + } + + fmt.Printf("[PUBLIC-WOPI] CheckFileInfo: file=%s token=%s size=%d\n", file.ID.String(), token, file.Size) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} + +// publicWopiGetFileHandler handles GET /public/wopi/share/{token}/contents +// Downloads the shared file content for Collabora +func publicWopiGetFileHandler(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 + } + + fmt.Printf("[PUBLIC-WOPI-GetFile] START: token=%s\n", token) + + // Get share link + 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 + } + + 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 + } + + // Create context with longer timeout for file downloads + downloadCtx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) + defer cancel() + + // Stream file + resp, err := client.Download(downloadCtx, file.Path, r.Header.Get("Range")) + 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 from Nextcloud response + for k, v := range resp.Header { + w.Header()[k] = v + } + + // Set status code (200 or 206 for partial) + w.WriteHeader(resp.StatusCode) + + // Copy body + io.Copy(w, resp.Body) +}