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) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-04-16 19:29:11 +08:00
parent 95135942a2
commit dea57e6b28
10 changed files with 28 additions and 75 deletions

View File

@ -76,13 +76,14 @@ nav being present needs to account for this.
- **Prisma models** (see `prisma/schema.prisma`): `Manga`, `Chapter`, `Page`. - **Prisma models** (see `prisma/schema.prisma`): `Manga`, `Chapter`, `Page`.
`Page` carries `width` / `height`. `Manga` has `genre` as a `Page` carries `width` / `height`. `Manga` has `genre` as a
comma-separated string; parse with `lib/genres.ts`. comma-separated string; parse with `lib/genres.ts`.
- **Images live in R2** (S3-compatible). Format is **WebP** (~2535% - **Images live in R2** (S3-compatible), served via the Cloudflare custom
smaller than JPEG). The app **never** serves R2 URLs directly — domain in `R2_PUBLIC_URL`. Format is **WebP** (~2535% smaller than
`lib/r2.ts::signUrl` / `signCoverUrls` mint presigned GETs with a 60 s JPEG). URLs stored in `Manga.coverUrl` / `Page.imageUrl` are already
TTL; `keyFromPublicUrl` reverses a public URL to its R2 key. browser-ready — pass them straight through. `lib/r2.ts::keyFromPublicUrl`
`signCoverUrls` is the standardized pattern for list pages — homepage reverses a stored URL back to its R2 key (used by the backfill script).
carousel, genre grid, search results, detail page all route through it. `getPresignedUploadUrl` still exists for `/api/upload` (admin PUTs).
Don't bypass it by handing raw `manga.coverUrl` to the browser. 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): - **R2 layout** (populated by the scraper — not this app):
- `manga/<slug>/chapters/<chapter_number>/<page_number>.webp` - `manga/<slug>/chapters/<chapter_number>/<page_number>.webp`
- `manga/<slug>/cover.webp` - `manga/<slug>/cover.webp`

View File

@ -1,5 +1,4 @@
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { signCoverUrls } from "@/lib/r2";
import { withGuards } from "@/lib/api-guards"; import { withGuards } from "@/lib/api-guards";
export const GET = withGuards( export const GET = withGuards(
@ -12,9 +11,7 @@ export const GET = withGuards(
}, },
}); });
const signedManga = await signCoverUrls(manga); return Response.json(manga);
return Response.json(signedManga);
} }
); );

View File

@ -1,5 +1,4 @@
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { signUrl } from "@/lib/r2";
import { decodeId } from "@/lib/hashids"; import { decodeId } from "@/lib/hashids";
import { withGuards } from "@/lib/api-guards"; import { withGuards } from "@/lib/api-guards";
@ -26,13 +25,6 @@ export const GET = withGuards(
select: { number: true, imageUrl: true }, select: { number: true, imageUrl: true },
}); });
const signedPages = await Promise.all( return Response.json(pages);
pages.map(async (p) => ({
number: p.number,
imageUrl: await signUrl(p.imageUrl),
}))
);
return Response.json(signedPages);
} }
); );

View File

@ -1,5 +1,4 @@
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { signCoverUrls } from "@/lib/r2";
import { withGuards } from "@/lib/api-guards"; import { withGuards } from "@/lib/api-guards";
export const GET = withGuards( export const GET = withGuards(
@ -27,6 +26,6 @@ export const GET = withGuards(
orderBy: { title: "asc" }, orderBy: { title: "asc" },
}); });
return Response.json(await signCoverUrls(results)); return Response.json(results);
} }
); );

View File

@ -1,5 +1,4 @@
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { signCoverUrls } from "@/lib/r2";
import { collectGenres } from "@/lib/genres"; import { collectGenres } from "@/lib/genres";
import { GenreTabs } from "@/components/GenreTabs"; import { GenreTabs } from "@/components/GenreTabs";
import type { Metadata } from "next"; import type { Metadata } from "next";
@ -17,12 +16,11 @@ export default async function GenrePage() {
include: { _count: { select: { chapters: true } } }, include: { _count: { select: { chapters: true } } },
}); });
const signedManga = await signCoverUrls(manga); const genres = collectGenres(manga);
const genres = collectGenres(signedManga);
return ( return (
<div className="max-w-6xl mx-auto px-4 py-5"> <div className="max-w-6xl mx-auto px-4 py-5">
<GenreTabs manga={signedManga} genres={genres} /> <GenreTabs manga={manga} genres={genres} />
</div> </div>
); );
} }

View File

