forked from akai/readest
0b18de0581
* 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>
181 lines
7.4 KiB
PL/PgSQL
181 lines
7.4 KiB
PL/PgSQL
-- 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;
|