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}