Editable landing page for the Site Admin
Some checks failed
CD / Build and push images (push) Successful in 3m50s
CD / Deploy to Test (push) Successful in 10s
CI / Lint, typecheck, test (push) Successful in 4m13s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Failing after 2m44s
CI / Import/export fidelity gate (push) Has been skipped

The public home page (/) now renders Markdown the Site Admin stores in
the new home.content instance setting, through the same sanitizing
pipeline as the legal pages; empty falls back to the built-in welcome
text. New public GET /home/content, an Admin → Settings editor with
live preview, and an e2e test covering default/configured/escaping/
admin-only.

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-12 23:50:16 +02:00
parent 79376bcdd1
commit 9c64166b10
12 changed files with 238 additions and 1 deletions

View File

@ -14,6 +14,7 @@ import { ConfigModule } from './config/config.module';
import { FilesModule } from './files/files.module'; import { FilesModule } from './files/files.module';
import { GrantsModule } from './grants/grants.module'; import { GrantsModule } from './grants/grants.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { HomeModule } from './home/home.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 { LegalModule } from './legal/legal.module';
@ -66,6 +67,7 @@ import { VersionsModule } from './versions/versions.module';
VersionsModule, VersionsModule,
LabelsModule, LabelsModule,
LegalModule, LegalModule,
HomeModule,
LinksModule, LinksModule,
SearchModule, SearchModule,
GrantsModule, GrantsModule,

View File

@ -0,0 +1,22 @@
import { Controller, Get } from '@nestjs/common';
import { HomeContentView } from '@dorfteich/shared';
import { Public } from '../auth/auth.guard';
import { HomeService } from './home.service';
/**
* Public landing-page content (the editable home page). `@Public()` the
* home page is reachable without a session. The Site Admin edits the
* underlying `home.content` setting through Admin Settings (PATCH
* /admin/settings), same as the legal texts.
*/
@Controller('home')
export class HomeController {
constructor(private readonly home: HomeService) {}
@Get('content')
@Public()
content(): Promise<HomeContentView> {
return this.home.content();
}
}

View File

@ -0,0 +1,81 @@
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';
/**
* Editable landing page end to end: the home body defaults to unconfigured,
* a Site Admin sets it through the generic settings PATCH, anonymous visitors
* read the rendered HTML, and stored Markdown can never smuggle script in.
*/
describe.skipIf(!hasTestDb)('landing page (e2e)', () => {
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: 'home.content' } });
app = await createTestApp();
const users = app.get(UsersService);
const admin = await users.createUser({
username: `home-admin-${suffix}`,
email: `home-admin-${suffix}@example.org`,
displayName: 'Home 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: 'home.content' } });
await prisma.$disconnect();
await app.close();
});
it('reports unconfigured content by default, without a session', async () => {
const res = await api().get('/api/v1/home/content').expect(200);
expect(res.body).toMatchObject({ configured: false, html: '' });
});
it('renders configured Markdown publicly and escapes script', async () => {
await api()
.patch('/api/v1/admin/settings')
.set('Cookie', adminCookie)
.send({ 'home.content': '# Welcome\n\nOur **wiki** <script>alert(1)</script>' })
.expect(200);
const res = await api().get('/api/v1/home/content').expect(200);
expect(res.body.configured).toBe(true);
expect(res.body.html).toContain('<h1>Welcome</h1>');
expect(res.body.html).toContain('<strong>wiki</strong>');
expect(res.body.html).not.toContain('<script>');
expect(res.body.html).toContain('&lt;script&gt;');
});
it('keeps the setting admin-only', async () => {
await api()
.patch('/api/v1/admin/settings')
.send({ 'home.content': 'anonymous edit' })
.expect(401);
});
});

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { HomeController } from './home.controller';
import { HomeService } from './home.service';
/**
* Editable landing page: the public home body from instance_settings
* (global SettingsModule), rendered publicly at GET /home/content.
*/
@Module({
controllers: [HomeController],
providers: [HomeService],
})
export class HomeModule {}

View File

@ -0,0 +1,23 @@
import { Injectable } from '@nestjs/common';
import { HomeContentView, docToHtml, markdownToDoc } from '@dorfteich/shared';
import { InstanceSettingsService } from '../settings/instance-settings.service';
/**
* Editable landing page (the public home page `/`): the Site Admin's Markdown
* from `instance_settings.home.content`, rendered through the sanitizing
* editor pipeline (markdown schema doc escaped HTML, security.md
* §Content) exactly like the legal pages arbitrary HTML or scripts in the
* setting can never reach visitors. Empty content = the SPA shows its
* built-in welcome text instead.
*/
@Injectable()
export class HomeService {
constructor(private readonly settings: InstanceSettingsService) {}
async content(): Promise<HomeContentView> {
const markdown = await this.settings.get('home.content');
const configured = markdown.trim().length > 0;
return { configured, html: configured ? docToHtml(markdownToDoc(markdown)) : '' };
}
}

View File

