From d73b120d06fe087c676eca6baebfa39c13c2352f Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Mon, 20 Jul 2026 00:49:52 +0200 Subject: [PATCH 1/3] #148: Seitenlisten filtern nach createdSince/updatedSince MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neues pageListQuerySchema (ISO 8601, Kulanz für Datum ohne Zeit), Query-Parameter auf interner und Public-API-Seitenliste, Prisma-where mit gte; neue Indizes (pondId, createdAt)/(pondId, updatedAt) als Migration. OpenAPI-Parameter, MCP-Parität (list_pages created_since/updated_since), Doku (api-guide, mcp-guide, public-api.md), DB-Test inkl. 400 bei ungültigem Datum. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC --- .../migration.sql | 5 +++ apps/api/prisma/schema.prisma | 4 ++ apps/api/src/mcp/mcp-tools.ts | 33 ++++++++++++-- apps/api/src/mcp/mcp.service.ts | 12 ++++- apps/api/src/pages/pages.controller.ts | 10 ++++- apps/api/src/pages/pages.service.ts | 18 ++++++-- apps/api/src/public-api/openapi.ts | 18 +++++++- .../src/public-api/public-api.controller.ts | 10 ++++- .../src/public-api/public-api.e2e.db.test.ts | 44 +++++++++++++++++++ apps/api/src/public-api/public-api.service.ts | 9 +++- docs/de/manual/api-guide.md | 3 ++ docs/de/manual/mcp-guide.md | 2 +- docs/manual/api-guide.md | 3 ++ docs/manual/mcp-guide.md | 2 +- docs/self-hosting/public-api.md | 2 +- packages/shared/src/public-api.ts | 15 +++++++ 16 files changed, 174 insertions(+), 16 deletions(-) create mode 100644 apps/api/prisma/migrations/20260719223755_page_time_filter_indexes/migration.sql diff --git a/apps/api/prisma/migrations/20260719223755_page_time_filter_indexes/migration.sql b/apps/api/prisma/migrations/20260719223755_page_time_filter_indexes/migration.sql new file mode 100644 index 0000000..6098cd9 --- /dev/null +++ b/apps/api/prisma/migrations/20260719223755_page_time_filter_indexes/migration.sql @@ -0,0 +1,5 @@ +-- CreateIndex +CREATE INDEX "pages_pond_id_created_at_idx" ON "pages"("pond_id", "created_at"); + +-- CreateIndex +CREATE INDEX "pages_pond_id_updated_at_idx" ON "pages"("pond_id", "updated_at"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index c316ff6..0175645 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -291,6 +291,10 @@ model Page { @@unique([pondId, slug]) @@index([pondId]) @@index([parentId]) + // Time-filtered listings (issue #148): "pages of this pond created/updated + // since X" hit these instead of scanning the pond. + @@index([pondId, createdAt]) + @@index([pondId, updatedAt]) @@map("pages") } diff --git a/apps/api/src/mcp/mcp-tools.ts b/apps/api/src/mcp/mcp-tools.ts index f2182fc..b748ae7 100644 --- a/apps/api/src/mcp/mcp-tools.ts +++ b/apps/api/src/mcp/mcp-tools.ts @@ -12,9 +12,21 @@ import { z } from 'zod'; const pond = z.string().min(1); const page = z.string().min(1); +/** ISO 8601 instant for `…_since` filters (issue #148). */ +const sinceInstant = z + .string() + .trim() + .regex(/^\d{4}-\d{2}-\d{2}([T ].+)?$/) + .refine((value) => !Number.isNaN(Date.parse(value))) + .transform((value) => new Date(value)); + export const MCP_TOOL_INPUTS = { list_ponds: z.object({}), - list_pages: z.object({ pond }), + list_pages: z.object({ + pond, + created_since: sinceInstant.optional(), + updated_since: sinceInstant.optional(), + }), read_page: z.object({ pond, page }), search: z.object({ query: z.string().min(1), @@ -68,8 +80,23 @@ export const MCP_TOOL_DEFINITIONS: { name: 'list_pages', description: 'List the readable pages of a pond: slug, title, parent (the page tree), labels, ' + - 'timestamps.', - inputSchema: { type: 'object', properties: { pond: pondProp }, required: ['pond'] }, + 'timestamps. Optional created_since/updated_since (ISO 8601) narrow to pages ' + + 'created/changed at or after that instant.', + inputSchema: { + type: 'object', + properties: { + pond: pondProp, + created_since: { + type: 'string', + description: 'ISO 8601 instant — only pages created at/after it', + }, + updated_since: { + type: 'string', + description: 'ISO 8601 instant — only pages updated at/after it', + }, + }, + required: ['pond'], + }, }, { name: 'read_page', diff --git a/apps/api/src/mcp/mcp.service.ts b/apps/api/src/mcp/mcp.service.ts index 91b861b..d1bb66b 100644 --- a/apps/api/src/mcp/mcp.service.ts +++ b/apps/api/src/mcp/mcp.service.ts @@ -97,9 +97,17 @@ export class McpService { case 'list_ponds': return asJson(await this.publicApi.listPonds(user, token, 'mcp')); - case 'list_pages': + case 'list_pages': { await this.assertPondExposed(input.pond!, token); - return asJson(await this.publicApi.listPages(user, input.pond!)); + // The zod input already turned the `…_since` strings into Dates (#148). + const since = args as { created_since?: Date; updated_since?: Date }; + return asJson( + await this.publicApi.listPages(user, input.pond!, { + createdSince: since.created_since, + updatedSince: since.updated_since, + }), + ); + } case 'read_page': await this.assertPondExposed(input.pond!, token); diff --git a/apps/api/src/pages/pages.controller.ts b/apps/api/src/pages/pages.controller.ts index c81f6f3..e1f2449 100644 --- a/apps/api/src/pages/pages.controller.ts +++ b/apps/api/src/pages/pages.controller.ts @@ -24,6 +24,7 @@ import { UpdatePageInput, createPageInputSchema, pageDeleteQuerySchema, + pageListQuerySchema, repositionPageInputSchema, updatePageInputSchema, } from '@dorfteich/shared'; @@ -57,9 +58,16 @@ export class PagesController { @RequiresPondRole('reader', { idParam: 'pondId' }) // the service filters per page async list( @Param('pondId') pondId: string, + @Query('createdSince') createdSince: string | undefined, + @Query('updatedSince') updatedSince: string | undefined, @Req() request: AuthedRequest, ): Promise { - return this.pages.list(request.user!, pondId); + // Optional time filters (issue #148); an invalid instant → 400. + const query = new ZodValidationPipe(pageListQuerySchema).transform({ + createdSince: createdSince || undefined, + updatedSince: updatedSince || undefined, + }); + return this.pages.list(request.user!, pondId, query); } @Get('pages/:id') diff --git a/apps/api/src/pages/pages.service.ts b/apps/api/src/pages/pages.service.ts index 7b80284..f118813 100644 --- a/apps/api/src/pages/pages.service.ts +++ b/apps/api/src/pages/pages.service.ts @@ -11,6 +11,7 @@ import { OutlineEntry, PageDeleteMode, PageListItemView, + PageListQuery, PageStateView, PageView, PluginPageSummary, @@ -151,13 +152,24 @@ export class PagesService { /** Sidebar page list, ordered per the pond's persisted sort mode (issue #26), * each with its assigned label ids for chips and filtering (issue #44). * Filtered to the pages the user may read (issue #52) — a label- or - * page-scoped reader sees only their slice of the pond. */ - async list(user: User, pondId: string): Promise { + * page-scoped reader sees only their slice of the pond. Optional time + * filters (issue #148) narrow to pages created/updated at or after an + * instant. */ + async list( + user: User | null, + pondId: string, + query?: PageListQuery, + ): Promise { const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); if (!pond) throw new NotFoundException(); const settings = pondSettingsSchema.parse(pond.settings ?? {}); const pages = await this.prisma.page.findMany({ - where: { pondId, deletedAt: null }, + where: { + pondId, + deletedAt: null, + ...(query?.createdSince ? { createdAt: { gte: query.createdSince } } : {}), + ...(query?.updatedSince ? { updatedAt: { gte: query.updatedSince } } : {}), + }, orderBy: PagesService.SORT_ORDER[settings.sidebarSort], include: { labels: { select: { labelId: true } } }, }); diff --git a/apps/api/src/public-api/openapi.ts b/apps/api/src/public-api/openapi.ts index 24b142c..559ee87 100644 --- a/apps/api/src/public-api/openapi.ts +++ b/apps/api/src/public-api/openapi.ts @@ -273,7 +273,23 @@ export function buildOpenApiDocument(): object { '/ponds/{pondSlug}/pages': { get: { summary: 'Readable pages of the pond.', - parameters: [pondParam], + parameters: [ + pondParam, + { + name: 'createdSince', + in: 'query', + required: false, + schema: { type: 'string', format: 'date-time' }, + description: 'Only pages created at or after this ISO 8601 instant (issue #148).', + }, + { + name: 'updatedSince', + in: 'query', + required: false, + schema: { type: 'string', format: 'date-time' }, + description: 'Only pages updated at or after this ISO 8601 instant (issue #148).', + }, + ], responses: { '200': jsonResponse('Pages', { type: 'array', items: ref('PageListItem') }), }, diff --git a/apps/api/src/public-api/public-api.controller.ts b/apps/api/src/public-api/public-api.controller.ts index 9ac57f1..200be74 100644 --- a/apps/api/src/public-api/public-api.controller.ts +++ b/apps/api/src/public-api/public-api.controller.ts @@ -17,6 +17,7 @@ import { commentListQuerySchema, createCommentInputSchema, createLabelInputSchema, + pageListQuerySchema, publicCreatePageInputSchema, publicSearchQuerySchema, publicUpdateLabelInputSchema, @@ -88,9 +89,16 @@ export class PublicApiController { @RequiresPondRole('reader', POND) listPages( @Param('pondSlug') pondSlug: string, + @Query('createdSince') createdSince: string | undefined, + @Query('updatedSince') updatedSince: string | undefined, @Req() request: PublicApiRequest, ): Promise { - return this.publicApi.listPages(request.user!, pondSlug); + // Optional time filters (issue #148); an invalid instant → 400. + const query = new ZodValidationPipe(pageListQuerySchema).transform({ + createdSince: createdSince || undefined, + updatedSince: updatedSince || undefined, + }); + return this.publicApi.listPages(request.user!, pondSlug, query); } @Post('ponds/:pondSlug/pages') diff --git a/apps/api/src/public-api/public-api.e2e.db.test.ts b/apps/api/src/public-api/public-api.e2e.db.test.ts index b8b91f6..704c81b 100644 --- a/apps/api/src/public-api/public-api.e2e.db.test.ts +++ b/apps/api/src/public-api/public-api.e2e.db.test.ts @@ -460,6 +460,50 @@ describe.skipIf(!hasTestDb)('public api v1 (e2e, issue #104)', () => { .expect(404); }); + it('filters the page list by createdSince/updatedSince (issue #148)', async () => { + const old = await pub() + .post(`/api/public/v1/ponds/${pondSlug}/pages`) + .set('Authorization', bearer('editor')) + .send({ title: `Since Old ${suffix}` }) + .expect(201); + const fresh = await pub() + .post(`/api/public/v1/ponds/${pondSlug}/pages`) + .set('Authorization', bearer('editor')) + .send({ title: `Since Fresh ${suffix}` }) + .expect(201); + const oldSlug = (old.body as PublicPageView).slug; + const freshSlug = (fresh.body as PublicPageView).slug; + // Backdate the old page below the cutoff (raw row: timestamps only). + await prisma.page.updateMany({ + where: { pondId, slug: oldSlug }, + data: { + createdAt: new Date('2000-01-01T00:00:00Z'), + updatedAt: new Date('2000-01-02T00:00:00Z'), + }, + }); + + for (const param of ['createdSince', 'updatedSince'] as const) { + const filtered = await pub() + .get(`/api/public/v1/ponds/${pondSlug}/pages?${param}=2020-01-01T00:00:00Z`) + .set('Authorization', bearer('reader')) + .expect(200); + const slugs = (filtered.body as PublicPageListItemView[]).map((p) => p.slug); + expect(slugs).toContain(freshSlug); + expect(slugs).not.toContain(oldSlug); + } + + // Unfiltered, both are there; an invalid instant is a 400. + const all = await pub() + .get(`/api/public/v1/ponds/${pondSlug}/pages`) + .set('Authorization', bearer('reader')) + .expect(200); + expect((all.body as PublicPageListItemView[]).map((p) => p.slug)).toContain(oldSlug); + await pub() + .get(`/api/public/v1/ponds/${pondSlug}/pages?updatedSince=not-a-date`) + .set('Authorization', bearer('reader')) + .expect(400); + }); + it('manages labels with pond-admin rights and assigns them to pages', async () => { const label = await pub() .post(`/api/public/v1/ponds/${pondSlug}/labels`) diff --git a/apps/api/src/public-api/public-api.service.ts b/apps/api/src/public-api/public-api.service.ts index 8dc0382..d4a0fce 100644 --- a/apps/api/src/public-api/public-api.service.ts +++ b/apps/api/src/public-api/public-api.service.ts @@ -7,6 +7,7 @@ import { pondFeatureEnabled, pondSettingsSchema, type CommentListFilter, + type PageListQuery, type CreateCommentInput, type CreateLabelInput, type LabelTreeNode, @@ -93,10 +94,14 @@ export class PublicApiService { return this.pondView(pond); } - async listPages(user: User, pondSlug: string): Promise { + async listPages( + user: User, + pondSlug: string, + query?: PageListQuery, + ): Promise { const pond = await this.requirePond(pondSlug); const [items, labelNames] = await Promise.all([ - this.pages.list(user, pond.id), + this.pages.list(user, pond.id, query), this.labelNames(pond.id), ]); // parentId is already permission-nulled by the list (#106); mapping it diff --git a/docs/de/manual/api-guide.md b/docs/de/manual/api-guide.md index 894cf85..6f0150e 100644 --- a/docs/de/manual/api-guide.md +++ b/docs/de/manual/api-guide.md @@ -52,6 +52,9 @@ curl -H "$AUTH" https://wiki.example.com/api/public/v1/ponds # Seiten eines Teichs: Slug, Titel, Parent (Seitenbaum-Slug), Labels, Zeitstempel curl -H "$AUTH" https://wiki.example.com/api/public/v1/ponds/team/pages +# Nur Seiten, die seit einem ISO-8601-Zeitpunkt erstellt/geändert wurden (#148) +curl -H "$AUTH" "https://wiki.example.com/api/public/v1/ponds/team/pages?updatedSince=2026-07-01T00:00:00Z" + # Eine Seite — Markdown-Quelle UND gerendertes, bereinigtes HTML curl -H "$AUTH" https://wiki.example.com/api/public/v1/ponds/team/pages/meeting-notes diff --git a/docs/de/manual/mcp-guide.md b/docs/de/manual/mcp-guide.md index b792e30..b9f6b45 100644 --- a/docs/de/manual/mcp-guide.md +++ b/docs/de/manual/mcp-guide.md @@ -65,7 +65,7 @@ Stdio-Clients überbrücken mit `mcp-remote`: | Tool | Tut | | ----------------------------------------------------- | -------------------------------------------------- | | `list_ponds` | die Teiche, die dieses Token erreicht | -| `list_pages(pond)` | Seiten mit Slug, Titel, Parent, Labels | +| `list_pages(pond, created_since?, updated_since?)` | Seiten mit Slug, Titel, Parent, Labels | | `read_page(pond, page)` | eine Seite als Markdown plus Metadaten | | `search(query, pond?, label?)` | Volltextsuche mit Snippets | | `create_page(pond, title, markdown, parent?)` | neue Seite aus Markdown _(write)_ | diff --git a/docs/manual/api-guide.md b/docs/manual/api-guide.md index 180166e..d1632f1 100644 --- a/docs/manual/api-guide.md +++ b/docs/manual/api-guide.md @@ -49,6 +49,9 @@ curl -H "$AUTH" https://wiki.example.com/api/public/v1/ponds # Pages of a pond: slug, title, parent (page-tree slug), labels, timestamps curl -H "$AUTH" https://wiki.example.com/api/public/v1/ponds/team/pages +# Only pages created/updated at or after an ISO 8601 instant (issue #148) +curl -H "$AUTH" "https://wiki.example.com/api/public/v1/ponds/team/pages?updatedSince=2026-07-01T00:00:00Z" + # One page — Markdown source AND rendered, sanitized HTML curl -H "$AUTH" https://wiki.example.com/api/public/v1/ponds/team/pages/meeting-notes diff --git a/docs/manual/mcp-guide.md b/docs/manual/mcp-guide.md index d6654f5..d5bee02 100644 --- a/docs/manual/mcp-guide.md +++ b/docs/manual/mcp-guide.md @@ -64,7 +64,7 @@ clients bridge with `mcp-remote`: | Tool | Does | | ----------------------------------------------------- | ------------------------------------------- | | `list_ponds` | the ponds this token can reach | -| `list_pages(pond)` | pages with slug, title, parent, labels | +| `list_pages(pond, created_since?, updated_since?)` | pages with slug, title, parent, labels | | `read_page(pond, page)` | a page as Markdown plus metadata | | `search(query, pond?, label?)` | full-text search with snippets | | `create_page(pond, title, markdown, parent?)` | new page from Markdown _(write)_ | diff --git a/docs/self-hosting/public-api.md b/docs/self-hosting/public-api.md index 5089bef..51c2087 100644 --- a/docs/self-hosting/public-api.md +++ b/docs/self-hosting/public-api.md @@ -43,7 +43,7 @@ curl -H "Authorization: Bearer dt_pat_..." \ | -------- | ---------------------------------------------------------------------------------------------------------------------------------- | | Identity | `GET /me` | | Ponds | `GET /ponds`, `GET /ponds/{slug}` | -| Pages | `GET/POST /ponds/{slug}/pages`, `GET/PATCH/DELETE /ponds/{slug}/pages/{pageSlug}` | +| Pages | `GET/POST /ponds/{slug}/pages` (list filters `?createdSince=`/`?updatedSince=`), `GET/PATCH/DELETE /ponds/{slug}/pages/{pageSlug}` | | Search | `GET /search?q=&pond=&label=` | | Export | `GET /ponds/{slug}/export/markdown` (ZIP) | | Labels | `GET/POST /ponds/{slug}/labels`, `PATCH/DELETE /ponds/{slug}/labels/{id}`, `PUT/DELETE /ponds/{slug}/pages/{pageSlug}/labels/{id}` | diff --git a/packages/shared/src/public-api.ts b/packages/shared/src/public-api.ts index 482a735..ffea41d 100644 --- a/packages/shared/src/public-api.ts +++ b/packages/shared/src/public-api.ts @@ -92,6 +92,21 @@ export const publicUpdateLabelInputSchema = z .partial(); export type PublicUpdateLabelInput = z.infer; +/** An ISO 8601 instant (date or date-time) for `…Since` filters (issue #148). */ +const sinceInstant = z + .string() + .trim() + .regex(/^\d{4}-\d{2}-\d{2}([T ].+)?$/, 'validation.invalid') + .refine((value) => !Number.isNaN(Date.parse(value)), 'validation.invalid') + .transform((value) => new Date(value)); + +/** Optional time filters for page listings (issue #148), applied as `>=`. */ +export const pageListQuerySchema = z.object({ + createdSince: sinceInstant.optional(), + updatedSince: sinceInstant.optional(), +}); +export type PageListQuery = z.infer; + export const publicSearchQuerySchema = z.object({ q: z.string().trim().min(1, 'validation.required').max(200), pond: z.string().trim().optional(), -- 2.45.2 From 89ffbc0e4da6a8b63cbfc37b1fc44f608e6124ad Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Mon, 20 Jul 2026 00:49:53 +0200 Subject: [PATCH 2/3] =?UTF-8?q?#147:=20Eigene=20Identit=C3=A4t=20in=20der?= =?UTF-8?q?=20API=20klar=20dokumentiert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/public/v1/me existiert bereits — OpenAPI-Summary nennt jetzt ausdrücklich die User-ID, api-guide (en+de) ebenso. MCP war bereits paritätisch (list_ponds + Token-Identität); kein neuer Endpoint nötig. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC --- apps/api/src/public-api/openapi.ts | 4 +++- docs/de/manual/api-guide.md | 4 ++-- docs/manual/api-guide.md | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/api/src/public-api/openapi.ts b/apps/api/src/public-api/openapi.ts index 559ee87..cc0a467 100644 --- a/apps/api/src/public-api/openapi.ts +++ b/apps/api/src/public-api/openapi.ts @@ -251,7 +251,9 @@ export function buildOpenApiDocument(): object { paths: { '/me': { get: { - summary: "The token's user and scope (client smoke test).", + summary: + "The token's user — id, username, display name — plus scope and pond " + + 'restriction. The way to find your own user id (client smoke test).', responses: { '200': jsonResponse('Token identity', ref('Me')) }, }, }, diff --git a/docs/de/manual/api-guide.md b/docs/de/manual/api-guide.md index 6f0150e..4ebb6f2 100644 --- a/docs/de/manual/api-guide.md +++ b/docs/de/manual/api-guide.md @@ -40,8 +40,8 @@ curl -H "Authorization: Bearer dt_pat_..." \ https://wiki.example.com/api/public/v1/me ``` -`GET /me` ist der Smoke-Test — er liefert dein Benutzerkonto, den -Token-Scope und eine etwaige Teich-Beschränkung. +`GET /me` ist der Smoke-Test — er liefert dein Benutzerkonto (samt +deiner User-ID), den Token-Scope und eine etwaige Teich-Beschränkung. ## Lesen diff --git a/docs/manual/api-guide.md b/docs/manual/api-guide.md index d1632f1..d9766ad 100644 --- a/docs/manual/api-guide.md +++ b/docs/manual/api-guide.md @@ -37,8 +37,8 @@ curl -H "Authorization: Bearer dt_pat_..." \ https://wiki.example.com/api/public/v1/me ``` -`GET /me` is the smoke test — it returns your user, the token scope, -and any pond restriction. +`GET /me` is the smoke test — it returns your user (including your +user id), the token scope, and any pond restriction. ## Reading -- 2.45.2 From 7252bd16e066f2d3f657b9c0c306e15d0a6cca88 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Mon, 20 Jul 2026 00:49:53 +0200 Subject: [PATCH 3/3] =?UTF-8?q?#149:=20Atom-Feeds=20f=C3=BCr=20Teiche=20un?= =?UTF-8?q?d=20Seiten,=20privat=20via=20Feed-Token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /public/:pond/feed.xml (zuletzt geänderte Seiten) und GET /public/:pond/:page/feed.xml (Versions-Historie), @Public mit 404-Semantik; öffentliche Teiche anonym, nicht-öffentliche über neues read-only Feed-Token je Nutzer als ?token=dt_feed_… (neue Tabelle feed_tokens + Migration, Verwaltung in den Nutzer-Einstellungen, FeedTokensSection). Öffentliche HTML-Seiten annoncieren den Teich-Feed per link rel=alternate. DB-Tests (anonym/privat/Token-Lifecycle) und User-Guide-Doku en+de. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC --- .../20260719224240_feed_tokens/migration.sql | 20 ++ apps/api/prisma/schema.prisma | 19 ++ apps/api/src/public/feed-tokens.controller.ts | 41 ++++ apps/api/src/public/feed-tokens.service.ts | 74 +++++++ apps/api/src/public/feed.e2e.db.test.ts | 198 ++++++++++++++++++ apps/api/src/public/feed.service.ts | 150 +++++++++++++ apps/api/src/public/html-shell.ts | 15 +- apps/api/src/public/public.controller.ts | 43 +++- apps/api/src/public/public.module.ts | 10 +- apps/api/src/public/public.service.ts | 5 + apps/web/e2e/settings-nav.spec.ts | 4 +- apps/web/src/api-tokens/FeedTokensSection.tsx | 115 ++++++++++ apps/web/src/pages/SettingsPage.tsx | 2 + docs/de/manual/user-guide.md | 15 +- docs/manual/user-guide.md | 14 +- packages/shared/i18n/de/apiTokens.json | 10 + packages/shared/i18n/en/apiTokens.json | 10 + packages/shared/src/feed-tokens.ts | 27 +++ packages/shared/src/index.ts | 1 + 19 files changed, 762 insertions(+), 11 deletions(-) create mode 100644 apps/api/prisma/migrations/20260719224240_feed_tokens/migration.sql create mode 100644 apps/api/src/public/feed-tokens.controller.ts create mode 100644 apps/api/src/public/feed-tokens.service.ts create mode 100644 apps/api/src/public/feed.e2e.db.test.ts create mode 100644 apps/api/src/public/feed.service.ts create mode 100644 apps/web/src/api-tokens/FeedTokensSection.tsx create mode 100644 packages/shared/src/feed-tokens.ts diff --git a/apps/api/prisma/migrations/20260719224240_feed_tokens/migration.sql b/apps/api/prisma/migrations/20260719224240_feed_tokens/migration.sql new file mode 100644 index 0000000..e2ab6a9 --- /dev/null +++ b/apps/api/prisma/migrations/20260719224240_feed_tokens/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "feed_tokens" ( + "id" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "last_used_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "feed_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "feed_tokens_token_hash_key" ON "feed_tokens"("token_hash"); + +-- CreateIndex +CREATE INDEX "feed_tokens_user_id_idx" ON "feed_tokens"("user_id"); + +-- AddForeignKey +ALTER TABLE "feed_tokens" ADD CONSTRAINT "feed_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 0175645..a3b88e4 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -51,6 +51,7 @@ model User { sessions Session[] authTokens AuthToken[] apiTokens ApiToken[] + feedTokens FeedToken[] ponds Pond[] pages Page[] attachments Attachment[] @@ -608,6 +609,24 @@ enum ApiTokenScope { /// user — the whole permission model applies — narrowed by `scope` and the /// optional pond restriction. Revoking keeps the row so the settings UI can /// show history; validation skips revoked/expired rows. +/// Read-only feed authentication (issue #149): a `dt_feed_…` secret carried as +/// a query parameter in Atom feed URLs, so feed readers can subscribe to +/// non-public ponds/pages. Deliberately much narrower than an ApiToken — +/// it can only ever authenticate the two feed endpoints, never the API. +model FeedToken { + id String @id @default(uuid()) + tokenHash String @unique @map("token_hash") + userId String @map("user_id") + name String + lastUsedAt DateTime? @map("last_used_at") + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("feed_tokens") +} + model ApiToken { id String @id @default(uuid()) tokenHash String @unique @map("token_hash") diff --git a/apps/api/src/public/feed-tokens.controller.ts b/apps/api/src/public/feed-tokens.controller.ts new file mode 100644 index 0000000..c78d478 --- /dev/null +++ b/apps/api/src/public/feed-tokens.controller.ts @@ -0,0 +1,41 @@ +import { Body, Controller, Delete, Get, HttpCode, Param, Post, Req } from '@nestjs/common'; +import { + createFeedTokenInputSchema, + type CreateFeedTokenInput, + type FeedTokenCreatedView, + type FeedTokenView, +} from '@dorfteich/shared'; + +import { AuthedRequest } from '../auth/auth.guard'; +import { ZodValidationPipe } from '../common/zod-validation.pipe'; +import { AuthenticatedOnly } from '../permissions/permission.decorators'; +import { FeedTokensService } from './feed-tokens.service'; + +/** + * Feed-token lifecycle for the settings UI (issue #149) — + * session-authenticated and owner-scoped, like the API-token controller. + */ +@Controller('users/me/feed-tokens') +@AuthenticatedOnly() +export class FeedTokensController { + constructor(private readonly tokens: FeedTokensService) {} + + @Get() + list(@Req() request: AuthedRequest): Promise { + return this.tokens.list(request.user!); + } + + @Post() + create( + @Body(new ZodValidationPipe(createFeedTokenInputSchema)) input: CreateFeedTokenInput, + @Req() request: AuthedRequest, + ): Promise { + return this.tokens.create(request.user!, input); + } + + @Delete(':id') + @HttpCode(204) + async remove(@Param('id') id: string, @Req() request: AuthedRequest): Promise { + await this.tokens.remove(request.user!, id); + } +} diff --git a/apps/api/src/public/feed-tokens.service.ts b/apps/api/src/public/feed-tokens.service.ts new file mode 100644 index 0000000..54b945a --- /dev/null +++ b/apps/api/src/public/feed-tokens.service.ts @@ -0,0 +1,74 @@ +import { createHash, randomBytes } from 'node:crypto'; + +import { Injectable, NotFoundException } from '@nestjs/common'; +import { + FEED_TOKEN_PREFIX, + type CreateFeedTokenInput, + type FeedTokenCreatedView, + type FeedTokenView, +} from '@dorfteich/shared'; +import { FeedToken, User } from '@prisma/client'; + +import { PrismaService } from '../prisma/prisma.service'; + +/** + * Feed-token lifecycle (issue #149). Mirrors the API-token mechanics — the + * secret (`dt_feed_`) is shown once and only its SHA-256 lands in the + * database — but the token is read-only by construction: the sole consumer is + * {@link FeedService}, which resolves it to a user for the two feed endpoints. + */ +@Injectable() +export class FeedTokensService { + constructor(private readonly prisma: PrismaService) {} + + async list(user: User): Promise { + const rows = await this.prisma.feedToken.findMany({ + where: { userId: user.id }, + orderBy: { createdAt: 'desc' }, + }); + return rows.map((row) => this.view(row)); + } + + async create(user: User, input: CreateFeedTokenInput): Promise { + const secret = `${FEED_TOKEN_PREFIX}${randomBytes(24).toString('hex')}`; + const row = await this.prisma.feedToken.create({ + data: { userId: user.id, name: input.name, tokenHash: hashFeedToken(secret) }, + }); + return { ...this.view(row), token: secret }; + } + + async remove(user: User, id: string): Promise { + const { count } = await this.prisma.feedToken.deleteMany({ + where: { id, userId: user.id }, + }); + if (count === 0) throw new NotFoundException(); + } + + /** The token's user, or null for a missing/invalid secret. */ + async resolve(secret: string): Promise { + if (!secret.startsWith(FEED_TOKEN_PREFIX)) return null; + const row = await this.prisma.feedToken.findUnique({ + where: { tokenHash: hashFeedToken(secret) }, + include: { user: true }, + }); + if (!row) return null; + // Best-effort usage stamp; a lost update here is harmless. + await this.prisma.feedToken + .update({ where: { id: row.id }, data: { lastUsedAt: new Date() } }) + .catch(() => undefined); + return row.user; + } + + private view(row: FeedToken): FeedTokenView { + return { + id: row.id, + name: row.name, + lastUsedAt: row.lastUsedAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + }; + } +} + +function hashFeedToken(raw: string): string { + return createHash('sha256').update(raw).digest('hex'); +} diff --git a/apps/api/src/public/feed.e2e.db.test.ts b/apps/api/src/public/feed.e2e.db.test.ts new file mode 100644 index 0000000..6c41517 --- /dev/null +++ b/apps/api/src/public/feed.e2e.db.test.ts @@ -0,0 +1,198 @@ +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'; + +/** + * Atom feeds end to end (issue #149): the pond feed lists recently updated + * pages, the page feed lists versions; a public pond serves anonymously, a + * private pond 404s without a feed token and opens with one; the feed-token + * lifecycle runs through the settings endpoints. + */ +describe.skipIf(!hasTestDb)('atom feeds (e2e, issue #149)', () => { + let app: INestApplication; + let prisma: PrismaClient; + const suffix = uniqueSuffix(); + const password = 'feeds sind bequem 1'; + + let ownerId: string; + let ownerCookie: string; + let pondSlug: string; + let pondId: string; + let privatePondSlug: string; + let privatePondId: string; + let feedToken: string; + + const api = () => request(app.getHttpServer()); + + async function makePage(pondIdV: string, slug: string, title: string): Promise { + const page = await prisma.page.create({ + data: { + pondId: pondIdV, + slug, + title, + createdBy: ownerId, + sortKey: 'a0', + ydocState: new Uint8Array(), + contentCache: { + create: { plainText: title, markdown: title, html: `

