···11+/**
22+ * MBD CDN — Cloudflare Worker
33+ *
44+ * Endpoints:
55+ * POST / — upload file (enforces per-IP limits)
66+ * GET /stats — global stats (images / videos / gifs / totalSize)
77+ * GET /limits — today's usage for the requesting IP
88+ *
99+ * D1 tables required:
1010+ * uploads (id, filename, path, content_type, file_type, size, upload_date)
1111+ * upload_limits (ip TEXT, date TEXT, file_count INT DEFAULT 0, total_size INT DEFAULT 0, PRIMARY KEY (ip, date))
1212+ */
1313+1414+// ── LIMIT CONSTANTS ────────────────────────────────────────────────────────
1515+const MAX_FILE_BYTES = 100 * 1024 * 1024; // 100 MB per file
1616+const MAX_DAY_BYTES = 1024 * 1024 * 1024; // 1 GB per IP per day
1717+const MAX_DAY_FILES = 30; // 30 files per IP per day
1818+1919+export default {
2020+ async fetch(request, env, ctx) {
2121+ const url = new URL(request.url);
2222+2323+ const cors = {
2424+ "Access-Control-Allow-Origin": "*",
2525+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
2626+ "Access-Control-Allow-Headers": "Content-Type",
2727+ };
2828+2929+ const json = (data, status = 200, extra = {}) =>
3030+ new Response(JSON.stringify(data), {
3131+ status,
3232+ headers: { "Content-Type": "application/json", ...cors, ...extra },
3333+ });
3434+3535+ // ── Sanity check bindings ───────────────────
3636+ if (!env.DB) {
3737+ return json({ success: false, error: "DB binding not configured" }, 500);
3838+ }
3939+ if (!env.MY_BUCKET) {
4040+ return json({ success: false, error: "MY_BUCKET binding not configured" }, 500);
4141+ }
4242+4343+ // ── CORS preflight ──────────────────────────────
4444+ if (request.method === "OPTIONS") {
4545+ return new Response(null, { headers: cors });
4646+ }
4747+4848+ // ── GET /stats ──────────────────────────────────
4949+ if (request.method === "GET" && url.pathname === "/stats") {
5050+ try {
5151+ const stats = await getStatistics(env);
5252+ return json(
5353+ { success: true, stats },
5454+ 200,
5555+ { "Cache-Control": "public, max-age=30, stale-while-revalidate=60" }
5656+ );
5757+ } catch (e) {
5858+ return json({ success: false, error: `Stats failed: ${e.message}` }, 500);
5959+ }
6060+ }
6161+6262+ // ── GET /limits ─────────────────────────────────
6363+ if (request.method === "GET" && url.pathname === "/limits") {
6464+ try {
6565+ const ip = clientIp(request);
6666+ const today = todayStr();
6767+ const row = await env.DB.prepare(
6868+ `SELECT file_count, total_size FROM upload_limits WHERE ip = ? AND date = ?`
6969+ ).bind(ip, today).first();
7070+7171+ return json(
7272+ {
7373+ success: true,
7474+ limits: {
7575+ file_count: row?.file_count || 0,
7676+ total_size: row?.total_size || 0,
7777+ max_files: MAX_DAY_FILES,
7878+ max_bytes: MAX_DAY_BYTES,
7979+ max_file: MAX_FILE_BYTES,
8080+ },
8181+ },
8282+ 200,
8383+ { "Cache-Control": "private, max-age=10" }
8484+ );
8585+ } catch (e) {
8686+ return json({ success: false, error: `Limits check failed: ${e.message}` }, 500);
8787+ }
8888+ }
8989+9090+ // ── POST / — Upload ─────────────────────────────
9191+ if (request.method === "POST") {
9292+ const ip = clientIp(request);
9393+ const today = todayStr();
9494+ const contentType = request.headers.get("Content-Type") || "application/octet-stream";
9595+ const contentLength = parseInt(request.headers.get("Content-Length") || "0");
9696+9797+ // ── 1. Per-file size check (fast, no I/O) ────
9898+ if (contentLength > MAX_FILE_BYTES) {
9999+ return json({
100100+ success: false,
101101+ error: `File too large. Max ${MAX_FILE_BYTES / 1024 / 1024} MB per file.`,
102102+ }, 413);
103103+ }
104104+105105+ try {
106106+ const now = new Date();
107107+ const dateStr = now.toISOString().split("T")[0];
108108+ const randomId = crypto.randomUUID();
109109+ const fileInfo = getFileInfo(contentType);
110110+ const path = `user-content/${dateStr}/${randomId}${fileInfo.extension}`;
111111+112112+ // ── 2. Race: DB limit check vs R2 upload ────
113113+ // request.body is a FixedLengthStream so R2 knows the content length.
114114+ // obj.size gives us the real byte count after the write completes.
115115+ const [usageResult, uploadResult] = await Promise.allSettled([
116116+ env.DB.prepare(
117117+ `SELECT file_count, total_size FROM upload_limits WHERE ip = ? AND date = ?`
118118+ ).bind(ip, today).first(),
119119+ env.MY_BUCKET.put(path, request.body, { httpMetadata: { contentType } }),
120120+ ]);
121121+122122+ // Bubble up R2 errors
123123+ if (uploadResult.status === "rejected") {
124124+ throw new Error(`R2 upload failed: ${uploadResult.reason?.message}`);
125125+ }
126126+127127+ // obj.size is ground truth — fall back to Content-Length only if absent
128128+ const actualSize = uploadResult.value?.size ?? contentLength;
129129+130130+ // ── 3. Enforce limits post-upload ───────────
131131+ const usage = usageResult.status === "fulfilled" ? usageResult.value : null;
132132+ const usedFiles = usage?.file_count || 0;
133133+ const usedBytes = usage?.total_size || 0;
134134+135135+ if (usedFiles >= MAX_DAY_FILES) {
136136+ ctx.waitUntil(env.MY_BUCKET.delete(path));
137137+ return json({
138138+ success: false,
139139+ error: `Daily file limit reached (${MAX_DAY_FILES} files/day). Resets at midnight UTC.`,
140140+ }, 429);
141141+ }
142142+143143+ if (usedBytes + actualSize > MAX_DAY_BYTES) {
144144+ ctx.waitUntil(env.MY_BUCKET.delete(path));
145145+ const remaining = MAX_DAY_BYTES - usedBytes;
146146+ return json({
147147+ success: false,
148148+ error: `Daily storage limit reached. You have ${fmtBytes(remaining)} remaining today. Resets at midnight UTC.`,
149149+ }, 429);
150150+ }
151151+152152+ // ── 4. Batch both D1 writes in one round-trip
153153+ ctx.waitUntil(
154154+ env.DB.batch([
155155+ env.DB.prepare(
156156+ `INSERT INTO uploads (id, filename, path, content_type, file_type, size, upload_date)
157157+ VALUES (?, ?, ?, ?, ?, ?, ?)`
158158+ ).bind(
159159+ randomId,
160160+ `${randomId}${fileInfo.extension}`,
161161+ path,
162162+ contentType,
163163+ fileInfo.type,
164164+ actualSize,
165165+ now.toISOString()
166166+ ),
167167+168168+ env.DB.prepare(
169169+ `INSERT INTO upload_limits (ip, date, file_count, total_size)
170170+ VALUES (?, ?, 1, ?)
171171+ ON CONFLICT(ip, date) DO UPDATE SET
172172+ file_count = file_count + 1,
173173+ total_size = total_size + excluded.total_size`
174174+ ).bind(ip, today, actualSize),
175175+ ])
176176+ );
177177+178178+ return json({
179179+ success: true,
180180+ url: `https://public-cdn.madebydanny.uk/${path}`,
181181+ path,
182182+ contentType,
183183+ fileType: fileInfo.type,
184184+ size: actualSize,
185185+ });
186186+187187+ } catch (e) {
188188+ return json({ success: false, error: `Upload failed: ${e.message}` }, 500);
189189+ }
190190+ }
191191+192192+ return json(
193193+ { success: false, error: "Invalid request. POST to upload, GET /stats or GET /limits." },
194194+ 405
195195+ );
196196+ },
197197+};
198198+199199+// ── HELPERS ────────────────────────────────────────────────────────────────
200200+201201+/** Run all stat queries in parallel */
202202+async function getStatistics(env) {
203203+ const [images, videos, gifs, storage] = await Promise.all([
204204+ env.DB.prepare(`SELECT COUNT(*) AS c FROM uploads WHERE file_type = 'image'`).first(),
205205+ env.DB.prepare(`SELECT COUNT(*) AS c FROM uploads WHERE file_type = 'video'`).first(),
206206+ env.DB.prepare(`SELECT COUNT(*) AS c FROM uploads WHERE file_type = 'gif'`).first(),
207207+ // COALESCE avoids NULL when the table is empty, which D1 handles more reliably
208208+ env.DB.prepare(`SELECT COALESCE(SUM(size), 0) AS s FROM uploads`).first(),
209209+ ]);
210210+211211+ return {
212212+ images: images?.c || 0,
213213+ videos: videos?.c || 0,
214214+ gifs: gifs?.c || 0,
215215+ totalSize: storage?.s || 0,
216216+ totalFiles: (images?.c || 0) + (videos?.c || 0) + (gifs?.c || 0),
217217+ };
218218+}
219219+220220+/** Best-effort client IP: Cloudflare header → fallback */
221221+function clientIp(request) {
222222+ return (
223223+ request.headers.get("CF-Connecting-IP") ||
224224+ request.headers.get("X-Forwarded-For")?.split(",")[0].trim() ||
225225+ "unknown"
226226+ );
227227+}
228228+229229+/** Today's date as YYYY-MM-DD in UTC */
230230+function todayStr() {
231231+ return new Date().toISOString().split("T")[0];
232232+}
233233+234234+/** Human-readable bytes (for error messages) */
235235+function fmtBytes(b) {
236236+ if (!b) return "0 B";
237237+ const k = 1024, s = ["B", "KB", "MB", "GB", "TB"];
238238+ const i = Math.floor(Math.log(b) / Math.log(k));
239239+ return (b / Math.pow(k, i)).toFixed(1).replace(/\.0$/, "") + " " + s[i];
240240+}
241241+242242+/** Map content-type → { type, extension } */
243243+function getFileInfo(contentType) {
244244+ const t = contentType.toLowerCase();
245245+246246+ if (t.includes("image/jpeg") || t.includes("image/jpg")) return { type: "image", extension: ".jpg" };
247247+ if (t.includes("image/png")) return { type: "image", extension: ".png" };
248248+ if (t.includes("image/gif")) return { type: "gif", extension: ".gif" };
249249+ if (t.includes("image/webp")) return { type: "image", extension: ".webp" };
250250+ if (t.includes("image/svg")) return { type: "image", extension: ".svg" };
251251+ if (t.includes("image/avif")) return { type: "image", extension: ".avif" };
252252+253253+ if (t.includes("video/mp4")) return { type: "video", extension: ".mp4" };
254254+ if (t.includes("video/webm")) return { type: "video", extension: ".webm" };
255255+ if (t.includes("video/quicktime")) return { type: "video", extension: ".mov" };
256256+ if (t.includes("video/")) return { type: "video", extension: "" };
257257+258258+ if (t.includes("application/pdf")) return { type: "document", extension: ".pdf" };
259259+260260+ if (t.includes("audio/mpeg")) return { type: "audio", extension: ".mp3" };
261261+ if (t.includes("audio/ogg")) return { type: "audio", extension: ".ogg" };
262262+ if (t.includes("audio/wav")) return { type: "audio", extension: ".wav" };
263263+ if (t.includes("audio/")) return { type: "audio", extension: "" };
264264+265265+ // Generic image catch-all comes last so it never swallows video/* types
266266+ if (t.includes("image/")) return { type: "image", extension: "" };
267267+268268+ return { type: "other", extension: "" };
269269+}
+1-1
index.html
···2020 <div id="total-visitors" style="font-size: 0.8em; color: gray; text-align: center;">
2121 Calculating total visits...
2222 </div>
2323- <p><a href="https://guestbook.madebydanny.uk">Guestbook</a> ~ <a href="https://pdsls.dev/at://did:plc:l37td5yhxl2irrzrgvei4qay/fm.teal.alpha.feed.play">Recently played Music</a> ~ <a href="/about.html">About Me</a> ~ <a href="https://microblog.madebydanny.uk">Microblog</a> ~ <a href="/photos.html">Photos</a></p>
2323+ <p><a href="https://guestbook.madebydanny.uk/danny">Guestbook</a> ~ <a href="https://pdsls.dev/at://did:plc:l37td5yhxl2irrzrgvei4qay/fm.teal.alpha.feed.play">Recently played Music</a> ~ <a href="/about.html">About Me</a> ~ <a href="https://microblog.madebydanny.uk">Microblog</a> ~ <a href="/photos.html">Photos</a></p>
2424 <p>I like to listen to Music <i>(Mainly Tate McRae and Taylor Swift)</i>, and post on Bluesky<br>I'm also on <a href="https://threads.net/@madebydanny.uk" target="_blank">Threads</a> and <a href="ttps://mastodon.social/@danielmorrisey" target="_blank">Mastodon</a>, but active on <a href="https://aturi.to/did:plc:l37td5yhxl2irrzrgvei4qay" target="_blank">Bluesky</a>, becuase it's the best social media platform</p>
2525 <div id="music-status-card">
2626 <div class="bsky-header">