mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
release-2.9.0 (#1532)
* fix(macos): skip single-instance lock in Mac App Store builds (#1454)
fix(macos): skip single-instance lock in Mac App Store builds
* Fix/backend optional sharp (#1455)
Fix/backend optional sharp
* feat: issue #1218 (#1218)
https://github.com/Termix-SSH/Support/issues/1218
* feat: Add option to use saved global custom theme under Connection Defaults (#1209)
https://github.com/Termix-SSH/Support/issues/1209
* feat: Add Fleet Sharing functionality to the UI (#1228)
https://github.com/Termix-SSH/Support/issues/1228
* feat: Add a list view to the Docker management page (#1237)
https://github.com/Termix-SSH/Support/issues/1237
* feat: issue #1264 (#1264)
https://github.com/Termix-SSH/Support/issues/1264
* feat: 2FA With FortiToken (#1288)
https://github.com/Termix-SSH/Support/issues/1288
* feat: Allow to disable showing paths to folders (#1274)
https://github.com/Termix-SSH/Support/issues/1274
* feat: Sync Network Graph between Desktop and Remote Server (#1245)
https://github.com/Termix-SSH/Support/issues/1245
* feat: Version Number on offline servers (#1291)
https://github.com/Termix-SSH/Support/issues/1291
* feat: Support OrbStack Docker socket path on macOS / Fix "Docker is not installed" on mac... (#1302)
https://github.com/Termix-SSH/Support/issues/1302
* feat: Sidebar host click should focus existing tab instead of opening a new connection (#1289)
https://github.com/Termix-SSH/Support/issues/1289
* fix: Search in side bar shows overlapping hosts when grouped by tags (#1303)
https://github.com/Termix-SSH/Support/issues/1303
* fix: I can't send a file on its own. (#1304)
https://github.com/Termix-SSH/Support/issues/1304
* fix: sudo password not autofilling (#1248)
https://github.com/Termix-SSH/Support/issues/1248
* fix: node-MainThread (#1300)
https://github.com/Termix-SSH/Support/issues/1300
* fix: Tunnel authentication with shared credentials (#1295)
https://github.com/Termix-SSH/Support/issues/1295
* fix: host key updates not syncing, metrics 404 race on first connect
Host key writes never bumped updatedAt so sync never picked them up.
Also retry the first metrics fetch briefly instead of failing right away.
* chore: increment ver
* feat: add category and icon fields to plugin manifest schema
* ci(deps): bump the github-actions group with 2 updates (#1458)
Bumps the github-actions group with 2 updates: [crowdin/github-action](https://github.com/crowdin/github-action) and [actions/github-script](https://github.com/actions/github-script).
Updates `crowdin/github-action` from 2 to 3
- [Release notes](https://github.com/crowdin/github-action/releases)
- [Commits](https://github.com/crowdin/github-action/compare/v2...v3)
Updates `actions/github-script` from 7 to 9
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)
---
updated-dependencies:
- dependency-name: crowdin/github-action
dependency-version: '3'
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: github-actions
- dependency-name: actions/github-script
dependency-version: '9'
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 13 updates (#1459)
Bumps the dev-patch-updates group with 13 updates:
| Package | From | To |
| --- | --- | --- |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.11.0` | `6.11.1` |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.1` | `6.7.2` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.9` | `6.43.12` |
| [@testing-library/dom](https://github.com/testing-library/dom-testing-library) | `10.4.1` | `10.4.2` |
| [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.5` | `14.6.7` |
| [@types/ssh2](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ssh2) | `1.15.5` | `1.15.6` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.0` | `6.1.1` |
| [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.34.1` | `3.34.3` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.4` | `0.5.7` |
| [i18next](https://github.com/i18next/i18next) | `26.4.0` | `26.4.2` |
| [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.8` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.12` | `17.0.14` |
Updates `@codemirror/commands` from 6.11.0 to 6.11.1
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/commits)
Updates `@codemirror/search` from 6.7.1 to 6.7.2
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)
Updates `@codemirror/view` from 6.43.9 to 6.43.12
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)
Updates `@testing-library/dom` from 10.4.1 to 10.4.2
- [Release notes](https://github.com/testing-library/dom-testing-library/releases)
- [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/dom-testing-library/compare/v10.4.1...v10.4.2)
Updates `@testing-library/react` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.2...v16.3.3)
Updates `@testing-library/user-event` from 14.6.5 to 14.6.7
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.5...v14.6.7)
Updates `@types/ssh2` from 1.15.5 to 1.15.6
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/ssh2)
Updates `@vitejs/plugin-react` from 6.1.0 to 6.1.1
- [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.1.1/packages/plugin-react)
Updates `cytoscape` from 3.34.1 to 3.34.3
- [Release notes](https://github.com/cytoscape/cytoscape.js/releases)
- [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.34.1...v3.34.3)
Updates `eslint-plugin-react-refresh` from 0.5.4 to 0.5.7
- [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.4...v0.5.7)
Updates `i18next` from 26.4.0 to 26.4.2
- [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.4.0...v26.4.2)
Updates `prettier` from 3.9.6 to 3.9.8
- [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.9.6...3.9.8)
Updates `react-i18next` from 17.0.12 to 17.0.14
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.12...v17.0.14)
---
updated-dependencies:
- dependency-name: "@codemirror/commands"
dependency-version: 6.11.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@codemirror/search"
dependency-version: 6.7.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
dependency-version: 6.43.12
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/dom"
dependency-version: 10.4.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/react"
dependency-version: 16.3.3
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/user-event"
dependency-version: 14.6.7
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@types/ssh2"
dependency-version: 1.15.6
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@vitejs/plugin-react"
dependency-version: 6.1.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: cytoscape
dependency-version: 3.34.3
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.7
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: i18next
dependency-version: 26.4.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: prettier
dependency-version: 3.9.8
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: react-i18next
dependency-version: 17.0.14
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 the prod-patch-updates group with 6 updates (#1461)
Bumps the prod-patch-updates group with 6 updates:
| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.10` | `3.14.13` |
| [compression](https://github.com/expressjs/compression) | `1.8.1` | `1.8.2` |
| [jose](https://github.com/panva/jose) | `6.2.9` | `6.2.12` |
| [jszip](https://github.com/Stuk/jszip) | `3.10.1` | `3.10.2` |
| [socks](https://github.com/JoshGlazebrook/socks) | `2.8.9` | `2.8.10` |
| [undici](https://github.com/nodejs/undici) | `8.10.0` | `8.10.2` |
Updates `@tanstack/react-virtual` from 3.14.10 to 3.14.13
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.13/packages/react-virtual)
Updates `compression` from 1.8.1 to 1.8.2
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/v1.8.1...v1.8.2)
Updates `jose` from 6.2.9 to 6.2.12
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.9...v6.2.12)
Updates `jszip` from 3.10.1 to 3.10.2
- [Changelog](https://github.com/Stuk/jszip/blob/main/CHANGES.md)
- [Commits](https://github.com/Stuk/jszip/compare/v3.10.1...v3.10.2)
Updates `socks` from 2.8.9 to 2.8.10
- [Release notes](https://github.com/JoshGlazebrook/socks/releases)
- [Commits](https://github.com/JoshGlazebrook/socks/compare/2.8.9...2.8.10)
Updates `undici` from 8.10.0 to 8.10.2
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.10.0...v8.10.2)
---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
dependency-version: 3.14.13
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: compression
dependency-version: 1.8.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: jose
dependency-version: 6.2.12
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: jszip
dependency-version: 3.10.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: socks
dependency-version: 2.8.10
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: undici
dependency-version: 8.10.2
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>
* feat: add plugin worker protocol, bootstrap and plugin_storage table
Worker-side ctx proxy plus the message envelope it talks over, and the
per-plugin key/value table behind ctx.storage with migrations for all
three dialects. Rows cascade with the plugin so uninstalling leaves
nothing behind.
* feat: add allowlisted host view for plugins
An allowlist rather than a list of fields to strip. A blocklist rots:
the moment a new secret-bearing column lands on SSHHost it starts
leaking silently. With an allowlist a new field stays invisible to
plugins until someone adds it here on purpose.
* feat: add plugin loader with manifest validation and crash restart
Validates an unpacked plugin, spawns a worker_threads worker and manages
activate/deactivate/crash-restart with backoff, disabling after three
tries.
The crash counter clears only after a plugin stays up past a stability
window, not on activation. Clearing it on activation let a plugin that
activates cleanly and then dies restart forever, because every attempt
looked like the first.
loader.ts documents what the worker boundary does and does not buy you.
Node's permission model is process-wide, not per-worker, so it cannot be
enabled for plugin workers alone and is not claimed here.
* feat: route internal events through a shared plugin event bus
Formalises three ad-hoc publish paths that already existed:
automation-events.ts, metrics/automation-bridge.ts and the
hostSessionStatus singleton, now the host.session.status topic.
Both invariants the old code relied on are preserved. Publishing stays
fire-and-forget, so a failing subscriber cannot disturb a metrics poll or
a host delete. The automations engine subscribes to the bus at scheduler
start instead of being imported ad hoc, which keeps the edge
one-directional: automations may import repositories, hosts modules must
not import automations.
* feat: add plugin ctx broker with permission gate and audit
Every ctx call a plugin makes is checked against its granted
capabilities, performed by the main thread, then audited. A capability
must be both declared in the manifest and granted in the database, so
widening a plugin's reach always needs a new manifest the user can see,
never just a database row.
Audit attribution is set by the broker, not the plugin. The plugin
supplies only the details, so it cannot forge the actor.
fix: stop plugin workers seeing plaintext credentials in error messages
A failing ctx.ssh.connect handed the worker the raw error from deep in
the SSH stack, which embeds the connect config. The worker received, in
full:
Authentication failed for root using password <SECRET>
where <SECRET> was the host's real plaintext password. Any plugin with
ssh.exec could read it by catching the error, defeating the whole point
of the handle-based ssh API.
Errors crossing the boundary are now sanitised. Only PluginFacingError
and PluginPermissionError, both authored here, reach a plugin verbatim;
anything else becomes a generic message and the real one goes to the
log. This uses a marker class rather than matching on message text,
because matching would start leaking again the moment an upstream error
happened to contain a familiar phrase.
Covered by credential-isolation.test.ts, which plants a sentinel in
every secret-bearing field and asserts it appears in no message the
worker ever receives.
* feat: dispatch plugin HTTP routes and wire the runtime into startup
ctx.http.route now registers a real router on the /plugin-api/:pluginId
dispatcher, replacing its stub 404. A plugin never touches the Express
req/res: it gets a plain summary and returns a plain object, so sockets
and session cookies stay on this side of the boundary. Authorization and
cookie headers are not forwarded.
Plugins load last in the start-up sequence, after the server is fully
wired, and are terminated before the database on shutdown so none can
outlive it. A plugin that fails to load cannot stop the backend.
fix: correct the nginx plugin-api location regex
The pattern was ^/plugin-api/(/.*)?$, which needs a double slash and so
never matched /plugin-api/<id>/<route>. Every other block in the file
uses ^/prefix(/.*)?$. Left alone, plugin routes would work in dev and
404 only behind Docker.
* fix: update stale SFTP transfer test for single file destination paths
The test still asserted transferToHost was called with the destination
directory "/srv". Commit 7727fa09 (#1304) deliberately changed a single
file transfer to send the full destination path including the filename,
so the correct expectation is "/srv/remote-1.txt". The code was right
and the test was left behind.
* feat: ship the ssh terminal as a first-party in-process plugin
Adds a hardcoded process:transport-owner tier so the terminal can own its
WebSocket server and ssh2 clients, which cannot cross the worker boundary.
* feat: ship docker as a first-party in-process plugin
Moves docker backend and frontend into plugins/docker/, alongside
ssh-terminal. Adds a host editor tab seam, a dashboard card registry,
and a build step to compile plugin backends into dist.
* feat: ship host metrics as a first-party in-process plugin
* fix: harden plugin auth and add per-request identity for ctx calls
Require auth on /plugin-api, gate plugin enable/grant/revoke routes
behind admin.plugins.manage, and thread the calling user through
worker ctx.hosts/ctx.ssh calls instead of always using the install
owner. Also adds the plugin permissions grant/revoke UI.
* feat: ship the AI assistant as a first-party in-process plugin
Moves src/backend/ai and its routes into plugins/ai, mounted through a
small /ai dispatcher instead of its own port. Moves the ai-enabled and
ai-private-endpoints settings routes out of user-settings-routes.ts
into the plugin too, so it has no leftover core dependents.
* fix: remove compiled plugin JS left behind in plugins/ after build
copy-bundled-plugins.cjs compiled and rewrote plugin backend TS in
place, copied it to dist/plugins, but never deleted the .js it had
just emitted next to the .ts sources. Those files shadowed the real
source for vitest/tsx and had import paths rewritten for the dist
layout, so tests failed with "Cannot find module" once a local build
had run. Affects docker, host-metrics and ai, not just this one.
* feat: add a permission-gated backend service registry for plugins
* feat: add a permission-gated UI action registry and action slots for plugins
Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal
plugin offers and the ai plugin fills, gated on ai.services.use.
* feat: add cross-plugin secret references gated by the provider's RBAC permission
* feat: move proxmox discovery/import/sync into a first-party plugin
* feat: move remote desktop (RDP/VNC/Telnet) into a first-party plugin
Relocates guacamole backend/frontend source and tests into
plugins/remote-desktop, following the ssh-terminal/docker pattern.
* feat: wire remote-desktop plugin into core and rename i18n keys
Repoints core consumers (dispatcher, collab, session-sharing, tab
utils) at the plugin, adds isTabTypeAvailable gating for rdp/vnc/
telnet, widens hostCapability to accept an array, and renames the
guacamole i18n namespace to remoteDesktop.
* feat: move fleets into a first-party plugin
* feat: move automations into a first-party plugin
* feat: remove legacy alert rules, superseded by automations
Alert rules already auto-migrate into automations on boot and the old
engine stands down after. Deletes the rule/firing tables, routes, UI,
and AI tools; keeps notification channels since automations still use
them. Also fixes automations' notify step, which still called the
deleted repository.
* feat: move network topology into a first-party plugin
* feat: move tailscale device discovery and settings into a plugin
Adds plugins/tailscale/ with a devices sidebar tab, admin settings
section, and re-auth dialog. Host Metrics tailscale manager card and
SSH re-auth banner parsing stay in core.
* feat: move workspaces into a first-party plugin
* feat: move web endpoint into a first-party plugin
* feat: collapse plugin runtime to one tier behind a plugin SDK
* feat: make each plugin an npm workspace built by a termix-plugin CLI
* feat: give plugins their own tables, migrations and sync entities
* feat: serve plugin http and websockets from one mount instead of ports
* feat: namespace plugin permissions and keep them valid when a plugin is off
* feat: give plugins their own settings and one settings screen
* feat: load plugin frontends at runtime and remove plugin ids from the shell
* fix: mock the ai status fetch in its activate test
* feat: add auth extension points and one ssh connect pipeline
* feat: rebuild workspaces as the reference plugin
* fix: gate second factors after SSO/LDAP behind an admin setting
* feat: convert network-topology to a full plugin (B1)
* feat: move snippets backend and tables into a plugin (B2)
* feat: point core frontend snippets pickers at the plugin (B2)
* feat: add the snippets plugin (B2)
* feat: wire ai, automations, fleets and ssh-terminal to snippets (B2)
* feat: finish the tailscale plugin, including SSH auth (B3)
* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
* chore: update proxmox and host-metrics locale files for B5
* chore: regenerate drizzle migration journals for B5
* feat: add ctx.hosts create/update to the SDK, host-metrics drops proxmox
* feat: move proxmox node stats and collectors into the proxmox plugin
* feat: drop proxmox host columns, use plugin settings instead
* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
* feat: convert file manager into a plugin
* feat: convert ssh tunnels into a plugin
* feat: finish the web-endpoint plugin, add ctx.desktop (B8)
* feat: move the terminal fully into the ssh-terminal plugin (B9)
* feat: convert tmux monitoring into a plugin (B10)
* feat: convert serial console into the serial plugin
* feat: move session sharing and collab rooms into a plugin (B12)
* feat: turn session recording into a plugin (B13)
* feat: finish the remote-desktop plugin (B14)
* feat: finish the host-metrics plugin, move host status into core (B16)
* feat: finish the docker plugin (B15)
* feat: finish the automations plugin (B17)
* feat: finish the ai plugin (B18)
* feat: finish the homepage plugin, move service links and canvas out of core (B19)
* feat: convert wake-on-lan into a plugin (B20)
* feat: convert secret-sources into a plugin (B20)
* feat: move totp and passkeys into plugins (C1)
* feat: move sso and ldap logins into plugins (C2)
* feat: move opkssh and warpgate into plugins (C3)
* feat: move step-ca into a plugin (C4)
* feat: move vault ssh signing into a plugin (C5)
* feat: move termix identity into a plugin (C6)
* feat: move acme certificate automation into a plugin (C7)
* fix: finish 2.9.0 plugin moves and get every check green
* chore: lock the plugin boundary and remove 2.9.0 leftovers
* fix: harden the plugin runtime after the 2.9.0 security review
* feat: add plugin packing, signing, pinned trust and tmxplug bundling
* test: prove 2.8 to 2.9.0 upgrade is lossless and fix what it caught
* fix: give bundled plugin backends a real require for CJS deps
* feat: improve UI loading time and jitter
* feat: show credential sharing inline in the sidebar like host sharing
* feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning
* feat: unify alerts into one plugin, channels, rules and inbox
* feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling
* fix: set blacksmith builder cache-key to stop broken gha cache fallback
* fix: link plugin-sdk workspace before npm ci in docker deps stage
* fix: link every plugin workspace before npm ci in docker deps stage
* fix(host-metrics): exclude image and firmware filesystems (#1469)
* fix(host-metrics): exclude image and firmware filesystems
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(hosts): normalize imported jump host references (#1470)
* fix(hosts): normalize imported jump host references
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(ci): build plugin SDK and repair MySQL settings migration (#1471)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): keep inline rename consistent (#1476)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): keep inline rename consistent
* fix(dashboard): reuse translated host status labels (#1475)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(dashboard): reuse translated host status labels
* docs(api): clarify local and remote version fields (#1474)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* docs(api): clarify local and remote version fields
* fix(file-manager): preserve selected backgrounds on hover (#1473)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): preserve selection feedback on hover
* test(ui): await dialog focus cleanup before teardown
* fix(file-manager): restore Ctrl/Cmd+F file search (#1472)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): focus file search with Ctrl or Cmd F
* fix: keep rounded-full circular in production builds
* fix(file-manager): cancel uploads and stop remote writes (#1477)
* fix(file-manager): cancel uploads and stop remote writes
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): apply compact density to directory tree (#1478)
* fix(file-manager): apply compact density to directory tree
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): preserve font sizes in compact mode (#1480)
* fix(file-manager): preserve font sizes in compact mode
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): prioritize file names in narrow lists (#1483)
* fix(file-manager): prioritize file names in narrow lists
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* fix(remote-desktop): probe the saved host guacd endpoint (#1481)
* fix(remote-desktop): probe the saved host guacd endpoint
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* fix(homepage): check allowlisted private service reachability (#1479)
* fix(homepage): check allowlisted private service reachability
* test(css): exercise the configured PostCSS plugin chain
* fix(remote-desktop): stop unsolicited clipboard read prompts (#1482)
* fix(remote-desktop): read clipboard only on explicit paste
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* chore: simplify plugin readmes, changelogs and manifest descriptions
* fix: keep plugins loaded after signing in from the login screen
* fix(file-manager): accept OS file drops over existing rows (#1484)
* fix(file-manager): accept OS file drops over existing rows
* test(file-manager): target the file label in drop tests
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* fix(file-manager): pipeline SFTP download reads (#1485)
* feat: add plugin sdk build to beta release
* fix: make link-store secret check immune to random base64 matches
* fix(ldap): preserve the directory-provided bind DN (#1486)
* fix: compute beta changes across dev branches and move the beta tag
* chore: untrack plugin sdk ARCHITECTURE.md
* chore: remove references to plugin sdk ARCHITECTURE.md
* feat: merge host editor terminal tabs into one owned by ssh-terminal
* feat: add descriptions to plugin host enable toggles
* fix(file-manager): show download progress in file viewers (#1487)
* fix(electron): signal backend readiness over IPC (#1488)
* fix(split-screen): allow layout creation without randomUUID (#1490)
* fix(split-screen): allow layout creation without randomUUID
* test(credentials): avoid flaky Ed25519 key generation
* fix(sidebar): remeasure visible rows after density changes (#1489)
* fix(sidebar): remeasure visible rows after density changes
* test(credentials): avoid flaky Ed25519 key generation
* feat(split-screen): redesign split screen with free-form panes and pane picker
* feat(sidebar): add host click behavior setting, default to always opening a new tab
* feat(snippets): redesign panel to match the host list
* fix(session-recording): stop session logs panel from refetching in a loop
* feat(snippets): bring back sharing, target hosts and drag reorder
* feat(ssh-terminal): add toolbar position, start state, display and fade host settings
* feat(ssh-terminal): add host setting to turn off password prompt auto-fill
* fix(ai): stop streams getting cut off and keep tool steps across messages
* feat: improve quick connect system using plugin api
* fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport
* feat: move terminal settings, look and ssh tools into ssh-terminal plugin
* feat: move snippets, keybinding actions and macros out of core into plugins
* feat: store rdp, vnc and telnet logins in a generic per-protocol table
* feat: type the plugin host record, move plugin strings out of core and add lint checks
* feat: replace core homepage widget registry with generic extension points
* feat: plugins only see their own host settings in the browser
* feat: harden plugin api for separate repos, signed updates and generic external login
* fix: keep plugin css below core utilities and version plugin assets by their css
* fix: load and save plugin host settings in the editor and tighten settings row spacing
* feat: redesign user host defaults system
* fix(remote-desktop): release held keys on focus loss (#1498)
* fix(session-sharing): replace local presentation after takeover (#1500)
* fix(file-manager): refresh disk usage with directory listing (#1503)
* fix(ssh-terminal): preserve input order during local echo reconciliation (#1505)
* feat(remote-desktop): expose display zoom controls for RDP (#1514)
* fix(file-manager): restore text selection and copying in Markdown previews (#1518)
* fix(sidebar): keep compact host addresses visible (#1524)
* fix(hosts): preserve plugin settings when reopening the editor (#1499)
* fix(ssh-terminal): close owner sockets when presentations unmount (#1501)
* fix(session-sharing): synchronize shared SSH terminal dimensions (#1502)
* fix(session-sharing): show participant presence in member terminals (#1504)
* fix(ai): sync provider configuration and keys across linked devices (#1506)
* feat(ssh-terminal): add a duplicate tab action (#1510)
* feat(file-manager): allow disabling move-to-trash confirmation (#1511)
* feat(file-manager): default to the remote login directory (#1512)
* feat(homepage): synchronize canvas layouts across devices (#1515)
* fix(shortcuts): prevent accidental double-Shift palette activation (#1519)
* feat(tmux-monitor): add a unified multi-host view (#1520)
* fix(electron): allow explicit quit with active connections (#1522)
* feat: reconnect all disconnected terminal tabs (#1523)
* fix(hosts): preserve recording and sharing settings in the dev editor (#1509)
* fix(database): preserve RDP domain during startup backfill (#1497)
* feat(sidebar): add keyboard type-ahead search for hosts (#1516)
* feat(automations): add per-host maintenance mode and recurring schedules (#1521)
* feat(tmux): add a per-host mouse toggle and preserve remote configuration (#1513)
* fix(session-sharing): use the linked server for desktop meetings and links (#1507)
* fix(tunnels): support TOTP for client tunnels and jump hosts (#1508)
* fix: type errors and lint failures from merged plugin prs
* feat(installer): make the Windows desktop shortcut optional (#1517)
* fix: ship the plugin sdk and serialport in desktop builds
* fix: prefer ipv4 when resolving ssh host names (#1365)
* fix: readable terminal selection with the light theme (#1319)
* fix: build desktop session share links on the linked server (#1350)
* fix: show meeting guests to members and hide the roster from guests (#1353)
* fix: reliable file manager saves with sudo prompt and no base64 guessing (#1335)
* fix: show disk usage for the mount being browsed (#1347)
* fix: load the linux tray icon reliably and log tray failures (#1332)
* fix: cover the gnome close path in the desktop quit test (#1332)
* fix: prompt for jump host TOTP in terminal, file manager and metrics (#1312)
* feat: optional auto reconnect for dropped ssh sessions (#1321)
* feat: tighter compact host rows with inline tags (#1325)
* feat: bring back the alert feed homepage widget
* feat: pick target terminals for snippets again
* feat: share button on connection tabs again
* feat: recent servers list when linking the desktop app
* feat: list the fixed app shortcuts in keyboard shortcuts
* fix: translate hardcoded toasts and drop em dashes from ui text
* fix: translate leftover hardcoded ui text
* fix: toolbar files button opens at the shell directory again
* fix: show the remote desktop note under connection origin again
* chore: openapi docs for host metrics manager routes
* chore: openapi docs for undocumented routes and fix two broken doc blocks
* fix: declare terminal.listSessions use for snippet targets
* revert: drop the alert feed widget, the notification center replaces it
* feat: restore the alert feed homepage widget
* fix: quick connect fills the host address from plugin auth editors like tailscale
* fix: docker container logs use the base background color
* chore: fix dependabot alerts and apply safe npm upgrades
* fix: proxmox stats tab reads its switch from plugin host settings
* fix: count plugin ssh logins toward host status and stop showing reachable as a warning
* fix: hide the built-in source pill in the host editor
* fix: host status shows online when the port answers and only warns on a failed login
* fix: drop the top border on the split layout menu
* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default
* fix: remote desktop auth method labels and notes for vnc and telnet
* fix: add description to the web endpoint enable switch
* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release
* feat: move telemetry out of core into a usage statistics plugin
* fix: recent activity card no longer cuts off host names early
* fix: remove duplicate top border on split screen menu
* fix: redesign session log player controls, fix tooltip overlap and drop dot separators
* fix: file manager and sftp text showing raw i18n keys
* fix: onboarding rerunning for existing users after upgrading
* fix: fleet detail buttons overflowing the sidebar
* fix: session log player scrolling off screen horizontally
* fix: network graph lag when adding hosts and on every render
* fix: maintenance index name, shared host read only view and badge polling
* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag
* chore: update release notes
* fix: remote desktop tabs reconnecting on every tab switch
* fix: build plugin sdk before linting in release workflow
* chore: lint, format, and bump version to 2.9.0
* chore: skip crowdin pre-translate in release for now
* chore: sync Crowdin translations for 2.9.0
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Koi <38512047+datlt4@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
4405 files changed
+1341026
-289722
No files matched your search
+435
-543
File diff suppressed because it is too large.
Load diff
+295
-1614
File diff suppressed because it is too large.
Load diff
@@ -98,199 +98,17 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
|
||||
columns: "user_id",
|
||||
},
|
||||
|
||||
// Snippets.
|
||||
{ name: "idx_snippets_user_id", table: "snippets", columns: "user_id" },
|
||||
{
|
||||
name: "idx_snippet_access_user_id",
|
||||
table: "snippet_access",
|
||||
columns: "user_id",
|
||||
},
|
||||
{
|
||||
name: "idx_snippet_access_snippet_id",
|
||||
table: "snippet_access",
|
||||
columns: "snippet_id",
|
||||
},
|
||||
{
|
||||
name: "idx_snippet_access_role_id",
|
||||
table: "snippet_access",
|
||||
columns: "role_id",
|
||||
},
|
||||
|
||||
// Per-user history and file manager surfaces.
|
||||
{
|
||||
name: "idx_recent_activity_user_ts",
|
||||
table: "recent_activity",
|
||||
columns: "user_id, timestamp",
|
||||
},
|
||||
{
|
||||
name: "idx_command_history_user_host",
|
||||
table: "command_history",
|
||||
columns: "user_id, host_id",
|
||||
},
|
||||
{
|
||||
name: "idx_file_manager_recent_user",
|
||||
table: "file_manager_recent",
|
||||
columns: "user_id, host_id",
|
||||
},
|
||||
{
|
||||
name: "idx_file_manager_pinned_user",
|
||||
table: "file_manager_pinned",
|
||||
columns: "user_id, host_id",
|
||||
},
|
||||
{
|
||||
name: "idx_file_manager_shortcuts_user",
|
||||
table: "file_manager_shortcuts",
|
||||
columns: "user_id, host_id",
|
||||
},
|
||||
{
|
||||
name: "idx_transfer_recent_user",
|
||||
table: "transfer_recent",
|
||||
columns: "user_id",
|
||||
},
|
||||
{
|
||||
name: "idx_user_open_tabs_user_id",
|
||||
table: "user_open_tabs",
|
||||
columns: "user_id",
|
||||
},
|
||||
{
|
||||
name: "idx_user_workspaces_user_id",
|
||||
table: "user_workspaces",
|
||||
columns: "user_id",
|
||||
},
|
||||
{
|
||||
name: "idx_homepage_items_user_id",
|
||||
table: "homepage_items",
|
||||
columns: "user_id",
|
||||
},
|
||||
{
|
||||
name: "idx_dismissed_alerts_user_id",
|
||||
table: "dismissed_alerts",
|
||||
columns: "user_id",
|
||||
},
|
||||
|
||||
// Recordings and live session sharing.
|
||||
{
|
||||
name: "idx_session_recordings_user_started",
|
||||
table: "session_recordings",
|
||||
columns: "user_id, started_at",
|
||||
},
|
||||
{
|
||||
name: "idx_session_recordings_host",
|
||||
table: "session_recordings",
|
||||
columns: "host_id",
|
||||
},
|
||||
{
|
||||
name: "idx_session_shares_session_id",
|
||||
table: "session_shares",
|
||||
columns: "session_id",
|
||||
},
|
||||
{
|
||||
name: "idx_session_shares_host_id",
|
||||
table: "session_shares",
|
||||
columns: "host_id",
|
||||
},
|
||||
|
||||
// Fleets.
|
||||
{
|
||||
name: "idx_fleet_members_fleet",
|
||||
table: "fleet_members",
|
||||
columns: "fleet_id",
|
||||
},
|
||||
{
|
||||
name: "idx_fleet_members_host",
|
||||
table: "fleet_members",
|
||||
columns: "host_id",
|
||||
},
|
||||
{
|
||||
name: "idx_fleet_inventory_user",
|
||||
table: "fleet_inventory",
|
||||
columns: "user_id",
|
||||
},
|
||||
|
||||
// Alerting.
|
||||
{
|
||||
name: "idx_alert_firings_rule",
|
||||
table: "alert_firings",
|
||||
columns: "rule_id, fired_at",
|
||||
},
|
||||
{
|
||||
name: "idx_alert_firings_host",
|
||||
table: "alert_firings",
|
||||
columns: "host_id",
|
||||
},
|
||||
|
||||
// Automations.
|
||||
{
|
||||
name: "idx_automations_user",
|
||||
table: "automations",
|
||||
columns: "user_id, enabled",
|
||||
},
|
||||
{
|
||||
name: "idx_automation_trigger_state_key",
|
||||
table: "automation_trigger_state",
|
||||
columns: "automation_id, state_key",
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
name: "idx_automation_schedules_automation",
|
||||
table: "automation_schedules",
|
||||
columns: "automation_id",
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
name: "idx_automation_schedules_due",
|
||||
table: "automation_schedules",
|
||||
columns: "next_due_at",
|
||||
},
|
||||
{
|
||||
name: "idx_automation_runs_automation",
|
||||
table: "automation_runs",
|
||||
columns: "automation_id, started_at",
|
||||
},
|
||||
{
|
||||
name: "idx_automation_runs_user",
|
||||
table: "automation_runs",
|
||||
columns: "user_id, started_at",
|
||||
},
|
||||
{
|
||||
name: "idx_automation_run_steps_run",
|
||||
table: "automation_run_steps",
|
||||
columns: "run_id, step_index",
|
||||
},
|
||||
{
|
||||
name: "idx_automation_channels_pair",
|
||||
table: "automation_channels",
|
||||
columns: "automation_id, channel_id",
|
||||
unique: true,
|
||||
},
|
||||
|
||||
// AI assistant.
|
||||
{
|
||||
name: "idx_ai_providers_user_label",
|
||||
table: "ai_providers",
|
||||
columns: "user_id, label",
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
name: "idx_ai_conversations_user",
|
||||
table: "ai_conversations",
|
||||
columns: "user_id, updated_at",
|
||||
},
|
||||
{
|
||||
name: "idx_ai_messages_conversation",
|
||||
table: "ai_messages",
|
||||
columns: "conversation_id, created_at",
|
||||
},
|
||||
{
|
||||
name: "idx_ai_proposals_user",
|
||||
table: "ai_proposals",
|
||||
columns: "user_id, status",
|
||||
},
|
||||
{
|
||||
name: "idx_ai_proposals_conversation",
|
||||
table: "ai_proposals",
|
||||
columns: "conversation_id",
|
||||
},
|
||||
];
|
||||
|
||||
interface IndexableDatabase {
|
||||
|
||||
+427
-1455
File diff suppressed because it is too large.
Load diff
+426
-1455
File diff suppressed because it is too large.
Load diff
+361
-1390
File diff suppressed because it is too large.
Load diff
@@ -1,407 +0,0 @@
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import {
|
||||
aiConversations,
|
||||
aiMessages,
|
||||
aiProposals,
|
||||
aiProviders,
|
||||
} from "../db/schema.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning } from "./returning.js";
|
||||
import { formatSqlTimestamp } from "./sql-timestamp.js";
|
||||
|
||||
const now = (): string => formatSqlTimestamp(new Date());
|
||||
|
||||
export type AiProviderRecord = typeof aiProviders.$inferSelect;
|
||||
export type AiConversationRecord = typeof aiConversations.$inferSelect;
|
||||
export type AiMessageRecord = typeof aiMessages.$inferSelect;
|
||||
export type AiProposalRecord = typeof aiProposals.$inferSelect;
|
||||
|
||||
export interface AiProviderInput {
|
||||
userId: string;
|
||||
providerType: string;
|
||||
label: string;
|
||||
baseUrl?: string | null;
|
||||
apiKey?: string | null;
|
||||
defaultModel?: string | null;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the first few characters so the UI can tell two keys apart without
|
||||
* ever receiving the key itself.
|
||||
*/
|
||||
export function apiKeyPrefix(apiKey: string | null | undefined): string | null {
|
||||
if (!apiKey) return null;
|
||||
return apiKey.slice(0, 6);
|
||||
}
|
||||
|
||||
export class AiRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Provider API keys are field-encrypted like any other credential. The key is
|
||||
* derived from the row id, which does not exist until after the insert, so a
|
||||
* new provider is written once and then re-encrypted in place with its real
|
||||
* id -- the same approach AlertRepository uses for channel configs.
|
||||
*/
|
||||
private userDataKey(userId: string): Buffer | null {
|
||||
try {
|
||||
return DataCrypto.getUserDataKey(userId);
|
||||
} catch {
|
||||
// Crypto is not initialized (tests, early boot); leave the value as is.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private encryptApiKey(
|
||||
apiKey: string,
|
||||
userId: string,
|
||||
recordId: number | string,
|
||||
): string {
|
||||
const userDataKey = this.userDataKey(userId);
|
||||
if (!userDataKey) return apiKey;
|
||||
return DataCrypto.encryptRecord(
|
||||
"ai_providers",
|
||||
{ id: recordId, apiKey },
|
||||
userId,
|
||||
userDataKey,
|
||||
).apiKey;
|
||||
}
|
||||
|
||||
private decryptApiKey(
|
||||
apiKey: string,
|
||||
userId: string,
|
||||
recordId: number | string,
|
||||
): string {
|
||||
const userDataKey = this.userDataKey(userId);
|
||||
if (!userDataKey) return apiKey;
|
||||
try {
|
||||
return DataCrypto.decryptRecord(
|
||||
"ai_providers",
|
||||
{ id: recordId, apiKey },
|
||||
userId,
|
||||
userDataKey,
|
||||
).apiKey;
|
||||
} catch {
|
||||
// Rows written before encryption was enabled are still plaintext.
|
||||
return apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
// --- providers ---
|
||||
|
||||
/** Never includes the key material; callers get the masked prefix only. */
|
||||
async listProviders(userId: string): Promise<AiProviderRecord[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(aiProviders)
|
||||
.where(eq(aiProviders.userId, userId))
|
||||
.orderBy(aiProviders.id);
|
||||
|
||||
return rows.map((row) => ({ ...row, apiKey: null }));
|
||||
}
|
||||
|
||||
async findProvider(
|
||||
id: number,
|
||||
userId: string,
|
||||
): Promise<AiProviderRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(aiProviders)
|
||||
.where(and(eq(aiProviders.id, id), eq(aiProviders.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!rows[0]) return null;
|
||||
return { ...rows[0], apiKey: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* The one path that returns usable key material. Only the provider adapters
|
||||
* call this, immediately before an outbound request.
|
||||
*/
|
||||
async findProviderWithSecret(
|
||||
id: number,
|
||||
userId: string,
|
||||
): Promise<AiProviderRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(aiProviders)
|
||||
.where(and(eq(aiProviders.id, id), eq(aiProviders.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
if (!row.apiKey) return row;
|
||||
return {
|
||||
...row,
|
||||
apiKey: this.decryptApiKey(row.apiKey, userId, row.id),
|
||||
};
|
||||
}
|
||||
|
||||
async createProvider(input: AiProviderInput): Promise<AiProviderRecord> {
|
||||
const [created] = await insertReturning(this.context, aiProviders, {
|
||||
userId: input.userId,
|
||||
providerType: input.providerType,
|
||||
label: input.label,
|
||||
baseUrl: input.baseUrl ?? null,
|
||||
apiKey: input.apiKey ?? null,
|
||||
apiKeyPrefix: apiKeyPrefix(input.apiKey),
|
||||
defaultModel: input.defaultModel ?? null,
|
||||
enabled: input.enabled ?? true,
|
||||
});
|
||||
|
||||
if (input.apiKey) {
|
||||
const encrypted = this.encryptApiKey(
|
||||
input.apiKey,
|
||||
input.userId,
|
||||
created.id,
|
||||
);
|
||||
if (encrypted !== input.apiKey) {
|
||||
await this.context.drizzle
|
||||
.update(aiProviders)
|
||||
.set({ apiKey: encrypted })
|
||||
.where(eq(aiProviders.id, created.id));
|
||||
}
|
||||
}
|
||||
|
||||
await this.afterWrite();
|
||||
return { ...created, apiKey: null };
|
||||
}
|
||||
|
||||
async updateProvider(
|
||||
id: number,
|
||||
userId: string,
|
||||
input: Partial<Omit<AiProviderInput, "userId">>,
|
||||
): Promise<AiProviderRecord | null> {
|
||||
const existing = await this.findProvider(id, userId);
|
||||
if (!existing) return null;
|
||||
|
||||
const updates: Record<string, unknown> = { updatedAt: now() };
|
||||
if (input.providerType !== undefined)
|
||||
updates.providerType = input.providerType;
|
||||
if (input.label !== undefined) updates.label = input.label;
|
||||
if (input.baseUrl !== undefined) updates.baseUrl = input.baseUrl;
|
||||
if (input.defaultModel !== undefined)
|
||||
updates.defaultModel = input.defaultModel;
|
||||
if (input.enabled !== undefined) updates.enabled = input.enabled;
|
||||
|
||||
// An empty string clears the key; undefined leaves it untouched.
|
||||
if (input.apiKey !== undefined) {
|
||||
if (input.apiKey) {
|
||||
updates.apiKey = this.encryptApiKey(input.apiKey, userId, id);
|
||||
updates.apiKeyPrefix = apiKeyPrefix(input.apiKey);
|
||||
} else {
|
||||
updates.apiKey = null;
|
||||
updates.apiKeyPrefix = null;
|
||||
}
|
||||
}
|
||||
|
||||
await this.context.drizzle
|
||||
.update(aiProviders)
|
||||
.set(updates)
|
||||
.where(and(eq(aiProviders.id, id), eq(aiProviders.userId, userId)));
|
||||
|
||||
await this.afterWrite();
|
||||
return this.findProvider(id, userId);
|
||||
}
|
||||
|
||||
async deleteProvider(id: number, userId: string): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(aiProviders)
|
||||
.where(and(eq(aiProviders.id, id), eq(aiProviders.userId, userId)));
|
||||
|
||||
const deleted = rowsAffected(result) > 0;
|
||||
if (deleted) await this.afterWrite();
|
||||
return deleted;
|
||||
}
|
||||
|
||||
// --- conversations ---
|
||||
|
||||
async listConversations(
|
||||
userId: string,
|
||||
limit = 50,
|
||||
): Promise<AiConversationRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(aiConversations)
|
||||
.where(eq(aiConversations.userId, userId))
|
||||
.orderBy(desc(aiConversations.updatedAt))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
async findConversation(
|
||||
id: number,
|
||||
userId: string,
|
||||
): Promise<AiConversationRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(aiConversations)
|
||||
.where(
|
||||
and(eq(aiConversations.id, id), eq(aiConversations.userId, userId)),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async createConversation(input: {
|
||||
userId: string;
|
||||
title?: string | null;
|
||||
providerId?: number | null;
|
||||
model?: string | null;
|
||||
}): Promise<AiConversationRecord> {
|
||||
const [created] = await insertReturning(this.context, aiConversations, {
|
||||
userId: input.userId,
|
||||
title: input.title ?? null,
|
||||
providerId: input.providerId ?? null,
|
||||
model: input.model ?? null,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async touchConversation(id: number, title?: string | null): Promise<void> {
|
||||
const updates: Record<string, unknown> = { updatedAt: now() };
|
||||
if (title) updates.title = title;
|
||||
await this.context.drizzle
|
||||
.update(aiConversations)
|
||||
.set(updates)
|
||||
.where(eq(aiConversations.id, id));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteConversation(id: number, userId: string): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(aiConversations)
|
||||
.where(
|
||||
and(eq(aiConversations.id, id), eq(aiConversations.userId, userId)),
|
||||
);
|
||||
const deleted = rowsAffected(result) > 0;
|
||||
if (deleted) await this.afterWrite();
|
||||
return deleted;
|
||||
}
|
||||
|
||||
// --- messages ---
|
||||
|
||||
async listMessages(conversationId: number): Promise<AiMessageRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(aiMessages)
|
||||
.where(eq(aiMessages.conversationId, conversationId))
|
||||
.orderBy(aiMessages.id);
|
||||
}
|
||||
|
||||
async appendMessage(input: {
|
||||
conversationId: number;
|
||||
role: string;
|
||||
content: string;
|
||||
toolCalls?: string | null;
|
||||
}): Promise<AiMessageRecord> {
|
||||
const [created] = await insertReturning(this.context, aiMessages, {
|
||||
conversationId: input.conversationId,
|
||||
role: input.role,
|
||||
content: input.content,
|
||||
toolCalls: input.toolCalls ?? null,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
// --- proposals ---
|
||||
|
||||
async listProposals(
|
||||
userId: string,
|
||||
conversationId?: number,
|
||||
): Promise<AiProposalRecord[]> {
|
||||
const where = conversationId
|
||||
? and(
|
||||
eq(aiProposals.userId, userId),
|
||||
eq(aiProposals.conversationId, conversationId),
|
||||
)
|
||||
: eq(aiProposals.userId, userId);
|
||||
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(aiProposals)
|
||||
.where(where)
|
||||
.orderBy(desc(aiProposals.id));
|
||||
}
|
||||
|
||||
async findProposal(
|
||||
id: number,
|
||||
userId: string,
|
||||
): Promise<AiProposalRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(aiProposals)
|
||||
.where(and(eq(aiProposals.id, id), eq(aiProposals.userId, userId)))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async createProposal(input: {
|
||||
conversationId: number;
|
||||
userId: string;
|
||||
kind: string;
|
||||
summary?: string | null;
|
||||
payload: string;
|
||||
}): Promise<AiProposalRecord> {
|
||||
const [created] = await insertReturning(this.context, aiProposals, {
|
||||
conversationId: input.conversationId,
|
||||
userId: input.userId,
|
||||
kind: input.kind,
|
||||
summary: input.summary ?? null,
|
||||
payload: input.payload,
|
||||
status: "pending",
|
||||
});
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async setProposalStatus(
|
||||
id: number,
|
||||
userId: string,
|
||||
status: "applied" | "rejected" | "expired",
|
||||
resultSummary?: string | null,
|
||||
): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.update(aiProposals)
|
||||
.set({
|
||||
status,
|
||||
appliedAt: status === "applied" ? now() : null,
|
||||
resultSummary: resultSummary ?? null,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(aiProposals.id, id),
|
||||
eq(aiProposals.userId, userId),
|
||||
eq(aiProposals.status, "pending"),
|
||||
),
|
||||
);
|
||||
|
||||
const updated = rowsAffected(result) > 0;
|
||||
if (updated) await this.afterWrite();
|
||||
return updated;
|
||||
}
|
||||
|
||||
// --- account deletion ---
|
||||
|
||||
async deleteByUserId(userId: string): Promise<void> {
|
||||
// ai_messages and ai_proposals cascade from ai_conversations.
|
||||
await this.context.drizzle
|
||||
.delete(aiConversations)
|
||||
.where(eq(aiConversations.userId, userId));
|
||||
await this.context.drizzle
|
||||
.delete(aiProviders)
|
||||
.where(eq(aiProviders.userId, userId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,712 +0,0 @@
|
||||
import { and, count, desc, eq, inArray, isNull, lt, or } from "drizzle-orm";
|
||||
import {
|
||||
alertFirings,
|
||||
alertRuleChannels,
|
||||
alertRules,
|
||||
hosts,
|
||||
notificationChannels,
|
||||
} from "../db/schema.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { sqlTimestampDaysAgo } from "./sql-timestamp.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.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>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Channel configs carry ntfy tokens and webhook auth headers, so they are
|
||||
* field-encrypted like any other secret. The key is derived from the row id,
|
||||
* which does not exist until after the insert, so a new channel is written
|
||||
* once and then re-encrypted in place with its real id.
|
||||
*/
|
||||
private userDataKey(userId: string): Buffer | null {
|
||||
try {
|
||||
return DataCrypto.getUserDataKey(userId);
|
||||
} catch {
|
||||
// Crypto is not initialized (tests, early boot); leave the value as is.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private encryptConfig(
|
||||
config: string,
|
||||
userId: string,
|
||||
recordId: number | string,
|
||||
): string {
|
||||
const userDataKey = this.userDataKey(userId);
|
||||
if (!userDataKey) return config;
|
||||
return DataCrypto.encryptRecord(
|
||||
"notification_channels",
|
||||
{ id: recordId, config },
|
||||
userId,
|
||||
userDataKey,
|
||||
).config;
|
||||
}
|
||||
|
||||
private decryptConfig(
|
||||
config: string,
|
||||
userId: string,
|
||||
recordId: number | string,
|
||||
): string {
|
||||
const userDataKey = this.userDataKey(userId);
|
||||
if (!userDataKey) return config;
|
||||
try {
|
||||
return DataCrypto.decryptRecord(
|
||||
"notification_channels",
|
||||
{ id: recordId, config },
|
||||
userId,
|
||||
userDataKey,
|
||||
).config;
|
||||
} catch {
|
||||
// Rows written before channel configs were encrypted are still plaintext.
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
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((row) => {
|
||||
const mapped = mapChannelRow(row);
|
||||
mapped.config = this.decryptConfig(mapped.config, userId, mapped.id);
|
||||
return mapped;
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (!rows[0]) return null;
|
||||
const mapped = mapChannelRow(rows[0]);
|
||||
mapped.config = this.decryptConfig(mapped.config, userId, mapped.id);
|
||||
return mapped;
|
||||
}
|
||||
|
||||
async createNotificationChannel(input: {
|
||||
userId: string;
|
||||
name: string;
|
||||
type: string;
|
||||
config: string;
|
||||
enabled: boolean;
|
||||
}): Promise<NotificationChannelRow> {
|
||||
const [created] = await insertReturning(
|
||||
this.context,
|
||||
notificationChannels,
|
||||
{
|
||||
userId: input.userId,
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
config: input.config,
|
||||
enabled: input.enabled,
|
||||
},
|
||||
);
|
||||
|
||||
const encrypted = this.encryptConfig(
|
||||
input.config,
|
||||
input.userId,
|
||||
created.id,
|
||||
);
|
||||
if (encrypted !== input.config) {
|
||||
await this.context.drizzle
|
||||
.update(notificationChannels)
|
||||
.set({ config: encrypted })
|
||||
.where(eq(notificationChannels.id, created.id));
|
||||
}
|
||||
|
||||
await this.afterWrite();
|
||||
const mapped = mapChannelRow(created);
|
||||
mapped.config = input.config;
|
||||
return mapped;
|
||||
}
|
||||
|
||||
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 values =
|
||||
input.config !== undefined
|
||||
? { ...input, config: this.encryptConfig(input.config, userId, id) }
|
||||
: input;
|
||||
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
notificationChannels,
|
||||
values,
|
||||
and(
|
||||
eq(notificationChannels.id, id),
|
||||
eq(notificationChannels.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (!updated) return null;
|
||||
await this.afterWrite();
|
||||
const mapped = mapChannelRow(updated);
|
||||
mapped.config = this.decryptConfig(mapped.config, userId, mapped.id);
|
||||
return mapped;
|
||||
}
|
||||
|
||||
async deleteNotificationChannel(
|
||||
id: number,
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(notificationChannels)
|
||||
.where(
|
||||
and(
|
||||
eq(notificationChannels.id, id),
|
||||
eq(notificationChannels.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) === 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 insertReturning(this.context, alertRules, {
|
||||
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,
|
||||
});
|
||||
|
||||
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 updateReturning(
|
||||
this.context,
|
||||
alertRules,
|
||||
{
|
||||
...(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,
|
||||
},
|
||||
and(eq(alertRules.id, id), eq(alertRules.userId, userId)),
|
||||
);
|
||||
|
||||
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 result = await this.context.drizzle
|
||||
.delete(alertRules)
|
||||
.where(and(eq(alertRules.id, id), eq(alertRules.userId, userId)));
|
||||
|
||||
if (rowsAffected(result) === 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();
|
||||
}
|
||||
|
||||
// A rule with host_id IS NULL means "all my hosts", not "all hosts on the
|
||||
// server". Without joining the host back to its owner, every user's wildcard
|
||||
// rule fired for every polled host and leaked other users' host names into
|
||||
// their alerts.
|
||||
async listEnabledRulesForHost(hostId: number): Promise<AlertEngineRule[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ rule: alertRules })
|
||||
.from(alertRules)
|
||||
.innerJoin(hosts, eq(hosts.id, hostId))
|
||||
.where(
|
||||
and(
|
||||
eq(alertRules.enabled, true),
|
||||
or(
|
||||
eq(alertRules.hostId, hostId),
|
||||
and(isNull(alertRules.hostId), eq(alertRules.userId, hosts.userId)),
|
||||
),
|
||||
),
|
||||
);
|
||||
return rows.map((row) => mapEngineRule(row.rule));
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
async pruneFiringsOlderThan(userId: string, days: number): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(alertFirings)
|
||||
.where(
|
||||
and(
|
||||
eq(alertFirings.userId, userId),
|
||||
lt(alertFirings.firedAt, sqlTimestampDaysAgo(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 firingResult = await this.context.drizzle
|
||||
.delete(alertFirings)
|
||||
.where(eq(alertFirings.userId, userId));
|
||||
|
||||
const linkFilters = [
|
||||
...(ruleIds.length > 0
|
||||
? [inArray(alertRuleChannels.ruleId, ruleIds)]
|
||||
: []),
|
||||
...(channelIds.length > 0
|
||||
? [inArray(alertRuleChannels.channelId, channelIds)]
|
||||
: []),
|
||||
];
|
||||
const linkResult =
|
||||
linkFilters.length === 0
|
||||
? null
|
||||
: await this.context.drizzle
|
||||
.delete(alertRuleChannels)
|
||||
.where(or(...linkFilters));
|
||||
|
||||
const ruleResult = await this.context.drizzle
|
||||
.delete(alertRules)
|
||||
.where(eq(alertRules.userId, userId));
|
||||
const result = await this.context.drizzle
|
||||
.delete(notificationChannels)
|
||||
.where(eq(notificationChannels.userId, userId));
|
||||
|
||||
if (
|
||||
rowsAffected(firingResult) > 0 ||
|
||||
rowsAffected(linkResult) > 0 ||
|
||||
rowsAffected(ruleResult) > 0 ||
|
||||
rowsAffected(result) > 0
|
||||
) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return {
|
||||
firingsDeleted: rowsAffected(firingResult),
|
||||
ruleLinksDeleted: rowsAffected(linkResult),
|
||||
rulesDeleted: rowsAffected(ruleResult),
|
||||
channelsDeleted: rowsAffected(result),
|
||||
};
|
||||
}
|
||||
|
||||
async listEnabledChannelsForRule(
|
||||
ruleId: number,
|
||||
): Promise<AlertEngineChannel[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
id: notificationChannels.id,
|
||||
userId: notificationChannels.userId,
|
||||
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),
|
||||
),
|
||||
);
|
||||
|
||||
// The engine sends without a user in scope, so decrypt against the owner.
|
||||
return rows.map(({ userId, ...channel }) => ({
|
||||
...channel,
|
||||
config: this.decryptConfig(channel.config, userId, channel.id),
|
||||
}));
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,784 +0,0 @@
|
||||
import { and, asc, desc, eq, inArray, lt, lte, sql } from "drizzle-orm";
|
||||
import {
|
||||
automationChannels,
|
||||
automationRunSteps,
|
||||
automationRuns,
|
||||
automationSchedules,
|
||||
automationTriggerState,
|
||||
automations,
|
||||
notificationChannels,
|
||||
} from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.js";
|
||||
|
||||
type AutomationRecord = typeof automations.$inferSelect;
|
||||
type AutomationRunRecord = typeof automationRuns.$inferSelect;
|
||||
type AutomationRunStepRecord = typeof automationRunSteps.$inferSelect;
|
||||
type TriggerStateRecord = typeof automationTriggerState.$inferSelect;
|
||||
type ScheduleRecord = typeof automationSchedules.$inferSelect;
|
||||
|
||||
export interface AutomationRow {
|
||||
id: number;
|
||||
user_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
enabled: number;
|
||||
definition: string;
|
||||
definition_version: number;
|
||||
concurrency_policy: string;
|
||||
max_run_seconds: number;
|
||||
dry_run: number;
|
||||
last_run_at: string | null;
|
||||
last_run_status: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
channels: number[];
|
||||
}
|
||||
|
||||
export interface AutomationRunRow {
|
||||
id: number;
|
||||
automation_id: number;
|
||||
user_id: string;
|
||||
trigger_type: string;
|
||||
trigger_context: string | null;
|
||||
status: string;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
duration_ms: number | null;
|
||||
error: string | null;
|
||||
dry_run: number;
|
||||
parent_run_id: number | null;
|
||||
automation_name?: string | null;
|
||||
}
|
||||
|
||||
export interface AutomationRunStepRow {
|
||||
id: number;
|
||||
run_id: number;
|
||||
step_index: number;
|
||||
step_id: string;
|
||||
step_type: string;
|
||||
status: string;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
output: string | null;
|
||||
error: string | null;
|
||||
truncated: number;
|
||||
}
|
||||
|
||||
/** The shape the engine loads; camelCase and already parsed where useful. */
|
||||
export interface AutomationEngineRow {
|
||||
id: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
definition: string;
|
||||
concurrencyPolicy: string;
|
||||
maxRunSeconds: number;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
export interface TriggerStateRow {
|
||||
automationId: number;
|
||||
stateKey: string;
|
||||
breachStartedAt: string | null;
|
||||
lastFiredAt: string | null;
|
||||
lastValue: number | null;
|
||||
lastObservedState: string | null;
|
||||
}
|
||||
|
||||
export interface DueScheduleRow {
|
||||
automationId: number;
|
||||
cron: string | null;
|
||||
intervalSeconds: number | null;
|
||||
timezone: string | null;
|
||||
nextDueAt: string | null;
|
||||
}
|
||||
|
||||
export class AutomationRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async list(userId: string): Promise<AutomationRow[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(automations)
|
||||
.where(eq(automations.userId, userId))
|
||||
.orderBy(asc(automations.name));
|
||||
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
// One query for every automation's channels rather than one per row.
|
||||
const links = await this.context.drizzle
|
||||
.select({
|
||||
automationId: automationChannels.automationId,
|
||||
channelId: automationChannels.channelId,
|
||||
})
|
||||
.from(automationChannels)
|
||||
.where(
|
||||
inArray(
|
||||
automationChannels.automationId,
|
||||
rows.map((row) => row.id),
|
||||
),
|
||||
);
|
||||
|
||||
const byAutomation = new Map<number, number[]>();
|
||||
for (const link of links) {
|
||||
const list = byAutomation.get(link.automationId) ?? [];
|
||||
list.push(link.channelId);
|
||||
byAutomation.set(link.automationId, list);
|
||||
}
|
||||
|
||||
return rows.map((row) => mapAutomationRow(row, byAutomation.get(row.id)));
|
||||
}
|
||||
|
||||
async findForUser(id: number, userId: string): Promise<AutomationRow | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(automations)
|
||||
.where(and(eq(automations.id, id), eq(automations.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!rows[0]) return null;
|
||||
return mapAutomationRow(rows[0], await this.listChannelIds(id));
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<AutomationEngineRow | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(automations)
|
||||
.where(eq(automations.id, id))
|
||||
.limit(1);
|
||||
return rows[0] ? mapEngineRow(rows[0]) : null;
|
||||
}
|
||||
|
||||
async create(input: {
|
||||
userId: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
enabled?: boolean;
|
||||
definition: string;
|
||||
concurrencyPolicy?: string;
|
||||
maxRunSeconds?: number;
|
||||
dryRun?: boolean;
|
||||
channels?: number[];
|
||||
now?: string;
|
||||
}): Promise<AutomationRow> {
|
||||
const now = input.now ?? new Date().toISOString();
|
||||
const [created] = await insertReturning(this.context, automations, {
|
||||
userId: input.userId,
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
enabled: input.enabled ?? true,
|
||||
definition: input.definition,
|
||||
concurrencyPolicy: input.concurrencyPolicy ?? "skip",
|
||||
maxRunSeconds: input.maxRunSeconds ?? 300,
|
||||
dryRun: input.dryRun ?? false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const channels = await this.replaceChannels(
|
||||
created.id,
|
||||
input.userId,
|
||||
input.channels ?? [],
|
||||
);
|
||||
await this.afterWrite();
|
||||
return mapAutomationRow(created, channels);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: number,
|
||||
userId: string,
|
||||
input: {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
enabled?: boolean;
|
||||
definition?: string;
|
||||
concurrencyPolicy?: string;
|
||||
maxRunSeconds?: number;
|
||||
dryRun?: boolean;
|
||||
channels?: number[];
|
||||
now?: string;
|
||||
},
|
||||
): Promise<AutomationRow | null> {
|
||||
const { channels, now, ...fields } = input;
|
||||
const values: Record<string, unknown> = { ...fields };
|
||||
|
||||
if (Object.keys(values).length > 0) {
|
||||
values.updatedAt = now ?? new Date().toISOString();
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
automations,
|
||||
values,
|
||||
and(eq(automations.id, id), eq(automations.userId, userId)),
|
||||
);
|
||||
if (!updated) return null;
|
||||
} else {
|
||||
const existing = await this.findForUser(id, userId);
|
||||
if (!existing) return null;
|
||||
}
|
||||
|
||||
if (channels) {
|
||||
await this.replaceChannels(id, userId, channels);
|
||||
}
|
||||
|
||||
await this.afterWrite();
|
||||
return this.findForUser(id, userId);
|
||||
}
|
||||
|
||||
async delete(id: number, userId: string): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(automations)
|
||||
.where(and(eq(automations.id, id), eq(automations.userId, userId)));
|
||||
|
||||
const deleted = rowsAffected(result) > 0;
|
||||
if (deleted) await this.afterWrite();
|
||||
return deleted;
|
||||
}
|
||||
|
||||
async setEnabled(
|
||||
id: number,
|
||||
userId: string,
|
||||
enabled: boolean,
|
||||
): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.update(automations)
|
||||
.set({ enabled, updatedAt: new Date().toISOString() })
|
||||
.where(and(eq(automations.id, id), eq(automations.userId, userId)));
|
||||
|
||||
const changed = rowsAffected(result) > 0;
|
||||
if (changed) await this.afterWrite();
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enabled automations owned by whoever owns the given host. Wildcard targets
|
||||
* must never reach across users, which is the bug the alert engine shipped
|
||||
* with.
|
||||
*/
|
||||
async listEnabledForUser(userId: string): Promise<AutomationEngineRow[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(automations)
|
||||
.where(
|
||||
and(eq(automations.enabled, true), eq(automations.userId, userId)),
|
||||
);
|
||||
return rows.map(mapEngineRow);
|
||||
}
|
||||
|
||||
async listAllEnabled(): Promise<AutomationEngineRow[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(automations)
|
||||
.where(eq(automations.enabled, true));
|
||||
return rows.map(mapEngineRow);
|
||||
}
|
||||
|
||||
// --- trigger state ---
|
||||
|
||||
async getTriggerState(
|
||||
automationId: number,
|
||||
stateKey: string,
|
||||
): Promise<TriggerStateRow | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(automationTriggerState)
|
||||
.where(
|
||||
and(
|
||||
eq(automationTriggerState.automationId, automationId),
|
||||
eq(automationTriggerState.stateKey, stateKey),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ? mapTriggerStateRow(rows[0]) : null;
|
||||
}
|
||||
|
||||
async upsertTriggerState(input: {
|
||||
automationId: number;
|
||||
stateKey: string;
|
||||
breachStartedAt?: string | null;
|
||||
lastFiredAt?: string | null;
|
||||
lastValue?: number | null;
|
||||
lastObservedState?: string | null;
|
||||
}): Promise<void> {
|
||||
const existing = await this.getTriggerState(
|
||||
input.automationId,
|
||||
input.stateKey,
|
||||
);
|
||||
const updatedAt = new Date().toISOString();
|
||||
|
||||
if (existing) {
|
||||
const values: Record<string, unknown> = { updatedAt };
|
||||
if (input.breachStartedAt !== undefined)
|
||||
values.breachStartedAt = input.breachStartedAt;
|
||||
if (input.lastFiredAt !== undefined)
|
||||
values.lastFiredAt = input.lastFiredAt;
|
||||
if (input.lastValue !== undefined) values.lastValue = input.lastValue;
|
||||
if (input.lastObservedState !== undefined)
|
||||
values.lastObservedState = input.lastObservedState;
|
||||
|
||||
await this.context.drizzle
|
||||
.update(automationTriggerState)
|
||||
.set(values)
|
||||
.where(
|
||||
and(
|
||||
eq(automationTriggerState.automationId, input.automationId),
|
||||
eq(automationTriggerState.stateKey, input.stateKey),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await this.context.drizzle.insert(automationTriggerState).values({
|
||||
automationId: input.automationId,
|
||||
stateKey: input.stateKey,
|
||||
breachStartedAt: input.breachStartedAt ?? null,
|
||||
lastFiredAt: input.lastFiredAt ?? null,
|
||||
lastValue: input.lastValue ?? null,
|
||||
lastObservedState: input.lastObservedState ?? null,
|
||||
updatedAt,
|
||||
});
|
||||
}
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async clearBreach(automationId: number, stateKey: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(automationTriggerState)
|
||||
.set({ breachStartedAt: null, updatedAt: new Date().toISOString() })
|
||||
.where(
|
||||
and(
|
||||
eq(automationTriggerState.automationId, automationId),
|
||||
eq(automationTriggerState.stateKey, stateKey),
|
||||
),
|
||||
);
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
/** Dwell windows the scheduler has to re-check without a fresh sample. */
|
||||
async listOpenBreaches(): Promise<TriggerStateRow[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(automationTriggerState)
|
||||
.where(sql`${automationTriggerState.breachStartedAt} IS NOT NULL`);
|
||||
return rows.map(mapTriggerStateRow);
|
||||
}
|
||||
|
||||
// --- schedules ---
|
||||
|
||||
async upsertSchedule(input: {
|
||||
automationId: number;
|
||||
cron: string | null;
|
||||
intervalSeconds: number | null;
|
||||
timezone: string | null;
|
||||
nextDueAt: string | null;
|
||||
}): Promise<void> {
|
||||
const existing = await this.context.drizzle
|
||||
.select({ id: automationSchedules.id })
|
||||
.from(automationSchedules)
|
||||
.where(eq(automationSchedules.automationId, input.automationId))
|
||||
.limit(1);
|
||||
|
||||
if (existing[0]) {
|
||||
await this.context.drizzle
|
||||
.update(automationSchedules)
|
||||
.set({
|
||||
cron: input.cron,
|
||||
intervalSeconds: input.intervalSeconds,
|
||||
timezone: input.timezone,
|
||||
nextDueAt: input.nextDueAt,
|
||||
})
|
||||
.where(eq(automationSchedules.automationId, input.automationId));
|
||||
} else {
|
||||
await this.context.drizzle.insert(automationSchedules).values(input);
|
||||
}
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteSchedule(automationId: number): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(automationSchedules)
|
||||
.where(eq(automationSchedules.automationId, automationId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
/** Schedules due at or before `now`, joined to their enabled automation. */
|
||||
async listDueSchedules(now: string): Promise<DueScheduleRow[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
automationId: automationSchedules.automationId,
|
||||
cron: automationSchedules.cron,
|
||||
intervalSeconds: automationSchedules.intervalSeconds,
|
||||
timezone: automationSchedules.timezone,
|
||||
nextDueAt: automationSchedules.nextDueAt,
|
||||
})
|
||||
.from(automationSchedules)
|
||||
.innerJoin(
|
||||
automations,
|
||||
eq(automations.id, automationSchedules.automationId),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(automations.enabled, true),
|
||||
lte(automationSchedules.nextDueAt, now),
|
||||
),
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async markScheduleTicked(
|
||||
automationId: number,
|
||||
nextDueAt: string | null,
|
||||
lastTickAt: string,
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(automationSchedules)
|
||||
.set({ nextDueAt, lastTickAt })
|
||||
.where(eq(automationSchedules.automationId, automationId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
// --- runs ---
|
||||
|
||||
async createRun(input: {
|
||||
automationId: number;
|
||||
userId: string;
|
||||
triggerType: string;
|
||||
triggerContext?: string | null;
|
||||
status: string;
|
||||
dryRun?: boolean;
|
||||
parentRunId?: number | null;
|
||||
now?: string;
|
||||
}): Promise<AutomationRunRow> {
|
||||
const [created] = await insertReturning(this.context, automationRuns, {
|
||||
automationId: input.automationId,
|
||||
userId: input.userId,
|
||||
triggerType: input.triggerType,
|
||||
triggerContext: input.triggerContext ?? null,
|
||||
status: input.status,
|
||||
startedAt: input.now ?? new Date().toISOString(),
|
||||
dryRun: input.dryRun ?? false,
|
||||
parentRunId: input.parentRunId ?? null,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return mapRunRow(created);
|
||||
}
|
||||
|
||||
async finishRun(
|
||||
runId: number,
|
||||
input: {
|
||||
status: string;
|
||||
error?: string | null;
|
||||
finishedAt?: string;
|
||||
durationMs?: number | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
const finishedAt = input.finishedAt ?? new Date().toISOString();
|
||||
await this.context.drizzle
|
||||
.update(automationRuns)
|
||||
.set({
|
||||
status: input.status,
|
||||
error: input.error ?? null,
|
||||
finishedAt,
|
||||
durationMs: input.durationMs ?? null,
|
||||
})
|
||||
.where(eq(automationRuns.id, runId));
|
||||
|
||||
const run = await this.context.drizzle
|
||||
.select({
|
||||
automationId: automationRuns.automationId,
|
||||
startedAt: automationRuns.startedAt,
|
||||
})
|
||||
.from(automationRuns)
|
||||
.where(eq(automationRuns.id, runId))
|
||||
.limit(1);
|
||||
|
||||
if (run[0]) {
|
||||
await this.context.drizzle
|
||||
.update(automations)
|
||||
.set({ lastRunAt: run[0].startedAt, lastRunStatus: input.status })
|
||||
.where(eq(automations.id, run[0].automationId));
|
||||
}
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async listRuns(
|
||||
userId: string,
|
||||
options: { automationId?: number; limit?: number; offset?: number } = {},
|
||||
): Promise<AutomationRunRow[]> {
|
||||
const limit = Math.min(Math.max(options.limit ?? 50, 1), 200);
|
||||
const offset = Math.max(options.offset ?? 0, 0);
|
||||
|
||||
const where = options.automationId
|
||||
? and(
|
||||
eq(automationRuns.userId, userId),
|
||||
eq(automationRuns.automationId, options.automationId),
|
||||
)
|
||||
: eq(automationRuns.userId, userId);
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
run: automationRuns,
|
||||
automationName: automations.name,
|
||||
})
|
||||
.from(automationRuns)
|
||||
.leftJoin(automations, eq(automations.id, automationRuns.automationId))
|
||||
.where(where)
|
||||
.orderBy(desc(automationRuns.startedAt), desc(automationRuns.id))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return rows.map((row) => ({
|
||||
...mapRunRow(row.run),
|
||||
automation_name: row.automationName ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
async findRunForUser(
|
||||
runId: number,
|
||||
userId: string,
|
||||
): Promise<AutomationRunRow | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(automationRuns)
|
||||
.where(
|
||||
and(eq(automationRuns.id, runId), eq(automationRuns.userId, userId)),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ? mapRunRow(rows[0]) : null;
|
||||
}
|
||||
|
||||
async countRunningFor(automationId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: automationRuns.id })
|
||||
.from(automationRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(automationRuns.automationId, automationId),
|
||||
eq(automationRuns.status, "running"),
|
||||
),
|
||||
);
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
// --- run steps ---
|
||||
|
||||
async createRunStep(input: {
|
||||
runId: number;
|
||||
stepIndex: number;
|
||||
stepId: string;
|
||||
stepType: string;
|
||||
status: string;
|
||||
now?: string;
|
||||
}): Promise<number> {
|
||||
const [created] = await insertReturning(this.context, automationRunSteps, {
|
||||
runId: input.runId,
|
||||
stepIndex: input.stepIndex,
|
||||
stepId: input.stepId,
|
||||
stepType: input.stepType,
|
||||
status: input.status,
|
||||
startedAt: input.now ?? new Date().toISOString(),
|
||||
});
|
||||
await this.afterWrite();
|
||||
return created.id;
|
||||
}
|
||||
|
||||
async finishRunStep(
|
||||
stepRowId: number,
|
||||
input: {
|
||||
status: string;
|
||||
output?: string | null;
|
||||
error?: string | null;
|
||||
truncated?: boolean;
|
||||
finishedAt?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(automationRunSteps)
|
||||
.set({
|
||||
status: input.status,
|
||||
output: input.output ?? null,
|
||||
error: input.error ?? null,
|
||||
truncated: input.truncated ?? false,
|
||||
finishedAt: input.finishedAt ?? new Date().toISOString(),
|
||||
})
|
||||
.where(eq(automationRunSteps.id, stepRowId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async listRunSteps(runId: number): Promise<AutomationRunStepRow[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(automationRunSteps)
|
||||
.where(eq(automationRunSteps.runId, runId))
|
||||
.orderBy(asc(automationRunSteps.stepIndex));
|
||||
return rows.map(mapRunStepRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims run history. Called from the scheduler's daily sweep rather than on
|
||||
* every write, which is what made the alert engine's pruning expensive.
|
||||
*/
|
||||
async pruneRunsOlderThan(days: number): Promise<number> {
|
||||
const cutoff = new Date(Date.now() - days * 86400000).toISOString();
|
||||
const result = await this.context.drizzle
|
||||
.delete(automationRuns)
|
||||
.where(lt(automationRuns.startedAt, cutoff));
|
||||
const deleted = rowsAffected(result);
|
||||
if (deleted > 0) await this.afterWrite();
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/** Marks runs left behind by a crash so they do not block concurrency. */
|
||||
async failStaleRunningRuns(olderThanIso: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.update(automationRuns)
|
||||
.set({
|
||||
status: "failed",
|
||||
error: "Interrupted by a server restart",
|
||||
finishedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(automationRuns.status, "running"),
|
||||
lt(automationRuns.startedAt, olderThanIso),
|
||||
),
|
||||
);
|
||||
const affected = rowsAffected(result);
|
||||
if (affected > 0) await this.afterWrite();
|
||||
return affected;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(automations)
|
||||
.where(eq(automations.userId, userId));
|
||||
const deleted = rowsAffected(result);
|
||||
if (deleted > 0) await this.afterWrite();
|
||||
return deleted;
|
||||
}
|
||||
|
||||
private async listChannelIds(automationId: number): Promise<number[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ channelId: automationChannels.channelId })
|
||||
.from(automationChannels)
|
||||
.where(eq(automationChannels.automationId, automationId));
|
||||
return rows.map((row) => row.channelId);
|
||||
}
|
||||
|
||||
private async replaceChannels(
|
||||
automationId: number,
|
||||
userId: string,
|
||||
channelIds: number[],
|
||||
): Promise<number[]> {
|
||||
await this.context.drizzle
|
||||
.delete(automationChannels)
|
||||
.where(eq(automationChannels.automationId, automationId));
|
||||
|
||||
if (channelIds.length === 0) return [];
|
||||
|
||||
// One lookup for the whole set instead of a query per channel.
|
||||
const owned = await this.context.drizzle
|
||||
.select({ id: notificationChannels.id })
|
||||
.from(notificationChannels)
|
||||
.where(
|
||||
and(
|
||||
eq(notificationChannels.userId, userId),
|
||||
inArray(notificationChannels.id, channelIds),
|
||||
),
|
||||
);
|
||||
|
||||
const linked = owned.map((row) => row.id);
|
||||
if (linked.length > 0) {
|
||||
await this.context.drizzle
|
||||
.insert(automationChannels)
|
||||
.values(linked.map((channelId) => ({ automationId, channelId })));
|
||||
}
|
||||
return linked;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
|
||||
function mapAutomationRow(
|
||||
row: AutomationRecord,
|
||||
channels: number[] = [],
|
||||
): AutomationRow {
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.userId,
|
||||
name: row.name,
|
||||
description: row.description ?? null,
|
||||
enabled: row.enabled ? 1 : 0,
|
||||
definition: row.definition,
|
||||
definition_version: row.definitionVersion,
|
||||
concurrency_policy: row.concurrencyPolicy,
|
||||
max_run_seconds: row.maxRunSeconds,
|
||||
dry_run: row.dryRun ? 1 : 0,
|
||||
last_run_at: row.lastRunAt ?? null,
|
||||
last_run_status: row.lastRunStatus ?? null,
|
||||
created_at: row.createdAt,
|
||||
updated_at: row.updatedAt,
|
||||
channels,
|
||||
};
|
||||
}
|
||||
|
||||
function mapEngineRow(row: AutomationRecord): AutomationEngineRow {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
name: row.name,
|
||||
enabled: !!row.enabled,
|
||||
definition: row.definition,
|
||||
concurrencyPolicy: row.concurrencyPolicy,
|
||||
maxRunSeconds: row.maxRunSeconds,
|
||||
dryRun: !!row.dryRun,
|
||||
};
|
||||
}
|
||||
|
||||
function mapTriggerStateRow(row: TriggerStateRecord): TriggerStateRow {
|
||||
return {
|
||||
automationId: row.automationId,
|
||||
stateKey: row.stateKey,
|
||||
breachStartedAt: row.breachStartedAt ?? null,
|
||||
lastFiredAt: row.lastFiredAt ?? null,
|
||||
lastValue: row.lastValue ?? null,
|
||||
lastObservedState: row.lastObservedState ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapRunRow(row: AutomationRunRecord): AutomationRunRow {
|
||||
return {
|
||||
id: row.id,
|
||||
automation_id: row.automationId,
|
||||
user_id: row.userId,
|
||||
trigger_type: row.triggerType,
|
||||
trigger_context: row.triggerContext ?? null,
|
||||
status: row.status,
|
||||
started_at: row.startedAt,
|
||||
finished_at: row.finishedAt ?? null,
|
||||
duration_ms: row.durationMs ?? null,
|
||||
error: row.error ?? null,
|
||||
dry_run: row.dryRun ? 1 : 0,
|
||||
parent_run_id: row.parentRunId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapRunStepRow(row: AutomationRunStepRecord): AutomationRunStepRow {
|
||||
return {
|
||||
id: row.id,
|
||||
run_id: row.runId,
|
||||
step_index: row.stepIndex,
|
||||
step_id: row.stepId,
|
||||
step_type: row.stepType,
|
||||
status: row.status,
|
||||
started_at: row.startedAt,
|
||||
finished_at: row.finishedAt ?? null,
|
||||
output: row.output ?? null,
|
||||
error: row.error ?? null,
|
||||
truncated: row.truncated ? 1 : 0,
|
||||
};
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import { and, asc, eq, sql } from "drizzle-orm";
|
||||
import { c2sTunnelPresets } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.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 insertReturning(this.context, c2sTunnelPresets, {
|
||||
userId,
|
||||
name: input.name,
|
||||
config: input.config,
|
||||
platform: input.platform ?? null,
|
||||
computerName: input.computerName ?? null,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async updateForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
updates: C2sTunnelPresetUpdateInput,
|
||||
): Promise<C2sTunnelPresetRecord | null> {
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
c2sTunnelPresets,
|
||||
{
|
||||
...updates,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
},
|
||||
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
|
||||
);
|
||||
|
||||
if (updated) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async deleteForUser(userId: string, id: number): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(c2sTunnelPresets)
|
||||
.where(
|
||||
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(c2sTunnelPresets)
|
||||
.where(eq(c2sTunnelPresets.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
import { collabRoomMembers, collabRooms, users } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { insertReturning } from "./returning.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
|
||||
export type CollabRoomRecord = typeof collabRooms.$inferSelect;
|
||||
export type CollabRoomMemberRecord = typeof collabRoomMembers.$inferSelect;
|
||||
|
||||
export type CollabRoomRole = "host" | "member";
|
||||
|
||||
export interface CollabRoomMemberWithUser {
|
||||
userId: string;
|
||||
username: string;
|
||||
roomRole: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CollabRoomStage {
|
||||
presenterUserId: string | null;
|
||||
stageProtocol: string | null;
|
||||
stageHostId: number | null;
|
||||
stageShareId: string | null;
|
||||
}
|
||||
|
||||
export class CollabRoomRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async createRoom(input: {
|
||||
id: string;
|
||||
name: string;
|
||||
ownerUserId: string;
|
||||
persistent: boolean;
|
||||
}): Promise<CollabRoomRecord> {
|
||||
const [created] = await insertReturning(this.context, collabRooms, {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
ownerUserId: input.ownerUserId,
|
||||
persistent: input.persistent,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<CollabRoomRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(collabRooms)
|
||||
.where(eq(collabRooms.id, id))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/** The live room whose stage points at this share, if any. */
|
||||
async findByStageShareId(shareId: string): Promise<CollabRoomRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(collabRooms)
|
||||
.where(
|
||||
and(eq(collabRooms.stageShareId, shareId), isNull(collabRooms.endedAt)),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findByGuestToken(token: string): Promise<CollabRoomRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(collabRooms)
|
||||
.where(
|
||||
and(eq(collabRooms.guestLinkToken, token), isNull(collabRooms.endedAt)),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async setGuestToken(roomId: string, token: string | null): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(collabRooms)
|
||||
.set({ guestLinkToken: token })
|
||||
.where(eq(collabRooms.id, roomId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async listForUser(userId: string): Promise<CollabRoomRecord[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ room: collabRooms })
|
||||
.from(collabRoomMembers)
|
||||
.innerJoin(collabRooms, eq(collabRoomMembers.roomId, collabRooms.id))
|
||||
.where(
|
||||
and(eq(collabRoomMembers.userId, userId), isNull(collabRooms.endedAt)),
|
||||
)
|
||||
.orderBy(desc(collabRooms.createdAt));
|
||||
return rows.map((row) => row.room);
|
||||
}
|
||||
|
||||
async findMember(
|
||||
roomId: string,
|
||||
userId: string,
|
||||
): Promise<CollabRoomMemberRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(collabRoomMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(collabRoomMembers.roomId, roomId),
|
||||
eq(collabRoomMembers.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async addMember(input: {
|
||||
roomId: string;
|
||||
userId: string;
|
||||
roomRole: CollabRoomRole;
|
||||
addedBy: string | null;
|
||||
}): Promise<boolean> {
|
||||
if (await this.findMember(input.roomId, input.userId)) return false;
|
||||
await this.context.drizzle.insert(collabRoomMembers).values({
|
||||
roomId: input.roomId,
|
||||
userId: input.userId,
|
||||
roomRole: input.roomRole,
|
||||
addedBy: input.addedBy,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async removeMember(roomId: string, userId: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(collabRoomMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(collabRoomMembers.roomId, roomId),
|
||||
eq(collabRoomMembers.userId, userId),
|
||||
),
|
||||
);
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async listMembers(roomId: string): Promise<CollabRoomMemberWithUser[]> {
|
||||
return this.context.drizzle
|
||||
.select({
|
||||
userId: collabRoomMembers.userId,
|
||||
username: users.username,
|
||||
roomRole: collabRoomMembers.roomRole,
|
||||
createdAt: collabRoomMembers.createdAt,
|
||||
})
|
||||
.from(collabRoomMembers)
|
||||
.innerJoin(users, eq(collabRoomMembers.userId, users.id))
|
||||
.where(eq(collabRoomMembers.roomId, roomId))
|
||||
.orderBy(users.username);
|
||||
}
|
||||
|
||||
async updateStage(roomId: string, stage: CollabRoomStage): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(collabRooms)
|
||||
.set(stage)
|
||||
.where(eq(collabRooms.id, roomId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async replaceStage(
|
||||
roomId: string,
|
||||
expectedShareId: string | null,
|
||||
stage: CollabRoomStage,
|
||||
): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.update(collabRooms)
|
||||
.set(stage)
|
||||
.where(
|
||||
and(
|
||||
eq(collabRooms.id, roomId),
|
||||
expectedShareId
|
||||
? eq(collabRooms.stageShareId, expectedShareId)
|
||||
: isNull(collabRooms.stageShareId),
|
||||
),
|
||||
);
|
||||
const changed = rowsAffected(result) > 0;
|
||||
if (changed) await this.afterWrite();
|
||||
return changed;
|
||||
}
|
||||
|
||||
async clearStage(roomId: string): Promise<void> {
|
||||
return this.updateStage(roomId, {
|
||||
presenterUserId: null,
|
||||
stageProtocol: null,
|
||||
stageHostId: null,
|
||||
stageShareId: null,
|
||||
});
|
||||
}
|
||||
|
||||
async endRoom(roomId: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(collabRooms)
|
||||
.set({
|
||||
endedAt: new Date().toISOString(),
|
||||
presenterUserId: null,
|
||||
stageProtocol: null,
|
||||
stageHostId: null,
|
||||
stageShareId: null,
|
||||
})
|
||||
.where(eq(collabRooms.id, roomId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteRoom(roomId: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(collabRooms)
|
||||
.where(eq(collabRooms.id, roomId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { commandHistory } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning } from "./returning.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 insertReturning(this.context, commandHistory, {
|
||||
userId,
|
||||
hostId,
|
||||
command,
|
||||
executedAt,
|
||||
});
|
||||
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 result = await this.context.drizzle
|
||||
.delete(commandHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(commandHistory.userId, userId),
|
||||
eq(commandHistory.hostId, hostId),
|
||||
eq(commandHistory.command, command),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async deleteByUserAndHost(userId: string, hostId: number): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(commandHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(commandHistory.userId, userId),
|
||||
eq(commandHistory.hostId, hostId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async deleteByHostId(hostId: number): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(commandHistory)
|
||||
.where(eq(commandHistory.hostId, hostId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async deleteByHostIds(hostIds: number[]): Promise<number> {
|
||||
if (hostIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const result = await this.context.drizzle
|
||||
.delete(commandHistory)
|
||||
.where(inArray(commandHistory.hostId, hostIds));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(commandHistory)
|
||||
.where(eq(commandHistory.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { dashboardServiceLinks } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import {
|
||||
deleteReturning,
|
||||
insertReturning,
|
||||
updateReturning,
|
||||
} from "./returning.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 insertReturning(
|
||||
this.context,
|
||||
dashboardServiceLinks,
|
||||
{
|
||||
syncId: randomUUID(),
|
||||
userId,
|
||||
label: input.label,
|
||||
url: input.url,
|
||||
order: nextOrder,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
},
|
||||
);
|
||||
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 updateReturning(
|
||||
this.context,
|
||||
dashboardServiceLinks,
|
||||
{ ...updates, updatedAt: new Date().toISOString() },
|
||||
and(
|
||||
eq(dashboardServiceLinks.id, id),
|
||||
eq(dashboardServiceLinks.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (updated) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async deleteForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<{ syncId: string | null } | null> {
|
||||
const rows = await deleteReturning(
|
||||
this.context,
|
||||
dashboardServiceLinks,
|
||||
and(
|
||||
eq(dashboardServiceLinks.id, id),
|
||||
eq(dashboardServiceLinks.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
await this.afterWrite();
|
||||
return { syncId: rows[0].syncId };
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(dashboardServiceLinks)
|
||||
.where(eq(dashboardServiceLinks.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { dismissedAlerts } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.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 result = await this.context.drizzle
|
||||
.delete(dismissedAlerts)
|
||||
.where(
|
||||
and(
|
||||
eq(dismissedAlerts.userId, userId),
|
||||
eq(dismissedAlerts.alertId, alertId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(dismissedAlerts)
|
||||
.where(eq(dismissedAlerts.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -3,68 +3,40 @@ import { getDb, getSqlite } from "../db/index.js";
|
||||
import { needsExplicitPersist, resolveDatabaseDialect } from "../db/dialect.js";
|
||||
import { primeSettingsCache, readCachedSetting } from "./settings-cache.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { WebauthnCredentialRepository } from "./webauthn-credential-repository.js";
|
||||
import { AiRepository } from "./ai-repository.js";
|
||||
import { AlertRepository } from "./alert-repository.js";
|
||||
import { AutomationRepository } from "./automation-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 { FleetRepository } from "./fleet-repository.js";
|
||||
import { FleetInventoryRepository } from "./fleet-inventory-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 { ProxmoxNodeHistoryRepository } from "./proxmox-node-history-repository.js";
|
||||
import { HostRepository } from "./host-repository.js";
|
||||
import { HostResolutionRepository } from "./host-resolution-repository.js";
|
||||
import { HostSidebarPreferenceRepository } from "./host-sidebar-preference-repository.js";
|
||||
import { CredentialSidebarPreferenceRepository } from "./credential-sidebar-preference-repository.js";
|
||||
import { UiPreferenceRepository } from "./ui-preference-repository.js";
|
||||
import { NetworkTopologyRepository } from "./network-topology-repository.js";
|
||||
import { OpenTabRepository } from "./open-tab-repository.js";
|
||||
import { OpksshTokenRepository } from "./opkssh-token-repository.js";
|
||||
import { PluginRepository } from "./plugin-repository.js";
|
||||
import { PluginStorageRepository } from "./plugin-storage-repository.js";
|
||||
import { PluginSettingsRepository } from "./plugin-settings-repository.js";
|
||||
import { HostDefaultsRepository } from "./host-defaults-repository.js";
|
||||
import { PluginMigrationRepository } from "./plugin-migration-repository.js";
|
||||
import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repository.js";
|
||||
import { PluginRegistryRepository } from "./plugin-registry-repository.js";
|
||||
import { PluginInstallCountRepository } from "./plugin-install-count-repository.js";
|
||||
import { UserAuthRepository } from "./user-auth-repository.js";
|
||||
import { RbacAccessRepository } from "./rbac-access-repository.js";
|
||||
import { RbacPermissionRepository } from "./rbac-permission-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 { SessionShareRepository } from "./session-share-repository.js";
|
||||
import { CollabRoomRepository } from "./collab-room-repository.js";
|
||||
import { SecretSourceRepository } from "./secret-source-repository.js";
|
||||
import { CredentialAccessRepository } from "./credential-access-repository.js";
|
||||
import { SharedCredentialSecretsRepository } from "./shared-credential-secrets-repository.js";
|
||||
import { FolderAccessRepository } from "./folder-access-repository.js";
|
||||
import { SettingsRepository } from "./settings-repository.js";
|
||||
import { HostProtocolAuthRepository } from "./host-protocol-auth-repository.js";
|
||||
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-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 { SyncTombstoneRepository } from "./sync-tombstone-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";
|
||||
import { WorkspaceRepository } from "./workspace-repository.js";
|
||||
|
||||
/**
|
||||
* The context every repository runs against.
|
||||
@@ -146,34 +118,6 @@ export function getCurrentSettingValue(key: string): string | null {
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
export function createCurrentWebauthnCredentialRepository(): WebauthnCredentialRepository {
|
||||
return new WebauthnCredentialRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("webauthn_credential_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentAiRepository(): AiRepository {
|
||||
return new AiRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("ai_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentAlertRepository(): AlertRepository {
|
||||
return new AlertRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("alert_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentAutomationRepository(): AutomationRepository {
|
||||
return new AutomationRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("automation_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentApiKeyRepository(): ApiKeyRepository {
|
||||
return new ApiKeyRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
@@ -188,20 +132,6 @@ export function createCurrentAuditLogRepository(): AuditLogRepository {
|
||||
);
|
||||
}
|
||||
|
||||
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(),
|
||||
@@ -209,62 +139,6 @@ export function createCurrentCredentialRepository(): CredentialRepository {
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentDashboardServiceLinkRepository(): DashboardServiceLinkRepository {
|
||||
return new DashboardServiceLinkRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("dashboard_service_link_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSyncTombstoneRepository(): SyncTombstoneRepository {
|
||||
return new SyncTombstoneRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("sync_tombstone_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 createCurrentFleetRepository(): FleetRepository {
|
||||
return new FleetRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("fleet_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentFleetInventoryRepository(): FleetInventoryRepository {
|
||||
return new FleetInventoryRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("fleet_inventory_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(),
|
||||
@@ -272,40 +146,6 @@ export function createCurrentHostFolderRepository(): HostFolderRepository {
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentHostHealthRepository(): HostHealthRepository {
|
||||
return new HostHealthRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("host_health_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentHostMetricsHistoryRepository(): HostMetricsHistoryRepository {
|
||||
return new HostMetricsHistoryRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryLazyWriteHook(
|
||||
"host_metrics_history_repository_write",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentHostMetricsPreferenceRepository(): HostMetricsPreferenceRepository {
|
||||
return new HostMetricsPreferenceRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook(
|
||||
"host_metrics_preference_repository_write",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentProxmoxNodeHistoryRepository(): ProxmoxNodeHistoryRepository {
|
||||
return new ProxmoxNodeHistoryRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryLazyWriteHook(
|
||||
"proxmox_node_history_repository_write",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentHostRepository(): HostRepository {
|
||||
return new HostRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
@@ -348,13 +188,6 @@ export function createCurrentUiPreferenceRepository(): UiPreferenceRepository {
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentNetworkTopologyRepository(): NetworkTopologyRepository {
|
||||
return new NetworkTopologyRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("network_topology_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentOpenTabRepository(): OpenTabRepository {
|
||||
return new OpenTabRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
@@ -362,13 +195,6 @@ export function createCurrentOpenTabRepository(): OpenTabRepository {
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentOpksshTokenRepository(): OpksshTokenRepository {
|
||||
return new OpksshTokenRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("opkssh_token_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentPluginRepository(): PluginRepository {
|
||||
return new PluginRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
@@ -376,6 +202,13 @@ export function createCurrentPluginRepository(): PluginRepository {
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentUserAuthRepository(): UserAuthRepository {
|
||||
return new UserAuthRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("user_auth_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentPluginPermissionGrantRepository(): PluginPermissionGrantRepository {
|
||||
return new PluginPermissionGrantRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
@@ -385,19 +218,38 @@ export function createCurrentPluginPermissionGrantRepository(): PluginPermission
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentPluginRegistryRepository(): PluginRegistryRepository {
|
||||
return new PluginRegistryRepository(
|
||||
export function createCurrentPluginStorageRepository(): PluginStorageRepository {
|
||||
return new PluginStorageRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("plugin_registry_repository_write"),
|
||||
createCurrentRepositoryWriteHook("plugin_storage_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentPluginInstallCountRepository(): PluginInstallCountRepository {
|
||||
return new PluginInstallCountRepository(
|
||||
export function createCurrentHostDefaultsRepository(): HostDefaultsRepository {
|
||||
return new HostDefaultsRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryLazyWriteHook(
|
||||
"plugin_install_count_repository_write",
|
||||
),
|
||||
createCurrentRepositoryWriteHook("host_defaults_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentPluginSettingsRepository(): PluginSettingsRepository {
|
||||
return new PluginSettingsRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("plugin_settings_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentPluginMigrationRepository(): PluginMigrationRepository {
|
||||
return new PluginMigrationRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("plugin_migration_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentRbacPermissionRepository(): RbacPermissionRepository {
|
||||
return new RbacPermissionRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("rbac_permission_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -422,13 +274,6 @@ export function createCurrentRoleRepository(): RoleRepository {
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSessionRecordingRepository(): SessionRecordingRepository {
|
||||
return new SessionRecordingRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("session_recording_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSessionRepository(): SessionRepository {
|
||||
return new SessionRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
@@ -436,20 +281,6 @@ export function createCurrentSessionRepository(): SessionRepository {
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentCollabRoomRepository(): CollabRoomRepository {
|
||||
return new CollabRoomRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("collab_room_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSessionShareRepository(): SessionShareRepository {
|
||||
return new SessionShareRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("session_share_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSettingsRepository(): SettingsRepository {
|
||||
return new SettingsRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
@@ -464,6 +295,13 @@ export function createCurrentSharedHostSecretsRepository(): SharedHostSecretsRep
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentHostProtocolAuthRepository(): HostProtocolAuthRepository {
|
||||
return new HostProtocolAuthRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("host_protocol_auth_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSharedHostAuthOverrideRepository(): SharedHostAuthOverrideRepository {
|
||||
return new SharedHostAuthOverrideRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
@@ -473,13 +311,6 @@ export function createCurrentSharedHostAuthOverrideRepository(): SharedHostAuthO
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSnippetRepository(): SnippetRepository {
|
||||
return new SnippetRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("snippet_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageRepository {
|
||||
return new SshCredentialUsageRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
@@ -487,41 +318,6 @@ export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageR
|
||||
);
|
||||
}
|
||||
|
||||
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(),
|
||||
@@ -570,34 +366,6 @@ export function createCurrentSharedCredentialSecretsRepository(): SharedCredenti
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSecretSourceRepository(): SecretSourceRepository {
|
||||
return new SecretSourceRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("secret_source_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"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentWorkspaceRepository(): WorkspaceRepository {
|
||||
return new WorkspaceRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("workspace_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the settings cache. Must run during startup on engines without a
|
||||
* synchronous read, before anything calls getCurrentSettingValue.
|
||||
|
||||
@@ -1,522 +0,0 @@
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
fileManagerPinned,
|
||||
fileManagerRecent,
|
||||
fileManagerShortcuts,
|
||||
} from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.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 result = await this.context.drizzle
|
||||
.delete(fileManagerRecent)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerRecent.userId, userId),
|
||||
eq(fileManagerRecent.hostId, input.hostId),
|
||||
eq(fileManagerRecent.path, input.path),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
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 result = await this.context.drizzle
|
||||
.delete(fileManagerPinned)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerPinned.userId, userId),
|
||||
eq(fileManagerPinned.hostId, input.hostId),
|
||||
eq(fileManagerPinned.path, input.path),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
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 result = await this.context.drizzle
|
||||
.delete(fileManagerShortcuts)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerShortcuts.userId, userId),
|
||||
eq(fileManagerShortcuts.hostId, input.hostId),
|
||||
eq(fileManagerShortcuts.path, input.path),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
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 result = await this.context.drizzle
|
||||
.delete(fileManagerRecent)
|
||||
.where(eq(fileManagerRecent.userId, userId));
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async deletePinnedByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(fileManagerPinned)
|
||||
.where(eq(fileManagerPinned.userId, userId));
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async deleteShortcutsByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(fileManagerShortcuts)
|
||||
.where(eq(fileManagerShortcuts.userId, userId));
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async deleteRecentByHostId(hostId: number): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(fileManagerRecent)
|
||||
.where(eq(fileManagerRecent.hostId, hostId));
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async deletePinnedByHostId(hostId: number): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(fileManagerPinned)
|
||||
.where(eq(fileManagerPinned.hostId, hostId));
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async deleteShortcutsByHostId(hostId: number): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(fileManagerShortcuts)
|
||||
.where(eq(fileManagerShortcuts.hostId, hostId));
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async deleteRecentByHostIds(hostIds: number[]): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(fileManagerRecent)
|
||||
.where(inArray(fileManagerRecent.hostId, hostIds));
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async deletePinnedByHostIds(hostIds: number[]): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(fileManagerPinned)
|
||||
.where(inArray(fileManagerPinned.hostId, hostIds));
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async deleteShortcutsByHostIds(hostIds: number[]): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(fileManagerShortcuts)
|
||||
.where(inArray(fileManagerShortcuts.hostId, hostIds));
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { fleetInventory } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { insertReturning, updateReturning } from "./returning.js";
|
||||
|
||||
export type FleetInventoryRecord = typeof fleetInventory.$inferSelect;
|
||||
|
||||
export interface FleetInventoryInput {
|
||||
osPrettyName: string | null;
|
||||
kernel: string | null;
|
||||
architecture: string | null;
|
||||
hostname: string | null;
|
||||
uptimeSeconds: number | null;
|
||||
ip: string | null;
|
||||
packageManager: string | null;
|
||||
}
|
||||
|
||||
export class FleetInventoryRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async upsert(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
input: FleetInventoryInput,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<FleetInventoryRecord> {
|
||||
const existing = await this.findOne(userId, hostId);
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
fleetInventory,
|
||||
{ ...input, collectedAt: now },
|
||||
eq(fleetInventory.id, existing.id),
|
||||
);
|
||||
await this.afterWrite();
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await insertReturning(this.context, fleetInventory, {
|
||||
userId,
|
||||
hostId,
|
||||
...input,
|
||||
collectedAt: now,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async findOne(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<FleetInventoryRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(fleetInventory)
|
||||
.where(
|
||||
and(
|
||||
eq(fleetInventory.userId, userId),
|
||||
eq(fleetInventory.hostId, hostId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async listForHosts(
|
||||
userId: string,
|
||||
hostIds: number[],
|
||||
): Promise<FleetInventoryRecord[]> {
|
||||
if (hostIds.length === 0) return [];
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(fleetInventory)
|
||||
.where(
|
||||
and(
|
||||
eq(fleetInventory.userId, userId),
|
||||
inArray(fleetInventory.hostId, hostIds),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { fleets, fleetMembers, hosts } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import {
|
||||
deleteReturning,
|
||||
insertReturning,
|
||||
updateReturning,
|
||||
} from "./returning.js";
|
||||
|
||||
export type FleetRecord = typeof fleets.$inferSelect;
|
||||
export type FleetMemberHostRecord = typeof hosts.$inferSelect;
|
||||
|
||||
export interface FleetCreateInput {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
tagRules?: string[];
|
||||
}
|
||||
|
||||
export interface FleetUpdateInput {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
tagRules?: string[];
|
||||
}
|
||||
|
||||
function parseTagRules(raw: string | null): string[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((t): t is string => typeof t === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export class FleetRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByUser(userId: string): Promise<FleetRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(fleets)
|
||||
.where(eq(fleets.userId, userId));
|
||||
}
|
||||
|
||||
async findById(userId: string, fleetId: number): Promise<FleetRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(fleets)
|
||||
.where(and(eq(fleets.id, fleetId), eq(fleets.userId, userId)))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async create(
|
||||
userId: string,
|
||||
input: FleetCreateInput,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<FleetRecord> {
|
||||
const [created] = await insertReturning(this.context, fleets, {
|
||||
userId,
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
color: input.color ?? null,
|
||||
icon: input.icon ?? null,
|
||||
tagRules: input.tagRules ? JSON.stringify(input.tagRules) : null,
|
||||
syncId: randomUUID(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async update(
|
||||
userId: string,
|
||||
fleetId: number,
|
||||
input: FleetUpdateInput,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<FleetRecord | null> {
|
||||
const existing = await this.findById(userId, fleetId);
|
||||
if (!existing) return null;
|
||||
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
fleets,
|
||||
{
|
||||
name: input.name ?? existing.name,
|
||||
description:
|
||||
input.description === undefined
|
||||
? existing.description
|
||||
: input.description,
|
||||
color: input.color === undefined ? existing.color : input.color,
|
||||
icon: input.icon === undefined ? existing.icon : input.icon,
|
||||
tagRules:
|
||||
input.tagRules === undefined
|
||||
? existing.tagRules
|
||||
: JSON.stringify(input.tagRules),
|
||||
updatedAt: now,
|
||||
},
|
||||
and(eq(fleets.id, fleetId), eq(fleets.userId, userId)),
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async delete(userId: string, fleetId: number): Promise<boolean> {
|
||||
const deleted = await deleteReturning(
|
||||
this.context,
|
||||
fleets,
|
||||
and(eq(fleets.id, fleetId), eq(fleets.userId, userId)),
|
||||
);
|
||||
|
||||
if (deleted.length > 0) {
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async addMember(
|
||||
fleetId: number,
|
||||
hostId: number,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
const existing = await this.context.drizzle
|
||||
.select({ id: fleetMembers.id })
|
||||
.from(fleetMembers)
|
||||
.where(
|
||||
and(eq(fleetMembers.fleetId, fleetId), eq(fleetMembers.hostId, hostId)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) return;
|
||||
|
||||
await this.context.drizzle.insert(fleetMembers).values({
|
||||
fleetId,
|
||||
hostId,
|
||||
addedAt: now,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async removeMember(fleetId: number, hostId: number): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(fleetMembers)
|
||||
.where(
|
||||
and(eq(fleetMembers.fleetId, fleetId), eq(fleetMembers.hostId, hostId)),
|
||||
);
|
||||
|
||||
const affected = rowsAffected(result) > 0;
|
||||
if (affected) await this.afterWrite();
|
||||
return affected;
|
||||
}
|
||||
|
||||
async listStaticMemberIds(fleetId: number): Promise<number[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ hostId: fleetMembers.hostId })
|
||||
.from(fleetMembers)
|
||||
.where(eq(fleetMembers.fleetId, fleetId));
|
||||
return rows.map((r) => r.hostId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective membership = static fleetMembers rows union hosts whose
|
||||
* comma-separated tags string intersects any tag in the fleet's tagRules.
|
||||
* Both sets are scoped to the fleet owner's hosts and deduplicated by id.
|
||||
*/
|
||||
async listEffectiveMembers(
|
||||
userId: string,
|
||||
fleetId: number,
|
||||
): Promise<FleetMemberHostRecord[]> {
|
||||
const fleet = await this.findById(userId, fleetId);
|
||||
if (!fleet) return [];
|
||||
|
||||
const staticIds = await this.listStaticMemberIds(fleetId);
|
||||
const tagRules = parseTagRules(fleet.tagRules);
|
||||
|
||||
const ownedHosts = await this.context.drizzle
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(eq(hosts.userId, userId));
|
||||
|
||||
const byId = new Map<number, FleetMemberHostRecord>();
|
||||
|
||||
for (const host of ownedHosts) {
|
||||
if (staticIds.includes(host.id)) {
|
||||
byId.set(host.id, host);
|
||||
continue;
|
||||
}
|
||||
if (tagRules.length === 0) continue;
|
||||
const hostTags = (host.tags ?? "")
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
if (hostTags.some((tag) => tagRules.includes(tag))) {
|
||||
byId.set(host.id, host);
|
||||
}
|
||||
}
|
||||
|
||||
// Static members can reference hosts the fleet owner no longer owns
|
||||
// (rare, but the FK cascade only fires on host delete, not transfer) -
|
||||
// filter those out rather than surface a partial/foreign row.
|
||||
return Array.from(byId.values());
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const userFleets = await this.listByUser(userId);
|
||||
const result = await this.context.drizzle
|
||||
.delete(fleets)
|
||||
.where(eq(fleets.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
return userFleets.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { homepageItems } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import {
|
||||
deleteReturning,
|
||||
insertReturning,
|
||||
updateReturning,
|
||||
} from "./returning.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 insertReturning(this.context, homepageItems, {
|
||||
syncId: randomUUID(),
|
||||
userId,
|
||||
typeId: input.typeId,
|
||||
title: input.title,
|
||||
config: input.config,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
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 updateReturning(
|
||||
this.context,
|
||||
homepageItems,
|
||||
{ ...updates, updatedAt },
|
||||
and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)),
|
||||
);
|
||||
|
||||
if (updated) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async deleteForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<{ syncId: string | null } | null> {
|
||||
const rows = await deleteReturning(
|
||||
this.context,
|
||||
homepageItems,
|
||||
and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)),
|
||||
);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
await this.afterWrite();
|
||||
return { syncId: rows[0].syncId };
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(homepageItems)
|
||||
.where(eq(homepageItems.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { homepageLayouts } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.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 insertReturning(this.context, homepageLayouts, {
|
||||
userId,
|
||||
layout,
|
||||
updatedAt,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
homepageLayouts,
|
||||
{ layout, updatedAt },
|
||||
eq(homepageLayouts.userId, userId),
|
||||
);
|
||||
await this.afterWrite();
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(homepageLayouts)
|
||||
.where(eq(homepageLayouts.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import { and, eq, inArray, type SQL } from "drizzle-orm";
|
||||
import { hostDefaults, hosts, sshFolders } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import type { HostDefaultsLevel } from "../../../types/host-defaults.js";
|
||||
|
||||
export type HostDefaultsRecord = typeof hostDefaults.$inferSelect;
|
||||
export type HostRow = typeof hosts.$inferSelect;
|
||||
|
||||
export interface HostDefaultsScope {
|
||||
level: HostDefaultsLevel;
|
||||
userId?: string | null;
|
||||
folderId?: number | null;
|
||||
}
|
||||
|
||||
export interface HostDefaultsEntry {
|
||||
namespace: string;
|
||||
key: string;
|
||||
/** JSON-encoded. */
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function scopeKeyFor(scope: HostDefaultsScope): string {
|
||||
if (scope.level === "admin") return "admin";
|
||||
if (scope.level === "user") return `u:${scope.userId}`;
|
||||
return `f:${scope.folderId}`;
|
||||
}
|
||||
|
||||
const CHUNK = 500;
|
||||
|
||||
function chunks<T>(items: T[], size = CHUNK): T[][] {
|
||||
const result: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += size) {
|
||||
result.push(items.slice(i, i + size));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Host defaults at every level, plus the reads and writes that apply them to
|
||||
* hosts in bulk. The resolution itself lives in hosts/defaults.
|
||||
*/
|
||||
export class HostDefaultsRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listScope(scope: HostDefaultsScope): Promise<HostDefaultsRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(hostDefaults)
|
||||
.where(eq(hostDefaults.scopeKey, scopeKeyFor(scope)));
|
||||
}
|
||||
|
||||
/** The admin rows, and every user and folder row the given users own. */
|
||||
async listForUsers(userIds: string[]): Promise<HostDefaultsRecord[]> {
|
||||
const admin = await this.context.drizzle
|
||||
.select()
|
||||
.from(hostDefaults)
|
||||
.where(eq(hostDefaults.level, "admin"));
|
||||
const own: HostDefaultsRecord[] = [];
|
||||
for (const part of chunks([...new Set(userIds)])) {
|
||||
own.push(
|
||||
...(await this.context.drizzle
|
||||
.select()
|
||||
.from(hostDefaults)
|
||||
.where(inArray(hostDefaults.userId, part))),
|
||||
);
|
||||
}
|
||||
return [...admin, ...own];
|
||||
}
|
||||
|
||||
/** The user's own and folder rows. */
|
||||
async listOwnedBy(userId: string): Promise<HostDefaultsRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(hostDefaults)
|
||||
.where(eq(hostDefaults.userId, userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets and clears keys at one level in one transaction. `set` values are
|
||||
* already JSON-encoded.
|
||||
*/
|
||||
async apply(
|
||||
scope: HostDefaultsScope,
|
||||
set: HostDefaultsEntry[],
|
||||
unset: Array<{ namespace: string; key: string }>,
|
||||
updatedBy: string | null,
|
||||
): Promise<void> {
|
||||
const scopeKey = scopeKeyFor(scope);
|
||||
const now = new Date().toISOString();
|
||||
const match = (namespace: string, key: string) =>
|
||||
and(
|
||||
eq(hostDefaults.scopeKey, scopeKey),
|
||||
eq(hostDefaults.namespace, namespace),
|
||||
eq(hostDefaults.key, key),
|
||||
)!;
|
||||
const touched = [...set, ...unset];
|
||||
if (touched.length === 0) return;
|
||||
|
||||
if (this.context.dialect === "sqlite") {
|
||||
// better-sqlite3 runs a transaction synchronously.
|
||||
const db = this.context.drizzle;
|
||||
db.transaction((tx) => {
|
||||
for (const entry of unset) {
|
||||
tx.delete(hostDefaults)
|
||||
.where(match(entry.namespace, entry.key))
|
||||
.run();
|
||||
}
|
||||
for (const entry of set) {
|
||||
const existing = tx
|
||||
.select({ id: hostDefaults.id })
|
||||
.from(hostDefaults)
|
||||
.where(match(entry.namespace, entry.key))
|
||||
.limit(1)
|
||||
.all();
|
||||
if (existing[0]) {
|
||||
tx.update(hostDefaults)
|
||||
.set({ value: entry.value, updatedBy, updatedAt: now })
|
||||
.where(eq(hostDefaults.id, existing[0].id))
|
||||
.run();
|
||||
} else {
|
||||
tx.insert(hostDefaults)
|
||||
.values({
|
||||
level: scope.level,
|
||||
scopeKey,
|
||||
userId: scope.level === "admin" ? null : (scope.userId ?? null),
|
||||
folderId:
|
||||
scope.level === "folder" ? (scope.folderId ?? null) : null,
|
||||
namespace: entry.namespace,
|
||||
key: entry.key,
|
||||
value: entry.value,
|
||||
updatedBy,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await this.context.drizzle.transaction(async (tx) => {
|
||||
for (const entry of unset) {
|
||||
await tx
|
||||
.delete(hostDefaults)
|
||||
.where(match(entry.namespace, entry.key));
|
||||
}
|
||||
for (const entry of set) {
|
||||
const existing = await tx
|
||||
.select({ id: hostDefaults.id })
|
||||
.from(hostDefaults)
|
||||
.where(match(entry.namespace, entry.key))
|
||||
.limit(1);
|
||||
if (existing[0]) {
|
||||
await tx
|
||||
.update(hostDefaults)
|
||||
.set({ value: entry.value, updatedBy, updatedAt: now })
|
||||
.where(eq(hostDefaults.id, existing[0].id));
|
||||
} else {
|
||||
await tx.insert(hostDefaults).values({
|
||||
level: scope.level,
|
||||
scopeKey,
|
||||
userId: scope.level === "admin" ? null : (scope.userId ?? null),
|
||||
folderId:
|
||||
scope.level === "folder" ? (scope.folderId ?? null) : null,
|
||||
namespace: entry.namespace,
|
||||
key: entry.key,
|
||||
value: entry.value,
|
||||
updatedBy,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
await this.onWrite?.();
|
||||
}
|
||||
|
||||
async deleteScope(scope: HostDefaultsScope): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(hostDefaults)
|
||||
.where(eq(hostDefaults.scopeKey, scopeKeyFor(scope)));
|
||||
const affected = rowsAffected(result);
|
||||
if (affected > 0) await this.onWrite?.();
|
||||
return affected;
|
||||
}
|
||||
|
||||
/** Folder ids and paths for a set of users. */
|
||||
async listFolders(
|
||||
userIds: string[],
|
||||
): Promise<Array<{ id: number; userId: string; name: string }>> {
|
||||
const rows: Array<{ id: number; userId: string; name: string }> = [];
|
||||
for (const part of chunks([...new Set(userIds)])) {
|
||||
rows.push(
|
||||
...(await this.context.drizzle
|
||||
.select({
|
||||
id: sshFolders.id,
|
||||
userId: sshFolders.userId,
|
||||
name: sshFolders.name,
|
||||
})
|
||||
.from(sshFolders)
|
||||
.where(inArray(sshFolders.userId, part))),
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
async findFolder(
|
||||
folderId: number,
|
||||
): Promise<{ id: number; userId: string; name: string } | null> {
|
||||
const [row] = await this.context.drizzle
|
||||
.select({
|
||||
id: sshFolders.id,
|
||||
userId: sshFolders.userId,
|
||||
name: sshFolders.name,
|
||||
})
|
||||
.from(sshFolders)
|
||||
.where(eq(sshFolders.id, folderId))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/** The folder row for a path, created when the folder only exists on hosts. */
|
||||
async ensureFolder(userId: string, name: string): Promise<number> {
|
||||
const [existing] = await this.context.drizzle
|
||||
.select({ id: sshFolders.id })
|
||||
.from(sshFolders)
|
||||
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
|
||||
.limit(1);
|
||||
if (existing) return existing.id;
|
||||
await this.context.drizzle.insert(sshFolders).values({ userId, name });
|
||||
const [created] = await this.context.drizzle
|
||||
.select({ id: sshFolders.id })
|
||||
.from(sshFolders)
|
||||
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
|
||||
.limit(1);
|
||||
await this.onWrite?.();
|
||||
return created.id;
|
||||
}
|
||||
|
||||
/** Host rows by id or by owner. Secrets are read as stored, never used. */
|
||||
async listHosts(filter: {
|
||||
hostIds?: number[];
|
||||
userIds?: string[];
|
||||
all?: boolean;
|
||||
}): Promise<HostRow[]> {
|
||||
const db = this.context.drizzle;
|
||||
if (filter.all) return db.select().from(hosts);
|
||||
const rows: HostRow[] = [];
|
||||
const conditions: SQL[] = [];
|
||||
for (const part of chunks(filter.hostIds ?? [])) {
|
||||
conditions.push(inArray(hosts.id, part));
|
||||
}
|
||||
for (const part of chunks(filter.userIds ?? [])) {
|
||||
conditions.push(inArray(hosts.userId, part));
|
||||
}
|
||||
for (const condition of conditions) {
|
||||
rows.push(...(await db.select().from(hosts).where(condition)));
|
||||
}
|
||||
const seen = new Set<number>();
|
||||
return rows.filter((row) => {
|
||||
if (seen.has(row.id)) return false;
|
||||
seen.add(row.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** Every host of the given owners, for walking sub-host chains. */
|
||||
async listHostPlacement(userIds: string[]): Promise<
|
||||
Array<{
|
||||
id: number;
|
||||
userId: string;
|
||||
folder: string | null;
|
||||
parentHostId: number | null;
|
||||
}>
|
||||
> {
|
||||
const rows: Array<{
|
||||
id: number;
|
||||
userId: string;
|
||||
folder: string | null;
|
||||
parentHostId: number | null;
|
||||
}> = [];
|
||||
for (const part of chunks([...new Set(userIds)])) {
|
||||
rows.push(
|
||||
...(await this.context.drizzle
|
||||
.select({
|
||||
id: hosts.id,
|
||||
userId: hosts.userId,
|
||||
folder: hosts.folder,
|
||||
parentHostId: hosts.parentHostId,
|
||||
})
|
||||
.from(hosts)
|
||||
.where(inArray(hosts.userId, part))),
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes column patches, one statement per host, in one transaction.
|
||||
* `updatedAt` is set on each, which is what sync looks for.
|
||||
*/
|
||||
async updateHosts(
|
||||
patches: Array<{ id: number; values: Partial<HostRow> }>,
|
||||
): Promise<void> {
|
||||
if (patches.length === 0) return;
|
||||
const now = new Date().toISOString();
|
||||
for (const part of chunks(patches)) {
|
||||
if (this.context.dialect === "sqlite") {
|
||||
this.context.drizzle.transaction((tx) => {
|
||||
for (const patch of part) {
|
||||
tx.update(hosts)
|
||||
.set({ ...patch.values, updatedAt: now })
|
||||
.where(eq(hosts.id, patch.id))
|
||||
.run();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await this.context.drizzle.transaction(async (tx) => {
|
||||
for (const patch of part) {
|
||||
await tx
|
||||
.update(hosts)
|
||||
.set({ ...patch.values, updatedAt: now })
|
||||
.where(eq(hosts.id, patch.id));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
await this.onWrite?.();
|
||||
}
|
||||
|
||||
/** Rows that name a folder, credential or host a user no longer has. */
|
||||
async deleteWhere(condition: SQL): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(hostDefaults)
|
||||
.where(condition);
|
||||
const affected = rowsAffected(result);
|
||||
if (affected > 0) await this.onWrite?.();
|
||||
return affected;
|
||||
}
|
||||
|
||||
async listByKey(
|
||||
namespace: string,
|
||||
key: string,
|
||||
): Promise<HostDefaultsRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(hostDefaults)
|
||||
.where(
|
||||
and(eq(hostDefaults.namespace, namespace), eq(hostDefaults.key, key)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,28 @@ export class HostFolderRepository {
|
||||
.where(eq(sshFolders.userId, userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a folder, its subfolders and every host in them on this device
|
||||
* only, or lets them sync again.
|
||||
*/
|
||||
async setLocalOnly(
|
||||
userId: string,
|
||||
folderName: string,
|
||||
localOnly: boolean,
|
||||
): Promise<void> {
|
||||
const inFolder = (col: SQLiteColumn) =>
|
||||
or(eq(col, folderName), like(col, `${folderName} / %`));
|
||||
await this.context.drizzle
|
||||
.update(sshFolders)
|
||||
.set({ localOnly })
|
||||
.where(and(eq(sshFolders.userId, userId), inFolder(sshFolders.name)));
|
||||
await this.context.drizzle
|
||||
.update(hosts)
|
||||
.set({ localOnly, updatedAt: new Date().toISOString() })
|
||||
.where(and(eq(hosts.userId, userId), inFolder(hosts.folder)));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async upsertMetadata(
|
||||
userId: string,
|
||||
name: string,
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
import { and, desc, eq, notInArray } from "drizzle-orm";
|
||||
import { hostHealthChecks, hostHealthHistory } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.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 updateReturning(
|
||||
this.context,
|
||||
hostHealthChecks,
|
||||
{ checks, intervalSeconds, updatedAt: now },
|
||||
eq(hostHealthChecks.id, existing.id),
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await insertReturning(this.context, hostHealthChecks, {
|
||||
userId,
|
||||
hostId,
|
||||
checks,
|
||||
intervalSeconds,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
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,
|
||||
})),
|
||||
);
|
||||
|
||||
await 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 historyResult = await this.context.drizzle
|
||||
.delete(hostHealthHistory)
|
||||
.where(eq(hostHealthHistory.userId, userId));
|
||||
|
||||
const result = await this.context.drizzle
|
||||
.delete(hostHealthChecks)
|
||||
.where(eq(hostHealthChecks.userId, userId));
|
||||
|
||||
if (rowsAffected(historyResult) > 0 || rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return {
|
||||
checksDeleted: rowsAffected(result),
|
||||
historyDeleted: rowsAffected(historyResult),
|
||||
};
|
||||
}
|
||||
|
||||
/** Keeps the newest `keep` rows for the host and drops the rest. */
|
||||
private async pruneHistory(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
keep: number,
|
||||
): Promise<void> {
|
||||
const scope = and(
|
||||
eq(hostHealthHistory.userId, userId),
|
||||
eq(hostHealthHistory.hostId, hostId),
|
||||
);
|
||||
|
||||
const retained = await this.context.drizzle
|
||||
.select({ id: hostHealthHistory.id })
|
||||
.from(hostHealthHistory)
|
||||
.where(scope)
|
||||
.orderBy(desc(hostHealthHistory.ts))
|
||||
.limit(keep);
|
||||
|
||||
// Nothing retained means nothing to keep back, so the scope alone is the
|
||||
// delete condition.
|
||||
await this.context.drizzle.delete(hostHealthHistory).where(
|
||||
retained.length
|
||||
? and(
|
||||
scope,
|
||||
notInArray(
|
||||
hostHealthHistory.id,
|
||||
retained.map((row) => row.id),
|
||||
),
|
||||
)
|
||||
: scope,
|
||||
);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { and, asc, eq, gte, lt, lte } from "drizzle-orm";
|
||||
import { hostMetricsHistory } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { sqlTimestampDaysAgo } from "./sql-timestamp.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();
|
||||
}
|
||||
|
||||
async pruneOlderThan(hostId: number, retentionDays: number): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(hostMetricsHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(hostMetricsHistory.hostId, hostId),
|
||||
lt(hostMetricsHistory.ts, sqlTimestampDaysAgo(retentionDays)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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?.();
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { hostMetricsPreferences, hosts } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.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 updateReturning(
|
||||
this.context,
|
||||
hostMetricsPreferences,
|
||||
{ layout, updatedAt: now },
|
||||
eq(hostMetricsPreferences.id, existing.id),
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await insertReturning(
|
||||
this.context,
|
||||
hostMetricsPreferences,
|
||||
{
|
||||
userId,
|
||||
hostId,
|
||||
layout,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async updateHostStatsConfig(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
statsConfig: string,
|
||||
): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.update(hosts)
|
||||
.set({ statsConfig })
|
||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)));
|
||||
|
||||
if (rowsAffected(result) === 0) return false;
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(hostMetricsPreferences)
|
||||
.where(eq(hostMetricsPreferences.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { hostProtocolAuth } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { FieldCrypto } from "../../utils/field-crypto.js";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
|
||||
export type HostProtocolAuthRecord = typeof hostProtocolAuth.$inferSelect;
|
||||
|
||||
/** A host's login for one plugin protocol, decrypted. */
|
||||
export interface HostProtocolLogin {
|
||||
protocol: string;
|
||||
authType: string;
|
||||
credentialId: number | null;
|
||||
username: string | null;
|
||||
password: string | null;
|
||||
/** Non-secret declared fields. */
|
||||
fields: Record<string, string>;
|
||||
/** Secret declared fields. */
|
||||
secretFields: Record<string, string>;
|
||||
}
|
||||
|
||||
/** The encryption context of a login's secret columns. */
|
||||
export function protocolAuthRecordId(hostId: number, protocol: string): string {
|
||||
return `host-protocol-auth-${hostId}-${protocol}`;
|
||||
}
|
||||
|
||||
function parseStringMap(value: string | null): Record<string, string> {
|
||||
if (!value) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, entry] of Object.entries(parsed)) {
|
||||
if (typeof entry === "string") out[key] = entry;
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function openField(
|
||||
value: string | null,
|
||||
dek: Buffer,
|
||||
recordId: string,
|
||||
fieldName: string,
|
||||
): string | null {
|
||||
if (!value) return null;
|
||||
if (!FieldCrypto.isEncrypted(value)) return value;
|
||||
try {
|
||||
return FieldCrypto.decryptField(value, dek, recordId, fieldName);
|
||||
} catch (error) {
|
||||
// A value that does not open is treated as missing, never passed on.
|
||||
databaseLogger.warn("A host protocol login could not be decrypted", {
|
||||
operation: "host_protocol_auth_decrypt",
|
||||
recordId,
|
||||
fieldName,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sealField(
|
||||
value: string | null,
|
||||
dek: Buffer,
|
||||
recordId: string,
|
||||
fieldName: string,
|
||||
): string | null {
|
||||
if (!value) return null;
|
||||
return FieldCrypto.encryptField(value, dek, recordId, fieldName);
|
||||
}
|
||||
|
||||
export function decryptProtocolLogin(
|
||||
row: HostProtocolAuthRecord,
|
||||
dek: Buffer,
|
||||
): HostProtocolLogin {
|
||||
const recordId = protocolAuthRecordId(row.hostId, row.protocol);
|
||||
const secrets = openField(row.secretFields, dek, recordId, "secretFields");
|
||||
return {
|
||||
protocol: row.protocol,
|
||||
authType: row.authType || "direct",
|
||||
credentialId: row.credentialId ?? null,
|
||||
username: row.username ?? null,
|
||||
password: openField(row.password, dek, recordId, "password"),
|
||||
fields: parseStringMap(row.fields),
|
||||
secretFields: parseStringMap(secrets),
|
||||
};
|
||||
}
|
||||
|
||||
export function encryptProtocolLogin(
|
||||
hostId: number,
|
||||
login: HostProtocolLogin,
|
||||
dek: Buffer,
|
||||
): Pick<
|
||||
HostProtocolAuthRecord,
|
||||
| "authType"
|
||||
| "credentialId"
|
||||
| "username"
|
||||
| "password"
|
||||
| "fields"
|
||||
| "secretFields"
|
||||
> {
|
||||
const recordId = protocolAuthRecordId(hostId, login.protocol);
|
||||
const secretFields = Object.keys(login.secretFields).length
|
||||
? JSON.stringify(login.secretFields)
|
||||
: null;
|
||||
return {
|
||||
authType: login.authType || "direct",
|
||||
credentialId: login.credentialId ?? null,
|
||||
username: login.username || null,
|
||||
password: sealField(login.password, dek, recordId, "password"),
|
||||
fields: Object.keys(login.fields).length
|
||||
? JSON.stringify(login.fields)
|
||||
: null,
|
||||
secretFields: sealField(secretFields, dek, recordId, "secretFields"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* host_protocol_auth: one login per host and plugin protocol, encrypted with
|
||||
* the host owner's data key.
|
||||
*/
|
||||
export class HostProtocolAuthRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listRowsForHosts(hostIds: number[]): Promise<HostProtocolAuthRecord[]> {
|
||||
const unique = Array.from(new Set(hostIds));
|
||||
if (unique.length === 0) return [];
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(hostProtocolAuth)
|
||||
.where(inArray(hostProtocolAuth.hostId, unique));
|
||||
}
|
||||
|
||||
async listRowsForHost(hostId: number): Promise<HostProtocolAuthRecord[]> {
|
||||
return this.listRowsForHosts([hostId]);
|
||||
}
|
||||
|
||||
async findRow(
|
||||
hostId: number,
|
||||
protocol: string,
|
||||
): Promise<HostProtocolAuthRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(hostProtocolAuth)
|
||||
.where(
|
||||
and(
|
||||
eq(hostProtocolAuth.hostId, hostId),
|
||||
eq(hostProtocolAuth.protocol, protocol),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async listForHost(hostId: number, dek: Buffer): Promise<HostProtocolLogin[]> {
|
||||
return (await this.listRowsForHost(hostId)).map((row) =>
|
||||
decryptProtocolLogin(row, dek),
|
||||
);
|
||||
}
|
||||
|
||||
async find(
|
||||
hostId: number,
|
||||
protocol: string,
|
||||
dek: Buffer,
|
||||
): Promise<HostProtocolLogin | null> {
|
||||
const row = await this.findRow(hostId, protocol);
|
||||
return row ? decryptProtocolLogin(row, dek) : null;
|
||||
}
|
||||
|
||||
async upsert(
|
||||
ownerId: string,
|
||||
hostId: number,
|
||||
login: HostProtocolLogin,
|
||||
dek: Buffer,
|
||||
): Promise<void> {
|
||||
const values = encryptProtocolLogin(hostId, login, dek);
|
||||
const existing = await this.findRow(hostId, login.protocol);
|
||||
if (existing) {
|
||||
await this.context.drizzle
|
||||
.update(hostProtocolAuth)
|
||||
.set({
|
||||
...values,
|
||||
userId: ownerId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(hostProtocolAuth.id, existing.id));
|
||||
} else {
|
||||
await this.context.drizzle.insert(hostProtocolAuth).values({
|
||||
...values,
|
||||
hostId,
|
||||
userId: ownerId,
|
||||
protocol: login.protocol,
|
||||
});
|
||||
}
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async delete(hostId: number, protocol: string): Promise<boolean> {
|
||||
const existing = await this.findRow(hostId, protocol);
|
||||
if (!existing) return false;
|
||||
await this.context.drizzle
|
||||
.delete(hostProtocolAuth)
|
||||
.where(eq(hostProtocolAuth.id, existing.id));
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The owner's hosts whose login for some protocol uses the credential. */
|
||||
async listHostIdsForCredential(
|
||||
ownerId: string,
|
||||
credentialId: number,
|
||||
): Promise<number[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ hostId: hostProtocolAuth.hostId })
|
||||
.from(hostProtocolAuth)
|
||||
.where(
|
||||
and(
|
||||
eq(hostProtocolAuth.userId, ownerId),
|
||||
eq(hostProtocolAuth.credentialId, credentialId),
|
||||
),
|
||||
);
|
||||
return Array.from(new Set(rows.map((row) => row.hostId)));
|
||||
}
|
||||
|
||||
/** Every login a user owns, for an export. */
|
||||
async listRowsForUser(userId: string): Promise<HostProtocolAuthRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(hostProtocolAuth)
|
||||
.where(eq(hostProtocolAuth.userId, userId));
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { hostAccess, hosts } from "../db/schema.js";
|
||||
import { hostAccess, hosts, pluginSettings } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
@@ -15,9 +15,7 @@ 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 {
|
||||
@@ -103,19 +101,6 @@ export class HostRepository {
|
||||
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()
|
||||
@@ -246,9 +231,7 @@ export class HostRepository {
|
||||
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)));
|
||||
@@ -338,6 +321,25 @@ export class HostRepository {
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
// Bulk path, so it never passes through deleteAccessForHost: clear the
|
||||
// host-scope plugin settings for these hosts before the rows go.
|
||||
const owned = await this.context.drizzle
|
||||
.select({ id: hosts.id })
|
||||
.from(hosts)
|
||||
.where(eq(hosts.userId, userId));
|
||||
|
||||
if (owned.length > 0) {
|
||||
await this.context.drizzle.delete(pluginSettings).where(
|
||||
and(
|
||||
eq(pluginSettings.scope, "host"),
|
||||
inArray(
|
||||
pluginSettings.scopeId,
|
||||
owned.map((row) => String(row.id)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.context.drizzle
|
||||
.delete(hosts)
|
||||
.where(eq(hosts.userId, userId));
|
||||
@@ -350,6 +352,18 @@ export class HostRepository {
|
||||
}
|
||||
|
||||
async deleteAccessForHost(hostId: number): Promise<number> {
|
||||
// plugin_settings.scope_id is polymorphic, so it has no foreign key to
|
||||
// ssh_data and the engine will not cascade it. Cleared alongside the
|
||||
// access rows, which every host delete path already goes through.
|
||||
await this.context.drizzle
|
||||
.delete(pluginSettings)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginSettings.scope, "host"),
|
||||
eq(pluginSettings.scopeId, String(hostId)),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await this.context.drizzle
|
||||
.delete(hostAccess)
|
||||
.where(eq(hostAccess.hostId, hostId));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, inArray, isNotNull } from "drizzle-orm";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { hosts, sshCredentials, sshFolders } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
@@ -15,10 +15,6 @@ export interface HostKeyVerificationRecord {
|
||||
export interface HostUpdateStateRecord {
|
||||
userId: string;
|
||||
credentialId: number | null;
|
||||
rdpCredentialId: number | null;
|
||||
vncCredentialId: number | null;
|
||||
telnetCredentialId: number | null;
|
||||
vaultProfileId: number | null;
|
||||
authType: string;
|
||||
parentHostId: number | null;
|
||||
folder: string | null;
|
||||
@@ -57,6 +53,17 @@ export type HostListRow = HostResolutionHostRecord & {
|
||||
expiresAt?: string | null;
|
||||
};
|
||||
|
||||
export interface HostStatusTargetRow {
|
||||
id: number;
|
||||
userId: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
connectionType: string;
|
||||
jumpHosts: string | null;
|
||||
statusCheckEnabled: boolean;
|
||||
statusCheckInterval: number | null;
|
||||
}
|
||||
|
||||
export class HostResolutionRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
@@ -132,10 +139,6 @@ export class HostResolutionRepository {
|
||||
.select({
|
||||
userId: hosts.userId,
|
||||
credentialId: hosts.credentialId,
|
||||
rdpCredentialId: hosts.rdpCredentialId,
|
||||
vncCredentialId: hosts.vncCredentialId,
|
||||
telnetCredentialId: hosts.telnetCredentialId,
|
||||
vaultProfileId: hosts.vaultProfileId,
|
||||
authType: hosts.authType,
|
||||
parentHostId: hosts.parentHostId,
|
||||
folder: hosts.folder,
|
||||
@@ -262,19 +265,36 @@ export class HostResolutionRepository {
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async listAllHosts(): Promise<HostResolutionHostRecord[]> {
|
||||
const rows = await this.context.drizzle.select().from(hosts);
|
||||
|
||||
return this.decryptManyByOwner("ssh_data", rows);
|
||||
/**
|
||||
* What the status checker needs, without decrypting anything: none of these
|
||||
* columns are encrypted. Filtered by owner, by id, or both.
|
||||
*/
|
||||
async listStatusTargets(filter: {
|
||||
userId?: string;
|
||||
hostIds?: number[];
|
||||
}): Promise<HostStatusTargetRow[]> {
|
||||
if (filter.hostIds && filter.hostIds.length === 0) return [];
|
||||
const conditions = [
|
||||
filter.userId ? eq(hosts.userId, filter.userId) : undefined,
|
||||
filter.hostIds ? inArray(hosts.id, filter.hostIds) : undefined,
|
||||
].filter((condition) => condition !== undefined);
|
||||
return this.context.drizzle
|
||||
.select({
|
||||
id: hosts.id,
|
||||
userId: hosts.userId,
|
||||
ip: hosts.ip,
|
||||
port: hosts.port,
|
||||
connectionType: hosts.connectionType,
|
||||
jumpHosts: hosts.jumpHosts,
|
||||
statusCheckEnabled: hosts.statusCheckEnabled,
|
||||
statusCheckInterval: hosts.statusCheckInterval,
|
||||
})
|
||||
.from(hosts)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined);
|
||||
}
|
||||
|
||||
async listHostsWithTunnelConnections(): Promise<HostResolutionHostRecord[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(
|
||||
and(eq(hosts.enableTunnel, true), isNotNull(hosts.tunnelConnections)),
|
||||
);
|
||||
async listAllHosts(): Promise<HostResolutionHostRecord[]> {
|
||||
const rows = await this.context.drizzle.select().from(hosts);
|
||||
|
||||
return this.decryptManyByOwner("ssh_data", rows);
|
||||
}
|
||||
@@ -326,6 +346,7 @@ export class HostResolutionRepository {
|
||||
hostKeyAlgorithm: algorithm,
|
||||
hostKeyFirstSeen: now,
|
||||
hostKeyLastVerified: now,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(hosts.id, hostId));
|
||||
await this.afterWrite();
|
||||
@@ -347,6 +368,7 @@ export class HostResolutionRepository {
|
||||
hostKeyAlgorithm: algorithm,
|
||||
hostKeyLastVerified: now,
|
||||
hostKeyChangedCount: currentChangeCount + 1,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(hosts.id, hostId));
|
||||
await this.afterWrite();
|
||||
@@ -358,7 +380,7 @@ export class HostResolutionRepository {
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(hosts)
|
||||
.set({ hostKeyLastVerified: now })
|
||||
.set({ hostKeyLastVerified: now, updatedAt: sql`CURRENT_TIMESTAMP` })
|
||||
.where(eq(hosts.id, hostId));
|
||||
await (this.onLazyWrite?.() ?? this.afterWrite());
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { networkTopology } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.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 result = await this.context.drizzle
|
||||
.delete(networkTopology)
|
||||
.where(eq(networkTopology.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { opksshTokens } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { upsert } from "./returning.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 upsert(
|
||||
this.context,
|
||||
opksshTokens,
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
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 result = await this.context.drizzle
|
||||
.update(opksshTokens)
|
||||
.set({ lastUsed })
|
||||
.where(
|
||||
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async deleteByUserAndHost(userId: string, hostId: number): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(opksshTokens)
|
||||
.where(
|
||||
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(opksshTokens)
|
||||
.where(eq(opksshTokens.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { pluginInstallCounts } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.js";
|
||||
|
||||
export type PluginInstallCountRecord = typeof pluginInstallCounts.$inferSelect;
|
||||
|
||||
export interface PluginInstallCountUpsertInput {
|
||||
pluginId: string;
|
||||
registryId: string;
|
||||
count: number;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export class PluginInstallCountRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByPlugin(pluginId: string): Promise<PluginInstallCountRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(pluginInstallCounts)
|
||||
.where(eq(pluginInstallCounts.pluginId, pluginId));
|
||||
}
|
||||
|
||||
async findByPluginAndRegistry(
|
||||
pluginId: string,
|
||||
registryId: string,
|
||||
): Promise<PluginInstallCountRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(pluginInstallCounts)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginInstallCounts.pluginId, pluginId),
|
||||
eq(pluginInstallCounts.registryId, registryId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async upsert(
|
||||
input: PluginInstallCountUpsertInput,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<PluginInstallCountRecord> {
|
||||
const existing = await this.findByPluginAndRegistry(
|
||||
input.pluginId,
|
||||
input.registryId,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
pluginInstallCounts,
|
||||
{
|
||||
count: input.count,
|
||||
source: input.source ?? existing.source,
|
||||
updatedAt: now,
|
||||
},
|
||||
eq(pluginInstallCounts.id, existing.id),
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await insertReturning(this.context, pluginInstallCounts, {
|
||||
pluginId: input.pluginId,
|
||||
registryId: input.registryId,
|
||||
count: input.count,
|
||||
source: input.source ?? "aggregate-telemetry",
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async deleteByPlugin(pluginId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(pluginInstallCounts)
|
||||
.where(eq(pluginInstallCounts.pluginId, pluginId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { pluginMigrations } from "../db/schema.js";
|
||||
import { insertReturning } from "./returning.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type PluginMigrationRecord = typeof pluginMigrations.$inferSelect;
|
||||
|
||||
/**
|
||||
* Which of a plugin's migrations have run.
|
||||
*
|
||||
* The checksum is the reason this is a table rather than a file marker: it
|
||||
* makes an applied migration immutable, so an edited one is caught instead of
|
||||
* leaving two installs with quietly different schemas.
|
||||
*/
|
||||
export class PluginMigrationRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByPlugin(pluginId: string): Promise<PluginMigrationRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(pluginMigrations)
|
||||
.where(eq(pluginMigrations.pluginId, pluginId));
|
||||
}
|
||||
|
||||
async record(
|
||||
pluginId: string,
|
||||
migrationId: string,
|
||||
checksum: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
await insertReturning(this.context, pluginMigrations, {
|
||||
pluginId,
|
||||
migrationId,
|
||||
checksum,
|
||||
appliedAt: now,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteByPlugin(pluginId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(pluginMigrations)
|
||||
.where(eq(pluginMigrations.pluginId, pluginId));
|
||||
|
||||
const affected = rowsAffected(result);
|
||||
if (affected > 0) await this.afterWrite();
|
||||
return affected;
|
||||
}
|
||||
|
||||
async has(pluginId: string, migrationId: string): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(pluginMigrations)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginMigrations.pluginId, pluginId),
|
||||
eq(pluginMigrations.migrationId, migrationId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,9 @@ export type PluginPermissionGrantRecord =
|
||||
export interface PluginPermissionGrantCreateInput {
|
||||
pluginId: string;
|
||||
capability: string;
|
||||
grantedBy: string;
|
||||
/** Null for a bundled grant, which no user made. */
|
||||
grantedBy: string | null;
|
||||
source?: "admin" | "bundled";
|
||||
}
|
||||
|
||||
export class PluginPermissionGrantRepository {
|
||||
@@ -55,6 +57,7 @@ export class PluginPermissionGrantRepository {
|
||||
pluginId: input.pluginId,
|
||||
capability: input.capability,
|
||||
grantedBy: input.grantedBy,
|
||||
source: input.source ?? "admin",
|
||||
grantedAt: now,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { pluginRegistries } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import {
|
||||
deleteReturning,
|
||||
insertReturning,
|
||||
updateReturning,
|
||||
} from "./returning.js";
|
||||
|
||||
export type PluginRegistryRecord = typeof pluginRegistries.$inferSelect;
|
||||
|
||||
export interface PluginRegistryCreateInput {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
kind?: string;
|
||||
enabled?: boolean;
|
||||
signingKey?: string | null;
|
||||
}
|
||||
|
||||
export interface PluginRegistryUpdateInput {
|
||||
name?: string;
|
||||
url?: string;
|
||||
kind?: string;
|
||||
enabled?: boolean;
|
||||
signingKey?: string | null;
|
||||
lastCheckedAt?: string | null;
|
||||
lastIndexHash?: string | null;
|
||||
}
|
||||
|
||||
export class PluginRegistryRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listAll(): Promise<PluginRegistryRecord[]> {
|
||||
return this.context.drizzle.select().from(pluginRegistries);
|
||||
}
|
||||
|
||||
async findById(registryId: string): Promise<PluginRegistryRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(pluginRegistries)
|
||||
.where(eq(pluginRegistries.id, registryId))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async create(
|
||||
input: PluginRegistryCreateInput,
|
||||
): Promise<PluginRegistryRecord> {
|
||||
const [created] = await insertReturning(this.context, pluginRegistries, {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
kind: input.kind ?? "community",
|
||||
enabled: input.enabled ?? true,
|
||||
signingKey: input.signingKey ?? null,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async update(
|
||||
registryId: string,
|
||||
input: PluginRegistryUpdateInput,
|
||||
): Promise<PluginRegistryRecord | null> {
|
||||
const existing = await this.findById(registryId);
|
||||
if (!existing) return null;
|
||||
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
pluginRegistries,
|
||||
{
|
||||
name: input.name ?? existing.name,
|
||||
url: input.url ?? existing.url,
|
||||
kind: input.kind ?? existing.kind,
|
||||
enabled: input.enabled ?? existing.enabled,
|
||||
signingKey:
|
||||
input.signingKey === undefined
|
||||
? existing.signingKey
|
||||
: input.signingKey,
|
||||
lastCheckedAt:
|
||||
input.lastCheckedAt === undefined
|
||||
? existing.lastCheckedAt
|
||||
: input.lastCheckedAt,
|
||||
lastIndexHash:
|
||||
input.lastIndexHash === undefined
|
||||
? existing.lastIndexHash
|
||||
: input.lastIndexHash,
|
||||
},
|
||||
eq(pluginRegistries.id, registryId),
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async delete(registryId: string): Promise<boolean> {
|
||||
const deleted = await deleteReturning(
|
||||
this.context,
|
||||
pluginRegistries,
|
||||
eq(pluginRegistries.id, registryId),
|
||||
);
|
||||
|
||||
if (deleted.length > 0) {
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async deleteAll(): Promise<number> {
|
||||
const all = await this.listAll();
|
||||
const result = await this.context.drizzle.delete(pluginRegistries);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
return all.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export interface PluginCreateInput {
|
||||
source?: string;
|
||||
registryId?: string | null;
|
||||
state?: string;
|
||||
lastError?: string | null;
|
||||
autoUpdate?: boolean;
|
||||
manifestJson: string;
|
||||
}
|
||||
@@ -29,6 +30,7 @@ export interface PluginUpdateInput {
|
||||
source?: string;
|
||||
registryId?: string | null;
|
||||
state?: string;
|
||||
lastError?: string | null;
|
||||
autoUpdate?: boolean;
|
||||
manifestJson?: string;
|
||||
}
|
||||
@@ -64,6 +66,7 @@ export class PluginRepository {
|
||||
source: input.source ?? "community",
|
||||
registryId: input.registryId ?? null,
|
||||
state: input.state ?? "disabled",
|
||||
lastError: input.lastError ?? null,
|
||||
autoUpdate: input.autoUpdate ?? false,
|
||||
manifestJson: input.manifestJson,
|
||||
installedAt: now,
|
||||
@@ -95,6 +98,8 @@ export class PluginRepository {
|
||||
? existing.registryId
|
||||
: input.registryId,
|
||||
state: input.state ?? existing.state,
|
||||
lastError:
|
||||
input.lastError === undefined ? existing.lastError : input.lastError,
|
||||
autoUpdate: input.autoUpdate ?? existing.autoUpdate,
|
||||
manifestJson: input.manifestJson ?? existing.manifestJson,
|
||||
updatedAt: now,
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||
import { pluginSettings } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.js";
|
||||
|
||||
export type PluginSettingsRecord = typeof pluginSettings.$inferSelect;
|
||||
|
||||
/**
|
||||
* "secret" holds ctx.secrets values, one row per user and key. The settings
|
||||
* service is driven by manifest fields, so it never reads them.
|
||||
*/
|
||||
export type PluginSettingsScope = "admin" | "user" | "host" | "secret";
|
||||
|
||||
/**
|
||||
* Values behind ctx.settings and the /plugins/:id/settings routes.
|
||||
*
|
||||
* scope_id is null for admin scope, so every lookup has to use IS NULL rather
|
||||
* than = NULL: SQL equality against null never matches, which would make an
|
||||
* admin setting look unset no matter how often it was written.
|
||||
*/
|
||||
export class PluginSettingsRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
private scopeMatch(
|
||||
pluginId: string,
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string | null,
|
||||
) {
|
||||
return and(
|
||||
eq(pluginSettings.pluginId, pluginId),
|
||||
eq(pluginSettings.scope, scope),
|
||||
scopeId === null
|
||||
? isNull(pluginSettings.scopeId)
|
||||
: eq(pluginSettings.scopeId, scopeId),
|
||||
);
|
||||
}
|
||||
|
||||
async get(
|
||||
pluginId: string,
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
): Promise<PluginSettingsRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(pluginSettings)
|
||||
.where(
|
||||
and(
|
||||
this.scopeMatch(pluginId, scope, scopeId),
|
||||
eq(pluginSettings.key, key),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async getAll(
|
||||
pluginId: string,
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string | null,
|
||||
): Promise<PluginSettingsRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(pluginSettings)
|
||||
.where(this.scopeMatch(pluginId, scope, scopeId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every row for a set of scope ids, in one query.
|
||||
*
|
||||
* The host list would otherwise run one query per host, which is the shape
|
||||
* that made other list endpoints slow at a few hundred hosts.
|
||||
*/
|
||||
async getAllForScopeIds(
|
||||
scope: PluginSettingsScope,
|
||||
scopeIds: string[],
|
||||
): Promise<PluginSettingsRecord[]> {
|
||||
if (scopeIds.length === 0) return [];
|
||||
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(pluginSettings)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginSettings.scope, scope),
|
||||
inArray(pluginSettings.scopeId, scopeIds),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every row across all scope ids for one plugin/scope/key, regardless of
|
||||
* owner. For a plugin's own background scan (auto-sync, polling) that needs
|
||||
* to find every host with a setting enabled, across every user, before it
|
||||
* knows which actor to run as.
|
||||
*/
|
||||
async listByKey(
|
||||
pluginId: string,
|
||||
scope: PluginSettingsScope,
|
||||
key: string,
|
||||
): Promise<PluginSettingsRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(pluginSettings)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginSettings.pluginId, pluginId),
|
||||
eq(pluginSettings.scope, scope),
|
||||
eq(pluginSettings.key, key),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async set(
|
||||
pluginId: string,
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
encrypted = false,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
const where = and(
|
||||
this.scopeMatch(pluginId, scope, scopeId),
|
||||
eq(pluginSettings.key, key),
|
||||
)!;
|
||||
|
||||
const existing = await this.context.drizzle
|
||||
.select()
|
||||
.from(pluginSettings)
|
||||
.where(where)
|
||||
.limit(1);
|
||||
|
||||
if (existing[0]) {
|
||||
await updateReturning(
|
||||
this.context,
|
||||
pluginSettings,
|
||||
{ value, encrypted, updatedAt: now },
|
||||
where,
|
||||
);
|
||||
} else {
|
||||
await insertReturning(this.context, pluginSettings, {
|
||||
pluginId,
|
||||
scope,
|
||||
scopeId,
|
||||
key,
|
||||
value,
|
||||
encrypted,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async delete(
|
||||
pluginId: string,
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(pluginSettings)
|
||||
.where(
|
||||
and(
|
||||
this.scopeMatch(pluginId, scope, scopeId),
|
||||
eq(pluginSettings.key, key),
|
||||
),
|
||||
);
|
||||
|
||||
const affected = rowsAffected(result) > 0;
|
||||
if (affected) await this.afterWrite();
|
||||
return affected;
|
||||
}
|
||||
|
||||
async deleteByPlugin(pluginId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(pluginSettings)
|
||||
.where(eq(pluginSettings.pluginId, pluginId));
|
||||
|
||||
const affected = rowsAffected(result);
|
||||
if (affected > 0) await this.afterWrite();
|
||||
return affected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes every plugin's rows for one user or host.
|
||||
*
|
||||
* scope_id carries no foreign key, so the user and host delete paths call
|
||||
* this instead of relying on the engine to cascade.
|
||||
*/
|
||||
async deleteByScope(
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string,
|
||||
): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(pluginSettings)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginSettings.scope, scope),
|
||||
eq(pluginSettings.scopeId, scopeId),
|
||||
),
|
||||
);
|
||||
|
||||
const affected = rowsAffected(result);
|
||||
if (affected > 0) await this.afterWrite();
|
||||
return affected;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { pluginStorage } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.js";
|
||||
|
||||
export type PluginStorageRecord = typeof pluginStorage.$inferSelect;
|
||||
|
||||
export class PluginStorageRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async get(pluginId: string, key: string): Promise<string | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(pluginStorage)
|
||||
.where(
|
||||
and(eq(pluginStorage.pluginId, pluginId), eq(pluginStorage.key, key)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0]?.value ?? null;
|
||||
}
|
||||
|
||||
async listKeys(pluginId: string): Promise<string[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(pluginStorage)
|
||||
.where(eq(pluginStorage.pluginId, pluginId));
|
||||
|
||||
return rows.map((row) => row.key);
|
||||
}
|
||||
|
||||
/** How many keys this plugin holds, for the per-plugin key cap. */
|
||||
async countKeys(pluginId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(pluginStorage)
|
||||
.where(eq(pluginStorage.pluginId, pluginId));
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async set(
|
||||
pluginId: string,
|
||||
key: string,
|
||||
value: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
const existing = await this.context.drizzle
|
||||
.select()
|
||||
.from(pluginStorage)
|
||||
.where(
|
||||
and(eq(pluginStorage.pluginId, pluginId), eq(pluginStorage.key, key)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing[0]) {
|
||||
await updateReturning(
|
||||
this.context,
|
||||
pluginStorage,
|
||||
{ value, updatedAt: now },
|
||||
and(eq(pluginStorage.pluginId, pluginId), eq(pluginStorage.key, key))!,
|
||||
);
|
||||
} else {
|
||||
await insertReturning(this.context, pluginStorage, {
|
||||
pluginId,
|
||||
key,
|
||||
value,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async delete(pluginId: string, key: string): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(pluginStorage)
|
||||
.where(
|
||||
and(eq(pluginStorage.pluginId, pluginId), eq(pluginStorage.key, key)),
|
||||
);
|
||||
|
||||
const affected = rowsAffected(result) > 0;
|
||||
if (affected) await this.afterWrite();
|
||||
return affected;
|
||||
}
|
||||
|
||||
async deleteByPlugin(pluginId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(pluginStorage)
|
||||
.where(eq(pluginStorage.pluginId, pluginId));
|
||||
|
||||
if (rowsAffected(result) > 0) await this.afterWrite();
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { and, asc, eq, gte, lt, lte } from "drizzle-orm";
|
||||
import { proxmoxNodeHistory } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { sqlTimestampDaysAgo } from "./sql-timestamp.js";
|
||||
|
||||
export type ProxmoxNodeHistoryRecord = typeof proxmoxNodeHistory.$inferSelect;
|
||||
|
||||
export interface ProxmoxNodeHistoryCreateInput {
|
||||
hostId: number;
|
||||
cpuPercent?: number | null;
|
||||
memPercent?: number | null;
|
||||
diskPercent?: number | null;
|
||||
netRxBytes?: number | null;
|
||||
netTxBytes?: number | null;
|
||||
}
|
||||
|
||||
export class ProxmoxNodeHistoryRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async create(input: ProxmoxNodeHistoryCreateInput): Promise<void> {
|
||||
await this.context.drizzle.insert(proxmoxNodeHistory).values({
|
||||
hostId: input.hostId,
|
||||
cpuPercent: input.cpuPercent,
|
||||
memPercent: input.memPercent,
|
||||
diskPercent: input.diskPercent,
|
||||
netRxBytes: input.netRxBytes,
|
||||
netTxBytes: input.netTxBytes,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async pruneOlderThan(hostId: number, retentionDays: number): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(proxmoxNodeHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(proxmoxNodeHistory.hostId, hostId),
|
||||
lt(proxmoxNodeHistory.ts, sqlTimestampDaysAgo(retentionDays)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async listRange(
|
||||
hostId: number,
|
||||
fromTs: string,
|
||||
toTs: string,
|
||||
): Promise<ProxmoxNodeHistoryRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(proxmoxNodeHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(proxmoxNodeHistory.hostId, hostId),
|
||||
gte(proxmoxNodeHistory.ts, fromTs),
|
||||
lte(proxmoxNodeHistory.ts, toTs),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(proxmoxNodeHistory.ts));
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
hosts,
|
||||
roles,
|
||||
sharedHostSecrets,
|
||||
snippetAccess,
|
||||
snippets,
|
||||
users,
|
||||
} from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
@@ -43,35 +41,9 @@ export interface RbacSharedHost {
|
||||
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;
|
||||
isNote: boolean;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -93,12 +65,6 @@ export type UpsertHostAccessInput = RbacAccessTarget & {
|
||||
expiresAt: string | null;
|
||||
};
|
||||
|
||||
export type UpsertSnippetAccessInput = RbacAccessTarget & {
|
||||
snippetId: number;
|
||||
grantedBy: string;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
|
||||
type RawAccessListItem = Omit<RbacAccessListItem, "targetType">;
|
||||
|
||||
function toAccessListItem(access: RawAccessListItem): RbacAccessListItem {
|
||||
@@ -249,76 +215,6 @@ export class RbacAccessRepository {
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
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 [created] = await insertReturning(this.context, snippetAccess, {
|
||||
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: created.id, 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[],
|
||||
@@ -371,146 +267,6 @@ export class RbacAccessRepository {
|
||||
.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,
|
||||
isNote: snippets.isNote,
|
||||
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,
|
||||
isNote: snippets.isNote,
|
||||
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> {
|
||||
@@ -569,9 +325,6 @@ export class RbacAccessRepository {
|
||||
.select({
|
||||
hostAccessId: hostAccess.id,
|
||||
credentialId: hosts.credentialId,
|
||||
rdpCredentialId: hosts.rdpCredentialId,
|
||||
vncCredentialId: hosts.vncCredentialId,
|
||||
telnetCredentialId: hosts.telnetCredentialId,
|
||||
hostId: hosts.id,
|
||||
hostOwnerId: hosts.userId,
|
||||
})
|
||||
@@ -670,17 +423,6 @@ export class RbacAccessRepository {
|
||||
);
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -698,23 +440,6 @@ export class RbacAccessRepository {
|
||||
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 { rbacKnownPermissions, rbacAppliedDefaults } from "../db/schema.js";
|
||||
import { insertReturning } from "./returning.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type RbacKnownPermissionRecord =
|
||||
typeof rbacKnownPermissions.$inferSelect;
|
||||
export type RbacAppliedDefaultRecord = typeof rbacAppliedDefaults.$inferSelect;
|
||||
|
||||
export interface KnownPermissionInput {
|
||||
permission: string;
|
||||
pluginId?: string | null;
|
||||
}
|
||||
|
||||
export interface AppliedDefaultInput {
|
||||
roleName: string;
|
||||
permission: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The two records that make plugin permissions outlive their plugin.
|
||||
*
|
||||
* `rbac_known_permissions` is why a role holding a plugin permission
|
||||
* (`<plugin>.use`) still saves while its plugin is disabled. `rbac_applied_defaults` is why a default an admin
|
||||
* revoked is not handed back on the next boot. Neither has a foreign key to
|
||||
* `plugins`, because both have to survive the plugin going away.
|
||||
*/
|
||||
export class RbacPermissionRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listKnown(): Promise<RbacKnownPermissionRecord[]> {
|
||||
return this.context.drizzle.select().from(rbacKnownPermissions);
|
||||
}
|
||||
|
||||
/** Inserts the ones that are new. Re-recording an existing one is a no-op. */
|
||||
async recordKnown(
|
||||
entries: KnownPermissionInput[],
|
||||
now = new Date().toISOString(),
|
||||
): Promise<number> {
|
||||
if (entries.length === 0) return 0;
|
||||
|
||||
const existing = new Set(
|
||||
(await this.listKnown()).map((row) => row.permission),
|
||||
);
|
||||
|
||||
let written = 0;
|
||||
for (const entry of entries) {
|
||||
if (existing.has(entry.permission)) continue;
|
||||
existing.add(entry.permission);
|
||||
|
||||
// Another boot can win the race on a shared database, and losing it just
|
||||
// means the row is already there.
|
||||
try {
|
||||
await insertReturning(this.context, rbacKnownPermissions, {
|
||||
permission: entry.permission,
|
||||
pluginId: entry.pluginId ?? null,
|
||||
firstSeenAt: now,
|
||||
});
|
||||
written += 1;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (written > 0) await this.afterWrite();
|
||||
return written;
|
||||
}
|
||||
|
||||
async listAppliedDefaults(): Promise<RbacAppliedDefaultRecord[]> {
|
||||
return this.context.drizzle.select().from(rbacAppliedDefaults);
|
||||
}
|
||||
|
||||
async recordAppliedDefaults(
|
||||
entries: AppliedDefaultInput[],
|
||||
now = new Date().toISOString(),
|
||||
): Promise<number> {
|
||||
if (entries.length === 0) return 0;
|
||||
|
||||
const existing = new Set(
|
||||
(await this.listAppliedDefaults()).map(
|
||||
(row) => `${row.roleName}:${row.permission}`,
|
||||
),
|
||||
);
|
||||
|
||||
let written = 0;
|
||||
for (const entry of entries) {
|
||||
const key = `${entry.roleName}:${entry.permission}`;
|
||||
if (existing.has(key)) continue;
|
||||
existing.add(key);
|
||||
|
||||
try {
|
||||
await insertReturning(this.context, rbacAppliedDefaults, {
|
||||
roleName: entry.roleName,
|
||||
permission: entry.permission,
|
||||
appliedAt: now,
|
||||
});
|
||||
written += 1;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (written > 0) await this.afterWrite();
|
||||
return written;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { desc, eq, or } from "drizzle-orm";
|
||||
import { secretSources } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { insertReturning } from "./returning.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { FieldCrypto } from "../../utils/field-crypto.js";
|
||||
|
||||
export type SecretSourceRecord = typeof secretSources.$inferSelect;
|
||||
export type SecretSourceKind = "onepassword-connect";
|
||||
|
||||
/** A row with the token still encrypted - safe to hand to the API. */
|
||||
export type SecretSourcePublic = Omit<SecretSourceRecord, "token"> & {
|
||||
hasToken: boolean;
|
||||
};
|
||||
|
||||
export interface SecretSourceCreateInput {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
kind: SecretSourceKind;
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
shared: boolean;
|
||||
}
|
||||
|
||||
export interface SecretSourceUpdateInput {
|
||||
name?: string;
|
||||
baseUrl?: string;
|
||||
token?: string;
|
||||
shared?: boolean;
|
||||
}
|
||||
|
||||
export function toPublicSecretSource(
|
||||
row: SecretSourceRecord,
|
||||
): SecretSourcePublic {
|
||||
const { token, ...rest } = row;
|
||||
return { ...rest, hasToken: token.length > 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* The token is encrypted with the owner's data key under the row id, like
|
||||
* vault_tokens - so a shared source only decrypts while its owner's key is
|
||||
* loaded, which the resolver reports as a clear error.
|
||||
*/
|
||||
export class SecretSourceRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
private encryptToken(id: string, ownerId: string, token: string): string {
|
||||
const key = DataCrypto.validateUserAccess(ownerId);
|
||||
return FieldCrypto.encryptField(token, key, id, "token");
|
||||
}
|
||||
|
||||
decryptToken(row: SecretSourceRecord): string {
|
||||
const key = DataCrypto.getUserDataKey(row.userId);
|
||||
if (!key) {
|
||||
throw new Error(
|
||||
"The secret source owner's data is locked; they need to sign in first",
|
||||
);
|
||||
}
|
||||
return FieldCrypto.decryptField(row.token, key, row.id, "token");
|
||||
}
|
||||
|
||||
async listVisibleToUser(userId: string): Promise<SecretSourceRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(secretSources)
|
||||
.where(
|
||||
or(eq(secretSources.userId, userId), eq(secretSources.shared, true)),
|
||||
)
|
||||
.orderBy(desc(secretSources.updatedAt));
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<SecretSourceRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(secretSources)
|
||||
.where(eq(secretSources.id, id))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async create(input: SecretSourceCreateInput): Promise<SecretSourceRecord> {
|
||||
const [created] = await insertReturning(this.context, secretSources, {
|
||||
id: input.id,
|
||||
userId: input.userId,
|
||||
name: input.name,
|
||||
kind: input.kind,
|
||||
baseUrl: input.baseUrl,
|
||||
token: this.encryptToken(input.id, input.userId, input.token),
|
||||
shared: input.shared,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async update(
|
||||
row: SecretSourceRecord,
|
||||
input: SecretSourceUpdateInput,
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(secretSources)
|
||||
.set({
|
||||
...(input.name !== undefined ? { name: input.name } : {}),
|
||||
...(input.baseUrl !== undefined ? { baseUrl: input.baseUrl } : {}),
|
||||
...(input.shared !== undefined ? { shared: input.shared } : {}),
|
||||
...(input.token !== undefined
|
||||
? { token: this.encryptToken(row.id, row.userId, input.token) }
|
||||
: {}),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(secretSources.id, row.id));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteById(id: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(secretSources)
|
||||
.where(eq(secretSources.id, id));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(secretSources)
|
||||
.where(eq(secretSources.userId, userId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
import { and, desc, eq, inArray, lt } from "drizzle-orm";
|
||||
import { hosts, sessionRecordings } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning } from "./returning.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 insertReturning(
|
||||
this.context,
|
||||
sessionRecordings,
|
||||
input,
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async updateEnded(
|
||||
id: number,
|
||||
input: {
|
||||
endedAt: string;
|
||||
duration: number | null;
|
||||
terminatedByOwner?: boolean;
|
||||
terminationReason?: string;
|
||||
},
|
||||
): 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 result = await this.context.drizzle
|
||||
.delete(sessionRecordings)
|
||||
.where(eq(sessionRecordings.id, id));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async deleteForUser(userId: string, id: number): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(sessionRecordings)
|
||||
.where(
|
||||
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detaches recordings from a user being deleted instead of removing them.
|
||||
* A recording is evidence about the host as much as about the person, and the
|
||||
* file stays on disk regardless — deleting only the row would orphan it.
|
||||
*/
|
||||
async anonymizeByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.update(sessionRecordings)
|
||||
.set({ userId: null })
|
||||
.where(eq(sessionRecordings.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(sessionRecordings)
|
||||
.where(eq(sessionRecordings.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async deleteByHostId(hostId: number): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(sessionRecordings)
|
||||
.where(eq(sessionRecordings.hostId, hostId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async deleteByHostIds(hostIds: number[]): Promise<number> {
|
||||
if (hostIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const result = await this.context.drizzle
|
||||
.delete(sessionRecordings)
|
||||
.where(inArray(sessionRecordings.hostId, hostIds));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
import { and, eq, gt, isNull, lt } from "drizzle-orm";
|
||||
import {
|
||||
hosts,
|
||||
sessionShareParticipants,
|
||||
sessionShares,
|
||||
users,
|
||||
} from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning } from "./returning.js";
|
||||
|
||||
export type SessionShareRecord = typeof sessionShares.$inferSelect;
|
||||
export type SessionShareParticipantRecord =
|
||||
typeof sessionShareParticipants.$inferSelect;
|
||||
|
||||
// "room" shares are minted internally by the collab-room routes and are
|
||||
// joinable by any member of the room whose stage references them.
|
||||
export type SessionShareType = "link" | "user" | "room";
|
||||
export type SessionSharePermissionLevel = "read-only" | "read-write";
|
||||
|
||||
export interface SessionShareCreateInput {
|
||||
id: string;
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
protocol: string;
|
||||
sessionId: string;
|
||||
tabInstanceId?: string | null;
|
||||
shareType: SessionShareType;
|
||||
targetUserId?: string | null;
|
||||
linkToken?: string | null;
|
||||
permissionLevel: SessionSharePermissionLevel;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface SessionShareWithHost extends SessionShareRecord {
|
||||
hostName: string | null;
|
||||
ownerUsername: string | null;
|
||||
}
|
||||
|
||||
export interface SharedWithMeRecord extends SessionShareRecord {
|
||||
hostName: string | null;
|
||||
ownerUsername: string | null;
|
||||
}
|
||||
|
||||
function activeShareFilter(now: string) {
|
||||
return and(isNull(sessionShares.revokedAt), gt(sessionShares.expiresAt, now));
|
||||
}
|
||||
|
||||
export class SessionShareRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async create(input: SessionShareCreateInput): Promise<SessionShareRecord> {
|
||||
const [created] = await insertReturning(this.context, sessionShares, {
|
||||
id: input.id,
|
||||
hostId: input.hostId,
|
||||
ownerUserId: input.ownerUserId,
|
||||
protocol: input.protocol,
|
||||
sessionId: input.sessionId,
|
||||
tabInstanceId: input.tabInstanceId ?? null,
|
||||
shareType: input.shareType,
|
||||
targetUserId: input.targetUserId ?? null,
|
||||
linkToken: input.linkToken ?? null,
|
||||
permissionLevel: input.permissionLevel,
|
||||
expiresAt: input.expiresAt,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<SessionShareRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(sessionShares)
|
||||
.where(eq(sessionShares.id, id))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findActiveById(
|
||||
id: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<SessionShareRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(sessionShares)
|
||||
.where(and(eq(sessionShares.id, id), activeShareFilter(now)))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findByLinkToken(
|
||||
linkToken: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<SessionShareRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(sessionShares)
|
||||
.where(
|
||||
and(eq(sessionShares.linkToken, linkToken), activeShareFilter(now)),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findActiveSharesForHost(
|
||||
hostId: number,
|
||||
ownerUserId: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<SessionShareRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(sessionShares)
|
||||
.where(
|
||||
and(
|
||||
eq(sessionShares.hostId, hostId),
|
||||
eq(sessionShares.ownerUserId, ownerUserId),
|
||||
activeShareFilter(now),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async findSharesTargetingUser(
|
||||
userId: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<SharedWithMeRecord[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
share: sessionShares,
|
||||
hostName: hosts.name,
|
||||
ownerUsername: users.username,
|
||||
})
|
||||
.from(sessionShares)
|
||||
.leftJoin(hosts, eq(sessionShares.hostId, hosts.id))
|
||||
.leftJoin(users, eq(sessionShares.ownerUserId, users.id))
|
||||
.where(
|
||||
and(
|
||||
eq(sessionShares.shareType, "user"),
|
||||
eq(sessionShares.targetUserId, userId),
|
||||
activeShareFilter(now),
|
||||
),
|
||||
);
|
||||
|
||||
return rows.map((row) => ({
|
||||
...row.share,
|
||||
hostName: row.hostName,
|
||||
ownerUsername: row.ownerUsername,
|
||||
}));
|
||||
}
|
||||
|
||||
async revoke(shareId: string, requestingUserId: string): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.update(sessionShares)
|
||||
.set({ revokedAt: new Date().toISOString() })
|
||||
.where(
|
||||
and(
|
||||
eq(sessionShares.id, shareId),
|
||||
eq(sessionShares.ownerUserId, requestingUserId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async revokeAsAdmin(shareId: string): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.update(sessionShares)
|
||||
.set({ revokedAt: new Date().toISOString() })
|
||||
.where(eq(sessionShares.id, shareId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async deleteExpiredShares(now = new Date().toISOString()): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(sessionShares)
|
||||
.where(lt(sessionShares.expiresAt, now));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async touchShareUsage(
|
||||
shareId: string,
|
||||
lastJoinedAt = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
const current = await this.findById(shareId);
|
||||
await this.context.drizzle
|
||||
.update(sessionShares)
|
||||
.set({
|
||||
lastJoinedAt,
|
||||
joinCount: (current?.joinCount ?? 0) + 1,
|
||||
})
|
||||
.where(eq(sessionShares.id, shareId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async recordParticipantJoin(
|
||||
shareId: string,
|
||||
userId: string | null,
|
||||
guestLabel: string | null,
|
||||
): Promise<SessionShareParticipantRecord> {
|
||||
const [created] = await insertReturning(
|
||||
this.context,
|
||||
sessionShareParticipants,
|
||||
{ shareId, userId, guestLabel },
|
||||
);
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async recordParticipantLeave(participantId: number): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(sessionShareParticipants)
|
||||
.set({ leftAt: new Date().toISOString() })
|
||||
.where(eq(sessionShareParticipants.id, participantId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteSharesForHost(hostId: number): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(sessionShares)
|
||||
.where(eq(sessionShares.hostId, hostId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
import { and, eq, inArray, or } from "drizzle-orm";
|
||||
import { hostAccess, hosts, sharedHostSecrets } from "../db/schema.js";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
hostAccess,
|
||||
hostProtocolAuth,
|
||||
hosts,
|
||||
sharedHostSecrets,
|
||||
} from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
|
||||
export type SharedHostSecretRecord = typeof sharedHostSecrets.$inferSelect;
|
||||
export type NewSharedHostSecretRecord = typeof sharedHostSecrets.$inferInsert;
|
||||
|
||||
export type ShareProtocol = "ssh" | "rdp" | "vnc" | "telnet";
|
||||
/** "ssh", or a protocol id a plugin declares. */
|
||||
export type ShareProtocol = string;
|
||||
|
||||
export class SharedHostSecretsRepository {
|
||||
constructor(
|
||||
@@ -178,19 +184,20 @@ export class SharedHostSecretsRepository {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: hosts.id })
|
||||
.from(hosts)
|
||||
.where(
|
||||
and(eq(hosts.userId, ownerId), eq(hosts.credentialId, credentialId)),
|
||||
);
|
||||
const protocolRows = await this.context.drizzle
|
||||
.select({ id: hostProtocolAuth.hostId })
|
||||
.from(hostProtocolAuth)
|
||||
.where(
|
||||
and(
|
||||
eq(hosts.userId, ownerId),
|
||||
or(
|
||||
eq(hosts.credentialId, credentialId),
|
||||
eq(hosts.rdpCredentialId, credentialId),
|
||||
eq(hosts.vncCredentialId, credentialId),
|
||||
eq(hosts.telnetCredentialId, credentialId),
|
||||
),
|
||||
eq(hostProtocolAuth.userId, ownerId),
|
||||
eq(hostProtocolAuth.credentialId, credentialId),
|
||||
),
|
||||
);
|
||||
|
||||
return rows.map((row) => row.id);
|
||||
return Array.from(new Set([...rows, ...protocolRows].map((row) => row.id)));
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
|
||||
@@ -1,575 +0,0 @@
|
||||
import { and, asc, eq, like, or, sql } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { snippetFolders, snippets } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import {
|
||||
deleteReturning,
|
||||
insertReturning,
|
||||
updateReturning,
|
||||
} from "./returning.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;
|
||||
isNote?: boolean;
|
||||
}
|
||||
export interface SnippetUpdateInput {
|
||||
name?: string;
|
||||
content?: string;
|
||||
description?: string | null;
|
||||
folder?: string | null;
|
||||
order?: number;
|
||||
hostFilter?: unknown;
|
||||
isNote?: boolean;
|
||||
}
|
||||
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))
|
||||
// coalesce, not asc(folder): folder is nullable, and NULLs sort first on
|
||||
// SQLite and MySQL but last on Postgres. An export whose row order depends
|
||||
// on the engine is not much of an export.
|
||||
.orderBy(sql`coalesce(${snippets.folder}, '')`, asc(snippets.order))
|
||||
);
|
||||
}
|
||||
|
||||
async listFoldersForExport(userId: string): Promise<SnippetFolderRecord[]> {
|
||||
return this.listFolders(userId);
|
||||
}
|
||||
|
||||
/** Snippets in a folder or any of its subfolders ("Parent / Child"). */
|
||||
async listOwnedSnippetsInFolder(
|
||||
userId: string,
|
||||
folder: string,
|
||||
): Promise<SnippetRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(
|
||||
and(
|
||||
eq(snippets.userId, userId),
|
||||
or(
|
||||
eq(snippets.folder, folder),
|
||||
like(snippets.folder, `${folder} / %`),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 insertReturning(this.context, snippets, {
|
||||
syncId: randomUUID(),
|
||||
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,
|
||||
isNote: input.isNote ?? false,
|
||||
});
|
||||
|
||||
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;
|
||||
isNote: boolean;
|
||||
}> = {
|
||||
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;
|
||||
if (input.isNote !== undefined) updateFields.isNote = input.isNote;
|
||||
|
||||
const rows = await updateReturning(
|
||||
this.context,
|
||||
snippets,
|
||||
updateFields,
|
||||
and(eq(snippets.id, snippetId), eq(snippets.userId, userId)),
|
||||
);
|
||||
|
||||
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 snippetResult = await this.context.drizzle
|
||||
.delete(snippets)
|
||||
.where(eq(snippets.userId, userId));
|
||||
|
||||
const result = await this.context.drizzle
|
||||
.delete(snippetFolders)
|
||||
.where(eq(snippetFolders.userId, userId));
|
||||
|
||||
if (rowsAffected(snippetResult) > 0 || rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return {
|
||||
snippetsDeleted: rowsAffected(snippetResult),
|
||||
foldersDeleted: rowsAffected(result),
|
||||
};
|
||||
}
|
||||
|
||||
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({
|
||||
syncId: randomUUID(),
|
||||
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 insertReturning(this.context, snippetFolders, {
|
||||
syncId: randomUUID(),
|
||||
userId,
|
||||
name: name.trim(),
|
||||
color: color?.trim() || null,
|
||||
icon: icon?.trim() || null,
|
||||
});
|
||||
|
||||
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 updateReturning(
|
||||
this.context,
|
||||
snippetFolders,
|
||||
updateFields,
|
||||
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
|
||||
);
|
||||
|
||||
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<{ syncId: string | null } | null> {
|
||||
await this.context.drizzle
|
||||
.update(snippets)
|
||||
.set({ folder: null })
|
||||
.where(and(eq(snippets.userId, userId), eq(snippets.folder, name)));
|
||||
|
||||
const rows = await deleteReturning(
|
||||
this.context,
|
||||
snippetFolders,
|
||||
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0] ? { syncId: rows[0].syncId } : null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { ssoProviders, users } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.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 insertReturning(this.context, ssoProviders, provider);
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async update(
|
||||
id: number,
|
||||
update: SsoProviderUpdate,
|
||||
): Promise<SsoProviderRecord | null> {
|
||||
const rows = await updateReturning(
|
||||
this.context,
|
||||
ssoProviders,
|
||||
update,
|
||||
eq(ssoProviders.id, id),
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async delete(id: number): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(ssoProviders)
|
||||
.where(eq(ssoProviders.id, id));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 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?.();
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { syncTombstones } from "../db/schema.js";
|
||||
import { timestampAtOrAfter } from "../sync-timestamp.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type SyncTombstoneRecord = typeof syncTombstones.$inferSelect;
|
||||
|
||||
export type SyncEntityType =
|
||||
| "hosts"
|
||||
| "sshCredentials"
|
||||
| "sshFolders"
|
||||
| "snippets"
|
||||
| "snippetFolders"
|
||||
| "vaultProfiles"
|
||||
| "dashboardServiceLinks"
|
||||
| "homepageItems"
|
||||
| "userPreferences";
|
||||
|
||||
export class SyncTombstoneRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async record(
|
||||
userId: string,
|
||||
entityType: SyncEntityType,
|
||||
syncId: string,
|
||||
): Promise<void> {
|
||||
if (!syncId) return;
|
||||
await this.context.drizzle.insert(syncTombstones).values({
|
||||
userId,
|
||||
entityType,
|
||||
syncId,
|
||||
});
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async recordMany(
|
||||
userId: string,
|
||||
entityType: SyncEntityType,
|
||||
syncIds: string[],
|
||||
): Promise<void> {
|
||||
const rows = syncIds.filter(Boolean);
|
||||
if (rows.length === 0) return;
|
||||
await this.context.drizzle
|
||||
.insert(syncTombstones)
|
||||
.values(rows.map((syncId) => ({ userId, entityType, syncId })));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async listSince(
|
||||
userId: string,
|
||||
entityType: SyncEntityType,
|
||||
since: string | null,
|
||||
): Promise<SyncTombstoneRecord[]> {
|
||||
const conditions = [
|
||||
eq(syncTombstones.userId, userId),
|
||||
eq(syncTombstones.entityType, entityType),
|
||||
];
|
||||
if (since)
|
||||
conditions.push(timestampAtOrAfter(syncTombstones.deletedAt, since));
|
||||
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(syncTombstones)
|
||||
.where(and(...conditions));
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
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";
|
||||
import {
|
||||
insertedId,
|
||||
rowsAffected,
|
||||
supportsReturning,
|
||||
} from "./mutation-result.js";
|
||||
import { updateReturning } from "./returning.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 = await this.insertThenEncrypt(userId, ca, userDataKey);
|
||||
|
||||
await this.afterWrite();
|
||||
return DataCrypto.decryptRecord(
|
||||
"termix_identity_ca",
|
||||
result,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a CA in two steps, because the ciphertext depends on the id.
|
||||
*
|
||||
* The private key is encrypted with the row's own id as context, which does
|
||||
* not exist until the row does. So: insert with an empty key, encrypt, update.
|
||||
* The empty key must never be observable, hence the transaction.
|
||||
*
|
||||
* Two branches because better-sqlite3 rejects an async transaction callback —
|
||||
* see the same note in UserRepository.
|
||||
*/
|
||||
private async insertThenEncrypt(
|
||||
userId: string,
|
||||
ca: NewTermixIdentityCaRecord,
|
||||
userDataKey: Buffer,
|
||||
): Promise<TermixIdentityCaRecord> {
|
||||
const draft = { ...ca, privateKey: "" };
|
||||
|
||||
const seal = (id: number) =>
|
||||
DataCrypto.encryptRecord(
|
||||
"termix_identity_ca",
|
||||
{ id, privateKey: ca.privateKey },
|
||||
userId,
|
||||
userDataKey,
|
||||
).privateKey;
|
||||
|
||||
if (this.context.dialect === "sqlite") {
|
||||
/* eslint-disable no-restricted-syntax -- sqlite-only branch: the dialect
|
||||
is checked directly above, and better-sqlite3 needs the synchronous
|
||||
.all() form, which has no async equivalent. */
|
||||
return this.context.drizzle.transaction((tx) => {
|
||||
const row = tx
|
||||
.insert(termixIdentityCa)
|
||||
.values(draft)
|
||||
.returning()
|
||||
.all()[0];
|
||||
return tx
|
||||
.update(termixIdentityCa)
|
||||
.set({ privateKey: seal(row.id) })
|
||||
.where(eq(termixIdentityCa.id, row.id))
|
||||
.returning()
|
||||
.all()[0];
|
||||
});
|
||||
/* eslint-enable no-restricted-syntax */
|
||||
}
|
||||
|
||||
return this.context.drizzle.transaction(async (tx) => {
|
||||
let id: number | null;
|
||||
if (supportsReturning(this.context.dialect)) {
|
||||
// eslint-disable-next-line no-restricted-syntax -- guarded by the check above
|
||||
const rows = await tx
|
||||
.insert(termixIdentityCa)
|
||||
.values(draft)
|
||||
.returning();
|
||||
id = rows[0]?.id ?? null;
|
||||
} else {
|
||||
id = insertedId(await tx.insert(termixIdentityCa).values(draft));
|
||||
}
|
||||
|
||||
if (id === null) {
|
||||
throw new Error("Insert into termix_identity_ca returned no id.");
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(termixIdentityCa)
|
||||
.set({ privateKey: seal(id) })
|
||||
.where(eq(termixIdentityCa.id, id));
|
||||
|
||||
const [row] = await tx
|
||||
.select()
|
||||
.from(termixIdentityCa)
|
||||
.where(eq(termixIdentityCa.id, id));
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
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 updateReturning(
|
||||
this.context,
|
||||
termixIdentityCa,
|
||||
{
|
||||
...update,
|
||||
...(encryptedPrivateKey ? { privateKey: encryptedPrivateKey } : {}),
|
||||
},
|
||||
eq(termixIdentityCa.identityId, identityId),
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
return this.decryptOne(rows[0] ?? null, userId);
|
||||
}
|
||||
|
||||
async deleteByIdentityId(identityId: number): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(termixIdentityCa)
|
||||
.where(eq(termixIdentityCa.identityId, identityId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(termixIdentityCa)
|
||||
.where(eq(termixIdentityCa.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
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?.();
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { termixIdentities, termixIdentityKeys } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.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 insertReturning(
|
||||
this.context,
|
||||
termixIdentities,
|
||||
identity,
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async updateIdentityForUser(
|
||||
userId: string,
|
||||
update: TermixIdentityUpdate,
|
||||
): Promise<TermixIdentityRecord | null> {
|
||||
const rows = await updateReturning(
|
||||
this.context,
|
||||
termixIdentities,
|
||||
update,
|
||||
eq(termixIdentities.userId, userId),
|
||||
);
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async deleteIdentityForUser(userId: string): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(termixIdentities)
|
||||
.where(eq(termixIdentities.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<{
|
||||
identitiesDeleted: number;
|
||||
keysDeleted: number;
|
||||
}> {
|
||||
const keyResult = await this.context.drizzle
|
||||
.delete(termixIdentityKeys)
|
||||
.where(eq(termixIdentityKeys.userId, userId));
|
||||
|
||||
const result = await this.context.drizzle
|
||||
.delete(termixIdentities)
|
||||
.where(eq(termixIdentities.userId, userId));
|
||||
|
||||
if (rowsAffected(keyResult) > 0 || rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return {
|
||||
identitiesDeleted: rowsAffected(result),
|
||||
keysDeleted: rowsAffected(keyResult),
|
||||
};
|
||||
}
|
||||
|
||||
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 insertReturning(this.context, termixIdentityKeys, key);
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async updateKeyForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
update: TermixIdentityKeyUpdate,
|
||||
): Promise<TermixIdentityKeyRecord | null> {
|
||||
const rows = await updateReturning(
|
||||
this.context,
|
||||
termixIdentityKeys,
|
||||
update,
|
||||
and(eq(termixIdentityKeys.id, id), eq(termixIdentityKeys.userId, userId)),
|
||||
);
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async deleteKeyForUser(userId: string, id: number): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(termixIdentityKeys)
|
||||
.where(
|
||||
and(
|
||||
eq(termixIdentityKeys.id, id),
|
||||
eq(termixIdentityKeys.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 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?.();
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { tmuxSessionTags } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.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 result = await this.context.drizzle
|
||||
.update(tmuxSessionTags)
|
||||
.set({ sessionName: newSessionName })
|
||||
.where(
|
||||
and(
|
||||
eq(tmuxSessionTags.hostId, hostId),
|
||||
eq(tmuxSessionTags.sessionName, sessionName),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async deleteSessionForHost(
|
||||
hostId: number,
|
||||
sessionName: string,
|
||||
): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(tmuxSessionTags)
|
||||
.where(
|
||||
and(
|
||||
eq(tmuxSessionTags.hostId, hostId),
|
||||
eq(tmuxSessionTags.sessionName, sessionName),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async replaceForUserHostSession(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
sessionName: string,
|
||||
tags: string[],
|
||||
): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(tmuxSessionTags)
|
||||
.where(
|
||||
and(
|
||||
eq(tmuxSessionTags.userId, userId),
|
||||
eq(tmuxSessionTags.hostId, hostId),
|
||||
eq(tmuxSessionTags.sessionName, sessionName),
|
||||
),
|
||||
);
|
||||
|
||||
if (tags.length > 0) {
|
||||
await this.context.drizzle.insert(tmuxSessionTags).values(
|
||||
tags.map((tag) => ({
|
||||
userId,
|
||||
hostId,
|
||||
sessionName,
|
||||
tag,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
const changedRows = rowsAffected(result) + tags.length;
|
||||
if (changedRows > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return changedRows;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(tmuxSessionTags)
|
||||
.where(eq(tmuxSessionTags.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import { and, desc, eq, inArray, or } from "drizzle-orm";
|
||||
import { transferRecent } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.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 result = await this.context.drizzle
|
||||
.delete(transferRecent)
|
||||
.where(inArray(transferRecent.id, idsToDelete));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(transferRecent)
|
||||
.where(eq(transferRecent.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async deleteByHostId(hostId: number): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(transferRecent)
|
||||
.where(
|
||||
or(
|
||||
eq(transferRecent.sourceHostId, hostId),
|
||||
eq(transferRecent.destHostId, hostId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async deleteByHostIds(hostIds: number[]): Promise<number> {
|
||||
if (hostIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const result = await this.context.drizzle
|
||||
.delete(transferRecent)
|
||||
.where(
|
||||
or(
|
||||
inArray(transferRecent.sourceHostId, hostIds),
|
||||
inArray(transferRecent.destHostId, hostIds),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import crypto from "crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { userExternalIdentities, userSecondFactors } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
|
||||
export type UserExternalIdentityRecord =
|
||||
typeof userExternalIdentities.$inferSelect;
|
||||
export type UserSecondFactorRecord = typeof userSecondFactors.$inferSelect;
|
||||
|
||||
/** Subjects are indexed, so they stay within the portable key length. */
|
||||
const MAX_EXTERNAL_SUBJECT_LENGTH = 255;
|
||||
|
||||
/**
|
||||
* Keeps a subject within the index length. Longer ones are hashed, which is
|
||||
* stable, so the same provider answer always finds the same row.
|
||||
*/
|
||||
function normalizeExternalSubject(subject: string): string {
|
||||
if (subject.length <= MAX_EXTERNAL_SUBJECT_LENGTH) return subject;
|
||||
return `sha256:${crypto.createHash("sha256").update(subject).digest("hex")}`;
|
||||
}
|
||||
|
||||
/** External sign-in identities and second-factor enrolments. */
|
||||
export class UserAuthRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async findIdentity(
|
||||
providerId: string,
|
||||
rawSubject: string,
|
||||
): Promise<UserExternalIdentityRecord | null> {
|
||||
const subject = normalizeExternalSubject(rawSubject);
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(userExternalIdentities)
|
||||
.where(
|
||||
and(
|
||||
eq(userExternalIdentities.providerId, providerId),
|
||||
eq(userExternalIdentities.subject, subject),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async listIdentitiesForUser(
|
||||
userId: string,
|
||||
): Promise<UserExternalIdentityRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(userExternalIdentities)
|
||||
.where(eq(userExternalIdentities.userId, userId));
|
||||
}
|
||||
|
||||
async countUsersForProvider(providerId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ userId: userExternalIdentities.userId })
|
||||
.from(userExternalIdentities)
|
||||
.where(eq(userExternalIdentities.providerId, providerId));
|
||||
return new Set(rows.map((row) => row.userId)).size;
|
||||
}
|
||||
|
||||
async listAllIdentities(): Promise<UserExternalIdentityRecord[]> {
|
||||
return this.context.drizzle.select().from(userExternalIdentities);
|
||||
}
|
||||
|
||||
/** Links or moves an identity to a user. Idempotent. */
|
||||
async linkIdentity(input: {
|
||||
userId: string;
|
||||
providerId: string;
|
||||
subject: string;
|
||||
email?: string | null;
|
||||
}): Promise<void> {
|
||||
const subject = normalizeExternalSubject(input.subject);
|
||||
const existing = await this.findIdentity(input.providerId, subject);
|
||||
if (existing) {
|
||||
if (existing.userId === input.userId) return;
|
||||
await this.context.drizzle
|
||||
.update(userExternalIdentities)
|
||||
.set({ userId: input.userId, email: input.email ?? existing.email })
|
||||
.where(eq(userExternalIdentities.id, existing.id));
|
||||
} else {
|
||||
await this.context.drizzle.insert(userExternalIdentities).values({
|
||||
userId: input.userId,
|
||||
providerId: input.providerId,
|
||||
subject,
|
||||
email: input.email ?? null,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
await this.onWrite?.();
|
||||
}
|
||||
|
||||
async unlinkIdentitiesForUser(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(userExternalIdentities)
|
||||
.where(eq(userExternalIdentities.userId, userId));
|
||||
const affected = rowsAffected(result);
|
||||
if (affected > 0) await this.onWrite?.();
|
||||
return affected;
|
||||
}
|
||||
|
||||
async moveIdentities(fromUserId: string, toUserId: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(userExternalIdentities)
|
||||
.set({ userId: toUserId })
|
||||
.where(eq(userExternalIdentities.userId, fromUserId));
|
||||
await this.onWrite?.();
|
||||
}
|
||||
|
||||
async listSecondFactors(userId: string): Promise<UserSecondFactorRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(userSecondFactors)
|
||||
.where(eq(userSecondFactors.userId, userId));
|
||||
}
|
||||
|
||||
async listAllSecondFactors(): Promise<UserSecondFactorRecord[]> {
|
||||
return this.context.drizzle.select().from(userSecondFactors);
|
||||
}
|
||||
|
||||
/** Users enrolled in at least one second factor, whichever plugin owns it. */
|
||||
async listUserIdsWithSecondFactors(): Promise<Set<string>> {
|
||||
const rows = await this.context.drizzle
|
||||
.selectDistinct({ userId: userSecondFactors.userId })
|
||||
.from(userSecondFactors);
|
||||
return new Set(rows.map((row) => row.userId));
|
||||
}
|
||||
|
||||
async countUsersWithSecondFactors(): Promise<number> {
|
||||
return (await this.listUserIdsWithSecondFactors()).size;
|
||||
}
|
||||
|
||||
async hasSecondFactor(userId: string): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ userId: userSecondFactors.userId })
|
||||
.from(userSecondFactors)
|
||||
.where(eq(userSecondFactors.userId, userId))
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async recordSecondFactor(
|
||||
userId: string,
|
||||
pluginId: string,
|
||||
factorId: string,
|
||||
): Promise<void> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(userSecondFactors)
|
||||
.where(
|
||||
and(
|
||||
eq(userSecondFactors.userId, userId),
|
||||
eq(userSecondFactors.pluginId, pluginId),
|
||||
eq(userSecondFactors.factorId, factorId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (rows[0]) return;
|
||||
await this.context.drizzle.insert(userSecondFactors).values({
|
||||
userId,
|
||||
pluginId,
|
||||
factorId,
|
||||
enrolledAt: new Date().toISOString(),
|
||||
});
|
||||
await this.onWrite?.();
|
||||
}
|
||||
|
||||
async removeSecondFactor(
|
||||
userId: string,
|
||||
pluginId: string,
|
||||
factorId: string,
|
||||
): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(userSecondFactors)
|
||||
.where(
|
||||
and(
|
||||
eq(userSecondFactors.userId, userId),
|
||||
eq(userSecondFactors.pluginId, pluginId),
|
||||
eq(userSecondFactors.factorId, factorId),
|
||||
),
|
||||
);
|
||||
const affected = rowsAffected(result) > 0;
|
||||
if (affected) await this.onWrite?.();
|
||||
return affected;
|
||||
}
|
||||
|
||||
async clearSecondFactors(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(userSecondFactors)
|
||||
.where(eq(userSecondFactors.userId, userId));
|
||||
const affected = rowsAffected(result);
|
||||
if (affected > 0) await this.onWrite?.();
|
||||
return affected;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { asc, eq, inArray, like, sql } from "drizzle-orm";
|
||||
import { users } from "../db/schema.js";
|
||||
import { and, asc, eq, inArray, like, sql } from "drizzle-orm";
|
||||
import { pluginSettings, users } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import {
|
||||
countValue,
|
||||
@@ -79,7 +79,7 @@ export class UserRepository {
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findByOidcIdentifier(
|
||||
async findByExternalIdentifier(
|
||||
oidcIdentifier: string,
|
||||
): Promise<UserRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
@@ -199,6 +199,18 @@ export class UserRepository {
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
// plugin_settings.scope_id is polymorphic, so it carries no foreign key to
|
||||
// users and the engine will not cascade it. Removed here rather than in a
|
||||
// route so every caller that deletes a user is covered.
|
||||
await this.context.drizzle
|
||||
.delete(pluginSettings)
|
||||
.where(
|
||||
and(
|
||||
inArray(pluginSettings.scope, ["user", "secret"]),
|
||||
eq(pluginSettings.scopeId, id),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await this.context.drizzle
|
||||
.delete(users)
|
||||
.where(eq(users.id, id));
|
||||
@@ -216,15 +228,6 @@ export class UserRepository {
|
||||
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 })
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import { desc, eq, or } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { vaultProfiles } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import {
|
||||
deleteReturning,
|
||||
insertReturning,
|
||||
updateReturning,
|
||||
} from "./returning.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 insertReturning(this.context, vaultProfiles, {
|
||||
syncId: randomUUID(),
|
||||
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,
|
||||
});
|
||||
|
||||
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 updateReturning(
|
||||
this.context,
|
||||
vaultProfiles,
|
||||
{
|
||||
...input,
|
||||
updatedAt: input.updatedAt ?? new Date().toISOString(),
|
||||
},
|
||||
eq(vaultProfiles.id, id),
|
||||
);
|
||||
|
||||
if (updated) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async deleteById(id: number): Promise<{ syncId: string | null } | null> {
|
||||
const rows = await deleteReturning(
|
||||
this.context,
|
||||
vaultProfiles,
|
||||
eq(vaultProfiles.id, id),
|
||||
);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
await this.afterWrite();
|
||||
return { syncId: rows[0].syncId };
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(vaultProfiles)
|
||||
.where(eq(vaultProfiles.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { vaultTokens } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { upsert } from "./returning.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 upsert(
|
||||
this.context,
|
||||
vaultTokens,
|
||||
{
|
||||
userId: input.userId,
|
||||
profileId: input.profileId,
|
||||
sshCert: input.sshCert,
|
||||
privateKey: input.privateKey,
|
||||
expiresAt: input.expiresAt,
|
||||
},
|
||||
{
|
||||
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 result = await this.context.drizzle
|
||||
.update(vaultTokens)
|
||||
.set({ lastUsed })
|
||||
.where(
|
||||
and(
|
||||
eq(vaultTokens.userId, userId),
|
||||
eq(vaultTokens.profileId, profileId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async deleteByUserAndProfile(
|
||||
userId: string,
|
||||
profileId: number,
|
||||
): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(vaultTokens)
|
||||
.where(
|
||||
and(
|
||||
eq(vaultTokens.userId, userId),
|
||||
eq(vaultTokens.profileId, profileId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(vaultTokens)
|
||||
.where(eq(vaultTokens.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { webauthnCredentials } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning } from "./returning.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 insertReturning(
|
||||
this.context,
|
||||
webauthnCredentials,
|
||||
record,
|
||||
);
|
||||
|
||||
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 result = await this.context.drizzle
|
||||
.delete(webauthnCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(webauthnCredentials.id, id),
|
||||
eq(webauthnCredentials.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
import { and, eq, ne } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { userWorkspaces } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.js";
|
||||
|
||||
export type WorkspaceRecord = typeof userWorkspaces.$inferSelect;
|
||||
|
||||
export interface WorkspaceCreateInput {
|
||||
name: string;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceUpdateInput {
|
||||
name?: string;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
export class WorkspaceRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByUser(userId: string): Promise<WorkspaceRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(userWorkspaces)
|
||||
.where(eq(userWorkspaces.userId, userId));
|
||||
}
|
||||
|
||||
async findById(userId: string, id: number): Promise<WorkspaceRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(userWorkspaces)
|
||||
.where(and(eq(userWorkspaces.id, id), eq(userWorkspaces.userId, userId)))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findLastSession(userId: string): Promise<WorkspaceRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(userWorkspaces)
|
||||
.where(
|
||||
and(
|
||||
eq(userWorkspaces.userId, userId),
|
||||
eq(userWorkspaces.kind, "last_session"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findDefault(userId: string): Promise<WorkspaceRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(userWorkspaces)
|
||||
.where(
|
||||
and(
|
||||
eq(userWorkspaces.userId, userId),
|
||||
eq(userWorkspaces.isDefault, true),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async upsertLastSession(
|
||||
userId: string,
|
||||
payload: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<WorkspaceRecord> {
|
||||
const existing = await this.findLastSession(userId);
|
||||
|
||||
if (!existing) {
|
||||
const [created] = await insertReturning(this.context, userWorkspaces, {
|
||||
userId,
|
||||
name: "Last Session",
|
||||
color: null,
|
||||
icon: null,
|
||||
kind: "last_session",
|
||||
isDefault: false,
|
||||
payload,
|
||||
syncId: randomUUID(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
userWorkspaces,
|
||||
{ payload, updatedAt: now },
|
||||
and(
|
||||
eq(userWorkspaces.id, existing.id),
|
||||
eq(userWorkspaces.userId, userId),
|
||||
),
|
||||
);
|
||||
await this.afterWrite();
|
||||
return updated;
|
||||
}
|
||||
|
||||
async create(
|
||||
userId: string,
|
||||
input: WorkspaceCreateInput,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<WorkspaceRecord> {
|
||||
const [created] = await insertReturning(this.context, userWorkspaces, {
|
||||
userId,
|
||||
name: input.name,
|
||||
color: input.color ?? null,
|
||||
icon: input.icon ?? null,
|
||||
kind: "manual",
|
||||
isDefault: false,
|
||||
payload: input.payload,
|
||||
syncId: randomUUID(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async update(
|
||||
userId: string,
|
||||
id: number,
|
||||
input: WorkspaceUpdateInput,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<WorkspaceRecord | null> {
|
||||
const existing = await this.findById(userId, id);
|
||||
if (!existing || existing.kind !== "manual") return null;
|
||||
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
userWorkspaces,
|
||||
{
|
||||
name: input.name ?? existing.name,
|
||||
color: input.color === undefined ? existing.color : input.color,
|
||||
icon: input.icon === undefined ? existing.icon : input.icon,
|
||||
updatedAt: now,
|
||||
},
|
||||
and(eq(userWorkspaces.id, id), eq(userWorkspaces.userId, userId)),
|
||||
);
|
||||
await this.afterWrite();
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async updateContent(
|
||||
userId: string,
|
||||
id: number,
|
||||
payload: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<WorkspaceRecord | null> {
|
||||
const existing = await this.findById(userId, id);
|
||||
if (!existing || existing.kind !== "manual") return null;
|
||||
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
userWorkspaces,
|
||||
{ payload, updatedAt: now },
|
||||
and(eq(userWorkspaces.id, id), eq(userWorkspaces.userId, userId)),
|
||||
);
|
||||
await this.afterWrite();
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async setDefault(
|
||||
userId: string,
|
||||
id: number,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<WorkspaceRecord | null> {
|
||||
const existing = await this.findById(userId, id);
|
||||
if (!existing || existing.kind !== "manual") return null;
|
||||
|
||||
await this.context.drizzle
|
||||
.update(userWorkspaces)
|
||||
.set({ isDefault: false, updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(userWorkspaces.userId, userId),
|
||||
eq(userWorkspaces.isDefault, true),
|
||||
ne(userWorkspaces.id, id),
|
||||
),
|
||||
);
|
||||
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
userWorkspaces,
|
||||
{ isDefault: true, updatedAt: now },
|
||||
and(eq(userWorkspaces.id, id), eq(userWorkspaces.userId, userId)),
|
||||
);
|
||||
await this.afterWrite();
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async unsetDefault(
|
||||
userId: string,
|
||||
id: number,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<WorkspaceRecord | null> {
|
||||
const existing = await this.findById(userId, id);
|
||||
if (!existing || existing.kind !== "manual") return null;
|
||||
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
userWorkspaces,
|
||||
{ isDefault: false, updatedAt: now },
|
||||
and(eq(userWorkspaces.id, id), eq(userWorkspaces.userId, userId)),
|
||||
);
|
||||
await this.afterWrite();
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async touchLastUsed(
|
||||
userId: string,
|
||||
id: number,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
const result = await this.context.drizzle
|
||||
.update(userWorkspaces)
|
||||
.set({ lastUsedAt: now })
|
||||
.where(and(eq(userWorkspaces.id, id), eq(userWorkspaces.userId, userId)));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
}
|
||||
|
||||
async delete(userId: string, id: number): Promise<boolean> {
|
||||
const existing = await this.findById(userId, id);
|
||||
if (!existing || existing.kind !== "manual") return false;
|
||||
|
||||
const result = await this.context.drizzle
|
||||
.delete(userWorkspaces)
|
||||
.where(and(eq(userWorkspaces.id, id), eq(userWorkspaces.userId, userId)));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const owned = await this.listByUser(userId);
|
||||
const result = await this.context.drizzle
|
||||
.delete(userWorkspaces)
|
||||
.where(eq(userWorkspaces.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
return owned.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,586 +0,0 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
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 { authLogger } from "../../utils/logger.js";
|
||||
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||
import { reloadNginxWithSSL } from "../../utils/nginx-ssl-reload.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");
|
||||
const ACME_WEBROOT = path.join(DATA_DIR, "acme-webroot");
|
||||
const CERTBOT_DIR = path.join(DATA_DIR, "certbot");
|
||||
const CERTBOT_CONFIG_DIR = path.join(CERTBOT_DIR, "config");
|
||||
const CERTBOT_WORK_DIR = path.join(CERTBOT_DIR, "work");
|
||||
const CERTBOT_LOGS_DIR = path.join(CERTBOT_DIR, "logs");
|
||||
const CLOUDFLARE_CREDENTIALS_FILE = path.join(
|
||||
DATA_DIR,
|
||||
"ssl",
|
||||
"cloudflare.ini",
|
||||
);
|
||||
|
||||
export type AcmeSettings = {
|
||||
enabled: boolean;
|
||||
domain: string;
|
||||
email: string;
|
||||
challengeType: "http-webroot" | "dns-cloudflare" | "manual";
|
||||
cloudflareToken: string;
|
||||
lastIssuedAt: string | null;
|
||||
certStatus: "none" | "valid" | "expiring" | "expired";
|
||||
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 {
|
||||
execFileSync("openssl", ["x509", "-in", certFile, "-noout"], {
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch {
|
||||
return { status: "none", expiresAt: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const endDateRaw = execFileSync(
|
||||
"openssl",
|
||||
["x509", "-in", certFile, "-noout", "-enddate"],
|
||||
{ stdio: "pipe" },
|
||||
)
|
||||
.toString()
|
||||
.trim()
|
||||
.replace("notAfter=", "");
|
||||
const expiresAt = new Date(endDateRaw).toISOString();
|
||||
|
||||
try {
|
||||
execFileSync(
|
||||
"openssl",
|
||||
["x509", "-in", certFile, "-checkend", "0", "-noout"],
|
||||
{
|
||||
stdio: "pipe",
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
return { status: "expired", expiresAt };
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync(
|
||||
"openssl",
|
||||
["x509", "-in", certFile, "-checkend", "2592000", "-noout"],
|
||||
{
|
||||
stdio: "pipe",
|
||||
},
|
||||
);
|
||||
return { status: "valid", expiresAt };
|
||||
} catch {
|
||||
return { status: "expiring", expiresAt };
|
||||
}
|
||||
} catch {
|
||||
return { status: "none", expiresAt: null };
|
||||
}
|
||||
}
|
||||
|
||||
async function getAcmeSettings(): Promise<AcmeSettings> {
|
||||
const { status, expiresAt } = getCertInfo();
|
||||
const value =
|
||||
await createCurrentSettingsRepository().get("acme_ssl_settings");
|
||||
const stored = value ? JSON.parse(value) : {};
|
||||
|
||||
return {
|
||||
enabled: stored.enabled ?? false,
|
||||
domain: stored.domain ?? "",
|
||||
email: stored.email ?? "",
|
||||
challengeType: stored.challengeType ?? "http-webroot",
|
||||
cloudflareToken: stored.cloudflareToken
|
||||
? `${stored.cloudflareToken.slice(0, 4)}${"*".repeat(Math.max(0, stored.cloudflareToken.length - 4))}`
|
||||
: "",
|
||||
lastIssuedAt: stored.lastIssuedAt ?? null,
|
||||
certStatus: status,
|
||||
certExpiresAt: expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function registerAcmeSSLRoutes(
|
||||
router: Router,
|
||||
authenticateJWT: RequestHandler,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/acme-ssl-settings:
|
||||
* get:
|
||||
* summary: Get ACME SSL settings
|
||||
* description: Returns current ACME/Let's Encrypt configuration and certificate status.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: ACME SSL settings and certificate status.
|
||||
* 500:
|
||||
* description: Failed to get ACME SSL settings.
|
||||
*/
|
||||
router.get("/acme-ssl-settings", authenticateJWT, async (_req, res) => {
|
||||
try {
|
||||
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" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/acme-ssl-settings:
|
||||
* patch:
|
||||
* summary: Update ACME SSL settings (admin only)
|
||||
* description: Saves ACME/Let's Encrypt configuration.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* domain:
|
||||
* type: string
|
||||
* email:
|
||||
* type: string
|
||||
* challengeType:
|
||||
* type: string
|
||||
* enum: [http-webroot, dns-cloudflare, manual]
|
||||
* cloudflareToken:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: ACME SSL settings updated.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Failed to update ACME SSL settings.
|
||||
*/
|
||||
router.patch("/acme-ssl-settings", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const actor = await getAdminActor(userId);
|
||||
if (!actor) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const updated = {
|
||||
...current,
|
||||
...(typeof enabled === "boolean" && { enabled }),
|
||||
...(typeof domain === "string" && { domain }),
|
||||
...(typeof email === "string" && { email }),
|
||||
...(typeof challengeType === "string" && { challengeType }),
|
||||
...(typeof cloudflareToken === "string" &&
|
||||
cloudflareToken &&
|
||||
!cloudflareToken.includes("*") && { cloudflareToken }),
|
||||
};
|
||||
|
||||
await settingsRepository.set(
|
||||
"acme_ssl_settings",
|
||||
JSON.stringify(updated),
|
||||
);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor.username ?? userId,
|
||||
action: "update_acme_ssl_settings",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({
|
||||
enabled,
|
||||
domain,
|
||||
email,
|
||||
challengeType,
|
||||
hasCloudflareToken: !!updated.cloudflareToken,
|
||||
}),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
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" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/acme-ssl-request:
|
||||
* post:
|
||||
* summary: Request or renew Let's Encrypt certificate (admin only)
|
||||
* description: Triggers certbot to issue or renew a certificate using the configured challenge method.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Certificate issued or renewed successfully.
|
||||
* 400:
|
||||
* description: Invalid configuration.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Certificate issuance failed.
|
||||
*/
|
||||
router.post("/acme-ssl-request", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const actor = await getAdminActor(userId);
|
||||
try {
|
||||
if (!actor) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
|
||||
const settingsValue =
|
||||
await createCurrentSettingsRepository().get("acme_ssl_settings");
|
||||
|
||||
if (!settingsValue) {
|
||||
return res.status(400).json({ error: "ACME settings not configured" });
|
||||
}
|
||||
|
||||
const settings = JSON.parse(settingsValue);
|
||||
const { domain, email, challengeType, cloudflareToken } = settings;
|
||||
|
||||
if (!domain || !email) {
|
||||
return res.status(400).json({ error: "Domain and email are required" });
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync("certbot", ["--version"], { stdio: "pipe" });
|
||||
} catch {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "certbot is not available in this environment" });
|
||||
}
|
||||
|
||||
await fs.mkdir(SSL_DIR, { recursive: true });
|
||||
await fs.mkdir(ACME_WEBROOT, { recursive: true });
|
||||
await fs.mkdir(CERTBOT_CONFIG_DIR, { recursive: true });
|
||||
await fs.mkdir(CERTBOT_WORK_DIR, { recursive: true });
|
||||
await fs.mkdir(CERTBOT_LOGS_DIR, { recursive: true });
|
||||
|
||||
const certbotDirArgs = [
|
||||
"--config-dir",
|
||||
CERTBOT_CONFIG_DIR,
|
||||
"--work-dir",
|
||||
CERTBOT_WORK_DIR,
|
||||
"--logs-dir",
|
||||
CERTBOT_LOGS_DIR,
|
||||
];
|
||||
|
||||
let certbotArgs: string[];
|
||||
|
||||
if (challengeType === "dns-cloudflare") {
|
||||
if (!cloudflareToken) {
|
||||
return res.status(400).json({
|
||||
error: "Cloudflare API token is required for DNS challenge",
|
||||
});
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(CLOUDFLARE_CREDENTIALS_FILE), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.writeFile(
|
||||
CLOUDFLARE_CREDENTIALS_FILE,
|
||||
`dns_cloudflare_api_token = ${cloudflareToken}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
certbotArgs = [
|
||||
"certonly",
|
||||
"--non-interactive",
|
||||
"--agree-tos",
|
||||
"--dns-cloudflare",
|
||||
"--dns-cloudflare-credentials",
|
||||
CLOUDFLARE_CREDENTIALS_FILE,
|
||||
"--dns-cloudflare-propagation-seconds",
|
||||
"30",
|
||||
"-d",
|
||||
domain,
|
||||
"--email",
|
||||
email,
|
||||
"--cert-name",
|
||||
"termix",
|
||||
...certbotDirArgs,
|
||||
];
|
||||
} else {
|
||||
certbotArgs = [
|
||||
"certonly",
|
||||
"--non-interactive",
|
||||
"--agree-tos",
|
||||
"--webroot",
|
||||
"-w",
|
||||
ACME_WEBROOT,
|
||||
"-d",
|
||||
domain,
|
||||
"--email",
|
||||
email,
|
||||
"--cert-name",
|
||||
"termix",
|
||||
...certbotDirArgs,
|
||||
];
|
||||
}
|
||||
|
||||
authLogger.info("Requesting Let's Encrypt certificate", {
|
||||
domain,
|
||||
challengeType,
|
||||
operation: "acme_cert_request",
|
||||
});
|
||||
|
||||
execFileSync("certbot", certbotArgs, {
|
||||
stdio: "pipe",
|
||||
timeout: 120000,
|
||||
});
|
||||
|
||||
const liveDir = path.join(CERTBOT_CONFIG_DIR, "live", "termix");
|
||||
const fullchainSrc = path.join(liveDir, "fullchain.pem");
|
||||
const privkeySrc = path.join(liveDir, "privkey.pem");
|
||||
const certDest = path.join(SSL_DIR, "termix.crt");
|
||||
const keyDest = path.join(SSL_DIR, "termix.key");
|
||||
|
||||
await fs.copyFile(fullchainSrc, certDest);
|
||||
await fs.copyFile(privkeySrc, keyDest);
|
||||
await fs.chmod(keyDest, 0o600);
|
||||
await fs.chmod(certDest, 0o644);
|
||||
|
||||
const updated = { ...settings, lastIssuedAt: new Date().toISOString() };
|
||||
await createCurrentSettingsRepository().set(
|
||||
"acme_ssl_settings",
|
||||
JSON.stringify(updated),
|
||||
);
|
||||
|
||||
authLogger.info("Let's Encrypt certificate issued and installed", {
|
||||
domain,
|
||||
operation: "acme_cert_installed",
|
||||
});
|
||||
|
||||
const reload = reloadNginxWithSSL();
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor.username ?? userId,
|
||||
action: "acme_ssl_request",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ domain, challengeType, success: true }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
reloadMessage: reload.message,
|
||||
...(await getAcmeSettings()),
|
||||
});
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
authLogger.error("ACME certificate request failed", err);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor?.username ?? userId,
|
||||
action: "acme_ssl_request",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ error: message }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: false,
|
||||
});
|
||||
|
||||
res.status(500).json({ error: `Certificate request failed: ${message}` });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/manual-ssl-upload:
|
||||
* post:
|
||||
* summary: Upload a manual/custom SSL certificate and key (admin only)
|
||||
* description: Validates and installs a user-supplied PEM certificate and private key as the active Termix SSL certificate.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* certificate:
|
||||
* type: string
|
||||
* privateKey:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Certificate uploaded and installed successfully.
|
||||
* 400:
|
||||
* description: Invalid or missing certificate/key.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Certificate installation failed.
|
||||
*/
|
||||
router.post("/manual-ssl-upload", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const actor = await getAdminActor(userId);
|
||||
try {
|
||||
if (!actor) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
|
||||
const { certificate, privateKey } = req.body;
|
||||
|
||||
if (
|
||||
typeof certificate !== "string" ||
|
||||
typeof privateKey !== "string" ||
|
||||
!certificate.includes("BEGIN CERTIFICATE") ||
|
||||
!privateKey.includes("PRIVATE KEY")
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: "A valid PEM certificate and private key are required",
|
||||
});
|
||||
}
|
||||
|
||||
await fs.mkdir(SSL_DIR, { recursive: true });
|
||||
|
||||
const tmpCertFile = path.join(SSL_DIR, ".manual-upload.crt.tmp");
|
||||
const tmpKeyFile = path.join(SSL_DIR, ".manual-upload.key.tmp");
|
||||
|
||||
try {
|
||||
await fs.writeFile(tmpCertFile, certificate, { mode: 0o644 });
|
||||
await fs.writeFile(tmpKeyFile, privateKey, { mode: 0o600 });
|
||||
|
||||
try {
|
||||
execFileSync("openssl", ["x509", "-in", tmpCertFile, "-noout"], {
|
||||
stdio: "pipe",
|
||||
});
|
||||
execFileSync(
|
||||
"openssl",
|
||||
["pkey", "-in", tmpKeyFile, "-noout", "-check"],
|
||||
{ stdio: "pipe" },
|
||||
);
|
||||
} catch {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"The provided certificate or private key is not valid PEM data",
|
||||
});
|
||||
}
|
||||
|
||||
const certPubkey = execFileSync(
|
||||
"openssl",
|
||||
["x509", "-in", tmpCertFile, "-noout", "-pubkey"],
|
||||
{ stdio: "pipe" },
|
||||
);
|
||||
const keyPubkey = execFileSync(
|
||||
"openssl",
|
||||
["pkey", "-in", tmpKeyFile, "-pubout"],
|
||||
{ stdio: "pipe" },
|
||||
);
|
||||
|
||||
if (!certPubkey.equals(keyPubkey)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "The certificate and private key do not match" });
|
||||
}
|
||||
|
||||
const certDest = path.join(SSL_DIR, "termix.crt");
|
||||
const keyDest = path.join(SSL_DIR, "termix.key");
|
||||
await fs.rename(tmpCertFile, certDest);
|
||||
await fs.rename(tmpKeyFile, keyDest);
|
||||
await fs.chmod(keyDest, 0o600);
|
||||
await fs.chmod(certDest, 0o644);
|
||||
} finally {
|
||||
await fs.rm(tmpCertFile, { force: true });
|
||||
await fs.rm(tmpKeyFile, { force: true });
|
||||
}
|
||||
|
||||
const settingsRepository = createCurrentSettingsRepository();
|
||||
const existing = await settingsRepository.get("acme_ssl_settings");
|
||||
const current = existing ? JSON.parse(existing) : {};
|
||||
const updated = {
|
||||
...current,
|
||||
challengeType: "manual",
|
||||
lastIssuedAt: new Date().toISOString(),
|
||||
};
|
||||
await settingsRepository.set(
|
||||
"acme_ssl_settings",
|
||||
JSON.stringify(updated),
|
||||
);
|
||||
|
||||
authLogger.info("Manual SSL certificate installed", {
|
||||
operation: "manual_ssl_installed",
|
||||
});
|
||||
|
||||
const reload = reloadNginxWithSSL();
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor.username ?? userId,
|
||||
action: "manual_ssl_upload",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ success: true }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
reloadMessage: reload.message,
|
||||
...(await getAcmeSettings()),
|
||||
});
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
authLogger.error("Manual SSL certificate upload failed", err);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor?.username ?? userId,
|
||||
action: "manual_ssl_upload",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ error: message }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: false,
|
||||
});
|
||||
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: `Certificate installation failed: ${message}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,534 +0,0 @@
|
||||
import express, { type Request, type Response } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { createCurrentAlertRepository } from "../repositories/factory.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { sendWebhook, sendNtfy } from "../../utils/notification-sender.js";
|
||||
import { sendDiscord } from "../../utils/discord-sender.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
const VALID_TRIGGER_TYPES = new Set([
|
||||
"host_offline",
|
||||
"host_online",
|
||||
"cpu_threshold",
|
||||
"memory_threshold",
|
||||
"disk_threshold",
|
||||
"health_check_failure",
|
||||
"health_check_recovery",
|
||||
"user_login",
|
||||
]);
|
||||
|
||||
router.use(authenticateJWT);
|
||||
|
||||
// ---- Notification Channels ----
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /notification-channels:
|
||||
* get:
|
||||
* summary: List notification channels for the current user
|
||||
* tags:
|
||||
* - Alerts
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of notification channels.
|
||||
*/
|
||||
router.get("/notification-channels", async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const rows =
|
||||
await createCurrentAlertRepository().listNotificationChannels(userId);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to list notification channels", {
|
||||
operation: "list_channels",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list channels" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /notification-channels:
|
||||
* post:
|
||||
* summary: Create a notification channel
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.post("/notification-channels", async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { name, type, config, enabled } = req.body as {
|
||||
name: string;
|
||||
type: string;
|
||||
config: unknown;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
if (!name || typeof name !== "string" || !name.trim()) {
|
||||
return res.status(400).json({ error: "name is required" });
|
||||
}
|
||||
if (type !== "webhook" && type !== "ntfy" && type !== "discord") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "type must be 'webhook', 'ntfy' or 'discord'" });
|
||||
}
|
||||
if (!config || typeof config !== "object") {
|
||||
return res.status(400).json({ error: "config is required" });
|
||||
}
|
||||
if (type === "ntfy") {
|
||||
const c = config as Record<string, unknown>;
|
||||
if (!c.url || typeof c.url !== "string")
|
||||
return res.status(400).json({ error: "ntfy config requires url" });
|
||||
if (!c.topic || typeof c.topic !== "string")
|
||||
return res.status(400).json({ error: "ntfy config requires topic" });
|
||||
}
|
||||
if (type === "webhook") {
|
||||
const c = config as Record<string, unknown>;
|
||||
if (!c.url || typeof c.url !== "string")
|
||||
return res.status(400).json({ error: "webhook config requires url" });
|
||||
}
|
||||
if (type === "discord") {
|
||||
const c = config as Record<string, unknown>;
|
||||
if (!c.url || typeof c.url !== "string")
|
||||
return res.status(400).json({ error: "discord config requires url" });
|
||||
if (
|
||||
!/^https:\/\/(?:canary\.|ptb\.)?(?:discord\.com|discordapp\.com)\/api\/webhooks\/.+/i.test(
|
||||
c.url,
|
||||
)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"discord config requires a valid Discord webhook URL (https://discord.com/api/webhooks/...)",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
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", {
|
||||
operation: "create_channel",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to create channel" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /notification-channels/{id}:
|
||||
* put:
|
||||
* summary: Update a notification channel
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
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 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" && type !== "discord") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "type must be 'webhook', 'ntfy' or 'discord'" });
|
||||
}
|
||||
if (
|
||||
name === undefined &&
|
||||
type === undefined &&
|
||||
config === undefined &&
|
||||
enabled === undefined
|
||||
) {
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
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
|
||||
* /notification-channels/{id}:
|
||||
* delete:
|
||||
* summary: Delete a notification channel
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
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
|
||||
* /notification-channels/{id}/test:
|
||||
* post:
|
||||
* summary: Send a test notification
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.post(
|
||||
"/notification-channels/:id/test",
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const channelId = Number(req.params.id);
|
||||
const row =
|
||||
await createCurrentAlertRepository().findNotificationChannelForUser(
|
||||
channelId,
|
||||
userId,
|
||||
);
|
||||
if (!row) return res.status(404).json({ error: "Channel not found" });
|
||||
|
||||
const testPayload = {
|
||||
hostName: "Test Host",
|
||||
hostId: 0,
|
||||
triggerType: "test",
|
||||
message: "This is a test notification from Termix",
|
||||
severity: "info" as const,
|
||||
timestamp: new Date().toISOString(),
|
||||
ruleId: 0,
|
||||
ruleName: "Test",
|
||||
};
|
||||
|
||||
try {
|
||||
let config: Record<string, unknown>;
|
||||
try {
|
||||
config = JSON.parse(row.config) as Record<string, unknown>;
|
||||
} catch {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: "Invalid channel config" });
|
||||
}
|
||||
|
||||
if (row.type === "webhook") {
|
||||
await sendWebhook(
|
||||
config as unknown as Parameters<typeof sendWebhook>[0],
|
||||
testPayload,
|
||||
);
|
||||
} else if (row.type === "ntfy") {
|
||||
await sendNtfy(
|
||||
config as unknown as Parameters<typeof sendNtfy>[0],
|
||||
testPayload,
|
||||
);
|
||||
} else if (row.type === "discord") {
|
||||
await sendDiscord(
|
||||
config as unknown as Parameters<typeof sendDiscord>[0],
|
||||
testPayload,
|
||||
);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.json({
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- Alert Rules ----
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alert-rules:
|
||||
* get:
|
||||
* summary: List alert rules for the current user
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.get("/alert-rules", async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
res.json(await createCurrentAlertRepository().listAlertRules(userId));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to list alert rules", {
|
||||
operation: "list_alert_rules",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list alert rules" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alert-rules:
|
||||
* post:
|
||||
* summary: Create an alert rule
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.post("/alert-rules", async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const {
|
||||
name,
|
||||
hostId,
|
||||
enabled,
|
||||
triggerType,
|
||||
thresholdValue,
|
||||
thresholdDurationSeconds,
|
||||
cooldownMinutes,
|
||||
channels = [],
|
||||
} = req.body as {
|
||||
name: string;
|
||||
hostId?: number | null;
|
||||
enabled?: boolean;
|
||||
triggerType: string;
|
||||
thresholdValue?: number | null;
|
||||
thresholdDurationSeconds?: number | null;
|
||||
cooldownMinutes?: number;
|
||||
channels?: number[];
|
||||
};
|
||||
|
||||
if (!name || typeof name !== "string" || !name.trim()) {
|
||||
return res.status(400).json({ error: "name is required" });
|
||||
}
|
||||
if (!VALID_TRIGGER_TYPES.has(triggerType)) {
|
||||
return res.status(400).json({ error: "Invalid triggerType" });
|
||||
}
|
||||
if (thresholdValue != null && (thresholdValue < 0 || thresholdValue > 100)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "thresholdValue must be between 0 and 100" });
|
||||
}
|
||||
if (thresholdDurationSeconds != null && thresholdDurationSeconds < 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "thresholdDurationSeconds must be >= 0" });
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
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", {
|
||||
operation: "create_alert_rule",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to create alert rule" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alert-rules/{id}:
|
||||
* put:
|
||||
* summary: Update an alert rule
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.put("/alert-rules/:id", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const ruleId = Number(req.params.id);
|
||||
|
||||
const repository = createCurrentAlertRepository();
|
||||
const existing = await repository.findAlertRuleForUser(ruleId, userId);
|
||||
if (!existing) return res.status(404).json({ error: "Alert rule not found" });
|
||||
|
||||
const {
|
||||
name,
|
||||
hostId,
|
||||
enabled,
|
||||
triggerType,
|
||||
thresholdValue,
|
||||
thresholdDurationSeconds,
|
||||
cooldownMinutes,
|
||||
channels,
|
||||
} = req.body as {
|
||||
name?: string;
|
||||
hostId?: number | null;
|
||||
enabled?: boolean;
|
||||
triggerType?: string;
|
||||
thresholdValue?: number | null;
|
||||
thresholdDurationSeconds?: number | null;
|
||||
cooldownMinutes?: number;
|
||||
channels?: number[];
|
||||
};
|
||||
|
||||
if (triggerType && !VALID_TRIGGER_TYPES.has(triggerType)) {
|
||||
return res.status(400).json({ error: "Invalid triggerType" });
|
||||
}
|
||||
|
||||
try {
|
||||
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",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to update alert rule" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alert-rules/{id}:
|
||||
* delete:
|
||||
* summary: Delete an alert rule
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.delete("/alert-rules/:id", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const ruleId = Number(req.params.id);
|
||||
const deleted = await createCurrentAlertRepository().deleteAlertRule(
|
||||
ruleId,
|
||||
userId,
|
||||
);
|
||||
if (!deleted) return res.status(404).json({ error: "Alert rule not found" });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ---- Alert Firings ----
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alert-firings:
|
||||
* get:
|
||||
* summary: List alert firings for the current user
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
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 {
|
||||
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",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list alert firings" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alert-firings/{id}/acknowledge:
|
||||
* post:
|
||||
* summary: Acknowledge an alert firing
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
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
|
||||
* /alert-firings/acknowledge-all:
|
||||
* post:
|
||||
* summary: Acknowledge all alert firings for the current user
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.post("/alert-firings/acknowledge-all", async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
await createCurrentAlertRepository().acknowledgeAllFirings(userId);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,282 +0,0 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type {
|
||||
AuthenticatedRequest,
|
||||
CacheEntry,
|
||||
TermixAlert,
|
||||
} from "../../../types/index.js";
|
||||
import express from "express";
|
||||
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();
|
||||
private readonly CACHE_DURATION = 5 * 60 * 1000;
|
||||
|
||||
set<T>(key: string, data: T): void {
|
||||
const now = Date.now();
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: now,
|
||||
expiresAt: now + this.CACHE_DURATION,
|
||||
});
|
||||
}
|
||||
|
||||
get<T>(key: string): T | null {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data as T;
|
||||
}
|
||||
}
|
||||
|
||||
const alertCache = new AlertCache();
|
||||
|
||||
const GITHUB_RAW_BASE = "https://raw.githubusercontent.com";
|
||||
const REPO_OWNER = "Termix-SSH";
|
||||
const REPO_NAME = "Docs";
|
||||
const ALERTS_FILE = "main/termix-alerts.json";
|
||||
|
||||
async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
|
||||
const cacheKey = "termix_alerts";
|
||||
const cachedData = alertCache.get<TermixAlert[]>(cacheKey);
|
||||
if (cachedData) {
|
||||
return cachedData;
|
||||
}
|
||||
try {
|
||||
const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"User-Agent": "TermixAlertChecker/1.0",
|
||||
},
|
||||
dispatcher: getProxyAgent(url),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
authLogger.warn("GitHub API returned error status", {
|
||||
operation: "alerts_fetch",
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
throw new Error(
|
||||
`GitHub raw content error: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const alerts: TermixAlert[] = (await response.json()) as TermixAlert[];
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const validAlerts = alerts.filter((alert) => {
|
||||
const expiryDate = new Date(alert.expiresAt);
|
||||
const isValid = expiryDate > now;
|
||||
return isValid;
|
||||
});
|
||||
|
||||
alertCache.set(cacheKey, validAlerts);
|
||||
return validAlerts;
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to fetch alerts from GitHub", {
|
||||
operation: "alerts_fetch",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alerts:
|
||||
* get:
|
||||
* summary: Get active alerts
|
||||
* description: Fetches active alerts for the authenticated user, excluding those that have been dismissed.
|
||||
* tags:
|
||||
* - Alerts
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of active alerts.
|
||||
* 500:
|
||||
* description: Failed to fetch alerts.
|
||||
*/
|
||||
router.get("/", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
const allAlerts = await fetchAlertsFromGitHub();
|
||||
|
||||
const dismissedAlertIds = new Set(
|
||||
await createCurrentDismissedAlertRepository().listAlertIdsByUserId(
|
||||
userId,
|
||||
),
|
||||
);
|
||||
|
||||
const activeAlertsForUser = allAlerts.filter(
|
||||
(alert) => !dismissedAlertIds.has(alert.id),
|
||||
);
|
||||
|
||||
res.json({
|
||||
alerts: activeAlertsForUser,
|
||||
cached: alertCache.get("termix_alerts") !== null,
|
||||
total_count: activeAlertsForUser.length,
|
||||
});
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to get user alerts", error);
|
||||
res.status(500).json({ error: "Failed to fetch alerts" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alerts/dismiss:
|
||||
* post:
|
||||
* summary: Dismiss an alert
|
||||
* description: Marks an alert as dismissed for the authenticated user.
|
||||
* tags:
|
||||
* - Alerts
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* alertId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Alert dismissed successfully.
|
||||
* 400:
|
||||
* description: Alert ID is required.
|
||||
* 409:
|
||||
* description: Alert already dismissed.
|
||||
* 500:
|
||||
* description: Failed to dismiss alert.
|
||||
*/
|
||||
router.post("/dismiss", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const { alertId } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!alertId) {
|
||||
authLogger.warn("Missing alertId in dismiss request", { userId });
|
||||
return res.status(400).json({ error: "Alert ID is required" });
|
||||
}
|
||||
|
||||
const existingDismissal =
|
||||
await createCurrentDismissedAlertRepository().findForUser(
|
||||
userId,
|
||||
alertId,
|
||||
);
|
||||
|
||||
if (existingDismissal) {
|
||||
authLogger.warn(`Alert ${alertId} already dismissed by user ${userId}`);
|
||||
return res.status(409).json({ error: "Alert already dismissed" });
|
||||
}
|
||||
|
||||
await createCurrentDismissedAlertRepository().create(userId, alertId);
|
||||
|
||||
res.json({ message: "Alert dismissed successfully" });
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to dismiss alert", error);
|
||||
res.status(500).json({ error: "Failed to dismiss alert" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alerts/dismissed:
|
||||
* get:
|
||||
* summary: Get dismissed alerts
|
||||
* description: Fetches a list of alerts that have been dismissed by the authenticated user.
|
||||
* tags:
|
||||
* - Alerts
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of dismissed alerts.
|
||||
* 500:
|
||||
* description: Failed to fetch dismissed alerts.
|
||||
*/
|
||||
router.get("/dismissed", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
const dismissedAlertRecords =
|
||||
await createCurrentDismissedAlertRepository().listByUserId(userId);
|
||||
|
||||
res.json({
|
||||
dismissed_alerts: dismissedAlertRecords,
|
||||
total_count: dismissedAlertRecords.length,
|
||||
});
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to get dismissed alerts", error);
|
||||
res.status(500).json({ error: "Failed to fetch dismissed alerts" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alerts/dismiss:
|
||||
* delete:
|
||||
* summary: Undismiss an alert
|
||||
* description: Removes an alert from the dismissed list for the authenticated user.
|
||||
* tags:
|
||||
* - Alerts
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* alertId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Alert undismissed successfully.
|
||||
* 400:
|
||||
* description: Alert ID is required.
|
||||
* 404:
|
||||
* description: Dismissed alert not found.
|
||||
* 500:
|
||||
* description: Failed to undismiss alert.
|
||||
*/
|
||||
router.delete("/dismiss", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const { alertId } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!alertId) {
|
||||
return res.status(400).json({ error: "Alert ID is required" });
|
||||
}
|
||||
|
||||
const deleted = await createCurrentDismissedAlertRepository().deleteForUser(
|
||||
userId,
|
||||
alertId,
|
||||
);
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: "Dismissed alert not found" });
|
||||
}
|
||||
res.json({ message: "Alert undismissed successfully" });
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to undismiss alert", error);
|
||||
res.status(500).json({ error: "Failed to undismiss alert" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* The 2.8 login URLs clients built against 2.8 (Termix-Mobile) still call.
|
||||
* Each forwards to the login pipeline for the method it names, so none of
|
||||
* them can sign anyone in on its own. Old identity provider callbacks are
|
||||
* redirected by the owning plugin's manifest (contributes.http.legacyRedirects).
|
||||
*/
|
||||
|
||||
import type { Router } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
listLegacySsoProviders,
|
||||
startRedirectLogin,
|
||||
verifyFormLogin,
|
||||
} from "./auth-routes.js";
|
||||
|
||||
export function registerAuthCompatRoutes(router: Router): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/{method}/authorize:
|
||||
* get:
|
||||
* summary: Start a redirect login (2.8 route)
|
||||
* description: Kept for 2.8 clients such as Termix-Mobile, which call /users/oidc/authorize. Same as /users/auth/{method}/start, with the provider in providerId.
|
||||
* tags:
|
||||
* - Auth
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: method
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: providerId
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: rememberMe
|
||||
* schema:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The authorization URL in auth_url.
|
||||
* 404:
|
||||
* description: That login method is not available.
|
||||
*/
|
||||
router.get("/:method/authorize", (req, res) =>
|
||||
startRedirectLogin(
|
||||
req,
|
||||
res,
|
||||
String(req.params.method),
|
||||
typeof req.query.providerId === "string" && req.query.providerId
|
||||
? req.query.providerId
|
||||
: null,
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/sso-providers:
|
||||
* get:
|
||||
* summary: List sign-in providers (2.8 route)
|
||||
* description: Kept for 2.8 clients. Every enabled instance of every external login method, in the 2.8 shape. /users/auth/methods is the current route.
|
||||
* tags:
|
||||
* - Auth
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Providers.
|
||||
*/
|
||||
router.get("/sso-providers", async (_req, res) => {
|
||||
try {
|
||||
res.json(await listLegacySsoProviders());
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to list SSO providers", error);
|
||||
res.status(500).json({ error: "Failed to list SSO providers" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/{method}/login:
|
||||
* post:
|
||||
* summary: Form login (2.8 route)
|
||||
* description: Kept for 2.8 clients, which call /users/ldap/login. Same as /users/auth/{method}/verify, with the provider in providerId.
|
||||
* tags:
|
||||
* - Auth
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: method
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Login successful, or a second factor is required.
|
||||
* 401:
|
||||
* description: Invalid credentials.
|
||||
* 404:
|
||||
* description: That login method is not available.
|
||||
*/
|
||||
router.post("/:method/login", (req, res) =>
|
||||
verifyFormLogin(
|
||||
req,
|
||||
res,
|
||||
String(req.params.method),
|
||||
req.body?.providerId != null ? String(req.body.providerId) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* The generic login surface: every registered login method and second factor
|
||||
* is reachable here, core's and plugins' alike. The older per-method routes
|
||||
* (/users/login, /users/oidc/*, /users/ldap/login, ...) stay as thin wrappers
|
||||
* so existing clients keep working.
|
||||
*/
|
||||
|
||||
import type { Request, Response, Router } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { isExternalTokenCallback } from "../../utils/external-login-callback.js";
|
||||
import { ensureCoreLoginProviders } from "../../auth/core-auth.js";
|
||||
import {
|
||||
readPendingLogin,
|
||||
redirectWithLoginError,
|
||||
respondWithLogin,
|
||||
respondWithRedirectLogin,
|
||||
sendLoginError,
|
||||
verifySecondFactorAndRespond,
|
||||
evaluateSecondFactors,
|
||||
} from "../../auth/login-pipeline.js";
|
||||
import {
|
||||
getLoginMethod,
|
||||
listLoginMethods,
|
||||
type LoginMethod,
|
||||
} from "../../auth/registry.js";
|
||||
import { isTrustedProxyAuthEnabled } from "../../utils/trusted-proxy-auth.js";
|
||||
import { LoginMethodError, type VerifiedIdentity } from "../../auth/types.js";
|
||||
|
||||
export interface PublicLoginMethod {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
kind: "redirect" | "form";
|
||||
labelKey: string;
|
||||
icon?: string;
|
||||
instances: Array<{ id: string; label: string; autoStart?: boolean }>;
|
||||
}
|
||||
|
||||
async function enabledInstances(method: LoginMethod) {
|
||||
return (await method.describe!()).filter((instance) => instance.enabled);
|
||||
}
|
||||
|
||||
/** External methods with at least one enabled instance. */
|
||||
export async function listExternalLoginMethods(): Promise<LoginMethod[]> {
|
||||
ensureCoreLoginProviders();
|
||||
const found: LoginMethod[] = [];
|
||||
for (const method of listLoginMethods()) {
|
||||
if (!method.external) continue;
|
||||
if (!method.describe) {
|
||||
found.push(method);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if ((await enabledInstances(method)).length > 0) found.push(method);
|
||||
} catch {
|
||||
// A method that cannot describe itself signs nobody in.
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** The 2.8 `GET /users/sso-providers` shape, built from external methods. */
|
||||
export async function listLegacySsoProviders(): Promise<
|
||||
Array<{
|
||||
id: number | string;
|
||||
name: string;
|
||||
type: string;
|
||||
displayOrder: number;
|
||||
}>
|
||||
> {
|
||||
const providers: Array<{
|
||||
id: number | string;
|
||||
name: string;
|
||||
type: string;
|
||||
displayOrder: number;
|
||||
}> = [];
|
||||
for (const method of await listExternalLoginMethods()) {
|
||||
if (!method.describe) continue;
|
||||
for (const instance of await enabledInstances(method)) {
|
||||
const numeric = Number(instance.id);
|
||||
providers.push({
|
||||
id: Number.isInteger(numeric) ? numeric : instance.id,
|
||||
name: instance.label,
|
||||
type: instance.type ?? method.id,
|
||||
displayOrder: providers.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
/** What the login screen may know: ids, labels and enabled instances. */
|
||||
export async function listPublicLoginMethods(): Promise<PublicLoginMethod[]> {
|
||||
ensureCoreLoginProviders();
|
||||
const methods: PublicLoginMethod[] = [];
|
||||
for (const method of listLoginMethods()) {
|
||||
let instances: PublicLoginMethod["instances"] = [];
|
||||
if (method.describe) {
|
||||
try {
|
||||
instances = (await method.describe())
|
||||
.filter((instance) => instance.enabled)
|
||||
.map((instance) => ({
|
||||
id: instance.id,
|
||||
label: instance.label,
|
||||
...(instance.autoStart && method.kind === "redirect"
|
||||
? { autoStart: true }
|
||||
: {}),
|
||||
}));
|
||||
} catch (error) {
|
||||
authLogger.warn("Login method could not describe itself", {
|
||||
operation: "login_method_describe",
|
||||
methodId: method.id,
|
||||
error,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (instances.length === 0) continue;
|
||||
}
|
||||
methods.push({
|
||||
id: method.id,
|
||||
pluginId: method.pluginId,
|
||||
kind: method.kind,
|
||||
labelKey: method.labelKey,
|
||||
icon: method.icon,
|
||||
instances,
|
||||
});
|
||||
}
|
||||
return methods;
|
||||
}
|
||||
|
||||
function instanceParam(req: Request): string | null {
|
||||
const value = req.query.instance ?? req.body?.instanceId;
|
||||
return typeof value === "string" && value ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trusted proxy login and external methods don't mix: the proxy already
|
||||
* decided who the user is.
|
||||
*/
|
||||
function refusedByTrustedProxy(method: LoginMethod, res: Response): boolean {
|
||||
if (!method.external || !isTrustedProxyAuthEnabled()) return false;
|
||||
res.status(409).json({
|
||||
error:
|
||||
"External login is disabled while trusted proxy authentication is enabled",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function methodOr404(req: Request, res: Response, id?: string) {
|
||||
ensureCoreLoginProviders();
|
||||
const method = getLoginMethod(id ?? String(req.params.methodId));
|
||||
if (!method) {
|
||||
res.status(404).json({ error: "Unknown login method" });
|
||||
return null;
|
||||
}
|
||||
if (refusedByTrustedProxy(method, res)) return null;
|
||||
return method;
|
||||
}
|
||||
|
||||
/**
|
||||
* A redirect method's callback, for core's route and for a plugin's own
|
||||
* public route through ctx.auth.completeRedirectLogin.
|
||||
*/
|
||||
export async function handleRedirectCallback(
|
||||
method: LoginMethod,
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
if (refusedByTrustedProxy(method, res)) return;
|
||||
if (!method.callback) {
|
||||
res.status(400).json({ error: "Not a redirect login method" });
|
||||
return;
|
||||
}
|
||||
let identity: VerifiedIdentity;
|
||||
try {
|
||||
identity = await method.callback(req as never);
|
||||
} catch (error) {
|
||||
const returnTo = (error as { returnTo?: string }).returnTo;
|
||||
if (returnTo) return redirectWithLoginError(res, returnTo, error);
|
||||
sendLoginError(res, error);
|
||||
return;
|
||||
}
|
||||
await respondWithRedirectLogin(
|
||||
req,
|
||||
res,
|
||||
identity,
|
||||
{ methodId: method.id, rememberMe: !!identity.rememberMe },
|
||||
isExternalTokenCallback,
|
||||
);
|
||||
}
|
||||
|
||||
/** Starts a redirect method, answering with its URL. */
|
||||
export async function startRedirectLogin(
|
||||
req: Request,
|
||||
res: Response,
|
||||
methodId: string,
|
||||
instanceId: string | null,
|
||||
): Promise<void> {
|
||||
const method = methodOr404(req, res, methodId);
|
||||
if (!method) return;
|
||||
if (method.kind !== "redirect" || !method.start) {
|
||||
res.status(400).json({ error: "Not a redirect login method" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await method.start(req as never, instanceId);
|
||||
// auth_url is the name 2.8 clients read.
|
||||
res.json({ ...result, auth_url: result.redirectUrl });
|
||||
} catch (error) {
|
||||
sendLoginError(res, error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs a form method and answers with a session or a second-factor step. */
|
||||
export async function verifyFormLogin(
|
||||
req: Request,
|
||||
res: Response,
|
||||
methodId: string,
|
||||
instanceId: string | null,
|
||||
): Promise<void> {
|
||||
const method = methodOr404(req, res, methodId);
|
||||
if (!method) return;
|
||||
if (method.kind !== "form" || !method.verify) {
|
||||
res.status(400).json({ error: "Not a form login method" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const identity = await method.verify(req as never, instanceId);
|
||||
await respondWithLogin(req, res, identity, {
|
||||
methodId: method.id,
|
||||
rememberMe: !!identity.rememberMe || !!req.body?.rememberMe,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof LoginMethodError)) {
|
||||
authLogger.error("Form login failed", error, { methodId: method.id });
|
||||
}
|
||||
sendLoginError(res, error);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerAuthRoutes(router: Router): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/auth/methods:
|
||||
* get:
|
||||
* summary: List login methods
|
||||
* description: Public. The login methods with at least one enabled instance, for the login screen. Carries ids and labels only.
|
||||
* tags:
|
||||
* - Auth
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Login methods.
|
||||
*/
|
||||
router.get("/auth/methods", async (_req, res) => {
|
||||
try {
|
||||
res.json({ methods: await listPublicLoginMethods() });
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to list login methods", error);
|
||||
res.status(500).json({ error: "Failed to list login methods" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/auth/second-factor/{factorId}/challenge:
|
||||
* post:
|
||||
* summary: Start a second factor
|
||||
* description: Returns whatever the factor needs the client to have before the user answers, for a login waiting on a second factor.
|
||||
* tags:
|
||||
* - Auth
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: factorId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Challenge data.
|
||||
* 401:
|
||||
* description: No pending login.
|
||||
* 404:
|
||||
* description: Factor not enabled for this user.
|
||||
*/
|
||||
router.post("/auth/second-factor/:factorId/challenge", async (req, res) => {
|
||||
try {
|
||||
const lookup = await readPendingLogin(req);
|
||||
if (!lookup) {
|
||||
return res.status(401).json({ error: "Invalid temporary token" });
|
||||
}
|
||||
const { required } = await evaluateSecondFactors(lookup.userId);
|
||||
const factor = required.find(
|
||||
(candidate) => candidate.id === req.params.factorId,
|
||||
);
|
||||
if (!factor) {
|
||||
return res.status(404).json({ error: "Second factor not enabled" });
|
||||
}
|
||||
res.json({
|
||||
challenge: (await factor.challenge?.(lookup.userId)) ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
sendLoginError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/auth/second-factor/{factorId}/verify:
|
||||
* post:
|
||||
* summary: Finish a second factor
|
||||
* description: Checks the user's answer for a login waiting on a second factor and issues the session. The pending token comes from temp_token in the body or the pending-login cookie.
|
||||
* tags:
|
||||
* - Auth
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: factorId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Login successful.
|
||||
* 401:
|
||||
* description: Wrong answer or no pending login.
|
||||
* 403:
|
||||
* description: An enrolled factor is unavailable.
|
||||
* 429:
|
||||
* description: Too many attempts.
|
||||
*/
|
||||
router.post("/auth/second-factor/:factorId/verify", async (req, res) => {
|
||||
try {
|
||||
await verifySecondFactorAndRespond(req, res, String(req.params.factorId));
|
||||
} catch (error) {
|
||||
sendLoginError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/totp/verify-login:
|
||||
* post:
|
||||
* summary: Finish a login with a second factor (2.8 route)
|
||||
* description: Kept for clients built against 2.8, such as Termix-Mobile. Verifies the pending login against the factor named in `factor`, or the user's first enrolled factor, the same way as /users/auth/second-factor/{factorId}/verify.
|
||||
* tags:
|
||||
* - Auth
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* temp_token:
|
||||
* type: string
|
||||
* totp_code:
|
||||
* type: string
|
||||
* factor:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Login finished.
|
||||
* 400:
|
||||
* description: Token and code are required.
|
||||
* 401:
|
||||
* description: Invalid temporary token or code.
|
||||
*/
|
||||
router.post("/totp/verify-login", async (req, res) => {
|
||||
if (!req.body?.temp_token || !req.body?.totp_code) {
|
||||
return res.status(400).json({ error: "Token and code are required" });
|
||||
}
|
||||
try {
|
||||
await verifySecondFactorAndRespond(
|
||||
req,
|
||||
res,
|
||||
typeof req.body.factor === "string" ? req.body.factor : undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
sendLoginError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/auth/{methodId}/start:
|
||||
* get:
|
||||
* summary: Start a redirect login
|
||||
* description: Returns the URL to send the browser to for a redirect login method, such as an SSO provider.
|
||||
* tags:
|
||||
* - Auth
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: methodId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: instance
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Redirect URL.
|
||||
* 404:
|
||||
* description: Unknown login method.
|
||||
*/
|
||||
router.get("/auth/:methodId/start", (req, res) =>
|
||||
startRedirectLogin(
|
||||
req,
|
||||
res,
|
||||
String(req.params.methodId),
|
||||
instanceParam(req),
|
||||
),
|
||||
);
|
||||
|
||||
const callback = async (req: Request, res: Response) => {
|
||||
const method = methodOr404(req, res);
|
||||
if (!method) return;
|
||||
await handleRedirectCallback(method, req, res);
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/auth/{methodId}/callback:
|
||||
* get:
|
||||
* summary: Redirect login callback
|
||||
* description: Where a redirect login method's provider sends the browser back. Redirects to the app with a session, a second-factor step, or an error.
|
||||
* tags:
|
||||
* - Auth
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: methodId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 302:
|
||||
* description: Back to the app.
|
||||
* post:
|
||||
* summary: Redirect login callback (form post)
|
||||
* description: Same as the GET form, for providers that post their answer.
|
||||
* tags:
|
||||
* - Auth
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: methodId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 302:
|
||||
* description: Back to the app.
|
||||
*/
|
||||
router.get("/auth/:methodId/callback", callback);
|
||||
router.post("/auth/:methodId/callback", callback);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/auth/{methodId}/verify:
|
||||
* post:
|
||||
* summary: Form login
|
||||
* description: Checks what the user typed for a form login method, then issues a session or asks for a second factor.
|
||||
* tags:
|
||||
* - Auth
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: methodId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Login successful, or a second factor is required.
|
||||
* 401:
|
||||
* description: Wrong credentials.
|
||||
* 404:
|
||||
* description: Unknown login method.
|
||||
*/
|
||||
router.post("/auth/:methodId/verify", (req, res) =>
|
||||
verifyFormLogin(req, res, String(req.params.methodId), instanceParam(req)),
|
||||
);
|
||||
}
|
||||
@@ -1,822 +0,0 @@
|
||||
import crypto from "node:crypto";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type {
|
||||
AutomationDefinition,
|
||||
Step,
|
||||
Trigger,
|
||||
} from "../../../types/automations.js";
|
||||
import { AUTOMATION_DEFINITION_VERSION } from "../../../types/automations.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
getAuditUsername,
|
||||
getRequestMeta,
|
||||
logAudit,
|
||||
} from "../../utils/audit-logger.js";
|
||||
import { createCurrentAutomationRepository } from "../repositories/factory.js";
|
||||
import type { AutomationRow } from "../repositories/automation-repository.js";
|
||||
import { AutomationEngine } from "../../automations/engine.js";
|
||||
import {
|
||||
computeNextDueAt,
|
||||
isValidCron,
|
||||
isValidTimezone,
|
||||
} from "../../automations/cron.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const permissionManager = PermissionManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
|
||||
const TRIGGER_KINDS = new Set([
|
||||
"metric_threshold",
|
||||
"host_status",
|
||||
"health_check",
|
||||
"schedule",
|
||||
"docker_event",
|
||||
"internal_event",
|
||||
"webhook",
|
||||
]);
|
||||
|
||||
const STEP_TYPES = new Set([
|
||||
"notify",
|
||||
"http",
|
||||
"run_snippet",
|
||||
"run_command",
|
||||
"docker",
|
||||
"tunnel",
|
||||
"wol",
|
||||
"wait",
|
||||
"set_var",
|
||||
"if",
|
||||
"run_automation",
|
||||
"stop",
|
||||
]);
|
||||
|
||||
const OPERATORS = new Set([
|
||||
">",
|
||||
"<",
|
||||
">=",
|
||||
"<=",
|
||||
"==",
|
||||
"!=",
|
||||
"contains",
|
||||
"not_contains",
|
||||
"changed",
|
||||
]);
|
||||
|
||||
function parseId(raw: unknown): number | null {
|
||||
const id = typeof raw === "string" ? parseInt(raw, 10) : NaN;
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a definition before it is stored. The engine treats the stored
|
||||
* blob as trusted, so everything it relies on is checked once here.
|
||||
*/
|
||||
export function validateDefinition(value: unknown): {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
definition?: AutomationDefinition;
|
||||
} {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return { ok: false, error: "Definition must be an object" };
|
||||
}
|
||||
|
||||
const candidate = value as Partial<AutomationDefinition>;
|
||||
const trigger = candidate.trigger as Trigger | undefined;
|
||||
|
||||
if (!trigger || !TRIGGER_KINDS.has(trigger.kind)) {
|
||||
return { ok: false, error: "Unknown or missing trigger kind" };
|
||||
}
|
||||
|
||||
if (trigger.kind === "metric_threshold") {
|
||||
if (!OPERATORS.has(trigger.operator)) {
|
||||
return { ok: false, error: "Unknown comparison operator" };
|
||||
}
|
||||
if (typeof trigger.value !== "number" || !Number.isFinite(trigger.value)) {
|
||||
return { ok: false, error: "Threshold value must be a number" };
|
||||
}
|
||||
if (!trigger.metric?.path) {
|
||||
return { ok: false, error: "Trigger is missing a metric" };
|
||||
}
|
||||
}
|
||||
|
||||
if (trigger.kind === "schedule") {
|
||||
const hasInterval =
|
||||
typeof trigger.intervalSeconds === "number" &&
|
||||
trigger.intervalSeconds > 0;
|
||||
const hasCron = isNonEmptyString(trigger.cron);
|
||||
if (!hasInterval && !hasCron) {
|
||||
return { ok: false, error: "Schedule needs an interval or a cron" };
|
||||
}
|
||||
if (hasCron && !isValidCron(trigger.cron as string)) {
|
||||
return { ok: false, error: "Cron expression is not valid" };
|
||||
}
|
||||
if (hasInterval && (trigger.intervalSeconds as number) < 60) {
|
||||
return { ok: false, error: "Interval must be at least 60 seconds" };
|
||||
}
|
||||
if (
|
||||
isNonEmptyString(trigger.timezone) &&
|
||||
!isValidTimezone(trigger.timezone)
|
||||
) {
|
||||
return { ok: false, error: "Time zone is not valid" };
|
||||
}
|
||||
}
|
||||
|
||||
const steps = candidate.steps;
|
||||
if (!Array.isArray(steps)) {
|
||||
return { ok: false, error: "Definition must include a steps array" };
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const stepError = validateSteps(steps as Step[], seen);
|
||||
if (stepError) return { ok: false, error: stepError };
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
definition: {
|
||||
version: candidate.version ?? AUTOMATION_DEFINITION_VERSION,
|
||||
trigger,
|
||||
steps: steps as Step[],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validateSteps(steps: Step[], seen: Set<string>): string | null {
|
||||
for (const step of steps) {
|
||||
if (!step || typeof step !== "object") return "Step must be an object";
|
||||
if (!isNonEmptyString(step.id)) return "Every step needs an id";
|
||||
if (seen.has(step.id)) return `Duplicate step id: ${step.id}`;
|
||||
seen.add(step.id);
|
||||
if (!STEP_TYPES.has(step.type)) {
|
||||
return `Unknown step type: ${step.type}`;
|
||||
}
|
||||
|
||||
if (step.type === "if") {
|
||||
if (!step.condition || !OPERATORS.has(step.condition.operator)) {
|
||||
return "Condition needs a valid operator";
|
||||
}
|
||||
const thenError = validateSteps(step.then ?? [], seen);
|
||||
if (thenError) return thenError;
|
||||
const elseError = validateSteps(step.else ?? [], seen);
|
||||
if (elseError) return elseError;
|
||||
}
|
||||
|
||||
if (step.type === "http" && !isNonEmptyString(step.url)) {
|
||||
return "HTTP steps need a URL";
|
||||
}
|
||||
if (step.type === "run_command" && !isNonEmptyString(step.command)) {
|
||||
return "Command steps need a command";
|
||||
}
|
||||
if (step.type === "wait" && typeof step.seconds !== "number") {
|
||||
return "Wait steps need a number of seconds";
|
||||
}
|
||||
if (step.type === "set_var" && !isNonEmptyString(step.name)) {
|
||||
return "Variable steps need a name";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Never leak a webhook token hash to the client. */
|
||||
function serialize(row: AutomationRow) {
|
||||
let definition: AutomationDefinition | null = null;
|
||||
try {
|
||||
definition = JSON.parse(row.definition) as AutomationDefinition;
|
||||
} catch {
|
||||
definition = null;
|
||||
}
|
||||
|
||||
if (definition?.trigger?.kind === "webhook") {
|
||||
definition = {
|
||||
...definition,
|
||||
trigger: { ...definition.trigger, tokenHash: "" },
|
||||
};
|
||||
}
|
||||
|
||||
return { ...row, definition };
|
||||
}
|
||||
|
||||
async function syncSchedule(
|
||||
automationId: number,
|
||||
definition: AutomationDefinition,
|
||||
): Promise<void> {
|
||||
const repository = createCurrentAutomationRepository();
|
||||
if (definition.trigger?.kind !== "schedule") {
|
||||
await repository.deleteSchedule(automationId);
|
||||
return;
|
||||
}
|
||||
|
||||
const trigger = definition.trigger;
|
||||
await repository.upsertSchedule({
|
||||
automationId,
|
||||
cron: trigger.cron ?? null,
|
||||
intervalSeconds: trigger.intervalSeconds ?? null,
|
||||
timezone: trigger.timezone ?? null,
|
||||
nextDueAt: computeNextDueAt({
|
||||
cron: trigger.cron,
|
||||
intervalSeconds: trigger.intervalSeconds,
|
||||
timezone: trigger.timezone,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /automations:
|
||||
* get:
|
||||
* summary: List the current user's automations
|
||||
* description: Returns every automation the caller owns, with its parsed definition and linked notification channels.
|
||||
* tags:
|
||||
* - Automations
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of automations.
|
||||
* 403:
|
||||
* description: Missing the automations.view permission.
|
||||
*/
|
||||
router.get(
|
||||
"/",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("automations.view"),
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const rows = await createCurrentAutomationRepository().list(userId);
|
||||
res.json(rows.map(serialize));
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to list automations", error, {
|
||||
operation: "automation_list_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list automations" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /automations/{id}:
|
||||
* get:
|
||||
* summary: Fetch a single automation
|
||||
* tags:
|
||||
* - Automations
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The automation.
|
||||
* 404:
|
||||
* description: Automation not found.
|
||||
*/
|
||||
router.get(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("automations.view"),
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = parseId(req.params.id);
|
||||
if (id === null) return res.status(400).json({ error: "Invalid id" });
|
||||
|
||||
try {
|
||||
const row = await createCurrentAutomationRepository().findForUser(
|
||||
id,
|
||||
userId,
|
||||
);
|
||||
if (!row) return res.status(404).json({ error: "Automation not found" });
|
||||
res.json(serialize(row));
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to fetch automation", error, {
|
||||
operation: "automation_get_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to fetch automation" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /automations:
|
||||
* post:
|
||||
* summary: Create an automation
|
||||
* description: Validates the trigger and every step before storing the definition. A schedule trigger also registers its next due time.
|
||||
* tags:
|
||||
* - Automations
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* definition:
|
||||
* type: object
|
||||
* responses:
|
||||
* 201:
|
||||
* description: The created automation.
|
||||
* 400:
|
||||
* description: Validation failed.
|
||||
*/
|
||||
router.post(
|
||||
"/",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("automations.create"),
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { name, description, enabled, definition, concurrencyPolicy } =
|
||||
req.body ?? {};
|
||||
|
||||
if (!isNonEmptyString(name)) {
|
||||
return res.status(400).json({ error: "Name is required" });
|
||||
}
|
||||
|
||||
const validated = validateDefinition(definition);
|
||||
if (!validated.ok || !validated.definition) {
|
||||
return res.status(400).json({ error: validated.error });
|
||||
}
|
||||
|
||||
// A webhook trigger's token is shown once here and only stored hashed.
|
||||
let webhookToken: string | undefined;
|
||||
if (validated.definition.trigger.kind === "webhook") {
|
||||
webhookToken = crypto.randomBytes(32).toString("hex");
|
||||
validated.definition = {
|
||||
...validated.definition,
|
||||
trigger: {
|
||||
kind: "webhook",
|
||||
tokenHash: hashToken(webhookToken),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const repository = createCurrentAutomationRepository();
|
||||
const created = await repository.create({
|
||||
userId,
|
||||
name: name.trim(),
|
||||
description: description ?? null,
|
||||
enabled: enabled !== false,
|
||||
definition: JSON.stringify(validated.definition),
|
||||
concurrencyPolicy,
|
||||
channels: Array.isArray(req.body?.channels) ? req.body.channels : [],
|
||||
});
|
||||
|
||||
await syncSchedule(created.id, validated.definition);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action: "create_automation",
|
||||
resourceType: "automation",
|
||||
resourceId: String(created.id),
|
||||
resourceName: created.name,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.status(201).json({ ...serialize(created), webhookToken });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to create automation", error, {
|
||||
operation: "automation_create_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to create automation" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /automations/{id}:
|
||||
* put:
|
||||
* summary: Update an automation
|
||||
* tags:
|
||||
* - Automations
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The updated automation.
|
||||
* 400:
|
||||
* description: Validation failed.
|
||||
* 404:
|
||||
* description: Automation not found.
|
||||
*/
|
||||
router.put(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("automations.edit"),
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = parseId(req.params.id);
|
||||
if (id === null) return res.status(400).json({ error: "Invalid id" });
|
||||
|
||||
const repository = createCurrentAutomationRepository();
|
||||
const existing = await repository.findForUser(id, userId);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "Automation not found" });
|
||||
}
|
||||
|
||||
const update: Record<string, unknown> = {};
|
||||
if (req.body?.name !== undefined) {
|
||||
if (!isNonEmptyString(req.body.name)) {
|
||||
return res.status(400).json({ error: "Name cannot be empty" });
|
||||
}
|
||||
update.name = req.body.name.trim();
|
||||
}
|
||||
if (req.body?.description !== undefined) {
|
||||
update.description = req.body.description;
|
||||
}
|
||||
if (req.body?.enabled !== undefined) update.enabled = !!req.body.enabled;
|
||||
if (req.body?.concurrencyPolicy !== undefined) {
|
||||
update.concurrencyPolicy = req.body.concurrencyPolicy;
|
||||
}
|
||||
if (Array.isArray(req.body?.channels)) update.channels = req.body.channels;
|
||||
|
||||
let parsedDefinition: AutomationDefinition | null = null;
|
||||
if (req.body?.definition !== undefined) {
|
||||
const validated = validateDefinition(req.body.definition);
|
||||
if (!validated.ok || !validated.definition) {
|
||||
return res.status(400).json({ error: validated.error });
|
||||
}
|
||||
|
||||
// Keep the stored token hash: the raw token is only ever shown once.
|
||||
if (validated.definition.trigger.kind === "webhook") {
|
||||
const previous = JSON.parse(
|
||||
existing.definition,
|
||||
) as AutomationDefinition;
|
||||
const previousHash =
|
||||
previous.trigger?.kind === "webhook"
|
||||
? previous.trigger.tokenHash
|
||||
: "";
|
||||
validated.definition = {
|
||||
...validated.definition,
|
||||
trigger: { kind: "webhook", tokenHash: previousHash },
|
||||
};
|
||||
}
|
||||
|
||||
parsedDefinition = validated.definition;
|
||||
update.definition = JSON.stringify(validated.definition);
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await repository.update(id, userId, update);
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: "Automation not found" });
|
||||
}
|
||||
|
||||
if (parsedDefinition) await syncSchedule(id, parsedDefinition);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action: "update_automation",
|
||||
resourceType: "automation",
|
||||
resourceId: String(id),
|
||||
resourceName: updated.name,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json(serialize(updated));
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to update automation", error, {
|
||||
operation: "automation_update_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to update automation" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /automations/{id}:
|
||||
* delete:
|
||||
* summary: Delete an automation
|
||||
* tags:
|
||||
* - Automations
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Deleted.
|
||||
* 404:
|
||||
* description: Automation not found.
|
||||
*/
|
||||
router.delete(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("automations.delete"),
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = parseId(req.params.id);
|
||||
if (id === null) return res.status(400).json({ error: "Invalid id" });
|
||||
|
||||
try {
|
||||
const deleted = await createCurrentAutomationRepository().delete(
|
||||
id,
|
||||
userId,
|
||||
);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: "Automation not found" });
|
||||
}
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action: "delete_automation",
|
||||
resourceType: "automation",
|
||||
resourceId: String(id),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to delete automation", error, {
|
||||
operation: "automation_delete_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to delete automation" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /automations/{id}/run:
|
||||
* post:
|
||||
* summary: Run an automation now
|
||||
* description: Runs immediately, as the automation's owner. Pass dryRun to record what each step would do without touching anything outside Termix.
|
||||
* tags:
|
||||
* - Automations
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* dryRun:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The run outcome.
|
||||
* 404:
|
||||
* description: Automation not found.
|
||||
*/
|
||||
router.post(
|
||||
"/:id/run",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("automations.run"),
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = parseId(req.params.id);
|
||||
if (id === null) return res.status(400).json({ error: "Invalid id" });
|
||||
|
||||
const existing = await createCurrentAutomationRepository().findForUser(
|
||||
id,
|
||||
userId,
|
||||
);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "Automation not found" });
|
||||
}
|
||||
|
||||
try {
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
automationId: id,
|
||||
triggerType: "manual",
|
||||
triggerContext: { manual: true, requestedBy: userId },
|
||||
dryRun: req.body?.dryRun === true,
|
||||
});
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action: "run_automation",
|
||||
resourceType: "automation",
|
||||
resourceId: String(id),
|
||||
resourceName: existing.name,
|
||||
details: JSON.stringify({
|
||||
status: outcome.status,
|
||||
dryRun: req.body?.dryRun === true,
|
||||
}),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: outcome.status === "success",
|
||||
});
|
||||
|
||||
res.json(outcome);
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to run automation", error, {
|
||||
operation: "automation_run_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to run automation" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /automations/runs:
|
||||
* get:
|
||||
* summary: List automation runs
|
||||
* tags:
|
||||
* - Automations
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: automationId
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Recent runs, newest first.
|
||||
*/
|
||||
router.get(
|
||||
"/runs/history",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("automations.view"),
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const runs = await createCurrentAutomationRepository().listRuns(userId, {
|
||||
automationId: parseId(req.query.automationId) ?? undefined,
|
||||
limit: Number(req.query.limit) || 50,
|
||||
offset: Number(req.query.offset) || 0,
|
||||
});
|
||||
res.json(runs);
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to list automation runs", error, {
|
||||
operation: "automation_runs_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list runs" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /automations/runs/{runId}/steps:
|
||||
* get:
|
||||
* summary: Step-by-step results for a run
|
||||
* tags:
|
||||
* - Automations
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: runId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The run's steps in order.
|
||||
* 404:
|
||||
* description: Run not found.
|
||||
*/
|
||||
router.get(
|
||||
"/runs/:runId/steps",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("automations.view"),
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const runId = parseId(req.params.runId);
|
||||
if (runId === null) return res.status(400).json({ error: "Invalid id" });
|
||||
|
||||
try {
|
||||
const repository = createCurrentAutomationRepository();
|
||||
const run = await repository.findRunForUser(runId, userId);
|
||||
if (!run) return res.status(404).json({ error: "Run not found" });
|
||||
|
||||
res.json(await repository.listRunSteps(runId));
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to list run steps", error, {
|
||||
operation: "automation_run_steps_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list run steps" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /automations/webhook/{token}:
|
||||
* post:
|
||||
* summary: Trigger an automation from an external system
|
||||
* description: Unauthenticated by design; the 32-byte token in the path is the credential and is compared against a stored hash in constant time.
|
||||
* tags:
|
||||
* - Automations
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: token
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 202:
|
||||
* description: The run was accepted.
|
||||
* 404:
|
||||
* description: No automation matches that token.
|
||||
*/
|
||||
router.post("/webhook/:token", async (req: Request, res: Response) => {
|
||||
const token = req.params.token;
|
||||
if (!isNonEmptyString(token) || token.length < 32) {
|
||||
return res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
|
||||
try {
|
||||
const repository = createCurrentAutomationRepository();
|
||||
const candidates = await repository.listAllEnabled();
|
||||
const wanted = hashToken(token);
|
||||
|
||||
const match = candidates.find((row) => {
|
||||
try {
|
||||
const definition = JSON.parse(row.definition) as AutomationDefinition;
|
||||
if (definition.trigger?.kind !== "webhook") return false;
|
||||
return timingSafeEqual(definition.trigger.tokenHash, wanted);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!match) return res.status(404).json({ error: "Not found" });
|
||||
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
automationId: match.id,
|
||||
triggerType: "webhook",
|
||||
triggerContext: {
|
||||
body: req.body ?? {},
|
||||
receivedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
res.status(202).json({ runId: outcome.runId, status: outcome.status });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Webhook automation failed", error, {
|
||||
operation: "automation_webhook_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to run automation" });
|
||||
}
|
||||
});
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return crypto.createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
function timingSafeEqual(a: string, b: string): boolean {
|
||||
const left = Buffer.from(a || "", "utf8");
|
||||
const right = Buffer.from(b || "", "utf8");
|
||||
if (left.length !== right.length) return false;
|
||||
return crypto.timingSafeEqual(left, right);
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -1,293 +0,0 @@
|
||||
import type {
|
||||
AuthenticatedRequest,
|
||||
TunnelConnection,
|
||||
} from "../../../types/index.js";
|
||||
import express, { type Request, type 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();
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
}
|
||||
|
||||
function parsePreset(row: C2sTunnelPresetRecord) {
|
||||
return {
|
||||
...row,
|
||||
config: JSON.parse(row.config) as TunnelConnection[],
|
||||
};
|
||||
}
|
||||
|
||||
function validateConfig(config: unknown): config is TunnelConnection[] {
|
||||
if (!Array.isArray(config)) return false;
|
||||
return config.every((item) => {
|
||||
if (!item || typeof item !== "object") return false;
|
||||
const tunnel = item as Partial<TunnelConnection>;
|
||||
const mode = tunnel.mode || tunnel.tunnelType;
|
||||
return (
|
||||
tunnel.scope === "c2s" &&
|
||||
(mode === "local" || mode === "remote" || mode === "dynamic") &&
|
||||
typeof tunnel.sourcePort === "number" &&
|
||||
tunnel.sourcePort >= 1 &&
|
||||
tunnel.sourcePort <= 65535 &&
|
||||
(mode === "dynamic" ||
|
||||
(typeof tunnel.endpointPort === "number" &&
|
||||
tunnel.endpointPort >= 1 &&
|
||||
tunnel.endpointPort <= 65535))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @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,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (!isNonEmptyString(userId)) {
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
}
|
||||
|
||||
try {
|
||||
const result =
|
||||
await createCurrentC2sTunnelPresetRepository().listByUserId(userId);
|
||||
res.json(result.map(parsePreset));
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to fetch C2S tunnel presets", error);
|
||||
res.status(500).json({ error: "Failed to fetch C2S tunnel presets" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @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,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { name, config, platform, computerName } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !isNonEmptyString(name)) {
|
||||
return res.status(400).json({ error: "Preset name is required" });
|
||||
}
|
||||
if (!validateConfig(config)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Invalid C2S tunnel configuration" });
|
||||
}
|
||||
|
||||
const trimmedName = name.trim();
|
||||
try {
|
||||
const presetRepository = createCurrentC2sTunnelPresetRepository();
|
||||
if (await presetRepository.hasNameForUser(userId, trimmedName)) {
|
||||
return res.status(409).json({ error: "Preset name already exists" });
|
||||
}
|
||||
|
||||
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: created.id,
|
||||
});
|
||||
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" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @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,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = Number(req.params.id);
|
||||
const { name, config, platform, computerName } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !Number.isInteger(id)) {
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
|
||||
try {
|
||||
const presetRepository = createCurrentC2sTunnelPresetRepository();
|
||||
const existing = await presetRepository.findByIdForUser(userId, id);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "Preset not found" });
|
||||
}
|
||||
|
||||
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();
|
||||
if (await presetRepository.hasNameForUser(userId, trimmedName, id)) {
|
||||
return res.status(409).json({ error: "Preset name already exists" });
|
||||
}
|
||||
updateFields.name = trimmedName;
|
||||
}
|
||||
|
||||
if (config !== undefined) {
|
||||
if (!validateConfig(config)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Invalid C2S tunnel configuration" });
|
||||
}
|
||||
updateFields.config = JSON.stringify(config);
|
||||
}
|
||||
if (platform !== undefined)
|
||||
updateFields.platform = platform?.trim() || null;
|
||||
if (computerName !== undefined)
|
||||
updateFields.computerName = computerName?.trim() || null;
|
||||
|
||||
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" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @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,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = Number(req.params.id);
|
||||
|
||||
if (!isNonEmptyString(userId) || !Number.isInteger(id)) {
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
|
||||
try {
|
||||
const presetRepository = createCurrentC2sTunnelPresetRepository();
|
||||
const existing = await presetRepository.findByIdForUser(userId, id);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "Preset not found" });
|
||||
}
|
||||
|
||||
await presetRepository.deleteForUser(userId, id);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to delete C2S tunnel preset", error);
|
||||
res.status(500).json({ error: "Failed to delete C2S tunnel preset" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -4,28 +4,56 @@ import type {
|
||||
CredentialBackend,
|
||||
} from "../../../types/index.js";
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import ssh2Pkg from "ssh2";
|
||||
import type { Client as SshClient } from "ssh2";
|
||||
import { createCurrentHostResolutionRepository } from "../repositories/factory.js";
|
||||
import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js";
|
||||
import { connectHost } from "../../hosts/connect/connect-host.js";
|
||||
import type { SshConnectHost } from "../../hosts/connect/types.js";
|
||||
import { resolveHostById } from "../../hosts/host-resolver.js";
|
||||
|
||||
const { Client } = ssh2Pkg;
|
||||
function describeDeployError(err: unknown): string {
|
||||
const message = getErrorMessage(err, "Connection failed");
|
||||
if (message.includes("All configured authentication methods failed")) {
|
||||
return "Authentication failed. Please check your credentials and ensure the SSH service is running.";
|
||||
}
|
||||
if (message.includes("ENOTFOUND") || message.includes("ENOENT")) {
|
||||
return "Could not resolve hostname or connect to server.";
|
||||
}
|
||||
if (message.includes("ECONNREFUSED")) {
|
||||
return "Connection refused. The server may not be running or the port may be incorrect.";
|
||||
}
|
||||
if (message.includes("ETIMEDOUT")) {
|
||||
return "Connection timed out. Check your network connection and server availability.";
|
||||
}
|
||||
if (
|
||||
message.includes("authentication failed") ||
|
||||
message.includes("Permission denied")
|
||||
) {
|
||||
return "Authentication failed. Please check your username and password/key.";
|
||||
}
|
||||
if (message === "SSH connection timeout") return "Connection timeout";
|
||||
return message;
|
||||
}
|
||||
|
||||
async function deploySSHKeyToHost(
|
||||
hostConfig: Record<string, unknown>,
|
||||
host: SshConnectHost,
|
||||
userId: string,
|
||||
credData: CredentialBackend,
|
||||
): Promise<{ success: boolean; message?: string; error?: string }> {
|
||||
const publicKey = credData.publicKey as string;
|
||||
|
||||
let conn: SshClient;
|
||||
try {
|
||||
({ client: conn } = await connectHost(host, {
|
||||
userId,
|
||||
purpose: "credential-deploy",
|
||||
timeoutMs: 120000,
|
||||
}));
|
||||
} catch (error) {
|
||||
return { success: false, error: describeDeployError(error) };
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const conn = new Client();
|
||||
|
||||
const connectionTimeout = setTimeout(() => {
|
||||
conn.destroy();
|
||||
resolve({ success: false, error: "Connection timeout" });
|
||||
}, 120000);
|
||||
|
||||
conn.on("ready", async () => {
|
||||
clearTimeout(connectionTimeout);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await new Promise<void>((resolveCmd, rejectCmd) => {
|
||||
const cmdTimeout = setTimeout(() => {
|
||||
@@ -238,121 +266,7 @@ async function deploySSHKeyToHost(
|
||||
error: getErrorMessage(error, "Deployment failed"),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
conn.on("error", (err) => {
|
||||
clearTimeout(connectionTimeout);
|
||||
let errorMessage = err.message;
|
||||
|
||||
if (
|
||||
err.message.includes("All configured authentication methods failed")
|
||||
) {
|
||||
errorMessage =
|
||||
"Authentication failed. Please check your credentials and ensure the SSH service is running.";
|
||||
} else if (
|
||||
err.message.includes("ENOTFOUND") ||
|
||||
err.message.includes("ENOENT")
|
||||
) {
|
||||
errorMessage = "Could not resolve hostname or connect to server.";
|
||||
} else if (err.message.includes("ECONNREFUSED")) {
|
||||
errorMessage =
|
||||
"Connection refused. The server may not be running or the port may be incorrect.";
|
||||
} else if (err.message.includes("ETIMEDOUT")) {
|
||||
errorMessage =
|
||||
"Connection timed out. Check your network connection and server availability.";
|
||||
} else if (
|
||||
err.message.includes("authentication failed") ||
|
||||
err.message.includes("Permission denied")
|
||||
) {
|
||||
errorMessage =
|
||||
"Authentication failed. Please check your username and password/key.";
|
||||
}
|
||||
|
||||
resolve({ success: false, error: errorMessage });
|
||||
});
|
||||
|
||||
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: ${getErrorMessage(keyError)}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
clearTimeout(connectionTimeout);
|
||||
resolve({
|
||||
success: false,
|
||||
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: getErrorMessage(error, "Connection failed"),
|
||||
});
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -456,6 +370,8 @@ export function registerCredentialDeployRoutes(
|
||||
error: "Public key is required for deployment",
|
||||
});
|
||||
}
|
||||
// Writing authorized_keys is an owner action, so ownership is checked
|
||||
// before the shared resolver fills in the credentials.
|
||||
const hostData = await repository.findHostByIdForUser(
|
||||
targetHostId,
|
||||
userId,
|
||||
@@ -468,57 +384,19 @@ export function registerCredentialDeployRoutes(
|
||||
});
|
||||
}
|
||||
|
||||
const hostConfig = {
|
||||
ip: hostData.ip,
|
||||
port: hostData.port,
|
||||
username: hostData.username,
|
||||
authType: hostData.authType,
|
||||
password: hostData.password,
|
||||
privateKey: hostData.key,
|
||||
keyPassword: hostData.keyPassword,
|
||||
};
|
||||
|
||||
if (hostData.authType === "credential" && hostData.credentialId) {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (!userId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Authentication required for credential resolution",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const hostCredential = await repository.findCredentialByIdForUser(
|
||||
hostData.credentialId as number,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (hostCredential) {
|
||||
hostConfig.authType = hostCredential.authType;
|
||||
hostConfig.username = hostCredential.username;
|
||||
|
||||
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({
|
||||
success: false,
|
||||
error: "Host credential not found",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to resolve host credentials",
|
||||
});
|
||||
}
|
||||
const resolvedHost = await resolveHostById(targetHostId, userId);
|
||||
if (!resolvedHost) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Host credential not found",
|
||||
});
|
||||
}
|
||||
|
||||
const deployResult = await deploySSHKeyToHost(hostConfig, credData);
|
||||
const deployResult = await deploySSHKeyToHost(
|
||||
resolvedHost as unknown as SshConnectHost,
|
||||
userId,
|
||||
credData,
|
||||
);
|
||||
|
||||
if (deployResult.success) {
|
||||
res.json({
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { listCredentialTypes } from "../../hosts/connect/auth-provider-registry.js";
|
||||
import { ensureCoreSshAuthProviders } from "../../hosts/connect/core-providers.js";
|
||||
import { deleteOwnedCredential } from "../../hosts/delete-credential.js";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
@@ -20,10 +23,18 @@ import {
|
||||
createCurrentRoleRepository,
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentSyncTombstoneRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import { rejectSharedCopyWrites } from "../../sync/shared-copy-guard.js";
|
||||
import { parseSharedSource } from "./host-normalizers.js";
|
||||
|
||||
/** Built-in password and key, plus any type a plugin offers for credentials. */
|
||||
function getCredentialTypes(): string[] {
|
||||
ensureCoreSshAuthProviders();
|
||||
return listCredentialTypes();
|
||||
}
|
||||
|
||||
const router = express.Router();
|
||||
router.use(rejectSharedCopyWrites("credential", /^\/(\d+)$/));
|
||||
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
@@ -110,16 +121,17 @@ router.post(
|
||||
return res.status(400).json({ error: "Name is required" });
|
||||
}
|
||||
|
||||
if (!["password", "key"].includes(authType)) {
|
||||
const credentialTypes = getCredentialTypes();
|
||||
if (!credentialTypes.includes(authType)) {
|
||||
authLogger.warn("Invalid auth type provided", {
|
||||
operation: "credential_create",
|
||||
userId,
|
||||
name,
|
||||
authType,
|
||||
});
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'Auth type must be "password" or "key"' });
|
||||
return res.status(400).json({
|
||||
error: `Auth type must be one of: ${credentialTypes.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -659,8 +671,14 @@ router.put(
|
||||
}
|
||||
if (updateData.username !== undefined)
|
||||
updateFields.username = updateData.username?.trim() || null;
|
||||
if (updateData.authType !== undefined)
|
||||
if (updateData.authType !== undefined) {
|
||||
if (!getCredentialTypes().includes(updateData.authType)) {
|
||||
return res.status(400).json({
|
||||
error: `Auth type must be one of: ${getCredentialTypes().join(", ")}`,
|
||||
});
|
||||
}
|
||||
updateFields.authType = updateData.authType;
|
||||
}
|
||||
if (updateData.keyType !== undefined)
|
||||
updateFields.keyType = updateData.keyType;
|
||||
|
||||
@@ -801,59 +819,11 @@ router.delete(
|
||||
|
||||
try {
|
||||
const credentialId = parseInt(id);
|
||||
const credentialToDelete =
|
||||
await createCurrentCredentialRepository().findDecryptedByIdForUser(
|
||||
userId,
|
||||
credentialId,
|
||||
);
|
||||
|
||||
if (!credentialToDelete) {
|
||||
const deleted = await deleteOwnedCredential(userId, credentialId);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: "Credential not found" });
|
||||
}
|
||||
|
||||
const hostsUsingCredential =
|
||||
await createCurrentHostResolutionRepository().listHostsUsingCredentialForUser(
|
||||
userId,
|
||||
credentialId,
|
||||
);
|
||||
|
||||
if (hostsUsingCredential.length > 0) {
|
||||
await createCurrentHostRepository().updateManyForUser(
|
||||
userId,
|
||||
hostsUsingCredential.map((host) => host.id),
|
||||
{
|
||||
credentialId: null,
|
||||
password: null,
|
||||
key: null,
|
||||
keyPassword: null,
|
||||
authType: "password",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { SharedHostSecretsManager } =
|
||||
await import("../../utils/shared-host-secrets-manager.js");
|
||||
const sharedSecretsManager = SharedHostSecretsManager.getInstance();
|
||||
await sharedSecretsManager.deleteForCredential(credentialId);
|
||||
|
||||
await createCurrentCredentialRepository().deleteForUser(
|
||||
userId,
|
||||
credentialId,
|
||||
);
|
||||
if (credentialToDelete.syncId) {
|
||||
await createCurrentSyncTombstoneRepository().record(
|
||||
userId,
|
||||
"sshCredentials",
|
||||
credentialToDelete.syncId,
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -867,7 +837,7 @@ router.delete(
|
||||
action: "delete_credential",
|
||||
resourceType: "credential",
|
||||
resourceId: id,
|
||||
resourceName: String(credentialToDelete.name ?? id),
|
||||
resourceName: String(deleted.name ?? id),
|
||||
ipAddress: cdIp,
|
||||
userAgent: cdUa,
|
||||
success: true,
|
||||
@@ -1123,6 +1093,19 @@ function formatCredentialOutput(
|
||||
lastUsed: credential.lastUsed,
|
||||
createdAt: credential.createdAt,
|
||||
updatedAt: credential.updatedAt,
|
||||
...sharedCopyFields(credential.sharedSource),
|
||||
};
|
||||
}
|
||||
|
||||
/** A read-only copy of a credential shared with a linked desktop's account. */
|
||||
function sharedCopyFields(value: unknown): Record<string, unknown> {
|
||||
const shared = parseSharedSource(value);
|
||||
if (!shared) return {};
|
||||
return {
|
||||
isShared: true,
|
||||
sharedCopy: true,
|
||||
ownerUsername: shared.owner || null,
|
||||
permissionLevel: shared.permissionLevel === "manage" ? "manage" : "use",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1145,13 +1128,6 @@ function formatSSHHostOutput(
|
||||
: [],
|
||||
pin: !!host.pin,
|
||||
authType: host.authType,
|
||||
enableTerminal: !!host.enableTerminal,
|
||||
enableTunnel: !!host.enableTunnel,
|
||||
tunnelConnections: host.tunnelConnections
|
||||
? JSON.parse(host.tunnelConnections as string)
|
||||
: [],
|
||||
enableFileManager: host.enableFileManager !== false,
|
||||
defaultPath: host.defaultPath,
|
||||
createdAt: host.createdAt,
|
||||
updatedAt: host.updatedAt,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { dashboardLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { createCurrentRecentActivityRepository } from "../repositories/factory.js";
|
||||
import { recordRecentActivity } from "../../services/recent-activity.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
const serverStartTime = Date.now();
|
||||
|
||||
function isUserDataUnlocked(userId: string): boolean {
|
||||
return DataCrypto.getUserDataKey(userId) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /dashboard/uptime:
|
||||
* get:
|
||||
* summary: Get server uptime
|
||||
* description: Returns the uptime of the server in various formats.
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Server uptime information.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* uptimeMs:
|
||||
* type: number
|
||||
* uptimeSeconds:
|
||||
* type: number
|
||||
* formatted:
|
||||
* type: string
|
||||
* 500:
|
||||
* description: Failed to get uptime.
|
||||
*/
|
||||
router.get("/uptime", authenticateJWT, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const uptimeMs = Date.now() - serverStartTime;
|
||||
const uptimeSeconds = Math.floor(uptimeMs / 1000);
|
||||
const days = Math.floor(uptimeSeconds / 86400);
|
||||
const hours = Math.floor((uptimeSeconds % 86400) / 3600);
|
||||
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
|
||||
|
||||
res.json({
|
||||
uptimeMs,
|
||||
uptimeSeconds,
|
||||
formatted: `${days}d ${hours}h ${minutes}m`,
|
||||
});
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to get uptime", err);
|
||||
res.status(500).json({ error: "Failed to get uptime" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /dashboard/activity/recent:
|
||||
* get:
|
||||
* summary: Get recent activity
|
||||
* description: Fetches the most recent activities for the authenticated user.
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: The maximum number of activities to return.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of recent activities.
|
||||
* 401:
|
||||
* description: Session expired.
|
||||
* 500:
|
||||
* description: Failed to get recent activity.
|
||||
*/
|
||||
router.get(
|
||||
"/activity/recent",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
const limit = Number(req.query.limit) || 20;
|
||||
|
||||
const activities =
|
||||
await createCurrentRecentActivityRepository().listByUserId(
|
||||
userId,
|
||||
limit,
|
||||
);
|
||||
|
||||
res.json(activities);
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to get recent activity", err);
|
||||
res.status(500).json({ error: "Failed to get recent activity" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /dashboard/activity/log:
|
||||
* post:
|
||||
* summary: Log a new activity
|
||||
* description: Logs a new user activity, such as accessing a terminal or file manager. This endpoint is rate-limited.
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* type:
|
||||
* type: string
|
||||
* description: An activity type a plugin's tab declares, lowercase letters, digits and underscores.
|
||||
* hostId:
|
||||
* type: integer
|
||||
* hostName:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Activity logged successfully or rate-limited.
|
||||
* 400:
|
||||
* description: Invalid request body.
|
||||
* 401:
|
||||
* description: Session expired.
|
||||
* 404:
|
||||
* description: Host not found or access denied.
|
||||
* 500:
|
||||
* description: Failed to log activity.
|
||||
*/
|
||||
router.post(
|
||||
"/activity/log",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
const { type, hostId, hostName } = req.body;
|
||||
|
||||
if (!type || !hostId || !hostName) {
|
||||
return res.status(400).json({
|
||||
error: "Missing required fields: type, hostId, hostName",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await recordRecentActivity(userId, {
|
||||
type,
|
||||
hostId,
|
||||
hostName,
|
||||
});
|
||||
if (result.status === "invalid_type") {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Invalid activity type. Use lowercase letters, digits and underscores.",
|
||||
});
|
||||
}
|
||||
if (result.status === "rate_limited") {
|
||||
return res.json({
|
||||
message: "Activity already logged recently (rate limited)",
|
||||
});
|
||||
}
|
||||
if (result.status === "denied") {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: "Host not found or access denied" });
|
||||
}
|
||||
|
||||
res.json({ message: "Activity logged", id: result.id });
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to log activity", err);
|
||||
res.status(500).json({ error: "Failed to log activity" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /dashboard/activity/reset:
|
||||
* delete:
|
||||
* summary: Reset recent activity
|
||||
* description: Clears all recent activity for the authenticated user.
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Recent activity cleared.
|
||||
* 401:
|
||||
* description: Session expired.
|
||||
* 500:
|
||||
* description: Failed to reset activity.
|
||||
*/
|
||||
router.delete(
|
||||
"/activity/reset",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
await createCurrentRecentActivityRepository().deleteByUserId(userId);
|
||||
|
||||
dashboardLogger.success("Recent activity cleared", {
|
||||
operation: "reset_recent_activity",
|
||||
userId,
|
||||
});
|
||||
|
||||
res.json({ message: "Recent activity cleared" });
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to reset activity", err);
|
||||
res.status(500).json({ error: "Failed to reset activity" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -1,263 +0,0 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { dashboardLogger } from "../../utils/logger.js";
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
import { isNonEmptyString } from "./host-normalizers.js";
|
||||
import {
|
||||
createCurrentDashboardServiceLinkRepository,
|
||||
createCurrentSyncTombstoneRepository,
|
||||
} 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:
|
||||
* get:
|
||||
* summary: Get service links
|
||||
* description: Returns all dashboard service links for the authenticated user.
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of service links.
|
||||
* 500:
|
||||
* description: Failed to fetch service links.
|
||||
*/
|
||||
dashboardServiceLinksRouter.get("/", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const links =
|
||||
await createCurrentDashboardServiceLinkRepository().listByUserId(userId);
|
||||
res.json(links);
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to fetch service links", err);
|
||||
res.status(500).json({ error: "Failed to fetch service links" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /service-links:
|
||||
* post:
|
||||
* summary: Create service link
|
||||
* description: Creates a new dashboard service link for the authenticated user.
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - label
|
||||
* - url
|
||||
* properties:
|
||||
* label:
|
||||
* type: string
|
||||
* url:
|
||||
* type: string
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Service link created.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 500:
|
||||
* description: Failed to create service link.
|
||||
*/
|
||||
dashboardServiceLinksRouter.post("/", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { label, url } = req.body;
|
||||
|
||||
if (!isNonEmptyString(label) || !isNonEmptyString(url)) {
|
||||
return res.status(400).json({ error: "label and url are required" });
|
||||
}
|
||||
if (!isValidUrl(url)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "url must be a valid http or https URL" });
|
||||
}
|
||||
|
||||
try {
|
||||
const created =
|
||||
await createCurrentDashboardServiceLinkRepository().createForUser(
|
||||
userId,
|
||||
{
|
||||
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);
|
||||
res.status(500).json({ error: "Failed to create service link" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /service-links/{id}:
|
||||
* delete:
|
||||
* summary: Delete service link
|
||||
* description: Deletes a dashboard service link by ID.
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Service link deleted.
|
||||
* 400:
|
||||
* description: Invalid id.
|
||||
* 404:
|
||||
* description: Not found.
|
||||
* 500:
|
||||
* description: Failed to delete service link.
|
||||
*/
|
||||
dashboardServiceLinksRouter.delete(
|
||||
"/:id",
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const idParam = Array.isArray(req.params.id)
|
||||
? req.params.id[0]
|
||||
: req.params.id;
|
||||
const id = parseInt(idParam);
|
||||
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({ error: "Invalid id" });
|
||||
}
|
||||
|
||||
try {
|
||||
const existing =
|
||||
await createCurrentDashboardServiceLinkRepository().findByIdForUser(
|
||||
userId,
|
||||
id,
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
|
||||
const deleted =
|
||||
await createCurrentDashboardServiceLinkRepository().deleteForUser(
|
||||
userId,
|
||||
id,
|
||||
);
|
||||
if (deleted?.syncId) {
|
||||
await createCurrentSyncTombstoneRepository().record(
|
||||
userId,
|
||||
"dashboardServiceLinks",
|
||||
deleted.syncId,
|
||||
);
|
||||
}
|
||||
|
||||
DatabaseSaveTrigger.triggerSave("dashboard_service_link_deleted");
|
||||
res.json({ message: "Service link deleted" });
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to delete service link", err);
|
||||
res.status(500).json({ error: "Failed to delete service link" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /service-links/{id}:
|
||||
* put:
|
||||
* summary: Update service link
|
||||
* description: Updates label or url of a dashboard service link.
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* label:
|
||||
* type: string
|
||||
* url:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Service link updated.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 404:
|
||||
* description: Not found.
|
||||
* 500:
|
||||
* description: Failed to update service link.
|
||||
*/
|
||||
dashboardServiceLinksRouter.put("/:id", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const idParam = Array.isArray(req.params.id)
|
||||
? req.params.id[0]
|
||||
: req.params.id;
|
||||
const id = parseInt(idParam);
|
||||
const { label, url } = req.body;
|
||||
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({ error: "Invalid id" });
|
||||
}
|
||||
if (url !== undefined && !isValidUrl(url)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "url must be a valid http or https URL" });
|
||||
}
|
||||
|
||||
try {
|
||||
const existing =
|
||||
await createCurrentDashboardServiceLinkRepository().findByIdForUser(
|
||||
userId,
|
||||
id,
|
||||
);
|
||||
|
||||
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 (isNonEmptyString(url)) updates.url = url.trim();
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return res.status(400).json({ error: "Nothing to update" });
|
||||
}
|
||||
|
||||
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);
|
||||
res.status(500).json({ error: "Failed to update service link" });
|
||||
}
|
||||
});
|
||||
@@ -1,48 +1,26 @@
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
createCurrentAiRepository,
|
||||
createCurrentAlertRepository,
|
||||
createCurrentApiKeyRepository,
|
||||
createCurrentAuditLogRepository,
|
||||
createCurrentC2sTunnelPresetRepository,
|
||||
createCurrentCommandHistoryRepository,
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentDashboardServiceLinkRepository,
|
||||
createCurrentDismissedAlertRepository,
|
||||
createCurrentFileManagerBookmarkRepository,
|
||||
createCurrentHomepageItemRepository,
|
||||
createCurrentHomepageLayoutRepository,
|
||||
createCurrentHostHealthRepository,
|
||||
createCurrentHostFolderRepository,
|
||||
createCurrentHostMetricsPreferenceRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentHostSidebarPreferenceRepository,
|
||||
createCurrentCredentialSidebarPreferenceRepository,
|
||||
createCurrentUiPreferenceRepository,
|
||||
createCurrentNetworkTopologyRepository,
|
||||
createCurrentOpksshTokenRepository,
|
||||
createCurrentOpenTabRepository,
|
||||
createCurrentRecentActivityRepository,
|
||||
createCurrentRbacAccessRepository,
|
||||
createCurrentRoleRepository,
|
||||
createCurrentSessionRepository,
|
||||
createCurrentSessionRecordingRepository,
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentSharedHostSecretsRepository,
|
||||
createCurrentSnippetRepository,
|
||||
createCurrentSshCredentialUsageRepository,
|
||||
createCurrentTermixIdentityCaRepository,
|
||||
createCurrentTermixIdentityRepository,
|
||||
createCurrentTmuxSessionTagRepository,
|
||||
createCurrentTrustedDeviceRepository,
|
||||
createCurrentUserPreferenceRepository,
|
||||
createCurrentUserRepository,
|
||||
createCurrentTransferRecentRepository,
|
||||
createCurrentVaultProfileRepository,
|
||||
createCurrentSecretSourceRepository,
|
||||
createCurrentSharedCredentialSecretsRepository,
|
||||
createCurrentCredentialAccessRepository,
|
||||
createCurrentVaultTokenRepository,
|
||||
} from "../repositories/factory.js";
|
||||
|
||||
export async function deleteUserAndRelatedData(
|
||||
@@ -68,9 +46,10 @@ export async function deleteUserAndRelatedData(
|
||||
userId,
|
||||
);
|
||||
|
||||
// Retained rather than deleted: these outlive the account by design.
|
||||
// See anonymizeByUserId on each repository.
|
||||
await createCurrentSessionRecordingRepository().anonymizeByUserId(userId);
|
||||
// Plugins drop or anonymize their own rows on user.deleted, or rely on
|
||||
// their refUser() foreign keys cascading.
|
||||
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
|
||||
pluginEvents.emit(TOPICS.userDeleted, { userId });
|
||||
|
||||
await createCurrentRbacAccessRepository().deleteHostAccessForUserReferences(
|
||||
userId,
|
||||
@@ -81,27 +60,14 @@ export async function deleteUserAndRelatedData(
|
||||
await createCurrentTrustedDeviceRepository().deleteByUserId(userId);
|
||||
|
||||
await createCurrentRoleRepository().removeAllRolesFromUser(userId);
|
||||
await createCurrentAiRepository().deleteByUserId(userId);
|
||||
await createCurrentAlertRepository().deleteByUserId(userId);
|
||||
await createCurrentAuditLogRepository().anonymizeByUserId(userId);
|
||||
|
||||
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
|
||||
|
||||
await createCurrentFileManagerBookmarkRepository().deleteByUserId(userId);
|
||||
|
||||
await createCurrentTransferRecentRepository().deleteByUserId(userId);
|
||||
|
||||
await createCurrentRecentActivityRepository().deleteByUserId(userId);
|
||||
await createCurrentDismissedAlertRepository().deleteByUserId(userId);
|
||||
|
||||
await createCurrentSnippetRepository().deleteByUserId(userId);
|
||||
|
||||
await createCurrentHostFolderRepository().deleteByUserId(userId);
|
||||
|
||||
await createCurrentCommandHistoryRepository().deleteByUserId(userId);
|
||||
|
||||
await createCurrentHostHealthRepository().deleteByUserId(userId);
|
||||
await createCurrentHostMetricsPreferenceRepository().deleteByUserId(userId);
|
||||
await createCurrentHostSidebarPreferenceRepository().deleteByUserId(userId);
|
||||
await createCurrentCredentialSidebarPreferenceRepository().deleteByUserId(
|
||||
userId,
|
||||
@@ -110,18 +76,8 @@ export async function deleteUserAndRelatedData(
|
||||
await createCurrentHostRepository().deleteByUserId(userId);
|
||||
await createCurrentCredentialRepository().deleteByUserId(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 createCurrentSecretSourceRepository().deleteByUserId(userId);
|
||||
await createCurrentTermixIdentityCaRepository().deleteByUserId(userId);
|
||||
await createCurrentTermixIdentityRepository().deleteByUserId(userId);
|
||||
await createCurrentTmuxSessionTagRepository().deleteByUserId(userId);
|
||||
// Plugin tables with a refUser() column cascade on the user row.
|
||||
|
||||
await createCurrentOpenTabRepository().deleteByUserId(userId);
|
||||
await createCurrentUserPreferenceRepository().deleteByUserId(userId);
|
||||
|
||||
|
||||
@@ -21,6 +21,17 @@ export function isLoopbackRequest(req: Request): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this backend may hand out the desktop auto-session at all: the
|
||||
* embedded desktop backend, or a development backend the desktop app points
|
||||
* at. A production server never does, whoever asks.
|
||||
*/
|
||||
export function allowsDesktopAutoSession(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
return env.ELECTRON_EMBEDDED === "true" || env.NODE_ENV !== "production";
|
||||
}
|
||||
|
||||
export function extractBearerOrCookieToken(req: Request): string | undefined {
|
||||
const cookieToken = (req as Request & { cookies?: Record<string, string> })
|
||||
.cookies?.jwt;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,120 +0,0 @@
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { homepageLogger } from "../../utils/logger.js";
|
||||
import https from "https";
|
||||
import http from "http";
|
||||
|
||||
export const homepageFaviconRouter = express.Router();
|
||||
|
||||
// Simple LRU cache: url -> { data: Buffer, contentType: string, expires: number }
|
||||
const faviconCache = new Map<
|
||||
string,
|
||||
{ data: Buffer; contentType: string; expires: number }
|
||||
>();
|
||||
const CACHE_SIZE = 100;
|
||||
const CACHE_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours
|
||||
const MAX_FAVICON_BYTES = 512 * 1024;
|
||||
|
||||
function evictIfNeeded() {
|
||||
if (faviconCache.size >= CACHE_SIZE) {
|
||||
const oldest = faviconCache.keys().next().value;
|
||||
if (oldest) faviconCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
function fetchUrl(url: string): Promise<{ data: Buffer; contentType: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const mod = url.startsWith("https") ? https : http;
|
||||
const req = mod.get(url, { timeout: 5000 }, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let bytes = 0;
|
||||
let settled = false;
|
||||
const fail = (error: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
res.destroy();
|
||||
reject(error);
|
||||
};
|
||||
res.on("data", (chunk: Buffer) => {
|
||||
bytes += chunk.length;
|
||||
if (bytes > MAX_FAVICON_BYTES) {
|
||||
fail(new Error("Favicon response too large"));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on("end", () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve({
|
||||
data: Buffer.concat(chunks),
|
||||
contentType: res.headers["content-type"] || "image/x-icon",
|
||||
});
|
||||
});
|
||||
res.on("error", fail);
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject(new Error("Favicon fetch timeout"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/favicon:
|
||||
* get:
|
||||
* summary: Proxy favicon fetch
|
||||
* description: Fetches and caches a site favicon server-side to avoid CORS issues.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: url
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Favicon image.
|
||||
* 400:
|
||||
* description: Invalid URL.
|
||||
* 500:
|
||||
* description: Failed to fetch favicon.
|
||||
*/
|
||||
homepageFaviconRouter.get("/", async (req: Request, res: Response) => {
|
||||
const rawUrl = req.query.url as string;
|
||||
if (!rawUrl) return res.status(400).json({ error: "url is required" });
|
||||
|
||||
let domain: string;
|
||||
try {
|
||||
domain = new URL(rawUrl).hostname;
|
||||
} catch {
|
||||
return res.status(400).json({ error: "Invalid URL" });
|
||||
}
|
||||
|
||||
const cached = faviconCache.get(domain);
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
res.setHeader("Content-Type", cached.contentType);
|
||||
res.setHeader("Cache-Control", "public, max-age=86400");
|
||||
return res.send(cached.data);
|
||||
}
|
||||
|
||||
const faviconUrl = `https://www.google.com/s2/favicons?sz=64&domain_url=${encodeURIComponent(domain)}`;
|
||||
|
||||
try {
|
||||
const { data, contentType } = await fetchUrl(faviconUrl);
|
||||
evictIfNeeded();
|
||||
faviconCache.set(domain, {
|
||||
data,
|
||||
contentType,
|
||||
expires: Date.now() + CACHE_TTL_MS,
|
||||
});
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader("Cache-Control", "public, max-age=86400");
|
||||
res.send(data);
|
||||
} catch {
|
||||
homepageLogger.warn("Failed to fetch favicon", { domain });
|
||||
res.status(500).json({ error: "Failed to fetch favicon" });
|
||||
}
|
||||
});
|
||||
@@ -1,202 +0,0 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { homepageLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
createCurrentHomepageItemRepository,
|
||||
createCurrentSyncTombstoneRepository,
|
||||
} from "../repositories/factory.js";
|
||||
|
||||
export const homepageItemsRouter = express.Router();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/items:
|
||||
* get:
|
||||
* summary: Get homepage items
|
||||
* description: Returns all homepage widget items for the authenticated user.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of homepage items.
|
||||
* 500:
|
||||
* description: Failed to fetch homepage items.
|
||||
*/
|
||||
homepageItemsRouter.get("/", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const items =
|
||||
await createCurrentHomepageItemRepository().listByUserId(userId);
|
||||
res.json(items);
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to fetch homepage items", err);
|
||||
res.status(500).json({ error: "Failed to fetch homepage items" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/items:
|
||||
* post:
|
||||
* summary: Create homepage item
|
||||
* description: Creates a new homepage widget item for the authenticated user.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - typeId
|
||||
* properties:
|
||||
* typeId:
|
||||
* type: string
|
||||
* title:
|
||||
* type: string
|
||||
* config:
|
||||
* type: object
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Homepage item created.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 500:
|
||||
* description: Failed to create homepage item.
|
||||
*/
|
||||
homepageItemsRouter.post("/", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { typeId, title, config } = req.body;
|
||||
|
||||
if (!typeId || typeof typeId !== "string") {
|
||||
return res.status(400).json({ error: "typeId is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await createCurrentHomepageItemRepository().createForUser(
|
||||
userId,
|
||||
{
|
||||
typeId,
|
||||
title: title ?? null,
|
||||
config: config ? JSON.stringify(config) : "{}",
|
||||
},
|
||||
);
|
||||
res.status(201).json(created);
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to create homepage item", err);
|
||||
res.status(500).json({ error: "Failed to create homepage item" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/items/{id}:
|
||||
* put:
|
||||
* summary: Update homepage item
|
||||
* description: Updates a homepage widget item.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* title:
|
||||
* type: string
|
||||
* config:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Homepage item updated.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 404:
|
||||
* description: Not found.
|
||||
* 500:
|
||||
* description: Failed to update homepage item.
|
||||
*/
|
||||
homepageItemsRouter.put("/:id", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = parseInt(req.params.id as string);
|
||||
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
|
||||
|
||||
const { title, config } = req.body;
|
||||
|
||||
try {
|
||||
const itemRepository = createCurrentHomepageItemRepository();
|
||||
const existing = await itemRepository.findByIdForUser(userId, id);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
|
||||
const updates: Parameters<typeof itemRepository.updateForUser>[2] = {};
|
||||
if (title !== undefined) updates.title = title;
|
||||
if (config !== undefined) updates.config = JSON.stringify(config);
|
||||
|
||||
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" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/items/{id}:
|
||||
* delete:
|
||||
* summary: Delete homepage item
|
||||
* description: Deletes a homepage widget item and cascades deletion to children if a folder.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Homepage item deleted.
|
||||
* 400:
|
||||
* description: Invalid id.
|
||||
* 404:
|
||||
* description: Not found.
|
||||
* 500:
|
||||
* description: Failed to delete homepage item.
|
||||
*/
|
||||
homepageItemsRouter.delete("/:id", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = parseInt(req.params.id as string);
|
||||
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
|
||||
|
||||
try {
|
||||
const itemRepository = createCurrentHomepageItemRepository();
|
||||
const existing = await itemRepository.findByIdForUser(userId, id);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
|
||||
const deleted = await itemRepository.deleteForUser(userId, id);
|
||||
if (deleted?.syncId) {
|
||||
await createCurrentSyncTombstoneRepository().record(
|
||||
userId,
|
||||
"homepageItems",
|
||||
deleted.syncId,
|
||||
);
|
||||
}
|
||||
res.json({ message: "Homepage item deleted" });
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to delete homepage item", err);
|
||||
res.status(500).json({ error: "Failed to delete homepage item" });
|
||||
}
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { homepageLogger } from "../../utils/logger.js";
|
||||
import { createCurrentHomepageLayoutRepository } from "../repositories/factory.js";
|
||||
|
||||
export const homepageLayoutRouter = express.Router();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/layout:
|
||||
* get:
|
||||
* summary: Get homepage layout
|
||||
* description: Returns the homepage canvas layout (widget positions, pan, zoom) for the authenticated user.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Layout data or null.
|
||||
* 500:
|
||||
* description: Failed to fetch homepage layout.
|
||||
*/
|
||||
homepageLayoutRouter.get("/", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const row =
|
||||
await createCurrentHomepageLayoutRepository().findByUserId(userId);
|
||||
|
||||
if (!row) {
|
||||
return res.json(null);
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(row.layout || "{}");
|
||||
res.json({ ...row, layout: parsed });
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to fetch homepage layout", err);
|
||||
res.status(500).json({ error: "Failed to fetch homepage layout" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/layout:
|
||||
* put:
|
||||
* summary: Save homepage layout
|
||||
* description: Saves or updates the homepage canvas layout for the authenticated user.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* entries:
|
||||
* type: array
|
||||
* pan:
|
||||
* type: object
|
||||
* zoom:
|
||||
* type: number
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Layout saved.
|
||||
* 500:
|
||||
* description: Failed to save homepage layout.
|
||||
*/
|
||||
homepageLayoutRouter.put("/", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const layoutData = req.body;
|
||||
|
||||
try {
|
||||
const layoutJson = JSON.stringify(layoutData);
|
||||
const now = new Date().toISOString();
|
||||
const updated = await createCurrentHomepageLayoutRepository().upsertForUser(
|
||||
userId,
|
||||
layoutJson,
|
||||
now,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(updated.layout);
|
||||
res.json({ ...updated, layout: parsed });
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to save homepage layout", err);
|
||||
res.status(500).json({ error: "Failed to save homepage layout" });
|
||||
}
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { homepageLogger } from "../../utils/logger.js";
|
||||
import { safeOutboundFetch } from "../../utils/safe-outbound-fetch.js";
|
||||
|
||||
export const homepagePingRouter = express.Router();
|
||||
|
||||
interface PingCacheEntry {
|
||||
ok: boolean;
|
||||
statusCode: number | null;
|
||||
latencyMs: number;
|
||||
expires: number;
|
||||
}
|
||||
|
||||
const pingCache = new Map<string, PingCacheEntry>();
|
||||
const CACHE_SIZE = 200;
|
||||
const FETCH_TIMEOUT_MS = 5000;
|
||||
|
||||
async function requestStatus(
|
||||
url: string,
|
||||
method: "HEAD" | "GET",
|
||||
): Promise<number | null> {
|
||||
const res = await safeOutboundFetch(url, {
|
||||
method,
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
// Discard the body without buffering it.
|
||||
await res.body?.cancel().catch(() => {});
|
||||
return res.status ?? null;
|
||||
}
|
||||
|
||||
async function pingUrl(
|
||||
url: string,
|
||||
): Promise<{ ok: boolean; statusCode: number | null; latencyMs: number }> {
|
||||
const start = performance.now();
|
||||
const elapsed = () => Math.round(performance.now() - start);
|
||||
try {
|
||||
let code = await requestStatus(url, "HEAD");
|
||||
if (code === 405) {
|
||||
code = await requestStatus(url, "GET");
|
||||
}
|
||||
return {
|
||||
ok: code !== null && code < 400,
|
||||
statusCode: code,
|
||||
latencyMs: elapsed(),
|
||||
};
|
||||
} catch {
|
||||
return { ok: false, statusCode: null, latencyMs: elapsed() };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/ping:
|
||||
* get:
|
||||
* summary: Check the HTTP reachability and latency of a URL
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: url
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: ttl
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Cache TTL in seconds (min 10)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Ping result with ok, statusCode and latencyMs.
|
||||
* 400:
|
||||
* description: Invalid or missing URL.
|
||||
*/
|
||||
homepagePingRouter.get("/", async (req: Request, res: Response) => {
|
||||
let targetUrl = req.query.url as string;
|
||||
const ttl = Math.max(10, Number(req.query.ttl) || 30) * 1000;
|
||||
|
||||
if (!targetUrl) return res.status(400).json({ error: "url is required" });
|
||||
if (!/^https?:\/\//i.test(targetUrl)) targetUrl = `https://${targetUrl}`;
|
||||
try {
|
||||
new URL(targetUrl);
|
||||
} catch {
|
||||
return res.status(400).json({ error: "Invalid URL" });
|
||||
}
|
||||
|
||||
const cached = pingCache.get(targetUrl);
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
return res.json({
|
||||
ok: cached.ok,
|
||||
statusCode: cached.statusCode,
|
||||
latencyMs: cached.latencyMs,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pingUrl(targetUrl);
|
||||
if (pingCache.size >= CACHE_SIZE) {
|
||||
const oldest = pingCache.keys().next().value;
|
||||
if (oldest) pingCache.delete(oldest);
|
||||
}
|
||||
pingCache.set(targetUrl, { ...result, expires: Date.now() + ttl });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
homepageLogger.warn("Ping failed", { targetUrl });
|
||||
res.status(500).json({ error: "Ping failed" });
|
||||
}
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { homepageLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
readResponseTextLimited,
|
||||
safeOutboundFetch,
|
||||
} from "../../utils/safe-outbound-fetch.js";
|
||||
|
||||
export const homepageProxyRouter = express.Router();
|
||||
|
||||
interface ProxyCacheEntry {
|
||||
data: unknown;
|
||||
expires: number;
|
||||
}
|
||||
|
||||
const proxyCache = new Map<string, ProxyCacheEntry>();
|
||||
const CACHE_SIZE = 50;
|
||||
const FETCH_TIMEOUT_MS = 8000;
|
||||
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
|
||||
const MAX_CACHE_TTL_SECONDS = 24 * 60 * 60;
|
||||
|
||||
async function fetchJson(rawUrl: string): Promise<unknown> {
|
||||
const response = await safeOutboundFetch(rawUrl, {
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Proxy fetch failed: ${response.status}`);
|
||||
const text = await readResponseTextLimited(response, MAX_RESPONSE_BYTES);
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error("Response is not valid JSON");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/proxy:
|
||||
* get:
|
||||
* summary: Proxy a JSON API URL and return the parsed response
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: url
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: ttl
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Cache TTL in seconds (min 10, default 60)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The JSON body returned by the target URL.
|
||||
* 400:
|
||||
* description: Invalid or missing URL, or non-JSON response.
|
||||
* 500:
|
||||
* description: Failed to fetch the target URL.
|
||||
*/
|
||||
homepageProxyRouter.get("/", async (req: Request, res: Response) => {
|
||||
const targetUrl = req.query.url as string;
|
||||
const ttl =
|
||||
Math.min(MAX_CACHE_TTL_SECONDS, Math.max(10, Number(req.query.ttl) || 60)) *
|
||||
1000;
|
||||
|
||||
if (!targetUrl) return res.status(400).json({ error: "url is required" });
|
||||
try {
|
||||
new URL(targetUrl);
|
||||
} catch {
|
||||
return res.status(400).json({ error: "Invalid URL" });
|
||||
}
|
||||
|
||||
const cached = proxyCache.get(targetUrl);
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
return res.json(cached.data);
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchJson(targetUrl);
|
||||
if (proxyCache.size >= CACHE_SIZE) {
|
||||
const oldest = proxyCache.keys().next().value;
|
||||
if (oldest) proxyCache.delete(oldest);
|
||||
}
|
||||
proxyCache.set(targetUrl, { data, expires: Date.now() + ttl });
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
const msg = getErrorMessage(err);
|
||||
homepageLogger.warn("Proxy fetch failed", { targetUrl, msg });
|
||||
if (msg.includes("not valid JSON")) {
|
||||
return res.status(400).json({ error: "Response is not valid JSON" });
|
||||
}
|
||||
res.status(500).json({ error: "Failed to fetch URL" });
|
||||
}
|
||||
});
|
||||
@@ -1,142 +0,0 @@
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { homepageLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
readResponseTextLimited,
|
||||
safeOutboundFetch,
|
||||
} from "../../utils/safe-outbound-fetch.js";
|
||||
|
||||
export const homepageRssRouter = express.Router();
|
||||
|
||||
const rssCache = new Map<string, { data: RssItem[]; expires: number }>();
|
||||
const CACHE_TTL_MS = 1000 * 60 * 15; // 15 minutes
|
||||
const CACHE_SIZE = 50;
|
||||
const FETCH_TIMEOUT_MS = 8000;
|
||||
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
interface RssItem {
|
||||
title: string;
|
||||
link: string;
|
||||
pubDate: string | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
function fetchXml(url: string): Promise<string> {
|
||||
return safeOutboundFetch(url, {
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) throw new Error(`RSS fetch failed: ${res.status}`);
|
||||
return readResponseTextLimited(res, MAX_RESPONSE_BYTES);
|
||||
});
|
||||
}
|
||||
|
||||
function parseRss(xml: string): RssItem[] {
|
||||
const items: RssItem[] = [];
|
||||
const itemRegex = /<item[\s>]([\s\S]*?)<\/item>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
const getText = (tag: string, src: string): string | null => {
|
||||
const m = new RegExp(
|
||||
`<${tag}[^>]*><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/${tag}>|<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`,
|
||||
"i",
|
||||
).exec(src);
|
||||
if (!m) return null;
|
||||
return (m[1] ?? m[2]).trim();
|
||||
};
|
||||
|
||||
const getLink = (src: string): string => {
|
||||
// Self-closing <link rel="alternate" href="..." /> (BBC style)
|
||||
const selfClose = /<link[^>]+href="([^"]+)"[^>]*\/?>/i.exec(src);
|
||||
if (selfClose) return selfClose[1];
|
||||
// Plain text <link>url</link>
|
||||
return getText("link", src) ?? "";
|
||||
};
|
||||
|
||||
while ((match = itemRegex.exec(xml)) !== null) {
|
||||
const src = match[1];
|
||||
items.push({
|
||||
title: getText("title", src) ?? "(no title)",
|
||||
link: getLink(src),
|
||||
pubDate: getText("pubDate", src) ?? getText("updated", src),
|
||||
description: getText("description", src) ?? getText("summary", src),
|
||||
});
|
||||
if (items.length >= 50) break;
|
||||
}
|
||||
|
||||
// Atom feed fallback
|
||||
if (items.length === 0) {
|
||||
const entryRegex = /<entry[\s>]([\s\S]*?)<\/entry>/gi;
|
||||
while ((match = entryRegex.exec(xml)) !== null) {
|
||||
const src = match[1];
|
||||
const linkMatch = /<link[^>]+href="([^"]+)"/.exec(src);
|
||||
items.push({
|
||||
title: getText("title", src) ?? "(no title)",
|
||||
link: linkMatch?.[1] ?? "",
|
||||
pubDate: getText("published", src) ?? getText("updated", src),
|
||||
description: getText("summary", src) ?? getText("content", src),
|
||||
});
|
||||
if (items.length >= 50) break;
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/rss:
|
||||
* get:
|
||||
* summary: Proxy and parse an RSS/Atom feed
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: url
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: max
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 10
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Array of feed items.
|
||||
* 400:
|
||||
* description: Invalid or missing URL.
|
||||
* 500:
|
||||
* description: Failed to fetch or parse the feed.
|
||||
*/
|
||||
homepageRssRouter.get("/", async (req: Request, res: Response) => {
|
||||
const feedUrl = req.query.url as string;
|
||||
const max = Math.min(50, Math.max(1, Number(req.query.max) || 10));
|
||||
|
||||
if (!feedUrl) return res.status(400).json({ error: "url is required" });
|
||||
|
||||
try {
|
||||
new URL(feedUrl);
|
||||
} catch {
|
||||
return res.status(400).json({ error: "Invalid URL" });
|
||||
}
|
||||
|
||||
const cached = rssCache.get(feedUrl);
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
return res.json(cached.data.slice(0, max));
|
||||
}
|
||||
|
||||
try {
|
||||
const xml = await fetchXml(feedUrl);
|
||||
const items = parseRss(xml);
|
||||
|
||||
if (rssCache.size >= CACHE_SIZE) {
|
||||
const oldest = rssCache.keys().next().value;
|
||||
if (oldest) rssCache.delete(oldest);
|
||||
}
|
||||
rssCache.set(feedUrl, { data: items, expires: Date.now() + CACHE_TTL_MS });
|
||||
|
||||
res.json(items.slice(0, max));
|
||||
} catch {
|
||||
homepageLogger.warn("Failed to fetch RSS feed", { feedUrl });
|
||||
res.status(500).json({ error: "Failed to fetch feed" });
|
||||
}
|
||||
});
|
||||
@@ -1,303 +0,0 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
import { createCurrentHostRepository } from "../repositories/factory.js";
|
||||
|
||||
type HostAutostartRoutesDeps = {
|
||||
authenticateJWT: RequestHandler;
|
||||
requireViewPermission: RequestHandler;
|
||||
requireEditPermission: RequestHandler;
|
||||
requireDataAccess: RequestHandler;
|
||||
};
|
||||
|
||||
export function registerHostAutostartRoutes(
|
||||
router: Router,
|
||||
{
|
||||
authenticateJWT,
|
||||
requireViewPermission,
|
||||
requireEditPermission,
|
||||
requireDataAccess,
|
||||
}: HostAutostartRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host/autostart/enable:
|
||||
* post:
|
||||
* summary: Enable autostart for SSH configuration
|
||||
* description: Enables autostart for a specific SSH configuration.
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sshConfigId:
|
||||
* type: number
|
||||
* responses:
|
||||
* 200:
|
||||
* description: AutoStart enabled successfully.
|
||||
* 400:
|
||||
* description: Valid sshConfigId is required.
|
||||
* 404:
|
||||
* description: SSH configuration not found.
|
||||
* 500:
|
||||
* description: Internal server error.
|
||||
*/
|
||||
router.post(
|
||||
"/autostart/enable",
|
||||
authenticateJWT,
|
||||
requireEditPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { sshConfigId } = req.body;
|
||||
|
||||
if (!sshConfigId || typeof sshConfigId !== "number") {
|
||||
sshLogger.warn(
|
||||
"Missing or invalid sshConfigId in autostart enable request",
|
||||
{
|
||||
operation: "autostart_enable",
|
||||
userId,
|
||||
sshConfigId,
|
||||
},
|
||||
);
|
||||
return res.status(400).json({ error: "Valid sshConfigId is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) {
|
||||
sshLogger.warn(
|
||||
"User attempted to enable autostart without unlocked data",
|
||||
{
|
||||
operation: "autostart_enable_failed",
|
||||
userId,
|
||||
sshConfigId,
|
||||
reason: "data_locked",
|
||||
},
|
||||
);
|
||||
return res.status(400).json({
|
||||
error: "Failed to enable autostart. Ensure user data is unlocked.",
|
||||
});
|
||||
}
|
||||
|
||||
const hostRepository = createCurrentHostRepository();
|
||||
const config = await hostRepository.findByIdForUser(
|
||||
userId,
|
||||
sshConfigId,
|
||||
);
|
||||
|
||||
if (!config) {
|
||||
sshLogger.warn("SSH config not found for autostart enable", {
|
||||
operation: "autostart_enable_failed",
|
||||
userId,
|
||||
sshConfigId,
|
||||
reason: "config_not_found",
|
||||
});
|
||||
return res.status(404).json({
|
||||
error: "SSH configuration not found",
|
||||
});
|
||||
}
|
||||
|
||||
const decryptedConfig = DataCrypto.decryptRecord(
|
||||
"ssh_data",
|
||||
config,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
let updatedTunnelConnections = config.tunnelConnections;
|
||||
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>) => {
|
||||
if (
|
||||
tunnel.autoStart &&
|
||||
tunnel.endpointHost &&
|
||||
!tunnel.endpointPassword &&
|
||||
!tunnel.endpointKey
|
||||
) {
|
||||
const endpointHost = endpointHosts.find(
|
||||
(h) =>
|
||||
h.name === tunnel.endpointHost ||
|
||||
`${h.username}@${h.ip}` === tunnel.endpointHost,
|
||||
);
|
||||
|
||||
if (endpointHost) {
|
||||
const decryptedEndpoint = DataCrypto.decryptRecord(
|
||||
"ssh_data",
|
||||
endpointHost,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
return {
|
||||
...tunnel,
|
||||
endpointPassword: decryptedEndpoint.password || null,
|
||||
endpointKey: decryptedEndpoint.key || null,
|
||||
endpointKeyPassword:
|
||||
decryptedEndpoint.keyPassword || null,
|
||||
endpointAuthType: endpointHost.authType,
|
||||
};
|
||||
}
|
||||
}
|
||||
return tunnel;
|
||||
}),
|
||||
);
|
||||
|
||||
updatedTunnelConnections = JSON.stringify(resolvedConnections);
|
||||
} catch (error) {
|
||||
sshLogger.warn("Failed to update tunnel connections", {
|
||||
operation: "tunnel_connections_update_failed",
|
||||
error: getErrorMessage(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",
|
||||
sshConfigId,
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.error("Error enabling autostart", error, {
|
||||
operation: "autostart_enable_error",
|
||||
userId,
|
||||
sshConfigId,
|
||||
});
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/autostart/disable:
|
||||
* delete:
|
||||
* summary: Disable autostart for SSH configuration
|
||||
* description: Disables autostart for a specific SSH configuration.
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sshConfigId:
|
||||
* type: number
|
||||
* responses:
|
||||
* 200:
|
||||
* description: AutoStart disabled successfully.
|
||||
* 400:
|
||||
* description: Valid sshConfigId is required.
|
||||
* 500:
|
||||
* description: Internal server error.
|
||||
*/
|
||||
router.delete(
|
||||
"/autostart/disable",
|
||||
authenticateJWT,
|
||||
requireEditPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { sshConfigId } = req.body;
|
||||
|
||||
if (!sshConfigId || typeof sshConfigId !== "number") {
|
||||
sshLogger.warn(
|
||||
"Missing or invalid sshConfigId in autostart disable request",
|
||||
{
|
||||
operation: "autostart_disable",
|
||||
userId,
|
||||
sshConfigId,
|
||||
},
|
||||
);
|
||||
return res.status(400).json({ error: "Valid sshConfigId is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
await createCurrentHostRepository().updateForUser(userId, sshConfigId, {
|
||||
autostartPassword: null,
|
||||
autostartKey: null,
|
||||
autostartKeyPassword: null,
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: "AutoStart disabled successfully",
|
||||
sshConfigId,
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.error("Error disabling autostart", error, {
|
||||
operation: "autostart_disable_error",
|
||||
userId,
|
||||
sshConfigId,
|
||||
});
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/autostart/status:
|
||||
* get:
|
||||
* summary: Get autostart status
|
||||
* description: Retrieves the autostart status for the user's SSH configurations.
|
||||
* tags:
|
||||
* - SSH
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of autostart configurations.
|
||||
* 500:
|
||||
* description: Internal server error.
|
||||
*/
|
||||
router.get(
|
||||
"/autostart/status",
|
||||
authenticateJWT,
|
||||
requireViewPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
try {
|
||||
const autostartConfigs = (
|
||||
await createCurrentHostRepository().listByUserId(userId)
|
||||
).filter((config) => config.autostartPassword || config.autostartKey);
|
||||
|
||||
const statusList = autostartConfigs.map((config) => ({
|
||||
sshConfigId: config.id,
|
||||
host: config.ip,
|
||||
port: config.port,
|
||||
username: config.username,
|
||||
authType: config.authType,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
autostart_configs: statusList,
|
||||
total_count: statusList.length,
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.error("Error getting autostart status", error, {
|
||||
operation: "autostart_status_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,22 @@ import {
|
||||
prepareHostImports,
|
||||
remapImportedJumpHosts,
|
||||
} from "./host-import-order.js";
|
||||
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
|
||||
import {
|
||||
applyDefaultsAfterHostWrite,
|
||||
applyHostDefaultsToWrite,
|
||||
} from "../../hosts/defaults/index.js";
|
||||
import {
|
||||
changeHostOverrides,
|
||||
type OverrideChange,
|
||||
} from "../../hosts/defaults/overrides.js";
|
||||
import { recompute } from "../../hosts/defaults/recompute.js";
|
||||
import { splitDefaultKey } from "../../../types/host-defaults.js";
|
||||
import {
|
||||
keepUsableProtocolCredentials,
|
||||
readProtocolAuthPayload,
|
||||
writeProtocolAuth,
|
||||
} from "../../hosts/protocol-auth/protocol-auth.js";
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
@@ -10,9 +26,17 @@ import {
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentHostDefaultsRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import { validateParentHostId } from "./host-parent-validation.js";
|
||||
import { serializeWebUiConfig } from "./host-web-endpoints.js";
|
||||
import {
|
||||
listSshAuthProviders,
|
||||
listSshAuthTypeOwners,
|
||||
} from "../../hosts/connect/auth-provider-registry.js";
|
||||
import {
|
||||
applyPluginHostImportSettings,
|
||||
setHostPluginEnabled,
|
||||
} from "./host-plugin-settings.js";
|
||||
import {
|
||||
isNonEmptyString,
|
||||
isValidPort,
|
||||
@@ -181,6 +205,29 @@ export function registerHostBulkRoutes(
|
||||
* parentHostId:
|
||||
* type: integer
|
||||
* nullable: true
|
||||
* statusCheckEnabled:
|
||||
* type: boolean
|
||||
* pin:
|
||||
* type: boolean
|
||||
* pluginEnable:
|
||||
* type: object
|
||||
* description: Plugin id to on or off, for each plugin that declares a host enable switch.
|
||||
* additionalProperties:
|
||||
* type: boolean
|
||||
* resetDefaults:
|
||||
* type: object
|
||||
* description: Hands keys back to the host defaults. `all`, or `namespaces` ("core" or a plugin id), or `keys` as "namespace.key".
|
||||
* properties:
|
||||
* all:
|
||||
* type: boolean
|
||||
* namespaces:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* keys:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Bulk update completed.
|
||||
@@ -277,28 +324,8 @@ export function registerHostBulkRoutes(
|
||||
simpleUpdates.folder = null;
|
||||
}
|
||||
}
|
||||
if (typeof updates.enableTerminal === "boolean")
|
||||
simpleUpdates.enableTerminal = updates.enableTerminal;
|
||||
if (typeof updates.enableTunnel === "boolean")
|
||||
simpleUpdates.enableTunnel = updates.enableTunnel;
|
||||
if (typeof updates.enableFileManager === "boolean")
|
||||
simpleUpdates.enableFileManager = updates.enableFileManager;
|
||||
if (typeof updates.enableDocker === "boolean")
|
||||
simpleUpdates.enableDocker = updates.enableDocker;
|
||||
if (typeof updates.enableWebUi === "boolean") {
|
||||
simpleUpdates.enableWebUi = updates.enableWebUi;
|
||||
if (!updates.enableWebUi) simpleUpdates.webUiConfig = null;
|
||||
}
|
||||
if (typeof updates.enableTmuxMonitor === "boolean")
|
||||
simpleUpdates.enableTmuxMonitor = updates.enableTmuxMonitor;
|
||||
if (typeof updates.enableTerminalToolbar === "boolean")
|
||||
simpleUpdates.enableTerminalToolbar = updates.enableTerminalToolbar;
|
||||
if (typeof updates.enableAiAssistant === "boolean")
|
||||
simpleUpdates.enableAiAssistant = updates.enableAiAssistant;
|
||||
// Disabling Proxmox is a plain flag flip; enabling is handled per-host
|
||||
// below so each host can default to its own stored credential.
|
||||
if (updates.enableProxmox === false)
|
||||
simpleUpdates.enableProxmox = false;
|
||||
if (typeof updates.statusCheckEnabled === "boolean")
|
||||
simpleUpdates.statusCheckEnabled = updates.statusCheckEnabled;
|
||||
|
||||
if (Object.keys(simpleUpdates).length > 0) {
|
||||
await hostRepository.updateManyForUser(
|
||||
@@ -307,53 +334,30 @@ export function registerHostBulkRoutes(
|
||||
simpleUpdates,
|
||||
);
|
||||
}
|
||||
if (typeof updates.statusCheckEnabled === "boolean") {
|
||||
await changeHostOverrides(ownedIds, {
|
||||
own: [["core", "statusCheckEnabled"]],
|
||||
});
|
||||
}
|
||||
if ("folder" in simpleUpdates || "parentHostId" in simpleUpdates) {
|
||||
void recompute({ userIds: [userId] }).catch(() => {});
|
||||
}
|
||||
|
||||
if (updates.statsConfig && typeof updates.statsConfig === "object") {
|
||||
for (const host of ownedHosts) {
|
||||
try {
|
||||
const existing = host.statsConfig
|
||||
? JSON.parse(host.statsConfig as string)
|
||||
: {};
|
||||
const merged = { ...existing, ...updates.statsConfig };
|
||||
await hostRepository.updateForUser(userId, host.id, {
|
||||
statsConfig: JSON.stringify(merged),
|
||||
});
|
||||
} catch {
|
||||
errors.push(`Failed to update statsConfig for host ${host.id}`);
|
||||
// Each plugin's own host switch, by plugin id: the field its manifest
|
||||
// names in contributes.settings.host.enableKey.
|
||||
if (updates.pluginEnable && typeof updates.pluginEnable === "object") {
|
||||
for (const [pluginId, enabled] of Object.entries(
|
||||
updates.pluginEnable as Record<string, unknown>,
|
||||
)) {
|
||||
if (typeof enabled !== "boolean") continue;
|
||||
if (!(await setHostPluginEnabled(pluginId, ownedIds, enabled))) {
|
||||
errors.push(`Plugin ${pluginId} has no host switch`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enabling Proxmox needs per-host handling: each host defaults its
|
||||
// Proxmox credential to the credential already stored on that host, so
|
||||
// discovery works right away without picking one by hand. Existing
|
||||
// proxmoxConfig values are preserved.
|
||||
if (updates.enableProxmox === true) {
|
||||
for (const host of ownedHosts) {
|
||||
try {
|
||||
const existing = host.proxmoxConfig
|
||||
? JSON.parse(host.proxmoxConfig as string)
|
||||
: {};
|
||||
const merged = {
|
||||
defaultCredentialId:
|
||||
existing.defaultCredentialId ?? host.credentialId ?? null,
|
||||
windowsPatterns: existing.windowsPatterns ?? "win, windows",
|
||||
dockerPatterns: existing.dockerPatterns ?? "docker",
|
||||
preferredPrefixes:
|
||||
existing.preferredPrefixes ?? "10., 192.168.",
|
||||
autoSyncEnabled: existing.autoSyncEnabled ?? false,
|
||||
syncIntervalMinutes: existing.syncIntervalMinutes ?? 15,
|
||||
markMissingGuests: existing.markMissingGuests ?? true,
|
||||
};
|
||||
await hostRepository.updateForUser(userId, host.id, {
|
||||
enableProxmox: true,
|
||||
proxmoxConfig: JSON.stringify(merged),
|
||||
});
|
||||
} catch {
|
||||
errors.push(`Failed to enable Proxmox for host ${host.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const reset = readDefaultsReset(updates.resetDefaults);
|
||||
if (reset) await changeHostOverrides(ownedIds, reset);
|
||||
|
||||
return res.json({
|
||||
updated: ownedIds.length,
|
||||
@@ -572,6 +576,7 @@ export function registerHostBulkRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
const knownAuthTypes = listKnownAuthTypes();
|
||||
for (const { host: hostData, index: i, exportId } of orderedHosts) {
|
||||
try {
|
||||
const effectiveConnectionType = hostData.connectionType || "ssh";
|
||||
@@ -611,20 +616,11 @@ export function registerHostBulkRoutes(
|
||||
if (
|
||||
effectiveConnectionType === "ssh" &&
|
||||
hostData.authType &&
|
||||
![
|
||||
"password",
|
||||
"key",
|
||||
"credential",
|
||||
"none",
|
||||
"opkssh",
|
||||
"stepca",
|
||||
"tailscale",
|
||||
"vault",
|
||||
].includes(hostData.authType)
|
||||
!knownAuthTypes.has(hostData.authType)
|
||||
) {
|
||||
results.failed++;
|
||||
results.errors.push(
|
||||
`Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', 'none', 'opkssh', 'stepca', 'tailscale', or 'vault'`,
|
||||
`Host ${i + 1}: Invalid authType. Must be one of ${[...knownAuthTypes].join(", ")}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -711,45 +707,18 @@ export function registerHostBulkRoutes(
|
||||
port: hostData.port,
|
||||
username,
|
||||
pin: hostData.pin || false,
|
||||
enableTerminal: hostData.enableTerminal !== false,
|
||||
enableTunnel: hostData.enableTunnel !== false,
|
||||
enableFileManager: hostData.enableFileManager !== false,
|
||||
enableDocker: hostData.enableDocker || false,
|
||||
enableWebUi: hostData.enableWebUi || false,
|
||||
enableProxmox: hostData.enableProxmox || false,
|
||||
enableTmuxMonitor: hostData.enableTmuxMonitor || false,
|
||||
enableTerminalToolbar: hostData.enableTerminalToolbar !== false,
|
||||
enableAiAssistant: hostData.enableAiAssistant || false,
|
||||
enableCommandHistory: hostData.enableCommandHistory !== false,
|
||||
showTerminalInSidebar: hostData.showTerminalInSidebar ? 1 : 0,
|
||||
showFileManagerInSidebar: hostData.showFileManagerInSidebar ? 1 : 0,
|
||||
showTunnelInSidebar: hostData.showTunnelInSidebar ? 1 : 0,
|
||||
showDockerInSidebar: hostData.showDockerInSidebar ? 1 : 0,
|
||||
showServerStatsInSidebar: hostData.showServerStatsInSidebar ? 1 : 0,
|
||||
defaultPath: hostData.defaultPath || "/",
|
||||
sudoPassword: hostData.sudoPassword || null,
|
||||
tunnelConnections: hostData.tunnelConnections
|
||||
? JSON.stringify(hostData.tunnelConnections)
|
||||
: "[]",
|
||||
jumpHosts: jumpHosts ? JSON.stringify(jumpHosts) : null,
|
||||
quickActions: hostData.quickActions
|
||||
? JSON.stringify(hostData.quickActions)
|
||||
: null,
|
||||
statsConfig: hostData.statsConfig
|
||||
? JSON.stringify(hostData.statsConfig)
|
||||
: null,
|
||||
dockerConfig: hostData.dockerConfig
|
||||
? JSON.stringify(hostData.dockerConfig)
|
||||
: null,
|
||||
webUiConfig: hostData.enableWebUi
|
||||
? serializeWebUiConfig(hostData.webUiConfig)
|
||||
: null,
|
||||
proxmoxConfig: hostData.proxmoxConfig
|
||||
? JSON.stringify(hostData.proxmoxConfig)
|
||||
: null,
|
||||
...importedStatusCheck(hostData as Record<string, unknown>),
|
||||
terminalConfig: hostData.terminalConfig
|
||||
? JSON.stringify(hostData.terminalConfig)
|
||||
: null,
|
||||
// A 2.8 export carries these inside terminalConfig.
|
||||
sshOptions:
|
||||
sshOptionsForWrite({
|
||||
sshOptions: hostData.sshOptions,
|
||||
terminalConfig: hostData.terminalConfig,
|
||||
}) ?? null,
|
||||
forceKeyboardInteractive: hostData.forceKeyboardInteractive
|
||||
? "true"
|
||||
: "false",
|
||||
@@ -769,9 +738,6 @@ export function registerHostBulkRoutes(
|
||||
? 1
|
||||
: 0,
|
||||
enableSsh: hostData.enableSsh ?? effectiveConnectionType === "ssh",
|
||||
enableRdp: hostData.enableRdp ?? false,
|
||||
enableVnc: hostData.enableVnc ?? false,
|
||||
enableTelnet: hostData.enableTelnet ?? false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -782,24 +748,6 @@ export function registerHostBulkRoutes(
|
||||
sshDataObj.key = null;
|
||||
sshDataObj.keyPassword = null;
|
||||
sshDataObj.keyType = null;
|
||||
sshDataObj.rdpUser = hostData.rdpUser || null;
|
||||
sshDataObj.rdpPassword = hostData.rdpPassword || null;
|
||||
sshDataObj.rdpDomain = hostData.rdpDomain || null;
|
||||
sshDataObj.rdpSecurity = hostData.rdpSecurity || null;
|
||||
sshDataObj.rdpIgnoreCert = hostData.rdpIgnoreCert ? 1 : 0;
|
||||
sshDataObj.rdpPort = hostData.rdpPort || 3389;
|
||||
sshDataObj.vncUser = hostData.vncUser || null;
|
||||
sshDataObj.vncPassword = hostData.vncPassword || null;
|
||||
sshDataObj.vncPort = hostData.vncPort || 5900;
|
||||
sshDataObj.telnetUser = hostData.telnetUser || null;
|
||||
sshDataObj.telnetPassword = hostData.telnetPassword || null;
|
||||
sshDataObj.telnetPort = hostData.telnetPort || 23;
|
||||
sshDataObj.enableRdp = hostData.enableRdp ? 1 : 0;
|
||||
sshDataObj.enableVnc = hostData.enableVnc ? 1 : 0;
|
||||
sshDataObj.enableTelnet = hostData.enableTelnet ? 1 : 0;
|
||||
sshDataObj.guacamoleConfig = hostData.guacamoleConfig
|
||||
? JSON.stringify(hostData.guacamoleConfig)
|
||||
: null;
|
||||
} else {
|
||||
sshDataObj.password =
|
||||
hostData.authType === "password" ? hostData.password : null;
|
||||
@@ -811,15 +759,18 @@ export function registerHostBulkRoutes(
|
||||
hostData.authType === "key" ? hostData.keyPassword || null : null;
|
||||
sshDataObj.keyType =
|
||||
hostData.authType === "key" ? hostData.keyType || "auto" : null;
|
||||
sshDataObj.domain = null;
|
||||
sshDataObj.security = null;
|
||||
sshDataObj.ignoreCert = 0;
|
||||
sshDataObj.guacamoleConfig = null;
|
||||
}
|
||||
|
||||
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
|
||||
const existing = existingHostMap?.get(lookupKey);
|
||||
await withHostDefaults(
|
||||
userId,
|
||||
existing?.id ?? null,
|
||||
sshDataObj,
|
||||
hostData as Record<string, unknown>,
|
||||
);
|
||||
|
||||
let savedHostId: number;
|
||||
if (existing) {
|
||||
const saved = await hostRepository.updateEncryptedForUser(
|
||||
userId,
|
||||
@@ -828,6 +779,7 @@ export function registerHostBulkRoutes(
|
||||
);
|
||||
if (!saved) throw new Error("Host no longer exists");
|
||||
if (exportId !== undefined) importedIds.set(exportId, existing.id);
|
||||
savedHostId = existing.id;
|
||||
results.updated++;
|
||||
} else {
|
||||
sshDataObj.createdAt = new Date().toISOString();
|
||||
@@ -836,8 +788,31 @@ export function registerHostBulkRoutes(
|
||||
sshDataObj,
|
||||
);
|
||||
if (exportId !== undefined) importedIds.set(exportId, saved.id);
|
||||
savedHostId = saved.id;
|
||||
results.success++;
|
||||
}
|
||||
|
||||
const protocolAuth = readProtocolAuthPayload(
|
||||
hostData as Record<string, unknown>,
|
||||
);
|
||||
if (protocolAuth) {
|
||||
await writeProtocolAuth(
|
||||
userId,
|
||||
savedHostId,
|
||||
await keepUsableProtocolCredentials(protocolAuth, userId),
|
||||
{ isOwner: true },
|
||||
);
|
||||
}
|
||||
|
||||
// Every enabled plugin that declares host-scope settings and
|
||||
// registered a hostImportNormalizer validates and writes its own
|
||||
// fields here, so this loop does not need to know which plugins
|
||||
// exist. See host-plugin-settings.ts.
|
||||
await applyPluginHostImportSettings(
|
||||
savedHostId,
|
||||
hostData as Record<string, unknown>,
|
||||
);
|
||||
await applyDefaultsAfterHostWrite(savedHostId);
|
||||
} catch (error) {
|
||||
results.failed++;
|
||||
results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`);
|
||||
@@ -991,32 +966,14 @@ export function registerHostBulkRoutes(
|
||||
keyType: null,
|
||||
credentialId: null,
|
||||
pin: false,
|
||||
enableTerminal: true,
|
||||
enableTunnel: true,
|
||||
enableFileManager: true,
|
||||
enableDocker: false,
|
||||
enableWebUi: false,
|
||||
enableProxmox: false,
|
||||
enableTmuxMonitor: false,
|
||||
enableTerminalToolbar: true,
|
||||
enableAiAssistant: false,
|
||||
showTerminalInSidebar: 0,
|
||||
showFileManagerInSidebar: 0,
|
||||
showTunnelInSidebar: 0,
|
||||
showDockerInSidebar: 0,
|
||||
showServerStatsInSidebar: 0,
|
||||
defaultPath: "/",
|
||||
sudoPassword: null,
|
||||
tunnelConnections: "[]",
|
||||
jumpHosts: hostData.jumpHosts
|
||||
? JSON.stringify(hostData.jumpHosts)
|
||||
: null,
|
||||
quickActions: null,
|
||||
statsConfig: null,
|
||||
dockerConfig: null,
|
||||
webUiConfig: null,
|
||||
proxmoxConfig: null,
|
||||
statusCheckEnabled: true,
|
||||
statusCheckInterval: null,
|
||||
terminalConfig: null,
|
||||
sshOptions: null,
|
||||
forceKeyboardInteractive: "false",
|
||||
notes: null,
|
||||
useSocks5: 0,
|
||||
@@ -1028,14 +985,17 @@ export function registerHostBulkRoutes(
|
||||
portKnockSequence: null,
|
||||
overrideCredentialUsername: 0,
|
||||
enableSsh: true,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
enableTelnet: false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
|
||||
const existing = existingHostMap?.get(lookupKey);
|
||||
await withHostDefaults(
|
||||
userId,
|
||||
existing?.id ?? null,
|
||||
sshDataObj,
|
||||
hostData as unknown as Record<string, unknown>,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await hostRepository.updateEncryptedForUser(
|
||||
@@ -1043,10 +1003,15 @@ export function registerHostBulkRoutes(
|
||||
existing.id,
|
||||
sshDataObj,
|
||||
);
|
||||
await applyDefaultsAfterHostWrite(existing.id);
|
||||
results.updated++;
|
||||
} else {
|
||||
sshDataObj.createdAt = new Date().toISOString();
|
||||
await hostRepository.createEncryptedForUser(userId, sshDataObj);
|
||||
const saved = await hostRepository.createEncryptedForUser(
|
||||
userId,
|
||||
sshDataObj,
|
||||
);
|
||||
await applyDefaultsAfterHostWrite(saved.id);
|
||||
results.success++;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1068,3 +1033,92 @@ export function registerHostBulkRoutes(
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Status check fields from an import row. Exports from before 2.9.0 carried
|
||||
* them inside statsConfig.
|
||||
*/
|
||||
function importedStatusCheck(raw: Record<string, unknown>): {
|
||||
statusCheckEnabled: boolean;
|
||||
statusCheckInterval: number | null;
|
||||
} {
|
||||
const legacy =
|
||||
raw.statsConfig && typeof raw.statsConfig === "object"
|
||||
? (raw.statsConfig as Record<string, unknown>)
|
||||
: {};
|
||||
const enabled = raw.statusCheckEnabled ?? legacy.statusCheckEnabled;
|
||||
const useGlobal = legacy.useGlobalStatusInterval !== false;
|
||||
const interval =
|
||||
raw.statusCheckInterval ?? (useGlobal ? null : legacy.statusCheckInterval);
|
||||
const seconds = Number(interval);
|
||||
return {
|
||||
statusCheckEnabled: enabled !== false && legacy.disableTcpPing !== true,
|
||||
statusCheckInterval:
|
||||
interval != null && Number.isInteger(seconds) && seconds >= 5
|
||||
? seconds
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
const BUILTIN_SSH_AUTH_TYPES = [
|
||||
"password",
|
||||
"key",
|
||||
"credential",
|
||||
"agent",
|
||||
"none",
|
||||
];
|
||||
|
||||
/** Core's own types plus every type a plugin registers or declares. */
|
||||
function listKnownAuthTypes(): Set<string> {
|
||||
return new Set([
|
||||
...BUILTIN_SSH_AUTH_TYPES,
|
||||
...listSshAuthProviders().map((provider) => provider.type),
|
||||
...listSshAuthTypeOwners().map((owner) => owner.type),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Fills an imported host's inherited keys and records what it sets itself. */
|
||||
async function withHostDefaults(
|
||||
userId: string,
|
||||
hostId: number | null,
|
||||
columns: Record<string, unknown>,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const stored =
|
||||
hostId === null
|
||||
? null
|
||||
: ((
|
||||
await createCurrentHostDefaultsRepository().listHosts({
|
||||
hostIds: [hostId],
|
||||
})
|
||||
)[0] ?? null);
|
||||
columns.defaultOverrides = JSON.stringify(
|
||||
await applyHostDefaultsToWrite({
|
||||
ownerId: userId,
|
||||
hostId,
|
||||
columns,
|
||||
body,
|
||||
stored,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** A resetDefaults body as an override change, or null when there is none. */
|
||||
export function readDefaultsReset(raw: unknown): OverrideChange | null {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const source = raw as Record<string, unknown>;
|
||||
const strings = (value: unknown) =>
|
||||
Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
const change: OverrideChange = {
|
||||
inheritAll: source.all === true,
|
||||
inheritNamespaces: strings(source.namespaces),
|
||||
inherit: strings(source.keys).map(splitDefaultKey),
|
||||
};
|
||||
return change.inheritAll ||
|
||||
change.inheritNamespaces!.length > 0 ||
|
||||
change.inherit!.length > 0
|
||||
? change
|
||||
: null;
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
import { createCurrentCommandHistoryRepository } from "../repositories/factory.js";
|
||||
import { isNonEmptyString } from "./host-normalizers.js";
|
||||
|
||||
export function registerHostCommandHistoryRoutes(
|
||||
router: Router,
|
||||
authenticateJWT: RequestHandler,
|
||||
requireViewPermission: RequestHandler,
|
||||
requireDataAccess: RequestHandler,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host/command-history/{hostId}:
|
||||
* get:
|
||||
* summary: Get command history
|
||||
* description: Retrieves the command history for a specific host.
|
||||
* tags:
|
||||
* - SSH
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of commands.
|
||||
* 400:
|
||||
* description: Invalid userId or hostId.
|
||||
* 500:
|
||||
* description: Failed to fetch command history.
|
||||
*/
|
||||
router.get(
|
||||
"/command-history/:hostId",
|
||||
authenticateJWT,
|
||||
requireViewPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostIdParam = Array.isArray(req.params.hostId)
|
||||
? req.params.hostId[0]
|
||||
: req.params.hostId;
|
||||
const hostId = parseInt(hostIdParam, 10);
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId) {
|
||||
sshLogger.warn("Invalid userId or hostId for command history fetch", {
|
||||
operation: "command_history_fetch",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
return res.status(400).json({ error: "Invalid userId or hostId" });
|
||||
}
|
||||
|
||||
try {
|
||||
const history =
|
||||
await createCurrentCommandHistoryRepository().listCommandsForHost(
|
||||
userId,
|
||||
hostId,
|
||||
);
|
||||
|
||||
res.json(history);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch command history from database", err, {
|
||||
operation: "command_history_fetch",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to fetch command history" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/command-history:
|
||||
* delete:
|
||||
* summary: Delete command from history
|
||||
* description: Deletes a specific command from the history of a host.
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* command:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Command deleted from history.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 500:
|
||||
* description: Failed to delete command.
|
||||
*/
|
||||
router.delete(
|
||||
"/command-history",
|
||||
authenticateJWT,
|
||||
requireViewPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId, command } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !command) {
|
||||
sshLogger.warn("Invalid data for command history deletion", {
|
||||
operation: "command_history_delete",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
return res.status(400).json({ error: "Invalid data" });
|
||||
}
|
||||
|
||||
try {
|
||||
await createCurrentCommandHistoryRepository().deleteCommandForHost(
|
||||
userId,
|
||||
hostId,
|
||||
command,
|
||||
);
|
||||
|
||||
res.json({ message: "Command deleted from history" });
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to delete command from history", err, {
|
||||
operation: "command_history_delete",
|
||||
hostId,
|
||||
userId,
|
||||
command,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to delete command" });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
getRequestMeta,
|
||||
logAudit,
|
||||
getAuditUsername,
|
||||
} from "../../utils/audit-logger.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import { createCurrentHostDefaultsRepository } from "../repositories/factory.js";
|
||||
import type { HostDefaultsScope } from "../repositories/host-defaults-repository.js";
|
||||
import { findUsableCredential } from "../../hosts/usable-credential.js";
|
||||
import {
|
||||
currentCatalog,
|
||||
materializeHosts,
|
||||
} from "../../hosts/defaults/materialize.js";
|
||||
import {
|
||||
getRecomputeJob,
|
||||
recompute,
|
||||
startRecomputeJob,
|
||||
targetForScope,
|
||||
} from "../../hosts/defaults/recompute.js";
|
||||
import {
|
||||
levelsAbove,
|
||||
readLevel,
|
||||
resolveAll,
|
||||
resolveForEditor,
|
||||
saveLevel,
|
||||
validateLevelChange,
|
||||
type CheckContext,
|
||||
type LevelChangeInput,
|
||||
} from "../../hosts/defaults/service.js";
|
||||
import { changeHostOverrides } from "../../hosts/defaults/overrides.js";
|
||||
import { readDefaultsReset } from "./host-bulk-routes.js";
|
||||
|
||||
type HostDefaultsRoutesDeps = {
|
||||
authenticateJWT: RequestHandler;
|
||||
requireEditPermission: RequestHandler;
|
||||
requireDataAccess: RequestHandler;
|
||||
requireAdminSettings: RequestHandler;
|
||||
};
|
||||
|
||||
function checkContext(actorId: string, ownerId: string | null): CheckContext {
|
||||
const permissions = PermissionManager.getInstance();
|
||||
return {
|
||||
actorId,
|
||||
ownerId,
|
||||
canUseCredential: async (credentialId, userId) =>
|
||||
(await findUsableCredential(credentialId, userId)) !== null,
|
||||
canUseHost: async (hostId, userId) =>
|
||||
(await permissions.canAccessHost(userId, hostId, "connect")).hasAccess,
|
||||
};
|
||||
}
|
||||
|
||||
function readChange(body: unknown): LevelChangeInput {
|
||||
const source =
|
||||
body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
||||
return {
|
||||
set:
|
||||
source.set && typeof source.set === "object" && !Array.isArray(source.set)
|
||||
? (source.set as Record<string, unknown>)
|
||||
: {},
|
||||
unset: Array.isArray(source.unset)
|
||||
? source.unset.filter((key): key is string => typeof key === "string")
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function describeLevel(
|
||||
scope: HostDefaultsScope,
|
||||
folderOwner?: { userId: string; name: string },
|
||||
) {
|
||||
const { catalog } = currentCatalog();
|
||||
return {
|
||||
values: await readLevel(scope),
|
||||
inherited: resolveAll(catalog, await levelsAbove(scope, folderOwner)),
|
||||
};
|
||||
}
|
||||
|
||||
/** The folder a user may edit defaults for, by id. */
|
||||
async function ownedFolder(userId: string, folderId: number) {
|
||||
const folder =
|
||||
await createCurrentHostDefaultsRepository().findFolder(folderId);
|
||||
return folder && folder.userId === userId ? folder : null;
|
||||
}
|
||||
|
||||
export function registerHostDefaultsRoutes(
|
||||
router: Router,
|
||||
{
|
||||
authenticateJWT,
|
||||
requireEditPermission,
|
||||
requireDataAccess,
|
||||
requireAdminSettings,
|
||||
}: HostDefaultsRoutesDeps,
|
||||
): void {
|
||||
const save = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
scope: HostDefaultsScope,
|
||||
ownerId: string | null,
|
||||
folderOwnerId?: string,
|
||||
) => {
|
||||
const actorId = (req as AuthenticatedRequest).userId;
|
||||
const change = await validateLevelChange(
|
||||
scope,
|
||||
readChange(req.body),
|
||||
checkContext(actorId, ownerId),
|
||||
);
|
||||
if (Object.keys(change.errors).length > 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Some defaults were rejected", errors: change.errors });
|
||||
}
|
||||
await saveLevel(scope, change, actorId);
|
||||
const keys = new Set([...change.set.keys(), ...change.unset]);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId: actorId,
|
||||
username: await getAuditUsername(actorId),
|
||||
action: "update_host_defaults",
|
||||
resourceType: "host_defaults",
|
||||
resourceId:
|
||||
scope.level === "admin"
|
||||
? "admin"
|
||||
: scope.level === "user"
|
||||
? String(scope.userId)
|
||||
: String(scope.folderId),
|
||||
resourceName: scope.level,
|
||||
details: JSON.stringify({ keys: [...keys] }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
const target = targetForScope(scope, folderOwnerId);
|
||||
if (scope.level === "admin") {
|
||||
const job = startRecomputeJob(target, keys);
|
||||
return res.json({ jobId: job.id });
|
||||
}
|
||||
const result = await recompute(target, keys);
|
||||
return res.json({ changedHosts: result.changedHostIds.length });
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/defaults/admin:
|
||||
* get:
|
||||
* summary: Get the server's host defaults
|
||||
* description: The host defaults every user's hosts follow unless the user, a folder or the host sets its own. Returns the values set at this level and what each key reads without them.
|
||||
* tags:
|
||||
* - Host Defaults
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Values and inherited values, keyed by "namespace.key".
|
||||
* 403:
|
||||
* description: Missing admin.settings.manage.
|
||||
*/
|
||||
router.get(
|
||||
"/defaults/admin",
|
||||
authenticateJWT,
|
||||
requireAdminSettings,
|
||||
async (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json(await describeLevel({ level: "admin" }));
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to read admin host defaults", error);
|
||||
res.status(500).json({ error: "Failed to read host defaults" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/defaults/admin:
|
||||
* put:
|
||||
* summary: Update the server's host defaults
|
||||
* description: Sets and clears server-wide host defaults. Every inheriting host is updated in the background; poll the returned job.
|
||||
* tags:
|
||||
* - Host Defaults
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* set:
|
||||
* type: object
|
||||
* description: Values by "namespace.key".
|
||||
* unset:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Saved. Returns the recompute job id.
|
||||
* 400:
|
||||
* description: One or more values were rejected.
|
||||
* 403:
|
||||
* description: Missing admin.settings.manage.
|
||||
*/
|
||||
router.put(
|
||||
"/defaults/admin",
|
||||
authenticateJWT,
|
||||
requireAdminSettings,
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
await save(req, res, { level: "admin" }, null);
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to update admin host defaults", error);
|
||||
res.status(500).json({ error: "Failed to update host defaults" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/defaults/user:
|
||||
* get:
|
||||
* summary: Get your host defaults
|
||||
* description: The defaults your hosts follow, over the server's. Returns the values you set and what each key reads without them.
|
||||
* tags:
|
||||
* - Host Defaults
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Values and inherited values, keyed by "namespace.key".
|
||||
*/
|
||||
router.get(
|
||||
"/defaults/user",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
res.json(await describeLevel({ level: "user", userId }));
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to read user host defaults", error);
|
||||
res.status(500).json({ error: "Failed to read host defaults" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/defaults/user:
|
||||
* put:
|
||||
* summary: Update your host defaults
|
||||
* description: Sets and clears your own host defaults and updates every host of yours that follows them.
|
||||
* tags:
|
||||
* - Host Defaults
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* set:
|
||||
* type: object
|
||||
* unset:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Saved. Returns how many hosts changed.
|
||||
* 400:
|
||||
* description: One or more values were rejected.
|
||||
*/
|
||||
router.put(
|
||||
"/defaults/user",
|
||||
authenticateJWT,
|
||||
requireEditPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
await save(req, res, { level: "user", userId }, userId);
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to update user host defaults", error);
|
||||
res.status(500).json({ error: "Failed to update host defaults" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/defaults/folders/{folderId}:
|
||||
* get:
|
||||
* summary: Get a folder's host defaults
|
||||
* description: The defaults hosts in this folder and its subfolders follow, over yours and the server's.
|
||||
* tags:
|
||||
* - Host Defaults
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: folderId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Values and inherited values, keyed by "namespace.key".
|
||||
* 404:
|
||||
* description: No such folder of yours.
|
||||
*/
|
||||
router.get(
|
||||
"/defaults/folders/:folderId",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const folder = await ownedFolder(userId, Number(req.params.folderId));
|
||||
if (!folder) return res.status(404).json({ error: "Folder not found" });
|
||||
try {
|
||||
res.json(
|
||||
await describeLevel(
|
||||
{ level: "folder", folderId: folder.id, userId },
|
||||
folder,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to read folder host defaults", error);
|
||||
res.status(500).json({ error: "Failed to read host defaults" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/defaults/folders/{folderId}:
|
||||
* put:
|
||||
* summary: Update a folder's host defaults
|
||||
* description: Sets and clears a folder's host defaults and updates every host in it (and its subfolders) that follows them.
|
||||
* tags:
|
||||
* - Host Defaults
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: folderId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Saved. Returns how many hosts changed.
|
||||
* 400:
|
||||
* description: One or more values were rejected.
|
||||
* 404:
|
||||
* description: No such folder of yours.
|
||||
*/
|
||||
router.put(
|
||||
"/defaults/folders/:folderId",
|
||||
authenticateJWT,
|
||||
requireEditPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const folder = await ownedFolder(userId, Number(req.params.folderId));
|
||||
if (!folder) return res.status(404).json({ error: "Folder not found" });
|
||||
try {
|
||||
await save(
|
||||
req,
|
||||
res,
|
||||
{ level: "folder", folderId: folder.id, userId },
|
||||
userId,
|
||||
userId,
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to update folder host defaults", error);
|
||||
res.status(500).json({ error: "Failed to update host defaults" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/defaults/folders:
|
||||
* post:
|
||||
* summary: Get or create a folder's defaults id
|
||||
* description: A folder can exist only as a path on its hosts. This returns its id, creating the folder row when needed, so its defaults can be edited.
|
||||
* tags:
|
||||
* - Host Defaults
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The folder's id.
|
||||
* 400:
|
||||
* description: Missing folder name.
|
||||
*/
|
||||
router.post(
|
||||
"/defaults/folders",
|
||||
authenticateJWT,
|
||||
requireEditPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const name = typeof req.body?.name === "string" ? req.body.name : "";
|
||||
if (!name.trim()) {
|
||||
return res.status(400).json({ error: "Folder name is required" });
|
||||
}
|
||||
try {
|
||||
const id = await createCurrentHostDefaultsRepository().ensureFolder(
|
||||
userId,
|
||||
name,
|
||||
);
|
||||
res.json({ id });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to find folder for host defaults", error);
|
||||
res.status(500).json({ error: "Failed to find the folder" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/defaults/preview:
|
||||
* post:
|
||||
* summary: Preview a host defaults change
|
||||
* description: How many hosts would change if a level were saved with these values. Nothing is written.
|
||||
* tags:
|
||||
* - Host Defaults
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* level:
|
||||
* type: string
|
||||
* enum: [admin, user, folder]
|
||||
* folderId:
|
||||
* type: integer
|
||||
* set:
|
||||
* type: object
|
||||
* unset:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The number of hosts that would change.
|
||||
* 403:
|
||||
* description: Not allowed to change that level.
|
||||
*/
|
||||
router.post(
|
||||
"/defaults/preview",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const level = req.body?.level;
|
||||
try {
|
||||
let target;
|
||||
let overlayUserId: string | undefined;
|
||||
let folderId: number | undefined;
|
||||
if (level === "admin") {
|
||||
const permissions = PermissionManager.getInstance();
|
||||
if (
|
||||
!(await permissions.hasPermission(userId, "admin.settings.manage"))
|
||||
) {
|
||||
return res.status(403).json({ error: "Access denied" });
|
||||
}
|
||||
target = { all: true };
|
||||
} else if (level === "user") {
|
||||
target = { userIds: [userId] };
|
||||
overlayUserId = userId;
|
||||
} else if (level === "folder") {
|
||||
const folder = await ownedFolder(userId, Number(req.body?.folderId));
|
||||
if (!folder)
|
||||
return res.status(404).json({ error: "Folder not found" });
|
||||
target = { userIds: [userId] };
|
||||
folderId = folder.id;
|
||||
} else {
|
||||
return res.status(400).json({ error: "Unknown level" });
|
||||
}
|
||||
const change = readChange(req.body);
|
||||
const { catalog } = currentCatalog();
|
||||
const set = new Map<string, unknown>();
|
||||
for (const [key, value] of Object.entries(change.set ?? {})) {
|
||||
const info = catalog.get(key);
|
||||
if (info) set.set(key, info.normalize(value));
|
||||
}
|
||||
const result = await materializeHosts(target, {
|
||||
dryRun: true,
|
||||
overlay: {
|
||||
level,
|
||||
userId: overlayUserId,
|
||||
folderId,
|
||||
set,
|
||||
unset: new Set(change.unset ?? []),
|
||||
},
|
||||
});
|
||||
res.json({ changedHosts: result.changedHostIds.length });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to preview host defaults", error);
|
||||
res.status(500).json({ error: "Failed to preview host defaults" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/defaults/resolve:
|
||||
* get:
|
||||
* summary: Resolve host defaults for a host
|
||||
* description: What every key resolves to for a host, or for a new host in a folder or under a parent host, and where each value comes from.
|
||||
* tags:
|
||||
* - Host Defaults
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: hostId
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: query
|
||||
* name: folder
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: parentHostId
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Resolved values with their source, keyed by "namespace.key".
|
||||
* 404:
|
||||
* description: No such host you can edit.
|
||||
*/
|
||||
router.get(
|
||||
"/defaults/resolve",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
let ownerId = userId;
|
||||
const hostId =
|
||||
req.query.hostId !== undefined ? Number(req.query.hostId) : null;
|
||||
if (hostId !== null) {
|
||||
if (!Number.isInteger(hostId)) {
|
||||
return res.status(400).json({ error: "Invalid host ID" });
|
||||
}
|
||||
const access = await PermissionManager.getInstance().canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
"edit",
|
||||
);
|
||||
if (!access.hasAccess) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
const [row] = await createCurrentHostDefaultsRepository().listHosts({
|
||||
hostIds: [hostId],
|
||||
});
|
||||
if (!row) return res.status(404).json({ error: "Host not found" });
|
||||
ownerId = row.userId;
|
||||
}
|
||||
const folder =
|
||||
typeof req.query.folder === "string" ? req.query.folder : undefined;
|
||||
const parentHostId =
|
||||
req.query.parentHostId !== undefined && req.query.parentHostId !== ""
|
||||
? Number(req.query.parentHostId)
|
||||
: undefined;
|
||||
res.json({
|
||||
values: await resolveForEditor({
|
||||
ownerId,
|
||||
hostId,
|
||||
folder: folder === undefined ? undefined : folder || null,
|
||||
parentHostId:
|
||||
parentHostId === undefined || !Number.isInteger(parentHostId)
|
||||
? folder !== undefined
|
||||
? null
|
||||
: undefined
|
||||
: parentHostId,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to resolve host defaults", error);
|
||||
res.status(500).json({ error: "Failed to resolve host defaults" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/defaults/jobs/{id}:
|
||||
* get:
|
||||
* summary: Host defaults job status
|
||||
* description: Progress of a server-wide host defaults update.
|
||||
* tags:
|
||||
* - Host Defaults
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The job's status and how many hosts changed.
|
||||
* 404:
|
||||
* description: No such job.
|
||||
*/
|
||||
router.get(
|
||||
"/defaults/jobs/:id",
|
||||
authenticateJWT,
|
||||
requireAdminSettings,
|
||||
(req: Request, res: Response) => {
|
||||
const job = getRecomputeJob(String(req.params.id));
|
||||
if (!job) return res.status(404).json({ error: "Job not found" });
|
||||
res.json(job);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/db/host/{id}/reset-defaults:
|
||||
* post:
|
||||
* summary: Reset a host to its defaults
|
||||
* description: Hands keys the host set itself back to its host defaults. `all`, or `namespaces` ("core" or a plugin id), or `keys` as "namespace.key".
|
||||
* tags:
|
||||
* - Host Defaults
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* all:
|
||||
* type: boolean
|
||||
* namespaces:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* keys:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Reset.
|
||||
* 400:
|
||||
* description: Nothing to reset.
|
||||
* 403:
|
||||
* description: No edit access to the host.
|
||||
*/
|
||||
router.post(
|
||||
"/db/host/:id/reset-defaults",
|
||||
authenticateJWT,
|
||||
requireEditPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostId = Number(req.params.id);
|
||||
if (!Number.isInteger(hostId)) {
|
||||
return res.status(400).json({ error: "Invalid host ID" });
|
||||
}
|
||||
const reset = readDefaultsReset(req.body);
|
||||
if (!reset) return res.status(400).json({ error: "Nothing to reset" });
|
||||
try {
|
||||
const access = await PermissionManager.getInstance().canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
"edit",
|
||||
);
|
||||
if (!access.hasAccess) {
|
||||
return res.status(403).json({ error: "Access denied" });
|
||||
}
|
||||
if (!access.isOwner) {
|
||||
// A shared editor never takes over the owner's SSH login.
|
||||
reset.inherit = (reset.inherit ?? []).filter(
|
||||
([namespace, key]) => !(namespace === "core" && key === "auth"),
|
||||
);
|
||||
if (reset.inheritAll || reset.inheritNamespaces?.includes("core")) {
|
||||
return res.status(403).json({
|
||||
error: "Only the host owner can reset the host's SSH login",
|
||||
});
|
||||
}
|
||||
}
|
||||
await changeHostOverrides([hostId], reset);
|
||||
res.json({ message: "Host reset to its defaults" });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to reset host defaults", error);
|
||||
res.status(500).json({ error: "Failed to reset host defaults" });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ export function applyHostEnrollmentDefaults(
|
||||
connectionType: "ssh",
|
||||
port: 22,
|
||||
authType: "none",
|
||||
enableTerminal: true,
|
||||
enableSsh: true,
|
||||
...hostData,
|
||||
};
|
||||
|
||||
@@ -1,544 +0,0 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
import { createCurrentFileManagerBookmarkRepository } from "../repositories/factory.js";
|
||||
import { isNonEmptyString } from "./host-normalizers.js";
|
||||
|
||||
export function registerHostFileManagerBookmarkRoutes(
|
||||
router: Router,
|
||||
authenticateJWT: RequestHandler,
|
||||
requireViewPermission: RequestHandler,
|
||||
requireDataAccess: RequestHandler,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host/file_manager/recent:
|
||||
* get:
|
||||
* summary: Get recent files
|
||||
* description: Retrieves a list of recent files for a specific host.
|
||||
* tags:
|
||||
* - SSH
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of recent files.
|
||||
* 400:
|
||||
* description: Invalid userId or hostId.
|
||||
* 500:
|
||||
* description: Failed to fetch recent files.
|
||||
*/
|
||||
router.get(
|
||||
"/file_manager/recent",
|
||||
authenticateJWT,
|
||||
requireViewPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostIdQuery = Array.isArray(req.query.hostId)
|
||||
? req.query.hostId[0]
|
||||
: req.query.hostId;
|
||||
const hostId = hostIdQuery ? parseInt(hostIdQuery as string) : null;
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
sshLogger.warn("Invalid userId for recent files fetch");
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
}
|
||||
|
||||
if (!hostId) {
|
||||
sshLogger.warn("Host ID is required for recent files fetch");
|
||||
return res.status(400).json({ error: "Host ID is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const recentFiles =
|
||||
await createCurrentFileManagerBookmarkRepository().listRecentForHost(
|
||||
userId,
|
||||
hostId,
|
||||
20,
|
||||
);
|
||||
|
||||
res.json(recentFiles);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch recent files", err);
|
||||
res.status(500).json({ error: "Failed to fetch recent files" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/file_manager/recent:
|
||||
* post:
|
||||
* summary: Add recent file
|
||||
* description: Adds a file to the list of recent files for a host.
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* path:
|
||||
* type: string
|
||||
* name:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Recent file added.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 500:
|
||||
* description: Failed to add recent file.
|
||||
*/
|
||||
router.post(
|
||||
"/file_manager/recent",
|
||||
authenticateJWT,
|
||||
requireViewPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId, path, name } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !path) {
|
||||
sshLogger.warn("Invalid data for recent file addition");
|
||||
return res.status(400).json({ error: "Invalid data" });
|
||||
}
|
||||
|
||||
try {
|
||||
await createCurrentFileManagerBookmarkRepository().upsertRecent(
|
||||
userId,
|
||||
{
|
||||
hostId,
|
||||
path,
|
||||
name,
|
||||
},
|
||||
);
|
||||
|
||||
res.json({ message: "Recent file added" });
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to add recent file", err);
|
||||
res.status(500).json({ error: "Failed to add recent file" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/file_manager/recent:
|
||||
* delete:
|
||||
* summary: Remove recent file
|
||||
* description: Removes a file from the list of recent files for a host.
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* path:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Recent file removed.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 500:
|
||||
* description: Failed to remove recent file.
|
||||
*/
|
||||
router.delete(
|
||||
"/file_manager/recent",
|
||||
authenticateJWT,
|
||||
requireViewPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId, path } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !path) {
|
||||
sshLogger.warn("Invalid data for recent file deletion");
|
||||
return res.status(400).json({ error: "Invalid data" });
|
||||
}
|
||||
|
||||
try {
|
||||
await createCurrentFileManagerBookmarkRepository().deleteRecentForHostPath(
|
||||
userId,
|
||||
{ hostId, path },
|
||||
);
|
||||
|
||||
res.json({ message: "Recent file removed" });
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to remove recent file", err);
|
||||
res.status(500).json({ error: "Failed to remove recent file" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/file_manager/pinned:
|
||||
* get:
|
||||
* summary: Get pinned files
|
||||
* description: Retrieves a list of pinned files for a specific host.
|
||||
* tags:
|
||||
* - SSH
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of pinned files.
|
||||
* 400:
|
||||
* description: Invalid userId or hostId.
|
||||
* 500:
|
||||
* description: Failed to fetch pinned files.
|
||||
*/
|
||||
router.get(
|
||||
"/file_manager/pinned",
|
||||
authenticateJWT,
|
||||
requireViewPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostIdQuery = Array.isArray(req.query.hostId)
|
||||
? req.query.hostId[0]
|
||||
: req.query.hostId;
|
||||
const hostId = hostIdQuery ? parseInt(hostIdQuery as string) : null;
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
sshLogger.warn("Invalid userId for pinned files fetch");
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
}
|
||||
|
||||
if (!hostId) {
|
||||
sshLogger.warn("Host ID is required for pinned files fetch");
|
||||
return res.status(400).json({ error: "Host ID is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const pinnedFiles =
|
||||
await createCurrentFileManagerBookmarkRepository().listPinnedForHost(
|
||||
userId,
|
||||
hostId,
|
||||
);
|
||||
|
||||
res.json(pinnedFiles);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch pinned files", err);
|
||||
res.status(500).json({ error: "Failed to fetch pinned files" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/file_manager/pinned:
|
||||
* post:
|
||||
* summary: Add pinned file
|
||||
* description: Adds a file to the list of pinned files for a host.
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* path:
|
||||
* type: string
|
||||
* name:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: File pinned.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 409:
|
||||
* description: File already pinned.
|
||||
* 500:
|
||||
* description: Failed to pin file.
|
||||
*/
|
||||
router.post(
|
||||
"/file_manager/pinned",
|
||||
authenticateJWT,
|
||||
requireViewPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId, path, name } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !path) {
|
||||
sshLogger.warn("Invalid data for pinned file addition");
|
||||
return res.status(400).json({ error: "Invalid data" });
|
||||
}
|
||||
|
||||
try {
|
||||
const created =
|
||||
await createCurrentFileManagerBookmarkRepository().createPinned(
|
||||
userId,
|
||||
{ hostId, path, name },
|
||||
);
|
||||
|
||||
if (!created) {
|
||||
return res.status(409).json({ error: "File already pinned" });
|
||||
}
|
||||
|
||||
res.json({ message: "File pinned" });
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to pin file", err);
|
||||
res.status(500).json({ error: "Failed to pin file" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/file_manager/pinned:
|
||||
* delete:
|
||||
* summary: Remove pinned file
|
||||
* description: Removes a file from the list of pinned files for a host.
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* path:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Pinned file removed.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 500:
|
||||
* description: Failed to remove pinned file.
|
||||
*/
|
||||
router.delete(
|
||||
"/file_manager/pinned",
|
||||
authenticateJWT,
|
||||
requireViewPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId, path } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !path) {
|
||||
sshLogger.warn("Invalid data for pinned file deletion");
|
||||
return res.status(400).json({ error: "Invalid data" });
|
||||
}
|
||||
|
||||
try {
|
||||
await createCurrentFileManagerBookmarkRepository().deletePinnedForHostPath(
|
||||
userId,
|
||||
{ hostId, path },
|
||||
);
|
||||
|
||||
res.json({ message: "Pinned file removed" });
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to remove pinned file", err);
|
||||
res.status(500).json({ error: "Failed to remove pinned file" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/file_manager/shortcuts:
|
||||
* get:
|
||||
* summary: Get shortcuts
|
||||
* description: Retrieves a list of shortcuts for a specific host.
|
||||
* tags:
|
||||
* - SSH
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of shortcuts.
|
||||
* 400:
|
||||
* description: Invalid userId or hostId.
|
||||
* 500:
|
||||
* description: Failed to fetch shortcuts.
|
||||
*/
|
||||
router.get(
|
||||
"/file_manager/shortcuts",
|
||||
authenticateJWT,
|
||||
requireViewPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostIdQuery = Array.isArray(req.query.hostId)
|
||||
? req.query.hostId[0]
|
||||
: req.query.hostId;
|
||||
const hostId = hostIdQuery ? parseInt(hostIdQuery as string) : null;
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
sshLogger.warn("Invalid userId for shortcuts fetch");
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
}
|
||||
|
||||
if (!hostId) {
|
||||
sshLogger.warn("Host ID is required for shortcuts fetch");
|
||||
return res.status(400).json({ error: "Host ID is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const shortcuts =
|
||||
await createCurrentFileManagerBookmarkRepository().listShortcutsForHost(
|
||||
userId,
|
||||
hostId,
|
||||
);
|
||||
|
||||
res.json(shortcuts);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch shortcuts", err);
|
||||
res.status(500).json({ error: "Failed to fetch shortcuts" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/file_manager/shortcuts:
|
||||
* post:
|
||||
* summary: Add shortcut
|
||||
* description: Adds a shortcut for a specific host.
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* path:
|
||||
* type: string
|
||||
* name:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Shortcut added.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 409:
|
||||
* description: Shortcut already exists.
|
||||
* 500:
|
||||
* description: Failed to add shortcut.
|
||||
*/
|
||||
router.post(
|
||||
"/file_manager/shortcuts",
|
||||
authenticateJWT,
|
||||
requireViewPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId, path, name } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !path) {
|
||||
sshLogger.warn("Invalid data for shortcut addition");
|
||||
return res.status(400).json({ error: "Invalid data" });
|
||||
}
|
||||
|
||||
try {
|
||||
const created =
|
||||
await createCurrentFileManagerBookmarkRepository().createShortcut(
|
||||
userId,
|
||||
{ hostId, path, name },
|
||||
);
|
||||
|
||||
if (!created) {
|
||||
return res.status(409).json({ error: "Shortcut already exists" });
|
||||
}
|
||||
|
||||
res.json({ message: "Shortcut added" });
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to add shortcut", err);
|
||||
res.status(500).json({ error: "Failed to add shortcut" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/file_manager/shortcuts:
|
||||
* delete:
|
||||
* summary: Remove shortcut
|
||||
* description: Removes a shortcut for a specific host.
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* path:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Shortcut removed.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 500:
|
||||
* description: Failed to remove shortcut.
|
||||
*/
|
||||
router.delete(
|
||||
"/file_manager/shortcuts",
|
||||
authenticateJWT,
|
||||
requireViewPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId, path } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !path) {
|
||||
sshLogger.warn("Invalid data for shortcut deletion");
|
||||
return res.status(400).json({ error: "Invalid data" });
|
||||
}
|
||||
|
||||
try {
|
||||
await createCurrentFileManagerBookmarkRepository().deleteShortcutForHostPath(
|
||||
userId,
|
||||
{ hostId, path },
|
||||
);
|
||||
|
||||
res.json({ message: "Shortcut removed" });
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to remove shortcut", err);
|
||||
res.status(500).json({ error: "Failed to remove shortcut" });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,18 @@
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { databaseLogger, sshLogger } from "../../utils/logger.js";
|
||||
import { pluginEvents, TOPICS } from "../../plugins/events.js";
|
||||
import {
|
||||
createCurrentCommandHistoryRepository,
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentFileManagerBookmarkRepository,
|
||||
createCurrentHostFolderRepository,
|
||||
createCurrentFolderAccessRepository,
|
||||
createCurrentRecentActivityRepository,
|
||||
createCurrentRbacAccessRepository,
|
||||
createCurrentSshCredentialUsageRepository,
|
||||
createCurrentSessionRecordingRepository,
|
||||
createCurrentTransferRecentRepository,
|
||||
createCurrentSyncTombstoneRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import { isNonEmptyString } from "./host-normalizers.js";
|
||||
import { recompute } from "../../hosts/defaults/recompute.js";
|
||||
import { setFolderCredentialDefault } from "../../hosts/defaults/service.js";
|
||||
|
||||
type HostFolderRoutesDeps = {
|
||||
authenticateJWT: RequestHandler;
|
||||
@@ -23,7 +21,6 @@ type HostFolderRoutesDeps = {
|
||||
requireDeletePermission: RequestHandler;
|
||||
requireCredentialEditPermission: RequestHandler;
|
||||
requireDataAccess: RequestHandler;
|
||||
statsServerUrl: string;
|
||||
};
|
||||
|
||||
export function registerHostFolderRoutes(
|
||||
@@ -35,7 +32,6 @@ export function registerHostFolderRoutes(
|
||||
requireDeletePermission,
|
||||
requireCredentialEditPermission,
|
||||
requireDataAccess,
|
||||
statsServerUrl,
|
||||
}: HostFolderRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
@@ -98,6 +94,8 @@ export function registerHostFolderRoutes(
|
||||
oldName,
|
||||
newName,
|
||||
);
|
||||
// Subfolders change parents with a rename, so their chains do too.
|
||||
void recompute({ userIds: [userId] }).catch(() => {});
|
||||
|
||||
res.json({
|
||||
message: "Folder renamed successfully",
|
||||
@@ -183,6 +181,9 @@ export function registerHostFolderRoutes(
|
||||
* credentialId:
|
||||
* type: integer
|
||||
* nullable: true
|
||||
* localOnly:
|
||||
* type: boolean
|
||||
* description: Desktop only. Keeps the folder and its hosts on this device instead of syncing them.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Folder metadata updated successfully.
|
||||
@@ -198,7 +199,7 @@ export function registerHostFolderRoutes(
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { name, color, icon, credentialId } = req.body;
|
||||
const { name, color, icon, credentialId, localOnly } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !name) {
|
||||
return res.status(400).json({ error: "Folder name is required" });
|
||||
@@ -239,6 +240,21 @@ export function registerHostFolderRoutes(
|
||||
icon,
|
||||
normalizedCredentialId,
|
||||
);
|
||||
if (typeof localOnly === "boolean") {
|
||||
await createCurrentHostFolderRepository().setLocalOnly(
|
||||
userId,
|
||||
name,
|
||||
localOnly,
|
||||
);
|
||||
}
|
||||
if (normalizedCredentialId !== undefined) {
|
||||
await setFolderCredentialDefault(
|
||||
userId,
|
||||
folder.id,
|
||||
normalizedCredentialId,
|
||||
);
|
||||
void recompute({ userIds: [userId] }).catch(() => {});
|
||||
}
|
||||
|
||||
if (!created) {
|
||||
databaseLogger.info("Updating SSH folder", {
|
||||
@@ -401,17 +417,7 @@ export function registerHostFolderRoutes(
|
||||
const hostIds = hostsToDelete.map((host) => host.id);
|
||||
|
||||
if (hostIds.length > 0) {
|
||||
await createCurrentFileManagerBookmarkRepository().deleteByHostIds(
|
||||
hostIds,
|
||||
);
|
||||
|
||||
await createCurrentTransferRecentRepository().deleteByHostIds(
|
||||
hostIds,
|
||||
);
|
||||
|
||||
await createCurrentCommandHistoryRepository().deleteByHostIds(
|
||||
hostIds,
|
||||
);
|
||||
// Plugin rows tied to a host cascade on their refHost() foreign keys.
|
||||
|
||||
await createCurrentSshCredentialUsageRepository().deleteByHostIds(
|
||||
hostIds,
|
||||
@@ -424,50 +430,19 @@ export function registerHostFolderRoutes(
|
||||
await createCurrentRbacAccessRepository().deleteHostAccessForHosts(
|
||||
hostIds,
|
||||
);
|
||||
|
||||
await createCurrentSessionRecordingRepository().deleteByHostIds(
|
||||
hostIds,
|
||||
);
|
||||
}
|
||||
|
||||
const { hostSyncIds, folderSyncIds } =
|
||||
await hostFolderRepository.deleteHostsAndFolderRecords(
|
||||
userId,
|
||||
folderName,
|
||||
);
|
||||
const tombstoneRepository = createCurrentSyncTombstoneRepository();
|
||||
await tombstoneRepository.recordMany(userId, "hosts", hostSyncIds);
|
||||
await tombstoneRepository.recordMany(
|
||||
await hostFolderRepository.deleteHostsAndFolderRecords(
|
||||
userId,
|
||||
"sshFolders",
|
||||
folderSyncIds,
|
||||
folderName,
|
||||
);
|
||||
|
||||
try {
|
||||
const axios = (await import("axios")).default;
|
||||
for (const host of hostsToDelete) {
|
||||
try {
|
||||
await axios.post(
|
||||
`${statsServerUrl}/host-deleted`,
|
||||
{ hostId: host.id },
|
||||
{
|
||||
headers: {
|
||||
Authorization: req.headers.authorization || "",
|
||||
Cookie: req.headers.cookie || "",
|
||||
},
|
||||
timeout: 5000,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
sshLogger.warn("Failed to notify stats server of host deletion", {
|
||||
operation: "folder_hosts_delete",
|
||||
hostId: host.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
pluginEvents.emit(TOPICS.hostDeleted, { hostId: host.id, userId });
|
||||
}
|
||||
} catch (err) {
|
||||
sshLogger.warn("Failed to notify stats server of folder deletion", {
|
||||
sshLogger.warn("Failed to publish host deletion events", {
|
||||
operation: "folder_hosts_delete",
|
||||
folderName,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { normalizeImportedHost } from "./host-normalizers.js";
|
||||
|
||||
function jumpHostId(value: unknown): unknown {
|
||||
return typeof value === "string" && /^\d+$/.test(value)
|
||||
? Number(value)
|
||||
: value;
|
||||
}
|
||||
|
||||
export function prepareHostImports(input: Record<string, unknown>[]) {
|
||||
const hosts = input.map((value, index) => ({
|
||||
host: normalizeImportedHost(value),
|
||||
@@ -27,7 +33,7 @@ export function prepareHostImports(input: Record<string, unknown>[]) {
|
||||
if (!Array.isArray(jumps))
|
||||
throw new Error(`Host ${entry.index + 1}: jumpHosts must be an array`);
|
||||
for (const jump of jumps) {
|
||||
const dependency = byId.get(jump?.hostId);
|
||||
const dependency = byId.get(jumpHostId(jump?.hostId));
|
||||
if (!dependency)
|
||||
throw new Error(
|
||||
`Host ${entry.index + 1}: jump host ${jump?.hostId} is missing from the export. Re-export and include all jump hosts.`,
|
||||
@@ -48,7 +54,7 @@ export function remapImportedJumpHosts(
|
||||
) {
|
||||
if (!Array.isArray(jumps)) return null;
|
||||
return jumps.map((jump) => {
|
||||
const hostId = importedIds.get(jump.hostId);
|
||||
const hostId = importedIds.get(jumpHostId(jump.hostId));
|
||||
if (hostId === undefined)
|
||||
throw new Error(`Jump host ${jump.hostId} failed to import`);
|
||||
return { hostId };
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
import type { Request, Response, Router } from "express";
|
||||
import { SystemCrypto } from "../../utils/system-crypto.js";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
import { createCurrentHostResolutionRepository } from "../repositories/factory.js";
|
||||
|
||||
export function registerHostInternalRoutes(router: Router): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host/db/host/internal:
|
||||
* get:
|
||||
* summary: Get internal SSH host data
|
||||
* description: Returns internal SSH host data for autostart tunnels. Requires internal auth token.
|
||||
* tags:
|
||||
* - SSH
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of autostart hosts.
|
||||
* 403:
|
||||
* description: Forbidden.
|
||||
* 500:
|
||||
* description: Failed to fetch autostart SSH data.
|
||||
*/
|
||||
router.get("/db/host/internal", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const internalToken = req.headers["x-internal-auth-token"];
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
const expectedToken = await systemCrypto.getInternalAuthToken();
|
||||
|
||||
if (internalToken !== expectedToken) {
|
||||
sshLogger.warn(
|
||||
"Unauthorized attempt to access internal SSH host endpoint",
|
||||
{
|
||||
source: req.ip,
|
||||
userAgent: req.headers["user-agent"],
|
||||
providedToken: internalToken ? "present" : "missing",
|
||||
},
|
||||
);
|
||||
return res.status(403).json({ error: "Forbidden" });
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to validate internal auth token", error);
|
||||
return res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
|
||||
try {
|
||||
const autostartHosts =
|
||||
await createCurrentHostResolutionRepository().listHostsWithTunnelConnections();
|
||||
|
||||
const result = autostartHosts
|
||||
.map((host) => {
|
||||
const tunnelConnections = host.tunnelConnections
|
||||
? JSON.parse(host.tunnelConnections)
|
||||
: [];
|
||||
|
||||
const hasAutoStartTunnels = tunnelConnections.some(
|
||||
(tunnel: Record<string, unknown>) => tunnel.autoStart,
|
||||
);
|
||||
|
||||
if (!hasAutoStartTunnels) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: host.id,
|
||||
userId: host.userId,
|
||||
name: host.name || `autostart-${host.id}`,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
authType: host.authType,
|
||||
keyType: host.keyType,
|
||||
credentialId: host.credentialId,
|
||||
enableTunnel: true,
|
||||
tunnelConnections: tunnelConnections.filter(
|
||||
(tunnel: Record<string, unknown>) => tunnel.autoStart,
|
||||
),
|
||||
pin: !!host.pin,
|
||||
enableTerminal: !!host.enableTerminal,
|
||||
enableFileManager: host.enableFileManager !== false,
|
||||
showTerminalInSidebar: !!host.showTerminalInSidebar,
|
||||
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
|
||||
showTunnelInSidebar: !!host.showTunnelInSidebar,
|
||||
showDockerInSidebar: !!host.showDockerInSidebar,
|
||||
showServerStatsInSidebar: !!host.showServerStatsInSidebar,
|
||||
tags: ["autostart"],
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch autostart SSH data", err);
|
||||
res.status(500).json({ error: "Failed to fetch autostart SSH data" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/db/host/internal/all:
|
||||
* get:
|
||||
* summary: Get all internal SSH host data
|
||||
* description: Returns all internal SSH host data. Requires internal auth token.
|
||||
* tags:
|
||||
* - SSH
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of all hosts.
|
||||
* 401:
|
||||
* description: Invalid or missing internal authentication token.
|
||||
* 500:
|
||||
* description: Failed to fetch all hosts.
|
||||
*/
|
||||
router.get("/db/host/internal/all", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const internalToken = req.headers["x-internal-auth-token"];
|
||||
if (!internalToken) {
|
||||
return res
|
||||
.status(401)
|
||||
.json({ error: "Internal authentication token required" });
|
||||
}
|
||||
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
const expectedToken = await systemCrypto.getInternalAuthToken();
|
||||
|
||||
if (internalToken !== expectedToken) {
|
||||
return res
|
||||
.status(401)
|
||||
.json({ error: "Invalid internal authentication token" });
|
||||
}
|
||||
|
||||
const allHosts =
|
||||
await createCurrentHostResolutionRepository().listAllHosts();
|
||||
|
||||
const result = allHosts.map((host) => {
|
||||
const tunnelConnections = host.tunnelConnections
|
||||
? JSON.parse(host.tunnelConnections)
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: host.id,
|
||||
userId: host.userId,
|
||||
name: host.name || `${host.username}@${host.ip}`,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
authType: host.authType,
|
||||
keyType: host.keyType,
|
||||
credentialId: host.credentialId,
|
||||
enableTunnel: !!host.enableTunnel,
|
||||
tunnelConnections: tunnelConnections,
|
||||
pin: !!host.pin,
|
||||
enableTerminal: !!host.enableTerminal,
|
||||
enableFileManager: host.enableFileManager !== false,
|
||||
showTerminalInSidebar: !!host.showTerminalInSidebar,
|
||||
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
|
||||
showTunnelInSidebar: !!host.showTunnelInSidebar,
|
||||
showDockerInSidebar: !!host.showDockerInSidebar,
|
||||
showServerStatsInSidebar: !!host.showServerStatsInSidebar,
|
||||
defaultPath: host.defaultPath,
|
||||
createdAt: host.createdAt,
|
||||
updatedAt: host.updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch all hosts for internal use", err);
|
||||
res.status(500).json({ error: "Failed to fetch all hosts" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import { sendWakeOnLan, isValidMac } from "../../utils/wake-on-lan.js";
|
||||
import type { RequestHandler, Response, Router } from "express";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
import { createCurrentHostResolutionRepository } from "../repositories/factory.js";
|
||||
|
||||
interface HostNetworkRoutesDeps {
|
||||
authenticateJWT: RequestHandler;
|
||||
@@ -95,54 +93,4 @@ export function registerHostNetworkRoutes(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/db/host/:id/wake",
|
||||
authenticateJWT,
|
||||
requireViewPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const hostId = Number.parseInt(String(req.params.id), 10);
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
try {
|
||||
const host = await createCurrentHostResolutionRepository().findHostById(
|
||||
hostId,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (!host || host.userId !== userId) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
if (!host.macAddress || !isValidMac(host.macAddress)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "No valid MAC address configured" });
|
||||
}
|
||||
|
||||
await sendWakeOnLan(
|
||||
host.macAddress,
|
||||
host.wolBroadcastAddress ?? undefined,
|
||||
);
|
||||
|
||||
sshLogger.info("Wake-on-LAN packet sent", {
|
||||
operation: "wake_on_lan",
|
||||
userId,
|
||||
hostId,
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
sshLogger.error("Wake-on-LAN failed", error, {
|
||||
operation: "wake_on_lan",
|
||||
userId,
|
||||
hostId,
|
||||
});
|
||||
res.status(500).json({
|
||||
error: getErrorMessage(error, "Failed to send WoL packet"),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js";
|
||||
import { parseWebUiConfig } from "./host-web-endpoints.js";
|
||||
import { parseDefaultOverrides } from "../../../types/host-defaults.js";
|
||||
import {
|
||||
parseSshOptions,
|
||||
type HostSshOptions,
|
||||
} from "../../hosts/ssh-options.js";
|
||||
import { listHostProtocols } from "../../hosts/protocol-auth/registry.js";
|
||||
import { sanitizeProtocolAuthForRecipient } from "../../hosts/protocol-auth/summary.js";
|
||||
|
||||
export function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
@@ -24,12 +29,7 @@ export function applyHostKeyTypeUpdate(
|
||||
}
|
||||
}
|
||||
|
||||
const PROTOCOL_ENABLE_FIELDS = [
|
||||
"enableSsh",
|
||||
"enableRdp",
|
||||
"enableVnc",
|
||||
"enableTelnet",
|
||||
] as const;
|
||||
const PROTOCOL_ENABLE_FIELDS = ["enableSsh"] as const;
|
||||
|
||||
export function normalizeProtocolEnableFields(
|
||||
values: Record<string, unknown>,
|
||||
@@ -43,12 +43,15 @@ export function normalizeProtocolEnableFields(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The owner's SSH authentication, which a shared editor may not change. A
|
||||
* plugin protocol's login is guarded in writeProtocolAuth instead.
|
||||
*/
|
||||
export const OWNER_PRIVATE_AUTH_FIELDS = {
|
||||
ssh: [
|
||||
"authType",
|
||||
"authMethod",
|
||||
"credentialId",
|
||||
"vaultProfileId",
|
||||
"overrideCredentialUsername",
|
||||
"shareSshAuth",
|
||||
"password",
|
||||
@@ -57,37 +60,26 @@ export const OWNER_PRIVATE_AUTH_FIELDS = {
|
||||
"keyType",
|
||||
"sudoPassword",
|
||||
],
|
||||
rdp: [
|
||||
"rdpAuthType",
|
||||
"rdpCredentialId",
|
||||
"rdpUser",
|
||||
"rdpPassword",
|
||||
"rdpDomain",
|
||||
],
|
||||
vnc: ["vncAuthType", "vncCredentialId", "vncUser", "vncPassword"],
|
||||
telnet: [
|
||||
"telnetAuthType",
|
||||
"telnetCredentialId",
|
||||
"telnetUser",
|
||||
"telnetPassword",
|
||||
],
|
||||
} as const satisfies Record<AuthOverrideProtocol, readonly string[]>;
|
||||
} as const;
|
||||
|
||||
export const OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS = [
|
||||
"sudoPassword",
|
||||
"agentSocketPath",
|
||||
] as const;
|
||||
|
||||
/** SSH options only the host's owner may change. */
|
||||
export const OWNER_PRIVATE_SSH_OPTION_FIELDS = ["agentSocketPath"] as const;
|
||||
|
||||
export function containsOwnerPrivateAuthUpdate(
|
||||
hostData: Record<string, unknown>,
|
||||
protocol: AuthOverrideProtocol,
|
||||
protocol: keyof typeof OWNER_PRIVATE_AUTH_FIELDS,
|
||||
): boolean {
|
||||
return OWNER_PRIVATE_AUTH_FIELDS[protocol].some((field) =>
|
||||
Object.prototype.hasOwnProperty.call(hostData, field),
|
||||
);
|
||||
}
|
||||
|
||||
export const FOLDER_PATH_SEPARATOR = " / ";
|
||||
const FOLDER_PATH_SEPARATOR = " / ";
|
||||
|
||||
/**
|
||||
* Re-paths a folder string when its ancestor folder is renamed. Returns the new
|
||||
@@ -177,33 +169,12 @@ export type NormalizedImportedHost = Record<string, unknown> & {
|
||||
credentialId?: number;
|
||||
credentialAlias?: string;
|
||||
pin?: unknown;
|
||||
enableTerminal?: unknown;
|
||||
enableTunnel?: unknown;
|
||||
enableFileManager?: unknown;
|
||||
enableDocker?: unknown;
|
||||
enableWebUi?: unknown;
|
||||
enableProxmox?: unknown;
|
||||
enableTmuxMonitor?: unknown;
|
||||
enableTerminalToolbar?: unknown;
|
||||
enableAiAssistant?: unknown;
|
||||
enableCommandHistory?: unknown;
|
||||
showTerminalInSidebar?: unknown;
|
||||
showFileManagerInSidebar?: unknown;
|
||||
showTunnelInSidebar?: unknown;
|
||||
showDockerInSidebar?: unknown;
|
||||
showServerStatsInSidebar?: unknown;
|
||||
defaultPath?: unknown;
|
||||
sudoPassword?: unknown;
|
||||
tunnelConnections?: unknown;
|
||||
jumpHosts?: unknown;
|
||||
quickActions?: unknown;
|
||||
statsConfig?: unknown;
|
||||
dockerConfig?: unknown;
|
||||
webUiConfig?: unknown;
|
||||
proxmoxConfig?: unknown;
|
||||
enableProxmoxStats?: unknown;
|
||||
proxmoxStatsConfig?: unknown;
|
||||
statusCheckEnabled?: unknown;
|
||||
statusCheckInterval?: unknown;
|
||||
terminalConfig?: unknown;
|
||||
sshOptions?: unknown;
|
||||
forceKeyboardInteractive?: unknown;
|
||||
notes?: unknown;
|
||||
useSocks5?: unknown;
|
||||
@@ -214,40 +185,41 @@ export type NormalizedImportedHost = Record<string, unknown> & {
|
||||
socks5ProxyChain?: unknown;
|
||||
portKnockSequence?: unknown;
|
||||
overrideCredentialUsername?: unknown;
|
||||
domain?: unknown;
|
||||
security?: unknown;
|
||||
ignoreCert?: unknown;
|
||||
guacamoleConfig?: unknown;
|
||||
enableSsh: boolean;
|
||||
enableRdp: boolean;
|
||||
enableVnc: boolean;
|
||||
enableTelnet: boolean;
|
||||
};
|
||||
|
||||
function legacyPrefix(protocolId: string): string {
|
||||
return protocolId.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function legacyKey(protocolId: string): string {
|
||||
const prefix = legacyPrefix(protocolId);
|
||||
return prefix.charAt(0).toUpperCase() + prefix.slice(1);
|
||||
}
|
||||
|
||||
export function normalizeImportedHost(
|
||||
hostData: Record<string, unknown>,
|
||||
): NormalizedImportedHost {
|
||||
const credentialAlias =
|
||||
asString(hostData.credentialAlias) || asString(hostData.credentialName);
|
||||
// A row with no connectionType names its protocol the way a 2.8 export
|
||||
// did: an "enable<Protocol>" flag and a "<protocol>Port".
|
||||
const flagged = listHostProtocols().find((protocol) =>
|
||||
asBoolean(hostData[`enable${legacyKey(protocol.id)}`]),
|
||||
);
|
||||
const connectionType =
|
||||
asString(hostData.connectionType) ||
|
||||
(asBoolean(hostData.enableRdp)
|
||||
? "rdp"
|
||||
: asBoolean(hostData.enableVnc)
|
||||
? "vnc"
|
||||
: asBoolean(hostData.enableTelnet)
|
||||
? "telnet"
|
||||
: "ssh");
|
||||
asString(hostData.connectionType) || flagged?.id || "ssh";
|
||||
const protocol = listHostProtocols().find(
|
||||
(entry) => entry.id === connectionType,
|
||||
);
|
||||
|
||||
const port =
|
||||
asPort(hostData.port) ||
|
||||
(connectionType === "rdp"
|
||||
? asPort(hostData.rdpPort) || 3389
|
||||
: connectionType === "vnc"
|
||||
? asPort(hostData.vncPort) || 5900
|
||||
: connectionType === "telnet"
|
||||
? asPort(hostData.telnetPort) || 23
|
||||
: asPort(hostData.sshPort) || 22);
|
||||
(protocol
|
||||
? asPort(hostData[`${legacyPrefix(protocol.id)}Port`]) ||
|
||||
protocol.defaultPort ||
|
||||
22
|
||||
: asPort(hostData.sshPort) || 22);
|
||||
|
||||
return {
|
||||
...hostData,
|
||||
@@ -276,33 +248,15 @@ export function normalizeImportedHost(
|
||||
hostData.enableSsh === undefined
|
||||
? connectionType === "ssh"
|
||||
: asBoolean(hostData.enableSsh),
|
||||
enableRdp:
|
||||
hostData.enableRdp === undefined
|
||||
? connectionType === "rdp"
|
||||
: asBoolean(hostData.enableRdp),
|
||||
enableVnc:
|
||||
hostData.enableVnc === undefined
|
||||
? connectionType === "vnc"
|
||||
: asBoolean(hostData.enableVnc),
|
||||
enableTelnet:
|
||||
hostData.enableTelnet === undefined
|
||||
? connectionType === "telnet"
|
||||
: asBoolean(hostData.enableTelnet),
|
||||
};
|
||||
}
|
||||
|
||||
const SENSITIVE_FIELDS = [
|
||||
"key",
|
||||
"keyPassword",
|
||||
"autostartKey",
|
||||
"autostartKeyPassword",
|
||||
"password",
|
||||
"sudoPassword",
|
||||
"socks5Password",
|
||||
"rdpPassword",
|
||||
"vncPassword",
|
||||
"telnetPassword",
|
||||
"autostartPassword",
|
||||
];
|
||||
|
||||
export function stripSensitiveFields(
|
||||
@@ -318,11 +272,9 @@ export function stripSensitiveFields(
|
||||
result.hasKey = !!host.key;
|
||||
result.hasKeyPassword = !!host.keyPassword;
|
||||
result.hasPassword = !!host.password;
|
||||
// 2.8 editors kept the sudo password inside terminal_config.
|
||||
result.hasSudoPassword =
|
||||
!!host.sudoPassword || !!terminalConfigForSudo?.sudoPassword;
|
||||
result.hasRdpPassword = !!host.rdpPassword;
|
||||
result.hasVncPassword = !!host.vncPassword;
|
||||
result.hasTelnetPassword = !!host.telnetPassword;
|
||||
for (const field of SENSITIVE_FIELDS) {
|
||||
delete result[field];
|
||||
}
|
||||
@@ -360,34 +312,11 @@ const CONNECT_LEVEL_FIELDS = new Set([
|
||||
"shareSshAuth",
|
||||
"authOverrides",
|
||||
"connectionType",
|
||||
"enableTerminal",
|
||||
"enableTunnel",
|
||||
"enableFileManager",
|
||||
"enableDocker",
|
||||
"enableWebUi",
|
||||
"webUiConfig",
|
||||
"enableProxmox",
|
||||
"enableProxmoxStats",
|
||||
"enableTmuxMonitor",
|
||||
"enableTerminalToolbar",
|
||||
"enableAiAssistant",
|
||||
"showTerminalInSidebar",
|
||||
"showFileManagerInSidebar",
|
||||
"showTunnelInSidebar",
|
||||
"showDockerInSidebar",
|
||||
"showServerStatsInSidebar",
|
||||
"statusCheckEnabled",
|
||||
"statusCheckInterval",
|
||||
"enableSsh",
|
||||
"enableRdp",
|
||||
"enableVnc",
|
||||
"enableTelnet",
|
||||
"sshPort",
|
||||
"rdpPort",
|
||||
"rdpAuthType",
|
||||
"vncPort",
|
||||
"telnetPort",
|
||||
"defaultPath",
|
||||
"scpLegacy",
|
||||
"tunnelConnections",
|
||||
"protocolAuth",
|
||||
"jumpHosts",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
@@ -409,16 +338,13 @@ export function sanitizeHostForRecipient(
|
||||
// can't see (or share permission on) the parent host row, so a shared
|
||||
// host always renders at root rather than leaking another host's id.
|
||||
delete stripped.parentHostId;
|
||||
if (
|
||||
stripped.terminalConfig &&
|
||||
typeof stripped.terminalConfig === "object" &&
|
||||
!Array.isArray(stripped.terminalConfig)
|
||||
) {
|
||||
const terminalConfig = {
|
||||
...(stripped.terminalConfig as Record<string, unknown>),
|
||||
};
|
||||
delete terminalConfig.agentSocketPath;
|
||||
stripped.terminalConfig = terminalConfig;
|
||||
for (const field of ["terminalConfig", "sshOptions"] as const) {
|
||||
const value = stripped[field];
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
const copy = { ...(value as Record<string, unknown>) };
|
||||
for (const key of OWNER_PRIVATE_SSH_OPTION_FIELDS) delete copy[key];
|
||||
stripped[field] = copy;
|
||||
}
|
||||
}
|
||||
const authOverrides =
|
||||
stripped.authOverrides &&
|
||||
@@ -432,6 +358,12 @@ export function sanitizeHostForRecipient(
|
||||
!Array.isArray(authOverrides.ssh)
|
||||
? (authOverrides.ssh as Record<string, unknown>)
|
||||
: undefined;
|
||||
if (stripped.protocolAuth !== undefined) {
|
||||
stripped.protocolAuth = sanitizeProtocolAuthForRecipient(
|
||||
stripped.protocolAuth,
|
||||
permissionLevel,
|
||||
);
|
||||
}
|
||||
if (!sshOverride?.credentialId) {
|
||||
stripped.hasPassword = false;
|
||||
stripped.hasKey = false;
|
||||
@@ -452,11 +384,76 @@ export function sanitizeHostForRecipient(
|
||||
return reduced;
|
||||
}
|
||||
|
||||
/** Who shared a host that a linked desktop holds a read-only copy of. */
|
||||
export function parseSharedSource(
|
||||
value: unknown,
|
||||
): { owner?: string; permissionLevel?: string } | null {
|
||||
if (typeof value !== "string" || !value) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonObject(value: unknown): Record<string, unknown> | null {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
if (typeof value !== "string" || !value) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? parsed
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* sshOptions, and terminalConfig in the shape 2.8 clients and exports read:
|
||||
* core's own keys only, the SSH options. Everything else 2.8 kept there now
|
||||
* belongs to plugins' host settings, which they put back through their
|
||||
* hostPayloadLegacy. A sudo password 2.8 kept in terminal_config comes out as
|
||||
* sudoPassword, for the sanitizers to handle.
|
||||
*/
|
||||
export function hostTerminalExport(host: Record<string, unknown>): {
|
||||
sshOptions: HostSshOptions;
|
||||
terminalConfig?: Record<string, unknown>;
|
||||
sudoPassword?: unknown;
|
||||
} {
|
||||
const raw = parseJsonObject(host.terminalConfig);
|
||||
const sshOptions = parseSshOptions(
|
||||
host.sshOptions != null ? host.sshOptions : raw,
|
||||
);
|
||||
const terminalConfig: Record<string, unknown> = { ...sshOptions };
|
||||
const legacySudo = !host.sudoPassword ? raw?.sudoPassword : undefined;
|
||||
return {
|
||||
sshOptions,
|
||||
terminalConfig:
|
||||
Object.keys(terminalConfig).length > 0 ? terminalConfig : undefined,
|
||||
...(legacySudo ? { sudoPassword: legacySudo } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function transformHostResponse(
|
||||
host: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const shared = parseSharedSource(host.sharedSource);
|
||||
return {
|
||||
...host,
|
||||
sharedSource: undefined,
|
||||
localOnly: !!host.localOnly,
|
||||
sharedCopy: !!shared,
|
||||
...(shared
|
||||
? {
|
||||
isShared: true,
|
||||
ownerUsername: shared.owner || undefined,
|
||||
permissionLevel: shared.permissionLevel || "connect",
|
||||
}
|
||||
: {}),
|
||||
tags:
|
||||
typeof host.tags === "string"
|
||||
? host.tags
|
||||
@@ -465,86 +462,26 @@ export function transformHostResponse(
|
||||
: [],
|
||||
pin: !!host.pin,
|
||||
shareSshAuth: !!host.shareSshAuth,
|
||||
enableTerminal: !!host.enableTerminal,
|
||||
enableTunnel: !!host.enableTunnel,
|
||||
enableFileManager: host.enableFileManager !== false,
|
||||
enableDocker: !!host.enableDocker,
|
||||
enableWebUi: !!host.enableWebUi,
|
||||
enableProxmox: !!host.enableProxmox,
|
||||
enableProxmoxStats: !!host.enableProxmoxStats,
|
||||
enableTmuxMonitor: !!host.enableTmuxMonitor,
|
||||
enableTerminalToolbar: host.enableTerminalToolbar !== false,
|
||||
enableAiAssistant: !!host.enableAiAssistant,
|
||||
showTerminalInSidebar: !!host.showTerminalInSidebar,
|
||||
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
|
||||
showTunnelInSidebar: !!host.showTunnelInSidebar,
|
||||
showDockerInSidebar: !!host.showDockerInSidebar,
|
||||
showServerStatsInSidebar: !!host.showServerStatsInSidebar,
|
||||
// Old hosts only had connection_type set; the per-protocol enable flags didn't exist yet.
|
||||
// The schema defaults (enableSsh=true, others=false) wrongly mark every old host as SSH.
|
||||
// Detect this migration case: if no non-SSH protocol is explicitly enabled AND
|
||||
// connectionType is set to a non-SSH value, fall back to inferring from connectionType.
|
||||
...(() => {
|
||||
const ct = host.connectionType;
|
||||
const rdp = !!host.enableRdp;
|
||||
const vnc = !!host.enableVnc;
|
||||
const tel = !!host.enableTelnet;
|
||||
const isMigratedNonSsh = !rdp && !vnc && !tel && ct && ct !== "ssh";
|
||||
return {
|
||||
enableSsh: isMigratedNonSsh ? false : !!host.enableSsh,
|
||||
enableRdp: isMigratedNonSsh ? ct === "rdp" : rdp,
|
||||
enableVnc: isMigratedNonSsh ? ct === "vnc" : vnc,
|
||||
enableTelnet: isMigratedNonSsh ? ct === "telnet" : tel,
|
||||
};
|
||||
})(),
|
||||
enableSsh: !!host.enableSsh,
|
||||
sshPort: host.sshPort ?? host.port ?? 22,
|
||||
rdpPort: host.rdpPort ?? 3389,
|
||||
vncPort: host.vncPort ?? 5900,
|
||||
telnetPort: host.telnetPort ?? 23,
|
||||
rdpUser: host.rdpUser || undefined,
|
||||
rdpDomain: host.rdpDomain || undefined,
|
||||
rdpSecurity: host.rdpSecurity || undefined,
|
||||
rdpIgnoreCert: !!host.rdpIgnoreCert,
|
||||
vncUser: host.vncUser || undefined,
|
||||
telnetUser: host.telnetUser || undefined,
|
||||
tunnelConnections: host.tunnelConnections
|
||||
? JSON.parse(host.tunnelConnections as string)
|
||||
: [],
|
||||
jumpHosts: host.jumpHosts ? JSON.parse(host.jumpHosts as string) : [],
|
||||
quickActions: host.quickActions
|
||||
? JSON.parse(host.quickActions as string)
|
||||
: [],
|
||||
statsConfig: host.statsConfig
|
||||
? JSON.parse(host.statsConfig as string)
|
||||
: undefined,
|
||||
terminalConfig: host.terminalConfig
|
||||
? JSON.parse(host.terminalConfig as string)
|
||||
: undefined,
|
||||
dockerConfig: host.dockerConfig
|
||||
? JSON.parse(host.dockerConfig as string)
|
||||
: undefined,
|
||||
// Guarded, unlike dockerConfig directly above: parseWebUiConfig never
|
||||
// throws, so a half-written config cannot take out the whole host listing.
|
||||
webUiConfig: parseWebUiConfig(host.webUiConfig),
|
||||
proxmoxConfig: host.proxmoxConfig
|
||||
? JSON.parse(host.proxmoxConfig as string)
|
||||
: undefined,
|
||||
proxmoxStatsConfig: host.proxmoxStatsConfig
|
||||
? JSON.parse(host.proxmoxStatsConfig as string)
|
||||
: undefined,
|
||||
// Moved into a plugin's host settings; its hostPayloadLegacy puts it
|
||||
// back for 2.8 clients.
|
||||
quickActions: undefined,
|
||||
statusCheckEnabled:
|
||||
host.statusCheckEnabled !== false && host.statusCheckEnabled !== 0,
|
||||
statusCheckInterval:
|
||||
typeof host.statusCheckInterval === "number"
|
||||
? host.statusCheckInterval
|
||||
: null,
|
||||
...hostTerminalExport(host),
|
||||
forceKeyboardInteractive: host.forceKeyboardInteractive === "true",
|
||||
useWarpgate: !!host.useWarpgate,
|
||||
socks5ProxyChain: host.socks5ProxyChain
|
||||
? JSON.parse(host.socks5ProxyChain as string)
|
||||
: [],
|
||||
portKnockSequence: host.portKnockSequence
|
||||
? JSON.parse(host.portKnockSequence as string)
|
||||
: [],
|
||||
domain: host.domain || undefined,
|
||||
security: host.security || undefined,
|
||||
ignoreCert: !!host.ignoreCert,
|
||||
guacamoleConfig: host.guacamoleConfig
|
||||
? JSON.parse(host.guacamoleConfig as string)
|
||||
: undefined,
|
||||
defaultOverrides: parseDefaultOverrides(host.defaultOverrides),
|
||||
};
|
||||
}
|
||||
@@ -1,841 +0,0 @@
|
||||
import type { Router, Request, Response } from "express";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
normalizeSelectOpParam,
|
||||
renderOpksshErrorPage,
|
||||
rewriteOPKSSHHtml,
|
||||
} from "./opkssh-html.js";
|
||||
|
||||
export function registerHostOpksshRoutes(router: Router): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host/opkssh-chooser/{requestId}:
|
||||
* get:
|
||||
* summary: Proxy OPKSSH provider chooser page and all related resources
|
||||
* tags: [SSH]
|
||||
* parameters:
|
||||
* - name: requestId
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Authentication request ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Chooser page content
|
||||
* 404:
|
||||
* description: Session not found
|
||||
* 500:
|
||||
* description: Proxy error
|
||||
*/
|
||||
|
||||
router.use(
|
||||
"/opkssh-chooser/:requestId",
|
||||
async (req: Request, res: Response) => {
|
||||
const requestId = Array.isArray(req.params.requestId)
|
||||
? req.params.requestId[0]
|
||||
: req.params.requestId;
|
||||
|
||||
const fullPath = req.originalUrl || req.url;
|
||||
const pathAfterRequestIdTemp =
|
||||
fullPath.split(`/host/opkssh-chooser/${requestId}`)[1] || "";
|
||||
|
||||
sshLogger.info("OPKSSH chooser proxy request", {
|
||||
operation: "opkssh_chooser_proxy_request",
|
||||
requestId,
|
||||
url: req.url,
|
||||
originalUrl: req.originalUrl,
|
||||
fullPath,
|
||||
pathAfterRequestId: pathAfterRequestIdTemp,
|
||||
method: req.method,
|
||||
});
|
||||
|
||||
try {
|
||||
const { getActiveAuthSession, registerOAuthState } =
|
||||
await import("../../hosts/opkssh-auth.js");
|
||||
const session = getActiveAuthSession(requestId);
|
||||
|
||||
if (!session) {
|
||||
sshLogger.error("Session not found for chooser request", {
|
||||
operation: "opkssh_chooser_session_not_found",
|
||||
requestId,
|
||||
});
|
||||
res.status(404).send(
|
||||
renderOpksshErrorPage({
|
||||
title: "Session Not Found",
|
||||
heading: "Session Not Found",
|
||||
message: "This authentication session has expired or is invalid.",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const axios = (await import("axios")).default;
|
||||
|
||||
const fullPath = req.originalUrl || req.url;
|
||||
const pathAfterRequestId =
|
||||
fullPath.split(`/host/opkssh-chooser/${requestId}`)[1] || "";
|
||||
const targetPath = pathAfterRequestId || "/chooser";
|
||||
|
||||
if (!session.localPort || session.localPort === 0) {
|
||||
sshLogger.error("OPKSSH session has no local port", {
|
||||
operation: "opkssh_chooser_proxy",
|
||||
requestId,
|
||||
sessionStatus: session.status,
|
||||
});
|
||||
res.status(500).send(
|
||||
renderOpksshErrorPage({
|
||||
title: "Error",
|
||||
heading: "Authentication Error",
|
||||
message:
|
||||
"Failed to load authentication page. OPKSSH process may not be ready yet. Please try again.",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// /select on OPKSSH's chooser redirects (possibly via multiple local hops) to the
|
||||
// external OAuth provider URL. The hops we may see:
|
||||
// 1. /select -> /select/ (Go ServeMux canonicalization, same chooser port)
|
||||
// 2. /select/?op=ALIAS -> http://localhost:CALLBACK_PORT/login (OPKSSH's separate callback listener)
|
||||
// 3. /login on the callback listener -> https://<provider>/authorize?... (external OAuth URL)
|
||||
if (targetPath.startsWith("/select")) {
|
||||
const selectaxios = (await import("axios")).default;
|
||||
const rawQs = targetPath.includes("?")
|
||||
? targetPath.slice(targetPath.indexOf("?"))
|
||||
: "";
|
||||
|
||||
let qs = rawQs;
|
||||
let opMappedFrom: string | undefined;
|
||||
if (rawQs) {
|
||||
try {
|
||||
const params = new URLSearchParams(rawQs.replace(/^\?/, ""));
|
||||
const rawOp = params.get("op");
|
||||
if (rawOp) {
|
||||
const mappedOp = normalizeSelectOpParam(
|
||||
rawOp,
|
||||
session.providers || [],
|
||||
);
|
||||
if (mappedOp !== rawOp) {
|
||||
params.set("op", mappedOp);
|
||||
qs = `?${params.toString()}`;
|
||||
opMappedFrom = rawOp;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* keep rawQs if parsing fails */
|
||||
}
|
||||
}
|
||||
|
||||
const chooserHost = `127.0.0.1:${session.localPort}`;
|
||||
const startUrl = `http://${chooserHost}/select/${qs}`;
|
||||
|
||||
sshLogger.info("Proxying OPKSSH /select", {
|
||||
operation: "opkssh_select_proxy",
|
||||
requestId,
|
||||
targetUrl: startUrl,
|
||||
opMappedFrom,
|
||||
});
|
||||
|
||||
const isLocalHostname = (host: string): boolean => {
|
||||
const bare = host.split(":")[0];
|
||||
return (
|
||||
bare === "127.0.0.1" || bare === "localhost" || bare === "[::1]"
|
||||
);
|
||||
};
|
||||
|
||||
interface UpstreamResponse {
|
||||
status: number;
|
||||
location?: string;
|
||||
contentType: string;
|
||||
body: string;
|
||||
targetUrl: string;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
const fetchUpstream = async (
|
||||
url: string,
|
||||
): Promise<UpstreamResponse> => {
|
||||
const started = Date.now();
|
||||
let hostHeader = chooserHost;
|
||||
try {
|
||||
hostHeader = new URL(url).host;
|
||||
} catch {
|
||||
/* fall back to chooser host */
|
||||
}
|
||||
const r = await selectaxios({
|
||||
method: "GET",
|
||||
url,
|
||||
maxRedirects: 0,
|
||||
validateStatus: () => true,
|
||||
timeout: 10000,
|
||||
responseType: "text",
|
||||
transformResponse: (v) => v,
|
||||
headers: { host: hostHeader },
|
||||
});
|
||||
const locHeader = r.headers["location"];
|
||||
const location = Array.isArray(locHeader)
|
||||
? locHeader[0]
|
||||
: locHeader;
|
||||
const ctHeader = r.headers["content-type"];
|
||||
const ctRaw = Array.isArray(ctHeader) ? ctHeader[0] : ctHeader;
|
||||
const contentType = typeof ctRaw === "string" ? ctRaw : "";
|
||||
const body =
|
||||
typeof r.data === "string" ? r.data : String(r.data ?? "");
|
||||
return {
|
||||
status: r.status,
|
||||
location: typeof location === "string" ? location : undefined,
|
||||
contentType,
|
||||
body,
|
||||
targetUrl: url,
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
};
|
||||
|
||||
const logResponse = (response: UpstreamResponse): void => {
|
||||
sshLogger.info("OPKSSH /select upstream response", {
|
||||
operation: "opkssh_select_upstream_response",
|
||||
requestId,
|
||||
targetUrl: response.targetUrl,
|
||||
status: response.status,
|
||||
location: response.location,
|
||||
contentType: response.contentType,
|
||||
elapsedMs: response.elapsedMs,
|
||||
bodyPreview: response.body.slice(0, 256),
|
||||
});
|
||||
};
|
||||
|
||||
const MAX_HOPS = 4;
|
||||
|
||||
try {
|
||||
let response = await fetchUpstream(startUrl);
|
||||
logResponse(response);
|
||||
|
||||
for (let hop = 0; hop < MAX_HOPS; hop++) {
|
||||
if (
|
||||
response.status < 300 ||
|
||||
response.status >= 400 ||
|
||||
!response.location
|
||||
) {
|
||||
break;
|
||||
}
|
||||
const loc = response.location;
|
||||
|
||||
// Relative path: resolve against the current upstream.
|
||||
if (loc.startsWith("/")) {
|
||||
let currentHost = chooserHost;
|
||||
try {
|
||||
currentHost = new URL(response.targetUrl).host;
|
||||
} catch {
|
||||
/* keep default */
|
||||
}
|
||||
response = await fetchUpstream(`http://${currentHost}${loc}`);
|
||||
logResponse(response);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Absolute URL: if it points to a localhost OPKSSH endpoint, capture
|
||||
// the port. Then redirect the BROWSER to the proxied path so that
|
||||
// Set-Cookie headers from OPKSSH's /login handler reach the browser
|
||||
// directly — following them server-side would swallow the cookie.
|
||||
if (/^https?:\/\//i.test(loc)) {
|
||||
try {
|
||||
const parsed = new URL(loc);
|
||||
if (isLocalHostname(parsed.host)) {
|
||||
// Capture callback listener port if not yet known.
|
||||
if (!session.callbackPort) {
|
||||
const port = parseInt(parsed.port, 10);
|
||||
if (!Number.isNaN(port)) {
|
||||
session.callbackPort = port;
|
||||
sshLogger.info(
|
||||
"Captured OPKSSH callback listener port from /select redirect",
|
||||
{
|
||||
operation: "opkssh_select_callback_port_detected",
|
||||
requestId,
|
||||
callbackPort: port,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
// Redirect browser through the chooser proxy so it can receive
|
||||
// the state cookie that OPKSSH sets on /login.
|
||||
const browserPath = `/host/opkssh-chooser/${requestId}${parsed.pathname}${parsed.search}`;
|
||||
sshLogger.info(
|
||||
"Redirecting browser to OPKSSH callback listener via proxy",
|
||||
{
|
||||
operation: "opkssh_select_browser_redirect_to_login",
|
||||
requestId,
|
||||
browserPath,
|
||||
callbackPort: session.callbackPort,
|
||||
},
|
||||
);
|
||||
res.redirect(302, browserPath);
|
||||
return;
|
||||
}
|
||||
// External OAuth provider URL — done, handled below.
|
||||
break;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
const isExternalRedirect =
|
||||
response.status >= 300 &&
|
||||
response.status < 400 &&
|
||||
!!response.location &&
|
||||
/^https?:\/\//i.test(response.location) &&
|
||||
(() => {
|
||||
try {
|
||||
return !isLocalHostname(
|
||||
new URL(response.location as string).host,
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
if (isExternalRedirect) {
|
||||
const oauthUrl = response.location as string;
|
||||
try {
|
||||
const parsed = new URL(oauthUrl);
|
||||
const oauthState = parsed.searchParams.get("state");
|
||||
if (oauthState) registerOAuthState(oauthState, requestId);
|
||||
} catch {
|
||||
/* already validated above */
|
||||
}
|
||||
sshLogger.info(
|
||||
"OPKSSH /select redirecting browser to OAuth provider",
|
||||
{
|
||||
operation: "opkssh_select_redirect",
|
||||
requestId,
|
||||
oauthUrl,
|
||||
},
|
||||
);
|
||||
res.redirect(302, oauthUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const bodyPreview = response.body.slice(0, 512);
|
||||
const detailLines = [
|
||||
`Upstream: ${response.targetUrl}`,
|
||||
`Status: ${response.status}`,
|
||||
response.location ? `Location: ${response.location}` : undefined,
|
||||
`Content-Type: ${response.contentType || "(none)"}`,
|
||||
`Elapsed: ${response.elapsedMs}ms`,
|
||||
"",
|
||||
bodyPreview
|
||||
? `Body (first 512 chars):\n${bodyPreview}`
|
||||
: "Body: (empty)",
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
sshLogger.error(
|
||||
"OPKSSH /select did not produce an OAuth redirect",
|
||||
{
|
||||
operation: "opkssh_select_no_oauth_redirect",
|
||||
requestId,
|
||||
status: response.status,
|
||||
location: response.location,
|
||||
contentType: response.contentType,
|
||||
bodyPreview,
|
||||
},
|
||||
);
|
||||
|
||||
res.status(502).send(
|
||||
renderOpksshErrorPage({
|
||||
title: "OPKSSH error",
|
||||
heading: "Failed to get OAuth redirect",
|
||||
message:
|
||||
"OPKSSH did not return an external OAuth provider URL. " +
|
||||
"This typically indicates a configuration mismatch between the provider's redirect_uris " +
|
||||
"and the Termix callback path. Check the server log for the OPKSSH response body.",
|
||||
details: detailLines.join("\n"),
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
sshLogger.error("Error proxying OPKSSH /select", err, {
|
||||
operation: "opkssh_select_proxy_error",
|
||||
requestId,
|
||||
targetUrl: startUrl,
|
||||
});
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
res.status(502).send(
|
||||
renderOpksshErrorPage({
|
||||
title: "OPKSSH error",
|
||||
heading: "Failed to reach OPKSSH service",
|
||||
message:
|
||||
"Termix could not connect to the local OPKSSH authentication service. " +
|
||||
"The OPKSSH process may have exited or is not listening yet.",
|
||||
details: `Upstream: ${startUrl}\nError: ${errMsg}`,
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Paths served by the callback listener, not the chooser.
|
||||
// The browser is redirected here so it receives Set-Cookie from OPKSSH.
|
||||
const isCallbackListenerPath =
|
||||
targetPath === "/login" ||
|
||||
targetPath.startsWith("/login?") ||
|
||||
targetPath === "/login-callback" ||
|
||||
targetPath.startsWith("/login-callback?");
|
||||
|
||||
const upstreamPort =
|
||||
isCallbackListenerPath && session.callbackPort
|
||||
? session.callbackPort
|
||||
: session.localPort;
|
||||
|
||||
const targetUrl = `http://127.0.0.1:${upstreamPort}${targetPath}`;
|
||||
|
||||
sshLogger.info("Proxying to OPKSSH chooser", {
|
||||
operation: "opkssh_chooser_proxy_request_to_opkssh",
|
||||
requestId,
|
||||
targetUrl,
|
||||
upstreamPort,
|
||||
targetPath,
|
||||
});
|
||||
|
||||
const response = await axios({
|
||||
method: req.method,
|
||||
url: targetUrl,
|
||||
headers: {
|
||||
...req.headers,
|
||||
host: `127.0.0.1:${upstreamPort}`,
|
||||
},
|
||||
data: req.body,
|
||||
timeout: 10000,
|
||||
validateStatus: () => true,
|
||||
maxRedirects: 0,
|
||||
responseType: "arraybuffer",
|
||||
});
|
||||
|
||||
sshLogger.info("OPKSSH chooser response received", {
|
||||
operation: "opkssh_chooser_proxy_response",
|
||||
requestId,
|
||||
statusCode: response.status,
|
||||
contentType: response.headers["content-type"],
|
||||
contentLength: response.headers["content-length"],
|
||||
hasLocation: !!response.headers.location,
|
||||
});
|
||||
|
||||
Object.entries(response.headers).forEach(([key, value]) => {
|
||||
if (key.toLowerCase() === "transfer-encoding") {
|
||||
return;
|
||||
}
|
||||
if (key.toLowerCase() === "location") {
|
||||
const location = value as string;
|
||||
if (location.startsWith("/")) {
|
||||
res.setHeader(
|
||||
key,
|
||||
`/host/opkssh-chooser/${requestId}${location}`,
|
||||
);
|
||||
} else {
|
||||
const localhostMatch = location.match(
|
||||
/^http:\/\/(?:localhost|127\.0\.0\.1):(\d+)(\/.*)?$/,
|
||||
);
|
||||
if (localhostMatch) {
|
||||
const port = parseInt(localhostMatch[1], 10);
|
||||
const path = localhostMatch[2] || "/";
|
||||
if (session.callbackPort && port === session.callbackPort) {
|
||||
res.setHeader(
|
||||
key,
|
||||
`/host/opkssh-callback/${requestId}${path}`,
|
||||
);
|
||||
} else if (port === session.localPort) {
|
||||
res.setHeader(
|
||||
key,
|
||||
`/host/opkssh-chooser/${requestId}${path}`,
|
||||
);
|
||||
} else {
|
||||
const isCallback =
|
||||
path.includes("login") || path.includes("callback");
|
||||
const prefix = isCallback
|
||||
? "opkssh-callback"
|
||||
: "opkssh-chooser";
|
||||
res.setHeader(key, `/host/${prefix}/${requestId}${path}`);
|
||||
}
|
||||
} else {
|
||||
// External redirect (e.g. to OIDC provider) — capture OAuth state for session binding
|
||||
try {
|
||||
const redirectUrl = new URL(location);
|
||||
const oauthState = redirectUrl.searchParams.get("state");
|
||||
if (oauthState) {
|
||||
registerOAuthState(oauthState, requestId);
|
||||
}
|
||||
} catch {
|
||||
// Not a valid URL, skip state capture
|
||||
}
|
||||
res.setHeader(key, value as string);
|
||||
}
|
||||
}
|
||||
} else if (key.toLowerCase() === "set-cookie") {
|
||||
// Rewrite cookies from OPKSSH's internal listener so they are scoped
|
||||
// to the Termix proxy path instead of OPKSSH's internal path.
|
||||
// The state cookie set by /login must survive to /login-callback.
|
||||
const cookies = Array.isArray(value) ? value : [value as string];
|
||||
const rewritten = cookies.map((cookie) => {
|
||||
return cookie
|
||||
.replace(/;\s*domain=[^;]*/gi, "")
|
||||
.replace(/;\s*path=[^;]*/gi, "; Path=/host/opkssh-callback/")
|
||||
.concat(
|
||||
cookie.match(/;\s*path=/i)
|
||||
? ""
|
||||
: "; Path=/host/opkssh-callback/",
|
||||
);
|
||||
});
|
||||
res.setHeader(key, rewritten);
|
||||
} else {
|
||||
res.setHeader(key, value as string);
|
||||
}
|
||||
});
|
||||
|
||||
// Set a cookie to correlate this browser with the requestId.
|
||||
// OAuth state capture from Location headers only works for 3xx redirects;
|
||||
// if OPKSSH redirects via JavaScript, the state is never registered.
|
||||
// This cookie survives the OIDC round-trip and identifies the session on callback.
|
||||
res.cookie("opkssh_request_id", requestId, {
|
||||
path: "/host/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
maxAge: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const contentType = String(response.headers["content-type"] || "");
|
||||
if (contentType.includes("text/html")) {
|
||||
const html = rewriteOPKSSHHtml(
|
||||
response.data.toString("utf-8"),
|
||||
requestId,
|
||||
"opkssh-chooser",
|
||||
);
|
||||
res.status(response.status).send(html);
|
||||
} else {
|
||||
res.status(response.status).send(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.error("Error proxying OPKSSH chooser", error, {
|
||||
operation: "opkssh_chooser_proxy_error",
|
||||
requestId,
|
||||
});
|
||||
res.status(500).send(
|
||||
renderOpksshErrorPage({
|
||||
title: "Error",
|
||||
heading: "Error",
|
||||
message: "Failed to load authentication page. Please try again.",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/opkssh-callback:
|
||||
* get:
|
||||
* summary: Static OAuth callback from OIDC provider for OPKSSH authentication
|
||||
* tags: [SSH]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Callback processed successfully
|
||||
* 404:
|
||||
* description: No active authentication session found
|
||||
* 500:
|
||||
* description: Authentication failed
|
||||
*/
|
||||
router.get("/opkssh-callback", async (req: Request, res: Response) => {
|
||||
try {
|
||||
sshLogger.info("OAuth callback received", {
|
||||
operation: "opkssh_static_callback_received",
|
||||
host: req.headers.host,
|
||||
});
|
||||
|
||||
const {
|
||||
getUserIdFromRequest,
|
||||
getActiveSessionsForUser,
|
||||
getActiveAuthSession,
|
||||
getRequestIdByOAuthState,
|
||||
clearOAuthState,
|
||||
} = await import("../../hosts/opkssh-auth.js");
|
||||
|
||||
const userId = await getUserIdFromRequest({
|
||||
cookies: req.cookies,
|
||||
headers: req.headers as Record<string, string | undefined>,
|
||||
});
|
||||
|
||||
sshLogger.info("User ID resolved", {
|
||||
operation: "opkssh_callback_user_lookup",
|
||||
userId: userId || "null",
|
||||
hasCookies: !!req.cookies?.jwt,
|
||||
cookieKeys: Object.keys(req.cookies || {}),
|
||||
});
|
||||
|
||||
let userSessions: Awaited<ReturnType<typeof getActiveSessionsForUser>> =
|
||||
[];
|
||||
|
||||
if (userId) {
|
||||
userSessions = getActiveSessionsForUser(userId);
|
||||
} else {
|
||||
// No JWT cookie (e.g. OAuth redirect landed in external browser).
|
||||
// Try to find the correct session via the OAuth state parameter.
|
||||
const oauthState = req.query.state as string | undefined;
|
||||
|
||||
if (oauthState) {
|
||||
const mappedRequestId = getRequestIdByOAuthState(oauthState);
|
||||
if (mappedRequestId) {
|
||||
const mappedSession = getActiveAuthSession(mappedRequestId);
|
||||
if (mappedSession) {
|
||||
userSessions = [mappedSession];
|
||||
clearOAuthState(oauthState);
|
||||
sshLogger.info("Resolved session via OAuth state parameter", {
|
||||
operation: "opkssh_callback_state_lookup",
|
||||
requestId: mappedRequestId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use the opkssh_request_id cookie set by the chooser proxy.
|
||||
// State capture only works for 3xx redirects; if OPKSSH redirects via
|
||||
// JavaScript in the HTML, the state is never registered in the map.
|
||||
if (userSessions.length === 0) {
|
||||
const cookieRequestId = req.cookies?.opkssh_request_id;
|
||||
if (cookieRequestId) {
|
||||
const cookieSession = getActiveAuthSession(cookieRequestId);
|
||||
if (cookieSession) {
|
||||
userSessions = [cookieSession];
|
||||
res.clearCookie("opkssh_request_id", { path: "/host/" });
|
||||
sshLogger.info("Resolved session via opkssh_request_id cookie", {
|
||||
operation: "opkssh_callback_cookie_lookup",
|
||||
requestId: cookieRequestId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (userSessions.length === 0) {
|
||||
sshLogger.warn(
|
||||
"OAuth callback with no JWT, no matching state, and no session cookie",
|
||||
{
|
||||
operation: "opkssh_callback_no_session_match",
|
||||
hasState: !!oauthState,
|
||||
hasCookie: !!req.cookies?.opkssh_request_id,
|
||||
},
|
||||
);
|
||||
res
|
||||
.status(401)
|
||||
.send("Authentication callback failed: unable to identify session");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
sshLogger.info("Active sessions for user", {
|
||||
operation: "opkssh_callback_session_lookup",
|
||||
userId,
|
||||
sessionCount: userSessions.length,
|
||||
sessions: userSessions.map((s) => ({
|
||||
requestId: s.requestId,
|
||||
status: s.status,
|
||||
hasCallbackPort: !!s.callbackPort,
|
||||
callbackPort: s.callbackPort,
|
||||
hasLocalPort: !!s.localPort,
|
||||
localPort: s.localPort,
|
||||
})),
|
||||
});
|
||||
|
||||
if (userSessions.length === 0) {
|
||||
sshLogger.error("No active sessions for callback", {
|
||||
operation: "opkssh_callback_no_sessions",
|
||||
userId,
|
||||
});
|
||||
res.status(404).send("No active authentication session found");
|
||||
return;
|
||||
}
|
||||
|
||||
const session = userSessions[userSessions.length - 1];
|
||||
|
||||
if (!session.callbackPort) {
|
||||
sshLogger.error("Session callback port not ready", {
|
||||
operation: "opkssh_callback_port_not_ready",
|
||||
userId,
|
||||
requestId: session.requestId,
|
||||
sessionStatus: session.status,
|
||||
hasLocalPort: !!session.localPort,
|
||||
});
|
||||
res.status(503).send("OPKSSH callback listener not ready yet");
|
||||
return;
|
||||
}
|
||||
|
||||
const queryString = req.url.includes("?")
|
||||
? req.url.substring(req.url.indexOf("?"))
|
||||
: "";
|
||||
// OPKSSH's internal callback listener handles `/login-callback` regardless of the
|
||||
// path used in --remote-redirect-uri. The dynamic route below defaults to that path.
|
||||
const redirectUrl = `/host/opkssh-callback/${session.requestId}${queryString}`;
|
||||
|
||||
sshLogger.info("Redirecting OAuth callback to dynamic route", {
|
||||
operation: "opkssh_static_callback_redirect",
|
||||
userId,
|
||||
requestId: session.requestId,
|
||||
callbackPort: session.callbackPort,
|
||||
queryParams: Object.keys(req.query),
|
||||
redirectUrl,
|
||||
});
|
||||
|
||||
res.redirect(302, redirectUrl);
|
||||
} catch (error) {
|
||||
sshLogger.error("Error handling OPKSSH static callback", error, {
|
||||
operation: "opkssh_static_callback_error",
|
||||
url: req.url,
|
||||
originalUrl: req.originalUrl,
|
||||
});
|
||||
res.status(500).send("Authentication callback failed");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/opkssh-callback/{requestId}:
|
||||
* get:
|
||||
* summary: OAuth callback from OIDC provider for OPKSSH authentication (handles all sub-paths)
|
||||
* tags: [SSH]
|
||||
* parameters:
|
||||
* - name: requestId
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Authentication request ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Callback processed successfully
|
||||
* 404:
|
||||
* description: Invalid authentication session
|
||||
* 500:
|
||||
* description: Authentication failed
|
||||
*/
|
||||
router.use(
|
||||
"/opkssh-callback/:requestId",
|
||||
async (req: Request, res: Response) => {
|
||||
const requestId = Array.isArray(req.params.requestId)
|
||||
? req.params.requestId[0]
|
||||
: req.params.requestId;
|
||||
|
||||
try {
|
||||
const { getActiveAuthSession } =
|
||||
await import("../../hosts/opkssh-auth.js");
|
||||
const session = getActiveAuthSession(requestId);
|
||||
|
||||
if (!session) {
|
||||
res.status(404).send(
|
||||
renderOpksshErrorPage({
|
||||
title: "Session Not Found",
|
||||
heading: "Session Not Found",
|
||||
message:
|
||||
"Authentication session expired or invalid. Please close this window and try again.",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const axios = (await import("axios")).default;
|
||||
const fullPath = req.originalUrl || req.url;
|
||||
const pathAfterRequestId =
|
||||
fullPath.split(`/host/opkssh-callback/${requestId}`)[1] || "";
|
||||
// pathAfterRequestId may be "", "?query=...", "/subpath", or "/subpath?query=..."
|
||||
// OPKSSH's internal listener serves /login-callback, so when no sub-path is present
|
||||
// (query-only or empty), prepend it.
|
||||
const targetPath =
|
||||
pathAfterRequestId === "" || pathAfterRequestId.startsWith("?")
|
||||
? `/login-callback${pathAfterRequestId}`
|
||||
: pathAfterRequestId;
|
||||
|
||||
if (!session.callbackPort || session.callbackPort === 0) {
|
||||
sshLogger.error("OPKSSH callback session has no callback port", {
|
||||
operation: "opkssh_callback_proxy",
|
||||
requestId,
|
||||
sessionStatus: session.status,
|
||||
});
|
||||
res.status(500).send(
|
||||
renderOpksshErrorPage({
|
||||
title: "Error",
|
||||
heading: "Callback Error",
|
||||
message:
|
||||
"OPKSSH callback listener not ready. Please try authenticating again.",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetUrl = `http://127.0.0.1:${session.callbackPort}${targetPath}`;
|
||||
|
||||
const response = await axios({
|
||||
method: req.method,
|
||||
url: targetUrl,
|
||||
headers: {
|
||||
...req.headers,
|
||||
host: `127.0.0.1:${session.callbackPort}`,
|
||||
},
|
||||
data: req.body,
|
||||
timeout: 10000,
|
||||
validateStatus: () => true,
|
||||
maxRedirects: 0,
|
||||
responseType: "arraybuffer",
|
||||
});
|
||||
|
||||
Object.entries(response.headers).forEach(([key, value]) => {
|
||||
if (key.toLowerCase() === "transfer-encoding") {
|
||||
return;
|
||||
}
|
||||
if (key.toLowerCase() === "location") {
|
||||
const location = value as string;
|
||||
if (location.startsWith("/")) {
|
||||
res.setHeader(
|
||||
key,
|
||||
`/host/opkssh-callback/${requestId}${location}`,
|
||||
);
|
||||
} else {
|
||||
res.setHeader(key, value as string);
|
||||
}
|
||||
} else {
|
||||
res.setHeader(key, value as string);
|
||||
}
|
||||
});
|
||||
|
||||
const contentType = String(response.headers["content-type"] || "");
|
||||
if (contentType.includes("text/html")) {
|
||||
const html = rewriteOPKSSHHtml(
|
||||
response.data.toString("utf-8"),
|
||||
requestId,
|
||||
"opkssh-callback",
|
||||
);
|
||||
res.status(response.status).send(html);
|
||||
} else {
|
||||
res.status(response.status).send(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.error("Error handling OPKSSH OAuth callback", error, {
|
||||
operation: "opkssh_oauth_callback_error",
|
||||
requestId,
|
||||
});
|
||||
|
||||
res.status(500).send(
|
||||
renderOpksshErrorPage({
|
||||
title: "Error",
|
||||
heading: "Error",
|
||||
message: "An unexpected error occurred. Please try again.",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* The pluginSettings map attached to a host on its way out.
|
||||
*
|
||||
* Without this the host editor would need one request per plugin per host,
|
||||
* which is the shape that makes a list of a few hundred hosts slow. One query
|
||||
* covers the whole list instead.
|
||||
*
|
||||
* Kept out of transformHostResponse because that function is synchronous and
|
||||
* shared by every host read path. Secrets are always redacted here, so nothing
|
||||
* on this path has to decrypt and the async work stays in one place.
|
||||
*/
|
||||
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import { createCurrentPluginSettingsRepository } from "../repositories/factory.js";
|
||||
import type { PluginSettingsRecord } from "../repositories/plugin-settings-repository.js";
|
||||
import {
|
||||
declaredFields,
|
||||
findField,
|
||||
resolveFieldValue,
|
||||
setSetting,
|
||||
} from "../../plugins/settings.js";
|
||||
import {
|
||||
coerceSettingValue,
|
||||
validateSettingValue,
|
||||
} from "@termix/plugin-sdk/settings";
|
||||
import { getPluginRuntime } from "../../plugins/index.js";
|
||||
import { consume } from "../../plugins/registry.js";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
|
||||
export type HostPluginSettings = Record<string, Record<string, unknown>>;
|
||||
|
||||
/**
|
||||
* Marks a host changed after one of its plugin settings did, so remote sync,
|
||||
* which pulls by updated_at, carries the new value across.
|
||||
*/
|
||||
export async function touchHost(hostId: number): Promise<void> {
|
||||
try {
|
||||
const { sql } = await import("drizzle-orm");
|
||||
const { runStatement } =
|
||||
await import("../../utils/crypto-migration/raw-rows.js");
|
||||
await runStatement(
|
||||
sql`UPDATE ssh_data SET updated_at = ${new Date().toISOString()} WHERE id = ${hostId}`,
|
||||
);
|
||||
} catch {
|
||||
// Only a sync hint; the setting itself is already saved.
|
||||
}
|
||||
}
|
||||
|
||||
/** Enabled plugins that declare host-scope settings, with their manifests. */
|
||||
export function hostSettingsPlugins(): PluginManifest[] {
|
||||
try {
|
||||
const { loader } = getPluginRuntime();
|
||||
return loader
|
||||
.list()
|
||||
.filter((plugin) => plugin.state === "active")
|
||||
.map((plugin) => plugin.manifest)
|
||||
.filter(
|
||||
(manifest): manifest is PluginManifest =>
|
||||
!!manifest && !!manifest.contributes?.settings?.host,
|
||||
);
|
||||
} catch {
|
||||
// The runtime is not up in every context that renders a host.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the map for a set of hosts in one query.
|
||||
*
|
||||
* Returns an empty map when no enabled plugin declares host settings, so the
|
||||
* common case costs nothing.
|
||||
*/
|
||||
export async function loadHostPluginSettings(
|
||||
hostIds: number[],
|
||||
): Promise<Map<number, HostPluginSettings>> {
|
||||
const result = new Map<number, HostPluginSettings>();
|
||||
const manifests = hostSettingsPlugins();
|
||||
if (manifests.length === 0 || hostIds.length === 0) return result;
|
||||
|
||||
let rows: PluginSettingsRecord[] = [];
|
||||
try {
|
||||
rows = await createCurrentPluginSettingsRepository().getAllForScopeIds(
|
||||
"host",
|
||||
hostIds.map(String),
|
||||
);
|
||||
} catch (error) {
|
||||
// A host list must not fail because a settings read did.
|
||||
sshLogger.warn("Failed to load host plugin settings", {
|
||||
operation: "host_plugin_settings",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
const byHostAndPlugin = new Map<string, PluginSettingsRecord[]>();
|
||||
for (const row of rows) {
|
||||
const key = `${row.scopeId}:${row.pluginId}`;
|
||||
const list = byHostAndPlugin.get(key);
|
||||
if (list) list.push(row);
|
||||
else byHostAndPlugin.set(key, [row]);
|
||||
}
|
||||
|
||||
for (const hostId of hostIds) {
|
||||
const perPlugin: HostPluginSettings = {};
|
||||
|
||||
for (const manifest of manifests) {
|
||||
const fields = declaredFields(manifest, "host");
|
||||
if (fields.length === 0) continue;
|
||||
|
||||
const stored = new Map(
|
||||
(byHostAndPlugin.get(`${hostId}:${manifest.id}`) ?? []).map((row) => [
|
||||
row.key,
|
||||
row,
|
||||
]),
|
||||
);
|
||||
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const field of fields) {
|
||||
values[field.key] = await resolveFieldValue(
|
||||
field,
|
||||
stored.get(field.key),
|
||||
{ redactSecrets: true },
|
||||
);
|
||||
}
|
||||
perPlugin[manifest.id] = values;
|
||||
}
|
||||
|
||||
if (Object.keys(perPlugin).length > 0) result.set(hostId, perPlugin);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const SHARE_LEVELS = ["connect", "view", "edit", "manage"];
|
||||
|
||||
/**
|
||||
* Drops the fields a shared host's recipient may not read: a field declares
|
||||
* the lowest share level that sees it with shareRead. The owner sees all.
|
||||
*/
|
||||
function visibleHostPluginSettings(
|
||||
values: HostPluginSettings,
|
||||
host: Record<string, unknown>,
|
||||
): HostPluginSettings {
|
||||
if (!host.isShared) return values;
|
||||
const level = SHARE_LEVELS.indexOf(
|
||||
typeof host.permissionLevel === "string" ? host.permissionLevel : "connect",
|
||||
);
|
||||
const result: HostPluginSettings = {};
|
||||
for (const manifest of hostSettingsPlugins()) {
|
||||
const own = values[manifest.id];
|
||||
if (!own) continue;
|
||||
const copy = { ...own };
|
||||
for (const field of declaredFields(manifest, "host")) {
|
||||
if (
|
||||
field.shareRead &&
|
||||
SHARE_LEVELS.indexOf(field.shareRead) > Math.max(level, 0)
|
||||
) {
|
||||
delete copy[field.key];
|
||||
}
|
||||
}
|
||||
result[manifest.id] = copy;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields a plugin still puts on the host payload in their pre-2.9 shape, for
|
||||
* clients outside Termix (Termix-Mobile) that have not moved to
|
||||
* pluginSettings yet. Registered as ctx.registry.provide(
|
||||
* "<id>.hostPayloadLegacy", fn); the function gets the plugin's own host
|
||||
* values and the host, and never overwrites a field core already set. When
|
||||
* both are objects the plugin's keys are added to core's, under the same
|
||||
* rule.
|
||||
*/
|
||||
export type PluginHostPayloadLegacy = (
|
||||
values: Record<string, unknown>,
|
||||
host: Record<string, unknown>,
|
||||
) => Record<string, unknown> | null;
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function applyLegacyFields(
|
||||
host: Record<string, unknown>,
|
||||
values: HostPluginSettings,
|
||||
): void {
|
||||
for (const manifest of hostSettingsPlugins()) {
|
||||
const own = values[manifest.id];
|
||||
if (!own) continue;
|
||||
const legacy = consume<PluginHostPayloadLegacy>(
|
||||
`${manifest.id}.hostPayloadLegacy`,
|
||||
);
|
||||
if (!legacy) continue;
|
||||
try {
|
||||
for (const [key, value] of Object.entries(legacy(own, host) ?? {})) {
|
||||
const current = host[key];
|
||||
if (current === undefined || current === null) {
|
||||
host[key] = value;
|
||||
} else if (isPlainObject(current) && isPlainObject(value)) {
|
||||
// An object core already sends (terminalConfig) gains the plugin's
|
||||
// keys, still never over one core set.
|
||||
host[key] = { ...value, ...current };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// A plugin's compat shape must never break a host read.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Attaches the map to already-transformed host objects, in place. */
|
||||
export function attachHostPluginSettings(
|
||||
hosts: Record<string, unknown>[],
|
||||
settings: Map<number, HostPluginSettings>,
|
||||
): void {
|
||||
if (settings.size === 0) return;
|
||||
for (const host of hosts) {
|
||||
const hostId = Number(host.id);
|
||||
const values = settings.get(hostId);
|
||||
if (!values) continue;
|
||||
host.pluginSettings = visibleHostPluginSettings(values, host);
|
||||
applyLegacyFields(host, host.pluginSettings as HostPluginSettings);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The single-host convenience wrapper. With a viewer, a shared host's
|
||||
* personal fields come from that viewer's own defaults.
|
||||
*/
|
||||
export async function withHostPluginSettings(
|
||||
host: Record<string, unknown>,
|
||||
viewerId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const hostId = Number(host.id);
|
||||
if (!Number.isInteger(hostId)) return host;
|
||||
|
||||
const settings = await loadHostPluginSettings([hostId]);
|
||||
if (viewerId) {
|
||||
const { applyPersonalHostValues } =
|
||||
await import("../../hosts/defaults/personal.js");
|
||||
await applyPersonalHostValues(settings, viewerId).catch(() => {});
|
||||
}
|
||||
const values = settings.get(hostId);
|
||||
if (!values) return host;
|
||||
const result: Record<string, unknown> = {
|
||||
...host,
|
||||
pluginSettings: visibleHostPluginSettings(values, host),
|
||||
};
|
||||
applyLegacyFields(result, result.pluginSettings as HostPluginSettings);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a set of host-scope plugin settings in one call, through the same
|
||||
* path as every other settings write: an undeclared key is refused, a value
|
||||
* is validated, and a secret field is encrypted.
|
||||
*/
|
||||
export async function writeHostPluginSettings(
|
||||
manifest: PluginManifest,
|
||||
hostId: number,
|
||||
values: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === undefined) continue;
|
||||
const error = await setSetting(manifest, "host", hostId, key, value);
|
||||
if (error) {
|
||||
sshLogger.warn("Refused a plugin host setting on import", {
|
||||
operation: "host_import_plugin_settings",
|
||||
pluginId: manifest.id,
|
||||
hostId,
|
||||
key,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the host settings a ctx.hosts write carries, keyed by plugin id,
|
||||
* before anything is written. A key the plugin does not declare, or a value
|
||||
* its field refuses, is an error. A plugin that is not running has nowhere to
|
||||
* keep them, so its entry is skipped and named in `skipped`.
|
||||
*/
|
||||
export function checkHostPluginSettingsInput(
|
||||
input: Record<string, Record<string, unknown>>,
|
||||
): {
|
||||
writes: Array<{ manifest: PluginManifest; values: Record<string, unknown> }>;
|
||||
skipped: string[];
|
||||
errors: string[];
|
||||
} {
|
||||
const running = new Map(hostSettingsPlugins().map((m) => [m.id, m]));
|
||||
const writes: Array<{
|
||||
manifest: PluginManifest;
|
||||
values: Record<string, unknown>;
|
||||
}> = [];
|
||||
const skipped: string[] = [];
|
||||
const errors: string[] = [];
|
||||
for (const [pluginId, values] of Object.entries(input)) {
|
||||
const manifest = running.get(pluginId);
|
||||
if (!manifest) {
|
||||
skipped.push(pluginId);
|
||||
continue;
|
||||
}
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
const field = findField(manifest, "host", key);
|
||||
if (!field) {
|
||||
errors.push(`${pluginId}.${key} is not a host setting it declares`);
|
||||
continue;
|
||||
}
|
||||
const error = validateSettingValue(
|
||||
field,
|
||||
coerceSettingValue(field, value),
|
||||
);
|
||||
if (error) errors.push(`${pluginId}.${key}: ${error}`);
|
||||
}
|
||||
writes.push({ manifest, values });
|
||||
}
|
||||
return { writes, skipped, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin's own validator for the fields it accepts inline on a bulk host
|
||||
* import row (host-bulk-routes.ts's Termix-JSON import path). Registered
|
||||
* through ctx.registry.provide("<pluginId>.hostImportNormalizer", fn) - not
|
||||
* part of the SDK's typed ctx surface, because only this one bulk-import
|
||||
* path calls it and a full contract is speculative until a second caller
|
||||
* needs it. Returns the fields to write (JSON-serializable, matching
|
||||
* writeHostPluginSettings's input), or null to write nothing for this row.
|
||||
*/
|
||||
export type PluginHostImportNormalizer = (
|
||||
raw: Record<string, unknown>,
|
||||
) => Record<string, unknown> | null;
|
||||
|
||||
/**
|
||||
* Runs every enabled plugin's registered import normalizer over one imported
|
||||
* host row and writes whatever each one returns, so a plugin's host-scope
|
||||
* settings are validated the same way on bulk import as they are anywhere
|
||||
* else - instead of host-bulk-routes.ts hardcoding one plugin's shape.
|
||||
*
|
||||
* Best-effort per plugin: one plugin's normalizer throwing must not fail the
|
||||
* whole import, so it is logged and skipped rather than propagated.
|
||||
*/
|
||||
export async function applyPluginHostImportSettings(
|
||||
hostId: number,
|
||||
raw: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const exported =
|
||||
raw.pluginSettings && typeof raw.pluginSettings === "object"
|
||||
? (raw.pluginSettings as HostPluginSettings)
|
||||
: {};
|
||||
|
||||
for (const manifest of hostSettingsPlugins()) {
|
||||
try {
|
||||
// What an export carried for this plugin, validated like any other
|
||||
// write. Secrets never leave the server, so there are none to restore.
|
||||
const carried = exported[manifest.id];
|
||||
if (carried && typeof carried === "object") {
|
||||
for (const field of declaredFields(manifest, "host")) {
|
||||
if (field.type === "secret" || !(field.key in carried)) continue;
|
||||
await setSetting(
|
||||
manifest,
|
||||
"host",
|
||||
hostId,
|
||||
field.key,
|
||||
carried[field.key],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizer = consume<PluginHostImportNormalizer>(
|
||||
`${manifest.id}.hostImportNormalizer`,
|
||||
);
|
||||
const values = normalizer?.(raw);
|
||||
if (values) await writeHostPluginSettings(manifest, hostId, values);
|
||||
} catch (error) {
|
||||
sshLogger.warn("Plugin host import normalizer failed", {
|
||||
operation: "host_import_plugin_settings",
|
||||
pluginId: manifest.id,
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns one plugin's host switch on or off for a set of hosts. Returns false
|
||||
* when the plugin is not running or declares no enableKey.
|
||||
*/
|
||||
export async function setHostPluginEnabled(
|
||||
pluginId: string,
|
||||
hostIds: number[],
|
||||
enabled: boolean,
|
||||
): Promise<boolean> {
|
||||
const manifest = hostSettingsPlugins().find((m) => m.id === pluginId);
|
||||
const enableKey = manifest?.contributes?.settings?.host?.enableKey;
|
||||
if (!manifest || !enableKey) return false;
|
||||
for (const hostId of hostIds) {
|
||||
await setSetting(manifest, "host", hostId, enableKey, enabled);
|
||||
await touchHost(hostId);
|
||||
}
|
||||
const { changeHostOverrides } =
|
||||
await import("../../hosts/defaults/overrides.js");
|
||||
await changeHostOverrides(hostIds, { own: [[pluginId, enableKey]] });
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
import { createCurrentSettingsRepository } from "../repositories/factory.js";
|
||||
import {
|
||||
DEFAULT_STATUS_INTERVAL,
|
||||
GLOBAL_STATUS_INTERVAL_KEY,
|
||||
hostStatusService,
|
||||
type HostStatusEntry,
|
||||
} from "../../hosts/status/host-status-service.js";
|
||||
|
||||
interface HostStatusRoutesDeps {
|
||||
authenticateJWT: RequestHandler;
|
||||
requireAdmin: RequestHandler;
|
||||
}
|
||||
|
||||
/** "1,2,3" to a set of ids; undefined means the caller asked for all. */
|
||||
export function parseStatusHostIds(value: unknown): Set<number> | null {
|
||||
if (value === undefined) return null;
|
||||
if (typeof value !== "string") return new Set();
|
||||
return new Set(
|
||||
value
|
||||
.split(",")
|
||||
.map(Number)
|
||||
.filter((id) => Number.isSafeInteger(id) && id > 0),
|
||||
);
|
||||
}
|
||||
|
||||
function sessionExpired(res: Response): Response {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
export function registerHostStatusRoutes(
|
||||
router: Router,
|
||||
{ authenticateJWT, requireAdmin }: HostStatusRoutesDeps,
|
||||
): void {
|
||||
const permissionManager = PermissionManager.getInstance();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/status:
|
||||
* get:
|
||||
* summary: Get host statuses
|
||||
* description: Returns the online, reachable or offline status of every host the user can see. Starts status checks for the user's own hosts on first call.
|
||||
* tags:
|
||||
* - SSH
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: hostIds
|
||||
* required: false
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Comma separated ids. Only these of the user's own hosts are checked.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A map of host ids to status entries.
|
||||
* 401:
|
||||
* description: Session expired - please log in again.
|
||||
*/
|
||||
router.get(
|
||||
"/status",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (DataCrypto.getUserDataKey(userId) === null)
|
||||
return sessionExpired(res);
|
||||
|
||||
const requested = parseStatusHostIds(req.query.hostIds);
|
||||
const statuses = await hostStatusService.statusesFor(userId, requested);
|
||||
|
||||
// One batched permission check: this is polled every few seconds, and a
|
||||
// per-host check made it linear in host count against the database.
|
||||
const entries = [...statuses.entries()];
|
||||
const allowed = await permissionManager.filterAccessibleHostIds(
|
||||
userId,
|
||||
entries.map(([id]) => id),
|
||||
);
|
||||
|
||||
const result: Record<number, HostStatusEntry> = {};
|
||||
for (const [id, entry] of entries) {
|
||||
if (allowed.has(id) && (requested === null || requested.has(id))) {
|
||||
result[id] = entry;
|
||||
}
|
||||
}
|
||||
res.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/status/settings:
|
||||
* get:
|
||||
* summary: Get status check settings
|
||||
* description: Returns the default number of seconds between status checks.
|
||||
* tags:
|
||||
* - SSH
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The status check settings.
|
||||
* 403:
|
||||
* description: Admin access required.
|
||||
*/
|
||||
router.get(
|
||||
"/status/settings",
|
||||
authenticateJWT,
|
||||
requireAdmin,
|
||||
async (_req: Request, res: Response) => {
|
||||
const value = Number(
|
||||
await createCurrentSettingsRepository().get(GLOBAL_STATUS_INTERVAL_KEY),
|
||||
);
|
||||
res.json({
|
||||
statusCheckInterval:
|
||||
Number.isInteger(value) && value >= 5
|
||||
? value
|
||||
: DEFAULT_STATUS_INTERVAL,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/status/settings:
|
||||
* put:
|
||||
* summary: Update status check settings
|
||||
* description: Sets the default number of seconds between status checks and re-times every running check.
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* statusCheckInterval:
|
||||
* type: integer
|
||||
* minimum: 5
|
||||
* maximum: 3600
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Settings saved.
|
||||
* 400:
|
||||
* description: Invalid interval.
|
||||
* 403:
|
||||
* description: Admin access required.
|
||||
*/
|
||||
router.put(
|
||||
"/status/settings",
|
||||
authenticateJWT,
|
||||
requireAdmin,
|
||||
async (req: Request, res: Response) => {
|
||||
const seconds = Number(req.body?.statusCheckInterval);
|
||||
if (!Number.isInteger(seconds) || seconds < 5 || seconds > 3600) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Status check interval must be 5 to 3600 seconds" });
|
||||
}
|
||||
try {
|
||||
await createCurrentSettingsRepository().set(
|
||||
GLOBAL_STATUS_INTERVAL_KEY,
|
||||
String(seconds),
|
||||
);
|
||||
hostStatusService.retimeAll();
|
||||
res.json({ statusCheckInterval: seconds });
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to save status check settings", error, {
|
||||
operation: "host_status_settings",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to save settings" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/status/refresh:
|
||||
* post:
|
||||
* summary: Restart status checks
|
||||
* description: Re-reads the user's own hosts and restarts their status checks.
|
||||
* tags:
|
||||
* - SSH
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Status checks restarted.
|
||||
* 401:
|
||||
* description: Session expired - please log in again.
|
||||
*/
|
||||
router.post(
|
||||
"/status/refresh",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (DataCrypto.getUserDataKey(userId) === null) {
|
||||
return sessionExpired(res);
|
||||
}
|
||||
await hostStatusService.refresh(userId);
|
||||
res.json({ message: "Status checks restarted" });
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/status/{id}:
|
||||
* get:
|
||||
* summary: Get one host's status
|
||||
* description: Returns the status of one host the user can connect to.
|
||||
* tags:
|
||||
* - SSH
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The host's status entry.
|
||||
* 400:
|
||||
* description: Invalid host id.
|
||||
* 404:
|
||||
* description: Status not available.
|
||||
*/
|
||||
router.get(
|
||||
"/status/:id",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return res.status(400).json({ error: "Invalid host ID" });
|
||||
}
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (DataCrypto.getUserDataKey(userId) === null) {
|
||||
return sessionExpired(res);
|
||||
}
|
||||
|
||||
const access = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
id,
|
||||
"connect",
|
||||
);
|
||||
if (!access.hasAccess) {
|
||||
return res.status(404).json({ error: "Status not available" });
|
||||
}
|
||||
|
||||
await hostStatusService.statusesFor(userId, null);
|
||||
const entry =
|
||||
hostStatusService.get(id) ?? (await hostStatusService.check(id));
|
||||
if (!entry) {
|
||||
return res.status(404).json({ error: "Status not available" });
|
||||
}
|
||||
res.json(entry);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { Request, Response, Router } from "express";
|
||||
import { escapeHtml } from "./opkssh-html.js";
|
||||
|
||||
function resultPage(ok: boolean, message: string): string {
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><title>Termix</title>
|
||||
<style>body{font-family:system-ui,sans-serif;background:#111;color:#eee;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
||||
main{max-width:28rem;padding:2rem;border:1px solid #333;background:#181818}h1{font-size:1.1rem;margin:0 0 .5rem}p{margin:0;color:#aaa}</style></head>
|
||||
<body><main><h1>${ok ? "Signed in" : "Sign-in failed"}</h1><p>${escapeHtml(message)}</p></main></body></html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The OIDC redirect target for Step CA sign-ins. Unauthenticated on purpose:
|
||||
* the browser that finishes the sign-in may not be the one running Termix,
|
||||
* so the request is matched to its session by the OAuth state.
|
||||
*/
|
||||
export function registerHostStepCaRoutes(router: Router): void {
|
||||
router.get("/step-ca-callback", async (req: Request, res: Response) => {
|
||||
const { completeStepCaAuth } = await import("../../hosts/step-ca-auth.js");
|
||||
const stringQuery = (name: string): string | undefined => {
|
||||
const value = req.query[name];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
};
|
||||
const result = await completeStepCaAuth({
|
||||
state: stringQuery("state"),
|
||||
code: stringQuery("code"),
|
||||
error: stringQuery("error"),
|
||||
error_description: stringQuery("error_description"),
|
||||
});
|
||||
res
|
||||
.status(result.ok ? 200 : 400)
|
||||
.type("html")
|
||||
.send(resultPage(result.ok, result.message));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* How a host update writes terminal_config and ssh_options, kept apart from
|
||||
* the route so it can be tested on its own.
|
||||
*/
|
||||
|
||||
import { createCurrentHostResolutionRepository } from "../repositories/factory.js";
|
||||
import { parseSshOptions } from "../../hosts/ssh-options.js";
|
||||
import {
|
||||
OWNER_PRIVATE_SSH_OPTION_FIELDS,
|
||||
OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS,
|
||||
} from "./host-normalizers.js";
|
||||
|
||||
export function parseTerminalConfig(
|
||||
value: unknown,
|
||||
): Record<string, unknown> | null {
|
||||
let parsed = value;
|
||||
if (typeof parsed === "string") {
|
||||
try {
|
||||
parsed = JSON.parse(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? { ...(parsed as Record<string, unknown>) }
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* terminal_config is merged into what is stored rather than replaced. The
|
||||
* 2.8 keys the editor no longer sends (the terminal's look, which the
|
||||
* ssh-terminal plugin copies into its host settings, and a sudo password 2.8
|
||||
* editors kept there) stay until 3.0.0 drops the column. A shared editor
|
||||
* never changes the owner's private keys in either JSON. Returns an error
|
||||
* for a terminalConfig that cannot be read.
|
||||
*/
|
||||
export async function mergeStoredTerminalFields(
|
||||
sshDataObj: Record<string, unknown>,
|
||||
hostData: Record<string, unknown>,
|
||||
hostId: number,
|
||||
ownerId: string,
|
||||
isOwner: boolean,
|
||||
): Promise<string | null> {
|
||||
const sentTerminalConfig =
|
||||
hostData.terminalConfig !== undefined && hostData.terminalConfig !== null;
|
||||
const incoming = sentTerminalConfig
|
||||
? parseTerminalConfig(hostData.terminalConfig)
|
||||
: null;
|
||||
if (sentTerminalConfig && !incoming) return "Invalid terminal config";
|
||||
delete sshDataObj.terminalConfig;
|
||||
|
||||
const sentSudoPassword = isOwner && hostData.sudoPassword !== undefined;
|
||||
if (!incoming && !sentSudoPassword && sshDataObj.sshOptions === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stored = await createCurrentHostResolutionRepository().findHostById(
|
||||
hostId,
|
||||
ownerId,
|
||||
);
|
||||
const storedConfig = parseTerminalConfig(stored?.terminalConfig) ?? {};
|
||||
|
||||
if (incoming || (sentSudoPassword && "sudoPassword" in storedConfig)) {
|
||||
const merged = { ...storedConfig, ...(incoming ?? {}) };
|
||||
if (!isOwner) {
|
||||
for (const field of OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS) {
|
||||
if (field in storedConfig) merged[field] = storedConfig[field];
|
||||
else delete merged[field];
|
||||
}
|
||||
}
|
||||
// A sudo password saved on its own lives in the encrypted column.
|
||||
if (sentSudoPassword) delete merged.sudoPassword;
|
||||
sshDataObj.terminalConfig = JSON.stringify(merged);
|
||||
}
|
||||
|
||||
if (!isOwner && sshDataObj.sshOptions !== undefined) {
|
||||
const options = parseSshOptions(sshDataObj.sshOptions);
|
||||
const storedOptions = parseSshOptions(stored?.sshOptions ?? storedConfig);
|
||||
for (const field of OWNER_PRIVATE_SSH_OPTION_FIELDS) {
|
||||
if (storedOptions[field] !== undefined) {
|
||||
options[field] = storedOptions[field];
|
||||
} else {
|
||||
delete options[field];
|
||||
}
|
||||
}
|
||||
sshDataObj.sshOptions = JSON.stringify(options);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
import {
|
||||
MAX_WEB_ENDPOINTS,
|
||||
MAX_WEB_ENDPOINT_LABEL_LENGTH,
|
||||
} from "../../../types/index.js";
|
||||
import type { WebEndpoint, WebUiConfig } from "../../../types/index.js";
|
||||
|
||||
export { MAX_WEB_ENDPOINTS, MAX_WEB_ENDPOINT_LABEL_LENGTH };
|
||||
|
||||
const SCHEMES = new Set(["http", "https"]);
|
||||
const ACCESS_VALUES = new Set(["direct", "tunnel"]);
|
||||
const RENDER_VALUES = new Set(["external", "embedded"]);
|
||||
|
||||
const MIN_PORT = 1;
|
||||
const MAX_PORT = 65535;
|
||||
|
||||
function isValidPort(value: unknown): value is number {
|
||||
return (
|
||||
typeof value === "number" &&
|
||||
Number.isInteger(value) &&
|
||||
value >= MIN_PORT &&
|
||||
value <= MAX_PORT
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* C0 controls and DEL. Checked by codepoint rather than by regex character
|
||||
* class so no escaping subtlety can let one through: a path like
|
||||
* "\t//evil.example" defeats a naive startsWith("//") guard, because the
|
||||
* browser strips the control character and then follows the authority.
|
||||
*/
|
||||
function hasControlCharacter(value: string): boolean {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code <= 0x1f || code === 0x7f) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerces a path to begin with "/", or returns null to drop the endpoint.
|
||||
*
|
||||
* This is THE enforcement point for a value that reaches an href and an
|
||||
* iframe src -- the editor's checks are UX. Anything that could redirect the
|
||||
* frame away from the endpoint is rejected rather than sanitized, because
|
||||
* sanitizing invites a second, subtly different implementation elsewhere.
|
||||
*/
|
||||
function normalizePath(raw: unknown): string | null {
|
||||
if (raw === undefined || raw === null || raw === "") return "/";
|
||||
if (typeof raw !== "string") return null;
|
||||
if (hasControlCharacter(raw)) return null;
|
||||
|
||||
const path = raw.startsWith("/") ? raw : `/${raw}`;
|
||||
|
||||
if (path.startsWith("//")) return null;
|
||||
if (/^\/[a-z][a-z0-9+.-]*:/i.test(path)) return null;
|
||||
if (path.includes("\\")) return null;
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* A bare host literal, or null to drop the endpoint, or undefined to leave the
|
||||
* field unset (the route then falls back to loopback).
|
||||
*
|
||||
* The value lands both in a TCP listener and in a URL authority, so nothing
|
||||
* carrying a scheme, port, path or credentials may survive.
|
||||
*/
|
||||
function normalizeBindHost(raw: unknown): string | null | undefined {
|
||||
if (raw === undefined || raw === null || raw === "") return undefined;
|
||||
if (typeof raw !== "string") return null;
|
||||
|
||||
const value = raw.trim();
|
||||
if (!value) return undefined;
|
||||
if (value.length > 253) return null;
|
||||
if (hasControlCharacter(value)) return null;
|
||||
|
||||
const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/;
|
||||
const HOSTNAME =
|
||||
/^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/;
|
||||
const IPV6_BRACKETED = /^\[[0-9A-Fa-f:.]+\]$/;
|
||||
|
||||
if (IPV4.test(value)) {
|
||||
return value.split(".").every((part) => Number(part) <= 255) ? value : null;
|
||||
}
|
||||
if (IPV6_BRACKETED.test(value) || HOSTNAME.test(value)) return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeEndpoint(raw: unknown): WebEndpoint | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const input = raw as Record<string, unknown>;
|
||||
|
||||
const id = typeof input.id === "string" ? input.id.trim() : "";
|
||||
if (!id) return null;
|
||||
|
||||
const label = typeof input.label === "string" ? input.label.trim() : "";
|
||||
if (!label) return null;
|
||||
|
||||
const scheme = input.scheme;
|
||||
if (typeof scheme !== "string" || !SCHEMES.has(scheme)) return null;
|
||||
|
||||
if (!isValidPort(input.port)) return null;
|
||||
|
||||
const path = normalizePath(input.path);
|
||||
if (path === null) return null;
|
||||
|
||||
const access = input.access;
|
||||
if (typeof access !== "string" || !ACCESS_VALUES.has(access)) return null;
|
||||
|
||||
const render = input.render;
|
||||
if (typeof render !== "string" || !RENDER_VALUES.has(render)) return null;
|
||||
|
||||
const isTunnel = access === "tunnel";
|
||||
|
||||
const endpoint: WebEndpoint = {
|
||||
id,
|
||||
label: label.slice(0, MAX_WEB_ENDPOINT_LABEL_LENGTH),
|
||||
scheme: scheme as WebEndpoint["scheme"],
|
||||
port: input.port,
|
||||
path,
|
||||
access: access as WebEndpoint["access"],
|
||||
render: render as WebEndpoint["render"],
|
||||
// Meaningless for a tunnel, whose host component is loopback and already
|
||||
// exempt from certificate checks. Cleared rather than carried so nothing
|
||||
// downstream has to re-derive that.
|
||||
ignoreCert: !isTunnel && input.ignoreCert === true,
|
||||
};
|
||||
|
||||
// Both are tunnel-only. Silently ignored on a direct endpoint rather than
|
||||
// rejected: they describe a forward that a direct endpoint never creates.
|
||||
if (isTunnel) {
|
||||
const bindHost = normalizeBindHost(input.bindHost);
|
||||
if (bindHost === null) return null;
|
||||
if (bindHost !== undefined) endpoint.bindHost = bindHost;
|
||||
|
||||
if (input.localPort !== undefined && input.localPort !== null) {
|
||||
if (!isValidPort(input.localPort)) return null;
|
||||
endpoint.localPort = input.localPort;
|
||||
}
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops any endpoint it refuses rather than rejecting the whole host -- one
|
||||
* bad row must not make a host unsaveable or unlistable. The editor is
|
||||
* responsible for telling the user before that happens
|
||||
* (src/ui/lib/web-endpoint-validation.ts).
|
||||
*/
|
||||
export function normalizeWebEndpoints(raw: unknown): WebEndpoint[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const endpoints: WebEndpoint[] = [];
|
||||
|
||||
for (const candidate of raw) {
|
||||
if (endpoints.length >= MAX_WEB_ENDPOINTS) break;
|
||||
const endpoint = normalizeEndpoint(candidate);
|
||||
if (!endpoint) continue;
|
||||
if (seen.has(endpoint.id)) continue;
|
||||
seen.add(endpoint.id);
|
||||
endpoints.push(endpoint);
|
||||
}
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* Never throws. A malformed stored value yields an empty endpoint list, so a
|
||||
* half-written config cannot take out the whole host listing -- which is what
|
||||
* dockerConfig's bare JSON.parse does today.
|
||||
*/
|
||||
export function parseWebUiConfig(raw: unknown): WebUiConfig {
|
||||
if (raw === undefined || raw === null) return { endpoints: [] };
|
||||
|
||||
let value: unknown = raw;
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
value = JSON.parse(raw);
|
||||
} catch {
|
||||
return { endpoints: [] };
|
||||
}
|
||||
}
|
||||
|
||||
if (!value || typeof value !== "object") return { endpoints: [] };
|
||||
return {
|
||||
endpoints: normalizeWebEndpoints(
|
||||
(value as { endpoints?: unknown }).endpoints,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Null when nothing survives normalization, so the column is cleared. */
|
||||
export function serializeWebUiConfig(config: unknown): string | null {
|
||||
const endpoints = normalizeWebEndpoints(
|
||||
(config as { endpoints?: unknown } | null | undefined)?.endpoints,
|
||||
);
|
||||
if (endpoints.length === 0) return null;
|
||||
return JSON.stringify({ endpoints });
|
||||
}
|
||||
+389
-1066
File diff suppressed because it is too large.
Load diff
@@ -1,13 +1,31 @@
|
||||
const VALID_ACTION_TYPES = [
|
||||
"copy",
|
||||
"paste",
|
||||
"sendControlCode",
|
||||
"sendText",
|
||||
"runSnippet",
|
||||
"nextTab",
|
||||
"previousTab",
|
||||
"openCommandPalette",
|
||||
];
|
||||
import {
|
||||
CORE_KEYBINDING_ACTIONS,
|
||||
validateKeybindingActionParams,
|
||||
type PluginKeybindingActionContribution,
|
||||
} from "@termix/plugin-sdk/manifest";
|
||||
|
||||
export interface DeclaredKeybindingAction extends PluginKeybindingActionContribution {
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
let declaredActions: () => DeclaredKeybindingAction[] = () => [];
|
||||
|
||||
/** Set by the plugin runtime: every installed plugin's declared actions. */
|
||||
export function setKeybindingActionSource(
|
||||
source: () => DeclaredKeybindingAction[],
|
||||
): void {
|
||||
declaredActions = source;
|
||||
}
|
||||
|
||||
/** The declaration a saved action type answers to, if a plugin made one. */
|
||||
export function findKeybindingAction(
|
||||
type: string,
|
||||
): DeclaredKeybindingAction | undefined {
|
||||
return declaredActions().find((action) => action.id === type);
|
||||
}
|
||||
|
||||
const ACTION_TYPE_PATTERN = /^[a-zA-Z][a-zA-Z0-9.-]{0,63}$/;
|
||||
const MAX_LOOSE_VALUE = 65_536;
|
||||
|
||||
export function isValidKeyCombo(combo: unknown): boolean {
|
||||
return (
|
||||
@@ -22,22 +40,32 @@ export function isValidKeyCombo(combo: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A saved action. The shell's own types take no parameters; a type a plugin
|
||||
* declared is checked against its declaration, whether or not the plugin is
|
||||
* running. A type nobody declares (its plugin was removed) is only checked
|
||||
* loosely, so the rest of the list can still be saved, and whichever plugin
|
||||
* claims it later ignores what it cannot use.
|
||||
*/
|
||||
export function isValidKeybindingAction(action: unknown): boolean {
|
||||
if (!action || typeof action !== "object") return false;
|
||||
const type = (action as { type?: unknown }).type;
|
||||
if (typeof type !== "string" || !VALID_ACTION_TYPES.includes(type))
|
||||
if (!action || typeof action !== "object" || Array.isArray(action)) {
|
||||
return false;
|
||||
if (type === "sendText") {
|
||||
return typeof (action as { text?: unknown }).text === "string";
|
||||
}
|
||||
if (type === "sendControlCode") {
|
||||
const code = (action as { controlCode?: unknown }).controlCode;
|
||||
return typeof code === "string" && /^[a-zA-Z]$/.test(code);
|
||||
const record = action as Record<string, unknown>;
|
||||
const type = record.type;
|
||||
if (typeof type !== "string" || !ACTION_TYPE_PATTERN.test(type)) {
|
||||
return false;
|
||||
}
|
||||
if (type === "runSnippet") {
|
||||
return typeof (action as { snippetId?: unknown }).snippetId === "string";
|
||||
}
|
||||
return true;
|
||||
if (CORE_KEYBINDING_ACTIONS.includes(type)) return true;
|
||||
const declared = findKeybindingAction(type);
|
||||
if (declared) return validateKeybindingActionParams(record, declared);
|
||||
return Object.entries(record).every(
|
||||
([, value]) =>
|
||||
value === null ||
|
||||
typeof value === "boolean" ||
|
||||
typeof value === "number" ||
|
||||
(typeof value === "string" && value.length <= MAX_LOOSE_VALUE),
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidKeybinding(entry: unknown): boolean {
|
||||
|
||||
@@ -1,409 +0,0 @@
|
||||
import type { Router } from "express";
|
||||
import type { LDAPProviderConfig } from "../../../types/index.js";
|
||||
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();
|
||||
|
||||
function ldapEscapeFilter(value: string): string {
|
||||
return value.replace(
|
||||
/[\\*()\x00]/g,
|
||||
(c) => `\\${c.charCodeAt(0).toString(16).padStart(2, "0")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function createLDAPClient(
|
||||
host: string,
|
||||
port: number,
|
||||
useTLS: boolean,
|
||||
): ldap.Client {
|
||||
const url = `${useTLS ? "ldaps" : "ldap"}://${host}:${port}`;
|
||||
return ldap.createClient({
|
||||
url,
|
||||
tlsOptions: useTLS ? { rejectUnauthorized: false } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function ldapBind(
|
||||
client: ldap.Client,
|
||||
dn: string,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.bind(dn, password, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function ldapSearch(
|
||||
client: ldap.Client,
|
||||
base: string,
|
||||
filter: string,
|
||||
attributes: string[],
|
||||
): Promise<ldap.SearchEntry[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const entries: ldap.SearchEntry[] = [];
|
||||
client.search(base, { filter, attributes, scope: "sub" }, (err, res) => {
|
||||
if (err) return reject(err);
|
||||
res.on("searchEntry", (entry) => entries.push(entry));
|
||||
res.on("error", reject);
|
||||
res.on("end", () => resolve(entries));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function ldapUnbind(client: ldap.Client): void {
|
||||
try {
|
||||
client.unbind();
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
|
||||
export function registerLDAPAuthRoutes(router: Router): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/ldap/login:
|
||||
* post:
|
||||
* summary: LDAP login
|
||||
* description: Authenticates a user against an LDAP server.
|
||||
* tags:
|
||||
* - SSO
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* providerId:
|
||||
* type: integer
|
||||
* username:
|
||||
* type: string
|
||||
* password:
|
||||
* type: string
|
||||
* rememberMe:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Login successful.
|
||||
* 400:
|
||||
* description: Missing fields.
|
||||
* 401:
|
||||
* description: Invalid credentials.
|
||||
* 403:
|
||||
* description: User not allowed.
|
||||
* 404:
|
||||
* description: Provider not found.
|
||||
*/
|
||||
router.post("/ldap/login", async (req, res) => {
|
||||
const { providerId, username, password, rememberMe } = req.body as {
|
||||
providerId: number;
|
||||
username: string;
|
||||
password: string;
|
||||
rememberMe?: boolean;
|
||||
};
|
||||
|
||||
if (!providerId || !username || !password) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "providerId, username, and password are required" });
|
||||
}
|
||||
|
||||
try {
|
||||
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) {
|
||||
authLogger.error("Failed to load LDAP provider", err);
|
||||
return res.status(500).json({ error: "Failed to load LDAP provider" });
|
||||
}
|
||||
|
||||
const providerResult = await loadProviderConfig(providerId);
|
||||
if (!providerResult) {
|
||||
return res.status(404).json({ error: "LDAP provider not found" });
|
||||
}
|
||||
const config = providerResult.config as unknown as LDAPProviderConfig;
|
||||
|
||||
if (
|
||||
!config.host ||
|
||||
!config.bindDN ||
|
||||
!config.userSearchBase ||
|
||||
!config.userSearchFilter
|
||||
) {
|
||||
return res.status(500).json({ error: "LDAP provider is misconfigured" });
|
||||
}
|
||||
|
||||
const serviceClient = createLDAPClient(
|
||||
config.host,
|
||||
config.port || 389,
|
||||
config.useTLS || false,
|
||||
);
|
||||
try {
|
||||
await ldapBind(serviceClient, config.bindDN, config.bindPassword);
|
||||
|
||||
const filter = config.userSearchFilter.replace(
|
||||
/\{\{username\}\}/g,
|
||||
ldapEscapeFilter(username),
|
||||
);
|
||||
const attrList = [
|
||||
config.usernameAttribute || "uid",
|
||||
config.displayNameAttribute || "cn",
|
||||
"mail",
|
||||
"email",
|
||||
];
|
||||
const entries = await ldapSearch(
|
||||
serviceClient,
|
||||
config.userSearchBase,
|
||||
filter,
|
||||
attrList,
|
||||
);
|
||||
|
||||
if (entries.length === 0) {
|
||||
authLogger.warn("LDAP user not found", {
|
||||
operation: "ldap_login",
|
||||
username,
|
||||
});
|
||||
return res.status(401).json({ error: "Invalid username or password" });
|
||||
}
|
||||
|
||||
const userEntry = entries[0];
|
||||
const userDN = userEntry.dn.toString();
|
||||
|
||||
const getAttr = (key: string): string => {
|
||||
const attr = userEntry.attributes.find((a) => a.type === key);
|
||||
return attr
|
||||
? Array.isArray(attr.values)
|
||||
? attr.values[0]
|
||||
: String(attr.values)
|
||||
: "";
|
||||
};
|
||||
|
||||
const ldapIdentifier =
|
||||
getAttr(config.usernameAttribute || "uid") || username;
|
||||
const displayName =
|
||||
getAttr(config.displayNameAttribute || "cn") || username;
|
||||
const email = getAttr("mail") || getAttr("email") || "";
|
||||
|
||||
if (config.allowedUsers) {
|
||||
if (
|
||||
!isOIDCUserAllowed(
|
||||
config.allowedUsers,
|
||||
ldapIdentifier,
|
||||
email || undefined,
|
||||
)
|
||||
) {
|
||||
authLogger.warn("LDAP user not in allowed list", {
|
||||
operation: "ldap_user_not_allowed",
|
||||
ldapIdentifier,
|
||||
});
|
||||
return res.status(403).json({ error: "User not allowed" });
|
||||
}
|
||||
}
|
||||
|
||||
// Re-bind with user's own credentials to verify password
|
||||
const userClient = createLDAPClient(
|
||||
config.host,
|
||||
config.port || 389,
|
||||
config.useTLS || false,
|
||||
);
|
||||
try {
|
||||
await ldapBind(userClient, userDN, password);
|
||||
} catch {
|
||||
authLogger.warn("LDAP bind failed - wrong password", {
|
||||
operation: "ldap_login",
|
||||
ldapIdentifier,
|
||||
});
|
||||
return res.status(401).json({ error: "Invalid username or password" });
|
||||
} finally {
|
||||
ldapUnbind(userClient);
|
||||
}
|
||||
|
||||
// Admin group check
|
||||
let isAdmin = false;
|
||||
if (config.adminGroup && config.groupSearchBase) {
|
||||
try {
|
||||
const groupFilter = `(member=${ldapEscapeFilter(userDN)})`;
|
||||
const groupEntries = await ldapSearch(
|
||||
serviceClient,
|
||||
config.groupSearchBase,
|
||||
groupFilter,
|
||||
["cn", "dn"],
|
||||
);
|
||||
isAdmin = groupEntries.some((g) => {
|
||||
const cn = g.attributes.find((a) => a.type === "cn");
|
||||
const cnVal = cn
|
||||
? Array.isArray(cn.values)
|
||||
? cn.values[0]
|
||||
: String(cn.values)
|
||||
: "";
|
||||
return (
|
||||
cnVal === config.adminGroup ||
|
||||
g.dn.toString() === config.adminGroup
|
||||
);
|
||||
});
|
||||
} catch (groupErr) {
|
||||
authLogger.warn("LDAP group check failed", {
|
||||
operation: "ldap_group_check",
|
||||
error: groupErr,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ldapUnbind(serviceClient);
|
||||
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
const oidcIdentifier = `ldap:${providerId}:${ldapIdentifier}`;
|
||||
const userRepository = createCurrentUserRepository();
|
||||
|
||||
let existingUser =
|
||||
await userRepository.findByOidcIdentifier(oidcIdentifier);
|
||||
|
||||
let userId: string;
|
||||
if (!existingUser) {
|
||||
let autoProvision = false;
|
||||
try {
|
||||
autoProvision = await createCurrentSettingsRepository().getBoolean(
|
||||
"oidc_auto_provision",
|
||||
false,
|
||||
);
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
if (!autoProvision)
|
||||
autoProvision =
|
||||
(process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() ===
|
||||
"true";
|
||||
|
||||
const isFirst = (await userRepository.countAll()) === 0;
|
||||
|
||||
if (!isFirst && !autoProvision) {
|
||||
return res.status(403).json({ error: "Registration is disabled" });
|
||||
}
|
||||
|
||||
userId = nanoid();
|
||||
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";
|
||||
await createCurrentRoleRepository().assignRoleNameToUser({
|
||||
userId,
|
||||
roleName: defaultRoleName,
|
||||
grantedBy: userId,
|
||||
});
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionDurationMs =
|
||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: 24 * 60 * 60 * 1000;
|
||||
await authManager.registerOIDCUser(userId, sessionDurationMs);
|
||||
} catch (encryptionError) {
|
||||
await userRepository.delete(userId);
|
||||
authLogger.error(
|
||||
"Failed to setup LDAP user encryption",
|
||||
encryptionError,
|
||||
);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to setup user security" });
|
||||
}
|
||||
|
||||
existingUser = await userRepository.findById(userId);
|
||||
if (!existingUser) {
|
||||
throw new Error("Created LDAP user could not be loaded");
|
||||
}
|
||||
} else {
|
||||
userId = existingUser.id;
|
||||
|
||||
// Sync admin status from group membership
|
||||
if (config.adminGroup && !!existingUser.isAdmin !== isAdmin) {
|
||||
existingUser =
|
||||
(await userRepository.update(userId, { isAdmin })) ?? existingUser;
|
||||
try {
|
||||
const newRoleName = isAdmin ? "admin" : "user";
|
||||
const oldRoleName = isAdmin ? "user" : "admin";
|
||||
await createCurrentRoleRepository().switchUserRoleName({
|
||||
userId,
|
||||
addRoleName: newRoleName,
|
||||
removeRoleName: oldRoleName,
|
||||
grantedBy: userId,
|
||||
});
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
// Update display name if not dual-auth
|
||||
const isDualAuth =
|
||||
existingUser.passwordHash && existingUser.passwordHash.trim() !== "";
|
||||
if (!isDualAuth && existingUser.username !== displayName) {
|
||||
existingUser =
|
||||
(await userRepository.update(userId, { username: displayName })) ??
|
||||
existingUser;
|
||||
}
|
||||
}
|
||||
|
||||
const userRecord = existingUser;
|
||||
try {
|
||||
await authManager.authenticateOIDCUser(userRecord.id, deviceInfo.type);
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
|
||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||
deviceType: deviceInfo.type,
|
||||
deviceInfo: deviceInfo.deviceInfo,
|
||||
rememberMe: rememberMe || false,
|
||||
});
|
||||
|
||||
authLogger.success("LDAP login successful", {
|
||||
operation: "ldap_login_complete",
|
||||
userId: userRecord.id,
|
||||
username: userRecord.username,
|
||||
});
|
||||
|
||||
const maxAge =
|
||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: rememberMe
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: 24 * 60 * 60 * 1000;
|
||||
|
||||
res.clearCookie("jwt", authManager.getClearCookieOptions(req));
|
||||
return res
|
||||
.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge))
|
||||
.json({ success: true, message: "Login successful" });
|
||||
} catch (err) {
|
||||
ldapUnbind(serviceClient);
|
||||
authLogger.error("LDAP login failed", err);
|
||||
return res.status(500).json({ error: "LDAP authentication failed" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import express from "express";
|
||||
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();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /network-topology:
|
||||
* get:
|
||||
* summary: Get network topology for authenticated user
|
||||
* description: Retrieves the saved network topology graph (nodes and edges) for the current user. Returns null if no topology exists.
|
||||
* tags:
|
||||
* - Network Topology
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Network topology retrieved successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* nullable: true
|
||||
* properties:
|
||||
* nodes:
|
||||
* type: array
|
||||
* description: Array of graph nodes (hosts and groups)
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* data:
|
||||
* type: object
|
||||
* description: Node data including id, label, type, etc.
|
||||
* position:
|
||||
* type: object
|
||||
* description: Node position coordinates
|
||||
* properties:
|
||||
* x:
|
||||
* type: number
|
||||
* y:
|
||||
* type: number
|
||||
* edges:
|
||||
* type: array
|
||||
* description: Array of graph edges (connections)
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* data:
|
||||
* type: object
|
||||
* description: Edge data including source and target node ids
|
||||
* 401:
|
||||
* description: User not authenticated
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.get(
|
||||
"/",
|
||||
authenticateJWT,
|
||||
async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "User not authenticated" });
|
||||
}
|
||||
|
||||
const record =
|
||||
await createCurrentNetworkTopologyRepository().findByUserId(userId);
|
||||
|
||||
if (record) {
|
||||
const topologyStr = record.topology;
|
||||
const topology = topologyStr ? JSON.parse(topologyStr) : null;
|
||||
return res.json(topology);
|
||||
} else {
|
||||
return res.json(null);
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to fetch network topology", error, {
|
||||
operation: "network_topology_fetch",
|
||||
userId: (req as AuthenticatedRequest).userId,
|
||||
});
|
||||
return res.status(500).json({
|
||||
error: "Failed to fetch network topology",
|
||||
details: (error as Error).message,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /network-topology:
|
||||
* post:
|
||||
* summary: Save network topology for authenticated user
|
||||
* description: Saves or updates the network topology graph. Uses upsert logic - creates new record if none exists, updates existing record otherwise.
|
||||
* tags:
|
||||
* - Network Topology
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - topology
|
||||
* properties:
|
||||
* topology:
|
||||
* type: object
|
||||
* description: Network topology data containing nodes and edges
|
||||
* properties:
|
||||
* nodes:
|
||||
* type: array
|
||||
* description: Array of graph nodes (hosts and groups)
|
||||
* edges:
|
||||
* type: array
|
||||
* description: Array of graph edges (connections)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Topology saved successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* 400:
|
||||
* description: Invalid topology data
|
||||
* 401:
|
||||
* description: User not authenticated
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.post(
|
||||
"/",
|
||||
authenticateJWT,
|
||||
async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "User not authenticated" });
|
||||
}
|
||||
|
||||
const { topology } = req.body;
|
||||
if (!topology) {
|
||||
return res.status(400).json({ error: "Topology data is required" });
|
||||
}
|
||||
|
||||
const topologyStr =
|
||||
typeof topology === "string" ? topology : JSON.stringify(topology);
|
||||
|
||||
await createCurrentNetworkTopologyRepository().upsertForUser(
|
||||
userId,
|
||||
topologyStr,
|
||||
);
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to save network topology", error, {
|
||||
operation: "network_topology_save",
|
||||
userId: (req as AuthenticatedRequest).userId,
|
||||
});
|
||||
return res.status(500).json({
|
||||
error: "Failed to save network topology",
|
||||
details: (error as Error).message,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -2,12 +2,8 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { sessionManager } from "../../hosts/terminal/session-manager.js";
|
||||
import {
|
||||
getCurrentSettingValue,
|
||||
createCurrentOpenTabRepository,
|
||||
createCurrentSessionShareRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import { liveTerminalSessions } from "../../hosts/live-terminal-sessions.js";
|
||||
import { createCurrentOpenTabRepository } from "../repositories/factory.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
@@ -26,27 +22,10 @@ const authenticateJWT = authManager.createAuthMiddleware();
|
||||
*/
|
||||
const DEFAULT_TAB_TTL_MINUTES = 30;
|
||||
|
||||
// Tabs live as long as the terminal keeps a detached session.
|
||||
function getTabTtlMs(): number {
|
||||
try {
|
||||
const value = getCurrentSettingValue("terminal_session_timeout_minutes");
|
||||
if (value) {
|
||||
const minutes = parseInt(value, 10);
|
||||
if (!isNaN(minutes) && minutes > 0) return minutes * 60_000;
|
||||
}
|
||||
} catch {
|
||||
// DB not available, use default
|
||||
}
|
||||
return DEFAULT_TAB_TTL_MINUTES * 60_000;
|
||||
}
|
||||
|
||||
// Legacy tab types that were renamed. Normalize on read so previously saved
|
||||
// tabs still restore to the correct (renamed) tab type.
|
||||
const LEGACY_TAB_TYPE_MAP: Record<string, string> = {
|
||||
stats: "host-metrics",
|
||||
};
|
||||
|
||||
function normalizeTabType(tabType: string): string {
|
||||
return LEGACY_TAB_TYPE_MAP[tabType] ?? tabType;
|
||||
const minutes = liveTerminalSessions.idleTimeoutMinutes();
|
||||
return (minutes && minutes > 0 ? minutes : DEFAULT_TAB_TTL_MINUTES) * 60_000;
|
||||
}
|
||||
|
||||
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
@@ -57,9 +36,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
userId,
|
||||
cutoff,
|
||||
);
|
||||
return res.json(
|
||||
tabs.map((tab) => ({ ...tab, tabType: normalizeTabType(tab.tabType) })),
|
||||
);
|
||||
return res.json(tabs);
|
||||
} catch (e) {
|
||||
databaseLogger.error("Failed to get open tabs", e, {
|
||||
operation: "get_open_tabs",
|
||||
@@ -275,18 +252,44 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /open-tabs/active-sessions:
|
||||
* /open-tabs/session-timeout:
|
||||
* get:
|
||||
* summary: Get all active backend sessions for the current user
|
||||
* description: >
|
||||
* Returns live terminal sessions from the session manager, both sessions the
|
||||
* caller owns and SSH sessions shared to the caller by another user (via
|
||||
* an in-app session share). Used by the Active Connections panel and tab restore logic.
|
||||
* summary: Get how long a detached session is kept
|
||||
* description: The terminal's idle timeout for detached sessions, which is also how long a saved open tab lives. 30 while the terminal is off.
|
||||
* tags:
|
||||
* - Open Tabs
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of active sessions (own and shared-with-me).
|
||||
* description: The timeout in minutes.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* minutes:
|
||||
* type: integer
|
||||
*/
|
||||
router.get(
|
||||
"/session-timeout",
|
||||
authenticateJWT,
|
||||
(_req: Request, res: Response) => {
|
||||
res.json({ minutes: getTabTtlMs() / 60_000 });
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /open-tabs/active-sessions:
|
||||
* get:
|
||||
* summary: Get all active backend sessions for the current user
|
||||
* description: >
|
||||
* Returns the live terminal sessions the caller owns. Used by the
|
||||
* Active Connections panel and tab restore logic.
|
||||
* tags:
|
||||
* - Open Tabs
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of the caller's active sessions.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
@@ -306,17 +309,6 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||
* type: boolean
|
||||
* createdAt:
|
||||
* type: number
|
||||
* isOwnSession:
|
||||
* type: boolean
|
||||
* sharedByUsername:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* permissionLevel:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* shareId:
|
||||
* type: string
|
||||
* nullable: true
|
||||
*/
|
||||
router.get(
|
||||
"/active-sessions",
|
||||
@@ -324,45 +316,16 @@ router.get(
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const ownSessions = sessionManager.getUserSessions(userId);
|
||||
const ownSessions = liveTerminalSessions.listForUser(userId);
|
||||
const result = ownSessions.map((s) => ({
|
||||
sessionId: s.id,
|
||||
hostId: s.hostId,
|
||||
hostName: s.hostName,
|
||||
tabInstanceId: s.attachedTabInstanceId ?? s.tabInstanceId ?? null,
|
||||
tabInstanceId: s.tabInstanceId,
|
||||
isConnected: s.isConnected,
|
||||
createdAt: s.createdAt,
|
||||
isOwnSession: true,
|
||||
sharedByUsername: null as string | null,
|
||||
permissionLevel: null as string | null,
|
||||
shareId: null as string | null,
|
||||
}));
|
||||
|
||||
const sharedWithMe =
|
||||
await createCurrentSessionShareRepository().findSharesTargetingUser(
|
||||
userId,
|
||||
);
|
||||
for (const share of sharedWithMe) {
|
||||
if (share.protocol !== "ssh") continue;
|
||||
const sharedSession = sessionManager.getSession(share.sessionId);
|
||||
if (!sharedSession || !sharedSession.isConnected) continue;
|
||||
result.push({
|
||||
sessionId: sharedSession.id,
|
||||
hostId: sharedSession.hostId,
|
||||
hostName: sharedSession.hostName,
|
||||
tabInstanceId:
|
||||
sharedSession.attachedTabInstanceId ??
|
||||
sharedSession.tabInstanceId ??
|
||||
null,
|
||||
isConnected: sharedSession.isConnected,
|
||||
createdAt: sharedSession.createdAt,
|
||||
isOwnSession: false,
|
||||
sharedByUsername: share.ownerUsername,
|
||||
permissionLevel: share.permissionLevel,
|
||||
shareId: share.id,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json(result);
|
||||
} catch (e) {
|
||||
databaseLogger.error("Failed to get active sessions", e, {
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
|
||||
export function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Replicates openpubkey's client/choosers/web_chooser.go IssuerToName().
|
||||
// OPKSSH's /select handler keys its providerMap by this derived name, NOT by the
|
||||
// `alias` field in config.yml. We need the same mapping so we can normalize any
|
||||
// `op=` query param we receive (which can be alias, issuer with protocol, or
|
||||
// issuer without protocol depending on client version) to what OPKSSH expects.
|
||||
function opksshIssuerToName(issuer: string): string | null {
|
||||
if (!issuer) return null;
|
||||
const withScheme =
|
||||
issuer.startsWith("http://") || issuer.startsWith("https://")
|
||||
? issuer
|
||||
: `https://${issuer}`;
|
||||
if (withScheme.startsWith("https://accounts.google.com")) return "google";
|
||||
if (withScheme.startsWith("https://login.microsoftonline.com"))
|
||||
return "azure";
|
||||
if (withScheme.startsWith("https://gitlab.com")) return "gitlab";
|
||||
if (withScheme.startsWith("https://issuer.hello.coop")) return "hello";
|
||||
if (withScheme.startsWith("https://")) {
|
||||
const host = withScheme.slice("https://".length).split("/")[0];
|
||||
return host || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeSelectOpParam(
|
||||
rawOp: string,
|
||||
providers: Array<{ alias: string; issuer: string }>,
|
||||
): string {
|
||||
if (!rawOp) return rawOp;
|
||||
const knownNames = new Set(
|
||||
providers
|
||||
.map((p) => opksshIssuerToName(p.issuer))
|
||||
.filter((n): n is string => typeof n === "string" && n.length > 0),
|
||||
);
|
||||
if (knownNames.has(rawOp)) return rawOp;
|
||||
|
||||
const derivedFromRaw = opksshIssuerToName(rawOp);
|
||||
if (derivedFromRaw && knownNames.has(derivedFromRaw)) return derivedFromRaw;
|
||||
|
||||
const matchByAlias = providers.find((p) => p.alias === rawOp);
|
||||
if (matchByAlias) {
|
||||
const name = opksshIssuerToName(matchByAlias.issuer);
|
||||
if (name) return name;
|
||||
}
|
||||
|
||||
return rawOp;
|
||||
}
|
||||
|
||||
type OpksshErrorPageOptions = {
|
||||
title: string;
|
||||
heading: string;
|
||||
message: string;
|
||||
details?: string;
|
||||
requestId?: string;
|
||||
statusCode?: number;
|
||||
};
|
||||
|
||||
export function renderOpksshErrorPage(opts: OpksshErrorPageOptions): string {
|
||||
const title = escapeHtml(opts.title);
|
||||
const heading = escapeHtml(opts.heading);
|
||||
const message = escapeHtml(opts.message);
|
||||
const detailsBlock = opts.details
|
||||
? `<pre class="details">${escapeHtml(opts.details)}</pre>`
|
||||
: "";
|
||||
const requestIdBlock = opts.requestId
|
||||
? `<p class="request-id">Request ID: ${escapeHtml(opts.requestId)}</p>`
|
||||
: "";
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>${title}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: #18181b;
|
||||
color: #fafafa;
|
||||
padding: 1rem;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
background: #27272a;
|
||||
padding: 3rem 2rem;
|
||||
border-radius: 0.625rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
max-width: 720px;
|
||||
width: 100%;
|
||||
}
|
||||
h1 {
|
||||
color: #fafafa;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
p {
|
||||
color: #9ca3af;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
p + p { margin-top: 0.5rem; }
|
||||
.details {
|
||||
color: #d4d4d8;
|
||||
text-align: left;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
margin-top: 1.25rem;
|
||||
padding: 0.875rem 1rem;
|
||||
background: #0f0f11;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 0.5rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.request-id {
|
||||
color: #6b7280;
|
||||
font-size: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>${heading}</h1>
|
||||
<p>${message}</p>
|
||||
${detailsBlock}
|
||||
${requestIdBlock}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function rewriteOPKSSHHtml(
|
||||
html: string,
|
||||
requestId: string,
|
||||
routePrefix: "opkssh-chooser" | "opkssh-callback",
|
||||
): string {
|
||||
const basePath = `/host/${routePrefix}/${requestId}`;
|
||||
const localHostPattern = "(?:localhost|127\\.0\\.0\\.1)";
|
||||
|
||||
const attrPatterns = ["action", "href", "src", "formaction"];
|
||||
for (const attr of attrPatterns) {
|
||||
html = html.replace(
|
||||
new RegExp(`${attr}="(/[^"]*)`, "g"),
|
||||
`${attr}="${basePath}$1`,
|
||||
);
|
||||
html = html.replace(
|
||||
new RegExp(`${attr}='(/[^']*)`, "g"),
|
||||
`${attr}='${basePath}$1`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const attr of ["href", "action", "src", "formaction"]) {
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`${attr}=["']?http:\\/\\/${localHostPattern}:\\d+\\/([^"'\\s]*)`,
|
||||
"g",
|
||||
),
|
||||
`${attr}="${basePath}/$1`,
|
||||
);
|
||||
}
|
||||
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(window\\.location\\.href\\s*=\\s*["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
|
||||
"g",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(window\\.location\\s*=\\s*["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
|
||||
"g",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(fetch\\(["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
|
||||
"g",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(location\\.assign\\(["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["']\\))`,
|
||||
"g",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(location\\.replace\\(["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["']\\))`,
|
||||
"g",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
|
||||
// XMLHttpRequest.open("GET", "http://localhost:PORT/path", ...)
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(\\.open\\(["']\\w+["']\\s*,\\s*["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
|
||||
"g",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(<meta[^>]+http-equiv=["']refresh["'][^>]+content=["'][^;]+;\\s*url=)http:\\/\\/${localHostPattern}:\\d+\\/([^"']+)(["'][^>]*>)`,
|
||||
"gi",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(data-[\\w-]+=["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
|
||||
"g",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
|
||||
const baseTag = `<base href="${basePath}/">`;
|
||||
|
||||
if (html.includes("<base")) {
|
||||
sshLogger.info("Replacing existing base tag", {
|
||||
operation: "opkssh_html_rewrite_base_tag",
|
||||
requestId,
|
||||
basePath,
|
||||
});
|
||||
html = html.replace(/<base[^>]*>/i, baseTag);
|
||||
} else if (html.includes("<head>")) {
|
||||
sshLogger.info("Inserting base tag into head", {
|
||||
operation: "opkssh_html_rewrite_base_tag_insert",
|
||||
requestId,
|
||||
basePath,
|
||||
});
|
||||
html = html.replace(/<head>/i, `<head>${baseTag}`);
|
||||
} else {
|
||||
sshLogger.warn("No <head> tag found, wrapping HTML", {
|
||||
operation: "opkssh_html_rewrite_no_head",
|
||||
requestId,
|
||||
htmlLength: html.length,
|
||||
htmlPreview: html.substring(0, 200),
|
||||
});
|
||||
html = `<!DOCTYPE html><html><head>${baseTag}</head><body>${html}</body></html>`;
|
||||
}
|
||||
|
||||
sshLogger.info("HTML rewrite complete", {
|
||||
operation: "opkssh_html_rewrite_complete",
|
||||
requestId,
|
||||
routePrefix,
|
||||
hasBaseTag: html.includes("<base href="),
|
||||
staticAssetCount: (html.match(/\/static\//g) || []).length,
|
||||
});
|
||||
|
||||
return html;
|
||||
}
|
||||
Loaded 100 of 4405 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user