@ -1,6 +1,5 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { signUrl } from "@/lib/r2";
import { parseGenres } from "@/lib/genres"; import { parseGenres } from "@/lib/genres";
import { ChapterList } from "@/components/ChapterList"; import { ChapterList } from "@/components/ChapterList";
import { ReadingProgressButton } from "@/components/ReadingProgressButton"; import { ReadingProgressButton } from "@/components/ReadingProgressButton";
@ -39,8 +38,6 @@ export default async function MangaDetailPage({ params }: Props) {
if (!manga) notFound(); if (!manga) notFound();
const signedCoverUrl = await signUrl(manga.coverUrl);
return ( return (
<div className="max-w-3xl mx-auto px-4 py-6"> <div className="max-w-3xl mx-auto px-4 py-6">
{/* Hero section */} {/* Hero section */}
@ -48,7 +45,7 @@ export default async function MangaDetailPage({ params }: Props) {
<div className="w-28 sm:w-36 shrink-0"> <div className="w-28 sm:w-36 shrink-0">
<div className="aspect-[3/4] rounded-xl overflow-hidden bg-card"> <div className="aspect-[3/4] rounded-xl overflow-hidden bg-card">
<img <img
src={signedCoverUrl} src={manga.coverUrl}
alt={manga.title} alt={manga.title}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />

View File

@ -1,5 +1,4 @@
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { signCoverUrls } from "@/lib/r2";
import { collectGenres } from "@/lib/genres"; import { collectGenres } from "@/lib/genres";
import { TrendingCarousel } from "@/components/TrendingCarousel"; import { TrendingCarousel } from "@/components/TrendingCarousel";
import { GenreTabs } from "@/components/GenreTabs"; import { GenreTabs } from "@/components/GenreTabs";
@ -13,20 +12,13 @@ export default async function Home() {
include: { _count: { select: { chapters: true } } }, include: { _count: { select: { chapters: true } } },
}); });
const signedManga = await signCoverUrls(manga); const trending = manga.slice(0, 10);
const genres = collectGenres(manga);
// Top 10 for trending
const trending = signedManga.slice(0, 10);
const genres = collectGenres(signedManga);
return ( return (
<div className="max-w-6xl mx-auto px-4 py-5 space-y-8"> <div className="max-w-6xl mx-auto px-4 py-5 space-y-8">
{/* Trending section — Webtoon-style ranked carousel */}
<TrendingCarousel manga={trending} /> <TrendingCarousel manga={trending} />
<GenreTabs manga={manga} genres={genres} />
{/* Genre browsing section — horizontal tabs + filtered grid */}
<GenreTabs manga={signedManga} genres={genres} />
</div> </div>
); );
} }

View File

@ -1,5 +1,4 @@
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { signCoverUrls } from "@/lib/r2";
import { MangaGrid } from "@/components/MangaGrid"; import { MangaGrid } from "@/components/MangaGrid";
import type { Metadata } from "next"; import type { Metadata } from "next";
@ -25,15 +24,13 @@ export default async function SearchPage({ searchParams }: Props) {
}) })
: []; : [];
const signedManga = await signCoverUrls(manga);
return ( return (
<div className="max-w-7xl mx-auto px-4 py-6"> <div className="max-w-7xl mx-auto px-4 py-6">
<h1 className="text-xl font-bold mb-4"> <h1 className="text-xl font-bold mb-4">
{q ? `Results for "${q}"` : "Search"} {q ? `Results for "${q}"` : "Search"}
</h1> </h1>
{q ? ( {q ? (
<MangaGrid manga={signedManga} /> <MangaGrid manga={manga} />
) : ( ) : (
<p className="text-muted text-center py-12"> <p className="text-muted text-center py-12">
Use the search bar above to find manga Use the search bar above to find manga

View File

@ -661,6 +661,15 @@ export function PageReader({
fetchPriority={isVisible ? "high" : "low"} fetchPriority={isVisible ? "high" : "low"}
className="w-full h-auto block [-webkit-touch-callout:none]" className="w-full h-auto block [-webkit-touch-callout:none]"
draggable={false} draggable={false}
onError={() => {
setImages((prev) => {
if (!prev[key]) return prev;
const next = { ...prev };
delete next[key];
return next;
});
forceFetchPage(chNum, p.number);
}}
/> />
) : ( ) : (
<LoadingLogo <LoadingLogo

View File

@ -1,8 +1,4 @@
import { import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
S3Client,
PutObjectCommand,
GetObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({ const s3 = new S3Client({
@ -27,33 +23,8 @@ export function getPublicUrl(key: string) {
return `${process.env.R2_PUBLIC_URL}/${key}`; return `${process.env.R2_PUBLIC_URL}/${key}`;
} }
export async function getPresignedReadUrl(key: string) {
const command = new GetObjectCommand({
Bucket: process.env.R2_BUCKET!,
Key: key,
});
return getSignedUrl(s3, command, { expiresIn: 60 });
}
export function keyFromPublicUrl(publicUrl: string): string | null { export function keyFromPublicUrl(publicUrl: string): string | null {
const prefix = process.env.R2_PUBLIC_URL!; const prefix = process.env.R2_PUBLIC_URL!;
if (!publicUrl.startsWith(prefix)) return null; if (!publicUrl.startsWith(prefix)) return null;
return publicUrl.replace(prefix, "").replace(/^\//, ""); return publicUrl.replace(prefix, "").replace(/^\//, "");
} }
export async function signUrl(publicUrl: string) {
const key = keyFromPublicUrl(publicUrl);
if (key === null) return publicUrl;
return getPresignedReadUrl(key);
}
export async function signCoverUrls<T extends { coverUrl: string }>(
items: T[]
): Promise<T[]> {
return Promise.all(
items.map(async (item) => ({
...item,
coverUrl: await signUrl(item.coverUrl),
}))
);
}