forked from akai/readest
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:
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
Reference in New Issue
Block a user