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,73 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { generateShareToken, hashShareToken, isValidShareToken } from '@/libs/share-server';
|
||||
|
||||
describe('share-server', () => {
|
||||
describe('generateShareToken', () => {
|
||||
it('produces a 22-char alphanumeric raw token', async () => {
|
||||
const { raw, hash } = await generateShareToken();
|
||||
expect(raw).toMatch(/^[A-Za-z0-9]{22}$/);
|
||||
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('produces unique tokens across calls', async () => {
|
||||
const tokens = new Set<string>();
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const { raw } = await generateShareToken();
|
||||
tokens.add(raw);
|
||||
}
|
||||
expect(tokens.size).toBe(50);
|
||||
});
|
||||
|
||||
it('hash is deterministic for the same raw input', async () => {
|
||||
const { raw, hash } = await generateShareToken();
|
||||
const again = await hashShareToken(raw);
|
||||
expect(again).toBe(hash);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidShareToken', () => {
|
||||
it('accepts well-formed 22-char alphanumeric tokens', () => {
|
||||
expect(isValidShareToken('aBcDeFgHiJkLmNoPqRsTuV')).toBe(true);
|
||||
expect(isValidShareToken('0123456789abcdefABCDEF')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects wrong-length tokens', () => {
|
||||
expect(isValidShareToken('short')).toBe(false);
|
||||
expect(isValidShareToken('aBcDeFgHiJkLmNoPqRsTuVextra')).toBe(false);
|
||||
expect(isValidShareToken('')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects tokens with non-alphanumeric characters', () => {
|
||||
expect(isValidShareToken('aBcDeFgHiJkLmNoPqRsTu-')).toBe(false);
|
||||
expect(isValidShareToken('aBcDeFgHiJkLmNoPqRsTu_')).toBe(false);
|
||||
expect(isValidShareToken('aBcDeFgHiJkLmNoPqRsTu.')).toBe(false);
|
||||
expect(isValidShareToken('aBcDeFgHiJkLmNoPqRs Tuv')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-string input', () => {
|
||||
expect(isValidShareToken(undefined)).toBe(false);
|
||||
expect(isValidShareToken(null)).toBe(false);
|
||||
expect(isValidShareToken(42)).toBe(false);
|
||||
expect(isValidShareToken({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashShareToken', () => {
|
||||
it('produces a 64-char lowercase hex SHA-256 digest', async () => {
|
||||
const hash = await hashShareToken('aBcDeFgHiJkLmNoPqRsTuV');
|
||||
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('different inputs produce different hashes', async () => {
|
||||
const a = await hashShareToken('aBcDeFgHiJkLmNoPqRsTuV');
|
||||
const b = await hashShareToken('aBcDeFgHiJkLmNoPqRsTuW');
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it('matches a known SHA-256 vector for sanity', async () => {
|
||||
// Known test vector: sha256("abc") = ba7816bf...
|
||||
const hash = await hashShareToken('abc');
|
||||
expect(hash).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildShareUrl, parseShareDeepLink } from '@/utils/share';
|
||||
|
||||
describe('buildShareUrl', () => {
|
||||
it('builds the canonical https URL for a token', () => {
|
||||
expect(buildShareUrl('aBcDeFgHiJkLmNoPqRsTuV')).toBe(
|
||||
'https://web.readest.com/s/aBcDeFgHiJkLmNoPqRsTuV',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseShareDeepLink', () => {
|
||||
const VALID_TOKEN = 'aBcDeFgHiJkLmNoPqRsTuV';
|
||||
|
||||
it('parses readest://share/{token}', () => {
|
||||
expect(parseShareDeepLink(`readest://share/${VALID_TOKEN}`)).toEqual({ token: VALID_TOKEN });
|
||||
});
|
||||
|
||||
it('parses https://web.readest.com/s/{token}', () => {
|
||||
expect(parseShareDeepLink(`https://web.readest.com/s/${VALID_TOKEN}`)).toEqual({
|
||||
token: VALID_TOKEN,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses *.readest.com subdomains for preview deploys', () => {
|
||||
expect(parseShareDeepLink(`https://staging.readest.com/s/${VALID_TOKEN}`)).toEqual({
|
||||
token: VALID_TOKEN,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects tokens of the wrong length', () => {
|
||||
expect(parseShareDeepLink('readest://share/short')).toBeNull();
|
||||
expect(parseShareDeepLink(`readest://share/${VALID_TOKEN}extra`)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects tokens with disallowed characters', () => {
|
||||
// Underscore and hyphen are explicitly NOT in the alphabet.
|
||||
const bad = 'aBcDeFgHiJkLmNoPqRsTu-';
|
||||
expect(parseShareDeepLink(`readest://share/${bad}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects URLs from third-party hosts', () => {
|
||||
expect(parseShareDeepLink(`https://evil.example.com/s/${VALID_TOKEN}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects readest:// URLs whose host is not "share"', () => {
|
||||
expect(parseShareDeepLink(`readest://book/${VALID_TOKEN}`)).toBeNull();
|
||||
expect(parseShareDeepLink(`readest://annotation/${VALID_TOKEN}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects nested or extra path segments', () => {
|
||||
expect(parseShareDeepLink(`https://web.readest.com/s/${VALID_TOKEN}/extra`)).toBeNull();
|
||||
expect(parseShareDeepLink(`https://web.readest.com/extra/s/${VALID_TOKEN}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for malformed input', () => {
|
||||
expect(parseShareDeepLink('')).toBeNull();
|
||||
expect(parseShareDeepLink('not-a-url')).toBeNull();
|
||||
expect(parseShareDeepLink('ftp://web.readest.com/s/' + VALID_TOKEN)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user