cloudflare r2 for images
This commit is contained in:
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 { 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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 */ }
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user