22 Commits
v0.2.0 ... main

Author SHA1 Message Date
CI
45ce90f3cc release v1.0.7 [skip ci] 2026-03-06 02:30:45 +00:00
3a817625c5 chore(deps): update docker/build-push-action action to v7 (#19)
All checks were successful
Push / release-docker-helm (push) Successful in 3m33s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [docker/build-push-action](https://github.com/docker/build-push-action) | action | major | `v6` → `v7` |

---

### Release Notes

<details>
<summary>docker/build-push-action (docker/build-push-action)</summary>

### [`v7`](https://github.com/docker/build-push-action/compare/v6...v7)

[Compare Source](https://github.com/docker/build-push-action/compare/v6...v7)

</details>

---

### Configuration

📅 **Schedule**: 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:eyJjcmVhdGVkSW5WZXIiOiI0My41Ni4xIiwidXBkYXRlZEluVmVyIjoiNDMuNTYuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Reviewed-on: #19
Reviewed-by: Keli Grubb <keligrubb324@gmail.com>
Co-authored-by: Renovate Bot <renovate@keligrubb.com>
Co-committed-by: Renovate Bot <renovate@keligrubb.com>
2026-03-06 02:28:31 +00:00
CI
d5abea48b3 release v1.0.6 [skip ci] 2026-03-05 15:37:20 +00:00
1da69ac272 patch: fix docker login during push stage (#18)
All checks were successful
Push / release-docker-helm (push) Successful in 4m24s
Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #18
2026-03-05 15:34:32 +00:00
CI
bd2092a3ea release v1.0.5 [skip ci] 2026-03-05 14:55:05 +00:00
afb6fb8ac7 patch: fix deploy pipeline stages for token registry uploads (#17)
Some checks failed
Push / release-docker-helm (push) Failing after 2m56s
Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #17
2026-03-05 14:52:10 +00:00
CI
414e9f4c33 release v1.0.4 [skip ci] 2026-03-04 20:04:05 +00:00
10035221fb patch: fix deploy pipeline (#15)
Some checks failed
Push / release-docker-helm (push) Failing after 2m15s
Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #15
2026-03-04 20:01:50 +00:00
52a6f4368c patch: migrate to gitea actions (#14)
Some checks failed
Push / release-docker-helm (push) Failing after 2s
Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #14
2026-03-04 19:49:16 +00:00
CI
e774dbc301 release v1.0.3 [skip ci] 2026-02-23 03:45:37 +00:00
5bf582f3ad fix(deps): update dependency vue-router to v5 (#12)
All checks were successful
ci/woodpecker/push/push Pipeline was successful
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [vue-router](https://router.vuejs.org) ([source](https://github.com/vuejs/router)) | [`^4.4.0` → `^5.0.0`](https://renovatebot.com/diffs/npm/vue-router/4.6.4/5.0.3) | ![age](https://developer.mend.io/api/mc/badges/age/npm/vue-router/5.0.3?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vue-router/4.6.4/5.0.3?slim=true) |

---

### Release Notes

<details>
<summary>vuejs/router (vue-router)</summary>

### [`v5.0.3`](https://github.com/vuejs/router/releases/tag/v5.0.3)

[Compare Source](https://github.com/vuejs/router/compare/v5.0.2...v5.0.3)

#####    🚨 Breaking Changes

- **experimental**:
  - Make miss() throw internally and return never  -  by [@&#8203;posva](https://github.com/posva) [<samp>(077e1)</samp>](https://github.com/vuejs/router/commit/077e1740)
  - Add reroute() and deprecate NavigationResult  -  by [@&#8203;posva](https://github.com/posva) [<samp>(308db)</samp>](https://github.com/vuejs/router/commit/308db14a)
  - Remove selectNavigationResult  -  by [@&#8203;posva](https://github.com/posva) [<samp>(9e88a)</samp>](https://github.com/vuejs/router/commit/9e88aed4)

#####    🚀 Features

- Support \_parent in nested folders  -  by [@&#8203;posva](https://github.com/posva) [<samp>(0a37f)</samp>](https://github.com/vuejs/router/commit/0a37f474)
- Warn on \_parent conflict  -  by [@&#8203;posva](https://github.com/posva) [<samp>(182fe)</samp>](https://github.com/vuejs/router/commit/182fe03a)
- Set \_parent as non matchable by default  -  by [@&#8203;posva](https://github.com/posva) [<samp>(8f91c)</samp>](https://github.com/vuejs/router/commit/8f91c99f)
- Warn on conflicting components for routes  -  by [@&#8203;posva](https://github.com/posva) [<samp>(34ace)</samp>](https://github.com/vuejs/router/commit/34aceb98)
- Use type module  -  by [@&#8203;posva](https://github.com/posva) [<samp>(dc9ff)</samp>](https://github.com/vuejs/router/commit/dc9ffe81)
- Add deprecation warning for next() callback in navigation guards  -  by [@&#8203;posva](https://github.com/posva) [<samp>(797f5)</samp>](https://github.com/vuejs/router/commit/797f55de)
- Extract alias from definePage  -  by [@&#8203;posva](https://github.com/posva) [<samp>(835df)</samp>](https://github.com/vuejs/router/commit/835df1ff)
- Display aliases in logs  -  by [@&#8203;posva](https://github.com/posva) [<samp>(7aa60)</samp>](https://github.com/vuejs/router/commit/7aa607fc)
- Deprecate new NavigationResult(to) in favor of reroute(to)  -  by [@&#8203;posva](https://github.com/posva) [<samp>(382e3)</samp>](https://github.com/vuejs/router/commit/382e34b4)
- **experimental**:
  - Handle aliasOf in resolvers  -  by [@&#8203;posva](https://github.com/posva) [<samp>(8fe45)</samp>](https://github.com/vuejs/router/commit/8fe453c9)
  - Generate aliases from override in resolver  -  by [@&#8203;posva](https://github.com/posva) [<samp>(a00ac)</samp>](https://github.com/vuejs/router/commit/a00ac355)
  - Warn against non absolute aliases  -  by [@&#8203;posva](https://github.com/posva) [<samp>(476c6)</samp>](https://github.com/vuejs/router/commit/476c6697)

#####    🐞 Bug Fixes

- Avoid non matchable routes in auto-routes ��-  by [@&#8203;posva](https://github.com/posva) [<samp>(48649)</samp>](https://github.com/vuejs/router/commit/48649030)
- Handle quotes in d.ts  -  by [@&#8203;posva](https://github.com/posva) [<samp>(d7764)</samp>](https://github.com/vuejs/router/commit/d7764700)
- Avoid route entry in map for \_parent  -  by [@&#8203;posva](https://github.com/posva) [<samp>(1dfcc)</samp>](https://github.com/vuejs/router/commit/1dfccf82)
- Handle nested groups  -  by [@&#8203;posva](https://github.com/posva) [<samp>(4a4be)</samp>](https://github.com/vuejs/router/commit/4a4bed94)
- Stable route ordering for group folders with same path  -  by [@&#8203;posva](https://github.com/posva) [<samp>(1db94)</samp>](https://github.com/vuejs/router/commit/1db9467c)
- Correct route ordering for group nodes with inflated scores  -  by [@&#8203;posva](https://github.com/posva) [<samp>(515f4)</samp>](https://github.com/vuejs/router/commit/515f4843)
- Cleanup old route overrides  -  by [@&#8203;posva](https://github.com/posva) [<samp>(b28a7)</samp>](https://github.com/vuejs/router/commit/b28a71e2)
- Remove name from \_parent.vue files  -  by [@&#8203;posva](https://github.com/posva) [<samp>(6e8f1)</samp>](https://github.com/vuejs/router/commit/6e8f1a11)
- **ci**:
  - Format sponsor files before change detection  -  by [@&#8203;posva](https://github.com/posva) [<samp>(f68d6)</samp>](https://github.com/vuejs/router/commit/f68d6fad)
  - Use manual git commit in update-sponsors  -  by [@&#8203;posva](https://github.com/posva) [<samp>(8ee99)</samp>](https://github.com/vuejs/router/commit/8ee992cb)
- **experimental**:
  - Resolve TS errors in resolver/router type hierarchy  -  by [@&#8203;posva](https://github.com/posva) [<samp>(a86f1)</samp>](https://github.com/vuejs/router/commit/a86f1f3a)
- **types**:
  - Relax RouteMapGeneric constraint for interface-based RouteNamedMap  -  by [@&#8203;YevheniiKotyrlo](https://github.com/YevheniiKotyrlo) in [#&#8203;2624](https://github.com/vuejs/router/issues/2624) [<samp>(cdf7b)</samp>](https://github.com/vuejs/router/commit/cdf7b442)
- **volar**:
  - Use `ts.getTokenPosOfNode` instead of `node.getStart`  -  by [@&#8203;KazariEX](https://github.com/KazariEX) in [#&#8203;2630](https://github.com/vuejs/router/issues/2630) [<samp>(0b050)</samp>](https://github.com/vuejs/router/commit/0b0504bd)

#####    🏎 Performance

- Avoid merging empty object in record  -  by [@&#8203;posva](https://github.com/posva) [<samp>(4213e)</samp>](https://github.com/vuejs/router/commit/4213eb66)

#####     [View changes on GitHub](https://github.com/vuejs/router/compare/v5.0.2...v5.0.3)

### [`v5.0.2`](https://github.com/vuejs/router/releases/tag/v5.0.2)

[Compare Source](https://github.com/vuejs/router/compare/v5.0.1...v5.0.2)

#####    🐞 Bug Fixes

- Remove devtools from iife build  -  by [@&#8203;posva](https://github.com/posva) [<samp>(58c03)</samp>](https://github.com/vuejs/router/commit/58c033c0)
- Loose version check vue-router  -  by [@&#8203;posva](https://github.com/posva) [<samp>(90e4b)</samp>](https://github.com/vuejs/router/commit/90e4bb80)
- **volar**: Empty options  -  by [@&#8203;posva](https://github.com/posva) [<samp>(02275)</samp>](https://github.com/vuejs/router/commit/022758a7)

#####     [View changes on GitHub](https://github.com/vuejs/router/compare/v5.0.1...v5.0.2)

### [`v5.0.1`](https://github.com/vuejs/router/releases/tag/v5.0.1)

[Compare Source](https://github.com/vuejs/router/compare/v5.0.0...v5.0.1)

#####    🐞 Bug Fixes

- **volar**: Make typed plugin work with vue-tsc  -  by [@&#8203;peter50216](https://github.com/peter50216) in [#&#8203;2607](https://github.com/vuejs/router/issues/2607) [<samp>(7845e)</samp>](https://github.com/vuejs/router/commit/7845e327)

#####     [View changes on GitHub](https://github.com/vuejs/router/compare/v5.0.0...v5.0.1)

### [`v5.0.0`](https://github.com/vuejs/router/releases/tag/v5.0.0)

[Compare Source](https://github.com/vuejs/router/compare/v4.6.4...v5.0.0)

Vue Router 5 is a *boring* release, it merges [unplugin-vue-router](https://uvr.esm.is) into the core package with no breaking changes. The only exception is that the *iife* build no longer includes `@vue/devtools-api` because it has been upgraded to v8 and does not expose an IIFE build itself. You can track that change in [this issue](https://github.com/vuejs/devtools/issues/989). See [the migration guide](https://router.vuejs.org/guide/migration/v4-to-v5.html) for instructions on how to upgrade from unplugin-vue-router to Vue Router 5.

#####    🚀 Features

- **experimental**: Query params are optional by default  -  by [@&#8203;posva](https://github.com/posva) [<samp>(78913)</samp>](https://github.com/vuejs/router/commit/78913551)
- Add volar plugins  -  by [@&#8203;posva](https://github.com/posva) [<samp>(530ac)</samp>](https://github.com/vuejs/router/commit/530ac53e)
- Add data loaders as experimental  -  by [@&#8203;posva](https://github.com/posva) [<samp>(ab895)</samp>](https://github.com/vuejs/router/commit/ab89513d)
- Add route json schema  -  by [@&#8203;posva](https://github.com/posva) [<samp>(20675)</samp>](https://github.com/vuejs/router/commit/2067515a)
- Upgrade devtools-api to v7  -  by [@&#8203;posva](https://github.com/posva) [<samp>(17b84)</samp>](https://github.com/vuejs/router/commit/17b841b8)
- Upgrade devtools to v8  -  by [@&#8203;posva](https://github.com/posva) [<samp>(b8aa2)</samp>](https://github.com/vuejs/router/commit/b8aa2395)
- Runtime error on missing param parsers  -  by [@&#8203;posva](https://github.com/posva) [<samp>(3444b)</samp>](https://github.com/vuejs/router/commit/3444bc94)
- **volar**: Allow rootDir option  -  by [@&#8203;posva](https://github.com/posva) [<samp>(df65a)</samp>](https://github.com/vuejs/router/commit/df65a864)

#####    🐞 Bug Fixes

- Avoid breaking older browsers support  -  by [@&#8203;posva](https://github.com/posva) [<samp>(c7ba4)</samp>](https://github.com/vuejs/router/commit/c7ba4507)
- Trigger navigation guards when keep-alive component is reactivated for different route  -  by [@&#8203;babu-ch](https://github.com/babu-ch) and [@&#8203;posva](https://github.com/posva) in [#&#8203;2604](https://github.com/vuejs/router/issues/2604) [<samp>(c7735)</samp>](https://github.com/vuejs/router/commit/c7735d30)
- Add automatic types for param parsers  -  by [@&#8203;posva](https://github.com/posva) [<samp>(0fb5d)</samp>](https://github.com/vuejs/router/commit/0fb5da34)
- Param parsers when dts is not at root  -  by [@&#8203;posva](https://github.com/posva) [<samp>(16b39)</samp>](https://github.com/vuejs/router/commit/16b39ff7)
- Expose resolveOptions for unplugin  -  by [@&#8203;posva](https://github.com/posva) [<samp>(35543)</samp>](https://github.com/vuejs/router/commit/355431b6)
- Escape tildes in paths  -  by [@&#8203;posva](https://github.com/posva) [<samp>(aac2e)</samp>](https://github.com/vuejs/router/commit/aac2e265)
- **volar**: Upgrade config read  -  by [@&#8203;posva](https://github.com/posva) [<samp>(e3024)</samp>](https://github.com/vuejs/router/commit/e3024d19)

#####     [View changes on GitHub](https://github.com/vuejs/router/compare/v4.6.4...v5.0.0)

</details>

---

### Configuration

📅 **Schedule**: 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:eyJjcmVhdGVkSW5WZXIiOiI0My4zMS4xIiwidXBkYXRlZEluVmVyIjoiNDMuMzEuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Reviewed-on: https://git.keligrubb.com/keligrubb/kestrelos/pulls/12
Co-authored-by: Renovate Bot <renovate@keligrubb.com>
Co-committed-by: Renovate Bot <renovate@keligrubb.com>
2026-02-23 03:44:55 +00:00
CI
7398bb0a16 release v1.0.2 [skip ci] 2026-02-22 20:44:40 +00:00
79d36df83c chore(deps): update dependency eslint to v10 (#10)
All checks were successful
ci/woodpecker/push/push Pipeline was successful
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [eslint](https://eslint.org) ([source](https://github.com/eslint/eslint)) | [`^9.0.0` → `^10.0.0`](https://renovatebot.com/diffs/npm/eslint/9.39.2/10.0.1) | ![age](https://developer.mend.io/api/mc/badges/age/npm/eslint/10.0.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eslint/9.39.2/10.0.1?slim=true) |

---

### Release Notes

<details>
<summary>eslint/eslint (eslint)</summary>

### [`v10.0.1`](https://github.com/eslint/eslint/releases/tag/v10.0.1)

[Compare Source](https://github.com/eslint/eslint/compare/v10.0.0...v10.0.1)

#### Bug Fixes

- [`c87d5bd`](c87d5bded5) fix: update eslint ([#&#8203;20531](https://github.com/eslint/eslint/issues/20531)) (renovate\[bot])
- [`d841001`](d84100115c) fix: update `minimatch` to `10.2.1` to address security vulnerabilities ([#&#8203;20519](https://github.com/eslint/eslint/issues/20519)) (루밀LuMir)
- [`04c2147`](04c21475b3) fix: update error message for unused suppressions ([#&#8203;20496](https://github.com/eslint/eslint/issues/20496)) (fnx)
- [`38b089c`](38b089c172) fix: update dependency [@&#8203;eslint/config-array](https://github.com/eslint/config-array) to ^0.23.1 ([#&#8203;20484](https://github.com/eslint/eslint/issues/20484)) (renovate\[bot])

#### Documentation

- [`5b3dbce`](5b3dbce50a) docs: add AI acknowledgement section to templates ([#&#8203;20431](https://github.com/eslint/eslint/issues/20431)) (루밀LuMir)
- [`6f23076`](6f23076037) docs: toggle nav in no-JS mode ([#&#8203;20476](https://github.com/eslint/eslint/issues/20476)) (Tanuj Kanti)
- [`b69cfb3`](b69cfb32a1) docs: Update README (GitHub Actions Bot)

#### Chores

- [`e5c281f`](e5c281ffd0) chore: updates for v9.39.3 release (Jenkins)
- [`8c3832a`](8c3832adb7) chore: update [@&#8203;typescript-eslint/parser](https://github.com/typescript-eslint/parser) to ^8.56.0 ([#&#8203;20514](https://github.com/eslint/eslint/issues/20514)) (Milos Djermanovic)
- [`8330d23`](8330d238ae) test: add tests for config-api ([#&#8203;20493](https://github.com/eslint/eslint/issues/20493)) (Milos Djermanovic)
- [`37d6e91`](37d6e91e88) chore: remove eslint v10 prereleases from eslint-config-eslint deps ([#&#8203;20494](https://github.com/eslint/eslint/issues/20494)) (Milos Djermanovic)
- [`da7cd0e`](da7cd0e791) refactor: cleanup error message templates ([#&#8203;20479](https://github.com/eslint/eslint/issues/20479)) (Francesco Trotta)
- [`84fb885`](84fb885d49) chore: package.json update for [@&#8203;eslint/js](https://github.com/eslint/js) release (Jenkins)
- [`1f66734`](1f667344b5) chore: add `eslint` to `peerDependencies` of `@eslint/js` ([#&#8203;20467](https://github.com/eslint/eslint/issues/20467)) (Milos Djermanovic)

### [`v10.0.0`](https://github.com/eslint/eslint/releases/tag/v10.0.0)

[Compare Source](https://github.com/eslint/eslint/compare/v9.39.3...v10.0.0)

#### Breaking Changes

- [`f9e54f4`](f9e54f43a5) feat!: estimate rule-tester failure location ([#&#8203;20420](https://github.com/eslint/eslint/issues/20420)) (ST-DDT)
- [`a176319`](a176319d8a) feat!: replace `chalk` with `styleText` and add `color` to `ResultsMeta` ([#&#8203;20227](https://github.com/eslint/eslint/issues/20227)) (루밀LuMir)
- [`c7046e6`](c7046e6c1e) feat!: enable JSX reference tracking ([#&#8203;20152](https://github.com/eslint/eslint/issues/20152)) (Pixel998)
- [`fa31a60`](fa31a60890) feat!: add `name` to configs ([#&#8203;20015](https://github.com/eslint/eslint/issues/20015)) (Kirk Waiblinger)
- [`3383e7e`](3383e7ec90) fix!: remove deprecated `SourceCode` methods ([#&#8203;20137](https://github.com/eslint/eslint/issues/20137)) (Pixel998)
- [`501abd0`](501abd0e91) feat!: update dependency minimatch to v10 ([#&#8203;20246](https://github.com/eslint/eslint/issues/20246)) (renovate\[bot])
- [`ca4d3b4`](ca4d3b4008) fix!: stricter rule tester assertions for valid test cases ([#&#8203;20125](https://github.com/eslint/eslint/issues/20125)) (唯然)
- [`96512a6`](96512a66c8) fix!: Remove deprecated rule context methods ([#&#8203;20086](https://github.com/eslint/eslint/issues/20086)) (Nicholas C. Zakas)
- [`c69fdac`](c69fdacdb2) feat!: remove eslintrc support ([#&#8203;20037](https://github.com/eslint/eslint/issues/20037)) (Francesco Trotta)
- [`208b5cc`](208b5cc34a) feat!: Use `ScopeManager#addGlobals()` ([#&#8203;20132](https://github.com/eslint/eslint/issues/20132)) (Milos Djermanovic)
- [`a2ee188`](a2ee188ea7) fix!: add `uniqueItems: true` in `no-invalid-regexp` option ([#&#8203;20155](https://github.com/eslint/eslint/issues/20155)) (Tanuj Kanti)
- [`a89059d`](a89059dbf2) feat!: Program range span entire source text ([#&#8203;20133](https://github.com/eslint/eslint/issues/20133)) (Pixel998)
- [`39a6424`](39a6424373) fix!: assert 'text' is a string across all RuleFixer methods ([#&#8203;20082](https://github.com/eslint/eslint/issues/20082)) (Pixel998)
- [`f28fbf8`](f28fbf8462) fix!: Deprecate `"always"` and `"as-needed"` options of the `radix` rule ([#&#8203;20223](https://github.com/eslint/eslint/issues/20223)) (Milos Djermanovic)
- [`aa3fb2b`](aa3fb2b233) fix!: tighten `func-names` schema ([#&#8203;20119](https://github.com/eslint/eslint/issues/20119)) (Pixel998)
- [`f6c0ed0`](f6c0ed0311) feat!: report `eslint-env` comments as errors ([#&#8203;20128](https://github.com/eslint/eslint/issues/20128)) (Francesco Trotta)
- [`4bf739f`](4bf739fb53) fix!: remove deprecated `LintMessage#nodeType` and `TestCaseError#type` ([#&#8203;20096](https://github.com/eslint/eslint/issues/20096)) (Pixel998)
- [`523c076`](523c076866) feat!: drop support for jiti < 2.2.0 ([#&#8203;20016](https://github.com/eslint/eslint/issues/20016)) (michael faith)
- [`454a292`](454a292c95) feat!: update `eslint:recommended` configuration ([#&#8203;20210](https://github.com/eslint/eslint/issues/20210)) (Pixel998)
- [`4f880ee`](4f880ee029) feat!: remove `v10_*` and inactive `unstable_*` flags ([#&#8203;20225](https://github.com/eslint/eslint/issues/20225)) (sethamus)
- [`f18115c`](f18115c363) feat!: `no-shadow-restricted-names` report `globalThis` by default ([#&#8203;20027](https://github.com/eslint/eslint/issues/20027)) (sethamus)
- [`c6358c3`](c6358c31fb) feat!: Require Node.js `^20.19.0 || ^22.13.0 || >=24` ([#&#8203;20160](https://github.com/eslint/eslint/issues/20160)) (Milos Djermanovic)

#### Features

- [`bff9091`](bff9091927) feat: handle `Array.fromAsync` in `array-callback-return` ([#&#8203;20457](https://github.com/eslint/eslint/issues/20457)) (Francesco Trotta)
- [`290c594`](290c594bb5) feat: add `self` to `no-implied-eval` rule ([#&#8203;20468](https://github.com/eslint/eslint/issues/20468)) (sethamus)
- [`43677de`](43677de07e) feat: fix handling of function and class expression names in `no-shadow` ([#&#8203;20432](https://github.com/eslint/eslint/issues/20432)) (Milos Djermanovic)
- [`f0cafe5`](f0cafe5f37) feat: rule tester add assertion option `requireData` ([#&#8203;20409](https://github.com/eslint/eslint/issues/20409)) (fnx)
- [`f7ab693`](f7ab6937e6) feat: output RuleTester test case failure index ([#&#8203;19976](https://github.com/eslint/eslint/issues/19976)) (ST-DDT)
- [`7cbcbf9`](7cbcbf9c3c) feat: add `countThis` option to `max-params` ([#&#8203;20236](https://github.com/eslint/eslint/issues/20236)) (Gerkin)
- [`f148a5e`](f148a5eaa1) feat: add error assertion options ([#&#8203;20247](https://github.com/eslint/eslint/issues/20247)) (ST-DDT)
- [`09e6654`](09e66549ec) feat: update error loc of `require-yield` and `no-useless-constructor` ([#&#8203;20267](https://github.com/eslint/eslint/issues/20267)) (Tanuj Kanti)

#### Bug Fixes

- [`436b82f`](436b82f3c0) fix: update eslint ([#&#8203;20473](https://github.com/eslint/eslint/issues/20473)) (renovate\[bot])
- [`1d29d22`](1d29d22fe3) fix: detect default `this` binding in `Array.fromAsync` callbacks ([#&#8203;20456](https://github.com/eslint/eslint/issues/20456)) (Francesco Trotta)
- [`727451e`](727451eff5) fix: fix regression of global mode report range in `strict` rule ([#&#8203;20462](https://github.com/eslint/eslint/issues/20462)) (ntnyq)
- [`e80485f`](e80485fcd2) fix: remove fake `FlatESLint` and `LegacyESLint` exports ([#&#8203;20460](https://github.com/eslint/eslint/issues/20460)) (Francesco Trotta)
- [`9eeff3b`](9eeff3bc13) fix: update esquery ([#&#8203;20423](https://github.com/eslint/eslint/issues/20423)) (cryptnix)
- [`b34b938`](b34b93852d) fix: use `Error.prepareStackTrace` to estimate failing test location ([#&#8203;20436](https://github.com/eslint/eslint/issues/20436)) (Francesco Trotta)
- [`51aab53`](51aab5393b) fix: update eslint ([#&#8203;20443](https://github.com/eslint/eslint/issues/20443)) (renovate\[bot])
- [`23490b2`](23490b2662) fix: handle space before colon in `RuleTester` location estimation ([#&#8203;20433](https://github.com/eslint/eslint/issues/20433)) (Francesco Trotta)
- [`f244dbf`](f244dbf219) fix: use `MessagePlaceholderData` type from `@eslint/core` ([#&#8203;20348](https://github.com/eslint/eslint/issues/20348)) (루밀LuMir)
- [`d186f8c`](d186f8c074) fix: update eslint ([#&#8203;20427](https://github.com/eslint/eslint/issues/20427)) (renovate\[bot])
- [`2332262`](2332262deb) fix: error location should not modify error message in RuleTester ([#&#8203;20421](https://github.com/eslint/eslint/issues/20421)) (Milos Djermanovic)
- [`ab99b21`](ab99b21a67) fix: ensure `filename` is passed as third argument to `verifyAndFix()` ([#&#8203;20405](https://github.com/eslint/eslint/issues/20405)) (루밀LuMir)
- [`8a60f3b`](8a60f3bc80) fix: remove `ecmaVersion` and `sourceType` from `ParserOptions` type ([#&#8203;20415](https://github.com/eslint/eslint/issues/20415)) (Pixel998)
- [`eafd727`](eafd727a06) fix: remove `TDZ` scope type ([#&#8203;20231](https://github.com/eslint/eslint/issues/20231)) (jaymarvelz)
- [`39d1f51`](39d1f51680) fix: correct `Scope` typings ([#&#8203;20404](https://github.com/eslint/eslint/issues/20404)) (sethamus)
- [`2bd0f13`](2bd0f13a92) fix: update `verify` and `verifyAndFix` types ([#&#8203;20384](https://github.com/eslint/eslint/issues/20384)) (Francesco Trotta)
- [`ba6ebfa`](ba6ebfa78d) fix: correct typings for `loadESLint()` and `shouldUseFlatConfig()` ([#&#8203;20393](https://github.com/eslint/eslint/issues/20393)) (루밀LuMir)
- [`e7673ae`](e7673ae096) fix: correct RuleTester typings ([#&#8203;20105](https://github.com/eslint/eslint/issues/20105)) (Pixel998)
- [`53e9522`](53e95222af) fix: strict removed formatters check ([#&#8203;20241](https://github.com/eslint/eslint/issues/20241)) (ntnyq)
- [`b017f09`](b017f094d4) fix: correct `no-restricted-import` messages ([#&#8203;20374](https://github.com/eslint/eslint/issues/20374)) (Francesco Trotta)

#### Documentation

- [`e978dda`](e978ddaab7) docs: Update README (GitHub Actions Bot)
- [`4cecf83`](4cecf8393a) docs: Update README (GitHub Actions Bot)
- [`c79f0ab`](c79f0ab2e2) docs: Update README (GitHub Actions Bot)
- [`773c052`](773c0527c7) docs: Update README (GitHub Actions Bot)
- [`f2962e4`](f2962e46a0) docs: document `meta.docs.frozen` property ([#&#8203;20475](https://github.com/eslint/eslint/issues/20475)) (Pixel998)
- [`8e94f58`](8e94f58beb) docs: fix broken anchor links from gerund heading updates ([#&#8203;20449](https://github.com/eslint/eslint/issues/20449)) (Copilot)
- [`1495654`](14956543d4) docs: Update README (GitHub Actions Bot)
- [`0b8ed5c`](0b8ed5c0aa) docs: document support for `:is` selector alias ([#&#8203;20454](https://github.com/eslint/eslint/issues/20454)) (sethamus)
- [`1c4b33f`](1c4b33fe86) docs: Document policies about ESM-only dependencies ([#&#8203;20448](https://github.com/eslint/eslint/issues/20448)) (Milos Djermanovic)
- [`3e5d38c`](3e5d38cdd5) docs: add missing indentation space in rule example ([#&#8203;20446](https://github.com/eslint/eslint/issues/20446)) (fnx)
- [`63a0c7c`](63a0c7c84b) docs: Update README (GitHub Actions Bot)
- [`65ed0c9`](65ed0c94e7) docs: Update README (GitHub Actions Bot)
- [`b0e4717`](b0e4717d66) docs: \[no-await-in-loop] Expand inapplicability ([#&#8203;20363](https://github.com/eslint/eslint/issues/20363)) (Niklas Hambüchen)
- [`fca421f`](fca421f6a4) docs: Update README (GitHub Actions Bot)
- [`d925c54`](d925c54f04) docs: update config syntax in `no-lone-blocks` ([#&#8203;20413](https://github.com/eslint/eslint/issues/20413)) (Pixel998)
- [`7d5c95f`](7d5c95f281) docs: remove redundant `sourceType: "module"` from rule examples ([#&#8203;20412](https://github.com/eslint/eslint/issues/20412)) (Pixel998)
- [`02e7e71`](02e7e71263) docs: correct `.mts` glob pattern in files with extensions example ([#&#8203;20403](https://github.com/eslint/eslint/issues/20403)) (Ali Essalihi)
- [`264b981`](264b981101) docs: Update README (GitHub Actions Bot)
- [`5a4324f`](5a4324f38e) docs: clarify `"local"` option of `no-unused-vars` ([#&#8203;20385](https://github.com/eslint/eslint/issues/20385)) (Milos Djermanovic)
- [`e593aa0`](e593aa0fd2) docs: improve clarity, grammar, and wording in documentation site README ([#&#8203;20370](https://github.com/eslint/eslint/issues/20370)) (Aditya)
- [`3f5062e`](3f5062ed5f) docs: Add messages property to rule meta documentation ([#&#8203;20361](https://github.com/eslint/eslint/issues/20361)) (Sabya Sachi)
- [`9e5a5c2`](9e5a5c2b6b) docs: remove `Examples` headings from rule docs ([#&#8203;20364](https://github.com/eslint/eslint/issues/20364)) (Milos Djermanovic)
- [`194f488`](194f488a8d) docs: Update README (GitHub Actions Bot)
- [`0f5a94a`](0f5a94a84b) docs: \[class-methods-use-this] explain purpose of rule ([#&#8203;20008](https://github.com/eslint/eslint/issues/20008)) (Kirk Waiblinger)
- [`df5566f`](df5566f826) docs: add Options section to all rule docs ([#&#8203;20296](https://github.com/eslint/eslint/issues/20296)) (sethamus)
- [`adf7a2b`](adf7a2b202) docs: no-unsafe-finally note for generator functions ([#&#8203;20330](https://github.com/eslint/eslint/issues/20330)) (Tom Pereira)
- [`ef7028c`](ef7028c968) docs: Update README (GitHub Actions Bot)
- [`fbae5d1`](fbae5d1885) docs: consistently use "v10.0.0" in migration guide ([#&#8203;20328](https://github.com/eslint/eslint/issues/20328)) (Pixel998)
- [`778aa2d`](778aa2d83e) docs: ignoring default file patterns ([#&#8203;20312](https://github.com/eslint/eslint/issues/20312)) (Tanuj Kanti)
- [`4b5dbcd`](4b5dbcdae5) docs: reorder v10 migration guide ([#&#8203;20315](https://github.com/eslint/eslint/issues/20315)) (Milos Djermanovic)
- [`5d84a73`](5d84a7371d) docs: Update README (GitHub Actions Bot)
- [`37c8863`](37c8863088) docs: fix incorrect anchor link in v10 migration guide ([#&#8203;20299](https://github.com/eslint/eslint/issues/20299)) (Pixel998)
- [`077ff02`](077ff028b6) docs: add migrate-to-10.0.0 doc ([#&#8203;20143](https://github.com/eslint/eslint/issues/20143)) (唯然)
- [`3822e1b`](3822e1b768) docs: Update README (GitHub Actions Bot)

#### Build Related

- [`9f08712`](9f0871236e) Build: changelog update for 10.0.0-rc.2 (Jenkins)
- [`1e2c449`](1e2c449701) Build: changelog update for 10.0.0-rc.1 (Jenkins)
- [`c4c72a8`](c4c72a8d99) Build: changelog update for 10.0.0-rc.0 (Jenkins)
- [`7e4daf9`](7e4daf93d2) Build: changelog update for 10.0.0-beta.0 (Jenkins)
- [`a126a2a`](a126a2ab13) build: add .scss files entry to knip ([#&#8203;20389](https://github.com/eslint/eslint/issues/20389)) (Francesco Trotta)
- [`f5c0193`](f5c01932f6) Build: changelog update for 10.0.0-alpha.1 (Jenkins)
- [`165326f`](165326f046) Build: changelog update for 10.0.0-alpha.0 (Jenkins)

#### Chores

- [`1ece282`](1ece282c22) chore: ignore `/docs/v9.x` in link checker ([#&#8203;20452](https://github.com/eslint/eslint/issues/20452)) (Milos Djermanovic)
- [`034e139`](034e139744) ci: add type integration test for `@html-eslint/eslint-plugin` ([#&#8203;20345](https://github.com/eslint/eslint/issues/20345)) (sethamus)
- [`f3fbc2f`](f3fbc2f60c) chore: set `@eslint/js` version to 10.0.0 to skip releasing it ([#&#8203;20466](https://github.com/eslint/eslint/issues/20466)) (Milos Djermanovic)
- [`afc0681`](afc06817bb) chore: remove scopeManager.addGlobals patch for typescript-eslint parser ([#&#8203;20461](https://github.com/eslint/eslint/issues/20461)) (fnx)
- [`3e5a173`](3e5a173053) refactor: use types from `@eslint/plugin-kit` ([#&#8203;20435](https://github.com/eslint/eslint/issues/20435)) (Pixel998)
- [`11644b1`](11644b1dc2) ci: rename workflows ([#&#8203;20463](https://github.com/eslint/eslint/issues/20463)) (Milos Djermanovic)
- [`2d14173`](2d14173729) chore: fix typos in docs and comments ([#&#8203;20458](https://github.com/eslint/eslint/issues/20458)) (o-m12a)
- [`6742f92`](6742f927ba) test: add endLine/endColumn to invalid test case in no-alert ([#&#8203;20441](https://github.com/eslint/eslint/issues/20441)) (경하)
- [`3e22c82`](3e22c82a87) test: add missing location data to no-template-curly-in-string tests ([#&#8203;20440](https://github.com/eslint/eslint/issues/20440)) (Haeun Kim)
- [`b4b3127`](b4b3127f85) chore: package.json update for [@&#8203;eslint/js](https://github.com/eslint/js) release (Jenkins)
- [`f658419`](f6584191cb) refactor: remove `raw` parser option from JS language ([#&#8203;20416](https://github.com/eslint/eslint/issues/20416)) (Pixel998)
- [`2c3efb7`](2c3efb728b) chore: remove `category` from type test fixtures ([#&#8203;20417](https://github.com/eslint/eslint/issues/20417)) (Pixel998)
- [`36193fd`](36193fd9ad) chore: remove `category` from formatter test fixtures ([#&#8203;20418](https://github.com/eslint/eslint/issues/20418)) (Pixel998)
- [`e8d203b`](e8d203b0d9) chore: add JSX language tag validation to `check-rule-examples` ([#&#8203;20414](https://github.com/eslint/eslint/issues/20414)) (Pixel998)
- [`bc465a1`](bc465a1e9d) chore: pin dependencies ([#&#8203;20397](https://github.com/eslint/eslint/issues/20397)) (renovate\[bot])
- [`703f0f5`](703f0f551d) test: replace deprecated rules in `linter` tests ([#&#8203;20406](https://github.com/eslint/eslint/issues/20406)) (루밀LuMir)
- [`ba71baa`](ba71baa872) test: enable `strict` mode in type tests ([#&#8203;20398](https://github.com/eslint/eslint/issues/20398)) (루밀LuMir)
- [`f9c4968`](f9c49683a6) refactor: remove `lib/linter/rules.js` ([#&#8203;20399](https://github.com/eslint/eslint/issues/20399)) (Francesco Trotta)
- [`6f1c48e`](6f1c48e5e7) chore: updates for v9.39.2 release (Jenkins)
- [`54bf0a3`](54bf0a3646) ci: create package manager test ([#&#8203;20392](https://github.com/eslint/eslint/issues/20392)) (루밀LuMir)
- [`3115021`](3115021439) refactor: simplify JSDoc comment detection logic ([#&#8203;20360](https://github.com/eslint/eslint/issues/20360)) (Pixel998)
- [`4345b17`](4345b172a8) chore: update `@eslint-community/regexpp` to `4.12.2` ([#&#8203;20366](https://github.com/eslint/eslint/issues/20366)) (루밀LuMir)
- [`772c9ee`](772c9ee9b6) chore: update dependency [@&#8203;eslint/eslintrc](https://github.com/eslint/eslintrc) to ^3.3.3 ([#&#8203;20359](https://github.com/eslint/eslint/issues/20359)) (renovate\[bot])
- [`0b14059`](0b14059491) chore: package.json update for [@&#8203;eslint/js](https://github.com/eslint/js) release (Jenkins)
- [`d6e7bf3`](d6e7bf3064) ci: bump actions/checkout from 5 to 6 ([#&#8203;20350](https://github.com/eslint/eslint/issues/20350)) (dependabot\[bot])
- [`139d456`](139d4567d4) chore: require mandatory headers in rule docs ([#&#8203;20347](https://github.com/eslint/eslint/issues/20347)) (Milos Djermanovic)
- [`3b0289c`](3b0289c7b6) chore: remove unused `.eslintignore` and test fixtures ([#&#8203;20316](https://github.com/eslint/eslint/issues/20316)) (Pixel998)
- [`a463e7b`](a463e7bea0) chore: update dependency js-yaml to v4 \[security] ([#&#8203;20319](https://github.com/eslint/eslint/issues/20319)) (renovate\[bot])
- [`ebfe905`](ebfe90533d) chore: remove redundant rules from eslint-config-eslint ([#&#8203;20327](https://github.com/eslint/eslint/issues/20327)) (Milos Djermanovic)
- [`88dfdb2`](88dfdb23ee) test: add regression tests for message placeholder interpolation ([#&#8203;20318](https://github.com/eslint/eslint/issues/20318)) (fnx)
- [`6ed0f75`](6ed0f758ff) chore: skip type checking in `eslint-config-eslint` ([#&#8203;20323](https://github.com/eslint/eslint/issues/20323)) (Francesco Trotta)
- [`1e2cad5`](1e2cad5f6f) chore: package.json update for [@&#8203;eslint/js](https://github.com/eslint/js) release (Jenkins)
- [`9da2679`](9da2679848) chore: update `@eslint/*` dependencies ([#&#8203;20321](https://github.com/eslint/eslint/issues/20321)) (Milos Djermanovic)
- [`0439794`](0439794181) refactor: use types from [@&#8203;eslint/core](https://github.com/eslint/core) ([#&#8203;20235](https://github.com/eslint/eslint/issues/20235)) (jaymarvelz)
- [`cb51ec2`](cb51ec2d6d) test: cleanup `SourceCode#traverse` tests ([#&#8203;20289](https://github.com/eslint/eslint/issues/20289)) (Milos Djermanovic)
- [`897a347`](897a3471d6) chore: remove restriction for `type` in rule tests ([#&#8203;20305](https://github.com/eslint/eslint/issues/20305)) (Pixel998)
- [`d972098`](d972098857) chore: ignore prettier updates in renovate to keep in sync with trunk ([#&#8203;20304](https://github.com/eslint/eslint/issues/20304)) (Pixel998)
- [`a086359`](a086359387) chore: remove redundant `fast-glob` dev-dependency ([#&#8203;20301](https://github.com/eslint/eslint/issues/20301)) (루밀LuMir)
- [`564b302`](564b30215c) chore: install `prettier` as a dev dependency ([#&#8203;20302](https://github.com/eslint/eslint/issues/20302)) (michael faith)
- [`8257b57`](8257b5729d) refactor: correct regex for `eslint-plugin/report-message-format` ([#&#8203;20300](https://github.com/eslint/eslint/issues/20300)) (루밀LuMir)
- [`e251671`](e2516713bc) refactor: extract assertions in RuleTester ([#&#8203;20135](https://github.com/eslint/eslint/issues/20135)) (唯然)
- [`2e7f25e`](2e7f25e189) chore: add `legacy-peer-deps` to `.npmrc` ([#&#8203;20281](https://github.com/eslint/eslint/issues/20281)) (Milos Djermanovic)
- [`39c638a`](39c638a9ae) chore: update eslint-config-eslint dependencies for v10 prereleases ([#&#8203;20278](https://github.com/eslint/eslint/issues/20278)) (Milos Djermanovic)
- [`8533b3f`](8533b3fa28) chore: update dependency [@&#8203;eslint/json](https://github.com/eslint/json) to ^0.14.0 ([#&#8203;20288](https://github.com/eslint/eslint/issues/20288)) (renovate\[bot])
- [`796ddf6`](796ddf6db5) chore: update dependency [@&#8203;eslint/js](https://github.com/eslint/js) to ^9.39.1 ([#&#8203;20285](https://github.com/eslint/eslint/issues/20285)) (renovate\[bot])

### [`v9.39.3`](https://github.com/eslint/eslint/releases/tag/v9.39.3)

[Compare Source](https://github.com/eslint/eslint/compare/v9.39.2...v9.39.3)

#### Bug Fixes

- [`791bf8d`](791bf8d7e7) fix: restore TypeScript 4.0 compatibility in types ([#&#8203;20504](https://github.com/eslint/eslint/issues/20504)) (sethamus)

#### Chores

- [`8594a43`](8594a436c2) chore: upgrade [@&#8203;eslint/js](https://github.com/eslint/js)@&#8203;9.39.3 ([#&#8203;20529](https://github.com/eslint/eslint/issues/20529)) (Milos Djermanovic)
- [`9ceef92`](9ceef92fbd) chore: package.json update for [@&#8203;eslint/js](https://github.com/eslint/js) release (Jenkins)
- [`af498c6`](af498c63b9) chore: ignore `/docs/v9.x` in link checker ([#&#8203;20453](https://github.com/eslint/eslint/issues/20453)) (Milos Djermanovic)

</details>

---

### Configuration

📅 **Schedule**: 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:eyJjcmVhdGVkSW5WZXIiOiI0My4zMS4xIiwidXBkYXRlZEluVmVyIjoiNDMuMzEuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Reviewed-on: https://git.keligrubb.com/keligrubb/kestrelos/pulls/10
Co-authored-by: Renovate Bot <renovate@keligrubb.com>
Co-committed-by: Renovate Bot <renovate@keligrubb.com>
2026-02-22 20:43:48 +00:00
CI
2146c06b02 release v1.0.1 [skip ci] 2026-02-22 03:34:33 +00:00
7d955cd89f chore: Configure Renovate (#7)
All checks were successful
ci/woodpecker/push/push Pipeline was successful
Welcome to [Renovate](https://github.com/renovatebot/renovate)! This is an onboarding PR to help you understand and configure settings before regular Pull Requests begin.

🚦 To activate Renovate, merge this Pull Request. To disable Renovate, simply close this Pull Request unmerged.

---
### Detected Package Files

 * `Dockerfile` (dockerfile)
 * `helm/kestrelos/values.yaml` (helm-values)
 * `package.json` (npm)
 * `.woodpecker/pr.yml` (woodpecker)
 * `.woodpecker/push.yml` (woodpecker)

### Configuration Summary

Based on the default config's presets, Renovate will:

  - Start dependency updates only once this onboarding PR is merged
  - Enable Renovate Dependency Dashboard creation.
  - Use semantic commit type `fix` for dependencies and `chore` for all others if semantic commits are in use.
  - Ignore `node_modules`, `bower_components`, `vendor` and various test/tests (except for nuget) directories.
  - Group known monorepo packages together.
  - Use curated list of recommended non-monorepo package groupings.
  - Show only the Age and Confidence Merge Confidence badges for pull requests.
  - Apply crowd-sourced package replacement rules.
  - Apply crowd-sourced workarounds for known problems with packages.
  - Ensure that every dependency pinned by digest and sourced from GitHub.com contains a link to the commit-to-commit diff
  - Correctly link to the source code for golang.org/x packages
  - Link to pkg.go.dev/... for golang.org/x packages' title
  - Pin Docker digests.
  - Pin `github-action` digests.
  - Enable Renovate configuration migration PRs when needed.
  - Pin dependency versions for development dependencies.
  - Recommended configuration for abandoned packages, treating packages without a release for 1 year as abandoned, while taking into account community-sourced overrides.
  - Wait until the npm package is three days old before raising the update. This a) introduces a short delay to allow for malware researchers and scanners to (possibly) detect any malicious behaviour in packages, and b) prevents the maintainer and/or NPM from unpublishing a package you already upgraded to, breaking builds.
  - Run lock file maintenance (updates) early Monday mornings.
  - Group all `minor` and `patch` updates together.

🔡 Do you want to change how Renovate upgrades your dependencies? Add your custom config to `renovate.json` in this branch. Renovate will update the Pull Request description the next time it runs.

---

### What to Expect

With your current configuration, Renovate will create 5 Pull Requests:

<details>
<summary>chore(deps): pin dependencies</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/pin-dependencies`
  - Merge into: `main`
  - Pin @iconify-json/tabler to `1.2.26`
  - Pin [@nuxt/eslint](https://github.com/nuxt/eslint) to `1.15.1`
  - Pin [@nuxt/test-utils](https://github.com/nuxt/test-utils) to `4.0.0`
  - Pin [@playwright/test](https://github.com/microsoft/playwright) to `1.58.2`
  - Pin [@vitest/coverage-v8](https://github.com/vitest-dev/vitest) to `4.0.18`
  - Pin [@vue/test-utils](https://github.com/vuejs/test-utils) to `2.4.6`
  - Upgrade alpine to `sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659`
  - Upgrade alpine/helm to `sha256:b5c85b997d83e89d9e8ff9215a14b03864274143981af45eb3fe729cdf782c73`
  - Pin [eslint](https://github.com/eslint/eslint) to `9.39.2`
  - Upgrade [git.keligrubb.com/keligrubb/kestrelos](https://github.com/nodejs/docker-node) to `sha256:a7e93276f5090e2c23792b4eedfb9228bfca182f651989551796356b365205e8`
  - Pin [happy-dom](https://github.com/capricorn86/happy-dom) to `20.6.1`
  - Upgrade mcr.microsoft.com/playwright to `sha256:6446946a1d9fd62d9ae501312a2d76a43ee688542b21622056a372959b65d63d`
  - Upgrade [node](https://github.com/nodejs/node) to `sha256:a81a03dd965b4052269a57fac857004022b522a4bf06e7a739e25e18bce45af2`
  - Pin [vitest](https://github.com/vitest-dev/vitest) to `4.0.18`
  - Upgrade woodpeckerci/plugin-kaniko to `sha256:b88802ba66af95ee28a8ffde08715631ec2892e024b2c74e90e19f73a5c2c602`

</details>

<details>
<summary>chore(deps): update all non-major dependencies</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/all-minor-patch`
  - Merge into: `main`
  - Upgrade [mediasoup-client](https://github.com/versatica/mediasoup-client) to `3.18.7`
  - Upgrade [tar](https://github.com/isaacs/node-tar) to `7.5.9`

</details>

<details>
<summary>chore(deps): update dependency eslint to v10</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/major-eslint-monorepo`
  - Merge into: `main`
  - Upgrade [eslint](https://github.com/eslint/eslint) to `^10.0.0`

</details>

<details>
<summary>fix(deps): update dependency vue-router to v5</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/vue-router-5.x`
  - Merge into: `main`
  - Upgrade [vue-router](https://github.com/vuejs/router) to `^5.0.0`

</details>

<details>
<summary>chore(deps): lock file maintenance</summary>

  - Schedule: ["* 0-3 * * 1"]
  - Branch name: `renovate/lock-file-maintenance`
  - Merge into: `main`
  - Regenerate lock files to use latest dependency versions

</details>

🚸 PR creation will be limited to maximum 2 per hour, so it doesn't swamp any CI resources or overwhelm the project. See docs for `prHourlyLimit` for details.

---

 Got questions? Check out Renovate's [Docs](https://docs.renovatebot.com/), particularly the Getting Started section.
If you need any further assistance then you can also [request help here](https://github.com/renovatebot/renovate/discussions).

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).

<!--renovate-config-hash:94693a990c975907e7f13da3309b9d56ba02b3983519b41786edf5cf031e457c-->

Reviewed-on: #7
Co-authored-by: Renovate Bot <renovate@keligrubb.com>
Co-committed-by: Renovate Bot <renovate@keligrubb.com>
2026-02-22 03:34:05 +00:00
68082fd893 patch: fix ci parallelism (#8)
Reviewed-on: #8
Co-authored-by: keligrubb <keligrubb324@gmail.com>
Co-committed-by: keligrubb <keligrubb324@gmail.com>
2026-02-22 03:11:23 +00:00
CI
7fc4685cfc release v1.0.0 [skip ci] 2026-02-17 16:42:30 +00:00
e61e6bc7e3 major: kestrel is now a tak server (#6)
All checks were successful
ci/woodpecker/push/push Pipeline was successful
## Added

- CoT (Cursor on Target) server on port 8089 enabling ATAK/iTAK device connectivity
- Support for TAK stream protocol and traditional XML CoT messages
- TLS/SSL support with automatic fallback to plain TCP
- Username/password authentication for CoT connections
- Real-time device position tracking with TTL-based expiration (90s default)
- API endpoints: `/api/cot/config`, `/api/cot/server-package`, `/api/cot/truststore`, `/api/me/cot-password`
- TAK Server section in Settings with QR code for iTAK setup
- ATAK password management in Account page for OIDC users
- CoT device markers on map showing real-time positions
- Comprehensive documentation in `docs/` directory
- Environment variables: `COT_PORT`, `COT_TTL_MS`, `COT_REQUIRE_AUTH`, `COT_SSL_CERT`, `COT_SSL_KEY`, `COT_DEBUG`
- Dependencies: `fast-xml-parser`, `jszip`, `qrcode`

## Changed

- Authentication system supports CoT password management for OIDC users
- Database schema includes `cot_password_hash` field
- Test suite refactored to follow functional design principles

## Removed

- Consolidated utility modules: `authConfig.js`, `authSkipPaths.js`, `bootstrap.js`, `poiConstants.js`, `session.js`

## Security

- XML entity expansion protection in CoT parser
- Enhanced input validation and SQL injection prevention
- Authentication timeout to prevent hanging connections

## Breaking Changes

- Port 8089 must be exposed for CoT server. Update firewall rules and Docker/Kubernetes configurations.

## Migration Notes

- OIDC users must set ATAK password via Account settings before connecting
- Docker: expose port 8089 (`-p 8089:8089`)
- Kubernetes: update Helm values to expose port 8089

Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #6
2026-02-17 16:41:41 +00:00
CI
b18283d3b3 release v0.4.0 [skip ci] 2026-02-15 04:08:16 +00:00
0aab29ea72 minor: new nav system (#5)
All checks were successful
ci/woodpecker/push/push Pipeline was successful
Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #5
2026-02-15 04:04:54 +00:00
CI
9261ba92bf release v0.3.0 [skip ci] 2026-02-14 04:53:34 +00:00
17f28401ba minor: heavily simplify server and app content. unify styling (#4)
All checks were successful
ci/woodpecker/push/push Pipeline was successful
Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #4
2026-02-14 04:52:18 +00:00
153 changed files with 7366 additions and 2790 deletions

85
.gitea/workflows/pr.yml Normal file
View File

@@ -0,0 +1,85 @@
name: PR
on:
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run lint
run: npm run lint
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm run test
e2e:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.2-noble
steps:
- uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Generate dev cert
run: ./scripts/gen-dev-cert.sh
- name: Run e2e tests
run: npm run test:e2e
env:
NODE_TLS_REJECT_UNAUTHORIZED: "0"
docker-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- 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: docker/setup-buildx-action@v4
- name: Build (dry run)
uses: docker/build-push-action@v7
with:
context: .
push: false
tags: ${{ steps.image.outputs.tag }}

63
.gitea/workflows/push.yml Normal file
View File

@@ -0,0 +1,63 @@
name: Push
on:
push:
branches: [main]
jobs:
release-docker-helm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
token: ${{ secrets.KESTRELOS_REPO_TOKEN }}
- 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 apt-get update -qq && sudo apt-get install -y -qq git wget
./scripts/release.sh
- name: Log in to container registry
uses: docker/login-action@v4
with:
registry: git.keligrubb.com
username: ${{ github.actor }}
password: ${{ secrets.KESTRELOS_REPO_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build Docker image
uses: docker/build-push-action@v7
with:
context: .
load: true
tags: kestrelos:built
- name: Push Docker image (all tags from .tags)
run: |
REGISTRY="git.keligrubb.com"
IMAGE="$REGISTRY/${{ github.repository }}"
while read -r tag; do
docker tag kestrelos:built "$IMAGE:$tag"
docker push "$IMAGE:$tag"
done < .tags
- name: Set up Helm
uses: azure/setup-helm@v4
- 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

View File

@@ -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

View File

@@ -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

View File

@@ -1,3 +1,43 @@
## [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)
## [0.3.0] - 2026-02-14
### Changed
- heavily simplify server and app content. unify styling (#4)
## [0.2.0] - 2026-02-12 ## [0.2.0] - 2026-02-12
### Changed ### Changed
- add a new release system (#3) - add a new release system (#3)

View File

@@ -16,11 +16,10 @@ USER node
WORKDIR /app WORKDIR /app
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
ENV PORT=3000
# Copy app as node user (builder stage ran as root) # Copy app as node user (builder stage ran as root)
COPY --from=builder --chown=node:node /app/.output ./.output COPY --from=builder --chown=node:node /app/.output ./.output
EXPOSE 3000 EXPOSE 3000 8089
CMD ["node", ".output/server/index.mjs"] CMD ["node", ".output/server/index.mjs"]

View File

@@ -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. 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.
![KestrelOS map UI](docs/screenshot.png)
## Stack ## Stack
- Nuxt 4, JavaScript, Tailwind CSS, ESLint, Vitest - 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 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. 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** server (runs automatically in the Nuxt process)
- **mediasoup-client** (browser library, included automatically) - **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 4000049999 on the server. **Streaming from a phone on your LAN:** The server auto-detects your machine's LAN IP (from network interfaces) and uses it for WebRTC. Open **https://<your-LAN-IP>:3000** on both phone and laptop (same IP as for your dev cert). To override (e.g. Docker or multiple NICs), set `MEDIASOUP_ANNOUNCED_IP`. Ensure firewall allows UDP/TCP ports 40000-49999 on the server.
See [docs/live-streaming.md](docs/live-streaming.md) for architecture details. See [docs/live-streaming.md](docs/live-streaming.md) for setup and usage.
### ATAK / CoT (Cursor on Target)
KestrelOS can act as a **TAK Server** so ATAK and iTAK devices connect and share positions. No plugins: in ATAK, add a **Server** connection (host = KestrelOS, port **8089** for CoT). Check **Use Authentication** and enter your **KestrelOS username** and **password** (local users use their login password; OIDC users must set an **ATAK password** once under **Account** in the web app). Devices relay CoT to each other (team members see each other on the ATAK map) and appear on the KestrelOS web map; they drop off after ~90 seconds if no updates. Optional: set `COT_TTL_MS`, `COT_REQUIRE_AUTH`; CoT runs on port 8089 (default).
## Scripts ## Scripts
- `npm run dev` development server - `npm run dev` - development server
- `npm run build` production build - `npm run build` - production build
- `npm run test` run tests - `npm run test` - run tests
- `npm run test:coverage` run tests with coverage (85% threshold) - `npm run test:coverage` - run tests with coverage (85% threshold)
- `npm run lint` ESLint (zero warnings) - `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 ## 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`). - **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 `PORT` as needed (e.g. in Docker/Helm). - **Environment**: No required env vars for basic run. For production, set `HOST=0.0.0.0` and expose ports 3000 (web/API) and 8089 (CoT). Set `COT_TTL_MS=90000`, `COT_REQUIRE_AUTH=true`. For TLS use `.dev-certs/` or set `COT_SSL_CERT` and `COT_SSL_KEY`.
- **Authentication**: The login page always offers password sign-in (local). Optionally set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before the first run to create the first admin; otherwise a default admin is created and its credentials are printed in the terminal. To also show an OIDC sign-in button, configure `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, and optionally `OIDC_LABEL`, `OIDC_REDIRECT_URI`. See [docs/auth.md](docs/auth.md) for provider-specific examples. - **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 terminalcopy 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. - **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. - **Database**: SQLite file at `data/kestrelos.db` (created automatically). Contains users, sessions, and POIs.
## Docker ## Docker
```bash ```bash
docker build -t kestrelos:latest . docker build -t kestrelos:latest .
docker run -p 3000:3000 kestrelos:latest docker run -p 3000:3000 -p 8089:8089 kestrelos:latest
``` ```
## Kubernetes (Helm) ## 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: Merges to `main` trigger a semver release. Use one of these prefixes in your PR title to set the version bump:
- `major:` breaking changes - `major:` - breaking changes
- `minor:` new features - `minor:` - new features
- `patch:` bug fixes, docs (default if no prefix) - `patch:` - bug fixes, docs (default if no prefix)
Example: `minor: Add map layer toggle` Example: `minor: Add map layer toggle`

View File

@@ -1,5 +1,5 @@
<template> <template>
<NuxtLayout> <NuxtLayout>
<NuxtPage /> <NuxtPage :key="$route.path" />
</NuxtLayout> </NuxtLayout>
</template> </template>

134
app/assets/css/main.css Normal file
View File

@@ -0,0 +1,134 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
.kestrel-page-heading { @apply text-xl font-semibold tracking-wide text-kestrel-text text-shadow-glow-sm; }
.kestrel-section-heading { @apply text-lg font-semibold tracking-wide text-kestrel-text text-shadow-glow-sm; }
.kestrel-panel-header { @apply flex items-center justify-between border-b border-kestrel-border px-4 py-3 shadow-border-header; }
.kestrel-video-frame { @apply relative aspect-video w-full overflow-hidden rounded border border-kestrel-border bg-black shadow-glow-inset-video; }
.kestrel-close-btn { @apply rounded p-1 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent; }
.kestrel-card { @apply rounded border border-kestrel-border bg-kestrel-surface shadow-glow-card; }
.kestrel-card-modal { @apply rounded-lg border border-kestrel-border bg-kestrel-surface shadow-glow-modal; }
.kestrel-label { @apply mb-1.5 block text-xs font-medium uppercase tracking-wider text-kestrel-muted; }
.kestrel-section-label { @apply mb-2 text-sm font-medium uppercase tracking-wider text-kestrel-muted; }
.kestrel-input { @apply w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text placeholder:text-kestrel-muted outline-none transition-colors focus:border-kestrel-accent; }
.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-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; }
}
/* Transitions: modal + drawer-backdrop (same fade) */
.modal-enter-active, .modal-leave-active,
.drawer-backdrop-enter-active, .drawer-backdrop-leave-active { transition: opacity 0.2s ease; }
.modal-enter-from, .modal-leave-to,
.drawer-backdrop-enter-from, .drawer-backdrop-leave-to { opacity: 0; }
.dropdown-enter-active, .dropdown-leave-active { transition: opacity 0.15s ease, transform 0.15s ease; }
.dropdown-enter-from, .dropdown-leave-to { opacity: 0; transform: translateY(-4px); }
.modal-enter-active .relative, .modal-leave-active .relative { transition: transform 0.2s ease; }
.modal-enter-from .relative, .modal-leave-to .relative { transform: scale(0.96); }
.nav-drawer { box-shadow: 8px 0 24px -4px rgba(34, 201, 201, 0.12); }
@media (min-width: 768px) { .nav-drawer { box-shadow: none; } }
/* Leaflet map */
.kestrel-map-container {
background: #000 !important;
}
.kestrel-map-container .leaflet-container {
border: none !important;
outline: none !important;
}
.kestrel-map-container .leaflet-tile-pane,
.kestrel-map-container .leaflet-map-pane,
.kestrel-map-container .leaflet-tile-container {
background: #000 !important;
}
.kestrel-map-container img.leaflet-tile {
background: #000 !important;
mix-blend-mode: normal;
}
.kestrel-map-container .poi-div-icon {
background: none;
border: none;
}
.kestrel-map-container .poi-icon-svg {
display: block;
width: 100%;
height: 100%;
pointer-events: none;
}
.kestrel-map-container .kestrel-poi-tooltip,
.kestrel-map-container .kestrel-live-popup-wrap .leaflet-popup-content-wrapper,
.kestrel-map-container .kestrel-live-popup-wrap .leaflet-popup-tip {
@apply bg-kestrel-surface-elevated border border-kestrel-glow rounded-md shadow-elevated;
}
.kestrel-map-container .kestrel-poi-tooltip {
@apply text-kestrel-text-bright text-xs font-[inherit] py-1.5 px-2.5;
}
.kestrel-map-container .kestrel-poi-tooltip::before,
.kestrel-map-container .kestrel-poi-tooltip::after {
border-color: #1e293b;
}
.kestrel-map-container .kestrel-live-popup-wrap .leaflet-popup-content {
@apply text-kestrel-text-bright my-2 mx-3 min-w-[200px];
}
.kestrel-map-container .kestrel-live-popup {
@apply text-kestrel-text-bright text-xs;
}
.kestrel-map-container .kestrel-live-popup img {
@apply block max-h-40 w-auto rounded bg-kestrel-bg;
}
.kestrel-map-container .leaflet-control-zoom,
.kestrel-map-container .leaflet-control-locate,
.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 .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;
height: 32px !important;
line-height: 32px !important;
background: #0d1424 !important;
color: #b8c9e0 !important;
text-decoration: none !important;
}
.kestrel-map-container .leaflet-control-zoom a + a,
.kestrel-map-container .savetiles.leaflet-bar a + a {
border-top: 1px solid rgba(34, 201, 201, 0.2);
}
.kestrel-map-container .leaflet-control-zoom a:hover,
.kestrel-map-container .leaflet-control-locate: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 .savetiles.leaflet-bar {
@apply flex flex-col;
}
.kestrel-map-container .savetiles.leaflet-bar a {
@apply min-w-[5.5em] leading-tight py-1.5 px-2.5 whitespace-nowrap text-center text-[11px] font-medium tracking-wide;
width: auto !important;
height: auto !important;
line-height: 1.25 !important;
padding: 6px 10px !important;
font-size: 11px !important;
}
.kestrel-map-container .leaflet-control-locate {
@apply flex items-center justify-center p-0 cursor-pointer;
}
.kestrel-map-container .leaflet-control-locate svg {
color: currentColor;
}
.kestrel-map-container .live-session-icon {
animation: live-pulse 1.5s ease-in-out infinite;
}
@keyframes live-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}

View File

@@ -0,0 +1,115 @@
<template>
<BaseModal
:show="show"
aria-labelledby="add-user-title"
@close="$emit('close')"
>
<div class="kestrel-card-modal w-full max-w-sm p-4">
<h3
id="add-user-title"
class="mb-3 text-sm font-medium text-kestrel-text"
>
Add user
</h3>
<form @submit.prevent="onSubmit">
<div class="mb-3 flex flex-col gap-1">
<label
for="add-identifier"
class="text-xs text-kestrel-muted"
>Username</label>
<input
id="add-identifier"
v-model="form.identifier"
type="text"
required
autocomplete="username"
class="kestrel-input"
placeholder="username"
>
</div>
<div class="mb-3 flex flex-col gap-1">
<label
for="add-password"
class="text-xs text-kestrel-muted"
>Password</label>
<input
id="add-password"
v-model="form.password"
type="password"
required
autocomplete="new-password"
class="kestrel-input"
placeholder="••••••••"
>
</div>
<div class="mb-4 flex flex-col gap-1">
<label
for="add-role"
class="text-xs text-kestrel-muted"
>Role</label>
<select
id="add-role"
v-model="form.role"
class="kestrel-input"
>
<option value="member">
member
</option>
<option value="leader">
leader
</option>
<option value="admin">
admin
</option>
</select>
</div>
<p
v-if="submitError"
class="mb-2 text-xs text-red-400"
>
{{ submitError }}
</p>
<div class="flex justify-end gap-2">
<button
type="button"
class="kestrel-btn-secondary"
@click="$emit('close')"
>
Cancel
</button>
<button
type="submit"
class="rounded border border-kestrel-accent px-3 py-1.5 text-sm text-kestrel-accent hover:bg-kestrel-accent-dim"
>
Add user
</button>
</div>
</form>
</div>
</BaseModal>
</template>
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
show: Boolean,
submitError: { type: String, default: '' },
})
const emit = defineEmits(['close', 'submit'])
const form = ref({ identifier: '', password: '', role: 'member' })
watch(() => props.show, (show) => {
if (show) form.value = { identifier: '', password: '', role: 'member' }
})
function onSubmit() {
emit('submit', {
identifier: form.value.identifier.trim(),
password: form.value.password,
role: form.value.role,
})
}
</script>

View File

@@ -0,0 +1,95 @@
<template>
<div class="relative">
<div ref="triggerRef">
<slot />
</div>
<Teleport
v-if="teleport"
to="body"
>
<Transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="open && placement"
ref="menuRef"
role="menu"
class="fixed z-[100] min-w-[6rem] rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow shadow-glow-dropdown"
:style="menuStyle"
>
<slot name="menu" />
</div>
</Transition>
</Teleport>
<Transition
v-else
name="dropdown"
>
<div
v-if="open"
ref="menuRef"
role="menu"
class="absolute right-0 top-full z-[2001] mt-1 min-w-[160px] rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow"
>
<slot name="menu" />
</div>
</Transition>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
const props = defineProps({
open: { type: Boolean, default: false },
teleport: { type: Boolean, default: false },
})
const emit = defineEmits(['close'])
const triggerRef = ref(null)
const menuRef = ref(null)
const placement = ref(null)
const menuStyle = computed(() => {
if (!placement.value) return undefined
const p = placement.value
return { top: p.top + 'px', left: p.left + 'px', minWidth: p.minWidth + 'px' }
})
watch(() => props.open, (open) => {
if (open && triggerRef.value && props.teleport) {
nextTick(() => {
const rect = triggerRef.value.getBoundingClientRect()
placement.value = {
top: rect.bottom + 4,
left: rect.left,
minWidth: Math.max(rect.width, 96),
}
})
}
else {
placement.value = null
}
})
function onDocumentClick(e) {
if (!props.open) return
const trigger = triggerRef.value
const menu = menuRef.value
const inTrigger = trigger && trigger.contains(e.target)
const inMenu = menu && menu.contains(e.target)
if (!inTrigger && !inMenu) emit('close')
}
onMounted(() => {
document.addEventListener('click', onDocumentClick)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onDocumentClick)
})
</script>

View File

@@ -0,0 +1,89 @@
<template>
<div class="flex min-h-0 flex-1 flex-col">
<header class="relative z-40 flex h-14 shrink-0 items-center gap-3 bg-kestrel-surface px-4">
<NuxtLink
to="/"
class="text-lg font-semibold tracking-wide text-kestrel-text no-underline text-shadow-glow-md transition-colors hover:text-kestrel-accent focus-visible:ring-2 focus-visible:ring-kestrel-accent focus-visible:rounded"
>
KestrelOS
</NuxtLink>
<button
type="button"
class="rounded p-2 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent md:hidden"
aria-label="Toggle navigation"
:aria-expanded="drawerOpen"
@click="drawerOpen = !drawerOpen"
>
<span
class="text-lg leading-none"
aria-hidden="true"
>&#9776;</span>
</button>
<div class="min-w-0 flex-1" />
<div class="flex items-center gap-2">
<UserMenu
v-if="user"
:user="user"
@signout="onLogout"
/>
<NuxtLink
v-else
to="/login"
class="rounded px-2 py-1 text-xs text-kestrel-muted hover:bg-kestrel-border hover:text-kestrel-accent"
>
Sign in
</NuxtLink>
</div>
</header>
<div class="flex min-h-0 flex-1">
<NavDrawer
v-model="drawerOpen"
v-model:collapsed="sidebarCollapsed"
:is-mobile="isMobile"
/>
<!-- Content area: rounded top-left so it nestles into the shell (GitLab gl-rounded-t-lg style). -->
<div class="relative min-h-0 flex-1 min-w-0 overflow-clip rounded-tl-lg">
<main class="relative h-full w-full min-h-0 overflow-auto">
<slot />
</main>
</div>
</div>
</div>
</template>
<script setup>
const isMobile = useMediaQuery('(max-width: 767px)')
const drawerOpen = ref(true)
const SIDEBAR_COLLAPSED_KEY = 'kestrelos-sidebar-collapsed'
const sidebarCollapsed = ref(false)
onMounted(() => {
try {
const stored = localStorage.getItem(SIDEBAR_COLLAPSED_KEY)
if (stored !== null) sidebarCollapsed.value = stored === 'true'
}
catch {
// localStorage unavailable (e.g. private mode)
}
})
watch(sidebarCollapsed, (v) => {
try {
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(v))
}
catch {
// localStorage unavailable
}
})
const { user, refresh } = useUser()
watch(isMobile, (mobile) => {
if (mobile) drawerOpen.value = false
}, { immediate: true })
async function onLogout() {
await $fetch('/api/auth/logout', { method: 'POST' })
await refresh()
await navigateTo('/')
}
</script>

View File

@@ -0,0 +1,36 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 z-[2000] flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
:aria-labelledby="ariaLabelledby"
@keydown.escape="$emit('close')"
>
<button
type="button"
class="absolute inset-0 bg-black/60 transition-opacity"
aria-label="Close"
@click="$emit('close')"
/>
<div
class="relative w-full"
@click.stop
>
<slot />
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
defineProps({
show: Boolean,
ariaLabelledby: { type: String, default: undefined },
})
defineEmits(['close'])
</script>

View File

@@ -7,18 +7,18 @@
/> />
<aside <aside
v-else v-else
class="flex flex-col border border-kestrel-border bg-kestrel-surface" class="kestrel-panel-base"
:class="asideClass" :class="inline ? 'kestrel-panel-inline' : 'kestrel-panel-overlay'"
role="dialog" role="dialog"
aria-label="Camera feed" aria-label="Camera feed"
> >
<div class="flex items-center justify-between border-b border-kestrel-border px-4 py-3 [box-shadow:0_1px_0_0_rgba(34,201,201,0.08)]"> <div class="kestrel-panel-header">
<h2 class="font-medium tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]"> <h2 class="font-medium tracking-wide text-kestrel-text text-shadow-glow-sm">
{{ camera?.name ?? 'Camera' }} {{ camera?.name ?? 'Camera' }}
</h2> </h2>
<button <button
type="button" type="button"
class="rounded p-1 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent" class="kestrel-close-btn"
aria-label="Close panel" aria-label="Close panel"
@click="$emit('close')" @click="$emit('close')"
> >
@@ -26,7 +26,7 @@
</button> </button>
</div> </div>
<div class="flex-1 overflow-auto p-4"> <div class="flex-1 overflow-auto p-4">
<div class="relative aspect-video w-full overflow-hidden rounded border border-kestrel-border bg-black [box-shadow:inset_0_0_20px_-8px_rgba(34,201,201,0.1)]"> <div class="kestrel-video-frame">
<template v-if="sourceType === 'hls'"> <template v-if="sourceType === 'hls'">
<video <video
ref="videoRef" ref="videoRef"
@@ -75,18 +75,14 @@ defineEmits(['close'])
const videoRef = ref(null) const videoRef = ref(null)
const streamError = ref(false) const streamError = ref(false)
const isLiveSession = computed(() => const isLiveSession = computed(() => props.camera?.hasStream !== undefined)
props.camera && typeof props.camera.hasStream !== 'undefined')
const asideClass = computed(() =>
props.inline ? 'rounded-lg shadow-glow' : 'absolute right-0 top-0 z-[1000] h-full w-full border-l shadow-glow md:w-[420px] [box-shadow:-8px_0_24px_-4px_rgba(34,201,201,0.12)]')
const streamUrl = computed(() => props.camera?.streamUrl ?? '') const streamUrl = computed(() => props.camera?.streamUrl ?? '')
const sourceType = computed(() => (props.camera?.sourceType === 'hls' ? 'hls' : 'mjpeg')) const sourceType = computed(() => (props.camera?.sourceType === 'hls' ? 'hls' : 'mjpeg'))
const safeStreamUrl = computed(() => { const safeStreamUrl = computed(() => {
const u = streamUrl.value const u = streamUrl.value?.trim()
return typeof u === 'string' && u.trim() && (u.startsWith('http://') || u.startsWith('https://')) ? u.trim() : '' return (u?.startsWith('http://') || u?.startsWith('https://')) ? u : ''
}) })
function initHls() { function initHls() {

View File

@@ -0,0 +1,46 @@
<template>
<BaseModal
:show="!!user"
aria-labelledby="delete-user-title"
@close="$emit('close')"
>
<div
v-if="user"
class="kestrel-card-modal w-full max-w-sm p-4"
>
<h3
id="delete-user-title"
class="mb-2 text-sm font-medium text-kestrel-text"
>
Delete user?
</h3>
<p class="mb-4 text-sm text-kestrel-muted">
Are you sure you want to delete <strong class="text-kestrel-text">{{ user.identifier }}</strong>? They will not be able to sign in again.
</p>
<div class="flex justify-end gap-2">
<button
type="button"
class="kestrel-btn-secondary"
@click="$emit('close')"
>
Cancel
</button>
<button
type="button"
class="rounded border border-red-500/60 bg-red-500/10 px-3 py-1.5 text-sm text-red-400 hover:bg-red-500/20"
@click="$emit('confirm')"
>
Delete
</button>
</div>
</div>
</BaseModal>
</template>
<script setup>
defineProps({
user: { type: Object, default: null },
})
defineEmits(['close', 'confirm'])
</script>

View File

@@ -0,0 +1,95 @@
<template>
<BaseModal
:show="!!user"
aria-labelledby="edit-user-title"
@close="$emit('close')"
>
<div
v-if="user"
class="kestrel-card-modal w-full max-w-sm p-4"
>
<h3
id="edit-user-title"
class="mb-3 text-sm font-medium text-kestrel-text"
>
Edit local user
</h3>
<form @submit.prevent="onSubmit">
<div class="mb-3 flex flex-col gap-1">
<label
for="edit-identifier"
class="text-xs text-kestrel-muted"
>Identifier</label>
<input
id="edit-identifier"
v-model="form.identifier"
type="text"
required
class="kestrel-input"
>
</div>
<div class="mb-4 flex flex-col gap-1">
<label
for="edit-password"
class="text-xs text-kestrel-muted"
>New password (leave blank to keep)</label>
<input
id="edit-password"
v-model="form.password"
type="password"
autocomplete="new-password"
class="kestrel-input"
placeholder="••••••••"
>
<p class="mt-0.5 text-xs text-kestrel-muted">
If you change your password, use the new one next time you sign in.
</p>
</div>
<p
v-if="submitError"
class="mb-2 text-xs text-red-400"
>
{{ submitError }}
</p>
<div class="flex justify-end gap-2">
<button
type="button"
class="kestrel-btn-secondary"
@click="$emit('close')"
>
Cancel
</button>
<button
type="submit"
class="rounded border border-kestrel-accent px-3 py-1.5 text-sm text-kestrel-accent hover:bg-kestrel-accent-dim"
>
Save
</button>
</div>
</form>
</div>
</BaseModal>
</template>
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
user: { type: Object, default: null },
submitError: { type: String, default: '' },
})
const emit = defineEmits(['close', 'submit'])
const form = ref({ identifier: '', password: '' })
watch(() => props.user, (u) => {
if (u) form.value = { identifier: u.identifier, password: '' }
}, { immediate: true })
function onSubmit() {
const payload = { identifier: form.value.identifier.trim() }
if (form.value.password) payload.password = form.value.password
emit('submit', payload)
}
</script>

View File

@@ -7,13 +7,13 @@
<div <div
v-if="contextMenu.type" v-if="contextMenu.type"
ref="contextMenuRef" ref="contextMenuRef"
class="pointer-events-auto absolute z-[1000] min-w-[120px] rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.2)]" class="pointer-events-auto absolute z-[1000] min-w-[120px] rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow shadow-glow-context"
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }" :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
> >
<template v-if="contextMenu.type === 'map'"> <template v-if="contextMenu.type === 'map'">
<button <button
type="button" type="button"
class="block w-full px-3 py-1.5 text-left text-sm text-kestrel-text hover:bg-kestrel-border" class="kestrel-context-menu-item"
@click="openAddPoiModal(contextMenu.latlng)" @click="openAddPoiModal(contextMenu.latlng)"
> >
Add POI here Add POI here
@@ -22,14 +22,14 @@
<template v-else-if="contextMenu.type === 'poi'"> <template v-else-if="contextMenu.type === 'poi'">
<button <button
type="button" type="button"
class="block w-full px-3 py-1.5 text-left text-sm text-kestrel-text hover:bg-kestrel-border" class="kestrel-context-menu-item"
@click="openEditPoiModal(contextMenu.poi)" @click="openEditPoiModal(contextMenu.poi)"
> >
Edit Edit
</button> </button>
<button <button
type="button" type="button"
class="block w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-kestrel-border" class="kestrel-context-menu-item-danger"
@click="openDeletePoiModal(contextMenu.poi)" @click="openDeletePoiModal(contextMenu.poi)"
> >
Delete Delete
@@ -37,176 +37,16 @@
</template> </template>
</div> </div>
<!-- POI modal (Add / Edit) --> <PoiModal
<Teleport to="body"> :show="showPoiModal"
<Transition name="modal"> :mode="poiModalMode"
<div :form="poiForm"
v-if="showPoiModal" :edit-poi="editPoi"
class="fixed inset-0 z-[2000] flex items-center justify-center p-4" :delete-poi="deletePoi"
role="dialog" @close="closePoiModal"
aria-modal="true" @submit="onPoiSubmit"
:aria-labelledby="poiModalMode === 'delete' ? 'delete-poi-title' : 'poi-modal-title'" @confirm-delete="confirmDeletePoi"
@keydown.escape="closePoiModal" />
>
<button
type="button"
class="absolute inset-0 bg-black/60 transition-opacity"
aria-label="Close"
@click="closePoiModal"
/>
<!-- Add / Edit form -->
<div
v-if="poiModalMode === 'add' || poiModalMode === 'edit'"
ref="poiModalRef"
class="relative w-full max-w-md rounded-lg border border-kestrel-border bg-kestrel-surface p-6 shadow-glow [box-shadow:0_0_32px_-8px_rgba(34,201,201,0.25)]"
@click.stop
>
<h2
id="poi-modal-title"
class="mb-4 text-lg font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]"
>
{{ poiModalMode === 'edit' ? 'Edit POI' : 'Add POI' }}
</h2>
<form
class="space-y-4"
@submit.prevent="submitPoiModal"
>
<div>
<label
for="add-poi-label"
class="mb-1.5 block text-xs font-medium uppercase tracking-wider text-kestrel-muted"
>
Label (optional)
</label>
<input
id="add-poi-label"
v-model="poiForm.label"
type="text"
placeholder="e.g. Rally point"
class="w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text placeholder:text-kestrel-muted outline-none transition-colors focus:border-kestrel-accent"
autocomplete="off"
>
</div>
<div>
<label
class="mb-1.5 block text-xs font-medium uppercase tracking-wider text-kestrel-muted"
>
Icon type
</label>
<div
:ref="el => iconDropdownOpen && (iconDropdownRef.value = el)"
class="relative inline-block w-full"
>
<button
type="button"
class="flex w-full min-w-0 items-center justify-between gap-2 rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-left text-sm text-kestrel-text transition-colors hover:border-kestrel-accent/50"
:aria-expanded="iconDropdownOpen"
aria-haspopup="listbox"
:aria-label="`Icon type: ${poiForm.iconType}`"
@click="iconDropdownOpen = !iconDropdownOpen"
>
<span class="flex items-center gap-2 capitalize">
<Icon
:name="POI_ICONIFY_IDS[poiForm.iconType]"
class="size-4 shrink-0"
/>
{{ poiForm.iconType }}
</span>
<span
class="text-kestrel-muted transition-transform"
:class="iconDropdownOpen && 'rotate-180'"
>
</span>
</button>
<Transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-show="iconDropdownOpen"
class="absolute left-0 right-0 top-full z-10 mt-1 rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow [box-shadow:0_4px_12px_-2px_rgba(34,201,201,0.15)]"
role="listbox"
>
<button
v-for="opt in POI_ICON_TYPES"
:key="opt"
type="button"
role="option"
:aria-selected="poiForm.iconType === opt"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm capitalize transition-colors"
:class="poiForm.iconType === opt
? 'bg-kestrel-accent-dim text-kestrel-accent'
: 'text-kestrel-text hover:bg-kestrel-border'"
@click="poiForm.iconType = opt; iconDropdownOpen = false"
>
<Icon
:name="POI_ICONIFY_IDS[opt]"
class="size-4 shrink-0"
/>
{{ opt }}
</button>
</div>
</Transition>
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<button
type="button"
class="rounded border border-kestrel-border px-4 py-2 text-sm text-kestrel-text transition-colors hover:bg-kestrel-border"
@click="closePoiModal"
>
Cancel
</button>
<button
type="submit"
class="rounded bg-kestrel-accent px-4 py-2 text-sm font-medium text-kestrel-bg transition-opacity hover:opacity-90"
>
{{ poiModalMode === 'edit' ? 'Save changes' : 'Add POI' }}
</button>
</div>
</form>
</div>
<!-- Delete confirmation -->
<div
v-if="poiModalMode === 'delete'"
ref="poiModalRef"
class="relative w-full max-w-sm rounded-lg border border-kestrel-border bg-kestrel-surface p-6 shadow-glow [box-shadow:0_0_32px_-8px_rgba(34,201,201,0.25)]"
@click.stop
>
<h2
id="delete-poi-title"
class="mb-2 text-lg font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]"
>
Delete POI?
</h2>
<p class="mb-4 text-sm text-kestrel-muted">
{{ deletePoi?.label ? `${deletePoi.label}” will be removed.` : 'This POI will be removed.' }}
</p>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded border border-kestrel-border px-4 py-2 text-sm text-kestrel-text transition-colors hover:bg-kestrel-border"
@click="closePoiModal"
>
Cancel
</button>
<button
type="button"
class="rounded bg-red-600 px-4 py-2 text-sm font-medium text-white transition-opacity hover:opacity-90"
@click="confirmDeletePoi"
>
Delete
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</div> </div>
</template> </template>
@@ -226,6 +66,10 @@ const props = defineProps({
type: Array, type: Array,
default: () => [], default: () => [],
}, },
cotEntities: {
type: Array,
default: () => [],
},
canEditPois: { canEditPois: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -241,17 +85,16 @@ const mapContext = ref(null)
const markersRef = ref([]) const markersRef = ref([])
const poiMarkersRef = ref({}) const poiMarkersRef = ref({})
const liveMarkersRef = ref({}) const liveMarkersRef = ref({})
const cotMarkersRef = ref({})
const contextMenu = ref({ ...CONTEXT_MENU_EMPTY }) const contextMenu = ref({ ...CONTEXT_MENU_EMPTY })
const showPoiModal = ref(false) const showPoiModal = ref(false)
const poiModalRef = ref(null)
const poiModalMode = ref('add') // 'add' | 'edit' | 'delete' const poiModalMode = ref('add') // 'add' | 'edit' | 'delete'
const addPoiLatlng = ref(null) const addPoiLatlng = ref(null)
const editPoi = ref(null) const editPoi = ref(null)
const deletePoi = ref(null) const deletePoi = ref(null)
const poiForm = ref({ label: '', iconType: 'pin' }) const poiForm = ref({ label: '', iconType: 'pin' })
const iconDropdownOpen = ref(false) const resizeObserver = ref(null)
const iconDropdownRef = ref(null)
const TILE_URL = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png' const TILE_URL = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png'
const TILE_SUBDOMAINS = 'abcd' const TILE_SUBDOMAINS = 'abcd'
@@ -259,11 +102,7 @@ const ATTRIBUTION = '&copy; <a href="https://www.openstreetmap.org/copyright">Op
const DEFAULT_VIEW = [37.7749, -122.4194] const DEFAULT_VIEW = [37.7749, -122.4194]
const DEFAULT_ZOOM = 17 const DEFAULT_ZOOM = 17
const MARKER_ICON_PATH = '/' const MARKER_ICON_PATH = '/'
const POI_ICON_TYPES = ['pin', 'flag', 'waypoint']
const POI_TOOLTIP_CLASS = 'kestrel-poi-tooltip' const POI_TOOLTIP_CLASS = 'kestrel-poi-tooltip'
/** Tabler icon names (Nuxt Icon / Iconify) modern technical aesthetic. */
const POI_ICONIFY_IDS = { pin: 'tabler:map-pin', flag: 'tabler:flag', waypoint: 'tabler:current-location' }
const POI_ICON_COLORS = { pin: '#22c9c9', flag: '#e53e3e', waypoint: '#a78bfa' } const POI_ICON_COLORS = { pin: '#22c9c9', flag: '#e53e3e', waypoint: '#a78bfa' }
const ICON_SIZE = 28 const ICON_SIZE = 28
@@ -279,8 +118,9 @@ function getPoiIconSvg(type) {
return shapes[type] || shapes.pin return shapes[type] || shapes.pin
} }
const VALID_POI_TYPES = ['pin', 'flag', 'waypoint']
function getPoiIcon(L, poi) { function getPoiIcon(L, poi) {
const type = poi.icon_type === 'pin' || poi.icon_type === 'flag' || poi.icon_type === 'waypoint' ? poi.icon_type : 'pin' const type = VALID_POI_TYPES.includes(poi.icon_type) ? poi.icon_type : 'pin'
const html = getPoiIconSvg(type) const html = getPoiIconSvg(type)
return L.divIcon({ return L.divIcon({
className: 'poi-div-icon', className: 'poi-div-icon',
@@ -290,7 +130,7 @@ function getPoiIcon(L, poi) {
}) })
} }
const LIVE_ICON_COLOR = '#22c9c9' const LIVE_ICON_COLOR = '#22c9c9' /* kestrel-accent - JS string for Leaflet SVG */
function getLiveSessionIcon(L) { 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>` 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({ return L.divIcon({
@@ -301,6 +141,17 @@ function getLiveSessionIcon(L) {
}) })
} }
const COT_ICON_COLOR = '#f59e0b' /* amber - ATAK/CoT devices */
function getCotEntityIcon(L) {
const html = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${COT_ICON_COLOR}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="8" r="2.5" fill="${COT_ICON_COLOR}"/></svg>`
return L.divIcon({
className: 'poi-div-icon cot-entity-icon',
html: `<span class="poi-icon-svg">${html}</span>`,
iconSize: [ICON_SIZE, ICON_SIZE],
iconAnchor: [ICON_SIZE / 2, ICON_SIZE],
})
}
function createMap(initialCenter) { function createMap(initialCenter) {
const { L, offlineApi } = leafletRef.value || {} const { L, offlineApi } = leafletRef.value || {}
if (typeof document === 'undefined' || !mapRef.value || !L?.map) return if (typeof document === 'undefined' || !mapRef.value || !L?.map) return
@@ -367,6 +218,8 @@ function createMap(initialCenter) {
updateMarkers() updateMarkers()
updatePoiMarkers() updatePoiMarkers()
updateLiveMarkers() updateLiveMarkers()
updateCotMarkers()
nextTick(() => map.invalidateSize())
} }
function updateMarkers() { function updateMarkers() {
@@ -439,7 +292,7 @@ function updateLiveMarkers() {
}) })
const next = sessions.reduce((acc, session) => { const next = sessions.reduce((acc, session) => {
const content = `<div class="kestrel-live-popup"><strong>${escapeHtml(session.label)}</strong>${session.hasStream ? ' <span style="color:#22c9c9">● Live</span>' : ''}</div>` const content = `<div class="kestrel-live-popup"><strong>${escapeHtml(session.label)}</strong>${session.hasStream ? ' <span class="text-kestrel-accent">● Live</span>' : ''}</div>`
const existing = prev[session.id] const existing = prev[session.id]
if (existing) { if (existing) {
existing.setLatLng([session.lat, session.lng]) existing.setLatLng([session.lat, session.lng])
@@ -456,6 +309,39 @@ function updateLiveMarkers() {
liveMarkersRef.value = next liveMarkersRef.value = next
} }
function updateCotMarkers() {
const ctx = mapContext.value
const { L } = leafletRef.value || {}
if (!ctx?.map || !L) return
const entities = (props.cotEntities || []).filter(
e => typeof e?.lat === 'number' && typeof e?.lng === 'number' && e?.id,
)
const byId = Object.fromEntries(entities.map(e => [e.id, e]))
const prev = cotMarkersRef.value
const icon = getCotEntityIcon(L)
Object.keys(prev).forEach((id) => {
if (!byId[id]) prev[id]?.remove()
})
const next = entities.reduce((acc, entity) => {
const content = `<div class="kestrel-live-popup"><strong>${escapeHtml(entity.label || entity.id)}</strong> <span class="text-kestrel-muted">ATAK</span></div>`
const existing = prev[entity.id]
if (existing) {
existing.setLatLng([entity.lat, entity.lng])
existing.setIcon(icon)
existing.getPopup()?.setContent(content)
return { ...acc, [entity.id]: existing }
}
const marker = L.marker([entity.lat, entity.lng], { icon })
.addTo(ctx.map)
.bindPopup(content, { className: 'kestrel-live-popup-wrap', maxWidth: 360 })
return { ...acc, [entity.id]: marker }
}, {})
cotMarkersRef.value = next
}
function escapeHtml(text) { function escapeHtml(text) {
const div = document.createElement('div') const div = document.createElement('div')
div.textContent = text div.textContent = text
@@ -473,7 +359,6 @@ function openAddPoiModal(latlng) {
editPoi.value = null editPoi.value = null
deletePoi.value = null deletePoi.value = null
poiForm.value = { label: '', iconType: 'pin' } poiForm.value = { label: '', iconType: 'pin' }
iconDropdownOpen.value = false
showPoiModal.value = true showPoiModal.value = true
} }
@@ -484,7 +369,6 @@ function openEditPoiModal(poi) {
addPoiLatlng.value = null addPoiLatlng.value = null
deletePoi.value = null deletePoi.value = null
poiForm.value = { label: (poi.label ?? '').trim(), iconType: poi.icon_type || 'pin' } poiForm.value = { label: (poi.label ?? '').trim(), iconType: poi.icon_type || 'pin' }
iconDropdownOpen.value = false
showPoiModal.value = true showPoiModal.value = true
} }
@@ -500,52 +384,38 @@ function openDeletePoiModal(poi) {
function closePoiModal() { function closePoiModal() {
showPoiModal.value = false showPoiModal.value = false
poiModalMode.value = 'add' poiModalMode.value = 'add'
iconDropdownOpen.value = false
addPoiLatlng.value = null addPoiLatlng.value = null
editPoi.value = null editPoi.value = null
deletePoi.value = null deletePoi.value = null
} }
function onPoiModalDocumentClick(e) { async function doPoiFetch(fn) {
if (!showPoiModal.value) return try {
if (iconDropdownOpen.value && iconDropdownRef.value && !iconDropdownRef.value.contains(e.target)) { await fn()
iconDropdownOpen.value = false emit('refreshPois')
closePoiModal()
} }
catch { /* ignore */ }
} }
async function submitPoiModal() { async function onPoiSubmit(payload) {
const { label, iconType } = payload
const body = { label: (label ?? '').trim(), iconType: iconType || 'pin' }
if (poiModalMode.value === 'add') { if (poiModalMode.value === 'add') {
const latlng = addPoiLatlng.value const latlng = addPoiLatlng.value
if (!latlng) return if (!latlng) return
const { label, iconType } = poiForm.value await doPoiFetch(() => $fetch('/api/pois', { method: 'POST', body: { ...body, lat: latlng.lat, lng: latlng.lng } }))
try {
await $fetch('/api/pois', { method: 'POST', body: { lat: latlng.lat, lng: latlng.lng, label: (label ?? '').trim(), iconType: iconType || 'pin' } })
emit('refreshPois')
closePoiModal()
}
catch { /* ignore */ }
return return
} }
if (poiModalMode.value === 'edit' && editPoi.value) { if (poiModalMode.value === 'edit' && editPoi.value) {
const { label, iconType } = poiForm.value await doPoiFetch(() => $fetch(`/api/pois/${editPoi.value.id}`, { method: 'PATCH', body }))
try {
await $fetch(`/api/pois/${editPoi.value.id}`, { method: 'PATCH', body: { label: (label ?? '').trim(), iconType: iconType || 'pin' } })
emit('refreshPois')
closePoiModal()
}
catch { /* ignore */ }
} }
} }
async function confirmDeletePoi() { async function confirmDeletePoi() {
const poi = deletePoi.value const poi = deletePoi.value
if (!poi?.id) return if (!poi?.id) return
try { await doPoiFetch(() => $fetch(`/api/pois/${poi.id}`, { method: 'DELETE' }))
await $fetch(`/api/pois/${poi.id}`, { method: 'DELETE' })
emit('refreshPois')
closePoiModal()
}
catch { /* ignore */ }
} }
function destroyMap() { function destroyMap() {
@@ -557,6 +427,8 @@ function destroyMap() {
poiMarkersRef.value = {} poiMarkersRef.value = {}
Object.values(liveMarkersRef.value).forEach(m => m?.remove()) Object.values(liveMarkersRef.value).forEach(m => m?.remove())
liveMarkersRef.value = {} liveMarkersRef.value = {}
Object.values(cotMarkersRef.value).forEach(m => m?.remove())
cotMarkersRef.value = {}
const ctx = mapContext.value const ctx = mapContext.value
if (ctx) { if (ctx) {
@@ -604,7 +476,15 @@ onMounted(async () => {
leafletRef.value = { L, offlineApi: offline } leafletRef.value = { L, offlineApi: offline }
initMapWithLocation() initMapWithLocation()
document.addEventListener('click', onDocumentClick) document.addEventListener('click', onDocumentClick)
document.addEventListener('click', onPoiModalDocumentClick)
nextTick(() => {
if (mapRef.value) {
resizeObserver.value = new ResizeObserver(() => {
mapContext.value?.map?.invalidateSize()
})
resizeObserver.value.observe(mapRef.value)
}
})
}) })
function onDocumentClick(e) { function onDocumentClick(e) {
@@ -613,166 +493,15 @@ function onDocumentClick(e) {
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('click', onDocumentClick) document.removeEventListener('click', onDocumentClick)
document.removeEventListener('click', onPoiModalDocumentClick) if (resizeObserver.value && mapRef.value) {
resizeObserver.value.disconnect()
resizeObserver.value = null
}
destroyMap() destroyMap()
}) })
watch(() => props.devices, () => updateMarkers(), { deep: true }) watch(() => props.devices, () => updateMarkers(), { deep: true })
watch([() => props.pois, () => props.canEditPois], () => updatePoiMarkers(), { deep: true }) watch([() => props.pois, () => props.canEditPois], () => updatePoiMarkers(), { deep: true })
watch(() => props.liveSessions, () => updateLiveMarkers(), { deep: true }) watch(() => props.liveSessions, () => updateLiveMarkers(), { deep: true })
watch(() => props.cotEntities, () => updateCotMarkers(), { deep: true })
</script> </script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .relative,
.modal-leave-active .relative {
transition: transform 0.2s ease;
}
.modal-enter-from .relative,
.modal-leave-to .relative {
transform: scale(0.96);
}
/* Unrendered/loading tiles show black instead of white when panning */
.kestrel-map-container {
background: #000 !important;
}
:deep(.leaflet-tile-pane),
:deep(.leaflet-map-pane),
:deep(.leaflet-tile-container) {
background: #000 !important;
}
:deep(img.leaflet-tile) {
background: #000 !important;
/* Override Leaflets plus-lighter so unloaded/empty tiles dont flash white */
mix-blend-mode: normal;
}
/* Leaflet injects divIcon HTML into the map; :deep() so these styles apply to that content */
:deep(.poi-div-icon) {
background: none;
border: none;
}
:deep(.poi-icon-svg) {
display: block;
width: 100%;
height: 100%;
pointer-events: none;
}
/* Dark-themed tooltip for POI labels (Leaflet creates these in the map container) */
:deep(.kestrel-poi-tooltip) {
background: #1e293b;
border: 1px solid rgba(34, 201, 201, 0.35);
border-radius: 6px;
color: #e2e8f0;
font-size: 12px;
font-family: inherit;
padding: 6px 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
:deep(.kestrel-poi-tooltip::before),
:deep(.kestrel-poi-tooltip::after) {
border-top-color: #1e293b;
border-bottom-color: #1e293b;
border-left-color: #1e293b;
border-right-color: #1e293b;
}
/* Live session popup (content injected by Leaflet) */
:deep(.kestrel-live-popup-wrap .leaflet-popup-content) {
margin: 8px 12px;
min-width: 200px;
}
:deep(.kestrel-live-popup) {
color: #e2e8f0;
font-size: 12px;
}
:deep(.kestrel-live-popup img) {
display: block;
max-height: 160px;
width: auto;
border-radius: 4px;
background: #0f172a;
}
:deep(.live-session-icon) {
animation: live-pulse 1.5s ease-in-out infinite;
}
@keyframes live-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
/* Map controls dark theme with cyan glow (zoom, locate, save/clear tiles) */
:deep(.leaflet-control-zoom),
:deep(.leaflet-control-locate),
:deep(.savetiles.leaflet-bar) {
border: 1px solid rgba(34, 201, 201, 0.35) !important;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 0 12px -2px rgba(34, 201, 201, 0.15);
font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
}
:deep(.leaflet-control-zoom a),
:deep(.leaflet-control-locate),
:deep(.savetiles.leaflet-bar a) {
width: 32px !important;
height: 32px !important;
line-height: 32px !important;
background: #0d1424 !important;
color: #b8c9e0 !important;
border: none !important;
border-radius: 0 !important;
font-size: 18px !important;
font-weight: 600;
text-decoration: none !important;
transition: background 0.15s, color 0.15s, box-shadow 0.15s, text-shadow 0.15s;
}
:deep(.leaflet-control-zoom a + a) {
border-top: 1px solid rgba(34, 201, 201, 0.2) !important;
}
:deep(.leaflet-control-zoom a:hover),
:deep(.leaflet-control-locate:hover),
:deep(.savetiles.leaflet-bar a:hover) {
background: #111a2e !important;
color: #22c9c9 !important;
box-shadow: 0 0 16px -2px rgba(34, 201, 201, 0.25);
text-shadow: 0 0 8px rgba(34, 201, 201, 0.35);
}
:deep(.leaflet-control-locate) {
display: flex !important;
align-items: center;
justify-content: center;
padding: 0;
cursor: pointer;
}
:deep(.leaflet-control-locate svg) {
color: currentColor;
}
/* Save/Clear tiles text buttons */
:deep(.savetiles.leaflet-bar) {
display: flex;
flex-direction: column;
}
:deep(.savetiles.leaflet-bar a) {
width: auto !important;
min-width: 5.5em;
height: auto !important;
line-height: 1.25 !important;
padding: 6px 10px !important;
white-space: nowrap;
text-align: center;
font-size: 11px !important;
font-weight: 500;
letter-spacing: 0.02em;
}
:deep(.savetiles.leaflet-bar a + a) {
border-top: 1px solid rgba(34, 201, 201, 0.2) !important;
}
</style>

View File

@@ -1,17 +1,17 @@
<template> <template>
<aside <aside
class="flex flex-col border border-kestrel-border bg-kestrel-surface" class="kestrel-panel-base"
:class="inline ? 'rounded-lg shadow-glow' : 'absolute right-0 top-0 z-[1000] h-full w-full border-l shadow-glow md:w-[420px] [box-shadow:-8px_0_24px_-4px_rgba(34,201,201,0.12)]'" :class="inline ? 'kestrel-panel-inline' : 'kestrel-panel-overlay'"
role="dialog" role="dialog"
aria-label="Live feed" aria-label="Live feed"
> >
<div class="flex items-center justify-between border-b border-kestrel-border px-4 py-3 [box-shadow:0_1px_0_0_rgba(34,201,201,0.08)]"> <div class="kestrel-panel-header">
<h2 class="font-medium tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]"> <h2 class="font-medium tracking-wide text-kestrel-text text-shadow-glow-sm">
{{ session?.label ?? 'Live' }} {{ session?.label ?? 'Live' }}
</h2> </h2>
<button <button
type="button" type="button"
class="rounded p-1 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent" class="kestrel-close-btn"
aria-label="Close panel" aria-label="Close panel"
@click="$emit('close')" @click="$emit('close')"
> >
@@ -22,7 +22,7 @@
<p class="mb-3 text-xs text-kestrel-muted"> <p class="mb-3 text-xs text-kestrel-muted">
Live camera feed (WebRTC) Live camera feed (WebRTC)
</p> </p>
<div class="relative aspect-video w-full overflow-hidden rounded border border-kestrel-border bg-black [box-shadow:inset_0_0_20px_-8px_rgba(34,201,201,0.1)]"> <div class="kestrel-video-frame">
<video <video
ref="videoRef" ref="videoRef"
autoplay autoplay
@@ -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. 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> </p>
<ul class="normal-case list-inside list-disc text-left text-kestrel-muted"> <ul class="normal-case list-inside list-disc text-left text-kestrel-muted">
<li><strong>Firewall:</strong> Open UDP/TCP 4000049999 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>Wrong host:</strong> Server must see the same address you use.</li>
<li><strong>Restrictive NAT / cellular:</strong> TURN may be required.</li> <li><strong>Restrictive NAT / cellular:</strong> TURN may be required.</li>
</ul> </ul>
@@ -66,7 +66,7 @@
Wrong host: server sees <strong>{{ failureReason.wrongHost.serverHostname }}</strong> but you opened at <strong>{{ failureReason.wrongHost.clientHostname }}</strong>. Wrong host: server sees <strong>{{ failureReason.wrongHost.serverHostname }}</strong> but you opened at <strong>{{ failureReason.wrongHost.clientHostname }}</strong>.
</p> </p>
<ul class="normal-case list-inside list-disc text-left text-kestrel-muted"> <ul class="normal-case list-inside list-disc text-left text-kestrel-muted">
<li>Firewall: open ports 4000049999.</li> <li>Firewall: open ports 40000-49999.</li>
<li>Wrong host: use same URL or set MEDIASOUP_ANNOUNCED_IP.</li> <li>Wrong host: use same URL or set MEDIASOUP_ANNOUNCED_IP.</li>
<li>Restrictive NAT: TURN may be required.</li> <li>Restrictive NAT: TURN may be required.</li>
</ul> </ul>
@@ -104,9 +104,9 @@ const hasStream = ref(false)
const error = ref('') const error = ref('')
const connectionState = ref('') // '', 'connecting', 'connected', 'failed' const connectionState = ref('') // '', 'connecting', 'connected', 'failed'
const failureReason = ref(null) // { wrongHost: { serverHostname, clientHostname } | null } const failureReason = ref(null) // { wrongHost: { serverHostname, clientHostname } | null }
let device = null const device = ref(null)
let recvTransport = null const recvTransport = ref(null)
let consumer = null const consumer = ref(null)
async function runFailureReasonCheck() { async function runFailureReasonCheck() {
failureReason.value = await getWebRTCFailureReason() 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}`, { const rtpCapabilities = await $fetch(`/api/live/webrtc/router-rtp-capabilities?sessionId=${props.session.id}`, {
credentials: 'include', credentials: 'include',
}) })
device = await createMediasoupDevice(rtpCapabilities) device.value = await createMediasoupDevice(rtpCapabilities)
recvTransport = await createRecvTransport(device, props.session.id) recvTransport.value = await createRecvTransport(device.value, props.session.id)
recvTransport.on('connectionstatechange', () => { recvTransport.value.on('connectionstatechange', () => {
const state = recvTransport.connectionState const state = recvTransport.value.connectionState
if (state === 'connected') connectionState.value = 'connected' if (state === 'connected') connectionState.value = 'connected'
else if (state === 'failed' || state === 'disconnected' || state === 'closed') { else if (state === 'failed' || state === 'disconnected' || state === 'closed') {
logWarn('LiveSessionPanel: Receive transport connection state changed', { logWarn('LiveSessionPanel: Receive transport connection state changed', {
state, state,
transportId: recvTransport.id, transportId: recvTransport.value.id,
sessionId: props.session.id, sessionId: props.session.id,
}) })
if (state === 'failed') { if (state === 'failed') {
@@ -154,8 +154,8 @@ async function setupWebRTC() {
} }
}) })
const connectionPromise = waitForConnectionState(recvTransport, 10000) const connectionPromise = waitForConnectionState(recvTransport.value, 10000)
consumer = await consumeProducer(recvTransport, device, props.session.id) consumer.value = await consumeProducer(recvTransport.value, device.value, props.session.id)
const finalConnectionState = await connectionPromise const finalConnectionState = await connectionPromise
if (finalConnectionState !== 'connected') { if (finalConnectionState !== 'connected') {
@@ -163,8 +163,8 @@ async function setupWebRTC() {
runFailureReasonCheck() runFailureReasonCheck()
logWarn('LiveSessionPanel: Transport not fully connected', { logWarn('LiveSessionPanel: Transport not fully connected', {
state: finalConnectionState, state: finalConnectionState,
transportId: recvTransport.id, transportId: recvTransport.value.id,
consumerId: consumer.id, consumerId: consumer.value.id,
}) })
} }
else { else {
@@ -182,14 +182,14 @@ async function setupWebRTC() {
attempts++ attempts++
} }
if (!consumer.track) { if (!consumer.value.track) {
logError('LiveSessionPanel: No video track available', { logError('LiveSessionPanel: No video track available', {
consumerId: consumer.id, consumerId: consumer.value.id,
consumerKind: consumer.kind, consumerKind: consumer.value.kind,
consumerPaused: consumer.paused, consumerPaused: consumer.value.paused,
consumerClosed: consumer.closed, consumerClosed: consumer.value.closed,
consumerProducerId: consumer.producerId, consumerProducerId: consumer.value.producerId,
transportConnectionState: recvTransport?.connectionState, transportConnectionState: recvTransport.value?.connectionState,
}) })
error.value = 'No video track available - consumer may not be receiving data from producer' error.value = 'No video track available - consumer may not be receiving data from producer'
return return
@@ -197,14 +197,14 @@ async function setupWebRTC() {
if (!videoRef.value) { if (!videoRef.value) {
logError('LiveSessionPanel: Video ref not available', { logError('LiveSessionPanel: Video ref not available', {
consumerId: consumer.id, consumerId: consumer.value.id,
hasTrack: !!consumer.track, hasTrack: !!consumer.value.track,
}) })
error.value = 'Video element not available' error.value = 'Video element not available'
return return
} }
const stream = new MediaStream([consumer.track]) const stream = new MediaStream([consumer.value.track])
videoRef.value.srcObject = stream videoRef.value.srcObject = stream
hasStream.value = true hasStream.value = true
@@ -227,7 +227,7 @@ async function setupWebRTC() {
if (resolved) return if (resolved) return
resolved = true resolved = true
videoRef.value.removeEventListener('loadedmetadata', handler) videoRef.value.removeEventListener('loadedmetadata', handler)
logWarn('LiveSessionPanel: Video metadata timeout', { consumerId: consumer.id }) logWarn('LiveSessionPanel: Video metadata timeout', { consumerId: consumer.value.id })
resolve() resolve()
}, 5000) }, 5000)
}) })
@@ -239,7 +239,7 @@ async function setupWebRTC() {
} }
catch (playErr) { catch (playErr) {
logWarn('LiveSessionPanel: Video play() failed (may need user interaction)', { logWarn('LiveSessionPanel: Video play() failed (may need user interaction)', {
consumerId: consumer.id, consumerId: consumer.value.id,
error: playErr.message || String(playErr), error: playErr.message || String(playErr),
errorName: playErr.name, errorName: playErr.name,
videoPaused: videoRef.value.paused, videoPaused: videoRef.value.paused,
@@ -248,12 +248,12 @@ async function setupWebRTC() {
// Don't set error - video might still work, just needs user interaction // 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' error.value = 'Video track ended'
hasStream.value = false hasStream.value = false
}) })
videoRef.value.addEventListener('error', () => { videoRef.value.addEventListener('error', () => {
logError('LiveSessionPanel: Video element error', { consumerId: consumer.id }) logError('LiveSessionPanel: Video element error', { consumerId: consumer.value.id })
}) })
} }
catch (err) { catch (err) {
@@ -274,15 +274,15 @@ async function setupWebRTC() {
} }
function cleanup() { function cleanup() {
if (consumer) { if (consumer.value) {
consumer.close() consumer.value.close()
consumer = null consumer.value = null
} }
if (recvTransport) { if (recvTransport.value) {
recvTransport.close() recvTransport.value.close()
recvTransport = null recvTransport.value = null
} }
device = null device.value = null
if (videoRef.value) { if (videoRef.value) {
videoRef.value.srcObject = null videoRef.value.srcObject = null
} }
@@ -308,7 +308,7 @@ watch(
watch( watch(
() => props.session?.hasStream, () => props.session?.hasStream,
(hasStream) => { (hasStream) => {
if (hasStream && props.session?.id && !device) { if (hasStream && props.session?.id && !device.value) {
setupWebRTC() setupWebRTC()
} }
else if (!hasStream) { else if (!hasStream) {

View File

@@ -0,0 +1,133 @@
<template>
<div class="overflow-x-auto rounded border border-kestrel-border">
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b border-kestrel-border bg-kestrel-surface-hover">
<th class="px-4 py-2 font-medium text-kestrel-text">
Identifier
</th>
<th class="px-4 py-2 font-medium text-kestrel-text">
Auth
</th>
<th class="px-4 py-2 font-medium text-kestrel-text">
Role
</th>
<th
v-if="isAdmin"
class="px-4 py-2 font-medium text-kestrel-text"
>
Actions
</th>
</tr>
</thead>
<tbody>
<tr
v-for="u in users"
:key="u.id"
class="border-b border-kestrel-border"
>
<td class="px-4 py-2 text-kestrel-text">
{{ u.identifier }}
</td>
<td class="px-4 py-2">
<span
class="rounded px-1.5 py-0.5 text-xs text-kestrel-muted"
:class="u.auth_provider === 'oidc' ? 'bg-kestrel-surface' : ''"
>
{{ u.auth_provider === 'oidc' ? 'OIDC' : 'Local' }}
</span>
</td>
<td class="px-4 py-2">
<AppDropdown
v-if="isAdmin"
:open="openRoleDropdownId === u.id"
teleport
@close="emit('closeRoleDropdown')"
>
<button
type="button"
class="flex min-w-[6rem] items-center justify-between gap-2 rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-left text-sm text-kestrel-text shadow-sm transition-colors hover:border-kestrel-accent/50 hover:bg-kestrel-surface"
:aria-expanded="openRoleDropdownId === u.id"
:aria-haspopup="true"
aria-label="Change role"
@click.stop="emit('toggleRoleDropdown', u.id)"
>
<span>{{ roleByUserId[u.id] ?? u.role }}</span>
<span
class="text-kestrel-muted transition-transform"
:class="openRoleDropdownId === u.id && 'rotate-180'"
>
</span>
</button>
<template #menu>
<button
v-for="role in roleOptions"
:key="role"
type="button"
role="menuitem"
class="block w-full px-3 py-1.5 text-left text-sm transition-colors"
:class="roleByUserId[u.id] === role
? 'bg-kestrel-accent-dim text-kestrel-accent'
: 'text-kestrel-text hover:bg-kestrel-border hover:text-kestrel-text'"
@click.stop="emit('selectRole', u.id, role)"
>
{{ role }}
</button>
</template>
</AppDropdown>
<span
v-else
class="text-kestrel-muted"
>{{ u.role }}</span>
</td>
<td
v-if="isAdmin"
class="px-4 py-2"
>
<div class="flex flex-wrap items-center gap-2">
<button
v-if="roleByUserId[u.id] !== u.role"
type="button"
class="rounded border border-kestrel-accent px-2 py-1 text-xs text-kestrel-accent hover:bg-kestrel-accent-dim"
@click="emit('saveRole', u.id)"
>
Save role
</button>
<template v-if="u.auth_provider !== 'oidc'">
<button
type="button"
class="rounded border border-kestrel-border px-2 py-1 text-xs text-kestrel-text hover:bg-kestrel-surface"
@click="emit('editUser', u)"
>
Edit
</button>
<button
v-if="u.id !== currentUserId"
type="button"
class="rounded border border-red-500/60 px-2 py-1 text-xs text-red-400 hover:bg-red-500/10"
@click="emit('deleteConfirm', u)"
>
Remove
</button>
</template>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup>
defineProps({
users: { type: Array, required: true },
roleByUserId: { type: Object, required: true },
roleOptions: { type: Array, required: true },
isAdmin: Boolean,
currentUserId: { type: [String, Number], default: null },
openRoleDropdownId: { type: [String, Number], default: null },
})
const emit = defineEmits(['toggleRoleDropdown', 'closeRoleDropdown', 'selectRole', 'saveRole', 'editUser', 'deleteConfirm'])
</script>

View File

@@ -1,8 +1,8 @@
<template> <template>
<Teleport to="body"> <div class="flex h-full shrink-0">
<Transition name="drawer-backdrop"> <Transition name="drawer-backdrop">
<button <button
v-if="modelValue" v-if="isMobile && modelValue"
type="button" type="button"
class="fixed inset-0 z-20 block h-full w-full border-0 bg-black/50 p-0 md:hidden" class="fixed inset-0 z-20 block h-full w-full border-0 bg-black/50 p-0 md:hidden"
aria-label="Close navigation" aria-label="Close navigation"
@@ -10,28 +10,29 @@
/> />
</Transition> </Transition>
<aside <aside
class="nav-drawer fixed left-0 top-0 z-30 flex h-full w-[260px] flex-col border-r border-kestrel-border bg-kestrel-surface transition-transform duration-200 ease-out" class="nav-drawer flex h-full flex-col bg-kestrel-surface transition-[width] duration-200 ease-out md:relative md:translate-x-0"
:class="{ '-translate-x-full': !modelValue }" :class="[
isMobile && !modelValue ? 'fixed left-0 top-14 z-30 -translate-x-full' : 'fixed left-0 top-14 z-30 md:relative md:top-0',
showCollapsed ? 'w-16' : 'w-[260px]',
]"
role="navigation" role="navigation"
aria-label="Main navigation" aria-label="Main navigation"
:aria-expanded="modelValue" :aria-expanded="modelValue"
> >
<div <div
class="flex h-14 shrink-0 items-center justify-between border-b border-kestrel-border bg-kestrel-surface px-4 shadow-glow-sm [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]" v-if="isMounted && isMobile"
class="flex shrink-0 items-center justify-end border-b border-kestrel-border bg-kestrel-surface px-2 py-1"
> >
<h2 class="text-sm font-medium uppercase tracking-wider text-kestrel-muted">
Navigation
</h2>
<button <button
type="button" type="button"
class="rounded p-1 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent" class="kestrel-close-btn"
aria-label="Close navigation" aria-label="Close navigation"
@click="close" @click="close"
> >
<span class="text-xl leading-none">&times;</span> <span class="text-xl leading-none">&times;</span>
</button> </button>
</div> </div>
<nav class="flex-1 overflow-auto py-2"> <nav class="flex-1 overflow-auto bg-kestrel-surface py-2">
<ul class="space-y-0.5 px-2"> <ul class="space-y-0.5 px-2">
<li <li
v-for="item in navItems" v-for="item in navItems"
@@ -39,50 +40,91 @@
> >
<NuxtLink <NuxtLink
:to="item.to" :to="item.to"
class="block rounded px-3 py-2 text-sm transition-colors" class="flex items-center gap-3 rounded px-3 py-2 text-sm transition-colors"
:class="isActive(item.to) :class="[
? 'border-l-2 border-kestrel-accent bg-kestrel-surface-hover font-medium text-kestrel-accent [text-shadow:0_0_8px_rgba(34,201,201,0.25)]' showCollapsed ? 'justify-center px-2' : '',
: 'border-l-2 border-transparent text-kestrel-muted hover:bg-kestrel-border hover:text-kestrel-text'" isActive(item.to)
@click="close" ? 'bg-kestrel-surface-hover font-medium text-kestrel-accent text-shadow-glow-sm'
: 'text-kestrel-muted hover:bg-kestrel-border hover:text-kestrel-text',
!showCollapsed && (isActive(item.to) ? 'border-l-2 border-kestrel-accent' : 'border-l-2 border-transparent'),
]"
:title="showCollapsed ? item.label : undefined"
@click="isMobile ? close() : undefined"
> >
{{ item.label }} <Icon
:name="item.icon"
class="size-5 shrink-0"
aria-hidden="true"
/>
<span
v-show="!showCollapsed"
class="truncate"
>{{ item.label }}</span>
</NuxtLink> </NuxtLink>
</li> </li>
</ul> </ul>
</nav> </nav>
<div
v-if="isMounted && !isMobile"
class="shrink-0 border-t border-kestrel-border bg-kestrel-surface py-2"
>
<button
type="button"
class="flex w-full items-center gap-3 rounded px-3 py-2 text-sm text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-text"
:class="showCollapsed ? 'justify-center px-2' : ''"
:aria-label="showCollapsed ? 'Expand sidebar' : 'Collapse sidebar'"
@click="toggleCollapsed"
>
<Icon
:name="showCollapsed ? 'tabler:chevron-right' : 'tabler:chevron-left'"
class="size-5 shrink-0"
aria-hidden="true"
/>
<span v-show="!showCollapsed">Collapse sidebar</span>
</button>
</div>
</aside> </aside>
</Teleport> </div>
</template> </template>
<script setup> <script setup>
defineProps({ const props = defineProps({
modelValue: { modelValue: { type: Boolean, default: false },
type: Boolean, collapsed: { type: Boolean, default: false },
default: false, isMobile: { type: Boolean, default: true },
},
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue', 'update:collapsed'])
const isMounted = ref(false)
const route = useRoute() const route = useRoute()
const { canEditPois } = useUser() const { canEditPois } = useUser()
const NAV_ITEMS = Object.freeze([
{ to: '/', label: 'Map', icon: 'tabler:map' },
{ to: '/cameras', label: 'Cameras', icon: 'tabler:video' },
{ to: '/poi', label: 'POI', icon: 'tabler:map-pin' },
{ to: '/members', label: 'Members', icon: 'tabler:users' },
{ to: '/account', label: 'Account', icon: 'tabler:user-circle' },
{ to: '/settings', label: 'Settings', icon: 'tabler:settings' },
])
const SHARE_LIVE_ITEM = { to: '/share-live', label: 'Share live', icon: 'tabler:live-photo' }
const navItems = computed(() => { const navItems = computed(() => {
const items = [ if (!canEditPois.value) return NAV_ITEMS
{ to: '/', label: 'Map' }, const list = [...NAV_ITEMS]
{ to: '/account', label: 'Account' }, list.splice(3, 0, SHARE_LIVE_ITEM)
{ to: '/cameras', label: 'Cameras' }, return list
{ to: '/poi', label: 'POI' },
{ to: '/members', label: 'Members' },
{ to: '/settings', label: 'Settings' },
]
if (canEditPois.value) {
items.splice(1, 0, { to: '/share-live', label: 'Share live' })
}
return items
}) })
const isActive = to => to === '/' ? route.path === '/' : route.path.startsWith(to) const showCollapsed = computed(() => props.collapsed && !props.isMobile)
function toggleCollapsed() {
emit('update:collapsed', !props.collapsed)
}
const isActive = to => (to === '/' ? route.path === '/' : route.path.startsWith(to))
function close() { function close() {
emit('update:modelValue', false) emit('update:modelValue', false)
@@ -95,6 +137,7 @@ function onEscape(e) {
defineExpose({ close }) defineExpose({ close })
onMounted(() => { onMounted(() => {
isMounted.value = true
document.addEventListener('keydown', onEscape) document.addEventListener('keydown', onEscape)
}) })
@@ -102,24 +145,3 @@ onBeforeUnmount(() => {
document.removeEventListener('keydown', onEscape) document.removeEventListener('keydown', onEscape)
}) })
</script> </script>
<style scoped>
.drawer-backdrop-enter-active,
.drawer-backdrop-leave-active {
transition: opacity 0.2s ease;
}
.drawer-backdrop-enter-from,
.drawer-backdrop-leave-to {
opacity: 0;
}
/* Same elevation as content: no right-edge shadow on desktop so drawer and navbar read as one layer */
.nav-drawer {
box-shadow: 8px 0 24px -4px rgba(34, 201, 201, 0.12);
}
@media (min-width: 768px) {
.nav-drawer {
box-shadow: none;
}
}
</style>

175
app/components/PoiModal.vue Normal file
View File

@@ -0,0 +1,175 @@
<template>
<BaseModal
:show="show"
:aria-labelledby="mode === 'delete' ? 'delete-poi-title' : 'poi-modal-title'"
@close="$emit('close')"
>
<div
v-if="mode === 'add' || mode === 'edit'"
ref="modalRef"
class="kestrel-card-modal relative w-full max-w-md p-6"
>
<h2
id="poi-modal-title"
class="kestrel-section-heading mb-4"
>
{{ mode === 'edit' ? 'Edit POI' : 'Add POI' }}
</h2>
<form
class="space-y-4"
@submit.prevent="$emit('submit', { label: localForm.label, iconType: localForm.iconType })"
>
<div>
<label
for="add-poi-label"
class="kestrel-label"
>Label (optional)</label>
<input
id="add-poi-label"
v-model="localForm.label"
type="text"
placeholder="e.g. Rally point"
class="kestrel-input"
autocomplete="off"
>
</div>
<div
ref="iconRef"
class="relative inline-block w-full"
>
<label class="kestrel-label">Icon type</label>
<button
type="button"
class="flex w-full min-w-0 items-center justify-between gap-2 rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-left text-sm text-kestrel-text transition-colors hover:border-kestrel-accent/50"
:aria-expanded="iconOpen"
aria-haspopup="listbox"
:aria-label="`Icon type: ${localForm.iconType}`"
@click="iconOpen = !iconOpen"
>
<span class="flex items-center gap-2 capitalize">
<Icon
:name="POI_ICONIFY_IDS[localForm.iconType]"
class="size-4 shrink-0"
/>
{{ localForm.iconType }}
</span>
<span
class="text-kestrel-muted transition-transform"
:class="iconOpen && 'rotate-180'"
></span>
</button>
<Transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-show="iconOpen"
class="absolute left-0 right-0 top-full z-10 mt-1 rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow shadow-glow-dropdown"
role="listbox"
>
<button
v-for="opt in POI_ICON_TYPES"
:key="opt"
type="button"
role="option"
:aria-selected="localForm.iconType === opt"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm capitalize transition-colors"
:class="localForm.iconType === opt ? 'bg-kestrel-accent-dim text-kestrel-accent' : 'text-kestrel-text hover:bg-kestrel-border'"
@click="localForm.iconType = opt; iconOpen = false"
>
<Icon
:name="POI_ICONIFY_IDS[opt]"
class="size-4 shrink-0"
/>
{{ opt }}
</button>
</div>
</Transition>
</div>
<div class="flex justify-end gap-2 pt-2">
<button
type="button"
class="kestrel-btn-secondary"
@click="$emit('close')"
>
Cancel
</button>
<button
type="submit"
class="rounded bg-kestrel-accent px-4 py-2 text-sm font-medium text-kestrel-bg transition-opacity hover:opacity-90"
>
{{ mode === 'edit' ? 'Save changes' : 'Add POI' }}
</button>
</div>
</form>
</div>
<div
v-else-if="mode === 'delete'"
ref="modalRef"
class="kestrel-card-modal relative w-full max-w-sm p-6"
>
<h2
id="delete-poi-title"
class="kestrel-section-heading mb-2"
>
Delete POI?
</h2>
<p class="mb-4 text-sm text-kestrel-muted">
{{ deletePoi?.label ? `"${deletePoi.label}" will be removed.` : 'This POI will be removed.' }}
</p>
<div class="flex justify-end gap-2">
<button
type="button"
class="kestrel-btn-secondary"
@click="$emit('close')"
>
Cancel
</button>
<button
type="button"
class="rounded bg-red-600 px-4 py-2 text-sm font-medium text-white transition-opacity hover:opacity-90"
@click="$emit('confirmDelete')"
>
Delete
</button>
</div>
</div>
</BaseModal>
</template>
<script setup>
const POI_ICONIFY_IDS = { pin: 'tabler:map-pin', flag: 'tabler:flag', waypoint: 'tabler:current-location' }
const POI_ICON_TYPES = Object.keys(POI_ICONIFY_IDS)
const props = defineProps({
show: Boolean,
mode: { type: String, default: 'add' },
form: { type: Object, default: () => ({ label: '', iconType: 'pin' }) },
editPoi: { type: Object, default: null },
deletePoi: { type: Object, default: null },
})
defineEmits(['close', 'submit', 'confirmDelete'])
const modalRef = ref(null)
const iconRef = ref(null)
const iconOpen = ref(false)
const localForm = ref({ label: '', iconType: 'pin' })
watch(() => props.show, (show) => {
if (!show) return
iconOpen.value = false
localForm.value = props.mode === 'edit' && props.editPoi
? { label: (props.editPoi.label ?? '').trim(), iconType: props.editPoi.icon_type || 'pin' }
: { ...props.form }
})
function onDocClick(e) {
if (iconOpen.value && iconRef.value && !iconRef.value.contains(e.target)) iconOpen.value = false
}
onMounted(() => document.addEventListener('click', onDocClick))
onBeforeUnmount(() => document.removeEventListener('click', onDocClick))
</script>

View File

@@ -0,0 +1,84 @@
<template>
<AppDropdown
:open="open"
@close="open = false"
>
<button
type="button"
class="flex rounded-full border border-kestrel-border bg-kestrel-surface p-0.5 transition-colors hover:bg-kestrel-border hover:border-kestrel-accent"
aria-label="User menu"
:aria-expanded="open"
aria-haspopup="true"
@click="open = !open"
>
<img
v-if="user?.avatar_url"
:src="user.avatar_url"
:alt="user.identifier"
class="h-8 w-8 rounded-full object-cover"
>
<span
v-else
class="flex h-8 w-8 items-center justify-center rounded-full bg-kestrel-border text-xs font-medium text-kestrel-text"
>
{{ initials }}
</span>
</button>
<template #menu>
<NuxtLink
to="/account"
class="kestrel-context-menu-item"
role="menuitem"
@click="open = false"
>
Profile
</NuxtLink>
<NuxtLink
to="/settings"
class="kestrel-context-menu-item"
role="menuitem"
@click="open = false"
>
Settings
</NuxtLink>
<button
type="button"
class="kestrel-context-menu-item-danger w-full"
role="menuitem"
@click="onSignOut"
>
Sign out
</button>
</template>
</AppDropdown>
</template>
<script setup>
const props = defineProps({
user: {
type: Object,
default: null,
},
})
const emit = defineEmits(['signout'])
const open = ref(false)
const initials = computed(() => {
const id = props.user?.identifier ?? ''
const parts = id.trim().split(/\s+/)
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase()
return id.slice(0, 2).toUpperCase() || '?'
})
function onSignOut() {
open.value = false
emit('signout')
}
const route = useRoute()
watch(() => route.path, () => {
open.value = false
})
</script>

View File

@@ -0,0 +1,12 @@
/** Auto-closes selectedCamera when the selected live session disappears from liveSessions. */
export function useAutoCloseLiveSession(selectedCamera, liveSessions) {
watch(
[() => selectedCamera.value, () => liveSessions.value],
([sel, sessions]) => {
if (!sel || typeof sel.hasStream === 'undefined') return
const stillActive = (sessions ?? []).some(s => s.id === sel.id)
if (!stillActive) selectedCamera.value = null
},
{ deep: true },
)
}

View File

@@ -1,16 +1,20 @@
/** /** Fetches devices + live sessions; polls when tab visible. */
* Fetches devices + live sessions (unified cameras). Optionally polls when tab is visible.
*/
const POLL_MS = 1500 const POLL_MS = 1500
const EMPTY_RESPONSE = Object.freeze({ devices: [], liveSessions: [], cotEntities: [] })
export function useCameras(options = {}) { export function useCameras(options = {}) {
const { poll: enablePoll = true } = options const { poll: enablePoll = true } = options
const { data, refresh } = useAsyncData( const { data, refresh } = useAsyncData(
'cameras', 'cameras',
() => $fetch('/api/cameras').catch(() => ({ devices: [], liveSessions: [] })), () => $fetch('/api/cameras').catch(() => EMPTY_RESPONSE),
{ default: () => ({ devices: [], liveSessions: [] }) }, { default: () => EMPTY_RESPONSE },
) )
const devices = computed(() => Object.freeze([...(data.value?.devices ?? [])]))
const liveSessions = computed(() => Object.freeze([...(data.value?.liveSessions ?? [])]))
const cotEntities = computed(() => Object.freeze([...(data.value?.cotEntities ?? [])]))
const cameras = computed(() => Object.freeze([...devices.value, ...liveSessions.value]))
const pollInterval = ref(null) const pollInterval = ref(null)
function startPolling() { function startPolling() {
if (!enablePoll || pollInterval.value) return if (!enablePoll || pollInterval.value) return
@@ -27,22 +31,11 @@ export function useCameras(options = {}) {
onMounted(() => { onMounted(() => {
if (typeof document === 'undefined') return if (typeof document === 'undefined') return
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') { document.visibilityState === 'visible' ? (startPolling(), refresh()) : stopPolling()
startPolling()
refresh()
}
else {
stopPolling()
}
}) })
if (document.visibilityState === 'visible') startPolling() if (document.visibilityState === 'visible') startPolling()
}) })
onBeforeUnmount(stopPolling) onBeforeUnmount(stopPolling)
const devices = computed(() => data.value?.devices ?? []) return Object.freeze({ data, devices, liveSessions, cotEntities, cameras, refresh, startPolling, stopPolling })
const liveSessions = computed(() => data.value?.liveSessions ?? [])
/** All cameras: devices first, then live sessions */
const cameras = computed(() => [...devices.value, ...liveSessions.value])
return { data, devices, liveSessions, cameras, refresh, startPolling, stopPolling }
} }

View File

@@ -1,24 +1,12 @@
/** /** Fetches live sessions; polls when tab visible. */
* Fetches active live sessions (camera + location sharing) and refreshes on an interval.
* Only runs when the app is focused so we don't poll in the background.
*/
const POLL_MS = 1500 const POLL_MS = 1500
export function useLiveSessions() { export function useLiveSessions() {
const { data: sessions, refresh } = useAsyncData( const { data: _sessions, refresh } = useAsyncData(
'live-sessions', 'live-sessions',
async () => { async () => {
try { try {
const result = await $fetch('/api/live') return await $fetch('/api/live')
if (process.env.NODE_ENV === 'development') {
console.log('[useLiveSessions] Fetched sessions:', result.map(s => ({
id: s.id,
label: s.label,
hasStream: s.hasStream,
})))
}
return result
} }
catch (err) { catch (err) {
const msg = err?.message ?? String(err) const msg = err?.message ?? String(err)
@@ -30,14 +18,13 @@ export function useLiveSessions() {
{ default: () => [] }, { default: () => [] },
) )
const sessions = computed(() => Object.freeze([...(_sessions.value ?? [])]))
const pollInterval = ref(null) const pollInterval = ref(null)
function startPolling() { function startPolling() {
if (pollInterval.value) return if (pollInterval.value) return
refresh() // Fetch immediately so new sessions show without waiting for first interval refresh()
pollInterval.value = setInterval(() => { pollInterval.value = setInterval(refresh, POLL_MS)
refresh()
}, POLL_MS)
} }
function stopPolling() { function stopPolling() {
@@ -49,21 +36,12 @@ export function useLiveSessions() {
onMounted(() => { onMounted(() => {
if (typeof document === 'undefined') return if (typeof document === 'undefined') return
const onFocus = () => startPolling()
const onBlur = () => stopPolling()
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') { document.visibilityState === 'visible' ? (startPolling(), refresh()) : stopPolling()
onFocus()
refresh() // Fresh data when returning to tab
}
else onBlur()
}) })
if (document.visibilityState === 'visible') startPolling() if (document.visibilityState === 'visible') startPolling()
}) })
onBeforeUnmount(stopPolling)
onBeforeUnmount(() => { return Object.freeze({ sessions, refresh, startPolling, stopPolling })
stopPolling()
})
return { sessions, refresh, startPolling, stopPolling }
} }

View File

@@ -0,0 +1,21 @@
/**
* Reactive viewport media query. SSR-safe: defaults to true (mobile) so sidebar closed on first paint.
* @param {string} query - CSS media query, e.g. '(max-width: 767px)'
* @returns {import('vue').Ref<boolean>} Ref that is true when the media query matches.
*/
export function useMediaQuery(query) {
const matches = ref(true)
const mql = ref(null)
const handler = (e) => {
matches.value = e.matches
}
onMounted(() => {
mql.value = window.matchMedia(query)
matches.value = mql.value.matches
mql.value.addEventListener('change', handler)
})
onBeforeUnmount(() => {
if (mql.value) mql.value.removeEventListener('change', handler)
})
return matches
}

View File

@@ -1,3 +1,5 @@
const EDIT_ROLES = Object.freeze(['admin', 'leader'])
export function useUser() { export function useUser() {
const requestFetch = useRequestFetch() const requestFetch = useRequestFetch()
const { data: user, refresh } = useAsyncData( const { data: user, refresh } = useAsyncData(
@@ -5,7 +7,7 @@ export function useUser() {
() => (requestFetch ?? $fetch)('/api/me').catch(() => null), () => (requestFetch ?? $fetch)('/api/me').catch(() => null),
{ default: () => null }, { default: () => null },
) )
const canEditPois = computed(() => user.value?.role === 'admin' || user.value?.role === 'leader') const canEditPois = computed(() => EDIT_ROLES.includes(user.value?.role))
const isAdmin = computed(() => user.value?.role === 'admin') const isAdmin = computed(() => user.value?.role === 'admin')
return { user, canEditPois, isAdmin, refresh } return Object.freeze({ user, canEditPois, isAdmin, refresh })
} }

View File

@@ -1,61 +1,26 @@
/** /** WebRTC/Mediasoup client utilities. */
* WebRTC composable for Mediasoup client operations.
* Handles device initialization, transport creation, and WebSocket signaling.
*/
import { logError, logWarn } from '../utils/logger.js' import { logError, logWarn } from '../utils/logger.js'
/** const FETCH_OPTS = { credentials: 'include' }
* Initialize Mediasoup device from router RTP capabilities.
* @param {object} rtpCapabilities
* @returns {Promise<object>} Mediasoup device
*/
export async function createMediasoupDevice(rtpCapabilities) {
// Dynamically import mediasoup-client only in browser
if (typeof window === 'undefined') {
throw new TypeError('Mediasoup device can only be created in browser')
}
// Use dynamic import for mediasoup-client export async function createMediasoupDevice(rtpCapabilities) {
if (typeof window === 'undefined') throw new TypeError('Mediasoup device can only be created in browser')
const { Device } = await import('mediasoup-client') const { Device } = await import('mediasoup-client')
const device = new Device() const device = new Device()
await device.load({ routerRtpCapabilities: rtpCapabilities }) await device.load({ routerRtpCapabilities: rtpCapabilities })
return device return device
} }
/**
* Create WebSocket connection for signaling.
* @param {string} url - WebSocket URL (e.g., 'ws://localhost:3000/ws')
* @returns {Promise<WebSocket>} WebSocket connection
*/
export function createWebSocketConnection(url) { export function createWebSocketConnection(url) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = url.startsWith('ws') ? url : `${protocol}//${window.location.host}/ws` const wsUrl = url.startsWith('ws') ? url : `${protocol}//${window.location.host}/ws`
const ws = new WebSocket(wsUrl) const ws = new WebSocket(wsUrl)
ws.onopen = () => resolve(ws)
ws.onopen = () => { ws.onerror = () => reject(new Error('WebSocket connection failed'))
resolve(ws)
}
ws.onerror = () => {
reject(new Error('WebSocket connection failed'))
}
ws.onclose = () => {
// Connection closed
}
}) })
} }
/**
* Send WebSocket message and wait for response.
* @param {WebSocket} ws
* @param {string} sessionId
* @param {string} type
* @param {object} data
* @returns {Promise<object>} Response message
*/
export function sendWebSocketMessage(ws, sessionId, type, data = {}) { export function sendWebSocketMessage(ws, sessionId, type, data = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (ws.readyState !== WebSocket.OPEN) { if (ws.readyState !== WebSocket.OPEN) {
@@ -95,41 +60,20 @@ export function sendWebSocketMessage(ws, sessionId, type, data = {}) {
}) })
} }
/** function attachTransportHandlers(transport, transportParams, sessionId, label, { onConnectSuccess, onConnectFailure } = {}) {
* Create send transport (for publisher).
* @param {object} device
* @param {string} sessionId
* @param {{ onConnectSuccess?: () => void, onConnectFailure?: (err: Error) => void }} [options] - Optional callbacks when transport connect succeeds or fails.
* @returns {Promise<object>} Transport with send method
*/
export async function createSendTransport(device, sessionId, options = {}) {
const { onConnectSuccess, onConnectFailure } = options
// Create transport via HTTP API
const transportParams = await $fetch('/api/live/webrtc/create-transport', {
method: 'POST',
body: { sessionId, isProducer: true },
credentials: 'include',
})
const transport = device.createSendTransport({
id: transportParams.id,
iceParameters: transportParams.iceParameters,
iceCandidates: transportParams.iceCandidates,
dtlsParameters: transportParams.dtlsParameters,
})
transport.on('connect', async ({ dtlsParameters }, callback, errback) => { transport.on('connect', async ({ dtlsParameters }, callback, errback) => {
try { try {
await $fetch('/api/live/webrtc/connect-transport', { await $fetch('/api/live/webrtc/connect-transport', {
method: 'POST', method: 'POST',
body: { sessionId, transportId: transportParams.id, dtlsParameters }, body: { sessionId, transportId: transportParams.id, dtlsParameters },
credentials: 'include', ...FETCH_OPTS,
}) })
onConnectSuccess?.() onConnectSuccess?.()
callback() callback()
} }
catch (err) { catch (err) {
logError('useWebRTC: Send transport connect failed', { logError(`useWebRTC: ${label} transport connect failed`, {
err: err.message || String(err), err: err?.message ?? String(err),
transportId: transportParams.id, transportId: transportParams.id,
connectionState: transport.connectionState, connectionState: transport.connectionState,
sessionId, sessionId,
@@ -138,48 +82,50 @@ export async function createSendTransport(device, sessionId, options = {}) {
errback(err) errback(err)
} }
}) })
transport.on('connectionstatechange', () => { transport.on('connectionstatechange', () => {
const state = transport.connectionState const state = transport.connectionState
if (state === 'failed' || state === 'disconnected' || state === 'closed') { if (['failed', 'disconnected', 'closed'].includes(state)) {
logWarn('useWebRTC: Send transport connection state changed', { logWarn(`useWebRTC: ${label} transport connection state changed`, { state, transportId: transportParams.id, sessionId })
state,
transportId: transportParams.id,
sessionId,
})
} }
}) })
}
export async function createSendTransport(device, sessionId, options = {}) {
const transportParams = await $fetch('/api/live/webrtc/create-transport', {
method: 'POST',
body: { sessionId, isProducer: true },
...FETCH_OPTS,
})
const transport = device.createSendTransport({
id: transportParams.id,
iceParameters: transportParams.iceParameters,
iceCandidates: transportParams.iceCandidates,
dtlsParameters: transportParams.dtlsParameters,
})
attachTransportHandlers(transport, transportParams, sessionId, 'Send', options)
transport.on('produce', async ({ kind, rtpParameters }, callback, errback) => { transport.on('produce', async ({ kind, rtpParameters }, callback, errback) => {
try { try {
const { id } = await $fetch('/api/live/webrtc/create-producer', { const { id } = await $fetch('/api/live/webrtc/create-producer', {
method: 'POST', method: 'POST',
body: { sessionId, transportId: transportParams.id, kind, rtpParameters }, body: { sessionId, transportId: transportParams.id, kind, rtpParameters },
credentials: 'include', ...FETCH_OPTS,
}) })
callback({ id }) callback({ id })
} }
catch (err) { catch (err) {
logError('useWebRTC: Producer creation failed', { err: err.message || String(err) }) logError('useWebRTC: Producer creation failed', { err: err?.message ?? String(err) })
errback(err) errback(err)
} }
}) })
return transport return transport
} }
/**
* Create receive transport (for viewer).
* @param {object} device
* @param {string} sessionId
* @returns {Promise<object>} Transport with consume method
*/
export async function createRecvTransport(device, sessionId) { export async function createRecvTransport(device, sessionId) {
// Create transport via HTTP API
const transportParams = await $fetch('/api/live/webrtc/create-transport', { const transportParams = await $fetch('/api/live/webrtc/create-transport', {
method: 'POST', method: 'POST',
body: { sessionId, isProducer: false }, body: { sessionId, isProducer: false },
credentials: 'include', ...FETCH_OPTS,
}) })
const transport = device.createRecvTransport({ const transport = device.createRecvTransport({
id: transportParams.id, id: transportParams.id,
@@ -187,55 +133,15 @@ export async function createRecvTransport(device, sessionId) {
iceCandidates: transportParams.iceCandidates, iceCandidates: transportParams.iceCandidates,
dtlsParameters: transportParams.dtlsParameters, dtlsParameters: transportParams.dtlsParameters,
}) })
attachTransportHandlers(transport, transportParams, sessionId, 'Recv')
// Set up connect handler (will be called by mediasoup-client when needed)
transport.on('connect', async ({ dtlsParameters }, callback, errback) => {
try {
await $fetch('/api/live/webrtc/connect-transport', {
method: 'POST',
body: { sessionId, transportId: transportParams.id, dtlsParameters },
credentials: 'include',
})
callback()
}
catch (err) {
logError('useWebRTC: Recv transport connect failed', {
err: err.message || String(err),
transportId: transportParams.id,
connectionState: transport.connectionState,
sessionId,
})
errback(err)
}
})
transport.on('connectionstatechange', () => {
const state = transport.connectionState
if (state === 'failed' || state === 'disconnected' || state === 'closed') {
logWarn('useWebRTC: Recv transport connection state changed', {
state,
transportId: transportParams.id,
sessionId,
})
}
})
return transport return transport
} }
/**
* Consume producer's stream (for viewer).
* @param {object} transport
* @param {object} device
* @param {string} sessionId
* @returns {Promise<object>} Consumer with track
*/
export async function consumeProducer(transport, device, sessionId) { export async function consumeProducer(transport, device, sessionId) {
const rtpCapabilities = device.rtpCapabilities
const consumerParams = await $fetch('/api/live/webrtc/create-consumer', { const consumerParams = await $fetch('/api/live/webrtc/create-consumer', {
method: 'POST', method: 'POST',
body: { sessionId, transportId: transport.id, rtpCapabilities }, body: { sessionId, transportId: transport.id, rtpCapabilities: device.rtpCapabilities },
credentials: 'include', ...FETCH_OPTS,
}) })
const consumer = await transport.consume({ const consumer = await transport.consume({
@@ -256,14 +162,6 @@ export async function consumeProducer(transport, device, sessionId) {
return consumer return consumer
} }
/**
* Resolve when condition() returns truthy, or after timeoutMs (then resolve anyway).
* No mutable shared state; cleanup on first completion.
* @param {() => unknown} condition
* @param {number} timeoutMs
* @param {number} intervalMs
* @returns {Promise<void>}
*/
function waitForCondition(condition, timeoutMs = 3000, intervalMs = 100) { function waitForCondition(condition, timeoutMs = 3000, intervalMs = 100) {
return new Promise((resolve) => { return new Promise((resolve) => {
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
@@ -285,27 +183,21 @@ function waitForCondition(condition, timeoutMs = 3000, intervalMs = 100) {
}) })
} }
/**
* Wait for transport connection state to reach a terminal state or timeout.
* @param {object} transport - Mediasoup transport with connectionState and on/off
* @param {number} timeoutMs
* @returns {Promise<string>} Final connection state
*/
export function waitForConnectionState(transport, timeoutMs = 10000) { export function waitForConnectionState(transport, timeoutMs = 10000) {
const terminal = ['connected', 'failed', 'disconnected', 'closed'] const terminal = ['connected', 'failed', 'disconnected', 'closed']
return new Promise((resolve) => { return new Promise((resolve) => {
let tid const tid = ref(null)
const handler = () => { const handler = () => {
const state = transport.connectionState const state = transport.connectionState
if (terminal.includes(state)) { if (terminal.includes(state)) {
transport.off('connectionstatechange', handler) transport.off('connectionstatechange', handler)
if (tid) clearTimeout(tid) if (tid.value) clearTimeout(tid.value)
resolve(state) resolve(state)
} }
} }
transport.on('connectionstatechange', handler) transport.on('connectionstatechange', handler)
handler() handler()
tid = setTimeout(() => { tid.value = setTimeout(() => {
transport.off('connectionstatechange', handler) transport.off('connectionstatechange', handler)
resolve(transport.connectionState) resolve(transport.connectionState)
}, timeoutMs) }, timeoutMs)

View File

@@ -1,18 +1,13 @@
/** /** Pure: fetches WebRTC failure reason (e.g. wrong host). Returns frozen object. */
* Fetch WebRTC failure reason (e.g. wrong host). Pure: same inputs → same output.
* @returns {Promise<{ wrongHost: { serverHostname: string, clientHostname: string } | null }>} Failure reason or null.
*/
export async function getWebRTCFailureReason() { export async function getWebRTCFailureReason() {
try { try {
const res = await $fetch('/api/live/debug-request-host', { credentials: 'include' }) const res = await $fetch('/api/live/debug-request-host', { credentials: 'include' })
const clientHostname = typeof window !== 'undefined' ? window.location.hostname : '' const clientHostname = typeof window !== 'undefined' ? window.location.hostname : ''
const serverHostname = res?.hostname ?? '' const serverHostname = res?.hostname ?? ''
if (serverHostname && clientHostname && serverHostname !== clientHostname) { if (serverHostname && clientHostname && serverHostname !== clientHostname) {
return { wrongHost: { serverHostname, clientHostname } } return Object.freeze({ wrongHost: Object.freeze({ serverHostname, clientHostname }) })
} }
} }
catch { catch { /* ignore */ }
// ignore return Object.freeze({ wrongHost: null })
}
return { wrongHost: null }
} }

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="flex min-h-screen items-center justify-center bg-kestrel-bg font-mono text-kestrel-text"> <div class="flex min-h-screen items-center justify-center bg-kestrel-bg font-mono text-kestrel-text">
<div class="text-center"> <div class="text-center">
<h1 class="text-2xl font-semibold tracking-wide [text-shadow:0_0_12px_rgba(34,201,201,0.3)]"> <h1 class="text-2xl font-semibold tracking-wide text-shadow-glow-md">
[ Error ] [ Error ]
</h1> </h1>
<p class="mt-2 text-sm text-kestrel-muted"> <p class="mt-2 text-sm text-kestrel-muted">

View File

@@ -1,71 +1,7 @@
<template> <template>
<div class="min-h-screen bg-kestrel-bg text-kestrel-text font-mono flex flex-col"> <div class="flex h-screen flex-col overflow-hidden bg-kestrel-bg font-mono text-kestrel-text">
<div class="relative flex flex-1 min-h-0"> <AppShell>
<NavDrawer v-model="drawerOpen" /> <slot />
<div </AppShell>
class="flex min-h-0 flex-1 flex-col transition-[margin] duration-200 ease-out"
:class="{ 'md:ml-[260px]': drawerOpen }"
>
<header class="flex h-14 shrink-0 items-center gap-3 border-b border-kestrel-border bg-kestrel-surface px-4 shadow-glow-sm [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]">
<button
type="button"
class="rounded p-2 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent"
aria-label="Toggle navigation"
:aria-expanded="drawerOpen"
@click="drawerOpen = !drawerOpen"
>
<span
class="text-lg leading-none"
aria-hidden="true"
>&#9776;</span>
</button>
<div class="min-w-0 flex-1">
<h1 class="text-lg font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_12px_rgba(34,201,201,0.35)]">
KestrelOS
</h1>
<p class="text-xs uppercase tracking-widest text-kestrel-muted">
&gt; Tactical Operations Center OSINT Feeds
</p>
</div>
<div class="flex items-center gap-2">
<template v-if="user">
<span class="text-xs text-kestrel-muted">{{ user.identifier }}</span>
<button
type="button"
class="rounded px-2 py-1 text-xs text-kestrel-muted hover:bg-kestrel-border hover:text-kestrel-accent"
@click="onLogout"
>
Logout
</button>
</template>
<NuxtLink
v-else
to="/login"
class="rounded px-2 py-1 text-xs text-kestrel-muted hover:bg-kestrel-border hover:text-kestrel-accent"
>
Sign in
</NuxtLink>
</div>
</header>
<main class="min-h-0 flex-1">
<slot />
</main>
</div>
</div>
</div> </div>
</template> </template>
<script setup>
const drawerOpen = ref(true)
const { user, refresh } = useUser()
const route = useRoute()
async function onLogout() {
await $fetch('/api/auth/logout', { method: 'POST' })
await refresh()
await navigateTo('/')
}
watch(() => route.path, () => {
drawerOpen.value = false
})
</script>

View File

@@ -1,15 +1,59 @@
<template> <template>
<div class="p-6"> <div class="p-6">
<h2 class="mb-4 text-xl font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]"> <h2 class="kestrel-page-heading mb-4">
Account Account
</h2> </h2>
<!-- Profile --> <section
v-if="user"
class="mb-8"
>
<h3 class="kestrel-section-label">
Profile photo
</h3>
<div class="kestrel-card flex items-center gap-4 p-4">
<div class="flex h-16 w-16 shrink-0 overflow-hidden rounded-full border border-kestrel-border bg-kestrel-border">
<img
v-if="user.avatar_url"
:src="`${user.avatar_url}${avatarBust ? `?t=${avatarBust}` : ''}`"
alt=""
class="h-full w-full object-cover"
>
<span
v-else
class="flex h-full w-full items-center justify-center text-lg font-medium text-kestrel-text"
>
{{ accountInitials }}
</span>
</div>
<div class="flex flex-wrap gap-2">
<label class="kestrel-btn-secondary cursor-pointer">
<input
type="file"
accept="image/jpeg,image/png"
class="sr-only"
:disabled="avatarLoading"
@change="onAvatarFileChange"
>
{{ avatarLoading ? 'Uploading…' : 'Upload' }}
</label>
<button
type="button"
class="kestrel-btn-secondary disabled:opacity-50"
:disabled="avatarLoading || !user.avatar_url"
@click="onRemoveAvatar"
>
Remove
</button>
</div>
</div>
</section>
<section class="mb-8"> <section class="mb-8">
<h3 class="mb-2 text-sm font-medium uppercase tracking-wider text-kestrel-muted"> <h3 class="kestrel-section-label">
Profile Profile
</h3> </h3>
<div class="rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]"> <div class="kestrel-card p-4">
<template v-if="user"> <template v-if="user">
<dl class="space-y-2 text-sm"> <dl class="space-y-2 text-sm">
<div> <div>
@@ -50,15 +94,79 @@
</div> </div>
</section> </section>
<!-- Change password (local only) --> <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 <section
v-if="user?.auth_provider === 'local'" v-if="user?.auth_provider === 'local'"
class="mb-8" class="mb-8"
> >
<h3 class="mb-2 text-sm font-medium uppercase tracking-wider text-kestrel-muted"> <h3 class="kestrel-section-label">
Change password Change password
</h3> </h3>
<div class="rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]"> <div class="kestrel-card p-4">
<p <p
v-if="passwordSuccess" v-if="passwordSuccess"
class="mb-3 text-sm text-green-400" class="mb-3 text-sm text-green-400"
@@ -78,46 +186,40 @@
<div> <div>
<label <label
for="account-current-password" for="account-current-password"
class="mb-1 block text-xs text-kestrel-muted" class="kestrel-label"
> >Current password</label>
Current password
</label>
<input <input
id="account-current-password" id="account-current-password"
v-model="currentPassword" v-model="currentPassword"
type="password" type="password"
autocomplete="current-password" autocomplete="current-password"
class="w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text outline-none focus:border-kestrel-accent" class="kestrel-input"
> >
</div> </div>
<div> <div>
<label <label
for="account-new-password" for="account-new-password"
class="mb-1 block text-xs text-kestrel-muted" class="kestrel-label"
> >New password</label>
New password
</label>
<input <input
id="account-new-password" id="account-new-password"
v-model="newPassword" v-model="newPassword"
type="password" type="password"
autocomplete="new-password" autocomplete="new-password"
class="w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text outline-none focus:border-kestrel-accent" class="kestrel-input"
> >
</div> </div>
<div> <div>
<label <label
for="account-confirm-password" for="account-confirm-password"
class="mb-1 block text-xs text-kestrel-muted" class="kestrel-label"
> >Confirm new password</label>
Confirm new password
</label>
<input <input
id="account-confirm-password" id="account-confirm-password"
v-model="confirmPassword" v-model="confirmPassword"
type="password" type="password"
autocomplete="new-password" autocomplete="new-password"
class="w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text outline-none focus:border-kestrel-accent" class="kestrel-input"
> >
</div> </div>
<button <button
@@ -134,14 +236,60 @@
</template> </template>
<script setup> <script setup>
const { user } = useUser() const { user, refresh } = useUser()
const avatarBust = ref(0)
const avatarLoading = ref(false)
const currentPassword = ref('') const currentPassword = ref('')
const newPassword = ref('') const newPassword = ref('')
const confirmPassword = ref('') const confirmPassword = ref('')
const passwordLoading = ref(false) const passwordLoading = ref(false)
const passwordSuccess = ref(false) const passwordSuccess = ref(false)
const passwordError = ref('') 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 ?? ''
const parts = id.trim().split(/\s+/)
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase()
return id.slice(0, 2).toUpperCase() || '?'
})
async function onAvatarFileChange(e) {
const file = e.target.files?.[0]
if (!file) return
avatarLoading.value = true
try {
const form = new FormData()
form.append('avatar', file, file.name)
await $fetch('/api/me/avatar', { method: 'PUT', body: form, credentials: 'include' })
avatarBust.value = Date.now()
await refresh()
}
catch {
// Error surfaced by refresh or network
}
finally {
avatarLoading.value = false
e.target.value = ''
}
}
async function onRemoveAvatar() {
avatarLoading.value = true
try {
await $fetch('/api/me/avatar', { method: 'DELETE', credentials: 'include' })
avatarBust.value = Date.now()
await refresh()
}
finally {
avatarLoading.value = false
}
}
async function onChangePassword() { async function onChangePassword() {
passwordError.value = '' passwordError.value = ''
@@ -176,4 +324,34 @@ async function onChangePassword() {
passwordLoading.value = false 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> </script>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="p-6"> <div class="p-6">
<h2 class="mb-4 text-xl font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]"> <h2 class="kestrel-page-heading mb-4">
Cameras Cameras
</h2> </h2>
<p class="mb-4 text-sm text-kestrel-muted"> <p class="mb-4 text-sm text-kestrel-muted">
@@ -80,6 +80,8 @@
<script setup> <script setup>
definePageMeta({ layout: 'default' }) definePageMeta({ layout: 'default' })
const { cameras } = useCameras() const { cameras, liveSessions } = useCameras()
const selectedCamera = ref(null) const selectedCamera = ref(null)
useAutoCloseLiveSession(selectedCamera, liveSessions)
</script> </script>

View File

@@ -1,11 +1,12 @@
<template> <template>
<div class="flex h-[calc(100vh-5rem)] w-full flex-col md:flex-row"> <div class="flex h-full w-full flex-col md:flex-row">
<div class="relative h-2/3 w-full md:h-full md:flex-1"> <div class="relative min-h-0 flex-1">
<ClientOnly> <ClientOnly>
<KestrelMap <KestrelMap
:devices="devices ?? []" :devices="devices ?? []"
:pois="pois ?? []" :pois="pois ?? []"
:live-sessions="liveSessions ?? []" :live-sessions="liveSessions ?? []"
:cot-entities="cotEntities ?? []"
:can-edit-pois="canEditPois" :can-edit-pois="canEditPois"
@select="selectedCamera = $event" @select="selectedCamera = $event"
@select-live="onSelectLive($event)" @select-live="onSelectLive($event)"
@@ -22,13 +23,14 @@
</template> </template>
<script setup> <script setup>
const { devices, liveSessions } = useCameras() const { devices, liveSessions, cotEntities } = useCameras()
const { data: pois, refresh: refreshPois } = usePois() const { data: pois, refresh: refreshPois } = usePois()
const { canEditPois } = useUser() const { canEditPois } = useUser()
const selectedCamera = ref(null) const selectedCamera = ref(null)
function onSelectLive(session) { function onSelectLive(session) {
const latest = (liveSessions.value || []).find(s => s.id === session?.id) selectedCamera.value = (liveSessions.value ?? []).find(s => s.id === session?.id) ?? session
selectedCamera.value = latest ?? session
} }
useAutoCloseLiveSession(selectedCamera, liveSessions)
</script> </script>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="flex min-h-[60vh] items-center justify-center p-6"> <div class="flex min-h-[60vh] items-center justify-center p-6">
<div class="w-full max-w-sm rounded border border-kestrel-border bg-kestrel-surface p-6 shadow-glow [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]"> <div class="kestrel-card w-full max-w-sm p-6">
<h2 class="mb-4 text-lg font-semibold text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]"> <h2 class="kestrel-section-heading mb-4">
Sign in Sign in
</h2> </h2>
<p <p
@@ -29,28 +29,28 @@
<div class="mb-3"> <div class="mb-3">
<label <label
for="login-identifier" for="login-identifier"
class="mb-1 block text-xs text-kestrel-muted" class="kestrel-label"
>Email or username</label> >Email or username</label>
<input <input
id="login-identifier" id="login-identifier"
v-model="identifier" v-model="identifier"
type="text" type="text"
autocomplete="username" autocomplete="username"
class="w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text outline-none focus:border-kestrel-accent" class="kestrel-input"
required required
> >
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label <label
for="login-password" for="login-password"
class="mb-1 block text-xs text-kestrel-muted" class="kestrel-label"
>Password</label> >Password</label>
<input <input
id="login-password" id="login-password"
v-model="password" v-model="password"
type="password" type="password"
autocomplete="current-password" autocomplete="current-password"
class="w-full rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-sm text-kestrel-text outline-none focus:border-kestrel-accent" class="kestrel-input"
required required
> >
</div> </div>
@@ -69,16 +69,16 @@
<script setup> <script setup>
const route = useRoute() const route = useRoute()
const redirect = computed(() => route.query.redirect || '/') const redirect = computed(() => route.query.redirect || '/')
const AUTH_CONFIG_DEFAULT = Object.freeze({ oidc: { enabled: false, label: '' } })
const { data: authConfig } = useAsyncData( const { data: authConfig } = useAsyncData(
'auth-config', 'auth-config',
() => $fetch('/api/auth/config').catch(() => ({ oidc: { enabled: false, label: '' } })), () => $fetch('/api/auth/config').catch(() => AUTH_CONFIG_DEFAULT),
{ default: () => null }, { default: () => null },
) )
const showDivider = computed(() => !!authConfig.value?.oidc?.enabled) const showDivider = computed(() => !!authConfig.value?.oidc?.enabled)
const oidcAuthorizeUrl = computed(() => { const oidcAuthorizeUrl = computed(() => {
const base = '/api/auth/oidc/authorize' const r = redirect.value
const q = redirect.value && redirect.value !== '/' ? `?redirect=${encodeURIComponent(redirect.value)}` : '' return `/api/auth/oidc/authorize${r && r !== '/' ? `?redirect=${encodeURIComponent(r)}` : ''}`
return base + q
}) })
const identifier = ref('') const identifier = ref('')

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="p-6"> <div class="p-6">
<h2 class="mb-2 text-xl font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]"> <h2 class="kestrel-page-heading mb-2">
Members Members
</h2> </h2>
<p <p
@@ -10,7 +10,7 @@
Sign in to view members. Sign in to view members.
</p> </p>
<p <p
v-else-if="!canViewMembers" v-else-if="!canEditPois"
class="text-sm text-kestrel-muted" class="text-sm text-kestrel-muted"
> >
You don't have access to the members list. You don't have access to the members list.
@@ -34,371 +34,51 @@
Add user Add user
</button> </button>
</div> </div>
<div class="overflow-x-auto rounded border border-kestrel-border"> <MembersTable
<table class="w-full text-left text-sm"> :users="users"
<thead> :role-by-user-id="roleByUserId"
<tr class="border-b border-kestrel-border bg-kestrel-surface-hover"> :role-options="roleOptions"
<th class="px-4 py-2 font-medium text-kestrel-text"> :is-admin="isAdmin"
Identifier :current-user-id="user?.id ?? null"
</th> :open-role-dropdown-id="openRoleDropdownId"
<th class="px-4 py-2 font-medium text-kestrel-text"> @toggle-role-dropdown="toggleRoleDropdown"
Auth @close-role-dropdown="openRoleDropdownId = null"
</th> @select-role="selectRole"
<th class="px-4 py-2 font-medium text-kestrel-text"> @save-role="saveRole"
Role @edit-user="openEditUser"
</th> @delete-confirm="openDeleteConfirm"
<th />
v-if="isAdmin"
class="px-4 py-2 font-medium text-kestrel-text"
>
Actions
</th>
</tr>
</thead>
<tbody>
<tr
v-for="u in users"
:key="u.id"
class="border-b border-kestrel-border"
>
<td class="px-4 py-2 text-kestrel-text">
{{ u.identifier }}
</td>
<td class="px-4 py-2">
<span
class="rounded px-1.5 py-0.5 text-xs text-kestrel-muted"
:class="u.auth_provider === 'oidc' ? 'bg-kestrel-surface' : ''"
>
{{ u.auth_provider === 'oidc' ? 'OIDC' : 'Local' }}
</span>
</td>
<td class="px-4 py-2">
<div
v-if="isAdmin"
:ref="el => setDropdownWrapRef(u.id, el)"
class="relative inline-block"
>
<button
type="button"
class="flex min-w-[6rem] items-center justify-between gap-2 rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-left text-sm text-kestrel-text shadow-sm transition-colors hover:border-kestrel-accent/50 hover:bg-kestrel-surface"
:aria-expanded="openRoleDropdownId === u.id"
:aria-haspopup="true"
aria-label="Change role"
@click.stop="toggleRoleDropdown(u.id)"
>
<span>{{ roleByUserId[u.id] ?? u.role }}</span>
<span
class="text-kestrel-muted transition-transform"
:class="openRoleDropdownId === u.id && 'rotate-180'"
>
</span>
</button>
</div>
<span
v-else
class="text-kestrel-muted"
>{{ u.role }}</span>
</td>
<td
v-if="isAdmin"
class="px-4 py-2"
>
<div class="flex flex-wrap items-center gap-2">
<button
v-if="roleByUserId[u.id] !== u.role"
type="button"
class="rounded border border-kestrel-accent px-2 py-1 text-xs text-kestrel-accent hover:bg-kestrel-accent-dim"
@click="saveRole(u.id)"
>
Save role
</button>
<template v-if="u.auth_provider !== 'oidc'">
<button
type="button"
class="rounded border border-kestrel-border px-2 py-1 text-xs text-kestrel-text hover:bg-kestrel-surface"
@click="openEditUser(u)"
>
Edit
</button>
<button
v-if="u.id !== user?.id"
type="button"
class="rounded border border-red-500/60 px-2 py-1 text-xs text-red-400 hover:bg-red-500/10"
@click="openDeleteConfirm(u)"
>
Remove
</button>
</template>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<Teleport to="body">
<Transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="openRoleDropdownId && dropdownPlacement"
ref="dropdownMenuRef"
role="menu"
class="fixed z-[100] min-w-[6rem] rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow [box-shadow:0_4px_12px_-2px_rgba(34,201,201,0.15)]"
:style="{
top: `${dropdownPlacement.top}px`,
left: `${dropdownPlacement.left}px`,
minWidth: `${dropdownPlacement.minWidth}px`,
}"
>
<button
v-for="role in roleOptions"
:key="role"
type="button"
role="menuitem"
class="block w-full px-3 py-1.5 text-left text-sm transition-colors"
:class="roleByUserId[openRoleDropdownId] === role
? 'bg-kestrel-accent-dim text-kestrel-accent'
: 'text-kestrel-text hover:bg-kestrel-border hover:text-kestrel-text'"
@click.stop="selectRole(openRoleDropdownId, role)"
>
{{ role }}
</button>
</div>
</Transition>
</Teleport>
<!-- Add user modal --> <!-- Add user modal -->
<Teleport to="body"> <AddUserModal
<div :show="addUserModalOpen"
v-if="addUserModalOpen" :submit-error="createError"
class="fixed inset-0 z-[200] flex items-center justify-center bg-black/50 p-4" @close="closeAddUserModal"
role="dialog" @submit="onAddUserSubmit"
aria-modal="true" />
aria-labelledby="add-user-title" <DeleteUserConfirmModal
@click.self="closeAddUserModal" :user="deleteConfirmUser"
> @close="deleteConfirmUser = null"
<div @confirm="confirmDeleteUser"
class="w-full max-w-sm rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow" />
@click.stop <EditUserModal
> :user="editUserModal"
<h3 :submit-error="editError"
id="add-user-title" @close="editUserModal = null"
class="mb-3 text-sm font-medium text-kestrel-text" @submit="onEditUserSubmit"
> />
Add user
</h3>
<form @submit.prevent="submitAddUser">
<div class="mb-3 flex flex-col gap-1">
<label
for="add-identifier"
class="text-xs text-kestrel-muted"
>Username</label>
<input
id="add-identifier"
v-model="newUser.identifier"
type="text"
required
autocomplete="username"
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1.5 text-sm text-kestrel-text"
placeholder="username"
>
</div>
<div class="mb-3 flex flex-col gap-1">
<label
for="add-password"
class="text-xs text-kestrel-muted"
>Password</label>
<input
id="add-password"
v-model="newUser.password"
type="password"
required
autocomplete="new-password"
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1.5 text-sm text-kestrel-text"
placeholder="••••••••"
>
</div>
<div class="mb-4 flex flex-col gap-1">
<label
for="add-role"
class="text-xs text-kestrel-muted"
>Role</label>
<select
id="add-role"
v-model="newUser.role"
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1.5 text-sm text-kestrel-text"
>
<option value="member">
member
</option>
<option value="leader">
leader
</option>
<option value="admin">
admin
</option>
</select>
</div>
<p
v-if="createError"
class="mb-2 text-xs text-red-400"
>
{{ createError }}
</p>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded border border-kestrel-border px-3 py-1.5 text-sm text-kestrel-text hover:bg-kestrel-surface-hover"
@click="closeAddUserModal"
>
Cancel
</button>
<button
type="submit"
class="rounded border border-kestrel-accent px-3 py-1.5 text-sm text-kestrel-accent hover:bg-kestrel-accent-dim"
>
Add user
</button>
</div>
</form>
</div>
</div>
</Teleport>
<!-- Delete user confirmation modal -->
<Teleport to="body">
<div
v-if="deleteConfirmUser"
class="fixed inset-0 z-[200] flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="delete-user-title"
@click.self="deleteConfirmUser = null"
>
<div
class="w-full max-w-sm rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow"
@click.stop
>
<h3
id="delete-user-title"
class="mb-2 text-sm font-medium text-kestrel-text"
>
Delete user?
</h3>
<p class="mb-4 text-sm text-kestrel-muted">
Are you sure you want to delete <strong class="text-kestrel-text">{{ deleteConfirmUser?.identifier }}</strong>? They will not be able to sign in again.
</p>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded border border-kestrel-border px-3 py-1.5 text-sm text-kestrel-text hover:bg-kestrel-surface-hover"
@click="deleteConfirmUser = null"
>
Cancel
</button>
<button
type="button"
class="rounded border border-red-500/60 bg-red-500/10 px-3 py-1.5 text-sm text-red-400 hover:bg-red-500/20"
@click="confirmDeleteUser"
>
Delete
</button>
</div>
</div>
</div>
</Teleport>
<Teleport to="body">
<div
v-if="editUserModal"
class="fixed inset-0 z-[200] flex items-center justify-center bg-black/50 p-4"
@click.self="editUserModal = null"
>
<div
class="w-full max-w-sm rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow"
role="dialog"
aria-modal="true"
aria-labelledby="edit-user-title"
>
<h3
id="edit-user-title"
class="mb-3 text-sm font-medium text-kestrel-text"
>
Edit local user
</h3>
<form @submit.prevent="submitEditUser">
<div class="mb-3 flex flex-col gap-1">
<label
for="edit-identifier"
class="text-xs text-kestrel-muted"
>Identifier</label>
<input
id="edit-identifier"
v-model="editForm.identifier"
type="text"
required
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1.5 text-sm text-kestrel-text"
>
</div>
<div class="mb-4 flex flex-col gap-1">
<label
for="edit-password"
class="text-xs text-kestrel-muted"
>New password (leave blank to keep)</label>
<input
id="edit-password"
v-model="editForm.password"
type="password"
autocomplete="new-password"
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1.5 text-sm text-kestrel-text"
placeholder="••••••••"
>
<p class="mt-0.5 text-xs text-kestrel-muted">
If you change your password, use the new one next time you sign in.
</p>
</div>
<p
v-if="editError"
class="mb-2 text-xs text-red-400"
>
{{ editError }}
</p>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded border border-kestrel-border px-3 py-1.5 text-sm text-kestrel-text hover:bg-kestrel-surface-hover"
@click="editUserModal = null"
>
Cancel
</button>
<button
type="submit"
class="rounded border border-kestrel-accent px-3 py-1.5 text-sm text-kestrel-accent hover:bg-kestrel-accent-dim"
>
Save
</button>
</div>
</form>
</div>
</div>
</Teleport>
</template> </template>
</div> </div>
</template> </template>
<script setup> <script setup>
const { user, isAdmin, refresh: refreshUser } = useUser() const { user, isAdmin, canEditPois, refresh: refreshUser } = useUser()
const canViewMembers = computed(() => user.value?.role === 'admin' || user.value?.role === 'leader')
const { data: usersData, refresh: refreshUsers } = useAsyncData( const { data: usersData, refresh: refreshUsers } = useAsyncData(
'users', 'users',
() => $fetch('/api/users').catch(() => []), () => $fetch('/api/users').catch(() => []),
{ default: () => [] }, { default: () => [] },
) )
const users = computed(() => (Array.isArray(usersData.value) ? usersData.value : [])) const users = computed(() => Object.freeze([...(usersData.value ?? [])]))
const roleOptions = ['admin', 'leader', 'member'] const roleOptions = ['admin', 'leader', 'member']
const pendingRoleUpdates = ref({}) const pendingRoleUpdates = ref({})
@@ -407,80 +87,26 @@ const roleByUserId = computed(() => {
return { ...base, ...pendingRoleUpdates.value } return { ...base, ...pendingRoleUpdates.value }
}) })
const openRoleDropdownId = ref(null) const openRoleDropdownId = ref(null)
const dropdownWrapRefs = ref({})
const dropdownPlacement = ref(null)
const dropdownMenuRef = ref(null)
const addUserModalOpen = ref(false) const addUserModalOpen = ref(false)
const newUser = ref({ identifier: '', password: '', role: 'member' })
const createError = ref('') const createError = ref('')
const editUserModal = ref(null) const editUserModal = ref(null)
const editForm = ref({ identifier: '', password: '' })
const editError = ref('') const editError = ref('')
const deleteConfirmUser = ref(null) const deleteConfirmUser = ref(null)
function setDropdownWrapRef(userId, el) { watch(user, () => {
if (el) dropdownWrapRefs.value[userId] = el if (canEditPois.value) refreshUsers()
else {
dropdownWrapRefs.value = Object.fromEntries(
Object.entries(dropdownWrapRefs.value).filter(([k]) => k !== userId),
)
}
}
watch(user, (u) => {
if (u?.role === 'admin' || u?.role === 'leader') refreshUsers()
}, { immediate: true }) }, { immediate: true })
function toggleRoleDropdown(userId) { function toggleRoleDropdown(userId) {
if (openRoleDropdownId.value === userId) { openRoleDropdownId.value = openRoleDropdownId.value === userId ? null : userId
openRoleDropdownId.value = null
dropdownPlacement.value = null
return
}
openRoleDropdownId.value = userId
nextTick(() => {
const wrap = dropdownWrapRefs.value[userId]
if (wrap) {
const rect = wrap.getBoundingClientRect()
dropdownPlacement.value = {
top: rect.bottom + 4,
left: rect.left,
minWidth: Math.max(rect.width, 96),
}
}
else {
dropdownPlacement.value = { top: 0, left: 0, minWidth: 96 }
}
})
} }
function selectRole(userId, role) { function selectRole(userId, role) {
pendingRoleUpdates.value = { ...pendingRoleUpdates.value, [userId]: role } pendingRoleUpdates.value = { ...pendingRoleUpdates.value, [userId]: role }
openRoleDropdownId.value = null openRoleDropdownId.value = null
dropdownPlacement.value = null
} }
function onDocumentClick(e) {
const openId = openRoleDropdownId.value
if (openId == null) return
const wrap = dropdownWrapRefs.value[openId]
const menu = dropdownMenuRef.value
const inTrigger = wrap && wrap.contains(e.target)
const inMenu = menu && menu.contains(e.target)
if (!inTrigger && !inMenu) {
openRoleDropdownId.value = null
dropdownPlacement.value = null
}
}
onMounted(() => {
document.addEventListener('click', onDocumentClick)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onDocumentClick)
})
async function saveRole(id) { async function saveRole(id) {
const role = roleByUserId.value[id] const role = roleByUserId.value[id]
if (!role) return if (!role) return
@@ -498,7 +124,6 @@ async function saveRole(id) {
function openAddUserModal() { function openAddUserModal() {
addUserModalOpen.value = true addUserModalOpen.value = true
newUser.value = { identifier: '', password: '', role: 'member' }
createError.value = '' createError.value = ''
} }
@@ -507,15 +132,15 @@ function closeAddUserModal() {
createError.value = '' createError.value = ''
} }
async function submitAddUser() { async function onAddUserSubmit(payload) {
createError.value = '' createError.value = ''
try { try {
await $fetch('/api/users', { await $fetch('/api/users', {
method: 'POST', method: 'POST',
body: { body: {
identifier: newUser.value.identifier.trim(), identifier: payload.identifier,
password: newUser.value.password, password: payload.password,
role: newUser.value.role, role: payload.role,
}, },
}) })
closeAddUserModal() closeAddUserModal()
@@ -528,21 +153,19 @@ async function submitAddUser() {
function openEditUser(u) { function openEditUser(u) {
editUserModal.value = u editUserModal.value = u
editForm.value = { identifier: u.identifier, password: '' }
editError.value = '' editError.value = ''
} }
async function submitEditUser() { async function onEditUserSubmit(payload) {
if (!editUserModal.value) return const u = editUserModal.value
if (!u) return
editError.value = '' editError.value = ''
const id = editUserModal.value.id const body = { identifier: payload.identifier.trim() }
const body = { identifier: editForm.value.identifier.trim() } if (payload.password) body.password = payload.password
if (editForm.value.password) body.password = editForm.value.password
try { try {
await $fetch(`/api/users/${id}`, { method: 'PATCH', body }) await $fetch(`/api/users/${u.id}`, { method: 'PATCH', body })
editUserModal.value = null editUserModal.value = null
await refreshUsers() await refreshUsers()
// If you edited yourself, refresh current user so the header/nav shows the new identifier
await refreshUser() await refreshUser()
} }
catch (e) { catch (e) {

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="p-6"> <div class="p-6">
<h2 class="mb-2 text-xl font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]"> <h2 class="kestrel-page-heading mb-2">
POI placement POI placement
</h2> </h2>
<p <p
@@ -17,7 +17,7 @@
<div> <div>
<label <label
for="poi-lat" for="poi-lat"
class="mb-1 block text-xs text-kestrel-muted" class="kestrel-label"
>Lat</label> >Lat</label>
<input <input
id="poi-lat" id="poi-lat"
@@ -25,13 +25,13 @@
type="number" type="number"
step="any" step="any"
required required
class="w-28 rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-sm text-kestrel-text" class="kestrel-input w-28"
> >
</div> </div>
<div> <div>
<label <label
for="poi-lng" for="poi-lng"
class="mb-1 block text-xs text-kestrel-muted" class="kestrel-label"
>Lng</label> >Lng</label>
<input <input
id="poi-lng" id="poi-lng"
@@ -39,39 +39,37 @@
type="number" type="number"
step="any" step="any"
required required
class="w-28 rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-sm text-kestrel-text" class="kestrel-input w-28"
> >
</div> </div>
<div> <div>
<label <label
for="poi-label" for="poi-label"
class="mb-1 block text-xs text-kestrel-muted" class="kestrel-label"
>Label</label> >Label</label>
<input <input
id="poi-label" id="poi-label"
v-model="form.label" v-model="form.label"
type="text" type="text"
class="w-40 rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-sm text-kestrel-text" class="kestrel-input w-40"
> >
</div> </div>
<div> <div>
<label <label
for="poi-icon" for="poi-icon"
class="mb-1 block text-xs text-kestrel-muted" class="kestrel-label"
>Icon</label> >Icon</label>
<select <select
id="poi-icon" id="poi-icon"
v-model="form.iconType" v-model="form.iconType"
class="rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-sm text-kestrel-text" class="kestrel-input w-28"
> >
<option value="pin"> <option
pin v-for="opt in POI_ICON_TYPES"
</option> :key="opt"
<option value="flag"> :value="opt"
flag >
</option> {{ opt }}
<option value="waypoint">
waypoint
</option> </option>
</select> </select>
</div> </div>
@@ -114,7 +112,7 @@
class="border-b border-kestrel-border" class="border-b border-kestrel-border"
> >
<td class="px-4 py-2 text-kestrel-text"> <td class="px-4 py-2 text-kestrel-text">
{{ p.label || '' }} {{ p.label || '-' }}
</td> </td>
<td class="px-4 py-2 text-kestrel-muted"> <td class="px-4 py-2 text-kestrel-muted">
{{ p.lat }} {{ p.lat }}
@@ -145,6 +143,8 @@
</template> </template>
<script setup> <script setup>
const POI_ICON_TYPES = Object.freeze(['pin', 'flag', 'waypoint'])
const { data: poisData, refresh } = usePois() const { data: poisData, refresh } = usePois()
const { canEditPois } = useUser() const { canEditPois } = useUser()
const poisList = computed(() => poisData.value ?? []) const poisList = computed(() => poisData.value ?? [])

View File

@@ -1,15 +1,14 @@
<template> <template>
<div class="p-6"> <div class="p-6">
<h2 class="mb-4 text-xl font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]"> <h2 class="kestrel-page-heading mb-4">
Settings Settings
</h2> </h2>
<!-- Map & offline -->
<section class="mb-8"> <section class="mb-8">
<h3 class="mb-2 text-sm font-medium uppercase tracking-wider text-kestrel-muted"> <h3 class="kestrel-section-label">
Map & offline Map & offline
</h3> </h3>
<div class="rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]"> <div class="kestrel-card p-4">
<p class="mb-3 text-sm text-kestrel-text"> <p class="mb-3 text-sm text-kestrel-text">
Clear saved map tiles to free storage. The map will load tiles from the network again when you use it. Clear saved map tiles to free storage. The map will load tiles from the network again when you use it.
</p> </p>
@@ -28,7 +27,7 @@
</p> </p>
<button <button
type="button" type="button"
class="rounded border border-kestrel-border px-4 py-2 text-sm text-kestrel-text transition-colors hover:bg-kestrel-border disabled:opacity-50" class="kestrel-btn-secondary disabled:opacity-50"
:disabled="tilesLoading" :disabled="tilesLoading"
@click="onClearTiles" @click="onClearTiles"
> >
@@ -37,12 +36,72 @@
</div> </div>
</section> </section>
<!-- About --> <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> <section>
<h3 class="mb-2 text-sm font-medium uppercase tracking-wider text-kestrel-muted"> <h3 class="kestrel-section-label">
About About
</h3> </h3>
<div class="rounded border border-kestrel-border bg-kestrel-surface p-4 shadow-glow [box-shadow:0_0_20px_-4px_rgba(34,201,201,0.15)]"> <div class="kestrel-card p-4">
<p class="font-medium text-kestrel-text"> <p class="font-medium text-kestrel-text">
KestrelOS KestrelOS
</p> </p>
@@ -69,6 +128,11 @@ const tilesMessage = ref('')
const tilesMessageSuccess = ref(false) const tilesMessageSuccess = ref(false)
const tilesLoading = ref(false) const tilesLoading = ref(false)
const cotConfig = ref(null)
const takQrDataUrl = ref('')
const takQrError = ref('')
const takServerString = ref('')
async function loadTilesStored() { async function loadTilesStored() {
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
try { try {
@@ -108,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(() => { onMounted(() => {
loadTilesStored() loadTilesStored()
loadTakQr()
}) })
</script> </script>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="flex min-h-[80vh] flex-col items-center justify-center p-6"> <div class="flex min-h-[80vh] flex-col items-center justify-center p-6">
<div class="w-full max-w-md rounded-lg border border-kestrel-border bg-kestrel-surface p-6 shadow-glow [box-shadow:0_0_24px_-6px_rgba(34,201,201,0.2)]"> <div class="kestrel-card-modal w-full max-w-md p-6">
<h2 class="mb-2 text-lg font-semibold tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]"> <h2 class="kestrel-section-heading mb-2">
Share live (camera + location) Share live (camera + location)
</h2> </h2>
<p class="mb-4 text-sm text-kestrel-muted"> <p class="mb-4 text-sm text-kestrel-muted">
@@ -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. 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> </p>
<ul class="mt-2 list-inside list-disc space-y-0.5 text-kestrel-muted"> <ul class="mt-2 list-inside list-disc space-y-0.5 text-kestrel-muted">
<li><strong>Firewall:</strong> Open UDP/TCP ports 4000049999 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>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> <li><strong>Restrictive NAT / cellular:</strong> A TURN server may be required (future enhancement).</li>
</ul> </ul>
@@ -55,7 +55,7 @@
<!-- Local preview --> <!-- Local preview -->
<div <div
v-if="stream && videoRef" v-if="stream && videoRef"
class="relative mb-4 aspect-video w-full overflow-hidden rounded border border-kestrel-border bg-black" class="kestrel-video-frame mb-4"
> >
<video <video
ref="videoRef" ref="videoRef"
@@ -68,7 +68,7 @@
v-if="sharing" v-if="sharing"
class="absolute bottom-2 left-2 rounded bg-black/70 px-2 py-1 text-xs text-green-400" 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>
</div> </div>
@@ -122,11 +122,11 @@ const starting = ref(false)
const isSecureContext = typeof window !== 'undefined' && window.isSecureContext const isSecureContext = typeof window !== 'undefined' && window.isSecureContext
const webrtcState = ref('') // '', 'connecting', 'connected', 'failed' const webrtcState = ref('') // '', 'connecting', 'connected', 'failed'
const webrtcFailureReason = ref(null) // { wrongHost: { serverHostname, clientHostname } | null } const webrtcFailureReason = ref(null) // { wrongHost: { serverHostname, clientHostname } | null }
let locationWatchId = null const locationWatchId = ref(null)
let locationIntervalId = null const locationIntervalId = ref(null)
let device = null const device = ref(null)
let sendTransport = null const sendTransport = ref(null)
let producer = null const producer = ref(null)
async function runFailureReasonCheck() { async function runFailureReasonCheck() {
webrtcFailureReason.value = await getWebRTCFailureReason() webrtcFailureReason.value = await getWebRTCFailureReason()
@@ -194,8 +194,8 @@ async function startSharing() {
const rtpCapabilities = await $fetch(`/api/live/webrtc/router-rtp-capabilities?sessionId=${sessionId.value}`, { const rtpCapabilities = await $fetch(`/api/live/webrtc/router-rtp-capabilities?sessionId=${sessionId.value}`, {
credentials: 'include', credentials: 'include',
}) })
device = await createMediasoupDevice(rtpCapabilities) device.value = await createMediasoupDevice(rtpCapabilities)
sendTransport = await createSendTransport(device, sessionId.value, { sendTransport.value = await createSendTransport(device.value, sessionId.value, {
onConnectSuccess: () => { webrtcState.value = 'connected' }, onConnectSuccess: () => { webrtcState.value = 'connected' },
onConnectFailure: () => { onConnectFailure: () => {
webrtcState.value = 'failed' webrtcState.value = 'failed'
@@ -208,31 +208,31 @@ async function startSharing() {
if (!videoTrack) { if (!videoTrack) {
throw new Error('No video track available') throw new Error('No video track available')
} }
producer = await sendTransport.produce({ track: videoTrack }) producer.value = await sendTransport.value.produce({ track: videoTrack })
// Monitor producer events // Monitor producer events
producer.on('transportclose', () => { producer.value.on('transportclose', () => {
logWarn('share-live: Producer transport closed', { logWarn('share-live: Producer transport closed', {
producerId: producer.id, producerId: producer.value.id,
producerPaused: producer.paused, producerPaused: producer.value.paused,
producerClosed: producer.closed, producerClosed: producer.value.closed,
}) })
}) })
producer.on('trackended', () => { producer.value.on('trackended', () => {
logWarn('share-live: Producer track ended', { logWarn('share-live: Producer track ended', {
producerId: producer.id, producerId: producer.value.id,
producerPaused: producer.paused, producerPaused: producer.value.paused,
producerClosed: producer.closed, producerClosed: producer.value.closed,
}) })
}) })
// Monitor transport state (mediasoup-client does not pass a parameter; read from transport.connectionState) // Monitor transport state (mediasoup-client does not pass a parameter; read from transport.connectionState)
sendTransport.on('connectionstatechange', () => { sendTransport.value.on('connectionstatechange', () => {
const state = sendTransport.connectionState const state = sendTransport.value.connectionState
if (state === 'connected') webrtcState.value = 'connected' if (state === 'connected') webrtcState.value = 'connected'
else if (state === 'failed' || state === 'disconnected' || state === 'closed') { else if (state === 'failed' || state === 'disconnected' || state === 'closed') {
logWarn('share-live: Send transport connection state changed', { logWarn('share-live: Send transport connection state changed', {
state, state,
transportId: sendTransport.id, transportId: sendTransport.value.id,
producerId: producer.id, producerId: producer.value.id,
}) })
if (state === 'failed') { if (state === 'failed') {
webrtcState.value = 'failed' webrtcState.value = 'failed'
@@ -241,25 +241,25 @@ async function startSharing() {
} }
}) })
// Monitor track state // Monitor track state
if (producer.track) { if (producer.value.track) {
producer.track.addEventListener('ended', () => { producer.value.track.addEventListener('ended', () => {
logWarn('share-live: Producer track ended', { logWarn('share-live: Producer track ended', {
producerId: producer.id, producerId: producer.value.id,
trackId: producer.track.id, trackId: producer.value.track.id,
trackReadyState: producer.track.readyState, trackReadyState: producer.value.track.readyState,
trackEnabled: producer.track.enabled, trackEnabled: producer.value.track.enabled,
trackMuted: producer.track.muted, trackMuted: producer.value.track.muted,
}) })
}) })
producer.track.addEventListener('mute', () => { producer.value.track.addEventListener('mute', () => {
logWarn('share-live: Producer track muted', { logWarn('share-live: Producer track muted', {
producerId: producer.id, producerId: producer.value.id,
trackId: producer.track.id, trackId: producer.value.track.id,
trackEnabled: producer.track.enabled, trackEnabled: producer.value.track.enabled,
trackMuted: producer.track.muted, trackMuted: producer.value.track.muted,
}) })
}) })
producer.track.addEventListener('unmute', () => {}) producer.value.track.addEventListener('unmute', () => {})
} }
webrtcState.value = 'connected' webrtcState.value = 'connected'
setStatus('WebRTC connected. Requesting location…') setStatus('WebRTC connected. Requesting location…')
@@ -273,7 +273,7 @@ async function startSharing() {
return return
} }
// 5. Get location (continuous) also requires HTTPS on mobile Safari // 5. Get location (continuous) - also requires HTTPS on mobile Safari
if (!navigator.geolocation) { if (!navigator.geolocation) {
setError('Geolocation not supported in this browser.') setError('Geolocation not supported in this browser.')
cleanup() cleanup()
@@ -281,7 +281,7 @@ async function startSharing() {
} }
try { try {
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
locationWatchId = navigator.geolocation.watchPosition( locationWatchId.value = navigator.geolocation.watchPosition(
(pos) => { (pos) => {
resolve(pos) resolve(pos)
}, },
@@ -332,9 +332,9 @@ async function startSharing() {
} }
catch (e) { catch (e) {
if (e?.statusCode === 404) { if (e?.statusCode === 404) {
if (locationIntervalId != null) { if (locationIntervalId.value != null) {
clearInterval(locationIntervalId) clearInterval(locationIntervalId.value)
locationIntervalId = null locationIntervalId.value = null
} }
sharing.value = false sharing.value = false
if (!locationUpdate404Logged) { if (!locationUpdate404Logged) {
@@ -350,7 +350,7 @@ async function startSharing() {
} }
await sendLocationUpdate() await sendLocationUpdate()
locationIntervalId = setInterval(sendLocationUpdate, 2000) locationIntervalId.value = setInterval(sendLocationUpdate, 2000)
} }
catch (e) { catch (e) {
starting.value = false starting.value = false
@@ -363,23 +363,23 @@ async function startSharing() {
} }
function cleanup() { function cleanup() {
if (locationWatchId != null && navigator.geolocation?.clearWatch) { if (locationWatchId.value != null && navigator.geolocation?.clearWatch) {
navigator.geolocation.clearWatch(locationWatchId) navigator.geolocation.clearWatch(locationWatchId.value)
} }
locationWatchId = null locationWatchId.value = null
if (locationIntervalId != null) { if (locationIntervalId.value != null) {
clearInterval(locationIntervalId) clearInterval(locationIntervalId.value)
} }
locationIntervalId = null locationIntervalId.value = null
if (producer) { if (producer.value) {
producer.close() producer.value.close()
producer = null producer.value = null
} }
if (sendTransport) { if (sendTransport.value) {
sendTransport.close() sendTransport.value.close()
sendTransport = null sendTransport.value = null
} }
device = null device.value = null
if (stream.value) { if (stream.value) {
stream.value.getTracks().forEach(t => t.stop()) stream.value.getTracks().forEach(t => t.stop())
stream.value = null stream.value = null

View File

@@ -1,3 +1,4 @@
/** Wraps $fetch to redirect to /login on 401 for same-origin requests. */
export default defineNuxtPlugin(() => { export default defineNuxtPlugin(() => {
const route = useRoute() const route = useRoute()
const baseFetch = globalThis.$fetch ?? $fetch const baseFetch = globalThis.$fetch ?? $fetch
@@ -6,8 +7,7 @@ export default defineNuxtPlugin(() => {
if (response?.status !== 401) return if (response?.status !== 401) return
const url = typeof request === 'string' ? request : request?.url ?? '' const url = typeof request === 'string' ? request : request?.url ?? ''
if (!url.startsWith('/')) return if (!url.startsWith('/')) return
const redirect = (route.fullPath && route.fullPath !== '/' ? route.fullPath : '/') navigateTo({ path: '/login', query: { redirect: route.fullPath || '/' } }, { replace: true })
navigateTo({ path: '/login', query: { redirect } }, { replace: true })
}, },
}) })
}) })

View File

@@ -1,88 +1,30 @@
/** /** Client-side logger: sends to server, falls back to console. */
* Client-side logger that sends logs to server for debugging. const sessionId = ref(null)
* Falls back to console if server logging fails. const userId = ref(null)
*/
let sessionId = null const CONSOLE_METHOD = Object.freeze({ error: 'error', warn: 'warn', info: 'log', debug: 'log' })
let userId = null
/**
* Initialize logger with session/user context.
* @param {string} sessId
* @param {string} uid
*/
export function initLogger(sessId, uid) { export function initLogger(sessId, uid) {
sessionId = sessId sessionId.value = sessId
userId = uid userId.value = uid
} }
/** function sendToServer(level, message, data) {
* Send log to server (non-blocking). setTimeout(() => {
* @param {string} level $fetch('/api/log', {
* @param {string} message method: 'POST',
* @param {object} data body: { level, message, data, sessionId: sessionId.value, userId: userId.value, timestamp: new Date().toISOString() },
*/ credentials: 'include',
async function sendToServer(level, message, data) { }).catch(() => { /* server down - don't spam console */ })
// Use setTimeout to avoid blocking - fire and forget
setTimeout(async () => {
try {
await $fetch('/api/log', {
method: 'POST',
body: {
level,
message,
data,
sessionId,
userId,
timestamp: new Date().toISOString(),
},
credentials: 'include',
}).catch(() => {
// Silently fail - don't spam console if server is down
})
}
catch {
// Ignore errors - logging shouldn't break the app
}
}, 0) }, 0)
} }
/** function log(level, message, data) {
* Log at error level. console[CONSOLE_METHOD[level]](`[${message}]`, data)
* @param {string} message sendToServer(level, message, data)
* @param {object} data
*/
export function logError(message, data) {
console.error(`[${message}]`, data)
sendToServer('error', message, data)
} }
/** export const logError = (message, data) => log('error', message, data)
* Log at warn level. export const logWarn = (message, data) => log('warn', message, data)
* @param {string} message export const logInfo = (message, data) => log('info', message, data)
* @param {object} data export const logDebug = (message, data) => log('debug', message, data)
*/
export function logWarn(message, data) {
console.warn(`[${message}]`, data)
sendToServer('warn', message, data)
}
/**
* Log at info level.
* @param {string} message
* @param {object} data
*/
export function logInfo(message, data) {
console.log(`[${message}]`, data)
sendToServer('info', message, data)
}
/**
* Log at debug level.
* @param {string} message
* @param {object} data
*/
export function logDebug(message, data) {
console.log(`[${message}]`, data)
sendToServer('debug', message, data)
}

11
docs/README.md Normal file
View File

@@ -0,0 +1,11 @@
# KestrelOS Documentation
Tactical Operations Center (TOC) for OSINT feeds: map view, cameras/devices, live sharing, and ATAK/iTAK integration.
## Quick Start
1. [Installation](installation.md) - npm, Docker, or Helm
2. [Authentication](auth.md) - First login (bootstrap admin or OIDC)
3. [Map and cameras](map-and-cameras.md) - Add devices and view streams
4. [ATAK and iTAK](atak-itak.md) - Connect TAK clients (port 8089)
5. [Share live](live-streaming.md) - Stream from mobile device (HTTPS required)

79
docs/atak-itak.md Normal file
View File

@@ -0,0 +1,79 @@
# ATAK and iTAK
KestrelOS acts as a **TAK Server**. ATAK (Android) and iTAK (iOS) connect on **port 8089** (CoT). Devices relay positions to each other and appear on the KestrelOS map.
## Connection
**Host:** KestrelOS hostname/IP
**Port:** `8089` (CoT)
**SSL:** Enable if server uses TLS (`.dev-certs/` or production cert)
**Authentication:**
- **Username:** KestrelOS identifier
- **Password:** Login password (local) or ATAK password (OIDC; set in **Account**)
## ATAK (Android)
1. **Settings****Network****Connections** → Add **TAK Server**
2. Set **Host** and **Port** (`8089`)
3. Enable **Use Authentication**, enter username/password
4. Save and connect
## iTAK (iOS)
**Option A - QR code (easiest):**
1. KestrelOS **Settings****TAK Server** → Scan QR with iTAK
2. Enter username/password when prompted
**Option B - Manual:**
1. **Settings****Network** → Add **TAK Server**
2. Set **Host**, **Port** (`8089`), enable SSL if needed
3. Enable **Use Authentication**, enter username/password
4. Save and connect
## Self-Signed Certificate (iTAK)
If server uses self-signed cert (`.dev-certs/`):
**Upload server package:**
1. KestrelOS **Settings****TAK Server****Download server package (zip)**
2. Transfer to iPhone (AirDrop, email, Safari)
3. iTAK: **Settings****Network****Servers****+** → **Upload server package**
4. Enter username/password
**Or use plain TCP:**
1. Stop KestrelOS, remove `.dev-certs/`, restart
2. Add server with **SSL disabled**
**ATAK (Android):** Download trust store from `https://your-server/api/cot/truststore`, import `.p12` (password: `kestrelos`), or use server package/plain TCP.
## OIDC Users
OIDC users must set an **ATAK password** first:
1. Sign in with OIDC
2. **Account****ATAK / device password** → set password
3. Use KestrelOS username + ATAK password in TAK client
## Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `COT_PORT` | `8089` | CoT server port |
| `COT_TTL_MS` | `90000` | Device timeout (~90s) |
| `COT_REQUIRE_AUTH` | `true` | Require authentication |
| `COT_SSL_CERT` | `.dev-certs/cert.pem` | TLS cert path |
| `COT_SSL_KEY` | `.dev-certs/key.pem` | TLS key path |
## Troubleshooting
**"Error authenticating" with no `[cot]` logs:**
- Connection not reaching server (TLS handshake failed or firewall blocking)
- Check server logs show `[cot] CoT server listening on 0.0.0.0:8089`
- Verify port `8089` (not `3000`) and firewall allows it
- For TLS: trust cert (server package) or use plain TCP
**"Error authenticating" with `[cot]` logs:**
- Username must be KestrelOS identifier
- Password must match (local: login password; OIDC: ATAK password)
**Devices not on map:** They appear only while sending updates; drop off after TTL (~90s).

39
docs/auth.md Normal file
View File

@@ -0,0 +1,39 @@
# Authentication
KestrelOS supports **local login** (username/email + password) and optional **OIDC** (SSO). All users must sign in.
## Local Login
**First run:** On first start, KestrelOS creates an admin account:
- If `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` are set → that account is created
- Otherwise → default admin (`admin`) with random password printed in terminal
**Sign in:** Open `/login`, enter identifier and password. Change password or add users via **Members** (admin only).
## OIDC (SSO)
**Enable:** Set `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`. Optional: `OIDC_LABEL`, `OIDC_REDIRECT_URI`, `OIDC_SCOPES`.
**IdP setup:**
1. Create OIDC client in your IdP (Keycloak, Auth0, etc.)
2. Set redirect URI: `https://<your-host>/api/auth/oidc/callback`
3. Copy Client ID and Secret to env vars
**Sign up:** Users sign up at the IdP. First OIDC login in KestrelOS creates their account automatically.
**Redirect URI:** Defaults to `{APP_URL}/api/auth/oidc/callback` (uses `NUXT_APP_URL`/`APP_URL` or falls back to `HOST`/`PORT`).
## OIDC Users and ATAK/iTAK
OIDC users don't have a KestrelOS password. To use ATAK/iTAK:
1. Sign in with OIDC
2. Go to **Account** → set **ATAK password**
3. Use KestrelOS username + ATAK password in TAK client
## Roles
- **Admin** - Manage users, edit POIs, add/edit devices (API)
- **Leader** - Edit POIs, add/edit devices (API)
- **Member** - View map/cameras/POIs, use Share live
Only admins can change roles (Members page).

61
docs/installation.md Normal file
View File

@@ -0,0 +1,61 @@
# Installation
Run KestrelOS from source (npm), Docker, or Kubernetes (Helm).
## npm (from source)
```bash
git clone <repository-url> kestrelos
cd kestrelos
npm install
npm run dev
```
Open **http://localhost:3000**. First run creates `data/kestrelos.db` and bootstraps an admin (see [Authentication](auth.md)).
**Production:**
```bash
npm run build
npm run preview
# or
node .output/server/index.mjs
```
Set `HOST=0.0.0.0` and `PORT` for production.
## Docker
```bash
docker build -t kestrelos:latest .
docker run -p 3000:3000 -p 8089:8089 \
-v kestrelos-data:/app/data \
kestrelos:latest
```
Expose ports **3000** (web/API) and **8089** (CoT for ATAK/iTAK).
## Helm (Kubernetes)
**From registry:**
```bash
helm repo add keligrubb --username USER --password TOKEN \
https://git.keligrubb.com/api/packages/keligrubb/helm
helm install kestrelos keligrubb/kestrelos
```
**From source:**
```bash
helm install kestrelos ./helm/kestrelos
```
Configure in `helm/kestrelos/values.yaml`. Health: `GET /health`, `/health/live`, `/health/ready`.
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `HOST` | Nuxt default | Bind address (use `0.0.0.0` for all interfaces) |
| `PORT` | `3000` | Web/API port |
| `DB_PATH` | `data/kestrelos.db` | SQLite database path |
See [Authentication](auth.md) for auth variables. See [ATAK and iTAK](atak-itak.md) for CoT options.

44
docs/live-streaming.md Normal file
View File

@@ -0,0 +1,44 @@
# Share Live
Stream your phone's camera and location to KestrelOS. Appears as a **live session** on the map and in **Cameras**. Uses **WebRTC** (Mediasoup) and requires **HTTPS** on mobile.
## Usage
1. Open **Share live** (sidebar → **Share live** or `/share-live`)
2. Tap **Start sharing**, allow camera/location permissions
3. Device appears on map and in **Cameras**
4. Tap **Stop sharing** to end
**Permissions:** Admin/leader can start sharing. All users can view live sessions.
## Requirements
- **HTTPS** (browsers require secure context for camera/geolocation)
- **Camera and location permissions**
- **WebRTC ports:** UDP/TCP `40000-49999` open on server
## Local Development
**Generate self-signed cert:**
```bash
chmod +x scripts/gen-dev-cert.sh
./scripts/gen-dev-cert.sh 192.168.1.123 # Your LAN IP
npm run dev
```
**On phone:** Open `https://192.168.1.123:3000`, accept cert warning, sign in, use Share live.
## WebRTC Configuration
- Server auto-detects LAN IP for WebRTC
- **Docker/multiple NICs:** Set `MEDIASOUP_ANNOUNCED_IP` to client-reachable IP/hostname
- **"Wrong host" error:** Use same URL on phone/server, or set `MEDIASOUP_ANNOUNCED_IP`
## Troubleshooting
| Issue | Fix |
|-------|-----|
| "HTTPS required" | Use `https://` (not `http://`) |
| "Media devices not available" | Ensure HTTPS and browser permissions |
| "WebRTC: failed" / "Wrong host" | Set `MEDIASOUP_ANNOUNCED_IP`, open firewall ports `40000-49999` |
| Stream not visible | Check server reachability and firewall |

52
docs/map-and-cameras.md Normal file
View File

@@ -0,0 +1,52 @@
# Map and Cameras
KestrelOS shows a **map** with devices, POIs, live sessions (Share live), and ATAK/iTAK positions. Click markers or use **Cameras** page to view streams.
## Map Layers
- **Devices** - Fixed feeds (IPTV, ALPR, CCTV, NVR, etc.) added via API
- **POIs** - Points of interest (admin/leader can edit)
- **Live sessions** - Mobile devices streaming via Share live
- **CoT (ATAK/iTAK)** - Amber markers for connected TAK devices (position only)
## Cameras
A **camera** is either:
1. A **device** - Fixed feed with stream URL
2. A **live session** - Mobile device streaming via Share live
View via map markers or **Cameras** page (sidebar).
## Device Types
| device_type | Use case |
|-------------|----------|
| `alpr`, `nvr`, `doorbell`, `feed`, `traffic`, `ip`, `drone` | Labeling/filtering |
**source_type:** `mjpeg` (MJPEG over HTTP) or `hls` (HLS `.m3u8` playlist)
Stream URLs must be `http://` or `https://`.
## API: Devices
**Create:** `POST /api/devices` (admin/leader)
```json
{
"name": "Main gate ALPR",
"device_type": "alpr",
"lat": 37.7749,
"lng": -122.4194,
"stream_url": "https://alpr.example.com/stream.m3u8",
"source_type": "hls"
}
```
**List:** `GET /api/devices`
**Update:** `PATCH /api/devices/:id`
**Delete:** `DELETE /api/devices/:id`
**Cameras endpoint:** `GET /api/cameras` returns devices + live sessions + CoT entities.
## POIs
Admins/leaders add/edit from **POI** page (sidebar). POIs appear as map pins (reference only, no stream).

BIN
docs/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

View File

@@ -2,5 +2,5 @@ apiVersion: v2
name: kestrelos name: kestrelos
description: KestrelOS TOC for OSINT feeds - map, camera feeds, offline tiles description: KestrelOS TOC for OSINT feeds - map, camera feeds, offline tiles
type: application type: application
version: 0.2.0 version: 1.0.7
appVersion: "0.2.0" appVersion: "1.0.7"

View File

@@ -2,7 +2,7 @@ replicaCount: 1
image: image:
repository: git.keligrubb.com/keligrubb/kestrelos repository: git.keligrubb.com/keligrubb/kestrelos
tag: 0.2.0 tag: 1.0.7
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
service: service:

View File

@@ -27,14 +27,17 @@ export default defineNuxtConfig({
], ],
}, },
}, },
css: ['~/assets/css/main.css'],
runtimeConfig: { runtimeConfig: {
public: { public: {
version: pkg.version ?? '', version: pkg.version ?? '',
}, },
cotTtlMs: 90_000,
cotRequireAuth: true,
cotDebug: false,
}, },
devServer: { devServer: {
host: '0.0.0.0', host: '0.0.0.0',
port: 3000,
...(useDevHttps ...(useDevHttps
? { https: { key: devKey, cert: devCert } } ? { https: { key: devKey, cert: devCert } }
: {}), : {}),

815
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "kestrelos", "name": "kestrelos",
"version": "0.2.0", "version": "1.0.7",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -10,6 +10,7 @@
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare", "postinstall": "nuxt prepare",
"test": "vitest", "test": "vitest",
"test:integration": "vitest run --config vitest.integration.config.js",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"test:e2e": "playwright test test/e2e", "test:e2e": "playwright test test/e2e",
"test:e2e:ui": "playwright test --ui test/e2e", "test:e2e:ui": "playwright test --ui test/e2e",
@@ -20,16 +21,19 @@
"dependencies": { "dependencies": {
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
"fast-xml-parser": "^5.3.6",
"hls.js": "^1.5.0", "hls.js": "^1.5.0",
"jszip": "^3.10.1",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet.offline": "^3.2.0", "leaflet.offline": "^3.2.0",
"mediasoup": "^3.19.14", "mediasoup": "^3.19.14",
"mediasoup-client": "^3.18.6", "mediasoup-client": "^3.18.6",
"nuxt": "^4.0.0", "nuxt": "^4.0.0",
"openid-client": "^6.8.2", "openid-client": "^6.8.2",
"qrcode": "^1.5.4",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"vue": "^3.4.0", "vue": "^3.4.0",
"vue-router": "^4.4.0", "vue-router": "^5.0.0",
"ws": "^8.18.0" "ws": "^8.18.0"
}, },
"devDependencies": { "devDependencies": {
@@ -39,7 +43,7 @@
"@playwright/test": "^1.58.2", "@playwright/test": "^1.58.2",
"@vitest/coverage-v8": "^4.0.0", "@vitest/coverage-v8": "^4.0.0",
"@vue/test-utils": "^2.4.0", "@vue/test-utils": "^2.4.0",
"eslint": "^9.0.0", "eslint": "^10.0.0",
"happy-dom": "^20.6.1", "happy-dom": "^20.6.1",
"vitest": "^4.0.0" "vitest": "^4.0.0"
}, },

3
renovate.json Normal file
View File

@@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}

View File

@@ -47,7 +47,7 @@ body="## Changelog
## Installation ## Installation
- [Docker image](${CI_FORGE_URL}/${CI_REPO_OWNER}/-/packages/container/${CI_REPO_NAME}) - [Docker image](${CI_FORGE_URL}/${CI_REPO_OWNER}/-/packages/container/${CI_REPO_NAME})
- [Helm chart](${CI_FORGE_URL}/${CI_REPO_OWNER}/-/packages/helm)" - [Helm chart](${CI_FORGE_URL}/${CI_REPO_OWNER}/-/packages/helm/${CI_REPO_NAME})"
release_url="${CI_FORGE_URL}/api/v1/repos/${CI_REPO_OWNER}/${CI_REPO_NAME}/releases" release_url="${CI_FORGE_URL}/api/v1/repos/${CI_REPO_OWNER}/${CI_REPO_NAME}/releases"
echo "$body" | awk -v tag="v$newVersion" 'BEGIN{printf "{\"tag_name\":\"" tag "\",\"name\":\"" tag "\",\"body\":\""} { gsub(/\\/,"\\\\"); gsub(/"/,"\\\""); if (NR>1) printf "\\n"; printf "%s", $0 } END{printf "\"}\n"}' > /tmp/release.json echo "$body" | awk -v tag="v$newVersion" 'BEGIN{printf "{\"tag_name\":\"" tag "\",\"name\":\"" tag "\",\"body\":\""} { gsub(/\\/,"\\\\"); gsub(/"/,"\\\""); if (NR>1) printf "\\n"; printf "%s", $0 } END{printf "\"}\n"}' > /tmp/release.json
wget -q -O /dev/null --post-file=/tmp/release.json \ wget -q -O /dev/null --post-file=/tmp/release.json \

View File

@@ -1,3 +1,3 @@
import { getAuthConfig } from '../../utils/authConfig.js' import { getAuthConfig } from '../../utils/oidc.js'
export default defineEventHandler(() => getAuthConfig()) export default defineEventHandler(() => getAuthConfig())

View File

@@ -1,7 +1,7 @@
import { setCookie } from 'h3' import { setCookie } from 'h3'
import { getDb } from '../../utils/db.js' import { getDb } from '../../utils/db.js'
import { verifyPassword } from '../../utils/password.js' import { verifyPassword } from '../../utils/password.js'
import { getSessionMaxAgeDays } from '../../utils/session.js' import { getSessionMaxAgeDays } from '../../utils/constants.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const body = await readBody(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)) { if (!user || !user.password_hash || !verifyPassword(password, user.password_hash)) {
throw createError({ statusCode: 401, message: 'Invalid credentials' }) 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 sessionDays = getSessionMaxAgeDays()
const sid = crypto.randomUUID() const sid = crypto.randomUUID()
const now = new Date() const now = new Date()

View File

@@ -1,5 +1,5 @@
import { getAuthConfig } from '../../../utils/authConfig.js'
import { import {
getAuthConfig,
getOidcConfig, getOidcConfig,
getOidcRedirectUri, getOidcRedirectUri,
createOidcParams, createOidcParams,

View File

@@ -6,7 +6,7 @@ import {
exchangeCode, exchangeCode,
} from '../../../utils/oidc.js' } from '../../../utils/oidc.js'
import { getDb } from '../../../utils/db.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' 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]) 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 sessionDays = getSessionMaxAgeDays()
const sid = crypto.randomUUID() const sid = crypto.randomUUID()
const now = new Date() const now = new Date()

View File

@@ -1,12 +1,19 @@
import { getDb } from '../utils/db.js' import { getDb } from '../utils/db.js'
import { requireAuth } from '../utils/authHelpers.js' import { requireAuth } from '../utils/authHelpers.js'
import { getActiveSessions } from '../utils/liveSessions.js' import { getActiveSessions } from '../utils/liveSessions.js'
import { getActiveEntities } from '../utils/cotStore.js'
import { rowToDevice, sanitizeDeviceForResponse } from '../utils/deviceUtils.js' import { rowToDevice, sanitizeDeviceForResponse } from '../utils/deviceUtils.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
requireAuth(event) requireAuth(event)
const [db, sessions] = await Promise.all([getDb(), getActiveSessions()]) const config = useRuntimeConfig()
const ttlMs = Number(config.cotTtlMs ?? 90_000) || 90_000
const [db, sessions, cotEntities] = await Promise.all([
getDb(),
getActiveSessions(),
getActiveEntities(ttlMs),
])
const rows = await db.all('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices ORDER BY id') const 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(r => rowToDevice(r)).filter(Boolean).map(sanitizeDeviceForResponse) const devices = rows.map(rowToDevice).filter(Boolean).map(sanitizeDeviceForResponse)
return { devices, liveSessions: sessions } return { devices, liveSessions: sessions, cotEntities }
}) })

View File

@@ -0,0 +1,8 @@
import { getCotSslPaths, getCotPort } from '../../utils/cotSsl.js'
/** Public CoT server config for QR code / client setup (port and whether TLS is used). */
export default defineEventHandler(() => {
const config = useRuntimeConfig()
const paths = getCotSslPaths(config)
return { port: getCotPort(), ssl: Boolean(paths) }
})

View File

@@ -0,0 +1,60 @@
import { existsSync } from 'node:fs'
import JSZip from 'jszip'
import { getCotSslPaths, getCotPort, TRUSTSTORE_PASSWORD, COT_TLS_REQUIRED_MESSAGE, buildP12FromCertPath } from '../../utils/cotSsl.js'
import { requireAuth } from '../../utils/authHelpers.js'
/**
* Build config.pref XML for iTAK: server connection + CA cert for trust (credentials entered in app).
* connectString format: host:port:ssl or host:port:tcp
*/
function buildConfigPref(hostname, port, ssl) {
const connectString = `${hostname}:${port}:${ssl ? 'ssl' : 'tcp'}`
return `<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<preference-set id="com.atakmap.app_preferences">
<entry key="connectionEntry">1</entry>
<entry key="description">KestrelOS</entry>
<entry key="enabled">true</entry>
<entry key="connectString">${escapeXml(connectString)}</entry>
<entry key="caCertPath">cert/caCert.p12</entry>
<entry key="caCertPassword">${escapeXml(TRUSTSTORE_PASSWORD)}</entry>
<entry key="cacheCredentials">true</entry>
</preference-set>
`
}
function escapeXml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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 }
}
})

View File

@@ -0,0 +1,24 @@
import { existsSync } from 'node:fs'
import { getCotSslPaths, TRUSTSTORE_PASSWORD, COT_TLS_REQUIRED_MESSAGE, buildP12FromCertPath } from '../../utils/cotSsl.js'
import { requireAuth } from '../../utils/authHelpers.js'
export default defineEventHandler((event) => {
requireAuth(event)
const config = useRuntimeConfig()
const paths = getCotSslPaths(config)
if (!paths || !existsSync(paths.certPath)) {
setResponseStatus(event, 404)
return { error: `CoT server is not using TLS or cert not found. Trust store ${COT_TLS_REQUIRED_MESSAGE}` }
}
try {
const p12 = buildP12FromCertPath(paths.certPath, TRUSTSTORE_PASSWORD)
setHeader(event, 'Content-Type', 'application/x-pkcs12')
setHeader(event, 'Content-Disposition', 'attachment; filename="kestrelos-cot-truststore.p12"')
return p12
}
catch (err) {
setResponseStatus(event, 500)
return { error: 'Failed to build trust store.', detail: err?.message }
}
})

View File

@@ -1,4 +1,4 @@
import { getDb } from '../utils/db.js' import { getDb, withTransaction } from '../utils/db.js'
import { requireAuth } from '../utils/authHelpers.js' import { requireAuth } from '../utils/authHelpers.js'
import { validateDeviceBody, rowToDevice, sanitizeDeviceForResponse } from '../utils/deviceUtils.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 body = await readBody(event).catch(() => ({}))
const { name, device_type, vendor, lat, lng, stream_url, source_type, config } = validateDeviceBody(body) const { name, device_type, vendor, lat, lng, stream_url, source_type, config } = validateDeviceBody(body)
const id = crypto.randomUUID() const id = crypto.randomUUID()
const { run, get } = await getDb() const db = await getDb()
await run( return withTransaction(db, async ({ run, get }) => {
'INSERT INTO devices (id, name, device_type, vendor, lat, lng, stream_url, source_type, config) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', await run(
[id, name, device_type, vendor, lat, lng, stream_url, source_type, config], '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) const row = await get('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices WHERE id = ?', [id])
if (!device) throw createError({ statusCode: 500, message: 'Device not found after insert' }) const device = rowToDevice(row)
return sanitizeDeviceForResponse(device) if (!device) throw createError({ statusCode: 500, message: 'Device not found after insert' })
return sanitizeDeviceForResponse(device)
})
}) })

View File

@@ -1,55 +1,49 @@
import { getDb } from '../../utils/db.js' import { getDb } from '../../utils/db.js'
import { requireAuth } from '../../utils/authHelpers.js' import { requireAuth } from '../../utils/authHelpers.js'
import { rowToDevice, sanitizeDeviceForResponse, DEVICE_TYPES, SOURCE_TYPES } from '../../utils/deviceUtils.js' import { rowToDevice, sanitizeDeviceForResponse, DEVICE_TYPES, SOURCE_TYPES } from '../../utils/deviceUtils.js'
import { buildUpdateQuery } from '../../utils/queryBuilder.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
requireAuth(event, { role: 'adminOrLeader' }) requireAuth(event, { role: 'adminOrLeader' })
const id = event.context.params?.id const id = event.context.params?.id
if (!id) throw createError({ statusCode: 400, message: 'id required' }) if (!id) throw createError({ statusCode: 400, message: 'id required' })
const body = (await readBody(event).catch(() => ({}))) || {} const body = (await readBody(event).catch(() => ({}))) || {}
const updates = [] const updates = {}
const params = []
if (typeof body.name === 'string') { if (typeof body.name === 'string') {
updates.push('name = ?') updates.name = body.name.trim()
params.push(body.name.trim())
} }
if (DEVICE_TYPES.includes(body.device_type)) { if (DEVICE_TYPES.includes(body.device_type)) {
updates.push('device_type = ?') updates.device_type = body.device_type
params.push(body.device_type)
} }
if (body.vendor !== undefined) { if (body.vendor !== undefined) {
updates.push('vendor = ?') updates.vendor = typeof body.vendor === 'string' && body.vendor.trim() ? body.vendor.trim() : null
params.push(typeof body.vendor === 'string' && body.vendor.trim() ? body.vendor.trim() : null)
} }
if (Number.isFinite(body.lat)) { if (Number.isFinite(body.lat)) {
updates.push('lat = ?') updates.lat = body.lat
params.push(body.lat)
} }
if (Number.isFinite(body.lng)) { if (Number.isFinite(body.lng)) {
updates.push('lng = ?') updates.lng = body.lng
params.push(body.lng)
} }
if (typeof body.stream_url === 'string') { if (typeof body.stream_url === 'string') {
updates.push('stream_url = ?') updates.stream_url = body.stream_url.trim()
params.push(body.stream_url.trim())
} }
if (SOURCE_TYPES.includes(body.source_type)) { if (SOURCE_TYPES.includes(body.source_type)) {
updates.push('source_type = ?') updates.source_type = body.source_type
params.push(body.source_type)
} }
if (body.config !== undefined) { if (body.config !== undefined) {
updates.push('config = ?') updates.config = typeof body.config === 'string' ? body.config : (body.config != null ? JSON.stringify(body.config) : null)
params.push(typeof body.config === 'string' ? body.config : (body.config != null ? JSON.stringify(body.config) : null))
} }
const { run, get } = await getDb() 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]) 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' }) if (!row) throw createError({ statusCode: 404, message: 'Device not found' })
const device = rowToDevice(row) const device = rowToDevice(row)
return device ? sanitizeDeviceForResponse(device) : row return device ? sanitizeDeviceForResponse(device) : row
} }
params.push(id) const { query, params } = buildUpdateQuery('devices', null, updates)
await run(`UPDATE devices SET ${updates.join(', ')} WHERE id = ?`, params) 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]) 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' }) if (!row) throw createError({ statusCode: 404, message: 'Device not found' })
const device = rowToDevice(row) const device = rowToDevice(row)

View File

@@ -1,35 +1,38 @@
import { requireAuth } from '../../utils/authHelpers.js' import { requireAuth } from '../../utils/authHelpers.js'
import { getLiveSession, deleteLiveSession } from '../../utils/liveSessions.js' import { getLiveSession, deleteLiveSession } from '../../utils/liveSessions.js'
import { closeRouter, getProducer, getTransport } from '../../utils/mediasoup.js' import { closeRouter, getProducer, getTransport } from '../../utils/mediasoup.js'
import { acquire } from '../../utils/asyncLock.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const user = requireAuth(event) const user = requireAuth(event)
const id = event.context.params?.id const id = event.context.params?.id
if (!id) throw createError({ statusCode: 400, message: 'id required' }) if (!id) throw createError({ statusCode: 400, message: 'id required' })
const session = getLiveSession(id) return await acquire(`session-delete-${id}`, async () => {
if (!session) throw createError({ statusCode: 404, message: 'Live session not found' }) const session = getLiveSession(id)
if (session.userId !== user.id) throw createError({ statusCode: 403, message: 'Forbidden' }) 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 // Clean up producer if it exists
if (session.producerId) { if (session.producerId) {
const producer = getProducer(session.producerId) const producer = getProducer(session.producerId)
if (producer) { if (producer) {
producer.close() producer.close()
}
} }
}
// Clean up transport if it exists // Clean up transport if it exists
if (session.transportId) { if (session.transportId) {
const transport = getTransport(session.transportId) const transport = getTransport(session.transportId)
if (transport) { if (transport) {
transport.close() transport.close()
}
} }
}
// Clean up router // Clean up router
await closeRouter(id) await closeRouter(id)
deleteLiveSession(id) await deleteLiveSession(id)
return { ok: true } return { ok: true }
})
}) })

View File

@@ -1,31 +1,57 @@
import { requireAuth } from '../../utils/authHelpers.js' import { requireAuth } from '../../utils/authHelpers.js'
import { getLiveSession, updateLiveSession } from '../../utils/liveSessions.js' import { getLiveSession, updateLiveSession } from '../../utils/liveSessions.js'
import { acquire } from '../../utils/asyncLock.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const user = requireAuth(event) const user = requireAuth(event)
const id = event.context.params?.id const id = event.context.params?.id
if (!id) throw createError({ statusCode: 400, message: 'id required' }) 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 body = await readBody(event).catch(() => ({}))
const lat = Number(body?.lat) const lat = Number(body?.lat)
const lng = Number(body?.lng) const lng = Number(body?.lng)
const updates = {} const updates = {}
if (Number.isFinite(lat)) updates.lat = lat if (Number.isFinite(lat)) updates.lat = lat
if (Number.isFinite(lng)) updates.lng = lng if (Number.isFinite(lng)) updates.lng = lng
if (Object.keys(updates).length) { if (Object.keys(updates).length === 0) {
updateLiveSession(id, updates) // 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) // Use lock to atomically check and update session
return { return await acquire(`session-patch-${id}`, async () => {
id: updated.id, const session = getLiveSession(id)
label: updated.label, if (!session) throw createError({ statusCode: 404, message: 'Live session not found' })
lat: updated.lat, if (session.userId !== user.id) throw createError({ statusCode: 403, message: 'Forbidden' })
lng: updated.lng,
updatedAt: updated.updatedAt, 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
}
})
}) })

View File

@@ -1,40 +1,44 @@
import { requireAuth } from '../../utils/authHelpers.js' import { requireAuth } from '../../utils/authHelpers.js'
import { import {
createSession, getOrCreateSession,
getActiveSessionByUserId, getActiveSessionByUserId,
deleteLiveSession, deleteLiveSession,
} from '../../utils/liveSessions.js' } from '../../utils/liveSessions.js'
import { closeRouter, getProducer, getTransport } from '../../utils/mediasoup.js' import { closeRouter, getProducer, getTransport } from '../../utils/mediasoup.js'
import { acquire } from '../../utils/asyncLock.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const user = requireAuth(event, { role: 'adminOrLeader' }) const user = requireAuth(event, { role: 'adminOrLeader' })
const body = await readBody(event).catch(() => ({})) 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) // Atomically get or create session, replacing existing if needed
const existing = getActiveSessionByUserId(user.id) return await acquire(`session-start-${user.id}`, async () => {
if (existing) { const existing = await getActiveSessionByUserId(user.id)
if (existing.producerId) { if (existing) {
const producer = getProducer(existing.producerId) // Clean up existing session resources
if (producer) producer.close() 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'}`) const session = await getOrCreateSession(user.id, label || `Live: ${user.identifier || 'User'}`)
console.log('[live.start] Session created:', { id: session.id, userId: user.id, label: session.label }) console.log('[live.start] Session ready:', { id: session.id, userId: user.id, label: session.label })
return { return {
id: session.id, id: session.id,
label: session.label, label: session.label,
} }
})
}) })

View File

@@ -3,7 +3,7 @@ import { getLiveSession } from '../../../utils/liveSessions.js'
import { getTransport } from '../../../utils/mediasoup.js' import { getTransport } from '../../../utils/mediasoup.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
requireAuth(event) // Verify authentication const user = requireAuth(event) // Verify authentication
const body = await readBody(event).catch(() => ({})) const body = await readBody(event).catch(() => ({}))
const { sessionId, transportId, dtlsParameters } = body const { sessionId, transportId, dtlsParameters } = body
@@ -15,8 +15,12 @@ export default defineEventHandler(async (event) => {
if (!session) { if (!session) {
throw createError({ statusCode: 404, message: 'Session not found' }) 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) const transport = getTransport(transportId)
if (!transport) { if (!transport) {

View File

@@ -3,7 +3,7 @@ import { getLiveSession } from '../../../utils/liveSessions.js'
import { getRouter, getTransport, getProducer, createConsumer } from '../../../utils/mediasoup.js' import { getRouter, getTransport, getProducer, createConsumer } from '../../../utils/mediasoup.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
requireAuth(event) // Verify authentication const user = requireAuth(event) // Verify authentication
const body = await readBody(event).catch(() => ({})) const body = await readBody(event).catch(() => ({}))
const { sessionId, transportId, rtpCapabilities } = body const { sessionId, transportId, rtpCapabilities } = body
@@ -15,6 +15,12 @@ export default defineEventHandler(async (event) => {
if (!session) { if (!session) {
throw createError({ statusCode: 404, message: `Session not found: ${sessionId}` }) 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) { if (!session.producerId) {
throw createError({ statusCode: 404, message: 'No producer available for this session' }) throw createError({ statusCode: 404, message: 'No producer available for this session' })
} }

View File

@@ -1,6 +1,7 @@
import { requireAuth } from '../../../utils/authHelpers.js' import { requireAuth } from '../../../utils/authHelpers.js'
import { getLiveSession, updateLiveSession } from '../../../utils/liveSessions.js' import { getLiveSession, updateLiveSession } from '../../../utils/liveSessions.js'
import { getTransport, producers } from '../../../utils/mediasoup.js' import { getTransport, producers } from '../../../utils/mediasoup.js'
import { acquire } from '../../../utils/asyncLock.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const user = requireAuth(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' }) throw createError({ statusCode: 400, message: 'sessionId, transportId, kind, and rtpParameters required' })
} }
const session = getLiveSession(sessionId) return await acquire(`create-producer-${sessionId}`, async () => {
if (!session) { const session = getLiveSession(sessionId)
throw createError({ statusCode: 404, message: 'Session not found' }) if (!session) {
} throw createError({ statusCode: 404, message: 'Session not found' })
if (session.userId !== user.id) { }
throw createError({ statusCode: 403, message: 'Forbidden' }) if (session.userId !== user.id) {
} throw createError({ statusCode: 403, message: 'Forbidden' })
}
const transport = getTransport(transportId) const transport = getTransport(transportId)
if (!transport) { if (!transport) {
throw createError({ statusCode: 404, message: 'Transport not found' }) throw createError({ statusCode: 404, message: 'Transport not found' })
} }
const producer = await transport.produce({ kind, rtpParameters }) const producer = await transport.produce({ kind, rtpParameters })
producers.set(producer.id, producer) producers.set(producer.id, producer)
producer.on('close', () => { producer.on('close', async () => {
producers.delete(producer.id) producers.delete(producer.id)
const s = getLiveSession(sessionId) const s = getLiveSession(sessionId)
if (s && s.producerId === producer.id) { if (s && s.producerId === producer.id) {
updateLiveSession(sessionId, { producerId: null }) 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,
}
}) })

View File

@@ -2,6 +2,7 @@ import { getRequestURL } from 'h3'
import { requireAuth } from '../../../utils/authHelpers.js' import { requireAuth } from '../../../utils/authHelpers.js'
import { getLiveSession, updateLiveSession } from '../../../utils/liveSessions.js' import { getLiveSession, updateLiveSession } from '../../../utils/liveSessions.js'
import { getRouter, createTransport } from '../../../utils/mediasoup.js' import { getRouter, createTransport } from '../../../utils/mediasoup.js'
import { acquire } from '../../../utils/asyncLock.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const user = requireAuth(event) const user = requireAuth(event)
@@ -12,28 +13,38 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, message: 'sessionId required' }) throw createError({ statusCode: 400, message: 'sessionId required' })
} }
const session = getLiveSession(sessionId) return await acquire(`create-transport-${sessionId}`, async () => {
if (!session) { const session = getLiveSession(sessionId)
throw createError({ statusCode: 404, message: 'Session not found' }) if (!session) {
} throw createError({ statusCode: 404, message: 'Session not found' })
}
// Only publisher (session owner) can create producer transport // Only publisher (session owner) can create producer transport
// Viewers can create consumer transports // Viewers can create consumer transports
if (isProducer && session.userId !== user.id) { if (isProducer && session.userId !== user.id) {
throw createError({ statusCode: 403, message: 'Forbidden' }) throw createError({ statusCode: 403, message: 'Forbidden' })
} }
const url = getRequestURL(event) const url = getRequestURL(event)
const requestHost = url.hostname const requestHost = url.hostname
const router = await getRouter(sessionId) const router = await getRouter(sessionId)
const { transport, params } = await createTransport(router, requestHost) const { transport, params } = await createTransport(router, requestHost)
if (isProducer) { if (isProducer) {
updateLiveSession(sessionId, { try {
transportId: transport.id, await updateLiveSession(sessionId, {
routerId: router.id, 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
})
}) })

View File

@@ -3,7 +3,7 @@ import { getLiveSession } from '../../../utils/liveSessions.js'
import { getRouter } from '../../../utils/mediasoup.js' import { getRouter } from '../../../utils/mediasoup.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
requireAuth(event) const user = requireAuth(event)
const sessionId = getQuery(event).sessionId const sessionId = getQuery(event).sessionId
if (!sessionId) { if (!sessionId) {
@@ -15,6 +15,11 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 404, message: 'Session not found' }) 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) const router = await getRouter(sessionId)
return router.rtpCapabilities return router.rtpCapabilities
}) })

View File

@@ -1,32 +1,11 @@
/** const CONSOLE_METHOD = Object.freeze({ error: 'error', warn: 'warn', info: 'log', debug: 'log' })
* Client-side logging endpoint.
* Accepts log messages from the browser and outputs them server-side.
*/
export default defineEventHandler(async (event) => {
// Note: Auth is optional - we rely on session cookie validation if needed
export default defineEventHandler(async (event) => {
const body = await readBody(event).catch(() => ({})) const body = await readBody(event).catch(() => ({}))
const { level, message, data, sessionId, userId } = body const { level, message, data, sessionId, userId } = body
const prefix = `[CLIENT${sessionId ? `:${sessionId}` : ''}${userId ? `:${userId.slice(0, 8)}` : ''}]`
const logPrefix = `[CLIENT${sessionId ? `:${sessionId}` : ''}${userId ? `:${userId.slice(0, 8)}` : ''}]` const msg = data ? `${message} ${JSON.stringify(data)}` : message
const logMessage = data ? `${message} ${JSON.stringify(data)}` : message const method = CONSOLE_METHOD[level] || 'log'
console[method](prefix, msg)
switch (level) {
case 'error':
console.error(logPrefix, logMessage)
break
case 'warn':
console.warn(logPrefix, logMessage)
break
case 'info':
console.log(logPrefix, logMessage)
break
case 'debug':
console.log(logPrefix, logMessage)
break
default:
console.log(logPrefix, logMessage)
}
return { ok: true } return { ok: true }
}) })

View File

@@ -1,5 +1,11 @@
export default defineEventHandler((event) => { export default defineEventHandler((event) => {
const user = event.context.user const user = event.context.user
if (!user) throw createError({ statusCode: 401, message: 'Unauthorized' }) if (!user) throw createError({ statusCode: 401, message: 'Unauthorized' })
return { id: user.id, identifier: user.identifier, role: user.role, auth_provider: user.auth_provider ?? 'local' } return {
id: user.id,
identifier: user.identifier,
role: user.role,
auth_provider: user.auth_provider ?? 'local',
avatar_url: user.avatar_path ? '/api/me/avatar' : null,
}
}) })

View File

@@ -0,0 +1,21 @@
import { unlink } from 'node:fs/promises'
import { join } from 'node:path'
import { getDb, getAvatarsDir } from '../../utils/db.js'
import { requireAuth } from '../../utils/authHelpers.js'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
if (!user.avatar_path) return { ok: true }
// 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])
return { ok: true }
})

View File

@@ -0,0 +1,30 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { getAvatarsDir } from '../../utils/db.js'
import { requireAuth } from '../../utils/authHelpers.js'
const MIME = Object.freeze({ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png' })
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
if (!user.avatar_path) throw createError({ statusCode: 404, message: 'No avatar' })
// 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)
setResponseHeader(event, 'Content-Type', mime)
setResponseHeader(event, 'Cache-Control', 'private, max-age=3600')
return buf
}
catch {
throw createError({ statusCode: 404, message: 'Avatar not found' })
}
})

View File

@@ -0,0 +1,57 @@
import { writeFile, unlink } from 'node:fs/promises'
import { join } from 'node:path'
import { readMultipartFormData } from 'h3'
import { getDb, getAvatarsDir } from '../../utils/db.js'
import { requireAuth } from '../../utils/authHelpers.js'
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)
const file = form?.find(f => f.name === 'avatar' && f.data)
if (!file || !file.filename) throw createError({ statusCode: 400, message: 'Missing avatar file' })
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' })
// 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)
await writeFile(path, file.data)
const { run } = await getDb()
const previous = user.avatar_path
await run('UPDATE users SET avatar_path = ? WHERE id = ?', [filename, user.id])
if (previous && previous !== filename) {
const oldPath = join(dir, previous)
await unlink(oldPath).catch(() => {})
}
return { ok: true }
})

View File

@@ -0,0 +1,26 @@
import { getDb } from '../../utils/db.js'
import { requireAuth } from '../../utils/authHelpers.js'
import { hashPassword } from '../../utils/password.js'
export default defineEventHandler(async (event) => {
const currentUser = requireAuth(event)
const body = await readBody(event).catch(() => ({}))
const password = body?.password
if (typeof password !== 'string' || password.length < 1) {
throw createError({ statusCode: 400, message: 'Password is required' })
}
const { get, run } = await getDb()
const user = await get(
'SELECT id, auth_provider FROM users WHERE id = ?',
[currentUser.id],
)
if (!user) {
throw createError({ statusCode: 404, message: 'User not found' })
}
const hash = hashPassword(password)
await run('UPDATE users SET cot_password_hash = ? WHERE id = ?', [hash, currentUser.id])
return { ok: true }
})

View File

@@ -1,18 +1,15 @@
import { getDb } from '../utils/db.js' import { getDb } from '../utils/db.js'
import { requireAuth } from '../utils/authHelpers.js' import { requireAuth } from '../utils/authHelpers.js'
import { POI_ICON_TYPES } from '../utils/validation.js'
const ICON_TYPES = ['pin', 'flag', 'waypoint']
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
requireAuth(event, { role: 'adminOrLeader' }) requireAuth(event, { role: 'adminOrLeader' })
const body = await readBody(event) const body = await readBody(event)
const lat = Number(body?.lat) const lat = Number(body?.lat)
const lng = Number(body?.lng) const lng = Number(body?.lng)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) { if (!Number.isFinite(lat) || !Number.isFinite(lng)) throw createError({ statusCode: 400, message: 'lat and lng required as numbers' })
throw createError({ statusCode: 400, message: 'lat and lng required as numbers' })
}
const label = typeof body?.label === 'string' ? body.label.trim() : '' const label = typeof body?.label === 'string' ? body.label.trim() : ''
const iconType = ICON_TYPES.includes(body?.iconType) ? body.iconType : 'pin' const iconType = POI_ICON_TYPES.includes(body?.iconType) ? body.iconType : 'pin'
const id = crypto.randomUUID() const id = crypto.randomUUID()
const { run } = await getDb() const { run } = await getDb()
await run( await run(

View File

@@ -1,40 +1,37 @@
import { getDb } from '../../utils/db.js' import { getDb } from '../../utils/db.js'
import { requireAuth } from '../../utils/authHelpers.js' import { requireAuth } from '../../utils/authHelpers.js'
import { POI_ICON_TYPES } from '../../utils/validation.js'
const ICON_TYPES = ['pin', 'flag', 'waypoint'] import { buildUpdateQuery } from '../../utils/queryBuilder.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
requireAuth(event, { role: 'adminOrLeader' }) requireAuth(event, { role: 'adminOrLeader' })
const id = event.context.params?.id const id = event.context.params?.id
if (!id) throw createError({ statusCode: 400, message: 'id required' }) if (!id) throw createError({ statusCode: 400, message: 'id required' })
const body = await readBody(event) || {} const body = (await readBody(event)) || {}
const updates = [] const updates = {}
const params = []
if (typeof body.label === 'string') { if (typeof body.label === 'string') {
updates.push('label = ?') updates.label = body.label.trim()
params.push(body.label.trim())
} }
if (ICON_TYPES.includes(body.iconType)) { if (POI_ICON_TYPES.includes(body.iconType)) {
updates.push('icon_type = ?') updates.icon_type = body.iconType
params.push(body.iconType)
} }
if (Number.isFinite(body.lat)) { if (Number.isFinite(body.lat)) {
updates.push('lat = ?') updates.lat = body.lat
params.push(body.lat)
} }
if (Number.isFinite(body.lng)) { if (Number.isFinite(body.lng)) {
updates.push('lng = ?') updates.lng = body.lng
params.push(body.lng)
} }
if (updates.length === 0) { if (Object.keys(updates).length === 0) {
const { get } = await getDb() const { get } = await getDb()
const row = await get('SELECT id, lat, lng, label, icon_type FROM pois WHERE id = ?', [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' }) if (!row) throw createError({ statusCode: 404, message: 'POI not found' })
return row return row
} }
params.push(id)
const { run, get } = await getDb() 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]) 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' }) if (!row) throw createError({ statusCode: 404, message: 'POI not found' })
return row return row

View File

@@ -1,4 +1,4 @@
import { getDb } from '../utils/db.js' import { getDb, withTransaction } from '../utils/db.js'
import { requireAuth } from '../utils/authHelpers.js' import { requireAuth } from '../utils/authHelpers.js'
import { hashPassword } from '../utils/password.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' }) throw createError({ statusCode: 400, message: 'role must be admin, leader, or member' })
} }
const { run, get } = await getDb() const db = await getDb()
const existing = await get('SELECT id FROM users WHERE identifier = ?', [identifier]) return withTransaction(db, async ({ run, get }) => {
if (existing) { const existing = await get('SELECT id FROM users WHERE identifier = ?', [identifier])
throw createError({ statusCode: 409, message: 'Identifier already in use' }) if (existing) {
} throw createError({ statusCode: 409, message: 'Identifier already in use' })
}
const id = crypto.randomUUID() const id = crypto.randomUUID()
const now = new Date().toISOString() const now = new Date().toISOString()
await run( await run(
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', '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], [id, identifier, hashPassword(password), role, now, 'local', null, null],
) )
const user = await get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id]) const user = await get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id])
return user return user
})
}) })

View File

@@ -1,6 +1,7 @@
import { getDb } from '../../utils/db.js' import { getDb, withTransaction } from '../../utils/db.js'
import { requireAuth } from '../../utils/authHelpers.js' import { requireAuth } from '../../utils/authHelpers.js'
import { hashPassword } from '../../utils/password.js' import { hashPassword } from '../../utils/password.js'
import { buildUpdateQuery } from '../../utils/queryBuilder.js'
const ROLES = ['admin', 'leader', 'member'] const ROLES = ['admin', 'leader', 'member']
@@ -9,52 +10,52 @@ export default defineEventHandler(async (event) => {
const id = event.context.params?.id const id = event.context.params?.id
if (!id) throw createError({ statusCode: 400, message: 'id required' }) if (!id) throw createError({ statusCode: 400, message: 'id required' })
const body = await readBody(event) 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]) return withTransaction(db, async ({ run, get }) => {
if (!user) throw createError({ statusCode: 404, message: 'User not found' }) 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 updates = {}
const params = []
if (body?.role !== undefined) { if (body?.role !== undefined) {
const role = body.role const role = body.role
if (!role || !ROLES.includes(role)) { if (!role || !ROLES.includes(role)) {
throw createError({ statusCode: 400, message: 'role must be admin, leader, or member' }) 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' })
} }
const existing = await get('SELECT id FROM users WHERE identifier = ? AND id != ?', [identifier, id]) updates.role = role
if (existing) {
throw createError({ statusCode: 409, message: 'Identifier already in use' })
}
updates.push('identifier = ?')
params.push(identifier)
} }
if (body?.password !== undefined && body.password !== '') {
const password = body.password if (user.auth_provider === 'local') {
if (typeof password !== 'string' || password.length < 1) { if (body?.identifier !== undefined) {
throw createError({ statusCode: 400, message: 'password cannot be empty' }) 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) { if (Object.keys(updates).length === 0) {
return { id: user.id, identifier: user.identifier, role: user.role, auth_provider: user.auth_provider ?? 'local' } return { id: user.id, identifier: user.identifier, role: user.role, auth_provider: user.auth_provider ?? 'local' }
} }
params.push(id) const { query, params } = buildUpdateQuery('users', null, updates)
await run(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, params) if (query) {
const updated = await get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id]) await run(query, [...params, id])
return updated }
const updated = await get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [id])
return updated
})
}) })

View File

@@ -1,6 +1,6 @@
import { getCookie } from 'h3' import { getCookie } from 'h3'
import { getDb } from '../utils/db.js' import { getDb } from '../utils/db.js'
import { skipAuth } from '../utils/authSkipPaths.js' import { skipAuth } from '../utils/authHelpers.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
if (skipAuth(event.path)) return if (skipAuth(event.path)) return
@@ -10,10 +10,16 @@ export default defineEventHandler(async (event) => {
const { get } = await getDb() const { get } = await getDb()
const session = await get('SELECT user_id, expires_at FROM sessions WHERE id = ?', [sid]) const session = await get('SELECT user_id, expires_at FROM sessions WHERE id = ?', [sid])
if (!session || new Date(session.expires_at) < new Date()) return if (!session || new Date(session.expires_at) < new Date()) return
const user = await get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [session.user_id]) const user = await get('SELECT id, identifier, role, auth_provider, avatar_path FROM users WHERE id = ?', [session.user_id])
if (user) { if (user) {
const authProvider = user.auth_provider ?? 'local' const authProvider = user.auth_provider ?? 'local'
event.context.user = { id: user.id, identifier: user.identifier, role: user.role, auth_provider: authProvider } event.context.user = {
id: user.id,
identifier: user.identifier,
role: user.role,
auth_provider: authProvider,
avatar_path: user.avatar_path ?? null,
}
} }
} }
catch { catch {

262
server/plugins/cot.js Normal file
View File

@@ -0,0 +1,262 @@
import { createServer as createTcpServer } from 'node:net'
import { createServer as createTlsServer } from 'node:tls'
import { readFileSync, existsSync } from 'node:fs'
import { updateFromCot } from '../utils/cotStore.js'
import { parseTakStreamFrame, parseTraditionalXmlFrame, parseCotPayload } from '../utils/cotParser.js'
import { validateCotAuth } from '../utils/cotAuth.js'
import { getCotSslPaths, getCotPort } from '../utils/cotSsl.js'
import { registerCleanup } from '../utils/shutdown.js'
import { COT_AUTH_TIMEOUT_MS } from '../utils/constants.js'
import { acquire } from '../utils/asyncLock.js'
const serverState = {
tcpServer: null,
tlsServer: null,
}
const relaySet = new Set()
const allSockets = new Set()
const socketBuffers = new WeakMap()
const socketAuthTimeout = new WeakMap()
function clearAuthTimeout(socket) {
const t = socketAuthTimeout.get(socket)
if (t) {
clearTimeout(t)
socketAuthTimeout.delete(socket)
}
}
function removeFromRelay(socket) {
relaySet.delete(socket)
allSockets.delete(socket)
clearAuthTimeout(socket)
socketBuffers.delete(socket)
}
function broadcast(senderSocket, rawMessage) {
for (const s of relaySet) {
if (s !== senderSocket && !s.destroyed && s.writable) {
try {
s.write(rawMessage)
}
catch (err) {
console.error('[cot] Broadcast write error:', err?.message)
}
}
}
}
const createPreview = (payload) => {
try {
const str = payload.toString('utf8')
if (str.startsWith('<')) {
const s = str.length <= 120 ? str : str.slice(0, 120) + '...'
// eslint-disable-next-line no-control-regex -- sanitize control chars for log preview
return s.replace(/[\u0000-\u0008\v\f\u000E-\u001F]/g, '.')
}
return 'hex:' + payload.subarray(0, Math.min(40, payload.length)).toString('hex')
}
catch {
return 'hex:' + payload.subarray(0, Math.min(40, payload.length)).toString('hex')
}
}
async function processFrame(socket, rawMessage, payload, authenticated) {
const requireAuth = socket._cotRequireAuth !== false
const debug = socket._cotDebug === true
const parsed = parseCotPayload(payload)
if (debug) {
const preview = createPreview(payload)
console.log('[cot] payload length:', payload.length, 'parsed:', parsed ? parsed.type : null, 'preview:', preview)
}
if (!parsed) return
if (parsed.type === 'auth') {
if (authenticated) return
console.log('[cot] auth attempt username=', parsed.username)
// Use lock per socket to prevent concurrent auth attempts
const socketKey = `cot-auth-${socket.remoteAddress || 'unknown'}-${socket.remotePort || 0}`
await acquire(socketKey, async () => {
// Re-check authentication state after acquiring lock
if (socket._cotAuthenticated || socket.destroyed) return
try {
const valid = await validateCotAuth(parsed.username, parsed.password)
console.log('[cot] auth result valid=', valid, 'for username=', parsed.username)
if (!socket.writable || socket.destroyed) return
if (valid) {
clearAuthTimeout(socket)
relaySet.add(socket)
socket._cotAuthenticated = true
}
else {
socket.destroy()
}
}
catch (err) {
console.log('[cot] auth validation error:', err?.message)
if (!socket.destroyed) socket.destroy()
}
}).catch((err) => {
console.log('[cot] auth lock error:', err?.message)
if (!socket.destroyed) socket.destroy()
})
return
}
if (parsed.type === 'cot') {
if (requireAuth && !authenticated) {
socket.destroy()
return
}
updateFromCot(parsed).catch((err) => {
console.error('[cot] Error updating from CoT:', err?.message)
})
if (authenticated) broadcast(socket, rawMessage)
}
}
const parseFrame = (buf) => {
const takResult = parseTakStreamFrame(buf)
if (takResult) return { result: takResult, frameType: 'tak' }
if (buf[0] === 0x3C) {
const xmlResult = parseTraditionalXmlFrame(buf)
if (xmlResult) return { result: xmlResult, frameType: 'traditional' }
}
return { result: null, frameType: null }
}
const processBufferedData = async (socket, buf, authenticated) => {
if (buf.length === 0) return buf
const { result, frameType } = parseFrame(buf)
if (result && socket._cotDebug) {
console.log('[cot] frame parsed as', frameType, 'bytesConsumed=', result.bytesConsumed)
}
if (!result) return buf
const { payload, bytesConsumed } = result
const rawMessage = buf.subarray(0, bytesConsumed)
await processFrame(socket, rawMessage, payload, authenticated)
if (socket.destroyed) return null
const remainingBuf = buf.subarray(bytesConsumed)
socketBuffers.set(socket, remainingBuf)
return processBufferedData(socket, remainingBuf, authenticated)
}
async function onData(socket, data) {
const existingBuf = socketBuffers.get(socket)
const buf = Buffer.concat([existingBuf || Buffer.alloc(0), data])
socketBuffers.set(socket, buf)
const authenticated = Boolean(socket._cotAuthenticated)
if (socket._cotDebug && buf.length > 0 && !socket._cotFirstChunkLogged) {
socket._cotFirstChunkLogged = true
const hex = buf.subarray(0, Math.min(80, buf.length)).toString('hex')
console.log('[cot] first chunk len=', buf.length, 'first bytes (hex):', hex, 'starts with 0xBF:', buf[0] === 0xBF, 'starts with <:', buf[0] === 0x3C)
}
await processBufferedData(socket, buf, authenticated)
}
function setupSocket(socket, tls = false) {
const remote = socket.remoteAddress || 'unknown'
console.log('[cot] client connected', tls ? '(TLS)' : '(TCP)', 'from', remote)
allSockets.add(socket)
const config = useRuntimeConfig()
socket._cotDebug = Boolean(config.cotDebug)
socket._cotRequireAuth = config.cotRequireAuth !== false
if (socket._cotRequireAuth) {
const timeout = setTimeout(() => {
if (!socket._cotAuthenticated && !socket.destroyed) {
console.log('[cot] auth timeout, closing connection from', remote)
socket.destroy()
}
}, COT_AUTH_TIMEOUT_MS)
socketAuthTimeout.set(socket, timeout)
}
else {
socket._cotAuthenticated = true
relaySet.add(socket)
}
socket.on('data', data => onData(socket, data))
socket.on('error', (err) => {
console.error('[cot] Socket error:', err?.message)
})
socket.on('close', () => {
console.log('[cot] client disconnected', socket._cotAuthenticated ? '(was authenticated)' : '', 'from', remote)
removeFromRelay(socket)
})
}
function startCotServers() {
const config = useRuntimeConfig()
const { certPath, keyPath } = getCotSslPaths(config) || {}
const hasTls = certPath && keyPath && existsSync(certPath) && existsSync(keyPath)
const port = getCotPort()
try {
if (hasTls) {
const tlsOpts = {
cert: readFileSync(certPath),
key: readFileSync(keyPath),
rejectUnauthorized: false,
}
serverState.tlsServer = createTlsServer(tlsOpts, socket => setupSocket(socket, true))
serverState.tlsServer.on('error', err => console.error('[cot] TLS server error:', err?.message))
serverState.tlsServer.listen(port, '0.0.0.0', () => {
console.log('[cot] CoT server listening on 0.0.0.0:' + port + ' (TLS) - use this port in ATAK/iTAK and enable SSL')
})
}
else {
serverState.tcpServer = createTcpServer(socket => setupSocket(socket, false))
serverState.tcpServer.on('error', err => console.error('[cot] TCP server error:', err?.message))
serverState.tcpServer.listen(port, '0.0.0.0', () => {
console.log('[cot] CoT server listening on 0.0.0.0:' + port + ' (plain TCP) - use this port in ATAK/iTAK with SSL disabled')
})
}
}
catch (err) {
console.error('[cot] Failed to start CoT server:', err?.message)
if (err?.code === 'EADDRINUSE') {
console.error('[cot] Port', port, 'is already in use. Stop the other process or set COT_PORT to a different port.')
}
}
}
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('ready', startCotServers)
// Start immediately so CoT is up before first request in dev; ready may fire late in some setups.
setImmediate(startCotServers)
const cleanupServers = () => {
if (serverState.tcpServer) {
serverState.tcpServer.close()
serverState.tcpServer = null
}
if (serverState.tlsServer) {
serverState.tlsServer.close()
serverState.tlsServer = null
}
}
const cleanupSockets = () => {
for (const s of allSockets) {
try {
s.destroy()
}
catch {
/* ignore */
}
}
allSockets.clear()
relaySet.clear()
}
registerCleanup(async () => {
cleanupSockets()
cleanupServers()
})
nitroApp.hooks.hook('close', async () => {
cleanupSockets()
cleanupServers()
})
})

View File

@@ -1,9 +1,5 @@
import { getDb, closeDb } from '../utils/db.js' import { getDb, closeDb } from '../utils/db.js'
/**
* Initialize DB at server startup.
* Close DB on server shutdown to avoid native sqlite3 crashes in worker teardown.
*/
export default defineNitroPlugin((nitroApp) => { export default defineNitroPlugin((nitroApp) => {
void getDb() void getDb()
nitroApp.hooks.hook('close', () => { nitroApp.hooks.hook('close', () => {

View File

@@ -1,17 +1,8 @@
/**
* WebSocket server for WebRTC signaling.
* Attaches to Nitro's HTTP server and handles WebSocket connections.
*/
import { WebSocketServer } from 'ws' import { WebSocketServer } from 'ws'
import { getDb } from '../utils/db.js' import { getDb } from '../utils/db.js'
import { handleWebSocketMessage } from '../utils/webrtcSignaling.js' import { handleWebSocketMessage } from '../utils/webrtcSignaling.js'
import { registerCleanup } from '../utils/shutdown.js'
/**
* Parse cookie header string into object.
* @param {string} cookieHeader
* @returns {Record<string, string>} Parsed cookie name-value pairs.
*/
function parseCookie(cookieHeader) { function parseCookie(cookieHeader) {
const cookies = {} const cookies = {}
if (!cookieHeader) return cookies if (!cookieHeader) return cookies
@@ -25,30 +16,16 @@ function parseCookie(cookieHeader) {
} }
let wss = null let wss = null
const connections = new Map() // sessionId -> Set<WebSocket> const connections = new Map()
/**
* Get WebSocket server instance.
* @returns {WebSocketServer | null} WebSocket server instance or null.
*/
export function getWebSocketServer() { export function getWebSocketServer() {
return wss return wss
} }
/**
* Get connections for a session.
* @param {string} sessionId
* @returns {Set<WebSocket>} Set of WebSockets for the session.
*/
export function getSessionConnections(sessionId) { export function getSessionConnections(sessionId) {
return connections.get(sessionId) || new Set() return connections.get(sessionId) || new Set()
} }
/**
* Add connection to session.
* @param {string} sessionId
* @param {WebSocket} ws
*/
export function addSessionConnection(sessionId, ws) { export function addSessionConnection(sessionId, ws) {
if (!connections.has(sessionId)) { if (!connections.has(sessionId)) {
connections.set(sessionId, new Set()) connections.set(sessionId, new Set())
@@ -56,11 +33,6 @@ export function addSessionConnection(sessionId, ws) {
connections.get(sessionId).add(ws) connections.get(sessionId).add(ws)
} }
/**
* Remove connection from session.
* @param {string} sessionId
* @param {WebSocket} ws
*/
export function removeSessionConnection(sessionId, ws) { export function removeSessionConnection(sessionId, ws) {
const conns = connections.get(sessionId) const conns = connections.get(sessionId)
if (conns) { if (conns) {
@@ -71,11 +43,6 @@ export function removeSessionConnection(sessionId, ws) {
} }
} }
/**
* Send message to all connections for a session.
* @param {string} sessionId
* @param {object} message
*/
export function broadcastToSession(sessionId, message) { export function broadcastToSession(sessionId, message) {
const conns = getSessionConnections(sessionId) const conns = getSessionConnections(sessionId)
const data = JSON.stringify(message) const data = JSON.stringify(message)
@@ -113,8 +80,15 @@ export default defineNitroPlugin((nitroApp) => {
callback(false, 401, 'Unauthorized') callback(false, 401, 'Unauthorized')
return 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.userId = session.user_id
info.req.userRole = user.role
callback(true) callback(true)
} }
catch (err) { catch (err) {
@@ -126,7 +100,8 @@ export default defineNitroPlugin((nitroApp) => {
wss.on('connection', (ws, req) => { wss.on('connection', (ws, req) => {
const userId = req.userId const userId = req.userId
if (!userId) { const userRole = req.userRole
if (!userId || !userRole) {
ws.close(1008, 'Unauthorized') ws.close(1008, 'Unauthorized')
return return
} }
@@ -143,6 +118,20 @@ export default defineNitroPlugin((nitroApp) => {
return 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 // Track session connection
if (currentSessionId !== sessionId) { if (currentSessionId !== sessionId) {
if (currentSessionId) { if (currentSessionId) {
@@ -176,6 +165,13 @@ export default defineNitroPlugin((nitroApp) => {
}) })
console.log('[websocket] WebSocket server started on /ws') console.log('[websocket] WebSocket server started on /ws')
registerCleanup(async () => {
if (wss) {
wss.close()
wss = null
}
})
}) })
nitroApp.hooks.hook('close', () => { nitroApp.hooks.hook('close', () => {

View File

@@ -1 +1,9 @@
export default defineEventHandler(() => ({ status: 'ready' })) import { healthCheck } from '../../utils/db.js'
export default defineEventHandler(async () => {
const health = await healthCheck()
if (!health.healthy) {
throw createError({ statusCode: 503, message: 'Database not ready' })
}
return { status: 'ready' }
})

47
server/utils/asyncLock.js Normal file
View File

@@ -0,0 +1,47 @@
/**
* Async lock utility - Promise-based mutex per key.
* Ensures only one async operation executes per key at a time.
*/
const locks = new Map()
/**
* Get or create a queue for a lock key.
* @param {string} lockKey - Lock key
* @returns {Promise<any>} Existing or new queue promise
*/
const getOrCreateQueue = (lockKey) => {
const existingQueue = locks.get(lockKey)
if (existingQueue) return existingQueue
const newQueue = Promise.resolve()
locks.set(lockKey, newQueue)
return newQueue
}
/**
* Acquire a lock for a key and execute callback.
* Only one callback per key executes at a time.
* @param {string} key - Lock key
* @param {Function} callback - Async function to execute
* @returns {Promise<any>} Result of callback
*/
export async function acquire(key, callback) {
const lockKey = String(key)
const queue = getOrCreateQueue(lockKey)
const next = queue.then(() => callback()).finally(() => {
if (locks.get(lockKey) === next) {
locks.delete(lockKey)
}
})
locks.set(lockKey, next)
return next
}
/**
* Clear all locks (for testing).
*/
export function clearLocks() {
locks.clear()
}

View File

@@ -1,17 +0,0 @@
/**
* Read auth config from env. Returns only non-secret data for client.
* Auth always allows local (password) sign-in and OIDC when configured.
* @returns {{ oidc: { enabled: boolean, label: string } }} Public auth config (oidc.enabled, oidc.label).
*/
export function getAuthConfig() {
const hasOidcEnv
= process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET
const envLabel = process.env.OIDC_LABEL ?? ''
const label = envLabel || (hasOidcEnv ? 'Sign in with OIDC' : '')
return {
oidc: {
enabled: !!hasOidcEnv,
label,
},
}
}

View File

@@ -1,20 +1,33 @@
/** const ROLES_ADMIN_OR_LEADER = Object.freeze(['admin', 'leader'])
* Require authenticated user. Optionally require role. Throws 401 if none, 403 if role insufficient.
* @param {import('h3').H3Event} event
* @param {{ role?: 'admin' | 'adminOrLeader' }} [opts] - role: 'admin' = admin only; 'adminOrLeader' = admin or leader
* @returns {{ id: string, identifier: string, role: string }} The current user.
*/
export function requireAuth(event, opts = {}) { export function requireAuth(event, opts = {}) {
const user = event.context.user const user = event.context.user
if (!user) { if (!user) throw createError({ statusCode: 401, message: 'Unauthorized' })
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
const { role } = opts const { role } = opts
if (role === 'admin' && user.role !== 'admin') { if (role === 'admin' && user.role !== 'admin') throw createError({ statusCode: 403, message: 'Forbidden' })
throw createError({ statusCode: 403, message: 'Forbidden' }) if (role === 'adminOrLeader' && !ROLES_ADMIN_OR_LEADER.includes(user.role)) throw createError({ statusCode: 403, message: 'Forbidden' })
}
if (role === 'adminOrLeader' && user.role !== 'admin' && user.role !== 'leader') {
throw createError({ statusCode: 403, message: 'Forbidden' })
}
return user return user
} }
// Auth path utilities
export const SKIP_PATHS = Object.freeze([
'/api/auth/login',
'/api/auth/logout',
'/api/auth/config',
'/api/auth/oidc/authorize',
'/api/auth/oidc/callback',
])
export const PROTECTED_PATH_PREFIXES = Object.freeze([
'/api/cameras',
'/api/devices',
'/api/live',
'/api/me',
'/api/pois',
'/api/users',
])
export function skipAuth(path) {
if (path.startsWith('/api/health') || path === '/health') return true
return SKIP_PATHS.some(p => path === p || path.startsWith(p + '/'))
}

View File

@@ -1,32 +0,0 @@
/**
* Paths that skip auth middleware (no session required).
* Do not add a path here if any handler under it uses requireAuth (with or without role).
* When adding a new API route that requires auth, add its path prefix to PROTECTED_PATH_PREFIXES below
* so tests can assert it is never skipped.
*/
export const SKIP_PATHS = [
'/api/auth/login',
'/api/auth/logout',
'/api/auth/config',
'/api/auth/oidc/authorize',
'/api/auth/oidc/callback',
]
/**
* Path prefixes for API routes that require an authenticated user (or role).
* Every path in this list must NOT be skipped (skipAuth must return false).
* Used by tests to prevent protected routes from being added to SKIP_PATHS.
*/
export const PROTECTED_PATH_PREFIXES = [
'/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 + '/'))
}

View File

@@ -1,29 +0,0 @@
import { randomBytes } from 'node:crypto'
import { hashPassword } from './password.js'
const DEFAULT_ADMIN_IDENTIFIER = 'admin'
const PASSWORD_CHARS = 'abcdefghjkmnopqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789'
const generateRandomPassword = () => {
const bytes = randomBytes(14)
return Array.from(bytes, 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 : DEFAULT_ADMIN_IDENTIFIER
const plainPassword = (email && password) ? password : generateRandomPassword()
await run(
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[crypto.randomUUID(), identifier, hashPassword(plainPassword), 'admin', new Date().toISOString(), 'local', null, null],
)
if (!email || !password) {
console.log(`\n[KestrelOS] No bootstrap admin configured. Default admin created. Sign in at /login with:\n\n Identifier: ${identifier}\n Password: ${plainPassword}\n\n Set BOOTSTRAP_EMAIL and BOOTSTRAP_PASSWORD to use your own credentials on first run.\n`)
}
}

30
server/utils/constants.js Normal file
View File

@@ -0,0 +1,30 @@
/**
* Application constants with environment variable support.
*/
// Timeouts (milliseconds)
export const COT_AUTH_TIMEOUT_MS = Number(process.env.COT_AUTH_TIMEOUT_MS) || 15_000
export const LIVE_SESSION_TTL_MS = Number(process.env.LIVE_SESSION_TTL_MS) || 60_000
export const COT_ENTITY_TTL_MS = Number(process.env.COT_ENTITY_TTL_MS) || 90_000
export const POLL_INTERVAL_MS = Number(process.env.POLL_INTERVAL_MS) || 1500
export const SHUTDOWN_TIMEOUT_MS = Number(process.env.SHUTDOWN_TIMEOUT_MS) || 30_000
// Ports
export const COT_PORT = Number(process.env.COT_PORT) || 8089
export const WEBSOCKET_PATH = process.env.WEBSOCKET_PATH || '/ws'
// Limits
export const MAX_PAYLOAD_BYTES = Number(process.env.MAX_PAYLOAD_BYTES) || 64 * 1024
export const MAX_STRING_LENGTH = Number(process.env.MAX_STRING_LENGTH) || 1000
export const MAX_IDENTIFIER_LENGTH = Number(process.env.MAX_IDENTIFIER_LENGTH) || 255
// Mediasoup
export const MEDIASOUP_RTC_MIN_PORT = Number(process.env.MEDIASOUP_RTC_MIN_PORT) || 40000
export const MEDIASOUP_RTC_MAX_PORT = Number(process.env.MEDIASOUP_RTC_MAX_PORT) || 49999
// Session
const [MIN_DAYS, MAX_DAYS, DEFAULT_DAYS] = [1, 365, 7]
export function getSessionMaxAgeDays() {
const raw = Number.parseInt(process.env.SESSION_MAX_AGE_DAYS ?? '', 10)
return Number.isFinite(raw) ? Math.max(MIN_DAYS, Math.min(MAX_DAYS, raw)) : DEFAULT_DAYS
}

25
server/utils/cotAuth.js Normal file
View File

@@ -0,0 +1,25 @@
import { getDb } from './db.js'
import { verifyPassword } from './password.js'
/**
* Validate CoT auth: local users use password_hash; OIDC users use cot_password_hash (ATAK password).
* @param {string} identifier - KestrelOS identifier (username)
* @param {string} password - Plain password from CoT auth
* @returns {Promise<boolean>} True if valid
*/
export async function validateCotAuth(identifier, password) {
const id = typeof identifier === 'string' ? identifier.trim() : ''
if (!id || typeof password !== 'string') return false
const { get } = await getDb()
const user = await get(
'SELECT auth_provider, password_hash, cot_password_hash FROM users WHERE identifier = ?',
[id],
)
if (!user) return false
const hash = user.auth_provider === 'local' ? user.password_hash : user.cot_password_hash
if (!hash) return false
return verifyPassword(password, hash)
}

151
server/utils/cotParser.js Normal file
View File

@@ -0,0 +1,151 @@
import { XMLParser } from 'fast-xml-parser'
import { MAX_PAYLOAD_BYTES } from './constants.js'
// CoT protocol detection constants
export const COT_FIRST_BYTE_TAK = 0xBF
export const COT_FIRST_BYTE_XML = 0x3C
/** @param {number} byte - First byte of stream. @returns {boolean} */
export function isCotFirstByte(byte) {
return byte === COT_FIRST_BYTE_TAK || byte === COT_FIRST_BYTE_XML
}
const TRADITIONAL_DELIMITER = Buffer.from('</event>', 'utf8')
/**
* @param {Buffer} buf
* @param {number} offset
* @param {number} value - Accumulated value
* @param {number} shift - Current bit shift
* @param {number} bytesRead - Bytes consumed so far
* @returns {{ value: number, bytesRead: number }} Decoded varint and bytes consumed.
*/
function readVarint(buf, offset, value = 0, shift = 0, bytesRead = 0) {
if (offset + bytesRead >= buf.length) return { value, bytesRead }
const b = buf[offset + bytesRead]
const newValue = value + ((b & 0x7F) << shift)
const newBytesRead = bytesRead + 1
if ((b & 0x80) === 0) return { value: newValue, bytesRead: newBytesRead }
const newShift = shift + 7
if (newShift > 28) return { value: 0, bytesRead: 0 }
return readVarint(buf, offset, newValue, newShift, newBytesRead)
}
/**
* TAK stream frame: 0xBF, varint length, payload.
* @param {Buffer} buf
* @returns {{ payload: Buffer, bytesConsumed: number } | null} Frame or null if incomplete/invalid.
*/
export function parseTakStreamFrame(buf) {
if (!buf || buf.length < 2 || buf[0] !== COT_FIRST_BYTE_TAK) return null
const { value: length, bytesRead } = readVarint(buf, 1)
if (length < 0 || length > MAX_PAYLOAD_BYTES) return null
const bytesConsumed = 1 + bytesRead + length
if (buf.length < bytesConsumed) return null
return { payload: buf.subarray(1 + bytesRead, bytesConsumed), bytesConsumed }
}
/**
* Traditional CoT: one XML message delimited by </event>.
* @param {Buffer} buf
* @returns {{ payload: Buffer, bytesConsumed: number } | null} Frame or null if incomplete.
*/
export function parseTraditionalXmlFrame(buf) {
if (!buf || buf.length < 8 || buf[0] !== COT_FIRST_BYTE_XML) return null
const idx = buf.indexOf(TRADITIONAL_DELIMITER)
if (idx === -1) return null
const bytesConsumed = idx + TRADITIONAL_DELIMITER.length
if (bytesConsumed > MAX_PAYLOAD_BYTES) return null
return { payload: buf.subarray(0, bytesConsumed), bytesConsumed }
}
const xmlParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
parseTagValue: false,
ignoreDeclaration: true,
ignorePiTags: true,
processEntities: false, // Disable entity expansion to prevent XML bomb attacks
maxAttributes: 100,
parseAttributeValue: false,
trimValues: true,
parseTrueNumberOnly: false,
arrayMode: false,
stopNodes: [], // Could add depth limit here if needed
})
/**
* Case-insensitive key lookup in nested object.
* @returns {unknown} Found value or undefined.
*/
function findInObject(obj, key) {
if (!obj || typeof obj !== 'object') return undefined
const k = key.toLowerCase()
for (const [name, val] of Object.entries(obj)) {
if (name.toLowerCase() === k) return val
if (typeof val === 'object' && val !== null) {
const found = findInObject(val, key)
if (found !== undefined) return found
}
}
return undefined
}
/**
* Extract { username, password } from detail.auth (or __auth / credentials).
* @returns {{ username: string, password: string } | null} Credentials or null if missing/invalid.
*/
function extractAuth(parsed) {
const detail = findInObject(parsed, 'detail')
if (!detail || typeof detail !== 'object') return null
const auth = findInObject(detail, 'auth') ?? findInObject(detail, '__auth') ?? findInObject(detail, 'credentials')
if (!auth || typeof auth !== 'object') return null
const username = auth['@_username'] ?? auth['@_Username'] ?? auth.username
const password = auth['@_password'] ?? auth['@_Password'] ?? auth.password
if (typeof username !== 'string' || typeof password !== 'string' || !username.trim()) return null
return { username: username.trim(), password }
}
/**
* Parse CoT XML payload into auth or position. Does not mutate payload.
* @param {Buffer} payload - UTF-8 XML
* @returns {{ type: 'auth', username: string, password: string } | { type: 'cot', id: string, lat: number, lng: number, label: string, eventType: string } | null} Auth or position, or null.
*/
export function parseCotPayload(payload) {
if (!payload?.length) return null
const str = payload.toString('utf8').trim()
if (!str.startsWith('<')) return null
try {
const parsed = xmlParser.parse(str)
const event = findInObject(parsed, 'event')
if (!event || typeof event !== 'object') return null
const auth = extractAuth(parsed)
if (auth) return { type: 'auth', username: auth.username, password: auth.password }
const uid = String(event['@_uid'] ?? event.uid ?? '')
const eventType = String(event['@_type'] ?? event.type ?? '')
const point = findInObject(parsed, 'point') ?? findInObject(event, 'point')
const extractCoords = (pt) => {
if (!pt || typeof pt !== 'object') return { lat: Number.NaN, lng: Number.NaN }
return {
lat: Number(pt['@_lat'] ?? pt.lat),
lng: Number(pt['@_lon'] ?? pt.lon ?? pt['@_lng'] ?? pt.lng),
}
}
const { lat, lng } = extractCoords(point)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null
const detail = findInObject(parsed, 'detail')
const contact = detail && typeof detail === 'object' ? (findInObject(detail, 'contact') ?? detail) : null
const callsign = contact && typeof contact === 'object'
? (contact['@_callsign'] ?? contact.callsign ?? contact['@_Callsign'])
: ''
const label = typeof callsign === 'string' ? callsign.trim() || uid : uid
return { type: 'cot', id: uid, lat, lng, label, eventType }
}
catch {
return null
}
}

73
server/utils/cotSsl.js Normal file
View File

@@ -0,0 +1,73 @@
import { existsSync, readFileSync, unlinkSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { tmpdir } from 'node:os'
import { execSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
/** Default password for the CoT trust store (document in atak-itak.md). */
export const TRUSTSTORE_PASSWORD = 'kestrelos'
/** Default CoT server port. */
export const DEFAULT_COT_PORT = 8089
/**
* CoT port from env or default.
* @returns {number} Port number (COT_PORT env or DEFAULT_COT_PORT).
*/
export function getCotPort() {
return Number(process.env.COT_PORT ?? DEFAULT_COT_PORT)
}
/** Message when an endpoint requires TLS but server is not using it. */
export const COT_TLS_REQUIRED_MESSAGE = 'Only available when the server runs with SSL (e.g. .dev-certs or COT_SSL_*).'
/**
* Resolve CoT server TLS cert and key paths (for plugin and API).
* @param {{ cotSslCert?: string, cotSslKey?: string }} [config] - Runtime config (optional)
* @returns {{ certPath: string, keyPath: string } | null} Paths when TLS is configured, else null.
*/
export function getCotSslPaths(config = {}) {
if (process.env.COT_SSL_CERT && process.env.COT_SSL_KEY) {
return { certPath: process.env.COT_SSL_CERT, keyPath: process.env.COT_SSL_KEY }
}
if (config.cotSslCert && config.cotSslKey) {
return { certPath: config.cotSslCert, keyPath: config.cotSslKey }
}
const candidates = [
join(process.cwd(), '.dev-certs', 'cert.pem'),
join(__dirname, '../../.dev-certs', 'cert.pem'),
]
for (const certPath of candidates) {
const keyPath = certPath.replace('cert.pem', 'key.pem')
if (existsSync(certPath) && existsSync(keyPath)) {
return { certPath, keyPath }
}
}
return null
}
/**
* Build a P12 trust store from a PEM cert path (for truststore download and server package).
* @param {string} certPath - Path to cert.pem
* @param {string} password - P12 password
* @returns {Buffer} P12 buffer
* @throws {Error} If openssl fails
*/
export function buildP12FromCertPath(certPath, password) {
const outPath = join(tmpdir(), `kestrelos-cot-p12-${Date.now()}.p12`)
try {
execSync(
`openssl pkcs12 -export -nokeys -in "${certPath}" -out "${outPath}" -passout pass:${password}`,
{ stdio: 'pipe' },
)
const p12 = readFileSync(outPath)
unlinkSync(outPath)
return p12
}
catch (err) {
if (existsSync(outPath)) unlinkSync(outPath)
throw err
}
}

71
server/utils/cotStore.js Normal file
View File

@@ -0,0 +1,71 @@
/**
* In-memory CoT entity store: upsert by id, prune on read by TTL.
* Single source of truth; getActiveEntities returns new objects (no mutation of returned refs).
*/
import { acquire } from './asyncLock.js'
import { COT_ENTITY_TTL_MS } from './constants.js'
const entities = new Map()
/**
* Upsert entity by id. Input is not mutated; stored value is a new object.
* @param {{ id: string, lat: number, lng: number, label?: string, eventType?: string, type?: string }} parsed
*/
export async function updateFromCot(parsed) {
if (!parsed || typeof parsed.id !== 'string') return
const lat = Number(parsed.lat)
const lng = Number(parsed.lng)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return
await acquire(`cot-${parsed.id}`, async () => {
const now = Date.now()
const existing = entities.get(parsed.id)
const label = typeof parsed.label === 'string' ? parsed.label : (existing?.label ?? parsed.id)
const type = typeof parsed.eventType === 'string' ? parsed.eventType : (typeof parsed.type === 'string' ? parsed.type : (existing?.type ?? ''))
entities.set(parsed.id, {
id: parsed.id,
lat,
lng,
label,
type,
updatedAt: now,
})
})
}
/**
* Active entities (updated within ttlMs). Prunes expired. Returns new array of new objects.
* @param {number} [ttlMs]
* @returns {Promise<Array<{ id: string, lat: number, lng: number, label: string, type: string, updatedAt: number }>>} Snapshot of active entities.
*/
export async function getActiveEntities(ttlMs = COT_ENTITY_TTL_MS) {
return acquire('cot-prune', async () => {
const now = Date.now()
const active = []
const expired = []
for (const entity of entities.values()) {
if (now - entity.updatedAt <= ttlMs) {
active.push({
id: entity.id,
lat: entity.lat,
lng: entity.lng,
label: entity.label ?? entity.id,
type: entity.type ?? '',
updatedAt: entity.updatedAt,
})
}
else {
expired.push(entity.id)
}
}
for (const id of expired) entities.delete(id)
return active
})
}
/** Clear store (tests only). */
export function clearCotStore() {
entities.clear()
}

View File

@@ -1,13 +1,16 @@
import { join } from 'node:path' import { join, dirname } from 'node:path'
import { mkdirSync, existsSync } from 'node:fs' import { mkdirSync, existsSync } from 'node:fs'
import { createRequire } from 'node:module' import { createRequire } from 'node:module'
import { promisify } from 'node:util' import { promisify } from 'node:util'
import { bootstrapAdmin } from './bootstrap.js' import { randomBytes } from 'node:crypto'
import { hashPassword } from './password.js'
import { registerCleanup } from './shutdown.js'
const require = createRequire(import.meta.url) // Resolve from project root so bundled server (e.g. .output) finds node_modules/sqlite3
const sqlite3 = require('sqlite3') const requireFromRoot = createRequire(join(process.cwd(), 'package.json'))
const sqlite3 = requireFromRoot('sqlite3')
const SCHEMA_VERSION = 2 const SCHEMA_VERSION = 4
const DB_BUSY_TIMEOUT_MS = 5000 const DB_BUSY_TIMEOUT_MS = 5000
let dbInstance = null let dbInstance = null
@@ -68,6 +71,12 @@ const getDbPath = () => {
return join(dir, 'kestrelos.db') return join(dir, 'kestrelos.db')
} }
export const getAvatarsDir = () => {
const dir = join(dirname(getDbPath()), 'avatars')
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
return dir
}
const getSchemaVersion = async (get) => { const getSchemaVersion = async (get) => {
try { try {
const row = await get('SELECT version FROM schema_version ORDER BY version DESC LIMIT 1') const row = await get('SELECT version FROM schema_version ORDER BY version DESC LIMIT 1')
@@ -99,6 +108,18 @@ const migrateToV2 = async (run, all) => {
} }
} }
const migrateToV3 = async (run, all) => {
const info = await all('PRAGMA table_info(users)')
if (info.some(c => c.name === 'avatar_path')) return
await run('ALTER TABLE users ADD COLUMN avatar_path TEXT')
}
const migrateToV4 = async (run, all) => {
const info = await all('PRAGMA table_info(users)')
if (info.some(c => c.name === 'cot_password_hash')) return
await run('ALTER TABLE users ADD COLUMN cot_password_hash TEXT')
}
const runMigrations = async (run, all, get) => { const runMigrations = async (run, all, get) => {
const version = await getSchemaVersion(get) const version = await getSchemaVersion(get)
if (version >= SCHEMA_VERSION) return if (version >= SCHEMA_VERSION) return
@@ -106,6 +127,14 @@ const runMigrations = async (run, all, get) => {
await migrateToV2(run, all) await migrateToV2(run, all)
await setSchemaVersion(run, 2) await setSchemaVersion(run, 2)
} }
if (version < 3) {
await migrateToV3(run, all)
await setSchemaVersion(run, 3)
}
if (version < 4) {
await migrateToV4(run, all)
await setSchemaVersion(run, 4)
}
} }
const initDb = async (db, run, all, get) => { const initDb = async (db, run, all, get) => {
@@ -124,7 +153,29 @@ const initDb = async (db, run, all, get) => {
await run(SCHEMA.pois) await run(SCHEMA.pois)
await run(SCHEMA.devices) await run(SCHEMA.devices)
if (!testPath) await bootstrapAdmin(run, get) if (!testPath) {
// Bootstrap admin user on first run
const PASSWORD_CHARS = Object.freeze('abcdefghjkmnopqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789')
const generateRandomPassword = () =>
Array.from(randomBytes(14), b => PASSWORD_CHARS[b % PASSWORD_CHARS.length]).join('')
const row = await get('SELECT COUNT(*) as n FROM users')
if (row?.n === 0) {
const email = process.env.BOOTSTRAP_EMAIL?.trim()
const password = process.env.BOOTSTRAP_PASSWORD
const identifier = (email && password) ? email : 'admin'
const plainPassword = (email && password) ? password : generateRandomPassword()
await run(
'INSERT INTO users (id, identifier, password_hash, role, created_at, auth_provider, oidc_issuer, oidc_sub) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[crypto.randomUUID(), identifier, hashPassword(plainPassword), 'admin', new Date().toISOString(), 'local', null, null],
)
if (!email || !password) {
console.log(`\n[KestrelOS] No bootstrap admin configured. Default admin created. Sign in at /login with:\n\n Identifier: ${identifier}\n Password: ${plainPassword}\n\n Set BOOTSTRAP_EMAIL and BOOTSTRAP_PASSWORD to use your own credentials on first run.\n`)
}
}
}
} }
export async function getDb() { export async function getDb() {
@@ -151,9 +202,91 @@ export async function getDb() {
} }
dbInstance = { db, run, all, get } dbInstance = { db, run, all, get }
registerCleanup(async () => {
if (dbInstance) {
try {
await new Promise((resolve, reject) => {
dbInstance.db.close((err) => {
if (err) reject(err)
else resolve()
})
})
}
catch (error) {
console.error('[db] Error closing database during shutdown:', error?.message)
}
dbInstance = null
}
})
return dbInstance 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() { export function closeDb() {
if (!dbInstance) return if (!dbInstance) return
try { try {

View File

@@ -1,47 +1,79 @@
import { closeRouter, getProducer, getTransport } from './mediasoup.js' 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() const sessions = new Map()
export const createSession = (userId, label = '') => { export const createSession = async (userId, label = '') => {
const id = crypto.randomUUID() return acquire(`session-create-${userId}`, async () => {
const session = { const id = crypto.randomUUID()
id, const session = {
userId, id,
label: (label || 'Live').trim() || 'Live', userId,
lat: 0, label: (label || 'Live').trim() || 'Live',
lng: 0, lat: 0,
updatedAt: Date.now(), lng: 0,
routerId: null, updatedAt: Date.now(),
producerId: null, routerId: null,
transportId: null, producerId: null,
} transportId: null,
sessions.set(id, session) }
return session 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 getLiveSession = id => sessions.get(id)
export const getActiveSessionByUserId = (userId) => { export const getActiveSessionByUserId = async (userId) => {
const now = Date.now() return acquire(`session-get-${userId}`, async () => {
for (const s of sessions.values()) { const now = Date.now()
if (s.userId === userId && now - s.updatedAt <= TTL_MS) return s for (const s of sessions.values()) {
} if (s.userId === userId && now - s.updatedAt <= LIVE_SESSION_TTL_MS) return s
}
})
} }
export const updateLiveSession = (id, updates) => { export const updateLiveSession = async (id, updates) => {
const session = sessions.get(id) return acquire(`session-update-${id}`, async () => {
if (!session) return const session = sessions.get(id)
const now = Date.now() if (!session) {
if (Number.isFinite(updates.lat)) session.lat = updates.lat throw new Error('Session not found')
if (Number.isFinite(updates.lng)) session.lng = updates.lng }
if (updates.routerId !== undefined) session.routerId = updates.routerId const now = Date.now()
if (updates.producerId !== undefined) session.producerId = updates.producerId if (Number.isFinite(updates.lat)) session.lat = updates.lat
if (updates.transportId !== undefined) session.transportId = updates.transportId if (Number.isFinite(updates.lng)) session.lng = updates.lng
session.updatedAt = now 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() export const clearSessions = () => sessions.clear()
@@ -62,31 +94,33 @@ const cleanupSession = async (session) => {
} }
export const getActiveSessions = async () => { export const getActiveSessions = async () => {
const now = Date.now() return acquire('get-active-sessions', async () => {
const active = [] const now = Date.now()
const expired = [] const active = []
const expired = []
for (const session of sessions.values()) { for (const session of sessions.values()) {
if (now - session.updatedAt <= TTL_MS) { if (now - session.updatedAt <= LIVE_SESSION_TTL_MS) {
active.push({ active.push({
id: session.id, id: session.id,
userId: session.userId, userId: session.userId,
label: session.label, label: session.label,
lat: session.lat, lat: session.lat,
lng: session.lng, lng: session.lng,
updatedAt: session.updatedAt, updatedAt: session.updatedAt,
hasStream: Boolean(session.producerId), 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) { return active
await cleanupSession(session) })
sessions.delete(session.id)
}
return active
} }

Some files were not shown because too many files have changed in this diff Show More