1193 lines
43 KiB
Markdown
1193 lines
43 KiB
Markdown
# 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:
|
||
- 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:
|
||
|
||
```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.
|