mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
feat(api): add versioned REST API with OpenAPI 3.1 spec
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 <ADMIN_PASSWORD>) 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 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,9 @@ Single Cloudflare Worker built with Hono. Routes:
|
|||||||
| `GET /` | Public status page (monitoring counters + link to admin) |
|
| `GET /` | Public status page (monitoring counters + link to admin) |
|
||||||
| `POST /api/inbound` | Webhook from ForwardEmail; IP-allowlisted to their MX sources |
|
| `POST /api/inbound` | Webhook from ForwardEmail; IP-allowlisted to their MX sources |
|
||||||
| `GET /api/stats` | Public monitoring counters (JSON) |
|
| `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 /rss/:feedId` | Public RSS 2.0 feed |
|
||||||
| `GET /atom/:feedId` | Public Atom feed (with WebSub hub header) |
|
| `GET /atom/:feedId` | Public Atom feed (with WebSub hub header) |
|
||||||
| `GET /entries/:feedId/:entryId` | Individual email HTML view |
|
| `GET /entries/:feedId/:entryId` | Individual email HTML view |
|
||||||
@@ -68,7 +71,12 @@ src/
|
|||||||
emails.tsx # Emails list/delete UI
|
emails.tsx # Emails list/delete UI
|
||||||
ui.tsx # Shared UI components
|
ui.tsx # Shared UI components
|
||||||
helpers.ts # Shared admin helpers
|
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/
|
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
|
cloudflare-email.ts # Cloudflare Email routing handler
|
||||||
email-parser.ts # Email parsing (mailparser)
|
email-parser.ts # Email parsing (mailparser)
|
||||||
email-processor.ts # Core ingestion logic (parse → store)
|
email-processor.ts # Core ingestion logic (parse → store)
|
||||||
|
|||||||
+13
@@ -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]`).
|
> **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 <ADMIN_PASSWORD>` (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
|
## Upgrading dependencies
|
||||||
|
|
||||||
To refresh dependencies to latest:
|
To refresh dependencies to latest:
|
||||||
|
|||||||
@@ -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)
|
- Email attachments stored in Cloudflare R2 and exposed as RSS enclosures (optional)
|
||||||
- Cloudflare KV storage for feed config + email metadata/content
|
- Cloudflare KV storage for feed config + email metadata/content
|
||||||
- Password-protected admin UI
|
- Password-protected admin UI
|
||||||
|
- Versioned REST API (`/api/v1/*`) with an OpenAPI 3.1 spec and Scalar docs for automation
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -52,7 +53,9 @@ Main routes:
|
|||||||
- `src/routes/rss.ts`: RSS rendering
|
- `src/routes/rss.ts`: RSS rendering
|
||||||
- `src/routes/atom.ts`: Atom feed rendering
|
- `src/routes/atom.ts`: Atom feed rendering
|
||||||
- `src/routes/files.ts`: attachment file serving from R2
|
- `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/home.tsx`: public status page (`GET /`)
|
||||||
- `src/routes/stats.ts`: monitoring counters API (`GET /api/stats`)
|
- `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
|
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.
|
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 <ADMIN_PASSWORD>`, 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
|
## Installation
|
||||||
|
|
||||||
See **[INSTALL.md](INSTALL.md)** for the full setup, deployment, and configuration guide. Quick start:
|
See **[INSTALL.md](INSTALL.md)** for the full setup, deployment, and configuration guide. Quick start:
|
||||||
|
|||||||
@@ -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.
|
- [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:<feedId>: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.
|
- [ ] **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:<feedId>: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.
|
||||||
|
|
||||||
|
|||||||
Generated
+134
-4
@@ -9,7 +9,9 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hono/zod-openapi": "^1.4.0",
|
||||||
"@hono/zod-validator": "^0.8.0",
|
"@hono/zod-validator": "^0.8.0",
|
||||||
|
"@scalar/hono-api-reference": "^0.10.19",
|
||||||
"escape-html": "^1.0.3",
|
"escape-html": "^1.0.3",
|
||||||
"feed": "5.2.1",
|
"feed": "5.2.1",
|
||||||
"hono": "4.12.22",
|
"hono": "4.12.22",
|
||||||
@@ -37,6 +39,18 @@
|
|||||||
"wrangler": "4.94.0"
|
"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": {
|
"node_modules/@babel/helper-string-parser": {
|
||||||
"version": "7.27.1",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
"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": "^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": {
|
"node_modules/@hono/zod-validator": {
|
||||||
"version": "0.8.0",
|
"version": "0.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/@hono/zod-validator/-/zod-validator-0.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/@hono/zod-validator/-/zod-validator-0.8.0.tgz",
|
||||||
@@ -1954,6 +1986,99 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@sindresorhus/is": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz",
|
||||||
@@ -4459,6 +4584,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -5026,7 +5160,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
||||||
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
|
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
@@ -5150,7 +5283,6 @@
|
|||||||
"version": "5.6.0",
|
"version": "5.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz",
|
||||||
"integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==",
|
"integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==",
|
||||||
"dev": true,
|
|
||||||
"license": "(MIT OR CC0-1.0)",
|
"license": "(MIT OR CC0-1.0)",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tagged-tag": "^1.0.0"
|
"tagged-tag": "^1.0.0"
|
||||||
@@ -6097,9 +6229,7 @@
|
|||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"optional": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"yaml": "bin.mjs"
|
"yaml": "bin.mjs"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -51,7 +51,9 @@
|
|||||||
"wrangler": "4.94.0"
|
"wrangler": "4.94.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hono/zod-openapi": "^1.4.0",
|
||||||
"@hono/zod-validator": "^0.8.0",
|
"@hono/zod-validator": "^0.8.0",
|
||||||
|
"@scalar/hono-api-reference": "^0.10.19",
|
||||||
"escape-html": "^1.0.3",
|
"escape-html": "^1.0.3",
|
||||||
"feed": "5.2.1",
|
"feed": "5.2.1",
|
||||||
"hono": "4.12.22",
|
"hono": "4.12.22",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { handle as handleStats } from "./routes/stats";
|
|||||||
import { handle as handleHome } from "./routes/home";
|
import { handle as handleHome } from "./routes/home";
|
||||||
import { handle as handleFavicon, handleFeedFavicon } from "./routes/favicon";
|
import { handle as handleFavicon, handleFeedFavicon } from "./routes/favicon";
|
||||||
import { hubRouter } from "./routes/hub";
|
import { hubRouter } from "./routes/hub";
|
||||||
|
import { apiApp } from "./routes/api";
|
||||||
import { handleCloudflareEmail } from "./lib/cloudflare-email";
|
import { handleCloudflareEmail } from "./lib/cloudflare-email";
|
||||||
import { Env } from "./types";
|
import { Env } from "./types";
|
||||||
import { logger } from "./lib/logger";
|
import { logger } from "./lib/logger";
|
||||||
@@ -168,6 +169,8 @@ admin.route("/", handleAdmin);
|
|||||||
|
|
||||||
// Mount the route groups
|
// Mount the route groups
|
||||||
app.route("/api", api);
|
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("/rss", rss);
|
||||||
app.route("/atom", atom);
|
app.route("/atom", atom);
|
||||||
app.route("/entries", entries);
|
app.route("/entries", entries);
|
||||||
|
|||||||
@@ -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 <ADMIN_PASSWORD>`. No cookie, no CSRF — token only.
|
||||||
|
*/
|
||||||
|
export async function apiAuthMiddleware(
|
||||||
|
c: Context<{ Bindings: Env }>,
|
||||||
|
next: () => Promise<void>,
|
||||||
|
): Promise<Response | void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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<UpdateFeedResult> {
|
||||||
|
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<DeleteFeedFastResult> {
|
||||||
|
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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
+13
-67
@@ -2,12 +2,14 @@ import { Context, Hono } from "hono";
|
|||||||
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
|
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Env, FeedConfig } from "../types";
|
import { Env } from "../types";
|
||||||
import { csrf } from "hono/csrf";
|
import { csrf } from "hono/csrf";
|
||||||
import { ADMIN_COOKIE_MAX_AGE } from "../config/constants";
|
import { ADMIN_COOKIE_MAX_AGE } from "../config/constants";
|
||||||
import { logger } from "../lib/logger";
|
import { logger } from "../lib/logger";
|
||||||
|
import { timingSafeEqual, checkProxyAuth } from "../lib/auth";
|
||||||
import { Layout, clampText } from "./admin/ui";
|
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 { feedRssUrl, feedAtomUrl, feedEmailAddress } from "../utils/urls";
|
||||||
import { feedsRouter } from "./admin/feeds";
|
import { feedsRouter } from "./admin/feeds";
|
||||||
import { emailsRouter } from "./admin/emails";
|
import { emailsRouter } from "./admin/emails";
|
||||||
@@ -37,27 +39,6 @@ app.use("*", async (c, next) => {
|
|||||||
await 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
|
// Authentication middleware for admin routes
|
||||||
async function authMiddleware(c: Context, next: () => Promise<void>) {
|
async function authMiddleware(c: Context, next: () => Promise<void>) {
|
||||||
const env = c.env;
|
const env = c.env;
|
||||||
@@ -69,23 +50,9 @@ async function authMiddleware(c: Context, next: () => Promise<void>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Proxy auth: only active when both env vars are present
|
// Proxy auth: only active when both env vars are present
|
||||||
if (env.PROXY_AUTH_SECRET && env.PROXY_TRUSTED_IPS) {
|
if (checkProxyAuth(c, env)) {
|
||||||
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();
|
return next();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: signed cookie
|
// Fallback: signed cookie
|
||||||
const authCookie = await getSignedCookie(
|
const authCookie = await getSignedCookie(
|
||||||
@@ -1020,45 +987,24 @@ app.post(
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
// Type assertion for environment variables
|
|
||||||
const env = c.env;
|
const env = c.env;
|
||||||
const emailStorage = env.EMAIL_STORAGE;
|
|
||||||
const feedId = c.req.param("feedId");
|
const feedId = c.req.param("feedId");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { title, description } = c.req.valid("json");
|
const { title, description } = c.req.valid("json");
|
||||||
const parsedData = { title, description, language: "en" as const };
|
|
||||||
|
|
||||||
// Get existing feed config
|
// In-place edit: only title/description, expiry untouched.
|
||||||
const feedConfigKey = `feed:${feedId}:config`;
|
const result = await updateFeedRecord(
|
||||||
const existingConfig = (await emailStorage.get(feedConfigKey, {
|
env,
|
||||||
type: "json",
|
feedId,
|
||||||
})) as FeedConfig | null;
|
{ title, description },
|
||||||
|
{ inPlace: true },
|
||||||
|
);
|
||||||
|
|
||||||
if (!existingConfig) {
|
if (result.status === "not_found") {
|
||||||
return c.json({ error: "Feed not found" }, 404);
|
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 });
|
return c.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error updating feed via API", { error: String(error) });
|
logger.error("Error updating feed via API", { error: String(error) });
|
||||||
|
|||||||
+26
-158
@@ -1,7 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Env, FeedConfig, FeedMetadata } from "../../types";
|
import { Env, FeedConfig } from "../../types";
|
||||||
import { generateFeedId } from "../../utils/id-generator";
|
|
||||||
import { bumpCounters } from "../../utils/stats";
|
import { bumpCounters } from "../../utils/stats";
|
||||||
import { waitUntilSafe } from "../../utils/worker";
|
import { waitUntilSafe } from "../../utils/worker";
|
||||||
import { feedRssUrl, feedEmailAddress } from "../../utils/urls";
|
import { feedRssUrl, feedEmailAddress } from "../../utils/urls";
|
||||||
@@ -10,13 +9,16 @@ import { sendUnsubscribes } from "../../utils/unsubscribe";
|
|||||||
import { getAttachmentBucket } from "../../utils/attachments";
|
import { getAttachmentBucket } from "../../utils/attachments";
|
||||||
import { Layout } from "./ui";
|
import { Layout } from "./ui";
|
||||||
import {
|
import {
|
||||||
addFeedToList,
|
|
||||||
updateFeedInList,
|
|
||||||
removeFeedFromList,
|
|
||||||
removeFeedsFromListBulk,
|
removeFeedsFromListBulk,
|
||||||
purgeFeedKeysStep,
|
purgeFeedKeysStep,
|
||||||
collectUnsubscribeUrls,
|
collectUnsubscribeUrls,
|
||||||
} from "./helpers";
|
} from "./helpers";
|
||||||
|
import {
|
||||||
|
createFeedRecord,
|
||||||
|
updateFeedRecord,
|
||||||
|
deleteFeedRecord,
|
||||||
|
deleteFeedFastDetailed,
|
||||||
|
} from "../../lib/feed-service";
|
||||||
|
|
||||||
type AppEnv = { Bindings: Env };
|
type AppEnv = { Bindings: Env };
|
||||||
|
|
||||||
@@ -56,56 +58,10 @@ const senderFilterSchema = z.object({
|
|||||||
value: z.string().min(1),
|
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<DeleteFeedFastResult> {
|
|
||||||
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<boolean> {
|
|
||||||
const result = await deleteFeedFastDetailed(emailStorage, feedId);
|
|
||||||
return result.ok;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Routes ────────────────────────────────────────────────────────────────────
|
// ── Routes ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
feedsRouter.post("/create", async (c) => {
|
feedsRouter.post("/create", async (c) => {
|
||||||
const env = c.env;
|
const env = c.env;
|
||||||
const emailStorage = env.EMAIL_STORAGE;
|
|
||||||
const isJson =
|
const isJson =
|
||||||
c.req.header("Content-Type")?.includes("application/json") ?? false;
|
c.req.header("Content-Type")?.includes("application/json") ?? false;
|
||||||
|
|
||||||
@@ -160,48 +116,17 @@ feedsRouter.post("/create", async (c) => {
|
|||||||
blockedSenders,
|
blockedSenders,
|
||||||
});
|
});
|
||||||
|
|
||||||
// FEED_TTL_HOURS overrides any client-submitted value
|
const lifetimeHours = lifetimeHoursRaw
|
||||||
const resolvedHours = env.FEED_TTL_HOURS
|
|
||||||
? parseInt(env.FEED_TTL_HOURS, 10)
|
|
||||||
: lifetimeHoursRaw
|
|
||||||
? parseInt(lifetimeHoursRaw, 10)
|
? parseInt(lifetimeHoursRaw, 10)
|
||||||
: NaN;
|
|
||||||
const expiresAt =
|
|
||||||
Number.isFinite(resolvedHours) && resolvedHours > 0
|
|
||||||
? Date.now() + resolvedHours * 3_600_000
|
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const feedId = generateFeedId();
|
const { feedId } = await createFeedRecord(env, {
|
||||||
|
|
||||||
const feedConfig: FeedConfig = {
|
|
||||||
title: parsedData.title,
|
title: parsedData.title,
|
||||||
description: parsedData.description,
|
description: parsedData.description,
|
||||||
language: parsedData.language,
|
language: parsedData.language,
|
||||||
allowed_senders: parsedData.allowedSenders,
|
allowedSenders: parsedData.allowedSenders,
|
||||||
blocked_senders: parsedData.blockedSenders,
|
blockedSenders: parsedData.blockedSenders,
|
||||||
created_at: Date.now(),
|
lifetimeHours,
|
||||||
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(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isJson) {
|
if (isJson) {
|
||||||
@@ -387,7 +312,6 @@ feedsRouter.get("/:feedId/edit", async (c) => {
|
|||||||
|
|
||||||
feedsRouter.post("/:feedId/edit", async (c) => {
|
feedsRouter.post("/:feedId/edit", async (c) => {
|
||||||
const env = c.env;
|
const env = c.env;
|
||||||
const emailStorage = env.EMAIL_STORAGE;
|
|
||||||
const feedId = c.req.param("feedId");
|
const feedId = c.req.param("feedId");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -411,60 +335,23 @@ feedsRouter.post("/:feedId/edit", async (c) => {
|
|||||||
blockedSenders,
|
blockedSenders,
|
||||||
});
|
});
|
||||||
|
|
||||||
const feedConfigKey = `feed:${feedId}:config`;
|
const result = await updateFeedRecord(env, feedId, {
|
||||||
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,
|
|
||||||
title: parsedData.title,
|
title: parsedData.title,
|
||||||
description: parsedData.description,
|
description: parsedData.description,
|
||||||
language: parsedData.language,
|
language: parsedData.language,
|
||||||
allowed_senders: parsedData.allowedSenders,
|
allowedSenders: parsedData.allowedSenders,
|
||||||
blocked_senders: parsedData.blockedSenders,
|
blockedSenders: parsedData.blockedSenders,
|
||||||
updated_at: Date.now(),
|
lifetimeHours: lifetimeHoursRaw
|
||||||
expires_at: newExpiresAt,
|
? parseInt(lifetimeHoursRaw, 10)
|
||||||
};
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
await emailStorage.put(feedConfigKey, JSON.stringify(updatedConfig));
|
if (result.status === "not_found") {
|
||||||
|
return c.text("Feed not found", 404);
|
||||||
await updateFeedInList(
|
}
|
||||||
emailStorage,
|
if (result.status === "expired") {
|
||||||
feedId,
|
return c.text("Feed has expired and cannot be modified.", 403);
|
||||||
parsedData.title,
|
}
|
||||||
parsedData.description,
|
|
||||||
newExpiresAt,
|
|
||||||
);
|
|
||||||
|
|
||||||
return c.redirect("/admin");
|
return c.redirect("/admin");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -534,31 +421,12 @@ feedsRouter.post("/:feedId/sender-filter", async (c) => {
|
|||||||
|
|
||||||
feedsRouter.post("/:feedId/delete", async (c) => {
|
feedsRouter.post("/:feedId/delete", async (c) => {
|
||||||
const env = c.env;
|
const env = c.env;
|
||||||
const emailStorage = env.EMAIL_STORAGE;
|
|
||||||
const feedId = c.req.param("feedId");
|
const feedId = c.req.param("feedId");
|
||||||
const view = c.req.query("view") === "table" ? "table" : "list";
|
const view = c.req.query("view") === "table" ? "table" : "list";
|
||||||
const wantsJson = (c.req.header("Accept") || "").includes("application/json");
|
const wantsJson = (c.req.header("Accept") || "").includes("application/json");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Read unsubscribe URLs before the metadata is deleted below.
|
await deleteFeedRecord(c, env, feedId);
|
||||||
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),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (wantsJson) {
|
if (wantsJson) {
|
||||||
return c.json({ ok: true, feedId });
|
return c.json({ ok: true, feedId });
|
||||||
|
|||||||
@@ -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<Response>;
|
||||||
|
|
||||||
|
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<string> {
|
||||||
|
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: "<p>hi</p>",
|
||||||
|
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: "<p>hi</p>",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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<string, unknown>;
|
||||||
|
};
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 = <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 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",
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -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");
|
||||||
Reference in New Issue
Block a user