// PAS Support Worker // // Public: POST /submit - anonymous feedback // GET /content/:key - homepage | announcements | meeting // Admin: GET /submissions - feedback list (Bearer token) // PUT /content/:key - update site content (Bearer token) // // Secret required: SESSION_SECRET (same value as the auth worker) // npx wrangler secret put SESSION_SECRET // Optional var: ALLOWED_ORIGIN const enc = new TextEncoder(); const dec = new TextDecoder(); const CONTENT_KEYS = new Set(["homepage", "announcements", "meeting"]); const MAX_MESSAGE_LENGTH = 2000; const MAX_CONTENT_BYTES = 20000; function fromB64u(str) { const b64 = str.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(str.length / 4) * 4, "="); return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); } async function isAdmin(request, env) { try { if (!env.SESSION_SECRET) return false; const token = (request.headers.get("Authorization") || "").replace(/^Bearer\s+/i, ""); const [body, sig] = token.split("."); if (!body || !sig) return false; const key = await crypto.subtle.importKey( "raw", enc.encode(env.SESSION_SECRET), { name: "HMAC", hash: "SHA-256" }, false, ["verify"] ); const ok = await crypto.subtle.verify("HMAC", key, fromB64u(sig), enc.encode(body)); if (!ok) return false; const payload = JSON.parse(dec.decode(fromB64u(body))); return payload.sub === "admin" && payload.exp > Date.now() / 1000; } catch { return false; } } function cors(env) { return { "Access-Control-Allow-Origin": env.ALLOWED_ORIGIN || "*", "Access-Control-Allow-Methods": "GET, POST, PUT, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization", "Vary": "Origin" }; } function json(env, body, status = 200, extra = {}) { return new Response(JSON.stringify(body), { status, headers: { ...cors(env), "Content-Type": "application/json", ...extra } }); } let tableReady = false; async function ensureContentTable(env) { if (tableReady) return; await env.pas_support .prepare("CREATE TABLE IF NOT EXISTS site_content (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT DEFAULT CURRENT_TIMESTAMP)") .run(); tableReady = true; } // Only allow plain objects of short strings (and http/https links) function sanitizeContent(value) { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const clean = Array.isArray(value) ? [] : {}; for (const [k, v] of Object.entries(value)) { if (typeof v === "string") { if (/link$/i.test(k) && v && !/^https?:\/\//i.test(v)) return null; // blocks javascript: URLs clean[k] = v.slice(0, 2000); } else if (v && typeof v === "object" && !Array.isArray(v)) { const inner = sanitizeContent(v); if (!inner) return null; clean[k] = inner; } else { return null; } } return clean; } export default { async fetch(request, env) { if (request.method === "OPTIONS") { return new Response(null, { status: 204, headers: cors(env) }); } const url = new URL(request.url); // ---------- anonymous feedback ---------- if (request.method === "POST" && url.pathname === "/submit") { try { const data = await request.json(); const message = String(data.message || "").trim(); if (!message) { return json(env, { success: false, message: "Message is required" }, 400); } if (message.length > MAX_MESSAGE_LENGTH) { return json(env, { success: false, message: "Message too long" }, 413); } await env.pas_support .prepare("INSERT INTO feedback (message) VALUES (?)") .bind(message) .run(); return json(env, { success: true }); } catch { return json(env, { success: false, message: "Failed to save feedback" }, 500); } } // ---------- admin: read feedback ---------- if (request.method === "GET" && url.pathname === "/submissions") { if (!(await isAdmin(request, env))) { return json(env, { success: false, message: "Unauthorized" }, 401); } try { const result = await env.pas_support .prepare("SELECT id, message, created_at FROM feedback ORDER BY id DESC LIMIT 50") .all(); return json(env, { success: true, submissions: result.results }, 200, { "Cache-Control": "no-store" }); } catch { return json(env, { success: false, message: "Failed to load submissions" }, 500); } } // ---------- site content (public read, admin write) ---------- const contentMatch = url.pathname.match(/^\/content\/([a-z]+)$/); if (contentMatch) { const key = contentMatch[1]; if (!CONTENT_KEYS.has(key)) { return json(env, { success: false, message: "Unknown content key" }, 404); } if (request.method === "GET") { try { await ensureContentTable(env); const row = await env.pas_support .prepare("SELECT value FROM site_content WHERE key = ?") .bind(key) .first(); return json(env, { success: true, content: row ? JSON.parse(row.value) : null }, 200, { "Cache-Control": "public, max-age=30" }); } catch { return json(env, { success: false, message: "Failed to load content" }, 500); } } if (request.method === "PUT") { if (!(await isAdmin(request, env))) { return json(env, { success: false, message: "Unauthorized" }, 401); } try { const raw = await request.text(); if (raw.length > MAX_CONTENT_BYTES) { return json(env, { success: false, message: "Content too large" }, 413); } const clean = sanitizeContent(JSON.parse(raw)); if (!clean) { return json(env, { success: false, message: "Invalid content" }, 400); } await ensureContentTable(env); await env.pas_support .prepare("INSERT INTO site_content (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP") .bind(key, JSON.stringify(clean)) .run(); return json(env, { success: true }); } catch { return json(env, { success: false, message: "Failed to save content" }, 500); } } } return new Response("PAS Support Worker Online", { status: 200, headers: cors(env) }); } };