Push could only reach one mobile device per user (a token file), quiet hours did not exist, and the web app had no service worker, so a closed tab never heard about finished work. This lands the shared interfaces first so onboarding can build on them: - push_subscriptions (web push endpoints and Expo tokens, one row per device) and user_notification_settings (quiet hours as minutes in the profile time zone, nudges, a per-day nudge counter), with CHECK constraints. - NotificationSettingsSchema and web push contracts, notifications.settings.get/update and notifications.webPush.config/subscribe/unsubscribe (owner-scoped, https endpoints only). - getNotificationSettings/updateNotificationSettings plus the policy and nudge-cap helpers. - VAPID keys from VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY or generated once and stored encrypted, and an SSRF-guarded web push sender built on the web-push package. - The web app links its manifest, registers a push-only service worker where supported (secure context, not the desktop app) and exposes webPushSupported/webPushEnabled/enableWebPush/ disableWebPush. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
268 lines
8.8 KiB
TypeScript
268 lines
8.8 KiB
TypeScript
import { lookup } from "node:dns/promises";
|
|
import { isIP } from "node:net";
|
|
import { readBoundedResponseBytes } from "@rakazo/core";
|
|
import { Agent } from "undici";
|
|
import {
|
|
createAddressCheckedLookup,
|
|
isPrivateAddress,
|
|
type ResolvedAddress,
|
|
type ResolveHostname,
|
|
} from "./network-address.js";
|
|
import { dispatcherFetch } from "./undici-fetch.js";
|
|
|
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
|
|
const MAX_REDIRECTS = 5;
|
|
|
|
export type { ResolveHostname } from "./network-address.js";
|
|
|
|
export interface SafeWebFetchOptions {
|
|
fetch?: typeof globalThis.fetch;
|
|
resolveHostname?: ResolveHostname;
|
|
timeoutMs?: number;
|
|
maxBytes?: number;
|
|
userAgent?: string;
|
|
headers?: Record<string, string>;
|
|
signal?: AbortSignal;
|
|
/**
|
|
* Test seam for dispatcher teardown. Defaults to `Agent.close()`.
|
|
* Always raced against the shared operation deadline.
|
|
*/
|
|
cleanup?: () => Promise<void>;
|
|
/**
|
|
* Test seam for force-teardown after cleanup. Defaults to `Agent.destroy()`.
|
|
* Invoked without awaiting so a hanging destroy cannot extend the deadline.
|
|
* May return a Promise (`Agent.destroy()` does); rejections are swallowed.
|
|
*/
|
|
destroy?: () => void | Promise<void>;
|
|
}
|
|
|
|
const defaultResolveHostname: ResolveHostname = (hostname) =>
|
|
lookup(hostname, { all: true, verbatim: true });
|
|
|
|
export async function assertSafeWebUrl(
|
|
value: string,
|
|
resolve: ResolveHostname = defaultResolveHostname,
|
|
signal?: AbortSignal,
|
|
): Promise<URL> {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(value);
|
|
} catch {
|
|
throw new Error("URL is invalid");
|
|
}
|
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
throw new Error("Only http: and https: URLs are allowed");
|
|
}
|
|
if (url.username || url.password) {
|
|
throw new Error("URL must not contain credentials");
|
|
}
|
|
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
|
if (isBlockedHostname(hostname)) {
|
|
throw new Error("URL targets a private or internal host");
|
|
}
|
|
assertPublicAddresses(await withAbort(resolve(hostname), signal));
|
|
return url;
|
|
}
|
|
|
|
export function isBlockedHostname(hostname: string): boolean {
|
|
const normalized = hostname.toLowerCase().replace(/\.$/, "");
|
|
if (
|
|
normalized === "localhost" ||
|
|
normalized.endsWith(".localhost") ||
|
|
normalized.endsWith(".local") ||
|
|
normalized.endsWith(".internal") ||
|
|
normalized === "metadata.google.internal" ||
|
|
normalized === "metadata.goog"
|
|
) {
|
|
return true;
|
|
}
|
|
if (isIP(normalized) !== 0) return isPrivateAddress(normalized);
|
|
return false;
|
|
}
|
|
|
|
export async function fetchSafeWebText(
|
|
url: string,
|
|
options: SafeWebFetchOptions = {},
|
|
): Promise<{ url: string; body: string; contentType: string | null }> {
|
|
const resolve = options.resolveHostname ?? defaultResolveHostname;
|
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
const baseFetch = options.fetch ?? dispatcherFetch;
|
|
const dispatcher = new Agent({
|
|
connect: { lookup: createAddressCheckedLookup(resolve, assertPublicAddresses) },
|
|
});
|
|
// One deadline for the whole redirect chain + body read + cleanup, not per hop.
|
|
const signal = combineSignals(options.signal, AbortSignal.timeout(timeoutMs));
|
|
const cleanup =
|
|
options.cleanup ??
|
|
(() =>
|
|
dispatcher.close().then(
|
|
() => undefined,
|
|
() => undefined,
|
|
));
|
|
const destroy = options.destroy ?? (() => dispatcher.destroy());
|
|
|
|
try {
|
|
return await followRedirects(url, {
|
|
baseFetch,
|
|
resolve,
|
|
dispatcher,
|
|
maxBytes,
|
|
userAgent: options.userAgent ?? "Rakazo/0.1 (+https://github.com/elie222/rakazo)",
|
|
headers: options.headers,
|
|
signal,
|
|
redirectsRemaining: MAX_REDIRECTS,
|
|
});
|
|
} finally {
|
|
// Race graceful close against the shared deadline. Do not wait unbounded on close().
|
|
await withAbort(cleanup(), signal).catch(() => undefined);
|
|
// If the deadline aborted (close hung or already timed out), force-destroy
|
|
// without awaiting so sockets/FDs cannot leak past the timeout.
|
|
// Agent.destroy() returns a Promise — swallow async rejection (sync try/catch cannot).
|
|
if (signal.aborted) {
|
|
try {
|
|
void Promise.resolve(destroy()).catch(() => undefined);
|
|
} catch {
|
|
// sync throw from destroy seam
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function followRedirects(
|
|
rawUrl: string,
|
|
state: {
|
|
baseFetch: typeof globalThis.fetch;
|
|
resolve: ResolveHostname;
|
|
dispatcher: Agent;
|
|
maxBytes: number;
|
|
userAgent: string;
|
|
headers?: Record<string, string>;
|
|
signal: AbortSignal;
|
|
redirectsRemaining: number;
|
|
},
|
|
): Promise<{ url: string; body: string; contentType: string | null }> {
|
|
if (state.signal.aborted) {
|
|
throw abortError(state.signal);
|
|
}
|
|
const validated = await assertSafeWebUrl(rawUrl, state.resolve, state.signal);
|
|
// Race fetch against the deadline — injected fetch may ignore init.signal.
|
|
const response = await withAbort(
|
|
state.baseFetch(validated.href, {
|
|
method: "GET",
|
|
redirect: "manual",
|
|
signal: state.signal,
|
|
headers: {
|
|
"user-agent": state.userAgent,
|
|
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.5",
|
|
...state.headers,
|
|
},
|
|
dispatcher: state.dispatcher,
|
|
} as RequestInit & { dispatcher: Agent }),
|
|
state.signal,
|
|
);
|
|
|
|
if (response.status >= 300 && response.status < 400) {
|
|
if (state.redirectsRemaining <= 0) {
|
|
await cancelResponseBody(response, state.signal);
|
|
throw new Error("Too many redirects");
|
|
}
|
|
const location = response.headers.get("location");
|
|
if (!location) {
|
|
await cancelResponseBody(response, state.signal);
|
|
throw new Error("Redirect missing Location header");
|
|
}
|
|
const next = new URL(location, validated.href);
|
|
const headers = next.origin === validated.origin ? state.headers : undefined;
|
|
// Release this hop before following — do not leave the body open across recursion.
|
|
await cancelResponseBody(response, state.signal);
|
|
return followRedirects(next.href, {
|
|
...state,
|
|
headers,
|
|
redirectsRemaining: state.redirectsRemaining - 1,
|
|
});
|
|
}
|
|
|
|
if (!response.ok) {
|
|
await cancelResponseBody(response, state.signal);
|
|
throw new Error(`Request failed: HTTP ${response.status}`);
|
|
}
|
|
|
|
const contentLength = response.headers.get("content-length");
|
|
if (contentLength && Number(contentLength) > state.maxBytes) {
|
|
await cancelResponseBody(response, state.signal);
|
|
throw new Error("Response is too large");
|
|
}
|
|
|
|
const buffer = await readBodyCapped(response, state.maxBytes, state.signal);
|
|
|
|
return {
|
|
url: validated.href,
|
|
body: new TextDecoder().decode(buffer),
|
|
contentType: response.headers.get("content-type"),
|
|
};
|
|
}
|
|
|
|
async function cancelResponseBody(response: Response, signal: AbortSignal): Promise<void> {
|
|
const cancel = response.body?.cancel() ?? Promise.resolve();
|
|
// A hanging cancel must not outlive the shared deadline.
|
|
await withAbort(
|
|
cancel.catch(() => undefined),
|
|
signal,
|
|
).catch(() => undefined);
|
|
}
|
|
|
|
/** Read the body as a stream and abort once maxBytes is exceeded (DoS guard). */
|
|
export async function readBodyCapped(
|
|
response: Response,
|
|
maxBytes: number,
|
|
signal?: AbortSignal,
|
|
): Promise<Uint8Array> {
|
|
return readBoundedResponseBytes(response, {
|
|
maxBytes,
|
|
tooLargeMessage: "Response is too large",
|
|
read: (operation) => {
|
|
if (response.body && signal?.aborted) throw abortError(signal);
|
|
return withAbort(operation(), signal);
|
|
},
|
|
});
|
|
}
|
|
|
|
export function assertPublicAddresses(addresses: ResolvedAddress[]): void {
|
|
if (addresses.length === 0 || addresses.some((entry) => isPrivateAddress(entry.address))) {
|
|
throw new Error("URL resolves to a private address");
|
|
}
|
|
}
|
|
|
|
function abortError(signal: AbortSignal): Error {
|
|
return signal.reason instanceof Error ? signal.reason : new Error("Request timed out");
|
|
}
|
|
|
|
/** Race a promise against an AbortSignal so stalled work cannot outlive the deadline. */
|
|
export function withAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
if (!signal) return promise;
|
|
if (signal.aborted) return Promise.reject(abortError(signal));
|
|
return new Promise<T>((resolve, reject) => {
|
|
const onAbort = () => reject(abortError(signal));
|
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
promise.then(
|
|
(value) => {
|
|
signal.removeEventListener("abort", onAbort);
|
|
resolve(value);
|
|
},
|
|
(error) => {
|
|
signal.removeEventListener("abort", onAbort);
|
|
reject(error);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
function combineSignals(...signals: Array<AbortSignal | undefined>): AbortSignal {
|
|
const active = signals.filter((signal): signal is AbortSignal => Boolean(signal));
|
|
if (active.length === 0) return new AbortController().signal;
|
|
if (active.length === 1) return active[0]!;
|
|
return AbortSignal.any(active);
|
|
}
|