import { randomInt } from "node:crypto"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import type { AdbConfig } from "./adb.js"; import { launchAdbTarget } from "./adb.js"; import { bluetoothActionSchema, readBluetoothStatus, triggerBluetoothAction } from "./bluetooth.js"; import type { CarTelemetryRecorder } from "./car-telemetry.js"; import type { CompanionDatabase } from "./database.js"; import { hashToken, randomToken } from "./security.js"; import { collectSystemStatus } from "./status.js"; import { TripStore } from "./trips.js"; const pairingSchema = z.object({ code: z.string().regex(/^\d{6}$/), deviceId: z.string().uuid(), deviceName: z.string().trim().min(1).max(80) }); const locationSchema = z.object({ capturedAt: z.string().datetime(), latitude: z.number().finite().min(-90).max(90), longitude: z.number().finite().min(-180).max(180), accuracyMeters: z.number().finite().min(0).max(10_000), altitudeMeters: z.number().finite().min(-1_000).max(20_000).nullable().optional(), speedKph: z.number().finite().min(0).max(1_000).nullable().optional(), bearingDegrees: z.number().finite().min(0).max(360).nullable().optional(), batteryPercent: z.number().finite().min(0).max(100).nullable().optional(), networkType: z.enum(["offline", "wifi", "cellular", "ethernet", "other"]).nullable().optional() }); type MobileDevice = { id: string; name: string; createdAt: string; lastSeenAt: string; revokedAt: string | null }; type AuthenticatedMobileDevice = { id: string; name: string }; export function isLoopbackAddress(address: string): boolean { return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1"; } function requireLoopback(request: FastifyRequest, reply: FastifyReply): boolean { if (isLoopbackAddress(request.ip)) return true; void reply.code(404).send({ error: { code: "NOT_FOUND", message: "Not found" } }); return false; } function authenticateMobile(database: CompanionDatabase, request: FastifyRequest, reply: FastifyReply): AuthenticatedMobileDevice | null { if (!requireLoopback(request, reply)) return null; const token = request.headers["x-mobile-token"]; if (typeof token !== "string" || token.length < 32) { void reply.code(401).send({ error: { code: "MOBILE_AUTH_REQUIRED", message: "Pair the phone again" } }); return null; } const device = database.prepare( "SELECT id, name FROM mobile_devices WHERE token_hash = ? AND revoked_at IS NULL" ).get(hashToken(token)) as AuthenticatedMobileDevice | undefined; if (!device) { void reply.code(401).send({ error: { code: "MOBILE_AUTH_INVALID", message: "This phone is no longer paired" } }); return null; } database.prepare("UPDATE mobile_devices SET last_seen_at = ? WHERE id = ?").run(new Date().toISOString(), device.id); return device; } function auditMobile(database: CompanionDatabase, device: AuthenticatedMobileDevice, action: string, result: "success" | "failure", details: Record = {}) { database.prepare( "INSERT INTO audit_events (action, result, details_json, created_at) VALUES (?, ?, ?, ?)" ).run(action, result, JSON.stringify({ mobileDeviceId: device.id, mobileDeviceName: device.name, ...details }), new Date().toISOString()); } export function registerMobileRoutes( app: FastifyInstance, database: CompanionDatabase, adbConfig: AdbConfig, carTelemetry: CarTelemetryRecorder, requireWebUser: (request: FastifyRequest, reply: FastifyReply) => boolean ): void { const trips = new TripStore(database); app.post("/api/mobile/pairing", async (request, reply) => { if (!requireWebUser(request, reply)) return; const code = String(randomInt(100_000, 1_000_000)); const now = new Date(); const expiresAt = new Date(now.getTime() + 10 * 60_000).toISOString(); database.prepare( `INSERT INTO mobile_pairing_codes (id, code_hash, expires_at, created_by, created_at) VALUES (1, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET code_hash = excluded.code_hash, expires_at = excluded.expires_at, created_by = excluded.created_by, created_at = excluded.created_at` ).run(hashToken(code), expiresAt, request.authUser!.id, now.toISOString()); return { code, expiresAt }; }); app.get("/api/mobile/devices", async (request, reply) => { if (!requireWebUser(request, reply)) return; const devices = database.prepare( `SELECT id, name, created_at AS createdAt, last_seen_at AS lastSeenAt, revoked_at AS revokedAt FROM mobile_devices ORDER BY created_at DESC` ).all() as MobileDevice[]; return { devices }; }); app.get("/api/trips", async (request, reply) => { if (!requireWebUser(request, reply)) return; return trips.list(); }); app.get<{ Params: { id: string } }>("/api/trips/:id", async (request, reply) => { if (!requireWebUser(request, reply)) return; const id = Number(request.params.id); if (!Number.isInteger(id) || id < 1) { return reply.code(400).send({ error: { code: "INVALID_TRIP", message: "That trip is invalid" } }); } const trip = trips.detail(id); if (!trip) return reply.code(404).send({ error: { code: "TRIP_NOT_FOUND", message: "Trip not found" } }); return trip; }); app.get("/api/mobile/bluetooth", async (request, reply) => { if (!requireWebUser(request, reply)) return; return readBluetoothStatus(); }); app.post("/api/mobile/bluetooth", { config: { rateLimit: { max: 12, timeWindow: "1 minute" } } }, async (request, reply) => { if (!requireWebUser(request, reply)) return; const parsed = z.object({ action: bluetoothActionSchema }).safeParse(request.body); if (!parsed.success) { return reply.code(400).send({ error: { code: "INVALID_BLUETOOTH_ACTION", message: "That Bluetooth action is not available" } }); } try { return await triggerBluetoothAction(parsed.data.action); } catch { return reply.code(503).send({ error: { code: "BLUETOOTH_ACTION_FAILED", message: "The Pi could not change its Bluetooth state" } }); } }); app.delete<{ Params: { id: string } }>("/api/mobile/devices/:id", async (request, reply) => { if (!requireWebUser(request, reply)) return; const result = database.prepare("UPDATE mobile_devices SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL") .run(new Date().toISOString(), request.params.id); if (result.changes === 0) return reply.code(404).send({ error: { code: "MOBILE_DEVICE_NOT_FOUND", message: "Phone not found" } }); return reply.code(204).send(); }); app.post("/internal/mobile/pair", { config: { rateLimit: { max: 8, timeWindow: "1 minute" } } }, async (request, reply) => { if (!requireLoopback(request, reply)) return; const parsed = pairingSchema.safeParse(request.body); if (!parsed.success) return reply.code(400).send({ error: { code: "INVALID_PAIRING_REQUEST", message: "Enter the six-digit pairing code" } }); const row = database.prepare("SELECT code_hash, expires_at FROM mobile_pairing_codes WHERE id = 1") .get() as { code_hash: string; expires_at: string } | undefined; if (!row || row.code_hash !== hashToken(parsed.data.code) || row.expires_at <= new Date().toISOString()) { return reply.code(401).send({ error: { code: "PAIRING_CODE_INVALID", message: "The pairing code is invalid or expired" } }); } const token = randomToken(); const now = new Date().toISOString(); database.transaction(() => { database.prepare( `INSERT INTO mobile_devices (id, name, token_hash, created_at, last_seen_at, revoked_at) VALUES (?, ?, ?, ?, ?, NULL) ON CONFLICT(id) DO UPDATE SET name = excluded.name, token_hash = excluded.token_hash, last_seen_at = excluded.last_seen_at, revoked_at = NULL` ).run(parsed.data.deviceId, parsed.data.deviceName, hashToken(token), now, now); database.prepare("DELETE FROM mobile_pairing_codes WHERE id = 1").run(); })(); return { token, deviceId: parsed.data.deviceId, pairedAt: now }; }); app.get("/internal/mobile/snapshot", async (request, reply) => { const device = authenticateMobile(database, request, reply); if (!device) return; const [system, car] = await Promise.all([ collectSystemStatus(adbConfig), carTelemetry.readLive().catch(() => null) ]); const shortcuts = database.prepare( "SELECT id, name, target_type AS type, target_value AS value FROM adb_shortcuts ORDER BY created_at, id" ).all(); const phone = database.prepare( `SELECT received_at AS receivedAt, captured_at AS capturedAt, latitude, longitude, accuracy_meters AS accuracyMeters, altitude_meters AS altitudeMeters, speed_kph AS speedKph, bearing_degrees AS bearingDegrees, battery_percent AS batteryPercent, network_type AS networkType FROM mobile_phone_state WHERE device_id = ?` ).get(device.id) ?? null; return { protocolVersion: 1, collectedAt: new Date().toISOString(), system, car, statistics: carTelemetry.store.overview(), shortcuts, phone }; }); app.post("/internal/mobile/location", async (request, reply) => { const device = authenticateMobile(database, request, reply); if (!device) return; const parsed = locationSchema.safeParse(request.body); if (!parsed.success) return reply.code(400).send({ error: { code: "INVALID_PHONE_LOCATION", message: "The phone location was invalid" } }); const value = parsed.data; const trip = database.transaction(() => { database.prepare( `INSERT INTO mobile_phone_state (device_id, received_at, captured_at, latitude, longitude, accuracy_meters, altitude_meters, speed_kph, bearing_degrees, battery_percent, network_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(device_id) DO UPDATE SET received_at = excluded.received_at, captured_at = excluded.captured_at, latitude = excluded.latitude, longitude = excluded.longitude, accuracy_meters = excluded.accuracy_meters, altitude_meters = excluded.altitude_meters, speed_kph = excluded.speed_kph, bearing_degrees = excluded.bearing_degrees, battery_percent = excluded.battery_percent, network_type = excluded.network_type` ).run(device.id, new Date().toISOString(), value.capturedAt, value.latitude, value.longitude, value.accuracyMeters, value.altitudeMeters ?? null, value.speedKph ?? null, value.bearingDegrees ?? null, value.batteryPercent ?? null, value.networkType ?? null); return trips.record({ deviceId: device.id, capturedAt: value.capturedAt, latitude: value.latitude, longitude: value.longitude, accuracyMeters: value.accuracyMeters, altitudeMeters: value.altitudeMeters ?? null, speedKph: value.speedKph ?? null, bearingDegrees: value.bearingDegrees ?? null, phoneBatteryPercent: value.batteryPercent ?? null }); })(); return { accepted: true, trip }; }); app.post<{ Params: { id: string } }>("/internal/mobile/shortcuts/:id/launch", async (request, reply) => { const device = authenticateMobile(database, request, reply); if (!device) return; const id = Number(request.params.id); const shortcut = Number.isInteger(id) ? database.prepare( "SELECT target_type AS type, target_value AS value FROM adb_shortcuts WHERE id = ?" ).get(id) as { type: "component" | "package"; value: string } | undefined : undefined; if (!shortcut) return reply.code(404).send({ error: { code: "ADB_SHORTCUT_NOT_FOUND", message: "Shortcut not found" } }); try { await launchAdbTarget(adbConfig, shortcut); auditMobile(database, device, "mobile.adb-launch", "success", { shortcutId: id }); return { launched: true }; } catch { auditMobile(database, device, "mobile.adb-launch", "failure", { shortcutId: id }); return reply.code(503).send({ error: { code: "ADB_LAUNCH_FAILED", message: "The shortcut could not be opened" } }); } }); }