43 KiB
WOPI Integration Technical Report for OxiCloud
Table of Contents
- WOPI Protocol Overview
- Required WOPI Endpoints
- CheckFileInfo Response JSON Structure
- WOPI Discovery XML
- Collabora Online vs OnlyOffice WOPI Differences
- OxiCloud Backend Changes
- OxiCloud Frontend Changes
- Docker-Compose Additions
- Implementation Roadmap
1. WOPI Protocol Overview
WOPI (Web Application Open Platform Interface) is a REST-based protocol that enables cloud storage platforms (the WOPI Host) to expose files to external document editors (the WOPI Client) such as Collabora Online or OnlyOffice Document Server.
Architecture Diagram
┌──────────────────────┐ WOPI REST API ┌──────────────────────┐
│ │◄──────────────────────────────► │ │
│ OxiCloud (WOPI Host)│ CheckFileInfo, GetFile, │ Collabora / Only- │
│ Rust / Axum │ PutFile, Lock, Unlock │ Office (WOPI Client)│
│ │ │ │
│ /wopi/files/{id} │ │ Document Editor │
│ /wopi/files/{id}/ │ │ iframe UI │
│ contents │ │ │
└──────────┬───────────┘ └──────────┬───────────┘
│ │
│ Host Page (iframe) │
└────────────────────────────────────────────────────────┘
Browser loads host page with iframe
pointing to WOPI Client action URL
Flow Summary:
- User clicks "Edit in Office" on a file in OxiCloud's frontend.
- OxiCloud generates a WOPI access token and constructs a host page URL.
- The host page contains an iframe whose
srcpoints to the WOPI Client's action URL (obtained from WOPI discovery), with theWOPISrcpointing back to OxiCloud's WOPI endpoints. - The WOPI Client (Collabora/OnlyOffice) calls OxiCloud's WOPI endpoints using the access token to:
- Get file metadata (
CheckFileInfo) - Download the file (
GetFile) - Lock the file (
Lock) - Save changes (
PutFile) - Unlock the file (
Unlock)
- Get file metadata (
2. Required WOPI Endpoints
All WOPI host endpoints must be located at URLs starting with /wopi. The following endpoints are required for a functional editing integration:
2.1 Files Endpoint: /wopi/files/{file_id}
| Operation | HTTP Method | URL | X-WOPI-Override | Required For |
|---|---|---|---|---|
| CheckFileInfo | GET |
/wopi/files/{file_id} |
— | All actions |
| Lock | POST |
/wopi/files/{file_id} |
LOCK |
Edit |
| Unlock | POST |
/wopi/files/{file_id} |
UNLOCK |
Edit |
| RefreshLock | POST |
/wopi/files/{file_id} |
REFRESH_LOCK |
Edit |
| UnlockAndRelock | POST |
/wopi/files/{file_id} |
LOCK (with X-WOPI-OldLock) |
Edit |
| PutRelativeFile | POST |
/wopi/files/{file_id} |
PUT_RELATIVE |
Save As |
| RenameFile | POST |
/wopi/files/{file_id} |
RENAME_FILE |
Rename from editor |
| DeleteFile | POST |
/wopi/files/{file_id} |
DELETE |
Delete from editor |
2.2 File Contents Endpoint: /wopi/files/{file_id}/contents
| Operation | HTTP Method | URL | X-WOPI-Override | Required For |
|---|---|---|---|---|
| GetFile | GET |
/wopi/files/{file_id}/contents |
— | All actions |
| PutFile | POST |
/wopi/files/{file_id}/contents |
PUT |
Save/Edit |
2.3 Detailed Endpoint Specifications
CheckFileInfo — GET /wopi/files/{file_id}?access_token=TOKEN
Returns JSON metadata about the file and user permissions. Required for all WOPI actions.
- Query Params:
access_token(string) - Request Headers:
X-WOPI-SessionContext(optional) - Response:
200 OKwith JSON body (see Section 3) - Error codes:
401 Unauthorized,404 Not Found,500 Server Error
GetFile — GET /wopi/files/{file_id}/contents?access_token=TOKEN
Returns the full binary contents of the file.
- Query Params:
access_token(string) - Request Headers:
X-WOPI-MaxExpectedSize(optional integer) - Response:
200 OKwith binary body - Response Headers:
X-WOPI-ItemVersion(optional) - Error codes:
401,404,412 Precondition Failed(file too large),500
PutFile — POST /wopi/files/{file_id}/contents?access_token=TOKEN
Saves updated file contents. The request body is the full binary content.
- Request Headers:
X-WOPI-Override: PUT(required)X-WOPI-Lock: <lock_id>(required if file is locked)X-WOPI-Editors: <comma-separated user IDs>(optional)
- Response Headers:
X-WOPI-ItemVersion,X-WOPI-Lock(on 409) - Error codes:
200 OK,401,404,409 Conflict(lock mismatch),413 Too Large,500
Special rule: If the file is unlocked and its size is 0 bytes, PutFile should succeed (supports new file creation).
Lock — POST /wopi/files/{file_id}?access_token=TOKEN
Locks the file for editing.
- Request Headers:
X-WOPI-Override: LOCK(required)X-WOPI-Lock: <lock_id>(required)
- Behavior:
- If unlocked → lock file, return
200 OK - If locked with same lock → refresh timer, return
200 OK - Otherwise →
409 ConflictwithX-WOPI-Lockresponse header
- If unlocked → lock file, return
- Error codes:
200,400 Bad Request,401,404,409,500
Unlock — POST /wopi/files/{file_id}?access_token=TOKEN
- Request Headers:
X-WOPI-Override: UNLOCKX-WOPI-Lock: <lock_id>
- Behavior: If lock matches → unlock,
200 OK. Otherwise →409 Conflict.
RefreshLock — POST /wopi/files/{file_id}?access_token=TOKEN
- Request Headers:
X-WOPI-Override: REFRESH_LOCKX-WOPI-Lock: <lock_id>
- Behavior: Resets the lock timer. Same conflict rules as Lock.
3. CheckFileInfo Response JSON Structure
{
// ─── REQUIRED ─────────────────────────────────────────
"BaseFileName": "document.docx",
"OwnerId": "user123",
"Size": 45678,
"UserId": "user456",
"Version": "v1706123456",
// ─── HOST CAPABILITIES ────────────────────────────────
"SupportsLocks": true,
"SupportsUpdate": true,
"SupportsRename": true,
"SupportsDeleteFile": false,
"SupportsExtendedLockLength": true,
"SupportsGetLock": true,
// ─── USER PERMISSIONS ─────────────────────────────────
"UserCanWrite": true,
"UserCanRename": true,
"UserCanNotWriteRelative": false,
"ReadOnly": false,
// ─── USER METADATA ────────────────────────────────────
"UserFriendlyName": "John Doe",
"IsAnonymousUser": false,
// ─── FILE URLs ────────────────────────────────────────
"CloseUrl": "https://cloud.example.com/files",
"DownloadUrl": "https://cloud.example.com/api/files/abc123",
"HostEditUrl": "https://cloud.example.com/wopi/edit/abc123",
"HostViewUrl": "https://cloud.example.com/wopi/view/abc123",
"FileSharingUrl": "https://cloud.example.com/share/abc123",
"SignoutUrl": "https://cloud.example.com/logout",
// ─── COLLABORA-SPECIFIC (optional) ───────────────────
"PostMessageOrigin": "https://cloud.example.com",
"HideSaveOption": false,
"HidePrintOption": false,
"DisablePrint": false,
"DisableExport": false,
"EnableOwnerTermination": true,
"LastModifiedTime": "2026-02-13T10:00:00Z"
}
Property Details
| Property | Type | Required | Description |
|---|---|---|---|
BaseFileName |
string | Yes | File name with extension, no path |
OwnerId |
string | Yes | Unique ID of the file owner (alphanumeric) |
Size |
long | Yes | File size in bytes |
UserId |
string | Yes | Unique ID of the current user (alphanumeric) |
Version |
string | Yes | Version string; must change on every file change |
SupportsLocks |
bool | No | Host supports Lock/Unlock/RefreshLock/UnlockAndRelock |
SupportsUpdate |
bool | No | Host supports PutFile and PutRelativeFile |
SupportsRename |
bool | No | Host supports RenameFile |
UserCanWrite |
bool | No | User has write permission |
UserFriendlyName |
string | No | Display name for the user |
PostMessageOrigin |
string | No | Origin for PostMessage communication |
LastModifiedTime |
string | No | ISO 8601 formatted last-modified time (Collabora uses this) |
4. WOPI Discovery XML
4.1 What is WOPI Discovery?
WOPI Discovery is the process by which OxiCloud (the WOPI host) learns the capabilities of the WOPI client (Collabora/OnlyOffice). The WOPI client exposes an XML document at a well-known URL that describes:
- Supported file types (extensions)
- Available actions (view, edit, editnew, etc.)
- Action URLs (the iframe URLs to load the editor)
- Proof keys (for request signing verification)
4.2 Discovery URLs
| WOPI Client | Discovery URL |
|---|---|
| Collabora Online | https://<collabora-host>:<port>/hosting/discovery |
| OnlyOffice Document Server | https://<onlyoffice-host>/hosting/discovery |
4.3 Discovery XML Structure
<?xml version="1.0" encoding="utf-8"?>
<wopi-discovery>
<net-zone name="external-https">
<app name="Word"
favIconUrl="https://collabora.example.com/favicon.ico">
<action name="view" ext="docx" default="true"
urlsrc="https://collabora.example.com/cool/word/view?
WOPISrc=WOPI_SOURCE&lang=UI_LLCC"/>
<action name="edit" ext="docx" default="true"
requires="locks,update"
urlsrc="https://collabora.example.com/cool/word/edit?
WOPISrc=WOPI_SOURCE&lang=UI_LLCC"/>
</app>
<app name="Excel"
favIconUrl="https://collabora.example.com/favicon_calc.ico">
<action name="view" ext="xlsx"
urlsrc="https://collabora.example.com/cool/calc/view?
WOPISrc=WOPI_SOURCE"/>
<action name="edit" ext="xlsx" requires="locks,update"
urlsrc="https://collabora.example.com/cool/calc/edit?
WOPISrc=WOPI_SOURCE"/>
</app>
<!-- ... more apps for Impress, Draw, etc. -->
</net-zone>
<proof-key oldvalue="..." value="..."
oldmodulus="..." modulus="..."
oldexponent="..." exponent="..."/>
</wopi-discovery>
4.4 How OxiCloud Uses Discovery
- Fetch and cache the discovery XML from the configured WOPI client URL (refresh every 12–24 hours or on proof key validation failure).
- Parse the XML to build a map of
extension → { action_name → urlsrc }. - When a user wants to edit/view a file:
- Look up the file extension in the map.
- Get the
urlsrcfor the desired action (e.g.,edit). - Transform the URL: replace
WOPI_SOURCEwith OxiCloud'sWOPISrc(the URL-encodedCheckFileInfoendpoint), and optionally replaceUI_LLCCwith the user's locale.
- The transformed URL becomes the iframe
srcon the host page.
4.5 Action Requirements
| Requirement | WOPI Operations Needed |
|---|---|
update |
PutFile, PutRelativeFile |
locks |
Lock, RefreshLock, Unlock, UnlockAndRelock |
5. Collabora Online vs OnlyOffice WOPI Differences
5.1 Integration Modes
| Aspect | Collabora Online (CODE) | OnlyOffice Document Server |
|---|---|---|
| Primary API | WOPI (native, first-class) | Document Server API (callback-based); WOPI support added later |
| WOPI Support | Full, native implementation | Supported since v7.2+, enabled via wopi.enable: true in config |
| Discovery URL | /hosting/discovery |
/hosting/discovery (when WOPI enabled) |
| Docker Image | collabora/code |
onlyoffice/documentserver |
| License | MPL 2.0 (CODE edition) | AGPL v3 (Community), commercial for >20 users |
5.2 WOPI Endpoint Differences
| Feature | Collabora Online | OnlyOffice (WOPI mode) |
|---|---|---|
| CheckFileInfo | Standard WOPI spec + extra properties (PostMessageOrigin, LastModifiedTime, HideSaveOption, HideExportOption, EnableOwnerTermination) |
Standard WOPI spec |
| GetFile | Standard | Standard |
| PutFile | Standard with X-LOOL-WOPI-Timestamp header (optional, for conflict detection) |
Standard |
| Lock/Unlock | Standard (WOPI locks) | Standard (WOPI locks); also has internal lock via refreshLockInterval config |
| Proof Keys | Uses WOPI proof key validation from discovery XML | Has its own WOPI proof key system (wopi.publicKey, wopi.modulus, wopi.exponent in config) |
| PutRelativeFile | Supported | Supported |
| RenameFile | Supported via WOPI | Supported via WOPI |
5.3 Collabora-Specific Extras
- PostMessageOrigin: Collabora heavily uses PostMessage for UI integration (close, save status, etc.). Must be set in CheckFileInfo.
X-LOOL-WOPI-Timestamp: Collabora sends the last-known modification timestamp in PutFile to detect conflicts. If the host's file is newer, it should return409 ConflictwithX-LOOL-WOPI-Timestampheader.- COOL protocol: Collabora's internal name is "COOL" (Collabora Online). URLs follow the pattern
/cool/<app>/edit.
5.4 OnlyOffice-Specific Extras
- Dual mode: OnlyOffice can operate in either its native Document Server API mode (with callback URLs and JWT) or WOPI mode. When
wopi.enable = true, it switches to WOPI and its/hosting/discoveryendpoint becomes available. - File type mapping: OnlyOffice has explicit configuration arrays for which extensions map to which editor:
wopi.wordEdit,wopi.cellEdit,wopi.slideEdit,wopi.pdfEdit, etc. - JWT/Secret keys: OnlyOffice uses
services.CoAuthoring.secret.inboxfor WOPI request validation alongside WOPI proof keys. - Browser token: OnlyOffice has a separate
services.CoAuthoring.token.enable.browsersetting.
5.5 Recommendation
Use Collabora Online (CODE) as the primary editor for self-hosted deployments:
- Native WOPI support with no configuration mode switching.
- Better open-source license (MPL 2.0).
- More straightforward integration.
- Widely used by Nextcloud, ownCloud, Seafile, etc.
Support OnlyOffice as secondary for users who prefer its UI:
- The WOPI interface is the same, so a single WOPI host implementation supports both.
- Only docker-compose and frontend discovery URL config differ.
6. OxiCloud Backend Changes
6.1 New Configuration (src/common/config.rs)
Add a WopiConfig struct following the existing pattern:
/// WOPI (Web Application Open Platform Interface) configuration
#[derive(Debug, Clone)]
pub struct WopiConfig {
/// Whether WOPI integration is enabled
pub enabled: bool,
/// URL to the WOPI client's discovery endpoint
/// e.g., "http://collabora:9980/hosting/discovery"
pub discovery_url: String,
/// The public-facing base URL of OxiCloud (used for WOPISrc)
/// e.g., "https://cloud.example.com"
pub public_base_url: String,
/// Secret key for signing WOPI access tokens (HMAC-SHA256)
pub secret: String,
/// Access token TTL in seconds (default: 86400 = 24 hours)
pub token_ttl_seconds: u64,
/// Lock expiration in seconds (default: 1800 = 30 minutes)
pub lock_expiration_seconds: u64,
/// Discovery XML cache TTL in seconds (default: 86400 = 24 hours)
pub discovery_cache_ttl_seconds: u64,
}
impl Default for WopiConfig {
fn default() -> Self {
Self {
enabled: false,
discovery_url: String::new(),
public_base_url: String::new(),
secret: String::new(),
token_ttl_seconds: 86400,
lock_expiration_seconds: 1800,
discovery_cache_ttl_seconds: 86400,
}
}
}
Environment variables (following existing OxiCloud env pattern):
| Variable | Purpose | Example |
|---|---|---|
WOPI_ENABLED |
Enable/disable WOPI | true |
WOPI_DISCOVERY_URL |
Discovery endpoint of the editor | http://collabora:9980/hosting/discovery |
WOPI_PUBLIC_BASE_URL |
Public URL of OxiCloud | https://cloud.example.com |
WOPI_SECRET |
HMAC secret for access tokens | supersecretkey123 |
WOPI_TOKEN_TTL |
Token lifetime in seconds | 86400 |
6.2 New WOPI Handler (src/interfaces/api/handlers/wopi_handler.rs)
use axum::{
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
Json,
};
use serde::{Deserialize, Serialize};
/// WOPI access token query parameter
#[derive(Deserialize)]
pub struct WopiTokenQuery {
pub access_token: String,
}
/// CheckFileInfo response
#[derive(Serialize)]
pub struct CheckFileInfoResponse {
#[serde(rename = "BaseFileName")]
pub base_file_name: String,
#[serde(rename = "OwnerId")]
pub owner_id: String,
#[serde(rename = "Size")]
pub size: i64,
#[serde(rename = "UserId")]
pub user_id: String,
#[serde(rename = "Version")]
pub version: String,
#[serde(rename = "SupportsLocks")]
pub supports_locks: bool,
#[serde(rename = "SupportsUpdate")]
pub supports_update: bool,
#[serde(rename = "SupportsRename")]
pub supports_rename: bool,
#[serde(rename = "UserCanWrite")]
pub user_can_write: bool,
#[serde(rename = "UserCanRename")]
pub user_can_rename: bool,
#[serde(rename = "UserFriendlyName")]
pub user_friendly_name: String,
#[serde(rename = "PostMessageOrigin")]
pub post_message_origin: String,
#[serde(rename = "CloseUrl")]
pub close_url: String,
#[serde(rename = "LastModifiedTime")]
pub last_modified_time: String,
}
pub struct WopiHandler;
impl WopiHandler {
/// GET /wopi/files/{file_id}?access_token=TOKEN
pub async fn check_file_info(
Path(file_id): Path<String>,
Query(token): Query<WopiTokenQuery>,
State(state): State<AppState>,
) -> impl IntoResponse {
// 1. Validate access_token (HMAC verification)
// 2. Extract user_id and file_id from token claims
// 3. Fetch file metadata from FileRetrievalService
// 4. Return CheckFileInfoResponse JSON
}
/// GET /wopi/files/{file_id}/contents?access_token=TOKEN
pub async fn get_file(
Path(file_id): Path<String>,
Query(token): Query<WopiTokenQuery>,
State(state): State<AppState>,
) -> impl IntoResponse {
// 1. Validate access_token
// 2. Stream file contents from storage
// 3. Return binary body with Content-Type
}
/// POST /wopi/files/{file_id}/contents?access_token=TOKEN
/// X-WOPI-Override: PUT
pub async fn put_file(
Path(file_id): Path<String>,
Query(token): Query<WopiTokenQuery>,
headers: HeaderMap,
State(state): State<AppState>,
body: axum::body::Bytes,
) -> impl IntoResponse {
// 1. Validate access_token
// 2. Check X-WOPI-Lock header against stored lock
// 3. If locked with different lock → 409 Conflict
// 4. If unlocked and file size > 0 → 409 Conflict
// 5. Write file contents via FileManagementService
// 6. Return 200 OK with X-WOPI-ItemVersion
}
/// POST /wopi/files/{file_id}?access_token=TOKEN
/// Dispatches based on X-WOPI-Override header
pub async fn file_operations(
Path(file_id): Path<String>,
Query(token): Query<WopiTokenQuery>,
headers: HeaderMap,
State(state): State<AppState>,
) -> impl IntoResponse {
let override_header = headers
.get("X-WOPI-Override")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
match override_header {
"LOCK" => Self::handle_lock(file_id, headers, state).await,
"UNLOCK" => Self::handle_unlock(file_id, headers, state).await,
"REFRESH_LOCK" => Self::handle_refresh_lock(file_id, headers, state).await,
"GET_LOCK" => Self::handle_get_lock(file_id, state).await,
"RENAME_FILE" => Self::handle_rename(file_id, headers, state).await,
"PUT_RELATIVE" => Self::handle_put_relative(file_id, headers, state).await,
"DELETE" => Self::handle_delete(file_id, state).await,
_ => (StatusCode::NOT_IMPLEMENTED, "Unknown override").into_response(),
}
}
}
6.3 WOPI Token Service (src/application/services/wopi_token_service.rs)
use hmac::{Hmac, Mac};
use sha2::Sha256;
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
pub struct WopiTokenClaims {
pub file_id: String,
pub user_id: String,
pub permissions: WopiPermissions,
pub expires_at: u64, // Unix timestamp
}
#[derive(Serialize, Deserialize)]
pub struct WopiPermissions {
pub can_write: bool,
pub can_rename: bool,
}
pub struct WopiTokenService {
secret: Vec<u8>,
ttl_seconds: u64,
}
impl WopiTokenService {
/// Generate an access token for a file+user pair
pub fn generate_token(&self, file_id: &str, user_id: &str, can_write: bool) -> (String, u64) {
// Create claims, serialize to JSON, HMAC-sign, base64url encode
// Return (token_string, ttl_in_milliseconds)
}
/// Validate and decode an access token
pub fn validate_token(&self, token: &str) -> Result<WopiTokenClaims, WopiError> {
// Decode, verify HMAC, check expiry
}
}
6.4 WOPI Lock Service (src/application/services/wopi_lock_service.rs)
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use std::time::{Instant, Duration};
pub struct WopiLockEntry {
pub lock_id: String,
pub expires_at: Instant,
}
/// In-memory lock store (for single-instance deployments)
/// For multi-instance, replace with Redis or database-backed locks
pub struct WopiLockService {
locks: Arc<RwLock<HashMap<String, WopiLockEntry>>>,
lock_duration: Duration,
}
impl WopiLockService {
pub async fn lock(&self, file_id: &str, lock_id: &str) -> Result<(), LockConflict>;
pub async fn unlock(&self, file_id: &str, lock_id: &str) -> Result<(), LockConflict>;
pub async fn refresh_lock(&self, file_id: &str, lock_id: &str) -> Result<(), LockConflict>;
pub async fn get_lock(&self, file_id: &str) -> Option<String>;
pub async fn is_locked(&self, file_id: &str) -> bool;
}
6.5 WOPI Discovery Service (src/infrastructure/services/wopi_discovery_service.rs)
use std::collections::HashMap;
#[derive(Clone, Debug)]
pub struct WopiAction {
pub name: String, // "view", "edit", "editnew"
pub ext: String, // "docx", "xlsx", etc.
pub urlsrc: String, // Template URL
pub requires: String, // "locks,update"
}
/// Caches parsed WOPI discovery data
pub struct WopiDiscoveryService {
discovery_url: String,
/// Map: extension → Vec<WopiAction>
actions: Arc<RwLock<HashMap<String, Vec<WopiAction>>>>,
last_fetched: Arc<RwLock<Option<Instant>>>,
cache_ttl: Duration,
}
impl WopiDiscoveryService {
/// Fetch and parse the discovery XML from the WOPI client
pub async fn refresh_discovery(&self) -> Result<(), Error>;
/// Get the editor URL for a given file extension and action
pub async fn get_action_url(
&self,
extension: &str,
action: &str, // "view" or "edit"
wopi_src: &str, // URL-encoded WOPISrc
) -> Option<String>;
/// Check if an extension is supported for editing
pub async fn supports_edit(&self, extension: &str) -> bool;
/// Check if an extension is supported for viewing
pub async fn supports_view(&self, extension: &str) -> bool;
/// Get list of all supported extensions
pub async fn get_supported_extensions(&self) -> Vec<String>;
}
6.6 Routes Integration (src/interfaces/api/routes.rs and src/main.rs)
New routes to add:
// In routes.rs or a new wopi_routes function:
pub fn create_wopi_routes(app_state: &AppState) -> Router<AppState> {
use crate::interfaces::api::handlers::wopi_handler::WopiHandler;
Router::new()
// CheckFileInfo
.route("/files/{file_id}", get(WopiHandler::check_file_info))
// Lock/Unlock/RefreshLock/Rename/Delete (dispatched by X-WOPI-Override)
.route("/files/{file_id}", post(WopiHandler::file_operations))
// GetFile
.route("/files/{file_id}/contents", get(WopiHandler::get_file))
// PutFile
.route("/files/{file_id}/contents", post(WopiHandler::put_file))
.with_state(app_state.clone())
}
In main.rs, mount at /wopi (outside of /api for WOPI spec compliance):
// In main.rs, after building other routers:
if config.wopi.enabled {
let wopi_router = create_wopi_routes(&app_state);
// WOPI routes are NOT behind auth middleware —
// they use their own access_token validation
app = app.nest("/wopi", wopi_router);
tracing::info!("WOPI integration enabled");
}
6.7 API Endpoint for Frontend: WOPI Editor URL
Add a new API endpoint for the frontend to request an editor URL:
/// GET /api/wopi/editor-url?file_id=X&action=edit
/// Returns the URL to open the WOPI editor for a file
pub async fn get_editor_url(
Query(params): Query<EditorUrlParams>,
State(state): State<AppState>,
user: AuthenticatedUser,
) -> impl IntoResponse {
// 1. Generate WOPI access token for (file_id, user_id)
// 2. Build WOPISrc = "{public_base_url}/wopi/files/{file_id}"
// 3. Look up action URL from discovery for file extension + action
// 4. Transform urlsrc: replace WOPI_SOURCE, UI_LLCC
// 5. Return JSON:
// {
// "editor_url": "https://collabora:9980/cool/word/edit?WOPISrc=...",
// "access_token": "...",
// "access_token_ttl": 86400000,
// "favicon_url": "..."
// }
}
6.8 New Files Summary
| File | Purpose |
|---|---|
src/interfaces/api/handlers/wopi_handler.rs |
WOPI HTTP handlers (CheckFileInfo, GetFile, PutFile, Lock, etc.) |
src/application/services/wopi_token_service.rs |
WOPI access token generation and validation |
src/application/services/wopi_lock_service.rs |
In-memory file lock management |
src/infrastructure/services/wopi_discovery_service.rs |
Discovery XML fetching, parsing, caching |
src/application/ports/wopi_ports.rs |
Port interfaces for WOPI services |
src/common/config.rs |
WopiConfig struct (added to AppConfig) |
src/common/di.rs |
Wire up WOPI services in AppState |
src/interfaces/api/routes.rs |
WOPI route definitions |
6.9 Dependencies to Add (Cargo.toml)
# WOPI support
hmac = "0.12"
sha2 = "0.10"
quick-xml = "0.36" # XML parsing for WOPI discovery
urlencoding = "2.1" # URL encoding for WOPISrc
base64 = "0.22" # Token encoding
7. OxiCloud Frontend Changes
7.1 New JavaScript Module: static/js/wopiEditor.js
/**
* OxiCloud WOPI Editor Integration
* Opens files in Collabora Online / OnlyOffice via WOPI protocol
*/
class WopiEditor {
constructor() {
this.supportedExtensions = null;
this.editorModal = null;
}
/**
* Check if a file can be opened in the WOPI editor
*/
async canEdit(filename) {
const ext = filename.split('.').pop().toLowerCase();
const editableExtensions = [
// Documents
'docx', 'doc', 'odt', 'rtf', 'txt', 'dotx', 'docm',
// Spreadsheets
'xlsx', 'xls', 'ods', 'csv', 'xltx', 'xlsm',
// Presentations
'pptx', 'ppt', 'odp', 'ppsx', 'potx', 'pptm',
// PDF
'pdf'
];
return editableExtensions.includes(ext);
}
/**
* Open a file in the WOPI editor
* @param {string} fileId - The file ID
* @param {string} fileName - The file name
* @param {string} action - "edit" or "view"
*/
async openEditor(fileId, fileName, action = 'edit') {
try {
// 1. Request editor URL from OxiCloud API
const response = await fetch(
`/api/wopi/editor-url?file_id=${encodeURIComponent(fileId)}&action=${action}`,
{
headers: {
'Authorization': `Bearer ${this.getAuthToken()}`
}
}
);
if (!response.ok) {
throw new Error('Failed to get editor URL');
}
const data = await response.json();
// data = { editor_url, access_token, access_token_ttl, favicon_url }
// 2. Open the editor in a modal with iframe
this.showEditorModal(data, fileName);
} catch (error) {
console.error('Failed to open WOPI editor:', error);
alert('Could not open the document editor. Please try again.');
}
}
/**
* Create and show the editor modal with WOPI iframe
*/
showEditorModal(editorData, fileName) {
// Remove existing modal if any
this.closeEditor();
// Create modal overlay
const modal = document.createElement('div');
modal.id = 'wopi-editor-modal';
modal.style.cssText = `
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
z-index: 10000; background: #fff;
`;
// Create header bar
const header = document.createElement('div');
header.style.cssText = `
height: 40px; background: #333; color: #fff; display: flex;
align-items: center; justify-content: space-between; padding: 0 16px;
`;
header.innerHTML = `
<span>${this.escapeHtml(fileName)}</span>
<button id="wopi-close-btn" style="background:none;border:none;
color:#fff;cursor:pointer;font-size:18px;">✕</button>
`;
// Create form + iframe (WOPI host page pattern)
const frameholder = document.createElement('div');
frameholder.style.cssText = `
position: absolute; top: 40px; left: 0; right: 0; bottom: 0;
`;
// Form to POST access_token to the editor iframe
const form = document.createElement('form');
form.id = 'wopi_form';
form.name = 'wopi_form';
form.target = 'wopi_frame';
form.action = editorData.editor_url;
form.method = 'post';
form.innerHTML = `
<input name="access_token" value="${editorData.access_token}" type="hidden"/>
<input name="access_token_ttl" value="${editorData.access_token_ttl}" type="hidden"/>
`;
// Create iframe dynamically (WOPI best practice)
const iframe = document.createElement('iframe');
iframe.name = 'wopi_frame';
iframe.id = 'wopi_frame';
iframe.title = 'Document Editor';
iframe.style.cssText = 'width:100%;height:100%;border:none;';
iframe.setAttribute('allowfullscreen', 'true');
iframe.setAttribute('sandbox',
'allow-scripts allow-same-origin allow-forms allow-popups ' +
'allow-top-navigation allow-popups-to-escape-sandbox');
iframe.setAttribute('allow',
"clipboard-read 'src'; clipboard-write 'src'");
frameholder.appendChild(iframe);
modal.appendChild(header);
modal.appendChild(form);
modal.appendChild(frameholder);
document.body.appendChild(modal);
// Close button handler
document.getElementById('wopi-close-btn').onclick = () => this.closeEditor();
// ESC key to close
this._escHandler = (e) => {
if (e.key === 'Escape') this.closeEditor();
};
document.addEventListener('keydown', this._escHandler);
// Submit the form to POST token to iframe
form.submit();
this.editorModal = modal;
}
closeEditor() {
const modal = document.getElementById('wopi-editor-modal');
if (modal) {
modal.remove();
}
if (this._escHandler) {
document.removeEventListener('keydown', this._escHandler);
}
this.editorModal = null;
// Refresh file list to show any changes
if (typeof loadFiles === 'function') {
loadFiles();
}
}
getAuthToken() {
return localStorage.getItem('auth_token') || '';
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
// Global instance
window.wopiEditor = new WopiEditor();
7.2 Changes to Existing Frontend Files
static/js/inlineViewer.js
Add WOPI editor integration to the openFile method — when a user opens a document file (docx, xlsx, pptx, etc.), redirect to the WOPI editor instead of the inline viewer:
// In openFile(file) method, add at the beginning:
if (window.wopiEditor && await window.wopiEditor.canEdit(file.name)) {
window.wopiEditor.openEditor(file.id, file.name, 'edit');
return;
}
static/js/contextMenus.js
Add "Edit in Office" context menu option for supported file types:
// Add to file context menu items:
{
label: 'Edit in Office',
icon: 'fas fa-file-word',
condition: (file) => window.wopiEditor?.canEdit(file.name),
action: (file) => window.wopiEditor.openEditor(file.id, file.name, 'edit')
},
{
label: 'View in Office',
icon: 'fas fa-eye',
condition: (file) => window.wopiEditor?.canEdit(file.name),
action: (file) => window.wopiEditor.openEditor(file.id, file.name, 'view')
}
static/index.html
Add the WOPI editor script:
<script src="/js/wopiEditor.js"></script>
7.3 PostMessage Integration (Optional Enhancement)
For deeper UI integration, listen for PostMessage events from the editor iframe:
window.addEventListener('message', (event) => {
// Verify origin matches the editor URL
const data = JSON.parse(event.data);
switch (data.MessageId) {
case 'App_LoadingStatus':
if (data.Values?.Status === 'Document_Loaded') {
console.log('Document loaded in editor');
}
break;
case 'UI_Close':
window.wopiEditor.closeEditor();
break;
case 'UI_FileVersions':
// Show version history UI
break;
case 'UI_Sharing':
// Show sharing UI
break;
}
});
8. Docker-Compose Additions
8.1 Collabora Online (CODE)
services:
# ... existing postgres and oxicloud services ...
collabora:
image: collabora/code:latest
restart: always
cap_add:
- MKNOD
environment:
# Allow OxiCloud domain to use Collabora
- "aliasgroup1=http://oxicloud:8086"
# Alternative: use domain= for regex pattern
# - "domain=oxicloud\\.example\\.com"
# Disable SSL (handled by reverse proxy)
- "extra_params=--o:ssl.enable=false --o:ssl.termination=true"
# Admin console credentials
- "username=admin"
- "password=admin_password"
# Server name for discovery
- "server_name=collabora.example.com"
ports:
- "9980:9980"
networks:
- oxicloud
depends_on:
- oxicloud
# Update oxicloud service with WOPI env vars:
oxicloud:
# ... existing config ...
environment:
- "OXICLOUD_DB_CONNECTION_STRING=postgres://postgres:postgres@postgres/oxicloud"
- "DATABASE_URL=postgres://postgres:postgres@postgres/oxicloud"
# WOPI configuration
- "WOPI_ENABLED=true"
- "WOPI_DISCOVERY_URL=http://collabora:9980/hosting/discovery"
- "WOPI_PUBLIC_BASE_URL=http://localhost:8086"
- "WOPI_SECRET=change-me-to-a-random-secret-key"
8.2 OnlyOffice Document Server (alternative)
services:
# ... existing services ...
onlyoffice:
image: onlyoffice/documentserver:latest
restart: always
environment:
# Enable WOPI mode
- "WOPI_ENABLED=true"
# JWT secret (must match OxiCloud's WOPI secret)
- "JWT_SECRET=change-me-to-a-random-secret-key"
- "JWT_ENABLED=true"
ports:
- "8088:80"
networks:
- oxicloud
volumes:
- onlyoffice_data:/var/www/onlyoffice/Data
- onlyoffice_logs:/var/log/onlyoffice
depends_on:
- oxicloud
volumes:
# ... existing volumes ...
onlyoffice_data:
onlyoffice_logs:
8.3 Full Docker-Compose with Both Editors
services:
postgres:
image: postgres:17.4-alpine
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: oxicloud
networks:
- oxicloud
volumes:
- pg_data:/var/lib/postgresql/
- ./db/schema.sql:/docker-entrypoint-initdb.d/10-schema.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
oxicloud:
image: diocrafts/oxicloud:latest
restart: always
build:
context: .
dockerfile: Dockerfile
ports:
- "8086:8086"
networks:
- oxicloud
depends_on:
postgres:
condition: service_healthy
environment:
- "OXICLOUD_DB_CONNECTION_STRING=postgres://postgres:postgres@postgres/oxicloud"
- "DATABASE_URL=postgres://postgres:postgres@postgres/oxicloud"
# WOPI configuration
- "WOPI_ENABLED=true"
- "WOPI_DISCOVERY_URL=http://collabora:9980/hosting/discovery"
- "WOPI_PUBLIC_BASE_URL=http://localhost:8086"
- "WOPI_SECRET=change-me-to-a-random-secret-key"
volumes:
- storage_data:/app/storage
collabora:
image: collabora/code:latest
restart: always
cap_add:
- MKNOD
environment:
- "aliasgroup1=http://oxicloud:8086"
- "extra_params=--o:ssl.enable=false --o:ssl.termination=true"
- "username=admin"
- "password=admin_password"
ports:
- "9980:9980"
networks:
- oxicloud
# Optional: OnlyOffice alternative
# onlyoffice:
# image: onlyoffice/documentserver:latest
# restart: always
# environment:
# - "WOPI_ENABLED=true"
# - "JWT_SECRET=change-me-to-a-random-secret-key"
# - "JWT_ENABLED=true"
# ports:
# - "8088:80"
# networks:
# - oxicloud
# volumes:
# - onlyoffice_data:/var/www/onlyoffice/Data
networks:
oxicloud:
driver: bridge
volumes:
pg_data:
storage_data:
# onlyoffice_data:
8.4 Reverse Proxy Considerations
In production, a reverse proxy (Nginx/Traefik) is typically needed:
# Collabora Online proxy
location ^~ /cool/ {
proxy_pass http://collabora:9980;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_read_timeout 36000s;
}
location ^~ /hosting/ {
proxy_pass http://collabora:9980;
proxy_set_header Host $http_host;
}
# WOPI endpoints (OxiCloud)
location ^~ /wopi/ {
proxy_pass http://oxicloud:8086;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
9. Implementation Roadmap
Phase 1: Core WOPI Host (Backend)
- Add
WopiConfigtoAppConfigand environment variable parsing - Implement
WopiTokenService(token generation/validation) - Implement
WopiLockService(in-memory lock store) - Implement
WopiDiscoveryService(discovery XML parsing) - Implement
WopiHandlerwithCheckFileInfo,GetFile,PutFile - Implement lock operations (
Lock,Unlock,RefreshLock) - Wire up in
di.rsand mount routes inmain.rs
Phase 2: Frontend Integration
- Create
wopiEditor.jsmodule - Add "Edit in Office" to context menu and file viewer
- Implement WOPI host page (iframe + form POST)
- Add PostMessage event handling
Phase 3: Docker & Deployment
- Add Collabora CODE to docker-compose
- Configure networking and environment variables
- Test end-to-end flow
- Add OnlyOffice as alternative option
Phase 4: Enhancements
- Proof key validation (verify requests come from legitimate WOPI client)
PutRelativeFile("Save As" support)RenameFileintegration- Multi-instance lock storage (Redis/DB-backed)
- Co-editing status indicators in the file list
- File version tracking for WOPI saves
Key Architectural Notes for OxiCloud
-
WOPI routes go at
/wopi/(top-level), following the same pattern as WebDAV/CalDAV/CardDAV which are merged at top-level inmain.rsfor protocol compliance. -
WOPI uses its own authentication (access tokens), not OxiCloud's JWT auth middleware. The WOPI routes should NOT be wrapped in
auth_middleware. Instead, each WOPI handler validates theaccess_tokenquery parameter internally. -
The existing
FileRetrievalServiceandFileManagementServiceinAppStatealready provide all the file operations needed (download, upload, rename, delete). WOPI handlers are thin adapters that call these existing services. -
File IDs in OxiCloud are already URL-safe strings (UUID-style), which satisfies the WOPI file ID requirement.
-
The
InlineVieweralready has a modal pattern that can be extended. The WOPI editor uses a similar full-screen modal approach but with an iframe instead of direct content rendering.