fix: UDP upload was completely broken — relay never received sessions or chunks

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 <noreply@anthropic.com>
This commit is contained in:
Zac Gaetano 2026-04-06 22:12:22 -04:00
parent 42fead82aa
commit efdaa48cb6
2 changed files with 73 additions and 4 deletions

View file

@ -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) => {

View file

@ -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);