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>
139 lines
5.1 KiB
TypeScript
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 });
|
|
}
|