feat: turn printing and export off in the office editor under the device policies

This commit is contained in:
Linus Rath
2026-11-01 08:40:03 +01:00
parent adbec718ef
commit 140db2cf8f
2 changed files with 116 additions and 0 deletions
+11
View File
@@ -3,6 +3,7 @@ import { logger } from '@/lib/logger';
import { decodeFileNodeName } from '@/lib/jmap/filenode-name';
import { getWopiFileNode } from '@/lib/wopi/files';
import { wopiContext } from '@/lib/wopi/request';
import { serverDevicePolicy, wopiDeviceInfo } from '@/lib/admin/server-device-policy';
/**
* WOPI CheckFileInfo + file-level operations (#425).
@@ -33,6 +34,14 @@ export async function GET(
}
const { payload } = auth;
// Organisation > Devices, enforced by the editor itself: no print, no
// export or save-a-copy, the watermark. Resolved for this login on the server.
const devices = await serverDevicePolicy({
serverUrl: payload.serverUrl,
authHeader: payload.authHeader,
username: payload.username,
trusted: auth.ctx.trusted,
}).then((policy) => wopiDeviceInfo(policy, payload.username), () => ({}));
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.
@@ -50,6 +59,7 @@ export async function GET(
...VIEW_ONLY_INFO,
Version: payload.fileId,
PostMessageOrigin: payload.postMessageOrigin || payload.origin,
...devices,
});
}
@@ -72,6 +82,7 @@ export async function GET(
LastModifiedTime: node.modified,
Version: node.blobId,
PostMessageOrigin: auth.payload.postMessageOrigin || auth.payload.origin,
...devices,
});
} catch (error) {
logger.error('WOPI CheckFileInfo failed', {
+105
View File
@@ -0,0 +1,105 @@
// @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';
// Organisation > Devices, held by the office editor: CheckFileInfo tells
// Collabora and ONLYOFFICE to turn print and export off and to show the
// watermark, for the login's own scopes.
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() }));
const jar = new Map<string, string>();
vi.mock('next/headers', () => ({
cookies: async () => ({
get: (name: string) => (jar.has(name) ? { name, value: jar.get(name)! } : undefined),
set: (name: string, value: string) => void jar.set(name, value),
delete: (name: string) => void jar.delete(name),
}),
}));
const policy = vi.hoisted(() => ({ current: {} as Record<string, unknown> }));
vi.mock('@/lib/admin/config-manager', () => ({
configManager: { get: (_key: string, fallback: unknown) => fallback, ensureLoaded: async () => {}, getPolicy: () => policy.current },
}));
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 { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { getWopiActions } from '@/lib/wopi/discovery';
import { fetchJmapServer } from '@/lib/stalwart/server-fetch';
import { fetchJmapSession } from '@/lib/stalwart/jmap-api';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { clearServerDeviceSubjects, wopiDeviceInfo } from '@/lib/admin/server-device-policy';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
process.env.ADMIN_STATE_DIR = mkdtempSync(path.join(tmpdir(), 'bw-wopi-dev-'));
const ORIGIN = 'https://webmail.example.com';
const OFFICE = 'https://office.example.com/browser/abc/cool.html?';
const DOCX = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
function request(url: string, init: { body?: unknown } = {}) {
return { nextUrl: new URL(url, ORIGIN), headers: new Headers(), json: async () => init.body, arrayBuffer: async () => new ArrayBuffer(0) } as unknown as Parameters<typeof launch>[0];
}
const params = (id: string) => ({ params: Promise.resolve({ fileId: id }) });
async function fileInfo(): Promise<Record<string, unknown>> {
const launched = await (await launch(request('/api/wopi/launch', { body: { blobId: 'Gblob1', name: 'Quote.docx', type: DOCX } }))).json();
const wopiSrc = new URL(new URL(launched.url).searchParams.get('WOPISrc')!);
const documentId = decodeURIComponent(wopiSrc.pathname.split('/').pop()!);
const res = await checkFileInfo(request(`/api/wopi/files/${documentId}?access_token=${encodeURIComponent(launched.accessToken)}`), params(documentId));
expect(res.status).toBe(200);
return res.json();
}
beforeEach(() => {
vi.clearAllMocks();
clearServerDeviceSubjects();
(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': 'c' },
accounts: { c: { name: 'user@example.com', isPersonal: true }, g: { name: 'sales@example.com', isPersonal: false } },
downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}?accept={type}',
});
(fetchJmapServer as Mock).mockImplementation(async () => new Response('DOCX', { status: 200, headers: { 'Content-Length': '4' } }));
});
describe('office editor under the device policies', () => {
it('says nothing extra while the policies are off', async () => {
policy.current = { ...DEFAULT_POLICY };
const info = await fileInfo();
expect(info.DisablePrint).toBeUndefined();
expect(info.DisableExport).toBeUndefined();
expect(info.WatermarkText).toBeUndefined();
});
it('turns print and export off and adds the watermark for a group scope from the session', async () => {
policy.current = {
...DEFAULT_POLICY,
scopes: [{ id: 's', label: 'Sales', match: { groups: ['sales@example.com'] }, policy: { organisation: { devices: { noPrint: true, noDownloads: true, watermark: true } } } }],
};
const info = await fileInfo();
expect(info).toMatchObject({ DisablePrint: true, HidePrintOption: true, DisableExport: true, HideExportOption: true, HideSaveOption: true });
expect(info.WatermarkText).toMatch(/^user@example\.com · \d{4}-\d{2}-\d{2}$/);
});
});
describe('wopiDeviceInfo', () => {
it('maps each switch', () => {
const off = DEFAULT_ORGANISATION_POLICY.devices;
expect(wopiDeviceInfo(off, 'a@x')).toEqual({});
expect(wopiDeviceInfo({ ...off, noExport: true }, 'a@x')).toMatchObject({ DisableExport: true, HideSaveOption: true });
expect(wopiDeviceInfo({ ...off, noPrint: true }, 'a@x')).toEqual({ DisablePrint: true, HidePrintOption: true });
});
});