Initial commit — Drive, an S3 file browser with WOPI editing

Lightweight replacement for the upstream La Suite Numérique drive
(Django/Celery/Next.js) built as a single Deno binary.

Server (Deno + Hono):
- S3 file operations via AWS SigV4 (no SDK) with pre-signed URLs
- WOPI host for Collabora Online (CheckFileInfo, GetFile, PutFile, locks)
- Ory Kratos session auth + CSRF protection
- Ory Keto permission model (OPL namespaces, not yet wired to routes)
- PostgreSQL metadata with recursive folder sizes
- S3 backfill API for registering files uploaded outside the UI
- OpenTelemetry tracing + metrics (opt-in via OTEL_ENABLED)

Frontend (React 19 + Cunningham v4 + react-aria):
- File browser with GridList, keyboard nav, multi-select
- Collabora editor iframe (full-screen, form POST, postMessage)
- Profile menu, waffle menu, drag-drop upload, asset type badges
- La Suite integration service theming (runtime CSS)

Testing (549 tests):
- 235 server unit tests (Deno) — 90%+ coverage
- 278 UI unit tests (Vitest) — 90%+ coverage
- 11 E2E tests (Playwright)
- 12 integration service tests (Playwright)
- 13 WOPI integration tests (Playwright + Docker Compose + Collabora)

MIT licensed.
This commit is contained in:
2026-03-25 18:28:37 +00:00
commit 58237d9e44
112 changed files with 26841 additions and 0 deletions

120
server/wopi/discovery.ts Normal file
View File

@@ -0,0 +1,120 @@
/**
* Collabora WOPI discovery — fetch and cache discovery.xml.
* Parses XML to extract urlsrc for each mimetype/action pair.
*/
import { withSpan } from "../telemetry.ts";
const COLLABORA_URL =
Deno.env.get("COLLABORA_URL") ??
"http://collabora.lasuite.svc.cluster.local:9980";
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
interface ActionEntry {
name: string;
ext: string;
urlsrc: string;
}
interface DiscoveryCache {
/** Map: mimetype -> ActionEntry[] */
actions: Map<string, ActionEntry[]>;
fetchedAt: number;
}
let cache: DiscoveryCache | null = null;
/**
* Parse discovery XML into a map of mimetype -> action entries.
*/
export function parseDiscoveryXml(
xml: string,
): Map<string, ActionEntry[]> {
const result = new Map<string, ActionEntry[]>();
// Match <app name="..."> blocks
const appRegex = /<app\s+name="([^"]*)"[^>]*>([\s\S]*?)<\/app>/g;
for (const appMatch of xml.matchAll(appRegex)) {
const mimetype = appMatch[1];
const appBody = appMatch[2];
const actions: ActionEntry[] = [];
// Match <action name="..." ext="..." urlsrc="..." />
const actionRegex =
/<action\s+([^>]*?)\/?\s*>/g;
for (const actionMatch of appBody.matchAll(actionRegex)) {
const attrs = actionMatch[1];
const name =
attrs.match(/name="([^"]*)"/)?.[1] ?? "";
const ext = attrs.match(/ext="([^"]*)"/)?.[1] ?? "";
const urlsrc =
attrs.match(/urlsrc="([^"]*)"/)?.[1] ?? "";
if (name && urlsrc) {
actions.push({ name, ext, urlsrc });
}
}
if (actions.length > 0) {
result.set(mimetype, actions);
}
}
return result;
}
async function fetchDiscovery(): Promise<Map<string, ActionEntry[]>> {
const url = `${COLLABORA_URL}/hosting/discovery`;
const resp = await fetch(url);
if (!resp.ok) {
throw new Error(
`Collabora discovery fetch failed: ${resp.status} ${resp.statusText}`,
);
}
const xml = await resp.text();
return parseDiscoveryXml(xml);
}
async function getDiscovery(): Promise<Map<string, ActionEntry[]>> {
if (cache && Date.now() - cache.fetchedAt < CACHE_TTL_MS) {
return cache.actions;
}
// Retry up to 3 times
let lastError: Error | null = null;
for (let i = 0; i < 3; i++) {
try {
const actions = await fetchDiscovery();
cache = { actions, fetchedAt: Date.now() };
return actions;
} catch (e) {
lastError = e as Error;
if (i < 2) await new Promise((r) => setTimeout(r, 1000 * (i + 1)));
}
}
throw lastError;
}
/**
* Get the Collabora editor URL for a given mimetype and action.
* Returns the urlsrc template or null if not found.
*/
export async function getCollaboraActionUrl(
mimetype: string,
action = "edit",
): Promise<string | null> {
const cacheHit = !!(cache && Date.now() - cache.fetchedAt < CACHE_TTL_MS);
return withSpan("collabora.discovery", { "collabora.mimetype": mimetype, "collabora.action": action, "collabora.cache_hit": cacheHit }, async () => {
const discovery = await getDiscovery();
const actions = discovery.get(mimetype);
if (!actions) return null;
const match = actions.find((a) => a.name === action);
return match?.urlsrc ?? null;
});
}
/** Clear cache (for testing). */
export function clearDiscoveryCache(): void {
cache = null;
}

