847 lines
27 KiB
Go
847 lines
27 KiB
Go
package http
|
|
|
|
import (
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"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
|
|
}
|
|
|
|
// 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 ")
|
|
|
|
// 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
|
|
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
|
|
fmt.Printf("[WOPI-STORAGE] PutFile uploading: file=%s remotePath=%s\n", fileID, remotePath)
|
|
err = webDAVClient.Upload(r.Context(), remotePath, 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, &userID)
|
|
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 (without access_token - that goes in a separate form field)
|
|
wopiSrc := fmt.Sprintf("https://go.b0esche.cloud/wopi/files/%s", fileID)
|
|
|
|
// 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=<encoded-url>
|
|
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(`<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Loading Document...</title>
|
|
<meta charset="utf-8">
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
html, body { width: 100%%; height: 100%%; overflow: hidden; }
|
|
.loading { position: fixed; top: 50%%; left: 50%%; transform: translate(-50%%, -50%%); text-align: center; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
|
.loading p { color: #666; margin-top: 10px; font-family: system-ui, sans-serif; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="loading">
|
|
<p>Loading Collabora Online...</p>
|
|
</div>
|
|
<form method="POST" action="%s" target="_self" id="collaboraForm" style="display: none;">
|
|
<input type="hidden" id="access_token" name="access_token" value="%s">
|
|
</form>
|
|
<script>
|
|
// Auto-submit the form to Collabora
|
|
var form = document.getElementById('collaboraForm');
|
|
if (form) {
|
|
console.log('[COLLABORA] Submitting form to %s');
|
|
form.submit();
|
|
} else {
|
|
console.error('[COLLABORA] Form not found');
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>`, 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))
|
|
|
|
fmt.Printf("[COLLABORA-PROXY] Served HTML form: file=%s user=%s wopi_src=%s editor_url=%s\n", fileID, userID.String(), wopiSrc, collaboraFullURL)
|
|
}
|