Files
readest/apps/readest-app/src/app/s/page.tsx
T
Huang Xin d1e7b4902c feat(share): time-limited share links with cfi-aware imports (#4037)
Add a Share Book feature that generates an expiring HTTPS share URL plus a
parallel readest://share/{token} deep link. Recipients land on /s/{token},
where logged-in users can one-tap "Add to my library" (R2 server-side
byte-copy) and anonymous users download the book or open it in the app.
Sharers manage active links from a dedicated "Manage Shared Links" panel
under user settings.

Highlights:
- 9 new App Router endpoints under /api/share (create, [token], cover,
  og.png, download, download/confirm, import, revoke, list).
- /s landing page with branded next/og chat unfurl image and SSR auth-cookie
  detection so logged-in recipients see "Add to my library" as the primary
  action without layout shift.
- Server-side R2 byte-copy for /import preserves the project's existing
  invariant that every files.file_key is prefixed with its row's user_id;
  stats / purge / delete / download routes work unchanged. URL-encodes the
  copy source so titles with spaces or '&' don't break the copy.
- Universal 7-day expiry cap, no tier differentiation, no "never". DMCA-risk
  reduction. Picker defaults to 3 days.
- Position-aware shares: "Share current page" toggle (off by default for
  privacy) attaches the sharer's CFI; recipient lands at the same paragraph.
- Per-user 50-share cap, rate limiting via Cache-Control: no-store on
  token-bearing responses, atomic SQL increment for download_count via a
  SECURITY DEFINER function so the public confirm beacon stays safe under
  concurrent fire.
- Soft revocation: presigned download URLs (5-min TTL) cannot be cancelled
  before TTL; documented as accepted v1 behavior.
- token + token_hash hybrid storage: public endpoints look up by hash and
  never select the raw token, so accidental SELECT-* leakage on a public
  route can't expose the bearer credential.
- Mobile / desktop Tauri share via tauri-plugin-sharekit; web falls back to
  navigator.share with a clipboard fallback when no native share method
  exists. Share-sheet dismissal no longer silently copies.

UI:
- New Dialog with a settings-card group: iOS-style segmented duration picker
  + toggle slider for "Share current page", on a single row each.
- Reader top-bar Share button, library context-menu Share entry, manage-
  shares list with cover thumbnails and overflow menu.
- New <SegmentedControl> primitive in src/components for reuse.

Coverage:
- Unit tests for token utils + URL parser (20 new tests, full suite at 3445).
- 31 locales translated for all new strings; en plurals hand-added per the
  project's hand-curated en convention.

DB migration in docker/volumes/db/migrations/002_add_book_shares.sql adds
the book_shares table, RLS policies, and the increment_book_share_download
RPC. Migration is idempotent.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:03:35 +02:00

322 lines
13 KiB
TypeScript

'use client';
import { Suspense, useEffect, useState } from 'react';
import Image from 'next/image';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import {
IoAlertCircleOutline,
IoBookOutline,
IoCloudDownloadOutline,
IoLibraryOutline,
IoOpenOutline,
} from 'react-icons/io5';
import { DOWNLOAD_READEST_URL } from '@/services/constants';
import { useTranslation, type TranslationFunc } from '@/hooks/useTranslation';
import { useAuth } from '@/context/AuthContext';
import { BrandHeader } from '@/components/landing/BrandHeader';
import { Card } from '@/components/landing/Card';
import { PageFooter } from '@/components/landing/PageFooter';
import { confirmDownload, getShare, importShare, type ShareMetadata } from '@/libs/share';
import { formatBytes } from '@/utils/book';
const formatExpiry = (iso: string, _: TranslationFunc): string => {
const ms = new Date(iso).getTime() - Date.now();
const days = Math.round(ms / (24 * 60 * 60 * 1000));
const hours = Math.round(ms / (60 * 60 * 1000));
if (days >= 1) return _('Expires in {{count}} days', { count: days });
if (hours > 0) return _('Expires in {{count}} hours', { count: hours });
return _('Expiring soon');
};
const ShareLanding = () => {
const _ = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
const pathname = usePathname();
const { user } = useAuth();
// Resolve the token from either the rewritten query (?token=) or the pretty
// path (/s/{token}). The next.config.mjs rewrite handles the web build; the
// pathname fallback handles Tauri (output: 'export', no rewrites), dev
// sessions where the rewrite isn't picked up without a server restart, and
// any deploy where the rewrite gets misconfigured. Mirrors src/app/o/page.tsx.
let token = searchParams?.get('token') ?? '';
if (!token && pathname) {
const segments = pathname.split('/').filter(Boolean);
if (segments[0] === 's' && segments[1]) {
token = segments[1];
}
}
const [meta, setMeta] = useState<ShareMetadata | null>(null);
const [loadError, setLoadError] = useState<{ status: number; message: string } | null>(null);
const [importing, setImporting] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
useEffect(() => {
if (!token) {
setLoadError({ status: 400, message: _('Missing share token') });
return;
}
let cancelled = false;
(async () => {
try {
const data = await getShare(token);
if (!cancelled) setMeta(data);
} catch (err) {
if (!cancelled) {
const status =
err && typeof err === 'object' && 'status' in err && typeof err.status === 'number'
? err.status
: 500;
setLoadError({ status, message: err instanceof Error ? err.message : 'Unknown error' });
}
}
})();
return () => {
cancelled = true;
};
}, [token, _]);
const downloadHref = `/api/share/${encodeURIComponent(token)}/download`;
const appHref = `readest://share/${encodeURIComponent(token)}`;
const handleDownloadClick = () => {
// Best-effort analytics ping. Doesn't block the navigation.
if (token) confirmDownload(token);
};
const handleAddToLibrary = async () => {
if (!token || importing) return;
setImporting(true);
setImportError(null);
try {
const result = await importShare(token);
// The reader resolves `ids` via getBookByHash, so pass the book hash —
// not the `files.id` UUID. Same identifier used by /reader/:hash links.
const params = new URLSearchParams();
params.set('ids', result.bookHash);
if (result.cfi) params.set('cfi', result.cfi);
router.push(`/reader?${params.toString()}`);
} catch (err) {
setImporting(false);
const message = err instanceof Error ? err.message : _('Could not add to your library');
setImportError(message);
}
};
if (loadError) {
// Pick a body copy that reflects the actual failure mode. Network /
// unknown failures get the generic "try again" message; only confirmed
// expired/revoked/not-found responses get the "no longer available" copy.
// This makes misconfigurations debuggable without inspecting devtools.
const isUnavailable = loadError.status === 410 || loadError.status === 404;
const isInvalidToken = loadError.status === 400;
const heading = isUnavailable
? _('This share link is no longer available')
: isInvalidToken
? _("This link can't be opened")
: _('Could not load shared book');
const body = isUnavailable
? _('The original link may have expired or been revoked.')
: isInvalidToken
? _('The share link is missing required information.')
: _('Please check your connection and try again.');
return (
<main className='bg-base-200 flex min-h-dvh flex-col items-center justify-center p-4 sm:p-8'>
<Card>
<div className='flex flex-col items-center text-center'>
<div className='bg-base-200 mb-4 flex h-16 w-16 items-center justify-center rounded-2xl'>
<IoAlertCircleOutline className='text-base-content/60 h-8 w-8' />
</div>
<h1 className='text-base-content text-2xl font-semibold'>{heading}</h1>
<p className='text-base-content/70 mt-2 text-sm'>{body}</p>
<a
href={DOWNLOAD_READEST_URL}
target='_blank'
rel='noopener'
className='btn btn-ghost btn-block mt-6'
>
{_('Get Readest')}
</a>
</div>
</Card>
<PageFooter tagline={_('Open-source ebook reader for everyone, on every device.')} />
</main>
);
}
if (!meta) {
return (
<main className='bg-base-200 flex min-h-dvh flex-col items-center justify-center p-4 sm:p-8'>
<Card>
<BrandHeader title={_('Loading shared book…')} alt={_('Readest logo')} />
<div
className='mt-6 flex flex-col items-center gap-3 py-4'
role='status'
aria-live='polite'
>
<span className='loading loading-dots loading-md text-primary' aria-hidden='true' />
</div>
</Card>
<PageFooter tagline={_('Open-source ebook reader for everyone, on every device.')} />
</main>
);
}
const coverSrc = meta.hasCover ? `/api/share/${encodeURIComponent(token)}/cover` : null;
const expiryLabel = formatExpiry(meta.expiresAt, _);
return (
<main className='bg-base-200 flex min-h-dvh flex-col items-center justify-center p-4 sm:p-6'>
{/* Inline card instead of <Card>: this surface needs a wider container
on desktop (sm:max-w-2xl) and a horizontal cover+content layout
that the shared Card primitive doesn't support. The /o landing
stays on the narrow Card; only /s gets the wider treatment. */}
<div className='bg-base-100 border-base-300/60 mx-auto w-full max-w-md overflow-hidden rounded-2xl border shadow-xl sm:max-w-2xl'>
{/* Branded header — small Readest mark + headline. Stays compact so
the card still fits on common viewports without scroll, but
gives the page identity at a glance. */}
<div className='flex flex-col items-center gap-2 px-5 pb-2 pt-5 sm:px-7 sm:pb-3 sm:pt-7'>
<Image
src='/icon.png'
alt={_('Readest logo')}
width={40}
height={40}
priority
className='rounded-lg'
/>
<span className='text-base-content text-base font-semibold'>{_('Shared with you')}</span>
</div>
<div className='flex flex-col items-center gap-5 px-5 pb-5 sm:flex-row sm:items-stretch sm:gap-7 sm:px-7 sm:pb-7'>
{/* Cover: dominant visual anchor. aspect-[2/3] keeps the box the
right shape whether or not the image loaded — stable layout
while the cover fetches. */}
<div className='aspect-[2/3] w-32 shrink-0 overflow-hidden rounded-lg shadow-lg sm:w-40 sm:self-center'>
{coverSrc ? (
// Plain <img>: source is a presigned URL that varies per
// request, so next/image's loader gives no win.
// eslint-disable-next-line @next/next/no-img-element
<img src={coverSrc} alt='' className='h-full w-full object-cover' loading='eager' />
) : (
<div className='bg-base-200 flex h-full w-full items-center justify-center'>
<IoBookOutline className='text-base-content/30 h-10 w-10' aria-hidden='true' />
</div>
)}
</div>
{/* Content column: title, author, meta line, then actions.
Centered on mobile, left-aligned on desktop where it sits to
the right of the cover. */}
<div className='flex min-w-0 flex-1 flex-col items-center text-center sm:items-start sm:justify-center sm:text-left'>
<h1 className='text-base-content line-clamp-3 text-xl font-semibold leading-tight sm:text-2xl'>
{meta.title}
</h1>
{meta.author && (
<p className='text-base-content/70 mt-1 truncate text-sm'>{meta.author}</p>
)}
<p className='text-base-content/50 mt-2 text-xs'>
{meta.format.toUpperCase()} · {formatBytes(meta.size)} · {expiryLabel}
</p>
<div className='mt-4 flex w-full flex-col gap-2 sm:mt-5'>
{user ? (
<>
<button
type='button'
onClick={handleAddToLibrary}
disabled={importing}
className='btn btn-primary btn-block flex-nowrap gap-2 whitespace-nowrap rounded-xl'
>
<IoLibraryOutline className='h-5 w-5' aria-hidden='true' />
{importing ? _('Adding…') : _('Add to my library')}
</button>
{/* Secondary actions side-by-side. flex-nowrap +
whitespace-nowrap override daisyUI's default
`flex-wrap: wrap` on .btn so the icon and label stay
on one line in narrow viewports. */}
<div className='flex gap-2'>
<a
href={downloadHref}
onClick={handleDownloadClick}
className='btn btn-ghost btn-sm flex-1 flex-nowrap gap-1.5 whitespace-nowrap rounded-xl'
>
<IoCloudDownloadOutline className='h-4 w-4' aria-hidden='true' />
{_('Download')}
</a>
<a
href={appHref}
className='btn btn-ghost btn-sm flex-1 flex-nowrap gap-1.5 whitespace-nowrap rounded-xl'
>
<IoOpenOutline className='h-4 w-4' aria-hidden='true' />
{_('Open in app')}
</a>
</div>
{importError && (
<p className='text-error mt-1 text-center text-xs sm:text-left' role='alert'>
{importError}
</p>
)}
</>
) : (
<>
{/* Anonymous flow: only 2 actions, so they sit side-by-side
on one row. The 3-action logged-in flow above keeps its
stacked design (primary + 2 secondary). Download stays
`btn-primary` to keep the "main thing" hierarchy clear,
Open in app is ghost. Labels match the logged-in flow's
shorter forms to avoid wrap in the tighter row layout. */}
<div className='flex w-full gap-2'>
<a
href={downloadHref}
onClick={handleDownloadClick}
className='btn btn-primary flex-1 flex-nowrap gap-1.5 whitespace-nowrap rounded-xl'
>
<IoCloudDownloadOutline className='h-5 w-5' aria-hidden='true' />
{_('Download')}
</a>
<a
href={appHref}
className='btn btn-ghost flex-1 flex-nowrap gap-1.5 whitespace-nowrap rounded-xl'
>
<IoOpenOutline className='h-5 w-5' aria-hidden='true' />
{_('Open in app')}
</a>
</div>
<p className='text-base-content/60 mt-1 text-center text-xs sm:text-left'>
{_("Don't have Readest?")}{' '}
<a
href={DOWNLOAD_READEST_URL}
target='_blank'
rel='noopener'
className='text-primary font-medium hover:underline'
>
{_('Download Readest')}
</a>
</p>
</>
)}
</div>
</div>
</div>
</div>
<PageFooter tagline={_('Open-source ebook reader for everyone, on every device.')} />
</main>
);
};
const Page = () => {
// useSearchParams must be wrapped in Suspense per Next 16 client-component
// contract, mirrors src/app/o/page.tsx.
return (
<Suspense fallback={null}>
<ShareLanding />
</Suspense>
);
};
export default Page;