From a47ed68d4552875269e43d6d0acb20bf20d79e12 Mon Sep 17 00:00:00 2001 From: Daniel Morrisey <142274379+therealfuntimeswithdanny@users.noreply.github.com> Date: Sat, 28 Feb 2026 16:17:41 -0500 Subject: [PATCH] updated CDN --- cdn.html | 658 --------------------- cdn/index.html | 1046 +++++++++++++++++++++++++++++++++ js/cdn-v2.js => cdn/script.js | 0 cdn/worker.js | 269 +++++++++ index.html | 2 +- 5 files changed, 1316 insertions(+), 659 deletions(-) delete mode 100644 cdn.html create mode 100644 cdn/index.html rename js/cdn-v2.js => cdn/script.js (100%) create mode 100644 cdn/worker.js diff --git a/cdn.html b/cdn.html deleted file mode 100644 index 03923e3..0000000 --- a/cdn.html +++ /dev/null @@ -1,658 +0,0 @@ - - - - - - MBD CDN - madebydanny.uk - - - - - - - - - - - - -

MBD CDN

-

- The MBD CDN is a network of servers located around the world powered by the Cloudflare global network. -
- It's designed to deliver media (images, videos, live broadcasts, etc) at extremely fast speeds with 100% uptime. -

- - -
-
-
-
--
-
Images
-
-
-
-
--
-
Videos
-
-
-
-
--
-
GIFs
-
-
-
-
--
-
Storage Used
-
-
- - -
- - - -
-
File:
-
Size:
-
Type:
-
- -
-
-
-
-
- - -
- -
- ✅ Public URL: -
- -
-
- -
-

Social Links:

- -
- - - - - - \ No newline at end of file diff --git a/cdn/index.html b/cdn/index.html new file mode 100644 index 0000000..4f6b372 --- /dev/null +++ b/cdn/index.html @@ -0,0 +1,1046 @@ + + + + + + MBD CDN - madebydanny.uk + + + + + + + + + + + + + + + +
+
+
+
+
--
+
Images
+
+
+
+
--
+
Videos
+
+
+
+
--
+
GIFs
+
+
+
+
--
+
Storage Used
+
+
+
+ + +
+
+ + + + +
+ +
+ + +
+
+

Upload a File

+

Images, GIFs, and videos accepted. Files are served globally via Cloudflare immediately after upload.

+ + + +
+
File:
+
Size:  ·  Type:
+
+ +
+
+
+
+
0%
+
+ + + +
+ +
+
✅ Public URL
+
+ + + Open + +
+
+
+ + +
+
+

About MBD CDN

+
+

The MBD CDN is a content delivery network built by madebydanny.uk to host and serve media files — images, GIFs, and videos — at extremely fast speeds with global availability.

+

Files uploaded to the CDN are stored in Cloudflare R2 object storage and served from Cloudflare's global edge network, which spans over 310 cities worldwide. This means your media is delivered from a server geographically close to whoever is viewing it — minimising latency and maximising load speed.

+

The platform is designed to be simple and permanent. Files are stored indefinitely once uploaded and are immediately available via a public URL.

+
+ +
+ +

Social Links

+ +
+
+ + +
+
+

How it Works

+

From the moment you click Upload to the moment someone loads your file — here's what happens.

+ +
+
+
1
+
+

You select a file

+

Your file is read locally in the browser and sent directly to the CDN API over HTTPS. It goes straight to the Cloudflare edge — no intermediate servers involved.

+
+
+
+
2
+
+

The Worker receives it

+

A Cloudflare Worker handles the upload at the edge. It assigns a UUID filename, detects the file type, and streams the body directly into R2 object storage — with no cold starts and near-instant response times.

+
+
+
+
3
+
+

R2 stores it permanently

+

The file is written to Cloudflare R2 — S3-compatible storage with zero egress fees and 11 nines of durability. Upload metadata is logged to D1 (Cloudflare's edge SQL database) to track stats.

+
+
+
+
4
+
+

You get a public URL

+

A permanent public-cdn.madebydanny.uk link is returned instantly. Anyone with it can access the file — served from whichever Cloudflare PoP is closest to them.

+
+
+
+ +
+
+ +

310+ Edge Locations

+

Files are cached and served globally — sub-50ms for most users regardless of where they are.

+
+
+ +

R2 Object Storage

+

Zero egress fees, no expiry. Files are stored in Cloudflare R2 with enterprise-grade durability.

+
+
+ +

Zero Cold Starts

+

Workers run at the edge with no spin-up delay — every upload and file request is handled immediately.

+
+
+
+
+ + +
+
+

Usage Limits

+

Fair-use limits are in place to keep the CDN reliable and available for everyone.

+ +
+
+ +
100 MB
+
Max File Size
+
Per individual upload
+
+
+ +
—
+
Daily Storage
+
Total uploads per day
+
+
+ +
—
+
Uploads Per Day
+
Resets at midnight UTC
+
+
+ +
+ + All limits reset daily at midnight UTC. They are enforced per IP to protect performance for all users. If you need higher limits, consider self-hosting the stack or get in touch via the social links in the About section. +
+ +
+

Your Usage Today

+
Loading your usage…
+ +
+ +
+ +

Accepted File Types

+

+ Images: JPEG, PNG, WebP, AVIF, SVG    + Animated: GIF    + Video: MP4, WebM, MOV +

+
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/js/cdn-v2.js b/cdn/script.js similarity index 100% rename from js/cdn-v2.js rename to cdn/script.js diff --git a/cdn/worker.js b/cdn/worker.js new file mode 100644 index 0000000..ff8e2ae --- /dev/null +++ b/cdn/worker.js @@ -0,0 +1,269 @@ +/** + * 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 = 100 * 1024 * 1024; // 100 MB per file +const MAX_DAY_BYTES = 1024 * 1024 * 1024; // 1 GB per IP per day +const MAX_DAY_FILES = 30; // 30 files per IP per day + +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 /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 ──── + // request.body is a FixedLengthStream so R2 knows the content length. + // obj.size gives us the real byte count after the write completes. + 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), + ]) + ); + + return json({ + success: true, + url: `https://public-cdn.madebydanny.uk/${path}`, + 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(), + // COALESCE avoids NULL when the table is empty, which D1 handles more reliably + 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: "" }; + + // Generic image catch-all comes last so it never swallows video/* types + if (t.includes("image/")) return { type: "image", extension: "" }; + + return { type: "other", extension: "" }; +} \ No newline at end of file diff --git a/index.html b/index.html index e8bb88c..f48d2fc 100644 --- a/index.html +++ b/index.html @@ -20,7 +20,7 @@
Calculating total visits...
-

Guestbook ~ Recently played Music ~ About Me ~ Microblog ~ Photos

+

Guestbook ~ Recently played Music ~ About Me ~ Microblog ~ Photos

I like to listen to Music (Mainly Tate McRae and Taylor Swift), and post on Bluesky
I'm also on Threads and Mastodon, but active on Bluesky, becuase it's the best social media platform

-- 2.51.2