import { env } from "cloudflare:workers"; import { invalidateUnread } from "../user-inbox"; import { z } from "zod"; import { placeholders } from "../db"; import { accountDb } from "../scoped-db"; import { filterReadable } from "../notification-store"; import { lfsUsage } from "../lfs/quota"; import { limitWrite, limitAccountWrite } from "../limits"; import { loadNotificationSettings } from "../notification-prefs"; import { createWebhookIntegration, deleteIntegrationById, listIntegrationsFor, redeliverDelivery, sendTestEvent, } from "../integrations/manage"; import type { ScopeRef } from "../integrations/scope"; import { noContent, readJson, requireAccountScope, requireScopedAdminWrite, requireScopedRepo } from "./http"; import type { ApiContext } from "./http"; import { INTEGRATION_EVENTS } from "~/lib/integrations"; const MAX_IDS = 100; export async function getLfsQuota(ctx: ApiContext): Promise { requireAccountScope(ctx); const usage = await lfsUsage(ctx.viewer.id); return Response.json({ storage_bytes: usage.storageBytes, storage_limit: usage.storageLimit, bandwidth_bytes: usage.bandwidthBytes, bandwidth_limit: usage.bandwidthLimit, period: usage.period, }); } function settingsJson(settings: Awaited>) { return { email_participating: settings.emailParticipating, email_watching: settings.emailWatching, web_participating: settings.webParticipating, web_watching: settings.webWatching, }; } export async function getNotificationSettingsApi(ctx: ApiContext): Promise { requireAccountScope(ctx); return Response.json(settingsJson(await loadNotificationSettings(ctx.viewer.id))); } const settingsInput = z.object({ email_participating: z.boolean(), email_watching: z.boolean(), web_participating: z.boolean(), web_watching: z.boolean(), }); /** Same upsert as the settings page's updateNotificationSettings server function. */ export async function putNotificationSettingsApi(ctx: ApiContext): Promise { const data = await readJson(ctx.request, settingsInput); requireAccountScope(ctx); await limitAccountWrite(ctx.viewer); await accountDb(ctx.viewer.id).run( `INSERT INTO notification_settings (user_id, email_participating, email_watching, web_participating, web_watching, updated_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (user_id) DO UPDATE SET email_participating = excluded.email_participating, email_watching = excluded.email_watching, web_participating = excluded.web_participating, web_watching = excluded.web_watching, updated_at = excluded.updated_at`, ctx.viewer.id, data.email_participating ? 1 : 0, data.email_watching ? 1 : 0, data.web_participating ? 1 : 0, data.web_watching ? 1 : 0, Date.now(), ); return Response.json(settingsJson(await loadNotificationSettings(ctx.viewer.id))); } /** Same query as the inbox's listNotifications server function. */ /** Stored URLs are absolute, but a malformed row must not take the whole inbox down. */ function notificationPath(url: string): string { try { return new URL(url, env.PUBLIC_URL).pathname; } catch { return "/notifications"; } } export async function listNotificationsApi(ctx: ApiContext): Promise { requireAccountScope(ctx); const everything = ctx.url.searchParams.get("all") === "true"; // Unreadable rows are filtered after the read, so over-fetch a little to still fill the page. const fetched = await accountDb(ctx.viewer.id).all<{ id: string; repo_id: string | null; reason: string; title: string; url: string; read_at: number | null; created_at: number; }>( `SELECT id, repo_id, reason, title, url, read_at, created_at FROM notifications WHERE user_id = ? ${everything ? "" : "AND read_at IS NULL"} ORDER BY created_at DESC LIMIT 300`, ctx.viewer.id, ); const rows = (await filterReadable(ctx.viewer.id, fetched)).slice(0, 100); return Response.json( rows.map((row) => ({ id: row.id, reason: row.reason, title: row.title, url: notificationPath(row.url), read: row.read_at !== null, created_at: row.created_at, })), ); } const markInput = z.object({ ids: z.array(z.string()).max(MAX_IDS).nullable().default(null) }); export async function markNotificationsApi(ctx: ApiContext): Promise { const data = await readJson(ctx.request, markInput); requireAccountScope(ctx); await limitAccountWrite(ctx.viewer); if (data.ids === null) { await accountDb(ctx.viewer.id).run( "UPDATE notifications SET read_at = ? WHERE user_id = ? AND read_at IS NULL", Date.now(), ctx.viewer.id, ); } else if (data.ids.length > 0) { await accountDb(ctx.viewer.id).run( `UPDATE notifications SET read_at = ? WHERE user_id = ? AND id IN (${placeholders(data.ids.length)})`, Date.now(), ctx.viewer.id, ...data.ids, ); } await invalidateUnread([ctx.viewer.id]); return noContent(); } /** Token gate on top of manage.ts's own viewer check: repo hooks need an admin-area grant, org hooks an account-level token. */ async function integrationScope(ctx: ApiContext, owner: string, repo: string | null, write = true): Promise { if (repo === null) requireAccountScope(ctx); else if (write) await requireScopedAdminWrite(ctx, owner, repo); else await requireScopedRepo(ctx, owner, repo, "admin", "admin"); return { owner, repo }; } export async function listHooks(ctx: ApiContext, owner: string, repo: string | null): Promise { const ref = await integrationScope(ctx, owner, repo, false); return Response.json(await listIntegrationsFor(ref, ctx.viewer)); } const hookInput = z.object({ kind: z.enum(["slack", "google_chat", "webhook"]).default("webhook"), name: z.string().trim().min(1).max(100), url: z.string().trim().url().max(2000), events: z.array(z.enum(INTEGRATION_EVENTS)).max(INTEGRATION_EVENTS.length).default([]), secret: z.string().trim().min(8).max(200).nullable().default(null), }); export async function createHook(ctx: ApiContext, owner: string, repo: string | null): Promise { const data = await readJson(ctx.request, hookInput); const ref = await integrationScope(ctx, owner, repo); await limitWrite(ctx.viewer); const created = await createWebhookIntegration(ref, ctx.viewer, data); return Response.json({ id: created.id, signing_secret: created.signingSecret }, { status: 201 }); } export async function deleteHook(ctx: ApiContext, owner: string, repo: string | null, id: string): Promise { const ref = await integrationScope(ctx, owner, repo, false); await limitWrite(ctx.viewer); await deleteIntegrationById(ref, ctx.viewer, id); return noContent(); } export async function pingHook(ctx: ApiContext, owner: string, repo: string | null, id: string): Promise { const ref = await integrationScope(ctx, owner, repo); await limitWrite(ctx.viewer); return Response.json({ delivery_id: await sendTestEvent(ref, ctx.viewer, id) }, { status: 202 }); } export async function redeliverHook( ctx: ApiContext, owner: string, repo: string | null, id: string, deliveryId: string, ): Promise { const ref = await integrationScope(ctx, owner, repo); await limitWrite(ctx.viewer); await redeliverDelivery(ref, ctx.viewer, id, deliveryId); return new Response(null, { status: 202 }); }