An instance had no way to look like itself: the top bar said "Dorfteich" whatever the operator called their instance, `instance.name` was never rendered in the running app at all, and there was no favicon anywhere — `index.html` had no `<link rel="icon">` and `public/` held only fonts and theme-init.js. Where the line is drawn, and why: - **The api never decodes an image.** Cropping, scaling and the conversion to PNG happen on a canvas in the browser; the api checks the PNG signature, reads the IHDR dimensions at their fixed offsets and enforces the caps. An image library would put a decoder in front of attacker-supplied bytes AND would have to be carried through the `--network none` offline build. Reading two big-endian integers is not decoding. - **SVG is refused**, with its own error message rather than a generic "not a PNG": it can carry script, and serving it from our own origin would be a cross-site-scripting vector. An operator who tried one should learn that it is deliberate. - **The crop is driven by number inputs, not by dragging.** A drag-only cropper excludes keyboard and switch users outright; a number input is arrow-key operable and screen-reader readable without any custom aria. The resulting pixel size is stated in text, not only drawn as a frame. - **The variant is chosen by CSS, not JavaScript.** `theme-init.js` has already resolved `data-theme` before first paint, so the correct logo is the one painted rather than the one that appears after a flash. Without a dark variant the LIGHT logo carries both themes — the operator's own asset shown unchanged beats one they did not choose (the rule #307 extends to ponds). The settings screen warns; it never blocks. - **The favicon link is static, its resource dynamic.** index.html stays a static file and the api answers with the uploaded icon or a shipped default — that route must never 404, or the browser keeps its generic icon for good. The default is generated by a script from Node's own zlib (`gen-default-favicon.mjs`), for the same offline-build reason. - Both favicon sizes are uploaded together: one source, one crop, so the tab icon and the home-screen icon can never disagree. - Branding is served WITHOUT a session, because the login screen carries it and the browser fetches the favicon before anyone signs in. The admin screen says so — an operator may not expect their logo to be public. - The metadata is not writable through the settings endpoint: it describes bytes on disk, and hand-writing it would claim an asset that is not there. `./data/branding` follows the three-step rule #303 paid for: env default + `data-dirs.ts` entry, compose volume (repo AND the stages on ONE), and the `mkdir`/`chown` line in the api Dockerfile. `data-dirs.test.ts` is new and closes the hole that made #303's variant invisible: the nightly archive skips a missing directory WORDLESSLY, so the fence now demands that every `*_DIR` the backup env declares actually travels in the archive. Verified against the real defect — removing the line fails it by name. Audit catalogue v1.7 (`branding.changed`), carrying `scope` from the start so #307 is the same event with a different scope, not a second id. Verified: api suite 103 files green (a lone `public-api` ECONNRESET under local parallel load, green in isolation — the documented local flake); branding suite 12 tests against a real directory; crop arithmetic unit tests; a11y pack 11/11 in both schemes; /admin measured at 320px with the new section (overflow 0); and the whole flow walked in the browser: upload → crop 780×180 → stored as 512×118 → logo in the sidebar linking home with the instance name as its accessible name → topbar wordmark following `instance.name` → light logo still shown under `data-theme="dark"`.
251 lines
9.5 KiB
TypeScript
251 lines
9.5 KiB
TypeScript
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
|
||
import { INestApplication } from '@nestjs/common';
|
||
import { PrismaClient } from '@prisma/client';
|
||
import request from 'supertest';
|
||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||
|
||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||
import { UsersService } from '../users/users.service';
|
||
|
||
/**
|
||
* A real PNG of `size`×`size`, built the same way the shipped default is —
|
||
* the api reads the IHDR, so the header has to be genuine.
|
||
*/
|
||
async function png(size: number): Promise<Buffer> {
|
||
const { deflateSync } = await import('node:zlib');
|
||
const crcTable = Array.from({ length: 256 }, (_, n) => {
|
||
let c = n;
|
||
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||
return c >>> 0;
|
||
});
|
||
const crc32 = (buf: Buffer): number => {
|
||
let c = 0xffffffff;
|
||
for (const byte of buf) c = crcTable[(c ^ byte) & 0xff]! ^ (c >>> 8);
|
||
return (c ^ 0xffffffff) >>> 0;
|
||
};
|
||
const chunk = (type: string, data: Buffer): Buffer => {
|
||
const length = Buffer.alloc(4);
|
||
length.writeUInt32BE(data.length);
|
||
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
||
const crc = Buffer.alloc(4);
|
||
crc.writeUInt32BE(crc32(body));
|
||
return Buffer.concat([length, body, crc]);
|
||
};
|
||
const ihdr = Buffer.alloc(13);
|
||
ihdr.writeUInt32BE(size, 0);
|
||
ihdr.writeUInt32BE(size, 4);
|
||
ihdr[8] = 8;
|
||
ihdr[9] = 6;
|
||
const raw = Buffer.alloc(size * (size * 4 + 1));
|
||
return Buffer.concat([
|
||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||
chunk('IHDR', ihdr),
|
||
chunk('IDAT', deflateSync(raw)),
|
||
chunk('IEND', Buffer.alloc(0)),
|
||
]);
|
||
}
|
||
|
||
describe.skipIf(!hasTestDb)('instance branding (e2e, issue #306)', () => {
|
||
let app: INestApplication;
|
||
let prisma: PrismaClient;
|
||
let brandingDir: string;
|
||
const suffix = uniqueSuffix();
|
||
const password = 'markenzeichen mit teich 1';
|
||
const admin = { username: `ba-${suffix}` };
|
||
const plain = { username: `bp-${suffix}` };
|
||
let adminCookie: string;
|
||
let plainCookie: string;
|
||
|
||
const api = () => request(app.getHttpServer());
|
||
|
||
beforeAll(async () => {
|
||
prisma = createTestPrisma();
|
||
await prisma.rateLimit.deleteMany({});
|
||
// A real directory: the point is that bytes land somewhere and come back.
|
||
brandingDir = await mkdtemp(join(tmpdir(), 'dorfteich-branding-'));
|
||
process.env.BRANDING_DIR = brandingDir;
|
||
app = await createTestApp();
|
||
const users = app.get(UsersService);
|
||
|
||
const adminUser = await users.createUser({
|
||
username: admin.username,
|
||
email: `${admin.username}@example.org`,
|
||
displayName: `Branding Admin ${suffix}`,
|
||
password,
|
||
locale: 'en',
|
||
});
|
||
await users.markEmailVerified(adminUser.id);
|
||
await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } });
|
||
|
||
const plainUser = await users.createUser({
|
||
username: plain.username,
|
||
email: `${plain.username}@example.org`,
|
||
displayName: `Branding Plain ${suffix}`,
|
||
password,
|
||
locale: 'en',
|
||
});
|
||
await users.markEmailVerified(plainUser.id);
|
||
|
||
const login = async (username: string): Promise<string> =>
|
||
sessionCookieOf(
|
||
await api()
|
||
.post('/api/v1/auth/login')
|
||
.send({ usernameOrEmail: username, password })
|
||
.expect(200),
|
||
);
|
||
adminCookie = await login(admin.username);
|
||
plainCookie = await login(plain.username);
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await prisma.instanceSetting.deleteMany({
|
||
where: { key: { in: ['instance.logo', 'instance.logoDark', 'instance.favicon'] } },
|
||
});
|
||
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
||
await prisma.$disconnect();
|
||
await app.close();
|
||
await rm(brandingDir, { recursive: true, force: true });
|
||
delete process.env.BRANDING_DIR;
|
||
});
|
||
|
||
it('serves the shipped default favicon before anything is uploaded', async () => {
|
||
// The `<link rel="icon">` in index.html is a constant — this route must
|
||
// never 404, or the browser keeps its generic icon for good.
|
||
const res = await api().get('/api/v1/branding/favicon').expect(200);
|
||
expect(res.headers['content-type']).toContain('image/png');
|
||
expect(res.body.subarray(0, 8).toString('latin1')).toContain('PNG');
|
||
});
|
||
|
||
it('stores a logo, reports it, and serves the bytes without a session', async () => {
|
||
const bytes = await png(64);
|
||
const view = await api()
|
||
.post('/api/v1/admin/branding/logo?variant=light')
|
||
.set('Cookie', adminCookie)
|
||
.attach('file', bytes, 'logo.png')
|
||
.expect(201);
|
||
expect(view.body.logo).toMatchObject({ width: 64, height: 64 });
|
||
expect(view.body.logoDark).toBeNull();
|
||
|
||
// On disk, under the key the pond override (#307) will extend.
|
||
const onDisk = await readFile(join(brandingDir, 'instance-logo-light.png'));
|
||
expect(onDisk.length).toBe(bytes.length);
|
||
|
||
// Anonymous: the login screen carries the branding.
|
||
const served = await api().get('/api/v1/branding/logo?variant=light').expect(200);
|
||
expect(served.headers['content-type']).toContain('image/png');
|
||
const anon = await api().get('/api/v1/branding').expect(200);
|
||
expect(anon.body.logo.hash).toBe(view.body.logo.hash);
|
||
expect(anon.body.instanceName).toBeTruthy();
|
||
});
|
||
|
||
it('answers 404 for a logo variant that was never uploaded', async () => {
|
||
// No shipped default for the logo: without one the app renders the
|
||
// instance NAME, so an empty answer is the honest one.
|
||
await api().get('/api/v1/branding/logo?variant=dark').expect(404);
|
||
});
|
||
|
||
it('rejects an SVG with its own message, not a generic one', async () => {
|
||
const res = await api()
|
||
.post('/api/v1/admin/branding/logo?variant=light')
|
||
.set('Cookie', adminCookie)
|
||
.attach('file', Buffer.from('<?xml version="1.0"?><svg xmlns="..."><script/></svg>'), 'x.png')
|
||
.expect(400);
|
||
expect(res.body.code).toBe('branding_svg_rejected');
|
||
});
|
||
|
||
it('rejects bytes that are not a PNG at all', async () => {
|
||
const res = await api()
|
||
.post('/api/v1/admin/branding/logo?variant=light')
|
||
.set('Cookie', adminCookie)
|
||
.attach('file', Buffer.from('GIF89a and then some'), 'x.png')
|
||
.expect(400);
|
||
expect(res.body.code).toBe('branding_not_a_png');
|
||
});
|
||
|
||
it('rejects a logo larger than the maximum edge', async () => {
|
||
const res = await api()
|
||
.post('/api/v1/admin/branding/logo?variant=light')
|
||
.set('Cookie', adminCookie)
|
||
.attach('file', await png(600), 'x.png')
|
||
.expect(400);
|
||
expect(res.body.code).toBe('branding_image_too_large');
|
||
});
|
||
|
||
it('takes both favicon sizes together and serves each back', async () => {
|
||
await api()
|
||
.post('/api/v1/admin/branding/favicon')
|
||
.set('Cookie', adminCookie)
|
||
.attach('png-32', await png(32), 'f32.png')
|
||
.attach('png-180', await png(180), 'f180.png')
|
||
.expect(201);
|
||
|
||
for (const size of [32, 180]) {
|
||
const res = await api().get(`/api/v1/branding/favicon?size=${size}`).expect(200);
|
||
expect(res.body.length).toBe((await png(size)).length);
|
||
}
|
||
});
|
||
|
||
it('refuses a favicon whose bytes do not match the size they claim', async () => {
|
||
const res = await api()
|
||
.post('/api/v1/admin/branding/favicon')
|
||
.set('Cookie', adminCookie)
|
||
.attach('png-32', await png(64), 'f32.png')
|
||
.attach('png-180', await png(180), 'f180.png')
|
||
.expect(400);
|
||
expect(res.body.code).toBe('branding_favicon_not_square');
|
||
});
|
||
|
||
it('clears an asset and falls back again', async () => {
|
||
await api().delete('/api/v1/admin/branding/favicon').set('Cookie', adminCookie).expect(200);
|
||
const view = await api().get('/api/v1/branding').expect(200);
|
||
expect(view.body.favicon).toBeNull();
|
||
// Back to the shipped default rather than a 404.
|
||
await api().get('/api/v1/branding/favicon').expect(200);
|
||
|
||
await api()
|
||
.delete('/api/v1/admin/branding/logo?variant=light')
|
||
.set('Cookie', adminCookie)
|
||
.expect(200);
|
||
await api().get('/api/v1/branding/logo?variant=light').expect(404);
|
||
});
|
||
|
||
it('keeps management away from a non-admin, but not reading', async () => {
|
||
await api()
|
||
.post('/api/v1/admin/branding/logo?variant=light')
|
||
.set('Cookie', plainCookie)
|
||
.attach('file', await png(32), 'x.png')
|
||
.expect(403);
|
||
await api().delete('/api/v1/admin/branding/favicon').set('Cookie', plainCookie).expect(403);
|
||
await api().get('/api/v1/branding').set('Cookie', plainCookie).expect(200);
|
||
});
|
||
|
||
it('audits every branding change with scope, asset and direction', async () => {
|
||
await api()
|
||
.post('/api/v1/admin/branding/logo?variant=dark')
|
||
.set('Cookie', adminCookie)
|
||
.attach('file', await png(48), 'logo.png')
|
||
.expect(201);
|
||
const entry = await prisma.auditEntry.findFirst({
|
||
where: { action: 'branding.changed', targetId: 'instance.logoDark' },
|
||
orderBy: { at: 'desc' },
|
||
});
|
||
expect(entry).not.toBeNull();
|
||
expect(entry!.details).toMatchObject({ scope: 'instance', asset: 'logoDark', change: 'set' });
|
||
});
|
||
|
||
it('refuses to write branding metadata through the settings endpoint', async () => {
|
||
// The metadata describes bytes on disk; hand-writing it would claim an
|
||
// asset that is not there, so the settings PATCH does not accept it.
|
||
const res = await api()
|
||
.patch('/api/v1/admin/settings')
|
||
.set('Cookie', adminCookie)
|
||
.send({ 'instance.logo': { hash: 'deadbeefdeadbeef', width: 10, height: 10 } })
|
||
.expect(400);
|
||
expect(res.body.code).toBe('bad_request');
|
||
});
|
||
});
|