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
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:
parent
28aa04d5e4
commit
fd2bdb3fb8
@ -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%';" | \
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'],
|
||||
|
||||
41
apps/api/src/legal/legal.controller.ts
Normal file
41
apps/api/src/legal/legal.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
114
apps/api/src/legal/legal.e2e.db.test.ts
Normal file
114
apps/api/src/legal/legal.e2e.db.test.ts
Normal 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 (<script>…), never as actual tags.
|
||||
expect(json.body.html).not.toContain('<script>');
|
||||
expect(json.body.html).not.toContain('<img');
|
||||
expect(json.body.html).toContain('<script>');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
14
apps/api/src/legal/legal.module.ts
Normal file
14
apps/api/src/legal/legal.module.ts
Normal 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 {}
|
||||
47
apps/api/src/legal/legal.service.ts
Normal file
47
apps/api/src/legal/legal.service.ts
Normal 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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
59
apps/api/src/public/html-shell.ts
Normal file
59
apps/api/src/public/html-shell.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
@ -4,6 +4,8 @@ import { Pond, User } from '@prisma/client';
|
||||
import { PermissionService } from '../permissions/permission.service';
|
||||
import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer';
|
||||
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. */
|
||||
export interface PublicPageContent {
|
||||
@ -35,6 +37,7 @@ export class PublicService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionService,
|
||||
private readonly fallbacks: PluginFallbackRenderer,
|
||||
private readonly settings: InstanceSettingsService,
|
||||
) {}
|
||||
|
||||
private async resolve(
|
||||
@ -82,34 +85,17 @@ export class PublicService {
|
||||
canonical: string,
|
||||
): Promise<string> {
|
||||
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
|
||||
// who may read the page (crawler-safe, cacheable).
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${title}</title>
|
||||
<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>
|
||||
// who may read the page (crawler-safe, cacheable). The shared shell adds
|
||||
// the legal footer links (issue #82) in the instance default locale.
|
||||
return htmlDocument({
|
||||
lang: await this.settings.get('instance.defaultLocale'),
|
||||
title: `${content.title} — ${content.pondName}`,
|
||||
canonical,
|
||||
bodyHtml: `<p class="public-page__pond">${escapeHtml(content.pondName)}</p>
|
||||
<h1>${escapeHtml(content.title)}</h1>
|
||||
${content.html}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
${content.html}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -126,13 +112,3 @@ function resolveMediaUrls(html: string): string {
|
||||
'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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
@ -48,6 +48,12 @@ export const INSTANCE_SETTINGS = {
|
||||
// SVG upload handling (security.md §Uploads): sanitize strips scripts and
|
||||
// event handlers with a maintained library; reject refuses SVG outright.
|
||||
'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
|
||||
// instance still requires setup and only /setup/* is reachable; once set
|
||||
// the wizard is locked for good (SetupStateService). Written by the wizard,
|
||||
|
||||
74
apps/web/e2e/legal.spec.ts
Normal file
74
apps/web/e2e/legal.spec.ts
Normal 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();
|
||||
});
|
||||
@ -5,6 +5,7 @@ import { AppLayout } from './layout/AppLayout';
|
||||
import { AdminSettingsPage } from './pages/AdminSettingsPage';
|
||||
import { FontCatalogPage } from './pages/FontCatalogPage';
|
||||
import { HomePage } from './pages/HomePage';
|
||||
import { LegalPage } from './pages/LegalPage';
|
||||
import { NotFoundPage } from './pages/NotFoundPage';
|
||||
import { PageEditorPage } from './pages/PageEditorPage';
|
||||
import { PluginPreviewPage } from './pages/PluginPreviewPage';
|
||||
@ -58,6 +59,8 @@ export function App(): React.JSX.Element {
|
||||
<Route path="reset-password" element={<ResetPasswordPage />} />
|
||||
{/* Public read-only page view — reachable without a session (issue #56). */}
|
||||
<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. */}
|
||||
<Route path="setup" element={<Navigate to="/" replace />} />
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ import deFiles from '@dorfteich/shared/i18n/de/files.json';
|
||||
import deFont from '@dorfteich/shared/i18n/de/font.json';
|
||||
import deImport from '@dorfteich/shared/i18n/de/import.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 deMembers from '@dorfteich/shared/i18n/de/members.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 enImport from '@dorfteich/shared/i18n/en/import.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 enMembers from '@dorfteich/shared/i18n/en/members.json';
|
||||
import enPlugins from '@dorfteich/shared/i18n/en/plugins.json';
|
||||
@ -63,6 +65,7 @@ void i18n
|
||||
font: enFont,
|
||||
import: enImport,
|
||||
labels: enLabels,
|
||||
legal: enLegal,
|
||||
links: enLinks,
|
||||
members: enMembers,
|
||||
plugins: enPlugins,
|
||||
@ -84,6 +87,7 @@ void i18n
|
||||
font: deFont,
|
||||
import: deImport,
|
||||
labels: deLabels,
|
||||
legal: deLegal,
|
||||
links: deLinks,
|
||||
members: deMembers,
|
||||
plugins: dePlugins,
|
||||
|
||||
@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
import { usePersistentState } from '../lib/use-persistent-state';
|
||||
import { Footer } from './Footer';
|
||||
import { SidebarChromeContext } from './sidebar-chrome';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { TopBar } from './TopBar';
|
||||
@ -35,6 +36,7 @@ export function AppLayout(): React.JSX.Element {
|
||||
<Sidebar collapsed={collapsed} />
|
||||
<main className="main">
|
||||
<Outlet />
|
||||
<Footer />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
18
apps/web/src/layout/Footer.tsx
Normal file
18
apps/web/src/layout/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { docToHtml, markdownToDoc } from '@dorfteich/shared';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@ -20,6 +21,8 @@ interface InstanceSettings {
|
||||
'quota.maxFileBytes': number;
|
||||
'upload.allowedExtensions': string[];
|
||||
'upload.svgPolicy': 'reject' | 'sanitize';
|
||||
'legal.imprint': string;
|
||||
'legal.privacyPolicy': string;
|
||||
}
|
||||
|
||||
export function AdminSettingsPage(): React.JSX.Element {
|
||||
@ -101,6 +104,7 @@ export function AdminSettingsPage(): React.JSX.Element {
|
||||
</section>
|
||||
|
||||
<UploadSettingsForm settings={settings.data} />
|
||||
<LegalSettingsForm settings={settings.data} />
|
||||
|
||||
<PluginManager />
|
||||
<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. */
|
||||
const SETTING_TO_QUOTA_KEY = {
|
||||
'quota.editorsPerPond': 'editors_per_pond',
|
||||
|
||||
47
apps/web/src/pages/LegalPage.tsx
Normal file
47
apps/web/src/pages/LegalPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -371,6 +371,42 @@ button {
|
||||
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) */
|
||||
.setup-shell {
|
||||
min-height: 100vh;
|
||||
|
||||
211
docs/self-hosting/legal-template.md
Normal file
211
docs/self-hosting/legal-template.md
Normal 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].
|
||||
```
|
||||
22
packages/shared/i18n/de/legal.json
Normal file
22
packages/shared/i18n/de/legal.json
Normal 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"
|
||||
}
|
||||
}
|
||||
22
packages/shared/i18n/en/legal.json
Normal file
22
packages/shared/i18n/en/legal.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,7 @@ export * from './fonts';
|
||||
export * from './health';
|
||||
export * from './i18n-tools';
|
||||
export * from './labels';
|
||||
export * from './legal';
|
||||
export * from './links';
|
||||
export * from './members';
|
||||
export * from './pages';
|
||||
|
||||
22
packages/shared/src/legal.ts
Normal file
22
packages/shared/src/legal.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user