forked from akai/readest
d1e7b4902c
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>
322 lines
13 KiB
TypeScript
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;
|