From 1d858e40e1e57644ba9e62bfbd1109efceaa43dd Mon Sep 17 00:00:00 2001 From: Zias van Nes Date: Fri, 10 Oct 2025 13:38:08 +0200 Subject: [PATCH] fix:use signed R2 URLs for uploaded media - uploadToR2 now returns storage path instead of a: full URL. - Generate signed R2 URLs (24h expiration) for images, thumbnails, and videos in media processor and when loading finds. - Update CSP to allow *.r2.cloudflarestorage.com for img-src --- src/hooks.server.ts | 2 +- src/lib/server/media-processor.ts | 15 ++++++++--- src/lib/server/r2.ts | 2 +- src/routes/finds/+page.server.ts | 42 +++++++++++++++++++++++++++---- 4 files changed, 51 insertions(+), 10 deletions(-) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 067652b..f3ae370 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -50,7 +50,7 @@ export const handle: Handle = async ({ event, resolve }) => { "worker-src 'self' blob:; " + "style-src 'self' 'unsafe-inline' fonts.googleapis.com; " + "font-src 'self' fonts.gstatic.com; " + - "img-src 'self' data: blob: *.openstreetmap.org *.tile.openstreetmap.org pub-6495d15c9d09b19ddc65f0a01892a183.r2.dev; " + + "img-src 'self' data: blob: *.openstreetmap.org *.tile.openstreetmap.org *.r2.cloudflarestorage.com; " + "connect-src 'self' *.openstreetmap.org; " + "frame-ancestors 'none'; " + "base-uri 'self'; " + diff --git a/src/lib/server/media-processor.ts b/src/lib/server/media-processor.ts index 46f657d..8760e9b 100644 --- a/src/lib/server/media-processor.ts +++ b/src/lib/server/media-processor.ts @@ -1,5 +1,5 @@ import sharp from 'sharp'; -import { uploadToR2 } from './r2'; +import { uploadToR2, getSignedR2Url } from './r2'; const THUMBNAIL_SIZE = 400; const MAX_IMAGE_SIZE = 1920; @@ -41,11 +41,17 @@ export async function processAndUploadImage( type: 'image/jpeg' }); - const [url, thumbnailUrl] = await Promise.all([ + const [imagePath, thumbPath] = await Promise.all([ uploadToR2(imageFile, `${filename}.jpg`, 'image/jpeg'), uploadToR2(thumbFile, `${filename}-thumb.jpg`, 'image/jpeg') ]); + // Generate signed URLs (24 hour expiration for images) + const [url, thumbnailUrl] = await Promise.all([ + getSignedR2Url(imagePath, 24 * 60 * 60), // 24 hours + getSignedR2Url(thumbPath, 24 * 60 * 60) // 24 hours + ]); + return { url, thumbnailUrl }; } @@ -58,7 +64,10 @@ export async function processAndUploadVideo( const filename = `finds/${findId}/video-${index}-${timestamp}.mp4`; // Upload video directly (no processing on server to save resources) - const url = await uploadToR2(file, filename, 'video/mp4'); + const videoPath = await uploadToR2(file, filename, 'video/mp4'); + + // Generate signed URL for video (24 hour expiration) + const url = await getSignedR2Url(videoPath, 24 * 60 * 60); // For video thumbnail, generate on client-side or use placeholder // This keeps server-side processing minimal diff --git a/src/lib/server/r2.ts b/src/lib/server/r2.ts index f349bad..d14e9f1 100644 --- a/src/lib/server/r2.ts +++ b/src/lib/server/r2.ts @@ -48,7 +48,7 @@ export async function uploadToR2(file: File, path: string, contentType: string): }) ); - return `${R2_PUBLIC_URL}/${path}`; + return path; } export async function deleteFromR2(path: string): Promise { diff --git a/src/routes/finds/+page.server.ts b/src/routes/finds/+page.server.ts index c03eefa..2dc7398 100644 --- a/src/routes/finds/+page.server.ts +++ b/src/routes/finds/+page.server.ts @@ -3,6 +3,7 @@ import type { PageServerLoad } from './$types'; import { db } from '$lib/server/db'; import { find, findMedia, user } from '$lib/server/db/schema'; import { eq, and, sql, desc } from 'drizzle-orm'; +import { getSignedR2Url } from '$lib/server/r2'; export const load: PageServerLoad = async ({ locals, url }) => { if (!locals.user) { @@ -105,11 +106,42 @@ export const load: PageServerLoad = async ({ locals, url }) => { {} as Record ); - // Combine finds with their media - const findsWithMedia = finds.map((findItem) => ({ - ...findItem, - media: mediaByFind[findItem.id] || [] - })); + // Combine finds with their media and generate signed URLs + const findsWithMedia = await Promise.all( + finds.map(async (findItem) => { + const findMedia = mediaByFind[findItem.id] || []; + + // Generate signed URLs for all media items + const mediaWithSignedUrls = await Promise.all( + findMedia.map(async (mediaItem) => { + // Extract path from URL if it's still a full URL, otherwise use as-is + const path = mediaItem.url.startsWith('https://') + ? mediaItem.url.split('/').slice(3).join('/') + : mediaItem.url; + + const thumbnailPath = mediaItem.thumbnailUrl?.startsWith('https://') + ? mediaItem.thumbnailUrl.split('/').slice(3).join('/') + : mediaItem.thumbnailUrl; + + const [signedUrl, signedThumbnailUrl] = await Promise.all([ + getSignedR2Url(path, 24 * 60 * 60), // 24 hours + thumbnailPath ? getSignedR2Url(thumbnailPath, 24 * 60 * 60) : Promise.resolve(null) + ]); + + return { + ...mediaItem, + url: signedUrl, + thumbnailUrl: signedThumbnailUrl + }; + }) + ); + + return { + ...findItem, + media: mediaWithSignedUrls + }; + }) + ); return { finds: findsWithMedia