feat(share): time-limited share links with cfi-aware imports (#4037)
Add a Share Book feature that generates an expiring HTTPS share URL plus a
parallel readest://share/{token} deep link. Recipients land on /s/{token},
where logged-in users can one-tap "Add to my library" (R2 server-side
byte-copy) and anonymous users download the book or open it in the app.
Sharers manage active links from a dedicated "Manage Shared Links" panel
under user settings.
Highlights:
- 9 new App Router endpoints under /api/share (create, [token], cover,
og.png, download, download/confirm, import, revoke, list).
- /s landing page with branded next/og chat unfurl image and SSR auth-cookie
detection so logged-in recipients see "Add to my library" as the primary
action without layout shift.
- Server-side R2 byte-copy for /import preserves the project's existing
invariant that every files.file_key is prefixed with its row's user_id;
stats / purge / delete / download routes work unchanged. URL-encodes the
copy source so titles with spaces or '&' don't break the copy.
- Universal 7-day expiry cap, no tier differentiation, no "never". DMCA-risk
reduction. Picker defaults to 3 days.
- Position-aware shares: "Share current page" toggle (off by default for
privacy) attaches the sharer's CFI; recipient lands at the same paragraph.
- Per-user 50-share cap, rate limiting via Cache-Control: no-store on
token-bearing responses, atomic SQL increment for download_count via a
SECURITY DEFINER function so the public confirm beacon stays safe under
concurrent fire.
- Soft revocation: presigned download URLs (5-min TTL) cannot be cancelled
before TTL; documented as accepted v1 behavior.
- token + token_hash hybrid storage: public endpoints look up by hash and
never select the raw token, so accidental SELECT-* leakage on a public
route can't expose the bearer credential.
- Mobile / desktop Tauri share via tauri-plugin-sharekit; web falls back to
navigator.share with a clipboard fallback when no native share method
exists. Share-sheet dismissal no longer silently copies.
UI:
- New Dialog with a settings-card group: iOS-style segmented duration picker
+ toggle slider for "Share current page", on a single row each.
- Reader top-bar Share button, library context-menu Share entry, manage-
shares list with cover thumbnails and overflow menu.
- New <SegmentedControl> primitive in src/components for reuse.
Coverage:
- Unit tests for token utils + URL parser (20 new tests, full suite at 3445).
- 31 locales translated for all new strings; en plurals hand-added per the
project's hand-curated en convention.
DB migration in docker/volumes/db/migrations/002_add_book_shares.sql adds
the book_shares table, RLS policies, and the increment_book_share_download
RPC. Migration is idempotent.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user