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:
120
server/wopi/discovery.ts
Normal file
120
server/wopi/discovery.ts
Normal 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
260
server/wopi/handler.ts
Normal 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
216
server/wopi/lock.ts
Normal 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
132
server/wopi/token.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user