feat(send): Send to Readest — multi-channel capture into your library (#4230)

* feat(send): Send to Readest — multi-channel capture into your library

A Send-to-Kindle equivalent: email, web-upload, share, or one-click
capture books and articles into the cloud library; they sync to every
device.

Architecture (client-side processing): out-of-app channels drop a raw
payload into a per-user send_inbox; Readest clients drain it through one
shared ingestService.ingestFile(). The server never parses or converts.

- ingestService.ingestFile() — channel-agnostic import orchestration
  extracted from library/page.tsx (DI-based, forceUpload support).
- send_addresses / send_allowed_senders / send_inbox tables + RLS + 4
  SECURITY DEFINER claim/lease RPCs (migration 012_send_to_readest.sql).
- Conversion subsystem (DOCX/RTF/HTML/article/TXT -> EPUB) in a Web Worker.
- send-email Cloudflare Email Worker; inbox-drainer controller +
  useInboxDrainer hook; /api/send/* routes.
- Send to Readest settings panel: inbound address, approved-sender
  allowlist, recent activity, per-device drain toggle.
- /send web page (file drop + article URL) + SSRF-guarded fetch-url proxy.
- OS-shared files routed through ingestFile; Manifest V3 browser extension.

Security: inbox state changes only via SECURITY DEFINER RPCs (clients get
SELECT-only on send_inbox); approved-sender allowlist gates email;
SSRF guard on the one server-side URL fetch; inbox payload signed URLs
authorize against send_inbox.user_id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: run format:check in the pre-push hook

Biome format checking is fast (~0.4s), so gate pushes on it too — catches
mis-formatted files that bypassed the staged-only pre-commit hook before
they reach CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(send): address CodeQL security findings

- ReDoS (senders.ts): the email regex had ambiguous quantifiers around
  the literal dot. Rewrote it linear-time (domain labels exclude '.')
  and cap the input at 254 chars.
- XSS (convertToEpub.ts): run untrusted HTML through DOMPurify
  (sanitizeForParsing — keeps document structure) before DOMParser, so
  title extraction and Readability never parse executable markup.
- SSRF (fetch-url.ts): harden the host guard — block bare single-label
  hostnames, IPv4-mapped IPv6, CGNAT/benchmark/multicast ranges, and the
  unspecified address. DNS rebinding stays a documented residual risk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-20 02:06:52 +08:00
committed by GitHub
parent 97221b8d23
commit 0b18de0581
79 changed files with 4920 additions and 63 deletions
@@ -0,0 +1,180 @@
-- Migration 012: Send to Readest — inbound capture (email / web / extension).
--
-- Out-of-app channels (email, browser extension) drop a raw payload into
-- `send_inbox`; any Readest client drains it through the normal import
-- pipeline. `send_inbox` state transitions go ONLY through the SECURITY
-- DEFINER RPCs below — clients have SELECT but no write policy, so a client
-- cannot forge `done`/`failed` or reset another device's claim.
-- Per-user inbound email address (the local part of `<address>@send.readest.com`).
CREATE TABLE IF NOT EXISTS public.send_addresses (
user_id uuid NOT NULL,
address text NOT NULL,
enabled boolean NOT NULL DEFAULT true,
created_at timestamp with time zone NOT NULL DEFAULT now(),
rotated_at timestamp with time zone NULL,
CONSTRAINT send_addresses_pkey PRIMARY KEY (user_id),
CONSTRAINT send_addresses_address_key UNIQUE (address),
CONSTRAINT send_addresses_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_send_addresses_address ON public.send_addresses (address);
ALTER TABLE public.send_addresses ENABLE ROW LEVEL SECURITY;
CREATE POLICY send_addresses_select ON public.send_addresses FOR SELECT TO authenticated USING ((SELECT auth.uid()) = user_id);
CREATE POLICY send_addresses_insert ON public.send_addresses FOR INSERT TO authenticated WITH CHECK ((SELECT auth.uid()) = user_id);
CREATE POLICY send_addresses_update ON public.send_addresses FOR UPDATE TO authenticated USING ((SELECT auth.uid()) = user_id);
CREATE POLICY send_addresses_delete ON public.send_addresses FOR DELETE TO authenticated USING ((SELECT auth.uid()) = user_id);
-- Approved-sender allowlist. `pending` rows are senders awaiting one-click approval.
CREATE TABLE IF NOT EXISTS public.send_allowed_senders (
id uuid NOT NULL DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
email text NOT NULL,
status text NOT NULL DEFAULT 'approved',
created_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT send_allowed_senders_pkey PRIMARY KEY (id),
CONSTRAINT send_allowed_senders_user_email_key UNIQUE (user_id, email),
CONSTRAINT send_allowed_senders_status_check CHECK (status IN ('approved', 'pending')),
CONSTRAINT send_allowed_senders_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_send_allowed_senders_user ON public.send_allowed_senders (user_id);
ALTER TABLE public.send_allowed_senders ENABLE ROW LEVEL SECURITY;
CREATE POLICY send_allowed_senders_select ON public.send_allowed_senders FOR SELECT TO authenticated USING ((SELECT auth.uid()) = user_id);
CREATE POLICY send_allowed_senders_insert ON public.send_allowed_senders FOR INSERT TO authenticated WITH CHECK ((SELECT auth.uid()) = user_id);
CREATE POLICY send_allowed_senders_update ON public.send_allowed_senders FOR UPDATE TO authenticated USING ((SELECT auth.uid()) = user_id);
CREATE POLICY send_allowed_senders_delete ON public.send_allowed_senders FOR DELETE TO authenticated USING ((SELECT auth.uid()) = user_id);
-- The capture inbox. Rows are kept after `done`/`failed` as the user's
-- "Recent activity" log; the R2 payload is deleted once a row reaches `done`.
CREATE TABLE IF NOT EXISTS public.send_inbox (
id uuid NOT NULL DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
kind text NOT NULL,
source text NOT NULL,
payload_key text NULL,
url text NULL,
filename text NULL,
subject_tag text NULL,
byte_size bigint NOT NULL DEFAULT 0,
status text NOT NULL DEFAULT 'pending',
claimed_by text NULL,
claimed_at timestamp with time zone NULL,
attempts integer NOT NULL DEFAULT 0,
error text NULL,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT send_inbox_pkey PRIMARY KEY (id),
CONSTRAINT send_inbox_kind_check CHECK (kind IN ('file', 'url', 'html')),
CONSTRAINT send_inbox_source_check CHECK (source IN ('email', 'extension')),
CONSTRAINT send_inbox_status_check CHECK (status IN ('pending', 'claimed', 'done', 'failed')),
CONSTRAINT send_inbox_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_send_inbox_user_status ON public.send_inbox (user_id, status, created_at);
ALTER TABLE public.send_inbox ENABLE ROW LEVEL SECURITY;
-- SELECT only: the Recent-activity UI reads rows. Writes go through the RPCs.
CREATE POLICY send_inbox_select ON public.send_inbox FOR SELECT TO authenticated USING ((SELECT auth.uid()) = user_id);
-- Claim the oldest drainable row (pending, or a claim whose 15-minute lease
-- expired). FOR UPDATE SKIP LOCKED makes concurrent drainers pick distinct rows.
CREATE OR REPLACE FUNCTION public.claim_inbox_item(p_device text)
RETURNS public.send_inbox
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_row public.send_inbox;
BEGIN
UPDATE public.send_inbox
SET status = 'claimed', claimed_by = p_device, claimed_at = now(), updated_at = now()
WHERE id = (
SELECT id FROM public.send_inbox
WHERE user_id = auth.uid()
AND (
status = 'pending'
OR (status = 'claimed' AND claimed_at < now() - interval '15 minutes')
)
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING * INTO v_row;
RETURN v_row;
END;
$$;
-- Refresh the lease for a long-running job; succeeds only if still owned.
CREATE OR REPLACE FUNCTION public.renew_inbox_claim(p_id uuid, p_device text)
RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_count integer;
BEGIN
UPDATE public.send_inbox
SET claimed_at = now(), updated_at = now()
WHERE id = p_id AND user_id = auth.uid()
AND status = 'claimed' AND claimed_by = p_device;
GET DIAGNOSTICS v_count = ROW_COUNT;
RETURN v_count > 0;
END;
$$;
-- Terminal success.
CREATE OR REPLACE FUNCTION public.complete_inbox_item(p_id uuid, p_device text)
RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_count integer;
BEGIN
UPDATE public.send_inbox
SET status = 'done', error = NULL, updated_at = now()
WHERE id = p_id AND user_id = auth.uid()
AND status = 'claimed' AND claimed_by = p_device;
GET DIAGNOSTICS v_count = ROW_COUNT;
RETURN v_count > 0;
END;
$$;
-- Failure: increment attempts atomically; back to `pending` for retry, or
-- terminal `failed` after the third attempt.
CREATE OR REPLACE FUNCTION public.fail_inbox_item(p_id uuid, p_device text, p_error text)
RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_count integer;
BEGIN
UPDATE public.send_inbox
SET attempts = attempts + 1,
status = CASE WHEN attempts + 1 >= 3 THEN 'failed' ELSE 'pending' END,
error = p_error,
claimed_by = NULL,
claimed_at = NULL,
updated_at = now()
WHERE id = p_id AND user_id = auth.uid()
AND status = 'claimed' AND claimed_by = p_device;
GET DIAGNOSTICS v_count = ROW_COUNT;
RETURN v_count > 0;
END;
$$;
GRANT ALL ON public.send_addresses TO authenticated;
GRANT ALL ON public.send_allowed_senders TO authenticated;
GRANT SELECT ON public.send_inbox TO authenticated;
GRANT EXECUTE ON FUNCTION public.claim_inbox_item(text) TO authenticated;
GRANT EXECUTE ON FUNCTION public.renew_inbox_claim(uuid, text) TO authenticated;
GRANT EXECUTE ON FUNCTION public.complete_inbox_item(uuid, text) TO authenticated;
GRANT EXECUTE ON FUNCTION public.fail_inbox_item(uuid, text, text) TO authenticated;