#149: Atom-Feeds für Teiche und Seiten, privat via Feed-Token
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 4m53s
CI / Build container images (pull_request) Successful in 4m1s
CI / Auth e2e pack (pull_request) Successful in 7m12s
CI / Import/export fidelity gate (pull_request) Successful in 1m0s
CD / Build and push images (push) Successful in 14s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 4m35s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Failing after 5m14s
CI / Import/export fidelity gate (push) Has been skipped

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
This commit is contained in:
Claude Fable 5 2026-07-20 00:49:53 +02:00
parent 89ffbc0e4d
commit 7252bd16e0
19 changed files with 762 additions and 11 deletions

View File

@ -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;

View File

@ -51,6 +51,7 @@ model User {
sessions Session[] sessions Session[]
authTokens AuthToken[] authTokens AuthToken[]
apiTokens ApiToken[] apiTokens ApiToken[]
feedTokens FeedToken[]
ponds Pond[] ponds Pond[]
pages Page[] pages Page[]
attachments Attachment[] attachments Attachment[]
@ -608,6 +609,24 @@ enum ApiTokenScope {
/// user — the whole permission model applies — narrowed by `scope` and the /// user — the whole permission model applies — narrowed by `scope` and the
/// optional pond restriction. Revoking keeps the row so the settings UI can /// optional pond restriction. Revoking keeps the row so the settings UI can
/// show history; validation skips revoked/expired rows. /// 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 { model ApiToken {
id String @id @default(uuid()) id String @id @default(uuid())
tokenHash String @unique @map("token_hash") tokenHash String @unique @map("token_hash")

View File

@ -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<FeedTokenView[]> {
return this.tokens.list(request.user!);
}
@Post()
create(
@Body(new ZodValidationPipe(createFeedTokenInputSchema)) input: CreateFeedTokenInput,
@Req() request: AuthedRequest,
): Promise<FeedTokenCreatedView> {
return this.tokens.create(request.user!, input);
}
@Delete(':id')
@HttpCode(204)
async remove(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
await this.tokens.remove(request.user!, id);
}
}

View File

@ -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_<random>`) 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<FeedTokenView[]> {
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<FeedTokenCreatedView> {
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<void> {
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<User | null> {
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');
}

View File

@ -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<string> {
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: `<p>${title}</p>`, 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('<feed xmlns="http://www.w3.org/2005/Atom">');
expect(res.text).toContain('<title>Feed Pond</title>');
expect(res.text).toContain('Newer Page');
expect(res.text).toContain('Older Page');
expect(res.text).toContain('<updated>');
// 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('<title>Newer Page — Feed Pond</title>');
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`);
});
});

View File

