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:
parent
95135942a2
commit
dea57e6b28
15
CLAUDE.md
15
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/<slug>/chapters/<chapter_number>/<page_number>.webp`
|
||||
- `manga/<slug>/cover.webp`
|
||||
|
||||
@ -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);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
14
app/page.tsx
14
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
31
lib/r2.ts
31
lib/r2.ts
@ -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),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user