From 7e5b996ee64b19030a3a3e499092e671a5e898bb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:01:47 +0200 Subject: [PATCH] feat: open office attachments in mail read-only in the document editor #1047 --- app/api/wopi/files/[fileId]/contents/route.ts | 33 ++-- app/api/wopi/files/[fileId]/route.ts | 30 ++- app/api/wopi/launch/route.ts | 77 +++++--- components/email/email-viewer.tsx | 23 ++- components/email/thread-conversation-view.tsx | 5 +- components/files/files-app.tsx | 10 +- components/files/wopi-editor.tsx | 37 +++- components/mail/mail-app.tsx | 40 +++- hooks/use-wopi-status.ts | 7 + lib/__tests__/wopi-attachment-routes.test.ts | 178 ++++++++++++++++++ .../wopi-token-not-a-session.test.ts | 11 +- lib/__tests__/wopi.test.ts | 53 +++++- lib/wopi/files.ts | 19 ++ lib/wopi/request.ts | 6 +- lib/wopi/token.ts | 47 ++++- 15 files changed, 489 insertions(+), 87 deletions(-) create mode 100644 lib/__tests__/wopi-attachment-routes.test.ts diff --git a/app/api/wopi/files/[fileId]/contents/route.ts b/app/api/wopi/files/[fileId]/contents/route.ts index af9a29b77..99e0b65ee 100644 --- a/app/api/wopi/files/[fileId]/contents/route.ts +++ b/app/api/wopi/files/[fileId]/contents/route.ts @@ -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 }); diff --git a/app/api/wopi/files/[fileId]/route.ts b/app/api/wopi/files/[fileId]/route.ts index d13c0802e..5cc0f1972 100644 --- a/app/api/wopi/files/[fileId]/route.ts +++ b/app/api/wopi/files/[fileId]/route.ts @@ -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 }); } diff --git a/app/api/wopi/launch/route.ts b/app/api/wopi/launch/route.ts index e84e3dfe6..69e5ec1ae 100644 --- a/app/api/wopi/launch/route.ts +++ b/app/api/wopi/launch/route.ts @@ -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 | 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; + 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('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), diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 0eeeb2122..ceb79774c 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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({
{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({
{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 ( @@ -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({
{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 ( @@ -5051,7 +5062,7 @@ export function EmailViewer({
{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({
{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 ( diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index e56fdf8ce..82b8a49e6 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -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({
{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 (