mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
feat: complete Phase 2 tech debt remediation
- Extract shared RSS/Atom fetch logic into feed-fetcher utility (P1-3)
- Split email-processor into validateEmail/storeEmail functions (P1-6)
- Add stateless HMAC-SHA256 CSRF protection to admin forms (P2-8)
- Fix Hono<{ Bindings: Env }> type safety across all routes (P3-13)
- Add entries.test.ts and files.test.ts with full coverage (P1-7)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+105
@@ -0,0 +1,105 @@
|
||||
# Tech Debt — Remediation Plan
|
||||
|
||||
Generated: 2026-05-22
|
||||
|
||||
## Scoring: Priority = (Impact + Risk) × (6 − Effort)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — This sprint (quick wins, high ROI)
|
||||
|
||||
| # | Task | Priority | Status |
|
||||
| ----- | ------------------------------------------------------------ | -------- | ------ |
|
||||
| P0-2 | Add `inbound.test.ts` with full coverage | 40 | DONE |
|
||||
| P0-1 | Document race condition + TODO for Durable Objects migration | 36 | DONE |
|
||||
| P2-9 | Fix orphaned KV key deletion in `storage.ts` feed trim | 18 | DONE |
|
||||
| P2-12 | Add `/health` endpoint | 15 | DONE |
|
||||
|
||||
## Phase 2 — Next milestone (3–5 days)
|
||||
|
||||
| # | Task | Priority | Status |
|
||||
| ----- | ----------------------------------------------------------- | -------- | ------ |
|
||||
| P1-7 | Add `entries.test.ts`, `files.test.ts`, hub lifecycle tests | 32 | DONE |
|
||||
| P1-3 | Extract shared RSS/Atom feed-fetching logic | 21 | DONE |
|
||||
| P1-6 | Split `email-processor.ts` responsibilities | 21 | DONE |
|
||||
| P2-8 | Add CSRF token to admin forms | 21 | DONE |
|
||||
| P3-13 | Fix `Hono<{ Bindings: Env }>` type safety | 8 | DONE |
|
||||
|
||||
## Phase 3 — Ongoing / Infrastructure
|
||||
|
||||
| # | Task | Priority |
|
||||
| ----- | ---------------------------------------------------------- | -------- |
|
||||
| P1-4 | Structured logging + error aggregation | 36 |
|
||||
| P1-5 | Rate limiting (Cloudflare WAF rules) | 24 |
|
||||
| P1-1 | Migrate feed metadata to Durable Objects for atomic writes | 36 |
|
||||
| P2-10 | Extract constants module (`src/config/constants.ts`) | 12 |
|
||||
| P2-11 | Split `admin.ts` into sub-modules | 8 |
|
||||
|
||||
---
|
||||
|
||||
## Top 3 Business Risks
|
||||
|
||||
1. **Data loss** — concurrent email ingest silently drops emails (KV race condition)
|
||||
2. **Undetected regressions** — inbound route has zero tests; any change is a gamble
|
||||
3. **Silent failures** — no error tracking means production issues are invisible
|
||||
|
||||
---
|
||||
|
||||
## Detailed Findings
|
||||
|
||||
### Critical
|
||||
|
||||
**Race condition in KV metadata updates** (`src/lib/email-processor.ts`)
|
||||
Two concurrent emails to the same feed read-modify-write the metadata non-atomically.
|
||||
The second writer silently overwrites the first's changes. Accepted limitation of KV;
|
||||
long-term fix is Cloudflare Durable Objects for serialised writes.
|
||||
|
||||
**Email ingest path has zero tests** (`src/routes/inbound.ts`)
|
||||
The most critical path in the system is entirely unguarded. Required: happy path,
|
||||
invalid IPs, missing fields, KV write failure, sender validation, attachment upload.
|
||||
|
||||
### High
|
||||
|
||||
**Duplicated RSS/Atom route logic** (`src/routes/rss.ts`, `src/routes/atom.ts`)
|
||||
Param extraction, feedId validation, KV fetches, pagination, cache headers and Link
|
||||
header building are duplicated verbatim. Fix: extract `fetchFeedAndEmails()` utility.
|
||||
|
||||
**No error tracking or production alerting**
|
||||
All errors are console-only, invisible in production. Fix: structured JSON logging
|
||||
|
||||
- Cloudflare Logpush or Sentry integration.
|
||||
|
||||
**No rate limiting on `/api/inbound` or `/admin` login**
|
||||
IP validation is insufficient; admin login has no brute-force protection.
|
||||
Fix: Cloudflare WAF rate-limiting rules.
|
||||
|
||||
**`email-processor.ts` violates single responsibility**
|
||||
Feed ID extraction, sender validation, attachment upload, KV write, feed trimming,
|
||||
and WebSub notification in one function. Fix: split into `validateEmail()`, `storeEmail()`.
|
||||
|
||||
### Medium
|
||||
|
||||
**No CSRF protection on admin forms** (`src/routes/admin.ts`)
|
||||
State-changing POSTs have no CSRF token. Fix: generate token on page load, validate on POST.
|
||||
|
||||
**Orphaned KV keys in `storage.ts` feed trim**
|
||||
`updateFeedMetadata()` truncates the metadata list but never deletes the actual KV entries.
|
||||
(Note: `email-processor.ts` already handles this correctly in the live code path.)
|
||||
|
||||
**Magic numbers without constants**
|
||||
Limits scattered as inline literals: 20 items, 50 metadata, 512 KB, 24h TTL, 30-day lease.
|
||||
Fix: extract to `src/config/constants.ts`.
|
||||
|
||||
**No `/health` endpoint**
|
||||
Monitoring tools cannot detect a bad deploy. Fix: `GET /health` returning `{ status: "ok" }`.
|
||||
|
||||
### Low
|
||||
|
||||
**`as unknown as Env` type cast** (`src/index.ts` line 7)
|
||||
Bypasses TypeScript; Env mismatches become runtime errors.
|
||||
|
||||
**Inconsistent utility organisation** (`lib/` vs `utils/`)
|
||||
No clear rule for placement of new utilities.
|
||||
|
||||
**No audit log for admin actions**
|
||||
Create/delete feed, bulk-delete emails — no record of who did what.
|
||||
+9
-7
@@ -10,6 +10,8 @@ import { hubRouter } from "./routes/hub";
|
||||
import { handleCloudflareEmail } from "./lib/cloudflare-email";
|
||||
import { Env } from "./types";
|
||||
|
||||
type AppEnv = { Bindings: Env };
|
||||
|
||||
const ALLOWED_ORIGINS = ["https://getmynews.app", "https://www.getmynews.app"];
|
||||
|
||||
// Fallback ForwardEmail.net IP addresses in case API fetch fails
|
||||
@@ -20,7 +22,7 @@ const FALLBACK_FORWARD_EMAIL_IPS = [
|
||||
];
|
||||
|
||||
// Create the main Hono app
|
||||
const app = new Hono();
|
||||
const app = new Hono<AppEnv>();
|
||||
|
||||
// Cache for ForwardEmail.net IPs with expiration
|
||||
let forwardEmailIpsCache: {
|
||||
@@ -95,12 +97,12 @@ app.use(
|
||||
);
|
||||
|
||||
// Group routes by functionality
|
||||
const api = new Hono();
|
||||
const rss = new Hono();
|
||||
const atom = new Hono();
|
||||
const entries = new Hono();
|
||||
const files = new Hono();
|
||||
const admin = new Hono();
|
||||
const api = new Hono<AppEnv>();
|
||||
const rss = new Hono<AppEnv>();
|
||||
const atom = new Hono<AppEnv>();
|
||||
const entries = new Hono<AppEnv>();
|
||||
const files = new Hono<AppEnv>();
|
||||
const admin = new Hono<AppEnv>();
|
||||
|
||||
// Webhook security middleware for /inbound - verify ForwardEmail.net IP
|
||||
api.use("/inbound", async (c, next) => {
|
||||
|
||||
+42
-11
@@ -25,13 +25,17 @@ export interface ProcessEmailInput {
|
||||
attachments?: RawAttachment[];
|
||||
}
|
||||
|
||||
type ValidationSuccess = { ok: true; feedId: string; feedConfig: FeedConfig };
|
||||
type ValidationFailure = { ok: false; response: Response };
|
||||
type ValidationResult = ValidationSuccess | ValidationFailure;
|
||||
|
||||
function normalizeEmail(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function senderMatchesAllowlist(
|
||||
sender: string,
|
||||
allowedSender: string, // already normalized by caller
|
||||
allowedSender: string,
|
||||
): boolean {
|
||||
if (!allowedSender) return false;
|
||||
|
||||
@@ -71,15 +75,17 @@ async function uploadAttachments(
|
||||
);
|
||||
}
|
||||
|
||||
export async function processEmail(
|
||||
export async function validateEmail(
|
||||
input: ProcessEmailInput,
|
||||
env: Env,
|
||||
ctx?: ExecutionContext,
|
||||
): Promise<Response> {
|
||||
): Promise<ValidationResult> {
|
||||
const feedId = EmailParser.extractFeedId(input.toAddress);
|
||||
if (!feedId) {
|
||||
console.error(`Invalid email address format: ${input.toAddress}`);
|
||||
return new Response("Invalid email address format", { status: 400 });
|
||||
return {
|
||||
ok: false,
|
||||
response: new Response("Invalid email address format", { status: 400 }),
|
||||
};
|
||||
}
|
||||
|
||||
const feedConfig = (await env.EMAIL_STORAGE.get(
|
||||
@@ -88,7 +94,10 @@ export async function processEmail(
|
||||
)) as FeedConfig | null;
|
||||
if (!feedConfig) {
|
||||
console.error(`Feed with ID ${feedId} does not exist or has been deleted`);
|
||||
return new Response("Feed does not exist", { status: 404 });
|
||||
return {
|
||||
ok: false,
|
||||
response: new Response("Feed does not exist", { status: 404 }),
|
||||
};
|
||||
}
|
||||
|
||||
const allowedSenders = (feedConfig.allowed_senders || [])
|
||||
@@ -103,15 +112,26 @@ export async function processEmail(
|
||||
if (!senderAllowed) {
|
||||
console.warn(
|
||||
`Rejected email for feed ${feedId}; sender not in allowlist`,
|
||||
{
|
||||
senders: input.senders,
|
||||
allowedSenders,
|
||||
},
|
||||
{ senders: input.senders, allowedSenders },
|
||||
);
|
||||
return new Response("Sender not allowed for this feed", { status: 403 });
|
||||
return {
|
||||
ok: false,
|
||||
response: new Response("Sender not allowed for this feed", {
|
||||
status: 403,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, feedId, feedConfig };
|
||||
}
|
||||
|
||||
export async function storeEmail(
|
||||
feedId: string,
|
||||
input: ProcessEmailInput,
|
||||
env: Env,
|
||||
ctx?: ExecutionContext,
|
||||
): Promise<void> {
|
||||
const storedAttachments: AttachmentData[] =
|
||||
env.ATTACHMENT_BUCKET && input.attachments?.length
|
||||
? await uploadAttachments(input.attachments, env.ATTACHMENT_BUCKET)
|
||||
@@ -189,5 +209,16 @@ export async function processEmail(
|
||||
if (ctx) {
|
||||
ctx.waitUntil(notifySubscribers(feedId, env));
|
||||
}
|
||||
}
|
||||
|
||||
export async function processEmail(
|
||||
input: ProcessEmailInput,
|
||||
env: Env,
|
||||
ctx?: ExecutionContext,
|
||||
): Promise<Response> {
|
||||
const validation = await validateEmail(input, env);
|
||||
if (!validation.ok) return validation.response;
|
||||
|
||||
await storeEmail(validation.feedId, input, env, ctx);
|
||||
return new Response("Email processed successfully", { status: 200 });
|
||||
}
|
||||
|
||||
@@ -3,17 +3,20 @@ import { Hono } from "hono";
|
||||
import app from "./admin";
|
||||
import { createMockEnv } from "../test/setup";
|
||||
import { Env } from "../types";
|
||||
import { generateCsrfToken } from "../utils/csrf";
|
||||
|
||||
describe("Admin Routes", () => {
|
||||
let testApp: Hono;
|
||||
let mockEnv: Env;
|
||||
let csrfToken: string;
|
||||
let request: (path: string, init?: RequestInit) => Promise<Response>;
|
||||
let loginAndGetCookie: () => Promise<string>;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
mockEnv = createMockEnv() as unknown as Env;
|
||||
testApp = new Hono();
|
||||
testApp.route("/admin", app);
|
||||
csrfToken = await generateCsrfToken("test-password");
|
||||
request = (path, init = {}) =>
|
||||
Promise.resolve(testApp.request(path, init, mockEnv));
|
||||
loginAndGetCookie = async () => {
|
||||
@@ -94,6 +97,7 @@ describe("Admin Routes", () => {
|
||||
const res = await request("/admin", {
|
||||
headers: {
|
||||
Cookie: authCookie,
|
||||
"X-CSRF-Token": csrfToken,
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
@@ -139,6 +143,7 @@ describe("Admin Routes", () => {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Cookie: authCookie,
|
||||
"X-CSRF-Token": csrfToken,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
@@ -175,6 +180,7 @@ describe("Admin Routes", () => {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Cookie: authCookie,
|
||||
"X-CSRF-Token": csrfToken,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
@@ -195,6 +201,7 @@ describe("Admin Routes", () => {
|
||||
headers: {
|
||||
Cookie: authCookie,
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": csrfToken,
|
||||
},
|
||||
body: JSON.stringify({ title: "", description: "desc" }),
|
||||
});
|
||||
@@ -241,6 +248,7 @@ describe("Admin Routes", () => {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Cookie: authCookie,
|
||||
"X-CSRF-Token": csrfToken,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
@@ -259,6 +267,7 @@ describe("Admin Routes", () => {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Cookie: authCookie,
|
||||
"X-CSRF-Token": csrfToken,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -291,6 +300,7 @@ describe("Admin Routes", () => {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Cookie: authCookie,
|
||||
"X-CSRF-Token": csrfToken,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
@@ -310,6 +320,7 @@ describe("Admin Routes", () => {
|
||||
headers: {
|
||||
Cookie: authCookie,
|
||||
Accept: "application/json",
|
||||
"X-CSRF-Token": csrfToken,
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -329,7 +340,7 @@ describe("Admin Routes", () => {
|
||||
formData.append("description", "Test");
|
||||
const createRes = await request("/admin/feeds/create", {
|
||||
method: "POST",
|
||||
headers: { Cookie: authCookie },
|
||||
headers: { Cookie: authCookie, "X-CSRF-Token": csrfToken },
|
||||
body: formData,
|
||||
});
|
||||
expect(createRes.status).toBe(302);
|
||||
@@ -350,7 +361,7 @@ describe("Admin Routes", () => {
|
||||
|
||||
const bulkDeleteRes = await request("/admin/feeds/bulk-delete", {
|
||||
method: "POST",
|
||||
headers: { Cookie: authCookie },
|
||||
headers: { Cookie: authCookie, "X-CSRF-Token": csrfToken },
|
||||
body: bulkForm,
|
||||
});
|
||||
|
||||
@@ -479,6 +490,7 @@ describe("Admin Routes", () => {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Cookie: authCookie,
|
||||
"X-CSRF-Token": csrfToken,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
@@ -528,6 +540,7 @@ describe("Admin Routes", () => {
|
||||
headers: {
|
||||
Cookie: authCookie,
|
||||
Accept: "application/json",
|
||||
"X-CSRF-Token": csrfToken,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
+83
-18
@@ -15,6 +15,9 @@ import {
|
||||
import { generateFeedId } from "../utils/id-generator";
|
||||
import { designSystem } from "../styles/index";
|
||||
import { interactiveScripts } from "../scripts/index";
|
||||
import { generateCsrfToken, verifyCsrfToken } from "../utils/csrf";
|
||||
|
||||
type AppEnv = { Bindings: Env };
|
||||
|
||||
/**
|
||||
* Admin routes handler for Email-to-RSS
|
||||
@@ -25,7 +28,7 @@ import { interactiveScripts } from "../scripts/index";
|
||||
* - Uses HttpOnly cookies to prevent XSS attacks
|
||||
* - Implements SameSite=Strict to prevent CSRF attacks
|
||||
*/
|
||||
const app = new Hono();
|
||||
const app = new Hono<AppEnv>();
|
||||
|
||||
// Export for testing
|
||||
export default app;
|
||||
@@ -33,7 +36,7 @@ export default app;
|
||||
const ADMIN_COOKIE_NAME = "admin_auth";
|
||||
const ADMIN_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 1 week
|
||||
|
||||
function waitUntilSafe(c: Context, promise: Promise<unknown>) {
|
||||
function waitUntilSafe(c: Context<AppEnv>, promise: Promise<unknown>) {
|
||||
// Hono throws when ExecutionContext isn't present (ex: Node unit tests).
|
||||
try {
|
||||
c.executionCtx.waitUntil(promise);
|
||||
@@ -90,7 +93,7 @@ function timingSafeEqual(a: string, b: string): boolean {
|
||||
|
||||
// Authentication middleware for admin routes
|
||||
async function authMiddleware(c: Context, next: () => Promise<void>) {
|
||||
const env = c.env as unknown as Env;
|
||||
const env = c.env;
|
||||
const path = new URL(c.req.url).pathname;
|
||||
|
||||
// Skip auth check for login page - note that path includes /admin prefix
|
||||
@@ -101,7 +104,7 @@ async function authMiddleware(c: Context, next: () => Promise<void>) {
|
||||
// Proxy auth: only active when both env vars are present
|
||||
if (env.PROXY_AUTH_SECRET && env.PROXY_TRUSTED_IPS) {
|
||||
const trustedIps = env.PROXY_TRUSTED_IPS.split(",")
|
||||
.map((s) => s.trim())
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean);
|
||||
const clientIp = c.req.header("CF-Connecting-IP") ?? "";
|
||||
const providedSecret = c.req.header("X-Auth-Proxy-Secret") ?? "";
|
||||
@@ -133,6 +136,48 @@ async function authMiddleware(c: Context, next: () => Promise<void>) {
|
||||
// Apply auth middleware to all admin routes
|
||||
app.use("*", authMiddleware);
|
||||
|
||||
// CSRF middleware: generates token for GET requests and validates on mutating requests
|
||||
app.use("*", async (c, next) => {
|
||||
const path = new URL(c.req.url).pathname;
|
||||
// Login route is pre-auth, so CSRF doesn't apply there
|
||||
if (path === "/admin/login") {
|
||||
return next();
|
||||
}
|
||||
|
||||
const token = await generateCsrfToken(c.env.ADMIN_PASSWORD);
|
||||
c.set("csrfToken", token);
|
||||
|
||||
if (
|
||||
c.req.method === "POST" ||
|
||||
c.req.method === "PUT" ||
|
||||
c.req.method === "DELETE"
|
||||
) {
|
||||
// Accept token from X-CSRF-Token header (JS fetch calls)
|
||||
const headerToken = c.req.header("X-CSRF-Token") ?? "";
|
||||
if (headerToken) {
|
||||
if (!(await verifyCsrfToken(c.env.ADMIN_PASSWORD, headerToken))) {
|
||||
return c.text("Invalid CSRF token", 403);
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
// For HTML form submissions: clone the request body to read _csrf without consuming the stream
|
||||
const contentType = c.req.header("Content-Type") ?? "";
|
||||
if (
|
||||
contentType.includes("application/x-www-form-urlencoded") ||
|
||||
contentType.includes("multipart/form-data")
|
||||
) {
|
||||
const form = await c.req.raw.clone().formData();
|
||||
const formToken = form.get("_csrf")?.toString() ?? "";
|
||||
if (!(await verifyCsrfToken(c.env.ADMIN_PASSWORD, formToken))) {
|
||||
return c.text("Invalid CSRF token", 403);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
||||
|
||||
// Schema for feed creation
|
||||
const createFeedSchema = z.object({
|
||||
title: z.string().min(1, "Title is required"),
|
||||
@@ -156,7 +201,7 @@ const authSchema = z.object({
|
||||
|
||||
// Base HTML layout with design system
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const layout = (title: string, content: any) => {
|
||||
const layout = (title: string, content: any, csrfToken = "") => {
|
||||
return html`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -164,6 +209,7 @@ const layout = (title: string, content: any) => {
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="csrf-token" content="${csrfToken}" />
|
||||
<style>
|
||||
${raw(designSystem)}
|
||||
</style>
|
||||
@@ -243,7 +289,7 @@ app.get("/login", (c) => {
|
||||
|
||||
// Handle login
|
||||
app.post("/login", async (c) => {
|
||||
const env = c.env as unknown as Env;
|
||||
const env = c.env;
|
||||
|
||||
try {
|
||||
const formData = await c.req.formData();
|
||||
@@ -281,7 +327,7 @@ app.get("/logout", (c) => {
|
||||
// Admin dashboard route
|
||||
app.get("/", async (c) => {
|
||||
// Type assertion for environment variables
|
||||
const env = c.env as unknown as Env;
|
||||
const env = c.env;
|
||||
const emailStorage = env.EMAIL_STORAGE;
|
||||
const url = new URL(c.req.url);
|
||||
const view = url.searchParams.get("view") === "table" ? "table" : "list";
|
||||
@@ -345,6 +391,11 @@ app.get("/", async (c) => {
|
||||
<div class="card">
|
||||
<h2>Create New Feed</h2>
|
||||
<form action="/admin/feeds/create" method="post">
|
||||
<input
|
||||
type="hidden"
|
||||
name="_csrf"
|
||||
value="${c.var.csrfToken ?? ""}"
|
||||
/>
|
||||
<div class="form-group">
|
||||
<label for="title">Feed Title</label>
|
||||
<input type="text" id="title" name="title" required />
|
||||
@@ -1211,6 +1262,7 @@ app.get("/", async (c) => {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
@@ -1467,6 +1519,7 @@ app.get("/", async (c) => {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
''X-CSRF-Token': document.querySelector('meta[name=\"csrf-token\"]')?.content || '',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ feedIds: batch }),
|
||||
@@ -1516,6 +1569,7 @@ app.get("/", async (c) => {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
''X-CSRF-Token': document.querySelector('meta[name=\"csrf-token\"]')?.content || '',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ feedIds: [feedId] }),
|
||||
@@ -1598,6 +1652,7 @@ app.get("/", async (c) => {
|
||||
`)};
|
||||
</script>
|
||||
`,
|
||||
c.var.csrfToken ?? "",
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -1605,7 +1660,7 @@ app.get("/", async (c) => {
|
||||
// Create a new feed
|
||||
app.post("/feeds/create", async (c) => {
|
||||
// Type assertion for environment variables
|
||||
const env = c.env as unknown as Env;
|
||||
const env = c.env;
|
||||
const emailStorage = env.EMAIL_STORAGE;
|
||||
const isJson =
|
||||
c.req.header("Content-Type")?.includes("application/json") ?? false;
|
||||
@@ -1702,7 +1757,7 @@ app.post("/feeds/create", async (c) => {
|
||||
// Edit feed page
|
||||
app.get("/feeds/:feedId/edit", async (c) => {
|
||||
// Type assertion for environment variables
|
||||
const env = c.env as unknown as Env;
|
||||
const env = c.env;
|
||||
const emailStorage = env.EMAIL_STORAGE;
|
||||
const feedId = c.req.param("feedId");
|
||||
|
||||
@@ -1734,6 +1789,11 @@ app.get("/feeds/:feedId/edit", async (c) => {
|
||||
|
||||
<div class="card">
|
||||
<form action="/admin/feeds/${feedId}/edit" method="post">
|
||||
<input
|
||||
type="hidden"
|
||||
name="_csrf"
|
||||
value="${c.var.csrfToken ?? ""}"
|
||||
/>
|
||||
<div class="form-group">
|
||||
<label for="title">Feed Title</label>
|
||||
<input
|
||||
@@ -1778,6 +1838,7 @@ ${(feedConfig.allowed_senders || []).join("\n")}</textarea
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
c.var.csrfToken ?? "",
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -1785,7 +1846,7 @@ ${(feedConfig.allowed_senders || []).join("\n")}</textarea
|
||||
// Update feed
|
||||
app.post("/feeds/:feedId/edit", async (c) => {
|
||||
// Type assertion for environment variables
|
||||
const env = c.env as unknown as Env;
|
||||
const env = c.env;
|
||||
const emailStorage = env.EMAIL_STORAGE;
|
||||
const feedId = c.req.param("feedId");
|
||||
|
||||
@@ -1978,7 +2039,7 @@ async function purgeFeedKeysStep(
|
||||
|
||||
// Delete feed
|
||||
app.post("/feeds/:feedId/delete", async (c) => {
|
||||
const env = c.env as unknown as Env;
|
||||
const env = c.env;
|
||||
const emailStorage = env.EMAIL_STORAGE;
|
||||
const feedId = c.req.param("feedId");
|
||||
const view = c.req.query("view") === "table" ? "table" : "list";
|
||||
@@ -2014,7 +2075,7 @@ app.post("/feeds/:feedId/delete", async (c) => {
|
||||
|
||||
// Purge all keys for a feed in small steps (used by the admin UI after deleting feeds).
|
||||
app.post("/feeds/:feedId/purge", async (c) => {
|
||||
const env = c.env as unknown as Env;
|
||||
const env = c.env;
|
||||
const emailStorage = env.EMAIL_STORAGE;
|
||||
const feedId = c.req.param("feedId");
|
||||
|
||||
@@ -2051,7 +2112,7 @@ app.post("/feeds/:feedId/purge", async (c) => {
|
||||
|
||||
// Bulk delete feeds selected in the dashboard
|
||||
app.post("/feeds/bulk-delete", async (c) => {
|
||||
const env = c.env as unknown as Env;
|
||||
const env = c.env;
|
||||
const emailStorage = env.EMAIL_STORAGE;
|
||||
const contentType = c.req.header("Content-Type") || "";
|
||||
const wantsJson =
|
||||
@@ -2188,7 +2249,7 @@ app.post("/feeds/bulk-delete", async (c) => {
|
||||
// View all emails for a feed
|
||||
app.get("/feeds/:feedId/emails", async (c) => {
|
||||
// Type assertion for environment variables
|
||||
const env = c.env as unknown as Env;
|
||||
const env = c.env;
|
||||
const emailStorage = env.EMAIL_STORAGE;
|
||||
const feedId = c.req.param("feedId");
|
||||
const message = c.req.query("message");
|
||||
@@ -2772,6 +2833,7 @@ app.get("/feeds/:feedId/emails", async (c) => {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
''X-CSRF-Token': document.querySelector('meta[name=\"csrf-token\"]')?.content || '',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
@@ -3023,6 +3085,7 @@ app.get("/feeds/:feedId/emails", async (c) => {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ emailKeys: batch }),
|
||||
@@ -3084,6 +3147,7 @@ app.get("/feeds/:feedId/emails", async (c) => {
|
||||
`)};
|
||||
</script>
|
||||
`,
|
||||
c.var.csrfToken ?? "",
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -3091,7 +3155,7 @@ app.get("/feeds/:feedId/emails", async (c) => {
|
||||
// View email content
|
||||
app.get("/emails/:emailKey", async (c) => {
|
||||
// Type assertion for environment variables
|
||||
const env = c.env as unknown as Env;
|
||||
const env = c.env;
|
||||
const emailStorage = env.EMAIL_STORAGE;
|
||||
const emailKey = c.req.param("emailKey");
|
||||
|
||||
@@ -3444,6 +3508,7 @@ ${emailData.content.replace(/</g, "<").replace(/>/g, ">")}</pre
|
||||
`)};
|
||||
</script>
|
||||
`,
|
||||
c.var.csrfToken ?? "",
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -3451,7 +3516,7 @@ ${emailData.content.replace(/</g, "<").replace(/>/g, ">")}</pre
|
||||
// Delete email
|
||||
app.post("/emails/:emailKey/delete", async (c) => {
|
||||
// Type assertion for environment variables
|
||||
const env = c.env as unknown as Env;
|
||||
const env = c.env;
|
||||
const emailStorage = env.EMAIL_STORAGE;
|
||||
const emailKey = c.req.param("emailKey");
|
||||
const wantsJson = (c.req.header("Accept") || "").includes("application/json");
|
||||
@@ -3515,7 +3580,7 @@ app.post("/emails/:emailKey/delete", async (c) => {
|
||||
|
||||
// Bulk delete selected emails from a feed
|
||||
app.post("/feeds/:feedId/emails/bulk-delete", async (c) => {
|
||||
const env = c.env as unknown as Env;
|
||||
const env = c.env;
|
||||
const emailStorage = env.EMAIL_STORAGE;
|
||||
const feedId = c.req.param("feedId");
|
||||
const contentType = c.req.header("Content-Type") || "";
|
||||
@@ -3769,7 +3834,7 @@ app.post(
|
||||
),
|
||||
async (c) => {
|
||||
// Type assertion for environment variables
|
||||
const env = c.env as unknown as Env;
|
||||
const env = c.env;
|
||||
const emailStorage = env.EMAIL_STORAGE;
|
||||
const feedId = c.req.param("feedId");
|
||||
|
||||
|
||||
+12
-41
@@ -1,56 +1,27 @@
|
||||
import { Context } from "hono";
|
||||
import { Env, FeedConfig, FeedMetadata, EmailData } from "../types";
|
||||
import { Env } from "../types";
|
||||
import { generateAtomFeed } from "../utils/feed-generator";
|
||||
import { fetchFeedData } from "../utils/feed-fetcher";
|
||||
|
||||
export async function handle(c: Context): Promise<Response> {
|
||||
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
||||
try {
|
||||
const env = c.env as unknown as Env;
|
||||
|
||||
const feedId = c.req.param("feedId");
|
||||
|
||||
if (!feedId) {
|
||||
return new Response("Feed ID is required", { status: 400 });
|
||||
}
|
||||
|
||||
const emailStorage = env.EMAIL_STORAGE;
|
||||
|
||||
const feedMetadata = (await emailStorage.get(
|
||||
`feed:${feedId}:metadata`,
|
||||
"json",
|
||||
)) as FeedMetadata | null;
|
||||
|
||||
if (!feedMetadata) {
|
||||
const feedData = await fetchFeedData(feedId, c.env, "atom");
|
||||
if (!feedData) {
|
||||
return new Response("Feed not found", { status: 404 });
|
||||
}
|
||||
|
||||
const feedConfig = ((await emailStorage.get(
|
||||
`feed:${feedId}:config`,
|
||||
"json",
|
||||
)) as FeedConfig | null) || {
|
||||
title: `Newsletter Feed ${feedId}`,
|
||||
description: "Converted email newsletter",
|
||||
site_url: `https://${env.DOMAIN}/atom/${feedId}`,
|
||||
feed_url: `https://${env.DOMAIN}/atom/${feedId}`,
|
||||
language: "en",
|
||||
created_at: Date.now(),
|
||||
};
|
||||
|
||||
const emails = feedMetadata.emails.slice(0, 20);
|
||||
const emailsData: EmailData[] = [];
|
||||
|
||||
for (const email of emails) {
|
||||
const emailData = (await emailStorage.get(
|
||||
email.key,
|
||||
"json",
|
||||
)) as EmailData | null;
|
||||
if (emailData) {
|
||||
emailsData.push(emailData);
|
||||
}
|
||||
}
|
||||
|
||||
const baseUrl = `https://${env.DOMAIN}`;
|
||||
const atomXml = generateAtomFeed(feedConfig, emailsData, baseUrl, feedId);
|
||||
|
||||
const baseUrl = `https://${c.env.DOMAIN}`;
|
||||
const atomXml = generateAtomFeed(
|
||||
feedData.feedConfig,
|
||||
feedData.emails,
|
||||
baseUrl,
|
||||
feedId,
|
||||
);
|
||||
const linkHeader = [
|
||||
`<${baseUrl}/hub>; rel="hub"`,
|
||||
`<${baseUrl}/atom/${feedId}>; rel="self"`,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { handle } from "./entries";
|
||||
import { createMockEnv } from "../test/setup";
|
||||
|
||||
const FEED_ID = "test-feed";
|
||||
const RECEIVED_AT = 1700000001000;
|
||||
const EMAIL_KEY = `feed:${FEED_ID}:${RECEIVED_AT}`;
|
||||
|
||||
function makeApp() {
|
||||
const app = new Hono();
|
||||
app.get("/:feedId/:entryId", handle);
|
||||
return app;
|
||||
}
|
||||
|
||||
async function seedFeed(env: ReturnType<typeof createMockEnv>) {
|
||||
await env.EMAIL_STORAGE.put(
|
||||
EMAIL_KEY,
|
||||
JSON.stringify({
|
||||
subject: "Test Subject",
|
||||
from: "sender@example.com",
|
||||
content: "<p>Email body</p>",
|
||||
receivedAt: RECEIVED_AT,
|
||||
headers: {},
|
||||
}),
|
||||
);
|
||||
await env.EMAIL_STORAGE.put(
|
||||
`feed:${FEED_ID}:metadata`,
|
||||
JSON.stringify({
|
||||
emails: [
|
||||
{ key: EMAIL_KEY, subject: "Test Subject", receivedAt: RECEIVED_AT },
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("GET /entries/:feedId/:entryId", () => {
|
||||
let env: ReturnType<typeof createMockEnv>;
|
||||
|
||||
beforeEach(() => {
|
||||
env = createMockEnv();
|
||||
});
|
||||
|
||||
it("returns 404 when feed does not exist", async () => {
|
||||
const app = makeApp();
|
||||
const res = await app.request(`/${FEED_ID}/999`, {}, env as any);
|
||||
expect(res.status).toBe(404);
|
||||
expect(await res.text()).toContain("Feed not found");
|
||||
});
|
||||
|
||||
it("returns 404 when entry does not exist in metadata", async () => {
|
||||
const app = makeApp();
|
||||
await env.EMAIL_STORAGE.put(
|
||||
`feed:${FEED_ID}:metadata`,
|
||||
JSON.stringify({ emails: [] }),
|
||||
);
|
||||
const res = await app.request(`/${FEED_ID}/999`, {}, env as any);
|
||||
expect(res.status).toBe(404);
|
||||
expect(await res.text()).toContain("Entry not found");
|
||||
});
|
||||
|
||||
it("returns 404 when entryId is not a number", async () => {
|
||||
const app = makeApp();
|
||||
const res = await app.request(`/${FEED_ID}/not-a-number`, {}, env as any);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 200 with HTML for valid entry", async () => {
|
||||
await seedFeed(env);
|
||||
const app = makeApp();
|
||||
const res = await app.request(`/${FEED_ID}/${RECEIVED_AT}`, {}, env as any);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toContain("text/html");
|
||||
});
|
||||
|
||||
it("includes email subject in HTML title", async () => {
|
||||
await seedFeed(env);
|
||||
const app = makeApp();
|
||||
const res = await app.request(`/${FEED_ID}/${RECEIVED_AT}`, {}, env as any);
|
||||
const body = await res.text();
|
||||
expect(body).toContain("Test Subject");
|
||||
});
|
||||
|
||||
it("includes email content in HTML body", async () => {
|
||||
await seedFeed(env);
|
||||
const app = makeApp();
|
||||
const res = await app.request(`/${FEED_ID}/${RECEIVED_AT}`, {}, env as any);
|
||||
const body = await res.text();
|
||||
expect(body).toContain("<p>Email body</p>");
|
||||
});
|
||||
|
||||
it("includes sender in HTML", async () => {
|
||||
await seedFeed(env);
|
||||
const app = makeApp();
|
||||
const res = await app.request(`/${FEED_ID}/${RECEIVED_AT}`, {}, env as any);
|
||||
const body = await res.text();
|
||||
expect(body).toContain("sender@example.com");
|
||||
});
|
||||
|
||||
it("sets Content-Security-Policy header", async () => {
|
||||
await seedFeed(env);
|
||||
const app = makeApp();
|
||||
const res = await app.request(`/${FEED_ID}/${RECEIVED_AT}`, {}, env as any);
|
||||
expect(res.headers.get("Content-Security-Policy")).toContain(
|
||||
"default-src 'none'",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,7 @@ import { Context } from "hono";
|
||||
import { html, raw } from "hono/html";
|
||||
import { Env, FeedMetadata, EmailData } from "../types";
|
||||
|
||||
export async function handle(c: Context): Promise<Response> {
|
||||
const env = c.env as unknown as Env;
|
||||
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
||||
const feedId = c.req.param("feedId");
|
||||
const receivedAt = parseInt(c.req.param("entryId"), 10);
|
||||
|
||||
@@ -11,7 +10,7 @@ export async function handle(c: Context): Promise<Response> {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const emailStorage = env.EMAIL_STORAGE;
|
||||
const emailStorage = c.env.EMAIL_STORAGE;
|
||||
|
||||
const feedMetadata = (await emailStorage.get(
|
||||
`feed:${feedId}:metadata`,
|
||||
|
||||
+3
-5
@@ -1,17 +1,15 @@
|
||||
import { Context } from "hono";
|
||||
import { Env } from "../types";
|
||||
|
||||
export async function handle(c: Context): Promise<Response> {
|
||||
const env = c.env as unknown as Env;
|
||||
|
||||
if (!env.ATTACHMENT_BUCKET) {
|
||||
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
||||
if (!c.env.ATTACHMENT_BUCKET) {
|
||||
return new Response("Attachment storage not configured", { status: 404 });
|
||||
}
|
||||
|
||||
const attachmentId = c.req.param("attachmentId");
|
||||
const filename = c.req.param("filename");
|
||||
|
||||
const object = await env.ATTACHMENT_BUCKET.get(attachmentId);
|
||||
const object = await c.env.ATTACHMENT_BUCKET.get(attachmentId);
|
||||
|
||||
if (!object) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
|
||||
+5
-3
@@ -1,11 +1,13 @@
|
||||
import { Hono, type Context } from "hono";
|
||||
import { Env } from "../types";
|
||||
|
||||
type AppEnv = { Bindings: Env };
|
||||
import {
|
||||
verifyAndStoreSubscription,
|
||||
verifyAndDeleteSubscription,
|
||||
} from "../utils/websub";
|
||||
|
||||
function waitUntilSafe(c: Context, promise: Promise<unknown>) {
|
||||
function waitUntilSafe(c: Context<AppEnv>, promise: Promise<unknown>) {
|
||||
// Hono throws when ExecutionContext isn't present (e.g. Node unit tests).
|
||||
try {
|
||||
c.executionCtx.waitUntil(promise);
|
||||
@@ -17,10 +19,10 @@ function waitUntilSafe(c: Context, promise: Promise<unknown>) {
|
||||
const DEFAULT_LEASE_SECONDS = 86400;
|
||||
const MAX_LEASE_SECONDS = 30 * 24 * 3600; // 30 days
|
||||
|
||||
export const hubRouter = new Hono();
|
||||
export const hubRouter = new Hono<AppEnv>();
|
||||
|
||||
hubRouter.post("/", async (c) => {
|
||||
const env = c.env as unknown as Env;
|
||||
const env = c.env;
|
||||
let form: FormData;
|
||||
try {
|
||||
form = await c.req.formData();
|
||||
|
||||
@@ -2,9 +2,8 @@ import { Context } from "hono";
|
||||
import { Env } from "../types";
|
||||
import { ForwardEmailPayload, handleForwardEmail } from "../lib/forwardemail";
|
||||
|
||||
export async function handle(c: Context): Promise<Response> {
|
||||
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
||||
try {
|
||||
const env = c.env as unknown as Env;
|
||||
const payload: ForwardEmailPayload = await c.req.json();
|
||||
|
||||
console.log("Received email:", {
|
||||
@@ -20,7 +19,7 @@ export async function handle(c: Context): Promise<Response> {
|
||||
} catch {
|
||||
// No ExecutionContext in this environment (e.g. tests); WebSub notifications will be skipped
|
||||
}
|
||||
return handleForwardEmail(payload, env, ctx);
|
||||
return handleForwardEmail(payload, c.env, ctx);
|
||||
} catch (error) {
|
||||
console.error("Error processing email:", error);
|
||||
return new Response("Error processing email", { status: 500 });
|
||||
|
||||
+15
-58
@@ -1,80 +1,37 @@
|
||||
import { Context } from "hono";
|
||||
import { Env, FeedConfig, FeedMetadata, EmailData } from "../types";
|
||||
import { Env } from "../types";
|
||||
import { generateRssFeed } from "../utils/feed-generator";
|
||||
import { fetchFeedData } from "../utils/feed-fetcher";
|
||||
|
||||
/**
|
||||
* Generates an RSS feed for a specific feed ID
|
||||
*/
|
||||
export async function handle(c: Context): Promise<Response> {
|
||||
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
||||
try {
|
||||
// Type assertion for environment variables
|
||||
const env = c.env as unknown as Env;
|
||||
|
||||
// Extract the feed ID from the route params
|
||||
const feedId = c.req.param("feedId");
|
||||
|
||||
if (!feedId) {
|
||||
return new Response("Feed ID is required", { status: 400 });
|
||||
}
|
||||
|
||||
// Get the KV namespace
|
||||
const emailStorage = env.EMAIL_STORAGE;
|
||||
|
||||
// Check if the feed exists
|
||||
const feedMetadataKey = `feed:${feedId}:metadata`;
|
||||
const feedMetadata = (await emailStorage.get(
|
||||
feedMetadataKey,
|
||||
"json",
|
||||
)) as FeedMetadata | null;
|
||||
|
||||
if (!feedMetadata) {
|
||||
const feedData = await fetchFeedData(feedId, c.env, "rss");
|
||||
if (!feedData) {
|
||||
return new Response("Feed not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Get feed configuration (title, description, etc.)
|
||||
const feedConfigKey = `feed:${feedId}:config`;
|
||||
const feedConfig = ((await emailStorage.get(
|
||||
feedConfigKey,
|
||||
"json",
|
||||
)) as FeedConfig | null) || {
|
||||
title: `Newsletter Feed ${feedId}`,
|
||||
description: "Converted email newsletter",
|
||||
site_url: `https://${env.DOMAIN}/rss/${feedId}`,
|
||||
feed_url: `https://${env.DOMAIN}/rss/${feedId}`,
|
||||
language: "en",
|
||||
created_at: Date.now(),
|
||||
};
|
||||
|
||||
// Get the emails for this feed (up to the last 20)
|
||||
const emails = feedMetadata.emails.slice(0, 20);
|
||||
const emailsData: EmailData[] = [];
|
||||
|
||||
// Fetch all email content
|
||||
for (const email of emails) {
|
||||
const emailData = (await emailStorage.get(
|
||||
email.key,
|
||||
"json",
|
||||
)) as EmailData | null;
|
||||
if (emailData) {
|
||||
emailsData.push(emailData);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate the RSS feed XML
|
||||
const baseUrl = `https://${env.DOMAIN}`;
|
||||
const rssXml = generateRssFeed(feedConfig, emailsData, baseUrl, feedId);
|
||||
|
||||
// Return the RSS feed with appropriate content type
|
||||
const baseUrl = `https://${c.env.DOMAIN}`;
|
||||
const rssXml = generateRssFeed(
|
||||
feedData.feedConfig,
|
||||
feedData.emails,
|
||||
baseUrl,
|
||||
feedId,
|
||||
);
|
||||
const linkHeader = [
|
||||
`<https://${env.DOMAIN}/hub>; rel="hub"`,
|
||||
`<https://${env.DOMAIN}/rss/${feedId}>; rel="self"`,
|
||||
`<${baseUrl}/hub>; rel="hub"`,
|
||||
`<${baseUrl}/rss/${feedId}>; rel="self"`,
|
||||
].join(", ");
|
||||
|
||||
return new Response(rssXml, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/rss+xml",
|
||||
"Cache-Control": "max-age=1800", // 30 minutes cache
|
||||
"Cache-Control": "max-age=1800",
|
||||
Link: linkHeader,
|
||||
},
|
||||
});
|
||||
|
||||
Vendored
+1
@@ -4,5 +4,6 @@ import { Env } from "./index";
|
||||
declare module "hono" {
|
||||
interface ContextVariableMap {
|
||||
env: Env;
|
||||
csrfToken: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
const BUCKET_MS = 10 * 60 * 1000; // 10-minute window
|
||||
|
||||
async function hmacHex(secret: string, message: string): Promise<string> {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
const sig = await crypto.subtle.sign(
|
||||
"HMAC",
|
||||
key,
|
||||
new TextEncoder().encode(message),
|
||||
);
|
||||
return Array.from(new Uint8Array(sig))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
export async function generateCsrfToken(secret: string): Promise<string> {
|
||||
const bucket = Math.floor(Date.now() / BUCKET_MS).toString();
|
||||
return hmacHex(secret, bucket);
|
||||
}
|
||||
|
||||
export async function verifyCsrfToken(
|
||||
secret: string,
|
||||
token: string,
|
||||
): Promise<boolean> {
|
||||
if (!token) return false;
|
||||
const now = Math.floor(Date.now() / BUCKET_MS);
|
||||
// Accept current and previous bucket to handle boundary cases
|
||||
for (const bucket of [now, now - 1]) {
|
||||
const expected = await hmacHex(secret, bucket.toString());
|
||||
if (token === expected) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Env, FeedConfig, FeedMetadata, EmailData } from "../types";
|
||||
|
||||
const MAX_FEED_ITEMS = 20;
|
||||
|
||||
export interface FeedData {
|
||||
feedConfig: FeedConfig;
|
||||
emails: EmailData[];
|
||||
}
|
||||
|
||||
export async function fetchFeedData(
|
||||
feedId: string,
|
||||
env: Env,
|
||||
feedPath: "rss" | "atom",
|
||||
): Promise<FeedData | null> {
|
||||
const storage = env.EMAIL_STORAGE;
|
||||
|
||||
const feedMetadata = (await storage.get(
|
||||
`feed:${feedId}:metadata`,
|
||||
"json",
|
||||
)) as FeedMetadata | null;
|
||||
|
||||
if (!feedMetadata) return null;
|
||||
|
||||
const feedConfig = ((await storage.get(
|
||||
`feed:${feedId}:config`,
|
||||
"json",
|
||||
)) as FeedConfig | null) ?? {
|
||||
title: `Newsletter Feed ${feedId}`,
|
||||
description: "Converted email newsletter",
|
||||
site_url: `https://${env.DOMAIN}/${feedPath}/${feedId}`,
|
||||
feed_url: `https://${env.DOMAIN}/${feedPath}/${feedId}`,
|
||||
language: "en",
|
||||
created_at: Date.now(),
|
||||
};
|
||||
|
||||
const emailRefs = feedMetadata.emails.slice(0, MAX_FEED_ITEMS);
|
||||
const emails: EmailData[] = [];
|
||||
for (const ref of emailRefs) {
|
||||
const data = (await storage.get(ref.key, "json")) as EmailData | null;
|
||||
if (data) emails.push(data);
|
||||
}
|
||||
|
||||
return { feedConfig, emails };
|
||||
}
|
||||
Reference in New Issue
Block a user