Files
Gerhard Scheikl d317e8c758 feat(admin): live redis kill-switch on tunnel actions, sortable columns + CSV export + bulk actions, Node 24 LTS
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.
2026-05-31 14:46:22 +02:00

80 lines
2.1 KiB
TypeScript

import { type NextRequest, NextResponse } from 'next/server';
import { requireAdminApi } from '@/lib/auth/admin-guard';
import { getTunnelsList } 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';
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 status = url.searchParams.get('status');
const sort = url.searchParams.get('sort');
const order = url.searchParams.get('order');
try {
// Respect search/status/sort but pull the full matching set (capped).
// SECURITY: the tunnel token is NEVER selected or exported.
const { tunnels } = await getTunnelsList({
page: 1,
perPage: EXPORT_MAX_ROWS,
search,
status,
sort,
order,
});
const header = [
'user_id',
'owner_email',
'subdomain',
'is_active',
'bytes_used',
'quota_bytes',
'usage_pct',
'last_seen_at',
'created_at',
];
const rows = tunnels.map((t) => [
t.user_id,
t.owner_email ?? '',
t.subdomain,
t.is_active ? 'true' : 'false',
t.bytes_used,
t.quota_bytes,
t.usage_pct.toFixed(1),
t.last_seen_at ?? '',
t.created_at,
]);
const csv = toCsv(header, rows);
await logAdminAction(auth.user, {
action: 'tunnel.export',
target_type: 'tunnel',
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="tunnels.csv"',
'Cache-Control': 'no-store',
Pragma: 'no-cache',
},
});
} catch (e) {
console.error('admin tunnels export failed', e);
return NextResponse.json(
{ error: 'internal error' },
{ status: 500, headers: { 'Cache-Control': 'no-store' } },
);
}
}