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:
@@ -0,0 +1,93 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
export interface SegmentedControlOption<T extends string | number> {
|
||||
value: T;
|
||||
label: React.ReactNode;
|
||||
// Optional accessible label when `label` is a non-text node (icon, badge…).
|
||||
ariaLabel?: string;
|
||||
// Per-option disable, in addition to the group-level `disabled` prop.
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface SegmentedControlProps<T extends string | number> {
|
||||
options: ReadonlyArray<SegmentedControlOption<T>>;
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
// Group-level accessible name (rendered as `aria-label` on the wrapper).
|
||||
ariaLabel?: string;
|
||||
// Group-level disable. Per-option `disabled` is OR'd with this.
|
||||
disabled?: boolean;
|
||||
size?: 'sm' | 'md';
|
||||
// Stretch segments to fill the container; otherwise they hug their content.
|
||||
fullWidth?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// iOS-style segmented control: a subtle track holds N equally-weighted
|
||||
// segments. The active one rises on top as a filled pill with a slight
|
||||
// shadow; inactive ones are flat, transparent, and slightly muted so the
|
||||
// group reads as a single control rather than a row of separate buttons.
|
||||
//
|
||||
// Generic over the value type so callers preserve number / string / enum
|
||||
// semantics:
|
||||
//
|
||||
// <SegmentedControl<number>
|
||||
// options={[{ value: 1, label: '1 day' }, ...]}
|
||||
// value={days}
|
||||
// onChange={setDays}
|
||||
// />
|
||||
const SegmentedControl = <T extends string | number>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
disabled,
|
||||
size = 'sm',
|
||||
fullWidth = false,
|
||||
className,
|
||||
}: SegmentedControlProps<T>) => {
|
||||
const sizeClasses = size === 'md' ? 'px-4 py-1.5 text-sm' : 'px-3 py-1 text-sm';
|
||||
|
||||
return (
|
||||
<div
|
||||
role='radiogroup'
|
||||
aria-label={ariaLabel}
|
||||
className={clsx(
|
||||
'bg-base-300/60 rounded-lg p-0.5',
|
||||
fullWidth ? 'flex w-full' : 'inline-flex',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{options.map((option) => {
|
||||
const selected = option.value === value;
|
||||
const optionDisabled = !!disabled || !!option.disabled;
|
||||
return (
|
||||
<button
|
||||
key={String(option.value)}
|
||||
type='button'
|
||||
role='radio'
|
||||
aria-checked={selected}
|
||||
aria-label={option.ariaLabel}
|
||||
disabled={optionDisabled}
|
||||
onClick={() => {
|
||||
if (!selected) onChange(option.value);
|
||||
}}
|
||||
className={clsx(
|
||||
'rounded-md font-medium transition-colors disabled:opacity-50',
|
||||
fullWidth && 'flex-1',
|
||||
sizeClasses,
|
||||
selected
|
||||
? 'bg-primary text-primary-content shadow-sm'
|
||||
: 'text-base-content/70 hover:text-base-content',
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SegmentedControl;
|
||||
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface BrandHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
alt: string;
|
||||
}
|
||||
|
||||
export const BrandHeader: React.FC<BrandHeaderProps> = ({ title, subtitle, alt }) => (
|
||||
<div className='flex flex-col items-center text-center'>
|
||||
<Image src='/icon.png' alt={alt} width={64} height={64} priority className='mb-4 rounded-2xl' />
|
||||
<h1 className='text-base-content text-2xl font-semibold'>{title}</h1>
|
||||
{subtitle && <p className='text-base-content/70 mt-2 text-sm'>{subtitle}</p>}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
// Card primitive shared by /o (annotation deeplink) and /s (share link)
|
||||
// landing pages. Mirrors the visual reference in src/app/o/page.tsx so the
|
||||
// two surfaces feel like the same Readest product.
|
||||
export const Card: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<div className='bg-base-100 border-base-300 mx-4 w-full max-w-md rounded-2xl border p-6 shadow-md sm:p-8'>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface PageFooterProps {
|
||||
tagline: string;
|
||||
}
|
||||
|
||||
export const PageFooter: React.FC<PageFooterProps> = ({ tagline }) => (
|
||||
<p className='text-base-content/50 mt-6 text-center text-xs'>
|
||||
<a
|
||||
href='https://readest.com'
|
||||
className='hover:text-base-content/80 font-medium transition-colors'
|
||||
target='_blank'
|
||||
rel='noopener'
|
||||
>
|
||||
Readest
|
||||
</a>
|
||||
<span className='mx-1.5'>·</span>
|
||||
<span>{tagline}</span>
|
||||
</p>
|
||||
);
|
||||
Reference in New Issue
Block a user