Files
Termix/src/backend/database/database.ts
T
ZacharyZcR 1e7c9ea53c draft: database layer refactor (#1054)
* feat(sshid) - sshid.io equivalent for termix (#919)

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

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

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

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

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

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

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

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

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

---------

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

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

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


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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

Bumps the major-updates group with 5 updates:

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


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

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

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

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

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

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

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

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

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

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

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

Two bugs introduced in v2.4.1:

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

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

Fixes #928

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

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

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

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

* fix: resolve tunnel endpoints reliably (#935)

* Fix Electron OIDC browser auth failures (#936)

* Allow RDP connections without stored credentials (#937)

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

* Fix terminal link dialog layering (#940)

* Confirm large files before opening editor (#942)

* Confirm closing active host connections (#943)

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

* fix: preserve unicode guacamole tokens (#933)

* Persist VNC authentication settings (#944)

* Fix Guacamole websocket base path (#946)

* Promote file manager terminals to tabs (#939)

* Guard Guacamole disconnect during startup (#945)

* chore: increment ver

* feat: bitwarden ssh agent integration

* feat: serial connections support

* fix: various small bug fixes

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

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

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

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

* feat: new grid and widget based homepage function

* feat: new donate button in dashboard

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

* chore: start database layer refactor

* docs: plan database layer refactor

* docs: audit database layer refactor phase zero

* chore: add database runtime adapter skeleton

* chore: add settings repository skeleton

* chore: add user session repository skeleton

* chore: add host credential repository skeleton

* chore: add field encryption boundary

* chore: migrate settings route slice

* chore: migrate user settings routes

* chore: migrate host metrics settings routes

* chore: migrate acme settings route

* chore: migrate terminal settings route

* chore: migrate tailscale settings read

* chore: migrate guacamole settings reads

* chore: migrate session timeout settings reads

* chore: migrate auth route settings reads

* chore: migrate host metrics settings reads

* chore: migrate startup settings reads

* chore: migrate user settings cleanup

* chore: migrate password reset settings

* chore: migrate oidc legacy settings read

* chore: migrate user route settings slice

* chore: migrate oidc state settings

* chore: migrate user login settings reads

* chore: migrate user crypto settings

* chore: consolidate startup settings defaults

* chore: consolidate database settings import export

* chore: migrate core session auth paths

* chore: migrate remaining session auth paths

* chore: migrate admin user routes

* chore: migrate user route admin checks

* chore: migrate user lifecycle routes

* chore: migrate auth user lookups

* chore: migrate oidc user routes

* chore: migrate api key repository paths

* docs: add database gray rollout guide

* chore: migrate trusted device paths

* chore: migrate user session route user lookups

* chore: add database repository rollout guard

* chore: expose repository rollout status

* chore: warn on repository rollout misconfiguration

* chore: migrate remaining user lookup helpers

* chore: migrate ssh user lookups

* chore: migrate user settings admin lookups

* chore: migrate acme ssl user lookups

* chore: migrate audit log admin checks

* chore: migrate oidc account user updates

* chore: migrate password reset user updates

* chore: migrate user deletion core records

* chore: migrate snippet audit user lookups

* chore: migrate ldap user sync paths

* chore: migrate totp user updates

* chore: migrate rbac user checks

* chore: migrate rbac role paths

* chore: migrate permission role lookups

* chore: migrate rbac access list reads

* chore: migrate shared rbac reads

* chore: migrate rbac access writes

* chore: migrate permission host access

* chore: migrate role host access lookup

* chore: migrate snippet access lookup

* chore: migrate shared credential access lookups

* chore: migrate host access cleanup writes

* chore: migrate host list access checks

* chore: migrate host access cleanup routes

* chore: migrate shared credential role lookups

* chore: migrate user role cleanup

* chore: migrate admin role sync

* chore: migrate ldap role sync

* chore: migrate user role assignment

* chore: migrate sso provider access

* chore: migrate audit log access

* chore: migrate user preference access

* chore: migrate open tab access

* chore: migrate dismissed alert access

* chore: migrate homepage layout access

* chore: migrate network topology access

* chore: migrate dashboard service link access

* chore: migrate command history access

* chore: migrate recent activity cleanup

* chore: migrate ssh credential usage access

* chore: migrate transfer recent access

* chore: migrate file manager bookmark access

* chore: migrate c2s tunnel preset access

* chore: migrate homepage item access

* chore: migrate session recording access

* chore: migrate tmux session tag access

* chore: migrate opkssh token access

* chore: migrate vault token access

* chore: migrate vault profile access

* chore: migrate host metrics preference access

* chore: migrate host health access

* chore: migrate host metrics history access

* chore: migrate alert persistence access

* chore: route alert host lookup through repository

* chore: migrate user data export reads

* chore: route host metrics stats sync through repository

* chore: migrate host folder persistence

* chore: migrate host resolution reads

* chore: route jump host resolution reads

* chore: route docker console jump host reads

* chore: route docker ssh resolution reads

* chore: route proxmox discovery resolution reads

* chore: route file manager activity host reads

* chore: route host metrics resolution reads

* chore: route ssh auth credential reads

* chore: route tunnel endpoint credential reads

* chore: route credential deployment resolution reads

* chore: route command history host flag reads

* chore: route snippet execution resolution reads

* chore: route terminal host resolution reads

* chore: route vault oidc host resolution reads

* chore: route wake on lan host reads

* chore: route internal host list reads

* chore: route host key verification persistence

* chore: route credential read paths

* chore: route credential host usage reads

* chore: route credential folder rename

* chore: route host owner access checks

* chore: route shared credential source reads

* chore: route user host credential cleanup

* chore: route credential delete reads

* chore: route credential update reads

* chore: route host credential reads

* chore: route host read paths

* chore: route host projection reads

* chore: route host list reads

* chore: route snippet read paths

* chore: route snippet folder writes

* chore: route snippet crud paths

* chore: route snippet bulk import

* chore: route rbac ownership reads

* chore: route user count reads

* chore: route cleanup snippets folders

* chore: route shared credential persistence

* chore: route dashboard activity

* chore: route guacamole host reads

* chore: route host bulk lookups

* chore: remove unlock-only simple db ops

* chore: route host autostart persistence

* chore: route ldap provisioning through users

* chore: route credential encrypted writes

* chore: route host encrypted writes

* chore: route bulk host encrypted writes

* chore: route termix id credentials

* chore: route termix id ca persistence

* chore: route termix identity persistence

* chore: route credential system migration

* chore: isolate user encryption migration storage

* chore: remove legacy simple db ops

* chore: isolate legacy sqlite migration copy

* chore: route database settings import export

* chore: route database host credential export

* chore: route database host credential import

* chore: route database file-manager import export

* chore: route database alert usage import export

* chore: route database user checks

* chore: isolate auth lazy migration storage

* chore: route explicit database saves

* chore: initialize database save boundary

* chore: route migration snapshot saves

* chore: isolate sqlite import constraints

* chore: route import sqlite boundary

* chore: route user encryption migration store

* chore: centralize current repository runtime

* chore: route more current repositories

* chore: route activity repository runtimes

* chore: route token repository runtimes

* chore: route health repository runtimes

* chore: route identity repository runtimes

* chore: route rbac repository runtime

* chore: centralize current sqlite runtime access

* chore: route user deletion key cleanup

* chore: route user deletion vault cleanup

* chore: route user deletion homepage cleanup

* chore: route user deletion health cleanup

* chore: route user deletion alert cleanup

* chore: route user deletion identity cleanup

* chore: add database layer preupgrade backup

* Fix database repository type errors

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

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

---------

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

2032 lines
62 KiB
TypeScript

import express from "express";
import http from "http";
import bodyParser from "body-parser";
import multer from "multer";
import cookieParser from "cookie-parser";
import userRoutes from "./routes/users.js";
import hostRoutes from "./routes/host.js";
import alertRoutes from "./routes/alerts.js";
import credentialsRoutes from "./routes/credentials.js";
import snippetsRoutes from "./routes/snippets.js";
import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
import terminalRoutes from "./routes/terminal.js";
import sessionLogRoutes from "./routes/session-log-routes.js";
import guacamoleRoutes from "../guacamole/routes.js";
import networkTopologyRoutes from "./routes/network-topology.js";
import rbacRoutes from "./routes/rbac.js";
import openTabsRoutes from "./routes/open-tabs.js";
import userPreferencesRoutes from "./routes/user-preferences.js";
import proxmoxRoutes from "./routes/proxmox.js";
import termixIdRoutes from "./routes/termix-id.js";
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
import { registerTailscaleRoutes } from "./routes/tailscale-routes.js";
import vaultRoutes from "./routes/vault.js";
import alertRulesRoutes from "./routes/alert-rules-routes.js";
import { createCorsMiddleware } from "../utils/cors-config.js";
import fs from "fs";
import path from "path";
import os from "os";
import "dotenv/config";
import { databaseLogger, apiLogger } from "../utils/logger.js";
import { AuthManager } from "../utils/auth-manager.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { DatabaseFileEncryption } from "../utils/database-file-encryption.js";
import { DatabaseMigration } from "../utils/database-migration.js";
import { UserDataExport } from "../utils/user-data-export.js";
import { AutoSSLSetup } from "../utils/auto-ssl-setup.js";
import { createCurrentCredentialRepository } from "./repositories/current-credential-repository.js";
import { createCurrentDismissedAlertRepository } from "./repositories/current-dismissed-alert-repository.js";
import { createCurrentFileManagerBookmarkRepository } from "./repositories/current-file-manager-bookmark-repository.js";
import { createCurrentHostRepository } from "./repositories/current-host-repository.js";
import { createCurrentSettingsRepository } from "./repositories/current-settings-repository.js";
import { createCurrentSshCredentialUsageRepository } from "./repositories/current-ssh-credential-usage-repository.js";
import { createCurrentUserRepository } from "./repositories/current-user-repository.js";
import { getRepositoryRolloutStatus } from "./repositories/repository-rollout.js";
import { withCurrentSqliteForeignKeysDisabled } from "./runtime/sqlite-foreign-key-boundary.js";
import { parseUserAgent } from "../utils/user-agent-parser.js";
import { getProxyAgent } from "../utils/proxy-agent.js";
import type {
CacheEntry,
GitHubRelease,
GitHubAPIResponse,
AuthenticatedRequest,
} from "../../types/index.js";
import { DatabaseSaveTrigger } from "./db/index.js";
import Database from "better-sqlite3";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
app.set("trust proxy", true);
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireAdmin = authManager.createAdminMiddleware();
app.use(createCorsMiddleware());
type SettingData = {
key: string;
value: string;
};
function shouldExportSetting(key: string): boolean {
return !key.startsWith("reset_code_") && !key.startsWith("temp_reset_token_");
}
async function getExportableSettings(): Promise<SettingData[]> {
const settingsRows = await createCurrentSettingsRepository().listAll();
return settingsRows.filter((setting) => shouldExportSetting(setting.key));
}
function writeSettingsToExportDatabase(
exportDb: Database.Database,
settingsRows: SettingData[],
): void {
const insertSetting = exportDb.prepare(`
INSERT INTO settings (key, value)
VALUES (?, ?)
`);
for (const setting of settingsRows) {
insertSetting.run(setting.key, setting.value);
}
}
function readImportedSettings(importDb: Database.Database): SettingData[] {
return importDb
.prepare("SELECT key, value FROM settings")
.all() as SettingData[];
}
async function upsertImportedSetting(setting: SettingData): Promise<void> {
await createCurrentSettingsRepository().upsert(setting.key, setting.value);
}
const uploadsDir = path.join(process.env.DATA_DIR || "./db/data", "uploads");
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, uploadsDir);
},
filename: (req, file, cb) => {
const timestamp = Date.now();
cb(null, `${timestamp}-${file.originalname}`);
},
});
const upload = multer({
storage: storage,
limits: {
fileSize: 1024 * 1024 * 1024,
},
fileFilter: (req, file, cb) => {
if (
file.originalname.endsWith(".termix-export.sqlite") ||
file.originalname.endsWith(".sqlite")
) {
cb(null, true);
} else {
cb(new Error("Only .termix-export.sqlite files are allowed"));
}
},
});
class GitHubCache {
private cache: Map<string, CacheEntry> = new Map();
private readonly CACHE_DURATION = 30 * 60 * 1000;
set<T>(key: string, data: T): void {
const now = Date.now();
this.cache.set(key, {
data,
timestamp: now,
expiresAt: now + this.CACHE_DURATION,
});
}
get<T>(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) {
return null;
}
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return null;
}
return entry.data as T;
}
}
const githubCache = new GitHubCache();
function parseSemver(
version: string | undefined,
): [number, number, number] | null {
const match = String(version || "").match(/(\d+)\.(\d+)(?:\.(\d+))?/);
if (!match) return null;
return [Number(match[1]), Number(match[2]), Number(match[3] || 0)];
}
function compareSemver(
a: string | undefined,
b: string | undefined,
): number | null {
const parsedA = parseSemver(a);
const parsedB = parseSemver(b);
if (!parsedA || !parsedB) return null;
for (let i = 0; i < 3; i += 1) {
if (parsedA[i] > parsedB[i]) return 1;
if (parsedA[i] < parsedB[i]) return -1;
}
return 0;
}
const GITHUB_API_BASE = "https://api.github.com";
const REPO_OWNER = "Termix-SSH";
const REPO_NAME = "Termix";
async function fetchGitHubAPI<T>(
endpoint: string,
cacheKey: string,
): Promise<GitHubAPIResponse<T>> {
const cachedEntry = githubCache.get<CacheEntry<T>>(cacheKey);
if (cachedEntry) {
return {
data: cachedEntry.data,
cached: true,
cache_age: Date.now() - cachedEntry.timestamp,
};
}
try {
const url = `${GITHUB_API_BASE}${endpoint}`;
const response = await fetch(url, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "TermixUpdateChecker/1.0",
"X-GitHub-Api-Version": "2022-11-28",
},
dispatcher: getProxyAgent(url),
});
if (!response.ok) {
throw new Error(
`GitHub API error: ${response.status} ${response.statusText}`,
);
}
const data = (await response.json()) as T;
const cacheData: CacheEntry<T> = {
data,
timestamp: Date.now(),
expiresAt: Date.now() + 30 * 60 * 1000,
};
githubCache.set(cacheKey, cacheData);
return {
data: data,
cached: false,
};
} catch (error) {
databaseLogger.error(`Failed to fetch from GitHub API`, error, {
operation: "github_api",
endpoint,
});
throw error;
}
}
app.use(bodyParser.json({ limit: "1gb" }));
app.use(bodyParser.urlencoded({ limit: "1gb", extended: true }));
app.use(bodyParser.raw({ limit: "5gb", type: "application/octet-stream" }));
app.use(cookieParser());
app.use((_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
next();
});
/**
* @openapi
* /health:
* get:
* summary: Health check
* description: Returns the health status of the server.
* tags:
* - General
* responses:
* 200:
* description: Server is healthy.
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: ok
*/
app.get("/health", (req, res) => {
res.json({ status: "ok" });
});
/**
* @openapi
* /version:
* get:
* summary: Get version information
* description: Returns the local and remote version of the application.
* tags:
* - General
* responses:
* 200:
* description: Version information.
* 404:
* description: Local version not set.
* 500:
* description: Fetch error.
*/
app.get("/version", authenticateJWT, async (req, res) => {
let localVersion = process.env.VERSION;
if (!localVersion) {
const versionSources = [
() => {
try {
const packagePath = path.resolve(process.cwd(), "package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
return packageJson.version;
} catch {
return null;
}
},
() => {
try {
const packagePath = path.resolve("/app", "package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
return packageJson.version;
} catch {
return null;
}
},
() => {
try {
const packagePath = path.resolve(__dirname, "../../../package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
return packageJson.version;
} catch {
return null;
}
},
];
for (const getVersion of versionSources) {
try {
const foundVersion = getVersion();
if (foundVersion && foundVersion !== "unknown") {
localVersion = foundVersion;
break;
}
} catch {
continue;
}
}
}
if (!localVersion) {
databaseLogger.error("No version information available", undefined, {
operation: "version_check",
});
return res.status(404).send("Local Version Not Set");
}
if (req.query.checkRemote === "false") {
return res.json({ localVersion, status: "update_check_disabled" });
}
try {
const cacheKey = "latest_release";
const releaseData = await fetchGitHubAPI<GitHubRelease>(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
cacheKey,
);
const rawTag = releaseData.data.tag_name || releaseData.data.name || "";
const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/);
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
if (!remoteVersion) {
databaseLogger.warn("Remote version not found in GitHub response", {
operation: "version_check",
rawTag,
});
return res.status(401).send("Remote Version Not Found");
}
const versionComparison = compareSemver(localVersion, remoteVersion);
const status =
versionComparison === null || versionComparison === 0
? "up_to_date"
: versionComparison > 0
? "beta"
: "requires_update";
const response = {
status,
localVersion: localVersion,
version: remoteVersion,
remoteVersion: remoteVersion,
latest_release: {
tag_name: releaseData.data.tag_name,
name: releaseData.data.name,
published_at: releaseData.data.published_at,
html_url: releaseData.data.html_url,
},
cached: releaseData.cached,
cache_age: releaseData.cache_age,
};
res.json(response);
} catch (err) {
databaseLogger.error("Version check failed", err, {
operation: "version_check",
});
res.status(500).send("Fetch Error");
}
});
/**
* @openapi
* /releases/rss:
* get:
* summary: Get releases in RSS format
* description: Returns the latest releases from the GitHub repository in an RSS-like JSON format.
* tags:
* - General
* parameters:
* - in: query
* name: page
* schema:
* type: integer
* description: The page number of the releases to fetch.
* - in: query
* name: per_page
* schema:
* type: integer
* description: The number of releases to fetch per page.
* responses:
* 200:
* description: Releases in RSS format.
* 500:
* description: Failed to generate RSS format.
*/
app.get("/releases/rss", authenticateJWT, async (req, res) => {
try {
const page = parseInt(req.query.page as string) || 1;
const per_page = Math.min(
parseInt(req.query.per_page as string) || 20,
100,
);
const cacheKey = `releases_rss_${page}_${per_page}`;
const releasesData = await fetchGitHubAPI<GitHubRelease[]>(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases?page=${page}&per_page=${per_page}`,
cacheKey,
);
const rssItems = releasesData.data.map((release) => ({
id: release.id,
title: release.name || release.tag_name,
description: release.body,
link: release.html_url,
pubDate: release.published_at,
version: release.tag_name,
isPrerelease: release.prerelease,
isDraft: release.draft,
assets: release.assets.map((asset) => ({
name: asset.name,
size: asset.size,
download_count: asset.download_count,
download_url: asset.browser_download_url,
})),
}));
const response = {
feed: {
title: `${REPO_NAME} Releases`,
description: `Latest releases from ${REPO_NAME} repository`,
link: `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases`,
updated: new Date().toISOString(),
},
items: rssItems,
total_count: rssItems.length,
cached: releasesData.cached,
cache_age: releasesData.cache_age,
};
res.json(response);
} catch (error) {
databaseLogger.error("Failed to generate RSS format", error, {
operation: "rss_releases",
});
res.status(500).json({
error: "Failed to generate RSS format",
details: error instanceof Error ? error.message : "Unknown error",
});
}
});
/**
* @openapi
* /encryption/status:
* get:
* summary: Get encryption status
* description: Returns the security status of the application.
* tags:
* - Encryption
* responses:
* 200:
* description: Security status.
* 500:
* description: Failed to get security status.
*/
app.get("/encryption/status", requireAdmin, async (req, res) => {
try {
const securityStatus = {
initialized: true,
system: { hasSecret: true, isValid: true },
activeSessions: {},
activeSessionCount: 0,
};
res.json({
security: securityStatus,
version: "v2-kek-dek",
});
} catch (error) {
apiLogger.error("Failed to get security status", error, {
operation: "security_status",
});
res.status(500).json({ error: "Failed to get security status" });
}
});
/**
* @openapi
* /encryption/initialize:
* post:
* summary: Initialize security system
* description: Initializes the security system for the application.
* tags:
* - Encryption
* responses:
* 200:
* description: Security system initialized successfully.
* 500:
* description: Failed to initialize security system.
*/
app.post("/encryption/initialize", requireAdmin, async (req, res) => {
try {
const authManager = AuthManager.getInstance();
const isValid = true;
if (!isValid) {
await authManager.initialize();
}
res.json({
success: true,
message: "Security system initialized successfully",
version: "v2-kek-dek",
note: "User data encryption will be set up when users log in",
});
} catch (error) {
apiLogger.error("Failed to initialize security system", error, {
operation: "security_init_api_failed",
});
res.status(500).json({ error: "Failed to initialize security system" });
}
});
/**
* @openapi
* /encryption/regenerate:
* post:
* summary: Regenerate JWT secret
* description: Regenerates the system JWT secret. This will invalidate all existing JWT tokens.
* tags:
* - Encryption
* responses:
* 200:
* description: System JWT secret regenerated.
* 500:
* description: Failed to regenerate JWT secret.
*/
app.post("/encryption/regenerate", requireAdmin, async (req, res) => {
try {
apiLogger.warn("System JWT secret regenerated via API", {
operation: "jwt_regenerate_api",
});
res.json({
success: true,
message: "System JWT secret regenerated",
warning:
"All existing JWT tokens are now invalid - users must re-authenticate",
note: "User data encryption keys are protected by passwords and cannot be regenerated",
});
} catch (error) {
apiLogger.error("Failed to regenerate JWT secret", error, {
operation: "jwt_regenerate_failed",
});
res.status(500).json({ error: "Failed to regenerate JWT secret" });
}
});
/**
* @openapi
* /encryption/regenerate-jwt:
* post:
* summary: Regenerate JWT secret
* description: Regenerates the JWT secret. This will invalidate all existing JWT tokens.
* tags:
* - Encryption
* responses:
* 200:
* description: New JWT secret generated.
* 500:
* description: Failed to regenerate JWT secret.
*/
app.post("/encryption/regenerate-jwt", requireAdmin, async (req, res) => {
try {
apiLogger.warn("JWT secret regenerated via API", {
operation: "jwt_secret_regenerate_api",
});
res.json({
success: true,
message: "New JWT secret generated",
warning:
"All existing JWT tokens are now invalid - users must re-authenticate",
});
} catch (error) {
apiLogger.error("Failed to regenerate JWT secret", error, {
operation: "jwt_secret_regenerate_failed",
});
res.status(500).json({ error: "Failed to regenerate JWT secret" });
}
});
/**
* @openapi
* /database/export:
* post:
* summary: Export user data
* description: Exports the user's data as a SQLite database file.
* tags:
* - Database
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* password:
* type: string
* responses:
* 200:
* description: User data exported successfully.
* 400:
* description: Password required for export.
* 401:
* description: Invalid password.
* 500:
* description: Failed to export user data.
*/
app.post("/database/export", authenticateJWT, async (req, res) => {
try {
const userId = (req as AuthenticatedRequest).userId;
const deviceInfo = parseUserAgent(req);
const userRepository = createCurrentUserRepository();
const user = await userRepository.findById(userId);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
const isOidcUser = !!user.isOidc;
if (!DataCrypto.getUserDataKey(userId)) {
if (isOidcUser) {
const oidcUnlocked = await authManager.authenticateOIDCUser(
userId,
deviceInfo.type,
);
if (!oidcUnlocked) {
return res.status(403).json({
error: "Failed to unlock user data with SSO credentials",
});
}
} else {
return res.status(403).json({
error: "User data is locked. Please log in again.",
});
}
}
apiLogger.info("Exporting user data as SQLite", {
operation: "user_data_sqlite_export_api",
userId,
});
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) {
throw new Error("User data not unlocked");
}
const tempDir = path.join(os.tmpdir(), "termix-exports");
try {
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
} catch (dirError) {
apiLogger.error("Failed to create temp directory", dirError, {
operation: "export_temp_dir_error",
tempDir,
});
throw new Error(`Failed to create temp directory: ${dirError.message}`, {
cause: dirError,
});
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const filename = `termix-export-${user[0].username}-${timestamp}.sqlite`;
const tempPath = path.join(tempDir, filename);
apiLogger.info("Creating export database", {
operation: "export_db_creation",
userId,
tempPath,
});
const exportDb = new Database(tempPath);
try {
exportDb.exec(`
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
is_oidc INTEGER NOT NULL DEFAULT 0,
oidc_identifier TEXT,
client_id TEXT,
client_secret TEXT,
issuer_url TEXT,
authorization_url TEXT,
token_url TEXT,
identifier_path TEXT,
name_path TEXT,
scopes TEXT DEFAULT 'openid email profile',
totp_secret TEXT,
totp_enabled INTEGER NOT NULL DEFAULT 0,
totp_backup_codes TEXT
);
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE ssh_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
connection_type TEXT NOT NULL DEFAULT 'ssh',
name TEXT,
ip TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT NOT NULL,
folder TEXT,
tags TEXT,
pin INTEGER NOT NULL DEFAULT 0,
auth_type TEXT NOT NULL,
force_keyboard_interactive TEXT,
password TEXT,
key TEXT,
key_password TEXT,
key_type TEXT,
sudo_password TEXT,
autostart_password TEXT,
autostart_key TEXT,
autostart_key_password TEXT,
credential_id INTEGER,
override_credential_username INTEGER,
enable_terminal INTEGER NOT NULL DEFAULT 1,
enable_tunnel INTEGER NOT NULL DEFAULT 1,
tunnel_connections TEXT,
jump_hosts TEXT,
enable_file_manager INTEGER NOT NULL DEFAULT 1,
enable_docker INTEGER NOT NULL DEFAULT 0,
show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1,
show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0,
show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0,
show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0,
show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0,
default_path TEXT,
stats_config TEXT,
docker_config TEXT,
terminal_config TEXT,
quick_actions TEXT,
notes TEXT,
use_socks5 INTEGER,
socks5_host TEXT,
socks5_port INTEGER,
socks5_username TEXT,
socks5_password TEXT,
socks5_proxy_chain TEXT,
domain TEXT,
security TEXT,
ignore_cert INTEGER NOT NULL DEFAULT 0,
guacamole_config TEXT,
mac_address TEXT,
port_knock_sequence TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE ssh_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
folder TEXT,
tags TEXT,
auth_type TEXT NOT NULL,
username TEXT,
password TEXT,
key TEXT,
private_key TEXT,
public_key TEXT,
key_password TEXT,
key_type TEXT,
detected_key_type TEXT,
usage_count INTEGER NOT NULL DEFAULT 0,
last_used TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE file_manager_recent (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE file_manager_pinned (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE file_manager_shortcuts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE transfer_recent (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
source_host_id INTEGER NOT NULL,
dest_host_id INTEGER NOT NULL,
dest_path TEXT NOT NULL,
dest_path_label TEXT NOT NULL,
last_used TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE dismissed_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
alert_id TEXT NOT NULL,
dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE ssh_credential_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
credential_id INTEGER NOT NULL,
host_id INTEGER NOT NULL,
user_id TEXT NOT NULL,
used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
const userRecord = user[0];
const insertUser = exportDb.prepare(`
INSERT INTO users (id, username, password_hash, is_admin, is_oidc, oidc_identifier, client_id, client_secret, issuer_url, authorization_url, token_url, identifier_path, name_path, scopes, totp_secret, totp_enabled, totp_backup_codes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
insertUser.run(
userRecord.id,
userRecord.username,
"[EXPORTED_USER_NO_PASSWORD]",
userRecord.isAdmin ? 1 : 0,
userRecord.isOidc ? 1 : 0,
userRecord.oidcIdentifier || null,
userRecord.clientId || null,
userRecord.clientSecret || null,
userRecord.issuerUrl || null,
userRecord.authorizationUrl || null,
userRecord.tokenUrl || null,
userRecord.identifierPath || null,
userRecord.namePath || null,
userRecord.scopes || null,
userRecord.totpSecret || null,
userRecord.totpEnabled ? 1 : 0,
userRecord.totpBackupCodes || null,
);
const sshHosts =
await createCurrentHostRepository().listDecryptedByUserId(userId);
const insertHost = exportDb.prepare(`
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, autostart_password, autostart_key, autostart_key_password, credential_id, override_credential_username, enable_terminal, enable_tunnel, tunnel_connections, jump_hosts, enable_file_manager, enable_docker, show_terminal_in_sidebar, show_file_manager_in_sidebar, show_tunnel_in_sidebar, show_docker_in_sidebar, show_server_stats_in_sidebar, default_path, stats_config, docker_config, terminal_config, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, domain, security, ignore_cert, guacamole_config, mac_address, port_knock_sequence, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const decrypted of sshHosts) {
insertHost.run(
decrypted.id,
decrypted.userId,
decrypted.connectionType || "ssh",
decrypted.name || null,
decrypted.ip,
decrypted.port,
decrypted.username,
decrypted.folder || null,
decrypted.tags || null,
decrypted.pin ? 1 : 0,
decrypted.authType,
decrypted.forceKeyboardInteractive || null,
decrypted.password || null,
decrypted.key || null,
decrypted.keyPassword || null,
decrypted.keyType || null,
decrypted.sudoPassword || null,
decrypted.autostartPassword || null,
decrypted.autostartKey || null,
decrypted.autostartKeyPassword || null,
decrypted.credentialId || null,
decrypted.overrideCredentialUsername ? 1 : 0,
decrypted.enableTerminal ? 1 : 0,
decrypted.enableTunnel ? 1 : 0,
decrypted.tunnelConnections || null,
decrypted.jumpHosts || null,
decrypted.enableFileManager ? 1 : 0,
decrypted.enableDocker ? 1 : 0,
decrypted.showTerminalInSidebar ? 1 : 0,
decrypted.showFileManagerInSidebar ? 1 : 0,
decrypted.showTunnelInSidebar ? 1 : 0,
decrypted.showDockerInSidebar ? 1 : 0,
decrypted.showServerStatsInSidebar ? 1 : 0,
decrypted.defaultPath || null,
decrypted.statsConfig || null,
decrypted.dockerConfig || null,
decrypted.terminalConfig || null,
decrypted.quickActions || null,
decrypted.notes || null,
decrypted.useSocks5 ? 1 : 0,
decrypted.socks5Host || null,
decrypted.socks5Port || null,
decrypted.socks5Username || null,
decrypted.socks5Password || null,
decrypted.socks5ProxyChain || null,
decrypted.domain || null,
decrypted.security || null,
decrypted.ignoreCert ? 1 : 0,
decrypted.guacamoleConfig || null,
decrypted.macAddress || null,
decrypted.portKnockSequence || null,
decrypted.createdAt,
decrypted.updatedAt,
);
}
const credentials =
await createCurrentCredentialRepository().listDecryptedByUserId(userId);
const insertCred = exportDb.prepare(`
INSERT INTO ssh_credentials (id, user_id, name, description, folder, tags, auth_type, username, password, key, private_key, public_key, key_password, key_type, detected_key_type, usage_count, last_used, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const decrypted of credentials) {
insertCred.run(
decrypted.id,
decrypted.userId,
decrypted.name,
decrypted.description || null,
decrypted.folder || null,
decrypted.tags || null,
decrypted.authType,
decrypted.username,
decrypted.password || null,
decrypted.key || null,
decrypted.privateKey || null,
decrypted.publicKey || null,
decrypted.keyPassword || null,
decrypted.keyType || null,
decrypted.detectedKeyType || null,
decrypted.usageCount || 0,
decrypted.lastUsed || null,
decrypted.createdAt,
decrypted.updatedAt,
);
}
const fileManagerRepository =
createCurrentFileManagerBookmarkRepository();
const [recentFiles, pinnedFiles, shortcuts] = await Promise.all([
fileManagerRepository.listRecentByUserId(userId),
fileManagerRepository.listPinnedByUserId(userId),
fileManagerRepository.listShortcutsByUserId(userId),
]);
const insertRecent = exportDb.prepare(`
INSERT INTO file_manager_recent (id, user_id, host_id, name, path, last_opened)
VALUES (?, ?, ?, ?, ?, ?)
`);
for (const item of recentFiles) {
insertRecent.run(
item.id,
item.userId,
item.hostId,
item.name,
item.path,
item.lastOpened,
);
}
const insertPinned = exportDb.prepare(`
INSERT INTO file_manager_pinned (id, user_id, host_id, name, path, pinned_at)
VALUES (?, ?, ?, ?, ?, ?)
`);
for (const item of pinnedFiles) {
insertPinned.run(
item.id,
item.userId,
item.hostId,
item.name,
item.path,
item.pinnedAt,
);
}
const insertShortcut = exportDb.prepare(`
INSERT INTO file_manager_shortcuts (id, user_id, host_id, name, path, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`);
for (const item of shortcuts) {
insertShortcut.run(
item.id,
item.userId,
item.hostId,
item.name,
item.path,
item.createdAt,
);
}
const dismissedAlertRepository = createCurrentDismissedAlertRepository();
const alerts = await dismissedAlertRepository.listByUserId(userId);
const insertAlert = exportDb.prepare(`
INSERT INTO dismissed_alerts (id, user_id, alert_id, dismissed_at)
VALUES (?, ?, ?, ?)
`);
for (const alert of alerts) {
insertAlert.run(
alert.id,
alert.userId,
alert.alertId,
alert.dismissedAt,
);
}
const sshCredentialUsageRepository =
createCurrentSshCredentialUsageRepository();
const usage = await sshCredentialUsageRepository.listByUserId(userId);
const insertUsage = exportDb.prepare(`
INSERT INTO ssh_credential_usage (id, credential_id, host_id, user_id, used_at)
VALUES (?, ?, ?, ?, ?)
`);
for (const item of usage) {
insertUsage.run(
item.id,
item.credentialId,
item.hostId,
item.userId,
item.usedAt,
);
}
writeSettingsToExportDatabase(exportDb, await getExportableSettings());
} finally {
exportDb.close();
}
res.setHeader("Content-Type", "application/x-sqlite3");
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
const fileStream = fs.createReadStream(tempPath);
fileStream.on("error", (streamError) => {
apiLogger.error("File stream error during export", streamError, {
operation: "export_file_stream_error",
userId,
tempPath,
});
if (!res.headersSent) {
res.status(500).json({
error: "Failed to stream export file",
details: streamError.message,
});
}
});
fileStream.on("end", () => {
apiLogger.success("User data exported as SQLite successfully", {
operation: "user_data_sqlite_export_success",
userId,
filename,
});
fs.unlink(tempPath, (err) => {
if (err) {
apiLogger.warn("Failed to clean up export file", {
operation: "export_cleanup_failed",
path: tempPath,
error: err.message,
});
}
});
});
fileStream.pipe(res);
} catch (error) {
apiLogger.error("User data SQLite export failed", error, {
operation: "user_data_sqlite_export_failed",
});
res.status(500).json({
error: "Failed to export user data",
details: error instanceof Error ? error.message : "Unknown error",
});
}
});
/**
* @openapi
* /database/import:
* post:
* summary: Import user data
* description: Imports user data from a SQLite database file.
* tags:
* - Database
* requestBody:
* required: true
* content:
* multipart/form-data:
* schema:
* type: object
* properties:
* file:
* type: string
* format: binary
* password:
* type: string
* responses:
* 200:
* description: Incremental import completed successfully.
* 400:
* description: No file uploaded or password required for import.
* 401:
* description: Invalid password.
* 500:
* description: Failed to import SQLite data.
*/
app.post(
"/database/import",
authenticateJWT,
upload.single("file"),
async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: "No file uploaded" });
}
const userId = (req as AuthenticatedRequest).userId;
const deviceInfo = parseUserAgent(req);
const userRepository = createCurrentUserRepository();
const userRecord = await userRepository.findById(userId);
if (!userRecord) {
return res.status(404).json({ error: "User not found" });
}
const isOidcUser = !!userRecord.isOidc;
if (!DataCrypto.getUserDataKey(userId)) {
if (isOidcUser) {
const oidcUnlocked = await authManager.authenticateOIDCUser(
userId,
deviceInfo.type,
);
if (!oidcUnlocked) {
return res.status(403).json({
error: "Failed to unlock user data with SSO credentials",
});
}
} else {
return res.status(403).json({
error: "User data is locked. Please log in again.",
});
}
}
apiLogger.info("Importing SQLite data", {
operation: "sqlite_import_api",
userId,
filename: req.file.originalname,
fileSize: req.file.size,
mimetype: req.file.mimetype,
});
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) {
throw new Error("User data not unlocked");
}
if (!fs.existsSync(req.file.path)) {
return res.status(400).json({
error: "Uploaded file not found",
details: "File was not properly uploaded",
});
}
const fileHeader = Buffer.alloc(16);
const fd = fs.openSync(req.file.path, "r");
fs.readSync(fd, fileHeader, 0, 16, 0);
fs.closeSync(fd);
const sqliteHeader = "SQLite format 3";
if (fileHeader.toString("utf8", 0, 15) !== sqliteHeader) {
return res.status(400).json({
error: "Invalid file format - not a SQLite database",
details: `Expected SQLite file, got file starting with: ${fileHeader.toString("utf8", 0, 15)}`,
});
}
let importDb;
try {
importDb = new Database(req.file.path, { readonly: true });
importDb
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
.all();
} catch (sqliteError) {
return res.status(400).json({
error: "Failed to open SQLite database",
details: sqliteError.message,
});
}
const result = {
success: false,
summary: {
sshHostsImported: 0,
sshCredentialsImported: 0,
fileManagerItemsImported: 0,
dismissedAlertsImported: 0,
credentialUsageImported: 0,
settingsImported: 0,
skippedItems: 0,
errors: [],
},
};
try {
await withCurrentSqliteForeignKeysDisabled(async () => {
try {
const importedHosts = importDb
.prepare("SELECT * FROM ssh_data")
.all();
for (const host of importedHosts) {
try {
const hostRepository = createCurrentHostRepository();
const exists = await hostRepository.existsForImportIdentity(
userId,
host.ip,
host.port,
host.username,
);
if (exists) {
result.summary.skippedItems++;
continue;
}
const hostData = {
userId: userId,
name: host.name,
ip: host.ip,
port: host.port,
username: host.username,
folder: host.folder,
tags: host.tags,
pin: Boolean(host.pin),
authType: host.auth_type,
forceKeyboardInteractive: host.force_keyboard_interactive,
password: host.password,
key: host.key,
keyPassword: host.key_password,
keyType: host.key_type,
sudoPassword: host.sudo_password,
autostartPassword: host.autostart_password,
autostartKey: host.autostart_key,
autostartKeyPassword: host.autostart_key_password,
credentialId: host.credential_id || null,
overrideCredentialUsername: Boolean(
host.override_credential_username,
),
enableTerminal: Boolean(host.enable_terminal),
enableTunnel: Boolean(host.enable_tunnel),
tunnelConnections: host.tunnel_connections,
jumpHosts: host.jump_hosts,
enableFileManager: Boolean(host.enable_file_manager),
enableDocker: Boolean(host.enable_docker),
showTerminalInSidebar: Boolean(host.show_terminal_in_sidebar),
showFileManagerInSidebar: Boolean(
host.show_file_manager_in_sidebar,
),
showTunnelInSidebar: Boolean(host.show_tunnel_in_sidebar),
showDockerInSidebar: Boolean(host.show_docker_in_sidebar),
showServerStatsInSidebar: Boolean(
host.show_server_stats_in_sidebar,
),
defaultPath: host.default_path,
statsConfig: host.stats_config,
terminalConfig: host.terminal_config,
quickActions: host.quick_actions,
notes: host.notes,
useSocks5: Boolean(host.use_socks5),
socks5Host: host.socks5_host,
socks5Port: host.socks5_port,
socks5Username: host.socks5_username,
socks5Password: host.socks5_password,
socks5ProxyChain: host.socks5_proxy_chain,
createdAt: host.created_at || new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await hostRepository.createEncryptedForUser(userId, hostData);
result.summary.sshHostsImported++;
} catch (hostError) {
result.summary.errors.push(
`SSH host import error: ${hostError.message}`,
);
}
}
} catch {
apiLogger.info("ssh_data table not found in import file, skipping");
}
try {
const importedCreds = importDb
.prepare("SELECT * FROM ssh_credentials")
.all();
for (const cred of importedCreds) {
try {
const credentialRepository =
createCurrentCredentialRepository();
const exists =
await credentialRepository.existsForImportIdentity(
userId,
cred.name,
cred.username,
);
if (exists) {
result.summary.skippedItems++;
continue;
}
const credData = {
userId: userId,
name: cred.name,
description: cred.description,
folder: cred.folder,
tags: cred.tags,
authType: cred.auth_type,
username: cred.username,
password: cred.password,
key: cred.key,
privateKey: cred.private_key,
publicKey: cred.public_key,
keyPassword: cred.key_password,
keyType: cred.key_type,
detectedKeyType: cred.detected_key_type,
usageCount: cred.usage_count || 0,
lastUsed: cred.last_used,
createdAt: cred.created_at || new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await credentialRepository.createEncryptedForUser(
userId,
credData,
);
result.summary.sshCredentialsImported++;
} catch (credError) {
result.summary.errors.push(
`SSH credential import error: ${credError.message}`,
);
}
}
} catch {
apiLogger.info(
"ssh_credentials table not found in import file, skipping",
);
}
const fileManagerTables = [
{
table: "file_manager_recent",
key: "fileManagerItemsImported",
},
{
table: "file_manager_pinned",
key: "fileManagerItemsImported",
},
{
table: "file_manager_shortcuts",
key: "fileManagerItemsImported",
},
];
const fileManagerRepository =
createCurrentFileManagerBookmarkRepository();
for (const { table, key } of fileManagerTables) {
try {
const importedItems = importDb
.prepare(`SELECT * FROM ${table}`)
.all();
for (const item of importedItems) {
try {
const bookmark = {
hostId: item.host_id,
name: item.name,
path: item.path,
};
const created =
table === "file_manager_recent"
? await fileManagerRepository.createRecentForImport(
userId,
bookmark,
item.last_opened,
)
: table === "file_manager_pinned"
? await fileManagerRepository.createPinnedForImport(
userId,
bookmark,
item.pinned_at,
)
: await fileManagerRepository.createShortcutForImport(
userId,
bookmark,
item.created_at,
);
if (created) {
result.summary[key]++;
} else {
result.summary.skippedItems++;
}
} catch (itemError) {
result.summary.errors.push(
`${table} import error: ${itemError.message}`,
);
}
}
} catch {
apiLogger.info(
`${table} table not found in import file, skipping`,
);
}
}
const dismissedAlertRepository =
createCurrentDismissedAlertRepository();
try {
const importedAlerts = importDb
.prepare("SELECT * FROM dismissed_alerts")
.all();
for (const alert of importedAlerts) {
try {
const created = await dismissedAlertRepository.createForImport(
userId,
alert.alert_id,
alert.dismissed_at,
);
if (created) {
result.summary.dismissedAlertsImported++;
} else {
result.summary.skippedItems++;
}
} catch (alertError) {
result.summary.errors.push(
`Dismissed alert import error: ${alertError.message}`,
);
}
}
} catch {
apiLogger.info(
"dismissed_alerts table not found in import file, skipping",
);
}
const targetUser = await userRepository.findById(userId);
if (targetUser?.isAdmin) {
try {
const importedSettings = readImportedSettings(importDb);
for (const setting of importedSettings) {
try {
await upsertImportedSetting(setting);
result.summary.settingsImported++;
} catch (settingError) {
result.summary.errors.push(
`Setting import error (${setting.key}): ${settingError.message}`,
);
}
}
} catch {
apiLogger.info(
"settings table not found in import file, skipping",
);
}
} else {
apiLogger.info(
"Settings import skipped - only admin users can import settings",
);
}
result.success = true;
try {
await DatabaseSaveTrigger.forceSave("database_import");
} catch (saveError) {
apiLogger.error(
"Failed to persist imported data to disk",
saveError,
{
operation: "import_force_save_failed",
userId,
},
);
}
});
} finally {
if (importDb) {
importDb.close();
}
}
try {
fs.unlinkSync(req.file.path);
} catch {
apiLogger.warn("Failed to clean up uploaded file", {
operation: "file_cleanup_warning",
filePath: req.file.path,
});
}
res.json({
success: result.success,
message: result.success
? "Incremental import completed successfully"
: "Import failed",
summary: result.summary,
});
if (result.success) {
apiLogger.success("SQLite data imported successfully", {
operation: "sqlite_import_api_success",
userId,
summary: result.summary,
});
}
} catch (error) {
if (req.file?.path && fs.existsSync(req.file.path)) {
try {
fs.unlinkSync(req.file.path);
} catch {
apiLogger.warn("Failed to clean up uploaded file after error", {
operation: "file_cleanup_error",
filePath: req.file.path,
});
}
}
apiLogger.error("SQLite import failed", error, {
operation: "sqlite_import_api_failed",
userId: (req as AuthenticatedRequest).userId,
});
res.status(500).json({
error: "Failed to import SQLite data",
details: error instanceof Error ? error.message : "Unknown error",
});
}
},
);
/**
* @openapi
* /database/export/preview:
* post:
* summary: Preview user data export
* description: Generates a preview of the user data export, including statistics about the data.
* tags:
* - Database
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* scope:
* type: string
* includeCredentials:
* type: boolean
* responses:
* 200:
* description: Export preview generated successfully.
* 500:
* description: Failed to generate export preview.
*/
app.post("/database/export/preview", authenticateJWT, async (req, res) => {
try {
const userId = (req as AuthenticatedRequest).userId;
const { scope = "user_data", includeCredentials = true } = req.body;
const exportData = await UserDataExport.exportUserData(userId, {
format: "encrypted",
scope,
includeCredentials,
});
const stats = UserDataExport.getExportStats(exportData);
res.json({
preview: true,
stats,
estimatedSize: JSON.stringify(exportData).length,
});
apiLogger.success("Export preview generated", {
operation: "export_preview_api_success",
userId,
totalRecords: stats.totalRecords,
});
} catch (error) {
apiLogger.error("Export preview failed", error, {
operation: "export_preview_api_failed",
});
res.status(500).json({
error: "Failed to generate export preview",
details: error instanceof Error ? error.message : "Unknown error",
});
}
});
/**
* @openapi
* /database/restore:
* post:
* summary: Restore database from backup
* description: Restores the database from an encrypted backup file.
* tags:
* - Database
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* backupPath:
* type: string
* targetPath:
* type: string
* responses:
* 200:
* description: Database restored successfully.
* 400:
* description: Backup path is required or invalid encrypted backup file.
* 500:
* description: Database restore failed.
*/
app.post("/database/restore", requireAdmin, async (req, res) => {
try {
const { backupPath, targetPath } = req.body;
if (!backupPath) {
return res.status(400).json({ error: "Backup path is required" });
}
if (!DatabaseFileEncryption.isEncryptedDatabaseFile(backupPath)) {
return res.status(400).json({ error: "Invalid encrypted backup file" });
}
const restoredPath =
await DatabaseFileEncryption.restoreFromEncryptedBackup(
backupPath,
targetPath,
);
res.json({
success: true,
message: "Database restored successfully",
restoredPath,
});
} catch (error) {
apiLogger.error("Database restore failed", error, {
operation: "database_restore_api_failed",
});
res.status(500).json({
error: "Database restore failed",
details: error instanceof Error ? error.message : "Unknown error",
});
}
});
app.use("/users", userRoutes);
app.use("/host", hostRoutes);
app.use("/alerts", alertRoutes);
app.use("/credentials", credentialsRoutes);
app.use("/snippets", snippetsRoutes);
app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
app.use("/terminal", terminalRoutes);
app.use("/session_logs", sessionLogRoutes);
app.use("/guacamole", guacamoleRoutes);
app.use("/network-topology", networkTopologyRoutes);
app.use("/rbac", rbacRoutes);
app.use("/open-tabs", openTabsRoutes);
app.use("/user-preferences", userPreferencesRoutes);
app.use("/proxmox", proxmoxRoutes);
app.use("/termix-id", termixIdRoutes);
registerAuditLogRoutes(app, authenticateJWT);
registerTailscaleRoutes(app, authenticateJWT);
app.use("/vault", vaultRoutes);
app.use("/", alertRulesRoutes);
const frontendDistPaths = [
path.join(__dirname, "../../../dist"),
path.join(__dirname, "../../dist"),
path.join(process.cwd(), "dist"),
];
const frontendDist = frontendDistPaths.find((p) =>
fs.existsSync(path.join(p, "index.html")),
);
if (frontendDist) {
databaseLogger.info(`Serving frontend from: ${frontendDist}`, {
operation: "static_files",
});
app.use(
express.static(frontendDist, {
setHeaders: (res, filePath) => {
const relativePath = path
.relative(frontendDist, filePath)
.replaceAll(path.sep, "/");
if (relativePath.startsWith("assets/")) {
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
return;
}
if (
relativePath === "index.html" ||
relativePath === "sw.js" ||
relativePath === "manifest.json"
) {
res.setHeader(
"Cache-Control",
"no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0",
);
}
},
}),
);
app.use((req, res, next) => {
if (
req.method === "GET" &&
req.accepts("html") &&
!req.headers.authorization
) {
res.setHeader(
"Cache-Control",
"no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0",
);
res.sendFile(path.join(frontendDist, "index.html"));
} else {
next();
}
});
}
app.use(
(
err: unknown,
req: express.Request,
res: express.Response,
_next: express.NextFunction,
) => {
void _next;
apiLogger.error("Unhandled error in request", err, {
operation: "error_handler",
method: req.method,
url: req.url,
userAgent: req.get("User-Agent"),
});
res.status(500).json({ error: "Internal Server Error" });
},
);
const HTTP_PORT = 30001;
async function initializeSecurity() {
try {
const authManager = AuthManager.getInstance();
await authManager.initialize();
DataCrypto.initialize();
const isValid = true;
if (!isValid) {
throw new Error("Security system validation failed");
}
} catch (error) {
databaseLogger.error("Failed to initialize security system", error, {
operation: "security_init_error",
});
throw error;
}
}
/**
* @openapi
* /database/migration/status:
* get:
* summary: Get database migration status
* description: Returns the status of the database migration.
* tags:
* - Database
* responses:
* 200:
* description: Migration status.
* 500:
* description: Failed to get migration status.
*/
app.get(
"/database/migration/status",
authenticateJWT,
requireAdmin,
async (req, res) => {
try {
const dataDir = process.env.DATA_DIR || "./db/data";
const migration = new DatabaseMigration(dataDir);
const status = migration.checkMigrationStatus();
const dbPath = path.join(dataDir, "db.sqlite");
const encryptedDbPath = `${dbPath}.encrypted`;
const files = fs.readdirSync(dataDir);
const backupFiles = files.filter((f) => f.includes(".migration-backup-"));
const migratedFiles = files.filter((f) => f.includes(".migrated-"));
let unencryptedSize = 0;
let encryptedSize = 0;
if (status.hasUnencryptedDb) {
try {
unencryptedSize = fs.statSync(dbPath).size;
} catch {
// expected - file may not exist
}
}
if (status.hasEncryptedDb) {
try {
encryptedSize = fs.statSync(encryptedDbPath).size;
} catch {
// expected - file may not exist
}
}
res.json({
migrationStatus: status,
repositoryRollout: getRepositoryRolloutStatus(),
files: {
unencryptedDbSize: unencryptedSize,
encryptedDbSize: encryptedSize,
backupFiles: backupFiles.length,
migratedFiles: migratedFiles.length,
},
});
} catch (error) {
apiLogger.error("Failed to get migration status", error, {
operation: "migration_status_api_failed",
});
res.status(500).json({
error: "Failed to get migration status",
details: error instanceof Error ? error.message : "Unknown error",
});
}
},
);
/**
* @openapi
* /database/migration/history:
* get:
* summary: Get database migration history
* description: Returns the history of database migrations.
* tags:
* - Database
* responses:
* 200:
* description: Migration history.
* 500:
* description: Failed to get migration history.
*/
app.get(
"/database/migration/history",
authenticateJWT,
requireAdmin,
async (req, res) => {
try {
const dataDir = process.env.DATA_DIR || "./db/data";
const files = fs.readdirSync(dataDir);
const backupFiles = files
.filter((f) => f.includes(".migration-backup-"))
.map((f) => {
const filePath = path.join(dataDir, f);
const stats = fs.statSync(filePath);
return {
name: f,
size: stats.size,
created: stats.birthtime,
modified: stats.mtime,
type: "backup",
};
})
.sort((a, b) => b.modified.getTime() - a.modified.getTime());
const migratedFiles = files
.filter((f) => f.includes(".migrated-"))
.map((f) => {
const filePath = path.join(dataDir, f);
const stats = fs.statSync(filePath);
return {
name: f,
size: stats.size,
created: stats.birthtime,
modified: stats.mtime,
type: "migrated",
};
})
.sort((a, b) => b.modified.getTime() - a.modified.getTime());
res.json({
files: [...backupFiles, ...migratedFiles],
summary: {
totalBackups: backupFiles.length,
totalMigrated: migratedFiles.length,
oldestBackup:
backupFiles.length > 0
? backupFiles[backupFiles.length - 1].created
: null,
newestBackup: backupFiles.length > 0 ? backupFiles[0].created : null,
},
});
} catch (error) {
apiLogger.error("Failed to get migration history", error, {
operation: "migration_history_api_failed",
});
res.status(500).json({
error: "Failed to get migration history",
details: error instanceof Error ? error.message : "Unknown error",
});
}
},
);
const httpServer = http.createServer(app);
httpServer.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
databaseLogger.error(
`Port ${HTTP_PORT} is already in use. Kill the existing process and retry.`,
err,
{
operation: "http_server_port_conflict",
port: HTTP_PORT,
},
);
process.exit(1);
}
throw err;
});
export const serverReady = new Promise<void>((resolve) => {
httpServer.listen(HTTP_PORT, async () => {
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
await initializeSecurity();
resolve();
});
});
const sslConfig = AutoSSLSetup.getSSLConfig();
if (sslConfig.enabled) {
databaseLogger.info(`SSL is enabled`, {
operation: "ssl_info",
nginx_https_port: sslConfig.port,
backend_http_port: HTTP_PORT,
});
}