mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
2b3f00f7e3
Centralise the KV key schema and all get/put access behind a FeedRepository class under src/domain/. Every feed/email/list/icon/websub/counter key was previously inlined across ~12 modules with two divergent storeEmail and addFeedToList implementations; the dead src/utils/storage.ts write path is removed and the email key convention unified on feed:<id>:<ts>. Behaviour-preserving: existing tests pass unchanged in logic, plus a new feed-repository.test.ts covering CRUD, key builders, list ops and counters. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
399 lines
12 KiB
TypeScript
399 lines
12 KiB
TypeScript
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
|
|
import { cors } from "hono/cors";
|
|
import { Scalar } from "@scalar/hono-api-reference";
|
|
import { Env, FeedConfig } from "../../types";
|
|
import { apiAuthMiddleware } from "../../lib/auth";
|
|
import {
|
|
createFeedRecord,
|
|
updateFeedRecord,
|
|
deleteFeedRecord,
|
|
} from "../../lib/feed-service";
|
|
import { deleteAttachmentsForEmails } from "../admin/helpers";
|
|
import { FeedRepository } from "../../domain/feed-repository";
|
|
import { getStats } from "../../utils/stats";
|
|
import { feedEmailAddress, feedRssUrl, feedAtomUrl } from "../../utils/urls";
|
|
import {
|
|
ErrorSchema,
|
|
FeedIdParam,
|
|
EntryIdParam,
|
|
FeedCreateSchema,
|
|
FeedUpdateSchema,
|
|
FeedSchema,
|
|
FeedListSchema,
|
|
EmailListSchema,
|
|
EmailSchema,
|
|
StatsSchema,
|
|
} from "./schemas";
|
|
|
|
type AppEnv = { Bindings: Env };
|
|
|
|
const OkSchema = z.object({ ok: z.boolean() }).openapi("Ok");
|
|
|
|
const jsonContent = <T>(schema: T, description: string) => ({
|
|
content: { "application/json": { schema } },
|
|
description,
|
|
});
|
|
|
|
const bearer = [{ bearerAuth: [] }];
|
|
|
|
function normalizeSenders(senders?: string[]): string[] | undefined {
|
|
return senders?.map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
}
|
|
|
|
function toFeed(
|
|
id: string,
|
|
config: FeedConfig,
|
|
emailCount: number,
|
|
env: Env,
|
|
): z.infer<typeof FeedSchema> {
|
|
return {
|
|
id,
|
|
title: config.title,
|
|
description: config.description,
|
|
language: config.language,
|
|
allowedSenders: config.allowed_senders ?? [],
|
|
blockedSenders: config.blocked_senders ?? [],
|
|
createdAt: config.created_at,
|
|
updatedAt: config.updated_at,
|
|
expiresAt: config.expires_at,
|
|
emailCount,
|
|
emailAddress: feedEmailAddress(id, env),
|
|
rssUrl: feedRssUrl(id, env),
|
|
atomUrl: feedAtomUrl(id, env),
|
|
};
|
|
}
|
|
|
|
export const apiApp = new OpenAPIHono<AppEnv>({
|
|
defaultHook: (result, c) => {
|
|
if (!result.success) {
|
|
const message = result.error.issues
|
|
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
|
.join("; ");
|
|
return c.json({ error: `Validation failed: ${message}` }, 400);
|
|
}
|
|
},
|
|
});
|
|
|
|
// Token auth on the feed/email routes. The spec, docs, and /v1/stats stay public.
|
|
apiApp.use("/v1/feeds", apiAuthMiddleware);
|
|
apiApp.use("/v1/feeds/*", apiAuthMiddleware);
|
|
|
|
// Public monitoring stats — readable from any origin (landing page, embeds).
|
|
apiApp.use("/v1/stats", cors({ origin: "*" }));
|
|
|
|
apiApp.openAPIRegistry.registerComponent("securitySchemes", "bearerAuth", {
|
|
type: "http",
|
|
scheme: "bearer",
|
|
description: "Use the admin password as the bearer token.",
|
|
});
|
|
|
|
// ── Feeds ─────────────────────────────────────────────────────────────────────
|
|
|
|
apiApp.openapi(
|
|
createRoute({
|
|
method: "get",
|
|
path: "/v1/feeds",
|
|
tags: ["Feeds"],
|
|
summary: "List all feeds",
|
|
security: bearer,
|
|
responses: {
|
|
200: jsonContent(FeedListSchema, "The list of feeds"),
|
|
401: jsonContent(ErrorSchema, "Unauthorized"),
|
|
},
|
|
}),
|
|
async (c) => {
|
|
const env = c.env;
|
|
const feeds = await FeedRepository.from(env).listFeeds();
|
|
return c.json(
|
|
{
|
|
feeds: feeds.map((f) => ({
|
|
id: f.id,
|
|
title: f.title,
|
|
description: f.description,
|
|
expiresAt: f.expires_at,
|
|
emailAddress: feedEmailAddress(f.id, env),
|
|
rssUrl: feedRssUrl(f.id, env),
|
|
atomUrl: feedAtomUrl(f.id, env),
|
|
})),
|
|
},
|
|
200,
|
|
);
|
|
},
|
|
);
|
|
|
|
apiApp.openapi(
|
|
createRoute({
|
|
method: "post",
|
|
path: "/v1/feeds",
|
|
tags: ["Feeds"],
|
|
summary: "Create a feed",
|
|
security: bearer,
|
|
request: {
|
|
body: { content: { "application/json": { schema: FeedCreateSchema } } },
|
|
},
|
|
responses: {
|
|
201: jsonContent(FeedSchema, "The created feed"),
|
|
400: jsonContent(ErrorSchema, "Invalid request body"),
|
|
401: jsonContent(ErrorSchema, "Unauthorized"),
|
|
},
|
|
}),
|
|
async (c) => {
|
|
const env = c.env;
|
|
const body = c.req.valid("json");
|
|
const { feedId, config } = await createFeedRecord(env, {
|
|
title: body.title,
|
|
description: body.description,
|
|
language: body.language,
|
|
allowedSenders: normalizeSenders(body.allowedSenders) ?? [],
|
|
blockedSenders: normalizeSenders(body.blockedSenders) ?? [],
|
|
lifetimeHours: body.lifetimeHours,
|
|
});
|
|
return c.json(toFeed(feedId, config, 0, env), 201);
|
|
},
|
|
);
|
|
|
|
apiApp.openapi(
|
|
createRoute({
|
|
method: "get",
|
|
path: "/v1/feeds/{feedId}",
|
|
tags: ["Feeds"],
|
|
summary: "Get a feed",
|
|
security: bearer,
|
|
request: { params: FeedIdParam },
|
|
responses: {
|
|
200: jsonContent(FeedSchema, "The feed"),
|
|
401: jsonContent(ErrorSchema, "Unauthorized"),
|
|
404: jsonContent(ErrorSchema, "Feed not found"),
|
|
},
|
|
}),
|
|
async (c) => {
|
|
const env = c.env;
|
|
const { feedId } = c.req.valid("param");
|
|
const repo = FeedRepository.from(env);
|
|
const config = await repo.getConfig(feedId);
|
|
if (!config) return c.json({ error: "Feed not found" }, 404);
|
|
const metadata = await repo.getMetadata(feedId);
|
|
return c.json(
|
|
toFeed(feedId, config, metadata?.emails.length ?? 0, env),
|
|
200,
|
|
);
|
|
},
|
|
);
|
|
|
|
apiApp.openapi(
|
|
createRoute({
|
|
method: "patch",
|
|
path: "/v1/feeds/{feedId}",
|
|
tags: ["Feeds"],
|
|
summary: "Update a feed",
|
|
security: bearer,
|
|
request: {
|
|
params: FeedIdParam,
|
|
body: { content: { "application/json": { schema: FeedUpdateSchema } } },
|
|
},
|
|
responses: {
|
|
200: jsonContent(FeedSchema, "The updated feed"),
|
|
400: jsonContent(ErrorSchema, "Invalid request body"),
|
|
401: jsonContent(ErrorSchema, "Unauthorized"),
|
|
404: jsonContent(ErrorSchema, "Feed not found"),
|
|
409: jsonContent(ErrorSchema, "Feed has expired"),
|
|
},
|
|
}),
|
|
async (c) => {
|
|
const env = c.env;
|
|
const { feedId } = c.req.valid("param");
|
|
const body = c.req.valid("json");
|
|
const result = await updateFeedRecord(env, feedId, {
|
|
title: body.title,
|
|
description: body.description,
|
|
language: body.language,
|
|
allowedSenders: normalizeSenders(body.allowedSenders),
|
|
blockedSenders: normalizeSenders(body.blockedSenders),
|
|
lifetimeHours: body.lifetimeHours,
|
|
});
|
|
if (result.status === "not_found")
|
|
return c.json({ error: "Feed not found" }, 404);
|
|
if (result.status === "expired")
|
|
return c.json({ error: "Feed has expired and cannot be modified" }, 409);
|
|
const metadata = await FeedRepository.from(env).getMetadata(feedId);
|
|
return c.json(
|
|
toFeed(feedId, result.config, metadata?.emails.length ?? 0, env),
|
|
200,
|
|
);
|
|
},
|
|
);
|
|
|
|
apiApp.openapi(
|
|
createRoute({
|
|
method: "delete",
|
|
path: "/v1/feeds/{feedId}",
|
|
tags: ["Feeds"],
|
|
summary: "Delete a feed",
|
|
security: bearer,
|
|
request: { params: FeedIdParam },
|
|
responses: {
|
|
200: jsonContent(OkSchema, "The feed was deleted"),
|
|
401: jsonContent(ErrorSchema, "Unauthorized"),
|
|
404: jsonContent(ErrorSchema, "Feed not found"),
|
|
},
|
|
}),
|
|
async (c) => {
|
|
const env = c.env;
|
|
const { feedId } = c.req.valid("param");
|
|
const removed = await deleteFeedRecord(c, env, feedId);
|
|
if (!removed) return c.json({ error: "Feed not found" }, 404);
|
|
return c.json({ ok: true }, 200);
|
|
},
|
|
);
|
|
|
|
// ── Emails ──────────────────────────────────────────────────────────────────
|
|
|
|
apiApp.openapi(
|
|
createRoute({
|
|
method: "get",
|
|
path: "/v1/feeds/{feedId}/emails",
|
|
tags: ["Emails"],
|
|
summary: "List a feed's emails",
|
|
security: bearer,
|
|
request: { params: FeedIdParam },
|
|
responses: {
|
|
200: jsonContent(EmailListSchema, "The feed's emails"),
|
|
401: jsonContent(ErrorSchema, "Unauthorized"),
|
|
404: jsonContent(ErrorSchema, "Feed not found"),
|
|
},
|
|
}),
|
|
async (c) => {
|
|
const env = c.env;
|
|
const { feedId } = c.req.valid("param");
|
|
const metadata = await FeedRepository.from(env).getMetadata(feedId);
|
|
if (!metadata) return c.json({ error: "Feed not found" }, 404);
|
|
return c.json(
|
|
{
|
|
emails: metadata.emails.map((e) => ({
|
|
entryId: e.receivedAt,
|
|
subject: e.subject,
|
|
receivedAt: e.receivedAt,
|
|
size: e.size,
|
|
attachmentIds: e.attachmentIds,
|
|
})),
|
|
},
|
|
200,
|
|
);
|
|
},
|
|
);
|
|
|
|
apiApp.openapi(
|
|
createRoute({
|
|
method: "get",
|
|
path: "/v1/feeds/{feedId}/emails/{entryId}",
|
|
tags: ["Emails"],
|
|
summary: "Get a single email",
|
|
security: bearer,
|
|
request: { params: EntryIdParam },
|
|
responses: {
|
|
200: jsonContent(EmailSchema, "The email"),
|
|
401: jsonContent(ErrorSchema, "Unauthorized"),
|
|
404: jsonContent(ErrorSchema, "Email not found"),
|
|
},
|
|
}),
|
|
async (c) => {
|
|
const env = c.env;
|
|
const { feedId, entryId } = c.req.valid("param");
|
|
const receivedAt = parseInt(entryId, 10);
|
|
const repo = FeedRepository.from(env);
|
|
const metadata = await repo.getMetadata(feedId);
|
|
const metaEntry = metadata?.emails.find((e) => e.receivedAt === receivedAt);
|
|
if (!metaEntry) return c.json({ error: "Email not found" }, 404);
|
|
const data = await repo.getEmail(metaEntry.key);
|
|
if (!data) return c.json({ error: "Email not found" }, 404);
|
|
return c.json(
|
|
{
|
|
entryId: receivedAt,
|
|
subject: data.subject,
|
|
from: data.from,
|
|
receivedAt: data.receivedAt,
|
|
content: data.content,
|
|
attachments: (data.attachments ?? []).map((a) => ({
|
|
id: a.id,
|
|
filename: a.filename,
|
|
contentType: a.contentType,
|
|
size: a.size,
|
|
url: `/files/${a.id}/${encodeURIComponent(a.filename)}`,
|
|
})),
|
|
},
|
|
200,
|
|
);
|
|
},
|
|
);
|
|
|
|
apiApp.openapi(
|
|
createRoute({
|
|
method: "delete",
|
|
path: "/v1/feeds/{feedId}/emails/{entryId}",
|
|
tags: ["Emails"],
|
|
summary: "Delete a single email",
|
|
security: bearer,
|
|
request: { params: EntryIdParam },
|
|
responses: {
|
|
200: jsonContent(OkSchema, "The email was deleted"),
|
|
401: jsonContent(ErrorSchema, "Unauthorized"),
|
|
404: jsonContent(ErrorSchema, "Email not found"),
|
|
},
|
|
}),
|
|
async (c) => {
|
|
const env = c.env;
|
|
const repo = FeedRepository.from(env);
|
|
const { feedId, entryId } = c.req.valid("param");
|
|
const receivedAt = parseInt(entryId, 10);
|
|
const metadata = await repo.getMetadata(feedId);
|
|
const metaEntry = metadata?.emails.find((e) => e.receivedAt === receivedAt);
|
|
if (!metadata || !metaEntry)
|
|
return c.json({ error: "Email not found" }, 404);
|
|
|
|
await repo.deleteEmail(metaEntry.key);
|
|
await deleteAttachmentsForEmails(env, metadata.emails, [metaEntry.key]);
|
|
metadata.emails = metadata.emails.filter((e) => e.key !== metaEntry.key);
|
|
await repo.putMetadata(feedId, metadata);
|
|
|
|
return c.json({ ok: true }, 200);
|
|
},
|
|
);
|
|
|
|
// ── Stats ─────────────────────────────────────────────────────────────────────
|
|
|
|
apiApp.openapi(
|
|
createRoute({
|
|
method: "get",
|
|
path: "/v1/stats",
|
|
tags: ["Stats"],
|
|
summary: "Read monitoring counters (public)",
|
|
responses: {
|
|
200: jsonContent(StatsSchema, "Monitoring counters"),
|
|
},
|
|
}),
|
|
async (c) => {
|
|
return c.json(await getStats(c.env), 200);
|
|
},
|
|
);
|
|
|
|
// ── OpenAPI document + docs (public) ───────────────────────────────────────────
|
|
|
|
apiApp.doc31("/openapi.json", {
|
|
openapi: "3.1.0",
|
|
info: {
|
|
title: "kill-the-news API",
|
|
version: "1.0.0",
|
|
description:
|
|
"REST API for managing email-to-RSS feeds, their emails, and monitoring counters.",
|
|
},
|
|
servers: [{ url: "/api" }],
|
|
});
|
|
|
|
apiApp.get(
|
|
"/docs",
|
|
Scalar({
|
|
url: "/api/openapi.json",
|
|
pageTitle: "kill-the-news API",
|
|
}),
|
|
);
|