Files
readest/apps/readest-app/src/utils/r2.ts
T
Huang Xin d1e7b4902c feat(share): time-limited share links with cfi-aware imports (#4037)
Add a Share Book feature that generates an expiring HTTPS share URL plus a
parallel readest://share/{token} deep link. Recipients land on /s/{token},
where logged-in users can one-tap "Add to my library" (R2 server-side
byte-copy) and anonymous users download the book or open it in the app.
Sharers manage active links from a dedicated "Manage Shared Links" panel
under user settings.

Highlights:
- 9 new App Router endpoints under /api/share (create, [token], cover,
  og.png, download, download/confirm, import, revoke, list).
- /s landing page with branded next/og chat unfurl image and SSR auth-cookie
  detection so logged-in recipients see "Add to my library" as the primary
  action without layout shift.
- Server-side R2 byte-copy for /import preserves the project's existing
  invariant that every files.file_key is prefixed with its row's user_id;
  stats / purge / delete / download routes work unchanged. URL-encodes the
  copy source so titles with spaces or '&' don't break the copy.
- Universal 7-day expiry cap, no tier differentiation, no "never". DMCA-risk
  reduction. Picker defaults to 3 days.
- Position-aware shares: "Share current page" toggle (off by default for
  privacy) attaches the sharer's CFI; recipient lands at the same paragraph.
- Per-user 50-share cap, rate limiting via Cache-Control: no-store on
  token-bearing responses, atomic SQL increment for download_count via a
  SECURITY DEFINER function so the public confirm beacon stays safe under
  concurrent fire.
- Soft revocation: presigned download URLs (5-min TTL) cannot be cancelled
  before TTL; documented as accepted v1 behavior.
- token + token_hash hybrid storage: public endpoints look up by hash and
  never select the raw token, so accidental SELECT-* leakage on a public
  route can't expose the bearer credential.
- Mobile / desktop Tauri share via tauri-plugin-sharekit; web falls back to
  navigator.share with a clipboard fallback when no native share method
  exists. Share-sheet dismissal no longer silently copies.

UI:
- New Dialog with a settings-card group: iOS-style segmented duration picker
  + toggle slider for "Share current page", on a single row each.
- Reader top-bar Share button, library context-menu Share entry, manage-
  shares list with cover thumbnails and overflow menu.
- New <SegmentedControl> primitive in src/components for reuse.

Coverage:
- Unit tests for token utils + URL parser (20 new tests, full suite at 3445).
- 31 locales translated for all new strings; en plurals hand-added per the
  project's hand-curated en convention.

DB migration in docker/volumes/db/migrations/002_add_book_shares.sql adds
the book_shares table, RLS policies, and the increment_book_share_download
RPC. Migration is idempotent.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:03:35 +02:00

97 lines
2.8 KiB
TypeScript

import { AwsClient } from 'aws4fetch';
export const r2Storage = {
getR2Client: () => {
return new AwsClient({
service: 's3',
region: process.env['R2_REGION'] || 'auto',
accessKeyId: process.env['R2_ACCESS_KEY_ID']!,
secretAccessKey: process.env['R2_SECRET_ACCESS_KEY']!,
});
},
getR2Url: () => {
const R2_ACCOUNT_ID = process.env['R2_ACCOUNT_ID']!;
return `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`;
},
getDownloadSignedUrl: async (bucketName: string, fileKey: string, expiresIn: number) => {
return (
await r2Storage
.getR2Client()
.sign(
new Request(
`${r2Storage.getR2Url()}/${bucketName}/${fileKey}?X-Amz-Expires=${expiresIn}`,
),
{
aws: { signQuery: true },
},
)
).url.toString();
},
getUploadSignedUrl: async (
bucketName: string,
fileKey: string,
contentLength: number,
expiresIn: number,
) => {
return (
await r2Storage.getR2Client().sign(
new Request(
`${r2Storage.getR2Url()}/${bucketName}/${fileKey}?X-Amz-Expires=${expiresIn}&X-Amz-SignedHeaders=content-length`,
{
method: 'PUT',
headers: {
'Content-Length': contentLength.toString(),
},
},
),
{
aws: { signQuery: true },
},
)
).url.toString();
},
deleteObject: async (bucketName: string, fileKey: string) => {
return await r2Storage.getR2Client().fetch(`${r2Storage.getR2Url()}/${bucketName}/${fileKey}`, {
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;
},
};