@ -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");
|
||||
@ -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;
|
||||
@ -51,6 +51,7 @@ model User {
|
||||
sessions Session[]
|
||||
authTokens AuthToken[]
|
||||
apiTokens ApiToken[]
|
||||
feedTokens FeedToken[]
|
||||
ponds Pond[]
|
||||
pages Page[]
|
||||
attachments Attachment[]
|
||||
@ -291,6 +292,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")
|
||||
}
|
||||
|
||||
@ -604,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")
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<PageListItemView[]> {
|
||||
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')
|
||||
|
||||
@ -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<PageListItemView[]> {
|
||||
* 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<PageListItemView[]> {
|
||||
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 } } },
|
||||
});
|
||||
|
||||
@ -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')) },
|
||||
},
|
||||
},
|
||||
@ -273,7 +275,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') }),
|
||||
},
|
||||
|
||||
@ -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<PublicPageListItemView[]> {
|
||||
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')
|
||||
|
||||
@ -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`)
|
||||
|
||||
@ -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<PublicPageListItemView[]> {
|
||||
async listPages(
|
||||
user: User,
|
||||
pondSlug: string,
|
||||
query?: PageListQuery,
|
||||
): Promise<PublicPageListItemView[]> {
|
||||
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
|
||||
|
||||
41
apps/api/src/public/feed-tokens.controller.ts
Normal file
41
apps/api/src/public/feed-tokens.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
74
apps/api/src/public/feed-tokens.service.ts
Normal file
74
apps/api/src/public/feed-tokens.service.ts
Normal 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');
|
||||
}
|
||||
198
apps/api/src/public/feed.e2e.db.test.ts
Normal file
198
apps/api/src/public/feed.e2e.db.test.ts
Normal 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`);
|
||||
});
|
||||
});
|
||||
150
apps/api/src/public/feed.service.ts
Normal file
150
apps/api/src/public/feed.service.ts
Normal 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`
|
||||
);
|
||||
}
|
||||
@ -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<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 privacyLabel = escapeHtml(apiI18n.t('legal:links.privacy', { lng: lang }));
|
||||
return `<!doctype html>
|
||||
@ -24,7 +35,7 @@ export function htmlDocument({ lang, title, canonical, bodyHtml }: HtmlShellOpti
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(title)}</title>${canonicalTag}
|
||||
<title>${escapeHtml(title)}</title>${canonicalTag}${feedTag}
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body { max-width: 48rem; margin: 2rem auto; padding: 0 1rem;
|
||||
|
||||
@ -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 { Response } from 'express';
|
||||
|
||||
import { AuthedRequest, Public } from '../auth/auth.guard';
|
||||
import { FeedService } from './feed.service';
|
||||
import { PublicPageContent, PublicService } from './public.service';
|
||||
|
||||
/**
|
||||
@ -13,7 +14,41 @@ import { PublicPageContent, PublicService } from './public.service';
|
||||
*/
|
||||
@Controller('public')
|
||||
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')
|
||||
@Public()
|
||||
@ -49,3 +84,7 @@ export class PublicController {
|
||||
return html;
|
||||
}
|
||||
}
|
||||
|
||||
function baseUrlOf(request: AuthedRequest): string {
|
||||
return `${request.protocol}://${request.get('host') ?? ''}`;
|
||||
}
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CommentsModule } from '../comments/comments.module';
|
||||
import { PagesModule } from '../pages/pages.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 { PublicService } from './public.service';
|
||||
import { ReadContentController } from './read-content.controller';
|
||||
@ -14,8 +18,8 @@ import { ReadContentController } from './read-content.controller';
|
||||
* marks `GET /media/:fileId` public too.
|
||||
*/
|
||||
@Module({
|
||||
imports: [PluginsModule, CommentsModule],
|
||||
controllers: [PublicController, ReadContentController],
|
||||
providers: [PublicService],
|
||||
imports: [PluginsModule, CommentsModule, PagesModule],
|
||||
controllers: [PublicController, ReadContentController, FeedTokensController],
|
||||
providers: [PublicService, FeedService, FeedTokensService],
|
||||
})
|
||||
export class PublicModule {}
|
||||
|
||||
@ -173,6 +173,11 @@ export class PublicService {
|
||||
lang: await this.settings.get('instance.defaultLocale'),
|
||||
title: `${content.title} — ${content.pondName}`,
|
||||
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>
|
||||
<h1>${escapeHtml(content.title)}</h1>
|
||||
${content.html}`,
|
||||
|
||||
@ -24,8 +24,8 @@ test('user settings show the jump nav and clicking scrolls + activates', async (
|
||||
const nav = page.locator('.settings-nav');
|
||||
await expect(nav).toBeVisible();
|
||||
const links = nav.locator('.settings-nav__link');
|
||||
// Profile, password, sessions, watches, API tokens, data export.
|
||||
await expect(links).toHaveCount(6);
|
||||
// Profile, password, sessions, watches, API tokens, feed tokens, data export.
|
||||
await expect(links).toHaveCount(7);
|
||||
|
||||
// Jump to the last section: it scrolls into view and becomes active.
|
||||
const last = links.last();
|
||||
|
||||
115
apps/web/src/api-tokens/FeedTokensSection.tsx
Normal file
115
apps/web/src/api-tokens/FeedTokensSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -11,6 +11,7 @@ import { SettingsLayout } from '../components/SettingsLayout';
|
||||
import { useDataExport } from '../export/use-data-export';
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api';
|
||||
import { ApiTokensSection } from '../api-tokens/ApiTokensSection';
|
||||
import { FeedTokensSection } from '../api-tokens/FeedTokensSection';
|
||||
import { WatchesSection } from '../watches/WatchesSection';
|
||||
|
||||
interface SessionView {
|
||||
@ -32,6 +33,7 @@ export function SettingsPage(): React.JSX.Element {
|
||||
<SessionsSection />
|
||||
<WatchesSection />
|
||||
<ApiTokensSection />
|
||||
<FeedTokensSection />
|
||||
<DataExportSection />
|
||||
</SettingsLayout>
|
||||
</>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)_ |
|
||||
|
||||
@ -166,7 +166,8 @@ eine Teich-Einstellung.
|
||||
Profil (Anzeigename, E-Mail, Sprache, Beobachten-Voreinstellungen,
|
||||
Digest-Frequenz), Passwort, aktive Sitzungen, deine beobachteten Seiten
|
||||
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
|
||||
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
|
||||
unter `/public/<teich>/<seite>` lesbar — mit der Typografie des Teichs
|
||||
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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)_ |
|
||||
|
||||
@ -148,7 +148,8 @@ only editors may comment is a pond setting.
|
||||
Profile (display name, e-mail, language, watch defaults, digest
|
||||
frequency), password, active sessions, your watches, **API tokens** (for
|
||||
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.
|
||||
|
||||
## 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
|
||||
without an account at `/public/<pond>/<page>` — with the pond's
|
||||
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.
|
||||
|
||||
@ -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}` |
|
||||
|
||||
@ -51,5 +51,15 @@
|
||||
"saved": "Gespeichert.",
|
||||
"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."
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,5 +51,15 @@
|
||||
"saved": "Saved.",
|
||||
"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."
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
27
packages/shared/src/feed-tokens.ts
Normal file
27
packages/shared/src/feed-tokens.ts
Normal 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>;
|
||||
@ -1,5 +1,6 @@
|
||||
export * from './admin-users';
|
||||
export * from './api-tokens';
|
||||
export * from './feed-tokens';
|
||||
export * from './api-error';
|
||||
export * from './auth';
|
||||
export * from './backup-set';
|
||||
|
||||
@ -92,6 +92,21 @@ export const publicUpdateLabelInputSchema = z
|
||||
.partial();
|
||||
export type PublicUpdateLabelInput = z.infer<typeof publicUpdateLabelInputSchema>;
|
||||
|
||||
/** 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<typeof pageListQuerySchema>;
|
||||
|
||||
export const publicSearchQuerySchema = z.object({
|
||||
q: z.string().trim().min(1, 'validation.required').max(200),
|
||||
pond: z.string().trim().optional(),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user