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:
Leon Bösche
2026-01-12 01:08:22 +01:00
parent 3e0094b11c
commit 1b20fe8b7f
5 changed files with 861 additions and 53 deletions

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'dart:convert';
import '../theme/app_theme.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/document_viewer/document_viewer_bloc.dart';
@@ -11,6 +12,7 @@ import '../injection.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http;
import 'package:webview_flutter/webview_flutter.dart';
// Modal version for overlay display
class DocumentViewerModal extends StatefulWidget {
@@ -325,68 +327,137 @@ class _DocumentViewerModalState extends State<DocumentViewerModal> {
}
Widget _buildCollaboraViewer(String documentUrl, String token) {
// Build HTML to embed Collabora Online viewer
// For now, we'll show the document download option with a link to open in Collabora
// A proper implementation would require WOPI protocol support
return Container(
color: AppTheme.primaryBackground,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.description,
size: 64,
color: AppTheme.accentColor,
),
const SizedBox(height: 16),
Text(
'Office Document',
style: TextStyle(
color: AppTheme.primaryText,
fontSize: 18,
fontWeight: FontWeight.bold,
// Create WOPI session to get WOPISrc URL
return FutureBuilder<WOPISession>(
future: _createWOPISession(token),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Container(
color: AppTheme.primaryBackground,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
AppTheme.accentColor,
),
),
const SizedBox(height: 16),
Text(
'Loading document in Collabora Online...',
style: TextStyle(
color: AppTheme.secondaryText,
fontSize: 12,
),
),
],
),
),
const SizedBox(height: 8),
Text(
'Collabora Online Viewer',
style: TextStyle(
color: AppTheme.secondaryText,
fontSize: 14,
);
}
if (snapshot.hasError) {
return Container(
color: AppTheme.primaryBackground,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Colors.red[400],
),
const SizedBox(height: 16),
Text(
'Failed to load document',
style: TextStyle(
color: AppTheme.primaryText,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'${snapshot.error}',
style: TextStyle(
color: AppTheme.secondaryText,
fontSize: 12,
),
),
],
),
),
const SizedBox(height: 16),
Text(
'Opening document in Collabora...',
style: TextStyle(
color: AppTheme.secondaryText,
fontSize: 12,
);
}
if (!snapshot.hasData) {
return Container(
color: AppTheme.primaryBackground,
child: const Center(
child: Text(
'No session data',
style: TextStyle(color: AppTheme.primaryText),
),
),
const SizedBox(height: 24),
CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
AppTheme.accentColor,
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
icon: const Icon(Icons.download),
label: const Text('Download File'),
onPressed: () {
// Open file download
// In a real implementation, you'd use url_launcher
// launchUrl(state.viewUrl);
},
),
],
),
),
);
}
final wopiSession = snapshot.data!;
// Build Collabora Online viewer URL with WOPISrc
final collaboraUrl = 'https://of.b0esche.cloud/loleaflet/dist/loleaflet.html?WOPISrc=${Uri.encodeComponent(wopiSession.wopisrc)}';
// Use WebView to display Collabora Online
return _buildWebView(collaboraUrl);
},
);
}
Future<WOPISession> _createWOPISession(String token) async {
try {
final sessionBloc = BlocProvider.of<SessionBloc>(context);
final baseUrl = (sessionBloc.state as SessionLoaded).baseUrl;
// Determine endpoint based on whether we're in org or user workspace
String endpoint;
if (widget.orgId.isNotEmpty && widget.orgId != 'personal') {
endpoint = '/orgs/${widget.orgId}/files/${widget.fileId}/wopi-session';
} else {
endpoint = '/user/files/${widget.fileId}/wopi-session';
}
final response = await http.post(
Uri.parse('$baseUrl$endpoint'),
headers: {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
},
).timeout(const Duration(seconds: 10));
if (response.statusCode == 200) {
final json = jsonDecode(response.body) as Map<String, dynamic>;
return WOPISession(
wopisrc: json['wopi_src'] as String,
accessToken: json['access_token'] as String,
);
} else {
throw Exception('Failed to create WOPI session: ${response.statusCode}');
}
} catch (e) {
throw Exception('Error creating WOPI session: $e');
}
}
Widget _buildWebView(String url) {
final controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..loadRequest(Uri.parse(url));
return WebViewWidget(controller: controller);
}
@override
void dispose() {
_viewerBloc.close();
@@ -394,6 +465,24 @@ class _DocumentViewerModalState extends State<DocumentViewerModal> {
}
}
// WOPI Session model for Collabora Online integration
class WOPISession {
final String wopisrc;
final String accessToken;
WOPISession({
required this.wopisrc,
required this.accessToken,
});
factory WOPISession.fromJson(Map<String, dynamic> json) {
return WOPISession(
wopisrc: json['wopi_src'] as String,
accessToken: json['access_token'] as String,
);
}
}
// Original page version (for routing if needed)
class DocumentViewer extends StatefulWidget {
final String orgId;

View File

@@ -659,3 +659,12 @@ func (db *DB) MarkChallengeUsed(ctx context.Context, challenge []byte) error {
`, challenge)
return err
}
// UpdateFileSize updates the size and last_modified timestamp of a file
func (db *DB) UpdateFileSize(ctx context.Context, fileID uuid.UUID, size int64) error {
_, err := db.ExecContext(ctx, `
UPDATE files
SET size = $1, last_modified = NOW()
WHERE id = $2
`, size, fileID)
return err
}

View File

@@ -98,6 +98,28 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut
// Health check
r.Get("/health", healthHandler)
// WOPI routes (public, token validation done per endpoint)
r.Route("/wopi", func(r chi.Router) {
r.Route("/files/{fileId}", func(r chi.Router) {
// CheckFileInfo: GET /wopi/files/{fileId}
r.Get("/", func(w http.ResponseWriter, req *http.Request) {
wopiCheckFileInfoHandler(w, req, db, jwtManager)
})
// GetFile: GET /wopi/files/{fileId}/contents
r.Get("/contents", func(w http.ResponseWriter, req *http.Request) {
wopiGetFileHandler(w, req, db, jwtManager, cfg)
})
// PutFile & Lock operations: POST /wopi/files/{fileId}/contents and POST /wopi/files/{fileId}
r.Post("/contents", func(w http.ResponseWriter, req *http.Request) {
wopiPutFileHandler(w, req, db, jwtManager)
})
// Lock operations: POST /wopi/files/{fileId}
r.Post("/", func(w http.ResponseWriter, req *http.Request) {
wopiLockHandler(w, req, db, jwtManager)
})
})
})
// Auth routes (no auth required)
r.Route("/auth", func(r chi.Router) {
r.Post("/refresh", func(w http.ResponseWriter, req *http.Request) {
@@ -159,6 +181,10 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut
r.Post("/user/files/move", func(w http.ResponseWriter, req *http.Request) {
moveUserFileHandler(w, req, db, auditLogger, cfg)
})
// WOPI session for user files
r.Post("/user/files/{fileId}/wopi-session", func(w http.ResponseWriter, req *http.Request) {
wopiSessionHandler(w, req, db, jwtManager, "https://of.b0esche.cloud")
})
// Org routes
r.Get("/orgs", func(w http.ResponseWriter, req *http.Request) {
@@ -211,6 +237,10 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut
r.Get("/meta", func(w http.ResponseWriter, req *http.Request) {
fileMetaHandler(w, req)
})
// WOPI session for org files
r.With(middleware.Permission(db, auditLogger, permission.DocumentView)).Post("/wopi-session", func(w http.ResponseWriter, req *http.Request) {
wopiSessionHandler(w, req, db, jwtManager, "https://of.b0esche.cloud")
})
})
r.Get("/activity", func(w http.ResponseWriter, req *http.Request) {
activityHandler(w, req, db)

View 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())
}

View File

@@ -0,0 +1,74 @@
package models
package models
import "time"
// WOPICheckFileInfoResponse represents the response to WOPI CheckFileInfo request
// Reference: https://docs.microsoft.com/en-us/openspecs/office_protocols/ms-wopi/4b8ffc3f-e8a6-4169-8c4e-34924ac6ae2f
type WOPICheckFileInfoResponse struct {
BaseFileName string `json:"BaseFileName"`
Size int64 `json:"Size"`
Version string `json:"Version"`
OwnerId string `json:"OwnerId"`
UserId string `json:"UserId"`
UserFriendlyName string `json:"UserFriendlyName"`
UserCanWrite bool `json:"UserCanWrite"`
UserCanRename bool `json:"UserCanRename"`
UserCanNotWriteRelative bool `json:"UserCanNotWriteRelative"`
ReadOnly bool `json:"ReadOnly"`
RestrictedWebViewOnly bool `json:"RestrictedWebViewOnly"`
} AccessToken string `json:"access_token"` WOPISrc string `json:"wopi_src"`type WOPISessionResponse struct {// WOPISessionResponse represents a response for creating a WOPI session} ClosePostMessage bool `json:"close_post_message"` BootstrapperUrl string `json:"bootstrapper_url,omitempty"` AccessTokenTTL int64 `json:"access_token_ttl"` AccessToken string `json:"access_token"`type WOPIAccessTokenResponse struct {// WOPIAccessTokenResponse represents a response with WOPI access token} FileID string `json:"file_id"`type WOPIAccessTokenRequest struct {// WOPIAccessTokenRequest represents a request to get WOPI access token} ExpiresAt time.Time `json:"expires_at"` CreatedAt time.Time `json:"created_at"` LockID string `json:"lock_id"` UserID string `json:"user_id"` FileID string `json:"file_id"`type WOPILockInfo struct {// WOPILockInfo represents information about a file lock} ItemVersion string `json:"ItemVersion"`type WOPIPutFileResponse struct {// WOPIPutFileResponse represents the response to WOPI PutFile request} DownloadUrl string `json:"DownloadUrl,omitempty"` FileSharingUrl string `json:"FileSharingUrl,omitempty"` ViewUrl string `json:"ViewUrl,omitempty"` EditUrl string `json:"EditUrl,omitempty"` CloseUrl string `json:"CloseUrl,omitempty"` TimeZone string `json:"TimeZone"` IsAnonymousUser bool `json:"IsAnonymousUser"` LastModifiedTime string `json:"LastModifiedTime"` SupportsScenarios []string `json:"SupportsScenarios"` SupportsFolders bool `json:"SupportsFolders"` SupportsRenameRelativeToFolder bool `json:"SupportsRenameRelativeToFolder"` SupportsRename bool `json:"SupportsRename"` SupportsDelete bool `json:"SupportsDelete"` SupportsGetLock bool `json:"SupportsGetLock"` SupportsExtendedLockLength bool `json:"SupportsExtendedLockLength"` SupportsLocks bool `json:"SupportsLocks"` SupportsCobalt bool `json:"SupportsCobalt"` SupportsUpdate bool `json:"SupportsUpdate"` EnableOwnerTermination bool `json:"EnableOwnerTermination"` UserCanCreateRelativeToFolder bool `json:"UserCanCreateRelativeToFolder"`