From: Roberto Morado Date: Wed, 20 May 2026 20:11:18 +0000 (-0400) Subject: Initial commit X-Git-Url: https://git.morado.dev/sitemap.xml?a=commitdiff_plain;h=3477b948b4a72fb10c93f0fe212a0fae1ab35dbd;p=env.morado.dev Initial commit --- 3477b948b4a72fb10c93f0fe212a0fae1ab35dbd diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..545fbdb --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(awk 'NR>=836 && NR<=1230' /Users/roramigator/p2p/p2p.ts)", + "Bash(awk 'NR>=122 && NR<=165' /Users/roramigator/p2p/p2p.ts)" + ] + } +} diff --git a/p2p.ts b/p2p.ts new file mode 100644 index 0000000..e19c87b --- /dev/null +++ b/p2p.ts @@ -0,0 +1,1388 @@ +// ───────────────────────────────────────────── +// drop. — anonymous P2P chat & file share +// Single-file Deno server · WebRTC + SSE +// run: deno run --allow-net main.ts +// ───────────────────────────────────────────── + +const PORT = 8080; +const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour +const CHUNK_SIZE = 16 * 1024; // 16KB DataChannel chunks + +// ── Types ──────────────────────────────────── + +interface Peer { + id: string; + controller: ReadableStreamDefaultController; + lastSeen: number; +} + +interface Session { + id: string; + peers: Map; + createdAt: number; + lastActivity: number; +} + +// ── State ──────────────────────────────────── + +const sessions = new Map(); + +function touch(session: Session) { + session.lastActivity = Date.now(); +} + +function generateId(len = 6): string { + const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + let id = ""; + for (let i = 0; i < len; i++) { + id += chars[Math.floor(Math.random() * chars.length)]; + } + return id; +} + +function generateSessionId(): string { + let id: string; + do { id = generateId(4) + "-" + generateId(4); } + while (sessions.has(id)); + return id; +} + +function generatePeerId(): string { + return generateId(8); +} + +function createSession(): Session { + const id = generateSessionId(); + const session: Session = { + id, + peers: new Map(), + createdAt: Date.now(), + lastActivity: Date.now(), + }; + sessions.set(id, session); + return session; +} + +const enc = new TextEncoder(); + +function broadcast(session: Session, fromPeerId: string, event: string, data: unknown) { + const payload = enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + for (const [pid, peer] of session.peers) { + if (pid !== fromPeerId) { + try { + peer.controller.enqueue(payload); + peer.lastSeen = Date.now(); + } catch (_) { + // peer disconnected + } + } + } +} + +function sendToPeer(session: Session, toPeerId: string, event: string, data: unknown) { + const peer = session.peers.get(toPeerId); + if (!peer) return; + try { + peer.controller.enqueue(enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)); + peer.lastSeen = Date.now(); + } catch (_) { + // peer disconnected + } +} + +function sendToSelf(peer: Peer, event: string, data: unknown) { + try { + peer.controller.enqueue(enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)); + } catch (_) { /* ignore */ } +} + +function removePeer(session: Session, peerId: string) { + session.peers.delete(peerId); + touch(session); + if (session.peers.size === 0) { + sessions.delete(session.id); + } else { + broadcast(session, peerId, "peer-left", { peerId }); + } +} + +// Cleanup expired sessions every 10 minutes +setInterval(() => { + const now = Date.now(); + for (const [id, session] of sessions) { + if (now - session.lastActivity > SESSION_TTL_MS) { + for (const peer of session.peers.values()) { + try { peer.controller.close(); } catch (_) { /* ignore */ } + } + sessions.delete(id); + } + } +}, 10 * 60 * 1000); + +// ── Rate Limiter ───────────────────────────── + +const rlStore = new Map(); + +function rateLimit(ip: string, key: string, max: number, windowMs = 60_000): boolean { + const k = ip + ":" + key; + const now = Date.now(); + const e = rlStore.get(k); + if (!e || now > e.reset) { rlStore.set(k, { count: 1, reset: now + windowMs }); return true; } + if (e.count >= max) return false; + e.count++; + return true; +} + +setInterval(() => { + const now = Date.now(); + for (const [k, e] of rlStore) if (now > e.reset) rlStore.delete(k); +}, 5 * 60 * 1000); + +// ── HTTP Router ────────────────────────────── + +async function handler(req: Request, ip: string): Promise { + const url = new URL(req.url); + const path = url.pathname; + + // GET / → HTML app shell + if (req.method === "GET" && path === "/") { + return new Response(HTML, { + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + } + + // POST /session → create new session + if (req.method === "POST" && path === "/session") { + if (!rateLimit(ip, "session-create", 10)) return json({ error: "Too many requests" }, 429); + const session = createSession(); + return json({ sessionId: session.id }); + } + + // GET /session/:id/exists → check if session exists + if (req.method === "GET" && path.match(/^\/session\/[^/]+\/exists$/)) { + if (!rateLimit(ip, "session-check", 30)) return json({ error: "Too many requests" }, 429); + const sessionId = path.split("/")[2]; + return json({ exists: sessions.has(sessionId) }); + } + + // GET /connect/:sessionId → SSE stream, join session + if (req.method === "GET" && path.match(/^\/connect\/[^/]+$/)) { + if (!rateLimit(ip, "connect", 10)) return json({ error: "Too many requests" }, 429); + const sessionId = path.split("/")[2]; + const session = sessions.get(sessionId); + if (!session) { + return json({ error: "Session not found" }, 404); + } + + if (session.peers.size >= 2) { + return json({ error: "Session full" }, 403); + } + + const peerId = generatePeerId(); + const isHost = session.peers.size === 0; + + let controller!: ReadableStreamDefaultController; + let heartbeat: ReturnType; + const stream = new ReadableStream({ + start(c) { controller = c; }, + cancel() { clearInterval(heartbeat); removePeer(session!, peerId); }, + }); + + const peer: Peer = { id: peerId, controller, lastSeen: Date.now() }; + session.peers.set(peerId, peer); + touch(session); + + heartbeat = setInterval(() => { + try { peer.controller.enqueue(enc.encode("event: heartbeat\ndata: {}\n\n")); } + catch (_) { clearInterval(heartbeat); } + }, 30_000); + + // Send welcome + setTimeout(() => { + sendToSelf(peer, "welcome", { peerId, sessionId, isHost, chunkSize: CHUNK_SIZE }); + if (!isHost) { + broadcast(session!, peerId, "peer-joined", { peerId }); + } + }, 50); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + }); + } + + // POST /signal/:sessionId → WebRTC signaling relay + if (req.method === "POST" && path.match(/^\/signal\/[^/]+$/)) { + if (!rateLimit(ip, "signal", 200)) return json({ error: "Too many requests" }, 429); + const sessionId = path.split("/")[2]; + const session = sessions.get(sessionId); + if (!session) return json({ error: "Session not found" }, 404); + + const body = await req.json(); + const { fromPeerId, toPeerId, type, payload } = body; + touch(session); + + if (toPeerId) { + sendToPeer(session, toPeerId, "signal", { fromPeerId, type, payload }); + } else { + broadcast(session, fromPeerId, "signal", { fromPeerId, type, payload }); + } + + return json({ ok: true }); + } + + // POST /leave/:sessionId + if (req.method === "POST" && path.match(/^\/leave\/[^/]+$/)) { + const sessionId = path.split("/")[2]; + const session = sessions.get(sessionId); + if (session) { + const body = await req.json(); + removePeer(session, body.peerId); + } + return json({ ok: true }); + } + + return new Response("Not found", { status: 404 }); +} + +function json(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +// ── Start ──────────────────────────────────── + +console.log(`drop. running → http://localhost:${PORT}`); +Deno.serve({ port: PORT }, (req, info) => handler(req, (info.remoteAddr as Deno.NetAddr).hostname)); + +// ── HTML / CSS / JS ────────────────────────── + +const HTML = ` + + + + +drop. + + + + + +
+
drop.
+
anonymous · ephemeral · peer-to-peer
+
+ +
or join
+
+
+ + +
+ +
+ + +
+
+
+
your session code
+
+
click to copy
+
+
+ share the code — waiting for the other peer +
+ +
+ + +
+
drop files to send
+
+
drop.
+
+
+
+ +
+
+
+
+ sending… +
+ 0% + +
+
+
+ +
+ + + +
+
+
+
+ + +
+
:(
+
something went wrong
+ +
+ +
+ + + +`;