2026-02-13 13:22:58 +01:00
# WOPI Integration Technical Report for OxiCloud
## Table of Contents
1. [WOPI Protocol Overview ](#1-wopi-protocol-overview )
2. [Required WOPI Endpoints ](#2-required-wopi-endpoints )
3. [CheckFileInfo Response JSON Structure ](#3-checkfileinfo-response-json-structure )
4. [WOPI Discovery XML ](#4-wopi-discovery-xml )
5. [Collabora Online vs OnlyOffice WOPI Differences ](#5-collabora-online-vs-onlyoffice-wopi-differences )
6. [OxiCloud Backend Changes ](#6-oxicloud-backend-changes )
7. [OxiCloud Frontend Changes ](#7-oxicloud-frontend-changes )
8. [Docker-Compose Additions ](#8-docker-compose-additions )
9. [Implementation Roadmap ](#9-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:**
1. User clicks "Edit in Office" on a file in OxiCloud's frontend.
2. OxiCloud generates a **WOPI access token** and constructs a **host page URL** .
3. The host page contains an iframe whose `src` points to the WOPI Client's **action URL** (obtained from WOPI discovery), with the `WOPISrc` pointing back to OxiCloud's WOPI endpoints.
4. 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` )
---
## 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 OK` with 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 OK` with 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 Conflict` with `X-WOPI-Lock` response header
- **Error codes:** `200` , `400 Bad Request` , `401` , `404` , `409` , `500`
#### Unlock — `POST /wopi/files/{file_id}?access_token=TOKEN`
- **Request Headers:**
- `X-WOPI-Override: UNLOCK`
- `X-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_LOCK`
- `X-WOPI-Lock: <lock_id>`
- **Behavior:** Resets the lock timer. Same conflict rules as Lock.
---
## 3. CheckFileInfo Response JSON Structure
```json
{
// ─── 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
<?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
1. **Fetch and cache** the discovery XML from the configured WOPI client URL (refresh every 12– 24 hours or on proof key validation failure).
2. **Parse** the XML to build a map of `extension → { action_name → urlsrc }` .
3. When a user wants to edit/view a file:
- Look up the file extension in the map.
- Get the `urlsrc` for the desired action (e.g., `edit` ).
- **Transform** the URL: replace `WOPI_SOURCE` with OxiCloud's `WOPISrc` (the URL-encoded `CheckFileInfo` endpoint), and optionally replace `UI_LLCC` with the user's locale.
4. The transformed URL becomes the iframe `src` on 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 return `409 Conflict` with `X-LOOL-WOPI-Timestamp` header.
- **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/discovery` endpoint 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.inbox` for WOPI request validation alongside WOPI proof keys.
- **Browser token:** OnlyOffice has a separate `services.CoAuthoring.token.enable.browser` setting.
### 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:
```rust
/// 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`)
```rust
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`)
```rust
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`)
```rust
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`)
```rust
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:**
```rust
// 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):
```rust
// 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:
```rust
/// 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`)
```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`
```javascript
/**
* 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:
```javascript
// 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:
```javascript
// 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:
```html
< script src = "/js/wopiEditor.js" ></ script >
```
### 7.3 PostMessage Integration (Optional Enhancement)
For deeper UI integration, listen for PostMessage events from the editor iframe:
```javascript
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)
```yaml
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)
```yaml
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
```yaml
services :
postgres :
image : postgres:17.4-alpine
restart : always
environment :
POSTGRES_USER : postgres
POSTGRES_PASSWORD : postgres
POSTGRES_DB : oxicloud
networks :
- oxicloud
volumes :
2026-04-11 21:39:11 +02:00
- pg_data:/var/lib/postgresql/
2026-02-13 13:22:58 +01:00
- ./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 :
2026-04-17 21:52:59 +00:00
image : diocrafts/oxicloud:latest
2026-02-13 13:22:58 +01:00
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:
```nginx
# 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)
1. Add `WopiConfig` to `AppConfig` and environment variable parsing
2. Implement `WopiTokenService` (token generation/validation)
3. Implement `WopiLockService` (in-memory lock store)
4. Implement `WopiDiscoveryService` (discovery XML parsing)
5. Implement `WopiHandler` with `CheckFileInfo` , `GetFile` , `PutFile`
6. Implement lock operations (`Lock` , `Unlock` , `RefreshLock` )
7. Wire up in `di.rs` and mount routes in `main.rs`
### Phase 2: Frontend Integration
1. Create `wopiEditor.js` module
2. Add "Edit in Office" to context menu and file viewer
3. Implement WOPI host page (iframe + form POST)
4. Add PostMessage event handling
### Phase 3: Docker & Deployment
1. Add Collabora CODE to docker-compose
2. Configure networking and environment variables
3. Test end-to-end flow
4. Add OnlyOffice as alternative option
### Phase 4: Enhancements
1. Proof key validation (verify requests come from legitimate WOPI client)
2. `PutRelativeFile` ("Save As" support)
3. `RenameFile` integration
4. Multi-instance lock storage (Redis/DB-backed)
5. Co-editing status indicators in the file list
6. File version tracking for WOPI saves
---
## Key Architectural Notes for OxiCloud
1. **WOPI routes go at `/wopi/` (top-level)** , following the same pattern as WebDAV/CalDAV/CardDAV which are merged at top-level in `main.rs` for protocol compliance.
2. **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 the `access_token` query parameter internally.
3. **The existing `FileRetrievalService` and `FileManagementService`** in `AppState` already provide all the file operations needed (download, upload, rename, delete). WOPI handlers are thin adapters that call these existing services.
4. **File IDs** in OxiCloud are already URL-safe strings (UUID-style), which satisfies the WOPI file ID requirement.
5. **The `InlineViewer`** already 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.