# 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&amp;lang=UI_LLCC"/>
      <action name="edit" ext="docx" default="true"
              requires="locks,update"
              urlsrc="https://collabora.example.com/cool/word/edit?
                      WOPISrc=WOPI_SOURCE&amp;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.
