forked from akai/readest
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>
111 lines
3.0 KiB
TypeScript
111 lines
3.0 KiB
TypeScript
import { S3Client } 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'] || '';
|
|
const S3_REGION = process.env['S3_REGION'] || 'auto';
|
|
const S3_ACCESS_KEY_ID = process.env['S3_ACCESS_KEY_ID'] || '';
|
|
const S3_SECRET_ACCESS_KEY = process.env['S3_SECRET_ACCESS_KEY'] || '';
|
|
|
|
export const s3Client = new S3Client({
|
|
forcePathStyle: true,
|
|
region: S3_REGION,
|
|
endpoint: S3_ENDPOINT,
|
|
credentials: {
|
|
accessKeyId: S3_ACCESS_KEY_ID,
|
|
secretAccessKey: S3_SECRET_ACCESS_KEY,
|
|
},
|
|
});
|
|
|
|
export const s3Storage = {
|
|
getClient: () => {
|
|
return new S3Client({
|
|
forcePathStyle: true,
|
|
region: S3_REGION,
|
|
endpoint: S3_ENDPOINT,
|
|
credentials: {
|
|
accessKeyId: S3_ACCESS_KEY_ID,
|
|
secretAccessKey: S3_SECRET_ACCESS_KEY,
|
|
},
|
|
});
|
|
},
|
|
|
|
getDownloadSignedUrl: async (bucketName: string, fileKey: string, expiresIn: number) => {
|
|
const getCommand = new GetObjectCommand({
|
|
Bucket: bucketName,
|
|
Key: fileKey,
|
|
});
|
|
const downloadUrl = await getSignedUrl(s3Client, getCommand, {
|
|
expiresIn: expiresIn,
|
|
});
|
|
return downloadUrl;
|
|
},
|
|
|
|
getUploadSignedUrl: async (
|
|
bucketName: string,
|
|
fileKey: string,
|
|
contentLength: number,
|
|
expiresIn: number,
|
|
) => {
|
|
const signableHeaders = new Set<string>();
|
|
signableHeaders.add('content-length');
|
|
const putCommand = new PutObjectCommand({
|
|
Bucket: bucketName,
|
|
Key: fileKey,
|
|
ContentLength: contentLength,
|
|
});
|
|
|
|
const uploadUrl = await getSignedUrl(s3Client, putCommand, {
|
|
expiresIn: expiresIn,
|
|
signableHeaders,
|
|
});
|
|
|
|
return uploadUrl;
|
|
},
|
|
|
|
deleteObject: async (bucketName: string, fileKey: string) => {
|
|
const deleteCommand = new DeleteObjectCommand({
|
|
Bucket: bucketName,
|
|
Key: fileKey,
|
|
});
|
|
|
|
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);
|
|
},
|
|
};
|