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`.
|
- **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** (~25–35%
|
- **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** (~25–35% 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`
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
14
app/page.tsx
14
app/page.tsx
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
31
lib/r2.ts
31
lib/r2.ts
@ -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),
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user