From dea57e6b28024fc1a13b2dc3275ac5f4304f99f1 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Thu, 16 Apr 2026 19:29:11 +0800 Subject: [PATCH] Drop R2 URL signing; serve images via custom domain Images now load direct from images.04080616.xyz. Removes read-side signing (signUrl/signCoverUrls + callers), unlocking browser and edge caching since URLs are stable. Presigned upload kept for /api/upload. PageReader retries failed loads via onError as a safety net. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 15 ++++++++------- app/api/manga/route.ts | 5 +---- app/api/pages/route.ts | 10 +--------- app/api/search/route.ts | 3 +-- app/genre/page.tsx | 6 ++---- app/manga/[slug]/page.tsx | 5 +---- app/page.tsx | 14 +++----------- app/search/page.tsx | 5 +---- components/PageReader.tsx | 9 +++++++++ lib/r2.ts | 31 +------------------------------ 10 files changed, 28 insertions(+), 75 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e82f4ca..ebf6c35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,13 +76,14 @@ nav being present needs to account for this. - **Prisma models** (see `prisma/schema.prisma`): `Manga`, `Chapter`, `Page`. `Page` carries `width` / `height`. `Manga` has `genre` as a comma-separated string; parse with `lib/genres.ts`. -- **Images live in R2** (S3-compatible). Format is **WebP** (~25–35% - smaller than JPEG). The app **never** serves R2 URLs directly — - `lib/r2.ts::signUrl` / `signCoverUrls` mint presigned GETs with a 60 s - TTL; `keyFromPublicUrl` reverses a public URL to its R2 key. - `signCoverUrls` is the standardized pattern for list pages — homepage - carousel, genre grid, search results, detail page all route through it. - Don't bypass it by handing raw `manga.coverUrl` to the browser. +- **Images live in R2** (S3-compatible), served via the Cloudflare custom + domain in `R2_PUBLIC_URL`. Format is **WebP** (~25–35% smaller than + JPEG). URLs stored in `Manga.coverUrl` / `Page.imageUrl` are already + browser-ready — pass them straight through. `lib/r2.ts::keyFromPublicUrl` + reverses a stored URL back to its R2 key (used by the backfill script). + `getPresignedUploadUrl` still exists for `/api/upload` (admin PUTs). + Read-side signing was removed — URL theft protection is handled at the + CDN layer (Cloudflare Hotlink Protection on the custom domain). - **R2 layout** (populated by the scraper — not this app): - `manga//chapters//.webp` - `manga//cover.webp` diff --git a/app/api/manga/route.ts b/app/api/manga/route.ts index 529e0d5..c42dbcb 100644 --- a/app/api/manga/route.ts +++ b/app/api/manga/route.ts @@ -1,5 +1,4 @@ import { prisma } from "@/lib/db"; -import { signCoverUrls } from "@/lib/r2"; import { withGuards } from "@/lib/api-guards"; export const GET = withGuards( @@ -12,9 +11,7 @@ export const GET = withGuards( }, }); - const signedManga = await signCoverUrls(manga); - - return Response.json(signedManga); + return Response.json(manga); } ); diff --git a/app/api/pages/route.ts b/app/api/pages/route.ts index b9230be..fbd228b 100644 --- a/app/api/pages/route.ts +++ b/app/api/pages/route.ts @@ -1,5 +1,4 @@ import { prisma } from "@/lib/db"; -import { signUrl } from "@/lib/r2"; import { decodeId } from "@/lib/hashids"; import { withGuards } from "@/lib/api-guards"; @@ -26,13 +25,6 @@ export const GET = withGuards( select: { number: true, imageUrl: true }, }); - const signedPages = await Promise.all( - pages.map(async (p) => ({ - number: p.number, - imageUrl: await signUrl(p.imageUrl), - })) - ); - - return Response.json(signedPages); + return Response.json(pages); } ); diff --git a/app/api/search/route.ts b/app/api/search/route.ts index e0e85bd..ba5e489 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -1,5 +1,4 @@ import { prisma } from "@/lib/db"; -import { signCoverUrls } from "@/lib/r2"; import { withGuards } from "@/lib/api-guards"; export const GET = withGuards( @@ -27,6 +26,6 @@ export const GET = withGuards( orderBy: { title: "asc" }, }); - return Response.json(await signCoverUrls(results)); + return Response.json(results); } ); diff --git a/app/genre/page.tsx b/app/genre/page.tsx index 79493d2..f59e3ea 100644 --- a/app/genre/page.tsx +++ b/app/genre/page.tsx @@ -1,5 +1,4 @@ import { prisma } from "@/lib/db"; -import { signCoverUrls } from "@/lib/r2"; import { collectGenres } from "@/lib/genres"; import { GenreTabs } from "@/components/GenreTabs"; import type { Metadata } from "next"; @@ -17,12 +16,11 @@ export default async function GenrePage() { include: { _count: { select: { chapters: true } } }, }); - const signedManga = await signCoverUrls(manga); - const genres = collectGenres(signedManga); + const genres = collectGenres(manga); return (
- +
); } diff --git a/app/manga/[slug]/page.tsx b/app/manga/[slug]/page.tsx index 941fa05..c8cbba3 100644 --- a/app/manga/[slug]/page.tsx +++ b/app/manga/[slug]/page.tsx @@ -1,6 +1,5 @@ import { notFound } from "next/navigation"; import { prisma } from "@/lib/db"; -import { signUrl } from "@/lib/r2"; import { parseGenres } from "@/lib/genres"; import { ChapterList } from "@/components/ChapterList"; import { ReadingProgressButton } from "@/components/ReadingProgressButton"; @@ -39,8 +38,6 @@ export default async function MangaDetailPage({ params }: Props) { if (!manga) notFound(); - const signedCoverUrl = await signUrl(manga.coverUrl); - return (
{/* Hero section */} @@ -48,7 +45,7 @@ export default async function MangaDetailPage({ params }: Props) {
{manga.title} diff --git a/app/page.tsx b/app/page.tsx index 2276f7b..ccad489 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,4 @@ import { prisma } from "@/lib/db"; -import { signCoverUrls } from "@/lib/r2"; import { collectGenres } from "@/lib/genres"; import { TrendingCarousel } from "@/components/TrendingCarousel"; import { GenreTabs } from "@/components/GenreTabs"; @@ -13,20 +12,13 @@ export default async function Home() { include: { _count: { select: { chapters: true } } }, }); - const signedManga = await signCoverUrls(manga); - - // Top 10 for trending - const trending = signedManga.slice(0, 10); - - const genres = collectGenres(signedManga); + const trending = manga.slice(0, 10); + const genres = collectGenres(manga); return (
- {/* Trending section — Webtoon-style ranked carousel */} - - {/* Genre browsing section — horizontal tabs + filtered grid */} - +
); } diff --git a/app/search/page.tsx b/app/search/page.tsx index 9516633..6e704b6 100644 --- a/app/search/page.tsx +++ b/app/search/page.tsx @@ -1,5 +1,4 @@ import { prisma } from "@/lib/db"; -import { signCoverUrls } from "@/lib/r2"; import { MangaGrid } from "@/components/MangaGrid"; import type { Metadata } from "next"; @@ -25,15 +24,13 @@ export default async function SearchPage({ searchParams }: Props) { }) : []; - const signedManga = await signCoverUrls(manga); - return (

{q ? `Results for "${q}"` : "Search"}

{q ? ( - + ) : (

Use the search bar above to find manga diff --git a/components/PageReader.tsx b/components/PageReader.tsx index 58ef609..dca3393 100644 --- a/components/PageReader.tsx +++ b/components/PageReader.tsx @@ -661,6 +661,15 @@ export function PageReader({ fetchPriority={isVisible ? "high" : "low"} className="w-full h-auto block [-webkit-touch-callout:none]" draggable={false} + onError={() => { + setImages((prev) => { + if (!prev[key]) return prev; + const next = { ...prev }; + delete next[key]; + return next; + }); + forceFetchPage(chNum, p.number); + }} /> ) : ( ( - items: T[] -): Promise { - return Promise.all( - items.map(async (item) => ({ - ...item, - coverUrl: await signUrl(item.coverUrl), - })) - ); -}