@ -81,6 +81,10 @@ export const INSTANCE_SETTINGS = {
// Site Admins a warning banner instead of silently missing). // Site Admins a warning banner instead of silently missing).
'legal.imprint': z.string().max(100_000).default(''), 'legal.imprint': z.string().max(100_000).default(''),
'legal.privacyPolicy': z.string().max(100_000).default(''), 'legal.privacyPolicy': z.string().max(100_000).default(''),
// Landing-page body: the Site Admin's Markdown for the public home page
// (`/`), rendered through the same sanitizing pipeline as the legal pages.
// Empty = the built-in default welcome text is shown instead.
'home.content': 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

@ -26,6 +26,7 @@ interface InstanceSettings {
'upload.svgPolicy': 'reject' | 'sanitize'; 'upload.svgPolicy': 'reject' | 'sanitize';
'legal.imprint': string; 'legal.imprint': string;
'legal.privacyPolicy': string; 'legal.privacyPolicy': string;
'home.content': string;
} }
export function AdminSettingsPage(): React.JSX.Element { export function AdminSettingsPage(): React.JSX.Element {
@ -111,6 +112,7 @@ export function AdminSettingsPage(): React.JSX.Element {
<UploadSettingsForm settings={settings.data} /> <UploadSettingsForm settings={settings.data} />
<PublicApiSettingsForm settings={settings.data} /> <PublicApiSettingsForm settings={settings.data} />
<LandingSettingsForm settings={settings.data} />
<LegalSettingsForm settings={settings.data} /> <LegalSettingsForm settings={settings.data} />
<PluginManager /> <PluginManager />
@ -235,6 +237,53 @@ function PublicApiSettingsForm({ settings }: { settings: InstanceSettings }): Re
); );
} }
/**
* Editable landing page: the Site Admin's Markdown for the public home page
* (`/`), rendered through the same sanitizing pipeline as the legal pages.
* Empty falls back to the built-in welcome text.
*/
function LandingSettingsForm({ settings }: { settings: InstanceSettings }): React.JSX.Element {
const { t } = useTranslation('settings');
const { t: tLegal } = useTranslation('legal');
const queryClient = useQueryClient();
const [content, setContent] = useState(settings['home.content']);
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', { 'home.content': content });
await queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] });
await queryClient.invalidateQueries({ queryKey: ['home-content'] });
setSaved(true);
} catch (err) {
setError(err);
} finally {
setBusy(false);
}
}
return (
<section className="settings-section">
<h2>{t('landing.title')}</h2>
<p className="field__hint">{t('landing.hint')}</p>
<form onSubmit={(event) => void onSubmit(event)} noValidate>
<FormError error={error} />
<FormSuccess message={saved ? t('landing.saved') : null} />
<LegalTextField label={t('landing.label')} value={content} onChange={setContent} />
<button type="submit" className="button" disabled={busy}>
{tLegal('admin.save')}
</button>
</form>
</section>
);
}
/** /**
* Legal pages (issue #82): imprint and privacy policy as Markdown, shown * Legal pages (issue #82): imprint and privacy policy as Markdown, shown
* publicly at /legal/imprint and /legal/privacy. The preview renders through * publicly at /legal/imprint and /legal/privacy. The preview renders through

View File

@ -1,12 +1,28 @@
import { HomeContentView } from '@dorfteich/shared';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { fetchHealth } from '../lib/api'; import { apiGet, fetchHealth } from '../lib/api';
/**
* Public landing page (`/`). When a Site Admin has written landing content
* (Admin Settings Landing page), it renders that server-sanitized
* HTML from the same Markdown pipeline as the legal pages. Otherwise it
* shows the built-in welcome text and an instance liveness pill.
*/
export function HomePage(): React.JSX.Element { export function HomePage(): React.JSX.Element {
const { t } = useTranslation(); const { t } = useTranslation();
const home = useQuery({
queryKey: ['home-content'],
queryFn: () => apiGet<HomeContentView>('/home/content'),
});
const health = useQuery({ queryKey: ['healthz'], queryFn: fetchHealth, retry: 1 }); const health = useQuery({ queryKey: ['healthz'], queryFn: fetchHealth, retry: 1 });
if (home.data?.configured) {
// Server-rendered through the sanitizing editor pipeline — safe.
return <article className="home-page" dangerouslySetInnerHTML={{ __html: home.data.html }} />;
}
return ( return (
<> <>
<h1>{t('home.title')}</h1> <h1>{t('home.title')}</h1>

View File

@ -45,5 +45,11 @@
"registrationClosed": "Geschlossen — keine neuen Registrierungen", "registrationClosed": "Geschlossen — keine neuen Registrierungen",
"save": "Speichern", "save": "Speichern",
"saved": "Gespeichert." "saved": "Gespeichert."
},
"landing": {
"title": "Startseite",
"hint": "Inhalt der öffentlichen Startseite (/) als Markdown. Leer lassen zeigt den eingebauten Willkommenstext.",
"label": "Startseiten-Inhalt (Markdown)",
"saved": "Gespeichert."
} }
} }

View File

@ -45,5 +45,11 @@
"registrationClosed": "Closed — no new registrations", "registrationClosed": "Closed — no new registrations",
"save": "Save", "save": "Save",
"saved": "Saved." "saved": "Saved."
},
"landing": {
"title": "Landing page",
"hint": "The public home page (/) content as Markdown. Leave empty to show the built-in welcome text.",
"label": "Landing page content (Markdown)",
"saved": "Saved."
} }
} }

View File

@ -0,0 +1,13 @@
/**
* Editable landing page: the public home page (`/`) renders Markdown the
* Site Admin stores in the `home.content` instance setting, through the same
* sanitizing pipeline as the legal pages. Empty = the built-in welcome text.
*/
/** What `GET /home/content` returns; the SPA's home page renders it. */
export interface HomeContentView {
/** False while the Site Admin has not provided a text — show the default. */
configured: boolean;
/** Server-rendered HTML from the stored Markdown; empty when unconfigured. */
html: string;
}

View File

@ -12,6 +12,7 @@ export * from './conversion';
export * from './files'; export * from './files';
export * from './fonts'; export * from './fonts';
export * from './health'; export * from './health';
export * from './home';
export * from './i18n-tools'; export * from './i18n-tools';
export * from './labels'; export * from './labels';
export * from './legal'; export * from './legal';