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`.
`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** (~2535%
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** (~2535% 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/<slug>/chapters/<chapter_number>/<page_number>.webp`
- `manga/<slug>/cover.webp`

View File

@ -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);
}
);

View File

@ -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);
}
);

View File

@ -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);
}
);

View File

@ -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 (
<div className="max-w-6xl mx-auto px-4 py-5">
<GenreTabs manga={signedManga} genres={genres} />
<GenreTabs manga={manga} genres={genres} />
</div>
);
}

View File

@ -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 (
<div className="max-w-3xl mx-auto px-4 py-6">
{/* Hero section */}
@ -48,7 +45,7 @@ export default async function MangaDetailPage({ params }: Props) {
<div className="w-28 sm:w-36 shrink-0">
<div className="aspect-[3/4] rounded-xl overflow-hidden bg-card">
<img
src={signedCoverUrl}
src={manga.coverUrl}
alt={manga.title}
className="w-full h-full object-cover"
/>

View File

@ -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 (
<div className="max-w-6xl mx-auto px-4 py-5 space-y-8">
{/* Trending section — Webtoon-style ranked carousel */}
<TrendingCarousel manga={trending} />
{/* Genre browsing section — horizontal tabs + filtered grid */}
<GenreTabs manga={signedManga} genres={genres} />
<GenreTabs manga={manga} genres={genres} />
</div>
);
}

View File

@ -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 (
<div className="max-w-7xl mx-auto px-4 py-6">
<h1 className="text-xl font-bold mb-4">
{q ? `Results for "${q}"` : "Search"}
</h1>
{q ? (
<MangaGrid manga={signedManga} />
<MangaGrid manga={manga} />
) : (
<p className="text-muted text-center py-12">
Use the search bar above to find manga

View File

@ -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);
}}
/>
) : (
<LoadingLogo

View File

@ -1,8 +1,4 @@
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
} from "@aws-sdk/client-s3";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({
@ -27,33 +23,8 @@ export function getPublicUrl(key: string) {
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 {
const prefix = process.env.R2_PUBLIC_URL!;
if (!publicUrl.startsWith(prefix)) return null;
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),
}))
);
}