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>
This commit is contained in:
Huang Xin
2026-05-03 01:03:35 +08:00
committed by GitHub
parent 176e5df771
commit d1e7b4902c
75 changed files with 5139 additions and 68 deletions
@@ -48,6 +48,8 @@ import Spinner from '@/components/Spinner';
import ModalPortal from '@/components/ModalPortal';
import BookshelfItem, { generateBookshelfItems } from './BookshelfItem';
import SelectModeActions from './SelectModeActions';
import ShareBookDialog from './ShareBookDialog';
import { useAuth } from '@/context/AuthContext';
import GroupingModal from './GroupingModal';
import SetStatusAlert from './SetStatusAlert';
@@ -462,6 +464,31 @@ const Bookshelf: React.FC<BookshelfProps> = ({
};
}, []);
const { user } = useAuth();
const [shareDialogBook, setShareDialogBook] = useState<Book | null>(null);
useEffect(() => {
const handleShareIntent = (event: CustomEvent) => {
const book = (event.detail as { book?: Book } | undefined)?.book;
if (!book) return;
if (!user) {
// Logged-out users can't share their own files; route through the
// login flow instead. The /auth route preserves a return path.
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Sign in to share books'),
timeout: 2500,
});
return;
}
setShareDialogBook(book);
};
eventDispatcher.on('show-share-dialog', handleShareIntent);
return () => {
eventDispatcher.off('show-share-dialog', handleShareIntent);
};
}, [user, _]);
// OverlayScrollbars + Virtuoso integration: Virtuoso manages its own
// scroller; OverlayScrollbars wraps it for overlay scrollbar rendering.
const osRootRef = useRef<HTMLDivElement>(null);
@@ -698,6 +725,11 @@ const Bookshelf: React.FC<BookshelfProps> = ({
onUpdateStatus={updateBooksStatus}
/>
)}
<ShareBookDialog
isOpen={!!shareDialogBook}
book={shareDialogBook}
onClose={() => setShareDialogBook(null)}
/>
</div>
);
};
@@ -252,6 +252,14 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
handleBookUpload(book);
},
});
const shareBookMenuItem = await MenuItem.new({
text: _('Share Book'),
action: async () => {
// Bookshelf.tsx hosts the dialog; we dispatch and let it route
// unauthenticated users into the login flow first.
eventDispatcher.dispatch('show-share-dialog', { book });
},
});
const deleteBookMenuItem = await MenuItem.new({
text: _('Delete'),
action: async () => {
@@ -278,6 +286,11 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
if (!book.uploadedAt && book.downloadedAt) {
menu.append(uploadBookMenuItem);
}
// Share is offered for any local-or-uploaded book; the dialog will trigger
// an upload first if the book hasn't been pushed yet.
if (book.downloadedAt || book.uploadedAt) {
menu.append(shareBookMenuItem);
}
menu.append(deleteBookMenuItem);
menu.popup();
};
@@ -0,0 +1,392 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import {
IoCheckmarkCircle,
IoCopyOutline,
IoLinkOutline,
IoShareSocialOutline,
} from 'react-icons/io5';
import Dialog from '@/components/Dialog';
import SegmentedControl from '@/components/SegmentedControl';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { eventDispatcher } from '@/utils/event';
import { Book } from '@/types/book';
import { SHARE_DEFAULT_EXPIRATION_DAYS, SHARE_EXPIRATION_DAYS } from '@/services/constants';
import { ShareApiError, createShare, revokeShare } from '@/libs/share';
import { formatBytes } from '@/utils/book';
interface ShareBookDialogProps {
isOpen: boolean;
book: Book | null;
// Optional starting position when launched from the reader top bar.
// When present, the dialog shows the "Share current page" toggle and
// includes the cfi in the create payload if the toggle is checked.
cfi?: string | null;
onClose: () => void;
}
interface CreatedShare {
url: string;
expiresAt: string;
hasCfi: boolean;
}
const ShareBookDialog: React.FC<ShareBookDialogProps> = ({ isOpen, book, cfi, onClose }) => {
const _ = useTranslation();
const { appService } = useEnv();
const [expirationDays, setExpirationDays] = useState<number>(SHARE_DEFAULT_EXPIRATION_DAYS);
// Off by default — sharing the current page reveals where the user is in
// the book, which is mild privacy data. Recipient still gets the book; the
// toggle only adds the cfi to the link. Mirrors the dialog's reset effect.
const [includeCfi, setIncludeCfi] = useState(false);
const [generating, setGenerating] = useState(false);
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [created, setCreated] = useState<CreatedShare | null>(null);
const [revoking, setRevoking] = useState(false);
const [copied, setCopied] = useState(false);
const [fileSize, setFileSize] = useState<number | null>(null);
// Reset transient state every time the dialog opens for a new book.
useEffect(() => {
if (!isOpen) return;
setExpirationDays(SHARE_DEFAULT_EXPIRATION_DAYS);
setIncludeCfi(false);
setGenerating(false);
setUploadProgress(null);
setErrorMessage(null);
setCreated(null);
setRevoking(false);
setCopied(false);
setFileSize(null);
}, [isOpen, book?.hash]);
// Look up the book's file size for the Hero metadata line. Mirrors the
// BookDetailModal pattern: ask appService once per (open, book) pair and
// tolerate failures silently — file size is decorative, not required.
useEffect(() => {
if (!isOpen || !book || !appService) return;
let cancelled = false;
(async () => {
try {
const size = await appService.getBookFileSize(book);
if (!cancelled) setFileSize(size);
} catch {
// Local file may be unavailable (cloud-only book); leave size null.
}
})();
return () => {
cancelled = true;
};
}, [isOpen, book?.hash, appService]); // eslint-disable-line react-hooks/exhaustive-deps
const expiryLabel = useMemo(() => {
if (!created) return null;
const date = new Date(created.expiresAt);
return date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
}, [created]);
if (!book) return null;
const handleGenerate = async () => {
if (!book || generating) return;
setGenerating(true);
setErrorMessage(null);
try {
// Upload first if the book lives only locally.
if (!book.uploadedAt && appService) {
try {
await appService.uploadBook(book, (progress) => {
setUploadProgress((progress.progress / progress.total) * 100);
});
} finally {
setUploadProgress(null);
}
}
const response = await createShare({
bookHash: book.hash,
expirationDays,
title: book.title,
author: book.author ?? null,
format: book.format,
cfi: cfi && includeCfi ? cfi : null,
});
setCreated({
url: response.url,
expiresAt: response.expiresAt,
hasCfi: !!(cfi && includeCfi),
});
} catch (err) {
const message =
err instanceof ShareApiError
? err.message
: err instanceof Error
? err.message
: _('Could not create share link');
setErrorMessage(message);
} finally {
setGenerating(false);
}
};
const handleCopy = async () => {
if (!created) return;
try {
await navigator.clipboard.writeText(created.url);
setCopied(true);
eventDispatcher.dispatch('toast', {
type: 'success',
message: _('Link copied'),
timeout: 2000,
});
window.setTimeout(() => setCopied(false), 2000);
} catch {
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Could not copy link'),
timeout: 2000,
});
}
};
const handleNativeShare = async () => {
if (!created) return;
const title = book.title;
const url = created.url;
// Tauri (mobile + desktop windowed): try sharekit. If the import or
// call throws, the plugin isn't usable on this platform — fall through
// to web/copy. If it resolves (success OR user dismissed the sheet
// without picking), we're done; do NOT silently copy on top of that.
if (appService?.isMobileApp || appService?.hasWindow) {
let sharekitWorked = false;
try {
const { shareText } = await import('@choochmeque/tauri-plugin-sharekit-api');
await shareText(`${title}\n${url}`);
sharekitWorked = true;
} catch (err) {
console.error('shareText failed; falling back:', err);
}
if (sharekitWorked) return;
}
// Web: navigator.share rejects with AbortError when the user dismisses
// the share sheet — that's an explicit "don't share" choice, not a
// signal to silently copy as a "helpful" fallback. Return on either
// resolve or reject; only fall through if the API isn't supported at all.
if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') {
try {
await navigator.share({ title, url });
} catch {
// User dismissed or share-time error; respect the choice.
}
return;
}
// Last resort: clipboard copy. Reached only when no native share method
// is available on this platform (e.g., Linux desktop without sharekit).
await handleCopy();
};
const handleRevoke = async () => {
if (!created || revoking) return;
if (!created.url) return;
// Extract token from the canonical URL we received from the server.
const segments = created.url.split('/');
const token = segments[segments.length - 1];
if (!token) return;
setRevoking(true);
try {
await revokeShare(token);
eventDispatcher.dispatch('toast', {
type: 'success',
message: _('Share revoked'),
timeout: 2000,
});
onClose();
} catch (err) {
const message = err instanceof Error ? err.message : _('Could not revoke share');
setErrorMessage(message);
} finally {
setRevoking(false);
}
};
return (
<Dialog
isOpen={isOpen}
title={_('Share Book')}
onClose={onClose}
boxClassName='sm:min-w-[480px] sm:max-w-[480px] sm:h-auto sm:max-h-[90%]'
contentClassName='!px-6 !py-4'
>
<div className='flex flex-col gap-5 pt-2'>
{/* Hero: cover + metadata. Cover gets a real shadow so it reads as a
physical book rather than a bordered thumbnail. Author + format
collapse into a single muted line for cleaner hierarchy. */}
<div className='flex items-center gap-4'>
{book.coverImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={book.coverImageUrl}
alt=''
className='h-28 w-20 shrink-0 rounded-lg object-cover shadow-md'
loading='lazy'
/>
) : (
<div className='bg-base-200 flex h-28 w-20 shrink-0 items-center justify-center rounded-lg shadow-md'>
<IoLinkOutline className='text-base-content/30 h-8 w-8' aria-hidden='true' />
</div>
)}
<div className='min-w-0 flex-1'>
<div className='text-base-content line-clamp-2 text-lg font-semibold leading-tight'>
{book.title}
</div>
<div className='text-base-content/60 mt-1.5 truncate text-sm'>
{[book.author, book.format, formatBytes(fileSize)].filter(Boolean).join(' · ')}
</div>
</div>
</div>
{!created ? (
<>
{/* Settings-card group: each row is a label + control, separated
by a hairline divider. Mirrors the iOS native settings idiom. */}
<div className='bg-base-200/60 divide-base-content/5 divide-y overflow-hidden rounded-2xl'>
{/* Both rows share min-h-12 so the segmented control's internal
pill height doesn't make this row visibly taller than the
toggle row below. */}
<div className='flex min-h-12 items-center justify-between gap-3 px-4 py-2'>
<span className='text-base-content text-sm font-medium'>{_('Expires in')}</span>
<SegmentedControl<number>
ariaLabel={_('Expires in')}
value={expirationDays}
onChange={setExpirationDays}
disabled={generating}
options={SHARE_EXPIRATION_DAYS.map((n) => ({
value: n,
label: _('{{count}} days', { count: n }),
}))}
/>
</div>
{cfi && (
<label className='flex min-h-12 cursor-pointer select-none items-center justify-between gap-3 px-4 py-2'>
<span className='text-base-content text-sm font-medium'>
{_('Share current page')}
</span>
<input
type='checkbox'
className='toggle toggle-primary'
checked={includeCfi}
onChange={(e) => setIncludeCfi(e.target.checked)}
disabled={generating}
/>
</label>
)}
</div>
{uploadProgress !== null && (
<div>
<div className='text-base-content/70 mb-1.5 text-xs'>
{_('Uploading book…')} {Math.round(uploadProgress)}%
</div>
<progress
className='progress progress-primary w-full'
value={uploadProgress}
max={100}
/>
</div>
)}
{errorMessage && (
<p className='text-error text-xs' role='alert'>
{errorMessage}
</p>
)}
<button
type='button'
onClick={handleGenerate}
disabled={generating}
className='btn btn-primary btn-block gap-2 rounded-2xl'
>
<IoLinkOutline className='h-5 w-5' aria-hidden='true' />
{generating ? _('Generating…') : _('Generate share link')}
</button>
</>
) : (
<>
{created.hasCfi && (
<div className='text-primary inline-flex items-center gap-1.5 self-start text-xs font-medium'>
<IoCheckmarkCircle className='h-4 w-4' aria-hidden='true' />
{_('Includes your current page')}
</div>
)}
{/* URL pill: softer than a bordered input, with the copy button
as an inline action chip. */}
<div className='bg-base-200/60 flex items-center gap-2 rounded-2xl p-2 pl-4'>
<input
type='text'
readOnly
value={created.url}
aria-label={_('Share URL')}
className='text-base-content min-w-0 flex-1 bg-transparent font-mono text-xs outline-none'
onFocus={(e) => e.currentTarget.select()}
/>
<button
type='button'
onClick={handleCopy}
className={`btn btn-sm gap-1 rounded-xl ${copied ? 'btn-success' : 'btn-primary'}`}
aria-label={_('Copy link')}
>
{copied ? (
<IoCheckmarkCircle className='h-4 w-4' aria-hidden='true' />
) : (
<IoCopyOutline className='h-4 w-4' aria-hidden='true' />
)}
{copied ? _('Copied') : _('Copy')}
</button>
</div>
<button
type='button'
onClick={handleNativeShare}
className='btn btn-block gap-2 rounded-2xl'
>
<IoShareSocialOutline className='h-5 w-5' aria-hidden='true' />
{_('Share via…')}
</button>
<p className='text-base-content/60 text-center text-xs'>
{_('Expires {{date}}', { date: expiryLabel ?? '' })}
<span className='mx-1.5'>·</span>
<button
type='button'
onClick={handleRevoke}
disabled={revoking}
className='link link-error text-xs'
>
{revoking ? _('Revoking…') : _('Revoke share')}
</button>
</p>
{errorMessage && (
<p className='text-error text-xs' role='alert'>
{errorMessage}
</p>
)}
</>
)}
</div>
</Dialog>
);
};
export default ShareBookDialog;
@@ -42,6 +42,7 @@ import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { useAppUrlIngress } from '@/hooks/useAppUrlIngress';
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { useOpenAnnotationLink } from '@/hooks/useOpenAnnotationLink';
import { useOpenShareLink } from '@/hooks/useOpenShareLink';
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
import { SelectedFile, useFileSelector } from '@/hooks/useFileSelector';
import { lockScreenOrientation, selectDirectory } from '@/utils/bridge';
@@ -164,6 +165,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
useAppUrlIngress();
useOpenWithBooks();
useOpenAnnotationLink();
useOpenShareLink();
useTransferQueue(libraryLoaded);
const { pullLibrary, pushLibrary } = useBooksSync();