@ -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<User | null> {
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<string> {
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<string> {
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<Pond> {
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) =>
` <entry>\n` +
` <id>${escapeHtml(entry.id)}</id>\n` +
` <title>${escapeHtml(entry.title)}</title>\n` +
` <link href="${escapeHtml(entry.link)}"/>\n` +
` <updated>${entry.updated.toISOString()}</updated>\n` +
(entry.summary ? ` <summary>${escapeHtml(entry.summary)}</summary>\n` : '') +
` </entry>`,
)
.join('\n');
return (
`<?xml version="1.0" encoding="utf-8"?>\n` +
`<feed xmlns="http://www.w3.org/2005/Atom">\n` +
` <id>${escapeHtml(feed.id)}</id>\n` +
` <title>${escapeHtml(feed.title)}</title>\n` +
` <link rel="self" href="${escapeHtml(feed.selfLink)}"/>\n` +
` <updated>${updated.toISOString()}</updated>\n` +
`${entries}\n` +
`</feed>\n`
);
}

View File

@ -12,11 +12,22 @@ export interface HtmlShellOptions {
/** Plain text; escaped here. */ /** Plain text; escaped here. */
title: string; title: string;
canonical?: string; canonical?: string;
/** Atom feed of the surrounding pond (issue #149), advertised to readers. */
feedUrl?: string;
bodyHtml: 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<link rel="canonical" href="${escapeHtml(canonical)}">` : ''; const canonicalTag = canonical ? `\n<link rel="canonical" href="${escapeHtml(canonical)}">` : '';
const feedTag = feedUrl
? `\n<link rel="alternate" type="application/atom+xml" href="${escapeHtml(feedUrl)}">`
: '';
const imprintLabel = escapeHtml(apiI18n.t('legal:links.imprint', { lng: lang })); const imprintLabel = escapeHtml(apiI18n.t('legal:links.imprint', { lng: lang }));
const privacyLabel = escapeHtml(apiI18n.t('legal:links.privacy', { lng: lang })); const privacyLabel = escapeHtml(apiI18n.t('legal:links.privacy', { lng: lang }));
return `<!doctype html> return `<!doctype html>
@ -24,7 +35,7 @@ export function htmlDocument({ lang, title, canonical, bodyHtml }: HtmlShellOpti
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>${canonicalTag} <title>${escapeHtml(title)}</title>${canonicalTag}${feedTag}
<style> <style>
:root { color-scheme: light dark; } :root { color-scheme: light dark; }
body { max-width: 48rem; margin: 2rem auto; padding: 0 1rem; body { max-width: 48rem; margin: 2rem auto; padding: 0 1rem;

View File

@ -1,8 +1,9 @@
import { Controller, Get, Param, Req, Res } from '@nestjs/common'; import { Controller, Get, Param, Query, Req, Res } from '@nestjs/common';
import type { PageCommentsView } from '@dorfteich/shared'; import type { PageCommentsView } from '@dorfteich/shared';
import type { Response } from 'express'; import type { Response } from 'express';
import { AuthedRequest, Public } from '../auth/auth.guard'; import { AuthedRequest, Public } from '../auth/auth.guard';
import { FeedService } from './feed.service';
import { PublicPageContent, PublicService } from './public.service'; import { PublicPageContent, PublicService } from './public.service';
/** /**
@ -13,7 +14,41 @@ import { PublicPageContent, PublicService } from './public.service';
*/ */
@Controller('public') @Controller('public')
export class PublicController { export class PublicController {
constructor(private readonly publicPages: PublicService) {} constructor(
private readonly publicPages: PublicService,
private readonly feeds: FeedService,
) {}
// The feed routes come FIRST: `:pondSlug/feed.xml` would otherwise be
// swallowed by the `:pondSlug/:pageSlug` HTML route below (issue #149).
@Get(':pondSlug/feed.xml')
@Public()
async pondFeed(
@Param('pondSlug') pondSlug: string,
@Query('token') token: string | undefined,
@Req() request: AuthedRequest,
@Res({ passthrough: true }) response: Response,
): Promise<string> {
const viewer = await this.feeds.viewerFor(request.user ?? null, token);
const xml = await this.feeds.pondFeed(viewer, pondSlug, baseUrlOf(request));
response.set('Content-Type', 'application/atom+xml; charset=utf-8');
return xml;
}
@Get(':pondSlug/:pageSlug/feed.xml')
@Public()
async pageFeed(
@Param('pondSlug') pondSlug: string,
@Param('pageSlug') pageSlug: string,
@Query('token') token: string | undefined,
@Req() request: AuthedRequest,
@Res({ passthrough: true }) response: Response,
): Promise<string> {
const viewer = await this.feeds.viewerFor(request.user ?? null, token);
const xml = await this.feeds.pageFeed(viewer, pondSlug, pageSlug, baseUrlOf(request));
response.set('Content-Type', 'application/atom+xml; charset=utf-8');
return xml;
}
@Get(':pondSlug/:pageSlug/content') @Get(':pondSlug/:pageSlug/content')
@Public() @Public()
@ -49,3 +84,7 @@ export class PublicController {
return html; return html;
} }
} }
function baseUrlOf(request: AuthedRequest): string {
return `${request.protocol}://${request.get('host') ?? ''}`;
}

View File

@ -1,8 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CommentsModule } from '../comments/comments.module'; import { CommentsModule } from '../comments/comments.module';
import { PagesModule } from '../pages/pages.module';
import { PluginsModule } from '../plugins/plugins.module'; import { PluginsModule } from '../plugins/plugins.module';
import { FeedTokensController } from './feed-tokens.controller';
import { FeedTokensService } from './feed-tokens.service';
import { FeedService } from './feed.service';
import { PublicController } from './public.controller'; import { PublicController } from './public.controller';
import { PublicService } from './public.service'; import { PublicService } from './public.service';
import { ReadContentController } from './read-content.controller'; import { ReadContentController } from './read-content.controller';
@ -14,8 +18,8 @@ import { ReadContentController } from './read-content.controller';
* marks `GET /media/:fileId` public too. * marks `GET /media/:fileId` public too.
*/ */
@Module({ @Module({
imports: [PluginsModule, CommentsModule], imports: [PluginsModule, CommentsModule, PagesModule],
controllers: [PublicController, ReadContentController], controllers: [PublicController, ReadContentController, FeedTokensController],
providers: [PublicService], providers: [PublicService, FeedService, FeedTokensService],
}) })
export class PublicModule {} export class PublicModule {}

View File

