Files
b0esche_cloud/go_cloud/internal/http/wopi_handlers.go

710 lines
23 KiB
Go

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(`<!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/browser/dist/cool.html" target="_self" id="collaboraForm" style="display: none;">
<input type="hidden" name="WOPISrc" value="%s">
</form>
<script>
// Auto-submit the form to Collabora
var form = document.getElementById('collaboraForm');
if (form) {
console.log('[COLLABORA] Submitting form to /browser/dist/cool.html with WOPISrc');
form.submit();
} else {
console.error('[COLLABORA] Form not found');
}
</script>
</body>
</html>`, collaboraURL, wopiSrc)
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\n", fileID, userID.String(), wopiSrc)
}