Files
readest/apps/readest-app/src/pages/api/send/inbox.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

139 lines
5.1 KiB
TypeScript

import type { NextApiRequest, NextApiResponse } from 'next';
import { createSupabaseAdminClient } from '@/utils/supabase';
import { corsAllMethods, runMiddleware } from '@/utils/cors';
import { validateUserAndToken } from '@/utils/access';
import { putObject } from '@/utils/object';
import { parseSubjectTag } from '@/services/send/sendAddress';
import { SEND_INBOX_BUCKET, SEND_INBOX_PENDING_LIMIT } from '@/services/constants';
import type { DBSendInboxItem } from '@/types/sendRecords';
const RECENT_LIMIT = 20;
const MAX_CLIP_HTML_BYTES = 5 * 1024 * 1024;
/**
* Inbox endpoint — clients route through here instead of querying Supabase
* directly.
* GET — list the caller's recent inbox items (the "Recent activity" list).
* POST — authenticated producer (browser extension): drop a captured URL in.
* (The email channel writes `send_inbox` from the email Worker.)
*/
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
await runMiddleware(req, res, corsAllMethods);
const { user } = await validateUserAndToken(req.headers['authorization']);
if (!user) {
return res.status(403).json({ error: 'Not authenticated' });
}
const supabase = createSupabaseAdminClient();
if (req.method === 'GET') {
const { data, error } = await supabase
.from('send_inbox')
.select('*')
.eq('user_id', user.id)
.order('created_at', { ascending: false })
.limit(RECENT_LIMIT)
.returns<DBSendInboxItem[]>();
if (error) {
return res.status(500).json({ error: error.message });
}
return res.status(200).json({ items: data ?? [] });
}
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
// Anti-abuse: cap undrained items so a leaked token can't flood R2 or the
// user's library. Count claimed items too — a crashed drainer can leave
// items stuck in `claimed` until the lease expires.
const { count, error: countError } = await supabase
.from('send_inbox')
.select('id', { count: 'exact', head: true })
.eq('user_id', user.id)
.in('status', ['pending', 'claimed']);
if (countError) {
return res.status(500).json({ error: countError.message });
}
if ((count ?? 0) >= SEND_INBOX_PENDING_LIMIT) {
return res.status(429).json({ error: 'Inbox is full — open Readest to process pending items' });
}
const kind = String(req.body?.kind ?? 'url');
if (kind === 'html') {
// Bookmarklet / extension path: caller posts the page's rendered HTML.
// This bypasses bot-protection that would defeat a server-side fetch
// (CAPTCHAs, login walls, JS-rendered content). The HTML lands in R2 and
// the drainer converts it identically to the email-attachment flow.
const html = String(req.body?.html ?? '');
if (!html) return res.status(400).json({ error: 'html is required' });
const bytes = new TextEncoder().encode(html);
if (bytes.byteLength > MAX_CLIP_HTML_BYTES) {
return res.status(413).json({ error: 'Page is too large to send' });
}
const title = req.body?.title ? String(req.body.title).slice(0, 500) : null;
const sourceUrl = req.body?.url ? String(req.body.url).slice(0, 2000) : null;
const { data: row, error: rowError } = await supabase
.from('send_inbox')
.insert({
user_id: user.id,
kind: 'html',
source: 'extension',
url: sourceUrl,
filename: title,
byte_size: bytes.byteLength,
subject_tag: parseSubjectTag(title) ?? null,
})
.select('id')
.single<{ id: string }>();
if (rowError) return res.status(500).json({ error: rowError.message });
const payloadKey = `inbox/${user.id}/${row.id}/page.html`;
try {
await putObject(payloadKey, bytes.buffer, 'text/html; charset=utf-8', SEND_INBOX_BUCKET);
} catch (err) {
// Roll back the inbox row so we never leave a `pending` item the
// drainer would only fail on.
await supabase.from('send_inbox').delete().eq('id', row.id);
console.error('Inbox clip upload failed:', err);
return res.status(500).json({ error: 'Could not store page' });
}
const { error: updateError } = await supabase
.from('send_inbox')
.update({ payload_key: payloadKey })
.eq('id', row.id);
if (updateError) return res.status(500).json({ error: updateError.message });
return res.status(200).json({ id: row.id });
}
// kind === 'url' (legacy extension path)
const url = String(req.body?.url ?? '').trim();
if (!url || !/^https?:\/\//i.test(url)) {
return res.status(400).json({ error: 'A valid http(s) URL is required' });
}
const title = req.body?.title ? String(req.body.title) : null;
const { data, error } = await supabase
.from('send_inbox')
.insert({
user_id: user.id,
kind: 'url',
source: 'extension',
url,
filename: title,
subject_tag: parseSubjectTag(title) ?? null,
})
.select('id')
.single<{ id: string }>();
if (error) {
return res.status(500).json({ error: error.message });
}
return res.status(200).json({ id: data.id });
}