Skip to content
Merged
1 change: 1 addition & 0 deletions .github/workflows/branch-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ jobs:
- run: pnpm typecheck
- run: pnpm lint
- run: pnpm test
- run: pnpm audit:js
- run: pnpm build
- run: pnpm build:cloud

Expand Down
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ jobs:
- run: pnpm typecheck
- run: pnpm lint
- run: pnpm test
- run: pnpm audit:js
- run: pnpm build
- run: pnpm build:cloud

Expand Down
47 changes: 47 additions & 0 deletions apps/server/src/services/serialMavlinkService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { afterEach, describe, expect, it, vi } from "vitest";

const mockDestroy = vi.hoisted(() => vi.fn());

vi.mock("serialport", () => {
class MockSerialPort {
isOpen = true;
open = vi.fn((callback: (error?: Error | null) => void) => callback());
close = vi.fn((callback: (error?: Error | null) => void) => {
this.isOpen = false;
callback();
});
removeAllListeners = vi.fn();
on = vi.fn();
static list = vi.fn(async () => []);
}

return { SerialPort: MockSerialPort };
});

vi.mock("node-mavlink", () => ({
default: {
createMavLinkStream: vi.fn(() => ({
on: vi.fn(),
removeAllListeners: vi.fn(),
destroy: mockDestroy
}))
}
}));

import { SerialMavlinkService } from "./serialMavlinkService.js";

describe("SerialMavlinkService cleanup", () => {
afterEach(() => {
vi.clearAllMocks();
});

it("disconnect clears the open port and marks serial disconnected", async () => {
const service = new SerialMavlinkService();
await service.connect({ path: "COM3", baudRate: 115200 });
expect(service.getStatus().serialConnected).toBe(true);

const status = await service.disconnect();
expect(status.serialConnected).toBe(false);
expect(mockDestroy).toHaveBeenCalled();
});
});
33 changes: 25 additions & 8 deletions apps/server/src/services/serialMavlinkService.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { EventEmitter } from "node:events";
import type { Readable } from "node:stream";
import mavlink from "node-mavlink";
import type { MavLinkPacket } from "node-mavlink";
import { SerialPort } from "serialport";
Expand All @@ -11,6 +12,7 @@ const { createMavLinkStream } = mavlink as typeof import("node-mavlink");
export class SerialMavlinkService extends EventEmitter {
private readonly store = new TelemetryStore();
private port: SerialPort | null = null;
private mavlinkStream: Readable | null = null;
private serialConnected = false;
private rawBytes = 0;
private parserErrors = 0;
Expand Down Expand Up @@ -69,6 +71,7 @@ export class SerialMavlinkService extends EventEmitter {
console.warn(`Dropped MAVLink packet with invalid CRC (${packet.length} bytes).`);
}
});
this.mavlinkStream = mavlinkStream;

