diff --git a/TODO.md b/TODO.md index af16a84..382de78 100644 --- a/TODO.md +++ b/TODO.md @@ -24,6 +24,6 @@ Feature gaps identified by comparing with [kill-the-newsletter](https://github.c - [x] **WebSub (PubSubHubbub) push notifications** — notify subscribers in real time when a new email arrives, instead of requiring them to poll the feed. Requires either integrating a public WebSub hub or implementing the hub protocol directly. -- [ ] **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. - [ ] **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/docs/index.html b/docs/index.html index 7687cb7..334e7db 100644 --- a/docs/index.html +++ b/docs/index.html @@ -376,6 +376,210 @@ footer a:hover { color: var(--text); } footer .sep { margin: 0 0.5rem; } + /* ── Demo banner ── */ + .demo-banner { + background: linear-gradient(135deg, rgba(246,130,31,0.08) 0%, rgba(246,130,31,0.03) 100%); + border-top: 1px solid rgba(246,130,31,0.2); + border-bottom: 1px solid rgba(246,130,31,0.2); + padding: 3.5rem 2rem; + } + + .demo-banner .inner { + max-width: 1100px; + margin: 0 auto; + display: grid; + grid-template-columns: 1fr auto; + gap: 2.5rem; + align-items: center; + } + + .demo-banner h2 { + font-size: clamp(1.3rem, 2.5vw, 1.75rem); + font-weight: 700; + letter-spacing: -0.02em; + margin-bottom: 0.6rem; + } + + .demo-banner h2 span { color: var(--accent); } + + .demo-banner p { + font-size: 0.95rem; + color: var(--muted); + max-width: 540px; + line-height: 1.65; + } + + .demo-creds { + display: inline-flex; + align-items: center; + gap: 1rem; + margin-top: 1rem; + padding: 0.6rem 1rem; + background: rgba(0,0,0,0.3); + border: 1px solid var(--border); + border-radius: 8px; + font-family: 'JetBrains Mono', monospace; + font-size: 0.82rem; + } + + .demo-creds .cred-item { color: var(--muted); } + .demo-creds .cred-item strong { color: var(--text); font-weight: 500; } + .demo-creds .cred-sep { color: #333; } + + .demo-actions { + display: flex; + flex-direction: column; + gap: 0.65rem; + align-items: flex-end; + flex-shrink: 0; + } + + .demo-note { + font-size: 0.75rem; + color: #555; + text-align: center; + } + + @media (max-width: 700px) { + .demo-banner .inner { grid-template-columns: 1fr; } + .demo-actions { align-items: flex-start; flex-direction: row; flex-wrap: wrap; } + .demo-note { text-align: left; } + } + + /* ── Nav links ── */ + nav-links { + display: flex; + align-items: center; + gap: 0.25rem; + } + .nav-link { + padding: 0.35rem 0.75rem; + border-radius: 6px; + font-size: 0.875rem; + color: var(--muted); + transition: color 0.15s, background 0.15s; + } + .nav-link:hover { color: var(--text); background: var(--surface); } + @media (max-width: 600px) { nav-links { display: none; } } + + /* ── Install section ── */ + .install-steps { + display: flex; + flex-direction: column; + gap: 1rem; + counter-reset: install-step; + } + + .install-step { + display: grid; + grid-template-columns: 48px 1fr; + gap: 1.25rem; + align-items: start; + } + + .install-step-num { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--accent-dim); + border: 1px solid rgba(246,130,31,0.35); + color: var(--accent); + font-weight: 700; + font-size: 0.9rem; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-top: 2px; + } + + .install-step-body h3 { + font-size: 1rem; + font-weight: 600; + margin-bottom: 0.5rem; + } + + .install-step-body p { + font-size: 0.875rem; + color: var(--muted); + margin-bottom: 0.75rem; + line-height: 1.65; + } + + .install-step-body p:last-child { margin-bottom: 0; } + + .install-step-body .code-block { margin-top: 0.75rem; } + + .install-step-body ul { + margin: 0.5rem 0 0.75rem 1.1rem; + font-size: 0.875rem; + color: var(--muted); + line-height: 1.8; + } + + .install-step-connector { + width: 1px; + background: var(--border); + height: 1.5rem; + margin-left: 19px; + } + + .install-split { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-top: 0.75rem; + } + @media (max-width: 700px) { .install-split { grid-template-columns: 1fr; } } + + .install-card { + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem 1.5rem; + background: var(--surface); + } + .install-card.highlight { border-color: rgba(246,130,31,0.4); } + + .install-card .tag { + display: inline-block; + padding: 0.2rem 0.55rem; + border-radius: 4px; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.6rem; + } + .tag-opt { background: rgba(255,255,255,0.06); color: var(--muted); } + .install-card h4 { font-size: 0.9rem; font-weight: 600; margin-bottom: 0.35rem; } + .install-card p { font-size: 0.825rem; color: var(--muted); line-height: 1.6; } + + .waf-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; + margin-top: 0.75rem; + } + .waf-table th { + text-align: left; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border); + color: var(--muted); + font-weight: 500; + font-size: 0.8rem; + } + .waf-table td { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid rgba(255,255,255,0.04); + vertical-align: top; + } + .waf-table td code { + font-family: 'JetBrains Mono', monospace; + font-size: 0.8em; + color: var(--accent); + } + .waf-table tr:last-child td { border-bottom: none; } + @media (max-width: 600px) { .step:not(:last-child)::after { display: none; } .step { padding-right: 0; } @@ -393,6 +597,11 @@ kill-the-news + + Features + How it works + Install + GitHub @@ -409,11 +618,40 @@

Turn email newsletters into private RSS feeds

Self-hosted on Cloudflare Workers. Your data stays in your own account, served from your own domain.

- - - View on GitHub + + + Try the demo - Quick Start ↓ + Self-host ↓ + + + GitHub + +
+ + + +
+
+
+

Try it live — no setup required

+

+ A hosted instance runs at demo.kill-the.news, pre-loaded with sample feeds. + Create a feed, grab the RSS URL, and add it to your reader — all without deploying anything. +

+
+ URL demo.kill-the.news/admin + · + Password password +
+
+
+ + + Open demo + + Resets periodically · for testing only +
@@ -548,6 +786,216 @@ + +
+
+
+ +

Deploy to Cloudflare

+

Everything you need to self-host kill-the-news on your own Cloudflare account, step by step.

+
+ +
+ + +
+
1
+
+

Prerequisites

+

You need a free Cloudflare account with a domain managed by Cloudflare DNS, and Node.js ≥ 18.

+
    +
  • Cloudflare account (free tier is enough)
  • +
  • A domain with DNS managed by Cloudflare
  • +
  • Node.js ≥ 18 & npm
  • +
  • Git
  • +
+
+
+
+ + +
+
2
+
+

Clone & authenticate

+

Clone the repo and log in to Cloudflare via Wrangler. This opens a browser window to authorize the CLI.

+
+
Terminal
+
$ git clone https://github.com/juherr/kill-the-news.git
+$ cd kill-the-news
+$ npm install
+$ npx wrangler login
+
+
+
+
+ + +
+
3
+
+

Run the setup script

+

The interactive setup script creates the KV namespace, sets the admin password secret, and writes wrangler.toml for you.

+
+
Terminal
+
$ bash setup.sh
+
+# The script will ask for:
+#   - Your domain (e.g. newsletters.example.com)
+#   - An admin password (stored as a Wrangler secret)
+#   - Whether to enable R2 for attachments (optional)
+
+

This generates wrangler.toml from the example template. Do not commit it — it is gitignored.

+
+
+
+ + +
+
4
+
+

Deploy the Worker

+
+
Terminal
+
$ npm run deploy
+
+# Wrangler compiles the Worker, uploads it to Cloudflare,
+# and prints the Worker URL. Your admin UI will be live at:
+#   https://<your-domain>/admin
+
+
+
+
+ + +
+
5
+
+

Configure email ingestion

+

Choose how incoming emails reach your Worker:

+
+
+ +

Cloudflare Email Routing

+

In the Cloudflare dashboard, go to Email → Email Routing and enable it on your domain. Add a catch-all rule with action Send to Worker pointing to your deployed Worker. No third-party service required.

+
+
+
Alternative
+

ForwardEmail Webhook

+

Point ForwardEmail MX records at your domain. ForwardEmail parses incoming mail and POSTs a JSON payload to /api/inbound. Useful if you already use ForwardEmail.

+
+
+
+
+
+ + +
+
6
+
+

Optional: enable attachment storage

+

To store email attachments and expose them as RSS enclosures, create an R2 bucket and bind it in wrangler.toml:

+
+
wrangler.toml
+
[[r2_buckets]]
+binding = "ATTACHMENT_BUCKET"
+bucket_name = "kill-the-news-attachments"
+
+

Attachments will be served at /files/:id/:filename and linked as <enclosure> elements in the RSS feed.

+
+
+
+ + +
+
7
+
+

Harden with WAF rate limiting

+

Protect your endpoints against abuse using Cloudflare WAF custom rate-limiting rules — no code changes required, pure infrastructure.

+ +
+
+ +

Via Cloudflare Dashboard

+

Go to Security → WAF → Rate limiting rules and create one rule per endpoint below.

+
+
+
Terraform
+

Via Terraform

+

Use cloudflare_ruleset with phase = "http_ratelimit" — see the snippet below.

+
+
+ +

Recommended limits:

+
+
WAF rules
+ + + + + + + + + + + + + + + + + + +
EndpointConditionLimitAction
/api/inboundURI path = /api/inbound, method = POST60 req / min / IPBlock (1 min)
/admin*URI path starts with /admin20 req / min / IPManaged Challenge (5 min)
+
+ +

Terraform equivalent:

+
+
main.tf
+
resource "cloudflare_ruleset" "rate_limiting" {
+  zone_id = var.cloudflare_zone_id
+  name    = "kill-the-news rate limiting"
+  kind    = "zone"
+  phase   = "http_ratelimit"
+
+  rules {
+    description = "Rate limit /api/inbound"
+    expression  = "(http.request.uri.path eq \"/api/inbound\" and http.request.method eq \"POST\")"
+    action      = "block"
+    ratelimit {
+      characteristics     = ["ip.src"]
+      period              = 60
+      requests_per_period = 60
+      mitigation_timeout  = 60
+    }
+  }
+
+  rules {
+    description = "Rate limit /admin"
+    expression  = "starts_with(http.request.uri.path, \"/admin\")"
+    action      = "managed_challenge"
+    ratelimit {
+      characteristics     = ["ip.src"]
+      period              = 60
+      requests_per_period = 20
+      mitigation_timeout  = 300
+    }
+  }
+}
+
+ +

+ These rules run at the Cloudflare edge before the Worker is invoked — zero latency impact on normal traffic. + If ForwardEmail's delivery IPs ever trigger the /api/inbound limit, add them as an IP Access Rule with action Allow under Security → WAF → Tools. +

+
+
+ +
+
+
+
diff --git a/src/index.ts b/src/index.ts index e971c5a..6f33ab0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -175,4 +175,17 @@ export default { ) { await handleCloudflareEmail(message, env, ctx); }, + async scheduled(_event: ScheduledEvent, env: Env, _ctx: ExecutionContext) { + let cursor: string | undefined; + let deleted = 0; + do { + const result = await env.EMAIL_STORAGE.list({ cursor }); + await Promise.all( + result.keys.map(({ name }) => env.EMAIL_STORAGE.delete(name)), + ); + deleted += result.keys.length; + cursor = result.list_complete ? undefined : result.cursor; + } while (cursor); + logger.info("Demo KV reset complete", { deleted }); + }, }; diff --git a/wrangler-example.toml b/wrangler-example.toml index 1a1a4b3..8555f91 100644 --- a/wrangler-example.toml +++ b/wrangler-example.toml @@ -83,3 +83,7 @@ routes = [ [env.demo.vars] DOMAIN = "demo.kill-the.news" + +# Nightly reset: wipe all KV data at 03:00 UTC so the demo stays clean +[env.demo.triggers] +crons = ["0 3 * * *"]