@ -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[]
|
sessions Session[]
|
||||||
authTokens AuthToken[]
|
authTokens AuthToken[]
|
||||||
apiTokens ApiToken[]
|
apiTokens ApiToken[]
|
||||||
|
feedTokens FeedToken[]
|
||||||
ponds Pond[]
|
ponds Pond[]
|
||||||
pages Page[]
|
pages Page[]
|
||||||
attachments Attachment[]
|
attachments Attachment[]
|
||||||
@ -291,6 +292,10 @@ model Page {
|
|||||||
@@unique([pondId, slug])
|
@@unique([pondId, slug])
|
||||||
@@index([pondId])
|
@@index([pondId])
|
||||||
@@index([parentId])
|
@@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")
|
@@map("pages")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -604,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")
|
||||||
|
|||||||
@ -12,9 +12,21 @@ import { z } from 'zod';
|
|||||||
const pond = z.string().min(1);
|
const pond = z.string().min(1);
|
||||||
const page = 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 = {
|
export const MCP_TOOL_INPUTS = {
|
||||||
list_ponds: z.object({}),
|
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 }),
|
read_page: z.object({ pond, page }),
|
||||||
search: z.object({
|
search: z.object({
|
||||||
query: z.string().min(1),
|
query: z.string().min(1),
|
||||||
@ -68,8 +80,23 @@ export const MCP_TOOL_DEFINITIONS: {
|
|||||||
name: 'list_pages',
|
name: 'list_pages',
|
||||||
description:
|
description:
|
||||||
'List the readable pages of a pond: slug, title, parent (the page tree), labels, ' +
|
'List the readable pages of a pond: slug, title, parent (the page tree), labels, ' +
|
||||||
'timestamps.',
|
'timestamps. Optional created_since/updated_since (ISO 8601) narrow to pages ' +
|
||||||
inputSchema: { type: 'object', properties: { pond: pondProp }, required: ['pond'] },
|
'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',
|
name: 'read_page',
|
||||||
|
|||||||
@ -97,9 +97,17 @@ export class McpService {
|
|||||||
case 'list_ponds':
|
case 'list_ponds':
|
||||||
return asJson(await this.publicApi.listPonds(user, token, 'mcp'));
|
return asJson(await this.publicApi.listPonds(user, token, 'mcp'));
|
||||||
|
|
||||||
case 'list_pages':
|
case 'list_pages': {
|
||||||
await this.assertPondExposed(input.pond!, token);
|
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':
|
case 'read_page':
|
||||||
await this.assertPondExposed(input.pond!, token);
|
await this.assertPondExposed(input.pond!, token);
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import {
|
|||||||
UpdatePageInput,
|
UpdatePageInput,
|
||||||
createPageInputSchema,
|
createPageInputSchema,
|
||||||
pageDeleteQuerySchema,
|
pageDeleteQuerySchema,
|
||||||
|
pageListQuerySchema,
|
||||||
repositionPageInputSchema,
|
repositionPageInputSchema,
|
||||||
updatePageInputSchema,
|
updatePageInputSchema,
|
||||||
} from '@dorfteich/shared';
|
} from '@dorfteich/shared';
|
||||||
@ -57,9 +58,16 @@ export class PagesController {
|
|||||||
@RequiresPondRole('reader', { idParam: 'pondId' }) // the service filters per page
|
@RequiresPondRole('reader', { idParam: 'pondId' }) // the service filters per page
|
||||||
async list(
|
async list(
|
||||||
@Param('pondId') pondId: string,
|
@Param('pondId') pondId: string,
|
||||||
|
@Query('createdSince') createdSince: string | undefined,
|
||||||
|
@Query('updatedSince') updatedSince: string | undefined,
|
||||||
@Req() request: AuthedRequest,
|
@Req() request: AuthedRequest,
|
||||||
): Promise<PageListItemView[]> {
|
): 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')
|
@Get('pages/:id')
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
OutlineEntry,
|
OutlineEntry,
|
||||||
PageDeleteMode,
|
PageDeleteMode,
|
||||||
PageListItemView,
|
PageListItemView,
|
||||||
|
PageListQuery,
|
||||||
PageStateView,
|
PageStateView,
|
||||||
PageView,
|
PageView,
|
||||||
PluginPageSummary,
|
PluginPageSummary,
|
||||||
@ -151,13 +152,24 @@ export class PagesService {
|
|||||||
/** Sidebar page list, ordered per the pond's persisted sort mode (issue #26),
|
/** 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).
|
* 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
|
* Filtered to the pages the user may read (issue #52) — a label- or
|
||||||
* page-scoped reader sees only their slice of the pond. */
|
* page-scoped reader sees only their slice of the pond. Optional time
|
||||||
async list(user: User, pondId: string): Promise<PageListItemView[]> {
|
* 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 } });
|
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
|
||||||
if (!pond) throw new NotFoundException();
|
if (!pond) throw new NotFoundException();
|
||||||
const settings = pondSettingsSchema.parse(pond.settings ?? {});
|
const settings = pondSettingsSchema.parse(pond.settings ?? {});
|
||||||
const pages = await this.prisma.page.findMany({
|
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],
|
orderBy: PagesService.SORT_ORDER[settings.sidebarSort],
|
||||||
include: { labels: { select: { labelId: true } } },
|
include: { labels: { select: { labelId: true } } },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -251,7 +251,9 @@ export function buildOpenApiDocument(): object {
|
|||||||
paths: {
|
paths: {
|
||||||
'/me': {
|
'/me': {
|
||||||
get: {
|
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')) },
|
responses: { '200': jsonResponse('Token identity', ref('Me')) },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -273,7 +275,23 @@ export function buildOpenApiDocument(): object {
|
|||||||
'/ponds/{pondSlug}/pages': {
|
'/ponds/{pondSlug}/pages': {
|
||||||
get: {
|
get: {
|
||||||
summary: 'Readable pages of the pond.',
|
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: {
|
responses: {
|
||||||
'200': jsonResponse('Pages', { type: 'array', items: ref('PageListItem') }),
|
'200': jsonResponse('Pages', { type: 'array', items: ref('PageListItem') }),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import {
|
|||||||
commentListQuerySchema,
|
commentListQuerySchema,
|
||||||
createCommentInputSchema,
|
createCommentInputSchema,
|
||||||
createLabelInputSchema,
|
createLabelInputSchema,
|
||||||
|
pageListQuerySchema,
|
||||||
publicCreatePageInputSchema,
|
publicCreatePageInputSchema,
|
||||||
publicSearchQuerySchema,
|
publicSearchQuerySchema,
|
||||||
publicUpdateLabelInputSchema,
|
publicUpdateLabelInputSchema,
|
||||||
@ -88,9 +89,16 @@ export class PublicApiController {
|
|||||||
@RequiresPondRole('reader', POND)
|
@RequiresPondRole('reader', POND)
|
||||||
listPages(
|
listPages(
|
||||||
@Param('pondSlug') pondSlug: string,
|
@Param('pondSlug') pondSlug: string,
|
||||||
|
@Query('createdSince') createdSince: string | undefined,
|
||||||
|
@Query('updatedSince') updatedSince: string | undefined,
|
||||||
@Req() request: PublicApiRequest,
|
@Req() request: PublicApiRequest,
|
||||||
): Promise<PublicPageListItemView[]> {
|
): 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')
|
@Post('ponds/:pondSlug/pages')
|
||||||
|
|||||||
@ -460,6 +460,50 @@ describe.skipIf(!hasTestDb)('public api v1 (e2e, issue #104)', () => {
|
|||||||
.expect(404);
|
.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 () => {
|
it('manages labels with pond-admin rights and assigns them to pages', async () => {
|
||||||
const label = await pub()
|
const label = await pub()
|
||||||
.post(`/api/public/v1/ponds/${pondSlug}/labels`)
|
.post(`/api/public/v1/ponds/${pondSlug}/labels`)
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
pondFeatureEnabled,
|
pondFeatureEnabled,
|
||||||
pondSettingsSchema,
|
pondSettingsSchema,
|
||||||
type CommentListFilter,
|
type CommentListFilter,
|
||||||
|
type PageListQuery,
|
||||||
type CreateCommentInput,
|
type CreateCommentInput,
|
||||||
type CreateLabelInput,
|
type CreateLabelInput,
|
||||||
type LabelTreeNode,
|
type LabelTreeNode,
|
||||||
@ -93,10 +94,14 @@ export class PublicApiService {
|
|||||||
return this.pondView(pond);
|
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 pond = await this.requirePond(pondSlug);
|
||||||
const [items, labelNames] = await Promise.all([
|
const [items, labelNames] = await Promise.all([
|
||||||
this.pages.list(user, pond.id),
|
this.pages.list(user, pond.id, query),
|
||||||
this.labelNames(pond.id),
|
this.labelNames(pond.id),
|
||||||
]);
|
]);
|
||||||
// parentId is already permission-nulled by the list (#106); mapping it
|
// 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. */
|
/** 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;
|
||||||
|
|||||||
@ -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') ?? ''}`;
|
||||||
|
}
|
||||||
|
|||||||
@ -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 {}
|
||||||
|
|||||||
@ -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}`,
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
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 { 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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -40,8 +40,8 @@ curl -H "Authorization: Bearer dt_pat_..." \
|
|||||||
https://wiki.example.com/api/public/v1/me
|
https://wiki.example.com/api/public/v1/me
|
||||||
```
|
```
|
||||||
|
|
||||||
`GET /me` ist der Smoke-Test — er liefert dein Benutzerkonto, den
|
`GET /me` ist der Smoke-Test — er liefert dein Benutzerkonto (samt
|
||||||
Token-Scope und eine etwaige Teich-Beschränkung.
|
deiner User-ID), den Token-Scope und eine etwaige Teich-Beschränkung.
|
||||||
|
|
||||||
## Lesen
|
## 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
|
# Seiten eines Teichs: Slug, Titel, Parent (Seitenbaum-Slug), Labels, Zeitstempel
|
||||||
curl -H "$AUTH" https://wiki.example.com/api/public/v1/ponds/team/pages
|
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
|
# Eine Seite — Markdown-Quelle UND gerendertes, bereinigtes HTML
|
||||||
curl -H "$AUTH" https://wiki.example.com/api/public/v1/ponds/team/pages/meeting-notes
|
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 |
|
| Tool | Tut |
|
||||||
| ----------------------------------------------------- | -------------------------------------------------- |
|
| ----------------------------------------------------- | -------------------------------------------------- |
|
||||||
| `list_ponds` | die Teiche, die dieses Token erreicht |
|
| `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 |
|
| `read_page(pond, page)` | eine Seite als Markdown plus Metadaten |
|
||||||
| `search(query, pond?, label?)` | Volltextsuche mit Snippets |
|
| `search(query, pond?, label?)` | Volltextsuche mit Snippets |
|
||||||
| `create_page(pond, title, markdown, parent?)` | neue Seite aus Markdown _(write)_ |
|
| `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,
|
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.
|
||||||
|
|||||||
@ -37,8 +37,8 @@ curl -H "Authorization: Bearer dt_pat_..." \
|
|||||||
https://wiki.example.com/api/public/v1/me
|
https://wiki.example.com/api/public/v1/me
|
||||||
```
|
```
|
||||||
|
|
||||||
`GET /me` is the smoke test — it returns your user, the token scope,
|
`GET /me` is the smoke test — it returns your user (including your
|
||||||
and any pond restriction.
|
user id), the token scope, and any pond restriction.
|
||||||
|
|
||||||
## Reading
|
## 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
|
# 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
|
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
|
# One page — Markdown source AND rendered, sanitized HTML
|
||||||
curl -H "$AUTH" https://wiki.example.com/api/public/v1/ponds/team/pages/meeting-notes
|
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 |
|
| Tool | Does |
|
||||||
| ----------------------------------------------------- | ------------------------------------------- |
|
| ----------------------------------------------------- | ------------------------------------------- |
|
||||||
| `list_ponds` | the ponds this token can reach |
|
| `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 |
|
| `read_page(pond, page)` | a page as Markdown plus metadata |
|
||||||
| `search(query, pond?, label?)` | full-text search with snippets |
|
| `search(query, pond?, label?)` | full-text search with snippets |
|
||||||
| `create_page(pond, title, markdown, parent?)` | new page from Markdown _(write)_ |
|
| `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
|
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.
|
||||||
|
|||||||
@ -43,7 +43,7 @@ curl -H "Authorization: Bearer dt_pat_..." \
|
|||||||
| -------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
| -------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Identity | `GET /me` |
|
| Identity | `GET /me` |
|
||||||
| Ponds | `GET /ponds`, `GET /ponds/{slug}` |
|
| 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=` |
|
| Search | `GET /search?q=&pond=&label=` |
|
||||||
| Export | `GET /ponds/{slug}/export/markdown` (ZIP) |
|
| 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}` |
|
| 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.",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 './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';
|
||||||
|
|||||||
@ -92,6 +92,21 @@ export const publicUpdateLabelInputSchema = z
|
|||||||
.partial();
|
.partial();
|
||||||
export type PublicUpdateLabelInput = z.infer<typeof publicUpdateLabelInputSchema>;
|
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({
|
export const publicSearchQuerySchema = z.object({
|
||||||
q: z.string().trim().min(1, 'validation.required').max(200),
|
q: z.string().trim().min(1, 'validation.required').max(200),
|
||||||
pond: z.string().trim().optional(),
|
pond: z.string().trim().optional(),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user