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,156 @@
|
||||
import { customAlphabet } from 'nanoid';
|
||||
import { createSupabaseAdminClient } from '@/utils/supabase';
|
||||
|
||||
// 22-char URL-safe alphabet (alphanumeric only — no `-` or `_`). Avoids
|
||||
// punctuation that some chat clients linkify oddly.
|
||||
const SHARE_TOKEN_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const SHARE_TOKEN_LENGTH = 22;
|
||||
const generator = customAlphabet(SHARE_TOKEN_ALPHABET, SHARE_TOKEN_LENGTH);
|
||||
|
||||
const SHARE_TOKEN_REGEX = new RegExp(`^[${SHARE_TOKEN_ALPHABET}]{${SHARE_TOKEN_LENGTH}}$`);
|
||||
|
||||
export const isValidShareToken = (token: unknown): token is string =>
|
||||
typeof token === 'string' && SHARE_TOKEN_REGEX.test(token);
|
||||
|
||||
// Generate a fresh share token. The raw value is shown to the user once at
|
||||
// create-time; only the hash is persisted to the database. A leaked DB read
|
||||
// therefore cannot recover live bearer credentials.
|
||||
export const generateShareToken = async (): Promise<{ raw: string; hash: string }> => {
|
||||
const raw = generator();
|
||||
const hash = await hashShareToken(raw);
|
||||
return { raw, hash };
|
||||
};
|
||||
|
||||
// SHA-256 of the raw token. Used at create (insert) and lookup (constant-time
|
||||
// comparison via the unique index). Implemented with WebCrypto so it runs in
|
||||
// both Node and edge runtimes.
|
||||
export const hashShareToken = async (raw: string): Promise<string> => {
|
||||
const data = new TextEncoder().encode(raw);
|
||||
const buffer = await crypto.subtle.digest('SHA-256', data);
|
||||
return [...new Uint8Array(buffer)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
};
|
||||
|
||||
// Reasons a share lookup may reject.
|
||||
export type ShareLookupRejection =
|
||||
| { kind: 'invalid_token' }
|
||||
| { kind: 'not_found' }
|
||||
| { kind: 'revoked' }
|
||||
| { kind: 'expired' }
|
||||
| { kind: 'source_deleted' }
|
||||
| { kind: 'lookup_failed'; detail?: string };
|
||||
|
||||
export interface ResolvedShare {
|
||||
id: string;
|
||||
userId: string;
|
||||
bookHash: string;
|
||||
bookTitle: string;
|
||||
bookAuthor: string | null;
|
||||
bookFormat: string;
|
||||
bookSize: number;
|
||||
cfi: string | null;
|
||||
expiresAt: string;
|
||||
revokedAt: string | null;
|
||||
downloadCount: number;
|
||||
createdAt: string;
|
||||
bookFileKey: string;
|
||||
coverFileKey: string | null;
|
||||
}
|
||||
|
||||
const isCoverKey = (fileKey: string): boolean => /\.(png|jpe?g|webp|gif)$/i.test(fileKey);
|
||||
|
||||
// Single source of truth for the "is this share alive and usable?" check.
|
||||
// Used by the public metadata, download, cover, og.png, and import routes
|
||||
// so the validation logic stays in one place.
|
||||
export const resolveActiveShare = async (
|
||||
rawToken: string,
|
||||
): Promise<{ ok: true; share: ResolvedShare } | { ok: false; reason: ShareLookupRejection }> => {
|
||||
if (!isValidShareToken(rawToken)) {
|
||||
return { ok: false, reason: { kind: 'invalid_token' } };
|
||||
}
|
||||
|
||||
const supabase = createSupabaseAdminClient();
|
||||
const tokenHash = await hashShareToken(rawToken);
|
||||
|
||||
const { data: row, error } = await supabase
|
||||
.from('book_shares')
|
||||
.select(
|
||||
'id, user_id, book_hash, book_title, book_author, book_format, book_size, cfi, expires_at, revoked_at, download_count, created_at',
|
||||
)
|
||||
.eq('token_hash', tokenHash)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
return { ok: false, reason: { kind: 'lookup_failed', detail: error.message } };
|
||||
}
|
||||
if (!row) {
|
||||
return { ok: false, reason: { kind: 'not_found' } };
|
||||
}
|
||||
if (row.revoked_at) {
|
||||
return { ok: false, reason: { kind: 'revoked' } };
|
||||
}
|
||||
if (new Date(row.expires_at).getTime() < Date.now()) {
|
||||
return { ok: false, reason: { kind: 'expired' } };
|
||||
}
|
||||
|
||||
const { data: files, error: filesError } = await supabase
|
||||
.from('files')
|
||||
.select('file_key')
|
||||
.eq('user_id', row.user_id)
|
||||
.eq('book_hash', row.book_hash)
|
||||
.is('deleted_at', null);
|
||||
if (filesError) {
|
||||
return { ok: false, reason: { kind: 'lookup_failed', detail: filesError.message } };
|
||||
}
|
||||
|
||||
const bookFile = files?.find((f) => !isCoverKey(f.file_key));
|
||||
if (!bookFile) {
|
||||
return { ok: false, reason: { kind: 'source_deleted' } };
|
||||
}
|
||||
const coverFile = files?.find((f) => isCoverKey(f.file_key));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
share: {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
bookHash: row.book_hash,
|
||||
bookTitle: row.book_title,
|
||||
bookAuthor: row.book_author,
|
||||
bookFormat: row.book_format,
|
||||
bookSize: row.book_size,
|
||||
cfi: row.cfi,
|
||||
expiresAt: row.expires_at,
|
||||
revokedAt: row.revoked_at,
|
||||
downloadCount: row.download_count,
|
||||
createdAt: row.created_at,
|
||||
bookFileKey: bookFile.file_key,
|
||||
coverFileKey: coverFile?.file_key ?? null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Maps the rejection kinds to the standard HTTP status + code combinations
|
||||
// used by every share endpoint. Centralized so the JSON error shape is
|
||||
// consistent across routes.
|
||||
export const rejectionToHttp = (
|
||||
reason: ShareLookupRejection,
|
||||
): { status: number; body: { error: string; code?: string } } => {
|
||||
switch (reason.kind) {
|
||||
case 'invalid_token':
|
||||
return { status: 400, body: { error: 'Invalid share token', code: 'invalid_token' } };
|
||||
case 'not_found':
|
||||
return { status: 404, body: { error: 'Share not found', code: 'not_found' } };
|
||||
case 'revoked':
|
||||
return { status: 410, body: { error: 'Share has been revoked', code: 'revoked' } };
|
||||
case 'expired':
|
||||
return { status: 410, body: { error: 'Share has expired', code: 'expired' } };
|
||||
case 'source_deleted':
|
||||
return {
|
||||
status: 410,
|
||||
body: { error: 'Shared book is no longer available', code: 'source_deleted' },
|
||||
};
|
||||
case 'lookup_failed':
|
||||
console.error('Share lookup failed:', reason.detail);
|
||||
return { status: 500, body: { error: 'Could not look up share' } };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
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.
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user