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()) } // 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 wopiSrc := fmt.Sprintf("https://go.b0esche.cloud/wopi/files/%s?access_token=%s", fileID, accessToken) // Return HTML page with auto-submitting form // The form POSTs to Collabora from within an iframe to work around CSP frame-ancestors restrictions // The iframe is hosted on the same domain as the embedded page, allowing the POST to complete htmlContent := fmt.Sprintf(`
Loading Collabora Online...