139 lines
4.3 KiB
TypeScript
139 lines
4.3 KiB
TypeScript
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 <device-token>` 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<DeviceTokenRow | null> {
|
|
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<DeviceTokenRow>();
|
|
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<Tunnel>();
|
|
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<Tunnel>();
|
|
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 });
|
|
}
|