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
@@ -55,6 +55,7 @@ interface AccountActionsProps {
onRestorePurchase?: () => void;
onManageSubscription?: () => void;
onManageStorage?: () => void;
onManageSharedLinks?: () => void;
}
const AccountActions: React.FC<AccountActionsProps> = ({
@@ -67,6 +68,7 @@ const AccountActions: React.FC<AccountActionsProps> = ({
onRestorePurchase,
onManageSubscription,
onManageStorage,
onManageSharedLinks,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
@@ -116,6 +118,14 @@ const AccountActions: React.FC<AccountActionsProps> = ({
{_('Manage Storage')}
</button>
)}
{onManageSharedLinks && (
<button
onClick={onManageSharedLinks}
className='w-full rounded-lg bg-purple-100 px-6 py-3 font-medium text-purple-600 transition-colors hover:bg-purple-200 md:w-auto'
>
{_('Manage Shared Links')}
</button>
)}
<button
onClick={onResetPassword}
className='w-full rounded-lg bg-gray-200 px-6 py-3 font-medium text-gray-800 transition-colors hover:bg-gray-300 md:w-auto'
@@ -0,0 +1,350 @@
'use client';
import React, { useCallback, useEffect, useState } from 'react';
import {
IoBookOutline,
IoCopyOutline,
IoLinkOutline,
IoShareSocialOutline,
IoTrashOutline,
} from 'react-icons/io5';
import { PiDotsThreeVerticalBold } from 'react-icons/pi';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { eventDispatcher } from '@/utils/event';
import { listShares, revokeShare } from '@/libs/share';
import { formatBytes } from '@/utils/book';
import Dropdown from '@/components/Dropdown';
import Menu from '@/components/Menu';
import MenuItem from '@/components/MenuItem';
interface ShareRow {
id: string;
token: string;
bookHash: string;
title: string;
author: string | null;
format: string;
size: number;
hasCfi: boolean;
expiresAt: string;
revokedAt: string | null;
downloadCount: number;
createdAt: string;
}
type Status = 'active' | 'expiring' | 'expired' | 'revoked';
const getStatus = (row: ShareRow): Status => {
if (row.revokedAt) return 'revoked';
const ms = new Date(row.expiresAt).getTime() - Date.now();
if (ms <= 0) return 'expired';
if (ms < 24 * 60 * 60 * 1000) return 'expiring';
return 'active';
};
// Small inline cover thumbnail. Falls back to a book-icon placeholder when the
// cover endpoint 404s (no cover uploaded) or 410s (share revoked/expired).
const ShareCover: React.FC<{ token: string; alt: string }> = ({ token, alt }) => {
const [failed, setFailed] = useState(false);
if (failed) {
return (
<div className='border-base-300 bg-base-200 flex h-14 w-10 shrink-0 items-center justify-center rounded border'>
<IoBookOutline className='text-base-content/40 h-5 w-5' aria-hidden='true' />
</div>
);
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/share/${encodeURIComponent(token)}/cover`}
alt={alt}
onError={() => setFailed(true)}
className='border-base-300 bg-base-200 h-14 w-10 shrink-0 rounded border object-cover'
loading='lazy'
/>
);
};
const SharedLinksSection: React.FC = () => {
const _ = useTranslation();
const { appService } = useEnv();
const [rows, setRows] = useState<ShareRow[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [shareUrlBase, setShareUrlBase] = useState<string>('');
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadPage = useCallback(
async (next: string | null, append: boolean) => {
if (append) setLoadingMore(true);
else setLoading(true);
setError(null);
try {
const response = await listShares(next);
const incoming = response.shares as unknown as ShareRow[];
setShareUrlBase(
(response as unknown as { shareUrlBase?: string }).shareUrlBase ?? shareUrlBase,
);
setRows((prev) => (append ? [...prev, ...incoming] : incoming));
setCursor(response.nextCursor);
} catch (err) {
setError(err instanceof Error ? err.message : _('Could not load your shares'));
} finally {
setLoading(false);
setLoadingMore(false);
}
},
[_, shareUrlBase],
);
useEffect(() => {
void loadPage(null, false);
// Initial load only; pagination is driven by the Load more button.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const buildUrl = (row: ShareRow): string | null => {
if (!row.token || !shareUrlBase) return null;
return `${shareUrlBase}/${row.token}`;
};
const handleCopy = async (row: ShareRow) => {
const url = buildUrl(row);
if (!url) return;
try {
await navigator.clipboard.writeText(url);
eventDispatcher.dispatch('toast', {
type: 'success',
message: _('Link copied'),
timeout: 2000,
});
} catch {
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Could not copy link'),
timeout: 2000,
});
}
};
const handleNativeShare = async (row: ShareRow) => {
const url = buildUrl(row);
if (!url) return;
const title = row.title;
// See ShareBookDialog.handleNativeShare for the rationale: only fall
// through to copy when no native share method is available at all.
// User-dismissal of the share sheet must NOT silently copy the link.
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;
}
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;
}
await handleCopy(row);
};
const handleRevoke = async (row: ShareRow) => {
if (!row.token) return;
const previous = rows;
// Optimistic remove. On failure, restore.
setRows((current) => current.filter((r) => r.id !== row.id));
try {
await revokeShare(row.token);
eventDispatcher.dispatch('toast', {
type: 'success',
message: _('Share revoked'),
timeout: 2000,
});
} catch (err) {
setRows(previous);
eventDispatcher.dispatch('toast', {
type: 'error',
message: err instanceof Error ? err.message : _('Could not revoke share'),
timeout: 2500,
});
}
};
const renderExpiry = (row: ShareRow) => {
const status = getStatus(row);
if (status === 'revoked') return _('Revoked');
const ms = new Date(row.expiresAt).getTime() - Date.now();
if (status === 'expired') return _('Expired');
if (status === 'expiring') {
const hours = Math.max(1, Math.round(ms / (60 * 60 * 1000)));
return _('Expires in {{count}} hours', { count: hours });
}
const days = Math.max(1, Math.round(ms / (24 * 60 * 60 * 1000)));
return _('Expires in {{count}} days', { count: days });
};
const badgeClass = (status: Status) => {
if (status === 'active') return 'badge badge-info';
if (status === 'expiring') return 'badge badge-warning';
return 'badge badge-ghost';
};
if (loading) {
return (
<section>
<h3 className='text-base-content text-lg font-semibold'>{_('Shared books')}</h3>
<div className='mt-4 flex flex-col gap-2'>
{[0, 1, 2].map((k) => (
<div key={k} className='bg-base-200 h-16 w-full animate-pulse rounded-lg' />
))}
</div>
</section>
);
}
if (error) {
return (
<section>
<h3 className='text-base-content text-lg font-semibold'>{_('Shared books')}</h3>
<p className='text-error mt-2 text-sm'>{error}</p>
</section>
);
}
if (rows.length === 0) {
return (
<section>
<div className='flex items-baseline justify-between'>
<h3 className='text-base-content text-lg font-semibold'>{_('Shared books')}</h3>
</div>
<div className='border-base-300 mt-4 flex flex-col items-center gap-3 rounded-2xl border border-dashed p-8 text-center'>
<div className='bg-base-200 flex h-16 w-16 items-center justify-center rounded-2xl'>
<IoLinkOutline className='text-base-content/60 h-8 w-8' aria-hidden='true' />
</div>
<p className='text-base-content text-base font-semibold'>
{_("You haven't shared any books yet")}
</p>
<p className='text-base-content/70 text-sm'>
{_('Open a book and tap Share to send it to a friend.')}
</p>
</div>
</section>
);
}
const activeCount = rows.filter(
(r) => getStatus(r) === 'active' || getStatus(r) === 'expiring',
).length;
return (
<section>
<div className='flex items-baseline justify-between'>
<h3 className='text-base-content text-lg font-semibold'>{_('Shared books')}</h3>
<span className='text-base-content/60 text-xs'>
{_('{{count}} active', { count: activeCount })}
</span>
</div>
<ul className='border-base-300 mt-4 divide-y divide-[var(--fallback-bc,oklch(var(--bc)/0.1))] overflow-hidden rounded-2xl border'>
{rows.map((row) => {
const status = getStatus(row);
const dimmed = status === 'expired' || status === 'revoked';
return (
<li
key={row.id}
className={`flex items-center gap-3 p-3 sm:p-4 ${dimmed ? 'opacity-60' : ''}`}
>
<ShareCover token={row.token} alt={row.title} />
<div className='min-w-0 flex-1'>
<div className='text-base-content truncate text-sm font-medium'>{row.title}</div>
<div className='text-base-content/60 truncate text-xs'>
{row.author ?? '—'} · {row.format.toUpperCase()} · {formatBytes(row.size)}
</div>
<div className='mt-1 flex items-center gap-2 text-xs'>
<span className={badgeClass(status)}>{renderExpiry(row)}</span>
{row.downloadCount > 0 && (
<span className='text-base-content/60'>
{_('{{count}} downloads', { count: row.downloadCount })}
</span>
)}
{row.hasCfi && (
<span className='text-base-content/60'>{_('starts at saved page')}</span>
)}
</div>
</div>
{!dimmed && (
<div className='flex items-center gap-1'>
<button
type='button'
title={_('Copy link')}
aria-label={_('Copy link')}
onClick={() => handleCopy(row)}
className='btn btn-ghost btn-sm'
>
<IoCopyOutline className='h-4 w-4' aria-hidden='true' />
</button>
<button
type='button'
title={_('Share via…')}
aria-label={_('Share via…')}
onClick={() => handleNativeShare(row)}
className='btn btn-ghost btn-sm'
>
<IoShareSocialOutline className='h-4 w-4' aria-hidden='true' />
</button>
{/*
* Use the project's Dropdown component (Headless-UI-style with
* an Overlay backdrop) instead of daisyUI's <details>/<summary>
* pattern. The bare daisyUI version doesn't position correctly
* inside this scrollable settings layout — it gets clipped by
* the rounded-2xl border on the surrounding <ul>.
*/}
<Dropdown
label={_('More actions')}
className='dropdown-bottom dropdown-end'
buttonClassName='btn btn-ghost btn-sm'
toggleButton={
<PiDotsThreeVerticalBold className='h-4 w-4' aria-hidden='true' />
}
>
<Menu className='dropdown-content bg-base-100 rounded-box z-[1] w-44 border p-1 shadow'>
<MenuItem
label={_('Revoke share')}
Icon={IoTrashOutline}
onClick={() => handleRevoke(row)}
/>
</Menu>
</Dropdown>
</div>
)}
</li>
);
})}
</ul>
{cursor && (
<button
type='button'
onClick={() => loadPage(cursor, true)}
disabled={loadingMore}
className='btn btn-ghost btn-block mt-3'
>
{loadingMore ? _('Loading…') : _('Load more')}
</button>
)}
</section>
);
};
export default SharedLinksSection;