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:
parent
42fead82aa
commit
efdaa48cb6
2 changed files with 73 additions and 4 deletions
34
server.js
34
server.js
|
|
@ -587,25 +587,51 @@ app.post("/api/presigned", requireAuth, async (req, res) => {
|
||||||
// Sessions stored in memory; relay handles actual transfer
|
// Sessions stored in memory; relay handles actual transfer
|
||||||
const udpSessions = new Map();
|
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;
|
const { filename, size, prefix } = req.body;
|
||||||
if (!filename || !size) return res.status(400).json({ success: false, error: "filename and size required" });
|
if (!filename || !size) return res.status(400).json({ success: false, error: "filename and size required" });
|
||||||
const relayUrl = db.relayConfig?.relayUrl || "";
|
const relayUrl = db.relayConfig?.relayUrl || "";
|
||||||
const udpPort = db.relayConfig?.udpPort || 5000;
|
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." });
|
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 publicRelayUrl = (db.relayConfig?.publicRelayUrl || "").trim() || relayUrl;
|
||||||
const sessionId = crypto.randomBytes(16).toString("hex");
|
const sessionId = crypto.randomBytes(16).toString("hex");
|
||||||
const key = (prefix ? `${prefix.replace(/[-\/]+$/, "")}--${filename}` : filename);
|
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, {
|
udpSessions.set(sessionId, {
|
||||||
sessionId, filename, size, key,
|
sessionId, filename, size, key,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
});
|
});
|
||||||
// Cleanup after 2 hours
|
|
||||||
setTimeout(() => udpSessions.delete(sessionId), 2 * 60 * 60 * 1000);
|
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) => {
|
app.get("/api/udp/session/:id", requireAuth, (req, res) => {
|
||||||
|
|
|
||||||
|
|
@ -235,7 +235,18 @@ udpServer.bind(UDP_PORT, "0.0.0.0", () => {
|
||||||
|
|
||||||
// ==================== HTTP CONTROL API ====================
|
// ==================== HTTP CONTROL API ====================
|
||||||
const app = express();
|
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.json());
|
||||||
|
app.use(express.raw({ type: "application/octet-stream", limit: "100mb" }));
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get("/health", (req, res) => {
|
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 });
|
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
|
// Get session progress
|
||||||
app.get("/session/:id", (req, res) => {
|
app.get("/session/:id", (req, res) => {
|
||||||
const session = sessions.get(req.params.id);
|
const session = sessions.get(req.params.id);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue