d317e8c758
WS1: pin all Docker stages to node:24.16.0-alpine; add engines node>=20. WS2: lib/redis.ts gains TTL-backed redisSet, redisDel, setTunnelActive (writes tunnel:active:<sub>=1/0 EX 30, TUNNEL_ACTIVE_TTL override, no-op without REDIS_URL); wired into tunnel active/delete/reassign routes. WS3: sortable columns, CSV export routes (token excluded), and bulk actions (self-account guard) across users/tunnels/audit admin tables.
86 lines
2.4 KiB
TypeScript
86 lines
2.4 KiB
TypeScript
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' } },
|
|
);
|
|
}
|
|
}
|