From 0efe77d5bae56f4ba866ac502eec7a829c1e99f3 Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Sun, 1 Mar 2026 17:15:09 +0100 Subject: [PATCH] cloudflare r2 for images --- .env.example | 7 ++ bun.lock | 5 ++ package.json | 1 + src/lib/server/storage/r2.ts | 70 +++++++++++++++++++ .../(app)/mappools/[id]/+page.server.ts | 3 +- src/routes/(app)/matches/[id]/+page.server.ts | 5 +- vite.config.ts | 3 + 7 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 src/lib/server/storage/r2.ts diff --git a/.env.example b/.env.example index d751aa9..a921b76 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,10 @@ GITHUB_CLIENT_SECRET="" # Discord notifications DISCORD_BOT_TOKEN="" DISCORD_MATCH_CHANNEL_ID="" + +# Cloudflare R2 image storage +R2_ACCOUNT_ID="" # Your Cloudflare Account ID +R2_ACCESS_KEY_ID="" # R2 API token Access Key ID +R2_SECRET_ACCESS_KEY="" # R2 API token Secret Access Key +R2_BUCKET="" # Bucket name, e.g. vash-esports-images +R2_PUBLIC_URL="" # Public URL for the bucket, e.g. https://cdn.esports.vash.software diff --git a/bun.lock b/bun.lock index 1154167..a67ff7d 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,7 @@ "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.18", + "@types/bun": "^1.3.9", "@types/node": "^22", "@vitest/browser-playwright": "^4.0.18", "better-auth": "^1.4.18", @@ -278,6 +279,8 @@ "@testing-library/svelte-core": ["@testing-library/svelte-core@1.0.0", "", { "peerDependencies": { "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" } }, "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ=="], + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], @@ -364,6 +367,8 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], diff --git a/package.json b/package.json index cbbff40..3a0401b 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.18", + "@types/bun": "^1.3.9", "@types/node": "^22", "@vitest/browser-playwright": "^4.0.18", "better-auth": "^1.4.18", diff --git a/src/lib/server/storage/r2.ts b/src/lib/server/storage/r2.ts new file mode 100644 index 0000000..1f24af6 --- /dev/null +++ b/src/lib/server/storage/r2.ts @@ -0,0 +1,70 @@ +import { env } from '$env/dynamic/private'; +import { S3Client } from 'bun'; + +function isConfigured(): boolean { + return !!(env.R2_ACCESS_KEY_ID && env.R2_SECRET_ACCESS_KEY && env.R2_ACCOUNT_ID && env.R2_BUCKET && env.R2_PUBLIC_URL); +} + +let client: S3Client | null = null; + +function getClient(): S3Client { + if (!client) { + client = new S3Client({ + accessKeyId: env.R2_ACCESS_KEY_ID, + secretAccessKey: env.R2_SECRET_ACCESS_KEY, + bucket: env.R2_BUCKET, + endpoint: `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com` + }); + } + return client; +} + +function urlToKey(url: string): string { + const u = new URL(url); + if (u.hostname === 'assets.ppy.sh') { + // e.g. /beatmaps/3591031/covers/card@2x.jpg → beatmaps/3591031/covers/card@2x.jpg + return u.pathname.replace(/^\//, ''); + } + if (u.hostname === 'a.ppy.sh') { + // e.g. /12345 → avatars/12345 + return `avatars${u.pathname}`; + } + // Generic fallback: stable key from URL path + return `misc${u.pathname}`; +} + +// In-memory set of keys confirmed to exist in R2 (reset on server restart — that's fine) +const knownKeys = new Set(); + +/** + * Returns a CDN URL for the given osu! image URL, uploading to R2 on first access. + * Falls back to the original URL if R2 is not configured or the upload fails. + */ +export async function proxyImage(osuUrl: string): Promise { + if (!isConfigured()) return osuUrl; + + const key = urlToKey(osuUrl); + const cdnUrl = `${env.R2_PUBLIC_URL}/${key}`; + + if (knownKeys.has(key)) return cdnUrl; + + try { + const r2 = getClient(); + const file = r2.file(key); + + const exists = await file.exists(); + if (!exists) { + const res = await fetch(osuUrl); + if (!res.ok) throw new Error(`fetch ${osuUrl} → ${res.status}`); + const contentType = res.headers.get('content-type') ?? 'image/jpeg'; + await file.write(res, { type: contentType }); + console.log(`[R2] uploaded ${key}`); + } + + knownKeys.add(key); + return cdnUrl; + } catch (err: unknown) { + console.error('[R2] proxyImage failed for', key, '—', err instanceof Error ? err.message : err); + return osuUrl; + } +} diff --git a/src/routes/(app)/mappools/[id]/+page.server.ts b/src/routes/(app)/mappools/[id]/+page.server.ts index ebc2fcd..95662b5 100644 --- a/src/routes/(app)/mappools/[id]/+page.server.ts +++ b/src/routes/(app)/mappools/[id]/+page.server.ts @@ -3,6 +3,7 @@ import { mappool, mappoolSlot } from '$lib/server/db/schema'; import { eq, and } from 'drizzle-orm'; import { error, redirect } from '@sveltejs/kit'; import { getBeatmap } from '$lib/server/osu/api'; +import { proxyImage } from '$lib/server/storage/r2'; import { requireAuth, requireOwnerOrAdmin } from '$lib/server/permissions'; import type { PageServerLoad, Actions } from './$types'; @@ -34,7 +35,7 @@ export const load: PageServerLoad = async ({ params, locals }) => { starRating: beatmap.difficulty_rating, bpm: beatmap.bpm, totalLength: beatmap.total_length, - coverUrl: beatmap.beatmapset.covers['card@2x'], + coverUrl: await proxyImage(beatmap.beatmapset.covers['card@2x']), url: beatmap.url } }; diff --git a/src/routes/(app)/matches/[id]/+page.server.ts b/src/routes/(app)/matches/[id]/+page.server.ts index 1352bd0..5676147 100644 --- a/src/routes/(app)/matches/[id]/+page.server.ts +++ b/src/routes/(app)/matches/[id]/+page.server.ts @@ -5,6 +5,7 @@ import { playPickedMap, closeLobby, forceStartGame } from '$lib/server/match/orc import { getLobby, getLobbyStatus } from '$lib/server/bancho/client'; import { error, redirect } from '@sveltejs/kit'; import { getBeatmap } from '$lib/server/osu/api'; +import { proxyImage } from '$lib/server/storage/r2'; import { hasRole } from '$lib/server/permissions'; import type { PageServerLoad, Actions } from './$types'; import { eq } from 'drizzle-orm'; @@ -35,8 +36,8 @@ export const load: PageServerLoad = async ({ params, locals }) => { starRating: bm.difficulty_rating, bpm: bm.bpm, totalLength: bm.total_length, - coverUrl: bm.beatmapset.covers['card@2x'], - listCoverUrl: bm.beatmapset.covers['list@2x'] + coverUrl: await proxyImage(bm.beatmapset.covers['card@2x']), + listCoverUrl: await proxyImage(bm.beatmapset.covers['list@2x']) }; } catch { /* skip */ } }) diff --git a/vite.config.ts b/vite.config.ts index 125d07c..3f8dcad 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,6 +6,9 @@ import { sveltekit } from '@sveltejs/kit/vite'; export default defineConfig({ plugins: [tailwindcss(), sveltekit(), devtoolsJson()], + ssr: { + external: ['bun'] + }, test: { expect: { requireAssertions: true }, projects: [