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.
This commit is contained in:
2025-11-17 10:48:40 +01:00
parent 08f7e77a86
commit 96a173b73b
7 changed files with 110 additions and 69 deletions

View File

@@ -4,7 +4,7 @@ import { sha256 } from '@oslojs/crypto/sha2';
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding'; import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import * as table from '$lib/server/db/schema'; 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; 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)); .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; let profilePictureUrl = user.profilePictureUrl;
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) { if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
// It's a path, generate signed URL // It's a path, generate local proxy URL
try { profilePictureUrl = getLocalR2Url(profilePictureUrl);
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60); // 24 hours
} catch (error) {
console.error('Failed to generate signed URL for profile picture:', error);
profilePictureUrl = null;
}
} }
return { return {

View File

@@ -72,3 +72,7 @@ export async function getSignedR2Url(path: string, expiresIn = 3600): Promise<st
return await getSignedUrl(r2Client, command, { expiresIn }); return await getSignedUrl(r2Client, command, { expiresIn });
} }
export function getLocalR2Url(path: string): string {
return `/api/media/${path}`;
}

View File

@@ -4,7 +4,7 @@ import { db } from '$lib/server/db';
import { find, findMedia, user, findLike, friendship } from '$lib/server/db/schema'; import { find, findMedia, user, findLike, friendship } from '$lib/server/db/schema';
import { eq, and, sql, desc, or } from 'drizzle-orm'; import { eq, and, sql, desc, or } from 'drizzle-orm';
import { encodeBase64url } from '@oslojs/encoding'; import { encodeBase64url } from '@oslojs/encoding';
import { getSignedR2Url } from '$lib/server/r2'; import { getLocalR2Url } from '$lib/server/r2';
function generateFindId(): string { function generateFindId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(15)); const bytes = crypto.getRandomValues(new Uint8Array(15));
@@ -176,31 +176,24 @@ export const GET: RequestHandler = async ({ url, locals }) => {
// Generate signed URLs for all media items // Generate signed URLs for all media items
const mediaWithSignedUrls = await Promise.all( const mediaWithSignedUrls = await Promise.all(
findMedia.map(async (mediaItem) => { findMedia.map(async (mediaItem) => {
// URLs in database are now paths, generate signed URLs directly // URLs in database are now paths, generate local proxy URLs
const [signedUrl, signedThumbnailUrl] = await Promise.all([ const localUrl = getLocalR2Url(mediaItem.url);
getSignedR2Url(mediaItem.url, 24 * 60 * 60), // 24 hours const localThumbnailUrl = mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')
mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/') ? getLocalR2Url(mediaItem.thumbnailUrl)
? getSignedR2Url(mediaItem.thumbnailUrl, 24 * 60 * 60) : mediaItem.thumbnailUrl; // Keep static placeholder paths as-is
: Promise.resolve(mediaItem.thumbnailUrl) // Keep static placeholder paths as-is
]);
return { return {
...mediaItem, ...mediaItem,
url: signedUrl, url: localUrl,
thumbnailUrl: signedThumbnailUrl 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; let userProfilePictureUrl = findItem.profilePictureUrl;
if (userProfilePictureUrl && !userProfilePictureUrl.startsWith('http')) { if (userProfilePictureUrl && !userProfilePictureUrl.startsWith('http')) {
try { userProfilePictureUrl = getLocalR2Url(userProfilePictureUrl);
userProfilePictureUrl = await getSignedR2Url(userProfilePictureUrl, 24 * 60 * 60);
} catch (error) {
console.error('Failed to generate signed URL for user profile picture:', error);
userProfilePictureUrl = null;
}
} }
return { return {

View File

@@ -4,7 +4,7 @@ import { db } from '$lib/server/db';
import { friendship, user } from '$lib/server/db/schema'; import { friendship, user } from '$lib/server/db/schema';
import { eq, and, or } from 'drizzle-orm'; import { eq, and, or } from 'drizzle-orm';
import { encodeBase64url } from '@oslojs/encoding'; import { encodeBase64url } from '@oslojs/encoding';
import { getSignedR2Url } from '$lib/server/r2'; import { getLocalR2Url } from '$lib/server/r2';
import { notificationService } from '$lib/server/notifications'; import { notificationService } from '$lib/server/notifications';
import { pushService } from '$lib/server/push'; 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))); .where(and(eq(friendship.friendId, locals.user.id), eq(friendship.status, status)));
} }
// Generate signed URLs for profile pictures // Generate local proxy URLs for profile pictures
const friendshipsWithSignedUrls = await Promise.all( const friendshipsWithSignedUrls = (friendships || []).map((friendship) => {
(friendships || []).map(async (friendship) => { let profilePictureUrl = friendship.friendProfilePictureUrl;
let profilePictureUrl = friendship.friendProfilePictureUrl; if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) { profilePictureUrl = getLocalR2Url(profilePictureUrl);
try { }
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60);
} catch (error) {
console.error('Failed to generate signed URL for profile picture:', error);
profilePictureUrl = null;
}
}
return { return {
...friendship, ...friendship,
friendProfilePictureUrl: profilePictureUrl friendProfilePictureUrl: profilePictureUrl
}; };
}) });
);
return json(friendshipsWithSignedUrls); return json(friendshipsWithSignedUrls);
} catch (err) { } catch (err) {

View File

@@ -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<Uint8Array>;
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 });
};

