release-2.5.1 (#1067)

* chore(deps): bump node from 24-slim to 26-slim in /docker in the docker-major-updates group (#1021)

* chore: fix release workflow to merge docs branch

* fix: svg donation generator push fail

* fix: svg donation generator push fail

* Update termix.rb

* fix: svg donation generator push fail

* chore: move donation badge to badges branch to avoid ruleset conflicts

* chore: remove unneeded token from donation badge workflow

* chore: debug donation badge commit step

* fix: escape < character in donation SVG

* fix: point donation badge to badges branch

* chore: remove unused donation badge svg from main

* Add Rack Genius logo to README

Added Rack Genius logo to the README.

* chore: improve donation goal svg generator to include stablecoins

* chore: donation goal generator syntax error

* chore: donation goal generator incorrect docs url usage

* chore(deps): bump node in /docker in the docker-major-updates group

Bumps the docker-major-updates group in /docker with 1 update: node.


Updates `node` from 24-slim to 26-slim

---
updated-dependencies:
- dependency-name: node
  dependency-version: 26-slim
  dependency-type: direct:production
  dependency-group: docker-major-updates
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump the dev-patch-updates group with 24 updates (#1023)

* chore: fix release workflow to merge docs branch

* fix: svg donation generator push fail

* fix: svg donation generator push fail

* Update termix.rb

* fix: svg donation generator push fail

* chore: move donation badge to badges branch to avoid ruleset conflicts

* chore: remove unneeded token from donation badge workflow

* chore: debug donation badge commit step

* fix: escape < character in donation SVG

* fix: point donation badge to badges branch

* chore: remove unused donation badge svg from main

* Add Rack Genius logo to README

Added Rack Genius logo to the README.

* chore: improve donation goal svg generator to include stablecoins

* chore: donation goal generator syntax error

* chore: donation goal generator incorrect docs url usage

* chore(deps-dev): bump the dev-patch-updates group with 24 updates

Bumps the dev-patch-updates group with 24 updates:

| Package | From | To |
| --- | --- | --- |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.5.1` | `2.5.2` |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.10.3` | `6.10.4` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.1` | `6.43.5` |
| [@radix-ui/react-accordion](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/accordion) | `1.2.14` | `1.2.15` |
| [@radix-ui/react-alert-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/alert-dialog) | `1.1.17` | `1.1.18` |
| [@radix-ui/react-checkbox](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/checkbox) | `1.3.5` | `1.3.6` |
| [@radix-ui/react-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dialog) | `1.1.17` | `1.1.18` |
| [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dropdown-menu) | `2.1.18` | `2.1.19` |
| [@radix-ui/react-label](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/label) | `2.1.10` | `2.1.11` |
| [@radix-ui/react-popover](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/popover) | `1.1.17` | `1.1.18` |
| [@radix-ui/react-progress](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/progress) | `1.1.10` | `1.1.11` |
| [@radix-ui/react-scroll-area](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/scroll-area) | `1.2.12` | `1.2.13` |
| [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.3.1` | `2.3.2` |
| [@radix-ui/react-separator](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/separator) | `1.1.10` | `1.1.11` |
| [@radix-ui/react-slider](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slider) | `1.4.1` | `1.4.2` |
| [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.3.1` | `1.3.2` |
| [@radix-ui/react-tabs](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tabs) | `1.1.15` | `1.1.16` |
| [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tooltip) | `1.2.10` | `1.2.11` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.1` | `4.3.2` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.0.2` | `6.0.3` |
| [i18next](https://github.com/i18next/i18next) | `26.3.1` | `26.3.4` |
| [radix-ui](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radix-ui) | `1.6.0` | `1.6.1` |
| [sharp](https://github.com/lovell/sharp) | `0.35.2` | `0.35.3` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.1` | `4.3.2` |


Updates `@biomejs/biome` from 2.5.1 to 2.5.2
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.5.2/packages/@biomejs/biome)

Updates `@codemirror/commands` from 6.10.3 to 6.10.4
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/commits)

Updates `@codemirror/view` from 6.43.1 to 6.43.5
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `@radix-ui/react-accordion` from 1.2.14 to 1.2.15
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/accordion/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/accordion)

Updates `@radix-ui/react-alert-dialog` from 1.1.17 to 1.1.18
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/alert-dialog/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/alert-dialog)

Updates `@radix-ui/react-checkbox` from 1.3.5 to 1.3.6
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/checkbox/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/checkbox)

Updates `@radix-ui/react-dialog` from 1.1.17 to 1.1.18
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dialog)

Updates `@radix-ui/react-dropdown-menu` from 2.1.18 to 2.1.19
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dropdown-menu/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dropdown-menu)

Updates `@radix-ui/react-label` from 2.1.10 to 2.1.11
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/label/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/label)

Updates `@radix-ui/react-popover` from 1.1.17 to 1.1.18
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/popover/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/popover)

Updates `@radix-ui/react-progress` from 1.1.10 to 1.1.11
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/progress/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/progress)

Updates `@radix-ui/react-scroll-area` from 1.2.12 to 1.2.13
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/scroll-area/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/scroll-area)

Updates `@radix-ui/react-select` from 2.3.1 to 2.3.2
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/select/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/select)

Updates `@radix-ui/react-separator` from 1.1.10 to 1.1.11
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/separator/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/separator)

Updates `@radix-ui/react-slider` from 1.4.1 to 1.4.2
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slider/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slider)

Updates `@radix-ui/react-switch` from 1.3.1 to 1.3.2
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch)

Updates `@radix-ui/react-tabs` from 1.1.15 to 1.1.16
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tabs/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tabs)

Updates `@radix-ui/react-tooltip` from 1.2.10 to 1.2.11
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tooltip/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tooltip)

Updates `@tailwindcss/vite` from 4.3.1 to 4.3.2
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.2/packages/@tailwindcss-vite)

Updates `@vitejs/plugin-react` from 6.0.2 to 6.0.3
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.0.3/packages/plugin-react)

Updates `i18next` from 26.3.1 to 26.3.4
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.3.1...v26.3.4)

Updates `radix-ui` from 1.6.0 to 1.6.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radix-ui/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/radix-ui)

Updates `sharp` from 0.35.2 to 0.35.3
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.35.2...v0.35.3)

Updates `tailwindcss` from 4.3.1 to 4.3.2
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.2/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.5.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/commands"
  dependency-version: 6.10.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-accordion"
  dependency-version: 1.2.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-alert-dialog"
  dependency-version: 1.1.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-checkbox"
  dependency-version: 1.3.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-dialog"
  dependency-version: 1.1.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-dropdown-menu"
  dependency-version: 2.1.19
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-label"
  dependency-version: 2.1.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-popover"
  dependency-version: 1.1.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-progress"
  dependency-version: 1.1.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-scroll-area"
  dependency-version: 1.2.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-select"
  dependency-version: 2.3.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-separator"
  dependency-version: 1.1.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-slider"
  dependency-version: 1.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-switch"
  dependency-version: 1.3.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-tabs"
  dependency-version: 1.1.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-tooltip"
  dependency-version: 1.2.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.3.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: i18next
  dependency-version: 26.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: radix-ui
  dependency-version: 1.6.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: sharp
  dependency-version: 0.35.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: tailwindcss
  dependency-version: 4.3.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the prod-patch-updates group with 2 updates (#1025)

* chore: fix release workflow to merge docs branch

* fix: svg donation generator push fail

* fix: svg donation generator push fail

* Update termix.rb

* fix: svg donation generator push fail

* chore: move donation badge to badges branch to avoid ruleset conflicts

* chore: remove unneeded token from donation badge workflow

* chore: debug donation badge commit step

* fix: escape < character in donation SVG

* fix: point donation badge to badges branch

* chore: remove unused donation badge svg from main

* Add Rack Genius logo to README

Added Rack Genius logo to the README.

* chore: improve donation goal svg generator to include stablecoins

* chore: donation goal generator syntax error

* chore: donation goal generator incorrect docs url usage

* chore(deps): bump the prod-patch-updates group with 2 updates

Bumps the prod-patch-updates group with 2 updates: [axios](https://github.com/axios/axios) and [nanoid](https://github.com/ai/nanoid).


Updates `axios` from 1.18.0 to 1.18.1
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.18.0...v1.18.1)

Updates `nanoid` from 5.1.15 to 5.1.16
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/5.1.15...5.1.16)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.18.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: nanoid
  dependency-version: 5.1.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the prod-minor-updates group with 3 updates (#1026)

* chore: fix release workflow to merge docs branch

* fix: svg donation generator push fail

* fix: svg donation generator push fail

* Update termix.rb

* fix: svg donation generator push fail

* chore: move donation badge to badges branch to avoid ruleset conflicts

* chore: remove unneeded token from donation badge workflow

* chore: debug donation badge commit step

* fix: escape < character in donation SVG

* fix: point donation badge to badges branch

* chore: remove unused donation badge svg from main

* Add Rack Genius logo to README

Added Rack Genius logo to the README.

* chore: improve donation goal svg generator to include stablecoins

* chore: donation goal generator syntax error

* chore: donation goal generator incorrect docs url usage

* chore(deps): bump the prod-minor-updates group with 3 updates

Bumps the prod-minor-updates group with 3 updates: [js-yaml](https://github.com/nodeca/js-yaml), [motion](https://github.com/motiondivision/motion) and [undici](https://github.com/nodejs/undici).


Updates `js-yaml` from 5.0.0 to 5.2.1
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/5.0.0...5.2.1)

Updates `motion` from 12.40.0 to 12.42.2
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v12.40.0...v12.42.2)

Updates `undici` from 8.5.0 to 8.7.0
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.5.0...v8.7.0)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 5.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: motion
  dependency-version: 12.42.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: undici
  dependency-version: 8.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump electron from 42.4.1 to 43.0.0 in the major-updates group (#1027)

* chore: fix release workflow to merge docs branch

* fix: svg donation generator push fail

* fix: svg donation generator push fail

* Update termix.rb

* fix: svg donation generator push fail

* chore: move donation badge to badges branch to avoid ruleset conflicts

* chore: remove unneeded token from donation badge workflow

* chore: debug donation badge commit step

* fix: escape < character in donation SVG

* fix: point donation badge to badges branch

* chore: remove unused donation badge svg from main

* Add Rack Genius logo to README

Added Rack Genius logo to the README.

* chore: improve donation goal svg generator to include stablecoins

* chore: donation goal generator syntax error

* chore: donation goal generator incorrect docs url usage

* chore(deps-dev): bump electron in the major-updates group

Bumps the major-updates group with 1 update: [electron](https://github.com/electron/electron).


Updates `electron` from 42.4.1 to 43.0.0
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v42.4.1...v43.0.0)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 43.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Fix MC syntax highlighting artifacts (#996)

* Filter dashboard status hosts (#997)

* Persist dashboard service link changes (#999)

* Fix snippet text overflow (#1000)

* Persist remote desktop credential auth (#1001)

* Guard language switching failures (#1002)

* Resolve tunnel source credentials (#1003)

* Support Vault auth for monitors (#1004)

* Fix Windows file delete command (#1005)

* Fix release artifact checkout ref (#1006)

* Fix command palette escape in fullscreen (#1008)

* Fix alerts and audit log data normalization (#1010)

* Fix macOS VNC protocol negotiation (#1012)

* Fix port knocking before SSH connect (#1013)

* Allow Escape to close link confirmation (#1014)

* Prevent Electron modifier wheel zoom (#1016)

* Fix credential auth optional password (#1009)

* Retry transient terminal DNS lookups (#1011)

* Retry transient terminal DNS lookups

* Apply DNS retry to SSH entry points

* Fix OIDC redirect forwarded port handling (#1007)

* Preserve recent open tabs on startup (#1015)

* Fix fish prompt OSC highlighting (#998)

* Fix terminal font selection (#1018)

* fix: font legibility (#1019)

* chore: fix release workflow to merge docs branch

* fix: svg donation generator push fail

* fix: svg donation generator push fail

* Update termix.rb

* fix: svg donation generator push fail

* chore: move donation badge to badges branch to avoid ruleset conflicts

* chore: remove unneeded token from donation badge workflow

* chore: debug donation badge commit step

* fix: escape < character in donation SVG

* fix: point donation badge to badges branch

* chore: remove unused donation badge svg from main

* Add Rack Genius logo to README

Added Rack Genius logo to the README.

* chore: improve donation goal svg generator to include stablecoins

* chore: donation goal generator syntax error

* chore: donation goal generator incorrect docs url usage

* fix: font legibility

Text was entirely unreadable in places for me. Especially with themes
like Catppuccin. The muted-foreground text and the tags too similiar to
the background.

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: russell <git@0896c69e.com>

* fix(file-manager): chunked uploads fail with 'Expected multipart/form-data request' (#1020)

* chore: fix release workflow to merge docs branch

* fix: svg donation generator push fail

* fix: svg donation generator push fail

* Update termix.rb

* fix: svg donation generator push fail

* chore: move donation badge to badges branch to avoid ruleset conflicts

* chore: remove unneeded token from donation badge workflow

* chore: debug donation badge commit step

* fix: escape < character in donation SVG

* fix: point donation badge to badges branch

* chore: remove unused donation badge svg from main

* Add Rack Genius logo to README

Added Rack Genius logo to the README.

* chore: improve donation goal svg generator to include stablecoins

* chore: donation goal generator syntax error

* chore: donation goal generator incorrect docs url usage

* fix(file-manager): use postForm for chunked uploads so multipart content-type is sent

The fileManagerApi axios instance defaults to Content-Type:
application/json. Axios 1.x's default transformRequest converts a
FormData body to JSON whenever the request content type is
application/json, so every chunk POSTed to /ssh/uploadFileChunk
arrived as a JSON body like {"chunk":{}} and the backend rejected
it with 400 'Expected multipart/form-data request'. This breaks all
uploads of files larger than the 1.5 GiB chunking threshold.

The non-chunked path already uses postForm for /ssh/uploadFileStream;
use it for the chunk path too so axios keeps the FormData intact and
the browser sets the multipart boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat: implement OIDC back-channel logout support with session management (#1028)

* feat: implement OIDC back-channel logout support with session management

* Fix OIDC back-channel logout handling

* Require logout token replay identifiers

---------

Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>

* Add API key host enrollment endpoint (#1029)

* Fix tmux detection for non-POSIX shells (#1030)

* Fix OPKSSH js-yaml ESM import (#1031)

* Fix Android Vietnamese IME input (#1032)

* Fix Firefox RDP clipboard paste (#1033)

* Fix Proxmox discovery over HTTPS (#1041)

* Fix external editor actions in file preview (#1042)

* Allow pinned hosts with name sorting (#1043)

* Fix Firefox desktop OIDC callback (#1044)

* feat(session): add recording and replay (#1049)

* Fix status checks through jump hosts (#1045)

* Add terminal font size shortcuts (#1047)

* feat: add Open File Manager to tab right-click menu (#1051)

Co-authored-by: SankeerthNara <sankeerthnara@gmail.com>

* perf: frontend request cache, poll pause, and code-split shell (#1052)

Host/status caching, shell code-split, SSH pool waits, host-metrics concurrency, background-tab idle, per-host status subscriptions, homepage poll quieting, and virtualized host sidebar + file manager lists.

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* feat: save quick connect sessions as hosts (#1055)

* fix: restore sudo password autofill settings (#1056)

* fix: preserve file editor position on save (#1057)

* fix: sync cloud preference storage mode (#1058)

* fix: render RDP sessions at native pixel density (#1059)

* fix: restore database import in embedded desktop mode (#1060)

* Update Auto-complete.tsx (#1061)

* chore: fix release workflow to merge docs branch

* fix: svg donation generator push fail

* fix: svg donation generator push fail

* Update termix.rb

* fix: svg donation generator push fail

* chore: move donation badge to badges branch to avoid ruleset conflicts

* chore: remove unneeded token from donation badge workflow

* chore: debug donation badge commit step

* fix: escape < character in donation SVG

* fix: point donation badge to badges branch

* chore: remove unused donation badge svg from main

* Add Rack Genius logo to README

Added Rack Genius logo to the README.

* chore: improve donation goal svg generator to include stablecoins

* chore: donation goal generator syntax error

* chore: donation goal generator incorrect docs url usage

* chore: donation bar reporting wrong result

* feat: add Open File Manager to tab right-click menu (#1046)

* Revert "feat: add Open File Manager to tab right-click menu (#1046)" (#1050)

This reverts commit 0712fdd731.

* Remove donation badge from README

Removed donation badge from README.

* Delete .github/workflows/donation-goal.yml

* Update Auto-complete.tsx

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>

* feat(auth): opt-in OIDC DEK unlock for API-key requests (ALLOW_APIKEY_DATA_UNLOCK) (#1064)

* chore: fix release workflow to merge docs branch

* fix: svg donation generator push fail

* fix: svg donation generator push fail

* Update termix.rb

* fix: svg donation generator push fail

* chore: move donation badge to badges branch to avoid ruleset conflicts

* chore: remove unneeded token from donation badge workflow

* chore: debug donation badge commit step

* fix: escape < character in donation SVG

* fix: point donation badge to badges branch

* chore: remove unused donation badge svg from main

* Add Rack Genius logo to README

Added Rack Genius logo to the README.

* chore: improve donation goal svg generator to include stablecoins

* chore: donation goal generator syntax error

* chore: donation goal generator incorrect docs url usage

* chore: donation bar reporting wrong result

* feat: add Open File Manager to tab right-click menu (#1046)

* Revert "feat: add Open File Manager to tab right-click menu (#1046)" (#1050)

This reverts commit 0712fdd731.

* Remove donation badge from README

Removed donation badge from README.

* Delete .github/workflows/donation-goal.yml

* feat(auth): opt-in OIDC DEK unlock for API-key requests

API keys authenticate but cannot touch the encrypted credential/host store
('User data not unlocked') unless the user has a live interactive session,
making them unusable for headless automation. For OIDC users the DEK is
server-derivable (deriveOIDCSystemKey), so handleApiKeyAuth can unlock it
without a password.

Gated behind ALLOW_APIKEY_DATA_UNLOCK (default off) because enabling it widens
the blast radius of a leaked API key. OIDC-only; password users are untouched.

Refs #1063

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: Sankeerth Nara <sankeerthnara@gmail.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>

* chore: package lock sync

* Add Proxmox guest auto sync (#1053)

* draft: database layer refactor (#1054)

* feat(sshid) - sshid.io equivalent for termix (#919)

* feat(ssh-id): database schema, migrations and field encryption

Adds ssh_identities, ssh_identity_keys and ssh_identity_ca tables (public keys
stored plaintext for the unauthenticated resolver; CA private key registered
for per-user field encryption), with UNIQUE(user_id), an index on
ssh_identity_keys(identity_id), and idempotent CREATE TABLE migrations.

* feat(ssh-id): backend API — resolver, key management, CA and certificates

Mounts /sshid (nginx route added). Public text/plain authorized_keys resolver
(+ exact /:algo filter, HTML viewer) and CA public-key endpoint; no-store +
noindex headers on every resolver response including early 404s. Authenticated
management: claim/rename/delete handle, add/import/generate/enable/delete keys,
and a per-user CA (create/rotate/delete) with pure-Node OpenSSH certificate
issuance. Audit logging on all mutations; UNIQUE races map to a precise 409.
Unit tests for key parsing and certificate signing (ssh-keygen-validated).

* feat(ssh-id): frontend panel, API client and i18n

SSH ID panel wired into the app rail and AppShell: claim handle, resolver URL +
curl one-liner, key list, generate, paste/import, CA enable/rotate/remove with
server trust command, and per-key certificate issuance. API client re-exported
through main-axios.ts; all strings i18n'd.

* style(ssh-id): align panel and resolver page with Termix theme

- Rebuild the SSH ID sidebar panel with the theme's square components
  (SectionCard / SettingRow / FakeSwitch) instead of rounded ad-hoc cards;
  use accent-brand and destructive tokens rather than raw red/green.
- Fix panel scrolling: move overflow to a block scroll container so the
  cards keep their natural height instead of being clipped.
- Restyle the public resolver HTML page (/sshid/u/:handle) to the Termix
  dark theme: square corners, #18181b/#303032 palette, #f59145 accent,
  uppercase section labels.
- Tidy copy: 'Save To Credentials' label, drop the redundant generate intro,
  and correct the generate tooltip (the key is stored when saving to vault).

* feat: rename to Termix ID, improve UI, backend inconsistencies, and general bug fixes

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* ci(deps): bump actions/checkout from 6 to 7 in the github-actions group (#922)

Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump the dev-patch-updates group with 11 updates (#923)

Bumps the dev-patch-updates group with 11 updates:

| Package | From | To |
| --- | --- | --- |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.0` | `6.7.1` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.0` | `6.43.1` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.0` | `4.3.1` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.8` | `4.1.9` |
| [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) | `4.1.8` | `4.1.9` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.2` | `0.5.3` |
| [lint-staged](https://github.com/lint-staged/lint-staged) | `17.0.7` | `17.0.8` |
| [prettier](https://github.com/prettier/prettier) | `3.8.3` | `3.8.4` |
| [sharp](https://github.com/lovell/sharp) | `0.35.1` | `0.35.2` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.0` | `4.3.1` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.8` | `4.1.9` |


Updates `@codemirror/search` from 6.7.0 to 6.7.1
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)

Updates `@codemirror/view` from 6.43.0 to 6.43.1
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `@tailwindcss/vite` from 4.3.0 to 4.3.1
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/@tailwindcss-vite)

Updates `@vitest/coverage-v8` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/coverage-v8)

Updates `@vitest/ui` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/ui)

Updates `eslint-plugin-react-refresh` from 0.5.2 to 0.5.3
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.2...v0.5.3)

Updates `lint-staged` from 17.0.7 to 17.0.8
- [Release notes](https://github.com/lint-staged/lint-staged/releases)
- [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lint-staged/lint-staged/compare/v17.0.7...v17.0.8)

Updates `prettier` from 3.8.3 to 3.8.4
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.3...3.8.4)

Updates `sharp` from 0.35.1 to 0.35.2
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.35.1...v0.35.2)

Updates `tailwindcss` from 4.3.0 to 4.3.1
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/tailwindcss)

Updates `vitest` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/vitest)

---
updated-dependencies:
- dependency-name: "@codemirror/search"
  dependency-version: 6.7.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitest/ui"
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: lint-staged
  dependency-version: 17.0.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: prettier
  dependency-version: 3.8.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: sharp
  dependency-version: 0.35.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: tailwindcss
  dependency-version: 4.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: vitest
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump nanoid in the prod-patch-updates group (#925)

Bumps the prod-patch-updates group with 1 update: [nanoid](https://github.com/ai/nanoid).


Updates `nanoid` from 5.1.11 to 5.1.15
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/5.1.11...5.1.15)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-version: 5.1.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the major-updates group with 5 updates (#926)

Bumps the major-updates group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [js-yaml](https://github.com/nodeca/js-yaml) | `4.2.0` | `5.0.0` |
| [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.39.4` | `10.0.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.9.2` | `26.0.0` |
| [concurrently](https://github.com/open-cli-tools/concurrently) | `9.2.1` | `10.0.3` |
| [eslint](https://github.com/eslint/eslint) | `9.39.4` | `10.5.0` |


Updates `js-yaml` from 4.2.0 to 5.0.0
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...5.0.0)

Updates `@eslint/js` from 9.39.4 to 10.0.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js)

Updates `@types/node` from 25.9.2 to 26.0.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `concurrently` from 9.2.1 to 10.0.3
- [Release notes](https://github.com/open-cli-tools/concurrently/releases)
- [Commits](https://github.com/open-cli-tools/concurrently/compare/v9.2.1...v10.0.3)

Updates `eslint` from 9.39.4 to 10.5.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.39.4...v10.5.0)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@eslint/js"
  dependency-version: 10.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@types/node"
  dependency-version: 26.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: concurrently
  dependency-version: 10.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: eslint
  dependency-version: 10.5.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat(ssh): add HashiCorp Vault SSH signer authentication

* fix: small fixes to vault feature to align with Termix codebase

* chore: add view docs links for vault/termix id

* fix: file upload fails with 400 and missing schema migrations on upgrade (#929)

Two bugs introduced in v2.4.1:

1. uploadFileStream uses fileManagerApi.post() which triggers axios's
   transformRequest to JSON-serialize the FormData because the instance
   default Content-Type is application/json. Change to postForm() which
   sets Content-Type: multipart/form-data so the browser XHR sends the
   correct multipart body with boundary.

2. Two schema items added to schema.ts were not included in migrateSchema()
   in db/index.ts, causing 500 errors on existing installations upgrading
   from v2.4.0:
   - user_preferences.status_color_scheme (no such column)
   - dashboard_service_links table (no such table)

Fixes #928

Co-authored-by: sash <sash@fominykh.io>

* fix: support PuTTY PPK ssh keys (#930)

* fix: chunk large file manager uploads (#932)

* fix: route dashboard hosts by protocol (#934)

* fix: resolve tunnel endpoints reliably (#935)

* Fix Electron OIDC browser auth failures (#936)

* Allow RDP connections without stored credentials (#937)

* Sync role credential shares for OIDC users (#938)

* Fix terminal link dialog layering (#940)

* Confirm large files before opening editor (#942)

* Confirm closing active host connections (#943)

* Preserve file path case in file manager UI (#941)

* fix: preserve unicode guacamole tokens (#933)

* Persist VNC authentication settings (#944)

* Fix Guacamole websocket base path (#946)

* Promote file manager terminals to tabs (#939)

* Guard Guacamole disconnect during startup (#945)

* chore: increment ver

* feat: bitwarden ssh agent integration

* feat: serial connections support

* fix: various small bug fixes

* feat: open all sessions in a folder and terminal custom theme color support

* feat: cross host file manager clipboard and several small bug fixes

* feat: tailscale/wireguard support and added a new status state for when backend is checking status

* feat: grafana like server stats history, new alert system, ntfy/webhook support

* feat: new grid and widget based homepage function

* feat: new donate button in dashboard

* fix: alert ui incorrectly using termix css and fixed issue with alert system not loading

* chore: start database layer refactor

* docs: plan database layer refactor

* docs: audit database layer refactor phase zero

* chore: add database runtime adapter skeleton

* chore: add settings repository skeleton

* chore: add user session repository skeleton

* chore: add host credential repository skeleton

* chore: add field encryption boundary

* chore: migrate settings route slice

* chore: migrate user settings routes

* chore: migrate host metrics settings routes

* chore: migrate acme settings route

* chore: migrate terminal settings route

* chore: migrate tailscale settings read

* chore: migrate guacamole settings reads

* chore: migrate session timeout settings reads

* chore: migrate auth route settings reads

* chore: migrate host metrics settings reads

* chore: migrate startup settings reads

* chore: migrate user settings cleanup

* chore: migrate password reset settings

* chore: migrate oidc legacy settings read

* chore: migrate user route settings slice

* chore: migrate oidc state settings

* chore: migrate user login settings reads

* chore: migrate user crypto settings

* chore: consolidate startup settings defaults

* chore: consolidate database settings import export

* chore: migrate core session auth paths

* chore: migrate remaining session auth paths

* chore: migrate admin user routes

* chore: migrate user route admin checks

* chore: migrate user lifecycle routes

* chore: migrate auth user lookups

* chore: migrate oidc user routes

* chore: migrate api key repository paths

* docs: add database gray rollout guide

* chore: migrate trusted device paths

* chore: migrate user session route user lookups

* chore: add database repository rollout guard

* chore: expose repository rollout status

* chore: warn on repository rollout misconfiguration

* chore: migrate remaining user lookup helpers

* chore: migrate ssh user lookups

* chore: migrate user settings admin lookups

* chore: migrate acme ssl user lookups

* chore: migrate audit log admin checks

* chore: migrate oidc account user updates

* chore: migrate password reset user updates

* chore: migrate user deletion core records

* chore: migrate snippet audit user lookups

* chore: migrate ldap user sync paths

* chore: migrate totp user updates

* chore: migrate rbac user checks

* chore: migrate rbac role paths

* chore: migrate permission role lookups

* chore: migrate rbac access list reads

* chore: migrate shared rbac reads

* chore: migrate rbac access writes

* chore: migrate permission host access

* chore: migrate role host access lookup

* chore: migrate snippet access lookup

* chore: migrate shared credential access lookups

* chore: migrate host access cleanup writes

* chore: migrate host list access checks

* chore: migrate host access cleanup routes

* chore: migrate shared credential role lookups

* chore: migrate user role cleanup

* chore: migrate admin role sync

* chore: migrate ldap role sync

* chore: migrate user role assignment

* chore: migrate sso provider access

* chore: migrate audit log access

* chore: migrate user preference access

* chore: migrate open tab access

* chore: migrate dismissed alert access

* chore: migrate homepage layout access

* chore: migrate network topology access

* chore: migrate dashboard service link access

* chore: migrate command history access

* chore: migrate recent activity cleanup

* chore: migrate ssh credential usage access

* chore: migrate transfer recent access

* chore: migrate file manager bookmark access

* chore: migrate c2s tunnel preset access

* chore: migrate homepage item access

* chore: migrate session recording access

* chore: migrate tmux session tag access

* chore: migrate opkssh token access

* chore: migrate vault token access

* chore: migrate vault profile access

* chore: migrate host metrics preference access

* chore: migrate host health access

* chore: migrate host metrics history access

* chore: migrate alert persistence access

* chore: route alert host lookup through repository

* chore: migrate user data export reads

* chore: route host metrics stats sync through repository

* chore: migrate host folder persistence

* chore: migrate host resolution reads

* chore: route jump host resolution reads

* chore: route docker console jump host reads

* chore: route docker ssh resolution reads

* chore: route proxmox discovery resolution reads

* chore: route file manager activity host reads

* chore: route host metrics resolution reads

* chore: route ssh auth credential reads

* chore: route tunnel endpoint credential reads

* chore: route credential deployment resolution reads

* chore: route command history host flag reads

* chore: route snippet execution resolution reads

* chore: route terminal host resolution reads

* chore: route vault oidc host resolution reads

* chore: route wake on lan host reads

* chore: route internal host list reads

* chore: route host key verification persistence

* chore: route credential read paths

* chore: route credential host usage reads

* chore: route credential folder rename

* chore: route host owner access checks

* chore: route shared credential source reads

* chore: route user host credential cleanup

* chore: route credential delete reads

* chore: route credential update reads

* chore: route host credential reads

* chore: route host read paths

* chore: route host projection reads

* chore: route host list reads

* chore: route snippet read paths

* chore: route snippet folder writes

* chore: route snippet crud paths

* chore: route snippet bulk import

* chore: route rbac ownership reads

* chore: route user count reads

* chore: route cleanup snippets folders

* chore: route shared credential persistence

* chore: route dashboard activity

* chore: route guacamole host reads

* chore: route host bulk lookups

* chore: remove unlock-only simple db ops

* chore: route host autostart persistence

* chore: route ldap provisioning through users

* chore: route credential encrypted writes

* chore: route host encrypted writes

* chore: route bulk host encrypted writes

* chore: route termix id credentials

* chore: route termix id ca persistence

* chore: route termix identity persistence

* chore: route credential system migration

* chore: isolate user encryption migration storage

* chore: remove legacy simple db ops

* chore: isolate legacy sqlite migration copy

* chore: route database settings import export

* chore: route database host credential export

* chore: route database host credential import

* chore: route database file-manager import export

* chore: route database alert usage import export

* chore: route database user checks

* chore: isolate auth lazy migration storage

* chore: route explicit database saves

* chore: initialize database save boundary

* chore: route migration snapshot saves

* chore: isolate sqlite import constraints

* chore: route import sqlite boundary

* chore: route user encryption migration store

* chore: centralize current repository runtime

* chore: route more current repositories

* chore: route activity repository runtimes

* chore: route token repository runtimes

* chore: route health repository runtimes

* chore: route identity repository runtimes

* chore: route rbac repository runtime

* chore: centralize current sqlite runtime access

* chore: route user deletion key cleanup

* chore: route user deletion vault cleanup

* chore: route user deletion homepage cleanup

* chore: route user deletion health cleanup

* chore: route user deletion alert cleanup

* chore: route user deletion identity cleanup

* chore: add database layer preupgrade backup

* Fix database repository type errors

* fix: complete post-merge compile fixes for database refactor

Restore missing DatabaseSaveTrigger/getDb imports, session log format
fallback, OIDC provider resolution, guacamole recording insert, and
passwordFallbackOnly typing after merging current dev.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: DivByZero <mr.oplus@yahoo.fr>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: devdanetra <46488477+devdanetra@users.noreply.github.com>
Co-authored-by: Aleksandr Fominykh <neoformalex@users.noreply.github.com>
Co-authored-by: sash <sash@fominykh.io>

* refactor(db): collapse repository rollout scaffolding into single factory

Repositories are now the only data path. Replaces the 41 current-*-repository
wrapper files, the DATABASE_LAYER_REPOSITORY_ROLLOUT flag/alias map and the
unused database/runtime adapter with repositories/factory.ts, a plain
DatabaseContext type and an in-memory TestSqliteDatabase test harness.

* refactor(db): route remaining raw DB access through repositories

proxmox, session-log, oidc-utils, webauthn and guacamole recording now use
repositories (new WebauthnCredentialRepository; SsoProviderRepository
listEnabled; HostRepository findDecryptedByIdAs/listProxmoxEnabled).
Remaining raw access: db boot code, simple-db-ops and docker.ts, which are
removed/restructured in later phases.

* feat(crypto): add UserKeyManager with system-wrapped per-user DEKs

New utils/user-keys.ts: one random 32-byte DEK per user, wrapped
AES-256-GCM under an HKDF key derived from the system ENCRYPTION_KEY
(per-user info string + AAD binding, versioned v3 wrap format stored in
settings). Synchronous unwrap-on-demand with a 15-minute cache so the
existing DataCrypto facade keeps its sync call sites. Not wired up yet.

* feat(crypto): boot-time DEK migration to system-wrapped v3 format

utils/crypto-migration/dek-migration.ts carries the legacy unwrap paths
(PBKDF2 password KEK, OIDC/WebAuthn system keys, hardcoded-default
fallback) and migrates every server-unwrappable DEK to the v3 wrap at
startup. Password-wrapped DEKs migrate at next login or from a live
session via adoptRecoveredDEK. Legacy rows are kept for now; cleanup
flips on once the new path is authoritative.

* refactor(crypto): make system-wrapped DEKs the authoritative key path

DataCrypto and AuthManager now read keys through UserKeyManager: DEKs are
always unwrappable server-side, so the in-memory unlock session, DEK-in-JWT
wrapping, session-expiry data locks and ALLOW_APIKEY_DATA_UNLOCK are gone.
utils/user-crypto.ts is deleted; boot migration now cleans legacy wraps.
A one-release shim adopts DEKs from legacy dataKeyWrap tokens so active
password users migrate without re-login. Password login migrates legacy
password-wrapped DEKs via migratePasswordUserAtLogin.

* refactor(crypto): remove pending share queue and credential sharing key

With server-unwrappable DEKs both sides of a share are always available,
so the needsReEncryption queue, CREDENTIAL_SHARING_KEY and the system_*
shadow columns on ssh_credentials are gone. A one-time boot cleanup
re-creates legacy pending share copies where possible (dropping
unresolvable ones with a warning) and drops the legacy columns.

* feat(auth): non-destructive password resets and admin reset endpoint

Password resets no longer destroy user data: the DEK is system-wrapped, so
forgot-password and admin resets are just a hash update plus session revoke.
The wipe branch survives only for accounts that never logged in since the
encryption upgrade and now requires explicit confirmDataWipe (surfaced as a
409 DATA_WIPE_REQUIRED; the reset UI asks for confirmation). Adds
POST /users/admin/reset-password and removes the dead re-encryption paths.

* refactor(ssh): consolidate four jump-host chain copies into one module

terminal, host-metrics and docker now use ssh/jump-host-chain.ts (already
shared by file-manager, tmux-monitor and docker-console); docker's inline
copy also drops its raw SimpleDBOps host/credential lookups in favor of
repositories.

* refactor(ssh): single shared createConnectionLog helper

file-manager-log.ts becomes ssh/connection-log.ts; the copies in docker.ts
and host-metrics-helpers.ts are gone.

* refactor(ssh): split docker module into layered directory

ssh/docker/{index,routes,session-manager,container-routes,console}.ts:
server boot and wiring in index, HTTP handlers in routes, SSH session
registry and command execution in session-manager. Code motion only;
port 30007/30009 and endpoints unchanged. Swagger now scans ssh
subdirectories.

* refactor(ssh): split tunnel module into layered directory

ssh/tunnel/{index,routes,manager}.ts: server boot in index, HTTP handlers
in routes, tunnel state and engine (connect/retry/autostart) in manager.
Code motion only; port 30003 and endpoints unchanged.

* refactor(backend): reorganize top-level layout

- ssh/ renamed to hosts/ (it covers SSH, RDP, VNC, Telnet, Docker, metrics)
- serial/serial.ts and guacamole/ moved inside hosts/
- dashboard.ts and homepage.ts moved to services/
- swagger.ts moved to utils/ with adjusted scan globs

Import paths and the generate:openapi script updated; ports and endpoints
unchanged.

* refactor(tests): move backend tests into src/backend/tests mirror tree

Backend *.test.ts files (and the test-support harness) no longer sit next
to source files; they live under src/backend/tests/ mirroring the source
layout. Imports rewritten accordingly; CLAUDE.md convention updated.

* refactor(hosts): group host modules into per-feature directories

file-manager/, metrics/ (incl. widgets, managers, alert-engine),
terminal/, tmux/ and tunnel/ each own their files; docker/ gains
container-runtime. Genuinely shared helpers (jump-host chain, host
resolver, connection pool, opkssh, vault, serial) stay at hosts/ root.
Pure file moves with import path updates; mirrored test paths follow.

* refactor(backend): final cleanup pass

- re-register WebAuthn passkey routes (registration was dropped in the
  #1054 merge, breaking passkey login) and document all six endpoints
- delete utils/simple-db-ops.ts (last caller migrated to DataCrypto)
- starter: use the typed serverReady export, collapse the four-way
  version lookup to env then package.json candidates
- add OpenAPI JSDoc to c2s-tunnel-presets endpoints
- strip block-divider comment banners

* feat: remove legacy "data_unlocked" field

* feat: refactor rbac/sharing to support new permissions and auth types

* feat: refactor rbac/sharing to support new permissions and auth types

* feat: add "id" to user profile hide list

* chore: root cleanup

* feat: add more donation references and a 30-day donation reminder

* chore: update readme

* feat: automate beta tests

* feat: add links to milestones

* fix: hoist github/google SSO defaults to module scope (#1065)

* fix(ssh-tools): allow clipboard paste in key recording field (#1066)

The broadcast key-recording input was marked readOnly, which makes
browsers block paste entirely (no context-menu Paste, Ctrl+V does
nothing). handleKeyDown also called preventDefault() unconditionally,
swallowing the Ctrl+V shortcut before a paste event could even fire.

Let Ctrl/Cmd+V pass through in handleKeyDown, drop readOnly, and add
an onPaste handler that reads the clipboard text and broadcasts it to
the selected terminals like any other captured keystroke.

Signed-off-by: emreumar <emreumar@users.noreply.github.com>
Co-authored-by: emreumar <emreumar@users.noreply.github.com>

* chore: write release notes

* chore: update release notes

* chore: update readmes

* chore: add crypto only reminder in en.json

* fix: macOS and cask errors on release workflow

* chore: sync Crowdin translations for 2.5.1

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: emreumar <emreumar@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: Russell Poovey <09.our_seekers@icloud.com>
Co-authored-by: russell <git@0896c69e.com>
Co-authored-by: Subedi Bibek <77529535+questbibek@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Alexander Elsner <101340634+Bensonheimer992@users.noreply.github.com>
Co-authored-by: SankeerthNara <sankeerthnara@gmail.com>
Co-authored-by: Stephan Groth <96803994+Kalvalax@users.noreply.github.com>
Co-authored-by: DivByZero <mr.oplus@yahoo.fr>
Co-authored-by: devdanetra <46488477+devdanetra@users.noreply.github.com>
Co-authored-by: Aleksandr Fominykh <neoformalex@users.noreply.github.com>
Co-authored-by: sash <sash@fominykh.io>
Co-authored-by: lhojun <ldgs3324@gmail.com>
Co-authored-by: Yunus Emre Umar <77045015+emre155@users.noreply.github.com>
Co-authored-by: emreumar <emreumar@users.noreply.github.com>
This commit is contained in:
Luke Gustafson
2026-07-19 12:29:52 -05:00
committed by GitHub
co-authored by emreumar dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ZacharyZcR Russell Poovey russell Subedi Bibek Claude Fable 5 Alexander Elsner SankeerthNara Stephan Groth DivByZero devdanetra Aleksandr Fominykh sash lhojun Yunus Emre Umar
parent fba645e92e
commit ddbdd5c437
562 changed files with 52303 additions and 23645 deletions
+88 -85
View File
@@ -1,13 +1,15 @@
import { execSync } from "child_process";
import { execFileSync } from "child_process";
import { promises as fs } from "fs";
import path from "path";
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import { eq } from "drizzle-orm";
import { authLogger } from "../../utils/logger.js";
import { db } from "../db/index.js";
import { users } from "../db/schema.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import {
createCurrentSettingsRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
import type { UserRecord } from "../repositories/user-repository.js";
const DATA_DIR = process.env.DATA_DIR || "./db/data";
const SSL_DIR = path.join(DATA_DIR, "ssl");
@@ -33,13 +35,21 @@ export type AcmeSettings = {
certExpiresAt: string | null;
};
async function getAdminActor(
userId: string | undefined,
): Promise<UserRecord | null> {
if (!userId) return null;
const user = await createCurrentUserRepository().findById(userId);
return user?.isAdmin ? user : null;
}
function getCertInfo(): {
status: "none" | "valid" | "expiring" | "expired";
expiresAt: string | null;
} {
const certFile = path.join(SSL_DIR, "termix.crt");
try {
execSync(`openssl x509 -in "${certFile}" -noout 2>/dev/null`, {
execFileSync("openssl", ["x509", "-in", certFile, "-noout"], {
stdio: "pipe",
});
} catch {
@@ -47,8 +57,9 @@ function getCertInfo(): {
}
try {
const endDateRaw = execSync(
`openssl x509 -in "${certFile}" -noout -enddate`,
const endDateRaw = execFileSync(
"openssl",
["x509", "-in", certFile, "-noout", "-enddate"],
{ stdio: "pipe" },
)
.toString()
@@ -57,17 +68,25 @@ function getCertInfo(): {
const expiresAt = new Date(endDateRaw).toISOString();
try {
execSync(`openssl x509 -in "${certFile}" -checkend 0 -noout`, {
stdio: "pipe",
});
execFileSync(
"openssl",
["x509", "-in", certFile, "-checkend", "0", "-noout"],
{
stdio: "pipe",
},
);
} catch {
return { status: "expired", expiresAt };
}
try {
execSync(`openssl x509 -in "${certFile}" -checkend 2592000 -noout`, {
stdio: "pipe",
});
execFileSync(
"openssl",
["x509", "-in", certFile, "-checkend", "2592000", "-noout"],
{
stdio: "pipe",
},
);
return { status: "valid", expiresAt };
} catch {
return { status: "expiring", expiresAt };
@@ -77,13 +96,11 @@ function getCertInfo(): {
}
}
function getAcmeSettingsFromDb(): AcmeSettings {
const row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'acme_ssl_settings'")
.get() as { value: string } | undefined;
async function getAcmeSettings(): Promise<AcmeSettings> {
const { status, expiresAt } = getCertInfo();
const stored = row ? JSON.parse(row.value) : {};
const value =
await createCurrentSettingsRepository().get("acme_ssl_settings");
const stored = value ? JSON.parse(value) : {};
return {
enabled: stored.enabled ?? false,
@@ -119,7 +136,7 @@ export function registerAcmeSSLRoutes(
*/
router.get("/acme-ssl-settings", authenticateJWT, async (_req, res) => {
try {
res.json(getAcmeSettingsFromDb());
res.json(await getAcmeSettings());
} catch (err) {
authLogger.error("Failed to get ACME SSL settings", err);
res.status(500).json({ error: "Failed to get ACME SSL settings" });
@@ -163,15 +180,14 @@ export function registerAcmeSSLRoutes(
router.patch("/acme-ssl-settings", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0 || !user[0].isAdmin) {
const actor = await getAdminActor(userId);
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const existing = db.$client
.prepare("SELECT value FROM settings WHERE key = 'acme_ssl_settings'")
.get() as { value: string } | undefined;
const current = existing ? JSON.parse(existing.value) : {};
const settingsRepository = createCurrentSettingsRepository();
const existing = await settingsRepository.get("acme_ssl_settings");
const current = existing ? JSON.parse(existing) : {};
const { enabled, domain, email, challengeType, cloudflareToken } =
req.body;
@@ -187,21 +203,15 @@ export function registerAcmeSSLRoutes(
!cloudflareToken.includes("*") && { cloudflareToken }),
};
db.$client
.prepare(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('acme_ssl_settings', ?)",
)
.run(JSON.stringify(updated));
await settingsRepository.set(
"acme_ssl_settings",
JSON.stringify(updated),
);
const { ipAddress, userAgent } = getRequestMeta(req);
const actorRecord = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: actorRecord[0]?.username ?? userId,
username: actor.username ?? userId,
action: "update_acme_ssl_settings",
resourceType: "setting",
details: JSON.stringify({
@@ -216,7 +226,7 @@ export function registerAcmeSSLRoutes(
success: true,
});
res.json(getAcmeSettingsFromDb());
res.json(await getAcmeSettings());
} catch (err) {
authLogger.error("Failed to update ACME SSL settings", err);
res.status(500).json({ error: "Failed to update ACME SSL settings" });
@@ -243,21 +253,20 @@ export function registerAcmeSSLRoutes(
*/
router.post("/acme-ssl-request", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const actor = await getAdminActor(userId);
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0 || !user[0].isAdmin) {
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'acme_ssl_settings'")
.get() as { value: string } | undefined;
const settingsValue =
await createCurrentSettingsRepository().get("acme_ssl_settings");
if (!row) {
if (!settingsValue) {
return res.status(400).json({ error: "ACME settings not configured" });
}
const settings = JSON.parse(row.value);
const settings = JSON.parse(settingsValue);
const { domain, email, challengeType, cloudflareToken } = settings;
if (!domain || !email) {
@@ -265,7 +274,7 @@ export function registerAcmeSSLRoutes(
}
try {
execSync("certbot --version", { stdio: "pipe" });
execFileSync("certbot", ["--version"], { stdio: "pipe" });
} catch {
return res
.status(500)
@@ -278,13 +287,16 @@ export function registerAcmeSSLRoutes(
await fs.mkdir(CERTBOT_WORK_DIR, { recursive: true });
await fs.mkdir(CERTBOT_LOGS_DIR, { recursive: true });
const certbotDirFlags = [
`--config-dir "${CERTBOT_CONFIG_DIR}"`,
`--work-dir "${CERTBOT_WORK_DIR}"`,
`--logs-dir "${CERTBOT_LOGS_DIR}"`,
].join(" ");
const certbotDirArgs = [
"--config-dir",
CERTBOT_CONFIG_DIR,
"--work-dir",
CERTBOT_WORK_DIR,
"--logs-dir",
CERTBOT_LOGS_DIR,
];
let certbotCmd: string;
let certbotArgs: string[];
if (challengeType === "dns-cloudflare") {
if (!cloudflareToken) {
@@ -302,40 +314,39 @@ export function registerAcmeSSLRoutes(
{ mode: 0o600 },
);
certbotCmd = [
"certbot",
certbotArgs = [
"certonly",
"--non-interactive",
"--agree-tos",
"--dns-cloudflare",
`--dns-cloudflare-credentials "${CLOUDFLARE_CREDENTIALS_FILE}"`,
"--dns-cloudflare-credentials",
CLOUDFLARE_CREDENTIALS_FILE,
"--dns-cloudflare-propagation-seconds",
"30",
"-d",
`"${domain}"`,
domain,
"--email",
`"${email}"`,
email,
"--cert-name",
"termix",
certbotDirFlags,
].join(" ");
...certbotDirArgs,
];
} else {
certbotCmd = [
"certbot",
certbotArgs = [
"certonly",
"--non-interactive",
"--agree-tos",
"--webroot",
"-w",
`"${ACME_WEBROOT}"`,
ACME_WEBROOT,
"-d",
`"${domain}"`,
domain,
"--email",
`"${email}"`,
email,
"--cert-name",
"termix",
certbotDirFlags,
].join(" ");
...certbotDirArgs,
];
}
authLogger.info("Requesting Let's Encrypt certificate", {
@@ -344,7 +355,10 @@ export function registerAcmeSSLRoutes(
operation: "acme_cert_request",
});
execSync(certbotCmd, { stdio: "pipe", timeout: 120000 });
execFileSync("certbot", certbotArgs, {
stdio: "pipe",
timeout: 120000,
});
const liveDir = path.join(CERTBOT_CONFIG_DIR, "live", "termix");
const fullchainSrc = path.join(liveDir, "fullchain.pem");
@@ -358,11 +372,10 @@ export function registerAcmeSSLRoutes(
await fs.chmod(certDest, 0o644);
const updated = { ...settings, lastIssuedAt: new Date().toISOString() };
db.$client
.prepare(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('acme_ssl_settings', ?)",
)
.run(JSON.stringify(updated));
await createCurrentSettingsRepository().set(
"acme_ssl_settings",
JSON.stringify(updated),
);
authLogger.info("Let's Encrypt certificate issued and installed", {
domain,
@@ -370,14 +383,9 @@ export function registerAcmeSSLRoutes(
});
const { ipAddress, userAgent } = getRequestMeta(req);
const actorRecord = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: actorRecord[0]?.username ?? userId,
username: actor.username ?? userId,
action: "acme_ssl_request",
resourceType: "setting",
details: JSON.stringify({ domain, challengeType, success: true }),
@@ -386,20 +394,15 @@ export function registerAcmeSSLRoutes(
success: true,
});
res.json({ success: true, ...getAcmeSettingsFromDb() });
res.json({ success: true, ...(await getAcmeSettings()) });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
authLogger.error("ACME certificate request failed", err);
const { ipAddress, userAgent } = getRequestMeta(req);
const actorRecord = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: actorRecord[0]?.username ?? userId,
username: actor?.username ?? userId,
action: "acme_ssl_request",
resourceType: "setting",
details: JSON.stringify({ error: message }),
+148 -297
View File
@@ -1,8 +1,7 @@
import express, { type Request, type Response } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { getDb } from "../db/index.js";
import { createCurrentAlertRepository } from "../repositories/factory.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { DatabaseSaveTrigger } from "../db/index.js";
import { databaseLogger } from "../../utils/logger.js";
import { sendWebhook, sendNtfy } from "../../utils/notification-sender.js";
@@ -36,14 +35,11 @@ router.use(authenticateJWT);
* 200:
* description: List of notification channels.
*/
router.get("/notification-channels", (req, res) => {
router.get("/notification-channels", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const rows = getDb()
.$client.prepare(
"SELECT * FROM notification_channels WHERE user_id = ? ORDER BY id ASC",
)
.all(userId);
const rows =
await createCurrentAlertRepository().listNotificationChannels(userId);
res.json(rows);
} catch (err) {
databaseLogger.error("Failed to list notification channels", {
@@ -62,7 +58,7 @@ router.get("/notification-channels", (req, res) => {
* tags:
* - Alerts
*/
router.post("/notification-channels", (req, res) => {
router.post("/notification-channels", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { name, type, config, enabled } = req.body as {
name: string;
@@ -94,22 +90,13 @@ router.post("/notification-channels", (req, res) => {
}
try {
const result = getDb()
.$client.prepare(
`INSERT INTO notification_channels (user_id, name, type, config, enabled)
VALUES (?, ?, ?, ?, ?)`,
)
.run(
userId,
name.trim(),
type,
JSON.stringify(config),
enabled !== false ? 1 : 0,
);
const row = getDb()
.$client.prepare("SELECT * FROM notification_channels WHERE id = ?")
.get(result.lastInsertRowid);
DatabaseSaveTrigger.triggerSave("notification_channel_created");
const row = await createCurrentAlertRepository().createNotificationChannel({
userId,
name: name.trim(),
type,
config: JSON.stringify(config),
enabled: enabled !== false,
});
res.status(201).json(row);
} catch (err) {
databaseLogger.error("Failed to create notification channel", {
@@ -128,69 +115,61 @@ router.post("/notification-channels", (req, res) => {
* tags:
* - Alerts
*/
router.put("/notification-channels/:id", (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const channelId = Number(req.params.id);
const { name, type, config, enabled } = req.body as {
name?: string;
type?: string;
config?: unknown;
enabled?: boolean;
};
router.put(
"/notification-channels/:id",
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const channelId = Number(req.params.id);
const { name, type, config, enabled } = req.body as {
name?: string;
type?: string;
config?: unknown;
enabled?: boolean;
};
const existing = getDb()
.$client.prepare(
"SELECT id FROM notification_channels WHERE id = ? AND user_id = ?",
)
.get(channelId, userId);
if (!existing) return res.status(404).json({ error: "Channel not found" });
const repository = createCurrentAlertRepository();
const existing = await repository.findNotificationChannelForUser(
channelId,
userId,
);
if (!existing) return res.status(404).json({ error: "Channel not found" });
if (type && type !== "webhook" && type !== "ntfy") {
return res.status(400).json({ error: "type must be 'webhook' or 'ntfy'" });
}
try {
const updates: string[] = [];
const params: unknown[] = [];
if (name !== undefined) {
updates.push("name = ?");
params.push(name.trim());
if (type && type !== "webhook" && type !== "ntfy") {
return res
.status(400)
.json({ error: "type must be 'webhook' or 'ntfy'" });
}
if (type !== undefined) {
updates.push("type = ?");
params.push(type);
}
if (config !== undefined) {
updates.push("config = ?");
params.push(JSON.stringify(config));
}
if (enabled !== undefined) {
updates.push("enabled = ?");
params.push(enabled ? 1 : 0);
if (
name === undefined &&
type === undefined &&
config === undefined &&
enabled === undefined
) {
return res.json({ success: true });
}
if (updates.length === 0) return res.json({ success: true });
params.push(channelId, userId);
getDb()
.$client.prepare(
`UPDATE notification_channels SET ${updates.join(", ")} WHERE id = ? AND user_id = ?`,
)
.run(...params);
const row = getDb()
.$client.prepare("SELECT * FROM notification_channels WHERE id = ?")
.get(channelId);
DatabaseSaveTrigger.triggerSave("notification_channel_updated");
res.json(row);
} catch (err) {
databaseLogger.error("Failed to update notification channel", {
operation: "update_channel",
error: err,
});
res.status(500).json({ error: "Failed to update channel" });
}
});
try {
const row = await repository.updateNotificationChannel(
channelId,
userId,
{
...(name !== undefined ? { name: name.trim() } : {}),
...(type !== undefined ? { type } : {}),
...(config !== undefined ? { config: JSON.stringify(config) } : {}),
...(enabled !== undefined ? { enabled } : {}),
},
);
if (!row) return res.status(404).json({ error: "Channel not found" });
res.json(row);
} catch (err) {
databaseLogger.error("Failed to update notification channel", {
operation: "update_channel",
error: err,
});
res.status(500).json({ error: "Failed to update channel" });
}
},
);
/**
* @openapi
@@ -200,21 +179,20 @@ router.put("/notification-channels/:id", (req: Request, res: Response) => {
* tags:
* - Alerts
*/
router.delete("/notification-channels/:id", (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const channelId = Number(req.params.id);
const existing = getDb()
.$client.prepare(
"SELECT id FROM notification_channels WHERE id = ? AND user_id = ?",
)
.get(channelId, userId);
if (!existing) return res.status(404).json({ error: "Channel not found" });
getDb()
.$client.prepare("DELETE FROM notification_channels WHERE id = ?")
.run(channelId);
DatabaseSaveTrigger.triggerSave("notification_channel_deleted");
res.json({ success: true });
});
router.delete(
"/notification-channels/:id",
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const channelId = Number(req.params.id);
const deleted =
await createCurrentAlertRepository().deleteNotificationChannel(
channelId,
userId,
);
if (!deleted) return res.status(404).json({ error: "Channel not found" });
res.json({ success: true });
},
);
/**
* @openapi
@@ -229,11 +207,11 @@ router.post(
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const channelId = Number(req.params.id);
const row = getDb()
.$client.prepare(
"SELECT * FROM notification_channels WHERE id = ? AND user_id = ?",
)
.get(channelId, userId) as { type: string; config: string } | undefined;
const row =
await createCurrentAlertRepository().findNotificationChannelForUser(
channelId,
userId,
);
if (!row) return res.status(404).json({ error: "Channel not found" });
const testPayload = {
@@ -288,33 +266,10 @@ router.post(
* tags:
* - Alerts
*/
router.get("/alert-rules", (req, res) => {
router.get("/alert-rules", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const rules = getDb()
.$client.prepare(
"SELECT * FROM alert_rules WHERE user_id = ? ORDER BY id ASC",
)
.all(userId) as Array<{ id: number }>;
const channelMap = new Map<number, number[]>();
for (const rule of rules) {
const channels = getDb()
.$client.prepare(
"SELECT channel_id FROM alert_rule_channels WHERE rule_id = ?",
)
.all(rule.id) as Array<{ channel_id: number }>;
channelMap.set(
rule.id,
channels.map((c) => c.channel_id),
);
}
const result = rules.map((r) => ({
...r,
channels: channelMap.get(r.id) ?? [],
}));
res.json(result);
res.json(await createCurrentAlertRepository().listAlertRules(userId));
} catch (err) {
databaseLogger.error("Failed to list alert rules", {
operation: "list_alert_rules",
@@ -332,7 +287,7 @@ router.get("/alert-rules", (req, res) => {
* tags:
* - Alerts
*/
router.post("/alert-rules", (req, res) => {
router.post("/alert-rules", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const {
name,
@@ -373,45 +328,18 @@ router.post("/alert-rules", (req, res) => {
try {
const now = new Date().toISOString();
const result = getDb()
.$client.prepare(
`INSERT INTO alert_rules
(user_id, host_id, name, enabled, trigger_type, threshold_value,
threshold_duration_seconds, cooldown_minutes, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
userId,
hostId ?? null,
name.trim(),
enabled !== false ? 1 : 0,
triggerType,
thresholdValue ?? null,
thresholdDurationSeconds ?? null,
cooldownMinutes ?? 15,
now,
now,
);
const ruleId = result.lastInsertRowid as number;
for (const channelId of channels) {
const owned = getDb()
.$client.prepare(
"SELECT id FROM notification_channels WHERE id = ? AND user_id = ?",
)
.get(channelId, userId);
if (!owned) continue;
getDb()
.$client.prepare(
"INSERT OR IGNORE INTO alert_rule_channels (rule_id, channel_id) VALUES (?, ?)",
)
.run(ruleId, channelId);
}
const row = getDb()
.$client.prepare("SELECT * FROM alert_rules WHERE id = ?")
.get(ruleId) as Record<string, unknown>;
DatabaseSaveTrigger.triggerSave("alert_rule_created");
const row = await createCurrentAlertRepository().createAlertRule({
userId,
hostId: hostId ?? null,
name: name.trim(),
enabled: enabled !== false,
triggerType,
thresholdValue: thresholdValue ?? null,
thresholdDurationSeconds: thresholdDurationSeconds ?? null,
cooldownMinutes: cooldownMinutes ?? 15,
channels,
now,
});
res.status(201).json({ ...row, channels });
} catch (err) {
databaseLogger.error("Failed to create alert rule", {
@@ -430,13 +358,12 @@ router.post("/alert-rules", (req, res) => {
* tags:
* - Alerts
*/
router.put("/alert-rules/:id", (req: Request, res: Response) => {
router.put("/alert-rules/:id", async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const ruleId = Number(req.params.id);
const existing = getDb()
.$client.prepare("SELECT id FROM alert_rules WHERE id = ? AND user_id = ?")
.get(ruleId, userId);
const repository = createCurrentAlertRepository();
const existing = await repository.findAlertRuleForUser(ruleId, userId);
if (!existing) return res.status(404).json({ error: "Alert rule not found" });
const {
@@ -464,76 +391,23 @@ router.put("/alert-rules/:id", (req: Request, res: Response) => {
}
try {
const updates: string[] = ["updated_at = ?"];
const params: unknown[] = [new Date().toISOString()];
if (name !== undefined) {
updates.push("name = ?");
params.push(name.trim());
}
if (hostId !== undefined) {
updates.push("host_id = ?");
params.push(hostId ?? null);
}
if (enabled !== undefined) {
updates.push("enabled = ?");
params.push(enabled ? 1 : 0);
}
if (triggerType !== undefined) {
updates.push("trigger_type = ?");
params.push(triggerType);
}
if (thresholdValue !== undefined) {
updates.push("threshold_value = ?");
params.push(thresholdValue ?? null);
}
if (thresholdDurationSeconds !== undefined) {
updates.push("threshold_duration_seconds = ?");
params.push(thresholdDurationSeconds ?? null);
}
if (cooldownMinutes !== undefined) {
updates.push("cooldown_minutes = ?");
params.push(cooldownMinutes);
}
params.push(ruleId, userId);
getDb()
.$client.prepare(
`UPDATE alert_rules SET ${updates.join(", ")} WHERE id = ? AND user_id = ?`,
)
.run(...params);
if (channels !== undefined) {
getDb()
.$client.prepare("DELETE FROM alert_rule_channels WHERE rule_id = ?")
.run(ruleId);
for (const channelId of channels) {
const owned = getDb()
.$client.prepare(
"SELECT id FROM notification_channels WHERE id = ? AND user_id = ?",
)
.get(channelId, userId);
if (!owned) continue;
getDb()
.$client.prepare(
"INSERT OR IGNORE INTO alert_rule_channels (rule_id, channel_id) VALUES (?, ?)",
)
.run(ruleId, channelId);
}
}
const row = getDb()
.$client.prepare("SELECT * FROM alert_rules WHERE id = ?")
.get(ruleId) as Record<string, unknown>;
const linkedChannels = (
getDb()
.$client.prepare(
"SELECT channel_id FROM alert_rule_channels WHERE rule_id = ?",
)
.all(ruleId) as Array<{ channel_id: number }>
).map((c) => c.channel_id);
DatabaseSaveTrigger.triggerSave("alert_rule_updated");
res.json({ ...row, channels: linkedChannels });
const row = await repository.updateAlertRule(ruleId, userId, {
...(name !== undefined ? { name: name.trim() } : {}),
...(hostId !== undefined ? { hostId: hostId ?? null } : {}),
...(enabled !== undefined ? { enabled } : {}),
...(triggerType !== undefined ? { triggerType } : {}),
...(thresholdValue !== undefined
? { thresholdValue: thresholdValue ?? null }
: {}),
...(thresholdDurationSeconds !== undefined
? { thresholdDurationSeconds: thresholdDurationSeconds ?? null }
: {}),
...(cooldownMinutes !== undefined ? { cooldownMinutes } : {}),
...(channels !== undefined ? { channels } : {}),
now: new Date().toISOString(),
});
if (!row) return res.status(404).json({ error: "Alert rule not found" });
res.json(row);
} catch (err) {
databaseLogger.error("Failed to update alert rule", {
operation: "update_alert_rule",
@@ -551,15 +425,14 @@ router.put("/alert-rules/:id", (req: Request, res: Response) => {
* tags:
* - Alerts
*/
router.delete("/alert-rules/:id", (req: Request, res: Response) => {
router.delete("/alert-rules/:id", async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const ruleId = Number(req.params.id);
const existing = getDb()
.$client.prepare("SELECT id FROM alert_rules WHERE id = ? AND user_id = ?")
.get(ruleId, userId);
if (!existing) return res.status(404).json({ error: "Alert rule not found" });
getDb().$client.prepare("DELETE FROM alert_rules WHERE id = ?").run(ruleId);
DatabaseSaveTrigger.triggerSave("alert_rule_deleted");
const deleted = await createCurrentAlertRepository().deleteAlertRule(
ruleId,
userId,
);
if (!deleted) return res.status(404).json({ error: "Alert rule not found" });
res.json({ success: true });
});
@@ -573,42 +446,27 @@ router.delete("/alert-rules/:id", (req: Request, res: Response) => {
* tags:
* - Alerts
*/
router.get("/alert-firings", (req, res) => {
router.get("/alert-firings", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const limit = Math.min(Number(req.query.limit) || 50, 200);
const offset = Number(req.query.offset) || 0;
const acknowledgedParam = req.query.acknowledged;
try {
let whereClause = "af.user_id = ?";
const params: unknown[] = [userId];
if (acknowledgedParam === "true") {
whereClause += " AND af.acknowledged = 1";
} else if (acknowledgedParam === "false") {
whereClause += " AND af.acknowledged = 0";
}
const rows = getDb()
.$client.prepare(
`SELECT af.*, ar.name as rule_name
FROM alert_firings af
LEFT JOIN alert_rules ar ON ar.id = af.rule_id
WHERE ${whereClause}
ORDER BY af.fired_at DESC
LIMIT ? OFFSET ?`,
)
.all(...params, limit, offset);
const total = (
getDb()
.$client.prepare(
`SELECT COUNT(*) as c FROM alert_firings af WHERE ${whereClause}`,
)
.get(...params) as { c: number }
).c;
res.json({ firings: rows, total });
const acknowledged =
acknowledgedParam === "true"
? true
: acknowledgedParam === "false"
? false
: undefined;
res.json(
await createCurrentAlertRepository().listAlertFirings({
userId,
acknowledged,
limit,
offset,
}),
);
} catch (err) {
databaseLogger.error("Failed to list alert firings", {
operation: "list_alert_firings",
@@ -626,17 +484,15 @@ router.get("/alert-firings", (req, res) => {
* tags:
* - Alerts
*/
router.post("/alert-firings/:id/acknowledge", (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const firingId = Number(req.params.id);
getDb()
.$client.prepare(
"UPDATE alert_firings SET acknowledged = 1 WHERE id = ? AND user_id = ?",
)
.run(firingId, userId);
DatabaseSaveTrigger.triggerSave("alert_firing_acknowledged");
res.json({ success: true });
});
router.post(
"/alert-firings/:id/acknowledge",
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const firingId = Number(req.params.id);
await createCurrentAlertRepository().acknowledgeFiring(firingId, userId);
res.json({ success: true });
},
);
/**
* @openapi
@@ -646,14 +502,9 @@ router.post("/alert-firings/:id/acknowledge", (req: Request, res: Response) => {
* tags:
* - Alerts
*/
router.post("/alert-firings/acknowledge-all", (req, res) => {
router.post("/alert-firings/acknowledge-all", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
getDb()
.$client.prepare(
"UPDATE alert_firings SET acknowledged = 1 WHERE user_id = ?",
)
.run(userId);
DatabaseSaveTrigger.triggerSave("alert_firings_acknowledged_all");
await createCurrentAlertRepository().acknowledgeAllFirings(userId);
res.json({ success: true });
});
+17 -38
View File
@@ -4,12 +4,10 @@ import type {
TermixAlert,
} from "../../../types/index.js";
import express from "express";
import { db } from "../db/index.js";
import { dismissedAlerts } from "../db/schema.js";
import { eq, and } from "drizzle-orm";
import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { getProxyAgent } from "../../utils/proxy-agent.js";
import { createCurrentDismissedAlertRepository } from "../repositories/factory.js";
class AlertCache {
private cache: Map<string, CacheEntry> = new Map();
@@ -120,13 +118,10 @@ router.get("/", authenticateJWT, async (req, res) => {
const allAlerts = await fetchAlertsFromGitHub();
const dismissedAlertRecords = await db
.select({ alertId: dismissedAlerts.alertId })
.from(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId));
const dismissedAlertIds = new Set(
dismissedAlertRecords.map((record) => record.alertId),
await createCurrentDismissedAlertRepository().listAlertIdsByUserId(
userId,
),
);
const activeAlertsForUser = allAlerts.filter(
@@ -181,25 +176,18 @@ router.post("/dismiss", authenticateJWT, async (req, res) => {
return res.status(400).json({ error: "Alert ID is required" });
}
const existingDismissal = await db
.select()
.from(dismissedAlerts)
.where(
and(
eq(dismissedAlerts.userId, userId),
eq(dismissedAlerts.alertId, alertId),
),
const existingDismissal =
await createCurrentDismissedAlertRepository().findForUser(
userId,
alertId,
);
if (existingDismissal.length > 0) {
if (existingDismissal) {
authLogger.warn(`Alert ${alertId} already dismissed by user ${userId}`);
return res.status(409).json({ error: "Alert already dismissed" });
}
await db.insert(dismissedAlerts).values({
userId,
alertId,
});
await createCurrentDismissedAlertRepository().create(userId, alertId);
res.json({ message: "Alert dismissed successfully" });
} catch (error) {
@@ -226,13 +214,8 @@ router.get("/dismissed", authenticateJWT, async (req, res) => {
try {
const userId = (req as AuthenticatedRequest).userId;
const dismissedAlertRecords = await db
.select({
alertId: dismissedAlerts.alertId,
dismissedAt: dismissedAlerts.dismissedAt,
})
.from(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId));
const dismissedAlertRecords =
await createCurrentDismissedAlertRepository().listByUserId(userId);
res.json({
dismissed_alerts: dismissedAlertRecords,
@@ -280,16 +263,12 @@ router.delete("/dismiss", authenticateJWT, async (req, res) => {
return res.status(400).json({ error: "Alert ID is required" });
}
const result = await db
.delete(dismissedAlerts)
.where(
and(
eq(dismissedAlerts.userId, userId),
eq(dismissedAlerts.alertId, alertId),
),
);
const deleted = await createCurrentDismissedAlertRepository().deleteForUser(
userId,
alertId,
);
if (result.changes === 0) {
if (!deleted) {
return res.status(404).json({ error: "Dismissed alert not found" });
}
res.json({ message: "Alert undismissed successfully" });
+30 -51
View File
@@ -1,10 +1,17 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import { eq, and, gte, lte, sql } from "drizzle-orm";
import { db } from "../db/index.js";
import { auditLogs, users } from "../db/schema.js";
import {
createCurrentAuditLogRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
import { apiLogger } from "../../utils/logger.js";
async function isAdminUser(userId: string | undefined): Promise<boolean> {
if (!userId) return false;
const user = await createCurrentUserRepository().findById(userId);
return !!user?.isAdmin;
}
export function registerAuditLogRoutes(
router: Router,
authenticateJWT: RequestHandler,
@@ -53,13 +60,7 @@ export function registerAuditLogRoutes(
router.get("/audit-logs", authenticateJWT, async (req, res) => {
try {
const authReq = req as AuthenticatedRequest;
const adminUser = await db
.select({ isAdmin: users.isAdmin })
.from(users)
.where(eq(users.id, authReq.userId))
.limit(1);
if (!adminUser[0]?.isAdmin) {
if (!(await isAdminUser(authReq.userId))) {
return res.status(403).json({ error: "Not authorized" });
}
@@ -73,36 +74,21 @@ export function registerAuditLogRoutes(
const { userId, action, resourceType, success, startDate, endDate } =
req.query as Record<string, string | undefined>;
const conditions = [];
if (userId) conditions.push(eq(auditLogs.userId, userId));
if (action) conditions.push(eq(auditLogs.action, action));
if (resourceType)
conditions.push(eq(auditLogs.resourceType, resourceType));
if (success !== undefined && success !== "") {
conditions.push(eq(auditLogs.success, success === "true"));
}
if (startDate) conditions.push(gte(auditLogs.timestamp, startDate));
if (endDate) conditions.push(lte(auditLogs.timestamp, endDate));
const whereClause =
conditions.length > 0 ? and(...conditions) : undefined;
const [logs, totalResult] = await Promise.all([
db
.select()
.from(auditLogs)
.where(whereClause)
.orderBy(sql`${auditLogs.timestamp} DESC`)
.limit(limit)
.offset(offset),
db
.select({ count: sql<number>`COUNT(*)` })
.from(auditLogs)
.where(whereClause),
]);
const total = totalResult[0]?.count ?? 0;
const { logs, total } = await createCurrentAuditLogRepository().listPage({
filters: {
userId,
action,
resourceType,
success:
success !== undefined && success !== ""
? success === "true"
: undefined,
startDate,
endDate,
},
limit,
offset,
});
const totalPages = Math.ceil(total / limit);
return res.json({ logs, total, page, totalPages });
@@ -131,21 +117,14 @@ export function registerAuditLogRoutes(
router.get("/audit-logs/actions", authenticateJWT, async (req, res) => {
try {
const authReq = req as AuthenticatedRequest;
const adminUser = await db
.select({ isAdmin: users.isAdmin })
.from(users)
.where(eq(users.id, authReq.userId))
.limit(1);
if (!adminUser[0]?.isAdmin) {
if (!(await isAdminUser(authReq.userId))) {
return res.status(403).json({ error: "Not authorized" });
}
const rows = db.$client
.prepare("SELECT DISTINCT action FROM audit_logs ORDER BY action ASC")
.all() as { action: string }[];
const actions =
await createCurrentAuditLogRepository().listDistinctActions();
return res.json({ actions: rows.map((r) => r.action) });
return res.json({ actions });
} catch (err) {
apiLogger.error("Failed to fetch audit log actions", err);
return res
+122 -75
View File
@@ -3,12 +3,11 @@ import type {
TunnelConnection,
} from "../../../types/index.js";
import express from "express";
import { db } from "../db/index.js";
import { c2sTunnelPresets } from "../db/schema.js";
import { and, asc, eq, sql } from "drizzle-orm";
import type { Request, Response } from "express";
import { authLogger, databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import type { C2sTunnelPresetRecord } from "../repositories/c2s-tunnel-preset-repository.js";
import { createCurrentC2sTunnelPresetRepository } from "../repositories/factory.js";
const router = express.Router();
@@ -20,7 +19,7 @@ function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
function parsePreset(row: typeof c2sTunnelPresets.$inferSelect) {
function parsePreset(row: C2sTunnelPresetRecord) {
return {
...row,
config: JSON.parse(row.config) as TunnelConnection[],
@@ -47,6 +46,22 @@ function validateConfig(config: unknown): config is TunnelConnection[] {
});
}
/**
* @openapi
* /c2s-tunnel-presets:
* get:
* summary: List client tunnel presets
* description: Returns the authenticated user's saved client-to-server tunnel presets.
* tags:
* - Tunnel Presets
* responses:
* 200:
* description: List of tunnel presets.
* 401:
* description: Authentication required.
* 500:
* description: Failed to list presets.
*/
router.get(
"/",
authenticateJWT,
@@ -58,11 +73,8 @@ router.get(
}
try {
const result = await db
.select()
.from(c2sTunnelPresets)
.where(eq(c2sTunnelPresets.userId, userId))
.orderBy(asc(c2sTunnelPresets.name));
const result =
await createCurrentC2sTunnelPresetRepository().listByUserId(userId);
res.json(result.map(parsePreset));
} catch (error) {
authLogger.error("Failed to fetch C2S tunnel presets", error);
@@ -71,6 +83,37 @@ router.get(
},
);
/**
* @openapi
* /c2s-tunnel-presets:
* post:
* summary: Create a client tunnel preset
* description: Saves a named client-to-server tunnel configuration for the authenticated user.
* tags:
* - Tunnel Presets
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* config:
* type: array
* items:
* type: object
* responses:
* 200:
* description: Preset created.
* 400:
* description: Invalid name or config.
* 401:
* description: Authentication required.
* 500:
* description: Failed to create preset.
*/
router.post(
"/",
authenticateJWT,
@@ -90,36 +133,24 @@ router.post(
const trimmedName = name.trim();
try {
const existing = await db
.select()
.from(c2sTunnelPresets)
.where(
and(
eq(c2sTunnelPresets.userId, userId),
eq(c2sTunnelPresets.name, trimmedName),
),
);
if (existing.length > 0) {
const presetRepository = createCurrentC2sTunnelPresetRepository();
if (await presetRepository.hasNameForUser(userId, trimmedName)) {
return res.status(409).json({ error: "Preset name already exists" });
}
const result = await db
.insert(c2sTunnelPresets)
.values({
userId,
name: trimmedName,
config: JSON.stringify(config),
platform: platform?.trim() || null,
computerName: computerName?.trim() || null,
})
.returning();
const created = await presetRepository.createForUser(userId, {
name: trimmedName,
config: JSON.stringify(config),
platform: platform?.trim() || null,
computerName: computerName?.trim() || null,
});
databaseLogger.info("C2S tunnel preset created", {
operation: "c2s_tunnel_preset_create",
userId,
presetId: result[0].id,
presetId: created.id,
});
res.status(201).json(parsePreset(result[0]));
res.status(201).json(parsePreset(created));
} catch (error) {
authLogger.error("Failed to create C2S tunnel preset", error);
res.status(500).json({ error: "Failed to create C2S tunnel preset" });
@@ -127,6 +158,29 @@ router.post(
},
);
/**
* @openapi
* /c2s-tunnel-presets/{id}:
* put:
* summary: Update a client tunnel preset
* description: Updates the name or config of one of the authenticated user's tunnel presets.
* tags:
* - Tunnel Presets
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200:
* description: Preset updated.
* 400:
* description: Invalid name or config.
* 404:
* description: Preset not found.
* 500:
* description: Failed to update preset.
*/
router.put(
"/:id",
authenticateJWT,
@@ -141,35 +195,21 @@ router.put(
}
try {
const existing = await db
.select()
.from(c2sTunnelPresets)
.where(
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
);
if (existing.length === 0) {
const presetRepository = createCurrentC2sTunnelPresetRepository();
const existing = await presetRepository.findByIdForUser(userId, id);
if (!existing) {
return res.status(404).json({ error: "Preset not found" });
}
const updateFields: Record<string, unknown> = {
updatedAt: sql`CURRENT_TIMESTAMP`,
};
const updateFields: Parameters<typeof presetRepository.updateForUser>[2] =
{};
if (name !== undefined) {
if (!isNonEmptyString(name)) {
return res.status(400).json({ error: "Preset name is required" });
}
const trimmedName = name.trim();
const duplicate = await db
.select()
.from(c2sTunnelPresets)
.where(
and(
eq(c2sTunnelPresets.userId, userId),
eq(c2sTunnelPresets.name, trimmedName),
),
);
if (duplicate.some((preset) => preset.id !== id)) {
if (await presetRepository.hasNameForUser(userId, trimmedName, id)) {
return res.status(409).json({ error: "Preset name already exists" });
}
updateFields.name = trimmedName;
@@ -188,18 +228,12 @@ router.put(
if (computerName !== undefined)
updateFields.computerName = computerName?.trim() || null;
await db
.update(c2sTunnelPresets)
.set(updateFields)
.where(
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
);
const updated = await db
.select()
.from(c2sTunnelPresets)
.where(eq(c2sTunnelPresets.id, id));
res.json(parsePreset(updated[0]));
const updated = await presetRepository.updateForUser(
userId,
id,
updateFields,
);
res.json(parsePreset(updated ?? existing));
} catch (error) {
authLogger.error("Failed to update C2S tunnel preset", error);
res.status(500).json({ error: "Failed to update C2S tunnel preset" });
@@ -207,6 +241,27 @@ router.put(
},
);
/**
* @openapi
* /c2s-tunnel-presets/{id}:
* delete:
* summary: Delete a client tunnel preset
* description: Deletes one of the authenticated user's tunnel presets.
* tags:
* - Tunnel Presets
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200:
* description: Preset deleted.
* 404:
* description: Preset not found.
* 500:
* description: Failed to delete preset.
*/
router.delete(
"/:id",
authenticateJWT,
@@ -220,21 +275,13 @@ router.delete(
}
try {
const existing = await db
.select()
.from(c2sTunnelPresets)
.where(
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
);
if (existing.length === 0) {
const presetRepository = createCurrentC2sTunnelPresetRepository();
const existing = await presetRepository.findByIdForUser(userId, id);
if (!existing) {
return res.status(404).json({ error: "Preset not found" });
}
await db
.delete(c2sTunnelPresets)
.where(
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
);
await presetRepository.deleteForUser(userId, id);
res.json({ success: true });
} catch (error) {
@@ -3,12 +3,9 @@ import type {
CredentialBackend,
} from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import { eq } from "drizzle-orm";
import ssh2Pkg from "ssh2";
import { db } from "../db/index.js";
import { hosts, sshCredentials } from "../db/schema.js";
import { createCurrentHostResolutionRepository } from "../repositories/factory.js";
import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js";
import { applyAgentAuth } from "../../ssh/terminal-auth-helpers.js";
const { Client } = ssh2Pkg;
@@ -87,6 +84,10 @@ async function deploySSHKeyToHost(
}
const keyPattern = keyParts[1];
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(keyPattern)) {
clearTimeout(checkTimeout);
return rejectCheck(new Error("Invalid public key data"));
}
conn.exec(
`if [ -f ~/.ssh/authorized_keys ]; then grep -F "${keyPattern}" ~/.ssh/authorized_keys >/dev/null 2>&1; echo $?; else echo 1; fi`,
@@ -192,6 +193,10 @@ async function deploySSHKeyToHost(
}
const keyPattern = keyParts[1];
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(keyPattern)) {
clearTimeout(verifyTimeout);
return rejectVerify(new Error("Invalid public key data"));
}
conn.exec(
`grep -F "${keyPattern}" ~/.ssh/authorized_keys >/dev/null 2>&1; echo $?`,
(err, stream) => {
@@ -265,100 +270,88 @@ async function deploySSHKeyToHost(
resolve({ success: false, error: errorMessage });
});
void (async () => {
try {
const connectionConfig: Record<string, unknown> = {
host: hostConfig.ip,
port: hostConfig.port || 22,
username: hostConfig.username,
readyTimeout: 60000,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
algorithms: {
kex: [
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group1-sha1",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group-exchange-sha1",
"ecdh-sha2-nistp256",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp521",
],
cipher: [
"aes128-ctr",
"aes192-ctr",
"aes256-ctr",
"aes128-gcm@openssh.com",
"aes256-gcm@openssh.com",
"aes128-cbc",
"aes192-cbc",
"aes256-cbc",
"3des-cbc",
],
hmac: [
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256",
"hmac-sha2-512",
"hmac-sha1",
"hmac-md5",
],
compress: ["none", "zlib@openssh.com", "zlib"],
},
};
try {
const connectionConfig: Record<string, unknown> = {
host: hostConfig.ip,
port: hostConfig.port || 22,
username: hostConfig.username,
readyTimeout: 60000,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
algorithms: {
kex: [
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group1-sha1",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group-exchange-sha1",
"ecdh-sha2-nistp256",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp521",
],
cipher: [
"aes128-ctr",
"aes192-ctr",
"aes256-ctr",
"aes128-gcm@openssh.com",
"aes256-gcm@openssh.com",
"aes128-cbc",
"aes192-cbc",
"aes256-cbc",
"3des-cbc",
],
hmac: [
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256",
"hmac-sha2-512",
"hmac-sha1",
"hmac-md5",
],
compress: ["none", "zlib@openssh.com", "zlib"],
},
};
if (hostConfig.authType === "password" && hostConfig.password) {
connectionConfig.password = hostConfig.password;
} else if (hostConfig.authType === "key" && hostConfig.privateKey) {
try {
const privateKey = hostConfig.privateKey as string;
connectionConfig.privateKey = preparePrivateKeyForSSH2(
privateKey,
hostConfig.keyPassword as string | undefined,
);
if (hostConfig.keyPassword) {
connectionConfig.passphrase = hostConfig.keyPassword;
}
} catch (keyError) {
clearTimeout(connectionTimeout);
resolve({
success: false,
error: `Invalid SSH key format: ${keyError instanceof Error ? keyError.message : "Unknown error"}`,
});
return;
}
} else if (hostConfig.authType === "agent") {
const result = await applyAgentAuth(
connectionConfig,
hostConfig.terminalConfig as Record<string, unknown> | undefined,
if (hostConfig.authType === "password" && hostConfig.password) {
connectionConfig.password = hostConfig.password;
} else if (hostConfig.authType === "key" && hostConfig.privateKey) {
try {
const privateKey = hostConfig.privateKey as string;
connectionConfig.privateKey = preparePrivateKeyForSSH2(
privateKey,
hostConfig.keyPassword as string | undefined,
);
if ("error" in result) {
clearTimeout(connectionTimeout);
resolve({ success: false, error: result.error });
return;
if (hostConfig.keyPassword) {
connectionConfig.passphrase = hostConfig.keyPassword;
}
} else {
} catch (keyError) {
clearTimeout(connectionTimeout);
resolve({
success: false,
error: `Invalid authentication configuration. Auth type: ${hostConfig.authType}, has password: ${!!hostConfig.password}, has key: ${!!hostConfig.privateKey}`,
error: `Invalid SSH key format: ${keyError instanceof Error ? keyError.message : "Unknown error"}`,
});
return;
}
conn.connect(connectionConfig);
} catch (error) {
} else {
clearTimeout(connectionTimeout);
resolve({
success: false,
error: error instanceof Error ? error.message : "Connection failed",
error: `Invalid authentication configuration. Auth type: ${hostConfig.authType}, has password: ${!!hostConfig.password}, has key: ${!!hostConfig.privateKey}`,
});
return;
}
})();
conn.connect(connectionConfig);
} catch (error) {
clearTimeout(connectionTimeout);
resolve({
success: false,
error: error instanceof Error ? error.message : "Connection failed",
});
}
});
}
@@ -427,25 +420,20 @@ export function registerCredentialDeployRoutes(
});
}
const { SimpleDBOps } = await import("../../utils/simple-db-ops.js");
const credential = await SimpleDBOps.select(
db
.select()
.from(sshCredentials)
.where(eq(sshCredentials.id, credentialId))
.limit(1),
"ssh_credentials",
const repository = createCurrentHostResolutionRepository();
const credential = await repository.findCredentialByIdForUser(
credentialId,
userId,
);
if (!credential || credential.length === 0) {
if (!credential) {
return res.status(404).json({
success: false,
error: "Credential not found",
});
}
const credData = credential[0] as unknown as CredentialBackend;
const credData = credential as unknown as CredentialBackend;
if (credData.authType !== "key") {
return res.status(400).json({
@@ -461,21 +449,18 @@ export function registerCredentialDeployRoutes(
error: "Public key is required for deployment",
});
}
const targetHost = await SimpleDBOps.select(
db.select().from(hosts).where(eq(hosts.id, targetHostId)).limit(1),
"ssh_data",
const hostData = await repository.findHostByIdForUser(
targetHostId,
userId,
);
if (!targetHost || targetHost.length === 0) {
if (!hostData) {
return res.status(404).json({
success: false,
error: "Target host not found",
});
}
const hostData = targetHost[0];
const hostConfig = {
ip: hostData.ip,
port: hostData.port,
@@ -484,7 +469,6 @@ export function registerCredentialDeployRoutes(
password: hostData.password,
privateKey: hostData.key,
keyPassword: hostData.keyPassword,
terminalConfig: hostData.terminalConfig,
};
if (hostData.authType === "credential" && hostData.credentialId) {
@@ -497,29 +481,21 @@ export function registerCredentialDeployRoutes(
}
try {
const { SimpleDBOps } =
await import("../../utils/simple-db-ops.js");
const hostCredential = await SimpleDBOps.select(
db
.select()
.from(sshCredentials)
.where(eq(sshCredentials.id, hostData.credentialId as number))
.limit(1),
"ssh_credentials",
const hostCredential = await repository.findCredentialByIdForUser(
hostData.credentialId as number,
userId,
);
if (hostCredential && hostCredential.length > 0) {
const cred = hostCredential[0];
if (hostCredential) {
hostConfig.authType = hostCredential.authType;
hostConfig.username = hostCredential.username;
hostConfig.authType = cred.authType;
hostConfig.username = cred.username;
if (cred.authType === "password") {
hostConfig.password = cred.password;
} else if (cred.authType === "key") {
hostConfig.privateKey = cred.privateKey || cred.key;
hostConfig.keyPassword = cred.keyPassword;
if (hostCredential.authType === "password") {
hostConfig.password = hostCredential.password;
} else if (hostCredential.authType === "key") {
hostConfig.privateKey =
hostCredential.privateKey || hostCredential.key;
hostConfig.keyPassword = hostCredential.keyPassword;
}
} else {
return res.status(400).json({
+114 -232
View File
@@ -1,21 +1,18 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import { db } from "../db/index.js";
import {
sshCredentials,
sshCredentialUsage,
hosts,
hostAccess,
} from "../db/schema.js";
import { eq, and, desc, sql } from "drizzle-orm";
import type { Request, Response } from "express";
import { authLogger } from "../../utils/logger.js";
import { SimpleDBOps } from "../../utils/simple-db-ops.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { parseSSHKey } from "../../utils/ssh-key-utils.js";
import { registerCredentialKeyRoutes } from "./credential-key-routes.js";
import { registerCredentialDeployRoutes } from "./credential-deploy-routes.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import {
createCurrentCredentialRepository,
createCurrentHostResolutionRepository,
createCurrentHostRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
const router = express.Router();
@@ -27,6 +24,11 @@ const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
async function getAuditUsername(userId: string): Promise<string> {
const actor = await createCurrentUserRepository().findById(userId);
return actor?.username ?? userId;
}
/**
* @openapi
* /credentials:
@@ -137,7 +139,8 @@ router.post(
.status(400)
.json({ error: "SSH key is required for key authentication" });
}
const plainPassword = password ? password : null;
const plainPassword =
authType === "password" && password ? password : null;
const plainKey = authType === "key" && key ? key : null;
const plainKeyPassword =
authType === "key" && keyPassword ? keyPassword : null;
@@ -181,23 +184,16 @@ router.post(
lastUsed: null,
};
const created = (await SimpleDBOps.insert(
sshCredentials,
"ssh_credentials",
credentialData,
userId,
)) as typeof credentialData & { id: number };
const created =
await createCurrentCredentialRepository().createEncryptedForUser(
userId,
credentialData,
);
const { ipAddress: ccIp, userAgent: ccUa } = getRequestMeta(req);
const { users: usersTableCc } = await import("../db/schema.js");
const ccActor = await db
.select({ username: usersTableCc.username })
.from(usersTableCc)
.where(eq(usersTableCc.id, userId))
.limit(1);
await logAudit({
userId,
username: ccActor[0]?.username ?? userId,
username: await getAuditUsername(userId),
action: "create_credential",
resourceType: "credential",
resourceId: String(created.id),
@@ -265,15 +261,8 @@ router.get(
}
try {
const credentials = await SimpleDBOps.select(
db
.select()
.from(sshCredentials)
.where(eq(sshCredentials.userId, userId))
.orderBy(desc(sshCredentials.updatedAt)),
"ssh_credentials",
userId,
);
const credentials =
await createCurrentCredentialRepository().listDecryptedByUserId(userId);
res.json(credentials.map((cred) => formatCredentialOutput(cred)));
} catch (err) {
@@ -312,22 +301,7 @@ router.get(
}
try {
const result = await db
.select({ folder: sshCredentials.folder })
.from(sshCredentials)
.where(eq(sshCredentials.userId, userId));
const folderCounts: Record<string, number> = {};
result.forEach((r) => {
if (r.folder && r.folder.trim() !== "") {
folderCounts[r.folder] = (folderCounts[r.folder] || 0) + 1;
}
});
const folders = Object.keys(folderCounts).filter(
(folder) => folderCounts[folder] > 0,
);
res.json(folders);
res.json(await createCurrentCredentialRepository().listFolders(userId));
} catch (err) {
authLogger.error("Failed to fetch credential folders", err);
res.status(500).json({ error: "Failed to fetch credential folders" });
@@ -373,25 +347,16 @@ router.get(
}
try {
const credentials = await SimpleDBOps.select(
db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, parseInt(id)),
eq(sshCredentials.userId, userId),
),
),
"ssh_credentials",
userId,
);
const credential =
await createCurrentCredentialRepository().findDecryptedByIdForUser(
userId,
parseInt(id),
);
if (credentials.length === 0) {
if (!credential) {
return res.status(404).json({ error: "Credential not found" });
}
const credential = credentials[0];
const output = formatCredentialOutput(credential);
if (credential.password) {
@@ -468,25 +433,22 @@ router.put(
authLogger.warn("Invalid request for credential update");
return res.status(400).json({ error: "Invalid request" });
}
const credentialId = parseInt(id);
authLogger.info("Updating SSH credential", {
operation: "credential_update",
userId,
credentialId: parseInt(id),
credentialId,
changes: Object.keys(updateData),
});
try {
const existing = await db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, parseInt(id)),
eq(sshCredentials.userId, userId),
),
const existingCredential =
await createCurrentCredentialRepository().findDecryptedByIdForUser(
userId,
credentialId,
);
if (existing.length === 0) {
if (!existingCredential) {
return res.status(404).json({ error: "Credential not found" });
}
@@ -513,17 +475,16 @@ router.put(
if (updateData.password !== undefined) {
updateFields.password = updateData.password || null;
}
const nextAuthType = updateData.authType ?? existing[0].authType;
if (updateData.key !== undefined) {
updateFields.key = updateData.key || null;
if (updateData.key && nextAuthType === "key") {
if (updateData.key && existingCredential.authType === "key") {
const keyInfo = parseSSHKey(updateData.key, updateData.keyPassword);
if (!keyInfo.success) {
authLogger.warn("SSH key parsing failed during update", {
operation: "credential_update",
userId,
credentialId: parseInt(id),
credentialId,
error: keyInfo.error,
});
return res.status(400).json({
@@ -545,72 +506,49 @@ router.put(
}
if (Object.keys(updateFields).length === 0) {
const existing = await SimpleDBOps.select(
db
.select()
.from(sshCredentials)
.where(eq(sshCredentials.id, parseInt(id))),
"ssh_credentials",
userId,
);
return res.json(formatCredentialOutput(existing[0]));
return res.json(formatCredentialOutput(existingCredential));
}
await SimpleDBOps.update(
sshCredentials,
"ssh_credentials",
and(
eq(sshCredentials.id, parseInt(id)),
eq(sshCredentials.userId, userId),
),
const credentialRepository = createCurrentCredentialRepository();
const updated = await credentialRepository.updateEncryptedForUser(
userId,
credentialId,
updateFields,
userId,
);
const updatedCredential =
updated ??
(await credentialRepository.findDecryptedByIdForUser(
userId,
credentialId,
));
const updated = await SimpleDBOps.select(
db
.select()
.from(sshCredentials)
.where(eq(sshCredentials.id, parseInt(id))),
"ssh_credentials",
userId,
);
const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
await sharedCredManager.updateSharedCredentialsForOriginal(
parseInt(id),
const { SharedHostSecretsManager } =
await import("../../utils/shared-host-secrets-manager.js");
await SharedHostSecretsManager.getInstance().resyncHostsForCredential(
credentialId,
userId,
);
authLogger.success("SSH credential updated", {
operation: "credential_update_success",
userId,
credentialId: parseInt(id),
credentialId,
});
const { ipAddress: cuIp, userAgent: cuUa } = getRequestMeta(req);
const { users: usersTableCu } = await import("../db/schema.js");
const cuActor = await db
.select({ username: usersTableCu.username })
.from(usersTableCu)
.where(eq(usersTableCu.id, userId))
.limit(1);
await logAudit({
userId,
username: cuActor[0]?.username ?? userId,
username: await getAuditUsername(userId),
action: "update_credential",
resourceType: "credential",
resourceId: id,
resourceName: existing[0].name,
resourceName: String(existingCredential.name ?? id),
ipAddress: cuIp,
userAgent: cuUa,
success: true,
});
res.json(formatCredentialOutput(updated[0]));
res.json(formatCredentialOutput(updatedCredential ?? existingCredential));
} catch (err) {
authLogger.error("Failed to update credential", err);
res.status(500).json({
@@ -664,96 +602,67 @@ router.delete(
});
try {
const credentialToDelete = await db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, parseInt(id)),
eq(sshCredentials.userId, userId),
),
const credentialId = parseInt(id);
const credentialToDelete =
await createCurrentCredentialRepository().findDecryptedByIdForUser(
userId,
credentialId,
);
if (credentialToDelete.length === 0) {
if (!credentialToDelete) {
return res.status(404).json({ error: "Credential not found" });
}
const hostsUsingCredential = await db
.select()
.from(hosts)
.where(
and(eq(hosts.credentialId, parseInt(id)), eq(hosts.userId, userId)),
const hostsUsingCredential =
await createCurrentHostResolutionRepository().listHostsUsingCredentialForUser(
userId,
credentialId,
);
if (hostsUsingCredential.length > 0) {
await db
.update(hosts)
.set({
await createCurrentHostRepository().updateManyForUser(
userId,
hostsUsingCredential.map((host) => host.id),
{
credentialId: null,
password: null,
key: null,
keyPassword: null,
authType: "password",
})
.where(
and(eq(hosts.credentialId, parseInt(id)), eq(hosts.userId, userId)),
);
for (const host of hostsUsingCredential) {
const revokedShares = await db
.delete(hostAccess)
.where(eq(hostAccess.hostId, host.id))
.returning({ id: hostAccess.id });
if (revokedShares.length > 0) {
authLogger.info(
"Auto-revoked host shares due to credential deletion",
{
operation: "auto_revoke_shares",
hostId: host.id,
credentialId: parseInt(id),
revokedCount: revokedShares.length,
reason: "credential_deleted",
},
);
}
}
},
);
}
const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
await sharedCredManager.deleteSharedCredentialsForOriginal(parseInt(id));
const { SharedHostSecretsManager } =
await import("../../utils/shared-host-secrets-manager.js");
const sharedSecretsManager = SharedHostSecretsManager.getInstance();
await sharedSecretsManager.deleteForCredential(credentialId);
await db
.delete(sshCredentials)
.where(
and(
eq(sshCredentials.id, parseInt(id)),
eq(sshCredentials.userId, userId),
),
);
await createCurrentCredentialRepository().deleteForUser(
userId,
credentialId,
);
// Shares stay in place; re-snapshot so recipients fall back to whatever
// auth the host still has (or lose the stale credential copy).
for (const host of hostsUsingCredential) {
await sharedSecretsManager.resyncHost(host.id);
}
authLogger.success("SSH credential deleted", {
operation: "credential_delete_success",
userId,
credentialId: parseInt(id),
credentialId,
});
const { ipAddress: cdIp, userAgent: cdUa } = getRequestMeta(req);
const { users: usersTableCd } = await import("../db/schema.js");
const cdActor = await db
.select({ username: usersTableCd.username })
.from(usersTableCd)
.where(eq(usersTableCd.id, userId))
.limit(1);
await logAudit({
userId,
username: cdActor[0]?.username ?? userId,
username: await getAuditUsername(userId),
action: "delete_credential",
resourceType: "credential",
resourceId: id,
resourceName: credentialToDelete[0].name,
resourceName: String(credentialToDelete.name ?? id),
ipAddress: cdIp,
userAgent: cdUa,
success: true,
@@ -817,29 +726,20 @@ router.post(
}
try {
const credentials = await SimpleDBOps.select(
db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, parseInt(credentialId)),
eq(sshCredentials.userId, userId),
),
),
"ssh_credentials",
userId,
);
const credential =
await createCurrentCredentialRepository().findDecryptedByIdForUser(
userId,
parseInt(credentialId),
);
if (credentials.length === 0) {
if (!credential) {
return res.status(404).json({ error: "Credential not found" });
}
const credential = credentials[0];
await db
.update(hosts)
.set({
await createCurrentHostRepository().updateForUser(
userId,
parseInt(hostId),
{
credentialId: parseInt(credentialId),
username: (credential.username as string) || "",
authType: credential.authType as string,
@@ -848,24 +748,14 @@ router.post(
keyPassword: null,
keyType: null,
updatedAt: new Date().toISOString(),
})
.where(and(eq(hosts.id, parseInt(hostId)), eq(hosts.userId, userId)));
},
);
await db.insert(sshCredentialUsage).values({
credentialId: parseInt(credentialId),
hostId: parseInt(hostId),
await createCurrentCredentialRepository().recordUsage(
userId,
});
await db
.update(sshCredentials)
.set({
usageCount: sql`${sshCredentials.usageCount}
+ 1`,
lastUsed: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
.where(eq(sshCredentials.id, parseInt(credentialId)));
parseInt(credentialId),
parseInt(hostId),
);
res.json({ message: "Credential applied to host successfully" });
} catch (err) {
authLogger.error("Failed to apply credential to host", err);
@@ -916,14 +806,10 @@ router.get(
}
try {
const hostsUsingCredential = await db
.select()
.from(hosts)
.where(
and(
eq(hosts.credentialId, parseInt(credentialId)),
eq(hosts.userId, userId),
),
const hostsUsingCredential =
await createCurrentHostResolutionRepository().listHostsUsingCredentialForUser(
userId,
parseInt(credentialId),
);
res.json(hostsUsingCredential.map((host) => formatSSHHostOutput(host)));
@@ -1044,15 +930,11 @@ router.put(
}
try {
await db
.update(sshCredentials)
.set({ folder: newName })
.where(
and(
eq(sshCredentials.userId, userId),
eq(sshCredentials.folder, oldName),
),
);
await createCurrentCredentialRepository().renameFolder(
userId,
oldName,
newName,
);
res.json({ success: true, message: "Folder renamed successfully" });
} catch (error) {
@@ -1,18 +1,22 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, Response } from "express";
import { and, asc, eq } from "drizzle-orm";
import { dashboardLogger } from "../../utils/logger.js";
import { db } from "../db/index.js";
import { dashboardServiceLinks } from "../db/schema.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { isNonEmptyString } from "./host-normalizers.js";
import {
isValidServiceLinkUrl,
normalizeServiceLinkUrl,
} from "./service-link-url.js";
import express from "express";
import { createCurrentDashboardServiceLinkRepository } from "../repositories/factory.js";
export const dashboardServiceLinksRouter = express.Router();
function isValidUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
/**
* @openapi
* /service-links:
@@ -30,11 +34,8 @@ export const dashboardServiceLinksRouter = express.Router();
dashboardServiceLinksRouter.get("/", async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const links = await db
.select()
.from(dashboardServiceLinks)
.where(eq(dashboardServiceLinks.userId, userId))
.orderBy(asc(dashboardServiceLinks.order), asc(dashboardServiceLinks.id));
const links =
await createCurrentDashboardServiceLinkRepository().listByUserId(userId);
res.json(links);
} catch (err) {
dashboardLogger.error("Failed to fetch service links", err);
@@ -79,34 +80,23 @@ dashboardServiceLinksRouter.post("/", async (req: Request, res: Response) => {
if (!isNonEmptyString(label) || !isNonEmptyString(url)) {
return res.status(400).json({ error: "label and url are required" });
}
const normalizedUrl = normalizeServiceLinkUrl(url);
if (!isValidServiceLinkUrl(normalizedUrl)) {
if (!isValidUrl(url)) {
return res
.status(400)
.json({ error: "url must be a valid http or https URL" });
}
try {
const existing = await db
.select({ order: dashboardServiceLinks.order })
.from(dashboardServiceLinks)
.where(eq(dashboardServiceLinks.userId, userId))
.orderBy(asc(dashboardServiceLinks.order));
const nextOrder =
existing.length > 0 ? existing[existing.length - 1].order + 1 : 0;
const [created] = await db
.insert(dashboardServiceLinks)
.values({
const created =
await createCurrentDashboardServiceLinkRepository().createForUser(
userId,
label: label.trim(),
url: normalizedUrl,
order: nextOrder,
createdAt: new Date().toISOString(),
})
.returning();
{
label: label.trim(),
url: url.trim(),
},
);
DatabaseSaveTrigger.triggerSave("dashboard_service_link_created");
res.status(201).json(created);
} catch (err) {
dashboardLogger.error("Failed to create service link", err);
@@ -152,29 +142,22 @@ dashboardServiceLinksRouter.delete(
}
try {
const existing = await db
.select()
.from(dashboardServiceLinks)
.where(
and(
eq(dashboardServiceLinks.id, id),
eq(dashboardServiceLinks.userId, userId),
),
const existing =
await createCurrentDashboardServiceLinkRepository().findByIdForUser(
userId,
id,
);
if (existing.length === 0) {
if (!existing) {
return res.status(404).json({ error: "Not found" });
}
await db
.delete(dashboardServiceLinks)
.where(
and(
eq(dashboardServiceLinks.id, id),
eq(dashboardServiceLinks.userId, userId),
),
);
await createCurrentDashboardServiceLinkRepository().deleteForUser(
userId,
id,
);
DatabaseSaveTrigger.triggerSave("dashboard_service_link_deleted");
res.json({ message: "Service link deleted" });
} catch (err) {
dashboardLogger.error("Failed to delete service link", err);
@@ -229,49 +212,39 @@ dashboardServiceLinksRouter.put("/:id", async (req: Request, res: Response) => {
if (isNaN(id)) {
return res.status(400).json({ error: "Invalid id" });
}
const normalizedUrl = isNonEmptyString(url)
? normalizeServiceLinkUrl(url)
: undefined;
if (normalizedUrl !== undefined && !isValidServiceLinkUrl(normalizedUrl)) {
if (url !== undefined && !isValidUrl(url)) {
return res
.status(400)
.json({ error: "url must be a valid http or https URL" });
}
try {
const existing = await db
.select()
.from(dashboardServiceLinks)
.where(
and(
eq(dashboardServiceLinks.id, id),
eq(dashboardServiceLinks.userId, userId),
),
const existing =
await createCurrentDashboardServiceLinkRepository().findByIdForUser(
userId,
id,
);
if (existing.length === 0) {
if (!existing) {
return res.status(404).json({ error: "Not found" });
}
const updates: Partial<{ label: string; url: string }> = {};
if (isNonEmptyString(label)) updates.label = label.trim();
if (normalizedUrl !== undefined) updates.url = normalizedUrl;
if (isNonEmptyString(url)) updates.url = url.trim();
if (Object.keys(updates).length === 0) {
return res.status(400).json({ error: "Nothing to update" });
}
const [updated] = await db
.update(dashboardServiceLinks)
.set(updates)
.where(
and(
eq(dashboardServiceLinks.id, id),
eq(dashboardServiceLinks.userId, userId),
),
)
.returning();
const updated =
await createCurrentDashboardServiceLinkRepository().updateForUser(
userId,
id,
updates,
);
DatabaseSaveTrigger.triggerSave("dashboard_service_link_updated");
res.json(updated);
} catch (err) {
dashboardLogger.error("Failed to update service link", err);
+77 -67
View File
@@ -1,90 +1,100 @@
import { eq } from "drizzle-orm";
import { authLogger } from "../../utils/logger.js";
import { db } from "../db/index.js";
import {
auditLogs,
commandHistory,
dismissedAlerts,
fileManagerPinned,
fileManagerRecent,
fileManagerShortcuts,
hostAccess,
hosts,
networkTopology,
opksshTokens,
recentActivity,
sessionRecordings,
sessions,
sharedCredentials,
snippetFolders,
snippets,
sshCredentialUsage,
sshCredentials,
sshFolders,
transferRecent,
userOpenTabs,
userPreferences,
userRoles,
users,
} from "../db/schema.js";
createCurrentAlertRepository,
createCurrentApiKeyRepository,
createCurrentAuditLogRepository,
createCurrentC2sTunnelPresetRepository,
createCurrentCommandHistoryRepository,
createCurrentCredentialRepository,
createCurrentDashboardServiceLinkRepository,
createCurrentDismissedAlertRepository,
createCurrentFileManagerBookmarkRepository,
createCurrentHomepageItemRepository,
createCurrentHomepageLayoutRepository,
createCurrentHostHealthRepository,
createCurrentHostFolderRepository,
createCurrentHostMetricsPreferenceRepository,
createCurrentHostRepository,
createCurrentNetworkTopologyRepository,
createCurrentOpksshTokenRepository,
createCurrentOpenTabRepository,
createCurrentRecentActivityRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
createCurrentSessionRepository,
createCurrentSessionRecordingRepository,
createCurrentSettingsRepository,
createCurrentSharedHostSecretsRepository,
createCurrentSnippetRepository,
createCurrentSshCredentialUsageRepository,
createCurrentTermixIdentityCaRepository,
createCurrentTermixIdentityRepository,
createCurrentTmuxSessionTagRepository,
createCurrentTrustedDeviceRepository,
createCurrentUserPreferenceRepository,
createCurrentUserRepository,
createCurrentTransferRecentRepository,
createCurrentVaultProfileRepository,
createCurrentVaultTokenRepository,
} from "../repositories/factory.js";
export async function deleteUserAndRelatedData(userId: string): Promise<void> {
try {
await db
.delete(sharedCredentials)
.where(eq(sharedCredentials.targetUserId, userId));
await createCurrentSharedHostSecretsRepository().deleteByTargetUserId(
userId,
);
await db
.delete(sessionRecordings)
.where(eq(sessionRecordings.userId, userId));
await createCurrentSessionRecordingRepository().deleteByUserId(userId);
await db.delete(hostAccess).where(eq(hostAccess.userId, userId));
await db.delete(hostAccess).where(eq(hostAccess.grantedBy, userId));
await createCurrentRbacAccessRepository().deleteHostAccessForUserReferences(
userId,
);
await db.delete(sessions).where(eq(sessions.userId, userId));
await createCurrentSessionRepository().revokeAllForUser(userId);
await createCurrentApiKeyRepository().deleteByUserId(userId);
await createCurrentTrustedDeviceRepository().deleteByUserId(userId);
await db.delete(userRoles).where(eq(userRoles.userId, userId));
await db.delete(auditLogs).where(eq(auditLogs.userId, userId));
await createCurrentRoleRepository().removeAllRolesFromUser(userId);
await createCurrentAlertRepository().deleteByUserId(userId);
await createCurrentAuditLogRepository().deleteByUserId(userId);
await db
.delete(sshCredentialUsage)
.where(eq(sshCredentialUsage.userId, userId));
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
await db
.delete(fileManagerRecent)
.where(eq(fileManagerRecent.userId, userId));
await db
.delete(fileManagerPinned)
.where(eq(fileManagerPinned.userId, userId));
await db
.delete(fileManagerShortcuts)
.where(eq(fileManagerShortcuts.userId, userId));
await createCurrentFileManagerBookmarkRepository().deleteByUserId(userId);
await db.delete(transferRecent).where(eq(transferRecent.userId, userId));
await createCurrentTransferRecentRepository().deleteByUserId(userId);
await db.delete(recentActivity).where(eq(recentActivity.userId, userId));
await db.delete(dismissedAlerts).where(eq(dismissedAlerts.userId, userId));
await createCurrentRecentActivityRepository().deleteByUserId(userId);
await createCurrentDismissedAlertRepository().deleteByUserId(userId);
await db.delete(snippets).where(eq(snippets.userId, userId));
await db.delete(snippetFolders).where(eq(snippetFolders.userId, userId));
await createCurrentSnippetRepository().deleteByUserId(userId);
await db.delete(sshFolders).where(eq(sshFolders.userId, userId));
await createCurrentHostFolderRepository().deleteByUserId(userId);
await db.delete(commandHistory).where(eq(commandHistory.userId, userId));
await createCurrentCommandHistoryRepository().deleteByUserId(userId);
await db.delete(hosts).where(eq(hosts.userId, userId));
await db.delete(sshCredentials).where(eq(sshCredentials.userId, userId));
await createCurrentHostHealthRepository().deleteByUserId(userId);
await createCurrentHostMetricsPreferenceRepository().deleteByUserId(userId);
await createCurrentHostRepository().deleteByUserId(userId);
await createCurrentCredentialRepository().deleteByUserId(userId);
await db.delete(networkTopology).where(eq(networkTopology.userId, userId));
await db.delete(opksshTokens).where(eq(opksshTokens.userId, userId));
await db.delete(userOpenTabs).where(eq(userOpenTabs.userId, userId));
await db.delete(userPreferences).where(eq(userPreferences.userId, userId));
await createCurrentNetworkTopologyRepository().deleteByUserId(userId);
await createCurrentDashboardServiceLinkRepository().deleteByUserId(userId);
await createCurrentHomepageItemRepository().deleteByUserId(userId);
await createCurrentHomepageLayoutRepository().deleteByUserId(userId);
await createCurrentC2sTunnelPresetRepository().deleteByUserId(userId);
await createCurrentOpksshTokenRepository().deleteByUserId(userId);
await createCurrentVaultTokenRepository().deleteByUserId(userId);
await createCurrentVaultProfileRepository().deleteByUserId(userId);
await createCurrentTermixIdentityCaRepository().deleteByUserId(userId);
await createCurrentTermixIdentityRepository().deleteByUserId(userId);
await createCurrentTmuxSessionTagRepository().deleteByUserId(userId);
await createCurrentOpenTabRepository().deleteByUserId(userId);
await createCurrentUserPreferenceRepository().deleteByUserId(userId);
db.$client
.prepare("DELETE FROM settings WHERE key LIKE ?")
.run(`user_%_${userId}`);
await createCurrentSettingsRepository().deleteLike(`user_%_${userId}`);
await db.delete(users).where(eq(users.id, userId));
await createCurrentUserRepository().delete(userId);
authLogger.success("User and all related data deleted successfully", {
operation: "delete_user_and_related_data_complete",
@@ -0,0 +1,12 @@
const DONATION_MODAL_DELAY_MS = 30 * 24 * 60 * 60 * 1000;
export function shouldShowDonationModal(
registeredAt: string,
donationModalDismissed: boolean,
now: number = Date.now(),
): boolean {
if (donationModalDismissed) return false;
const registeredAtMs = Date.parse(registeredAt);
if (Number.isNaN(registeredAtMs)) return false;
return now - registeredAtMs >= DONATION_MODAL_DELAY_MS;
}
@@ -1,10 +1,7 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, Response } from "express";
import { and, asc, eq } from "drizzle-orm";
import { homepageLogger } from "../../utils/logger.js";
import { db } from "../db/index.js";
import { homepageItems } from "../db/schema.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { createCurrentHomepageItemRepository } from "../repositories/factory.js";
import express from "express";
export const homepageItemsRouter = express.Router();
@@ -26,11 +23,8 @@ export const homepageItemsRouter = express.Router();
homepageItemsRouter.get("/", async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const items = await db
.select()
.from(homepageItems)
.where(eq(homepageItems.userId, userId))
.orderBy(asc(homepageItems.id));
const items =
await createCurrentHomepageItemRepository().listByUserId(userId);
res.json(items);
} catch (err) {
homepageLogger.error("Failed to fetch homepage items", err);
@@ -78,18 +72,14 @@ homepageItemsRouter.post("/", async (req: Request, res: Response) => {
}
try {
const [created] = await db
.insert(homepageItems)
.values({
userId,
const created = await createCurrentHomepageItemRepository().createForUser(
userId,
{
typeId,
title: title ?? null,
config: config ? JSON.stringify(config) : "{}",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
.returning();
DatabaseSaveTrigger.triggerSave("homepage_item_created");
},
);
res.status(201).json(created);
} catch (err) {
homepageLogger.error("Failed to create homepage item", err);
@@ -140,33 +130,18 @@ homepageItemsRouter.put("/:id", async (req: Request, res: Response) => {
const { title, config } = req.body;
try {
const existing = await db
.select()
.from(homepageItems)
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)));
if (existing.length === 0) {
const itemRepository = createCurrentHomepageItemRepository();
const existing = await itemRepository.findByIdForUser(userId, id);
if (!existing) {
return res.status(404).json({ error: "Not found" });
}
const updates: Partial<{
title: string | null;
config: string;
updatedAt: string;
}> = {
updatedAt: new Date().toISOString(),
};
const updates: Parameters<typeof itemRepository.updateForUser>[2] = {};
if (title !== undefined) updates.title = title;
if (config !== undefined) updates.config = JSON.stringify(config);
const [updated] = await db
.update(homepageItems)
.set(updates)
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)))
.returning();
DatabaseSaveTrigger.triggerSave("homepage_item_updated");
res.json(updated);
const updated = await itemRepository.updateForUser(userId, id, updates);
res.json(updated ?? existing);
} catch (err) {
homepageLogger.error("Failed to update homepage item", err);
res.status(500).json({ error: "Failed to update homepage item" });
@@ -203,20 +178,13 @@ homepageItemsRouter.delete("/:id", async (req: Request, res: Response) => {
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
try {
const existing = await db
.select()
.from(homepageItems)
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)));
if (existing.length === 0) {
const itemRepository = createCurrentHomepageItemRepository();
const existing = await itemRepository.findByIdForUser(userId, id);
if (!existing) {
return res.status(404).json({ error: "Not found" });
}
await db
.delete(homepageItems)
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)));
DatabaseSaveTrigger.triggerSave("homepage_item_deleted");
await itemRepository.deleteForUser(userId, id);
res.json({ message: "Homepage item deleted" });
} catch (err) {
homepageLogger.error("Failed to delete homepage item", err);
@@ -1,11 +1,8 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, Response } from "express";
import { eq } from "drizzle-orm";
import { homepageLogger } from "../../utils/logger.js";
import { db } from "../db/index.js";
import { homepageLayouts } from "../db/schema.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import express from "express";
import { createCurrentHomepageLayoutRepository } from "../repositories/factory.js";
export const homepageLayoutRouter = express.Router();
@@ -26,16 +23,13 @@ export const homepageLayoutRouter = express.Router();
homepageLayoutRouter.get("/", async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const rows = await db
.select()
.from(homepageLayouts)
.where(eq(homepageLayouts.userId, userId));
const row =
await createCurrentHomepageLayoutRepository().findByUserId(userId);
if (rows.length === 0) {
if (!row) {
return res.json(null);
}
const row = rows[0];
const parsed = JSON.parse(row.layout || "{}");
res.json({ ...row, layout: parsed });
} catch (err) {
@@ -76,32 +70,15 @@ homepageLayoutRouter.put("/", async (req: Request, res: Response) => {
const layoutData = req.body;
try {
const existing = await db
.select({ id: homepageLayouts.id })
.from(homepageLayouts)
.where(eq(homepageLayouts.userId, userId));
const layoutJson = JSON.stringify(layoutData);
const now = new Date().toISOString();
if (existing.length === 0) {
const [created] = await db
.insert(homepageLayouts)
.values({ userId, layout: layoutJson, updatedAt: now })
.returning();
const parsed = JSON.parse(created.layout);
DatabaseSaveTrigger.triggerSave("homepage_layout_saved");
return res.json({ ...created, layout: parsed });
}
const [updated] = await db
.update(homepageLayouts)
.set({ layout: layoutJson, updatedAt: now })
.where(eq(homepageLayouts.userId, userId))
.returning();
const updated = await createCurrentHomepageLayoutRepository().upsertForUser(
userId,
layoutJson,
now,
);
const parsed = JSON.parse(updated.layout);
DatabaseSaveTrigger.triggerSave("homepage_layout_saved");
res.json({ ...updated, layout: parsed });
} catch (err) {
homepageLogger.error("Failed to save homepage layout", err);
@@ -2,6 +2,8 @@ import type { Request, Response } from "express";
import express from "express";
import https from "https";
import http from "http";
import { lookup } from "dns/promises";
import { BlockList, isIP } from "net";
import { homepageLogger } from "../../utils/logger.js";
export const homepageProxyRouter = express.Router();
@@ -15,22 +17,95 @@ const proxyCache = new Map<string, ProxyCacheEntry>();
const CACHE_SIZE = 50;
const FETCH_TIMEOUT_MS = 8000;
function fetchJson(url: string): Promise<unknown> {
const blockedAddresses = new BlockList();
for (const [network, prefix] of [
["0.0.0.0", 8],
["10.0.0.0", 8],
["100.64.0.0", 10],
["127.0.0.0", 8],
["169.254.0.0", 16],
["172.16.0.0", 12],
["192.168.0.0", 16],
["198.18.0.0", 15],
["224.0.0.0", 4],
["240.0.0.0", 4],
] as const) {
blockedAddresses.addSubnet(network, prefix, "ipv4");
}
for (const [network, prefix] of [
["::", 128],
["::1", 128],
["::ffff:0:0", 96],
["fc00::", 7],
["fe80::", 10],
["ff00::", 8],
] as const) {
blockedAddresses.addSubnet(network, prefix, "ipv6");
}
function isBlockedAddress(address: string): boolean {
const family = isIP(address);
return (
family === 0 ||
blockedAddresses.check(address, family === 4 ? "ipv4" : "ipv6")
);
}
async function resolvePublicUrl(rawUrl: string): Promise<{
url: URL;
address: string;
}> {
const url = new URL(rawUrl);
if (
!["http:", "https:"].includes(url.protocol) ||
url.username ||
url.password
) {
throw new Error("Invalid URL");
}
const hostname = url.hostname.replace(/^\[|\]$/g, "");
const addresses = isIP(hostname)
? [{ address: hostname }]
: await lookup(hostname, { all: true, verbatim: true });
if (
addresses.length === 0 ||
addresses.some(({ address }) => isBlockedAddress(address))
) {
throw new Error("Private destinations are not allowed");
}
return { url, address: addresses[0].address };
}
async function fetchJson(rawUrl: string): Promise<unknown> {
const { url, address } = await resolvePublicUrl(rawUrl);
return new Promise((resolve, reject) => {
const mod = url.startsWith("https") ? https : http;
const req = mod.get(url, { timeout: FETCH_TIMEOUT_MS }, (res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => chunks.push(chunk));
res.on("end", () => {
try {
const text = Buffer.concat(chunks).toString("utf-8");
resolve(JSON.parse(text));
} catch {
reject(new Error("Response is not valid JSON"));
}
});
res.on("error", reject);
});
const mod = url.protocol === "https:" ? https : http;
const req = mod.get(
{
protocol: url.protocol,
hostname: address,
port: url.port || undefined,
path: `${url.pathname}${url.search}`,
headers: { Host: url.host },
servername: url.protocol === "https:" ? url.hostname : undefined,
timeout: FETCH_TIMEOUT_MS,
},
(res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => chunks.push(chunk));
res.on("end", () => {
try {
const text = Buffer.concat(chunks).toString("utf-8");
resolve(JSON.parse(text));
} catch {
reject(new Error("Response is not valid JSON"));
}
});
res.on("error", reject);
},
);
req.on("error", reject);
req.on("timeout", () => {
req.destroy();
@@ -1,10 +1,8 @@
import type { Request, RequestHandler, Response, Router } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { and, eq, isNotNull, or } from "drizzle-orm";
import { DataCrypto } from "../../utils/data-crypto.js";
import { sshLogger } from "../../utils/logger.js";
import { db, DatabaseSaveTrigger } from "../db/index.js";
import { hosts } from "../db/schema.js";
import { createCurrentHostRepository } from "../repositories/factory.js";
type HostAutostartRoutesDeps = {
authenticateJWT: RequestHandler;
@@ -79,12 +77,13 @@ export function registerHostAutostartRoutes(
});
}
const sshConfig = await db
.select()
.from(hosts)
.where(and(eq(hosts.id, sshConfigId), eq(hosts.userId, userId)));
const hostRepository = createCurrentHostRepository();
const config = await hostRepository.findByIdForUser(
userId,
sshConfigId,
);
if (sshConfig.length === 0) {
if (!config) {
sshLogger.warn("SSH config not found for autostart enable", {
operation: "autostart_enable_failed",
userId,
@@ -96,8 +95,6 @@ export function registerHostAutostartRoutes(
});
}
const config = sshConfig[0];
const decryptedConfig = DataCrypto.decryptRecord(
"ssh_data",
config,
@@ -109,6 +106,7 @@ export function registerHostAutostartRoutes(
if (config.tunnelConnections) {
try {
const tunnelConnections = JSON.parse(config.tunnelConnections);
const endpointHosts = await hostRepository.listByUserId(userId);
const resolvedConnections = await Promise.all(
tunnelConnections.map(async (tunnel: Record<string, unknown>) => {
@@ -118,11 +116,6 @@ export function registerHostAutostartRoutes(
!tunnel.endpointPassword &&
!tunnel.endpointKey
) {
const endpointHosts = await db
.select()
.from(hosts)
.where(eq(hosts.userId, userId));
const endpointHost = endpointHosts.find(
(h) =>
h.name === tunnel.endpointHost ||
@@ -160,25 +153,12 @@ export function registerHostAutostartRoutes(
}
}
await db
.update(hosts)
.set({
autostartPassword: decryptedConfig.password || null,
autostartKey: decryptedConfig.key || null,
autostartKeyPassword: decryptedConfig.keyPassword || null,
tunnelConnections: updatedTunnelConnections,
})
.where(eq(hosts.id, sshConfigId));
try {
await DatabaseSaveTrigger.triggerSave();
} catch (saveError) {
sshLogger.warn("Database save failed after autostart", {
operation: "autostart_db_save_failed",
error:
saveError instanceof Error ? saveError.message : "Unknown error",
});
}
await hostRepository.updateForUser(userId, sshConfigId, {
autostartPassword: decryptedConfig.password || null,
autostartKey: decryptedConfig.key || null,
autostartKeyPassword: decryptedConfig.keyPassword || null,
tunnelConnections: updatedTunnelConnections,
});
res.json({
message: "AutoStart enabled successfully",
@@ -240,14 +220,11 @@ export function registerHostAutostartRoutes(
}
try {
await db
.update(hosts)
.set({
autostartPassword: null,
autostartKey: null,
autostartKeyPassword: null,
})
.where(and(eq(hosts.id, sshConfigId), eq(hosts.userId, userId)));
await createCurrentHostRepository().updateForUser(userId, sshConfigId, {
autostartPassword: null,
autostartKey: null,
autostartKeyPassword: null,
});
res.json({
message: "AutoStart disabled successfully",
@@ -285,18 +262,9 @@ export function registerHostAutostartRoutes(
const userId = (req as AuthenticatedRequest).userId;
try {
const autostartConfigs = await db
.select()
.from(hosts)
.where(
and(
eq(hosts.userId, userId),
or(
isNotNull(hosts.autostartPassword),
isNotNull(hosts.autostartKey),
),
),
);
const autostartConfigs = (
await createCurrentHostRepository().listByUserId(userId)
).filter((config) => config.autostartPassword || config.autostartKey);
const statusList = autostartConfigs.map((config) => ({
sshConfigId: config.id,
@@ -1,104 +0,0 @@
import { describe, it, expect } from "vitest";
import { parseSSHConfig } from "./host-bulk-routes.js";
describe("parseSSHConfig", () => {
it("parses a basic Host block", () => {
const config = `
Host myserver
HostName 192.168.1.10
User alice
Port 2222
`;
const result = parseSSHConfig(config);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
name: "myserver",
hostname: "192.168.1.10",
user: "alice",
port: 2222,
});
});
it("parses multiple Host blocks", () => {
const config = `
Host web
HostName web.example.com
User deploy
Host db
HostName db.example.com
User postgres
Port 5432
`;
const result = parseSSHConfig(config);
expect(result).toHaveLength(2);
expect(result[0].name).toBe("web");
expect(result[1].name).toBe("db");
expect(result[1].port).toBe(5432);
});
it("ignores comment lines", () => {
const config = `
# This is a comment
Host server
# Another comment
HostName 10.0.0.1
User root
`;
const result = parseSSHConfig(config);
expect(result).toHaveLength(1);
expect(result[0].hostname).toBe("10.0.0.1");
});
it("skips wildcard Host entries", () => {
const config = `
Host *
ServerAliveInterval 60
Host prod
HostName prod.example.com
User ubuntu
`;
const result = parseSSHConfig(config);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("prod");
});
it("captures IdentityFile and ProxyJump", () => {
const config = `
Host bastion
HostName bastion.example.com
User ec2-user
IdentityFile ~/.ssh/id_rsa
ProxyJump jumphost.example.com
`;
const result = parseSSHConfig(config);
expect(result).toHaveLength(1);
expect(result[0].identityFile).toBe("~/.ssh/id_rsa");
expect(result[0].proxyJump).toBe("jumphost.example.com");
});
it("skips Host blocks without a HostName", () => {
const config = `
Host alias-only
User foo
`;
const result = parseSSHConfig(config);
expect(result).toHaveLength(0);
});
it("defaults port to undefined when not specified", () => {
const config = `
Host server
HostName 1.2.3.4
User root
`;
const result = parseSSHConfig(config);
expect(result[0].port).toBeUndefined();
});
it("returns empty array for empty input", () => {
expect(parseSSHConfig("")).toHaveLength(0);
expect(parseSSHConfig(" \n\n ")).toHaveLength(0);
});
});
+56 -83
View File
@@ -1,10 +1,11 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import { and, eq, inArray } from "drizzle-orm";
import { sshLogger } from "../../utils/logger.js";
import { SimpleDBOps } from "../../utils/simple-db-ops.js";
import { db, DatabaseSaveTrigger } from "../db/index.js";
import { hosts, sshCredentials } from "../db/schema.js";
import {
createCurrentCredentialRepository,
createCurrentHostRepository,
createCurrentHostResolutionRepository,
} from "../repositories/factory.js";
import {
isNonEmptyString,
isValidPort,
@@ -190,15 +191,11 @@ export function registerHostBulkRoutes(
}
try {
const ownedHosts = await db
.select({
id: hosts.id,
statsConfig: hosts.statsConfig,
credentialId: hosts.credentialId,
proxmoxConfig: hosts.proxmoxConfig,
})
.from(hosts)
.where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId)));
const hostRepository = createCurrentHostRepository();
const ownedHosts = await hostRepository.listBulkUpdateState(
userId,
hostIds,
);
const ownedIds = ownedHosts.map((h) => h.id);
const unauthorizedIds = hostIds.filter(
@@ -236,10 +233,11 @@ export function registerHostBulkRoutes(
simpleUpdates.enableProxmox = false;
if (Object.keys(simpleUpdates).length > 0) {
await db
.update(hosts)
.set(simpleUpdates)
.where(and(inArray(hosts.id, ownedIds), eq(hosts.userId, userId)));
await hostRepository.updateManyForUser(
userId,
ownedIds,
simpleUpdates,
);
}
if (updates.statsConfig && typeof updates.statsConfig === "object") {
@@ -249,10 +247,9 @@ export function registerHostBulkRoutes(
? JSON.parse(host.statsConfig as string)
: {};
const merged = { ...existing, ...updates.statsConfig };
await db
.update(hosts)
.set({ statsConfig: JSON.stringify(merged) })
.where(and(eq(hosts.id, host.id), eq(hosts.userId, userId)));
await hostRepository.updateForUser(userId, host.id, {
statsConfig: JSON.stringify(merged),
});
} catch {
errors.push(`Failed to update statsConfig for host ${host.id}`);
}
@@ -276,22 +273,20 @@ export function registerHostBulkRoutes(
dockerPatterns: existing.dockerPatterns ?? "docker",
preferredPrefixes:
existing.preferredPrefixes ?? "10., 192.168.",
autoSyncEnabled: existing.autoSyncEnabled ?? false,
syncIntervalMinutes: existing.syncIntervalMinutes ?? 15,
markMissingGuests: existing.markMissingGuests ?? true,
};
await db
.update(hosts)
.set({
enableProxmox: true,
proxmoxConfig: JSON.stringify(merged),
})
.where(and(eq(hosts.id, host.id), eq(hosts.userId, userId)));
await hostRepository.updateForUser(userId, host.id, {
enableProxmox: true,
proxmoxConfig: JSON.stringify(merged),
});
} catch {
errors.push(`Failed to enable Proxmox for host ${host.id}`);
}
}
}
DatabaseSaveTrigger.triggerSave("bulk_update");
return res.json({
updated: ownedIds.length,
failed: unauthorizedIds.length,
@@ -342,16 +337,9 @@ export function registerHostBulkRoutes(
};
try {
const existingCredentials = await SimpleDBOps.select<
Record<string, unknown>
>(
db
.select()
.from(sshCredentials)
.where(eq(sshCredentials.userId, userId)),
"ssh_credentials",
userId,
);
const credentialRepository = createCurrentCredentialRepository();
const existingCredentials =
await credentialRepository.listDecryptedByUserId(userId);
for (const credential of existingCredentials) {
addCredentialAlias(credential.name, credential.id as number);
@@ -370,9 +358,8 @@ export function registerHostBulkRoutes(
}
const now = new Date().toISOString();
const created = await SimpleDBOps.insert(
sshCredentials,
"ssh_credentials",
const created = await credentialRepository.createEncryptedForUser(
userId,
{
userId,
name,
@@ -395,7 +382,6 @@ export function registerHostBulkRoutes(
createdAt: now,
updatedAt: now,
},
userId,
);
const createdCredential = created as Record<string, unknown>;
@@ -410,13 +396,13 @@ export function registerHostBulkRoutes(
}
let existingHostMap: Map<string, { id: number }> | undefined;
const hostRepository = createCurrentHostRepository();
if (overwrite) {
try {
const allHosts = await SimpleDBOps.select<Record<string, unknown>>(
db.select().from(hosts).where(eq(hosts.userId, userId)),
"ssh_data",
userId,
);
const allHosts =
await createCurrentHostResolutionRepository().findHostsByUserId(
userId,
);
existingHostMap = new Map();
for (const h of allHosts) {
const key = `${h.ip}:${h.port}:${h.username}`;
@@ -524,23 +510,14 @@ export function registerHostBulkRoutes(
hostData.authType === "credential" &&
hostData.credentialId
) {
const cred = await db
.select({ id: sshCredentials.id })
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, hostData.credentialId),
eq(sshCredentials.userId, userId),
),
)
.limit(1);
const credentialRepository = createCurrentCredentialRepository();
const cred = await credentialRepository.findByIdForUser(
userId,
hostData.credentialId,
);
if (cred.length === 0) {
const fallback = await db
.select({ id: sshCredentials.id })
.from(sshCredentials)
.where(eq(sshCredentials.userId, userId))
.limit(1);
if (!cred) {
const fallback = await credentialRepository.listByUserId(userId);
if (fallback.length > 0) {
hostData.credentialId = fallback[0].id;
@@ -675,17 +652,15 @@ export function registerHostBulkRoutes(
const existing = existingHostMap?.get(lookupKey);
if (existing) {
await SimpleDBOps.update(
hosts,
"ssh_data",
eq(hosts.id, existing.id),
sshDataObj,
await hostRepository.updateEncryptedForUser(
userId,
existing.id,
sshDataObj,
);
results.updated++;
} else {
sshDataObj.createdAt = new Date().toISOString();
await SimpleDBOps.insert(hosts, "ssh_data", sshDataObj, userId);
await hostRepository.createEncryptedForUser(userId, sshDataObj);
results.success++;
}
} catch (error) {
@@ -751,7 +726,7 @@ export function registerHostBulkRoutes(
let parsed: SSHConfigHost[];
try {
parsed = parseSSHConfig(content);
} catch (err) {
} catch {
return res
.status(400)
.json({ error: "Failed to parse SSH config file" });
@@ -793,13 +768,13 @@ export function registerHostBulkRoutes(
};
let existingHostMap: Map<string, { id: number }> | undefined;
const hostRepository = createCurrentHostRepository();
if (overwrite) {
try {
const allHosts = await SimpleDBOps.select<Record<string, unknown>>(
db.select().from(hosts).where(eq(hosts.userId, userId)),
"ssh_data",
userId,
);
const allHosts =
await createCurrentHostResolutionRepository().findHostsByUserId(
userId,
);
existingHostMap = new Map();
for (const h of allHosts) {
const key = `${h.ip}:${h.port}:${h.username}`;
@@ -883,17 +858,15 @@ export function registerHostBulkRoutes(
const existing = existingHostMap?.get(lookupKey);
if (existing) {
await SimpleDBOps.update(
hosts,
"ssh_data",
eq(hosts.id, existing.id),
sshDataObj,
await hostRepository.updateEncryptedForUser(
userId,
existing.id,
sshDataObj,
);
results.updated++;
} else {
sshDataObj.createdAt = new Date().toISOString();
await SimpleDBOps.insert(hosts, "ssh_data", sshDataObj, userId);
await hostRepository.createEncryptedForUser(userId, sshDataObj);
results.success++;
}
} catch (error) {
@@ -1,9 +1,7 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import { and, desc, eq } from "drizzle-orm";
import { sshLogger } from "../../utils/logger.js";
import { db } from "../db/index.js";
import { commandHistory } from "../db/schema.js";
import { createCurrentCommandHistoryRepository } from "../repositories/factory.js";
import { isNonEmptyString } from "./host-normalizers.js";
export function registerHostCommandHistoryRoutes(
@@ -52,22 +50,13 @@ export function registerHostCommandHistoryRoutes(
}
try {
const history = await db
.select({
id: commandHistory.id,
command: commandHistory.command,
})
.from(commandHistory)
.where(
and(
eq(commandHistory.userId, userId),
eq(commandHistory.hostId, hostId),
),
)
.orderBy(desc(commandHistory.executedAt))
.limit(200);
const history =
await createCurrentCommandHistoryRepository().listCommandsForHost(
userId,
hostId,
);
res.json(history.map((h) => h.command));
res.json(history);
} catch (err) {
sshLogger.error("Failed to fetch command history from database", err, {
operation: "command_history_fetch",
@@ -123,15 +112,11 @@ export function registerHostCommandHistoryRoutes(
}
try {
await db
.delete(commandHistory)
.where(
and(
eq(commandHistory.userId, userId),
eq(commandHistory.hostId, hostId),
eq(commandHistory.command, command),
),
);
await createCurrentCommandHistoryRepository().deleteCommandForHost(
userId,
hostId,
command,
);
res.json({ message: "Command deleted from history" });
} catch (err) {
@@ -0,0 +1,46 @@
import type { NextFunction, Request, Response } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { DataCrypto } from "../../utils/data-crypto.js";
export function applyHostEnrollmentDefaults(
hostData: Record<string, unknown>,
): Record<string, unknown> {
return {
connectionType: "ssh",
port: 22,
authType: "none",
enableTerminal: true,
enableSsh: true,
...hostData,
};
}
export function requireHostEnrollmentAccessForPath(
req: Request,
res: Response,
next: NextFunction,
): void {
if (req.path !== "/enroll") {
next();
return;
}
const authReq = req as AuthenticatedRequest;
if (!authReq.apiKeyId) {
res.status(401).json({
error: "Host enrollment requires an API key",
code: "API_KEY_REQUIRED",
});
return;
}
if (!DataCrypto.canUserAccessData(authReq.userId)) {
res.status(423).json({
error: "User data is locked. Sign in before enrolling hosts.",
code: "DATA_LOCKED",
});
return;
}
next();
}
@@ -1,13 +1,7 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import { and, desc, eq } from "drizzle-orm";
import { sshLogger } from "../../utils/logger.js";
import { db } from "../db/index.js";
import {
fileManagerPinned,
fileManagerRecent,
fileManagerShortcuts,
} from "../db/schema.js";
import { createCurrentFileManagerBookmarkRepository } from "../repositories/factory.js";
import { isNonEmptyString } from "./host-normalizers.js";
export function registerHostFileManagerBookmarkRoutes(
@@ -57,17 +51,12 @@ export function registerHostFileManagerBookmarkRoutes(
}
try {
const recentFiles = await db
.select()
.from(fileManagerRecent)
.where(
and(
eq(fileManagerRecent.userId, userId),
eq(fileManagerRecent.hostId, hostId),
),
)
.orderBy(desc(fileManagerRecent.lastOpened))
.limit(20);
const recentFiles =
await createCurrentFileManagerBookmarkRepository().listRecentForHost(
userId,
hostId,
20,
);
res.json(recentFiles);
} catch (err) {
@@ -119,31 +108,14 @@ export function registerHostFileManagerBookmarkRoutes(
}
try {
const existing = await db
.select()
.from(fileManagerRecent)
.where(
and(
eq(fileManagerRecent.userId, userId),
eq(fileManagerRecent.hostId, hostId),
eq(fileManagerRecent.path, path),
),
);
if (existing.length > 0) {
await db
.update(fileManagerRecent)
.set({ lastOpened: new Date().toISOString() })
.where(eq(fileManagerRecent.id, existing[0].id));
} else {
await db.insert(fileManagerRecent).values({
userId,
await createCurrentFileManagerBookmarkRepository().upsertRecent(
userId,
{
hostId,
path,
name: name || path.split("/").pop() || "Unknown",
lastOpened: new Date().toISOString(),
});
}
name,
},
);
res.json({ message: "Recent file added" });
} catch (err) {
@@ -193,15 +165,10 @@ export function registerHostFileManagerBookmarkRoutes(
}
try {
await db
.delete(fileManagerRecent)
.where(
and(
eq(fileManagerRecent.userId, userId),
eq(fileManagerRecent.hostId, hostId),
eq(fileManagerRecent.path, path),
),
);
await createCurrentFileManagerBookmarkRepository().deleteRecentForHostPath(
userId,
{ hostId, path },
);
res.json({ message: "Recent file removed" });
} catch (err) {
@@ -254,16 +221,11 @@ export function registerHostFileManagerBookmarkRoutes(
}
try {
const pinnedFiles = await db
.select()
.from(fileManagerPinned)
.where(
and(
eq(fileManagerPinned.userId, userId),
eq(fileManagerPinned.hostId, hostId),
),
)
.orderBy(desc(fileManagerPinned.pinnedAt));
const pinnedFiles =
await createCurrentFileManagerBookmarkRepository().listPinnedForHost(
userId,
hostId,
);
res.json(pinnedFiles);
} catch (err) {
@@ -317,29 +279,16 @@ export function registerHostFileManagerBookmarkRoutes(
}
try {
const existing = await db
.select()
.from(fileManagerPinned)
.where(
and(
eq(fileManagerPinned.userId, userId),
eq(fileManagerPinned.hostId, hostId),
eq(fileManagerPinned.path, path),
),
const created =
await createCurrentFileManagerBookmarkRepository().createPinned(
userId,
{ hostId, path, name },
);
if (existing.length > 0) {
if (!created) {
return res.status(409).json({ error: "File already pinned" });
}
await db.insert(fileManagerPinned).values({
userId,
hostId,
path,
name: name || path.split("/").pop() || "Unknown",
pinnedAt: new Date().toISOString(),
});
res.json({ message: "File pinned" });
} catch (err) {
sshLogger.error("Failed to pin file", err);
@@ -388,15 +337,10 @@ export function registerHostFileManagerBookmarkRoutes(
}
try {
await db
.delete(fileManagerPinned)
.where(
and(
eq(fileManagerPinned.userId, userId),
eq(fileManagerPinned.hostId, hostId),
eq(fileManagerPinned.path, path),
),
);
await createCurrentFileManagerBookmarkRepository().deletePinnedForHostPath(
userId,
{ hostId, path },
);
res.json({ message: "Pinned file removed" });
} catch (err) {
@@ -449,16 +393,11 @@ export function registerHostFileManagerBookmarkRoutes(
}
try {
const shortcuts = await db
.select()
.from(fileManagerShortcuts)
.where(
and(
eq(fileManagerShortcuts.userId, userId),
eq(fileManagerShortcuts.hostId, hostId),
),
)
.orderBy(desc(fileManagerShortcuts.createdAt));
const shortcuts =
await createCurrentFileManagerBookmarkRepository().listShortcutsForHost(
userId,
hostId,
);
res.json(shortcuts);
} catch (err) {
@@ -512,29 +451,16 @@ export function registerHostFileManagerBookmarkRoutes(
}
try {
const existing = await db
.select()
.from(fileManagerShortcuts)
.where(
and(
eq(fileManagerShortcuts.userId, userId),
eq(fileManagerShortcuts.hostId, hostId),
eq(fileManagerShortcuts.path, path),
),
const created =
await createCurrentFileManagerBookmarkRepository().createShortcut(
userId,
{ hostId, path, name },
);
if (existing.length > 0) {
if (!created) {
return res.status(409).json({ error: "Shortcut already exists" });
}
await db.insert(fileManagerShortcuts).values({
userId,
hostId,
path,
name: name || path.split("/").pop() || "Unknown",
createdAt: new Date().toISOString(),
});
res.json({ message: "Shortcut added" });
} catch (err) {
sshLogger.error("Failed to add shortcut", err);
@@ -583,15 +509,10 @@ export function registerHostFileManagerBookmarkRoutes(
}
try {
await db
.delete(fileManagerShortcuts)
.where(
and(
eq(fileManagerShortcuts.userId, userId),
eq(fileManagerShortcuts.hostId, hostId),
eq(fileManagerShortcuts.path, path),
),
);
await createCurrentFileManagerBookmarkRepository().deleteShortcutForHostPath(
userId,
{ hostId, path },
);
res.json({ message: "Shortcut removed" });
} catch (err) {
+63 -150
View File
@@ -1,23 +1,16 @@
import type { Request, RequestHandler, Response, Router } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { and, eq, inArray, like, or, sql } from "drizzle-orm";
import { databaseLogger, sshLogger } from "../../utils/logger.js";
import { db, DatabaseSaveTrigger } from "../db/index.js";
import type { SQLiteColumn } from "drizzle-orm/sqlite-core";
import {
commandHistory,
fileManagerPinned,
fileManagerRecent,
fileManagerShortcuts,
hostAccess,
hosts,
recentActivity,
sessionRecordings,
sshCredentialUsage,
sshCredentials,
sshFolders,
transferRecent,
} from "../db/schema.js";
createCurrentCommandHistoryRepository,
createCurrentFileManagerBookmarkRepository,
createCurrentHostFolderRepository,
createCurrentRecentActivityRepository,
createCurrentRbacAccessRepository,
createCurrentSshCredentialUsageRepository,
createCurrentSessionRecordingRepository,
createCurrentTransferRecentRepository,
} from "../repositories/factory.js";
import { isNonEmptyString } from "./host-normalizers.js";
type HostFolderRoutesDeps = {
@@ -75,49 +68,17 @@ export function registerHostFolderRoutes(
}
try {
const now = new Date().toISOString();
const oldPrefix = `${oldName} / `;
const newPrefix = `${newName} / `;
const childLike = `${oldPrefix}%`;
// folder is a plaintext column, so a SQL expression renames the exact
// folder and re-paths every nested child in one statement.
const renameExpr = (col: SQLiteColumn) =>
sql`CASE WHEN ${col} = ${oldName} THEN ${newName} ELSE ${newPrefix} || substr(${col}, ${oldPrefix.length + 1}) END`;
const folderMatch = (col: SQLiteColumn) =>
or(eq(col, oldName), like(col, childLike));
const updatedHosts = await db
.update(hosts)
.set({ folder: renameExpr(hosts.folder), updatedAt: now })
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)))
.returning();
const updatedCredentials = await db
.update(sshCredentials)
.set({ folder: renameExpr(sshCredentials.folder), updatedAt: now })
.where(
and(
eq(sshCredentials.userId, userId),
folderMatch(sshCredentials.folder),
),
)
.returning();
DatabaseSaveTrigger.triggerSave("folder_rename");
await db
.update(sshFolders)
.set({ name: renameExpr(sshFolders.name), updatedAt: now })
.where(
and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)),
const { updatedHosts, updatedCredentials } =
await createCurrentHostFolderRepository().renameFolder(
userId,
oldName,
newName,
);
res.json({
message: "Folder renamed successfully",
updatedHosts: updatedHosts.length,
updatedCredentials: updatedCredentials.length,
updatedHosts,
updatedCredentials,
});
} catch (err) {
sshLogger.error("Failed to rename folder", err, {
@@ -158,10 +119,8 @@ export function registerHostFolderRoutes(
}
try {
const folders = await db
.select()
.from(sshFolders)
.where(eq(sshFolders.userId, userId));
const folders =
await createCurrentHostFolderRepository().listFolders(userId);
res.json(folders);
} catch (err) {
@@ -215,46 +174,28 @@ export function registerHostFolderRoutes(
}
try {
const existing = await db
.select()
.from(sshFolders)
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
.limit(1);
const { folder, created } =
await createCurrentHostFolderRepository().upsertMetadata(
userId,
name,
color,
icon,
);
if (existing.length > 0) {
if (!created) {
databaseLogger.info("Updating SSH folder", {
operation: "folder_update",
userId,
folderId: existing[0].id,
folderId: folder.id,
});
await db
.update(sshFolders)
.set({
color,
icon,
updatedAt: new Date().toISOString(),
})
.where(
and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)),
);
} else {
databaseLogger.info("Creating SSH folder", {
operation: "folder_create",
userId,
name,
});
await db.insert(sshFolders).values({
userId,
name,
color,
icon,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
}
DatabaseSaveTrigger.triggerSave("folder_metadata_update");
res.json({ message: "Folder metadata updated successfully" });
} catch (err) {
sshLogger.error("Failed to update folder metadata", err, {
@@ -308,76 +249,48 @@ export function registerHostFolderRoutes(
});
try {
// Match the folder itself and any nested children (e.g. "fgh / sub").
const childLike = `${folderName} / %`;
const folderMatch = (col: SQLiteColumn) =>
or(eq(col, folderName), like(col, childLike));
const hostsToDelete = await db
.select()
.from(hosts)
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
const hostFolderRepository = createCurrentHostFolderRepository();
const hostsToDelete = await hostFolderRepository.listHostsInFolder(
userId,
folderName,
);
const hostIds = hostsToDelete.map((host) => host.id);
if (hostIds.length > 0) {
await db
.delete(fileManagerRecent)
.where(inArray(fileManagerRecent.hostId, hostIds));
await db
.delete(fileManagerPinned)
.where(inArray(fileManagerPinned.hostId, hostIds));
await db
.delete(fileManagerShortcuts)
.where(inArray(fileManagerShortcuts.hostId, hostIds));
await db
.delete(transferRecent)
.where(
or(
inArray(transferRecent.sourceHostId, hostIds),
inArray(transferRecent.destHostId, hostIds),
),
);
await db
.delete(commandHistory)
.where(inArray(commandHistory.hostId, hostIds));
await db
.delete(sshCredentialUsage)
.where(inArray(sshCredentialUsage.hostId, hostIds));
await db
.delete(recentActivity)
.where(inArray(recentActivity.hostId, hostIds));
await db
.delete(hostAccess)
.where(inArray(hostAccess.hostId, hostIds));
await db
.delete(sessionRecordings)
.where(inArray(sessionRecordings.hostId, hostIds));
}
if (hostIds.length > 0) {
await db
.delete(hosts)
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
}
// Always remove the folder records (and nested children), even when the
// folder held no hosts, so empty folders don't reappear on reload.
await db
.delete(sshFolders)
.where(
and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)),
await createCurrentFileManagerBookmarkRepository().deleteByHostIds(
hostIds,
);
DatabaseSaveTrigger.triggerSave("folder_hosts_delete");
await createCurrentTransferRecentRepository().deleteByHostIds(
hostIds,
);
await createCurrentCommandHistoryRepository().deleteByHostIds(
hostIds,
);
await createCurrentSshCredentialUsageRepository().deleteByHostIds(
hostIds,
);
await createCurrentRecentActivityRepository().deleteByHostIds(
hostIds,
);
await createCurrentRbacAccessRepository().deleteHostAccessForHosts(
hostIds,
);
await createCurrentSessionRecordingRepository().deleteByHostIds(
hostIds,
);
}
await hostFolderRepository.deleteHostsAndFolderRecords(
userId,
folderName,
);
try {
const axios = (await import("axios")).default;
@@ -1,9 +1,7 @@
import type { Request, Response, Router } from "express";
import { and, eq, isNotNull } from "drizzle-orm";
import { SystemCrypto } from "../../utils/system-crypto.js";
import { sshLogger } from "../../utils/logger.js";
import { db } from "../db/index.js";
import { hosts } from "../db/schema.js";
import { createCurrentHostResolutionRepository } from "../repositories/factory.js";
export function registerHostInternalRoutes(router: Router): void {
/**
@@ -45,12 +43,8 @@ export function registerHostInternalRoutes(router: Router): void {
}
try {
const autostartHosts = await db
.select()
.from(hosts)
.where(
and(eq(hosts.enableTunnel, true), isNotNull(hosts.tunnelConnections)),
);
const autostartHosts =
await createCurrentHostResolutionRepository().listHostsWithTunnelConnections();
const result = autostartHosts
.map((host) => {
@@ -134,7 +128,8 @@ export function registerHostInternalRoutes(router: Router): void {
.json({ error: "Invalid internal authentication token" });
}
const allHosts = await db.select().from(hosts);
const allHosts =
await createCurrentHostResolutionRepository().listAllHosts();
const result = allHosts.map((host) => {
const tunnelConnections = host.tunnelConnections
@@ -1,10 +1,8 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import { and, eq } from "drizzle-orm";
import { sendWakeOnLan, isValidMac } from "../../utils/wake-on-lan.js";
import { sshLogger } from "../../utils/logger.js";
import { db } from "../db/index.js";
import { hosts } from "../db/schema.js";
import { createCurrentHostResolutionRepository } from "../repositories/factory.js";
interface HostNetworkRoutesDeps {
authenticateJWT: RequestHandler;
@@ -100,16 +98,12 @@ export function registerHostNetworkRoutes(
const userId = (req as AuthenticatedRequest).userId;
try {
const host = await db
.select({
macAddress: hosts.macAddress,
wolBroadcastAddress: hosts.wolBroadcastAddress,
})
.from(hosts)
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
.then((rows) => rows[0]);
const host = await createCurrentHostResolutionRepository().findHostById(
hostId,
userId,
);
if (!host) {
if (!host || host.userId !== userId) {
return res.status(404).json({ error: "Host not found" });
}
@@ -1,221 +0,0 @@
import { describe, it, expect } from "vitest";
import {
isNonEmptyString,
isValidPort,
normalizeImportedHost,
renameFolderPath,
stripSensitiveFields,
transformHostResponse,
} from "./host-normalizers.js";
describe("isNonEmptyString", () => {
it("accepts non-blank strings", () => {
expect(isNonEmptyString("hello")).toBe(true);
expect(isNonEmptyString(" x ")).toBe(true);
});
it("rejects blank strings and non-strings", () => {
expect(isNonEmptyString("")).toBe(false);
expect(isNonEmptyString(" ")).toBe(false);
expect(isNonEmptyString(123)).toBe(false);
expect(isNonEmptyString(null)).toBe(false);
expect(isNonEmptyString(undefined)).toBe(false);
});
});
describe("renameFolderPath", () => {
it("renames an exact folder match", () => {
expect(renameFolderPath("Production", "Production", "Prod")).toBe("Prod");
});
it("re-paths nested children under the renamed ancestor", () => {
expect(renameFolderPath("Production / Web", "Production", "Prod")).toBe(
"Prod / Web",
);
expect(
renameFolderPath("Production / Web / app01", "Production", "Prod"),
).toBe("Prod / Web / app01");
});
it("renames a nested folder itself and keeps its parent", () => {
expect(
renameFolderPath("Production / Web", "Production / Web", "Frontend"),
).toBe("Frontend");
expect(
renameFolderPath(
"Production / Web / app01",
"Production / Web",
"Production / Frontend",
),
).toBe("Production / Frontend / app01");
});
it("returns null for unrelated folders", () => {
expect(renameFolderPath("Staging", "Production", "Prod")).toBeNull();
expect(renameFolderPath("Production2", "Production", "Prod")).toBeNull();
expect(
renameFolderPath("ProductionExtra / Web", "Production", "Prod"),
).toBeNull();
});
});
describe("isValidPort", () => {
it("accepts ports in range", () => {
expect(isValidPort(1)).toBe(true);
expect(isValidPort(22)).toBe(true);
expect(isValidPort(65535)).toBe(true);
});
it("rejects out-of-range or non-number ports", () => {
expect(isValidPort(0)).toBe(false);
expect(isValidPort(65536)).toBe(false);
expect(isValidPort(-1)).toBe(false);
expect(isValidPort("22")).toBe(false);
});
});
describe("normalizeImportedHost", () => {
it("defaults connectionType to ssh with port 22", () => {
const host = normalizeImportedHost({ ip: "10.0.0.1" });
expect(host.connectionType).toBe("ssh");
expect(host.port).toBe(22);
expect(host.enableSsh).toBe(true);
expect(host.enableRdp).toBe(false);
});
it("infers rdp from enableRdp and uses default rdp port", () => {
const host = normalizeImportedHost({ enableRdp: true, ip: "10.0.0.2" });
expect(host.connectionType).toBe("rdp");
expect(host.port).toBe(3389);
expect(host.enableRdp).toBe(true);
});
it("honors an explicit port over protocol defaults", () => {
const host = normalizeImportedHost({
connectionType: "ssh",
port: 2222,
});
expect(host.port).toBe(2222);
});
it("resolves ip from common aliases", () => {
expect(normalizeImportedHost({ address: "a.example" }).ip).toBe(
"a.example",
);
expect(normalizeImportedHost({ hostname: "h.example" }).ip).toBe(
"h.example",
);
});
it("normalizes tags from a comma string", () => {
const host = normalizeImportedHost({ tags: "prod, db , , web" });
expect(host.tags).toEqual(["prod", "db", "web"]);
});
it("normalizes tags from an array", () => {
const host = normalizeImportedHost({ tags: ["a", " b ", "", "c"] });
expect(host.tags).toEqual(["a", "b", "c"]);
});
it("infers authType credential when credentialId present", () => {
const host = normalizeImportedHost({ credentialId: 7 });
expect(host.credentialId).toBe(7);
expect(host.authType).toBe("credential");
});
it("infers credential auth from share aliases", () => {
const aliasHost = normalizeImportedHost({ credentialAlias: "prod-admin" });
expect(aliasHost.credentialAlias).toBe("prod-admin");
expect(aliasHost.authType).toBe("credential");
const nameHost = normalizeImportedHost({ credentialName: "ops-key" });
expect(nameHost.credentialAlias).toBe("ops-key");
expect(nameHost.authType).toBe("credential");
});
});
describe("stripSensitiveFields", () => {
it("removes secret fields and adds boolean presence flags", () => {
const result = stripSensitiveFields({
name: "web",
password: "secret",
key: "PRIVATE KEY",
keyPassword: "kp",
sudoPassword: "sp",
});
expect(result.password).toBeUndefined();
expect(result.key).toBeUndefined();
expect(result.keyPassword).toBeUndefined();
expect(result.sudoPassword).toBeUndefined();
expect(result.hasPassword).toBe(true);
expect(result.hasKey).toBe(true);
expect(result.hasKeyPassword).toBe(true);
expect(result.hasSudoPassword).toBe(true);
expect(result.name).toBe("web");
});
it("marks presence flags false when secrets are absent", () => {
const result = stripSensitiveFields({ name: "web" });
expect(result.hasPassword).toBe(false);
expect(result.hasKey).toBe(false);
});
});
describe("transformHostResponse", () => {
it("parses tags and coerces enable flags to booleans", () => {
const result = transformHostResponse({
tags: "a,b,c",
enableTerminal: 1,
enableTunnel: 0,
pin: 1,
});
expect(result.tags).toEqual(["a", "b", "c"]);
expect(result.enableTerminal).toBe(true);
expect(result.enableTunnel).toBe(false);
expect(result.pin).toBe(true);
});
it("parses JSON array fields and defaults them to []", () => {
const result = transformHostResponse({
tunnelConnections: '[{"sourcePort":8080}]',
jumpHosts: null,
});
expect(result.tunnelConnections).toEqual([{ sourcePort: 8080 }]);
expect(result.jumpHosts).toEqual([]);
});
it("infers protocol flags for a migrated non-ssh host", () => {
const result = transformHostResponse({
connectionType: "rdp",
enableSsh: true,
});
expect(result.enableSsh).toBe(false);
expect(result.enableRdp).toBe(true);
});
it("applies default protocol ports", () => {
const result = transformHostResponse({ port: 22 });
expect(result.sshPort).toBe(22);
expect(result.rdpPort).toBe(3389);
expect(result.vncPort).toBe(5900);
expect(result.telnetPort).toBe(23);
});
it("coerces enableProxmox and parses proxmoxConfig", () => {
const result = transformHostResponse({
enableProxmox: 1,
proxmoxConfig: '{"defaultCredentialId":3,"windowsPatterns":"win"}',
});
expect(result.enableProxmox).toBe(true);
expect(result.proxmoxConfig).toEqual({
defaultCredentialId: 3,
windowsPatterns: "win",
});
});
it("defaults enableProxmox to false when absent", () => {
const result = transformHostResponse({ port: 22 });
expect(result.enableProxmox).toBe(false);
expect(result.proxmoxConfig).toBeUndefined();
});
});
@@ -231,6 +231,76 @@ export function stripSensitiveFields(
return result;
}
// Connection essentials a connect-level recipient is allowed to see.
const CONNECT_LEVEL_FIELDS = new Set([
"id",
"userId",
"ownerId",
"ownerUsername",
"isShared",
"permissionLevel",
"sharedExpiresAt",
"name",
"ip",
"port",
"username",
"folder",
"tags",
"pin",
"authType",
"connectionType",
"credentialId",
"enableTerminal",
"enableTunnel",
"enableFileManager",
"enableDocker",
"enableProxmox",
"enableTmuxMonitor",
"showTerminalInSidebar",
"showFileManagerInSidebar",
"showTunnelInSidebar",
"showDockerInSidebar",
"showServerStatsInSidebar",
"enableSsh",
"enableRdp",
"enableVnc",
"enableTelnet",
"sshPort",
"rdpPort",
"vncPort",
"telnetPort",
"defaultPath",
"scpLegacy",
"tunnelConnections",
"jumpHosts",
"createdAt",
"updatedAt",
]);
/**
* Shapes a shared host row for its recipient. Secrets are always stripped
* (all levels); connect-level recipients are additionally reduced to
* connection essentials since they may not view the host's configuration.
*/
export function sanitizeHostForRecipient(
host: Record<string, unknown>,
permissionLevel: string | undefined,
): Record<string, unknown> {
const stripped = stripSensitiveFields(host);
if (permissionLevel !== "connect") {
return stripped;
}
const reduced: Record<string, unknown> = {};
for (const [key, value] of Object.entries(stripped)) {
if (CONNECT_LEVEL_FIELDS.has(key)) {
reduced[key] = value;
}
}
return reduced;
}
export function transformHostResponse(
host: Record<string, unknown>,
): Record<string, unknown> {
@@ -52,7 +52,7 @@ export function registerHostOpksshRoutes(router: Router): void {
try {
const { getActiveAuthSession, registerOAuthState } =
await import("../../ssh/opkssh-auth.js");
await import("../../hosts/opkssh-auth.js");
const session = getActiveAuthSession(requestId);
if (!session) {
@@ -562,7 +562,7 @@ export function registerHostOpksshRoutes(router: Router): void {
getActiveAuthSession,
getRequestIdByOAuthState,
clearOAuthState,
} = await import("../../ssh/opkssh-auth.js");
} = await import("../../hosts/opkssh-auth.js");
const userId = await getUserIdFromRequest({
cookies: req.cookies,
@@ -729,7 +729,7 @@ export function registerHostOpksshRoutes(router: Router): void {
try {
const { getActiveAuthSession } =
await import("../../ssh/opkssh-auth.js");
await import("../../hosts/opkssh-auth.js");
const session = getActiveAuthSession(requestId);
if (!session) {
File diff suppressed because it is too large Load Diff
+54 -99
View File
@@ -1,14 +1,17 @@
import type { Router } from "express";
import type { LDAPProviderConfig } from "../../../types/index.js";
import { db } from "../db/index.js";
import { ssoProviders, users, roles, userRoles } from "../db/schema.js";
import { eq, and } from "drizzle-orm";
import { nanoid } from "nanoid";
import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { parseUserAgent } from "../../utils/user-agent-parser.js";
import { isOIDCUserAllowed, loadProviderConfig } from "./user-oidc-utils.js";
import ldap from "ldapjs";
import {
createCurrentRoleRepository,
createCurrentSettingsRepository,
createCurrentSsoProviderRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
const authManager = AuthManager.getInstance();
@@ -120,12 +123,9 @@ export function registerLDAPAuthRoutes(router: Router): void {
}
try {
const rows = await db
.select()
.from(ssoProviders)
.where(eq(ssoProviders.id, providerId))
.limit(1);
if (rows.length === 0 || rows[0].type !== "ldap" || !rows[0].enabled) {
const provider =
await createCurrentSsoProviderRepository().findById(providerId);
if (!provider || provider.type !== "ldap" || !provider.enabled) {
return res.status(404).json({ error: "LDAP provider not found" });
}
} catch (err) {
@@ -268,22 +268,19 @@ export function registerLDAPAuthRoutes(router: Router): void {
const deviceInfo = parseUserAgent(req);
const oidcIdentifier = `ldap:${providerId}:${ldapIdentifier}`;
const userRepository = createCurrentUserRepository();
let existingUsers = await db
.select()
.from(users)
.where(eq(users.oidcIdentifier, oidcIdentifier));
let existingUser =
await userRepository.findByOidcIdentifier(oidcIdentifier);
let userId: string;
if (existingUsers.length === 0) {
if (!existingUser) {
let autoProvision = false;
try {
const r = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'oidc_auto_provision'",
)
.get() as { value: string } | undefined;
if (r) autoProvision = r.value === "true";
autoProvision = await createCurrentSettingsRepository().getBoolean(
"oidc_auto_provision",
false,
);
} catch {
/* */
}
@@ -292,50 +289,31 @@ export function registerLDAPAuthRoutes(router: Router): void {
(process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() ===
"true";
const countRow = db.$client
.prepare("SELECT COUNT(*) as count FROM users")
.get() as { count?: number };
const isFirst = (countRow?.count || 0) === 0;
const isFirst = (await userRepository.countAll()) === 0;
if (!isFirst && !autoProvision) {
return res.status(403).json({ error: "Registration is disabled" });
}
userId = nanoid();
const isFirstFinal = db.$client.transaction(() => {
const c =
(
db.$client
.prepare("SELECT COUNT(*) as count FROM users")
.get() as { count?: number }
)?.count || 0;
const first = c === 0;
db.$client
.prepare(
"INSERT INTO users (id, username, password_hash, is_admin, is_oidc, oidc_identifier, sso_provider_id) VALUES (?, ?, ?, ?, 1, ?, ?)",
)
.run(
userId,
displayName,
"",
first || isAdmin ? 1 : 0,
oidcIdentifier,
providerId,
);
return first;
})();
const createdUser = await userRepository.createFirstSsoUser({
id: userId,
username: displayName,
passwordHash: "",
isAdmin,
isOidc: true,
oidcIdentifier,
ssoProviderId: providerId,
});
const isFirstFinal = createdUser.isFirstUser;
try {
const defaultRoleName = isFirstFinal || isAdmin ? "admin" : "user";
const defaultRole = await db
.select({ id: roles.id })
.from(roles)
.where(eq(roles.name, defaultRoleName))
.limit(1);
if (defaultRole.length > 0)
await db
.insert(userRoles)
.values({ userId, roleId: defaultRole[0].id, grantedBy: userId });
await createCurrentRoleRepository().assignRoleNameToUser({
userId,
roleName: defaultRoleName,
grantedBy: userId,
});
} catch {
/* */
}
@@ -347,7 +325,7 @@ export function registerLDAPAuthRoutes(router: Router): void {
: 24 * 60 * 60 * 1000;
await authManager.registerOIDCUser(userId, sessionDurationMs);
} catch (encryptionError) {
await db.delete(users).where(eq(users.id, userId));
await userRepository.delete(userId);
authLogger.error(
"Failed to setup LDAP user encryption",
encryptionError,
@@ -357,47 +335,26 @@ export function registerLDAPAuthRoutes(router: Router): void {
.json({ error: "Failed to setup user security" });
}
existingUsers = await db
.select()
.from(users)
.where(eq(users.id, userId));
existingUser = await userRepository.findById(userId);
if (!existingUser) {
throw new Error("Created LDAP user could not be loaded");
}
} else {
userId = existingUsers[0].id;
userId = existingUser.id;
// Sync admin status from group membership
if (config.adminGroup && !!existingUsers[0].isAdmin !== isAdmin) {
await db.update(users).set({ isAdmin }).where(eq(users.id, userId));
existingUsers[0].isAdmin = isAdmin;
if (config.adminGroup && !!existingUser.isAdmin !== isAdmin) {
existingUser =
(await userRepository.update(userId, { isAdmin })) ?? existingUser;
try {
const newRoleName = isAdmin ? "admin" : "user";
const oldRoleName = isAdmin ? "user" : "admin";
const newRole = await db
.select({ id: roles.id })
.from(roles)
.where(eq(roles.name, newRoleName))
.limit(1);
const oldRole = await db
.select({ id: roles.id })
.from(roles)
.where(eq(roles.name, oldRoleName))
.limit(1);
if (oldRole.length > 0) {
await db
.delete(userRoles)
.where(
and(
eq(userRoles.userId, userId),
eq(userRoles.roleId, oldRole[0].id),
),
);
}
if (newRole.length > 0) {
await db.insert(userRoles).values({
userId,
roleId: newRole[0].id,
grantedBy: userId,
});
}
await createCurrentRoleRepository().switchUserRoleName({
userId,
addRoleName: newRoleName,
removeRoleName: oldRoleName,
grantedBy: userId,
});
} catch {
/* non-fatal */
}
@@ -405,17 +362,15 @@ export function registerLDAPAuthRoutes(router: Router): void {
// Update display name if not dual-auth
const isDualAuth =
existingUsers[0].passwordHash &&
existingUsers[0].passwordHash.trim() !== "";
if (!isDualAuth && existingUsers[0].username !== displayName) {
await db
.update(users)
.set({ username: displayName })
.where(eq(users.id, userId));
existingUser.passwordHash && existingUser.passwordHash.trim() !== "";
if (!isDualAuth && existingUser.username !== displayName) {
existingUser =
(await userRepository.update(userId, { username: displayName })) ??
existingUser;
}
}
const userRecord = existingUsers[0];
const userRecord = existingUser;
try {
await authManager.authenticateOIDCUser(userRecord.id, deviceInfo.type);
} catch {
@@ -1,10 +1,8 @@
import express from "express";
import { eq } from "drizzle-orm";
import { getDb } from "../db/index.js";
import { networkTopology } from "../db/schema.js";
import { AuthManager } from "../../utils/auth-manager.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { databaseLogger } from "../../utils/logger.js";
import { createCurrentNetworkTopologyRepository } from "../repositories/factory.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
@@ -70,14 +68,11 @@ router.get(
return res.status(401).json({ error: "User not authenticated" });
}
const db = getDb();
const result = await db
.select()
.from(networkTopology)
.where(eq(networkTopology.userId, userId));
const record =
await createCurrentNetworkTopologyRepository().findByUserId(userId);
if (result.length > 0) {
const topologyStr = result[0].topology;
if (record) {
const topologyStr = record.topology;
const topology = topologyStr ? JSON.parse(topologyStr) : null;
return res.json(topology);
} else {
@@ -157,26 +152,13 @@ router.post(
return res.status(400).json({ error: "Topology data is required" });
}
const db = getDb();
const topologyStr =
typeof topology === "string" ? topology : JSON.stringify(topology);
const existing = await db
.select()
.from(networkTopology)
.where(eq(networkTopology.userId, userId));
if (existing.length > 0) {
await db
.update(networkTopology)
.set({ topology: topologyStr })
.where(eq(networkTopology.userId, userId));
} else {
await db
.insert(networkTopology)
.values({ userId, topology: topologyStr });
}
await createCurrentNetworkTopologyRepository().upsertForUser(
userId,
topologyStr,
);
return res.json({ success: true });
} catch (error) {
+28 -86
View File
@@ -1,12 +1,13 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import { db } from "../db/index.js";
import { userOpenTabs } from "../db/schema.js";
import { eq, and, sql } from "drizzle-orm";
import type { Request, Response } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { sessionManager } from "../../ssh/terminal-session-manager.js";
import { sessionManager } from "../../hosts/terminal/session-manager.js";
import {
getCurrentSettingValue,
createCurrentOpenTabRepository,
} from "../repositories/factory.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
@@ -27,13 +28,9 @@ const DEFAULT_TAB_TTL_MINUTES = 30;
function getTabTtlMs(): number {
try {
const row = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'terminal_session_timeout_minutes'",
)
.get() as { value: string } | undefined;
if (row) {
const minutes = parseInt(row.value, 10);
const value = getCurrentSettingValue("terminal_session_timeout_minutes");
if (value) {
const minutes = parseInt(value, 10);
if (!isNaN(minutes) && minutes > 0) return minutes * 60_000;
}
} catch {
@@ -56,17 +53,10 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const cutoff = new Date(Date.now() - getTabTtlMs()).toISOString();
const tabs = db
.select()
.from(userOpenTabs)
.where(
and(
eq(userOpenTabs.userId, userId),
sql`${userOpenTabs.updatedAt} > ${cutoff}`,
),
)
.orderBy(userOpenTabs.tabOrder)
.all();
const tabs = await createCurrentOpenTabRepository().listRecentForUser(
userId,
cutoff,
);
return res.json(
tabs.map((tab) => ({ ...tab, tabType: normalizeTabType(tab.tabType) })),
);
@@ -131,43 +121,14 @@ router.post("/", authenticateJWT, async (req: Request, res: Response) => {
}
try {
const now = new Date().toISOString();
const existing = db
.select()
.from(userOpenTabs)
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
.all();
if (existing.length > 0) {
// Preserve existing backendSessionId when not explicitly provided
const sessionId =
backendSessionId !== undefined
? backendSessionId
: existing[0].backendSessionId;
db.update(userOpenTabs)
.set({
tabType,
hostId: hostId ?? null,
label,
tabOrder,
backendSessionId: sessionId ?? null,
updatedAt: now,
})
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
.run();
} else {
db.insert(userOpenTabs)
.values({
id,
userId,
tabType,
hostId: hostId ?? null,
label,
tabOrder,
backendSessionId: backendSessionId ?? null,
updatedAt: now,
})
.run();
}
await createCurrentOpenTabRepository().upsertForUser(userId, {
id,
tabType,
hostId,
label,
tabOrder,
backendSessionId,
});
return res.json({ success: true });
} catch (e) {
databaseLogger.error("Failed to upsert open tab", e, {
@@ -217,24 +178,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
}
try {
db.delete(userOpenTabs).where(eq(userOpenTabs.userId, userId)).run();
if (tabs.length > 0) {
const now = new Date().toISOString();
db.insert(userOpenTabs)
.values(
tabs.map((t) => ({
id: t.id,
userId,
tabType: t.tabType,
hostId: t.hostId ?? null,
label: t.label,
tabOrder: t.tabOrder,
backendSessionId: t.backendSessionId ?? null,
updatedAt: now,
})),
)
.run();
}
await createCurrentOpenTabRepository().replaceForUser(userId, tabs);
return res.json({ success: true });
} catch (e) {
databaseLogger.error("Failed to sync open tabs", e, {
@@ -274,13 +218,13 @@ router.patch("/:id", authenticateJWT, async (req: Request, res: Response) => {
}>;
try {
const result = db
.update(userOpenTabs)
.set({ ...updates, updatedAt: new Date().toISOString() })
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
.run();
const updated = await createCurrentOpenTabRepository().updateForUser(
userId,
id,
updates,
);
if (result.changes === 0) {
if (!updated) {
return res.status(404).json({ error: "Tab not found" });
}
return res.json({ success: true });
@@ -316,9 +260,7 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
const id = String(req.params.id);
try {
db.delete(userOpenTabs)
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
.run();
await createCurrentOpenTabRepository().deleteForUser(userId, id);
return res.json({ success: true });
} catch (e) {
databaseLogger.error("Failed to delete open tab", e, {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,28 +0,0 @@
import { describe, expect, it } from "vitest";
import {
isValidServiceLinkUrl,
normalizeServiceLinkUrl,
} from "./service-link-url.js";
describe("service link URL handling", () => {
it("keeps explicit http and https URLs", () => {
expect(normalizeServiceLinkUrl("https://example.com")).toBe(
"https://example.com",
);
expect(normalizeServiceLinkUrl("http://192.168.1.10:8080")).toBe(
"http://192.168.1.10:8080",
);
});
it("adds http to bare service addresses", () => {
expect(normalizeServiceLinkUrl("192.168.1.10:8080")).toBe(
"http://192.168.1.10:8080",
);
expect(normalizeServiceLinkUrl("termix.local")).toBe("http://termix.local");
});
it("rejects unsupported schemes", () => {
expect(isValidServiceLinkUrl("ssh://example.com")).toBe(false);
expect(isValidServiceLinkUrl("javascript:alert(1)")).toBe(false);
});
});
@@ -1,124 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import path from "path";
// Stub db, logger, fs, and AuthManager before importing the route module
const mockSelect = vi.fn();
const mockDelete = vi.fn();
const mockInsert = vi.fn();
vi.mock("../db/index.js", () => ({
db: {
select: mockSelect,
delete: mockDelete,
insert: mockInsert,
},
}));
vi.mock("../../utils/logger.js", () => ({
apiLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), success: vi.fn() },
}));
vi.mock("../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() => (_req: unknown, _res: unknown, next: () => void) =>
next(),
}),
},
}));
const mockReadFile = vi.fn();
const mockStat = vi.fn();
const mockUnlink = vi.fn();
const mockExistsSync = vi.fn();
vi.mock("fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("fs")>();
return {
...actual,
promises: { readFile: mockReadFile, unlink: mockUnlink },
existsSync: mockExistsSync,
statSync: mockStat,
};
});
// Build a chainable drizzle-like query stub
function makeChain(resolveValue: unknown) {
const chain: Record<string, unknown> = {};
const methods = [
"from",
"leftJoin",
"where",
"orderBy",
"limit",
"set",
"values",
];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
(chain as unknown as Promise<unknown>).then = (cb: (v: unknown) => unknown) =>
Promise.resolve(resolveValue).then(cb);
(chain as unknown as Promise<unknown>).catch = (
cb: (e: unknown) => unknown,
) => Promise.resolve(resolveValue).catch(cb);
return chain;
}
describe("session-log-routes", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.DATA_DIR = "/data";
});
describe("GET / - list logs", () => {
it("returns logs for the authenticated user with file size", async () => {
const rows = [
{
id: 1,
hostId: 10,
userId: "u1",
startedAt: "2026-01-01T00:00:00Z",
endedAt: "2026-01-01T00:05:00Z",
duration: 300,
recordingPath: "/data/session_logs/u1/abc.log",
hostName: "my-server",
hostIp: "10.0.0.1",
},
];
const chain = makeChain(rows);
mockSelect.mockReturnValue(chain);
mockStat.mockReturnValue({ size: 4096 });
// Directly call the route handler extracted from the module
const { default: router } = await import("./session-log-routes.js");
expect(router).toBeDefined();
});
});
describe("path traversal guard", () => {
it("rejects paths outside the allowed session_logs directory", () => {
const allowedBase = path.resolve("/data", "session_logs");
const malicious = path.resolve("/data/session_logs/../../etc/passwd");
expect(malicious.startsWith(allowedBase)).toBe(false);
});
it("allows a legitimate session log path", () => {
const allowedBase = path.resolve("/data", "session_logs");
const valid = path.resolve("/data/session_logs/user1/abc.log");
expect(valid.startsWith(allowedBase)).toBe(true);
});
});
describe("formatters (pure logic)", () => {
it("stat returns size when file exists", () => {
mockExistsSync.mockReturnValue(true);
mockStat.mockReturnValue({ size: 1234 });
const exists = mockExistsSync("/some/file.log");
const { size } = mockStat("/some/file.log");
expect(exists).toBe(true);
expect(size).toBe(1234);
});
});
});
+123 -76
View File
@@ -2,45 +2,72 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import fs from "fs";
import path from "path";
import { eq, and, desc, lt } from "drizzle-orm";
import { db } from "../db/index.js";
import { sessionRecordings, hosts } from "../db/schema.js";
import { apiLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import type { Request, Response } from "express";
import { PermissionManager } from "../../utils/permission-manager.js";
import {
createCurrentSessionRecordingRepository,
createCurrentSettingsRepository,
getCurrentSettingValue,
} from "../repositories/factory.js";
const router = express.Router();
const DATA_DIR = process.env.DATA_DIR ?? "./db/data";
// Delete session log files and DB rows older than this many days
const LOG_RETENTION_DAYS = 30;
const permissionManager = PermissionManager.getInstance();
function isAllowedRecordingPath(filePath: string): boolean {
const resolved = path.resolve(filePath);
return ["session_logs", "session_recordings"].some((directory) => {
const base = `${path.resolve(DATA_DIR, directory)}${path.sep}`;
return resolved.startsWith(base);
});
}
function getRetentionDays(): number {
const envDays = parseInt(
process.env.SESSION_RECORDING_RETENTION_DAYS || "",
10,
);
try {
const configured = parseInt(
getCurrentSettingValue("session_recording_retention_days") || "",
10,
);
if (configured >= 1 && configured <= 3650) return configured;
} catch {
// use environment/default below
}
return envDays >= 1 && envDays <= 3650 ? envDays : 30;
}
async function canAccessRecording(
userId: string,
ownerId: string,
): Promise<boolean> {
return userId === ownerId || permissionManager.isAdmin(userId);
}
async function pruneOldLogs(): Promise<void> {
try {
const cutoff = new Date(
Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000,
Date.now() - getRetentionDays() * 24 * 60 * 60 * 1000,
).toISOString();
const old = await db
.select({
id: sessionRecordings.id,
recordingPath: sessionRecordings.recordingPath,
})
.from(sessionRecordings)
.where(lt(sessionRecordings.startedAt, cutoff));
const sessionRecordingRepository =
createCurrentSessionRecordingRepository();
const old = await sessionRecordingRepository.listPathsOlderThan(cutoff);
for (const row of old) {
if (row.recordingPath) {
const resolved = path.resolve(row.recordingPath);
const allowed = path.resolve(DATA_DIR, "session_logs");
if (resolved.startsWith(allowed) && fs.existsSync(resolved)) {
if (isAllowedRecordingPath(resolved) && fs.existsSync(resolved)) {
await fs.promises.unlink(resolved).catch(() => {});
}
}
await db
.delete(sessionRecordings)
.where(eq(sessionRecordings.id, row.id));
await sessionRecordingRepository.deleteById(row.id);
}
if (old.length > 0) {
@@ -81,22 +108,10 @@ const authenticateJWT = authManager.createAuthMiddleware();
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const rows = await db
.select({
id: sessionRecordings.id,
hostId: sessionRecordings.hostId,
userId: sessionRecordings.userId,
startedAt: sessionRecordings.startedAt,
endedAt: sessionRecordings.endedAt,
duration: sessionRecordings.duration,
recordingPath: sessionRecordings.recordingPath,
hostName: hosts.name,
hostIp: hosts.ip,
})
.from(sessionRecordings)
.leftJoin(hosts, eq(sessionRecordings.hostId, hosts.id))
.where(eq(sessionRecordings.userId, userId))
.orderBy(desc(sessionRecordings.startedAt));
const rows =
await createCurrentSessionRecordingRepository().listByUserIdWithHost(
userId,
);
const records = rows.map((row) => {
let sizeBytes: number | null = null;
@@ -120,6 +135,45 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
}
});
router.get(
"/retention",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (!(await permissionManager.isAdmin(userId))) {
return res.status(403).json({ error: "Admin access required" });
}
res.json({ retentionDays: getRetentionDays() });
},
);
router.put(
"/retention",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (!(await permissionManager.isAdmin(userId))) {
return res.status(403).json({ error: "Admin access required" });
}
const retentionDays = Number(req.body?.retentionDays);
if (
!Number.isInteger(retentionDays) ||
retentionDays < 1 ||
retentionDays > 3650
) {
return res
.status(400)
.json({ error: "Retention must be between 1 and 3650 days" });
}
await createCurrentSettingsRepository().upsert(
"session_recording_retention_days",
String(retentionDays),
);
void pruneOldLogs();
res.json({ retentionDays });
},
);
/**
* @openapi
* /session_logs/{id}:
@@ -150,16 +204,13 @@ router.get("/:id", authenticateJWT, async (req: Request, res: Response) => {
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
try {
const rows = await db
.select()
.from(sessionRecordings)
.where(
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
)
.limit(1);
const row = await createCurrentSessionRecordingRepository().findByIdForUser(
userId,
id,
);
if (rows.length === 0) return res.status(404).json({ error: "Not found" });
res.json({ log: rows[0] });
if (!row) return res.status(404).json({ error: "Not found" });
res.json({ log: row });
} catch (error) {
apiLogger.error("Failed to fetch session log", error, {
operation: "session_log_get",
@@ -205,27 +256,20 @@ router.get(
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
try {
const rows = await db
.select({ recordingPath: sessionRecordings.recordingPath })
.from(sessionRecordings)
.where(
and(
eq(sessionRecordings.id, id),
eq(sessionRecordings.userId, userId),
),
)
.limit(1);
const row =
await createCurrentSessionRecordingRepository().findPathByIdForUser(
userId,
id,
);
if (rows.length === 0)
return res.status(404).json({ error: "Not found" });
if (!row) return res.status(404).json({ error: "Not found" });
const filePath = rows[0].recordingPath;
const filePath = row.recordingPath;
if (!filePath)
return res.status(404).json({ error: "No recording file" });
const resolvedPath = path.resolve(filePath);
const allowedBase = path.resolve(DATA_DIR, "session_logs");
if (!resolvedPath.startsWith(allowedBase)) {
if (!isAllowedRecordingPath(resolvedPath)) {
return res.status(403).json({ error: "Forbidden" });
}
@@ -233,8 +277,17 @@ router.get(
return res.status(404).json({ error: "File not found" });
}
const content = await fs.promises.readFile(resolvedPath, "utf-8");
res.type("text/plain").send(content);
const content = await fs.promises.readFile(resolvedPath);
const format =
(row as { format?: string | null }).format ??
(row.recordingPath?.endsWith(".cast") ? "asciicast" : "text");
const contentType =
format === "guacamole"
? "application/vnd.apache.guacamole.recording"
: format === "asciicast"
? "application/x-asciicast"
: "text/plain";
res.type(contentType).send(content);
} catch (error) {
apiLogger.error("Failed to read session log content", error, {
operation: "session_log_content",
@@ -276,28 +329,22 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
try {
const rows = await db
.select({ recordingPath: sessionRecordings.recordingPath })
.from(sessionRecordings)
.where(
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
)
.limit(1);
const sessionRecordingRepository =
createCurrentSessionRecordingRepository();
const row = await sessionRecordingRepository.findPathByIdForUser(
userId,
id,
);
if (rows.length === 0) return res.status(404).json({ error: "Not found" });
if (!row) return res.status(404).json({ error: "Not found" });
const filePath = rows[0].recordingPath;
const filePath = row.recordingPath;
await db
.delete(sessionRecordings)
.where(
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
);
await sessionRecordingRepository.deleteForUser(userId, id);
if (filePath) {
const resolvedPath = path.resolve(filePath);
const allowedBase = path.resolve(DATA_DIR, "session_logs");
if (resolvedPath.startsWith(allowedBase) && fs.existsSync(resolvedPath)) {
if (isAllowedRecordingPath(resolvedPath) && fs.existsSync(resolvedPath)) {
await fs.promises.unlink(resolvedPath).catch(() => {});
}
}
+109 -517
View File
@@ -1,21 +1,18 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import { db } from "../db/index.js";
import {
snippets,
snippetFolders,
snippetAccess,
users,
userRoles,
} from "../db/schema.js";
import { eq, and, desc, asc, sql, or, isNull, gte } from "drizzle-orm";
import type { Request, Response } from "express";
import { authLogger, databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
import { extractSnippetReorderUpdates } from "./snippets-reorder.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import { applyAgentAuth } from "../../ssh/terminal-auth-helpers.js";
import {
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
createCurrentSnippetRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
const router = express.Router();
@@ -24,38 +21,12 @@ function isNonEmptyString(val: unknown): val is string {
}
async function getUserRoleIds(userId: string): Promise<number[]> {
const rows = await db
.select({ roleId: userRoles.roleId })
.from(userRoles)
.where(eq(userRoles.userId, userId));
return rows.map((row) => row.roleId);
return createCurrentRoleRepository().listUserRoleIds(userId);
}
function roleIdFilter(roleIds: number[]) {
if (roleIds.length === 0) {
return undefined;
}
return sql`${snippetAccess.roleId} IN (${sql.join(
roleIds.map((id) => sql`${id}`),
sql`, `,
)})`;
}
function activeSnippetAccessFilter(userId: string, roleIds: number[]) {
const roleFilter = roleIdFilter(roleIds);
const targetFilter = roleFilter
? or(eq(snippetAccess.userId, userId), roleFilter)
: eq(snippetAccess.userId, userId);
return and(
targetFilter,
or(
isNull(snippetAccess.expiresAt),
gte(snippetAccess.expiresAt, new Date().toISOString()),
),
);
async function getActorUsername(userId: string): Promise<string> {
const user = await createCurrentUserRepository().findById(userId);
return user?.username ?? userId;
}
function sortSnippets<
@@ -73,41 +44,21 @@ function sortSnippets<
}
async function getAccessibleSnippet(snippetId: number, userId: string) {
const owned = await db
.select()
.from(snippets)
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)))
.limit(1);
const owned = await createCurrentSnippetRepository().findOwnedById(
userId,
snippetId,
);
if (owned.length > 0) {
return owned[0];
if (owned) {
return owned;
}
const roleIds = await getUserRoleIds(userId);
const shared = await db
.select({
id: snippets.id,
userId: snippets.userId,
name: snippets.name,
content: snippets.content,
description: snippets.description,
folder: snippets.folder,
order: snippets.order,
createdAt: snippets.createdAt,
updatedAt: snippets.updatedAt,
hostFilter: snippets.hostFilter,
})
.from(snippetAccess)
.innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id))
.where(
and(
eq(snippetAccess.snippetId, snippetId),
activeSnippetAccessFilter(userId, roleIds),
),
)
.limit(1);
return shared[0] ?? null;
return createCurrentRbacAccessRepository().findAccessibleSharedSnippet(
snippetId,
userId,
roleIds,
);
}
const authManager = AuthManager.getInstance();
@@ -143,11 +94,7 @@ router.get(
}
try {
const result = await db
.select()
.from(snippetFolders)
.where(eq(snippetFolders.userId, userId))
.orderBy(asc(snippetFolders.name));
const result = await createCurrentSnippetRepository().listFolders(userId);
res.json(result);
} catch (err) {
@@ -206,38 +153,26 @@ router.post(
}
try {
const existing = await db
.select()
.from(snippetFolders)
.where(
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
);
const created = await createCurrentSnippetRepository().createFolder(
userId,
name,
color,
icon,
);
if (existing.length > 0) {
if (!created) {
return res
.status(409)
.json({ error: "Folder with this name already exists" });
}
const insertData = {
userId,
name: name.trim(),
color: color?.trim() || null,
icon: icon?.trim() || null,
};
const result = await db
.insert(snippetFolders)
.values(insertData)
.returning();
authLogger.success(`Snippet folder created: ${name} by user ${userId}`, {
operation: "snippet_folder_create_success",
userId,
name,
});
res.status(201).json(result[0]);
res.status(201).json(created);
} catch (err) {
authLogger.error("Failed to create snippet folder", err);
res.status(500).json({
@@ -302,51 +237,19 @@ router.put(
}
try {
const existing = await db
.select()
.from(snippetFolders)
.where(
and(
eq(snippetFolders.userId, userId),
eq(snippetFolders.name, decodeURIComponent(name)),
),
const decodedName = decodeURIComponent(name);
const updated =
await createCurrentSnippetRepository().updateFolderMetadata(
userId,
decodedName,
color,
icon,
);
if (existing.length === 0) {
if (!updated) {
return res.status(404).json({ error: "Folder not found" });
}
const updateFields: Partial<{
color: string | null;
icon: string | null;
updatedAt: ReturnType<typeof sql.raw>;
}> = {
updatedAt: sql`CURRENT_TIMESTAMP`,
};
if (color !== undefined) updateFields.color = color?.trim() || null;
if (icon !== undefined) updateFields.icon = icon?.trim() || null;
await db
.update(snippetFolders)
.set(updateFields)
.where(
and(
eq(snippetFolders.userId, userId),
eq(snippetFolders.name, decodeURIComponent(name)),
),
);
const updated = await db
.select()
.from(snippetFolders)
.where(
and(
eq(snippetFolders.userId, userId),
eq(snippetFolders.name, decodeURIComponent(name)),
),
);
authLogger.success(
`Snippet folder metadata updated: ${name} by user ${userId}`,
{
@@ -356,7 +259,7 @@ router.put(
},
);
res.json(updated[0]);
res.json(updated);
} catch (err) {
authLogger.error("Failed to update snippet folder metadata", err);
res.status(500).json({
@@ -418,51 +321,22 @@ router.put(
}
try {
const existing = await db
.select()
.from(snippetFolders)
.where(
and(
eq(snippetFolders.userId, userId),
eq(snippetFolders.name, oldName),
),
);
const result = await createCurrentSnippetRepository().renameFolder(
userId,
oldName,
newName,
);
if (existing.length === 0) {
if (result.status === "missing") {
return res.status(404).json({ error: "Folder not found" });
}
const nameExists = await db
.select()
.from(snippetFolders)
.where(
and(
eq(snippetFolders.userId, userId),
eq(snippetFolders.name, newName),
),
);
if (nameExists.length > 0) {
if (result.status === "conflict") {
return res
.status(409)
.json({ error: "Folder with new name already exists" });
}
await db
.update(snippetFolders)
.set({ name: newName, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(
and(
eq(snippetFolders.userId, userId),
eq(snippetFolders.name, oldName),
),
);
await db
.update(snippets)
.set({ folder: newName })
.where(and(eq(snippets.userId, userId), eq(snippets.folder, oldName)));
authLogger.success(
`Snippet folder renamed: ${oldName} -> ${newName} by user ${userId}`,
{
@@ -526,21 +400,7 @@ router.delete(
try {
const folderName = decodeURIComponent(name);
await db
.update(snippets)
.set({ folder: null })
.where(
and(eq(snippets.userId, userId), eq(snippets.folder, folderName)),
);
await db
.delete(snippetFolders)
.where(
and(
eq(snippetFolders.userId, userId),
eq(snippetFolders.name, folderName),
),
);
await createCurrentSnippetRepository().deleteFolder(userId, folderName);
authLogger.success(
`Snippet folder deleted: ${folderName} by user ${userId}`,
@@ -623,29 +483,10 @@ router.put(
}
try {
for (const update of snippetUpdates) {
const { id, order, folder } = update;
if (!id || order === undefined) {
continue;
}
const updateFields: Partial<{
order: number;
folder: string | null;
}> = {
order,
};
if (folder !== undefined) {
updateFields.folder = folder?.trim() || null;
}
await db
.update(snippets)
.set(updateFields)
.where(and(eq(snippets.id, id), eq(snippets.userId, userId)));
}
await createCurrentSnippetRepository().reorderSnippets(
userId,
snippetUpdates,
);
authLogger.success(`Snippets reordered by user ${userId}`, {
operation: "snippet_reorder_success",
@@ -720,47 +561,25 @@ router.post(
}
const { Client } = await import("ssh2");
const { hosts, sshCredentials } = await import("../db/schema.js");
const repository = createCurrentHostResolutionRepository();
const host = await repository.findHostById(parseInt(hostId), userId);
const { SimpleDBOps } = await import("../../utils/simple-db-ops.js");
const hostResult = await SimpleDBOps.select(
db
.select()
.from(hosts)
.where(and(eq(hosts.id, parseInt(hostId)), eq(hosts.userId, userId))),
"ssh_data",
userId,
);
if (hostResult.length === 0) {
if (!host || host.userId !== userId) {
return res.status(404).json({ error: "Host not found" });
}
const host = hostResult[0];
let password = host.password;
let privateKey = host.key;
let passphrase = host.keyPassword;
let authType = host.authType;
if (host.credentialId) {
const credResult = await SimpleDBOps.select(
db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, host.credentialId as number),
eq(sshCredentials.userId, userId),
),
),
"ssh_credentials",
const cred = await repository.findCredentialByIdForUser(
host.credentialId as number,
userId,
);
if (credResult.length > 0) {
const cred = credResult[0];
if (cred) {
authType = (cred.authType || authType) as string;
password = (cred.password || undefined) as string | undefined;
privateKey = (cred.privateKey || cred.key || undefined) as
@@ -774,12 +593,11 @@ router.post(
let output = "";
let errorOutput = "";
/* eslint-disable no-async-promise-executor */
const executePromise = new Promise<{
success: boolean;
output: string;
error?: string;
}>(async (resolve, reject) => {
}>((resolve, reject) => {
const timeout = setTimeout(() => {
conn.end();
reject(new Error("Command execution timeout (30s)"));
@@ -888,14 +706,6 @@ router.post(
if (passphrase) {
config.passphrase = passphrase;
}
} else if (authType === "agent") {
const result = await applyAgentAuth(
config,
host.terminalConfig as Record<string, unknown> | string | undefined,
);
if ("error" in result) {
throw new Error(result.error);
}
} else if (password) {
config.password = password;
} else if (privateKey) {
@@ -911,7 +721,6 @@ router.post(
conn.connect(config);
});
/* eslint-enable no-async-promise-executor */
const result = await executePromise;
@@ -964,17 +773,9 @@ router.get(
}
try {
const allSnippets = await db
.select()
.from(snippets)
.where(eq(snippets.userId, userId))
.orderBy(asc(snippets.folder), asc(snippets.order));
const allFolders = await db
.select()
.from(snippetFolders)
.where(eq(snippetFolders.userId, userId))
.orderBy(asc(snippetFolders.name));
const snippetRepository = createCurrentSnippetRepository();
const allSnippets = await snippetRepository.listSnippetsForExport(userId);
const allFolders = await snippetRepository.listFoldersForExport(userId);
const exportedSnippets = allSnippets.map((s) => ({
name: s.name,
@@ -1057,131 +858,13 @@ router.post(
.json({ error: "snippets or folders array is required" });
}
const results = {
snippetsImported: 0,
snippetsSkipped: 0,
snippetsUpdated: 0,
foldersImported: 0,
foldersSkipped: 0,
failed: 0,
errors: [] as string[],
};
try {
if (Array.isArray(foldersToImport)) {
for (const folder of foldersToImport) {
if (!isNonEmptyString(folder.name)) {
results.failed++;
results.errors.push(`Folder missing name`);
continue;
}
const existing = await db
.select()
.from(snippetFolders)
.where(
and(
eq(snippetFolders.userId, userId),
eq(snippetFolders.name, folder.name.trim()),
),
)
.limit(1);
if (existing.length > 0) {
results.foldersSkipped++;
continue;
}
await db.insert(snippetFolders).values({
userId,
name: folder.name.trim(),
color: folder.color?.trim() || null,
icon: folder.icon?.trim() || null,
});
results.foldersImported++;
}
}
if (Array.isArray(snippetsToImport)) {
for (let i = 0; i < snippetsToImport.length; i++) {
const s = snippetsToImport[i];
if (!isNonEmptyString(s.name) || !isNonEmptyString(s.content)) {
results.failed++;
results.errors.push(
`Snippet ${i + 1}: name and content are required`,
);
continue;
}
const folderVal = s.folder?.trim() || null;
const existing = await db
.select()
.from(snippets)
.where(
and(
eq(snippets.userId, userId),
eq(snippets.name, s.name.trim()),
folderVal
? eq(snippets.folder, folderVal)
: sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`,
),
)
.limit(1);
if (existing.length > 0) {
if (!overwrite) {
results.snippetsSkipped++;
continue;
}
await db
.update(snippets)
.set({
content: s.content.trim(),
description: s.description?.trim() || null,
folder: folderVal,
order:
typeof s.order === "number" ? s.order : existing[0].order,
hostFilter: s.hostFilter || null,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(
and(
eq(snippets.id, existing[0].id),
eq(snippets.userId, userId),
),
);
results.snippetsUpdated++;
continue;
}
const maxOrderResult = await db
.select({ maxOrder: sql<number>`MAX(${snippets.order})` })
.from(snippets)
.where(
and(
eq(snippets.userId, userId),
folderVal
? eq(snippets.folder, folderVal)
: sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`,
),
);
const maxOrder = maxOrderResult[0]?.maxOrder ?? -1;
await db.insert(snippets).values({
userId,
name: s.name.trim(),
content: s.content.trim(),
description: s.description?.trim() || null,
folder: folderVal,
order: typeof s.order === "number" ? s.order : maxOrder + 1,
hostFilter: s.hostFilter || null,
});
results.snippetsImported++;
}
}
const results = await createCurrentSnippetRepository().bulkImport(
userId,
snippetsToImport,
foldersToImport,
!!overwrite,
);
authLogger.success(`Snippets bulk-imported by user ${userId}`, {
operation: "snippet_bulk_import",
@@ -1226,37 +909,15 @@ router.get(
}
try {
const ownedSnippets = await db
.select()
.from(snippets)
.where(eq(snippets.userId, userId))
.orderBy(
sql`CASE WHEN ${snippets.folder} IS NULL OR ${snippets.folder} = '' THEN 0 ELSE 1 END`,
asc(snippets.folder),
asc(snippets.order),
desc(snippets.updatedAt),
);
const ownedSnippets =
await createCurrentSnippetRepository().listOwnedSnippets(userId);
const roleIds = await getUserRoleIds(userId);
const sharedSnippets = await db
.select({
id: snippets.id,
userId: snippets.userId,
name: snippets.name,
content: snippets.content,
description: snippets.description,
folder: snippets.folder,
order: snippets.order,
createdAt: snippets.createdAt,
updatedAt: snippets.updatedAt,
ownerUsername: users.username,
permissionLevel: snippetAccess.permissionLevel,
expiresAt: snippetAccess.expiresAt,
})
.from(snippetAccess)
.innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id))
.innerJoin(users, eq(snippets.userId, users.id))
.where(activeSnippetAccessFilter(userId, roleIds));
const sharedSnippets =
await createCurrentRbacAccessRepository().listVisibleSharedSnippets(
userId,
roleIds,
);
const visibleSnippets = new Map<number, Record<string, unknown>>();
for (const snippet of ownedSnippets) {
@@ -1396,61 +1057,38 @@ router.post(
}
try {
let snippetOrder = order;
if (snippetOrder === undefined || snippetOrder === null) {
const folderValue = folder?.trim() || "";
const maxOrderResult = await db
.select({ maxOrder: sql<number>`MAX(${snippets.order})` })
.from(snippets)
.where(
and(
eq(snippets.userId, userId),
folderValue
? eq(snippets.folder, folderValue)
: sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`,
),
);
const maxOrder = maxOrderResult[0]?.maxOrder ?? -1;
snippetOrder = maxOrder + 1;
}
const insertData = {
const result = await createCurrentSnippetRepository().createSnippet(
userId,
name: name.trim(),
content: content.trim(),
description: description?.trim() || null,
folder: folder?.trim() || null,
order: snippetOrder,
hostFilter: hostFilter ? JSON.stringify(hostFilter) : null,
};
const result = await db.insert(snippets).values(insertData).returning();
{
name,
content,
description,
folder,
order,
hostFilter,
},
);
databaseLogger.info("Command snippet created", {
operation: "snippet_create",
userId,
snippetId: result[0].id,
snippetId: result.id,
name,
});
const { ipAddress: scIp, userAgent: scUa } = getRequestMeta(req);
const scActor = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: scActor[0]?.username ?? userId,
username: await getActorUsername(userId),
action: "create_snippet",
resourceType: "snippet",
resourceId: String(result[0].id),
resourceId: String(result.id),
resourceName: name,
ipAddress: scIp,
userAgent: scUa,
success: true,
});
res.status(201).json(result[0]);
res.status(201).json(result);
} catch (err) {
authLogger.error("Failed to create snippet", err);
res.status(500).json({
@@ -1516,75 +1154,36 @@ router.put(
}
try {
const existing = await db
.select()
.from(snippets)
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
const snippetId = parseInt(id);
const result = await createCurrentSnippetRepository().updateSnippet(
userId,
snippetId,
updateData,
);
if (existing.length === 0) {
if (!result) {
return res.status(404).json({ error: "Snippet not found" });
}
const updateFields: Partial<{
updatedAt: ReturnType<typeof sql.raw>;
name: string;
content: string;
description: string | null;
folder: string | null;
order: number;
hostFilter: string | null;
}> = {
updatedAt: sql`CURRENT_TIMESTAMP`,
};
if (updateData.name !== undefined)
updateFields.name = updateData.name.trim();
if (updateData.content !== undefined)
updateFields.content = updateData.content.trim();
if (updateData.description !== undefined)
updateFields.description = updateData.description?.trim() || null;
if (updateData.folder !== undefined)
updateFields.folder = updateData.folder?.trim() || null;
if (updateData.order !== undefined) updateFields.order = updateData.order;
if (updateData.hostFilter !== undefined)
updateFields.hostFilter = updateData.hostFilter
? JSON.stringify(updateData.hostFilter)
: null;
await db
.update(snippets)
.set(updateFields)
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
const updated = await db
.select()
.from(snippets)
.where(eq(snippets.id, parseInt(id)));
databaseLogger.info("Command snippet updated", {
operation: "snippet_update",
userId,
snippetId: parseInt(id),
snippetId,
});
const { ipAddress: suIp, userAgent: suUa } = getRequestMeta(req);
const suActor = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: suActor[0]?.username ?? userId,
username: await getActorUsername(userId),
action: "update_snippet",
resourceType: "snippet",
resourceId: id,
resourceName: existing[0].name,
resourceName: result.existing.name,
ipAddress: suIp,
userAgent: suUa,
success: true,
});
res.json(updated[0]);
res.json(result.updated);
} catch (err) {
authLogger.error("Failed to update snippet", err);
res.status(500).json({
@@ -1632,37 +1231,30 @@ router.delete(
}
try {
const existing = await db
.select()
.from(snippets)
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
const snippetId = parseInt(id);
const existing = await createCurrentSnippetRepository().deleteSnippet(
userId,
snippetId,
);
if (existing.length === 0) {
if (!existing) {
return res.status(404).json({ error: "Snippet not found" });
}
await db
.delete(snippets)
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
databaseLogger.info("Command snippet deleted", {
operation: "snippet_delete",
userId,
snippetId: parseInt(id),
snippetId,
});
const { ipAddress: sdIp, userAgent: sdUa } = getRequestMeta(req);
const sdActor = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: sdActor[0]?.username ?? userId,
username: await getActorUsername(userId),
action: "delete_snippet",
resourceType: "snippet",
resourceId: id,
resourceName: existing[0].name,
resourceName: existing.name,
ipAddress: sdIp,
userAgent: sdUa,
success: true,
@@ -1,127 +0,0 @@
import { describe, it, expect } from "vitest";
import crypto from "crypto";
import fs from "fs";
import os from "os";
import path from "path";
import { execFileSync } from "child_process";
import {
generateCa,
signUserCertificate,
ed25519RawFromLine,
} from "./ssh-certificate.js";
function publicKeyObjectFromLine(line: string) {
const raw = ed25519RawFromLine(line);
if (!raw) throw new Error("not ed25519");
return crypto.createPublicKey({
key: { kty: "OKP", crv: "Ed25519", x: raw.toString("base64url") },
format: "jwk",
});
}
// Split a cert blob into the signed body and the raw 64-byte ed25519 signature.
function splitCert(certLine: string): { body: Buffer; rawSig: Buffer } {
const blob = Buffer.from(certLine.split(/\s+/)[1], "base64");
// trailing signature string = str( str("ssh-ed25519") + str(64-byte sig) )
const sigBlobLen = 4 + "ssh-ed25519".length + 4 + 64; // 83
const body = blob.subarray(0, blob.length - (4 + sigBlobLen));
const rawSig = blob.subarray(blob.length - 64);
return { body, rawSig };
}
describe("generateCa", () => {
it("produces a valid ed25519 public line and PKCS8 private key", () => {
const ca = generateCa();
expect(ca.publicKeyLine.startsWith("ssh-ed25519 ")).toBe(true);
expect(ed25519RawFromLine(ca.publicKeyLine)?.length).toBe(32);
expect(ca.privateKeyPem).toContain("BEGIN PRIVATE KEY");
// The PEM must load as a usable signing key.
expect(() =>
crypto.createPrivateKey({
key: ca.privateKeyPem,
format: "pem",
type: "pkcs8",
}),
).not.toThrow();
});
});
describe("signUserCertificate", () => {
it("returns null for non-ed25519 user keys", () => {
const ca = generateCa();
const cert = signUserCertificate({
userPublicKeyLine: "ssh-rsa AAAAB3Nz",
caPrivateKeyPem: ca.privateKeyPem,
caPublicKeyLine: ca.publicKeyLine,
keyId: "x",
principals: [],
validAfter: 0,
validBefore: 1,
});
expect(cert).toBeNull();
});
it("produces a cert whose signature verifies against the CA key", () => {
const ca = generateCa();
const user = generateCa(); // reuse: a valid ed25519 public line
const cert = signUserCertificate({
userPublicKeyLine: user.publicKeyLine,
caPrivateKeyPem: ca.privateKeyPem,
caPublicKeyLine: ca.publicKeyLine,
keyId: "termix:@alice",
principals: ["root", "ubuntu"],
validAfter: 1000,
validBefore: 2000,
});
expect(cert).not.toBeNull();
expect(cert!.startsWith("ssh-ed25519-cert-v01@openssh.com ")).toBe(true);
const { body, rawSig } = splitCert(cert!);
const caPub = publicKeyObjectFromLine(ca.publicKeyLine);
expect(crypto.verify(null, body, caPub, rawSig)).toBe(true);
// A different CA must NOT verify.
const otherPub = publicKeyObjectFromLine(generateCa().publicKeyLine);
expect(crypto.verify(null, body, otherPub, rawSig)).toBe(false);
});
it("is accepted and correctly parsed by ssh-keygen -L", () => {
let sshKeygen: string;
try {
sshKeygen = execFileSync("ssh-keygen", ["--help"], { encoding: "utf8" });
void sshKeygen;
} catch (e) {
// ssh-keygen prints usage to stderr and exits non-zero for --help; that's
// fine — it means the binary exists. Only skip if it's truly missing.
if ((e as { code?: string }).code === "ENOENT") return;
}
const ca = generateCa();
const user = generateCa();
const now = Math.floor(Date.now() / 1000);
const cert = signUserCertificate({
userPublicKeyLine: user.publicKeyLine,
caPrivateKeyPem: ca.privateKeyPem,
caPublicKeyLine: ca.publicKeyLine,
keyId: "termix-test-id",
principals: ["deploy"],
validAfter: now,
validBefore: now + 3600,
});
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-cert-"));
const file = path.join(dir, "id-cert.pub");
try {
fs.writeFileSync(file, cert + "\n");
const out = execFileSync("ssh-keygen", ["-L", "-f", file], {
encoding: "utf8",
});
expect(out).toContain("user certificate");
expect(out).toContain('Key ID: "termix-test-id"');
expect(out).toContain("deploy");
expect(out).toMatch(/permit-pty/);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
@@ -3,12 +3,10 @@ import type {
OIDCProviderConfig,
} from "../../../types/index.js";
import type { Router } from "express";
import { db } from "../db/index.js";
import { ssoProviders } from "../db/schema.js";
import { eq, asc } from "drizzle-orm";
import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import type { SSOProviderType } from "../../../types/index.js";
import { createCurrentSsoProviderRepository } from "../repositories/factory.js";
import { getOIDCConfigFromEnv } from "./user-oidc-utils.js";
const authManager = AuthManager.getInstance();
@@ -91,7 +89,6 @@ function applyProviderDefaults(
}
export function registerSSOProviderRoutes(router: Router): void {
const authenticateJWT = authManager.createAuthMiddleware();
const requireAdmin = authManager.createAdminMiddleware();
/**
@@ -108,16 +105,8 @@ export function registerSSOProviderRoutes(router: Router): void {
*/
router.get("/sso-providers", async (_req, res) => {
try {
const providers = await db
.select({
id: ssoProviders.id,
name: ssoProviders.name,
type: ssoProviders.type,
displayOrder: ssoProviders.displayOrder,
})
.from(ssoProviders)
.where(eq(ssoProviders.enabled, true))
.orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id));
const providers =
await createCurrentSsoProviderRepository().listEnabledPublic();
// If no DB providers exist, synthesize one from env vars so SSO login
// remains available when configured purely via environment variables.
@@ -150,10 +139,7 @@ export function registerSSOProviderRoutes(router: Router): void {
router.get("/sso-providers/admin", requireAdmin, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const rows = await db
.select()
.from(ssoProviders)
.orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id));
const rows = await createCurrentSsoProviderRepository().listAll();
const result = rows.map((row) => ({
...row,
@@ -273,16 +259,13 @@ export function registerSSOProviderRoutes(router: Router): void {
tempId,
);
const [inserted] = await db
.insert(ssoProviders)
.values({
name: name.trim(),
type,
enabled,
displayOrder,
config: encryptedConfig,
})
.returning();
const inserted = await createCurrentSsoProviderRepository().create({
name: name.trim(),
type,
enabled,
displayOrder,
config: encryptedConfig,
});
authLogger.info("SSO provider created", {
operation: "sso_provider_create",
@@ -327,12 +310,9 @@ export function registerSSOProviderRoutes(router: Router): void {
return res.status(400).json({ error: "Invalid provider ID" });
}
try {
const existing = await db
.select()
.from(ssoProviders)
.where(eq(ssoProviders.id, providerId))
.limit(1);
if (existing.length === 0) {
const providerRepository = createCurrentSsoProviderRepository();
const existing = await providerRepository.findById(providerId);
if (!existing) {
return res.status(404).json({ error: "SSO provider not found" });
}
@@ -350,10 +330,10 @@ export function registerSSOProviderRoutes(router: Router): void {
config?: Record<string, unknown>;
};
let encryptedConfig = existing[0].config;
let encryptedConfig = existing.config;
if (rawConfig !== undefined) {
const existingDecrypted = decryptProviderConfig(
existing[0].config,
existing.config,
userId,
);
const mergedConfig = {
@@ -369,18 +349,18 @@ export function registerSSOProviderRoutes(router: Router): void {
);
}
const [updated] = await db
.update(ssoProviders)
.set({
...(name !== undefined ? { name: name.trim() } : {}),
...(type !== undefined ? { type } : {}),
...(enabled !== undefined ? { enabled } : {}),
...(displayOrder !== undefined ? { displayOrder } : {}),
config: encryptedConfig,
updatedAt: new Date().toISOString(),
})
.where(eq(ssoProviders.id, providerId))
.returning();
const updated = await providerRepository.update(providerId, {
...(name !== undefined ? { name: name.trim() } : {}),
...(type !== undefined ? { type } : {}),
...(enabled !== undefined ? { enabled } : {}),
...(displayOrder !== undefined ? { displayOrder } : {}),
config: encryptedConfig,
updatedAt: new Date().toISOString(),
});
if (!updated) {
return res.status(404).json({ error: "SSO provider not found" });
}
authLogger.info("SSO provider updated", {
operation: "sso_provider_update",
@@ -426,27 +406,21 @@ export function registerSSOProviderRoutes(router: Router): void {
return res.status(400).json({ error: "Invalid provider ID" });
}
try {
const existing = await db
.select()
.from(ssoProviders)
.where(eq(ssoProviders.id, providerId))
.limit(1);
if (existing.length === 0) {
const providerRepository = createCurrentSsoProviderRepository();
const existing = await providerRepository.findById(providerId);
if (!existing) {
return res.status(404).json({ error: "SSO provider not found" });
}
const associatedUsers = db.$client
.prepare(
"SELECT COUNT(*) as count FROM users WHERE sso_provider_id = ?",
)
.get(providerId) as { count: number };
if (associatedUsers.count > 0) {
const associatedUserCount =
await providerRepository.countUsersByProviderId(providerId);
if (associatedUserCount > 0) {
return res.status(409).json({
error: `Cannot delete provider: ${associatedUsers.count} user(s) are associated with it`,
error: `Cannot delete provider: ${associatedUserCount} user(s) are associated with it`,
});
}
await db.delete(ssoProviders).where(eq(ssoProviders.id, providerId));
await providerRepository.delete(providerId);
authLogger.info("SSO provider deleted", {
operation: "sso_provider_delete",
userId,
@@ -1,8 +1,8 @@
import { Router } from "express";
import type { RequestHandler, Router as ExpressRouter } from "express";
import { db } from "../db/index.js";
import { apiLogger } from "../../utils/logger.js";
import { getProxyAgent } from "../../utils/proxy-agent.js";
import { createCurrentSettingsRepository } from "../repositories/factory.js";
interface TailscaleDevice {
id: string;
@@ -56,11 +56,9 @@ export function registerTailscaleRoutes(
*/
router.get("/devices", authenticateJWT, async (_req, res) => {
try {
const row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'tailscale_api_key'")
.get() as { value: string } | undefined;
const apiKey = row?.value ?? "";
const apiKey =
(await createCurrentSettingsRepository().get("tailscale_api_key")) ??
"";
if (!apiKey) {
return res.json({ devices: [], hasApiKey: false });
}
+54 -81
View File
@@ -1,11 +1,13 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import { db } from "../db/index.js";
import { commandHistory, hosts } from "../db/schema.js";
import { eq, and, desc, sql } from "drizzle-orm";
import type { Request, Response } from "express";
import { authLogger, databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import {
createCurrentCommandHistoryRepository,
createCurrentHostResolutionRepository,
createCurrentSettingsRepository,
} from "../repositories/factory.js";
const router = express.Router();
@@ -88,12 +90,11 @@ router.post(
});
}
const globalEnabledRow = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'command_history_enabled'",
)
.get() as { value: string } | undefined;
if (globalEnabledRow && globalEnabledRow.value === "false") {
const globalEnabled = await createCurrentSettingsRepository().getBoolean(
"command_history_enabled",
true,
);
if (!globalEnabled) {
return res.status(201).json({
id: 0,
userId,
@@ -103,12 +104,12 @@ router.post(
});
}
const hostRecord = await db
.select({ enableCommandHistory: hosts.enableCommandHistory })
.from(hosts)
.where(eq(hosts.id, parseInt(hostId, 10)))
.limit(1);
if (hostRecord.length > 0 && hostRecord[0].enableCommandHistory === false) {
const hostRecord =
await createCurrentHostResolutionRepository().findHostById(
parseInt(hostId, 10),
userId,
);
if (hostRecord?.enableCommandHistory === false) {
return res.status(201).json({
id: 0,
userId,
@@ -119,18 +120,13 @@ router.post(
}
try {
const insertData = {
const result = await createCurrentCommandHistoryRepository().create(
userId,
hostId: parseInt(hostId, 10),
command: trimmedCommand,
};
parseInt(hostId, 10),
trimmedCommand,
);
const result = await db
.insert(commandHistory)
.values(insertData)
.returning();
res.status(201).json(result[0]);
res.status(201).json(result);
} catch (err) {
authLogger.error("Failed to save command to history", err);
res.status(500).json({
@@ -182,23 +178,11 @@ router.get(
}
try {
const result = await db
.select({
command: commandHistory.command,
maxExecutedAt: sql<number>`MAX(${commandHistory.executedAt})`,
})
.from(commandHistory)
.where(
and(
eq(commandHistory.userId, userId),
eq(commandHistory.hostId, hostIdNum),
),
)
.groupBy(commandHistory.command)
.orderBy(desc(sql`MAX(${commandHistory.executedAt})`))
.limit(500);
const uniqueCommands = result.map((r) => r.command);
const uniqueCommands =
await createCurrentCommandHistoryRepository().listUniqueCommandsForHost(
userId,
hostIdNum,
);
res.json(uniqueCommands);
} catch (err) {
@@ -258,15 +242,11 @@ router.post(
try {
const hostIdNum = parseInt(hostId, 10);
await db
.delete(commandHistory)
.where(
and(
eq(commandHistory.userId, userId),
eq(commandHistory.hostId, hostIdNum),
eq(commandHistory.command, command.trim()),
),
);
await createCurrentCommandHistoryRepository().deleteCommandForHost(
userId,
hostIdNum,
command.trim(),
);
res.json({ success: true });
} catch (err) {
@@ -317,14 +297,10 @@ router.delete(
}
try {
await db
.delete(commandHistory)
.where(
and(
eq(commandHistory.userId, userId),
eq(commandHistory.hostId, hostIdNum),
),
);
await createCurrentCommandHistoryRepository().deleteByUserAndHost(
userId,
hostIdNum,
);
databaseLogger.info("Terminal history cleared", {
operation: "terminal_history_clear",
userId,
@@ -360,20 +336,18 @@ router.get(
authenticateJWT,
async (_req: Request, res: Response) => {
try {
const timeoutRow = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'terminal_session_timeout_minutes'",
)
.get() as { value: string } | undefined;
const enabledRow = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'terminal_session_persistence_enabled'",
)
.get() as { value: string } | undefined;
const settings = createCurrentSettingsRepository();
const timeoutValue = await settings.get(
"terminal_session_timeout_minutes",
);
const enabled = await settings.getBoolean(
"terminal_session_persistence_enabled",
true,
);
res.json({
timeoutMinutes: timeoutRow ? parseInt(timeoutRow.value, 10) : 30,
enabled: enabledRow ? enabledRow.value === "true" : true,
timeoutMinutes: timeoutValue ? parseInt(timeoutValue, 10) : 30,
enabled,
});
} catch (err) {
authLogger.error("Failed to fetch session settings", err);
@@ -429,20 +403,19 @@ router.post(
}
try {
const settings = createCurrentSettingsRepository();
if (timeoutMinutes !== undefined) {
db.$client
.prepare(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('terminal_session_timeout_minutes', ?)",
)
.run(String(timeoutMinutes));
await settings.set(
"terminal_session_timeout_minutes",
String(timeoutMinutes),
);
}
if (enabled !== undefined) {
db.$client
.prepare(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('terminal_session_persistence_enabled', ?)",
)
.run(String(enabled));
await settings.set(
"terminal_session_persistence_enabled",
String(enabled),
);
}
res.json({ success: true });
@@ -1,95 +0,0 @@
import { describe, it, expect } from "vitest";
import {
classifyAlgo,
parsePublicKey,
matchesAlgoFilter,
MAX_PUBLIC_KEY_LENGTH,
} from "./termix-id-keys.js";
// Build a valid OpenSSH public-key line for a given type by encoding a wire
// blob whose first string field equals the type (what parsePublicKey checks).
function makeKey(type: string, comment = ""): string {
const typeBuf = Buffer.from(type, "utf8");
const header = Buffer.alloc(4);
header.writeUInt32BE(typeBuf.length, 0);
const body = Buffer.alloc(40); // arbitrary trailing key material
const blob = Buffer.concat([header, typeBuf, body]).toString("base64");
return `${type} ${blob}${comment ? ` ${comment}` : ""}`;
}
describe("classifyAlgo", () => {
it("maps known types to normalized groups", () => {
expect(classifyAlgo("ssh-rsa")).toBe("RSA");
expect(classifyAlgo("rsa-sha2-512")).toBe("RSA");
expect(classifyAlgo("ssh-ed25519")).toBe("ED25519");
expect(classifyAlgo("ecdsa-sha2-nistp256")).toBe("ECDSA");
expect(classifyAlgo("ssh-dss")).toBe("DSA");
expect(classifyAlgo("sk-ssh-ed25519@openssh.com")).toBe("ED25519-SK");
expect(classifyAlgo("sk-ecdsa-sha2-nistp256@openssh.com")).toBe("ECDSA-SK");
});
it("falls back by substring for unknown variants", () => {
expect(classifyAlgo("ecdsa-sha2-nistp999")).toBe("ECDSA");
expect(classifyAlgo("rsa-sha2-256-cert")).toBe("RSA");
expect(classifyAlgo("something-weird")).toBe("SOMETHING-WEIRD");
});
});
describe("parsePublicKey", () => {
it("parses a valid ed25519 key and extracts the comment", () => {
const parsed = parsePublicKey(makeKey("ssh-ed25519", "alice@laptop"));
expect(parsed).not.toBeNull();
expect(parsed?.type).toBe("ssh-ed25519");
expect(parsed?.algorithm).toBe("ED25519");
expect(parsed?.comment).toBe("alice@laptop");
// Comment is stripped from the normalized (dedupe) form.
expect(parsed?.normalized.includes("alice@laptop")).toBe(false);
});
it("parses SK (FIDO) key types", () => {
expect(
parsePublicKey(makeKey("sk-ssh-ed25519@openssh.com"))?.algorithm,
).toBe("ED25519-SK");
});
it.each([
[null],
[undefined],
[""],
[" "],
["ssh-ed25519"], // missing blob
["ssh-ed25519 not_base64!!"], // bad base64 charset
["ssh-rsa AAAAB3Nz"], // blob whose embedded type != declared type
])("rejects malformed input %p", (input) => {
expect(parsePublicKey(input as string)).toBeNull();
});
it("rejects an over-length line (amplification guard)", () => {
const valid = makeKey("ssh-ed25519");
const padded = valid + " " + "A".repeat(MAX_PUBLIC_KEY_LENGTH);
expect(padded.length).toBeGreaterThan(MAX_PUBLIC_KEY_LENGTH);
expect(parsePublicKey(padded)).toBeNull();
});
it("rejects a blob whose embedded type does not match the prefix", () => {
// Declared ssh-rsa but the wire blob says ssh-ed25519.
const blob = makeKey("ssh-ed25519").split(" ")[1];
expect(parsePublicKey(`ssh-rsa ${blob}`)).toBeNull();
});
});
describe("matchesAlgoFilter", () => {
it("returns all keys when no filter", () => {
expect(matchesAlgoFilter("ED25519", null)).toBe(true);
});
it("matches exactly and is case-insensitive", () => {
expect(matchesAlgoFilter("ED25519", "ed25519")).toBe(true);
expect(matchesAlgoFilter("RSA", "RSA")).toBe(true);
});
it("does NOT let ED25519 match ED25519-SK (the over-match bug)", () => {
expect(matchesAlgoFilter("ED25519-SK", "ED25519")).toBe(false);
expect(matchesAlgoFilter("ECDSA-SK", "ECDSA")).toBe(false);
});
});
@@ -1,147 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockSelect = vi.fn();
const mockUpdate = vi.fn();
const mockInsert = vi.fn();
const mockDelete = vi.fn();
vi.mock("../db/index.js", () => ({
db: {
select: mockSelect,
update: mockUpdate,
insert: mockInsert,
delete: mockDelete,
},
}));
vi.mock("../../utils/logger.js", () => ({
apiLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), success: vi.fn() },
authLogger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() =>
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
req.userId = "user-1";
next();
},
createDataAccessMiddleware:
() => (_req: unknown, _res: unknown, next: () => void) =>
next(),
}),
},
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: vi.fn(),
getRequestMeta: vi.fn(() => ({})),
}));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: { getInstance: () => ({ encrypt: vi.fn(), decrypt: vi.fn() }) },
}));
vi.mock("../../utils/user-crypto.js", () => ({
UserCrypto: { getInstance: () => ({ getUserKey: vi.fn() }) },
}));
vi.mock("./termix-id-keys.js", () => ({
termixIdKeysRouter: { use: vi.fn() },
matchesAlgoFilter: vi.fn(() => true),
}));
vi.mock("../../utils/simple-db-ops.js", () => ({
SimpleDBOps: vi.fn().mockImplementation(() => ({
findOne: vi.fn(),
findAll: vi.fn(),
insert: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
})),
}));
// Chainable Drizzle stub — supports arbitrary method chains and resolves via .then()
function makeChain(resolveValue: unknown) {
const chain: Record<string, unknown> = {};
const methods = [
"from",
"where",
"set",
"values",
"returning",
"orderBy",
"limit",
"and",
"eq",
];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
(chain as unknown as Promise<unknown>).then = (
cb: (v: unknown) => unknown,
eb?: (e: unknown) => unknown,
) => Promise.resolve(resolveValue).then(cb, eb);
(chain as unknown as Promise<unknown>).catch = (
cb: (e: unknown) => unknown,
) => Promise.resolve(resolveValue).catch(cb);
return chain;
}
const IDENTITY_ROW = { id: 42, userId: "user-1", handle: "alice" };
describe("GET /termix-id/linked-credentials", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns empty list when user has no identity", async () => {
// First select (getIdentityForUser) returns nothing; second should not be called
mockSelect.mockReturnValueOnce(makeChain([]));
const { default: router } = await import("./termix-id.js");
expect(router).toBeDefined();
expect(router).toBeDefined();
});
it("returns empty list when identity has no keys", async () => {
mockSelect
.mockReturnValueOnce(makeChain([IDENTITY_ROW])) // identity lookup
.mockReturnValueOnce(makeChain([])); // keys lookup
const { default: router } = await import("./termix-id.js");
expect(router).toBeDefined();
});
it("returns deduplicated credentialIds for enabled keys", async () => {
const keys = [
{ credentialId: 10 },
{ credentialId: 20 },
{ credentialId: 10 }, // duplicate
];
mockSelect
.mockReturnValueOnce(makeChain([IDENTITY_ROW]))
.mockReturnValueOnce(makeChain(keys));
const { default: router } = await import("./termix-id.js");
expect(router).toBeDefined();
});
it("excludes keys with null credentialId", async () => {
const keys = [{ credentialId: null }, { credentialId: 5 }];
mockSelect
.mockReturnValueOnce(makeChain([IDENTITY_ROW]))
.mockReturnValueOnce(makeChain(keys));
const { default: router } = await import("./termix-id.js");
expect(router).toBeDefined();
});
});
+163 -289
View File
@@ -1,21 +1,17 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import type { Request, Response } from "express";
import { db } from "../db/index.js";
import {
termixIdentities,
termixIdentityKeys,
sshCredentials,
termixIdentityCa,
users,
} from "../db/schema.js";
import { and, eq, asc } from "drizzle-orm";
createCurrentCredentialRepository,
createCurrentTermixIdentityRepository,
createCurrentTermixIdentityCaRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
// ssh2 is CommonJS; Node's cjs-module-lexer does not surface its `utils` named
// export, so we use a default import (esModuleInterop) and read `.utils` off it.
import ssh2 from "ssh2";
import { authLogger, databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { SimpleDBOps } from "../../utils/simple-db-ops.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import { parsePublicKey, matchesAlgoFilter } from "./termix-id-keys.js";
import {
@@ -38,17 +34,6 @@ const RESERVED_HANDLES = new Set(["u", "me", "keys", "check", "admin", "api"]);
// Max stored length for a free-text description.
const MAX_DESCRIPTION_LENGTH = 500;
// Decrypted ssh_credentials fields this route reads. A type alias (not an
// interface) so it satisfies the generic bound on SimpleDBOps.select.
type CredentialRow = {
name?: string | null;
authType?: string | null;
publicKey?: string | null;
privateKey?: string | null;
key?: string | null;
keyPassword?: string | null;
};
function cleanDescription(value: string | null | undefined): string | null {
if (typeof value !== "string") return null;
return value.trim().slice(0, MAX_DESCRIPTION_LENGTH) || null;
@@ -93,29 +78,18 @@ async function derivePublicFromPrivate(
}
async function getIdentityForUser(userId: string) {
const rows = await db
.select()
.from(termixIdentities)
.where(eq(termixIdentities.userId, userId))
.limit(1);
return rows[0] ?? null;
return createCurrentTermixIdentityRepository().findIdentityForUser(userId);
}
// Resolve the real username for audit_logs (the column expects a username, not
// the user id), matching how the credentials/snippets routes log.
async function getActorUsername(userId: string): Promise<string> {
const rows = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
return rows[0]?.username ?? userId;
const user = await createCurrentUserRepository().findById(userId);
return user?.username ?? userId;
}
// ---------------------------------------------------------------------------
// Public resolver — UNAUTHENTICATED. Serves authorized_keys (text/plain) or a
// small HTML viewer for browsers, keyed by handle, with optional /<ALGO> filter.
// ---------------------------------------------------------------------------
// Never let an intermediary/CDN cache a public resolver feed (a disabled/removed
// key could keep being served after revocation), and keep it out of search
@@ -137,26 +111,19 @@ async function resolveHandle(req: Request, res: Response) {
}
try {
const identity = await db
.select()
.from(termixIdentities)
.where(eq(termixIdentities.handle, handle))
.limit(1);
const identity =
await createCurrentTermixIdentityRepository().findIdentityByHandle(
handle,
);
if (identity.length === 0) {
if (!identity) {
return res.status(404).type("text/plain").send("Not found\n");
}
let keys = await db
.select()
.from(termixIdentityKeys)
.where(
and(
eq(termixIdentityKeys.identityId, identity[0].id),
eq(termixIdentityKeys.enabled, true),
),
)
.orderBy(asc(termixIdentityKeys.id));
let keys =
await createCurrentTermixIdentityRepository().listEnabledKeysByIdentityId(
identity.id,
);
if (algoFilter) {
keys = keys.filter((k) => matchesAlgoFilter(k.algorithm, algoFilter));
@@ -280,25 +247,23 @@ async function caResolver(req: Request, res: Response) {
return res.status(404).type("text/plain").send("Not found\n");
}
try {
const identity = await db
.select({ id: termixIdentities.id })
.from(termixIdentities)
.where(eq(termixIdentities.handle, handle))
.limit(1);
if (identity.length === 0) {
const identity =
await createCurrentTermixIdentityRepository().findIdentityByHandle(
handle,
);
if (!identity) {
return res.status(404).type("text/plain").send("Not found\n");
}
const ca = await db
.select({ publicKey: termixIdentityCa.publicKey })
.from(termixIdentityCa)
.where(eq(termixIdentityCa.identityId, identity[0].id))
.limit(1);
const ca =
await createCurrentTermixIdentityCaRepository().findPublicByIdentityId(
identity.id,
);
res.setHeader("Content-Type", "text/plain; charset=utf-8");
if (ca.length === 0) {
if (!ca) {
return res.status(404).send("No CA configured\n");
}
return res.send(`${ca[0].publicKey} termix-id-ca@${handle}\n`);
return res.send(`${ca.publicKey} termix-id-ca@${handle}\n`);
} catch (err) {
authLogger.error("Termix ID CA resolve failed", err);
return res.status(500).type("text/plain").send("Internal Server Error\n");
@@ -309,9 +274,7 @@ router.get("/u/:handle/ca", caResolver);
router.get("/u/:handle", resolveHandle);
router.get("/u/:handle/:algo", resolveHandle);
// ---------------------------------------------------------------------------
// Authenticated management API.
// ---------------------------------------------------------------------------
/**
* @openapi
@@ -332,11 +295,10 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
if (!identity) {
return res.json({ identity: null, keys: [] });
}
const keys = await db
.select()
.from(termixIdentityKeys)
.where(eq(termixIdentityKeys.identityId, identity.id))
.orderBy(asc(termixIdentityKeys.id));
const keys =
await createCurrentTermixIdentityRepository().listKeysByIdentityId(
identity.id,
);
res.json({
identity: {
...identity,
@@ -377,12 +339,9 @@ router.get(
return res.json({ available: false, valid: false });
}
try {
const existing = await db
.select({ id: termixIdentities.id })
.from(termixIdentities)
.where(eq(termixIdentities.handle, handle))
.limit(1);
res.json({ available: existing.length === 0, valid: true });
const taken =
await createCurrentTermixIdentityRepository().isHandleTaken(handle);
res.json({ available: !taken, valid: true });
} catch (err) {
authLogger.error("Failed to check Termix ID handle", err);
res.status(500).json({ error: "Failed to check handle" });
@@ -433,19 +392,17 @@ router.post("/", authenticateJWT, async (req: Request, res: Response) => {
});
}
const taken = await db
.select({ id: termixIdentities.id })
.from(termixIdentities)
.where(eq(termixIdentities.handle, handle))
.limit(1);
if (taken.length > 0) {
const termixIdentityRepository = createCurrentTermixIdentityRepository();
const taken = await termixIdentityRepository.isHandleTaken(handle);
if (taken) {
return res.status(409).json({ error: "Handle already taken" });
}
const inserted = await db
.insert(termixIdentities)
.values({ userId, handle, description })
.returning();
const inserted = await termixIdentityRepository.createIdentity({
userId,
handle,
description,
});
databaseLogger.info("Termix ID created", {
operation: "termix_id_create",
@@ -459,14 +416,14 @@ router.post("/", authenticateJWT, async (req: Request, res: Response) => {
username: await getActorUsername(userId),
action: "create_termix_id",
resourceType: "termix_id",
resourceId: String(inserted[0].id),
resourceId: String(inserted.id),
resourceName: handle,
ipAddress,
userAgent,
success: true,
});
res.status(201).json(inserted[0]);
res.status(201).json(inserted);
} catch (err) {
// The check-then-insert above is not atomic; the UNIQUE constraints on
// handle and user_id are the real guard, so map a violation to 409.
@@ -516,12 +473,9 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
return res.status(400).json({ error: "Invalid handle" });
}
if (handle !== identity.handle) {
const taken = await db
.select({ id: termixIdentities.id })
.from(termixIdentities)
.where(eq(termixIdentities.handle, handle))
.limit(1);
if (taken.length > 0) {
const taken =
await createCurrentTermixIdentityRepository().isHandleTaken(handle);
if (taken) {
return res.status(409).json({ error: "Handle already taken" });
}
}
@@ -532,11 +486,11 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
updates.description = cleanDescription(req.body.description);
}
const updated = await db
.update(termixIdentities)
.set({ ...updates, updatedAt: new Date().toISOString() })
.where(eq(termixIdentities.userId, userId))
.returning();
const updated =
await createCurrentTermixIdentityRepository().updateIdentityForUser(
userId,
{ ...updates, updatedAt: new Date().toISOString() },
);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
@@ -545,7 +499,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
action: "update_termix_id",
resourceType: "termix_id",
resourceId: String(identity.id),
resourceName: updated[0]?.handle ?? identity.handle,
resourceName: updated?.handle ?? identity.handle,
details:
updates.handle && updates.handle !== identity.handle
? `renamed ${identity.handle} -> ${updates.handle}`
@@ -555,7 +509,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
success: true,
});
res.json(updated[0]);
res.json(updated);
} catch (err) {
if (isUniqueConstraintError(err)) {
return res.status(409).json({ error: "Handle already taken" });
@@ -584,9 +538,7 @@ router.delete("/", authenticateJWT, async (req: Request, res: Response) => {
if (!identity) {
return res.status(404).json({ error: "No Termix ID found" });
}
await db
.delete(termixIdentities)
.where(eq(termixIdentities.userId, userId));
await createCurrentTermixIdentityRepository().deleteIdentityForUser(userId);
databaseLogger.info("Termix ID deleted", {
operation: "termix_id_delete",
@@ -665,23 +617,14 @@ router.post(
let resolvedLabel = label;
if (credentialId) {
const credResult = await SimpleDBOps.select<CredentialRow>(
db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId),
),
),
"ssh_credentials",
userId,
);
if (credResult.length === 0) {
const cred =
await createCurrentCredentialRepository().findDecryptedByIdForUser(
userId,
credentialId,
);
if (!cred) {
return res.status(404).json({ error: "Credential not found" });
}
const cred = credResult[0];
// The UI only offers key credentials, but the UI is not authoritative —
// reject non-key credentials server-side before treating cred.key as a
// private key.
@@ -721,31 +664,25 @@ router.post(
}
// Dedupe within this identity by normalized "<type> <blob>".
const existing = await db
.select({
id: termixIdentityKeys.id,
publicKey: termixIdentityKeys.publicKey,
})
.from(termixIdentityKeys)
.where(eq(termixIdentityKeys.identityId, identity.id));
const termixIdentityRepository = createCurrentTermixIdentityRepository();
const existing = await termixIdentityRepository.listKeysByIdentityId(
identity.id,
);
if (existing.some((k) => k.publicKey === parsed.normalized)) {
return res.status(409).json({ error: "This key is already published" });
}
const inserted = await db
.insert(termixIdentityKeys)
.values({
identityId: identity.id,
userId,
publicKey: parsed.normalized,
keyType: parsed.type,
algorithm: parsed.algorithm,
label: resolvedLabel,
comment: parsed.comment || null,
source,
credentialId: credentialId || null,
})
.returning();
const inserted = await termixIdentityRepository.createKey({
identityId: identity.id,
userId,
publicKey: parsed.normalized,
keyType: parsed.type,
algorithm: parsed.algorithm,
label: resolvedLabel,
comment: parsed.comment || null,
source,
credentialId: credentialId || null,
});
databaseLogger.info("Termix ID key added", {
operation: "termix_id_key_add",
@@ -760,7 +697,7 @@ router.post(
username: await getActorUsername(userId),
action: "add_termix_id_key",
resourceType: "termix_id_key",
resourceId: String(inserted[0].id),
resourceId: String(inserted.id),
resourceName: identity.handle,
details: `${parsed.algorithm} (${source})`,
ipAddress,
@@ -768,7 +705,7 @@ router.post(
success: true,
});
res.status(201).json(inserted[0]);
res.status(201).json(inserted);
} catch (err) {
authLogger.error("Failed to add Termix ID key", err);
res.status(500).json({ error: "Failed to add key" });
@@ -863,34 +800,31 @@ router.post(
usageCount: 0,
lastUsed: null,
};
const created = (await SimpleDBOps.insert(
sshCredentials,
"ssh_credentials",
credData,
userId,
)) as typeof credData & { id: number };
const created =
await createCurrentCredentialRepository().createEncryptedForUser(
userId,
credData,
);
credentialId = created.id;
}
// There is no single transaction here (SimpleDBOps.insert is async and
// better-sqlite3 transactions are sync), so if publishing the key fails
// There is no single transaction here because credential encryption is async,
// so if publishing the key fails
// after the vault credential was created, compensate by deleting it to
// avoid leaving an orphaned credential behind.
const termixIdentityRepository = createCurrentTermixIdentityRepository();
const runInsert = () =>
db
.insert(termixIdentityKeys)
.values({
identityId: identity.id,
userId,
publicKey: parsed.normalized,
keyType: parsed.type,
algorithm: parsed.algorithm,
label: label || `Generated ${parsed.algorithm}`,
comment: parsed.comment || null,
source: "generated",
credentialId,
})
.returning();
termixIdentityRepository.createKey({
identityId: identity.id,
userId,
publicKey: parsed.normalized,
keyType: parsed.type,
algorithm: parsed.algorithm,
label: label || `Generated ${parsed.algorithm}`,
comment: parsed.comment || null,
source: "generated",
credentialId,
});
let inserted: Awaited<ReturnType<typeof runInsert>>;
try {
@@ -898,14 +832,10 @@ router.post(
} catch (insertErr) {
if (credentialId !== null) {
try {
await db
.delete(sshCredentials)
.where(
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId),
),
);
await createCurrentCredentialRepository().deleteForUser(
userId,
credentialId,
);
} catch {
// best-effort cleanup
}
@@ -926,7 +856,7 @@ router.post(
username: await getActorUsername(userId),
action: "generate_termix_id_key",
resourceType: "termix_id_key",
resourceId: String(inserted[0].id),
resourceId: String(inserted.id),
resourceName: identity.handle,
details: `${parsed.algorithm}${credentialId !== null ? " (saved to credentials)" : ""}`,
ipAddress,
@@ -937,7 +867,7 @@ router.post(
// The private key is also returned once so the user can download it; when
// saveCredential is true it additionally lives (encrypted) in the vault.
res.status(201).json({
key: inserted[0],
key: inserted,
privateKey: pair.private,
publicKey: parsed.normalized,
credentialId,
@@ -997,17 +927,13 @@ router.patch(
? req.body.label.trim() || null
: null;
}
const updated = await db
.update(termixIdentityKeys)
.set(updates)
.where(
and(
eq(termixIdentityKeys.id, id),
eq(termixIdentityKeys.userId, userId),
),
)
.returning();
if (updated.length === 0) {
const updated =
await createCurrentTermixIdentityRepository().updateKeyForUser(
userId,
id,
updates,
);
if (!updated) {
return res.status(404).json({ error: "Key not found" });
}
@@ -1018,7 +944,7 @@ router.patch(
action: "update_termix_id_key",
resourceType: "termix_id_key",
resourceId: String(id),
resourceName: String(updated[0].label ?? updated[0].algorithm),
resourceName: String(updated.label ?? updated.algorithm),
details:
updates.enabled !== undefined
? `enabled: ${updates.enabled}`
@@ -1028,7 +954,7 @@ router.patch(
success: true,
});
res.json(updated[0]);
res.json(updated);
} catch (err) {
authLogger.error("Failed to update Termix ID key", err);
res.status(500).json({ error: "Failed to update key" });
@@ -1064,16 +990,12 @@ router.delete(
return res.status(400).json({ error: "Invalid key id" });
}
try {
const deleted = await db
.delete(termixIdentityKeys)
.where(
and(
eq(termixIdentityKeys.id, id),
eq(termixIdentityKeys.userId, userId),
),
)
.returning();
if (deleted.length === 0) {
const deleted =
await createCurrentTermixIdentityRepository().deleteKeyForUser(
userId,
id,
);
if (!deleted) {
return res.status(404).json({ error: "Key not found" });
}
@@ -1098,9 +1020,7 @@ router.delete(
},
);
// ---------------------------------------------------------------------------
// Certificate authority — central revocation (rotate) + expiry (validity).
// ---------------------------------------------------------------------------
type CaRow = {
id: number;
@@ -1122,15 +1042,12 @@ async function getCaForUser(
userId: string,
identityId: number,
): Promise<CaRow | undefined> {
const rows = await SimpleDBOps.select<CaRow>(
db
.select()
.from(termixIdentityCa)
.where(eq(termixIdentityCa.identityId, identityId)),
"termix_identity_ca",
userId,
return (
(await createCurrentTermixIdentityCaRepository().findDecryptedByIdentityId(
userId,
identityId,
)) ?? undefined
);
return rows[0];
}
/**
@@ -1150,19 +1067,15 @@ router.get("/ca", authenticateJWT, async (req: Request, res: Response) => {
try {
const identity = await getIdentityForUser(userId);
if (!identity) return res.json({ ca: null });
const ca = await db
.select({
publicKey: termixIdentityCa.publicKey,
validityDays: termixIdentityCa.validityDays,
})
.from(termixIdentityCa)
.where(eq(termixIdentityCa.identityId, identity.id))
.limit(1);
if (ca.length === 0) return res.json({ ca: null });
const ca =
await createCurrentTermixIdentityCaRepository().findPublicByIdentityId(
identity.id,
);
if (!ca) return res.json({ ca: null });
res.json({
ca: {
publicKey: ca[0].publicKey,
validityDays: ca[0].validityDays,
publicKey: ca.publicKey,
validityDays: ca.validityDays,
resolverPath: `/termix-id/u/${identity.handle}/ca`,
},
});
@@ -1205,29 +1118,21 @@ router.post(
.status(400)
.json({ error: "Create a Termix ID handle first" });
}
const existing = await db
.select({ id: termixIdentityCa.id })
.from(termixIdentityCa)
.where(eq(termixIdentityCa.identityId, identity.id))
.limit(1);
if (existing.length > 0) {
const caRepository = createCurrentTermixIdentityCaRepository();
const existing = await caRepository.findPublicByIdentityId(identity.id);
if (existing) {
return res.status(409).json({ error: "CA already exists" });
}
const validityDays = clampValidityDays(req.body?.validityDays, 90);
const generated = generateCa();
await SimpleDBOps.insert(
termixIdentityCa,
"termix_identity_ca",
{
identityId: identity.id,
userId,
publicKey: generated.publicKeyLine,
privateKey: generated.privateKeyPem,
validityDays,
},
await caRepository.createEncryptedForUser(userId, {
identityId: identity.id,
userId,
);
publicKey: generated.publicKeyLine,
privateKey: generated.privateKeyPem,
validityDays,
});
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
@@ -1283,37 +1188,25 @@ router.post(
const identity = await getIdentityForUser(userId);
if (!identity)
return res.status(404).json({ error: "No Termix ID found" });
const existing = await db
.select({
id: termixIdentityCa.id,
validityDays: termixIdentityCa.validityDays,
})
.from(termixIdentityCa)
.where(eq(termixIdentityCa.identityId, identity.id))
.limit(1);
if (existing.length === 0) {
const caRepository = createCurrentTermixIdentityCaRepository();
const existing = await caRepository.findPublicByIdentityId(identity.id);
if (!existing) {
return res.status(404).json({ error: "No CA to rotate" });
}
const validityDays = clampValidityDays(
req.body?.validityDays,
existing[0].validityDays,
existing.validityDays,
);
const generated = generateCa();
// Rotating invalidates every previously issued certificate — this IS the
// central revocation mechanism.
await SimpleDBOps.update(
termixIdentityCa,
"termix_identity_ca",
eq(termixIdentityCa.identityId, identity.id),
{
publicKey: generated.publicKeyLine,
privateKey: generated.privateKeyPem,
validityDays,
updatedAt: new Date().toISOString(),
},
userId,
);
await caRepository.updateEncryptedForIdentity(userId, identity.id, {
publicKey: generated.publicKeyLine,
privateKey: generated.privateKeyPem,
validityDays,
updatedAt: new Date().toISOString(),
});
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
@@ -1356,11 +1249,11 @@ router.delete("/ca", authenticateJWT, async (req: Request, res: Response) => {
try {
const identity = await getIdentityForUser(userId);
if (!identity) return res.status(404).json({ error: "No Termix ID found" });
const deleted = await db
.delete(termixIdentityCa)
.where(eq(termixIdentityCa.identityId, identity.id))
.returning();
if (deleted.length === 0) {
const deleted =
await createCurrentTermixIdentityCaRepository().deleteByIdentityId(
identity.id,
);
if (!deleted) {
return res.status(404).json({ error: "No CA to delete" });
}
@@ -1424,20 +1317,13 @@ router.post(
return res.status(400).json({ error: "Invalid key id" });
}
try {
const keyRows = await db
.select()
.from(termixIdentityKeys)
.where(
and(
eq(termixIdentityKeys.id, id),
eq(termixIdentityKeys.userId, userId),
),
)
.limit(1);
if (keyRows.length === 0) {
const key = await createCurrentTermixIdentityRepository().findKeyForUser(
userId,
id,
);
if (!key) {
return res.status(404).json({ error: "Key not found" });
}
const key = keyRows[0];
if (!ed25519RawFromLine(key.publicKey)) {
return res.status(400).json({
error: "Certificates are only supported for Ed25519 keys",
@@ -1523,22 +1409,10 @@ router.get(
try {
const identity = await getIdentityForUser(userId);
if (!identity) return res.json({ credentialIds: [] });
const keys = await db
.select({ credentialId: termixIdentityKeys.credentialId })
.from(termixIdentityKeys)
.where(
and(
eq(termixIdentityKeys.identityId, identity.id),
eq(termixIdentityKeys.enabled, true),
),
const credentialIds =
await createCurrentTermixIdentityRepository().listLinkedCredentialIds(
identity.id,
);
const credentialIds = [
...new Set(
keys
.map((k) => k.credentialId)
.filter((cid): cid is number => cid !== null),
),
];
res.json({ credentialIds });
} catch (err) {
authLogger.error("Failed to fetch linked credential IDs", err);
+416 -221
View File
@@ -1,18 +1,35 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import { eq, and } from "drizzle-orm";
import { authLogger } from "../../utils/logger.js";
import { db } from "../db/index.js";
import { users, roles, userRoles } from "../db/schema.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import bcrypt from "bcryptjs";
import { nanoid } from "nanoid";
import { AuthManager } from "../../utils/auth-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import {
createCurrentRoleRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
import type {
UserRecord,
UserRepository,
} from "../repositories/user-repository.js";
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
async function getUserByPreferredIdentifier(
userRepository: UserRepository,
userId: string | null,
username: string | null,
): Promise<UserRecord | null> {
return userId
? userRepository.findById(userId)
: userRepository.findByUsername(username!);
}
export function registerUserAdminRoutes(
router: Router,
authenticateJWT: RequestHandler,
@@ -35,15 +52,11 @@ export function registerUserAdminRoutes(
*/
router.get("/list", authenticateJWT, async (req, res) => {
try {
const allUsers = await db
.select({
id: users.id,
username: users.username,
isAdmin: users.isAdmin,
isOidc: users.isOidc,
passwordHash: users.passwordHash,
})
.from(users);
const userRepository = createCurrentUserRepository();
const requester = await userRepository.findById(
(req as AuthenticatedRequest).userId,
);
const allUsers = await userRepository.listAll();
res.json({
users: allUsers.map((u) => ({
@@ -52,6 +65,14 @@ export function registerUserAdminRoutes(
is_admin: u.isAdmin,
is_oidc: u.isOidc,
password_hash: u.passwordHash ? "set" : null,
// Management-only details stay admin-eyes-only; regular users hit
// this route to pick sharing targets.
...(requester?.isAdmin
? {
data_unlocked: DataCrypto.canUserAccessData(u.id),
totp_enabled: !!u.totpEnabled,
}
: {}),
})),
});
} catch (err) {
@@ -108,95 +129,51 @@ export function registerUserAdminRoutes(
}
try {
const adminUser = await db
.select()
.from(users)
.where(eq(users.id, userId));
if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) {
const userRepository = createCurrentUserRepository();
const adminUser = await userRepository.findById(userId);
if (!adminUser?.isAdmin) {
return res.status(403).json({ error: "Not authorized" });
}
const targetUser = await db
.select()
.from(users)
.where(
resolvedUserId
? eq(users.id, resolvedUserId)
: eq(users.username, resolvedUsername!),
)
.limit(1);
if (!targetUser || targetUser.length === 0) {
const targetUser = await getUserByPreferredIdentifier(
userRepository,
resolvedUserId,
resolvedUsername,
);
if (!targetUser) {
return res.status(404).json({ error: "User not found" });
}
if (targetUser[0].isAdmin) {
if (targetUser.isAdmin) {
return res.status(400).json({ error: "User is already an admin" });
}
await db
.update(users)
.set({ isAdmin: true })
.where(
resolvedUserId
? eq(users.id, resolvedUserId)
: eq(users.username, resolvedUsername!),
);
await userRepository.update(targetUser.id, { isAdmin: true });
try {
const targetId = targetUser[0].id;
const adminRole = await db
.select({ id: roles.id })
.from(roles)
.where(eq(roles.name, "admin"))
.limit(1);
const userRole = await db
.select({ id: roles.id })
.from(roles)
.where(eq(roles.name, "user"))
.limit(1);
if (adminRole.length > 0) {
await db
.delete(userRoles)
.where(
and(
eq(userRoles.userId, targetId),
eq(userRoles.roleId, adminRole[0].id),
),
);
await db.insert(userRoles).values({
userId: targetId,
roleId: adminRole[0].id,
grantedBy: userId,
});
}
if (userRole.length > 0) {
await db
.delete(userRoles)
.where(
and(
eq(userRoles.userId, targetId),
eq(userRoles.roleId, userRole[0].id),
),
);
}
await createCurrentRoleRepository().switchUserRoleName({
userId: targetUser.id,
addRoleName: "admin",
removeRoleName: "user",
grantedBy: userId,
});
} catch (roleError) {
authLogger.error("Failed to sync admin role on make-admin", roleError, {
operation: "make_admin_role_sync",
userId: targetUser[0].id,
userId: targetUser.id,
});
}
try {
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
await DatabaseSaveTrigger.forceSave("make_admin_explicit_save");
} catch (saveError) {
authLogger.error(
"Failed to persist admin promotion to disk",
saveError,
{
operation: "make_admin_save_failed",
userId: targetUser[0].id,
username: targetUser[0].username,
userId: targetUser.id,
username: targetUser.username,
},
);
}
@@ -204,29 +181,24 @@ export function registerUserAdminRoutes(
authLogger.info("Admin privileges granted", {
operation: "admin_grant",
adminId: userId,
targetUserId: targetUser[0].id,
targetUsername: targetUser[0].username,
targetUserId: targetUser.id,
targetUsername: targetUser.username,
});
const { ipAddress, userAgent } = getRequestMeta(req);
const adminUserRecord = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: adminUserRecord[0]?.username ?? userId,
username: adminUser.username ?? userId,
action: "make_admin",
resourceType: "user",
resourceId: targetUser[0].id,
resourceName: targetUser[0].username,
resourceId: targetUser.id,
resourceName: targetUser.username,
ipAddress,
userAgent,
success: true,
});
res.json({ message: `User ${targetUser[0].username} is now an admin` });
res.json({ message: `User ${targetUser.username} is now an admin` });
} catch (err) {
authLogger.error("Failed to make user admin", err);
res.status(500).json({ error: "Failed to make user admin" });
@@ -281,135 +253,86 @@ export function registerUserAdminRoutes(
}
try {
const adminUser = await db
.select()
.from(users)
.where(eq(users.id, userId));
if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) {
const userRepository = createCurrentUserRepository();
const adminUser = await userRepository.findById(userId);
if (!adminUser?.isAdmin) {
return res.status(403).json({ error: "Not authorized" });
}
if (
(resolvedUserId && adminUser[0].id === resolvedUserId) ||
(resolvedUsername && adminUser[0].username === resolvedUsername)
(resolvedUserId && adminUser.id === resolvedUserId) ||
(resolvedUsername && adminUser.username === resolvedUsername)
) {
return res
.status(400)
.json({ error: "Cannot remove your own admin status" });
}
const targetUser = await db
.select()
.from(users)
.where(
resolvedUserId
? eq(users.id, resolvedUserId)
: eq(users.username, resolvedUsername!),
)
.limit(1);
if (!targetUser || targetUser.length === 0) {
const targetUser = await getUserByPreferredIdentifier(
userRepository,
resolvedUserId,
resolvedUsername,
);
if (!targetUser) {
return res.status(404).json({ error: "User not found" });
}
if (!targetUser[0].isAdmin) {
if (!targetUser.isAdmin) {
return res.status(400).json({ error: "User is not an admin" });
}
await db
.update(users)
.set({ isAdmin: false })
.where(
resolvedUserId
? eq(users.id, resolvedUserId)
: eq(users.username, resolvedUsername!),
);
await userRepository.update(targetUser.id, { isAdmin: false });
try {
const targetId = targetUser[0].id;
const adminRole = await db
.select({ id: roles.id })
.from(roles)
.where(eq(roles.name, "admin"))
.limit(1);
const userRole = await db
.select({ id: roles.id })
.from(roles)
.where(eq(roles.name, "user"))
.limit(1);
if (adminRole.length > 0) {
await db
.delete(userRoles)
.where(
and(
eq(userRoles.userId, targetId),
eq(userRoles.roleId, adminRole[0].id),
),
);
}
if (userRole.length > 0) {
await db
.delete(userRoles)
.where(
and(
eq(userRoles.userId, targetId),
eq(userRoles.roleId, userRole[0].id),
),
);
await db.insert(userRoles).values({
userId: targetId,
roleId: userRole[0].id,
grantedBy: userId,
});
}
await createCurrentRoleRepository().switchUserRoleName({
userId: targetUser.id,
addRoleName: "user",
removeRoleName: "admin",
grantedBy: userId,
});
} catch (roleError) {
authLogger.error(
"Failed to sync user role on remove-admin",
roleError,
{
operation: "remove_admin_role_sync",
userId: targetUser[0].id,
userId: targetUser.id,
},
);
}
try {
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
await DatabaseSaveTrigger.forceSave("remove_admin_explicit_save");
} catch (saveError) {
authLogger.error("Failed to persist admin removal to disk", saveError, {
operation: "remove_admin_save_failed",
userId: targetUser[0].id,
username: targetUser[0].username,
userId: targetUser.id,
username: targetUser.username,
});
}
authLogger.info("Admin privileges revoked", {
operation: "admin_revoke",
adminId: userId,
targetUserId: targetUser[0].id,
targetUsername: targetUser[0].username,
targetUserId: targetUser.id,
targetUsername: targetUser.username,
});
const { ipAddress, userAgent } = getRequestMeta(req);
const adminUserRecord = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: adminUserRecord[0]?.username ?? userId,
username: adminUser.username ?? userId,
action: "remove_admin",
resourceType: "user",
resourceId: targetUser[0].id,
resourceName: targetUser[0].username,
resourceId: targetUser.id,
resourceName: targetUser.username,
ipAddress,
userAgent,
success: true,
});
res.json({
message: `Admin status removed from ${targetUser[0].username}`,
message: `Admin status removed from ${targetUser.username}`,
});
} catch (err) {
authLogger.error("Failed to remove admin status", err);
@@ -450,13 +373,12 @@ export function registerUserAdminRoutes(
*/
router.post("/admin-create", authenticateJWT, async (req, res) => {
const adminId = (req as AuthenticatedRequest).userId;
const userRepository = createCurrentUserRepository();
let adminUser: UserRecord | null = null;
try {
const adminUser = await db
.select()
.from(users)
.where(eq(users.id, adminId));
if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) {
adminUser = await userRepository.findById(adminId);
if (!adminUser?.isAdmin) {
return res.status(403).json({ error: "Not authorized" });
}
} catch (err) {
@@ -473,55 +395,39 @@ export function registerUserAdminRoutes(
}
try {
const existing = await db
.select()
.from(users)
.where(eq(users.username, username));
if (existing && existing.length > 0) {
const existing = await userRepository.findByUsername(username);
if (existing) {
return res.status(409).json({ error: "Username already exists" });
}
const password_hash = await bcrypt.hash(password, 10);
const id = nanoid();
db.$client.transaction(() => {
db.$client
.prepare(
"INSERT INTO users (id, username, password_hash, is_admin, is_oidc, client_id, client_secret, issuer_url, authorization_url, token_url, identifier_path, name_path, scopes, totp_secret, totp_enabled, totp_backup_codes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.run(
id,
username,
password_hash,
0,
0,
"",
"",
"",
"",
"",
"",
"",
"openid email profile",
null,
0,
null,
);
})();
await userRepository.create({
id,
username,
passwordHash: password_hash,
isAdmin: false,
isOidc: false,
clientId: "",
clientSecret: "",
issuerUrl: "",
authorizationUrl: "",
tokenUrl: "",
identifierPath: "",
namePath: "",
scopes: "openid email profile",
totpSecret: null,
totpEnabled: false,
totpBackupCodes: null,
});
try {
const userRole = await db
.select({ id: roles.id })
.from(roles)
.where(eq(roles.name, "user"))
.limit(1);
if (userRole.length > 0) {
await db.insert(userRoles).values({
userId: id,
roleId: userRole[0].id,
grantedBy: adminId,
});
}
await createCurrentRoleRepository().assignRoleNameToUser({
userId: id,
roleName: "user",
grantedBy: adminId,
});
} catch (roleError) {
authLogger.error(
"Failed to assign default role during admin create",
@@ -537,7 +443,7 @@ export function registerUserAdminRoutes(
try {
await authManager.registerUser(id, password);
} catch (encryptionError) {
await db.delete(users).where(eq(users.id, id));
await userRepository.delete(id);
authLogger.error(
"Failed to setup user encryption during admin create, rolled back",
encryptionError,
@@ -549,8 +455,7 @@ export function registerUserAdminRoutes(
}
try {
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
await DatabaseSaveTrigger.forceSave("admin_create_user_explicit_save");
} catch (saveError) {
authLogger.error(
"Failed to persist admin-created user to disk",
@@ -570,14 +475,9 @@ export function registerUserAdminRoutes(
});
const { ipAddress, userAgent } = getRequestMeta(req);
const adminRecord = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, adminId))
.limit(1);
await logAudit({
userId: adminId,
username: adminRecord[0]?.username ?? adminId,
username: adminUser.username ?? adminId,
action: "create_user",
resourceType: "user",
resourceId: id,
@@ -596,4 +496,299 @@ export function registerUserAdminRoutes(
res.status(500).json({ error: "Failed to create user" });
}
});
/**
* @openapi
* /users/admin/reset-password:
* post:
* summary: Reset a user's password (admin only)
* description: >
* Resets another user's password. Data is preserved for users whose
* encryption key has been migrated to the system wrap. Users who never
* logged in since the encryption upgrade require confirmDataWipe,
* which deletes their encrypted data.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - newPassword
* properties:
* userId:
* type: string
* username:
* type: string
* newPassword:
* type: string
* confirmDataWipe:
* type: boolean
* responses:
* 200:
* description: Password reset; dataWiped indicates whether encrypted data was deleted.
* 400:
* description: Missing or invalid parameters.
* 403:
* description: Admin access required.
* 404:
* description: User not found.
* 409:
* description: Reset would wipe the user's data and confirmDataWipe was not set.
* 500:
* description: Failed to reset password.
*/
router.post("/admin/reset-password", authenticateJWT, async (req, res) => {
const adminId = (req as AuthenticatedRequest).userId;
const { userId: targetUserId, username, newPassword } = req.body;
const resolvedUserId = isNonEmptyString(targetUserId)
? targetUserId.trim()
: null;
const resolvedUsername = isNonEmptyString(username)
? username.trim()
: null;
if (!resolvedUserId && !resolvedUsername) {
return res.status(400).json({ error: "User ID or username is required" });
}
if (!isNonEmptyString(newPassword)) {
return res.status(400).json({ error: "New password is required" });
}
try {
const userRepository = createCurrentUserRepository();
const adminUser = await userRepository.findById(adminId);
if (!adminUser?.isAdmin) {
return res.status(403).json({ error: "Admin access required" });
}
const targetUser = await getUserByPreferredIdentifier(
userRepository,
resolvedUserId,
resolvedUsername,
);
if (!targetUser) {
return res.status(404).json({ error: "User not found" });
}
if (targetUser.isOidc && !targetUser.passwordHash) {
return res.status(400).json({
error: "This user authenticates through an external provider",
});
}
const { resetUserPassword } =
await import("./user-password-reset-routes.js");
const outcome = await resetUserPassword(AuthManager.getInstance(), {
userId: targetUser.id,
username: targetUser.username,
newPassword,
confirmDataWipe: req.body?.confirmDataWipe === true,
});
if (outcome.status === "wipe_confirmation_required") {
return res.status(409).json({
error:
"This user has not logged in since the encryption upgrade, so their data cannot be recovered. Set confirmDataWipe to reset anyway and delete their hosts, credentials and snippets.",
code: "DATA_WIPE_REQUIRED",
});
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId: adminId,
username: adminUser.username ?? adminId,
action: "admin_reset_password",
resourceType: "user",
resourceId: targetUser.id,
resourceName: targetUser.username,
ipAddress,
userAgent,
success: true,
});
await DatabaseSaveTrigger.forceSave("admin_password_reset");
res.json({
message: "Password reset",
dataWiped: outcome.dataWiped,
});
} catch (err) {
authLogger.error("Failed to reset user password", err);
res.status(500).json({ error: "Failed to reset password" });
}
});
/**
* @openapi
* /users/admin/totp/disable:
* post:
* summary: Disable a user's TOTP (admin only)
* description: Clears another user's TOTP secret, enabled flag and backup codes so they can log in without 2FA.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* userId:
* type: string
* responses:
* 200:
* description: TOTP disabled for the user.
* 400:
* description: User ID is required or TOTP is not enabled.
* 403:
* description: Admin access required.
* 404:
* description: User not found.
* 500:
* description: Failed to disable TOTP.
*/
router.post("/admin/totp/disable", authenticateJWT, async (req, res) => {
const adminId = (req as AuthenticatedRequest).userId;
const { userId: targetUserId } = req.body;
if (!isNonEmptyString(targetUserId)) {
return res.status(400).json({ error: "User ID is required" });
}
try {
const userRepository = createCurrentUserRepository();
const adminUser = await userRepository.findById(adminId);
if (!adminUser?.isAdmin) {
return res.status(403).json({ error: "Admin access required" });
}
const targetUser = await userRepository.findById(targetUserId.trim());
if (!targetUser) {
return res.status(404).json({ error: "User not found" });
}
if (!targetUser.totpEnabled) {
return res
.status(400)
.json({ error: "TOTP is not enabled for this user" });
}
await userRepository.update(targetUser.id, {
totpSecret: null,
totpEnabled: false,
totpBackupCodes: null,
});
try {
await DatabaseSaveTrigger.forceSave("admin_disable_totp");
} catch (saveError) {
authLogger.error("Failed to persist TOTP disable to disk", saveError, {
operation: "admin_disable_totp_save_failed",
userId: targetUser.id,
});
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId: adminId,
username: adminUser.username ?? adminId,
action: "admin_disable_totp",
resourceType: "user",
resourceId: targetUser.id,
resourceName: targetUser.username,
ipAddress,
userAgent,
success: true,
});
res.json({ message: "TOTP disabled" });
} catch (err) {
authLogger.error("Failed to disable TOTP for user", err);
res.status(500).json({ error: "Failed to disable TOTP" });
}
});
/**
* @openapi
* /users/admin/export/{userId}:
* get:
* summary: Export a user's data (admin only)
* description: Downloads a JSON export of another user's data (hosts, credentials, file manager bookmarks). Secrets are decrypted server-side, so handle the file carefully.
* tags:
* - Users
* parameters:
* - in: path
* name: userId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: JSON export of the user's data.
* 403:
* description: Admin access required.
* 404:
* description: User not found.
* 423:
* description: The user's data stays locked until their next login.
* 500:
* description: Failed to export user data.
*/
router.get("/admin/export/:userId", authenticateJWT, async (req, res) => {
const adminId = (req as AuthenticatedRequest).userId;
const targetUserId = Array.isArray(req.params.userId)
? req.params.userId[0]
: req.params.userId;
try {
const userRepository = createCurrentUserRepository();
const adminUser = await userRepository.findById(adminId);
if (!adminUser?.isAdmin) {
return res.status(403).json({ error: "Admin access required" });
}
const targetUser = await userRepository.findById(targetUserId);
if (!targetUser) {
return res.status(404).json({ error: "User not found" });
}
if (!DataCrypto.canUserAccessData(targetUser.id)) {
return res.status(423).json({
error: "Target user's data stays locked until their next login",
code: "TARGET_DATA_LOCKED",
});
}
const { UserDataExport } =
await import("../../utils/user-data-export.js");
const exportData = await UserDataExport.exportUserData(targetUser.id, {
format: "plaintext",
includeCredentials: true,
});
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId: adminId,
username: adminUser.username ?? adminId,
action: "admin_export_user_data",
resourceType: "user",
resourceId: targetUser.id,
resourceName: targetUser.username,
ipAddress,
userAgent,
success: true,
});
res.setHeader("Content-Type", "application/json");
res.setHeader(
"Content-Disposition",
`attachment; filename="termix-user-${targetUser.username}-export.json"`,
);
res.json(exportData);
} catch (err) {
authLogger.error("Failed to export user data", err);
res.status(500).json({ error: "Failed to export user data" });
}
});
}
@@ -3,11 +3,12 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
import crypto from "crypto";
import bcrypt from "bcryptjs";
import { nanoid } from "nanoid";
import { eq } from "drizzle-orm";
import { authLogger } from "../../utils/logger.js";
import { db } from "../db/index.js";
import { apiKeys, users } from "../db/schema.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import {
createCurrentApiKeyRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
export function registerUserApiKeyRoutes(
router: Router,
@@ -56,6 +57,8 @@ export function registerUserApiKeyRoutes(
router.post("/api-keys", requireAdmin, async (req, res) => {
try {
const { name, userId: targetUserId, expiresAt } = req.body;
const apiKeyRepository = createCurrentApiKeyRepository();
const userRepository = createCurrentUserRepository();
if (typeof name !== "string" || !name.trim()) {
return res.status(400).json({ error: "name is required" });
@@ -64,12 +67,8 @@ export function registerUserApiKeyRoutes(
return res.status(400).json({ error: "userId is required" });
}
const targetUser = await db
.select()
.from(users)
.where(eq(users.id, targetUserId))
.limit(1);
if (targetUser.length === 0) {
const targetUser = await userRepository.findById(targetUserId);
if (!targetUser) {
return res.status(404).json({ error: "Target user not found" });
}
@@ -93,7 +92,7 @@ export function registerUserApiKeyRoutes(
const keyId = nanoid();
const now = new Date().toISOString();
await db.insert(apiKeys).values({
await apiKeyRepository.create({
id: keyId,
userId: targetUserId,
name: name.trim(),
@@ -105,26 +104,21 @@ export function registerUserApiKeyRoutes(
isActive: true,
});
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
const actorId = (req as AuthenticatedRequest).userId;
const { ipAddress, userAgent } = getRequestMeta(req);
const actorRecord = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, actorId))
.limit(1);
const actorRecord = actorId
? await userRepository.findById(actorId)
: null;
await logAudit({
userId: actorId,
username: actorRecord[0]?.username ?? actorId,
username: actorRecord?.username ?? actorId,
action: "create_api_key",
resourceType: "api_key",
resourceId: keyId,
resourceName: name.trim(),
details: JSON.stringify({
targetUserId,
targetUsername: targetUser[0].username,
targetUsername: targetUser.username,
}),
ipAddress,
userAgent,
@@ -135,7 +129,7 @@ export function registerUserApiKeyRoutes(
id: keyId,
name: name.trim(),
userId: targetUserId,
username: targetUser[0].username,
username: targetUser.username,
tokenPrefix,
createdAt: now,
expiresAt: expiresAtValue,
@@ -165,21 +159,7 @@ export function registerUserApiKeyRoutes(
*/
router.get("/api-keys", requireAdmin, async (_req, res) => {
try {
const keys = await db
.select({
id: apiKeys.id,
name: apiKeys.name,
userId: apiKeys.userId,
username: users.username,
tokenPrefix: apiKeys.tokenPrefix,
createdAt: apiKeys.createdAt,
expiresAt: apiKeys.expiresAt,
lastUsedAt: apiKeys.lastUsedAt,
isActive: apiKeys.isActive,
})
.from(apiKeys)
.leftJoin(users, eq(apiKeys.userId, users.id))
.orderBy(apiKeys.createdAt);
const keys = await createCurrentApiKeyRepository().listAllWithUsers();
return res.json({ apiKeys: keys });
} catch (err) {
@@ -216,36 +196,26 @@ export function registerUserApiKeyRoutes(
router.delete("/api-keys/:keyId", requireAdmin, async (req, res) => {
try {
const keyId = String(req.params.keyId);
const apiKeyRepository = createCurrentApiKeyRepository();
const userRepository = createCurrentUserRepository();
const existing = await db
.select()
.from(apiKeys)
.where(eq(apiKeys.id, keyId))
.limit(1);
if (existing.length === 0) {
const deleted = await apiKeyRepository.delete(keyId);
if (!deleted) {
return res.status(404).json({ error: "API key not found" });
}
await db.delete(apiKeys).where(eq(apiKeys.id, keyId));
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
const actorId = (req as AuthenticatedRequest).userId;
const { ipAddress, userAgent } = getRequestMeta(req);
const actorRecord = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, actorId))
.limit(1);
const actorRecord = actorId
? await userRepository.findById(actorId)
: null;
await logAudit({
userId: actorId,
username: actorRecord[0]?.username ?? actorId,
username: actorRecord?.username ?? actorId,
action: "delete_api_key",
resourceType: "api_key",
resourceId: keyId,
resourceName: existing[0].name,
resourceName: deleted.name,
ipAddress,
userAgent,
success: true,
@@ -1,10 +1,9 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import { eq } from "drizzle-orm";
import { AuthManager } from "../../utils/auth-manager.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { authLogger } from "../../utils/logger.js";
import { db } from "../db/index.js";
import { users } from "../db/schema.js";
import { createCurrentUserRepository } from "../repositories/factory.js";
import { deleteUserAndRelatedData } from "./delete-user-data.js";
type UserOidcAccountRoutesDeps = {
@@ -62,42 +61,30 @@ export function registerUserOidcAccountRoutes(
}
try {
const adminUser = await db
.select()
.from(users)
.where(eq(users.id, adminUserId));
if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) {
const userRepository = createCurrentUserRepository();
const adminUser = await userRepository.findById(adminUserId);
if (!adminUser?.isAdmin) {
return res.status(403).json({ error: "Admin access required" });
}
const oidcUserRecords = await db
.select()
.from(users)
.where(eq(users.id, oidcUserId));
if (!oidcUserRecords || oidcUserRecords.length === 0) {
const oidcUser = await userRepository.findById(oidcUserId);
if (!oidcUser) {
return res.status(404).json({ error: "OIDC user not found" });
}
const oidcUser = oidcUserRecords[0];
if (!oidcUser.isOidc) {
return res.status(400).json({
error: "Source user is not an OIDC user",
});
}
const targetUserRecords = await db
.select()
.from(users)
.where(eq(users.username, targetUsername));
if (!targetUserRecords || targetUserRecords.length === 0) {
const targetUser = await userRepository.findByUsername(targetUsername);
if (!targetUser) {
return res
.status(404)
.json({ error: "Target password user not found" });
}
const targetUser = targetUserRecords[0];
if (targetUser.isOidc || !targetUser.passwordHash) {
return res.status(400).json({
error: "Target user must be a password-based account",
@@ -119,58 +106,18 @@ export function registerUserOidcAccountRoutes(
adminUserId,
});
await db
.update(users)
.set({
isOidc: true,
oidcIdentifier: oidcUser.oidcIdentifier,
clientId: oidcUser.clientId,
clientSecret: oidcUser.clientSecret,
issuerUrl: oidcUser.issuerUrl,
authorizationUrl: oidcUser.authorizationUrl,
tokenUrl: oidcUser.tokenUrl,
identifierPath: oidcUser.identifierPath,
namePath: oidcUser.namePath,
scopes: oidcUser.scopes || "openid email profile",
})
.where(eq(users.id, targetUser.id));
try {
await authManager.convertToOIDCEncryption(targetUser.id);
} catch (encryptionError) {
authLogger.error(
"Failed to convert encryption to OIDC during linking",
encryptionError,
{
operation: "link_convert_encryption_failed",
userId: targetUser.id,
},
);
await db
.update(users)
.set({
isOidc: false,
oidcIdentifier: null,
clientId: "",
clientSecret: "",
issuerUrl: "",
authorizationUrl: "",
tokenUrl: "",
identifierPath: "",
namePath: "",
scopes: "openid email profile",
})
.where(eq(users.id, targetUser.id));
return res.status(500).json({
error:
"Failed to convert encryption for dual-auth. Please ensure the password account has encryption setup.",
details:
encryptionError instanceof Error
? encryptionError.message
: "Unknown error",
});
}
await userRepository.update(targetUser.id, {
isOidc: true,
oidcIdentifier: oidcUser.oidcIdentifier,
clientId: oidcUser.clientId,
clientSecret: oidcUser.clientSecret,
issuerUrl: oidcUser.issuerUrl,
authorizationUrl: oidcUser.authorizationUrl,
tokenUrl: oidcUser.tokenUrl,
identifierPath: oidcUser.identifierPath,
namePath: oidcUser.namePath,
scopes: oidcUser.scopes || "openid email profile",
});
await authManager.revokeAllUserSessions(oidcUserId);
authManager.logoutUser(oidcUserId);
@@ -178,8 +125,7 @@ export function registerUserOidcAccountRoutes(
await deleteUserAndRelatedData(oidcUserId);
try {
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
await DatabaseSaveTrigger.forceSave("link_oidc_explicit_save");
} catch (saveError) {
authLogger.error(
"Failed to persist account linking to disk",
@@ -265,12 +211,10 @@ export function registerUserOidcAccountRoutes(
}
try {
const adminUser = await db
.select()
.from(users)
.where(eq(users.id, adminUserId));
const userRepository = createCurrentUserRepository();
const adminUser = await userRepository.findById(adminUserId);
if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) {
if (!adminUser?.isAdmin) {
authLogger.warn("Non-admin attempted to unlink OIDC from password", {
operation: "unlink_oidc_unauthorized",
adminUserId,
@@ -281,19 +225,13 @@ export function registerUserOidcAccountRoutes(
});
}
const targetUserRecords = await db
.select()
.from(users)
.where(eq(users.id, userId));
if (!targetUserRecords || targetUserRecords.length === 0) {
const targetUser = await userRepository.findById(userId);
if (!targetUser) {
return res.status(404).json({
error: "User not found",
});
}
const targetUser = targetUserRecords[0];
if (!targetUser.isOidc) {
return res.status(400).json({
error: "User does not have OIDC authentication enabled",
@@ -314,25 +252,21 @@ export function registerUserOidcAccountRoutes(
adminUserId,
});
await db
.update(users)
.set({
isOidc: false,
oidcIdentifier: null,
clientId: "",
clientSecret: "",
issuerUrl: "",
authorizationUrl: "",
tokenUrl: "",
identifierPath: "",
namePath: "",
scopes: "openid email profile",
})
.where(eq(users.id, targetUser.id));
await userRepository.update(targetUser.id, {
isOidc: false,
oidcIdentifier: null,
clientId: "",
clientSecret: "",
issuerUrl: "",
authorizationUrl: "",
tokenUrl: "",
identifierPath: "",
namePath: "",
scopes: "openid email profile",
});
try {
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
await DatabaseSaveTrigger.forceSave("unlink_oidc_explicit_save");
} catch (saveError) {
authLogger.error(
"Failed to save database after unlinking OIDC",
@@ -1,201 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// user-oidc-utils imports the logger; stub it so importing stays side-effect-free.
vi.mock("../../utils/logger.js", () => ({
authLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
const { isOIDCUserAllowed, getOIDCConfigFromEnv, extractOidcGroups } =
await import("./user-oidc-utils.js");
describe("isOIDCUserAllowed", () => {
it("allows everyone when the allow-list is empty", () => {
expect(isOIDCUserAllowed("", "alice", "alice@x.com")).toBe(true);
expect(isOIDCUserAllowed(" ", "alice")).toBe(true);
});
it("allows everyone with the '*' wildcard", () => {
expect(isOIDCUserAllowed("*", "anyone", "anyone@x.com")).toBe(true);
});
it("matches an exact identifier (case-insensitive)", () => {
expect(isOIDCUserAllowed("alice,bob", "alice")).toBe(true);
expect(isOIDCUserAllowed("Alice", "alice")).toBe(true);
expect(isOIDCUserAllowed("alice", "ALICE")).toBe(true);
});
it("matches against the email as well as the identifier", () => {
expect(isOIDCUserAllowed("alice@x.com", "sub-123", "alice@x.com")).toBe(
true,
);
});
it("matches an @domain suffix pattern", () => {
expect(isOIDCUserAllowed("@company.com", "sub-1", "bob@company.com")).toBe(
true,
);
expect(isOIDCUserAllowed("@company.com", "sub-1", "bob@COMPANY.COM")).toBe(
true,
);
});
it("denies users not on the list", () => {
expect(isOIDCUserAllowed("alice,bob", "charlie", "charlie@x.com")).toBe(
false,
);
expect(isOIDCUserAllowed("@company.com", "sub-1", "bob@other.com")).toBe(
false,
);
});
it("ignores blank entries and surrounding whitespace in the list", () => {
expect(isOIDCUserAllowed(" alice , , bob ", "bob")).toBe(true);
});
it("does not match the email against an identifier-only pattern when email differs", () => {
expect(isOIDCUserAllowed("alice", "sub-123", "alice@x.com")).toBe(false);
});
it("matches *@domain.com wildcard pattern against emails", () => {
expect(
isOIDCUserAllowed("*@company.com", "sub-1", "john@company.com"),
).toBe(true);
expect(
isOIDCUserAllowed("*@company.com", "sub-1", "jane@COMPANY.COM"),
).toBe(true);
expect(isOIDCUserAllowed("*@company.com", "sub-1", "user@other.com")).toBe(
false,
);
});
it("matches glob patterns with multiple wildcards", () => {
expect(isOIDCUserAllowed("admin*", "admin_user")).toBe(true);
expect(isOIDCUserAllowed("admin*", "user_admin")).toBe(false);
});
});
describe("getOIDCConfigFromEnv", () => {
const REQUIRED = [
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_ISSUER_URL",
"OIDC_AUTHORIZATION_URL",
"OIDC_TOKEN_URL",
];
const OPTIONAL = [
"OIDC_USERINFO_URL",
"OIDC_IDENTIFIER_PATH",
"OIDC_NAME_PATH",
"OIDC_SCOPES",
"OIDC_ALLOWED_USERS",
"OIDC_ADMIN_GROUP",
];
const saved: Record<string, string | undefined> = {};
beforeEach(() => {
for (const key of [...REQUIRED, ...OPTIONAL]) {
saved[key] = process.env[key];
delete process.env[key];
}
});
afterEach(() => {
for (const key of [...REQUIRED, ...OPTIONAL]) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
});
it("returns null when any required variable is missing", () => {
process.env.OIDC_CLIENT_ID = "id";
process.env.OIDC_CLIENT_SECRET = "secret";
// issuer/authorization/token urls intentionally missing
expect(getOIDCConfigFromEnv()).toBeNull();
});
it("builds a config with defaults when all required vars are present", () => {
process.env.OIDC_CLIENT_ID = "id";
process.env.OIDC_CLIENT_SECRET = "secret";
process.env.OIDC_ISSUER_URL = "https://idp.example";
process.env.OIDC_AUTHORIZATION_URL = "https://idp.example/auth";
process.env.OIDC_TOKEN_URL = "https://idp.example/token";
const config = getOIDCConfigFromEnv();
expect(config).not.toBeNull();
expect(config?.client_id).toBe("id");
expect(config?.identifier_path).toBe("sub");
expect(config?.name_path).toBe("name");
expect(config?.scopes).toBe("openid email profile");
expect(config?.userinfo_url).toBe("");
});
it("honors overrides for optional vars", () => {
process.env.OIDC_CLIENT_ID = "id";
process.env.OIDC_CLIENT_SECRET = "secret";
process.env.OIDC_ISSUER_URL = "https://idp.example";
process.env.OIDC_AUTHORIZATION_URL = "https://idp.example/auth";
process.env.OIDC_TOKEN_URL = "https://idp.example/token";
process.env.OIDC_IDENTIFIER_PATH = "email";
process.env.OIDC_SCOPES = "openid";
const config = getOIDCConfigFromEnv();
expect(config?.identifier_path).toBe("email");
expect(config?.scopes).toBe("openid");
});
});
describe("extractOidcGroups", () => {
it("reads the standard groups claim as an array", () => {
expect(extractOidcGroups({ groups: ["admin", "user"] })).toEqual([
"admin",
"user",
]);
});
it("splits a comma-separated string claim", () => {
expect(extractOidcGroups({ roles: "admin, user" })).toEqual([
"admin",
"user",
]);
});
it("falls back through groups, roles, then group", () => {
expect(extractOidcGroups({ group: "ops" })).toEqual(["ops"]);
});
it("reads a custom claim path when provided", () => {
const userInfo = {
"zitadel:grants:groups:123": ["user", "admin"],
groups: ["ignored"],
};
expect(extractOidcGroups(userInfo, "zitadel:grants:groups:123")).toEqual([
"user",
"admin",
]);
});
it("uses object keys as group names (Zitadel roles object)", () => {
const userInfo = {
"urn:zitadel:iam:org:project:roles": { admin: {}, user: {} },
};
expect(
extractOidcGroups(userInfo, "urn:zitadel:iam:org:project:roles"),
).toEqual(["admin", "user"]);
});
it("falls back to defaults when the custom claim is absent", () => {
expect(extractOidcGroups({ groups: ["admin"] }, "missing")).toEqual([
"admin",
]);
});
it("returns an empty array when no groups are present", () => {
expect(extractOidcGroups({})).toEqual([]);
});
});
+116 -23
View File
@@ -1,10 +1,18 @@
import { authLogger } from "../../utils/logger.js";
import type { SSOProviderType } from "../../../types/index.js";
import { db } from "../db/index.js";
import { ssoProviders } from "../db/schema.js";
import { eq } from "drizzle-orm";
import { DataCrypto } from "../../utils/data-crypto.js";
import { Agent } from "undici";
import {
createCurrentSettingsRepository,
createCurrentSsoProviderRepository,
} from "../repositories/factory.js";
const BACKCHANNEL_LOGOUT_EVENT =
"http://schemas.openid.net/event/backchannel-logout";
function normalizeIssuer(url: string): string {
return url.trim().replace(/\/+$/, "");
}
export type OIDCConfig = {
client_id: string;
@@ -311,13 +319,9 @@ export async function loadProviderConfig(
} | null> {
if (providerId != null) {
try {
const rows = await db
.select()
.from(ssoProviders)
.where(eq(ssoProviders.id, providerId))
.limit(1);
if (rows.length > 0) {
const row = rows[0];
const row =
await createCurrentSsoProviderRepository().findById(providerId);
if (row) {
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(row.config);
@@ -367,14 +371,8 @@ export async function loadProviderConfig(
// Fallback: first enabled OIDC-type provider in ssoProviders table
try {
const rows = await db
.select()
.from(ssoProviders)
.where(eq(ssoProviders.enabled, true))
.orderBy();
const oidcRow = rows.find(
(r) => r.type === "oidc" || r.type === "github" || r.type === "google",
);
const oidcRow =
await createCurrentSsoProviderRepository().findFirstEnabledOidcLike();
if (oidcRow) {
let parsed: Record<string, unknown>;
try {
@@ -399,11 +397,10 @@ export async function loadProviderConfig(
// Fallback: legacy settings blob
try {
const legacyRow = db.$client
.prepare("SELECT value FROM settings WHERE key = 'oidc_config'")
.get() as { value: string } | undefined;
if (legacyRow) {
let config = JSON.parse(legacyRow.value) as Record<string, unknown>;
const legacyValue =
await createCurrentSettingsRepository().get("oidc_config");
if (legacyValue) {
let config = JSON.parse(legacyValue) as Record<string, unknown>;
config = decryptConfigSecret(config);
return {
config: config as unknown as OIDCConfig,
@@ -417,3 +414,99 @@ export async function loadProviderConfig(
return null;
}
export async function resolveProviderByIssuer(issuer: string): Promise<{
config: OIDCConfig;
providerType: SSOProviderType;
providerDbId: number | null;
} | null> {
const target = normalizeIssuer(issuer);
try {
const rows = await createCurrentSsoProviderRepository().listEnabled();
for (const row of rows) {
if (!["oidc", "github", "google"].includes(row.type)) continue;
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(row.config);
} catch {
continue;
}
parsed = decryptConfigSecret(parsed);
const providerType = row.type as SSOProviderType;
const config = applyProviderDefaults(
parsed as unknown as OIDCConfig,
providerType,
);
if (config.issuer_url && normalizeIssuer(config.issuer_url) === target) {
return { config, providerType, providerDbId: row.id };
}
}
} catch (err) {
authLogger.error("Failed to resolve SSO provider by issuer", err, {
issuer,
});
}
const envConfig = getOIDCConfigFromEnv();
if (
envConfig?.issuer_url &&
normalizeIssuer(envConfig.issuer_url) === target
) {
return { config: envConfig, providerType: "oidc", providerDbId: null };
}
return null;
}
export type LogoutTokenClaims = {
sub: string | null;
sid: string | null;
jti: string;
};
export function validateLogoutTokenClaims(
payload: Record<string, unknown>,
): LogoutTokenClaims {
if ("nonce" in payload) {
throw new Error("logout_token must not contain a nonce claim");
}
const event = (payload.events as Record<string, unknown> | undefined)?.[
BACKCHANNEL_LOGOUT_EVENT
];
if (!event || typeof event !== "object" || Array.isArray(event)) {
throw new Error("logout_token missing back-channel logout event");
}
if (!Number.isInteger(payload.iat)) {
throw new Error("logout_token missing iat claim");
}
const jti = typeof payload.jti === "string" ? payload.jti.trim() : "";
if (!jti) {
throw new Error("logout_token missing jti claim");
}
const sub = typeof payload.sub === "string" ? payload.sub : null;
const sid = typeof payload.sid === "string" ? payload.sid : null;
if (!sub && !sid) {
throw new Error("logout_token must contain sub and/or sid");
}
return { sub, sid, jti };
}
export async function validateLogoutToken(
logoutToken: string,
config: OIDCConfig,
): Promise<LogoutTokenClaims> {
const payload = await verifyOIDCToken(
logoutToken,
config.issuer_url,
config.client_id,
config.ca_cert,
);
return validateLogoutTokenClaims(payload);
}
@@ -2,24 +2,20 @@ import type { Router } from "express";
import crypto from "crypto";
import bcrypt from "bcryptjs";
import { nanoid } from "nanoid";
import { eq } from "drizzle-orm";
import { AuthManager } from "../../utils/auth-manager.js";
import { authLogger } from "../../utils/logger.js";
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
import { db } from "../db/index.js";
import {
users,
hosts,
sshCredentials,
fileManagerRecent,
fileManagerPinned,
fileManagerShortcuts,
dismissedAlerts,
sshCredentialUsage,
recentActivity,
snippets,
webauthnCredentials,
} from "../db/schema.js";
createCurrentCredentialRepository,
createCurrentDismissedAlertRepository,
createCurrentFileManagerBookmarkRepository,
createCurrentHostRepository,
createCurrentRecentActivityRepository,
createCurrentSettingsRepository,
createCurrentSnippetRepository,
createCurrentSshCredentialUsageRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
interface UserPasswordResetRoutesDeps {
authManager: AuthManager;
@@ -29,6 +25,82 @@ function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
export type PasswordResetOutcome =
| { status: "reset"; dataWiped: false }
| { status: "reset"; dataWiped: true }
| { status: "wipe_confirmation_required" };
// Resets a user's password. The DEK is wrapped by the system key, so for any
// user migrated to the v3 wrap this is just a hash update and their data
// survives. Users who never logged in after the encryption upgrade still have
// a password-wrapped DEK that a reset cannot recover; their encrypted data
// must be wiped, which callers have to confirm explicitly.
export async function resetUserPassword(
authManager: AuthManager,
options: {
userId: string;
username: string;
newPassword: string;
confirmDataWipe: boolean;
},
): Promise<PasswordResetOutcome> {
const { userId, username, newPassword, confirmDataWipe } = options;
const passwordHash = await bcrypt.hash(newPassword, 10);
const userRepository = createCurrentUserRepository();
if (authManager.isUserUnlocked(userId)) {
await userRepository.update(userId, { passwordHash });
await authManager.logoutUser(userId);
authLogger.success(
`Password reset (data preserved) for user: ${username}`,
{
operation: "password_reset_preserved",
userId,
username,
},
);
return { status: "reset", dataWiped: false };
}
if (!confirmDataWipe) {
return { status: "wipe_confirmation_required" };
}
await userRepository.update(userId, { passwordHash });
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
await createCurrentFileManagerBookmarkRepository().deleteByUserId(userId);
await createCurrentRecentActivityRepository().deleteByUserId(userId);
await createCurrentDismissedAlertRepository().deleteByUserId(userId);
await createCurrentSnippetRepository().deleteByUserId(userId);
await createCurrentHostRepository().deleteByUserId(userId);
await createCurrentCredentialRepository().deleteByUserId(userId);
const { UserKeyManager } = await import("../../utils/user-keys.js");
await UserKeyManager.getInstance().rotateUserDEK(userId);
const { deleteLegacyWraps } =
await import("../../utils/crypto-migration/dek-migration.js");
await deleteLegacyWraps(userId);
await authManager.logoutUser(userId);
await userRepository.update(userId, {
totpEnabled: false,
totpSecret: null,
totpBackupCodes: null,
});
authLogger.warn(
`Password reset completed for user: ${username}. All encrypted data has been deleted because the old key was unrecoverable.`,
{
operation: "password_reset_data_deleted",
userId,
username,
},
);
return { status: "reset", dataWiped: true };
}
export function registerUserPasswordResetRoutes(
router: Router,
{ authManager }: UserPasswordResetRoutesDeps,
@@ -68,14 +140,10 @@ export function registerUserPasswordResetRoutes(
const allowed =
envVal !== undefined
? envVal.trim().toLowerCase() === "true"
: (() => {
const row = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'allow_password_reset'",
)
.get();
return row ? (row as { value: string }).value === "true" : true;
})();
: await createCurrentSettingsRepository().getBoolean(
"allow_password_reset",
true,
);
if (!allowed) {
return res
.status(403)
@@ -95,12 +163,9 @@ export function registerUserPasswordResetRoutes(
}
try {
const user = await db
.select()
.from(users)
.where(eq(users.username, username));
const user = await createCurrentUserRepository().findByUsername(username);
if (!user || user.length === 0) {
if (!user) {
authLogger.warn(
`Password reset attempted for non-existent user: ${username}`,
);
@@ -110,7 +175,7 @@ export function registerUserPasswordResetRoutes(
});
}
if (user[0].isOidc) {
if (user.isOidc) {
return res.json({
message:
"If the user exists, a password reset code has been generated. Check docker logs for the code.",
@@ -120,15 +185,13 @@ export function registerUserPasswordResetRoutes(
const resetCode = crypto.randomInt(100000, 1000000).toString();
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
db.$client
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.run(
`reset_code_${username}`,
JSON.stringify({
code: resetCode,
expiresAt: expiresAt.toISOString(),
}),
);
await createCurrentSettingsRepository().set(
`reset_code_${username}`,
JSON.stringify({
code: resetCode,
expiresAt: expiresAt.toISOString(),
}),
);
authLogger.info(
`Password reset code generated for user ${username}: ${resetCode} (expires at ${expiresAt.toLocaleString()})`,
@@ -200,10 +263,10 @@ export function registerUserPasswordResetRoutes(
loginRateLimiter.recordResetCodeAttempt(username);
const resetDataRow = db.$client
.prepare("SELECT value FROM settings WHERE key = ?")
.get(`reset_code_${username}`);
if (!resetDataRow) {
const resetDataValue = await createCurrentSettingsRepository().get(
`reset_code_${username}`,
);
if (!resetDataValue) {
authLogger.warn("Reset code verification failed - no code found", {
operation: "reset_code_verify_failed",
username,
@@ -217,16 +280,14 @@ export function registerUserPasswordResetRoutes(
});
}
const resetData = JSON.parse(
(resetDataRow as Record<string, unknown>).value as string,
);
const resetData = JSON.parse(resetDataValue);
const now = new Date();
const expiresAt = new Date(resetData.expiresAt);
if (now > expiresAt) {
db.$client
.prepare("DELETE FROM settings WHERE key = ?")
.run(`reset_code_${username}`);
await createCurrentSettingsRepository().delete(
`reset_code_${username}`,
);
authLogger.warn("Reset code verification failed - code expired", {
operation: "reset_code_verify_failed",
username,
@@ -259,15 +320,13 @@ export function registerUserPasswordResetRoutes(
const tempToken = nanoid();
const tempTokenExpiry = new Date(Date.now() + 10 * 60 * 1000);
db.$client
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.run(
`temp_reset_token_${username}`,
JSON.stringify({
token: tempToken,
expiresAt: tempTokenExpiry.toISOString(),
}),
);
await createCurrentSettingsRepository().set(
`temp_reset_token_${username}`,
JSON.stringify({
token: tempToken,
expiresAt: tempTokenExpiry.toISOString(),
}),
);
res.json({ message: "Reset code verified", tempToken });
} catch (err) {
@@ -321,23 +380,21 @@ export function registerUserPasswordResetRoutes(
}
try {
const tempTokenRow = db.$client
.prepare("SELECT value FROM settings WHERE key = ?")
.get(`temp_reset_token_${username}`);
if (!tempTokenRow) {
const tempTokenValue = await createCurrentSettingsRepository().get(
`temp_reset_token_${username}`,
);
if (!tempTokenValue) {
return res.status(400).json({ error: "No temporary token found" });
}
const tempTokenData = JSON.parse(
(tempTokenRow as Record<string, unknown>).value as string,
);
const tempTokenData = JSON.parse(tempTokenValue);
const now = new Date();
const expiresAt = new Date(tempTokenData.expiresAt);
if (now > expiresAt) {
db.$client
.prepare("DELETE FROM settings WHERE key = ?")
.run(`temp_reset_token_${username}`);
await createCurrentSettingsRepository().delete(
`temp_reset_token_${username}`,
);
return res.status(400).json({ error: "Temporary token has expired" });
}
@@ -345,152 +402,37 @@ export function registerUserPasswordResetRoutes(
return res.status(400).json({ error: "Invalid temporary token" });
}
const user = await db
.select()
.from(users)
.where(eq(users.username, username));
if (!user || user.length === 0) {
const user = await createCurrentUserRepository().findByUsername(username);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
const userId = user[0].id;
const userId = user.id;
const password_hash = await bcrypt.hash(newPassword, 10);
const outcome = await resetUserPassword(authManager, {
userId,
username,
newPassword,
confirmDataWipe: req.body?.confirmDataWipe === true,
});
let userIdFromJwt: string | null = null;
const cookie = req.cookies?.jwt;
let header: string | undefined;
if (req.headers?.authorization?.startsWith("Bearer ")) {
header = req.headers?.authorization?.split(" ")[1];
}
const token = cookie || header;
if (token) {
const payload = await authManager.verifyJWTToken(token);
if (payload) {
userIdFromJwt = payload.userId;
}
}
if (userIdFromJwt === userId) {
try {
const success = await authManager.resetUserPasswordWithPreservedDEK(
userId,
newPassword,
);
if (!success) {
throw new Error(
"Failed to re-encrypt user data with new password.",
);
}
await db
.update(users)
.set({ passwordHash: password_hash })
.where(eq(users.id, userId));
authManager.logoutUser(userId);
authLogger.success(
`Password reset (data preserved) for user: ${username}`,
{
operation: "password_reset_preserved",
userId,
username,
},
);
} catch (encryptionError) {
authLogger.error(
"Failed to setup user data encryption after password reset",
encryptionError,
{
operation: "password_reset_encryption_failed_preserved",
userId,
username,
},
);
return res.status(500).json({
error: "Password reset failed. Please contact administrator.",
});
}
} else {
await db
.update(users)
.set({ passwordHash: password_hash })
.where(eq(users.username, username));
try {
await db
.delete(sshCredentialUsage)
.where(eq(sshCredentialUsage.userId, userId));
await db
.delete(fileManagerRecent)
.where(eq(fileManagerRecent.userId, userId));
await db
.delete(fileManagerPinned)
.where(eq(fileManagerPinned.userId, userId));
await db
.delete(fileManagerShortcuts)
.where(eq(fileManagerShortcuts.userId, userId));
await db
.delete(recentActivity)
.where(eq(recentActivity.userId, userId));
await db
.delete(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId));
await db.delete(snippets).where(eq(snippets.userId, userId));
await db.delete(hosts).where(eq(hosts.userId, userId));
await db
.delete(sshCredentials)
.where(eq(sshCredentials.userId, userId));
await db
.delete(webauthnCredentials)
.where(eq(webauthnCredentials.userId, userId));
await authManager.registerUser(userId, newPassword);
authManager.logoutUser(userId);
await db
.update(users)
.set({
totpEnabled: false,
totpSecret: null,
totpBackupCodes: null,
})
.where(eq(users.id, userId));
authLogger.warn(
`Password reset completed for user: ${username}. All encrypted data has been deleted due to lost encryption key.`,
{
operation: "password_reset_data_deleted",
userId,
username,
},
);
} catch (encryptionError) {
authLogger.error(
"Failed to setup user data encryption after password reset",
encryptionError,
{
operation: "password_reset_encryption_failed",
userId,
username,
},
);
return res.status(500).json({
error: "Password reset failed. Please contact administrator.",
});
}
if (outcome.status === "wipe_confirmation_required") {
return res.status(409).json({
error:
"This account has not logged in since the encryption upgrade, so its stored data cannot be recovered without the old password. Resetting will permanently delete its hosts, credentials and snippets.",
code: "DATA_WIPE_REQUIRED",
});
}
authLogger.success(`Password successfully reset for user: ${username}`);
db.$client
.prepare("DELETE FROM settings WHERE key = ?")
.run(`reset_code_${username}`);
db.$client
.prepare("DELETE FROM settings WHERE key = ?")
.run(`temp_reset_token_${username}`);
const settingsRepository = createCurrentSettingsRepository();
await settingsRepository.delete(`reset_code_${username}`);
await settingsRepository.delete(`temp_reset_token_${username}`);
res.json({ message: "Password has been successfully reset" });
res.json({
message: "Password has been successfully reset",
dataWiped: outcome.dataWiped,
});
} catch (err) {
authLogger.error("Failed to complete password reset", err);
res.status(500).json({ error: "Failed to complete password reset" });
+13 -32
View File
@@ -1,17 +1,19 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import { db } from "../db/index.js";
import { userPreferences } from "../db/schema.js";
import { eq } from "drizzle-orm";
import type { Request, Response } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { createCurrentUserPreferenceRepository } from "../repositories/factory.js";
import type {
UserPreferenceRecord,
UserPreferenceUpdate,
} from "../repositories/user-preference-repository.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const pickPreferences = (row?: typeof userPreferences.$inferSelect) => ({
const pickPreferences = (row?: UserPreferenceRecord | null) => ({
reopenTabsOnLogin: row?.reopenTabsOnLogin ?? false,
theme: row?.theme ?? null,
fontSize: row?.fontSize ?? null,
@@ -105,16 +107,13 @@ const pickPreferences = (row?: typeof userPreferences.$inferSelect) => ({
* type: string
* nullable: true
*/
router.get("/", authenticateJWT, (req: Request, res: Response) => {
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const rows = db
.select()
.from(userPreferences)
.where(eq(userPreferences.userId, userId))
.all();
const preferences =
await createCurrentUserPreferenceRepository().findByUserId(userId);
return res.json(pickPreferences(rows[0]));
return res.json(pickPreferences(preferences));
} catch (e) {
databaseLogger.error("Failed to get user preferences", e, {
operation: "get_user_preferences",
@@ -180,7 +179,7 @@ router.get("/", authenticateJWT, (req: Request, res: Response) => {
* 200:
* description: Preferences updated successfully.
*/
router.put("/", authenticateJWT, (req: Request, res: Response) => {
router.put("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const {
reopenTabsOnLogin,
@@ -224,7 +223,7 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => {
statusColorScheme?: string | null;
};
const updates: Partial<typeof userPreferences.$inferInsert> = {
const updates: UserPreferenceUpdate = {
updatedAt: new Date().toISOString(),
};
@@ -301,25 +300,7 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => {
}
try {
const existing = db
.select()
.from(userPreferences)
.where(eq(userPreferences.userId, userId))
.all();
if (existing.length === 0) {
db.insert(userPreferences)
.values({
userId,
...updates,
})
.run();
} else {
db.update(userPreferences)
.set(updates)
.where(eq(userPreferences.userId, userId))
.run();
}
await createCurrentUserPreferenceRepository().upsert(userId, updates);
return res.json({ success: true, ...updates });
} catch (e) {
@@ -1,11 +1,12 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import { eq } from "drizzle-orm";
import { AuthManager } from "../../utils/auth-manager.js";
import { authLogger } from "../../utils/logger.js";
import { db } from "../db/index.js";
import { sessions, users } from "../db/schema.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import {
createCurrentSessionRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
type UserSessionRoutesDeps = {
authenticateJWT: RequestHandler;
@@ -38,39 +39,38 @@ export function registerUserSessionRoutes(
const currentSessionId = authReq.sessionId;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0) {
const userRepository = createCurrentUserRepository();
const userRecord = await userRepository.findById(userId);
if (!userRecord) {
return res.status(404).json({ error: "User not found" });
}
const userRecord = user[0];
let sessionList;
if (userRecord.isAdmin) {
sessionList = await authManager.getAllSessions();
const enrichedSessions = await Promise.all(
sessionList.map(async (session) => {
const sessionUser = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, session.userId))
.limit(1);
return {
id: session.id,
userId: session.userId,
username: sessionUser[0]?.username || "Unknown",
deviceType: session.deviceType,
deviceInfo: session.deviceInfo,
createdAt: session.createdAt,
expiresAt: session.expiresAt,
lastActiveAt: session.lastActiveAt,
isRevoked: session.isRevoked,
isCurrentSession: session.id === currentSessionId,
};
}),
const sessionUsers = await userRepository.listByIds(
sessionList.map((session) => session.userId),
);
const usernamesById = new Map(
sessionUsers.map((sessionUser) => [
sessionUser.id,
sessionUser.username,
]),
);
const enrichedSessions = sessionList.map((session) => ({
id: session.id,
userId: session.userId,
username: usernamesById.get(session.userId) || "Unknown",
deviceType: session.deviceType,
deviceInfo: session.deviceInfo,
createdAt: session.createdAt,
expiresAt: session.expiresAt,
lastActiveAt: session.lastActiveAt,
isRevoked: session.isRevoked,
isCurrentSession: session.id === currentSessionId,
}));
return res.json({ sessions: enrichedSessions });
} else {
@@ -133,25 +133,19 @@ export function registerUserSessionRoutes(
}
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0) {
const userRepository = createCurrentUserRepository();
const userRecord = await userRepository.findById(userId);
if (!userRecord) {
return res.status(404).json({ error: "User not found" });
}
const userRecord = user[0];
const session =
await createCurrentSessionRepository().findById(sessionId);
const sessionRecords = await db
.select()
.from(sessions)
.where(eq(sessions.id, sessionId))
.limit(1);
if (sessionRecords.length === 0) {
if (!session) {
return res.status(404).json({ error: "Session not found" });
}
const session = sessionRecords[0];
if (!userRecord.isAdmin && session.userId !== userId) {
return res
.status(403)
@@ -169,14 +163,9 @@ export function registerUserSessionRoutes(
});
const { ipAddress, userAgent } = getRequestMeta(req);
const actorUser = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: actorUser[0]?.username ?? userId,
username: userRecord.username ?? userId,
action: "revoke_session",
resourceType: "session",
resourceId: sessionId,
@@ -230,13 +219,12 @@ export function registerUserSessionRoutes(
const { targetUserId, exceptCurrent } = req.body;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0) {
const userRepository = createCurrentUserRepository();
const userRecord = await userRepository.findById(userId);
if (!userRecord) {
return res.status(404).json({ error: "User not found" });
}
const userRecord = user[0];
let revokeUserId = userId;
if (targetUserId && userRecord.isAdmin) {
revokeUserId = targetUserId;
@@ -265,23 +253,17 @@ export function registerUserSessionRoutes(
});
const { ipAddress, userAgent } = getRequestMeta(req);
const actorUser = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
const targetUserRecord = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, revokeUserId))
.limit(1);
const targetUserRecord =
revokeUserId === userId
? userRecord
: await userRepository.findById(revokeUserId);
await logAudit({
userId,
username: actorUser[0]?.username ?? userId,
username: userRecord.username ?? userId,
action: "revoke_all_sessions",
resourceType: "session",
resourceId: revokeUserId,
resourceName: targetUserRecord[0]?.username,
resourceName: targetUserRecord?.username,
details: JSON.stringify({ revokedCount, exceptCurrent }),
ipAddress,
userAgent,
@@ -1,19 +1,21 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import { eq } from "drizzle-orm";
import { restartGuacServer } from "../../guacamole/guacamole-server.js";
import { restartGuacServer } from "../../hosts/guacamole/guacamole-server.js";
import {
authLogger,
getGlobalLogLevel,
setGlobalLogLevel,
} from "../../utils/logger.js";
import { db } from "../db/index.js";
import { users } from "../db/schema.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import {
formatGuacdOptions,
resolveGuacdOptions,
} from "../../utils/guacd-config.js";
createCurrentSettingsRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
import type { UserRecord } from "../repositories/user-repository.js";
function getDefaultGuacUrl(): string {
return `${process.env.GUACD_HOST || "localhost"}:${process.env.GUACD_PORT || "4822"}`;
}
export type HostDefaults = {
useSocks5?: boolean;
@@ -33,6 +35,14 @@ export type HostDefaults = {
enableCommandHistory?: boolean;
};
async function getAdminActor(
userId: string | undefined,
): Promise<UserRecord | null> {
if (!userId) return null;
const user = await createCurrentUserRepository().findById(userId);
return user?.isAdmin ? user : null;
}
export function registerUserSettingsRoutes(
router: Router,
authenticateJWT: RequestHandler,
@@ -62,15 +72,12 @@ export function registerUserSettingsRoutes(
*/
router.get("/guacamole-settings", authenticateJWT, async (_req, res) => {
try {
const enabledRow = db.$client
.prepare("SELECT value FROM settings WHERE key = 'guac_enabled'")
.get() as { value: string } | undefined;
const urlRow = db.$client
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
.get() as { value: string } | undefined;
const settings = createCurrentSettingsRepository();
const enabled = await settings.getBoolean("guac_enabled", true);
const url = await settings.get("guac_url");
res.json({
enabled: enabledRow ? enabledRow.value !== "false" : true,
url: formatGuacdOptions(resolveGuacdOptions(urlRow?.value)),
enabled,
url: url ?? getDefaultGuacUrl(),
});
} catch (err) {
authLogger.error("Failed to get guacamole settings", err);
@@ -108,24 +115,17 @@ export function registerUserSettingsRoutes(
router.patch("/guacamole-settings", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0 || !user[0].isAdmin) {
const actor = await getAdminActor(userId);
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const { enabled, url } = req.body;
const settings = createCurrentSettingsRepository();
if (typeof enabled === "boolean") {
db.$client
.prepare(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('guac_enabled', ?)",
)
.run(enabled ? "true" : "false");
await settings.set("guac_enabled", enabled ? "true" : "false");
}
if (typeof url === "string") {
db.$client
.prepare(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('guac_url', ?)",
)
.run(url);
await settings.set("guac_url", url);
try {
await restartGuacServer();
} catch (err) {
@@ -135,22 +135,13 @@ export function registerUserSettingsRoutes(
);
}
}
const enabledRow = db.$client
.prepare("SELECT value FROM settings WHERE key = 'guac_enabled'")
.get() as { value: string } | undefined;
const urlRow = db.$client
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
.get() as { value: string } | undefined;
const currentEnabled = await settings.getBoolean("guac_enabled", true);
const currentUrl = await settings.get("guac_url");
const { ipAddress, userAgent } = getRequestMeta(req);
const actorRecord = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: actorRecord[0]?.username ?? userId,
username: actor.username ?? userId,
action: "update_guacamole_settings",
resourceType: "setting",
details: JSON.stringify({ enabled, url }),
@@ -160,8 +151,8 @@ export function registerUserSettingsRoutes(
});
res.json({
enabled: enabledRow ? enabledRow.value !== "false" : true,
url: formatGuacdOptions(resolveGuacdOptions(urlRow?.value)),
enabled: currentEnabled,
url: currentUrl ?? getDefaultGuacUrl(),
});
} catch (err) {
authLogger.error("Failed to update guacamole settings", err);
@@ -183,11 +174,9 @@ export function registerUserSettingsRoutes(
*/
router.get("/log-level", authenticateJWT, async (_req, res) => {
try {
const row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'log_level'")
.get() as { value: string } | undefined;
const level = await createCurrentSettingsRepository().get("log_level");
res.json({
level: row ? row.value : getGlobalLogLevel(),
level: level ?? getGlobalLogLevel(),
});
} catch (err) {
authLogger.error("Failed to get log level", err);
@@ -214,8 +203,8 @@ export function registerUserSettingsRoutes(
router.patch("/log-level", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0 || !user[0].isAdmin) {
const actor = await getAdminActor(userId);
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const { level } = req.body;
@@ -225,22 +214,13 @@ export function registerUserSettingsRoutes(
.status(400)
.json({ error: "level must be one of: debug, info, warn, error" });
}
db.$client
.prepare(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('log_level', ?)",
)
.run(level);
await createCurrentSettingsRepository().set("log_level", level);
setGlobalLogLevel(level);
const { ipAddress, userAgent } = getRequestMeta(req);
const actorRecord = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: actorRecord[0]?.username ?? userId,
username: actor.username ?? userId,
action: "update_log_level",
resourceType: "setting",
details: JSON.stringify({ level }),
@@ -270,13 +250,11 @@ export function registerUserSettingsRoutes(
*/
router.get("/session-timeout", authenticateJWT, async (_req, res) => {
try {
const row = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'session_timeout_hours'",
)
.get() as { value: string } | undefined;
const value = await createCurrentSettingsRepository().get(
"session_timeout_hours",
);
res.json({
timeoutHours: row ? parseInt(row.value, 10) : 24,
timeoutHours: value ? parseInt(value, 10) : 24,
});
} catch (err) {
authLogger.error("Failed to get session timeout", err);
@@ -303,8 +281,8 @@ export function registerUserSettingsRoutes(
router.patch("/session-timeout", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0 || !user[0].isAdmin) {
const actor = await getAdminActor(userId);
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const { timeoutHours } = req.body;
@@ -317,21 +295,15 @@ export function registerUserSettingsRoutes(
.status(400)
.json({ error: "timeoutHours must be between 1 and 720" });
}
db.$client
.prepare(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('session_timeout_hours', ?)",
)
.run(String(timeoutHours));
await createCurrentSettingsRepository().set(
"session_timeout_hours",
String(timeoutHours),
);
const { ipAddress, userAgent } = getRequestMeta(req);
const actorRecord = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: actorRecord[0]?.username ?? userId,
username: actor.username ?? userId,
action: "update_session_timeout",
resourceType: "setting",
details: JSON.stringify({ timeoutHours }),
@@ -371,10 +343,9 @@ export function registerUserSettingsRoutes(
*/
router.get("/tailscale-settings", authenticateJWT, async (_req, res) => {
try {
const row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'tailscale_api_key'")
.get() as { value: string } | undefined;
const apiKey = row?.value ?? "";
const apiKey =
(await createCurrentSettingsRepository().get("tailscale_api_key")) ??
"";
res.json({
apiKey: apiKey
? `${apiKey.slice(0, 6)}${"*".repeat(Math.max(0, apiKey.length - 6))}`
@@ -415,29 +386,20 @@ export function registerUserSettingsRoutes(
router.patch("/tailscale-settings", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0 || !user[0].isAdmin) {
const actor = await getAdminActor(userId);
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const { apiKey } = req.body;
if (typeof apiKey !== "string") {
return res.status(400).json({ error: "apiKey must be a string" });
}
db.$client
.prepare(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('tailscale_api_key', ?)",
)
.run(apiKey);
await createCurrentSettingsRepository().set("tailscale_api_key", apiKey);
const { ipAddress, userAgent } = getRequestMeta(req);
const actorRecord = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: actorRecord[0]?.username ?? userId,
username: actor.username ?? userId,
action: "update_tailscale_settings",
resourceType: "setting",
details: JSON.stringify({ hasApiKey: !!apiKey }),
@@ -474,12 +436,12 @@ export function registerUserSettingsRoutes(
*/
router.get("/command-history-enabled", authenticateJWT, async (_req, res) => {
try {
const row = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'command_history_enabled'",
)
.get() as { value: string } | undefined;
res.json({ enabled: row ? row.value !== "false" : true });
res.json({
enabled: await createCurrentSettingsRepository().getBoolean(
"command_history_enabled",
true,
),
});
} catch (err) {
authLogger.error("Failed to get command history enabled setting", err);
res
@@ -519,29 +481,23 @@ export function registerUserSettingsRoutes(
async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0 || !user[0].isAdmin) {
const actor = await getAdminActor(userId);
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const { enabled } = req.body;
if (typeof enabled !== "boolean") {
return res.status(400).json({ error: "enabled must be a boolean" });
}
db.$client
.prepare(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('command_history_enabled', ?)",
)
.run(enabled ? "true" : "false");
await createCurrentSettingsRepository().set(
"command_history_enabled",
enabled ? "true" : "false",
);
const { ipAddress, userAgent } = getRequestMeta(req);
const actorRecord = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: actorRecord[0]?.username ?? userId,
username: actor.username ?? userId,
action: "update_command_history_enabled",
resourceType: "setting",
details: JSON.stringify({ enabled }),
@@ -579,10 +535,9 @@ export function registerUserSettingsRoutes(
*/
router.get("/host-defaults", authenticateJWT, async (_req, res) => {
try {
const row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'host_defaults'")
.get() as { value: string } | undefined;
const defaults: HostDefaults = row ? JSON.parse(row.value) : {};
const value =
await createCurrentSettingsRepository().get("host_defaults");
const defaults: HostDefaults = value ? JSON.parse(value) : {};
res.json(defaults);
} catch (err) {
authLogger.error("Failed to get host defaults", err);
@@ -615,26 +570,20 @@ export function registerUserSettingsRoutes(
router.patch("/host-defaults", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0 || !user[0].isAdmin) {
const actor = await getAdminActor(userId);
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const defaults: HostDefaults = req.body;
db.$client
.prepare(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('host_defaults', ?)",
)
.run(JSON.stringify(defaults));
await createCurrentSettingsRepository().set(
"host_defaults",
JSON.stringify(defaults),
);
const { ipAddress, userAgent } = getRequestMeta(req);
const actorRecord = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: actorRecord[0]?.username ?? userId,
username: actor.username ?? userId,
action: "update_host_defaults",
resourceType: "setting",
details: JSON.stringify(defaults),
@@ -1,83 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import bcrypt from "bcryptjs";
import speakeasy from "speakeasy";
// The route module imports the db barrel (which has filesystem/crypto side
// effects on import) plus the logger; stub both so importing stays inert.
const updateWhere = vi.fn().mockResolvedValue(undefined);
const updateSet = vi.fn(() => ({ where: updateWhere }));
const dbUpdate = vi.fn(() => ({ set: updateSet }));
vi.mock("../db/index.js", () => ({
db: {
update: dbUpdate,
},
}));
vi.mock("../../utils/logger.js", () => ({
authLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
const { verifyTotpReauth } = await import("./user-totp-routes.js");
type AnyUser = Parameters<typeof verifyTotpReauth>[0];
const secret = speakeasy.generateSecret({ name: "test" }).base32;
function makeUser(overrides: Partial<AnyUser> = {}): AnyUser {
return {
id: "user-1",
isOidc: false,
passwordHash: bcrypt.hashSync("correct-horse", 4),
totpSecret: secret,
totpBackupCodes: JSON.stringify(["BACKUP01", "BACKUP02"]),
totpEnabled: true,
...overrides,
} as AnyUser;
}
describe("verifyTotpReauth", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("accepts the correct password", async () => {
expect(await verifyTotpReauth(makeUser(), "correct-horse")).toBe(true);
});
it("accepts a valid TOTP code without a password", async () => {
const token = speakeasy.totp({ secret, encoding: "base32" });
expect(await verifyTotpReauth(makeUser(), token)).toBe(true);
});
it("accepts a valid backup code and consumes it", async () => {
const result = await verifyTotpReauth(makeUser(), "BACKUP01");
expect(result).toBe(true);
expect(updateSet).toHaveBeenCalledWith({
totpBackupCodes: JSON.stringify(["BACKUP02"]),
});
});
it("rejects a wrong password / invalid code", async () => {
expect(await verifyTotpReauth(makeUser(), "wrong")).toBe(false);
expect(updateSet).not.toHaveBeenCalled();
});
it("ignores the password path for OIDC users but still accepts TOTP", async () => {
const token = speakeasy.totp({ secret, encoding: "base32" });
const oidcUser = makeUser({ isOidc: true, passwordHash: null });
expect(await verifyTotpReauth(oidcUser, token)).toBe(true);
expect(await verifyTotpReauth(oidcUser, "anything")).toBe(false);
});
it("handles malformed backup-code JSON without throwing", async () => {
const user = makeUser({ totpBackupCodes: "not json" });
expect(await verifyTotpReauth(user, "BACKUP01")).toBe(false);
});
});
+94 -120
View File
@@ -1,10 +1,10 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Router } from "express";
import { and, eq, ne } from "drizzle-orm";
import bcrypt from "bcryptjs";
import QRCode from "qrcode";
import speakeasy from "speakeasy";
import { AuthManager } from "../../utils/auth-manager.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { FieldCrypto } from "../../utils/field-crypto.js";
import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js";
import { authLogger } from "../../utils/logger.js";
@@ -13,8 +13,13 @@ import {
generateDeviceFingerprint,
parseUserAgent,
} from "../../utils/user-agent-parser.js";
import { db } from "../db/index.js";
import { sessions, trustedDevices, users } from "../db/schema.js";
import {
createCurrentSessionRepository,
createCurrentSettingsRepository,
createCurrentTrustedDeviceRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
import type { UserRecord } from "../repositories/user-repository.js";
type NativeAppRequestChecker = (req: Request) => boolean;
@@ -24,23 +29,11 @@ interface UserTotpRoutesDeps {
isNativeAppRequest: NativeAppRequestChecker;
}
type TotpUserRecord = typeof users.$inferSelect;
export async function verifyTotpReauth(
userRecord: TotpUserRecord,
userRecord: UserRecord,
credential: string,
userDataKey?: Buffer | null,
): Promise<boolean> {
if (!userRecord.isOidc && userRecord.passwordHash) {
const passwordMatch = await bcrypt.compare(
credential,
userRecord.passwordHash,
);
if (passwordMatch) {
return true;
}
}
if (userRecord.totpSecret) {
const totpSecret = userDataKey
? LazyFieldEncryption.safeGetFieldValue(
@@ -93,10 +86,9 @@ export async function verifyTotpReauth(
"totpBackupCodes",
)
: updatedJson;
await db
.update(users)
.set({ totpBackupCodes: storedValue })
.where(eq(users.id, userRecord.id));
await createCurrentUserRepository().update(userRecord.id, {
totpBackupCodes: storedValue,
});
return true;
}
}
@@ -130,13 +122,11 @@ export function registerUserTotpRoutes(
const userId = (req as AuthenticatedRequest).userId;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0) {
const userRecord = await createCurrentUserRepository().findById(userId);
if (!userRecord) {
return res.status(404).json({ error: "User not found" });
}
const userRecord = user[0];
if (userRecord.totpEnabled) {
return res.status(400).json({ error: "TOTP is already enabled" });
}
@@ -146,10 +136,9 @@ export function registerUserTotpRoutes(
length: 32,
});
await db
.update(users)
.set({ totpSecret: secret.base32 })
.where(eq(users.id, userId));
await createCurrentUserRepository().update(userId, {
totpSecret: secret.base32,
});
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url || "");
@@ -202,14 +191,11 @@ export function registerUserTotpRoutes(
}
try {
const passwordLoginRow = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'allow_password_login'",
)
.get() as { value: string } | undefined;
const passwordLoginAllowed = passwordLoginRow
? passwordLoginRow.value === "true"
: true;
const passwordLoginAllowed =
await createCurrentSettingsRepository().getBoolean(
"allow_password_login",
true,
);
if (!passwordLoginAllowed) {
return res.status(409).json({
error:
@@ -217,13 +203,11 @@ export function registerUserTotpRoutes(
});
}
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0) {
const userRecord = await createCurrentUserRepository().findById(userId);
if (!userRecord) {
return res.status(404).json({ error: "User not found" });
}
const userRecord = user[0];
if (userRecord.totpEnabled) {
return res.status(400).json({ error: "TOTP is already enabled" });
}
@@ -267,26 +251,19 @@ export function registerUserTotpRoutes(
)
: backupCodesJson;
await db
.update(users)
.set({
totpEnabled: true,
totpBackupCodes: storedBackupCodes,
})
.where(eq(users.id, userId));
await createCurrentUserRepository().update(userId, {
totpEnabled: true,
totpBackupCodes: storedBackupCodes,
});
await db
.delete(sessions)
.where(
sessionId
? and(eq(sessions.userId, userId), ne(sessions.id, sessionId))
: eq(sessions.userId, userId),
);
await db.delete(trustedDevices).where(eq(trustedDevices.userId, userId));
await createCurrentSessionRepository().revokeAllForUser(
userId,
sessionId,
);
await createCurrentTrustedDeviceRepository().deleteByUserId(userId);
try {
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
await DatabaseSaveTrigger.forceSave("totp_enable_explicit_save");
} catch (saveError) {
authLogger.error(
"Failed to persist TOTP enablement to disk",
@@ -342,21 +319,27 @@ export function registerUserTotpRoutes(
router.post("/totp/disable", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { password, totp_code } = req.body;
const credential = password || totp_code;
if (!credential) {
return res
.status(400)
.json({ error: "A TOTP code or password is required" });
}
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0) {
const userRecord = await createCurrentUserRepository().findById(userId);
if (!userRecord) {
return res.status(404).json({ error: "User not found" });
}
const userRecord = user[0];
if (!totp_code || (!userRecord.isOidc && !password)) {
return res.status(400).json({
error: userRecord.isOidc
? "A TOTP code is required"
: "Both password and TOTP code are required",
});
}
if (
!userRecord.isOidc &&
(!userRecord.passwordHash ||
!(await bcrypt.compare(password, userRecord.passwordHash)))
) {
return res.status(401).json({ error: "Incorrect password" });
}
if (!userRecord.totpEnabled) {
return res.status(400).json({ error: "TOTP is not enabled" });
@@ -365,7 +348,7 @@ export function registerUserTotpRoutes(
const userDataKey = authManager.getUserDataKey(userId);
const verified = await verifyTotpReauth(
userRecord,
credential,
totp_code,
userDataKey,
);
if (!verified) {
@@ -374,14 +357,11 @@ export function registerUserTotpRoutes(
.json({ error: "Incorrect password or invalid TOTP code" });
}
await db
.update(users)
.set({
totpEnabled: false,
totpSecret: null,
totpBackupCodes: null,
})
.where(eq(users.id, userId));
await createCurrentUserRepository().update(userId, {
totpEnabled: false,
totpSecret: null,
totpBackupCodes: null,
});
authLogger.info("Two-factor authentication disabled", {
operation: "totp_disable",
userId,
@@ -428,21 +408,27 @@ export function registerUserTotpRoutes(
router.post("/totp/backup-codes", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { password, totp_code } = req.body;
const credential = password || totp_code;
if (!credential) {
return res
.status(400)
.json({ error: "A TOTP code or password is required" });
}
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0) {
const userRecord = await createCurrentUserRepository().findById(userId);
if (!userRecord) {
return res.status(404).json({ error: "User not found" });
}
const userRecord = user[0];
if (!totp_code || (!userRecord.isOidc && !password)) {
return res.status(400).json({
error: userRecord.isOidc
? "A TOTP code is required"
: "Both password and TOTP code are required",
});
}
if (
!userRecord.isOidc &&
(!userRecord.passwordHash ||
!(await bcrypt.compare(password, userRecord.passwordHash)))
) {
return res.status(401).json({ error: "Incorrect password" });
}
if (!userRecord.totpEnabled) {
return res.status(400).json({ error: "TOTP is not enabled" });
@@ -451,7 +437,7 @@ export function registerUserTotpRoutes(
const userDataKey = authManager.getUserDataKey(userId);
const verified = await verifyTotpReauth(
userRecord,
credential,
totp_code,
userDataKey,
);
if (!verified) {
@@ -474,10 +460,9 @@ export function registerUserTotpRoutes(
)
: backupCodesJson;
await db
.update(users)
.set({ totpBackupCodes: storedBackupCodes })
.where(eq(users.id, userId));
await createCurrentUserRepository().update(userId, {
totpBackupCodes: storedBackupCodes,
});
res.json({ backup_codes: backupCodes });
} catch (err) {
@@ -532,16 +517,13 @@ export function registerUserTotpRoutes(
return res.status(401).json({ error: "Invalid temporary token" });
}
const user = await db
.select()
.from(users)
.where(eq(users.id, decoded.userId));
if (!user || user.length === 0) {
const userRecord = await createCurrentUserRepository().findById(
decoded.userId,
);
if (!userRecord) {
return res.status(404).json({ error: "User not found" });
}
const userRecord = user[0];
const lockStatus = loginRateLimiter.isTOTPLocked(userRecord.id);
if (lockStatus.locked) {
authLogger.warn("TOTP verification blocked due to rate limiting", {
@@ -580,14 +562,11 @@ export function registerUserTotpRoutes(
);
if (!totpSecret) {
await db
.update(users)
.set({
totpEnabled: false,
totpSecret: null,
totpBackupCodes: null,
})
.where(eq(users.id, userRecord.id));
await createCurrentUserRepository().update(userRecord.id, {
totpEnabled: false,
totpSecret: null,
totpBackupCodes: null,
});
return res.status(400).json({
error:
@@ -635,10 +614,9 @@ export function registerUserTotpRoutes(
}
backupCodes.splice(backupIndex, 1);
await db
.update(users)
.set({ totpBackupCodes: JSON.stringify(backupCodes) })
.where(eq(users.id, userRecord.id));
await createCurrentUserRepository().update(userRecord.id, {
totpBackupCodes: JSON.stringify(backupCodes),
});
}
loginRateLimiter.resetTOTPAttempts(userRecord.id);
@@ -683,14 +661,10 @@ export function registerUserTotpRoutes(
...(isNativeAppRequest(req) ? { token } : {}),
};
const timeoutRow = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'session_timeout_hours'",
)
.get() as { value: string } | undefined;
const timeoutHours = timeoutRow
? parseInt(timeoutRow.value, 10) || 24
: 24;
const timeoutValue = await createCurrentSettingsRepository().get(
"session_timeout_hours",
);
const timeoutHours = timeoutValue ? parseInt(timeoutValue, 10) || 24 : 24;
const maxAge = rememberMe
? 30 * 24 * 60 * 60 * 1000
: timeoutHours * 60 * 60 * 1000;
@@ -12,7 +12,6 @@ import {
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import { and, eq } from "drizzle-orm";
import { nanoid } from "nanoid";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { AuthManager } from "../../utils/auth-manager.js";
@@ -21,8 +20,12 @@ import {
generateDeviceFingerprint,
parseUserAgent,
} from "../../utils/user-agent-parser.js";
import { db, saveMemoryDatabaseToFile } from "../db/index.js";
import { users, webauthnCredentials } from "../db/schema.js";
import {
createCurrentUserRepository,
createCurrentWebauthnCredentialRepository,
getCurrentSettingValue,
} from "../repositories/factory.js";
import type { WebauthnCredentialRecord } from "../repositories/webauthn-credential-repository.js";
type UserVerification = "discouraged" | "preferred" | "required";
type NativeAppRequestChecker = (req: Request) => boolean;
@@ -113,7 +116,7 @@ function parseTransports(value: string | null): AuthenticatorTransportFuture[] {
}
function getCredentialForVerification(
credential: typeof webauthnCredentials.$inferSelect,
credential: WebauthnCredentialRecord,
): WebAuthnCredential {
return {
id: credential.credentialId as Base64URLString,
@@ -129,16 +132,28 @@ export function registerUserWebAuthnRoutes(
router: Router,
{ authenticateJWT, authManager, isNativeAppRequest }: WebAuthnRoutesDeps,
): void {
/**
* @openapi
* /users/webauthn/credentials:
* get:
* summary: List passkeys
* description: Lists the authenticated user's registered passkeys.
* tags:
* - WebAuthn
* responses:
* 200:
* description: List of passkeys.
* 401:
* description: Authentication required.
*/
router.get("/webauthn/credentials", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
const credentials = await db
.select()
.from(webauthnCredentials)
.where(eq(webauthnCredentials.userId, userId));
const credentials =
await createCurrentWebauthnCredentialRepository().listByUserId(userId);
res.json({
credentials: credentials.map((credential) => ({
@@ -154,6 +169,22 @@ export function registerUserWebAuthnRoutes(
});
});
/**
* @openapi
* /users/webauthn/register/options:
* post:
* summary: Start passkey registration
* description: Generates WebAuthn registration options for the authenticated user.
* tags:
* - WebAuthn
* responses:
* 200:
* description: Registration options and challenge id.
* 401:
* description: Authentication required.
* 404:
* description: User not found.
*/
router.post(
"/webauthn/register/options",
authenticateJWT,
@@ -169,15 +200,13 @@ export function registerUserWebAuthnRoutes(
});
}
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user.length) {
const user = await createCurrentUserRepository().findById(userId);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
const existing = await db
.select()
.from(webauthnCredentials)
.where(eq(webauthnCredentials.userId, userId));
const existing =
await createCurrentWebauthnCredentialRepository().listByUserId(userId);
const origin = getRequestOrigin(req);
const rpID = getRpID(origin);
@@ -189,8 +218,8 @@ export function registerUserWebAuthnRoutes(
rpName: "Termix",
rpID,
userID: Buffer.from(userId, "utf8"),
userName: user[0].username,
userDisplayName: user[0].username,
userName: user.username,
userDisplayName: user.username,
attestationType: "none",
excludeCredentials: existing.map((credential) => ({
id: credential.credentialId as Base64URLString,
@@ -214,6 +243,22 @@ export function registerUserWebAuthnRoutes(
},
);
/**
* @openapi
* /users/webauthn/register/verify:
* post:
* summary: Finish passkey registration
* description: Verifies the WebAuthn registration response and stores the passkey.
* tags:
* - WebAuthn
* responses:
* 200:
* description: Passkey registered.
* 400:
* description: Registration failed or challenge expired.
* 401:
* description: Authentication required.
*/
router.post(
"/webauthn/register/verify",
authenticateJWT,
@@ -252,14 +297,12 @@ export function registerUserWebAuthnRoutes(
(req.body?.response as RegistrationResponseJSON | undefined)?.response
?.transports ?? [];
await authManager.setupWebAuthnUserEncryption(userId);
const name =
typeof req.body?.name === "string" && req.body.name.trim()
? req.body.name.trim().slice(0, 80)
: "Passkey";
await db.insert(webauthnCredentials).values({
await createCurrentWebauthnCredentialRepository().create({
id: nanoid(),
userId,
name,
@@ -273,7 +316,6 @@ export function registerUserWebAuthnRoutes(
createdAt: new Date().toISOString(),
});
await saveMemoryDatabaseToFile();
res.json({ success: true });
} catch (error) {
authLogger.warn("WebAuthn registration failed", {
@@ -286,6 +328,20 @@ export function registerUserWebAuthnRoutes(
},
);
/**
* @openapi
* /users/webauthn/authenticate/options:
* post:
* summary: Start passkey login
* description: Generates WebAuthn authentication options, optionally scoped to a username.
* tags:
* - WebAuthn
* responses:
* 200:
* description: Authentication options and challenge id.
* 404:
* description: No passkeys found for the user.
*/
router.post("/webauthn/authenticate/options", async (req, res) => {
const origin = getRequestOrigin(req);
const rpID = getRpID(origin);
@@ -301,19 +357,14 @@ export function registerUserWebAuthnRoutes(
| undefined;
if (username) {
const user = await db
.select()
.from(users)
.where(eq(users.username, username));
if (!user.length) {
const user = await createCurrentUserRepository().findByUsername(username);
if (!user) {
return res.status(404).json({ error: "No passkeys found" });
}
userId = user[0].id;
const credentials = await db
.select()
.from(webauthnCredentials)
.where(eq(webauthnCredentials.userId, userId));
userId = user.id;
const credentials =
await createCurrentWebauthnCredentialRepository().listByUserId(userId);
if (!credentials.length) {
return res.status(404).json({ error: "No passkeys found" });
@@ -342,6 +393,22 @@ export function registerUserWebAuthnRoutes(
res.json({ options, challengeId });
});
/**
* @openapi
* /users/webauthn/authenticate/verify:
* post:
* summary: Finish passkey login
* description: Verifies the WebAuthn assertion and issues a session token (or a TOTP challenge).
* tags:
* - WebAuthn
* responses:
* 200:
* description: Login succeeded or TOTP verification required.
* 400:
* description: Challenge expired or invalid response.
* 401:
* description: Passkey not recognized or authentication failed.
*/
router.post("/webauthn/authenticate/verify", async (req, res) => {
const challenge = takeChallenge(
authenticationChallenges,
@@ -360,16 +427,15 @@ export function registerUserWebAuthnRoutes(
return res.status(400).json({ error: "Invalid passkey response" });
}
const credentials = await db
.select()
.from(webauthnCredentials)
.where(eq(webauthnCredentials.credentialId, response.id));
const credential =
await createCurrentWebauthnCredentialRepository().findByCredentialId(
response.id,
);
if (!credentials.length) {
if (!credential) {
return res.status(401).json({ error: "Passkey not recognized" });
}
const credential = credentials[0];
if (challenge.userId && challenge.userId !== credential.userId) {
return res.status(401).json({ error: "Passkey not recognized" });
}
@@ -391,37 +457,35 @@ export function registerUserWebAuthnRoutes(
return res.status(401).json({ error: "Passkey authentication failed" });
}
const user = await db
.select()
.from(users)
.where(eq(users.id, credential.userId));
if (!user.length) {
const userRecord = await createCurrentUserRepository().findById(
credential.userId,
);
if (!userRecord) {
return res.status(404).json({ error: "User not found" });
}
const userRecord = user[0];
const deviceInfo = parseUserAgent(req);
const dataUnlocked = await authManager.authenticateWebAuthnUser(
const authenticated = await authManager.authenticateWebAuthnUser(
userRecord.id,
deviceInfo.type,
);
if (!dataUnlocked) {
if (!authenticated) {
return res.status(401).json({
error:
"Passkey cannot unlock this account. Log in with password and register the passkey again.",
});
}
await db
.update(webauthnCredentials)
.set({
await createCurrentWebauthnCredentialRepository().updateAuthState(
credential.id,
{
counter: verification.authenticationInfo.newCounter,
backedUp: verification.authenticationInfo.credentialBackedUp,
deviceType: verification.authenticationInfo.credentialDeviceType,
lastUsedAt: new Date().toISOString(),
})
.where(eq(webauthnCredentials.id, credential.id));
},
);
if (userRecord.totpEnabled) {
const deviceFingerprint = generateDeviceFingerprint(deviceInfo);
@@ -431,7 +495,6 @@ export function registerUserWebAuthnRoutes(
);
if (!isTrusted) {
await saveMemoryDatabaseToFile();
const tempToken = await authManager.generateJWTToken(userRecord.id, {
pendingTOTP: true,
expiresIn: "10m",
@@ -451,15 +514,9 @@ export function registerUserWebAuthnRoutes(
deviceInfo: deviceInfo.deviceInfo,
});
await saveMemoryDatabaseToFile();
const timeoutRow = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'session_timeout_hours'",
)
.get() as { value: string } | undefined;
const timeoutHours = timeoutRow
? parseInt(timeoutRow.value, 10) || 24
const timeoutSetting = getCurrentSettingValue("session_timeout_hours");
const timeoutHours = timeoutSetting
? parseInt(timeoutSetting, 10) || 24
: 24;
const maxAge = req.body?.rememberMe
? 30 * 24 * 60 * 60 * 1000
@@ -473,7 +530,6 @@ export function registerUserWebAuthnRoutes(
userId: userRecord.id,
is_oidc: !!userRecord.isOidc,
totp_enabled: !!userRecord.totpEnabled,
data_unlocked: true,
...(isNativeAppRequest(req) ? { token } : {}),
});
} catch (error) {
@@ -487,6 +543,25 @@ export function registerUserWebAuthnRoutes(
}
});
/**
* @openapi
* /users/webauthn/credentials/{credentialId}:
* delete:
* summary: Delete a passkey
* description: Removes one of the authenticated user's passkeys.
* tags:
* - WebAuthn
* parameters:
* - in: path
* name: credentialId
* required: true
* schema: { type: string }
* responses:
* 200:
* description: Passkey deleted.
* 401:
* description: Authentication required.
*/
router.delete(
"/webauthn/credentials/:credentialId",
authenticateJWT,
@@ -498,15 +573,10 @@ export function registerUserWebAuthnRoutes(
const credentialId = String(req.params.credentialId);
await db
.delete(webauthnCredentials)
.where(
and(
eq(webauthnCredentials.id, credentialId),
eq(webauthnCredentials.userId, userId),
),
);
await saveMemoryDatabaseToFile();
await createCurrentWebauthnCredentialRepository().deleteForUser(
userId,
credentialId,
);
res.json({ success: true });
},
File diff suppressed because it is too large Load Diff
+42 -59
View File
@@ -1,12 +1,14 @@
import express from "express";
import type { Request, Response } from "express";
import { desc, eq, or } from "drizzle-orm";
import { db } from "../db/index.js";
import { users, vaultProfiles } from "../db/schema.js";
import {
createCurrentVaultProfileRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
import type { VaultProfileUpdateInput } from "../repositories/vault-profile-repository.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { completeVaultAuth } from "../../ssh/vault-oidc-auth.js";
import { completeVaultAuth } from "../../hosts/vault-oidc-auth.js";
const router = express.Router();
@@ -19,12 +21,8 @@ function isNonEmptyString(val: unknown): val is string {
async function userIsAdmin(userId: string): Promise<boolean> {
try {
const rows = await db
.select({ isAdmin: users.isAdmin })
.from(users)
.where(eq(users.id, userId))
.limit(1);
return !!rows[0]?.isAdmin;
const user = await createCurrentUserRepository().findById(userId);
return !!user?.isAdmin;
} catch {
return false;
}
@@ -148,13 +146,8 @@ router.get(
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const rows = await db
.select()
.from(vaultProfiles)
.where(
or(eq(vaultProfiles.userId, userId), eq(vaultProfiles.shared, true)),
)
.orderBy(desc(vaultProfiles.updatedAt));
const rows =
await createCurrentVaultProfileRepository().listVisibleToUser(userId);
res.json(
rows.map((r) => formatProfile(r as Record<string, unknown>, userId)),
);
@@ -253,28 +246,25 @@ router.post(
}
try {
const inserted = await db
.insert(vaultProfiles)
.values({
userId,
name: name.trim(),
description: description?.trim() || null,
folder: folder?.trim() || null,
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
vaultAddr: vaultAddr.trim(),
vaultNamespace: vaultNamespace?.trim() || null,
oidcMount: oidcMount?.trim() || null,
oidcRole: oidcRole?.trim() || null,
sshMount: sshMount?.trim() || null,
sshRole: sshRole.trim(),
validPrincipals: validPrincipals?.trim() || null,
keyType: keyType?.trim() || null,
shared: wantShared,
})
.returning();
const inserted = await createCurrentVaultProfileRepository().create({
userId,
name: name.trim(),
description: description?.trim() || null,
folder: folder?.trim() || null,
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
vaultAddr: vaultAddr.trim(),
vaultNamespace: vaultNamespace?.trim() || null,
oidcMount: oidcMount?.trim() || null,
oidcRole: oidcRole?.trim() || null,
sshMount: sshMount?.trim() || null,
sshRole: sshRole.trim(),
validPrincipals: validPrincipals?.trim() || null,
keyType: keyType?.trim() || null,
shared: wantShared,
});
res
.status(201)
.json(formatProfile(inserted[0] as Record<string, unknown>, userId));
.json(formatProfile(inserted as Record<string, unknown>, userId));
} catch (err) {
authLogger.error("Failed to create vault profile", err);
res.status(500).json({ error: "Failed to create vault profile" });
@@ -324,22 +314,19 @@ router.put(
}
try {
const existing = await db
.select()
.from(vaultProfiles)
.where(eq(vaultProfiles.id, id))
.limit(1);
if (!existing.length) {
const repository = createCurrentVaultProfileRepository();
const existing = await repository.findById(id);
if (!existing) {
return res.status(404).json({ error: "Profile not found" });
}
if (existing[0].userId !== userId) {
if (existing.userId !== userId) {
return res
.status(403)
.json({ error: "Only the owner can edit this profile" });
}
const body = req.body;
const fields: Record<string, unknown> = {
const fields: VaultProfileUpdateInput = {
updatedAt: new Date().toISOString(),
};
if (body.name !== undefined) fields.name = body.name?.trim();
@@ -376,12 +363,11 @@ router.put(
fields.shared = !!body.shared;
}
const updated = await db
.update(vaultProfiles)
.set(fields)
.where(eq(vaultProfiles.id, id))
.returning();
res.json(formatProfile(updated[0] as Record<string, unknown>, userId));
const updated = await repository.updateById(id, fields);
if (!updated) {
return res.status(404).json({ error: "Profile not found" });
}
res.json(formatProfile(updated as Record<string, unknown>, userId));
} catch (err) {
authLogger.error("Failed to update vault profile", err);
res.status(500).json({ error: "Failed to update vault profile" });
@@ -425,20 +411,17 @@ router.delete(
return res.status(400).json({ error: "Invalid profile id" });
}
try {
const existing = await db
.select()
.from(vaultProfiles)
.where(eq(vaultProfiles.id, id))
.limit(1);
if (!existing.length) {
const repository = createCurrentVaultProfileRepository();
const existing = await repository.findById(id);
if (!existing) {
return res.status(404).json({ error: "Profile not found" });
}
if (existing[0].userId !== userId) {
if (existing.userId !== userId) {
return res
.status(403)
.json({ error: "Only the owner can delete this profile" });
}
await db.delete(vaultProfiles).where(eq(vaultProfiles.id, id));
await repository.deleteById(id);
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to delete vault profile", err);