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.
121 lines
3.3 KiB
TypeScript
121 lines
3.3 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|