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