import { type NextRequest, NextResponse } from 'next/server'; import { requireAdminApi } from '@/lib/auth/admin-guard'; import { getUsersList } from '@/lib/admin/list'; import { logAdminAction } from '@/lib/auth/audit'; import { toCsv, EXPORT_MAX_ROWS } from '@/lib/admin/csv'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; function isBanned(banned_until: string | null): boolean { return !!banned_until && new Date(banned_until).getTime() > Date.now(); } export async function GET(req: NextRequest) { const auth = await requireAdminApi(); if (!auth.ok) return auth.response; const url = new URL(req.url); const search = url.searchParams.get('search') ?? ''; const sort = url.searchParams.get('sort'); const order = url.searchParams.get('order'); try { // Respect search/sort but pull the full matching set (capped). NOTE: never // export secrets — the users list carries no token. const { users } = await getUsersList({ page: 1, perPage: EXPORT_MAX_ROWS, search, sort, order, }); const header = [ 'email', 'role', 'status', 'tunnel_subdomain', 'tunnel_active', 'bytes_used', 'quota_bytes', 'created_at', 'last_sign_in_at', ]; const rows = users.map((u) => [ u.email ?? '', u.role, isBanned(u.banned_until) ? 'banned' : u.email_confirmed_at ? 'confirmed' : 'unconfirmed', u.tunnel?.subdomain ?? '', u.tunnel ? (u.tunnel.is_active ? 'true' : 'false') : '', u.tunnel?.bytes_used ?? '', u.tunnel?.quota_bytes ?? '', u.created_at, u.last_sign_in_at ?? '', ]); const csv = toCsv(header, rows); await logAdminAction(auth.user, { action: 'user.export', target_type: 'user', details: { count: rows.length, capped: rows.length >= EXPORT_MAX_ROWS }, }); return new NextResponse(csv, { status: 200, headers: { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': 'attachment; filename="users.csv"', 'Cache-Control': 'no-store', Pragma: 'no-cache', }, }); } catch (e) { console.error('admin users export failed', e); return NextResponse.json( { error: 'internal error' }, { status: 500, headers: { 'Cache-Control': 'no-store' } }, ); } }