Compare commits
4 Commits
v0.4.0
...
0e6e848a29
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e6e848a29 | |||
| 68082fd893 | |||
|
|
7fc4685cfc | ||
| e61e6bc7e3 |
@@ -2,25 +2,28 @@ when:
|
||||
- event: pull_request
|
||||
|
||||
steps:
|
||||
- name: lint
|
||||
- name: install
|
||||
image: node:24-slim
|
||||
depends_on: []
|
||||
commands:
|
||||
- npm ci
|
||||
|
||||
- name: lint
|
||||
image: node:24-slim
|
||||
depends_on: [install]
|
||||
commands:
|
||||
- npm run lint
|
||||
|
||||
- name: test
|
||||
image: node:24-slim
|
||||
depends_on: []
|
||||
depends_on: [install]
|
||||
commands:
|
||||
- npm ci
|
||||
- npm run test
|
||||
|
||||
- name: e2e
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-noble
|
||||
depends_on: []
|
||||
depends_on: [install]
|
||||
commands:
|
||||
- npm ci
|
||||
- ./scripts/gen-dev-cert.sh
|
||||
- npm run test:e2e
|
||||
environment:
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
## [1.0.0] - 2026-02-17
|
||||
### Changed
|
||||
- kestrel is now a tak server (#6)
|
||||
|
||||
## [0.4.0] - 2026-02-15
|
||||
### Changed
|
||||
- new nav system (#5)
|
||||
|
||||
@@ -16,11 +16,10 @@ USER node
|
||||
WORKDIR /app
|
||||
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=3000
|
||||
|
||||
# Copy app as node user (builder stage ran as root)
|
||||
COPY --from=builder --chown=node:node /app/.output ./.output
|
||||
|
||||
EXPOSE 3000
|
||||
EXPOSE 3000 8089
|
||||
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
|
||||
43
README.md
43
README.md
@@ -2,6 +2,8 @@
|
||||
|
||||
Tactical Operations Center (TOC) for OSINT feeds. Map view with offline-capable tiles and clickable camera/feed sources; click a marker to view the live stream.
|
||||
|
||||

|
||||
|
||||
## Stack
|
||||
|
||||
- Nuxt 4, JavaScript, Tailwind CSS, ESLint, Vitest
|
||||
@@ -34,7 +36,7 @@ Camera and geolocation in the browser require a **secure context** (HTTPS) when
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. On your phone, open **https://192.168.1.123:3000** (same IP you passed above). Accept the browser's “untrusted certificate” warning once (e.g. Advanced → Proceed). Then log in and use Share live; camera and location will work.
|
||||
3. On your phone, open **https://192.168.1.123:3000** (same IP you passed above). Accept the browser's "untrusted certificate" warning once (e.g. Advanced → Proceed). Then log in and use Share live; camera and location will work.
|
||||
|
||||
Without the certs, `npm run dev` still runs over HTTP as before.
|
||||
|
||||
@@ -48,31 +50,40 @@ The **Share live** feature uses WebRTC for real-time video streaming from mobile
|
||||
- **Mediasoup** server (runs automatically in the Nuxt process)
|
||||
- **mediasoup-client** (browser library, included automatically)
|
||||
|
||||
**Streaming from a phone on your LAN:** The server auto-detects your machine's LAN IP (from network interfaces) and uses it for WebRTC. Open **https://<your-LAN-IP>:3000** on both phone and laptop (same IP as for your dev cert). To override (e.g. Docker or multiple NICs), set `MEDIASOUP_ANNOUNCED_IP`. Ensure firewall allows UDP/TCP ports 40000–49999 on the server.
|
||||
**Streaming from a phone on your LAN:** The server auto-detects your machine's LAN IP (from network interfaces) and uses it for WebRTC. Open **https://<your-LAN-IP>:3000** on both phone and laptop (same IP as for your dev cert). To override (e.g. Docker or multiple NICs), set `MEDIASOUP_ANNOUNCED_IP`. Ensure firewall allows UDP/TCP ports 40000-49999 on the server.
|
||||
|
||||
See [docs/live-streaming.md](docs/live-streaming.md) for architecture details.
|
||||
See [docs/live-streaming.md](docs/live-streaming.md) for setup and usage.
|
||||
|
||||
### ATAK / CoT (Cursor on Target)
|
||||
|
||||
KestrelOS can act as a **TAK Server** so ATAK and iTAK devices connect and share positions. No plugins: in ATAK, add a **Server** connection (host = KestrelOS, port **8089** for CoT). Check **Use Authentication** and enter your **KestrelOS username** and **password** (local users use their login password; OIDC users must set an **ATAK password** once under **Account** in the web app). Devices relay CoT to each other (team members see each other on the ATAK map) and appear on the KestrelOS web map; they drop off after ~90 seconds if no updates. Optional: set `COT_TTL_MS`, `COT_REQUIRE_AUTH`; CoT runs on port 8089 (default).
|
||||
|
||||
## Scripts
|
||||
|
||||
- `npm run dev` – development server
|
||||
- `npm run build` – production build
|
||||
- `npm run test` – run tests
|
||||
- `npm run test:coverage` – run tests with coverage (85% threshold)
|
||||
- `npm run lint` – ESLint (zero warnings)
|
||||
- `npm run dev` - development server
|
||||
- `npm run build` - production build
|
||||
- `npm run test` - run tests
|
||||
- `npm run test:coverage` - run tests with coverage (85% threshold)
|
||||
- `npm run test:e2e` - Playwright E2E tests
|
||||
- `npm run lint` - ESLint (zero warnings)
|
||||
|
||||
## Documentation
|
||||
|
||||
Full docs are in the **[docs/](docs/README.md)** directory: [installation](docs/installation.md) (npm, Docker, Helm), [authentication](docs/auth.md) (local login, OIDC), [map and cameras](docs/map-and-cameras.md) (adding IPTV, ALPR, CCTV, NVR, etc.), [ATAK and iTAK](docs/atak-itak.md), and [Share live](docs/live-streaming.md) (mobile device as live camera).
|
||||
|
||||
## Configuration
|
||||
|
||||
- **Devices**: Manage cameras/devices via the API (`/api/devices`) or the Members/Cameras UI. Each device needs `name`, `device_type`, `lat`, `lng`, `stream_url`, and `source_type` (`mjpeg` or `hls`).
|
||||
- **Environment**: No required env vars for basic run. For production, set `HOST=0.0.0.0` and `PORT` as needed (e.g. in Docker/Helm).
|
||||
- **Authentication**: The login page always offers password sign-in (local). Optionally set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before the first run to create the first admin; otherwise a default admin is created and its credentials are printed in the terminal. To also show an OIDC sign-in button, configure `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, and optionally `OIDC_LABEL`, `OIDC_REDIRECT_URI`. See [docs/auth.md](docs/auth.md) for provider-specific examples.
|
||||
- **Bootstrap admin** (when using local auth): The server initializes the database and runs bootstrap at startup. On first run (no users in the database), it creates the first admin. If you set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before starting, that account is created. If you don't set them, a default admin is created (identifier: `admin`) with a random password and the credentials are printed in the terminal—copy them and sign in at `/login`, then change the password or add users via Members. Use **Members** to change roles (admin, leader, member). Only admins can change roles; admins and leaders can edit POIs.
|
||||
- **Devices**: Manage cameras/devices via the API (`/api/devices`); see [Map and cameras](docs/map-and-cameras.md). Each device needs `name`, `device_type`, `lat`, `lng`, `stream_url`, and `source_type` (`mjpeg` or `hls`).
|
||||
- **Environment**: No required env vars for basic run. For production, set `HOST=0.0.0.0` and expose ports 3000 (web/API) and 8089 (CoT). Set `COT_TTL_MS=90000`, `COT_REQUIRE_AUTH=true`. For TLS use `.dev-certs/` or set `COT_SSL_CERT` and `COT_SSL_KEY`.
|
||||
- **Authentication**: The login page always offers password sign-in (local). Optionally set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before the first run to create the first admin; otherwise a default admin is created and its credentials are printed in the terminal. To also show an OIDC sign-in button, configure `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, and optionally `OIDC_LABEL`, `OIDC_REDIRECT_URI`. See [docs/auth.md](docs/auth.md) for local login, OIDC config, and sign up.
|
||||
- **Bootstrap admin** (when using local auth): The server initializes the database and runs bootstrap at startup. On first run (no users in the database), it creates the first admin. If you set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before starting, that account is created. If you don't set them, a default admin is created (identifier: `admin`) with a random password and the credentials are printed in the terminal-copy them and sign in at `/login`, then change the password or add users via Members. Use **Members** to change roles (admin, leader, member). Only admins can change roles; admins and leaders can edit POIs.
|
||||
- **Database**: SQLite file at `data/kestrelos.db` (created automatically). Contains users, sessions, and POIs.
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker build -t kestrelos:latest .
|
||||
docker run -p 3000:3000 kestrelos:latest
|
||||
docker run -p 3000:3000 -p 8089:8089 kestrelos:latest
|
||||
```
|
||||
|
||||
## Kubernetes (Helm)
|
||||
@@ -95,9 +106,9 @@ Health: `GET /health` (overview), `GET /health/live` (liveness), `GET /health/re
|
||||
|
||||
Merges to `main` trigger a semver release. Use one of these prefixes in your PR title to set the version bump:
|
||||
|
||||
- `major:` – breaking changes
|
||||
- `minor:` – new features
|
||||
- `patch:` – bug fixes, docs (default if no prefix)
|
||||
- `major:` - breaking changes
|
||||
- `minor:` - new features
|
||||
- `patch:` - bug fixes, docs (default if no prefix)
|
||||
|
||||
Example: `minor: Add map layer toggle`
|
||||
|
||||
|
||||
@@ -66,6 +66,10 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
cotEntities: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
canEditPois: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -81,6 +85,7 @@ const mapContext = ref(null)
|
||||
const markersRef = ref([])
|
||||
const poiMarkersRef = ref({})
|
||||
const liveMarkersRef = ref({})
|
||||
const cotMarkersRef = ref({})
|
||||
const contextMenu = ref({ ...CONTEXT_MENU_EMPTY })
|
||||
|
||||
const showPoiModal = ref(false)
|
||||
@@ -89,6 +94,7 @@ const addPoiLatlng = ref(null)
|
||||
const editPoi = ref(null)
|
||||
const deletePoi = ref(null)
|
||||
const poiForm = ref({ label: '', iconType: 'pin' })
|
||||
const resizeObserver = ref(null)
|
||||
|
||||
const TILE_URL = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png'
|
||||
const TILE_SUBDOMAINS = 'abcd'
|
||||
@@ -124,7 +130,7 @@ function getPoiIcon(L, poi) {
|
||||
})
|
||||
}
|
||||
|
||||
const LIVE_ICON_COLOR = '#22c9c9' /* kestrel-accent – JS string for Leaflet SVG */
|
||||
const LIVE_ICON_COLOR = '#22c9c9' /* kestrel-accent - JS string for Leaflet SVG */
|
||||
function getLiveSessionIcon(L) {
|
||||
const html = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${LIVE_ICON_COLOR}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="2" fill="${LIVE_ICON_COLOR}"/></svg>`
|
||||
return L.divIcon({
|
||||
@@ -135,6 +141,17 @@ function getLiveSessionIcon(L) {
|
||||
})
|
||||
}
|
||||
|
||||
const COT_ICON_COLOR = '#f59e0b' /* amber - ATAK/CoT devices */
|
||||
function getCotEntityIcon(L) {
|
||||
const html = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${COT_ICON_COLOR}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="8" r="2.5" fill="${COT_ICON_COLOR}"/></svg>`
|
||||
return L.divIcon({
|
||||
className: 'poi-div-icon cot-entity-icon',
|
||||
html: `<span class="poi-icon-svg">${html}</span>`,
|
||||
iconSize: [ICON_SIZE, ICON_SIZE],
|
||||
iconAnchor: [ICON_SIZE / 2, ICON_SIZE],
|
||||
})
|
||||
}
|
||||
|
||||
function createMap(initialCenter) {
|
||||
const { L, offlineApi } = leafletRef.value || {}
|
||||
if (typeof document === 'undefined' || !mapRef.value || !L?.map) return
|
||||
@@ -201,6 +218,7 @@ function createMap(initialCenter) {
|
||||
updateMarkers()
|
||||
updatePoiMarkers()
|
||||
updateLiveMarkers()
|
||||
updateCotMarkers()
|
||||
nextTick(() => map.invalidateSize())
|
||||
}
|
||||
|
||||
@@ -291,6 +309,39 @@ function updateLiveMarkers() {
|
||||
liveMarkersRef.value = next
|
||||
}
|
||||
|
||||
function updateCotMarkers() {
|
||||
const ctx = mapContext.value
|
||||
const { L } = leafletRef.value || {}
|
||||
if (!ctx?.map || !L) return
|
||||
|
||||
const entities = (props.cotEntities || []).filter(
|
||||
e => typeof e?.lat === 'number' && typeof e?.lng === 'number' && e?.id,
|
||||
)
|
||||
const byId = Object.fromEntries(entities.map(e => [e.id, e]))
|
||||
const prev = cotMarkersRef.value
|
||||
const icon = getCotEntityIcon(L)
|
||||
|
||||
Object.keys(prev).forEach((id) => {
|
||||
if (!byId[id]) prev[id]?.remove()
|
||||
})
|
||||
|
||||
const next = entities.reduce((acc, entity) => {
|
||||
const content = `<div class="kestrel-live-popup"><strong>${escapeHtml(entity.label || entity.id)}</strong> <span class="text-kestrel-muted">ATAK</span></div>`
|
||||
const existing = prev[entity.id]
|
||||
if (existing) {
|
||||
existing.setLatLng([entity.lat, entity.lng])
|
||||
existing.setIcon(icon)
|
||||
existing.getPopup()?.setContent(content)
|
||||
return { ...acc, [entity.id]: existing }
|
||||
}
|
||||
const marker = L.marker([entity.lat, entity.lng], { icon })
|
||||
.addTo(ctx.map)
|
||||
.bindPopup(content, { className: 'kestrel-live-popup-wrap', maxWidth: 360 })
|
||||
return { ...acc, [entity.id]: marker }
|
||||
}, {})
|
||||
cotMarkersRef.value = next
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div')
|
||||
div.textContent = text
|
||||
@@ -376,6 +427,8 @@ function destroyMap() {
|
||||
poiMarkersRef.value = {}
|
||||
Object.values(liveMarkersRef.value).forEach(m => m?.remove())
|
||||
liveMarkersRef.value = {}
|
||||
Object.values(cotMarkersRef.value).forEach(m => m?.remove())
|
||||
cotMarkersRef.value = {}
|
||||
|
||||
const ctx = mapContext.value
|
||||
if (ctx) {
|
||||
@@ -404,8 +457,6 @@ function initMapWithLocation() {
|
||||
)
|
||||
}
|
||||
|
||||
let resizeObserver = null
|
||||
|
||||
onMounted(async () => {
|
||||
if (!import.meta.client || typeof document === 'undefined') return
|
||||
const [leaflet, offline] = await Promise.all([
|
||||
@@ -428,10 +479,10 @@ onMounted(async () => {
|
||||
|
||||
nextTick(() => {
|
||||
if (mapRef.value) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
resizeObserver.value = new ResizeObserver(() => {
|
||||
mapContext.value?.map?.invalidateSize()
|
||||
})
|
||||
resizeObserver.observe(mapRef.value)
|
||||
resizeObserver.value.observe(mapRef.value)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -442,9 +493,9 @@ function onDocumentClick(e) {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
if (resizeObserver && mapRef.value) {
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver = null
|
||||
if (resizeObserver.value && mapRef.value) {
|
||||
resizeObserver.value.disconnect()
|
||||
resizeObserver.value = null
|
||||
}
|
||||
destroyMap()
|
||||
})
|
||||
@@ -452,4 +503,5 @@ onBeforeUnmount(() => {
|
||||
watch(() => props.devices, () => updateMarkers(), { deep: true })
|
||||
watch([() => props.pois, () => props.canEditPois], () => updatePoiMarkers(), { deep: true })
|
||||
watch(() => props.liveSessions, () => updateLiveMarkers(), { deep: true })
|
||||
watch(() => props.cotEntities, () => updateCotMarkers(), { deep: true })
|
||||
</script>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
Wrong host: server sees <strong>{{ failureReason.wrongHost.serverHostname }}</strong> but you opened this page at <strong>{{ failureReason.wrongHost.clientHostname }}</strong>. Use the same URL or set MEDIASOUP_ANNOUNCED_IP.
|
||||
</p>
|
||||
<ul class="normal-case list-inside list-disc text-left text-kestrel-muted">
|
||||
<li><strong>Firewall:</strong> Open UDP/TCP 40000–49999 on the server.</li>
|
||||
<li><strong>Firewall:</strong> Open UDP/TCP 40000-49999 on the server.</li>
|
||||
<li><strong>Wrong host:</strong> Server must see the same address you use.</li>
|
||||
<li><strong>Restrictive NAT / cellular:</strong> TURN may be required.</li>
|
||||
</ul>
|
||||
@@ -66,7 +66,7 @@
|
||||
Wrong host: server sees <strong>{{ failureReason.wrongHost.serverHostname }}</strong> but you opened at <strong>{{ failureReason.wrongHost.clientHostname }}</strong>.
|
||||
</p>
|
||||
<ul class="normal-case list-inside list-disc text-left text-kestrel-muted">
|
||||
<li>Firewall: open ports 40000–49999.</li>
|
||||
<li>Firewall: open ports 40000-49999.</li>
|
||||
<li>Wrong host: use same URL or set MEDIASOUP_ANNOUNCED_IP.</li>
|
||||
<li>Restrictive NAT: TURN may be required.</li>
|
||||
</ul>
|
||||
@@ -104,9 +104,9 @@ const hasStream = ref(false)
|
||||
const error = ref('')
|
||||
const connectionState = ref('') // '', 'connecting', 'connected', 'failed'
|
||||
const failureReason = ref(null) // { wrongHost: { serverHostname, clientHostname } | null }
|
||||
let device = null
|
||||
let recvTransport = null
|
||||
let consumer = null
|
||||
const device = ref(null)
|
||||
const recvTransport = ref(null)
|
||||
const consumer = ref(null)
|
||||
|
||||
async function runFailureReasonCheck() {
|
||||
failureReason.value = await getWebRTCFailureReason()
|
||||
@@ -135,16 +135,16 @@ async function setupWebRTC() {
|
||||
const rtpCapabilities = await $fetch(`/api/live/webrtc/router-rtp-capabilities?sessionId=${props.session.id}`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
device = await createMediasoupDevice(rtpCapabilities)
|
||||
recvTransport = await createRecvTransport(device, props.session.id)
|
||||
device.value = await createMediasoupDevice(rtpCapabilities)
|
||||
recvTransport.value = await createRecvTransport(device.value, props.session.id)
|
||||
|
||||
recvTransport.on('connectionstatechange', () => {
|
||||
const state = recvTransport.connectionState
|
||||
recvTransport.value.on('connectionstatechange', () => {
|
||||
const state = recvTransport.value.connectionState
|
||||
if (state === 'connected') connectionState.value = 'connected'
|
||||
else if (state === 'failed' || state === 'disconnected' || state === 'closed') {
|
||||
logWarn('LiveSessionPanel: Receive transport connection state changed', {
|
||||
state,
|
||||
transportId: recvTransport.id,
|
||||
transportId: recvTransport.value.id,
|
||||
sessionId: props.session.id,
|
||||
})
|
||||
if (state === 'failed') {
|
||||
@@ -154,8 +154,8 @@ async function setupWebRTC() {
|
||||
}
|
||||
})
|
||||
|
||||
const connectionPromise = waitForConnectionState(recvTransport, 10000)
|
||||
consumer = await consumeProducer(recvTransport, device, props.session.id)
|
||||
const connectionPromise = waitForConnectionState(recvTransport.value, 10000)
|
||||
consumer.value = await consumeProducer(recvTransport.value, device.value, props.session.id)
|
||||
const finalConnectionState = await connectionPromise
|
||||
|
||||
if (finalConnectionState !== 'connected') {
|
||||
@@ -163,8 +163,8 @@ async function setupWebRTC() {
|
||||
runFailureReasonCheck()
|
||||
logWarn('LiveSessionPanel: Transport not fully connected', {
|
||||
state: finalConnectionState,
|
||||
transportId: recvTransport.id,
|
||||
consumerId: consumer.id,
|
||||
transportId: recvTransport.value.id,
|
||||
consumerId: consumer.value.id,
|
||||
})
|
||||
}
|
||||
else {
|
||||
@@ -182,14 +182,14 @@ async function setupWebRTC() {
|
||||
attempts++
|
||||
}
|
||||
|
||||
if (!consumer.track) {
|
||||
if (!consumer.value.track) {
|
||||
logError('LiveSessionPanel: No video track available', {
|
||||
consumerId: consumer.id,
|
||||
consumerKind: consumer.kind,
|
||||
consumerPaused: consumer.paused,
|
||||
consumerClosed: consumer.closed,
|
||||
consumerProducerId: consumer.producerId,
|
||||
transportConnectionState: recvTransport?.connectionState,
|
||||
consumerId: consumer.value.id,
|
||||
consumerKind: consumer.value.kind,
|
||||
consumerPaused: consumer.value.paused,
|
||||
consumerClosed: consumer.value.closed,
|
||||
consumerProducerId: consumer.value.producerId,
|
||||
transportConnectionState: recvTransport.value?.connectionState,
|
||||
})
|
||||
error.value = 'No video track available - consumer may not be receiving data from producer'
|
||||
return
|
||||
@@ -197,14 +197,14 @@ async function setupWebRTC() {
|
||||
|
||||
if (!videoRef.value) {
|
||||
logError('LiveSessionPanel: Video ref not available', {
|
||||
consumerId: consumer.id,
|
||||
hasTrack: !!consumer.track,
|
||||
consumerId: consumer.value.id,
|
||||
hasTrack: !!consumer.value.track,
|
||||
})
|
||||
error.value = 'Video element not available'
|
||||
return
|
||||
}
|
||||
|
||||
const stream = new MediaStream([consumer.track])
|
||||
const stream = new MediaStream([consumer.value.track])
|
||||
videoRef.value.srcObject = stream
|
||||
hasStream.value = true
|
||||
|
||||
@@ -227,7 +227,7 @@ async function setupWebRTC() {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
videoRef.value.removeEventListener('loadedmetadata', handler)
|
||||
logWarn('LiveSessionPanel: Video metadata timeout', { consumerId: consumer.id })
|
||||
logWarn('LiveSessionPanel: Video metadata timeout', { consumerId: consumer.value.id })
|
||||
resolve()
|
||||
}, 5000)
|
||||
})
|
||||
@@ -239,7 +239,7 @@ async function setupWebRTC() {
|
||||
}
|
||||
catch (playErr) {
|
||||
logWarn('LiveSessionPanel: Video play() failed (may need user interaction)', {
|
||||
consumerId: consumer.id,
|
||||
consumerId: consumer.value.id,
|
||||
error: playErr.message || String(playErr),
|
||||
errorName: playErr.name,
|
||||
videoPaused: videoRef.value.paused,
|
||||
@@ -248,12 +248,12 @@ async function setupWebRTC() {
|
||||
// Don't set error - video might still work, just needs user interaction
|
||||
}
|
||||
|
||||
consumer.track.addEventListener('ended', () => {
|
||||
consumer.value.track.addEventListener('ended', () => {
|
||||
error.value = 'Video track ended'
|
||||
hasStream.value = false
|
||||
})
|
||||
videoRef.value.addEventListener('error', () => {
|
||||
logError('LiveSessionPanel: Video element error', { consumerId: consumer.id })
|
||||
logError('LiveSessionPanel: Video element error', { consumerId: consumer.value.id })
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
@@ -274,15 +274,15 @@ async function setupWebRTC() {
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (consumer) {
|
||||
consumer.close()
|
||||
consumer = null
|
||||
if (consumer.value) {
|
||||
consumer.value.close()
|
||||
consumer.value = null
|
||||
}
|
||||
if (recvTransport) {
|
||||
recvTransport.close()
|
||||
recvTransport = null
|
||||
if (recvTransport.value) {
|
||||
recvTransport.value.close()
|
||||
recvTransport.value = null
|
||||
}
|
||||
device = null
|
||||
device.value = null
|
||||
if (videoRef.value) {
|
||||
videoRef.value.srcObject = null
|
||||
}
|
||||
@@ -308,7 +308,7 @@ watch(
|
||||
watch(
|
||||
() => props.session?.hasStream,
|
||||
(hasStream) => {
|
||||
if (hasStream && props.session?.id && !device) {
|
||||
if (hasStream && props.session?.id && !device.value) {
|
||||
setupWebRTC()
|
||||
}
|
||||
else if (!hasStream) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Fetches devices + live sessions; polls when tab visible. */
|
||||
const POLL_MS = 1500
|
||||
const EMPTY_RESPONSE = Object.freeze({ devices: [], liveSessions: [] })
|
||||
const EMPTY_RESPONSE = Object.freeze({ devices: [], liveSessions: [], cotEntities: [] })
|
||||
|
||||
export function useCameras(options = {}) {
|
||||
const { poll: enablePoll = true } = options
|
||||
@@ -12,6 +12,7 @@ export function useCameras(options = {}) {
|
||||
|
||||
const devices = computed(() => Object.freeze([...(data.value?.devices ?? [])]))
|
||||
const liveSessions = computed(() => Object.freeze([...(data.value?.liveSessions ?? [])]))
|
||||
const cotEntities = computed(() => Object.freeze([...(data.value?.cotEntities ?? [])]))
|
||||
const cameras = computed(() => Object.freeze([...devices.value, ...liveSessions.value]))
|
||||
|
||||
const pollInterval = ref(null)
|
||||
@@ -36,5 +37,5 @@ export function useCameras(options = {}) {
|
||||
})
|
||||
onBeforeUnmount(stopPolling)
|
||||
|
||||
return Object.freeze({ data, devices, liveSessions, cameras, refresh, startPolling, stopPolling })
|
||||
return Object.freeze({ data, devices, liveSessions, cotEntities, cameras, refresh, startPolling, stopPolling })
|
||||
}
|
||||
|
||||
@@ -5,17 +5,17 @@
|
||||
*/
|
||||
export function useMediaQuery(query) {
|
||||
const matches = ref(true)
|
||||
let mql = null
|
||||
const mql = ref(null)
|
||||
const handler = (e) => {
|
||||
matches.value = e.matches
|
||||
}
|
||||
onMounted(() => {
|
||||
mql = window.matchMedia(query)
|
||||
matches.value = mql.matches
|
||||
mql.addEventListener('change', handler)
|
||||
mql.value = window.matchMedia(query)
|
||||
matches.value = mql.value.matches
|
||||
mql.value.addEventListener('change', handler)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (mql) mql.removeEventListener('change', handler)
|
||||
if (mql.value) mql.value.removeEventListener('change', handler)
|
||||
})
|
||||
return matches
|
||||
}
|
||||
|
||||
@@ -186,18 +186,18 @@ function waitForCondition(condition, timeoutMs = 3000, intervalMs = 100) {
|
||||
export function waitForConnectionState(transport, timeoutMs = 10000) {
|
||||
const terminal = ['connected', 'failed', 'disconnected', 'closed']
|
||||
return new Promise((resolve) => {
|
||||
let tid
|
||||
const tid = ref(null)
|
||||
const handler = () => {
|
||||
const state = transport.connectionState
|
||||
if (terminal.includes(state)) {
|
||||
transport.off('connectionstatechange', handler)
|
||||
if (tid) clearTimeout(tid)
|
||||
if (tid.value) clearTimeout(tid.value)
|
||||
resolve(state)
|
||||
}
|
||||
}
|
||||
transport.on('connectionstatechange', handler)
|
||||
handler()
|
||||
tid = setTimeout(() => {
|
||||
tid.value = setTimeout(() => {
|
||||
transport.off('connectionstatechange', handler)
|
||||
resolve(transport.connectionState)
|
||||
}, timeoutMs)
|
||||
|
||||
@@ -94,6 +94,71 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="user"
|
||||
class="mb-8"
|
||||
>
|
||||
<h3 class="kestrel-section-label">
|
||||
ATAK / device password
|
||||
</h3>
|
||||
<div class="kestrel-card p-4">
|
||||
<p class="mb-3 text-sm text-kestrel-muted">
|
||||
{{ user.auth_provider === 'oidc' ? 'Set a password to use when connecting from ATAK (check "Use Authentication" and enter your KestrelOS username and this password).' : 'Optionally set a separate password for ATAK; otherwise use your login password.' }}
|
||||
</p>
|
||||
<p
|
||||
v-if="cotPasswordSuccess"
|
||||
class="mb-3 text-sm text-green-400"
|
||||
>
|
||||
ATAK password saved.
|
||||
</p>
|
||||
<p
|
||||
v-if="cotPasswordError"
|
||||
class="mb-3 text-sm text-red-400"
|
||||
>
|
||||
{{ cotPasswordError }}
|
||||
</p>
|
||||
<form
|
||||
class="space-y-3"
|
||||
@submit.prevent="onSetCotPassword"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
for="account-cot-password"
|
||||
class="kestrel-label"
|
||||
>ATAK password</label>
|
||||
<input
|
||||
id="account-cot-password"
|
||||
v-model="cotPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="kestrel-input"
|
||||
:placeholder="user.auth_provider === 'oidc' ? 'Set password for ATAK' : 'Optional'"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="account-cot-password-confirm"
|
||||
class="kestrel-label"
|
||||
>Confirm ATAK password</label>
|
||||
<input
|
||||
id="account-cot-password-confirm"
|
||||
v-model="cotPasswordConfirm"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="kestrel-input"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-kestrel-accent px-4 py-2 text-sm font-medium text-kestrel-bg transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
:disabled="cotPasswordLoading"
|
||||
>
|
||||
{{ cotPasswordLoading ? 'Saving…' : 'Save ATAK password' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="user?.auth_provider === 'local'"
|
||||
class="mb-8"
|
||||
@@ -181,6 +246,11 @@ const confirmPassword = ref('')
|
||||
const passwordLoading = ref(false)
|
||||
const passwordSuccess = ref(false)
|
||||
const passwordError = ref('')
|
||||
const cotPassword = ref('')
|
||||
const cotPasswordConfirm = ref('')
|
||||
const cotPasswordLoading = ref(false)
|
||||
const cotPasswordSuccess = ref(false)
|
||||
const cotPasswordError = ref('')
|
||||
|
||||
const accountInitials = computed(() => {
|
||||
const id = user.value?.identifier ?? ''
|
||||
@@ -254,4 +324,34 @@ async function onChangePassword() {
|
||||
passwordLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSetCotPassword() {
|
||||
cotPasswordError.value = ''
|
||||
cotPasswordSuccess.value = false
|
||||
if (cotPassword.value !== cotPasswordConfirm.value) {
|
||||
cotPasswordError.value = 'Password and confirmation do not match.'
|
||||
return
|
||||
}
|
||||
if (cotPassword.value.length < 1) {
|
||||
cotPasswordError.value = 'Password cannot be empty.'
|
||||
return
|
||||
}
|
||||
cotPasswordLoading.value = true
|
||||
try {
|
||||
await $fetch('/api/me/cot-password', {
|
||||
method: 'PUT',
|
||||
body: { password: cotPassword.value },
|
||||
credentials: 'include',
|
||||
})
|
||||
cotPassword.value = ''
|
||||
cotPasswordConfirm.value = ''
|
||||
cotPasswordSuccess.value = true
|
||||
}
|
||||
catch (e) {
|
||||
cotPasswordError.value = e.data?.message ?? e.message ?? 'Failed to save ATAK password.'
|
||||
}
|
||||
finally {
|
||||
cotPasswordLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
:devices="devices ?? []"
|
||||
:pois="pois ?? []"
|
||||
:live-sessions="liveSessions ?? []"
|
||||
:cot-entities="cotEntities ?? []"
|
||||
:can-edit-pois="canEditPois"
|
||||
@select="selectedCamera = $event"
|
||||
@select-live="onSelectLive($event)"
|
||||
@@ -22,7 +23,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const { devices, liveSessions } = useCameras()
|
||||
const { devices, liveSessions, cotEntities } = useCameras()
|
||||
const { data: pois, refresh: refreshPois } = usePois()
|
||||
const { canEditPois } = useUser()
|
||||
const selectedCamera = ref(null)
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
class="border-b border-kestrel-border"
|
||||
>
|
||||
<td class="px-4 py-2 text-kestrel-text">
|
||||
{{ p.label || '—' }}
|
||||
{{ p.label || '-' }}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-kestrel-muted">
|
||||
{{ p.lat }}
|
||||
|
||||
@@ -36,6 +36,67 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h3 class="kestrel-section-label">
|
||||
TAK Server (ATAK / iTAK)
|
||||
</h3>
|
||||
<div class="kestrel-card p-4">
|
||||
<p class="mb-3 text-sm text-kestrel-text">
|
||||
Scan this QR code with iTAK (or ATAK) to add this KestrelOS server. You'll be prompted for your KestrelOS username and password after scanning.
|
||||
</p>
|
||||
<div
|
||||
v-if="takQrDataUrl"
|
||||
class="inline-block rounded-lg border border-kestrel-border bg-white p-3"
|
||||
>
|
||||
<img
|
||||
:src="takQrDataUrl"
|
||||
alt="TAK Server QR code"
|
||||
class="h-48 w-48"
|
||||
width="192"
|
||||
height="192"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-else-if="takQrError"
|
||||
class="text-sm text-red-400"
|
||||
>
|
||||
{{ takQrError }}
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
class="text-sm text-kestrel-muted"
|
||||
>
|
||||
Loading QR code…
|
||||
</p>
|
||||
<p
|
||||
v-if="takServerString"
|
||||
class="mt-3 text-xs text-kestrel-muted break-all"
|
||||
>
|
||||
{{ takServerString }}
|
||||
</p>
|
||||
<template v-if="cotConfig?.ssl">
|
||||
<p class="mt-3 text-sm text-kestrel-text">
|
||||
This server uses a self-signed certificate. iTAK will not connect until it trusts the cert.
|
||||
</p>
|
||||
<ol class="mt-2 list-decimal list-inside space-y-1 text-sm text-kestrel-text">
|
||||
<li>
|
||||
<strong>Upload server package:</strong> Download below, then in iTAK tap Add Server (+) → Upload server package and select the zip; enter KestrelOS username and password when prompted.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Plain TCP:</strong> Remove or rename <code class="bg-kestrel-surface px-1 rounded">.dev-certs</code>, restart, then in iTAK add the server with SSL disabled.
|
||||
</li>
|
||||
</ol>
|
||||
<a
|
||||
href="/api/cot/server-package"
|
||||
download="kestrelos-itak-server-package.zip"
|
||||
class="kestrel-btn-secondary mt-3 inline-block"
|
||||
>
|
||||
Download server package (zip)
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="kestrel-section-label">
|
||||
About
|
||||
@@ -67,6 +128,11 @@ const tilesMessage = ref('')
|
||||
const tilesMessageSuccess = ref(false)
|
||||
const tilesLoading = ref(false)
|
||||
|
||||
const cotConfig = ref(null)
|
||||
const takQrDataUrl = ref('')
|
||||
const takQrError = ref('')
|
||||
const takServerString = ref('')
|
||||
|
||||
async function loadTilesStored() {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
@@ -106,7 +172,26 @@ async function onClearTiles() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTakQr() {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
const res = await $fetch('/api/cot/config')
|
||||
cotConfig.value = res
|
||||
const hostname = window.location.hostname
|
||||
const port = res?.port ?? 8089
|
||||
const protocol = res?.ssl ? 'ssl' : 'tcp'
|
||||
const str = `KestrelOS,${hostname},${port},${protocol}`
|
||||
takServerString.value = str
|
||||
const QRCode = (await import('qrcode')).default
|
||||
takQrDataUrl.value = await QRCode.toDataURL(str, { width: 192, margin: 1 })
|
||||
}
|
||||
catch (e) {
|
||||
takQrError.value = e?.data?.error ?? e?.message ?? 'Could not load TAK server config.'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTilesStored()
|
||||
loadTakQr()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
Wrong host: server sees <strong>{{ webrtcFailureReason.wrongHost.serverHostname }}</strong> but you opened this page at <strong>{{ webrtcFailureReason.wrongHost.clientHostname }}</strong>. Use the same URL on phone and server, or set MEDIASOUP_ANNOUNCED_IP.
|
||||
</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-0.5 text-kestrel-muted">
|
||||
<li><strong>Firewall:</strong> Open UDP/TCP ports 40000–49999 on the server.</li>
|
||||
<li><strong>Firewall:</strong> Open UDP/TCP ports 40000-49999 on the server.</li>
|
||||
<li><strong>Wrong host:</strong> Server must see the same address you use (see above or open /api/live/debug-request-host).</li>
|
||||
<li><strong>Restrictive NAT / cellular:</strong> A TURN server may be required (future enhancement).</li>
|
||||
</ul>
|
||||
@@ -68,7 +68,7 @@
|
||||
v-if="sharing"
|
||||
class="absolute bottom-2 left-2 rounded bg-black/70 px-2 py-1 text-xs text-green-400"
|
||||
>
|
||||
● Live — you appear on the map
|
||||
● Live - you appear on the map
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -122,11 +122,11 @@ const starting = ref(false)
|
||||
const isSecureContext = typeof window !== 'undefined' && window.isSecureContext
|
||||
const webrtcState = ref('') // '', 'connecting', 'connected', 'failed'
|
||||
const webrtcFailureReason = ref(null) // { wrongHost: { serverHostname, clientHostname } | null }
|
||||
let locationWatchId = null
|
||||
let locationIntervalId = null
|
||||
let device = null
|
||||
let sendTransport = null
|
||||
let producer = null
|
||||
const locationWatchId = ref(null)
|
||||
const locationIntervalId = ref(null)
|
||||
const device = ref(null)
|
||||
const sendTransport = ref(null)
|
||||
const producer = ref(null)
|
||||
|
||||
async function runFailureReasonCheck() {
|
||||
webrtcFailureReason.value = await getWebRTCFailureReason()
|
||||
@@ -194,8 +194,8 @@ async function startSharing() {
|
||||
const rtpCapabilities = await $fetch(`/api/live/webrtc/router-rtp-capabilities?sessionId=${sessionId.value}`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
device = await createMediasoupDevice(rtpCapabilities)
|
||||
sendTransport = await createSendTransport(device, sessionId.value, {
|
||||
device.value = await createMediasoupDevice(rtpCapabilities)
|
||||
sendTransport.value = await createSendTransport(device.value, sessionId.value, {
|
||||
onConnectSuccess: () => { webrtcState.value = 'connected' },
|
||||
onConnectFailure: () => {
|
||||
webrtcState.value = 'failed'
|
||||
@@ -208,31 +208,31 @@ async function startSharing() {
|
||||
if (!videoTrack) {
|
||||
throw new Error('No video track available')
|
||||
}
|
||||
producer = await sendTransport.produce({ track: videoTrack })
|
||||
producer.value = await sendTransport.value.produce({ track: videoTrack })
|
||||
// Monitor producer events
|
||||
producer.on('transportclose', () => {
|
||||
producer.value.on('transportclose', () => {
|
||||
logWarn('share-live: Producer transport closed', {
|
||||
producerId: producer.id,
|
||||
producerPaused: producer.paused,
|
||||
producerClosed: producer.closed,
|
||||
producerId: producer.value.id,
|
||||
producerPaused: producer.value.paused,
|
||||
producerClosed: producer.value.closed,
|
||||
})
|
||||
})
|
||||
producer.on('trackended', () => {
|
||||
producer.value.on('trackended', () => {
|
||||
logWarn('share-live: Producer track ended', {
|
||||
producerId: producer.id,
|
||||
producerPaused: producer.paused,
|
||||
producerClosed: producer.closed,
|
||||
producerId: producer.value.id,
|
||||
producerPaused: producer.value.paused,
|
||||
producerClosed: producer.value.closed,
|
||||
})
|
||||
})
|
||||
// Monitor transport state (mediasoup-client does not pass a parameter; read from transport.connectionState)
|
||||
sendTransport.on('connectionstatechange', () => {
|
||||
const state = sendTransport.connectionState
|
||||
sendTransport.value.on('connectionstatechange', () => {
|
||||
const state = sendTransport.value.connectionState
|
||||
if (state === 'connected') webrtcState.value = 'connected'
|
||||
else if (state === 'failed' || state === 'disconnected' || state === 'closed') {
|
||||
logWarn('share-live: Send transport connection state changed', {
|
||||
state,
|
||||
transportId: sendTransport.id,
|
||||
producerId: producer.id,
|
||||
transportId: sendTransport.value.id,
|
||||
producerId: producer.value.id,
|
||||
})
|
||||
if (state === 'failed') {
|
||||
webrtcState.value = 'failed'
|
||||
@@ -241,25 +241,25 @@ async function startSharing() {
|
||||
}
|
||||
})
|
||||
// Monitor track state
|
||||
if (producer.track) {
|
||||
producer.track.addEventListener('ended', () => {
|
||||
if (producer.value.track) {
|
||||
producer.value.track.addEventListener('ended', () => {
|
||||
logWarn('share-live: Producer track ended', {
|
||||
producerId: producer.id,
|
||||
trackId: producer.track.id,
|
||||
trackReadyState: producer.track.readyState,
|
||||
trackEnabled: producer.track.enabled,
|
||||
trackMuted: producer.track.muted,
|
||||
producerId: producer.value.id,
|
||||
trackId: producer.value.track.id,
|
||||
trackReadyState: producer.value.track.readyState,
|
||||
trackEnabled: producer.value.track.enabled,
|
||||
trackMuted: producer.value.track.muted,
|
||||
})
|
||||
})
|
||||
producer.track.addEventListener('mute', () => {
|
||||
producer.value.track.addEventListener('mute', () => {
|
||||
logWarn('share-live: Producer track muted', {
|
||||
producerId: producer.id,
|
||||
trackId: producer.track.id,
|
||||
trackEnabled: producer.track.enabled,
|
||||
trackMuted: producer.track.muted,
|
||||
producerId: producer.value.id,
|
||||
trackId: producer.value.track.id,
|
||||
trackEnabled: producer.value.track.enabled,
|
||||
trackMuted: producer.value.track.muted,
|
||||
})
|
||||
})
|
||||
producer.track.addEventListener('unmute', () => {})
|
||||
producer.value.track.addEventListener('unmute', () => {})
|
||||
}
|
||||
webrtcState.value = 'connected'
|
||||
setStatus('WebRTC connected. Requesting location…')
|
||||
@@ -273,7 +273,7 @@ async function startSharing() {
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Get location (continuous) — also requires HTTPS on mobile Safari
|
||||
// 5. Get location (continuous) - also requires HTTPS on mobile Safari
|
||||
if (!navigator.geolocation) {
|
||||
setError('Geolocation not supported in this browser.')
|
||||
cleanup()
|
||||
@@ -281,7 +281,7 @@ async function startSharing() {
|
||||
}
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
locationWatchId = navigator.geolocation.watchPosition(
|
||||
locationWatchId.value = navigator.geolocation.watchPosition(
|
||||
(pos) => {
|
||||
resolve(pos)
|
||||
},
|
||||
@@ -332,9 +332,9 @@ async function startSharing() {
|
||||
}
|
||||
catch (e) {
|
||||
if (e?.statusCode === 404) {
|
||||
if (locationIntervalId != null) {
|
||||
clearInterval(locationIntervalId)
|
||||
locationIntervalId = null
|
||||
if (locationIntervalId.value != null) {
|
||||
clearInterval(locationIntervalId.value)
|
||||
locationIntervalId.value = null
|
||||
}
|
||||
sharing.value = false
|
||||
if (!locationUpdate404Logged) {
|
||||
@@ -350,7 +350,7 @@ async function startSharing() {
|
||||
}
|
||||
|
||||
await sendLocationUpdate()
|
||||
locationIntervalId = setInterval(sendLocationUpdate, 2000)
|
||||
locationIntervalId.value = setInterval(sendLocationUpdate, 2000)
|
||||
}
|
||||
catch (e) {
|
||||
starting.value = false
|
||||
@@ -363,23 +363,23 @@ async function startSharing() {
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (locationWatchId != null && navigator.geolocation?.clearWatch) {
|
||||
navigator.geolocation.clearWatch(locationWatchId)
|
||||
if (locationWatchId.value != null && navigator.geolocation?.clearWatch) {
|
||||
navigator.geolocation.clearWatch(locationWatchId.value)
|
||||
}
|
||||
locationWatchId = null
|
||||
if (locationIntervalId != null) {
|
||||
clearInterval(locationIntervalId)
|
||||
locationWatchId.value = null
|
||||
if (locationIntervalId.value != null) {
|
||||
clearInterval(locationIntervalId.value)
|
||||
}
|
||||
locationIntervalId = null
|
||||
if (producer) {
|
||||
producer.close()
|
||||
producer = null
|
||||
locationIntervalId.value = null
|
||||
if (producer.value) {
|
||||
producer.value.close()
|
||||
producer.value = null
|
||||
}
|
||||
if (sendTransport) {
|
||||
sendTransport.close()
|
||||
sendTransport = null
|
||||
if (sendTransport.value) {
|
||||
sendTransport.value.close()
|
||||
sendTransport.value = null
|
||||
}
|
||||
device = null
|
||||
device.value = null
|
||||
if (stream.value) {
|
||||
stream.value.getTracks().forEach(t => t.stop())
|
||||
stream.value = null
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
/** Client-side logger: sends to server, falls back to console. */
|
||||
let sessionId = null
|
||||
let userId = null
|
||||
const sessionId = ref(null)
|
||||
const userId = ref(null)
|
||||
|
||||
const CONSOLE_METHOD = Object.freeze({ error: 'error', warn: 'warn', info: 'log', debug: 'log' })
|
||||
|
||||
export function initLogger(sessId, uid) {
|
||||
sessionId = sessId
|
||||
userId = uid
|
||||
sessionId.value = sessId
|
||||
userId.value = uid
|
||||
}
|
||||
|
||||
function sendToServer(level, message, data) {
|
||||
setTimeout(() => {
|
||||
$fetch('/api/log', {
|
||||
method: 'POST',
|
||||
body: { level, message, data, sessionId, userId, timestamp: new Date().toISOString() },
|
||||
body: { level, message, data, sessionId: sessionId.value, userId: userId.value, timestamp: new Date().toISOString() },
|
||||
credentials: 'include',
|
||||
}).catch(() => { /* server down - don't spam console */ })
|
||||
}, 0)
|
||||
|
||||
11
docs/README.md
Normal file
11
docs/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# KestrelOS Documentation
|
||||
|
||||
Tactical Operations Center (TOC) for OSINT feeds: map view, cameras/devices, live sharing, and ATAK/iTAK integration.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. [Installation](installation.md) - npm, Docker, or Helm
|
||||
2. [Authentication](auth.md) - First login (bootstrap admin or OIDC)
|
||||
3. [Map and cameras](map-and-cameras.md) - Add devices and view streams
|
||||
4. [ATAK and iTAK](atak-itak.md) - Connect TAK clients (port 8089)
|
||||
5. [Share live](live-streaming.md) - Stream from mobile device (HTTPS required)
|
||||
79
docs/atak-itak.md
Normal file
79
docs/atak-itak.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# ATAK and iTAK
|
||||
|
||||
KestrelOS acts as a **TAK Server**. ATAK (Android) and iTAK (iOS) connect on **port 8089** (CoT). Devices relay positions to each other and appear on the KestrelOS map.
|
||||
|
||||
## Connection
|
||||
|
||||
**Host:** KestrelOS hostname/IP
|
||||
**Port:** `8089` (CoT)
|
||||
**SSL:** Enable if server uses TLS (`.dev-certs/` or production cert)
|
||||
|
||||
**Authentication:**
|
||||
- **Username:** KestrelOS identifier
|
||||
- **Password:** Login password (local) or ATAK password (OIDC; set in **Account**)
|
||||
|
||||
## ATAK (Android)
|
||||
|
||||
1. **Settings** → **Network** → **Connections** → Add **TAK Server**
|
||||
2. Set **Host** and **Port** (`8089`)
|
||||
3. Enable **Use Authentication**, enter username/password
|
||||
4. Save and connect
|
||||
|
||||
## iTAK (iOS)
|
||||
|
||||
**Option A - QR code (easiest):**
|
||||
1. KestrelOS **Settings** → **TAK Server** → Scan QR with iTAK
|
||||
2. Enter username/password when prompted
|
||||
|
||||
**Option B - Manual:**
|
||||
1. **Settings** → **Network** → Add **TAK Server**
|
||||
2. Set **Host**, **Port** (`8089`), enable SSL if needed
|
||||
3. Enable **Use Authentication**, enter username/password
|
||||
4. Save and connect
|
||||
|
||||
## Self-Signed Certificate (iTAK)
|
||||
|
||||
If server uses self-signed cert (`.dev-certs/`):
|
||||
|
||||
**Upload server package:**
|
||||
1. KestrelOS **Settings** → **TAK Server** → **Download server package (zip)**
|
||||
2. Transfer to iPhone (AirDrop, email, Safari)
|
||||
3. iTAK: **Settings** → **Network** → **Servers** → **+** → **Upload server package**
|
||||
4. Enter username/password
|
||||
|
||||
**Or use plain TCP:**
|
||||
1. Stop KestrelOS, remove `.dev-certs/`, restart
|
||||
2. Add server with **SSL disabled**
|
||||
|
||||
**ATAK (Android):** Download trust store from `https://your-server/api/cot/truststore`, import `.p12` (password: `kestrelos`), or use server package/plain TCP.
|
||||
|
||||
## OIDC Users
|
||||
|
||||
OIDC users must set an **ATAK password** first:
|
||||
1. Sign in with OIDC
|
||||
2. **Account** → **ATAK / device password** → set password
|
||||
3. Use KestrelOS username + ATAK password in TAK client
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `COT_PORT` | `8089` | CoT server port |
|
||||
| `COT_TTL_MS` | `90000` | Device timeout (~90s) |
|
||||
| `COT_REQUIRE_AUTH` | `true` | Require authentication |
|
||||
| `COT_SSL_CERT` | `.dev-certs/cert.pem` | TLS cert path |
|
||||
| `COT_SSL_KEY` | `.dev-certs/key.pem` | TLS key path |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"Error authenticating" with no `[cot]` logs:**
|
||||
- Connection not reaching server (TLS handshake failed or firewall blocking)
|
||||
- Check server logs show `[cot] CoT server listening on 0.0.0.0:8089`
|
||||
- Verify port `8089` (not `3000`) and firewall allows it
|
||||
- For TLS: trust cert (server package) or use plain TCP
|
||||
|
||||
**"Error authenticating" with `[cot]` logs:**
|
||||
- Username must be KestrelOS identifier
|
||||
- Password must match (local: login password; OIDC: ATAK password)
|
||||
|
||||
**Devices not on map:** They appear only while sending updates; drop off after TTL (~90s).
|
||||
39
docs/auth.md
Normal file
39
docs/auth.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Authentication
|
||||
|
||||
KestrelOS supports **local login** (username/email + password) and optional **OIDC** (SSO). All users must sign in.
|
||||
|
||||
## Local Login
|
||||
|
||||
**First run:** On first start, KestrelOS creates an admin account:
|
||||
- If `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` are set → that account is created
|
||||
- Otherwise → default admin (`admin`) with random password printed in terminal
|
||||
|
||||
**Sign in:** Open `/login`, enter identifier and password. Change password or add users via **Members** (admin only).
|
||||
|
||||
## OIDC (SSO)
|
||||
|
||||
**Enable:** Set `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`. Optional: `OIDC_LABEL`, `OIDC_REDIRECT_URI`, `OIDC_SCOPES`.
|
||||
|
||||
**IdP setup:**
|
||||
1. Create OIDC client in your IdP (Keycloak, Auth0, etc.)
|
||||
2. Set redirect URI: `https://<your-host>/api/auth/oidc/callback`
|
||||
3. Copy Client ID and Secret to env vars
|
||||
|
||||
**Sign up:** Users sign up at the IdP. First OIDC login in KestrelOS creates their account automatically.
|
||||
|
||||
**Redirect URI:** Defaults to `{APP_URL}/api/auth/oidc/callback` (uses `NUXT_APP_URL`/`APP_URL` or falls back to `HOST`/`PORT`).
|
||||
|
||||
## OIDC Users and ATAK/iTAK
|
||||
|
||||
OIDC users don't have a KestrelOS password. To use ATAK/iTAK:
|
||||
1. Sign in with OIDC
|
||||
2. Go to **Account** → set **ATAK password**
|
||||
3. Use KestrelOS username + ATAK password in TAK client
|
||||
|
||||
## Roles
|
||||
|
||||
- **Admin** - Manage users, edit POIs, add/edit devices (API)
|
||||
- **Leader** - Edit POIs, add/edit devices (API)
|
||||
- **Member** - View map/cameras/POIs, use Share live
|
||||
|
||||
Only admins can change roles (Members page).
|
||||
61
docs/installation.md
Normal file
61
docs/installation.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Installation
|
||||
|
||||
Run KestrelOS from source (npm), Docker, or Kubernetes (Helm).
|
||||
|
||||
## npm (from source)
|
||||
|
||||
```bash
|
||||
git clone <repository-url> kestrelos
|
||||
cd kestrelos
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open **http://localhost:3000**. First run creates `data/kestrelos.db` and bootstraps an admin (see [Authentication](auth.md)).
|
||||
|
||||
**Production:**
|
||||
```bash
|
||||
npm run build
|
||||
npm run preview
|
||||
# or
|
||||
node .output/server/index.mjs
|
||||
```
|
||||
|
||||
Set `HOST=0.0.0.0` and `PORT` for production.
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker build -t kestrelos:latest .
|
||||
docker run -p 3000:3000 -p 8089:8089 \
|
||||
-v kestrelos-data:/app/data \
|
||||
kestrelos:latest
|
||||
```
|
||||
|
||||
Expose ports **3000** (web/API) and **8089** (CoT for ATAK/iTAK).
|
||||
|
||||
## Helm (Kubernetes)
|
||||
|
||||
**From registry:**
|
||||
```bash
|
||||
helm repo add keligrubb --username USER --password TOKEN \
|
||||
https://git.keligrubb.com/api/packages/keligrubb/helm
|
||||
helm install kestrelos keligrubb/kestrelos
|
||||
```
|
||||
|
||||
**From source:**
|
||||
```bash
|
||||
helm install kestrelos ./helm/kestrelos
|
||||
```
|
||||
|
||||
Configure in `helm/kestrelos/values.yaml`. Health: `GET /health`, `/health/live`, `/health/ready`.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `HOST` | Nuxt default | Bind address (use `0.0.0.0` for all interfaces) |
|
||||
| `PORT` | `3000` | Web/API port |
|
||||
| `DB_PATH` | `data/kestrelos.db` | SQLite database path |
|
||||
|
||||
See [Authentication](auth.md) for auth variables. See [ATAK and iTAK](atak-itak.md) for CoT options.
|
||||
44
docs/live-streaming.md
Normal file
44
docs/live-streaming.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Share Live
|
||||
|
||||
Stream your phone's camera and location to KestrelOS. Appears as a **live session** on the map and in **Cameras**. Uses **WebRTC** (Mediasoup) and requires **HTTPS** on mobile.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Open **Share live** (sidebar → **Share live** or `/share-live`)
|
||||
2. Tap **Start sharing**, allow camera/location permissions
|
||||
3. Device appears on map and in **Cameras**
|
||||
4. Tap **Stop sharing** to end
|
||||
|
||||
**Permissions:** Admin/leader can start sharing. All users can view live sessions.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **HTTPS** (browsers require secure context for camera/geolocation)
|
||||
- **Camera and location permissions**
|
||||
- **WebRTC ports:** UDP/TCP `40000-49999` open on server
|
||||
|
||||
## Local Development
|
||||
|
||||
**Generate self-signed cert:**
|
||||
```bash
|
||||
chmod +x scripts/gen-dev-cert.sh
|
||||
./scripts/gen-dev-cert.sh 192.168.1.123 # Your LAN IP
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**On phone:** Open `https://192.168.1.123:3000`, accept cert warning, sign in, use Share live.
|
||||
|
||||
## WebRTC Configuration
|
||||
|
||||
- Server auto-detects LAN IP for WebRTC
|
||||
- **Docker/multiple NICs:** Set `MEDIASOUP_ANNOUNCED_IP` to client-reachable IP/hostname
|
||||
- **"Wrong host" error:** Use same URL on phone/server, or set `MEDIASOUP_ANNOUNCED_IP`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Fix |
|
||||
|-------|-----|
|
||||
| "HTTPS required" | Use `https://` (not `http://`) |
|
||||
| "Media devices not available" | Ensure HTTPS and browser permissions |
|
||||
| "WebRTC: failed" / "Wrong host" | Set `MEDIASOUP_ANNOUNCED_IP`, open firewall ports `40000-49999` |
|
||||
| Stream not visible | Check server reachability and firewall |
|
||||
52
docs/map-and-cameras.md
Normal file
52
docs/map-and-cameras.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Map and Cameras
|
||||
|
||||
KestrelOS shows a **map** with devices, POIs, live sessions (Share live), and ATAK/iTAK positions. Click markers or use **Cameras** page to view streams.
|
||||
|
||||
## Map Layers
|
||||
|
||||
- **Devices** - Fixed feeds (IPTV, ALPR, CCTV, NVR, etc.) added via API
|
||||
- **POIs** - Points of interest (admin/leader can edit)
|
||||
- **Live sessions** - Mobile devices streaming via Share live
|
||||
- **CoT (ATAK/iTAK)** - Amber markers for connected TAK devices (position only)
|
||||
|
||||
## Cameras
|
||||
|
||||
A **camera** is either:
|
||||
1. A **device** - Fixed feed with stream URL
|
||||
2. A **live session** - Mobile device streaming via Share live
|
||||
|
||||
View via map markers or **Cameras** page (sidebar).
|
||||
|
||||
## Device Types
|
||||
|
||||
| device_type | Use case |
|
||||
|-------------|----------|
|
||||
| `alpr`, `nvr`, `doorbell`, `feed`, `traffic`, `ip`, `drone` | Labeling/filtering |
|
||||
|
||||
**source_type:** `mjpeg` (MJPEG over HTTP) or `hls` (HLS `.m3u8` playlist)
|
||||
|
||||
Stream URLs must be `http://` or `https://`.
|
||||
|
||||
## API: Devices
|
||||
|
||||
**Create:** `POST /api/devices` (admin/leader)
|
||||
```json
|
||||
{
|
||||
"name": "Main gate ALPR",
|
||||
"device_type": "alpr",
|
||||
"lat": 37.7749,
|
||||
"lng": -122.4194,
|
||||
"stream_url": "https://alpr.example.com/stream.m3u8",
|
||||
"source_type": "hls"
|
||||
}
|
||||
```
|
||||
|
||||
**List:** `GET /api/devices`
|
||||
**Update:** `PATCH /api/devices/:id`
|
||||
**Delete:** `DELETE /api/devices/:id`
|
||||
|
||||
**Cameras endpoint:** `GET /api/cameras` returns devices + live sessions + CoT entities.
|
||||
|
||||
## POIs
|
||||
|
||||
Admins/leaders add/edit from **POI** page (sidebar). POIs appear as map pins (reference only, no stream).
|
||||
BIN
docs/screenshot.png
Normal file
BIN
docs/screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 159 KiB |
@@ -2,5 +2,5 @@ apiVersion: v2
|
||||
name: kestrelos
|
||||
description: KestrelOS TOC for OSINT feeds - map, camera feeds, offline tiles
|
||||
type: application
|
||||
version: 0.4.0
|
||||
appVersion: "0.4.0"
|
||||
version: 1.0.0
|
||||
appVersion: "1.0.0"
|
||||
|
||||
@@ -2,7 +2,7 @@ replicaCount: 1
|
||||
|
||||
image:
|
||||
repository: git.keligrubb.com/keligrubb/kestrelos
|
||||
tag: 0.4.0
|
||||
tag: 1.0.0
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
service:
|
||||
|
||||
@@ -32,10 +32,12 @@ export default defineNuxtConfig({
|
||||
public: {
|
||||
version: pkg.version ?? '',
|
||||
},
|
||||
cotTtlMs: 90_000,
|
||||
cotRequireAuth: true,
|
||||
cotDebug: false,
|
||||
},
|
||||
devServer: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
...(useDevHttps
|
||||
? { https: { key: devKey, cert: devCert } }
|
||||
: {}),
|
||||
|
||||
297
package-lock.json
generated
297
package-lock.json
generated
@@ -1,21 +1,26 @@
|
||||
{
|
||||
"name": "kestrelos",
|
||||
"version": "0.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "kestrelos",
|
||||
"version": "0.3.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"fast-xml-parser": "^5.3.6",
|
||||
"hls.js": "^1.5.0",
|
||||
"jszip": "^3.10.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.offline": "^3.2.0",
|
||||
"mediasoup": "^3.19.14",
|
||||
"mediasoup-client": "^3.18.6",
|
||||
"nuxt": "^4.0.0",
|
||||
"openid-client": "^6.8.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"sqlite3": "^5.1.7",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.4.0",
|
||||
@@ -6680,6 +6685,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase-css": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||
@@ -7422,6 +7436,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
@@ -7581,6 +7604,12 @@
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dlv": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||
@@ -8753,6 +8782,24 @@
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.3.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz",
|
||||
"integrity": "sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"strnum": "^2.1.2"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||
@@ -9711,6 +9758,12 @@
|
||||
"integrity": "sha512-3MOLanc3sb3LNGWQl1RlQlNWURE5g32aUphrDyFeCsxBTk08iE3VNe4CwsUZ0Qs1X+EfX0+r29Sxdpza4B+yRA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -10397,6 +10450,48 @@
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jszip/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/keygrip": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz",
|
||||
@@ -10703,6 +10798,15 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -12506,6 +12610,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
@@ -12518,6 +12631,12 @@
|
||||
"integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -12567,7 +12686,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -12729,6 +12847,15 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/portfinder": {
|
||||
"version": "1.0.38",
|
||||
"resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz",
|
||||
@@ -13491,6 +13618,141 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/quansync": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
|
||||
@@ -13789,6 +14051,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/reserved-identifiers": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/reserved-identifiers/-/reserved-identifiers-1.2.0.tgz",
|
||||
@@ -14274,8 +14542,13 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
@@ -14799,6 +15072,18 @@
|
||||
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
|
||||
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/structured-clone-es": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/structured-clone-es/-/structured-clone-es-1.0.0.tgz",
|
||||
@@ -16670,6 +16955,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kestrelos",
|
||||
"version": "0.4.0",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -10,6 +10,7 @@
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare",
|
||||
"test": "vitest",
|
||||
"test:integration": "vitest run --config vitest.integration.config.js",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test test/e2e",
|
||||
"test:e2e:ui": "playwright test --ui test/e2e",
|
||||
@@ -20,13 +21,16 @@
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"fast-xml-parser": "^5.3.6",
|
||||
"hls.js": "^1.5.0",
|
||||
"jszip": "^3.10.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.offline": "^3.2.0",
|
||||
"mediasoup": "^3.19.14",
|
||||
"mediasoup-client": "^3.18.6",
|
||||
"nuxt": "^4.0.0",
|
||||
"openid-client": "^6.8.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"sqlite3": "^5.1.7",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.4.0",
|
||||
|
||||
3
renovate.json
Normal file
3
renovate.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
import { getAuthConfig } from '../../utils/authConfig.js'
|
||||
import { getAuthConfig } from '../../utils/oidc.js'
|
||||
|
||||
export default defineEventHandler(() => getAuthConfig())
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { setCookie } from 'h3'
|
||||
import { getDb } from '../../utils/db.js'
|
||||
import { verifyPassword } from '../../utils/password.js'
|
||||
import { getSessionMaxAgeDays } from '../../utils/session.js'
|
||||
import { getSessionMaxAgeDays } from '../../utils/constants.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
@@ -15,6 +15,10 @@ export default defineEventHandler(async (event) => {
|
||||
if (!user || !user.password_hash || !verifyPassword(password, user.password_hash)) {
|
||||
throw createError({ statusCode: 401, message: 'Invalid credentials' })
|
||||
}
|
||||
|
||||
// Invalidate all existing sessions for this user to prevent session fixation
|
||||
await run('DELETE FROM sessions WHERE user_id = ?', [user.id])
|
||||
|
||||
const sessionDays = getSessionMaxAgeDays()
|
||||
const sid = crypto.randomUUID()
|
||||
const now = new Date()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getAuthConfig } from '../../../utils/authConfig.js'
|
||||
import {
|
||||
getAuthConfig,
|
||||
getOidcConfig,
|
||||
getOidcRedirectUri,
|
||||
createOidcParams,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
exchangeCode,
|
||||
} from '../../../utils/oidc.js'
|
||||
import { getDb } from '../../../utils/db.js'
|
||||
import { getSessionMaxAgeDays } from '../../../utils/session.js'
|
||||
import { getSessionMaxAgeDays } from '../../../utils/constants.js'
|
||||
|
||||
const DEFAULT_ROLE = process.env.OIDC_DEFAULT_ROLE || 'member'
|
||||
|
||||
@@ -74,6 +74,9 @@ export default defineEventHandler(async (event) => {
|
||||
user = await get('SELECT id, identifier, role FROM users WHERE id = ?', [id])
|
||||
}
|
||||
|
||||
// Invalidate all existing sessions for this user to prevent session fixation
|
||||
await run('DELETE FROM sessions WHERE user_id = ?', [user.id])
|
||||
|
||||
const sessionDays = getSessionMaxAgeDays()
|
||||
const sid = crypto.randomUUID()
|
||||
const now = new Date()
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { getDb } from '../utils/db.js'
|
||||
import { requireAuth } from '../utils/authHelpers.js'
|
||||
import { getActiveSessions } from '../utils/liveSessions.js'
|
||||
import { getActiveEntities } from '../utils/cotStore.js'
|
||||
import { rowToDevice, sanitizeDeviceForResponse } from '../utils/deviceUtils.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAuth(event)
|
||||
const [db, sessions] = await Promise.all([getDb(), getActiveSessions()])
|
||||
const config = useRuntimeConfig()
|
||||
const ttlMs = Number(config.cotTtlMs ?? 90_000) || 90_000
|
||||
const [db, sessions, cotEntities] = await Promise.all([
|
||||
getDb(),
|
||||
getActiveSessions(),
|
||||
getActiveEntities(ttlMs),
|
||||
])
|
||||
const rows = await db.all('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices ORDER BY id')
|
||||
const devices = rows.map(rowToDevice).filter(Boolean).map(sanitizeDeviceForResponse)
|
||||
return { devices, liveSessions: sessions }
|
||||
return { devices, liveSessions: sessions, cotEntities }
|
||||
})
|
||||
|
||||
8
server/api/cot/config.get.js
Normal file
8
server/api/cot/config.get.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { getCotSslPaths, getCotPort } from '../../utils/cotSsl.js'
|
||||
|
||||
/** Public CoT server config for QR code / client setup (port and whether TLS is used). */
|
||||
export default defineEventHandler(() => {
|
||||
const config = useRuntimeConfig()
|
||||
const paths = getCotSslPaths(config)
|
||||
return { port: getCotPort(), ssl: Boolean(paths) }
|
||||
})
|
||||
60
server/api/cot/server-package.get.js
Normal file
60
server/api/cot/server-package.get.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import JSZip from 'jszip'
|
||||
import { getCotSslPaths, getCotPort, TRUSTSTORE_PASSWORD, COT_TLS_REQUIRED_MESSAGE, buildP12FromCertPath } from '../../utils/cotSsl.js'
|
||||
import { requireAuth } from '../../utils/authHelpers.js'
|
||||
|
||||
/**
|
||||
* Build config.pref XML for iTAK: server connection + CA cert for trust (credentials entered in app).
|
||||
* connectString format: host:port:ssl or host:port:tcp
|
||||
*/
|
||||
function buildConfigPref(hostname, port, ssl) {
|
||||
const connectString = `${hostname}:${port}:${ssl ? 'ssl' : 'tcp'}`
|
||||
return `<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<preference-set id="com.atakmap.app_preferences">
|
||||
<entry key="connectionEntry">1</entry>
|
||||
<entry key="description">KestrelOS</entry>
|
||||
<entry key="enabled">true</entry>
|
||||
<entry key="connectString">${escapeXml(connectString)}</entry>
|
||||
<entry key="caCertPath">cert/caCert.p12</entry>
|
||||
<entry key="caCertPassword">${escapeXml(TRUSTSTORE_PASSWORD)}</entry>
|
||||
<entry key="cacheCredentials">true</entry>
|
||||
</preference-set>
|
||||
`
|
||||
}
|
||||
|
||||
function escapeXml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAuth(event)
|
||||
const config = useRuntimeConfig()
|
||||
const paths = getCotSslPaths(config)
|
||||
if (!paths || !existsSync(paths.certPath)) {
|
||||
setResponseStatus(event, 404)
|
||||
return { error: `CoT server is not using TLS. Server package ${COT_TLS_REQUIRED_MESSAGE} Use the QR code and add the server with SSL disabled (plain TCP) instead.` }
|
||||
}
|
||||
|
||||
const hostname = getRequestURL(event).hostname
|
||||
const port = getCotPort()
|
||||
|
||||
try {
|
||||
const p12 = buildP12FromCertPath(paths.certPath, TRUSTSTORE_PASSWORD)
|
||||
const zip = new JSZip()
|
||||
zip.file('config.pref', buildConfigPref(hostname, port, true))
|
||||
zip.folder('cert').file('caCert.p12', p12)
|
||||
|
||||
const blob = await zip.generateAsync({ type: 'nodebuffer' })
|
||||
setHeader(event, 'Content-Type', 'application/zip')
|
||||
setHeader(event, 'Content-Disposition', 'attachment; filename="kestrelos-itak-server-package.zip"')
|
||||
return blob
|
||||
}
|
||||
catch (err) {
|
||||
setResponseStatus(event, 500)
|
||||
return { error: 'Failed to build server package.', detail: err?.message }
|
||||
}
|
||||
})
|
||||
24
server/api/cot/truststore.get.js
Normal file
24
server/api/cot/truststore.get.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { getCotSslPaths, TRUSTSTORE_PASSWORD, COT_TLS_REQUIRED_MESSAGE, buildP12FromCertPath } from '../../utils/cotSsl.js'
|
||||
import { requireAuth } from '../../utils/authHelpers.js'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
requireAuth(event)
|
||||
const config = useRuntimeConfig()
|
||||
const paths = getCotSslPaths(config)
|
||||
if (!paths || !existsSync(paths.certPath)) {
|
||||
setResponseStatus(event, 404)
|
||||
return { error: `CoT server is not using TLS or cert not found. Trust store ${COT_TLS_REQUIRED_MESSAGE}` }
|
||||
}
|
||||
|
||||
try {
|
||||
const p12 = buildP12FromCertPath(paths.certPath, TRUSTSTORE_PASSWORD)
|
||||
setHeader(event, 'Content-Type', 'application/x-pkcs12')
|
||||
setHeader(event, 'Content-Disposition', 'attachment; filename="kestrelos-cot-truststore.p12"')
|
||||
return p12
|
||||
}
|
||||
catch (err) {
|
||||
setResponseStatus(event, 500)
|
||||
return { error: 'Failed to build trust store.', detail: err?.message }
|
||||
}
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getDb } from '../utils/db.js'
|
||||
import { getDb, withTransaction } from '../utils/db.js'
|
||||
import { requireAuth } from '../utils/authHelpers.js'
|
||||
import { validateDeviceBody, rowToDevice, sanitizeDeviceForResponse } from '../utils/deviceUtils.js'
|
||||
|
||||
@@ -7,13 +7,15 @@ export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event).catch(() => ({}))
|
||||
const { name, device_type, vendor, lat, lng, stream_url, source_type, config } = validateDeviceBody(body)
|
||||
const id = crypto.randomUUID()
|
||||
const { run, get } = await getDb()
|
||||
await run(
|
||||
'INSERT INTO devices (id, name, device_type, vendor, lat, lng, stream_url, source_type, config) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, name, device_type, vendor, lat, lng, stream_url, source_type, config],
|
||||
)
|
||||
const row = await get('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices WHERE id = ?', [id])
|
||||
const device = rowToDevice(row)
|
||||
if (!device) throw createError({ statusCode: 500, message: 'Device not found after insert' })
|
||||
return sanitizeDeviceForResponse(device)
|
||||
const db = await getDb()
|
||||
return withTransaction(db, async ({ run, get }) => {
|
||||
await run(
|
||||
'INSERT INTO devices (id, name, device_type, vendor, lat, lng, stream_url, source_type, config) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, name, device_type, vendor, lat, lng, stream_url, source_type, config],
|
||||
)
|
||||
const row = await get('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices WHERE id = ?', [id])
|
||||
const device = rowToDevice(row)
|
||||
if (!device) throw createError({ statusCode: 500, message: 'Device not found after insert' })
|
||||
return sanitizeDeviceForResponse(device)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,55 +1,49 @@
|
||||
import { getDb } from '../../utils/db.js'
|
||||
import { requireAuth } from '../../utils/authHelpers.js'
|
||||
import { rowToDevice, sanitizeDeviceForResponse, DEVICE_TYPES, SOURCE_TYPES } from '../../utils/deviceUtils.js'
|
||||
import { buildUpdateQuery } from '../../utils/queryBuilder.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAuth(event, { role: 'adminOrLeader' })
|
||||
const id = event.context.params?.id
|
||||
if (!id) throw createError({ statusCode: 400, message: 'id required' })
|
||||
const body = (await readBody(event).catch(() => ({}))) || {}
|
||||
const updates = []
|
||||
const params = []
|
||||
const updates = {}
|
||||
if (typeof body.name === 'string') {
|
||||
updates.push('name = ?')
|
||||
params.push(body.name.trim())
|
||||
updates.name = body.name.trim()
|
||||
}
|
||||
if (DEVICE_TYPES.includes(body.device_type)) {
|
||||
updates.push('device_type = ?')
|
||||
params.push(body.device_type)
|
||||
updates.device_type = body.device_type
|
||||
}
|
||||
if (body.vendor !== undefined) {
|
||||
updates.push('vendor = ?')
|
||||
params.push(typeof body.vendor === 'string' && body.vendor.trim() ? body.vendor.trim() : null)
|
||||
updates.vendor = typeof body.vendor === 'string' && body.vendor.trim() ? body.vendor.trim() : null
|
||||
}
|
||||
if (Number.isFinite(body.lat)) {
|
||||
updates.push('lat = ?')
|
||||
params.push(body.lat)
|
||||
updates.lat = body.lat
|
||||
}
|
||||
if (Number.isFinite(body.lng)) {
|
||||
updates.push('lng = ?')
|
||||
params.push(body.lng)
|
||||
updates.lng = body.lng
|
||||
}
|
||||
if (typeof body.stream_url === 'string') {
|
||||
updates.push('stream_url = ?')
|
||||
params.push(body.stream_url.trim())
|
||||
updates.stream_url = body.stream_url.trim()
|
||||
}
|
||||
if (SOURCE_TYPES.includes(body.source_type)) {
|
||||
updates.push('source_type = ?')
|
||||
params.push(body.source_type)
|
||||
updates.source_type = body.source_type
|
||||
}
|
||||
if (body.config !== undefined) {
|
||||
updates.push('config = ?')
|
||||
params.push(typeof body.config === 'string' ? body.config : (body.config != null ? JSON.stringify(body.config) : null))
|
||||
updates.config = typeof body.config === 'string' ? body.config : (body.config != null ? JSON.stringify(body.config) : null)
|
||||
}
|
||||
const { run, get } = await getDb()
|
||||
if (updates.length === 0) {
|
||||
if (Object.keys(updates).length === 0) {
|
||||
const row = await get('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices WHERE id = ?', [id])
|
||||
if (!row) throw createError({ statusCode: 404, message: 'Device not found' })
|
||||
const device = rowToDevice(row)
|
||||
return device ? sanitizeDeviceForResponse(device) : row
|
||||
}
|
||||
params.push(id)
|
||||
await run(`UPDATE devices SET ${updates.join(', ')} WHERE id = ?`, params)
|
||||
const { query, params } = buildUpdateQuery('devices', null, updates)
|
||||
if (query) {
|
||||
await run(query, [...params, id])
|
||||
}
|
||||
const row = await get('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices WHERE id = ?', [id])
|
||||
if (!row) throw createError({ statusCode: 404, message: 'Device not found' })
|
||||
const device = rowToDevice(row)
|
||||
|
||||
@@ -1,35 +1,38 @@
|
||||
import { requireAuth } from '../../utils/authHelpers.js'
|
||||
import { getLiveSession, deleteLiveSession } from '../../utils/liveSessions.js'
|
||||
import { closeRouter, getProducer, getTransport } from '../../utils/mediasoup.js'
|
||||
import { acquire } from '../../utils/asyncLock.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
const id = event.context.params?.id
|
||||
if (!id) throw createError({ statusCode: 400, message: 'id required' })
|
||||
|
||||
const session = getLiveSession(id)
|
||||
if (!session) throw createError({ statusCode: 404, message: 'Live session not found' })
|
||||
if (session.userId !== user.id) throw createError({ statusCode: 403, message: 'Forbidden' })
|
||||
return await acquire(`session-delete-${id}`, async () => {
|
||||
const session = getLiveSession(id)
|
||||
if (!session) throw createError({ statusCode: 404, message: 'Live session not found' })
|
||||
if (session.userId !== user.id) throw createError({ statusCode: 403, message: 'Forbidden' })
|
||||
|
||||
// Clean up producer if it exists
|
||||
if (session.producerId) {
|
||||
const producer = getProducer(session.producerId)
|
||||
if (producer) {
|
||||
producer.close()
|
||||
// Clean up producer if it exists
|
||||
if (session.producerId) {
|
||||
const producer = getProducer(session.producerId)
|
||||
if (producer) {
|
||||
producer.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up transport if it exists
|
||||
if (session.transportId) {
|
||||
const transport = getTransport(session.transportId)
|
||||
if (transport) {
|
||||
transport.close()
|
||||
// Clean up transport if it exists
|
||||
if (session.transportId) {
|
||||
const transport = getTransport(session.transportId)
|
||||
if (transport) {
|
||||
transport.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up router
|
||||
await closeRouter(id)
|
||||
// Clean up router
|
||||
await closeRouter(id)
|
||||
|
||||
deleteLiveSession(id)
|
||||
return { ok: true }
|
||||
await deleteLiveSession(id)
|
||||
return { ok: true }
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,31 +1,57 @@
|
||||
import { requireAuth } from '../../utils/authHelpers.js'
|
||||
import { getLiveSession, updateLiveSession } from '../../utils/liveSessions.js'
|
||||
import { acquire } from '../../utils/asyncLock.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
const id = event.context.params?.id
|
||||
if (!id) throw createError({ statusCode: 400, message: 'id required' })
|
||||
|
||||
const session = getLiveSession(id)
|
||||
if (!session) throw createError({ statusCode: 404, message: 'Live session not found' })
|
||||
if (session.userId !== user.id) throw createError({ statusCode: 403, message: 'Forbidden' })
|
||||
|
||||
const body = await readBody(event).catch(() => ({}))
|
||||
const lat = Number(body?.lat)
|
||||
const lng = Number(body?.lng)
|
||||
const updates = {}
|
||||
if (Number.isFinite(lat)) updates.lat = lat
|
||||
if (Number.isFinite(lng)) updates.lng = lng
|
||||
if (Object.keys(updates).length) {
|
||||
updateLiveSession(id, updates)
|
||||
if (Object.keys(updates).length === 0) {
|
||||
// No updates, just return current session
|
||||
const session = getLiveSession(id)
|
||||
if (!session) throw createError({ statusCode: 404, message: 'Live session not found' })
|
||||
if (session.userId !== user.id) throw createError({ statusCode: 403, message: 'Forbidden' })
|
||||
return {
|
||||
id: session.id,
|
||||
label: session.label,
|
||||
lat: session.lat,
|
||||
lng: session.lng,
|
||||
updatedAt: session.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
const updated = getLiveSession(id)
|
||||
return {
|
||||
id: updated.id,
|
||||
label: updated.label,
|
||||
lat: updated.lat,
|
||||
lng: updated.lng,
|
||||
updatedAt: updated.updatedAt,
|
||||
}
|
||||
// Use lock to atomically check and update session
|
||||
return await acquire(`session-patch-${id}`, async () => {
|
||||
const session = getLiveSession(id)
|
||||
if (!session) throw createError({ statusCode: 404, message: 'Live session not found' })
|
||||
if (session.userId !== user.id) throw createError({ statusCode: 403, message: 'Forbidden' })
|
||||
|
||||
try {
|
||||
const updated = await updateLiveSession(id, updates)
|
||||
// Re-verify after update (updateLiveSession throws if session not found)
|
||||
if (!updated || updated.userId !== user.id) {
|
||||
throw createError({ statusCode: 404, message: 'Live session not found' })
|
||||
}
|
||||
return {
|
||||
id: updated.id,
|
||||
label: updated.label,
|
||||
lat: updated.lat,
|
||||
lng: updated.lng,
|
||||
updatedAt: updated.updatedAt,
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
if (err.message === 'Session not found') {
|
||||
throw createError({ statusCode: 404, message: 'Live session not found' })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,40 +1,44 @@
|
||||
import { requireAuth } from '../../utils/authHelpers.js'
|
||||
import {
|
||||
createSession,
|
||||
getOrCreateSession,
|
||||
getActiveSessionByUserId,
|
||||
deleteLiveSession,
|
||||
} from '../../utils/liveSessions.js'
|
||||
import { closeRouter, getProducer, getTransport } from '../../utils/mediasoup.js'
|
||||
import { acquire } from '../../utils/asyncLock.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event, { role: 'adminOrLeader' })
|
||||
const body = await readBody(event).catch(() => ({}))
|
||||
const label = typeof body?.label === 'string' ? body.label.trim() : ''
|
||||
const label = typeof body?.label === 'string' ? body.label.trim().slice(0, 100) : ''
|
||||
|
||||
// Replace any existing live session for this user (one session per user)
|
||||
const existing = getActiveSessionByUserId(user.id)
|
||||
if (existing) {
|
||||
if (existing.producerId) {
|
||||
const producer = getProducer(existing.producerId)
|
||||
if (producer) producer.close()
|
||||
// Atomically get or create session, replacing existing if needed
|
||||
return await acquire(`session-start-${user.id}`, async () => {
|
||||
const existing = await getActiveSessionByUserId(user.id)
|
||||
if (existing) {
|
||||
// Clean up existing session resources
|
||||
if (existing.producerId) {
|
||||
const producer = getProducer(existing.producerId)
|
||||
if (producer) producer.close()
|
||||
}
|
||||
if (existing.transportId) {
|
||||
const transport = getTransport(existing.transportId)
|
||||
if (transport) transport.close()
|
||||
}
|
||||
if (existing.routerId) {
|
||||
await closeRouter(existing.id).catch((err) => {
|
||||
console.error('[live.start] Error closing previous router:', err)
|
||||
})
|
||||
}
|
||||
await deleteLiveSession(existing.id)
|
||||
console.log('[live.start] Replaced previous session:', existing.id)
|
||||
}
|
||||
if (existing.transportId) {
|
||||
const transport = getTransport(existing.transportId)
|
||||
if (transport) transport.close()
|
||||
}
|
||||
if (existing.routerId) {
|
||||
await closeRouter(existing.id).catch((err) => {
|
||||
console.error('[live.start] Error closing previous router:', err)
|
||||
})
|
||||
}
|
||||
deleteLiveSession(existing.id)
|
||||
console.log('[live.start] Replaced previous session:', existing.id)
|
||||
}
|
||||
|
||||
const session = createSession(user.id, label || `Live: ${user.identifier || 'User'}`)
|
||||
console.log('[live.start] Session created:', { id: session.id, userId: user.id, label: session.label })
|
||||
return {
|
||||
id: session.id,
|
||||
label: session.label,
|
||||
}
|
||||
const session = await getOrCreateSession(user.id, label || `Live: ${user.identifier || 'User'}`)
|
||||
console.log('[live.start] Session ready:', { id: session.id, userId: user.id, label: session.label })
|
||||
return {
|
||||
id: session.id,
|
||||
label: session.label,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getLiveSession } from '../../../utils/liveSessions.js'
|
||||
import { getTransport } from '../../../utils/mediasoup.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAuth(event) // Verify authentication
|
||||
const user = requireAuth(event) // Verify authentication
|
||||
const body = await readBody(event).catch(() => ({}))
|
||||
const { sessionId, transportId, dtlsParameters } = body
|
||||
|
||||
@@ -15,8 +15,12 @@ export default defineEventHandler(async (event) => {
|
||||
if (!session) {
|
||||
throw createError({ statusCode: 404, message: 'Session not found' })
|
||||
}
|
||||
// Note: Both publisher and viewers can connect their own transports
|
||||
// The transportId ensures they can only connect transports they created
|
||||
|
||||
// Verify user has permission to connect transport for this session
|
||||
// Only session owner or admin/leader can connect transports
|
||||
if (session.userId !== user.id && user.role !== 'admin' && user.role !== 'leader') {
|
||||
throw createError({ statusCode: 403, message: 'Forbidden' })
|
||||
}
|
||||
|
||||
const transport = getTransport(transportId)
|
||||
if (!transport) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getLiveSession } from '../../../utils/liveSessions.js'
|
||||
import { getRouter, getTransport, getProducer, createConsumer } from '../../../utils/mediasoup.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAuth(event) // Verify authentication
|
||||
const user = requireAuth(event) // Verify authentication
|
||||
const body = await readBody(event).catch(() => ({}))
|
||||
const { sessionId, transportId, rtpCapabilities } = body
|
||||
|
||||
@@ -15,6 +15,12 @@ export default defineEventHandler(async (event) => {
|
||||
if (!session) {
|
||||
throw createError({ statusCode: 404, message: `Session not found: ${sessionId}` })
|
||||
}
|
||||
|
||||
// Authorization check: only session owner or admin/leader can consume
|
||||
if (session.userId !== user.id && user.role !== 'admin' && user.role !== 'leader') {
|
||||
throw createError({ statusCode: 403, message: 'Forbidden' })
|
||||
}
|
||||
|
||||
if (!session.producerId) {
|
||||
throw createError({ statusCode: 404, message: 'No producer available for this session' })
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { requireAuth } from '../../../utils/authHelpers.js'
|
||||
import { getLiveSession, updateLiveSession } from '../../../utils/liveSessions.js'
|
||||
import { getTransport, producers } from '../../../utils/mediasoup.js'
|
||||
import { acquire } from '../../../utils/asyncLock.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
@@ -11,33 +12,48 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 400, message: 'sessionId, transportId, kind, and rtpParameters required' })
|
||||
}
|
||||
|
||||
const session = getLiveSession(sessionId)
|
||||
if (!session) {
|
||||
throw createError({ statusCode: 404, message: 'Session not found' })
|
||||
}
|
||||
if (session.userId !== user.id) {
|
||||
throw createError({ statusCode: 403, message: 'Forbidden' })
|
||||
}
|
||||
return await acquire(`create-producer-${sessionId}`, async () => {
|
||||
const session = getLiveSession(sessionId)
|
||||
if (!session) {
|
||||
throw createError({ statusCode: 404, message: 'Session not found' })
|
||||
}
|
||||
if (session.userId !== user.id) {
|
||||
throw createError({ statusCode: 403, message: 'Forbidden' })
|
||||
}
|
||||
|
||||
const transport = getTransport(transportId)
|
||||
if (!transport) {
|
||||
throw createError({ statusCode: 404, message: 'Transport not found' })
|
||||
}
|
||||
const transport = getTransport(transportId)
|
||||
if (!transport) {
|
||||
throw createError({ statusCode: 404, message: 'Transport not found' })
|
||||
}
|
||||
|
||||
const producer = await transport.produce({ kind, rtpParameters })
|
||||
producers.set(producer.id, producer)
|
||||
producer.on('close', () => {
|
||||
producers.delete(producer.id)
|
||||
const s = getLiveSession(sessionId)
|
||||
if (s && s.producerId === producer.id) {
|
||||
updateLiveSession(sessionId, { producerId: null })
|
||||
const producer = await transport.produce({ kind, rtpParameters })
|
||||
producers.set(producer.id, producer)
|
||||
producer.on('close', async () => {
|
||||
producers.delete(producer.id)
|
||||
const s = getLiveSession(sessionId)
|
||||
if (s && s.producerId === producer.id) {
|
||||
try {
|
||||
await updateLiveSession(sessionId, { producerId: null })
|
||||
}
|
||||
catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await updateLiveSession(sessionId, { producerId: producer.id })
|
||||
}
|
||||
catch (err) {
|
||||
if (err.message === 'Session not found') {
|
||||
throw createError({ statusCode: 404, message: 'Session not found' })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
return {
|
||||
id: producer.id,
|
||||
kind: producer.kind,
|
||||
}
|
||||
})
|
||||
|
||||
updateLiveSession(sessionId, { producerId: producer.id })
|
||||
|
||||
return {
|
||||
id: producer.id,
|
||||
kind: producer.kind,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getRequestURL } from 'h3'
|
||||
import { requireAuth } from '../../../utils/authHelpers.js'
|
||||
import { getLiveSession, updateLiveSession } from '../../../utils/liveSessions.js'
|
||||
import { getRouter, createTransport } from '../../../utils/mediasoup.js'
|
||||
import { acquire } from '../../../utils/asyncLock.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
@@ -12,28 +13,38 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 400, message: 'sessionId required' })
|
||||
}
|
||||
|
||||
const session = getLiveSession(sessionId)
|
||||
if (!session) {
|
||||
throw createError({ statusCode: 404, message: 'Session not found' })
|
||||
}
|
||||
return await acquire(`create-transport-${sessionId}`, async () => {
|
||||
const session = getLiveSession(sessionId)
|
||||
if (!session) {
|
||||
throw createError({ statusCode: 404, message: 'Session not found' })
|
||||
}
|
||||
|
||||
// Only publisher (session owner) can create producer transport
|
||||
// Viewers can create consumer transports
|
||||
if (isProducer && session.userId !== user.id) {
|
||||
throw createError({ statusCode: 403, message: 'Forbidden' })
|
||||
}
|
||||
// Only publisher (session owner) can create producer transport
|
||||
// Viewers can create consumer transports
|
||||
if (isProducer && session.userId !== user.id) {
|
||||
throw createError({ statusCode: 403, message: 'Forbidden' })
|
||||
}
|
||||
|
||||
const url = getRequestURL(event)
|
||||
const requestHost = url.hostname
|
||||
const router = await getRouter(sessionId)
|
||||
const { transport, params } = await createTransport(router, requestHost)
|
||||
const url = getRequestURL(event)
|
||||
const requestHost = url.hostname
|
||||
const router = await getRouter(sessionId)
|
||||
const { transport, params } = await createTransport(router, requestHost)
|
||||
|
||||
if (isProducer) {
|
||||
updateLiveSession(sessionId, {
|
||||
transportId: transport.id,
|
||||
routerId: router.id,
|
||||
})
|
||||
}
|
||||
if (isProducer) {
|
||||
try {
|
||||
await updateLiveSession(sessionId, {
|
||||
transportId: transport.id,
|
||||
routerId: router.id,
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
if (err.message === 'Session not found') {
|
||||
throw createError({ statusCode: 404, message: 'Session not found' })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
return params
|
||||
return params
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getLiveSession } from '../../../utils/liveSessions.js'
|
||||
import { getRouter } from '../../../utils/mediasoup.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAuth(event)
|
||||
const user = requireAuth(event)
|
||||
const sessionId = getQuery(event).sessionId
|
||||
|
||||
if (!sessionId) {
|
||||
@@ -15,6 +15,11 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 404, message: 'Session not found' })
|
||||
}
|
||||
|
||||
// Only session owner or admin/leader can access
|
||||
if (session.userId !== user.id && user.role !== 'admin' && user.role !== 'leader') {
|
||||
throw createError({ statusCode: 403, message: 'Forbidden' })
|
||||
}
|
||||
|
||||
const router = await getRouter(sessionId)
|
||||
return router.rtpCapabilities
|
||||
})
|
||||
|
||||
@@ -6,7 +6,14 @@ import { requireAuth } from '../../utils/authHelpers.js'
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
if (!user.avatar_path) return { ok: true }
|
||||
const path = join(getAvatarsDir(), user.avatar_path)
|
||||
|
||||
// Validate avatar path to prevent path traversal attacks
|
||||
const filename = user.avatar_path
|
||||
if (!filename || !/^[a-f0-9-]+\.(?:jpg|jpeg|png)$/i.test(filename)) {
|
||||
throw createError({ statusCode: 400, message: 'Invalid avatar path' })
|
||||
}
|
||||
|
||||
const path = join(getAvatarsDir(), filename)
|
||||
await unlink(path).catch(() => {})
|
||||
const { run } = await getDb()
|
||||
await run('UPDATE users SET avatar_path = NULL WHERE id = ?', [user.id])
|
||||
|
||||
@@ -8,8 +8,15 @@ const MIME = Object.freeze({ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
if (!user.avatar_path) throw createError({ statusCode: 404, message: 'No avatar' })
|
||||
const path = join(getAvatarsDir(), user.avatar_path)
|
||||
const ext = user.avatar_path.split('.').pop()?.toLowerCase()
|
||||
|
||||
// Validate avatar path to prevent path traversal attacks
|
||||
const filename = user.avatar_path
|
||||
if (!filename || !/^[a-f0-9-]+\.(?:jpg|jpeg|png)$/i.test(filename)) {
|
||||
throw createError({ statusCode: 400, message: 'Invalid avatar path' })
|
||||
}
|
||||
|
||||
const path = join(getAvatarsDir(), filename)
|
||||
const ext = filename.split('.').pop()?.toLowerCase()
|
||||
const mime = MIME[ext] ?? 'application/octet-stream'
|
||||
try {
|
||||
const buf = await readFile(path)
|
||||
|
||||
@@ -8,6 +8,24 @@ const MAX_SIZE = 2 * 1024 * 1024
|
||||
const ALLOWED_TYPES = Object.freeze(['image/jpeg', 'image/png'])
|
||||
const EXT_BY_MIME = Object.freeze({ 'image/jpeg': 'jpg', 'image/png': 'png' })
|
||||
|
||||
/**
|
||||
* Validate image content using magic bytes to prevent MIME type spoofing.
|
||||
* @param {Buffer} buffer - File data buffer
|
||||
* @returns {string|null} Detected MIME type or null if invalid
|
||||
*/
|
||||
function validateImageContent(buffer) {
|
||||
if (!buffer || buffer.length < 8) return null
|
||||
// JPEG: FF D8 FF
|
||||
if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) {
|
||||
return 'image/jpeg'
|
||||
}
|
||||
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
||||
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) {
|
||||
return 'image/png'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
const form = await readMultipartFormData(event)
|
||||
@@ -16,7 +34,14 @@ export default defineEventHandler(async (event) => {
|
||||
if (file.data.length > MAX_SIZE) throw createError({ statusCode: 400, message: 'File too large' })
|
||||
const mime = file.type ?? ''
|
||||
if (!ALLOWED_TYPES.includes(mime)) throw createError({ statusCode: 400, message: 'Invalid type; use JPEG or PNG' })
|
||||
const ext = EXT_BY_MIME[mime] ?? 'jpg'
|
||||
|
||||
// Validate file content matches declared MIME type
|
||||
const actualMime = validateImageContent(file.data)
|
||||
if (!actualMime || actualMime !== mime) {
|
||||
throw createError({ statusCode: 400, message: 'File content does not match declared type' })
|
||||
}
|
||||
|
||||
const ext = EXT_BY_MIME[actualMime] ?? 'jpg'
|
||||
const filename = `${user.id}.${ext}`
|
||||
const dir = getAvatarsDir()
|
||||
const path = join(dir, filename)
|
||||
|
||||
26
server/api/me/cot-password.put.js
Normal file
26
server/api/me/cot-password.put.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { getDb } from '../../utils/db.js'
|
||||
import { requireAuth } from '../../utils/authHelpers.js'
|
||||
import { hashPassword } from '../../utils/password.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const currentUser = requireAuth(event)
|
||||
const body = await readBody(event).catch(() => ({}))
|
||||
const password = body?.password
|
||||
|
||||
if (typeof password !== 'string' || password.length < 1) {
|
||||
throw createError({ statusCode: 400, message: 'Password is required' })
|
||||
}
|
||||
|
||||
const { get, run } = await getDb()
|
||||
const user = await get(
|
||||
'SELECT id, auth_provider FROM users WHERE id = ?',
|
||||
[currentUser.id],
|
||||
)
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 404, message: 'User not found' })
|
||||
}
|
||||
|
||||
const hash = hashPassword(password)
|
||||
await run('UPDATE users SET cot_password_hash = ? WHERE id = ?', [hash, currentUser.id])
|
||||
return { ok: true }
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getDb } from '../utils/db.js'
|
||||
import { requireAuth } from '../utils/authHelpers.js'
|
||||
import { POI_ICON_TYPES } from '../utils/poiConstants.js'
|
||||
import { POI_ICON_TYPES } from '../utils/validation.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAuth(event, { role: 'adminOrLeader' })
|
||||
|
||||
@@ -1,39 +1,37 @@
|
||||
import { getDb } from '../../utils/db.js'
|
||||
import { requireAuth } from '../../utils/authHelpers.js'
|
||||
import { POI_ICON_TYPES } from '../../utils/poiConstants.js'
|
||||
import { POI_ICON_TYPES } from '../../utils/validation.js'
|
||||
import { buildUpdateQuery } from '../../utils/queryBuilder.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAuth(event, { role: 'adminOrLeader' })
|
||||
const id = event.context.params?.id
|
||||
if (!id) throw createError({ statusCode: 400, message: 'id required' })
|
||||
const body = (await readBody(event)) || {}
|
||||
const updates = []
|
||||
const params = []
|
||||
const updates = {}
|
||||
if (typeof body.label === 'string') {
|
||||
updates.push('label = ?')
|
||||
params.push(body.label.trim())
|
||||
updates.label = body.label.trim()
|
||||
}
|
||||
if (POI_ICON_TYPES.includes(body.iconType)) {
|
||||
updates.push('icon_type = ?')
|
||||
params.push(body.iconType)
|
||||
updates.icon_type = body.iconType
|
||||
}
|
||||
if (Number.isFinite(body.lat)) {
|
||||
updates.push('lat = ?')
|
||||
params.push(body.lat)
|
||||
updates.lat = body.lat
|
||||
}
|
||||
if (Number.isFinite(body.lng)) {
|
||||
updates.push('lng = ?')
|
||||
params.push(body.lng)
|
||||
updates.lng = body.lng
|
||||
}
|
||||
if (updates.length === 0) {
|
||||
if (Object.keys(updates).length === 0) {
|
||||
const { get } = await getDb()
|
||||
const row = await get('SELECT id, lat, lng, label, icon_type FROM pois WHERE id = ?', [id])
|
||||
if (!row) throw createError({ statusCode: 404, message: 'POI not found' })
|
||||
return row
|
||||
}
|
||||
params.push(id)
|
||||
const { run, get } = await getDb()
|
||||
await run(`UPDATE pois SET ${updates.join(', ')} WHERE id = ?`, params)
|
||||
const { query, params } = buildUpdateQuery('pois', null, updates)
|
||||
if (query) {
|
||||
await run(query, [...params, id])
|
||||
}
|
||||
const row = await get('SELECT id, lat, lng, label, icon_type FROM pois WHERE id = ?', [id])
|
||||
if (!row) throw createError({ statusCode: 404, message: 'POI not found' })
|
||||
return row
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getDb } from '../utils/db.js'
|
||||
import { getDb, withTransaction } from '../utils/db.js'
|
||||
import { requireAuth } from '../utils/authHelpers.js'
|
||||
import { hashPassword } from '../utils/password.js'
|
||||
|
||||
@@ -21,18 +21,20 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 400, message: 'role must be admin, leader, or member' })
|
||||
}
|
||||
|
||||
const { run, get } = await getDb()
|
||||
const existing = await get('SELECT id FROM users WHERE identifier = ?', [identifier])
|
||||
if (existing) {
|
||||
throw createError({ statusCode: 409, message: 'Identifier already in use' })
|
||||
}
|
||||
const db = await getDb()
|
||||
return withTransaction(db, async ({ run, get }) => {
|
||||
const existing = await get('SELECT id FROM users WHERE identifier = ?', [identifier])
|
||||
if (existing) {
|
||||
throw createError({ statusCode: 409, message: 'Identifier already in use' })
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID()
|
||||
const now = new Date().toISOString()
|
||||
await run(
|
||||
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, identifier, hashPassword(password), role, now, 'local', null, null],
|
||||
)
|
||||
const user = await get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id])
|
||||
return user
|
||||
const id = crypto.randomUUID()
|
||||
const now = new Date().toISOString()
|
||||
await run(
|
||||
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, identifier, hashPassword(password), role, now, 'local', null, null],
|
||||
)
|
||||
const user = await get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id])
|
||||
return user
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getDb } from '../../utils/db.js'
|
||||
import { getDb, withTransaction } from '../../utils/db.js'
|
||||
import { requireAuth } from '../../utils/authHelpers.js'
|
||||
import { hashPassword } from '../../utils/password.js'
|
||||
import { buildUpdateQuery } from '../../utils/queryBuilder.js'
|
||||
|
||||
const ROLES = ['admin', 'leader', 'member']
|
||||
|
||||
@@ -9,52 +10,52 @@ export default defineEventHandler(async (event) => {
|
||||
const id = event.context.params?.id
|
||||
if (!id) throw createError({ statusCode: 400, message: 'id required' })
|
||||
const body = await readBody(event)
|
||||
const { run, get } = await getDb()
|
||||
const db = await getDb()
|
||||
|
||||
const user = await get('SELECT id, identifier, role, auth_provider, password_hash FROM users WHERE id = ?', [id])
|
||||
if (!user) throw createError({ statusCode: 404, message: 'User not found' })
|
||||
return withTransaction(db, async ({ run, get }) => {
|
||||
const user = await get('SELECT id, identifier, role, auth_provider, password_hash FROM users WHERE id = ?', [id])
|
||||
if (!user) throw createError({ statusCode: 404, message: 'User not found' })
|
||||
|
||||
const updates = []
|
||||
const params = []
|
||||
const updates = {}
|
||||
|
||||
if (body?.role !== undefined) {
|
||||
const role = body.role
|
||||
if (!role || !ROLES.includes(role)) {
|
||||
throw createError({ statusCode: 400, message: 'role must be admin, leader, or member' })
|
||||
}
|
||||
updates.push('role = ?')
|
||||
params.push(role)
|
||||
}
|
||||
|
||||
if (user.auth_provider === 'local') {
|
||||
if (body?.identifier !== undefined) {
|
||||
const identifier = body.identifier?.trim()
|
||||
if (!identifier || identifier.length < 1) {
|
||||
throw createError({ statusCode: 400, message: 'identifier cannot be empty' })
|
||||
if (body?.role !== undefined) {
|
||||
const role = body.role
|
||||
if (!role || !ROLES.includes(role)) {
|
||||
throw createError({ statusCode: 400, message: 'role must be admin, leader, or member' })
|
||||
}
|
||||
const existing = await get('SELECT id FROM users WHERE identifier = ? AND id != ?', [identifier, id])
|
||||
if (existing) {
|
||||
throw createError({ statusCode: 409, message: 'Identifier already in use' })
|
||||
}
|
||||
updates.push('identifier = ?')
|
||||
params.push(identifier)
|
||||
updates.role = role
|
||||
}
|
||||
if (body?.password !== undefined && body.password !== '') {
|
||||
const password = body.password
|
||||
if (typeof password !== 'string' || password.length < 1) {
|
||||
throw createError({ statusCode: 400, message: 'password cannot be empty' })
|
||||
|
||||
if (user.auth_provider === 'local') {
|
||||
if (body?.identifier !== undefined) {
|
||||
const identifier = body.identifier?.trim()
|
||||
if (!identifier || identifier.length < 1) {
|
||||
throw createError({ statusCode: 400, message: 'identifier cannot be empty' })
|
||||
}
|
||||
const existing = await get('SELECT id FROM users WHERE identifier = ? AND id != ?', [identifier, id])
|
||||
if (existing) {
|
||||
throw createError({ statusCode: 409, message: 'Identifier already in use' })
|
||||
}
|
||||
updates.identifier = identifier
|
||||
}
|
||||
if (body?.password !== undefined && body.password !== '') {
|
||||
const password = body.password
|
||||
if (typeof password !== 'string' || password.length < 1) {
|
||||
throw createError({ statusCode: 400, message: 'password cannot be empty' })
|
||||
}
|
||||
updates.password_hash = hashPassword(password)
|
||||
}
|
||||
updates.push('password_hash = ?')
|
||||
params.push(hashPassword(password))
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return { id: user.id, identifier: user.identifier, role: user.role, auth_provider: user.auth_provider ?? 'local' }
|
||||
}
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return { id: user.id, identifier: user.identifier, role: user.role, auth_provider: user.auth_provider ?? 'local' }
|
||||
}
|
||||
|
||||
params.push(id)
|
||||
await run(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, params)
|
||||
const updated = await get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id])
|
||||
return updated
|
||||
const { query, params } = buildUpdateQuery('users', null, updates)
|
||||
if (query) {
|
||||
await run(query, [...params, id])
|
||||
}
|
||||
const updated = await get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id])
|
||||
return updated
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getCookie } from 'h3'
|
||||
import { getDb } from '../utils/db.js'
|
||||
import { skipAuth } from '../utils/authSkipPaths.js'
|
||||
import { skipAuth } from '../utils/authHelpers.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
if (skipAuth(event.path)) return
|
||||
|
||||
262
server/plugins/cot.js
Normal file
262
server/plugins/cot.js
Normal file
@@ -0,0 +1,262 @@
|
||||
import { createServer as createTcpServer } from 'node:net'
|
||||
import { createServer as createTlsServer } from 'node:tls'
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { updateFromCot } from '../utils/cotStore.js'
|
||||
import { parseTakStreamFrame, parseTraditionalXmlFrame, parseCotPayload } from '../utils/cotParser.js'
|
||||
import { validateCotAuth } from '../utils/cotAuth.js'
|
||||
import { getCotSslPaths, getCotPort } from '../utils/cotSsl.js'
|
||||
import { registerCleanup } from '../utils/shutdown.js'
|
||||
import { COT_AUTH_TIMEOUT_MS } from '../utils/constants.js'
|
||||
import { acquire } from '../utils/asyncLock.js'
|
||||
|
||||
const serverState = {
|
||||
tcpServer: null,
|
||||
tlsServer: null,
|
||||
}
|
||||
const relaySet = new Set()
|
||||
const allSockets = new Set()
|
||||
const socketBuffers = new WeakMap()
|
||||
const socketAuthTimeout = new WeakMap()
|
||||
|
||||
function clearAuthTimeout(socket) {
|
||||
const t = socketAuthTimeout.get(socket)
|
||||
if (t) {
|
||||
clearTimeout(t)
|
||||
socketAuthTimeout.delete(socket)
|
||||
}
|
||||
}
|
||||
|
||||
function removeFromRelay(socket) {
|
||||
relaySet.delete(socket)
|
||||
allSockets.delete(socket)
|
||||
clearAuthTimeout(socket)
|
||||
socketBuffers.delete(socket)
|
||||
}
|
||||
|
||||
function broadcast(senderSocket, rawMessage) {
|
||||
for (const s of relaySet) {
|
||||
if (s !== senderSocket && !s.destroyed && s.writable) {
|
||||
try {
|
||||
s.write(rawMessage)
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[cot] Broadcast write error:', err?.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const createPreview = (payload) => {
|
||||
try {
|
||||
const str = payload.toString('utf8')
|
||||
if (str.startsWith('<')) {
|
||||
const s = str.length <= 120 ? str : str.slice(0, 120) + '...'
|
||||
// eslint-disable-next-line no-control-regex -- sanitize control chars for log preview
|
||||
return s.replace(/[\u0000-\u0008\v\f\u000E-\u001F]/g, '.')
|
||||
}
|
||||
return 'hex:' + payload.subarray(0, Math.min(40, payload.length)).toString('hex')
|
||||
}
|
||||
catch {
|
||||
return 'hex:' + payload.subarray(0, Math.min(40, payload.length)).toString('hex')
|
||||
}
|
||||
}
|
||||
|
||||
async function processFrame(socket, rawMessage, payload, authenticated) {
|
||||
const requireAuth = socket._cotRequireAuth !== false
|
||||
const debug = socket._cotDebug === true
|
||||
const parsed = parseCotPayload(payload)
|
||||
if (debug) {
|
||||
const preview = createPreview(payload)
|
||||
console.log('[cot] payload length:', payload.length, 'parsed:', parsed ? parsed.type : null, 'preview:', preview)
|
||||
}
|
||||
if (!parsed) return
|
||||
|
||||
if (parsed.type === 'auth') {
|
||||
if (authenticated) return
|
||||
console.log('[cot] auth attempt username=', parsed.username)
|
||||
// Use lock per socket to prevent concurrent auth attempts
|
||||
const socketKey = `cot-auth-${socket.remoteAddress || 'unknown'}-${socket.remotePort || 0}`
|
||||
await acquire(socketKey, async () => {
|
||||
// Re-check authentication state after acquiring lock
|
||||
if (socket._cotAuthenticated || socket.destroyed) return
|
||||
try {
|
||||
const valid = await validateCotAuth(parsed.username, parsed.password)
|
||||
console.log('[cot] auth result valid=', valid, 'for username=', parsed.username)
|
||||
if (!socket.writable || socket.destroyed) return
|
||||
if (valid) {
|
||||
clearAuthTimeout(socket)
|
||||
relaySet.add(socket)
|
||||
socket._cotAuthenticated = true
|
||||
}
|
||||
else {
|
||||
socket.destroy()
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.log('[cot] auth validation error:', err?.message)
|
||||
if (!socket.destroyed) socket.destroy()
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.log('[cot] auth lock error:', err?.message)
|
||||
if (!socket.destroyed) socket.destroy()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (parsed.type === 'cot') {
|
||||
if (requireAuth && !authenticated) {
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
updateFromCot(parsed).catch((err) => {
|
||||
console.error('[cot] Error updating from CoT:', err?.message)
|
||||
})
|
||||
if (authenticated) broadcast(socket, rawMessage)
|
||||
}
|
||||
}
|
||||
|
||||
const parseFrame = (buf) => {
|
||||
const takResult = parseTakStreamFrame(buf)
|
||||
if (takResult) return { result: takResult, frameType: 'tak' }
|
||||
if (buf[0] === 0x3C) {
|
||||
const xmlResult = parseTraditionalXmlFrame(buf)
|
||||
if (xmlResult) return { result: xmlResult, frameType: 'traditional' }
|
||||
}
|
||||
return { result: null, frameType: null }
|
||||
}
|
||||
|
||||
const processBufferedData = async (socket, buf, authenticated) => {
|
||||
if (buf.length === 0) return buf
|
||||
const { result, frameType } = parseFrame(buf)
|
||||
if (result && socket._cotDebug) {
|
||||
console.log('[cot] frame parsed as', frameType, 'bytesConsumed=', result.bytesConsumed)
|
||||
}
|
||||
if (!result) return buf
|
||||
const { payload, bytesConsumed } = result
|
||||
const rawMessage = buf.subarray(0, bytesConsumed)
|
||||
await processFrame(socket, rawMessage, payload, authenticated)
|
||||
if (socket.destroyed) return null
|
||||
const remainingBuf = buf.subarray(bytesConsumed)
|
||||
socketBuffers.set(socket, remainingBuf)
|
||||
return processBufferedData(socket, remainingBuf, authenticated)
|
||||
}
|
||||
|
||||
async function onData(socket, data) {
|
||||
const existingBuf = socketBuffers.get(socket)
|
||||
const buf = Buffer.concat([existingBuf || Buffer.alloc(0), data])
|
||||
socketBuffers.set(socket, buf)
|
||||
const authenticated = Boolean(socket._cotAuthenticated)
|
||||
|
||||
if (socket._cotDebug && buf.length > 0 && !socket._cotFirstChunkLogged) {
|
||||
socket._cotFirstChunkLogged = true
|
||||
const hex = buf.subarray(0, Math.min(80, buf.length)).toString('hex')
|
||||
console.log('[cot] first chunk len=', buf.length, 'first bytes (hex):', hex, 'starts with 0xBF:', buf[0] === 0xBF, 'starts with <:', buf[0] === 0x3C)
|
||||
}
|
||||
await processBufferedData(socket, buf, authenticated)
|
||||
}
|
||||
|
||||
function setupSocket(socket, tls = false) {
|
||||
const remote = socket.remoteAddress || 'unknown'
|
||||
console.log('[cot] client connected', tls ? '(TLS)' : '(TCP)', 'from', remote)
|
||||
allSockets.add(socket)
|
||||
const config = useRuntimeConfig()
|
||||
socket._cotDebug = Boolean(config.cotDebug)
|
||||
socket._cotRequireAuth = config.cotRequireAuth !== false
|
||||
if (socket._cotRequireAuth) {
|
||||
const timeout = setTimeout(() => {
|
||||
if (!socket._cotAuthenticated && !socket.destroyed) {
|
||||
console.log('[cot] auth timeout, closing connection from', remote)
|
||||
socket.destroy()
|
||||
}
|
||||
}, COT_AUTH_TIMEOUT_MS)
|
||||
socketAuthTimeout.set(socket, timeout)
|
||||
}
|
||||
else {
|
||||
socket._cotAuthenticated = true
|
||||
relaySet.add(socket)
|
||||
}
|
||||
|
||||
socket.on('data', data => onData(socket, data))
|
||||
socket.on('error', (err) => {
|
||||
console.error('[cot] Socket error:', err?.message)
|
||||
})
|
||||
socket.on('close', () => {
|
||||
console.log('[cot] client disconnected', socket._cotAuthenticated ? '(was authenticated)' : '', 'from', remote)
|
||||
removeFromRelay(socket)
|
||||
})
|
||||
}
|
||||
|
||||
function startCotServers() {
|
||||
const config = useRuntimeConfig()
|
||||
const { certPath, keyPath } = getCotSslPaths(config) || {}
|
||||
const hasTls = certPath && keyPath && existsSync(certPath) && existsSync(keyPath)
|
||||
const port = getCotPort()
|
||||
|
||||
try {
|
||||
if (hasTls) {
|
||||
const tlsOpts = {
|
||||
cert: readFileSync(certPath),
|
||||
key: readFileSync(keyPath),
|
||||
rejectUnauthorized: false,
|
||||
}
|
||||
serverState.tlsServer = createTlsServer(tlsOpts, socket => setupSocket(socket, true))
|
||||
serverState.tlsServer.on('error', err => console.error('[cot] TLS server error:', err?.message))
|
||||
serverState.tlsServer.listen(port, '0.0.0.0', () => {
|
||||
console.log('[cot] CoT server listening on 0.0.0.0:' + port + ' (TLS) - use this port in ATAK/iTAK and enable SSL')
|
||||
})
|
||||
}
|
||||
else {
|
||||
serverState.tcpServer = createTcpServer(socket => setupSocket(socket, false))
|
||||
serverState.tcpServer.on('error', err => console.error('[cot] TCP server error:', err?.message))
|
||||
serverState.tcpServer.listen(port, '0.0.0.0', () => {
|
||||
console.log('[cot] CoT server listening on 0.0.0.0:' + port + ' (plain TCP) - use this port in ATAK/iTAK with SSL disabled')
|
||||
})
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[cot] Failed to start CoT server:', err?.message)
|
||||
if (err?.code === 'EADDRINUSE') {
|
||||
console.error('[cot] Port', port, 'is already in use. Stop the other process or set COT_PORT to a different port.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
nitroApp.hooks.hook('ready', startCotServers)
|
||||
// Start immediately so CoT is up before first request in dev; ready may fire late in some setups.
|
||||
setImmediate(startCotServers)
|
||||
|
||||
const cleanupServers = () => {
|
||||
if (serverState.tcpServer) {
|
||||
serverState.tcpServer.close()
|
||||
serverState.tcpServer = null
|
||||
}
|
||||
if (serverState.tlsServer) {
|
||||
serverState.tlsServer.close()
|
||||
serverState.tlsServer = null
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupSockets = () => {
|
||||
for (const s of allSockets) {
|
||||
try {
|
||||
s.destroy()
|
||||
}
|
||||
catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
allSockets.clear()
|
||||
relaySet.clear()
|
||||
}
|
||||
|
||||
registerCleanup(async () => {
|
||||
cleanupSockets()
|
||||
cleanupServers()
|
||||
})
|
||||
|
||||
nitroApp.hooks.hook('close', async () => {
|
||||
cleanupSockets()
|
||||
cleanupServers()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { WebSocketServer } from 'ws'
|
||||
import { getDb } from '../utils/db.js'
|
||||
import { handleWebSocketMessage } from '../utils/webrtcSignaling.js'
|
||||
import { registerCleanup } from '../utils/shutdown.js'
|
||||
|
||||
function parseCookie(cookieHeader) {
|
||||
const cookies = {}
|
||||
@@ -79,8 +80,15 @@ export default defineNitroPlugin((nitroApp) => {
|
||||
callback(false, 401, 'Unauthorized')
|
||||
return
|
||||
}
|
||||
// Store user_id in request for later use
|
||||
// Get user role for authorization checks
|
||||
const user = await get('SELECT id, role FROM users WHERE id = ?', [session.user_id])
|
||||
if (!user) {
|
||||
callback(false, 401, 'Unauthorized')
|
||||
return
|
||||
}
|
||||
// Store user_id and role in request for later use
|
||||
info.req.userId = session.user_id
|
||||
info.req.userRole = user.role
|
||||
callback(true)
|
||||
}
|
||||
catch (err) {
|
||||
@@ -92,7 +100,8 @@ export default defineNitroPlugin((nitroApp) => {
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
const userId = req.userId
|
||||
if (!userId) {
|
||||
const userRole = req.userRole
|
||||
if (!userId || !userRole) {
|
||||
ws.close(1008, 'Unauthorized')
|
||||
return
|
||||
}
|
||||
@@ -109,6 +118,20 @@ export default defineNitroPlugin((nitroApp) => {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify user has access to this session (authorization check per message)
|
||||
const { getLiveSession } = await import('../utils/liveSessions.js')
|
||||
const session = getLiveSession(sessionId)
|
||||
if (!session) {
|
||||
ws.send(JSON.stringify({ error: 'Session not found' }))
|
||||
return
|
||||
}
|
||||
|
||||
// Only session owner or admin/leader can access the session
|
||||
if (session.userId !== userId && userRole !== 'admin' && userRole !== 'leader') {
|
||||
ws.send(JSON.stringify({ error: 'Forbidden' }))
|
||||
return
|
||||
}
|
||||
|
||||
// Track session connection
|
||||
if (currentSessionId !== sessionId) {
|
||||
if (currentSessionId) {
|
||||
@@ -142,6 +165,13 @@ export default defineNitroPlugin((nitroApp) => {
|
||||
})
|
||||
|
||||
console.log('[websocket] WebSocket server started on /ws')
|
||||
|
||||
registerCleanup(async () => {
|
||||
if (wss) {
|
||||
wss.close()
|
||||
wss = null
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
nitroApp.hooks.hook('close', () => {
|
||||
|
||||
@@ -1 +1,9 @@
|
||||
export default defineEventHandler(() => ({ status: 'ready' }))
|
||||
import { healthCheck } from '../../utils/db.js'
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
const health = await healthCheck()
|
||||
if (!health.healthy) {
|
||||
throw createError({ statusCode: 503, message: 'Database not ready' })
|
||||
}
|
||||
return { status: 'ready' }
|
||||
})
|
||||
|
||||
47
server/utils/asyncLock.js
Normal file
47
server/utils/asyncLock.js
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Async lock utility - Promise-based mutex per key.
|
||||
* Ensures only one async operation executes per key at a time.
|
||||
*/
|
||||
|
||||
const locks = new Map()
|
||||
|
||||
/**
|
||||
* Get or create a queue for a lock key.
|
||||
* @param {string} lockKey - Lock key
|
||||
* @returns {Promise<any>} Existing or new queue promise
|
||||
*/
|
||||
const getOrCreateQueue = (lockKey) => {
|
||||
const existingQueue = locks.get(lockKey)
|
||||
if (existingQueue) return existingQueue
|
||||
const newQueue = Promise.resolve()
|
||||
locks.set(lockKey, newQueue)
|
||||
return newQueue
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a lock for a key and execute callback.
|
||||
* Only one callback per key executes at a time.
|
||||
* @param {string} key - Lock key
|
||||
* @param {Function} callback - Async function to execute
|
||||
* @returns {Promise<any>} Result of callback
|
||||
*/
|
||||
export async function acquire(key, callback) {
|
||||
const lockKey = String(key)
|
||||
const queue = getOrCreateQueue(lockKey)
|
||||
|
||||
const next = queue.then(() => callback()).finally(() => {
|
||||
if (locks.get(lockKey) === next) {
|
||||
locks.delete(lockKey)
|
||||
}
|
||||
})
|
||||
|
||||
locks.set(lockKey, next)
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all locks (for testing).
|
||||
*/
|
||||
export function clearLocks() {
|
||||
locks.clear()
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export function getAuthConfig() {
|
||||
const hasOidc = !!(process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET)
|
||||
const label = process.env.OIDC_LABEL?.trim() || (hasOidc ? 'Sign in with OIDC' : '')
|
||||
return Object.freeze({ oidc: { enabled: hasOidc, label } })
|
||||
}
|
||||
@@ -8,3 +8,26 @@ export function requireAuth(event, opts = {}) {
|
||||
if (role === 'adminOrLeader' && !ROLES_ADMIN_OR_LEADER.includes(user.role)) throw createError({ statusCode: 403, message: 'Forbidden' })
|
||||
return user
|
||||
}
|
||||
|
||||
// Auth path utilities
|
||||
export const SKIP_PATHS = Object.freeze([
|
||||
'/api/auth/login',
|
||||
'/api/auth/logout',
|
||||
'/api/auth/config',
|
||||
'/api/auth/oidc/authorize',
|
||||
'/api/auth/oidc/callback',
|
||||
])
|
||||
|
||||
export const PROTECTED_PATH_PREFIXES = Object.freeze([
|
||||
'/api/cameras',
|
||||
'/api/devices',
|
||||
'/api/live',
|
||||
'/api/me',
|
||||
'/api/pois',
|
||||
'/api/users',
|
||||
])
|
||||
|
||||
export function skipAuth(path) {
|
||||
if (path.startsWith('/api/health') || path === '/health') return true
|
||||
return SKIP_PATHS.some(p => path === p || path.startsWith(p + '/'))
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
/** Paths that skip auth (no session required). Do not add if any handler uses requireAuth. */
|
||||
export const SKIP_PATHS = Object.freeze([
|
||||
'/api/auth/login',
|
||||
'/api/auth/logout',
|
||||
'/api/auth/config',
|
||||
'/api/auth/oidc/authorize',
|
||||
'/api/auth/oidc/callback',
|
||||
])
|
||||
|
||||
/** Path prefixes for protected routes. Used by tests to ensure they're never in SKIP_PATHS. */
|
||||
export const PROTECTED_PATH_PREFIXES = Object.freeze([
|
||||
'/api/cameras',
|
||||
'/api/devices',
|
||||
'/api/live',
|
||||
'/api/me',
|
||||
'/api/pois',
|
||||
'/api/users',
|
||||
])
|
||||
|
||||
export function skipAuth(path) {
|
||||
if (path.startsWith('/api/health') || path === '/health') return true
|
||||
return SKIP_PATHS.some(p => path === p || path.startsWith(p + '/'))
|
||||
}
|
||||
26
server/utils/bootstrap.js
vendored
26
server/utils/bootstrap.js
vendored
@@ -1,26 +0,0 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { hashPassword } from './password.js'
|
||||
|
||||
const PASSWORD_CHARS = Object.freeze('abcdefghjkmnopqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789')
|
||||
|
||||
const generateRandomPassword = () =>
|
||||
Array.from(randomBytes(14), b => PASSWORD_CHARS[b % PASSWORD_CHARS.length]).join('')
|
||||
|
||||
export async function bootstrapAdmin(run, get) {
|
||||
const row = await get('SELECT COUNT(*) as n FROM users')
|
||||
if (row?.n !== 0) return
|
||||
|
||||
const email = process.env.BOOTSTRAP_EMAIL?.trim()
|
||||
const password = process.env.BOOTSTRAP_PASSWORD
|
||||
const identifier = (email && password) ? email : 'admin'
|
||||
const plainPassword = (email && password) ? password : generateRandomPassword()
|
||||
|
||||
await run(
|
||||
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[crypto.randomUUID(), identifier, hashPassword(plainPassword), 'admin', new Date().toISOString(), 'local', null, null],
|
||||
)
|
||||
|
||||
if (!email || !password) {
|
||||
console.log(`\n[KestrelOS] No bootstrap admin configured. Default admin created. Sign in at /login with:\n\n Identifier: ${identifier}\n Password: ${plainPassword}\n\n Set BOOTSTRAP_EMAIL and BOOTSTRAP_PASSWORD to use your own credentials on first run.\n`)
|
||||
}
|
||||
}
|
||||
30
server/utils/constants.js
Normal file
30
server/utils/constants.js
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Application constants with environment variable support.
|
||||
*/
|
||||
|
||||
// Timeouts (milliseconds)
|
||||
export const COT_AUTH_TIMEOUT_MS = Number(process.env.COT_AUTH_TIMEOUT_MS) || 15_000
|
||||
export const LIVE_SESSION_TTL_MS = Number(process.env.LIVE_SESSION_TTL_MS) || 60_000
|
||||
export const COT_ENTITY_TTL_MS = Number(process.env.COT_ENTITY_TTL_MS) || 90_000
|
||||
export const POLL_INTERVAL_MS = Number(process.env.POLL_INTERVAL_MS) || 1500
|
||||
export const SHUTDOWN_TIMEOUT_MS = Number(process.env.SHUTDOWN_TIMEOUT_MS) || 30_000
|
||||
|
||||
// Ports
|
||||
export const COT_PORT = Number(process.env.COT_PORT) || 8089
|
||||
export const WEBSOCKET_PATH = process.env.WEBSOCKET_PATH || '/ws'
|
||||
|
||||
// Limits
|
||||
export const MAX_PAYLOAD_BYTES = Number(process.env.MAX_PAYLOAD_BYTES) || 64 * 1024
|
||||
export const MAX_STRING_LENGTH = Number(process.env.MAX_STRING_LENGTH) || 1000
|
||||
export const MAX_IDENTIFIER_LENGTH = Number(process.env.MAX_IDENTIFIER_LENGTH) || 255
|
||||
|
||||
// Mediasoup
|
||||
export const MEDIASOUP_RTC_MIN_PORT = Number(process.env.MEDIASOUP_RTC_MIN_PORT) || 40000
|
||||
export const MEDIASOUP_RTC_MAX_PORT = Number(process.env.MEDIASOUP_RTC_MAX_PORT) || 49999
|
||||
|
||||
// Session
|
||||
const [MIN_DAYS, MAX_DAYS, DEFAULT_DAYS] = [1, 365, 7]
|
||||
export function getSessionMaxAgeDays() {
|
||||
const raw = Number.parseInt(process.env.SESSION_MAX_AGE_DAYS ?? '', 10)
|
||||
return Number.isFinite(raw) ? Math.max(MIN_DAYS, Math.min(MAX_DAYS, raw)) : DEFAULT_DAYS
|
||||
}
|
||||
25
server/utils/cotAuth.js
Normal file
25
server/utils/cotAuth.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { getDb } from './db.js'
|
||||
import { verifyPassword } from './password.js'
|
||||
|
||||
/**
|
||||
* Validate CoT auth: local users use password_hash; OIDC users use cot_password_hash (ATAK password).
|
||||
* @param {string} identifier - KestrelOS identifier (username)
|
||||
* @param {string} password - Plain password from CoT auth
|
||||
* @returns {Promise<boolean>} True if valid
|
||||
*/
|
||||
export async function validateCotAuth(identifier, password) {
|
||||
const id = typeof identifier === 'string' ? identifier.trim() : ''
|
||||
if (!id || typeof password !== 'string') return false
|
||||
|
||||
const { get } = await getDb()
|
||||
const user = await get(
|
||||
'SELECT auth_provider, password_hash, cot_password_hash FROM users WHERE identifier = ?',
|
||||
[id],
|
||||
)
|
||||
if (!user) return false
|
||||
|
||||
const hash = user.auth_provider === 'local' ? user.password_hash : user.cot_password_hash
|
||||
if (!hash) return false
|
||||
|
||||
return verifyPassword(password, hash)
|
||||
}
|
||||
151
server/utils/cotParser.js
Normal file
151
server/utils/cotParser.js
Normal file
@@ -0,0 +1,151 @@
|
||||
import { XMLParser } from 'fast-xml-parser'
|
||||
import { MAX_PAYLOAD_BYTES } from './constants.js'
|
||||
|
||||
// CoT protocol detection constants
|
||||
export const COT_FIRST_BYTE_TAK = 0xBF
|
||||
export const COT_FIRST_BYTE_XML = 0x3C
|
||||
|
||||
/** @param {number} byte - First byte of stream. @returns {boolean} */
|
||||
export function isCotFirstByte(byte) {
|
||||
return byte === COT_FIRST_BYTE_TAK || byte === COT_FIRST_BYTE_XML
|
||||
}
|
||||
|
||||
const TRADITIONAL_DELIMITER = Buffer.from('</event>', 'utf8')
|
||||
|
||||
/**
|
||||
* @param {Buffer} buf
|
||||
* @param {number} offset
|
||||
* @param {number} value - Accumulated value
|
||||
* @param {number} shift - Current bit shift
|
||||
* @param {number} bytesRead - Bytes consumed so far
|
||||
* @returns {{ value: number, bytesRead: number }} Decoded varint and bytes consumed.
|
||||
*/
|
||||
function readVarint(buf, offset, value = 0, shift = 0, bytesRead = 0) {
|
||||
if (offset + bytesRead >= buf.length) return { value, bytesRead }
|
||||
const b = buf[offset + bytesRead]
|
||||
const newValue = value + ((b & 0x7F) << shift)
|
||||
const newBytesRead = bytesRead + 1
|
||||
if ((b & 0x80) === 0) return { value: newValue, bytesRead: newBytesRead }
|
||||
const newShift = shift + 7
|
||||
if (newShift > 28) return { value: 0, bytesRead: 0 }
|
||||
return readVarint(buf, offset, newValue, newShift, newBytesRead)
|
||||
}
|
||||
|
||||
/**
|
||||
* TAK stream frame: 0xBF, varint length, payload.
|
||||
* @param {Buffer} buf
|
||||
* @returns {{ payload: Buffer, bytesConsumed: number } | null} Frame or null if incomplete/invalid.
|
||||
*/
|
||||
export function parseTakStreamFrame(buf) {
|
||||
if (!buf || buf.length < 2 || buf[0] !== COT_FIRST_BYTE_TAK) return null
|
||||
const { value: length, bytesRead } = readVarint(buf, 1)
|
||||
if (length < 0 || length > MAX_PAYLOAD_BYTES) return null
|
||||
const bytesConsumed = 1 + bytesRead + length
|
||||
if (buf.length < bytesConsumed) return null
|
||||
return { payload: buf.subarray(1 + bytesRead, bytesConsumed), bytesConsumed }
|
||||
}
|
||||
|
||||
/**
|
||||
* Traditional CoT: one XML message delimited by </event>.
|
||||
* @param {Buffer} buf
|
||||
* @returns {{ payload: Buffer, bytesConsumed: number } | null} Frame or null if incomplete.
|
||||
*/
|
||||
export function parseTraditionalXmlFrame(buf) {
|
||||
if (!buf || buf.length < 8 || buf[0] !== COT_FIRST_BYTE_XML) return null
|
||||
const idx = buf.indexOf(TRADITIONAL_DELIMITER)
|
||||
if (idx === -1) return null
|
||||
const bytesConsumed = idx + TRADITIONAL_DELIMITER.length
|
||||
if (bytesConsumed > MAX_PAYLOAD_BYTES) return null
|
||||
return { payload: buf.subarray(0, bytesConsumed), bytesConsumed }
|
||||
}
|
||||
|
||||
const xmlParser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
parseTagValue: false,
|
||||
ignoreDeclaration: true,
|
||||
ignorePiTags: true,
|
||||
processEntities: false, // Disable entity expansion to prevent XML bomb attacks
|
||||
maxAttributes: 100,
|
||||
parseAttributeValue: false,
|
||||
trimValues: true,
|
||||
parseTrueNumberOnly: false,
|
||||
arrayMode: false,
|
||||
stopNodes: [], // Could add depth limit here if needed
|
||||
})
|
||||
|
||||
/**
|
||||
* Case-insensitive key lookup in nested object.
|
||||
* @returns {unknown} Found value or undefined.
|
||||
*/
|
||||
function findInObject(obj, key) {
|
||||
if (!obj || typeof obj !== 'object') return undefined
|
||||
const k = key.toLowerCase()
|
||||
for (const [name, val] of Object.entries(obj)) {
|
||||
if (name.toLowerCase() === k) return val
|
||||
if (typeof val === 'object' && val !== null) {
|
||||
const found = findInObject(val, key)
|
||||
if (found !== undefined) return found
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract { username, password } from detail.auth (or __auth / credentials).
|
||||
* @returns {{ username: string, password: string } | null} Credentials or null if missing/invalid.
|
||||
*/
|
||||
function extractAuth(parsed) {
|
||||
const detail = findInObject(parsed, 'detail')
|
||||
if (!detail || typeof detail !== 'object') return null
|
||||
const auth = findInObject(detail, 'auth') ?? findInObject(detail, '__auth') ?? findInObject(detail, 'credentials')
|
||||
if (!auth || typeof auth !== 'object') return null
|
||||
const username = auth['@_username'] ?? auth['@_Username'] ?? auth.username
|
||||
const password = auth['@_password'] ?? auth['@_Password'] ?? auth.password
|
||||
if (typeof username !== 'string' || typeof password !== 'string' || !username.trim()) return null
|
||||
return { username: username.trim(), password }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CoT XML payload into auth or position. Does not mutate payload.
|
||||
* @param {Buffer} payload - UTF-8 XML
|
||||
* @returns {{ type: 'auth', username: string, password: string } | { type: 'cot', id: string, lat: number, lng: number, label: string, eventType: string } | null} Auth or position, or null.
|
||||
*/
|
||||
export function parseCotPayload(payload) {
|
||||
if (!payload?.length) return null
|
||||
const str = payload.toString('utf8').trim()
|
||||
if (!str.startsWith('<')) return null
|
||||
try {
|
||||
const parsed = xmlParser.parse(str)
|
||||
const event = findInObject(parsed, 'event')
|
||||
if (!event || typeof event !== 'object') return null
|
||||
|
||||
const auth = extractAuth(parsed)
|
||||
if (auth) return { type: 'auth', username: auth.username, password: auth.password }
|
||||
|
||||
const uid = String(event['@_uid'] ?? event.uid ?? '')
|
||||
const eventType = String(event['@_type'] ?? event.type ?? '')
|
||||
const point = findInObject(parsed, 'point') ?? findInObject(event, 'point')
|
||||
const extractCoords = (pt) => {
|
||||
if (!pt || typeof pt !== 'object') return { lat: Number.NaN, lng: Number.NaN }
|
||||
return {
|
||||
lat: Number(pt['@_lat'] ?? pt.lat),
|
||||
lng: Number(pt['@_lon'] ?? pt.lon ?? pt['@_lng'] ?? pt.lng),
|
||||
}
|
||||
}
|
||||
const { lat, lng } = extractCoords(point)
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null
|
||||
|
||||
const detail = findInObject(parsed, 'detail')
|
||||
const contact = detail && typeof detail === 'object' ? (findInObject(detail, 'contact') ?? detail) : null
|
||||
const callsign = contact && typeof contact === 'object'
|
||||
? (contact['@_callsign'] ?? contact.callsign ?? contact['@_Callsign'])
|
||||
: ''
|
||||
const label = typeof callsign === 'string' ? callsign.trim() || uid : uid
|
||||
|
||||
return { type: 'cot', id: uid, lat, lng, label, eventType }
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
73
server/utils/cotSsl.js
Normal file
73
server/utils/cotSsl.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { existsSync, readFileSync, unlinkSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { execSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
/** Default password for the CoT trust store (document in atak-itak.md). */
|
||||
export const TRUSTSTORE_PASSWORD = 'kestrelos'
|
||||
|
||||
/** Default CoT server port. */
|
||||
export const DEFAULT_COT_PORT = 8089
|
||||
|
||||
/**
|
||||
* CoT port from env or default.
|
||||
* @returns {number} Port number (COT_PORT env or DEFAULT_COT_PORT).
|
||||
*/
|
||||
export function getCotPort() {
|
||||
return Number(process.env.COT_PORT ?? DEFAULT_COT_PORT)
|
||||
}
|
||||
|
||||
/** Message when an endpoint requires TLS but server is not using it. */
|
||||
export const COT_TLS_REQUIRED_MESSAGE = 'Only available when the server runs with SSL (e.g. .dev-certs or COT_SSL_*).'
|
||||
|
||||
/**
|
||||
* Resolve CoT server TLS cert and key paths (for plugin and API).
|
||||
* @param {{ cotSslCert?: string, cotSslKey?: string }} [config] - Runtime config (optional)
|
||||
* @returns {{ certPath: string, keyPath: string } | null} Paths when TLS is configured, else null.
|
||||
*/
|
||||
export function getCotSslPaths(config = {}) {
|
||||
if (process.env.COT_SSL_CERT && process.env.COT_SSL_KEY) {
|
||||
return { certPath: process.env.COT_SSL_CERT, keyPath: process.env.COT_SSL_KEY }
|
||||
}
|
||||
if (config.cotSslCert && config.cotSslKey) {
|
||||
return { certPath: config.cotSslCert, keyPath: config.cotSslKey }
|
||||
}
|
||||
const candidates = [
|
||||
join(process.cwd(), '.dev-certs', 'cert.pem'),
|
||||
join(__dirname, '../../.dev-certs', 'cert.pem'),
|
||||
]
|
||||
for (const certPath of candidates) {
|
||||
const keyPath = certPath.replace('cert.pem', 'key.pem')
|
||||
if (existsSync(certPath) && existsSync(keyPath)) {
|
||||
return { certPath, keyPath }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a P12 trust store from a PEM cert path (for truststore download and server package).
|
||||
* @param {string} certPath - Path to cert.pem
|
||||
* @param {string} password - P12 password
|
||||
* @returns {Buffer} P12 buffer
|
||||
* @throws {Error} If openssl fails
|
||||
*/
|
||||
export function buildP12FromCertPath(certPath, password) {
|
||||
const outPath = join(tmpdir(), `kestrelos-cot-p12-${Date.now()}.p12`)
|
||||
try {
|
||||
execSync(
|
||||
`openssl pkcs12 -export -nokeys -in "${certPath}" -out "${outPath}" -passout pass:${password}`,
|
||||
{ stdio: 'pipe' },
|
||||
)
|
||||
const p12 = readFileSync(outPath)
|
||||
unlinkSync(outPath)
|
||||
return p12
|
||||
}
|
||||
catch (err) {
|
||||
if (existsSync(outPath)) unlinkSync(outPath)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
71
server/utils/cotStore.js
Normal file
71
server/utils/cotStore.js
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* In-memory CoT entity store: upsert by id, prune on read by TTL.
|
||||
* Single source of truth; getActiveEntities returns new objects (no mutation of returned refs).
|
||||
*/
|
||||
|
||||
import { acquire } from './asyncLock.js'
|
||||
import { COT_ENTITY_TTL_MS } from './constants.js'
|
||||
|
||||
const entities = new Map()
|
||||
|
||||
/**
|
||||
* Upsert entity by id. Input is not mutated; stored value is a new object.
|
||||
* @param {{ id: string, lat: number, lng: number, label?: string, eventType?: string, type?: string }} parsed
|
||||
*/
|
||||
export async function updateFromCot(parsed) {
|
||||
if (!parsed || typeof parsed.id !== 'string') return
|
||||
const lat = Number(parsed.lat)
|
||||
const lng = Number(parsed.lng)
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return
|
||||
|
||||
await acquire(`cot-${parsed.id}`, async () => {
|
||||
const now = Date.now()
|
||||
const existing = entities.get(parsed.id)
|
||||
const label = typeof parsed.label === 'string' ? parsed.label : (existing?.label ?? parsed.id)
|
||||
const type = typeof parsed.eventType === 'string' ? parsed.eventType : (typeof parsed.type === 'string' ? parsed.type : (existing?.type ?? ''))
|
||||
|
||||
entities.set(parsed.id, {
|
||||
id: parsed.id,
|
||||
lat,
|
||||
lng,
|
||||
label,
|
||||
type,
|
||||
updatedAt: now,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Active entities (updated within ttlMs). Prunes expired. Returns new array of new objects.
|
||||
* @param {number} [ttlMs]
|
||||
* @returns {Promise<Array<{ id: string, lat: number, lng: number, label: string, type: string, updatedAt: number }>>} Snapshot of active entities.
|
||||
*/
|
||||
export async function getActiveEntities(ttlMs = COT_ENTITY_TTL_MS) {
|
||||
return acquire('cot-prune', async () => {
|
||||
const now = Date.now()
|
||||
const active = []
|
||||
const expired = []
|
||||
for (const entity of entities.values()) {
|
||||
if (now - entity.updatedAt <= ttlMs) {
|
||||
active.push({
|
||||
id: entity.id,
|
||||
lat: entity.lat,
|
||||
lng: entity.lng,
|
||||
label: entity.label ?? entity.id,
|
||||
type: entity.type ?? '',
|
||||
updatedAt: entity.updatedAt,
|
||||
})
|
||||
}
|
||||
else {
|
||||
expired.push(entity.id)
|
||||
}
|
||||
}
|
||||
for (const id of expired) entities.delete(id)
|
||||
return active
|
||||
})
|
||||
}
|
||||
|
||||
/** Clear store (tests only). */
|
||||
export function clearCotStore() {
|
||||
entities.clear()
|
||||
}
|
||||
@@ -2,12 +2,15 @@ import { join, dirname } from 'node:path'
|
||||
import { mkdirSync, existsSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { promisify } from 'node:util'
|
||||
import { bootstrapAdmin } from './bootstrap.js'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { hashPassword } from './password.js'
|
||||
import { registerCleanup } from './shutdown.js'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const sqlite3 = require('sqlite3')
|
||||
// Resolve from project root so bundled server (e.g. .output) finds node_modules/sqlite3
|
||||
const requireFromRoot = createRequire(join(process.cwd(), 'package.json'))
|
||||
const sqlite3 = requireFromRoot('sqlite3')
|
||||
|
||||
const SCHEMA_VERSION = 3
|
||||
const SCHEMA_VERSION = 4
|
||||
const DB_BUSY_TIMEOUT_MS = 5000
|
||||
|
||||
let dbInstance = null
|
||||
@@ -111,6 +114,12 @@ const migrateToV3 = async (run, all) => {
|
||||
await run('ALTER TABLE users ADD COLUMN avatar_path TEXT')
|
||||
}
|
||||
|
||||
const migrateToV4 = async (run, all) => {
|
||||
const info = await all('PRAGMA table_info(users)')
|
||||
if (info.some(c => c.name === 'cot_password_hash')) return
|
||||
await run('ALTER TABLE users ADD COLUMN cot_password_hash TEXT')
|
||||
}
|
||||
|
||||
const runMigrations = async (run, all, get) => {
|
||||
const version = await getSchemaVersion(get)
|
||||
if (version >= SCHEMA_VERSION) return
|
||||
@@ -122,6 +131,10 @@ const runMigrations = async (run, all, get) => {
|
||||
await migrateToV3(run, all)
|
||||
await setSchemaVersion(run, 3)
|
||||
}
|
||||
if (version < 4) {
|
||||
await migrateToV4(run, all)
|
||||
await setSchemaVersion(run, 4)
|
||||
}
|
||||
}
|
||||
|
||||
const initDb = async (db, run, all, get) => {
|
||||
@@ -140,7 +153,29 @@ const initDb = async (db, run, all, get) => {
|
||||
await run(SCHEMA.pois)
|
||||
await run(SCHEMA.devices)
|
||||
|
||||
if (!testPath) await bootstrapAdmin(run, get)
|
||||
if (!testPath) {
|
||||
// Bootstrap admin user on first run
|
||||
const PASSWORD_CHARS = Object.freeze('abcdefghjkmnopqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789')
|
||||
const generateRandomPassword = () =>
|
||||
Array.from(randomBytes(14), b => PASSWORD_CHARS[b % PASSWORD_CHARS.length]).join('')
|
||||
|
||||
const row = await get('SELECT COUNT(*) as n FROM users')
|
||||
if (row?.n === 0) {
|
||||
const email = process.env.BOOTSTRAP_EMAIL?.trim()
|
||||
const password = process.env.BOOTSTRAP_PASSWORD
|
||||
const identifier = (email && password) ? email : 'admin'
|
||||
const plainPassword = (email && password) ? password : generateRandomPassword()
|
||||
|
||||
await run(
|
||||
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[crypto.randomUUID(), identifier, hashPassword(plainPassword), 'admin', new Date().toISOString(), 'local', null, null],
|
||||
)
|
||||
|
||||
if (!email || !password) {
|
||||
console.log(`\n[KestrelOS] No bootstrap admin configured. Default admin created. Sign in at /login with:\n\n Identifier: ${identifier}\n Password: ${plainPassword}\n\n Set BOOTSTRAP_EMAIL and BOOTSTRAP_PASSWORD to use your own credentials on first run.\n`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDb() {
|
||||
@@ -167,9 +202,91 @@ export async function getDb() {
|
||||
}
|
||||
|
||||
dbInstance = { db, run, all, get }
|
||||
|
||||
registerCleanup(async () => {
|
||||
if (dbInstance) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
dbInstance.db.close((err) => {
|
||||
if (err) reject(err)
|
||||
else resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[db] Error closing database during shutdown:', error?.message)
|
||||
}
|
||||
dbInstance = null
|
||||
}
|
||||
})
|
||||
|
||||
return dbInstance
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check for database connection.
|
||||
* @returns {Promise<{ healthy: boolean, error?: string }>} Health status
|
||||
*/
|
||||
export async function healthCheck() {
|
||||
try {
|
||||
const db = await getDb()
|
||||
await db.get('SELECT 1')
|
||||
return { healthy: true }
|
||||
}
|
||||
catch (error) {
|
||||
return {
|
||||
healthy: false,
|
||||
error: error?.message || String(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Database connection model documentation:
|
||||
*
|
||||
* KestrelOS uses SQLite with WAL (Write-Ahead Logging) mode for concurrent access.
|
||||
* - Single connection instance shared across all requests (singleton pattern)
|
||||
* - WAL mode allows multiple readers and one writer concurrently
|
||||
* - Connection is initialized on first getDb() call and reused thereafter
|
||||
* - Busy timeout is set to 5000ms to handle concurrent access gracefully
|
||||
* - Transactions are supported via withTransaction() helper
|
||||
*
|
||||
* Concurrency considerations:
|
||||
* - SQLite with WAL handles concurrent reads efficiently
|
||||
* - Writes are serialized (one at a time)
|
||||
* - For high write loads, consider migrating to PostgreSQL
|
||||
* - Current model is suitable for moderate traffic (< 100 req/sec)
|
||||
*
|
||||
* Connection lifecycle:
|
||||
* - Created on first getDb() call
|
||||
* - Persists for application lifetime
|
||||
* - Closed during graceful shutdown
|
||||
* - Test path can be set via setDbPathForTest() for testing
|
||||
*/
|
||||
|
||||
/**
|
||||
* Execute a callback within a database transaction.
|
||||
* Automatically commits on success or rolls back on error.
|
||||
* @param {object} db - Database instance from getDb()
|
||||
* @param {Function} callback - Async function receiving { run, all, get }
|
||||
* @returns {Promise<any>} Result of callback
|
||||
*/
|
||||
export async function withTransaction(db, callback) {
|
||||
const { run } = db
|
||||
await run('BEGIN TRANSACTION')
|
||||
try {
|
||||
const result = await callback(db)
|
||||
await run('COMMIT')
|
||||
return result
|
||||
}
|
||||
catch (error) {
|
||||
await run('ROLLBACK').catch(() => {
|
||||
// Ignore rollback errors
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function closeDb() {
|
||||
if (!dbInstance) return
|
||||
try {
|
||||
|
||||
@@ -1,47 +1,79 @@
|
||||
import { closeRouter, getProducer, getTransport } from './mediasoup.js'
|
||||
import { acquire } from './asyncLock.js'
|
||||
import { LIVE_SESSION_TTL_MS } from './constants.js'
|
||||
|
||||
const TTL_MS = 60_000
|
||||
const sessions = new Map()
|
||||
|
||||
export const createSession = (userId, label = '') => {
|
||||
const id = crypto.randomUUID()
|
||||
const session = {
|
||||
id,
|
||||
userId,
|
||||
label: (label || 'Live').trim() || 'Live',
|
||||
lat: 0,
|
||||
lng: 0,
|
||||
updatedAt: Date.now(),
|
||||
routerId: null,
|
||||
producerId: null,
|
||||
transportId: null,
|
||||
}
|
||||
sessions.set(id, session)
|
||||
return session
|
||||
export const createSession = async (userId, label = '') => {
|
||||
return acquire(`session-create-${userId}`, async () => {
|
||||
const id = crypto.randomUUID()
|
||||
const session = {
|
||||
id,
|
||||
userId,
|
||||
label: (label || 'Live').trim() || 'Live',
|
||||
lat: 0,
|
||||
lng: 0,
|
||||
updatedAt: Date.now(),
|
||||
routerId: null,
|
||||
producerId: null,
|
||||
transportId: null,
|
||||
}
|
||||
sessions.set(id, session)
|
||||
return session
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically get existing active session or create new one for user.
|
||||
* @param {string} userId - User ID
|
||||
* @param {string} label - Session label
|
||||
* @returns {Promise<object>} Session object
|
||||
*/
|
||||
export const getOrCreateSession = async (userId, label = '') => {
|
||||
return acquire(`session-get-or-create-${userId}`, async () => {
|
||||
const now = Date.now()
|
||||
for (const s of sessions.values()) {
|
||||
if (s.userId === userId && now - s.updatedAt <= LIVE_SESSION_TTL_MS) {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return await createSession(userId, label)
|
||||
})
|
||||
}
|
||||
|
||||
export const getLiveSession = id => sessions.get(id)
|
||||
|
||||
export const getActiveSessionByUserId = (userId) => {
|
||||
const now = Date.now()
|
||||
for (const s of sessions.values()) {
|
||||
if (s.userId === userId && now - s.updatedAt <= TTL_MS) return s
|
||||
}
|
||||
export const getActiveSessionByUserId = async (userId) => {
|
||||
return acquire(`session-get-${userId}`, async () => {
|
||||
const now = Date.now()
|
||||
for (const s of sessions.values()) {
|
||||
if (s.userId === userId && now - s.updatedAt <= LIVE_SESSION_TTL_MS) return s
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const updateLiveSession = (id, updates) => {
|
||||
const session = sessions.get(id)
|
||||
if (!session) return
|
||||
const now = Date.now()
|
||||
if (Number.isFinite(updates.lat)) session.lat = updates.lat
|
||||
if (Number.isFinite(updates.lng)) session.lng = updates.lng
|
||||
if (updates.routerId !== undefined) session.routerId = updates.routerId
|
||||
if (updates.producerId !== undefined) session.producerId = updates.producerId
|
||||
if (updates.transportId !== undefined) session.transportId = updates.transportId
|
||||
session.updatedAt = now
|
||||
export const updateLiveSession = async (id, updates) => {
|
||||
return acquire(`session-update-${id}`, async () => {
|
||||
const session = sessions.get(id)
|
||||
if (!session) {
|
||||
throw new Error('Session not found')
|
||||
}
|
||||
const now = Date.now()
|
||||
if (Number.isFinite(updates.lat)) session.lat = updates.lat
|
||||
if (Number.isFinite(updates.lng)) session.lng = updates.lng
|
||||
if (updates.routerId !== undefined) session.routerId = updates.routerId
|
||||
if (updates.producerId !== undefined) session.producerId = updates.producerId
|
||||
if (updates.transportId !== undefined) session.transportId = updates.transportId
|
||||
session.updatedAt = now
|
||||
return session
|
||||
})
|
||||
}
|
||||
|
||||
export const deleteLiveSession = id => sessions.delete(id)
|
||||
export const deleteLiveSession = async (id) => {
|
||||
await acquire(`session-delete-${id}`, async () => {
|
||||
sessions.delete(id)
|
||||
})
|
||||
}
|
||||
|
||||
export const clearSessions = () => sessions.clear()
|
||||
|
||||
@@ -62,31 +94,33 @@ const cleanupSession = async (session) => {
|
||||
}
|
||||
|
||||
export const getActiveSessions = async () => {
|
||||
const now = Date.now()
|
||||
const active = []
|
||||
const expired = []
|
||||
return acquire('get-active-sessions', async () => {
|
||||
const now = Date.now()
|
||||
const active = []
|
||||
const expired = []
|
||||
|
||||
for (const session of sessions.values()) {
|
||||
if (now - session.updatedAt <= TTL_MS) {
|
||||
active.push({
|
||||
id: session.id,
|
||||
userId: session.userId,
|
||||
label: session.label,
|
||||
lat: session.lat,
|
||||
lng: session.lng,
|
||||
updatedAt: session.updatedAt,
|
||||
hasStream: Boolean(session.producerId),
|
||||
})
|
||||
for (const session of sessions.values()) {
|
||||
if (now - session.updatedAt <= LIVE_SESSION_TTL_MS) {
|
||||
active.push({
|
||||
id: session.id,
|
||||
userId: session.userId,
|
||||
label: session.label,
|
||||
lat: session.lat,
|
||||
lng: session.lng,
|
||||
updatedAt: session.updatedAt,
|
||||
hasStream: Boolean(session.producerId),
|
||||
})
|
||||
}
|
||||
else {
|
||||
expired.push(session)
|
||||
}
|
||||
}
|
||||
else {
|
||||
expired.push(session)
|
||||
|
||||
for (const session of expired) {
|
||||
await cleanupSession(session)
|
||||
sessions.delete(session.id)
|
||||
}
|
||||
}
|
||||
|
||||
for (const session of expired) {
|
||||
await cleanupSession(session)
|
||||
sessions.delete(session.id)
|
||||
}
|
||||
|
||||
return active
|
||||
return active
|
||||
})
|
||||
}
|
||||
|
||||
84
server/utils/logger.js
Normal file
84
server/utils/logger.js
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Structured logger with request context support.
|
||||
* Uses AsyncLocalStorage to provide request-scoped context that's automatically isolated per async context.
|
||||
*/
|
||||
|
||||
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||
|
||||
const asyncLocalStorage = new AsyncLocalStorage()
|
||||
|
||||
/**
|
||||
* Run a function with logger context. Context is automatically isolated per async execution.
|
||||
* @param {string} reqId - Request ID
|
||||
* @param {string|null} uId - User ID (optional)
|
||||
* @param {Function} fn - Function to run with context
|
||||
* @returns {Promise<any>} Result of the function
|
||||
*/
|
||||
export function runWithContext(reqId, uId, fn) {
|
||||
return asyncLocalStorage.run({ requestId: reqId, userId: uId }, fn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set context for the current async context. Use runWithContext() instead for proper isolation.
|
||||
* @deprecated Use runWithContext() instead for proper async context isolation
|
||||
* @param {string} reqId - Request ID
|
||||
* @param {string|null} uId - User ID (optional)
|
||||
*/
|
||||
export function setContext(reqId, uId = null) {
|
||||
const store = asyncLocalStorage.getStore()
|
||||
if (store) {
|
||||
store.requestId = reqId
|
||||
store.userId = uId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear context for the current async context.
|
||||
* @deprecated Context is automatically cleared when async context ends. Use runWithContext() instead.
|
||||
*/
|
||||
export function clearContext() {
|
||||
const store = asyncLocalStorage.getStore()
|
||||
if (store) {
|
||||
store.requestId = null
|
||||
store.userId = null
|
||||
}
|
||||
}
|
||||
|
||||
function getContext() {
|
||||
return asyncLocalStorage.getStore() || { requestId: null, userId: null }
|
||||
}
|
||||
|
||||
function formatMessage(level, message, context = {}) {
|
||||
const { requestId, userId } = getContext()
|
||||
const timestamp = new Date().toISOString()
|
||||
const ctx = {
|
||||
timestamp,
|
||||
level,
|
||||
requestId,
|
||||
...(userId && { userId }),
|
||||
...context,
|
||||
}
|
||||
return `[${level.toUpperCase()}] ${JSON.stringify({ message, ...ctx })}`
|
||||
}
|
||||
|
||||
export function info(message, context = {}) {
|
||||
console.log(formatMessage('info', message, context))
|
||||
}
|
||||
|
||||
export function error(message, context = {}) {
|
||||
const ctx = { ...context }
|
||||
if (context.error && context.error.stack) {
|
||||
ctx.stack = context.error.stack
|
||||
}
|
||||
console.error(formatMessage('error', message, ctx))
|
||||
}
|
||||
|
||||
export function warn(message, context = {}) {
|
||||
console.warn(formatMessage('warn', message, context))
|
||||
}
|
||||
|
||||
export function debug(message, context = {}) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.debug(formatMessage('debug', message, context))
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import os from 'node:os'
|
||||
import mediasoup from 'mediasoup'
|
||||
import { acquire } from './asyncLock.js'
|
||||
import { MEDIASOUP_RTC_MIN_PORT, MEDIASOUP_RTC_MAX_PORT } from './constants.js'
|
||||
|
||||
let worker = null
|
||||
const routers = new Map()
|
||||
@@ -17,22 +19,25 @@ export const getWorker = async () => {
|
||||
worker = await mediasoup.createWorker({
|
||||
logLevel: process.env.NODE_ENV === 'development' ? 'debug' : 'warn',
|
||||
logTags: ['info', 'ice', 'dtls', 'rtp', 'srtp', 'rtcp'],
|
||||
rtcMinPort: 40000,
|
||||
rtcMaxPort: 49999,
|
||||
rtcMinPort: MEDIASOUP_RTC_MIN_PORT,
|
||||
rtcMaxPort: MEDIASOUP_RTC_MAX_PORT,
|
||||
})
|
||||
worker.on('died', () => {
|
||||
console.error('[mediasoup] Worker died, exiting')
|
||||
process.exit(1)
|
||||
worker.on('died', async (error) => {
|
||||
console.error('[mediasoup] Worker died:', error?.message || String(error))
|
||||
const { graceful } = await import('./shutdown.js')
|
||||
await graceful(error || new Error('Mediasoup worker died'))
|
||||
})
|
||||
return worker
|
||||
}
|
||||
|
||||
export const getRouter = async (sessionId) => {
|
||||
const existing = routers.get(sessionId)
|
||||
if (existing) return existing
|
||||
const router = await (await getWorker()).createRouter({ mediaCodecs: MEDIA_CODECS })
|
||||
routers.set(sessionId, router)
|
||||
return router
|
||||
return acquire(`router-${sessionId}`, async () => {
|
||||
const existing = routers.get(sessionId)
|
||||
if (existing) return existing
|
||||
const router = await (await getWorker()).createRouter({ mediaCodecs: MEDIA_CODECS })
|
||||
routers.set(sessionId, router)
|
||||
return router
|
||||
})
|
||||
}
|
||||
|
||||
const isIPv4 = (host) => {
|
||||
@@ -64,34 +69,36 @@ const resolveAnnouncedIp = (requestHost) => {
|
||||
}
|
||||
|
||||
export const createTransport = async (router, requestHost = undefined) => {
|
||||
const announcedIp = resolveAnnouncedIp(requestHost)
|
||||
const listenIps = announcedIp
|
||||
? [{ ip: '0.0.0.0', announcedIp }, { ip: '127.0.0.1' }]
|
||||
: [{ ip: '127.0.0.1' }]
|
||||
return acquire(`transport-${router.id}`, async () => {
|
||||
const announcedIp = resolveAnnouncedIp(requestHost)
|
||||
const listenIps = announcedIp
|
||||
? [{ ip: '0.0.0.0', announcedIp }, { ip: '127.0.0.1' }]
|
||||
: [{ ip: '127.0.0.1' }]
|
||||
|
||||
const transport = await router.createWebRtcTransport({
|
||||
listenIps,
|
||||
enableUdp: true,
|
||||
enableTcp: true,
|
||||
preferUdp: true,
|
||||
initialAvailableOutgoingBitrate: 1_000_000,
|
||||
}).catch((err) => {
|
||||
console.error('[mediasoup] Transport creation failed:', err)
|
||||
throw new Error(`Failed to create transport: ${err.message || String(err)}`)
|
||||
const transport = await router.createWebRtcTransport({
|
||||
listenIps,
|
||||
enableUdp: true,
|
||||
enableTcp: true,
|
||||
preferUdp: true,
|
||||
initialAvailableOutgoingBitrate: 1_000_000,
|
||||
}).catch((err) => {
|
||||
console.error('[mediasoup] Transport creation failed:', err)
|
||||
throw new Error(`Failed to create transport: ${err.message || String(err)}`)
|
||||
})
|
||||
|
||||
transports.set(transport.id, transport)
|
||||
transport.on('close', () => transports.delete(transport.id))
|
||||
|
||||
return {
|
||||
transport,
|
||||
params: {
|
||||
id: transport.id,
|
||||
iceParameters: transport.iceParameters,
|
||||
iceCandidates: transport.iceCandidates,
|
||||
dtlsParameters: transport.dtlsParameters,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
transports.set(transport.id, transport)
|
||||
transport.on('close', () => transports.delete(transport.id))
|
||||
|
||||
return {
|
||||
transport,
|
||||
params: {
|
||||
id: transport.id,
|
||||
iceParameters: transport.iceParameters,
|
||||
iceCandidates: transport.iceCandidates,
|
||||
dtlsParameters: transport.dtlsParameters,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const getTransport = transportId => transports.get(transportId)
|
||||
|
||||
@@ -3,6 +3,13 @@ import * as oidc from 'openid-client'
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000
|
||||
const configCache = new Map()
|
||||
|
||||
// Auth configuration
|
||||
export function getAuthConfig() {
|
||||
const hasOidc = !!(process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET)
|
||||
const label = process.env.OIDC_LABEL?.trim() || (hasOidc ? 'Sign in with OIDC' : '')
|
||||
return Object.freeze({ oidc: { enabled: hasOidc, label } })
|
||||
}
|
||||
|
||||
function getRedirectUri() {
|
||||
const explicit
|
||||
= process.env.OIDC_REDIRECT_URI ?? process.env.OPENID_REDIRECT_URI ?? ''
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const POI_ICON_TYPES = Object.freeze(['pin', 'flag', 'waypoint'])
|
||||
28
server/utils/queryBuilder.js
Normal file
28
server/utils/queryBuilder.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Query builder for safe dynamic UPDATE queries with column whitelist validation.
|
||||
* Prevents SQL injection by validating column names against allowed sets.
|
||||
*/
|
||||
|
||||
const ALLOWED_COLUMNS = {
|
||||
devices: new Set(['name', 'device_type', 'vendor', 'lat', 'lng', 'stream_url', 'source_type', 'config']),
|
||||
users: new Set(['role', 'identifier', 'password_hash']),
|
||||
pois: new Set(['label', 'icon_type', 'lat', 'lng']),
|
||||
}
|
||||
|
||||
export function buildUpdateQuery(table, allowedColumns, updates) {
|
||||
if (!ALLOWED_COLUMNS[table]) throw new Error(`Unknown table: ${table}`)
|
||||
const columns = allowedColumns || ALLOWED_COLUMNS[table]
|
||||
const clauses = []
|
||||
const params = []
|
||||
for (const [column, value] of Object.entries(updates)) {
|
||||
if (!columns.has(column)) throw new Error(`Invalid column: ${column} for table: ${table}`)
|
||||
clauses.push(`${column} = ?`)
|
||||
params.push(value)
|
||||
}
|
||||
if (clauses.length === 0) return { query: '', params: [] }
|
||||
return { query: `UPDATE ${table} SET ${clauses.join(', ')} WHERE id = ?`, params }
|
||||
}
|
||||
|
||||
export function getAllowedColumns(table) {
|
||||
return ALLOWED_COLUMNS[table] || new Set()
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
const [MIN_DAYS, MAX_DAYS, DEFAULT_DAYS] = [1, 365, 7]
|
||||
|
||||
export function getSessionMaxAgeDays() {
|
||||
const raw = Number.parseInt(process.env.SESSION_MAX_AGE_DAYS ?? '', 10)
|
||||
return Number.isFinite(raw) ? Math.max(MIN_DAYS, Math.min(MAX_DAYS, raw)) : DEFAULT_DAYS
|
||||
}
|
||||
78
server/utils/shutdown.js
Normal file
78
server/utils/shutdown.js
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Graceful shutdown handler - registers cleanup functions and handles shutdown signals.
|
||||
*/
|
||||
|
||||
import { SHUTDOWN_TIMEOUT_MS } from './constants.js'
|
||||
|
||||
const cleanupFunctions = []
|
||||
const shutdownState = {
|
||||
isShuttingDown: false,
|
||||
}
|
||||
|
||||
export function clearCleanup() {
|
||||
cleanupFunctions.length = 0
|
||||
shutdownState.isShuttingDown = false
|
||||
}
|
||||
|
||||
export function registerCleanup(fn) {
|
||||
if (typeof fn !== 'function') throw new TypeError('Cleanup function must be a function')
|
||||
cleanupFunctions.push(fn)
|
||||
}
|
||||
|
||||
const executeCleanupFunction = async (fn, index) => {
|
||||
try {
|
||||
await fn()
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`[shutdown] Cleanup function ${index} failed:`, error?.message || String(error))
|
||||
}
|
||||
}
|
||||
|
||||
const executeCleanupReverse = async (functions, index = functions.length - 1) => {
|
||||
if (index < 0) return
|
||||
await executeCleanupFunction(functions[index], index)
|
||||
return executeCleanupReverse(functions, index - 1)
|
||||
}
|
||||
|
||||
async function executeCleanup() {
|
||||
if (shutdownState.isShuttingDown) return
|
||||
shutdownState.isShuttingDown = true
|
||||
await executeCleanupReverse(cleanupFunctions)
|
||||
}
|
||||
|
||||
export async function graceful(error) {
|
||||
if (error) {
|
||||
console.error('[shutdown] Shutting down due to error:', error?.message || String(error))
|
||||
if (error.stack) console.error('[shutdown] Stack trace:', error.stack)
|
||||
}
|
||||
else {
|
||||
console.log('[shutdown] Initiating graceful shutdown')
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
console.error('[shutdown] Shutdown timeout exceeded, forcing exit')
|
||||
process.exit(1)
|
||||
}, SHUTDOWN_TIMEOUT_MS)
|
||||
try {
|
||||
await executeCleanup()
|
||||
clearTimeout(timeout)
|
||||
console.log('[shutdown] Cleanup complete')
|
||||
process.exit(error ? 1 : 0)
|
||||
}
|
||||
catch (err) {
|
||||
clearTimeout(timeout)
|
||||
console.error('[shutdown] Error during cleanup:', err?.message || String(err))
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
export function initShutdownHandlers() {
|
||||
for (const signal of ['SIGTERM', 'SIGINT']) {
|
||||
process.on(signal, () => {
|
||||
console.log(`[shutdown] Received ${signal}`)
|
||||
graceful().catch((err) => {
|
||||
console.error('[shutdown] Error in graceful shutdown:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
150
server/utils/validation.js
Normal file
150
server/utils/validation.js
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Validation and sanitization utilities - pure functions for consistent input validation and cleaning.
|
||||
*/
|
||||
|
||||
import { MAX_IDENTIFIER_LENGTH, MAX_STRING_LENGTH } from './constants.js'
|
||||
import { DEVICE_TYPES, SOURCE_TYPES } from './deviceUtils.js'
|
||||
|
||||
// Constants
|
||||
export const POI_ICON_TYPES = Object.freeze(['pin', 'flag', 'waypoint'])
|
||||
|
||||
// Sanitization functions
|
||||
const IDENTIFIER_REGEX = /^\w+$/
|
||||
|
||||
export function sanitizeString(str, maxLength = MAX_STRING_LENGTH) {
|
||||
if (typeof str !== 'string') return ''
|
||||
const trimmed = str.trim()
|
||||
return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed
|
||||
}
|
||||
|
||||
export function sanitizeIdentifier(str) {
|
||||
if (typeof str !== 'string') return ''
|
||||
const trimmed = str.trim()
|
||||
if (trimmed.length === 0 || trimmed.length > MAX_IDENTIFIER_LENGTH) return ''
|
||||
return IDENTIFIER_REGEX.test(trimmed) ? trimmed : ''
|
||||
}
|
||||
|
||||
export function sanitizeLabel(str, maxLength = MAX_STRING_LENGTH) {
|
||||
return sanitizeString(str, maxLength)
|
||||
}
|
||||
|
||||
const ROLES = ['admin', 'leader', 'member']
|
||||
|
||||
const validateNumber = (value, field) => {
|
||||
const num = Number(value)
|
||||
return Number.isFinite(num) ? { valid: true, value: num } : { valid: false, error: `${field} must be a finite number` }
|
||||
}
|
||||
|
||||
const validateEnum = (value, allowed, field) => allowed.includes(value) ? { valid: true, value } : { valid: false, error: `Invalid ${field}` }
|
||||
|
||||
const handleField = (d, field, handler, updates, errors, outputField = null) => {
|
||||
if (d[field] !== undefined) {
|
||||
const result = handler(d[field])
|
||||
if (result.valid) updates[outputField || field] = result.value
|
||||
else errors.push(result.error)
|
||||
}
|
||||
}
|
||||
|
||||
export function validateDevice(data) {
|
||||
if (!data || typeof data !== 'object') return { valid: false, errors: ['body required'] }
|
||||
const d = /** @type {Record<string, unknown>} */ (data)
|
||||
const errors = []
|
||||
const latCheck = validateNumber(d.lat, 'lat')
|
||||
const lngCheck = validateNumber(d.lng, 'lng')
|
||||
if (!latCheck.valid || !lngCheck.valid) errors.push('lat and lng required as finite numbers')
|
||||
if (errors.length > 0) return { valid: false, errors }
|
||||
return {
|
||||
valid: true,
|
||||
errors: [],
|
||||
data: {
|
||||
name: sanitizeString(d.name, 1000),
|
||||
device_type: validateEnum(d.device_type, DEVICE_TYPES, 'device_type').value || 'feed',
|
||||
vendor: d.vendor !== undefined ? sanitizeString(d.vendor, 255) : null,
|
||||
lat: latCheck.value,
|
||||
lng: lngCheck.value,
|
||||
stream_url: typeof d.stream_url === 'string' ? sanitizeString(d.stream_url, 2000) : '',
|
||||
source_type: validateEnum(d.source_type, SOURCE_TYPES, 'source_type').value || 'mjpeg',
|
||||
config: d.config == null ? null : (typeof d.config === 'string' ? d.config : JSON.stringify(d.config)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function validateUpdateDevice(data) {
|
||||
if (!data || typeof data !== 'object') return { valid: true, errors: [], data: {} }
|
||||
const d = /** @type {Record<string, unknown>} */ (data)
|
||||
const errors = []
|
||||
const updates = {}
|
||||
if (d.name !== undefined) updates.name = sanitizeString(d.name, 1000)
|
||||
handleField(d, 'device_type', v => validateEnum(v, DEVICE_TYPES, 'device_type'), updates, errors)
|
||||
if (d.vendor !== undefined) updates.vendor = d.vendor === null || d.vendor === '' ? null : sanitizeString(d.vendor, 255)
|
||||
handleField(d, 'lat', v => validateNumber(v, 'lat'), updates, errors)
|
||||
handleField(d, 'lng', v => validateNumber(v, 'lng'), updates, errors)
|
||||
if (d.stream_url !== undefined) updates.stream_url = sanitizeString(d.stream_url, 2000)
|
||||
handleField(d, 'source_type', v => validateEnum(v, SOURCE_TYPES, 'source_type'), updates, errors)
|
||||
if (d.config !== undefined) updates.config = d.config === null ? null : (typeof d.config === 'string' ? d.config : JSON.stringify(d.config))
|
||||
return errors.length > 0 ? { valid: false, errors } : { valid: true, errors: [], data: updates }
|
||||
}
|
||||
|
||||
export function validateUser(data) {
|
||||
if (!data || typeof data !== 'object') return { valid: false, errors: ['body required'] }
|
||||
const d = /** @type {Record<string, unknown>} */ (data)
|
||||
const errors = []
|
||||
const identifier = sanitizeIdentifier(d.identifier)
|
||||
const password = typeof d.password === 'string' ? d.password : ''
|
||||
const role = typeof d.role === 'string' ? d.role : ''
|
||||
if (!identifier) errors.push('identifier required')
|
||||
if (!password) errors.push('password required')
|
||||
if (!role || !ROLES.includes(role)) errors.push('role must be admin, leader, or member')
|
||||
return errors.length > 0 ? { valid: false, errors } : { valid: true, errors: [], data: { identifier, password, role: role || 'member' } }
|
||||
}
|
||||
|
||||
export function validateUpdateUser(data) {
|
||||
if (!data || typeof data !== 'object') return { valid: true, errors: [], data: {} }
|
||||
const d = /** @type {Record<string, unknown>} */ (data)
|
||||
const errors = []
|
||||
const updates = {}
|
||||
if (d.role !== undefined) {
|
||||
if (ROLES.includes(d.role)) updates.role = d.role
|
||||
else errors.push('role must be admin, leader, or member')
|
||||
}
|
||||
if (d.identifier !== undefined) {
|
||||
const identifier = sanitizeIdentifier(d.identifier)
|
||||
if (!identifier) errors.push('identifier cannot be empty')
|
||||
else updates.identifier = identifier
|
||||
}
|
||||
if (d.password !== undefined && d.password !== '') {
|
||||
if (typeof d.password !== 'string' || !d.password) errors.push('password cannot be empty')
|
||||
else updates.password = d.password
|
||||
}
|
||||
return errors.length > 0 ? { valid: false, errors } : { valid: true, errors: [], data: updates }
|
||||
}
|
||||
|
||||
export function validatePoi(data) {
|
||||
if (!data || typeof data !== 'object') return { valid: false, errors: ['body required'] }
|
||||
const d = /** @type {Record<string, unknown>} */ (data)
|
||||
const latCheck = validateNumber(d.lat, 'lat')
|
||||
const lngCheck = validateNumber(d.lng, 'lng')
|
||||
if (!latCheck.valid || !lngCheck.valid) return { valid: false, errors: ['lat and lng required as finite numbers'] }
|
||||
return {
|
||||
valid: true,
|
||||
errors: [],
|
||||
data: {
|
||||
lat: latCheck.value,
|
||||
lng: lngCheck.value,
|
||||
label: sanitizeLabel(d.label, 500),
|
||||
icon_type: validateEnum(d.iconType, POI_ICON_TYPES, 'iconType').value || 'pin',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function validateUpdatePoi(data) {
|
||||
if (!data || typeof data !== 'object') return { valid: true, errors: [], data: {} }
|
||||
const d = /** @type {Record<string, unknown>} */ (data)
|
||||
const errors = []
|
||||
const updates = {}
|
||||
if (d.label !== undefined) updates.label = sanitizeLabel(d.label, 500)
|
||||
handleField(d, 'iconType', v => validateEnum(v, POI_ICON_TYPES, 'iconType'), updates, errors, 'icon_type')
|
||||
handleField(d, 'lat', v => validateNumber(v, 'lat'), updates, errors)
|
||||
handleField(d, 'lng', v => validateNumber(v, 'lng'), updates, errors)
|
||||
return errors.length > 0 ? { valid: false, errors } : { valid: true, errors: [], data: updates }
|
||||
}
|
||||
@@ -20,7 +20,15 @@ export async function handleWebSocketMessage(userId, sessionId, type, data) {
|
||||
case 'create-transport': {
|
||||
const router = await getRouter(sessionId)
|
||||
const { transport, params } = await createTransport(router)
|
||||
updateLiveSession(sessionId, { transportId: transport.id, routerId: router.id })
|
||||
try {
|
||||
await updateLiveSession(sessionId, { transportId: transport.id, routerId: router.id })
|
||||
}
|
||||
catch (err) {
|
||||
if (err.message === 'Session not found') {
|
||||
return { error: 'Session not found' }
|
||||
}
|
||||
throw err
|
||||
}
|
||||
return { type: 'transport-created', data: params }
|
||||
}
|
||||
case 'connect-transport': {
|
||||
|
||||
54
test/helpers/env.js
Normal file
54
test/helpers/env.js
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Functional helpers for test environment management.
|
||||
* Returns new objects instead of mutating process.env directly.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates a new env object with specified overrides
|
||||
* @param {Record<string, string | undefined>} overrides - Env vars to set/override
|
||||
* @returns {Record<string, string>} New env object
|
||||
*/
|
||||
export const withEnv = overrides => ({
|
||||
...process.env,
|
||||
...Object.fromEntries(
|
||||
Object.entries(overrides).filter(([, v]) => v !== undefined),
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
* Creates a new env object with specified vars removed
|
||||
* @param {string[]} keys - Env var keys to remove
|
||||
* @returns {Record<string, string>} New env object
|
||||
*/
|
||||
export const withoutEnv = (keys) => {
|
||||
const result = { ...process.env }
|
||||
for (const key of keys) {
|
||||
delete result[key]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a function with a temporary env, restoring original after
|
||||
* @param {Record<string, string | undefined>} env - Temporary env to use
|
||||
* @param {() => any} fn - Function to execute
|
||||
* @returns {any} Result of fn()
|
||||
*/
|
||||
export const withTemporaryEnv = (env, fn) => {
|
||||
const original = { ...process.env }
|
||||
try {
|
||||
// Set defined values
|
||||
Object.entries(env).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
process.env[key] = value
|
||||
}
|
||||
else {
|
||||
delete process.env[key]
|
||||
}
|
||||
})
|
||||
return fn()
|
||||
}
|
||||
finally {
|
||||
process.env = original
|
||||
}
|
||||
}
|
||||
59
test/helpers/fakeAtakClient.js
Normal file
59
test/helpers/fakeAtakClient.js
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Encode a number as varint bytes (little-endian, continuation bit).
|
||||
* @param {number} value - Value to encode
|
||||
* @param {number[]} bytes - Accumulated bytes (default empty)
|
||||
* @returns {number[]} Varint bytes
|
||||
*/
|
||||
const encodeVarint = (value, bytes = []) => {
|
||||
const byte = value & 0x7F
|
||||
const remaining = value >>> 7
|
||||
if (remaining === 0) {
|
||||
return [...bytes, byte]
|
||||
}
|
||||
return encodeVarint(remaining, [...bytes, byte | 0x80])
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a TAK Protocol stream frame: 0xBF, varint payload length, payload.
|
||||
* @param {string|Buffer} payload - UTF-8 payload (e.g. CoT XML)
|
||||
* @returns {Buffer} TAK stream frame buffer.
|
||||
*/
|
||||
export function buildTakFrame(payload) {
|
||||
const buf = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf8')
|
||||
const varint = encodeVarint(buf.length)
|
||||
return Buffer.concat([Buffer.from([0xBF]), Buffer.from(varint), buf])
|
||||
}
|
||||
|
||||
/**
|
||||
* Build CoT XML for a position update (event + point + optional contact).
|
||||
* @param {object} opts - Position options
|
||||
* @param {string} opts.uid - Entity UID
|
||||
* @param {number} opts.lat - Latitude
|
||||
* @param {number} opts.lon - Longitude
|
||||
* @param {string} [opts.callsign] - Optional callsign
|
||||
* @param {string} [opts.type] - Optional event type (default a-f-G)
|
||||
* @returns {string} CoT XML string.
|
||||
*/
|
||||
export function buildPositionCotXml({ uid, lat, lon, callsign, type = 'a-f-G' }) {
|
||||
const contact = callsign ? `<detail><contact callsign="${escapeXml(callsign)}"/></detail>` : ''
|
||||
return `<event uid="${escapeXml(uid)}" type="${escapeXml(type)}"><point lat="${lat}" lon="${lon}"/>${contact}</event>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build CoT XML for auth (username/password).
|
||||
* @param {object} opts - Auth options
|
||||
* @param {string} opts.username - Username
|
||||
* @param {string} opts.password - Password
|
||||
* @returns {string} CoT XML string.
|
||||
*/
|
||||
export function buildAuthCotXml({ username, password }) {
|
||||
return `<event><detail><auth username="${escapeXml(username)}" password="${escapeXml(password)}"/></detail></event>`
|
||||
}
|
||||
|
||||
function escapeXml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
128
test/integration/server-and-cot.spec.js
Normal file
128
test/integration/server-and-cot.spec.js
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*
|
||||
* Integration test: built server on 3000 (API) and 8089 (CoT). Uses temp DB and bootstrap
|
||||
* user so CoT auth can be asserted (socket stays open on success).
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { spawn, execSync } from 'node:child_process'
|
||||
import { connect } from 'node:tls'
|
||||
import { existsSync, mkdirSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { buildAuthCotXml } from '../helpers/fakeAtakClient.js'
|
||||
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const projectRoot = join(__dirname, '../..')
|
||||
const devCertsDir = join(projectRoot, '.dev-certs')
|
||||
const devKey = join(devCertsDir, 'key.pem')
|
||||
const devCert = join(devCertsDir, 'cert.pem')
|
||||
|
||||
const API_PORT = 3000
|
||||
const COT_PORT = 8089
|
||||
const COT_AUTH_USER = 'test'
|
||||
const COT_AUTH_PASS = 'test'
|
||||
|
||||
function ensureDevCerts() {
|
||||
if (existsSync(devKey) && existsSync(devCert)) return
|
||||
mkdirSync(devCertsDir, { recursive: true })
|
||||
execSync(
|
||||
`openssl req -x509 -newkey rsa:2048 -keyout "${devKey}" -out "${devCert}" -days 365 -nodes -subj "/CN=localhost" -addext "subjectAltName=IP:127.0.0.1,DNS:localhost"`,
|
||||
{ cwd: projectRoot, stdio: 'pipe' },
|
||||
)
|
||||
}
|
||||
|
||||
const FETCH_TIMEOUT_MS = 5000
|
||||
|
||||
async function waitForHealth(timeoutMs = 90000) {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
for (const protocol of ['https', 'http']) {
|
||||
try {
|
||||
const baseURL = `${protocol}://localhost:${API_PORT}`
|
||||
const ctrl = new AbortController()
|
||||
const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS)
|
||||
const res = await fetch(`${baseURL}/health`, { method: 'GET', signal: ctrl.signal })
|
||||
clearTimeout(t)
|
||||
if (res.ok) return baseURL
|
||||
}
|
||||
catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
}
|
||||
throw new Error(`Health not OK on ${API_PORT} within ${timeoutMs}ms`)
|
||||
}
|
||||
|
||||
describe('Server and CoT integration', () => {
|
||||
const testState = {
|
||||
serverProcess: null,
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
ensureDevCerts()
|
||||
const serverPath = join(projectRoot, '.output', 'server', 'index.mjs')
|
||||
if (!existsSync(serverPath)) {
|
||||
execSync('npm run build', { cwd: projectRoot, stdio: 'pipe' })
|
||||
}
|
||||
const dbPath = join(tmpdir(), `kestrelos-it-${process.pid}-${Date.now()}.db`)
|
||||
const env = {
|
||||
...process.env,
|
||||
DB_PATH: dbPath,
|
||||
BOOTSTRAP_EMAIL: COT_AUTH_USER,
|
||||
BOOTSTRAP_PASSWORD: COT_AUTH_PASS,
|
||||
}
|
||||
testState.serverProcess = spawn('node', ['.output/server/index.mjs'], {
|
||||
cwd: projectRoot,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
testState.serverProcess.stdout?.on('data', d => process.stdout.write(d))
|
||||
testState.serverProcess.stderr?.on('data', d => process.stderr.write(d))
|
||||
await waitForHealth(90000)
|
||||
}, 120000)
|
||||
|
||||
afterAll(() => {
|
||||
if (testState.serverProcess?.pid) {
|
||||
testState.serverProcess.kill('SIGTERM')
|
||||
}
|
||||
})
|
||||
|
||||
it('serves health on port 3000', async () => {
|
||||
const tryProtocols = async (protocols) => {
|
||||
if (protocols.length === 0) throw new Error('No protocol succeeded')
|
||||
try {
|
||||
const res = await fetch(`${protocols[0]}://localhost:${API_PORT}/health`, { method: 'GET', headers: { Accept: 'application/json' } })
|
||||
if (res?.ok) return res
|
||||
return tryProtocols(protocols.slice(1))
|
||||
}
|
||||
catch {
|
||||
return tryProtocols(protocols.slice(1))
|
||||
}
|
||||
}
|
||||
const res = await tryProtocols(['https', 'http'])
|
||||
expect(res?.ok).toBe(true)
|
||||
const body = await res.json()
|
||||
expect(body).toHaveProperty('status', 'ok')
|
||||
expect(body).toHaveProperty('endpoints')
|
||||
expect(body.endpoints).toHaveProperty('ready', '/health/ready')
|
||||
})
|
||||
|
||||
it('CoT on 8089: TAK client auth with username/password succeeds (socket stays open)', async () => {
|
||||
const payload = buildAuthCotXml({ username: COT_AUTH_USER, password: COT_AUTH_PASS })
|
||||
const socket = await new Promise((resolve, reject) => {
|
||||
const s = connect(COT_PORT, '127.0.0.1', { rejectUnauthorized: false }, () => {
|
||||
s.write(payload, () => resolve(s))
|
||||
})
|
||||
s.on('error', reject)
|
||||
s.setTimeout(8000, () => reject(new Error('connect timeout')))
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 600))
|
||||
expect(socket.destroyed).toBe(false)
|
||||
socket.destroy()
|
||||
})
|
||||
})
|
||||
83
test/integration/shutdown.spec.js
Normal file
83
test/integration/shutdown.spec.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { registerCleanup, graceful, initShutdownHandlers, clearCleanup } from '../../server/utils/shutdown.js'
|
||||
|
||||
describe('shutdown integration', () => {
|
||||
const testState = {
|
||||
originalExit: null,
|
||||
exitCalls: [],
|
||||
originalOn: null,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearCleanup()
|
||||
testState.exitCalls = []
|
||||
testState.originalExit = process.exit
|
||||
process.exit = vi.fn((code) => {
|
||||
testState.exitCalls.push(code)
|
||||
})
|
||||
testState.originalOn = process.on
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.exit = testState.originalExit
|
||||
process.on = testState.originalOn
|
||||
clearCleanup()
|
||||
})
|
||||
|
||||
it('initializes signal handlers', () => {
|
||||
const handlers = {}
|
||||
process.on = vi.fn((signal, handler) => {
|
||||
handlers[signal] = handler
|
||||
})
|
||||
initShutdownHandlers()
|
||||
expect(process.on).toHaveBeenCalledWith('SIGTERM', expect.any(Function))
|
||||
expect(process.on).toHaveBeenCalledWith('SIGINT', expect.any(Function))
|
||||
process.on = testState.originalOn
|
||||
})
|
||||
|
||||
it('signal handler calls graceful', async () => {
|
||||
const handlers = {}
|
||||
process.on = vi.fn((signal, handler) => {
|
||||
handlers[signal] = handler
|
||||
})
|
||||
initShutdownHandlers()
|
||||
const sigtermHandler = handlers.SIGTERM
|
||||
expect(sigtermHandler).toBeDefined()
|
||||
await sigtermHandler()
|
||||
expect(testState.exitCalls.length).toBeGreaterThan(0)
|
||||
process.on = testState.originalOn
|
||||
})
|
||||
|
||||
it('signal handler handles graceful error', async () => {
|
||||
const handlers = {}
|
||||
process.on = vi.fn((signal, handler) => {
|
||||
handlers[signal] = handler
|
||||
})
|
||||
initShutdownHandlers()
|
||||
const sigintHandler = handlers.SIGINT
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
registerCleanup(async () => {
|
||||
throw new Error('Force error')
|
||||
})
|
||||
await sigintHandler()
|
||||
expect(testState.exitCalls.length).toBeGreaterThan(0)
|
||||
process.on = testState.originalOn
|
||||
})
|
||||
|
||||
it('covers timeout path in graceful', async () => {
|
||||
registerCleanup(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 40000))
|
||||
})
|
||||
graceful()
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
expect(testState.exitCalls.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('covers graceful catch block', async () => {
|
||||
registerCleanup(async () => {
|
||||
throw new Error('Test error')
|
||||
})
|
||||
await graceful()
|
||||
expect(testState.exitCalls.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -2,84 +2,58 @@ import { describe, it, expect } from 'vitest'
|
||||
import { mountSuspended } from '@nuxt/test-utils/runtime'
|
||||
import CameraViewer from '../../app/components/CameraViewer.vue'
|
||||
|
||||
const createCamera = (overrides = {}) => ({
|
||||
id: 't1',
|
||||
name: 'Test Camera',
|
||||
streamUrl: 'https://example.com/stream.mjpg',
|
||||
sourceType: 'mjpeg',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('CameraViewer (device stream)', () => {
|
||||
it('renders device name and close button', async () => {
|
||||
const camera = {
|
||||
id: 't1',
|
||||
name: 'Test Camera',
|
||||
streamUrl: 'https://example.com/stream.mjpg',
|
||||
sourceType: 'mjpeg',
|
||||
}
|
||||
const wrapper = await mountSuspended(CameraViewer, {
|
||||
props: { camera },
|
||||
props: { camera: createCamera({ name: 'Test Camera' }) },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Test Camera')
|
||||
expect(wrapper.find('button[aria-label="Close panel"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not set img src for non-http streamUrl', async () => {
|
||||
const camera = {
|
||||
id: 't2',
|
||||
name: 'Bad',
|
||||
streamUrl: 'javascript:alert(1)',
|
||||
sourceType: 'mjpeg',
|
||||
}
|
||||
it.each([
|
||||
['javascript:alert(1)', false],
|
||||
['https://example.com/cam.mjpg', true],
|
||||
])('handles streamUrl: %s -> img exists: %s', async (streamUrl, shouldExist) => {
|
||||
const wrapper = await mountSuspended(CameraViewer, {
|
||||
props: { camera },
|
||||
props: { camera: createCamera({ streamUrl }) },
|
||||
})
|
||||
const img = wrapper.find('img')
|
||||
expect(img.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('uses safe http streamUrl for img', async () => {
|
||||
const camera = {
|
||||
id: 't3',
|
||||
name: 'OK',
|
||||
streamUrl: 'https://example.com/cam.mjpg',
|
||||
sourceType: 'mjpeg',
|
||||
expect(img.exists()).toBe(shouldExist)
|
||||
if (shouldExist) {
|
||||
expect(img.attributes('src')).toBe(streamUrl)
|
||||
}
|
||||
const wrapper = await mountSuspended(CameraViewer, {
|
||||
props: { camera },
|
||||
})
|
||||
const img = wrapper.find('img')
|
||||
expect(img.exists()).toBe(true)
|
||||
expect(img.attributes('src')).toBe('https://example.com/cam.mjpg')
|
||||
})
|
||||
|
||||
it('emits close when close button clicked', async () => {
|
||||
const camera = {
|
||||
id: 't5',
|
||||
name: 'Close me',
|
||||
streamUrl: '',
|
||||
sourceType: 'mjpeg',
|
||||
}
|
||||
const wrapper = await mountSuspended(CameraViewer, { props: { camera } })
|
||||
const wrapper = await mountSuspended(CameraViewer, {
|
||||
props: { camera: createCamera() },
|
||||
})
|
||||
await wrapper.find('button[aria-label="Close panel"]').trigger('click')
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows stream unavailable when img errors', async () => {
|
||||
const camera = {
|
||||
id: 't6',
|
||||
name: 'Broken',
|
||||
streamUrl: 'https://example.com/bad.mjpg',
|
||||
sourceType: 'mjpeg',
|
||||
}
|
||||
const wrapper = await mountSuspended(CameraViewer, { props: { camera } })
|
||||
const img = wrapper.find('img')
|
||||
await img.trigger('error')
|
||||
const wrapper = await mountSuspended(CameraViewer, {
|
||||
props: { camera: createCamera({ streamUrl: 'https://example.com/bad.mjpg' }) },
|
||||
})
|
||||
await wrapper.find('img').trigger('error')
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.text()).toContain('Stream unavailable')
|
||||
})
|
||||
|
||||
it('renders video element for hls sourceType', async () => {
|
||||
const camera = {
|
||||
id: 't7',
|
||||
name: 'HLS Camera',
|
||||
streamUrl: 'https://example.com/stream.m3u8',
|
||||
sourceType: 'hls',
|
||||
}
|
||||
const wrapper = await mountSuspended(CameraViewer, { props: { camera } })
|
||||
const wrapper = await mountSuspended(CameraViewer, {
|
||||
props: { camera: createCamera({ sourceType: 'hls', streamUrl: 'https://example.com/stream.m3u8' }) },
|
||||
})
|
||||
expect(wrapper.find('video').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,58 +1,43 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||
import NavDrawer from '../../app/components/NavDrawer.vue'
|
||||
|
||||
const withAuth = () => {
|
||||
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'user', role: 'member', avatar_url: null }), { method: 'GET' })
|
||||
const mountDrawer = (props = {}) => {
|
||||
return mountSuspended(NavDrawer, {
|
||||
props: { modelValue: true, ...props },
|
||||
attachTo: document.body,
|
||||
})
|
||||
}
|
||||
|
||||
describe('NavDrawer', () => {
|
||||
it('renders navigation links with correct paths', async () => {
|
||||
withAuth()
|
||||
await mountSuspended(NavDrawer, {
|
||||
props: { modelValue: true },
|
||||
attachTo: document.body,
|
||||
})
|
||||
const links = document.body.querySelectorAll('aside nav a[href]')
|
||||
const hrefs = [...links].map(a => a.getAttribute('href'))
|
||||
expect(hrefs).toContain('/')
|
||||
expect(hrefs).toContain('/account')
|
||||
expect(hrefs).toContain('/cameras')
|
||||
expect(hrefs).toContain('/poi')
|
||||
expect(hrefs).toContain('/members')
|
||||
expect(hrefs).toContain('/settings')
|
||||
expect(links.length).toBeGreaterThanOrEqual(6)
|
||||
beforeEach(() => {
|
||||
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'user', role: 'member', avatar_url: null }), { method: 'GET' })
|
||||
})
|
||||
|
||||
it('renders Map and Settings labels', async () => {
|
||||
withAuth()
|
||||
await mountSuspended(NavDrawer, {
|
||||
props: { modelValue: true },
|
||||
attachTo: document.body,
|
||||
})
|
||||
expect(document.body.textContent).toContain('Map')
|
||||
expect(document.body.textContent).toContain('Settings')
|
||||
it('renders navigation links with correct paths', async () => {
|
||||
await mountDrawer()
|
||||
const hrefs = [...document.body.querySelectorAll('aside nav a[href]')].map(a => a.getAttribute('href'))
|
||||
expect(hrefs).toEqual(expect.arrayContaining(['/', '/account', '/cameras', '/poi', '/members', '/settings']))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Map'],
|
||||
['Settings'],
|
||||
])('renders %s label', async (label) => {
|
||||
await mountDrawer()
|
||||
expect(document.body.textContent).toContain(label)
|
||||
})
|
||||
|
||||
it('emits update:modelValue when close is triggered', async () => {
|
||||
withAuth()
|
||||
const wrapper = await mountSuspended(NavDrawer, {
|
||||
props: { modelValue: true },
|
||||
attachTo: document.body,
|
||||
})
|
||||
const wrapper = await mountDrawer()
|
||||
expect(document.body.querySelector('aside button[aria-label="Close navigation"]')).toBeTruthy()
|
||||
await wrapper.vm.close()
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
|
||||
})
|
||||
|
||||
it('applies active styling for current route', async () => {
|
||||
withAuth()
|
||||
await mountSuspended(NavDrawer, {
|
||||
props: { modelValue: true },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await mountDrawer()
|
||||
const mapLink = document.body.querySelector('aside nav a[href="/"]')
|
||||
expect(mapLink).toBeTruthy()
|
||||
expect(mapLink.className).toMatch(/kestrel-accent|border-kestrel-accent/)
|
||||
expect(mapLink?.className).toMatch(/kestrel-accent|border-kestrel-accent/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,13 @@ import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||
import Index from '../../app/pages/index.vue'
|
||||
import Login from '../../app/pages/login.vue'
|
||||
|
||||
const wait = (ms = 200) => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
const setupProtectedEndpoints = () => {
|
||||
registerEndpoint('/api/cameras', () => ({ devices: [], liveSessions: [] }), { method: 'GET' })
|
||||
registerEndpoint('/api/pois', () => [], { method: 'GET' })
|
||||
}
|
||||
|
||||
describe('auth middleware', () => {
|
||||
it('allows /login without redirect when unauthenticated', async () => {
|
||||
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
||||
@@ -11,28 +18,25 @@ describe('auth middleware', () => {
|
||||
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('redirects to /login with redirect query when unauthenticated and visiting protected route', async () => {
|
||||
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
||||
registerEndpoint('/api/cameras', () => ({ devices: [], liveSessions: [] }), { method: 'GET' })
|
||||
registerEndpoint('/api/pois', () => [], { method: 'GET' })
|
||||
it.each([
|
||||
[() => null, '/login', { redirect: '/' }],
|
||||
[
|
||||
() => {
|
||||
throw createError({ statusCode: 401 })
|
||||
},
|
||||
'/login',
|
||||
undefined,
|
||||
],
|
||||
])('redirects to /login when unauthenticated: %s', async (meResponse, expectedPath, expectedQuery) => {
|
||||
registerEndpoint('/api/me', meResponse, { method: 'GET' })
|
||||
setupProtectedEndpoints()
|
||||
await mountSuspended(Index)
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
await wait(meResponse.toString().includes('401') ? 250 : 200)
|
||||
const router = useRouter()
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
expect(router.currentRoute.value.query.redirect).toBe('/')
|
||||
})
|
||||
|
||||
it('401 handler redirects to login when API returns 401', async () => {
|
||||
registerEndpoint('/api/me', () => {
|
||||
throw createError({ statusCode: 401 })
|
||||
}, { method: 'GET' })
|
||||
registerEndpoint('/api/cameras', () => ({ devices: [], liveSessions: [] }), { method: 'GET' })
|
||||
registerEndpoint('/api/pois', () => [], { method: 'GET' })
|
||||
await mountSuspended(Index)
|
||||
await new Promise(r => setTimeout(r, 250))
|
||||
const router = useRouter()
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
expect(router.currentRoute.value.path).toBe(expectedPath)
|
||||
if (expectedQuery) {
|
||||
expect(router.currentRoute.value.query).toMatchObject(expectedQuery)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,46 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||
import DefaultLayout from '../../app/layouts/default.vue'
|
||||
import NavDrawer from '../../app/components/NavDrawer.vue'
|
||||
|
||||
const withAuth = () => {
|
||||
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'user', role: 'member', avatar_url: null }), { method: 'GET' })
|
||||
}
|
||||
const wait = (ms = 100) => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
describe('default layout', () => {
|
||||
it('renders KestrelOS header', async () => {
|
||||
withAuth()
|
||||
const wrapper = await mountSuspended(DefaultLayout)
|
||||
expect(wrapper.text()).toContain('KestrelOS')
|
||||
beforeEach(() => {
|
||||
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'user', role: 'member', avatar_url: null }), { method: 'GET' })
|
||||
})
|
||||
|
||||
it('renders drawer toggle with accessible label on mobile', async () => {
|
||||
withAuth()
|
||||
it.each([
|
||||
['KestrelOS header', 'KestrelOS'],
|
||||
['drawer toggle', 'button[aria-label="Toggle navigation"]'],
|
||||
])('renders %s', async (description, selector) => {
|
||||
const wrapper = await mountSuspended(DefaultLayout)
|
||||
const toggle = wrapper.find('button[aria-label="Toggle navigation"]')
|
||||
expect(toggle.exists()).toBe(true)
|
||||
if (selector.startsWith('button')) {
|
||||
expect(wrapper.find(selector).exists()).toBe(true)
|
||||
}
|
||||
else {
|
||||
expect(wrapper.text()).toContain(selector)
|
||||
}
|
||||
})
|
||||
|
||||
it('renders NavDrawer', async () => {
|
||||
withAuth()
|
||||
const wrapper = await mountSuspended(DefaultLayout)
|
||||
expect(wrapper.findComponent(NavDrawer).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders user menu and sign out navigates home', async () => {
|
||||
withAuth()
|
||||
registerEndpoint('/api/auth/logout', () => null, { method: 'POST' })
|
||||
const wrapper = await mountSuspended(DefaultLayout)
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
await wait()
|
||||
const menuTrigger = wrapper.find('button[aria-label="User menu"]')
|
||||
expect(menuTrigger.exists()).toBe(true)
|
||||
await menuTrigger.trigger('click')
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
await wait(50)
|
||||
const signOut = wrapper.find('button[role="menuitem"]')
|
||||
expect(signOut.exists()).toBe(true)
|
||||
expect(signOut.text()).toContain('Sign out')
|
||||
await signOut.trigger('click')
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
await wait()
|
||||
const router = useRouter()
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.path).toBe('/')
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest'
|
||||
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||
import Index from '../../app/pages/index.vue'
|
||||
|
||||
const wait = (ms = 150) => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
describe('index page', () => {
|
||||
it('renders map and uses cameras', async () => {
|
||||
registerEndpoint('/api/cameras', () => ({
|
||||
@@ -11,7 +13,7 @@ describe('index page', () => {
|
||||
registerEndpoint('/api/pois', () => [])
|
||||
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
||||
const wrapper = await mountSuspended(Index)
|
||||
await new Promise(r => setTimeout(r, 150))
|
||||
await wait()
|
||||
expect(wrapper.findComponent({ name: 'KestrelMap' }).exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
102
test/nuxt/logger.spec.js
Normal file
102
test/nuxt/logger.spec.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||
import { readBody } from 'h3'
|
||||
import { initLogger, logError, logWarn, logInfo, logDebug } from '../../app/utils/logger.js'
|
||||
|
||||
const wait = (ms = 10) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
describe('app/utils/logger', () => {
|
||||
const consoleMocks = {}
|
||||
const originalConsole = {}
|
||||
const testState = {
|
||||
serverCalls: [],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
testState.serverCalls = []
|
||||
const calls = { log: [], error: [], warn: [], debug: [] }
|
||||
|
||||
Object.keys(calls).forEach((key) => {
|
||||
originalConsole[key] = console[key]
|
||||
consoleMocks[key] = vi.fn((...args) => calls[key].push(args))
|
||||
console[key] = consoleMocks[key]
|
||||
})
|
||||
|
||||
registerEndpoint('/api/log', async (event) => {
|
||||
const body = event.body || (await readBody(event).catch(() => ({})))
|
||||
testState.serverCalls.push(body)
|
||||
return { ok: true }
|
||||
}, { method: 'POST' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.keys(originalConsole).forEach((key) => {
|
||||
console[key] = originalConsole[key]
|
||||
})
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('initLogger', () => {
|
||||
it('sets sessionId and userId for server calls', async () => {
|
||||
initLogger('session-123', 'user-456')
|
||||
logError('Test message')
|
||||
await wait()
|
||||
|
||||
expect(testState.serverCalls[0]).toMatchObject({
|
||||
sessionId: 'session-123',
|
||||
userId: 'user-456',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('log functions', () => {
|
||||
it.each([
|
||||
['logError', logError, 'error', 'error'],
|
||||
['logWarn', logWarn, 'warn', 'warn'],
|
||||
['logInfo', logInfo, 'info', 'log'],
|
||||
['logDebug', logDebug, 'debug', 'log'],
|
||||
])('%s logs to console and sends to server', async (name, logFn, level, consoleKey) => {
|
||||
initLogger('session-123', 'user-456')
|
||||
logFn('Test message', { key: 'value' })
|
||||
await wait()
|
||||
|
||||
expect(consoleMocks[consoleKey]).toHaveBeenCalledWith(`[Test message]`, { key: 'value' })
|
||||
expect(testState.serverCalls[0]).toMatchObject({
|
||||
level,
|
||||
message: 'Test message',
|
||||
data: { key: 'value' },
|
||||
})
|
||||
})
|
||||
|
||||
it('handles server fetch failure gracefully', async () => {
|
||||
registerEndpoint('/api/log', () => {
|
||||
throw new Error('Network error')
|
||||
}, { method: 'POST' })
|
||||
|
||||
initLogger('session-123', 'user-456')
|
||||
expect(() => logError('Test error')).not.toThrow()
|
||||
await wait()
|
||||
expect(consoleMocks.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendToServer', () => {
|
||||
it('includes timestamp in server request', async () => {
|
||||
initLogger('session-123', 'user-456')
|
||||
logError('Test message')
|
||||
await wait()
|
||||
|
||||
expect(testState.serverCalls[0].timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
|
||||
})
|
||||
|
||||
it('handles null sessionId and userId', async () => {
|
||||
initLogger(null, null)
|
||||
logError('Test message')
|
||||
await wait()
|
||||
|
||||
const { sessionId, userId } = testState.serverCalls[0]
|
||||
expect(sessionId === null || sessionId === undefined).toBe(true)
|
||||
expect(userId === null || userId === undefined).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,31 +2,34 @@ import { describe, it, expect } from 'vitest'
|
||||
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||
import Login from '../../app/pages/login.vue'
|
||||
|
||||
const wait = (ms = 50) => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
describe('login page', () => {
|
||||
it('renders sign in form (local auth always shown)', async () => {
|
||||
registerEndpoint('/api/auth/config', () => ({ oidc: { enabled: false, label: '' } }), { method: 'GET' })
|
||||
const wrapper = await mountSuspended(Login)
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
await wait()
|
||||
expect(wrapper.text()).toContain('Sign in')
|
||||
expect(wrapper.find('input[type="text"]').exists()).toBe(true)
|
||||
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows OIDC button when OIDC is enabled', async () => {
|
||||
registerEndpoint('/api/auth/config', () => ({ oidc: { enabled: true, label: 'Sign in with Authentik' } }), { method: 'GET' })
|
||||
it.each([
|
||||
[{ enabled: true, label: 'Sign in with Authentik' }, true, false],
|
||||
[{ enabled: true, label: 'Sign in with OIDC' }, true, true],
|
||||
])('shows OIDC when enabled: %j', async (oidcConfig, shouldShowButton, shouldShowPassword) => {
|
||||
registerEndpoint('/api/auth/config', () => ({ oidc: oidcConfig }), { method: 'GET' })
|
||||
await clearNuxtData('auth-config')
|
||||
const wrapper = await mountSuspended(Login)
|
||||
await new Promise(r => setTimeout(r, 150))
|
||||
expect(wrapper.text()).toContain('Sign in with Authentik')
|
||||
expect(wrapper.find('a[href*="/api/auth/oidc/authorize"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows both OIDC button and password form when OIDC is enabled', async () => {
|
||||
registerEndpoint('/api/auth/config', () => ({ oidc: { enabled: true, label: 'Sign in with OIDC' } }), { method: 'GET' })
|
||||
await clearNuxtData('auth-config')
|
||||
const wrapper = await mountSuspended(Login)
|
||||
await new Promise(r => setTimeout(r, 150))
|
||||
expect(wrapper.find('a[href*="/api/auth/oidc/authorize"]').exists()).toBe(true)
|
||||
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
|
||||
await wait(150)
|
||||
if (shouldShowButton) {
|
||||
expect(wrapper.find('a[href*="/api/auth/oidc/authorize"]').exists()).toBe(true)
|
||||
if (oidcConfig.label) {
|
||||
expect(wrapper.text()).toContain(oidcConfig.label)
|
||||
}
|
||||
}
|
||||
if (shouldShowPassword) {
|
||||
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,18 +2,41 @@ import { describe, it, expect } from 'vitest'
|
||||
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||
import Members from '../../app/pages/members.vue'
|
||||
|
||||
const wait = (ms = 100) => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
const setupEndpoints = (userResponse) => {
|
||||
registerEndpoint('/api/me', userResponse, { method: 'GET' })
|
||||
registerEndpoint('/api/users', () => [])
|
||||
}
|
||||
|
||||
describe('members page', () => {
|
||||
it('renders Members heading', async () => {
|
||||
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
||||
registerEndpoint('/api/users', () => [])
|
||||
setupEndpoints(() => null)
|
||||
const wrapper = await mountSuspended(Members)
|
||||
expect(wrapper.text()).toContain('Members')
|
||||
})
|
||||
|
||||
it('shows sign in message when no user', async () => {
|
||||
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
||||
registerEndpoint('/api/users', () => [])
|
||||
setupEndpoints(() => null)
|
||||
const wrapper = await mountSuspended(Members)
|
||||
expect(wrapper.text()).toMatch(/Sign in to view members/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
{ id: '1', identifier: 'admin', role: 'admin', avatar_url: null },
|
||||
['Add user', /Only admins can change roles/],
|
||||
],
|
||||
[
|
||||
{ id: '2', identifier: 'leader', role: 'leader', avatar_url: null },
|
||||
['Members', 'Identifier'],
|
||||
],
|
||||
])('shows content for %s role', async (user, expectedTexts) => {
|
||||
setupEndpoints(() => user)
|
||||
const wrapper = await mountSuspended(Members)
|
||||
await wait(user.role === 'leader' ? 150 : 100)
|
||||
expectedTexts.forEach((text) => {
|
||||
expect(wrapper.text()).toMatch(text)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||
import Poi from '../../app/pages/poi.vue'
|
||||
|
||||
describe('poi page', () => {
|
||||
it('renders POI placement heading', async () => {
|
||||
beforeEach(() => {
|
||||
registerEndpoint('/api/pois', () => [])
|
||||
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
||||
const wrapper = await mountSuspended(Poi)
|
||||
expect(wrapper.text()).toContain('POI placement')
|
||||
})
|
||||
|
||||
it('shows view-only message when cannot edit', async () => {
|
||||
registerEndpoint('/api/pois', () => [])
|
||||
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
||||
it.each([
|
||||
['POI placement heading', 'POI placement'],
|
||||
['view-only message', /View-only|Sign in as admin/],
|
||||
])('renders %s', async (description, expected) => {
|
||||
const wrapper = await mountSuspended(Poi)
|
||||
expect(wrapper.text()).toMatch(/View-only|Sign in as admin/)
|
||||
expect(wrapper.text()).toMatch(expected)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,27 +2,45 @@ import { describe, it, expect } from 'vitest'
|
||||
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||
import Index from '../../app/pages/index.vue'
|
||||
|
||||
const wait = (ms = 100) => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
const setupEndpoints = (camerasResponse) => {
|
||||
registerEndpoint('/api/cameras', camerasResponse)
|
||||
registerEndpoint('/api/pois', () => [])
|
||||
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
||||
}
|
||||
|
||||
describe('useCameras', () => {
|
||||
it('page uses cameras from API', async () => {
|
||||
registerEndpoint('/api/cameras', () => ({
|
||||
setupEndpoints(() => ({
|
||||
devices: [{ id: '1', name: 'Test', lat: 37.7, lng: -122.4, streamUrl: '', sourceType: 'mjpeg', device_type: 'feed' }],
|
||||
liveSessions: [],
|
||||
cotEntities: [],
|
||||
}))
|
||||
registerEndpoint('/api/pois', () => [])
|
||||
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
||||
const wrapper = await mountSuspended(Index)
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
await wait()
|
||||
expect(wrapper.findComponent({ name: 'KestrelMap' }).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('exposes cotEntities from API', async () => {
|
||||
const cotEntities = [{ id: 'cot-1', lat: 38, lng: -123, label: 'ATAK1' }]
|
||||
setupEndpoints(() => ({
|
||||
devices: [],
|
||||
liveSessions: [],
|
||||
cotEntities,
|
||||
}))
|
||||
const wrapper = await mountSuspended(Index)
|
||||
await wait()
|
||||
const map = wrapper.findComponent({ name: 'KestrelMap' })
|
||||
expect(map.props('cotEntities')).toEqual(cotEntities)
|
||||
})
|
||||
|
||||
it('handles API error and falls back to empty devices and liveSessions', async () => {
|
||||
registerEndpoint('/api/cameras', () => {
|
||||
setupEndpoints(() => {
|
||||
throw new Error('network')
|
||||
})
|
||||
registerEndpoint('/api/pois', () => [])
|
||||
registerEndpoint('/api/me', () => null, { method: 'GET' })
|
||||
const wrapper = await mountSuspended(Index)
|
||||
await new Promise(r => setTimeout(r, 150))
|
||||
await wait(150)
|
||||
expect(wrapper.findComponent({ name: 'KestrelMap' }).exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,38 +3,59 @@ import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { useLiveSessions } from '../../app/composables/useLiveSessions.js'
|
||||
|
||||
const wait = (ms = 100) => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
const createTestComponent = (setupFn) => {
|
||||
return defineComponent({
|
||||
setup: setupFn,
|
||||
})
|
||||
}
|
||||
|
||||
const setupEndpoints = (liveResponse) => {
|
||||
registerEndpoint('/api/live', liveResponse)
|
||||
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'u', role: 'member' }), { method: 'GET' })
|
||||
}
|
||||
|
||||
describe('useLiveSessions', () => {
|
||||
it('fetches sessions from API and returns sessions ref', async () => {
|
||||
registerEndpoint('/api/live', () => [
|
||||
{ id: 's1', label: 'Live 1', hasStream: true, lat: 37, lng: -122 },
|
||||
])
|
||||
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'u', role: 'member' }), { method: 'GET' })
|
||||
const TestComponent = defineComponent({
|
||||
setup() {
|
||||
const { sessions } = useLiveSessions()
|
||||
return () => h('div', { 'data-sessions': JSON.stringify(sessions.value) })
|
||||
},
|
||||
setupEndpoints(() => [{ id: 's1', label: 'Live 1', hasStream: true, lat: 37, lng: -122 }])
|
||||
const TestComponent = createTestComponent(() => {
|
||||
const { sessions } = useLiveSessions()
|
||||
return () => h('div', { 'data-sessions': JSON.stringify(sessions.value) })
|
||||
})
|
||||
const wrapper = await mountSuspended(TestComponent)
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
await wait()
|
||||
expect(wrapper.find('[data-sessions]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns empty array when fetch fails', async () => {
|
||||
registerEndpoint('/api/live', () => {
|
||||
setupEndpoints(() => {
|
||||
throw new Error('fetch failed')
|
||||
})
|
||||
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'u', role: 'member' }), { method: 'GET' })
|
||||
const TestComponent = defineComponent({
|
||||
setup() {
|
||||
const { sessions } = useLiveSessions()
|
||||
return () => h('div', { 'data-sessions': JSON.stringify(sessions.value) })
|
||||
},
|
||||
const TestComponent = createTestComponent(() => {
|
||||
const { sessions } = useLiveSessions()
|
||||
return () => h('div', { 'data-sessions': JSON.stringify(sessions.value) })
|
||||
})
|
||||
const wrapper = await mountSuspended(TestComponent)
|
||||
await new Promise(r => setTimeout(r, 150))
|
||||
const el = wrapper.find('[data-sessions]')
|
||||
expect(el.exists()).toBe(true)
|
||||
expect(JSON.parse(el.attributes('data-sessions'))).toEqual([])
|
||||
await wait(150)
|
||||
const sessions = JSON.parse(wrapper.find('[data-sessions]').attributes('data-sessions'))
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
|
||||
it('startPolling and stopPolling manage interval', async () => {
|
||||
setupEndpoints(() => [])
|
||||
const TestComponent = createTestComponent(() => {
|
||||
const { startPolling, stopPolling } = useLiveSessions()
|
||||
return () => h('div', {
|
||||
onClick: () => {
|
||||
startPolling()
|
||||
startPolling()
|
||||
stopPolling()
|
||||
},
|
||||
})
|
||||
})
|
||||
const wrapper = await mountSuspended(TestComponent)
|
||||
await wrapper.trigger('click')
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
104
test/unit/asyncLock.spec.js
Normal file
104
test/unit/asyncLock.spec.js
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { acquire, clearLocks } from '../../server/utils/asyncLock.js'
|
||||
|
||||
describe('asyncLock', () => {
|
||||
beforeEach(() => {
|
||||
clearLocks()
|
||||
})
|
||||
|
||||
it('executes callback immediately when no lock exists', async () => {
|
||||
const executed = { value: false }
|
||||
await acquire('test', async () => {
|
||||
executed.value = true
|
||||
return 42
|
||||
})
|
||||
expect(executed.value).toBe(true)
|
||||
})
|
||||
|
||||
it('returns callback result', async () => {
|
||||
const result = await acquire('test', async () => {
|
||||
return { value: 123 }
|
||||
})
|
||||
expect(result).toEqual({ value: 123 })
|
||||
})
|
||||
|
||||
it('serializes concurrent operations on same key', async () => {
|
||||
const results = []
|
||||
const promises = Array.from({ length: 5 }, (_, i) =>
|
||||
acquire('same-key', async () => {
|
||||
results.push(`start-${i}`)
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
results.push(`end-${i}`)
|
||||
return i
|
||||
}),
|
||||
)
|
||||
|
||||
await Promise.all(promises)
|
||||
|
||||
// Operations should be serialized: start-end pairs should not interleave
|
||||
expect(results.length).toBe(10)
|
||||
Array.from({ length: 5 }, (_, i) => {
|
||||
expect(results[i * 2]).toBe(`start-${i}`)
|
||||
expect(results[i * 2 + 1]).toBe(`end-${i}`)
|
||||
})
|
||||
})
|
||||
|
||||
it('allows parallel operations on different keys', async () => {
|
||||
const results = []
|
||||
const promises = Array.from({ length: 5 }, (_, i) =>
|
||||
acquire(`key-${i}`, async () => {
|
||||
results.push(`start-${i}`)
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
results.push(`end-${i}`)
|
||||
return i
|
||||
}),
|
||||
)
|
||||
|
||||
await Promise.all(promises)
|
||||
|
||||
// Different keys can run in parallel
|
||||
expect(results.length).toBe(10)
|
||||
// All starts should come before all ends (parallel execution)
|
||||
const starts = results.filter(r => r.startsWith('start'))
|
||||
const ends = results.filter(r => r.startsWith('end'))
|
||||
expect(starts.length).toBe(5)
|
||||
expect(ends.length).toBe(5)
|
||||
})
|
||||
|
||||
it('handles errors and releases lock', async () => {
|
||||
const callCount = { value: 0 }
|
||||
try {
|
||||
await acquire('error-key', async () => {
|
||||
callCount.value++
|
||||
throw new Error('Test error')
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
expect(error.message).toBe('Test error')
|
||||
}
|
||||
|
||||
// Lock should be released, next operation should execute
|
||||
await acquire('error-key', async () => {
|
||||
callCount.value++
|
||||
return 'success'
|
||||
})
|
||||
|
||||
expect(callCount.value).toBe(2)
|
||||
})
|
||||
|
||||
it('maintains lock ordering', async () => {
|
||||
const order = []
|
||||
const promises = Array.from({ length: 3 }, (_, i) =>
|
||||
acquire('ordered', async () => {
|
||||
order.push(`before-${i}`)
|
||||
await new Promise(resolve => setTimeout(resolve, 5))
|
||||
order.push(`after-${i}`)
|
||||
}),
|
||||
)
|
||||
|
||||
await Promise.all(promises)
|
||||
|
||||
// Should execute in order: before-0, after-0, before-1, after-1, before-2, after-2
|
||||
expect(order).toEqual(['before-0', 'after-0', 'before-1', 'after-1', 'before-2', 'after-2'])
|
||||
})
|
||||
})
|
||||
@@ -1,43 +1,51 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { getAuthConfig } from '../../server/utils/authConfig.js'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getAuthConfig } from '../../server/utils/oidc.js'
|
||||
import { withTemporaryEnv } from '../helpers/env.js'
|
||||
|
||||
describe('authConfig', () => {
|
||||
const origEnv = { ...process.env }
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...origEnv }
|
||||
it('returns oidc disabled when OIDC env vars are unset', () => {
|
||||
withTemporaryEnv(
|
||||
{ OIDC_ISSUER: undefined, OIDC_CLIENT_ID: undefined, OIDC_CLIENT_SECRET: undefined },
|
||||
() => {
|
||||
expect(getAuthConfig()).toEqual({ oidc: { enabled: false, label: '' } })
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('returns oidc disabled when OIDC env vars are unset', () => {
|
||||
delete process.env.OIDC_ISSUER
|
||||
delete process.env.OIDC_CLIENT_ID
|
||||
delete process.env.OIDC_CLIENT_SECRET
|
||||
expect(getAuthConfig()).toEqual({
|
||||
oidc: { enabled: false, label: '' },
|
||||
it.each([
|
||||
[{ OIDC_ISSUER: 'https://auth.example.com' }, false],
|
||||
[{ OIDC_CLIENT_ID: 'client' }, false],
|
||||
[{ OIDC_ISSUER: 'https://auth.example.com', OIDC_CLIENT_ID: 'client' }, false],
|
||||
])('returns oidc disabled when only some vars are set: %j', (env, expected) => {
|
||||
withTemporaryEnv({ ...env, OIDC_CLIENT_SECRET: undefined }, () => {
|
||||
expect(getAuthConfig().oidc.enabled).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
it('returns oidc disabled when only some OIDC vars are set', () => {
|
||||
process.env.OIDC_ISSUER = 'https://auth.example.com'
|
||||
process.env.OIDC_CLIENT_ID = 'client'
|
||||
delete process.env.OIDC_CLIENT_SECRET
|
||||
expect(getAuthConfig().oidc.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('returns oidc enabled and default label when all OIDC vars are set', () => {
|
||||
process.env.OIDC_ISSUER = 'https://auth.example.com'
|
||||
process.env.OIDC_CLIENT_ID = 'client'
|
||||
process.env.OIDC_CLIENT_SECRET = 'secret'
|
||||
const config = getAuthConfig()
|
||||
expect(config.oidc.enabled).toBe(true)
|
||||
expect(config.oidc.label).toBe('Sign in with OIDC')
|
||||
it('returns oidc enabled with default label when all vars are set', () => {
|
||||
withTemporaryEnv(
|
||||
{
|
||||
OIDC_ISSUER: 'https://auth.example.com',
|
||||
OIDC_CLIENT_ID: 'client',
|
||||
OIDC_CLIENT_SECRET: 'secret',
|
||||
},
|
||||
() => {
|
||||
expect(getAuthConfig()).toEqual({ oidc: { enabled: true, label: 'Sign in with OIDC' } })
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('uses OIDC_LABEL when set', () => {
|
||||
process.env.OIDC_ISSUER = 'https://auth.example.com'
|
||||
process.env.OIDC_CLIENT_ID = 'client'
|
||||
process.env.OIDC_CLIENT_SECRET = 'secret'
|
||||
process.env.OIDC_LABEL = 'Sign in with Authentik'
|
||||
expect(getAuthConfig().oidc.label).toBe('Sign in with Authentik')
|
||||
withTemporaryEnv(
|
||||
{
|
||||
OIDC_ISSUER: 'https://auth.example.com',
|
||||
OIDC_CLIENT_ID: 'client',
|
||||
OIDC_CLIENT_SECRET: 'secret',
|
||||
OIDC_LABEL: 'Sign in with Authentik',
|
||||
},
|
||||
() => {
|
||||
expect(getAuthConfig().oidc.label).toBe('Sign in with Authentik')
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { requireAuth } from '../../server/utils/authHelpers.js'
|
||||
|
||||
function mockEvent(user = null) {
|
||||
return { context: { user } }
|
||||
}
|
||||
const mockEvent = (user = null) => ({ context: { user } })
|
||||
|
||||
describe('authHelpers', () => {
|
||||
it('requireAuth throws 401 when no user', () => {
|
||||
@@ -19,43 +17,29 @@ describe('authHelpers', () => {
|
||||
|
||||
it('requireAuth returns user when set', () => {
|
||||
const user = { id: '1', identifier: 'a@b.com', role: 'member' }
|
||||
expect(requireAuth(mockEvent(user))).toEqual(user)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['member', 'adminOrLeader', 403],
|
||||
['admin', 'adminOrLeader', null],
|
||||
['leader', 'adminOrLeader', null],
|
||||
['leader', 'admin', 403],
|
||||
['admin', 'admin', null],
|
||||
])('requireAuth with %s role and %s requirement', (userRole, requirement, expectedStatus) => {
|
||||
const user = { id: '1', identifier: 'a', role: userRole }
|
||||
const event = mockEvent(user)
|
||||
expect(requireAuth(event)).toEqual(user)
|
||||
})
|
||||
|
||||
it('requireAuth with adminOrLeader throws 403 for member', () => {
|
||||
const event = mockEvent({ id: '1', identifier: 'a', role: 'member' })
|
||||
expect(() => requireAuth(event, { role: 'adminOrLeader' })).toThrow()
|
||||
try {
|
||||
requireAuth(event, { role: 'adminOrLeader' })
|
||||
if (expectedStatus === null) {
|
||||
expect(requireAuth(event, { role: requirement })).toEqual(user)
|
||||
}
|
||||
catch (e) {
|
||||
expect(e.statusCode).toBe(403)
|
||||
else {
|
||||
expect(() => requireAuth(event, { role: requirement })).toThrow()
|
||||
try {
|
||||
requireAuth(event, { role: requirement })
|
||||
}
|
||||
catch (e) {
|
||||
expect(e.statusCode).toBe(expectedStatus)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('requireAuth with adminOrLeader returns user for admin', () => {
|
||||
const user = { id: '1', identifier: 'a', role: 'admin' }
|
||||
expect(requireAuth(mockEvent(user), { role: 'adminOrLeader' })).toEqual(user)
|
||||
})
|
||||
|
||||
it('requireAuth with adminOrLeader returns user for leader', () => {
|
||||
const user = { id: '1', identifier: 'a', role: 'leader' }
|
||||
expect(requireAuth(mockEvent(user), { role: 'adminOrLeader' })).toEqual(user)
|
||||
})
|
||||
|
||||
it('requireAuth with admin throws 403 for leader', () => {
|
||||
const event = mockEvent({ id: '1', identifier: 'a', role: 'leader' })
|
||||
try {
|
||||
requireAuth(event, { role: 'admin' })
|
||||
}
|
||||
catch (e) {
|
||||
expect(e.statusCode).toBe(403)
|
||||
}
|
||||
})
|
||||
|
||||
it('requireAuth with admin returns user for admin', () => {
|
||||
const user = { id: '1', identifier: 'a', role: 'admin' }
|
||||
expect(requireAuth(mockEvent(user), { role: 'admin' })).toEqual(user)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,36 +1,40 @@
|
||||
/**
|
||||
* Ensures no API route that requires auth (requireAuth with optional role)
|
||||
* is in the auth skip list. When adding a new protected API, add its path prefix to
|
||||
* PROTECTED_PATH_PREFIXES in server/utils/authSkipPaths.js so these tests fail if it gets skipped.
|
||||
* PROTECTED_PATH_PREFIXES in server/utils/authHelpers.js so these tests fail if it gets skipped.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { skipAuth, SKIP_PATHS, PROTECTED_PATH_PREFIXES } from '../../server/utils/authSkipPaths.js'
|
||||
import { skipAuth, SKIP_PATHS, PROTECTED_PATH_PREFIXES } from '../../server/utils/authHelpers.js'
|
||||
|
||||
describe('authSkipPaths', () => {
|
||||
it('does not skip any protected path (auth required for these)', () => {
|
||||
for (const path of PROTECTED_PATH_PREFIXES) {
|
||||
it('does not skip any protected path', () => {
|
||||
const protectedPaths = [
|
||||
...PROTECTED_PATH_PREFIXES,
|
||||
'/api/cameras',
|
||||
'/api/devices',
|
||||
'/api/devices/any-id',
|
||||
'/api/me',
|
||||
'/api/pois',
|
||||
'/api/pois/any-id',
|
||||
'/api/users',
|
||||
'/api/users/any-id',
|
||||
]
|
||||
protectedPaths.forEach((path) => {
|
||||
expect(skipAuth(path)).toBe(false)
|
||||
}
|
||||
// Also check a concrete path under each prefix
|
||||
expect(skipAuth('/api/cameras')).toBe(false)
|
||||
expect(skipAuth('/api/devices')).toBe(false)
|
||||
expect(skipAuth('/api/devices/any-id')).toBe(false)
|
||||
expect(skipAuth('/api/me')).toBe(false)
|
||||
expect(skipAuth('/api/pois')).toBe(false)
|
||||
expect(skipAuth('/api/pois/any-id')).toBe(false)
|
||||
expect(skipAuth('/api/users')).toBe(false)
|
||||
expect(skipAuth('/api/users/any-id')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('skips known public paths', () => {
|
||||
expect(skipAuth('/api/auth/login')).toBe(true)
|
||||
expect(skipAuth('/api/auth/logout')).toBe(true)
|
||||
expect(skipAuth('/api/auth/config')).toBe(true)
|
||||
expect(skipAuth('/api/auth/oidc/authorize')).toBe(true)
|
||||
expect(skipAuth('/api/auth/oidc/callback')).toBe(true)
|
||||
expect(skipAuth('/api/health')).toBe(true)
|
||||
expect(skipAuth('/api/health/ready')).toBe(true)
|
||||
expect(skipAuth('/health')).toBe(true)
|
||||
it.each([
|
||||
'/api/auth/login',
|
||||
'/api/auth/logout',
|
||||
'/api/auth/config',
|
||||
'/api/auth/oidc/authorize',
|
||||
'/api/auth/oidc/callback',
|
||||
'/api/health',
|
||||
'/api/health/ready',
|
||||
'/health',
|
||||
])('skips public path: %s', (path) => {
|
||||
expect(skipAuth(path)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps SKIP_PATHS and PROTECTED_PATH_PREFIXES disjoint', () => {
|
||||
|
||||
40
test/unit/constants.spec.js
Normal file
40
test/unit/constants.spec.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
COT_AUTH_TIMEOUT_MS,
|
||||
LIVE_SESSION_TTL_MS,
|
||||
COT_ENTITY_TTL_MS,
|
||||
POLL_INTERVAL_MS,
|
||||
SHUTDOWN_TIMEOUT_MS,
|
||||
COT_PORT,
|
||||
WEBSOCKET_PATH,
|
||||
MAX_PAYLOAD_BYTES,
|
||||
MAX_STRING_LENGTH,
|
||||
MAX_IDENTIFIER_LENGTH,
|
||||
MEDIASOUP_RTC_MIN_PORT,
|
||||
MEDIASOUP_RTC_MAX_PORT,
|
||||
} from '../../server/utils/constants.js'
|
||||
|
||||
describe('constants', () => {
|
||||
it('uses default values when env vars not set', () => {
|
||||
expect(COT_AUTH_TIMEOUT_MS).toBe(15000)
|
||||
expect(LIVE_SESSION_TTL_MS).toBe(60000)
|
||||
expect(COT_ENTITY_TTL_MS).toBe(90000)
|
||||
expect(POLL_INTERVAL_MS).toBe(1500)
|
||||
expect(SHUTDOWN_TIMEOUT_MS).toBe(30000)
|
||||
expect(COT_PORT).toBe(8089)
|
||||
expect(WEBSOCKET_PATH).toBe('/ws')
|
||||
expect(MAX_PAYLOAD_BYTES).toBe(64 * 1024)
|
||||
expect(MAX_STRING_LENGTH).toBe(1000)
|
||||
expect(MAX_IDENTIFIER_LENGTH).toBe(255)
|
||||
expect(MEDIASOUP_RTC_MIN_PORT).toBe(40000)
|
||||
expect(MEDIASOUP_RTC_MAX_PORT).toBe(49999)
|
||||
})
|
||||
|
||||
it('handles invalid env var values gracefully', () => {
|
||||
// Constants are evaluated at module load time, so env vars set in tests won't affect them
|
||||
// This test verifies the pattern: Number(process.env.VAR) || default
|
||||
const invalidValue = Number('invalid')
|
||||
expect(Number.isNaN(invalidValue)).toBe(true)
|
||||
expect(invalidValue || 15000).toBe(15000)
|
||||
})
|
||||
})
|
||||
63
test/unit/cotAuth.spec.js
Normal file
63
test/unit/cotAuth.spec.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { getDb, setDbPathForTest } from '../../server/utils/db.js'
|
||||
import { hashPassword } from '../../server/utils/password.js'
|
||||
import { validateCotAuth } from '../../server/utils/cotAuth.js'
|
||||
|
||||
describe('cotAuth', () => {
|
||||
beforeEach(async () => {
|
||||
setDbPathForTest(':memory:')
|
||||
const { run } = await getDb()
|
||||
const now = new Date().toISOString()
|
||||
await run(
|
||||
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
['local-1', 'localuser', hashPassword('webpass'), 'member', now, 'local', null, null],
|
||||
)
|
||||
await run(
|
||||
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub, cot_password_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
['oidc-1', 'oidcuser', null, 'member', now, 'oidc', 'https://idp', 'sub-1', hashPassword('atakpass')],
|
||||
)
|
||||
await run(
|
||||
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub, cot_password_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
['oidc-2', 'nopass', null, 'member', now, 'oidc', 'https://idp', 'sub-2', null],
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setDbPathForTest(null)
|
||||
})
|
||||
|
||||
it('validates local user with correct password', async () => {
|
||||
const ok = await validateCotAuth('localuser', 'webpass')
|
||||
expect(ok).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects local user with wrong password', async () => {
|
||||
const ok = await validateCotAuth('localuser', 'wrong')
|
||||
expect(ok).toBe(false)
|
||||
})
|
||||
|
||||
it('validates OIDC user with correct ATAK password', async () => {
|
||||
const ok = await validateCotAuth('oidcuser', 'atakpass')
|
||||
expect(ok).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects OIDC user with wrong ATAK password', async () => {
|
||||
const ok = await validateCotAuth('oidcuser', 'wrong')
|
||||
expect(ok).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects OIDC user who has not set ATAK password', async () => {
|
||||
const ok = await validateCotAuth('nopass', 'any')
|
||||
expect(ok).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects unknown identifier', async () => {
|
||||
const ok = await validateCotAuth('nobody', 'x')
|
||||
expect(ok).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects empty identifier', async () => {
|
||||
const ok = await validateCotAuth('', 'x')
|
||||
expect(ok).toBe(false)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user