a1279a65ce
Builds the URL-clipping path of the "Send to Readest" feature: paste a
link, the renderer ingests the rendered page, and a self-contained EPUB
lands in the library. No server proxy, no external CDN refs left in the
EPUB once it's saved.
Architecture
- New Rust `clip_url` command spawns a hidden Tauri WebviewWindow at the
target URL with a real Chrome UA + WebKit fingerprint mask, so TLS-
fingerprint and JS-challenge walls (Cloudflare, Medium, X, WeChat MP)
resolve naturally instead of bouncing the server proxy.
- Capture transport is URL-payload navigation to a one-shot
127.0.0.1:RANDOM_PORT/clip/{token}?d={url-safe-base64} listener.
Top-level navigation isn't governed by CSP connect-src / form-action /
WebKit Private Network Access — the four earlier transports
(fetch, <form>, custom URI scheme, window.name) were each blocked by
one of those.
- Page-to-EPUB bundler (`assetBundler`) walks <img>/<picture> with
src → data-src → data-original → data-srcset → srcset fallback so lazy-
loading sites don't ship a 60px LQIP; fetches assets in parallel with a
per-asset timeout + per-asset/total caps; failed images degrade to alt-
text placeholders. A per-site rules table (seeded with WeChat MP) + a
selector fallback catches articles Readability misextracts. Builder
prepends the article <h1> + byline so the EPUB has a proper opening.
- Nested EPUB TOC built from h1–h6.
UI surfaces
- "From Web URL" entry in the library Import menu, gated to Tauri; web
build hides the URL field and points at the browser extension.
- `ImportFromUrlDialog` with auto-height (overrides Dialog's `sm:h-[65%]`
default) and a dim placeholder for the URL field.
- Clip webview window styled to match Readest's main window — macOS
decorations + overlay title bar; other desktops decorationless with a
drop shadow; native background + in-page loading overlay pick up the
caller's `themeCode.bg`/`fg` so light/dark/eink/custom themes all
render correctly. Title localised, all five overlay/title strings
translated across 33 locales.
Notes
- Gates the macOS traffic-light positioner to main/reader-* windows so
the decorationless clip window no longer null-derefs in
`position_traffic_lights`.
- Stricter validation across the path: schemes restricted to http/https,
hex-color parsing rejects malformed values, server endpoint returns
400 on missing/invalid base64.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
110 lines
3.1 KiB
TypeScript
110 lines
3.1 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();
|
|
},
|
|
|
|
putObject: async (
|
|
bucketName: string,
|
|
fileKey: string,
|
|
body: ArrayBuffer | string,
|
|
contentType: string,
|
|
) => {
|
|
return await r2Storage.getR2Client().fetch(`${r2Storage.getR2Url()}/${bucketName}/${fileKey}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': contentType },
|
|
body,
|
|
});
|
|
},
|
|
|
|
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;
|
|
},
|
|
};
|