chahinebrini b0a7091ac7
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
Deploy Staging / Build backend (Nitro) (push) Has been cancelled
Deploy Staging / Deploy zu Hetzner (push) Has been cancelled
feat(backend): /api/protection/event akzeptiert Extension-Secret
- Extension-Auth-Path via x-extension-secret Header.
- Ermittelt userId anhand deviceId aus UserDevice.
- EXTENSION_SECRET in runtimeConfig + Infisical staging.
2026-06-18 09:38:05 +02:00

119 lines
3.7 KiB
TypeScript

import { getHeader } from "h3";
import { requireUser } from "../../utils/auth";
import {
appendProtectionEventDeduped,
type ProtectionSource,
} from "../../db/protectionStateLog";
import {
upsertDeviceProtectionState,
type ProtectionType,
} from "../../db/device-protection";
const VALID_SOURCES: ProtectionSource[] = ["vpn", "mdm", "client"];
function sourceToProtectionType(source: ProtectionSource): ProtectionType {
if (source === "mdm") return "nefilter";
return "vpn";
}
async function resolveUserIdFromDeviceId(
deviceId: string,
): Promise<string | null> {
const db = usePrisma();
const row = await db.userDevice.findUnique({
where: { deviceId },
select: { userId: true },
});
return row?.userId ?? null;
}
/**
* POST /api/protection/event
*
* Body: {
* active: boolean,
* source: 'vpn' | 'mdm' | 'client',
* deviceId?: string // optional, falls bekannt (z.B. native App)
* }
*
* Auth: Bearer <jwt> (native app) OR x-extension-secret: <secret> (iOS
* Network-Extension when the container app is not running).
*
* Called from the native app (useProtectionState / lib/protection) when the
* combined protection state transitions on↔off. The client deduplicates
* locally (only fires on real transitions); the server deduplicates again
* against the last DB row for the user.
*
* Side-effect: if deviceId is provided (body or x-device-id header), the
* per-device protection state (device_protection_states) is also updated so
* that MDM / Magic-App views see the current nefilter/vpn status.
*
* Returns { success: true, written: true } if a new row was written,
* { success: true, written: false } if deduplicated (state unchanged).
*/
export default defineEventHandler(async (event) => {
const body = await readBody(event);
if (typeof body?.active !== "boolean") {
throw createError({ statusCode: 400, message: "active (boolean) required" });
}
const source: ProtectionSource = VALID_SOURCES.includes(body.source)
? (body.source as ProtectionSource)
: "client";
let userId: string;
// ─── Extension auth path (iOS Network Extension) ────────────────────────────
const extensionSecret = getHeader(event, "x-extension-secret");
const config = useRuntimeConfig(event);
const expectedSecret = (config as any).extensionSecret as string | undefined;
if (extensionSecret && extensionSecret === expectedSecret && expectedSecret) {
const deviceId = body?.deviceId;
if (!deviceId || typeof deviceId !== "string") {
throw createError({
statusCode: 400,
message: "deviceId required for extension auth",
});
}
const resolved = await resolveUserIdFromDeviceId(deviceId);
if (!resolved) {
throw createError({
statusCode: 404,
message: "device not found",
});
}
userId = resolved;
} else {
// ─── Standard auth path (container app with JWT) ──────────────────────────
const user = await requireUser(event);
userId = user.id;
}
const row = await appendProtectionEventDeduped(userId, body.active, source);
const deviceId = body?.deviceId ?? getHeader(event, "x-device-id") ?? null;
if (deviceId && typeof deviceId === "string") {
try {
await upsertDeviceProtectionState(
userId,
deviceId,
"ios",
sourceToProtectionType(source),
body.active,
new Date(),
`protection event via ${source}`,
"native-app",
);
} catch (err: any) {
console.error(
"[protection/event] device_protection_states upsert failed:",
err?.message ?? err,
);
}
}
return { success: true, written: row !== null };
});