Files
pi-car-companion/server/src/mobile.ts
T

239 lines
12 KiB
TypeScript

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<string, unknown> = {}) {
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" } });
}
});
}