View File

@@ -3,7 +3,7 @@ import type { RequestHandler } from './$types';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { user, friendship } from '$lib/server/db/schema'; import { user, friendship } from '$lib/server/db/schema';
import { eq, and, or, ilike, ne } from 'drizzle-orm'; 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 }) => { export const GET: RequestHandler = async ({ url, locals }) => {
if (!locals.user) { if (!locals.user) {
@@ -63,28 +63,21 @@ export const GET: RequestHandler = async ({ url, locals }) => {
friendshipStatusMap.set(otherUserId, f.status); friendshipStatusMap.set(otherUserId, f.status);
}); });
// Generate signed URLs and add friendship status // Generate local proxy URLs and add friendship status
const usersWithSignedUrls = await Promise.all( const usersWithSignedUrls = users.map((userItem) => {
users.map(async (userItem) => { let profilePictureUrl = userItem.profilePictureUrl;
let profilePictureUrl = userItem.profilePictureUrl; if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) { profilePictureUrl = getLocalR2Url(profilePictureUrl);
try { }
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60);
} catch (error) {
console.error('Failed to generate signed URL for profile picture:', error);
profilePictureUrl = null;
}
}
const friendshipStatus = friendshipStatusMap.get(userItem.id) || 'none'; const friendshipStatus = friendshipStatusMap.get(userItem.id) || 'none';
return { return {
...userItem, ...userItem,
profilePictureUrl, profilePictureUrl,
friendshipStatus friendshipStatus
}; };
}) });
);
return json(usersWithSignedUrls); return json(usersWithSignedUrls);
} catch (err) { } catch (err) {

View File

@@ -130,8 +130,8 @@ self.addEventListener('fetch', (event) => {
} }
} }
// Handle R2 resources with cache-first strategy // Handle R2 resources and local media proxy with cache-first strategy
if (url.hostname.includes('.r2.dev') || url.hostname.includes('.r2.cloudflarestorage.com')) { if (url.hostname.includes('.r2.dev') || url.hostname.includes('.r2.cloudflarestorage.com') || url.pathname.startsWith('/api/media/')) {
const cachedResponse = await r2Cache.match(event.request); const cachedResponse = await r2Cache.match(event.request);
if (cachedResponse) { if (cachedResponse) {
return cachedResponse; return cachedResponse;
@@ -146,7 +146,7 @@ self.addEventListener('fetch', (event) => {
return response; return response;
} catch { } catch {
// Return cached version if available, or fall through to other cache checks // 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 });
} }
} }