SPB Git

spb/fetcha

Public
18.3 KB · 438 lines typescript
1"use server";23import { randomBytes } from "node:crypto";4import { revalidatePath } from "next/cache";5import { z } from "zod";6import {7  getDb,8  users,9  sessions,10  organizations,11  providerConfigs,12  domainProfiles,13  abuseEvents,14  featureFlags,15  statusIncidents,16  eq,17} from "@fetcha/db";18import { newId, PLANS } from "@fetcha/core";19import { requireAdmin, requestMeta } from "@/lib/session";20import { writeAudit } from "@/lib/audit";21import { internalApi, InternalApiError } from "@/lib/api";22import type { AuthUser } from "@/lib/auth";2324export type AdminActionResult<T = undefined> = { ok: true; data?: T; warning?: string } | { ok: false; error: string };2526/** Every admin mutation is recorded as `admin.action` with a `type` discriminator in metadata. */27async function audit(admin: AuthUser, type: string, target: string | null, metadata: Record<string, unknown> = {}, organizationId?: string | null) {28  const meta = await requestMeta();29  await writeAudit({30    userId: admin.id,31    organizationId: organizationId ?? null,32    action: "admin.action",33    target,34    metadata: { type, ...metadata },35    ipAddress: meta.ip,36    userAgent: meta.userAgent,37  });38}3940function fail(e: unknown): { ok: false; error: string } {41  if (e instanceof InternalApiError) return { ok: false, error: `${e.code}: ${e.message}` };42  if (e instanceof z.ZodError) return { ok: false, error: e.issues[0]?.message ?? "Invalid input" };43  return { ok: false, error: (e as Error)?.message ?? "Unexpected error" };44}4546function revalidateAdmin() {47  revalidatePath("/admin", "layout");48}4950// ---------------------------------------------------------------------------51// Users52// ---------------------------------------------------------------------------53export async function banUser(userId: string, reason: string): Promise<AdminActionResult> {54  const admin = await requireAdmin();55  try {56    const r = z.string().trim().min(3, "Give a reason (3+ characters)").max(500).parse(reason);57    if (userId === admin.id) return { ok: false, error: "You cannot ban yourself." };58    const db = getDb();59    const [u] = await db.select({ id: users.id, email: users.email }).from(users).where(eq(users.id, userId)).limit(1);60    if (!u) return { ok: false, error: "User not found" };61    await db.update(users).set({ banned: true, banReason: r, updatedAt: new Date() }).where(eq(users.id, userId));62    await db.delete(sessions).where(eq(sessions.userId, userId));63    await audit(admin, "user.ban", userId, { email: u.email, reason: r });64    revalidateAdmin();65    return { ok: true };66  } catch (e) {67    return fail(e);68  }69}7071export async function unbanUser(userId: string): Promise<AdminActionResult> {72  const admin = await requireAdmin();73  try {74    const db = getDb();75    const [u] = await db.select({ id: users.id, email: users.email }).from(users).where(eq(users.id, userId)).limit(1);76    if (!u) return { ok: false, error: "User not found" };77    await db.update(users).set({ banned: false, banReason: null, updatedAt: new Date() }).where(eq(users.id, userId));78    await audit(admin, "user.unban", userId, { email: u.email });79    revalidateAdmin();80    return { ok: true };81  } catch (e) {82    return fail(e);83  }84}8586export async function setUserRole(userId: string, role: "admin" | "user"): Promise<AdminActionResult> {87  const admin = await requireAdmin();88  try {89    z.enum(["admin", "user"]).parse(role);90    if (userId === admin.id && role !== "admin") return { ok: false, error: "You cannot demote yourself." };91    const db = getDb();92    const [u] = await db.select({ id: users.id, email: users.email, role: users.role }).from(users).where(eq(users.id, userId)).limit(1);93    if (!u) return { ok: false, error: "User not found" };94    await db.update(users).set({ role, updatedAt: new Date() }).where(eq(users.id, userId));95    await audit(admin, role === "admin" ? "user.promote" : "user.demote", userId, { email: u.email, from: u.role, to: role });96    revalidateAdmin();97    return { ok: true };98  } catch (e) {99    return fail(e);100  }101}102103export async function forceVerifyEmail(userId: string): Promise<AdminActionResult> {104  const admin = await requireAdmin();105  try {106    const db = getDb();107    const [u] = await db.select({ id: users.id, email: users.email }).from(users).where(eq(users.id, userId)).limit(1);108    if (!u) return { ok: false, error: "User not found" };109    await db.update(users).set({ emailVerified: true, updatedAt: new Date() }).where(eq(users.id, userId));110    await audit(admin, "user.force_verify", userId, { email: u.email });111    revalidateAdmin();112    return { ok: true };113  } catch (e) {114    return fail(e);115  }116}117118// ---------------------------------------------------------------------------119// Organizations120// ---------------------------------------------------------------------------121export async function updateOrganizationPlan(orgId: string, plan: string): Promise<AdminActionResult> {122  const admin = await requireAdmin();123  try {124    const p = z.enum(PLANS).parse(plan);125    const db = getDb();126    const [o] = await db.select({ id: organizations.id, plan: organizations.plan }).from(organizations).where(eq(organizations.id, orgId)).limit(1);127    if (!o) return { ok: false, error: "Organization not found" };128    await db.update(organizations).set({ plan: p, updatedAt: new Date() }).where(eq(organizations.id, orgId));129    await audit(admin, "org.plan", orgId, { from: o.plan, to: p }, orgId);130    revalidateAdmin();131    return { ok: true };132  } catch (e) {133    return fail(e);134  }135}136137export async function setOrganizationProviderVisibility(orgId: string, enabled: boolean): Promise<AdminActionResult> {138  const admin = await requireAdmin();139  try {140    const db = getDb();141    const res = await db.update(organizations).set({ providerVisibility: Boolean(enabled), updatedAt: new Date() }).where(eq(organizations.id, orgId)).returning({ id: organizations.id });142    if (!res.length) return { ok: false, error: "Organization not found" };143    await audit(admin, "org.provider_visibility", orgId, { enabled: Boolean(enabled) }, orgId);144    revalidateAdmin();145    return { ok: true };146  } catch (e) {147    return fail(e);148  }149}150151export async function setOrganizationSuspended(orgId: string, suspended: boolean, reason?: string): Promise<AdminActionResult> {152  const admin = await requireAdmin();153  try {154    const db = getDb();155    const res = await db.update(organizations).set({ suspended: Boolean(suspended), updatedAt: new Date() }).where(eq(organizations.id, orgId)).returning({ id: organizations.id });156    if (!res.length) return { ok: false, error: "Organization not found" };157    await audit(admin, suspended ? "org.suspend" : "org.unsuspend", orgId, { reason: reason?.trim() || null }, orgId);158    revalidateAdmin();159    return { ok: true };160  } catch (e) {161    return fail(e);162  }163}164165const limitsSchema = z.object({166  softLimitUsd: z.number().min(0).nullable(),167  hardLimitUsd: z.number().min(0).nullable(),168});169170export async function updateOrganizationLimits(orgId: string, input: { softLimitUsd: number | null; hardLimitUsd: number | null }): Promise<AdminActionResult> {171  const admin = await requireAdmin();172  try {173    const d = limitsSchema.parse(input);174    if (d.softLimitUsd !== null && d.hardLimitUsd !== null && d.softLimitUsd > d.hardLimitUsd) return { ok: false, error: "Soft limit must be ≤ hard limit." };175    const db = getDb();176    const res = await db.update(organizations).set({ softLimitUsd: d.softLimitUsd, hardLimitUsd: d.hardLimitUsd, updatedAt: new Date() }).where(eq(organizations.id, orgId)).returning({ id: organizations.id });177    if (!res.length) return { ok: false, error: "Organization not found" };178    await audit(admin, "org.limits", orgId, { ...d }, orgId);179    revalidateAdmin();180    return { ok: true };181  } catch (e) {182    return fail(e);183  }184}185186// ---------------------------------------------------------------------------187// Providers188// ---------------------------------------------------------------------------189const providerConfigSchema = z.object({190  label: z.string().trim().min(1).max(64),191  enabled: z.boolean(),192  networks: z.array(z.enum(["datacenter", "residential", "isp", "mobile"])).min(0),193  pricePerGbUsd: z.record(z.string(), z.number().min(0).max(1000)),194  weight: z.number().min(0).max(10),195  maxConcurrency: z.number().int().min(1).max(100_000),196  notes: z.string().trim().max(2000).nullable(),197});198199export type ProviderConfigInput = z.infer<typeof providerConfigSchema>;200201export async function updateProviderConfig(id: string, input: ProviderConfigInput): Promise<AdminActionResult> {202  const admin = await requireAdmin();203  try {204    const pid = z.string().regex(/^[a-z0-9_-]{2,32}$/, "Invalid provider id").parse(id);205    const d = providerConfigSchema.parse(input);206    const db = getDb();207    const set = { label: d.label, enabled: d.enabled, networks: d.networks, pricePerGbUsd: d.pricePerGbUsd, weight: d.weight, maxConcurrency: d.maxConcurrency, notes: d.notes, updatedAt: new Date() };208    await db.insert(providerConfigs).values({ id: pid, ...set }).onConflictDoUpdate({ target: providerConfigs.id, set });209    await audit(admin, "provider.config", pid, { ...set, updatedAt: undefined });210    let warning: string | undefined;211    try {212      await internalApi.reloadProviders();213    } catch (e) {214      warning = `Saved, but the API did not reload providers: ${(e as Error).message}`;215    }216    revalidateAdmin();217    return { ok: true, warning };218  } catch (e) {219    return fail(e);220  }221}222223export async function resetCircuit(key?: string): Promise<AdminActionResult> {224  const admin = await requireAdmin();225  try {226    await internalApi.resetCircuit(key);227    await audit(admin, "provider.circuit_reset", key ?? "all", {});228    revalidateAdmin();229    return { ok: true };230  } catch (e) {231    return fail(e);232  }233}234235export async function runProviderProbe(): Promise<AdminActionResult> {236  const admin = await requireAdmin();237  try {238    await internalApi.probeProviders();239    await audit(admin, "provider.probe", null, {});240    revalidateAdmin();241    return { ok: true };242  } catch (e) {243    return fail(e);244  }245}246247// ---------------------------------------------------------------------------248// Domains249// ---------------------------------------------------------------------------250const routeKeyRe = /^[a-z0-9_-]+:(datacenter|residential|isp|mobile)$/;251const policySchema = z.object({252  force_network: z.enum(["datacenter", "residential", "isp", "mobile"]).nullable().optional(),253  order: z.array(z.string().regex(routeKeyRe, "Route keys look like provider:network")).max(16).optional(),254});255256export async function updateDomainPolicy(domain: string, policy: { force_network?: string | null; order?: string[] }): Promise<AdminActionResult> {257  const admin = await requireAdmin();258  try {259    const dom = z.string().trim().min(1).max(253).parse(domain).toLowerCase();260    const p = policySchema.parse({ force_network: policy.force_network || null, order: policy.order ?? [] });261    const next: { order?: string[]; force_network?: string } = {};262    if (p.order?.length) next.order = Array.from(new Set(p.order));263    if (p.force_network) next.force_network = p.force_network;264    const value = Object.keys(next).length ? next : null;265    const db = getDb();266    const res = await db.update(domainProfiles).set({ policy: value, updatedAt: new Date() }).where(eq(domainProfiles.domain, dom)).returning({ domain: domainProfiles.domain });267    if (!res.length) {268      await db.insert(domainProfiles).values({ domain: dom, policy: value });269    }270    await audit(admin, "domain.policy", dom, { policy: value });271    revalidateAdmin();272    return { ok: true };273  } catch (e) {274    return fail(e);275  }276}277278export async function resetDomainStats(domain: string): Promise<AdminActionResult> {279  const admin = await requireAdmin();280  try {281    const dom = z.string().trim().min(1).max(253).parse(domain).toLowerCase();282    const res = await getDb()283      .update(domainProfiles)284      .set({ requests: 0, successes: 0, blocks: 0, captchas: 0, browserRequired: 0, avgLatencyMs: 0, routeStats: {}, preferredNetwork: null, preferredProvider: null, updatedAt: new Date() })285      .where(eq(domainProfiles.domain, dom))286      .returning({ domain: domainProfiles.domain });287    if (!res.length) return { ok: false, error: "Domain profile not found" };288    await audit(admin, "domain.reset_stats", dom, {});289    revalidateAdmin();290    return { ok: true };291  } catch (e) {292    return fail(e);293  }294}295296// ---------------------------------------------------------------------------297// Abuse298// ---------------------------------------------------------------------------299export async function resolveAbuseEvent(id: string, resolved = true): Promise<AdminActionResult> {300  const admin = await requireAdmin();301  try {302    const res = await getDb().update(abuseEvents).set({ resolved }).where(eq(abuseEvents.id, id)).returning({ id: abuseEvents.id });303    if (!res.length) return { ok: false, error: "Abuse event not found" };304    await audit(admin, resolved ? "abuse.resolve" : "abuse.reopen", id, {});305    revalidateAdmin();306    return { ok: true };307  } catch (e) {308    return fail(e);309  }310}311312const abuseSchema = z.object({313  kind: z.enum(["ssrf_attempt", "prohibited_target", "rate_abuse", "credential_abuse", "manual"]),314  severity: z.enum(["low", "medium", "high", "critical"]),315  organizationId: z.string().trim().max(64).optional().or(z.literal("")),316  projectId: z.string().trim().max(64).optional().or(z.literal("")),317  requestId: z.string().trim().max(64).optional().or(z.literal("")),318  detail: z.string().trim().min(3, "Describe the event").max(2000),319});320321export async function recordAbuseEvent(input: z.input<typeof abuseSchema>): Promise<AdminActionResult<{ id: string }>> {322  const admin = await requireAdmin();323  try {324    const d = abuseSchema.parse(input);325    const db = getDb();326    if (d.organizationId) {327      const [o] = await db.select({ id: organizations.id }).from(organizations).where(eq(organizations.id, d.organizationId)).limit(1);328      if (!o) return { ok: false, error: "Organization id does not exist" };329    }330    const id = newId("abuse");331    await db.insert(abuseEvents).values({332      id,333      organizationId: d.organizationId || null,334      projectId: d.projectId || null,335      requestId: d.requestId || null,336      kind: d.kind,337      severity: d.severity,338      detail: d.detail,339    });340    await audit(admin, "abuse.record", id, { kind: d.kind, severity: d.severity }, d.organizationId || null);341    revalidateAdmin();342    return { ok: true, data: { id } };343  } catch (e) {344    return fail(e);345  }346}347348// ---------------------------------------------------------------------------349// Feature flags350// ---------------------------------------------------------------------------351export async function setFeatureFlag(key: string, enabled: boolean): Promise<AdminActionResult> {352  const admin = await requireAdmin();353  try {354    const k = z.string().regex(/^[a-z0-9_.-]{2,64}$/, "Flag keys: lowercase, digits, . _ -").parse(key);355    const res = await getDb().update(featureFlags).set({ enabled: Boolean(enabled), updatedAt: new Date() }).where(eq(featureFlags.key, k)).returning({ key: featureFlags.key });356    if (!res.length) return { ok: false, error: "Flag not found" };357    await audit(admin, "flag.set", k, { enabled: Boolean(enabled) });358    revalidateAdmin();359    return { ok: true };360  } catch (e) {361    return fail(e);362  }363}364365const flagSchema = z.object({366  key: z.string().trim().regex(/^[a-z0-9_.-]{2,64}$/, "Flag keys: lowercase, digits, . _ -"),367  description: z.string().trim().max(280).optional().or(z.literal("")),368  enabled: z.boolean().default(false),369  plans: z.array(z.enum(PLANS)).optional(),370  organizationIds: z.array(z.string().trim().min(1)).max(200).optional(),371});372373export async function upsertFeatureFlag(input: z.input<typeof flagSchema>): Promise<AdminActionResult> {374  const admin = await requireAdmin();375  try {376    const d = flagSchema.parse(input);377    const set = { description: d.description || null, enabled: d.enabled, plans: d.plans?.length ? d.plans : null, organizationIds: d.organizationIds?.length ? d.organizationIds : null, updatedAt: new Date() };378    await getDb().insert(featureFlags).values({ key: d.key, ...set }).onConflictDoUpdate({ target: featureFlags.key, set });379    await audit(admin, "flag.upsert", d.key, { ...set, updatedAt: undefined });380    revalidateAdmin();381    return { ok: true };382  } catch (e) {383    return fail(e);384  }385}386387export async function deleteFeatureFlag(key: string): Promise<AdminActionResult> {388  const admin = await requireAdmin();389  try {390    const res = await getDb().delete(featureFlags).where(eq(featureFlags.key, key)).returning({ key: featureFlags.key });391    if (!res.length) return { ok: false, error: "Flag not found" };392    await audit(admin, "flag.delete", key, {});393    revalidateAdmin();394    return { ok: true };395  } catch (e) {396    return fail(e);397  }398}399400// ---------------------------------------------------------------------------401// Status incidents402// ---------------------------------------------------------------------------403const incidentSchema = z.object({404  component: z.enum(["api", "dashboard", "residential", "datacenter", "isp", "mobile", "sessions", "billing", "website"]),405  title: z.string().trim().min(3).max(140),406  body: z.string().trim().max(4000).optional().or(z.literal("")),407  severity: z.enum(["minor", "major", "critical", "maintenance"]),408});409410export async function createIncident(input: z.input<typeof incidentSchema>): Promise<AdminActionResult<{ id: string }>> {411  const admin = await requireAdmin();412  try {413    const d = incidentSchema.parse(input);414    const id = `inc_${randomBytes(8).toString("hex")}`;415    await getDb().insert(statusIncidents).values({ id, component: d.component, title: d.title, body: d.body || null, severity: d.severity });416    await audit(admin, "incident.create", id, { component: d.component, severity: d.severity, title: d.title });417    revalidateAdmin();418    revalidatePath("/status");419    return { ok: true, data: { id } };420  } catch (e) {421    return fail(e);422  }423}424425export async function resolveIncident(id: string, resolved = true): Promise<AdminActionResult> {426  const admin = await requireAdmin();427  try {428    const res = await getDb().update(statusIncidents).set({ resolvedAt: resolved ? new Date() : null }).where(eq(statusIncidents.id, id)).returning({ id: statusIncidents.id });429    if (!res.length) return { ok: false, error: "Incident not found" };430    await audit(admin, resolved ? "incident.resolve" : "incident.reopen", id, {});431    revalidateAdmin();432    revalidatePath("/status");433    return { ok: true };434  } catch (e) {435    return fail(e);436  }437}438
Files · 6b215c2d5f54167737c2d57307f22c84bc934434
Menu

Theme