/** * MBD CDN — Cloudflare Worker * * Endpoints: * POST / — upload file (enforces per-IP limits) * GET /stats — global stats (images / videos / gifs / totalSize) * GET /limits — today's usage for the requesting IP * * D1 tables required: * uploads (id, filename, path, content_type, file_type, size, upload_date) * upload_limits (ip TEXT, date TEXT, file_count INT DEFAULT 0, total_size INT DEFAULT 0, PRIMARY KEY (ip, date)) */ // ── LIMIT CONSTANTS ──────────────────────────────────────────────────────── const MAX_FILE_BYTES = 70 * 1024 * 1024; // 70 MB per file const MAX_DAY_BYTES = 300 * 1024 * 1024; // 300 MB per IP per day const MAX_DAY_FILES = 20; // 20 files per IP per day const CDN_BASE_URL = "https://cdn.madebydanny.uk"; export default { async fetch(request, env, ctx) { const url = new URL(request.url); const cors = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", }; const json = (data, status = 200, extra = {}) => new Response(JSON.stringify(data), { status, headers: { "Content-Type": "application/json", ...cors, ...extra }, }); // ── Sanity check bindings ─────────────────── if (!env.DB) { return json({ success: false, error: "DB binding not configured" }, 500); } if (!env.MY_BUCKET) { return json({ success: false, error: "MY_BUCKET binding not configured" }, 500); } // ── CORS preflight ────────────────────────────── if (request.method === "OPTIONS") { return new Response(null, { headers: cors }); } // ── GET /user-content/… — Serve file from R2 ─── if (request.method === "GET" && url.pathname.startsWith("/user-content/")) { const key = url.pathname.slice(1); // strip leading / try { const object = await env.MY_BUCKET.get(key); if (!object) { return new Response("Not found", { status: 404, headers: cors }); } const headers = new Headers(cors); object.writeHttpMetadata(headers); headers.set("Cache-Control", "public, max-age=31536000, immutable"); headers.set("ETag", object.httpEtag); return new Response(object.body, { headers }); } catch (e) { return new Response("Failed to fetch file", { status: 500, headers: cors }); } } // ── GET /stats ────────────────────────────────── if (request.method === "GET" && url.pathname === "/stats") { try { const stats = await getStatistics(env); return json( { success: true, stats }, 200, { "Cache-Control": "public, max-age=30, stale-while-revalidate=60" } ); } catch (e) { return json({ success: false, error: `Stats failed: ${e.message}` }, 500); } } // ── GET /limits ───────────────────────────────── if (request.method === "GET" && url.pathname === "/limits") { try { const ip = clientIp(request); const today = todayStr(); const row = await env.DB.prepare( `SELECT file_count, total_size FROM upload_limits WHERE ip = ? AND date = ?` ).bind(ip, today).first(); return json( { success: true, limits: { file_count: row?.file_count || 0, total_size: row?.total_size || 0, max_files: MAX_DAY_FILES, max_bytes: MAX_DAY_BYTES, max_file: MAX_FILE_BYTES, }, }, 200, { "Cache-Control": "private, max-age=10" } ); } catch (e) { return json({ success: false, error: `Limits check failed: ${e.message}` }, 500); } } // ── POST / — Upload ───────────────────────────── if (request.method === "POST") { const ip = clientIp(request); const today = todayStr(); const contentType = request.headers.get("Content-Type") || "application/octet-stream"; const contentLength = parseInt(request.headers.get("Content-Length") || "0"); // ── 1. Per-file size check (fast, no I/O) ──── if (contentLength > MAX_FILE_BYTES) { return json({ success: false, error: `File too large. Max ${MAX_FILE_BYTES / 1024 / 1024} MB per file.`, }, 413); } try { const now = new Date(); const dateStr = now.toISOString().split("T")[0]; const randomId = crypto.randomUUID(); const fileInfo = getFileInfo(contentType); const path = `user-content/${dateStr}/${randomId}${fileInfo.extension}`; // ── 2. Race: DB limit check vs R2 upload ──── const [usageResult, uploadResult] = await Promise.allSettled([ env.DB.prepare( `SELECT file_count, total_size FROM upload_limits WHERE ip = ? AND date = ?` ).bind(ip, today).first(), env.MY_BUCKET.put(path, request.body, { httpMetadata: { contentType } }), ]); // Bubble up R2 errors if (uploadResult.status === "rejected") { throw new Error(`R2 upload failed: ${uploadResult.reason?.message}`); } // obj.size is ground truth — fall back to Content-Length only if absent const actualSize = uploadResult.value?.size ?? contentLength; // ── 3. Enforce limits post-upload ─────────── const usage = usageResult.status === "fulfilled" ? usageResult.value : null; const usedFiles = usage?.file_count || 0; const usedBytes = usage?.total_size || 0; if (usedFiles >= MAX_DAY_FILES) { ctx.waitUntil(env.MY_BUCKET.delete(path)); return json({ success: false, error: `Daily file limit reached (${MAX_DAY_FILES} files/day). Resets at midnight UTC.`, }, 429); } if (usedBytes + actualSize > MAX_DAY_BYTES) { ctx.waitUntil(env.MY_BUCKET.delete(path)); const remaining = MAX_DAY_BYTES - usedBytes; return json({ success: false, error: `Daily storage limit reached. You have ${fmtBytes(remaining)} remaining today. Resets at midnight UTC.`, }, 429); } // ── 4. Batch both D1 writes in one round-trip ctx.waitUntil( env.DB.batch([ env.DB.prepare( `INSERT INTO uploads (id, filename, path, content_type, file_type, size, upload_date) VALUES (?, ?, ?, ?, ?, ?, ?)` ).bind( randomId, `${randomId}${fileInfo.extension}`, path, contentType, fileInfo.type, actualSize, now.toISOString() ), env.DB.prepare( `INSERT INTO upload_limits (ip, date, file_count, total_size) VALUES (?, ?, 1, ?) ON CONFLICT(ip, date) DO UPDATE SET file_count = file_count + 1, total_size = total_size + excluded.total_size` ).bind(ip, today, actualSize), ]) ); const fileUrl = `${CDN_BASE_URL}/${path}`; return json({ success: true, url: fileUrl, path, contentType, fileType: fileInfo.type, size: actualSize, }); } catch (e) { return json({ success: false, error: `Upload failed: ${e.message}` }, 500); } } return json( { success: false, error: "Invalid request. POST to upload, GET /stats or GET /limits." }, 405 ); }, }; // ── HELPERS ──────────────────────────────────────────────────────────────── /** Run all stat queries in parallel */ async function getStatistics(env) { const [images, videos, gifs, storage] = await Promise.all([ env.DB.prepare(`SELECT COUNT(*) AS c FROM uploads WHERE file_type = 'image'`).first(), env.DB.prepare(`SELECT COUNT(*) AS c FROM uploads WHERE file_type = 'video'`).first(), env.DB.prepare(`SELECT COUNT(*) AS c FROM uploads WHERE file_type = 'gif'`).first(), env.DB.prepare(`SELECT COALESCE(SUM(size), 0) AS s FROM uploads`).first(), ]); return { images: images?.c || 0, videos: videos?.c || 0, gifs: gifs?.c || 0, totalSize: storage?.s || 0, totalFiles: (images?.c || 0) + (videos?.c || 0) + (gifs?.c || 0), }; } /** Best-effort client IP: Cloudflare header → fallback */ function clientIp(request) { return ( request.headers.get("CF-Connecting-IP") || request.headers.get("X-Forwarded-For")?.split(",")[0].trim() || "unknown" ); } /** Today's date as YYYY-MM-DD in UTC */ function todayStr() { return new Date().toISOString().split("T")[0]; } /** Human-readable bytes (for error messages) */ function fmtBytes(b) { if (!b) return "0 B"; const k = 1024, s = ["B", "KB", "MB", "GB", "TB"]; const i = Math.floor(Math.log(b) / Math.log(k)); return (b / Math.pow(k, i)).toFixed(1).replace(/\.0$/, "") + " " + s[i]; } /** Map content-type → { type, extension } */ function getFileInfo(contentType) { const t = contentType.toLowerCase(); if (t.includes("image/jpeg") || t.includes("image/jpg")) return { type: "image", extension: ".jpg" }; if (t.includes("image/png")) return { type: "image", extension: ".png" }; if (t.includes("image/gif")) return { type: "gif", extension: ".gif" }; if (t.includes("image/webp")) return { type: "image", extension: ".webp" }; if (t.includes("image/svg")) return { type: "image", extension: ".svg" }; if (t.includes("image/avif")) return { type: "image", extension: ".avif" }; if (t.includes("video/mp4")) return { type: "video", extension: ".mp4" }; if (t.includes("video/webm")) return { type: "video", extension: ".webm" }; if (t.includes("video/quicktime")) return { type: "video", extension: ".mov" }; if (t.includes("video/")) return { type: "video", extension: "" }; if (t.includes("application/pdf")) return { type: "document", extension: ".pdf" }; if (t.includes("audio/mpeg")) return { type: "audio", extension: ".mp3" }; if (t.includes("audio/ogg")) return { type: "audio", extension: ".ogg" }; if (t.includes("audio/wav")) return { type: "audio", extension: ".wav" }; if (t.includes("audio/")) return { type: "audio", extension: "" }; if (t.includes("image/")) return { type: "image", extension: "" }; return { type: "other", extension: "" }; }