@ -173,6 +173,11 @@ export class PublicService {
lang: await this.settings.get('instance.defaultLocale'), lang: await this.settings.get('instance.defaultLocale'),
title: `${content.title}${content.pondName}`, title: `${content.title}${content.pondName}`,
canonical, canonical,
// Advertise the pond's Atom feed (issue #149) to feed readers.
feedUrl: new URL(
`/api/v1/public/${encodeURIComponent(pondSlug)}/feed.xml`,
canonical,
).toString(),
bodyHtml: `<p class="public-page__pond">${escapeHtml(content.pondName)}</p> bodyHtml: `<p class="public-page__pond">${escapeHtml(content.pondName)}</p>
<h1>${escapeHtml(content.title)}</h1> <h1>${escapeHtml(content.title)}</h1>
${content.html}`, ${content.html}`,

View File

@ -24,8 +24,8 @@ test('user settings show the jump nav and clicking scrolls + activates', async (
const nav = page.locator('.settings-nav'); const nav = page.locator('.settings-nav');
await expect(nav).toBeVisible(); await expect(nav).toBeVisible();
const links = nav.locator('.settings-nav__link'); const links = nav.locator('.settings-nav__link');
// Profile, password, sessions, watches, API tokens, data export. // Profile, password, sessions, watches, API tokens, feed tokens, data export.
await expect(links).toHaveCount(6); await expect(links).toHaveCount(7);
// Jump to the last section: it scrolls into view and becomes active. // Jump to the last section: it scrolls into view and becomes active.
const last = links.last(); const last = links.last();

View File

@ -0,0 +1,115 @@
import type { FeedTokenCreatedView, FeedTokenView } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FormError } from '../components/forms';
import { apiDelete, apiGet, apiPost } from '../lib/api';
const FEED_TOKENS_QUERY_KEY = ['users', 'me', 'feed-tokens'] as const;
/**
* Feed-token management in the user settings (issue #149): create, the
* one-time secret reveal with a ready-to-paste feed URL, and the list with
* delete. Feed tokens are read-only and only authenticate the Atom feeds.
*/
export function FeedTokensSection(): React.JSX.Element {
const { t, i18n } = useTranslation('apiTokens');
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [created, setCreated] = useState<FeedTokenCreatedView | null>(null);
const [error, setError] = useState<unknown>(null);
const [busy, setBusy] = useState(false);
const tokens = useQuery({
queryKey: FEED_TOKENS_QUERY_KEY,
queryFn: () => apiGet<FeedTokenView[]>('/users/me/feed-tokens'),
});
const invalidate = () => queryClient.invalidateQueries({ queryKey: FEED_TOKENS_QUERY_KEY });
const submit = async (event: React.FormEvent): Promise<void> => {
event.preventDefault();
setError(null);
setCreated(null);
setBusy(true);
try {
const view = await apiPost<FeedTokenCreatedView>('/users/me/feed-tokens', { name });
setCreated(view);
setName('');
await invalidate();
} catch (err) {
setError(err);
} finally {
setBusy(false);
}
};
const remove = async (id: string): Promise<void> => {
await apiDelete(`/users/me/feed-tokens/${id}`);
await invalidate();
};
const formatTime = (iso: string): string =>
new Intl.DateTimeFormat(i18n.language, { dateStyle: 'medium' }).format(new Date(iso));
return (
<section className="settings-section api-tokens">
<h2>{t('feed.title')}</h2>
<p className="api-tokens__intro">{t('feed.intro')}</p>
<form className="api-tokens__create" onSubmit={(event) => void submit(event)}>
<FormError error={error} />
<label>
{t('fields.name')}
<input
type="text"
value={name}
required
maxLength={80}
onChange={(event) => setName(event.target.value)}
/>
</label>
<button type="submit" className="button" disabled={busy}>
{t('feed.create')}
</button>
</form>
{created && (
<div className="api-tokens__reveal" role="status">
<p>{t('feed.revealHint')}</p>
<code className="api-tokens__secret">{created.token}</code>
<p className="api-tokens__hint">
{t('feed.urlHint', {
url: `${window.location.origin}/api/v1/public/<${t('feed.pondPlaceholder')}>/feed.xml?token=${created.token}`,
})}
</p>
</div>
)}
{tokens.data && tokens.data.length === 0 && <p>{t('feed.empty')}</p>}
{tokens.data && tokens.data.length > 0 && (
<table className="table">
<thead>
<tr>
<th>{t('fields.name')}</th>
<th>{t('list.created')}</th>
<th>{t('list.lastUsed')}</th>
<th></th>
</tr>
</thead>
<tbody>
{tokens.data.map((token) => (
<tr key={token.id}>
<td>{token.name}</td>
<td>{formatTime(token.createdAt)}</td>
<td>{token.lastUsedAt ? formatTime(token.lastUsedAt) : '—'}</td>
<td>
<button type="button" className="linklike" onClick={() => void remove(token.id)}>
{t('feed.delete')}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
);
}

View File

@ -11,6 +11,7 @@ import { SettingsLayout } from '../components/SettingsLayout';
import { useDataExport } from '../export/use-data-export'; import { useDataExport } from '../export/use-data-export';
import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api'; import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api';
import { ApiTokensSection } from '../api-tokens/ApiTokensSection'; import { ApiTokensSection } from '../api-tokens/ApiTokensSection';
import { FeedTokensSection } from '../api-tokens/FeedTokensSection';
import { WatchesSection } from '../watches/WatchesSection'; import { WatchesSection } from '../watches/WatchesSection';
interface SessionView { interface SessionView {
@ -32,6 +33,7 @@ export function SettingsPage(): React.JSX.Element {
<SessionsSection /> <SessionsSection />
<WatchesSection /> <WatchesSection />
<ApiTokensSection /> <ApiTokensSection />
<FeedTokensSection />
<DataExportSection /> <DataExportSection />
</SettingsLayout> </SettingsLayout>
</> </>

View File

@ -166,7 +166,8 @@ eine Teich-Einstellung.
Profil (Anzeigename, E-Mail, Sprache, Beobachten-Voreinstellungen, Profil (Anzeigename, E-Mail, Sprache, Beobachten-Voreinstellungen,
Digest-Frequenz), Passwort, aktive Sitzungen, deine beobachteten Seiten Digest-Frequenz), Passwort, aktive Sitzungen, deine beobachteten Seiten
und Teiche, **API-Tokens** (für Skripte und KI-Assistenten — siehe das und Teiche, **API-Tokens** (für Skripte und KI-Assistenten — siehe das
[API-Handbuch](api-guide.md) und das [MCP-Handbuch](mcp-guide.md)) und [API-Handbuch](api-guide.md) und das [MCP-Handbuch](mcp-guide.md)), **Feed-Tokens** (nur-lesend, für
Atom-Feeds nicht-öffentlicher Teiche) und
**Meine Daten exportieren**: ein ZIP mit deinen Profildaten und dem **Meine Daten exportieren**: ein ZIP mit deinen Profildaten und dem
vollständigen Inhalt deiner eigenen Teiche. vollständigen Inhalt deiner eigenen Teiche.
@ -175,3 +176,15 @@ vollständigen Inhalt deiner eigenen Teiche.
Hat ein Teich-Admin eine Seite öffentlich geschaltet, ist sie ohne Konto Hat ein Teich-Admin eine Seite öffentlich geschaltet, ist sie ohne Konto
unter `/public/<teich>/<seite>` lesbar — mit der Typografie des Teichs unter `/public/<teich>/<seite>` lesbar — mit der Typografie des Teichs
und einem Link auf die Rechtsseiten der Instanz. und einem Link auf die Rechtsseiten der Instanz.
## Feeds (Atom)
Jeder Teich hat einen Atom-Feed seiner zuletzt angelegten und geänderten
Seiten unter `/api/v1/public/<teich>/feed.xml`, jede Seite einen Feed
ihrer Versions-Historie unter `/api/v1/public/<teich>/<seite>/feed.xml`
(Issue #149). Öffentliche Teiche liefern sie ohne Konto — öffentliche
Seiten machen den Teich-Feed für Feedreader auch per `<link>` bekannt.
Für nicht-öffentliche Teiche legst du unter _Einstellungen →
Feed-Tokens_ ein **Feed-Token** an und hängst es als `?token=dt_feed_…`
an die URL — Feed-Tokens sind nur-lesend und authentifizieren
ausschließlich Feeds, nie die API.

View File

@ -148,7 +148,8 @@ only editors may comment is a pond setting.
Profile (display name, e-mail, language, watch defaults, digest Profile (display name, e-mail, language, watch defaults, digest
frequency), password, active sessions, your watches, **API tokens** (for frequency), password, active sessions, your watches, **API tokens** (for
scripts and AI assistants — see the [API guide](api-guide.md) and scripts and AI assistants — see the [API guide](api-guide.md) and
[MCP guide](mcp-guide.md)), and **data export**: a ZIP with your profile [MCP guide](mcp-guide.md)), **feed tokens** (read-only, for Atom
feeds of non-public ponds), and **data export**: a ZIP with your profile
data and the full content of your own ponds. data and the full content of your own ponds.
## Public pages ## Public pages
@ -156,3 +157,14 @@ data and the full content of your own ponds.
If a pond admin has published a page for the public, it is readable If a pond admin has published a page for the public, it is readable
without an account at `/public/<pond>/<page>` — with the pond's without an account at `/public/<pond>/<page>` — with the pond's
typography and a link to the instance's legal pages. typography and a link to the instance's legal pages.
## Feeds (Atom)
Every pond has an Atom feed of its recently created and updated pages at
`/api/v1/public/<pond>/feed.xml`, and every page has a feed of its
version history at `/api/v1/public/<pond>/<page>/feed.xml` (issue #149).
Public ponds serve them without an account — public pages also advertise
the pond feed to feed readers. For non-public ponds, create a **feed
token** under _Settings → Feed tokens_ and append it to the URL as
`?token=dt_feed_…` — feed tokens are read-only and only ever
authenticate feeds, never the API.

View File

@ -51,5 +51,15 @@
"saved": "Gespeichert.", "saved": "Gespeichert.",
"mcpLabel": "Eingebauten MCP-Endpoint aktivieren", "mcpLabel": "Eingebauten MCP-Endpoint aktivieren",
"mcpHint": "Hauptschalter (standardmäßig aus), unabhängig von der REST-API. MCP-Clients verbinden sich mit einem API-Token auf /api/mcp; jeder Teich gibt sich zusätzlich über seine Teich-Einstellungen frei. Siehe docs/self-hosting/public-api.md." "mcpHint": "Hauptschalter (standardmäßig aus), unabhängig von der REST-API. MCP-Clients verbinden sich mit einem API-Token auf /api/mcp; jeder Teich gibt sich zusätzlich über seine Teich-Einstellungen frei. Siehe docs/self-hosting/public-api.md."
},
"feed": {
"title": "Feed-Tokens",
"intro": "Nur-Lese-Tokens für Atom-Feeds: Hänge ?token=… an eine Feed-URL, damit dein Feedreader auch nicht-öffentliche Teiche und Seiten abonnieren kann. API-Zugriff gewähren sie nie.",
"create": "Feed-Token erstellen",
"revealHint": "Kopiere das Token jetzt — es wird nur einmal angezeigt.",
"urlHint": "Beispiel-Feed-URL: {{url}}",
"pondPlaceholder": "teich",
"empty": "Noch keine Feed-Tokens.",
"delete": "Löschen"
} }
} }

View File

@ -51,5 +51,15 @@
"saved": "Saved.", "saved": "Saved.",
"mcpLabel": "Enable the built-in MCP endpoint", "mcpLabel": "Enable the built-in MCP endpoint",
"mcpHint": "Master switch (default off), independent of the REST API. MCP clients connect to /api/mcp with an API token; each pond additionally opts in via its pond settings. See docs/self-hosting/public-api.md." "mcpHint": "Master switch (default off), independent of the REST API. MCP clients connect to /api/mcp with an API token; each pond additionally opts in via its pond settings. See docs/self-hosting/public-api.md."
},
"feed": {
"title": "Feed tokens",
"intro": "Read-only tokens for Atom feeds: append ?token=… to a feed URL so your feed reader can subscribe to non-public ponds and pages. They never grant API access.",
"create": "Create feed token",
"revealHint": "Copy the token now — it is only shown once.",
"urlHint": "Example feed URL: {{url}}",
"pondPlaceholder": "pond",
"empty": "No feed tokens yet.",
"delete": "Delete"
} }
} }

View File

@ -0,0 +1,27 @@
import { z } from 'zod';
/**
* Feed tokens (issue #149): read-only secrets (`dt_feed_…`) carried as a
* query parameter in Atom feed URLs, so feed readers can subscribe to
* non-public ponds and pages. Managed in the user settings; deliberately
* narrower than API tokens they only ever authenticate the feed endpoints.
*/
export const FEED_TOKEN_PREFIX = 'dt_feed_';
export interface FeedTokenView {
id: string;
name: string;
lastUsedAt: string | null;
createdAt: string;
}
/** Returned once at creation — the secret is never shown again. */
export interface FeedTokenCreatedView extends FeedTokenView {
token: string;
}
export const createFeedTokenInputSchema = z.object({
name: z.string().trim().min(1, 'validation.required').max(80, 'validation.tooLong'),
});
export type CreateFeedTokenInput = z.infer<typeof createFeedTokenInputSchema>;

View File

@ -1,5 +1,6 @@
export * from './admin-users'; export * from './admin-users';
export * from './api-tokens'; export * from './api-tokens';
export * from './feed-tokens';
export * from './api-error'; export * from './api-error';
export * from './auth'; export * from './auth';
export * from './backup-set'; export * from './backup-set';