Bug: existing devices registered before Frontend started sending x-device-name/
x-device-model/x-device-os headers stayed with NULL fields forever — DeviceLimit
sheet shows only platform label ("iPhone" without iOS version, no name).
Fix:
- touchDevice() now accepts optional { name, model, osVersion } and updates
these fields when headers are provided (existing-row backfill).
- requireUser auth middleware reads URL-encoded x-device-* headers + passes
them to both touchDevice() (existing) and registerDevice() (auto-register).
After deploy: next authenticated request from updated client backfills the
device record automatically (throttled per TOUCH_THROTTLE_MS = 1×/min).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
132 lines
4.1 KiB
TypeScript
132 lines
4.1 KiB
TypeScript
import { createClient } from '@supabase/supabase-js';
|
||
import type { H3Event } from 'h3';
|
||
import { isAdminUser } from '../db/admin';
|
||
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;
|
||
|
||
// Frontend sendet name/model/osVersion als x-device-* Headers (URL-encoded)
|
||
function readEncoded(name: string): string | null {
|
||
const raw = getHeader(event, name);
|
||
if (!raw) return null;
|
||
try { return decodeURIComponent(raw); } catch { return raw; }
|
||
}
|
||
const deviceName = readEncoded('x-device-name');
|
||
const deviceModel = readEncoded('x-device-model');
|
||
const deviceOs = readEncoded('x-device-os');
|
||
|
||
const existing = await findUserDevice(user.id, deviceId);
|
||
if (existing) {
|
||
// Touch lastSeenAt + Backfill name/model/osVersion falls noch nicht gesetzt,
|
||
// throttled auf 1×/min — fire-and-forget
|
||
if (Date.now() - existing.lastSeenAt.getTime() > TOUCH_THROTTLE_MS) {
|
||
touchDevice(user.id, deviceId, {
|
||
name: deviceName,
|
||
model: deviceModel,
|
||
osVersion: deviceOs,
|
||
}).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,
|
||
name: deviceName,
|
||
model: deviceModel,
|
||
osVersion: deviceOs,
|
||
maxDevices: limits.maxAppDevices,
|
||
});
|
||
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.maxAppDevices,
|
||
plan: profile?.plan ?? 'free',
|
||
devices,
|
||
},
|
||
});
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* requireAdmin — wirft 401 wenn nicht eingeloggt, 403 wenn nicht in admin_users.
|
||
* Wraps requireUser mit skipDeviceCheck=true (Admin-App hat kein Device-Binding).
|
||
*/
|
||
export async function requireAdmin(event: H3Event) {
|
||
const user = await requireUser(event, { skipDeviceCheck: true });
|
||
|
||
const admin = await isAdminUser(user.id);
|
||
if (!admin) {
|
||
throw createError({ statusCode: 403, message: 'Kein Admin-Zugang' });
|
||
}
|
||
|
||
return user;
|
||
} |