package http import ( "encoding/json" "encoding/xml" "fmt" "io" "net/http" "net/url" "path" "path/filepath" "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" ) // Collabora discovery cache var ( collaboraEditorURL string collaboraDiscoveryCache time.Time collaboraDiscoveryMu sync.RWMutex ) // getCollaboraEditorURL fetches the editor URL from Collabora's discovery endpoint func getCollaboraEditorURL(collaboraBaseURL string) string { collaboraDiscoveryMu.RLock() // Cache for 5 minutes if collaboraEditorURL != "" && time.Since(collaboraDiscoveryCache) < 5*time.Minute { url := collaboraEditorURL collaboraDiscoveryMu.RUnlock() return url } collaboraDiscoveryMu.RUnlock() // Fetch discovery collaboraDiscoveryMu.Lock() defer collaboraDiscoveryMu.Unlock() // Double-check after acquiring write lock if collaboraEditorURL != "" && time.Since(collaboraDiscoveryCache) < 5*time.Minute { return collaboraEditorURL } discoveryURL := collaboraBaseURL + "/hosting/discovery" resp, err := http.Get(discoveryURL) if err != nil { fmt.Printf("[COLLABORA] Failed to fetch discovery: %v\n", err) // Fallback to guessed URL return collaboraBaseURL + "/browser/dist/cool.html" } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { fmt.Printf("[COLLABORA] Failed to read discovery: %v\n", err) return collaboraBaseURL + "/browser/dist/cool.html" } // Parse XML to extract urlsrc type Action struct { Name string `xml:"name,attr"` Ext string `xml:"ext,attr"` URLSrc string `xml:"urlsrc,attr"` } type App struct { Name string `xml:"name,attr"` Actions []Action `xml:"action"` } type NetZone struct { Apps []App `xml:"app"` } type WopiDiscovery struct { NetZone NetZone `xml:"net-zone"` } var discovery WopiDiscovery if err := xml.Unmarshal(body, &discovery); err != nil { fmt.Printf("[COLLABORA] Failed to parse discovery XML: %v\n", err) return collaboraBaseURL + "/browser/dist/cool.html" } // Find the first edit action URL (they all have the same base) for _, app := range discovery.NetZone.Apps { for _, action := range app.Actions { if action.URLSrc != "" { // Extract base URL (remove query string marker) url := strings.TrimSuffix(action.URLSrc, "?") collaboraEditorURL = url collaboraDiscoveryCache = time.Now() fmt.Printf("[COLLABORA] Discovered editor URL: %s\n", url) return url } } } fmt.Printf("[COLLABORA] No editor URL found in discovery\n") return collaboraBaseURL + "/browser/dist/cool.html" } // 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 } // URL-decode the token in case it was encoded if decodedToken, err := url.QueryUnescape(accessToken); err == nil { accessToken = decodedToken } fmt.Printf("[WOPI-DEBUG] CheckFileInfo received token: %s\n", accessToken) // 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 } fmt.Printf("[WOPI-CheckFileInfo] START: file=%s user=%s size=%d path=%s\n", fileID, userID.String(), file.Size, file.Path) // Get user info for UserFriendlyName user, err := db.GetUserByID(r.Context(), userID) if err != nil { fmt.Printf("[WOPI-REQUEST] Failed to get user info: user=%s error=%v\n", userID.String(), err) errors.WriteError(w, errors.CodeInternal, "Failed to get user info", http.StatusInternalServerError) return } // Verify user has access to this file canAccess := false var ownerID string // Prefer org ownership when file belongs to an org and the user is a member if file.OrgID != nil { member, err := db.GetOrgMember(r.Context(), *file.OrgID, userID) if err == nil && member != nil { canAccess = true ownerID = file.OrgID.String() } } // Fallback to per-user file ownership if !canAccess && file.UserID != nil && *file.UserID == userID { canAccess = true ownerID = userID.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 } // Ensure LastModifiedTime is not zero lastModifiedTime := file.LastModified if lastModifiedTime.IsZero() { lastModifiedTime = file.CreatedAt } if lastModifiedTime.IsZero() { lastModifiedTime = time.Now() } // Build response response := models.WOPICheckFileInfoResponse{ BaseFileName: file.Name, Size: file.Size, Version: file.ID.String(), OwnerId: ownerID, UserId: userID.String(), UserFriendlyName: user.DisplayName, 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: lastModifiedTime.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 } fmt.Printf("[WOPI-GetFile] START: file=%s\n", fileID) // 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 var remotePath string // Prefer org storage when present and the user is a member if file.OrgID != nil { member, err := db.GetOrgMember(r.Context(), *file.OrgID, userID) if err == nil && member != nil { canAccess = true // Use user's WebDAV client for org files too webDAVClient, err = getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass) 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 } // Org files: stored under /orgs/{orgID}/ prefix rel := strings.TrimPrefix(file.Path, "/") remotePath = path.Join("/orgs", file.OrgID.String(), rel) } } // Fallback to per-user files if !canAccess && file.UserID != nil && *file.UserID == userID { canAccess = true webDAVClient, err = getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass) 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 } // User files: path is relative to user's WebDAV root remotePath = file.Path } 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 fmt.Printf("[WOPI-STORAGE] GetFile downloading: file=%s remotePath=%s\n", fileID, remotePath) resp, err := webDAVClient.Download(r.Context(), remotePath, "") 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() fmt.Printf("[WOPI-STORAGE] Download response status: %d\n", resp.StatusCode) // 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, cfg *config.Config) { 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 ") // URL-decode the token in case it was encoded if decodedToken, err := url.QueryUnescape(accessToken); err == nil { accessToken = decodedToken } fmt.Printf("[WOPI-DEBUG] PutFile received token: %s\n", accessToken) // 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 var remotePath string // Prefer org storage when present and the user is a member if file.OrgID != nil { member, err := db.GetOrgMember(r.Context(), *file.OrgID, userID) if err == nil && member != nil { canAccess = true // Use user's WebDAV client for org files too webDAVClient, err = getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass) 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 } // Org files: stored under /orgs/{orgID}/ prefix rel := strings.TrimPrefix(file.Path, "/") remotePath = path.Join("/orgs", file.OrgID.String(), rel) } } // Fallback to per-user files if !canAccess && file.UserID != nil && *file.UserID == userID { canAccess = true webDAVClient, err = getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass) 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 } // User files: path is relative to user's WebDAV root remotePath = file.Path } 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 first 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() // Check for suggested target (used for export operations like Save as PDF) suggestedTarget := r.Header.Get("X-WOPI-SuggestedTarget") isExport := suggestedTarget != "" var targetFile *database.File var targetRemotePath string if isExport { // Parse suggested target var newName string if strings.HasPrefix(suggestedTarget, ".") { // Extension only, e.g., ".pdf" baseName := strings.TrimSuffix(file.Name, filepath.Ext(file.Name)) newName = baseName + suggestedTarget } else { // Full filename, e.g., "document.pdf" newName = suggestedTarget } // Determine new path var newPath string if file.OrgID != nil { // For org files, place in same directory dir := filepath.Dir(file.Path) newPath = filepath.Join(dir, newName) } else { // For user files, place in same directory dir := filepath.Dir(file.Path) newPath = filepath.Join(dir, newName) } // Create new file entry in database newFile, err := db.CreateFile(r.Context(), file.OrgID, file.UserID, newName, newPath, "application/pdf", int64(len(content))) if err != nil { fmt.Printf("[WOPI-EXPORT] Failed to create export file: %v\n", err) errors.WriteError(w, errors.CodeInternal, "Failed to create export file", http.StatusInternalServerError) return } targetFile = newFile // Set remote path for the new file if file.OrgID != nil { rel := strings.TrimPrefix(newPath, "/") targetRemotePath = path.Join("/orgs", file.OrgID.String(), rel) } else { targetRemotePath = newPath } fmt.Printf("[WOPI-EXPORT] Export operation: original=%s new=%s path=%s\n", file.Name, newName, targetRemotePath) } else { // Normal save operation targetFile = file targetRemotePath = remotePath } // Upload to storage fmt.Printf("[WOPI-STORAGE] PutFile uploading: file=%s remotePath=%s\n", targetFile.ID.String(), targetRemotePath) err = webDAVClient.Upload(r.Context(), targetRemotePath, 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", targetFile.ID.String(), targetFile.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(), targetFile.ID, newSize, &userID) if err != nil { fmt.Printf("[WOPI-STORAGE] Failed to update file size: file=%s error=%v\n", targetFile.ID.String(), err) // Don't fail the upload, just log the warning } fmt.Printf("[WOPI-STORAGE] PutFile: file=%s user=%s bytes=%d\n", targetFile.ID.String(), userID.String(), newSize) // Return response response := models.WOPIPutFileResponse{ ItemVersion: targetFile.ID.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()) } // CollaboraProxyHandler serves an HTML page that POSTs WOPISrc to Collabora // This avoids CORS issues by having the POST originate from our domain func collaboraProxyHandler(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 { errors.WriteError(w, errors.CodeUnauthenticated, "Not authenticated", http.StatusUnauthorized) return } userID, err := uuid.Parse(userIDStr) if err != nil { errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest) return } // 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 (with access_token as query parameter) // JWT tokens are URL-safe, so no additional encoding needed wopiSrc := fmt.Sprintf("https://go.b0esche.cloud/wopi/files/%s?access_token=%s", fileID, accessToken) // Get the correct Collabora editor URL from discovery (includes version hash) editorURL := getCollaboraEditorURL(collaboraURL) // URL-encode the WOPISrc for use in the form action URL encodedWopiSrc := url.QueryEscape(wopiSrc) // Build the full Collabora URL with WOPISrc as query parameter // Collabora expects: cool.html?WOPISrc= collaboraFullURL := fmt.Sprintf("%s?WOPISrc=%s", editorURL, encodedWopiSrc) // Return HTML page with auto-submitting form // The form POSTs to Collabora with access_token in the body // WOPISrc must be in the URL as a query parameter htmlContent := fmt.Sprintf(` Loading Document...

Loading Collabora Online...

`, collaboraFullURL, accessToken, collaboraFullURL) w.Header().Set("Content-Type", "text/html; charset=utf-8") // Don't set X-Frame-Options - this endpoint is meant to be loaded in an iframe w.WriteHeader(http.StatusOK) w.Write([]byte(htmlContent)) }