From efdaa48cb64b751b79808fb5e814fb437c230280 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Mon, 6 Apr 2026 22:12:22 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20UDP=20upload=20was=20completely=20broken?= =?UTF-8?q?=20=E2=80=94=20relay=20never=20received=20sessions=20or=20chunk?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: three critical bugs in the UDP upload flow: 1. Main server never registered sessions on the relay — it stored them in its own memory but never called POST /session on the relay, so the relay had no idea about any upload sessions. 2. Relay had no HTTP chunk endpoint — the Chrome extension sends chunks via HTTP POST to /session/:id/chunk/:index, but the relay only had a binary UDP listener. Added the HTTP fallback endpoint. 3. Relay had no CORS headers — browser requests from chrome-extension:// origins were blocked. Added CORS middleware. The flow now works: Browser → POST /api/udp/session (main server) Main server → POST /session (relay, with s3Config) Browser → POST /session/:id/chunk/:n (relay, via public URL) Relay → S3 multipart upload Browser → POST /api/udp/session/:id/complete (main server) Co-Authored-By: Claude Sonnet 4.6 --- server.js | 34 ++++++++++++++++++++++++++++++---- udp-relay/server.js | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/server.js b/server.js index f8a1c31..e8940c6 100644 --- a/server.js +++ b/server.js @@ -587,25 +587,51 @@ app.post("/api/presigned", requireAuth, async (req, res) => { // Sessions stored in memory; relay handles actual transfer const udpSessions = new Map(); -app.post("/api/udp/session", requireAuth, (req, res) => { +app.post("/api/udp/session", requireAuth, async (req, res) => { const { filename, size, prefix } = req.body; if (!filename || !size) return res.status(400).json({ success: false, error: "filename and size required" }); const relayUrl = db.relayConfig?.relayUrl || ""; const udpPort = db.relayConfig?.udpPort || 5000; if (!relayUrl) return res.status(503).json({ success: false, error: "UDP relay not configured. Go to Admin → Relay Settings." }); - // Public URL is what the browser connects to — falls back to internal if not set const publicRelayUrl = (db.relayConfig?.publicRelayUrl || "").trim() || relayUrl; const sessionId = crypto.randomBytes(16).toString("hex"); const key = (prefix ? `${prefix.replace(/[-\/]+$/, "")}--${filename}` : filename); + const s3Cfg = db.s3Config || {}; + + // Register session on the relay server so it can accept chunks and upload to S3 + try { + const relayResp = await fetch(`${relayUrl}/session`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionId, filename, size, key, + bucket: s3Cfg.bucket || "", + s3Config: { + endpoint: s3Cfg.endpoint || "", + region: s3Cfg.region || "us-east-1", + accessKeyId: s3Cfg.accessKeyId || "", + secretAccessKey: s3Cfg.secretAccessKey || "", + }, + }), + signal: AbortSignal.timeout(5000), + }); + if (!relayResp.ok) { + const err = await relayResp.json().catch(() => ({})); + return res.status(502).json({ success: false, error: `Relay error: ${err.error || relayResp.status}` }); + } + } catch (err) { + console.error("[UDP] Failed to register session on relay:", err.message); + return res.status(502).json({ success: false, error: `Cannot reach relay: ${err.message}` }); + } + udpSessions.set(sessionId, { sessionId, filename, size, key, status: "pending", createdAt: Date.now(), updatedAt: Date.now(), }); - // Cleanup after 2 hours setTimeout(() => udpSessions.delete(sessionId), 2 * 60 * 60 * 1000); - res.json({ success: true, sessionId, relayUrl: publicRelayUrl, udpPort, key, s3Bucket: db.s3Config?.bucket || "" }); + res.json({ success: true, sessionId, relayUrl: publicRelayUrl, udpPort, key, s3Bucket: s3Cfg.bucket || "" }); }); app.get("/api/udp/session/:id", requireAuth, (req, res) => { diff --git a/udp-relay/server.js b/udp-relay/server.js index 43b4938..dafecb6 100644 --- a/udp-relay/server.js +++ b/udp-relay/server.js @@ -235,7 +235,18 @@ udpServer.bind(UDP_PORT, "0.0.0.0", () => { // ==================== HTTP CONTROL API ==================== const app = express(); + +// CORS — browsers (Chrome extension) send chunks via HTTP fallback +app.use((req, res, next) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization"); + if (req.method === "OPTIONS") return res.sendStatus(204); + next(); +}); + app.use(express.json()); +app.use(express.raw({ type: "application/octet-stream", limit: "100mb" })); // Health check app.get("/health", (req, res) => { @@ -266,6 +277,38 @@ app.post("/session", (req, res) => { res.json({ success: true, sessionId, udpPort: UDP_PORT, chunkSize: CHUNK_SIZE, expectedChunks: session.expectedChunks }); }); +// HTTP chunk fallback — Chrome extensions can't send raw UDP, so they POST chunks over HTTP +app.post("/session/:id/chunk/:index", async (req, res) => { + const session = sessions.get(req.params.id); + if (!session) return res.status(404).json({ error: "Session not found" }); + const chunkIndex = parseInt(req.params.index); + if (isNaN(chunkIndex)) return res.status(400).json({ error: "Invalid chunk index" }); + + try { + // Initialize multipart upload on first chunk + if (session.status === "pending") { + await session.initMultipart(); + } + await session.receiveChunk(chunkIndex, req.body); + + // Auto-complete when all chunks received + if (session.chunks.size === session.expectedChunks && session.status === "receiving") { + await session.complete(); + } + + res.json({ + success: true, + chunkIndex, + receivedChunks: session.chunks.size, + expectedChunks: session.expectedChunks, + percent: session.progress().percent, + }); + } catch (err) { + console.error(`[HTTP] Chunk ${chunkIndex} error for ${req.params.id.slice(0, 8)}:`, err.message); + res.status(500).json({ error: err.message }); + } +}); + // Get session progress app.get("/session/:id", (req, res) => { const session = sessions.get(req.params.id);