From a2e36e6e86da2c2fd20286bc80c728a1c06867d1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:32:39 +0200 Subject: [PATCH] fix: open read-only documents in ONLYOFFICE without the -103 warning #1187 --- app/api/wopi/files/[fileId]/route.ts | 10 ++++++++++ lib/__tests__/wopi-attachment-routes.test.ts | 3 +++ lib/__tests__/wopi-shared-file-routes.test.ts | 13 ++++++++++++- lib/__tests__/wopi.test.ts | 17 +++++++++++++++++ lib/wopi/discovery.ts | 13 ++++++++++++- 5 files changed, 54 insertions(+), 2 deletions(-) diff --git a/app/api/wopi/files/[fileId]/route.ts b/app/api/wopi/files/[fileId]/route.ts index c8dae34e7..1cbf28807 100644 --- a/app/api/wopi/files/[fileId]/route.ts +++ b/app/api/wopi/files/[fileId]/route.ts @@ -12,6 +12,14 @@ import { wopiContext } from '@/lib/wopi/request'; * cookie on these requests. */ +/** + * ONLYOFFICE treats a document the user may not edit as forced into view + * mode and warns about it ("Error code: -103" on 9.1), even when the document + * was opened through the view action. UserCanReview keeps it off that path, + * and with SupportsReviewing false it grants nothing (#1187). + */ +const VIEW_ONLY_INFO = { UserCanReview: true, SupportsReviewing: false }; + /** GET = CheckFileInfo */ export async function GET( request: NextRequest, @@ -39,6 +47,7 @@ export async function GET( UserCanNotWriteRelative: true, SupportsUpdate: false, SupportsLocks: false, + ...VIEW_ONLY_INFO, Version: payload.fileId, PostMessageOrigin: payload.postMessageOrigin || payload.origin, }); @@ -59,6 +68,7 @@ export async function GET( UserCanNotWriteRelative: true, SupportsUpdate: true, SupportsLocks: false, + ...(auth.payload.canWrite ? {} : VIEW_ONLY_INFO), LastModifiedTime: node.modified, Version: node.blobId, PostMessageOrigin: auth.payload.postMessageOrigin || auth.payload.origin, diff --git a/lib/__tests__/wopi-attachment-routes.test.ts b/lib/__tests__/wopi-attachment-routes.test.ts index 6c4cf9e36..7dd79b67c 100644 --- a/lib/__tests__/wopi-attachment-routes.test.ts +++ b/lib/__tests__/wopi-attachment-routes.test.ts @@ -158,6 +158,9 @@ describe('WOPI calls for an attachment token', () => { UserCanWrite: false, ReadOnly: true, UserCanNotWriteRelative: true, + // Keeps ONLYOFFICE from warning that it was forced into view mode (#1187). + UserCanReview: true, + SupportsReviewing: false, Version: 'Gblob1', PostMessageOrigin: ORIGIN, }); diff --git a/lib/__tests__/wopi-shared-file-routes.test.ts b/lib/__tests__/wopi-shared-file-routes.test.ts index f7df0d92c..68d9f5212 100644 --- a/lib/__tests__/wopi-shared-file-routes.test.ts +++ b/lib/__tests__/wopi-shared-file-routes.test.ts @@ -140,7 +140,18 @@ describe('WOPI on a file shared with the user (#1094)', () => { 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' }); + const body = await info.json(); + expect(body).toMatchObject({ BaseFileName: 'new-file.docx', UserCanWrite: true, Version: 'Gshared' }); + expect(body).not.toHaveProperty('UserCanReview'); + }); + + it('describes a file shared read-only so ONLYOFFICE opens it without a warning (#1187)', async () => { + node.myRights.mayModifyContent = false; + const { data, documentId, token } = await openShared(); + expect(data.readOnly).toBe(true); + + const info = await checkFileInfo(request(`/api/wopi/files/${documentId}?${token}`), params(documentId)); + expect(await info.json()).toMatchObject({ UserCanWrite: false, UserCanReview: true, SupportsReviewing: false }); }); it('GetFile reads the blob through the owner account', async () => { diff --git a/lib/__tests__/wopi.test.ts b/lib/__tests__/wopi.test.ts index 2c6382abf..357e0f528 100644 --- a/lib/__tests__/wopi.test.ts +++ b/lib/__tests__/wopi.test.ts @@ -38,6 +38,23 @@ describe('parseWopiDiscovery', () => { expect(actions.edit.docx).toContain('/cool.html'); }); + it('unescapes the urlsrc, so ONLYOFFICE placeholders are filled in or dropped (#1187)', () => { + const xml = + '' + + '' + + '' + + ''; + const actions = parseWopiDiscovery(xml); + expect(actions.view.xlsx).toBe( + 'https://oo.example/hosting/wopi/cell/view?&', + ); + expect(buildWopiActionUrl(actions.view.xlsx, 'https://mail.example/api/wopi/files/f1', 'de')).toBe( + 'https://oo.example/hosting/wopi/cell/view?rs=de&ui=de&&WOPISrc=https%3A%2F%2Fmail.example%2Fapi%2Fwopi%2Ffiles%2Ff1', + ); + }); + it('ignores actions without an extension and tolerates junk', () => { expect(parseWopiDiscovery('')).toEqual({ edit: {}, view: {} }); expect(parseWopiDiscovery('')).toEqual({ edit: {}, view: {} }); diff --git a/lib/wopi/discovery.ts b/lib/wopi/discovery.ts index 735127816..d2150567f 100644 --- a/lib/wopi/discovery.ts +++ b/lib/wopi/discovery.ts @@ -68,13 +68,24 @@ function discoveryUrlFor(clientUrl: string): string { } } +const XML_ENTITIES: Record = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" }; + +/** + * Attribute values are XML-escaped: ONLYOFFICE writes its urlsrc placeholders + * as `<ui=UI_LLCC&>`, which buildWopiActionUrl only recognises once + * they read `` again (#1187). + */ +function decodeXmlEntities(value: string): string { + return value.replace(/&(lt|gt|amp|quot|apos);/g, (_match, name: string) => XML_ENTITIES[name]); +} + export function parseWopiDiscovery(xml: string): WopiActions { const actions: WopiActions = { edit: {}, view: {} }; const tags = xml.match(/]*\/?>/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] ?? ''; + return decodeXmlEntities(m?.[1] ?? ''); }; const name = attr('name').toLowerCase(); const ext = attr('ext').toLowerCase();