port.on("data", (chunk: Buffer) => {
this.rawBytes += chunk.length;
Expand All @@ -78,6 +81,7 @@ export class SerialMavlinkService extends EventEmitter {
this.parserErrors += 1;
this.lastSerialError = error instanceof Error ? error.message : String(error);
console.error("MAVLink parser error:", error);
void this.disconnect();
});

port.on("close", () => {
Expand All @@ -88,24 +92,37 @@ export class SerialMavlinkService extends EventEmitter {
port.on("error", (error) => {
this.lastSerialError = error.message;
console.error("Serial port error:", error);
void this.disconnect();
});

this.emitTelemetry();
return this.getStatus();
}

async disconnect(): Promise<BackendStatus> {
if (this.port?.isOpen) {
const port = this.port;
await new Promise<void>((resolve, reject) => {
port.close((error) => {
if (error) reject(error);
else resolve();
const port = this.port;
const stream = this.mavlinkStream;

this.port = null;
this.mavlinkStream = null;

if (stream) {
stream.removeAllListeners();
stream.destroy();
}

if (port) {
port.removeAllListeners();
if (port.isOpen) {
await new Promise<void>((resolve, reject) => {
port.close((error) => {
if (error) reject(error);
else resolve();
});
});
});
}
}

this.port = null;
this.serialConnected = false;
this.store.setConnected(false);
this.emitTelemetry();
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ export function App() {
alerts={alerts}
preflight={preflight}
targetEstimation={targetEstimation}
telemetryStale={telemetryStale}
/>
<ErrorBoundary>
<MapPanel
Expand Down
12 changes: 10 additions & 2 deletions apps/web/src/components/GroundTargetPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,19 @@ export function GroundTargetPanel({
<div className="mb-3 flex flex-wrap items-center gap-2">
<Badge tone={qualityTone}>{estimate?.quality ?? "idle"}</Badge>
{liveOnlyBlocked && <Badge tone="warn">live only</Badge>}
{estimate?.valid === false && estimate.reasons.length > 0 && (
<span className="font-mono text-[10px] uppercase tracking-wider text-slate-400">{estimate.reasons.join(", ")}</span>
{(estimate?.reasons.length ?? 0) > 0 && (
<span className="font-mono text-[10px] uppercase tracking-wider text-slate-400">
{estimate!.reasons.map(formatTargetReason).join(" · ")}
</span>
)}
</div>

{estimate?.quality === "warn" && (
<p className="mb-2 text-[10px] leading-snug text-amber-200/90">
Warn estimate — verify gimbal calibration, altitude datum, and terrain before acting on coordinates.
</p>
)}

<div className="grid grid-cols-2 gap-2">
<Metric label="Target Lat" value={formatNumber(estimate?.lat, 6)} tone={metricTone(estimate)} />
<Metric label="Target Lon" value={formatNumber(estimate?.lon, 6)} tone={metricTone(estimate)} />
Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/components/MapPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,11 +155,19 @@ export function MapPanel({ telemetry, coordinate, home, groundTarget, telemetryS
source?.setData(pointCollection(homeLngLat));
}, [homeLngLat, mapReady, styleEpoch]);

useEffect(() => {
const map = mapRef.current;
if (!map || !mapReady || !map.getLayer("drone-point")) return;
map.setPaintProperty("drone-point", "circle-opacity", telemetryStale ? 0.45 : 1);
map.setPaintProperty("drone-point", "circle-color", telemetryStale ? "#94a3b8" : "#22d3ee");
}, [telemetryStale, mapReady, styleEpoch]);

useEffect(() => {
const map = mapRef.current;
if (!map || !mapReady) return;

const showTarget =
!telemetryStale &&
groundTarget &&
(groundTarget.valid || groundTarget.quality === "warn") &&
groundTarget.lat !== null &&
Expand All @@ -183,7 +191,7 @@ export function MapPanel({ telemetry, coordinate, home, groundTarget, telemetryS

targetSource?.setData(pointCollection(targetLngLat));
losSource?.setData(lineFeature(droneLngLat, targetLngLat));
}, [groundTarget, droneLngLat, mapReady, styleEpoch]);
}, [groundTarget, droneLngLat, mapReady, styleEpoch, telemetryStale]);

return (
<main className="relative min-w-0 flex-1">
Expand Down
21 changes: 19 additions & 2 deletions apps/web/src/components/TelemetrySidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,17 @@ interface TelemetrySidebarProps {
alerts: AlertItem[];
preflight: PreflightHealth;
targetEstimation: TargetEstimationController;
telemetryStale?: boolean;
}

export function TelemetrySidebar({ telemetry, distanceFromHome, alerts, preflight, targetEstimation }: TelemetrySidebarProps) {
export function TelemetrySidebar({
telemetry,
distanceFromHome,
alerts,
preflight,
targetEstimation,
telemetryStale = false
}: TelemetrySidebarProps) {
const [view, setView] = useState<SidebarView>(() => readSidebarView());
const [cardOrder, setCardOrder] = useState<SidebarCardId[]>(() => loadSidebarOrder());
const batteryPercent = clampBatteryPercent(telemetry.battery.remainingPercent);
Expand All @@ -40,7 +48,16 @@ export function TelemetrySidebar({ telemetry, distanceFromHome, alerts, prefligh
}, [cardOrder]);

return (
<aside className="z-10 flex w-[320px] shrink-0 flex-col gap-3 overflow-y-auto border-r border-cyan-300/10 bg-slate-950/78 p-3 backdrop-blur">
<aside
className={`z-10 flex w-[320px] shrink-0 flex-col gap-3 overflow-y-auto border-r border-cyan-300/10 bg-slate-950/78 p-3 backdrop-blur ${
telemetryStale ? "opacity-70 saturate-75" : ""
}`}
>
{telemetryStale && (
<div className="rounded border border-amber-400/40 bg-amber-950/40 px-2 py-1 text-center text-[10px] font-semibold uppercase tracking-[0.14em] text-amber-200">
Link stale — data may be outdated
</div>
)}
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] uppercase tracking-[0.2em] text-slate-500">Telemetry</span>
<div className="flex items-center gap-1.5">
Expand Down
21 changes: 16 additions & 5 deletions apps/web/src/components/VideoPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { useEffect, useRef, useState, type CSSProperties, type PointerEvent } from "react";
import type { TargetEstimate } from "@uav-ground-control-station/shared";
import { formatNumber } from "../lib/format";
import { sanitizeHttpUrl } from "../lib/safeHttpUrl";
import { Badge } from "./Panel";

type VideoKind = "mjpeg" | "hls" | "webrtc";

const defaultUrl = import.meta.env.VITE_VIDEO_URL ?? "";
const defaultUrl = sanitizeHttpUrl(import.meta.env.VITE_VIDEO_URL ?? "");
const defaultKind = (import.meta.env.VITE_VIDEO_KIND as VideoKind | undefined) ?? "mjpeg";

interface VideoPanelProps {
Expand All @@ -14,7 +15,7 @@ interface VideoPanelProps {

export function VideoPanel({ estimate }: VideoPanelProps) {
const [collapsed, setCollapsed] = useState(false);
const [url, setUrl] = useState(() => localStorage.getItem("uav-gcs.video.url") ?? defaultUrl);
const [url, setUrl] = useState(() => sanitizeHttpUrl(localStorage.getItem("uav-gcs.video.url") ?? defaultUrl));
const [kind, setKind] = useState<VideoKind>(() => (localStorage.getItem("uav-gcs.video.kind") as VideoKind | null) ?? defaultKind);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const dragOffsetRef = useRef<{ x: number; y: number } | null>(null);
Expand Down Expand Up @@ -88,7 +89,12 @@ export function VideoPanel({ estimate }: VideoPanelProps) {
<option value="hls">HLS</option>
<option value="webrtc">WebRTC</option>
</select>
<input className="input-dark" value={url} onChange={(event) => setUrl(event.target.value)} placeholder="Video URL" />
<input
className="input-dark"
value={url}
onChange={(event) => setUrl(sanitizeHttpUrl(event.target.value))}
placeholder="Video URL (http/https)"
/>
</div>
</>
)}
Expand All @@ -112,12 +118,17 @@ function VideoTargetStrip({ estimate }: { estimate: TargetEstimate | null }) {
}

function VideoContent({ url, kind }: { url: string; kind: VideoKind }) {
const safeUrl = sanitizeHttpUrl(url);
if (!safeUrl) {
return <div className="flex h-full items-center justify-center text-sm text-slate-500">Invalid or unsupported video URL</div>;
}

if (kind === "mjpeg") {
return <img className="h-full w-full object-cover" src={url} alt="Camera feed" />;
return <img className="h-full w-full object-cover" src={safeUrl} alt="Camera feed" />;
}

if (kind === "hls") {
return <video className="h-full w-full object-cover" src={url} controls muted playsInline />;
return <video className="h-full w-full object-cover" src={safeUrl} controls muted playsInline />;
}

return (
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/lib/finiteNumber.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { finiteNumber, finiteOrNull } from "@uav-ground-control-station/shared";
import { describe, expect, it } from "vitest";

describe("finiteNumber helpers", () => {
it("maps non-finite values to null", () => {
expect(finiteOrNull(Number.NaN)).toBeNull();
expect(finiteOrNull(Number.POSITIVE_INFINITY)).toBeNull();
expect(finiteOrNull(null)).toBeNull();
expect(finiteOrNull(12.5)).toBe(12.5);
});

it("finiteNumber rejects non-finite numbers", () => {
expect(finiteNumber(Number.NaN)).toBeNull();
expect(finiteNumber(0)).toBe(0);
});
});
5 changes: 3 additions & 2 deletions apps/web/src/lib/mapBasemaps.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { StyleSpecification } from "maplibre-gl";
import { sanitizeHttpUrl, sanitizeTileTemplateUrl } from "./safeHttpUrl";

export type MapBasemapId = "tactical" | "satellite" | "topo";

Expand Down Expand Up @@ -37,7 +38,7 @@ export function isMapBasemapSwitcherEnabled(): boolean {
}

export function buildMapStyle(basemapId: MapBasemapId): StyleSpecification | string {
const customStyleUrl = import.meta.env.VITE_MAP_STYLE_URL;
const customStyleUrl = sanitizeHttpUrl(import.meta.env.VITE_MAP_STYLE_URL ?? "");
if (customStyleUrl) return customStyleUrl;

const { tiles, attribution } = basemapTileConfig(basemapId);
Expand Down Expand Up @@ -65,7 +66,7 @@ export function buildMapStyle(basemapId: MapBasemapId): StyleSpecification | str
export function basemapTileConfig(basemapId: MapBasemapId): { tiles: string[]; attribution: string } {
switch (basemapId) {
case "satellite": {
const customSatelliteUrl = import.meta.env.VITE_SATELLITE_TILE_URL;
const customSatelliteUrl = sanitizeTileTemplateUrl(import.meta.env.VITE_SATELLITE_TILE_URL ?? "");
if (customSatelliteUrl) {
return {
tiles: [customSatelliteUrl],
Expand Down
25 changes: 25 additions & 0 deletions apps/web/src/lib/safeHttpUrl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { sanitizeHttpUrl, sanitizeTileTemplateUrl } from "./safeHttpUrl";

describe("sanitizeHttpUrl", () => {
it("accepts http and https URLs", () => {
expect(sanitizeHttpUrl("http://127.0.0.1:8080/stream")).toBe("http://127.0.0.1:8080/stream");
expect(sanitizeHttpUrl("https://example.com/video.mjpg")).toBe("https://example.com/video.mjpg");
});

it("rejects dangerous or unsupported schemes", () => {
expect(sanitizeHttpUrl("javascript:alert(1)")).toBe("");
expect(sanitizeHttpUrl("data:text/html,hi")).toBe("");
expect(sanitizeHttpUrl("file:///etc/passwd")).toBe("");
});

it("rejects URLs with embedded credentials", () => {
expect(sanitizeHttpUrl("http://user:pass@localhost/stream")).toBe("");
});

it("preserves tile template placeholders", () => {
expect(sanitizeTileTemplateUrl("https://example.test/{z}/{x}/{y}.png")).toBe(
"https://example.test/{z}/{x}/{y}.png"
);
});
});
38 changes: 38 additions & 0 deletions apps/web/src/lib/safeHttpUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/** Allow only http(s) media/map URLs — reject javascript:, data:, and credential URLs. */
export function sanitizeHttpUrl(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return "";
}

try {
const url = new URL(trimmed);
if (url.protocol !== "http:" && url.protocol !== "https:") {
return "";
}
if (url.username || url.password) {
return "";
}
return url.href;
} catch {
return "";
}
}

/** Validate raster tile template URLs without encoding `{z}/{x}/{y}` placeholders. */
export function sanitizeTileTemplateUrl(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return "";
}
if (!/^https?:\/\//i.test(trimmed)) {
return "";
}
if (/^(javascript|data|file):/i.test(trimmed)) {
return "";
}
if (/^https?:\/\/[^/]+:[^/]+@/i.test(trimmed)) {
return "";
}
return trimmed;
}
Loading