feat: WOPI office document editing in Files (Collabora/OnlyOffice/EuroOffice) #425

This commit is contained in:
Linus Rath
2026-08-31 23:16:19 +02:00
parent 58ebf8d4c5
commit ca7fbbe416
43 changed files with 97180 additions and 96011 deletions
+10
View File
@@ -153,6 +153,16 @@ export function SettingsTab() {
)}
</SettingsSection>
<SettingsSection title="Document Editing (WOPI)">
<TextSetting label="WOPI Client URL" configKey="wopiClientUrl" value={currentValue('wopiClientUrl') as string} source={config.wopiClientUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://office.example.com" />
<TextSetting label="WOPI Host URL Override" configKey="wopiHostUrl" value={currentValue('wopiHostUrl') as string} source={config.wopiHostUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" />
<div className="px-4 py-2.5">
<p className="text-xs text-muted-foreground leading-relaxed">
Point the client URL at a WOPI-compatible office suite (Collabora Online, OnlyOffice, EuroOffice, …) to let users edit documents in Files. The host override is only needed when the editor reaches this webmail under a different address than the browser does (e.g. Docker networks). The editor must also allow this webmail as a WOPI host.
</p>
</div>
</SettingsSection>
<SettingsSection title="Logging">
<SelectSetting label="Log Format" configKey="logFormat" value={currentValue('logFormat') as string} source={config.logFormat?.source} options={['text', 'json']} onChange={handleChange} onRevert={handleRevert} />
<SelectSetting label="Log Level" configKey="logLevel" value={currentValue('logLevel') as string} source={config.logLevel?.source} options={['error', 'warn', 'info', 'debug']} onChange={handleChange} onRevert={handleRevert} />
@@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { downloadFileBlob, getWopiFileNode, updateFileNodeBlob, uploadFileBlob } from '@/lib/wopi/files';
import { wopiContext } from '@/lib/wopi/request';
/**
* WOPI GetFile / PutFile (#425). Content flows purely over JMAP: GetFile
* streams the node's blob, PutFile uploads the editor's bytes as a new blob
* and points the FileNode at it via `FileNode/set { blobId }`.
*/
/** GET = GetFile */
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ fileId: string }> },
) {
try {
const { fileId } = await params;
const auth = await wopiContext(request, fileId);
if (!auth) {
return NextResponse.json({ error: 'Invalid access token' }, { status: 401 });
}
const node = await getWopiFileNode(auth.ctx, auth.payload.accountId, fileId);
if (!node || !node.blobId) {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}
const upstream = await downloadFileBlob(
auth.ctx, auth.payload.accountId, node.blobId, node.name, node.type,
);
if (!upstream.ok) {
return NextResponse.json({ error: 'Blob download failed' }, { status: 502 });
}
const headers = new Headers();
headers.set('Content-Type', node.type || 'application/octet-stream');
const contentLength = upstream.headers.get('Content-Length');
if (contentLength) headers.set('Content-Length', contentLength);
return new NextResponse(upstream.body, { status: 200, headers });
} catch (error) {
logger.error('WOPI GetFile failed', {
error: error instanceof Error ? error.message : 'Unknown',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/** POST = PutFile (X-WOPI-Override: PUT) */
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ fileId: string }> },
) {
try {
const { fileId } = await params;
const auth = await wopiContext(request, fileId);
if (!auth) {
return NextResponse.json({ error: 'Invalid access token' }, { status: 401 });
}
if (!auth.payload.canWrite) {
return NextResponse.json({ error: 'Read-only token' }, { status: 403 });
}
const node = await getWopiFileNode(auth.ctx, auth.payload.accountId, fileId);
if (!node || !node.blobId) {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}
// Collabora sends the LastModifiedTime it saw at load/last save. A
// mismatch means someone else changed the document meanwhile - answer
// 409 + COOLStatusCode 1010 so the editor prompts the user instead of
// silently overwriting.
const clientStamp =
request.headers.get('X-COOL-WOPI-Timestamp') || request.headers.get('X-LOOL-WOPI-Timestamp');
if (clientStamp && node.modified) {
const ours = Date.parse(node.modified);
const theirs = Date.parse(clientStamp);
if (Number.isFinite(ours) && Number.isFinite(theirs) && ours !== theirs) {
return NextResponse.json({ COOLStatusCode: 1010 }, { status: 409 });
}
}
const body = await request.arrayBuffer();
if (body.byteLength === 0) {
return NextResponse.json({ error: 'Empty document rejected' }, { status: 409 });
}
const blob = await uploadFileBlob(auth.ctx, auth.payload.accountId, body, node.type);
const { modified } = await updateFileNodeBlob(
auth.ctx, auth.payload.accountId, fileId, blob.blobId,
);
return NextResponse.json({ LastModifiedTime: modified });
} catch (error) {
logger.error('WOPI PutFile failed', {
error: error instanceof Error ? error.message : 'Unknown',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+78
View File
@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { decodeFileNodeName } from '@/lib/jmap/filenode-name';
import { getWopiFileNode } from '@/lib/wopi/files';
import { wopiContext } from '@/lib/wopi/request';
/**
* WOPI CheckFileInfo + file-level operations (#425).
*
* Called by the WOPI client (Collabora/OnlyOffice/EuroOffice) server-to-server
* with the access token minted by /api/wopi/launch - there is no session
* cookie on these requests.
*/
/** GET = CheckFileInfo */
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ fileId: string }> },
) {
try {
const { fileId } = await params;
const auth = await wopiContext(request, fileId);
if (!auth) {
return NextResponse.json({ error: 'Invalid access token' }, { status: 401 });
}
const node = await getWopiFileNode(auth.ctx, auth.payload.accountId, fileId);
if (!node || !node.blobId) {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}
return NextResponse.json({
BaseFileName: decodeFileNodeName(node.name),
Size: node.size,
OwnerId: auth.payload.username,
UserId: auth.payload.username,
UserFriendlyName: auth.payload.username,
UserCanWrite: auth.payload.canWrite,
UserCanNotWriteRelative: true,
SupportsUpdate: true,
SupportsLocks: false,
LastModifiedTime: node.modified,
Version: node.blobId,
PostMessageOrigin: auth.payload.origin,
});
} catch (error) {
logger.error('WOPI CheckFileInfo failed', {
error: error instanceof Error ? error.message : 'Unknown',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
const LOCK_OPERATIONS = new Set(['LOCK', 'UNLOCK', 'REFRESH_LOCK', 'GET_LOCK']);
/**
* POST = lock operations. FileNodes have no lock concept to map these onto,
* so they are acknowledged as no-ops (CheckFileInfo advertises
* SupportsLocks: false; well-behaved clients don't send them at all).
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ fileId: string }> },
) {
const { fileId } = await params;
const auth = await wopiContext(request, fileId);
if (!auth) {
return NextResponse.json({ error: 'Invalid access token' }, { status: 401 });
}
const operation = request.headers.get('X-WOPI-Override')?.toUpperCase() || '';
if (LOCK_OPERATIONS.has(operation)) {
const headers = new Headers();
if (operation === 'GET_LOCK') headers.set('X-WOPI-Lock', '');
return new NextResponse(null, { status: 200, headers });
}
return NextResponse.json({ error: 'Unsupported operation' }, { status: 501 });
}
+93
View File
@@ -0,0 +1,93 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { configManager } from '@/lib/admin/config-manager';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { fetchJmapSession } from '@/lib/stalwart/jmap-api';
import { getWopiActions, buildWopiActionUrl } from '@/lib/wopi/discovery';
import { getWopiFileNode } from '@/lib/wopi/files';
import { mintWopiToken } from '@/lib/wopi/token';
/**
* POST /api/wopi/launch { fileId: string, accountId?: string }
*
* Mints a WOPI access token scoped to one file node and returns the editor
* URL to POST it to (#425). Session-authenticated - this is the only WOPI
* route the browser calls; everything under /api/wopi/files is called by the
* editor server-to-server with the token minted here.
*/
export async function POST(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const body = (await request.json().catch(() => null)) as { fileId?: unknown; accountId?: unknown } | null;
const fileId = typeof body?.fileId === 'string' ? body.fileId : '';
if (!fileId) {
return NextResponse.json({ error: 'fileId is required' }, { status: 400 });
}
const actions = await getWopiActions();
if (!actions) {
return NextResponse.json({ error: 'No document editor is configured' }, { status: 503 });
}
const ctx = { serverUrl: creds.serverUrl, authHeader: creds.authHeader, trusted: creds.trusted };
let accountId = typeof body?.accountId === 'string' ? body.accountId : '';
if (!accountId) {
const session = await fetchJmapSession(creds.serverUrl, creds.authHeader, { trusted: creds.trusted });
accountId =
session?.primaryAccounts?.['urn:ietf:params:jmap:filenode'] ||
Object.keys(session?.accounts ?? {})[0] ||
'';
}
if (!accountId) {
return NextResponse.json({ error: 'No files account' }, { status: 404 });
}
const node = await getWopiFileNode(ctx, accountId, fileId);
if (!node || !node.blobId) {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}
const ext = node.name.split('.').pop()?.toLowerCase() || '';
const canWrite = node.myRights ? !!node.myRights.mayModifyContent : true;
const urlsrc = (canWrite && actions.edit[ext]) || actions.view[ext] || actions.edit[ext];
if (!urlsrc) {
return NextResponse.json({ error: 'File type not supported by the editor' }, { status: 415 });
}
const editable = canWrite && !!actions.edit[ext];
// Where the editor reaches this webmail. Deployments where the editor
// sees a different host than the browser (docker networks, split DNS)
// override it via wopiHostUrl.
const hostBase =
configManager.get<string>('wopiHostUrl', '').trim().replace(/\/+$/, '') ||
request.nextUrl.origin;
const wopiSrc = `${hostBase}/api/wopi/files/${encodeURIComponent(fileId)}`;
const { token, expiresAt } = mintWopiToken({
serverUrl: creds.serverUrl,
authHeader: creds.authHeader,
username: creds.username,
accountId,
fileId,
canWrite: editable,
origin: request.nextUrl.origin,
});
return NextResponse.json({
url: buildWopiActionUrl(urlsrc, wopiSrc),
accessToken: token,
accessTokenTtl: expiresAt,
readOnly: !editable,
});
} catch (error) {
logger.error('WOPI launch failed', {
error: error instanceof Error ? error.message : 'Unknown',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+33
View File
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { getWopiActions, getWopiClientUrl } from '@/lib/wopi/discovery';
/**
* GET /api/wopi/status
*
* Tells the Files UI whether a WOPI document editor is configured and which
* file extensions it can edit/view (#425). Session-authenticated; the WOPI
* client URL itself stays server-side.
*/
export async function GET(request: NextRequest) {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const clientUrl = await getWopiClientUrl();
if (!clientUrl) {
return NextResponse.json({ enabled: false, editExtensions: [], viewExtensions: [] });
}
const actions = await getWopiActions();
if (!actions) {
return NextResponse.json({ enabled: false, editExtensions: [], viewExtensions: [] });
}
return NextResponse.json({
enabled: true,
editExtensions: Object.keys(actions.edit),
viewExtensions: Object.keys(actions.view),
});
}
+20 -1
View File
@@ -11,7 +11,7 @@ import {
AlertCircle, Star, Clock, FolderUp,
FileArchive, FileSpreadsheet, Presentation, FileCode,
Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon,
Menu, Users, Share2,
Menu, Users, Share2, SquarePen,
} from "lucide-react";
import { useIsDesktop } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button";
@@ -80,6 +80,9 @@ interface FileBrowserProps {
onMoveToParent: (names: string[]) => Promise<void>;
onPreviewImage: (name: string) => void;
onPreviewFile: (name: string) => void;
/** WOPI document editing (#425): whether a file can open in the document editor. */
isOfficeEditable?: (name: string) => boolean;
onEditFile?: (name: string) => void;
onShowDetails: (name: string) => void;
onCreateTextFile: (name: string) => Promise<void>;
onDuplicate: (name: string) => Promise<void>;
@@ -365,6 +368,8 @@ export function FileBrowser({
onMoveToParent,
onPreviewImage,
onPreviewFile,
isOfficeEditable,
onEditFile,
onShowDetails,
onCreateTextFile,
onDuplicate,
@@ -686,6 +691,8 @@ export function FileBrowser({
? `/${resource.name}`
: `${currentPath}/${resource.name}`;
onNavigate(newPath, resource.id);
} else if (isOfficeEditable?.(resource.name) && onEditFile) {
onEditFile(resource.name);
} else if (isPreviewable(resource.name)) {
if (isImageFile(resource.name)) {
onPreviewImage(resource.name);
@@ -1697,6 +1704,18 @@ export function FileBrowser({
{t("preview")}
</button>
)}
{!resources.find(r => r.name === contextMenu.name)?.isDirectory && isOfficeEditable?.(contextMenu.name) && onEditFile && (
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
onClick={() => {
onEditFile(contextMenu.name);
setContextMenu(null);
}}
>
<SquarePen className="w-4 h-4" />
{t("office_edit")}
</button>
)}
{!resources.find(r => r.name === contextMenu.name)?.isDirectory && (
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
+28
View File
@@ -26,6 +26,8 @@ import { FileBrowser } from "@/components/files/file-browser";
import type { FileNodeRights } from "@/lib/jmap/types";
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { WopiEditor } from "@/components/files/wopi-editor";
import { useWopiStatus, fileExtension } from "@/hooks/use-wopi-status";
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
import type { FolderLayout } from "@/components/files/files-settings-dialog";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
@@ -127,6 +129,14 @@ export function FilesApp({ linkSegments }: FilesAppProps = {}) {
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [previewFile, setPreviewFile] = useState<string | null>(null);
// WOPI document editing (#425): name of the file open in the editor overlay.
const [editFile, setEditFile] = useState<string | null>(null);
const wopiStatus = useWopiStatus(filesEnabled);
const isOfficeEditable = useCallback((name: string) => {
if (!wopiStatus?.enabled) return false;
const ext = fileExtension(name);
return wopiStatus.editExtensions.includes(ext) || wopiStatus.viewExtensions.includes(ext);
}, [wopiStatus]);
const [showDetails, setShowDetails] = useState(false);
const [detailName, setDetailName] = useState<string | null>(null);
@@ -649,6 +659,8 @@ export function FilesApp({ linkSegments }: FilesAppProps = {}) {
onMoveToParent={handleMoveToParent}
onPreviewImage={handlePreviewImage}
onPreviewFile={handlePreviewFile}
isOfficeEditable={isOfficeEditable}
onEditFile={setEditFile}
onShowDetails={handleShowDetails}
onCreateTextFile={handleCreateTextFile}
onDuplicate={handleDuplicate}
@@ -707,6 +719,22 @@ export function FilesApp({ linkSegments }: FilesAppProps = {}) {
/>
)}
{/* WOPI document editor overlay (#425) */}
{editFile && (() => {
const editResource = resources.find(r => r.name === editFile);
return editResource ? (
<WopiEditor
resource={editResource}
accountId={filesAccountId}
onClose={() => {
setEditFile(null);
// The editor may have saved new content - pick up size/mtime.
refresh();
}}
/>
) : null;
})()}
{/* Legacy file migration progress (issue #379) */}
{migrationProgress && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
+139
View File
@@ -0,0 +1,139 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Loader2, X } from "lucide-react";
import type { FileResource } from "@/stores/file-store";
interface WopiEditorProps {
resource: FileResource;
/** Files account id of the browsing account (multi-account contexts). */
accountId?: string | null;
onClose: () => void;
}
interface LaunchData {
url: string;
accessToken: string;
accessTokenTtl: number;
readOnly: boolean;
}
/**
* Full-screen WOPI editor overlay (#425). The editor (Collabora Online,
* OnlyOffice/EuroOffice, ...) is launched the way the WOPI spec prescribes:
* a form POST of the access token to the editor URL, targeted at an iframe.
*/
export function WopiEditor({ resource, accountId, onClose }: WopiEditorProps) {
const t = useTranslations("files");
const [launch, setLaunch] = useState<LaunchData | null>(null);
const [error, setError] = useState<string | null>(null);
const [frameLoaded, setFrameLoaded] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
const submittedRef = useRef(false);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch("/api/wopi/launch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fileId: resource.id, accountId: accountId || undefined }),
});
if (!res.ok) throw new Error(`launch failed (${res.status})`);
const data = (await res.json()) as LaunchData;
if (!cancelled) setLaunch(data);
} catch {
if (!cancelled) setError(t("office_error"));
}
})();
return () => {
cancelled = true;
};
}, [resource.id, accountId, t]);
// Submit the launch form exactly once, after it is in the DOM.
useEffect(() => {
if (launch && formRef.current && !submittedRef.current) {
submittedRef.current = true;
formRef.current.submit();
}
}, [launch]);
// Close on Escape and when the editor posts a close message
// (Collabora sends {"MessageId":"close"} / UI_Close via postMessage).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
const onMessage = (e: MessageEvent) => {
if (typeof e.data !== "string") return;
try {
const msg = JSON.parse(e.data) as { MessageId?: string };
if (msg.MessageId === "close" || msg.MessageId === "UI_Close") onClose();
} catch {
// Not a WOPI post message - ignore.
}
};
window.addEventListener("keydown", onKey);
window.addEventListener("message", onMessage);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("message", onMessage);
};
}, [onClose]);
return (
<div className="fixed inset-0 z-[100] bg-background flex flex-col">
<div className="flex items-center justify-between gap-3 px-4 h-12 border-b border-border shrink-0">
<span className="text-sm font-medium text-foreground truncate">{resource.name}</span>
<button
onClick={onClose}
aria-label={t("office_close")}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="relative flex-1 min-h-0">
{error ? (
<div className="absolute inset-0 flex items-center justify-center px-6 text-center text-sm text-muted-foreground">
{error}
</div>
) : (
<>
{!frameLoaded && (
<div className="absolute inset-0 flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
{t("office_loading")}
</div>
)}
<iframe
name="wopi-editor-frame"
title={resource.name}
className="w-full h-full border-0"
onLoad={() => setFrameLoaded(true)}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads allow-modals"
referrerPolicy="no-referrer"
allow="clipboard-read; clipboard-write"
/>
{launch && (
<form
ref={formRef}
action={launch.url}
method="POST"
target="wopi-editor-frame"
hidden
>
<input name="access_token" value={launch.accessToken} type="hidden" readOnly />
<input name="access_token_ttl" value={String(launch.accessTokenTtl)} type="hidden" readOnly />
</form>
)}
</>
)}
</div>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import { useEffect, useState } from "react";
/**
* Whether a WOPI document editor is configured for this deployment and which
* file extensions it handles (#425). Fetched once per page load and shared
* across consumers via a module-level promise.
*/
export interface WopiStatus {
enabled: boolean;
editExtensions: string[];
viewExtensions: string[];
}
const DISABLED: WopiStatus = { enabled: false, editExtensions: [], viewExtensions: [] };
let statusPromise: Promise<WopiStatus> | null = null;
function fetchWopiStatus(): Promise<WopiStatus> {
if (!statusPromise) {
statusPromise = fetch("/api/wopi/status")
.then((res) => (res.ok ? res.json() : DISABLED))
.then((data) => ({
enabled: !!data?.enabled,
editExtensions: Array.isArray(data?.editExtensions) ? data.editExtensions : [],
viewExtensions: Array.isArray(data?.viewExtensions) ? data.viewExtensions : [],
}))
.catch(() => {
statusPromise = null; // allow a retry on the next mount
return DISABLED;
});
}
return statusPromise;
}
export function useWopiStatus(enabled: boolean): WopiStatus | null {
const [status, setStatus] = useState<WopiStatus | null>(null);
useEffect(() => {
if (!enabled) return;
let cancelled = false;
fetchWopiStatus().then((s) => {
if (!cancelled) setStatus(s);
});
return () => {
cancelled = true;
};
}, [enabled]);
return status;
}
/** Lowercased extension of a file name, without the dot. */
export function fileExtension(name: string): string {
const parts = name.toLowerCase().split(".");
return parts.length > 1 ? parts.pop() || "" : "";
}
+106
View File
@@ -0,0 +1,106 @@
import { describe, it, expect, vi } from 'vitest';
import { parseWopiDiscovery, buildWopiActionUrl } from '@/lib/wopi/discovery';
import { mintWopiToken, verifyWopiToken } from '@/lib/wopi/token';
// token.ts encrypts via lib/auth/crypto, whose key comes solely from
// getSessionSecret() - mock that seam like auth-crypto.test.ts does.
vi.mock('@/lib/auth/session-secret', () => ({
getSessionSecret: () => 'x'.repeat(32),
hasSessionSecret: () => true,
}));
vi.mock('@/lib/logger', () => ({
logger: { warn: () => {}, error: () => {}, info: () => {}, debug: () => {} },
}));
const DISCOVERY_XML = `<?xml version="1.0" encoding="UTF-8"?>
<wopi-discovery>
<net-zone name="external-http">
<app name="writer">
<action default="true" ext="odt" name="edit" urlsrc="http://office.example:9980/browser/abc123/cool.html?"/>
<action ext="docx" name="edit" urlsrc="http://office.example:9980/browser/abc123/cool.html?"/>
<action ext="doc" name="view" urlsrc="http://office.example:9980/browser/abc123/cool.html?"/>
</app>
<app name="calc">
<action default="true" ext="ods" name="edit" urlsrc="http://office.example:9980/browser/abc123/cool.html?"/>
<action ext="xlsx" name="edit" urlsrc="http://office.example:9980/browser/abc123/cool.html?"/>
</app>
<app name="application/vnd.openxmlformats-officedocument.wordprocessingml.document">
<action default="true" ext="" name="edit" urlsrc="http://office.example:9980/browser/abc123/cool.html?"/>
</app>
</net-zone>
</wopi-discovery>`;
describe('parseWopiDiscovery', () => {
it('maps extensions to edit/view urlsrc', () => {
const actions = parseWopiDiscovery(DISCOVERY_XML);
expect(Object.keys(actions.edit).sort()).toEqual(['docx', 'ods', 'odt', 'xlsx']);
expect(actions.view).toHaveProperty('doc');
expect(actions.edit.docx).toContain('/cool.html');
});
it('ignores actions without an extension and tolerates junk', () => {
expect(parseWopiDiscovery('<notxml>')).toEqual({ edit: {}, view: {} });
expect(parseWopiDiscovery('')).toEqual({ edit: {}, view: {} });
});
});
describe('buildWopiActionUrl', () => {
it('appends WOPISrc to a urlsrc ending in ?', () => {
const url = buildWopiActionUrl(
'http://office.example/browser/abc/cool.html?',
'http://mail.example/api/wopi/files/f1',
);
expect(url).toBe(
'http://office.example/browser/abc/cool.html?WOPISrc=http%3A%2F%2Fmail.example%2Fapi%2Fwopi%2Ffiles%2Ff1',
);
});
it('drops optional <placeholder&> groups and joins with &', () => {
const url = buildWopiActionUrl(
'http://office.example/cool.html?lang=en<ui=UI_LLCC&><rs=DC_LLCC&>',
'http://mail.example/api/wopi/files/f1',
);
expect(url).toBe(
'http://office.example/cool.html?lang=en&WOPISrc=http%3A%2F%2Fmail.example%2Fapi%2Fwopi%2Ffiles%2Ff1',
);
});
it('starts the query string when urlsrc has none', () => {
const url = buildWopiActionUrl('http://office.example/edit', 'http://h/api/wopi/files/f1');
expect(url).toContain('/edit?WOPISrc=');
});
});
describe('mintWopiToken / verifyWopiToken', () => {
const payload = {
serverUrl: 'https://mail.example.com',
authHeader: 'Basic dXNlcjpwYXNz',
username: 'user@example.com',
accountId: 'c',
fileId: 'f42',
canWrite: true,
origin: 'https://webmail.example.com',
};
it('round-trips and binds to the fileId', () => {
const { token, expiresAt } = mintWopiToken(payload);
expect(expiresAt).toBeGreaterThan(Date.now());
const verified = verifyWopiToken(token, 'f42');
expect(verified).toMatchObject(payload);
// A token for one file must not authorize another.
expect(verifyWopiToken(token, 'other-file')).toBeNull();
});
it('rejects garbage, empty and expired tokens', () => {
expect(verifyWopiToken(null, 'f42')).toBeNull();
expect(verifyWopiToken('not-a-token', 'f42')).toBeNull();
const { token } = mintWopiToken(payload);
const realNow = Date.now;
vi.spyOn(Date, 'now').mockReturnValue(realNow() + 7 * 60 * 60 * 1000);
try {
expect(verifyWopiToken(token, 'f42')).toBeNull();
} finally {
vi.restoreAllMocks();
}
});
});
+9
View File
@@ -261,6 +261,15 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
logLevel: { envVar: 'LOG_LEVEL', type: 'enum', defaultValue: 'info', enumValues: ['error', 'warn', 'info', 'debug'] },
sessionSecret: { envVar: 'SESSION_SECRET', fileEnvVar: 'SESSION_SECRET_FILE', type: 'string', defaultValue: '' },
extensionDirectoryUrl: { envVar: 'EXTENSION_DIRECTORY_URL', type: 'url', defaultValue: 'https://extensions.bulwarkmail.org' },
// WOPI document editing (#425). `wopiClientUrl` is the editor's base URL
// (Collabora Online / OnlyOffice / EuroOffice, ...); discovery is fetched
// from `<url>/hosting/discovery` unless the URL already carries a path.
// Empty = feature off.
wopiClientUrl: { envVar: 'WOPI_CLIENT_URL', type: 'url', defaultValue: '' },
// How the WOPI editor reaches this webmail (WOPISrc base). Empty = derive
// from the request origin; set it when the editor sees a different host
// than the browser (docker networks, split DNS).
wopiHostUrl: { envVar: 'WOPI_HOST_URL', type: 'url', defaultValue: '' },
};
/** Keys that should never be exposed to the client config endpoint */
+119
View File
@@ -0,0 +1,119 @@
import { configManager } from '@/lib/admin/config-manager';
import { logger } from '@/lib/logger';
/**
* WOPI client discovery (#425).
*
* A WOPI client (Collabora Online, OnlyOffice/EuroOffice, ...) publishes the
* document types it can open and the editor URL for each action at
* `<client>/hosting/discovery`:
*
* <wopi-discovery><net-zone><app name="writer">
* <action name="edit" ext="odt" urlsrc="https://office.example/browser/abc/cool.html?"/>
* ...
*
* The admin configures `wopiClientUrl`; we fetch and cache the discovery XML
* server-side (the URL is operator-supplied and may be on a private network,
* so it deliberately does NOT go through the public-URL guard).
*/
export interface WopiActions {
/** extension (lowercase, no dot) -> editor urlsrc for the "edit" action */
edit: Record<string, string>;
/** extension -> urlsrc for the "view" action */
view: Record<string, string>;
}
const DISCOVERY_TTL_MS = 5 * 60 * 1000;
let discoveryCache: { url: string; fetchedAt: number; actions: WopiActions } | null = null;
export async function getWopiClientUrl(): Promise<string> {
await configManager.ensureLoaded();
return configManager.get<string>('wopiClientUrl', '').trim().replace(/\/+$/, '');
}
function discoveryUrlFor(clientUrl: string): string {
try {
const url = new URL(clientUrl);
// A bare origin means "the editor's base URL" - discovery lives at the
// well-known path. A URL with a path is taken as the discovery URL itself.
if (url.pathname === '/' || url.pathname === '') {
return `${url.origin}/hosting/discovery`;
}
return clientUrl;
} catch {
return '';
}
}
export function parseWopiDiscovery(xml: string): WopiActions {
const actions: WopiActions = { edit: {}, view: {} };
const tags = xml.match(/<action\b[^>]*\/?>/gi) || [];
for (const tag of tags) {
const attr = (name: string): string => {
const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`, 'i'));
return m?.[1] ?? '';
};
const name = attr('name').toLowerCase();
const ext = attr('ext').toLowerCase();
const urlsrc = attr('urlsrc');
if (!ext || !urlsrc) continue;
if (name === 'edit' && !actions.edit[ext]) actions.edit[ext] = urlsrc;
if (name === 'view' && !actions.view[ext]) actions.view[ext] = urlsrc;
}
return actions;
}
/**
* Fetch (with a short cache) the configured WOPI client's action map.
* Returns null when no client is configured or discovery is unreachable.
*/
export async function getWopiActions(): Promise<WopiActions | null> {
const clientUrl = await getWopiClientUrl();
if (!clientUrl) return null;
const discoveryUrl = discoveryUrlFor(clientUrl);
if (!discoveryUrl) return null;
if (
discoveryCache &&
discoveryCache.url === discoveryUrl &&
Date.now() - discoveryCache.fetchedAt < DISCOVERY_TTL_MS
) {
return discoveryCache.actions;
}
try {
const res = await fetch(discoveryUrl, { headers: { Accept: 'text/xml' } });
if (!res.ok) {
logger.warn('WOPI discovery fetch failed', { discoveryUrl, status: res.status });
return null;
}
const actions = parseWopiDiscovery(await res.text());
if (Object.keys(actions.edit).length === 0 && Object.keys(actions.view).length === 0) {
logger.warn('WOPI discovery returned no actions', { discoveryUrl });
return null;
}
discoveryCache = { url: discoveryUrl, fetchedAt: Date.now(), actions };
return actions;
} catch (error) {
logger.warn('WOPI discovery unreachable', {
discoveryUrl,
error: error instanceof Error ? error.message : 'Unknown',
});
return null;
}
}
/**
* Build the editor launch URL from a discovery `urlsrc` and the WOPISrc of
* the file. Placeholder groups like `<ui=UI_LLCC&>` are optional per the WOPI
* spec and are dropped.
*/
export function buildWopiActionUrl(urlsrc: string, wopiSrc: string): string {
const base = urlsrc.replace(/<[^>]*>/g, '');
const sep = base.includes('?')
? (base.endsWith('?') || base.endsWith('&') ? '' : '&')
: '?';
return `${base}${sep}WOPISrc=${encodeURIComponent(wopiSrc)}`;
}
+191
View File
@@ -0,0 +1,191 @@
import { postJmap, fetchJmapSession, rebaseApiUrl } from '@/lib/stalwart/jmap-api';
import { fetchJmapServer } from '@/lib/stalwart/server-fetch';
import type { FileNode } from '@/lib/jmap/types';
/**
* Server-side JMAP FileNode content I/O for the WOPI host (#425).
*
* The WOPI client calls CheckFileInfo/GetFile/PutFile server-to-server, so
* this module talks to the JMAP server directly with the credentials carried
* in the (encrypted) access token. Content is addressed purely via JMAP -
* blob download for GetFile, blob upload + `FileNode/set { blobId }` for
* PutFile - which sidesteps the DAV name-encoding quirks (#869) and works
* regardless of where the node sits in the folder tree.
*/
export interface WopiJmapContext {
serverUrl: string;
authHeader: string;
/** Mirrors StalwartCredentials.trusted - false routes via the rebinding-safe fetch. */
trusted: boolean;
}
const USING = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:filenode'];
export const WOPI_FILE_NODE_PROPERTIES = [
'id', 'parentId', 'name', 'type', 'blobId', 'size', 'modified', 'myRights',
];
type JmapMethodResponse = [string, Record<string, unknown>, string];
async function callJmap(ctx: WopiJmapContext, methodCalls: unknown[]): Promise<JmapMethodResponse[]> {
const body = JSON.stringify({ using: USING, methodCalls });
const options = { trusted: ctx.trusted };
const directUrl = `${ctx.serverUrl}/jmap/`;
let response = await postJmap(directUrl, ctx.authHeader, body, options);
if (response.status === 404) {
// Same recovery as the JMAP passthrough (#627): resolve the session's
// advertised apiUrl rebased onto the reachable host and retry once.
const session = await fetchJmapSession(ctx.serverUrl, ctx.authHeader, options);
const apiUrl = rebaseApiUrl(session, ctx.serverUrl);
if (apiUrl && apiUrl !== directUrl) {
response = await postJmap(apiUrl, ctx.authHeader, body, options);
}
}
if (!response.ok) {
throw new Error(`JMAP request failed (${response.status})`);
}
const json = (await response.json()) as { methodResponses?: JmapMethodResponse[] };
return json.methodResponses ?? [];
}
export async function getWopiFileNode(
ctx: WopiJmapContext,
accountId: string,
fileId: string,
): Promise<FileNode | null> {
const responses = await callJmap(ctx, [
['FileNode/get', { accountId, ids: [fileId], properties: WOPI_FILE_NODE_PROPERTIES }, 'g0'],
]);
const [name, payload] = responses[0] ?? [];
if (name !== 'FileNode/get') return null;
const list = (payload?.list as FileNode[] | undefined) ?? [];
return list[0] ?? null;
}
// The session's download/upload templates advertise the server's public
// hostname, which this process may not be able to reach (see rebaseApiUrl) -
// keep the template's path but rebase onto the stored serverUrl's origin.
// Falls back to Stalwart's canonical paths when no session is available.
const FALLBACK_DOWNLOAD_PATH = '/jmap/download/{accountId}/{blobId}/{name}?accept={type}';
const FALLBACK_UPLOAD_PATH = '/jmap/upload/{accountId}/';
const blobUrlCache = new Map<string, { fetchedAt: number; downloadUrl: string; uploadUrl: string }>();
const BLOB_URL_TTL_MS = 5 * 60 * 1000;
function rebaseTemplate(template: string | undefined, serverUrl: string, fallbackPath: string): string {
const origin = new URL(serverUrl).origin;
if (!template) return origin + fallbackPath;
// Not parsed with `new URL` - the {placeholders} would get percent-encoded.
const path = template.replace(/^[a-z]+:\/\/[^/]+/i, '');
return origin + (path.startsWith('/') ? path : `/${path}`);
}
async function getBlobUrls(ctx: WopiJmapContext): Promise<{ downloadUrl: string; uploadUrl: string }> {
const cached = blobUrlCache.get(ctx.serverUrl);
if (cached && Date.now() - cached.fetchedAt < BLOB_URL_TTL_MS) return cached;
let downloadTemplate: string | undefined;
let uploadTemplate: string | undefined;
try {
const session = await fetchJmapSession(ctx.serverUrl, ctx.authHeader, { trusted: ctx.trusted }) as
| ({ downloadUrl?: string; uploadUrl?: string } & Record<string, unknown>)
| null;
downloadTemplate = session?.downloadUrl;
uploadTemplate = session?.uploadUrl;
} catch {
// Fall through to the canonical Stalwart paths.
}
const entry = {
fetchedAt: Date.now(),
downloadUrl: rebaseTemplate(downloadTemplate, ctx.serverUrl, FALLBACK_DOWNLOAD_PATH),
uploadUrl: rebaseTemplate(uploadTemplate, ctx.serverUrl, FALLBACK_UPLOAD_PATH),
};
blobUrlCache.set(ctx.serverUrl, entry);
return entry;
}
function expandTemplate(template: string, vars: Record<string, string>): string {
return template.replace(/\{([A-Za-z]+)\}/g, (match, key: string) =>
key in vars ? encodeURIComponent(vars[key]) : match,
);
}
/** Stream a blob's content from the JMAP server (WOPI GetFile). */
export async function downloadFileBlob(
ctx: WopiJmapContext,
accountId: string,
blobId: string,
name: string,
type: string,
): Promise<Response> {
const { downloadUrl } = await getBlobUrls(ctx);
const url = expandTemplate(downloadUrl, {
accountId,
blobId,
name,
type: type || 'application/octet-stream',
});
return fetchJmapServer(url, {
method: 'GET',
headers: { Authorization: ctx.authHeader },
}, ctx.trusted);
}
/** Upload new content as a blob (first half of WOPI PutFile). */
export async function uploadFileBlob(
ctx: WopiJmapContext,
accountId: string,
body: ArrayBuffer,
contentType: string,
): Promise<{ blobId: string; size: number; type: string }> {
const { uploadUrl } = await getBlobUrls(ctx);
const url = expandTemplate(uploadUrl, { accountId });
const response = await fetchJmapServer(url, {
method: 'POST',
headers: {
Authorization: ctx.authHeader,
'Content-Type': contentType || 'application/octet-stream',
},
body,
}, ctx.trusted);
if (!response.ok) {
throw new Error(`Blob upload failed (${response.status})`);
}
const json = (await response.json()) as { blobId?: string; size?: number; type?: string };
if (!json.blobId) throw new Error('Blob upload returned no blobId');
return { blobId: json.blobId, size: json.size ?? body.byteLength, type: json.type ?? contentType };
}
/**
* Point an existing FileNode at a new content blob (second half of PutFile).
* Returns the node's post-update `modified` timestamp.
*/
export async function updateFileNodeBlob(
ctx: WopiJmapContext,
accountId: string,
fileId: string,
blobId: string,
): Promise<{ modified: string }> {
const responses = await callJmap(ctx, [
['FileNode/set', { accountId, update: { [fileId]: { blobId } } }, 's0'],
['FileNode/get', { accountId, ids: [fileId], properties: ['id', 'modified'] }, 'g1'],
]);
const [setName, setPayload] = responses[0] ?? [];
if (setName !== 'FileNode/set') {
throw new Error((setPayload?.description as string) || 'FileNode/set failed');
}
const updated = setPayload?.updated as Record<string, unknown> | undefined;
if (!updated || !(fileId in updated)) {
const notUpdated = setPayload?.notUpdated as Record<string, { description?: string }> | undefined;
throw new Error(notUpdated?.[fileId]?.description || 'FileNode content update rejected');
}
const getPayload = responses[1]?.[1];
const node = ((getPayload?.list as FileNode[] | undefined) ?? [])[0];
return { modified: node?.modified ?? new Date().toISOString() };
}
+25
View File
@@ -0,0 +1,25 @@
import type { NextRequest } from 'next/server';
import { isTrustedJmapServerUrl } from '@/lib/stalwart/server-fetch';
import type { WopiJmapContext } from '@/lib/wopi/files';
import { verifyWopiToken, type WopiTokenPayload } from '@/lib/wopi/token';
/**
* Authenticate an incoming WOPI request (called by the editor
* server-to-server, no session cookie) from its `access_token` query
* parameter, scoped to the fileId in the URL.
*/
export async function wopiContext(
request: NextRequest,
fileId: string,
): Promise<{ payload: WopiTokenPayload; ctx: WopiJmapContext } | null> {
const payload = verifyWopiToken(request.nextUrl.searchParams.get('access_token'), fileId);
if (!payload) return null;
// Trust is re-derived from the current config, not persisted in the token
// (mirrors lib/stalwart/server-fetch.ts): removing a server from the config
// immediately drops its tokens to the rebinding-safe fetch path.
const trusted = await isTrustedJmapServerUrl(payload.serverUrl);
return {
payload,
ctx: { serverUrl: payload.serverUrl, authHeader: payload.authHeader, trusted },
};
}
+69
View File
@@ -0,0 +1,69 @@
import { encryptPayload, decryptPayload } from '@/lib/auth/crypto';
/**
* WOPI access tokens (#425).
*
* The WOPI client (Collabora / OnlyOffice / EuroOffice, ...) receives this
* token in the launch form POST and echoes it back on every WOPI call
* (CheckFileInfo, GetFile, PutFile). It is an AES-256-GCM blob keyed off
* SESSION_SECRET - opaque to the editor - carrying the stored Stalwart
* credentials plus the one file node it is scoped to. The editor can
* therefore only reach the /api/wopi/files/<fileId> surface for that node,
* never the JMAP server itself.
*/
export interface WopiTokenPayload {
serverUrl: string;
authHeader: string;
username: string;
accountId: string;
fileId: string;
canWrite: boolean;
/** Browser origin that embeds the editor iframe (WOPI PostMessageOrigin). */
origin: string;
/** Expiry, ms since epoch. */
exp: number;
}
export const WOPI_TOKEN_TTL_MS = 6 * 60 * 60 * 1000;
// The token travels as a form field and is re-embedded into URLs by the
// editor. Standard base64 breaks on that trip: '+' decodes to a space under
// x-www-form-urlencoded rules and '/' needs escaping, so the token is issued
// in the URL-safe alphabet (base64url) instead.
function toBase64Url(token: string): string {
return token.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function fromBase64Url(token: string): string {
// Also map stray spaces back to '+' in case an intermediary applied
// form-urlencoded decoding to a legacy standard-base64 token.
return token.replace(/ /g, '+').replace(/-/g, '+').replace(/_/g, '/');
}
export function mintWopiToken(payload: Omit<WopiTokenPayload, 'exp'>): { token: string; expiresAt: number } {
const expiresAt = Date.now() + WOPI_TOKEN_TTL_MS;
const token = toBase64Url(encryptPayload({ v: 1, t: 'wopi', ...payload, exp: expiresAt }));
return { token, expiresAt };
}
/**
* Decrypt and validate a WOPI access token. `fileId` must match the id in
* the request URL so a token for one document cannot address another.
*/
export function verifyWopiToken(token: string | null, fileId: string): WopiTokenPayload | null {
if (!token) return null;
const raw = decryptPayload(fromBase64Url(token));
if (!raw || raw.v !== 1 || raw.t !== 'wopi') return null;
const p = raw as unknown as WopiTokenPayload;
if (
typeof p.serverUrl !== 'string' || !p.serverUrl ||
typeof p.authHeader !== 'string' || !p.authHeader ||
typeof p.accountId !== 'string' || !p.accountId ||
typeof p.fileId !== 'string' || !p.fileId ||
typeof p.exp !== 'number'
) return null;
if (p.fileId !== fileId) return null;
if (Date.now() > p.exp) return null;
return p;
}
+3559 -3556
View File
File diff suppressed because it is too large Load Diff
+3559 -3556
View File
File diff suppressed because it is too large Load Diff
+3559 -3556
View File
File diff suppressed because it is too large Load Diff
+3559 -3556
View File
File diff suppressed because it is too large Load Diff
+3559 -3556
View File
File diff suppressed because it is too large Load Diff
+3559 -3556
View File
File diff suppressed because it is too large Load Diff
+3559 -3556
View File
File diff suppressed because it is too large Load Diff
+3559 -3556
View File
File diff suppressed because it is too large Load Diff
+3559 -3556
View File
File diff suppressed because it is too large Load Diff
+3560 -3557
View File
File diff suppressed because it is too large Load Diff
+3559 -3556
View File
File diff suppressed because it is too large Load Diff
+3559 -3556
View File
File diff suppressed because it is too large Load Diff
+3559 -3556
View File
File diff suppressed because it is too large Load Diff
+3559 -3556
View File
File diff suppressed because it is too large Load Diff
+3559 -3556
View File
File diff suppressed because it is too large Load Diff
+3559 -3556
View File
File diff suppressed because it is too large Load Diff
+3559 -3556
View File
File diff suppressed because it is too large Load Diff
+3559 -3556
View File
File diff suppressed because it is too large Load Diff
+3559 -3556
View File
File diff suppressed because it is too large Load Diff
+3559 -3556
View File
File diff suppressed because it is too large Load Diff
+3559 -3556
View File
File diff suppressed because it is too large Load Diff
+3559 -3556
View File
File diff suppressed because it is too large Load Diff
+3559 -3556
View File
File diff suppressed because it is too large Load Diff
+3559 -3556
View File
File diff suppressed because it is too large Load Diff
+3559 -3556
View File
File diff suppressed because it is too large Load Diff
+3554 -3551
View File
File diff suppressed because it is too large Load Diff
+3559 -3556
View File
File diff suppressed because it is too large Load Diff
+13 -2
View File
@@ -206,9 +206,20 @@ export async function proxy(request: NextRequest) {
// governs the apps users add themselves.
const managedAppFrameOrigins = inlineAppFrameOrigins(policy.defaultSidebarApps);
// WOPI document editor (#425): the editor is launched by POSTing the access
// token into an iframe on its origin, so that origin must be allowed in both
// frame-src AND form-action (which is otherwise 'self').
let wopiOrigin = "";
try {
const wopiClientUrl = configManager.get<string>("wopiClientUrl", "").trim();
if (wopiClientUrl) wopiOrigin = new URL(wopiClientUrl).origin;
} catch {
// Invalid admin-supplied URL - leave the CSP unchanged.
}
const frameOrigins: string[] = [];
const seenFrameOrigins = new Set<string>();
for (const origin of [...pluginFrameOrigins, ...managedAppFrameOrigins, ...appFrameOrigins]) {
for (const origin of [...pluginFrameOrigins, ...managedAppFrameOrigins, ...appFrameOrigins, ...(wopiOrigin ? [wopiOrigin] : [])]) {
const key = origin.toLowerCase();
if (seenFrameOrigins.has(key)) continue;
seenFrameOrigins.add(key);
@@ -230,7 +241,7 @@ export async function proxy(request: NextRequest) {
frameSrc,
`object-src 'self' blob:`,
`base-uri 'self'`,
`form-action 'self'`,
`form-action 'self'${wopiOrigin ? ` ${wopiOrigin}` : ""}`,
`frame-ancestors ${frameAncestors}`,
`media-src 'self' blob:`,
].join("; ");