260
server/wopi/handler.ts Normal file
View File

@@ -0,0 +1,260 @@
/**
* WOPI endpoint handlers.
*/
import type { Context } from "hono";
import sql from "../db.ts";
import { getObject, putObject } from "../s3.ts";
import { withSpan } from "../telemetry.ts";
import { verifyWopiToken, generateWopiToken } from "./token.ts";
import type { WopiTokenPayload } from "./token.ts";
import { getCollaboraActionUrl } from "./discovery.ts";
import {
acquireLock,
getLock,
refreshLock,
releaseLock,
unlockAndRelock,
} from "./lock.ts";
// ── Token validation helper ─────────────────────────────────────────────────
async function validateToken(c: Context): Promise<WopiTokenPayload | null> {
const token = c.req.query("access_token");
if (!token) return null;
return verifyWopiToken(token);
}
function wopiError(c: Context, status: number, msg: string): Response {
return c.text(msg, status);
}
// ── CheckFileInfo ───────────────────────────────────────────────────────────
/** GET /wopi/files/:id */
export async function wopiCheckFileInfo(c: Context): Promise<Response> {
return withSpan("wopi.checkFileInfo", { "wopi.file_id": c.req.param("id") ?? "" }, async () => {
const payload = await validateToken(c);
if (!payload) return wopiError(c, 401, "Invalid access token");
const fileId = c.req.param("id");
if (payload.fid !== fileId) return wopiError(c, 401, "Token/file mismatch");
const [file] = await sql`
SELECT * FROM files WHERE id = ${fileId}
`;
if (!file) return wopiError(c, 404, "File not found");
return c.json({
BaseFileName: file.filename,
OwnerId: file.owner_id,
Size: Number(file.size),
UserId: payload.uid,
UserFriendlyName: payload.unm,
Version: file.updated_at?.toISOString?.() ?? String(file.updated_at),
UserCanWrite: payload.wr,
UserCanNotWriteRelative: true,
SupportsLocks: true,
SupportsUpdate: payload.wr,
SupportsGetLock: true,
LastModifiedTime: file.updated_at?.toISOString?.() ?? String(file.updated_at),
});
});
}
// ── GetFile ─────────────────────────────────────────────────────────────────
/** GET /wopi/files/:id/contents */
export async function wopiGetFile(c: Context): Promise<Response> {
return withSpan("wopi.getFile", { "wopi.file_id": c.req.param("id") ?? "" }, async () => {
const payload = await validateToken(c);
if (!payload) return wopiError(c, 401, "Invalid access token");
const fileId = c.req.param("id");
if (payload.fid !== fileId) return wopiError(c, 401, "Token/file mismatch");
const [file] = await sql`
SELECT s3_key, mimetype FROM files WHERE id = ${fileId}
`;
if (!file) return wopiError(c, 404, "File not found");
const resp = await getObject(file.s3_key);
if (!resp.ok) return wopiError(c, 500, "Failed to retrieve file from storage");
const headers = new Headers();
headers.set("Content-Type", file.mimetype);
if (resp.headers.get("content-length")) {
headers.set("Content-Length", resp.headers.get("content-length")!);
}
return new Response(resp.body, { status: 200, headers });
});
}
// ── PutFile ─────────────────────────────────────────────────────────────────
/** POST /wopi/files/:id/contents */
export async function wopiPutFile(c: Context): Promise<Response> {
return withSpan("wopi.putFile", { "wopi.file_id": c.req.param("id") ?? "" }, async () => {
const payload = await validateToken(c);
if (!payload) return wopiError(c, 401, "Invalid access token");
if (!payload.wr) return wopiError(c, 403, "No write permission");
const fileId = c.req.param("id");
if (payload.fid !== fileId) return wopiError(c, 401, "Token/file mismatch");
// Verify lock
const requestLock = c.req.header("X-WOPI-Lock") ?? "";
const currentLock = await getLock(fileId);
if (currentLock && currentLock !== requestLock) {
const headers = new Headers();
headers.set("X-WOPI-Lock", currentLock);
return new Response("Lock mismatch", { status: 409, headers });
}
const [file] = await sql`
SELECT s3_key, mimetype FROM files WHERE id = ${fileId}
`;
if (!file) return wopiError(c, 404, "File not found");
const body = new Uint8Array(await c.req.arrayBuffer());
await putObject(file.s3_key, body, file.mimetype);
// Update size + timestamp
await sql`
UPDATE files SET size = ${body.length}, updated_at = now() WHERE id = ${fileId}
`;
return c.text("", 200);
});
}
// ── Lock/Unlock/RefreshLock/GetLock (routed by X-WOPI-Override) ─────────────
/** POST /wopi/files/:id */
export async function wopiPostAction(c: Context): Promise<Response> {
const override = c.req.header("X-WOPI-Override")?.toUpperCase() ?? "UNKNOWN";
return withSpan(`wopi.${override.toLowerCase()}`, { "wopi.file_id": c.req.param("id") ?? "", "wopi.override": override }, async () => {
const payload = await validateToken(c);
if (!payload) return wopiError(c, 401, "Invalid access token");
const fileId = c.req.param("id");
if (payload.fid !== fileId) return wopiError(c, 401, "Token/file mismatch");
const lockId = c.req.header("X-WOPI-Lock") ?? "";
const oldLockId = c.req.header("X-WOPI-OldLock") ?? "";
switch (override) {
case "LOCK": {
if (oldLockId) {
const result = await unlockAndRelock(fileId, oldLockId, lockId);
if (!result.success) {
const headers = new Headers();
if (result.existingLockId) headers.set("X-WOPI-Lock", result.existingLockId);
return new Response("Lock mismatch", { status: 409, headers });
}
return c.text("", 200);
}
const result = await acquireLock(fileId, lockId);
if (!result.success) {
const headers = new Headers();
if (result.existingLockId) headers.set("X-WOPI-Lock", result.existingLockId);
return new Response("Lock conflict", { status: 409, headers });
}
return c.text("", 200);
}
case "GET_LOCK": {
const current = await getLock(fileId);
const headers = new Headers();
headers.set("X-WOPI-Lock", current ?? "");
return new Response("", { status: 200, headers });
}
case "REFRESH_LOCK": {
const result = await refreshLock(fileId, lockId);
if (!result.success) {
const headers = new Headers();
if (result.existingLockId) headers.set("X-WOPI-Lock", result.existingLockId);
return new Response("Lock mismatch", { status: 409, headers });
}
return c.text("", 200);
}
case "UNLOCK": {
const result = await releaseLock(fileId, lockId);
if (!result.success) {
const headers = new Headers();
if (result.existingLockId) headers.set("X-WOPI-Lock", result.existingLockId);
return new Response("Lock mismatch", { status: 409, headers });
}
return c.text("", 200);
}
case "PUT_RELATIVE":
return wopiError(c, 501, "PutRelative not supported");
case "RENAME_FILE": {
if (!payload.wr) return wopiError(c, 403, "No write permission");
const newName = c.req.header("X-WOPI-RequestedName") ?? "";
if (!newName) return wopiError(c, 400, "Missing X-WOPI-RequestedName");
await sql`UPDATE files SET filename = ${newName}, updated_at = now() WHERE id = ${fileId}`;
return c.json({ Name: newName });
}
default:
return wopiError(c, 501, `Unknown override: ${override}`);
}
});
}
// ── Token generation endpoint ───────────────────────────────────────────────
/** POST /api/wopi/token — session-authenticated, generates token for a file */
export async function generateWopiTokenHandler(c: Context): Promise<Response> {
const identity = c.get("identity");
if (!identity?.id) return c.json({ error: "Unauthorized" }, 401);
const body = await c.req.json();
const fileId = body.file_id;
if (!fileId) return c.json({ error: "file_id required" }, 400);
// Verify file exists and user has access (owner check)
// TODO: Replace owner_id check with Keto permission check when permissions are wired up
const [file] = await sql`
SELECT id, owner_id, mimetype FROM files
WHERE id = ${fileId} AND deleted_at IS NULL AND owner_id = ${identity.id}
`;
if (!file) return c.json({ error: "File not found" }, 404);
const canWrite = file.owner_id === identity.id;
const token = await generateWopiToken(
fileId,
identity.id,
identity.name || identity.email,
canWrite,
);
const tokenTtl = Date.now() + 8 * 3600 * 1000;
// Build the Collabora editor URL with WOPISrc
let editorUrl: string | null = null;
try {
const urlsrc = await getCollaboraActionUrl(file?.mimetype ?? "", "edit");
if (urlsrc) {
const PUBLIC_URL = Deno.env.get("PUBLIC_URL") ?? "http://localhost:3000";
const wopiSrc = encodeURIComponent(`${PUBLIC_URL}/wopi/files/${fileId}`);
editorUrl = `${urlsrc}WOPISrc=${wopiSrc}`;
}
} catch {
// Discovery not available — editorUrl stays null
}
return c.json({
access_token: token,
access_token_ttl: tokenTtl,
editor_url: editorUrl,
});
}

