From b993de43bc6adb4a2718204cc94f25611c435d7e Mon Sep 17 00:00:00 2001 From: yiekheng Date: Wed, 15 Apr 2026 21:48:15 +0800 Subject: [PATCH] Reader: fix resume bug, add loading skeleton, scraping protection, bounded image cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resume scroll position only when arriving via 继续阅读 (?resume=1). Plain chapter-list / drawer clicks now actively scroll to top on mount. - Progress format extended to {chapter, page, ratio} for within-page precision; legacy bare-number and {chapter, page} still read correctly. - Tappable skeleton logo (sunflower outline, spins) while a page loads; tap force-fetches a fresh signed URL. - Viewport-priority image loading: second IntersectionObserver at margin 0 marks truly-visible pages, drives and fires immediate single-page fetches that cut the batch queue. - Bounded image cache: unmount previous chapter's elements when currentPage > 5 into the new chapter; placeholders stay for layout. One AbortController per live chapter; unmount aborts in-flight batches. - Hashed chapter IDs on the wire via hashids; DB PKs unchanged. - Origin/Referer allowlist + rate limiting on all /api/* routes via a withGuards(opts, handler) wrapper (eliminates 6-line boilerplate x5). - robots.txt allows Googlebot/Bingbot/Slurp/DuckDuckBot/Baiduspider/ YandexBot only; disallows /api/ for all UAs. - Extract pure helpers for future TDD: lib/scroll-ratio.ts (calcScrollRatio, scrollOffsetFromRatio), lib/progress.ts (parseProgress + injectable StorageLike), lib/rate-limit.ts (optional { now, store, ipOf } deps), lib/api-guards.ts. - New env keys: HASHIDS_SALT, ALLOWED_ORIGINS (wired into docker-compose). Co-Authored-By: Claude Opus 4.6 (1M context) --- app/api/chapters/[chapterId]/meta/route.ts | 35 ++-- app/api/manga/route.ts | 29 +-- app/api/pages/route.ts | 58 +++--- app/api/search/route.ts | 50 ++--- app/api/upload/route.ts | 6 +- app/manga/[slug]/[chapter]/page.tsx | 17 +- app/robots.ts | 27 +++ components/LoadingLogo.tsx | 40 ++++ components/PageReader.tsx | 214 ++++++++++++++++++--- components/ReadingProgressButton.tsx | 47 +---- docker-compose.yml | 2 + lib/api-guards.ts | 32 +++ lib/hashids.ts | 15 ++ lib/origin-check.ts | 37 ++++ lib/progress.ts | 68 +++++++ lib/rate-limit.ts | 60 ++++++ lib/scroll-ratio.ts | 19 ++ package-lock.json | 7 + package.json | 1 + 19 files changed, 610 insertions(+), 154 deletions(-) create mode 100644 app/robots.ts create mode 100644 components/LoadingLogo.tsx create mode 100644 lib/api-guards.ts create mode 100644 lib/hashids.ts create mode 100644 lib/origin-check.ts create mode 100644 lib/progress.ts create mode 100644 lib/rate-limit.ts create mode 100644 lib/scroll-ratio.ts diff --git a/app/api/chapters/[chapterId]/meta/route.ts b/app/api/chapters/[chapterId]/meta/route.ts index 2c324ab..56ba787 100644 --- a/app/api/chapters/[chapterId]/meta/route.ts +++ b/app/api/chapters/[chapterId]/meta/route.ts @@ -1,19 +1,24 @@ import { prisma } from "@/lib/db"; +import { decodeId } from "@/lib/hashids"; +import { withGuards } from "@/lib/api-guards"; -type Params = { params: Promise<{ chapterId: string }> }; +type Ctx = { params: Promise<{ chapterId: string }> }; -export async function GET(_request: Request, { params }: Params) { - const { chapterId: raw } = await params; - const chapterId = parseInt(raw, 10); - if (isNaN(chapterId)) { - return Response.json({ error: "Invalid chapterId" }, { status: 400 }); +export const GET = withGuards( + { rateLimit: { key: "chapter-meta", limit: 60, windowMs: 60_000 } }, + async (_request, { params }) => { + const { chapterId: raw } = await params; + const chapterId = decodeId(raw); + if (chapterId === null) { + return Response.json({ error: "Invalid chapterId" }, { status: 400 }); + } + + const pages = await prisma.page.findMany({ + where: { chapterId }, + orderBy: { number: "asc" }, + select: { number: true, width: true, height: true }, + }); + + return Response.json(pages); } - - const pages = await prisma.page.findMany({ - where: { chapterId }, - orderBy: { number: "asc" }, - select: { number: true, width: true, height: true }, - }); - - return Response.json(pages); -} +); diff --git a/app/api/manga/route.ts b/app/api/manga/route.ts index df89808..529e0d5 100644 --- a/app/api/manga/route.ts +++ b/app/api/manga/route.ts @@ -1,21 +1,24 @@ import { prisma } from "@/lib/db"; import { signCoverUrls } from "@/lib/r2"; -import { NextRequest } from "next/server"; +import { withGuards } from "@/lib/api-guards"; -export async function GET() { - const manga = await prisma.manga.findMany({ - orderBy: { updatedAt: "desc" }, - include: { - _count: { select: { chapters: true } }, - }, - }); +export const GET = withGuards( + { rateLimit: { key: "manga-list", limit: 30, windowMs: 60_000 } }, + async () => { + const manga = await prisma.manga.findMany({ + orderBy: { updatedAt: "desc" }, + include: { + _count: { select: { chapters: true } }, + }, + }); - const signedManga = await signCoverUrls(manga); + const signedManga = await signCoverUrls(manga); - return Response.json(signedManga); -} + return Response.json(signedManga); + } +); -export async function POST(request: NextRequest) { +export const POST = withGuards({}, async (request) => { const body = await request.json(); const { title, description, coverUrl, slug, status } = body; @@ -38,4 +41,4 @@ export async function POST(request: NextRequest) { }); return Response.json(manga, { status: 201 }); -} +}); diff --git a/app/api/pages/route.ts b/app/api/pages/route.ts index 8fe520f..b9230be 100644 --- a/app/api/pages/route.ts +++ b/app/api/pages/route.ts @@ -1,30 +1,38 @@ import { prisma } from "@/lib/db"; import { signUrl } from "@/lib/r2"; +import { decodeId } from "@/lib/hashids"; +import { withGuards } from "@/lib/api-guards"; -export async function GET(request: Request) { - const { searchParams } = new URL(request.url); - const chapterId = parseInt(searchParams.get("chapterId") ?? "", 10); - const offset = Math.max(parseInt(searchParams.get("offset") ?? "0", 10), 0); - const limit = Math.min(Math.max(parseInt(searchParams.get("limit") ?? "7", 10), 1), 20); +export const GET = withGuards( + { rateLimit: { key: "pages", limit: 300, windowMs: 60_000 } }, + async (request) => { + const { searchParams } = new URL(request.url); + const chapterId = decodeId(searchParams.get("chapter") ?? ""); + const offset = Math.max(parseInt(searchParams.get("offset") ?? "0", 10), 0); + const limit = Math.min( + Math.max(parseInt(searchParams.get("limit") ?? "7", 10), 1), + 20 + ); - if (isNaN(chapterId)) { - return Response.json({ error: "Missing chapterId" }, { status: 400 }); + if (chapterId === null) { + return Response.json({ error: "Missing chapter" }, { status: 400 }); + } + + const pages = await prisma.page.findMany({ + where: { chapterId }, + orderBy: { number: "asc" }, + skip: offset, + take: limit, + 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); } - - const pages = await prisma.page.findMany({ - where: { chapterId }, - orderBy: { number: "asc" }, - skip: offset, - take: limit, - 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); -} +); diff --git a/app/api/search/route.ts b/app/api/search/route.ts index 9c4b666..e0e85bd 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -1,28 +1,32 @@ import { prisma } from "@/lib/db"; import { signCoverUrls } from "@/lib/r2"; +import { withGuards } from "@/lib/api-guards"; -export async function GET(request: Request) { - const { searchParams } = new URL(request.url); - const q = searchParams.get("q")?.trim(); +export const GET = withGuards( + { rateLimit: { key: "search", limit: 30, windowMs: 60_000 } }, + async (request) => { + const { searchParams } = new URL(request.url); + const q = searchParams.get("q")?.trim(); - if (!q || q.length < 2) { - return Response.json([]); + if (!q || q.length < 2) { + return Response.json([]); + } + + const results = await prisma.manga.findMany({ + where: { + status: "PUBLISHED", + title: { contains: q, mode: "insensitive" }, + }, + select: { + id: true, + title: true, + slug: true, + coverUrl: true, + }, + take: 8, + orderBy: { title: "asc" }, + }); + + return Response.json(await signCoverUrls(results)); } - - const results = await prisma.manga.findMany({ - where: { - status: "PUBLISHED", - title: { contains: q, mode: "insensitive" }, - }, - select: { - id: true, - title: true, - slug: true, - coverUrl: true, - }, - take: 8, - orderBy: { title: "asc" }, - }); - - return Response.json(await signCoverUrls(results)); -} +); diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts index 76106e5..257b6d4 100644 --- a/app/api/upload/route.ts +++ b/app/api/upload/route.ts @@ -1,7 +1,7 @@ -import { NextRequest } from "next/server"; import { getPresignedUploadUrl, getPublicUrl } from "@/lib/r2"; +import { withGuards } from "@/lib/api-guards"; -export async function POST(request: NextRequest) { +export const POST = withGuards({}, async (request) => { const body = await request.json(); const { key } = body; @@ -16,4 +16,4 @@ export async function POST(request: NextRequest) { const publicUrl = getPublicUrl(key); return Response.json({ uploadUrl, publicUrl }); -} +}); diff --git a/app/manga/[slug]/[chapter]/page.tsx b/app/manga/[slug]/[chapter]/page.tsx index 84a9536..f8f702f 100644 --- a/app/manga/[slug]/[chapter]/page.tsx +++ b/app/manga/[slug]/[chapter]/page.tsx @@ -1,13 +1,19 @@ import { notFound } from "next/navigation"; import { prisma } from "@/lib/db"; import { PageReader } from "@/components/PageReader"; +import { encodeId } from "@/lib/hashids"; import type { Metadata } from "next"; type Props = { params: Promise<{ slug: string; chapter: string }>; + searchParams: Promise<{ resume?: string }>; }; -export async function generateMetadata({ params }: Props): Promise { +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string; chapter: string }>; +}): Promise { const { slug, chapter } = await params; const chapterNum = parseInt(chapter, 10); if (isNaN(chapterNum)) return { title: "Not Found" }; @@ -21,8 +27,12 @@ export async function generateMetadata({ params }: Props): Promise { }; } -export default async function ChapterReaderPage({ params }: Props) { +export default async function ChapterReaderPage({ + params, + searchParams, +}: Props) { const { slug, chapter } = await params; + const { resume } = await searchParams; const chapterNum = parseInt(chapter, 10); if (isNaN(chapterNum)) notFound(); @@ -51,7 +61,7 @@ export default async function ChapterReaderPage({ params }: Props) { if (!currentChapter) notFound(); const allChapters = manga.chapters.map((c) => ({ - id: c.id, + id: encodeId(c.id), number: c.number, title: c.title, totalPages: c._count.pages, @@ -64,6 +74,7 @@ export default async function ChapterReaderPage({ params }: Props) { startChapterNumber={currentChapter.number} chapters={allChapters} initialChapterMeta={initialChapterMeta} + resume={resume === "1"} /> ); } diff --git a/app/robots.ts b/app/robots.ts new file mode 100644 index 0000000..b77b2fc --- /dev/null +++ b/app/robots.ts @@ -0,0 +1,27 @@ +import type { MetadataRoute } from "next"; + +const SITE_URL = "https://www.04080616.xyz"; + +export default function robots(): MetadataRoute.Robots { + return { + rules: [ + { + userAgent: [ + "Googlebot", + "Bingbot", + "Slurp", + "DuckDuckBot", + "Baiduspider", + "YandexBot", + ], + allow: "/", + disallow: "/api/", + }, + { + userAgent: "*", + disallow: "/", + }, + ], + sitemap: `${SITE_URL}/sitemap.xml`, + }; +} diff --git a/components/LoadingLogo.tsx b/components/LoadingLogo.tsx new file mode 100644 index 0000000..f1e4803 --- /dev/null +++ b/components/LoadingLogo.tsx @@ -0,0 +1,40 @@ +"use client"; + +type Props = { + onTap?: () => void; +}; + +export function LoadingLogo({ onTap }: Props) { + return ( + + ); +} diff --git a/components/PageReader.tsx b/components/PageReader.tsx index 87bf8a8..3036e1a 100644 --- a/components/PageReader.tsx +++ b/components/PageReader.tsx @@ -15,9 +15,14 @@ import { readProgress, writeProgress, } from "@/components/ReadingProgressButton"; +import { LoadingLogo } from "@/components/LoadingLogo"; +import { + calcScrollRatio, + scrollOffsetFromRatio, +} from "@/lib/scroll-ratio"; type ChapterMeta = { - id: number; + id: string; number: number; title: string; totalPages: number; @@ -31,11 +36,13 @@ type PageReaderProps = { startChapterNumber: number; chapters: ChapterMeta[]; initialChapterMeta: PageMeta[]; + resume: boolean; }; const PREFETCH_NEXT_AT = 3; const IMAGE_BATCH_RADIUS = 3; const DOUBLE_TAP_MS = 280; +const KEEP_PREV_CHAPTER_PAGES = 5; const pageKey = (chNum: number, pNum: number) => `${chNum}-${pNum}`; @@ -51,6 +58,7 @@ export function PageReader({ startChapterNumber, chapters, initialChapterMeta, + resume, }: PageReaderProps) { const [showUI, setShowUI] = useState(true); const [showDrawer, setShowDrawer] = useState(false); @@ -58,16 +66,17 @@ export function PageReader({ [startChapterNumber]: initialChapterMeta, }); const [images, setImages] = useState>({}); + const [visibleKeys, setVisibleKeys] = useState>(new Set()); const [currentChapterNum, setCurrentChapterNum] = useState(startChapterNumber); const [currentPageNum, setCurrentPageNum] = useState(() => { - if (typeof window === "undefined") return 1; + if (typeof window === "undefined" || !resume) return 1; const p = readProgress(mangaSlug); if (p && p.chapter === startChapterNumber && p.page > 1) return p.page; return 1; }); + const currentRatioRef = useRef(0); - // Observer stays stable across state updates. const imagesRef = useRef(images); const chapterMetasRef = useRef(chapterMetas); useEffect(() => { @@ -79,14 +88,16 @@ export function PageReader({ const metaInflightRef = useRef>(new Set()); const imagesInflightRef = useRef>(new Set()); + const forceInflightRef = useRef>(new Set()); + const radiusAbortRef = useRef>(new Map()); const pageElRef = useRef>(new Map()); const observerRef = useRef(null); + const viewportObserverRef = useRef(null); const hiddenByScrollRef = useRef(false); const drawerScrollRef = useRef(null); const drawerActiveRef = useRef(null); - // Pages currently inside the observer's viewport margin. The scroll tick - // walks this small set instead of every loaded page. const intersectingPagesRef = useRef>(new Map()); + const visibleKeysRef = useRef>(new Set()); const loadedChapterNumbers = useMemo(() => { return Object.keys(chapterMetas) @@ -118,13 +129,23 @@ export function PageReader({ if (toFetch.length === 0) return; const minP = toFetch[0]; const maxP = toFetch[toFetch.length - 1]; + // One controller per live chapter — every batch for this chapter + // reuses the signal so chapter-unmount aborts them all in one shot. + let controller = radiusAbortRef.current.get(chapterNum); + if (!controller) { + controller = new AbortController(); + radiusAbortRef.current.set(chapterNum, controller); + } try { const res = await fetch( - `/api/pages?chapterId=${chapter.id}&offset=${minP - 1}&limit=${ + `/api/pages?chapter=${chapter.id}&offset=${minP - 1}&limit=${ maxP - minP + 1 - }` + }`, + { signal: controller.signal } ); + if (!res.ok) return; const batch: { number: number; imageUrl: string }[] = await res.json(); + if (!Array.isArray(batch)) return; setImages((prev) => { const next = { ...prev }; for (const item of batch) { @@ -133,13 +154,42 @@ export function PageReader({ return next; }); } catch { - // observer will re-trigger on next intersection + // aborted or failed — observer will re-trigger on next intersection } finally { for (const p of toFetch) imagesInflightRef.current.delete(pageKey(chapterNum, p)); } }, - [chapters] + [chapterByNumber] + ); + + // Tracked separately from imagesInflightRef so rapid taps dedup against + // each other but don't block on a slow radius fetch already in flight. + const forceFetchPage = useCallback( + async (chapterNum: number, pageNum: number) => { + const chapter = chapterByNumber.get(chapterNum); + if (!chapter) return; + const key = pageKey(chapterNum, pageNum); + if (forceInflightRef.current.has(key)) return; + forceInflightRef.current.add(key); + try { + const res = await fetch( + `/api/pages?chapter=${chapter.id}&offset=${pageNum - 1}&limit=1` + ); + if (!res.ok) return; + const batch: { number: number; imageUrl: string }[] = await res.json(); + if (!Array.isArray(batch) || batch.length === 0) return; + setImages((prev) => ({ + ...prev, + [pageKey(chapterNum, batch[0].number)]: batch[0].imageUrl, + })); + } catch { + // user can tap again + } finally { + forceInflightRef.current.delete(key); + } + }, + [chapterByNumber] ); const prefetchNextChapterMeta = useCallback( @@ -152,7 +202,9 @@ export function PageReader({ metaInflightRef.current.add(next.number); try { const res = await fetch(`/api/chapters/${next.id}/meta`); + if (!res.ok) return; const meta: PageMeta[] = await res.json(); + if (!Array.isArray(meta)) return; setChapterMetas((prev) => ({ ...prev, [next.number]: meta })); } catch { // will retry next observer fire @@ -186,43 +238,101 @@ export function PageReader({ }, { rootMargin: "1200px" } ); + + viewportObserverRef.current = new IntersectionObserver( + (entries) => { + let changed = false; + for (const e of entries) { + const el = e.target as HTMLDivElement; + const chNum = Number(el.dataset.chapter); + const pNum = Number(el.dataset.page); + if (!chNum || !pNum) continue; + const key = pageKey(chNum, pNum); + if (e.isIntersecting) { + if (!visibleKeysRef.current.has(key)) { + visibleKeysRef.current.add(key); + changed = true; + } + if ( + !imagesRef.current[key] && + !imagesInflightRef.current.has(key) + ) { + forceFetchPage(chNum, pNum); + } + } else if (visibleKeysRef.current.delete(key)) { + changed = true; + } + } + if (changed) setVisibleKeys(new Set(visibleKeysRef.current)); + }, + { rootMargin: "0px" } + ); + for (const el of pageElRef.current.values()) { observerRef.current.observe(el); + viewportObserverRef.current.observe(el); } - return () => observerRef.current?.disconnect(); - }, [fetchImagesAround, prefetchNextChapterMeta, chapterByNumber]); + return () => { + observerRef.current?.disconnect(); + viewportObserverRef.current?.disconnect(); + }; + }, [ + fetchImagesAround, + forceFetchPage, + prefetchNextChapterMeta, + chapterByNumber, + ]); const setPageRef = useCallback((key: string, el: HTMLDivElement | null) => { const observer = observerRef.current; + const viewportObserver = viewportObserverRef.current; const prev = pageElRef.current.get(key); - if (prev && observer) observer.unobserve(prev); + if (prev) { + observer?.unobserve(prev); + viewportObserver?.unobserve(prev); + } if (el) { pageElRef.current.set(key, el); - if (observer) observer.observe(el); + observer?.observe(el); + viewportObserver?.observe(el); } else { pageElRef.current.delete(key); } }, []); - // Sync scroll + rAF re-scroll: defends against browser scroll-restoration - // on hard reload (the sync pass handles soft nav where Link scroll={false}). + // All reader Links use scroll={false} to preserve scroll during in-reader + // nav (natural scroll between chapters updates URL without remount). On + // a fresh mount we must actively position the scroll: resume-to-saved + // if ?resume=1 AND the saved chapter matches; otherwise top. const resumeDoneRef = useRef(false); useLayoutEffect(() => { if (resumeDoneRef.current) return; resumeDoneRef.current = true; + const instantTop = (top: number) => + window.scrollTo({ top, behavior: "instant" as ScrollBehavior }); + if (!resume) { + instantTop(0); + return; + } const p = readProgress(mangaSlug); - if (!p || p.chapter !== startChapterNumber || p.page <= 1) return; + if (!p || p.chapter !== startChapterNumber) { + instantTop(0); + return; + } + if (p.page <= 1 && p.ratio <= 0) { + instantTop(0); + return; + } const scrollToResume = () => { const el = pageElRef.current.get(pageKey(startChapterNumber, p.page)); if (!el) return; - window.scrollTo({ - top: el.offsetTop, - behavior: "instant" as ScrollBehavior, - }); + instantTop( + scrollOffsetFromRatio(el.offsetTop, el.offsetHeight, p.ratio) + ); }; scrollToResume(); requestAnimationFrame(scrollToResume); - }, [mangaSlug, startChapterNumber]); + }, [mangaSlug, startChapterNumber, resume]); useEffect(() => { let rafId = 0; @@ -233,22 +343,29 @@ export function PageReader({ hiddenByScrollRef.current = true; setShowUI(false); } - // Walk only the pages currently inside the 1200px viewport margin - // (maintained by the observer) and pick the one with the greatest - // offsetTop still above y+80 — that's the topmost visible page. - let bestCh = currentChapterNum; - let bestPg = currentPageNum; + // Pick the topmost page whose top edge is above y+80 (top edge of the + // content below the sticky header); walking the small intersecting set. + let bestCh = 0; + let bestPg = 0; let bestTop = -1; + let bestEl: HTMLDivElement | null = null; for (const { chNum, pNum, el } of intersectingPagesRef.current.values()) { const top = el.offsetTop; if (top <= y + 80 && top > bestTop) { bestTop = top; bestCh = chNum; bestPg = pNum; + bestEl = el; } } - if (bestCh !== currentChapterNum) setCurrentChapterNum(bestCh); - if (bestPg !== currentPageNum) setCurrentPageNum(bestPg); + if (!bestEl) return; + currentRatioRef.current = calcScrollRatio( + y, + bestTop, + bestEl.offsetHeight + ); + setCurrentChapterNum(bestCh); + setCurrentPageNum(bestPg); }; const onScroll = () => { if (rafId) return; @@ -259,17 +376,44 @@ export function PageReader({ window.removeEventListener("scroll", onScroll); if (rafId) cancelAnimationFrame(rafId); }; - }, [currentChapterNum, currentPageNum]); + }, []); useEffect(() => { writeProgress(mangaSlug, { chapter: currentChapterNum, page: currentPageNum, + ratio: currentRatioRef.current, }); }, [mangaSlug, currentChapterNum, currentPageNum]); - // Keep URL in sync with the chapter currently in the viewport so browser - // back / reload returns to the latest chapter, not the one first opened. + // Aspect-ratio placeholders stay so layout is preserved; observer + // re-fetches images on scrollback into an unmounted chapter. + useEffect(() => { + const keep = new Set([currentChapterNum]); + if (currentPageNum <= KEEP_PREV_CHAPTER_PAGES) { + keep.add(currentChapterNum - 1); + } + for (const [ch, ctrl] of radiusAbortRef.current) { + if (!keep.has(ch)) { + ctrl.abort(); + radiusAbortRef.current.delete(ch); + } + } + setImages((prev) => { + let changed = false; + const next: Record = {}; + for (const [k, v] of Object.entries(prev)) { + const ch = Number(k.split("-")[0]); + if (keep.has(ch)) { + next[k] = v; + } else { + changed = true; + } + } + return changed ? next : prev; + }); + }, [currentChapterNum, currentPageNum]); + useEffect(() => { const url = `/manga/${mangaSlug}/${currentChapterNum}`; if (window.location.pathname === url) return; @@ -440,6 +584,7 @@ export function PageReader({ {meta.map((p) => { const key = pageKey(chNum, p.number); const url = images[key]; + const isVisible = visibleKeys.has(key); const aspect = p.width > 0 && p.height > 0 ? `${p.width} / ${p.height}` @@ -453,13 +598,18 @@ export function PageReader({ className="relative leading-[0] w-full" style={{ aspectRatio: aspect }} > - {url && ( + {url ? ( {`Page + ) : ( + forceFetchPage(chNum, p.number)} + /> )} ); diff --git a/components/ReadingProgressButton.tsx b/components/ReadingProgressButton.tsx index e4f1353..7c12175 100644 --- a/components/ReadingProgressButton.tsx +++ b/components/ReadingProgressButton.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import Link from "next/link"; +import { readProgress, type ReadingProgress } from "@/lib/progress"; type ChapterLite = { number: number; @@ -13,45 +14,8 @@ type Props = { chapters: ChapterLite[]; }; -export type ReadingProgress = { - chapter: number; - page: number; -}; - -function storageKey(slug: string) { - return `sunnymh:last-read:${slug}`; -} - -export function readProgress(slug: string): ReadingProgress | null { - if (typeof window === "undefined") return null; - const raw = window.localStorage.getItem(storageKey(slug)); - if (!raw) return null; - // New format: JSON { chapter, page } - if (raw.startsWith("{")) { - try { - const parsed = JSON.parse(raw) as ReadingProgress; - if ( - typeof parsed.chapter === "number" && - typeof parsed.page === "number" && - parsed.chapter > 0 && - parsed.page > 0 - ) { - return parsed; - } - } catch { - return null; - } - return null; - } - // Legacy format: bare chapter number - const n = Number(raw); - return Number.isFinite(n) && n > 0 ? { chapter: n, page: 1 } : null; -} - -export function writeProgress(slug: string, progress: ReadingProgress) { - if (typeof window === "undefined") return; - window.localStorage.setItem(storageKey(slug), JSON.stringify(progress)); -} +export { readProgress, writeProgress } from "@/lib/progress"; +export type { ReadingProgress } from "@/lib/progress"; export function ReadingProgressButton({ mangaSlug, chapters }: Props) { const [progress, setProgress] = useState(null); @@ -67,10 +31,13 @@ export function ReadingProgressButton({ mangaSlug, chapters }: Props) { ? chapters.find((c) => c.number === progress.chapter) : null; const target = resumeChapter ?? first; + const href = resumeChapter + ? `/manga/${mangaSlug}/${target.number}?resume=1` + : `/manga/${mangaSlug}/${target.number}`; return ( diff --git a/docker-compose.yml b/docker-compose.yml index dc9ea3e..ed934f7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,4 +11,6 @@ services: R2_SECRET_KEY: ${R2_SECRET_KEY} R2_BUCKET: ${R2_BUCKET} R2_PUBLIC_URL: ${R2_PUBLIC_URL} + HASHIDS_SALT: ${HASHIDS_SALT} + ALLOWED_ORIGINS: ${ALLOWED_ORIGINS} restart: unless-stopped diff --git a/lib/api-guards.ts b/lib/api-guards.ts new file mode 100644 index 0000000..1906d62 --- /dev/null +++ b/lib/api-guards.ts @@ -0,0 +1,32 @@ +import { checkOrigin } from "@/lib/origin-check"; +import { checkRateLimit } from "@/lib/rate-limit"; + +type RateLimitOpts = { + key: string; + limit: number; + windowMs: number; +}; + +type GuardOpts = { + origin?: boolean; + rateLimit?: RateLimitOpts; +}; + +type Handler = (request: Request, ctx: TCtx) => Promise; + +export function withGuards( + opts: GuardOpts, + handler: Handler +): Handler { + return async (request, ctx) => { + if (opts.origin !== false) { + const blocked = checkOrigin(request); + if (blocked) return blocked; + } + if (opts.rateLimit) { + const blocked = checkRateLimit(request, opts.rateLimit); + if (blocked) return blocked; + } + return handler(request, ctx); + }; +} diff --git a/lib/hashids.ts b/lib/hashids.ts new file mode 100644 index 0000000..88e3235 --- /dev/null +++ b/lib/hashids.ts @@ -0,0 +1,15 @@ +import Hashids from "hashids"; + +const salt = process.env.HASHIDS_SALT ?? ""; +const hashids = new Hashids(salt, 8); + +export function encodeId(n: number): string { + return hashids.encode(n); +} + +export function decodeId(s: string): number | null { + const decoded = hashids.decode(s); + if (decoded.length !== 1) return null; + const n = Number(decoded[0]); + return Number.isFinite(n) && n > 0 ? n : null; +} diff --git a/lib/origin-check.ts b/lib/origin-check.ts new file mode 100644 index 0000000..5c22db1 --- /dev/null +++ b/lib/origin-check.ts @@ -0,0 +1,37 @@ +const DEFAULT_ORIGINS = [ + "http://localhost:3000", + "http://localhost:3001", + "http://10.8.0.2:3000", +]; + +function allowedOrigins(): string[] { + const env = process.env.ALLOWED_ORIGINS; + if (!env) return DEFAULT_ORIGINS; + return env + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + +export function checkOrigin(request: Request): Response | null { + const allowed = allowedOrigins(); + const origin = request.headers.get("origin"); + if (origin) { + return allowed.includes(origin) + ? null + : Response.json({ error: "Forbidden" }, { status: 403 }); + } + const referer = request.headers.get("referer"); + if (referer) { + try { + const url = new URL(referer); + const base = `${url.protocol}//${url.host}`; + return allowed.includes(base) + ? null + : Response.json({ error: "Forbidden" }, { status: 403 }); + } catch { + return Response.json({ error: "Forbidden" }, { status: 403 }); + } + } + return Response.json({ error: "Forbidden" }, { status: 403 }); +} diff --git a/lib/progress.ts b/lib/progress.ts new file mode 100644 index 0000000..63ad6d4 --- /dev/null +++ b/lib/progress.ts @@ -0,0 +1,68 @@ +export type ReadingProgress = { + chapter: number; + page: number; + ratio: number; +}; + +export type StorageLike = Pick; + +export function storageKey(slug: string): string { + return `sunnymh:last-read:${slug}`; +} + +function clampRatio(n: unknown): number { + const v = typeof n === "number" ? n : Number(n); + if (!Number.isFinite(v)) return 0; + if (v < 0) return 0; + if (v > 1) return 1; + return v; +} + +export function parseProgress(raw: string | null): ReadingProgress | null { + if (!raw) return null; + if (raw.startsWith("{")) { + try { + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.chapter === "number" && + typeof parsed.page === "number" && + parsed.chapter > 0 && + parsed.page > 0 + ) { + return { + chapter: parsed.chapter, + page: parsed.page, + ratio: clampRatio(parsed.ratio), + }; + } + } catch { + return null; + } + return null; + } + const n = Number(raw); + return Number.isFinite(n) && n > 0 + ? { chapter: n, page: 1, ratio: 0 } + : null; +} + +export function readProgress( + slug: string, + storage: StorageLike | null = defaultStorage() +): ReadingProgress | null { + if (!storage) return null; + return parseProgress(storage.getItem(storageKey(slug))); +} + +export function writeProgress( + slug: string, + progress: ReadingProgress, + storage: StorageLike | null = defaultStorage() +): void { + if (!storage) return; + storage.setItem(storageKey(slug), JSON.stringify(progress)); +} + +function defaultStorage(): StorageLike | null { + return typeof window === "undefined" ? null : window.localStorage; +} diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts new file mode 100644 index 0000000..dbfa680 --- /dev/null +++ b/lib/rate-limit.ts @@ -0,0 +1,60 @@ +export type Bucket = { count: number; resetAt: number }; + +export type RateLimitStore = Pick< + Map, + "get" | "set" | "delete" | "size" +> & { + [Symbol.iterator](): IterableIterator<[string, Bucket]>; +}; + +export type RateLimitDeps = { + now?: () => number; + store?: RateLimitStore; + ipOf?: (request: Request) => string; +}; + +const defaultStore: RateLimitStore = new Map(); + +function defaultIpOf(request: Request): string { + const fwd = request.headers.get("x-forwarded-for"); + if (fwd) return fwd.split(",")[0].trim(); + const real = request.headers.get("x-real-ip"); + if (real) return real.trim(); + return "unknown"; +} + +export function checkRateLimit( + request: Request, + opts: { key: string; limit: number; windowMs: number }, + deps: RateLimitDeps = {} +): Response | null { + const now = (deps.now ?? Date.now)(); + const store = deps.store ?? defaultStore; + const ip = (deps.ipOf ?? defaultIpOf)(request); + const bucketKey = `${opts.key}:${ip}`; + const bucket = store.get(bucketKey); + + if (!bucket || now >= bucket.resetAt) { + store.set(bucketKey, { count: 1, resetAt: now + opts.windowMs }); + if (store.size > 10000) { + for (const [k, b] of store) { + if (b.resetAt <= now) store.delete(k); + } + } + return null; + } + + if (bucket.count >= opts.limit) { + const retryAfter = Math.ceil((bucket.resetAt - now) / 1000); + return Response.json( + { error: "Too many requests" }, + { + status: 429, + headers: { "Retry-After": String(retryAfter) }, + } + ); + } + + bucket.count += 1; + return null; +} diff --git a/lib/scroll-ratio.ts b/lib/scroll-ratio.ts new file mode 100644 index 0000000..4bd181d --- /dev/null +++ b/lib/scroll-ratio.ts @@ -0,0 +1,19 @@ +export function calcScrollRatio( + scrollY: number, + elementTop: number, + elementHeight: number +): number { + const h = elementHeight || 1; + const raw = (scrollY - elementTop) / h; + if (raw < 0) return 0; + if (raw > 1) return 1; + return raw; +} + +export function scrollOffsetFromRatio( + elementTop: number, + elementHeight: number, + ratio: number +): number { + return elementTop + elementHeight * ratio; +} diff --git a/package-lock.json b/package-lock.json index 2e43f7e..507dff0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@aws-sdk/client-s3": "^3.1015.0", "@aws-sdk/s3-request-presigner": "^3.1015.0", "@prisma/client": "^6.19.2", + "hashids": "^2.3.0", "image-size": "^2.0.2", "next": "16.2.1", "prisma": "^6.19.2", @@ -6316,6 +6317,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hashids": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/hashids/-/hashids-2.3.0.tgz", + "integrity": "sha512-ljM73TE/avEhNnazxaj0Dw3BbEUuLC5yYCQ9RSkSUcT4ZSU6ZebdKCIBJ+xT/DnSYW36E9k82GH1Q6MydSIosQ==", + "license": "MIT" + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", diff --git a/package.json b/package.json index 61e34c3..cf0cfa5 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@aws-sdk/client-s3": "^3.1015.0", "@aws-sdk/s3-request-presigner": "^3.1015.0", "@prisma/client": "^6.19.2", + "hashids": "^2.3.0", "image-size": "^2.0.2", "next": "16.2.1", "prisma": "^6.19.2",