mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
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 commit0712fdd731. * 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 commit0712fdd731. * 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:
562 files changed
+52303
-23645
No files matched your search
+319
-368
@@ -11,7 +11,7 @@ import snippetsRoutes from "./routes/snippets.js";
|
||||
import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
|
||||
import terminalRoutes from "./routes/terminal.js";
|
||||
import sessionLogRoutes from "./routes/session-log-routes.js";
|
||||
import guacamoleRoutes from "../guacamole/routes.js";
|
||||
import guacamoleRoutes from "../hosts/guacamole/routes.js";
|
||||
import networkTopologyRoutes from "./routes/network-topology.js";
|
||||
import rbacRoutes from "./routes/rbac.js";
|
||||
import openTabsRoutes from "./routes/open-tabs.js";
|
||||
@@ -34,27 +34,25 @@ import { DatabaseFileEncryption } from "../utils/database-file-encryption.js";
|
||||
import { DatabaseMigration } from "../utils/database-migration.js";
|
||||
import { UserDataExport } from "../utils/user-data-export.js";
|
||||
import { AutoSSLSetup } from "../utils/auto-ssl-setup.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import {
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentDismissedAlertRepository,
|
||||
createCurrentFileManagerBookmarkRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentSshCredentialUsageRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "./repositories/factory.js";
|
||||
import { withCurrentSqliteForeignKeysDisabled } from "./repositories/sqlite-foreign-keys.js";
|
||||
import { parseUserAgent } from "../utils/user-agent-parser.js";
|
||||
import { getProxyAgent } from "../utils/proxy-agent.js";
|
||||
import {
|
||||
users,
|
||||
hosts,
|
||||
sshCredentials,
|
||||
fileManagerRecent,
|
||||
fileManagerPinned,
|
||||
fileManagerShortcuts,
|
||||
dismissedAlerts,
|
||||
sshCredentialUsage,
|
||||
settings,
|
||||
} from "./db/schema.js";
|
||||
import type {
|
||||
CacheEntry,
|
||||
GitHubRelease,
|
||||
GitHubAPIResponse,
|
||||
AuthenticatedRequest,
|
||||
} from "../../types/index.js";
|
||||
import { getDb, DatabaseSaveTrigger } from "./db/index.js";
|
||||
import { DatabaseSaveTrigger } from "./db/index.js";
|
||||
import Database from "better-sqlite3";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
@@ -70,6 +68,45 @@ const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireAdmin = authManager.createAdminMiddleware();
|
||||
app.use(createCorsMiddleware());
|
||||
|
||||
type SettingData = {
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
function shouldExportSetting(key: string): boolean {
|
||||
return !key.startsWith("reset_code_") && !key.startsWith("temp_reset_token_");
|
||||
}
|
||||
|
||||
async function getExportableSettings(): Promise<SettingData[]> {
|
||||
const settingsRows = await createCurrentSettingsRepository().listAll();
|
||||
|
||||
return settingsRows.filter((setting) => shouldExportSetting(setting.key));
|
||||
}
|
||||
|
||||
function writeSettingsToExportDatabase(
|
||||
exportDb: Database.Database,
|
||||
settingsRows: SettingData[],
|
||||
): void {
|
||||
const insertSetting = exportDb.prepare(`
|
||||
INSERT INTO settings (key, value)
|
||||
VALUES (?, ?)
|
||||
`);
|
||||
|
||||
for (const setting of settingsRows) {
|
||||
insertSetting.run(setting.key, setting.value);
|
||||
}
|
||||
}
|
||||
|
||||
function readImportedSettings(importDb: Database.Database): SettingData[] {
|
||||
return importDb
|
||||
.prepare("SELECT key, value FROM settings")
|
||||
.all() as SettingData[];
|
||||
}
|
||||
|
||||
async function upsertImportedSetting(setting: SettingData): Promise<void> {
|
||||
await createCurrentSettingsRepository().upsert(setting.key, setting.value);
|
||||
}
|
||||
|
||||
const uploadsDir = path.join(process.env.DATA_DIR || "./db/data", "uploads");
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
@@ -621,12 +658,13 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
|
||||
const user = await getDb().select().from(users).where(eq(users.id, userId));
|
||||
if (!user || user.length === 0) {
|
||||
const userRepository = createCurrentUserRepository();
|
||||
const user = await userRepository.findById(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
const isOidcUser = !!user[0].isOidc;
|
||||
const isOidcUser = !!user.isOidc;
|
||||
|
||||
if (!DataCrypto.getUserDataKey(userId)) {
|
||||
if (isOidcUser) {
|
||||
@@ -867,22 +905,14 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
userRecord.totpBackupCodes || null,
|
||||
);
|
||||
|
||||
const sshHosts = await getDb()
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(eq(hosts.userId, userId));
|
||||
const sshHosts =
|
||||
await createCurrentHostRepository().listDecryptedByUserId(userId);
|
||||
const insertHost = exportDb.prepare(`
|
||||
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, autostart_password, autostart_key, autostart_key_password, credential_id, override_credential_username, enable_terminal, enable_tunnel, tunnel_connections, jump_hosts, enable_file_manager, enable_docker, show_terminal_in_sidebar, show_file_manager_in_sidebar, show_tunnel_in_sidebar, show_docker_in_sidebar, show_server_stats_in_sidebar, default_path, stats_config, docker_config, terminal_config, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, domain, security, ignore_cert, guacamole_config, mac_address, port_knock_sequence, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const host of sshHosts) {
|
||||
const decrypted = DataCrypto.decryptRecord(
|
||||
"ssh_data",
|
||||
host,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
for (const decrypted of sshHosts) {
|
||||
insertHost.run(
|
||||
decrypted.id,
|
||||
decrypted.userId,
|
||||
@@ -940,22 +970,14 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
);
|
||||
}
|
||||
|
||||
const credentials = await getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.userId, userId));
|
||||
const credentials =
|
||||
await createCurrentCredentialRepository().listDecryptedByUserId(userId);
|
||||
const insertCred = exportDb.prepare(`
|
||||
INSERT INTO ssh_credentials (id, user_id, name, description, folder, tags, auth_type, username, password, key, private_key, public_key, key_password, key_type, detected_key_type, usage_count, last_used, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const cred of credentials) {
|
||||
const decrypted = DataCrypto.decryptRecord(
|
||||
"ssh_credentials",
|
||||
cred,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
for (const decrypted of credentials) {
|
||||
insertCred.run(
|
||||
decrypted.id,
|
||||
decrypted.userId,
|
||||
@@ -979,19 +1001,12 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
);
|
||||
}
|
||||
|
||||
const fileManagerRepository =
|
||||
createCurrentFileManagerBookmarkRepository();
|
||||
const [recentFiles, pinnedFiles, shortcuts] = await Promise.all([
|
||||
getDb()
|
||||
.select()
|
||||
.from(fileManagerRecent)
|
||||
.where(eq(fileManagerRecent.userId, userId)),
|
||||
getDb()
|
||||
.select()
|
||||
.from(fileManagerPinned)
|
||||
.where(eq(fileManagerPinned.userId, userId)),
|
||||
getDb()
|
||||
.select()
|
||||
.from(fileManagerShortcuts)
|
||||
.where(eq(fileManagerShortcuts.userId, userId)),
|
||||
fileManagerRepository.listRecentByUserId(userId),
|
||||
fileManagerRepository.listPinnedByUserId(userId),
|
||||
fileManagerRepository.listShortcutsByUserId(userId),
|
||||
]);
|
||||
|
||||
const insertRecent = exportDb.prepare(`
|
||||
@@ -1039,10 +1054,8 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
);
|
||||
}
|
||||
|
||||
const alerts = await getDb()
|
||||
.select()
|
||||
.from(dismissedAlerts)
|
||||
.where(eq(dismissedAlerts.userId, userId));
|
||||
const dismissedAlertRepository = createCurrentDismissedAlertRepository();
|
||||
const alerts = await dismissedAlertRepository.listByUserId(userId);
|
||||
const insertAlert = exportDb.prepare(`
|
||||
INSERT INTO dismissed_alerts (id, user_id, alert_id, dismissed_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
@@ -1056,10 +1069,9 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
);
|
||||
}
|
||||
|
||||
const usage = await getDb()
|
||||
.select()
|
||||
.from(sshCredentialUsage)
|
||||
.where(eq(sshCredentialUsage.userId, userId));
|
||||
const sshCredentialUsageRepository =
|
||||
createCurrentSshCredentialUsageRepository();
|
||||
const usage = await sshCredentialUsageRepository.listByUserId(userId);
|
||||
const insertUsage = exportDb.prepare(`
|
||||
INSERT INTO ssh_credential_usage (id, credential_id, host_id, user_id, used_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
@@ -1074,20 +1086,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
);
|
||||
}
|
||||
|
||||
const settingsData = await getDb().select().from(settings);
|
||||
const insertSetting = exportDb.prepare(`
|
||||
INSERT INTO settings (key, value)
|
||||
VALUES (?, ?)
|
||||
`);
|
||||
for (const setting of settingsData) {
|
||||
if (
|
||||
setting.key.startsWith("reset_code_") ||
|
||||
setting.key.startsWith("temp_reset_token_")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
insertSetting.run(setting.key, setting.value);
|
||||
}
|
||||
writeSettingsToExportDatabase(exportDb, await getExportableSettings());
|
||||
} finally {
|
||||
exportDb.close();
|
||||
}
|
||||
@@ -1182,19 +1181,16 @@ app.post(
|
||||
}
|
||||
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const mainDb = getDb();
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
|
||||
const userRecords = await mainDb
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId));
|
||||
const userRepository = createCurrentUserRepository();
|
||||
const userRecord = await userRepository.findById(userId);
|
||||
|
||||
if (!userRecords || userRecords.length === 0) {
|
||||
if (!userRecord) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
const isOidcUser = !!userRecords[0].isOidc;
|
||||
const isOidcUser = !!userRecord.isOidc;
|
||||
|
||||
if (!DataCrypto.getUserDataKey(userId)) {
|
||||
if (isOidcUser) {
|
||||
@@ -1276,332 +1272,287 @@ app.post(
|
||||
};
|
||||
|
||||
try {
|
||||
mainDb.$client.exec("PRAGMA foreign_keys = OFF");
|
||||
try {
|
||||
const importedHosts = importDb
|
||||
.prepare("SELECT * FROM ssh_data")
|
||||
.all();
|
||||
for (const host of importedHosts) {
|
||||
try {
|
||||
const existing = await mainDb
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(
|
||||
and(
|
||||
eq(hosts.userId, userId),
|
||||
eq(hosts.ip, host.ip),
|
||||
eq(hosts.port, host.port),
|
||||
eq(hosts.username, host.username),
|
||||
),
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
result.summary.skippedItems++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const hostData = {
|
||||
userId: userId,
|
||||
name: host.name,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
folder: host.folder,
|
||||
tags: host.tags,
|
||||
pin: Boolean(host.pin),
|
||||
authType: host.auth_type,
|
||||
forceKeyboardInteractive: host.force_keyboard_interactive,
|
||||
password: host.password,
|
||||
key: host.key,
|
||||
keyPassword: host.key_password,
|
||||
keyType: host.key_type,
|
||||
sudoPassword: host.sudo_password,
|
||||
autostartPassword: host.autostart_password,
|
||||
autostartKey: host.autostart_key,
|
||||
autostartKeyPassword: host.autostart_key_password,
|
||||
credentialId: host.credential_id || null,
|
||||
overrideCredentialUsername: Boolean(
|
||||
host.override_credential_username,
|
||||
),
|
||||
enableTerminal: Boolean(host.enable_terminal),
|
||||
enableTunnel: Boolean(host.enable_tunnel),
|
||||
tunnelConnections: host.tunnel_connections,
|
||||
jumpHosts: host.jump_hosts,
|
||||
enableFileManager: Boolean(host.enable_file_manager),
|
||||
enableDocker: Boolean(host.enable_docker),
|
||||
showTerminalInSidebar: Boolean(host.show_terminal_in_sidebar),
|
||||
showFileManagerInSidebar: Boolean(
|
||||
host.show_file_manager_in_sidebar,
|
||||
),
|
||||
showTunnelInSidebar: Boolean(host.show_tunnel_in_sidebar),
|
||||
showDockerInSidebar: Boolean(host.show_docker_in_sidebar),
|
||||
showServerStatsInSidebar: Boolean(
|
||||
host.show_server_stats_in_sidebar,
|
||||
),
|
||||
defaultPath: host.default_path,
|
||||
statsConfig: host.stats_config,
|
||||
terminalConfig: host.terminal_config,
|
||||
quickActions: host.quick_actions,
|
||||
notes: host.notes,
|
||||
useSocks5: Boolean(host.use_socks5),
|
||||
socks5Host: host.socks5_host,
|
||||
socks5Port: host.socks5_port,
|
||||
socks5Username: host.socks5_username,
|
||||
socks5Password: host.socks5_password,
|
||||
socks5ProxyChain: host.socks5_proxy_chain,
|
||||
createdAt: host.created_at || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const encrypted = DataCrypto.encryptRecord(
|
||||
"ssh_data",
|
||||
hostData,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
await mainDb.insert(hosts).values(encrypted);
|
||||
result.summary.sshHostsImported++;
|
||||
} catch (hostError) {
|
||||
result.summary.errors.push(
|
||||
`SSH host import error: ${hostError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
apiLogger.info("ssh_data table not found in import file, skipping");
|
||||
}
|
||||
|
||||
try {
|
||||
const importedCreds = importDb
|
||||
.prepare("SELECT * FROM ssh_credentials")
|
||||
.all();
|
||||
for (const cred of importedCreds) {
|
||||
try {
|
||||
const existing = await mainDb
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.userId, userId),
|
||||
eq(sshCredentials.name, cred.name),
|
||||
eq(sshCredentials.username, cred.username),
|
||||
),
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
result.summary.skippedItems++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const credData = {
|
||||
userId: userId,
|
||||
name: cred.name,
|
||||
description: cred.description,
|
||||
folder: cred.folder,
|
||||
tags: cred.tags,
|
||||
authType: cred.auth_type,
|
||||
username: cred.username,
|
||||
password: cred.password,
|
||||
key: cred.key,
|
||||
privateKey: cred.private_key,
|
||||
publicKey: cred.public_key,
|
||||
keyPassword: cred.key_password,
|
||||
keyType: cred.key_type,
|
||||
detectedKeyType: cred.detected_key_type,
|
||||
usageCount: cred.usage_count || 0,
|
||||
lastUsed: cred.last_used,
|
||||
createdAt: cred.created_at || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const encrypted = DataCrypto.encryptRecord(
|
||||
"ssh_credentials",
|
||||
credData,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
await mainDb.insert(sshCredentials).values(encrypted);
|
||||
result.summary.sshCredentialsImported++;
|
||||
} catch (credError) {
|
||||
result.summary.errors.push(
|
||||
`SSH credential import error: ${credError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
apiLogger.info(
|
||||
"ssh_credentials table not found in import file, skipping",
|
||||
);
|
||||
}
|
||||
|
||||
const fileManagerTables = [
|
||||
{
|
||||
table: "file_manager_recent",
|
||||
schema: fileManagerRecent,
|
||||
key: "fileManagerItemsImported",
|
||||
},
|
||||
{
|
||||
table: "file_manager_pinned",
|
||||
schema: fileManagerPinned,
|
||||
key: "fileManagerItemsImported",
|
||||
},
|
||||
{
|
||||
table: "file_manager_shortcuts",
|
||||
schema: fileManagerShortcuts,
|
||||
key: "fileManagerItemsImported",
|
||||
},
|
||||
];
|
||||
|
||||
for (const { table, schema, key } of fileManagerTables) {
|
||||
await withCurrentSqliteForeignKeysDisabled(async () => {
|
||||
try {
|
||||
const importedItems = importDb
|
||||
.prepare(`SELECT * FROM ${table}`)
|
||||
const importedHosts = importDb
|
||||
.prepare("SELECT * FROM ssh_data")
|
||||
.all();
|
||||
for (const item of importedItems) {
|
||||
for (const host of importedHosts) {
|
||||
try {
|
||||
const existing = await mainDb
|
||||
.select()
|
||||
.from(schema)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.userId, userId),
|
||||
eq(schema.path, item.path),
|
||||
eq(schema.name, item.name),
|
||||
),
|
||||
);
|
||||
const hostRepository = createCurrentHostRepository();
|
||||
const exists = await hostRepository.existsForImportIdentity(
|
||||
userId,
|
||||
host.ip,
|
||||
host.port,
|
||||
host.username,
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
if (exists) {
|
||||
result.summary.skippedItems++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const itemData = {
|
||||
const hostData = {
|
||||
userId: userId,
|
||||
hostId: item.host_id,
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
...(table === "file_manager_recent" && {
|
||||
lastOpened: item.last_opened,
|
||||
}),
|
||||
...(table === "file_manager_pinned" && {
|
||||
pinnedAt: item.pinned_at,
|
||||
}),
|
||||
...(table === "file_manager_shortcuts" && {
|
||||
createdAt: item.created_at,
|
||||
}),
|
||||
name: host.name,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
folder: host.folder,
|
||||
tags: host.tags,
|
||||
pin: Boolean(host.pin),
|
||||
authType: host.auth_type,
|
||||
forceKeyboardInteractive: host.force_keyboard_interactive,
|
||||
password: host.password,
|
||||
key: host.key,
|
||||
keyPassword: host.key_password,
|
||||
keyType: host.key_type,
|
||||
sudoPassword: host.sudo_password,
|
||||
autostartPassword: host.autostart_password,
|
||||
autostartKey: host.autostart_key,
|
||||
autostartKeyPassword: host.autostart_key_password,
|
||||
credentialId: host.credential_id || null,
|
||||
overrideCredentialUsername: Boolean(
|
||||
host.override_credential_username,
|
||||
),
|
||||
enableTerminal: Boolean(host.enable_terminal),
|
||||
enableTunnel: Boolean(host.enable_tunnel),
|
||||
tunnelConnections: host.tunnel_connections,
|
||||
jumpHosts: host.jump_hosts,
|
||||
enableFileManager: Boolean(host.enable_file_manager),
|
||||
enableDocker: Boolean(host.enable_docker),
|
||||
showTerminalInSidebar: Boolean(host.show_terminal_in_sidebar),
|
||||
showFileManagerInSidebar: Boolean(
|
||||
host.show_file_manager_in_sidebar,
|
||||
),
|
||||
showTunnelInSidebar: Boolean(host.show_tunnel_in_sidebar),
|
||||
showDockerInSidebar: Boolean(host.show_docker_in_sidebar),
|
||||
showServerStatsInSidebar: Boolean(
|
||||
host.show_server_stats_in_sidebar,
|
||||
),
|
||||
defaultPath: host.default_path,
|
||||
statsConfig: host.stats_config,
|
||||
terminalConfig: host.terminal_config,
|
||||
quickActions: host.quick_actions,
|
||||
notes: host.notes,
|
||||
useSocks5: Boolean(host.use_socks5),
|
||||
socks5Host: host.socks5_host,
|
||||
socks5Port: host.socks5_port,
|
||||
socks5Username: host.socks5_username,
|
||||
socks5Password: host.socks5_password,
|
||||
socks5ProxyChain: host.socks5_proxy_chain,
|
||||
createdAt: host.created_at || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await mainDb.insert(schema).values(itemData);
|
||||
result.summary[key]++;
|
||||
} catch (itemError) {
|
||||
await hostRepository.createEncryptedForUser(userId, hostData);
|
||||
result.summary.sshHostsImported++;
|
||||
} catch (hostError) {
|
||||
result.summary.errors.push(
|
||||
`${table} import error: ${itemError.message}`,
|
||||
`SSH host import error: ${hostError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
apiLogger.info(`${table} table not found in import file, skipping`);
|
||||
apiLogger.info("ssh_data table not found in import file, skipping");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const importedAlerts = importDb
|
||||
.prepare("SELECT * FROM dismissed_alerts")
|
||||
.all();
|
||||
for (const alert of importedAlerts) {
|
||||
try {
|
||||
const existing = await mainDb
|
||||
.select()
|
||||
.from(dismissedAlerts)
|
||||
.where(
|
||||
and(
|
||||
eq(dismissedAlerts.userId, userId),
|
||||
eq(dismissedAlerts.alertId, alert.alert_id),
|
||||
),
|
||||
try {
|
||||
const importedCreds = importDb
|
||||
.prepare("SELECT * FROM ssh_credentials")
|
||||
.all();
|
||||
for (const cred of importedCreds) {
|
||||
try {
|
||||
const credentialRepository =
|
||||
createCurrentCredentialRepository();
|
||||
const exists =
|
||||
await credentialRepository.existsForImportIdentity(
|
||||
userId,
|
||||
cred.name,
|
||||
cred.username,
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
result.summary.skippedItems++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const credData = {
|
||||
userId: userId,
|
||||
name: cred.name,
|
||||
description: cred.description,
|
||||
folder: cred.folder,
|
||||
tags: cred.tags,
|
||||
authType: cred.auth_type,
|
||||
username: cred.username,
|
||||
password: cred.password,
|
||||
key: cred.key,
|
||||
privateKey: cred.private_key,
|
||||
publicKey: cred.public_key,
|
||||
keyPassword: cred.key_password,
|
||||
keyType: cred.key_type,
|
||||
detectedKeyType: cred.detected_key_type,
|
||||
usageCount: cred.usage_count || 0,
|
||||
lastUsed: cred.last_used,
|
||||
createdAt: cred.created_at || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await credentialRepository.createEncryptedForUser(
|
||||
userId,
|
||||
credData,
|
||||
);
|
||||
result.summary.sshCredentialsImported++;
|
||||
} catch (credError) {
|
||||
result.summary.errors.push(
|
||||
`SSH credential import error: ${credError.message}`,
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
result.summary.skippedItems++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
apiLogger.info(
|
||||
"ssh_credentials table not found in import file, skipping",
|
||||
);
|
||||
}
|
||||
|
||||
await mainDb.insert(dismissedAlerts).values({
|
||||
userId: userId,
|
||||
alertId: alert.alert_id,
|
||||
dismissedAt: alert.dismissed_at || new Date().toISOString(),
|
||||
});
|
||||
result.summary.dismissedAlertsImported++;
|
||||
} catch (alertError) {
|
||||
result.summary.errors.push(
|
||||
`Dismissed alert import error: ${alertError.message}`,
|
||||
const fileManagerTables = [
|
||||
{
|
||||
table: "file_manager_recent",
|
||||
key: "fileManagerItemsImported",
|
||||
},
|
||||
{
|
||||
table: "file_manager_pinned",
|
||||
key: "fileManagerItemsImported",
|
||||
},
|
||||
{
|
||||
table: "file_manager_shortcuts",
|
||||
key: "fileManagerItemsImported",
|
||||
},
|
||||
];
|
||||
|
||||
const fileManagerRepository =
|
||||
createCurrentFileManagerBookmarkRepository();
|
||||
|
||||
for (const { table, key } of fileManagerTables) {
|
||||
try {
|
||||
const importedItems = importDb
|
||||
.prepare(`SELECT * FROM ${table}`)
|
||||
.all();
|
||||
for (const item of importedItems) {
|
||||
try {
|
||||
const bookmark = {
|
||||
hostId: item.host_id,
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
};
|
||||
const created =
|
||||
table === "file_manager_recent"
|
||||
? await fileManagerRepository.createRecentForImport(
|
||||
userId,
|
||||
bookmark,
|
||||
item.last_opened,
|
||||
)
|
||||
: table === "file_manager_pinned"
|
||||
? await fileManagerRepository.createPinnedForImport(
|
||||
userId,
|
||||
bookmark,
|
||||
item.pinned_at,
|
||||
)
|
||||
: await fileManagerRepository.createShortcutForImport(
|
||||
userId,
|
||||
bookmark,
|
||||
item.created_at,
|
||||
);
|
||||
|
||||
if (created) {
|
||||
result.summary[key]++;
|
||||
} else {
|
||||
result.summary.skippedItems++;
|
||||
}
|
||||
} catch (itemError) {
|
||||
result.summary.errors.push(
|
||||
`${table} import error: ${itemError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
apiLogger.info(
|
||||
`${table} table not found in import file, skipping`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
apiLogger.info(
|
||||
"dismissed_alerts table not found in import file, skipping",
|
||||
);
|
||||
}
|
||||
|
||||
const targetUser = await mainDb
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId));
|
||||
if (targetUser.length > 0 && targetUser[0].isAdmin) {
|
||||
const dismissedAlertRepository =
|
||||
createCurrentDismissedAlertRepository();
|
||||
|
||||
try {
|
||||
const importedSettings = importDb
|
||||
.prepare("SELECT * FROM settings")
|
||||
const importedAlerts = importDb
|
||||
.prepare("SELECT * FROM dismissed_alerts")
|
||||
.all();
|
||||
for (const setting of importedSettings) {
|
||||
for (const alert of importedAlerts) {
|
||||
try {
|
||||
const existing = await mainDb
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, setting.key));
|
||||
|
||||
if (existing.length > 0) {
|
||||
await mainDb
|
||||
.update(settings)
|
||||
.set({ value: setting.value })
|
||||
.where(eq(settings.key, setting.key));
|
||||
result.summary.settingsImported++;
|
||||
const created = await dismissedAlertRepository.createForImport(
|
||||
userId,
|
||||
alert.alert_id,
|
||||
alert.dismissed_at,
|
||||
);
|
||||
if (created) {
|
||||
result.summary.dismissedAlertsImported++;
|
||||
} else {
|
||||
await mainDb.insert(settings).values({
|
||||
key: setting.key,
|
||||
value: setting.value,
|
||||
});
|
||||
result.summary.settingsImported++;
|
||||
result.summary.skippedItems++;
|
||||
}
|
||||
} catch (settingError) {
|
||||
} catch (alertError) {
|
||||
result.summary.errors.push(
|
||||
`Setting import error (${setting.key}): ${settingError.message}`,
|
||||
`Dismissed alert import error: ${alertError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
apiLogger.info("settings table not found in import file, skipping");
|
||||
apiLogger.info(
|
||||
"dismissed_alerts table not found in import file, skipping",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
apiLogger.info(
|
||||
"Settings import skipped - only admin users can import settings",
|
||||
);
|
||||
}
|
||||
|
||||
mainDb.$client.exec("PRAGMA foreign_keys = ON");
|
||||
result.success = true;
|
||||
const targetUser = await userRepository.findById(userId);
|
||||
if (targetUser?.isAdmin) {
|
||||
try {
|
||||
const importedSettings = readImportedSettings(importDb);
|
||||
for (const setting of importedSettings) {
|
||||
try {
|
||||
await upsertImportedSetting(setting);
|
||||
result.summary.settingsImported++;
|
||||
} catch (settingError) {
|
||||
result.summary.errors.push(
|
||||
`Setting import error (${setting.key}): ${settingError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
apiLogger.info(
|
||||
"settings table not found in import file, skipping",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
apiLogger.info(
|
||||
"Settings import skipped - only admin users can import settings",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await DatabaseSaveTrigger.forceSave("database_import");
|
||||
} catch (saveError) {
|
||||
apiLogger.error(
|
||||
"Failed to persist imported data to disk",
|
||||
saveError,
|
||||
{
|
||||
operation: "import_force_save_failed",
|
||||
userId,
|
||||
},
|
||||
);
|
||||
}
|
||||
result.success = true;
|
||||
|
||||
try {
|
||||
await DatabaseSaveTrigger.forceSave("database_import");
|
||||
} catch (saveError) {
|
||||
apiLogger.error(
|
||||
"Failed to persist imported data to disk",
|
||||
saveError,
|
||||
{
|
||||
operation: "import_force_save_failed",
|
||||
userId,
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
if (importDb) {
|
||||
importDb.close();
|
||||
|
||||
@@ -25,6 +25,28 @@ let memoryDatabase: Database.Database;
|
||||
let isNewDatabase = false;
|
||||
let sqlite: Database.Database;
|
||||
|
||||
function getRawSettingValue(key: string): string | null {
|
||||
const row = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = ?")
|
||||
.get(key) as { value?: string } | undefined;
|
||||
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
function setRawSettingValue(key: string, value: string): void {
|
||||
sqlite
|
||||
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
||||
.run(key, value);
|
||||
}
|
||||
|
||||
function ensureRawSettingDefault(key: string, value: string): void {
|
||||
if (getRawSettingValue(key) === null) {
|
||||
sqlite
|
||||
.prepare("INSERT INTO settings (key, value) VALUES (?, ?)")
|
||||
.run(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeDatabaseAsync(): Promise<void> {
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
|
||||
@@ -165,7 +187,9 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
scopes TEXT DEFAULT 'openid email profile',
|
||||
totp_secret TEXT,
|
||||
totp_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
totp_backup_codes TEXT
|
||||
totp_backup_codes TEXT,
|
||||
registered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
donation_modal_dismissed INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
@@ -460,6 +484,8 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
commands TEXT,
|
||||
dangerous_actions TEXT,
|
||||
recording_path TEXT,
|
||||
protocol TEXT NOT NULL DEFAULT 'ssh',
|
||||
format TEXT NOT NULL DEFAULT 'text',
|
||||
terminated_by_owner INTEGER DEFAULT 0,
|
||||
termination_reason TEXT,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||
@@ -564,12 +590,30 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
`);
|
||||
|
||||
try {
|
||||
sqlite.prepare("DELETE FROM user_open_tabs").run();
|
||||
databaseLogger.info("Open tabs cleared on startup", {
|
||||
operation: "db_init_open_tabs_cleanup",
|
||||
});
|
||||
const timeoutRow = sqlite
|
||||
.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'terminal_session_timeout_minutes'",
|
||||
)
|
||||
.get() as { value: string } | undefined;
|
||||
const timeoutMinutes = timeoutRow
|
||||
? parseInt(timeoutRow.value, 10)
|
||||
: 30;
|
||||
const ttlMs =
|
||||
!isNaN(timeoutMinutes) && timeoutMinutes > 0
|
||||
? timeoutMinutes * 60_000
|
||||
: 30 * 60_000;
|
||||
const cutoff = new Date(Date.now() - ttlMs).toISOString();
|
||||
const result = sqlite
|
||||
.prepare("DELETE FROM user_open_tabs WHERE updated_at <= ?")
|
||||
.run(cutoff);
|
||||
if (result.changes > 0) {
|
||||
databaseLogger.info("Expired open tabs cleared on startup", {
|
||||
operation: "db_init_open_tabs_cleanup",
|
||||
count: result.changes,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not clear open tabs on startup", {
|
||||
databaseLogger.warn("Could not clear expired open tabs on startup", {
|
||||
operation: "db_init_open_tabs_cleanup_failed",
|
||||
error: e,
|
||||
});
|
||||
@@ -595,16 +639,7 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
migrateSchema();
|
||||
|
||||
try {
|
||||
const row = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
|
||||
.get();
|
||||
if (!row) {
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')",
|
||||
)
|
||||
.run();
|
||||
}
|
||||
ensureRawSettingDefault("allow_registration", "true");
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not initialize default settings", {
|
||||
operation: "db_init",
|
||||
@@ -613,16 +648,7 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
const row = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'allow_password_login'")
|
||||
.get();
|
||||
if (!row) {
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES ('allow_password_login', 'true')",
|
||||
)
|
||||
.run();
|
||||
}
|
||||
ensureRawSettingDefault("allow_password_login", "true");
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not initialize allow_password_login setting", {
|
||||
operation: "db_init",
|
||||
@@ -631,16 +657,7 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
const row = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'guac_enabled'")
|
||||
.get();
|
||||
if (!row) {
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES ('guac_enabled', 'true')",
|
||||
)
|
||||
.run();
|
||||
}
|
||||
ensureRawSettingDefault("guac_enabled", "true");
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not initialize guac_enabled setting", {
|
||||
operation: "db_init",
|
||||
@@ -649,16 +666,7 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
const row = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
|
||||
.get();
|
||||
if (!row) {
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES ('guac_url', ?)",
|
||||
)
|
||||
.run(getDefaultGuacdUrl());
|
||||
}
|
||||
ensureRawSettingDefault("guac_url", getDefaultGuacdUrl());
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not initialize guac_url setting", {
|
||||
operation: "db_init",
|
||||
@@ -695,6 +703,17 @@ const addColumnIfNotExists = (
|
||||
};
|
||||
|
||||
const migrateSchema = () => {
|
||||
addColumnIfNotExists(
|
||||
"session_recordings",
|
||||
"protocol",
|
||||
"TEXT NOT NULL DEFAULT 'ssh'",
|
||||
);
|
||||
addColumnIfNotExists(
|
||||
"session_recordings",
|
||||
"format",
|
||||
"TEXT NOT NULL DEFAULT 'text'",
|
||||
);
|
||||
|
||||
addColumnIfNotExists("user_preferences", "theme", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "font_size", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "accent_color", "TEXT");
|
||||
@@ -747,6 +766,62 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists("users", "totp_backup_codes", "TEXT");
|
||||
|
||||
const hadRegisteredAtColumn = (() => {
|
||||
try {
|
||||
sqlite.prepare(`SELECT "registered_at" FROM users LIMIT 1`).get();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
// SQLite's ALTER TABLE ADD COLUMN rejects non-constant defaults like
|
||||
// CURRENT_TIMESTAMP, so the column is added empty and backfilled below.
|
||||
addColumnIfNotExists("users", "registered_at", "TEXT");
|
||||
if (!hadRegisteredAtColumn) {
|
||||
// Pre-existing users are backdated past the 30 day mark so they see the
|
||||
// donation modal immediately on upgrade instead of waiting a fresh
|
||||
// 30 days as if they had just registered.
|
||||
try {
|
||||
sqlite.exec(
|
||||
`UPDATE users SET registered_at = datetime('now', '-31 days') WHERE registered_at IS NULL`,
|
||||
);
|
||||
} catch (backfillError) {
|
||||
databaseLogger.warn("Failed to backfill users.registered_at", {
|
||||
operation: "schema_migration",
|
||||
error:
|
||||
backfillError instanceof Error
|
||||
? backfillError.message
|
||||
: String(backfillError),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
sqlite.exec(
|
||||
`UPDATE users SET registered_at = CURRENT_TIMESTAMP WHERE registered_at IS NULL`,
|
||||
);
|
||||
} catch (backfillError) {
|
||||
databaseLogger.warn(
|
||||
"Failed to backfill NULL users.registered_at values",
|
||||
{
|
||||
operation: "schema_migration",
|
||||
error:
|
||||
backfillError instanceof Error
|
||||
? backfillError.message
|
||||
: String(backfillError),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
addColumnIfNotExists(
|
||||
"users",
|
||||
"donation_modal_dismissed",
|
||||
"INTEGER NOT NULL DEFAULT 0",
|
||||
);
|
||||
|
||||
addColumnIfNotExists("sessions", "oidc_sub", "TEXT");
|
||||
addColumnIfNotExists("sessions", "oidc_sid", "TEXT");
|
||||
addColumnIfNotExists("sessions", "sso_provider_id", "INTEGER");
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -918,10 +993,6 @@ const migrateSchema = () => {
|
||||
|
||||
addColumnIfNotExists("ssh_credentials", "cert_public_key", "TEXT");
|
||||
|
||||
addColumnIfNotExists("ssh_credentials", "system_password", "TEXT");
|
||||
addColumnIfNotExists("ssh_credentials", "system_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_credentials", "system_key_password", "TEXT");
|
||||
|
||||
try {
|
||||
const tableInfo = sqlite.prepare("PRAGMA table_info(ssh_credentials)").all() as Array<{
|
||||
cid: number;
|
||||
@@ -959,9 +1030,6 @@ const migrateSchema = () => {
|
||||
private_key TEXT,
|
||||
public_key TEXT,
|
||||
detected_key_type TEXT,
|
||||
system_password TEXT,
|
||||
system_key TEXT,
|
||||
system_key_password TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
@@ -1553,6 +1621,8 @@ const migrateSchema = () => {
|
||||
commands TEXT,
|
||||
dangerous_actions TEXT,
|
||||
recording_path TEXT,
|
||||
protocol TEXT NOT NULL DEFAULT 'ssh',
|
||||
format TEXT NOT NULL DEFAULT 'text',
|
||||
terminated_by_owner INTEGER DEFAULT 0,
|
||||
termination_reason TEXT,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||
@@ -1569,37 +1639,54 @@ const migrateSchema = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM shared_credentials LIMIT 1").get();
|
||||
sqlite.prepare("SELECT id FROM shared_host_secrets LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS shared_credentials (
|
||||
CREATE TABLE IF NOT EXISTS shared_host_secrets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_access_id INTEGER NOT NULL,
|
||||
original_credential_id INTEGER NOT NULL,
|
||||
target_user_id TEXT NOT NULL,
|
||||
encrypted_username TEXT NOT NULL,
|
||||
encrypted_auth_type TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL DEFAULT 'ssh',
|
||||
source_type TEXT NOT NULL DEFAULT 'credential',
|
||||
original_credential_id INTEGER,
|
||||
encrypted_username TEXT,
|
||||
encrypted_auth_type TEXT,
|
||||
encrypted_password TEXT,
|
||||
encrypted_key TEXT,
|
||||
encrypted_key_password TEXT,
|
||||
encrypted_key_type TEXT,
|
||||
encrypted_domain TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
needs_re_encryption INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(host_access_id, target_user_id, protocol),
|
||||
FOREIGN KEY (host_access_id) REFERENCES host_access (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (original_credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create shared_credentials table", {
|
||||
databaseLogger.warn("Failed to create shared_host_secrets table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (getRawSettingValue("rbac_permission_levels_v2") === null) {
|
||||
sqlite.exec(
|
||||
"UPDATE host_access SET permission_level = 'connect' WHERE permission_level = 'view'",
|
||||
);
|
||||
setRawSettingValue("rbac_permission_levels_v2", "done");
|
||||
}
|
||||
} catch (migrateError) {
|
||||
databaseLogger.warn("Failed to migrate legacy view permission level", {
|
||||
operation: "schema_migration",
|
||||
error: migrateError,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM opkssh_tokens LIMIT 1").get();
|
||||
} catch {
|
||||
@@ -1900,31 +1987,30 @@ const migrateSchema = () => {
|
||||
|
||||
// Migrate legacy single oidc_config settings blob into sso_providers table
|
||||
try {
|
||||
const migrationDone = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'sso_migration_v1'")
|
||||
.get();
|
||||
const migrationDone = getRawSettingValue("sso_migration_v1");
|
||||
if (!migrationDone) {
|
||||
const providerCount = (
|
||||
sqlite.prepare("SELECT COUNT(*) as c FROM sso_providers").get() as { c: number }
|
||||
sqlite.prepare("SELECT COUNT(*) as c FROM sso_providers").get() as {
|
||||
c: number;
|
||||
}
|
||||
).c;
|
||||
if (providerCount === 0) {
|
||||
const legacyRow = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'oidc_config'")
|
||||
.get() as { value: string } | undefined;
|
||||
if (legacyRow) {
|
||||
const legacyConfig = getRawSettingValue("oidc_config");
|
||||
if (legacyConfig) {
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO sso_providers (name, type, enabled, display_order, config) VALUES (?, 'oidc', 1, 0, ?)",
|
||||
)
|
||||
.run("OIDC", legacyRow.value);
|
||||
databaseLogger.info("Migrated legacy oidc_config into sso_providers table", {
|
||||
operation: "sso_migration_v1",
|
||||
});
|
||||
.run("OIDC", legacyConfig);
|
||||
databaseLogger.info(
|
||||
"Migrated legacy oidc_config into sso_providers table",
|
||||
{
|
||||
operation: "sso_migration_v1",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
sqlite
|
||||
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('sso_migration_v1', 'true')")
|
||||
.run();
|
||||
setRawSettingValue("sso_migration_v1", "true");
|
||||
}
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Failed to run SSO migration v1", {
|
||||
@@ -2063,19 +2149,15 @@ const migrateSchema = () => {
|
||||
|
||||
// Seed default metrics history retention setting
|
||||
try {
|
||||
const retentionRow = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'metrics_history_retention_days'")
|
||||
.get();
|
||||
if (!retentionRow) {
|
||||
sqlite
|
||||
.prepare("INSERT INTO settings (key, value) VALUES ('metrics_history_retention_days', '7')")
|
||||
.run();
|
||||
}
|
||||
ensureRawSettingDefault("metrics_history_retention_days", "7");
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not initialize metrics_history_retention_days setting", {
|
||||
operation: "schema_migration",
|
||||
error: e,
|
||||
});
|
||||
databaseLogger.warn(
|
||||
"Could not initialize metrics_history_retention_days setting",
|
||||
{
|
||||
operation: "schema_migration",
|
||||
error: e,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// --- homepage begin ---
|
||||
@@ -2166,21 +2248,23 @@ async function saveMemoryDatabaseToFile(): Promise<void> {
|
||||
}
|
||||
|
||||
async function handlePostInitFileEncryption() {
|
||||
if (!enableFileEncryption) return;
|
||||
|
||||
try {
|
||||
if (memoryDatabase) {
|
||||
await saveMemoryDatabaseToFile();
|
||||
DatabaseSaveTrigger.initialize(saveMemoryDatabaseToFile);
|
||||
|
||||
if (enableFileEncryption) {
|
||||
await saveMemoryDatabaseToFile();
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
if (DatabaseSaveTrigger.isDirty) {
|
||||
saveMemoryDatabaseToFile();
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
DatabaseSaveTrigger.initialize(saveMemoryDatabaseToFile);
|
||||
}
|
||||
|
||||
if (!enableFileEncryption) return;
|
||||
|
||||
try {
|
||||
const migration = new DatabaseMigration(dataDir);
|
||||
migration.cleanupOldBackups();
|
||||
|
||||
@@ -24,6 +24,13 @@ export const users = sqliteTable("users", {
|
||||
.notNull()
|
||||
.default(false),
|
||||
totpBackupCodes: text("totp_backup_codes"),
|
||||
|
||||
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
donationModalDismissed: integer("donation_modal_dismissed", {
|
||||
mode: "boolean",
|
||||
})
|
||||
.notNull()
|
||||
.default(false),
|
||||
});
|
||||
|
||||
export const settings = sqliteTable("settings", {
|
||||
@@ -54,6 +61,9 @@ export const sessions = sqliteTable("sessions", {
|
||||
jwtToken: text("jwt_token").notNull(),
|
||||
deviceType: text("device_type").notNull(),
|
||||
deviceInfo: text("device_info").notNull(),
|
||||
oidcSub: text("oidc_sub"),
|
||||
oidcSid: text("oidc_sid"),
|
||||
ssoProviderId: integer("sso_provider_id"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -341,9 +351,6 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
|
||||
|
||||
certPublicKey: text("cert_public_key", { length: 8192 }),
|
||||
|
||||
systemPassword: text("system_password"),
|
||||
systemKey: text("system_key", { length: 16384 }),
|
||||
systemKeyPassword: text("system_key_password"),
|
||||
|
||||
usageCount: integer("usage_count").notNull().default(0),
|
||||
lastUsed: text("last_used"),
|
||||
@@ -523,7 +530,7 @@ export const hostAccess = sqliteTable("host_access", {
|
||||
|
||||
permissionLevel: text("permission_level")
|
||||
.notNull()
|
||||
.default("view"),
|
||||
.default("connect"),
|
||||
|
||||
expiresAt: text("expires_at"),
|
||||
|
||||
@@ -538,27 +545,32 @@ export const hostAccess = sqliteTable("host_access", {
|
||||
),
|
||||
});
|
||||
|
||||
export const sharedCredentials = sqliteTable("shared_credentials", {
|
||||
export const sharedHostSecrets = sqliteTable("shared_host_secrets", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
|
||||
hostAccessId: integer("host_access_id")
|
||||
.notNull()
|
||||
.references(() => hostAccess.id, { onDelete: "cascade" }),
|
||||
|
||||
originalCredentialId: integer("original_credential_id")
|
||||
.notNull()
|
||||
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||
|
||||
targetUserId: text("target_user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
encryptedUsername: text("encrypted_username").notNull(),
|
||||
encryptedAuthType: text("encrypted_auth_type").notNull(),
|
||||
protocol: text("protocol").notNull().default("ssh"),
|
||||
sourceType: text("source_type").notNull().default("credential"),
|
||||
|
||||
originalCredentialId: integer("original_credential_id").references(
|
||||
() => sshCredentials.id,
|
||||
{ onDelete: "cascade" },
|
||||
),
|
||||
|
||||
encryptedUsername: text("encrypted_username"),
|
||||
encryptedAuthType: text("encrypted_auth_type"),
|
||||
encryptedPassword: text("encrypted_password"),
|
||||
encryptedKey: text("encrypted_key", { length: 16384 }),
|
||||
encryptedKeyPassword: text("encrypted_key_password"),
|
||||
encryptedKeyType: text("encrypted_key_type"),
|
||||
encryptedDomain: text("encrypted_domain"),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
@@ -566,10 +578,6 @@ export const sharedCredentials = sqliteTable("shared_credentials", {
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
|
||||
needsReEncryption: integer("needs_re_encryption", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
});
|
||||
|
||||
export const roles = sqliteTable("roles", {
|
||||
@@ -657,6 +665,8 @@ export const sessionRecordings = sqliteTable("session_recordings", {
|
||||
dangerousActions: text("dangerous_actions"),
|
||||
|
||||
recordingPath: text("recording_path"),
|
||||
protocol: text("protocol").notNull().default("ssh"),
|
||||
format: text("format").notNull().default("text"),
|
||||
|
||||
terminatedByOwner: integer("terminated_by_owner", { mode: "boolean" })
|
||||
.default(false),
|
||||
|
||||
@@ -0,0 +1,622 @@
|
||||
import { and, count, desc, eq, inArray, isNull, or } from "drizzle-orm";
|
||||
import {
|
||||
alertFirings,
|
||||
alertRuleChannels,
|
||||
alertRules,
|
||||
hosts,
|
||||
notificationChannels,
|
||||
} from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
type AlertRuleRecord = typeof alertRules.$inferSelect;
|
||||
type NotificationChannelRecord = typeof notificationChannels.$inferSelect;
|
||||
type AlertFiringRecord = typeof alertFirings.$inferSelect;
|
||||
|
||||
export interface NotificationChannelRow {
|
||||
id: number;
|
||||
user_id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
config: string;
|
||||
enabled: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AlertRuleRow {
|
||||
id: number;
|
||||
user_id: string;
|
||||
host_id: number | null;
|
||||
name: string;
|
||||
enabled: number;
|
||||
trigger_type: string;
|
||||
threshold_value: number | null;
|
||||
threshold_duration_seconds: number | null;
|
||||
cooldown_minutes: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AlertRuleWithChannelsRow extends AlertRuleRow {
|
||||
channels: number[];
|
||||
}
|
||||
|
||||
export interface AlertFiringRow {
|
||||
id: number;
|
||||
user_id: string;
|
||||
rule_id: number;
|
||||
host_id: number;
|
||||
host_name: string;
|
||||
fired_at: string;
|
||||
resolved_at: string | null;
|
||||
value: number | null;
|
||||
message: string;
|
||||
severity: string;
|
||||
acknowledged: number;
|
||||
rule_name: string | null;
|
||||
}
|
||||
|
||||
export interface AlertEngineRule {
|
||||
id: number;
|
||||
userId: string;
|
||||
hostId: number | null;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
triggerType: string;
|
||||
thresholdValue: number | null;
|
||||
thresholdDurationSeconds: number | null;
|
||||
cooldownMinutes: number;
|
||||
}
|
||||
|
||||
export interface AlertEngineChannel {
|
||||
id: number;
|
||||
type: string;
|
||||
config: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export class AlertRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listNotificationChannels(
|
||||
userId: string,
|
||||
): Promise<NotificationChannelRow[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(notificationChannels)
|
||||
.where(eq(notificationChannels.userId, userId))
|
||||
.orderBy(notificationChannels.id);
|
||||
|
||||
return rows.map(mapChannelRow);
|
||||
}
|
||||
|
||||
async findNotificationChannelForUser(
|
||||
id: number,
|
||||
userId: string,
|
||||
): Promise<NotificationChannelRow | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(notificationChannels)
|
||||
.where(
|
||||
and(
|
||||
eq(notificationChannels.id, id),
|
||||
eq(notificationChannels.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ? mapChannelRow(rows[0]) : null;
|
||||
}
|
||||
|
||||
async createNotificationChannel(input: {
|
||||
userId: string;
|
||||
name: string;
|
||||
type: string;
|
||||
config: string;
|
||||
enabled: boolean;
|
||||
}): Promise<NotificationChannelRow> {
|
||||
const [created] = await this.context.drizzle
|
||||
.insert(notificationChannels)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
config: input.config,
|
||||
enabled: input.enabled,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return mapChannelRow(created);
|
||||
}
|
||||
|
||||
async updateNotificationChannel(
|
||||
id: number,
|
||||
userId: string,
|
||||
input: {
|
||||
name?: string;
|
||||
type?: string;
|
||||
config?: string;
|
||||
enabled?: boolean;
|
||||
},
|
||||
): Promise<NotificationChannelRow | null> {
|
||||
if (Object.keys(input).length === 0) {
|
||||
return this.findNotificationChannelForUser(id, userId);
|
||||
}
|
||||
|
||||
const [updated] = await this.context.drizzle
|
||||
.update(notificationChannels)
|
||||
.set(input)
|
||||
.where(
|
||||
and(
|
||||
eq(notificationChannels.id, id),
|
||||
eq(notificationChannels.userId, userId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (!updated) return null;
|
||||
await this.afterWrite();
|
||||
return mapChannelRow(updated);
|
||||
}
|
||||
|
||||
async deleteNotificationChannel(
|
||||
id: number,
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
const deleted = await this.context.drizzle
|
||||
.delete(notificationChannels)
|
||||
.where(
|
||||
and(
|
||||
eq(notificationChannels.id, id),
|
||||
eq(notificationChannels.userId, userId),
|
||||
),
|
||||
)
|
||||
.returning({ id: notificationChannels.id });
|
||||
|
||||
if (deleted.length === 0) return false;
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async listAlertRules(userId: string): Promise<AlertRuleWithChannelsRow[]> {
|
||||
const rules = await this.context.drizzle
|
||||
.select()
|
||||
.from(alertRules)
|
||||
.where(eq(alertRules.userId, userId))
|
||||
.orderBy(alertRules.id);
|
||||
|
||||
const result: AlertRuleWithChannelsRow[] = [];
|
||||
for (const rule of rules) {
|
||||
result.push({
|
||||
...mapRuleRow(rule),
|
||||
channels: await this.listChannelIdsForRule(rule.id),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async createAlertRule(input: {
|
||||
userId: string;
|
||||
hostId: number | null;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
triggerType: string;
|
||||
thresholdValue: number | null;
|
||||
thresholdDurationSeconds: number | null;
|
||||
cooldownMinutes: number;
|
||||
channels: number[];
|
||||
now: string;
|
||||
}): Promise<AlertRuleWithChannelsRow> {
|
||||
const [created] = await this.context.drizzle
|
||||
.insert(alertRules)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
hostId: input.hostId,
|
||||
name: input.name,
|
||||
enabled: input.enabled,
|
||||
triggerType: input.triggerType,
|
||||
thresholdValue: input.thresholdValue,
|
||||
thresholdDurationSeconds: input.thresholdDurationSeconds,
|
||||
cooldownMinutes: input.cooldownMinutes,
|
||||
createdAt: input.now,
|
||||
updatedAt: input.now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const channels = await this.replaceRuleChannels(
|
||||
created.id,
|
||||
input.userId,
|
||||
input.channels,
|
||||
);
|
||||
await this.afterWrite();
|
||||
return { ...mapRuleRow(created), channels };
|
||||
}
|
||||
|
||||
async findAlertRuleForUser(
|
||||
id: number,
|
||||
userId: string,
|
||||
): Promise<AlertRuleRow | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(alertRules)
|
||||
.where(and(eq(alertRules.id, id), eq(alertRules.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ? mapRuleRow(rows[0]) : null;
|
||||
}
|
||||
|
||||
async updateAlertRule(
|
||||
id: number,
|
||||
userId: string,
|
||||
input: {
|
||||
name?: string;
|
||||
hostId?: number | null;
|
||||
enabled?: boolean;
|
||||
triggerType?: string;
|
||||
thresholdValue?: number | null;
|
||||
thresholdDurationSeconds?: number | null;
|
||||
cooldownMinutes?: number;
|
||||
channels?: number[];
|
||||
now: string;
|
||||
},
|
||||
): Promise<AlertRuleWithChannelsRow | null> {
|
||||
const [updated] = await this.context.drizzle
|
||||
.update(alertRules)
|
||||
.set({
|
||||
...(input.name !== undefined ? { name: input.name } : {}),
|
||||
...(input.hostId !== undefined ? { hostId: input.hostId } : {}),
|
||||
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
|
||||
...(input.triggerType !== undefined
|
||||
? { triggerType: input.triggerType }
|
||||
: {}),
|
||||
...(input.thresholdValue !== undefined
|
||||
? { thresholdValue: input.thresholdValue }
|
||||
: {}),
|
||||
...(input.thresholdDurationSeconds !== undefined
|
||||
? { thresholdDurationSeconds: input.thresholdDurationSeconds }
|
||||
: {}),
|
||||
...(input.cooldownMinutes !== undefined
|
||||
? { cooldownMinutes: input.cooldownMinutes }
|
||||
: {}),
|
||||
updatedAt: input.now,
|
||||
})
|
||||
.where(and(eq(alertRules.id, id), eq(alertRules.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (!updated) return null;
|
||||
|
||||
const channels =
|
||||
input.channels === undefined
|
||||
? await this.listChannelIdsForRule(id)
|
||||
: await this.replaceRuleChannels(id, userId, input.channels);
|
||||
|
||||
await this.afterWrite();
|
||||
return { ...mapRuleRow(updated), channels };
|
||||
}
|
||||
|
||||
async deleteAlertRule(id: number, userId: string): Promise<boolean> {
|
||||
const deleted = await this.context.drizzle
|
||||
.delete(alertRules)
|
||||
.where(and(eq(alertRules.id, id), eq(alertRules.userId, userId)))
|
||||
.returning({ id: alertRules.id });
|
||||
|
||||
if (deleted.length === 0) return false;
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async listAlertFirings(input: {
|
||||
userId: string;
|
||||
acknowledged?: boolean;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}): Promise<{ firings: AlertFiringRow[]; total: number }> {
|
||||
const filters = [eq(alertFirings.userId, input.userId)];
|
||||
if (input.acknowledged !== undefined) {
|
||||
filters.push(eq(alertFirings.acknowledged, input.acknowledged));
|
||||
}
|
||||
|
||||
const where = and(...filters);
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
firing: alertFirings,
|
||||
ruleName: alertRules.name,
|
||||
})
|
||||
.from(alertFirings)
|
||||
.leftJoin(alertRules, eq(alertRules.id, alertFirings.ruleId))
|
||||
.where(where)
|
||||
.orderBy(desc(alertFirings.firedAt))
|
||||
.limit(input.limit)
|
||||
.offset(input.offset);
|
||||
|
||||
const totalRows = await this.context.drizzle
|
||||
.select({ total: count() })
|
||||
.from(alertFirings)
|
||||
.where(where);
|
||||
|
||||
return {
|
||||
firings: rows.map((row) => mapFiringRow(row.firing, row.ruleName)),
|
||||
total: totalRows[0]?.total ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async acknowledgeFiring(id: number, userId: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(alertFirings)
|
||||
.set({ acknowledged: true })
|
||||
.where(and(eq(alertFirings.id, id), eq(alertFirings.userId, userId)));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async acknowledgeAllFirings(userId: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(alertFirings)
|
||||
.set({ acknowledged: true })
|
||||
.where(eq(alertFirings.userId, userId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async listEnabledRulesForHost(hostId: number): Promise<AlertEngineRule[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(alertRules)
|
||||
.where(
|
||||
and(
|
||||
eq(alertRules.enabled, true),
|
||||
or(eq(alertRules.hostId, hostId), isNull(alertRules.hostId)),
|
||||
),
|
||||
);
|
||||
return rows.map(mapEngineRule);
|
||||
}
|
||||
|
||||
async listEnabledRulesForHostUser(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<AlertEngineRule[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(alertRules)
|
||||
.where(
|
||||
and(
|
||||
eq(alertRules.enabled, true),
|
||||
eq(alertRules.userId, userId),
|
||||
or(eq(alertRules.hostId, hostId), isNull(alertRules.hostId)),
|
||||
),
|
||||
);
|
||||
return rows.map(mapEngineRule);
|
||||
}
|
||||
|
||||
async findRuleById(id: number): Promise<AlertEngineRule | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(alertRules)
|
||||
.where(eq(alertRules.id, id))
|
||||
.limit(1);
|
||||
return rows[0] ? mapEngineRule(rows[0]) : null;
|
||||
}
|
||||
|
||||
async createFiring(input: {
|
||||
userId: string;
|
||||
ruleId: number;
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
value: number | null;
|
||||
message: string;
|
||||
severity: string;
|
||||
}): Promise<void> {
|
||||
await this.context.drizzle.insert(alertFirings).values(input);
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
pruneFiringsOlderThan(userId: string, days: number): void {
|
||||
this.context.sqlite
|
||||
?.prepare(
|
||||
"DELETE FROM alert_firings WHERE user_id = ? AND fired_at < datetime('now', ?)",
|
||||
)
|
||||
.run(userId, `-${days} days`);
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<{
|
||||
firingsDeleted: number;
|
||||
ruleLinksDeleted: number;
|
||||
rulesDeleted: number;
|
||||
channelsDeleted: number;
|
||||
}> {
|
||||
const ruleIds = (
|
||||
await this.context.drizzle
|
||||
.select({ id: alertRules.id })
|
||||
.from(alertRules)
|
||||
.where(eq(alertRules.userId, userId))
|
||||
).map((row) => row.id);
|
||||
const channelIds = (
|
||||
await this.context.drizzle
|
||||
.select({ id: notificationChannels.id })
|
||||
.from(notificationChannels)
|
||||
.where(eq(notificationChannels.userId, userId))
|
||||
).map((row) => row.id);
|
||||
|
||||
const firingRows = await this.context.drizzle
|
||||
.delete(alertFirings)
|
||||
.where(eq(alertFirings.userId, userId))
|
||||
.returning({ id: alertFirings.id });
|
||||
|
||||
const linkFilters = [
|
||||
...(ruleIds.length > 0
|
||||
? [inArray(alertRuleChannels.ruleId, ruleIds)]
|
||||
: []),
|
||||
...(channelIds.length > 0
|
||||
? [inArray(alertRuleChannels.channelId, channelIds)]
|
||||
: []),
|
||||
];
|
||||
const linkRows =
|
||||
linkFilters.length === 0
|
||||
? []
|
||||
: await this.context.drizzle
|
||||
.delete(alertRuleChannels)
|
||||
.where(or(...linkFilters))
|
||||
.returning({ id: alertRuleChannels.id });
|
||||
|
||||
const ruleRows = await this.context.drizzle
|
||||
.delete(alertRules)
|
||||
.where(eq(alertRules.userId, userId))
|
||||
.returning({ id: alertRules.id });
|
||||
const channelRows = await this.context.drizzle
|
||||
.delete(notificationChannels)
|
||||
.where(eq(notificationChannels.userId, userId))
|
||||
.returning({ id: notificationChannels.id });
|
||||
|
||||
if (
|
||||
firingRows.length > 0 ||
|
||||
linkRows.length > 0 ||
|
||||
ruleRows.length > 0 ||
|
||||
channelRows.length > 0
|
||||
) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return {
|
||||
firingsDeleted: firingRows.length,
|
||||
ruleLinksDeleted: linkRows.length,
|
||||
rulesDeleted: ruleRows.length,
|
||||
channelsDeleted: channelRows.length,
|
||||
};
|
||||
}
|
||||
|
||||
async listEnabledChannelsForRule(
|
||||
ruleId: number,
|
||||
): Promise<AlertEngineChannel[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
id: notificationChannels.id,
|
||||
type: notificationChannels.type,
|
||||
config: notificationChannels.config,
|
||||
enabled: notificationChannels.enabled,
|
||||
})
|
||||
.from(notificationChannels)
|
||||
.innerJoin(
|
||||
alertRuleChannels,
|
||||
eq(alertRuleChannels.channelId, notificationChannels.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(alertRuleChannels.ruleId, ruleId),
|
||||
eq(notificationChannels.enabled, true),
|
||||
),
|
||||
);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
async getHostDisplayName(hostId: number): Promise<string | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ name: hosts.name, ip: hosts.ip })
|
||||
.from(hosts)
|
||||
.where(eq(hosts.id, hostId))
|
||||
.limit(1);
|
||||
|
||||
const row = rows[0];
|
||||
return row ? row.name || row.ip : null;
|
||||
}
|
||||
|
||||
private async replaceRuleChannels(
|
||||
ruleId: number,
|
||||
userId: string,
|
||||
channelIds: number[],
|
||||
): Promise<number[]> {
|
||||
await this.context.drizzle
|
||||
.delete(alertRuleChannels)
|
||||
.where(eq(alertRuleChannels.ruleId, ruleId));
|
||||
|
||||
const linked: number[] = [];
|
||||
for (const channelId of channelIds) {
|
||||
const channel = await this.findNotificationChannelForUser(
|
||||
channelId,
|
||||
userId,
|
||||
);
|
||||
if (!channel) continue;
|
||||
await this.context.drizzle
|
||||
.insert(alertRuleChannels)
|
||||
.values({ ruleId, channelId });
|
||||
linked.push(channelId);
|
||||
}
|
||||
return linked;
|
||||
}
|
||||
|
||||
private async listChannelIdsForRule(ruleId: number): Promise<number[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ channelId: alertRuleChannels.channelId })
|
||||
.from(alertRuleChannels)
|
||||
.where(eq(alertRuleChannels.ruleId, ruleId));
|
||||
|
||||
return rows.map((row) => row.channelId);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
|
||||
function mapChannelRow(row: NotificationChannelRecord): NotificationChannelRow {
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.userId,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
config: row.config,
|
||||
enabled: row.enabled ? 1 : 0,
|
||||
created_at: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function mapRuleRow(row: AlertRuleRecord): AlertRuleRow {
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.userId,
|
||||
host_id: row.hostId,
|
||||
name: row.name,
|
||||
enabled: row.enabled ? 1 : 0,
|
||||
trigger_type: row.triggerType,
|
||||
threshold_value: row.thresholdValue,
|
||||
threshold_duration_seconds: row.thresholdDurationSeconds,
|
||||
cooldown_minutes: row.cooldownMinutes,
|
||||
created_at: row.createdAt,
|
||||
updated_at: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function mapFiringRow(
|
||||
row: AlertFiringRecord,
|
||||
ruleName: string | null,
|
||||
): AlertFiringRow {
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.userId,
|
||||
rule_id: row.ruleId,
|
||||
host_id: row.hostId,
|
||||
host_name: row.hostName,
|
||||
fired_at: row.firedAt,
|
||||
resolved_at: row.resolvedAt,
|
||||
value: row.value,
|
||||
message: row.message,
|
||||
severity: row.severity,
|
||||
acknowledged: row.acknowledged ? 1 : 0,
|
||||
rule_name: ruleName,
|
||||
};
|
||||
}
|
||||
|
||||
function mapEngineRule(row: AlertRuleRecord): AlertEngineRule {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
hostId: row.hostId,
|
||||
name: row.name,
|
||||
enabled: row.enabled,
|
||||
triggerType: row.triggerType,
|
||||
thresholdValue: row.thresholdValue,
|
||||
thresholdDurationSeconds: row.thresholdDurationSeconds,
|
||||
cooldownMinutes: row.cooldownMinutes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { apiKeys, users } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type ApiKeyRecord = typeof apiKeys.$inferSelect;
|
||||
export type NewApiKeyRecord = typeof apiKeys.$inferInsert;
|
||||
|
||||
export interface ApiKeyListRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
userId: string;
|
||||
username: string | null;
|
||||
tokenPrefix: string;
|
||||
createdAt: string;
|
||||
expiresAt: string | null;
|
||||
lastUsedAt: string | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export class ApiKeyRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async create(apiKey: NewApiKeyRecord): Promise<ApiKeyRecord> {
|
||||
const rows = await this.context.drizzle
|
||||
.insert(apiKeys)
|
||||
.values(apiKey)
|
||||
.returning();
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async listAllWithUsers(): Promise<ApiKeyListRecord[]> {
|
||||
return this.context.drizzle
|
||||
.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);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<ApiKeyRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(apiKeys)
|
||||
.where(eq(apiKeys.id, id))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async listActiveByTokenPrefix(tokenPrefix: string): Promise<ApiKeyRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(apiKeys)
|
||||
.where(
|
||||
and(eq(apiKeys.tokenPrefix, tokenPrefix), eq(apiKeys.isActive, true)),
|
||||
);
|
||||
}
|
||||
|
||||
async updateLastUsedAt(id: string, lastUsedAt: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(apiKeys)
|
||||
.set({ lastUsedAt })
|
||||
.where(eq(apiKeys.id, id));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<ApiKeyRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(apiKeys)
|
||||
.where(eq(apiKeys.id, id))
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(apiKeys)
|
||||
.where(eq(apiKeys.userId, userId))
|
||||
.returning({ id: apiKeys.id });
|
||||
|
||||
await this.afterWrite();
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { and, asc, desc, eq, gte, inArray, lte, sql } from "drizzle-orm";
|
||||
import { auditLogs } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type AuditLogRecord = typeof auditLogs.$inferSelect;
|
||||
export type NewAuditLogRecord = typeof auditLogs.$inferInsert;
|
||||
|
||||
export type AuditLogFilters = {
|
||||
userId?: string;
|
||||
action?: string;
|
||||
resourceType?: string;
|
||||
success?: boolean;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
};
|
||||
|
||||
export type AuditLogPage = {
|
||||
logs: AuditLogRecord[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
const PRUNE_MAX = 10000;
|
||||
const PRUNE_TARGET = 9000;
|
||||
|
||||
export class AuditLogRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async create(entry: NewAuditLogRecord): Promise<void> {
|
||||
await this.context.drizzle.insert(auditLogs).values(entry);
|
||||
await this.pruneIfNeeded();
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async listPage(input: {
|
||||
filters: AuditLogFilters;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}): Promise<AuditLogPage> {
|
||||
const whereClause = this.buildWhere(input.filters);
|
||||
|
||||
const [logs, totalResult] = await Promise.all([
|
||||
this.context.drizzle
|
||||
.select()
|
||||
.from(auditLogs)
|
||||
.where(whereClause)
|
||||
.orderBy(desc(auditLogs.timestamp))
|
||||
.limit(input.limit)
|
||||
.offset(input.offset),
|
||||
this.context.drizzle
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
.from(auditLogs)
|
||||
.where(whereClause),
|
||||
]);
|
||||
|
||||
return {
|
||||
logs,
|
||||
total: totalResult[0]?.count ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async listDistinctActions(): Promise<string[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.selectDistinct({ action: auditLogs.action })
|
||||
.from(auditLogs)
|
||||
.orderBy(asc(auditLogs.action));
|
||||
|
||||
return rows.map((row) => row.action);
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(auditLogs)
|
||||
.where(eq(auditLogs.userId, userId))
|
||||
.returning({ id: auditLogs.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private buildWhere(filters: AuditLogFilters) {
|
||||
const conditions = [];
|
||||
|
||||
if (filters.userId) conditions.push(eq(auditLogs.userId, filters.userId));
|
||||
if (filters.action) conditions.push(eq(auditLogs.action, filters.action));
|
||||
if (filters.resourceType) {
|
||||
conditions.push(eq(auditLogs.resourceType, filters.resourceType));
|
||||
}
|
||||
if (filters.success !== undefined) {
|
||||
conditions.push(eq(auditLogs.success, filters.success));
|
||||
}
|
||||
if (filters.startDate) {
|
||||
conditions.push(gte(auditLogs.timestamp, filters.startDate));
|
||||
}
|
||||
if (filters.endDate) {
|
||||
conditions.push(lte(auditLogs.timestamp, filters.endDate));
|
||||
}
|
||||
|
||||
return conditions.length > 0 ? and(...conditions) : undefined;
|
||||
}
|
||||
|
||||
private async pruneIfNeeded(): Promise<void> {
|
||||
const countResult = await this.context.drizzle
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
.from(auditLogs);
|
||||
const count = countResult[0]?.count ?? 0;
|
||||
|
||||
if (count < PRUNE_MAX) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deleteCount = count - PRUNE_TARGET;
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: auditLogs.id })
|
||||
.from(auditLogs)
|
||||
.orderBy(asc(auditLogs.timestamp))
|
||||
.limit(deleteCount);
|
||||
const ids = rows.map((row) => row.id);
|
||||
|
||||
if (ids.length > 0) {
|
||||
await this.context.drizzle
|
||||
.delete(auditLogs)
|
||||
.where(inArray(auditLogs.id, ids));
|
||||
}
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { and, asc, eq, sql } from "drizzle-orm";
|
||||
import { c2sTunnelPresets } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type C2sTunnelPresetRecord = typeof c2sTunnelPresets.$inferSelect;
|
||||
|
||||
export interface C2sTunnelPresetCreateInput {
|
||||
name: string;
|
||||
config: string;
|
||||
platform?: string | null;
|
||||
computerName?: string | null;
|
||||
}
|
||||
|
||||
export type C2sTunnelPresetUpdateInput = Partial<C2sTunnelPresetCreateInput>;
|
||||
|
||||
export class C2sTunnelPresetRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByUserId(userId: string): Promise<C2sTunnelPresetRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(c2sTunnelPresets)
|
||||
.where(eq(c2sTunnelPresets.userId, userId))
|
||||
.orderBy(asc(c2sTunnelPresets.name));
|
||||
}
|
||||
|
||||
async findByIdForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<C2sTunnelPresetRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(c2sTunnelPresets)
|
||||
.where(
|
||||
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async hasNameForUser(
|
||||
userId: string,
|
||||
name: string,
|
||||
excludingId?: number,
|
||||
): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: c2sTunnelPresets.id })
|
||||
.from(c2sTunnelPresets)
|
||||
.where(
|
||||
and(
|
||||
eq(c2sTunnelPresets.userId, userId),
|
||||
eq(c2sTunnelPresets.name, name),
|
||||
),
|
||||
);
|
||||
|
||||
return rows.some((row) => row.id !== excludingId);
|
||||
}
|
||||
|
||||
async createForUser(
|
||||
userId: string,
|
||||
input: C2sTunnelPresetCreateInput,
|
||||
): Promise<C2sTunnelPresetRecord> {
|
||||
const [created] = await this.context.drizzle
|
||||
.insert(c2sTunnelPresets)
|
||||
.values({
|
||||
userId,
|
||||
name: input.name,
|
||||
config: input.config,
|
||||
platform: input.platform ?? null,
|
||||
computerName: input.computerName ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async updateForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
updates: C2sTunnelPresetUpdateInput,
|
||||
): Promise<C2sTunnelPresetRecord | null> {
|
||||
const [updated] = await this.context.drizzle
|
||||
.update(c2sTunnelPresets)
|
||||
.set({
|
||||
...updates,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(
|
||||
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (updated) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async deleteForUser(userId: string, id: number): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(c2sTunnelPresets)
|
||||
.where(
|
||||
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
|
||||
)
|
||||
.returning({ id: c2sTunnelPresets.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(c2sTunnelPresets)
|
||||
.where(eq(c2sTunnelPresets.userId, userId))
|
||||
.returning({ id: c2sTunnelPresets.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { commandHistory } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type CommandHistoryRecord = typeof commandHistory.$inferSelect;
|
||||
|
||||
export class CommandHistoryRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
command: string,
|
||||
executedAt = new Date().toISOString(),
|
||||
): Promise<CommandHistoryRecord> {
|
||||
const [created] = await this.context.drizzle
|
||||
.insert(commandHistory)
|
||||
.values({ userId, hostId, command, executedAt })
|
||||
.returning();
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async listUniqueCommandsForHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
limit = 500,
|
||||
): Promise<string[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
command: commandHistory.command,
|
||||
maxExecutedAt: sql<number>`MAX(${commandHistory.executedAt})`,
|
||||
})
|
||||
.from(commandHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(commandHistory.userId, userId),
|
||||
eq(commandHistory.hostId, hostId),
|
||||
),
|
||||
)
|
||||
.groupBy(commandHistory.command)
|
||||
.orderBy(desc(sql`MAX(${commandHistory.executedAt})`))
|
||||
.limit(limit);
|
||||
|
||||
return rows.map((row) => row.command);
|
||||
}
|
||||
|
||||
async listCommandsForHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
limit = 200,
|
||||
): Promise<string[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
id: commandHistory.id,
|
||||
command: commandHistory.command,
|
||||
})
|
||||
.from(commandHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(commandHistory.userId, userId),
|
||||
eq(commandHistory.hostId, hostId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(commandHistory.executedAt))
|
||||
.limit(limit);
|
||||
|
||||
return rows.map((row) => row.command);
|
||||
}
|
||||
|
||||
async deleteCommandForHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
command: string,
|
||||
): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(commandHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(commandHistory.userId, userId),
|
||||
eq(commandHistory.hostId, hostId),
|
||||
eq(commandHistory.command, command),
|
||||
),
|
||||
)
|
||||
.returning({ id: commandHistory.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteByUserAndHost(userId: string, hostId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(commandHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(commandHistory.userId, userId),
|
||||
eq(commandHistory.hostId, hostId),
|
||||
),
|
||||
)
|
||||
.returning({ id: commandHistory.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteByHostId(hostId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(commandHistory)
|
||||
.where(eq(commandHistory.hostId, hostId))
|
||||
.returning({ id: commandHistory.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteByHostIds(hostIds: number[]): Promise<number> {
|
||||
if (hostIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.delete(commandHistory)
|
||||
.where(inArray(commandHistory.hostId, hostIds))
|
||||
.returning({ id: commandHistory.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(commandHistory)
|
||||
.where(eq(commandHistory.userId, userId))
|
||||
.returning({ id: commandHistory.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
import { sshCredentials, sshCredentialUsage } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
|
||||
export type CredentialRecord = typeof sshCredentials.$inferSelect;
|
||||
export type NewCredentialRecord = typeof sshCredentials.$inferInsert;
|
||||
export type CredentialUpdate = Partial<
|
||||
Omit<NewCredentialRecord, "id" | "userId">
|
||||
>;
|
||||
|
||||
export class CredentialRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async create(credential: NewCredentialRecord): Promise<CredentialRecord> {
|
||||
const rows = await this.context.drizzle
|
||||
.insert(sshCredentials)
|
||||
.values(credential)
|
||||
.returning();
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async createEncryptedForUser(
|
||||
userId: string,
|
||||
credential: NewCredentialRecord | Record<string, unknown>,
|
||||
): Promise<CredentialRecord> {
|
||||
const userDataKey = DataCrypto.validateUserAccess(userId);
|
||||
const tempId = credential.id ?? Date.now();
|
||||
const dataWithTempId = { ...credential, id: tempId };
|
||||
const encryptedCredential = this.encryptCredentialRecordForWrite(
|
||||
dataWithTempId,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
if (!credential.id) {
|
||||
delete (encryptedCredential as Partial<NewCredentialRecord>).id;
|
||||
}
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.insert(sshCredentials)
|
||||
.values(encryptedCredential as NewCredentialRecord)
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return DataCrypto.decryptRecord(
|
||||
"ssh_credentials",
|
||||
rows[0],
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
}
|
||||
|
||||
async findByIdForUser(
|
||||
userId: string,
|
||||
credentialId: number,
|
||||
): Promise<CredentialRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findById(credentialId: number): Promise<CredentialRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.id, credentialId))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async listByUserId(userId: string): Promise<CredentialRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.userId, userId))
|
||||
.orderBy(desc(sshCredentials.updatedAt));
|
||||
}
|
||||
|
||||
async existsForImportIdentity(
|
||||
userId: string,
|
||||
name: string,
|
||||
username: string | null,
|
||||
): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: sshCredentials.id })
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.userId, userId),
|
||||
eq(sshCredentials.name, name),
|
||||
eq(sshCredentials.username, username),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async findDecryptedByIdForUser(
|
||||
userId: string,
|
||||
credentialId: number,
|
||||
): Promise<CredentialRecord | null> {
|
||||
const row = await this.findByIdForUser(userId, credentialId);
|
||||
return this.decryptOne(row, userId);
|
||||
}
|
||||
|
||||
async listDecryptedByUserId(userId: string): Promise<CredentialRecord[]> {
|
||||
const rows = await this.listByUserId(userId);
|
||||
return this.decryptMany(rows, userId);
|
||||
}
|
||||
|
||||
async listFolders(userId: string): Promise<string[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ folder: sshCredentials.folder })
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.userId, userId));
|
||||
|
||||
return [...new Set(rows.map((row) => row.folder).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
async renameFolder(
|
||||
userId: string,
|
||||
oldName: string,
|
||||
newName: string,
|
||||
): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.update(sshCredentials)
|
||||
.set({ folder: newName })
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.userId, userId),
|
||||
eq(sshCredentials.folder, oldName),
|
||||
),
|
||||
)
|
||||
.returning({ id: sshCredentials.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async updateForUser(
|
||||
userId: string,
|
||||
credentialId: number,
|
||||
update: CredentialUpdate,
|
||||
): Promise<CredentialRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.update(sshCredentials)
|
||||
.set(update)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async updateEncryptedForUser(
|
||||
userId: string,
|
||||
credentialId: number,
|
||||
update: CredentialUpdate,
|
||||
): Promise<CredentialRecord | null> {
|
||||
const userDataKey = DataCrypto.validateUserAccess(userId);
|
||||
const encryptedUpdate = this.encryptCredentialRecordForWrite(
|
||||
update,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.update(sshCredentials)
|
||||
.set(encryptedUpdate)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return this.decryptOne(rows[0] ?? null, userId);
|
||||
}
|
||||
|
||||
async deleteForUser(userId: string, credentialId: number): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
)
|
||||
.returning({ id: sshCredentials.id });
|
||||
|
||||
await this.afterWrite();
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sshCredentials)
|
||||
.where(eq(sshCredentials.userId, userId))
|
||||
.returning({ id: sshCredentials.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async recordUsage(
|
||||
userId: string,
|
||||
credentialId: number,
|
||||
hostId: number,
|
||||
usedAt = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
await this.context.drizzle.insert(sshCredentialUsage).values({
|
||||
credentialId,
|
||||
hostId,
|
||||
userId,
|
||||
usedAt,
|
||||
});
|
||||
|
||||
await this.context.drizzle
|
||||
.update(sshCredentials)
|
||||
.set({
|
||||
lastUsed: usedAt,
|
||||
usageCount: sql`${sshCredentials.usageCount} + 1`,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
);
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
private decryptOne<T extends Record<string, unknown>>(
|
||||
record: T | null,
|
||||
userId: string,
|
||||
): T | null {
|
||||
if (!record) return null;
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) return null;
|
||||
return DataCrypto.decryptRecord(
|
||||
"ssh_credentials",
|
||||
record,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
}
|
||||
|
||||
private decryptMany<T extends Record<string, unknown>>(
|
||||
records: T[],
|
||||
userId: string,
|
||||
): T[] {
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) return [];
|
||||
return DataCrypto.decryptRecords(
|
||||
"ssh_credentials",
|
||||
records,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
}
|
||||
|
||||
private encryptCredentialRecordForWrite<T extends Record<string, unknown>>(
|
||||
record: T,
|
||||
userId: string,
|
||||
userDataKey: Buffer,
|
||||
): T {
|
||||
return DataCrypto.encryptRecord(
|
||||
"ssh_credentials",
|
||||
record,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { dashboardServiceLinks } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type DashboardServiceLinkRecord =
|
||||
typeof dashboardServiceLinks.$inferSelect;
|
||||
|
||||
export type DashboardServiceLinkUpdate = Partial<{
|
||||
label: string;
|
||||
url: string;
|
||||
}>;
|
||||
|
||||
export class DashboardServiceLinkRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByUserId(userId: string): Promise<DashboardServiceLinkRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(dashboardServiceLinks)
|
||||
.where(eq(dashboardServiceLinks.userId, userId))
|
||||
.orderBy(asc(dashboardServiceLinks.order), asc(dashboardServiceLinks.id));
|
||||
}
|
||||
|
||||
async createForUser(
|
||||
userId: string,
|
||||
input: { label: string; url: string },
|
||||
createdAt = new Date().toISOString(),
|
||||
): Promise<DashboardServiceLinkRecord> {
|
||||
const existing = await this.context.drizzle
|
||||
.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 this.context.drizzle
|
||||
.insert(dashboardServiceLinks)
|
||||
.values({
|
||||
userId,
|
||||
label: input.label,
|
||||
url: input.url,
|
||||
order: nextOrder,
|
||||
createdAt,
|
||||
})
|
||||
.returning();
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async findByIdForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<DashboardServiceLinkRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(dashboardServiceLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(dashboardServiceLinks.id, id),
|
||||
eq(dashboardServiceLinks.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async updateForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
updates: DashboardServiceLinkUpdate,
|
||||
): Promise<DashboardServiceLinkRecord | null> {
|
||||
const [updated] = await this.context.drizzle
|
||||
.update(dashboardServiceLinks)
|
||||
.set(updates)
|
||||
.where(
|
||||
and(
|
||||
eq(dashboardServiceLinks.id, id),
|
||||
eq(dashboardServiceLinks.userId, userId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (updated) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async deleteForUser(userId: string, id: number): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(dashboardServiceLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(dashboardServiceLinks.id, id),
|
||||
eq(dashboardServiceLinks.userId, userId),
|
||||
),
|
||||
)
|
||||
.returning({ id: dashboardServiceLinks.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(dashboardServiceLinks)
|
||||
.where(eq(dashboardServiceLinks.userId, userId))
|
||||
.returning({ id: dashboardServiceLinks.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
|
||||
import type { Database as BetterSqliteDatabase } from "better-sqlite3";
|
||||
import type * as schema from "../db/schema.js";
|
||||
|
||||
export interface DatabaseContext {
|
||||
dialect: "sqlite";
|
||||
drizzle: BetterSQLite3Database<typeof schema>;
|
||||
sqlite?: BetterSqliteDatabase;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { dismissedAlerts } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type DismissedAlertRecord = typeof dismissedAlerts.$inferSelect;
|
||||
|
||||
export class DismissedAlertRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByUserId(userId: string): Promise<DismissedAlertRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(dismissedAlerts)
|
||||
.where(eq(dismissedAlerts.userId, userId));
|
||||
}
|
||||
|
||||
async listAlertIdsByUserId(userId: string): Promise<string[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ alertId: dismissedAlerts.alertId })
|
||||
.from(dismissedAlerts)
|
||||
.where(eq(dismissedAlerts.userId, userId));
|
||||
|
||||
return rows.map((row) => row.alertId);
|
||||
}
|
||||
|
||||
async findForUser(
|
||||
userId: string,
|
||||
alertId: string,
|
||||
): Promise<DismissedAlertRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(dismissedAlerts)
|
||||
.where(
|
||||
and(
|
||||
eq(dismissedAlerts.userId, userId),
|
||||
eq(dismissedAlerts.alertId, alertId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async create(userId: string, alertId: string): Promise<void> {
|
||||
await this.context.drizzle.insert(dismissedAlerts).values({
|
||||
userId,
|
||||
alertId,
|
||||
});
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async createForImport(
|
||||
userId: string,
|
||||
alertId: string,
|
||||
dismissedAt = new Date().toISOString(),
|
||||
): Promise<boolean> {
|
||||
const existing = await this.findForUser(userId, alertId);
|
||||
if (existing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.context.drizzle.insert(dismissedAlerts).values({
|
||||
userId,
|
||||
alertId,
|
||||
dismissedAt,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteForUser(userId: string, alertId: string): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(dismissedAlerts)
|
||||
.where(
|
||||
and(
|
||||
eq(dismissedAlerts.userId, userId),
|
||||
eq(dismissedAlerts.alertId, alertId),
|
||||
),
|
||||
)
|
||||
.returning({ id: dismissedAlerts.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(dismissedAlerts)
|
||||
.where(eq(dismissedAlerts.userId, userId))
|
||||
.returning({ id: dismissedAlerts.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
import { getDb, getSqlite } from "../db/index.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { WebauthnCredentialRepository } from "./webauthn-credential-repository.js";
|
||||
import { AlertRepository } from "./alert-repository.js";
|
||||
import { ApiKeyRepository } from "./api-key-repository.js";
|
||||
import { AuditLogRepository } from "./audit-log-repository.js";
|
||||
import { C2sTunnelPresetRepository } from "./c2s-tunnel-preset-repository.js";
|
||||
import { CommandHistoryRepository } from "./command-history-repository.js";
|
||||
import { CredentialRepository } from "./credential-repository.js";
|
||||
import { DashboardServiceLinkRepository } from "./dashboard-service-link-repository.js";
|
||||
import { DismissedAlertRepository } from "./dismissed-alert-repository.js";
|
||||
import { FileManagerBookmarkRepository } from "./file-manager-bookmark-repository.js";
|
||||
import { HomepageItemRepository } from "./homepage-item-repository.js";
|
||||
import { HomepageLayoutRepository } from "./homepage-layout-repository.js";
|
||||
import { HostFolderRepository } from "./host-folder-repository.js";
|
||||
import { HostHealthRepository } from "./host-health-repository.js";
|
||||
import { HostMetricsHistoryRepository } from "./host-metrics-history-repository.js";
|
||||
import { HostMetricsPreferenceRepository } from "./host-metrics-preference-repository.js";
|
||||
import { HostRepository } from "./host-repository.js";
|
||||
import { HostResolutionRepository } from "./host-resolution-repository.js";
|
||||
import { NetworkTopologyRepository } from "./network-topology-repository.js";
|
||||
import { OpenTabRepository } from "./open-tab-repository.js";
|
||||
import { OpksshTokenRepository } from "./opkssh-token-repository.js";
|
||||
import { RbacAccessRepository } from "./rbac-access-repository.js";
|
||||
import { RecentActivityRepository } from "./recent-activity-repository.js";
|
||||
import { RoleRepository } from "./role-repository.js";
|
||||
import { SessionRecordingRepository } from "./session-recording-repository.js";
|
||||
import { SessionRepository } from "./session-repository.js";
|
||||
import { SettingsRepository } from "./settings-repository.js";
|
||||
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
|
||||
import { SnippetRepository } from "./snippet-repository.js";
|
||||
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
|
||||
import { SsoProviderRepository } from "./sso-provider-repository.js";
|
||||
import { TermixIdentityCaRepository } from "./termix-identity-ca-repository.js";
|
||||
import { TermixIdentityRepository } from "./termix-identity-repository.js";
|
||||
import { TmuxSessionTagRepository } from "./tmux-session-tag-repository.js";
|
||||
import { TransferRecentRepository } from "./transfer-recent-repository.js";
|
||||
import { TrustedDeviceRepository } from "./trusted-device-repository.js";
|
||||
import { UserDataExportRepository } from "./user-data-export-repository.js";
|
||||
import { UserPreferenceRepository } from "./user-preference-repository.js";
|
||||
import { UserRepository } from "./user-repository.js";
|
||||
import { VaultProfileRepository } from "./vault-profile-repository.js";
|
||||
import { VaultTokenRepository } from "./vault-token-repository.js";
|
||||
|
||||
export function createCurrentRepositoryContext(): DatabaseContext {
|
||||
return {
|
||||
dialect: "sqlite",
|
||||
drizzle: getDb(),
|
||||
sqlite: getSqlite(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createCurrentRepositoryWriteHook(
|
||||
reason: string,
|
||||
): () => Promise<void> {
|
||||
return () => DatabaseSaveTrigger.forceSave(reason);
|
||||
}
|
||||
|
||||
export function getCurrentRepositorySqlite() {
|
||||
return getSqlite();
|
||||
}
|
||||
|
||||
export function getCurrentSettingValue(key: string): string | null {
|
||||
const row = getCurrentRepositorySqlite()
|
||||
.prepare("SELECT value FROM settings WHERE key = ?")
|
||||
.get(key) as { value?: string } | undefined;
|
||||
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
export function createCurrentWebauthnCredentialRepository(): WebauthnCredentialRepository {
|
||||
return new WebauthnCredentialRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("webauthn_credential_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentAlertRepository(): AlertRepository {
|
||||
return new AlertRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("alert_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentApiKeyRepository(): ApiKeyRepository {
|
||||
return new ApiKeyRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("api_key_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentAuditLogRepository(): AuditLogRepository {
|
||||
return new AuditLogRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("audit_log_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentC2sTunnelPresetRepository(): C2sTunnelPresetRepository {
|
||||
return new C2sTunnelPresetRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("c2s_tunnel_preset_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentCommandHistoryRepository(): CommandHistoryRepository {
|
||||
return new CommandHistoryRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("command_history_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentCredentialRepository(): CredentialRepository {
|
||||
return new CredentialRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("credential_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentDashboardServiceLinkRepository(): DashboardServiceLinkRepository {
|
||||
return new DashboardServiceLinkRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("dashboard_service_link_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentDismissedAlertRepository(): DismissedAlertRepository {
|
||||
return new DismissedAlertRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("dismissed_alert_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentFileManagerBookmarkRepository(): FileManagerBookmarkRepository {
|
||||
return new FileManagerBookmarkRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("file_manager_bookmarks_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentHomepageItemRepository(): HomepageItemRepository {
|
||||
return new HomepageItemRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("homepage_item_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentHomepageLayoutRepository(): HomepageLayoutRepository {
|
||||
return new HomepageLayoutRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("homepage_layout_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentHostFolderRepository(): HostFolderRepository {
|
||||
return new HostFolderRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("host_folder_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentHostHealthRepository(): HostHealthRepository {
|
||||
return new HostHealthRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("host_health_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentHostMetricsHistoryRepository(): HostMetricsHistoryRepository {
|
||||
return new HostMetricsHistoryRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("host_metrics_history_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentHostMetricsPreferenceRepository(): HostMetricsPreferenceRepository {
|
||||
return new HostMetricsPreferenceRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook(
|
||||
"host_metrics_preference_repository_write",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentHostRepository(): HostRepository {
|
||||
return new HostRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("host_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentHostResolutionRepository(): HostResolutionRepository {
|
||||
return new HostResolutionRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("host_resolution_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentNetworkTopologyRepository(): NetworkTopologyRepository {
|
||||
return new NetworkTopologyRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("network_topology_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentOpenTabRepository(): OpenTabRepository {
|
||||
return new OpenTabRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("open_tab_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentOpksshTokenRepository(): OpksshTokenRepository {
|
||||
return new OpksshTokenRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("opkssh_token_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentRbacAccessRepository(): RbacAccessRepository {
|
||||
return new RbacAccessRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("rbac_access_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentRecentActivityRepository(): RecentActivityRepository {
|
||||
return new RecentActivityRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("recent_activity_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentRoleRepository(): RoleRepository {
|
||||
return new RoleRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("role_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSessionRecordingRepository(): SessionRecordingRepository {
|
||||
return new SessionRecordingRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("session_recording_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSessionRepository(): SessionRepository {
|
||||
return new SessionRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("session_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSettingsRepository(): SettingsRepository {
|
||||
return new SettingsRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("settings_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSharedHostSecretsRepository(): SharedHostSecretsRepository {
|
||||
return new SharedHostSecretsRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("shared_host_secrets_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSnippetRepository(): SnippetRepository {
|
||||
return new SnippetRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("snippet_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageRepository {
|
||||
return new SshCredentialUsageRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("ssh_credential_usage_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSsoProviderRepository(): SsoProviderRepository {
|
||||
return new SsoProviderRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("sso_provider_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentTermixIdentityCaRepository(): TermixIdentityCaRepository {
|
||||
return new TermixIdentityCaRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("termix_identity_ca_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentTermixIdentityRepository(): TermixIdentityRepository {
|
||||
return new TermixIdentityRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("termix_identity_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentTmuxSessionTagRepository(): TmuxSessionTagRepository {
|
||||
return new TmuxSessionTagRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("tmux_session_tag_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentTransferRecentRepository(): TransferRecentRepository {
|
||||
return new TransferRecentRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("transfer_recent_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentTrustedDeviceRepository(): TrustedDeviceRepository {
|
||||
return new TrustedDeviceRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("trusted_device_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentUserDataExportRepository(): UserDataExportRepository {
|
||||
return new UserDataExportRepository(createCurrentRepositoryContext());
|
||||
}
|
||||
|
||||
export function createCurrentUserPreferenceRepository(): UserPreferenceRepository {
|
||||
return new UserPreferenceRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("user_preference_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentUserRepository(): UserRepository {
|
||||
return new UserRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("user_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentVaultProfileRepository(): VaultProfileRepository {
|
||||
return new VaultProfileRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("vault_profile_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentVaultTokenRepository(): VaultTokenRepository {
|
||||
return new VaultTokenRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("vault_token_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { FieldCrypto } from "../../utils/field-crypto.js";
|
||||
import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js";
|
||||
|
||||
const FIELD_ENCRYPTION_POLICY = {
|
||||
users: {
|
||||
sensitive: new Set([
|
||||
"passwordHash",
|
||||
"clientSecret",
|
||||
"totpSecret",
|
||||
"totpBackupCodes",
|
||||
"oidcIdentifier",
|
||||
]),
|
||||
plaintext: new Set(["id", "username", "isAdmin", "isOidc"]),
|
||||
},
|
||||
ssh_data: {
|
||||
sensitive: new Set([
|
||||
"password",
|
||||
"key",
|
||||
"keyPassword",
|
||||
"sudoPassword",
|
||||
"autostartPassword",
|
||||
"autostartKey",
|
||||
"autostartKeyPassword",
|
||||
"socks5Password",
|
||||
"rdpPassword",
|
||||
"vncPassword",
|
||||
"telnetPassword",
|
||||
]),
|
||||
plaintext: new Set([
|
||||
"id",
|
||||
"userId",
|
||||
"connectionType",
|
||||
"name",
|
||||
"ip",
|
||||
"port",
|
||||
"username",
|
||||
"folder",
|
||||
"tags",
|
||||
"authType",
|
||||
"credentialId",
|
||||
]),
|
||||
},
|
||||
ssh_credentials: {
|
||||
sensitive: new Set([
|
||||
"password",
|
||||
"key",
|
||||
"privateKey",
|
||||
"publicKey",
|
||||
"keyPassword",
|
||||
]),
|
||||
plaintext: new Set([
|
||||
"id",
|
||||
"userId",
|
||||
"name",
|
||||
"description",
|
||||
"folder",
|
||||
"tags",
|
||||
"authType",
|
||||
"username",
|
||||
"keyType",
|
||||
"detectedKeyType",
|
||||
"usageCount",
|
||||
"lastUsed",
|
||||
]),
|
||||
},
|
||||
opkssh_tokens: {
|
||||
sensitive: new Set(["sshCert", "privateKey"]),
|
||||
plaintext: new Set(["id", "userId", "hostId", "createdAt", "expiresAt"]),
|
||||
},
|
||||
termix_identity_ca: {
|
||||
sensitive: new Set(["privateKey"]),
|
||||
plaintext: new Set(["id", "publicKey", "createdAt", "updatedAt"]),
|
||||
},
|
||||
vault_tokens: {
|
||||
sensitive: new Set(["sshCert", "privateKey"]),
|
||||
plaintext: new Set(["id", "userId", "profileId", "expiresAt"]),
|
||||
},
|
||||
} as const;
|
||||
|
||||
type PolicyTable = keyof typeof FIELD_ENCRYPTION_POLICY;
|
||||
export type FieldClassification = "sensitive" | "plaintext" | "unknown";
|
||||
|
||||
export class FieldEncryptionBoundary {
|
||||
static classifyField(
|
||||
tableName: string,
|
||||
fieldName: string,
|
||||
): FieldClassification {
|
||||
const policy = this.getPolicy(tableName);
|
||||
if (!policy) return "unknown";
|
||||
if (policy.sensitive.has(fieldName)) return "sensitive";
|
||||
if (policy.plaintext.has(fieldName)) return "plaintext";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
static getSensitiveFields(tableName: string): string[] {
|
||||
const policy = this.getPolicy(tableName);
|
||||
return policy ? [...policy.sensitive].sort() : [];
|
||||
}
|
||||
|
||||
static encryptRecord<T extends Record<string, unknown>>(
|
||||
tableName: string,
|
||||
record: T,
|
||||
userDataKey: Buffer,
|
||||
recordId = record.id,
|
||||
): T {
|
||||
const id = this.requireRecordId(recordId);
|
||||
const encryptedRecord: Record<string, unknown> = { ...record };
|
||||
|
||||
for (const fieldName of this.getSensitiveFields(tableName)) {
|
||||
const value = encryptedRecord[fieldName];
|
||||
if (typeof value === "string" && value) {
|
||||
encryptedRecord[fieldName] = FieldCrypto.encryptField(
|
||||
value,
|
||||
userDataKey,
|
||||
id,
|
||||
fieldName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return encryptedRecord as T;
|
||||
}
|
||||
|
||||
static decryptRecord<T extends Record<string, unknown>>(
|
||||
tableName: string,
|
||||
record: T,
|
||||
userDataKey: Buffer,
|
||||
recordId = record.id,
|
||||
): T {
|
||||
const id = this.requireRecordId(recordId);
|
||||
const decryptedRecord: Record<string, unknown> = { ...record };
|
||||
|
||||
for (const fieldName of this.getSensitiveFields(tableName)) {
|
||||
const value = decryptedRecord[fieldName];
|
||||
if (typeof value === "string" && value) {
|
||||
decryptedRecord[fieldName] = LazyFieldEncryption.safeGetFieldValue(
|
||||
value,
|
||||
userDataKey,
|
||||
id,
|
||||
fieldName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return decryptedRecord as T;
|
||||
}
|
||||
|
||||
private static getPolicy(tableName: string) {
|
||||
return FIELD_ENCRYPTION_POLICY[tableName as PolicyTable];
|
||||
}
|
||||
|
||||
private static requireRecordId(recordId: unknown): string {
|
||||
if (recordId === null || recordId === undefined || recordId === "") {
|
||||
throw new Error("Field encryption requires a stable record id.");
|
||||
}
|
||||
return String(recordId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
fileManagerPinned,
|
||||
fileManagerRecent,
|
||||
fileManagerShortcuts,
|
||||
} from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type FileManagerRecentRecord = typeof fileManagerRecent.$inferSelect;
|
||||
export type FileManagerPinnedRecord = typeof fileManagerPinned.$inferSelect;
|
||||
export type FileManagerShortcutRecord =
|
||||
typeof fileManagerShortcuts.$inferSelect;
|
||||
|
||||
export interface FileManagerBookmarkInput {
|
||||
hostId: number;
|
||||
path: string;
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
function resolveBookmarkName(path: string, name?: string | null): string {
|
||||
return name || path.split("/").pop() || "Unknown";
|
||||
}
|
||||
|
||||
export class FileManagerBookmarkRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listRecentForHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
limit = 20,
|
||||
): Promise<FileManagerRecentRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(fileManagerRecent)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerRecent.userId, userId),
|
||||
eq(fileManagerRecent.hostId, hostId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(fileManagerRecent.lastOpened))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
async listRecentByUserId(userId: string): Promise<FileManagerRecentRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(fileManagerRecent)
|
||||
.where(eq(fileManagerRecent.userId, userId));
|
||||
}
|
||||
|
||||
async upsertRecent(
|
||||
userId: string,
|
||||
input: FileManagerBookmarkInput,
|
||||
lastOpened = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
const [existing] = await this.context.drizzle
|
||||
.select({ id: fileManagerRecent.id })
|
||||
.from(fileManagerRecent)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerRecent.userId, userId),
|
||||
eq(fileManagerRecent.hostId, input.hostId),
|
||||
eq(fileManagerRecent.path, input.path),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
await this.context.drizzle
|
||||
.update(fileManagerRecent)
|
||||
.set({ lastOpened })
|
||||
.where(eq(fileManagerRecent.id, existing.id));
|
||||
} else {
|
||||
await this.context.drizzle.insert(fileManagerRecent).values({
|
||||
userId,
|
||||
hostId: input.hostId,
|
||||
path: input.path,
|
||||
name: resolveBookmarkName(input.path, input.name),
|
||||
lastOpened,
|
||||
});
|
||||
}
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async createRecentForImport(
|
||||
userId: string,
|
||||
input: FileManagerBookmarkInput,
|
||||
lastOpened = new Date().toISOString(),
|
||||
): Promise<boolean> {
|
||||
const exists = await this.existsRecentImportItem(userId, input);
|
||||
if (exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.context.drizzle.insert(fileManagerRecent).values({
|
||||
userId,
|
||||
hostId: input.hostId,
|
||||
path: input.path,
|
||||
name: resolveBookmarkName(input.path, input.name),
|
||||
lastOpened,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteRecentForHostPath(
|
||||
userId: string,
|
||||
input: Pick<FileManagerBookmarkInput, "hostId" | "path">,
|
||||
): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(fileManagerRecent)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerRecent.userId, userId),
|
||||
eq(fileManagerRecent.hostId, input.hostId),
|
||||
eq(fileManagerRecent.path, input.path),
|
||||
),
|
||||
)
|
||||
.returning({ id: fileManagerRecent.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async listPinnedForHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<FileManagerPinnedRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(fileManagerPinned)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerPinned.userId, userId),
|
||||
eq(fileManagerPinned.hostId, hostId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(fileManagerPinned.pinnedAt));
|
||||
}
|
||||
|
||||
async listPinnedByUserId(userId: string): Promise<FileManagerPinnedRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(fileManagerPinned)
|
||||
.where(eq(fileManagerPinned.userId, userId));
|
||||
}
|
||||
|
||||
async createPinned(
|
||||
userId: string,
|
||||
input: FileManagerBookmarkInput,
|
||||
pinnedAt = new Date().toISOString(),
|
||||
): Promise<boolean> {
|
||||
const exists = await this.existsPinned(userId, input.hostId, input.path);
|
||||
if (exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.context.drizzle.insert(fileManagerPinned).values({
|
||||
userId,
|
||||
hostId: input.hostId,
|
||||
path: input.path,
|
||||
name: resolveBookmarkName(input.path, input.name),
|
||||
pinnedAt,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async createPinnedForImport(
|
||||
userId: string,
|
||||
input: FileManagerBookmarkInput,
|
||||
pinnedAt = new Date().toISOString(),
|
||||
): Promise<boolean> {
|
||||
const exists = await this.existsPinnedImportItem(userId, input);
|
||||
if (exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.context.drizzle.insert(fileManagerPinned).values({
|
||||
userId,
|
||||
hostId: input.hostId,
|
||||
path: input.path,
|
||||
name: resolveBookmarkName(input.path, input.name),
|
||||
pinnedAt,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async deletePinnedForHostPath(
|
||||
userId: string,
|
||||
input: Pick<FileManagerBookmarkInput, "hostId" | "path">,
|
||||
): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(fileManagerPinned)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerPinned.userId, userId),
|
||||
eq(fileManagerPinned.hostId, input.hostId),
|
||||
eq(fileManagerPinned.path, input.path),
|
||||
),
|
||||
)
|
||||
.returning({ id: fileManagerPinned.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async listShortcutsForHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<FileManagerShortcutRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(fileManagerShortcuts)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerShortcuts.userId, userId),
|
||||
eq(fileManagerShortcuts.hostId, hostId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(fileManagerShortcuts.createdAt));
|
||||
}
|
||||
|
||||
async listShortcutsByUserId(
|
||||
userId: string,
|
||||
): Promise<FileManagerShortcutRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(fileManagerShortcuts)
|
||||
.where(eq(fileManagerShortcuts.userId, userId));
|
||||
}
|
||||
|
||||
async createShortcut(
|
||||
userId: string,
|
||||
input: FileManagerBookmarkInput,
|
||||
createdAt = new Date().toISOString(),
|
||||
): Promise<boolean> {
|
||||
const exists = await this.existsShortcut(userId, input.hostId, input.path);
|
||||
if (exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.context.drizzle.insert(fileManagerShortcuts).values({
|
||||
userId,
|
||||
hostId: input.hostId,
|
||||
path: input.path,
|
||||
name: resolveBookmarkName(input.path, input.name),
|
||||
createdAt,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async createShortcutForImport(
|
||||
userId: string,
|
||||
input: FileManagerBookmarkInput,
|
||||
createdAt = new Date().toISOString(),
|
||||
): Promise<boolean> {
|
||||
const exists = await this.existsShortcutImportItem(userId, input);
|
||||
if (exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.context.drizzle.insert(fileManagerShortcuts).values({
|
||||
userId,
|
||||
hostId: input.hostId,
|
||||
path: input.path,
|
||||
name: resolveBookmarkName(input.path, input.name),
|
||||
createdAt,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteShortcutForHostPath(
|
||||
userId: string,
|
||||
input: Pick<FileManagerBookmarkInput, "hostId" | "path">,
|
||||
): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(fileManagerShortcuts)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerShortcuts.userId, userId),
|
||||
eq(fileManagerShortcuts.hostId, input.hostId),
|
||||
eq(fileManagerShortcuts.path, input.path),
|
||||
),
|
||||
)
|
||||
.returning({ id: fileManagerShortcuts.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const total =
|
||||
(await this.deleteRecentByUserId(userId)) +
|
||||
(await this.deletePinnedByUserId(userId)) +
|
||||
(await this.deleteShortcutsByUserId(userId));
|
||||
|
||||
if (total > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
async deleteByHostId(hostId: number): Promise<number> {
|
||||
const total =
|
||||
(await this.deleteRecentByHostId(hostId)) +
|
||||
(await this.deletePinnedByHostId(hostId)) +
|
||||
(await this.deleteShortcutsByHostId(hostId));
|
||||
|
||||
if (total > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
async deleteByHostIds(hostIds: number[]): Promise<number> {
|
||||
if (hostIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const total =
|
||||
(await this.deleteRecentByHostIds(hostIds)) +
|
||||
(await this.deletePinnedByHostIds(hostIds)) +
|
||||
(await this.deleteShortcutsByHostIds(hostIds));
|
||||
|
||||
if (total > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
private async existsPinned(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
path: string,
|
||||
): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: fileManagerPinned.id })
|
||||
.from(fileManagerPinned)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerPinned.userId, userId),
|
||||
eq(fileManagerPinned.hostId, hostId),
|
||||
eq(fileManagerPinned.path, path),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
private async existsRecentImportItem(
|
||||
userId: string,
|
||||
input: FileManagerBookmarkInput,
|
||||
): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: fileManagerRecent.id })
|
||||
.from(fileManagerRecent)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerRecent.userId, userId),
|
||||
eq(fileManagerRecent.path, input.path),
|
||||
eq(
|
||||
fileManagerRecent.name,
|
||||
resolveBookmarkName(input.path, input.name),
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
private async existsPinnedImportItem(
|
||||
userId: string,
|
||||
input: FileManagerBookmarkInput,
|
||||
): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: fileManagerPinned.id })
|
||||
.from(fileManagerPinned)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerPinned.userId, userId),
|
||||
eq(fileManagerPinned.path, input.path),
|
||||
eq(
|
||||
fileManagerPinned.name,
|
||||
resolveBookmarkName(input.path, input.name),
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
private async existsShortcutImportItem(
|
||||
userId: string,
|
||||
input: FileManagerBookmarkInput,
|
||||
): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: fileManagerShortcuts.id })
|
||||
.from(fileManagerShortcuts)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerShortcuts.userId, userId),
|
||||
eq(fileManagerShortcuts.path, input.path),
|
||||
eq(
|
||||
fileManagerShortcuts.name,
|
||||
resolveBookmarkName(input.path, input.name),
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
private async existsShortcut(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
path: string,
|
||||
): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: fileManagerShortcuts.id })
|
||||
.from(fileManagerShortcuts)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerShortcuts.userId, userId),
|
||||
eq(fileManagerShortcuts.hostId, hostId),
|
||||
eq(fileManagerShortcuts.path, path),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
private async deleteRecentByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(fileManagerRecent)
|
||||
.where(eq(fileManagerRecent.userId, userId))
|
||||
.returning({ id: fileManagerRecent.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async deletePinnedByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(fileManagerPinned)
|
||||
.where(eq(fileManagerPinned.userId, userId))
|
||||
.returning({ id: fileManagerPinned.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async deleteShortcutsByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(fileManagerShortcuts)
|
||||
.where(eq(fileManagerShortcuts.userId, userId))
|
||||
.returning({ id: fileManagerShortcuts.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async deleteRecentByHostId(hostId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(fileManagerRecent)
|
||||
.where(eq(fileManagerRecent.hostId, hostId))
|
||||
.returning({ id: fileManagerRecent.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async deletePinnedByHostId(hostId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(fileManagerPinned)
|
||||
.where(eq(fileManagerPinned.hostId, hostId))
|
||||
.returning({ id: fileManagerPinned.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async deleteShortcutsByHostId(hostId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(fileManagerShortcuts)
|
||||
.where(eq(fileManagerShortcuts.hostId, hostId))
|
||||
.returning({ id: fileManagerShortcuts.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async deleteRecentByHostIds(hostIds: number[]): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(fileManagerRecent)
|
||||
.where(inArray(fileManagerRecent.hostId, hostIds))
|
||||
.returning({ id: fileManagerRecent.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async deletePinnedByHostIds(hostIds: number[]): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(fileManagerPinned)
|
||||
.where(inArray(fileManagerPinned.hostId, hostIds))
|
||||
.returning({ id: fileManagerPinned.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async deleteShortcutsByHostIds(hostIds: number[]): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(fileManagerShortcuts)
|
||||
.where(inArray(fileManagerShortcuts.hostId, hostIds))
|
||||
.returning({ id: fileManagerShortcuts.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { homepageItems } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type HomepageItemRecord = typeof homepageItems.$inferSelect;
|
||||
|
||||
export interface HomepageItemCreateInput {
|
||||
typeId: string;
|
||||
title: string | null;
|
||||
config: string;
|
||||
}
|
||||
|
||||
export type HomepageItemUpdateInput = Partial<{
|
||||
title: string | null;
|
||||
config: string;
|
||||
}>;
|
||||
|
||||
export class HomepageItemRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByUserId(userId: string): Promise<HomepageItemRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(homepageItems)
|
||||
.where(eq(homepageItems.userId, userId))
|
||||
.orderBy(asc(homepageItems.id));
|
||||
}
|
||||
|
||||
async createForUser(
|
||||
userId: string,
|
||||
input: HomepageItemCreateInput,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<HomepageItemRecord> {
|
||||
const [created] = await this.context.drizzle
|
||||
.insert(homepageItems)
|
||||
.values({
|
||||
userId,
|
||||
typeId: input.typeId,
|
||||
title: input.title,
|
||||
config: input.config,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async findByIdForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<HomepageItemRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(homepageItems)
|
||||
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async updateForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
updates: HomepageItemUpdateInput,
|
||||
updatedAt = new Date().toISOString(),
|
||||
): Promise<HomepageItemRecord | null> {
|
||||
const [updated] = await this.context.drizzle
|
||||
.update(homepageItems)
|
||||
.set({ ...updates, updatedAt })
|
||||
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (updated) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async deleteForUser(userId: string, id: number): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(homepageItems)
|
||||
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)))
|
||||
.returning({ id: homepageItems.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(homepageItems)
|
||||
.where(eq(homepageItems.userId, userId))
|
||||
.returning({ id: homepageItems.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { homepageLayouts } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type HomepageLayoutRecord = typeof homepageLayouts.$inferSelect;
|
||||
|
||||
export class HomepageLayoutRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async findByUserId(userId: string): Promise<HomepageLayoutRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(homepageLayouts)
|
||||
.where(eq(homepageLayouts.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async upsertForUser(
|
||||
userId: string,
|
||||
layout: string,
|
||||
updatedAt = new Date().toISOString(),
|
||||
): Promise<HomepageLayoutRecord> {
|
||||
const existing = await this.findByUserId(userId);
|
||||
|
||||
if (!existing) {
|
||||
const [created] = await this.context.drizzle
|
||||
.insert(homepageLayouts)
|
||||
.values({ userId, layout, updatedAt })
|
||||
.returning();
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
const [updated] = await this.context.drizzle
|
||||
.update(homepageLayouts)
|
||||
.set({ layout, updatedAt })
|
||||
.where(eq(homepageLayouts.userId, userId))
|
||||
.returning();
|
||||
await this.afterWrite();
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(homepageLayouts)
|
||||
.where(eq(homepageLayouts.userId, userId))
|
||||
.returning({ id: homepageLayouts.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { and, eq, like, or, sql } from "drizzle-orm";
|
||||
import type { SQLiteColumn } from "drizzle-orm/sqlite-core";
|
||||
import { hosts, sshCredentials, sshFolders } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type HostFolderRecord = typeof sshFolders.$inferSelect;
|
||||
export type HostFolderHostRecord = typeof hosts.$inferSelect;
|
||||
|
||||
export interface RenameFolderResult {
|
||||
updatedHosts: number;
|
||||
updatedCredentials: number;
|
||||
}
|
||||
|
||||
export class HostFolderRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async renameFolder(
|
||||
userId: string,
|
||||
oldName: string,
|
||||
newName: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<RenameFolderResult> {
|
||||
const oldPrefix = `${oldName} / `;
|
||||
const newPrefix = `${newName} / `;
|
||||
const childLike = `${oldPrefix}%`;
|
||||
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 this.context.drizzle
|
||||
.update(hosts)
|
||||
.set({ folder: renameExpr(hosts.folder), updatedAt: now })
|
||||
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)))
|
||||
.returning({ id: hosts.id });
|
||||
|
||||
const updatedCredentials = await this.context.drizzle
|
||||
.update(sshCredentials)
|
||||
.set({ folder: renameExpr(sshCredentials.folder), updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.userId, userId),
|
||||
folderMatch(sshCredentials.folder),
|
||||
),
|
||||
)
|
||||
.returning({ id: sshCredentials.id });
|
||||
|
||||
await this.context.drizzle
|
||||
.update(sshFolders)
|
||||
.set({ name: renameExpr(sshFolders.name), updatedAt: now })
|
||||
.where(and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)));
|
||||
|
||||
await this.afterWrite();
|
||||
return {
|
||||
updatedHosts: updatedHosts.length,
|
||||
updatedCredentials: updatedCredentials.length,
|
||||
};
|
||||
}
|
||||
|
||||
async listFolders(userId: string): Promise<HostFolderRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(sshFolders)
|
||||
.where(eq(sshFolders.userId, userId));
|
||||
}
|
||||
|
||||
async upsertMetadata(
|
||||
userId: string,
|
||||
name: string,
|
||||
color: string | null | undefined,
|
||||
icon: string | null | undefined,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<{ folder: HostFolderRecord; created: boolean }> {
|
||||
const existing = await this.findFolder(userId, name);
|
||||
if (existing) {
|
||||
const [updated] = await this.context.drizzle
|
||||
.update(sshFolders)
|
||||
.set({ color, icon, updatedAt: now })
|
||||
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return { folder: updated, created: false };
|
||||
}
|
||||
|
||||
const [created] = await this.context.drizzle
|
||||
.insert(sshFolders)
|
||||
.values({
|
||||
userId,
|
||||
name,
|
||||
color,
|
||||
icon,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return { folder: created, created: true };
|
||||
}
|
||||
|
||||
async listHostsInFolder(
|
||||
userId: string,
|
||||
folderName: string,
|
||||
): Promise<HostFolderHostRecord[]> {
|
||||
const folderMatch = (col: SQLiteColumn) =>
|
||||
or(eq(col, folderName), like(col, `${folderName} / %`));
|
||||
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
|
||||
}
|
||||
|
||||
async deleteHostsAndFolderRecords(
|
||||
userId: string,
|
||||
folderName: string,
|
||||
): Promise<void> {
|
||||
const folderMatch = (col: SQLiteColumn) =>
|
||||
or(eq(col, folderName), like(col, `${folderName} / %`));
|
||||
|
||||
const hostsToDelete = await this.listHostsInFolder(userId, folderName);
|
||||
if (hostsToDelete.length > 0) {
|
||||
await this.context.drizzle
|
||||
.delete(hosts)
|
||||
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
|
||||
}
|
||||
|
||||
await this.context.drizzle
|
||||
.delete(sshFolders)
|
||||
.where(and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)));
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sshFolders)
|
||||
.where(eq(sshFolders.userId, userId))
|
||||
.returning({ id: sshFolders.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async findFolder(
|
||||
userId: string,
|
||||
name: string,
|
||||
): Promise<HostFolderRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(sshFolders)
|
||||
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { hostHealthChecks, hostHealthHistory } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type HostHealthCheckRecord = typeof hostHealthChecks.$inferSelect;
|
||||
export type HostHealthHistoryRecord = typeof hostHealthHistory.$inferSelect;
|
||||
|
||||
export interface HostHealthResultInput {
|
||||
checkId: string;
|
||||
ok: boolean;
|
||||
latencyMs: number | null;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export class HostHealthRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async findChecksByUserAndHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<HostHealthCheckRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(hostHealthChecks)
|
||||
.where(
|
||||
and(
|
||||
eq(hostHealthChecks.userId, userId),
|
||||
eq(hostHealthChecks.hostId, hostId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async upsertChecks(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
checks: string,
|
||||
intervalSeconds: number,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<HostHealthCheckRecord> {
|
||||
const existing = await this.findChecksByUserAndHost(userId, hostId);
|
||||
if (existing) {
|
||||
const [updated] = await this.context.drizzle
|
||||
.update(hostHealthChecks)
|
||||
.set({ checks, intervalSeconds, updatedAt: now })
|
||||
.where(eq(hostHealthChecks.id, existing.id))
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await this.context.drizzle
|
||||
.insert(hostHealthChecks)
|
||||
.values({
|
||||
userId,
|
||||
hostId,
|
||||
checks,
|
||||
intervalSeconds,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async recordHistory(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
results: HostHealthResultInput[],
|
||||
keep: number,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<number> {
|
||||
if (results.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
await this.context.drizzle.insert(hostHealthHistory).values(
|
||||
results.map((result) => ({
|
||||
userId,
|
||||
hostId,
|
||||
checkId: result.checkId,
|
||||
ts: now,
|
||||
ok: result.ok,
|
||||
latencyMs: result.latencyMs,
|
||||
detail: result.detail,
|
||||
})),
|
||||
);
|
||||
|
||||
this.pruneHistory(userId, hostId, keep);
|
||||
await this.afterWrite();
|
||||
return results.length;
|
||||
}
|
||||
|
||||
async listHistory(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
limit: number,
|
||||
): Promise<HostHealthHistoryRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(hostHealthHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(hostHealthHistory.userId, userId),
|
||||
eq(hostHealthHistory.hostId, hostId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(hostHealthHistory.ts))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<{
|
||||
checksDeleted: number;
|
||||
historyDeleted: number;
|
||||
}> {
|
||||
const historyRows = await this.context.drizzle
|
||||
.delete(hostHealthHistory)
|
||||
.where(eq(hostHealthHistory.userId, userId))
|
||||
.returning({ id: hostHealthHistory.id });
|
||||
|
||||
const checkRows = await this.context.drizzle
|
||||
.delete(hostHealthChecks)
|
||||
.where(eq(hostHealthChecks.userId, userId))
|
||||
.returning({ id: hostHealthChecks.id });
|
||||
|
||||
if (historyRows.length > 0 || checkRows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return {
|
||||
checksDeleted: checkRows.length,
|
||||
historyDeleted: historyRows.length,
|
||||
};
|
||||
}
|
||||
|
||||
private pruneHistory(userId: string, hostId: number, keep: number): void {
|
||||
this.context.sqlite
|
||||
?.prepare(
|
||||
`DELETE FROM host_health_history
|
||||
WHERE id IN (
|
||||
SELECT id FROM host_health_history
|
||||
WHERE user_id = ? AND host_id = ?
|
||||
AND id NOT IN (
|
||||
SELECT id FROM host_health_history
|
||||
WHERE user_id = ? AND host_id = ?
|
||||
ORDER BY ts DESC LIMIT ?
|
||||
)
|
||||
)`,
|
||||
)
|
||||
.run(userId, hostId, userId, hostId, keep);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { and, asc, eq, gte, lte } from "drizzle-orm";
|
||||
import { hostMetricsHistory } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type HostMetricsHistoryRecord = typeof hostMetricsHistory.$inferSelect;
|
||||
|
||||
export interface HostMetricsHistoryCreateInput {
|
||||
hostId: number;
|
||||
cpuPercent?: number | null;
|
||||
memPercent?: number | null;
|
||||
diskPercent?: number | null;
|
||||
netRxBytes?: number | null;
|
||||
netTxBytes?: number | null;
|
||||
}
|
||||
|
||||
export class HostMetricsHistoryRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async create(input: HostMetricsHistoryCreateInput): Promise<void> {
|
||||
await this.context.drizzle.insert(hostMetricsHistory).values({
|
||||
hostId: input.hostId,
|
||||
cpuPercent: input.cpuPercent,
|
||||
memPercent: input.memPercent,
|
||||
diskPercent: input.diskPercent,
|
||||
netRxBytes: input.netRxBytes,
|
||||
netTxBytes: input.netTxBytes,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
pruneOlderThan(hostId: number, retentionDays: number): void {
|
||||
this.context.sqlite
|
||||
?.prepare(
|
||||
"DELETE FROM host_metrics_history WHERE host_id = ? AND ts < datetime('now', ?)",
|
||||
)
|
||||
.run(hostId, `-${retentionDays} days`);
|
||||
}
|
||||
|
||||
async listRange(
|
||||
hostId: number,
|
||||
fromTs: string,
|
||||
toTs: string,
|
||||
): Promise<HostMetricsHistoryRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(hostMetricsHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(hostMetricsHistory.hostId, hostId),
|
||||
gte(hostMetricsHistory.ts, fromTs),
|
||||
lte(hostMetricsHistory.ts, toTs),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(hostMetricsHistory.ts));
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { hostMetricsPreferences, hosts } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type HostMetricsPreferenceRecord =
|
||||
typeof hostMetricsPreferences.$inferSelect;
|
||||
|
||||
export class HostMetricsPreferenceRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async findByUserAndHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<HostMetricsPreferenceRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(hostMetricsPreferences)
|
||||
.where(
|
||||
and(
|
||||
eq(hostMetricsPreferences.userId, userId),
|
||||
eq(hostMetricsPreferences.hostId, hostId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async upsertLayout(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
layout: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<HostMetricsPreferenceRecord> {
|
||||
const existing = await this.findByUserAndHost(userId, hostId);
|
||||
if (existing) {
|
||||
const [updated] = await this.context.drizzle
|
||||
.update(hostMetricsPreferences)
|
||||
.set({ layout, updatedAt: now })
|
||||
.where(eq(hostMetricsPreferences.id, existing.id))
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await this.context.drizzle
|
||||
.insert(hostMetricsPreferences)
|
||||
.values({
|
||||
userId,
|
||||
hostId,
|
||||
layout,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async updateHostStatsConfig(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
statsConfig: string,
|
||||
): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.update(hosts)
|
||||
.set({ statsConfig })
|
||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
||||
.returning({ id: hosts.id });
|
||||
|
||||
if (rows.length === 0) return false;
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(hostMetricsPreferences)
|
||||
.where(eq(hostMetricsPreferences.userId, userId))
|
||||
.returning({ id: hostMetricsPreferences.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { hostAccess, hosts } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
|
||||
export type HostRecord = typeof hosts.$inferSelect;
|
||||
export type NewHostRecord = typeof hosts.$inferInsert;
|
||||
export type HostUpdate = Partial<Omit<NewHostRecord, "id" | "userId">>;
|
||||
export interface HostBulkUpdateState {
|
||||
id: number;
|
||||
statsConfig: string | null;
|
||||
credentialId: number | null;
|
||||
proxmoxConfig: string | null;
|
||||
}
|
||||
|
||||
export class HostRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async create(host: NewHostRecord): Promise<HostRecord> {
|
||||
const rows = await this.context.drizzle
|
||||
.insert(hosts)
|
||||
.values(host)
|
||||
.returning();
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async createEncryptedForUser(
|
||||
userId: string,
|
||||
host: NewHostRecord | Record<string, unknown>,
|
||||
): Promise<HostRecord> {
|
||||
const userDataKey = DataCrypto.validateUserAccess(userId);
|
||||
const tempId = host.id ?? Date.now();
|
||||
const dataWithTempId = { ...host, id: tempId };
|
||||
const encryptedHost = DataCrypto.encryptRecord(
|
||||
"ssh_data",
|
||||
dataWithTempId,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
if (!host.id) {
|
||||
delete (encryptedHost as Partial<NewHostRecord>).id;
|
||||
}
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.insert(hosts)
|
||||
.values(encryptedHost as NewHostRecord)
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return DataCrypto.decryptRecord("ssh_data", rows[0], userId, userDataKey);
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<HostRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(eq(hosts.id, id))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findByIdForUser(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<HostRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findDecryptedByIdAs(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<HostRecord | null> {
|
||||
const row = await this.findById(hostId);
|
||||
if (!row) return null;
|
||||
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) return null;
|
||||
|
||||
return DataCrypto.decryptRecord("ssh_data", row, userId, userDataKey);
|
||||
}
|
||||
|
||||
async listProxmoxEnabled(): Promise<
|
||||
Pick<HostRecord, "id" | "userId" | "proxmoxConfig">[]
|
||||
> {
|
||||
return this.context.drizzle
|
||||
.select({
|
||||
id: hosts.id,
|
||||
userId: hosts.userId,
|
||||
proxmoxConfig: hosts.proxmoxConfig,
|
||||
})
|
||||
.from(hosts)
|
||||
.where(eq(hosts.enableProxmox, true));
|
||||
}
|
||||
|
||||
async listByUserId(userId: string): Promise<HostRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(eq(hosts.userId, userId));
|
||||
}
|
||||
|
||||
async listDecryptedByUserId(userId: string): Promise<HostRecord[]> {
|
||||
const rows = await this.listByUserId(userId);
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) return [];
|
||||
return DataCrypto.decryptRecords("ssh_data", rows, userId, userDataKey);
|
||||
}
|
||||
|
||||
async existsForImportIdentity(
|
||||
userId: string,
|
||||
ip: string,
|
||||
port: number,
|
||||
username: string,
|
||||
): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: hosts.id })
|
||||
.from(hosts)
|
||||
.where(
|
||||
and(
|
||||
eq(hosts.userId, userId),
|
||||
eq(hosts.ip, ip),
|
||||
eq(hosts.port, port),
|
||||
eq(hosts.username, username),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async updateForUser(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
update: HostUpdate,
|
||||
): Promise<HostRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.update(hosts)
|
||||
.set(update)
|
||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async updateEncryptedForUser(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
update: HostUpdate,
|
||||
): Promise<HostRecord | null> {
|
||||
const userDataKey = DataCrypto.validateUserAccess(userId);
|
||||
const encryptedUpdate = DataCrypto.encryptRecord(
|
||||
"ssh_data",
|
||||
update,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.update(hosts)
|
||||
.set(encryptedUpdate)
|
||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0]
|
||||
? DataCrypto.decryptRecord("ssh_data", rows[0], userId, userDataKey)
|
||||
: null;
|
||||
}
|
||||
|
||||
async listBulkUpdateState(
|
||||
userId: string,
|
||||
hostIds: number[],
|
||||
): Promise<HostBulkUpdateState[]> {
|
||||
if (hostIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.context.drizzle
|
||||
.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)));
|
||||
}
|
||||
|
||||
async updateManyForUser(
|
||||
userId: string,
|
||||
hostIds: number[],
|
||||
update: HostUpdate,
|
||||
): Promise<number> {
|
||||
if (hostIds.length === 0 || Object.keys(update).length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.update(hosts)
|
||||
.set(update)
|
||||
.where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId)))
|
||||
.returning({ id: hosts.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteForUser(userId: string, hostId: number): Promise<boolean> {
|
||||
await this.deleteAccessForHost(hostId);
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.delete(hosts)
|
||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
||||
.returning({ id: hosts.id });
|
||||
|
||||
await this.afterWrite();
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(hosts)
|
||||
.where(eq(hosts.userId, userId))
|
||||
.returning({ id: hosts.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteAccessForHost(hostId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(hostAccess)
|
||||
.where(eq(hostAccess.hostId, hostId))
|
||||
.returning({ id: hostAccess.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import { and, eq, inArray, isNotNull } from "drizzle-orm";
|
||||
import { hostAccess, hosts, sshCredentials } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
|
||||
export type HostResolutionHostRecord = typeof hosts.$inferSelect;
|
||||
export type HostResolutionCredentialRecord = typeof sshCredentials.$inferSelect;
|
||||
export interface HostKeyVerificationRecord {
|
||||
hostKeyFingerprint: string | null;
|
||||
hostKeyType: string | null;
|
||||
hostKeyAlgorithm: string | null;
|
||||
hostKeyChangedCount: number | null;
|
||||
name: string | null;
|
||||
}
|
||||
export interface HostUpdateStateRecord {
|
||||
userId: string;
|
||||
credentialId: number | null;
|
||||
rdpCredentialId: number | null;
|
||||
vncCredentialId: number | null;
|
||||
telnetCredentialId: number | null;
|
||||
vaultProfileId: number | null;
|
||||
authType: string;
|
||||
}
|
||||
export interface HostListAccessEntry {
|
||||
hostId: number;
|
||||
permissionLevel: string;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
export type HostListRow = HostResolutionHostRecord & {
|
||||
ownerId: string;
|
||||
isShared: boolean;
|
||||
permissionLevel?: string;
|
||||
expiresAt?: string | null;
|
||||
};
|
||||
|
||||
export class HostResolutionRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async findHostById(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<HostResolutionHostRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(eq(hosts.id, hostId))
|
||||
.limit(1);
|
||||
|
||||
return this.decryptOne("ssh_data", rows[0], userId);
|
||||
}
|
||||
|
||||
async findHostByIdForUser(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<HostResolutionHostRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
return this.decryptOne("ssh_data", rows[0], userId);
|
||||
}
|
||||
|
||||
async findHostUpdateState(
|
||||
hostId: number,
|
||||
): Promise<HostUpdateStateRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
userId: hosts.userId,
|
||||
credentialId: hosts.credentialId,
|
||||
rdpCredentialId: hosts.rdpCredentialId,
|
||||
vncCredentialId: hosts.vncCredentialId,
|
||||
telnetCredentialId: hosts.telnetCredentialId,
|
||||
vaultProfileId: hosts.vaultProfileId,
|
||||
authType: hosts.authType,
|
||||
})
|
||||
.from(hosts)
|
||||
.where(eq(hosts.id, hostId))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findHostsByUserId(userId: string): Promise<HostResolutionHostRecord[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(eq(hosts.userId, userId));
|
||||
|
||||
return this.decryptMany("ssh_data", rows, userId);
|
||||
}
|
||||
|
||||
async listHostRowsForAccessList(
|
||||
userId: string,
|
||||
accessEntries: HostListAccessEntry[],
|
||||
): Promise<HostListRow[]> {
|
||||
const ownHostRows = await this.context.drizzle
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(eq(hosts.userId, userId));
|
||||
|
||||
const sharedHostIds = Array.from(
|
||||
new Set(accessEntries.map((access) => access.hostId)),
|
||||
);
|
||||
const sharedHostRows =
|
||||
sharedHostIds.length > 0
|
||||
? await this.context.drizzle
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(inArray(hosts.id, sharedHostIds))
|
||||
: [];
|
||||
const sharedHostsById = new Map(
|
||||
sharedHostRows.map((host) => [host.id, host]),
|
||||
);
|
||||
|
||||
return [
|
||||
...ownHostRows.map((host) => ({
|
||||
...host,
|
||||
ownerId: host.userId,
|
||||
isShared: false,
|
||||
permissionLevel: undefined,
|
||||
expiresAt: undefined,
|
||||
})),
|
||||
...accessEntries.flatMap((access) => {
|
||||
const host = sharedHostsById.get(access.hostId);
|
||||
if (!host || host.userId === userId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
...host,
|
||||
ownerId: host.userId,
|
||||
isShared: host.userId !== userId,
|
||||
permissionLevel: access.permissionLevel,
|
||||
expiresAt: access.expiresAt,
|
||||
},
|
||||
];
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
async findHostOwnerId(hostId: number): Promise<string | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ ownerId: hosts.userId })
|
||||
.from(hosts)
|
||||
.where(eq(hosts.id, hostId))
|
||||
.limit(1);
|
||||
|
||||
return rows[0]?.ownerId ?? null;
|
||||
}
|
||||
|
||||
async isHostOwnedByUser(hostId: number, userId: string): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: hosts.id })
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async listAllHosts(): Promise<HostResolutionHostRecord[]> {
|
||||
const rows = await this.context.drizzle.select().from(hosts);
|
||||
|
||||
return this.decryptManyByOwner("ssh_data", rows);
|
||||
}
|
||||
|
||||
async listHostsWithTunnelConnections(): Promise<HostResolutionHostRecord[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(
|
||||
and(eq(hosts.enableTunnel, true), isNotNull(hosts.tunnelConnections)),
|
||||
);
|
||||
|
||||
return this.decryptManyByOwner("ssh_data", rows);
|
||||
}
|
||||
|
||||
async listHostsUsingCredentialForUser(
|
||||
userId: string,
|
||||
credentialId: number,
|
||||
): Promise<HostResolutionHostRecord[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(
|
||||
and(eq(hosts.credentialId, credentialId), eq(hosts.userId, userId)),
|
||||
);
|
||||
|
||||
return this.decryptMany("ssh_data", rows, userId);
|
||||
}
|
||||
|
||||
async findHostKeyVerificationData(
|
||||
hostId: number,
|
||||
): Promise<HostKeyVerificationRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
hostKeyFingerprint: hosts.hostKeyFingerprint,
|
||||
hostKeyType: hosts.hostKeyType,
|
||||
hostKeyAlgorithm: hosts.hostKeyAlgorithm,
|
||||
hostKeyChangedCount: hosts.hostKeyChangedCount,
|
||||
name: hosts.name,
|
||||
})
|
||||
.from(hosts)
|
||||
.where(eq(hosts.id, hostId))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async storeHostKey(
|
||||
hostId: number,
|
||||
fingerprint: string,
|
||||
keyType: string,
|
||||
algorithm: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(hosts)
|
||||
.set({
|
||||
hostKeyFingerprint: fingerprint,
|
||||
hostKeyType: keyType,
|
||||
hostKeyAlgorithm: algorithm,
|
||||
hostKeyFirstSeen: now,
|
||||
hostKeyLastVerified: now,
|
||||
})
|
||||
.where(eq(hosts.id, hostId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async updateHostKey(
|
||||
hostId: number,
|
||||
fingerprint: string,
|
||||
keyType: string,
|
||||
algorithm: string,
|
||||
currentChangeCount: number,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(hosts)
|
||||
.set({
|
||||
hostKeyFingerprint: fingerprint,
|
||||
hostKeyType: keyType,
|
||||
hostKeyAlgorithm: algorithm,
|
||||
hostKeyLastVerified: now,
|
||||
hostKeyChangedCount: currentChangeCount + 1,
|
||||
})
|
||||
.where(eq(hosts.id, hostId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async touchHostKeyLastVerified(
|
||||
hostId: number,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(hosts)
|
||||
.set({ hostKeyLastVerified: now })
|
||||
.where(eq(hosts.id, hostId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async findCredentialByIdForUser(
|
||||
credentialId: number,
|
||||
userId: string,
|
||||
): Promise<HostResolutionCredentialRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return this.decryptOne("ssh_credentials", rows[0], userId);
|
||||
}
|
||||
|
||||
async findCredentialByIdForOwnerDecryptedAs(
|
||||
credentialId: number,
|
||||
ownerUserId: string,
|
||||
decryptUserId: string,
|
||||
): Promise<HostResolutionCredentialRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
eq(sshCredentials.userId, ownerUserId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return this.decryptOne("ssh_credentials", rows[0], decryptUserId);
|
||||
}
|
||||
|
||||
async findOverrideCredentialId(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<number | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ overrideCredentialId: hostAccess.overrideCredentialId })
|
||||
.from(hostAccess)
|
||||
.where(and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
return rows[0]?.overrideCredentialId ?? null;
|
||||
}
|
||||
|
||||
private decryptOne<T extends Record<string, unknown>>(
|
||||
tableName: "ssh_data" | "ssh_credentials",
|
||||
record: T | undefined,
|
||||
userId: string,
|
||||
): T | null {
|
||||
if (!record) return null;
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) return null;
|
||||
return DataCrypto.decryptRecord(tableName, record, userId, userDataKey);
|
||||
}
|
||||
|
||||
private decryptMany<T extends Record<string, unknown>>(
|
||||
tableName: "ssh_data" | "ssh_credentials",
|
||||
records: T[],
|
||||
userId: string,
|
||||
): T[] {
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) return [];
|
||||
return records.map((record) =>
|
||||
DataCrypto.decryptRecord(tableName, record, userId, userDataKey),
|
||||
);
|
||||
}
|
||||
|
||||
private decryptManyByOwner<
|
||||
T extends Record<string, unknown> & { userId: string },
|
||||
>(tableName: "ssh_data" | "ssh_credentials", records: T[]): T[] {
|
||||
return records.flatMap((record) => {
|
||||
const userDataKey = DataCrypto.getUserDataKey(record.userId);
|
||||
if (!userDataKey) return [];
|
||||
return [
|
||||
DataCrypto.decryptRecord(tableName, record, record.userId, userDataKey),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { networkTopology } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type NetworkTopologyRecord = typeof networkTopology.$inferSelect;
|
||||
|
||||
export class NetworkTopologyRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async findByUserId(userId: string): Promise<NetworkTopologyRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(networkTopology)
|
||||
.where(eq(networkTopology.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async upsertForUser(
|
||||
userId: string,
|
||||
topology: string,
|
||||
updatedAt = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
const existing = await this.findByUserId(userId);
|
||||
|
||||
if (existing) {
|
||||
await this.context.drizzle
|
||||
.update(networkTopology)
|
||||
.set({ topology, updatedAt })
|
||||
.where(eq(networkTopology.userId, userId));
|
||||
await this.afterWrite();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.context.drizzle.insert(networkTopology).values({
|
||||
userId,
|
||||
topology,
|
||||
updatedAt,
|
||||
});
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(networkTopology)
|
||||
.where(eq(networkTopology.userId, userId))
|
||||
.returning({ id: networkTopology.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { and, eq, gt } from "drizzle-orm";
|
||||
import { userOpenTabs } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type OpenTabRecord = typeof userOpenTabs.$inferSelect;
|
||||
export type NewOpenTabRecord = typeof userOpenTabs.$inferInsert;
|
||||
export type OpenTabUpdate = Partial<
|
||||
Pick<NewOpenTabRecord, "label" | "tabOrder" | "backendSessionId">
|
||||
>;
|
||||
|
||||
export type OpenTabUpsertInput = Pick<
|
||||
NewOpenTabRecord,
|
||||
"id" | "tabType" | "label" | "tabOrder"
|
||||
> & {
|
||||
hostId?: number | null;
|
||||
backendSessionId?: string | null;
|
||||
};
|
||||
|
||||
export class OpenTabRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listRecentForUser(
|
||||
userId: string,
|
||||
updatedAfter: string,
|
||||
): Promise<OpenTabRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(userOpenTabs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOpenTabs.userId, userId),
|
||||
gt(userOpenTabs.updatedAt, updatedAfter),
|
||||
),
|
||||
)
|
||||
.orderBy(userOpenTabs.tabOrder);
|
||||
}
|
||||
|
||||
async upsertForUser(
|
||||
userId: string,
|
||||
input: OpenTabUpsertInput,
|
||||
updatedAt = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
const existing = await this.findByIdForUser(userId, input.id);
|
||||
if (existing) {
|
||||
await this.context.drizzle
|
||||
.update(userOpenTabs)
|
||||
.set({
|
||||
tabType: input.tabType,
|
||||
hostId: input.hostId ?? null,
|
||||
label: input.label,
|
||||
tabOrder: input.tabOrder,
|
||||
backendSessionId:
|
||||
input.backendSessionId !== undefined
|
||||
? input.backendSessionId
|
||||
: existing.backendSessionId,
|
||||
updatedAt,
|
||||
})
|
||||
.where(
|
||||
and(eq(userOpenTabs.id, input.id), eq(userOpenTabs.userId, userId)),
|
||||
);
|
||||
await this.afterWrite();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.context.drizzle.insert(userOpenTabs).values({
|
||||
id: input.id,
|
||||
userId,
|
||||
tabType: input.tabType,
|
||||
hostId: input.hostId ?? null,
|
||||
label: input.label,
|
||||
tabOrder: input.tabOrder,
|
||||
backendSessionId: input.backendSessionId ?? null,
|
||||
updatedAt,
|
||||
});
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async replaceForUser(
|
||||
userId: string,
|
||||
tabs: OpenTabUpsertInput[],
|
||||
updatedAt = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(userOpenTabs)
|
||||
.where(eq(userOpenTabs.userId, userId));
|
||||
|
||||
if (tabs.length > 0) {
|
||||
await this.context.drizzle.insert(userOpenTabs).values(
|
||||
tabs.map((tab) => ({
|
||||
id: tab.id,
|
||||
userId,
|
||||
tabType: tab.tabType,
|
||||
hostId: tab.hostId ?? null,
|
||||
label: tab.label,
|
||||
tabOrder: tab.tabOrder,
|
||||
backendSessionId: tab.backendSessionId ?? null,
|
||||
updatedAt,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async updateForUser(
|
||||
userId: string,
|
||||
id: string,
|
||||
update: OpenTabUpdate,
|
||||
updatedAt = new Date().toISOString(),
|
||||
): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.update(userOpenTabs)
|
||||
.set({ ...update, updatedAt })
|
||||
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
|
||||
.returning({ id: userOpenTabs.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteForUser(userId: string, id: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(userOpenTabs)
|
||||
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
|
||||
.returning({ id: userOpenTabs.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(userOpenTabs)
|
||||
.where(eq(userOpenTabs.userId, userId))
|
||||
.returning({ id: userOpenTabs.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async findByIdForUser(
|
||||
userId: string,
|
||||
id: string,
|
||||
): Promise<OpenTabRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(userOpenTabs)
|
||||
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { opksshTokens } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type OpksshTokenRecord = typeof opksshTokens.$inferSelect;
|
||||
|
||||
export interface OpksshTokenUpsertInput {
|
||||
userId: string;
|
||||
hostId: number;
|
||||
sshCert: string;
|
||||
privateKey: string;
|
||||
email?: string | null;
|
||||
sub?: string | null;
|
||||
issuer?: string | null;
|
||||
audience?: string | null;
|
||||
expiresAt: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export class OpksshTokenRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async upsert(input: OpksshTokenUpsertInput): Promise<void> {
|
||||
const createdAt = input.createdAt ?? new Date().toISOString();
|
||||
|
||||
await this.context.drizzle
|
||||
.insert(opksshTokens)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
hostId: input.hostId,
|
||||
sshCert: input.sshCert,
|
||||
privateKey: input.privateKey,
|
||||
email: input.email,
|
||||
sub: input.sub,
|
||||
issuer: input.issuer,
|
||||
audience: input.audience,
|
||||
expiresAt: input.expiresAt,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [opksshTokens.userId, opksshTokens.hostId],
|
||||
set: {
|
||||
sshCert: input.sshCert,
|
||||
privateKey: input.privateKey,
|
||||
email: input.email,
|
||||
sub: input.sub,
|
||||
issuer: input.issuer,
|
||||
audience: input.audience,
|
||||
expiresAt: input.expiresAt,
|
||||
createdAt,
|
||||
},
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async findByUserAndHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<OpksshTokenRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(opksshTokens)
|
||||
.where(
|
||||
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async updateLastUsed(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
lastUsed = new Date().toISOString(),
|
||||
): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.update(opksshTokens)
|
||||
.set({ lastUsed })
|
||||
.where(
|
||||
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
|
||||
)
|
||||
.returning({ id: opksshTokens.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteByUserAndHost(userId: string, hostId: number): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(opksshTokens)
|
||||
.where(
|
||||
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
|
||||
)
|
||||
.returning({ id: opksshTokens.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(opksshTokens)
|
||||
.where(eq(opksshTokens.userId, userId))
|
||||
.returning({ id: opksshTokens.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,721 @@
|
||||
import { and, desc, eq, gte, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import {
|
||||
hostAccess,
|
||||
hosts,
|
||||
roles,
|
||||
sharedHostSecrets,
|
||||
snippetAccess,
|
||||
snippets,
|
||||
users,
|
||||
} from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type RbacAccessTargetType = "user" | "role";
|
||||
|
||||
export interface RbacAccessListItem {
|
||||
id: number;
|
||||
targetType: RbacAccessTargetType;
|
||||
userId: string | null;
|
||||
roleId: number | null;
|
||||
username: string | null;
|
||||
roleName: string | null;
|
||||
roleDisplayName: string | null;
|
||||
grantedBy: string;
|
||||
grantedByUsername: string | null;
|
||||
permissionLevel: string;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface RbacSharedHost {
|
||||
id: number;
|
||||
name: string | null;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string | null;
|
||||
tags: string | null;
|
||||
permissionLevel: string;
|
||||
expiresAt: string | null;
|
||||
grantedBy: string;
|
||||
ownerUsername: string;
|
||||
}
|
||||
|
||||
export interface RbacSharedSnippet {
|
||||
id: number;
|
||||
name: string;
|
||||
content: string;
|
||||
description: string | null;
|
||||
folder: string | null;
|
||||
ownerUsername: string;
|
||||
permissionLevel: string;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface RbacVisibleSharedSnippet extends RbacSharedSnippet {
|
||||
userId: string;
|
||||
order: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface RbacAccessibleSnippet extends RbacVisibleSharedSnippet {
|
||||
hostFilter: string | null;
|
||||
}
|
||||
|
||||
export interface RbacRoleHostAccessCredentialSource {
|
||||
hostAccessId: number;
|
||||
credentialId: number | null;
|
||||
rdpCredentialId: number | null;
|
||||
vncCredentialId: number | null;
|
||||
telnetCredentialId: number | null;
|
||||
hostId: number;
|
||||
hostOwnerId: string;
|
||||
}
|
||||
|
||||
export interface RbacVisibleHostAccessEntry {
|
||||
hostId: number;
|
||||
permissionLevel: string;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export type RbacAccessTarget =
|
||||
| { targetType: "user"; targetUserId: string }
|
||||
| { targetType: "role"; targetRoleId: number };
|
||||
|
||||
export type UpsertHostAccessInput = RbacAccessTarget & {
|
||||
hostId: number;
|
||||
grantedBy: string;
|
||||
permissionLevel: string;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
|
||||
export type UpsertSnippetAccessInput = RbacAccessTarget & {
|
||||
snippetId: number;
|
||||
grantedBy: string;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
|
||||
type RawAccessListItem = Omit<RbacAccessListItem, "targetType">;
|
||||
|
||||
function toAccessListItem(access: RawAccessListItem): RbacAccessListItem {
|
||||
return {
|
||||
...access,
|
||||
targetType: access.userId ? "user" : "role",
|
||||
};
|
||||
}
|
||||
|
||||
export class RbacAccessRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listHostAccess(hostId: number): Promise<RbacAccessListItem[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
id: hostAccess.id,
|
||||
userId: hostAccess.userId,
|
||||
roleId: hostAccess.roleId,
|
||||
username: users.username,
|
||||
roleName: roles.name,
|
||||
roleDisplayName: roles.displayName,
|
||||
grantedBy: hostAccess.grantedBy,
|
||||
grantedByUsername: sql<
|
||||
string | null
|
||||
>`(SELECT username FROM users WHERE id = ${hostAccess.grantedBy})`,
|
||||
permissionLevel: hostAccess.permissionLevel,
|
||||
expiresAt: hostAccess.expiresAt,
|
||||
createdAt: hostAccess.createdAt,
|
||||
})
|
||||
.from(hostAccess)
|
||||
.leftJoin(users, eq(hostAccess.userId, users.id))
|
||||
.leftJoin(roles, eq(hostAccess.roleId, roles.id))
|
||||
.where(eq(hostAccess.hostId, hostId))
|
||||
.orderBy(desc(hostAccess.createdAt));
|
||||
|
||||
return rows.map(toAccessListItem);
|
||||
}
|
||||
|
||||
async upsertHostAccess(input: UpsertHostAccessInput): Promise<{
|
||||
id: number;
|
||||
created: boolean;
|
||||
}> {
|
||||
const existing = await this.findHostAccess(input.hostId, input);
|
||||
|
||||
if (existing) {
|
||||
await this.context.drizzle
|
||||
.update(hostAccess)
|
||||
.set({
|
||||
permissionLevel: input.permissionLevel,
|
||||
expiresAt: input.expiresAt,
|
||||
})
|
||||
.where(eq(hostAccess.id, existing.id));
|
||||
|
||||
await this.afterWrite();
|
||||
return { id: existing.id, created: false };
|
||||
}
|
||||
|
||||
const result = await this.context.drizzle.insert(hostAccess).values({
|
||||
hostId: input.hostId,
|
||||
userId: input.targetType === "user" ? input.targetUserId : null,
|
||||
roleId: input.targetType === "role" ? input.targetRoleId : null,
|
||||
grantedBy: input.grantedBy,
|
||||
permissionLevel: input.permissionLevel,
|
||||
expiresAt: input.expiresAt,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
return { id: Number(result.lastInsertRowid), created: true };
|
||||
}
|
||||
|
||||
async revokeHostAccess(accessId: number, hostId: number): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(hostAccess)
|
||||
.where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId)));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteHostAccessForHost(hostId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(hostAccess)
|
||||
.where(eq(hostAccess.hostId, hostId))
|
||||
.returning({ id: hostAccess.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteHostAccessForHosts(hostIds: number[]): Promise<number> {
|
||||
if (hostIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.delete(hostAccess)
|
||||
.where(inArray(hostAccess.hostId, hostIds))
|
||||
.returning({ id: hostAccess.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteHostAccessForUserReferences(userId: string): Promise<number> {
|
||||
const directRows = await this.context.drizzle
|
||||
.delete(hostAccess)
|
||||
.where(eq(hostAccess.userId, userId))
|
||||
.returning({ id: hostAccess.id });
|
||||
|
||||
const grantedRows = await this.context.drizzle
|
||||
.delete(hostAccess)
|
||||
.where(eq(hostAccess.grantedBy, userId))
|
||||
.returning({ id: hostAccess.id });
|
||||
|
||||
const deletedCount = directRows.length + grantedRows.length;
|
||||
if (deletedCount > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
async findDirectHostAccess(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<typeof hostAccess.$inferSelect | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(hostAccess)
|
||||
.where(and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async updateHostAccessOverrideCredential(
|
||||
accessId: number,
|
||||
credentialId: number | null,
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(hostAccess)
|
||||
.set({ overrideCredentialId: credentialId })
|
||||
.where(eq(hostAccess.id, accessId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async listSnippetAccess(snippetId: number): Promise<RbacAccessListItem[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
id: snippetAccess.id,
|
||||
userId: snippetAccess.userId,
|
||||
roleId: snippetAccess.roleId,
|
||||
username: users.username,
|
||||
roleName: roles.name,
|
||||
roleDisplayName: roles.displayName,
|
||||
grantedBy: snippetAccess.grantedBy,
|
||||
grantedByUsername: sql<
|
||||
string | null
|
||||
>`(SELECT username FROM users WHERE id = ${snippetAccess.grantedBy})`,
|
||||
permissionLevel: snippetAccess.permissionLevel,
|
||||
expiresAt: snippetAccess.expiresAt,
|
||||
createdAt: snippetAccess.createdAt,
|
||||
})
|
||||
.from(snippetAccess)
|
||||
.leftJoin(users, eq(snippetAccess.userId, users.id))
|
||||
.leftJoin(roles, eq(snippetAccess.roleId, roles.id))
|
||||
.where(eq(snippetAccess.snippetId, snippetId))
|
||||
.orderBy(desc(snippetAccess.createdAt));
|
||||
|
||||
return rows.map(toAccessListItem);
|
||||
}
|
||||
|
||||
async upsertSnippetAccess(input: UpsertSnippetAccessInput): Promise<{
|
||||
id: number;
|
||||
created: boolean;
|
||||
}> {
|
||||
const existing = await this.findSnippetAccess(input.snippetId, input);
|
||||
|
||||
if (existing) {
|
||||
await this.context.drizzle
|
||||
.update(snippetAccess)
|
||||
.set({ expiresAt: input.expiresAt })
|
||||
.where(eq(snippetAccess.id, existing.id));
|
||||
|
||||
await this.afterWrite();
|
||||
return { id: existing.id, created: false };
|
||||
}
|
||||
|
||||
const result = await this.context.drizzle.insert(snippetAccess).values({
|
||||
snippetId: input.snippetId,
|
||||
userId: input.targetType === "user" ? input.targetUserId : null,
|
||||
roleId: input.targetType === "role" ? input.targetRoleId : null,
|
||||
grantedBy: input.grantedBy,
|
||||
permissionLevel: "view",
|
||||
expiresAt: input.expiresAt,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
return { id: Number(result.lastInsertRowid), created: true };
|
||||
}
|
||||
|
||||
async revokeSnippetAccess(
|
||||
accessId: number,
|
||||
snippetId: number,
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(snippetAccess)
|
||||
.where(
|
||||
and(
|
||||
eq(snippetAccess.id, accessId),
|
||||
eq(snippetAccess.snippetId, snippetId),
|
||||
),
|
||||
);
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async listSharedHosts(
|
||||
userId: string,
|
||||
roleIds: number[],
|
||||
now = new Date().toISOString(),
|
||||
): Promise<RbacSharedHost[]> {
|
||||
return this.context.drizzle
|
||||
.select({
|
||||
id: hosts.id,
|
||||
name: hosts.name,
|
||||
ip: hosts.ip,
|
||||
port: hosts.port,
|
||||
username: hosts.username,
|
||||
folder: hosts.folder,
|
||||
tags: hosts.tags,
|
||||
permissionLevel: hostAccess.permissionLevel,
|
||||
expiresAt: hostAccess.expiresAt,
|
||||
grantedBy: hostAccess.grantedBy,
|
||||
ownerUsername: users.username,
|
||||
})
|
||||
.from(hostAccess)
|
||||
.innerJoin(hosts, eq(hostAccess.hostId, hosts.id))
|
||||
.innerJoin(users, eq(hosts.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
this.userOrRoleHostAccessFilter(userId, roleIds),
|
||||
or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(hostAccess.createdAt));
|
||||
}
|
||||
|
||||
async listVisibleHostAccessEntries(
|
||||
userId: string,
|
||||
roleIds: number[],
|
||||
now = new Date().toISOString(),
|
||||
): Promise<RbacVisibleHostAccessEntry[]> {
|
||||
return this.context.drizzle
|
||||
.select({
|
||||
hostId: hostAccess.hostId,
|
||||
permissionLevel: hostAccess.permissionLevel,
|
||||
expiresAt: hostAccess.expiresAt,
|
||||
})
|
||||
.from(hostAccess)
|
||||
.where(
|
||||
and(
|
||||
this.userOrRoleHostAccessFilter(userId, roleIds),
|
||||
or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(hostAccess.createdAt));
|
||||
}
|
||||
|
||||
async listSharedSnippets(
|
||||
userId: string,
|
||||
roleIds: number[],
|
||||
now = new Date().toISOString(),
|
||||
): Promise<RbacSharedSnippet[]> {
|
||||
const directShared = await this.context.drizzle
|
||||
.select({
|
||||
id: snippets.id,
|
||||
name: snippets.name,
|
||||
content: snippets.content,
|
||||
description: snippets.description,
|
||||
folder: snippets.folder,
|
||||
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(
|
||||
and(
|
||||
eq(snippetAccess.userId, userId),
|
||||
or(
|
||||
isNull(snippetAccess.expiresAt),
|
||||
gte(snippetAccess.expiresAt, now),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (roleIds.length === 0) {
|
||||
return directShared;
|
||||
}
|
||||
|
||||
const directIds = new Set(directShared.map((snippet) => snippet.id));
|
||||
const roleShared = await this.context.drizzle
|
||||
.select({
|
||||
id: snippets.id,
|
||||
name: snippets.name,
|
||||
content: snippets.content,
|
||||
description: snippets.description,
|
||||
folder: snippets.folder,
|
||||
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(
|
||||
and(
|
||||
or(
|
||||
isNull(snippetAccess.expiresAt),
|
||||
gte(snippetAccess.expiresAt, now),
|
||||
),
|
||||
inArray(snippetAccess.roleId, roleIds),
|
||||
),
|
||||
);
|
||||
|
||||
return [
|
||||
...directShared,
|
||||
...roleShared.filter((snippet) => !directIds.has(snippet.id)),
|
||||
];
|
||||
}
|
||||
|
||||
async listVisibleSharedSnippets(
|
||||
userId: string,
|
||||
roleIds: number[],
|
||||
now = new Date().toISOString(),
|
||||
): Promise<RbacVisibleSharedSnippet[]> {
|
||||
return this.context.drizzle
|
||||
.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(
|
||||
and(
|
||||
this.userOrRoleSnippetAccessFilter(userId, roleIds),
|
||||
or(
|
||||
isNull(snippetAccess.expiresAt),
|
||||
gte(snippetAccess.expiresAt, now),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async findAccessibleSharedSnippet(
|
||||
snippetId: number,
|
||||
userId: string,
|
||||
roleIds: number[],
|
||||
now = new Date().toISOString(),
|
||||
): Promise<RbacAccessibleSnippet | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.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,
|
||||
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(
|
||||
and(
|
||||
eq(snippetAccess.snippetId, snippetId),
|
||||
this.userOrRoleSnippetAccessFilter(userId, roleIds),
|
||||
or(
|
||||
isNull(snippetAccess.expiresAt),
|
||||
gte(snippetAccess.expiresAt, now),
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async deleteExpiredHostAccess(
|
||||
now = new Date().toISOString(),
|
||||
): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(hostAccess)
|
||||
.where(
|
||||
and(
|
||||
sql`${hostAccess.expiresAt} IS NOT NULL`,
|
||||
sql`${hostAccess.expiresAt} <= ${now}`,
|
||||
),
|
||||
)
|
||||
.returning({ id: hostAccess.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async findActiveHostAccess(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
roleIds: number[],
|
||||
now = new Date().toISOString(),
|
||||
): Promise<typeof hostAccess.$inferSelect | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(hostAccess)
|
||||
.where(
|
||||
and(
|
||||
eq(hostAccess.hostId, hostId),
|
||||
this.userOrRoleHostAccessFilter(userId, roleIds),
|
||||
or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async touchHostAccess(
|
||||
accessId: number,
|
||||
lastAccessedAt = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(hostAccess)
|
||||
.set({ lastAccessedAt })
|
||||
.where(eq(hostAccess.id, accessId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async listRoleHostAccessCredentialSources(
|
||||
roleId: number,
|
||||
): Promise<RbacRoleHostAccessCredentialSource[]> {
|
||||
return this.context.drizzle
|
||||
.select({
|
||||
hostAccessId: hostAccess.id,
|
||||
credentialId: hosts.credentialId,
|
||||
rdpCredentialId: hosts.rdpCredentialId,
|
||||
vncCredentialId: hosts.vncCredentialId,
|
||||
telnetCredentialId: hosts.telnetCredentialId,
|
||||
hostId: hosts.id,
|
||||
hostOwnerId: hosts.userId,
|
||||
})
|
||||
.from(hostAccess)
|
||||
.innerJoin(hosts, eq(hostAccess.hostId, hosts.id))
|
||||
.where(eq(hostAccess.roleId, roleId));
|
||||
}
|
||||
|
||||
async findSharedSecretForHostUserProtocol(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
protocol: string,
|
||||
): Promise<typeof sharedHostSecrets.$inferSelect | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
secret: sharedHostSecrets,
|
||||
})
|
||||
.from(sharedHostSecrets)
|
||||
.innerJoin(hostAccess, eq(sharedHostSecrets.hostAccessId, hostAccess.id))
|
||||
.where(
|
||||
and(
|
||||
eq(hostAccess.hostId, hostId),
|
||||
eq(sharedHostSecrets.targetUserId, userId),
|
||||
eq(sharedHostSecrets.protocol, protocol),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0]?.secret ?? null;
|
||||
}
|
||||
|
||||
async listActiveHostAccessGrants(
|
||||
hostId: number,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<(typeof hostAccess.$inferSelect)[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(hostAccess)
|
||||
.where(
|
||||
and(
|
||||
eq(hostAccess.hostId, hostId),
|
||||
or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async findHostAccessById(
|
||||
accessId: number,
|
||||
hostId: number,
|
||||
): Promise<typeof hostAccess.$inferSelect | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(hostAccess)
|
||||
.where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId)))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async updateHostAccessGrant(
|
||||
accessId: number,
|
||||
hostId: number,
|
||||
update: { permissionLevel?: string; expiresAt?: string | null },
|
||||
): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.update(hostAccess)
|
||||
.set(update)
|
||||
.where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId)))
|
||||
.returning({ id: hostAccess.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async findHostAccessOwnerId(hostAccessId: number): Promise<string | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ ownerId: hosts.userId })
|
||||
.from(hostAccess)
|
||||
.innerJoin(hosts, eq(hostAccess.hostId, hosts.id))
|
||||
.where(eq(hostAccess.id, hostAccessId))
|
||||
.limit(1);
|
||||
|
||||
return rows[0]?.ownerId ?? null;
|
||||
}
|
||||
|
||||
private userOrRoleHostAccessFilter(userId: string, roleIds: number[]) {
|
||||
if (roleIds.length === 0) {
|
||||
return eq(hostAccess.userId, userId);
|
||||
}
|
||||
|
||||
return or(
|
||||
eq(hostAccess.userId, userId),
|
||||
inArray(hostAccess.roleId, roleIds),
|
||||
);
|
||||
}
|
||||
|
||||
private userOrRoleSnippetAccessFilter(userId: string, roleIds: number[]) {
|
||||
if (roleIds.length === 0) {
|
||||
return eq(snippetAccess.userId, userId);
|
||||
}
|
||||
|
||||
return or(
|
||||
eq(snippetAccess.userId, userId),
|
||||
inArray(snippetAccess.roleId, roleIds),
|
||||
);
|
||||
}
|
||||
|
||||
private async findHostAccess(hostId: number, target: RbacAccessTarget) {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(hostAccess)
|
||||
.where(
|
||||
and(
|
||||
eq(hostAccess.hostId, hostId),
|
||||
target.targetType === "user"
|
||||
? eq(hostAccess.userId, target.targetUserId)
|
||||
: eq(hostAccess.roleId, target.targetRoleId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async findSnippetAccess(snippetId: number, target: RbacAccessTarget) {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(snippetAccess)
|
||||
.where(
|
||||
and(
|
||||
eq(snippetAccess.snippetId, snippetId),
|
||||
target.targetType === "user"
|
||||
? eq(snippetAccess.userId, target.targetUserId)
|
||||
: eq(snippetAccess.roleId, target.targetRoleId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { desc, eq, inArray } from "drizzle-orm";
|
||||
import { recentActivity } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type RecentActivityRecord = typeof recentActivity.$inferSelect;
|
||||
export type NewRecentActivityRecord = typeof recentActivity.$inferInsert;
|
||||
|
||||
export class RecentActivityRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByUserId(
|
||||
userId: string,
|
||||
limit: number,
|
||||
): Promise<RecentActivityRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(recentActivity)
|
||||
.where(eq(recentActivity.userId, userId))
|
||||
.orderBy(desc(recentActivity.timestamp))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
async create(
|
||||
activity: NewRecentActivityRecord,
|
||||
): Promise<RecentActivityRecord> {
|
||||
const rows = await this.context.drizzle
|
||||
.insert(recentActivity)
|
||||
.values(activity)
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async trimUserActivity(userId: string, keepCount: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: recentActivity.id })
|
||||
.from(recentActivity)
|
||||
.where(eq(recentActivity.userId, userId))
|
||||
.orderBy(desc(recentActivity.timestamp));
|
||||
|
||||
const idsToDelete = rows
|
||||
.slice(keepCount)
|
||||
.map((row) => row.id)
|
||||
.filter((id) => typeof id === "number");
|
||||
|
||||
if (idsToDelete.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const deletedRows = await this.context.drizzle
|
||||
.delete(recentActivity)
|
||||
.where(inArray(recentActivity.id, idsToDelete))
|
||||
.returning({ id: recentActivity.id });
|
||||
|
||||
if (deletedRows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return deletedRows.length;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(recentActivity)
|
||||
.where(eq(recentActivity.userId, userId))
|
||||
.returning({ id: recentActivity.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteByHostId(hostId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(recentActivity)
|
||||
.where(eq(recentActivity.hostId, hostId))
|
||||
.returning({ id: recentActivity.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteByHostIds(hostIds: number[]): Promise<number> {
|
||||
if (hostIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.delete(recentActivity)
|
||||
.where(inArray(recentActivity.hostId, hostIds))
|
||||
.returning({ id: recentActivity.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { hostAccess, roles, userRoles } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type RoleRecord = typeof roles.$inferSelect;
|
||||
export type NewRoleRecord = typeof roles.$inferInsert;
|
||||
export type RoleUpdate = Pick<
|
||||
Partial<NewRoleRecord>,
|
||||
"displayName" | "description" | "updatedAt"
|
||||
>;
|
||||
|
||||
export type UserRoleWithRole = {
|
||||
id: number;
|
||||
roleId: number;
|
||||
roleName: string;
|
||||
roleDisplayName: string;
|
||||
description: string | null;
|
||||
isSystem: boolean;
|
||||
grantedAt: string;
|
||||
};
|
||||
|
||||
export type UserRolePermissionRecord = {
|
||||
permissions: string | null;
|
||||
};
|
||||
|
||||
export type UserRoleNameSwitchResult = {
|
||||
added: boolean;
|
||||
removed: boolean;
|
||||
};
|
||||
|
||||
export class RoleRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listRoles(): Promise<RoleRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(roles)
|
||||
.orderBy(roles.isSystem, roles.name);
|
||||
}
|
||||
|
||||
async findRoleById(id: number): Promise<RoleRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(eq(roles.id, id))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findRoleByName(name: string): Promise<RoleRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(eq(roles.name, name))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async createRole(role: NewRoleRecord): Promise<number> {
|
||||
const result = await this.context.drizzle.insert(roles).values(role);
|
||||
await this.afterWrite();
|
||||
return Number(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
async updateRole(id: number, update: RoleUpdate): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.update(roles)
|
||||
.set(update)
|
||||
.where(eq(roles.id, id))
|
||||
.returning({ id: roles.id });
|
||||
|
||||
await this.afterWrite();
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteRole(id: number): Promise<{ deletedUserIds: string[] }> {
|
||||
const deletedUserRoles = await this.context.drizzle
|
||||
.delete(userRoles)
|
||||
.where(eq(userRoles.roleId, id))
|
||||
.returning({ userId: userRoles.userId });
|
||||
|
||||
await this.context.drizzle
|
||||
.delete(hostAccess)
|
||||
.where(eq(hostAccess.roleId, id));
|
||||
|
||||
await this.context.drizzle.delete(roles).where(eq(roles.id, id));
|
||||
await this.afterWrite();
|
||||
|
||||
return {
|
||||
deletedUserIds: deletedUserRoles.map((row) => row.userId),
|
||||
};
|
||||
}
|
||||
|
||||
async findUserRole(
|
||||
userId: string,
|
||||
roleId: number,
|
||||
): Promise<typeof userRoles.$inferSelect | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(userRoles)
|
||||
.where(and(eq(userRoles.userId, userId), eq(userRoles.roleId, roleId)))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async assignRoleToUser(input: {
|
||||
userId: string;
|
||||
roleId: number;
|
||||
grantedBy: string;
|
||||
}): Promise<void> {
|
||||
await this.context.drizzle.insert(userRoles).values(input);
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async assignRoleNameToUser(input: {
|
||||
userId: string;
|
||||
roleName: string;
|
||||
grantedBy: string;
|
||||
}): Promise<boolean> {
|
||||
const role = await this.findRoleByName(input.roleName);
|
||||
if (!role) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.context.drizzle.insert(userRoles).values({
|
||||
userId: input.userId,
|
||||
roleId: role.id,
|
||||
grantedBy: input.grantedBy,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async switchUserRoleName(input: {
|
||||
userId: string;
|
||||
addRoleName: string;
|
||||
removeRoleName: string;
|
||||
grantedBy: string;
|
||||
}): Promise<UserRoleNameSwitchResult> {
|
||||
const [addRole, removeRole] = await Promise.all([
|
||||
this.findRoleByName(input.addRoleName),
|
||||
this.findRoleByName(input.removeRoleName),
|
||||
]);
|
||||
|
||||
let added = false;
|
||||
let removed = false;
|
||||
|
||||
if (addRole) {
|
||||
await this.context.drizzle
|
||||
.delete(userRoles)
|
||||
.where(
|
||||
and(
|
||||
eq(userRoles.userId, input.userId),
|
||||
eq(userRoles.roleId, addRole.id),
|
||||
),
|
||||
);
|
||||
await this.context.drizzle.insert(userRoles).values({
|
||||
userId: input.userId,
|
||||
roleId: addRole.id,
|
||||
grantedBy: input.grantedBy,
|
||||
});
|
||||
added = true;
|
||||
}
|
||||
|
||||
if (removeRole) {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(userRoles)
|
||||
.where(
|
||||
and(
|
||||
eq(userRoles.userId, input.userId),
|
||||
eq(userRoles.roleId, removeRole.id),
|
||||
),
|
||||
)
|
||||
.returning({ id: userRoles.id });
|
||||
removed = rows.length > 0;
|
||||
}
|
||||
|
||||
if (added || removed) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return { added, removed };
|
||||
}
|
||||
|
||||
async removeRoleFromUser(userId: string, roleId: number): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(userRoles)
|
||||
.where(and(eq(userRoles.userId, userId), eq(userRoles.roleId, roleId)));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async removeAllRolesFromUser(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(userRoles)
|
||||
.where(eq(userRoles.userId, userId))
|
||||
.returning({ id: userRoles.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async listUserRoleIds(userId: string): Promise<number[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ roleId: userRoles.roleId })
|
||||
.from(userRoles)
|
||||
.where(eq(userRoles.userId, userId));
|
||||
|
||||
return rows.map((row) => row.roleId);
|
||||
}
|
||||
|
||||
async listRoleUserIds(roleId: number): Promise<string[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ userId: userRoles.userId })
|
||||
.from(userRoles)
|
||||
.where(eq(userRoles.roleId, roleId));
|
||||
|
||||
return rows.map((row) => row.userId);
|
||||
}
|
||||
|
||||
async listUserRolePermissions(
|
||||
userId: string,
|
||||
): Promise<UserRolePermissionRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select({ permissions: roles.permissions })
|
||||
.from(userRoles)
|
||||
.innerJoin(roles, eq(userRoles.roleId, roles.id))
|
||||
.where(eq(userRoles.userId, userId));
|
||||
}
|
||||
|
||||
async userHasAnyRoleName(
|
||||
userId: string,
|
||||
roleNames: string[],
|
||||
): Promise<boolean> {
|
||||
if (roleNames.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.select({ roleName: roles.name })
|
||||
.from(userRoles)
|
||||
.innerJoin(roles, eq(userRoles.roleId, roles.id))
|
||||
.where(and(eq(userRoles.userId, userId), inArray(roles.name, roleNames)))
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async listUserRoles(userId: string): Promise<UserRoleWithRole[]> {
|
||||
return this.context.drizzle
|
||||
.select({
|
||||
id: userRoles.id,
|
||||
roleId: roles.id,
|
||||
roleName: roles.name,
|
||||
roleDisplayName: roles.displayName,
|
||||
description: roles.description,
|
||||
isSystem: roles.isSystem,
|
||||
grantedAt: userRoles.grantedAt,
|
||||
})
|
||||
.from(userRoles)
|
||||
.innerJoin(roles, eq(userRoles.roleId, roles.id))
|
||||
.where(eq(userRoles.userId, userId));
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { and, desc, eq, inArray, lt } from "drizzle-orm";
|
||||
import { hosts, sessionRecordings } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type SessionRecordingRecord = typeof sessionRecordings.$inferSelect;
|
||||
|
||||
export interface SessionRecordingCreateInput {
|
||||
hostId: number;
|
||||
userId: string;
|
||||
startedAt: string;
|
||||
endedAt?: string | null;
|
||||
duration?: number | null;
|
||||
commands?: string | null;
|
||||
dangerousActions?: string | null;
|
||||
recordingPath?: string | null;
|
||||
protocol?: string | null;
|
||||
format?: string | null;
|
||||
terminatedByOwner?: boolean | null;
|
||||
terminationReason?: string | null;
|
||||
}
|
||||
|
||||
export interface SessionRecordingListRecord {
|
||||
id: number;
|
||||
hostId: number;
|
||||
userId: string;
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
duration: number | null;
|
||||
recordingPath: string | null;
|
||||
protocol: string;
|
||||
format: string | null;
|
||||
hostName: string | null;
|
||||
hostIp: string | null;
|
||||
}
|
||||
|
||||
export interface SessionRecordingPathRecord {
|
||||
id: number;
|
||||
recordingPath: string | null;
|
||||
}
|
||||
|
||||
export class SessionRecordingRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
input: SessionRecordingCreateInput,
|
||||
): Promise<SessionRecordingRecord> {
|
||||
const [created] = await this.context.drizzle
|
||||
.insert(sessionRecordings)
|
||||
.values(input)
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async updateEnded(
|
||||
id: number,
|
||||
input: { endedAt: string; duration: number | null },
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(sessionRecordings)
|
||||
.set(input)
|
||||
.where(eq(sessionRecordings.id, id));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<SessionRecordingRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(sessionRecordings)
|
||||
.where(eq(sessionRecordings.id, id))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findPathById(id: number): Promise<
|
||||
| (SessionRecordingPathRecord & {
|
||||
userId: string;
|
||||
format?: string | null;
|
||||
})
|
||||
| null
|
||||
> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
id: sessionRecordings.id,
|
||||
recordingPath: sessionRecordings.recordingPath,
|
||||
userId: sessionRecordings.userId,
|
||||
format: sessionRecordings.format,
|
||||
})
|
||||
.from(sessionRecordings)
|
||||
.where(eq(sessionRecordings.id, id))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async listByUserIdWithHost(
|
||||
userId: string,
|
||||
): Promise<SessionRecordingListRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select({
|
||||
id: sessionRecordings.id,
|
||||
hostId: sessionRecordings.hostId,
|
||||
userId: sessionRecordings.userId,
|
||||
startedAt: sessionRecordings.startedAt,
|
||||
endedAt: sessionRecordings.endedAt,
|
||||
duration: sessionRecordings.duration,
|
||||
recordingPath: sessionRecordings.recordingPath,
|
||||
protocol: sessionRecordings.protocol,
|
||||
format: sessionRecordings.format,
|
||||
hostName: hosts.name,
|
||||
hostIp: hosts.ip,
|
||||
})
|
||||
.from(sessionRecordings)
|
||||
.leftJoin(hosts, eq(sessionRecordings.hostId, hosts.id))
|
||||
.where(eq(sessionRecordings.userId, userId))
|
||||
.orderBy(desc(sessionRecordings.startedAt));
|
||||
}
|
||||
|
||||
async findByIdForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<SessionRecordingRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(sessionRecordings)
|
||||
.where(
|
||||
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findPathByIdForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<SessionRecordingPathRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
id: sessionRecordings.id,
|
||||
recordingPath: sessionRecordings.recordingPath,
|
||||
})
|
||||
.from(sessionRecordings)
|
||||
.where(
|
||||
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async listPathsOlderThan(
|
||||
cutoff: string,
|
||||
): Promise<SessionRecordingPathRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select({
|
||||
id: sessionRecordings.id,
|
||||
recordingPath: sessionRecordings.recordingPath,
|
||||
})
|
||||
.from(sessionRecordings)
|
||||
.where(lt(sessionRecordings.startedAt, cutoff));
|
||||
}
|
||||
|
||||
async deleteById(id: number): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sessionRecordings)
|
||||
.where(eq(sessionRecordings.id, id))
|
||||
.returning({ id: sessionRecordings.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteForUser(userId: string, id: number): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sessionRecordings)
|
||||
.where(
|
||||
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
|
||||
)
|
||||
.returning({ id: sessionRecordings.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sessionRecordings)
|
||||
.where(eq(sessionRecordings.userId, userId))
|
||||
.returning({ id: sessionRecordings.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteByHostId(hostId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sessionRecordings)
|
||||
.where(eq(sessionRecordings.hostId, hostId))
|
||||
.returning({ id: sessionRecordings.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteByHostIds(hostIds: number[]): Promise<number> {
|
||||
if (hostIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sessionRecordings)
|
||||
.where(inArray(sessionRecordings.hostId, hostIds))
|
||||
.returning({ id: sessionRecordings.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { and, eq, lte, ne } from "drizzle-orm";
|
||||
import { sessions } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type SessionRecord = typeof sessions.$inferSelect;
|
||||
export type NewSessionRecord = typeof sessions.$inferInsert;
|
||||
|
||||
export class SessionRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async create(session: NewSessionRecord): Promise<SessionRecord> {
|
||||
const rows = await this.context.drizzle
|
||||
.insert(sessions)
|
||||
.values(session)
|
||||
.returning();
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async listAll(): Promise<SessionRecord[]> {
|
||||
return this.context.drizzle.select().from(sessions);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<SessionRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.id, id))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async listByUserId(userId: string): Promise<SessionRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.userId, userId));
|
||||
}
|
||||
|
||||
async listExpired(now = new Date()): Promise<SessionRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(lte(sessions.expiresAt, now.toISOString()));
|
||||
}
|
||||
|
||||
async touch(
|
||||
id: string,
|
||||
lastActiveAt = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(sessions)
|
||||
.set({ lastActiveAt })
|
||||
.where(eq(sessions.id, id));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async updateToken(
|
||||
id: string,
|
||||
jwtToken: string,
|
||||
lastActiveAt = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(sessions)
|
||||
.set({ jwtToken, lastActiveAt })
|
||||
.where(eq(sessions.id, id));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async revoke(id: string): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sessions)
|
||||
.where(eq(sessions.id, id))
|
||||
.returning({ id: sessions.id });
|
||||
|
||||
await this.afterWrite();
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async revokeAllForUser(
|
||||
userId: string,
|
||||
exceptSessionId?: string,
|
||||
): Promise<number> {
|
||||
const where = exceptSessionId
|
||||
? and(eq(sessions.userId, userId), ne(sessions.id, exceptSessionId))
|
||||
: eq(sessions.userId, userId);
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sessions)
|
||||
.where(where)
|
||||
.returning({ id: sessions.id });
|
||||
|
||||
await this.afterWrite();
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteExpired(now = new Date()): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sessions)
|
||||
.where(lte(sessions.expiresAt, now.toISOString()))
|
||||
.returning({ id: sessions.id });
|
||||
|
||||
await this.afterWrite();
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { eq, like } from "drizzle-orm";
|
||||
import { settings } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export class SettingsRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ value: settings.value })
|
||||
.from(settings)
|
||||
.where(eq(settings.key, key))
|
||||
.limit(1);
|
||||
|
||||
return rows[0]?.value ?? null;
|
||||
}
|
||||
|
||||
async listAll(): Promise<Array<{ key: string; value: string }>> {
|
||||
return this.context.drizzle
|
||||
.select({ key: settings.key, value: settings.value })
|
||||
.from(settings);
|
||||
}
|
||||
|
||||
async getBoolean(key: string, fallback = false): Promise<boolean> {
|
||||
const value = await this.get(key);
|
||||
if (value === null) return fallback;
|
||||
return value === "true" || value === "1";
|
||||
}
|
||||
|
||||
async set(key: string, value: string): Promise<void> {
|
||||
const existing = await this.get(key);
|
||||
if (existing === null) {
|
||||
await this.context.drizzle.insert(settings).values({ key, value });
|
||||
await this.afterWrite();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.context.drizzle
|
||||
.update(settings)
|
||||
.set({ value })
|
||||
.where(eq(settings.key, key));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async upsert(key: string, value: string): Promise<void> {
|
||||
await this.set(key, value);
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
await this.context.drizzle.delete(settings).where(eq(settings.key, key));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteLike(pattern: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(settings)
|
||||
.where(like(settings.key, pattern))
|
||||
.returning({ key: settings.key });
|
||||
await this.afterWrite();
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { and, eq, inArray, or } from "drizzle-orm";
|
||||
import { hostAccess, hosts, sharedHostSecrets } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type SharedHostSecretRecord = typeof sharedHostSecrets.$inferSelect;
|
||||
export type NewSharedHostSecretRecord = typeof sharedHostSecrets.$inferInsert;
|
||||
|
||||
export type ShareProtocol = "ssh" | "rdp" | "vnc" | "telnet";
|
||||
|
||||
export class SharedHostSecretsRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async upsert(record: NewSharedHostSecretRecord): Promise<void> {
|
||||
const existing = await this.context.drizzle
|
||||
.select({ id: sharedHostSecrets.id })
|
||||
.from(sharedHostSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(sharedHostSecrets.hostAccessId, record.hostAccessId),
|
||||
eq(sharedHostSecrets.targetUserId, record.targetUserId),
|
||||
eq(sharedHostSecrets.protocol, record.protocol ?? "ssh"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await this.context.drizzle
|
||||
.update(sharedHostSecrets)
|
||||
.set({ ...record, updatedAt: new Date().toISOString() })
|
||||
.where(eq(sharedHostSecrets.id, existing[0].id));
|
||||
} else {
|
||||
await this.context.drizzle.insert(sharedHostSecrets).values(record);
|
||||
}
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async findForHostUserProtocol(
|
||||
hostId: number,
|
||||
targetUserId: string,
|
||||
protocol: ShareProtocol,
|
||||
): Promise<SharedHostSecretRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ secret: sharedHostSecrets })
|
||||
.from(sharedHostSecrets)
|
||||
.innerJoin(hostAccess, eq(sharedHostSecrets.hostAccessId, hostAccess.id))
|
||||
.where(
|
||||
and(
|
||||
eq(hostAccess.hostId, hostId),
|
||||
eq(sharedHostSecrets.targetUserId, targetUserId),
|
||||
eq(sharedHostSecrets.protocol, protocol),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0]?.secret ?? null;
|
||||
}
|
||||
|
||||
async existsForHostAccessAndTargetUser(
|
||||
hostAccessId: number,
|
||||
targetUserId: string,
|
||||
): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: sharedHostSecrets.id })
|
||||
.from(sharedHostSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(sharedHostSecrets.hostAccessId, hostAccessId),
|
||||
eq(sharedHostSecrets.targetUserId, targetUserId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteForHostAccessAndTarget(
|
||||
hostAccessId: number,
|
||||
targetUserId: string,
|
||||
keepProtocols: ShareProtocol[] = [],
|
||||
): Promise<void> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
id: sharedHostSecrets.id,
|
||||
protocol: sharedHostSecrets.protocol,
|
||||
})
|
||||
.from(sharedHostSecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(sharedHostSecrets.hostAccessId, hostAccessId),
|
||||
eq(sharedHostSecrets.targetUserId, targetUserId),
|
||||
),
|
||||
);
|
||||
|
||||
const staleIds = rows
|
||||
.filter((row) => !keepProtocols.includes(row.protocol as ShareProtocol))
|
||||
.map((row) => row.id);
|
||||
|
||||
if (staleIds.length > 0) {
|
||||
await this.context.drizzle
|
||||
.delete(sharedHostSecrets)
|
||||
.where(inArray(sharedHostSecrets.id, staleIds));
|
||||
await this.afterWrite();
|
||||
}
|
||||
}
|
||||
|
||||
async deleteByHostAccessId(hostAccessId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sharedHostSecrets)
|
||||
.where(eq(sharedHostSecrets.hostAccessId, hostAccessId))
|
||||
.returning({ id: sharedHostSecrets.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteForRoleMember(
|
||||
roleId: number,
|
||||
targetUserId: string,
|
||||
): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: sharedHostSecrets.id })
|
||||
.from(sharedHostSecrets)
|
||||
.innerJoin(hostAccess, eq(sharedHostSecrets.hostAccessId, hostAccess.id))
|
||||
.where(
|
||||
and(
|
||||
eq(hostAccess.roleId, roleId),
|
||||
eq(sharedHostSecrets.targetUserId, targetUserId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rows.length === 0) return 0;
|
||||
|
||||
await this.context.drizzle.delete(sharedHostSecrets).where(
|
||||
inArray(
|
||||
sharedHostSecrets.id,
|
||||
rows.map((row) => row.id),
|
||||
),
|
||||
);
|
||||
await this.afterWrite();
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteByOriginalCredentialId(credentialId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sharedHostSecrets)
|
||||
.where(eq(sharedHostSecrets.originalCredentialId, credentialId))
|
||||
.returning({ id: sharedHostSecrets.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteByTargetUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sharedHostSecrets)
|
||||
.where(eq(sharedHostSecrets.targetUserId, userId))
|
||||
.returning({ id: sharedHostSecrets.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async findHostIdsReferencingCredential(
|
||||
ownerId: string,
|
||||
credentialId: number,
|
||||
): Promise<number[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: hosts.id })
|
||||
.from(hosts)
|
||||
.where(
|
||||
and(
|
||||
eq(hosts.userId, ownerId),
|
||||
or(
|
||||
eq(hosts.credentialId, credentialId),
|
||||
eq(hosts.rdpCredentialId, credentialId),
|
||||
eq(hosts.vncCredentialId, credentialId),
|
||||
eq(hosts.telnetCredentialId, credentialId),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
import { and, asc, eq, sql } from "drizzle-orm";
|
||||
import { snippetFolders, snippets } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type SnippetRecord = typeof snippets.$inferSelect;
|
||||
export type SnippetFolderRecord = typeof snippetFolders.$inferSelect;
|
||||
export interface RenameSnippetFolderResult {
|
||||
status: "renamed" | "missing" | "conflict";
|
||||
}
|
||||
export interface SnippetReorderUpdate {
|
||||
id: number;
|
||||
order: number;
|
||||
folder?: string;
|
||||
}
|
||||
export interface NewSnippetInput {
|
||||
name: string;
|
||||
content: string;
|
||||
description?: string | null;
|
||||
folder?: string | null;
|
||||
order?: number | null;
|
||||
hostFilter?: unknown;
|
||||
}
|
||||
export interface SnippetUpdateInput {
|
||||
name?: string;
|
||||
content?: string;
|
||||
description?: string | null;
|
||||
folder?: string | null;
|
||||
order?: number;
|
||||
hostFilter?: unknown;
|
||||
}
|
||||
export interface UpdateSnippetResult {
|
||||
existing: SnippetRecord;
|
||||
updated: SnippetRecord;
|
||||
}
|
||||
export interface SnippetBulkImportResult {
|
||||
snippetsImported: number;
|
||||
snippetsSkipped: number;
|
||||
snippetsUpdated: number;
|
||||
foldersImported: number;
|
||||
foldersSkipped: number;
|
||||
failed: number;
|
||||
errors: string[];
|
||||
}
|
||||
interface BulkImportSnippetInput {
|
||||
name?: unknown;
|
||||
content?: unknown;
|
||||
description?: string | null;
|
||||
folder?: string | null;
|
||||
order?: unknown;
|
||||
hostFilter?: string | null;
|
||||
}
|
||||
interface BulkImportFolderInput {
|
||||
name?: unknown;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
export class SnippetRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async findOwnedById(
|
||||
userId: string,
|
||||
snippetId: number,
|
||||
): Promise<SnippetRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async listFolders(userId: string): Promise<SnippetFolderRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(snippetFolders)
|
||||
.where(eq(snippetFolders.userId, userId))
|
||||
.orderBy(asc(snippetFolders.name));
|
||||
}
|
||||
|
||||
async listSnippetsForExport(userId: string): Promise<SnippetRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(eq(snippets.userId, userId))
|
||||
.orderBy(asc(snippets.folder), asc(snippets.order));
|
||||
}
|
||||
|
||||
async listFoldersForExport(userId: string): Promise<SnippetFolderRecord[]> {
|
||||
return this.listFolders(userId);
|
||||
}
|
||||
|
||||
async listOwnedSnippets(userId: string): Promise<SnippetRecord[]> {
|
||||
return this.context.drizzle
|
||||
.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),
|
||||
sql`${snippets.updatedAt} DESC`,
|
||||
);
|
||||
}
|
||||
|
||||
async reorderSnippets(
|
||||
userId: string,
|
||||
updates: SnippetReorderUpdate[],
|
||||
): Promise<void> {
|
||||
for (const update of updates) {
|
||||
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 this.context.drizzle
|
||||
.update(snippets)
|
||||
.set(updateFields)
|
||||
.where(and(eq(snippets.id, id), eq(snippets.userId, userId)));
|
||||
}
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async createSnippet(
|
||||
userId: string,
|
||||
input: NewSnippetInput,
|
||||
): Promise<SnippetRecord> {
|
||||
const folderValue = input.folder?.trim() || "";
|
||||
const order =
|
||||
input.order === undefined || input.order === null
|
||||
? await this.nextOrderForFolder(userId, folderValue)
|
||||
: input.order;
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.insert(snippets)
|
||||
.values({
|
||||
userId,
|
||||
name: input.name.trim(),
|
||||
content: input.content.trim(),
|
||||
description: input.description?.trim() || null,
|
||||
folder: input.folder?.trim() || null,
|
||||
order,
|
||||
hostFilter: input.hostFilter ? JSON.stringify(input.hostFilter) : null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async updateSnippet(
|
||||
userId: string,
|
||||
snippetId: number,
|
||||
input: SnippetUpdateInput,
|
||||
): Promise<UpdateSnippetResult | null> {
|
||||
const existing = await this.findOwnedById(userId, snippetId);
|
||||
if (!existing) return null;
|
||||
|
||||
const updateFields: Partial<{
|
||||
updatedAt: ReturnType<typeof sql>;
|
||||
name: string;
|
||||
content: string;
|
||||
description: string | null;
|
||||
folder: string | null;
|
||||
order: number;
|
||||
hostFilter: string | null;
|
||||
}> = {
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
};
|
||||
|
||||
if (input.name !== undefined) updateFields.name = input.name.trim();
|
||||
if (input.content !== undefined)
|
||||
updateFields.content = input.content.trim();
|
||||
if (input.description !== undefined)
|
||||
updateFields.description = input.description?.trim() || null;
|
||||
if (input.folder !== undefined)
|
||||
updateFields.folder = input.folder?.trim() || null;
|
||||
if (input.order !== undefined) updateFields.order = input.order;
|
||||
if (input.hostFilter !== undefined)
|
||||
updateFields.hostFilter = input.hostFilter
|
||||
? JSON.stringify(input.hostFilter)
|
||||
: null;
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.update(snippets)
|
||||
.set(updateFields)
|
||||
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)))
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return { existing, updated: rows[0] };
|
||||
}
|
||||
|
||||
async deleteSnippet(
|
||||
userId: string,
|
||||
snippetId: number,
|
||||
): Promise<SnippetRecord | null> {
|
||||
const existing = await this.findOwnedById(userId, snippetId);
|
||||
if (!existing) return null;
|
||||
|
||||
await this.context.drizzle
|
||||
.delete(snippets)
|
||||
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)));
|
||||
|
||||
await this.afterWrite();
|
||||
return existing;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<{
|
||||
snippetsDeleted: number;
|
||||
foldersDeleted: number;
|
||||
}> {
|
||||
const deletedSnippets = await this.context.drizzle
|
||||
.delete(snippets)
|
||||
.where(eq(snippets.userId, userId))
|
||||
.returning({ id: snippets.id });
|
||||
|
||||
const deletedFolders = await this.context.drizzle
|
||||
.delete(snippetFolders)
|
||||
.where(eq(snippetFolders.userId, userId))
|
||||
.returning({ id: snippetFolders.id });
|
||||
|
||||
if (deletedSnippets.length > 0 || deletedFolders.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return {
|
||||
snippetsDeleted: deletedSnippets.length,
|
||||
foldersDeleted: deletedFolders.length,
|
||||
};
|
||||
}
|
||||
|
||||
async bulkImport(
|
||||
userId: string,
|
||||
snippetsToImport: unknown[] | undefined,
|
||||
foldersToImport: unknown[] | undefined,
|
||||
overwrite: boolean,
|
||||
): Promise<SnippetBulkImportResult> {
|
||||
const results: SnippetBulkImportResult = {
|
||||
snippetsImported: 0,
|
||||
snippetsSkipped: 0,
|
||||
snippetsUpdated: 0,
|
||||
foldersImported: 0,
|
||||
foldersSkipped: 0,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
let changed = false;
|
||||
|
||||
if (Array.isArray(foldersToImport)) {
|
||||
for (const rawFolder of foldersToImport) {
|
||||
const folder = rawFolder as BulkImportFolderInput;
|
||||
if (!isNonEmptyString(folder.name)) {
|
||||
results.failed++;
|
||||
results.errors.push(`Folder missing name`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const created = await this.createFolder(
|
||||
userId,
|
||||
folder.name,
|
||||
folder.color,
|
||||
folder.icon,
|
||||
false,
|
||||
);
|
||||
|
||||
if (!created) {
|
||||
results.foldersSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
changed = true;
|
||||
results.foldersImported++;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(snippetsToImport)) {
|
||||
for (let i = 0; i < snippetsToImport.length; i++) {
|
||||
const snippet = snippetsToImport[i] as BulkImportSnippetInput;
|
||||
|
||||
if (
|
||||
!isNonEmptyString(snippet.name) ||
|
||||
!isNonEmptyString(snippet.content)
|
||||
) {
|
||||
results.failed++;
|
||||
results.errors.push(
|
||||
`Snippet ${i + 1}: name and content are required`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const folderVal = snippet.folder?.trim() || null;
|
||||
const existing = await this.findByNameAndFolder(
|
||||
userId,
|
||||
snippet.name.trim(),
|
||||
folderVal,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
if (!overwrite) {
|
||||
results.snippetsSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.context.drizzle
|
||||
.update(snippets)
|
||||
.set({
|
||||
content: snippet.content.trim(),
|
||||
description: snippet.description?.trim() || null,
|
||||
folder: folderVal,
|
||||
order:
|
||||
typeof snippet.order === "number"
|
||||
? snippet.order
|
||||
: existing.order,
|
||||
hostFilter: snippet.hostFilter || null,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(
|
||||
and(eq(snippets.id, existing.id), eq(snippets.userId, userId)),
|
||||
);
|
||||
changed = true;
|
||||
results.snippetsUpdated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const maxOrder = await this.maxOrderForFolder(userId, folderVal);
|
||||
await this.context.drizzle.insert(snippets).values({
|
||||
userId,
|
||||
name: snippet.name.trim(),
|
||||
content: snippet.content.trim(),
|
||||
description: snippet.description?.trim() || null,
|
||||
folder: folderVal,
|
||||
order:
|
||||
typeof snippet.order === "number" ? snippet.order : maxOrder + 1,
|
||||
hostFilter: snippet.hostFilter || null,
|
||||
});
|
||||
changed = true;
|
||||
results.snippetsImported++;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async createFolder(
|
||||
userId: string,
|
||||
name: string,
|
||||
color: string | null | undefined,
|
||||
icon: string | null | undefined,
|
||||
triggerSave = true,
|
||||
): Promise<SnippetFolderRecord | null> {
|
||||
const existing = await this.findFolderByName(userId, name);
|
||||
if (existing) return null;
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.insert(snippetFolders)
|
||||
.values({
|
||||
userId,
|
||||
name: name.trim(),
|
||||
color: color?.trim() || null,
|
||||
icon: icon?.trim() || null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (triggerSave) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async updateFolderMetadata(
|
||||
userId: string,
|
||||
name: string,
|
||||
color: string | null | undefined,
|
||||
icon: string | null | undefined,
|
||||
): Promise<SnippetFolderRecord | null> {
|
||||
const existing = await this.findFolderByName(userId, name);
|
||||
if (!existing) return null;
|
||||
|
||||
const updateFields: Partial<{
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
updatedAt: ReturnType<typeof sql>;
|
||||
}> = {
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
};
|
||||
|
||||
if (color !== undefined) updateFields.color = color?.trim() || null;
|
||||
if (icon !== undefined) updateFields.icon = icon?.trim() || null;
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.update(snippetFolders)
|
||||
.set(updateFields)
|
||||
.where(
|
||||
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
|
||||
)
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async renameFolder(
|
||||
userId: string,
|
||||
oldName: string,
|
||||
newName: string,
|
||||
): Promise<RenameSnippetFolderResult> {
|
||||
const existing = await this.findFolderByName(userId, oldName);
|
||||
if (!existing) return { status: "missing" };
|
||||
|
||||
const nameExists = await this.findFolderByName(userId, newName);
|
||||
if (nameExists) return { status: "conflict" };
|
||||
|
||||
await this.context.drizzle
|
||||
.update(snippetFolders)
|
||||
.set({ name: newName, updatedAt: sql`CURRENT_TIMESTAMP` })
|
||||
.where(
|
||||
and(
|
||||
eq(snippetFolders.userId, userId),
|
||||
eq(snippetFolders.name, oldName),
|
||||
),
|
||||
);
|
||||
|
||||
await this.context.drizzle
|
||||
.update(snippets)
|
||||
.set({ folder: newName })
|
||||
.where(and(eq(snippets.userId, userId), eq(snippets.folder, oldName)));
|
||||
|
||||
await this.afterWrite();
|
||||
return { status: "renamed" };
|
||||
}
|
||||
|
||||
async deleteFolder(userId: string, name: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(snippets)
|
||||
.set({ folder: null })
|
||||
.where(and(eq(snippets.userId, userId), eq(snippets.folder, name)));
|
||||
|
||||
await this.context.drizzle
|
||||
.delete(snippetFolders)
|
||||
.where(
|
||||
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
private async findFolderByName(
|
||||
userId: string,
|
||||
name: string,
|
||||
): Promise<SnippetFolderRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(snippetFolders)
|
||||
.where(
|
||||
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async nextOrderForFolder(
|
||||
userId: string,
|
||||
folder: string,
|
||||
): Promise<number> {
|
||||
return (await this.maxOrderForFolder(userId, folder || null)) + 1;
|
||||
}
|
||||
|
||||
private async maxOrderForFolder(
|
||||
userId: string,
|
||||
folder: string | null,
|
||||
): Promise<number> {
|
||||
const maxOrderResult = await this.context.drizzle
|
||||
.select({ maxOrder: sql<number>`MAX(${snippets.order})` })
|
||||
.from(snippets)
|
||||
.where(
|
||||
and(
|
||||
eq(snippets.userId, userId),
|
||||
folder
|
||||
? eq(snippets.folder, folder)
|
||||
: sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`,
|
||||
),
|
||||
);
|
||||
const maxOrder = maxOrderResult[0]?.maxOrder ?? -1;
|
||||
return maxOrder;
|
||||
}
|
||||
|
||||
private async findByNameAndFolder(
|
||||
userId: string,
|
||||
name: string,
|
||||
folder: string | null,
|
||||
): Promise<SnippetRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(
|
||||
and(
|
||||
eq(snippets.userId, userId),
|
||||
eq(snippets.name, name),
|
||||
folder
|
||||
? eq(snippets.folder, folder)
|
||||
: sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`,
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { getCurrentRepositorySqlite } from "./factory.js";
|
||||
|
||||
export interface SqliteForeignKeyClient {
|
||||
exec(sql: string): unknown;
|
||||
}
|
||||
|
||||
export async function withSqliteForeignKeysDisabled<T>(
|
||||
sqlite: SqliteForeignKeyClient,
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
sqlite.exec("PRAGMA foreign_keys = OFF");
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
sqlite.exec("PRAGMA foreign_keys = ON");
|
||||
}
|
||||
}
|
||||
|
||||
export async function withCurrentSqliteForeignKeysDisabled<T>(
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
return withSqliteForeignKeysDisabled(getCurrentRepositorySqlite(), operation);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { sshCredentialUsage } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type SshCredentialUsageRecord = typeof sshCredentialUsage.$inferSelect;
|
||||
|
||||
export class SshCredentialUsageRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByUserId(userId: string): Promise<SshCredentialUsageRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(sshCredentialUsage)
|
||||
.where(eq(sshCredentialUsage.userId, userId));
|
||||
}
|
||||
|
||||
async create(
|
||||
credentialId: number,
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<SshCredentialUsageRecord> {
|
||||
const [created] = await this.context.drizzle
|
||||
.insert(sshCredentialUsage)
|
||||
.values({ credentialId, hostId, userId })
|
||||
.returning();
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sshCredentialUsage)
|
||||
.where(eq(sshCredentialUsage.userId, userId))
|
||||
.returning({ id: sshCredentialUsage.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteByHostId(hostId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sshCredentialUsage)
|
||||
.where(eq(sshCredentialUsage.hostId, hostId))
|
||||
.returning({ id: sshCredentialUsage.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteByHostIds(hostIds: number[]): Promise<number> {
|
||||
if (hostIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sshCredentialUsage)
|
||||
.where(inArray(sshCredentialUsage.hostId, hostIds))
|
||||
.returning({ id: sshCredentialUsage.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { ssoProviders, users } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type SsoProviderRecord = typeof ssoProviders.$inferSelect;
|
||||
export type NewSsoProviderRecord = typeof ssoProviders.$inferInsert;
|
||||
export type SsoProviderUpdate = Partial<
|
||||
Pick<
|
||||
NewSsoProviderRecord,
|
||||
"name" | "type" | "enabled" | "displayOrder" | "config" | "updatedAt"
|
||||
>
|
||||
>;
|
||||
|
||||
export type PublicSsoProviderRecord = Pick<
|
||||
SsoProviderRecord,
|
||||
"id" | "name" | "type" | "displayOrder"
|
||||
>;
|
||||
|
||||
export class SsoProviderRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listEnabledPublic(): Promise<PublicSsoProviderRecord[]> {
|
||||
return this.context.drizzle
|
||||
.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));
|
||||
}
|
||||
|
||||
async listEnabled(): Promise<SsoProviderRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(ssoProviders)
|
||||
.where(eq(ssoProviders.enabled, true))
|
||||
.orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id));
|
||||
}
|
||||
|
||||
async listAll(): Promise<SsoProviderRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(ssoProviders)
|
||||
.orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id));
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<SsoProviderRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(ssoProviders)
|
||||
.where(eq(ssoProviders.id, id))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findFirstEnabledOidcLike(): Promise<SsoProviderRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(ssoProviders)
|
||||
.where(eq(ssoProviders.enabled, true))
|
||||
.orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id));
|
||||
|
||||
return (
|
||||
rows.find(
|
||||
(row) =>
|
||||
row.type === "oidc" || row.type === "github" || row.type === "google",
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async create(provider: NewSsoProviderRecord): Promise<SsoProviderRecord> {
|
||||
const rows = await this.context.drizzle
|
||||
.insert(ssoProviders)
|
||||
.values(provider)
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async update(
|
||||
id: number,
|
||||
update: SsoProviderUpdate,
|
||||
): Promise<SsoProviderRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.update(ssoProviders)
|
||||
.set(update)
|
||||
.where(eq(ssoProviders.id, id))
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async delete(id: number): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(ssoProviders)
|
||||
.where(eq(ssoProviders.id, id))
|
||||
.returning({ id: ssoProviders.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async countUsersByProviderId(providerId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.ssoProviderId, providerId));
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { termixIdentityCa } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
|
||||
export type TermixIdentityCaRecord = typeof termixIdentityCa.$inferSelect;
|
||||
export type NewTermixIdentityCaRecord = typeof termixIdentityCa.$inferInsert;
|
||||
export type TermixIdentityCaUpdate = Partial<
|
||||
Pick<
|
||||
NewTermixIdentityCaRecord,
|
||||
"publicKey" | "privateKey" | "validityDays" | "updatedAt"
|
||||
>
|
||||
>;
|
||||
|
||||
export class TermixIdentityCaRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async findPublicByIdentityId(
|
||||
identityId: number,
|
||||
): Promise<Pick<
|
||||
TermixIdentityCaRecord,
|
||||
"publicKey" | "validityDays"
|
||||
> | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
publicKey: termixIdentityCa.publicKey,
|
||||
validityDays: termixIdentityCa.validityDays,
|
||||
})
|
||||
.from(termixIdentityCa)
|
||||
.where(eq(termixIdentityCa.identityId, identityId))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findDecryptedByIdentityId(
|
||||
userId: string,
|
||||
identityId: number,
|
||||
): Promise<TermixIdentityCaRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(termixIdentityCa)
|
||||
.where(eq(termixIdentityCa.identityId, identityId))
|
||||
.limit(1);
|
||||
|
||||
return this.decryptOne(rows[0] ?? null, userId);
|
||||
}
|
||||
|
||||
async createEncryptedForUser(
|
||||
userId: string,
|
||||
ca: NewTermixIdentityCaRecord,
|
||||
): Promise<TermixIdentityCaRecord> {
|
||||
const userDataKey = DataCrypto.validateUserAccess(userId);
|
||||
const result = this.context.drizzle.transaction((tx) => {
|
||||
const inserted = tx
|
||||
.insert(termixIdentityCa)
|
||||
.values({ ...ca, privateKey: "" })
|
||||
.returning()
|
||||
.all();
|
||||
const row = inserted[0];
|
||||
const encrypted = DataCrypto.encryptRecord(
|
||||
"termix_identity_ca",
|
||||
{ id: row.id, privateKey: ca.privateKey },
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
return tx
|
||||
.update(termixIdentityCa)
|
||||
.set({ privateKey: encrypted.privateKey })
|
||||
.where(eq(termixIdentityCa.id, row.id))
|
||||
.returning()
|
||||
.all()[0];
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
return DataCrypto.decryptRecord(
|
||||
"termix_identity_ca",
|
||||
result,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
}
|
||||
|
||||
async updateEncryptedForIdentity(
|
||||
userId: string,
|
||||
identityId: number,
|
||||
update: TermixIdentityCaUpdate,
|
||||
): Promise<TermixIdentityCaRecord | null> {
|
||||
const existing = await this.findDecryptedByIdentityId(userId, identityId);
|
||||
if (!existing) return null;
|
||||
|
||||
const userDataKey = DataCrypto.validateUserAccess(userId);
|
||||
const encryptedPrivateKey = update.privateKey
|
||||
? DataCrypto.encryptRecord(
|
||||
"termix_identity_ca",
|
||||
{ id: existing.id, privateKey: update.privateKey },
|
||||
userId,
|
||||
userDataKey,
|
||||
).privateKey
|
||||
: undefined;
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.update(termixIdentityCa)
|
||||
.set({
|
||||
...update,
|
||||
...(encryptedPrivateKey ? { privateKey: encryptedPrivateKey } : {}),
|
||||
})
|
||||
.where(eq(termixIdentityCa.identityId, identityId))
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return this.decryptOne(rows[0] ?? null, userId);
|
||||
}
|
||||
|
||||
async deleteByIdentityId(identityId: number): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(termixIdentityCa)
|
||||
.where(eq(termixIdentityCa.identityId, identityId))
|
||||
.returning({ id: termixIdentityCa.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(termixIdentityCa)
|
||||
.where(eq(termixIdentityCa.userId, userId))
|
||||
.returning({ id: termixIdentityCa.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private decryptOne<T extends Record<string, unknown>>(
|
||||
record: T | null,
|
||||
userId: string,
|
||||
): T | null {
|
||||
if (!record) return null;
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) return null;
|
||||
return DataCrypto.decryptRecord(
|
||||
"termix_identity_ca",
|
||||
record,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { termixIdentities, termixIdentityKeys } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type TermixIdentityRecord = typeof termixIdentities.$inferSelect;
|
||||
export type NewTermixIdentityRecord = typeof termixIdentities.$inferInsert;
|
||||
export type TermixIdentityUpdate = Partial<
|
||||
Pick<NewTermixIdentityRecord, "handle" | "description" | "updatedAt">
|
||||
>;
|
||||
|
||||
export type TermixIdentityKeyRecord = typeof termixIdentityKeys.$inferSelect;
|
||||
export type NewTermixIdentityKeyRecord = typeof termixIdentityKeys.$inferInsert;
|
||||
export type TermixIdentityKeyUpdate = Partial<
|
||||
Pick<NewTermixIdentityKeyRecord, "enabled" | "label">
|
||||
>;
|
||||
|
||||
export class TermixIdentityRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async findIdentityForUser(
|
||||
userId: string,
|
||||
): Promise<TermixIdentityRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(termixIdentities)
|
||||
.where(eq(termixIdentities.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findIdentityByHandle(
|
||||
handle: string,
|
||||
): Promise<TermixIdentityRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(termixIdentities)
|
||||
.where(eq(termixIdentities.handle, handle))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async isHandleTaken(handle: string): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: termixIdentities.id })
|
||||
.from(termixIdentities)
|
||||
.where(eq(termixIdentities.handle, handle))
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async createIdentity(
|
||||
identity: NewTermixIdentityRecord,
|
||||
): Promise<TermixIdentityRecord> {
|
||||
const rows = await this.context.drizzle
|
||||
.insert(termixIdentities)
|
||||
.values(identity)
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async updateIdentityForUser(
|
||||
userId: string,
|
||||
update: TermixIdentityUpdate,
|
||||
): Promise<TermixIdentityRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.update(termixIdentities)
|
||||
.set(update)
|
||||
.where(eq(termixIdentities.userId, userId))
|
||||
.returning();
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async deleteIdentityForUser(userId: string): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(termixIdentities)
|
||||
.where(eq(termixIdentities.userId, userId))
|
||||
.returning({ id: termixIdentities.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<{
|
||||
identitiesDeleted: number;
|
||||
keysDeleted: number;
|
||||
}> {
|
||||
const keyRows = await this.context.drizzle
|
||||
.delete(termixIdentityKeys)
|
||||
.where(eq(termixIdentityKeys.userId, userId))
|
||||
.returning({ id: termixIdentityKeys.id });
|
||||
|
||||
const identityRows = await this.context.drizzle
|
||||
.delete(termixIdentities)
|
||||
.where(eq(termixIdentities.userId, userId))
|
||||
.returning({ id: termixIdentities.id });
|
||||
|
||||
if (keyRows.length > 0 || identityRows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return {
|
||||
identitiesDeleted: identityRows.length,
|
||||
keysDeleted: keyRows.length,
|
||||
};
|
||||
}
|
||||
|
||||
async listKeysByIdentityId(
|
||||
identityId: number,
|
||||
): Promise<TermixIdentityKeyRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(termixIdentityKeys)
|
||||
.where(eq(termixIdentityKeys.identityId, identityId))
|
||||
.orderBy(asc(termixIdentityKeys.id));
|
||||
}
|
||||
|
||||
async listEnabledKeysByIdentityId(
|
||||
identityId: number,
|
||||
): Promise<TermixIdentityKeyRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(termixIdentityKeys)
|
||||
.where(
|
||||
and(
|
||||
eq(termixIdentityKeys.identityId, identityId),
|
||||
eq(termixIdentityKeys.enabled, true),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(termixIdentityKeys.id));
|
||||
}
|
||||
|
||||
async listLinkedCredentialIds(identityId: number): Promise<number[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ credentialId: termixIdentityKeys.credentialId })
|
||||
.from(termixIdentityKeys)
|
||||
.where(
|
||||
and(
|
||||
eq(termixIdentityKeys.identityId, identityId),
|
||||
eq(termixIdentityKeys.enabled, true),
|
||||
),
|
||||
);
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
rows
|
||||
.map((row) => row.credentialId)
|
||||
.filter(
|
||||
(credentialId): credentialId is number => credentialId !== null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async createKey(
|
||||
key: NewTermixIdentityKeyRecord,
|
||||
): Promise<TermixIdentityKeyRecord> {
|
||||
const rows = await this.context.drizzle
|
||||
.insert(termixIdentityKeys)
|
||||
.values(key)
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async updateKeyForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
update: TermixIdentityKeyUpdate,
|
||||
): Promise<TermixIdentityKeyRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.update(termixIdentityKeys)
|
||||
.set(update)
|
||||
.where(
|
||||
and(
|
||||
eq(termixIdentityKeys.id, id),
|
||||
eq(termixIdentityKeys.userId, userId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async deleteKeyForUser(userId: string, id: number): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(termixIdentityKeys)
|
||||
.where(
|
||||
and(
|
||||
eq(termixIdentityKeys.id, id),
|
||||
eq(termixIdentityKeys.userId, userId),
|
||||
),
|
||||
)
|
||||
.returning({ id: termixIdentityKeys.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async findKeyForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<TermixIdentityKeyRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(termixIdentityKeys)
|
||||
.where(
|
||||
and(
|
||||
eq(termixIdentityKeys.id, id),
|
||||
eq(termixIdentityKeys.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { tmuxSessionTags } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type TmuxSessionTagRecord = typeof tmuxSessionTags.$inferSelect;
|
||||
|
||||
export interface TmuxSessionTagInput {
|
||||
userId: string;
|
||||
hostId: number;
|
||||
sessionName: string;
|
||||
tag: string;
|
||||
}
|
||||
|
||||
export class TmuxSessionTagRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByUserAndHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<Map<string, string[]>> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(tmuxSessionTags)
|
||||
.where(
|
||||
and(
|
||||
eq(tmuxSessionTags.userId, userId),
|
||||
eq(tmuxSessionTags.hostId, hostId),
|
||||
),
|
||||
);
|
||||
|
||||
const bySession = new Map<string, string[]>();
|
||||
for (const row of rows) {
|
||||
const tags = bySession.get(row.sessionName) ?? [];
|
||||
tags.push(row.tag);
|
||||
bySession.set(row.sessionName, tags);
|
||||
}
|
||||
return bySession;
|
||||
}
|
||||
|
||||
async renameSessionForHost(
|
||||
hostId: number,
|
||||
sessionName: string,
|
||||
newSessionName: string,
|
||||
): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.update(tmuxSessionTags)
|
||||
.set({ sessionName: newSessionName })
|
||||
.where(
|
||||
and(
|
||||
eq(tmuxSessionTags.hostId, hostId),
|
||||
eq(tmuxSessionTags.sessionName, sessionName),
|
||||
),
|
||||
)
|
||||
.returning({ id: tmuxSessionTags.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteSessionForHost(
|
||||
hostId: number,
|
||||
sessionName: string,
|
||||
): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(tmuxSessionTags)
|
||||
.where(
|
||||
and(
|
||||
eq(tmuxSessionTags.hostId, hostId),
|
||||
eq(tmuxSessionTags.sessionName, sessionName),
|
||||
),
|
||||
)
|
||||
.returning({ id: tmuxSessionTags.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async replaceForUserHostSession(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
sessionName: string,
|
||||
tags: string[],
|
||||
): Promise<number> {
|
||||
const deletedRows = await this.context.drizzle
|
||||
.delete(tmuxSessionTags)
|
||||
.where(
|
||||
and(
|
||||
eq(tmuxSessionTags.userId, userId),
|
||||
eq(tmuxSessionTags.hostId, hostId),
|
||||
eq(tmuxSessionTags.sessionName, sessionName),
|
||||
),
|
||||
)
|
||||
.returning({ id: tmuxSessionTags.id });
|
||||
|
||||
if (tags.length > 0) {
|
||||
await this.context.drizzle.insert(tmuxSessionTags).values(
|
||||
tags.map((tag) => ({
|
||||
userId,
|
||||
hostId,
|
||||
sessionName,
|
||||
tag,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
const changedRows = deletedRows.length + tags.length;
|
||||
if (changedRows > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return changedRows;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(tmuxSessionTags)
|
||||
.where(eq(tmuxSessionTags.userId, userId))
|
||||
.returning({ id: tmuxSessionTags.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { and, desc, eq, inArray, or } from "drizzle-orm";
|
||||
import { transferRecent } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type TransferRecentRecord = typeof transferRecent.$inferSelect;
|
||||
|
||||
export interface TransferRecentDestinationInput {
|
||||
sourceHostId: number;
|
||||
destHostId: number;
|
||||
destPath: string;
|
||||
destPathLabel?: string | null;
|
||||
}
|
||||
|
||||
export class TransferRecentRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByUserId(userId: string): Promise<TransferRecentRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(transferRecent)
|
||||
.where(eq(transferRecent.userId, userId));
|
||||
}
|
||||
|
||||
async listBySourceHost(
|
||||
userId: string,
|
||||
sourceHostId: number,
|
||||
limit = 10,
|
||||
): Promise<TransferRecentRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(transferRecent)
|
||||
.where(
|
||||
and(
|
||||
eq(transferRecent.userId, userId),
|
||||
eq(transferRecent.sourceHostId, sourceHostId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(transferRecent.lastUsed))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
async upsertForDestination(
|
||||
userId: string,
|
||||
input: TransferRecentDestinationInput,
|
||||
lastUsed = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
const [existing] = await this.context.drizzle
|
||||
.select({ id: transferRecent.id })
|
||||
.from(transferRecent)
|
||||
.where(
|
||||
and(
|
||||
eq(transferRecent.userId, userId),
|
||||
eq(transferRecent.sourceHostId, input.sourceHostId),
|
||||
eq(transferRecent.destHostId, input.destHostId),
|
||||
eq(transferRecent.destPath, input.destPath),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
await this.context.drizzle
|
||||
.update(transferRecent)
|
||||
.set({ lastUsed })
|
||||
.where(eq(transferRecent.id, existing.id));
|
||||
} else {
|
||||
await this.context.drizzle.insert(transferRecent).values({
|
||||
userId,
|
||||
sourceHostId: input.sourceHostId,
|
||||
destHostId: input.destHostId,
|
||||
destPath: input.destPath,
|
||||
destPathLabel: input.destPathLabel || input.destPath,
|
||||
lastUsed,
|
||||
});
|
||||
}
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async pruneSourceHost(
|
||||
userId: string,
|
||||
sourceHostId: number,
|
||||
keep = 10,
|
||||
): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: transferRecent.id })
|
||||
.from(transferRecent)
|
||||
.where(
|
||||
and(
|
||||
eq(transferRecent.userId, userId),
|
||||
eq(transferRecent.sourceHostId, sourceHostId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(transferRecent.lastUsed));
|
||||
|
||||
const idsToDelete = rows.slice(keep).map((row) => row.id);
|
||||
if (idsToDelete.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const deleted = await this.context.drizzle
|
||||
.delete(transferRecent)
|
||||
.where(inArray(transferRecent.id, idsToDelete))
|
||||
.returning({ id: transferRecent.id });
|
||||
|
||||
if (deleted.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return deleted.length;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(transferRecent)
|
||||
.where(eq(transferRecent.userId, userId))
|
||||
.returning({ id: transferRecent.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteByHostId(hostId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(transferRecent)
|
||||
.where(
|
||||
or(
|
||||
eq(transferRecent.sourceHostId, hostId),
|
||||
eq(transferRecent.destHostId, hostId),
|
||||
),
|
||||
)
|
||||
.returning({ id: transferRecent.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteByHostIds(hostIds: number[]): Promise<number> {
|
||||
if (hostIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.delete(transferRecent)
|
||||
.where(
|
||||
or(
|
||||
inArray(transferRecent.sourceHostId, hostIds),
|
||||
inArray(transferRecent.destHostId, hostIds),
|
||||
),
|
||||
)
|
||||
.returning({ id: transferRecent.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { trustedDevices } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type TrustedDeviceRecord = typeof trustedDevices.$inferSelect;
|
||||
export type NewTrustedDeviceRecord = typeof trustedDevices.$inferInsert;
|
||||
|
||||
export class TrustedDeviceRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async findByUserAndFingerprint(
|
||||
userId: string,
|
||||
deviceFingerprint: string,
|
||||
): Promise<TrustedDeviceRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(trustedDevices)
|
||||
.where(
|
||||
and(
|
||||
eq(trustedDevices.userId, userId),
|
||||
eq(trustedDevices.deviceFingerprint, deviceFingerprint),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async touch(
|
||||
userId: string,
|
||||
deviceFingerprint: string,
|
||||
lastUsedAt = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(trustedDevices)
|
||||
.set({ lastUsedAt })
|
||||
.where(
|
||||
and(
|
||||
eq(trustedDevices.userId, userId),
|
||||
eq(trustedDevices.deviceFingerprint, deviceFingerprint),
|
||||
),
|
||||
);
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async upsert(device: NewTrustedDeviceRecord): Promise<void> {
|
||||
const existing = await this.findByUserAndFingerprint(
|
||||
device.userId,
|
||||
device.deviceFingerprint,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await this.context.drizzle
|
||||
.update(trustedDevices)
|
||||
.set({
|
||||
expiresAt: device.expiresAt,
|
||||
lastUsedAt: device.lastUsedAt,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(trustedDevices.userId, device.userId),
|
||||
eq(trustedDevices.deviceFingerprint, device.deviceFingerprint),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await this.context.drizzle.insert(trustedDevices).values(device);
|
||||
}
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteByUserAndFingerprint(
|
||||
userId: string,
|
||||
deviceFingerprint: string,
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(trustedDevices)
|
||||
.where(
|
||||
and(
|
||||
eq(trustedDevices.userId, userId),
|
||||
eq(trustedDevices.deviceFingerprint, deviceFingerprint),
|
||||
),
|
||||
);
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(trustedDevices)
|
||||
.where(eq(trustedDevices.userId, userId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { hosts, sshCredentials } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type UserDataExportHostRecord = typeof hosts.$inferSelect;
|
||||
export type UserDataExportCredentialRecord = typeof sshCredentials.$inferSelect;
|
||||
|
||||
export class UserDataExportRepository {
|
||||
constructor(private readonly context: DatabaseContext) {}
|
||||
|
||||
async listHostsByUserId(userId: string): Promise<UserDataExportHostRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(eq(hosts.userId, userId));
|
||||
}
|
||||
|
||||
async listCredentialsByUserId(
|
||||
userId: string,
|
||||
): Promise<UserDataExportCredentialRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.userId, userId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { userPreferences } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type UserPreferenceRecord = typeof userPreferences.$inferSelect;
|
||||
export type NewUserPreferenceRecord = typeof userPreferences.$inferInsert;
|
||||
export type UserPreferenceUpdate = Partial<
|
||||
Omit<NewUserPreferenceRecord, "userId">
|
||||
>;
|
||||
|
||||
export class UserPreferenceRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async findByUserId(userId: string): Promise<UserPreferenceRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(userPreferences)
|
||||
.where(eq(userPreferences.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async upsert(
|
||||
userId: string,
|
||||
update: UserPreferenceUpdate,
|
||||
): Promise<UserPreferenceRecord> {
|
||||
const existing = await this.findByUserId(userId);
|
||||
|
||||
if (!existing) {
|
||||
const rows = await this.context.drizzle
|
||||
.insert(userPreferences)
|
||||
.values({ userId, ...update })
|
||||
.returning();
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.update(userPreferences)
|
||||
.set(update)
|
||||
.where(eq(userPreferences.userId, userId))
|
||||
.returning();
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(userPreferences)
|
||||
.where(eq(userPreferences.userId, userId))
|
||||
.returning({ userId: userPreferences.userId });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { users } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type UserRecord = typeof users.$inferSelect;
|
||||
export type NewUserRecord = typeof users.$inferInsert;
|
||||
export type UserUpdate = Partial<Omit<NewUserRecord, "id">>;
|
||||
export type NewFirstLocalUserRecord = Omit<NewUserRecord, "isAdmin">;
|
||||
|
||||
export class UserRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listAll(): Promise<UserRecord[]> {
|
||||
return this.context.drizzle.select().from(users);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<UserRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, id))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findByUsername(username: string): Promise<UserRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, username))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findByOidcIdentifier(
|
||||
oidcIdentifier: string,
|
||||
): Promise<UserRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.oidcIdentifier, oidcIdentifier))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async listByIds(ids: string[]): Promise<UserRecord[]> {
|
||||
const uniqueIds = Array.from(new Set(ids.filter(Boolean)));
|
||||
if (uniqueIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(users)
|
||||
.where(inArray(users.id, uniqueIds));
|
||||
}
|
||||
|
||||
async create(user: NewUserRecord): Promise<UserRecord> {
|
||||
const rows = await this.context.drizzle
|
||||
.insert(users)
|
||||
.values(user)
|
||||
.returning();
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async createFirstLocalUser(
|
||||
user: NewFirstLocalUserRecord,
|
||||
): Promise<{ user: UserRecord; isFirstUser: boolean }> {
|
||||
const result = this.context.drizzle.transaction((tx) => {
|
||||
const existingUsers = tx.select({ id: users.id }).from(users).all();
|
||||
const isFirstUser = existingUsers.length === 0;
|
||||
const rows = tx
|
||||
.insert(users)
|
||||
.values({ ...user, isAdmin: isFirstUser })
|
||||
.returning()
|
||||
.all();
|
||||
|
||||
return { user: rows[0], isFirstUser };
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
return result;
|
||||
}
|
||||
|
||||
async createFirstSsoUser(
|
||||
user: NewUserRecord,
|
||||
): Promise<{ user: UserRecord; isFirstUser: boolean }> {
|
||||
const result = this.context.drizzle.transaction((tx) => {
|
||||
const existingUsers = tx.select({ id: users.id }).from(users).all();
|
||||
const isFirstUser = existingUsers.length === 0;
|
||||
const rows = tx
|
||||
.insert(users)
|
||||
.values({ ...user, isAdmin: isFirstUser || Boolean(user.isAdmin) })
|
||||
.returning()
|
||||
.all();
|
||||
|
||||
return { user: rows[0], isFirstUser };
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
return result;
|
||||
}
|
||||
|
||||
async update(id: string, update: UserUpdate): Promise<UserRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.update(users)
|
||||
.set(update)
|
||||
.where(eq(users.id, id))
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(users)
|
||||
.where(eq(users.id, id))
|
||||
.returning({ id: users.id });
|
||||
|
||||
await this.afterWrite();
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async countAdmins(): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.isAdmin, true));
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async countTotpEnabled(): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.totpEnabled, true));
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async countAll(): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: users.id })
|
||||
.from(users);
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { desc, eq, or } from "drizzle-orm";
|
||||
import { vaultProfiles } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type VaultProfileRecord = typeof vaultProfiles.$inferSelect;
|
||||
|
||||
export interface VaultProfileCreateInput {
|
||||
userId: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
folder?: string | null;
|
||||
tags?: string | null;
|
||||
vaultAddr: string;
|
||||
vaultNamespace?: string | null;
|
||||
oidcMount?: string | null;
|
||||
oidcRole?: string | null;
|
||||
sshMount?: string | null;
|
||||
sshRole: string;
|
||||
validPrincipals?: string | null;
|
||||
keyType?: string | null;
|
||||
shared?: boolean;
|
||||
}
|
||||
|
||||
export type VaultProfileUpdateInput = Partial<
|
||||
Omit<VaultProfileCreateInput, "userId">
|
||||
> & {
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export class VaultProfileRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listVisibleToUser(userId: string): Promise<VaultProfileRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(vaultProfiles)
|
||||
.where(
|
||||
or(eq(vaultProfiles.userId, userId), eq(vaultProfiles.shared, true)),
|
||||
)
|
||||
.orderBy(desc(vaultProfiles.updatedAt));
|
||||
}
|
||||
|
||||
async create(input: VaultProfileCreateInput): Promise<VaultProfileRecord> {
|
||||
const [created] = await this.context.drizzle
|
||||
.insert(vaultProfiles)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
folder: input.folder,
|
||||
tags: input.tags,
|
||||
vaultAddr: input.vaultAddr,
|
||||
vaultNamespace: input.vaultNamespace,
|
||||
oidcMount: input.oidcMount,
|
||||
oidcRole: input.oidcRole,
|
||||
sshMount: input.sshMount,
|
||||
sshRole: input.sshRole,
|
||||
validPrincipals: input.validPrincipals,
|
||||
keyType: input.keyType,
|
||||
shared: input.shared ?? false,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<VaultProfileRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(vaultProfiles)
|
||||
.where(eq(vaultProfiles.id, id))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async updateById(
|
||||
id: number,
|
||||
input: VaultProfileUpdateInput,
|
||||
): Promise<VaultProfileRecord | null> {
|
||||
const [updated] = await this.context.drizzle
|
||||
.update(vaultProfiles)
|
||||
.set({
|
||||
...input,
|
||||
updatedAt: input.updatedAt ?? new Date().toISOString(),
|
||||
})
|
||||
.where(eq(vaultProfiles.id, id))
|
||||
.returning();
|
||||
|
||||
if (updated) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async deleteById(id: number): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(vaultProfiles)
|
||||
.where(eq(vaultProfiles.id, id))
|
||||
.returning({ id: vaultProfiles.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(vaultProfiles)
|
||||
.where(eq(vaultProfiles.userId, userId))
|
||||
.returning({ id: vaultProfiles.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { vaultTokens } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type VaultTokenRecord = typeof vaultTokens.$inferSelect;
|
||||
|
||||
export interface VaultTokenUpsertInput {
|
||||
userId: string;
|
||||
profileId: number;
|
||||
sshCert: string;
|
||||
privateKey: string;
|
||||
expiresAt: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export class VaultTokenRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async upsert(input: VaultTokenUpsertInput): Promise<void> {
|
||||
const createdAt = input.createdAt ?? new Date().toISOString();
|
||||
|
||||
await this.context.drizzle
|
||||
.insert(vaultTokens)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
profileId: input.profileId,
|
||||
sshCert: input.sshCert,
|
||||
privateKey: input.privateKey,
|
||||
expiresAt: input.expiresAt,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [vaultTokens.userId, vaultTokens.profileId],
|
||||
set: {
|
||||
sshCert: input.sshCert,
|
||||
privateKey: input.privateKey,
|
||||
expiresAt: input.expiresAt,
|
||||
createdAt,
|
||||
},
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async findByUserAndProfile(
|
||||
userId: string,
|
||||
profileId: number,
|
||||
): Promise<VaultTokenRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(vaultTokens)
|
||||
.where(
|
||||
and(
|
||||
eq(vaultTokens.userId, userId),
|
||||
eq(vaultTokens.profileId, profileId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async updateLastUsed(
|
||||
userId: string,
|
||||
profileId: number,
|
||||
lastUsed = new Date().toISOString(),
|
||||
): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.update(vaultTokens)
|
||||
.set({ lastUsed })
|
||||
.where(
|
||||
and(
|
||||
eq(vaultTokens.userId, userId),
|
||||
eq(vaultTokens.profileId, profileId),
|
||||
),
|
||||
)
|
||||
.returning({ id: vaultTokens.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteByUserAndProfile(
|
||||
userId: string,
|
||||
profileId: number,
|
||||
): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(vaultTokens)
|
||||
.where(
|
||||
and(
|
||||
eq(vaultTokens.userId, userId),
|
||||
eq(vaultTokens.profileId, profileId),
|
||||
),
|
||||
)
|
||||
.returning({ id: vaultTokens.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(vaultTokens)
|
||||
.where(eq(vaultTokens.userId, userId))
|
||||
.returning({ id: vaultTokens.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { webauthnCredentials } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type WebauthnCredentialRecord = typeof webauthnCredentials.$inferSelect;
|
||||
export type NewWebauthnCredentialRecord =
|
||||
typeof webauthnCredentials.$inferInsert;
|
||||
|
||||
export interface WebauthnCredentialAuthState {
|
||||
counter: number;
|
||||
backedUp: boolean;
|
||||
deviceType: string | null;
|
||||
lastUsedAt: string;
|
||||
}
|
||||
|
||||
export class WebauthnCredentialRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByUserId(userId: string): Promise<WebauthnCredentialRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(webauthnCredentials)
|
||||
.where(eq(webauthnCredentials.userId, userId));
|
||||
}
|
||||
|
||||
async findByCredentialId(
|
||||
credentialId: string,
|
||||
): Promise<WebauthnCredentialRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(webauthnCredentials)
|
||||
.where(eq(webauthnCredentials.credentialId, credentialId))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async create(
|
||||
record: NewWebauthnCredentialRecord,
|
||||
): Promise<WebauthnCredentialRecord> {
|
||||
const rows = await this.context.drizzle
|
||||
.insert(webauthnCredentials)
|
||||
.values(record)
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async updateAuthState(
|
||||
id: string,
|
||||
state: WebauthnCredentialAuthState,
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(webauthnCredentials)
|
||||
.set(state)
|
||||
.where(eq(webauthnCredentials.id, id));
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteForUser(userId: string, id: string): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(webauthnCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(webauthnCredentials.id, id),
|
||||
eq(webauthnCredentials.userId, userId),
|
||||
),
|
||||
)
|
||||
.returning({ id: webauthnCredentials.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+391
-696
File diff suppressed because it is too large.
Load diff
@@ -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) {
|
||||
|
||||
@@ -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
+600
-615
File diff suppressed because it is too large.
Load diff
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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,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" });
|
||||
|
||||
@@ -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,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
@@ -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);
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import GuacamoleLite from "guacamole-lite";
|
||||
import { guacLogger } from "../utils/logger.js";
|
||||
import { GuacamoleTokenService } from "./token-service.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { resolveGuacdOptions } from "../utils/guacd-config.js";
|
||||
|
||||
const tokenService = GuacamoleTokenService.getInstance();
|
||||
|
||||
function readGuacdOptions(): { host: string; port: number } {
|
||||
let dbUrl: string | undefined;
|
||||
try {
|
||||
const db = getDb();
|
||||
const urlRow = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
|
||||
.get() as { value: string } | undefined;
|
||||
dbUrl = urlRow?.value;
|
||||
} catch {
|
||||
// DB not available yet, use env var defaults
|
||||
}
|
||||
return resolveGuacdOptions(dbUrl);
|
||||
}
|
||||
|
||||
const GUAC_WS_PORT = 30008;
|
||||
|
||||
const websocketOptions = {
|
||||
port: GUAC_WS_PORT,
|
||||
};
|
||||
|
||||
const clientOptions = {
|
||||
crypt: {
|
||||
cypher: "AES-256-CBC",
|
||||
key: tokenService.getEncryptionKey(),
|
||||
},
|
||||
log: {
|
||||
level: "ERRORS",
|
||||
stdLog: (...args: unknown[]) => {
|
||||
guacLogger.info(args.join(" "));
|
||||
},
|
||||
errorLog: (...args: unknown[]) => {
|
||||
guacLogger.error(args.join(" "));
|
||||
},
|
||||
},
|
||||
allowedUnencryptedConnectionSettings: {
|
||||
rdp: ["width", "height"],
|
||||
vnc: ["width", "height"],
|
||||
telnet: ["width", "height"],
|
||||
},
|
||||
connectionDefaultSettings: {
|
||||
rdp: {
|
||||
security: "any",
|
||||
"ignore-cert": true,
|
||||
"enable-wallpaper": false,
|
||||
"enable-font-smoothing": true,
|
||||
"enable-desktop-composition": false,
|
||||
"disable-audio": false,
|
||||
"enable-drive": false,
|
||||
"resize-method": "display-update",
|
||||
width: 1280,
|
||||
height: 720,
|
||||
dpi: 96,
|
||||
audio: ["audio/L16"],
|
||||
},
|
||||
vnc: {
|
||||
"swap-red-blue": false,
|
||||
cursor: "remote",
|
||||
security: "any",
|
||||
width: 1280,
|
||||
height: 720,
|
||||
},
|
||||
telnet: {
|
||||
"terminal-type": "xterm-256color",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const _origConsoleLog = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
const msg = args[0];
|
||||
if (typeof msg === "string" && msg.startsWith("New client connection"))
|
||||
return;
|
||||
_origConsoleLog(...args);
|
||||
};
|
||||
|
||||
function createGuacServer(): GuacamoleLite {
|
||||
const guacdOptions = readGuacdOptions();
|
||||
const server = new GuacamoleLite(
|
||||
websocketOptions,
|
||||
guacdOptions,
|
||||
clientOptions,
|
||||
);
|
||||
|
||||
server.on(
|
||||
"open",
|
||||
(clientConnection: { connectionSettings?: Record<string, unknown> }) => {
|
||||
guacLogger.info("Guacamole connection opened", {
|
||||
operation: "guac_connection_open",
|
||||
type: clientConnection.connectionSettings?.type,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
server.on(
|
||||
"close",
|
||||
(clientConnection: { connectionSettings?: Record<string, unknown> }) => {
|
||||
guacLogger.info("Guacamole connection closed", {
|
||||
operation: "guac_connection_close",
|
||||
type: clientConnection.connectionSettings?.type,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
server.on(
|
||||
"error",
|
||||
(
|
||||
clientConnection: { connectionSettings?: Record<string, unknown> },
|
||||
error: Error,
|
||||
) => {
|
||||
guacLogger.error("Guacamole connection error", error, {
|
||||
operation: "guac_connection_error",
|
||||
type: clientConnection.connectionSettings?.type,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
let guacServer = createGuacServer();
|
||||
|
||||
export async function restartGuacServer(): Promise<void> {
|
||||
try {
|
||||
guacServer.close();
|
||||
} catch (err) {
|
||||
guacLogger.error("Error closing guac server during restart", err as Error);
|
||||
}
|
||||
guacServer = createGuacServer();
|
||||
}
|
||||
|
||||
export { guacServer, tokenService };
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { WebSocket } from "ws";
|
||||
import { sshLogger, authLogger } from "../utils/logger.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { sshCredentials } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { createCurrentHostResolutionRepository } from "../database/repositories/factory.js";
|
||||
interface ResolvedCredentials {
|
||||
username: string;
|
||||
password?: string;
|
||||
@@ -59,22 +56,13 @@ export class SSHAuthManager {
|
||||
};
|
||||
|
||||
if (hostConfig.credentialId) {
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, hostConfig.credentialId),
|
||||
eq(sshCredentials.userId, this.context.userId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
this.context.userId,
|
||||
);
|
||||
const cred =
|
||||
await createCurrentHostResolutionRepository().findCredentialByIdForUser(
|
||||
hostConfig.credentialId,
|
||||
this.context.userId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const cred = credentials[0];
|
||||
if (cred) {
|
||||
resolvedCredentials = {
|
||||
username: (cred.username as string) || hostConfig.username,
|
||||
password: (cred.password as string) || undefined,
|
||||
File renamed without changes.
@@ -21,6 +21,19 @@ export function pickResolvedUsername(
|
||||
return cred;
|
||||
}
|
||||
|
||||
/**
|
||||
* A host can keep a per-host password while using a shared key credential.
|
||||
* Prefer that host-specific value and fall back to the credential password.
|
||||
*/
|
||||
export function pickResolvedPassword(
|
||||
hostPassword: unknown,
|
||||
credentialPassword: unknown,
|
||||
): string | undefined {
|
||||
if (isNonEmptyString(hostPassword)) return hostPassword;
|
||||
if (isNonEmptyString(credentialPassword)) return credentialPassword;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands the `$oidc.preferred_username` placeholder in an SSH username to the
|
||||
* connecting user's OIDC identifier. Returns the username unchanged if it does
|
||||
@@ -35,18 +48,10 @@ export async function expandOidcUsername(
|
||||
}
|
||||
|
||||
try {
|
||||
const { getDb } = await import("../database/db/index.js");
|
||||
const { users } = await import("../database/db/schema.js");
|
||||
const { eq } = await import("drizzle-orm");
|
||||
|
||||
const db = getDb();
|
||||
const rows = await db
|
||||
.select({ oidcIdentifier: users.oidcIdentifier })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
const oidcIdentifier = rows[0]?.oidcIdentifier;
|
||||
const { createCurrentUserRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const user = await createCurrentUserRepository().findById(userId);
|
||||
const oidcIdentifier = user?.oidcIdentifier;
|
||||
if (!oidcIdentifier) return username;
|
||||
|
||||
return username.replace(/\$oidc\.preferred_username/g, oidcIdentifier);
|
||||
@@ -1,19 +1,17 @@
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||||
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { systemLogger } from "../utils/logger.js";
|
||||
import type { SSHHost } from "../../types/index.js";
|
||||
import { applyAgentAuth } from "./terminal-auth-helpers.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { createCurrentHostResolutionRepository } from "../../database/repositories/factory.js";
|
||||
import { systemLogger } from "../../utils/logger.js";
|
||||
import type { SSHHost } from "../../../types/index.js";
|
||||
import { applyAgentAuth } from "../terminal-auth-helpers.js";
|
||||
import {
|
||||
containerCommand,
|
||||
getContainerRuntimeConfig,
|
||||
type ContainerRuntime,
|
||||
} from "./container-runtime.js";
|
||||
import { resolveSshConnectConfigHost } from "../ssh-dns.js";
|
||||
|
||||
const sshLogger = systemLogger;
|
||||
|
||||
@@ -91,24 +89,17 @@ async function createJumpHostChain(
|
||||
}
|
||||
|
||||
let currentClient: SSHClient | null = null;
|
||||
const repository = createCurrentHostResolutionRepository();
|
||||
|
||||
for (let i = 0; i < jumpHosts.length; i++) {
|
||||
const jumpHostId = jumpHosts[i].hostId;
|
||||
|
||||
const jumpHostData = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.id, jumpHostId), eq(hosts.userId, userId))),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (jumpHostData.length === 0) {
|
||||
const resolvedJumpHost = await repository.findHostById(jumpHostId, userId);
|
||||
if (!resolvedJumpHost || resolvedJumpHost.userId !== userId) {
|
||||
throw new Error(`Jump host ${jumpHostId} not found`);
|
||||
}
|
||||
|
||||
const jumpHost = jumpHostData[0] as unknown as SSHHost;
|
||||
const jumpHost = resolvedJumpHost as unknown as SSHHost;
|
||||
if (typeof jumpHost.jumpHosts === "string" && jumpHost.jumpHosts) {
|
||||
try {
|
||||
jumpHost.jumpHosts = JSON.parse(jumpHost.jumpHosts);
|
||||
@@ -133,22 +124,12 @@ async function createJumpHostChain(
|
||||
};
|
||||
|
||||
if (jumpHost.credentialId) {
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, jumpHost.credentialId as number),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
const credential = await repository.findCredentialByIdForUser(
|
||||
jumpHost.credentialId as number,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
if (credential) {
|
||||
resolvedCredentials = {
|
||||
password: credential.password as string | undefined,
|
||||
sshKey: (credential.key || credential.privateKey) as
|
||||
@@ -276,6 +257,10 @@ async function createJumpHostChain(
|
||||
});
|
||||
}
|
||||
|
||||
if (!config.sock) {
|
||||
await resolveSshConnectConfigHost(config);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.on("ready", () => resolve());
|
||||
client.on("error", reject);
|
||||
@@ -398,7 +383,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
|
||||
try {
|
||||
// Resolve host with credentials server-side
|
||||
const { resolveHostById } = await import("./host-resolver.js");
|
||||
const { resolveHostById } = await import("../host-resolver.js");
|
||||
const resolvedHost = await resolveHostById(hostId, userId);
|
||||
|
||||
if (!resolvedHost) {
|
||||
@@ -489,6 +474,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.sock) {
|
||||
await resolveSshConnectConfigHost(config);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.on("ready", () => resolve());
|
||||
client.on("error", reject);
|
||||
Loaded 100 of 562 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user