From 96a173b73b4aae459065c4035fd1dc16d628c97c Mon Sep 17 00:00:00 2001 From: Zias van Nes Date: Mon, 17 Nov 2025 10:48:40 +0100 Subject: [PATCH] feat:use local proxy for media use local proxy for media so that media doesnt need to be requested from r2 everytime but can be cached locally. this also fixes some csp issues ive been having. --- src/lib/server/auth.ts | 13 ++--- src/lib/server/r2.ts | 4 ++ src/routes/api/finds/+server.ts | 27 ++++------ src/routes/api/friends/+server.ts | 31 +++++------ src/routes/api/media/[...path]/+server.ts | 63 +++++++++++++++++++++++ src/routes/api/users/+server.ts | 35 +++++-------- src/service-worker.ts | 6 +-- 7 files changed, 110 insertions(+), 69 deletions(-) create mode 100644 src/routes/api/media/[...path]/+server.ts diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 9115d81..59200bd 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -4,7 +4,7 @@ import { sha256 } from '@oslojs/crypto/sha2'; import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding'; import { db } from '$lib/server/db'; import * as table from '$lib/server/db/schema'; -import { getSignedR2Url } from '$lib/server/r2'; +import { getLocalR2Url } from '$lib/server/r2'; const DAY_IN_MS = 1000 * 60 * 60 * 24; @@ -63,16 +63,11 @@ export async function validateSessionToken(token: string) { .where(eq(table.session.id, session.id)); } - // Generate signed URL for profile picture if it exists + // Generate local proxy URL for profile picture if it exists let profilePictureUrl = user.profilePictureUrl; if (profilePictureUrl && !profilePictureUrl.startsWith('http')) { - // It's a path, generate signed URL - try { - profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60); // 24 hours - } catch (error) { - console.error('Failed to generate signed URL for profile picture:', error); - profilePictureUrl = null; - } + // It's a path, generate local proxy URL + profilePictureUrl = getLocalR2Url(profilePictureUrl); } return { diff --git a/src/lib/server/r2.ts b/src/lib/server/r2.ts index d14e9f1..6bbc4de 100644 --- a/src/lib/server/r2.ts +++ b/src/lib/server/r2.ts @@ -72,3 +72,7 @@ export async function getSignedR2Url(path: string, expiresIn = 3600): Promise { // Generate signed URLs for all media items const mediaWithSignedUrls = await Promise.all( findMedia.map(async (mediaItem) => { - // URLs in database are now paths, generate signed URLs directly - const [signedUrl, signedThumbnailUrl] = await Promise.all([ - getSignedR2Url(mediaItem.url, 24 * 60 * 60), // 24 hours - mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/') - ? getSignedR2Url(mediaItem.thumbnailUrl, 24 * 60 * 60) - : Promise.resolve(mediaItem.thumbnailUrl) // Keep static placeholder paths as-is - ]); + // URLs in database are now paths, generate local proxy URLs + const localUrl = getLocalR2Url(mediaItem.url); + const localThumbnailUrl = mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/') + ? getLocalR2Url(mediaItem.thumbnailUrl) + : mediaItem.thumbnailUrl; // Keep static placeholder paths as-is return { ...mediaItem, - url: signedUrl, - thumbnailUrl: signedThumbnailUrl + url: localUrl, + thumbnailUrl: localThumbnailUrl }; }) ); - // Generate signed URL for user profile picture if it exists + // Generate local proxy URL for user profile picture if it exists let userProfilePictureUrl = findItem.profilePictureUrl; if (userProfilePictureUrl && !userProfilePictureUrl.startsWith('http')) { - try { - userProfilePictureUrl = await getSignedR2Url(userProfilePictureUrl, 24 * 60 * 60); - } catch (error) { - console.error('Failed to generate signed URL for user profile picture:', error); - userProfilePictureUrl = null; - } + userProfilePictureUrl = getLocalR2Url(userProfilePictureUrl); } return { diff --git a/src/routes/api/friends/+server.ts b/src/routes/api/friends/+server.ts index 79a6974..527592f 100644 --- a/src/routes/api/friends/+server.ts +++ b/src/routes/api/friends/+server.ts @@ -4,7 +4,7 @@ import { db } from '$lib/server/db'; import { friendship, user } from '$lib/server/db/schema'; import { eq, and, or } from 'drizzle-orm'; import { encodeBase64url } from '@oslojs/encoding'; -import { getSignedR2Url } from '$lib/server/r2'; +import { getLocalR2Url } from '$lib/server/r2'; import { notificationService } from '$lib/server/notifications'; import { pushService } from '$lib/server/push'; @@ -98,25 +98,18 @@ export const GET: RequestHandler = async ({ url, locals }) => { .where(and(eq(friendship.friendId, locals.user.id), eq(friendship.status, status))); } - // Generate signed URLs for profile pictures - const friendshipsWithSignedUrls = await Promise.all( - (friendships || []).map(async (friendship) => { - let profilePictureUrl = friendship.friendProfilePictureUrl; - if (profilePictureUrl && !profilePictureUrl.startsWith('http')) { - try { - profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60); - } catch (error) { - console.error('Failed to generate signed URL for profile picture:', error); - profilePictureUrl = null; - } - } + // Generate local proxy URLs for profile pictures + const friendshipsWithSignedUrls = (friendships || []).map((friendship) => { + let profilePictureUrl = friendship.friendProfilePictureUrl; + if (profilePictureUrl && !profilePictureUrl.startsWith('http')) { + profilePictureUrl = getLocalR2Url(profilePictureUrl); + } - return { - ...friendship, - friendProfilePictureUrl: profilePictureUrl - }; - }) - ); + return { + ...friendship, + friendProfilePictureUrl: profilePictureUrl + }; + }); return json(friendshipsWithSignedUrls); } catch (err) { diff --git a/src/routes/api/media/[...path]/+server.ts b/src/routes/api/media/[...path]/+server.ts new file mode 100644 index 0000000..746b487 --- /dev/null +++ b/src/routes/api/media/[...path]/+server.ts @@ -0,0 +1,63 @@ +import type { RequestHandler } from './$types'; +import { error } from '@sveltejs/kit'; +import { GetObjectCommand } from '@aws-sdk/client-s3'; +import { r2Client } from '$lib/server/r2'; +import { env } from '$env/dynamic/private'; + +const R2_BUCKET_NAME = env.R2_BUCKET_NAME; + +export const GET: RequestHandler = async ({ params, setHeaders }) => { + const path = params.path; + + if (!path) { + throw error(400, 'Path is required'); + } + + if (!R2_BUCKET_NAME) { + throw error(500, 'R2_BUCKET_NAME environment variable is required'); + } + + try { + const command = new GetObjectCommand({ + Bucket: R2_BUCKET_NAME, + Key: path + }); + + const response = await r2Client.send(command); + + if (!response.Body) { + throw error(404, 'File not found'); + } + + const chunks: Uint8Array[] = []; + const body = response.Body as AsyncIterable; + for await (const chunk of body) { + chunks.push(chunk); + } + const buffer = Buffer.concat(chunks); + + setHeaders({ + 'Content-Type': response.ContentType || 'application/octet-stream', + 'Cache-Control': response.CacheControl || 'public, max-age=31536000, immutable', + 'Content-Length': buffer.length.toString(), + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type' + }); + + return new Response(buffer); + } catch (err) { + console.error('Error fetching file from R2:', err); + throw error(500, 'Failed to fetch file'); + } +}; + +export const OPTIONS: RequestHandler = async ({ setHeaders }) => { + setHeaders({ + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type' + }); + + return new Response(null, { status: 204 }); +}; diff --git a/src/routes/api/users/+server.ts b/src/routes/api/users/+server.ts index e3dceb0..c3bf8a5 100644 --- a/src/routes/api/users/+server.ts +++ b/src/routes/api/users/+server.ts @@ -3,7 +3,7 @@ import type { RequestHandler } from './$types'; import { db } from '$lib/server/db'; import { user, friendship } from '$lib/server/db/schema'; import { eq, and, or, ilike, ne } from 'drizzle-orm'; -import { getSignedR2Url } from '$lib/server/r2'; +import { getLocalR2Url } from '$lib/server/r2'; export const GET: RequestHandler = async ({ url, locals }) => { if (!locals.user) { @@ -63,28 +63,21 @@ export const GET: RequestHandler = async ({ url, locals }) => { friendshipStatusMap.set(otherUserId, f.status); }); - // Generate signed URLs and add friendship status - const usersWithSignedUrls = await Promise.all( - users.map(async (userItem) => { - let profilePictureUrl = userItem.profilePictureUrl; - if (profilePictureUrl && !profilePictureUrl.startsWith('http')) { - try { - profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60); - } catch (error) { - console.error('Failed to generate signed URL for profile picture:', error); - profilePictureUrl = null; - } - } + // Generate local proxy URLs and add friendship status + const usersWithSignedUrls = users.map((userItem) => { + let profilePictureUrl = userItem.profilePictureUrl; + if (profilePictureUrl && !profilePictureUrl.startsWith('http')) { + profilePictureUrl = getLocalR2Url(profilePictureUrl); + } - const friendshipStatus = friendshipStatusMap.get(userItem.id) || 'none'; + const friendshipStatus = friendshipStatusMap.get(userItem.id) || 'none'; - return { - ...userItem, - profilePictureUrl, - friendshipStatus - }; - }) - ); + return { + ...userItem, + profilePictureUrl, + friendshipStatus + }; + }); return json(usersWithSignedUrls); } catch (err) { diff --git a/src/service-worker.ts b/src/service-worker.ts index f67491b..593b217 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -130,8 +130,8 @@ self.addEventListener('fetch', (event) => { } } - // Handle R2 resources with cache-first strategy - if (url.hostname.includes('.r2.dev') || url.hostname.includes('.r2.cloudflarestorage.com')) { + // Handle R2 resources and local media proxy with cache-first strategy + if (url.hostname.includes('.r2.dev') || url.hostname.includes('.r2.cloudflarestorage.com') || url.pathname.startsWith('/api/media/')) { const cachedResponse = await r2Cache.match(event.request); if (cachedResponse) { return cachedResponse; @@ -146,7 +146,7 @@ self.addEventListener('fetch', (event) => { return response; } catch { // Return cached version if available, or fall through to other cache checks - return cachedResponse || new Response('R2 resource not available', { status: 404 }); + return cachedResponse || new Response('Media resource not available', { status: 404 }); } }