239 lines
12 KiB
TypeScript
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" } });
|
|
}
|
|
});
|
|
}
|