// Cloudflare Pages Function — Kevin Hub Source Access // File contents stored directly in D1 (text-only, no R2). const COOKIE = "session"; const SESSION_DAYS = 365 * 5; const MAX_FILE_SIZE = 500 * 1024; // 500 KB per file function json(data, init = {}) { return new Response(JSON.stringify(data), { ...init, headers: { "Content-Type": "application/json", ...(init.headers || {}) } }); } function bad(msg, code = 400) { return json({ error: msg }, { status: code }); } function uuid() { return crypto.randomUUID(); } async function hashPassword(password, salt) { const enc = new TextEncoder(); const buf = await crypto.subtle.digest("SHA-256", enc.encode(salt + ":" + password)); return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, "0")).join(""); } function newSalt() { return [...crypto.getRandomValues(new Uint8Array(16))] .map(b => b.toString(16).padStart(2, "0")).join(""); } function parseCookies(header) { const out = {}; (header || "").split(";").forEach(p => { const [k, ...v] = p.trim().split("="); if (k) out[k] = decodeURIComponent(v.join("=")); }); return out; } async function getSession(env, request) { const token = parseCookies(request.headers.get("Cookie"))[COOKIE]; if (!token) return { session: null, blacklisted: null }; const row = await env.DB.prepare( "SELECT s.token, s.expires_at, u.* FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ?" ).bind(token).first(); if (!row) return { session: null, blacklisted: null }; if (row.expires_at < Date.now()) { await env.DB.prepare("DELETE FROM sessions WHERE token = ?").bind(token).run(); return { session: null, blacklisted: null }; } if (row.blacklisted) { await env.DB.prepare("DELETE FROM sessions WHERE user_id = ?").bind(row.id).run(); return { session: null, blacklisted: { reason: row.blacklist_reason || "No reason provided." } }; } return { session: row, blacklisted: null }; } function setCookie(token) { return `${COOKIE}=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${SESSION_DAYS * 86400}`; } function clearCookie() { return `${COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`; } function userOut(u) { return { id: u.id, username: u.username, is_admin: !!u.is_admin, whitelisted: !!u.whitelisted, blacklisted: !!u.blacklisted }; } export async function onRequest({ request, env, params }) { const url = new URL(request.url); const path = "/" + (params.path || []).join("/"); const method = request.method; try { // ================= AUTH ================= if (path === "/signup" && method === "POST") { const { username, password } = await request.json(); if (!username || !password) return bad("Missing fields."); if (username.length < 3 || username.length > 32) return bad("Username 3-32 chars."); if (password.length < 4) return bad("Password too short."); const existing = await env.DB.prepare( "SELECT id, blacklisted, blacklist_reason FROM users WHERE username = ?" ).bind(username).first(); if (existing) { if (existing.blacklisted) { return json({ error: "blacklisted", blacklisted: true, reason: existing.blacklist_reason || "No reason provided." }, { status: 403 }); } return bad("Username taken."); } const id = uuid(); const salt = newSalt(); const hash = await hashPassword(password, salt); const now = Date.now(); await env.DB.prepare( "INSERT INTO users(id, username, password_hash, salt, is_admin, whitelisted, blacklisted, created_at) VALUES(?,?,?,?,0,0,0,?)" ).bind(id, username, hash, salt, now).run(); const token = uuid() + uuid(); const expires = now + SESSION_DAYS * 86400000; await env.DB.prepare( "INSERT INTO sessions(token, user_id, expires_at, created_at) VALUES(?,?,?,?)" ).bind(token, id, expires, now).run(); return new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json", "Set-Cookie": setCookie(token) } }); } if (path === "/login" && method === "POST") { const { username, password } = await request.json(); if (!username || !password) return bad("Missing fields."); const u = await env.DB.prepare("SELECT * FROM users WHERE username = ?").bind(username).first(); if (!u) return bad("Invalid credentials.", 401); if (u.blacklisted) { return json({ error: "blacklisted", blacklisted: true, reason: u.blacklist_reason || "No reason provided." }, { status: 403 }); } const hash = await hashPassword(password, u.salt); if (hash !== u.password_hash) return bad("Invalid credentials.", 401); const token = uuid() + uuid(); const now = Date.now(); const expires = now + SESSION_DAYS * 86400000; await env.DB.prepare( "INSERT INTO sessions(token, user_id, expires_at, created_at) VALUES(?,?,?,?)" ).bind(token, u.id, expires, now).run(); return new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json", "Set-Cookie": setCookie(token) } }); } if (path === "/logout" && method === "POST") { const token = parseCookies(request.headers.get("Cookie"))[COOKIE]; if (token) await env.DB.prepare("DELETE FROM sessions WHERE token = ?").bind(token).run(); return new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json", "Set-Cookie": clearCookie() } }); } if (path === "/me" && method === "GET") { const { session, blacklisted } = await getSession(env, request); if (blacklisted) { return json({ user: null, blacklisted: true, blacklist_reason: blacklisted.reason }); } return json({ user: session ? userOut(session) : null, blacklisted: false }); } // ================= SESSION ================= const { session: s, blacklisted: bl } = await getSession(env, request); if (bl) return json({ blacklisted: true, reason: bl.reason }, { status: 403 }); const isAllowed = s && (s.whitelisted || s.is_admin); // ================= FILES ================= if (path === "/files" && method === "GET") { if (!isAllowed) return bad("Not whitelisted.", 403); const q = url.searchParams.get("q"); const sql = q ? "SELECT id, name, size, uploaded_at FROM files WHERE name LIKE ? ORDER BY uploaded_at DESC LIMIT 500" : "SELECT id, name, size, uploaded_at FROM files ORDER BY uploaded_at DESC LIMIT 500"; const stmt = env.DB.prepare(sql); const { results } = await (q ? stmt.bind(`%${q}%`) : stmt).all(); return json({ files: results }); } if (path.startsWith("/file/") && method === "GET") { if (!isAllowed) return bad("Not whitelisted.", 403); const id = path.split("/").pop(); const row = await env.DB.prepare( "SELECT content FROM files WHERE id = ?" ).bind(id).first(); if (!row) return bad("Not found.", 404); return new Response(row.content, { headers: { "Content-Type": "text/plain; charset=utf-8" } }); } if (path.startsWith("/download/") && method === "GET") { if (!isAllowed) return bad("Not whitelisted.", 403); const id = path.split("/").pop(); const row = await env.DB.prepare( "SELECT name, content FROM files WHERE id = ?" ).bind(id).first(); if (!row) return bad("Not found.", 404); return new Response(row.content, { headers: { "Content-Type": "text/plain; charset=utf-8", "Content-Disposition": `attachment; filename="${row.name}"` } }); } // ================= ADMIN ================= if (path.startsWith("/admin/")) { if (!s || !s.is_admin) return bad("Forbidden.", 403); if (path === "/admin/users" && method === "GET") { const { results } = await env.DB.prepare( "SELECT id, username, is_admin, whitelisted, blacklisted, blacklist_reason, created_at FROM users ORDER BY created_at DESC" ).all(); return json({ users: results }); } if (path.startsWith("/admin/whitelist/") && method === "POST") { const id = path.split("/").pop(); const { whitelisted } = await request.json(); await env.DB.prepare("UPDATE users SET whitelisted = ? WHERE id = ?") .bind(whitelisted ? 1 : 0, id).run(); return json({ ok: true }); } if (path.startsWith("/admin/blacklist/") && method === "POST") { const id = path.split("/").pop(); if (id === s.id) return bad("Can't blacklist yourself."); const { blacklisted, reason } = await request.json(); await env.DB.prepare( "UPDATE users SET blacklisted = ?, blacklist_reason = ? WHERE id = ?" ).bind( blacklisted ? 1 : 0, blacklisted ? (reason || "No reason provided.") : null, id ).run(); if (blacklisted) { await env.DB.prepare("DELETE FROM sessions WHERE user_id = ?").bind(id).run(); } return json({ ok: true }); } if (path.startsWith("/admin/users/") && method === "DELETE") { const id = path.split("/").pop(); if (id === s.id) return bad("Can't delete yourself."); await env.DB.prepare("DELETE FROM users WHERE id = ?").bind(id).run(); await env.DB.prepare("DELETE FROM sessions WHERE user_id = ?").bind(id).run(); return json({ ok: true }); } if (path === "/admin/files" && method === "GET") { const { results } = await env.DB.prepare( "SELECT id, name, size, uploaded_at FROM files ORDER BY uploaded_at DESC LIMIT 1000" ).all(); return json({ files: results }); } if (path.startsWith("/admin/files/") && method === "DELETE") { const id = path.split("/").pop(); await env.DB.prepare("DELETE FROM files WHERE id = ?").bind(id).run(); return json({ ok: true }); } if (path === "/admin/upload" && method === "POST") { const form = await request.formData(); const entries = form.getAll("files"); if (!entries.length) return bad("No files."); let count = 0; const now = Date.now(); const skipped = []; for (const entry of entries) { if (typeof entry === "string") continue; const name = entry.name || "file"; const buf = await entry.arrayBuffer(); if (buf.byteLength > MAX_FILE_SIZE) { skipped.push(`${name} (too large)`); continue; } // Reject obvious binaries (null byte check) const u8 = new Uint8Array(buf); let isBinary = false; for (let i = 0; i < Math.min(u8.length, 8000); i++) { if (u8[i] === 0) { isBinary = true; break; } } if (isBinary) { skipped.push(`${name} (binary not supported)`); continue; } const content = new TextDecoder("utf-8").decode(u8); const id = uuid(); await env.DB.prepare( "INSERT INTO files(id, name, content, size, uploaded_at) VALUES(?,?,?,?,?)" ).bind(id, name, content, buf.byteLength, now + count).run(); count++; } return json({ ok: true, count, skipped }); } } return new Response("kevin hub api", { status: 200 }); } catch (e) { return bad("Server error: " + e.message, 500); } }