fix: open read-only documents in ONLYOFFICE without the -103 warning #1187

This commit is contained in:
Linus Rath
2026-10-08 14:32:39 +02:00
parent 3d48b12204
commit a2e36e6e86
5 changed files with 54 additions and 2 deletions
+10
View File
@@ -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,
@@ -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,
});
+12 -1
View File
@@ -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 () => {
+17
View File
@@ -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 =
'<wopi-discovery><net-zone name="external-http"><app name="Excel">' +
'<action name="view" ext="xlsx" urlsrc="https://oo.example/hosting/wopi/cell/view?' +
'&lt;rs=DC_LLCC&amp;&gt;&lt;dchat=DISABLE_CHAT&amp;&gt;&lt;ui=UI_LLCC&amp;&gt;&lt;wopisrc=WOPI_SOURCE&amp;&gt;&amp;"/>' +
'<action name="edit" ext="xlsx" default="true" requires="locks,update" urlsrc="https://oo.example/hosting/wopi/cell/edit?' +
'&lt;ui=UI_LLCC&amp;&gt;&amp;"/>' +
'</app></net-zone></wopi-discovery>';
const actions = parseWopiDiscovery(xml);
expect(actions.view.xlsx).toBe(
'https://oo.example/hosting/wopi/cell/view?<rs=DC_LLCC&><dchat=DISABLE_CHAT&><ui=UI_LLCC&><wopisrc=WOPI_SOURCE&>&',
);
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('<notxml>')).toEqual({ edit: {}, view: {} });
expect(parseWopiDiscovery('')).toEqual({ edit: {}, view: {} });
+12 -1
View File
@@ -68,13 +68,24 @@ function discoveryUrlFor(clientUrl: string): string {
}
}
const XML_ENTITIES: Record<string, string> = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" };
/**
* Attribute values are XML-escaped: ONLYOFFICE writes its urlsrc placeholders
* as `&lt;ui=UI_LLCC&amp;&gt;`, which buildWopiActionUrl only recognises once
* they read `<ui=UI_LLCC&>` 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(/<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] ?? '';
return decodeXmlEntities(m?.[1] ?? '');
};
const name = attr('name').toLowerCase();
const ext = attr('ext').toLowerCase();