mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-30 02:41:34 +00:00
release-2.7.0 (#1264)
* feat: redesign host/credential sidebars with synced preferences and manual drag-to-reorder * chore: run format * chore(deps-dev): bump @types/pg in the dev-patch-updates group (#1162) Bumps the dev-patch-updates group with 1 update: [@types/pg](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/pg). Updates `@types/pg` from 8.20.0 to 8.20.3 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/pg) --- updated-dependencies: - dependency-name: "@types/pg" dependency-version: 8.20.3 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-dev): bump the dev-minor-updates group with 4 updates (#1163) Bumps the dev-minor-updates group with 4 updates: [react-hook-form](https://github.com/react-hook-form/react-hook-form), [react-icons](https://github.com/react-icons/react-icons), [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite). Updates `react-hook-form` from 7.79.0 to 7.84.0 - [Release notes](https://github.com/react-hook-form/react-hook-form/releases) - [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md) - [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.79.0...v7.84.0) Updates `react-icons` from 5.6.0 to 5.7.0 - [Release notes](https://github.com/react-icons/react-icons/releases) - [Commits](https://github.com/react-icons/react-icons/compare/v5.6.0...v5.7.0) Updates `typescript-eslint` from 8.61.1 to 8.66.0 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.66.0/packages/typescript-eslint) Updates `vite` from 8.0.16 to 8.2.0 - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/create-vite@8.2.0/packages/vite) --- updated-dependencies: - dependency-name: react-hook-form dependency-version: 7.84.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: react-icons dependency-version: 5.7.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: typescript-eslint dependency-version: 8.66.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: vite dependency-version: 8.2.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-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 3 updates (#1164) Bumps the prod-patch-updates group with 3 updates: [jose](https://github.com/panva/jose), [js-yaml](https://github.com/nodeca/js-yaml) and [nanoid](https://github.com/ai/nanoid). Updates `jose` from 6.2.7 to 6.2.8 - [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.7...v6.2.8) Updates `js-yaml` from 5.2.2 to 5.2.3 - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/5.2.2...5.2.3) Updates `nanoid` from 6.0.0 to 6.0.1 - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/6.0.0...6.0.1) --- updated-dependencies: - dependency-name: jose dependency-version: 6.2.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates - dependency-name: js-yaml dependency-version: 5.2.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates - dependency-name: nanoid dependency-version: 6.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump undici in the prod-minor-updates group (#1165) Bumps the prod-minor-updates group with 1 update: [undici](https://github.com/nodejs/undici). Updates `undici` from 8.9.0 to 8.10.0 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v8.9.0...v8.10.0) --- updated-dependencies: - dependency-name: undici dependency-version: 8.10.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates ... 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 major-updates group with 2 updates (#1166) Bumps the major-updates group with 2 updates: [@types/better-sqlite3](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/better-sqlite3) and [jsdom](https://github.com/jsdom/jsdom). Updates `@types/better-sqlite3` from 7.6.13 to 9.6.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/better-sqlite3) Updates `jsdom` from 29.1.1 to 30.0.1 - [Release notes](https://github.com/jsdom/jsdom/releases) - [Commits](https://github.com/jsdom/jsdom/compare/v29.1.1...v30.0.1) --- updated-dependencies: - dependency-name: "@types/better-sqlite3" dependency-version: 9.6.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: jsdom dependency-version: 30.0.1 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix: stop resyncAutoIncrement failing on tables without an id column (#1173) The Postgres branch asked pg_get_serial_sequence(table, 'id') about every table a fixture had inserted into. That function raises 42703 when the column does not exist, rather than returning null, so any seed touching a table keyed on something else took down the fixture. host_sidebar_preferences is keyed on user_id and has no id at all, which is why the Postgres job on dev-2.7.0 fails for every pull request. Drive the lookup from information_schema so a missing id column yields no row instead of an error. A text primary key still returns a null sequence and is still skipped, as before. * chore: install the git hooks that were already configured (#1174) husky, lint-staged, commitlint and their config have been in the repo since v1.8.0 (#429): .husky/pre-commit runs lint-staged, .husky/commit-msg runs commitlint, the lint-staged globs are in package.json and the commitlint rules in .commitlintrc.json. None of it has ever run. husky only takes effect once it sets core.hooksPath, and that happens in the prepare lifecycle script, which the package did not define -- so every clone installed the tooling and left the hooks unwired. That is why formatting keeps failing in CI rather than locally: three of the four open pull requests fail lint-and-build on prettier alone, touching between one and five files each, and the check is the first place anyone finds out. prepare falls back to true so a checkout without a .git directory cannot break installation. The Docker build passes --ignore-scripts, so it never runs this at all. Also pin the Prettier extension to the repo's own copy via prettier.prettierPath, and let .vscode/settings.json out of .gitignore so it applies to everyone. The extension bundles its own prettier otherwise, which formats to a different version's rules than the one CI enforces. * fix: derive the ssh_credentials rebuild from the live schema (#1172) The startup rebuild that drops the old username NOT NULL constraint restated the table's columns as a literal and then copied rows with INSERT INTO temp SELECT <every live column>. The table has gained columns since that literal was written — cert_public_key, pin, sort_order and sync_id are all added by addColumnIfNotExists before the rebuild runs — so the destination was narrower than the source. SQLite rejected the INSERT on a column count mismatch, the error was swallowed as a warning, and the constraint survived every restart. Read the CREATE TABLE statement back from sqlite_master and rewrite just the table name and the username constraint, so the replacement table cannot fall behind the real one. Copy rows by explicit column name rather than positionally, and replay the table's indexes, which DROP TABLE would otherwise take with it along with the sync_id uniqueness. * fix: make audit_logs.user_id nullable on fresh SQLite installs (#1171) The audit trail is meant to outlive the account it belongs to: deleting a user nulls user_id and keeps username for attribution. schema.ts, the Drizzle migrations and AuditLogRepository.anonymizeByUserId were all written against that, but the runtime bootstrap still created user_id TEXT NOT NULL. A second CREATE TABLE IF NOT EXISTS further down migrateSchema() had the correct nullable column, but it can never run — the primary bootstrap has already created the table, so IF NOT EXISTS is a no-op. Every fresh install therefore got the old constraint, and user deletion failed with "NOT NULL constraint failed: audit_logs.user_id" for any account that had logged in at least once, via both the admin delete path and the OIDC account-link cleanup. Fix the primary bootstrap, and rebuild the table on existing databases using the same pattern already used for ssh_credentials.username, since SQLite cannot ALTER a column. * fix: key the sync upsert on the row it just looked up (#1175) A sync push locates the stored row twice -- once to decide insert vs update, once to write it -- and the two lookups were spelled out separately. Only the read knew about singleton entities; the write always keyed on table.id. userPreferences is the only singleton, and user_preferences is the one synced table with no id column: its primary key is user_id. table.id was therefore undefined, and drizzle emitted a comparison with nothing on its left: ( = ? and "user_preferences"."user_id" = ?) The insert branch was unaffected, so the first push of preferences succeeded and every push after it -- the steady state -- failed with SqliteError: near "=": syntax error. Preference sync never converged, and both sides ship the same handler, so the desktop's embedded backend failed identically. Extract the lookup into locateSyncRow() and use it for the read, the update and the tombstone delete, so the three cannot drift apart again. The tombstone path already handled singletons correctly; it now shares the one expression rather than keeping a third copy of it. * fix: refuse an SSH connection whose host id resolves elsewhere (#1176) A client identifies a host by the numeric row id of the database it is displaying. With the desktop connection origin set to "Remote server", that id is resolved against the sync server's ssh_data instead, and the two autoincrement sequences need not line up -- they diverge as soon as each side accumulates inserts and deletes in a different order. resolveHostById() then returns whichever row owns that id here, and the handler takes the address, the credentials, the jump hosts and the stored host key from it. The session opens on a machine the user did not pick, while the host list, host details and export all keep showing the right one. Commands run on the wrong server, a host key mismatch is reported for the wrong reason, and anything typed at the prompt goes to the wrong place. Compare the resolved address against the one the client sent, and refuse when they disagree. Checking at the point the row is loaded covers every use of it rather than each site separately. Addresses are compared with brackets stripped and casing folded, so an IPv6 literal or a hostname written differently is not treated as a different machine; when the server has no address stored, the client's own details are used as before. This stops the wrong-machine session. It does not make delegated connections work when the ids have drifted -- that needs the host to be addressed by syncId across the boundary, which the connection protocol does not currently carry. * fix: refuse SFTP and Docker console on a mismatched host id too (#1177) The wrong-machine guard added for SSH covered one of the paths that resolve a client-supplied host id against this server's ssh_data. The file manager and the Docker console take the same id from the same client and dial whatever row owns it here. The file manager then browses, edits and deletes files on that machine, and the Docker console attaches to its daemon -- both while the UI shows the host the user actually picked. Reuse hostAddressMismatch at each point the row is loaded. The two file manager sites sit inside "failed to resolve credentials, carry on" handlers, so the refusal is a distinct error type those catches rethrow; swallowing it would resume the connection this is meant to stop. The Docker console reports over its socket, as it does for every other refusal. The user-facing wording now lives next to the check instead of being written out at each site. Still uncovered, and not fixable this way: file-manager's transfer session, jump-host-chain and the proxmox routes resolve an id with no client-supplied address to compare it against. Those need the host to be addressed by syncId across the boundary. * feat: address hosts by syncId when a connection is delegated (#1178) A numeric host id belongs to the database that produced it. The desktop app lists hosts from its embedded database and names them by row id, so when a connection is delegated to a sync server that id is resolved against a different table, whose autoincrement sequence has no reason to agree. The row it lands on is a different machine, and it supplies the address, the credentials, the jump hosts and the stored host key. #1176 and #1177 made that refuse rather than connect. Refusing is right, but it leaves "Remote server" unusable once the ids have drifted, which is the state the reporter was in. syncId already names a host identically on both sides -- remote sync relies on it, ssh_data.sync_id is unique, and the API already returns it. It just never reached the backend: hostToSSHHost() builds its result field by field and dropped it. Carry it through, and resolve with it when it is present: resolveHostBySyncId(syncId, userId) // translate, then reuse -> findHostIdBySyncId(syncId) // this database's own row id -> resolveHostById(hostId, userId) // permissions, decryption, audit The translation is deliberately not scoped to a user -- sync_id is unique across the table and a shared host belongs to someone else -- so access stays with the permission check in the id-based path, which the new tests cover. An unknown syncId resolves to nothing rather than falling back to the numeric id: an unknown host is precisely where guessing picks the wrong machine. Clients that send no syncId are unchanged, address comparison included, so an older desktop keeps its safety net instead of breaking. * fix(homepage): make the System Overview update indicator able to fire (#1168) The widget's "Update available" row and orange version text were unreachable, for two independent reasons that each alone would have been enough. It called `getVersionInfo(false)`, and `checkRemote=false` makes /version return early with `{localVersion, status: "update_check_disabled"}` -- no GitHub fetch, no remote version, nothing to compare. It then read `info.updateAvailable`, a field the route does not return in either mode; the success response carries status, localVersion, version, remoteVersion, latest_release, cached and cache_age. `Boolean(undefined)` is false, always. The read type-checked only because `getVersionInfo()` is declared as `Record<string, unknown>`, so a property name that does not exist is indistinguishable from one that does. Let the endpoint do the comparison and read `status === "requires_update"`, which is what the dashboard stats bar and the profile panel badge already do. The row's label was `homepage.overviewUpdate`, whose English string is "Up to date" -- as the label of an update-available row it read "Up to date / Update available". Nobody has seen that, because the row has never rendered; fixing the indicator without the label would have shipped it. Give it its own key. That leaves `homepage.overviewUpdate` unused; it is left in place rather than removed, since it would be the natural value for an always-visible row and that is a product decision, not part of this fix. * fix: capture real client IP for SSH login alerts behind reverse proxy (#1169) * fix: capture real client IP for SSH login alerts behind reverse proxy The WebSocket terminal handler used req.socket.remoteAddress for the "user logged in" alert message, which is the immediate TCP peer (the reverse proxy) rather than the actual client IP forwarded via X-Forwarded-For. This made trust-proxy config on Traefik irrelevant since Termix never read the header for this code path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test: cover getClientIp forwarded-header and socket fallback paths Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * fix: keep already-shared hosts sharing their SSH authentication (#1179) Sharing a host used to hand the owner's SSH authentication to the recipient unconditionally. 2.6.1 put that behind ssh_data.share_ssh_auth, added as NOT NULL DEFAULT 0. Existing rows took the default, so every host shared before the upgrade stopped supplying credentials the moment the column appeared. The snapshot in collectProtocolSnapshots() is guarded by host.shareSshAuth, so nothing was captured; resolveRecipientSharedHostAuthentication() then fell through to "required" and the recipient got "No valid authentication method provided" on a host that had worked the day before. Downgrading to 2.6.0 restored it, since that code has no such column to consult. Backfill the flag for hosts that already appear in host_access. That is where the previous behaviour was in effect and where the owner had already agreed to share; hosts nobody has shared keep the new default and stay off until their owner shares them. Guarded by a settings key so it runs once. Without that, an owner who turns sharing back off would have it turned on again by the next restart. * fix: let a single credential disable 2FA again (#1180) The disable dialog has one field, labelled "Enter TOTP code or password", and its caller passes that value as disableTOTP(input) -- so it arrives as `password` with `totp_code` undefined. That call has been unchanged since v2.3.0. 2.5.1 changed the route to require both: if (!totp_code || (!userRecord.isOidc && !password)) -> 400 replacing `const credential = password || totp_code`. The first check has rejected every attempt since, whatever the user typed, so nobody has been able to turn 2FA off -- the client reports the generic "Failed to disable 2FA", which hides which check failed. Take one credential again and try it as a TOTP code, a backup code, then the account password. verifyTotpReauth still refuses the password itself, so that comparison stays in the route; an OIDC user has no password hash and reaches neither. The backup-codes route has the same shape but no caller in the UI -- its codes are returned when TOTP is enabled -- so it is left alone rather than changed blind. * fix: attach user-managed CA certificates over SFTP too (#1181) opkssh-cert-auth.ts exports two helpers that end in the same _applyCertToConnection: setupOPKSSHCertAuth, and setupCACertAuth for user-managed CA-signed -cert.pub files. The file manager called the first one twice and the second one never. So a host whose key is paired with a CA-signed certificate authenticated in a terminal and failed over SFTP, while OPKSSH certificates -- going through the other helper -- worked in both. The file manager was not missing certificate support in general; it was missing one of the two paths into it. The connection also never carried the certificate to begin with: cert_public_key was not among the fields copied into resolvedCredentials, so both places that build an SFTP connection now read it and attach it where the private key is prepared -- the dedicated transfer session and the main connect route. An unusable certificate is logged and skipped rather than failing the connection. The key alone may still be accepted, which is what happened while this was not wired up at all, and turning that into a hard failure would break setups that currently work. Reported in #1160 with the call-site asymmetry already traced; the reporter noted they could not confirm the link to their failure, having moved off SSH CAs. The asymmetry is real either way and reproduces the symptom exactly. * fix: authenticate the desktop Docker console WebSocket (#1182) The console WS opted out of the query token: buildOriginWsUrl({ ..., includeLocalJwt: false }) leaving it with no credential at all on the desktop. The browser WebSocket API cannot set an Authorization header, and while Electron's main process injects a remembered JWT cookie, it requires an exact origin match -- the cookie belongs to the API origin (localhost:30001) while the console connects to 127.0.0.1:30009, so nothing is attached. The backend then closes the handshake with 1008 before it logs anything, which is why the log has no docker-console entries while stats and logs polling keep succeeding on the same host. The web build is unaffected: it connects same-origin and its cookie is sent normally. Drop the opt-out so the console carries the local JWT like the SSH terminal does -- the same token, the same query parameter, and the backend already reads it there. Guacamole passes includeLocalJwt: false too, but rdp/vnc/telnet always resolve to "remote", so that call never reaches the local branch. * fix: use getClientIp in getRequestMeta for correct audit-log IPs (#1183) * fix: capture real client IP for SSH login alerts behind reverse proxy The WebSocket terminal handler used req.socket.remoteAddress for the "user logged in" alert message, which is the immediate TCP peer (the reverse proxy) rather than the actual client IP forwarded via X-Forwarded-For. This made trust-proxy config on Traefik irrelevant since Termix never read the header for this code path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test: cover getClientIp forwarded-header and socket fallback paths Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: use getClientIp in getRequestMeta for correct audit-log IPs getRequestMeta had near-duplicate, strictly worse forwarded-header logic: the array branch didn't split/trim, there was no socket-peer fallback, and it returned "" instead of "unknown". Delegate to getClientIp so the audit trail gets the same correctness as the terminal login-alert path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * feat: add terminal image handoff (#1170) * chore: sync Crowdin translations * fix(homepage): make the System Overview update indicator able to fire (#1168) The widget's "Update available" row and orange version text were unreachable, for two independent reasons that each alone would have been enough. It called `getVersionInfo(false)`, and `checkRemote=false` makes /version return early with `{localVersion, status: "update_check_disabled"}` -- no GitHub fetch, no remote version, nothing to compare. It then read `info.updateAvailable`, a field the route does not return in either mode; the success response carries status, localVersion, version, remoteVersion, latest_release, cached and cache_age. `Boolean(undefined)` is false, always. The read type-checked only because `getVersionInfo()` is declared as `Record<string, unknown>`, so a property name that does not exist is indistinguishable from one that does. Let the endpoint do the comparison and read `status === "requires_update"`, which is what the dashboard stats bar and the profile panel badge already do. The row's label was `homepage.overviewUpdate`, whose English string is "Up to date" -- as the label of an update-available row it read "Up to date / Update available". Nobody has seen that, because the row has never rendered; fixing the indicator without the label would have shipped it. Give it its own key. That leaves `homepage.overviewUpdate` unused; it is left in place rather than removed, since it would be the natural value for an always-visible row and that is a product decision, not part of this fix. * fix: capture real client IP for SSH login alerts behind reverse proxy (#1169) * fix: capture real client IP for SSH login alerts behind reverse proxy The WebSocket terminal handler used req.socket.remoteAddress for the "user logged in" alert message, which is the immediate TCP peer (the reverse proxy) rather than the actual client IP forwarded via X-Forwarded-For. This made trust-proxy config on Traefik irrelevant since Termix never read the header for this code path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test: cover getClientIp forwarded-header and socket fallback paths Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * feat: add terminal image handoff Add authenticated browser upload and clipboard image handoff for terminal agents. Normalize images through Sharp, enforce storage and request limits, preserve host-visible paths, and provide a stable three-button terminal toolbar. * docs: document terminal image handoff deployment --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: kacperpietrzyk <105545577+kacperpietrzyk@users.noreply.github.com> Co-authored-by: Brennan Neoh <497569+brennanneoh@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * fix(desktop): stop suppressing the update prompt, and make the version badge reachable (#1167) * fix(desktop): stop suppressing the update prompt for users who need it The startup update modal stored its dismissal under the local app version rather than the remote version being offered, and the up-to-date branch wrote that key with no user interaction at all. A user who launched while current had their own version recorded; once the next release shipped, `dismissedVersion === currentVersion` still held and the modal was skipped on every launch. It reappeared only after the user had already updated -- the inverse of what it is for. Present since v2.3.0. Key the dismissal on the offered remote version instead. The change is backward compatible: an existing key holding 2.6.0 compares unequal against a remote 2.6.1, so affected installs are prompted on their next launch. When the check itself fails there is no remote version, so nothing is recorded and no future prompt is suppressed. That left the version badge as the only remaining signal, and it was an inert span on both surfaces that render it -- the profile panel and the dashboard stats bar -- even though the `getVersionInfo()` response it is built from already carries `latest_release.html_url`. Extract the duplicated badge into `components/version-badge.tsx` and make the update case a link to the release, with an accessible name that says where it goes. The beta and stable cases stay inert. `getVersionInfo()` returned `Record<string, unknown>`, so the release URL was unreachable without a cast; give it a `VersionInfo` type that keeps an index signature, since `SystemOverviewWidget` reads `updateAvailable` off the same response. * test: cover the read that actually reaches the badge The extracted VersionBadge is unit-tested, but the line that decides whether it ever receives a URL -- pulling `latest_release.html_url` out of the version response -- was duplicated at both call sites and asserted nowhere. A wrong property there compiles (the response type keeps an index signature) and every existing test still passes. Give it a name, `releaseUrlFrom`, use it from both surfaces, and test it: the happy path, a response with no release, a release with no URL, and a missing response, since the caller's fetch can reject. Empty string is the contract the badge reads as "nothing to link to", so it stays an inert span rather than rendering a dead anchor. * docs: state the index signature's real reason The comment claimed the version endpoint carries fields beyond the typed ones, citing `updateAvailable`. It does not -- `GET /version` returns status, localVersion, version, remoteVersion, latest_release, cached and cache_age, and nothing else. SystemOverviewWidget reads `updateAvailable` off it regardless, which is why the permissive index signature has to stay, but that is a stale read rather than an undocumented field. Say so accurately. * Send alerts in Discord channels with Webhooks (#1158) * feat(utils): add discord webhook sender Add a utility to send alert embeds to Discord webhooks. * fix(utils): validate DNS and use global fetch for outbound requests Prevent private destination access and rely on global fetch after DNS validation. * chore(logger): include extra context in logs Show additional sanitized context entries for clearer diagnostics. * feat(alerts): support discord channel type in routes and engine Accept discord channels and route alerts to the Discord sender. * feat(ui): add Discord option to notification channel dialog Allow creating/editing Discord webhook channels with username/avatar. * fix(ui/api): accept structured config payload for notification channels Allow the client to pass structured config objects (or strings) when creating/updating channels. * chore: sync Crowdin translations * fix(homepage): make the System Overview update indicator able to fire (#1168) The widget's "Update available" row and orange version text were unreachable, for two independent reasons that each alone would have been enough. It called `getVersionInfo(false)`, and `checkRemote=false` makes /version return early with `{localVersion, status: "update_check_disabled"}` -- no GitHub fetch, no remote version, nothing to compare. It then read `info.updateAvailable`, a field the route does not return in either mode; the success response carries status, localVersion, version, remoteVersion, latest_release, cached and cache_age. `Boolean(undefined)` is false, always. The read type-checked only because `getVersionInfo()` is declared as `Record<string, unknown>`, so a property name that does not exist is indistinguishable from one that does. Let the endpoint do the comparison and read `status === "requires_update"`, which is what the dashboard stats bar and the profile panel badge already do. The row's label was `homepage.overviewUpdate`, whose English string is "Up to date" -- as the label of an update-available row it read "Up to date / Update available". Nobody has seen that, because the row has never rendered; fixing the indicator without the label would have shipped it. Give it its own key. That leaves `homepage.overviewUpdate` unused; it is left in place rather than removed, since it would be the natural value for an always-visible row and that is a product decision, not part of this fix. * fix: capture real client IP for SSH login alerts behind reverse proxy (#1169) * fix: capture real client IP for SSH login alerts behind reverse proxy The WebSocket terminal handler used req.socket.remoteAddress for the "user logged in" alert message, which is the immediate TCP peer (the reverse proxy) rather than the actual client IP forwarded via X-Forwarded-For. This made trust-proxy config on Traefik irrelevant since Termix never read the header for this code path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test: cover getClientIp forwarded-header and socket fallback paths Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * chore: add url to SENSITIVE_FIELDS for discord url * fix: enforce SSRF protection on outbound fetches Use `undici.fetch` with the custom DNS lookup hook to ensure the validated DNS resolution is the one used for the connection. Fix DNS lookup/address validation bugs and add coverage for private, public and invalid addresses, including the resolution issue affecting Discord endpoints. * chore: prettier format * fix: validate all DNS addresses and close dispatcher * fix DNS lookup validation and callback handling * update safe outbound fetch tests * ensure created dispatcher is properly closed * chore: remode url from SENSITIVE_FIELDS for other logs --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: kacperpietrzyk <105545577+kacperpietrzyk@users.noreply.github.com> Co-authored-by: Brennan Neoh <497569+brennanneoh@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * fix tmux UTF-8 path handling (#1157) Co-authored-by: Carl <scarlettme@qq.com> * chore: update package lock * chore: update gitnore * fix: [BUG] (#1049) https://github.com/Termix-SSH/Support/issues/1049 * fix: test commitlint path fix (#1021) * fix: SGR mouse-tracking escape codes printed as text (#1023) * fix: quote $1 in commit-msg hook so it works from git worktrees * fix: [BUG] could not connect to the database (#1057) https://github.com/Termix-SSH/Support/issues/1057 * fix: [BUG] VNC connect macOS screen sharing failed (#1063) https://github.com/Termix-SSH/Support/issues/1063 * fix: [BUG] Meta key (#1075) https://github.com/Termix-SSH/Support/issues/1075 * fix: [BUG] Remote sync doesn't work with Termix behind nginx proxy (#1085) https://github.com/Termix-SSH/Support/issues/1085 * fix: [BUG] webhook not working (#1080) https://github.com/Termix-SSH/Support/issues/1080 * fix: [BUG] First server sync doesn't refresh UI (#1084) https://github.com/Termix-SSH/Support/issues/1084 * fix: [BUG] How to enable SSL using custom certificate (#1083) https://github.com/Termix-SSH/Support/issues/1083 * fix: [BUG] Sudo Password Auto-fill Persistance (#1098) https://github.com/Termix-SSH/Support/issues/1098 * feat: [FEATURE] Expand Snippets Function (#1031) https://github.com/Termix-SSH/Support/issues/1031 * feat: [FEATURE] (#1055) https://github.com/Termix-SSH/Support/issues/1055 * feat: [FEATURE] Support for Headscale API Keys (hskey prefix) and Custom API Endpoints (#1013) https://github.com/Termix-SSH/Support/issues/1013 * feat: [FEATURE] Allow paste on non https (#1026) https://github.com/Termix-SSH/Support/issues/1026 * feat: be-azerty layout (#1073) https://github.com/Termix-SSH/Support/issues/1073 * feat: Keyboard shortcuts to move between open tabs (#1069) https://github.com/Termix-SSH/Support/issues/1069 * feat: Session Logs as a downloadable text file (#1058) https://github.com/Termix-SSH/Support/issues/1058 * fix: persist and auto-fill saved SSH and sudo passwords * fix: persist docker runtime selection and docker manager UI issues * feat: Allow excluding specific mounts from disk usage metrics (#1046) https://github.com/Termix-SSH/Support/issues/1046 * feat: Expand Snippets Function (#1031) https://github.com/Termix-SSH/Support/issues/1031 * chore: restore the prettier baseline on dev-2.7.0 (#1185) Five files on dev-2.7.0 do not match prettier, so `npx prettier --check .` fails and takes lint-and-build with it — on every pull request, whatever it changes. Formatting only, produced by `npx prettier --write` on exactly the files the check names. No logic touched: tsc passes for both configs, backend 148 files / 1106 tests and UI 71 files / 479 tests all pass. * test: keep the tmux escaping test runnable on Windows (#1184) The escaping check ran its command through /bin/sh. That binary does not exist on Windows, and Windows is a supported platform for the desktop app, so `npm test` fails there on a test about string quoting. CI is ubuntu-only and would never see it. Assert the escaped string directly, which covers the rule on every platform, and keep the round trip through a real shell as a separate case guarded by platform -- it is the stronger evidence where a shell exists. * chore: drop the unreachable table probes from migrateSchema (#1186) Eleven blocks in migrateSchema() guarded a CREATE TABLE IF NOT EXISTS behind SELECT id FROM <table> LIMIT 1, for tables the primary bootstrap had already created earlier in the same startup. The probe could not throw, so the catch never ran. Two of those unreachable copies had drifted from the definition actually in use. sessions had lost ON DELETE CASCADE, and session_recordings still carried user_id TEXT NOT NULL with ON DELETE CASCADE and no username -- the shape from before audit trails were made to outlive the account. They would have taken effect had anything ever reordered startup. Kept, because they are not the same thing: - blocks whose catch runs ALTER TABLE ADD COLUMN. CREATE TABLE IF NOT EXISTS is a no-op on a table that exists, so a database created before a column was added still needs the ALTER. Those probe a column, not a table. - blocks that are a table's only creation point. - the user_open_tabs block, which is a data migration; its SELECT is a precondition, not a probe. Deletion only, no behaviour change. * fix: repair the frontend type-check and clear the 299 errors behind it (#1189) * fix: repair frontend type-check configuration and the errors it exposed The root tsconfig.json is solution-style with "files": [], so the `npx tsc --noEmit` that CI runs compiles nothing at all. Frontend types have therefore never been checked, and 299 errors had accumulated behind that no-op. This clears just over half of them; nothing here changes runtime behaviour. Configuration: - "@/types" resolved through the "@/*" fallback to src/ui/types, which does not exist. Added an explicit mapping to src/types/index.ts. - src/vite-env.d.ts sits outside the include list, so import.meta.env and the ?url import suffix were unknown. Added. - src/ui/types/ held a single file, keybindings.ts, while every other shared type lives in src/types/. Six modules imported it as "@/types/keybindings" and silently resolved to nothing. Moved. Type definitions that had fallen behind the code: - guacamoleConfig and terminalConfig were Record<string, unknown> in ui-types while the editor read concrete fields off them. Both now use the real interfaces; GuacamoleConfig is extracted from its inline definition in guacamole-api.ts so the two cannot drift again. - customThemeColors and TerminalTheme["colors"] described the same object with different optionality. Aligned. - FileWindow declared its own SSHHost whose authType was "password" | "key", which no longer matches the eight the app supports. - connectSSH and listSSHFiles returned Record<string, unknown>, so every field the callers destructured arrived as unknown. - AxiosRequestConfig and AxiosResponse were used without being imported. Also adds asHttpError() for the handful of catch blocks that reached into an unknown binding, and narrows the Host | HostFolder comparator and the RailItem union at the points where the discriminant was not carrying. Note: dbHealthMonitor.reportDatabaseError was being called with a second argument it does not accept, so the authenticated-or-not flag was already being discarded at runtime. Dropped the argument to match the signature; whether that flag was meant to gate the report is worth a separate look. * fix: clear more of the frontend type-check baseline Continues the previous commit; 140 errors down to 70. Three of these were real defects rather than missing annotations. Defects: - DashboardTab counted active tunnels by comparing status to "CONNECTED", but CONNECTION_STATES.CONNECTED is "connected" and that is what the tunnel manager emits, so the count was always zero. Now compares against the constant. - QuickActionsCard requires isAdmin and gates a block of admin-only actions on it, but neither call site passed it — those actions never rendered. Both call sites also passed isAdmin to HostStatusCard, which does not accept it; the prop had evidently been moved and the call sites missed. - The host editor stores jump host ids as strings and sent them straight to an API typed for numbers. Backend host lookups compare against an integer column, which a string does not match on Postgres or MySQL. Converted. Types brought in line with the data: - Host and HostData were missing hasPassword, hasSudoPassword, sortOrder, instanceId, connectionOrigin, vaultProfileId, syncId, and the "vault" authType; TabContextTab was missing the "tunnel" tab, which TabContext already branched on. - statsConfig and terminalConfig used inline shapes that had drifted from StatsConfig and TerminalConfig. Both now reference the real interfaces; excludedMounts, which the editor reads, was added to StatsConfig. - downloadSSHFile, generateKeyPair and generatePublicKeyFromPrivate all returned Record<string, unknown> while callers read named fields. - The Guacamole declarations were missing Keyboard.reset, Client.onfile, InputStream.sendAck, Status.Code and BlobReader, all already in use. - NetworkTopologyNode/Edge could not be discriminated, though the graph code tells them apart by testing for source/target. ProxyNode.type is now 4 | 5 | "http" | "socks4" | "socks5". The editor writes the string spellings while proxy-helper.ts tests for "http" and casts everything else to 4|5 before handing it to the socks client, so a chained proxy reaches it as "socks5" rather than 5. Typed as what is actually stored; reconciling the two spellings needs a migration decision and is left alone here. * fix: continue clearing the frontend type-check baseline 70 errors down to 44. Dead configuration removed: - Terminal set terminal.options.bellStyle on xterm, which dropped the option in v5. The host editor still exposes the setting and stores it; it has simply had no effect on the terminal since that upgrade. Making the bell work again means handling the onBell event and is left alone. - CodeEditor passed scrollPastEnd to basicSetup, which has no such option. - FileManager passed an id to openWindow, which assigns its own and discards what it is given — the component was already being rendered under a different id than the one the caller held. Widgets that were registered but unreachable: - DockerActivityWidget and SshQuickConnectWidget register under "docker_activity" and "ssh_quick_connect", neither of which was in WidgetTypeId, and both referenced config interfaces that did not exist. Added the ids and the two interfaces, inferred from their edit forms and defaultConfig. More endpoints given their real return types: getRecentFiles, getPinnedFiles, getFolderShortcuts (arrays, not records), downloadSSHFile, copySSHItem, generateKeyPair, generatePublicKeyFromPrivate and getSnippets. parseGuacamoleConfig() handles the host row carrying guacamoleConfig either parsed or as raw JSON, which GuacamoleApp was reading fields off directly. TerminalHostConfig was missing name, which it reads for the activity log. * fix: continue clearing the frontend type-check baseline 44 errors down to 17. Host and AuditLog are now type aliases rather than interfaces. An interface has no implicit index signature, so neither could be assigned to the `[key: string]: unknown` shapes that TerminalHostConfig, HostMetricsTab's HostConfig and several helpers declare — eight errors came from that alone. More dead configuration: - i18n passed checkWhitelist to the language detector, which no longer has that option; supportedLngs already covers it. - SSHAuthDialog passed scrollPastEnd to basicSetup, same as CodeEditor. - AudioPreview's onLoadedMetadata never fired: react-h5-audio-player spells the prop onLoadedMetaData. - MarkdownRenderer destructured `inline` from code(), which react-markdown removed in v9, so the flag was always undefined and every inline span took the block branch when it happened to carry a language class. Now derived from whether a className is present at all. - SnippetsPanel put a title prop on a lucide icon, which does not forward it; changed to aria-label so the hint is actually reachable. updateHostConfig in TabContext replaced tab.hostConfig wholesale with the six-field literal it receives, dropping everything else the tab held about the host. It now merges onto the existing config. Also: getReleasesRSS, getUserAlerts and getVersionInfo have real return types (UpdateLog kept private copies of two of them, and VersionInfo was missing `version`, which the endpoint sends and the panel renders); wakeOnLan and vncCredentialId get the numeric ids they are typed for; and the tmux formatter takes i18next's TFunction instead of a hand-written signature it does not satisfy. * fix: clear the last frontend type errors and make CI actually run the check Baseline is now zero, so the check can be turned on. `npx tsc --noEmit` — what CI ran and what `npm run type-check` was — compiles nothing: the root tsconfig.json is solution-style with "files": [], and plain tsc does not follow project references. Both are now `tsc -b`, which builds tsconfig.app.json and tsconfig.node.json. Verified by planting a type error and watching the command fail. Last defects in this batch: - patchOpenTab could not carry hostId, so quick-connect's "save this host and attach the tab to it" call was passing a field excluded from the type all the way down. The column exists and updateForUser spreads whatever it receives, so the write worked; only the types disagreed. Widened front to back. - The file-comparison window opened without x, y, width or height — every other openWindow call passes them — and sent a `type` field WindowInstance does not have. - HostEditor gated a block on authType === "warpgate", which is not one of the eight authType values. Unreachable, and it held only a label and a description. Removed. - FileManager passed onLoadDirectory to a sidebar that neither declares nor reads it, and FileManagerApp passed embedded to a FileManager that has no such prop. - TunnelApp's minimal Host was missing three required flags. The remainder were assertions at boundaries that are genuinely loose: bulk host import takes rows assembled from untyped input and validates them server-side, and a vi.fn() whose body only throws infers never. * feat: add drive file browser and drag-and-drop upload for RDP (#1187) Drive redirection could already be enabled per host, but the redirected drive lived inside guacd with no way to reach it from the browser: the client never handled onfilesystem, so the mounted volume was writable from Windows and invisible from Termix. Add a file browser panel that lists the drive, downloads files, and uploads them, plus drag-and-drop onto the display which opens the panel and uploads into the directory currently shown. The disable-upload and disable-download connection settings are honoured by the UI, not just passed to guacd. A rejected upload stops the BlobWriter without firing onerror or oncomplete, so the error ack is watched explicitly; otherwise the transfer would hang forever. Directory reads carry a deadline for the same reason. Also declares Guacamole.Object, Client.onfilesystem, BlobReader and BlobWriter in the local type definitions, which previously omitted them. * fix: keep the mouse working on touch-capable devices in RDP/VNC (#1190) Reported as "mouse input broken, keyboard fine" after 2.5.1 (#1102). 2.5.1 bound Guacamole.Mouse unconditionally. 2.6.0 replaced that with a three-way branch on touchMode, and the touch branches replace the mouse binding instead of adding to it: if (touchMode === "touchscreen") new Guacamole.Mouse.Touchscreen(el) else if (touchMode === "touchpad") new Guacamole.Mouse.Touchpad(el) else new Guacamole.Mouse(el) The two do not overlap. Guacamole.Mouse listens for mousedown/mousemove/ mouseup; Touchscreen and Touchpad listen only for touchstart/touchmove/ touchend. So in a touch mode nothing is listening for the mouse at all. touchMode defaults to "touchscreen" whenever navigator.maxTouchPoints > 0, which is true of every laptop with a touchscreen — machines that are still driven by a mouse. Those users lost the pointer entirely while the keyboard kept working, because Guacamole.Keyboard is bound independently. The physical pointer is now always bound and a touch emulator is layered on top when one is selected. Extracted to bindPointerInput() so the binding is testable; the test fails against the old branch. Note the issue also carries a second, unrelated report where well-formed mouse frames do reach guacd and the VNC leg ignores them. That one is not this, and the guacd image is pinned to 1.6.0 in both 2.5.1 and 2.6.1, so it is not an upgrade either. * fix: deduplicate /api/folders requests to prevent intermittent folder disappearance (#1191) * chore: sync Crowdin translations * fix: deduplicate /api/folders requests to prevent intermittent folder disappearance getSSHFolders() had no request deduplication while getSSHHosts() used a TTL cache with in-flight dedupe. When loadHosts() fired multiple times during rapid navigation between Credentials and Hosts panels, the folder response could arrive after the hosts response, causing the sidebar tree to render without folder metadata. - Add foldersCache (10s TTL) in hosts-request-cache.ts - Wrap getSSHFolders() API call in getCachedSSHFolders() - Invalidate folders cache on renameFolder, updateFolderMetadata, deleteAllHostsInFolder, and renameCredentialFolder - Include foldersCache in invalidateHostsAndStatusCaches() Closes Termix-SSH/Support#1103 Signed-off-by: RawNuke <67506722+RawNuke@users.noreply.github.com> --------- Signed-off-by: RawNuke <67506722+RawNuke@users.noreply.github.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> * chore(deps): bump undici from 8.9.0 to 8.10.0 in the prod-minor-updates group (#1195) * chore: sync Crowdin translations * chore(deps): bump undici in the prod-minor-updates group Bumps the prod-minor-updates group with 1 update: [undici](https://github.com/nodejs/undici). Updates `undici` from 8.9.0 to 8.10.0 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v8.9.0...v8.10.0) --- updated-dependencies: - dependency-name: undici dependency-version: 8.10.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates ... Signed-off-by: dependabot[bot] <support@github.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat: proxmox metrics integration * feat: add folder select to the host multi select feature * feat: implement context aware terminal toolbar with quick links, host info, image pasting, etc * feat: made toolbar open file manager at path * fix: delete folder route not invalidating host list cache * fix: match host list icons with tab bar iconfix * fix: change sidebar reset button icon to seperate against fullscreen button * feat: unify connection system and add connection logs to guacd hosts * fix: make mobile terminal scrollback match xterm wheel behavior (#1198) * fix: route mobile terminal scrolling through xterm viewport * docs: document mobile terminal touch scrolling * chore: add a note to not place files in docs * chore: remove touch imput from docs * feat: improve snippet system with variable snippets and collapse settings * feat: new fleet system with snippet, packages, files, and inventory features * fix: command pallete not loading new activity and made enter load first item * feat: add subhost from parent host organization feature * feat: add workspaces feature to save tab layout * perf: greatly improved performance across metrics polling and host management for enterprise users * feat: add a onboarding system with a new interface simplicity system * feat: finalize the multi dialect database system * fix: bind trusted MFA devices to client installs (#1202) * fix: merge OIDC group claims across sources (#1203) * fix: allow disabling SSH keepalives (#1204) * fix: distinguish reachable and available hosts (#1206) * fix: throttle session activity persistence (#1207) * fix: preserve saved RDP connection settings (#1208) * fix: authenticate tunnel status stream (#1209) * fix: select quick-created credentials (#1210) * fix: stagger initial metrics collection (#1211) * fix: stagger initial metrics collection * fix: admit reachable hosts to initial metrics * fix: prevent long host names shifting dashboard metrics (#1205) * feat: add global touch input settings (#1201) Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * fix: keep host list row sizing stable (#1213) * fix(guacamole): correct Windows key mapping (#1216) * fix: normalize OIDC discovery issuer URLs (#1218) * fix: prompt for RDP domain credentials (#1212) * fix: route status checks by connection origin (#1214) * fix: restore desktop Tailscale configuration (#1215) * fix(docker): restore Node 24 for ssh2 native crypto (#1217) * feat: added new automations feature with events, channels, and steps * feat: allowed some tabs in the app rail to be opened as its own tab or in a new right sidebar * feat: expand onboarding process with more customization/features * feat: initial implementation of the termix ai feature * chore: run linter * fix: issue #424 (#424) https://github.com/Termix-SSH/Support/issues/424 * fix: Not working without internet connection. Missing OPKSSH binary in pre-built image. (#1133) https://github.com/Termix-SSH/Support/issues/1133 * fix: SQLite forceSave on telemetry writes causes periodic SSH terminal stalls in 2.6.x (#1109) https://github.com/Termix-SSH/Support/issues/1109 * feat: How to enable SSL using custom certificate (#1083) https://github.com/Termix-SSH/Support/issues/1083 * fix: show profile API key after creation (#1221) * feat: add trusted proxy authentication (#1222) * fix: clarify SSH agent authentication (#1224) * feat: add first-class split screen tabs (#1226) * feat: add split tab data model * feat: make split screens top-level tabs * feat: persist and manage split layouts * feat: launch native RDP on Windows desktop (#1223) * feat: launch native RDP on Windows * style: format native RDP launcher * feat: enhance custom disk and network metrics (#1220) * feat: enhance host disk and network metrics * fix: align enhanced metrics types * fix: preserve Proxmox guest identity on edit (#1219) * fix: preserve Proxmox guest identity on edit * fix: type Proxmox guest source metadata * chore: dead-code cleanup and small refactors (#1225) * chore: remove dead code and unused exports * chore: remove unused api client functions * chore: remove unused backend helpers * refactor: extract getErrorMessage helper for repeated error extraction * refactor: unify error message extraction across backend with getErrorMessage * refactor: unify error message extraction in frontend with getErrorMessage * refactor: merge duplicate imports from the same module * refactor: use Array.includes in TabBar * chore: drop biome, keep prettier as the single formatter * style: apply prettier formatting to refactored files * fix: close active tab with Ctrl+W on Windows * fix: make tray Quit terminate the desktop app * feat: verify host transfer integrity * fix: reuse transfer sessions during verification * feat: select the fastest host transfer route * feat: tune host transfers adaptively * feat: adapt background polling to activity (#1233) * feat: adapt background polling to activity * feat: extend adaptive polling coverage * feat: make polling cost and network aware (#1234) * feat: make repeat navigation feel instant (#1235) * feat: make file operations feel immediate (#1236) * feat: preload likely user actions (#1237) * feat: preload likely file previews * feat: preload likely host tools * feat: preload likely file viewers * fix: replace stale terminal input listeners * feat: add links to docs for all new features * chore: update readme * fix: warn before discarding host changes (#1229) * feat: learn local host action preferences (#1238) * feat(terminal-toolbar): add bounded movable desktop toolbar (#1239) * feat: add local adaptive decision engine (#1240) * feat: adapt speculative resource usage (#1241) * feat: persist adaptive transfer profiles (#1242) * Fix .preferred_username when using LDAP login. (#1243) * chore: sync Crowdin translations * Fix .preferred_username when using LDAP login. Strips internal LDAP prefix from username. --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> * feat: learn direct transfer routes (#1244) * feat: learn speculative preload usefulness (#1245) * fix: - Adjusting the SSH Authentication from Vault to something else fails (#1152) https://github.com/Termix-SSH/Support/issues/1152 * fix: terminal graphical display, special characters inserted, distorted - `midnight comma... (#1145) https://github.com/Termix-SSH/Support/issues/1145 * feat: single click on host in list opens session - should be only on double click (#1146) https://github.com/Termix-SSH/Support/issues/1146 * feat: Terminal: custom font/ font selection/ how-to for adding a font - `MesloLGS NF` (#1140) https://github.com/Termix-SSH/Support/issues/1140 * fix: revert host single click to open session, make double click an option (#1146) Single click opens a session again by default. The old double click behavior can be turned on in Customize Sidebar. * chore: drop prettier check from beta release workflow, run formatter * chore: patch dependabot vulnerabilities via npm overrides * fix: reset adaptive resource state between tests to stop cross-test leaks * feat: replace terminal toolbar density popover with a native select * fix: pin hardwareConcurrency in adaptive budget tests so CI cores don't change the tier * fix: allow dylib files in mac universal arch rules so mas build packages sharp * feat: add file manager trash (#1250) * feat: add inheritable connection defaults (#1246) * feat: add desktop local terminal (#1247) * feat: add interactive terminal macros (#1248) * feat: add adaptive SSH local echo (#1249) * fix: sync desktop host changes immediately (#1252) * fix: route desktop sharing through synced server (#1253) * Fix terminal image uploads and add safe diagnostics (#1254) * feat: add configurable terminal image storage backends * feat: add admin image storage settings * fix: preserve native clipboard PNG uploads * fix: quote terminal image paths safely * docs: record image storage security remediation plan * fix: close remote image SFTP channels * fix: restrict remote image SFTP permissions * fix: bound remote image SFTP writes * fix: add best effort remote image retention * fix: cap normalized image output size * fix: bound concurrent image processing * fix: fail closed on local image inspection errors * test: cover fail closed image storage and atomic settings * fix: enforce remote image quota and upload admission * fix: serialize remote quota and verify existing paths * fix: use synchronous sqlite settings transaction * fix: keep settings transactions portable across dialects * fix: bound image processing admission queue * fix: serialize remote image quota across processes * fix: recover stale remote image locks safely * fix: preserve remote storage errors during unlock * fix: fail closed when stale lock removal fails * fix: harden image upload resource and storage cleanup * fix: bound SFTP operations and lock lifetime * fix: bound SFTP acquisition and cleanup callbacks * fix: close late SFTP channels and test cleanup stalls * fix: preserve SFTP inspection client context * feat: add image upload source metadata * fix: expose image upload metadata in logs * chore: exclude internal plan from pull request * style: apply prettier formatting --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * fix: batch of security hardening fixes (#1255) * fix: bind desktop auto-session loopback check to the TCP peer address * fix: escape HTML entities in Vault OIDC callback responses * fix: route homepage ping and rss through the SSRF-safe outbound fetch * fix: scope tunnel status endpoints to hosts the caller can access * chore: update release notes * chore: update release notes to write more about the ai integration * fix: unbreak windows and macos electron builds after node-pty Install Spectre-mitigated MSVC libs on the Windows runner and cover node-pty's spawn-helper in the macOS universal arch rules. * fix: rework connection defaults ui into a dialog and add missing i18n keys * fix: rework macros panel with i18n, plain text matching, and list layout * feat: add docs links for trash, connection defaults, and local echo * fix: make image storage and trash tests pass on windows * fix: stop docs links squeezing sidebar panel headers * fix: put automations docs link back on the tabs row * fix(desktop): keep Linux credential storage working on unrecognised desktops (#1261) Chromium resolves safeStorage's backend from XDG_CURRENT_DESKTOP and falls back to the basic_text store for any desktop it has no mapping for, which covers every wlroots-style compositor (Hyprland, sway, niri, river). isEncryptionAvailable() reports false for that store, so saveRemoteSyncJwt refused every write and the OIDC sign-in it was storing appeared to succeed. The sync engine then found no JWT and reported the session as expired, which sent users looking at their OIDC provider for a fault that was never there. Name the libsecret backend explicitly on those desktops. They run an ordinary Secret Service, so that is enough to make encryption available again. KWallet desktops keep their auto-detected backend, an explicit --password-store still wins, and no stored secret can be orphaned by the switch because isEncryptionAvailable() gated every write that would have created one. Also stop discarding the {success: false} the main process returns when it cannot store a credential: on a machine with no Secret Service at all, the sign-in now says so instead of silently completing. Co-authored-by: alexandre-vl <rafaelsenchais@gmail.com> * chore: update release notes * chore: update release notes * fix(file-manager): widen trash dialog so names and paths are not cut off * fix(sidebar): stop hover action tray overlapping the row below it * fix(hosts): make real status colors toggle actually apply * feat(local-terminal): add rail button and fix hardcoded tab label * chore: update release notes * fix(ai): hide assistant everywhere when admin disables it globally * fix(automations): fix concurrency race, wire docker and internal event triggers Claim the in-flight slot in the same tick it is checked, poll container state for docker_event triggers, emit the internal events, apply the schedule time zone, and expose the concurrency policy in the editor. * fix(sidebar): rework host and credential drag-to-reorder Adds a lock toggle in the sort menu and fixes reorder positioning, cross-folder drops, and the duplicate drop indicator. * chore(sidebar): drop unused sortKey prop from host and credential trees * fix(sidebar): fix row height in click tray mode so status stripes stop overlapping * fix(onboarding): remove add-first-host step that closed onboarding mid-flow * fix(release): upload release notes so Mac App Store review submission stops failing * chore: sync Crowdin translations for 2.7.0 --------- Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: RawNuke <67506722+RawNuke@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: kacperpietrzyk <105545577+kacperpietrzyk@users.noreply.github.com> Co-authored-by: Brennan Neoh <497569+brennanneoh@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: T3rM1nAt0-R <niraj.sangani91@gmail.com> Co-authored-by: Horziox <horziox.dev@gmail.com> Co-authored-by: William Shi <184219650@qq.com> Co-authored-by: Carl <scarlettme@qq.com> Co-authored-by: Raw_Nuke <67506722+RawNuke@users.noreply.github.com> Co-authored-by: njz-cvm <njz@cvm.com> Co-authored-by: Alexandre VARGAS <alexandre.vargas.lopez@gmail.com> Co-authored-by: alexandre-vl <rafaelsenchais@gmail.com>
This commit is contained in:
co-authored by
dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ZacharyZcR
kacperpietrzyk
Brennan Neoh
Claude Sonnet 5
T3rM1nAt0-R
Horziox
William Shi
Carl
Raw_Nuke
njz-cvm
Alexandre VARGAS
alexandre-vl
parent
5021ccf3e2
commit
7ae1648c25
@@ -1,3 +1,4 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import { execFileSync } from "child_process";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
@@ -5,6 +6,7 @@ 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,
|
||||
@@ -382,6 +384,8 @@ export function registerAcmeSSLRoutes(
|
||||
operation: "acme_cert_installed",
|
||||
});
|
||||
|
||||
const reload = reloadNginxWithSSL();
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
@@ -394,9 +398,13 @@ export function registerAcmeSSLRoutes(
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ success: true, ...(await getAcmeSettings()) });
|
||||
res.json({
|
||||
success: true,
|
||||
reloadMessage: reload.message,
|
||||
...(await getAcmeSettings()),
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
const message = getErrorMessage(err);
|
||||
authLogger.error("ACME certificate request failed", err);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
@@ -535,6 +543,8 @@ export function registerAcmeSSLRoutes(
|
||||
operation: "manual_ssl_installed",
|
||||
});
|
||||
|
||||
const reload = reloadNginxWithSSL();
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
@@ -547,9 +557,13 @@ export function registerAcmeSSLRoutes(
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ success: true, ...(await getAcmeSettings()) });
|
||||
res.json({
|
||||
success: true,
|
||||
reloadMessage: reload.message,
|
||||
...(await getAcmeSettings()),
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
const message = getErrorMessage(err);
|
||||
authLogger.error("Manual SSL certificate upload failed", err);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
|
||||
@@ -4,6 +4,7 @@ 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();
|
||||
@@ -70,8 +71,10 @@ router.post("/notification-channels", async (req, res) => {
|
||||
if (!name || typeof name !== "string" || !name.trim()) {
|
||||
return res.status(400).json({ error: "name is required" });
|
||||
}
|
||||
if (type !== "webhook" && type !== "ntfy") {
|
||||
return res.status(400).json({ error: "type must be 'webhook' or 'ntfy'" });
|
||||
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" });
|
||||
@@ -88,6 +91,21 @@ router.post("/notification-channels", async (req, res) => {
|
||||
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({
|
||||
@@ -134,10 +152,10 @@ router.put(
|
||||
);
|
||||
if (!existing) return res.status(404).json({ error: "Channel not found" });
|
||||
|
||||
if (type && type !== "webhook" && type !== "ntfy") {
|
||||
if (type && type !== "webhook" && type !== "ntfy" && type !== "discord") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "type must be 'webhook' or 'ntfy'" });
|
||||
.json({ error: "type must be 'webhook', 'ntfy' or 'discord'" });
|
||||
}
|
||||
if (
|
||||
name === undefined &&
|
||||
@@ -245,6 +263,11 @@ router.post(
|
||||
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) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type {
|
||||
AuthenticatedRequest,
|
||||
CacheEntry,
|
||||
@@ -87,7 +88,7 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to fetch alerts from GitHub", {
|
||||
operation: "alerts_fetch",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,812 @@
|
||||
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 { 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 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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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;
|
||||
@@ -2,8 +2,7 @@ import type {
|
||||
AuthenticatedRequest,
|
||||
TunnelConnection,
|
||||
} from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import type { Request, Response } from "express";
|
||||
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";
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { createCurrentCredentialRepository } from "../repositories/factory.js";
|
||||
|
||||
export function registerCredentialBulkRoutes(
|
||||
router: Router,
|
||||
authenticateJWT: RequestHandler,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /credentials/reorder:
|
||||
* put:
|
||||
* summary: Reorder credentials
|
||||
* description: Sets a manual sortOrder for multiple credentials within the same folder, used by drag-to-reorder in the sidebar's manual sort mode.
|
||||
* tags:
|
||||
* - Credentials
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* positions:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* sortOrder:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Credentials reordered successfully.
|
||||
* 400:
|
||||
* description: Invalid positions array.
|
||||
* 500:
|
||||
* description: Failed to reorder credentials.
|
||||
*/
|
||||
router.put(
|
||||
"/reorder",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { positions } = req.body as {
|
||||
positions?: { id?: unknown; sortOrder?: unknown }[];
|
||||
};
|
||||
|
||||
if (!Array.isArray(positions)) {
|
||||
return res.status(400).json({ error: "positions array is required" });
|
||||
}
|
||||
|
||||
const normalized: { id: number; sortOrder: number }[] = [];
|
||||
for (const entry of positions) {
|
||||
if (
|
||||
typeof entry?.id !== "number" ||
|
||||
!Number.isInteger(entry.id) ||
|
||||
typeof entry.sortOrder !== "number" ||
|
||||
!Number.isFinite(entry.sortOrder)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Each position requires an integer id and a numeric sortOrder",
|
||||
});
|
||||
}
|
||||
normalized.push({ id: entry.id, sortOrder: entry.sortOrder });
|
||||
}
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return res.status(400).json({ error: "positions array is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const updated =
|
||||
await createCurrentCredentialRepository().reorderForUser(
|
||||
userId,
|
||||
normalized,
|
||||
);
|
||||
return res.json({ updated });
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to reorder credentials:", error);
|
||||
return res.status(500).json({ error: "Failed to reorder credentials" });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type {
|
||||
AuthenticatedRequest,
|
||||
CredentialBackend,
|
||||
@@ -234,7 +235,7 @@ async function deploySSHKeyToHost(
|
||||
conn.end();
|
||||
resolve({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Deployment failed",
|
||||
error: getErrorMessage(error, "Deployment failed"),
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -331,7 +332,7 @@ async function deploySSHKeyToHost(
|
||||
clearTimeout(connectionTimeout);
|
||||
resolve({
|
||||
success: false,
|
||||
error: `Invalid SSH key format: ${keyError instanceof Error ? keyError.message : "Unknown error"}`,
|
||||
error: `Invalid SSH key format: ${getErrorMessage(keyError)}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -349,7 +350,7 @@ async function deploySSHKeyToHost(
|
||||
clearTimeout(connectionTimeout);
|
||||
resolve({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Connection failed",
|
||||
error: getErrorMessage(error, "Connection failed"),
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -527,8 +528,7 @@ export function registerCredentialDeployRoutes(
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to deploy SSH key",
|
||||
error: getErrorMessage(error, "Failed to deploy SSH key"),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import crypto from "crypto";
|
||||
import ssh2Pkg from "ssh2";
|
||||
@@ -54,8 +55,7 @@ function generateSSHKeyPair(
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error ? error.message : "SSH key generation failed",
|
||||
error: getErrorMessage(error, "SSH key generation failed"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -116,10 +116,7 @@ export function registerCredentialKeyRoutes(
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to detect key type", error);
|
||||
res.status(500).json({
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to detect key type",
|
||||
error: getErrorMessage(error, "Failed to detect key type"),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -174,10 +171,7 @@ export function registerCredentialKeyRoutes(
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to detect public key type", error);
|
||||
res.status(500).json({
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to detect public key type",
|
||||
error: getErrorMessage(error, "Failed to detect public key type"),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -245,10 +239,7 @@ export function registerCredentialKeyRoutes(
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to validate key pair", error);
|
||||
res.status(500).json({
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to validate key pair",
|
||||
error: getErrorMessage(error, "Failed to validate key pair"),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -312,10 +303,7 @@ export function registerCredentialKeyRoutes(
|
||||
authLogger.error("Failed to generate key pair", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to generate key pair",
|
||||
error: getErrorMessage(error, "Failed to generate key pair"),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -501,10 +489,7 @@ export function registerCredentialKeyRoutes(
|
||||
authLogger.error("Failed to generate public key", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to generate public key",
|
||||
error: getErrorMessage(error, "Failed to generate public key"),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
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 { createCurrentCredentialSidebarPreferenceRepository } from "../repositories/factory.js";
|
||||
import {
|
||||
defaultCredentialSidebarPreferences,
|
||||
sanitizeCredentialSidebarPreferences,
|
||||
} from "../../../types/credential-sidebar-preferences.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /credential-sidebar/preferences:
|
||||
* get:
|
||||
* summary: Get the credential sidebar preferences for the current user
|
||||
* description: Returns the current user's saved credential sidebar preferences (sort, filters, open folders, display settings). Unlike /host-sidebar/preferences, there is no legacy-column migration to perform here — credentials never had exploded preference columns on userPreferences — so a first-time GET simply returns the defaults without writing a row; a row is only created once the user actually changes something via PUT.
|
||||
* tags:
|
||||
* - Credential Sidebar
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The current user's credential sidebar preferences.
|
||||
*/
|
||||
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const existing =
|
||||
await createCurrentCredentialSidebarPreferenceRepository().findByUserId(
|
||||
userId,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
const preferences = sanitizeCredentialSidebarPreferences(
|
||||
JSON.parse(existing.data),
|
||||
);
|
||||
return res.json({ preferences });
|
||||
}
|
||||
|
||||
return res.json({ preferences: defaultCredentialSidebarPreferences() });
|
||||
} catch (e) {
|
||||
databaseLogger.error("Failed to get credential sidebar preferences", e, {
|
||||
operation: "get_credential_sidebar_preferences",
|
||||
userId,
|
||||
});
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to get credential sidebar preferences" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /credential-sidebar/preferences:
|
||||
* put:
|
||||
* summary: Update the credential sidebar preferences for the current user
|
||||
* description: Persists the current user's credential sidebar preferences (sort, filters, open folders, display settings) as a single JSON document.
|
||||
* tags:
|
||||
* - Credential Sidebar
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Preferences updated successfully.
|
||||
* 400:
|
||||
* description: Invalid preferences payload.
|
||||
*/
|
||||
router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!req.body || typeof req.body !== "object") {
|
||||
return res.status(400).json({ error: "Invalid preferences payload" });
|
||||
}
|
||||
|
||||
try {
|
||||
const existing =
|
||||
await createCurrentCredentialSidebarPreferenceRepository().findByUserId(
|
||||
userId,
|
||||
);
|
||||
const base = existing
|
||||
? sanitizeCredentialSidebarPreferences(JSON.parse(existing.data))
|
||||
: defaultCredentialSidebarPreferences();
|
||||
|
||||
const merged = sanitizeCredentialSidebarPreferences({
|
||||
...base,
|
||||
...req.body,
|
||||
display: { ...base.display, ...(req.body.display ?? {}) },
|
||||
sort: { ...base.sort, ...(req.body.sort ?? {}) },
|
||||
filters: { ...base.filters, ...(req.body.filters ?? {}) },
|
||||
});
|
||||
|
||||
await createCurrentCredentialSidebarPreferenceRepository().upsert(
|
||||
userId,
|
||||
JSON.stringify(merged),
|
||||
);
|
||||
|
||||
return res.json({ success: true, preferences: merged });
|
||||
} catch (e) {
|
||||
databaseLogger.error("Failed to update credential sidebar preferences", e, {
|
||||
operation: "update_credential_sidebar_preferences",
|
||||
userId,
|
||||
});
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to update credential sidebar preferences" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,11 +1,12 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import type { Request, Response } from "express";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { parseSSHKey } from "../../utils/ssh-key-utils.js";
|
||||
import { registerCredentialKeyRoutes } from "./credential-key-routes.js";
|
||||
import { registerCredentialDeployRoutes } from "./credential-deploy-routes.js";
|
||||
import { registerCredentialBulkRoutes } from "./credential-bulk-routes.js";
|
||||
import {
|
||||
logAudit,
|
||||
getAuditUsername,
|
||||
@@ -224,8 +225,7 @@ router.post(
|
||||
username,
|
||||
});
|
||||
res.status(500).json({
|
||||
error:
|
||||
err instanceof Error ? err.message : "Failed to create credential",
|
||||
error: getErrorMessage(err, "Failed to create credential"),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -308,6 +308,11 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
// Registered here (before the PUT /:id route below) so the literal
|
||||
// "/reorder" path segment is matched before Express falls through to the
|
||||
// PUT /:id param route and treats "reorder" as an id.
|
||||
registerCredentialBulkRoutes(router, authenticateJWT);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /credentials/{id}:
|
||||
@@ -377,8 +382,7 @@ router.get(
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to fetch credential", err);
|
||||
res.status(500).json({
|
||||
error:
|
||||
err instanceof Error ? err.message : "Failed to fetch credential",
|
||||
error: getErrorMessage(err, "Failed to fetch credential"),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -551,8 +555,7 @@ router.put(
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to update credential", err);
|
||||
res.status(500).json({
|
||||
error:
|
||||
err instanceof Error ? err.message : "Failed to update credential",
|
||||
error: getErrorMessage(err, "Failed to update credential"),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -678,8 +681,7 @@ router.delete(
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to delete credential", err);
|
||||
res.status(500).json({
|
||||
error:
|
||||
err instanceof Error ? err.message : "Failed to delete credential",
|
||||
error: getErrorMessage(err, "Failed to delete credential"),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -766,10 +768,7 @@ router.post(
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to apply credential to host", err);
|
||||
res.status(500).json({
|
||||
error:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to apply credential to host",
|
||||
error: getErrorMessage(err, "Failed to apply credential to host"),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -822,10 +821,7 @@ router.get(
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to fetch hosts using credential", err);
|
||||
res.status(500).json({
|
||||
error:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to fetch hosts using credential",
|
||||
error: getErrorMessage(err, "Failed to fetch hosts using credential"),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -845,6 +841,8 @@ function formatCredentialOutput(
|
||||
? credential.tags.split(",").filter(Boolean)
|
||||
: []
|
||||
: [],
|
||||
pin: !!credential.pin,
|
||||
sortOrder: credential.sortOrder ?? null,
|
||||
authType: credential.authType,
|
||||
username: credential.username || null,
|
||||
publicKey: credential.publicKey,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { Request, Response } from "express";
|
||||
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 express from "express";
|
||||
import {
|
||||
createCurrentDashboardServiceLinkRepository,
|
||||
createCurrentSyncTombstoneRepository,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
createCurrentAiRepository,
|
||||
createCurrentAlertRepository,
|
||||
createCurrentApiKeyRepository,
|
||||
createCurrentAuditLogRepository,
|
||||
@@ -15,6 +16,9 @@ import {
|
||||
createCurrentHostFolderRepository,
|
||||
createCurrentHostMetricsPreferenceRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentHostSidebarPreferenceRepository,
|
||||
createCurrentCredentialSidebarPreferenceRepository,
|
||||
createCurrentUiPreferenceRepository,
|
||||
createCurrentNetworkTopologyRepository,
|
||||
createCurrentOpksshTokenRepository,
|
||||
createCurrentOpenTabRepository,
|
||||
@@ -57,6 +61,7 @@ export async function deleteUserAndRelatedData(userId: string): Promise<void> {
|
||||
await createCurrentTrustedDeviceRepository().deleteByUserId(userId);
|
||||
|
||||
await createCurrentRoleRepository().removeAllRolesFromUser(userId);
|
||||
await createCurrentAiRepository().deleteByUserId(userId);
|
||||
await createCurrentAlertRepository().deleteByUserId(userId);
|
||||
await createCurrentAuditLogRepository().anonymizeByUserId(userId);
|
||||
|
||||
@@ -77,6 +82,11 @@ export async function deleteUserAndRelatedData(userId: string): Promise<void> {
|
||||
|
||||
await createCurrentHostHealthRepository().deleteByUserId(userId);
|
||||
await createCurrentHostMetricsPreferenceRepository().deleteByUserId(userId);
|
||||
await createCurrentHostSidebarPreferenceRepository().deleteByUserId(userId);
|
||||
await createCurrentCredentialSidebarPreferenceRepository().deleteByUserId(
|
||||
userId,
|
||||
);
|
||||
await createCurrentUiPreferenceRepository().deleteByUserId(userId);
|
||||
await createCurrentHostRepository().deleteByUserId(userId);
|
||||
await createCurrentCredentialRepository().deleteByUserId(userId);
|
||||
|
||||
|
||||
@@ -2,7 +2,17 @@ import type { Request } from "express";
|
||||
import type { UserRecord } from "../repositories/user-repository.js";
|
||||
|
||||
export function isLoopbackRequest(req: Request): boolean {
|
||||
const ip = req.ip || req.socket?.remoteAddress || "";
|
||||
// Requests relayed by the bundled nginx always carry X-Real-IP, which
|
||||
// nginx overwrites with the actual client address -- so its presence
|
||||
// means the caller reached the backend through the reverse proxy and is
|
||||
// not a local process, whatever the TCP peer address says (it is nginx
|
||||
// itself on loopback). Everything else is judged by the TCP peer
|
||||
// address, which no client-supplied header can influence: with
|
||||
// `trust proxy = true`, req.ip comes from X-Forwarded-For and would let
|
||||
// any remote caller claim to be loopback.
|
||||
if (req.headers["x-real-ip"]) return false;
|
||||
|
||||
const ip = req.socket?.remoteAddress || "";
|
||||
return (
|
||||
ip === "127.0.0.1" ||
|
||||
ip === "::1" ||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
||||
import type { Request, Response } from "express";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { homepageLogger } from "../../utils/logger.js";
|
||||
import express from "express";
|
||||
import https from "https";
|
||||
import http from "http";
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { Request, Response } from "express";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { homepageLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
createCurrentHomepageItemRepository,
|
||||
createCurrentSyncTombstoneRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import express from "express";
|
||||
|
||||
export const homepageItemsRouter = express.Router();
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { Request, Response } from "express";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { homepageLogger } from "../../utils/logger.js";
|
||||
import express from "express";
|
||||
import { createCurrentHomepageLayoutRepository } from "../repositories/factory.js";
|
||||
|
||||
export const homepageLayoutRouter = express.Router();
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { Request, Response } from "express";
|
||||
import express from "express";
|
||||
import https from "https";
|
||||
import http from "http";
|
||||
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();
|
||||
|
||||
@@ -17,54 +15,37 @@ const pingCache = new Map<string, PingCacheEntry>();
|
||||
const CACHE_SIZE = 200;
|
||||
const FETCH_TIMEOUT_MS = 5000;
|
||||
|
||||
function pingUrl(
|
||||
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 }> {
|
||||
return new Promise((resolve) => {
|
||||
const start = performance.now();
|
||||
const mod = url.startsWith("https") ? https : http;
|
||||
|
||||
const done = (ok: boolean, statusCode: number | null) => {
|
||||
resolve({
|
||||
ok,
|
||||
statusCode,
|
||||
latencyMs: Math.round(performance.now() - start),
|
||||
});
|
||||
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(),
|
||||
};
|
||||
|
||||
const tryGet = () => {
|
||||
const req = mod.get(url, { timeout: FETCH_TIMEOUT_MS }, (res) => {
|
||||
res.resume();
|
||||
const code = res.statusCode ?? null;
|
||||
done(code !== null && code < 400, code);
|
||||
});
|
||||
req.on("error", () => done(false, null));
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
done(false, null);
|
||||
});
|
||||
};
|
||||
|
||||
const req = mod.request(
|
||||
url,
|
||||
{ method: "HEAD", timeout: FETCH_TIMEOUT_MS },
|
||||
(res) => {
|
||||
res.resume();
|
||||
const code = res.statusCode ?? null;
|
||||
if (code === 405) {
|
||||
tryGet();
|
||||
} else {
|
||||
done(code !== null && code < 400, code);
|
||||
}
|
||||
},
|
||||
);
|
||||
req.on("error", () => done(false, null));
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
done(false, null);
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
} catch {
|
||||
return { ok: false, statusCode: null, latencyMs: elapsed() };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Request, Response } from "express";
|
||||
import express from "express";
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import https from "https";
|
||||
import http from "http";
|
||||
import { lookup } from "dns/promises";
|
||||
@@ -132,7 +132,7 @@ homepageProxyRouter.get("/", async (req: Request, res: Response) => {
|
||||
proxyCache.set(targetUrl, { data, expires: Date.now() + ttl });
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Unknown error";
|
||||
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" });
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { Request, Response } from "express";
|
||||
import express from "express";
|
||||
import https from "https";
|
||||
import http from "http";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { homepageLogger } from "../../utils/logger.js";
|
||||
import { safeOutboundFetch } from "../../utils/safe-outbound-fetch.js";
|
||||
|
||||
export const homepageRssRouter = express.Router();
|
||||
|
||||
@@ -19,19 +17,11 @@ interface RssItem {
|
||||
}
|
||||
|
||||
function fetchXml(url: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const mod = url.startsWith("https") ? https : http;
|
||||
const req = mod.get(url, { timeout: FETCH_TIMEOUT_MS }, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
||||
res.on("error", reject);
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject(new Error("RSS fetch timeout"));
|
||||
});
|
||||
return safeOutboundFetch(url, {
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) throw new Error(`RSS fetch failed: ${res.status}`);
|
||||
return res.text();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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";
|
||||
@@ -148,7 +149,7 @@ export function registerHostAutostartRoutes(
|
||||
} catch (error) {
|
||||
sshLogger.warn("Failed to update tunnel connections", {
|
||||
operation: "tunnel_connections_update_failed",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
@@ -6,6 +7,7 @@ import {
|
||||
createCurrentHostRepository,
|
||||
createCurrentHostResolutionRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import { validateParentHostId } from "./host-parent-validation.js";
|
||||
import {
|
||||
isNonEmptyString,
|
||||
isValidPort,
|
||||
@@ -154,6 +156,13 @@ export function registerHostBulkRoutes(
|
||||
* type: number
|
||||
* updates:
|
||||
* type: object
|
||||
* description: Partial fields to apply. Setting folder clears parentHostId and vice versa, since a host is either in a folder or nested under a parent host.
|
||||
* properties:
|
||||
* folder:
|
||||
* type: string
|
||||
* parentHostId:
|
||||
* type: integer
|
||||
* nullable: true
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Bulk update completed.
|
||||
@@ -215,8 +224,39 @@ export function registerHostBulkRoutes(
|
||||
|
||||
const simpleUpdates: Record<string, unknown> = {};
|
||||
if (typeof updates.pin === "boolean") simpleUpdates.pin = updates.pin;
|
||||
if (typeof updates.folder === "string")
|
||||
if (typeof updates.folder === "string") {
|
||||
simpleUpdates.folder = updates.folder || null;
|
||||
// Folder placement and parent-host placement are mutually
|
||||
// exclusive -- assigning a folder (including moving to root, an
|
||||
// empty folder) clears any parent host, matching the single-host
|
||||
// update route's behavior.
|
||||
simpleUpdates.parentHostId = null;
|
||||
}
|
||||
if (updates.parentHostId !== undefined) {
|
||||
if (updates.parentHostId === null) {
|
||||
simpleUpdates.parentHostId = null;
|
||||
} else {
|
||||
const numericParentHostId = Number(updates.parentHostId);
|
||||
if (!Number.isInteger(numericParentHostId)) {
|
||||
return res.status(400).json({ error: "Invalid parent host" });
|
||||
}
|
||||
// A bulk move can only ever target one parent host at a time
|
||||
// (the caller drags a selection onto one drop target), so every
|
||||
// id in the batch is checked against the same candidate parent.
|
||||
for (const id of ownedIds) {
|
||||
const parentError = await validateParentHostId(
|
||||
userId,
|
||||
id,
|
||||
numericParentHostId,
|
||||
);
|
||||
if (parentError) {
|
||||
return res.status(400).json({ error: parentError });
|
||||
}
|
||||
}
|
||||
simpleUpdates.parentHostId = numericParentHostId;
|
||||
simpleUpdates.folder = null;
|
||||
}
|
||||
}
|
||||
if (typeof updates.enableTerminal === "boolean")
|
||||
simpleUpdates.enableTerminal = updates.enableTerminal;
|
||||
if (typeof updates.enableTunnel === "boolean")
|
||||
@@ -227,6 +267,8 @@ export function registerHostBulkRoutes(
|
||||
simpleUpdates.enableDocker = updates.enableDocker;
|
||||
if (typeof updates.enableTmuxMonitor === "boolean")
|
||||
simpleUpdates.enableTmuxMonitor = updates.enableTmuxMonitor;
|
||||
if (typeof updates.enableTerminalToolbar === "boolean")
|
||||
simpleUpdates.enableTerminalToolbar = updates.enableTerminalToolbar;
|
||||
// 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)
|
||||
@@ -299,6 +341,84 @@ export function registerHostBulkRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/reorder:
|
||||
* put:
|
||||
* summary: Reorder hosts
|
||||
* description: Sets a manual sortOrder for multiple hosts within the same folder, used by drag-to-reorder in the sidebar's manual sort mode.
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* positions:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* sortOrder:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Hosts reordered successfully.
|
||||
* 400:
|
||||
* description: Invalid positions array.
|
||||
* 500:
|
||||
* description: Failed to reorder hosts.
|
||||
*/
|
||||
router.put(
|
||||
"/reorder",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { positions } = req.body as {
|
||||
positions?: { id?: unknown; sortOrder?: unknown }[];
|
||||
};
|
||||
|
||||
if (!Array.isArray(positions)) {
|
||||
return res.status(400).json({ error: "positions array is required" });
|
||||
}
|
||||
|
||||
const normalized: { id: number; sortOrder: number }[] = [];
|
||||
for (const entry of positions) {
|
||||
if (
|
||||
typeof entry?.id !== "number" ||
|
||||
!Number.isInteger(entry.id) ||
|
||||
typeof entry.sortOrder !== "number" ||
|
||||
!Number.isFinite(entry.sortOrder)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Each position requires an integer id and a numeric sortOrder",
|
||||
});
|
||||
}
|
||||
normalized.push({ id: entry.id, sortOrder: entry.sortOrder });
|
||||
}
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return res.status(400).json({ error: "positions array is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await createCurrentHostRepository().reorderForUser(
|
||||
userId,
|
||||
normalized,
|
||||
);
|
||||
return res.json({ updated });
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to reorder hosts:", error);
|
||||
return res.status(500).json({ error: "Failed to reorder hosts" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/bulk-import",
|
||||
authenticateJWT,
|
||||
@@ -391,7 +511,7 @@ export function registerHostBulkRoutes(
|
||||
}
|
||||
} catch (error) {
|
||||
results.errors.push(
|
||||
`Credential placeholders: ${error instanceof Error ? error.message : "failed to prepare credential aliases"}`,
|
||||
`Credential placeholders: ${getErrorMessage(error, "failed to prepare credential aliases")}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -553,6 +673,7 @@ export function registerHostBulkRoutes(
|
||||
enableDocker: hostData.enableDocker || false,
|
||||
enableProxmox: hostData.enableProxmox || false,
|
||||
enableTmuxMonitor: hostData.enableTmuxMonitor || false,
|
||||
enableTerminalToolbar: hostData.enableTerminalToolbar !== false,
|
||||
showTerminalInSidebar: hostData.showTerminalInSidebar ? 1 : 0,
|
||||
showFileManagerInSidebar: hostData.showFileManagerInSidebar ? 1 : 0,
|
||||
showTunnelInSidebar: hostData.showTunnelInSidebar ? 1 : 0,
|
||||
@@ -665,9 +786,7 @@ export function registerHostBulkRoutes(
|
||||
}
|
||||
} catch (error) {
|
||||
results.failed++;
|
||||
results.errors.push(
|
||||
`Host ${i + 1}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -821,6 +940,7 @@ export function registerHostBulkRoutes(
|
||||
enableDocker: false,
|
||||
enableProxmox: false,
|
||||
enableTmuxMonitor: false,
|
||||
enableTerminalToolbar: true,
|
||||
showTerminalInSidebar: 0,
|
||||
showFileManagerInSidebar: 0,
|
||||
showTunnelInSidebar: 0,
|
||||
@@ -872,7 +992,7 @@ export function registerHostBulkRoutes(
|
||||
} catch (error) {
|
||||
results.failed++;
|
||||
results.errors.push(
|
||||
`Host "${parsed[i].name}": ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
`Host "${parsed[i].name}": ${getErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,6 +240,87 @@ export function registerHostFolderRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/folders/reorder:
|
||||
* put:
|
||||
* summary: Reorder folders
|
||||
* description: Sets a manual sortOrder for multiple sibling folders, used by drag-to-reorder in the sidebar's manual sort mode. Folders with no existing metadata row are created.
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* positions:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* sortOrder:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Folders reordered successfully.
|
||||
* 400:
|
||||
* description: Invalid positions array.
|
||||
* 500:
|
||||
* description: Failed to reorder folders.
|
||||
*/
|
||||
router.put(
|
||||
"/folders/reorder",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { positions } = req.body as {
|
||||
positions?: { name?: unknown; sortOrder?: unknown }[];
|
||||
};
|
||||
|
||||
if (!isNonEmptyString(userId) || !Array.isArray(positions)) {
|
||||
return res.status(400).json({ error: "positions array is required" });
|
||||
}
|
||||
|
||||
const normalized: { name: string; sortOrder: number }[] = [];
|
||||
for (const entry of positions) {
|
||||
if (
|
||||
typeof entry?.name !== "string" ||
|
||||
!entry.name ||
|
||||
typeof entry.sortOrder !== "number" ||
|
||||
!Number.isFinite(entry.sortOrder)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: "Each position requires a name and a numeric sortOrder",
|
||||
});
|
||||
}
|
||||
normalized.push({ name: entry.name, sortOrder: entry.sortOrder });
|
||||
}
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return res.status(400).json({ error: "positions array is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const updated =
|
||||
await createCurrentHostFolderRepository().reorderFolders(
|
||||
userId,
|
||||
normalized,
|
||||
);
|
||||
res.json({ updated });
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to reorder folders", err, {
|
||||
operation: "folders_reorder",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to reorder folders" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/folders/{name}/hosts:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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";
|
||||
@@ -83,7 +84,7 @@ export function registerHostNetworkRoutes(
|
||||
});
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -132,10 +133,7 @@ export function registerHostNetworkRoutes(
|
||||
hostId,
|
||||
});
|
||||
res.status(500).json({
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to send WoL packet",
|
||||
error: getErrorMessage(error, "Failed to send WoL packet"),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -154,6 +154,7 @@ export type NormalizedImportedHost = Record<string, unknown> & {
|
||||
enableDocker?: unknown;
|
||||
enableProxmox?: unknown;
|
||||
enableTmuxMonitor?: unknown;
|
||||
enableTerminalToolbar?: unknown;
|
||||
showTerminalInSidebar?: unknown;
|
||||
showFileManagerInSidebar?: unknown;
|
||||
showTunnelInSidebar?: unknown;
|
||||
@@ -167,6 +168,8 @@ export type NormalizedImportedHost = Record<string, unknown> & {
|
||||
statsConfig?: unknown;
|
||||
dockerConfig?: unknown;
|
||||
proxmoxConfig?: unknown;
|
||||
enableProxmoxStats?: unknown;
|
||||
proxmoxStatsConfig?: unknown;
|
||||
terminalConfig?: unknown;
|
||||
forceKeyboardInteractive?: unknown;
|
||||
notes?: unknown;
|
||||
@@ -273,10 +276,17 @@ export function stripSensitiveFields(
|
||||
host: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const result = { ...host };
|
||||
const terminalConfigForSudo =
|
||||
host.terminalConfig &&
|
||||
typeof host.terminalConfig === "object" &&
|
||||
!Array.isArray(host.terminalConfig)
|
||||
? (host.terminalConfig as Record<string, unknown>)
|
||||
: undefined;
|
||||
result.hasKey = !!host.key;
|
||||
result.hasKeyPassword = !!host.keyPassword;
|
||||
result.hasPassword = !!host.password;
|
||||
result.hasSudoPassword = !!host.sudoPassword;
|
||||
result.hasSudoPassword =
|
||||
!!host.sudoPassword || !!terminalConfigForSudo?.sudoPassword;
|
||||
result.hasRdpPassword = !!host.rdpPassword;
|
||||
result.hasVncPassword = !!host.vncPassword;
|
||||
result.hasTelnetPassword = !!host.telnetPassword;
|
||||
@@ -322,7 +332,9 @@ const CONNECT_LEVEL_FIELDS = new Set([
|
||||
"enableFileManager",
|
||||
"enableDocker",
|
||||
"enableProxmox",
|
||||
"enableProxmoxStats",
|
||||
"enableTmuxMonitor",
|
||||
"enableTerminalToolbar",
|
||||
"showTerminalInSidebar",
|
||||
"showFileManagerInSidebar",
|
||||
"showTunnelInSidebar",
|
||||
@@ -356,6 +368,10 @@ export function sanitizeHostForRecipient(
|
||||
const stripped = stripSensitiveFields(host);
|
||||
delete stripped.credentialId;
|
||||
delete stripped.overrideCredentialUsername;
|
||||
// Sub-host nesting is per-owner tree structure; a recipient generally
|
||||
// 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" &&
|
||||
@@ -417,7 +433,9 @@ export function transformHostResponse(
|
||||
enableFileManager: host.enableFileManager !== false,
|
||||
enableDocker: !!host.enableDocker,
|
||||
enableProxmox: !!host.enableProxmox,
|
||||
enableProxmoxStats: !!host.enableProxmoxStats,
|
||||
enableTmuxMonitor: !!host.enableTmuxMonitor,
|
||||
enableTerminalToolbar: host.enableTerminalToolbar !== false,
|
||||
showTerminalInSidebar: !!host.showTerminalInSidebar,
|
||||
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
|
||||
showTunnelInSidebar: !!host.showTunnelInSidebar,
|
||||
@@ -469,6 +487,9 @@ export function transformHostResponse(
|
||||
proxmoxConfig: host.proxmoxConfig
|
||||
? JSON.parse(host.proxmoxConfig as string)
|
||||
: undefined,
|
||||
proxmoxStatsConfig: host.proxmoxStatsConfig
|
||||
? JSON.parse(host.proxmoxStatsConfig as string)
|
||||
: undefined,
|
||||
forceKeyboardInteractive: host.forceKeyboardInteractive === "true",
|
||||
useWarpgate: !!host.useWarpgate,
|
||||
socks5ProxyChain: host.socks5ProxyChain
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { createCurrentHostResolutionRepository } from "../repositories/factory.js";
|
||||
|
||||
/**
|
||||
* Validates a proposed parentHostId for a host owned by `userId`.
|
||||
*
|
||||
* Rejects a parent that doesn't exist/isn't owned by the same user, a
|
||||
* self-reference, and any assignment that would create a cycle (the
|
||||
* candidate parent's own ancestor chain already contains the host being
|
||||
* assigned). Walks parentHostId in-app rather than via a recursive SQL CTE,
|
||||
* matching the existing ancestor-walk convention in
|
||||
* findFolderCredentialId (host-resolution-repository.ts).
|
||||
*
|
||||
* `hostId` is null when validating a create (the host doesn't have an id
|
||||
* yet, so only self-reference/cycle-with-itself is impossible to hit).
|
||||
*/
|
||||
export async function validateParentHostId(
|
||||
userId: string,
|
||||
hostId: number | null,
|
||||
parentHostId: number,
|
||||
): Promise<string | null> {
|
||||
if (hostId !== null && parentHostId === hostId) {
|
||||
return "A host cannot be its own parent";
|
||||
}
|
||||
|
||||
const links =
|
||||
await createCurrentHostResolutionRepository().listOwnHostParentLinks(
|
||||
userId,
|
||||
);
|
||||
const linksById = new Map(links.map((link) => [link.id, link.parentHostId]));
|
||||
|
||||
if (!linksById.has(parentHostId)) {
|
||||
return "Parent host not found";
|
||||
}
|
||||
|
||||
let current: number | null = parentHostId;
|
||||
const visited = new Set<number>();
|
||||
while (current !== null) {
|
||||
if (hostId !== null && current === hostId) {
|
||||
return "That host is a descendant of this host, and cannot be its parent";
|
||||
}
|
||||
if (visited.has(current)) break;
|
||||
visited.add(current);
|
||||
current = linksById.get(current) ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
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 {
|
||||
createCurrentHostSidebarPreferenceRepository,
|
||||
createCurrentUserPreferenceRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import {
|
||||
defaultHostSidebarPreferences,
|
||||
sanitizeHostSidebarPreferences,
|
||||
} from "../../../types/host-sidebar-preferences.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host-sidebar/preferences:
|
||||
* get:
|
||||
* summary: Get the host sidebar preferences for the current user
|
||||
* description: Returns the current user's saved sidebar preferences (sort, group, filters, open folders, display settings). On first access, seeds the preferences from the legacy per-column user-preferences fields (showHostTags, hostTrayOnClick, compactHostView, statusColorScheme) so existing settings are not lost.
|
||||
* tags:
|
||||
* - Host Sidebar
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The current user's sidebar preferences.
|
||||
*/
|
||||
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const existing =
|
||||
await createCurrentHostSidebarPreferenceRepository().findByUserId(userId);
|
||||
|
||||
if (existing) {
|
||||
const preferences = sanitizeHostSidebarPreferences(
|
||||
JSON.parse(existing.data),
|
||||
);
|
||||
return res.json({ preferences });
|
||||
}
|
||||
|
||||
const legacy =
|
||||
await createCurrentUserPreferenceRepository().findByUserId(userId);
|
||||
const defaults = defaultHostSidebarPreferences();
|
||||
const seeded = sanitizeHostSidebarPreferences({
|
||||
...defaults,
|
||||
display: {
|
||||
...defaults.display,
|
||||
showTags: legacy?.showHostTags ?? defaults.display.showTags,
|
||||
trayTrigger:
|
||||
legacy?.hostTrayOnClick == null
|
||||
? defaults.display.trayTrigger
|
||||
: legacy.hostTrayOnClick
|
||||
? "click"
|
||||
: "hover",
|
||||
density:
|
||||
legacy?.compactHostView == null
|
||||
? defaults.display.density
|
||||
: legacy.compactHostView
|
||||
? "compact"
|
||||
: "comfortable",
|
||||
statusColorScheme:
|
||||
legacy?.statusColorScheme ?? defaults.display.statusColorScheme,
|
||||
},
|
||||
});
|
||||
|
||||
await createCurrentHostSidebarPreferenceRepository().upsert(
|
||||
userId,
|
||||
JSON.stringify(seeded),
|
||||
);
|
||||
|
||||
return res.json({ preferences: seeded });
|
||||
} catch (e) {
|
||||
databaseLogger.error("Failed to get host sidebar preferences", e, {
|
||||
operation: "get_host_sidebar_preferences",
|
||||
userId,
|
||||
});
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to get host sidebar preferences" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host-sidebar/preferences:
|
||||
* put:
|
||||
* summary: Update the host sidebar preferences for the current user
|
||||
* description: Persists the current user's sidebar preferences (sort, group, filters, open folders, display settings) as a single JSON document.
|
||||
* tags:
|
||||
* - Host Sidebar
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Preferences updated successfully.
|
||||
* 400:
|
||||
* description: Invalid preferences payload.
|
||||
*/
|
||||
router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!req.body || typeof req.body !== "object") {
|
||||
return res.status(400).json({ error: "Invalid preferences payload" });
|
||||
}
|
||||
|
||||
try {
|
||||
const existing =
|
||||
await createCurrentHostSidebarPreferenceRepository().findByUserId(userId);
|
||||
const base = existing
|
||||
? sanitizeHostSidebarPreferences(JSON.parse(existing.data))
|
||||
: defaultHostSidebarPreferences();
|
||||
|
||||
const merged = sanitizeHostSidebarPreferences({
|
||||
...base,
|
||||
...req.body,
|
||||
display: { ...base.display, ...(req.body.display ?? {}) },
|
||||
sort: { ...base.sort, ...(req.body.sort ?? {}) },
|
||||
filters: { ...base.filters, ...(req.body.filters ?? {}) },
|
||||
});
|
||||
|
||||
await createCurrentHostSidebarPreferenceRepository().upsert(
|
||||
userId,
|
||||
JSON.stringify(merged),
|
||||
);
|
||||
|
||||
return res.json({ success: true, preferences: merged });
|
||||
} catch (e) {
|
||||
databaseLogger.error("Failed to update host sidebar preferences", e, {
|
||||
operation: "update_host_sidebar_preferences",
|
||||
userId,
|
||||
});
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to update host sidebar preferences" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import type { Request, Response } from "express";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import axios from "axios";
|
||||
import multer from "multer";
|
||||
import { sshLogger, databaseLogger } from "../../utils/logger.js";
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
pickResolvedPassword,
|
||||
pickResolvedUsername,
|
||||
} from "../../hosts/credential-username.js";
|
||||
import { notifyAutomationInternalEvent } from "../../hosts/metrics/automation-bridge.js";
|
||||
import {
|
||||
createCurrentCommandHistoryRepository,
|
||||
createCurrentCredentialRepository,
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
stripSensitiveFields,
|
||||
transformHostResponse,
|
||||
} from "./host-normalizers.js";
|
||||
import { validateParentHostId } from "./host-parent-validation.js";
|
||||
import { registerHostOpksshRoutes } from "./host-opkssh-routes.js";
|
||||
import { registerHostFolderRoutes } from "./host-folder-routes.js";
|
||||
import { registerHostFileManagerBookmarkRoutes } from "./host-file-manager-bookmark-routes.js";
|
||||
@@ -56,7 +58,10 @@ import {
|
||||
getAuditUsername,
|
||||
getRequestMeta,
|
||||
} from "../../utils/audit-logger.js";
|
||||
import type { HostResolutionHostRecord } from "../repositories/host-resolution-repository.js";
|
||||
import type {
|
||||
HostResolutionCredentialRecord,
|
||||
HostResolutionHostRecord,
|
||||
} from "../repositories/host-resolution-repository.js";
|
||||
import {
|
||||
requiresPersonalHostAuthentication,
|
||||
resolveRecipientSharedHostAuthentication,
|
||||
@@ -162,6 +167,7 @@ router.post(
|
||||
connectionType,
|
||||
name,
|
||||
folder,
|
||||
parentHostId,
|
||||
tags,
|
||||
ip,
|
||||
port,
|
||||
@@ -186,6 +192,7 @@ router.post(
|
||||
enableDocker,
|
||||
enableProxmox,
|
||||
enableTmuxMonitor,
|
||||
enableTerminalToolbar,
|
||||
allowSessionSharing,
|
||||
showTerminalInSidebar,
|
||||
showFileManagerInSidebar,
|
||||
@@ -199,6 +206,8 @@ router.post(
|
||||
statsConfig,
|
||||
dockerConfig,
|
||||
proxmoxConfig,
|
||||
enableProxmoxStats,
|
||||
proxmoxStatsConfig,
|
||||
terminalConfig,
|
||||
forceKeyboardInteractive,
|
||||
domain,
|
||||
@@ -264,6 +273,23 @@ router.post(
|
||||
return res.status(400).json({ error: "Invalid SSH data" });
|
||||
}
|
||||
|
||||
let validatedParentHostId: number | null = null;
|
||||
if (parentHostId !== undefined && parentHostId !== null) {
|
||||
const numericParentHostId = Number(parentHostId);
|
||||
if (!Number.isInteger(numericParentHostId)) {
|
||||
return res.status(400).json({ error: "Invalid parent host" });
|
||||
}
|
||||
const parentError = await validateParentHostId(
|
||||
userId,
|
||||
null,
|
||||
numericParentHostId,
|
||||
);
|
||||
if (parentError) {
|
||||
return res.status(400).json({ error: parentError });
|
||||
}
|
||||
validatedParentHostId = numericParentHostId;
|
||||
}
|
||||
|
||||
const effectiveConnectionType = connectionType || "ssh";
|
||||
const effectiveAuthType =
|
||||
authType ||
|
||||
@@ -277,7 +303,10 @@ router.post(
|
||||
userId: userId,
|
||||
connectionType: effectiveConnectionType,
|
||||
name: effectiveName,
|
||||
folder: folder || null,
|
||||
// A host is either placed in a folder or nested under a parent host,
|
||||
// never both -- setting one clears the other.
|
||||
folder: validatedParentHostId ? null : folder || null,
|
||||
parentHostId: validatedParentHostId,
|
||||
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
|
||||
ip,
|
||||
port,
|
||||
@@ -286,7 +315,8 @@ router.post(
|
||||
useWarpgate: useWarpgate ? 1 : 0,
|
||||
shareSshAuth: shareSshAuth === true ? 1 : 0,
|
||||
credentialId: credentialId || null,
|
||||
vaultProfileId: vaultProfileId || null,
|
||||
vaultProfileId:
|
||||
effectiveAuthType === "vault" ? vaultProfileId || null : null,
|
||||
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
||||
pin: pin ? 1 : 0,
|
||||
enableTerminal: enableTerminal ? 1 : 0,
|
||||
@@ -304,6 +334,7 @@ router.post(
|
||||
enableDocker: enableDocker ? 1 : 0,
|
||||
enableProxmox: enableProxmox ? 1 : 0,
|
||||
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
|
||||
enableTerminalToolbar: enableTerminalToolbar === false ? 0 : 1,
|
||||
allowSessionSharing: allowSessionSharing === false ? 0 : 1,
|
||||
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
|
||||
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
|
||||
@@ -326,6 +357,12 @@ router.post(
|
||||
? proxmoxConfig
|
||||
: JSON.stringify(proxmoxConfig)
|
||||
: null,
|
||||
enableProxmoxStats: enableProxmoxStats ? 1 : 0,
|
||||
proxmoxStatsConfig: proxmoxStatsConfig
|
||||
? typeof proxmoxStatsConfig === "string"
|
||||
? proxmoxStatsConfig
|
||||
: JSON.stringify(proxmoxStatsConfig)
|
||||
: null,
|
||||
terminalConfig: terminalConfig
|
||||
? typeof terminalConfig === "string"
|
||||
? terminalConfig
|
||||
@@ -485,7 +522,14 @@ router.post(
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json(resolvedHost);
|
||||
notifyAutomationInternalEvent(
|
||||
"host_added",
|
||||
userId,
|
||||
createdHost.id as number,
|
||||
{ name: String(name ?? ip) },
|
||||
);
|
||||
|
||||
res.json(stripSensitiveFields(resolvedHost));
|
||||
notifyStatsHostUpdated(
|
||||
createdHost.id as number,
|
||||
req.headers,
|
||||
@@ -710,7 +754,9 @@ router.post(
|
||||
enableFileManager: true,
|
||||
enableDocker: false,
|
||||
enableProxmox: false,
|
||||
enableProxmoxStats: false,
|
||||
enableTmuxMonitor: false,
|
||||
enableTerminalToolbar: true,
|
||||
showTerminalInSidebar: true,
|
||||
showFileManagerInSidebar: false,
|
||||
showTunnelInSidebar: false,
|
||||
@@ -813,6 +859,7 @@ router.put(
|
||||
connectionType,
|
||||
name,
|
||||
folder,
|
||||
parentHostId,
|
||||
tags,
|
||||
ip,
|
||||
port,
|
||||
@@ -837,6 +884,7 @@ router.put(
|
||||
enableDocker,
|
||||
enableProxmox,
|
||||
enableTmuxMonitor,
|
||||
enableTerminalToolbar,
|
||||
allowSessionSharing,
|
||||
showTerminalInSidebar,
|
||||
showFileManagerInSidebar,
|
||||
@@ -850,6 +898,8 @@ router.put(
|
||||
statsConfig,
|
||||
dockerConfig,
|
||||
proxmoxConfig,
|
||||
enableProxmoxStats,
|
||||
proxmoxStatsConfig,
|
||||
terminalConfig,
|
||||
forceKeyboardInteractive,
|
||||
domain,
|
||||
@@ -917,6 +967,27 @@ router.put(
|
||||
return res.status(400).json({ error: "Invalid SSH data" });
|
||||
}
|
||||
|
||||
let validatedParentHostId: number | null | undefined = undefined;
|
||||
if (parentHostId !== undefined) {
|
||||
if (parentHostId === null) {
|
||||
validatedParentHostId = null;
|
||||
} else {
|
||||
const numericParentHostId = Number(parentHostId);
|
||||
if (!Number.isInteger(numericParentHostId)) {
|
||||
return res.status(400).json({ error: "Invalid parent host" });
|
||||
}
|
||||
const parentError = await validateParentHostId(
|
||||
userId,
|
||||
Number(hostId),
|
||||
numericParentHostId,
|
||||
);
|
||||
if (parentError) {
|
||||
return res.status(400).json({ error: parentError });
|
||||
}
|
||||
validatedParentHostId = numericParentHostId;
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveAuthType = authType || authMethod;
|
||||
const effectiveUsername =
|
||||
username || rdpUser || vncUser || telnetUser || "";
|
||||
@@ -925,7 +996,10 @@ router.put(
|
||||
const sshDataObj: Record<string, unknown> = {
|
||||
connectionType: connectionType || "ssh",
|
||||
name: effectiveName,
|
||||
folder,
|
||||
// A host is either placed in a folder or nested under a parent host,
|
||||
// never both. When the caller is assigning a parent, clear folder;
|
||||
// when the caller is assigning a folder, clear parentHostId.
|
||||
folder: validatedParentHostId ? null : folder,
|
||||
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
|
||||
ip,
|
||||
port,
|
||||
@@ -934,7 +1008,8 @@ router.put(
|
||||
useWarpgate: useWarpgate ? 1 : 0,
|
||||
shareSshAuth: shareSshAuth === true ? 1 : 0,
|
||||
credentialId: credentialId || null,
|
||||
vaultProfileId: vaultProfileId || null,
|
||||
vaultProfileId:
|
||||
effectiveAuthType === "vault" ? vaultProfileId || null : null,
|
||||
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
||||
pin: pin ? 1 : 0,
|
||||
enableTerminal: enableTerminal ? 1 : 0,
|
||||
@@ -952,6 +1027,7 @@ router.put(
|
||||
enableDocker: enableDocker ? 1 : 0,
|
||||
enableProxmox: enableProxmox ? 1 : 0,
|
||||
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
|
||||
enableTerminalToolbar: enableTerminalToolbar === false ? 0 : 1,
|
||||
allowSessionSharing: allowSessionSharing === false ? 0 : 1,
|
||||
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
|
||||
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
|
||||
@@ -974,6 +1050,12 @@ router.put(
|
||||
? proxmoxConfig
|
||||
: JSON.stringify(proxmoxConfig)
|
||||
: null,
|
||||
enableProxmoxStats: enableProxmoxStats ? 1 : 0,
|
||||
proxmoxStatsConfig: proxmoxStatsConfig
|
||||
? typeof proxmoxStatsConfig === "string"
|
||||
? proxmoxStatsConfig
|
||||
: JSON.stringify(proxmoxStatsConfig)
|
||||
: null,
|
||||
terminalConfig: terminalConfig
|
||||
? typeof terminalConfig === "string"
|
||||
? terminalConfig
|
||||
@@ -1100,6 +1182,15 @@ router.put(
|
||||
if (vncPassword) sshDataObj.vncPassword = vncPassword;
|
||||
if (telnetPassword) sshDataObj.telnetPassword = telnetPassword;
|
||||
|
||||
if (validatedParentHostId !== undefined) {
|
||||
sshDataObj.parentHostId = validatedParentHostId;
|
||||
} else if (folder !== undefined) {
|
||||
// Caller is assigning a folder (including clearing it back to root)
|
||||
// without touching parentHostId -- folder placement replaces
|
||||
// parent-host placement either way.
|
||||
sshDataObj.parentHostId = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const accessInfo = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
@@ -1244,6 +1335,33 @@ router.put(
|
||||
for (const field of OWNER_PRIVATE_AUTH_FIELDS.ssh) {
|
||||
delete sshDataObj[field];
|
||||
}
|
||||
} else if (
|
||||
sshDataObj.terminalConfig &&
|
||||
(hostData.terminalConfig as Record<string, unknown> | undefined)
|
||||
?.sudoPassword === undefined
|
||||
) {
|
||||
// The editor omits sudoPassword entirely when the user hasn't
|
||||
// touched the field, so preserve whatever is already stored instead
|
||||
// of letting the wholesale terminalConfig replacement below wipe it.
|
||||
const existingHost =
|
||||
await createCurrentHostResolutionRepository().findHostById(
|
||||
Number(hostId),
|
||||
ownerId,
|
||||
);
|
||||
const existingTerminalConfig = existingHost?.terminalConfig
|
||||
? (JSON.parse(existingHost.terminalConfig as string) as Record<
|
||||
string,
|
||||
unknown
|
||||
>)
|
||||
: undefined;
|
||||
if (existingTerminalConfig?.sudoPassword !== undefined) {
|
||||
const incomingTerminalConfig = JSON.parse(
|
||||
sshDataObj.terminalConfig as string,
|
||||
) as Record<string, unknown>;
|
||||
incomingTerminalConfig.sudoPassword =
|
||||
existingTerminalConfig.sudoPassword;
|
||||
sshDataObj.terminalConfig = JSON.stringify(incomingTerminalConfig);
|
||||
}
|
||||
}
|
||||
|
||||
await createCurrentHostRepository().updateEncryptedForUser(
|
||||
@@ -1262,10 +1380,7 @@ router.put(
|
||||
sshLogger.warn("Failed to resync shared host secrets after update", {
|
||||
operation: "host_update_resync",
|
||||
hostId: parseInt(hostId),
|
||||
error:
|
||||
resyncError instanceof Error
|
||||
? resyncError.message
|
||||
: "Unknown error",
|
||||
error: getErrorMessage(resyncError),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1307,7 +1422,7 @@ router.put(
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json(resolvedHost);
|
||||
res.json(stripSensitiveFields(resolvedHost));
|
||||
notifyStatsHostUpdated(parseInt(hostId), req.headers, "host_update");
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to update SSH host in database", err, {
|
||||
@@ -1387,31 +1502,40 @@ router.get(
|
||||
operation: "host_fetch_own_decrypt_failed",
|
||||
userId,
|
||||
hostId: host.id,
|
||||
error:
|
||||
decryptError instanceof Error
|
||||
? decryptError.message
|
||||
: "Unknown error",
|
||||
error: getErrorMessage(decryptError),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One lookup for every owner rather than one per shared host.
|
||||
const ownerUsernames = new Map<string, string>();
|
||||
const userRepository = createCurrentUserRepository();
|
||||
for (const sharedHost of sharedHosts) {
|
||||
const ownerId = sharedHost.userId as string;
|
||||
if (!ownerUsernames.has(ownerId)) {
|
||||
try {
|
||||
const owner = await userRepository.findById(ownerId);
|
||||
ownerUsernames.set(ownerId, owner?.username ?? "");
|
||||
} catch {
|
||||
ownerUsernames.set(ownerId, "");
|
||||
const ownerIds = Array.from(
|
||||
new Set(sharedHosts.map((host) => host.userId as string)),
|
||||
);
|
||||
if (ownerIds.length > 0) {
|
||||
try {
|
||||
const owners =
|
||||
await createCurrentUserRepository().listByIds(ownerIds);
|
||||
for (const owner of owners) {
|
||||
ownerUsernames.set(owner.id, owner.username ?? "");
|
||||
}
|
||||
} catch {
|
||||
// Falls through to an undefined ownerUsername below.
|
||||
}
|
||||
}
|
||||
|
||||
const data = [...decryptedOwnHosts, ...sharedHosts];
|
||||
|
||||
// Own hosts all resolve against the caller's own credentials, so they can
|
||||
// be fetched and decrypted in one batch instead of once per host.
|
||||
const ownCredentialIds = decryptedOwnHosts
|
||||
.map((host) => host.credentialId)
|
||||
.filter((id): id is number => typeof id === "number");
|
||||
const credentialsById = await createCurrentHostResolutionRepository()
|
||||
.listCredentialsByIdsForUser(ownCredentialIds, userId)
|
||||
.catch(() => new Map<number, HostResolutionCredentialRecord>());
|
||||
|
||||
const result = await Promise.all(
|
||||
data.map(async (row: Record<string, unknown>) => {
|
||||
const baseHost = {
|
||||
@@ -1425,7 +1549,8 @@ router.get(
|
||||
};
|
||||
|
||||
const resolved =
|
||||
(await resolveHostCredentials(baseHost, userId)) || baseHost;
|
||||
(await resolveHostCredentials(baseHost, userId, credentialsById)) ||
|
||||
baseHost;
|
||||
return resolved;
|
||||
}),
|
||||
);
|
||||
@@ -1776,7 +1901,9 @@ router.get(
|
||||
scpLegacy: !!resolvedHost.scpLegacy,
|
||||
enableDocker: !!resolvedHost.enableDocker,
|
||||
enableProxmox: !!resolvedHost.enableProxmox,
|
||||
enableProxmoxStats: !!resolvedHost.enableProxmoxStats,
|
||||
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
|
||||
enableTerminalToolbar: resolvedHost.enableTerminalToolbar !== false,
|
||||
showTerminalInSidebar: !!resolvedHost.showTerminalInSidebar,
|
||||
showFileManagerInSidebar: !!resolvedHost.showFileManagerInSidebar,
|
||||
showTunnelInSidebar: !!resolvedHost.showTunnelInSidebar,
|
||||
@@ -1802,6 +1929,9 @@ router.get(
|
||||
proxmoxConfig: resolvedHost.proxmoxConfig
|
||||
? JSON.parse(resolvedHost.proxmoxConfig as string)
|
||||
: null,
|
||||
proxmoxStatsConfig: resolvedHost.proxmoxStatsConfig
|
||||
? JSON.parse(resolvedHost.proxmoxStatsConfig as string)
|
||||
: null,
|
||||
terminalConfig: resolvedHost.terminalConfig
|
||||
? JSON.parse(resolvedHost.terminalConfig as string)
|
||||
: null,
|
||||
@@ -1932,6 +2062,8 @@ router.get(
|
||||
enableDocker: !!resolvedHost.enableDocker,
|
||||
enableProxmox: !!resolvedHost.enableProxmox,
|
||||
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
|
||||
enableTerminalToolbar:
|
||||
resolvedHost.enableTerminalToolbar !== false,
|
||||
showTerminalInSidebar: !!resolvedHost.showTerminalInSidebar,
|
||||
showFileManagerInSidebar: !!resolvedHost.showFileManagerInSidebar,
|
||||
showTunnelInSidebar: !!resolvedHost.showTunnelInSidebar,
|
||||
@@ -2185,6 +2317,10 @@ router.delete(
|
||||
success: true,
|
||||
});
|
||||
|
||||
notifyAutomationInternalEvent("host_deleted", userId, numericHostId, {
|
||||
name: hostToDelete.name ?? hostToDelete.ip,
|
||||
});
|
||||
|
||||
try {
|
||||
const axios = (await import("axios")).default;
|
||||
await axios.post(
|
||||
@@ -2295,6 +2431,12 @@ registerHostCommandHistoryRoutes(router, authenticateJWT);
|
||||
async function resolveHostCredentials(
|
||||
host: Record<string, unknown>,
|
||||
requestingUserId?: string,
|
||||
/**
|
||||
* Credentials already fetched for this request, keyed by id. The host list
|
||||
* preloads them in one query; single-host callers omit it and fall back to
|
||||
* fetching the one credential they need.
|
||||
*/
|
||||
preloadedCredentials?: Map<number, HostResolutionCredentialRecord>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const ownerId = (host.ownerId || host.userId) as string | undefined;
|
||||
@@ -2424,10 +2566,11 @@ async function resolveHostCredentials(
|
||||
const credentialOwnerId = (host.ownerId || host.userId) as string;
|
||||
|
||||
const credential =
|
||||
await createCurrentHostResolutionRepository().findCredentialByIdForUser(
|
||||
preloadedCredentials?.get(credentialId) ??
|
||||
(await createCurrentHostResolutionRepository().findCredentialByIdForUser(
|
||||
credentialId,
|
||||
credentialOwnerId,
|
||||
);
|
||||
));
|
||||
|
||||
if (credential) {
|
||||
const resolvedHost: Record<string, unknown> = {
|
||||
@@ -2454,7 +2597,7 @@ async function resolveHostCredentials(
|
||||
return { ...host };
|
||||
} catch (error) {
|
||||
sshLogger.warn(
|
||||
`Failed to resolve credentials for host ${host.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
`Failed to resolve credentials for host ${host.id}: ${getErrorMessage(error)}`,
|
||||
);
|
||||
return host;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import type { Request, Response } from "express";
|
||||
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";
|
||||
@@ -213,6 +212,7 @@ router.patch("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = String(req.params.id);
|
||||
const updates = req.body as Partial<{
|
||||
hostId: number | null;
|
||||
label: string;
|
||||
tabOrder: number;
|
||||
backendSessionId: string | null;
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import express from "express";
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import { logger } from "../../utils/logger.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { createCurrentHostRepository } from "../repositories/factory.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { SSHHost } from "../../../types/index.js";
|
||||
import {
|
||||
type AuthenticatedRequest,
|
||||
type SSHHost,
|
||||
} from "../../../types/index.js";
|
||||
import { SSHHostKeyVerifier } from "../../hosts/host-key-verifier.js";
|
||||
import { resolveHostById } from "../../hosts/host-resolver.js";
|
||||
import { createJumpHostChain } from "../../hosts/jump-host-chain.js";
|
||||
import { resolveProxmoxImportAuth } from "./proxmox-import-auth.js";
|
||||
import { isSafeNodeName } from "../../hosts/proxmox-shared.js";
|
||||
|
||||
const router = express.Router();
|
||||
const proxmoxLogger = logger;
|
||||
@@ -24,14 +28,6 @@ const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
|
||||
// Helpers
|
||||
|
||||
// Proxmox node names are restricted to [a-zA-Z0-9-] by PVE itself,
|
||||
// but we validate defensively before using in a shell command.
|
||||
const SAFE_NODE_RE = /^[a-zA-Z0-9._-]{1,64}$/;
|
||||
|
||||
function isSafeNodeName(name: string): boolean {
|
||||
return SAFE_NODE_RE.test(name);
|
||||
}
|
||||
|
||||
function execCommand(
|
||||
client: SSHClient,
|
||||
command: string,
|
||||
@@ -240,6 +236,17 @@ function guestSourceKey(sourceHostId: number, guest: ProxmoxGuest): string {
|
||||
return `${sourceHostId}:${guest.node}:${guest.type}:${guest.vmid}`;
|
||||
}
|
||||
|
||||
function guestTags(guest: ProxmoxGuest): string[] {
|
||||
const idTag = guest.type === "lxc" ? `ct-${guest.vmid}` : `vm-${guest.vmid}`;
|
||||
return [
|
||||
"proxmox",
|
||||
guest.type,
|
||||
guest.node,
|
||||
idTag,
|
||||
...(guest.enableDocker ? ["docker"] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function mergeTags(
|
||||
existing: unknown,
|
||||
additions: string[],
|
||||
@@ -701,11 +708,7 @@ async function syncProxmoxHost(
|
||||
username,
|
||||
connectionType,
|
||||
folder: existing?.folder || sourceHostName,
|
||||
tags: mergeTags(
|
||||
existing?.tags,
|
||||
["proxmox", guest.type, guest.node],
|
||||
["proxmox-missing"],
|
||||
),
|
||||
tags: mergeTags(existing?.tags, guestTags(guest), ["proxmox-missing"]),
|
||||
proxmoxConfig: JSON.stringify(proxmoxConfig),
|
||||
updatedAt: now,
|
||||
};
|
||||
@@ -814,7 +817,7 @@ async function syncProxmoxHost(
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
const message = getErrorMessage(error);
|
||||
result.errors.push(message);
|
||||
await writeSyncStatus(userId, sourceHostId, {
|
||||
lastSyncAt: startedAt,
|
||||
@@ -855,7 +858,7 @@ router.post("/sync", authenticateJWT, requireDataAccess, async (req, res) => {
|
||||
const result = await syncProxmoxHost(userId, parsedHostId);
|
||||
return res.json(result);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
const message = getErrorMessage(err);
|
||||
const status =
|
||||
(err as Error & { code?: string; status?: number }).code ===
|
||||
"SESSION_EXPIRED"
|
||||
@@ -1041,7 +1044,7 @@ router.get(
|
||||
jumpHosts: discovery.jumpHosts,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
const message = getErrorMessage(err);
|
||||
proxmoxLogger.error("Proxmox discovery (stream) failed", err, {
|
||||
operation: "proxmox_discover",
|
||||
hostId: parsedHostId,
|
||||
@@ -1086,7 +1089,7 @@ router.post(
|
||||
jumpHosts: discovery.jumpHosts,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
const message = getErrorMessage(err);
|
||||
proxmoxLogger.error("Proxmox discovery failed", err, {
|
||||
operation: "proxmox_discover",
|
||||
hostId: parsedHostId,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import type { Response } from "express";
|
||||
import express, { type Response } from "express";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { getRequestMeta } from "../../utils/audit-logger.js";
|
||||
@@ -41,11 +41,13 @@ function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function isSharePermissionLevel(value: unknown): value is SharePermissionLevel {
|
||||
export function isSharePermissionLevel(
|
||||
value: unknown,
|
||||
): value is SharePermissionLevel {
|
||||
return SHARE_PERMISSION_LEVELS.includes(value as SharePermissionLevel);
|
||||
}
|
||||
|
||||
function expiryFromDuration(durationHours: unknown): string | null {
|
||||
export function expiryFromDuration(durationHours: unknown): string | null {
|
||||
if (durationHours && typeof durationHours === "number" && durationHours > 0) {
|
||||
const expiryDate = new Date();
|
||||
expiryDate.setTime(expiryDate.getTime() + durationHours * 60 * 60 * 1000);
|
||||
@@ -67,12 +69,12 @@ async function canManageHostSharing(
|
||||
return { allowed: access.hasAccess, isOwner: access.isOwner };
|
||||
}
|
||||
|
||||
interface ShareTarget {
|
||||
export interface ShareTarget {
|
||||
type: "user" | "role";
|
||||
id: string | number;
|
||||
}
|
||||
|
||||
function parseShareTargets(
|
||||
export function parseShareTargets(
|
||||
body: Record<string, unknown>,
|
||||
): ShareTarget[] | null {
|
||||
const rawTargets = body.targets;
|
||||
@@ -279,10 +281,7 @@ router.post(
|
||||
operation: "rbac_host_share_snapshot_failed",
|
||||
hostId,
|
||||
accessId: accessGrant.id,
|
||||
error:
|
||||
snapshotError instanceof Error
|
||||
? snapshotError.message
|
||||
: "Unknown error",
|
||||
error: getErrorMessage(snapshotError),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -492,10 +491,7 @@ router.post(
|
||||
operation: "rbac_folder_share_snapshot_failed",
|
||||
hostId: host.id,
|
||||
accessId: accessGrant.id,
|
||||
error:
|
||||
snapshotError instanceof Error
|
||||
? snapshotError.message
|
||||
: "Unknown error",
|
||||
error: getErrorMessage(snapshotError),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1433,10 +1429,7 @@ router.delete(
|
||||
operation: "remove_role_secret_cleanup",
|
||||
targetUserId,
|
||||
roleId,
|
||||
error:
|
||||
cleanupError instanceof Error
|
||||
? cleanupError.message
|
||||
: "Unknown error",
|
||||
error: getErrorMessage(cleanupError),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { apiLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import type { Request, Response } from "express";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import {
|
||||
createCurrentSessionRecordingRepository,
|
||||
|
||||
@@ -4,6 +4,59 @@ export interface SnippetExecutionResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SnippetHostVars {
|
||||
ip?: string;
|
||||
username?: string;
|
||||
port?: number | string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const INPUT_PATTERN =
|
||||
/\$\{INPUT_(\d+)(?::([^}$]+))?\}|\$INPUT_(\d+)(?![a-zA-Z0-9_])/g;
|
||||
|
||||
function replaceVar(content: string, name: string, value?: string): string {
|
||||
if (value === undefined) return content;
|
||||
const pattern = new RegExp(`\\$\\{?${name}\\}?`, "g");
|
||||
return content.replace(pattern, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors src/ui/lib/snippet-variables.ts resolveSnippetContent. Frontend and
|
||||
* backend are separate builds, so this is kept as a small standalone copy
|
||||
* rather than a shared package for one pure function.
|
||||
*/
|
||||
export function resolveSnippetCommand(
|
||||
content: string,
|
||||
host: SnippetHostVars | null,
|
||||
inputValues: Record<string, string> = {},
|
||||
): string {
|
||||
let resolved = content;
|
||||
|
||||
resolved = replaceVar(resolved, "HOST", host?.ip);
|
||||
resolved = replaceVar(resolved, "USER", host?.username);
|
||||
resolved = replaceVar(
|
||||
resolved,
|
||||
"PORT",
|
||||
host?.port !== undefined ? String(host.port) : undefined,
|
||||
);
|
||||
resolved = replaceVar(resolved, "NAME", host?.name);
|
||||
|
||||
resolved = resolved.replace(
|
||||
INPUT_PATTERN,
|
||||
(
|
||||
fullMatch,
|
||||
braceDigits: string | undefined,
|
||||
_label,
|
||||
plainDigits: string | undefined,
|
||||
) => {
|
||||
const key = `INPUT_${braceDigits ?? plainDigits}`;
|
||||
return key in inputValues ? inputValues[key] : fullMatch;
|
||||
},
|
||||
);
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function getSnippetExecutionTimeoutMs(
|
||||
value = process.env.SNIPPET_EXECUTION_TIMEOUT_SECONDS,
|
||||
): number | undefined {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import type { Request, Response } from "express";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { authLogger, databaseLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
|
||||
@@ -8,6 +8,7 @@ import { extractSnippetReorderUpdates } from "./snippets-reorder.js";
|
||||
import {
|
||||
createSnippetExecutionResult,
|
||||
getSnippetExecutionTimeoutMs,
|
||||
resolveSnippetCommand,
|
||||
} from "./snippets-execution.js";
|
||||
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||
import {
|
||||
@@ -181,10 +182,7 @@ router.post(
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to create snippet folder", err);
|
||||
res.status(500).json({
|
||||
error:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to create snippet folder",
|
||||
error: getErrorMessage(err, "Failed to create snippet folder"),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -268,10 +266,7 @@ router.put(
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to update snippet folder metadata", err);
|
||||
res.status(500).json({
|
||||
error:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to update snippet folder metadata",
|
||||
error: getErrorMessage(err, "Failed to update snippet folder metadata"),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -356,10 +351,7 @@ router.put(
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to rename snippet folder", err);
|
||||
res.status(500).json({
|
||||
error:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to rename snippet folder",
|
||||
error: getErrorMessage(err, "Failed to rename snippet folder"),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -430,10 +422,7 @@ router.delete(
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to delete snippet folder", err);
|
||||
res.status(500).json({
|
||||
error:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to delete snippet folder",
|
||||
error: getErrorMessage(err, "Failed to delete snippet folder"),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -513,8 +502,7 @@ router.put(
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to reorder snippets", err);
|
||||
res.status(500).json({
|
||||
error:
|
||||
err instanceof Error ? err.message : "Failed to reorder snippets",
|
||||
error: getErrorMessage(err, "Failed to reorder snippets"),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -539,6 +527,15 @@ router.put(
|
||||
* type: integer
|
||||
* hostId:
|
||||
* type: integer
|
||||
* inputValues:
|
||||
* type: object
|
||||
* description: >
|
||||
* Optional resolved values for $INPUT_n placeholders in the
|
||||
* snippet content, keyed by "INPUT_n". Host variables
|
||||
* ($HOST, $USER, $PORT, $NAME) are resolved server-side per
|
||||
* target host and do not need to be passed here.
|
||||
* additionalProperties:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Snippet executed successfully.
|
||||
@@ -555,7 +552,7 @@ router.post(
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { snippetId, hostId } = req.body;
|
||||
const { snippetId, hostId, inputValues } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !snippetId || !hostId) {
|
||||
authLogger.warn("Invalid snippet execution request", {
|
||||
@@ -575,6 +572,12 @@ router.post(
|
||||
return res.status(404).json({ error: "Snippet not found" });
|
||||
}
|
||||
|
||||
if (snippet.isNote) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Notes cannot be executed on a host" });
|
||||
}
|
||||
|
||||
const { Client } = await import("ssh2");
|
||||
const repository = createCurrentHostResolutionRepository();
|
||||
const host = await repository.findHostById(parseInt(hostId), userId);
|
||||
@@ -607,6 +610,17 @@ router.post(
|
||||
let output = "";
|
||||
let errorOutput = "";
|
||||
|
||||
const resolvedCommand = resolveSnippetCommand(
|
||||
snippet.content,
|
||||
{
|
||||
ip: host.ip,
|
||||
username: host.username,
|
||||
port: host.port,
|
||||
name: host.name,
|
||||
},
|
||||
inputValues && typeof inputValues === "object" ? inputValues : {},
|
||||
);
|
||||
|
||||
const executePromise = new Promise<{
|
||||
success: boolean;
|
||||
output: string;
|
||||
@@ -616,7 +630,7 @@ router.post(
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
|
||||
conn.on("ready", () => {
|
||||
conn.exec(snippet.content, (err, stream) => {
|
||||
conn.exec(resolvedCommand, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(timeout);
|
||||
conn.end();
|
||||
@@ -757,7 +771,7 @@ router.post(
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to execute snippet", err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : "Failed to execute snippet",
|
||||
error: getErrorMessage(err, "Failed to execute snippet"),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -1014,7 +1028,7 @@ router.get(
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to fetch snippet", err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : "Failed to fetch snippet",
|
||||
error: getErrorMessage(err, "Failed to fetch snippet"),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -1045,6 +1059,9 @@ router.get(
|
||||
* type: string
|
||||
* order:
|
||||
* type: integer
|
||||
* isNote:
|
||||
* type: boolean
|
||||
* description: When true, the snippet is a note (copy/paste only, not directly executable on a host).
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Snippet created successfully.
|
||||
@@ -1059,7 +1076,8 @@ router.post(
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { name, content, description, folder, order, hostFilter } = req.body;
|
||||
const { name, content, description, folder, order, hostFilter, isNote } =
|
||||
req.body;
|
||||
|
||||
if (
|
||||
!isNonEmptyString(userId) ||
|
||||
@@ -1085,6 +1103,7 @@ router.post(
|
||||
folder,
|
||||
order,
|
||||
hostFilter,
|
||||
isNote,
|
||||
},
|
||||
);
|
||||
databaseLogger.info("Command snippet created", {
|
||||
@@ -1111,7 +1130,7 @@ router.post(
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to create snippet", err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : "Failed to create snippet",
|
||||
error: getErrorMessage(err, "Failed to create snippet"),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -1148,6 +1167,9 @@ router.post(
|
||||
* type: string
|
||||
* order:
|
||||
* type: integer
|
||||
* isNote:
|
||||
* type: boolean
|
||||
* description: When true, the snippet is a note (copy/paste only, not directly executable on a host).
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The updated snippet.
|
||||
@@ -1206,7 +1228,7 @@ router.put(
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to update snippet", err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : "Failed to update snippet",
|
||||
error: getErrorMessage(err, "Failed to update snippet"),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -1291,7 +1313,7 @@ router.delete(
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to delete snippet", err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : "Failed to delete snippet",
|
||||
error: getErrorMessage(err, "Failed to delete snippet"),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
decryptSsoConfigSecrets,
|
||||
encryptSsoConfigSecrets,
|
||||
} from "../../utils/system-secret-crypto.js";
|
||||
import { isTrustedProxyAuthEnabled } from "../../utils/trusted-proxy-auth.js";
|
||||
|
||||
function isOidcLike(type: SSOProviderType): boolean {
|
||||
return type === "oidc" || type === "github" || type === "google";
|
||||
}
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
@@ -188,6 +193,12 @@ export function registerSSOProviderRoutes(router: Router): void {
|
||||
if (!validTypes.includes(type)) {
|
||||
return res.status(400).json({ error: "Invalid provider type" });
|
||||
}
|
||||
if (isTrustedProxyAuthEnabled() && enabled && isOidcLike(type)) {
|
||||
return res.status(409).json({
|
||||
error:
|
||||
"OIDC providers cannot be enabled with trusted proxy authentication",
|
||||
});
|
||||
}
|
||||
|
||||
const configWithDefaults =
|
||||
type === "github" || type === "google"
|
||||
@@ -317,6 +328,19 @@ export function registerSSOProviderRoutes(router: Router): void {
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const effectiveType = type ?? (existing.type as SSOProviderType);
|
||||
const effectiveEnabled = enabled ?? existing.enabled;
|
||||
if (
|
||||
isTrustedProxyAuthEnabled() &&
|
||||
effectiveEnabled &&
|
||||
isOidcLike(effectiveType)
|
||||
) {
|
||||
return res.status(409).json({
|
||||
error:
|
||||
"OIDC providers cannot be enabled with trusted proxy authentication",
|
||||
});
|
||||
}
|
||||
|
||||
let encryptedConfig = existing.config;
|
||||
if (rawConfig !== undefined) {
|
||||
const existingDecrypted = await decryptProviderConfig(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Request, Response } from "express";
|
||||
import express from "express";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { and, eq, type SQL } from "drizzle-orm";
|
||||
import {
|
||||
hosts,
|
||||
sshCredentials,
|
||||
@@ -85,6 +84,31 @@ export function isValidEntityType(value: unknown): value is SyncEntityType {
|
||||
return typeof value === "string" && VALID_ENTITY_TYPES.has(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Locates the stored row a sync payload corresponds to.
|
||||
*
|
||||
* Read and write have to agree on this. A singleton entity is keyed on its
|
||||
* owner rather than a sync id, and `user_preferences` — the only singleton —
|
||||
* has no `id` column at all, so an update cannot fall back to one: `table.id`
|
||||
* is undefined there and drizzle emits `WHERE = ?`.
|
||||
*/
|
||||
export function locateSyncRow(
|
||||
entityType: SyncEntityType,
|
||||
userId: string,
|
||||
syncId: string,
|
||||
): SQL {
|
||||
const { table, singleton } = ENTITY_CONFIG[entityType];
|
||||
|
||||
if (singleton) {
|
||||
return eq(table.userId, userId);
|
||||
}
|
||||
|
||||
return and(
|
||||
eq((table as typeof hosts).syncId, syncId),
|
||||
eq(table.userId, userId),
|
||||
)!;
|
||||
}
|
||||
|
||||
async function findReferenceSyncId(
|
||||
context: RepositoryContext,
|
||||
entityType: SyncReferenceEntity,
|
||||
@@ -301,19 +325,12 @@ router.post(
|
||||
}
|
||||
|
||||
try {
|
||||
const { table, singleton } = ENTITY_CONFIG[entityType];
|
||||
const { table } = ENTITY_CONFIG[entityType];
|
||||
const context = createCurrentRepositoryContext();
|
||||
|
||||
await context.drizzle
|
||||
.delete(table as typeof hosts)
|
||||
.where(
|
||||
singleton
|
||||
? eq(table.userId, userId)
|
||||
: and(
|
||||
eq((table as typeof hosts).syncId, syncId),
|
||||
eq(table.userId, userId),
|
||||
),
|
||||
);
|
||||
.where(locateSyncRow(entityType, userId, syncId));
|
||||
|
||||
await createCurrentSyncTombstoneRepository().record(
|
||||
userId,
|
||||
@@ -372,20 +389,17 @@ router.post(
|
||||
}
|
||||
|
||||
try {
|
||||
// singleton is still needed below: those tables have no sync_id column
|
||||
// for the insert to populate.
|
||||
const { table, singleton } = ENTITY_CONFIG[entityType];
|
||||
const context = createCurrentRepositoryContext();
|
||||
|
||||
const locateRow = locateSyncRow(entityType, userId, syncId);
|
||||
|
||||
const existingRows = await context.drizzle
|
||||
.select()
|
||||
.from(table as typeof hosts)
|
||||
.where(
|
||||
singleton
|
||||
? eq(table.userId, userId)
|
||||
: and(
|
||||
eq((table as typeof hosts).syncId, syncId),
|
||||
eq(table.userId, userId),
|
||||
),
|
||||
)
|
||||
.where(locateRow)
|
||||
.limit(1);
|
||||
const existing = existingRows[0] as Record<string, unknown> | undefined;
|
||||
|
||||
@@ -407,12 +421,7 @@ router.post(
|
||||
const updatedRows = await context.drizzle
|
||||
.update(table as typeof hosts)
|
||||
.set(encryptedPayload)
|
||||
.where(
|
||||
and(
|
||||
eq((table as typeof hosts).id, existing.id as number),
|
||||
eq(table.userId, userId),
|
||||
),
|
||||
)
|
||||
.where(locateRow)
|
||||
.returning();
|
||||
resultRow = updatedRows[0] as Record<string, unknown>;
|
||||
} else {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Router } from "express";
|
||||
import type { RequestHandler, Router as ExpressRouter } from "express";
|
||||
import {
|
||||
Router,
|
||||
type RequestHandler,
|
||||
type Router as ExpressRouter,
|
||||
} from "express";
|
||||
import { apiLogger } from "../../utils/logger.js";
|
||||
import { getProxyAgent } from "../../utils/proxy-agent.js";
|
||||
import { getFetchDispatcher } from "../../utils/proxy-agent.js";
|
||||
import { createCurrentSettingsRepository } from "../repositories/factory.js";
|
||||
|
||||
interface TailscaleDevice {
|
||||
@@ -23,10 +26,15 @@ interface TailscaleAPIDevice {
|
||||
nodeId?: string;
|
||||
}
|
||||
|
||||
const TAILSCALE_API_BASE = "https://api.tailscale.com/api/v2";
|
||||
const DEFAULT_TAILSCALE_API_BASE = "https://api.tailscale.com/api/v2";
|
||||
|
||||
const router = Router();
|
||||
|
||||
function normalizeApiBase(raw: string | null): string {
|
||||
const trimmed = (raw ?? "").trim().replace(/\/+$/, "");
|
||||
return trimmed || DEFAULT_TAILSCALE_API_BASE;
|
||||
}
|
||||
|
||||
export function registerTailscaleRoutes(
|
||||
app: ExpressRouter,
|
||||
authenticateJWT: RequestHandler,
|
||||
@@ -56,20 +64,22 @@ export function registerTailscaleRoutes(
|
||||
*/
|
||||
router.get("/devices", authenticateJWT, async (_req, res) => {
|
||||
try {
|
||||
const apiKey =
|
||||
(await createCurrentSettingsRepository().get("tailscale_api_key")) ??
|
||||
"";
|
||||
const settingsRepo = createCurrentSettingsRepository();
|
||||
const apiKey = (await settingsRepo.get("tailscale_api_key")) ?? "";
|
||||
if (!apiKey) {
|
||||
return res.json({ devices: [], hasApiKey: false });
|
||||
}
|
||||
const apiBase = normalizeApiBase(
|
||||
await settingsRepo.get("tailscale_api_base_url"),
|
||||
);
|
||||
|
||||
const url = `${TAILSCALE_API_BASE}/tailnet/-/devices?fields=all`;
|
||||
const url = `${apiBase}/tailnet/-/devices?fields=all`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"User-Agent": "Termix/1.0",
|
||||
},
|
||||
dispatcher: getProxyAgent(url),
|
||||
dispatcher: getFetchDispatcher(url),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -78,13 +88,17 @@ export function registerTailscaleRoutes(
|
||||
status: response.status,
|
||||
});
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return res
|
||||
.status(401)
|
||||
.json({ error: "Invalid Tailscale API key", devices: [] });
|
||||
return res.status(401).json({
|
||||
error: "Invalid Tailscale API key",
|
||||
devices: [],
|
||||
hasApiKey: true,
|
||||
});
|
||||
}
|
||||
return res
|
||||
.status(502)
|
||||
.json({ error: "Tailscale API error", devices: [] });
|
||||
return res.status(502).json({
|
||||
error: "Tailscale API error",
|
||||
devices: [],
|
||||
hasApiKey: true,
|
||||
});
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { devices: TailscaleAPIDevice[] };
|
||||
@@ -103,9 +117,11 @@ export function registerTailscaleRoutes(
|
||||
apiLogger.error("Failed to fetch Tailscale devices", err, {
|
||||
operation: "tailscale_devices",
|
||||
});
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to fetch Tailscale devices", devices: [] });
|
||||
res.status(500).json({
|
||||
error: "Failed to fetch Tailscale devices",
|
||||
devices: [],
|
||||
hasApiKey: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import path from "path";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
|
||||
/**
|
||||
* Terminal image storage modes.
|
||||
*
|
||||
* - `local`: always write to the backend's mapped local storage. Deterministic:
|
||||
* never falls back to the remote SFTP path.
|
||||
* - `remote-sftp`: always write to the connected terminal's SSH host over SFTP.
|
||||
* Deterministic: never falls back to local storage.
|
||||
* - `auto`: pick by capability only — remote SFTP when a connected terminal
|
||||
* session exists, local storage otherwise. Configuration never influences
|
||||
* this choice.
|
||||
*/
|
||||
export const TERMINAL_IMAGE_STORAGE_MODES = [
|
||||
"auto",
|
||||
"local",
|
||||
"remote-sftp",
|
||||
] as const;
|
||||
|
||||
export type TerminalImageStorageMode =
|
||||
(typeof TERMINAL_IMAGE_STORAGE_MODES)[number];
|
||||
|
||||
export interface TerminalImageStorageSettings {
|
||||
mode: TerminalImageStorageMode;
|
||||
/** Absolute path on the Termix backend where local-mode files are written. */
|
||||
localDir: string;
|
||||
/**
|
||||
* Absolute path handed to the terminal agent in local mode. This is the
|
||||
* host-side view of `localDir` (e.g. /tmp mapped into the container); it is
|
||||
* the only path ever exposed to callers.
|
||||
*/
|
||||
hostPath: string;
|
||||
ttlMs: number;
|
||||
maxCount: number;
|
||||
maxBytes: number;
|
||||
/** Both localDir and hostPath were explicitly configured and must be probed. */
|
||||
localMappingConfigured: boolean;
|
||||
}
|
||||
|
||||
/** Settings-table keys. Persisted values always win over environment. */
|
||||
export const TERMINAL_IMAGE_STORAGE_KEYS = {
|
||||
mode: "terminal_image_storage_mode",
|
||||
localDir: "terminal_image_local_dir",
|
||||
hostPath: "terminal_image_host_path",
|
||||
ttlMs: "terminal_image_ttl_ms",
|
||||
maxCount: "terminal_image_max_count",
|
||||
maxBytes: "terminal_image_max_storage_bytes",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Legacy environment variables from the original env-only configuration. They
|
||||
* seed defaults only when no database value exists for the same field.
|
||||
*/
|
||||
export const TERMINAL_IMAGE_STORAGE_ENV = {
|
||||
mode: "TERMIX_IMAGE_STORAGE_MODE",
|
||||
localDir: "TERMIX_IMAGE_DIR",
|
||||
hostPath: "TERMIX_IMAGE_HOST_PATH",
|
||||
ttlMs: "TERMIX_IMAGE_TTL_MS",
|
||||
maxCount: "TERMIX_MAX_IMAGE_COUNT",
|
||||
maxBytes: "TERMIX_MAX_IMAGE_STORAGE_BYTES",
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_IMAGE_TTL_MS = 3_600_000;
|
||||
export const DEFAULT_IMAGE_MAX_COUNT = 100;
|
||||
export const DEFAULT_IMAGE_MAX_BYTES = 5_368_709_120;
|
||||
export const DEFAULT_IMAGE_HOST_PATH = "/tmp/termix-image-v0";
|
||||
export const MIN_IMAGE_MAX_BYTES = 1_048_576;
|
||||
|
||||
export function defaultImageLocalDir(env: NodeJS.ProcessEnv): string {
|
||||
return path.resolve(
|
||||
path.join(env.DATA_DIR || "./db/data", "termix-image-v0"),
|
||||
);
|
||||
}
|
||||
|
||||
export function parseTerminalImageStorageMode(
|
||||
value: unknown,
|
||||
): TerminalImageStorageMode | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return (TERMINAL_IMAGE_STORAGE_MODES as readonly string[]).includes(
|
||||
normalized,
|
||||
)
|
||||
? (normalized as TerminalImageStorageMode)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local write directory must be an absolute path without NUL bytes. Relative
|
||||
* values are rejected rather than resolved: a relative entry silently depends
|
||||
* on the process cwd, which differs between Docker, systemd and dev runs.
|
||||
*/
|
||||
export function parseImageLocalDir(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || hasUnsafePathSyntax(trimmed)) return null;
|
||||
if (!path.isAbsolute(trimmed)) return null;
|
||||
return path.resolve(trimmed);
|
||||
}
|
||||
|
||||
/** Agent-visible path. Always POSIX-style and absolute. */
|
||||
export function parseImageHostPath(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || hasUnsafePathSyntax(trimmed)) return null;
|
||||
if (!path.posix.isAbsolute(trimmed)) return null;
|
||||
return path.posix.normalize(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Numeric fields: unparseable values are rejected (caller falls through to the
|
||||
* next source); parseable but out-of-range values are clamped, matching the
|
||||
* original env-only behavior.
|
||||
*/
|
||||
function parseClampedInt(value: unknown, min: number): number | null {
|
||||
if (typeof value !== "string" && typeof value !== "number") return null;
|
||||
const parsed =
|
||||
typeof value === "number" ? value : Number.parseInt(value.trim(), 10);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
return Math.max(min, Math.trunc(parsed));
|
||||
}
|
||||
|
||||
interface SettingSource {
|
||||
get(key: string): Promise<string | null>;
|
||||
}
|
||||
|
||||
function warnInvalid(key: string, source: string): void {
|
||||
databaseLogger.warn("Ignoring invalid terminal image storage setting", {
|
||||
operation: "terminal_image_storage_settings_invalid",
|
||||
key,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
function hasUnsafePathSyntax(value: string): boolean {
|
||||
return (
|
||||
/[\u0000-\u001f\u007f]/.test(value) ||
|
||||
value.split(/[\\/]+/).some((segment) => segment === "..") ||
|
||||
value.includes("//") ||
|
||||
value.includes("\\")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the effective image storage settings.
|
||||
*
|
||||
* Per-field precedence: a valid persisted database value wins; a legacy
|
||||
* TERMIX_IMAGE_* variable seeds the default only when no database value
|
||||
* exists; otherwise the built-in default applies. Invalid values are skipped
|
||||
* with a warning and resolution falls through to the next source.
|
||||
*
|
||||
* Mode compatibility: with no mode in the database or environment, an explicit
|
||||
* legacy local mapping (TERMIX_IMAGE_DIR) keeps old deployments on `local`;
|
||||
* everything else defaults to `auto`.
|
||||
*/
|
||||
export async function resolveTerminalImageStorageSettings(
|
||||
settings: SettingSource,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<TerminalImageStorageSettings> {
|
||||
async function pick<T>(
|
||||
key: string,
|
||||
envName: string,
|
||||
parse: (value: unknown) => T | null,
|
||||
fallback: T,
|
||||
): Promise<T> {
|
||||
const stored = await settings.get(key);
|
||||
if (stored !== null) {
|
||||
const parsed = parse(stored);
|
||||
if (parsed !== null) return parsed;
|
||||
warnInvalid(key, "database");
|
||||
}
|
||||
const fromEnv = env[envName];
|
||||
if (fromEnv !== undefined) {
|
||||
const parsed = parse(fromEnv);
|
||||
if (parsed !== null) return parsed;
|
||||
warnInvalid(key, "environment");
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const dbModeRaw = await settings.get(TERMINAL_IMAGE_STORAGE_KEYS.mode);
|
||||
let mode: TerminalImageStorageMode | null = null;
|
||||
if (dbModeRaw !== null) {
|
||||
mode = parseTerminalImageStorageMode(dbModeRaw);
|
||||
if (mode === null)
|
||||
warnInvalid(TERMINAL_IMAGE_STORAGE_KEYS.mode, "database");
|
||||
}
|
||||
if (mode === null) {
|
||||
const envModeRaw = env[TERMINAL_IMAGE_STORAGE_ENV.mode];
|
||||
if (envModeRaw !== undefined) {
|
||||
mode = parseTerminalImageStorageMode(envModeRaw);
|
||||
if (mode === null)
|
||||
warnInvalid(TERMINAL_IMAGE_STORAGE_KEYS.mode, "environment");
|
||||
}
|
||||
}
|
||||
if (mode === null) {
|
||||
// Legacy deployments configured local storage purely through
|
||||
// TERMIX_IMAGE_DIR; keep them on local mode unless a database value says
|
||||
// otherwise.
|
||||
mode =
|
||||
env[TERMINAL_IMAGE_STORAGE_ENV.localDir] !== undefined ? "local" : "auto";
|
||||
}
|
||||
|
||||
const legacyLocalDir = parseImageLocalDir(
|
||||
env[TERMINAL_IMAGE_STORAGE_ENV.localDir],
|
||||
);
|
||||
const [localDir, hostPath, ttlMs, maxCount, maxBytes] = await Promise.all([
|
||||
pick(
|
||||
TERMINAL_IMAGE_STORAGE_KEYS.localDir,
|
||||
TERMINAL_IMAGE_STORAGE_ENV.localDir,
|
||||
parseImageLocalDir,
|
||||
defaultImageLocalDir(env),
|
||||
),
|
||||
pick(
|
||||
TERMINAL_IMAGE_STORAGE_KEYS.hostPath,
|
||||
TERMINAL_IMAGE_STORAGE_ENV.hostPath,
|
||||
parseImageHostPath,
|
||||
legacyLocalDir ?? DEFAULT_IMAGE_HOST_PATH,
|
||||
),
|
||||
pick(
|
||||
TERMINAL_IMAGE_STORAGE_KEYS.ttlMs,
|
||||
TERMINAL_IMAGE_STORAGE_ENV.ttlMs,
|
||||
(value) => parseClampedInt(value, 0),
|
||||
DEFAULT_IMAGE_TTL_MS,
|
||||
),
|
||||
pick(
|
||||
TERMINAL_IMAGE_STORAGE_KEYS.maxCount,
|
||||
TERMINAL_IMAGE_STORAGE_ENV.maxCount,
|
||||
(value) => parseClampedInt(value, 1),
|
||||
DEFAULT_IMAGE_MAX_COUNT,
|
||||
),
|
||||
pick(
|
||||
TERMINAL_IMAGE_STORAGE_KEYS.maxBytes,
|
||||
TERMINAL_IMAGE_STORAGE_ENV.maxBytes,
|
||||
(value) => parseClampedInt(value, MIN_IMAGE_MAX_BYTES),
|
||||
DEFAULT_IMAGE_MAX_BYTES,
|
||||
),
|
||||
]);
|
||||
|
||||
const persistedLocalDir = await settings.get(
|
||||
TERMINAL_IMAGE_STORAGE_KEYS.localDir,
|
||||
);
|
||||
const persistedHostPath = await settings.get(
|
||||
TERMINAL_IMAGE_STORAGE_KEYS.hostPath,
|
||||
);
|
||||
const localMappingConfigured =
|
||||
((persistedLocalDir !== null &&
|
||||
parseImageLocalDir(persistedLocalDir) !== null) ||
|
||||
parseImageLocalDir(env[TERMINAL_IMAGE_STORAGE_ENV.localDir]) !== null) &&
|
||||
((persistedHostPath !== null &&
|
||||
parseImageHostPath(persistedHostPath) !== null) ||
|
||||
parseImageHostPath(env[TERMINAL_IMAGE_STORAGE_ENV.hostPath]) !== null ||
|
||||
legacyLocalDir !== null);
|
||||
|
||||
return {
|
||||
mode,
|
||||
localDir,
|
||||
hostPath,
|
||||
ttlMs,
|
||||
maxCount,
|
||||
maxBytes,
|
||||
localMappingConfigured,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
exceedsImageStorageLimit,
|
||||
isExpiredImage,
|
||||
isImageFilename,
|
||||
} from "./terminal-image-utils.js";
|
||||
import type { TerminalImageStorageSettings } from "./terminal-image-storage-settings.js";
|
||||
|
||||
/**
|
||||
* Stable error codes for image storage failures. These are part of the upload
|
||||
* route's contract; `IMAGE_REMOTE_WRITE_FAILED` predates this module and must
|
||||
* not change.
|
||||
*/
|
||||
export type TerminalImageStorageErrorCode =
|
||||
| "IMAGE_STORAGE_LIMIT_REACHED"
|
||||
| "IMAGE_LOCAL_WRITE_FAILED"
|
||||
| "IMAGE_LOCAL_INSPECTION_FAILED"
|
||||
| "IMAGE_REMOTE_QUOTA_UNAVAILABLE"
|
||||
| "IMAGE_REMOTE_WRITE_FAILED";
|
||||
|
||||
export class TerminalImageStorageError extends Error {
|
||||
constructor(
|
||||
readonly code: TerminalImageStorageErrorCode,
|
||||
message: string,
|
||||
readonly cause?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "TerminalImageStorageError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface StoredTerminalImage {
|
||||
id: string;
|
||||
filename: string;
|
||||
/** Agent-visible path; never a backend-internal path. */
|
||||
shellPath: string;
|
||||
storage: "local" | "remote-sftp";
|
||||
}
|
||||
|
||||
/**
|
||||
* Mode selection for one upload. Explicit modes are deterministic — they are
|
||||
* returned regardless of capability and the route reports the failure; only
|
||||
* `auto` falls back, and only on capability (a connected terminal session
|
||||
* with SFTP), never on configuration.
|
||||
*/
|
||||
export function selectImageStorageMode(
|
||||
settings: Pick<
|
||||
TerminalImageStorageSettings,
|
||||
"mode" | "localMappingConfigured"
|
||||
>,
|
||||
capability: { remoteSftpAvailable: boolean; localHostVisible?: boolean },
|
||||
): "local" | "remote-sftp" | "unavailable" {
|
||||
if (settings.mode === "local") return "local";
|
||||
if (settings.mode === "remote-sftp") return "remote-sftp";
|
||||
if (settings.localMappingConfigured && capability.localHostVisible === true) {
|
||||
return "local";
|
||||
}
|
||||
return capability.remoteSftpAvailable ? "remote-sftp" : "unavailable";
|
||||
}
|
||||
|
||||
// Capacity checks and writes are serialized so concurrent uploads cannot
|
||||
// bypass the count or byte limits.
|
||||
let imageStorageQueue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
function withImageStorageLock<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = imageStorageQueue.then(operation, operation);
|
||||
imageStorageQueue = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function cleanupExpiredImages(
|
||||
localDir: string,
|
||||
ttlMs: number,
|
||||
): Promise<void> {
|
||||
const entries = await fs.readdir(localDir, { withFileTypes: true });
|
||||
const now = Date.now();
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isFile() && isImageFilename(entry.name))
|
||||
.map(async (entry) => {
|
||||
const filePath = path.join(localDir, entry.name);
|
||||
const stat = await fs.stat(filePath);
|
||||
if (stat && isExpiredImage(stat.mtimeMs, now, ttlMs)) {
|
||||
await fs.unlink(filePath).catch(() => undefined);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function getActiveImageStorageUsage(
|
||||
localDir: string,
|
||||
ttlMs: number,
|
||||
): Promise<{ fileCount: number; totalBytes: number }> {
|
||||
const entries = await fs.readdir(localDir, { withFileTypes: true });
|
||||
const now = Date.now();
|
||||
const stats = await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isFile() && isImageFilename(entry.name))
|
||||
.map(async (entry) => {
|
||||
const stat = await fs.stat(path.join(localDir, entry.name));
|
||||
return !isExpiredImage(stat.mtimeMs, now, ttlMs) ? stat : null;
|
||||
}),
|
||||
);
|
||||
|
||||
return stats.reduce(
|
||||
(usage, stat) => {
|
||||
if (stat) {
|
||||
usage.fileCount += 1;
|
||||
usage.totalBytes += stat.size;
|
||||
}
|
||||
return usage;
|
||||
},
|
||||
{ fileCount: 0, totalBytes: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Local mapped-storage adapter. Enforces the TTL/count/byte policy and raises
|
||||
* `IMAGE_STORAGE_LIMIT_REACHED` (HTTP 507 at the route) when the caps are hit.
|
||||
* The returned shellPath is built from the agent-visible hostPath — the
|
||||
* backend's own localDir is never exposed.
|
||||
*/
|
||||
export async function storeImageLocally(
|
||||
image: Buffer,
|
||||
settings: TerminalImageStorageSettings,
|
||||
): Promise<StoredTerminalImage> {
|
||||
return withImageStorageLock(async () => {
|
||||
let usage: { fileCount: number; totalBytes: number };
|
||||
try {
|
||||
await fs.mkdir(settings.localDir, { recursive: true });
|
||||
await cleanupExpiredImages(settings.localDir, settings.ttlMs);
|
||||
usage = await getActiveImageStorageUsage(
|
||||
settings.localDir,
|
||||
settings.ttlMs,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new TerminalImageStorageError(
|
||||
"IMAGE_LOCAL_INSPECTION_FAILED",
|
||||
"Unable to inspect local image storage",
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (
|
||||
exceedsImageStorageLimit(
|
||||
usage.fileCount,
|
||||
usage.totalBytes,
|
||||
image.length,
|
||||
settings.maxCount,
|
||||
settings.maxBytes,
|
||||
)
|
||||
) {
|
||||
throw new TerminalImageStorageError(
|
||||
"IMAGE_STORAGE_LIMIT_REACHED",
|
||||
"Image storage limit reached",
|
||||
);
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const filename = `${id}.png`;
|
||||
try {
|
||||
await fs.writeFile(path.join(settings.localDir, filename), image);
|
||||
} catch (error) {
|
||||
await fs
|
||||
.rm(path.join(settings.localDir, filename), { force: true })
|
||||
.catch(() => undefined);
|
||||
throw new TerminalImageStorageError(
|
||||
"IMAGE_LOCAL_WRITE_FAILED",
|
||||
"Failed to write image to local storage",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
filename,
|
||||
shellPath: path.posix.join(settings.hostPath, filename),
|
||||
storage: "local",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Remote directory (on the SSH host the terminal is connected to) that
|
||||
// uploaded/pasted images are written into. Always POSIX-style: this is a
|
||||
// path on the remote shell, not on the Termix backend's own filesystem.
|
||||
export const REMOTE_IMAGE_DIR = "/tmp/termix-images";
|
||||
|
||||
/** Minimal SFTP surface the remote adapter needs (satisfied by ssh2). */
|
||||
export interface ImageSftpClient {
|
||||
mkdir(
|
||||
dir: string,
|
||||
attrsOrCallback: { mode?: number } | ((err?: Error) => void),
|
||||
callback?: (err?: Error) => void,
|
||||
): void;
|
||||
createWriteStream(
|
||||
remotePath: string,
|
||||
options?: { mode?: number },
|
||||
): NodeJS.WritableStream;
|
||||
stat?: (
|
||||
dir: string,
|
||||
callback: (
|
||||
error: Error | undefined,
|
||||
attrs?: { mode?: number; mtime?: number },
|
||||
) => void,
|
||||
) => void;
|
||||
lstat?: (
|
||||
dir: string,
|
||||
callback: (
|
||||
error: Error | undefined,
|
||||
attrs?: { mode?: number; mtime?: number },
|
||||
) => void,
|
||||
) => void;
|
||||
chmod?: (
|
||||
dir: string,
|
||||
mode: number,
|
||||
callback: (error?: Error) => void,
|
||||
) => void;
|
||||
readdir?: (
|
||||
dir: string,
|
||||
callback: (error: Error | undefined, entries: ImageSftpEntry[]) => void,
|
||||
) => void;
|
||||
unlink?: (remotePath: string, callback: (error?: Error) => void) => void;
|
||||
rmdir?: (dir: string, callback: (error?: Error) => void) => void;
|
||||
end?: () => void;
|
||||
}
|
||||
|
||||
interface ImageSftpEntry {
|
||||
filename: string;
|
||||
attrs?: { mtime?: number; size?: number };
|
||||
}
|
||||
|
||||
export interface ImageSshExecClient {
|
||||
exec(
|
||||
command: string,
|
||||
callback: (error: Error | undefined, stream?: ImageExecStream) => void,
|
||||
): void;
|
||||
}
|
||||
|
||||
interface ImageExecStream {
|
||||
on(event: "close", listener: (code: number | null) => void): this;
|
||||
on(event: "error", listener: (error: Error) => void): this;
|
||||
resume(): void;
|
||||
}
|
||||
|
||||
function quoteRemotePath(value: string): string {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function execBounded(
|
||||
sshConn: ImageSshExecClient,
|
||||
command: string,
|
||||
timeoutMs = 3_000,
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (result: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(result);
|
||||
};
|
||||
const timer = setTimeout(() => finish(false), timeoutMs);
|
||||
sshConn.exec(command, (error, stream) => {
|
||||
if (error || !stream) {
|
||||
clearTimeout(timer);
|
||||
finish(false);
|
||||
return;
|
||||
}
|
||||
stream.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
finish(code === 0);
|
||||
});
|
||||
stream.on("error", () => {
|
||||
clearTimeout(timer);
|
||||
finish(false);
|
||||
});
|
||||
stream.resume();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function withTimeout<T>(
|
||||
operation: Promise<T>,
|
||||
timeoutMs: number,
|
||||
message: string,
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
operation.then(
|
||||
(value) => {
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
},
|
||||
(error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Verify a configured local mapping from the currently connected SSH session. */
|
||||
export async function probeLocalImageVisibility(
|
||||
sshConn: ImageSshExecClient,
|
||||
settings: Pick<TerminalImageStorageSettings, "localDir" | "hostPath">,
|
||||
): Promise<boolean> {
|
||||
const filename = `.termix-image-probe-${randomUUID()}`;
|
||||
const localProbe = path.join(settings.localDir, filename);
|
||||
const remoteProbe = path.posix.join(settings.hostPath, filename);
|
||||
await fs.mkdir(settings.localDir, { recursive: true });
|
||||
await fs.writeFile(localProbe, "termix-image-probe", { flag: "wx" });
|
||||
try {
|
||||
return await execBounded(
|
||||
sshConn,
|
||||
`test -f -- ${quoteRemotePath(remoteProbe)}`,
|
||||
);
|
||||
} finally {
|
||||
await fs.unlink(localProbe).catch(() => undefined);
|
||||
await execBounded(sshConn, `rm -f -- ${quoteRemotePath(remoteProbe)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function sftpMkdir(sftp: ImageSftpClient, dir: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.mkdir(dir, { mode: 0o700 }, (err) => {
|
||||
if (!err) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const inspect = (sftp.lstat ?? sftp.stat)?.bind(sftp);
|
||||
if (!inspect) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
inspect(dir, (inspectError, attrs) => {
|
||||
if (inspectError || !attrs) {
|
||||
reject(inspectError ?? err);
|
||||
return;
|
||||
}
|
||||
if (attrs.mode !== undefined && (attrs.mode & 0o170000) !== 0o040000) {
|
||||
reject(new Error("Remote image path is not a directory"));
|
||||
return;
|
||||
}
|
||||
if (!sftp.chmod) {
|
||||
reject(
|
||||
new Error("Remote image directory permissions cannot be verified"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
sftp.chmod(dir, 0o700, (chmodError) => {
|
||||
if (chmodError) reject(chmodError);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const REMOTE_IMAGE_LOCK_DIR = `${REMOTE_IMAGE_DIR}/.termix-write-lock`;
|
||||
const REMOTE_IMAGE_LOCK_LEASE_MS = 60_000;
|
||||
|
||||
function waitForRemoteImageLock(
|
||||
sftp: ImageSftpClient,
|
||||
attempts = 60,
|
||||
): Promise<() => Promise<void>> {
|
||||
if (!sftp.rmdir) {
|
||||
return Promise.reject(
|
||||
new TerminalImageStorageError(
|
||||
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
|
||||
"Remote image storage lock cannot be verified",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let remaining = attempts;
|
||||
const tryAcquire = () => {
|
||||
sftp.mkdir(REMOTE_IMAGE_LOCK_DIR, { mode: 0o700 }, (error) => {
|
||||
if (!error) {
|
||||
resolve(
|
||||
() =>
|
||||
new Promise<void>((releaseResolve, releaseReject) => {
|
||||
sftp.rmdir!(REMOTE_IMAGE_LOCK_DIR, (releaseError) =>
|
||||
releaseError ? releaseReject(releaseError) : releaseResolve(),
|
||||
);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const inspect = (sftp.lstat ?? sftp.stat)?.bind(sftp);
|
||||
if (!inspect) {
|
||||
reject(
|
||||
new TerminalImageStorageError(
|
||||
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
|
||||
"Remote image storage lock cannot be verified",
|
||||
error,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
inspect(REMOTE_IMAGE_LOCK_DIR, (inspectError, attrs) => {
|
||||
const stale =
|
||||
!inspectError &&
|
||||
typeof attrs?.mtime === "number" &&
|
||||
Date.now() - attrs.mtime * 1000 > REMOTE_IMAGE_LOCK_LEASE_MS;
|
||||
if (stale) {
|
||||
sftp.rmdir!(REMOTE_IMAGE_LOCK_DIR, (removeError) => {
|
||||
if (removeError) {
|
||||
if (--remaining <= 0) {
|
||||
reject(
|
||||
new TerminalImageStorageError(
|
||||
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
|
||||
"Stale remote image storage lock cannot be removed",
|
||||
removeError,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setTimeout(tryAcquire, 50);
|
||||
return;
|
||||
}
|
||||
tryAcquire();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (--remaining <= 0) {
|
||||
reject(
|
||||
new TerminalImageStorageError(
|
||||
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
|
||||
"Remote image storage lock is unavailable",
|
||||
error,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setTimeout(tryAcquire, 50);
|
||||
});
|
||||
});
|
||||
};
|
||||
tryAcquire();
|
||||
});
|
||||
}
|
||||
|
||||
function sftpWriteFile(
|
||||
sftp: ImageSftpClient,
|
||||
remotePath: string,
|
||||
data: Buffer,
|
||||
timeoutMs = 10_000,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const stream = sftp.createWriteStream(remotePath, {
|
||||
mode: 0o600,
|
||||
}) as NodeJS.WritableStream & {
|
||||
destroy?: () => void;
|
||||
};
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
stream.destroy?.();
|
||||
reject(new Error("SFTP image write timed out"));
|
||||
}, timeoutMs);
|
||||
const finish = (error?: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
};
|
||||
stream.on("error", (error: Error) => finish(error));
|
||||
stream.on("close", () => finish());
|
||||
stream.end(data);
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanupExpiredRemoteImages(
|
||||
sftp: ImageSftpClient,
|
||||
ttlMs: number | undefined,
|
||||
nowMs = Date.now(),
|
||||
): Promise<void> {
|
||||
if (!ttlMs || ttlMs <= 0 || !sftp.readdir || !sftp.unlink) return;
|
||||
const entries = await new Promise<ImageSftpEntry[]>((resolve) => {
|
||||
sftp.readdir!(REMOTE_IMAGE_DIR, (error, result) => {
|
||||
resolve(error ? [] : result);
|
||||
});
|
||||
});
|
||||
const cutoffSeconds = (nowMs - ttlMs) / 1000;
|
||||
const uuidPng =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.png$/i;
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter(
|
||||
(entry) =>
|
||||
uuidPng.test(entry.filename) &&
|
||||
typeof entry.attrs?.mtime === "number" &&
|
||||
entry.attrs.mtime < cutoffSeconds,
|
||||
)
|
||||
.map((entry) =>
|
||||
withTimeout(
|
||||
new Promise<void>((resolve) => {
|
||||
sftp.unlink!(`${REMOTE_IMAGE_DIR}/${entry.filename}`, () =>
|
||||
resolve(),
|
||||
);
|
||||
}),
|
||||
3_000,
|
||||
"SFTP cleanup operation timed out",
|
||||
).catch(() => undefined),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const REMOTE_UUID_PNG_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.png$/i;
|
||||
|
||||
async function enforceRemoteImageLimits(
|
||||
sftp: ImageSftpClient,
|
||||
imageBytes: number,
|
||||
maxCount: number,
|
||||
maxBytes: number,
|
||||
): Promise<void> {
|
||||
if (!sftp.readdir) {
|
||||
throw new TerminalImageStorageError(
|
||||
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
|
||||
"Remote image storage limits cannot be verified",
|
||||
);
|
||||
}
|
||||
let entries: ImageSftpEntry[];
|
||||
try {
|
||||
entries = await new Promise<ImageSftpEntry[]>((resolve, reject) => {
|
||||
sftp.readdir!(REMOTE_IMAGE_DIR, (error, result) => {
|
||||
if (error) reject(error);
|
||||
else resolve(result);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
throw new TerminalImageStorageError(
|
||||
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
|
||||
"Remote image storage limits cannot be verified",
|
||||
error,
|
||||
);
|
||||
}
|
||||
const images = entries.filter((entry) =>
|
||||
REMOTE_UUID_PNG_PATTERN.test(entry.filename),
|
||||
);
|
||||
const totalBytes = images.reduce((sum, entry) => {
|
||||
if (typeof entry.attrs?.size !== "number") {
|
||||
throw new TerminalImageStorageError(
|
||||
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
|
||||
"Remote image storage limits cannot be verified",
|
||||
);
|
||||
}
|
||||
return sum + entry.attrs.size;
|
||||
}, 0);
|
||||
if (images.length >= maxCount || totalBytes + imageBytes > maxBytes) {
|
||||
throw new TerminalImageStorageError(
|
||||
"IMAGE_STORAGE_LIMIT_REACHED",
|
||||
"Image storage limit reached",
|
||||
);
|
||||
}
|
||||
}
|
||||
async function storeImageViaSftpUnlocked(
|
||||
sftp: ImageSftpClient,
|
||||
image: Buffer,
|
||||
options: {
|
||||
writeTimeoutMs?: number;
|
||||
ttlMs?: number;
|
||||
maxCount?: number;
|
||||
maxBytes?: number;
|
||||
nowMs?: number;
|
||||
} = {},
|
||||
): Promise<StoredTerminalImage> {
|
||||
const id = randomUUID();
|
||||
const filename = `${id}.png`;
|
||||
const remotePath = `${REMOTE_IMAGE_DIR}/${filename}`;
|
||||
|
||||
let releaseRemoteLock: (() => Promise<void>) | undefined;
|
||||
let operationError: TerminalImageStorageError | undefined;
|
||||
try {
|
||||
await withTimeout(
|
||||
sftpMkdir(sftp, REMOTE_IMAGE_DIR),
|
||||
3_000,
|
||||
"SFTP directory operation timed out",
|
||||
);
|
||||
releaseRemoteLock = await withTimeout(
|
||||
waitForRemoteImageLock(sftp),
|
||||
10_000,
|
||||
"SFTP lock operation timed out",
|
||||
);
|
||||
await withTimeout(
|
||||
cleanupExpiredRemoteImages(sftp, options.ttlMs, options.nowMs),
|
||||
5_000,
|
||||
"SFTP cleanup operation timed out",
|
||||
);
|
||||
if (options.maxCount !== undefined || options.maxBytes !== undefined) {
|
||||
await withTimeout(
|
||||
enforceRemoteImageLimits(
|
||||
sftp,
|
||||
image.length,
|
||||
options.maxCount ?? 100,
|
||||
options.maxBytes ?? 5_368_709_120,
|
||||
),
|
||||
5_000,
|
||||
"SFTP quota operation timed out",
|
||||
);
|
||||
}
|
||||
await sftpWriteFile(sftp, remotePath, image, options.writeTimeoutMs);
|
||||
} catch (error) {
|
||||
if (sftp.unlink) {
|
||||
await withTimeout(
|
||||
new Promise<void>((resolve) => {
|
||||
sftp.unlink!(remotePath, () => resolve());
|
||||
}),
|
||||
3_000,
|
||||
"SFTP cleanup operation timed out",
|
||||
).catch(() => undefined);
|
||||
}
|
||||
operationError =
|
||||
error instanceof TerminalImageStorageError
|
||||
? error
|
||||
: new TerminalImageStorageError(
|
||||
"IMAGE_REMOTE_WRITE_FAILED",
|
||||
"Failed to write image to the remote host",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
if (releaseRemoteLock) {
|
||||
try {
|
||||
await withTimeout(
|
||||
releaseRemoteLock(),
|
||||
3_000,
|
||||
"SFTP lock release timed out",
|
||||
);
|
||||
} catch (releaseError) {
|
||||
if (!operationError) {
|
||||
if (sftp.unlink) {
|
||||
await withTimeout(
|
||||
new Promise<void>((resolve) => {
|
||||
sftp.unlink!(remotePath, () => resolve());
|
||||
}),
|
||||
3_000,
|
||||
"SFTP cleanup operation timed out",
|
||||
).catch(() => undefined);
|
||||
}
|
||||
operationError = new TerminalImageStorageError(
|
||||
"IMAGE_REMOTE_WRITE_FAILED",
|
||||
"Failed to release remote image storage lock",
|
||||
releaseError,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (operationError) throw operationError;
|
||||
|
||||
return { id, filename, shellPath: remotePath, storage: "remote-sftp" };
|
||||
}
|
||||
|
||||
export function storeImageViaSftp(
|
||||
sftp: ImageSftpClient,
|
||||
image: Buffer,
|
||||
options: Parameters<typeof storeImageViaSftpUnlocked>[2] = {},
|
||||
): Promise<StoredTerminalImage> {
|
||||
return withImageStorageLock(() =>
|
||||
withTimeout(
|
||||
storeImageViaSftpUnlocked(sftp, image, options),
|
||||
20_000,
|
||||
"SFTP image operation timed out",
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Accepted decoded input formats; uploads are normalized to PNG by the route.
|
||||
export const IMAGE_FORMAT_EXTENSIONS: Record<string, string> = {
|
||||
avif: "avif",
|
||||
gif: "gif",
|
||||
heif: "heif",
|
||||
jpeg: "jpg",
|
||||
jp2: "jp2",
|
||||
jxl: "jxl",
|
||||
png: "png",
|
||||
tiff: "tiff",
|
||||
webp: "webp",
|
||||
};
|
||||
|
||||
export function imageExtensionForFormat(
|
||||
format: string | undefined,
|
||||
): string | undefined {
|
||||
return format ? IMAGE_FORMAT_EXTENSIONS[format] : undefined;
|
||||
}
|
||||
|
||||
export const MAX_NORMALIZED_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
export function exceedsNormalizedImageSize(
|
||||
byteLength: number,
|
||||
maxBytes = MAX_NORMALIZED_IMAGE_BYTES,
|
||||
): boolean {
|
||||
return byteLength > maxBytes;
|
||||
}
|
||||
|
||||
export function createConcurrencyLimiter(
|
||||
limit: number,
|
||||
maxQueued = Number.POSITIVE_INFINITY,
|
||||
): {
|
||||
acquire: () => Promise<() => void>;
|
||||
readonly active: number;
|
||||
readonly queued: number;
|
||||
} {
|
||||
const max = Math.max(1, Math.floor(limit));
|
||||
const queueLimit = Math.max(0, Math.floor(maxQueued));
|
||||
let active = 0;
|
||||
const waiters: Array<() => void> = [];
|
||||
|
||||
const startNext = () => {
|
||||
if (active >= max || waiters.length === 0) return;
|
||||
active += 1;
|
||||
waiters.shift()!();
|
||||
};
|
||||
|
||||
return {
|
||||
acquire: () =>
|
||||
new Promise<() => void>((resolve, reject) => {
|
||||
if (active >= max && waiters.length >= queueLimit) {
|
||||
reject(new Error("Concurrency admission queue is full"));
|
||||
return;
|
||||
}
|
||||
waiters.push(() => {
|
||||
let released = false;
|
||||
resolve(() => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
active -= 1;
|
||||
startNext();
|
||||
});
|
||||
});
|
||||
startNext();
|
||||
}),
|
||||
get active() {
|
||||
return active;
|
||||
},
|
||||
get queued() {
|
||||
return waiters.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const IMAGE_FILENAME_PATTERN = /^[0-9a-f-]{36}\.[a-z0-9]+$/i;
|
||||
|
||||
export function isImageFilename(filename: string): boolean {
|
||||
return IMAGE_FILENAME_PATTERN.test(filename);
|
||||
}
|
||||
|
||||
export function isExpiredImage(
|
||||
modifiedAtMs: number,
|
||||
nowMs: number,
|
||||
ttlMs: number,
|
||||
): boolean {
|
||||
if (ttlMs <= 0) return false;
|
||||
return nowMs - modifiedAtMs > ttlMs;
|
||||
}
|
||||
|
||||
export function exceedsImageStorageLimit(
|
||||
fileCount: number,
|
||||
totalBytes: number,
|
||||
incomingBytes: number,
|
||||
maxFileCount: number,
|
||||
maxStorageBytes: number,
|
||||
): boolean {
|
||||
return (
|
||||
fileCount >= maxFileCount || totalBytes + incomingBytes > maxStorageBytes
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,30 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import type { Request, Response } from "express";
|
||||
import express, {
|
||||
type NextFunction,
|
||||
type Request,
|
||||
type Response,
|
||||
} from "express";
|
||||
import { randomUUID } from "crypto";
|
||||
import multer from "multer";
|
||||
import sharp from "sharp";
|
||||
import {
|
||||
createConcurrencyLimiter,
|
||||
exceedsNormalizedImageSize,
|
||||
imageExtensionForFormat,
|
||||
} from "./terminal-image-utils.js";
|
||||
import { resolveTerminalImageStorageSettings } from "./terminal-image-storage-settings.js";
|
||||
import {
|
||||
selectImageStorageMode,
|
||||
probeLocalImageVisibility,
|
||||
storeImageLocally,
|
||||
storeImageViaSftp,
|
||||
TerminalImageStorageError,
|
||||
type ImageSftpClient,
|
||||
} from "./terminal-image-storage.js";
|
||||
import { authLogger, databaseLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { sessionManager } from "../../hosts/terminal/session-manager.js";
|
||||
import {
|
||||
createCurrentCommandHistoryRepository,
|
||||
createCurrentHostResolutionRepository,
|
||||
@@ -19,6 +41,293 @@ const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
|
||||
// Browser image handoff for local terminal-agent workflows.
|
||||
const imageUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: 50 * 1024 * 1024,
|
||||
fields: 4,
|
||||
fieldSize: 64 * 1024,
|
||||
files: 1,
|
||||
parts: 5,
|
||||
headerPairs: 200,
|
||||
},
|
||||
});
|
||||
const imageUploadMiddleware = imageUpload.single("image");
|
||||
const imageProcessingLimiter = createConcurrencyLimiter(4, 4);
|
||||
const imageMultipartAdmissionLimiter = createConcurrencyLimiter(4, 4);
|
||||
let imageUploadSequence = 0;
|
||||
|
||||
async function handleImageUploadMiddleware(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> {
|
||||
let releaseAdmission: (() => void) | undefined;
|
||||
try {
|
||||
releaseAdmission = await imageMultipartAdmissionLimiter.acquire();
|
||||
} catch {
|
||||
res.status(503).json({
|
||||
error: "Image upload capacity is temporarily unavailable",
|
||||
code: "IMAGE_UPLOAD_CAPACITY_EXCEEDED",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
imageUploadMiddleware(req, res, (error: unknown) => {
|
||||
try {
|
||||
if (!error) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
if (error instanceof multer.MulterError) {
|
||||
databaseLogger.warn("Image upload multipart request rejected", {
|
||||
operation: "terminal_image_upload_multipart_rejected",
|
||||
code: error.code,
|
||||
field: error.field,
|
||||
contentType: req.headers["content-type"]?.split(";", 1)[0],
|
||||
});
|
||||
res.status(400).json({
|
||||
error: "Image upload request rejected",
|
||||
code: error.code,
|
||||
field: error.field,
|
||||
});
|
||||
return;
|
||||
}
|
||||
databaseLogger.warn("Image upload multipart request malformed", {
|
||||
operation: "terminal_image_upload_multipart_invalid",
|
||||
contentType: req.headers["content-type"]?.split(";", 1)[0],
|
||||
});
|
||||
res.status(400).json({
|
||||
error: "Malformed image upload request",
|
||||
code: "IMAGE_MULTIPART_INVALID",
|
||||
});
|
||||
} finally {
|
||||
releaseAdmission?.();
|
||||
}
|
||||
});
|
||||
}
|
||||
function findTerminalSession(userId: string, instanceId: string) {
|
||||
return sessionManager
|
||||
.getUserSessions(userId)
|
||||
.find(
|
||||
(session) =>
|
||||
(session.attachedTabInstanceId ?? session.tabInstanceId) ===
|
||||
instanceId && session.isConnected,
|
||||
);
|
||||
}
|
||||
|
||||
router.post(
|
||||
"/image-upload",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
handleImageUploadMiddleware,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const instanceId = req.body?.instanceId;
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
error: "Image required",
|
||||
code: "IMAGE_FILE_MISSING",
|
||||
});
|
||||
}
|
||||
if (!isNonEmptyString(userId)) {
|
||||
return res.status(400).json({
|
||||
error: "Missing terminal session",
|
||||
code: "IMAGE_SESSION_MISSING",
|
||||
});
|
||||
}
|
||||
|
||||
const requestId = randomUUID();
|
||||
const sequence = ++imageUploadSequence;
|
||||
const source =
|
||||
req.body?.source === "file" || req.body?.source === "clipboard"
|
||||
? req.body.source
|
||||
: undefined;
|
||||
const clientUploadTimestamp =
|
||||
typeof req.body?.clientUploadTimestamp === "string" &&
|
||||
!Number.isNaN(Date.parse(req.body.clientUploadTimestamp))
|
||||
? req.body.clientUploadTimestamp
|
||||
: undefined;
|
||||
const serverReceivedAt = new Date().toISOString();
|
||||
databaseLogger.info("Terminal image upload received", {
|
||||
operation: "terminal_image_upload_received",
|
||||
requestId,
|
||||
sequence,
|
||||
source,
|
||||
clientUploadTimestamp,
|
||||
serverReceivedAt,
|
||||
bytes: req.file.size,
|
||||
});
|
||||
|
||||
const storageSettings = await resolveTerminalImageStorageSettings(
|
||||
createCurrentSettingsRepository(),
|
||||
);
|
||||
const session = isNonEmptyString(instanceId)
|
||||
? findTerminalSession(userId, instanceId)
|
||||
: undefined;
|
||||
let localHostVisible = false;
|
||||
if (storageSettings.localMappingConfigured && session?.sshConn) {
|
||||
localHostVisible = await probeLocalImageVisibility(
|
||||
session.sshConn,
|
||||
storageSettings,
|
||||
).catch(() => false);
|
||||
}
|
||||
const storageMode = selectImageStorageMode(storageSettings, {
|
||||
remoteSftpAvailable: !!session?.sshConn,
|
||||
localHostVisible,
|
||||
});
|
||||
|
||||
if (storageMode === "unavailable") {
|
||||
return res.status(503).json({
|
||||
error: "Image storage is unavailable",
|
||||
code: "IMAGE_STORAGE_UNAVAILABLE",
|
||||
});
|
||||
}
|
||||
|
||||
if (storageMode === "local" && !storageSettings.localMappingConfigured) {
|
||||
return res.status(503).json({
|
||||
error: "Local image storage is not configured",
|
||||
code: "IMAGE_LOCAL_STORAGE_NOT_CONFIGURED",
|
||||
});
|
||||
}
|
||||
|
||||
if (storageMode === "remote-sftp") {
|
||||
if (!isNonEmptyString(instanceId)) {
|
||||
return res.status(400).json({
|
||||
error: "Missing terminal session",
|
||||
code: "IMAGE_SESSION_MISSING",
|
||||
});
|
||||
}
|
||||
if (!session || !session.sshConn) {
|
||||
return res.status(409).json({
|
||||
error: "Terminal is not connected",
|
||||
code: "IMAGE_TERMINAL_NOT_CONNECTED",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let normalizedImage: Buffer;
|
||||
let releaseImageProcessingSlot: (() => void) | undefined;
|
||||
try {
|
||||
releaseImageProcessingSlot = await imageProcessingLimiter.acquire();
|
||||
} catch {
|
||||
return res.status(503).json({
|
||||
error: "Image upload capacity is temporarily exhausted",
|
||||
code: "IMAGE_UPLOAD_CAPACITY_EXCEEDED",
|
||||
});
|
||||
}
|
||||
try {
|
||||
const source = sharp(req.file.buffer, {
|
||||
failOn: "error",
|
||||
limitInputPixels: 40_000_000,
|
||||
});
|
||||
const { format } = await source.metadata();
|
||||
if (!imageExtensionForFormat(format)) {
|
||||
return res.status(400).json({
|
||||
error: "Unsupported image format",
|
||||
code: "IMAGE_FORMAT_UNSUPPORTED",
|
||||
});
|
||||
}
|
||||
normalizedImage = await source.rotate().png().toBuffer();
|
||||
if (exceedsNormalizedImageSize(normalizedImage.length)) {
|
||||
return res.status(413).json({
|
||||
error: "Normalized image is too large",
|
||||
code: "IMAGE_NORMALIZED_SIZE_LIMIT",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Image upload failed image decoding", {
|
||||
operation: "terminal_image_upload_decode",
|
||||
mimeType: req.file.mimetype,
|
||||
bytes: req.file.size,
|
||||
reason: getErrorMessage(error, "unknown"),
|
||||
});
|
||||
return res.status(400).json({
|
||||
error: "Invalid image data",
|
||||
code: "IMAGE_DECODE_FAILED",
|
||||
});
|
||||
} finally {
|
||||
releaseImageProcessingSlot?.();
|
||||
}
|
||||
|
||||
let remoteSftp: ImageSftpClient | undefined;
|
||||
try {
|
||||
const stored =
|
||||
storageMode === "remote-sftp"
|
||||
? await (async () => {
|
||||
remoteSftp = await new Promise<ImageSftpClient>(
|
||||
(resolve, reject) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
settled = true;
|
||||
reject(new Error("SFTP channel acquisition timed out"));
|
||||
}, 3_000);
|
||||
session!.sshConn!.sftp((err, sftp) => {
|
||||
if (settled) {
|
||||
sftp?.end?.();
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (err) return reject(err);
|
||||
resolve(sftp);
|
||||
});
|
||||
},
|
||||
);
|
||||
return storeImageViaSftp(remoteSftp, normalizedImage, {
|
||||
ttlMs: storageSettings.ttlMs,
|
||||
maxCount: storageSettings.maxCount,
|
||||
maxBytes: storageSettings.maxBytes,
|
||||
});
|
||||
})()
|
||||
: await storeImageLocally(normalizedImage, storageSettings);
|
||||
|
||||
res.json(stored);
|
||||
} catch (error) {
|
||||
if (error instanceof TerminalImageStorageError) {
|
||||
const status =
|
||||
error.code === "IMAGE_STORAGE_LIMIT_REACHED"
|
||||
? 507
|
||||
: error.code === "IMAGE_REMOTE_WRITE_FAILED"
|
||||
? 502
|
||||
: error.code === "IMAGE_REMOTE_QUOTA_UNAVAILABLE"
|
||||
? 503
|
||||
: error.code === "IMAGE_LOCAL_INSPECTION_FAILED"
|
||||
? 503
|
||||
: 500;
|
||||
databaseLogger.warn("Image upload storage write failed", {
|
||||
operation:
|
||||
error.code === "IMAGE_REMOTE_WRITE_FAILED"
|
||||
? "terminal_image_upload_sftp_failed"
|
||||
: "terminal_image_upload_local_failed",
|
||||
code: error.code,
|
||||
userId,
|
||||
instanceId,
|
||||
reason: getErrorMessage(error.cause ?? error, "unknown"),
|
||||
});
|
||||
return res.status(status).json({
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
});
|
||||
}
|
||||
databaseLogger.warn("Image upload failed to acquire remote channel", {
|
||||
operation: "terminal_image_upload_sftp_failed",
|
||||
code: "IMAGE_REMOTE_WRITE_FAILED",
|
||||
userId,
|
||||
instanceId,
|
||||
reason: getErrorMessage(error, "unknown"),
|
||||
});
|
||||
return res.status(502).json({
|
||||
error: "Failed to write image to the remote host",
|
||||
code: "IMAGE_REMOTE_WRITE_FAILED",
|
||||
});
|
||||
} finally {
|
||||
remoteSftp?.end?.();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /terminal/command_history:
|
||||
@@ -130,7 +439,7 @@ router.post(
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to save command to history", err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : "Failed to save command",
|
||||
error: getErrorMessage(err, "Failed to save command"),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -188,7 +497,7 @@ router.get(
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to fetch command history", err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : "Failed to fetch history",
|
||||
error: getErrorMessage(err, "Failed to fetch history"),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -252,7 +561,7 @@ router.post(
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to delete command from history", err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : "Failed to delete command",
|
||||
error: getErrorMessage(err, "Failed to delete command"),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -311,7 +620,7 @@ router.delete(
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to clear command history", err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : "Failed to clear history",
|
||||
error: getErrorMessage(err, "Failed to clear history"),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -352,7 +661,7 @@ router.get(
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to fetch session settings", err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : "Failed to fetch settings",
|
||||
error: getErrorMessage(err, "Failed to fetch settings"),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -422,7 +731,7 @@ router.post(
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to save session settings", err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : "Failed to save settings",
|
||||
error: getErrorMessage(err, "Failed to save settings"),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import type { Request, Response } from "express";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import {
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentTermixIdentityRepository,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { RequestHandler, Router } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import {
|
||||
normalizeTouchInputSettings,
|
||||
TOUCH_INPUT_SETTING_KEY,
|
||||
validateTouchInputSettingsUpdate,
|
||||
} from "../../../types/touch-input-settings.js";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../repositories/factory.js";
|
||||
|
||||
async function readSettings() {
|
||||
const raw = await createCurrentSettingsRepository().get(
|
||||
TOUCH_INPUT_SETTING_KEY,
|
||||
);
|
||||
if (!raw) return normalizeTouchInputSettings(null);
|
||||
try {
|
||||
return normalizeTouchInputSettings(JSON.parse(raw));
|
||||
} catch {
|
||||
return normalizeTouchInputSettings(null);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerTouchInputSettingsRoutes(
|
||||
router: Router,
|
||||
authenticateJWT: RequestHandler,
|
||||
): void {
|
||||
router.get("/touch-input-settings", authenticateJWT, async (_req, res) => {
|
||||
try {
|
||||
res.json(await readSettings());
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to get touch input settings", error);
|
||||
res.status(500).json({ error: "Failed to get touch input settings" });
|
||||
}
|
||||
});
|
||||
|
||||
router.patch("/touch-input-settings", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const user = userId
|
||||
? await createCurrentUserRepository().findById(userId)
|
||||
: null;
|
||||
if (!user?.isAdmin) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
const validationError = validateTouchInputSettingsUpdate(req.body);
|
||||
if (validationError) {
|
||||
return res.status(400).json({ error: validationError });
|
||||
}
|
||||
const current = await readSettings();
|
||||
const merged = { ...current, ...req.body };
|
||||
const mergedValidationError = validateTouchInputSettingsUpdate(merged);
|
||||
if (mergedValidationError) {
|
||||
return res.status(400).json({ error: mergedValidationError });
|
||||
}
|
||||
const next = normalizeTouchInputSettings(merged);
|
||||
await createCurrentSettingsRepository().set(
|
||||
TOUCH_INPUT_SETTING_KEY,
|
||||
JSON.stringify(next),
|
||||
);
|
||||
res.json(next);
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to update touch input settings", error);
|
||||
res.status(500).json({ error: "Failed to update touch input settings" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
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 {
|
||||
createCurrentUiPreferenceRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import {
|
||||
defaultUiPreferences,
|
||||
sanitizeUiPreferences,
|
||||
UI_ONBOARDING_VERSION,
|
||||
type UiPreferences,
|
||||
} from "../../../types/ui-preferences.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
/** Accounts younger than this are treated as new and get onboarding. */
|
||||
const NEW_ACCOUNT_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Existing users must never be ambushed by onboarding. A user with no
|
||||
* ui_preferences row who registered more than a day ago predates this feature,
|
||||
* so they are handed a completed onboarding state. This is a read-time default
|
||||
* rather than a migration write: nothing is persisted until the user actually
|
||||
* changes something, so it stays correct on a fresh database too.
|
||||
*/
|
||||
function withOnboardingBackfill(
|
||||
preferences: UiPreferences,
|
||||
registeredAt: string | null | undefined,
|
||||
): UiPreferences {
|
||||
const registeredMs = registeredAt ? Date.parse(registeredAt) : Number.NaN;
|
||||
const isNewAccount =
|
||||
Number.isFinite(registeredMs) &&
|
||||
Date.now() - registeredMs < NEW_ACCOUNT_WINDOW_MS;
|
||||
|
||||
if (isNewAccount) return preferences;
|
||||
|
||||
return {
|
||||
...preferences,
|
||||
onboarding: {
|
||||
...preferences.onboarding,
|
||||
completedVersion: UI_ONBOARDING_VERSION,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ui-preferences:
|
||||
* get:
|
||||
* summary: Get the UI complexity preferences for the current user
|
||||
* description: Returns the current user's interface preset (simple, balanced, advanced or custom), their per-area overrides, and their onboarding state. A first-time GET returns defaults without writing a row; a row is only created once the user actually changes something via PUT. Users who registered before this feature existed are returned an already-completed onboarding state so they are never shown the first-run flow.
|
||||
* tags:
|
||||
* - UI Preferences
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The current user's UI preferences.
|
||||
*/
|
||||
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const existing =
|
||||
await createCurrentUiPreferenceRepository().findByUserId(userId);
|
||||
|
||||
if (existing) {
|
||||
return res.json({
|
||||
preferences: sanitizeUiPreferences(JSON.parse(existing.data)),
|
||||
});
|
||||
}
|
||||
|
||||
const user = await createCurrentUserRepository().findById(userId);
|
||||
return res.json({
|
||||
preferences: withOnboardingBackfill(
|
||||
defaultUiPreferences(),
|
||||
user?.registeredAt,
|
||||
),
|
||||
});
|
||||
} catch (e) {
|
||||
databaseLogger.error("Failed to get UI preferences", e, {
|
||||
operation: "get_ui_preferences",
|
||||
userId,
|
||||
});
|
||||
return res.status(500).json({ error: "Failed to get UI preferences" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ui-preferences:
|
||||
* put:
|
||||
* summary: Update the UI complexity preferences for the current user
|
||||
* description: Persists the current user's interface preset, per-area overrides and onboarding state as a single JSON document. Overrides are merged two levels deep, so a request only has to send the keys it changes. A null at a key clears that single override; a null at an area clears every override for that area; a null at overrides clears all of them.
|
||||
* tags:
|
||||
* - UI Preferences
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Preferences updated successfully.
|
||||
* 400:
|
||||
* description: Invalid preferences payload.
|
||||
*/
|
||||
router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!req.body || typeof req.body !== "object") {
|
||||
return res.status(400).json({ error: "Invalid preferences payload" });
|
||||
}
|
||||
|
||||
try {
|
||||
const repository = createCurrentUiPreferenceRepository();
|
||||
const existing = await repository.findByUserId(userId);
|
||||
const base = existing
|
||||
? sanitizeUiPreferences(JSON.parse(existing.data))
|
||||
: defaultUiPreferences();
|
||||
|
||||
const body = req.body as Record<string, unknown>;
|
||||
|
||||
// Two levels of merging, with null meaning "clear this". A plain spread
|
||||
// could not express clearing an override, which the settings UI needs to
|
||||
// hand a knob back to the preset.
|
||||
const mergedOverrides: Record<string, Record<string, unknown>> = {
|
||||
...(base.overrides as Record<string, Record<string, unknown>>),
|
||||
};
|
||||
|
||||
if (body.overrides === null) {
|
||||
for (const area of Object.keys(mergedOverrides)) {
|
||||
delete mergedOverrides[area];
|
||||
}
|
||||
} else if (body.overrides && typeof body.overrides === "object") {
|
||||
for (const [area, patch] of Object.entries(
|
||||
body.overrides as Record<string, unknown>,
|
||||
)) {
|
||||
if (patch === null) {
|
||||
delete mergedOverrides[area];
|
||||
continue;
|
||||
}
|
||||
if (!patch || typeof patch !== "object") continue;
|
||||
|
||||
const next = { ...(mergedOverrides[area] ?? {}) };
|
||||
for (const [key, value] of Object.entries(
|
||||
patch as Record<string, unknown>,
|
||||
)) {
|
||||
if (value === null) delete next[key];
|
||||
else next[key] = value;
|
||||
}
|
||||
mergedOverrides[area] = next;
|
||||
}
|
||||
}
|
||||
|
||||
const merged = sanitizeUiPreferences({
|
||||
...base,
|
||||
...body,
|
||||
// Must come after the body spread, or a raw overrides payload would
|
||||
// clobber the merge above.
|
||||
overrides: mergedOverrides,
|
||||
onboarding: {
|
||||
...base.onboarding,
|
||||
...((body.onboarding as Record<string, unknown>) ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
await repository.upsert(userId, JSON.stringify(merged));
|
||||
|
||||
return res.json({ success: true, preferences: merged });
|
||||
} catch (e) {
|
||||
databaseLogger.error("Failed to update UI preferences", e, {
|
||||
operation: "update_ui_preferences",
|
||||
userId,
|
||||
});
|
||||
return res.status(500).json({ error: "Failed to update UI preferences" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -38,13 +38,36 @@ export function registerUserAdminRoutes(
|
||||
* @openapi
|
||||
* /users/list:
|
||||
* get:
|
||||
* summary: List all users
|
||||
* description: Retrieves a list of all users in the system.
|
||||
* summary: List users
|
||||
* description: >
|
||||
* Retrieves users in the system. Without `limit` the full list is
|
||||
* returned, which is what the sharing pickers rely on. Pass `limit`
|
||||
* (and optionally `offset`/`search`) to page through large directories.
|
||||
* tags:
|
||||
* - Users
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: search
|
||||
* required: false
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Case-insensitive username substring filter.
|
||||
* - in: query
|
||||
* name: limit
|
||||
* required: false
|
||||
* schema:
|
||||
* type: integer
|
||||
* maximum: 500
|
||||
* description: Page size. Omit to return every user.
|
||||
* - in: query
|
||||
* name: offset
|
||||
* required: false
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Number of users to skip. Requires `limit`.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of users.
|
||||
* description: A list of users, with the total matching count.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
@@ -56,10 +79,36 @@ export function registerUserAdminRoutes(
|
||||
const requester = await userRepository.findById(
|
||||
(req as AuthenticatedRequest).userId,
|
||||
);
|
||||
const allUsers = await userRepository.listAll();
|
||||
|
||||
const query = req.query ?? {};
|
||||
const search =
|
||||
typeof query.search === "string" ? query.search : undefined;
|
||||
const rawLimit = Number(query.limit);
|
||||
// Paging is opt-in: the share pickers fetch the whole list and filter it
|
||||
// client-side, so a request without ?limit keeps the original behaviour.
|
||||
const paginated = Number.isFinite(rawLimit) && rawLimit > 0;
|
||||
const limit = paginated ? Math.min(Math.floor(rawLimit), 500) : 0;
|
||||
const rawOffset = Number(query.offset);
|
||||
const offset =
|
||||
Number.isFinite(rawOffset) && rawOffset > 0 ? Math.floor(rawOffset) : 0;
|
||||
|
||||
let pageUsers: UserRecord[];
|
||||
let total: number;
|
||||
if (paginated) {
|
||||
const page = await userRepository.listPage({ search, limit, offset });
|
||||
pageUsers = page.users;
|
||||
total = page.total;
|
||||
} else {
|
||||
const all = await userRepository.listAll();
|
||||
const term = search?.trim().toLowerCase();
|
||||
pageUsers = term
|
||||
? all.filter((u) => u.username?.toLowerCase().includes(term))
|
||||
: all;
|
||||
total = pageUsers.length;
|
||||
}
|
||||
|
||||
res.json({
|
||||
users: allUsers.map((u) => ({
|
||||
users: pageUsers.map((u) => ({
|
||||
userId: u.id,
|
||||
username: u.username,
|
||||
is_admin: u.isAdmin,
|
||||
@@ -74,6 +123,8 @@ export function registerUserAdminRoutes(
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
total,
|
||||
...(paginated ? { limit, offset } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to list users", err);
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { sessionManager } from "../../hosts/terminal/session-manager.js";
|
||||
import { createCurrentSettingsRepository } from "../repositories/factory.js";
|
||||
import {
|
||||
MIN_IMAGE_MAX_BYTES,
|
||||
TERMINAL_IMAGE_STORAGE_KEYS,
|
||||
parseImageHostPath,
|
||||
parseImageLocalDir,
|
||||
parseTerminalImageStorageMode,
|
||||
resolveTerminalImageStorageSettings,
|
||||
type TerminalImageStorageSettings,
|
||||
} from "./terminal-image-storage-settings.js";
|
||||
import {
|
||||
probeLocalImageVisibility,
|
||||
selectImageStorageMode,
|
||||
} from "./terminal-image-storage.js";
|
||||
|
||||
/**
|
||||
* Admin-only terminal image storage settings.
|
||||
*
|
||||
* The public shape deliberately omits `localDir`: it is a backend-internal
|
||||
* path and only the agent-visible `hostPath` may leave the server. No
|
||||
* credentials or connection details are ever returned here.
|
||||
*/
|
||||
interface PublicImageStorageSettings {
|
||||
mode: TerminalImageStorageSettings["mode"];
|
||||
hostPath: string;
|
||||
ttlMs: number;
|
||||
maxCount: number;
|
||||
maxBytes: number;
|
||||
localMappingConfigured: boolean;
|
||||
}
|
||||
|
||||
function toPublicSettings(
|
||||
settings: TerminalImageStorageSettings,
|
||||
): PublicImageStorageSettings {
|
||||
return {
|
||||
mode: settings.mode,
|
||||
hostPath: settings.hostPath,
|
||||
ttlMs: settings.ttlMs,
|
||||
maxCount: settings.maxCount,
|
||||
maxBytes: settings.maxBytes,
|
||||
localMappingConfigured: settings.localMappingConfigured,
|
||||
};
|
||||
}
|
||||
|
||||
const PATCHABLE_FIELDS = [
|
||||
"mode",
|
||||
"localDir",
|
||||
"hostPath",
|
||||
"ttlMs",
|
||||
"maxCount",
|
||||
"maxBytes",
|
||||
] as const;
|
||||
|
||||
type PatchableField = (typeof PATCHABLE_FIELDS)[number];
|
||||
|
||||
function invalidField(res: Response, field: string): void {
|
||||
// Safe by construction: the field name is fixed, the rejected value and
|
||||
// any backend path are never echoed back.
|
||||
res.status(400).json({
|
||||
error: `Invalid value for ${field}`,
|
||||
code: "IMAGE_STORAGE_SETTINGS_INVALID",
|
||||
field,
|
||||
});
|
||||
}
|
||||
|
||||
function parseIntegerField(value: unknown, min: number): number | null {
|
||||
if (typeof value !== "number" || !Number.isInteger(value)) return null;
|
||||
if (value < min) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a PATCH body and returns the settings-table writes it implies, or
|
||||
* null after the response has already been failed with a 400.
|
||||
*/
|
||||
function buildImageStorageWrites(
|
||||
body: unknown,
|
||||
res: Response,
|
||||
): Array<{ key: string; value: string }> | null {
|
||||
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
||||
res.status(400).json({
|
||||
error: "Invalid request body",
|
||||
code: "IMAGE_STORAGE_SETTINGS_INVALID",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const field of Object.keys(body)) {
|
||||
if (!(PATCHABLE_FIELDS as readonly string[]).includes(field)) {
|
||||
res.status(400).json({
|
||||
error: `Unknown setting field: ${field}`,
|
||||
code: "IMAGE_STORAGE_SETTINGS_UNKNOWN_FIELD",
|
||||
field,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const input = body as Partial<Record<PatchableField, unknown>>;
|
||||
const writes: Array<{ key: string; value: string }> = [];
|
||||
|
||||
if (input.mode !== undefined) {
|
||||
const mode = parseTerminalImageStorageMode(input.mode);
|
||||
if (mode === null) {
|
||||
invalidField(res, "mode");
|
||||
return null;
|
||||
}
|
||||
writes.push({ key: TERMINAL_IMAGE_STORAGE_KEYS.mode, value: mode });
|
||||
}
|
||||
|
||||
if (input.localDir !== undefined) {
|
||||
const localDir = parseImageLocalDir(input.localDir);
|
||||
if (localDir === null) {
|
||||
invalidField(res, "localDir");
|
||||
return null;
|
||||
}
|
||||
writes.push({ key: TERMINAL_IMAGE_STORAGE_KEYS.localDir, value: localDir });
|
||||
}
|
||||
|
||||
if (input.hostPath !== undefined) {
|
||||
const hostPath = parseImageHostPath(input.hostPath);
|
||||
if (hostPath === null) {
|
||||
invalidField(res, "hostPath");
|
||||
return null;
|
||||
}
|
||||
writes.push({
|
||||
key: TERMINAL_IMAGE_STORAGE_KEYS.hostPath,
|
||||
value: hostPath,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.ttlMs !== undefined) {
|
||||
const ttlMs = parseIntegerField(input.ttlMs, 0);
|
||||
if (ttlMs === null) {
|
||||
invalidField(res, "ttlMs");
|
||||
return null;
|
||||
}
|
||||
writes.push({
|
||||
key: TERMINAL_IMAGE_STORAGE_KEYS.ttlMs,
|
||||
value: String(ttlMs),
|
||||
});
|
||||
}
|
||||
|
||||
if (input.maxCount !== undefined) {
|
||||
const maxCount = parseIntegerField(input.maxCount, 1);
|
||||
if (maxCount === null) {
|
||||
invalidField(res, "maxCount");
|
||||
return null;
|
||||
}
|
||||
writes.push({
|
||||
key: TERMINAL_IMAGE_STORAGE_KEYS.maxCount,
|
||||
value: String(maxCount),
|
||||
});
|
||||
}
|
||||
|
||||
if (input.maxBytes !== undefined) {
|
||||
const maxBytes = parseIntegerField(input.maxBytes, MIN_IMAGE_MAX_BYTES);
|
||||
if (maxBytes === null) {
|
||||
invalidField(res, "maxBytes");
|
||||
return null;
|
||||
}
|
||||
writes.push({
|
||||
key: TERMINAL_IMAGE_STORAGE_KEYS.maxBytes,
|
||||
value: String(maxBytes),
|
||||
});
|
||||
}
|
||||
|
||||
return writes;
|
||||
}
|
||||
|
||||
export function registerUserImageStorageRoutes(
|
||||
router: Router,
|
||||
requireAdmin: RequestHandler,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/terminal-image-storage-settings:
|
||||
* get:
|
||||
* summary: Get terminal image storage settings (admin only)
|
||||
* description: Returns the effective terminal image storage settings. The backend-internal localDir is never exposed; only the agent-visible hostPath is returned.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Effective image storage settings.
|
||||
* 401:
|
||||
* description: Not authenticated.
|
||||
* 403:
|
||||
* description: Admin access required.
|
||||
* 500:
|
||||
* description: Failed to load settings.
|
||||
*/
|
||||
router.get(
|
||||
"/terminal-image-storage-settings",
|
||||
requireAdmin,
|
||||
async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const settings = await resolveTerminalImageStorageSettings(
|
||||
createCurrentSettingsRepository(),
|
||||
);
|
||||
res.json(toPublicSettings(settings));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to load image storage settings", err);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to load image storage settings" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/terminal-image-storage-settings:
|
||||
* patch:
|
||||
* summary: Update terminal image storage settings (admin only)
|
||||
* description: Persists a partial update. Accepts only mode (auto, local, remote-sftp), localDir, hostPath, ttlMs, maxCount and maxBytes; invalid values are rejected with a 400.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Updated effective settings.
|
||||
* 400:
|
||||
* description: Invalid or unknown setting field.
|
||||
* 401:
|
||||
* description: Not authenticated.
|
||||
* 403:
|
||||
* description: Admin access required.
|
||||
* 500:
|
||||
* description: Failed to save settings.
|
||||
*/
|
||||
router.patch(
|
||||
"/terminal-image-storage-settings",
|
||||
requireAdmin,
|
||||
async (req: Request, res: Response) => {
|
||||
const writes = buildImageStorageWrites(req.body, res);
|
||||
if (writes === null) return;
|
||||
|
||||
try {
|
||||
const settings = createCurrentSettingsRepository();
|
||||
if (typeof settings.setMany !== "function") {
|
||||
throw new Error("Atomic settings persistence is unavailable");
|
||||
}
|
||||
await settings.setMany(writes);
|
||||
const resolved = await resolveTerminalImageStorageSettings(settings);
|
||||
res.json(toPublicSettings(resolved));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to save image storage settings", err);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to save image storage settings" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/terminal-image-storage-settings/test:
|
||||
* post:
|
||||
* summary: Test image storage visibility (admin only)
|
||||
* description: Reports which storage mode an upload would take for one of the caller's already-connected terminal sessions. Uses the bounded local-mapping probe only; it never opens new connections.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Visibility test result.
|
||||
* 400:
|
||||
* description: Missing terminal session instanceId.
|
||||
* 401:
|
||||
* description: Not authenticated.
|
||||
* 403:
|
||||
* description: Admin access required.
|
||||
* 500:
|
||||
* description: Failed to run the test.
|
||||
*/
|
||||
router.post(
|
||||
"/terminal-image-storage-settings/test",
|
||||
requireAdmin,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const instanceId = req.body?.instanceId;
|
||||
if (typeof instanceId !== "string" || instanceId.trim().length === 0) {
|
||||
return res.status(400).json({
|
||||
error: "Missing terminal session",
|
||||
code: "IMAGE_SESSION_MISSING",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await resolveTerminalImageStorageSettings(
|
||||
createCurrentSettingsRepository(),
|
||||
);
|
||||
const session = sessionManager
|
||||
.getUserSessions(userId)
|
||||
.find(
|
||||
(candidate) =>
|
||||
(candidate.attachedTabInstanceId ?? candidate.tabInstanceId) ===
|
||||
instanceId && candidate.isConnected,
|
||||
);
|
||||
const remoteSftpAvailable = !!session?.sshConn;
|
||||
|
||||
let localHostVisible: boolean | null = null;
|
||||
if (settings.localMappingConfigured && session?.sshConn) {
|
||||
localHostVisible = await probeLocalImageVisibility(
|
||||
session.sshConn,
|
||||
settings,
|
||||
).catch(() => false);
|
||||
}
|
||||
|
||||
const selectedMode = selectImageStorageMode(settings, {
|
||||
remoteSftpAvailable,
|
||||
...(localHostVisible !== null ? { localHostVisible } : {}),
|
||||
});
|
||||
|
||||
res.json({
|
||||
mode: settings.mode,
|
||||
connected: !!session,
|
||||
remoteSftpAvailable,
|
||||
localHostVisible,
|
||||
selectedMode,
|
||||
localMappingConfigured: settings.localMappingConfigured,
|
||||
});
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to test image storage visibility", err);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to test image storage visibility" });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { RequestHandler, Router } from "express";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
@@ -163,7 +164,7 @@ export function registerUserOidcAccountRoutes(
|
||||
});
|
||||
res.status(500).json({
|
||||
error: "Failed to link accounts",
|
||||
details: err instanceof Error ? err.message : "Unknown error",
|
||||
details: getErrorMessage(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -297,7 +298,7 @@ export function registerUserOidcAccountRoutes(
|
||||
});
|
||||
res.status(500).json({
|
||||
error: "Failed to unlink OIDC",
|
||||
details: err instanceof Error ? err.message : "Unknown error",
|
||||
details: getErrorMessage(err),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -23,7 +23,10 @@ export class OIDCTokenFormatError extends Error {
|
||||
}
|
||||
|
||||
function normalizeIssuer(url: string): string {
|
||||
return url.trim().replace(/\/+$/, "");
|
||||
return url
|
||||
.trim()
|
||||
.replace(/\/+$/, "")
|
||||
.replace(/\/\.well-known\/openid-configuration$/, "");
|
||||
}
|
||||
|
||||
export type OIDCConfig = {
|
||||
@@ -205,6 +208,22 @@ export function extractOidcGroups(
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDC providers may return group claims in the ID token, userinfo response,
|
||||
* or both. Keep every verified source authoritative instead of letting a
|
||||
* sparse userinfo payload overwrite claims from the ID token.
|
||||
*/
|
||||
export function extractOidcGroupsFromSources(
|
||||
sources: Record<string, unknown>[],
|
||||
groupClaim?: string,
|
||||
): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
sources.flatMap((source) => extractOidcGroups(source, groupClaim)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function isOIDCUserAllowed(
|
||||
allowedUsers: string,
|
||||
identifier: string,
|
||||
@@ -260,14 +279,12 @@ export async function verifyOIDCToken(
|
||||
}
|
||||
|
||||
const fetchOptions = buildFetchOptions(caCert);
|
||||
const normalizedIssuerUrl = issuerUrl.endsWith("/")
|
||||
? issuerUrl.slice(0, -1)
|
||||
: issuerUrl;
|
||||
const configuredIssuerUrl = issuerUrl.trim().replace(/\/+$/, "");
|
||||
const normalizedIssuerUrl = normalizeIssuer(issuerUrl);
|
||||
const possibleIssuers = [
|
||||
issuerUrl,
|
||||
normalizedIssuerUrl,
|
||||
issuerUrl.replace(/\/application\/o\/[^/]+$/, ""),
|
||||
normalizedIssuerUrl.replace(/\/application\/o\/[^/]+$/, ""),
|
||||
...(configuredIssuerUrl === normalizedIssuerUrl ? [issuerUrl] : []),
|
||||
];
|
||||
|
||||
const jwksUrls = [
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import type { Request, Response } from "express";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { createCurrentUserPreferenceRepository } from "../repositories/factory.js";
|
||||
@@ -32,17 +31,35 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
|
||||
disableUpdateCheck: row?.disableUpdateCheck ?? null,
|
||||
confirmTabClose: row?.confirmTabClose ?? null,
|
||||
hiddenRailTabs: row?.hiddenRailTabs ?? null,
|
||||
aiAssistantEnabled: row?.aiAssistantEnabled ?? null,
|
||||
aiReadOnlyCommands: row?.aiReadOnlyCommands ?? null,
|
||||
compactHostView: row?.compactHostView ?? null,
|
||||
statusColorScheme: row?.statusColorScheme ?? null,
|
||||
customThemes: row?.customThemes ?? null,
|
||||
customKeybindings: row?.customKeybindings ?? null,
|
||||
terminalDefaults: row?.terminalDefaults ?? null,
|
||||
rdpDefaults: row?.rdpDefaults ?? null,
|
||||
terminalMacros: row?.terminalMacros ?? null,
|
||||
});
|
||||
|
||||
const connectionDefaultFields = ["terminalDefaults", "rdpDefaults"] as const;
|
||||
|
||||
export function validateDefaultsJson(value: string): boolean {
|
||||
if (value.length > 32_768) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return !!parsed && typeof parsed === "object" && !Array.isArray(parsed);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /user-preferences:
|
||||
* get:
|
||||
* summary: Get preferences for the current user
|
||||
* description: showHostTags, hostTrayOnClick, compactHostView, statusColorScheme and foldersCollapsed are legacy fields, kept here read-only for backward compatibility. The authoritative copy is GET /host-sidebar/preferences.
|
||||
* tags:
|
||||
* - User Preferences
|
||||
* responses:
|
||||
@@ -139,6 +156,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
* /user-preferences:
|
||||
* put:
|
||||
* summary: Update preferences for the current user
|
||||
* description: showHostTags, hostTrayOnClick, compactHostView, statusColorScheme and foldersCollapsed are no longer accepted here -- they moved to PUT /host-sidebar/preferences as part of the sidebar redesign.
|
||||
* tags:
|
||||
* - User Preferences
|
||||
* requestBody:
|
||||
@@ -164,16 +182,10 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
* type: boolean
|
||||
* commandPaletteEnabled:
|
||||
* type: boolean
|
||||
* showHostTags:
|
||||
* type: boolean
|
||||
* hostTrayOnClick:
|
||||
* type: boolean
|
||||
* pinAppRail:
|
||||
* type: boolean
|
||||
* expandAppRailOnHover:
|
||||
* type: boolean
|
||||
* foldersCollapsed:
|
||||
* type: boolean
|
||||
* confirmSnippetExecution:
|
||||
* type: boolean
|
||||
* disableUpdateCheck:
|
||||
@@ -182,10 +194,6 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
* type: boolean
|
||||
* hiddenRailTabs:
|
||||
* type: string
|
||||
* compactHostView:
|
||||
* type: boolean
|
||||
* statusColorScheme:
|
||||
* type: string
|
||||
* customThemes:
|
||||
* type: string
|
||||
* description: JSON-encoded array of the user's saved global custom terminal themes.
|
||||
@@ -207,19 +215,19 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
storageMode,
|
||||
commandAutocomplete,
|
||||
commandPaletteEnabled,
|
||||
showHostTags,
|
||||
hostTrayOnClick,
|
||||
pinAppRail,
|
||||
expandAppRailOnHover,
|
||||
foldersCollapsed,
|
||||
confirmSnippetExecution,
|
||||
disableUpdateCheck,
|
||||
confirmTabClose,
|
||||
hiddenRailTabs,
|
||||
compactHostView,
|
||||
statusColorScheme,
|
||||
aiAssistantEnabled,
|
||||
aiReadOnlyCommands,
|
||||
customThemes,
|
||||
customKeybindings,
|
||||
terminalDefaults,
|
||||
rdpDefaults,
|
||||
terminalMacros,
|
||||
} = req.body as {
|
||||
reopenTabsOnLogin?: boolean;
|
||||
theme?: string | null;
|
||||
@@ -229,20 +237,25 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
storageMode?: string | null;
|
||||
commandAutocomplete?: boolean | null;
|
||||
commandPaletteEnabled?: boolean | null;
|
||||
showHostTags?: boolean | null;
|
||||
hostTrayOnClick?: boolean | null;
|
||||
pinAppRail?: boolean | null;
|
||||
expandAppRailOnHover?: boolean | null;
|
||||
foldersCollapsed?: boolean | null;
|
||||
confirmSnippetExecution?: boolean | null;
|
||||
disableUpdateCheck?: boolean | null;
|
||||
confirmTabClose?: boolean | null;
|
||||
hiddenRailTabs?: string | null;
|
||||
compactHostView?: boolean | null;
|
||||
statusColorScheme?: string | null;
|
||||
aiAssistantEnabled?: boolean | null;
|
||||
aiReadOnlyCommands?: boolean | null;
|
||||
customThemes?: string | null;
|
||||
customKeybindings?: string | null;
|
||||
terminalDefaults?: string | null;
|
||||
rdpDefaults?: string | null;
|
||||
terminalMacros?: string | null;
|
||||
};
|
||||
// showHostTags, hostTrayOnClick, compactHostView, statusColorScheme,
|
||||
// foldersCollapsed are no longer writable here -- they moved to
|
||||
// /host-sidebar/preferences as of the sidebar redesign. The columns stay
|
||||
// in the table (read once as a migration seed by that route) but this
|
||||
// endpoint silently ignores them if a stale client still sends them.
|
||||
|
||||
const updates: UserPreferenceUpdate = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
@@ -264,15 +277,30 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
language,
|
||||
storageMode,
|
||||
hiddenRailTabs,
|
||||
statusColorScheme,
|
||||
customThemes,
|
||||
customKeybindings,
|
||||
terminalDefaults,
|
||||
rdpDefaults,
|
||||
terminalMacros,
|
||||
})) {
|
||||
if (value !== undefined && value !== null && typeof value !== "string") {
|
||||
return res.status(400).json({ error: `${key} must be a string` });
|
||||
}
|
||||
}
|
||||
|
||||
const connectionDefaults = {
|
||||
terminalDefaults,
|
||||
rdpDefaults,
|
||||
};
|
||||
for (const key of connectionDefaultFields) {
|
||||
const value = connectionDefaults[key];
|
||||
if (value !== undefined && value !== null && !validateDefaultsJson(value)) {
|
||||
return res.status(400).json({
|
||||
error: `${key} must be a JSON-encoded object of at most 32 KiB`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (customThemes !== undefined && customThemes !== null) {
|
||||
let parsedThemes: unknown;
|
||||
try {
|
||||
@@ -323,18 +351,44 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (terminalMacros !== undefined && terminalMacros !== null) {
|
||||
let parsedMacros: unknown;
|
||||
try {
|
||||
parsedMacros = JSON.parse(terminalMacros);
|
||||
} catch {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "terminalMacros must be a JSON-encoded array" });
|
||||
}
|
||||
if (
|
||||
terminalMacros.length > 512 * 1024 ||
|
||||
!Array.isArray(parsedMacros) ||
|
||||
parsedMacros.length > 100 ||
|
||||
!parsedMacros.every(
|
||||
(macro) =>
|
||||
!!macro &&
|
||||
typeof macro === "object" &&
|
||||
typeof (macro as { id?: unknown }).id === "string" &&
|
||||
typeof (macro as { name?: unknown }).name === "string" &&
|
||||
Array.isArray((macro as { steps?: unknown }).steps),
|
||||
)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: "terminalMacros must contain at most 100 valid macros",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const boolFields: Record<string, boolean | null | undefined> = {
|
||||
commandAutocomplete,
|
||||
commandPaletteEnabled,
|
||||
showHostTags,
|
||||
hostTrayOnClick,
|
||||
pinAppRail,
|
||||
expandAppRailOnHover,
|
||||
foldersCollapsed,
|
||||
confirmSnippetExecution,
|
||||
disableUpdateCheck,
|
||||
confirmTabClose,
|
||||
compactHostView,
|
||||
aiAssistantEnabled,
|
||||
aiReadOnlyCommands,
|
||||
};
|
||||
for (const [key, value] of Object.entries(boolFields)) {
|
||||
if (value !== undefined && value !== null && typeof value !== "boolean") {
|
||||
@@ -348,28 +402,29 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
if (language !== undefined) updates.language = language;
|
||||
if (storageMode !== undefined) updates.storageMode = storageMode;
|
||||
if (hiddenRailTabs !== undefined) updates.hiddenRailTabs = hiddenRailTabs;
|
||||
if (aiAssistantEnabled !== undefined)
|
||||
updates.aiAssistantEnabled = aiAssistantEnabled;
|
||||
if (aiReadOnlyCommands !== undefined)
|
||||
updates.aiReadOnlyCommands = aiReadOnlyCommands;
|
||||
if (commandAutocomplete !== undefined)
|
||||
updates.commandAutocomplete = commandAutocomplete;
|
||||
if (commandPaletteEnabled !== undefined)
|
||||
updates.commandPaletteEnabled = commandPaletteEnabled;
|
||||
if (showHostTags !== undefined) updates.showHostTags = showHostTags;
|
||||
if (hostTrayOnClick !== undefined) updates.hostTrayOnClick = hostTrayOnClick;
|
||||
if (pinAppRail !== undefined) updates.pinAppRail = pinAppRail;
|
||||
if (expandAppRailOnHover !== undefined)
|
||||
updates.expandAppRailOnHover = expandAppRailOnHover;
|
||||
if (foldersCollapsed !== undefined)
|
||||
updates.foldersCollapsed = foldersCollapsed;
|
||||
if (confirmSnippetExecution !== undefined)
|
||||
updates.confirmSnippetExecution = confirmSnippetExecution;
|
||||
if (disableUpdateCheck !== undefined)
|
||||
updates.disableUpdateCheck = disableUpdateCheck;
|
||||
if (confirmTabClose !== undefined) updates.confirmTabClose = confirmTabClose;
|
||||
if (compactHostView !== undefined) updates.compactHostView = compactHostView;
|
||||
if (statusColorScheme !== undefined)
|
||||
updates.statusColorScheme = statusColorScheme;
|
||||
if (customThemes !== undefined) updates.customThemes = customThemes;
|
||||
if (customKeybindings !== undefined)
|
||||
updates.customKeybindings = customKeybindings;
|
||||
if (terminalDefaults !== undefined)
|
||||
updates.terminalDefaults = terminalDefaults;
|
||||
if (rdpDefaults !== undefined) updates.rdpDefaults = rdpDefaults;
|
||||
if (terminalMacros !== undefined) updates.terminalMacros = terminalMacros;
|
||||
|
||||
if (Object.keys(updates).length === 1) {
|
||||
return res.status(400).json({ error: "No preferences provided" });
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "../../utils/logger.js";
|
||||
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||
import { getTelemetryEnvOverride } from "../../utils/analytics.js";
|
||||
import { AI_PRIVATE_ALLOWLIST_KEY, parseAllowlist } from "../../ai/egress.js";
|
||||
import {
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserRepository,
|
||||
@@ -341,17 +342,22 @@ export function registerUserSettingsRoutes(
|
||||
* description: Masked API key or empty string if not set.
|
||||
* hasApiKey:
|
||||
* type: boolean
|
||||
* apiBaseUrl:
|
||||
* type: string
|
||||
* description: Custom control-plane API base URL (e.g. a Headscale instance), or empty string for the Tailscale default.
|
||||
*/
|
||||
router.get("/tailscale-settings", authenticateJWT, async (_req, res) => {
|
||||
try {
|
||||
const apiKey =
|
||||
(await createCurrentSettingsRepository().get("tailscale_api_key")) ??
|
||||
"";
|
||||
const settingsRepo = createCurrentSettingsRepository();
|
||||
const apiKey = (await settingsRepo.get("tailscale_api_key")) ?? "";
|
||||
const apiBaseUrl =
|
||||
(await settingsRepo.get("tailscale_api_base_url")) ?? "";
|
||||
res.json({
|
||||
apiKey: apiKey
|
||||
? `${apiKey.slice(0, 6)}${"*".repeat(Math.max(0, apiKey.length - 6))}`
|
||||
: "",
|
||||
hasApiKey: !!apiKey,
|
||||
apiBaseUrl,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get Tailscale settings", err);
|
||||
@@ -376,6 +382,9 @@ export function registerUserSettingsRoutes(
|
||||
* properties:
|
||||
* apiKey:
|
||||
* type: string
|
||||
* apiBaseUrl:
|
||||
* type: string
|
||||
* description: Optional custom control-plane API base URL (e.g. a Headscale instance). Leave empty to use the Tailscale default.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Tailscale settings updated.
|
||||
@@ -391,11 +400,21 @@ export function registerUserSettingsRoutes(
|
||||
if (!actor) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
const { apiKey } = req.body;
|
||||
const { apiKey, apiBaseUrl } = req.body;
|
||||
if (typeof apiKey !== "string") {
|
||||
return res.status(400).json({ error: "apiKey must be a string" });
|
||||
}
|
||||
await createCurrentSettingsRepository().set("tailscale_api_key", apiKey);
|
||||
if (apiBaseUrl !== undefined && typeof apiBaseUrl !== "string") {
|
||||
return res.status(400).json({ error: "apiBaseUrl must be a string" });
|
||||
}
|
||||
const settingsRepo = createCurrentSettingsRepository();
|
||||
await settingsRepo.set("tailscale_api_key", apiKey);
|
||||
if (apiBaseUrl !== undefined) {
|
||||
await settingsRepo.set(
|
||||
"tailscale_api_base_url",
|
||||
apiBaseUrl.trim().replace(/\/+$/, ""),
|
||||
);
|
||||
}
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
@@ -735,6 +754,206 @@ export function registerUserSettingsRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/ai-enabled:
|
||||
* get:
|
||||
* summary: Get whether the AI assistant is enabled instance-wide
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: AI enabled status.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
*/
|
||||
router.get("/ai-enabled", authenticateJWT, async (_req, res) => {
|
||||
try {
|
||||
res.json({
|
||||
// Defaults to false so upgrading an install never turns the assistant
|
||||
// on without an admin deciding to.
|
||||
enabled: await createCurrentSettingsRepository().getBoolean(
|
||||
"ai_globally_enabled",
|
||||
false,
|
||||
),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get AI enabled setting", err);
|
||||
res.status(500).json({ error: "Failed to get AI enabled setting" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/ai-enabled:
|
||||
* patch:
|
||||
* summary: Update the instance-wide AI assistant setting (admin only)
|
||||
* description: Turning this off hides and blocks the assistant for every user, whatever their own preference says.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Setting updated.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
*/
|
||||
router.patch("/ai-enabled", 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 { enabled } = req.body;
|
||||
if (typeof enabled !== "boolean") {
|
||||
return res.status(400).json({ error: "enabled must be a boolean" });
|
||||
}
|
||||
await createCurrentSettingsRepository().set(
|
||||
"ai_globally_enabled",
|
||||
enabled ? "true" : "false",
|
||||
);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor.username ?? userId,
|
||||
action: "update_ai_enabled",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ enabled }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ enabled });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to update AI enabled setting", err);
|
||||
res.status(500).json({ error: "Failed to update AI enabled setting" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/ai-private-endpoints:
|
||||
* get:
|
||||
* summary: Get the allowlist of private AI endpoint hosts
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Allowed hosts.
|
||||
*/
|
||||
router.get("/ai-private-endpoints", authenticateJWT, async (_req, res) => {
|
||||
try {
|
||||
const raw = await createCurrentSettingsRepository().get(
|
||||
AI_PRIVATE_ALLOWLIST_KEY,
|
||||
);
|
||||
res.json({ hosts: parseAllowlist(raw) });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get AI private endpoint allowlist", err);
|
||||
res.status(500).json({ error: "Failed to get the allowlist" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/ai-private-endpoints:
|
||||
* patch:
|
||||
* summary: Replace the allowlist of private AI endpoint hosts (admin only)
|
||||
* description: >
|
||||
* Providers on private or loopback addresses, such as a self-hosted
|
||||
* Ollama, are refused unless their host appears here. Without this an
|
||||
* ordinary user could point a provider at an internal service and use
|
||||
* the server as a probe of its own network.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* hosts:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Allowlist updated.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
*/
|
||||
router.patch("/ai-private-endpoints", 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 { hosts } = req.body;
|
||||
if (!Array.isArray(hosts)) {
|
||||
return res.status(400).json({ error: "hosts must be an array" });
|
||||
}
|
||||
if (hosts.length > 50) {
|
||||
return res.status(400).json({ error: "At most 50 hosts are allowed" });
|
||||
}
|
||||
|
||||
const cleaned: string[] = [];
|
||||
for (const entry of hosts) {
|
||||
if (typeof entry !== "string") {
|
||||
return res.status(400).json({ error: "Each host must be a string" });
|
||||
}
|
||||
const host = entry.trim().toLowerCase();
|
||||
if (!host) continue;
|
||||
// A bare host, not a URL: no scheme, path, port or whitespace.
|
||||
if (!/^[a-z0-9._:-]+$/.test(host)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: `${entry} is not a valid hostname` });
|
||||
}
|
||||
if (!cleaned.includes(host)) cleaned.push(host);
|
||||
}
|
||||
|
||||
await createCurrentSettingsRepository().set(
|
||||
AI_PRIVATE_ALLOWLIST_KEY,
|
||||
JSON.stringify(cleaned),
|
||||
);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor.username ?? userId,
|
||||
action: "update_ai_private_endpoints",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ hosts: cleaned }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ hosts: cleaned });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to update AI private endpoint allowlist", err);
|
||||
res.status(500).json({ error: "Failed to update the allowlist" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/host-defaults:
|
||||
|
||||
@@ -9,8 +9,10 @@ import { FieldCrypto } from "../../utils/field-crypto.js";
|
||||
import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
|
||||
import { isTrustedProxyAuthEnabled } from "../../utils/trusted-proxy-auth.js";
|
||||
import {
|
||||
generateDeviceFingerprint,
|
||||
getDeviceId,
|
||||
parseUserAgent,
|
||||
} from "../../utils/user-agent-parser.js";
|
||||
import {
|
||||
@@ -182,6 +184,11 @@ export function registerUserTotpRoutes(
|
||||
* description: Failed to enable TOTP.
|
||||
*/
|
||||
router.post("/totp/enable", authenticateJWT, async (req, res) => {
|
||||
if (isTrustedProxyAuthEnabled()) {
|
||||
return res.status(409).json({
|
||||
error: "TOTP is disabled while trusted proxy authentication is enabled",
|
||||
});
|
||||
}
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const sessionId = (req as AuthenticatedRequest).sessionId;
|
||||
const { totp_code } = req.body;
|
||||
@@ -325,32 +332,35 @@ export function registerUserTotpRoutes(
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
if (!totp_code || (!userRecord.isOidc && !password)) {
|
||||
// One re-authentication value, whichever kind it is. The dialog offers a
|
||||
// single field -- "Enter TOTP code or password" -- so it arrives in
|
||||
// whichever of the two body fields the caller happened to use.
|
||||
const credential = totp_code || password;
|
||||
if (!credential) {
|
||||
return res.status(400).json({
|
||||
error: userRecord.isOidc
|
||||
? "A TOTP code is required"
|
||||
: "Both password and TOTP code are required",
|
||||
: "A TOTP code or password is required",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!userRecord.isOidc &&
|
||||
(!userRecord.passwordHash ||
|
||||
!(await bcrypt.compare(password, userRecord.passwordHash)))
|
||||
) {
|
||||
return res.status(401).json({ error: "Incorrect password" });
|
||||
}
|
||||
|
||||
if (!userRecord.totpEnabled) {
|
||||
return res.status(400).json({ error: "TOTP is not enabled" });
|
||||
}
|
||||
|
||||
const userDataKey = authManager.getUserDataKey(userId);
|
||||
const verified = await verifyTotpReauth(
|
||||
// TOTP code or backup code first; verifyTotpReauth deliberately refuses
|
||||
// the account password, so that stays a separate comparison here.
|
||||
let verified = await verifyTotpReauth(
|
||||
userRecord,
|
||||
totp_code,
|
||||
credential,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
if (!verified && !userRecord.isOidc && userRecord.passwordHash) {
|
||||
verified = await bcrypt.compare(credential, userRecord.passwordHash);
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
return res
|
||||
.status(401)
|
||||
@@ -624,18 +634,23 @@ export function registerUserTotpRoutes(
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
|
||||
if (rememberMe) {
|
||||
const deviceFingerprint = generateDeviceFingerprint(deviceInfo);
|
||||
await authManager.addTrustedDevice(
|
||||
userRecord.id,
|
||||
deviceFingerprint,
|
||||
deviceInfo.type,
|
||||
deviceInfo.deviceInfo,
|
||||
const deviceFingerprint = generateDeviceFingerprint(
|
||||
deviceInfo,
|
||||
getDeviceId(req),
|
||||
);
|
||||
authLogger.info("Device automatically trusted via Remember Me", {
|
||||
operation: "totp_auto_trust",
|
||||
userId: userRecord.id,
|
||||
deviceType: deviceInfo.type,
|
||||
});
|
||||
if (deviceFingerprint) {
|
||||
await authManager.addTrustedDevice(
|
||||
userRecord.id,
|
||||
deviceFingerprint,
|
||||
deviceInfo.type,
|
||||
deviceInfo.deviceInfo,
|
||||
);
|
||||
authLogger.info("Device automatically trusted via Remember Me", {
|
||||
operation: "totp_auto_trust",
|
||||
userId: userRecord.id,
|
||||
deviceType: deviceInfo.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { Request, RequestHandler, Router } from "express";
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
@@ -18,6 +19,7 @@ import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
generateDeviceFingerprint,
|
||||
getDeviceId,
|
||||
parseUserAgent,
|
||||
} from "../../utils/user-agent-parser.js";
|
||||
import {
|
||||
@@ -321,7 +323,7 @@ export function registerUserWebAuthnRoutes(
|
||||
authLogger.warn("WebAuthn registration failed", {
|
||||
operation: "webauthn_register_verify",
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : "Unknown",
|
||||
error: getErrorMessage(error, "Unknown"),
|
||||
});
|
||||
res.status(400).json({ error: "Passkey registration failed" });
|
||||
}
|
||||
@@ -487,11 +489,13 @@ export function registerUserWebAuthnRoutes(
|
||||
);
|
||||
|
||||
if (userRecord.totpEnabled) {
|
||||
const deviceFingerprint = generateDeviceFingerprint(deviceInfo);
|
||||
const isTrusted = await authManager.isTrustedDevice(
|
||||
userRecord.id,
|
||||
deviceFingerprint,
|
||||
const deviceFingerprint = generateDeviceFingerprint(
|
||||
deviceInfo,
|
||||
getDeviceId(req),
|
||||
);
|
||||
const isTrusted = deviceFingerprint
|
||||
? await authManager.isTrustedDevice(userRecord.id, deviceFingerprint)
|
||||
: false;
|
||||
|
||||
if (!isTrusted) {
|
||||
const tempToken = await authManager.generateJWTToken(userRecord.id, {
|
||||
@@ -536,7 +540,7 @@ export function registerUserWebAuthnRoutes(
|
||||
operation: "webauthn_auth_verify",
|
||||
credentialId: credential.id,
|
||||
userId: credential.userId,
|
||||
error: error instanceof Error ? error.message : "Unknown",
|
||||
error: getErrorMessage(error, "Unknown"),
|
||||
});
|
||||
res.status(401).json({ error: "Passkey authentication failed" });
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import bcrypt from "bcryptjs";
|
||||
import crypto from "crypto";
|
||||
import { nanoid } from "nanoid";
|
||||
import type { Request, Response } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import {
|
||||
getDeviceId,
|
||||
parseUserAgent,
|
||||
generateDeviceFingerprint,
|
||||
} from "../../utils/user-agent-parser.js";
|
||||
@@ -30,7 +31,7 @@ import {
|
||||
isOIDCUserAllowed,
|
||||
OIDCTokenFormatError,
|
||||
verifyOIDCToken,
|
||||
extractOidcGroups,
|
||||
extractOidcGroupsFromSources,
|
||||
parseOidcRoleMap,
|
||||
resolveOidcMappedRoles,
|
||||
loadProviderConfig,
|
||||
@@ -39,7 +40,9 @@ import {
|
||||
validateLogoutToken,
|
||||
} from "./user-oidc-utils.js";
|
||||
import { registerUserApiKeyRoutes } from "./user-api-key-routes.js";
|
||||
import { registerUserImageStorageRoutes } from "./user-image-storage-routes.js";
|
||||
import { registerUserSettingsRoutes } from "./user-settings-routes.js";
|
||||
import { registerTouchInputSettingsRoutes } from "./touch-input-settings-routes.js";
|
||||
import { registerAcmeSSLRoutes } from "./acme-ssl-routes.js";
|
||||
import { registerUserTotpRoutes } from "./user-totp-routes.js";
|
||||
import { registerUserWebAuthnRoutes } from "./user-webauthn-routes.js";
|
||||
@@ -51,18 +54,35 @@ import { registerUserDataAccessRoutes } from "./user-data-access-routes.js";
|
||||
import { registerSSOProviderRoutes } from "./sso-provider-routes.js";
|
||||
import { registerLDAPAuthRoutes } from "./ldap-auth-routes.js";
|
||||
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||
import { notifyAutomationInternalEvent } from "../../hosts/metrics/automation-bridge.js";
|
||||
import {
|
||||
createCurrentSettingsRepository,
|
||||
getCurrentSettingValue,
|
||||
createCurrentRoleRepository,
|
||||
createCurrentSsoProviderRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import type { UserRecord } from "../repositories/user-repository.js";
|
||||
import {
|
||||
getTrustedProxyAuthConfig,
|
||||
isTrustedProxyAddress,
|
||||
isTrustedProxyAuthEnabled,
|
||||
resolveTrustedProxyRoles,
|
||||
} from "../../utils/trusted-proxy-auth.js";
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use((req, res, next) => {
|
||||
if (isTrustedProxyAuthEnabled() && req.path.startsWith("/oidc")) {
|
||||
return res.status(409).json({
|
||||
error: "OIDC is disabled while trusted proxy authentication is enabled",
|
||||
});
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
async function syncSharedCredentialsForUserRoles(
|
||||
userId: string,
|
||||
operation: string,
|
||||
@@ -133,6 +153,15 @@ async function requireCurrentAdmin(userId: string): Promise<UserRecord | null> {
|
||||
return user?.isAdmin ? user : null;
|
||||
}
|
||||
|
||||
// RFC 7636 PKCE: 43-128 char unreserved-character string.
|
||||
function generatePkceCodeVerifier(): string {
|
||||
return crypto.randomBytes(64).toString("base64url");
|
||||
}
|
||||
|
||||
function generatePkceCodeChallenge(verifier: string): string {
|
||||
return crypto.createHash("sha256").update(verifier).digest("base64url");
|
||||
}
|
||||
|
||||
async function deleteOIDCStateSettings(state: string): Promise<void> {
|
||||
const settingsRepository = createCurrentSettingsRepository();
|
||||
await settingsRepository.delete(`oidc_state_${state}`);
|
||||
@@ -140,6 +169,7 @@ async function deleteOIDCStateSettings(state: string): Promise<void> {
|
||||
await settingsRepository.delete(`oidc_frontend_origin_${state}`);
|
||||
await settingsRepository.delete(`oidc_remember_me_${state}`);
|
||||
await settingsRepository.delete(`oidc_provider_${state}`);
|
||||
await settingsRepository.delete(`oidc_pkce_verifier_${state}`);
|
||||
}
|
||||
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
@@ -681,6 +711,9 @@ router.get("/oidc/authorize", async (req, res) => {
|
||||
frontendOrigin = origin;
|
||||
}
|
||||
|
||||
const codeVerifier = generatePkceCodeVerifier();
|
||||
const codeChallenge = generatePkceCodeChallenge(codeVerifier);
|
||||
|
||||
const settingsRepository = createCurrentSettingsRepository();
|
||||
await settingsRepository.set(`oidc_state_${state}`, nonce);
|
||||
await settingsRepository.set(
|
||||
@@ -695,6 +728,7 @@ router.get("/oidc/authorize", async (req, res) => {
|
||||
`oidc_remember_me_${state}`,
|
||||
rememberMe === "true" ? "true" : "false",
|
||||
);
|
||||
await settingsRepository.set(`oidc_pkce_verifier_${state}`, codeVerifier);
|
||||
|
||||
if (providerDbId != null) {
|
||||
await settingsRepository.set(
|
||||
@@ -710,6 +744,8 @@ router.get("/oidc/authorize", async (req, res) => {
|
||||
authUrl.searchParams.set("scope", config.scopes);
|
||||
authUrl.searchParams.set("state", state);
|
||||
authUrl.searchParams.set("nonce", nonce);
|
||||
authUrl.searchParams.set("code_challenge", codeChallenge);
|
||||
authUrl.searchParams.set("code_challenge_method", "S256");
|
||||
|
||||
res.json({ auth_url: authUrl.toString(), state, nonce });
|
||||
} catch (err) {
|
||||
@@ -749,6 +785,9 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
const storedRememberMeValue = await settingsRepository.get(
|
||||
`oidc_remember_me_${state}`,
|
||||
);
|
||||
const storedCodeVerifier = await settingsRepository.get(
|
||||
`oidc_pkce_verifier_${state}`,
|
||||
);
|
||||
|
||||
if (!storedBackendCallback || !storedFrontendOrigin) {
|
||||
return res
|
||||
@@ -990,6 +1029,7 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
client_secret: config.client_secret,
|
||||
code: code,
|
||||
redirect_uri: backendCallbackUri,
|
||||
...(storedCodeVerifier ? { code_verifier: storedCodeVerifier } : {}),
|
||||
}),
|
||||
...fetchOptions,
|
||||
});
|
||||
@@ -1014,6 +1054,7 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
await deleteOIDCStateSettings(state);
|
||||
|
||||
let userInfo: Record<string, unknown> = null;
|
||||
const oidcClaimSources: Record<string, unknown>[] = [];
|
||||
const userInfoUrls: string[] = [];
|
||||
|
||||
const normalizedIssuerUrl = config.issuer_url.endsWith("/")
|
||||
@@ -1069,6 +1110,7 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
});
|
||||
return res.status(401).json({ error: "Invalid OIDC token nonce" });
|
||||
}
|
||||
oidcClaimSources.push(userInfo);
|
||||
} catch (error) {
|
||||
// A token we cannot parse as a JWS carries no claims we could trust, so
|
||||
// fall through to the userinfo endpoint instead of failing the login.
|
||||
@@ -1102,6 +1144,7 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
oidcClaimSources.push(fetchedUserInfo);
|
||||
userInfo = { ...userInfo, ...fetchedUserInfo };
|
||||
break;
|
||||
} else {
|
||||
@@ -1306,8 +1349,8 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
|
||||
// Sync admin status based on OIDC group membership
|
||||
if (config.admin_group) {
|
||||
const groups = extractOidcGroups(
|
||||
userInfo as Record<string, unknown>,
|
||||
const groups = extractOidcGroupsFromSources(
|
||||
oidcClaimSources,
|
||||
config.group_claim,
|
||||
);
|
||||
|
||||
@@ -1369,8 +1412,8 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
);
|
||||
|
||||
if (roleMap.size > 0) {
|
||||
const groups = extractOidcGroups(
|
||||
userInfo as Record<string, unknown>,
|
||||
const groups = extractOidcGroupsFromSources(
|
||||
oidcClaimSources,
|
||||
config.group_claim,
|
||||
);
|
||||
const { desired, managed } = resolveOidcMappedRoles(groups, roleMap);
|
||||
@@ -1532,7 +1575,179 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
* 500:
|
||||
* description: Login failed.
|
||||
*/
|
||||
router.post("/proxy-login", async (req, res) => {
|
||||
let config;
|
||||
try {
|
||||
config = getTrustedProxyAuthConfig();
|
||||
} catch (error) {
|
||||
authLogger.error(
|
||||
"Invalid trusted proxy authentication configuration",
|
||||
error,
|
||||
);
|
||||
return res
|
||||
.status(503)
|
||||
.json({ error: "Proxy authentication is misconfigured" });
|
||||
}
|
||||
if (!config.enabled) return res.json({ enabled: false });
|
||||
|
||||
const sourceAddress = req.socket.remoteAddress;
|
||||
try {
|
||||
if (!isTrustedProxyAddress(sourceAddress, config.trustedProxies)) {
|
||||
authLogger.warn(
|
||||
"Rejected proxy authentication from an untrusted source",
|
||||
{
|
||||
operation: "trusted_proxy_auth_rejected",
|
||||
sourceAddress,
|
||||
},
|
||||
);
|
||||
return res.status(403).json({ error: "Untrusted authentication proxy" });
|
||||
}
|
||||
} catch (error) {
|
||||
authLogger.error("Invalid trusted proxy allowlist", error);
|
||||
return res
|
||||
.status(503)
|
||||
.json({ error: "Proxy authentication is misconfigured" });
|
||||
}
|
||||
|
||||
const usernameValue = req.headers[config.usernameHeader];
|
||||
const roleValue = req.headers[config.roleHeader];
|
||||
const username = Array.isArray(usernameValue)
|
||||
? usernameValue[0]
|
||||
: usernameValue;
|
||||
const roleHeader = Array.isArray(roleValue) ? roleValue[0] : roleValue;
|
||||
if (!isNonEmptyString(username) || !isNonEmptyString(roleHeader)) {
|
||||
return res
|
||||
.status(401)
|
||||
.json({ error: "Proxy authentication headers are missing" });
|
||||
}
|
||||
|
||||
const mappedRoles = resolveTrustedProxyRoles(roleHeader, config.roleMap);
|
||||
if (!mappedRoles) {
|
||||
return res.status(403).json({ error: "Proxy role is not mapped" });
|
||||
}
|
||||
|
||||
try {
|
||||
const [legacyOidc, enabledProviders, userRecord] = await Promise.all([
|
||||
createCurrentSettingsRepository().get("oidc_config"),
|
||||
createCurrentSsoProviderRepository().listEnabled(),
|
||||
createCurrentUserRepository().findByUsername(username),
|
||||
]);
|
||||
const hasOidc =
|
||||
Boolean(getOIDCConfigFromEnv() || legacyOidc) ||
|
||||
enabledProviders.some((provider) =>
|
||||
["oidc", "github", "google"].includes(provider.type),
|
||||
);
|
||||
if (hasOidc) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: "Proxy authentication cannot be used with OIDC" });
|
||||
}
|
||||
if (!userRecord) {
|
||||
return res.status(403).json({ error: "Proxy user must already exist" });
|
||||
}
|
||||
if (userRecord.isOidc || userRecord.totpEnabled) {
|
||||
return res.status(409).json({
|
||||
error: "Proxy authentication cannot be used with OIDC or TOTP users",
|
||||
});
|
||||
}
|
||||
|
||||
const roleRepository = createCurrentRoleRepository();
|
||||
const managedRoles = new Set([...config.roleMap.values()].flat());
|
||||
for (const roleName of managedRoles) {
|
||||
if (!(await roleRepository.findRoleByName(roleName))) {
|
||||
authLogger.error("Trusted proxy role map references a missing role", {
|
||||
operation: "trusted_proxy_auth_missing_role",
|
||||
roleName,
|
||||
});
|
||||
return res
|
||||
.status(503)
|
||||
.json({ error: "Proxy role mapping is misconfigured" });
|
||||
}
|
||||
}
|
||||
|
||||
const currentRoles = await roleRepository.listUserRoles(userRecord.id);
|
||||
const currentNames = new Set(currentRoles.map((role) => role.roleName));
|
||||
for (const roleName of mappedRoles) {
|
||||
if (!currentNames.has(roleName)) {
|
||||
await roleRepository.assignRoleNameToUser({
|
||||
userId: userRecord.id,
|
||||
roleName,
|
||||
grantedBy: userRecord.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const role of currentRoles) {
|
||||
if (
|
||||
managedRoles.has(role.roleName) &&
|
||||
!mappedRoles.includes(role.roleName)
|
||||
) {
|
||||
await roleRepository.removeRoleFromUser(userRecord.id, role.roleId);
|
||||
}
|
||||
}
|
||||
PermissionManager.getInstance().invalidateUserPermissionCache(
|
||||
userRecord.id,
|
||||
);
|
||||
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
if (
|
||||
!(await authManager.authenticateWebAuthnUser(
|
||||
userRecord.id,
|
||||
deviceInfo.type,
|
||||
))
|
||||
) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: "User encryption data is unavailable" });
|
||||
}
|
||||
await syncSharedCredentialsForUserRoles(
|
||||
userRecord.id,
|
||||
"trusted_proxy_login_role_shared_credentials",
|
||||
);
|
||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||
deviceType: deviceInfo.type,
|
||||
deviceInfo: deviceInfo.deviceInfo,
|
||||
});
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId: userRecord.id,
|
||||
username: userRecord.username,
|
||||
action: "trusted_proxy_login",
|
||||
resourceType: "session",
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
authLogger.success("Trusted proxy login successful", {
|
||||
operation: "trusted_proxy_login",
|
||||
userId: userRecord.id,
|
||||
sessionId: payload?.sessionId,
|
||||
mappedRoles,
|
||||
});
|
||||
|
||||
return res
|
||||
.cookie("jwt", token, authManager.getSecureCookieOptions(req))
|
||||
.json({
|
||||
enabled: true,
|
||||
success: true,
|
||||
username: userRecord.username,
|
||||
userId: userRecord.id,
|
||||
is_admin: !!userRecord.isAdmin,
|
||||
...(isNativeAppRequest(req) ? { token } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
authLogger.error("Trusted proxy login failed", error);
|
||||
return res.status(500).json({ error: "Proxy authentication failed" });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/login", async (req, res) => {
|
||||
if (isTrustedProxyAuthEnabled()) {
|
||||
return res.status(403).json({
|
||||
error:
|
||||
"Password login is disabled while trusted proxy authentication is enabled",
|
||||
});
|
||||
}
|
||||
const { username, password, rememberMe } = req.body;
|
||||
const clientIp = req.ip || req.socket.remoteAddress || "unknown";
|
||||
authLogger.info("User login request received", {
|
||||
@@ -1643,13 +1858,15 @@ router.post("/login", async (req, res) => {
|
||||
);
|
||||
|
||||
if (userRecord.totpEnabled) {
|
||||
const deviceFingerprint = generateDeviceFingerprint(deviceInfo);
|
||||
|
||||
const isTrusted = await authManager.isTrustedDevice(
|
||||
userRecord.id,
|
||||
deviceFingerprint,
|
||||
const deviceFingerprint = generateDeviceFingerprint(
|
||||
deviceInfo,
|
||||
getDeviceId(req),
|
||||
);
|
||||
|
||||
const isTrusted = deviceFingerprint
|
||||
? await authManager.isTrustedDevice(userRecord.id, deviceFingerprint)
|
||||
: false;
|
||||
|
||||
if (isTrusted) {
|
||||
authLogger.info("TOTP bypassed for trusted device", {
|
||||
operation: "totp_bypass",
|
||||
@@ -1697,6 +1914,11 @@ router.post("/login", async (req, res) => {
|
||||
success: true,
|
||||
});
|
||||
|
||||
notifyAutomationInternalEvent("user_login", userRecord.id, undefined, {
|
||||
username,
|
||||
ipAddress: loginIp,
|
||||
});
|
||||
|
||||
const response: Record<string, unknown> = {
|
||||
success: true,
|
||||
is_admin: !!userRecord.isAdmin,
|
||||
@@ -2786,9 +3008,11 @@ registerUserOidcAccountRoutes(router, {
|
||||
});
|
||||
|
||||
registerUserSettingsRoutes(router, authenticateJWT);
|
||||
registerTouchInputSettingsRoutes(router, authenticateJWT);
|
||||
registerAcmeSSLRoutes(router, authenticateJWT);
|
||||
|
||||
registerUserApiKeyRoutes(router, requireAdmin);
|
||||
registerUserImageStorageRoutes(router, requireAdmin);
|
||||
|
||||
registerSSOProviderRoutes(router);
|
||||
registerLDAPAuthRoutes(router);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import express from "express";
|
||||
import type { Request, Response } from "express";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import {
|
||||
createCurrentVaultProfileRepository,
|
||||
createCurrentUserRepository,
|
||||
@@ -93,11 +92,19 @@ router.get("/oidc/callback", async (req: Request, res: Response) => {
|
||||
const code = String(req.query.code || "");
|
||||
const oidcError = req.query.error ? String(req.query.error) : "";
|
||||
|
||||
const esc = (value: string): string =>
|
||||
value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
const html = (title: string, message: string) =>
|
||||
`<!doctype html><html><head><meta charset="utf-8"><title>${title}</title>
|
||||
`<!doctype html><html><head><meta charset="utf-8"><title>${esc(title)}</title>
|
||||
<style>body{font-family:system-ui,sans-serif;background:#0b0b0c;color:#e5e5e5;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
||||
.card{max-width:420px;text-align:center;padding:24px;border:1px solid #2a2a2e;border-radius:8px}</style></head>
|
||||
<body><div class="card"><h2>${title}</h2><p>${message}</p>
|
||||
<body><div class="card"><h2>${esc(title)}</h2><p>${esc(message)}</p>
|
||||
<script>setTimeout(function(){window.close()},1500)</script></div></body></html>`;
|
||||
|
||||
if (oidcError) {
|
||||
|
||||
@@ -0,0 +1,638 @@
|
||||
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 { createCurrentWorkspaceRepository } from "../repositories/factory.js";
|
||||
import type { WorkspaceRecord } from "../repositories/workspace-repository.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 parseWorkspaceId(raw: unknown): number | null {
|
||||
const id = typeof raw === "string" ? parseInt(raw, 10) : NaN;
|
||||
return Number.isInteger(id) ? id : null;
|
||||
}
|
||||
|
||||
function isValidPayload(val: unknown): val is Record<string, unknown> {
|
||||
return (
|
||||
typeof val === "object" &&
|
||||
val !== null &&
|
||||
Array.isArray((val as Record<string, unknown>).tabs)
|
||||
);
|
||||
}
|
||||
|
||||
function serialize(record: WorkspaceRecord) {
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(record.payload || "{}");
|
||||
} catch {
|
||||
payload = { version: 1, tabs: [] };
|
||||
}
|
||||
const tabs = Array.isArray((payload as { tabs?: unknown[] })?.tabs)
|
||||
? (payload as { tabs: unknown[] }).tabs
|
||||
: [];
|
||||
|
||||
return {
|
||||
...record,
|
||||
payload,
|
||||
tabCount: tabs.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces:
|
||||
* get:
|
||||
* summary: List the current user's saved workspaces
|
||||
* description: Returns every manual workspace plus the single auto-maintained "Last Session" workspace, each with a computed tabCount.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of workspaces.
|
||||
*/
|
||||
router.get(
|
||||
"/",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
try {
|
||||
const records =
|
||||
await createCurrentWorkspaceRepository().listByUser(userId);
|
||||
res.json(records.map(serialize));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to list workspaces", err, {
|
||||
operation: "workspace_list_failed",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list workspaces" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces:
|
||||
* post:
|
||||
* summary: Save the current tab arrangement as a new named workspace
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* color:
|
||||
* type: string
|
||||
* icon:
|
||||
* type: string
|
||||
* payload:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Workspace created.
|
||||
* 400:
|
||||
* description: Invalid request body.
|
||||
*/
|
||||
router.post(
|
||||
"/",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { name, color, icon, payload } = req.body ?? {};
|
||||
|
||||
if (!isNonEmptyString(name)) {
|
||||
return res.status(400).json({ error: "Workspace name is required" });
|
||||
}
|
||||
if (!isValidPayload(payload)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "payload with a tabs array is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await createCurrentWorkspaceRepository().create(userId, {
|
||||
name: name.trim(),
|
||||
color: isNonEmptyString(color) ? color : null,
|
||||
icon: isNonEmptyString(icon) ? icon : null,
|
||||
payload: JSON.stringify(payload),
|
||||
});
|
||||
res.json(serialize(created));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to create workspace", err, {
|
||||
operation: "workspace_create_failed",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to create workspace" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces/last-session:
|
||||
* get:
|
||||
* summary: Fetch the auto-maintained "Last Session" workspace
|
||||
* description: Returns null if the current session has never been auto-saved yet.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The Last Session workspace, or null.
|
||||
*/
|
||||
router.get(
|
||||
"/last-session",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
try {
|
||||
const record =
|
||||
await createCurrentWorkspaceRepository().findLastSession(userId);
|
||||
res.json(record ? serialize(record) : null);
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to fetch last session workspace", err, {
|
||||
operation: "workspace_last_session_get_failed",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to fetch last session workspace" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces/last-session:
|
||||
* put:
|
||||
* summary: Upsert the auto-maintained "Last Session" workspace
|
||||
* description: Always overwrites the single Last Session row for the caller - never creates a second one. Called by the frontend's debounced auto-save effect.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* payload:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Last Session workspace saved.
|
||||
* 400:
|
||||
* description: Invalid request body.
|
||||
*/
|
||||
router.put(
|
||||
"/last-session",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { payload } = req.body ?? {};
|
||||
|
||||
if (!isValidPayload(payload)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "payload with a tabs array is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const saved = await createCurrentWorkspaceRepository().upsertLastSession(
|
||||
userId,
|
||||
JSON.stringify(payload),
|
||||
);
|
||||
res.json(serialize(saved));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to save last session workspace", err, {
|
||||
operation: "workspace_last_session_save_failed",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to save last session workspace" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces/{id}:
|
||||
* patch:
|
||||
* summary: Rename or recolor a workspace
|
||||
* description: Does not accept a payload - use PUT /workspaces/{id}/content to overwrite a workspace's saved tab arrangement. Rejects the Last Session workspace.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Workspace updated.
|
||||
* 400:
|
||||
* description: Invalid request, or target is the Last Session workspace.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.patch(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = parseWorkspaceId(req.params.id);
|
||||
if (id === null) {
|
||||
return res.status(400).json({ error: "Invalid workspace ID" });
|
||||
}
|
||||
|
||||
const { name, color, icon } = req.body ?? {};
|
||||
if (name !== undefined && !isNonEmptyString(name)) {
|
||||
return res.status(400).json({ error: "Workspace name cannot be empty" });
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await createCurrentWorkspaceRepository().update(
|
||||
userId,
|
||||
id,
|
||||
{
|
||||
name: name !== undefined ? name.trim() : undefined,
|
||||
color,
|
||||
icon,
|
||||
},
|
||||
);
|
||||
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: "Workspace not found" });
|
||||
}
|
||||
res.json(serialize(updated));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to update workspace", err, {
|
||||
operation: "workspace_update_failed",
|
||||
userId,
|
||||
workspaceId: id,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to update workspace" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces/{id}/content:
|
||||
* put:
|
||||
* summary: Overwrite a workspace's saved tab arrangement with a new payload
|
||||
* description: Used by "Update with current" in the Workspaces panel. Rejects the Last Session workspace.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* payload:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Workspace content updated.
|
||||
* 400:
|
||||
* description: Invalid request body.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.put(
|
||||
"/:id/content",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = parseWorkspaceId(req.params.id);
|
||||
if (id === null) {
|
||||
return res.status(400).json({ error: "Invalid workspace ID" });
|
||||
}
|
||||
|
||||
const { payload } = req.body ?? {};
|
||||
if (!isValidPayload(payload)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "payload with a tabs array is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await createCurrentWorkspaceRepository().updateContent(
|
||||
userId,
|
||||
id,
|
||||
JSON.stringify(payload),
|
||||
);
|
||||
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: "Workspace not found" });
|
||||
}
|
||||
res.json(serialize(updated));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to update workspace content", err, {
|
||||
operation: "workspace_content_update_failed",
|
||||
userId,
|
||||
workspaceId: id,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to update workspace content" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces/{id}/duplicate:
|
||||
* post:
|
||||
* summary: Duplicate a workspace's content and color/icon under a new name
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: New workspace created from the duplicate.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.post(
|
||||
"/:id/duplicate",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = parseWorkspaceId(req.params.id);
|
||||
const { name } = req.body ?? {};
|
||||
if (id === null) {
|
||||
return res.status(400).json({ error: "Invalid workspace ID" });
|
||||
}
|
||||
if (!isNonEmptyString(name)) {
|
||||
return res.status(400).json({ error: "Workspace name is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const repository = createCurrentWorkspaceRepository();
|
||||
const source = await repository.findById(userId, id);
|
||||
if (!source) {
|
||||
return res.status(404).json({ error: "Workspace not found" });
|
||||
}
|
||||
|
||||
const created = await repository.create(userId, {
|
||||
name: name.trim(),
|
||||
color: source.color,
|
||||
icon: source.icon,
|
||||
payload: source.payload,
|
||||
});
|
||||
res.json(serialize(created));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to duplicate workspace", err, {
|
||||
operation: "workspace_duplicate_failed",
|
||||
userId,
|
||||
workspaceId: id,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to duplicate workspace" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces/{id}/set-default:
|
||||
* post:
|
||||
* summary: Mark a workspace as the restore-on-login default
|
||||
* description: Clears isDefault on any other workspace for the caller. Idempotent if the target is already the default. Rejects the Last Session workspace.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Workspace set as default.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.post(
|
||||
"/:id/set-default",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = parseWorkspaceId(req.params.id);
|
||||
if (id === null) {
|
||||
return res.status(400).json({ error: "Invalid workspace ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await createCurrentWorkspaceRepository().setDefault(
|
||||
userId,
|
||||
id,
|
||||
);
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: "Workspace not found" });
|
||||
}
|
||||
res.json(serialize(updated));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to set default workspace", err, {
|
||||
operation: "workspace_set_default_failed",
|
||||
userId,
|
||||
workspaceId: id,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to set default workspace" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces/{id}/unset-default:
|
||||
* post:
|
||||
* summary: Remove a workspace as the restore-on-login default
|
||||
* description: Idempotent if the target is not currently the default. Rejects the Last Session workspace.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Workspace unset as default.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.post(
|
||||
"/:id/unset-default",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = parseWorkspaceId(req.params.id);
|
||||
if (id === null) {
|
||||
return res.status(400).json({ error: "Invalid workspace ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await createCurrentWorkspaceRepository().unsetDefault(
|
||||
userId,
|
||||
id,
|
||||
);
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: "Workspace not found" });
|
||||
}
|
||||
res.json(serialize(updated));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to unset default workspace", err, {
|
||||
operation: "workspace_unset_default_failed",
|
||||
userId,
|
||||
workspaceId: id,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to unset default workspace" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces/{id}/apply:
|
||||
* post:
|
||||
* summary: Fetch a workspace to apply and mark it as just used
|
||||
* description: Returns the full workspace with its payload parsed, and touches lastUsedAt server-side so the caller does not need a second round trip.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The workspace to apply.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.post(
|
||||
"/:id/apply",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = parseWorkspaceId(req.params.id);
|
||||
if (id === null) {
|
||||
return res.status(400).json({ error: "Invalid workspace ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
const repository = createCurrentWorkspaceRepository();
|
||||
const record = await repository.findById(userId, id);
|
||||
if (!record) {
|
||||
return res.status(404).json({ error: "Workspace not found" });
|
||||
}
|
||||
|
||||
await repository.touchLastUsed(userId, id);
|
||||
res.json(serialize(record));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to apply workspace", err, {
|
||||
operation: "workspace_apply_failed",
|
||||
userId,
|
||||
workspaceId: id,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to apply workspace" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces/{id}:
|
||||
* delete:
|
||||
* summary: Delete a workspace
|
||||
* description: Rejects the Last Session workspace, which is not user-deletable.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Workspace deleted.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.delete(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = parseWorkspaceId(req.params.id);
|
||||
if (id === null) {
|
||||
return res.status(400).json({ error: "Invalid workspace ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
const deleted = await createCurrentWorkspaceRepository().delete(
|
||||
userId,
|
||||
id,
|
||||
);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: "Workspace not found" });
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to delete workspace", err, {
|
||||
operation: "workspace_delete_failed",
|
||||
userId,
|
||||
workspaceId: id,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to delete workspace" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user