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]
This commit is contained in:
606
go_cloud/internal/http/wopi_handlers.go
Normal file
606
go_cloud/internal/http/wopi_handlers.go
Normal file
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user