Files
readest/apps/readest-app/src/utils/r2.ts
T
Huang Xin a1279a65ce feat(send): clip web URLs into self-contained EPUBs via Tauri webview (#4241)
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>
2026-05-20 19:48:09 +02:00

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;
},
};