import { type NextRequest } from 'next/server'; import { randomBytes } from 'node:crypto'; import { getSupabaseAdmin } from '@/lib/supabase/admin'; import { jsonNoStore } from '@/lib/admin/response'; import { validateSubdomain } from '@/lib/validation'; import { setTunnelActive } from '@/lib/redis'; import { hashToken, FRP_SERVER_ADDR, FRP_SERVER_PORT } from '@/lib/device'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; type DeviceTokenRow = { id: number; user_id: string }; /** * Authenticate a request by its `Authorization: Bearer ` header. * Returns the matching, non-revoked device_tokens row (and bumps last_used_at), * or null when the token is missing/invalid/revoked. */ async function authDevice(req: NextRequest): Promise { const auth = req.headers.get('authorization') ?? ''; const m = /^Bearer\s+(.+)$/i.exec(auth.trim()); if (!m) return null; const admin = getSupabaseAdmin(); const { data, error } = await admin .from('device_tokens') .select('id, user_id') .eq('token_hash', hashToken(m[1])) .is('revoked_at', null) .maybeSingle(); if (error || !data) return null; await admin .from('device_tokens') .update({ last_used_at: new Date().toISOString() }) .eq('id', data.id); return data; } type Tunnel = { subdomain: string; token: string }; /** GET — return the device's current tunnel config. */ export async function GET(req: NextRequest) { const device = await authDevice(req); if (!device) return jsonNoStore({ error: 'unauthorized' }, { status: 401 }); const admin = getSupabaseAdmin(); const { data: tunnel, error } = await admin .from('tunnels') .select('subdomain, token') .eq('user_id', device.user_id) .maybeSingle(); if (error) return jsonNoStore({ error: 'failed' }, { status: 500 }); if (!tunnel) return jsonNoStore({ error: 'no_tunnel' }, { status: 404 }); return jsonNoStore({ subdomain: tunnel.subdomain, token: tunnel.token, server_addr: FRP_SERVER_ADDR, server_port: FRP_SERVER_PORT, }); } type PostBody = { subdomain?: unknown }; /** POST — claim/change the subdomain (rotates the frp token). */ export async function POST(req: NextRequest) { const device = await authDevice(req); if (!device) return jsonNoStore({ error: 'unauthorized' }, { status: 401 }); let body: PostBody; try { body = (await req.json()) as PostBody; } catch { return jsonNoStore({ error: 'invalid json' }, { status: 400 }); } const v = validateSubdomain(body.subdomain); if (!v.ok) { return jsonNoStore({ error: v.error }, { status: 400 }); } const subdomain = v.value; const admin = getSupabaseAdmin(); const { data: existing, error: existingErr } = await admin .from('tunnels') .select('user_id') .eq('subdomain', subdomain) .maybeSingle<{ user_id: string }>(); if (existingErr) { return jsonNoStore({ error: 'failed' }, { status: 500 }); } if (existing && existing.user_id !== device.user_id) { return jsonNoStore({ error: 'subdomain taken' }, { status: 409 }); } const token = randomBytes(32).toString('hex'); const { data, error } = await admin .from('tunnels') .upsert( { user_id: device.user_id, subdomain, token, is_active: true }, { onConflict: 'user_id' }, ) .select('subdomain, token') .single(); if (error) { if ((error as { code?: string }).code === '23505') { return jsonNoStore({ error: 'subdomain taken' }, { status: 409 }); } return jsonNoStore({ error: 'failed' }, { status: 500 }); } await setTunnelActive(subdomain, true); return jsonNoStore({ subdomain: data.subdomain, token: data.token, server_addr: FRP_SERVER_ADDR, server_port: FRP_SERVER_PORT, }); } /** DELETE — unpair this device by revoking its token (tunnel is kept). */ export async function DELETE(req: NextRequest) { const device = await authDevice(req); if (!device) return jsonNoStore({ error: 'unauthorized' }, { status: 401 }); const admin = getSupabaseAdmin(); const { error } = await admin .from('device_tokens') .update({ revoked_at: new Date().toISOString() }) .eq('id', device.id); if (error) return jsonNoStore({ error: 'failed' }, { status: 500 }); return jsonNoStore({ ok: true }); }