cloudflare r2 for images
This commit is contained in:
@@ -18,3 +18,10 @@ GITHUB_CLIENT_SECRET=""
|
|||||||
# Discord notifications
|
# Discord notifications
|
||||||
DISCORD_BOT_TOKEN=""
|
DISCORD_BOT_TOKEN=""
|
||||||
DISCORD_MATCH_CHANNEL_ID=""
|
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
|
||||||
|
|||||||
5
bun.lock
5
bun.lock
@@ -18,6 +18,7 @@
|
|||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/bun": "^1.3.9",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@vitest/browser-playwright": "^4.0.18",
|
"@vitest/browser-playwright": "^4.0.18",
|
||||||
"better-auth": "^1.4.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=="],
|
"@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/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=="],
|
"@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=="],
|
"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=="],
|
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||||
|
|
||||||
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/bun": "^1.3.9",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@vitest/browser-playwright": "^4.0.18",
|
"@vitest/browser-playwright": "^4.0.18",
|
||||||
"better-auth": "^1.4.18",
|
"better-auth": "^1.4.18",
|
||||||
|
|||||||
70
src/lib/server/storage/r2.ts
Normal file
70
src/lib/server/storage/r2.ts
Normal file
@@ -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<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { mappool, mappoolSlot } from '$lib/server/db/schema';
|
|||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import { error, redirect } from '@sveltejs/kit';
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
import { getBeatmap } from '$lib/server/osu/api';
|
import { getBeatmap } from '$lib/server/osu/api';
|
||||||
|
import { proxyImage } from '$lib/server/storage/r2';
|
||||||
import { requireAuth, requireOwnerOrAdmin } from '$lib/server/permissions';
|
import { requireAuth, requireOwnerOrAdmin } from '$lib/server/permissions';
|
||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
|||||||
starRating: beatmap.difficulty_rating,
|
starRating: beatmap.difficulty_rating,
|
||||||
bpm: beatmap.bpm,
|
bpm: beatmap.bpm,
|
||||||
totalLength: beatmap.total_length,
|
totalLength: beatmap.total_length,
|
||||||
coverUrl: beatmap.beatmapset.covers['card@2x'],
|
coverUrl: await proxyImage(beatmap.beatmapset.covers['card@2x']),
|
||||||
url: beatmap.url
|
url: beatmap.url
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { playPickedMap, closeLobby, forceStartGame } from '$lib/server/match/orc
|
|||||||
import { getLobby, getLobbyStatus } from '$lib/server/bancho/client';
|
import { getLobby, getLobbyStatus } from '$lib/server/bancho/client';
|
||||||
import { error, redirect } from '@sveltejs/kit';
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
import { getBeatmap } from '$lib/server/osu/api';
|
import { getBeatmap } from '$lib/server/osu/api';
|
||||||
|
import { proxyImage } from '$lib/server/storage/r2';
|
||||||
import { hasRole } from '$lib/server/permissions';
|
import { hasRole } from '$lib/server/permissions';
|
||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
@@ -35,8 +36,8 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
|||||||
starRating: bm.difficulty_rating,
|
starRating: bm.difficulty_rating,
|
||||||
bpm: bm.bpm,
|
bpm: bm.bpm,
|
||||||
totalLength: bm.total_length,
|
totalLength: bm.total_length,
|
||||||
coverUrl: bm.beatmapset.covers['card@2x'],
|
coverUrl: await proxyImage(bm.beatmapset.covers['card@2x']),
|
||||||
listCoverUrl: bm.beatmapset.covers['list@2x']
|
listCoverUrl: await proxyImage(bm.beatmapset.covers['list@2x'])
|
||||||
};
|
};
|
||||||
} catch { /* skip */ }
|
} catch { /* skip */ }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { sveltekit } from '@sveltejs/kit/vite';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [tailwindcss(), sveltekit(), devtoolsJson()],
|
plugins: [tailwindcss(), sveltekit(), devtoolsJson()],
|
||||||
|
ssr: {
|
||||||
|
external: ['bun']
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
expect: { requireAssertions: true },
|
expect: { requireAssertions: true },
|
||||||
projects: [
|
projects: [
|
||||||
|
|||||||
Reference in New Issue
Block a user