98 lines
3.0 KiB
TypeScript
98 lines
3.0 KiB
TypeScript
import { createClient } from '@supabase/supabase-js';
|
||
import type { H3Event } from 'h3';
|
||
import { findUserDevice, registerDevice, touchDevice } from '../db/devices';
|
||
import { getProfile } from '../db/profile';
|
||
import { getPlanLimits } from './plan-features';
|
||
|
||
const TOUCH_THROTTLE_MS = 60_000; // touch lastSeenAt höchstens 1×/min pro Device
|
||
|
||
export interface RequireUserOptions {
|
||
/** Bootstrap-Endpoints (z.B. /api/devices/register) brauchen kein Device-Binding */
|
||
skipDeviceCheck?: boolean;
|
||
}
|
||
|
||
export async function requireUser(
|
||
event: H3Event,
|
||
opts: RequireUserOptions = {},
|
||
) {
|
||
const authHeader = getHeader(event, 'authorization');
|
||
let token = authHeader?.replace('Bearer ', '');
|
||
|
||
if (!token) {
|
||
const query = getQuery(event);
|
||
token = query.token as string;
|
||
}
|
||
|
||
if (!token) {
|
||
throw createError({ statusCode: 401, message: 'Nicht eingeloggt' });
|
||
}
|
||
|
||
const config = useRuntimeConfig(event);
|
||
const supabaseCfg =
|
||
(config as any).public?.supabase ?? (config as any).supabase;
|
||
const client = createClient(
|
||
supabaseCfg.url as string,
|
||
supabaseCfg.key as string,
|
||
{ global: { headers: { Authorization: `Bearer ${token}` } } },
|
||
);
|
||
|
||
const {
|
||
data: { user },
|
||
error,
|
||
} = await client.auth.getUser();
|
||
|
||
if (error || !user) {
|
||
throw createError({ statusCode: 401, message: 'Nicht eingeloggt' });
|
||
}
|
||
|
||
if (opts.skipDeviceCheck) return user;
|
||
|
||
// Device-Binding: nur enforced wenn Client einen x-device-id Header schickt.
|
||
// Web-Clients ohne Header laufen weiter wie bisher.
|
||
const deviceId = getHeader(event, 'x-device-id');
|
||
if (!deviceId) return user;
|
||
|
||
const existing = await findUserDevice(user.id, deviceId);
|
||
if (existing) {
|
||
// Touch lastSeenAt, throttled auf 1×/min — fire-and-forget
|
||
if (Date.now() - existing.lastSeenAt.getTime() > TOUCH_THROTTLE_MS) {
|
||
touchDevice(user.id, deviceId).catch(() => {});
|
||
}
|
||
return user;
|
||
}
|
||
|
||
// Device unbekannt — Auto-Register (ohne Frontend-explicit-call)
|
||
// Plan-Limit holen
|
||
const profile = await getProfile(user.id);
|
||
const limits = getPlanLimits(profile?.plan ?? 'free');
|
||
const platform = getHeader(event, 'x-platform') ?? 'unknown';
|
||
|
||
try {
|
||
await registerDevice({
|
||
userId: user.id,
|
||
deviceId,
|
||
platform,
|
||
maxDevices: limits.maxDevices,
|
||
});
|
||
return user;
|
||
} catch (err: any) {
|
||
if (err.code === 'DEVICE_LIMIT_REACHED') {
|
||
// Devices-Liste mitschicken damit das Frontend-Modal die Geräte
|
||
// anzeigen + Freigeben-Buttons rendern kann (auch wenn der 403
|
||
// nicht vom register-Endpoint sondern vom auth-Middleware kommt).
|
||
const { listUserDevices } = await import('../db/devices');
|
||
const devices = await listUserDevices(user.id);
|
||
throw createError({
|
||
statusCode: 403,
|
||
statusMessage: 'device_limit_reached',
|
||
data: {
|
||
error: 'device_limit_reached',
|
||
max: limits.maxDevices,
|
||
plan: profile?.plan ?? 'free',
|
||
devices,
|
||
},
|
||
});
|
||
}
|
||
throw err;
|
||
}
|
||
} |