Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cebeee4040 | |||
| bb01e9a06c | |||
| a6b87305a1 | |||
| 829db93065 | |||
| afaf305bda | |||
| 2a48715ea8 | |||
| 9d34f5f7c5 | |||
| 5b4bb6b33a | |||
| e16b492257 | |||
| 265e02119a | |||
| 82d454ade4 | |||
| a9b300d711 | |||
| fded3a04d4 | |||
| 0d897f17b5 | |||
| 216f6f83fe | |||
| c023bdccae | |||
| 78f3ad8fcc | |||
| 2e7a52ed15 | |||
| 221b3533e5 | |||
| 578dccd0cf | |||
| 0ecad475ef | |||
| d5789b79a6 | |||
| 45ce90f3cc | |||
| 3a817625c5 | |||
| d5abea48b3 | |||
| 1da69ac272 | |||
| bd2092a3ea | |||
| afb6fb8ac7 | |||
| 414e9f4c33 | |||
| 10035221fb | |||
| 52a6f4368c | |||
| e774dbc301 | |||
| 5bf582f3ad | |||
| 7398bb0a16 | |||
| 79d36df83c | |||
| 2146c06b02 | |||
| 7d955cd89f | |||
| 68082fd893 | |||
| 7fc4685cfc | |||
| e61e6bc7e3 |
@@ -0,0 +1,92 @@
|
||||
name: PR
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://git.keligrubb.com/actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: https://git.keligrubb.com/actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run lint
|
||||
run: npm run lint
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://git.keligrubb.com/actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: https://git.keligrubb.com/actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
steps:
|
||||
- uses: https://git.keligrubb.com/actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: https://git.keligrubb.com/actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Generate dev cert
|
||||
run: ./scripts/gen-dev-cert.sh
|
||||
|
||||
- name: Run e2e tests
|
||||
run: npm run test:e2e
|
||||
env:
|
||||
NODE_TLS_REJECT_UNAUTHORIZED: "0"
|
||||
|
||||
docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://git.keligrubb.com/actions/checkout@v7
|
||||
|
||||
- name: Set Docker image tag
|
||||
id: image
|
||||
run: |
|
||||
REGISTRY="${GITHUB_SERVER_URL#https://}"
|
||||
REGISTRY="${REGISTRY#http://}"
|
||||
echo "tag=${REGISTRY}/${{ github.repository }}:latest" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: https://git.keligrubb.com/actions/docker-setup-buildx-action@v4
|
||||
|
||||
- name: Build (dry run)
|
||||
uses: https://git.keligrubb.com/actions/docker-build-push-action@v7
|
||||
env:
|
||||
# Keeps GITHUB_OUTPUT small; Gitea act-runner can choke on multiline
|
||||
# outputs when PR webhook payloads (e.g. Renovate bodies) are huge.
|
||||
DOCKER_BUILD_SUMMARY: "false"
|
||||
DOCKER_BUILD_RECORD_UPLOAD: "false"
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
provenance: false
|
||||
sbom: false
|
||||
tags: ${{ steps.image.outputs.tag }}
|
||||
@@ -0,0 +1,90 @@
|
||||
name: Push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://git.keligrubb.com/actions/checkout@v7
|
||||
with:
|
||||
token: ${{ secrets.KESTRELOS_REPO_TOKEN }}
|
||||
|
||||
- name: Get PR description for changelog
|
||||
env:
|
||||
GITEA_REPO_TOKEN: ${{ secrets.KESTRELOS_REPO_TOKEN }}
|
||||
run: |
|
||||
sudo rm -f /etc/apt/sources.list.d/microsoft*.list /etc/apt/sources.list.d/azure*.list 2>/dev/null || true
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq jq
|
||||
RESP=$(curl -sf -H "Authorization: token $GITEA_REPO_TOKEN" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/commits/${{ github.sha }}/pull") || true
|
||||
if [ -n "$RESP" ]; then
|
||||
echo "$RESP" | jq -r '.body // empty' > .ci_pr_body 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Release (bump, tag, push, create release)
|
||||
env:
|
||||
CI_REPO_OWNER: ${{ github.actor }}
|
||||
CI_REPO_NAME: ${{ github.event.repository.name }}
|
||||
CI_FORGE_URL: ${{ github.server_url }}
|
||||
CI_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
|
||||
GITEA_REPO_TOKEN: ${{ secrets.KESTRELOS_REPO_TOKEN }}
|
||||
run: |
|
||||
sudo rm -f /etc/apt/sources.list.d/microsoft*.list /etc/apt/sources.list.d/azure*.list 2>/dev/null || true
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq git wget
|
||||
./scripts/release.sh
|
||||
|
||||
publish:
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://git.keligrubb.com/actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.KESTRELOS_REPO_TOKEN }}
|
||||
|
||||
- name: Log in to container registry
|
||||
uses: https://git.keligrubb.com/actions/docker-login-action@v4
|
||||
with:
|
||||
registry: git.keligrubb.com
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.KESTRELOS_REPO_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: https://git.keligrubb.com/actions/docker-setup-buildx-action@v4
|
||||
|
||||
- name: Build Docker image
|
||||
uses: https://git.keligrubb.com/actions/docker-build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
load: true
|
||||
tags: kestrelos:built
|
||||
|
||||
- name: Push Docker image (version + latest)
|
||||
run: |
|
||||
VERSION=$(awk '/"version"/ { match($0, /[0-9]+\.[0-9]+\.[0-9]+/); print substr($0, RSTART, RLENGTH); exit }' package.json)
|
||||
case "$VERSION" in
|
||||
[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) echo "error: package.json version must be x.y.z (got: $VERSION)"; exit 1 ;;
|
||||
esac
|
||||
REGISTRY="git.keligrubb.com"
|
||||
IMAGE="$REGISTRY/${{ github.repository }}"
|
||||
for tag in "$VERSION" latest; do
|
||||
docker tag kestrelos:built "$IMAGE:$tag"
|
||||
docker push "$IMAGE:$tag"
|
||||
done
|
||||
|
||||
- name: Set up Helm
|
||||
uses: https://git.keligrubb.com/actions/setup-helm@v5
|
||||
|
||||
- name: Package and push Helm chart
|
||||
env:
|
||||
GITEA_REPO_TOKEN: ${{ secrets.KESTRELOS_REPO_TOKEN }}
|
||||
run: |
|
||||
helm package helm/kestrelos
|
||||
for f in kestrelos-*.tgz; do
|
||||
curl -sf -u "${{ github.actor }}:$GITEA_REPO_TOKEN" -X POST --upload-file "$f" \
|
||||
"${{ github.server_url }}/api/packages/${{ github.actor }}/helm/api/charts"
|
||||
done
|
||||
@@ -1,38 +0,0 @@
|
||||
when:
|
||||
- event: pull_request
|
||||
|
||||
steps:
|
||||
- name: lint
|
||||
image: node:24-slim
|
||||
depends_on: []
|
||||
commands:
|
||||
- npm ci
|
||||
- npm run lint
|
||||
|
||||
- name: test
|
||||
image: node:24-slim
|
||||
depends_on: []
|
||||
commands:
|
||||
- npm ci
|
||||
- npm run test
|
||||
|
||||
- name: e2e
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-noble
|
||||
depends_on: []
|
||||
commands:
|
||||
- npm ci
|
||||
- ./scripts/gen-dev-cert.sh
|
||||
- npm run test:e2e
|
||||
environment:
|
||||
NODE_TLS_REJECT_UNAUTHORIZED: "0"
|
||||
|
||||
- name: docker-build
|
||||
image: woodpeckerci/plugin-kaniko
|
||||
depends_on: []
|
||||
settings:
|
||||
repo: ${CI_REPO_OWNER}/${CI_REPO_NAME}
|
||||
registry: git.keligrubb.com
|
||||
tags: latest
|
||||
dry-run: true
|
||||
single-snapshot: true
|
||||
cleanup: true
|
||||
@@ -1,36 +0,0 @@
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
|
||||
steps:
|
||||
- name: release
|
||||
image: alpine
|
||||
commands:
|
||||
- apk add --no-cache git
|
||||
- ./scripts/release.sh
|
||||
environment:
|
||||
GITEA_REPO_TOKEN:
|
||||
from_secret: gitea_repo_token
|
||||
|
||||
- name: docker
|
||||
image: woodpeckerci/plugin-kaniko
|
||||
depends_on: [release]
|
||||
settings:
|
||||
repo: ${CI_REPO_OWNER}/${CI_REPO_NAME}
|
||||
registry: git.keligrubb.com
|
||||
username: ${CI_REPO_OWNER}
|
||||
password:
|
||||
from_secret: gitea_registry_token
|
||||
single-snapshot: true
|
||||
cleanup: true
|
||||
|
||||
- name: helm
|
||||
image: alpine/helm
|
||||
depends_on: [release]
|
||||
environment:
|
||||
GITEA_REGISTRY_TOKEN:
|
||||
from_secret: gitea_registry_token
|
||||
commands:
|
||||
- apk add --no-cache curl
|
||||
- helm package helm/kestrelos
|
||||
- curl -sf -u $CI_REPO_OWNER:$GITEA_REGISTRY_TOKEN -X POST --upload-file kestrelos-*.tgz https://git.keligrubb.com/api/packages/$CI_REPO_OWNER/helm/api/charts
|
||||
+593
@@ -1,3 +1,596 @@
|
||||
## [1.1.6] - 2026-06-24
|
||||
### Changed
|
||||
- Add ADS-B, AIS, and ALPR map layers with live CoT streaming (#36)
|
||||
|
||||
## Summary
|
||||
|
||||
- **ADS-B & AIS:** OpenSky and AISStream OSINT feeds upsert into the CoT store; tactical tracks still arrive via adsbcot/aiscot on `:8089`. Map clients subscribe via `GET /api/cot/stream` (SSE) with viewport bbox filtering and Air / Surface / Team layer toggles.
|
||||
- **ALPR (Flock/OSM):** Toggleable license-plate reader layer sourced from OpenStreetMap, with SQLite cache, Overpass fallback, tiled viewport fetching, and clustered markers with direction cones.
|
||||
- **Map performance:** Ring-based tile selection (fixes zoom-out crash), immutable tile cache, incremental marker sync, split cluster load/query, and padded SSE bbox to reduce reconnect churn.
|
||||
|
||||
## Docs
|
||||
|
||||
- `docs/tracking.md` — ADS-B/AIS accuracy tiers, freshness, self-hosted receivers, optional OSINT API keys
|
||||
- `docs/map-and-cameras.md` — ALPR layer and map behavior updates
|
||||
|
||||
## [1.1.5] - 2026-06-21
|
||||
### Changed
|
||||
- update https://git.keligrubb.com/actions/checkout action to v7 (#35)
|
||||
|
||||
This PR contains the following updates:
|
||||
|
||||
| Package | Type | Update | Change |
|
||||
|---|---|---|---|
|
||||
| [https://git.keligrubb.com/actions/checkout](https://git.keligrubb.com/actions/checkout) | action | major | `v6` → `v7` |
|
||||
|
||||
---
|
||||
|
||||
### Release Notes
|
||||
|
||||
<details>
|
||||
<summary>actions/checkout (https://git.keligrubb.com/actions/checkout)</summary>
|
||||
|
||||
### [`v7.0.0`](https://git.keligrubb.com/actions/checkout/blob/HEAD/CHANGELOG.md#v700)
|
||||
|
||||
[Compare Source](https://git.keligrubb.com/actions/checkout/compare/v7...v7)
|
||||
|
||||
- Block checking out fork PR for pull\_request\_target and workflow\_run by [@​aiqiaoy](https://github.com/aiqiaoy) in [#​2454](https://github.com/actions/checkout/pull/2454)
|
||||
- Bump actions/publish-immutable-action from 0.0.3 to 0.0.4 in the minor-actions-dependencies group across 1 directory by [@​dependabot](https://github.com/dependabot)\[bot] in [#​2458](https://github.com/actions/checkout/pull/2458)
|
||||
- Bump flatted from 3.3.1 to 3.4.2 by [@​dependabot](https://github.com/dependabot)\[bot] in [#​2460](https://github.com/actions/checkout/pull/2460)
|
||||
- Bump js-yaml from 4.1.0 to 4.2.0 by [@​dependabot](https://github.com/dependabot)\[bot] in [#​2461](https://github.com/actions/checkout/pull/2461)
|
||||
- Bump [@​actions/core](https://github.com/actions/core) and [@​actions/tool-cache](https://github.com/actions/tool-cache) and Remove uuid by [@​dependabot](https://github.com/dependabot)\[bot] in [#​2459](https://github.com/actions/checkout/pull/2459)
|
||||
- upgrade module to esm and update dependencies by [@​aiqiaoy](https://github.com/aiqiaoy) in [#​2463](https://github.com/actions/checkout/pull/2463)
|
||||
- Bump the minor-npm-dependencies group across 1 directory with 3 updates by [@​dependabot](https://github.com/dependabot)\[bot] in [#​2462](https://github.com/actions/checkout/pull/2462)
|
||||
|
||||
### [`v7`](https://git.keligrubb.com/actions/checkout/blob/HEAD/CHANGELOG.md#v700)
|
||||
|
||||
[Compare Source](https://git.keligrubb.com/actions/checkout/compare/v6.0.3...v7)
|
||||
|
||||
- Block checking out fork PR for pull\_request\_target and workflow\_run by [@​aiqiaoy](https://github.com/aiqiaoy) in [#​2454](https://github.com/actions/checkout/pull/2454)
|
||||
- Bump actions/publish-immutable-action from 0.0.3 to 0.0.4 in the minor-actions-dependencies group across 1 directory by [@​dependabot](https://github.com/dependabot)\[bot] in [#​2458](https://github.com/actions/checkout/pull/2458)
|
||||
- Bump flatted from 3.3.1 to 3.4.2 by [@​dependabot](https://github.com/dependabot)\[bot] in [#​2460](https://github.com/actions/checkout/pull/2460)
|
||||
- Bump js-yaml from 4.1.0 to 4.2.0 by [@​dependabot](https://github.com/dependabot)\[bot] in [#​2461](https://github.com/actions/checkout/pull/2461)
|
||||
- Bump [@​actions/core](https://github.com/actions/core) and [@​actions/tool-cache](https://github.com/actions/tool-cache) and Remove uuid by [@​dependabot](https://github.com/dependabot)\[bot] in [#​2459](https://github.com/actions/checkout/pull/2459)
|
||||
- upgrade module to esm and update dependencies by [@​aiqiaoy](https://github.com/aiqiaoy) in [#​2463](https://github.com/actions/checkout/pull/2463)
|
||||
- Bump the minor-npm-dependencies group across 1 directory with 3 updates by [@​dependabot](https://github.com/dependabot)\[bot] in [#​2462](https://github.com/actions/checkout/pull/2462)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
|
||||
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yMzMuNCIsInVwZGF0ZWRJblZlciI6IjQzLjIzMy40IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
|
||||
|
||||
## [1.1.4] - 2026-04-29
|
||||
### Changed
|
||||
- update all non-major dependencies (#31)
|
||||
|
||||
This PR contains the following updates:
|
||||
|
||||
| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|
||||
|---|---|---|---|
|
||||
| [@vitest/coverage-v8](https://vitest.dev/guide/coverage) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8)) | [`4.1.4` → `4.1.5`](https://renovatebot.com/diffs/npm/@vitest%2fcoverage-v8/4.1.4/4.1.5) |  |  |
|
||||
| [@vue/test-utils](https://github.com/vuejs/test-utils) | [`2.4.6` → `2.4.8`](https://renovatebot.com/diffs/npm/@vue%2ftest-utils/2.4.6/2.4.8) |  |  |
|
||||
| [eslint](https://eslint.org) ([source](https://github.com/eslint/eslint)) | [`10.2.0` → `10.2.1`](https://renovatebot.com/diffs/npm/eslint/10.2.0/10.2.1) |  |  |
|
||||
| [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) | [`5.6.0` → `5.7.2`](https://renovatebot.com/diffs/npm/fast-xml-parser/5.6.0/5.7.2) |  |  |
|
||||
| [mediasoup](https://mediasoup.org) ([source](https://github.com/versatica/mediasoup)) | [`3.19.19` → `3.19.21`](https://renovatebot.com/diffs/npm/mediasoup/3.19.19/3.19.21) |  |  |
|
||||
| [mediasoup-client](https://mediasoup.org) ([source](https://github.com/versatica/mediasoup-client)) | [`3.18.8` → `3.19.0`](https://renovatebot.com/diffs/npm/mediasoup-client/3.18.8/3.19.0) |  |  |
|
||||
| [vitest](https://vitest.dev) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)) | [`4.1.4` → `4.1.5`](https://renovatebot.com/diffs/npm/vitest/4.1.4/4.1.5) |  |  |
|
||||
| [vue](https://vuejs.org/) ([source](https://github.com/vuejs/core)) | [`3.5.32` → `3.5.33`](https://renovatebot.com/diffs/npm/vue/3.5.32/3.5.33) |  |  |
|
||||
| [vue-router](https://router.vuejs.org) ([source](https://github.com/vuejs/router)) | [`5.0.4` → `5.0.6`](https://renovatebot.com/diffs/npm/vue-router/5.0.4/5.0.6) |  |  |
|
||||
|
||||
---
|
||||
|
||||
### Release Notes
|
||||
|
||||
<details>
|
||||
<summary>vitest-dev/vitest (@​vitest/coverage-v8)</summary>
|
||||
|
||||
### [`v4.1.5`](https://github.com/vitest-dev/vitest/releases/tag/v4.1.5)
|
||||
|
||||
[Compare Source](https://github.com/vitest-dev/vitest/compare/v4.1.4...v4.1.5)
|
||||
|
||||
##### 🚀 Experimental Features
|
||||
|
||||
- **coverage**: Istanbul to support `instrumenter` option - by [@​BartWaardenburg](https://github.com/BartWaardenburg) and [@​AriPerkkio](https://github.com/AriPerkkio) in [#​10119](https://github.com/vitest-dev/vitest/issues/10119) [<samp>(0e0ff)</samp>](https://github.com/vitest-dev/vitest/commit/0e0ff41c7)
|
||||
|
||||
##### 🐞 Bug Fixes
|
||||
|
||||
- \--project negation excludes browser instances - by [@​felamaslen](https://github.com/felamaslen) in [#​10131](https://github.com/vitest-dev/vitest/issues/10131) [<samp>(9423d)</samp>](https://github.com/vitest-dev/vitest/commit/9423dc084)
|
||||
- Project color label on html reporter - by [@​hi-ogawa](https://github.com/hi-ogawa) in [#​10142](https://github.com/vitest-dev/vitest/issues/10142) [<samp>(596f7)</samp>](https://github.com/vitest-dev/vitest/commit/596f73986)
|
||||
- Fix `vi.defineHelper` called as object method - by [@​hi-ogawa](https://github.com/hi-ogawa) in [#​10163](https://github.com/vitest-dev/vitest/issues/10163) [<samp>(122c2)</samp>](https://github.com/vitest-dev/vitest/commit/122c25b5b)
|
||||
- Alias `agent` reporter to `minimal` - by [@​sheremet-va](https://github.com/sheremet-va) in [#​10157](https://github.com/vitest-dev/vitest/issues/10157) [<samp>(663b9)</samp>](https://github.com/vitest-dev/vitest/commit/663b99fe3)
|
||||
- Respect diff config options in soft assertions - by [@​Copilot](https://github.com/Copilot), **sheremet-va** and [@​sheremet-va](https://github.com/sheremet-va) in [#​8696](https://github.com/vitest-dev/vitest/issues/8696) [<samp>(9787d)</samp>](https://github.com/vitest-dev/vitest/commit/9787dedad)
|
||||
- Respect diff config options in soft assertions " - by [@​sheremet-va](https://github.com/sheremet-va) in [#​8696](https://github.com/vitest-dev/vitest/issues/8696) [<samp>(7dc6d)</samp>](https://github.com/vitest-dev/vitest/commit/7dc6d54fd)
|
||||
- **ast-collect**: Recognize \_*vi\_import* prefix in static test discovery - by [@​Yejneshwar](https://github.com/Yejneshwar) in [#​10129](https://github.com/vitest-dev/vitest/issues/10129) [<samp>(32546)</samp>](https://github.com/vitest-dev/vitest/commit/325463ab2)
|
||||
- **coverage**: Descriptive error message when reports directory is removed during test run - by [@​DaveT1991](https://github.com/DaveT1991) and [@​AriPerkkio](https://github.com/AriPerkkio) in [#​10117](https://github.com/vitest-dev/vitest/issues/10117) [<samp>(14133)</samp>](https://github.com/vitest-dev/vitest/commit/1413382e1)
|
||||
- **snapshot**: Increase default snapshot max output length - by [@​hi-ogawa](https://github.com/hi-ogawa) and **Codex** in [#​10150](https://github.com/vitest-dev/vitest/issues/10150) [<samp>(21e66)</samp>](https://github.com/vitest-dev/vitest/commit/21e66ff63)
|
||||
- **ui**: Fix jsx/tsx syntax highlight - by [@​hi-ogawa](https://github.com/hi-ogawa) in [#​10152](https://github.com/vitest-dev/vitest/issues/10152) [<samp>(f1b1f)</samp>](https://github.com/vitest-dev/vitest/commit/f1b1f6c7b)
|
||||
- **web-worker**: Support MessagePort objects referenced inside postMessage data - by [@​whitphx](https://github.com/whitphx) and **Claude Opus 4.6 (1M context)** in [#​9927](https://github.com/vitest-dev/vitest/issues/9927) and [#​10124](https://github.com/vitest-dev/vitest/issues/10124) [<samp>(7ad7d)</samp>](https://github.com/vitest-dev/vitest/commit/7ad7d39af)
|
||||
- **api**: Make test-specification options writable - by [@​sheremet-va](https://github.com/sheremet-va) in [#​10154](https://github.com/vitest-dev/vitest/issues/10154) [<samp>(6abd5)</samp>](https://github.com/vitest-dev/vitest/commit/6abd557b7)
|
||||
|
||||
##### [View changes on GitHub](https://github.com/vitest-dev/vitest/compare/v4.1.4...v4.1.5)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>vuejs/test-utils (@​vue/test-utils)</summary>
|
||||
|
||||
### [`v2.4.8`](https://github.com/vuejs/test-utils/releases/tag/v2.4.8)
|
||||
|
||||
[Compare Source](https://github.com/vuejs/test-utils/compare/v2.4.7...v2.4.8)
|
||||
|
||||
[compare changes](https://github.com/vuejs/test-utils/compare/v2.4.7...v2.4.8)
|
||||
|
||||
##### 🩹 Fixes
|
||||
|
||||
- Correct declaration entrypoint ([#​2826](https://github.com/vuejs/test-utils/pull/2826))
|
||||
|
||||
##### 🤖 CI
|
||||
|
||||
- Enable pkg.pr.new ([#​2827](https://github.com/vuejs/test-utils/pull/2827))
|
||||
|
||||
##### ❤️ Contributors
|
||||
|
||||
- Cédric Exbrayat ([@​cexbrayat](https://github.com/cexbrayat))
|
||||
- Daniel Roe ([@​danielroe](https://github.com/danielroe))
|
||||
|
||||
### [`v2.4.7`](https://github.com/vuejs/test-utils/releases/tag/v2.4.7)
|
||||
|
||||
[Compare Source](https://github.com/vuejs/test-utils/compare/v2.4.6...v2.4.7)
|
||||
|
||||
[compare changes](https://github.com/vuejs/test-utils/compare/v2.4.6...v2.4.7)
|
||||
|
||||
##### 🚀 Enhancements
|
||||
|
||||
- Add Chinese docs translation ([#​2552](https://github.com/vuejs/test-utils/pull/2552))
|
||||
- SetData()/shallowMount with initialData for components using the Composition API / <script setup> ([#​2655](https://github.com/vuejs/test-utils/pull/2655))
|
||||
|
||||
##### 🩹 Fixes
|
||||
|
||||
- Preserve code from keyboard events ([#​2434](https://github.com/vuejs/test-utils/pull/2434))
|
||||
- Switch browser and require exports definitions ([#​2501](https://github.com/vuejs/test-utils/pull/2501))
|
||||
- Re-add peer dependencies but with wider range ([#​2511](https://github.com/vuejs/test-utils/pull/2511))
|
||||
- Resolve warnings in docs:dev ([30b7491](https://github.com/vuejs/test-utils/commit/30b7491))
|
||||
- Resolve TypeScript type errors in .vitepress/config ([#​2549](https://github.com/vuejs/test-utils/pull/2549))
|
||||
- Accept FunctionalComponent<any> as selector ([0bb947f](https://github.com/vuejs/test-utils/commit/0bb947f))
|
||||
- Text() misses content for array functional component ([#​2579](https://github.com/vuejs/test-utils/pull/2579))
|
||||
- Use await in test ([c5482b4](https://github.com/vuejs/test-utils/commit/c5482b4))
|
||||
- **deps:** Update dependency vue-component-type-helpers to v3 ([#​2683](https://github.com/vuejs/test-utils/pull/2683))
|
||||
- Remove wrapper div when unmount ([#​2700](https://github.com/vuejs/test-utils/pull/2700))
|
||||
- Make mount options slots compatible with noUncheckedIndexedAccess true ([#​2713](https://github.com/vuejs/test-utils/pull/2713))
|
||||
- Add missing peerDependency [@​vue/compiler-dom](https://github.com/vue/compiler-dom) ([75801ba](https://github.com/vuejs/test-utils/commit/75801ba))
|
||||
- **docs:** Declare css module for vitepress typecheck ([ddaca97](https://github.com/vuejs/test-utils/commit/ddaca97))
|
||||
|
||||
##### 💅 Refactors
|
||||
|
||||
- Enforce consistent usage of type imports ([#​2734](https://github.com/vuejs/test-utils/pull/2734))
|
||||
|
||||
##### 📖 Documentation
|
||||
|
||||
- Clarify findComponent vs getComponent ([#​2435](https://github.com/vuejs/test-utils/pull/2435))
|
||||
- Update fr docs ([67064ef](https://github.com/vuejs/test-utils/commit/67064ef))
|
||||
- Add note about partial transition stub support ([#​2431](https://github.com/vuejs/test-utils/pull/2431))
|
||||
- Fix missing data at passing data section essentials guide ([dda205e](https://github.com/vuejs/test-utils/commit/dda205e))
|
||||
- Fix missing data at passing data section essentials guide fr ([ae2c72c](https://github.com/vuejs/test-utils/commit/ae2c72c))
|
||||
- Fix plugin TS declaration example ([#​2466](https://github.com/vuejs/test-utils/pull/2466))
|
||||
- Fixed incorrect checkbox value check ([#​2495](https://github.com/vuejs/test-utils/pull/2495))
|
||||
- Capital letter in sentence fix ([#​2499](https://github.com/vuejs/test-utils/pull/2499))
|
||||
- Import missing DOMWrapper on Implementation of the plugin section ([#​2519](https://github.com/vuejs/test-utils/pull/2519))
|
||||
- Add migration step for deprecated ref syntax in findAllComponents ([#​2498](https://github.com/vuejs/test-utils/pull/2498))
|
||||
- Correct anchor hash links and fix typo ([#​2551](https://github.com/vuejs/test-utils/pull/2551))
|
||||
- Center logo on home ([#​2559](https://github.com/vuejs/test-utils/pull/2559))
|
||||
- **zh-cn:** Review a-crash-course ([#​2563](https://github.com/vuejs/test-utils/pull/2563))
|
||||
- Use code-group for install commands ([#​2571](https://github.com/vuejs/test-utils/pull/2571))
|
||||
- **zh-cn:** Review event-handing.md ([#​2572](https://github.com/vuejs/test-utils/pull/2572))
|
||||
- **zh-cn:** Enhance conditional-rendering.md ([#​2562](https://github.com/vuejs/test-utils/pull/2562))
|
||||
- **zh-cn:** Review easy-to-test ([#​2567](https://github.com/vuejs/test-utils/pull/2567))
|
||||
- **zh-cn:** Review passing-data.md ([#​2575](https://github.com/vuejs/test-utils/pull/2575))
|
||||
- **zh-cn:** Review async-suspense.md ([#​2576](https://github.com/vuejs/test-utils/pull/2576))
|
||||
- **zh:** 优化 API 文档格式和内容 ([#​2569](https://github.com/vuejs/test-utils/pull/2569))
|
||||
- **zh:** 更新 Vitest 模拟日期和计时器的说明 ([#​2578](https://github.com/vuejs/test-utils/pull/2578))
|
||||
- **zh-cn:** Review http-requests.md ([#​2580](https://github.com/vuejs/test-utils/pull/2580))
|
||||
- **zh-cn:** Review forms ([#​2582](https://github.com/vuejs/test-utils/pull/2582))
|
||||
- **zh-cn:** Guide/advanced/slots.md ([#​2565](https://github.com/vuejs/test-utils/pull/2565))
|
||||
- **zh:** Review extending-vtu ([#​2583](https://github.com/vuejs/test-utils/pull/2583))
|
||||
- **zh:** Review index ([#​2584](https://github.com/vuejs/test-utils/pull/2584))
|
||||
- Fix modelValue test example ([85bfdf4](https://github.com/vuejs/test-utils/commit/85bfdf4))
|
||||
- Removes broken link from plugins.md ([69bc1ce](https://github.com/vuejs/test-utils/commit/69bc1ce))
|
||||
- **zh:** Review transitions, component-instance, and reusability-composition ([#​2616](https://github.com/vuejs/test-utils/pull/2616))
|
||||
- **zh:** Review v-model and vuex ([#​2617](https://github.com/vuejs/test-utils/pull/2617))
|
||||
- **zh:** Review all the rest advanced guide ([#​2619](https://github.com/vuejs/test-utils/pull/2619))
|
||||
- **zh:** Review migration ([#​2623](https://github.com/vuejs/test-utils/pull/2623))
|
||||
- Fix a typo in transitions.md ([#​2635](https://github.com/vuejs/test-utils/pull/2635))
|
||||
- Update crash-course to script setup ([c81aa79](https://github.com/vuejs/test-utils/commit/c81aa79))
|
||||
- Update Essentials section to setup (composition api) ([#​2647](https://github.com/vuejs/test-utils/pull/2647))
|
||||
- Typos in examples ([#​2678](https://github.com/vuejs/test-utils/pull/2678))
|
||||
- Typo in easy-to-test.md ([#​2710](https://github.com/vuejs/test-utils/pull/2710))
|
||||
- Add note about mocking requestAnimationFrame for transitions ([2324c65](https://github.com/vuejs/test-utils/commit/2324c65))
|
||||
- Updated example TodoApp to script setup ([#​2727](https://github.com/vuejs/test-utils/pull/2727))
|
||||
- Remove "Using data" section from "Conditional Rendering" guide and fix passing data test example ([#​2743](https://github.com/vuejs/test-utils/pull/2743))
|
||||
- Follow-up fixes for the conditional rendering guide ([#​2744](https://github.com/vuejs/test-utils/pull/2744))
|
||||
- Mention shallowMount stub name changes in migration guide ([80e051a](https://github.com/vuejs/test-utils/commit/80e051a))
|
||||
- Update conditional rendering documentation to clarify isVisible() usage with attachTo ([#​2799](https://github.com/vuejs/test-utils/pull/2799))
|
||||
- Restore Options API component for data() mounting example ([#​2804](https://github.com/vuejs/test-utils/pull/2804))
|
||||
- Promote Vitest as recommended test runner ([#​2805](https://github.com/vuejs/test-utils/pull/2805))
|
||||
- **api:** Note that setValue does not accept objects on `<select>` ([#​2819](https://github.com/vuejs/test-utils/pull/2819))
|
||||
|
||||
##### 🏡 Chore
|
||||
|
||||
- Add api/index.md to docs:translation:compare ([6b8681c](https://github.com/vuejs/test-utils/commit/6b8681c))
|
||||
- Remove unnecessary generic arguments ([cfd70c6](https://github.com/vuejs/test-utils/commit/cfd70c6))
|
||||
- Ignore TS error in config object ([9d0a618](https://github.com/vuejs/test-utils/commit/9d0a618))
|
||||
- Simplify eslint packages ([c1d0ffd](https://github.com/vuejs/test-utils/commit/c1d0ffd))
|
||||
- Use eslint v9 with flat config ([2f19fdf](https://github.com/vuejs/test-utils/commit/2f19fdf))
|
||||
- Expose Stubs type publicly ([#​2492](https://github.com/vuejs/test-utils/pull/2492))
|
||||
- Update documentation file path ([9c96594](https://github.com/vuejs/test-utils/commit/9c96594))
|
||||
- Use pnpm v10 ([e4c2cb3](https://github.com/vuejs/test-utils/commit/e4c2cb3))
|
||||
- Pnpm approve build ([81c54e9](https://github.com/vuejs/test-utils/commit/81c54e9))
|
||||
- Use github issue forms ([#​2673](https://github.com/vuejs/test-utils/pull/2673))
|
||||
- Exclude class components from test type-checking ([0899008](https://github.com/vuejs/test-utils/commit/0899008))
|
||||
- Add explicit coverage include for vitest v4 ([51672b9](https://github.com/vuejs/test-utils/commit/51672b9))
|
||||
- Update to prettier v3.7 ([fed9e7c](https://github.com/vuejs/test-utils/commit/fed9e7c))
|
||||
- Migrate to oxfmt ([81c1de9](https://github.com/vuejs/test-utils/commit/81c1de9))
|
||||
- Migrate to oxlint ([a361908](https://github.com/vuejs/test-utils/commit/a361908))
|
||||
- Prepare TypeScript 6 migration settings ([55e1262](https://github.com/vuejs/test-utils/commit/55e1262))
|
||||
- Adjust tsd config for TypeScript 6 ([7d23eb5](https://github.com/vuejs/test-utils/commit/7d23eb5))
|
||||
- Avoid TypeScript 6 target deprecation warning ([81d063c](https://github.com/vuejs/test-utils/commit/81d063c))
|
||||
|
||||
##### 🤖 CI
|
||||
|
||||
- Remove node v22 build ([7ebf58d](https://github.com/vuejs/test-utils/commit/7ebf58d))
|
||||
- Add node v22 build ([57540ee](https://github.com/vuejs/test-utils/commit/57540ee))
|
||||
- Use "pool: threads" instead of vmThreads ([d0cbb54](https://github.com/vuejs/test-utils/commit/d0cbb54))
|
||||
- Remove node v18 and add v24 ([fd9cf95](https://github.com/vuejs/test-utils/commit/fd9cf95))
|
||||
- Add trusted publishing release workflow ([#​2825](https://github.com/vuejs/test-utils/pull/2825))
|
||||
|
||||
##### ❤️ Contributors
|
||||
|
||||
- Lachlan Miller ([@​lmiller1990](https://github.com/lmiller1990))
|
||||
- cexbrayat ([@​cexbrayat](https://github.com/cexbrayat))
|
||||
- Nicolas Bonamy ([@​nbonamy](https://github.com/nbonamy))
|
||||
- KatWorkGit ([@​KatWorkGit](https://github.com/KatWorkGit))
|
||||
- Wouter Kroes ([@​wouterkroes](https://github.com/wouterkroes))
|
||||
- Rama Muhammad Murshal ([@​ramammurshal](https://github.com/ramammurshal))
|
||||
- Evan You ([@​yyx990803](https://github.com/yyx990803))
|
||||
- Vlad Starkovsky ([@​starkovsky](https://github.com/starkovsky))
|
||||
- Joe ([@​joaoprp](https://github.com/joaoprp))
|
||||
- Priyadarshi Kumar ([@​Psingh132](https://github.com/Psingh132))
|
||||
- Sébastien Ronveaux ([@​sronveaux](https://github.com/sronveaux))
|
||||
- Gilliam ([@​Gi11i4m](https://github.com/Gi11i4m))
|
||||
- Baranov Dmytro ([@​dimas7001](https://github.com/dimas7001))
|
||||
- BrendonHenrique ([@​BrendonHenrique](https://github.com/BrendonHenrique))
|
||||
- Lorenz van Herwaarden ([@​lorenzvanherwaarden](https://github.com/lorenzvanherwaarden))
|
||||
- wuzhiqing ([@​DDDDD12138](https://github.com/DDDDD12138))
|
||||
- 阿菜 Cai ([@​RSS1102](https://github.com/RSS1102))
|
||||
- Jinjiang ([@​Jinjiang](https://github.com/Jinjiang))
|
||||
- Kylin ([@​lxKylin](https://github.com/lxKylin))
|
||||
- Qianhe Chen ([@​chenqianhe](https://github.com/chenqianhe))
|
||||
- 时瑶 ([@​KiritaniAyaka](https://github.com/KiritaniAyaka))
|
||||
- h7ml ([@​h7ml](https://github.com/h7ml))
|
||||
- Nicander ([@​Nicander93](https://github.com/Nicander93))
|
||||
- Take-John ([@​takejohn](https://github.com/takejohn))
|
||||
- ilyasherstoboev ([@​ilyasherstoboev](https://github.com/ilyasherstoboev))
|
||||
- aimerie ([@​aimerie](https://github.com/aimerie))
|
||||
- Miguel Rincon ([@​miguelrincon](https://github.com/miguelrincon))
|
||||
- bcastlel ([@​bcastlel](https://github.com/bcastlel))
|
||||
- Claudiu ([@​sofuxro](https://github.com/sofuxro))
|
||||
- Artem Dragunov ([@​dragunovartem99](https://github.com/dragunovartem99))
|
||||
- Robin ([@​OrbisK](https://github.com/OrbisK))
|
||||
- Koen Mertens ([@​KCMertens](https://github.com/KCMertens))
|
||||
- meomking ([@​CaptainWang98](https://github.com/CaptainWang98))
|
||||
- Pepijn Olivier ([@​pepijnolivier](https://github.com/pepijnolivier))
|
||||
- Tomina ([@​Thomaash](https://github.com/Thomaash))
|
||||
- Gareth Jones ([@​G-Rath](https://github.com/G-Rath))
|
||||
- Jerry Hogan ([@​hdJerry](https://github.com/hdJerry))
|
||||
- Marco Pasqualetti ([@​marcalexiei](https://github.com/marcalexiei))
|
||||
- guoxk ([@​guoxk-me](https://github.com/guoxk-me))
|
||||
- kimulaco ([@​kimulaco](https://github.com/kimulaco))
|
||||
- Erwan IQUEL ([@​Olympus5](https://github.com/Olympus5))
|
||||
- Matt Van Horn ([@​mvanhorn](https://github.com/mvanhorn))
|
||||
- Daniel Roe ([@​danielroe](https://github.com/danielroe))
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>eslint/eslint (eslint)</summary>
|
||||
|
||||
### [`v10.2.1`](https://github.com/eslint/eslint/releases/tag/v10.2.1)
|
||||
|
||||
[Compare Source](https://github.com/eslint/eslint/compare/v10.2.0...v10.2.1)
|
||||
|
||||
#### Bug Fixes
|
||||
|
||||
- [`14be92b`](https://github.com/eslint/eslint/commit/14be92b6d1fa0923b8923830f2208e5e2705b002) fix: model generator yield resumption paths in code path analysis ([#​20665](https://github.com/eslint/eslint/issues/20665)) (sethamus)
|
||||
- [`84a19d2`](https://github.com/eslint/eslint/commit/84a19d2c32255db6b9cfc08644a607aae6d5cb62) fix: no-async-promise-executor false positives for shadowed Promise ([#​20740](https://github.com/eslint/eslint/issues/20740)) (xbinaryx)
|
||||
- [`af764af`](https://github.com/eslint/eslint/commit/af764af0ec38225755fbf8a6f207f0c77b595a8d) fix: clarify language and processor validation errors ([#​20729](https://github.com/eslint/eslint/issues/20729)) (Pixel998)
|
||||
- [`e251b89`](https://github.com/eslint/eslint/commit/e251b89a38280973e468a4a9386c138f4f55d10d) fix: update eslint ([#​20715](https://github.com/eslint/eslint/issues/20715)) (renovate\[bot])
|
||||
|
||||
#### Documentation
|
||||
|
||||
- [`ca92ca0`](https://github.com/eslint/eslint/commit/ca92ca0fb4599e8de1e2fb914e695fe7397cbe63) docs: reuse markdown-it instance for markdown filter ([#​20768](https://github.com/eslint/eslint/issues/20768)) (Amaresh S M)
|
||||
- [`57d2ee2`](https://github.com/eslint/eslint/commit/57d2ee213305cee0cb55ef08e0480b57396269a9) docs: Enable Eleventy incremental mode for watch ([#​20767](https://github.com/eslint/eslint/issues/20767)) (Amaresh S M)
|
||||
- [`c1621b9`](https://github.com/eslint/eslint/commit/c1621b915742276e5f4b25efe790ca62296330dc) docs: fix typos in code-path-analyzer.js ([#​20700](https://github.com/eslint/eslint/issues/20700)) (Ayush Shukla)
|
||||
- [`1418d52`](https://github.com/eslint/eslint/commit/1418d522d10bde1960f4942afb548bc7160ec49e) docs: Update README (GitHub Actions Bot)
|
||||
- [`39771e6`](https://github.com/eslint/eslint/commit/39771e6e600f0b0617fdeafff6dd07e4211ffde6) docs: Update README (GitHub Actions Bot)
|
||||
- [`71e0469`](https://github.com/eslint/eslint/commit/71e04693def2df57268f08f3072a2749df6bf438) docs: fix incomplete JSDoc param description in no-shadow rule ([#​20728](https://github.com/eslint/eslint/issues/20728)) (kuldeep kumar)
|
||||
- [`22119ce`](https://github.com/eslint/eslint/commit/22119ceb93e28f62262fc1d98ff1b1442d6e2dbf) docs: clarify scope of for-direction rule with dead code examples ([#​20723](https://github.com/eslint/eslint/issues/20723)) (Amaresh S M)
|
||||
- [`8f3fb77`](https://github.com/eslint/eslint/commit/8f3fb77f122a5641d1833cad5d93f3f54fa3be0b) docs: document `meta.docs.dialects` ([#​20718](https://github.com/eslint/eslint/issues/20718)) (Pixel998)
|
||||
|
||||
#### Chores
|
||||
|
||||
- [`7ddfea9`](https://github.com/eslint/eslint/commit/7ddfea9c4f62add1588c5c0b0da568c299246383) chore: update dependency prettier to v3.8.2 ([#​20770](https://github.com/eslint/eslint/issues/20770)) (renovate\[bot])
|
||||
- [`fac40e1`](https://github.com/eslint/eslint/commit/fac40e1de2ba7646cc7cd2d3f93fbdd1f8819001) ci: bump pnpm/action-setup from 5.0.0 to 6.0.0 ([#​20763](https://github.com/eslint/eslint/issues/20763)) (dependabot\[bot])
|
||||
- [`7246f92`](https://github.com/eslint/eslint/commit/7246f923332522d8b3d46b6ee646fce88535f3fb) test: add tests for SuppressionsService.load() error handling ([#​20734](https://github.com/eslint/eslint/issues/20734)) (kuldeep kumar)
|
||||
- [`4f34b1e`](https://github.com/eslint/eslint/commit/4f34b1e592b0f63d766d9903998e8e36eb49d3aa) chore: update pnpm/action-setup action to v5 ([#​20762](https://github.com/eslint/eslint/issues/20762)) (renovate\[bot])
|
||||
- [`51080eb`](https://github.com/eslint/eslint/commit/51080eb5c98d619434e4835dbe9f1c6654aca3b8) test: processor service ([#​20731](https://github.com/eslint/eslint/issues/20731)) (kuldeep kumar)
|
||||
- [`e7e1889`](https://github.com/eslint/eslint/commit/e7e1889fca9b6044e08f41b38df20a1ce45808c8) chore: remove stale babel-eslint10 fixture and test ([#​20727](https://github.com/eslint/eslint/issues/20727)) (kuldeep kumar)
|
||||
- [`4e1a87c`](https://github.com/eslint/eslint/commit/4e1a87cb8fb90e309524bc36bc5f31b9f9cfaa76) test: remove redundant async/await in flat config array tests ([#​20722](https://github.com/eslint/eslint/issues/20722)) (Pixel998)
|
||||
- [`066eabb`](https://github.com/eslint/eslint/commit/066eabb3643b12931f991594969bcc0028f71a5f) test: add rule metadata coverage for `languages` and `docs.dialects` ([#​20717](https://github.com/eslint/eslint/issues/20717)) (Pixel998)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>NaturalIntelligence/fast-xml-parser (fast-xml-parser)</summary>
|
||||
|
||||
### [`v5.7.2`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.7.2): backward compatibility for numerical external entity, fix #​705, #​817
|
||||
|
||||
[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.7.1...v5.7.2)
|
||||
|
||||
- allow numerical external entity for backward compatibility
|
||||
- fix [#​705](https://github.com/NaturalIntelligence/fast-xml-parser/issues/705): attributesGroupName working with preserveOrder
|
||||
- fix [#​817](https://github.com/NaturalIntelligence/fast-xml-parser/issues/817): stackoverflow when tag expression is very long
|
||||
|
||||
### [`v5.7.1`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.7.1): upgrade @​nodable/entities and FXB
|
||||
|
||||
[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.7.0...v5.7.1)
|
||||
|
||||
- Use `@nodable/entities` v2.1.0
|
||||
- breaking changes
|
||||
- single entity scan. You're not allowed to use entity value to form another entity name.
|
||||
- you cant add numeric external entity
|
||||
- entity error message when expantion limit is crossed might change
|
||||
- typings are updated for new options related to process entity
|
||||
- please follow documentation of `@nodable/entities` for more detail.
|
||||
- performance
|
||||
- if processEntities is false, then there should not be impact on performance.
|
||||
- if processEntities is true, but you dont pass entity decoder separately then performance may degrade by approx 8-10%
|
||||
- if processEntities is true, and you pass entity decoder separately
|
||||
- if no entity then performance should be same as before
|
||||
- if there are entities then performance should be increased from past versions
|
||||
- ignoreAttributes is not required to be set to set xml version for NCR entity value
|
||||
- update 'fast-xml-builder' to sanitize malicious CDATA and comment's content
|
||||
|
||||
### [`v5.7.0`](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.6.0...v5.7.0)
|
||||
|
||||
[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.6.0...v5.7.0)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>versatica/mediasoup (mediasoup)</summary>
|
||||
|
||||
### [`v3.19.21`](https://github.com/versatica/mediasoup/blob/HEAD/CHANGELOG.md#31921)
|
||||
|
||||
[Compare Source](https://github.com/versatica/mediasoup/compare/3.19.20...3.19.21)
|
||||
|
||||
- Worker: Fix regression in `DirectTransport` when closing a `DataProducer` or `DataConsumer` ([PR #​1780](https://github.com/versatica/mediasoup/pull/1780)).
|
||||
|
||||
### [`v3.19.20`](https://github.com/versatica/mediasoup/blob/HEAD/CHANGELOG.md#31920)
|
||||
|
||||
[Compare Source](https://github.com/versatica/mediasoup/compare/3.19.19...3.19.20)
|
||||
|
||||
- Worker: Add `useBuiltInSctpStack` setting (defaults to `false`) to enable mediasoup built-in SCTP stack ([PR #​1777](https://github.com/versatica/mediasoup/pull/1777)).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>versatica/mediasoup-client (mediasoup-client)</summary>
|
||||
|
||||
### [`v3.19.0`](https://github.com/versatica/mediasoup-client/compare/3.18.8...3.19.0)
|
||||
|
||||
[Compare Source](https://github.com/versatica/mediasoup-client/compare/3.18.8...3.19.0)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>vuejs/core (vue)</summary>
|
||||
|
||||
### [`v3.5.33`](https://github.com/vuejs/core/blob/HEAD/CHANGELOG.md#3533-2026-04-22)
|
||||
|
||||
[Compare Source](https://github.com/vuejs/core/compare/v3.5.32...v3.5.33)
|
||||
|
||||
##### Bug Fixes
|
||||
|
||||
- **compiler-sfc:** handle nested :deep in selector pseudos ([#​14725](https://github.com/vuejs/core/issues/14725)) ([bb9d265](https://github.com/vuejs/core/commit/bb9d265d8dcdde2af824fc01b24f9a7b3169f5fa)), closes [#​14724](https://github.com/vuejs/core/issues/14724)
|
||||
- **reactivity:** unlink effect scopes on out-of-order off ([#​14734](https://github.com/vuejs/core/issues/14734)) ([e7659be](https://github.com/vuejs/core/commit/e7659beafc5407e892fa70f3f4ade80263b0905d)), closes [#​14733](https://github.com/vuejs/core/issues/14733)
|
||||
- **runtime-dom:** preserve textarea resize dimensions ([#​14747](https://github.com/vuejs/core/issues/14747)) ([11fb2fd](https://github.com/vuejs/core/commit/11fb2fd4a246e40f6f350701dfea73ec525b4f59)), closes [#​14741](https://github.com/vuejs/core/issues/14741)
|
||||
- **teleport:** don't move teleport children if not mounted ([#​14702](https://github.com/vuejs/core/issues/14702)) ([6a61f44](https://github.com/vuejs/core/commit/6a61f4452ba1a31fc929cadf8abe3337ac4d3a46)), closes [#​14701](https://github.com/vuejs/core/issues/14701)
|
||||
- **transition:** preserve placeholder for conditional explicit default slots ([#​14748](https://github.com/vuejs/core/issues/14748)) ([45990ce](https://github.com/vuejs/core/commit/45990cecf4604b2f39c571ab6aefa49d362af36a)), closes [#​14727](https://github.com/vuejs/core/issues/14727)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>vuejs/router (vue-router)</summary>
|
||||
|
||||
### [`v5.0.6`](https://github.com/vuejs/router/releases/tag/v5.0.6)
|
||||
|
||||
[Compare Source](https://github.com/vuejs/router/compare/v5.0.5...v5.0.6)
|
||||
|
||||
##### 🐞 Bug Fixes
|
||||
|
||||
- Missing closing quote in generated import - by [@​zjy040525](https://github.com/zjy040525) and [@​posva](https://github.com/posva) in [#​2688](https://github.com/vuejs/router/issues/2688) [<samp>(32f78)</samp>](https://github.com/vuejs/router/commit/32f78c77)
|
||||
|
||||
##### [View changes on GitHub](https://github.com/vuejs/router/compare/v5.0.5...v5.0.6)
|
||||
|
||||
### [`v5.0.5`](https://github.com/vuejs/router/releases/tag/v5.0.5)
|
||||
|
||||
[Compare Source](https://github.com/vuejs/router/compare/v5.0.4...v5.0.5)
|
||||
|
||||
##### 🚀 Features
|
||||
|
||||
- Enable standard schema param parsers - by [@​posva](https://github.com/posva) [<samp>(ea8e3)</samp>](https://github.com/vuejs/router/commit/ea8e3e21)
|
||||
- Normalize param parsers once - by [@​posva](https://github.com/posva) [<samp>(48087)</samp>](https://github.com/vuejs/router/commit/480877cc)
|
||||
|
||||
##### 🐞 Bug Fixes
|
||||
|
||||
- Track definePage imports per-file to fix named view race condition - by [@​posva](https://github.com/posva) [<samp>(11191)</samp>](https://github.com/vuejs/router/commit/11191bca)
|
||||
- Avoid double decoding hash on string location - by [@​posva](https://github.com/posva) [<samp>(1578c)</samp>](https://github.com/vuejs/router/commit/1578c9e9)
|
||||
|
||||
##### [View changes on GitHub](https://github.com/vuejs/router/compare/v5.0.4...v5.0.5)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
|
||||
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMzIuMyIsInVwZGF0ZWRJblZlciI6IjQzLjE1MC4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
|
||||
|
||||
## [1.1.3] - 2026-04-19
|
||||
### Changed
|
||||
- update dependency mediasoup-client to v3.18.8 (#30)
|
||||
|
||||
This PR contains the following updates:
|
||||
|
||||
| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|
||||
|---|---|---|---|
|
||||
| [mediasoup-client](https://mediasoup.org) ([source](https://github.com/versatica/mediasoup-client)) | [`3.18.7` → `3.18.8`](https://renovatebot.com/diffs/npm/mediasoup-client/3.18.7/3.18.8) |  |  |
|
||||
|
||||
---
|
||||
|
||||
### Release Notes
|
||||
|
||||
<details>
|
||||
<summary>versatica/mediasoup-client (mediasoup-client)</summary>
|
||||
|
||||
### [`v3.18.8`](https://github.com/versatica/mediasoup-client/compare/3.18.7...3.18.8)
|
||||
|
||||
[Compare Source](https://github.com/versatica/mediasoup-client/compare/3.18.7...3.18.8)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
|
||||
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMjkuMCIsInVwZGF0ZWRJblZlciI6IjQzLjEyOS4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
|
||||
|
||||
## [1.1.2] - 2026-04-15
|
||||
### Changed
|
||||
- Update dependency fast-xml-parser to v5.6.0 (#28)
|
||||
|
||||
This PR contains the following updates:
|
||||
|
||||
| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|
||||
|---|---|---|---|
|
||||
| [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) | [`5.5.12` → `5.6.0`](https://renovatebot.com/diffs/npm/fast-xml-parser/5.5.12/5.6.0) |  |  |
|
||||
|
||||
---
|
||||
|
||||
### Release Notes
|
||||
|
||||
<details>
|
||||
<summary>NaturalIntelligence/fast-xml-parser (fast-xml-parser)</summary>
|
||||
|
||||
### [`v5.6.0`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.6.0): use @​nodable/entities to replace entities
|
||||
|
||||
[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.12...v5.6.0)
|
||||
|
||||
- No API change
|
||||
- No change in performance for basic usage
|
||||
- No typing change
|
||||
- No config change
|
||||
- new dependency
|
||||
- breaking: error messages for entities might have been changed.
|
||||
-
|
||||
|
||||
**Full Changelog**: <https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.12...v5.6.0>
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Configuration
|
||||
|
||||
📅 **Schedule**: (UTC)
|
||||
|
||||
- Branch creation
|
||||
- At any time (no schedule defined)
|
||||
- Automerge
|
||||
- At any time (no schedule defined)
|
||||
|
||||
🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.
|
||||
|
||||
♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.
|
||||
|
||||
🔕 **Ignore**: Close this PR and you won't be reminded about this update again.
|
||||
|
||||
---
|
||||
|
||||
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
|
||||
|
||||
---
|
||||
|
||||
This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
|
||||
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMjAuMSIsInVwZGF0ZWRJblZlciI6IjQzLjEyMC4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
|
||||
|
||||
## [1.1.1] - 2026-04-15
|
||||
### Changed
|
||||
- split push release/publish and harden workflows (#27)
|
||||
|
||||
### Added
|
||||
* Separate release from Docker/Helm publish
|
||||
* enrich releases with PRbodies when available
|
||||
* tighten release.sh validation and idempotency
|
||||
* trim PR docker-build metadata for act-runner stability
|
||||
|
||||
## [1.1.0] - 2026-04-15
|
||||
### Changed
|
||||
- Update all non-major dependencies (#25)
|
||||
|
||||
## [1.0.10] - 2026-04-15
|
||||
### Changed
|
||||
- Remove npm overrides for tar (#26)
|
||||
|
||||
## [1.0.9] - 2026-03-24
|
||||
### Changed
|
||||
- update https://git.keligrubb.com/actions/setup-helm action to v5 (#23)
|
||||
|
||||
## [1.0.8] - 2026-03-12
|
||||
### Changed
|
||||
- fix release file (#22)
|
||||
|
||||
## [1.0.7] - 2026-03-06
|
||||
### Changed
|
||||
- chore(deps): update docker/build-push-action action to v7 (#19)
|
||||
|
||||
## [1.0.6] - 2026-03-05
|
||||
### Changed
|
||||
- fix docker login during push stage (#18)
|
||||
|
||||
## [1.0.5] - 2026-03-05
|
||||
### Changed
|
||||
- fix deploy pipeline stages for token registry uploads (#17)
|
||||
|
||||
## [1.0.4] - 2026-03-04
|
||||
### Changed
|
||||
- fix deploy pipeline (#15)
|
||||
|
||||
## [1.0.3] - 2026-02-23
|
||||
### Changed
|
||||
- fix(deps): update dependency vue-router to v5 (#12)
|
||||
|
||||
## [1.0.2] - 2026-02-22
|
||||
### Changed
|
||||
- chore(deps): update dependency eslint to v10 (#10)
|
||||
|
||||
## [1.0.1] - 2026-02-22
|
||||
### Changed
|
||||
- chore: Configure Renovate (#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)
|
||||
|
||||
+1
-2
@@ -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"]
|
||||
|
||||
@@ -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. 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). 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`
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage :key="$route.path" />
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
+38
-2
@@ -16,6 +16,9 @@
|
||||
.kestrel-btn-secondary { @apply rounded border border-kestrel-border px-4 py-2 text-sm text-kestrel-text transition-colors hover:bg-kestrel-border; }
|
||||
.kestrel-context-menu-item { @apply block w-full px-3 py-1.5 text-left text-sm text-kestrel-text transition-colors hover:bg-kestrel-border; }
|
||||
.kestrel-context-menu-item-danger { @apply block w-full px-3 py-1.5 text-left text-sm text-red-400 transition-colors hover:bg-kestrel-border; }
|
||||
.kestrel-cot-layer-btn { @apply rounded px-1.5 py-0.5 text-kestrel-muted transition-colors hover:text-kestrel-text; }
|
||||
.kestrel-cot-layer-btn-active { @apply bg-kestrel-border text-kestrel-accent; }
|
||||
.cot-icon-rotatable { @apply inline-flex origin-center; }
|
||||
.kestrel-panel-base { @apply flex flex-col border border-kestrel-border bg-kestrel-surface; }
|
||||
.kestrel-panel-inline { @apply rounded-lg shadow-glow; }
|
||||
.kestrel-panel-overlay { @apply absolute right-0 top-0 z-[1000] h-full w-full border-l shadow-glow md:w-[420px] shadow-glow-panel; }
|
||||
@@ -84,12 +87,14 @@
|
||||
}
|
||||
.kestrel-map-container .leaflet-control-zoom,
|
||||
.kestrel-map-container .leaflet-control-locate,
|
||||
.kestrel-map-container .leaflet-control-alpr,
|
||||
.kestrel-map-container .savetiles.leaflet-bar {
|
||||
@apply rounded-md overflow-hidden font-mono border border-kestrel-glow shadow-glow-sm;
|
||||
border-color: rgba(34, 201, 201, 0.35) !important;
|
||||
}
|
||||
.kestrel-map-container .leaflet-control-zoom a,
|
||||
.kestrel-map-container .leaflet-control-locate,
|
||||
.kestrel-map-container .leaflet-control-alpr,
|
||||
.kestrel-map-container .savetiles.leaflet-bar a {
|
||||
@apply w-8 h-8 leading-8 bg-kestrel-surface text-kestrel-text border-none rounded-none text-lg font-semibold no-underline transition-all duration-150;
|
||||
width: 32px !important;
|
||||
@@ -105,9 +110,14 @@
|
||||
}
|
||||
.kestrel-map-container .leaflet-control-zoom a:hover,
|
||||
.kestrel-map-container .leaflet-control-locate:hover,
|
||||
.kestrel-map-container .leaflet-control-alpr:hover,
|
||||
.kestrel-map-container .savetiles.leaflet-bar a:hover {
|
||||
@apply bg-kestrel-surface-hover text-kestrel-accent shadow-glow-md text-shadow-glow-md;
|
||||
}
|
||||
.kestrel-map-container .leaflet-control-alpr[aria-pressed="true"] {
|
||||
color: #ef4444 !important;
|
||||
box-shadow: 0 0 8px rgba(239, 68, 68, 0.45);
|
||||
}
|
||||
.kestrel-map-container .savetiles.leaflet-bar {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
@@ -119,12 +129,38 @@
|
||||
padding: 6px 10px !important;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
.kestrel-map-container .leaflet-control-locate {
|
||||
.kestrel-map-container .leaflet-control-locate,
|
||||
.kestrel-map-container .leaflet-control-alpr {
|
||||
@apply flex items-center justify-center p-0 cursor-pointer;
|
||||
}
|
||||
.kestrel-map-container .leaflet-control-locate svg {
|
||||
.kestrel-map-container .leaflet-control-locate svg,
|
||||
.kestrel-map-container .leaflet-control-alpr svg {
|
||||
color: currentColor;
|
||||
}
|
||||
.kestrel-map-container .alpr-cone {
|
||||
display: inline-flex;
|
||||
transform-origin: center center;
|
||||
}
|
||||
.kestrel-map-container .alpr-cluster-icon {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
.kestrel-map-container .alpr-cluster {
|
||||
@apply flex items-center justify-center rounded-full bg-red-500/90 font-mono text-xs font-semibold text-white;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-shadow: 0 0 8px rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
.kestrel-map-container .cot-cluster-icon {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
.kestrel-map-container .cot-cluster {
|
||||
@apply flex items-center justify-center rounded-full bg-sky-500/90 font-mono text-xs font-semibold text-white;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-shadow: 0 0 8px rgba(56, 189, 248, 0.5);
|
||||
}
|
||||
.kestrel-map-container .live-session-icon {
|
||||
animation: live-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,11 @@
|
||||
:user="user"
|
||||
@signout="onLogout"
|
||||
/>
|
||||
<span
|
||||
v-else-if="authPending"
|
||||
class="inline-block h-8 w-8"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<NuxtLink
|
||||
v-else
|
||||
to="/login"
|
||||
@@ -75,7 +80,7 @@ watch(sidebarCollapsed, (v) => {
|
||||
}
|
||||
})
|
||||
|
||||
const { user, refresh } = useUser()
|
||||
const { user, authPending, refresh } = useUser()
|
||||
|
||||
watch(isMobile, (mobile) => {
|
||||
if (mobile) drawerOpen.value = false
|
||||
|
||||
+154
-12
@@ -47,11 +47,41 @@
|
||||
@submit="onPoiSubmit"
|
||||
@confirm-delete="confirmDeletePoi"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="mapContext"
|
||||
class="pointer-events-auto absolute right-3 top-3 z-[1000] flex gap-0.5 rounded border border-kestrel-border bg-kestrel-surface/95 p-0.5 text-xs shadow-glow"
|
||||
data-testid="cot-layer-toggles"
|
||||
>
|
||||
<button
|
||||
v-for="layer in COT_LAYERS"
|
||||
:key="layer.key"
|
||||
type="button"
|
||||
class="kestrel-cot-layer-btn"
|
||||
:class="{ 'kestrel-cot-layer-btn-active': cotLayers[layer.key] }"
|
||||
@click="emit('toggleCotLayer', layer.key)"
|
||||
>
|
||||
{{ layer.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import {
|
||||
createAlprControl,
|
||||
createAlprLayer,
|
||||
setAlprControlPressed,
|
||||
syncAlprLayer,
|
||||
} from '~/utils/alprMapLayer.js'
|
||||
import {
|
||||
createCotLayer,
|
||||
getCotClusters,
|
||||
loadCotCluster,
|
||||
syncCotLayer,
|
||||
} from '~/utils/cotMapLayer.js'
|
||||
import { clearFeatureMarkers } from '~/utils/mapMarkerSync.js'
|
||||
|
||||
const props = defineProps({
|
||||
devices: {
|
||||
@@ -66,13 +96,29 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
cotEntities: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
cotLayers: {
|
||||
type: Object,
|
||||
default: () => ({ air: true, surface: true, ground: true }),
|
||||
},
|
||||
alprMarkers: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
showAlpr: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
canEditPois: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select', 'selectLive', 'refreshPois'])
|
||||
const emit = defineEmits(['select', 'selectLive', 'refreshPois', 'boundsChange', 'toggleAlpr', 'toggleCotLayer'])
|
||||
const CONTEXT_MENU_EMPTY = Object.freeze({ type: null, poi: null, latlng: null, x: 0, y: 0 })
|
||||
const mapRef = ref(null)
|
||||
const contextMenuRef = ref(null)
|
||||
@@ -81,6 +127,9 @@ const mapContext = ref(null)
|
||||
const markersRef = ref([])
|
||||
const poiMarkersRef = ref({})
|
||||
const liveMarkersRef = ref({})
|
||||
const cotLayerRef = ref(null)
|
||||
const cotMapView = ref(null)
|
||||
const alprLayerRef = ref(null)
|
||||
const contextMenu = ref({ ...CONTEXT_MENU_EMPTY })
|
||||
|
||||
const showPoiModal = ref(false)
|
||||
@@ -89,6 +138,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'
|
||||
@@ -98,6 +148,11 @@ const DEFAULT_ZOOM = 17
|
||||
const MARKER_ICON_PATH = '/'
|
||||
const POI_TOOLTIP_CLASS = 'kestrel-poi-tooltip'
|
||||
const POI_ICON_COLORS = { pin: '#22c9c9', flag: '#e53e3e', waypoint: '#a78bfa' }
|
||||
const COT_LAYERS = Object.freeze([
|
||||
{ key: 'air', label: 'Air' },
|
||||
{ key: 'surface', label: 'Surface' },
|
||||
{ key: 'ground', label: 'Team' },
|
||||
])
|
||||
|
||||
const ICON_SIZE = 28
|
||||
|
||||
@@ -124,7 +179,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 +190,44 @@ function getLiveSessionIcon(L) {
|
||||
})
|
||||
}
|
||||
|
||||
function refreshCotMapView(map) {
|
||||
const bounds = map.getBounds()
|
||||
cotMapView.value = {
|
||||
south: bounds.getSouth(),
|
||||
west: bounds.getWest(),
|
||||
north: bounds.getNorth(),
|
||||
east: bounds.getEast(),
|
||||
zoom: map.getZoom(),
|
||||
}
|
||||
}
|
||||
|
||||
function renderCotLayer() {
|
||||
const ctx = mapContext.value
|
||||
const { L } = leafletRef.value || {}
|
||||
const layer = cotLayerRef.value
|
||||
if (!ctx?.map || !L || !layer) return
|
||||
const view = cotMapView.value
|
||||
const features = view ? getCotClusters(view) : []
|
||||
syncCotLayer(L, ctx.map, layer, features)
|
||||
}
|
||||
|
||||
function reloadCotCluster() {
|
||||
loadCotCluster(props.cotEntities || [])
|
||||
renderCotLayer()
|
||||
}
|
||||
|
||||
function emitBounds(map) {
|
||||
refreshCotMapView(map)
|
||||
const bounds = map.getBounds()
|
||||
emit('boundsChange', {
|
||||
south: bounds.getSouth(),
|
||||
west: bounds.getWest(),
|
||||
north: bounds.getNorth(),
|
||||
east: bounds.getEast(),
|
||||
zoom: map.getZoom(),
|
||||
})
|
||||
}
|
||||
|
||||
function createMap(initialCenter) {
|
||||
const { L, offlineApi } = leafletRef.value || {}
|
||||
if (typeof document === 'undefined' || !mapRef.value || !L?.map) return
|
||||
@@ -143,7 +236,7 @@ function createMap(initialCenter) {
|
||||
? initialCenter
|
||||
: DEFAULT_VIEW
|
||||
|
||||
const map = L.map(mapRef.value, { zoomControl: false, attributionControl: false }).setView(center, DEFAULT_ZOOM)
|
||||
const map = L.map(mapRef.value, { zoomControl: false, attributionControl: false, minZoom: 1, maxZoom: 19 }).setView(center, DEFAULT_ZOOM)
|
||||
L.control.zoom({ position: 'topleft' }).addTo(map)
|
||||
|
||||
const locateControl = L.control({ position: 'topleft' })
|
||||
@@ -169,6 +262,14 @@ function createMap(initialCenter) {
|
||||
}
|
||||
locateControl.addTo(map)
|
||||
|
||||
const alprControl = createAlprControl(L, {
|
||||
showAlpr: props.showAlpr,
|
||||
onToggle: () => emit('toggleAlpr'),
|
||||
})
|
||||
alprControl.addTo(map)
|
||||
const alprLayer = createAlprLayer(L, map)
|
||||
const cotLayer = createCotLayer(L, map)
|
||||
|
||||
const baseLayer = L.tileLayer(TILE_URL, {
|
||||
attribution: ATTRIBUTION,
|
||||
subdomains: TILE_SUBDOMAINS,
|
||||
@@ -197,11 +298,28 @@ function createMap(initialCenter) {
|
||||
contextMenu.value = { type: 'map', latlng: e.latlng, x: pt.x, y: pt.y }
|
||||
})
|
||||
|
||||
mapContext.value = { map, layer: baseLayer, control, locateControl }
|
||||
map.on('moveend', () => {
|
||||
emitBounds(map)
|
||||
renderCotLayer()
|
||||
})
|
||||
map.on('zoomend', () => {
|
||||
emitBounds(map)
|
||||
renderCotLayer()
|
||||
})
|
||||
|
||||
mapContext.value = { map, layer: baseLayer, control, locateControl, alprControl }
|
||||
alprLayerRef.value = alprLayer
|
||||
cotLayerRef.value = cotLayer
|
||||
refreshCotMapView(map)
|
||||
updateMarkers()
|
||||
updatePoiMarkers()
|
||||
updateLiveMarkers()
|
||||
nextTick(() => map.invalidateSize())
|
||||
reloadCotCluster()
|
||||
updateAlprLayer()
|
||||
nextTick(() => {
|
||||
map.invalidateSize()
|
||||
emitBounds(map)
|
||||
})
|
||||
}
|
||||
|
||||
function updateMarkers() {
|
||||
@@ -291,6 +409,18 @@ function updateLiveMarkers() {
|
||||
liveMarkersRef.value = next
|
||||
}
|
||||
|
||||
function updateAlprLayer() {
|
||||
const ctx = mapContext.value
|
||||
const { L } = leafletRef.value || {}
|
||||
const layer = alprLayerRef.value
|
||||
if (!ctx?.map || !L || !layer) return
|
||||
if (!props.showAlpr) {
|
||||
clearFeatureMarkers(layer)
|
||||
return
|
||||
}
|
||||
syncAlprLayer(L, ctx.map, layer, props.alprMarkers || [])
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div')
|
||||
div.textContent = text
|
||||
@@ -376,15 +506,22 @@ function destroyMap() {
|
||||
poiMarkersRef.value = {}
|
||||
Object.values(liveMarkersRef.value).forEach(m => m?.remove())
|
||||
liveMarkersRef.value = {}
|
||||
clearFeatureMarkers(cotLayerRef.value)
|
||||
clearFeatureMarkers(alprLayerRef.value)
|
||||
|
||||
const ctx = mapContext.value
|
||||
if (ctx) {
|
||||
if (ctx.control && ctx.map) ctx.map.removeControl(ctx.control)
|
||||
if (ctx.locateControl && ctx.map) ctx.map.removeControl(ctx.locateControl)
|
||||
if (ctx.alprControl && ctx.map) ctx.map.removeControl(ctx.alprControl)
|
||||
if (cotLayerRef.value && ctx.map) ctx.map.removeLayer(cotLayerRef.value)
|
||||
if (alprLayerRef.value && ctx.map) ctx.map.removeLayer(alprLayerRef.value)
|
||||
if (ctx.layer && ctx.map) ctx.map.removeLayer(ctx.layer)
|
||||
if (ctx.map) ctx.map.remove()
|
||||
mapContext.value = null
|
||||
}
|
||||
cotLayerRef.value = null
|
||||
alprLayerRef.value = null
|
||||
}
|
||||
|
||||
function initMapWithLocation() {
|
||||
@@ -404,8 +541,6 @@ function initMapWithLocation() {
|
||||
)
|
||||
}
|
||||
|
||||
let resizeObserver = null
|
||||
|
||||
onMounted(async () => {
|
||||
if (!import.meta.client || typeof document === 'undefined') return
|
||||
const [leaflet, offline] = await Promise.all([
|
||||
@@ -428,10 +563,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 +577,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 +587,11 @@ onBeforeUnmount(() => {
|
||||
watch(() => props.devices, () => updateMarkers(), { deep: true })
|
||||
watch([() => props.pois, () => props.canEditPois], () => updatePoiMarkers(), { deep: true })
|
||||
watch(() => props.liveSessions, () => updateLiveMarkers(), { deep: true })
|
||||
watch(() => props.cotEntities, () => reloadCotCluster())
|
||||
watch(() => props.alprMarkers, () => updateAlprLayer())
|
||||
watch(() => props.showAlpr, (enabled) => {
|
||||
setAlprControlPressed(mapContext.value?.alprControl, enabled)
|
||||
if (enabled && mapContext.value?.map) emitBounds(mapContext.value.map)
|
||||
else updateAlprLayer()
|
||||
})
|
||||
</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) {
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { createClusterIndex } from '~/utils/mapCluster.js'
|
||||
import {
|
||||
MAX_BBOX_DEGREES,
|
||||
bboxFetchKey,
|
||||
bboxToTileKey,
|
||||
tileKey,
|
||||
tilesNearCenter,
|
||||
} from '~/utils/alprViewport.js'
|
||||
|
||||
const STORAGE_KEY = 'kestrelos:showAlprLayer'
|
||||
const MIN_FETCH_ZOOM = 10
|
||||
const MAX_TILE_FETCHES = 16
|
||||
const MAX_CACHED_TILES = 64
|
||||
const TILE_RETENTION = 3
|
||||
const EMPTY_TILES = Object.freeze({})
|
||||
|
||||
const mergeFeatures = lists => Object.values(
|
||||
lists.flat().reduce((acc, feature) => {
|
||||
const id = feature.id ?? feature.properties?.osmId
|
||||
return id == null ? acc : { ...acc, [id]: feature }
|
||||
}, {}),
|
||||
)
|
||||
|
||||
const offsets = radius => Array.from({ length: 2 * radius + 1 }, (_, i) => i - radius)
|
||||
|
||||
const retentionKeys = bounds => new Set(
|
||||
tilesNearCenter(bounds, MAX_TILE_FETCHES).flatMap((tile) => {
|
||||
const r0 = Math.floor(tile.south / MAX_BBOX_DEGREES)
|
||||
const c0 = Math.floor(tile.west / MAX_BBOX_DEGREES)
|
||||
return offsets(TILE_RETENTION).flatMap(dr =>
|
||||
offsets(TILE_RETENTION).map(dc => tileKey(r0 + dr, c0 + dc)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const pruneTileCache = (cache, bounds) => {
|
||||
const keep = retentionKeys(bounds)
|
||||
const retained = Object.fromEntries(
|
||||
Object.entries(cache).filter(([key]) => keep.has(key)),
|
||||
)
|
||||
const overflow = Object.keys(retained).length - MAX_CACHED_TILES
|
||||
if (overflow <= 0) return Object.freeze(retained)
|
||||
|
||||
const cr = Math.floor((bounds.south + bounds.north) / 2 / MAX_BBOX_DEGREES)
|
||||
const cc = Math.floor((bounds.west + bounds.east) / 2 / MAX_BBOX_DEGREES)
|
||||
const drop = new Set(
|
||||
Object.keys(retained)
|
||||
.map((key) => {
|
||||
const [r, c] = key.split(',').map(Number)
|
||||
return { key, dist: Math.hypot(r - cr, c - cc) }
|
||||
})
|
||||
.sort((a, b) => b.dist - a.dist)
|
||||
.slice(0, overflow)
|
||||
.map(({ key }) => key),
|
||||
)
|
||||
return Object.freeze(
|
||||
Object.fromEntries(Object.entries(retained).filter(([key]) => !drop.has(key))),
|
||||
)
|
||||
}
|
||||
|
||||
const cacheChanged = (before, after) => Object.keys(before).length !== Object.keys(after).length
|
||||
|
||||
export function useAlprCameras() {
|
||||
const showAlpr = useState('showAlprLayer', () => {
|
||||
if (!import.meta.client) return true
|
||||
return localStorage.getItem(STORAGE_KEY) !== '0'
|
||||
})
|
||||
const view = ref(null)
|
||||
const tiles = ref(EMPTY_TILES)
|
||||
const cluster = createClusterIndex({ radius: 50, maxZoom: 17 })
|
||||
const debounceTimer = ref(null)
|
||||
const requestId = ref(0)
|
||||
const lastFetchKey = ref('')
|
||||
|
||||
const applyTiles = (next) => {
|
||||
tiles.value = next
|
||||
cluster.load(mergeFeatures(Object.values(next)))
|
||||
}
|
||||
|
||||
watch(showAlpr, (enabled) => {
|
||||
if (import.meta.client) localStorage.setItem(STORAGE_KEY, enabled ? '1' : '0')
|
||||
if (!enabled) {
|
||||
applyTiles(EMPTY_TILES)
|
||||
view.value = null
|
||||
lastFetchKey.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
const alprMarkers = computed(() => view.value ? cluster.query(view.value) : [])
|
||||
|
||||
const toggleAlpr = () => {
|
||||
showAlpr.value = !showAlpr.value
|
||||
}
|
||||
|
||||
const onBoundsChange = (bounds) => {
|
||||
if (!showAlpr.value || !bounds) return
|
||||
view.value = bounds
|
||||
|
||||
const pruned = pruneTileCache(tiles.value, bounds)
|
||||
if (cacheChanged(tiles.value, pruned)) applyTiles(pruned)
|
||||
|
||||
if ((bounds.zoom ?? 14) < MIN_FETCH_ZOOM) return
|
||||
|
||||
const key = bboxFetchKey(bounds)
|
||||
if (key === lastFetchKey.value) return
|
||||
if (debounceTimer.value) clearTimeout(debounceTimer.value)
|
||||
debounceTimer.value = setTimeout(() => {
|
||||
lastFetchKey.value = key
|
||||
fetchViewport(bounds)
|
||||
}, 400)
|
||||
}
|
||||
|
||||
const fetchViewport = async (bounds) => {
|
||||
const id = requestId.value + 1
|
||||
requestId.value = id
|
||||
const missing = tilesNearCenter(bounds, MAX_TILE_FETCHES)
|
||||
.filter(tile => !(bboxToTileKey(tile) in tiles.value))
|
||||
|
||||
try {
|
||||
const fetched = missing.length
|
||||
? await Promise.all(missing.map(async (tile) => {
|
||||
const params = new URLSearchParams({
|
||||
south: String(tile.south),
|
||||
west: String(tile.west),
|
||||
north: String(tile.north),
|
||||
east: String(tile.east),
|
||||
})
|
||||
const data = await $fetch(`/api/alpr?${params}`).catch(() => null)
|
||||
return [bboxToTileKey(tile), data?.features ?? []]
|
||||
}))
|
||||
: []
|
||||
|
||||
if (id !== requestId.value) return
|
||||
|
||||
const merged = Object.freeze({
|
||||
...tiles.value,
|
||||
...Object.fromEntries(fetched),
|
||||
})
|
||||
const pruned = pruneTileCache(merged, bounds)
|
||||
applyTiles(pruned)
|
||||
}
|
||||
catch { /* keep last good index */ }
|
||||
}
|
||||
|
||||
return Object.freeze({ showAlpr, toggleAlpr, alprMarkers, onBoundsChange })
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
const STORAGE_KEY = 'kestrel-cot-layers'
|
||||
|
||||
const DEFAULT_LAYERS = Object.freeze({ air: true, surface: true, ground: true })
|
||||
|
||||
function loadLayers() {
|
||||
if (typeof localStorage === 'undefined') return { ...DEFAULT_LAYERS }
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return { ...DEFAULT_LAYERS }
|
||||
const parsed = JSON.parse(raw)
|
||||
return {
|
||||
air: parsed.air !== false,
|
||||
surface: parsed.surface !== false,
|
||||
ground: parsed.ground !== false,
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return { ...DEFAULT_LAYERS }
|
||||
}
|
||||
}
|
||||
|
||||
function saveLayers(layers) {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(layers))
|
||||
}
|
||||
catch { /* ignore quota */ }
|
||||
}
|
||||
|
||||
export function useCotLayers() {
|
||||
const layers = ref(loadLayers())
|
||||
|
||||
const layerQuery = computed(() => {
|
||||
const parts = []
|
||||
if (layers.value.air) parts.push('air')
|
||||
if (layers.value.surface) parts.push('surface')
|
||||
if (layers.value.ground) parts.push('ground')
|
||||
return parts.length ? parts.join(',') : 'none'
|
||||
})
|
||||
|
||||
function toggleLayer(name) {
|
||||
if (!(name in DEFAULT_LAYERS)) return
|
||||
layers.value = { ...layers.value, [name]: !layers.value[name] }
|
||||
saveLayers(layers.value)
|
||||
}
|
||||
|
||||
return Object.freeze({ layers, layerQuery, toggleLayer })
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { bboxFetchKey } from '~/utils/alprViewport.js'
|
||||
|
||||
const DEBOUNCE_MS = 300
|
||||
const EMPTY_ENTITIES = Object.freeze({})
|
||||
|
||||
const expandBounds = (bounds, factor = 0.25) => {
|
||||
const latPad = (bounds.north - bounds.south) * factor
|
||||
const lngPad = (bounds.east - bounds.west) * factor
|
||||
return {
|
||||
south: Math.max(-90, bounds.south - latPad),
|
||||
north: Math.min(90, bounds.north + latPad),
|
||||
west: bounds.west - lngPad,
|
||||
east: bounds.east + lngPad,
|
||||
}
|
||||
}
|
||||
|
||||
const entitiesFromList = list => Object.freeze(
|
||||
Object.fromEntries(
|
||||
(list ?? []).filter(entity => entity?.id).map(entity => [entity.id, entity]),
|
||||
),
|
||||
)
|
||||
|
||||
export function useCotStream(boundsRef, layerQueryRef) {
|
||||
const entities = ref(EMPTY_ENTITIES)
|
||||
const cotEntities = computed(() => Object.freeze(Object.values(entities.value)))
|
||||
const eventSource = ref(null)
|
||||
const debounceTimer = ref(null)
|
||||
const subscribedKey = ref('')
|
||||
const streamBounds = ref(null)
|
||||
|
||||
const setEntities = (record) => {
|
||||
entities.value = Object.freeze(record)
|
||||
}
|
||||
|
||||
const parseEvent = (e, fn) => {
|
||||
try {
|
||||
fn(JSON.parse(e.data))
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const closeStream = () => {
|
||||
eventSource.value?.close()
|
||||
eventSource.value = null
|
||||
}
|
||||
|
||||
const connect = () => {
|
||||
if (typeof window === 'undefined' || typeof EventSource === 'undefined') return
|
||||
const bounds = streamBounds.value ?? boundsRef.value
|
||||
if (!bounds) return
|
||||
|
||||
closeStream()
|
||||
const q = new URLSearchParams({
|
||||
bbox: `${bounds.west},${bounds.south},${bounds.east},${bounds.north}`,
|
||||
layers: unref(layerQueryRef) || 'air,surface,ground',
|
||||
})
|
||||
const es = new EventSource(`/api/cot/stream?${q}`)
|
||||
eventSource.value = es
|
||||
|
||||
es.addEventListener('snapshot', e => parseEvent(e, ({ entities: list }) => {
|
||||
setEntities(entitiesFromList(list))
|
||||
}))
|
||||
es.addEventListener('update', e => parseEvent(e, ({ entity }) => {
|
||||
if (!entity?.id) return
|
||||
setEntities({ ...entities.value, [entity.id]: entity })
|
||||
}))
|
||||
es.addEventListener('remove', e => parseEvent(e, ({ id }) => {
|
||||
if (!id) return
|
||||
setEntities(Object.fromEntries(
|
||||
Object.entries(entities.value).filter(([key]) => key !== String(id)),
|
||||
))
|
||||
}))
|
||||
es.onerror = () => {
|
||||
closeStream()
|
||||
scheduleConnect()
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleConnect = () => {
|
||||
if (debounceTimer.value) clearTimeout(debounceTimer.value)
|
||||
debounceTimer.value = setTimeout(() => {
|
||||
debounceTimer.value = null
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return
|
||||
connect()
|
||||
}, DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
const maybeReconnect = (bounds) => {
|
||||
if (!bounds) return
|
||||
const key = bboxFetchKey(bounds)
|
||||
if (key === subscribedKey.value) return
|
||||
subscribedKey.value = key
|
||||
streamBounds.value = expandBounds(bounds)
|
||||
scheduleConnect()
|
||||
}
|
||||
|
||||
watch(boundsRef, maybeReconnect, { deep: true })
|
||||
watch(layerQueryRef, () => {
|
||||
subscribedKey.value = ''
|
||||
scheduleConnect()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof document === 'undefined') return
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
document.visibilityState === 'visible' ? scheduleConnect() : closeStream()
|
||||
})
|
||||
if (document.visibilityState === 'visible') maybeReconnect(boundsRef.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (debounceTimer.value) clearTimeout(debounceTimer.value)
|
||||
closeStream()
|
||||
})
|
||||
|
||||
return Object.freeze({ cotEntities })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -2,12 +2,13 @@ const EDIT_ROLES = Object.freeze(['admin', 'leader'])
|
||||
|
||||
export function useUser() {
|
||||
const requestFetch = useRequestFetch()
|
||||
const { data: user, refresh } = useAsyncData(
|
||||
const { data: user, refresh, status } = useAsyncData(
|
||||
'user',
|
||||
() => (requestFetch ?? $fetch)('/api/me').catch(() => null),
|
||||
{ default: () => null },
|
||||
)
|
||||
const authPending = computed(() => status.value === 'pending')
|
||||
const canEditPois = computed(() => EDIT_ROLES.includes(user.value?.role))
|
||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||
return Object.freeze({ user, canEditPois, isAdmin, refresh })
|
||||
return Object.freeze({ user, authPending, canEditPois, isAdmin, refresh })
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -3,7 +3,7 @@ const LOGIN_PATH = '/login'
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
if (to.path === LOGIN_PATH) return
|
||||
const { user, refresh } = useUser()
|
||||
await refresh()
|
||||
if (!user.value) await refresh()
|
||||
if (user.value) return
|
||||
const redirect = to.fullPath.startsWith('/') ? to.fullPath : `/${to.fullPath}`
|
||||
return navigateTo({ path: LOGIN_PATH, query: { redirect } }, { replace: true })
|
||||
|
||||
@@ -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,11 +6,24 @@
|
||||
:devices="devices ?? []"
|
||||
:pois="pois ?? []"
|
||||
:live-sessions="liveSessions ?? []"
|
||||
:cot-entities="cotEntities ?? []"
|
||||
:cot-layers="cotLayers"
|
||||
:alpr-markers="showAlpr ? (alprMarkers ?? []) : []"
|
||||
:show-alpr="showAlpr"
|
||||
:can-edit-pois="canEditPois"
|
||||
@select="selectedCamera = $event"
|
||||
@select-live="onSelectLive($event)"
|
||||
@refresh-pois="refreshPois"
|
||||
@bounds-change="onMapBoundsChange"
|
||||
@toggle-alpr="toggleAlpr"
|
||||
@toggle-cot-layer="toggleLayer"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div
|
||||
class="h-full min-h-[300px] bg-kestrel-bg"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
<CameraViewer
|
||||
@@ -25,8 +38,17 @@
|
||||
const { devices, liveSessions } = useCameras()
|
||||
const { data: pois, refresh: refreshPois } = usePois()
|
||||
const { canEditPois } = useUser()
|
||||
const { showAlpr, toggleAlpr, alprMarkers, onBoundsChange: onAlprBoundsChange } = useAlprCameras()
|
||||
const { layers: cotLayers, layerQuery, toggleLayer } = useCotLayers()
|
||||
const mapBounds = ref(null)
|
||||
const { cotEntities } = useCotStream(mapBounds, layerQuery)
|
||||
const selectedCamera = ref(null)
|
||||
|
||||
function onMapBoundsChange(bounds) {
|
||||
mapBounds.value = bounds
|
||||
onAlprBoundsChange(bounds)
|
||||
}
|
||||
|
||||
function onSelectLive(session) {
|
||||
selectedCamera.value = (liveSessions.value ?? []).find(s => s.id === session?.id) ?? session
|
||||
}
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+54
-54
@@ -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
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { syncFeatureMarkers } from './mapMarkerSync.js'
|
||||
|
||||
const ICON_SIZE = 28
|
||||
const CONE_SIZE = 52
|
||||
const ALPR_COLOR = '#ef4444'
|
||||
const DEFAULT_FOV = 60
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div')
|
||||
div.textContent = text
|
||||
return div.innerHTML
|
||||
}
|
||||
|
||||
function conePath(fov) {
|
||||
const half = Math.min(Math.max(fov / 2, 10), 85)
|
||||
const cx = 26
|
||||
const cy = 26
|
||||
const r = 24
|
||||
const toRad = deg => ((deg - 90) * Math.PI) / 180
|
||||
const x1 = cx + r * Math.cos(toRad(-half))
|
||||
const y1 = cy + r * Math.sin(toRad(-half))
|
||||
const x2 = cx + r * Math.cos(toRad(half))
|
||||
const y2 = cy + r * Math.sin(toRad(half))
|
||||
return `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 0 1 ${x2} ${y2} Z`
|
||||
}
|
||||
|
||||
function compassLabel(deg) {
|
||||
if (!Number.isFinite(deg)) return null
|
||||
const dirs = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
|
||||
const idx = Math.round((((deg % 360) + 360) % 360) / 45) % 8
|
||||
return dirs[idx]
|
||||
}
|
||||
|
||||
function wikidataLink(label, qid) {
|
||||
if (!qid) return escapeHtml(label)
|
||||
const id = escapeHtml(qid)
|
||||
return `<a href="https://www.wikidata.org/wiki/${id}" target="_blank" rel="noopener">${escapeHtml(label)}</a>`
|
||||
}
|
||||
|
||||
function popupLine(parts) {
|
||||
const line = parts.filter(Boolean).join(' · ')
|
||||
return line ? `<div class="text-kestrel-muted text-xs mt-1">${line}</div>` : ''
|
||||
}
|
||||
|
||||
function titleHtml(props) {
|
||||
if (props.name) return escapeHtml(props.name)
|
||||
if (props.model) {
|
||||
const mfr = props.manufacturer ? escapeHtml(props.manufacturer) : null
|
||||
return mfr ? `${mfr} <span class="text-kestrel-accent">${escapeHtml(props.model)}</span>` : escapeHtml(props.model)
|
||||
}
|
||||
const makeModel = [props.manufacturer, props.model].filter(Boolean).join(' ')
|
||||
if (makeModel) {
|
||||
if (props.manufacturerWikidata && !props.model) return wikidataLink(makeModel, props.manufacturerWikidata)
|
||||
return escapeHtml(makeModel)
|
||||
}
|
||||
if (props.operator) return wikidataLink(props.operator, props.operatorWikidata)
|
||||
if (props.ref) return escapeHtml(props.ref)
|
||||
return 'ALPR camera'
|
||||
}
|
||||
|
||||
function titleText(props) {
|
||||
if (props.name) return props.name
|
||||
if (props.model) {
|
||||
return [props.manufacturer, props.model].filter(Boolean).join(' ')
|
||||
}
|
||||
const makeModel = [props.manufacturer, props.model].filter(Boolean).join(' ')
|
||||
if (makeModel) return makeModel
|
||||
if (props.operator) return props.operator
|
||||
if (props.ref) return props.ref
|
||||
return 'ALPR camera'
|
||||
}
|
||||
|
||||
function modelLineHtml(props) {
|
||||
if (props.model) {
|
||||
return `<div class="text-sm mt-0.5"><span class="text-kestrel-muted">Model</span> <strong>${escapeHtml(props.model)}</strong></div>`
|
||||
}
|
||||
if (props.modelUnknown) {
|
||||
return '<div class="text-kestrel-muted text-xs mt-0.5">Model not recorded in OpenStreetMap</div>'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** Human-readable ALPR popup (GeoJSON properties in, HTML out). */
|
||||
export function formatAlprPopup(props) {
|
||||
const title = titleHtml(props)
|
||||
const headline = titleText(props)
|
||||
const makeModel = [props.manufacturer, props.model].filter(Boolean).join(' ')
|
||||
|
||||
const identity = []
|
||||
if (props.name && makeModel) identity.push(escapeHtml(makeModel))
|
||||
if (props.operator && headline !== props.operator) {
|
||||
identity.push(wikidataLink(props.operator, props.operatorWikidata))
|
||||
}
|
||||
if (props.ref && headline !== props.ref) identity.push(escapeHtml(props.ref))
|
||||
if (props.brand) identity.push(escapeHtml(props.brand))
|
||||
|
||||
const view = []
|
||||
if (props.direction != null) {
|
||||
const deg = Math.round(props.direction)
|
||||
const compass = compassLabel(props.direction)
|
||||
view.push(compass ? `Facing ${compass} (${deg}°)` : `Facing ${deg}°`)
|
||||
}
|
||||
if (props.fov != null && props.direction != null) view.push(`~${Math.round(props.fov)}° view`)
|
||||
|
||||
const note = props.description || props.note
|
||||
const noteHtml = note ? `<div class="text-kestrel-muted text-xs mt-1">${escapeHtml(note)}</div>` : ''
|
||||
const identityHtml = identity.length
|
||||
? `<div class="text-kestrel-muted text-xs mt-0.5">${identity.join(' · ')}</div>`
|
||||
: ''
|
||||
const modelHtml = (props.model || props.modelUnknown) ? modelLineHtml(props) : ''
|
||||
const foot = `<div class="text-kestrel-muted text-xs mt-2"><a href="https://www.openstreetmap.org/node/${props.osmId}" target="_blank" rel="noopener">View on OpenStreetMap</a></div>`
|
||||
|
||||
return `<div class="kestrel-live-popup"><strong>${title}</strong> <span class="text-kestrel-muted">License plate reader</span>${modelHtml}${identityHtml}${popupLine(view)}${noteHtml}${foot}</div>`
|
||||
}
|
||||
|
||||
function popupHtml(props) {
|
||||
return formatAlprPopup(props)
|
||||
}
|
||||
|
||||
function pointIcon(L, direction, fov) {
|
||||
const hasDirection = Number.isFinite(direction)
|
||||
if (!hasDirection) {
|
||||
const html = `<span class="poi-icon-svg"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${ALPR_COLOR}" stroke-width="2"><rect x="3" y="5" width="18" height="12" rx="2"/><circle cx="12" cy="11" r="3"/><path d="M8 21h8"/></svg></span>`
|
||||
return L.divIcon({
|
||||
className: 'poi-div-icon alpr-icon',
|
||||
html,
|
||||
iconSize: [ICON_SIZE, ICON_SIZE],
|
||||
iconAnchor: [ICON_SIZE / 2, ICON_SIZE],
|
||||
})
|
||||
}
|
||||
const spread = Number.isFinite(fov) ? fov : DEFAULT_FOV
|
||||
const html = `<span class="alpr-cone" style="transform:rotate(${direction}deg)"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52" width="${CONE_SIZE}" height="${CONE_SIZE}"><path d="${conePath(spread)}" fill="${ALPR_COLOR}" fill-opacity="0.3" stroke="${ALPR_COLOR}" stroke-width="1.5"/><circle cx="26" cy="26" r="3" fill="${ALPR_COLOR}"/></svg></span>`
|
||||
return L.divIcon({
|
||||
className: 'poi-div-icon alpr-icon',
|
||||
html,
|
||||
iconSize: [CONE_SIZE, CONE_SIZE],
|
||||
iconAnchor: [CONE_SIZE / 2, CONE_SIZE / 2],
|
||||
})
|
||||
}
|
||||
|
||||
function clusterIcon(L, count) {
|
||||
const size = count < 10 ? 28 : count < 100 ? 34 : 40
|
||||
return L.divIcon({
|
||||
className: 'alpr-cluster-icon',
|
||||
html: `<span class="alpr-cluster">${count}</span>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
})
|
||||
}
|
||||
|
||||
export function createAlprControl(L, { showAlpr, onToggle }) {
|
||||
const control = L.control({ position: 'topleft' })
|
||||
control.onAdd = function () {
|
||||
const el = document.createElement('button')
|
||||
el.type = 'button'
|
||||
el.className = 'leaflet-bar leaflet-control-alpr'
|
||||
el.title = 'Toggle ALPR cameras (OSM)'
|
||||
el.setAttribute('aria-label', 'Toggle ALPR cameras')
|
||||
el.setAttribute('aria-pressed', showAlpr ? 'true' : 'false')
|
||||
el.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><rect x="3" y="5" width="18" height="12" rx="2"/><circle cx="12" cy="11" r="3"/></svg>'
|
||||
el.addEventListener('click', onToggle)
|
||||
control._button = el
|
||||
return el
|
||||
}
|
||||
return control
|
||||
}
|
||||
|
||||
export function setAlprControlPressed(control, pressed) {
|
||||
control?._button?.setAttribute('aria-pressed', pressed ? 'true' : 'false')
|
||||
}
|
||||
|
||||
function featureKey(feature) {
|
||||
const props = feature.properties ?? {}
|
||||
if (props.cluster) return `c:${props.cluster_id}`
|
||||
const id = props.osmId
|
||||
return id != null ? `a:${id}` : null
|
||||
}
|
||||
|
||||
function coords(feature) {
|
||||
const [lng, lat] = feature.geometry.coordinates
|
||||
return { lat, lng }
|
||||
}
|
||||
|
||||
function attachClusterClick(marker, feature, map) {
|
||||
marker.on('click', () => {
|
||||
const { lat, lng } = coords(feature)
|
||||
const props = feature.properties ?? {}
|
||||
const zoom = props.expansionZoom ?? map.getZoom() + 2
|
||||
map.setView([lat, lng], Math.min(zoom, 19), { animate: true })
|
||||
})
|
||||
}
|
||||
|
||||
export function createAlprLayer(L, map) {
|
||||
return L.layerGroup().addTo(map)
|
||||
}
|
||||
|
||||
export function syncAlprLayer(L, map, layer, features) {
|
||||
syncFeatureMarkers(layer, features, {
|
||||
keyFor: featureKey,
|
||||
create: (feature) => {
|
||||
const { lat, lng } = coords(feature)
|
||||
const props = feature.properties ?? {}
|
||||
const isCluster = Boolean(props.cluster)
|
||||
const icon = isCluster ? clusterIcon(L, props.point_count) : pointIcon(L, props.direction, props.fov)
|
||||
const marker = L.marker([lat, lng], { icon })
|
||||
if (isCluster) attachClusterClick(marker, feature, map)
|
||||
else marker.bindPopup(popupHtml(props), { className: 'kestrel-live-popup-wrap', maxWidth: 320 })
|
||||
return marker
|
||||
},
|
||||
update: (marker, feature) => {
|
||||
const { lat, lng } = coords(feature)
|
||||
const props = feature.properties ?? {}
|
||||
const isCluster = Boolean(props.cluster)
|
||||
marker.setLatLng([lat, lng])
|
||||
const icon = isCluster ? clusterIcon(L, props.point_count) : pointIcon(L, props.direction, props.fov)
|
||||
marker.setIcon(icon)
|
||||
if (!isCluster) marker.setPopupContent(popupHtml(props))
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
export const MAX_BBOX_DEGREES = 0.5
|
||||
|
||||
export function tileKey(row, col) {
|
||||
return `${row},${col}`
|
||||
}
|
||||
|
||||
export function bboxToTileKey(bbox) {
|
||||
const row = Math.floor(bbox.south / MAX_BBOX_DEGREES)
|
||||
const col = Math.floor(bbox.west / MAX_BBOX_DEGREES)
|
||||
return tileKey(row, col)
|
||||
}
|
||||
|
||||
function tileBox(row, col, step = MAX_BBOX_DEGREES) {
|
||||
const south = row * step
|
||||
const west = col * step
|
||||
return { south, west, north: south + step, east: west + step }
|
||||
}
|
||||
|
||||
export function bboxFetchKey(bounds) {
|
||||
const zoom = bounds.zoom ?? 14
|
||||
const step = zoom >= 14 ? 0.025 : zoom >= 11 ? 0.1 : zoom >= 8 ? 0.25 : 1
|
||||
const q = v => Math.round(v / step) * step
|
||||
return [q(bounds.south), q(bounds.west), q(bounds.north), q(bounds.east)].join(',')
|
||||
}
|
||||
|
||||
function ringOffsets(radius) {
|
||||
if (radius === 0) return [[0, 0]]
|
||||
return Array.from({ length: 2 * radius + 1 }, (_, i) => i - radius)
|
||||
.flatMap(dr => Array.from({ length: 2 * radius + 1 }, (_, j) => j - radius)
|
||||
.filter(dc => Math.abs(dr) === radius || Math.abs(dc) === radius)
|
||||
.map(dc => [dr, dc]))
|
||||
}
|
||||
|
||||
function collectTiles(state) {
|
||||
const {
|
||||
centerRow, centerCol, minRow, maxRow, minCol, maxCol, step, limit, radius, seen, tiles,
|
||||
} = state
|
||||
if (tiles.length >= limit) return tiles
|
||||
|
||||
const inBounds = (row, col) => row >= minRow && row <= maxRow && col >= minCol && col <= maxCol
|
||||
const { nextSeen, added } = ringOffsets(radius)
|
||||
.map(([dr, dc]) => [centerRow + dr, centerCol + dc])
|
||||
.filter(([row, col]) => inBounds(row, col))
|
||||
.reduce((acc, [row, col]) => {
|
||||
const key = `${row},${col}`
|
||||
if (acc.nextSeen.has(key)) return acc
|
||||
return {
|
||||
nextSeen: new Set([...acc.nextSeen, key]),
|
||||
added: [...acc.added, tileBox(row, col, step)],
|
||||
}
|
||||
}, { nextSeen: seen, added: [] })
|
||||
|
||||
if (added.length === 0 && radius > 0) return tiles
|
||||
|
||||
const nextTiles = [...tiles, ...added].slice(0, limit)
|
||||
if (nextTiles.length >= limit) return nextTiles
|
||||
return collectTiles({ ...state, radius: radius + 1, seen: nextSeen, tiles: nextTiles })
|
||||
}
|
||||
|
||||
export function tilesNearCenter(bounds, limit) {
|
||||
const step = MAX_BBOX_DEGREES
|
||||
const lat = (bounds.south + bounds.north) / 2
|
||||
const lng = (bounds.west + bounds.east) / 2
|
||||
return collectTiles({
|
||||
centerRow: Math.floor(lat / step),
|
||||
centerCol: Math.floor(lng / step),
|
||||
minRow: Math.floor(bounds.south / step),
|
||||
maxRow: Math.ceil(bounds.north / step) - 1,
|
||||
minCol: Math.floor(bounds.west / step),
|
||||
maxCol: Math.ceil(bounds.east / step) - 1,
|
||||
step,
|
||||
limit,
|
||||
radius: 0,
|
||||
seen: new Set(),
|
||||
tiles: [],
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/** Map CoT / ADS-B entity display: icons and popups. */
|
||||
|
||||
export const COT_COLORS = {
|
||||
air: '#60a5fa',
|
||||
helicopter: '#fbbf24',
|
||||
surface: '#38bdf8',
|
||||
ground: '#f59e0b',
|
||||
}
|
||||
|
||||
export function cotCategory(type) {
|
||||
const t = typeof type === 'string' ? type : ''
|
||||
if (t.startsWith('a-f-A-')) return 'air'
|
||||
if (t.startsWith('a-f-S-')) return 'surface'
|
||||
return 'ground'
|
||||
}
|
||||
|
||||
/** Whether the entity is a helicopter or fixed-wing aircraft. @returns {'helicopter' | 'fixedWing'} */
|
||||
export function cotAirIconKind(entity) {
|
||||
const type = entity?.type ?? ''
|
||||
if (type.endsWith('-C-H') || type.endsWith('-M-H')) return 'helicopter'
|
||||
return 'fixedWing'
|
||||
}
|
||||
|
||||
function iconWrap(heading, inner) {
|
||||
const rotate = Number.isFinite(heading) ? ` style="transform:rotate(${heading}deg)"` : ''
|
||||
return `<span class="poi-icon-svg cot-icon-rotatable"${rotate}>${inner}</span>`
|
||||
}
|
||||
|
||||
const PLANE_SVG = color =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="${color}"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>`
|
||||
|
||||
const HELI_SVG = color =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="1.75" stroke-linecap="round"><circle cx="12" cy="12" r="2.5" fill="${color}"/><path d="M3 8h18M3 12h18"/><path d="M12 8v8"/><path d="M9 16h6"/></svg>`
|
||||
|
||||
const SHIP_SVG = color =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2"><path d="M2 20c2-4 6-6 10-6s8 2 10 6"/><path d="M12 14V4"/><path d="m8 8 4-4 4 4"/></svg>`
|
||||
|
||||
const GROUND_SVG = color =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="8" r="2.5" fill="${color}"/></svg>`
|
||||
|
||||
export function getCotIconHtml(entity) {
|
||||
const category = cotCategory(entity?.type)
|
||||
const heading = Number(entity?.heading)
|
||||
if (category === 'air') {
|
||||
const kind = cotAirIconKind(entity)
|
||||
const color = kind === 'helicopter' ? COT_COLORS.helicopter : COT_COLORS.air
|
||||
const svg = kind === 'helicopter' ? HELI_SVG(color) : PLANE_SVG(color)
|
||||
return { html: iconWrap(heading, svg), className: `cot-entity-${kind}` }
|
||||
}
|
||||
if (category === 'surface') {
|
||||
return { html: iconWrap(heading, SHIP_SVG(COT_COLORS.surface)), className: 'cot-entity-surface' }
|
||||
}
|
||||
return { html: iconWrap(undefined, GROUND_SVG(COT_COLORS.ground)), className: 'cot-entity-ground' }
|
||||
}
|
||||
|
||||
function msToKnots(ms) {
|
||||
return Number.isFinite(ms) ? Math.round(ms * 1.94384) : null
|
||||
}
|
||||
|
||||
function metersToFeet(m) {
|
||||
return Number.isFinite(m) ? Math.round(m * 3.28084) : null
|
||||
}
|
||||
|
||||
function fmtHeading(deg) {
|
||||
return Number.isFinite(deg) ? `${Math.round(deg)}°` : null
|
||||
}
|
||||
|
||||
function fmtVerticalFpm(ms) {
|
||||
if (!Number.isFinite(ms) || ms === 0) return null
|
||||
const fpm = Math.round(ms * 196.85)
|
||||
return `${fpm > 0 ? '+' : ''}${fpm} fpm`
|
||||
}
|
||||
|
||||
function icaoFromEntity(entity) {
|
||||
if (entity?.icao) return String(entity.icao).toUpperCase()
|
||||
if (typeof entity?.id === 'string' && entity.id.startsWith('ICAO.')) {
|
||||
return entity.id.slice(5).toUpperCase()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function mmsiFromEntity(entity) {
|
||||
if (entity?.mmsi) return String(entity.mmsi)
|
||||
if (typeof entity?.id === 'string' && entity.id.startsWith('MMSI.')) return entity.id.slice(5)
|
||||
return null
|
||||
}
|
||||
|
||||
function popupLine(escape, parts) {
|
||||
const line = parts.filter(Boolean).join(' · ')
|
||||
return line ? `<div class="text-kestrel-muted text-xs mt-1">${line}</div>` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} entity
|
||||
* @param {(s: string) => string} escape
|
||||
*/
|
||||
export function formatCotPopup(entity, escape) {
|
||||
const category = cotCategory(entity?.type)
|
||||
const label = escape(entity?.label || entity?.id || 'Unknown')
|
||||
|
||||
if (entity?.source === 'adsb' || category === 'air') {
|
||||
const tag = cotAirIconKind(entity) === 'helicopter' ? 'Helicopter' : 'Aircraft'
|
||||
const icao = icaoFromEntity(entity)
|
||||
const meta = [
|
||||
icao ? `ICAO ${icao}` : null,
|
||||
entity?.originCountry ? escape(String(entity.originCountry)) : null,
|
||||
].filter(Boolean).join(' · ')
|
||||
const alt = metersToFeet(entity?.altitude)
|
||||
const stats = [
|
||||
alt != null ? `${alt.toLocaleString()} ft` : null,
|
||||
entity?.onGround ? 'On ground' : null,
|
||||
msToKnots(entity?.speed) != null ? `${msToKnots(entity.speed)} kt` : null,
|
||||
fmtHeading(entity?.heading),
|
||||
fmtVerticalFpm(entity?.verticalRate),
|
||||
entity?.squawk ? `Squawk ${escape(String(entity.squawk))}` : null,
|
||||
]
|
||||
return `<div class="kestrel-live-popup"><strong>${label}</strong> <span class="text-kestrel-muted">${tag}</span>${meta ? `<div class="text-kestrel-muted text-xs mt-0.5">${meta}</div>` : ''}${popupLine(escape, stats)}</div>`
|
||||
}
|
||||
|
||||
if (entity?.source === 'ais' || category === 'surface') {
|
||||
const mmsi = mmsiFromEntity(entity)
|
||||
const meta = mmsi ? `MMSI ${escape(mmsi)}` : ''
|
||||
const stats = [
|
||||
Number.isFinite(entity?.speed) ? `${Number(entity.speed).toFixed(1)} kt` : null,
|
||||
fmtHeading(entity?.heading),
|
||||
]
|
||||
return `<div class="kestrel-live-popup"><strong>${label}</strong> <span class="text-kestrel-muted">Vessel</span>${meta ? `<div class="text-kestrel-muted text-xs mt-0.5">${meta}</div>` : ''}${popupLine(escape, stats)}</div>`
|
||||
}
|
||||
|
||||
return `<div class="kestrel-live-popup"><strong>${label}</strong> <span class="text-kestrel-muted">Team</span></div>`
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { createClusterIndex } from './mapCluster.js'
|
||||
import { syncFeatureMarkers } from './mapMarkerSync.js'
|
||||
import { cotCategory, formatCotPopup, getCotIconHtml } from './cotDisplay.js'
|
||||
|
||||
const ICON_SIZE = 28
|
||||
const CLUSTER = createClusterIndex({ radius: 50, maxZoom: 14, minPoints: 2 })
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div')
|
||||
div.textContent = text
|
||||
return div.innerHTML
|
||||
}
|
||||
|
||||
export function entitiesToFeatures(entities) {
|
||||
return (entities || [])
|
||||
.filter(e => typeof e?.lat === 'number' && typeof e?.lng === 'number' && e?.id)
|
||||
.map(e => ({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [e.lng, e.lat] },
|
||||
properties: { entity: e, cotCategory: cotCategory(e.type) },
|
||||
}))
|
||||
}
|
||||
|
||||
export function loadCotCluster(entities) {
|
||||
CLUSTER.load(entitiesToFeatures(entities))
|
||||
}
|
||||
|
||||
export function getCotClusters(view) {
|
||||
return CLUSTER.query(view)
|
||||
}
|
||||
|
||||
function featureKey(feature) {
|
||||
const props = feature.properties ?? {}
|
||||
if (props.cluster) return `c:${props.cluster_id}`
|
||||
const id = props.entity?.id
|
||||
return id != null ? `e:${id}` : null
|
||||
}
|
||||
|
||||
function clusterIcon(L, count) {
|
||||
const size = count < 10 ? 28 : count < 100 ? 34 : 40
|
||||
return L.divIcon({
|
||||
className: 'cot-cluster-icon',
|
||||
html: `<span class="cot-cluster">${count}</span>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
})
|
||||
}
|
||||
|
||||
function entityIcon(L, entity) {
|
||||
const { html, className } = getCotIconHtml(entity)
|
||||
return L.divIcon({
|
||||
className: `poi-div-icon cot-entity-icon ${className}`,
|
||||
html,
|
||||
iconSize: [ICON_SIZE, ICON_SIZE],
|
||||
iconAnchor: [ICON_SIZE / 2, ICON_SIZE / 2],
|
||||
})
|
||||
}
|
||||
|
||||
function coords(feature) {
|
||||
const [lng, lat] = feature.geometry.coordinates
|
||||
return { lat, lng }
|
||||
}
|
||||
|
||||
function attachClusterClick(marker, feature, map) {
|
||||
marker.on('click', () => {
|
||||
const { lat, lng } = coords(feature)
|
||||
const props = feature.properties ?? {}
|
||||
const zoom = props.expansionZoom ?? map.getZoom() + 2
|
||||
map.setView([lat, lng], Math.min(zoom, 19), { animate: true })
|
||||
})
|
||||
}
|
||||
|
||||
export function createCotLayer(L, map) {
|
||||
return L.layerGroup().addTo(map)
|
||||
}
|
||||
|
||||
export function syncCotLayer(L, map, layer, features) {
|
||||
syncFeatureMarkers(layer, features, {
|
||||
keyFor: featureKey,
|
||||
create: (feature) => {
|
||||
const { lat, lng } = coords(feature)
|
||||
const props = feature.properties ?? {}
|
||||
const isCluster = Boolean(props.cluster)
|
||||
const icon = isCluster ? clusterIcon(L, props.point_count) : entityIcon(L, props.entity)
|
||||
const marker = L.marker([lat, lng], { icon })
|
||||
if (isCluster) attachClusterClick(marker, feature, map)
|
||||
else if (props.entity) {
|
||||
marker.bindPopup(
|
||||
formatCotPopup(props.entity, escapeHtml),
|
||||
{ className: 'kestrel-live-popup-wrap', maxWidth: 360 },
|
||||
)
|
||||
}
|
||||
return marker
|
||||
},
|
||||
update: (marker, feature) => {
|
||||
const { lat, lng } = coords(feature)
|
||||
const props = feature.properties ?? {}
|
||||
const isCluster = Boolean(props.cluster)
|
||||
marker.setLatLng([lat, lng])
|
||||
const icon = isCluster ? clusterIcon(L, props.point_count) : entityIcon(L, props.entity)
|
||||
marker.setIcon(icon)
|
||||
if (!isCluster && props.entity) {
|
||||
marker.setPopupContent(formatCotPopup(props.entity, escapeHtml))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
+5
-5
@@ -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)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import Supercluster from 'supercluster'
|
||||
|
||||
export function createClusterIndex(options = {}) {
|
||||
const index = new Supercluster(options)
|
||||
const state = { features: Object.freeze([]) }
|
||||
|
||||
return {
|
||||
load(features) {
|
||||
const list = Object.freeze([...(features ?? [])])
|
||||
index.load(list)
|
||||
state.features = list
|
||||
},
|
||||
query(view) {
|
||||
if (!view || state.features.length === 0) return []
|
||||
const { west, south, east, north, zoom } = view
|
||||
return index.getClusters(
|
||||
[west, south, east, north],
|
||||
Math.floor(zoom ?? 14),
|
||||
).map((feature) => {
|
||||
if (!feature.properties?.cluster) return feature
|
||||
return {
|
||||
...feature,
|
||||
properties: {
|
||||
...feature.properties,
|
||||
expansionZoom: index.getClusterExpansionZoom(feature.properties.cluster_id),
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
const SYNC_KEY = '_kestrelMarkerSync'
|
||||
|
||||
const pointFeatures = (features, keyFor) => (features ?? [])
|
||||
.filter(f => f?.geometry?.type === 'Point')
|
||||
.map(f => ({ feature: f, key: keyFor(f) }))
|
||||
.filter(({ key }) => key != null)
|
||||
|
||||
export function syncFeatureMarkers(layer, features, { keyFor, create, update }) {
|
||||
const prev = layer[SYNC_KEY] ?? new Map()
|
||||
const next = pointFeatures(features, keyFor).reduce((map, { feature, key }) => {
|
||||
const existing = prev.get(key)
|
||||
if (existing) {
|
||||
update(existing, feature)
|
||||
return new Map([...map, [key, existing]])
|
||||
}
|
||||
const marker = create(feature)
|
||||
layer.addLayer(marker)
|
||||
return new Map([...map, [key, marker]])
|
||||
}, new Map())
|
||||
|
||||
Array.from(prev.entries())
|
||||
.filter(([key]) => !next.has(key))
|
||||
.forEach(([, marker]) => layer.removeLayer(marker))
|
||||
|
||||
layer[SYNC_KEY] = next
|
||||
}
|
||||
|
||||
export function clearFeatureMarkers(layer) {
|
||||
if (!layer) return
|
||||
const prev = layer[SYNC_KEY]
|
||||
if (prev) {
|
||||
Array.from(prev.values()).forEach(marker => layer.removeLayer(marker))
|
||||
layer[SYNC_KEY] = new Map()
|
||||
return
|
||||
}
|
||||
layer.clearLayers()
|
||||
}
|
||||
@@ -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)
|
||||
@@ -0,0 +1,80 @@
|
||||
# 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.
|
||||
|
||||
ADS-B and AIS via [adsbcot](https://github.com/snstac/adsbcot) / [aiscot](https://github.com/snstac/aiscot): see [tracking.md](tracking.md).
|
||||
|
||||
## 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_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).
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 MiB |
@@ -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 |
|
||||
@@ -0,0 +1,62 @@
|
||||
# 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
|
||||
- **ALPR (OSM / DeFlock)** - Crowdsourced license-plate cameras from OpenStreetMap; toggle on the map (camera icon control). Reference only, no stream.
|
||||
- **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.
|
||||
|
||||
## ALPR layer (DeFlock / OpenStreetMap)
|
||||
|
||||
deflock.me has no bulk download API. KestrelOS queries OpenStreetMap via Overpass (`surveillance:type=ALPR`) and returns **GeoJSON FeatureCollection** from `GET /api/alpr`.
|
||||
|
||||
- **Map:** ALPR layer is on by default (toggle top-left to hide). Marker popups show OSM identifying tags (manufacturer, model, operator, ref, Wikidata, etc.) when contributors tagged them.
|
||||
- **Offline:** Run `npm run import:alpr` to preload SQLite; cache serves automatically when Overpass is unreachable.
|
||||
|
||||
Attribution: © OpenStreetMap contributors.
|
||||
|
||||
## POIs
|
||||
|
||||
Admins/leaders add/edit from **POI** page (sidebar). POIs appear as map pins (reference only, no stream).
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 159 KiB |
@@ -0,0 +1,48 @@
|
||||
# ADS-B and AIS
|
||||
|
||||
Aircraft and vessels use the same **CoT** store as ATAK/iTAK. The map consumes `GET /api/cot/stream` (SSE, viewport bbox). Toggle **Air**, **Surface**, and **Team** on the map.
|
||||
|
||||
## Accuracy tiers
|
||||
|
||||
1. **Tactical (best):** local SDR/AIS receiver → [adsbcot](https://github.com/snstac/adsbcot) / [aiscot](https://github.com/snstac/aiscot) → KestrelOS CoT `:8089` (sub-second updates).
|
||||
2. **Vessels (live OSINT):** AISStream WebSocket push as vessels transmit.
|
||||
3. **Aircraft (awareness OSINT):** OpenSky bbox poll — not a live stream; typical lag ~5s.
|
||||
|
||||
For tactical use, run local receivers. Do not rely on OpenSky alone.
|
||||
|
||||
## Freshness
|
||||
|
||||
Tracks update via SSE `update` events (CoT `:8089`, AISStream) or coalesced `snapshot` after each OpenSky poll. Stale tracks are removed automatically (team ~90s, OSINT ~30s without a new fix).
|
||||
|
||||
OSINT feeds run only while a map client is connected (SSE subscriber). Keep the map tab visible for live updates.
|
||||
|
||||
## Self-hosted
|
||||
|
||||
**ADS-B:** [adsbcot](https://github.com/snstac/adsbcot) → `tls://host:8089`
|
||||
|
||||
```ini
|
||||
[adsbcot]
|
||||
COT_URL = tls://kestrelos.example.com:8089
|
||||
FEED_URL = tcp+beast://127.0.0.1:30005
|
||||
```
|
||||
|
||||
**AIS:** [aiscot](https://github.com/snstac/aiscot) → `tls://host:8089`
|
||||
|
||||
```ini
|
||||
[aiscot]
|
||||
COT_URL = tls://kestrelos.example.com:8089
|
||||
FEED_URL = tcp://127.0.0.1:10110
|
||||
```
|
||||
|
||||
Use KestrelOS credentials (see [atak-itak.md](atak-itak.md)).
|
||||
|
||||
## OSINT APIs (optional)
|
||||
|
||||
Set these only if you want viewport OSINT without local receivers:
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `AISSTREAM_API_KEY` | AISStream WebSocket |
|
||||
| `OPENSKY_CLIENT_ID` / `OPENSKY_CLIENT_SECRET` | OpenSky OAuth (recommended for production) |
|
||||
|
||||
UIDs: `ICAO.*` (ADS-B), `MMSI.*` (AIS). Icons follow CoT type (`a-f-A-*`, `a-f-S-*`, `a-f-G-*`).
|
||||
@@ -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.1.6
|
||||
appVersion: "1.1.6"
|
||||
|
||||
@@ -2,7 +2,7 @@ replicaCount: 1
|
||||
|
||||
image:
|
||||
repository: git.keligrubb.com/keligrubb/kestrelos
|
||||
tag: 0.4.0
|
||||
tag: 1.1.6
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
service:
|
||||
|
||||
+5
-1
@@ -32,10 +32,14 @@ export default defineNuxtConfig({
|
||||
public: {
|
||||
version: pkg.version ?? '',
|
||||
},
|
||||
cotRequireAuth: true,
|
||||
cotDebug: false,
|
||||
aisstreamApiKey: process.env.AISSTREAM_API_KEY || '',
|
||||
openskyClientId: process.env.OPENSKY_CLIENT_ID || '',
|
||||
openskyClientSecret: process.env.OPENSKY_CLIENT_SECRET || '',
|
||||
},
|
||||
devServer: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
...(useDevHttps
|
||||
? { https: { key: devKey, cert: devCert } }
|
||||
: {}),
|
||||
|
||||
Generated
+5036
-4907
File diff suppressed because it is too large
Load Diff
+27
-25
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kestrelos",
|
||||
"version": "0.4.0",
|
||||
"version": "1.1.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -10,40 +10,42 @@
|
||||
"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",
|
||||
"test:e2e:debug": "playwright test --debug test/e2e",
|
||||
"test:e2e:install": "playwright install --with-deps webkit chromium firefox",
|
||||
"lint": "eslint . --max-warnings 0"
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"import:alpr": "node scripts/import-alpr.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxt/icon": "^2.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"hls.js": "^1.5.0",
|
||||
"fast-xml-parser": "^5.9.3",
|
||||
"hls.js": "^1.6.16",
|
||||
"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",
|
||||
"sqlite3": "^5.1.7",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.4.0",
|
||||
"ws": "^8.18.0"
|
||||
"leaflet.offline": "^3.2.1",
|
||||
"mediasoup": "^3.20.9",
|
||||
"mediasoup-client": "^3.21.0",
|
||||
"nuxt": "^4.4.8",
|
||||
"openid-client": "^6.8.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"supercluster": "^8.0.1",
|
||||
"vue": "^3.5.38",
|
||||
"vue-router": "^5.1.0",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/tabler": "^1.2.26",
|
||||
"@nuxt/eslint": "^1.15.0",
|
||||
"@nuxt/test-utils": "^4.0.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@vitest/coverage-v8": "^4.0.0",
|
||||
"@vue/test-utils": "^2.4.0",
|
||||
"eslint": "^9.0.0",
|
||||
"happy-dom": "^20.6.1",
|
||||
"vitest": "^4.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"tar": "^7.5.7"
|
||||
"@iconify-json/tabler": "^1.2.35",
|
||||
"@nuxt/eslint": "^1.16.0",
|
||||
"@nuxt/test-utils": "^4.0.3",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"@vue/test-utils": "^2.4.11",
|
||||
"eslint": "^10.5.0",
|
||||
"happy-dom": "^20.10.6",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env node
|
||||
import { getDb, closeDb } from '../server/utils/db.js'
|
||||
import { importAllAlprNodes } from '../server/utils/alpr.js'
|
||||
|
||||
try {
|
||||
const db = await getDb()
|
||||
console.log('[import-alpr] Fetching ALPR nodes from Overpass…')
|
||||
const count = await importAllAlprNodes(db)
|
||||
console.log(`[import-alpr] Cached ${count} nodes in SQLite.`)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[import-alpr] Failed:', error?.message || error)
|
||||
process.exitCode = 1
|
||||
}
|
||||
finally {
|
||||
closeDb()
|
||||
}
|
||||
+42
-9
@@ -3,18 +3,51 @@ set -e
|
||||
|
||||
# version
|
||||
msg="${CI_COMMIT_MESSAGE:-}"
|
||||
# optional PR body (written by workflow from Gitea API when this commit is a merged PR)
|
||||
if [ -f .ci_pr_body ]; then
|
||||
CI_PR_DESCRIPTION=$(cat .ci_pr_body); rm -f .ci_pr_body
|
||||
else
|
||||
CI_PR_DESCRIPTION=""
|
||||
fi
|
||||
export CI_PR_DESCRIPTION
|
||||
bump=patch
|
||||
echo "$msg" | grep -qi minor: && bump=minor
|
||||
# Conventional commits: chore/fix => patch, feat => minor
|
||||
echo "$msg" | grep -Eqi '(^|[[:space:]])(fix|chore)(\([^)]*\))?:' && bump=patch
|
||||
echo "$msg" | grep -Eqi '(^|[[:space:]])feat(\([^)]*\))?:' && bump=minor
|
||||
# Conventional commits breaking change: type!:
|
||||
echo "$msg" | grep -Eqi '(^|[[:space:]])[a-zA-Z]+(\([^)]*\))?!:' && bump=major
|
||||
# Explicit bump prefixes still supported (but never downgrade a major bump)
|
||||
echo "$msg" | grep -qi minor: && [ "$bump" != "major" ] && bump=minor
|
||||
echo "$msg" | grep -qi major: && bump=major
|
||||
cur=$(awk '/"version"/ { match($0, /[0-9]+\.[0-9]+\.[0-9]+/); print substr($0, RSTART, RLENGTH); exit }' package.json)
|
||||
case "$cur" in
|
||||
[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) echo "error: package.json version must be x.y.z (got: $cur)"; exit 1 ;;
|
||||
esac
|
||||
major=$(echo "$cur" | cut -d. -f1); minor=$(echo "$cur" | cut -d. -f2); patch=$(echo "$cur" | cut -d. -f3)
|
||||
case "$bump" in major) major=$((major+1)); minor=0; patch=0 ;; minor) minor=$((minor+1)); patch=0 ;; patch) patch=$((patch+1)) ;; esac
|
||||
newVersion="$major.$minor.$patch"
|
||||
[ -z "$cur" ] && { echo "error: could not read version from package.json"; exit 1; }
|
||||
|
||||
# changelog entry (strip prefix from first line)
|
||||
changelogEntry=$(echo "$msg" | head -1 | awk '{sub(/^[mM]ajor:[ \t]*/,""); sub(/^[mM]inor:[ \t]*/,""); sub(/^[pP]atch:[ \t]*/,""); print}')
|
||||
url="https://${CI_REPO_OWNER}:${GITEA_REPO_TOKEN}@${CI_FORGE_URL#https://}/${CI_REPO_OWNER}/${CI_REPO_NAME}.git"
|
||||
if [ -n "$(git ls-remote "$url" "refs/tags/v$newVersion" 2>/dev/null)" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# changelog entry (strip explicit bump prefixes & any conventional-commit type(scope):); optional PR description enriches it
|
||||
changelogEntry=$(
|
||||
echo "$msg" \
|
||||
| head -1 \
|
||||
| sed -E 's/^[[:space:]]*[mM]ajor:[[:space:]]*//; s/^[[:space:]]*[mM]inor:[[:space:]]*//; s/^[[:space:]]*[pP]atch:[[:space:]]*//' \
|
||||
| sed -E 's/^[[:space:]]*[a-zA-Z]+(\([^)]*\))?:[[:space:]]*//'
|
||||
)
|
||||
[ -z "$changelogEntry" ] && changelogEntry="Release v$newVersion"
|
||||
if [ -n "$CI_PR_DESCRIPTION" ]; then
|
||||
changelogFull="- $changelogEntry
|
||||
|
||||
$CI_PR_DESCRIPTION"
|
||||
else
|
||||
changelogFull="- $changelogEntry"
|
||||
fi
|
||||
|
||||
# bump files
|
||||
awk -v v="$newVersion" '/"version"/ { sub(/[0-9]+\.[0-9]+\.[0-9]+/, v) } { print }' package.json > package.json.tmp && mv package.json.tmp package.json
|
||||
@@ -24,18 +57,18 @@ awk -v v="$newVersion" '/^ tag:/ { $0 = " tag: " v }; { print }' helm/kestrelo
|
||||
# changelog
|
||||
new="## [$newVersion] - $(date +%Y-%m-%d)
|
||||
### Changed
|
||||
- $changelogEntry
|
||||
$changelogFull
|
||||
|
||||
"
|
||||
{ [ ! -f CHANGELOG.md ] && printf '# Changelog\n\n'; printf '%s' "$new"; [ -f CHANGELOG.md ] && cat CHANGELOG.md; } > CHANGELOG.md.tmp && mv CHANGELOG.md.tmp CHANGELOG.md
|
||||
# Create CHANGELOG.md if missing (first release); otherwise prepend new entry to existing content.
|
||||
{ [ ! -f CHANGELOG.md ] && printf '# Changelog\n\n'; printf '%s' "$new"; [ -f CHANGELOG.md ] && cat CHANGELOG.md || true; } > CHANGELOG.md.tmp && mv CHANGELOG.md.tmp CHANGELOG.md
|
||||
|
||||
# git
|
||||
git config user.email "ci@kestrelos" && git config user.name "CI"
|
||||
git add package.json helm/kestrelos/Chart.yaml helm/kestrelos/values.yaml CHANGELOG.md
|
||||
git commit -m "release v$newVersion [skip ci]"
|
||||
url="https://${CI_REPO_OWNER}:${GITEA_REPO_TOKEN}@${CI_FORGE_URL#https://}/${CI_REPO_OWNER}/${CI_REPO_NAME}.git"
|
||||
git tag "v$newVersion"
|
||||
# artifact for kaniko (tag list)
|
||||
# artifact for docker (tag list)
|
||||
printf '%s\n%s\n' "$newVersion" "latest" > .tags
|
||||
retry() { n=0; while ! "$@"; do n=$((n+1)); [ $n -ge 3 ] && return 1; sleep 2; done; }
|
||||
retry git push "$url" HEAD:main "v$newVersion"
|
||||
@@ -43,7 +76,7 @@ retry git push "$url" HEAD:main "v$newVersion"
|
||||
# gitea release
|
||||
body="## Changelog
|
||||
### Changed
|
||||
- $changelogEntry
|
||||
$changelogFull
|
||||
|
||||
## Installation
|
||||
- [Docker image](${CI_FORGE_URL}/${CI_REPO_OWNER}/-/packages/container/${CI_REPO_NAME})
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getDb } from '../utils/db.js'
|
||||
import { requireAuth } from '../utils/authHelpers.js'
|
||||
import { getAlprCameras, parseBbox } from '../utils/alpr.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAuth(event)
|
||||
let bbox
|
||||
try {
|
||||
bbox = parseBbox(getQuery(event))
|
||||
}
|
||||
catch (error) {
|
||||
throw createError({
|
||||
statusCode: error?.statusCode || 400,
|
||||
message: error?.message || 'invalid bbox',
|
||||
})
|
||||
}
|
||||
const db = await getDb()
|
||||
return getAlprCameras(db, bbox)
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -5,7 +5,10 @@ import { rowToDevice, sanitizeDeviceForResponse } from '../utils/deviceUtils.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAuth(event)
|
||||
const [db, sessions] = await Promise.all([getDb(), getActiveSessions()])
|
||||
const [db, sessions] = await Promise.all([
|
||||
getDb(),
|
||||
getActiveSessions(),
|
||||
])
|
||||
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 }
|
||||
|
||||
@@ -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) }
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createEventStream } from 'h3'
|
||||
import { requireAuth } from '../../utils/authHelpers.js'
|
||||
import { getActiveEntitiesInBbox } from '../../utils/cotStore.js'
|
||||
import { registerSubscriber } from '../../utils/cotSubscribers.js'
|
||||
import { getCotSnapshotOpts } from '../../utils/cotSnapshot.js'
|
||||
import { COT_SSE_HEARTBEAT_MS } from '../../utils/constants.js'
|
||||
import { parseBboxParam, parseLayersParam } from '../../utils/cotEntityUtils.js'
|
||||
import { scheduleTrackingFeedRefresh } from '../../utils/trackingFeed.js'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
requireAuth(event)
|
||||
const query = getQuery(event)
|
||||
const bbox = parseBboxParam(typeof query.bbox === 'string' ? query.bbox : undefined)
|
||||
const layers = parseLayersParam(typeof query.layers === 'string' ? query.layers : undefined)
|
||||
const snapshotOpts = getCotSnapshotOpts()
|
||||
|
||||
const stream = createEventStream(event)
|
||||
|
||||
const push = (eventName, data) => stream.push({ event: eventName, data })
|
||||
|
||||
const sendSnapshot = async () => {
|
||||
const entities = await getActiveEntitiesInBbox(bbox, { ...snapshotOpts, layers })
|
||||
await push('snapshot', JSON.stringify({ entities }))
|
||||
}
|
||||
|
||||
const unregister = registerSubscriber({ bbox, layers, push })
|
||||
|
||||
let heartbeat
|
||||
|
||||
stream.onClosed(async () => {
|
||||
clearInterval(heartbeat)
|
||||
unregister()
|
||||
scheduleTrackingFeedRefresh()
|
||||
})
|
||||
|
||||
void (async () => {
|
||||
scheduleTrackingFeedRefresh()
|
||||
await sendSnapshot()
|
||||
heartbeat = setInterval(() => {
|
||||
push('heartbeat', '{}').catch(() => {})
|
||||
}, COT_SSE_HEARTBEAT_MS)
|
||||
})()
|
||||
|
||||
return stream.send()
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
})
|
||||
+12
-10
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+16
-14
@@ -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
|
||||
|
||||
@@ -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, type: parsed.eventType }).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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { registerCleanup } from '../utils/shutdown.js'
|
||||
import { onCotChange, pruneStaleEntities } from '../utils/cotStore.js'
|
||||
import { getCotSnapshotOpts } from '../utils/cotSnapshot.js'
|
||||
import {
|
||||
notifySubscribersForEntity,
|
||||
notifySubscribersRemove,
|
||||
} from '../utils/cotSubscribers.js'
|
||||
import { COT_PRUNE_INTERVAL_MS } from '../utils/constants.js'
|
||||
|
||||
let pruneTimer = null
|
||||
let offChange = () => {}
|
||||
|
||||
export default defineNitroPlugin(() => {
|
||||
offChange = onCotChange((changeEvent, payload) => {
|
||||
if (changeEvent === 'update' && payload?.entity) {
|
||||
notifySubscribersForEntity('update', { entity: payload.entity }, payload.entity).catch(() => {})
|
||||
}
|
||||
else if (changeEvent === 'remove' && payload?.id) {
|
||||
notifySubscribersRemove(payload.id).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
pruneTimer = setInterval(() => {
|
||||
pruneStaleEntities(getCotSnapshotOpts()).catch(() => {})
|
||||
}, COT_PRUNE_INTERVAL_MS)
|
||||
|
||||
registerCleanup(() => {
|
||||
offChange()
|
||||
if (pruneTimer) clearInterval(pruneTimer)
|
||||
pruneTimer = null
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { registerCleanup } from '../utils/shutdown.js'
|
||||
import { startTrackingFeed, stopTrackingFeed } from '../utils/trackingFeed.js'
|
||||
|
||||
export default defineNitroPlugin(() => {
|
||||
startTrackingFeed()
|
||||
registerCleanup(stopTrackingFeed)
|
||||
})
|
||||
@@ -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' }
|
||||
})
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { cameraToFeature, featureCollection } from './alprGeo.js'
|
||||
|
||||
const OVERPASS_URL = 'https://overpass.deflock.org/api/interpreter'
|
||||
const MAX_BBOX_DEGREES = 0.5
|
||||
const CACHE_TTL_MS = 86_400_000
|
||||
const TILE_SCALE = 10
|
||||
|
||||
function httpError(statusCode, message) {
|
||||
const err = new Error(message)
|
||||
err.statusCode = statusCode
|
||||
return err
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} tags
|
||||
* @returns {Record<string, string>} String OSM tags only
|
||||
*/
|
||||
function normalizeTags(tags) {
|
||||
if (!tags || typeof tags !== 'object') return {}
|
||||
return Object.fromEntries(
|
||||
Object.entries(/** @type {Record<string, unknown>} */ (tags))
|
||||
.filter(([, v]) => typeof v === 'string')
|
||||
.map(([k, v]) => [k, /** @type {string} */ (v)]),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} element
|
||||
* @returns {{ osmId: number, lat: number, lng: number, manufacturer: string | null, direction: number | null, tags: Record<string, string> } | null} Parsed camera or null
|
||||
*/
|
||||
export function parseOverpassElement(element) {
|
||||
if (!element || typeof element !== 'object') return null
|
||||
const el = /** @type {Record<string, unknown>} */ (element)
|
||||
if (el.type !== 'node' || typeof el.id !== 'number') return null
|
||||
const lat = Number(el.lat)
|
||||
const lng = Number(el.lon ?? el.lng)
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null
|
||||
const tags = normalizeTags(el.tags)
|
||||
const directionRaw = tags['camera:direction'] ?? tags.direction
|
||||
const direction = directionRaw != null && directionRaw !== '' ? Number(directionRaw) : null
|
||||
const fovRaw = tags['camera:angle'] ?? tags['surveillance:angle']
|
||||
const fov = fovRaw != null && fovRaw !== '' ? Number(fovRaw) : null
|
||||
return {
|
||||
osmId: el.id,
|
||||
lat,
|
||||
lng,
|
||||
manufacturer: tags.manufacturer ?? tags.brand ?? null,
|
||||
direction: Number.isFinite(direction) ? direction : null,
|
||||
fov: Number.isFinite(fov) ? fov : null,
|
||||
tags,
|
||||
}
|
||||
}
|
||||
|
||||
export function parseBbox(query) {
|
||||
const south = Number(query.south)
|
||||
const west = Number(query.west)
|
||||
const north = Number(query.north)
|
||||
const east = Number(query.east)
|
||||
if (![south, west, north, east].every(Number.isFinite)) {
|
||||
throw httpError(400, 'south, west, north, east required')
|
||||
}
|
||||
if (south >= north || west >= east) {
|
||||
throw httpError(400, 'invalid bbox')
|
||||
}
|
||||
if (north - south > MAX_BBOX_DEGREES || east - west > MAX_BBOX_DEGREES) {
|
||||
throw httpError(400, `bbox exceeds ${MAX_BBOX_DEGREES}°`)
|
||||
}
|
||||
return { south, west, north, east }
|
||||
}
|
||||
|
||||
/** @param {{ south: number, west: number, north: number, east: number }} bbox */
|
||||
export function tileKeysForBbox(bbox) {
|
||||
const latMin = Math.floor(bbox.south * TILE_SCALE)
|
||||
const latMax = Math.floor(bbox.north * TILE_SCALE)
|
||||
const lngMin = Math.floor(bbox.west * TILE_SCALE)
|
||||
const lngMax = Math.floor(bbox.east * TILE_SCALE)
|
||||
const keys = []
|
||||
for (let lat = latMin; lat <= latMax; lat++) {
|
||||
for (let lng = lngMin; lng <= lngMax; lng++) {
|
||||
keys.push(`${lat}:${lng}`)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
export function buildBboxQuery(bbox) {
|
||||
const { south, west, north, east } = bbox
|
||||
return `[out:json][timeout:25];node["surveillance:type"="ALPR"](${south},${west},${north},${east});out body;`
|
||||
}
|
||||
|
||||
export function buildGlobalQuery() {
|
||||
return '[out:json][timeout:300];node["surveillance:type"="ALPR"];out body;'
|
||||
}
|
||||
|
||||
function rowToCamera(row, source) {
|
||||
const tags = (() => {
|
||||
try {
|
||||
return JSON.parse(row.tags)
|
||||
}
|
||||
catch {
|
||||
return {}
|
||||
}
|
||||
})()
|
||||
const fovRaw = tags['camera:angle'] ?? tags['surveillance:angle']
|
||||
const fov = fovRaw != null && fovRaw !== '' ? Number(fovRaw) : null
|
||||
return {
|
||||
osmId: row.osm_id,
|
||||
lat: row.lat,
|
||||
lng: row.lng,
|
||||
manufacturer: row.manufacturer ?? null,
|
||||
direction: row.direction ?? null,
|
||||
fov: Number.isFinite(fov) ? fov : null,
|
||||
tags,
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
|
||||
export async function upsertAlprNode(db, camera) {
|
||||
const now = new Date().toISOString()
|
||||
await db.run(
|
||||
`INSERT INTO alpr_nodes (osm_id, lat, lng, manufacturer, direction, tags, fetched_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(osm_id) DO UPDATE SET
|
||||
lat = excluded.lat,
|
||||
lng = excluded.lng,
|
||||
manufacturer = excluded.manufacturer,
|
||||
direction = excluded.direction,
|
||||
tags = excluded.tags,
|
||||
fetched_at = excluded.fetched_at`,
|
||||
[
|
||||
camera.osmId,
|
||||
camera.lat,
|
||||
camera.lng,
|
||||
camera.manufacturer,
|
||||
camera.direction,
|
||||
JSON.stringify(camera.tags),
|
||||
now,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
|
||||
export async function upsertAlprNodesBatch(db, cameras) {
|
||||
if (!cameras.length) return
|
||||
await db.run('BEGIN TRANSACTION')
|
||||
try {
|
||||
for (const camera of cameras) {
|
||||
await upsertAlprNode(db, camera)
|
||||
}
|
||||
await db.run('COMMIT')
|
||||
}
|
||||
catch (error) {
|
||||
await db.run('ROLLBACK').catch(() => {})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
|
||||
export async function markTilesFetched(db, keys) {
|
||||
if (!keys.length) return
|
||||
const now = new Date().toISOString()
|
||||
await db.run('BEGIN TRANSACTION')
|
||||
try {
|
||||
for (const key of keys) {
|
||||
await db.run('INSERT OR REPLACE INTO alpr_tiles (tile_key, fetched_at) VALUES (?, ?)', [key, now])
|
||||
}
|
||||
await db.run('COMMIT')
|
||||
}
|
||||
catch (error) {
|
||||
await db.run('ROLLBACK').catch(() => {})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
|
||||
async function tilesAreFresh(db, keys) {
|
||||
if (!keys.length) return false
|
||||
const placeholders = keys.map(() => '?').join(',')
|
||||
const rows = await db.all(
|
||||
`SELECT tile_key, fetched_at FROM alpr_tiles WHERE tile_key IN (${placeholders})`,
|
||||
keys,
|
||||
)
|
||||
if (rows.length !== keys.length) return false
|
||||
const cutoff = Date.now() - CACHE_TTL_MS
|
||||
return rows.every(row => Date.parse(row.fetched_at) >= cutoff)
|
||||
}
|
||||
|
||||
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
|
||||
export async function getCachedAlprNodes(db, bbox) {
|
||||
const rows = await db.all(
|
||||
`SELECT osm_id, lat, lng, manufacturer, direction, tags
|
||||
FROM alpr_nodes
|
||||
WHERE lat >= ? AND lat <= ? AND lng >= ? AND lng <= ?`,
|
||||
[bbox.south, bbox.north, bbox.west, bbox.east],
|
||||
)
|
||||
return rows.map(row => rowToCamera(row, 'cache'))
|
||||
}
|
||||
|
||||
export async function fetchOverpass(query) {
|
||||
const res = await fetch(OVERPASS_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `data=${encodeURIComponent(query)}`,
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw httpError(502, `Overpass error: ${res.status}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
const elements = Array.isArray(data?.elements) ? data.elements : []
|
||||
return elements.map(parseOverpassElement).filter(Boolean)
|
||||
}
|
||||
|
||||
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
|
||||
async function fetchAndCacheBbox(db, bbox, tileKeys) {
|
||||
const live = await fetchOverpass(buildBboxQuery(bbox))
|
||||
await upsertAlprNodesBatch(db, live)
|
||||
await markTilesFetched(db, tileKeys)
|
||||
return live.map(c => ({ ...c, source: 'live' }))
|
||||
}
|
||||
|
||||
const refreshInFlight = new Map()
|
||||
|
||||
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
|
||||
function refreshBboxInBackground(db, bbox, tileKeys) {
|
||||
const key = tileKeys.join('|')
|
||||
if (refreshInFlight.has(key)) return
|
||||
const job = fetchAndCacheBbox(db, bbox, tileKeys)
|
||||
.catch(() => {})
|
||||
.finally(() => refreshInFlight.delete(key))
|
||||
refreshInFlight.set(key, job)
|
||||
}
|
||||
|
||||
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
|
||||
export async function getAlprCameras(db, bbox) {
|
||||
const tileKeys = tileKeysForBbox(bbox)
|
||||
const cached = await getCachedAlprNodes(db, bbox)
|
||||
|
||||
if (await tilesAreFresh(db, tileKeys)) {
|
||||
return featureCollection(cached.map(cameraToFeature), 'cache')
|
||||
}
|
||||
|
||||
if (cached.length > 0) {
|
||||
refreshBboxInBackground(db, bbox, tileKeys)
|
||||
return featureCollection(cached.map(cameraToFeature), 'cache')
|
||||
}
|
||||
|
||||
try {
|
||||
const live = await fetchAndCacheBbox(db, bbox, tileKeys)
|
||||
return featureCollection(live.map(cameraToFeature), 'live')
|
||||
}
|
||||
catch {
|
||||
return featureCollection(cached.map(cameraToFeature), 'cache')
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
|
||||
export async function importAllAlprNodes(db) {
|
||||
const cameras = await fetchOverpass(buildGlobalQuery())
|
||||
await upsertAlprNodesBatch(db, cameras)
|
||||
return cameras.length
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
const ATTRIBUTION = '© OpenStreetMap contributors'
|
||||
|
||||
/** Longest match first — Flock, Genetec, Leonardo, etc. */
|
||||
const KNOWN_ALPR_MODELS = [
|
||||
'Falcon Flex',
|
||||
'AutoVu CR-H2',
|
||||
'AutoVu Sharp',
|
||||
'UnicamVELOCITY3',
|
||||
'Falcon',
|
||||
'Sparrow',
|
||||
'Raven',
|
||||
'Condor',
|
||||
'Wing',
|
||||
'Pelican',
|
||||
]
|
||||
|
||||
const GENERIC_CAMERA_NAMES = new Set([
|
||||
'flock alpr camera',
|
||||
'flock alpr cameera',
|
||||
'flock camera',
|
||||
'flock alpr',
|
||||
'flock',
|
||||
'flock camera',
|
||||
'automatic license plate reader (alpr)',
|
||||
'alpr camera',
|
||||
'alpr',
|
||||
])
|
||||
|
||||
const SKIP_EXTRA_TAGS = new Set([
|
||||
'surveillance:type',
|
||||
'man_made',
|
||||
'camera:direction',
|
||||
'direction',
|
||||
'camera:angle',
|
||||
'surveillance:angle',
|
||||
'manufacturer',
|
||||
'brand',
|
||||
'model',
|
||||
'operator',
|
||||
'name',
|
||||
'ref',
|
||||
'surveillance',
|
||||
'camera:type',
|
||||
'description',
|
||||
'note',
|
||||
'colour',
|
||||
'color',
|
||||
'manufacturer:wikidata',
|
||||
'model:wikidata',
|
||||
'operator:wikidata',
|
||||
])
|
||||
|
||||
function escapeRegex(text) {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
/** @param {string} value */
|
||||
export function normalizeModelName(value) {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
const lower = trimmed.toLowerCase()
|
||||
for (const model of KNOWN_ALPR_MODELS) {
|
||||
if (model.toLowerCase() === lower) return model
|
||||
}
|
||||
return trimmed.split(/\s+/).map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
|
||||
).join(' ')
|
||||
}
|
||||
|
||||
/** @param {string} text */
|
||||
function matchKnownModel(text) {
|
||||
for (const model of KNOWN_ALPR_MODELS) {
|
||||
const re = new RegExp(`\\b${escapeRegex(model)}\\b`, 'i')
|
||||
if (re.test(text)) return model
|
||||
}
|
||||
const flock = text.match(/\bFlock\s+([a-z][\w -]*)/i)
|
||||
if (flock) {
|
||||
const fragment = flock[1].trim()
|
||||
if (!fragment || /^safety$/i.test(fragment)) return null
|
||||
return matchKnownModel(fragment)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** @param {Record<string, string>} tags */
|
||||
export function inferModelFromTags(tags) {
|
||||
const t = tags ?? {}
|
||||
if (t.model?.trim()) return normalizeModelName(t.model)
|
||||
for (const key of ['name', 'description', 'note']) {
|
||||
const value = t[key]
|
||||
if (!value?.trim()) continue
|
||||
const model = matchKnownModel(value)
|
||||
if (model) return model
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** @param {string | null | undefined} name */
|
||||
export function isGenericCameraName(name) {
|
||||
if (!name?.trim()) return false
|
||||
const normalized = name.trim().toLowerCase()
|
||||
if (GENERIC_CAMERA_NAMES.has(normalized)) return true
|
||||
if (/^flock\s+alpr\b/i.test(name) && !matchKnownModel(name)) return true
|
||||
if (matchKnownModel(name) && /\bflock\b/i.test(name)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** @param {Record<string, string>} tags */
|
||||
export function displayNameFromTags(tags) {
|
||||
const name = tags?.name?.trim()
|
||||
if (!name || isGenericCameraName(name)) return null
|
||||
if (matchKnownModel(name)) return null
|
||||
return name
|
||||
}
|
||||
|
||||
/** @param {Record<string, string>} tags */
|
||||
export function identifyingProperties(tags, manufacturer = null) {
|
||||
const t = tags ?? {}
|
||||
const mfr = manufacturer ?? t.manufacturer ?? t.brand ?? null
|
||||
const model = inferModelFromTags(t)
|
||||
const name = displayNameFromTags(t)
|
||||
/** @type {Record<string, string | boolean>} */
|
||||
const props = {}
|
||||
if (mfr) props.manufacturer = mfr
|
||||
if (model) props.model = model
|
||||
if (mfr && !model) props.modelUnknown = true
|
||||
if (t.brand && t.brand !== mfr) props.brand = t.brand
|
||||
if (t.operator) props.operator = t.operator
|
||||
if (name) props.name = name
|
||||
if (t.ref) props.ref = t.ref
|
||||
if (t.surveillance) props.surveillance = t.surveillance
|
||||
if (t['camera:type']) props.cameraType = t['camera:type']
|
||||
if (t.description) props.description = t.description
|
||||
if (t.note) props.note = t.note
|
||||
const colour = t.colour ?? t.color
|
||||
if (colour) props.colour = colour
|
||||
if (t['manufacturer:wikidata']) props.manufacturerWikidata = t['manufacturer:wikidata']
|
||||
if (t['model:wikidata']) props.modelWikidata = t['model:wikidata']
|
||||
if (t['operator:wikidata']) props.operatorWikidata = t['operator:wikidata']
|
||||
|
||||
const extra = {}
|
||||
for (const [key, value] of Object.entries(t)) {
|
||||
if (SKIP_EXTRA_TAGS.has(key) || !value?.trim()) continue
|
||||
extra[key] = value
|
||||
}
|
||||
if (Object.keys(extra).length) props.tags = extra
|
||||
return props
|
||||
}
|
||||
|
||||
/** @param {{ osmId: number, lat: number, lng: number, manufacturer: string | null, direction: number | null, fov: number | null, tags: Record<string, string> }} camera */
|
||||
export function cameraToFeature(camera) {
|
||||
return {
|
||||
type: 'Feature',
|
||||
id: camera.osmId,
|
||||
geometry: { type: 'Point', coordinates: [camera.lng, camera.lat] },
|
||||
properties: {
|
||||
osmId: camera.osmId,
|
||||
direction: camera.direction,
|
||||
fov: camera.fov,
|
||||
...identifyingProperties(camera.tags, camera.manufacturer),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {ReturnType<typeof cameraToFeature>[]} features */
|
||||
export function featureCollection(features, source) {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features,
|
||||
attribution: ATTRIBUTION,
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
export { ATTRIBUTION }
|
||||
@@ -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,28 @@ 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/alpr',
|
||||
'/api/cot',
|
||||
'/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 + '/'))
|
||||
}
|
||||
Vendored
-26
@@ -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`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Application constants with environment variable support.
|
||||
*/
|
||||
|
||||
// CoT / tracking (fixed defaults — not env-configurable)
|
||||
export const COT_TTL_MS = 90_000
|
||||
/** @deprecated Use COT_TTL_MS */
|
||||
export const COT_ENTITY_TTL_MS = COT_TTL_MS
|
||||
export const COT_OSINT_TTL_MS = 30_000
|
||||
export const COT_PRUNE_INTERVAL_MS = 15_000
|
||||
export const COT_SSE_HEARTBEAT_MS = 15_000
|
||||
export const OPENSKY_CACHE_MS = 5_000
|
||||
export const TRACKING_FEED_DEBOUNCE_MS = 500
|
||||
export const COT_TAK_FILTER_BBOX = false
|
||||
export const COT_SSE_MAX_ENTITIES = 2000
|
||||
export const MAX_OPENSKY_BBOX_DEGREES = 10
|
||||
|
||||
// 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 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* CoT entity helpers: filters and OSINT → CoT mapping.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {'adsb' | 'ais' | 'tak'} Inferred track source.
|
||||
*/
|
||||
export function inferSourceFromId(id) {
|
||||
if (typeof id !== 'string') return 'tak'
|
||||
if (id.startsWith('ICAO.')) return 'adsb'
|
||||
if (id.startsWith('MMSI.')) return 'ais'
|
||||
return 'tak'
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} [type]
|
||||
* @returns {'air' | 'surface' | 'ground'} CoT display category.
|
||||
*/
|
||||
export function cotCategoryFromType(type) {
|
||||
const t = typeof type === 'string' ? type : ''
|
||||
if (t.startsWith('a-f-A-')) return 'air'
|
||||
if (t.startsWith('a-f-S-')) return 'surface'
|
||||
return 'ground'
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ lat: number, lng: number }} point
|
||||
* @param {{ west: number, south: number, east: number, north: number }} bbox
|
||||
*/
|
||||
export function isInBbox(point, bbox) {
|
||||
if (!point || !bbox) return false
|
||||
const { lat, lng } = point
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return false
|
||||
return lat >= bbox.south && lat <= bbox.north && lng >= bbox.west && lng <= bbox.east
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Set<string> | string[] | undefined} layers
|
||||
* @param {{ type?: string, source?: string }} entity
|
||||
*/
|
||||
export function matchesLayerFilter(layers, entity) {
|
||||
if (!layers || layers.size === 0) return true
|
||||
const category = cotCategoryFromType(entity.type)
|
||||
if (layers.has('air') && category === 'air') return true
|
||||
if (layers.has('surface') && category === 'surface') return true
|
||||
if (layers.has('ground') && category === 'ground') return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** OpenSky emitter category → MilStd CoT air type. */
|
||||
function openSkyCategoryToType(category) {
|
||||
if (category === 8) return 'a-f-A-C-H' // rotorcraft
|
||||
if (category === 14) return 'a-f-A-C-F' // UAV — plane icon
|
||||
return 'a-f-A-C-F'
|
||||
}
|
||||
|
||||
/** OpenSky state vector → CoT upsert. */
|
||||
export function openSkyStateToCot(state) {
|
||||
if (!Array.isArray(state) || state.length < 11) return null
|
||||
const icao24 = String(state[0] ?? '').trim().toLowerCase()
|
||||
if (!icao24) return null
|
||||
const lat = Number(state[6])
|
||||
const lng = Number(state[5])
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null
|
||||
const callsign = typeof state[1] === 'string' ? state[1].trim() : ''
|
||||
const originCountry = typeof state[2] === 'string' ? state[2].trim() : ''
|
||||
const category = Number(state[17])
|
||||
const type = Number.isFinite(category) ? openSkyCategoryToType(category) : 'a-f-A-C-F'
|
||||
const heading = Number(state[10])
|
||||
const speed = Number(state[9])
|
||||
const altitude = Number(state[7])
|
||||
const verticalRate = Number(state[11])
|
||||
const onGround = state[8] === true
|
||||
const squawk = state[14] != null ? String(state[14]).padStart(4, '0') : undefined
|
||||
return {
|
||||
id: `ICAO.${icao24}`,
|
||||
lat,
|
||||
lng,
|
||||
label: callsign || icao24.toUpperCase(),
|
||||
type,
|
||||
source: 'adsb',
|
||||
icao: icao24,
|
||||
originCountry: originCountry || undefined,
|
||||
heading: Number.isFinite(heading) ? heading : undefined,
|
||||
speed: Number.isFinite(speed) ? speed : undefined,
|
||||
altitude: Number.isFinite(altitude) ? altitude : undefined,
|
||||
verticalRate: Number.isFinite(verticalRate) ? verticalRate : undefined,
|
||||
onGround: onGround || undefined,
|
||||
squawk: squawk && squawk !== '0000' ? squawk : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** AISStream position report → CoT upsert. */
|
||||
export function aisStreamMessageToCot(message) {
|
||||
if (!message || typeof message !== 'object') return null
|
||||
const meta = /** @type {Record<string, unknown>} */ (message.MetaData)
|
||||
const msg = /** @type {Record<string, unknown>} */ (message.Message)
|
||||
if (!msg || typeof msg !== 'object') return null
|
||||
const report = /** @type {Record<string, unknown>} */ (
|
||||
msg.PositionReport ?? msg.StandardClassBPositionReport ?? msg.ExtendedClassBPositionReport
|
||||
)
|
||||
if (!report || typeof report !== 'object') return null
|
||||
const mmsi = Number(meta?.MMSI ?? report.UserID)
|
||||
if (!Number.isFinite(mmsi)) return null
|
||||
const lat = Number(report.Latitude)
|
||||
const lng = Number(report.Longitude)
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null
|
||||
const shipName = typeof meta?.ShipName === 'string' ? meta.ShipName.trim() : ''
|
||||
const heading = Number(report.Cog ?? report.TrueHeading)
|
||||
const speed = Number(report.Sog)
|
||||
return {
|
||||
id: `MMSI.${mmsi}`,
|
||||
lat,
|
||||
lng,
|
||||
label: shipName || `MMSI ${mmsi}`,
|
||||
type: 'a-f-S-C',
|
||||
source: 'ais',
|
||||
mmsi: String(mmsi),
|
||||
heading: Number.isFinite(heading) ? heading : undefined,
|
||||
speed: Number.isFinite(speed) ? speed : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of subscriber bboxes.
|
||||
* @param {Array<{ west: number, south: number, east: number, north: number } | null | undefined>} boxes
|
||||
*/
|
||||
export function unionBboxes(boxes) {
|
||||
let west = Infinity
|
||||
let south = Infinity
|
||||
let east = -Infinity
|
||||
let north = -Infinity
|
||||
let has = false
|
||||
for (const bbox of boxes) {
|
||||
if (!bbox) continue
|
||||
has = true
|
||||
west = Math.min(west, bbox.west)
|
||||
south = Math.min(south, bbox.south)
|
||||
east = Math.max(east, bbox.east)
|
||||
north = Math.max(north, bbox.north)
|
||||
}
|
||||
return has ? { west, south, east, north } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Shrink bbox to max span (degrees per axis), centered on midpoint.
|
||||
* @param {{ west: number, south: number, east: number, north: number } | null} bbox
|
||||
* @param {number} maxDegrees
|
||||
*/
|
||||
export function clampBbox(bbox, maxDegrees) {
|
||||
if (!bbox || !Number.isFinite(maxDegrees) || maxDegrees <= 0) return bbox
|
||||
const latSpan = bbox.north - bbox.south
|
||||
const lngSpan = bbox.east - bbox.west
|
||||
if (latSpan <= maxDegrees && lngSpan <= maxDegrees) return bbox
|
||||
const latMid = (bbox.north + bbox.south) / 2
|
||||
const lngMid = (bbox.east + bbox.west) / 2
|
||||
const half = maxDegrees / 2
|
||||
return {
|
||||
south: latMid - half,
|
||||
north: latMid + half,
|
||||
west: lngMid - half,
|
||||
east: lngMid + half,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | undefined} raw
|
||||
* @returns {Set<string>} Enabled layer names.
|
||||
*/
|
||||
export function parseLayersParam(raw) {
|
||||
if (!raw || typeof raw !== 'string') {
|
||||
return new Set(['air', 'surface', 'ground'])
|
||||
}
|
||||
const parts = raw.split(',').map(s => s.trim().toLowerCase()).filter(Boolean)
|
||||
if (parts.length === 0 || parts.includes('none')) return new Set()
|
||||
return new Set(parts)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | undefined} raw
|
||||
* @returns {{ west: number, south: number, east: number, north: number } | null} Parsed bbox or null.
|
||||
*/
|
||||
export function parseBboxParam(raw) {
|
||||
if (!raw || typeof raw !== 'string') return null
|
||||
const parts = raw.split(',').map(s => Number(s.trim()))
|
||||
if (parts.length !== 4 || parts.some(n => !Number.isFinite(n))) return null
|
||||
const [west, south, east, north] = parts
|
||||
if (south > north || west > east) return null
|
||||
return { west, south, east, north }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
COT_OSINT_TTL_MS,
|
||||
COT_SSE_MAX_ENTITIES,
|
||||
COT_TAK_FILTER_BBOX,
|
||||
COT_TTL_MS,
|
||||
} from './constants.js'
|
||||
|
||||
export function getCotSnapshotOpts() {
|
||||
return {
|
||||
ttlMs: COT_TTL_MS,
|
||||
osintTtlMs: COT_OSINT_TTL_MS,
|
||||
takFilterBbox: COT_TAK_FILTER_BBOX,
|
||||
maxEntities: COT_SSE_MAX_ENTITIES,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* In-memory CoT store (TAK, ADS-B, AIS).
|
||||
*/
|
||||
|
||||
import { acquire } from './asyncLock.js'
|
||||
import { COT_OSINT_TTL_MS, COT_TTL_MS } from './constants.js'
|
||||
import { inferSourceFromId, isInBbox, matchesLayerFilter } from './cotEntityUtils.js'
|
||||
|
||||
const entities = new Map()
|
||||
/** @type {Set<(event: string, payload: unknown) => void>} */
|
||||
const listeners = new Set()
|
||||
|
||||
/**
|
||||
* @param {(event: string, payload: unknown) => void} fn
|
||||
* @returns {() => void} Unsubscribe function.
|
||||
*/
|
||||
export function onCotChange(fn) {
|
||||
listeners.add(fn)
|
||||
return () => listeners.delete(fn)
|
||||
}
|
||||
|
||||
function emitChange(event, payload) {
|
||||
for (const fn of listeners) {
|
||||
try {
|
||||
fn(event, payload)
|
||||
}
|
||||
catch {
|
||||
/* ignore listener errors */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pickOptionalNumber(value, fallback) {
|
||||
if (value === undefined || value === null) return fallback
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} entity
|
||||
*/
|
||||
function toSnapshot(entity) {
|
||||
return {
|
||||
id: entity.id,
|
||||
lat: entity.lat,
|
||||
lng: entity.lng,
|
||||
label: entity.label ?? entity.id,
|
||||
type: entity.type ?? '',
|
||||
source: entity.source ?? 'tak',
|
||||
heading: entity.heading,
|
||||
speed: entity.speed,
|
||||
altitude: entity.altitude,
|
||||
verticalRate: entity.verticalRate,
|
||||
onGround: entity.onGround,
|
||||
originCountry: entity.originCountry,
|
||||
icao: entity.icao,
|
||||
mmsi: entity.mmsi,
|
||||
squawk: entity.squawk,
|
||||
updatedAt: entity.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} entity
|
||||
* @param {{ ttlMs?: number, osintTtlMs?: number }} opts
|
||||
*/
|
||||
function entityTtlMs(entity, opts) {
|
||||
const source = entity.source
|
||||
if (source === 'adsb' || source === 'ais') {
|
||||
return opts.osintTtlMs ?? COT_OSINT_TTL_MS
|
||||
}
|
||||
return opts.ttlMs ?? COT_TTL_MS
|
||||
}
|
||||
|
||||
function isEntityExpired(entity, now, opts) {
|
||||
return now - entity.updatedAt > entityTtlMs(entity, opts)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, source?: string, heading?: number, speed?: number, altitude?: number }} parsed
|
||||
* @param {{ silent?: boolean }} [options]
|
||||
*/
|
||||
export async function updateFromCot(parsed, options = {}) {
|
||||
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
|
||||
|
||||
let snapshot = null
|
||||
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 ?? ''))
|
||||
const explicitSource = parsed.source
|
||||
const source = explicitSource === 'adsb' || explicitSource === 'ais' || explicitSource === 'tak'
|
||||
? explicitSource
|
||||
: inferSourceFromId(parsed.id)
|
||||
|
||||
const stored = {
|
||||
id: parsed.id,
|
||||
lat,
|
||||
lng,
|
||||
label,
|
||||
type,
|
||||
source,
|
||||
heading: pickOptionalNumber(parsed.heading, existing?.heading),
|
||||
speed: pickOptionalNumber(parsed.speed, existing?.speed),
|
||||
altitude: pickOptionalNumber(parsed.altitude, existing?.altitude),
|
||||
verticalRate: pickOptionalNumber(parsed.verticalRate, existing?.verticalRate),
|
||||
onGround: typeof parsed.onGround === 'boolean' ? parsed.onGround : existing?.onGround,
|
||||
originCountry: typeof parsed.originCountry === 'string' ? parsed.originCountry : existing?.originCountry,
|
||||
icao: typeof parsed.icao === 'string' ? parsed.icao : existing?.icao,
|
||||
mmsi: typeof parsed.mmsi === 'string' ? parsed.mmsi : existing?.mmsi,
|
||||
squawk: typeof parsed.squawk === 'string' ? parsed.squawk : existing?.squawk,
|
||||
updatedAt: now,
|
||||
}
|
||||
entities.set(parsed.id, stored)
|
||||
snapshot = toSnapshot(stored)
|
||||
})
|
||||
|
||||
if (snapshot && !options.silent) emitChange('update', { entity: snapshot })
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} now
|
||||
* @param {{ ttlMs?: number, osintTtlMs?: number }} opts
|
||||
*/
|
||||
function pruneExpired(now, opts) {
|
||||
const expired = []
|
||||
for (const entity of entities.values()) {
|
||||
if (isEntityExpired(entity, now, opts)) expired.push(entity.id)
|
||||
}
|
||||
for (const id of expired) {
|
||||
entities.delete(id)
|
||||
emitChange('remove', { id })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ ttlMs?: number, osintTtlMs?: number }} [opts]
|
||||
*/
|
||||
export async function pruneStaleEntities(opts = {}) {
|
||||
const ttlMs = opts.ttlMs ?? COT_TTL_MS
|
||||
const osintTtlMs = opts.osintTtlMs ?? COT_OSINT_TTL_MS
|
||||
await acquire('cot-prune', async () => {
|
||||
pruneExpired(Date.now(), { ttlMs, osintTtlMs })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} entity
|
||||
* @param {{ west: number, south: number, east: number, north: number } | null | undefined} bbox
|
||||
* @param {boolean} takFilterBbox
|
||||
*/
|
||||
function passesBboxFilter(entity, bbox, takFilterBbox) {
|
||||
if (!bbox) return true
|
||||
const inBox = isInBbox(entity, bbox)
|
||||
if (entity.source === 'tak' && !takFilterBbox) return true
|
||||
return inBox
|
||||
}
|
||||
|
||||
/**
|
||||
* Active entities (updated within ttlMs). Prunes expired. Returns new array of new objects.
|
||||
* @param {{ ttlMs?: number, osintTtlMs?: number }} [opts]
|
||||
*/
|
||||
export async function getActiveEntities(opts = {}) {
|
||||
const ttlMs = opts.ttlMs ?? COT_TTL_MS
|
||||
const osintTtlMs = opts.osintTtlMs ?? COT_OSINT_TTL_MS
|
||||
return acquire('cot-prune', async () => {
|
||||
const now = Date.now()
|
||||
pruneExpired(now, { ttlMs, osintTtlMs })
|
||||
return [...entities.values()].map(toSnapshot)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Active entities filtered by viewport bbox and layer set.
|
||||
* @param {{ west: number, south: number, east: number, north: number } | null} bbox
|
||||
* @param {{ ttlMs?: number, osintTtlMs?: number, layers?: Set<string>, takFilterBbox?: boolean, maxEntities?: number }} [opts]
|
||||
*/
|
||||
export async function getActiveEntitiesInBbox(bbox, opts = {}) {
|
||||
const ttlMs = opts.ttlMs ?? COT_TTL_MS
|
||||
const osintTtlMs = opts.osintTtlMs ?? COT_OSINT_TTL_MS
|
||||
const layers = opts.layers
|
||||
const takFilterBbox = Boolean(opts.takFilterBbox)
|
||||
const ttlOpts = { ttlMs, osintTtlMs }
|
||||
|
||||
return acquire('cot-prune', async () => {
|
||||
const now = Date.now()
|
||||
pruneExpired(now, ttlOpts)
|
||||
const active = []
|
||||
for (const entity of entities.values()) {
|
||||
if (isEntityExpired(entity, now, ttlOpts)) continue
|
||||
const snap = toSnapshot(entity)
|
||||
if (!passesBboxFilter(snap, bbox, takFilterBbox)) continue
|
||||
if (!matchesLayerFilter(layers, snap)) continue
|
||||
active.push(snap)
|
||||
}
|
||||
const maxEntities = opts.maxEntities
|
||||
if (maxEntities != null && active.length > maxEntities) {
|
||||
active.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
|
||||
active.length = maxEntities
|
||||
}
|
||||
return active
|
||||
})
|
||||
}
|
||||
|
||||
/** Clear store (tests only). */
|
||||
export function clearCotStore() {
|
||||
entities.clear()
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/** SSE subscriber registry; bbox union drives OSINT feeds. */
|
||||
|
||||
import { getActiveEntitiesInBbox } from './cotStore.js'
|
||||
import { isInBbox, matchesLayerFilter, unionBboxes } from './cotEntityUtils.js'
|
||||
|
||||
/** @typedef {{ west: number, south: number, east: number, north: number }} Bbox */
|
||||
/** @typedef {(event: string, data: string) => Promise<void> | void} PushFn */
|
||||
|
||||
/** @type {Map<string, { bbox: Bbox | null, layers: Set<string>, push: PushFn }>} */
|
||||
const subscribers = new Map()
|
||||
let nextId = 1
|
||||
|
||||
/**
|
||||
* @param {{ bbox: Bbox | null, layers: Set<string>, push: PushFn }} sub
|
||||
* @returns {() => void} Unregister function.
|
||||
*/
|
||||
export function registerSubscriber(sub) {
|
||||
const id = String(nextId++)
|
||||
subscribers.set(id, sub)
|
||||
return () => subscribers.delete(id)
|
||||
}
|
||||
|
||||
/** @returns {Bbox | null} Union of all subscriber bboxes. */
|
||||
export function getSubscriberBboxUnion() {
|
||||
return unionBboxes([...subscribers.values()].map(s => s.bbox))
|
||||
}
|
||||
|
||||
export function getSubscriberCount() {
|
||||
return subscribers.size
|
||||
}
|
||||
|
||||
export function clearSubscribers() {
|
||||
subscribers.clear()
|
||||
}
|
||||
|
||||
export async function notifySubscribersForEntity(event, payload, entity) {
|
||||
const data = JSON.stringify(payload)
|
||||
const tasks = []
|
||||
for (const sub of subscribers.values()) {
|
||||
if (sub.bbox && !isInBbox(entity, sub.bbox)) continue
|
||||
if (!matchesLayerFilter(sub.layers, entity)) continue
|
||||
tasks.push(Promise.resolve(sub.push(event, data)))
|
||||
}
|
||||
await Promise.all(tasks)
|
||||
}
|
||||
|
||||
export async function notifySubscribersRemove(id) {
|
||||
const data = JSON.stringify({ id })
|
||||
await Promise.all(
|
||||
[...subscribers.values()].map(sub => Promise.resolve(sub.push('remove', data))),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a filtered snapshot to each active SSE subscriber.
|
||||
* @param {{ ttlMs?: number, osintTtlMs?: number, takFilterBbox?: boolean, maxEntities?: number }} snapshotOpts
|
||||
*/
|
||||
export async function broadcastSubscriberSnapshots(snapshotOpts) {
|
||||
const tasks = []
|
||||
for (const sub of subscribers.values()) {
|
||||
tasks.push((async () => {
|
||||
const entities = await getActiveEntitiesInBbox(sub.bbox, {
|
||||
...snapshotOpts,
|
||||
layers: sub.layers,
|
||||
})
|
||||
await sub.push('snapshot', JSON.stringify({ entities }))
|
||||
})())
|
||||
}
|
||||
await Promise.all(tasks)
|
||||
}
|
||||
+236
-19
@@ -1,13 +1,14 @@
|
||||
import { join, dirname } from 'node:path'
|
||||
import { mkdirSync, existsSync } from 'node:fs'
|
||||
import { randomBytes, randomUUID } from 'node:crypto'
|
||||
import { createRequire } from 'node:module'
|
||||
import { promisify } from 'node:util'
|
||||
import { bootstrapAdmin } from './bootstrap.js'
|
||||
import { hashPassword } from './password.js'
|
||||
import { registerCleanup } from './shutdown.js'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const sqlite3 = require('sqlite3')
|
||||
const requireFromRoot = createRequire(join(process.cwd(), 'package.json'))
|
||||
const { DatabaseSync } = requireFromRoot('node:sqlite')
|
||||
|
||||
const SCHEMA_VERSION = 3
|
||||
const SCHEMA_VERSION = 6
|
||||
const DB_BUSY_TIMEOUT_MS = 5000
|
||||
|
||||
let dbInstance = null
|
||||
@@ -58,6 +59,20 @@ const SCHEMA = {
|
||||
source_type TEXT NOT NULL DEFAULT 'mjpeg',
|
||||
config TEXT
|
||||
)`,
|
||||
alpr_nodes: `CREATE TABLE IF NOT EXISTS alpr_nodes (
|
||||
osm_id INTEGER PRIMARY KEY,
|
||||
lat REAL NOT NULL,
|
||||
lng REAL NOT NULL,
|
||||
manufacturer TEXT,
|
||||
direction INTEGER,
|
||||
tags TEXT NOT NULL,
|
||||
fetched_at TEXT NOT NULL
|
||||
)`,
|
||||
alpr_nodes_index: 'CREATE INDEX IF NOT EXISTS idx_alpr_lat_lng ON alpr_nodes(lat, lng)',
|
||||
alpr_tiles: `CREATE TABLE IF NOT EXISTS alpr_tiles (
|
||||
tile_key TEXT PRIMARY KEY,
|
||||
fetched_at TEXT NOT NULL
|
||||
)`,
|
||||
}
|
||||
|
||||
const getDbPath = () => {
|
||||
@@ -111,6 +126,25 @@ 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 migrateToV5 = async (run, all) => {
|
||||
const tables = await all('SELECT name FROM sqlite_master WHERE type=\'table\' AND name=\'alpr_nodes\'')
|
||||
if (tables.length > 0) return
|
||||
await run(SCHEMA.alpr_nodes)
|
||||
await run(SCHEMA.alpr_nodes_index)
|
||||
}
|
||||
|
||||
const migrateToV6 = async (run, all) => {
|
||||
const tables = await all('SELECT name FROM sqlite_master WHERE type=\'table\' AND name=\'alpr_tiles\'')
|
||||
if (tables.length > 0) return
|
||||
await run(SCHEMA.alpr_tiles)
|
||||
}
|
||||
|
||||
const runMigrations = async (run, all, get) => {
|
||||
const version = await getSchemaVersion(get)
|
||||
if (version >= SCHEMA_VERSION) return
|
||||
@@ -122,6 +156,18 @@ 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)
|
||||
}
|
||||
if (version < 5) {
|
||||
await migrateToV5(run, all)
|
||||
await setSchemaVersion(run, 5)
|
||||
}
|
||||
if (version < 6) {
|
||||
await migrateToV6(run, all)
|
||||
await setSchemaVersion(run, 6)
|
||||
}
|
||||
}
|
||||
|
||||
const initDb = async (db, run, all, get) => {
|
||||
@@ -131,7 +177,6 @@ const initDb = async (db, run, all, get) => {
|
||||
catch {
|
||||
// WAL not supported (e.g., network filesystem)
|
||||
}
|
||||
db.configure('busyTimeout', DB_BUSY_TIMEOUT_MS)
|
||||
|
||||
await run(SCHEMA.schema_version)
|
||||
await run(SCHEMA.users)
|
||||
@@ -139,43 +184,215 @@ const initDb = async (db, run, all, get) => {
|
||||
await run(SCHEMA.sessions)
|
||||
await run(SCHEMA.pois)
|
||||
await run(SCHEMA.devices)
|
||||
await run(SCHEMA.alpr_nodes)
|
||||
await run(SCHEMA.alpr_nodes_index)
|
||||
await run(SCHEMA.alpr_tiles)
|
||||
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[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() {
|
||||
if (dbInstance) return dbInstance
|
||||
|
||||
const db = new sqlite3.Database(getDbPath(), (err) => {
|
||||
if (err) {
|
||||
const sqliteDb = (() => {
|
||||
try {
|
||||
return new DatabaseSync(getDbPath(), { timeout: DB_BUSY_TIMEOUT_MS })
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[db] Failed to open database:', err.message)
|
||||
throw err
|
||||
}
|
||||
})
|
||||
})()
|
||||
|
||||
const run = promisify(db.run.bind(db))
|
||||
const all = promisify(db.all.bind(db))
|
||||
const get = promisify(db.get.bind(db))
|
||||
const run = (sql, params = []) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const stmt = sqliteDb.prepare(sql)
|
||||
try {
|
||||
stmt.run(...params)
|
||||
resolve()
|
||||
}
|
||||
catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
finally {
|
||||
try {
|
||||
stmt.finalize()
|
||||
}
|
||||
catch {
|
||||
// ignore finalize errors
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const all = (sql, params = []) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const stmt = sqliteDb.prepare(sql)
|
||||
try {
|
||||
const rows = stmt.all(...params)
|
||||
resolve(rows)
|
||||
}
|
||||
catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
finally {
|
||||
try {
|
||||
stmt.finalize()
|
||||
}
|
||||
catch {
|
||||
// ignore finalize errors
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const get = (sql, params = []) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const stmt = sqliteDb.prepare(sql)
|
||||
try {
|
||||
const row = stmt.get(...params)
|
||||
resolve(row)
|
||||
}
|
||||
catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
finally {
|
||||
try {
|
||||
stmt.finalize()
|
||||
}
|
||||
catch {
|
||||
// ignore finalize errors
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const wrappedDb = {
|
||||
close(callback) {
|
||||
try {
|
||||
sqliteDb.close()
|
||||
if (typeof callback === 'function') callback(null)
|
||||
}
|
||||
catch (error) {
|
||||
if (typeof callback === 'function') callback(error)
|
||||
else throw error
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
await initDb(db, run, all, get)
|
||||
await initDb(wrappedDb, run, all, get)
|
||||
}
|
||||
catch (error) {
|
||||
db.close()
|
||||
wrappedDb.close()
|
||||
console.error('[db] Database initialization failed:', error.message)
|
||||
throw error
|
||||
}
|
||||
|
||||
dbInstance = { db, run, all, get }
|
||||
dbInstance = { db: wrappedDb, run, all, get }
|
||||
|
||||
registerCleanup(async () => {
|
||||
if (dbInstance) {
|
||||
try {
|
||||
dbInstance.db.close()
|
||||
}
|
||||
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 {
|
||||
dbInstance.db.close((err) => {
|
||||
if (err) console.error('[db] Error closing database:', err.message)
|
||||
})
|
||||
dbInstance.db.close()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[db] Error closing database:', error.message)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user