mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: WOPI office document editing in Files (Collabora/OnlyOffice/EuroOffice) #425
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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() || "" : "";
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 */
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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() };
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+3559
-3556
File diff suppressed because it is too large
Load Diff
+3559
-3556
File diff suppressed because it is too large
Load Diff
+3559
-3556
File diff suppressed because it is too large
Load Diff
+3559
-3556
File diff suppressed because it is too large
Load Diff
+3559
-3556
File diff suppressed because it is too large
Load Diff
+3559
-3556
File diff suppressed because it is too large
Load Diff
+3559
-3556
File diff suppressed because it is too large
Load Diff
+3559
-3556
File diff suppressed because it is too large
Load Diff
+3560
-3557
File diff suppressed because it is too large
Load Diff
+3559
-3556
File diff suppressed because it is too large
Load Diff
+3559
-3556
File diff suppressed because it is too large
Load Diff
+3559
-3556
File diff suppressed because it is too large
Load Diff
+3559
-3556
File diff suppressed because it is too large
Load Diff
+3559
-3556
File diff suppressed because it is too large
Load Diff
+3559
-3556
File diff suppressed because it is too large
Load Diff
+3559
-3556
File diff suppressed because it is too large
Load Diff
+3559
-3556
File diff suppressed because it is too large
Load Diff
+3559
-3556
File diff suppressed because it is too large
Load Diff
+3559
-3556
File diff suppressed because it is too large
Load Diff
+3559
-3556
File diff suppressed because it is too large
Load Diff
+3559
-3556
File diff suppressed because it is too large
Load Diff
+3559
-3556
File diff suppressed because it is too large
Load Diff
+3559
-3556
File diff suppressed because it is too large
Load Diff
+3559
-3556
File diff suppressed because it is too large
Load Diff
+3554
-3551
File diff suppressed because it is too large
Load Diff
+3559
-3556
File diff suppressed because it is too large
Load Diff
@@ -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("; ");
|
||||
|
||||
Reference in New Issue
Block a user