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:
Huang Xin
2026-05-03 01:03:35 +08:00
committed by GitHub
parent 176e5df771
commit d1e7b4902c
75 changed files with 5139 additions and 68 deletions
@@ -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();
});
});
@@ -0,0 +1,39 @@
import { NextResponse } from 'next/server';
import { getDownloadSignedUrl } from '@/utils/object';
import { rejectionToHttp, resolveActiveShare } from '@/libs/share-server';
import { SHARE_PRESIGN_TTL_SECONDS } from '@/services/constants';
interface RouteParams {
params: Promise<{ token: string }>;
}
// GET /api/share/[token]/cover — public 302 redirect to a presigned cover URL.
// Cached briefly so chat-app preview crawlers don't re-fetch the same image
// for every recipient. Covers aren't sensitive; max-age is intentional.
export async function GET(_request: Request, { params }: RouteParams) {
const { token } = await params;
const result = await resolveActiveShare(token);
if (!result.ok) {
const { status, body } = rejectionToHttp(result.reason);
return NextResponse.json(body, { status });
}
const { share } = result;
if (!share.coverFileKey) {
return NextResponse.json({ error: 'No cover for this share' }, { status: 404 });
}
let url: string;
try {
url = await getDownloadSignedUrl(share.coverFileKey, SHARE_PRESIGN_TTL_SECONDS);
} catch (err) {
console.error('Share cover presign failed:', err);
return NextResponse.json({ error: 'Could not sign cover URL' }, { status: 500 });
}
return NextResponse.redirect(url, {
status: 302,
headers: { 'Cache-Control': 'public, max-age=300' },
});
}
@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server';
import { createSupabaseAdminClient } from '@/utils/supabase';
import { hashShareToken, isValidShareToken } from '@/libs/share-server';
interface RouteParams {
params: Promise<{ token: string }>;
}
// POST /api/share/[token]/download/confirm — analytics ping fired by the
// landing-page Download button (post-click) and the in-app deeplink hook on
// successful import. Best-effort: the user-facing action does not depend on
// this returning 2xx. Lookup is by token_hash so the row stays cheap to find.
//
// Increments are done in a single SQL UPDATE so concurrent requests cannot
// race a read-modify-write. We also accept the small risk that an increment
// lands shortly after a revoke — that's harmless, the counter doesn't grant
// access. The validity check below skips obviously dead shares so crawlers
// hitting expired links don't pollute the count after the fact.
export async function POST(_request: Request, { params }: RouteParams) {
const { token } = await params;
if (!isValidShareToken(token)) {
// Silently OK — this is a best-effort beacon, not an enforcement point.
return new NextResponse(null, { status: 204 });
}
const supabase = createSupabaseAdminClient();
const tokenHash = await hashShareToken(token);
// Atomic conditional update via the SQL function defined alongside the
// table. Only bumps rows that are still active so late-firing pings on
// expired/revoked shares don't pollute the count.
const nowIso = new Date().toISOString();
const { error } = await supabase.rpc('increment_book_share_download', {
p_token_hash: tokenHash,
p_now: nowIso,
});
if (error) {
// Best-effort beacon — log but never surface to the caller.
console.error('download confirm rpc failed:', error);
}
return new NextResponse(null, {
status: 204,
headers: { 'Cache-Control': 'private, no-store' },
});
}
@@ -0,0 +1,41 @@
import { NextResponse } from 'next/server';
import { getDownloadSignedUrl } from '@/utils/object';
import { rejectionToHttp, resolveActiveShare } from '@/libs/share-server';
import { SHARE_PRESIGN_TTL_SECONDS } from '@/services/constants';
interface RouteParams {
params: Promise<{ token: string }>;
}
// GET /api/share/[token]/download — public, 302 to a short-lived presigned URL.
// IMPORTANT: this endpoint MUST NOT write to the database. iMessage / WhatsApp /
// Slack / Twitter unfurlers and browser prefetchers will hit this URL just by
// previewing a link. Counting them would inflate `download_count` to garbage.
// Real downloads ping POST /download/confirm separately so the count tracks
// user intent, not crawler curiosity.
export async function GET(_request: Request, { params }: RouteParams) {
const { token } = await params;
const result = await resolveActiveShare(token);
if (!result.ok) {
const { status, body } = rejectionToHttp(result.reason);
return NextResponse.json(body, { status });
}
const { share } = result;
let url: string;
try {
url = await getDownloadSignedUrl(share.bookFileKey, SHARE_PRESIGN_TTL_SECONDS);
} catch (err) {
console.error('Share download presign failed:', err);
return NextResponse.json({ error: 'Could not sign download URL' }, { status: 500 });
}
return NextResponse.redirect(url, {
status: 302,
// Don't let intermediaries cache the redirect target itself; the presign
// expires fast but caching the 302 would point future requests at a
// soon-to-be-dead URL.
headers: { 'Cache-Control': 'private, no-store' },
});
}
@@ -0,0 +1,217 @@
import { NextResponse } from 'next/server';
import { createSupabaseAdminClient } from '@/utils/supabase';
import { copyObject, objectExists } from '@/utils/object';
import {
STORAGE_QUOTA_GRACE_BYTES,
getStoragePlanData,
validateUserAndToken,
} from '@/utils/access';
import { rejectionToHttp, resolveActiveShare } from '@/libs/share-server';
interface RouteParams {
params: Promise<{ token: string }>;
}
// POST /api/share/[token]/import — recipient-side library import. Auth required.
//
// Strategy: R2 server-side byte-copy.
// The existing `files` table consumers (stats / purge / delete / download)
// all assume `file_key` starts with the row's `user_id`. A reference-based
// import would silently break those invariants, so we copy the bytes into
// the recipient's namespace instead. R2 server-side copy is one API call
// and incurs no egress.
//
// Idempotent: if the recipient already has a non-deleted `files` row for the
// same `book_hash`, we return their existing fileId with `alreadyOwned: true`
// and skip the copy. Saves egress on repeated imports.
export async function POST(request: Request, { params }: RouteParams) {
const { token: shareToken } = await params;
const { user, token: jwt } = await validateUserAndToken(request.headers.get('authorization'));
if (!user || !jwt) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const result = await resolveActiveShare(shareToken);
if (!result.ok) {
const { status, body } = rejectionToHttp(result.reason);
return NextResponse.json(body, { status });
}
const { share } = result;
// Self-imports are no-ops; redirect the user to their own copy without
// burning a copy operation.
if (share.userId === user.id) {
const supabase = createSupabaseAdminClient();
const { data: own } = await supabase
.from('files')
.select('id, book_hash')
.eq('user_id', user.id)
.eq('book_hash', share.bookHash)
.is('deleted_at', null)
.not('file_key', 'ilike', '%.png')
.not('file_key', 'ilike', '%.jpg')
.not('file_key', 'ilike', '%.jpeg')
.not('file_key', 'ilike', '%.webp')
.not('file_key', 'ilike', '%.gif')
.limit(1)
.maybeSingle();
if (own) {
return NextResponse.json({
fileId: own.id,
alreadyOwned: true,
bookHash: share.bookHash,
cfi: share.cfi,
});
}
}
const supabase = createSupabaseAdminClient();
// Idempotency: look up existing rows for the same (user_id, book_hash),
// INCLUDING soft-deleted ones. file_key is unique globally, so an active
// import that the user later deleted leaves a row that would collide with
// a fresh insert below — we restore it instead of failing.
const { data: existing, error: existingError } = await supabase
.from('files')
.select('id, file_key, deleted_at')
.eq('user_id', user.id)
.eq('book_hash', share.bookHash);
if (existingError) {
console.error('Share import existing-row lookup failed:', existingError);
return NextResponse.json({ error: 'Could not check library' }, { status: 500 });
}
const existingRows = (existing ?? []).filter((f) => !/\.(png|jpe?g|webp|gif)$/i.test(f.file_key));
const liveRow = existingRows.find((f) => f.deleted_at === null);
if (liveRow) {
return NextResponse.json({
fileId: liveRow.id,
alreadyOwned: true,
bookHash: share.bookHash,
cfi: share.cfi,
});
}
const deletedRow = existingRows.find((f) => f.deleted_at !== null);
if (deletedRow) {
// Restore the soft-deleted row so the unique file_key constraint isn't
// hit by a fresh insert. The bytes may also still be in storage; if the
// copy below succeeds it overwrites them, if it doesn't we leave the
// row in its restored state so the user can re-attempt later.
const { error: restoreError } = await supabase
.from('files')
.update({ deleted_at: null, updated_at: new Date().toISOString() })
.eq('id', deletedRow.id);
if (restoreError) {
console.error('Share import restore-deleted-row failed:', restoreError);
return NextResponse.json({ error: 'Could not restore book' }, { status: 500 });
}
return NextResponse.json({
fileId: deletedRow.id,
alreadyOwned: true,
bookHash: share.bookHash,
cfi: share.cfi,
});
}
// Quota check before doing any byte-copy work. JWT-based but consistent
// with how the existing upload endpoint enforces it.
const { usage, quota } = getStoragePlanData(jwt);
if (usage + share.bookSize > quota + STORAGE_QUOTA_GRACE_BYTES) {
return NextResponse.json(
{ error: 'Insufficient storage quota', code: 'quota_exceeded', usage, quota },
{ status: 402 },
);
}
// Translate the sharer's file_keys into the recipient's namespace by
// swapping the leading user-id prefix. Existing convention: file_key looks
// like `${userId}/Readest/Book/{hash}/{filename}`.
const sharerPrefix = `${share.userId}/`;
const recipientPrefix = `${user.id}/`;
const remap = (sourceKey: string): string | null => {
if (!sourceKey.startsWith(sharerPrefix)) return null;
return recipientPrefix + sourceKey.slice(sharerPrefix.length);
};
const destBookKey = remap(share.bookFileKey);
if (!destBookKey) {
console.error('Share import: source key does not start with sharer user id', share.bookFileKey);
return NextResponse.json({ error: 'Cannot remap shared file' }, { status: 500 });
}
// Verify source bytes still exist before allocating a destination row.
const sourceExists = await objectExists(share.bookFileKey);
if (!sourceExists) {
return NextResponse.json(
{ error: 'Shared book is no longer available', code: 'source_deleted' },
{ status: 410 },
);
}
// Insert destination row first (to grab a stable id), then copy bytes,
// then mark the row clean. On copy failure we soft-delete the row so the
// user's library doesn't show a phantom book.
const { data: insertedBook, error: insertBookError } = await supabase
.from('files')
.insert({
user_id: user.id,
book_hash: share.bookHash,
file_key: destBookKey,
file_size: share.bookSize,
})
.select('id')
.single();
if (insertBookError || !insertedBook) {
console.error('Share import insert book row failed:', insertBookError);
return NextResponse.json({ error: 'Could not import book' }, { status: 500 });
}
try {
const copyResp = await copyObject(share.bookFileKey, destBookKey);
// R2 (aws4fetch) returns a Response; S3 SDK returns a structured object.
// Both throw on hard failures; treat any non-ok HTTP response as a fail.
if (copyResp && typeof (copyResp as Response).ok === 'boolean' && !(copyResp as Response).ok) {
throw new Error(`R2 copy failed: ${(copyResp as Response).status}`);
}
} catch (err) {
console.error('Share import book copy failed:', err);
// Soft-delete the orphaned row so it doesn't count against quota or appear
// in the library list.
await supabase
.from('files')
.update({ deleted_at: new Date().toISOString() })
.eq('id', insertedBook.id);
return NextResponse.json({ error: 'Could not import book' }, { status: 500 });
}
// Cover is best-effort. A failure here doesn't fail the import — the
// recipient still gets the book; the cover will simply be missing in
// their library until they refresh from elsewhere.
if (share.coverFileKey) {
const destCoverKey = remap(share.coverFileKey);
if (destCoverKey) {
try {
const coverExists = await objectExists(share.coverFileKey);
if (coverExists) {
await copyObject(share.coverFileKey, destCoverKey);
await supabase.from('files').insert({
user_id: user.id,
book_hash: share.bookHash,
file_key: destCoverKey,
file_size: 0, // unknown; not material — covers don't bill
});
}
} catch (err) {
console.error('Share import cover copy failed (non-fatal):', err);
}
}
}
return NextResponse.json({
fileId: insertedBook.id,
alreadyOwned: false,
bookHash: share.bookHash,
cfi: share.cfi,
});
}
@@ -0,0 +1,172 @@
import { ImageResponse } from 'next/og';
import { NextResponse } from 'next/server';
import { getDownloadSignedUrl } from '@/utils/object';
import { rejectionToHttp, resolveActiveShare } from '@/libs/share-server';
import { SHARE_PRESIGN_TTL_SECONDS } from '@/services/constants';
// Edge runtime is required for `next/og` (uses Satori + WASM under the hood
// and doesn't run on Node-style serverless). Keeps cold start fast on
// CloudFlare Workers via OpenNext.
export const runtime = 'edge';
interface RouteParams {
params: Promise<{ token: string }>;
}
const WIDTH = 1200;
const HEIGHT = 630;
// GET /api/share/[token]/og.png — server-rendered branded card for chat
// unfurls. Stable URL, cached for an hour: unfurlers (iMessage, WhatsApp,
// Twitter, Slack) cache aggressively, so a short-lived signed cover URL would
// break previews after expiry. By proxying through this route we get a stable
// URL even though the underlying R2 object is presigned per-fetch.
export async function GET(_request: Request, { params }: RouteParams) {
const { token } = await params;
const result = await resolveActiveShare(token);
if (!result.ok) {
const { status, body } = rejectionToHttp(result.reason);
return NextResponse.json(body, { status });
}
const { share } = result;
let coverDataUrl: string | null = null;
if (share.coverFileKey) {
try {
const signedUrl = await getDownloadSignedUrl(share.coverFileKey, SHARE_PRESIGN_TTL_SECONDS);
const response = await fetch(signedUrl);
if (response.ok) {
const buffer = await response.arrayBuffer();
const contentType = response.headers.get('content-type') ?? 'image/jpeg';
coverDataUrl = `data:${contentType};base64,${arrayBufferToBase64(buffer)}`;
}
} catch (err) {
console.error('Share og.png cover fetch failed:', err);
// Fall through to text-only card.
}
}
// JSX form is XSS-safe by construction: ImageResponse escapes text content.
// No raw HTML strings cross the boundary.
return new ImageResponse(
coverDataUrl
? withCoverCard(coverDataUrl, share.bookTitle, share.bookAuthor)
: textOnlyCard(share.bookTitle, share.bookAuthor),
{
width: WIDTH,
height: HEIGHT,
headers: {
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
},
},
);
}
const arrayBufferToBase64 = (buffer: ArrayBuffer): string => {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]!);
}
return btoa(binary);
};
// Cover-on-left composition. Asymmetric (anti-slop). Cover is the visual
// anchor; metadata sits to the right with strong vertical hierarchy.
const withCoverCard = (cover: string, title: string, author: string | null) => (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#ffffff',
padding: '64px',
gap: '64px',
fontFamily: 'serif',
}}
>
<img
src={cover}
width={320}
height={480}
style={{
objectFit: 'cover',
border: '1px solid #e5e5e5',
boxShadow: '0 6px 24px rgba(0,0,0,0.08)',
}}
alt=''
/>
<div
style={{
display: 'flex',
flexDirection: 'column',
flex: 1,
gap: '24px',
minWidth: 0,
}}
>
<div
style={{
fontSize: 56,
fontWeight: 700,
color: '#1a1a1a',
lineHeight: 1.15,
letterSpacing: '-0.02em',
}}
>
{clamp(title, 90)}
</div>
{author && (
<div style={{ fontSize: 32, color: '#525252', fontWeight: 400 }}>{clamp(author, 60)}</div>
)}
<div style={{ flex: 1 }} />
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ fontSize: 22, color: '#0066cc', fontWeight: 500 }}>Shared via Readest</div>
<div style={{ fontSize: 18, color: '#a3a3a3' }}>readest.com</div>
</div>
</div>
</div>
);
// Cover-less fallback (eng-review locked option A). Title becomes the visual
// anchor at display size. No placeholder rectangle, no procedural pattern.
const textOnlyCard = (title: string, author: string | null) => (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
backgroundColor: '#ffffff',
padding: '96px 80px',
fontFamily: 'serif',
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '32px' }}>
<div
style={{
fontSize: 88,
fontWeight: 700,
color: '#1a1a1a',
lineHeight: 1.05,
letterSpacing: '-0.03em',
}}
>
{clamp(title, 80)}
</div>
{author && (
<div style={{ fontSize: 40, color: '#525252', fontWeight: 400 }}>{clamp(author, 60)}</div>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ fontSize: 26, color: '#0066cc', fontWeight: 500 }}>Shared via Readest</div>
<div style={{ fontSize: 20, color: '#a3a3a3' }}>readest.com</div>
</div>
</div>
);
const clamp = (s: string, max: number): string => (s.length > max ? `${s.slice(0, max - 1)}` : s);
@@ -0,0 +1,62 @@
import { NextResponse } from 'next/server';
import { createSupabaseAdminClient } from '@/utils/supabase';
import { validateUserAndToken } from '@/utils/access';
import { hashShareToken, isValidShareToken } from '@/libs/share-server';
interface RouteParams {
params: Promise<{ token: string }>;
}
// POST /api/share/[token]/revoke — owner-only. Sets revoked_at = now() so
// future landing-page visits and downloads return 410. Note: presigned URLs
// already minted (max ~5 min TTL) cannot be canceled — this is a documented
// soft-revocation grace, not a hard guarantee.
export async function POST(request: Request, { params }: RouteParams) {
const { token } = await params;
if (!isValidShareToken(token)) {
return NextResponse.json({ error: 'Invalid share token' }, { status: 400 });
}
const { user, token: jwt } = await validateUserAndToken(request.headers.get('authorization'));
if (!user || !jwt) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const supabase = createSupabaseAdminClient();
const tokenHash = await hashShareToken(token);
// RLS would suffice, but we use the admin client elsewhere; gate explicitly
// on user_id to keep the contract obvious to readers.
const { data: share, error: lookupError } = await supabase
.from('book_shares')
.select('id, user_id, revoked_at')
.eq('token_hash', tokenHash)
.maybeSingle();
if (lookupError) {
console.error('book_shares lookup failed:', lookupError);
return NextResponse.json({ error: 'Could not look up share' }, { status: 500 });
}
if (!share) {
return NextResponse.json({ error: 'Share not found' }, { status: 404 });
}
if (share.user_id !== user.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Idempotent: re-revoking returns success without churning the timestamp.
if (share.revoked_at) {
return new NextResponse(null, { status: 204 });
}
const { error: updateError } = await supabase
.from('book_shares')
.update({ revoked_at: new Date().toISOString() })
.eq('id', share.id);
if (updateError) {
console.error('book_shares revoke failed:', updateError);
return NextResponse.json({ error: 'Could not revoke share' }, { status: 500 });
}
return new NextResponse(null, { status: 204 });
}
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import { rejectionToHttp, resolveActiveShare } from '@/libs/share-server';
interface RouteParams {
params: Promise<{ token: string }>;
}
// GET /api/share/[token] — public metadata used by the /s landing page.
// Returns 410 if the share is revoked, expired, or its source file no longer
// exists. Never returns presigned URLs in this body — covers and downloads
// are fetched from dedicated endpoints with their own caching semantics.
export async function GET(_request: Request, { params }: RouteParams) {
const { token } = await params;
const result = await resolveActiveShare(token);
if (!result.ok) {
const { status, body } = rejectionToHttp(result.reason);
return NextResponse.json(body, { status });
}
const { share } = result;
return NextResponse.json(
{
title: share.bookTitle,
author: share.bookAuthor,
format: share.bookFormat,
size: share.bookSize,
expiresAt: share.expiresAt,
hasCover: !!share.coverFileKey,
hasCfi: !!share.cfi,
downloadCount: share.downloadCount,
},
{ headers: { 'Cache-Control': 'private, no-store' } },
);
}
@@ -0,0 +1,178 @@
import { NextResponse } from 'next/server';
import { createSupabaseAdminClient } from '@/utils/supabase';
import { validateUserAndToken } from '@/utils/access';
import { generateShareToken } from '@/libs/share-server';
import { objectExists } from '@/utils/object';
import {
SHARE_BASE_URL,
SHARE_CFI_MAX_LENGTH,
SHARE_EXPIRATION_DAYS,
SHARE_MAX_PER_USER,
} from '@/services/constants';
interface CreateShareBody {
bookHash?: unknown;
expirationDays?: unknown;
title?: unknown;
author?: unknown;
format?: unknown;
cfi?: unknown;
}
const isAllowedExpiration = (value: unknown): value is number =>
typeof value === 'number' &&
Number.isInteger(value) &&
(SHARE_EXPIRATION_DAYS as readonly number[]).includes(value);
// Bounds the snapshotted text fields the client can pass through. The metadata
// is rendered on a public landing page and embedded in the OG image, so a
// hostile client could try to abuse very long values for layout disruption.
const trimText = (value: unknown, max: number): string | null => {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed) return null;
return trimmed.length > max ? trimmed.slice(0, max) : trimmed;
};
// Reject the C0 control range (U+0000-U+001F) and DEL (U+007F). The cfi
// is round-tripped into URLs and rendered into HTML; a control byte in
// either path would do bad things.
const isControlChar = (s: string): boolean => /[\u0000-\u001f\u007f]/.test(s);
export async function POST(request: Request) {
const { user, token } = await validateUserAndToken(request.headers.get('authorization'));
if (!user || !token) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
let body: CreateShareBody;
try {
body = (await request.json()) as CreateShareBody;
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const bookHash = trimText(body.bookHash, 64);
if (!bookHash) {
return NextResponse.json({ error: 'Missing or invalid bookHash' }, { status: 400 });
}
if (!isAllowedExpiration(body.expirationDays)) {
return NextResponse.json(
{
error: `expirationDays must be one of ${SHARE_EXPIRATION_DAYS.join(', ')}`,
code: 'invalid_expiration',
},
{ status: 400 },
);
}
const expirationDays = body.expirationDays;
const title = trimText(body.title, 512);
if (!title) {
return NextResponse.json({ error: 'Missing or invalid title' }, { status: 400 });
}
const author = trimText(body.author, 256);
const format = trimText(body.format, 16);
if (!format) {
return NextResponse.json({ error: 'Missing or invalid format' }, { status: 400 });
}
let cfi: string | null = null;
if (body.cfi != null) {
cfi = trimText(body.cfi, SHARE_CFI_MAX_LENGTH);
if (cfi && isControlChar(cfi)) {
return NextResponse.json({ error: 'cfi contains invalid characters' }, { status: 400 });
}
}
const supabase = createSupabaseAdminClient();
// Active-share cap — silently enforced. Counts only non-revoked, non-expired rows.
const { count: activeCount, error: countError } = await supabase
.from('book_shares')
.select('id', { count: 'exact', head: true })
.eq('user_id', user.id)
.is('revoked_at', null)
.gt('expires_at', new Date().toISOString());
if (countError) {
console.error('book_shares cap query failed:', countError);
return NextResponse.json({ error: 'Could not check share quota' }, { status: 500 });
}
if ((activeCount ?? 0) >= SHARE_MAX_PER_USER) {
return NextResponse.json(
{
error: `You have reached the maximum of ${SHARE_MAX_PER_USER} active shares.`,
code: 'share_limit_reached',
},
{ status: 429 },
);
}
// Look up the live `files` row for this user's book. Re-uploads of the same
// hash follow the share automatically because we resolve at every access.
const { data: bookFiles, error: filesError } = await supabase
.from('files')
.select('file_key, file_size')
.eq('user_id', user.id)
.eq('book_hash', bookHash)
.is('deleted_at', null);
if (filesError) {
console.error('book_shares files lookup failed:', filesError);
return NextResponse.json({ error: 'Could not look up book' }, { status: 500 });
}
if (!bookFiles || bookFiles.length === 0) {
return NextResponse.json(
{ error: 'Book is not uploaded yet', code: 'book_not_uploaded' },
{ status: 409 },
);
}
// Pick the book file (not the cover) by extension. Covers are PNG/JPG;
// book files are EPUB/PDF/MOBI/etc. The widest filter is "is not an image".
const bookFile = bookFiles.find((f) => !/\.(png|jpe?g|webp|gif)$/i.test(f.file_key));
if (!bookFile) {
return NextResponse.json(
{ error: 'Book file row not found', code: 'book_not_uploaded' },
{ status: 409 },
);
}
const size = bookFile.file_size;
// The `files` row is inserted before bytes upload (storage/upload.ts:74), so
// a ghost row can exist if the client aborted. HEAD R2 to confirm bytes are
// really there before we make the share publicly resolvable.
const exists = await objectExists(bookFile.file_key);
if (!exists) {
return NextResponse.json(
{ error: 'Book upload is incomplete; please retry', code: 'upload_incomplete' },
{ status: 409 },
);
}
const { raw, hash } = await generateShareToken();
const expiresAt = new Date(Date.now() + expirationDays * 24 * 60 * 60 * 1000);
const { error: insertError } = await supabase.from('book_shares').insert({
token_hash: hash,
token: raw,
user_id: user.id,
book_hash: bookHash,
book_title: title,
book_author: author,
book_format: format,
book_size: size,
cfi,
expires_at: expiresAt.toISOString(),
});
if (insertError) {
console.error('book_shares insert failed:', insertError);
return NextResponse.json({ error: 'Could not create share' }, { status: 500 });
}
return NextResponse.json({
token: raw,
url: `${SHARE_BASE_URL}/${raw}`,
expiresAt: expiresAt.toISOString(),
});
}
@@ -0,0 +1,81 @@
import { NextResponse } from 'next/server';
import { createSupabaseAdminClient } from '@/utils/supabase';
import { validateUserAndToken } from '@/utils/access';
import { SHARE_BASE_URL } from '@/services/constants';
const PAGE_SIZE = 25;
// GET /api/share/list?cursor=<created_at_iso>:<id>
// Owner-only. Cursor-paginated list of the caller's shares (active + expired).
// Cursor format mirrors the (created_at DESC, id DESC) order so duplicates and
// drops are impossible across pages even when rows are added concurrently.
export async function GET(request: Request) {
const { user, token } = await validateUserAndToken(request.headers.get('authorization'));
if (!user || !token) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const url = new URL(request.url);
const rawCursor = url.searchParams.get('cursor');
let cursorCreatedAt: string | null = null;
let cursorId: string | null = null;
if (rawCursor) {
const sep = rawCursor.indexOf('|');
if (sep > 0) {
cursorCreatedAt = rawCursor.slice(0, sep);
cursorId = rawCursor.slice(sep + 1);
}
}
const supabase = createSupabaseAdminClient();
let query = supabase
.from('book_shares')
.select(
'id, user_id, token, book_hash, book_title, book_author, book_format, book_size, cfi, expires_at, revoked_at, download_count, created_at',
)
.eq('user_id', user.id)
.order('created_at', { ascending: false })
.order('id', { ascending: false })
.limit(PAGE_SIZE + 1);
if (cursorCreatedAt && cursorId) {
// Strict less-than on (created_at, id) lexicographic to avoid skipping ties.
query = query.or(
`created_at.lt.${cursorCreatedAt},and(created_at.eq.${cursorCreatedAt},id.lt.${cursorId})`,
);
}
const { data, error } = await query;
if (error) {
console.error('book_shares list failed:', error);
return NextResponse.json({ error: 'Could not list shares' }, { status: 500 });
}
const rows = data ?? [];
const hasMore = rows.length > PAGE_SIZE;
const page = hasMore ? rows.slice(0, PAGE_SIZE) : rows;
const last = page.length > 0 ? page[page.length - 1] : null;
const nextCursor = hasMore && last ? `${last.created_at}|${last.id}` : null;
return NextResponse.json({
shares: page.map((row) => ({
id: row.id,
// Plaintext token surfaced to the OWNER only. RLS ensures other users
// cannot read this row; this endpoint is auth-gated and queried by
// user_id so a token never leaves the sharer's session.
token: row.token,
bookHash: row.book_hash,
title: row.book_title,
author: row.book_author,
format: row.book_format,
size: row.book_size,
hasCfi: !!row.cfi,
expiresAt: row.expires_at,
revokedAt: row.revoked_at,
downloadCount: row.download_count,
createdAt: row.created_at,
})),
nextCursor,
shareUrlBase: SHARE_BASE_URL,
});
}
@@ -48,6 +48,8 @@ import Spinner from '@/components/Spinner';
import ModalPortal from '@/components/ModalPortal';
import BookshelfItem, { generateBookshelfItems } from './BookshelfItem';
import SelectModeActions from './SelectModeActions';
import ShareBookDialog from './ShareBookDialog';
import { useAuth } from '@/context/AuthContext';
import GroupingModal from './GroupingModal';
import SetStatusAlert from './SetStatusAlert';
@@ -462,6 +464,31 @@ const Bookshelf: React.FC<BookshelfProps> = ({
};
}, []);
const { user } = useAuth();
const [shareDialogBook, setShareDialogBook] = useState<Book | null>(null);
useEffect(() => {
const handleShareIntent = (event: CustomEvent) => {
const book = (event.detail as { book?: Book } | undefined)?.book;
if (!book) return;
if (!user) {
// Logged-out users can't share their own files; route through the
// login flow instead. The /auth route preserves a return path.
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Sign in to share books'),
timeout: 2500,
});
return;
}
setShareDialogBook(book);
};
eventDispatcher.on('show-share-dialog', handleShareIntent);
return () => {
eventDispatcher.off('show-share-dialog', handleShareIntent);
};
}, [user, _]);
// OverlayScrollbars + Virtuoso integration: Virtuoso manages its own
// scroller; OverlayScrollbars wraps it for overlay scrollbar rendering.
const osRootRef = useRef<HTMLDivElement>(null);
@@ -698,6 +725,11 @@ const Bookshelf: React.FC<BookshelfProps> = ({
onUpdateStatus={updateBooksStatus}
/>
)}
<ShareBookDialog
isOpen={!!shareDialogBook}
book={shareDialogBook}
onClose={() => setShareDialogBook(null)}
/>
</div>
);
};
@@ -252,6 +252,14 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
handleBookUpload(book);
},
});
const shareBookMenuItem = await MenuItem.new({
text: _('Share Book'),
action: async () => {
// Bookshelf.tsx hosts the dialog; we dispatch and let it route
// unauthenticated users into the login flow first.
eventDispatcher.dispatch('show-share-dialog', { book });
},
});
const deleteBookMenuItem = await MenuItem.new({
text: _('Delete'),
action: async () => {
@@ -278,6 +286,11 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
if (!book.uploadedAt && book.downloadedAt) {
menu.append(uploadBookMenuItem);
}
// Share is offered for any local-or-uploaded book; the dialog will trigger
// an upload first if the book hasn't been pushed yet.
if (book.downloadedAt || book.uploadedAt) {
menu.append(shareBookMenuItem);
}
menu.append(deleteBookMenuItem);
menu.popup();
};
@@ -0,0 +1,392 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import {
IoCheckmarkCircle,
IoCopyOutline,
IoLinkOutline,
IoShareSocialOutline,
} from 'react-icons/io5';
import Dialog from '@/components/Dialog';
import SegmentedControl from '@/components/SegmentedControl';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { eventDispatcher } from '@/utils/event';
import { Book } from '@/types/book';
import { SHARE_DEFAULT_EXPIRATION_DAYS, SHARE_EXPIRATION_DAYS } from '@/services/constants';
import { ShareApiError, createShare, revokeShare } from '@/libs/share';
import { formatBytes } from '@/utils/book';
interface ShareBookDialogProps {
isOpen: boolean;
book: Book | null;
// Optional starting position when launched from the reader top bar.
// When present, the dialog shows the "Share current page" toggle and
// includes the cfi in the create payload if the toggle is checked.
cfi?: string | null;
onClose: () => void;
}
interface CreatedShare {
url: string;
expiresAt: string;
hasCfi: boolean;
}
const ShareBookDialog: React.FC<ShareBookDialogProps> = ({ isOpen, book, cfi, onClose }) => {
const _ = useTranslation();
const { appService } = useEnv();
const [expirationDays, setExpirationDays] = useState<number>(SHARE_DEFAULT_EXPIRATION_DAYS);
// Off by default — sharing the current page reveals where the user is in
// the book, which is mild privacy data. Recipient still gets the book; the
// toggle only adds the cfi to the link. Mirrors the dialog's reset effect.
const [includeCfi, setIncludeCfi] = useState(false);
const [generating, setGenerating] = useState(false);
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [created, setCreated] = useState<CreatedShare | null>(null);
const [revoking, setRevoking] = useState(false);
const [copied, setCopied] = useState(false);
const [fileSize, setFileSize] = useState<number | null>(null);
// Reset transient state every time the dialog opens for a new book.
useEffect(() => {
if (!isOpen) return;
setExpirationDays(SHARE_DEFAULT_EXPIRATION_DAYS);
setIncludeCfi(false);
setGenerating(false);
setUploadProgress(null);
setErrorMessage(null);
setCreated(null);
setRevoking(false);
setCopied(false);
setFileSize(null);
}, [isOpen, book?.hash]);
// Look up the book's file size for the Hero metadata line. Mirrors the
// BookDetailModal pattern: ask appService once per (open, book) pair and
// tolerate failures silently — file size is decorative, not required.
useEffect(() => {
if (!isOpen || !book || !appService) return;
let cancelled = false;
(async () => {
try {
const size = await appService.getBookFileSize(book);
if (!cancelled) setFileSize(size);
} catch {
// Local file may be unavailable (cloud-only book); leave size null.
}
})();
return () => {
cancelled = true;
};
}, [isOpen, book?.hash, appService]); // eslint-disable-line react-hooks/exhaustive-deps
const expiryLabel = useMemo(() => {
if (!created) return null;
const date = new Date(created.expiresAt);
return date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
}, [created]);
if (!book) return null;
const handleGenerate = async () => {
if (!book || generating) return;
setGenerating(true);
setErrorMessage(null);
try {
// Upload first if the book lives only locally.
if (!book.uploadedAt && appService) {
try {
await appService.uploadBook(book, (progress) => {
setUploadProgress((progress.progress / progress.total) * 100);
});
} finally {
setUploadProgress(null);
}
}
const response = await createShare({
bookHash: book.hash,
expirationDays,
title: book.title,
author: book.author ?? null,
format: book.format,
cfi: cfi && includeCfi ? cfi : null,
});
setCreated({
url: response.url,
expiresAt: response.expiresAt,
hasCfi: !!(cfi && includeCfi),
});
} catch (err) {
const message =
err instanceof ShareApiError
? err.message
: err instanceof Error
? err.message
: _('Could not create share link');
setErrorMessage(message);
} finally {
setGenerating(false);
}
};
const handleCopy = async () => {
if (!created) return;
try {
await navigator.clipboard.writeText(created.url);
setCopied(true);
eventDispatcher.dispatch('toast', {
type: 'success',
message: _('Link copied'),
timeout: 2000,
});
window.setTimeout(() => setCopied(false), 2000);
} catch {
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Could not copy link'),
timeout: 2000,
});
}
};
const handleNativeShare = async () => {
if (!created) return;
const title = book.title;
const url = created.url;
// Tauri (mobile + desktop windowed): try sharekit. If the import or
// call throws, the plugin isn't usable on this platform — fall through
// to web/copy. If it resolves (success OR user dismissed the sheet
// without picking), we're done; do NOT silently copy on top of that.
if (appService?.isMobileApp || appService?.hasWindow) {
let sharekitWorked = false;
try {
const { shareText } = await import('@choochmeque/tauri-plugin-sharekit-api');
await shareText(`${title}\n${url}`);
sharekitWorked = true;
} catch (err) {
console.error('shareText failed; falling back:', err);
}
if (sharekitWorked) return;
}
// Web: navigator.share rejects with AbortError when the user dismisses
// the share sheet — that's an explicit "don't share" choice, not a
// signal to silently copy as a "helpful" fallback. Return on either
// resolve or reject; only fall through if the API isn't supported at all.
if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') {
try {
await navigator.share({ title, url });
} catch {
// User dismissed or share-time error; respect the choice.
}
return;
}
// Last resort: clipboard copy. Reached only when no native share method
// is available on this platform (e.g., Linux desktop without sharekit).
await handleCopy();
};
const handleRevoke = async () => {
if (!created || revoking) return;
if (!created.url) return;
// Extract token from the canonical URL we received from the server.
const segments = created.url.split('/');
const token = segments[segments.length - 1];
if (!token) return;
setRevoking(true);
try {
await revokeShare(token);
eventDispatcher.dispatch('toast', {
type: 'success',
message: _('Share revoked'),
timeout: 2000,
});
onClose();
} catch (err) {
const message = err instanceof Error ? err.message : _('Could not revoke share');
setErrorMessage(message);
} finally {
setRevoking(false);
}
};
return (
<Dialog
isOpen={isOpen}
title={_('Share Book')}
onClose={onClose}
boxClassName='sm:min-w-[480px] sm:max-w-[480px] sm:h-auto sm:max-h-[90%]'
contentClassName='!px-6 !py-4'
>
<div className='flex flex-col gap-5 pt-2'>
{/* Hero: cover + metadata. Cover gets a real shadow so it reads as a
physical book rather than a bordered thumbnail. Author + format
collapse into a single muted line for cleaner hierarchy. */}
<div className='flex items-center gap-4'>
{book.coverImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={book.coverImageUrl}
alt=''
className='h-28 w-20 shrink-0 rounded-lg object-cover shadow-md'
loading='lazy'
/>
) : (
<div className='bg-base-200 flex h-28 w-20 shrink-0 items-center justify-center rounded-lg shadow-md'>
<IoLinkOutline className='text-base-content/30 h-8 w-8' aria-hidden='true' />
</div>
)}
<div className='min-w-0 flex-1'>
<div className='text-base-content line-clamp-2 text-lg font-semibold leading-tight'>
{book.title}
</div>
<div className='text-base-content/60 mt-1.5 truncate text-sm'>
{[book.author, book.format, formatBytes(fileSize)].filter(Boolean).join(' · ')}
</div>
</div>
</div>
{!created ? (
<>
{/* Settings-card group: each row is a label + control, separated
by a hairline divider. Mirrors the iOS native settings idiom. */}
<div className='bg-base-200/60 divide-base-content/5 divide-y overflow-hidden rounded-2xl'>
{/* Both rows share min-h-12 so the segmented control's internal
pill height doesn't make this row visibly taller than the
toggle row below. */}
<div className='flex min-h-12 items-center justify-between gap-3 px-4 py-2'>
<span className='text-base-content text-sm font-medium'>{_('Expires in')}</span>
<SegmentedControl<number>
ariaLabel={_('Expires in')}
value={expirationDays}
onChange={setExpirationDays}
disabled={generating}
options={SHARE_EXPIRATION_DAYS.map((n) => ({
value: n,
label: _('{{count}} days', { count: n }),
}))}
/>
</div>
{cfi && (
<label className='flex min-h-12 cursor-pointer select-none items-center justify-between gap-3 px-4 py-2'>
<span className='text-base-content text-sm font-medium'>
{_('Share current page')}
</span>
<input
type='checkbox'
className='toggle toggle-primary'
checked={includeCfi}
onChange={(e) => setIncludeCfi(e.target.checked)}
disabled={generating}
/>
</label>
)}
</div>
{uploadProgress !== null && (
<div>
<div className='text-base-content/70 mb-1.5 text-xs'>
{_('Uploading book…')} {Math.round(uploadProgress)}%
</div>
<progress
className='progress progress-primary w-full'
value={uploadProgress}
max={100}
/>
</div>
)}
{errorMessage && (
<p className='text-error text-xs' role='alert'>
{errorMessage}
</p>
)}
<button
type='button'
onClick={handleGenerate}
disabled={generating}
className='btn btn-primary btn-block gap-2 rounded-2xl'
>
<IoLinkOutline className='h-5 w-5' aria-hidden='true' />
{generating ? _('Generating…') : _('Generate share link')}
</button>
</>
) : (
<>
{created.hasCfi && (
<div className='text-primary inline-flex items-center gap-1.5 self-start text-xs font-medium'>
<IoCheckmarkCircle className='h-4 w-4' aria-hidden='true' />
{_('Includes your current page')}
</div>
)}
{/* URL pill: softer than a bordered input, with the copy button
as an inline action chip. */}
<div className='bg-base-200/60 flex items-center gap-2 rounded-2xl p-2 pl-4'>
<input
type='text'
readOnly
value={created.url}
aria-label={_('Share URL')}
className='text-base-content min-w-0 flex-1 bg-transparent font-mono text-xs outline-none'
onFocus={(e) => e.currentTarget.select()}
/>
<button
type='button'
onClick={handleCopy}
className={`btn btn-sm gap-1 rounded-xl ${copied ? 'btn-success' : 'btn-primary'}`}
aria-label={_('Copy link')}
>
{copied ? (
<IoCheckmarkCircle className='h-4 w-4' aria-hidden='true' />
) : (
<IoCopyOutline className='h-4 w-4' aria-hidden='true' />
)}
{copied ? _('Copied') : _('Copy')}
</button>
</div>
<button
type='button'
onClick={handleNativeShare}
className='btn btn-block gap-2 rounded-2xl'
>
<IoShareSocialOutline className='h-5 w-5' aria-hidden='true' />
{_('Share via…')}
</button>
<p className='text-base-content/60 text-center text-xs'>
{_('Expires {{date}}', { date: expiryLabel ?? '' })}
<span className='mx-1.5'>·</span>
<button
type='button'
onClick={handleRevoke}
disabled={revoking}
className='link link-error text-xs'
>
{revoking ? _('Revoking…') : _('Revoke share')}
</button>
</p>
{errorMessage && (
<p className='text-error text-xs' role='alert'>
{errorMessage}
</p>
)}
</>
)}
</div>
</Dialog>
);
};
export default ShareBookDialog;
@@ -42,6 +42,7 @@ import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { useAppUrlIngress } from '@/hooks/useAppUrlIngress';
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { useOpenAnnotationLink } from '@/hooks/useOpenAnnotationLink';
import { useOpenShareLink } from '@/hooks/useOpenShareLink';
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
import { SelectedFile, useFileSelector } from '@/hooks/useFileSelector';
import { lockScreenOrientation, selectDirectory } from '@/utils/bridge';
@@ -164,6 +165,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
useAppUrlIngress();
useOpenWithBooks();
useOpenAnnotationLink();
useOpenShareLink();
useTransferQueue(libraryLoaded);
const { pullLibrary, pushLibrary } = useBooksSync();
+3 -34
View File
@@ -1,12 +1,14 @@
'use client';
import { Suspense, useEffect, useState } from 'react';
import Image from 'next/image';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
import { IoAlertCircleOutline, IoBookOutline, IoOpenOutline } from 'react-icons/io5';
import { DOWNLOAD_READEST_URL, READEST_WEB_BASE_URL } from '@/services/constants';
import { useTranslation } from '@/hooks/useTranslation';
import { buildAnnotationAppUrl } from '@/utils/deeplink';
import { BrandHeader } from '@/components/landing/BrandHeader';
import { Card } from '@/components/landing/Card';
import { PageFooter } from '@/components/landing/PageFooter';
type Platform = 'android-chromium' | 'android-other' | 'ios' | 'desktop' | 'unknown';
@@ -37,39 +39,6 @@ const buildWebReaderUrl = (bookHash: string, cfi: string | null): string => {
return `/reader/${bookHash}${query}`;
};
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>
);
const BrandHeader: React.FC<{ title: string; subtitle: string; alt: string }> = ({
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>
<p className='text-base-content/70 mt-2 text-sm'>{subtitle}</p>
</div>
);
const PageFooter: React.FC<{ tagline: string }> = ({ 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>
);
const OpenAnnotationLanding = () => {
const _ = useTranslation();
const router = useRouter();
@@ -26,6 +26,7 @@ import WindowButtons from '@/components/WindowButtons';
import QuickActionMenu from './annotator/QuickActionMenu';
import SidebarToggler from './SidebarToggler';
import BookmarkToggler from './BookmarkToggler';
import ShareToggler from './ShareToggler';
import NotebookToggler from './NotebookToggler';
import SettingsToggler from './SettingsToggler';
import TranslationToggler from './TranslationToggler';
@@ -219,6 +220,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
<VscLibrary size={iconSize18} className='fill-base-content' />
</button>
<BookmarkToggler bookKey={bookKey} />
<ShareToggler bookKey={bookKey} />
<TranslationToggler bookKey={bookKey} />
</div>
{enableAnnotationQuickActions && (
@@ -28,6 +28,8 @@ import {
import { clearDiscordPresence } from '@/utils/discord';
import { BOOK_IDS_SEPARATOR } from '@/services/constants';
import { BookDetailModal } from '@/components/metadata';
import ShareBookDialog from '@/app/library/components/ShareBookDialog';
import { useAuth } from '@/context/AuthContext';
import useBooksManager from '../hooks/useBooksManager';
import useBookShortcuts from '../hooks/useBookShortcuts';
@@ -50,6 +52,11 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
const { initViewState, getViewState, clearViewState } = useReaderStore();
const { isSettingsDialogOpen, settingsDialogBookKey } = useSettingsStore();
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
const [shareDialogState, setShareDialogState] = useState<{
book: Book;
cfi: string | null;
} | null>(null);
const { user } = useAuth();
const isInitiating = useRef(false);
const [loading, setLoading] = useState(false);
const [errorLoading, setErrorLoading] = useState(false);
@@ -104,6 +111,29 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
};
}, []);
useEffect(() => {
const handleShareIntent = (event: CustomEvent) => {
const detail = event.detail as { book: Book; cfi?: string | null } | undefined;
if (!detail?.book) return;
if (!user) {
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Sign in to share books'),
timeout: 2500,
});
return;
}
setShareDialogState({
book: detail.book,
cfi: detail.cfi ?? null,
});
};
eventDispatcher.on('show-share-dialog', handleShareIntent);
return () => {
eventDispatcher.off('show-share-dialog', handleShareIntent);
};
}, [user, _]);
useEffect(() => {
if (bookKeys && bookKeys.length > 0) {
const settings = useSettingsStore.getState().settings;
@@ -249,6 +279,12 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
onClose={() => setShowDetailsBook(null)}
/>
)}
<ShareBookDialog
isOpen={!!shareDialogState}
book={shareDialogState?.book ?? null}
cfi={shareDialogState?.cfi ?? null}
onClose={() => setShareDialogState(null)}
/>
</div>
);
};
@@ -0,0 +1,44 @@
import React, { useCallback } from 'react';
import { IoShareOutline } from 'react-icons/io5';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { eventDispatcher } from '@/utils/event';
interface ShareTogglerProps {
bookKey: string;
}
// Reader top-bar Share button. Matches BookmarkToggler/TranslationToggler
// shape so it slots into the existing header rhythm.
const ShareToggler: React.FC<ShareTogglerProps> = ({ bookKey }) => {
const _ = useTranslation();
const iconSize18 = useResponsiveSize(18);
const { getProgress } = useReaderStore();
const { getBookData } = useBookDataStore();
const handleShare = useCallback(() => {
const bookData = getBookData(bookKey);
if (!bookData?.book) return;
const progress = getProgress(bookKey);
eventDispatcher.dispatch('show-share-dialog', {
book: bookData.book,
cfi: progress?.location ?? null,
});
}, [bookKey, getBookData, getProgress]);
return (
<button
title={_('Share Book')}
type='button'
onClick={handleShare}
className='btn btn-ghost h-8 min-h-8 w-8 p-0'
aria-label={_('Share Book')}
>
<IoShareOutline size={iconSize18} className='fill-base-content' />
</button>
);
};
export default ShareToggler;
+2
View File
@@ -6,6 +6,7 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useAppUrlIngress } from '@/hooks/useAppUrlIngress';
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { useOpenAnnotationLink } from '@/hooks/useOpenAnnotationLink';
import { useOpenShareLink } from '@/hooks/useOpenShareLink';
import { useSettingsStore } from '@/store/settingsStore';
import { checkForAppUpdates, checkAppReleaseNotes } from '@/helpers/updater';
import { tauriHandleSetAlwaysOnTop } from '@/utils/window';
@@ -20,6 +21,7 @@ export default function Page() {
useAppUrlIngress();
useOpenWithBooks();
useOpenAnnotationLink();
useOpenShareLink();
useEffect(() => {
const doCheckAppUpdates = async () => {
+75
View File
@@ -0,0 +1,75 @@
import type { Metadata, ResolvingMetadata } from 'next';
import { READEST_WEB_BASE_URL, SHARE_BASE_URL } from '@/services/constants';
import { resolveActiveShare } from '@/libs/share-server';
// Server-rendered metadata for chat unfurls. The /s page itself is a client
// component that mirrors /o/page.tsx, but the OG/Twitter tags must be in the
// initial HTML so iMessage / WhatsApp / Twitter / Slack crawlers can read
// them without executing JS.
//
// In the Tauri build (output: 'export'), this whole route is dropped because
// rewrites and dynamic metadata require a server. Tauri intercepts the
// readest://share/{token} deep link before /s ever loads.
interface LayoutProps {
children: React.ReactNode;
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}
interface MetadataProps {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
export async function generateMetadata(
{ searchParams }: MetadataProps,
_parent: ResolvingMetadata,
): Promise<Metadata> {
const params = (await searchParams) ?? {};
const tokenParam = params['token'];
const token = Array.isArray(tokenParam) ? tokenParam[0] : tokenParam;
if (!token) {
return {
title: 'Open in Readest',
description: 'Open-source ebook reader for everyone, on every device.',
};
}
const result = await resolveActiveShare(token);
if (!result.ok) {
return {
title: 'Share link unavailable · Readest',
description: 'This share link is no longer available.',
};
}
const { share } = result;
const shareUrl = `${SHARE_BASE_URL}/${token}`;
const ogImage = `${READEST_WEB_BASE_URL}/api/share/${token}/og.png`;
return {
title: `${share.bookTitle} · Shared via Readest`,
description: share.bookAuthor
? `${share.bookAuthor} · Shared via Readest`
: 'Shared via Readest',
openGraph: {
type: 'book',
url: shareUrl,
title: share.bookTitle,
description: share.bookAuthor
? `${share.bookAuthor} · Shared via Readest`
: 'Shared via Readest',
images: [{ url: ogImage, width: 1200, height: 630 }],
},
twitter: {
card: 'summary_large_image',
title: share.bookTitle,
description: share.bookAuthor
? `${share.bookAuthor} · Shared via Readest`
: 'Shared via Readest',
images: [ogImage],
},
};
}
export default function ShareLandingLayout({ children }: LayoutProps) {
return <>{children}</>;
}
+321
View File
@@ -0,0 +1,321 @@
'use client';
import { Suspense, useEffect, useState } from 'react';
import Image from 'next/image';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import {
IoAlertCircleOutline,
IoBookOutline,
IoCloudDownloadOutline,
IoLibraryOutline,
IoOpenOutline,
} from 'react-icons/io5';
import { DOWNLOAD_READEST_URL } from '@/services/constants';
import { useTranslation, type TranslationFunc } from '@/hooks/useTranslation';
import { useAuth } from '@/context/AuthContext';
import { BrandHeader } from '@/components/landing/BrandHeader';
import { Card } from '@/components/landing/Card';
import { PageFooter } from '@/components/landing/PageFooter';
import { confirmDownload, getShare, importShare, type ShareMetadata } from '@/libs/share';
import { formatBytes } from '@/utils/book';
const formatExpiry = (iso: string, _: TranslationFunc): string => {
const ms = new Date(iso).getTime() - Date.now();
const days = Math.round(ms / (24 * 60 * 60 * 1000));
const hours = Math.round(ms / (60 * 60 * 1000));
if (days >= 1) return _('Expires in {{count}} days', { count: days });
if (hours > 0) return _('Expires in {{count}} hours', { count: hours });
return _('Expiring soon');
};
const ShareLanding = () => {
const _ = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
const pathname = usePathname();
const { user } = useAuth();
// Resolve the token from either the rewritten query (?token=) or the pretty
// path (/s/{token}). The next.config.mjs rewrite handles the web build; the
// pathname fallback handles Tauri (output: 'export', no rewrites), dev
// sessions where the rewrite isn't picked up without a server restart, and
// any deploy where the rewrite gets misconfigured. Mirrors src/app/o/page.tsx.
let token = searchParams?.get('token') ?? '';
if (!token && pathname) {
const segments = pathname.split('/').filter(Boolean);
if (segments[0] === 's' && segments[1]) {
token = segments[1];
}
}
const [meta, setMeta] = useState<ShareMetadata | null>(null);
const [loadError, setLoadError] = useState<{ status: number; message: string } | null>(null);
const [importing, setImporting] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
useEffect(() => {
if (!token) {
setLoadError({ status: 400, message: _('Missing share token') });
return;
}
let cancelled = false;
(async () => {
try {
const data = await getShare(token);
if (!cancelled) setMeta(data);
} catch (err) {
if (!cancelled) {
const status =
err && typeof err === 'object' && 'status' in err && typeof err.status === 'number'
? err.status
: 500;
setLoadError({ status, message: err instanceof Error ? err.message : 'Unknown error' });
}
}
})();
return () => {
cancelled = true;
};
}, [token, _]);
const downloadHref = `/api/share/${encodeURIComponent(token)}/download`;
const appHref = `readest://share/${encodeURIComponent(token)}`;
const handleDownloadClick = () => {
// Best-effort analytics ping. Doesn't block the navigation.
if (token) confirmDownload(token);
};
const handleAddToLibrary = async () => {
if (!token || importing) return;
setImporting(true);
setImportError(null);
try {
const result = await importShare(token);
// The reader resolves `ids` via getBookByHash, so pass the book hash —
// not the `files.id` UUID. Same identifier used by /reader/:hash links.
const params = new URLSearchParams();
params.set('ids', result.bookHash);
if (result.cfi) params.set('cfi', result.cfi);
router.push(`/reader?${params.toString()}`);
} catch (err) {
setImporting(false);
const message = err instanceof Error ? err.message : _('Could not add to your library');
setImportError(message);
}
};
if (loadError) {
// Pick a body copy that reflects the actual failure mode. Network /
// unknown failures get the generic "try again" message; only confirmed
// expired/revoked/not-found responses get the "no longer available" copy.
// This makes misconfigurations debuggable without inspecting devtools.
const isUnavailable = loadError.status === 410 || loadError.status === 404;
const isInvalidToken = loadError.status === 400;
const heading = isUnavailable
? _('This share link is no longer available')
: isInvalidToken
? _("This link can't be opened")
: _('Could not load shared book');
const body = isUnavailable
? _('The original link may have expired or been revoked.')
: isInvalidToken
? _('The share link is missing required information.')
: _('Please check your connection and try again.');
return (
<main className='bg-base-200 flex min-h-dvh flex-col items-center justify-center p-4 sm:p-8'>
<Card>
<div className='flex flex-col items-center text-center'>
<div className='bg-base-200 mb-4 flex h-16 w-16 items-center justify-center rounded-2xl'>
<IoAlertCircleOutline className='text-base-content/60 h-8 w-8' />
</div>
<h1 className='text-base-content text-2xl font-semibold'>{heading}</h1>
<p className='text-base-content/70 mt-2 text-sm'>{body}</p>
<a
href={DOWNLOAD_READEST_URL}
target='_blank'
rel='noopener'
className='btn btn-ghost btn-block mt-6'
>
{_('Get Readest')}
</a>
</div>
</Card>
<PageFooter tagline={_('Open-source ebook reader for everyone, on every device.')} />
</main>
);
}
if (!meta) {
return (
<main className='bg-base-200 flex min-h-dvh flex-col items-center justify-center p-4 sm:p-8'>
<Card>
<BrandHeader title={_('Loading shared book…')} alt={_('Readest logo')} />
<div
className='mt-6 flex flex-col items-center gap-3 py-4'
role='status'
aria-live='polite'
>
<span className='loading loading-dots loading-md text-primary' aria-hidden='true' />
</div>
</Card>
<PageFooter tagline={_('Open-source ebook reader for everyone, on every device.')} />
</main>
);
}
const coverSrc = meta.hasCover ? `/api/share/${encodeURIComponent(token)}/cover` : null;
const expiryLabel = formatExpiry(meta.expiresAt, _);
return (
<main className='bg-base-200 flex min-h-dvh flex-col items-center justify-center p-4 sm:p-6'>
{/* Inline card instead of <Card>: this surface needs a wider container
on desktop (sm:max-w-2xl) and a horizontal cover+content layout
that the shared Card primitive doesn't support. The /o landing
stays on the narrow Card; only /s gets the wider treatment. */}
<div className='bg-base-100 border-base-300/60 mx-auto w-full max-w-md overflow-hidden rounded-2xl border shadow-xl sm:max-w-2xl'>
{/* Branded header — small Readest mark + headline. Stays compact so
the card still fits on common viewports without scroll, but
gives the page identity at a glance. */}
<div className='flex flex-col items-center gap-2 px-5 pb-2 pt-5 sm:px-7 sm:pb-3 sm:pt-7'>
<Image
src='/icon.png'
alt={_('Readest logo')}
width={40}
height={40}
priority
className='rounded-lg'
/>
<span className='text-base-content text-base font-semibold'>{_('Shared with you')}</span>
</div>
<div className='flex flex-col items-center gap-5 px-5 pb-5 sm:flex-row sm:items-stretch sm:gap-7 sm:px-7 sm:pb-7'>
{/* Cover: dominant visual anchor. aspect-[2/3] keeps the box the
right shape whether or not the image loaded — stable layout
while the cover fetches. */}
<div className='aspect-[2/3] w-32 shrink-0 overflow-hidden rounded-lg shadow-lg sm:w-40 sm:self-center'>
{coverSrc ? (
// Plain <img>: source is a presigned URL that varies per
// request, so next/image's loader gives no win.
// eslint-disable-next-line @next/next/no-img-element
<img src={coverSrc} alt='' className='h-full w-full object-cover' loading='eager' />
) : (
<div className='bg-base-200 flex h-full w-full items-center justify-center'>
<IoBookOutline className='text-base-content/30 h-10 w-10' aria-hidden='true' />
</div>
)}
</div>
{/* Content column: title, author, meta line, then actions.
Centered on mobile, left-aligned on desktop where it sits to
the right of the cover. */}
<div className='flex min-w-0 flex-1 flex-col items-center text-center sm:items-start sm:justify-center sm:text-left'>
<h1 className='text-base-content line-clamp-3 text-xl font-semibold leading-tight sm:text-2xl'>
{meta.title}
</h1>
{meta.author && (
<p className='text-base-content/70 mt-1 truncate text-sm'>{meta.author}</p>
)}
<p className='text-base-content/50 mt-2 text-xs'>
{meta.format.toUpperCase()} · {formatBytes(meta.size)} · {expiryLabel}
</p>
<div className='mt-4 flex w-full flex-col gap-2 sm:mt-5'>
{user ? (
<>
<button
type='button'
onClick={handleAddToLibrary}
disabled={importing}
className='btn btn-primary btn-block flex-nowrap gap-2 whitespace-nowrap rounded-xl'
>
<IoLibraryOutline className='h-5 w-5' aria-hidden='true' />
{importing ? _('Adding…') : _('Add to my library')}
</button>
{/* Secondary actions side-by-side. flex-nowrap +
whitespace-nowrap override daisyUI's default
`flex-wrap: wrap` on .btn so the icon and label stay
on one line in narrow viewports. */}
<div className='flex gap-2'>
<a
href={downloadHref}
onClick={handleDownloadClick}
className='btn btn-ghost btn-sm flex-1 flex-nowrap gap-1.5 whitespace-nowrap rounded-xl'
>
<IoCloudDownloadOutline className='h-4 w-4' aria-hidden='true' />
{_('Download')}
</a>
<a
href={appHref}
className='btn btn-ghost btn-sm flex-1 flex-nowrap gap-1.5 whitespace-nowrap rounded-xl'
>
<IoOpenOutline className='h-4 w-4' aria-hidden='true' />
{_('Open in app')}
</a>
</div>
{importError && (
<p className='text-error mt-1 text-center text-xs sm:text-left' role='alert'>
{importError}
</p>
)}
</>
) : (
<>
{/* Anonymous flow: only 2 actions, so they sit side-by-side
on one row. The 3-action logged-in flow above keeps its
stacked design (primary + 2 secondary). Download stays
`btn-primary` to keep the "main thing" hierarchy clear,
Open in app is ghost. Labels match the logged-in flow's
shorter forms to avoid wrap in the tighter row layout. */}
<div className='flex w-full gap-2'>
<a
href={downloadHref}
onClick={handleDownloadClick}
className='btn btn-primary flex-1 flex-nowrap gap-1.5 whitespace-nowrap rounded-xl'
>
<IoCloudDownloadOutline className='h-5 w-5' aria-hidden='true' />
{_('Download')}
</a>
<a
href={appHref}
className='btn btn-ghost flex-1 flex-nowrap gap-1.5 whitespace-nowrap rounded-xl'
>
<IoOpenOutline className='h-5 w-5' aria-hidden='true' />
{_('Open in app')}
</a>
</div>
<p className='text-base-content/60 mt-1 text-center text-xs sm:text-left'>
{_("Don't have Readest?")}{' '}
<a
href={DOWNLOAD_READEST_URL}
target='_blank'
rel='noopener'
className='text-primary font-medium hover:underline'
>
{_('Download Readest')}
</a>
</p>
</>
)}
</div>
</div>
</div>
</div>
<PageFooter tagline={_('Open-source ebook reader for everyone, on every device.')} />
</main>
);
};
const Page = () => {
// useSearchParams must be wrapped in Suspense per Next 16 client-component
// contract, mirrors src/app/o/page.tsx.
return (
<Suspense fallback={null}>
<ShareLanding />
</Suspense>
);
};
export default Page;
@@ -55,6 +55,7 @@ interface AccountActionsProps {
onRestorePurchase?: () => void;
onManageSubscription?: () => void;
onManageStorage?: () => void;
onManageSharedLinks?: () => void;
}
const AccountActions: React.FC<AccountActionsProps> = ({
@@ -67,6 +68,7 @@ const AccountActions: React.FC<AccountActionsProps> = ({
onRestorePurchase,
onManageSubscription,
onManageStorage,
onManageSharedLinks,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
@@ -116,6 +118,14 @@ const AccountActions: React.FC<AccountActionsProps> = ({
{_('Manage Storage')}
</button>
)}
{onManageSharedLinks && (
<button
onClick={onManageSharedLinks}
className='w-full rounded-lg bg-purple-100 px-6 py-3 font-medium text-purple-600 transition-colors hover:bg-purple-200 md:w-auto'
>
{_('Manage Shared Links')}
</button>
)}
<button
onClick={onResetPassword}
className='w-full rounded-lg bg-gray-200 px-6 py-3 font-medium text-gray-800 transition-colors hover:bg-gray-300 md:w-auto'
@@ -0,0 +1,350 @@
'use client';
import React, { useCallback, useEffect, useState } from 'react';
import {
IoBookOutline,
IoCopyOutline,
IoLinkOutline,
IoShareSocialOutline,
IoTrashOutline,
} from 'react-icons/io5';
import { PiDotsThreeVerticalBold } from 'react-icons/pi';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { eventDispatcher } from '@/utils/event';
import { listShares, revokeShare } from '@/libs/share';
import { formatBytes } from '@/utils/book';
import Dropdown from '@/components/Dropdown';
import Menu from '@/components/Menu';
import MenuItem from '@/components/MenuItem';
interface ShareRow {
id: string;
token: string;
bookHash: string;
title: string;
author: string | null;
format: string;
size: number;
hasCfi: boolean;
expiresAt: string;
revokedAt: string | null;
downloadCount: number;
createdAt: string;
}
type Status = 'active' | 'expiring' | 'expired' | 'revoked';
const getStatus = (row: ShareRow): Status => {
if (row.revokedAt) return 'revoked';
const ms = new Date(row.expiresAt).getTime() - Date.now();
if (ms <= 0) return 'expired';
if (ms < 24 * 60 * 60 * 1000) return 'expiring';
return 'active';
};
// Small inline cover thumbnail. Falls back to a book-icon placeholder when the
// cover endpoint 404s (no cover uploaded) or 410s (share revoked/expired).
const ShareCover: React.FC<{ token: string; alt: string }> = ({ token, alt }) => {
const [failed, setFailed] = useState(false);
if (failed) {
return (
<div className='border-base-300 bg-base-200 flex h-14 w-10 shrink-0 items-center justify-center rounded border'>
<IoBookOutline className='text-base-content/40 h-5 w-5' aria-hidden='true' />
</div>
);
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/share/${encodeURIComponent(token)}/cover`}
alt={alt}
onError={() => setFailed(true)}
className='border-base-300 bg-base-200 h-14 w-10 shrink-0 rounded border object-cover'
loading='lazy'
/>
);
};
const SharedLinksSection: React.FC = () => {
const _ = useTranslation();
const { appService } = useEnv();
const [rows, setRows] = useState<ShareRow[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [shareUrlBase, setShareUrlBase] = useState<string>('');
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadPage = useCallback(
async (next: string | null, append: boolean) => {
if (append) setLoadingMore(true);
else setLoading(true);
setError(null);
try {
const response = await listShares(next);
const incoming = response.shares as unknown as ShareRow[];
setShareUrlBase(
(response as unknown as { shareUrlBase?: string }).shareUrlBase ?? shareUrlBase,
);
setRows((prev) => (append ? [...prev, ...incoming] : incoming));
setCursor(response.nextCursor);
} catch (err) {
setError(err instanceof Error ? err.message : _('Could not load your shares'));
} finally {
setLoading(false);
setLoadingMore(false);
}
},
[_, shareUrlBase],
);
useEffect(() => {
void loadPage(null, false);
// Initial load only; pagination is driven by the Load more button.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const buildUrl = (row: ShareRow): string | null => {
if (!row.token || !shareUrlBase) return null;
return `${shareUrlBase}/${row.token}`;
};
const handleCopy = async (row: ShareRow) => {
const url = buildUrl(row);
if (!url) return;
try {
await navigator.clipboard.writeText(url);
eventDispatcher.dispatch('toast', {
type: 'success',
message: _('Link copied'),
timeout: 2000,
});
} catch {
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Could not copy link'),
timeout: 2000,
});
}
};
const handleNativeShare = async (row: ShareRow) => {
const url = buildUrl(row);
if (!url) return;
const title = row.title;
// See ShareBookDialog.handleNativeShare for the rationale: only fall
// through to copy when no native share method is available at all.
// User-dismissal of the share sheet must NOT silently copy the link.
if (appService?.isMobileApp || appService?.hasWindow) {
let sharekitWorked = false;
try {
const { shareText } = await import('@choochmeque/tauri-plugin-sharekit-api');
await shareText(`${title}\n${url}`);
sharekitWorked = true;
} catch (err) {
console.error('shareText failed; falling back:', err);
}
if (sharekitWorked) return;
}
if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') {
try {
await navigator.share({ title, url });
} catch {
// User dismissed or share-time error; respect the choice.
}
return;
}
await handleCopy(row);
};
const handleRevoke = async (row: ShareRow) => {
if (!row.token) return;
const previous = rows;
// Optimistic remove. On failure, restore.
setRows((current) => current.filter((r) => r.id !== row.id));
try {
await revokeShare(row.token);
eventDispatcher.dispatch('toast', {
type: 'success',
message: _('Share revoked'),
timeout: 2000,
});
} catch (err) {
setRows(previous);
eventDispatcher.dispatch('toast', {
type: 'error',
message: err instanceof Error ? err.message : _('Could not revoke share'),
timeout: 2500,
});
}
};
const renderExpiry = (row: ShareRow) => {
const status = getStatus(row);
if (status === 'revoked') return _('Revoked');
const ms = new Date(row.expiresAt).getTime() - Date.now();
if (status === 'expired') return _('Expired');
if (status === 'expiring') {
const hours = Math.max(1, Math.round(ms / (60 * 60 * 1000)));
return _('Expires in {{count}} hours', { count: hours });
}
const days = Math.max(1, Math.round(ms / (24 * 60 * 60 * 1000)));
return _('Expires in {{count}} days', { count: days });
};
const badgeClass = (status: Status) => {
if (status === 'active') return 'badge badge-info';
if (status === 'expiring') return 'badge badge-warning';
return 'badge badge-ghost';
};
if (loading) {
return (
<section>
<h3 className='text-base-content text-lg font-semibold'>{_('Shared books')}</h3>
<div className='mt-4 flex flex-col gap-2'>
{[0, 1, 2].map((k) => (
<div key={k} className='bg-base-200 h-16 w-full animate-pulse rounded-lg' />
))}
</div>
</section>
);
}
if (error) {
return (
<section>
<h3 className='text-base-content text-lg font-semibold'>{_('Shared books')}</h3>
<p className='text-error mt-2 text-sm'>{error}</p>
</section>
);
}
if (rows.length === 0) {
return (
<section>
<div className='flex items-baseline justify-between'>
<h3 className='text-base-content text-lg font-semibold'>{_('Shared books')}</h3>
</div>
<div className='border-base-300 mt-4 flex flex-col items-center gap-3 rounded-2xl border border-dashed p-8 text-center'>
<div className='bg-base-200 flex h-16 w-16 items-center justify-center rounded-2xl'>
<IoLinkOutline className='text-base-content/60 h-8 w-8' aria-hidden='true' />
</div>
<p className='text-base-content text-base font-semibold'>
{_("You haven't shared any books yet")}
</p>
<p className='text-base-content/70 text-sm'>
{_('Open a book and tap Share to send it to a friend.')}
</p>
</div>
</section>
);
}
const activeCount = rows.filter(
(r) => getStatus(r) === 'active' || getStatus(r) === 'expiring',
).length;
return (
<section>
<div className='flex items-baseline justify-between'>
<h3 className='text-base-content text-lg font-semibold'>{_('Shared books')}</h3>
<span className='text-base-content/60 text-xs'>
{_('{{count}} active', { count: activeCount })}
</span>
</div>
<ul className='border-base-300 mt-4 divide-y divide-[var(--fallback-bc,oklch(var(--bc)/0.1))] overflow-hidden rounded-2xl border'>
{rows.map((row) => {
const status = getStatus(row);
const dimmed = status === 'expired' || status === 'revoked';
return (
<li
key={row.id}
className={`flex items-center gap-3 p-3 sm:p-4 ${dimmed ? 'opacity-60' : ''}`}
>
<ShareCover token={row.token} alt={row.title} />
<div className='min-w-0 flex-1'>
<div className='text-base-content truncate text-sm font-medium'>{row.title}</div>
<div className='text-base-content/60 truncate text-xs'>
{row.author ?? '—'} · {row.format.toUpperCase()} · {formatBytes(row.size)}
</div>
<div className='mt-1 flex items-center gap-2 text-xs'>
<span className={badgeClass(status)}>{renderExpiry(row)}</span>
{row.downloadCount > 0 && (
<span className='text-base-content/60'>
{_('{{count}} downloads', { count: row.downloadCount })}
</span>
)}
{row.hasCfi && (
<span className='text-base-content/60'>{_('starts at saved page')}</span>
)}
</div>
</div>
{!dimmed && (
<div className='flex items-center gap-1'>
<button
type='button'
title={_('Copy link')}
aria-label={_('Copy link')}
onClick={() => handleCopy(row)}
className='btn btn-ghost btn-sm'
>
<IoCopyOutline className='h-4 w-4' aria-hidden='true' />
</button>
<button
type='button'
title={_('Share via…')}
aria-label={_('Share via…')}
onClick={() => handleNativeShare(row)}
className='btn btn-ghost btn-sm'
>
<IoShareSocialOutline className='h-4 w-4' aria-hidden='true' />
</button>
{/*
* Use the project's Dropdown component (Headless-UI-style with
* an Overlay backdrop) instead of daisyUI's <details>/<summary>
* pattern. The bare daisyUI version doesn't position correctly
* inside this scrollable settings layout — it gets clipped by
* the rounded-2xl border on the surrounding <ul>.
*/}
<Dropdown
label={_('More actions')}
className='dropdown-bottom dropdown-end'
buttonClassName='btn btn-ghost btn-sm'
toggleButton={
<PiDotsThreeVerticalBold className='h-4 w-4' aria-hidden='true' />
}
>
<Menu className='dropdown-content bg-base-100 rounded-box z-[1] w-44 border p-1 shadow'>
<MenuItem
label={_('Revoke share')}
Icon={IoTrashOutline}
onClick={() => handleRevoke(row)}
/>
</Menu>
</Dropdown>
</div>
)}
</li>
);
})}
</ul>
{cursor && (
<button
type='button'
onClick={() => loadPage(cursor, true)}
disabled={loadingMore}
className='btn btn-ghost btn-block mt-3'
>
{loadingMore ? _('Loading…') : _('Load more')}
</button>
)}
</section>
);
};
export default SharedLinksSection;
+14 -1
View File
@@ -40,6 +40,7 @@ import UsageStats from './components/UsageStats';
import PlansComparison from './components/PlansComparison';
import AccountActions from './components/AccountActions';
import StorageManager from './components/StorageManager';
import SharedLinksSection from './components/SharedLinksSection';
import Checkout from './components/Checkout';
type CheckoutState = {
@@ -58,6 +59,7 @@ const ProfilePage = () => {
const [loading, setLoading] = useState(false);
const [showEmbeddedCheckout, setShowEmbeddedCheckout] = useState(false);
const [showStorageManager, setShowStorageManager] = useState(false);
const [showSharedLinksManager, setShowSharedLinksManager] = useState(false);
const [checkoutState, setCheckoutState] = useState<CheckoutState>({
clientSecret: '',
sessionId: '',
@@ -105,6 +107,8 @@ const ProfilePage = () => {
} else if (showStorageManager) {
setShowStorageManager(false);
refresh();
} else if (showSharedLinksManager) {
setShowSharedLinksManager(false);
} else {
navigateToLibrary(router);
}
@@ -230,6 +234,10 @@ const ProfilePage = () => {
setShowStorageManager(true);
};
const handleManageSharedLinks = () => {
setShowSharedLinksManager(true);
};
if (!mounted) {
return null;
}
@@ -292,13 +300,17 @@ const ProfilePage = () => {
planDetails={userPlanDetails}
/>
{!showStorageManager && <UsageStats quotas={quotas} />}
{!showStorageManager && !showSharedLinksManager && <UsageStats quotas={quotas} />}
</div>
{showStorageManager ? (
<div className='flex flex-col gap-y-8 px-6'>
<StorageManager />
</div>
) : showSharedLinksManager ? (
<div className='flex flex-col gap-y-8 px-6'>
<SharedLinksSection />
</div>
) : (
<>
<div className='flex flex-col gap-y-8 sm:px-6'>
@@ -323,6 +335,7 @@ const ProfilePage = () => {
onRestorePurchase={handleIAPRestorePurchase}
onManageSubscription={handleManageSubscription}
onManageStorage={handleManageStorage}
onManageSharedLinks={handleManageSharedLinks}
/>
</div>
</>
@@ -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>
);
@@ -0,0 +1,132 @@
import { useCallback, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { getCurrent } from '@tauri-apps/plugin-deep-link';
import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
import { isTauriAppPlatform } from '@/services/environment';
import { eventDispatcher } from '@/utils/event';
import { useAuth } from '@/context/AuthContext';
import { navigateToReader } from '@/utils/nav';
import { ShareApiError, confirmDownload, importShare } from '@/libs/share';
import { parseShareDeepLink, type ShareDeepLink } from '@/utils/share';
import { useTranslation } from './useTranslation';
// Module-scoped flag matches the useOpenAnnotationLink pattern. Tauri's
// getCurrent() keeps returning the launch URL for the entire app session, so
// without this every remount would re-process the cold-start URL.
let coldStartConsumed = false;
/**
* Receive book share deep links and import the book into the user's library.
*
* Architecture:
* - useOpenWithBooks owns the Tauri URL channels and re-broadcasts every
* URL as the 'app-incoming-url' event. This hook subscribes for warm /
* live deliveries.
* - For cold-start, getCurrent() is read once at module scope.
* - Library-load deferral: on cold-start the URL may arrive before the
* library store hydrates. Stash and replay once libraryLoaded.
*
* Supported URL shapes (see src/utils/share.ts):
* readest://share/{token}
* https://web.readest.com/s/{token}
*
* Auth-gated paths:
* - Logged-in: POST /api/share/[token]/import (server-side R2 byte-copy),
* navigate to the new fileId in the reader at the sharer's cfi.
* - Logged-out: surface a toast directing the user to the web landing
* page where they can download anonymously. We don't try to do an
* anonymous download here — the in-app library is the user's space and
* a logged-out import has nowhere to land.
*/
export function useOpenShareLink() {
const _ = useTranslation();
const router = useRouter();
const { appService } = useEnv();
const { user } = useAuth();
const libraryLoaded = useLibraryStore((s) => s.libraryLoaded);
const pending = useRef<ShareDeepLink | null>(null);
const handleShareLink = useCallback(
async ({ token }: ShareDeepLink) => {
if (!user) {
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Sign in to import shared books'),
timeout: 2500,
});
return;
}
try {
const result = await importShare(token);
// Best-effort analytics ping; doesn't affect UX.
confirmDownload(token);
// The reader resolves IDs via getBookByHash, so navigate with the
// book hash (not the `files.id` UUID).
const queryParams = result.cfi ? `cfi=${encodeURIComponent(result.cfi)}` : undefined;
navigateToReader(router, [result.bookHash], queryParams);
eventDispatcher.dispatch('toast', {
type: 'success',
message: result.alreadyOwned ? _('Already in your library') : _('Added to your library'),
timeout: 2500,
});
} catch (err) {
const message =
err instanceof ShareApiError
? err.message
: err instanceof Error
? err.message
: _('Could not import shared book');
eventDispatcher.dispatch('toast', {
type: 'error',
message,
timeout: 3000,
});
}
},
[_, router, user],
);
useEffect(() => {
if (!isTauriAppPlatform() || !appService) return;
const handle = (url: string) => {
const parsed = parseShareDeepLink(url);
if (!parsed) return;
if (!useLibraryStore.getState().libraryLoaded) {
pending.current = parsed;
return;
}
void handleShareLink(parsed);
};
if (!coldStartConsumed) {
coldStartConsumed = true;
getCurrent()
.then((urls) => urls?.forEach(handle))
.catch(() => {
// Plugin not available on this platform — live channel still works.
});
}
const onIncoming = (event: CustomEvent) => {
const { urls } = event.detail as { urls: string[] };
urls.forEach(handle);
};
eventDispatcher.on('app-incoming-url', onIncoming);
return () => {
eventDispatcher.off('app-incoming-url', onIncoming);
};
}, [appService, handleShareLink]);
// Replay any deferred deep link once the library hydrates.
useEffect(() => {
if (!libraryLoaded || !pending.current) return;
const parsed = pending.current;
pending.current = null;
void handleShareLink(parsed);
}, [libraryLoaded, handleShareLink]);
}
+156
View File
@@ -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' } };
}
};
+150
View File
@@ -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.
}
};
@@ -728,6 +728,14 @@ export const DOWNLOAD_READEST_URL = 'https://readest.com?utm_source=readest_web'
export const READEST_WEB_BASE_URL = 'https://web.readest.com';
export const READEST_NODE_BASE_URL = 'https://node.readest.com';
export const SHARE_BASE_URL = `${READEST_WEB_BASE_URL}/s`;
export const SHARE_EXPIRATION_DAYS = [1, 3, 7] as const;
export const SHARE_DEFAULT_EXPIRATION_DAYS = 3;
export const SHARE_MAX_PER_USER = 50;
export const SHARE_TOKEN_LENGTH = 22;
export const SHARE_PRESIGN_TTL_SECONDS = 300;
export const SHARE_CFI_MAX_LENGTH = 512;
const LATEST_DOWNLOAD_BASE_URL = 'https://download.readest.com/releases';
export const READEST_UPDATER_FILE = `${LATEST_DOWNLOAD_BASE_URL}/latest.json`;
+36
View File
@@ -43,3 +43,39 @@ export const deleteObject = async (fileKey: string, bucketName?: string) => {
return await s3Storage.deleteObject(bucketName, fileKey);
}
};
// Returns true if the object exists in storage. Used to verify uploads completed
// before treating a `files` row as shareable.
export const objectExists = async (fileKey: string, bucketName?: string): Promise<boolean> => {
const storageType = getStorageType();
try {
if (storageType === 'r2') {
bucketName = bucketName || process.env['R2_BUCKET_NAME'] || '';
const response = await r2Storage.headObject(bucketName, fileKey);
return response.ok;
} else {
bucketName = bucketName || process.env['S3_BUCKET_NAME'] || '';
await s3Storage.headObject(bucketName, fileKey);
return true;
}
} catch {
return false;
}
};
// Server-side byte copy used by /api/share/[token]/import to clone a shared
// book into the recipient's namespace without egress.
export const copyObject = async (
sourceFileKey: string,
destFileKey: string,
bucketName?: string,
) => {
const storageType = getStorageType();
if (storageType === 'r2') {
bucketName = bucketName || process.env['R2_BUCKET_NAME'] || '';
return await r2Storage.copyObject(bucketName, sourceFileKey, destFileKey);
} else {
bucketName = bucketName || process.env['S3_BUCKET_NAME'] || '';
return await s3Storage.copyObject(bucketName, sourceFileKey, destFileKey);
}
};
+34
View File
@@ -59,4 +59,38 @@ export const r2Storage = {
method: 'DELETE',
});
},
headObject: async (bucketName: string, fileKey: string) => {
const response = await r2Storage
.getR2Client()
.fetch(`${r2Storage.getR2Url()}/${bucketName}/${fileKey}`, {
method: 'HEAD',
});
return response;
},
copyObject: async (
bucketName: string,
sourceFileKey: string,
destFileKey: string,
sourceBucketName?: string,
) => {
const srcBucket = sourceBucketName || bucketName;
// S3 / R2 require the copy-source header to be URL-encoded segment-by-
// segment. file_key is built from the original filename, so spaces and
// reserved chars (e.g. `My Book.epub`, `A&B.epub`) are common and would
// otherwise break the copy. We encode each path segment but keep the
// separating slashes literal.
const encodeKey = (key: string): string => key.split('/').map(encodeURIComponent).join('/');
const copySource = `/${srcBucket}/${encodeKey(sourceFileKey)}`;
const response = await r2Storage
.getR2Client()
.fetch(`${r2Storage.getR2Url()}/${bucketName}/${destFileKey}`, {
method: 'PUT',
headers: {
'x-amz-copy-source': copySource,
},
});
return response;
},
};
+37 -1
View File
@@ -1,5 +1,11 @@
import { S3Client } from '@aws-sdk/client-s3';
import { GetObjectCommand, DeleteObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import {
GetObjectCommand,
DeleteObjectCommand,
PutObjectCommand,
HeadObjectCommand,
CopyObjectCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const S3_ENDPOINT = process.env['S3_ENDPOINT'] || '';
@@ -71,4 +77,34 @@ export const s3Storage = {
return await s3Storage.getClient().send(deleteCommand);
},
headObject: async (bucketName: string, fileKey: string) => {
const headCommand = new HeadObjectCommand({
Bucket: bucketName,
Key: fileKey,
});
return await s3Storage.getClient().send(headCommand);
},
copyObject: async (
bucketName: string,
sourceFileKey: string,
destFileKey: string,
sourceBucketName?: string,
) => {
const srcBucket = sourceBucketName || bucketName;
// S3 requires CopySource to be URL-encoded segment-by-segment. file_key
// is built from the original filename, so spaces and reserved chars
// (e.g. `My Book.epub`, `A&B.epub`) are common and would otherwise
// break the copy.
const encodeKey = (key: string): string => key.split('/').map(encodeURIComponent).join('/');
const copyCommand = new CopyObjectCommand({
Bucket: bucketName,
Key: destFileKey,
CopySource: `${srcBucket}/${encodeKey(sourceFileKey)}`,
});
return await s3Storage.getClient().send(copyCommand);
},
};
+53
View File
@@ -0,0 +1,53 @@
import { READEST_WEB_BASE_URL, SHARE_BASE_URL, SHARE_TOKEN_LENGTH } from '@/services/constants';
export interface ShareDeepLink {
token: string;
// Reserved for future query params (e.g., recipient locale, share variant).
// Currently no params are emitted, but parseShareDeepLink preserves the
// shape so callers don't need to be updated when more arrive.
}
const TOKEN_RE = new RegExp(`^[A-Za-z0-9]{${SHARE_TOKEN_LENGTH}}$`);
const isValidToken = (raw: unknown): raw is string => typeof raw === 'string' && TOKEN_RE.test(raw);
// Canonical share URL embedded in the dialog, share sheet, and any "copy link"
// affordance. Always points at the public web target.
export const buildShareUrl = (token: string): string => `${SHARE_BASE_URL}/${token}`;
// Parses both the custom-scheme and HTTPS forms used by the deeplink ingress.
// readest://share/{token}
// https://web.readest.com/s/{token}
// Returns null on invalid input so callers can fall through to other parsers.
export const parseShareDeepLink = (url: string): ShareDeepLink | null => {
if (!url) return null;
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return null;
}
if (parsed.protocol === 'readest:') {
// For readest://share/{token} the host portion holds the path segment
// before the slash. Use pathname for the token; url.host == 'share'.
if (parsed.host !== 'share') return null;
const token = parsed.pathname.replace(/^\/+/, '').replace(/\/+$/, '');
return isValidToken(token) ? { token } : null;
}
if (parsed.protocol === 'https:' || parsed.protocol === 'http:') {
if (!isWebReadestHost(parsed.host)) return null;
const segments = parsed.pathname.split('/').filter(Boolean);
if (segments.length !== 2 || segments[0] !== 's') return null;
const token = segments[1]!;
return isValidToken(token) ? { token } : null;
}
return null;
};
const isWebReadestHost = (host: string): boolean => {
// Matches the production host and any preview domain Readest may serve from.
// Conservative: accepts only the exact production host or a *.readest.com
// subdomain so a third-party site cannot impersonate a share URL.
if (host === new URL(READEST_WEB_BASE_URL).host) return true;
return host.endsWith('.readest.com');
};