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:
@@ -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