Compare commits
53
Commits
v0.4.0
..
41d31185ec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41d31185ec | ||
|
|
b8d4ab7b48 | ||
|
|
9c6960d7c7 | ||
|
|
068f82c0ff | ||
|
|
1f45cc152a | ||
|
|
be95b6864b | ||
|
|
cfac12b699 | ||
|
|
88e520aa59 | ||
|
|
5aa3bd8977 | ||
|
|
6fae069dec | ||
|
|
2d32047ae2 | ||
|
|
4d835a4f82 | ||
|
|
8c3f878636 | ||
|
|
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@v7
|
||||
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@v7
|
||||
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.62.1-noble
|
||||
steps:
|
||||
- uses: https://git.keligrubb.com/actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: https://git.keligrubb.com/actions/setup-node@v7
|
||||
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
|
||||
+978
@@ -1,3 +1,981 @@
|
||||
## [1.1.12] - 2026-08-23
|
||||
### Changed
|
||||
- update all non-major dependencies (#47)
|
||||
|
||||
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.10` → `4.1.11`](https://renovatebot.com/diffs/npm/@vitest%2fcoverage-v8/4.1.10/4.1.11) |  |  |
|
||||
| [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) | [`5.10.1` → `5.11.0`](https://renovatebot.com/diffs/npm/fast-xml-parser/5.10.1/5.11.0) |  |  |
|
||||
| [happy-dom](https://github.com/capricorn86/happy-dom) | [`20.11.2` → `20.11.6`](https://renovatebot.com/diffs/npm/happy-dom/20.11.2/20.11.6) |  |  |
|
||||
| [hls.js](https://github.com/video-dev/hls.js) | [`1.7.0` → `1.7.1`](https://renovatebot.com/diffs/npm/hls.js/1.7.0/1.7.1) |  |  |
|
||||
| [mediasoup](https://mediasoup.org) ([source](https://github.com/versatica/mediasoup)) | [`3.24.2` → `3.26.0`](https://renovatebot.com/diffs/npm/mediasoup/3.24.2/3.26.0) |  |  |
|
||||
| [openid-client](https://github.com/panva/openid-client) | [`6.8.5` → `6.8.7`](https://renovatebot.com/diffs/npm/openid-client/6.8.5/6.8.7) |  |  |
|
||||
| [vitest](https://vitest.dev) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)) | [`4.1.10` → `4.1.11`](https://renovatebot.com/diffs/npm/vitest/4.1.10/4.1.11) |  |  |
|
||||
|
||||
---
|
||||
|
||||
### Release Notes
|
||||
|
||||
<details>
|
||||
<summary>vitest-dev/vitest (@​vitest/coverage-v8)</summary>
|
||||
|
||||
### [`v4.1.11`](https://github.com/vitest-dev/vitest/releases/tag/v4.1.11)
|
||||
|
||||
[Compare Source](https://github.com/vitest-dev/vitest/compare/v4.1.10...v4.1.11)
|
||||
|
||||
##### 🐞 Bug Fixes
|
||||
|
||||
- Revive global concurrency limit for test lifecycle \[backport to v4] - by [@​sheremet-va](https://github.com/sheremet-va) and [@​hi-ogawa](https://github.com/hi-ogawa) in [#​10992](https://github.com/vitest-dev/vitest/issues/10992) [<samp>(5146d)</samp>](https://github.com/vitest-dev/vitest/commit/5146df80b)
|
||||
- **browser**:
|
||||
- Encode iframeId in tester iframe URL \[backport to v4] - by [@​sheremet-va](https://github.com/sheremet-va), **Pduhard** and **Claude Opus 4.8** in [#​10955](https://github.com/vitest-dev/vitest/issues/10955) [<samp>(10b2c)</samp>](https://github.com/vitest-dev/vitest/commit/10b2cd201)
|
||||
- Trigger playwright/chromium gc on lower disk availability \[backport to v4] - by [@​hi-ogawa](https://github.com/hi-ogawa), **Hiroshi Ogawa** and **OpenCode** in [#​10951](https://github.com/vitest-dev/vitest/issues/10951) [<samp>(9851d)</samp>](https://github.com/vitest-dev/vitest/commit/9851dbc41)
|
||||
- **mocker**:
|
||||
- Restrict redirect mocks to the fs allowlist \[backport to v4] - by [@​sheremet-va](https://github.com/sheremet-va) in [#​10974](https://github.com/vitest-dev/vitest/issues/10974) [<samp>(fe5a1)</samp>](https://github.com/vitest-dev/vitest/commit/fe5a11d3c)
|
||||
|
||||
##### [View changes on GitHub](https://github.com/vitest-dev/vitest/compare/v4.1.10...v4.1.11)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>NaturalIntelligence/fast-xml-parser (fast-xml-parser)</summary>
|
||||
|
||||
### [`v5.11.0`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.11.0)
|
||||
|
||||
[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.10.1...v5.11.0)
|
||||
|
||||
#### What's Changed
|
||||
|
||||
- add support for endIndex in node metadata (5.x edition) by [@​Wain-PC](https://github.com/Wain-PC) in [#​850](https://github.com/NaturalIntelligence/fast-xml-parser/pull/850)
|
||||
- fix: don't crash on a closing tag with no matching opening tag by [@​hdimer](https://github.com/hdimer) in [#​861](https://github.com/NaturalIntelligence/fast-xml-parser/pull/861)
|
||||
|
||||
#### New Contributors
|
||||
|
||||
- [@​Wain-PC](https://github.com/Wain-PC) made their first contribution in [#​850](https://github.com/NaturalIntelligence/fast-xml-parser/pull/850)
|
||||
- [@​hdimer](https://github.com/hdimer) made their first contribution in [#​861](https://github.com/NaturalIntelligence/fast-xml-parser/pull/861)
|
||||
|
||||
**Full Changelog**: <https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.10.1...v5.11.0>
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>capricorn86/happy-dom (happy-dom)</summary>
|
||||
|
||||
### [`v20.11.6`](https://github.com/capricorn86/happy-dom/releases/tag/v20.11.6)
|
||||
|
||||
[Compare Source](https://github.com/capricorn86/happy-dom/compare/v20.11.5...v20.11.6)
|
||||
|
||||
##### :construction\_worker\_man: Patch fixes
|
||||
|
||||
- Updates docs for the global-registrator package - By **[@​capricorn86](https://github.com/capricorn86)** in task [#​2300](https://github.com/capricorn86/happy-dom/issues/2300)
|
||||
|
||||
### [`v20.11.5`](https://github.com/capricorn86/happy-dom/releases/tag/v20.11.5)
|
||||
|
||||
[Compare Source](https://github.com/capricorn86/happy-dom/compare/v20.11.4...v20.11.5)
|
||||
|
||||
##### :construction\_worker\_man: Patch fixes
|
||||
|
||||
- Allow explicit element types for querySelector (e.g. `querySelector<HTMLInputElement>(".my-input")`) - By **[@​cyphercodes](https://github.com/cyphercodes)**
|
||||
|
||||
### [`v20.11.4`](https://github.com/capricorn86/happy-dom/releases/tag/v20.11.4)
|
||||
|
||||
[Compare Source](https://github.com/capricorn86/happy-dom/compare/v20.11.3...v20.11.4)
|
||||
|
||||
##### :construction\_worker\_man: Patch fixes
|
||||
|
||||
- Fixes the CORS check `fetch()` to match origins instead of host and protocol - By **[@​rexxars](https://github.com/rexxars)** in task [#​1490](https://github.com/capricorn86/happy-dom/issues/1490)
|
||||
|
||||
### [`v20.11.3`](https://github.com/capricorn86/happy-dom/releases/tag/v20.11.3)
|
||||
|
||||
[Compare Source](https://github.com/capricorn86/happy-dom/compare/v20.11.2...v20.11.3)
|
||||
|
||||
##### :construction\_worker\_man: Patch fixes
|
||||
|
||||
- Make document.links return a live HTMLCollection - By **[@​bangseongbeom](https://github.com/bangseongbeom)** in task [#​2299](https://github.com/capricorn86/happy-dom/issues/2299)
|
||||
- Copy labels array to prevent mutation of cached querySelectorAll result - By **[@​mixelburg](https://github.com/mixelburg)** in task [#​2226](https://github.com/capricorn86/happy-dom/issues/2226)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>video-dev/hls.js (hls.js)</summary>
|
||||
|
||||
### [`v1.7.1`](https://github.com/video-dev/hls.js/releases/tag/v1.7.1)
|
||||
|
||||
[Compare Source](https://github.com/video-dev/hls.js/compare/v1.7.0...v1.7.1)
|
||||
|
||||
### Summary
|
||||
|
||||
HLS.js v1.7.1 includes bug fixes and improvements over the previous release.
|
||||
|
||||
#### Changes Since The Last Release
|
||||
|
||||
- Fix Interstitial snap-out at live edge and `BUFFER_APPEND_NO_PROGRESS` false positives ([#​7979](https://github.com/video-dev/hls.js/issues/7979)) [@​robwalch](https://github.com/robwalch)
|
||||
- Workaround issue where `ManagedMediaSource` does not emit "startstreaming" when seeking ([#​7984](https://github.com/video-dev/hls.js/issues/7984))
|
||||
- Fix permanent stall loading fragment-hint parts of encrypted low-latency streams ([#​7976](https://github.com/video-dev/hls.js/issues/7976)) [@​zaki699-blip](https://github.com/zaki699-blip)
|
||||
- Document decode timebase change in MIGRATING ([#​7986](https://github.com/video-dev/hls.js/issues/7986)) [@​robwalch](https://github.com/robwalch)
|
||||
|
||||
#### Demo Page
|
||||
|
||||
<https://26ea065a.hls-js-dev.pages.dev/demo/>
|
||||
|
||||
#### API and Breaking Changes
|
||||
|
||||
No public exports were removed and no runtime behavior changes are required to upgrade from v1.6 to v1.7. TypeScript consumers might see new compile errors where previously loose types have been narrowed. Each is listed with upgrade guidance in the migration guide:
|
||||
<https://github.com/video-dev/hls.js/blob/v1.7.0/MIGRATING.md#migrating-from-hlsjs-16-to-17>
|
||||
|
||||
Some exported type dependencies ("eventemitter3", "[@​svta/cml-cmcd](https://github.com/svta/cml-cmcd)", "[@​svta/cml-utils](https://github.com/svta/cml-utils)", "[@​svta/cml-structured-field-values](https://github.com/svta/cml-structured-field-values)") have not been bundled with hls.d.ts. Please file an issue if this is blocking you from upgrading.
|
||||
|
||||
#### Feedback
|
||||
|
||||
Please provide feedback via [Issues in GitHub](https://github.com/video-dev/hls.js/issues/new/choose). For more details on how to contribute to HLS.js, see our [CONTRIBUTING guide](https://github.com/video-dev/hls.js/blob/master/CONTRIBUTING.md).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>versatica/mediasoup (mediasoup)</summary>
|
||||
|
||||
### [`v3.26.0`](https://github.com/versatica/mediasoup/blob/HEAD/CHANGELOG.md#3260)
|
||||
|
||||
[Compare Source](https://github.com/versatica/mediasoup/compare/3.25.0...3.26.0)
|
||||
|
||||
- **Breaking change:** Simulcast and SVC: Limit temporal layer to the preferred one ([PR #​1892](https://github.com/versatica/mediasoup/pull/1892)).
|
||||
|
||||
### [`v3.25.0`](https://github.com/versatica/mediasoup/blob/HEAD/CHANGELOG.md#3250)
|
||||
|
||||
[Compare Source](https://github.com/versatica/mediasoup/compare/3.24.2...3.25.0)
|
||||
|
||||
- Worker: Fix undefined behavior in `RtpStreamRecv::UpdateScore()` when no packets were received ([PR #​1886](https://github.com/versatica/mediasoup/pull/1886)).
|
||||
- SCTP: Fix `SackChunk::GetValidatedGapAckBlocks()` returning a bogus gap-ack-block ([PR #​1891](https://github.com/versatica/mediasoup/pull/1891)).
|
||||
- Do not make generated RTCP Sender Reports depend on RTP packet arrival time ([issue #​1881](https://github.com/versatica/mediasoup/issues/1881)):
|
||||
- `RemoteClockOffsetEstimator` class ([PR #​1882](https://github.com/versatica/mediasoup/pull/1882)).
|
||||
- Prepare `RtpStream` classes for capture time based RTCP Sender Reports ([PR #​1883](https://github.com/versatica/mediasoup/pull/1883), [PR #​1888](https://github.com/versatica/mediasoup/pull/1888)).
|
||||
- `RemoteCaptureTimeEstimator` class ([PR #​1884](https://github.com/versatica/mediasoup/pull/1884)).
|
||||
- Estimate the capture instant of each received RTP packet ([PR #​1885](https://github.com/versatica/mediasoup/pull/1885)).
|
||||
- Generate RTCP Sender Reports based on the capture instant of the media rather than on the packet arrival time ([PR #​1887](https://github.com/versatica/mediasoup/pull/1887)).
|
||||
- `SimulcastProducerStreamManager`: Apply new capture time logic and fix 'abs-capture-time' rewriting ([PR #​1889](https://github.com/versatica/mediasoup/pull/1889)).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>panva/openid-client (openid-client)</summary>
|
||||
|
||||
### [`v6.8.7`](https://github.com/panva/openid-client/blob/HEAD/CHANGELOG.md#687-2026-08-20)
|
||||
|
||||
[Compare Source](https://github.com/panva/openid-client/compare/v6.8.6...v6.8.7)
|
||||
|
||||
##### Fixes
|
||||
|
||||
- allow destructuring the claims helper ([38bd8c0](https://github.com/panva/openid-client/commit/38bd8c052a7e1e6d0e2beda14d35fb38b9b26d4c)), references [#​887](https://github.com/panva/openid-client/issues/887)
|
||||
|
||||
### [`v6.8.6`](https://github.com/panva/openid-client/blob/HEAD/CHANGELOG.md#686-2026-08-18)
|
||||
|
||||
[Compare Source](https://github.com/panva/openid-client/compare/v6.8.5...v6.8.6)
|
||||
|
||||
##### Fixes
|
||||
|
||||
- avoid undefined user-agent in fetchProtectedResource ([492c3c3](https://github.com/panva/openid-client/commit/492c3c36aad1ac324661b808b32bc17a35d22665)), references [#​885](https://github.com/panva/openid-client/issues/885)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
|
||||
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4zMy4yIiwidXBkYXRlZEluVmVyIjoiNDQuMzkuMiIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->
|
||||
|
||||
## [1.1.11] - 2026-08-19
|
||||
### Changed
|
||||
- remove unused broadcastToSession export (#45)
|
||||
|
||||
## Removed
|
||||
|
||||
- `broadcastToSession` from `server/plugins/websocket.js` — an exported but never-imported, never-called dead-code function. Its dependencies (`getSessionConnections`, `addSessionConnection`, `removeSessionConnection`) are retained since they remain actively used for per-session connection tracking within the plugin.
|
||||
|
||||
## Notes
|
||||
|
||||
- Full repository search confirms zero remaining references to `broadcastToSession`.
|
||||
- ESLint passes; full test suite passes (50 files, 406 tests).
|
||||
|
||||
Closes #40
|
||||
|
||||
## [1.1.10] - 2026-08-14
|
||||
### Changed
|
||||
- update dependencies and fix security vulnerabilities (#44)
|
||||
|
||||
## Summary
|
||||
|
||||
Updates all out-of-date dependencies and applies `npm audit fix` to resolve 14 security vulnerabilities (3 critical, 8 high).
|
||||
|
||||
### Dependency Updates
|
||||
|
||||
| Package | Current → Latest |
|
||||
|---|---|
|
||||
| `nuxt` | 4.4.8 → 4.5.2 |
|
||||
| `mediasoup` | 3.20.9 → 3.24.2 |
|
||||
| `mediasoup-client` | 3.21.0 → 3.22.0 |
|
||||
| `hls.js` | 1.6.16 → 1.7.0 |
|
||||
| `vue` | 3.5.38 → 3.5.41 |
|
||||
| `vue-router` | 5.1.0 → 5.2.0 |
|
||||
| `@nuxt/icon` | 2.2.3 → 2.5.0 |
|
||||
| `eslint` | 10.5.0 → 10.8.1 |
|
||||
| `vitest` / `@vitest/coverage-v8` | 4.1.9 → 4.1.10 |
|
||||
| `ws` | 8.21.0 → 8.21.3 |
|
||||
| `openid-client` | 6.8.4 → 6.8.5 |
|
||||
| `fast-xml-parser` | 5.9.3 → 5.10.1 |
|
||||
| `happy-dom` | 20.10.6 → 20.11.2 |
|
||||
| `@playwright/test` | 1.61.1 → 1.62.1 |
|
||||
| `@nuxt/eslint` | 1.16.0 → 1.17.0 |
|
||||
| `@nuxt/test-utils` | 4.0.3 → 4.1.0 |
|
||||
| `@iconify-json/tabler` | 1.2.35 → 1.2.38 |
|
||||
|
||||
### Security Audit Fixes (`npm audit fix`)
|
||||
|
||||
- **`@nuxt/devtools`** (critical): Unauthenticated DevTools RPC allows arbitrary command execution on the developer's host
|
||||
- **`tar`** (critical): Multiple process-crash/DoS vulnerabilities via crafted tar archives
|
||||
- **`esbuild`** (high): Arbitrary file read when running dev server on Windows
|
||||
- **`brace-expansion`** (high): Multiple DoS vectors (memory exhaustion, process hang)
|
||||
- **`flatted`** (high): Prototype pollution via `parse()`
|
||||
- **`svgo`** (high): `removeScripts` plugin leaves executable scripts intact
|
||||
|
||||
### Verification
|
||||
|
||||
- All 406 tests pass (3 skipped)
|
||||
- Lint clean (`eslint . --max-warnings 0`)
|
||||
- `npm audit` reports 0 vulnerabilities
|
||||
|
||||
## [1.1.9] - 2026-08-13
|
||||
### Changed
|
||||
- update dependency fast-xml-parser to v5.10.1 [security] (#42)
|
||||
|
||||
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.9.3` → `5.10.1`](https://renovatebot.com/diffs/npm/fast-xml-parser/5.9.3/5.10.1) |  |  |
|
||||
|
||||
---
|
||||
|
||||
### fast-xml-parser: Repeated DOCTYPE declarations reset entity expansion limits
|
||||
[GHSA-8r6m-32jq-jx6q](https://github.com/advisories/GHSA-8r6m-32jq-jx6q)
|
||||
|
||||
<details>
|
||||
<summary>More information</summary>
|
||||
|
||||
#### Details
|
||||
##### Impact
|
||||
`fast-xml-parser` processes multiple "DOCTYPE" declarations within a single XML document. Each declaration passes its entities to `@nodable/entities` through `addInputEntities()`.
|
||||
|
||||
`addInputEntities()` resets the entity expansion counters every time it is called. An attacker can therefore insert additional DOCTYPE declarations to repeatedly reset maxTotalExpansions and maxExpandedLength during one parse operation.
|
||||
|
||||
This allows a crafted XML document to exceed the configured entity-expansion limits and can cause excessive CPU use, event-loop blocking, memory exhaustion, and process termination.
|
||||
|
||||
##### Workarounds
|
||||
- Manually check if multiple DOCTYPEs are not present in input contents
|
||||
- Update to v5.10.1
|
||||
- Keep `processEntity` flag off
|
||||
|
||||
#### Severity
|
||||
- CVSS Score: 8.7 / 10 (High)
|
||||
- Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N`
|
||||
|
||||
#### References
|
||||
- [https://github.com/NaturalIntelligence/fast-xml-parser/security/advisories/GHSA-8r6m-32jq-jx6q](https://github.com/NaturalIntelligence/fast-xml-parser/security/advisories/GHSA-8r6m-32jq-jx6q)
|
||||
- [https://github.com/NaturalIntelligence/fast-xml-parser/commit/4e546e03987662de5495d050b5fba26bea65383f](https://github.com/NaturalIntelligence/fast-xml-parser/commit/4e546e03987662de5495d050b5fba26bea65383f)
|
||||
- [https://github.com/NaturalIntelligence/fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser)
|
||||
- [https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.10.1](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.10.1)
|
||||
|
||||
This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-8r6m-32jq-jx6q) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)).
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Release Notes
|
||||
|
||||
<details>
|
||||
<summary>NaturalIntelligence/fast-xml-parser (fast-xml-parser)</summary>
|
||||
|
||||
### [`v5.10.1`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.10.1)
|
||||
|
||||
[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.10.0...v5.10.1)
|
||||
|
||||
**Full Changelog**: <https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.10.0...v5.10.1>
|
||||
|
||||
### [`v5.10.0`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.10.0)
|
||||
|
||||
[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.9.3...v5.10.0)
|
||||
|
||||
#### What's Changed
|
||||
|
||||
- Bump actions/checkout from 6.0.3 to 7.0.0 by [@​dependabot](https://github.com/dependabot)\[bot] in [#​849](https://github.com/NaturalIntelligence/fast-xml-parser/pull/849)
|
||||
- Bump zizmorcore/zizmor-action from 0.5.6 to 0.5.7 by [@​dependabot](https://github.com/dependabot)\[bot] in [#​848](https://github.com/NaturalIntelligence/fast-xml-parser/pull/848)
|
||||
|
||||
**Full Changelog**: <https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.9.3...v5.10.0>
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
|
||||
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4yOC4wIiwidXBkYXRlZEluVmVyIjoiNDQuMjguMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIiwic2VjdXJpdHkiXX0=-->
|
||||
|
||||
## [1.1.8] - 2026-08-13
|
||||
### Changed
|
||||
- update https://git.keligrubb.com/actions/setup-node action to v7 (#38)
|
||||
|
||||
This PR contains the following updates:
|
||||
|
||||
| Package | Type | Update | Change |
|
||||
|---|---|---|---|
|
||||
| [https://git.keligrubb.com/actions/setup-node](https://git.keligrubb.com/actions/setup-node) | action | major | `v6` → `v7` |
|
||||
|
||||
---
|
||||
|
||||
### Release Notes
|
||||
|
||||
<details>
|
||||
<summary>actions/setup-node (https://git.keligrubb.com/actions/setup-node)</summary>
|
||||
|
||||
### [`v7.0.0`](https://git.keligrubb.com/actions/setup-node/compare/v7...v7)
|
||||
|
||||
[Compare Source](https://git.keligrubb.com/actions/setup-node/compare/v7...v7)
|
||||
|
||||
### [`v7`](https://git.keligrubb.com/actions/setup-node/compare/v6.5.0...v7)
|
||||
|
||||
[Compare Source](https://git.keligrubb.com/actions/setup-node/compare/v6.5.0...v7)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
|
||||
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yNjUuNCIsInVwZGF0ZWRJblZlciI6IjQzLjI2NS40IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
|
||||
|
||||
## [1.1.7] - 2026-08-13
|
||||
### Changed
|
||||
- update dependency supercluster to v9 (#41)
|
||||
|
||||
This PR contains the following updates:
|
||||
|
||||
| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|
||||
|---|---|---|---|
|
||||
| [supercluster](https://github.com/mapbox/supercluster) | [`^8.0.1` → `^9.0.0`](https://renovatebot.com/diffs/npm/supercluster/8.0.1/9.0.0) |  |  |
|
||||
|
||||
---
|
||||
|
||||
### Release Notes
|
||||
|
||||
<details>
|
||||
<summary>mapbox/supercluster (supercluster)</summary>
|
||||
|
||||
### [`v9.0.0`](https://github.com/mapbox/supercluster/releases/tag/v9.0.0)
|
||||
|
||||
[Compare Source](https://github.com/mapbox/supercluster/compare/v8.0.1...v9.0.0)
|
||||
|
||||
- Radically optimize **memory footprint and performance** — on a sample 1M points index, Supercluster now uses 94% less transient allocation, 57% lower peak heap, 65% less retained memory, and runs 23% faster. [#​258](https://github.com/mapbox/supercluster/issues/258)
|
||||
- Improve internal coordinate precision by 6 bits (64 times).
|
||||
- ⚠️ Breaking: hard-cap `maxZoom` at 30 (it wasn't practical to use it on higher zooms anyway).
|
||||
- Add support for `MultiPoint` features. [#​263](https://github.com/mapbox/supercluster/issues/263)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
|
||||
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4yNy4wIiwidXBkYXRlZEluVmVyIjoiNDQuMjcuMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->
|
||||
|
||||
## [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.12
|
||||
appVersion: "1.1.12"
|
||||
|
||||
@@ -2,7 +2,7 @@ replicaCount: 1
|
||||
|
||||
image:
|
||||
repository: git.keligrubb.com/keligrubb/kestrelos
|
||||
tag: 0.4.0
|
||||
tag: 1.1.12
|
||||
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
+5207
-6301
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.12",
|
||||
"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.5.0",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"hls.js": "^1.5.0",
|
||||
"fast-xml-parser": "^5.10.1",
|
||||
"hls.js": "^1.7.0",
|
||||
"jszip": "^3.10.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.offline": "^3.2.0",
|
||||
"mediasoup": "^3.19.14",
|
||||
"mediasoup-client": "^3.18.6",
|
||||
"nuxt": "^4.0.0",
|
||||
"openid-client": "^6.8.2",
|
||||
"sqlite3": "^5.1.7",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.4.0",
|
||||
"ws": "^8.18.0"
|
||||
"leaflet.offline": "^3.2.1",
|
||||
"mediasoup": "^3.24.2",
|
||||
"mediasoup-client": "^3.22.0",
|
||||
"nuxt": "^4.5.2",
|
||||
"openid-client": "^6.8.5",
|
||||
"qrcode": "^1.5.4",
|
||||
"supercluster": "^9.0.0",
|
||||
"vue": "^3.5.41",
|
||||
"vue-router": "^5.2.0",
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"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.38",
|
||||
"@nuxt/eslint": "^1.17.0",
|
||||
"@nuxt/test-utils": "^4.1.0",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"@vue/test-utils": "^2.4.11",
|
||||
"eslint": "^10.8.1",
|
||||
"happy-dom": "^20.11.2",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
+32
-12
@@ -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 = {}
|
||||
@@ -42,16 +43,6 @@ export function removeSessionConnection(sessionId, ws) {
|
||||
}
|
||||
}
|
||||
|
||||
export function broadcastToSession(sessionId, message) {
|
||||
const conns = getSessionConnections(sessionId)
|
||||
const data = JSON.stringify(message)
|
||||
for (const ws of conns) {
|
||||
if (ws.readyState === 1) { // OPEN
|
||||
ws.send(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
nitroApp.hooks.hook('ready', async () => {
|
||||
const server = nitroApp.h3App.server || nitroApp.h3App.nodeServer
|
||||
@@ -79,8 +70,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 +90,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 +108,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 +155,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