Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 4m53s
CI / Build container images (pull_request) Successful in 4m1s
CI / Auth e2e pack (pull_request) Successful in 7m12s
CI / Import/export fidelity gate (pull_request) Successful in 1m0s
CD / Build and push images (push) Successful in 14s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 4m35s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Failing after 5m14s
CI / Import/export fidelity gate (push) Has been skipped
GET /public/:pond/feed.xml (zuletzt geänderte Seiten) und GET /public/:pond/:page/feed.xml (Versions-Historie), @Public mit 404-Semantik; öffentliche Teiche anonym, nicht-öffentliche über neues read-only Feed-Token je Nutzer als ?token=dt_feed_… (neue Tabelle feed_tokens + Migration, Verwaltung in den Nutzer-Einstellungen, FeedTokensSection). Öffentliche HTML-Seiten annoncieren den Teich-Feed per link rel=alternate. DB-Tests (anonym/privat/Token-Lifecycle) und User-Guide-Doku en+de. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
75 lines
2.5 KiB
TypeScript
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');
|
|
}
|