forked from akai/readest
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,63 @@
|
||||
-- Migration 002: Add book_shares table for time-limited share links
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.book_shares (
|
||||
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
-- token_hash is the lookup key (sha256(token)). It is unique-indexed so
|
||||
-- public landing-page reads and downloads are O(1). The plaintext token
|
||||
-- is also stored so the owner can copy links after the create dialog
|
||||
-- closes. RLS prevents anyone but the owner from reading the plaintext.
|
||||
-- Public endpoints look up by hash only and never select the token column.
|
||||
token_hash text NOT NULL,
|
||||
token text NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
book_hash text NOT NULL,
|
||||
book_title text NOT NULL,
|
||||
book_author text NULL,
|
||||
book_format text NOT NULL,
|
||||
book_size bigint NOT NULL,
|
||||
cfi text NULL,
|
||||
expires_at timestamp with time zone NOT NULL,
|
||||
revoked_at timestamp with time zone NULL,
|
||||
download_count integer NOT NULL DEFAULT 0,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
CONSTRAINT book_shares_pkey PRIMARY KEY (id),
|
||||
CONSTRAINT book_shares_token_hash_key UNIQUE (token_hash),
|
||||
CONSTRAINT book_shares_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_book_shares_user_id ON public.book_shares (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_book_shares_user_id_book_hash ON public.book_shares (user_id, book_hash);
|
||||
|
||||
ALTER TABLE public.book_shares ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY book_shares_select ON public.book_shares
|
||||
FOR SELECT TO authenticated USING ((SELECT auth.uid()) = user_id);
|
||||
CREATE POLICY book_shares_insert ON public.book_shares
|
||||
FOR INSERT TO authenticated WITH CHECK ((SELECT auth.uid()) = user_id);
|
||||
CREATE POLICY book_shares_update ON public.book_shares
|
||||
FOR UPDATE TO authenticated USING ((SELECT auth.uid()) = user_id);
|
||||
CREATE POLICY book_shares_delete ON public.book_shares
|
||||
FOR DELETE TO authenticated USING ((SELECT auth.uid()) = user_id);
|
||||
|
||||
-- Atomic download_count increment used by the public /download/confirm beacon.
|
||||
-- Runs as SECURITY DEFINER so unauthenticated callers can bump the counter
|
||||
-- (the route gate is "the share is active"; the function enforces that).
|
||||
-- Only increments rows that are active right now — bypasses revoked/expired
|
||||
-- so late-firing analytics pings don't pollute the count.
|
||||
CREATE OR REPLACE FUNCTION public.increment_book_share_download(
|
||||
p_token_hash text,
|
||||
p_now timestamp with time zone
|
||||
) RETURNS void
|
||||
LANGUAGE sql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
UPDATE public.book_shares
|
||||
SET download_count = download_count + 1
|
||||
WHERE token_hash = p_token_hash
|
||||
AND revoked_at IS NULL
|
||||
AND expires_at > p_now;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.increment_book_share_download(text, timestamp with time zone)
|
||||
TO anon, authenticated, service_role;
|
||||
Reference in New Issue
Block a user