Implement public WOPI routes for shared files and integrate Collabora for document viewing

This commit is contained in:
Leon Bösche
2026-01-25 15:47:59 +01:00
parent 7582f27899
commit 0aea602122
3 changed files with 208 additions and 35 deletions

View File

@@ -55,15 +55,6 @@ class _PublicFileViewerState extends State<PublicFileViewer> {
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<PublicFileViewer> {
});
}
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<PublicFileViewer> {
),
);
} 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,

Binary file not shown.

View File

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