mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
feat: open office attachments in mail read-only in the document editor #1047
This commit is contained in:
@@ -5,8 +5,9 @@ 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 }`.
|
||||
* streams the node's blob (or, for a mail attachment, the attachment blob
|
||||
* itself - #1047), PutFile uploads the editor's bytes as a new blob and
|
||||
* points the FileNode at it via `FileNode/set { blobId }`.
|
||||
*/
|
||||
|
||||
/** GET = GetFile */
|
||||
@@ -15,26 +16,33 @@ export async function GET(
|
||||
{ params }: { params: Promise<{ fileId: string }> },
|
||||
) {
|
||||
try {
|
||||
const { fileId } = await params;
|
||||
const auth = await wopiContext(request, fileId);
|
||||
const { fileId: documentId } = await params;
|
||||
const auth = await wopiContext(request, documentId);
|
||||
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 { payload } = auth;
|
||||
let source: { blobId: string; name: string; type: string };
|
||||
if (payload.kind === 'attachment') {
|
||||
source = { blobId: payload.fileId, name: payload.name || 'attachment', type: payload.type || '' };
|
||||
} else {
|
||||
const node = await getWopiFileNode(auth.ctx, payload.accountId, payload.fileId);
|
||||
if (!node || !node.blobId) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
||||
}
|
||||
source = { blobId: node.blobId, name: node.name, type: node.type };
|
||||
}
|
||||
|
||||
const upstream = await downloadFileBlob(
|
||||
auth.ctx, auth.payload.accountId, node.blobId, node.name, node.type,
|
||||
auth.ctx, payload.accountId, source.blobId, source.name, source.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');
|
||||
headers.set('Content-Type', source.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 });
|
||||
@@ -52,15 +60,16 @@ export async function POST(
|
||||
{ params }: { params: Promise<{ fileId: string }> },
|
||||
) {
|
||||
try {
|
||||
const { fileId } = await params;
|
||||
const auth = await wopiContext(request, fileId);
|
||||
const { fileId: documentId } = await params;
|
||||
const auth = await wopiContext(request, documentId);
|
||||
if (!auth) {
|
||||
return NextResponse.json({ error: 'Invalid access token' }, { status: 401 });
|
||||
}
|
||||
if (!auth.payload.canWrite) {
|
||||
if (!auth.payload.canWrite || auth.payload.kind === 'attachment') {
|
||||
return NextResponse.json({ error: 'Read-only token' }, { status: 403 });
|
||||
}
|
||||
|
||||
const fileId = auth.payload.fileId;
|
||||
const node = await getWopiFileNode(auth.ctx, auth.payload.accountId, fileId);
|
||||
if (!node || !node.blobId) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
||||
|
||||
@@ -18,13 +18,33 @@ export async function GET(
|
||||
{ params }: { params: Promise<{ fileId: string }> },
|
||||
) {
|
||||
try {
|
||||
const { fileId } = await params;
|
||||
const auth = await wopiContext(request, fileId);
|
||||
const { fileId: documentId } = await params;
|
||||
const auth = await wopiContext(request, documentId);
|
||||
if (!auth) {
|
||||
return NextResponse.json({ error: 'Invalid access token' }, { status: 401 });
|
||||
}
|
||||
|
||||
const node = await getWopiFileNode(auth.ctx, auth.payload.accountId, fileId);
|
||||
const { payload } = auth;
|
||||
if (payload.kind === 'attachment') {
|
||||
// Mail attachments open read-only (#1047): the blob is immutable, so
|
||||
// its id doubles as the version and there is nothing to save back to.
|
||||
return NextResponse.json({
|
||||
BaseFileName: payload.name || 'attachment',
|
||||
Size: payload.size ?? 0,
|
||||
OwnerId: payload.username,
|
||||
UserId: payload.username,
|
||||
UserFriendlyName: payload.username,
|
||||
UserCanWrite: false,
|
||||
ReadOnly: true,
|
||||
UserCanNotWriteRelative: true,
|
||||
SupportsUpdate: false,
|
||||
SupportsLocks: false,
|
||||
Version: payload.fileId,
|
||||
PostMessageOrigin: payload.origin,
|
||||
});
|
||||
}
|
||||
|
||||
const node = await getWopiFileNode(auth.ctx, payload.accountId, payload.fileId);
|
||||
if (!node || !node.blobId) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
||||
}
|
||||
@@ -62,8 +82,8 @@ export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ fileId: string }> },
|
||||
) {
|
||||
const { fileId } = await params;
|
||||
const auth = await wopiContext(request, fileId);
|
||||
const { fileId: documentId } = await params;
|
||||
const auth = await wopiContext(request, documentId);
|
||||
if (!auth) {
|
||||
return NextResponse.json({ error: 'Invalid access token' }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -4,16 +4,19 @@ 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';
|
||||
import { getWopiFileNode, probeBlob } from '@/lib/wopi/files';
|
||||
import { mintWopiToken, wopiDocumentId, type WopiTokenPayload } from '@/lib/wopi/token';
|
||||
|
||||
/**
|
||||
* POST /api/wopi/launch { fileId: string, accountId?: string }
|
||||
* POST /api/wopi/launch
|
||||
* { fileId: string, accountId?: string } - a Files node
|
||||
* { blobId: string, name: string, type?: string, accountId?: string } - a mail attachment
|
||||
*
|
||||
* 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.
|
||||
* Mints a WOPI access token scoped to one document and returns the editor
|
||||
* URL to POST it to (#425). Attachments always open read-only (#1047).
|
||||
* 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 {
|
||||
@@ -22,10 +25,16 @@ export async function POST(request: NextRequest) {
|
||||
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 body = (await request.json().catch(() => null)) as Record<string, unknown> | null;
|
||||
const str = (key: string): string => (typeof body?.[key] === 'string' ? (body[key] as string) : '');
|
||||
const fileId = str('fileId');
|
||||
const blobId = str('blobId');
|
||||
const attachmentName = str('name').trim();
|
||||
if (!fileId && !blobId) {
|
||||
return NextResponse.json({ error: 'fileId or blobId is required' }, { status: 400 });
|
||||
}
|
||||
if (blobId && !attachmentName) {
|
||||
return NextResponse.json({ error: 'name is required for attachments' }, { status: 400 });
|
||||
}
|
||||
|
||||
const actions = await getWopiActions();
|
||||
@@ -41,25 +50,45 @@ export async function POST(request: NextRequest) {
|
||||
const trusted = creds.trusted || new URL(serverUrl).origin === request.nextUrl.origin;
|
||||
const ctx = { serverUrl, authHeader: creds.authHeader, trusted };
|
||||
|
||||
let accountId = typeof body?.accountId === 'string' ? body.accountId : '';
|
||||
let accountId = str('accountId');
|
||||
if (!accountId) {
|
||||
const session = await fetchJmapSession(serverUrl, creds.authHeader, { trusted });
|
||||
const capability = blobId ? 'urn:ietf:params:jmap:mail' : 'urn:ietf:params:jmap:filenode';
|
||||
accountId =
|
||||
session?.primaryAccounts?.['urn:ietf:params:jmap:filenode'] ||
|
||||
session?.primaryAccounts?.[capability] ||
|
||||
Object.keys(session?.accounts ?? {})[0] ||
|
||||
'';
|
||||
}
|
||||
if (!accountId) {
|
||||
return NextResponse.json({ error: 'No files account' }, { status: 404 });
|
||||
return NextResponse.json({ error: blobId ? 'No mail account' : '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 });
|
||||
let document: Pick<WopiTokenPayload, 'kind' | 'fileId' | 'name' | 'type' | 'size'>;
|
||||
let name: string;
|
||||
let canWrite: boolean;
|
||||
if (blobId) {
|
||||
const type = str('type');
|
||||
// Confirms the blob is readable with these credentials before the
|
||||
// editor is pointed at it, and reads its size for CheckFileInfo.
|
||||
const probe = await probeBlob(ctx, accountId, blobId, attachmentName, type);
|
||||
if (!probe) {
|
||||
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 });
|
||||
}
|
||||
const clientSize = typeof body?.size === 'number' && body.size >= 0 ? body.size : 0;
|
||||
name = attachmentName;
|
||||
canWrite = false;
|
||||
document = { kind: 'attachment', fileId: blobId, name, type, size: probe.size ?? clientSize };
|
||||
} else {
|
||||
const node = await getWopiFileNode(ctx, accountId, fileId);
|
||||
if (!node || !node.blobId) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
||||
}
|
||||
name = node.name;
|
||||
canWrite = node.myRights ? !!node.myRights.mayModifyContent : true;
|
||||
document = { kind: 'file', fileId };
|
||||
}
|
||||
|
||||
const ext = node.name.split('.').pop()?.toLowerCase() || '';
|
||||
const canWrite = node.myRights ? !!node.myRights.mayModifyContent : true;
|
||||
const ext = name.split('.').pop()?.toLowerCase() || '';
|
||||
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 });
|
||||
@@ -72,17 +101,17 @@ export async function POST(request: NextRequest) {
|
||||
const hostBase =
|
||||
configManager.get<string>('wopiHostUrl', '').trim().replace(/\/+$/, '') ||
|
||||
request.nextUrl.origin;
|
||||
const wopiSrc = `${hostBase}/api/wopi/files/${encodeURIComponent(fileId)}`;
|
||||
|
||||
const { token, expiresAt } = mintWopiToken({
|
||||
const tokenPayload = {
|
||||
serverUrl,
|
||||
authHeader: creds.authHeader,
|
||||
username: creds.username,
|
||||
accountId,
|
||||
fileId,
|
||||
...document,
|
||||
canWrite: editable,
|
||||
origin: request.nextUrl.origin,
|
||||
});
|
||||
};
|
||||
const wopiSrc = `${hostBase}/api/wopi/files/${wopiDocumentId(tokenPayload)}`;
|
||||
const { token, expiresAt } = mintWopiToken(tokenPayload);
|
||||
|
||||
return NextResponse.json({
|
||||
url: buildWopiActionUrl(urlsrc, wopiSrc),
|
||||
|
||||
@@ -107,6 +107,7 @@ import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invit
|
||||
import { RecipientPopover } from "./recipient-popover";
|
||||
import { MailtoLink } from "@/components/ui/mailto-link";
|
||||
import { inertBlobType, isFilePreviewable, isMimeTypeSafeForInlinePreview, toInertBlob } from "@/lib/file-preview";
|
||||
import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status";
|
||||
import { parseTnef, isTnefAttachment } from "@/lib/tnef";
|
||||
import { debug } from "@/lib/debug";
|
||||
import type { TnefAttachment } from "@/lib/tnef";
|
||||
@@ -677,6 +678,16 @@ export function EmailViewer({
|
||||
const messageSpacing = useSettingsStore((state) => state.messageSpacing);
|
||||
const plainTextFont = useSettingsStore((state) => state.plainTextFont);
|
||||
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
|
||||
// Office documents the built-in preview can't render still open in the
|
||||
// configured WOPI editor (#1047) - only blob-backed ones, the editor
|
||||
// fetches the content server-side.
|
||||
const wopiStatus = useWopiStatus(true);
|
||||
const isAttachmentPreviewable = useCallback(
|
||||
(attachment: EffectiveAttachment) =>
|
||||
isFilePreviewable(attachment.name || undefined, attachment.type)
|
||||
|| (!!attachment.blobId && canWopiOpen(wopiStatus, attachment.name)),
|
||||
[wopiStatus],
|
||||
);
|
||||
const mailAttachmentActionRef = useRef(mailAttachmentAction);
|
||||
mailAttachmentActionRef.current = mailAttachmentAction;
|
||||
const attachmentPosition = useSettingsStore((state) => state.attachmentPosition);
|
||||
@@ -4067,7 +4078,7 @@ export function EmailViewer({
|
||||
<div className="relative flex flex-col items-end justify-start gap-1 flex-shrink-0 max-w-[50%]">
|
||||
{effectiveAttachments.slice(0, 2).map((attachment) => {
|
||||
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
|
||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||
const isPreviewable = isAttachmentPreviewable(attachment);
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
const thumbUrl = imageThumbUrls[attachment.id];
|
||||
return (
|
||||
@@ -4164,7 +4175,7 @@ export function EmailViewer({
|
||||
<div className="absolute top-full end-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[220px]">
|
||||
{effectiveAttachments.slice(2).map((attachment) => {
|
||||
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
|
||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||
const isPreviewable = isAttachmentPreviewable(attachment);
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
@@ -4879,7 +4890,7 @@ export function EmailViewer({
|
||||
.slice(0, visibleBelowHeaderCount ?? effectiveAttachments.length)
|
||||
.map((attachment) => {
|
||||
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
|
||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||
const isPreviewable = isAttachmentPreviewable(attachment);
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
const thumbUrl = imageThumbUrls[attachment.id];
|
||||
return (
|
||||
@@ -4981,7 +4992,7 @@ export function EmailViewer({
|
||||
<div className="absolute top-full end-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[260px] max-h-[60vh] overflow-y-auto">
|
||||
{effectiveAttachments.slice(visibleBelowHeaderCount).map((attachment) => {
|
||||
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
|
||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||
const isPreviewable = isAttachmentPreviewable(attachment);
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
@@ -5051,7 +5062,7 @@ export function EmailViewer({
|
||||
<div className="relative flex items-center gap-1.5 flex-wrap">
|
||||
{effectiveAttachments.slice(0, 2).map((attachment) => {
|
||||
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
|
||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||
const isPreviewable = isAttachmentPreviewable(attachment);
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
const thumbUrl = imageThumbUrls[attachment.id];
|
||||
return (
|
||||
@@ -5147,7 +5158,7 @@ export function EmailViewer({
|
||||
<div className="absolute top-full start-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[220px]">
|
||||
{effectiveAttachments.slice(2).map((attachment) => {
|
||||
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
|
||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||
const isPreviewable = isAttachmentPreviewable(attachment);
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
return (
|
||||
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
|
||||
@@ -37,6 +37,7 @@ import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { isFilePreviewable, toInertBlob } from "@/lib/file-preview";
|
||||
import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status";
|
||||
|
||||
interface ThreadConversationViewProps {
|
||||
thread: ThreadGroup;
|
||||
@@ -240,6 +241,7 @@ function EmailCard({
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
|
||||
const wopiStatus = useWopiStatus(true);
|
||||
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
|
||||
const emailAlwaysLightMode = useSettingsStore((state) => state.emailAlwaysLightMode);
|
||||
const plainTextFont = useSettingsStore((state) => state.plainTextFont);
|
||||
@@ -647,7 +649,8 @@ function EmailCard({
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{visibleAttachments.map((attachment, idx) => {
|
||||
const Icon = getFileIcon(attachment.name, attachment.type);
|
||||
const isPreviewable = isFilePreviewable(attachment.name, attachment.type);
|
||||
const isPreviewable = isFilePreviewable(attachment.name, attachment.type)
|
||||
|| (!!attachment.blobId && canWopiOpen(wopiStatus, attachment.name));
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -27,7 +27,7 @@ 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 { useWopiStatus, canWopiOpen } 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";
|
||||
@@ -135,11 +135,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
// 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 isOfficeEditable = useCallback((name: string) => canWopiOpen(wopiStatus, name), [wopiStatus]);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [detailName, setDetailName] = useState<string | null>(null);
|
||||
|
||||
@@ -727,7 +723,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
const editResource = resources.find(r => r.name === editFile);
|
||||
return editResource ? (
|
||||
<WopiEditor
|
||||
resource={editResource}
|
||||
target={{ kind: "file", id: editResource.id, name: editResource.name }}
|
||||
accountId={filesAccountId}
|
||||
onClose={() => {
|
||||
setEditFile(null);
|
||||
|
||||
@@ -3,12 +3,22 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Loader2, X } from "@/components/icons";
|
||||
import type { FileResource } from "@/stores/file-store";
|
||||
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
|
||||
|
||||
/** What the overlay opens: a Files node, or a mail attachment (read-only, #1047). */
|
||||
export type WopiDocument =
|
||||
| { kind: "file"; id: string; name: string }
|
||||
| { kind: "attachment"; blobId: string; name: string; type?: string; size?: number };
|
||||
|
||||
interface WopiEditorProps {
|
||||
resource: FileResource;
|
||||
/** Files account id of the browsing account (multi-account contexts). */
|
||||
target: WopiDocument;
|
||||
/** JMAP account holding the node/blob (multi-account and shared contexts). */
|
||||
accountId?: string | null;
|
||||
/**
|
||||
* Cookie slot of the login that owns the document. Defaults to the active
|
||||
* account; the unified inbox passes the slot of the message's own login.
|
||||
*/
|
||||
slot?: number | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -23,23 +33,32 @@ interface LaunchData {
|
||||
* 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.
|
||||
* Mail attachments open in the same overlay as a read-only viewer (#1047).
|
||||
*/
|
||||
export function WopiEditor({ resource, accountId, onClose }: WopiEditorProps) {
|
||||
export function WopiEditor({ target, accountId, slot, 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);
|
||||
// Launch once per document; the object itself is rebuilt on every render.
|
||||
const launchBody = JSON.stringify(
|
||||
target.kind === "attachment"
|
||||
? { blobId: target.blobId, name: target.name, type: target.type, size: target.size, accountId: accountId || undefined }
|
||||
: { fileId: target.id, accountId: accountId || undefined },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const slotHeaders: Record<string, string> =
|
||||
typeof slot === "number" ? { "X-JMAP-Cookie-Slot": String(slot) } : getActiveAccountSlotHeaders();
|
||||
const res = await fetch("/api/wopi/launch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fileId: resource.id, accountId: accountId || undefined }),
|
||||
headers: { "Content-Type": "application/json", ...slotHeaders },
|
||||
body: launchBody,
|
||||
});
|
||||
if (!res.ok) throw new Error(`launch failed (${res.status})`);
|
||||
const data = (await res.json()) as LaunchData;
|
||||
@@ -51,7 +70,7 @@ export function WopiEditor({ resource, accountId, onClose }: WopiEditorProps) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [resource.id, accountId, t]);
|
||||
}, [launchBody, slot, t]);
|
||||
|
||||
// Submit the launch form exactly once, after it is in the DOM.
|
||||
useEffect(() => {
|
||||
@@ -87,7 +106,7 @@ export function WopiEditor({ resource, accountId, onClose }: WopiEditorProps) {
|
||||
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>
|
||||
<span className="text-sm font-medium text-foreground truncate">{target.name}</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label={t("office_close")}
|
||||
@@ -112,7 +131,7 @@ export function WopiEditor({ resource, accountId, onClose }: WopiEditorProps) {
|
||||
)}
|
||||
<iframe
|
||||
name="wopi-editor-frame"
|
||||
title={resource.name}
|
||||
title={target.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"
|
||||
|
||||
@@ -85,6 +85,8 @@ import { useProTabStore } from "@/stores/pro-tab-store";
|
||||
import { useProMultiAccountMailboxes } from "@/hooks/use-pro-multi-account-mailboxes";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { WopiEditor } from "@/components/files/wopi-editor";
|
||||
import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
import { appendHtmlSignature, appendPlainTextSignature } from "@/lib/signature-utils";
|
||||
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||
@@ -169,6 +171,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
|
||||
const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(null);
|
||||
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string; accountId?: string; clientAccountId?: string } | null>(null);
|
||||
// Office attachments open read-only in the configured WOPI editor (#1047).
|
||||
const [officeAttachment, setOfficeAttachment] = useState<{ blobId: string; name: string; type?: string; size?: number; accountId: string; slot: number | null } | null>(null);
|
||||
const wopiStatus = useWopiStatus(true);
|
||||
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null);
|
||||
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
|
||||
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -3036,6 +3041,24 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
return { blobClient, accountId, clientAccountId };
|
||||
}, [isUnifiedView, client]);
|
||||
|
||||
// Opens an attachment the built-in preview cannot render but the office
|
||||
// editor can (docx, odt, xlsx, ...). The launch route runs server-side with
|
||||
// one login's cookie, so it needs that login's slot and an explicit
|
||||
// accountId (the client's default one when the message is not delegated).
|
||||
const openOfficeAttachment = useCallback((
|
||||
blobClient: NonNullable<typeof client>,
|
||||
attachment: { blobId: string; name: string; type?: string; size?: number },
|
||||
accountId: string | undefined,
|
||||
clientAccountId: string | undefined,
|
||||
): boolean => {
|
||||
if (isFilePreviewable(attachment.name, attachment.type) || !canWopiOpen(wopiStatus, attachment.name)) return false;
|
||||
const slot = clientAccountId
|
||||
? (useAccountStore.getState().getAccountById(clientAccountId)?.cookieSlot ?? null)
|
||||
: null;
|
||||
setOfficeAttachment({ ...attachment, accountId: accountId || blobClient.getAccountId(), slot });
|
||||
return true;
|
||||
}, [wopiStatus]);
|
||||
|
||||
// Opening an attachment straight from a list row. Deliberately resolves
|
||||
// the blob source from that row's own email rather than the selected one:
|
||||
// in the unified inbox each row can belong to a different account, and
|
||||
@@ -3050,11 +3073,14 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
setPreviewAttachment({ blobId: attachment.blobId, name, type: attachment.type, accountId, clientAccountId });
|
||||
return;
|
||||
}
|
||||
if (mailAttachmentAction === 'preview' && openOfficeAttachment(
|
||||
blobClient, { blobId: attachment.blobId, name, type: attachment.type, size: attachment.size }, accountId, clientAccountId,
|
||||
)) return;
|
||||
await blobClient.downloadBlob(attachment.blobId, name, attachment.type, accountId);
|
||||
} catch (error) {
|
||||
console.error("Failed to open attachment from the list:", error);
|
||||
}
|
||||
}, [resolveBlobSource]);
|
||||
}, [resolveBlobSource, openOfficeAttachment]);
|
||||
|
||||
// Where a list row's attachment parts are fetched from: the same routing
|
||||
// the viewer uses to open that row (source stamps first, then the account
|
||||
@@ -3100,6 +3126,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
setPreviewAttachment({ blobId, name, type, accountId, clientAccountId });
|
||||
return;
|
||||
}
|
||||
if (!forceDownload && mailAttachmentAction === 'preview' && openOfficeAttachment(
|
||||
blobClient, { blobId, name, type }, accountId, clientAccountId,
|
||||
)) return;
|
||||
|
||||
await blobClient.downloadBlob(blobId, name, type, accountId);
|
||||
} catch (error) {
|
||||
@@ -4453,6 +4482,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{officeAttachment && (
|
||||
<WopiEditor
|
||||
target={{ kind: "attachment", blobId: officeAttachment.blobId, name: officeAttachment.name, type: officeAttachment.type, size: officeAttachment.size }}
|
||||
accountId={officeAttachment.accountId}
|
||||
slot={officeAttachment.slot}
|
||||
onClose={() => setOfficeAttachment(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Screen reader live region for dynamic status announcements */}
|
||||
<div className="sr-only" aria-live="polite" aria-atomic="true" id="sr-status" />
|
||||
|
||||
|
||||
@@ -59,3 +59,10 @@ export function fileExtension(name: string): string {
|
||||
const parts = name.toLowerCase().split(".");
|
||||
return parts.length > 1 ? parts.pop() || "" : "";
|
||||
}
|
||||
|
||||
/** Whether the configured editor can open (edit or view) a file of this name. */
|
||||
export function canWopiOpen(status: WopiStatus | null, name?: string | null): boolean {
|
||||
if (!status?.enabled || !name) return false;
|
||||
const ext = fileExtension(name);
|
||||
return !!ext && (status.editExtensions.includes(ext) || status.viewExtensions.includes(ext));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
|
||||
// #1047: mail attachments open read-only in the WOPI editor. The launch route
|
||||
// mints a blob-scoped token; CheckFileInfo/GetFile serve the attachment blob
|
||||
// and PutFile refuses to write anything back.
|
||||
|
||||
vi.mock('@/lib/auth/session-secret', () => ({
|
||||
getSessionSecret: () => 'x'.repeat(32),
|
||||
hasSessionSecret: () => true,
|
||||
}));
|
||||
vi.mock('@/lib/logger', () => ({
|
||||
logger: { warn: () => {}, error: () => {}, info: () => {}, debug: () => {} },
|
||||
}));
|
||||
vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: vi.fn() }));
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: { get: (_key: string, fallback: unknown) => fallback, ensureLoaded: async () => {} },
|
||||
}));
|
||||
vi.mock('@/lib/wopi/discovery', async (importActual) => ({
|
||||
...(await importActual<typeof import('@/lib/wopi/discovery')>()),
|
||||
getWopiActions: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/lib/stalwart/server-fetch', () => ({
|
||||
fetchJmapServer: vi.fn(),
|
||||
isTrustedJmapServerUrl: async () => true,
|
||||
}));
|
||||
vi.mock('@/lib/stalwart/jmap-api', () => ({
|
||||
fetchJmapSession: vi.fn(),
|
||||
postJmap: vi.fn(),
|
||||
rebaseApiUrl: () => null,
|
||||
}));
|
||||
|
||||
import { POST as launch } from '@/app/api/wopi/launch/route';
|
||||
import { GET as checkFileInfo } from '@/app/api/wopi/files/[fileId]/route';
|
||||
import { GET as getFile, POST as putFile } from '@/app/api/wopi/files/[fileId]/contents/route';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { getWopiActions } from '@/lib/wopi/discovery';
|
||||
import { fetchJmapServer } from '@/lib/stalwart/server-fetch';
|
||||
import { fetchJmapSession, postJmap } from '@/lib/stalwart/jmap-api';
|
||||
|
||||
const ORIGIN = 'https://webmail.example.com';
|
||||
const OFFICE = 'https://office.example.com/browser/abc/cool.html?';
|
||||
const DOCX = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||
|
||||
function request(url: string, init: { body?: unknown; bytes?: ArrayBuffer; headers?: Record<string, string> } = {}) {
|
||||
const parsed = new URL(url, ORIGIN);
|
||||
return {
|
||||
nextUrl: parsed,
|
||||
headers: new Headers(init.headers),
|
||||
json: async () => init.body,
|
||||
arrayBuffer: async () => init.bytes ?? new ArrayBuffer(0),
|
||||
} as unknown as Parameters<typeof launch>[0];
|
||||
}
|
||||
|
||||
const params = (id: string) => ({ params: Promise.resolve({ fileId: id }) });
|
||||
|
||||
async function launchAttachment(body: Record<string, unknown>) {
|
||||
const res = await launch(request('/api/wopi/launch', { body }));
|
||||
const data = await res.json();
|
||||
return { status: res.status, data };
|
||||
}
|
||||
|
||||
/** Pull the document id and token the editor would use out of a launch response. */
|
||||
function editorCall(data: { url: string; accessToken: string }) {
|
||||
const wopiSrc = new URL(new URL(data.url).searchParams.get('WOPISrc')!);
|
||||
const documentId = decodeURIComponent(wopiSrc.pathname.split('/').pop()!);
|
||||
const token = `access_token=${encodeURIComponent(data.accessToken)}`;
|
||||
return { documentId, wopiSrc, token };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(getStalwartCredentials as Mock).mockResolvedValue({
|
||||
serverUrl: 'https://mail.example.com',
|
||||
authHeader: 'Basic dXNlcjpwYXNz',
|
||||
username: 'user@example.com',
|
||||
trusted: true,
|
||||
slot: 0,
|
||||
hasSessionCookie: true,
|
||||
});
|
||||
(getWopiActions as Mock).mockResolvedValue({ edit: { docx: OFFICE, odt: OFFICE }, view: {} });
|
||||
(fetchJmapSession as Mock).mockResolvedValue({
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'c', 'urn:ietf:params:jmap:filenode': 'f' },
|
||||
accounts: { c: {} },
|
||||
downloadUrl: 'https://public.example.com/jmap/download/{accountId}/{blobId}/{name}?accept={type}',
|
||||
});
|
||||
(fetchJmapServer as Mock).mockImplementation(async () =>
|
||||
new Response('DOCX-BYTES', { status: 200, headers: { 'Content-Length': '10' } }),
|
||||
);
|
||||
});
|
||||
|
||||
describe('WOPI attachment launch', () => {
|
||||
it('mints a read-only token for the attachment blob in the primary mail account', async () => {
|
||||
const { status, data } = await launchAttachment({ blobId: 'Gblob1', name: 'Quote.docx', type: DOCX });
|
||||
expect(status).toBe(200);
|
||||
expect(data.readOnly).toBe(true);
|
||||
expect(data.url.startsWith(OFFICE)).toBe(true);
|
||||
|
||||
// The blob was probed in the mail account, not the files account.
|
||||
const probeUrl = (fetchJmapServer as Mock).mock.calls[0][0] as string;
|
||||
expect(probeUrl).toBe(`https://mail.example.com/jmap/download/c/Gblob1/Quote.docx?accept=${encodeURIComponent(DOCX)}`);
|
||||
// Attachments never touch FileNode.
|
||||
expect(postJmap).not.toHaveBeenCalled();
|
||||
|
||||
const { documentId, wopiSrc } = editorCall(data);
|
||||
expect(wopiSrc.origin).toBe(ORIGIN);
|
||||
// Not the raw blob id: sessions are keyed by WOPISrc.
|
||||
expect(documentId).not.toBe('Gblob1');
|
||||
});
|
||||
|
||||
it('uses the accountId the client sends (shared / unified-inbox messages)', async () => {
|
||||
await launchAttachment({ blobId: 'Gblob1', name: 'Quote.docx', accountId: 'shared1' });
|
||||
expect((fetchJmapServer as Mock).mock.calls[0][0]).toContain('/jmap/download/shared1/Gblob1/');
|
||||
});
|
||||
|
||||
it('404s when the blob cannot be downloaded', async () => {
|
||||
(fetchJmapServer as Mock).mockResolvedValue(new Response('nope', { status: 404 }));
|
||||
const { status } = await launchAttachment({ blobId: 'Gmissing', name: 'Quote.docx' });
|
||||
expect(status).toBe(404);
|
||||
});
|
||||
|
||||
it('415s for types the editor does not handle, and 400s without a name', async () => {
|
||||
expect((await launchAttachment({ blobId: 'Gblob1', name: 'archive.zip' })).status).toBe(415);
|
||||
expect((await launchAttachment({ blobId: 'Gblob1' })).status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WOPI calls for an attachment token', () => {
|
||||
async function launched() {
|
||||
const { data } = await launchAttachment({ blobId: 'Gblob1', name: 'Quote.docx', type: DOCX, size: 999 });
|
||||
(fetchJmapServer as Mock).mockClear();
|
||||
return editorCall(data);
|
||||
}
|
||||
|
||||
it('CheckFileInfo describes the attachment as read-only, with the probed size', async () => {
|
||||
const { documentId, token } = await launched();
|
||||
const res = await checkFileInfo(request(`/api/wopi/files/${documentId}?${token}`), params(documentId));
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toMatchObject({
|
||||
BaseFileName: 'Quote.docx',
|
||||
Size: 10,
|
||||
UserCanWrite: false,
|
||||
ReadOnly: true,
|
||||
UserCanNotWriteRelative: true,
|
||||
Version: 'Gblob1',
|
||||
PostMessageOrigin: ORIGIN,
|
||||
});
|
||||
expect(postJmap).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('GetFile streams the attachment blob', async () => {
|
||||
const { documentId, token } = await launched();
|
||||
const res = await getFile(request(`/api/wopi/files/${documentId}/contents?${token}`), params(documentId));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('Content-Type')).toBe(DOCX);
|
||||
expect(await res.text()).toBe('DOCX-BYTES');
|
||||
expect((fetchJmapServer as Mock).mock.calls[0][0]).toContain('/jmap/download/c/Gblob1/Quote.docx');
|
||||
});
|
||||
|
||||
it('PutFile is refused', async () => {
|
||||
const { documentId, token } = await launched();
|
||||
const res = await putFile(
|
||||
request(`/api/wopi/files/${documentId}/contents?${token}`, { bytes: new TextEncoder().encode('x').buffer as ArrayBuffer }),
|
||||
params(documentId),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
expect(fetchJmapServer).not.toHaveBeenCalled();
|
||||
expect(postJmap).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('the token does not work under the raw blob id or another document id', async () => {
|
||||
const { token } = await launched();
|
||||
for (const id of ['Gblob1', 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA']) {
|
||||
const res = await checkFileInfo(request(`/api/wopi/files/${id}?${token}`), params(id));
|
||||
expect(res.status).toBe(401);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mintWopiToken, verifyWopiToken } from '@/lib/wopi/token';
|
||||
import { mintWopiToken, verifyWopiToken, wopiDocumentId } from '@/lib/wopi/token';
|
||||
import { encryptPayload } from '@/lib/auth/crypto';
|
||||
import {
|
||||
readStalwartAuthContextFromStore,
|
||||
@@ -41,13 +41,14 @@ function cookieStore(initial: Record<string, string> = {}): CookieStore {
|
||||
}
|
||||
|
||||
describe('editor token presented as a session cookie', () => {
|
||||
const { token } = mintWopiToken({
|
||||
const scope = {
|
||||
...CREDENTIALS,
|
||||
accountId: 'c',
|
||||
fileId: 'd',
|
||||
canWrite: true,
|
||||
origin: 'https://mail.example.com',
|
||||
});
|
||||
};
|
||||
const { token } = mintWopiToken(scope);
|
||||
|
||||
it('is rejected by the session context reader', () => {
|
||||
const store = cookieStore({ [stalwartAuthContextCookieName(0)]: token });
|
||||
@@ -63,8 +64,8 @@ describe('editor token presented as a session cookie', () => {
|
||||
});
|
||||
|
||||
it('still works for the file it was scoped to', () => {
|
||||
expect(verifyWopiToken(token, 'd')).toMatchObject({ fileId: 'd', username: CREDENTIALS.username });
|
||||
expect(verifyWopiToken(token, 'other')).toBeNull();
|
||||
expect(verifyWopiToken(token, wopiDocumentId(scope))).toMatchObject({ fileId: 'd', username: CREDENTIALS.username });
|
||||
expect(verifyWopiToken(token, wopiDocumentId({ ...scope, fileId: 'other' }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { parseWopiDiscovery, buildWopiActionUrl } from '@/lib/wopi/discovery';
|
||||
import { mintWopiToken, verifyWopiToken } from '@/lib/wopi/token';
|
||||
import { mintWopiToken, verifyWopiToken, wopiDocumentId } 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.
|
||||
@@ -82,25 +82,64 @@ describe('mintWopiToken / verifyWopiToken', () => {
|
||||
origin: 'https://webmail.example.com',
|
||||
};
|
||||
|
||||
it('round-trips and binds to the fileId', () => {
|
||||
const docId = wopiDocumentId(payload);
|
||||
|
||||
it('round-trips and binds to the document id', () => {
|
||||
const { token, expiresAt } = mintWopiToken(payload);
|
||||
expect(expiresAt).toBeGreaterThan(Date.now());
|
||||
const verified = verifyWopiToken(token, 'f42');
|
||||
const verified = verifyWopiToken(token, docId);
|
||||
expect(verified).toMatchObject(payload);
|
||||
// A token for one file must not authorize another.
|
||||
expect(verifyWopiToken(token, 'other-file')).toBeNull();
|
||||
expect(verifyWopiToken(token, wopiDocumentId({ ...payload, fileId: 'other-file' }))).toBeNull();
|
||||
// Nor is the raw FileNode id accepted in the URL any more.
|
||||
expect(verifyWopiToken(token, 'f42')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects garbage, empty and expired tokens', () => {
|
||||
expect(verifyWopiToken(null, 'f42')).toBeNull();
|
||||
expect(verifyWopiToken('not-a-token', 'f42')).toBeNull();
|
||||
expect(verifyWopiToken(null, docId)).toBeNull();
|
||||
expect(verifyWopiToken('not-a-token', docId)).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();
|
||||
expect(verifyWopiToken(token, docId)).toBeNull();
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it('carries read-only attachment metadata (#1047)', () => {
|
||||
const attachment = {
|
||||
...payload,
|
||||
kind: 'attachment' as const,
|
||||
fileId: 'Gblob123',
|
||||
name: 'Quote.docx',
|
||||
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
size: 12345,
|
||||
canWrite: false,
|
||||
};
|
||||
const { token } = mintWopiToken(attachment);
|
||||
expect(verifyWopiToken(token, wopiDocumentId(attachment))).toMatchObject(attachment);
|
||||
// A blob id that happens to equal a FileNode id is a different document.
|
||||
expect(wopiDocumentId(attachment)).not.toBe(wopiDocumentId({ ...payload, fileId: 'Gblob123' }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('wopiDocumentId', () => {
|
||||
const base = { serverUrl: 'https://mail.example.com', accountId: 'c', fileId: 'b' };
|
||||
|
||||
// WOPI clients key open sessions by WOPISrc: two accounts' node "b" must
|
||||
// not share one, or the second user is served the first user's document.
|
||||
it('differs per account and per server for the same node id', () => {
|
||||
const id = wopiDocumentId(base);
|
||||
expect(wopiDocumentId({ ...base, accountId: 'd' })).not.toBe(id);
|
||||
expect(wopiDocumentId({ ...base, serverUrl: 'https://other.example.com' })).not.toBe(id);
|
||||
});
|
||||
|
||||
it('is stable, URL-safe and treats a missing kind as a file', () => {
|
||||
const id = wopiDocumentId(base);
|
||||
expect(wopiDocumentId({ ...base })).toBe(id);
|
||||
expect(wopiDocumentId({ ...base, kind: 'file' })).toBe(id);
|
||||
expect(id).toMatch(/^[A-Za-z0-9_-]{32}$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -135,6 +135,25 @@ export async function downloadFileBlob(
|
||||
}, ctx.trusted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that a mail attachment blob is readable with these credentials and
|
||||
* learn its size (#1047). Only the response headers are read; the body is
|
||||
* cancelled. Returns null when the blob cannot be downloaded.
|
||||
*/
|
||||
export async function probeBlob(
|
||||
ctx: WopiJmapContext,
|
||||
accountId: string,
|
||||
blobId: string,
|
||||
name: string,
|
||||
type: string,
|
||||
): Promise<{ size: number | null } | null> {
|
||||
const response = await downloadFileBlob(ctx, accountId, blobId, name, type);
|
||||
await response.body?.cancel().catch(() => {});
|
||||
if (!response.ok) return null;
|
||||
const length = Number(response.headers.get('Content-Length'));
|
||||
return { size: Number.isFinite(length) && length >= 0 && response.headers.has('Content-Length') ? length : null };
|
||||
}
|
||||
|
||||
/** Upload new content as a blob (first half of WOPI PutFile). */
|
||||
export async function uploadFileBlob(
|
||||
ctx: WopiJmapContext,
|
||||
|
||||
+3
-3
@@ -6,13 +6,13 @@ 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.
|
||||
* parameter, scoped to the document id in the URL (see wopiDocumentId).
|
||||
*/
|
||||
export async function wopiContext(
|
||||
request: NextRequest,
|
||||
fileId: string,
|
||||
documentId: string,
|
||||
): Promise<{ payload: WopiTokenPayload; ctx: WopiJmapContext } | null> {
|
||||
const payload = verifyWopiToken(request.nextUrl.searchParams.get('access_token'), fileId);
|
||||
const payload = verifyWopiToken(request.nextUrl.searchParams.get('access_token'), documentId);
|
||||
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
|
||||
|
||||
+40
-7
@@ -1,3 +1,4 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { encryptPayload, decryptPayload } from '@/lib/auth/crypto';
|
||||
|
||||
/**
|
||||
@@ -7,17 +8,30 @@ import { encryptPayload, decryptPayload } from '@/lib/auth/crypto';
|
||||
* 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.
|
||||
* credentials plus the one document it is scoped to: a Files node, or a mail
|
||||
* attachment blob opened read-only (#1047). The editor can therefore only
|
||||
* reach the /api/wopi/files/<documentId> surface for that document, never
|
||||
* the JMAP server itself.
|
||||
*/
|
||||
|
||||
export type WopiDocumentKind = 'file' | 'attachment';
|
||||
|
||||
export interface WopiTokenPayload {
|
||||
serverUrl: string;
|
||||
authHeader: string;
|
||||
username: string;
|
||||
accountId: string;
|
||||
/** FileNode id for 'file', blobId for 'attachment'. */
|
||||
fileId: string;
|
||||
/** Absent on tokens minted before attachments existed - those are files. */
|
||||
kind?: WopiDocumentKind;
|
||||
/**
|
||||
* Attachment metadata. A blob has no name/type/size of its own, so the
|
||||
* launch route records them for CheckFileInfo/GetFile.
|
||||
*/
|
||||
name?: string;
|
||||
type?: string;
|
||||
size?: number;
|
||||
canWrite: boolean;
|
||||
/** Browser origin that embeds the editor iframe (WOPI PostMessageOrigin). */
|
||||
origin: string;
|
||||
@@ -41,6 +55,23 @@ function fromBase64Url(token: string): string {
|
||||
return token.replace(/ /g, '+').replace(/-/g, '+').replace(/_/g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* The id in the WOPISrc URL (/api/wopi/files/<id>). WOPI clients key their
|
||||
* open-document sessions by WOPISrc, so the raw FileNode id or blobId is not
|
||||
* enough: ids like "b" repeat in every account, and a second user opening
|
||||
* their own "b" would be joined into the first user's session and served
|
||||
* that document. Hashing in the server and account keeps the id unique per
|
||||
* document while two people on a shared account still co-edit one session.
|
||||
*/
|
||||
export function wopiDocumentId(
|
||||
p: Pick<WopiTokenPayload, 'serverUrl' | 'accountId' | 'fileId' | 'kind'>,
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update([p.kind ?? 'file', p.serverUrl, p.accountId, p.fileId].join('\n'))
|
||||
.digest('base64url')
|
||||
.slice(0, 32);
|
||||
}
|
||||
|
||||
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 }, 'wopi-token'));
|
||||
@@ -48,10 +79,11 @@ export function mintWopiToken(payload: Omit<WopiTokenPayload, 'exp'>): { token:
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Decrypt and validate a WOPI access token. `documentId` is the id from the
|
||||
* request URL; it must be the token's own wopiDocumentId so a token for one
|
||||
* document cannot address another.
|
||||
*/
|
||||
export function verifyWopiToken(token: string | null, fileId: string): WopiTokenPayload | null {
|
||||
export function verifyWopiToken(token: string | null, documentId: string): WopiTokenPayload | null {
|
||||
if (!token) return null;
|
||||
const raw = decryptPayload(fromBase64Url(token), 'wopi-token');
|
||||
if (!raw || raw.v !== 1 || raw.t !== 'wopi') return null;
|
||||
@@ -63,7 +95,8 @@ export function verifyWopiToken(token: string | null, fileId: string): WopiToken
|
||||
typeof p.fileId !== 'string' || !p.fileId ||
|
||||
typeof p.exp !== 'number'
|
||||
) return null;
|
||||
if (p.fileId !== fileId) return null;
|
||||
if (p.kind !== undefined && p.kind !== 'file' && p.kind !== 'attachment') return null;
|
||||
if (wopiDocumentId(p) !== documentId) return null;
|
||||
if (Date.now() > p.exp) return null;
|
||||
return p;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user