d1e7b4902c
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>
76 lines
2.4 KiB
TypeScript
76 lines
2.4 KiB
TypeScript
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}</>;
|
|
}
|