chahinebrini 77edd67cbe fix(magic): explicit imports + staging defaults + sheet height
- backend/api/magic/register: explicit import of MAGIC_DEVICE_LIMIT
  and createAdGuardClient (Nitro auto-import was missing them
  → ReferenceError → HTTP 500 on /api/magic/register)
- mac-app: default backendBaseUrl falls back to staging.rebreak.org
  (app.rebreak.org serves wrong TLS cert)
- native MagicSheet: fallback download/dmg URLs point to staging
- native settings: Magic sheet capped at detents=[0.85] so AppHeader
  stays visible
- bundles all in-flight Magic feature work (pair create/redeem,
  device endpoints, schema, adguard utils, mac-app, locales)
2026-06-03 08:25:02 +02:00

162 lines
5.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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" });
}
// ─── RebreakMagic-Session-Token (mgc_*) ──────────────────────────────────
// Mac-App benutzt keinen Supabase-JWT, sondern einen mgc_*-Token den sie
// beim Pairing erhalten hat. Diese Tokens sind nur für /api/magic/* gültig
// und unsere requireUser-Funktion akzeptiert sie überall — Endpoint-Layer
// ist verantwortlich Magic-Tokens nur dort zuzulassen wo sinnvoll.
if (token.startsWith("mgc_")) {
const db = usePrisma();
const session = await db.magicSession.findUnique({
where: { token },
select: { id: true, userId: true, revokedAt: true },
});
if (!session || session.revokedAt) {
throw createError({ statusCode: 401, message: "Magic-Session ungültig" });
}
// Touch lastUsedAt fire-and-forget
db.magicSession
.update({
where: { id: session.id },
data: { lastUsedAt: new Date() },
})
.catch(() => {});
// Synthetisches User-Objekt mit minimalen Feldern (auth.users-kompatibel).
return { id: session.userId } as any;
}
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;
}