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:
Luke Gustafson
2026-08-19 14:12:06 -05:00
committed by GitHub
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
837 changed files with 387941 additions and 15831 deletions
+18 -4
View File
@@ -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) {
+2 -1
View File
@@ -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 [];
}
+812
View File
@@ -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;
+16 -18
View File
@@ -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),
});
}
}
+126 -6
View File
@@ -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;
+173 -30
View File
@@ -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;
}
+2 -2
View File
@@ -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;
+22 -19
View File
@@ -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,
+11 -18
View File
@@ -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 {
+50 -28
View File
@@ -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(
+35 -26
View File
@@ -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 {
+34 -18
View File
@@ -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
);
}
+317 -8
View File
@@ -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 -2
View File
@@ -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 -6
View File
@@ -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 = [
+89 -34
View File
@@ -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:
+38 -23
View File
@@ -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" });
}
+236 -12
View File
@@ -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);
+11 -4
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
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) {
+638
View File
@@ -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;