216
server/wopi/lock.ts Normal file
View File

@@ -0,0 +1,216 @@
/**
* Valkey (Redis)-backed WOPI lock service with TTL.
* Uses an injectable store interface so tests can use an in-memory Map.
*/
const LOCK_TTL_SECONDS = 30 * 60; // 30 minutes
const KEY_PREFIX = "wopi:lock:";
// ── Store interface ─────────────────────────────────────────────────────────
export interface LockStore {
/** Get value for key, or null if missing/expired. */
get(key: string): Promise<string | null>;
/**
* Set key=value only if key does not exist. Returns true if set, false if conflict.
* TTL in seconds.
*/
setNX(key: string, value: string, ttlSeconds: number): Promise<boolean>;
/** Set key=value unconditionally with TTL. */
set(key: string, value: string, ttlSeconds: number): Promise<void>;
/** Delete key. */
del(key: string): Promise<void>;
/** Set TTL on existing key (returns false if key doesn't exist). */
expire(key: string, ttlSeconds: number): Promise<boolean>;
}
// ── In-memory store (for tests + development) ──────────────────────────────
export class InMemoryLockStore implements LockStore {
private store = new Map<string, { value: string; expiresAt: number }>();
async get(key: string): Promise<string | null> {
const entry = this.store.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return null;
}
return entry.value;
}
async setNX(key: string, value: string, ttlSeconds: number): Promise<boolean> {
const existing = await this.get(key);
if (existing !== null) return false;
this.store.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 });
return true;
}
async set(key: string, value: string, ttlSeconds: number): Promise<void> {
this.store.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 });
}
async del(key: string): Promise<void> {
this.store.delete(key);
}
async expire(key: string, ttlSeconds: number): Promise<boolean> {
const entry = this.store.get(key);
if (!entry) return false;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return false;
}
entry.expiresAt = Date.now() + ttlSeconds * 1000;
return true;
}
}
// ── Valkey (Redis) store using ioredis ──────────────────────────────────────
export class ValkeyLockStore implements LockStore {
private client: {
get(key: string): Promise<string | null>;
set(key: string, value: string, ex: string, ttl: number, nx: string): Promise<string | null>;
set(key: string, value: string, ex: string, ttl: number): Promise<string | null>;
del(key: string): Promise<number>;
expire(key: string, ttl: number): Promise<number>;
};
constructor() {
// Lazy-init to avoid import issues in tests
const url = Deno.env.get("VALKEY_URL") ?? "redis://localhost:6379/2";
// deno-lint-ignore no-explicit-any
const Redis = (globalThis as any).Redis ?? null;
if (!Redis) {
throw new Error("ioredis not available — use InMemoryLockStore for tests");
}
this.client = new Redis(url);
}
async get(key: string): Promise<string | null> {
return this.client.get(key);
}
async setNX(key: string, value: string, ttlSeconds: number): Promise<boolean> {
const result = await this.client.set(key, value, "EX", ttlSeconds, "NX");
return result === "OK";
}
async set(key: string, value: string, ttlSeconds: number): Promise<void> {
await this.client.set(key, value, "EX", ttlSeconds);
}
async del(key: string): Promise<void> {
await this.client.del(key);
}
async expire(key: string, ttlSeconds: number): Promise<boolean> {
const result = await this.client.expire(key, ttlSeconds);
return result === 1;
}
}
// ── Lock service ────────────────────────────────────────────────────────────
let _store: LockStore | null = null;
export function setLockStore(store: LockStore): void {
_store = store;
}
function getStore(): LockStore {
if (!_store) {
// Try Valkey, fall back to in-memory
try {
_store = new ValkeyLockStore();
} catch {
console.warn("WOPI lock: falling back to in-memory store");
_store = new InMemoryLockStore();
}
}
return _store;
}
export interface LockResult {
success: boolean;
existingLockId?: string;
}
export async function acquireLock(
fileId: string,
lockId: string,
): Promise<LockResult> {
const store = getStore();
const key = KEY_PREFIX + fileId;
const set = await store.setNX(key, lockId, LOCK_TTL_SECONDS);
if (set) return { success: true };
const existing = await store.get(key);
if (existing === lockId) {
// Same lock — refresh TTL
await store.expire(key, LOCK_TTL_SECONDS);
return { success: true };
}
return { success: false, existingLockId: existing ?? undefined };
}
export async function getLock(fileId: string): Promise<string | null> {
const store = getStore();
return store.get(KEY_PREFIX + fileId);
}
export async function refreshLock(
fileId: string,
lockId: string,
): Promise<LockResult> {
const store = getStore();
const key = KEY_PREFIX + fileId;
const existing = await store.get(key);
if (existing === null) {
return { success: false };
}
if (existing !== lockId) {
return { success: false, existingLockId: existing };
}
await store.expire(key, LOCK_TTL_SECONDS);
return { success: true };
}
export async function releaseLock(
fileId: string,
lockId: string,
): Promise<LockResult> {
const store = getStore();
const key = KEY_PREFIX + fileId;
const existing = await store.get(key);
if (existing === null) {
return { success: true }; // Already unlocked
}
if (existing !== lockId) {
return { success: false, existingLockId: existing };
}
await store.del(key);
return { success: true };
}
export async function unlockAndRelock(
fileId: string,
oldLockId: string,
newLockId: string,
): Promise<LockResult> {
const store = getStore();
const key = KEY_PREFIX + fileId;
const existing = await store.get(key);
if (existing !== oldLockId) {
return { success: false, existingLockId: existing ?? undefined };
}
await store.set(key, newLockId, LOCK_TTL_SECONDS);
return { success: true };
}

