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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
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())
|
||||
}
|
||||
74
go_cloud/internal/models/wopi.go
Normal file
74
go_cloud/internal/models/wopi.go
Normal 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"`
|
||||
Reference in New Issue
Block a user