madebydanny.uk written in html, css, and a lot of JavaScript I don't understand
madeydanny.uk
html
css
javascript
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}