132
server/wopi/token.ts Normal file
View File

@@ -0,0 +1,132 @@
/**
* JWT-based WOPI access tokens using Web Crypto (HMAC-SHA256).
*/
const TEST_MODE = Deno.env.get("DRIVER_TEST_MODE") === "1";
const WOPI_JWT_SECRET = Deno.env.get("WOPI_JWT_SECRET") ?? (TEST_MODE ? "test-wopi-secret" : "");
if (!WOPI_JWT_SECRET && !TEST_MODE) {
throw new Error("WOPI_JWT_SECRET must be set in production");
}
const encoder = new TextEncoder();
const decoder = new TextDecoder();
// ── Base64url helpers ───────────────────────────────────────────────────────
function base64urlEncode(data: Uint8Array): string {
const binString = Array.from(data, (b) => String.fromCharCode(b)).join("");
return btoa(binString).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function base64urlDecode(str: string): Uint8Array {
let s = str.replace(/-/g, "+").replace(/_/g, "/");
while (s.length % 4) s += "=";
const binString = atob(s);
return Uint8Array.from(binString, (c) => c.charCodeAt(0));
}
// ── HMAC helpers ────────────────────────────────────────────────────────────
async function hmacSign(data: Uint8Array, secret: string): Promise<Uint8Array> {
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign("HMAC", key, data as unknown as BufferSource);
return new Uint8Array(sig);
}
async function hmacVerify(data: Uint8Array, signature: Uint8Array, secret: string): Promise<boolean> {
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"],
);
return crypto.subtle.verify("HMAC", key, signature as unknown as BufferSource, data as unknown as BufferSource);
}
// ── Token types ─────────────────────────────────────────────────────────────
export interface WopiTokenPayload {
/** File UUID */
fid: string;
/** User ID (Kratos identity) */
uid: string;
/** User display name */
unm: string;
/** Can write */
wr: boolean;
/** Issued at (unix seconds) */
iat: number;
/** Expires at (unix seconds) */
exp: number;
}
// ── Public API ──────────────────────────────────────────────────────────────
const DEFAULT_EXPIRES_SECONDS = 8 * 3600; // 8 hours
export async function generateWopiToken(
fileId: string,
userId: string,
userName: string,
canWrite: boolean,
expiresInSeconds = DEFAULT_EXPIRES_SECONDS,
secret = WOPI_JWT_SECRET,
): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const payload: WopiTokenPayload = {
fid: fileId,
uid: userId,
unm: userName,
wr: canWrite,
iat: now,
exp: now + expiresInSeconds,
};
const header = base64urlEncode(
encoder.encode(JSON.stringify({ alg: "HS256", typ: "JWT" })),
);
const body = base64urlEncode(
encoder.encode(JSON.stringify(payload)),
);
const sigInput = encoder.encode(`${header}.${body}`);
const sig = await hmacSign(sigInput, secret);
return `${header}.${body}.${base64urlEncode(sig)}`;
}
export async function verifyWopiToken(
token: string,
secret = WOPI_JWT_SECRET,
): Promise<WopiTokenPayload | null> {
const parts = token.split(".");
if (parts.length !== 3) return null;
const [header, body, sig] = parts;
// Verify signature
const sigInput = encoder.encode(`${header}.${body}`);
const sigBytes = base64urlDecode(sig);
const valid = await hmacVerify(sigInput, sigBytes, secret);
if (!valid) return null;
// Parse payload
let payload: WopiTokenPayload;
try {
payload = JSON.parse(decoder.decode(base64urlDecode(body)));
} catch {
return null;
}
// Check expiry
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) return null;
return payload;
}