import { constants } from "node:fs"; import { open, readdir, rmdir, unlink } from "node:fs/promises"; import path from "node:path"; import { ExpoPushTokenSchema } from "@rakazo/contracts"; import type { PrismaClient } from "@rakazo/db"; import { savePushSubscription } from "@rakazo/db"; import { combineSignals } from "./connector-safety.js"; import type { PushOutcome, PushPayload, PushTarget } from "./push-notifications.js"; import { readBodyCapped, withAbort } from "./web-ssrf.js"; const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0; const EXPO_PUSH_TIMEOUT_MS = 15_000; export const MAX_EXPO_PUSH_RESPONSE_BYTES = 64 * 1024; const LEGACY_TOKEN_FILE = /^([A-Za-z0-9_-]{1,128})\.txt$/; /** Where the API kept the single Expo token per user before push_subscriptions existed. */ export function legacyPushTokenDir(dataDir: string) { return path.join(dataDir, "push-tokens"); } async function readLegacyToken(file: string): Promise { try { const handle = await open(file, constants.O_RDONLY | O_NOFOLLOW); try { const token = ExpoPushTokenSchema.safeParse(await handle.readFile("utf8")); return token.success ? token.data : undefined; } finally { await handle.close(); } } catch { return undefined; } } /** * Moves tokens from the legacy files into push_subscriptions once, then deletes each file. A file * whose user no longer exists, or that holds no usable token, is deleted without import; a * database failure keeps the file for the next start. */ export async function importLegacyPushTokens( prisma: PrismaClient, dataDir: string, ): Promise { const dir = legacyPushTokenDir(dataDir); let names: string[]; try { names = await readdir(dir); } catch { return 0; } let imported = 0; for (const name of names) { const userId = LEGACY_TOKEN_FILE.exec(name)?.[1]; if (!userId) continue; const file = path.join(dir, name); const token = await readLegacyToken(file); if (token) { const user = await prisma.user.findUnique({ where: { id: userId }, select: { id: true } }); if (user) { await savePushSubscription(prisma, userId, { kind: "expo", endpoint: token }); imported += 1; } } await unlink(file).catch(() => undefined); } await rmdir(dir).catch(() => undefined); return imported; } export type ExpoPushTicket = { status?: string; message?: string; details?: { error?: string }; }; export function expoPushTickets(body: unknown): ExpoPushTicket[] { if (!body || typeof body !== "object") return []; const data = (body as { data?: unknown }).data; if (Array.isArray(data)) { return data.filter((item): item is ExpoPushTicket => Boolean(item) && typeof item === "object"); } if (data && typeof data === "object") return [data as ExpoPushTicket]; return []; } export function expoPushErrorMessage(body: unknown, status: number): string | undefined { if (body && typeof body === "object" && "errors" in body) { const errors = (body as { errors?: Array<{ message?: string }> }).errors; if (Array.isArray(errors) && errors.length > 0) { return errors.map((error) => error.message ?? "expo push error").join("; "); } } const failed = expoPushTickets(body).filter((ticket) => ticket.status === "error"); if (failed.length > 0) { return failed .map((ticket) => ticket.message ?? ticket.details?.error ?? "expo push ticket error") .join("; "); } if (status < 200 || status >= 300) return `expo push failed (${status})`; return undefined; } /** Sends to one Expo push token; "gone" when Expo reports the device is no longer registered. */ export class ExpoPushSender { async send(target: PushTarget, payload: PushPayload, signal?: AbortSignal): Promise { const deadline = combineSignals(signal, AbortSignal.timeout(EXPO_PUSH_TIMEOUT_MS)); const response = await fetch("https://exp.host/--/api/v2/push/send", { method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, body: JSON.stringify({ to: target.endpoint, title: payload.title, body: payload.body, collapseId: payload.threadId, tag: payload.threadId, data: { kind: payload.kind, botId: payload.botId, threadId: payload.threadId }, }), signal: deadline, }); const body = await readExpoPushBody(response, deadline); if (response.ok && body === undefined) { throw new Error("Expo push returned an invalid response."); } if (expoPushTickets(body).some((ticket) => ticket.details?.error === "DeviceNotRegistered")) { return "gone"; } const failure = expoPushErrorMessage(body, response.status); if (failure) throw new Error(failure); return "sent"; } } async function readExpoPushBody(response: Response, signal: AbortSignal): Promise { const declared = Number(response.headers.get("content-length") ?? 0); if (Number.isFinite(declared) && declared > MAX_EXPO_PUSH_RESPONSE_BYTES) { const cancel = response.body?.cancel() ?? Promise.resolve(); await withAbort( cancel.catch(() => undefined), signal, ).catch(() => undefined); throw new Error("Expo push response is too large."); } try { const bytes = await readBodyCapped(response, MAX_EXPO_PUSH_RESPONSE_BYTES, signal); if (bytes.byteLength === 0) return undefined; return JSON.parse(new TextDecoder().decode(bytes)) as unknown; } catch (error) { if (error instanceof Error && error.message === "Response is too large") { throw new Error("Expo push response is too large."); } if (error instanceof SyntaxError) return undefined; throw error; } }