madebydanny.uk written in html, css, and a lot of JavaScript I don't understand madeydanny.uk
html css javascript
at main 287 lines 11 kB view raw
1/** 2 * MBD CDN — Cloudflare Worker 3 * 4 * Endpoints: 5 * POST / — upload file (enforces per-IP limits) 6 * GET /stats — global stats (images / videos / gifs / totalSize) 7 * GET /limits — today's usage for the requesting IP 8 * 9 * D1 tables required: 10 * uploads (id, filename, path, content_type, file_type, size, upload_date) 11 * upload_limits (ip TEXT, date TEXT, file_count INT DEFAULT 0, total_size INT DEFAULT 0, PRIMARY KEY (ip, date)) 12 */ 13 14// ── LIMIT CONSTANTS ──────────────────────────────────────────────────────── 15const MAX_FILE_BYTES = 70 * 1024 * 1024; // 70 MB per file 16const MAX_DAY_BYTES = 300 * 1024 * 1024; // 300 MB per IP per day 17const MAX_DAY_FILES = 20; // 20 files per IP per day 18 19const CDN_BASE_URL = "https://cdn.madebydanny.uk"; 20 21export default { 22 async fetch(request, env, ctx) { 23 const url = new URL(request.url); 24 25 const cors = { 26 "Access-Control-Allow-Origin": "*", 27 "Access-Control-Allow-Methods": "GET, POST, OPTIONS", 28 "Access-Control-Allow-Headers": "Content-Type", 29 }; 30 31 const json = (data, status = 200, extra = {}) => 32 new Response(JSON.stringify(data), { 33 status, 34 headers: { "Content-Type": "application/json", ...cors, ...extra }, 35 }); 36 37 // ── Sanity check bindings ─────────────────── 38 if (!env.DB) { 39 return json({ success: false, error: "DB binding not configured" }, 500); 40 } 41 if (!env.MY_BUCKET) { 42 return json({ success: false, error: "MY_BUCKET binding not configured" }, 500); 43 } 44 45 // ── CORS preflight ────────────────────────────── 46 if (request.method === "OPTIONS") { 47 return new Response(null, { headers: cors }); 48 } 49 50 // ── GET /user-content/… — Serve file from R2 ─── 51 if (request.method === "GET" && url.pathname.startsWith("/user-content/")) { 52 const key = url.pathname.slice(1); // strip leading / 53 try { 54 const object = await env.MY_BUCKET.get(key); 55 if (!object) { 56 return new Response("Not found", { status: 404, headers: cors }); 57 } 58 const headers = new Headers(cors); 59 object.writeHttpMetadata(headers); 60 headers.set("Cache-Control", "public, max-age=31536000, immutable"); 61 headers.set("ETag", object.httpEtag); 62 return new Response(object.body, { headers }); 63 } catch (e) { 64 return new Response("Failed to fetch file", { status: 500, headers: cors }); 65 } 66 } 67 68 // ── GET /stats ────────────────────────────────── 69 if (request.method === "GET" && url.pathname === "/stats") { 70 try { 71 const stats = await getStatistics(env); 72 return json( 73 { success: true, stats }, 74 200, 75 { "Cache-Control": "public, max-age=30, stale-while-revalidate=60" } 76 ); 77 } catch (e) { 78 return json({ success: false, error: `Stats failed: ${e.message}` }, 500); 79 } 80 } 81 82 // ── GET /limits ───────────────────────────────── 83 if (request.method === "GET" && url.pathname === "/limits") { 84 try { 85 const ip = clientIp(request); 86 const today = todayStr(); 87 const row = await env.DB.prepare( 88 `SELECT file_count, total_size FROM upload_limits WHERE ip = ? AND date = ?` 89 ).bind(ip, today).first(); 90 91 return json( 92 { 93 success: true, 94 limits: { 95 file_count: row?.file_count || 0, 96 total_size: row?.total_size || 0, 97 max_files: MAX_DAY_FILES, 98 max_bytes: MAX_DAY_BYTES, 99 max_file: MAX_FILE_BYTES, 100 }, 101 }, 102 200, 103 { "Cache-Control": "private, max-age=10" } 104 ); 105 } catch (e) { 106 return json({ success: false, error: `Limits check failed: ${e.message}` }, 500); 107 } 108 } 109 110 // ── POST / — Upload ───────────────────────────── 111 if (request.method === "POST") { 112 const ip = clientIp(request); 113 const today = todayStr(); 114 const contentType = request.headers.get("Content-Type") || "application/octet-stream"; 115 const contentLength = parseInt(request.headers.get("Content-Length") || "0"); 116 117 // ── 1. Per-file size check (fast, no I/O) ──── 118 if (contentLength > MAX_FILE_BYTES) { 119 return json({ 120 success: false, 121 error: `File too large. Max ${MAX_FILE_BYTES / 1024 / 1024} MB per file.`, 122 }, 413); 123 } 124 125 try { 126 const now = new Date(); 127 const dateStr = now.toISOString().split("T")[0]; 128 const randomId = crypto.randomUUID(); 129 const fileInfo = getFileInfo(contentType); 130 const path = `user-content/${dateStr}/${randomId}${fileInfo.extension}`; 131 132 // ── 2. Race: DB limit check vs R2 upload ──── 133 const [usageResult, uploadResult] = await Promise.allSettled([ 134 env.DB.prepare( 135 `SELECT file_count, total_size FROM upload_limits WHERE ip = ? AND date = ?` 136 ).bind(ip, today).first(), 137 env.MY_BUCKET.put(path, request.body, { httpMetadata: { contentType } }), 138 ]); 139 140 // Bubble up R2 errors 141 if (uploadResult.status === "rejected") { 142 throw new Error(`R2 upload failed: ${uploadResult.reason?.message}`); 143 } 144 145 // obj.size is ground truth — fall back to Content-Length only if absent 146 const actualSize = uploadResult.value?.size ?? contentLength; 147 148 // ── 3. Enforce limits post-upload ─────────── 149 const usage = usageResult.status === "fulfilled" ? usageResult.value : null; 150 const usedFiles = usage?.file_count || 0; 151 const usedBytes = usage?.total_size || 0; 152 153 if (usedFiles >= MAX_DAY_FILES) { 154 ctx.waitUntil(env.MY_BUCKET.delete(path)); 155 return json({ 156 success: false, 157 error: `Daily file limit reached (${MAX_DAY_FILES} files/day). Resets at midnight UTC.`, 158 }, 429); 159 } 160 161 if (usedBytes + actualSize > MAX_DAY_BYTES) { 162 ctx.waitUntil(env.MY_BUCKET.delete(path)); 163 const remaining = MAX_DAY_BYTES - usedBytes; 164 return json({ 165 success: false, 166 error: `Daily storage limit reached. You have ${fmtBytes(remaining)} remaining today. Resets at midnight UTC.`, 167 }, 429); 168 } 169 170 // ── 4. Batch both D1 writes in one round-trip 171 ctx.waitUntil( 172 env.DB.batch([ 173 env.DB.prepare( 174 `INSERT INTO uploads (id, filename, path, content_type, file_type, size, upload_date) 175 VALUES (?, ?, ?, ?, ?, ?, ?)` 176 ).bind( 177 randomId, 178 `${randomId}${fileInfo.extension}`, 179 path, 180 contentType, 181 fileInfo.type, 182 actualSize, 183 now.toISOString() 184 ), 185 186 env.DB.prepare( 187 `INSERT INTO upload_limits (ip, date, file_count, total_size) 188 VALUES (?, ?, 1, ?) 189 ON CONFLICT(ip, date) DO UPDATE SET 190 file_count = file_count + 1, 191 total_size = total_size + excluded.total_size` 192 ).bind(ip, today, actualSize), 193 ]) 194 ); 195 196 const fileUrl = `${CDN_BASE_URL}/${path}`; 197 198 return json({ 199 success: true, 200 url: fileUrl, 201 path, 202 contentType, 203 fileType: fileInfo.type, 204 size: actualSize, 205 }); 206 207 } catch (e) { 208 return json({ success: false, error: `Upload failed: ${e.message}` }, 500); 209 } 210 } 211 212 return json( 213 { success: false, error: "Invalid request. POST to upload, GET /stats or GET /limits." }, 214 405 215 ); 216 }, 217}; 218 219// ── HELPERS ──────────────────────────────────────────────────────────────── 220 221/** Run all stat queries in parallel */ 222async function getStatistics(env) { 223 const [images, videos, gifs, storage] = await Promise.all([ 224 env.DB.prepare(`SELECT COUNT(*) AS c FROM uploads WHERE file_type = 'image'`).first(), 225 env.DB.prepare(`SELECT COUNT(*) AS c FROM uploads WHERE file_type = 'video'`).first(), 226 env.DB.prepare(`SELECT COUNT(*) AS c FROM uploads WHERE file_type = 'gif'`).first(), 227 env.DB.prepare(`SELECT COALESCE(SUM(size), 0) AS s FROM uploads`).first(), 228 ]); 229 230 return { 231 images: images?.c || 0, 232 videos: videos?.c || 0, 233 gifs: gifs?.c || 0, 234 totalSize: storage?.s || 0, 235 totalFiles: (images?.c || 0) + (videos?.c || 0) + (gifs?.c || 0), 236 }; 237} 238 239/** Best-effort client IP: Cloudflare header → fallback */ 240function clientIp(request) { 241 return ( 242 request.headers.get("CF-Connecting-IP") || 243 request.headers.get("X-Forwarded-For")?.split(",")[0].trim() || 244 "unknown" 245 ); 246} 247 248/** Today's date as YYYY-MM-DD in UTC */ 249function todayStr() { 250 return new Date().toISOString().split("T")[0]; 251} 252 253/** Human-readable bytes (for error messages) */ 254function fmtBytes(b) { 255 if (!b) return "0 B"; 256 const k = 1024, s = ["B", "KB", "MB", "GB", "TB"]; 257 const i = Math.floor(Math.log(b) / Math.log(k)); 258 return (b / Math.pow(k, i)).toFixed(1).replace(/\.0$/, "") + " " + s[i]; 259} 260 261/** Map content-type → { type, extension } */ 262function getFileInfo(contentType) { 263 const t = contentType.toLowerCase(); 264 265 if (t.includes("image/jpeg") || t.includes("image/jpg")) return { type: "image", extension: ".jpg" }; 266 if (t.includes("image/png")) return { type: "image", extension: ".png" }; 267 if (t.includes("image/gif")) return { type: "gif", extension: ".gif" }; 268 if (t.includes("image/webp")) return { type: "image", extension: ".webp" }; 269 if (t.includes("image/svg")) return { type: "image", extension: ".svg" }; 270 if (t.includes("image/avif")) return { type: "image", extension: ".avif" }; 271 272 if (t.includes("video/mp4")) return { type: "video", extension: ".mp4" }; 273 if (t.includes("video/webm")) return { type: "video", extension: ".webm" }; 274 if (t.includes("video/quicktime")) return { type: "video", extension: ".mov" }; 275 if (t.includes("video/")) return { type: "video", extension: "" }; 276 277 if (t.includes("application/pdf")) return { type: "document", extension: ".pdf" }; 278 279 if (t.includes("audio/mpeg")) return { type: "audio", extension: ".mp3" }; 280 if (t.includes("audio/ogg")) return { type: "audio", extension: ".ogg" }; 281 if (t.includes("audio/wav")) return { type: "audio", extension: ".wav" }; 282 if (t.includes("audio/")) return { type: "audio", extension: "" }; 283 284 if (t.includes("image/")) return { type: "image", extension: "" }; 285 286 return { type: "other", extension: "" }; 287}