mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
fix(security): lock down admin + add bulk cleanup UI
This commit is contained in:
@@ -35,6 +35,7 @@ Current keys used by routes:
|
|||||||
|
|
||||||
- `feeds:list` -> `{ feeds: Array<{ id, title }> }`
|
- `feeds:list` -> `{ feeds: Array<{ id, title }> }`
|
||||||
- `feed:<feedId>:config` -> feed config object
|
- `feed:<feedId>:config` -> feed config object
|
||||||
|
- `feed:<feedId>:config.allowed_senders` -> optional sender allowlist (email or domain)
|
||||||
- `feed:<feedId>:metadata` -> `{ emails: Array<{ key, subject, receivedAt }> }`
|
- `feed:<feedId>:metadata` -> `{ emails: Array<{ key, subject, receivedAt }> }`
|
||||||
- `feed:<feedId>:<timestamp>` -> stored email body/metadata
|
- `feed:<feedId>:<timestamp>` -> stored email body/metadata
|
||||||
|
|
||||||
@@ -68,9 +69,17 @@ Notes:
|
|||||||
## Security assumptions
|
## Security assumptions
|
||||||
|
|
||||||
- Inbound endpoint only accepts requests from ForwardEmail source IPs.
|
- Inbound endpoint only accepts requests from ForwardEmail source IPs.
|
||||||
- Admin access uses cookie gate and password stored in Worker secret (`ADMIN_PASSWORD`).
|
- Admin access uses a signed cookie gate and password stored in Worker secret (`ADMIN_PASSWORD`).
|
||||||
|
- Admin pages set `Cache-Control: no-store`.
|
||||||
|
- Prefer setting `allowed_senders` on legitimate feeds to reduce inbound spam.
|
||||||
- Do not hardcode credentials or domain-specific secrets into tracked files.
|
- Do not hardcode credentials or domain-specific secrets into tracked files.
|
||||||
|
|
||||||
|
## Spam cleanup workflow
|
||||||
|
|
||||||
|
- First choice: use dashboard bulk actions (`/admin`) with search + checkbox selection.
|
||||||
|
- Use **Table** view for bulk delete.
|
||||||
|
- Avoid wildcard deletion; prefer search + small batches to reduce risk of deleting legitimate feeds.
|
||||||
|
|
||||||
## Cloudflare/Wrangler conventions
|
## Cloudflare/Wrangler conventions
|
||||||
|
|
||||||
- `wrangler.toml` is generated locally from `wrangler-example.toml`.
|
- `wrangler.toml` is generated locally from `wrangler-example.toml`.
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ Email-to-RSS keeps the same workflow while avoiding shared domains and shared da
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- One-click feed creation from an admin dashboard
|
- One-click feed creation from an admin dashboard
|
||||||
|
- Bulk feed/email deletion from the admin dashboard (safe checkbox-based flow)
|
||||||
- Unique newsletter addresses per feed (for example `apple.mountain.42@yourdomain.com`)
|
- Unique newsletter addresses per feed (for example `apple.mountain.42@yourdomain.com`)
|
||||||
- ForwardEmail webhook ingestion with source-IP verification
|
- ForwardEmail webhook ingestion with source-IP verification
|
||||||
|
- Optional per-feed sender allowlist (`email@domain.com` or `domain.com`)
|
||||||
- RSS generation on demand (`/rss/:feedId`)
|
- RSS generation on demand (`/rss/:feedId`)
|
||||||
- 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
|
||||||
@@ -97,9 +99,21 @@ npm run build
|
|||||||
## Security notes
|
## Security notes
|
||||||
|
|
||||||
- Inbound webhook access is IP-restricted to ForwardEmail MX sources.
|
- Inbound webhook access is IP-restricted to ForwardEmail MX sources.
|
||||||
- Admin auth is cookie-based (`HttpOnly`, `SameSite=Strict`).
|
- Admin auth uses a signed, `HttpOnly`, `Secure`, `SameSite=Strict` cookie.
|
||||||
|
- Admin responses are `no-store` to avoid cache leakage.
|
||||||
|
- For high-value feeds, set `Allowed senders` so only known sender addresses/domains are accepted.
|
||||||
- You should use a strong admin password and rotate periodically.
|
- You should use a strong admin password and rotate periodically.
|
||||||
|
|
||||||
|
## Spam cleanup runbook
|
||||||
|
|
||||||
|
### UI-first cleanup
|
||||||
|
|
||||||
|
1. Open `/admin`.
|
||||||
|
2. Switch to **Table** view.
|
||||||
|
3. Use the search box to filter obvious spam feeds.
|
||||||
|
4. Select rows and use **Delete Selected Feeds**.
|
||||||
|
5. For legitimate feeds that got spam emails, open **Emails**, filter by subject, then **Delete Selected Emails**.
|
||||||
|
|
||||||
## Upgrading dependencies
|
## Upgrading dependencies
|
||||||
|
|
||||||
To refresh dependencies to latest:
|
To refresh dependencies to latest:
|
||||||
|
|||||||
@@ -8,12 +8,25 @@ describe("Admin Routes", () => {
|
|||||||
let testApp: Hono;
|
let testApp: Hono;
|
||||||
let mockEnv: Env;
|
let mockEnv: Env;
|
||||||
let request: (path: string, init?: RequestInit) => Promise<Response>;
|
let request: (path: string, init?: RequestInit) => Promise<Response>;
|
||||||
|
let loginAndGetCookie: () => Promise<string>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockEnv = createMockEnv();
|
mockEnv = createMockEnv();
|
||||||
testApp = new Hono();
|
testApp = new Hono();
|
||||||
testApp.route("/admin", app);
|
testApp.route("/admin", app);
|
||||||
request = (path, init = {}) => testApp.request(path, init, mockEnv);
|
request = (path, init = {}) => testApp.request(path, init, mockEnv);
|
||||||
|
loginAndGetCookie = async () => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("password", "test-password");
|
||||||
|
const response = await request("/admin/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(302);
|
||||||
|
const setCookie = response.headers.get("Set-Cookie");
|
||||||
|
expect(setCookie).toBeTruthy();
|
||||||
|
return (setCookie as string).split(";")[0];
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Authentication", () => {
|
describe("Authentication", () => {
|
||||||
@@ -38,11 +51,13 @@ describe("Admin Routes", () => {
|
|||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(302);
|
||||||
|
expect(res.headers.get("Location")).toBe("/admin");
|
||||||
const cookie = res.headers.get("Set-Cookie");
|
const cookie = res.headers.get("Set-Cookie");
|
||||||
expect(cookie).toContain("admin_auth=true");
|
expect(cookie).toContain("admin_auth=");
|
||||||
expect(cookie).toContain("HttpOnly");
|
expect(cookie).toContain("HttpOnly");
|
||||||
expect(cookie).toContain("SameSite=Strict");
|
expect(cookie).toContain("SameSite=Strict");
|
||||||
|
expect(cookie).toContain("Secure");
|
||||||
expect(cookie).toContain("Path=/");
|
expect(cookie).toContain("Path=/");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,9 +88,8 @@ describe("Admin Routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Protected Routes", () => {
|
describe("Protected Routes", () => {
|
||||||
const authCookie = "admin_auth=true";
|
|
||||||
|
|
||||||
it("should allow access to dashboard with valid auth cookie", async () => {
|
it("should allow access to dashboard with valid auth cookie", async () => {
|
||||||
|
const authCookie = await loginAndGetCookie();
|
||||||
const res = await request("/admin", {
|
const res = await request("/admin", {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: authCookie,
|
Cookie: authCookie,
|
||||||
@@ -85,6 +99,16 @@ describe("Admin Routes", () => {
|
|||||||
expect(res.headers.get("Content-Type")).toContain("text/html");
|
expect(res.headers.get("Content-Type")).toContain("text/html");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should reject access with forged auth cookie", async () => {
|
||||||
|
const res = await request("/admin", {
|
||||||
|
headers: {
|
||||||
|
Cookie: "admin_auth=true",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(302);
|
||||||
|
expect(res.headers.get("Location")).toBe("/admin/login");
|
||||||
|
});
|
||||||
|
|
||||||
describe("Feed Creation", () => {
|
describe("Feed Creation", () => {
|
||||||
it("should prevent feed creation without authentication", async () => {
|
it("should prevent feed creation without authentication", async () => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -105,6 +129,7 @@ describe("Admin Routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should allow feed creation with valid authentication", async () => {
|
it("should allow feed creation with valid authentication", async () => {
|
||||||
|
const authCookie = await loginAndGetCookie();
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("title", "Test Feed");
|
formData.append("title", "Test Feed");
|
||||||
formData.append("description", "Test Description");
|
formData.append("description", "Test Description");
|
||||||
@@ -118,7 +143,7 @@ describe("Admin Routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(302); // Redirects back to dashboard
|
expect(res.status).toBe(302); // Redirects back to dashboard
|
||||||
expect(res.headers.get("Location")).toBe("/admin");
|
expect(res.headers.get("Location")).toBe("/admin?view=list");
|
||||||
|
|
||||||
// Verify feed was created in KV
|
// Verify feed was created in KV
|
||||||
const feedList = (await mockEnv.EMAIL_STORAGE.get(
|
const feedList = (await mockEnv.EMAIL_STORAGE.get(
|
||||||
@@ -141,6 +166,7 @@ describe("Admin Routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should reject feed creation with missing title", async () => {
|
it("should reject feed creation with missing title", async () => {
|
||||||
|
const authCookie = await loginAndGetCookie();
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("description", "Test Description");
|
formData.append("description", "Test Description");
|
||||||
|
|
||||||
@@ -187,6 +213,7 @@ describe("Admin Routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should allow feed deletion with valid authentication", async () => {
|
it("should allow feed deletion with valid authentication", async () => {
|
||||||
|
const authCookie = await loginAndGetCookie();
|
||||||
// First create a feed
|
// First create a feed
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("title", "Test Feed");
|
formData.append("title", "Test Feed");
|
||||||
@@ -218,7 +245,7 @@ describe("Admin Routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(deleteRes.status).toBe(302);
|
expect(deleteRes.status).toBe(302);
|
||||||
expect(deleteRes.headers.get("Location")).toBe("/admin");
|
expect(deleteRes.headers.get("Location")).toBe("/admin?view=list");
|
||||||
|
|
||||||
// Verify feed was deleted
|
// Verify feed was deleted
|
||||||
const updatedFeedList = (await mockEnv.EMAIL_STORAGE.get(
|
const updatedFeedList = (await mockEnv.EMAIL_STORAGE.get(
|
||||||
@@ -235,6 +262,53 @@ describe("Admin Routes", () => {
|
|||||||
);
|
);
|
||||||
expect(feedConfig).toBeNull();
|
expect(feedConfig).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should allow bulk feed deletion with valid authentication", async () => {
|
||||||
|
const authCookie = await loginAndGetCookie();
|
||||||
|
|
||||||
|
for (const title of ["Feed A", "Feed B"]) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("title", title);
|
||||||
|
formData.append("description", "Test");
|
||||||
|
const createRes = await request("/admin/feeds/create", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Cookie: authCookie },
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
expect(createRes.status).toBe(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
const feedListBefore = (await mockEnv.EMAIL_STORAGE.get(
|
||||||
|
"feeds:list",
|
||||||
|
"json",
|
||||||
|
)) as {
|
||||||
|
feeds: Array<{ id: string; title: string }>;
|
||||||
|
} | null;
|
||||||
|
expect(feedListBefore?.feeds.length).toBe(2);
|
||||||
|
|
||||||
|
const bulkForm = new FormData();
|
||||||
|
for (const feed of feedListBefore?.feeds || []) {
|
||||||
|
bulkForm.append("feedIds", feed.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bulkDeleteRes = await request("/admin/feeds/bulk-delete", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Cookie: authCookie },
|
||||||
|
body: bulkForm,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(bulkDeleteRes.status).toBe(302);
|
||||||
|
expect(bulkDeleteRes.headers.get("Location")).toContain("/admin?view=list");
|
||||||
|
expect(bulkDeleteRes.headers.get("Location")).toContain("message=bulkDeleted");
|
||||||
|
|
||||||
|
const feedListAfter = (await mockEnv.EMAIL_STORAGE.get(
|
||||||
|
"feeds:list",
|
||||||
|
"json",
|
||||||
|
)) as {
|
||||||
|
feeds: Array<{ id: string; title: string }>;
|
||||||
|
} | null;
|
||||||
|
expect(feedListAfter?.feeds.length).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+1346
-366
File diff suppressed because it is too large
Load Diff
+94
-15
@@ -1,6 +1,6 @@
|
|||||||
import { Context } from 'hono';
|
import { Context } from "hono";
|
||||||
import { EmailParser } from '../utils/email-parser';
|
import { EmailParser } from "../utils/email-parser";
|
||||||
import { Env, FeedMetadata } from '../types';
|
import { Env, FeedConfig, FeedMetadata } from "../types";
|
||||||
|
|
||||||
// Interface for ForwardEmail.net webhook payload
|
// Interface for ForwardEmail.net webhook payload
|
||||||
interface ForwardEmailPayload {
|
interface ForwardEmailPayload {
|
||||||
@@ -21,6 +21,52 @@ interface ForwardEmailPayload {
|
|||||||
attachments?: Array<any>;
|
attachments?: Array<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeEmail(value: string): string {
|
||||||
|
return value.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractIncomingSenderAddresses(
|
||||||
|
payload: ForwardEmailPayload,
|
||||||
|
): string[] {
|
||||||
|
const valueEntries = payload.from?.value || [];
|
||||||
|
const structuredAddresses = valueEntries
|
||||||
|
.map((entry) => entry.address || "")
|
||||||
|
.map(normalizeEmail)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (structuredAddresses.length > 0) {
|
||||||
|
return Array.from(new Set(structuredAddresses));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback parser for plain text like "Name <sender@example.com>"
|
||||||
|
const fromText = payload.from?.text || "";
|
||||||
|
const matches =
|
||||||
|
fromText.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi) || [];
|
||||||
|
return Array.from(new Set(matches.map(normalizeEmail)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function senderMatchesAllowlist(
|
||||||
|
sender: string,
|
||||||
|
allowedSender: string,
|
||||||
|
): boolean {
|
||||||
|
const normalizedSender = normalizeEmail(sender);
|
||||||
|
const normalizedAllowed = normalizeEmail(allowedSender);
|
||||||
|
|
||||||
|
if (!normalizedAllowed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedAllowed.includes("@")) {
|
||||||
|
return normalizedSender === normalizedAllowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const senderDomain = normalizedSender.split("@")[1] || "";
|
||||||
|
const normalizedDomain = normalizedAllowed.startsWith("@")
|
||||||
|
? normalizedAllowed.slice(1)
|
||||||
|
: normalizedAllowed;
|
||||||
|
return senderDomain === normalizedDomain;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle incoming emails from ForwardEmail.net webhook
|
* Handle incoming emails from ForwardEmail.net webhook
|
||||||
*/
|
*/
|
||||||
@@ -35,27 +81,57 @@ export async function handle(c: Context): Promise<Response> {
|
|||||||
// Log basic information about the incoming email
|
// Log basic information about the incoming email
|
||||||
console.log("Received email:", {
|
console.log("Received email:", {
|
||||||
to: payload.recipients?.[0],
|
to: payload.recipients?.[0],
|
||||||
from: payload.from?.text || 'Unknown',
|
from: payload.from?.text || "Unknown",
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
contentType: payload.html ? 'HTML' : 'Text'
|
contentType: payload.html ? "HTML" : "Text",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Extract feed ID from email address (e.g., apple.mountain.42@domain.com -> apple.mountain.42)
|
// Extract feed ID from email address (e.g., apple.mountain.42@domain.com -> apple.mountain.42)
|
||||||
const toAddress = payload.recipients?.[0] || '';
|
const toAddress = payload.recipients?.[0] || "";
|
||||||
const feedId = EmailParser.extractFeedId(toAddress);
|
const feedId = EmailParser.extractFeedId(toAddress);
|
||||||
|
|
||||||
if (!feedId) {
|
if (!feedId) {
|
||||||
console.error(`Invalid email address format: ${toAddress}`);
|
console.error(`Invalid email address format: ${toAddress}`);
|
||||||
return new Response('Invalid email address format', { status: 400 });
|
return new Response("Invalid email address format", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the feed exists by looking up the feed configuration
|
// Check if the feed exists by looking up the feed configuration
|
||||||
const feedConfigKey = `feed:${feedId}:config`;
|
const feedConfigKey = `feed:${feedId}:config`;
|
||||||
const feedConfig = await env.EMAIL_STORAGE.get(feedConfigKey, 'json');
|
const feedConfig = (await env.EMAIL_STORAGE.get(
|
||||||
|
feedConfigKey,
|
||||||
|
"json",
|
||||||
|
)) as FeedConfig | null;
|
||||||
|
|
||||||
if (!feedConfig) {
|
if (!feedConfig) {
|
||||||
console.error(`Feed with ID ${feedId} does not exist or has been deleted`);
|
console.error(
|
||||||
return new Response('Feed does not exist', { status: 404 });
|
`Feed with ID ${feedId} does not exist or has been deleted`,
|
||||||
|
);
|
||||||
|
return new Response("Feed does not exist", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedSenders = (feedConfig.allowed_senders || [])
|
||||||
|
.map(normalizeEmail)
|
||||||
|
.filter(Boolean);
|
||||||
|
if (allowedSenders.length > 0) {
|
||||||
|
const incomingSenders = extractIncomingSenderAddresses(payload);
|
||||||
|
const senderAllowed = incomingSenders.some((sender) =>
|
||||||
|
allowedSenders.some((allowedSender) =>
|
||||||
|
senderMatchesAllowlist(sender, allowedSender),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!senderAllowed) {
|
||||||
|
console.warn(
|
||||||
|
`Rejected email for feed ${feedId}; sender not in allowlist`,
|
||||||
|
{
|
||||||
|
incomingSenders,
|
||||||
|
allowedSenders,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return new Response("Sender not allowed for this feed", {
|
||||||
|
status: 403,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the email using our simplified parser
|
// Parse the email using our simplified parser
|
||||||
@@ -69,22 +145,25 @@ export async function handle(c: Context): Promise<Response> {
|
|||||||
|
|
||||||
// Get existing feed metadata
|
// Get existing feed metadata
|
||||||
const feedMetadataKey = `feed:${feedId}:metadata`;
|
const feedMetadataKey = `feed:${feedId}:metadata`;
|
||||||
const feedMetadata = (await env.EMAIL_STORAGE.get(feedMetadataKey, 'json') || { emails: [] }) as FeedMetadata;
|
const feedMetadata = ((await env.EMAIL_STORAGE.get(
|
||||||
|
feedMetadataKey,
|
||||||
|
"json",
|
||||||
|
)) || { emails: [] }) as FeedMetadata;
|
||||||
|
|
||||||
// Add this email to the feed metadata
|
// Add this email to the feed metadata
|
||||||
feedMetadata.emails.unshift({
|
feedMetadata.emails.unshift({
|
||||||
key: emailKey,
|
key: emailKey,
|
||||||
subject: emailData.subject,
|
subject: emailData.subject,
|
||||||
receivedAt: emailData.receivedAt
|
receivedAt: emailData.receivedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store updated feed metadata
|
// Store updated feed metadata
|
||||||
await env.EMAIL_STORAGE.put(feedMetadataKey, JSON.stringify(feedMetadata));
|
await env.EMAIL_STORAGE.put(feedMetadataKey, JSON.stringify(feedMetadata));
|
||||||
|
|
||||||
console.log(`Successfully processed email for feed ${feedId}`);
|
console.log(`Successfully processed email for feed ${feedId}`);
|
||||||
return new Response('Email processed successfully', { status: 200 });
|
return new Response("Email processed successfully", { status: 200 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error processing email:', error);
|
console.error("Error processing email:", error);
|
||||||
return new Response('Error processing email', { status: 500 });
|
return new Response("Error processing email", { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+3
-43
@@ -1,43 +1,3 @@
|
|||||||
// Authentication helper functions
|
// Legacy export retained for compatibility.
|
||||||
// Handles user authentication state
|
// Authentication is now fully enforced server-side.
|
||||||
|
export const authHelpers = ``;
|
||||||
export const authHelpers = `
|
|
||||||
// Check if user is authenticated
|
|
||||||
function isAuthenticated() {
|
|
||||||
// Check localStorage first (client-side)
|
|
||||||
if (localStorage.getItem('authenticated') === 'true') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for cookie (server-side auth)
|
|
||||||
function getCookie(name) {
|
|
||||||
const value = \`; \${document.cookie}\`;
|
|
||||||
const parts = value.split(\`; \${name}=\`);
|
|
||||||
if (parts.length === 2) return parts.pop().split(';').shift();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return getCookie('admin_auth') === 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set authentication state
|
|
||||||
function setAuthenticated(value) {
|
|
||||||
localStorage.setItem('authenticated', value ? 'true' : 'false');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logout function
|
|
||||||
function logout() {
|
|
||||||
localStorage.removeItem('authenticated');
|
|
||||||
// Also clear the cookie by setting expiry in the past
|
|
||||||
document.cookie = 'admin_auth=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
|
|
||||||
window.location.href = '/admin/login';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check authentication on page load
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const path = window.location.pathname;
|
|
||||||
if (path !== '/admin/login' && !isAuthenticated()) {
|
|
||||||
window.location.href = '/admin/login';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
`;
|
|
||||||
|
|||||||
+10
-4
@@ -1,9 +1,9 @@
|
|||||||
// Main scripts exports file
|
// Main scripts exports file
|
||||||
// Combines and re-exports all JavaScript functionality
|
// Combines and re-exports all JavaScript functionality
|
||||||
|
|
||||||
import { modalScripts, emailViewScripts, initScripts } from './interactions';
|
import { modalScripts, emailViewScripts, initScripts } from "./interactions";
|
||||||
import { clipboardScripts } from './clipboard';
|
import { clipboardScripts } from "./clipboard";
|
||||||
import { authHelpers } from './auth';
|
import { authHelpers } from "./auth";
|
||||||
|
|
||||||
// Combine all scripts into a single JavaScript string
|
// Combine all scripts into a single JavaScript string
|
||||||
export const interactiveScripts = `
|
export const interactiveScripts = `
|
||||||
@@ -14,4 +14,10 @@ export const interactiveScripts = `
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
// Re-export for modular usage if needed
|
// Re-export for modular usage if needed
|
||||||
export { modalScripts, emailViewScripts, initScripts, clipboardScripts, authHelpers };
|
export {
|
||||||
|
modalScripts,
|
||||||
|
emailViewScripts,
|
||||||
|
initScripts,
|
||||||
|
clipboardScripts,
|
||||||
|
authHelpers,
|
||||||
|
};
|
||||||
|
|||||||
+180
-1
@@ -413,7 +413,7 @@ export const componentStyles = `
|
|||||||
|
|
||||||
.email-raw pre {
|
.email-raw pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: 'Menlo', monospace;
|
font-family: var(--font-family-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
@@ -483,6 +483,185 @@ export const componentStyles = `
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Toolbar + segmented control (Apple-ish) */
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-group {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-group-fill {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.search {
|
||||||
|
min-width: 280px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background-color: rgba(60, 60, 67, 0.12);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
backdrop-filter: blur(var(--blur-sm));
|
||||||
|
-webkit-backdrop-filter: blur(var(--blur-sm));
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented {
|
||||||
|
display: inline-flex;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background-color: rgba(60, 60, 67, 0.12);
|
||||||
|
backdrop-filter: blur(var(--blur-sm));
|
||||||
|
-webkit-backdrop-filter: blur(var(--blur-sm));
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
user-select: none;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-item:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-item.is-active {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background-color: rgba(255, 255, 255, 0.12);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
.segmented-item.is-active {
|
||||||
|
background-color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background-color: rgba(60, 60, 67, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
.table-wrap {
|
||||||
|
background-color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.table.table-feeds {
|
||||||
|
min-width: 860px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.table.table-emails {
|
||||||
|
min-width: 760px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.table th,
|
||||||
|
table.table td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.table thead th {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
background-color: rgba(44, 44, 46, 0.35);
|
||||||
|
backdrop-filter: blur(var(--blur-sm));
|
||||||
|
-webkit-backdrop-filter: blur(var(--blur-sm));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
table.table thead th {
|
||||||
|
background-color: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table.table tbody tr:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
table.table tbody tr:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table.table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.table code {
|
||||||
|
font-family: var(--font-family-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact copy-to-clipboard for table cells */
|
||||||
|
.copyable.copyable-inline {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyable.copyable-inline .copyable-content {
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyable.copyable-inline .copyable-value {
|
||||||
|
margin-right: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* Feed and Email Lists */
|
/* Feed and Email Lists */
|
||||||
.feed-list,
|
.feed-list,
|
||||||
.email-list {
|
.email-list {
|
||||||
|
|||||||
@@ -15,18 +15,22 @@ export const layoutStyles = `
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
|
||||||
/* Add subtle gradient background */
|
/* Liquid-glass-ish background (subtle, non-distracting) */
|
||||||
background-image: linear-gradient(135deg,
|
background-image:
|
||||||
rgba(0, 0, 0, 0.02) 0%,
|
radial-gradient(1200px circle at 20% 10%, rgba(10, 132, 255, 0.18), transparent 55%),
|
||||||
rgba(255, 255, 255, 0.02) 100%);
|
radial-gradient(900px circle at 80% 20%, rgba(94, 92, 230, 0.14), transparent 60%),
|
||||||
|
radial-gradient(700px circle at 50% 100%, rgba(48, 209, 88, 0.10), transparent 60%),
|
||||||
|
linear-gradient(135deg, rgba(255, 255, 255, 0.03) 0%, rgba(0, 0, 0, 0.03) 100%);
|
||||||
background-attachment: fixed;
|
background-attachment: fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Main Container */
|
/* Main Container */
|
||||||
.container {
|
.container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 800px;
|
max-width: 980px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: var(--spacing-xl);
|
padding: var(--spacing-xl);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
@@ -12,6 +12,21 @@ export const utilityStyles = `
|
|||||||
animation: fadeIn 0.5s ease-out;
|
animation: fadeIn 0.5s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.fade-in {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Copyable content styling */
|
/* Copyable content styling */
|
||||||
.copyable {
|
.copyable {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
|
|
||||||
export const variables = `
|
export const variables = `
|
||||||
:root {
|
:root {
|
||||||
/* Typography - Using Inter font */
|
/* Typography - Prefer system UI fonts (Apple HIG-ish) */
|
||||||
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif;
|
--font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--font-family-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
color-scheme: light dark;
|
||||||
--font-size-xs: 12px;
|
--font-size-xs: 12px;
|
||||||
--font-size-sm: 14px;
|
--font-size-sm: 14px;
|
||||||
--font-size-md: 16px;
|
--font-size-md: 16px;
|
||||||
@@ -103,6 +105,5 @@ export const lightModeTheme = `
|
|||||||
|
|
||||||
// Inter font import
|
// Inter font import
|
||||||
export const fontImport = `
|
export const fontImport = `
|
||||||
/* Inter Font Import */
|
/* No external font imports. */
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
|
||||||
`;
|
`;
|
||||||
+20
-6
@@ -18,6 +18,7 @@ export interface EmailData {
|
|||||||
export interface FeedConfig {
|
export interface FeedConfig {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
allowed_senders?: string[];
|
||||||
language: string;
|
language: string;
|
||||||
site_url: string;
|
site_url: string;
|
||||||
feed_url: string;
|
feed_url: string;
|
||||||
@@ -53,13 +54,26 @@ export interface FeedListItem {
|
|||||||
declare global {
|
declare global {
|
||||||
// This is not an ideal solution but works for our example
|
// This is not an ideal solution but works for our example
|
||||||
interface KVNamespace {
|
interface KVNamespace {
|
||||||
get(key: string, options?: { type: 'text' }): Promise<string | null>;
|
get(key: string, options?: { type: "text" }): Promise<string | null>;
|
||||||
get(key: string, options: { type: 'json' }): Promise<any | null>;
|
get(key: string, options: { type: "json" }): Promise<any | null>;
|
||||||
get(key: string, options: { type: 'arrayBuffer' }): Promise<ArrayBuffer | null>;
|
get(
|
||||||
get(key: string, options: { type: 'stream' }): Promise<ReadableStream | null>;
|
key: string,
|
||||||
put(key: string, value: string | ArrayBuffer | ReadableStream | FormData): Promise<void>;
|
options: { type: "arrayBuffer" },
|
||||||
|
): Promise<ArrayBuffer | null>;
|
||||||
|
get(
|
||||||
|
key: string,
|
||||||
|
options: { type: "stream" },
|
||||||
|
): Promise<ReadableStream | null>;
|
||||||
|
put(
|
||||||
|
key: string,
|
||||||
|
value: string | ArrayBuffer | ReadableStream | FormData,
|
||||||
|
): Promise<void>;
|
||||||
delete(key: string): Promise<void>;
|
delete(key: string): Promise<void>;
|
||||||
list(options?: { prefix?: string; limit?: number; cursor?: string }): Promise<{
|
list(options?: {
|
||||||
|
prefix?: string;
|
||||||
|
limit?: number;
|
||||||
|
cursor?: string;
|
||||||
|
}): Promise<{
|
||||||
keys: { name: string; expiration?: number }[];
|
keys: { name: string; expiration?: number }[];
|
||||||
list_complete: boolean;
|
list_complete: boolean;
|
||||||
cursor?: string;
|
cursor?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user