diff --git a/src/app/(app)/home/page.tsx b/src/app/(app)/home/page.tsx index 8a9124b..00ef2fe 100644 --- a/src/app/(app)/home/page.tsx +++ b/src/app/(app)/home/page.tsx @@ -267,9 +267,10 @@ export default function HomePage() { } setPhotoStep(2, { status: "done" }); - // 4. Update in-memory state immediately + // 4. Update in-memory state with proxy URL (avoids R2 503 on cross-origin img) + const proxyUrl = `/api/img?key=${encodeURIComponent(key)}`; setPhotoError(false); - updateChildImage(childId, publicUrl); + updateChildImage(childId, proxyUrl); } catch (err) { setPhotoUploadSteps(prev => prev.map(s => s.status === "active" ? { ...s, status: "error", detail: String(err) } : s diff --git a/src/app/(app)/memories/page.tsx b/src/app/(app)/memories/page.tsx index eaa571b..4a4675f 100644 --- a/src/app/(app)/memories/page.tsx +++ b/src/app/(app)/memories/page.tsx @@ -247,8 +247,9 @@ export default function MemoriesPage() { setStep(2, { status: "done" }); // ── Optimistic grid update ───────────────────────────────────────────── + const proxyUrl = `/api/img?key=${encodeURIComponent(key)}`; const optimistic: Memory = { - id: memoryId, key, url: publicUrl, thumbnailUrl: null, + id: memoryId, key, url: proxyUrl, thumbnailUrl: null, sizeBytes: file.size, mimeType: contentType, title: null, description: pendingFolder || null, takenAt: null, visionCaption: null, visionTags: null, isPrivate: false, diff --git a/src/app/(app)/profile/page.tsx b/src/app/(app)/profile/page.tsx index 0635a79..ffa2935 100644 --- a/src/app/(app)/profile/page.tsx +++ b/src/app/(app)/profile/page.tsx @@ -119,8 +119,8 @@ export default function ProfilePage() { } setStep(2, { status: "done" }); - setAvatarUrl(newPublicUrl); setAvatarError(false); + setAvatarUrl(`/api/img?key=${encodeURIComponent(key)}`); } catch (err) { // Mark the currently-active step as failed setSteps(prev => prev.map(s => diff --git a/src/app/api/auth/profile/route.ts b/src/app/api/auth/profile/route.ts index 0be3d46..d5d8bdb 100644 --- a/src/app/api/auth/profile/route.ts +++ b/src/app/api/auth/profile/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { sql } from "@/db"; import { cookies } from "next/headers"; +import { toProxyUrl } from "@/lib/r2-proxy"; // GET current user profile from session export async function GET() { @@ -40,7 +41,7 @@ export async function GET() { id: session.id, email: session.email, name: session.name || "Parent", - avatarUrl: session.image || null, + avatarUrl: toProxyUrl(session.image) || null, familyId: members?.[0]?.family_id, familyName: members?.[0]?.family_name, memberSince: session.created_at, diff --git a/src/app/api/children/route.ts b/src/app/api/children/route.ts index ca20b54..a59c3a7 100644 --- a/src/app/api/children/route.ts +++ b/src/app/api/children/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { sql } from "@/db"; import { validateSession, requireFamily } from "@/lib/auth"; import { checkChildLimit } from "@/lib/quota"; +import { toProxyUrl } from "@/lib/r2-proxy"; // GET - list children (family only) export async function GET(request: Request) { @@ -13,11 +14,15 @@ export async function GET(request: Request) { const familyId = auth.session!.familyId!; try { - const children = await sql.unsafe( + const rows = await sql.unsafe( `SELECT id, name, birth_date as "birthDate", sex, stage, image_url as "imageUrl", created_at as "createdAt" FROM children WHERE family_id = $1 ORDER BY created_at DESC`, [familyId] ); - return NextResponse.json({ children: children || [] }); + const children = (rows || []).map((c: any) => ({ + ...c, + imageUrl: toProxyUrl(c.imageUrl) ?? null, + })); + return NextResponse.json({ children }); } catch (error) { console.error(error); return NextResponse.json({ error: String(error) }, { status: 500 }); diff --git a/src/app/api/img/route.ts b/src/app/api/img/route.ts new file mode 100644 index 0000000..6999d01 --- /dev/null +++ b/src/app/api/img/route.ts @@ -0,0 +1,50 @@ +import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; +import { NextRequest, NextResponse } from "next/server"; + +const ALLOWED_PREFIXES = ["avatars/", "profiles/", "memories/", "thumbnails/"]; + +export async function GET(req: NextRequest) { + const key = req.nextUrl.searchParams.get("key"); + if (!key) return NextResponse.json({ error: "key required" }, { status: 400 }); + + // Only proxy our own R2 objects + if (!ALLOWED_PREFIXES.some(p => key.startsWith(p))) { + return NextResponse.json({ error: "Invalid key" }, { status: 403 }); + } + + const accountId = process.env.R2_ACCOUNT_ID; + const accessKeyId = process.env.R2_ACCESS_KEY_ID; + const secretKey = process.env.R2_SECRET_ACCESS_KEY; + const bucket = process.env.R2_BUCKET_NAME; + + if (!accountId || !accessKeyId || !secretKey || !bucket) { + return NextResponse.json({ error: "Storage not configured" }, { status: 500 }); + } + + const client = new S3Client({ + region: "auto", + endpoint: `https://${accountId}.r2.cloudflarestorage.com`, + credentials: { accessKeyId, secretAccessKey: secretKey }, + }); + + try { + const obj = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key })); + if (!obj.Body) return new NextResponse(null, { status: 404 }); + + const bytes = await (obj.Body as any).transformToByteArray(); + return new NextResponse(bytes, { + status: 200, + headers: { + "Content-Type": obj.ContentType || "image/jpeg", + "Cache-Control": "public, max-age=604800, immutable", + ...(obj.ContentLength ? { "Content-Length": String(obj.ContentLength) } : {}), + }, + }); + } catch (e: any) { + if (e?.name === "NoSuchKey" || e?.$metadata?.httpStatusCode === 404) { + return new NextResponse(null, { status: 404 }); + } + console.error("R2 img proxy error:", e); + return new NextResponse(null, { status: 502 }); + } +} diff --git a/src/app/api/memories/route.ts b/src/app/api/memories/route.ts index 927c91f..6299598 100644 --- a/src/app/api/memories/route.ts +++ b/src/app/api/memories/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { requireFamily } from "@/lib/auth"; import { sql } from "@/db"; +import { toProxyUrl } from "@/lib/r2-proxy"; function getBaseUrl() { const pub = process.env.R2_PUBLIC_URL; @@ -9,11 +10,13 @@ function getBaseUrl() { } function toMemoryDto(m: Record, baseUrl: string) { + const rawUrl = `${baseUrl}/${m.r2_key}`; + const rawThumb = m.r2_thumbnail_key ? `${baseUrl}/${m.r2_thumbnail_key}` : null; return { id: m.id, key: m.r2_key, - url: `${baseUrl}/${m.r2_key}`, - thumbnailUrl: m.r2_thumbnail_key ? `${baseUrl}/${m.r2_thumbnail_key}` : null, + url: toProxyUrl(rawUrl) ?? rawUrl, + thumbnailUrl: toProxyUrl(rawThumb), sizeBytes: m.size_bytes, mimeType: m.mime_type, title: m.title, diff --git a/src/lib/r2-proxy.ts b/src/lib/r2-proxy.ts new file mode 100644 index 0000000..0d82911 --- /dev/null +++ b/src/lib/r2-proxy.ts @@ -0,0 +1,22 @@ +/** + * Converts a direct R2 public URL to a same-origin proxy URL. + * Used in API responses so tags load via /api/img instead of + * pub-xxx.r2.dev (which returns 503 for cross-origin sub-resource requests + * due to Cloudflare Bot Management on the r2.dev domain). + */ + +function getR2BaseUrl() { + const pub = process.env.R2_PUBLIC_URL; + const id = process.env.R2_ACCOUNT_ID; + return pub || `https://pub-${id}.r2.dev`; +} + +export function toProxyUrl(r2Url: string | null | undefined): string | null { + if (!r2Url) return null; + const base = getR2BaseUrl(); + if (r2Url.startsWith(base + "/")) { + const key = r2Url.slice(base.length + 1); + return `/api/img?key=${encodeURIComponent(key)}`; + } + return r2Url; +}