From 45d2a14a12c6744b98bc1ab391182cd0b78f1569 Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Sat, 23 May 2026 23:01:15 +0200 Subject: [PATCH] feat(api): add versioned REST API with OpenAPI 3.1 spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose /api/v1/* for feed and email management (feeds CRUD, email list/get/delete, stats) so the service can be automated without scraping the admin UI. Built on @hono/zod-openapi; the OpenAPI 3.1 spec is served at /api/openapi.json with a Scalar reference at /api/docs. Auth is token-based (Authorization: Bearer ) plus the existing reverse-proxy headers — no cookie, no CSRF. Extracted the auth primitives into src/lib/auth.ts and the feed create/update/delete orchestration into src/lib/feed-service.ts so the admin UI and the REST API share a single source of truth. Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 8 + INSTALL.md | 13 ++ README.md | 39 +++- TODO.md | 2 +- package-lock.json | 138 ++++++++++++- package.json | 2 + src/index.ts | 3 + src/lib/auth.ts | 78 ++++++++ src/lib/feed-service.ts | 245 +++++++++++++++++++++++ src/routes/admin.tsx | 82 ++------ src/routes/admin/feeds.tsx | 188 +++--------------- src/routes/api/api.test.ts | 284 ++++++++++++++++++++++++++ src/routes/api/index.ts | 397 +++++++++++++++++++++++++++++++++++++ src/routes/api/schemas.ts | 153 ++++++++++++++ 14 files changed, 1398 insertions(+), 234 deletions(-) create mode 100644 src/lib/auth.ts create mode 100644 src/lib/feed-service.ts create mode 100644 src/routes/api/api.test.ts create mode 100644 src/routes/api/index.ts create mode 100644 src/routes/api/schemas.ts diff --git a/CLAUDE.md b/CLAUDE.md index 728d94d..1c9b0cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,9 @@ Single Cloudflare Worker built with Hono. Routes: | `GET /` | Public status page (monitoring counters + link to admin) | | `POST /api/inbound` | Webhook from ForwardEmail; IP-allowlisted to their MX sources | | `GET /api/stats` | Public monitoring counters (JSON) | +| `/api/v1/*` | Versioned REST API (Bearer/proxy auth) — feeds + emails CRUD, stats | +| `GET /api/openapi.json` | OpenAPI 3.1 spec (public) | +| `GET /api/docs` | Rendered API reference (Scalar, public) | | `GET /rss/:feedId` | Public RSS 2.0 feed | | `GET /atom/:feedId` | Public Atom feed (with WebSub hub header) | | `GET /entries/:feedId/:entryId` | Individual email HTML view | @@ -68,7 +71,12 @@ src/ emails.tsx # Emails list/delete UI ui.tsx # Shared UI components helpers.ts # Shared admin helpers + api/ # Versioned REST API (@hono/zod-openapi) + index.ts # OpenAPIHono app: /v1 routes + /openapi.json + /docs + schemas.ts # Zod schemas (validation + OpenAPI source of truth) lib/ + auth.ts # timingSafeEqual, proxy-auth check, API bearer middleware + feed-service.ts # Shared feed create/update/delete (admin UI + REST API) cloudflare-email.ts # Cloudflare Email routing handler email-parser.ts # Email parsing (mailparser) email-processor.ts # Core ingestion logic (parse → store) diff --git a/INSTALL.md b/INSTALL.md index fff0a0a..16129e0 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -184,6 +184,19 @@ Password login remains available as a fallback when the proxy check fails. > **Security note:** `CF-Connecting-IP` can be spoofed on direct `workers.dev` requests. Disable the `workers.dev` subdomain in production (`workers_dev = false` in `[env.production]`). +### REST API authentication + +The versioned REST API (`/api/v1/*`) is authenticated independently of the cookie-based +admin UI — there is no CSRF check, so it is suited to server-to-server automation. A +request is authorized when **either**: + +- it carries `Authorization: Bearer ` (the same admin password secret), **or** +- it passes the reverse-proxy check above (`PROXY_TRUSTED_IPS` + `X-Auth-Proxy-Secret` + `Remote-User`). + +The OpenAPI 3.1 spec (`/api/openapi.json`) and the Scalar reference (`/api/docs`) are +public. In the Scalar UI, click **Authorize** and paste the admin password as the bearer +token to try requests. See the route table in [README.md](README.md#rest-api). + ## Upgrading dependencies To refresh dependencies to latest: diff --git a/README.md b/README.md index 6f0eb6e..70c0702 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ kill-the-news keeps the same workflow while avoiding shared domains and shared d - Email attachments stored in Cloudflare R2 and exposed as RSS enclosures (optional) - Cloudflare KV storage for feed config + email metadata/content - Password-protected admin UI +- Versioned REST API (`/api/v1/*`) with an OpenAPI 3.1 spec and Scalar docs for automation ## Architecture @@ -52,7 +53,9 @@ Main routes: - `src/routes/rss.ts`: RSS rendering - `src/routes/atom.ts`: Atom feed rendering - `src/routes/files.ts`: attachment file serving from R2 -- `src/routes/admin.ts`: admin UI + feed CRUD +- `src/routes/admin.tsx`: admin UI + feed CRUD +- `src/routes/api/`: versioned REST API + OpenAPI spec/docs (`/api/v1/*`, `/api/openapi.json`, `/api/docs`) +- `src/lib/feed-service.ts`: shared feed create/update/delete (used by the admin UI and the REST API) - `src/routes/home.tsx`: public status page (`GET /`) - `src/routes/stats.ts`: monitoring counters API (`GET /api/stats`) @@ -75,6 +78,40 @@ Main routes: The same figures are rendered on the public status page at `GET /`. Cumulative counters are persisted in the `EMAIL_STORAGE` KV under the `stats:counters` key. +### REST API + +A versioned REST API lets you automate feed and email management without scraping the +admin UI. The OpenAPI 3.1 spec is served at `GET /api/openapi.json` and a rendered +reference (Scalar) at `GET /api/docs` — both public. + +All `/api/v1/*` endpoints require authentication, using either: + +- **Bearer token**: `Authorization: Bearer `, or +- **Reverse-proxy auth**: the same trusted-IP + `X-Auth-Proxy-Secret` + `Remote-User` + headers as the admin UI (see [INSTALL.md](INSTALL.md)). + +| Method | Path | Purpose | +| -------- | ------------------------------------ | ------------------------ | +| `GET` | `/api/v1/feeds` | List feeds | +| `POST` | `/api/v1/feeds` | Create a feed | +| `GET` | `/api/v1/feeds/{feedId}` | Get a feed | +| `PATCH` | `/api/v1/feeds/{feedId}` | Update a feed | +| `DELETE` | `/api/v1/feeds/{feedId}` | Delete a feed | +| `GET` | `/api/v1/feeds/{feedId}/emails` | List a feed's emails | +| `GET` | `/api/v1/feeds/{feedId}/emails/{id}` | Get a single email | +| `DELETE` | `/api/v1/feeds/{feedId}/emails/{id}` | Delete a single email | +| `GET` | `/api/v1/stats` | Read monitoring counters | + +The email `{id}` is the email's `receivedAt` timestamp (as returned by the list endpoint). + +```bash +# Create a feed +curl -X POST https://yourdomain.com/api/v1/feeds \ + -H "Authorization: Bearer $ADMIN_PASSWORD" \ + -H 'Content-Type: application/json' \ + -d '{"title":"Daily Digest","allowedSenders":["news@example.com"]}' +``` + ## Installation See **[INSTALL.md](INSTALL.md)** for the full setup, deployment, and configuration guide. Quick start: diff --git a/TODO.md b/TODO.md index 1864b09..5f54cbf 100644 --- a/TODO.md +++ b/TODO.md @@ -32,7 +32,7 @@ Feature gaps identified by comparing with [kill-the-newsletter](https://github.c - [x] **Rate limiting via Cloudflare WAF rules** — protect `/api/inbound` and `/admin` against abuse. Configure WAF custom rules in the Cloudflare dashboard (or via Terraform): rate-limit `/api/inbound` to ~60 req/min per IP, and `/admin` to ~20 req/min per IP. No code changes required; this is pure infrastructure configuration. -- [ ] **REST API with OpenAPI description** — expose a documented, machine-consumable REST API for feed/email management (create/list/update/delete feeds, list/read/delete emails, read stats) so the service can be automated without scraping the admin UI. Today only a couple of ad-hoc JSON endpoints exist (`POST /admin/feeds/create`, `POST /admin/api/feeds/:feedId/update`). Consolidate these under a versioned `/api/v1/*` surface with consistent auth (reuse the admin password / proxy-auth) and ship an OpenAPI 3.1 spec served at e.g. `/api/openapi.json` plus a rendered docs page. Prefer `@hono/zod-openapi` so the existing Zod schemas in `src/routes/admin/feeds.tsx` drive both validation and the generated spec (single source of truth). +- [x] **REST API with OpenAPI description** — expose a documented, machine-consumable REST API for feed/email management (create/list/update/delete feeds, list/read/delete emails, read stats) so the service can be automated without scraping the admin UI. Implemented as a versioned `/api/v1/*` surface (Bearer-token auth with the admin password, plus the existing proxy-auth) built on `@hono/zod-openapi`; the OpenAPI 3.1 spec is served at `/api/openapi.json` with a Scalar docs page at `/api/docs`. Feed create/update/delete logic was extracted into `src/lib/feed-service.ts` so the admin UI and the REST API share a single source of truth. - [ ] **Migrate feed metadata to Durable Objects for atomic writes** — the current KV-based metadata store has a read-modify-write race condition: two concurrent emails to the same feed can silently overwrite each other's changes. Cloudflare Durable Objects serialise access per feed and eliminate the race entirely. Requires replacing `feed::metadata` KV writes in `src/lib/email-processor.ts` with a Durable Object that exposes an `appendEmail()` RPC, updating `wrangler.toml` with a DO binding, and migrating existing metadata at deploy time. diff --git a/package-lock.json b/package-lock.json index f2d2a27..0070b81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,9 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "@hono/zod-openapi": "^1.4.0", "@hono/zod-validator": "^0.8.0", + "@scalar/hono-api-reference": "^0.10.19", "escape-html": "^1.0.3", "feed": "5.2.1", "hono": "4.12.22", @@ -37,6 +39,18 @@ "wrangler": "4.94.0" } }, + "node_modules/@asteasolutions/zod-to-openapi": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-8.5.0.tgz", + "integrity": "sha512-SABbKiObg5dLRiTFnqiW1WWwGcg1BJfmHtT2asIBnBHg6Smy/Ms2KHc650+JI4Hw7lSkdiNebEGXpwoxfben8Q==", + "license": "MIT", + "dependencies": { + "openapi3-ts": "^4.1.2" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -822,6 +836,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@hono/zod-openapi": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@hono/zod-openapi/-/zod-openapi-1.4.0.tgz", + "integrity": "sha512-AFchqR1N/NxfI4hUOSGI2/g8zLROxA1OE7Oh5JJFlTaGxhrdRyH+93gd0tIBpb0z8s9r8hUoNnaOBfHbdb4NMw==", + "license": "MIT", + "dependencies": { + "@asteasolutions/zod-to-openapi": "^8.5.0", + "@hono/zod-validator": "^0.8.0", + "openapi3-ts": "^4.5.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "hono": ">=4.10.0", + "zod": "^4.0.0" + } + }, "node_modules/@hono/zod-validator": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/@hono/zod-validator/-/zod-validator-0.8.0.tgz", @@ -1954,6 +1986,99 @@ "dev": true, "license": "MIT" }, + "node_modules/@scalar/client-side-rendering": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/@scalar/client-side-rendering/-/client-side-rendering-0.1.12.tgz", + "integrity": "sha512-prwHK4ozTU268BHZ/5OstoKB23JSidDuvddAOp0bVz9c29ZxsyzzxPtPcVgF7X16LiZnS1OzY030FoDCM+iC9Q==", + "license": "MIT", + "dependencies": { + "@scalar/schemas": "0.3.2", + "@scalar/types": "0.12.2", + "@scalar/validation": "0.6.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/helpers": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@scalar/helpers/-/helpers-0.8.0.tgz", + "integrity": "sha512-gmOC6VravNB9VDl6wnt/GOj4K/hn48tj5bpW4AM4MhH8Ubil6uu7g1DSoKHwltu8Ks79KEtR6JmOrROi9R7jaQ==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/hono-api-reference": { + "version": "0.10.19", + "resolved": "https://registry.npmjs.org/@scalar/hono-api-reference/-/hono-api-reference-0.10.19.tgz", + "integrity": "sha512-6EfwN/lfPvePzAxe9UE8fr/ZuAAqS6ttUwQu9JTgk2Xl/clicaVVSOc0gyGt+8GLXdysoNinjZ74we8xqNWyCA==", + "license": "MIT", + "dependencies": { + "@scalar/client-side-rendering": "0.1.12" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "hono": "^4.12.5" + } + }, + "node_modules/@scalar/schemas": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@scalar/schemas/-/schemas-0.3.2.tgz", + "integrity": "sha512-iadXBgJ02XUU5C5s6/xh/PmGLzUPd7X8upXIvPWBXDcQ4FHACNgkG8PPZ/beYM8UPDDkTUPM3ygEs0G6jKwGjQ==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.8.0", + "@scalar/validation": "0.6.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/types": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@scalar/types/-/types-0.12.2.tgz", + "integrity": "sha512-EzLkubCb7xioiTm9eYnmn/032akaq4kkrrdclgV2uezwtniR8ErQICjhMl2AjBWL6nstHiFZ9RnPZm2Z2/KM0Q==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.8.0", + "nanoid": "^5.1.6", + "type-fest": "^5.3.1", + "zod": "^4.3.5" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/types/node_modules/nanoid": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz", + "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/@scalar/validation": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@scalar/validation/-/validation-0.6.0.tgz", + "integrity": "sha512-tpmmG+/xRE2Kn9RpflU3AIyZv08v10+E1ZrJCx7z6+/91zHVxy0M73kC1LT4/8PbYNt85ywyC8+n+D99JdMcGA==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/@sindresorhus/is": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", @@ -4459,6 +4584,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi3-ts": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.5.0.tgz", + "integrity": "sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==", + "license": "MIT", + "dependencies": { + "yaml": "^2.8.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5026,7 +5160,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", - "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -5150,7 +5283,6 @@ "version": "5.6.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", - "dev": true, "license": "(MIT OR CC0-1.0)", "dependencies": { "tagged-tag": "^1.0.0" @@ -6097,9 +6229,7 @@ "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "dev": true, "license": "ISC", - "optional": true, "bin": { "yaml": "bin.mjs" }, diff --git a/package.json b/package.json index 4ccd6b0..34a5f6d 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,9 @@ "wrangler": "4.94.0" }, "dependencies": { + "@hono/zod-openapi": "^1.4.0", "@hono/zod-validator": "^0.8.0", + "@scalar/hono-api-reference": "^0.10.19", "escape-html": "^1.0.3", "feed": "5.2.1", "hono": "4.12.22", diff --git a/src/index.ts b/src/index.ts index 66f70a4..812d730 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { handle as handleStats } from "./routes/stats"; import { handle as handleHome } from "./routes/home"; import { handle as handleFavicon, handleFeedFavicon } from "./routes/favicon"; import { hubRouter } from "./routes/hub"; +import { apiApp } from "./routes/api"; import { handleCloudflareEmail } from "./lib/cloudflare-email"; import { Env } from "./types"; import { logger } from "./lib/logger"; @@ -168,6 +169,8 @@ admin.route("/", handleAdmin); // Mount the route groups app.route("/api", api); +// Versioned REST API + OpenAPI spec/docs (/api/v1/*, /api/openapi.json, /api/docs) +app.route("/api", apiApp); app.route("/rss", rss); app.route("/atom", atom); app.route("/entries", entries); diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..a3169d6 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,78 @@ +import { Context } from "hono"; +import { Env } from "../types"; + +/** + * Constant-time string comparison. Prefers the runtime's native + * `crypto.subtle.timingSafeEqual` (Cloudflare Workers) and falls back to a + * manual constant-time loop in environments that lack it (Node test runtime). + */ +export function timingSafeEqual(a: string, b: string): boolean { + const enc = new TextEncoder(); + const aBytes = enc.encode(a); + const bBytes = enc.encode(b); + // Try native timing-safe implementation first (Cloudflare Workers runtime) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const subtle = crypto.subtle as any; + if (typeof subtle.timingSafeEqual === "function") { + if (aBytes.length !== bBytes.length) return false; + return subtle.timingSafeEqual(aBytes, bBytes); + } + // Constant-time fallback for Node (test environment): encode length + // mismatch into `diff` so the loop always runs over the full length. + const len = Math.max(aBytes.length, bBytes.length); + let diff = aBytes.length ^ bBytes.length; + for (let i = 0; i < len; i++) { + diff |= (aBytes[i] ?? 0) ^ (bBytes[i] ?? 0); + } + return diff === 0; +} + +/** + * Reverse-proxy authentication: trusted only when both `PROXY_AUTH_SECRET` and + * `PROXY_TRUSTED_IPS` are configured, the request comes from a trusted IP, the + * shared secret matches, and a `Remote-User`/`X-Forwarded-User` is present. + */ +export function checkProxyAuth(c: Context, env: Env): boolean { + if (!env.PROXY_AUTH_SECRET || !env.PROXY_TRUSTED_IPS) return false; + + const trustedIps = env.PROXY_TRUSTED_IPS.split(",") + .map((s: string) => s.trim()) + .filter(Boolean); + const clientIp = c.req.header("CF-Connecting-IP") ?? ""; + const providedSecret = c.req.header("X-Auth-Proxy-Secret") ?? ""; + const remoteUser = + c.req.header("Remote-User") || c.req.header("X-Forwarded-User") || ""; + + return ( + trustedIps.includes(clientIp) && + timingSafeEqual(providedSecret, env.PROXY_AUTH_SECRET) && + remoteUser.length > 0 + ); +} + +/** + * Authentication for the machine-facing REST API (`/api/v1/*`). + * Grants access when proxy auth passes OR the request carries a valid + * `Authorization: Bearer `. No cookie, no CSRF — token only. + */ +export async function apiAuthMiddleware( + c: Context<{ Bindings: Env }>, + next: () => Promise, +): Promise { + const env = c.env; + + if (checkProxyAuth(c, env)) { + return next(); + } + + const authHeader = c.req.header("Authorization") ?? ""; + const token = authHeader.startsWith("Bearer ") + ? authHeader.slice("Bearer ".length) + : ""; + + if (token && timingSafeEqual(token, env.ADMIN_PASSWORD)) { + return next(); + } + + return c.json({ error: "Unauthorized" }, 401); +} diff --git a/src/lib/feed-service.ts b/src/lib/feed-service.ts new file mode 100644 index 0000000..b6de352 --- /dev/null +++ b/src/lib/feed-service.ts @@ -0,0 +1,245 @@ +import { Context } from "hono"; +import { Env, FeedConfig, FeedMetadata } from "../types"; +import { generateFeedId } from "../utils/id-generator"; +import { bumpCounters } from "../utils/stats"; +import { waitUntilSafe } from "../utils/worker"; +import { sendUnsubscribes } from "../utils/unsubscribe"; +import { getAttachmentBucket } from "../utils/attachments"; +import { + addFeedToList, + updateFeedInList, + removeFeedFromList, + purgeFeedKeysStep, + collectUnsubscribeUrls, +} from "../routes/admin/helpers"; + +const HOUR_MS = 3_600_000; + +/** + * Resolve a feed's `expires_at` from a requested lifetime (hours). A server-side + * `FEED_TTL_HOURS` always overrides the client-supplied value. Returns undefined + * when no positive lifetime applies (i.e. the feed never expires). + */ +function resolveExpiresAt( + env: Env, + lifetimeHours?: number, +): number | undefined { + const hours = env.FEED_TTL_HOURS + ? parseInt(env.FEED_TTL_HOURS, 10) + : (lifetimeHours ?? NaN); + return Number.isFinite(hours) && hours > 0 + ? Date.now() + hours * HOUR_MS + : undefined; +} + +export interface CreateFeedInput { + title: string; + description?: string; + language: string; + allowedSenders: string[]; + blockedSenders: string[]; + lifetimeHours?: number; +} + +/** + * Create a feed: write its config + empty metadata, register it in the global + * list, and bump the `feeds_created` counter. Returns the new feed id + config. + */ +export async function createFeedRecord( + env: Env, + input: CreateFeedInput, +): Promise<{ feedId: string; config: FeedConfig }> { + const emailStorage = env.EMAIL_STORAGE; + const expiresAt = resolveExpiresAt(env, input.lifetimeHours); + const feedId = generateFeedId(); + + const config: FeedConfig = { + title: input.title, + description: input.description, + language: input.language, + allowed_senders: input.allowedSenders, + blocked_senders: input.blockedSenders, + created_at: Date.now(), + updated_at: Date.now(), + ...(expiresAt !== undefined ? { expires_at: expiresAt } : {}), + }; + + const metadata: FeedMetadata = { emails: [] }; + + await Promise.all([ + emailStorage.put(`feed:${feedId}:config`, JSON.stringify(config)), + emailStorage.put(`feed:${feedId}:metadata`, JSON.stringify(metadata)), + ]); + + await addFeedToList( + emailStorage, + feedId, + input.title, + input.description, + expiresAt, + ); + + await bumpCounters(emailStorage, { + feeds_created: 1, + last_feed_created_at: new Date().toISOString(), + }); + + return { feedId, config }; +} + +export interface UpdateFeedInput { + title?: string; + description?: string; + language?: string; + allowedSenders?: string[]; + blockedSenders?: string[]; + lifetimeHours?: number; +} + +export type UpdateFeedResult = + | { status: "ok"; config: FeedConfig } + | { status: "not_found" } + | { status: "expired" }; + +/** + * Apply a partial patch to a feed's config and mirror title/description/expiry + * into the global list. Fields left undefined on `input` are preserved. + * + * A full edit (default) rejects expired feeds and recomputes `expires_at` from + * `FEED_TTL_HOURS`/`lifetimeHours`. `inPlace` skips both — used by the dashboard's + * minimal title/description edit, which must never touch expiry. + */ +export async function updateFeedRecord( + env: Env, + feedId: string, + input: UpdateFeedInput, + options: { inPlace?: boolean } = {}, +): Promise { + const emailStorage = env.EMAIL_STORAGE; + const feedConfigKey = `feed:${feedId}:config`; + + const existing = (await emailStorage.get(feedConfigKey, { + type: "json", + })) as FeedConfig | null; + + if (!existing) return { status: "not_found" }; + + if ( + !options.inPlace && + existing.expires_at !== undefined && + existing.expires_at <= Date.now() + ) { + return { status: "expired" }; + } + + // Full edit recomputes expiry (FEED_TTL_HOURS or a supplied lifetime resets the + // clock; an absent lifetime preserves it). In-place edits leave expiry alone. + const expiresAt = + !options.inPlace && + (env.FEED_TTL_HOURS || input.lifetimeHours !== undefined) + ? resolveExpiresAt(env, input.lifetimeHours) + : existing.expires_at; + + const config: FeedConfig = { + ...existing, + ...(input.title !== undefined ? { title: input.title } : {}), + ...(input.description !== undefined + ? { description: input.description } + : {}), + ...(input.language !== undefined ? { language: input.language } : {}), + ...(input.allowedSenders !== undefined + ? { allowed_senders: input.allowedSenders } + : {}), + ...(input.blockedSenders !== undefined + ? { blocked_senders: input.blockedSenders } + : {}), + updated_at: Date.now(), + expires_at: expiresAt, + }; + + await emailStorage.put(feedConfigKey, JSON.stringify(config)); + await updateFeedInList( + emailStorage, + feedId, + config.title, + config.description, + expiresAt, + ); + + return { status: "ok", config }; +} + +type DeleteFeedFastResult = { + ok: boolean; + configDeleted: boolean; + metadataDeleted: boolean; + errors: string[]; +}; + +/** + * Delete a feed's config + metadata keys, reporting per-key outcomes. The + * larger email/attachment cleanup is handled separately via purgeFeedKeysStep. + */ +export async function deleteFeedFastDetailed( + emailStorage: KVNamespace, + feedId: string, +): Promise { + const feedConfigKey = `feed:${feedId}:config`; + const feedMetadataKey = `feed:${feedId}:metadata`; + + const errors: string[] = []; + let configDeleted = false; + let metadataDeleted = false; + + try { + await emailStorage.delete(feedConfigKey); + configDeleted = true; + } catch (error) { + errors.push(`config delete failed: ${String(error)}`); + } + + try { + await emailStorage.delete(feedMetadataKey); + metadataDeleted = true; + } catch (error) { + errors.push(`metadata delete failed: ${String(error)}`); + } + + return { ok: configDeleted, configDeleted, metadataDeleted, errors }; +} + +/** + * Delete a single feed end-to-end: capture unsubscribe URLs, drop its config + + * metadata, remove it from the list, bump the counter, and schedule background + * unsubscribe requests + key purge via ctx.waitUntil. Returns whether the feed + * was present in the global list. + */ +export async function deleteFeedRecord( + c: Context<{ Bindings: Env }>, + env: Env, + feedId: string, +): Promise { + const emailStorage = env.EMAIL_STORAGE; + + // Read unsubscribe URLs before the metadata is deleted below. + const unsubscribeUrls = await collectUnsubscribeUrls(emailStorage, feedId); + + await deleteFeedFastDetailed(emailStorage, feedId); + const removed = await removeFeedFromList(emailStorage, feedId); + if (removed) { + await bumpCounters(emailStorage, { feeds_deleted: 1 }); + } + + if (unsubscribeUrls.length > 0) { + waitUntilSafe(c, sendUnsubscribes(unsubscribeUrls, env)); + } + + waitUntilSafe( + c, + purgeFeedKeysStep(emailStorage, feedId, { + bucket: getAttachmentBucket(env), + }), + ); + + return removed; +} diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index f5c172f..bb7f672 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -2,12 +2,14 @@ import { Context, Hono } from "hono"; import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod"; -import { Env, FeedConfig } from "../types"; +import { Env } from "../types"; import { csrf } from "hono/csrf"; import { ADMIN_COOKIE_MAX_AGE } from "../config/constants"; import { logger } from "../lib/logger"; +import { timingSafeEqual, checkProxyAuth } from "../lib/auth"; import { Layout, clampText } from "./admin/ui"; -import { listAllFeeds, updateFeedInList } from "./admin/helpers"; +import { listAllFeeds } from "./admin/helpers"; +import { updateFeedRecord } from "../lib/feed-service"; import { feedRssUrl, feedAtomUrl, feedEmailAddress } from "../utils/urls"; import { feedsRouter } from "./admin/feeds"; import { emailsRouter } from "./admin/emails"; @@ -37,27 +39,6 @@ app.use("*", async (c, next) => { await next(); }); -function timingSafeEqual(a: string, b: string): boolean { - const enc = new TextEncoder(); - const aBytes = enc.encode(a); - const bBytes = enc.encode(b); - // Try native timing-safe implementation first (Cloudflare Workers runtime) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const subtle = crypto.subtle as any; - if (typeof subtle.timingSafeEqual === "function") { - if (aBytes.length !== bBytes.length) return false; - return subtle.timingSafeEqual(aBytes, bBytes); - } - // Constant-time fallback for Node (test environment): encode length - // mismatch into `diff` so the loop always runs over the full length. - const len = Math.max(aBytes.length, bBytes.length); - let diff = aBytes.length ^ bBytes.length; - for (let i = 0; i < len; i++) { - diff |= (aBytes[i] ?? 0) ^ (bBytes[i] ?? 0); - } - return diff === 0; -} - // Authentication middleware for admin routes async function authMiddleware(c: Context, next: () => Promise) { const env = c.env; @@ -69,22 +50,8 @@ async function authMiddleware(c: Context, next: () => Promise) { } // Proxy auth: only active when both env vars are present - if (env.PROXY_AUTH_SECRET && env.PROXY_TRUSTED_IPS) { - const trustedIps = env.PROXY_TRUSTED_IPS.split(",") - .map((s: string) => s.trim()) - .filter(Boolean); - const clientIp = c.req.header("CF-Connecting-IP") ?? ""; - const providedSecret = c.req.header("X-Auth-Proxy-Secret") ?? ""; - const remoteUser = - c.req.header("Remote-User") || c.req.header("X-Forwarded-User") || ""; - - if ( - trustedIps.includes(clientIp) && - timingSafeEqual(providedSecret, env.PROXY_AUTH_SECRET) && - remoteUser.length > 0 - ) { - return next(); - } + if (checkProxyAuth(c, env)) { + return next(); } // Fallback: signed cookie @@ -1020,45 +987,24 @@ app.post( }, ), async (c) => { - // Type assertion for environment variables const env = c.env; - const emailStorage = env.EMAIL_STORAGE; const feedId = c.req.param("feedId"); try { const { title, description } = c.req.valid("json"); - const parsedData = { title, description, language: "en" as const }; - // Get existing feed config - const feedConfigKey = `feed:${feedId}:config`; - const existingConfig = (await emailStorage.get(feedConfigKey, { - type: "json", - })) as FeedConfig | null; + // In-place edit: only title/description, expiry untouched. + const result = await updateFeedRecord( + env, + feedId, + { title, description }, + { inPlace: true }, + ); - if (!existingConfig) { + if (result.status === "not_found") { return c.json({ error: "Feed not found" }, 404); } - // Update feed configuration - await emailStorage.put( - feedConfigKey, - JSON.stringify({ - ...existingConfig, - title: parsedData.title, - description: parsedData.description, - updated_at: Date.now(), - }), - ); - - // Update feed in the list of all feeds - await updateFeedInList( - emailStorage, - feedId, - parsedData.title, - parsedData.description, - ); - - // Return success response return c.json({ success: true }); } catch (error) { logger.error("Error updating feed via API", { error: String(error) }); diff --git a/src/routes/admin/feeds.tsx b/src/routes/admin/feeds.tsx index 344f117..f92d4e1 100644 --- a/src/routes/admin/feeds.tsx +++ b/src/routes/admin/feeds.tsx @@ -1,7 +1,6 @@ import { Hono } from "hono"; import { z } from "zod"; -import { Env, FeedConfig, FeedMetadata } from "../../types"; -import { generateFeedId } from "../../utils/id-generator"; +import { Env, FeedConfig } from "../../types"; import { bumpCounters } from "../../utils/stats"; import { waitUntilSafe } from "../../utils/worker"; import { feedRssUrl, feedEmailAddress } from "../../utils/urls"; @@ -10,13 +9,16 @@ import { sendUnsubscribes } from "../../utils/unsubscribe"; import { getAttachmentBucket } from "../../utils/attachments"; import { Layout } from "./ui"; import { - addFeedToList, - updateFeedInList, - removeFeedFromList, removeFeedsFromListBulk, purgeFeedKeysStep, collectUnsubscribeUrls, } from "./helpers"; +import { + createFeedRecord, + updateFeedRecord, + deleteFeedRecord, + deleteFeedFastDetailed, +} from "../../lib/feed-service"; type AppEnv = { Bindings: Env }; @@ -56,56 +58,10 @@ const senderFilterSchema = z.object({ value: z.string().min(1), }); -// ── Delete helpers ──────────────────────────────────────────────────────────── - -type DeleteFeedFastResult = { - ok: boolean; - configDeleted: boolean; - metadataDeleted: boolean; - errors: string[]; -}; - -async function deleteFeedFastDetailed( - emailStorage: KVNamespace, - feedId: string, -): Promise { - const feedConfigKey = `feed:${feedId}:config`; - const feedMetadataKey = `feed:${feedId}:metadata`; - - const errors: string[] = []; - let configDeleted = false; - let metadataDeleted = false; - - try { - await emailStorage.delete(feedConfigKey); - configDeleted = true; - } catch (error) { - errors.push(`config delete failed: ${String(error)}`); - } - - try { - await emailStorage.delete(feedMetadataKey); - metadataDeleted = true; - } catch (error) { - errors.push(`metadata delete failed: ${String(error)}`); - } - - return { ok: configDeleted, configDeleted, metadataDeleted, errors }; -} - -async function deleteFeedFast( - emailStorage: KVNamespace, - feedId: string, -): Promise { - const result = await deleteFeedFastDetailed(emailStorage, feedId); - return result.ok; -} - // ── Routes ──────────────────────────────────────────────────────────────────── feedsRouter.post("/create", async (c) => { const env = c.env; - const emailStorage = env.EMAIL_STORAGE; const isJson = c.req.header("Content-Type")?.includes("application/json") ?? false; @@ -160,48 +116,17 @@ feedsRouter.post("/create", async (c) => { blockedSenders, }); - // FEED_TTL_HOURS overrides any client-submitted value - const resolvedHours = env.FEED_TTL_HOURS - ? parseInt(env.FEED_TTL_HOURS, 10) - : lifetimeHoursRaw - ? parseInt(lifetimeHoursRaw, 10) - : NaN; - const expiresAt = - Number.isFinite(resolvedHours) && resolvedHours > 0 - ? Date.now() + resolvedHours * 3_600_000 - : undefined; + const lifetimeHours = lifetimeHoursRaw + ? parseInt(lifetimeHoursRaw, 10) + : undefined; - const feedId = generateFeedId(); - - const feedConfig: FeedConfig = { + const { feedId } = await createFeedRecord(env, { title: parsedData.title, description: parsedData.description, language: parsedData.language, - allowed_senders: parsedData.allowedSenders, - blocked_senders: parsedData.blockedSenders, - created_at: Date.now(), - updated_at: Date.now(), - ...(expiresAt !== undefined ? { expires_at: expiresAt } : {}), - }; - - const feedMetadata: FeedMetadata = { emails: [] }; - - await Promise.all([ - emailStorage.put(`feed:${feedId}:config`, JSON.stringify(feedConfig)), - emailStorage.put(`feed:${feedId}:metadata`, JSON.stringify(feedMetadata)), - ]); - - await addFeedToList( - emailStorage, - feedId, - parsedData.title, - parsedData.description, - expiresAt, - ); - - await bumpCounters(emailStorage, { - feeds_created: 1, - last_feed_created_at: new Date().toISOString(), + allowedSenders: parsedData.allowedSenders, + blockedSenders: parsedData.blockedSenders, + lifetimeHours, }); if (isJson) { @@ -387,7 +312,6 @@ feedsRouter.get("/:feedId/edit", async (c) => { feedsRouter.post("/:feedId/edit", async (c) => { const env = c.env; - const emailStorage = env.EMAIL_STORAGE; const feedId = c.req.param("feedId"); try { @@ -411,60 +335,23 @@ feedsRouter.post("/:feedId/edit", async (c) => { blockedSenders, }); - const feedConfigKey = `feed:${feedId}:config`; - const existingConfig = (await emailStorage.get(feedConfigKey, { - type: "json", - })) as FeedConfig | null; - - if (!existingConfig) { - return c.text("Feed not found", 404); - } - - // Expired feeds cannot be edited - if ( - existingConfig.expires_at !== undefined && - existingConfig.expires_at <= Date.now() - ) { - return c.text("Feed has expired and cannot be modified.", 403); - } - - // Resolve new expires_at: - // - FEED_TTL_HOURS set: always recompute from env (reset TTL from now) - // - Field submitted: set new expiry from now - // - Field empty: preserve existing expires_at (no silent removal) - let newExpiresAt: number | undefined; - if (env.FEED_TTL_HOURS) { - const h = parseInt(env.FEED_TTL_HOURS, 10); - newExpiresAt = - Number.isFinite(h) && h > 0 ? Date.now() + h * 3_600_000 : undefined; - } else if (lifetimeHoursRaw) { - const h = parseInt(lifetimeHoursRaw, 10); - newExpiresAt = - Number.isFinite(h) && h > 0 ? Date.now() + h * 3_600_000 : undefined; - } else { - newExpiresAt = existingConfig.expires_at; - } - - const updatedConfig: FeedConfig = { - ...existingConfig, + const result = await updateFeedRecord(env, feedId, { title: parsedData.title, description: parsedData.description, language: parsedData.language, - allowed_senders: parsedData.allowedSenders, - blocked_senders: parsedData.blockedSenders, - updated_at: Date.now(), - expires_at: newExpiresAt, - }; + allowedSenders: parsedData.allowedSenders, + blockedSenders: parsedData.blockedSenders, + lifetimeHours: lifetimeHoursRaw + ? parseInt(lifetimeHoursRaw, 10) + : undefined, + }); - await emailStorage.put(feedConfigKey, JSON.stringify(updatedConfig)); - - await updateFeedInList( - emailStorage, - feedId, - parsedData.title, - parsedData.description, - newExpiresAt, - ); + if (result.status === "not_found") { + return c.text("Feed not found", 404); + } + if (result.status === "expired") { + return c.text("Feed has expired and cannot be modified.", 403); + } return c.redirect("/admin"); } catch (error) { @@ -534,31 +421,12 @@ feedsRouter.post("/:feedId/sender-filter", async (c) => { feedsRouter.post("/:feedId/delete", async (c) => { const env = c.env; - const emailStorage = env.EMAIL_STORAGE; const feedId = c.req.param("feedId"); const view = c.req.query("view") === "table" ? "table" : "list"; const wantsJson = (c.req.header("Accept") || "").includes("application/json"); try { - // Read unsubscribe URLs before the metadata is deleted below. - const unsubscribeUrls = await collectUnsubscribeUrls(emailStorage, feedId); - - await deleteFeedFast(emailStorage, feedId); - const removed = await removeFeedFromList(emailStorage, feedId); - if (removed) { - await bumpCounters(emailStorage, { feeds_deleted: 1 }); - } - - if (unsubscribeUrls.length > 0) { - waitUntilSafe(c, sendUnsubscribes(unsubscribeUrls, env)); - } - - waitUntilSafe( - c, - purgeFeedKeysStep(emailStorage, feedId, { - bucket: getAttachmentBucket(env), - }), - ); + await deleteFeedRecord(c, env, feedId); if (wantsJson) { return c.json({ ok: true, feedId }); diff --git a/src/routes/api/api.test.ts b/src/routes/api/api.test.ts new file mode 100644 index 0000000..f66e7ed --- /dev/null +++ b/src/routes/api/api.test.ts @@ -0,0 +1,284 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Hono } from "hono"; +import { apiApp } from "./index"; +import { createMockEnv } from "../../test/setup"; +import { Env } from "../../types"; + +const PASSWORD = "test-password"; +const authHeaders = { Authorization: `Bearer ${PASSWORD}` }; + +describe("REST API (/api/v1)", () => { + let testApp: Hono; + let mockEnv: Env; + let request: (path: string, init?: RequestInit) => Promise; + + beforeEach(() => { + mockEnv = createMockEnv() as unknown as Env; + testApp = new Hono(); + testApp.route("/api", apiApp); + request = (path, init = {}) => + Promise.resolve(testApp.request(path, init, mockEnv)); + }); + + async function createFeed(title = "Test Feed"): Promise { + const res = await request("/api/v1/feeds", { + method: "POST", + headers: { ...authHeaders, "Content-Type": "application/json" }, + body: JSON.stringify({ title }), + }); + expect(res.status).toBe(201); + const body = (await res.json()) as { id: string }; + return body.id; + } + + describe("Authentication", () => { + it("rejects requests without a token", async () => { + const res = await request("/api/v1/feeds"); + expect(res.status).toBe(401); + expect((await res.json()) as { error: string }).toEqual({ + error: "Unauthorized", + }); + }); + + it("rejects requests with a wrong token", async () => { + const res = await request("/api/v1/feeds", { + headers: { Authorization: "Bearer nope" }, + }); + expect(res.status).toBe(401); + }); + + it("accepts a valid Bearer token", async () => { + const res = await request("/api/v1/feeds", { headers: authHeaders }); + expect(res.status).toBe(200); + }); + + it("accepts proxy auth headers", async () => { + const proxyApp = new Hono(); + proxyApp.route("/api", apiApp); + const proxyEnv = { + ...createMockEnv(), + PROXY_TRUSTED_IPS: "10.0.0.1", + PROXY_AUTH_SECRET: "proxy-secret", + } as unknown as Env; + const res = await proxyApp.request( + "/api/v1/feeds", + { + headers: { + "CF-Connecting-IP": "10.0.0.1", + "X-Auth-Proxy-Secret": "proxy-secret", + "Remote-User": "alice", + }, + }, + proxyEnv, + ); + expect(res.status).toBe(200); + }); + }); + + describe("Feeds CRUD", () => { + it("creates, reads, lists, updates and deletes a feed", async () => { + // Create + const createRes = await request("/api/v1/feeds", { + method: "POST", + headers: { ...authHeaders, "Content-Type": "application/json" }, + body: JSON.stringify({ + title: "Daily Digest", + description: "news", + allowedSenders: ["News@Example.com"], + }), + }); + expect(createRes.status).toBe(201); + const created = (await createRes.json()) as { + id: string; + title: string; + allowedSenders: string[]; + emailAddress: string; + rssUrl: string; + atomUrl: string; + emailCount: number; + }; + expect(created.title).toBe("Daily Digest"); + // senders are normalized to lowercase + expect(created.allowedSenders).toEqual(["news@example.com"]); + expect(created.emailCount).toBe(0); + expect(created.rssUrl).toContain(`/rss/${created.id}`); + + // Get + const getRes = await request(`/api/v1/feeds/${created.id}`, { + headers: authHeaders, + }); + expect(getRes.status).toBe(200); + expect((await getRes.json()) as { id: string }).toMatchObject({ + id: created.id, + title: "Daily Digest", + }); + + // List + const listRes = await request("/api/v1/feeds", { headers: authHeaders }); + const list = (await listRes.json()) as { feeds: { id: string }[] }; + expect(list.feeds.map((f) => f.id)).toContain(created.id); + + // Update + const patchRes = await request(`/api/v1/feeds/${created.id}`, { + method: "PATCH", + headers: { ...authHeaders, "Content-Type": "application/json" }, + body: JSON.stringify({ title: "Renamed" }), + }); + expect(patchRes.status).toBe(200); + expect((await patchRes.json()) as { title: string }).toMatchObject({ + title: "Renamed", + }); + + // Delete + const delRes = await request(`/api/v1/feeds/${created.id}`, { + method: "DELETE", + headers: authHeaders, + }); + expect(delRes.status).toBe(200); + expect((await delRes.json()) as { ok: boolean }).toEqual({ ok: true }); + + // Gone from the list + const after = await request("/api/v1/feeds", { headers: authHeaders }); + const afterList = (await after.json()) as { feeds: { id: string }[] }; + expect(afterList.feeds.map((f) => f.id)).not.toContain(created.id); + }); + + it("returns 400 for an invalid create body", async () => { + const res = await request("/api/v1/feeds", { + method: "POST", + headers: { ...authHeaders, "Content-Type": "application/json" }, + body: JSON.stringify({ title: "" }), + }); + expect(res.status).toBe(400); + expect((await res.json()) as { error: string }).toHaveProperty("error"); + }); + + it("returns 404 when getting a missing feed", async () => { + const res = await request("/api/v1/feeds/does-not-exist", { + headers: authHeaders, + }); + expect(res.status).toBe(404); + }); + + it("returns 404 when deleting a missing feed", async () => { + const res = await request("/api/v1/feeds/does-not-exist", { + method: "DELETE", + headers: authHeaders, + }); + expect(res.status).toBe(404); + }); + + it("returns 404 when updating a missing feed", async () => { + const res = await request("/api/v1/feeds/does-not-exist", { + method: "PATCH", + headers: { ...authHeaders, "Content-Type": "application/json" }, + body: JSON.stringify({ title: "x" }), + }); + expect(res.status).toBe(404); + }); + }); + + describe("Emails", () => { + it("lists, reads and deletes an email", async () => { + const feedId = await createFeed(); + + // Seed an email directly into KV (mirrors storeEmail's key shape). + const receivedAt = 1737000000000; + const key = `feed:${feedId}:email:${receivedAt}`; + await mockEnv.EMAIL_STORAGE.put( + key, + JSON.stringify({ + subject: "Hello", + from: "news@example.com", + content: "

hi

", + receivedAt, + headers: {}, + }), + ); + await mockEnv.EMAIL_STORAGE.put( + `feed:${feedId}:metadata`, + JSON.stringify({ + emails: [{ key, subject: "Hello", receivedAt }], + }), + ); + + // List + const listRes = await request(`/api/v1/feeds/${feedId}/emails`, { + headers: authHeaders, + }); + expect(listRes.status).toBe(200); + const list = (await listRes.json()) as { + emails: { entryId: number; subject: string }[]; + }; + expect(list.emails).toHaveLength(1); + expect(list.emails[0]).toMatchObject({ + entryId: receivedAt, + subject: "Hello", + }); + + // Get single + const getRes = await request( + `/api/v1/feeds/${feedId}/emails/${receivedAt}`, + { headers: authHeaders }, + ); + expect(getRes.status).toBe(200); + expect((await getRes.json()) as { content: string }).toMatchObject({ + from: "news@example.com", + content: "

hi

", + }); + + // Delete + const delRes = await request( + `/api/v1/feeds/${feedId}/emails/${receivedAt}`, + { method: "DELETE", headers: authHeaders }, + ); + expect(delRes.status).toBe(200); + expect(await mockEnv.EMAIL_STORAGE.get(key)).toBeNull(); + + // Gone + const after = await request( + `/api/v1/feeds/${feedId}/emails/${receivedAt}`, + { headers: authHeaders }, + ); + expect(after.status).toBe(404); + }); + + it("returns 404 listing emails for a missing feed", async () => { + const res = await request("/api/v1/feeds/missing/emails", { + headers: authHeaders, + }); + expect(res.status).toBe(404); + }); + }); + + describe("Stats", () => { + it("returns monitoring counters", async () => { + await createFeed(); + const res = await request("/api/v1/stats", { headers: authHeaders }); + expect(res.status).toBe(200); + const stats = (await res.json()) as { + feeds_created: number; + active_feeds: number; + attachments_enabled: boolean; + }; + expect(stats.feeds_created).toBeGreaterThanOrEqual(1); + expect(stats.active_feeds).toBeGreaterThanOrEqual(1); + expect(typeof stats.attachments_enabled).toBe("boolean"); + }); + }); + + describe("OpenAPI document", () => { + it("serves a public OpenAPI 3.1 spec", async () => { + const res = await request("/api/openapi.json"); + expect(res.status).toBe(200); + const doc = (await res.json()) as { + openapi: string; + paths: Record; + }; + expect(doc.openapi).toBe("3.1.0"); + expect(doc.paths).toHaveProperty("/v1/feeds"); + expect(doc.paths).toHaveProperty("/v1/feeds/{feedId}"); + expect(doc.paths).toHaveProperty("/v1/stats"); + }); + }); +}); diff --git a/src/routes/api/index.ts b/src/routes/api/index.ts new file mode 100644 index 0000000..2087b25 --- /dev/null +++ b/src/routes/api/index.ts @@ -0,0 +1,397 @@ +import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi"; +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 { listAllFeeds, deleteAttachmentsForEmails } from "../admin/helpers"; +import { + getFeedConfig, + getFeedMetadata, + getEmailData, +} from "../../utils/storage"; +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 = (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 { + 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({ + 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 every /v1 route. The spec + docs stay public. +apiApp.use("/v1/*", apiAuthMiddleware); + +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 listAllFeeds(env.EMAIL_STORAGE); + 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 config = await getFeedConfig(env.EMAIL_STORAGE, feedId); + if (!config) return c.json({ error: "Feed not found" }, 404); + const metadata = await getFeedMetadata(env.EMAIL_STORAGE, 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 getFeedMetadata(env.EMAIL_STORAGE, 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 getFeedMetadata(env.EMAIL_STORAGE, 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 metadata = await getFeedMetadata(env.EMAIL_STORAGE, feedId); + const metaEntry = metadata?.emails.find((e) => e.receivedAt === receivedAt); + if (!metaEntry) return c.json({ error: "Email not found" }, 404); + const data = await getEmailData(env.EMAIL_STORAGE, 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 emailStorage = env.EMAIL_STORAGE; + const { feedId, entryId } = c.req.valid("param"); + const receivedAt = parseInt(entryId, 10); + const metadata = await getFeedMetadata(emailStorage, feedId); + const metaEntry = metadata?.emails.find((e) => e.receivedAt === receivedAt); + if (!metadata || !metaEntry) + return c.json({ error: "Email not found" }, 404); + + await emailStorage.delete(metaEntry.key); + await deleteAttachmentsForEmails(env, metadata.emails, [metaEntry.key]); + metadata.emails = metadata.emails.filter((e) => e.key !== metaEntry.key); + await emailStorage.put(`feed:${feedId}:metadata`, JSON.stringify(metadata)); + + return c.json({ ok: true }, 200); + }, +); + +// ── Stats ───────────────────────────────────────────────────────────────────── + +apiApp.openapi( + createRoute({ + method: "get", + path: "/v1/stats", + tags: ["Stats"], + summary: "Read monitoring counters", + security: bearer, + responses: { + 200: jsonContent(StatsSchema, "Monitoring counters"), + 401: jsonContent(ErrorSchema, "Unauthorized"), + }, + }), + 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", + }), +); diff --git a/src/routes/api/schemas.ts b/src/routes/api/schemas.ts new file mode 100644 index 0000000..0e40876 --- /dev/null +++ b/src/routes/api/schemas.ts @@ -0,0 +1,153 @@ +import { z } from "@hono/zod-openapi"; + +// ── Shared ────────────────────────────────────────────────────────────────── + +export const ErrorSchema = z + .object({ + error: z.string().openapi({ example: "Feed not found" }), + }) + .openapi("Error"); + +export const FeedIdParam = z.object({ + feedId: z + .string() + .min(1) + .openapi({ + param: { name: "feedId", in: "path" }, + example: "happy-otter-1234", + }), +}); + +export const EntryIdParam = FeedIdParam.extend({ + entryId: z + .string() + .regex(/^\d+$/, "entryId must be the email's receivedAt timestamp") + .openapi({ + param: { name: "entryId", in: "path" }, + example: "1737000000000", + }), +}); + +// ── Feeds ─────────────────────────────────────────────────────────────────── + +export const FeedCreateSchema = z + .object({ + title: z.string().min(1).openapi({ example: "Daily Tech Digest" }), + description: z.string().optional(), + language: z.string().optional().default("en"), + allowedSenders: z.array(z.string()).optional().default([]), + blockedSenders: z.array(z.string()).optional().default([]), + lifetimeHours: z.number().int().positive().optional().openapi({ + description: + "Hours until the feed expires. Ignored when the server enforces a fixed FEED_TTL_HOURS.", + }), + }) + .openapi("FeedCreate"); + +export const FeedUpdateSchema = z + .object({ + title: z.string().min(1).optional(), + description: z.string().optional(), + language: z.string().optional(), + allowedSenders: z.array(z.string()).optional(), + blockedSenders: z.array(z.string()).optional(), + lifetimeHours: z.number().int().positive().optional().openapi({ + description: "Reset the feed's lifetime to this many hours from now.", + }), + }) + .openapi("FeedUpdate"); + +export const FeedSummarySchema = z + .object({ + id: z.string(), + title: z.string(), + description: z.string().optional(), + expiresAt: z.number().optional(), + emailAddress: z.string(), + rssUrl: z.string(), + atomUrl: z.string(), + }) + .openapi("FeedSummary"); + +export const FeedListSchema = z + .object({ feeds: z.array(FeedSummarySchema) }) + .openapi("FeedList"); + +export const FeedSchema = z + .object({ + id: z.string(), + title: z.string(), + description: z.string().optional(), + language: z.string(), + allowedSenders: z.array(z.string()), + blockedSenders: z.array(z.string()), + createdAt: z.number(), + updatedAt: z.number().optional(), + expiresAt: z.number().optional(), + emailCount: z.number(), + emailAddress: z.string(), + rssUrl: z.string(), + atomUrl: z.string(), + }) + .openapi("Feed"); + +// ── Emails ────────────────────────────────────────────────────────────────── + +export const EmailSummarySchema = z + .object({ + entryId: z.number().openapi({ + description: "Email receivedAt timestamp; used as the path id.", + }), + subject: z.string(), + receivedAt: z.number(), + size: z.number().optional(), + attachmentIds: z.array(z.string()).optional(), + }) + .openapi("EmailSummary"); + +export const EmailListSchema = z + .object({ emails: z.array(EmailSummarySchema) }) + .openapi("EmailList"); + +export const AttachmentSchema = z + .object({ + id: z.string(), + filename: z.string(), + contentType: z.string(), + size: z.number(), + url: z.string(), + }) + .openapi("Attachment"); + +export const EmailSchema = z + .object({ + entryId: z.number(), + subject: z.string(), + from: z.string(), + receivedAt: z.number(), + content: z.string(), + attachments: z.array(AttachmentSchema), + }) + .openapi("Email"); + +// ── Stats ─────────────────────────────────────────────────────────────────── + +export const StatsSchema = z + .object({ + feeds_created: z.number(), + feeds_deleted: z.number(), + emails_received: z.number(), + emails_rejected: z.number(), + unsubscribes_sent: z.number(), + active_feeds: z.number(), + websub_subscriptions_active: z.number(), + attachments_enabled: z.boolean(), + last_email_at: z.string().optional(), + last_feed_created_at: z.string().optional(), + first_seen: z.string().optional(), + attachments_bytes: z.number().optional(), + attachments_count: z.number().optional(), + kv_bytes_estimated: z.number().optional(), + storage_scanned_at: z.string().optional(), + }) + .openapi("Stats");