forked from akai/readest
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:
@@ -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;
|
||||
Reference in New Issue
Block a user