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.
This commit is contained in:
Gerhard Scheikl
2026-05-31 14:46:22 +02:00
parent 1adb6e7b3f
commit d317e8c758
22 changed files with 1296 additions and 173 deletions
+73
View File
@@ -0,0 +1,73 @@
import { type NextRequest, NextResponse } from 'next/server';
import { requireAdminApi } from '@/lib/auth/admin-guard';
import { getAuditList } 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 action = url.searchParams.get('action') ?? '';
const targetType = url.searchParams.get('target_type') ?? '';
const sort = url.searchParams.get('sort');
const order = url.searchParams.get('order');
try {
// Respect action/target_type filters + sort, full set (capped). Audit
// details already exclude secrets (no tokens are ever logged).
const { entries } = await getAuditList({
page: 1,
perPage: EXPORT_MAX_ROWS,
action,
targetType,
sort,
order,
});
const header = [
'created_at',
'actor_email',
'action',
'target_type',
'target_id',
'details',
];
const rows = entries.map((e) => [
e.created_at,
e.actor_email ?? '',
e.action,
e.target_type ?? '',
e.target_id ?? '',
JSON.stringify(e.details ?? {}),
]);
const csv = toCsv(header, rows);
await logAdminAction(auth.user, {
action: 'audit.export',
target_type: 'audit',
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="audit.csv"',
'Cache-Control': 'no-store',
Pragma: 'no-cache',
},
});
} catch (e) {
console.error('admin audit export failed', e);
return NextResponse.json(
{ error: 'internal error' },
{ status: 500, headers: { 'Cache-Control': 'no-store' } },
);
}
}
+4
View File
@@ -16,6 +16,8 @@ export async function GET(req: NextRequest) {
const perPage = parsePerPageParam(url.searchParams.get('perPage'), 50, 100);
const action = url.searchParams.get('action') ?? '';
const targetType = url.searchParams.get('target_type') ?? '';
const sort = url.searchParams.get('sort');
const order = url.searchParams.get('order');
try {
const { entries, total } = await getAuditList({
@@ -23,6 +25,8 @@ export async function GET(req: NextRequest) {
perPage,
action,
targetType,
sort,
order,
});
return jsonNoStore({ entries, total, page, perPage });
} catch (e) {
+6 -4
View File
@@ -3,7 +3,7 @@ import { requireAdminApi } from '@/lib/auth/admin-guard';
import { getSupabaseAdmin } from '@/lib/supabase/admin';
import { logAdminAction } from '@/lib/auth/audit';
import { isUuid, parseBoolean } from '@/lib/admin/validators';
import { redisSet } from '@/lib/redis';
import { setTunnelActive } from '@/lib/redis';
import { jsonNoStore } from '@/lib/admin/response';
export const runtime = 'nodejs';
@@ -50,14 +50,16 @@ export async function POST(
return jsonNoStore({ error: 'tunnel not found' }, { status: 404 });
}
// Best-effort live kill-switch (never throws).
await redisSet(`tunnel:active:${data.subdomain}`, isActive ? '1' : '0');
// Best-effort live kill-switch (never throws). Writes tunnel:active:<sub>
// = "1"/"0" with TTL so the edge gate drops/allows a live connection within
// ~1s. No-op when REDIS_URL is unset.
const redisOk = await setTunnelActive(data.subdomain, isActive);
await logAdminAction(auth.user, {
action: isActive ? 'tunnel.activate' : 'tunnel.deactivate',
target_type: 'tunnel',
target_id: id,
details: { subdomain: data.subdomain, is_active: isActive },
details: { subdomain: data.subdomain, is_active: isActive, redis: redisOk },
});
return jsonNoStore({ ok: true, is_active: isActive });
+19 -3
View File
@@ -5,6 +5,7 @@ import { logAdminAction } from '@/lib/auth/audit';
import { isUuid } from '@/lib/admin/validators';
import { validateSubdomain } from '@/lib/validation';
import { isSubdomainReserved } from '@/lib/admin/reserved';
import { setTunnelActive } from '@/lib/redis';
import { jsonNoStore } from '@/lib/admin/response';
export const runtime = 'nodejs';
@@ -49,13 +50,22 @@ export async function POST(
// Reject if taken by a different tunnel (keyed by owner user_id).
const { data: existing } = await admin
.from('tunnels')
.select('user_id')
.select('user_id, subdomain')
.eq('subdomain', subdomain)
.maybeSingle<{ user_id: string }>();
.maybeSingle<{ user_id: string; subdomain: string }>();
if (existing && existing.user_id !== id) {
return jsonNoStore({ error: 'subdomain taken' }, { status: 409 });
}
// Capture the current subdomain so we can drop the OLD hostname's live
// connection once it is freed by the rename.
const { data: current } = await admin
.from('tunnels')
.select('subdomain')
.eq('user_id', id)
.maybeSingle<{ subdomain: string }>();
const oldSubdomain = current?.subdomain ?? null;
const { data, error } = await admin
.from('tunnels')
.update({ subdomain })
@@ -74,11 +84,17 @@ export async function POST(
return jsonNoStore({ error: 'tunnel not found' }, { status: 404 });
}
// Best-effort: drop any live connection on the OLD subdomain so the former
// hostname stops resolving as active. No-op when REDIS_URL is unset.
if (oldSubdomain && oldSubdomain !== subdomain) {
await setTunnelActive(oldSubdomain, false);
}
await logAdminAction(auth.user, {
action: 'tunnel.reassign',
target_type: 'tunnel',
target_id: id,
details: { subdomain },
details: { subdomain, previous_subdomain: oldSubdomain },
});
return jsonNoStore({ ok: true, subdomain });
+5 -4
View File
@@ -3,7 +3,7 @@ import { requireAdminApi } from '@/lib/auth/admin-guard';
import { getSupabaseAdmin } from '@/lib/supabase/admin';
import { logAdminAction } from '@/lib/auth/audit';
import { isUuid } from '@/lib/admin/validators';
import { redisSet } from '@/lib/redis';
import { setTunnelActive } from '@/lib/redis';
import { jsonNoStore } from '@/lib/admin/response';
export const runtime = 'nodejs';
@@ -36,14 +36,15 @@ export async function DELETE(
return jsonNoStore({ error: 'tunnel not found' }, { status: 404 });
}
// Best-effort live kill-switch.
await redisSet(`tunnel:active:${data.subdomain}`, '0');
// Best-effort live kill-switch: write "0" so any live connection on the
// freed subdomain drops within ~1s. No-op when REDIS_URL is unset.
const redisOk = await setTunnelActive(data.subdomain, false);
await logAdminAction(auth.user, {
action: 'tunnel.delete',
target_type: 'tunnel',
target_id: id,
details: { subdomain: data.subdomain },
details: { subdomain: data.subdomain, redis: redisOk },
});
return jsonNoStore({ ok: true });
+79
View File
@@ -0,0 +1,79 @@
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' } },
);
}
}
+4
View File
@@ -16,6 +16,8 @@ export async function GET(req: NextRequest) {
const perPage = parsePerPageParam(url.searchParams.get('perPage'), 25, 100);
const search = url.searchParams.get('search') ?? '';
const status = url.searchParams.get('status'); // active|inactive|over_quota
const sort = url.searchParams.get('sort');
const order = url.searchParams.get('order');
try {
const { tunnels, total } = await getTunnelsList({
@@ -23,6 +25,8 @@ export async function GET(req: NextRequest) {
perPage,
search,
status,
sort,
order,
});
return jsonNoStore({ tunnels, total, page, perPage });
} catch (e) {
+85
View File
@@ -0,0 +1,85 @@
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' } },
);
}
}
+9 -1
View File
@@ -15,9 +15,17 @@ export async function GET(req: NextRequest) {
const page = parsePageParam(url.searchParams.get('page'), 1);
const perPage = parsePerPageParam(url.searchParams.get('perPage'), 25, 100);
const search = url.searchParams.get('search') ?? '';
const sort = url.searchParams.get('sort');
const order = url.searchParams.get('order');
try {
const { users, total } = await getUsersList({ page, perPage, search });
const { users, total } = await getUsersList({
page,
perPage,
search,
sort,
order,
});
return jsonNoStore({ users, total, page, perPage });
} catch (e) {
console.error('admin users list failed', e);