dorfteich/apps/api/src/public/feed-tokens.service.ts
Claude Fable 5 7252bd16e0
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 4m53s
CI / Build container images (pull_request) Successful in 4m1s
CI / Auth e2e pack (pull_request) Successful in 7m12s
CI / Import/export fidelity gate (pull_request) Successful in 1m0s
CD / Build and push images (push) Successful in 14s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 4m35s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Failing after 5m14s
CI / Import/export fidelity gate (push) Has been skipped
#149: Atom-Feeds für Teiche und Seiten, privat via Feed-Token
GET /public/:pond/feed.xml (zuletzt geänderte Seiten) und
GET /public/:pond/:page/feed.xml (Versions-Historie), @Public mit
404-Semantik; öffentliche Teiche anonym, nicht-öffentliche über neues
read-only Feed-Token je Nutzer als ?token=dt_feed_… (neue Tabelle
feed_tokens + Migration, Verwaltung in den Nutzer-Einstellungen,
FeedTokensSection). Öffentliche HTML-Seiten annoncieren den Teich-Feed
per link rel=alternate. DB-Tests (anonym/privat/Token-Lifecycle) und
User-Guide-Doku en+de.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-20 00:49:53 +02:00

75 lines
2.5 KiB
TypeScript

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');
}