Add instance legal pages with public rendering and footer links (#82)
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m7s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m14s
CD / Deploy to Test (push) Successful in 12s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 9s
CI / Auth e2e pack (push) Successful in 5m9s
CI / Import/export fidelity gate (push) Successful in 45s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
Claude Fable 5 2026-07-11 16:40:04 +02:00
parent 28aa04d5e4
commit fd2bdb3fb8
23 changed files with 858 additions and 38 deletions

View File

@ -207,6 +207,11 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \ E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/public.spec.ts 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 - name: Reset login rate limit before admin-quotas pack
run: | run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \

View File

@ -13,6 +13,7 @@ import { GrantsModule } from './grants/grants.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { ImportExportModule } from './import-export/import-export.module'; import { ImportExportModule } from './import-export/import-export.module';
import { LabelsModule } from './labels/labels.module'; import { LabelsModule } from './labels/labels.module';
import { LegalModule } from './legal/legal.module';
import { LinksModule } from './links/links.module'; import { LinksModule } from './links/links.module';
import { MailModule } from './mail/mail.module'; import { MailModule } from './mail/mail.module';
import { MembersModule } from './members/members.module'; import { MembersModule } from './members/members.module';
@ -49,6 +50,7 @@ import { VersionsModule } from './versions/versions.module';
CompactionModule, CompactionModule,
VersionsModule, VersionsModule,
LabelsModule, LabelsModule,
LegalModule,
LinksModule, LinksModule,
SearchModule, SearchModule,
GrantsModule, GrantsModule,

View File

@ -1,6 +1,8 @@
import deErrors from '@dorfteich/shared/i18n/de/errors.json'; 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 deMails from '@dorfteich/shared/i18n/de/mails.json';
import enErrors from '@dorfteich/shared/i18n/en/errors.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 enMails from '@dorfteich/shared/i18n/en/mails.json';
import { createInstance, type i18n as I18n } from 'i18next'; import { createInstance, type i18n as I18n } from 'i18next';
@ -13,8 +15,8 @@ export const apiI18n: I18n = createInstance();
void apiI18n.init({ void apiI18n.init({
resources: { resources: {
en: { errors: enErrors, mails: enMails }, en: { errors: enErrors, mails: enMails, legal: enLegal },
de: { errors: deErrors, mails: deMails }, de: { errors: deErrors, mails: deMails, legal: deLegal },
}, },
fallbackLng: 'en', fallbackLng: 'en',
supportedLngs: ['de', 'en'], supportedLngs: ['de', 'en'],

View File

@ -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<LegalPageView> {
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<string> {
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;
}
}

View File

@ -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('<h2>Operator</h2>');
expect(json.body.html).toContain('<strong>Example Lane 1</strong>');
const html = await api().get('/api/v1/legal/privacy').expect(200);
expect(html.text).toContain('<!doctype html>');
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 <script>alert(1)</script> <img src=x onerror=alert(1)>' })
.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 (&lt;script&gt;…), never as actual tags.
expect(json.body.html).not.toContain('<script>');
expect(json.body.html).not.toContain('<img');
expect(json.body.html).toContain('&lt;script&gt;');
});
it('404s unknown kinds and keeps the settings admin-only', async () => {
await api().get('/api/v1/legal/terms/content').expect(404);
await api().get('/api/v1/legal/terms').expect(404);
await api()
.patch('/api/v1/admin/settings')
.send({ 'legal.imprint': 'anonymous edit' })
.expect(401);
});
});

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { LegalController } from './legal.controller';
import { LegalService } from './legal.service';
/**
* Instance legal pages (issue #82): imprint and privacy policy from
* instance_settings (global SettingsModule), rendered publicly.
*/
@Module({
controllers: [LegalController],
providers: [LegalService],
})
export class LegalModule {}

View File

@ -0,0 +1,47 @@
import { Injectable } from '@nestjs/common';
import { LegalKind, LegalPageView, docToHtml, markdownToDoc } from '@dorfteich/shared';
import { apiI18n } from '../i18n/api-i18n';
import { escapeHtml, htmlDocument } from '../public/html-shell';
import { InstanceSettingKey, InstanceSettingsService } from '../settings/instance-settings.service';
const SETTING_FOR_KIND: Record<LegalKind, InstanceSettingKey & `legal.${string}`> = {
imprint: 'legal.imprint',
privacy: 'legal.privacyPolicy',
};
/**
* Instance legal pages (issue #82, security.md §Privacy): the Site Admin's
* Markdown from instance_settings, rendered through the sanitizing editor
* pipeline (markdown schema doc escaped HTML, security.md §Content)
* arbitrary HTML or scripts in the setting can never reach visitors.
* Unconfigured pages render a notice instead of 404ing, so the links never
* dangle; the SPA additionally warns Site Admins (AC).
*/
@Injectable()
export class LegalService {
constructor(private readonly settings: InstanceSettingsService) {}
async view(kind: LegalKind): Promise<LegalPageView> {
const markdown = await this.settings.get(SETTING_FOR_KIND[kind]);
const configured = markdown.trim().length > 0;
return { kind, configured, html: configured ? docToHtml(markdownToDoc(markdown)) : '' };
}
/** A self-contained HTML document, like #56's public page view. */
async html(kind: LegalKind, canonical: string): Promise<string> {
const view = await this.view(kind);
const lang = await this.settings.get('instance.defaultLocale');
const instanceName = await this.settings.get('instance.name');
const title = apiI18n.t(`legal:title.${kind}`, { lng: lang });
const body = view.configured
? view.html
: `<p>${escapeHtml(apiI18n.t('legal:notConfigured', { lng: lang }))}</p>`;
return htmlDocument({
lang,
title: `${title}${instanceName}`,
canonical,
bodyHtml: `<h1>${escapeHtml(title)}</h1>\n${body}`,
});
}
}

View File

@ -0,0 +1,59 @@
import { apiI18n } from '../i18n/api-i18n';
/**
* The document shell shared by every server-rendered public page (#56's
* page view, #82's legal pages): identical for every viewer, crawler-safe,
* no session-dependent content. Chrome (footer links) renders in the
* instance default locale (ADR 0012). `bodyHtml` must already be safe
* it comes from the sanitizing editor renderers, never from raw input.
*/
export interface HtmlShellOptions {
lang: 'de' | 'en';
/** Plain text; escaped here. */
title: string;
canonical?: string;
bodyHtml: string;
}
export function htmlDocument({ lang, title, canonical, bodyHtml }: HtmlShellOptions): string {
const canonicalTag = canonical ? `\n<link rel="canonical" href="${escapeHtml(canonical)}">` : '';
const imprintLabel = escapeHtml(apiI18n.t('legal:links.imprint', { lng: lang }));
const privacyLabel = escapeHtml(apiI18n.t('legal:links.privacy', { lng: lang }));
return `<!doctype html>
<html lang="${lang}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>${canonicalTag}
<style>
:root { color-scheme: light dark; }
body { max-width: 48rem; margin: 2rem auto; padding: 0 1rem;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; line-height: 1.6; }
img { max-width: 100%; height: auto; }
.public-page__pond { color: #64748b; font-size: 0.9rem; }
pre { overflow-x: auto; }
.public-footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid #64748b;
font-size: 0.9rem; }
</style>
</head>
<body>
<main class="public-page">
${bodyHtml}
</main>
<footer class="public-footer">
<a href="/legal/imprint">${imprintLabel}</a> · <a href="/legal/privacy">${privacyLabel}</a>
</footer>
</body>
</html>
`;
}
/** Minimal HTML escaping for the values interpolated into the shell (not the
* already-sanitized body HTML). */
export function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View File

@ -4,6 +4,8 @@ import { Pond, User } from '@prisma/client';
import { PermissionService } from '../permissions/permission.service'; import { PermissionService } from '../permissions/permission.service';
import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer'; import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { InstanceSettingsService } from '../settings/instance-settings.service';
import { escapeHtml, htmlDocument } from './html-shell';
/** The JSON the SPA renders for an anonymous (or any) reader of a public page. */ /** The JSON the SPA renders for an anonymous (or any) reader of a public page. */
export interface PublicPageContent { export interface PublicPageContent {
@ -35,6 +37,7 @@ export class PublicService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly permissions: PermissionService, private readonly permissions: PermissionService,
private readonly fallbacks: PluginFallbackRenderer, private readonly fallbacks: PluginFallbackRenderer,
private readonly settings: InstanceSettingsService,
) {} ) {}
private async resolve( private async resolve(
@ -82,34 +85,17 @@ export class PublicService {
canonical: string, canonical: string,
): Promise<string> { ): Promise<string> {
const content = await this.content(user, pondSlug, pageSlug); const content = await this.content(user, pondSlug, pageSlug);
const title = escapeHtml(`${content.title}${content.pondName}`);
// No session-dependent content: this document is identical for every viewer // No session-dependent content: this document is identical for every viewer
// who may read the page (crawler-safe, cacheable). // who may read the page (crawler-safe, cacheable). The shared shell adds
return `<!doctype html> // the legal footer links (issue #82) in the instance default locale.
<html lang="en"> return htmlDocument({
<head> lang: await this.settings.get('instance.defaultLocale'),
<meta charset="utf-8"> title: `${content.title}${content.pondName}`,
<meta name="viewport" content="width=device-width, initial-scale=1"> canonical,
<title>${title}</title> bodyHtml: `<p class="public-page__pond">${escapeHtml(content.pondName)}</p>
<link rel="canonical" href="${escapeHtml(canonical)}">
<style>
:root { color-scheme: light dark; }
body { max-width: 48rem; margin: 2rem auto; padding: 0 1rem;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; line-height: 1.6; }
img { max-width: 100%; height: auto; }
.public-page__pond { color: #64748b; font-size: 0.9rem; }
pre { overflow-x: auto; }
</style>
</head>
<body>
<main class="public-page">
<p class="public-page__pond">${escapeHtml(content.pondName)}</p>
<h1>${escapeHtml(content.title)}</h1> <h1>${escapeHtml(content.title)}</h1>
${content.html} ${content.html}`,
</main> });
</body>
</html>
`;
} }
} }
@ -126,13 +112,3 @@ function resolveMediaUrls(html: string): string {
'src="/api/v1/media/$1" data-file-id="$1"', 'src="/api/v1/media/$1" data-file-id="$1"',
); );
} }
/** Minimal HTML escaping for the values we interpolate into the shell (not the
* already-sanitized cached body HTML). */
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View File

