From 1b20fe8b7fdaec3f8db269607ba568a870e957c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leon=20B=C3=B6sche?= Date: Mon, 12 Jan 2026 01:08:22 +0100 Subject: [PATCH] Implement WOPI integration for Collabora Online - Add WOPI models (CheckFileInfoResponse, PutFileResponse, LockInfo) - Implement WOPI handlers: CheckFileInfo, GetFile, PutFile, Lock operations - Add file locking mechanism to prevent concurrent editing conflicts - Add WOPI session endpoint for generating access tokens - Add UpdateFileSize method to database - Add WOPI routes (/wopi/files/* endpoints) - Update Flutter document viewer to load Collabora via WOPI WOPISrc URL - Implement WebView integration for Collabora Online viewer - Add comprehensive logging for WOPI operations [WOPI-TOKEN], [WOPI-REQUEST], [WOPI-STORAGE], [WOPI-LOCK] --- b0esche_cloud/lib/pages/document_viewer.dart | 195 ++++-- go_cloud/internal/database/db.go | 9 + go_cloud/internal/http/routes.go | 30 + go_cloud/internal/http/wopi_handlers.go | 606 +++++++++++++++++++ go_cloud/internal/models/wopi.go | 74 +++ 5 files changed, 861 insertions(+), 53 deletions(-) create mode 100644 go_cloud/internal/http/wopi_handlers.go create mode 100644 go_cloud/internal/models/wopi.go diff --git a/b0esche_cloud/lib/pages/document_viewer.dart b/b0esche_cloud/lib/pages/document_viewer.dart index afdd5a6..28d6ac2 100644 --- a/b0esche_cloud/lib/pages/document_viewer.dart +++ b/b0esche_cloud/lib/pages/document_viewer.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'dart:convert'; import '../theme/app_theme.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../blocs/document_viewer/document_viewer_bloc.dart'; @@ -11,6 +12,7 @@ import '../injection.dart'; import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; import 'package:go_router/go_router.dart'; import 'package:http/http.dart' as http; +import 'package:webview_flutter/webview_flutter.dart'; // Modal version for overlay display class DocumentViewerModal extends StatefulWidget { @@ -325,68 +327,137 @@ class _DocumentViewerModalState extends State { } Widget _buildCollaboraViewer(String documentUrl, String token) { - // Build HTML to embed Collabora Online viewer - // For now, we'll show the document download option with a link to open in Collabora - // A proper implementation would require WOPI protocol support - - return Container( - color: AppTheme.primaryBackground, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.description, - size: 64, - color: AppTheme.accentColor, - ), - const SizedBox(height: 16), - Text( - 'Office Document', - style: TextStyle( - color: AppTheme.primaryText, - fontSize: 18, - fontWeight: FontWeight.bold, + // Create WOPI session to get WOPISrc URL + return FutureBuilder( + future: _createWOPISession(token), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return Container( + color: AppTheme.primaryBackground, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + AppTheme.accentColor, + ), + ), + const SizedBox(height: 16), + Text( + 'Loading document in Collabora Online...', + style: TextStyle( + color: AppTheme.secondaryText, + fontSize: 12, + ), + ), + ], ), ), - const SizedBox(height: 8), - Text( - 'Collabora Online Viewer', - style: TextStyle( - color: AppTheme.secondaryText, - fontSize: 14, + ); + } + + if (snapshot.hasError) { + return Container( + color: AppTheme.primaryBackground, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: Colors.red[400], + ), + const SizedBox(height: 16), + Text( + 'Failed to load document', + style: TextStyle( + color: AppTheme.primaryText, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + '${snapshot.error}', + style: TextStyle( + color: AppTheme.secondaryText, + fontSize: 12, + ), + ), + ], ), ), - const SizedBox(height: 16), - Text( - 'Opening document in Collabora...', - style: TextStyle( - color: AppTheme.secondaryText, - fontSize: 12, + ); + } + + if (!snapshot.hasData) { + return Container( + color: AppTheme.primaryBackground, + child: const Center( + child: Text( + 'No session data', + style: TextStyle(color: AppTheme.primaryText), ), ), - const SizedBox(height: 24), - CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation( - AppTheme.accentColor, - ), - ), - const SizedBox(height: 24), - ElevatedButton.icon( - icon: const Icon(Icons.download), - label: const Text('Download File'), - onPressed: () { - // Open file download - // In a real implementation, you'd use url_launcher - // launchUrl(state.viewUrl); - }, - ), - ], - ), - ), + ); + } + + final wopiSession = snapshot.data!; + + // Build Collabora Online viewer URL with WOPISrc + final collaboraUrl = 'https://of.b0esche.cloud/loleaflet/dist/loleaflet.html?WOPISrc=${Uri.encodeComponent(wopiSession.wopisrc)}'; + + // Use WebView to display Collabora Online + return _buildWebView(collaboraUrl); + }, ); } + Future _createWOPISession(String token) async { + try { + final sessionBloc = BlocProvider.of(context); + final baseUrl = (sessionBloc.state as SessionLoaded).baseUrl; + + // Determine endpoint based on whether we're in org or user workspace + String endpoint; + if (widget.orgId.isNotEmpty && widget.orgId != 'personal') { + endpoint = '/orgs/${widget.orgId}/files/${widget.fileId}/wopi-session'; + } else { + endpoint = '/user/files/${widget.fileId}/wopi-session'; + } + + final response = await http.post( + Uri.parse('$baseUrl$endpoint'), + headers: { + 'Authorization': 'Bearer $token', + 'Content-Type': 'application/json', + }, + ).timeout(const Duration(seconds: 10)); + + if (response.statusCode == 200) { + final json = jsonDecode(response.body) as Map; + return WOPISession( + wopisrc: json['wopi_src'] as String, + accessToken: json['access_token'] as String, + ); + } else { + throw Exception('Failed to create WOPI session: ${response.statusCode}'); + } + } catch (e) { + throw Exception('Error creating WOPI session: $e'); + } + } + + Widget _buildWebView(String url) { + final controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..loadRequest(Uri.parse(url)); + + return WebViewWidget(controller: controller); + } + @override void dispose() { _viewerBloc.close(); @@ -394,6 +465,24 @@ class _DocumentViewerModalState extends State { } } +// WOPI Session model for Collabora Online integration +class WOPISession { + final String wopisrc; + final String accessToken; + + WOPISession({ + required this.wopisrc, + required this.accessToken, + }); + + factory WOPISession.fromJson(Map json) { + return WOPISession( + wopisrc: json['wopi_src'] as String, + accessToken: json['access_token'] as String, + ); + } +} + // Original page version (for routing if needed) class DocumentViewer extends StatefulWidget { final String orgId; diff --git a/go_cloud/internal/database/db.go b/go_cloud/internal/database/db.go index 4e4ae9f..67db336 100644 --- a/go_cloud/internal/database/db.go +++ b/go_cloud/internal/database/db.go @@ -659,3 +659,12 @@ func (db *DB) MarkChallengeUsed(ctx context.Context, challenge []byte) error { `, challenge) return err } +// UpdateFileSize updates the size and last_modified timestamp of a file +func (db *DB) UpdateFileSize(ctx context.Context, fileID uuid.UUID, size int64) error { + _, err := db.ExecContext(ctx, ` + UPDATE files + SET size = $1, last_modified = NOW() + WHERE id = $2 + `, size, fileID) + return err +} \ No newline at end of file diff --git a/go_cloud/internal/http/routes.go b/go_cloud/internal/http/routes.go index cdcbf94..d68ec39 100644 --- a/go_cloud/internal/http/routes.go +++ b/go_cloud/internal/http/routes.go @@ -98,6 +98,28 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut // Health check r.Get("/health", healthHandler) + // WOPI routes (public, token validation done per endpoint) + r.Route("/wopi", func(r chi.Router) { + r.Route("/files/{fileId}", func(r chi.Router) { + // CheckFileInfo: GET /wopi/files/{fileId} + r.Get("/", func(w http.ResponseWriter, req *http.Request) { + wopiCheckFileInfoHandler(w, req, db, jwtManager) + }) + // GetFile: GET /wopi/files/{fileId}/contents + r.Get("/contents", func(w http.ResponseWriter, req *http.Request) { + wopiGetFileHandler(w, req, db, jwtManager, cfg) + }) + // PutFile & Lock operations: POST /wopi/files/{fileId}/contents and POST /wopi/files/{fileId} + r.Post("/contents", func(w http.ResponseWriter, req *http.Request) { + wopiPutFileHandler(w, req, db, jwtManager) + }) + // Lock operations: POST /wopi/files/{fileId} + r.Post("/", func(w http.ResponseWriter, req *http.Request) { + wopiLockHandler(w, req, db, jwtManager) + }) + }) + }) + // Auth routes (no auth required) r.Route("/auth", func(r chi.Router) { r.Post("/refresh", func(w http.ResponseWriter, req *http.Request) { @@ -159,6 +181,10 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut r.Post("/user/files/move", func(w http.ResponseWriter, req *http.Request) { moveUserFileHandler(w, req, db, auditLogger, cfg) }) + // WOPI session for user files + r.Post("/user/files/{fileId}/wopi-session", func(w http.ResponseWriter, req *http.Request) { + wopiSessionHandler(w, req, db, jwtManager, "https://of.b0esche.cloud") + }) // Org routes r.Get("/orgs", func(w http.ResponseWriter, req *http.Request) { @@ -211,6 +237,10 @@ 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) }) + // 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") + }) }) r.Get("/activity", func(w http.ResponseWriter, req *http.Request) { activityHandler(w, req, db) diff --git a/go_cloud/internal/http/wopi_handlers.go b/go_cloud/internal/http/wopi_handlers.go new file mode 100644 index 0000000..398e2c0 --- /dev/null +++ b/go_cloud/internal/http/wopi_handlers.go @@ -0,0 +1,606 @@ +package http + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "go.b0esche.cloud/backend/internal/config" + "go.b0esche.cloud/backend/internal/database" + "go.b0esche.cloud/backend/internal/errors" + "go.b0esche.cloud/backend/internal/middleware" + "go.b0esche.cloud/backend/internal/models" + "go.b0esche.cloud/backend/internal/storage" + "go.b0esche.cloud/backend/pkg/jwt" +) + +// WOPILockManager manages file locks to prevent concurrent editing conflicts +type WOPILockManager struct { + locks map[string]*models.WOPILockInfo + mu sync.RWMutex +} + +var lockManager = &WOPILockManager{ + locks: make(map[string]*models.WOPILockInfo), +} + +// AcquireLock tries to acquire a lock for a file +func (m *WOPILockManager) AcquireLock(fileID, userID string) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if existing, ok := m.locks[fileID]; ok { + // Check if lock has expired + if time.Now().Before(existing.ExpiresAt) { + // Lock still active - check if same user + if existing.UserID != userID { + fmt.Printf("[WOPI-LOCK] Lock conflict: file=%s locked_by=%s requested_by=%s\n", fileID, existing.UserID, userID) + return "", fmt.Errorf("file locked by another user") + } + // Same user, refresh the lock + lockID := uuid.New().String() + m.locks[fileID] = &models.WOPILockInfo{ + FileID: fileID, + UserID: userID, + LockID: lockID, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(30 * time.Minute), + } + fmt.Printf("[WOPI-LOCK] Lock refreshed: file=%s user=%s lock_id=%s\n", fileID, userID, lockID) + return lockID, nil + } + // Lock expired, remove it + delete(m.locks, fileID) + } + + // Acquire new lock + lockID := uuid.New().String() + m.locks[fileID] = &models.WOPILockInfo{ + FileID: fileID, + UserID: userID, + LockID: lockID, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(30 * time.Minute), + } + fmt.Printf("[WOPI-LOCK] Lock acquired: file=%s user=%s lock_id=%s\n", fileID, userID, lockID) + return lockID, nil +} + +// ReleaseLock releases a lock for a file +func (m *WOPILockManager) ReleaseLock(fileID, userID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if lock, ok := m.locks[fileID]; ok { + if lock.UserID == userID { + delete(m.locks, fileID) + fmt.Printf("[WOPI-LOCK] Lock released: file=%s user=%s\n", fileID, userID) + return nil + } + return fmt.Errorf("lock held by different user") + } + return fmt.Errorf("no lock found") +} + +// GetLock returns the current lock info for a file +func (m *WOPILockManager) GetLock(fileID string) *models.WOPILockInfo { + m.mu.RLock() + defer m.mu.RUnlock() + + if lock, ok := m.locks[fileID]; ok { + // Check if expired + if time.Now().Before(lock.ExpiresAt) { + return lock + } + // Expired, will be cleaned up on next acquire attempt + } + return nil +} + +// validateWOPIAccessToken validates a WOPI access token +func validateWOPIAccessToken(tokenString string, jwtManager *jwt.Manager) (*jwt.Claims, error) { + claims, err := jwtManager.Validate(tokenString) + if err != nil { + fmt.Printf("[WOPI-TOKEN] Token validation failed: %v\n", err) + return nil, err + } + + // Check if token has expired + if time.Now().After(claims.ExpiresAt.Time) { + fmt.Printf("[WOPI-TOKEN] Token expired: user=%s\n", claims.UserID) + return nil, fmt.Errorf("token expired") + } + + fmt.Printf("[WOPI-TOKEN] Token validated: user=%s expires=%v\n", claims.UserID, claims.ExpiresAt.Time) + return claims, nil +} + +// WOPICheckFileInfoHandler handles GET /wopi/files/{fileId} +// Returns metadata about the file and user permissions +func wopiCheckFileInfoHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager) { + fileID := r.PathValue("fileId") + if fileID == "" { + errors.WriteError(w, errors.CodeInvalidArgument, "Missing fileId", http.StatusBadRequest) + return + } + + // Get access token from query parameter + accessToken := r.URL.Query().Get("access_token") + if accessToken == "" { + errors.WriteError(w, errors.CodeUnauthenticated, "Missing access_token", http.StatusUnauthorized) + return + } + + // Validate token + claims, err := validateWOPIAccessToken(accessToken, jwtManager) + if err != nil { + errors.WriteError(w, errors.CodeUnauthenticated, "Invalid or expired token", http.StatusUnauthorized) + return + } + + userID, _ := uuid.Parse(claims.UserID) + + // Get file info from database + fileUUID, err := uuid.Parse(fileID) + if err != nil { + errors.WriteError(w, errors.CodeInvalidArgument, "Invalid fileId format", http.StatusBadRequest) + return + } + + file, err := db.GetFileByID(r.Context(), fileUUID) + if err != nil { + fmt.Printf("[WOPI-REQUEST] File not found: file=%s error=%v\n", fileID, err) + errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound) + return + } + + // Verify user has access to this file + canAccess := false + var ownerID string + + if file.UserID != nil && *file.UserID == userID { + canAccess = true + ownerID = userID.String() + } else if file.OrgID != nil { + // Check if user is member of the org + member, err := db.GetOrgMember(r.Context(), *file.OrgID, userID) + if err == nil && member != nil { + canAccess = true + ownerID = file.OrgID.String() + } + } + + if !canAccess { + fmt.Printf("[WOPI-REQUEST] Access denied: file=%s user=%s\n", fileID, userID.String()) + errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden) + return + } + + // Build response + response := models.WOPICheckFileInfoResponse{ + BaseFileName: file.Name, + Size: file.Size, + Version: file.ID.String(), + OwnerId: ownerID, + UserId: userID.String(), + UserFriendlyName: "", // Could be populated from user info + UserCanWrite: true, + UserCanRename: false, + UserCanNotWriteRelative: false, + ReadOnly: false, + RestrictedWebViewOnly: false, + UserCanCreateRelativeToFolder: false, + EnableOwnerTermination: false, + SupportsUpdate: true, + SupportsCobalt: false, + SupportsLocks: true, + SupportsExtendedLockLength: false, + SupportsGetLock: true, + SupportsDelete: false, + SupportsRename: false, + SupportsRenameRelativeToFolder: false, + SupportsFolders: false, + SupportsScenarios: []string{"default"}, + LastModifiedTime: file.LastModified.UTC().Format(time.RFC3339), + IsAnonymousUser: false, + TimeZone: "UTC", + } + + fmt.Printf("[WOPI-REQUEST] CheckFileInfo: file=%s user=%s size=%d\n", fileID, userID.String(), file.Size) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} + +// WOPIGetFileHandler handles GET /wopi/files/{fileId}/contents +// Downloads the document file content +func wopiGetFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, cfg *config.Config) { + fileID := r.PathValue("fileId") + if fileID == "" { + errors.WriteError(w, errors.CodeInvalidArgument, "Missing fileId", http.StatusBadRequest) + return + } + + // Get access token from query parameter + accessToken := r.URL.Query().Get("access_token") + if accessToken == "" { + errors.WriteError(w, errors.CodeUnauthenticated, "Missing access_token", http.StatusUnauthorized) + return + } + + // Validate token + claims, err := validateWOPIAccessToken(accessToken, jwtManager) + if err != nil { + errors.WriteError(w, errors.CodeUnauthenticated, "Invalid or expired token", http.StatusUnauthorized) + return + } + + userID, _ := uuid.Parse(claims.UserID) + + // Get file info from database + fileUUID, err := uuid.Parse(fileID) + if err != nil { + errors.WriteError(w, errors.CodeInvalidArgument, "Invalid fileId format", http.StatusBadRequest) + return + } + + file, err := db.GetFileByID(r.Context(), fileUUID) + if err != nil { + fmt.Printf("[WOPI-REQUEST] GetFile - File not found: file=%s error=%v\n", fileID, err) + errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound) + return + } + + // Verify user has access to this file + canAccess := false + var webDAVClient *storage.WebDAVClient + + if file.UserID != nil && *file.UserID == userID { + canAccess = true + // Get user's WebDAV client - need to pass config + // For now, create a new WebDAV client without full config + webDAVClient, err = getUserWebDAVClient(r.Context(), db, userID, "http://nc.b0esche.cloud", "admin", "") + if err != nil { + fmt.Printf("[WOPI-STORAGE] Failed to get user WebDAV client: %v\n", err) + errors.WriteError(w, errors.CodeInternal, "Storage error", http.StatusInternalServerError) + return + } + } else if file.OrgID != nil { + // Check if user is member of the org + member, err := db.GetOrgMember(r.Context(), *file.OrgID, userID) + if err == nil && member != nil { + canAccess = true + // Create admin WebDAV client for org files + cfg := &config.Config{ + NextcloudURL: "http://nc.b0esche.cloud", + NextcloudUser: "admin", + NextcloudPass: "", + NextcloudBase: "/", + } + webDAVClient = storage.NewWebDAVClient(cfg) + } + } + + if !canAccess { + fmt.Printf("[WOPI-REQUEST] GetFile - Access denied: file=%s user=%s\n", fileID, userID.String()) + errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden) + return + } + + // Download file from storage + resp, err := webDAVClient.Download(r.Context(), file.Path, "") + if err != nil { + fmt.Printf("[WOPI-STORAGE] Failed to download file: file=%s path=%s error=%v\n", fileID, file.Path, err) + errors.WriteError(w, errors.CodeNotFound, "File not found in storage", http.StatusNotFound) + return + } + defer resp.Body.Close() + + // Set response headers + contentType := getMimeType(file.Name) + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Length", fmt.Sprintf("%d", file.Size)) + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", file.Name)) + w.WriteHeader(http.StatusOK) + + fmt.Printf("[WOPI-STORAGE] GetFile: file=%s user=%s bytes=%d\n", fileID, userID.String(), file.Size) + + // Stream file content + io.Copy(w, resp.Body) +} + +// WOPIPutFileHandler handles POST /wopi/files/{fileId}/contents +// Uploads edited document back to storage +func wopiPutFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager) { + fileID := r.PathValue("fileId") + if fileID == "" { + errors.WriteError(w, errors.CodeInvalidArgument, "Missing fileId", http.StatusBadRequest) + return + } + + // Get access token from Authorization header + authHeader := r.Header.Get("Authorization") + if !strings.HasPrefix(authHeader, "Bearer ") { + errors.WriteError(w, errors.CodeUnauthenticated, "Missing authorization", http.StatusUnauthorized) + return + } + accessToken := strings.TrimPrefix(authHeader, "Bearer ") + + // Validate token + claims, err := validateWOPIAccessToken(accessToken, jwtManager) + if err != nil { + errors.WriteError(w, errors.CodeUnauthenticated, "Invalid or expired token", http.StatusUnauthorized) + return + } + + userID, _ := uuid.Parse(claims.UserID) + + // Get file info from database + fileUUID, err := uuid.Parse(fileID) + if err != nil { + errors.WriteError(w, errors.CodeInvalidArgument, "Invalid fileId format", http.StatusBadRequest) + return + } + + file, err := db.GetFileByID(r.Context(), fileUUID) + if err != nil { + fmt.Printf("[WOPI-REQUEST] PutFile - File not found: file=%s\n", fileID) + errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound) + return + } + + // Verify user has access to this file + canAccess := false + var webDAVClient *storage.WebDAVClient + + if file.UserID != nil && *file.UserID == userID { + canAccess = true + webDAVClient, err = getUserWebDAVClient(r.Context(), db, userID, "http://nc.b0esche.cloud", "admin", "") + if err != nil { + fmt.Printf("[WOPI-STORAGE] Failed to get user WebDAV client: %v\n", err) + errors.WriteError(w, errors.CodeInternal, "Storage error", http.StatusInternalServerError) + return + } + } else if file.OrgID != nil { + member, err := db.GetOrgMember(r.Context(), *file.OrgID, userID) + if err == nil && member != nil { + canAccess = true + // Create admin WebDAV client for org files + cfg := &config.Config{ + NextcloudURL: "http://nc.b0esche.cloud", + NextcloudUser: "admin", + NextcloudPass: "", + NextcloudBase: "/", + } + webDAVClient = storage.NewWebDAVClient(cfg) + } + } + + if !canAccess { + fmt.Printf("[WOPI-REQUEST] PutFile - Access denied: file=%s user=%s\n", fileID, userID.String()) + errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden) + return + } + + // Check lock + lock := lockManager.GetLock(fileID) + if lock != nil && lock.UserID != userID.String() { + fmt.Printf("[WOPI-LOCK] Put conflict: file=%s locked_by=%s user=%s\n", fileID, lock.UserID, userID.String()) + w.WriteHeader(http.StatusConflict) + return + } + + // Read file content from request body + content, err := io.ReadAll(r.Body) + if err != nil { + fmt.Printf("[WOPI-STORAGE] Failed to read request body: %v\n", err) + errors.WriteError(w, errors.CodeInternal, "Failed to read content", http.StatusInternalServerError) + return + } + defer r.Body.Close() + + // Upload to storage + err = webDAVClient.Upload(r.Context(), file.Path, strings.NewReader(string(content)), int64(len(content))) + if err != nil { + fmt.Printf("[WOPI-STORAGE] Failed to upload file: file=%s path=%s error=%v\n", fileID, file.Path, err) + errors.WriteError(w, errors.CodeInternal, "Failed to save file", http.StatusInternalServerError) + return + } + + // Update file size and modification time in database + newSize := int64(len(content)) + err = db.UpdateFileSize(r.Context(), fileUUID, newSize) + if err != nil { + fmt.Printf("[WOPI-STORAGE] Failed to update file size: file=%s error=%v\n", fileID, err) + // Don't fail the upload, just log the warning + } + + fmt.Printf("[WOPI-STORAGE] PutFile: file=%s user=%s bytes=%d\n", fileID, userID.String(), newSize) + + // Return response + response := models.WOPIPutFileResponse{ + ItemVersion: fileUUID.String(), + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} + +// WOPILockHandler handles POST /wopi/files/{fileId} with X-WOPI-Override header for lock operations +func wopiLockHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager) { + fileID := r.PathValue("fileId") + if fileID == "" { + errors.WriteError(w, errors.CodeInvalidArgument, "Missing fileId", http.StatusBadRequest) + return + } + + // Get access token from Authorization header + authHeader := r.Header.Get("Authorization") + if !strings.HasPrefix(authHeader, "Bearer ") { + errors.WriteError(w, errors.CodeUnauthenticated, "Missing authorization", http.StatusUnauthorized) + return + } + accessToken := strings.TrimPrefix(authHeader, "Bearer ") + + // Validate token + claims, err := validateWOPIAccessToken(accessToken, jwtManager) + if err != nil { + errors.WriteError(w, errors.CodeUnauthenticated, "Invalid or expired token", http.StatusUnauthorized) + return + } + + userID := claims.UserID + override := r.Header.Get("X-WOPI-Override") + + // Get file to verify access + fileUUID, err := uuid.Parse(fileID) + if err != nil { + errors.WriteError(w, errors.CodeInvalidArgument, "Invalid fileId format", http.StatusBadRequest) + return + } + + file, err := db.GetFileByID(r.Context(), fileUUID) + if err != nil { + errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound) + return + } + + // Verify access + canAccess := false + if file.UserID != nil && file.UserID.String() == userID { + canAccess = true + } else if file.OrgID != nil { + userUUID, _ := uuid.Parse(userID) + member, err := db.GetOrgMember(r.Context(), *file.OrgID, userUUID) + canAccess = (err == nil && member != nil) + } + + if !canAccess { + errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden) + return + } + + // Handle lock operations + switch override { + case "LOCK": + // Acquire lock + lockID, err := lockManager.AcquireLock(fileID, userID) + if err != nil { + fmt.Printf("[WOPI-LOCK] Lock acquisition failed: file=%s user=%s error=%s\n", fileID, userID, err.Error()) + w.WriteHeader(http.StatusConflict) + w.Write([]byte(fmt.Sprintf(`{"error": "%s"}`, err.Error()))) + return + } + + w.Header().Set("X-WOPI-LockID", lockID) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + + case "UNLOCK": + // Release lock + err := lockManager.ReleaseLock(fileID, userID) + if err != nil { + fmt.Printf("[WOPI-LOCK] Lock release failed: file=%s user=%s error=%s\n", fileID, userID, err.Error()) + w.WriteHeader(http.StatusConflict) + w.Write([]byte(fmt.Sprintf(`{"error": "%s"}`, err.Error()))) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + + case "GET_LOCK": + // Get lock info + lock := lockManager.GetLock(fileID) + if lock == nil { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + return + } + + w.Header().Set("X-WOPI-LockID", lock.LockID) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + + default: + errors.WriteError(w, errors.CodeInvalidArgument, "Unknown X-WOPI-Override value", http.StatusBadRequest) + } +} + +// WOPISessionHandler handles POST /user/files/{fileId}/wopi-session and /orgs/{orgId}/files/{fileId}/wopi-session +// Returns WOPISrc URL and access token for opening document in Collabora +func wopiSessionHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, collaboraURL string) { + fileID := r.PathValue("fileId") + if fileID == "" { + errors.WriteError(w, errors.CodeInvalidArgument, "Missing fileId", http.StatusBadRequest) + return + } + + // Get user from context (from auth middleware) + userIDStr, ok := middleware.GetUserID(r.Context()) + if !ok || userIDStr == "" { + errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized) + return + } + + userID, _ := uuid.Parse(userIDStr) + + // Get file info + fileUUID, err := uuid.Parse(fileID) + if err != nil { + errors.WriteError(w, errors.CodeInvalidArgument, "Invalid fileId format", http.StatusBadRequest) + return + } + + file, err := db.GetFileByID(r.Context(), fileUUID) + if err != nil { + errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound) + return + } + + // Verify access + canAccess := false + if file.UserID != nil && *file.UserID == userID { + canAccess = true + } else if file.OrgID != nil { + member, err := db.GetOrgMember(r.Context(), *file.OrgID, userID) + canAccess = (err == nil && member != nil) + } + + if !canAccess { + errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden) + return + } + + // Generate WOPI access token (1 hour duration) + accessToken, err := jwtManager.GenerateWithDuration(userID.String(), nil, "", 1*time.Hour) + if err != nil { + errors.WriteError(w, errors.CodeInternal, "Failed to generate token", http.StatusInternalServerError) + return + } + + // Build WOPISrc URL + wopisrc := fmt.Sprintf("https://go.b0esche.cloud/wopi/files/%s?access_token=%s", fileID, accessToken) + + response := models.WOPISessionResponse{ + WOPISrc: wopisrc, + AccessToken: accessToken, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) + + fmt.Printf("[WOPI-REQUEST] Session created: file=%s user=%s\n", fileID, userID.String()) +} diff --git a/go_cloud/internal/models/wopi.go b/go_cloud/internal/models/wopi.go new file mode 100644 index 0000000..df64016 --- /dev/null +++ b/go_cloud/internal/models/wopi.go @@ -0,0 +1,74 @@ +package models +package models + +import "time" + +// WOPICheckFileInfoResponse represents the response to WOPI CheckFileInfo request +// Reference: https://docs.microsoft.com/en-us/openspecs/office_protocols/ms-wopi/4b8ffc3f-e8a6-4169-8c4e-34924ac6ae2f +type WOPICheckFileInfoResponse struct { + BaseFileName string `json:"BaseFileName"` + Size int64 `json:"Size"` + Version string `json:"Version"` + OwnerId string `json:"OwnerId"` + UserId string `json:"UserId"` + UserFriendlyName string `json:"UserFriendlyName"` + UserCanWrite bool `json:"UserCanWrite"` + UserCanRename bool `json:"UserCanRename"` + UserCanNotWriteRelative bool `json:"UserCanNotWriteRelative"` + ReadOnly bool `json:"ReadOnly"` + RestrictedWebViewOnly bool `json:"RestrictedWebViewOnly"` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +} AccessToken string `json:"access_token"` WOPISrc string `json:"wopi_src"`type WOPISessionResponse struct {// WOPISessionResponse represents a response for creating a WOPI session} ClosePostMessage bool `json:"close_post_message"` BootstrapperUrl string `json:"bootstrapper_url,omitempty"` AccessTokenTTL int64 `json:"access_token_ttl"` AccessToken string `json:"access_token"`type WOPIAccessTokenResponse struct {// WOPIAccessTokenResponse represents a response with WOPI access token} FileID string `json:"file_id"`type WOPIAccessTokenRequest struct {// WOPIAccessTokenRequest represents a request to get WOPI access token} ExpiresAt time.Time `json:"expires_at"` CreatedAt time.Time `json:"created_at"` LockID string `json:"lock_id"` UserID string `json:"user_id"` FileID string `json:"file_id"`type WOPILockInfo struct {// WOPILockInfo represents information about a file lock} ItemVersion string `json:"ItemVersion"`type WOPIPutFileResponse struct {// WOPIPutFileResponse represents the response to WOPI PutFile request} DownloadUrl string `json:"DownloadUrl,omitempty"` FileSharingUrl string `json:"FileSharingUrl,omitempty"` ViewUrl string `json:"ViewUrl,omitempty"` EditUrl string `json:"EditUrl,omitempty"` CloseUrl string `json:"CloseUrl,omitempty"` TimeZone string `json:"TimeZone"` IsAnonymousUser bool `json:"IsAnonymousUser"` LastModifiedTime string `json:"LastModifiedTime"` SupportsScenarios []string `json:"SupportsScenarios"` SupportsFolders bool `json:"SupportsFolders"` SupportsRenameRelativeToFolder bool `json:"SupportsRenameRelativeToFolder"` SupportsRename bool `json:"SupportsRename"` SupportsDelete bool `json:"SupportsDelete"` SupportsGetLock bool `json:"SupportsGetLock"` SupportsExtendedLockLength bool `json:"SupportsExtendedLockLength"` SupportsLocks bool `json:"SupportsLocks"` SupportsCobalt bool `json:"SupportsCobalt"` SupportsUpdate bool `json:"SupportsUpdate"` EnableOwnerTermination bool `json:"EnableOwnerTermination"` UserCanCreateRelativeToFolder bool `json:"UserCanCreateRelativeToFolder"` \ No newline at end of file