// ============================================================ // CONFIGURATION // ============================================================ const INVITE_CODE = "YOUR_INVITE_CODE_HERE"; // optional gate, set to "" to disable // ============================================================ const CORS = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization", }; export default { async fetch(request, env) { const url = new URL(request.url); if (request.method === "OPTIONS") return new Response(null, { headers: CORS }); // ── PUBLIC ROUTES (no auth) ────────────────────────── if (url.pathname === "/register" && request.method === "POST") { const { username, password } = await request.json(); if (!username || !password) return json({ error: "Missing fields" }, 400); if (!/^[a-zA-Z0-9_\-.]{2,24}$/.test(username)) return json({ error: "Invalid username" }, 400); if (password.length < 4) return json({ error: "Password too short" }, 400); const existing = await env.CHAT_KV.get("user:" + username.toLowerCase()); if (existing) return json({ error: "Username taken" }, 409); const hash = await sha256(password); await env.CHAT_KV.put("user:" + username.toLowerCase(), JSON.stringify({ username, hash })); const token = await makeSession(env, username); return json({ token, username }); } if (url.pathname === "/login" && request.method === "POST") { const { username, password } = await request.json(); if (!username || !password) return json({ error: "Missing fields" }, 400); const raw = await env.CHAT_KV.get("user:" + username.toLowerCase()); if (!raw) return json({ error: "Invalid username or password" }, 401); const user = JSON.parse(raw); const hash = await sha256(password); if (hash !== user.hash) return json({ error: "Invalid username or password" }, 401); const token = await makeSession(env, user.username); return json({ token, username: user.username }); } // ── AUTH REQUIRED ROUTES ───────────────────────────── const authUser = await authenticate(request, env); if (!authUser) return json({ error: "Unauthorized" }, 401); // Users list if (url.pathname === "/users" && request.method === "GET") { const presenceKeys = await env.CHAT_KV.list({ prefix: "presence:" }); const now = Date.now(); const online = new Set(); for (const key of presenceKeys.keys) { const raw = await env.CHAT_KV.get(key.name); if (!raw) continue; const { ts } = JSON.parse(raw); if (now - ts < 45000) online.add(key.name.replace("presence:", "")); } const userKeys = await env.CHAT_KV.list({ prefix: "user:" }); const users = []; for (const key of userKeys.keys) { const raw = await env.CHAT_KV.get(key.name); if (!raw) continue; const u = JSON.parse(raw); users.push({ username: u.username, online: online.has(u.username) }); } return json(users); } // Ping — lightweight timestamp so clients know when to refetch if (url.pathname === "/ping" && request.method === "GET") { const raw = await env.CHAT_KV.get("ping"); return json({ ts: raw ? JSON.parse(raw).ts : 0 }); } if (url.pathname === "/ping" && request.method === "POST") { await env.CHAT_KV.put("ping", JSON.stringify({ ts: Date.now() })); return json({ ok: true }); } // Heartbeat if (url.pathname === "/heartbeat" && request.method === "POST") { await env.CHAT_KV.put("presence:" + authUser, JSON.stringify({ ts: Date.now() }), { expirationTtl: 60 }); return json({ ok: true }); } // Group messages if (url.pathname === "/messages" && request.method === "GET") { return json(await getGroupMessages(env)); } if (url.pathname === "/messages" && request.method === "POST") { const { text } = await request.json(); if (!text || !text.trim()) return json({ error: "Empty message" }, 400); const messages = await getGroupMessages(env); const msg = { id: crypto.randomUUID(), username: authUser, text: text.trim(), ts: Date.now(), edited: false }; messages.push(msg); await env.CHAT_KV.put("messages", JSON.stringify(messages)); return json({ ok: true, id: msg.id }); } // Edit group message if (url.pathname.startsWith("/messages/") && request.method === "PUT") { const id = url.pathname.split("/")[2]; const { text } = await request.json(); if (!text || !text.trim()) return json({ error: "Empty message" }, 400); const messages = await getGroupMessages(env); const idx = messages.findIndex(m => m.id === id); if (idx === -1) return json({ error: "Not found" }, 404); if (messages[idx].username !== authUser) return json({ error: "Forbidden" }, 403); messages[idx].text = text.trim(); messages[idx].edited = true; await env.CHAT_KV.put("messages", JSON.stringify(messages)); return json({ ok: true }); } // Delete group message if (url.pathname.startsWith("/messages/") && request.method === "DELETE") { const id = url.pathname.split("/")[2]; const messages = await getGroupMessages(env); const idx = messages.findIndex(m => m.id === id); if (idx === -1) return json({ error: "Not found" }, 404); if (messages[idx].username !== authUser) return json({ error: "Forbidden" }, 403); messages.splice(idx, 1); await env.CHAT_KV.put("messages", JSON.stringify(messages)); return json({ ok: true }); } // DMs — get thread if (url.pathname.startsWith("/dm/") && request.method === "GET") { const other = url.pathname.split("/")[2]; const thread = await getDMThread(env, authUser, other); // mark as read await env.CHAT_KV.put("dmread:" + authUser + ":" + other, JSON.stringify({ ts: Date.now() })); return json(thread); } // DMs — send if (url.pathname.startsWith("/dm/") && request.method === "POST") { const other = url.pathname.split("/")[2]; const { text } = await request.json(); if (!text || !text.trim()) return json({ error: "Empty message" }, 400); const thread = await getDMThread(env, authUser, other); const msg = { id: crypto.randomUUID(), from: authUser, text: text.trim(), ts: Date.now(), edited: false }; thread.push(msg); const key = dmKey(authUser, other); await env.CHAT_KV.put(key, JSON.stringify(thread)); return json({ ok: true, id: msg.id }); } // DMs — edit if (url.pathname.startsWith("/dm/") && request.method === "PUT") { const parts = url.pathname.split("/"); const other = parts[2]; const id = parts[3]; const { text } = await request.json(); if (!text || !text.trim()) return json({ error: "Empty" }, 400); const thread = await getDMThread(env, authUser, other); const idx = thread.findIndex(m => m.id === id); if (idx === -1) return json({ error: "Not found" }, 404); if (thread[idx].from !== authUser) return json({ error: "Forbidden" }, 403); thread[idx].text = text.trim(); thread[idx].edited = true; await env.CHAT_KV.put(dmKey(authUser, other), JSON.stringify(thread)); return json({ ok: true }); } // DMs — delete if (url.pathname.startsWith("/dm/") && request.method === "DELETE") { const parts = url.pathname.split("/"); const other = parts[2]; const id = parts[3]; const thread = await getDMThread(env, authUser, other); const idx = thread.findIndex(m => m.id === id); if (idx === -1) return json({ error: "Not found" }, 404); if (thread[idx].from !== authUser) return json({ error: "Forbidden" }, 403); thread.splice(idx, 1); await env.CHAT_KV.put(dmKey(authUser, other), JSON.stringify(thread)); return json({ ok: true }); } // Unread DM counts if (url.pathname === "/unread" && request.method === "GET") { const userKeys = await env.CHAT_KV.list({ prefix: "user:" }); const unread = []; for (const key of userKeys.keys) { const raw = await env.CHAT_KV.get(key.name); if (!raw) continue; const u = JSON.parse(raw); if (u.username === authUser) continue; const thread = await getDMThread(env, authUser, u.username); if (thread.length === 0) continue; const readRaw = await env.CHAT_KV.get("dmread:" + authUser + ":" + u.username); const readTs = readRaw ? JSON.parse(readRaw).ts : 0; const count = thread.filter(m => m.from !== authUser && m.ts > readTs).length; if (count > 0) unread.push({ from: u.username, count }); } return json(unread); } return new Response("Not found", { status: 404 }); }, }; // ── HELPERS ────────────────────────────────────────────────── async function sha256(str) { const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(str)); return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join(""); } async function makeSession(env, username) { const token = crypto.randomUUID() + crypto.randomUUID(); await env.CHAT_KV.put("session:" + token, JSON.stringify({ username }), { expirationTtl: 60 * 60 * 24 * 30 }); return token; } async function authenticate(request, env) { const auth = request.headers.get("Authorization"); if (!auth || !auth.startsWith("Bearer ")) return null; const token = auth.slice(7); const raw = await env.CHAT_KV.get("session:" + token); if (!raw) return null; return JSON.parse(raw).username; } async function getGroupMessages(env) { const raw = await env.CHAT_KV.get("messages"); if (!raw) return []; const all = JSON.parse(raw); const cutoff = Date.now() - 24 * 60 * 60 * 1000; return all.filter(m => m.ts > cutoff); } function dmKey(a, b) { return "dm:" + [a.toLowerCase(), b.toLowerCase()].sort().join(":"); } async function getDMThread(env, a, b) { const raw = await env.CHAT_KV.get(dmKey(a, b)); if (!raw) return []; const all = JSON.parse(raw); const cutoff = Date.now() - 24 * 60 * 60 * 1000; return all.filter(m => m.ts > cutoff); } function json(data, status = 200) { return new Response(JSON.stringify(data), { status, headers: { ...CORS, "Content-Type": "application/json" }, }); }