mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 16:56:33 +00:00
feat: add anyvali input validation to all external API endpoints
Create shared/api-schemas.ts with av.object schemas for: - pair/initiate, pair/claim (pairing flow) - kiosk/heartbeat (telemetry with displays, partitions, hwmon) - kiosk/event (ONVIF/system events) - kiosk/logs (batched log entries) - firmware/applied, os/applied (update reports) - auth/login, auth/totp, setup (admin auth) Each endpoint now calls validateBody(Schema, body) which returns 400 on schema violation. All string fields have maxLength, numeric fields have min/max ranges, arrays strip unknown keys. Rejects malformed input before it reaches DB or business logic. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
515f7088cc
commit
5d23079086
4 changed files with 252 additions and 141 deletions
|
|
@ -7,6 +7,7 @@ import type { AdminDeps } from "./index.js";
|
||||||
import { LoginPage, TotpPage, RecoveryPage } from "../../web-templates/auth-pages.js";
|
import { LoginPage, TotpPage, RecoveryPage } from "../../web-templates/auth-pages.js";
|
||||||
import { audit } from "../../shared/audit.js";
|
import { audit } from "../../shared/audit.js";
|
||||||
import { createRateLimiter } from "../../shared/rate-limit.js";
|
import { createRateLimiter } from "../../shared/rate-limit.js";
|
||||||
|
import { LoginBody, TotpBody, validateBody } from "../../shared/api-schemas.js";
|
||||||
|
|
||||||
|
|
||||||
export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
|
export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
@ -37,13 +38,14 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await readBody<{ username?: string; password?: string }>(event);
|
let body: { username: string; password: string };
|
||||||
const username = (body?.username ?? "").trim();
|
try {
|
||||||
const password = body?.password ?? "";
|
body = validateBody(LoginBody, await readBody(event));
|
||||||
|
} catch {
|
||||||
if (!username || !password) {
|
return htmlPage(LoginPage({ error: "Username and password required.", username: "" }));
|
||||||
return htmlPage(LoginPage({ error: "Username and password required.", username }));
|
|
||||||
}
|
}
|
||||||
|
const username = body.username.trim();
|
||||||
|
const password = body.password;
|
||||||
|
|
||||||
const user = await deps.repo.getUserByUsername(username);
|
const user = await deps.repo.getUserByUsername(username);
|
||||||
if (!user || !user.is_active) {
|
if (!user || !user.is_active) {
|
||||||
|
|
@ -128,10 +130,15 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
|
||||||
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await readBody<{ code?: string }>(event);
|
let totpBody: { code: string };
|
||||||
const code = (body?.code ?? "").trim().replace(/\s/g, "");
|
try {
|
||||||
|
totpBody = validateBody(TotpBody, await readBody(event));
|
||||||
|
} catch {
|
||||||
|
return htmlPage(TotpPage({ error: "Enter a 6-digit code." }));
|
||||||
|
}
|
||||||
|
const code = totpBody.code.trim().replace(/\s/g, "");
|
||||||
|
|
||||||
if (!code || code.length !== 6) {
|
if (code.length !== 6) {
|
||||||
return htmlPage(TotpPage({ error: "Enter a 6-digit code." }));
|
return htmlPage(TotpPage({ error: "Enter a 6-digit code." }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { type H3, readBody } from "h3";
|
||||||
import { htmlPage } from "./html-response.js";
|
import { htmlPage } from "./html-response.js";
|
||||||
import type { AdminDeps } from "./index.js";
|
import type { AdminDeps } from "./index.js";
|
||||||
import { SetupPage } from "../../web-templates/auth-pages.js";
|
import { SetupPage } from "../../web-templates/auth-pages.js";
|
||||||
|
import { SetupBody, validateBody } from "../../shared/api-schemas.js";
|
||||||
|
|
||||||
export function registerSetupRoutes(app: H3, deps: AdminDeps): void {
|
export function registerSetupRoutes(app: H3, deps: AdminDeps): void {
|
||||||
app.get("/setup", async () => {
|
app.get("/setup", async () => {
|
||||||
|
|
@ -19,19 +20,19 @@ export function registerSetupRoutes(app: H3, deps: AdminDeps): void {
|
||||||
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await readBody<{ username?: string; password?: string }>(event);
|
let body: { username: string; password: string };
|
||||||
const username = (body?.username ?? "").trim();
|
try {
|
||||||
const password = body?.password ?? "";
|
body = validateBody(SetupBody, await readBody(event));
|
||||||
|
} catch {
|
||||||
|
return htmlPage(SetupPage({ error: "Username (3-64 chars) and password (12+ chars) required.", username: "" }));
|
||||||
|
}
|
||||||
|
const username = body.username.trim();
|
||||||
|
const password = body.password;
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
if (!username || username.length < 3 || username.length > 64) {
|
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
|
||||||
errors.push("Username must be 3–64 characters.");
|
|
||||||
} else if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
|
|
||||||
errors.push("Username may only contain letters, digits, underscore, or hyphen.");
|
errors.push("Username may only contain letters, digits, underscore, or hyphen.");
|
||||||
}
|
}
|
||||||
if (password.length < 12) {
|
|
||||||
errors.push("Password must be at least 12 characters.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
return htmlPage(SetupPage({ error: errors.join(" "), username }));
|
return htmlPage(SetupPage({ error: errors.join(" "), username }));
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,10 @@ import { createHash } from "node:crypto";
|
||||||
import type { AuthApi } from "../../shared/auth.js";
|
import type { AuthApi } from "../../shared/auth.js";
|
||||||
import type { SecretsApi } from "../../shared/secrets.js";
|
import type { SecretsApi } from "../../shared/secrets.js";
|
||||||
import type { FirmwareChannel } from "../../shared/types.js";
|
import type { FirmwareChannel } from "../../shared/types.js";
|
||||||
|
import {
|
||||||
|
PairInitiateBody, PairClaimBody, HeartbeatBody, EventBody,
|
||||||
|
KioskLogsBody, FirmwareAppliedBody, OsAppliedBody, validateBody,
|
||||||
|
} from "../../shared/api-schemas.js";
|
||||||
|
|
||||||
// ---- Config -----------------------------------------------------------------
|
// ---- Config -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -292,18 +296,13 @@ function registerPairingRoutes(
|
||||||
throw createError({ statusCode: 429, statusMessage: "rate limited" });
|
throw createError({ statusCode: 429, statusMessage: "rate limited" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await readBody<{
|
const body = validateBody(PairInitiateBody, await readBody(event));
|
||||||
proposed_name?: string;
|
|
||||||
hardware_model?: string;
|
|
||||||
capabilities?: string[];
|
|
||||||
managed_image?: boolean;
|
|
||||||
}>(event);
|
|
||||||
|
|
||||||
const result = await initiatePairing(repo, {
|
const result = await initiatePairing(repo, {
|
||||||
proposedName: body?.proposed_name ?? null,
|
proposedName: body.proposed_name || null,
|
||||||
hardwareModel: body?.hardware_model ?? null,
|
hardwareModel: body.hardware_model || null,
|
||||||
capabilities: body?.capabilities ?? [],
|
capabilities: body.capabilities,
|
||||||
managedImage: body?.managed_image === true,
|
managedImage: body.managed_image,
|
||||||
codeTtlSeconds: codeTtl,
|
codeTtlSeconds: codeTtl,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -319,9 +318,8 @@ function registerPairingRoutes(
|
||||||
throw createError({ statusCode: 429, statusMessage: "rate limited" });
|
throw createError({ statusCode: 429, statusMessage: "rate limited" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await readBody<{ code?: string }>(event);
|
const body = validateBody(PairClaimBody, await readBody(event));
|
||||||
const code = (body?.code ?? "").trim().toUpperCase();
|
const code = body.code.trim().toUpperCase();
|
||||||
if (!code) throw createError({ statusCode: 400, statusMessage: "code required" });
|
|
||||||
|
|
||||||
const reqObs = event.context.obs!;
|
const reqObs = event.context.obs!;
|
||||||
const result = await claimPairing(repo, code, reqObs);
|
const result = await claimPairing(repo, code, reqObs);
|
||||||
|
|
@ -459,44 +457,7 @@ function registerKioskRoutes(
|
||||||
if (!kiosk) return { bf_kiosk_deleted: true };
|
if (!kiosk) return { bf_kiosk_deleted: true };
|
||||||
event.context.obs?.log.info("heartbeat from kiosk {id}", { id: String(kiosk.id) });
|
event.context.obs?.log.info("heartbeat from kiosk {id}", { id: String(kiosk.id) });
|
||||||
|
|
||||||
const body = await readBody<{
|
const body = validateBody(HeartbeatBody, await readBody(event));
|
||||||
bundle_version?: string;
|
|
||||||
kiosk_app_version?: string;
|
|
||||||
os_version?: string;
|
|
||||||
displays?: Array<{
|
|
||||||
index?: number;
|
|
||||||
name: string;
|
|
||||||
width_px: number;
|
|
||||||
height_px: number;
|
|
||||||
power_state?: "awake" | "standby" | "unknown";
|
|
||||||
}>;
|
|
||||||
cpu_temp_c?: number | null;
|
|
||||||
cpu_load_percent?: number | null;
|
|
||||||
fan_rpm?: number | null;
|
|
||||||
fan_pwm?: number | null;
|
|
||||||
memory_total_mb?: number | null;
|
|
||||||
memory_used_mb?: number | null;
|
|
||||||
disk_total_mb?: number | null;
|
|
||||||
disk_free_mb?: number | null;
|
|
||||||
disk_used_percent?: number | null;
|
|
||||||
local_key?: string | null;
|
|
||||||
local_port?: number | null;
|
|
||||||
reported_hostname?: string | null;
|
|
||||||
network_interfaces?: Array<Record<string, unknown>>;
|
|
||||||
partitions?: Array<{
|
|
||||||
device: string;
|
|
||||||
mountpoint: string;
|
|
||||||
total_mb: number;
|
|
||||||
used_mb: number;
|
|
||||||
free_mb: number;
|
|
||||||
used_percent: number;
|
|
||||||
}>;
|
|
||||||
// Managed-image kiosk echoes back the version it last applied, and the
|
|
||||||
// last apply error (if any). Server uses these to decide whether to
|
|
||||||
// include pending_config in the response.
|
|
||||||
managed_config_applied_version?: number;
|
|
||||||
managed_config_error?: string | null;
|
|
||||||
}>(event);
|
|
||||||
|
|
||||||
// Capture the kiosk's LAN-side IP from the heartbeat connection so admin
|
// Capture the kiosk's LAN-side IP from the heartbeat connection so admin
|
||||||
// can render a copy-paste URL even when the kiosk has no DNS name.
|
// can render a copy-paste URL even when the kiosk has no DNS name.
|
||||||
|
|
@ -505,26 +466,26 @@ function registerKioskRoutes(
|
||||||
?? null;
|
?? null;
|
||||||
|
|
||||||
await repo.touchKiosk(kiosk.id, {
|
await repo.touchKiosk(kiosk.id, {
|
||||||
bundle_version: body?.bundle_version ?? null,
|
bundle_version: body.bundle_version ?? null,
|
||||||
kiosk_app_version: body?.kiosk_app_version ?? null,
|
kiosk_app_version: body.kiosk_app_version ?? null,
|
||||||
os_version: body?.os_version ?? null,
|
os_version: body.os_version ?? null,
|
||||||
cpu_temp_c: body?.cpu_temp_c ?? null,
|
cpu_temp_c: body.cpu_temp_c ?? null,
|
||||||
cpu_load_percent: body?.cpu_load_percent ?? null,
|
cpu_load_percent: body.cpu_load_percent ?? null,
|
||||||
fan_rpm: body?.fan_rpm ?? null,
|
fan_rpm: body.fan_rpm ?? null,
|
||||||
fan_pwm: body?.fan_pwm ?? null,
|
fan_pwm: body.fan_pwm ?? null,
|
||||||
memory_total_mb: body?.memory_total_mb ?? null,
|
memory_total_mb: body.memory_total_mb ?? null,
|
||||||
memory_used_mb: body?.memory_used_mb ?? null,
|
memory_used_mb: body.memory_used_mb ?? null,
|
||||||
disk_total_mb: body?.disk_total_mb ?? null,
|
disk_total_mb: body.disk_total_mb ?? null,
|
||||||
disk_free_mb: body?.disk_free_mb ?? null,
|
disk_free_mb: body.disk_free_mb ?? null,
|
||||||
disk_used_percent: body?.disk_used_percent ?? null,
|
disk_used_percent: body.disk_used_percent ?? null,
|
||||||
local_key: body?.local_key ?? null,
|
local_key: body.local_key ?? null,
|
||||||
local_port: body?.local_port ?? null,
|
local_port: body.local_port ?? null,
|
||||||
local_last_ip: remoteIp,
|
local_last_ip: remoteIp,
|
||||||
reported_hostname: body?.reported_hostname ?? null,
|
reported_hostname: body.reported_hostname ?? null,
|
||||||
network_interfaces_json: Array.isArray(body?.network_interfaces)
|
network_interfaces_json: Array.isArray(body.network_interfaces)
|
||||||
? JSON.stringify(body.network_interfaces)
|
? JSON.stringify(body.network_interfaces)
|
||||||
: null,
|
: null,
|
||||||
partitions_json: Array.isArray(body?.partitions)
|
partitions_json: Array.isArray(body.partitions)
|
||||||
? JSON.stringify(body.partitions)
|
? JSON.stringify(body.partitions)
|
||||||
: null,
|
: null,
|
||||||
});
|
});
|
||||||
|
|
@ -534,7 +495,7 @@ function registerKioskRoutes(
|
||||||
// successful apply (kiosk omits it). verifyKioskKey returns just {id};
|
// successful apply (kiosk omits it). verifyKioskKey returns just {id};
|
||||||
// re-read the full row to check the managed_image flag.
|
// re-read the full row to check the managed_image flag.
|
||||||
const kioskFull = await repo.getKioskById(kiosk.id);
|
const kioskFull = await repo.getKioskById(kiosk.id);
|
||||||
if (kioskFull?.managed_image && typeof body?.managed_config_applied_version === "number") {
|
if (kioskFull?.managed_image && typeof body.managed_config_applied_version === "number") {
|
||||||
const patch: Record<string, unknown> = {
|
const patch: Record<string, unknown> = {
|
||||||
managed_config_applied_version: body.managed_config_applied_version,
|
managed_config_applied_version: body.managed_config_applied_version,
|
||||||
managed_config_applied_at: new Date().toISOString(),
|
managed_config_applied_at: new Date().toISOString(),
|
||||||
|
|
@ -547,24 +508,24 @@ function registerKioskRoutes(
|
||||||
|
|
||||||
// Mirror to MQTT bridge (no-op when BF_MQTT_URL unset).
|
// Mirror to MQTT bridge (no-op when BF_MQTT_URL unset).
|
||||||
mqtt.publishTelemetry(kiosk.id, {
|
mqtt.publishTelemetry(kiosk.id, {
|
||||||
kiosk_app_version: body?.kiosk_app_version,
|
kiosk_app_version: body.kiosk_app_version,
|
||||||
bundle_version: body?.bundle_version,
|
bundle_version: body.bundle_version,
|
||||||
cpu_temp_c: body?.cpu_temp_c,
|
cpu_temp_c: body.cpu_temp_c,
|
||||||
cpu_load_percent: body?.cpu_load_percent,
|
cpu_load_percent: body.cpu_load_percent,
|
||||||
fan_rpm: body?.fan_rpm,
|
fan_rpm: body.fan_rpm,
|
||||||
fan_pwm: body?.fan_pwm,
|
fan_pwm: body.fan_pwm,
|
||||||
memory_total_mb: body?.memory_total_mb,
|
memory_total_mb: body.memory_total_mb,
|
||||||
memory_used_mb: body?.memory_used_mb,
|
memory_used_mb: body.memory_used_mb,
|
||||||
disk_total_mb: body?.disk_total_mb,
|
disk_total_mb: body.disk_total_mb,
|
||||||
disk_free_mb: body?.disk_free_mb,
|
disk_free_mb: body.disk_free_mb,
|
||||||
disk_used_percent: body?.disk_used_percent,
|
disk_used_percent: body.disk_used_percent,
|
||||||
ip: remoteIp,
|
ip: remoteIp,
|
||||||
reported_hostname: body?.reported_hostname,
|
reported_hostname: body.reported_hostname,
|
||||||
network_interfaces: body?.network_interfaces,
|
network_interfaces: body.network_interfaces,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sync displays reported by the kiosk
|
// Sync displays reported by the kiosk
|
||||||
if (Array.isArray(body?.displays)) {
|
if (Array.isArray(body.displays)) {
|
||||||
const existing = await repo.listDisplaysForKiosk(kiosk.id);
|
const existing = await repo.listDisplaysForKiosk(kiosk.id);
|
||||||
const seenDisplayIds = new Set<string>();
|
const seenDisplayIds = new Set<string>();
|
||||||
for (const [position, reported] of body.displays.entries()) {
|
for (const [position, reported] of body.displays.entries()) {
|
||||||
|
|
@ -665,20 +626,11 @@ function registerKioskRoutes(
|
||||||
const kiosk = await auth.verifyKioskKey(token);
|
const kiosk = await auth.verifyKioskKey(token);
|
||||||
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
|
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
|
||||||
|
|
||||||
const body = await readBody<{
|
const body = validateBody(EventBody, await readBody(event));
|
||||||
topic: string;
|
const payload = (body.payload ?? {}) as Record<string, unknown>;
|
||||||
source_type?: string;
|
|
||||||
camera_id?: string;
|
|
||||||
property_op?: string;
|
|
||||||
payload?: Record<string, unknown>;
|
|
||||||
}>(event);
|
|
||||||
|
|
||||||
if (!body?.topic) throw createError({ statusCode: 400, statusMessage: "topic required" });
|
|
||||||
event.context.obs?.log.info("event from kiosk {id} topic {topic}", { id: String(kiosk.id), topic: body.topic });
|
event.context.obs?.log.info("event from kiosk {id} topic {topic}", { id: String(kiosk.id), topic: body.topic });
|
||||||
|
|
||||||
// Dedup: Hikvision cameras send duplicate ONVIF events within ~1s.
|
const dedupKey = `${kiosk.id}:${body.camera_id ?? 0}:${body.topic}:${JSON.stringify(payload["source"] ?? "")}`;
|
||||||
// Key = kiosk_id:camera_id:topic:source_keys_hash. Window = 2s.
|
|
||||||
const dedupKey = `${kiosk.id}:${body.camera_id ?? 0}:${body.topic}:${JSON.stringify(body.payload?.["source"] ?? "")}`;
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (eventDedupCache.has(dedupKey)) {
|
if (eventDedupCache.has(dedupKey)) {
|
||||||
const lastSeen = eventDedupCache.get(dedupKey)!;
|
const lastSeen = eventDedupCache.get(dedupKey)!;
|
||||||
|
|
@ -703,7 +655,7 @@ function registerKioskRoutes(
|
||||||
source_type: (body.source_type as any) ?? "system",
|
source_type: (body.source_type as any) ?? "system",
|
||||||
topic: body.topic,
|
topic: body.topic,
|
||||||
property_op: body.property_op ?? null,
|
property_op: body.property_op ?? null,
|
||||||
payload: body.payload ?? {},
|
payload,
|
||||||
forwarded_to_nodered: false,
|
forwarded_to_nodered: false,
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|
@ -714,7 +666,7 @@ function registerKioskRoutes(
|
||||||
source_type: (body.source_type as any) ?? "system",
|
source_type: (body.source_type as any) ?? "system",
|
||||||
topic: body.topic,
|
topic: body.topic,
|
||||||
property_op: body.property_op ?? null,
|
property_op: body.property_op ?? null,
|
||||||
payload: body.payload ?? {},
|
payload,
|
||||||
forwarded_to_nodered: false,
|
forwarded_to_nodered: false,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -725,8 +677,8 @@ function registerKioskRoutes(
|
||||||
// Side-effect: persist active layout per display so the admin UI can
|
// Side-effect: persist active layout per display so the admin UI can
|
||||||
// surface "currently showing X" without having to query event_log.
|
// surface "currently showing X" without having to query event_log.
|
||||||
if (body.topic === "layout.changed") {
|
if (body.topic === "layout.changed") {
|
||||||
const displayId = String(body.payload?.["display_id"] ?? "");
|
const displayId = String(payload["display_id"] ?? "");
|
||||||
const layoutId = String(body.payload?.["layout_id"] ?? "");
|
const layoutId = String(payload["layout_id"] ?? "");
|
||||||
if (displayId && layoutId) {
|
if (displayId && layoutId) {
|
||||||
try {
|
try {
|
||||||
await repo.updateDisplay(displayId, { active_layout_id: layoutId } as any);
|
await repo.updateDisplay(displayId, { active_layout_id: layoutId } as any);
|
||||||
|
|
@ -792,26 +744,19 @@ function registerKioskRoutes(
|
||||||
const kiosk = await auth.verifyKioskKey(token);
|
const kiosk = await auth.verifyKioskKey(token);
|
||||||
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
|
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
|
||||||
|
|
||||||
const body = await readBody<{
|
const body = validateBody(KioskLogsBody, await readBody(event));
|
||||||
entries?: Array<{ level?: string; message?: string; context?: Record<string, unknown>; logged_at?: string }>;
|
if (body.entries.length === 0) {
|
||||||
}>(event);
|
|
||||||
|
|
||||||
const raw = body?.entries;
|
|
||||||
if (!Array.isArray(raw) || raw.length === 0) {
|
|
||||||
throw createError({ statusCode: 400, statusMessage: "entries array required" });
|
throw createError({ statusCode: 400, statusMessage: "entries array required" });
|
||||||
}
|
}
|
||||||
if (raw.length > 100) {
|
|
||||||
throw createError({ statusCode: 400, statusMessage: "max 100 entries per batch" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const validLevels = new Set(["debug", "info", "warn", "error"]);
|
const validLevels = new Set(["debug", "info", "warn", "error"]);
|
||||||
const entries = raw
|
const entries = body.entries
|
||||||
.filter((e) => e.message && typeof e.message === "string")
|
.filter((e: any) => e.message.length > 0)
|
||||||
.map((e) => ({
|
.map((e: any) => ({
|
||||||
level: (validLevels.has(e.level ?? "") ? e.level! : "info") as "debug" | "info" | "warn" | "error",
|
level: (validLevels.has(e.level) ? e.level : "info") as "debug" | "info" | "warn" | "error",
|
||||||
message: e.message!,
|
message: String(e.message),
|
||||||
context: e.context ?? {},
|
context: (e.context ?? {}) as Record<string, unknown>,
|
||||||
logged_at: e.logged_at,
|
logged_at: e.logged_at as string | undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const count = await repo.insertKioskLogs(kiosk.id, entries);
|
const count = await repo.insertKioskLogs(kiosk.id, entries);
|
||||||
|
|
@ -935,10 +880,7 @@ function registerKioskRoutes(
|
||||||
const kiosk = await auth.verifyKioskKey(token);
|
const kiosk = await auth.verifyKioskKey(token);
|
||||||
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
|
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
|
||||||
|
|
||||||
const body = await readBody<{ version: string; error?: string }>(event);
|
const body = validateBody(FirmwareAppliedBody, await readBody(event));
|
||||||
if (!body?.version) {
|
|
||||||
throw createError({ statusCode: 400, statusMessage: "version required" });
|
|
||||||
}
|
|
||||||
await repo.recordKioskFirmwareAttempt(kiosk.id, body.version, body.error ?? null);
|
await repo.recordKioskFirmwareAttempt(kiosk.id, body.version, body.error ?? null);
|
||||||
await repo.insertEvent({
|
await repo.insertEvent({
|
||||||
source_kiosk_id: kiosk.id,
|
source_kiosk_id: kiosk.id,
|
||||||
|
|
@ -1079,10 +1021,7 @@ function registerKioskRoutes(
|
||||||
const kiosk = await auth.verifyKioskKey(token);
|
const kiosk = await auth.verifyKioskKey(token);
|
||||||
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
|
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
|
||||||
|
|
||||||
const body = await readBody<{ version: string; error?: string }>(event);
|
const body = validateBody(OsAppliedBody, await readBody(event));
|
||||||
if (!body?.version) {
|
|
||||||
throw createError({ statusCode: 400, statusMessage: "version required" });
|
|
||||||
}
|
|
||||||
await repo.recordKioskOsUpdateAttempt(kiosk.id, body.version, body.error ?? null);
|
await repo.recordKioskOsUpdateAttempt(kiosk.id, body.version, body.error ?? null);
|
||||||
await repo.insertEvent({
|
await repo.insertEvent({
|
||||||
source_kiosk_id: kiosk.id,
|
source_kiosk_id: kiosk.id,
|
||||||
|
|
|
||||||
164
server/src/shared/api-schemas.ts
Normal file
164
server/src/shared/api-schemas.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
/**
|
||||||
|
* Anyvali input schemas for all external-facing API endpoints.
|
||||||
|
* Applied via validateBody() / validateQuery() helpers.
|
||||||
|
*/
|
||||||
|
import * as av from "@anyvali/js";
|
||||||
|
|
||||||
|
// ---- Kiosk API (service-api-http) -------------------------------------------
|
||||||
|
|
||||||
|
export const PairInitiateBody = av.object(
|
||||||
|
{
|
||||||
|
proposed_name: av.string().maxLength(128).default(""),
|
||||||
|
hardware_model: av.string().maxLength(128).default(""),
|
||||||
|
capabilities: av.array(av.string().maxLength(64)).default([]),
|
||||||
|
managed_image: av.bool().default(false),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PairClaimBody = av.object(
|
||||||
|
{
|
||||||
|
code: av.string().minLength(1).maxLength(16),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const HeartbeatDisplay = av.object(
|
||||||
|
{
|
||||||
|
index: av.int().min(0).max(32).default(0),
|
||||||
|
name: av.string().maxLength(128).default(""),
|
||||||
|
width_px: av.int().min(0).max(16384).default(0),
|
||||||
|
height_px: av.int().min(0).max(16384).default(0),
|
||||||
|
power_state: av.string().maxLength(16).default("unknown"),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const HeartbeatPartition = av.object(
|
||||||
|
{
|
||||||
|
device: av.string().maxLength(128).default(""),
|
||||||
|
mountpoint: av.string().maxLength(256).default(""),
|
||||||
|
total_mb: av.int().min(0).default(0),
|
||||||
|
used_mb: av.int().min(0).default(0),
|
||||||
|
free_mb: av.int().min(0).default(0),
|
||||||
|
used_percent: av.number().min(0).max(100).default(0),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const HeartbeatBody = av.object(
|
||||||
|
{
|
||||||
|
bundle_version: av.string().maxLength(128).default(""),
|
||||||
|
kiosk_app_version: av.string().maxLength(64).default(""),
|
||||||
|
os_version: av.string().maxLength(64).default(""),
|
||||||
|
displays: av.array(HeartbeatDisplay).default([]),
|
||||||
|
cpu_temp_c: av.nullable(av.number().min(-40).max(150)).default(null),
|
||||||
|
cpu_load_percent: av.nullable(av.number().min(0).max(100)).default(null),
|
||||||
|
fan_rpm: av.nullable(av.int().min(0).max(50000)).default(null),
|
||||||
|
fan_pwm: av.nullable(av.int().min(0).max(255)).default(null),
|
||||||
|
memory_total_mb: av.nullable(av.int().min(0)).default(null),
|
||||||
|
memory_used_mb: av.nullable(av.int().min(0)).default(null),
|
||||||
|
disk_total_mb: av.nullable(av.int().min(0)).default(null),
|
||||||
|
disk_free_mb: av.nullable(av.int().min(0)).default(null),
|
||||||
|
disk_used_percent: av.nullable(av.number().min(0).max(100)).default(null),
|
||||||
|
local_key: av.nullable(av.string().maxLength(256)).default(null),
|
||||||
|
local_port: av.nullable(av.int().min(1).max(65535)).default(null),
|
||||||
|
reported_hostname: av.nullable(av.string().maxLength(256)).default(null),
|
||||||
|
network_interfaces: av.array(av.any()).default([]),
|
||||||
|
partitions: av.array(HeartbeatPartition).default([]),
|
||||||
|
managed_config_applied_version: av.optional(av.int().min(0)),
|
||||||
|
managed_config_error: av.optional(av.nullable(av.string().maxLength(4096))),
|
||||||
|
onvif_subscriptions: av.optional(av.any()),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const EventBody = av.object(
|
||||||
|
{
|
||||||
|
topic: av.string().minLength(1).maxLength(512),
|
||||||
|
source_type: av.string().maxLength(32).default("system"),
|
||||||
|
camera_id: av.nullable(av.string().maxLength(64)).default(null),
|
||||||
|
property_op: av.nullable(av.string().maxLength(32)).default(null),
|
||||||
|
payload: av.any().default({}),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const KioskLogEntry = av.object(
|
||||||
|
{
|
||||||
|
level: av.string().maxLength(16).default("info"),
|
||||||
|
message: av.string().maxLength(4096).default(""),
|
||||||
|
context: av.any().default({}),
|
||||||
|
logged_at: av.optional(av.string().maxLength(64)),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const KioskLogsBody = av.object(
|
||||||
|
{
|
||||||
|
entries: av.array(KioskLogEntry).default([]),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FirmwareAppliedBody = av.object(
|
||||||
|
{
|
||||||
|
version: av.string().minLength(1).maxLength(64),
|
||||||
|
error: av.optional(av.string().maxLength(4096)),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const OsAppliedBody = av.object(
|
||||||
|
{
|
||||||
|
version: av.string().minLength(1).maxLength(64),
|
||||||
|
error: av.optional(av.string().maxLength(4096)),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Auth (routes-auth, routes-setup) ----------------------------------------
|
||||||
|
|
||||||
|
export const LoginBody = av.object(
|
||||||
|
{
|
||||||
|
username: av.string().minLength(1).maxLength(128),
|
||||||
|
password: av.string().minLength(1).maxLength(1024),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TotpBody = av.object(
|
||||||
|
{
|
||||||
|
code: av.string().minLength(1).maxLength(16),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SetupBody = av.object(
|
||||||
|
{
|
||||||
|
username: av.string().minLength(3).maxLength(64),
|
||||||
|
password: av.string().minLength(12).maxLength(1024),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PasswordChangeBody = av.object(
|
||||||
|
{
|
||||||
|
current_password: av.string().minLength(1).maxLength(1024),
|
||||||
|
new_password: av.string().minLength(12).maxLength(1024),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Helper -----------------------------------------------------------------
|
||||||
|
|
||||||
|
export function validateBody<T>(schema: { safeParse(input: unknown): { success: boolean; data?: T; error?: unknown } }, raw: unknown): T {
|
||||||
|
const result = schema.safeParse(raw);
|
||||||
|
if (!result.success) {
|
||||||
|
const msg = typeof result.error === "object" && result.error && "message" in result.error
|
||||||
|
? String((result.error as any).message)
|
||||||
|
: "invalid request body";
|
||||||
|
throw Object.assign(new Error(msg), { status: 400, statusText: "Bad Request" });
|
||||||
|
}
|
||||||
|
return result.data as T;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue