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();