From 9c64166b1078fe067dc37e2cd9a8cf640c0a3fa1 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sun, 12 Jul 2026 23:50:16 +0200 Subject: [PATCH] Editable landing page for the Site Admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1 --- apps/api/src/app.module.ts | 2 + apps/api/src/home/home.controller.ts | 22 +++++ apps/api/src/home/home.e2e.db.test.ts | 81 +++++++++++++++++++ apps/api/src/home/home.module.ts | 14 ++++ apps/api/src/home/home.service.ts | 23 ++++++ .../src/settings/instance-settings.service.ts | 4 + apps/web/src/pages/AdminSettingsPage.tsx | 49 +++++++++++ apps/web/src/pages/HomePage.tsx | 18 ++++- packages/shared/i18n/de/settings.json | 6 ++ packages/shared/i18n/en/settings.json | 6 ++ packages/shared/src/home.ts | 13 +++ packages/shared/src/index.ts | 1 + 12 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/home/home.controller.ts create mode 100644 apps/api/src/home/home.e2e.db.test.ts create mode 100644 apps/api/src/home/home.module.ts create mode 100644 apps/api/src/home/home.service.ts create mode 100644 packages/shared/src/home.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index dc890f5..1d581c0 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -14,6 +14,7 @@ import { ConfigModule } from './config/config.module'; import { FilesModule } from './files/files.module'; import { GrantsModule } from './grants/grants.module'; import { HealthModule } from './health/health.module'; +import { HomeModule } from './home/home.module'; import { ImportExportModule } from './import-export/import-export.module'; import { LabelsModule } from './labels/labels.module'; import { LegalModule } from './legal/legal.module'; @@ -66,6 +67,7 @@ import { VersionsModule } from './versions/versions.module'; VersionsModule, LabelsModule, LegalModule, + HomeModule, LinksModule, SearchModule, GrantsModule, diff --git a/apps/api/src/home/home.controller.ts b/apps/api/src/home/home.controller.ts new file mode 100644 index 0000000..cadb295 --- /dev/null +++ b/apps/api/src/home/home.controller.ts @@ -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 { + return this.home.content(); + } +} diff --git a/apps/api/src/home/home.e2e.db.test.ts b/apps/api/src/home/home.e2e.db.test.ts new file mode 100644 index 0000000..2515fe8 --- /dev/null +++ b/apps/api/src/home/home.e2e.db.test.ts @@ -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** ' }) + .expect(200); + + const res = await api().get('/api/v1/home/content').expect(200); + expect(res.body.configured).toBe(true); + expect(res.body.html).toContain('

Welcome

'); + expect(res.body.html).toContain('wiki'); + expect(res.body.html).not.toContain('