${title}

`, outline: [] }, + }, + }, + }); + return page.id; + } + + beforeAll(async () => { + prisma = createTestPrisma(); + app = await createTestApp(); + const users = app.get(UsersService); + const username = `feed-owner-${suffix}`; + const owner = await users.createUser({ + username, + email: `${username}@example.test`, + displayName: 'Feed Owner', + password, + locale: 'en', + }); + ownerId = owner.id; + await users.markEmailVerified(ownerId); + ownerCookie = sessionCookieOf( + await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: username, password }) + .expect(200), + ); + + pondSlug = `feed-pond-${suffix}`; + const pond = await prisma.pond.create({ + data: { slug: pondSlug, name: 'Feed Pond', type: 'SHARED', ownerId }, + }); + pondId = pond.id; + await makePage(pondId, `older-${suffix}`, 'Older Page'); + const newerId = await makePage(pondId, `newer-${suffix}`, 'Newer Page'); + await prisma.pageVersion.create({ + data: { + pageId: newerId, + ydocSnapshot: new Uint8Array(), + trigger: 'MANUAL', + label: 'First draft', + createdBy: ownerId, + }, + }); + await prisma.roleGrant.create({ + data: { + pondId, + subjectType: 'PUBLIC', + subjectId: null, + role: 'READER', + scopeType: 'POND', + scopeId: null, + effect: 'ALLOW', + createdBy: ownerId, + }, + }); + + privatePondSlug = `feed-priv-${suffix}`; + const priv = await prisma.pond.create({ + data: { slug: privatePondSlug, name: 'Private Feed Pond', type: 'SHARED', ownerId }, + }); + privatePondId = priv.id; + await makePage(privatePondId, `hidden-${suffix}`, 'Hidden Page'); + // Raw ponds carry no owner grant row — give the owner explicit read + // access so the feed token (resolving to the owner) may see the pond. + await prisma.roleGrant.create({ + data: { + pondId: privatePondId, + subjectType: 'USER', + subjectId: ownerId, + role: 'READER', + scopeType: 'POND', + scopeId: null, + effect: 'ALLOW', + createdBy: ownerId, + }, + }); + }); + + afterAll(async () => { + await prisma.feedToken.deleteMany({ where: { userId: ownerId } }); + await prisma.roleGrant.deleteMany({ where: { pond: { ownerId } } }); + await prisma.pageVersion.deleteMany({ where: { page: { pond: { ownerId } } } }); + await prisma.pageContentCache.deleteMany({ where: { page: { pond: { ownerId } } } }); + await prisma.page.deleteMany({ where: { pond: { ownerId } } }); + await prisma.pond.deleteMany({ where: { ownerId } }); + await prisma.session.deleteMany({ where: { userId: ownerId } }); + await prisma.user.deleteMany({ where: { id: ownerId } }); + await prisma.$disconnect(); + await app.close(); + }); + + it('serves a public pond feed anonymously as Atom', async () => { + const res = await api().get(`/api/v1/public/${pondSlug}/feed.xml`).expect(200); + expect(res.headers['content-type']).toContain('application/atom+xml'); + expect(res.text).toContain(''); + expect(res.text).toContain('Feed Pond'); + expect(res.text).toContain('Newer Page'); + expect(res.text).toContain('Older Page'); + expect(res.text).toContain(''); + // Anonymous entries link into the public view. + expect(res.text).toContain(`/api/v1/public/${pondSlug}/newer-${suffix}`); + }); + + it('serves a page feed built from the version history', async () => { + const res = await api().get(`/api/v1/public/${pondSlug}/newer-${suffix}/feed.xml`).expect(200); + expect(res.text).toContain('Newer Page — Feed Pond'); + expect(res.text).toContain('First draft'); + }); + + it('runs the feed-token lifecycle and opens a private pond with it', async () => { + // Without any auth the private pond hides (404, #60). + await api().get(`/api/v1/public/${privatePondSlug}/feed.xml`).expect(404); + + const created = await api() + .post('/api/v1/users/me/feed-tokens') + .set('Cookie', ownerCookie) + .send({ name: 'Reader im Wohnzimmer' }) + .expect(201); + feedToken = created.body.token as string; + expect(feedToken).toMatch(/^dt_feed_/); + + // The token authenticates the feed; entries link into the app. + const res = await api() + .get(`/api/v1/public/${privatePondSlug}/feed.xml?token=${feedToken}`) + .expect(200); + expect(res.text).toContain('Hidden Page'); + expect(res.text).toContain(`/p/${privatePondSlug}/hidden-${suffix}`); + + // Garbage tokens fall back to anonymous → 404 for the private pond. + await api().get(`/api/v1/public/${privatePondSlug}/feed.xml?token=dt_feed_junk`).expect(404); + + // List shows it (without the secret); delete kills the access. + const list = await api() + .get('/api/v1/users/me/feed-tokens') + .set('Cookie', ownerCookie) + .expect(200); + expect(list.body).toHaveLength(1); + expect(list.body[0].name).toBe('Reader im Wohnzimmer'); + expect(list.body[0].token).toBeUndefined(); + await api() + .delete(`/api/v1/users/me/feed-tokens/${list.body[0].id}`) + .set('Cookie', ownerCookie) + .expect(204); + await api().get(`/api/v1/public/${privatePondSlug}/feed.xml?token=${feedToken}`).expect(404); + }); + + it('keeps the page feed permission-checked', async () => { + await api().get(`/api/v1/public/${privatePondSlug}/hidden-${suffix}/feed.xml`).expect(404); + }); + + it('advertises the pond feed in the public HTML shell', async () => { + const res = await api().get(`/api/v1/public/${pondSlug}/newer-${suffix}`).expect(200); + expect(res.text).toContain('rel="alternate" type="application/atom+xml"'); + expect(res.text).toContain(`/api/v1/public/${pondSlug}/feed.xml`); + }); +}); diff --git a/apps/api/src/public/feed.service.ts b/apps/api/src/public/feed.service.ts new file mode 100644 index 0000000..6a82006 --- /dev/null +++ b/apps/api/src/public/feed.service.ts @@ -0,0 +1,150 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { Pond, User } from '@prisma/client'; + +import { PagesService } from '../pages/pages.service'; +import { PermissionService } from '../permissions/permission.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { FeedTokensService } from './feed-tokens.service'; +import { escapeHtml } from './html-shell'; + +/** How many entries a feed carries — plenty for readers polling regularly. */ +const FEED_ENTRIES = 30; + +interface FeedEntry { + id: string; + title: string; + link: string; + updated: Date; + summary?: string; +} + +/** + * Atom feeds (issue #149): per pond (recently created/updated pages) and per + * page (its version history). Anonymous visitors get exactly what the public + * grant allows — a non-public pond 404s, never leaks. A `?token=dt_feed_…` + * query parameter authenticates the request as the token's user (feed readers + * cannot send headers), so private ponds become subscribable too; links then + * point into the app instead of the public view. + */ +@Injectable() +export class FeedService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionService, + private readonly pages: PagesService, + private readonly feedTokens: FeedTokensService, + ) {} + + /** The effective viewer: feed token > session user > anonymous. */ + async viewerFor(sessionUser: User | null, token: string | undefined): Promise { + if (token) { + const tokenUser = await this.feedTokens.resolve(token); + if (tokenUser) return tokenUser; + } + return sessionUser; + } + + /** Recently updated pages of a pond as Atom XML. */ + async pondFeed(user: User | null, pondSlug: string, baseUrl: string): Promise { + const pond = await this.requireVisiblePond(user, pondSlug); + const items = await this.pages.list(user, pond.id); + const anonymous = user === null; + const entries = items + .slice() + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + .slice(0, FEED_ENTRIES) + .map((page) => ({ + id: `${baseUrl}/api/v1/public/${pond.slug}/${page.slug}`, + title: page.title, + link: anonymous + ? `${baseUrl}/api/v1/public/${pond.slug}/${page.slug}` + : `${baseUrl}/p/${pond.slug}/${page.slug}`, + updated: new Date(page.updatedAt), + })); + return atomDocument({ + id: `${baseUrl}/api/v1/public/${pond.slug}/feed.xml`, + title: pond.name, + selfLink: `${baseUrl}/api/v1/public/${pond.slug}/feed.xml`, + entries, + }); + } + + /** A page's version history as Atom XML. */ + async pageFeed( + user: User | null, + pondSlug: string, + pageSlug: string, + baseUrl: string, + ): Promise { + const pond = await this.requireVisiblePond(user, pondSlug); + const page = await this.prisma.page.findFirst({ + where: { pondId: pond.id, slug: pageSlug, deletedAt: null }, + select: { id: true, pondId: true, slug: true, title: true }, + }); + if (!page || !(await this.permissions.canAccessPage(user, page, 'read'))) { + throw new NotFoundException(); + } + const versions = await this.prisma.pageVersion.findMany({ + where: { pageId: page.id }, + orderBy: { createdAt: 'desc' }, + take: FEED_ENTRIES, + select: { id: true, label: true, trigger: true, createdAt: true }, + }); + const link = + user === null + ? `${baseUrl}/api/v1/public/${pond.slug}/${page.slug}` + : `${baseUrl}/p/${pond.slug}/${page.slug}`; + const entries = versions.map((version) => ({ + id: `urn:dorfteich:version:${version.id}`, + title: version.label ?? version.trigger.toLowerCase(), + link, + updated: version.createdAt, + })); + return atomDocument({ + id: `${baseUrl}/api/v1/public/${pond.slug}/${page.slug}/feed.xml`, + title: `${page.title} — ${pond.name}`, + selfLink: `${baseUrl}/api/v1/public/${pond.slug}/${page.slug}/feed.xml`, + entries, + }); + } + + /** The pond, 404-hidden from viewers who may not even see it (#60). */ + private async requireVisiblePond(user: User | null, pondSlug: string): Promise { + const pond = await this.prisma.pond.findFirst({ where: { slug: pondSlug, deletedAt: null } }); + if (!pond || !(await this.permissions.canSeePond(user, pond.id))) { + throw new NotFoundException(); + } + return pond; + } +} + +function atomDocument(feed: { + id: string; + title: string; + selfLink: string; + entries: FeedEntry[]; +}): string { + const updated = feed.entries[0]?.updated ?? new Date(); + const entries = feed.entries + .map( + (entry) => + ` \n` + + ` ${escapeHtml(entry.id)}\n` + + ` ${escapeHtml(entry.title)}\n` + + ` \n` + + ` ${entry.updated.toISOString()}\n` + + (entry.summary ? ` ${escapeHtml(entry.summary)}\n` : '') + + ` `, + ) + .join('\n'); + return ( + `\n` + + `\n` + + ` ${escapeHtml(feed.id)}\n` + + ` ${escapeHtml(feed.title)}\n` + + ` \n` + + ` ${updated.toISOString()}\n` + + `${entries}\n` + + `\n` + ); +} diff --git a/apps/api/src/public/html-shell.ts b/apps/api/src/public/html-shell.ts index 197e4c4..6a91319 100644 --- a/apps/api/src/public/html-shell.ts +++ b/apps/api/src/public/html-shell.ts @@ -12,11 +12,22 @@ export interface HtmlShellOptions { /** Plain text; escaped here. */ title: string; canonical?: string; + /** Atom feed of the surrounding pond (issue #149), advertised to readers. */ + feedUrl?: string; bodyHtml: string; } -export function htmlDocument({ lang, title, canonical, bodyHtml }: HtmlShellOptions): string { +export function htmlDocument({ + lang, + title, + canonical, + feedUrl, + bodyHtml, +}: HtmlShellOptions): string { const canonicalTag = canonical ? `\n` : ''; + const feedTag = feedUrl + ? `\n` + : ''; const imprintLabel = escapeHtml(apiI18n.t('legal:links.imprint', { lng: lang })); const privacyLabel = escapeHtml(apiI18n.t('legal:links.privacy', { lng: lang })); return ` @@ -24,7 +35,7 @@ export function htmlDocument({ lang, title, canonical, bodyHtml }: HtmlShellOpti -${escapeHtml(title)}${canonicalTag} +${escapeHtml(title)}${canonicalTag}${feedTag}