Proxy R2 images through /api/img to fix 503 from Cloudflare Bot Management
pub-xxx.r2.dev returns 503 for cross-origin sub-resource requests (img tags, fetch) due to Cloudflare Bot Management on the r2.dev dev domain. Direct browser navigation works but programmatic loading fails, so all uploaded images appeared as placeholders after upload. Fix: route all image display through a same-origin /api/img?key=... proxy that fetches from R2 via the S3 API server-side. API responses (profile, children, memories) now return proxy URLs. After upload, UI state is updated with proxy URLs directly rather than raw R2 URLs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
51e36633b9
commit
a3a0ddf3c9
8 changed files with 92 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
50
src/app/api/img/route.ts
Normal file
50
src/app/api/img/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, unknown>, 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,
|
||||
|
|
|
|||
22
src/lib/r2-proxy.ts
Normal file
22
src/lib/r2-proxy.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* Converts a direct R2 public URL to a same-origin proxy URL.
|
||||
* Used in API responses so <img> 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue