fix: open and save files shared with you in the office editor #1094

This commit is contained in:
Linus Rath
2026-09-26 22:28:34 +02:00
parent e2a4145939
commit 5ce8c89c51
7 changed files with 242 additions and 17 deletions
@@ -6,8 +6,8 @@ import { wopiContext } from '@/lib/wopi/request';
/**
* WOPI GetFile / PutFile (#425). Content flows purely over JMAP: GetFile
* 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 }`.
* itself - #1047), PutFile uploads the editor's bytes as a new blob into the
* user's own account and points the FileNode at it via `FileNode/set { blobId }`.
*/
/**
@@ -115,7 +115,9 @@ export async function POST(
return NextResponse.json({ error: 'Empty document rejected' }, { status: 409 });
}
const blob = await uploadFileBlob(auth.ctx, auth.payload.accountId, body, node.type);
const blob = await uploadFileBlob(
auth.ctx, auth.payload.uploadAccountId || auth.payload.accountId, body, node.type,
);
const { modified } = await updateFileNodeBlob(
auth.ctx, auth.payload.accountId, fileId, blob.blobId,
);
+11 -8
View File
@@ -58,19 +58,22 @@ export async function POST(request: NextRequest) {
const ctx = { serverUrl, authHeader: creds.authHeader, trusted };
let accountId = str('accountId');
const capability = blobId ? 'urn:ietf:params:jmap:mail' : 'urn:ietf:params:jmap:filenode';
// Files need the session even with an explicit account: PutFile uploads
// into the login's own account, which is not the one holding a node
// shared with the user (#1094).
const session = !accountId || !blobId
? await fetchJmapSession(serverUrl, creds.authHeader, { trusted })
: null;
const ownAccountId = session?.primaryAccounts?.[capability] || '';
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?.[capability] ||
Object.keys(session?.accounts ?? {})[0] ||
'';
accountId = ownAccountId || Object.keys(session?.accounts ?? {})[0] || '';
}
if (!accountId) {
return NextResponse.json({ error: blobId ? 'No mail account' : 'No files account' }, { status: 404 });
}
let document: Pick<WopiTokenPayload, 'kind' | 'fileId' | 'name' | 'type' | 'size'>;
let document: Pick<WopiTokenPayload, 'kind' | 'fileId' | 'name' | 'type' | 'size' | 'uploadAccountId'>;
let name: string;
let canWrite: boolean;
if (blobId) {
@@ -92,7 +95,7 @@ export async function POST(request: NextRequest) {
}
name = node.name;
canWrite = node.myRights ? !!node.myRights.mayModifyContent : true;
document = { kind: 'file', fileId };
document = { kind: 'file', fileId, uploadAccountId: ownAccountId || accountId };
}
const ext = name.split('.').pop()?.toLowerCase() || '';
+8 -5
View File
@@ -10,7 +10,7 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store';
import { useAccountStore } from "@/stores/account-store";
import { useEmailStore } from "@/stores/email-store";
import { useFileStore } from "@/stores/file-store";
import { useFileStore, resourceServerRef } from "@/stores/file-store";
import { toast } from "@/stores/toast-store";
import { cn, formatFileSize } from "@/lib/utils";
import { NavigationRail } from "@/components/layout/navigation-rail";
@@ -721,17 +721,20 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
{/* WOPI document editor overlay (#425) */}
{editFile && (() => {
const editResource = resources.find(r => r.name === editFile);
return editResource ? (
if (!editResource) return null;
// A file shared with the user lives in the owner's account (#1094).
const node = resourceServerRef(editResource);
return (
<WopiEditor
target={{ kind: "file", id: editResource.id, name: editResource.name }}
accountId={filesAccountId}
target={{ kind: "file", id: node.id, name: editResource.name }}
accountId={node.accountId ?? filesAccountId}
onClose={() => {
setEditFile(null);
// The editor may have saved new content - pick up size/mtime.
refresh();
}}
/>
) : null;
);
})()}
{/* Legacy file migration progress (issue #379) */}
@@ -0,0 +1,180 @@
// @vitest-environment node
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
// #1094: a Files node shared with the user lives in the owner's account.
// The editor must address it there, and PutFile must upload the new content
// into the user's own account - Stalwart refuses a blob the user uploaded
// into the owner's account ("You do not have access to blobId").
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('next/headers', () => ({
cookies: async () => ({ get: () => undefined, set: () => {}, delete: () => {} }),
}));
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';
process.env.ADMIN_STATE_DIR = mkdtempSync(path.join(tmpdir(), 'bw-wopi-shared-'));
const ORIGIN = 'https://webmail.example.com';
const OFFICE = 'https://office.example.com/browser/abc/cool.html?';
const DOCX = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
const OWN = 'c';
const OWNER = 'd';
function request(url: string, init: { body?: unknown; bytes?: ArrayBuffer } = {}) {
return {
nextUrl: new URL(url, ORIGIN),
headers: new 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 }) });
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()!);
return { documentId, token: `access_token=${encodeURIComponent(data.accessToken)}` };
}
// The shared node as Stalwart 0.16.23 reports it to the user it is shared with.
let node: { id: string; name: string; type: string; blobId: string; size: number; modified: string; myRights: Record<string, boolean> };
let setCalls: Array<{ accountId: string; update: Record<string, { blobId: string }> }>;
beforeEach(() => {
vi.clearAllMocks();
node = {
id: 'b', name: 'new-file.docx', type: DOCX, blobId: 'Gshared', size: 10, modified: '2026-09-26T20:00:00Z',
myRights: { mayRead: true, mayRename: true, mayModifyContent: true },
};
setCalls = [];
(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 }, view: {} });
(fetchJmapSession as Mock).mockResolvedValue({
primaryAccounts: { 'urn:ietf:params:jmap:mail': OWN, 'urn:ietf:params:jmap:filenode': OWN },
accounts: { [OWN]: { isPersonal: true }, [OWNER]: { isPersonal: false } },
});
(postJmap as Mock).mockImplementation(async (_url: string, _auth: string, body: string) => {
const { methodCalls } = JSON.parse(body) as { methodCalls: [string, Record<string, unknown>, string][] };
const methodResponses = methodCalls.map(([method, args, callId]) => {
const inOwner = args.accountId === OWNER;
if (method === 'FileNode/get') {
const ids = args.ids as string[];
const found = inOwner && ids.includes(node.id);
return [method, { accountId: args.accountId, list: found ? [node] : [], notFound: found ? [] : ids }, callId];
}
// FileNode/set
const update = args.update as Record<string, { blobId: string }>;
setCalls.push({ accountId: args.accountId as string, update });
const patch = update[node.id];
if (!inOwner || !patch) return [method, { notUpdated: { [Object.keys(update)[0]]: { type: 'notFound' } } }, callId];
if (!patch.blobId.startsWith(`upload-${OWN}-`)) {
return [method, { notUpdated: { [node.id]: { type: 'forbidden', description: `You do not have access to blobId ${patch.blobId}.` } } }, callId];
}
node = { ...node, blobId: patch.blobId, modified: '2026-09-26T21:00:00Z' };
return [method, { updated: { [node.id]: null } }, callId];
});
return new Response(JSON.stringify({ methodResponses }), { status: 200 });
});
let uploads = 0;
(fetchJmapServer as Mock).mockImplementation(async (url: string, init: { method: string }) => {
const upload = /\/jmap\/upload\/([^/]+)\//.exec(url);
if (init.method === 'POST' && upload) {
return Response.json({ accountId: upload[1], blobId: `upload-${upload[1]}-${++uploads}`, type: DOCX, size: 3 });
}
return new Response('DOCX-BYTES', { status: 200, headers: { 'Content-Length': '10' } });
});
});
async function openShared() {
const res = await launch(request('/api/wopi/launch', { body: { fileId: 'b', accountId: OWNER } }));
expect(res.status).toBe(200);
const data = await res.json();
return { data, ...editorCall(data) };
}
describe('WOPI on a file shared with the user (#1094)', () => {
it('launches editable from the owner account and the bare node id', async () => {
const { data, documentId, token } = await openShared();
expect(data.readOnly).toBe(false);
const info = await checkFileInfo(request(`/api/wopi/files/${documentId}?${token}`), params(documentId));
expect(info.status).toBe(200);
expect(await info.json()).toMatchObject({ BaseFileName: 'new-file.docx', UserCanWrite: true, Version: 'Gshared' });
});
it('GetFile reads the blob through the owner account', async () => {
const { documentId, token } = await openShared();
(fetchJmapServer as Mock).mockClear();
const res = await getFile(request(`/api/wopi/files/${documentId}/contents?${token}`), params(documentId));
expect(res.status).toBe(200);
expect((fetchJmapServer as Mock).mock.calls[0][0]).toContain(`/jmap/download/${OWNER}/Gshared/`);
});
it('PutFile uploads into the own account and updates the node in the owner account', async () => {
const { documentId, token } = await openShared();
(fetchJmapServer as Mock).mockClear();
const res = await putFile(
request(`/api/wopi/files/${documentId}/contents?${token}`, { bytes: new TextEncoder().encode('new').buffer as ArrayBuffer }),
params(documentId),
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ LastModifiedTime: '2026-09-26T21:00:00Z' });
const [uploadUrl, uploadInit] = (fetchJmapServer as Mock).mock.calls[0];
expect(uploadInit.method).toBe('POST');
expect(uploadUrl).toContain(`/jmap/upload/${OWN}/`);
expect(setCalls).toEqual([{ accountId: OWNER, update: { b: { blobId: `upload-${OWN}-1` } } }]);
});
it('shares one editor session with the owner opening the same file', async () => {
const { documentId } = await openShared();
// The owner's own files account is the one the node lives in.
(fetchJmapSession as Mock).mockResolvedValue({
primaryAccounts: { 'urn:ietf:params:jmap:filenode': OWNER },
accounts: { [OWNER]: { isPersonal: true } },
});
const res = await launch(request('/api/wopi/launch', { body: { fileId: 'b' } }));
expect(editorCall(await res.json()).documentId).toBe(documentId);
});
});
+8
View File
@@ -32,6 +32,13 @@ export interface WopiTokenPayload {
name?: string;
type?: string;
size?: number;
/**
* Account PutFile uploads new content into: the login's own. For a node
* shared with the user, `accountId` is the owner's, and Stalwart doesn't
* let the user reference a blob they uploaded there (#1094). Absent on
* older tokens - those upload into `accountId`.
*/
uploadAccountId?: string;
canWrite: boolean;
/** Browser origin that embeds the editor iframe (WOPI PostMessageOrigin). */
origin: string;
@@ -104,6 +111,7 @@ export function verifyWopiToken(token: string | null, documentId: string): WopiT
if (p.kind !== undefined && p.kind !== 'file' && p.kind !== 'attachment') return null;
if (p.bid !== undefined && typeof p.bid !== 'string') return null;
if (p.slot !== undefined && typeof p.slot !== 'number') return null;
if (p.uploadAccountId !== undefined && (typeof p.uploadAccountId !== 'string' || !p.uploadAccountId)) return null;
if (wopiDocumentId(p) !== documentId) return null;
if (Date.now() > p.exp) return null;
return p;
+14 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useFileStore } from '../file-store';
import { useFileStore, resourceServerRef } from '../file-store';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { FileNode } from '@/lib/jmap/types';
@@ -411,3 +411,16 @@ describe('file-store modification date (issue #700)', () => {
expect(useFileStore.getState().resources[0].lastModified).toBe('2025-01-01T00:00:00Z');
});
});
describe('resourceServerRef (issue #1094)', () => {
it('splits a node of a shared account into its owner account and bare id', () => {
// The WOPI editor used to send "d:b" with the user's own account, and
// FileNode/get answered notFound.
expect(resourceServerRef({ id: 'd:b', ownerAccountId: 'd' })).toEqual({ accountId: 'd', id: 'b' });
});
it('keeps own nodes as they are', () => {
expect(resourceServerRef({ id: 'b', ownerAccountId: 'c' })).toEqual({ accountId: 'c', id: 'b' });
expect(resourceServerRef({ id: 'b' })).toEqual({ accountId: undefined, id: 'b' });
});
});
+16
View File
@@ -166,6 +166,22 @@ function isCrossAccountId(id: string | null): boolean {
return id != null && id.includes(':');
}
/**
* The account and bare server id behind a listed resource, for callers that
* address the node on the server themselves (the WOPI editor, #1094). A node
* of a shared account is listed as "accountId:nodeId"; `accountId` is only
* known for nodes listed across accounts.
*/
export function resourceServerRef(
resource: Pick<FileResource, 'id' | 'ownerAccountId'>,
): { accountId?: string; id: string } {
const owner = resource.ownerAccountId;
if (owner && resource.id.startsWith(`${owner}:`)) {
return { accountId: owner, id: resource.id.slice(owner.length + 1) };
}
return { accountId: owner, id: resource.id };
}
function nodeToResource(node: FileNode): FileResource {
const isDir = isFolder(node);
return {