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
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:
parent
79376bcdd1
commit
9c64166b10
@ -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,
|
||||||
|
|||||||
22
apps/api/src/home/home.controller.ts
Normal file
22
apps/api/src/home/home.controller.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
81
apps/api/src/home/home.e2e.db.test.ts
Normal file
81
apps/api/src/home/home.e2e.db.test.ts
Normal 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('<script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the setting admin-only', async () => {
|
||||||
|
await api()
|
||||||
|
.patch('/api/v1/admin/settings')
|
||||||
|
.send({ 'home.content': 'anonymous edit' })
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
14
apps/api/src/home/home.module.ts
Normal file
14
apps/api/src/home/home.module.ts
Normal 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 {}
|
||||||
23
apps/api/src/home/home.service.ts
Normal file
23
apps/api/src/home/home.service.ts
Normal 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)) : '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
packages/shared/src/home.ts
Normal file
13
packages/shared/src/home.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user