@ -48,6 +48,12 @@ export const INSTANCE_SETTINGS = {
// SVG upload handling (security.md §Uploads): sanitize strips scripts and // SVG upload handling (security.md §Uploads): sanitize strips scripts and
// event handlers with a maintained library; reject refuses SVG outright. // event handlers with a maintained library; reject refuses SVG outright.
'upload.svgPolicy': z.enum(['reject', 'sanitize']).default('sanitize'), 'upload.svgPolicy': z.enum(['reject', 'sanitize']).default('sanitize'),
// Instance legal pages (issue #82, security.md §Privacy): Markdown texts
// for imprint and privacy policy, rendered publicly at /legal/<kind>.
// Empty = not configured yet (the legal pages then show a notice and
// Site Admins a warning banner instead of silently missing).
'legal.imprint': z.string().max(100_000).default(''),
'legal.privacyPolicy': z.string().max(100_000).default(''),
// When the first-run setup wizard completed (issue #80). Null = the // When the first-run setup wizard completed (issue #80). Null = the
// instance still requires setup and only /setup/* is reachable; once set // instance still requires setup and only /setup/* is reachable; once set
// the wizard is locked for good (SetupStateService). Written by the wizard, // the wizard is locked for good (SetupStateService). Written by the wizard,

View File

@ -0,0 +1,74 @@
import { expect, test } from '@playwright/test';
import { contextForUser } from './helpers';
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
const IMPRINT_TEXT = 'E2E Legal Operator, Example Lane 1';
/**
* Instance legal pages (issue #82): a Site Admin's Markdown renders publicly,
* footer links reach it from everywhere (including auth screens), and an
* unconfigured page shows a notice plus a warning banner for Site Admins.
* The state is set explicitly up front, so the pack is repeatable.
*/
test.beforeAll(async ({ browser }) => {
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
const response = await admin.request.patch('/api/v1/admin/settings', {
data: { 'legal.imprint': `## Operator\n\n${IMPRINT_TEXT}`, 'legal.privacyPolicy': '' },
});
expect(response.ok()).toBe(true);
await admin.close();
});
test('footer links lead anonymous visitors to the configured imprint', async ({ page }) => {
// The auth screen carries the footer too (AC: links appear everywhere).
await page.goto('/login');
await page
.locator('.app-footer')
.getByRole('link', { name: /imprint|impressum/i })
.click();
await expect(page).toHaveURL(/\/legal\/imprint$/);
await expect(page.getByRole('heading', { name: /imprint|impressum/i }).first()).toBeVisible();
await expect(page.getByText(IMPRINT_TEXT)).toBeVisible();
});
test('an unconfigured page shows a notice to visitors, not a 404', async ({ page }) => {
await page.goto('/legal/privacy');
await expect(page.getByText(/not provided this text yet|noch nicht hinterlegt/i)).toBeVisible();
// Anonymous visitors get the neutral notice only — no admin warning.
await expect(page.getByRole('alert')).toHaveCount(0);
});
test('site admins see a warning banner on unconfigured pages', async ({ browser }) => {
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
const page = await admin.newPage();
await page.goto('/legal/privacy');
const banner = page.getByRole('alert');
await expect(banner).toBeVisible();
await banner.getByRole('link').click();
await expect(page).toHaveURL(/\/admin$/);
await admin.close();
});
test('the admin form previews and publishes the privacy policy', async ({ browser }) => {
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
const page = await admin.newPage();
await page.goto('/admin');
// Second legal editor = the privacy policy (imprint comes first).
const editor = page.locator('.legal-editor').nth(1);
await editor.locator('textarea').fill('We process **only what is needed**.');
await editor.getByRole('button', { name: /preview|vorschau/i }).click();
await expect(editor.locator('.legal-editor__preview strong')).toHaveText('only what is needed');
await page.getByRole('button', { name: /save legal pages|rechtsseiten speichern/i }).click();
await expect(page.getByRole('status')).toBeVisible();
await admin.close();
const anonymous = await browser.newContext({ baseURL: BASE_URL });
const anonymousPage = await anonymous.newPage();
await anonymousPage.goto('/legal/privacy');
await expect(anonymousPage.getByText('only what is needed')).toBeVisible();
await anonymous.close();
});

View File

@ -5,6 +5,7 @@ import { AppLayout } from './layout/AppLayout';
import { AdminSettingsPage } from './pages/AdminSettingsPage'; import { AdminSettingsPage } from './pages/AdminSettingsPage';
import { FontCatalogPage } from './pages/FontCatalogPage'; import { FontCatalogPage } from './pages/FontCatalogPage';
import { HomePage } from './pages/HomePage'; import { HomePage } from './pages/HomePage';
import { LegalPage } from './pages/LegalPage';
import { NotFoundPage } from './pages/NotFoundPage'; import { NotFoundPage } from './pages/NotFoundPage';
import { PageEditorPage } from './pages/PageEditorPage'; import { PageEditorPage } from './pages/PageEditorPage';
import { PluginPreviewPage } from './pages/PluginPreviewPage'; import { PluginPreviewPage } from './pages/PluginPreviewPage';
@ -58,6 +59,8 @@ export function App(): React.JSX.Element {
<Route path="reset-password" element={<ResetPasswordPage />} /> <Route path="reset-password" element={<ResetPasswordPage />} />
{/* Public read-only page view — reachable without a session (issue #56). */} {/* Public read-only page view — reachable without a session (issue #56). */}
<Route path="public/:pondSlug/:pageSlug" element={<PublicPageView />} /> <Route path="public/:pondSlug/:pageSlug" element={<PublicPageView />} />
{/* Legal pages — public, no session required (issue #82). */}
<Route path="legal/:kind" element={<LegalPage />} />
{/* Setup is done exactly once — afterwards the wizard url just goes home. */} {/* Setup is done exactly once — afterwards the wizard url just goes home. */}
<Route path="setup" element={<Navigate to="/" replace />} /> <Route path="setup" element={<Navigate to="/" replace />} />

View File

@ -8,6 +8,7 @@ import deFiles from '@dorfteich/shared/i18n/de/files.json';
import deFont from '@dorfteich/shared/i18n/de/font.json'; import deFont from '@dorfteich/shared/i18n/de/font.json';
import deImport from '@dorfteich/shared/i18n/de/import.json'; import deImport from '@dorfteich/shared/i18n/de/import.json';
import deLabels from '@dorfteich/shared/i18n/de/labels.json'; import deLabels from '@dorfteich/shared/i18n/de/labels.json';
import deLegal from '@dorfteich/shared/i18n/de/legal.json';
import deLinks from '@dorfteich/shared/i18n/de/links.json'; import deLinks from '@dorfteich/shared/i18n/de/links.json';
import deMembers from '@dorfteich/shared/i18n/de/members.json'; import deMembers from '@dorfteich/shared/i18n/de/members.json';
import dePlugins from '@dorfteich/shared/i18n/de/plugins.json'; import dePlugins from '@dorfteich/shared/i18n/de/plugins.json';
@ -27,6 +28,7 @@ import enFiles from '@dorfteich/shared/i18n/en/files.json';
import enFont from '@dorfteich/shared/i18n/en/font.json'; import enFont from '@dorfteich/shared/i18n/en/font.json';
import enImport from '@dorfteich/shared/i18n/en/import.json'; import enImport from '@dorfteich/shared/i18n/en/import.json';
import enLabels from '@dorfteich/shared/i18n/en/labels.json'; import enLabels from '@dorfteich/shared/i18n/en/labels.json';
import enLegal from '@dorfteich/shared/i18n/en/legal.json';
import enLinks from '@dorfteich/shared/i18n/en/links.json'; import enLinks from '@dorfteich/shared/i18n/en/links.json';
import enMembers from '@dorfteich/shared/i18n/en/members.json'; import enMembers from '@dorfteich/shared/i18n/en/members.json';
import enPlugins from '@dorfteich/shared/i18n/en/plugins.json'; import enPlugins from '@dorfteich/shared/i18n/en/plugins.json';
@ -63,6 +65,7 @@ void i18n
font: enFont, font: enFont,
import: enImport, import: enImport,
labels: enLabels, labels: enLabels,
legal: enLegal,
links: enLinks, links: enLinks,
members: enMembers, members: enMembers,
plugins: enPlugins, plugins: enPlugins,
@ -84,6 +87,7 @@ void i18n
font: deFont, font: deFont,
import: deImport, import: deImport,
labels: deLabels, labels: deLabels,
legal: deLegal,
links: deLinks, links: deLinks,
members: deMembers, members: deMembers,
plugins: dePlugins, plugins: dePlugins,

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { usePersistentState } from '../lib/use-persistent-state'; import { usePersistentState } from '../lib/use-persistent-state';
import { Footer } from './Footer';
import { SidebarChromeContext } from './sidebar-chrome'; import { SidebarChromeContext } from './sidebar-chrome';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { TopBar } from './TopBar'; import { TopBar } from './TopBar';
@ -35,6 +36,7 @@ export function AppLayout(): React.JSX.Element {
<Sidebar collapsed={collapsed} /> <Sidebar collapsed={collapsed} />
<main className="main"> <main className="main">
<Outlet /> <Outlet />
<Footer />
</main> </main>
</div> </div>
</div> </div>

View File

@ -0,0 +1,18 @@
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
/**
* The app-wide footer (issue #82): legal links on every view editor,
* public pages, and auth screens all render inside AppLayout, so this one
* spot covers them all.
*/
export function Footer(): React.JSX.Element {
const { t } = useTranslation('legal');
return (
<footer className="app-footer">
<Link to="/legal/imprint">{t('links.imprint')}</Link>
<span aria-hidden>·</span>
<Link to="/legal/privacy">{t('links.privacy')}</Link>
</footer>
);
}

View File

@ -1,4 +1,5 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { docToHtml, markdownToDoc } from '@dorfteich/shared';
import { useState } from 'react'; import { useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -20,6 +21,8 @@ interface InstanceSettings {
'quota.maxFileBytes': number; 'quota.maxFileBytes': number;
'upload.allowedExtensions': string[]; 'upload.allowedExtensions': string[];
'upload.svgPolicy': 'reject' | 'sanitize'; 'upload.svgPolicy': 'reject' | 'sanitize';
'legal.imprint': string;
'legal.privacyPolicy': string;
} }
export function AdminSettingsPage(): React.JSX.Element { export function AdminSettingsPage(): React.JSX.Element {
@ -101,6 +104,7 @@ export function AdminSettingsPage(): React.JSX.Element {
</section> </section>
<UploadSettingsForm settings={settings.data} /> <UploadSettingsForm settings={settings.data} />
<LegalSettingsForm settings={settings.data} />
<PluginManager /> <PluginManager />
<QuotaManager /> <QuotaManager />
@ -175,6 +179,94 @@ function UploadSettingsForm({ settings }: { settings: InstanceSettings }): React
); );
} }
/**
* Legal pages (issue #82): imprint and privacy policy as Markdown, shown
* publicly at /legal/imprint and /legal/privacy. The preview renders through
* the same shared pipeline the api uses (markdown schema doc HTML), so
* what the admin sees is what visitors get.
*/
function LegalSettingsForm({ settings }: { settings: InstanceSettings }): React.JSX.Element {
const { t } = useTranslation('legal');
const queryClient = useQueryClient();
const [imprint, setImprint] = useState(settings['legal.imprint']);
const [privacy, setPrivacy] = useState(settings['legal.privacyPolicy']);
const [error, setError] = useState<unknown>(null);
const [saved, setSaved] = useState(false);
const [busy, setBusy] = useState(false);
async function onSubmit(event: React.FormEvent): Promise<void> {
event.preventDefault();
setError(null);
setSaved(false);
setBusy(true);
try {
await apiPatch('/admin/settings', {
'legal.imprint': imprint,
'legal.privacyPolicy': privacy,
});
await queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] });
await queryClient.invalidateQueries({ queryKey: ['legal'] });
setSaved(true);
} catch (err) {
setError(err);
} finally {
setBusy(false);
}
}
return (
<section className="settings-section">
<h2>{t('admin.title')}</h2>
<p className="field__hint">{t('admin.hint')}</p>
<form onSubmit={(event) => void onSubmit(event)} noValidate>
<FormError error={error} />
<FormSuccess message={saved ? t('admin.save') : null} />
<LegalTextField label={t('admin.imprint')} value={imprint} onChange={setImprint} />
<LegalTextField label={t('admin.privacyPolicy')} value={privacy} onChange={setPrivacy} />
<button type="submit" className="button" disabled={busy}>
{t('admin.save')}
</button>
</form>
</section>
);
}
/** One Markdown textarea with a toggleable rendered preview. */
function LegalTextField({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (value: string) => void;
}): React.JSX.Element {
const { t } = useTranslation('legal');
const [preview, setPreview] = useState(false);
return (
<div className="legal-editor">
<Field label={label}>
<textarea
rows={10}
value={value}
onChange={(event) => onChange(event.target.value)}
spellCheck={false}
/>
</Field>
<button type="button" className="linklike" onClick={() => setPreview(!preview)}>
{preview ? t('admin.hidePreview') : t('admin.preview')}
</button>
{preview && (
// Same sanitizing pipeline as the api's public rendering — safe.
<div
className="legal-editor__preview legal-page__body"
dangerouslySetInnerHTML={{ __html: docToHtml(markdownToDoc(value)) }}
/>
)}
</div>
);
}
/** Map the instance-setting key to the shared quota key its label lives under. */ /** Map the instance-setting key to the shared quota key its label lives under. */
const SETTING_TO_QUOTA_KEY = { const SETTING_TO_QUOTA_KEY = {
'quota.editorsPerPond': 'editors_per_pond', 'quota.editorsPerPond': 'editors_per_pond',

View File

@ -0,0 +1,47 @@
import { useQuery } from '@tanstack/react-query';
import { LegalPageView, isLegalKind } from '@dorfteich/shared';
import { useTranslation } from 'react-i18next';
import { Link, useParams } from 'react-router-dom';
import { useAuth } from '../auth/auth-context';
import { apiGet } from '../lib/api';
import { NotFoundPage } from './NotFoundPage';
/**
* Public legal page (issue #82): imprint or privacy policy, reachable
* without a session. The HTML comes from the api's sanitizing Markdown
* pipeline (like #56's public view) safe to render. An unconfigured page
* shows a notice instead of silently missing, and Site Admins additionally
* get a warning banner pointing at the settings (AC).
*/
export function LegalPage(): React.JSX.Element {
const { t } = useTranslation('legal');
const { user } = useAuth();
const { kind = '' } = useParams<{ kind: string }>();
const query = useQuery({
queryKey: ['legal', kind],
queryFn: () => apiGet<LegalPageView>(`/legal/${kind}/content`),
enabled: isLegalKind(kind),
});
if (!isLegalKind(kind)) return <NotFoundPage />;
if (!query.data) return <div aria-busy="true" />;
return (
<article className="legal-page">
<h1>{t(`title.${kind}`)}</h1>
{!query.data.configured && user?.isSiteAdmin && (
<p className="form-banner form-banner--error" role="alert">
{t('adminWarning')} <Link to="/admin">{t('adminWarningLink')}</Link>
</p>
)}
{query.data.configured ? (
// Server-rendered through the sanitizing editor pipeline — safe.
<div className="legal-page__body" dangerouslySetInnerHTML={{ __html: query.data.html }} />
) : (
<p className="legal-page__placeholder">{t('notConfigured')}</p>
)}
</article>
);
}

View File

@ -371,6 +371,42 @@ button {
font-size: 0.95rem; font-size: 0.95rem;
} }
/* Legal pages + footer (issue #82) */
.app-footer {
display: flex;
gap: var(--space-2);
margin-top: var(--space-8);
padding-top: var(--space-3);
border-top: 1px solid var(--color-border);
color: var(--color-text-muted);
font-size: 0.85rem;
}
.legal-page {
max-width: 44rem;
}
.legal-page__placeholder {
color: var(--color-text-muted);
}
.legal-editor {
margin-bottom: var(--space-4);
}
.legal-editor textarea {
width: 100%;
font-family: var(--font-mono);
font-size: 0.9rem;
}
.legal-editor__preview {
margin-top: var(--space-3);
padding: var(--space-3);
border: 1px dashed var(--color-border);
border-radius: var(--radius);
}
/* First-run setup wizard (issue #81) */ /* First-run setup wizard (issue #81) */
.setup-shell { .setup-shell {
min-height: 100vh; min-height: 100vh;

View File

@ -0,0 +1,211 @@
# Legal page templates (imprint & privacy policy)
Dorfteich ships legal pages as a product feature (issue #82, security.md
§Privacy): Site Admins paste Markdown into **Administration → Legal pages**,
and the texts render publicly at `/legal/imprint` and `/legal/privacy`
linked from the footer on every view. This document provides starting
templates whose privacy section matches **what Dorfteich actually
processes**; keep it in sync when a change adds or removes a processing
activity (review anchor: security.md §Privacy).
> **Not legal advice.** These templates are a technically accurate starting
> point, not a substitute for a lawyer. Operator-specific parts (identity,
> hosting provider, log retention, backups) are marked with `[…]`
> placeholders and MUST be filled in. dorfteich.online uses the operator's
> own standard texts, applied at go-live (#89) — not these templates.
## What Dorfteich processes (review checklist)
The privacy templates below cover exactly these activities. When one of
them changes, update the templates in the same change:
| Activity | Data | Where implemented |
| ----------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
| Accounts | username, e-mail, password hash, display name, locale | `users` table; signup/verification (ADR 0007) |
| Sessions | session cookie (`dt_session`), user agent, expiry | `sessions` table |
| Rate limiting | IP address in short-lived counters | `rate_limits` table (short TTL, operations.md) |
| Reverse-proxy logs | IP address, requested URL | host level (Caddy/nginx), operator-managed rotation |
| Transactional e-mail | recipient address, mail content | SMTP relay (wizard/secret store, #80); verification, password reset |
| Content & uploads | pages, attachments authored by users | ponds/pages/files; server sees plaintext (no E2E, security.md §Out of scope) |
| Data export | self-service ZIP (profile + own ponds) | `POST /users/me/data-export` (#68) |
| Deletion | trash/purge for content; pseudonymized authorship ("deleted user") | PseudonymizationService (#59), trash retention (ADR 0013) |
| No third-party requests | fonts self-hosted, no CDNs, no analytics | ADR 0016, CSP in nginx.conf |
## Imprint template (fill in and paste into Administration → Legal pages)
The legal pages render their own localized heading (Imprint/Impressum,
Privacy policy/Datenschutzerklärung), so the templates start straight with
the content — do not add a top-level `#` heading of your own.
```markdown
**Operator of this instance**
[Name]
[Street and number]
[Postal code, city]
[Country]
**Contact**
E-mail: [address]
[Phone: optional]
[If applicable: register entries, VAT ID, persons responsible for content.]
```
German version:
```markdown
**Betreiberin/Betreiber dieser Instanz**
[Name]
[Straße und Hausnummer]
[PLZ, Ort]
[Land]
**Kontakt**
E-Mail: [Adresse]
[Telefon: optional]
[Falls zutreffend: Registereinträge, USt-IdNr., inhaltlich Verantwortliche.]
```
## Privacy policy template (English)
```markdown
This instance of Dorfteich is operated by [operator, see imprint]. We
process personal data only as far as running this service requires
(Art. 6 (1) (b) GDPR for accounts, Art. 6 (1) (f) GDPR for abuse
protection and operational logs).
## Accounts
When you register, we store your username, e-mail address, a password
hash (never the password itself), your display name, and your language
preference. The e-mail address is used to verify your account and for
password resets. Accounts are visible to other members through the names
you choose.
## Sessions and cookies
After signing in, a session cookie (`dt_session`) keeps you signed in.
We store the session together with the browser identification (user
agent) your client sends and an expiry time. There are no tracking or
third-party cookies.
## Abuse protection and server logs
To protect the service against abuse, IP addresses are counted in
short-lived rate-limit records that expire automatically. The web server
in front of this instance additionally writes access logs containing IP
addresses; these are rotated and deleted after [retention, e.g. 14 days].
## E-mail
Transactional e-mails (account verification, password reset) are sent
through the SMTP relay [relay provider]. Your e-mail address and the
message content are transmitted to that relay for delivery.
## Content you create
Pages and uploaded files you create are stored on the server and are
visible to the people your ponds' permissions allow. Content is not
end-to-end encrypted — the server processes it in plaintext to provide
search, export, and rendering. Deleted pages remain restorable from the
trash for [trash retention, default 30] days before they are permanently
removed.
## No third-party requests
Your browser talks only to this instance: fonts are self-hosted, and
there are no CDNs, no analytics, and no embedded third-party services.
## Your rights
You can export your own data (profile and the ponds you own) as a ZIP
archive at any time from your account settings (right of access and data
portability). When your account is deleted, your personal pond and the
ponds you own are deleted through the trash process, and your authorship
on shared content is replaced with "deleted user" (pseudonymization).
You further have the right to rectification, erasure, restriction of
processing, and to lodge a complaint with a supervisory authority. To
exercise your rights, contact [address from the imprint].
## Hosting and backups
This instance is hosted at [provider, location]. Backups are kept for
[retention] and stored [location/provider].
```
## Privacy policy template (Deutsch)
```markdown
Diese Dorfteich-Instanz wird betrieben von [Betreiberin/Betreiber, siehe
Impressum]. Wir verarbeiten personenbezogene Daten nur, soweit der
Betrieb dieses Dienstes es erfordert (Art. 6 Abs. 1 lit. b DSGVO für
Konten, Art. 6 Abs. 1 lit. f DSGVO für Missbrauchsschutz und
Betriebs-Logs).
## Konten
Bei der Registrierung speichern wir Benutzername, E-Mail-Adresse, einen
Passwort-Hash (nie das Passwort selbst), den Anzeigenamen und die
Spracheinstellung. Die E-Mail-Adresse dient der Bestätigung des Kontos
und dem Zurücksetzen des Passworts. Konten sind für andere Mitglieder
unter den von dir gewählten Namen sichtbar.
## Sitzungen und Cookies
Nach der Anmeldung hält ein Sitzungs-Cookie (`dt_session`) dich
angemeldet. Wir speichern die Sitzung zusammen mit der von deinem
Browser übermittelten Kennung (User-Agent) und einem Ablaufzeitpunkt.
Es gibt keine Tracking- oder Drittanbieter-Cookies.
## Missbrauchsschutz und Server-Logs
Zum Schutz vor Missbrauch werden IP-Adressen in kurzlebigen
Rate-Limit-Einträgen gezählt, die automatisch verfallen. Der
vorgeschaltete Webserver schreibt zusätzlich Zugriffs-Logs mit
IP-Adressen; diese werden rotiert und nach [Aufbewahrung, z. B. 14
Tagen] gelöscht.
## E-Mail
Transaktions-E-Mails (Konto-Bestätigung, Passwort-Zurücksetzen) werden
über den SMTP-Server [Anbieter] versendet. Dafür werden deine
E-Mail-Adresse und der Nachrichteninhalt an diesen Server übermittelt.
## Von dir erstellte Inhalte
Von dir angelegte Seiten und hochgeladene Dateien liegen auf dem Server
und sind für die Personen sichtbar, die die Berechtigungen deiner Teiche
zulassen. Inhalte sind nicht Ende-zu-Ende-verschlüsselt — der Server
verarbeitet sie im Klartext, um Suche, Export und Darstellung
bereitzustellen. Gelöschte Seiten bleiben [Aufbewahrung, Standard 30]
Tage im Papierkorb wiederherstellbar, bevor sie endgültig entfernt
werden.
## Keine Anfragen an Dritte
Dein Browser kommuniziert ausschließlich mit dieser Instanz: Schriften
sind selbst gehostet, es gibt keine CDNs, keine Analyse-Dienste und
keine eingebetteten Drittanbieter.
## Deine Rechte
Du kannst deine eigenen Daten (Profil und die dir gehörenden Teiche)
jederzeit in den Konto-Einstellungen als ZIP-Archiv exportieren
(Auskunft und Datenübertragbarkeit). Bei der Löschung deines Kontos
werden dein persönlicher Teich und die dir gehörenden Teiche über den
Papierkorb-Prozess gelöscht; deine Autorenschaft an geteilten Inhalten
wird durch „gelöschte Nutzerin/gelöschter Nutzer" ersetzt
(Pseudonymisierung). Darüber hinaus hast du das Recht auf Berichtigung,
Löschung, Einschränkung der Verarbeitung und auf Beschwerde bei einer
Aufsichtsbehörde. Wende dich dazu an [Kontakt aus dem Impressum].
## Hosting und Backups
Diese Instanz wird gehostet bei [Anbieter, Standort]. Backups werden
[Aufbewahrung] aufbewahrt und liegen [Ort/Anbieter].
```

View File

@ -0,0 +1,22 @@
{
"links": {
"imprint": "Impressum",
"privacy": "Datenschutzerklärung"
},
"title": {
"imprint": "Impressum",
"privacy": "Datenschutzerklärung"
},
"notConfigured": "Die Betreiberin oder der Betreiber dieser Instanz hat diesen Text noch nicht hinterlegt.",
"adminWarning": "Diese Rechtsseite ist noch nicht eingerichtet — Besuchende sehen aktuell einen Platzhalter statt des erforderlichen Textes.",
"adminWarningLink": "Text in den Administrations-Einstellungen hinterlegen",
"admin": {
"title": "Rechtsseiten",
"hint": "Beide Texte sind Markdown und erscheinen öffentlich unter /legal/imprint und /legal/privacy — ohne Anmeldung. Eine Datenschutz-Vorlage für die Verarbeitungen von Dorfteich liegt in docs/self-hosting/legal-template.md bei.",
"imprint": "Impressum",
"privacyPolicy": "Datenschutzerklärung",
"preview": "Vorschau anzeigen",
"hidePreview": "Vorschau ausblenden",
"save": "Rechtsseiten speichern"
}
}

View File

@ -0,0 +1,22 @@
{
"links": {
"imprint": "Imprint",
"privacy": "Privacy policy"
},
"title": {
"imprint": "Imprint",
"privacy": "Privacy policy"
},
"notConfigured": "The operator of this instance has not provided this text yet.",
"adminWarning": "This legal page is not configured yet — visitors currently see a placeholder instead of the required text.",
"adminWarningLink": "Add the text in the administration settings",
"admin": {
"title": "Legal pages",
"hint": "Both texts are Markdown and appear publicly at /legal/imprint and /legal/privacy — no sign-in required. A privacy-policy template covering Dorfteich's processing ships in docs/self-hosting/legal-template.md.",
"imprint": "Imprint",
"privacyPolicy": "Privacy policy",
"preview": "Show preview",
"hidePreview": "Hide preview",
"save": "Save legal pages"
}
}

View File

@ -10,6 +10,7 @@ export * from './fonts';
export * from './health'; export * from './health';
export * from './i18n-tools'; export * from './i18n-tools';
export * from './labels'; export * from './labels';
export * from './legal';
export * from './links'; export * from './links';
export * from './members'; export * from './members';
export * from './pages'; export * from './pages';

View File

@ -0,0 +1,22 @@
/**
* Instance legal pages (issue #82, security.md §Privacy): imprint and
* privacy policy are instance settings holding Markdown, edited by Site
* Admins and rendered publicly at /legal/<kind> a core feature for
* EU-hosted instances, not an afterthought.
*/
export const LEGAL_KINDS = ['imprint', 'privacy'] as const;
export type LegalKind = (typeof LEGAL_KINDS)[number];
export function isLegalKind(value: string): value is LegalKind {
return (LEGAL_KINDS as readonly string[]).includes(value);
}
/** What `GET /legal/:kind/content` returns; the SPA's legal page renders it. */
export interface LegalPageView {
kind: LegalKind;
/** False while the Site Admin has not provided a text yet. */
configured: boolean;
/** Server-rendered HTML from the stored Markdown; empty when unconfigured. */
html: string;
}