From fd2bdb3fb8900354d57dbaeace001bea65cefe58 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sat, 11 Jul 2026 16:40:04 +0200 Subject: [PATCH] Add instance legal pages with public rendering and footer links (#82) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Imprint and privacy policy are two new Markdown instance settings (legal.imprint, legal.privacyPolicy), edited by Site Admins in a new "Legal pages" admin section with a toggleable rendered preview. The preview uses the same shared pipeline the server renders with (markdown → schema doc → escaped HTML), so stored markup can never smuggle script to visitors. The pages render publicly at /legal/imprint and /legal/privacy — as an SPA route plus, like #56, a self-contained server-rendered HTML document under /api/v1/legal/:kind. The endpoints are setup-exempt: legal information stays reachable even while the first-run wizard is pending. Unconfigured pages show a localized notice instead of 404ing, and Site Admins additionally get a warning banner linking to the settings. A new footer with both links appears on every SPA view (editor, auth screens, public pages) and in the server-rendered documents, whose shared shell moved to public/html-shell.ts and now renders its chrome in the instance default locale (ADR 0012). docs/self-hosting/legal-template.md ships imprint and privacy-policy templates in English and German whose sections mirror Dorfteich's actual processing activities (accounts, sessions, rate-limit IPs, proxy logs, transactional mail, content, export, deletion, no third-party requests), with a review checklist tied to security.md §Privacy. New `legal` i18n namespace (de+en); api and web e2e coverage including a new CI legal pack (footer navigation, notice vs. admin banner, and the admin form publishing a text end to end). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1 --- .gitea/workflows/ci.yml | 5 + apps/api/src/app.module.ts | 2 + apps/api/src/i18n/api-i18n.ts | 6 +- apps/api/src/legal/legal.controller.ts | 41 ++++ apps/api/src/legal/legal.e2e.db.test.ts | 114 ++++++++++ apps/api/src/legal/legal.module.ts | 14 ++ apps/api/src/legal/legal.service.ts | 47 ++++ apps/api/src/public/html-shell.ts | 59 +++++ apps/api/src/public/public.service.ts | 48 +--- .../src/settings/instance-settings.service.ts | 6 + apps/web/e2e/legal.spec.ts | 74 ++++++ apps/web/src/App.tsx | 3 + apps/web/src/i18n/index.ts | 4 + apps/web/src/layout/AppLayout.tsx | 2 + apps/web/src/layout/Footer.tsx | 18 ++ apps/web/src/pages/AdminSettingsPage.tsx | 92 ++++++++ apps/web/src/pages/LegalPage.tsx | 47 ++++ apps/web/src/styles/base.css | 36 +++ docs/self-hosting/legal-template.md | 211 ++++++++++++++++++ packages/shared/i18n/de/legal.json | 22 ++ packages/shared/i18n/en/legal.json | 22 ++ packages/shared/src/index.ts | 1 + packages/shared/src/legal.ts | 22 ++ 23 files changed, 858 insertions(+), 38 deletions(-) create mode 100644 apps/api/src/legal/legal.controller.ts create mode 100644 apps/api/src/legal/legal.e2e.db.test.ts create mode 100644 apps/api/src/legal/legal.module.ts create mode 100644 apps/api/src/legal/legal.service.ts create mode 100644 apps/api/src/public/html-shell.ts create mode 100644 apps/web/e2e/legal.spec.ts create mode 100644 apps/web/src/layout/Footer.tsx create mode 100644 apps/web/src/pages/LegalPage.tsx create mode 100644 docs/self-hosting/legal-template.md create mode 100644 packages/shared/i18n/de/legal.json create mode 100644 packages/shared/i18n/en/legal.json create mode 100644 packages/shared/src/legal.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 3ba1f50..9c482ff 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -207,6 +207,11 @@ jobs: E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/public.spec.ts + - name: Run legal pack + run: | + E2E_BASE_URL=http://localhost:5173 \ + pnpm --filter @dorfteich/web exec playwright test e2e/legal.spec.ts + - name: Reset login rate limit before admin-quotas pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 7e6f81c..202764d 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -13,6 +13,7 @@ import { GrantsModule } from './grants/grants.module'; import { HealthModule } from './health/health.module'; import { ImportExportModule } from './import-export/import-export.module'; import { LabelsModule } from './labels/labels.module'; +import { LegalModule } from './legal/legal.module'; import { LinksModule } from './links/links.module'; import { MailModule } from './mail/mail.module'; import { MembersModule } from './members/members.module'; @@ -49,6 +50,7 @@ import { VersionsModule } from './versions/versions.module'; CompactionModule, VersionsModule, LabelsModule, + LegalModule, LinksModule, SearchModule, GrantsModule, diff --git a/apps/api/src/i18n/api-i18n.ts b/apps/api/src/i18n/api-i18n.ts index 71550ce..e5a3819 100644 --- a/apps/api/src/i18n/api-i18n.ts +++ b/apps/api/src/i18n/api-i18n.ts @@ -1,6 +1,8 @@ import deErrors from '@dorfteich/shared/i18n/de/errors.json'; +import deLegal from '@dorfteich/shared/i18n/de/legal.json'; import deMails from '@dorfteich/shared/i18n/de/mails.json'; import enErrors from '@dorfteich/shared/i18n/en/errors.json'; +import enLegal from '@dorfteich/shared/i18n/en/legal.json'; import enMails from '@dorfteich/shared/i18n/en/mails.json'; import { createInstance, type i18n as I18n } from 'i18next'; @@ -13,8 +15,8 @@ export const apiI18n: I18n = createInstance(); void apiI18n.init({ resources: { - en: { errors: enErrors, mails: enMails }, - de: { errors: deErrors, mails: deMails }, + en: { errors: enErrors, mails: enMails, legal: enLegal }, + de: { errors: deErrors, mails: deMails, legal: deLegal }, }, fallbackLng: 'en', supportedLngs: ['de', 'en'], diff --git a/apps/api/src/legal/legal.controller.ts b/apps/api/src/legal/legal.controller.ts new file mode 100644 index 0000000..1274776 --- /dev/null +++ b/apps/api/src/legal/legal.controller.ts @@ -0,0 +1,41 @@ +import { Controller, Get, NotFoundException, Param, Req, Res } from '@nestjs/common'; +import { LegalPageView, isLegalKind } from '@dorfteich/shared'; +import type { Request, Response } from 'express'; + +import { Public } from '../auth/auth.guard'; +import { SetupExempt } from '../setup/setup.guard'; +import { LegalService } from './legal.service'; + +/** + * Public legal pages (issue #82). `@Public()` — no session required — and + * `@SetupExempt()`: legal information must be reachable whenever the site + * is, including while the first-run wizard is still pending. Two shapes + * like #56: JSON for the SPA's /legal/:kind route, and a self-contained + * HTML document for direct hits and crawlers. + */ +@SetupExempt() +@Controller('legal') +export class LegalController { + constructor(private readonly legal: LegalService) {} + + @Get(':kind/content') + @Public() + content(@Param('kind') kind: string): Promise { + if (!isLegalKind(kind)) throw new NotFoundException(); + return this.legal.view(kind); + } + + @Get(':kind') + @Public() + async html( + @Param('kind') kind: string, + @Req() request: Request, + @Res({ passthrough: true }) response: Response, + ): Promise { + if (!isLegalKind(kind)) throw new NotFoundException(); + const canonical = `${request.protocol}://${request.get('host') ?? ''}${request.originalUrl}`; + const html = await this.legal.html(kind, canonical); + response.set('Content-Type', 'text/html; charset=utf-8'); + return html; + } +} diff --git a/apps/api/src/legal/legal.e2e.db.test.ts b/apps/api/src/legal/legal.e2e.db.test.ts new file mode 100644 index 0000000..24e99ca --- /dev/null +++ b/apps/api/src/legal/legal.e2e.db.test.ts @@ -0,0 +1,114 @@ +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'; + +const LEGAL_KEYS = ['legal.imprint', 'legal.privacyPolicy']; + +/** + * Instance legal pages end to end (issue #82): a Site Admin configures the + * Markdown through the generic settings PATCH, anonymous visitors read the + * rendered result via JSON and HTML; unconfigured pages report so instead + * of 404ing, and stored Markdown can never smuggle script into the output. + */ +describe.skipIf(!hasTestDb)('legal pages (e2e, issue #82)', () => { + let app: INestApplication; + let prisma: PrismaClient; + let adminCookie: string; + const suffix = uniqueSuffix(); + const password = 'ein sehr langes testpasswort'; + + const api = () => request(app.getHttpServer()); + + beforeAll(async () => { + prisma = createTestPrisma(); + await prisma.rateLimit.deleteMany({}); + await prisma.instanceSetting.deleteMany({ where: { key: { in: LEGAL_KEYS } } }); + app = await createTestApp(); + + const users = app.get(UsersService); + const admin = await users.createUser({ + username: `legal-admin-${suffix}`, + email: `legal-admin-${suffix}@example.org`, + displayName: 'Legal Admin', + password, + locale: 'en', + }); + await users.markEmailVerified(admin.id); + await prisma.user.update({ where: { id: admin.id }, data: { isSiteAdmin: true } }); + const res = await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: admin.username, password }) + .expect(200); + adminCookie = sessionCookieOf(res); + }); + + afterAll(async () => { + await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); + await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } }); + await prisma.instanceSetting.deleteMany({ where: { key: { in: LEGAL_KEYS } } }); + await prisma.$disconnect(); + await app.close(); + }); + + it('reports unconfigured pages instead of 404ing', async () => { + const json = await api().get('/api/v1/legal/imprint/content').expect(200); + expect(json.body).toMatchObject({ kind: 'imprint', configured: false, html: '' }); + + const html = await api().get('/api/v1/legal/imprint').expect(200); + expect(html.headers['content-type']).toContain('text/html'); + // The notice, not an empty page (AC: never silently missing). + expect(html.text).toContain('not provided this text yet'); + }); + + it('renders configured Markdown publicly, without a session', async () => { + await api() + .patch('/api/v1/admin/settings') + .set('Cookie', adminCookie) + .send({ + 'legal.imprint': '## Operator\n\nJane Doe, **Example Lane 1**', + 'legal.privacyPolicy': 'We only process what the service needs.', + }) + .expect(200); + + const json = await api().get('/api/v1/legal/imprint/content').expect(200); + expect(json.body.configured).toBe(true); + expect(json.body.html).toContain('

Operator

'); + expect(json.body.html).toContain('Example Lane 1'); + + const html = await api().get('/api/v1/legal/privacy').expect(200); + expect(html.text).toContain(''); + expect(html.text).toContain('We only process what the service needs.'); + // The shared shell links both legal pages in its footer (issue #82). + expect(html.text).toContain('href="/legal/imprint"'); + expect(html.text).not.toContain('dt_session'); + }); + + it('never lets stored Markdown smuggle script into the output', async () => { + await api() + .patch('/api/v1/admin/settings') + .set('Cookie', adminCookie) + .send({ 'legal.imprint': 'Hello ' }) + .expect(200); + + const json = await api().get('/api/v1/legal/imprint/content').expect(200); + // The pipeline escapes markup wholesale — it survives only as inert + // text (<script>…), never as actual tags. + expect(json.body.html).not.toContain('