Files
readest/apps/readest-app/src/libs/share.ts
T
Huang Xin d1e7b4902c 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>
2026-05-02 19:03:35 +02:00

151 lines
4.9 KiB
TypeScript

import { getAPIBaseUrl } from '@/services/environment';
import { fetchWithAuth } from '@/utils/fetch';
const SHARE_API = getAPIBaseUrl() + '/share';
export interface CreateShareInput {
bookHash: string;
expirationDays: number; // must be one of [1, 3, 7]
title: string;
author?: string | null;
format: string;
// Note: `size` is intentionally not part of the input. The server reads the
// canonical size from the user's `files` row to avoid client/server drift.
cfi?: string | null;
}
export interface CreateShareResponse {
token: string;
url: string;
expiresAt: string;
}
export interface ShareMetadata {
title: string;
author: string | null;
format: string;
size: number;
expiresAt: string;
hasCover: boolean;
hasCfi: boolean;
downloadCount: number;
// Owner-only fields (returned only when the caller is the sharer).
token?: string;
bookHash?: string;
createdAt?: string;
revokedAt?: string | null;
}
export interface ShareListResponse {
shares: Array<
ShareMetadata & {
token: string;
bookHash: string;
createdAt: string;
revokedAt: string | null;
}
>;
nextCursor: string | null;
}
export interface ImportShareResponse {
fileId: string;
alreadyOwned: boolean;
bookHash: string;
cfi: string | null;
}
export class ShareApiError extends Error {
constructor(
public readonly status: number,
public readonly code: string | undefined,
message: string,
) {
super(message);
this.name = 'ShareApiError';
}
}
const parseError = async (response: Response): Promise<ShareApiError> => {
let code: string | undefined;
let message = response.statusText || 'Request failed';
try {
const body = (await response.json()) as { error?: string; code?: string };
if (body?.error) message = body.error;
if (body?.code) code = body.code;
} catch {
// Body wasn't JSON; keep the default message.
}
return new ShareApiError(response.status, code, message);
};
const jsonHeaders = { 'Content-Type': 'application/json' };
// Owner-only. Creates a share row for an already-uploaded book.
export const createShare = async (input: CreateShareInput): Promise<CreateShareResponse> => {
const response = await fetchWithAuth(`${SHARE_API}/create`, {
method: 'POST',
headers: jsonHeaders,
body: JSON.stringify(input),
});
if (!response.ok) throw await parseError(response);
return (await response.json()) as CreateShareResponse;
};
// Public. Used by the landing page to render metadata.
export const getShare = async (token: string): Promise<ShareMetadata> => {
const response = await fetch(`${SHARE_API}/${encodeURIComponent(token)}`, {
method: 'GET',
cache: 'no-store',
});
if (!response.ok) throw await parseError(response);
return (await response.json()) as ShareMetadata;
};
// Owner-only. Revokes a share immediately. Note that already-minted presigned
// download URLs remain valid until their TTL expires (max ~5 min).
export const revokeShare = async (token: string): Promise<void> => {
const response = await fetchWithAuth(`${SHARE_API}/${encodeURIComponent(token)}/revoke`, {
method: 'POST',
});
if (!response.ok) throw await parseError(response);
};
// Owner-only. Paginated list of the caller's shares (active + expired).
export const listShares = async (cursor?: string | null): Promise<ShareListResponse> => {
// SHARE_API is relative in dev (`/api/share`) and absolute in prod, so we
// can't use `new URL()` here unconditionally — relative paths throw
// "Invalid URL" without a base. Build the query string manually.
const qs = cursor ? `?cursor=${encodeURIComponent(cursor)}` : '';
const response = await fetchWithAuth(`${SHARE_API}/list${qs}`, { method: 'GET' });
if (!response.ok) throw await parseError(response);
return (await response.json()) as ShareListResponse;
};
// Recipient-side, requires auth. Adds the shared book to the caller's library
// by R2 server-side byte-copy. Idempotent: if the recipient already owns a
// non-deleted file with the same book_hash, returns alreadyOwned: true and
// the existing fileId.
export const importShare = async (token: string): Promise<ImportShareResponse> => {
const response = await fetchWithAuth(`${SHARE_API}/${encodeURIComponent(token)}/import`, {
method: 'POST',
});
if (!response.ok) throw await parseError(response);
return (await response.json()) as ImportShareResponse;
};
// Public. Best-effort analytics ping fired by the landing page Download button
// and the in-app deeplink hook on a successful import. Failures are silent —
// the user-visible action does NOT depend on this succeeding.
export const confirmDownload = async (token: string): Promise<void> => {
try {
await fetch(`${SHARE_API}/${encodeURIComponent(token)}/download/confirm`, {
method: 'POST',
cache: 'no-store',
keepalive: true,
});
} catch {
// Intentionally swallowed; this is analytics, not a gate.
}
};