mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 05:13:36 +00:00
v2.2.0 (#738)
* Improve Docker container list UI * Rework SSH tunnel forwarding * Update macOS Electron packaging * Optimize frontend bundle splitting * Add beta version update status * Add client tunnel preset management * Secure cookie authentication flows * Add client tunnel bridge support * Preserve sessions on restart * Update runtime to Node 24 * Add client remote tunnel support * Fix stale frontend cache handling * Fix Docker image platforms for Node 24 * Fix Electron packaging workflows * Fix client auth cache after upgrades * chore: cleanup files * fix: npm i error * Fix OIDC auth cookie readiness * Fix Docker npm ci config * Add react-is peer dependency * Fix Electron auth and cache handling * Improve terminal clipboard and refresh actions * feat: add API keys * feat: improve lazy loading with loading spinners * feat: Introduce FolderTree component with lazy-loading and motion animations for improved file manager UX (#735) * feat: integrate FolderTree component with lazy-loading for file manager sidebar - Add motion animation library (v12.38.0) for smooth UI transitions - Create new FolderTree component with advanced keyboard navigation support - Refactor kbd component: introduce KbdKey and KbdSeparator subcomponents - Implement lazy-loading strategy for directory tree in FileManagerSidebar - Refactor FileManagerSidebar with improved code organization and better separation of concerns - Update keyboard shortcut displays across CommandPalette, FileViewer, and Dashboard - Change React/ReactDOM dependency flags from dev to devOptional in package-lock.json BREAKING CHANGE: KbdGroup component has been replaced. Use <Kbd><KbdKey>...</KbdKey><KbdSeparator /></Kbd> instead. - Improves UX with smooth animations and better folder navigation - Reduces initial load time through lazy-loading subdirectories - Enhances accessibility with ARIA labels and keyboard navigation - Maintains dark mode support and proper styling * fix: incorrect use of the theme system and linked file manger sidebar with current folder --------- Co-authored-by: suryacagur <suryacagur.dev@gmail.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Enhance VNC token generation to include optional username parameter and refactor username input handling in HostGeneralTab (#733) * Fix Docker build info generation * Remove unused node-fetch dependency * feat: prompt user for SSH key passphrase on use (#715) When an encrypted SSH key has no stored passphrase, show a lightweight dialog prompting the user to enter it at connection time instead of failing with a parse error. Supports both desktop and mobile terminals. Closes Termix-SSH/Support#354 * fix: prevent session crash when uploading to permission-denied directory (#716) - Wrap writeFile sftp.stat callback in try-catch to prevent uncaught exceptions from escaping the callback into the event loop - Add missing stream.stderr error handler in writeFile fallback to prevent unhandled error events from crashing the process - Remove bogus activeOperations decrement in both writeFile and uploadFile fallback methods (counter was never incremented) - Add res.headersSent checks in fallback disconnect paths to prevent ERR_HTTP_HEADERS_SENT crashes Closes Termix-SSH/Support#652 * feat: add LOG_TIMESTAMP_FORMAT env var for 24h/ISO log timestamps (#718) Support LOG_TIMESTAMP_FORMAT environment variable with values: - "24h": 24-hour format (14:58:45) - "iso": ISO 8601 format (2026-04-25T14:58:45.000Z) - default: locale format (2:58:45 PM) Closes Termix-SSH/Support#650 * feat: open file manager at terminal current working directory (#719) * feat: open file manager at terminal current working directory When right-clicking in the terminal and selecting "Open File Manager Here", query the current working directory via a separate SSH exec channel and pass it as the initial path to the file manager tab. Closes Termix-SSH/Support#649 * chore: sync package-lock.json with node-fetch and deps Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: remove undefined TerminalContextMenu from bad merge resolution Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: show reconnect overlay when SSH server reboots (#720) When the remote server reboots, the SSH connection closes while the stream is still active. The close handler only sent the "disconnected" message when sshStream was null, so the frontend never received the disconnect notification and hung with a blinking cursor. Change the else-if condition to always send the "disconnected" message regardless of stream state. Closes Termix-SSH/Support#648 * feat: support read-only Docker container mode (#721) Move nginx runtime files (config, pid, logs, temp dirs) from /app/nginx/ to /tmp/nginx/ so the container can run with read_only: true. Template files remain in /app/nginx/ as read-only assets. Users can now harden the container with: read_only: true tmpfs: - /tmp Closes Termix-SSH/Support#647 * fix: allow editing host folder without re-entering password (#722) When editing an existing host, the password field is stripped by the backend for security. The form validation treated the empty password as invalid, disabling the Update Host button even for non-auth changes like folder assignment. Use an "existing_password" sentinel (mirroring the existing "existing_key" pattern) to represent an unchanged password during editing, skip validation for it, and omit it from the update payload. Closes Termix-SSH/Support#645 * fix: auto-close tab on graceful SSH disconnect (exit/Ctrl+D) (#723) Distinguish between graceful shell exit and unexpected disconnection using the stream close event's exit code. When the shell exits normally (code != null), send "session_ended" instead of "disconnected". The frontend auto-closes the tab on session_ended, and shows the reconnect overlay only on unexpected disconnections. Closes Termix-SSH/Support#643 * fix: reattach existing SSH session on WebSocket reconnect (#724) WebSocket reconnection was always creating a new SSH connection with full authentication instead of reattaching to the existing SSH session. The condition `!isReconnectingRef.current` prevented session reattach during reconnection, causing repeated password auth attempts that trigger SSHGuard/fail2ban blocking. Remove the guard so reconnection tries to reattach the persisted session first. If the session has expired, the backend sends sessionExpired and the frontend falls back to a new connection. Closes Termix-SSH/Support#644 * fix: prevent browser crash when uploading large files (>100MB) (#725) The file-to-base64 conversion used a byte-by-byte string concatenation loop (String.fromCharCode + btoa), which allocated ~3x the file size in intermediate strings, causing the browser tab to OOM on files over ~100MB. Replace with FileReader.readAsDataURL which delegates base64 encoding to the browser engine natively, avoiding the intermediate allocations. Closes Termix-SSH/Support#577 * fix: support SSH multi-factor auth with publickey + password (#726) When sshd requires AuthenticationMethods publickey,password, the connection failed because the key auth branch only set privateKey without also setting password. After publickey partial auth succeeded, ssh2 sent keyboard-interactive (due to tryKeyboard:true) instead of password, which the server rejected. Pass the credential password alongside the private key so ssh2 can complete the password step after publickey succeeds. Closes Termix-SSH/Support#629 * feat(oidc): add OIDC_ALLOW_REGISTRATION env to bypass allow_registration for OIDC (#727) The `allow_registration` setting blocks both password-based and OIDC user creation. Admins who want to close password registration but still onboard new users via a trusted IdP (with the existing `OIDC_ALLOWED_USERS` whitelist) have no way to do that today. Introduce an `OIDC_ALLOW_REGISTRATION` env var. When set to `true`, the OIDC callback skips the `allow_registration` settings check while still honoring the `OIDC_ALLOWED_USERS` whitelist. Password registration via `POST /users/create` continues to respect `allow_registration`. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * perf: lazy load locales, file previews, and decouple startup imports (#729) * perf: lazy load locale bundles * perf: lazy load file preview modules * perf: avoid eager api client load on startup * chore: remove dead code, tighten types, fix lint warnings (#730) * chore: clean up low-risk lint warnings * chore: tighten utility types * chore: preserve backend error causes * chore: simplify command palette host state * chore: remove unused frontend code * chore: prune stale frontend state * chore: trim unused navigation code * chore: prune unused user settings props * chore: trim unused sidebar state * chore: remove stale host editor imports * chore: tighten shared frontend types * chore: narrow desktop helper types * chore: type network topology data * chore: type connection log errors * chore: use typed tab context * chore: type api client error metadata * chore: tighten terminal config types * chore: type host proxy chains * chore: type host editor form data * chore: use typed host viewer fields * chore: format app builder patch script * Fix client auth cache after upgrades * chore: fix pr checks after dev merge * fix: remove duplicate session-expired useEffect in FullScreenAppWrapper Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Xenthys <x@dis.gg> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: npm package warnings * feat: reconnect after file manager disconnects * feat: add docs button in api keys * feat: change colors for server tunnels * fix: fetch password from API for Copy Password button (#736) * chore: update readme's * feat: improve c2s UI in user profile * feat: improve ssh key detection and move open file manager at path for terminal button * fix: restore missing getHostPassword import in Tab.tsx (#737) * fix: security related fixes * feat: improve alert code * Fix Electron clipboard handling * fix: untranslated alert text --------- Co-authored-by: Xenthys <x@dis.gg> Co-authored-by: PT Kelana Tech Solutions <ptkelanatechsolutions@gmail.com> Co-authored-by: suryacagur <suryacagur.dev@gmail.com> Co-authored-by: zimmra <28514085+zimmra@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Fuad <funtik1229@yandex.ru>
This commit is contained in:
@@ -8,12 +8,12 @@ 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 guacamoleRoutes from "../guacamole/routes.js";
|
||||
import networkTopologyRoutes from "./routes/network-topology.js";
|
||||
import rbacRoutes from "./routes/rbac.js";
|
||||
import { createCorsMiddleware } from "../utils/cors-config.js";
|
||||
import fetch from "node-fetch";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
@@ -118,6 +118,31 @@ class GitHubCache {
|
||||
|
||||
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";
|
||||
@@ -143,7 +168,7 @@ async function fetchGitHubAPI<T>(
|
||||
"User-Agent": "TermixUpdateChecker/1.0",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
agent: getProxyAgent(url),
|
||||
dispatcher: getProxyAgent(url),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -299,12 +324,19 @@ app.get("/version", authenticateJWT, async (req, res) => {
|
||||
return res.status(401).send("Remote Version Not Found");
|
||||
}
|
||||
|
||||
const isUpToDate = localVersion === remoteVersion;
|
||||
const versionComparison = compareSemver(localVersion, remoteVersion);
|
||||
const status =
|
||||
versionComparison === null || versionComparison === 0
|
||||
? "up_to_date"
|
||||
: versionComparison > 0
|
||||
? "beta"
|
||||
: "requires_update";
|
||||
|
||||
const response = {
|
||||
status: isUpToDate ? "up_to_date" : "requires_update",
|
||||
status,
|
||||
localVersion: localVersion,
|
||||
version: remoteVersion,
|
||||
remoteVersion: remoteVersion,
|
||||
latest_release: {
|
||||
tag_name: releaseData.data.tag_name,
|
||||
name: releaseData.data.name,
|
||||
@@ -624,7 +656,9 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
operation: "export_temp_dir_error",
|
||||
tempDir,
|
||||
});
|
||||
throw new Error(`Failed to create temp directory: ${dirError.message}`);
|
||||
throw new Error(`Failed to create temp directory: ${dirError.message}`, {
|
||||
cause: dirError,
|
||||
});
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
@@ -1161,7 +1195,7 @@ app.post(
|
||||
mimetype: req.file.mimetype,
|
||||
});
|
||||
|
||||
let userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) {
|
||||
throw new Error("User data not unlocked");
|
||||
}
|
||||
@@ -1719,6 +1753,7 @@ 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("/guacamole", guacamoleRoutes);
|
||||
app.use("/network-topology", networkTopologyRoutes);
|
||||
@@ -1738,10 +1773,38 @@ if (frontendDist) {
|
||||
databaseLogger.info(`Serving frontend from: ${frontendDist}`, {
|
||||
operation: "static_files",
|
||||
});
|
||||
app.use(express.static(frontendDist));
|
||||
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")) {
|
||||
res.setHeader(
|
||||
"Cache-Control",
|
||||
"no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0",
|
||||
);
|
||||
res.sendFile(path.join(frontendDist, "index.html"));
|
||||
} else {
|
||||
next();
|
||||
@@ -1750,13 +1813,13 @@ if (frontendDist) {
|
||||
}
|
||||
|
||||
app.use(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
(
|
||||
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,
|
||||
|
||||
@@ -118,6 +118,7 @@ async function initializeDatabaseAsync(): Promise<void> {
|
||||
|
||||
throw new Error(
|
||||
`Database decryption failed: ${error instanceof Error ? error.message : "Unknown error"}. This prevents data loss.`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -317,6 +318,18 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS c2s_tunnel_presets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
config TEXT NOT NULL,
|
||||
platform TEXT,
|
||||
computer_name TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -424,10 +437,31 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
token_prefix TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT,
|
||||
last_used_at TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
`);
|
||||
|
||||
try {
|
||||
sqlite.prepare("DELETE FROM sessions").run();
|
||||
const result = sqlite
|
||||
.prepare("DELETE FROM sessions WHERE expires_at <= ?")
|
||||
.run(new Date().toISOString());
|
||||
if (result.changes > 0) {
|
||||
databaseLogger.info("Expired sessions cleaned up on startup", {
|
||||
operation: "db_init_session_cleanup",
|
||||
deletedSessions: result.changes,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not clear expired sessions on startup", {
|
||||
operation: "db_init_session_cleanup_failed",
|
||||
@@ -803,6 +837,31 @@ const migrateSchema = () => {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM c2s_tunnel_presets LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS c2s_tunnel_presets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
config TEXT NOT NULL,
|
||||
platform TEXT,
|
||||
computer_name TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create c2s_tunnel_presets table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite
|
||||
.prepare("SELECT id FROM sessions LIMIT 1")
|
||||
@@ -1175,6 +1234,32 @@ const migrateSchema = () => {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM api_keys LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
token_prefix TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT,
|
||||
last_used_at TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create api_keys table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const existingRoles = sqlite.prepare("SELECT name, is_system FROM roles").all() as Array<{ name: string; is_system: number }>;
|
||||
|
||||
|
||||
@@ -298,6 +298,23 @@ export const snippetFolders = sqliteTable("snippet_folders", {
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const c2sTunnelPresets = sqliteTable("c2s_tunnel_presets", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
config: text("config").notNull(),
|
||||
platform: text("platform"),
|
||||
computerName: text("computer_name"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const snippetAccess = sqliteTable("snippet_access", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
snippetId: integer("snippet_id")
|
||||
@@ -572,3 +589,17 @@ export const opksshTokens = sqliteTable("opkssh_tokens", {
|
||||
expiresAt: text("expires_at").notNull(),
|
||||
lastUsed: text("last_used"),
|
||||
});
|
||||
|
||||
export const apiKeys = sqliteTable("api_keys", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
tokenHash: text("token_hash").notNull(),
|
||||
tokenPrefix: text("token_prefix").notNull(),
|
||||
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
expiresAt: text("expires_at"),
|
||||
lastUsedAt: text("last_used_at"),
|
||||
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ import express from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import { dismissedAlerts } from "../db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import fetch from "node-fetch";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { getProxyAgent } from "../../utils/proxy-agent.js";
|
||||
@@ -61,7 +60,7 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
|
||||
Accept: "application/json",
|
||||
"User-Agent": "TermixAlertChecker/1.0",
|
||||
},
|
||||
agent: getProxyAgent(url),
|
||||
dispatcher: getProxyAgent(url),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import type {
|
||||
AuthenticatedRequest,
|
||||
TunnelConnection,
|
||||
} from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import { c2sTunnelPresets } from "../db/schema.js";
|
||||
import { and, asc, eq, sql } from "drizzle-orm";
|
||||
import type { Request, Response } from "express";
|
||||
import { authLogger, databaseLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
}
|
||||
|
||||
function parsePreset(row: typeof c2sTunnelPresets.$inferSelect) {
|
||||
return {
|
||||
...row,
|
||||
config: JSON.parse(row.config) as TunnelConnection[],
|
||||
};
|
||||
}
|
||||
|
||||
function validateConfig(config: unknown): config is TunnelConnection[] {
|
||||
if (!Array.isArray(config)) return false;
|
||||
return config.every((item) => {
|
||||
if (!item || typeof item !== "object") return false;
|
||||
const tunnel = item as Partial<TunnelConnection>;
|
||||
const mode = tunnel.mode || tunnel.tunnelType;
|
||||
return (
|
||||
tunnel.scope === "c2s" &&
|
||||
(mode === "local" || mode === "remote" || mode === "dynamic") &&
|
||||
typeof tunnel.sourcePort === "number" &&
|
||||
tunnel.sourcePort >= 1 &&
|
||||
tunnel.sourcePort <= 65535 &&
|
||||
(mode === "dynamic" ||
|
||||
(typeof tunnel.endpointPort === "number" &&
|
||||
tunnel.endpointPort >= 1 &&
|
||||
tunnel.endpointPort <= 65535))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
router.get(
|
||||
"/",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (!isNonEmptyString(userId)) {
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await db
|
||||
.select()
|
||||
.from(c2sTunnelPresets)
|
||||
.where(eq(c2sTunnelPresets.userId, userId))
|
||||
.orderBy(asc(c2sTunnelPresets.name));
|
||||
res.json(result.map(parsePreset));
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to fetch C2S tunnel presets", error);
|
||||
res.status(500).json({ error: "Failed to fetch C2S tunnel presets" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { name, config, platform, computerName } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !isNonEmptyString(name)) {
|
||||
return res.status(400).json({ error: "Preset name is required" });
|
||||
}
|
||||
if (!validateConfig(config)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Invalid C2S tunnel configuration" });
|
||||
}
|
||||
|
||||
const trimmedName = name.trim();
|
||||
try {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(c2sTunnelPresets)
|
||||
.where(
|
||||
and(
|
||||
eq(c2sTunnelPresets.userId, userId),
|
||||
eq(c2sTunnelPresets.name, trimmedName),
|
||||
),
|
||||
);
|
||||
if (existing.length > 0) {
|
||||
return res.status(409).json({ error: "Preset name already exists" });
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.insert(c2sTunnelPresets)
|
||||
.values({
|
||||
userId,
|
||||
name: trimmedName,
|
||||
config: JSON.stringify(config),
|
||||
platform: platform?.trim() || null,
|
||||
computerName: computerName?.trim() || null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
databaseLogger.info("C2S tunnel preset created", {
|
||||
operation: "c2s_tunnel_preset_create",
|
||||
userId,
|
||||
presetId: result[0].id,
|
||||
});
|
||||
res.status(201).json(parsePreset(result[0]));
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to create C2S tunnel preset", error);
|
||||
res.status(500).json({ error: "Failed to create C2S tunnel preset" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.put(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = Number(req.params.id);
|
||||
const { name, config, platform, computerName } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !Number.isInteger(id)) {
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(c2sTunnelPresets)
|
||||
.where(
|
||||
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
|
||||
);
|
||||
if (existing.length === 0) {
|
||||
return res.status(404).json({ error: "Preset not found" });
|
||||
}
|
||||
|
||||
const updateFields: Record<string, unknown> = {
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
};
|
||||
|
||||
if (name !== undefined) {
|
||||
if (!isNonEmptyString(name)) {
|
||||
return res.status(400).json({ error: "Preset name is required" });
|
||||
}
|
||||
const trimmedName = name.trim();
|
||||
const duplicate = await db
|
||||
.select()
|
||||
.from(c2sTunnelPresets)
|
||||
.where(
|
||||
and(
|
||||
eq(c2sTunnelPresets.userId, userId),
|
||||
eq(c2sTunnelPresets.name, trimmedName),
|
||||
),
|
||||
);
|
||||
if (duplicate.some((preset) => preset.id !== id)) {
|
||||
return res.status(409).json({ error: "Preset name already exists" });
|
||||
}
|
||||
updateFields.name = trimmedName;
|
||||
}
|
||||
|
||||
if (config !== undefined) {
|
||||
if (!validateConfig(config)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Invalid C2S tunnel configuration" });
|
||||
}
|
||||
updateFields.config = JSON.stringify(config);
|
||||
}
|
||||
if (platform !== undefined)
|
||||
updateFields.platform = platform?.trim() || null;
|
||||
if (computerName !== undefined)
|
||||
updateFields.computerName = computerName?.trim() || null;
|
||||
|
||||
await db
|
||||
.update(c2sTunnelPresets)
|
||||
.set(updateFields)
|
||||
.where(
|
||||
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
|
||||
);
|
||||
|
||||
const updated = await db
|
||||
.select()
|
||||
.from(c2sTunnelPresets)
|
||||
.where(eq(c2sTunnelPresets.id, id));
|
||||
res.json(parsePreset(updated[0]));
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to update C2S tunnel preset", error);
|
||||
res.status(500).json({ error: "Failed to update C2S tunnel preset" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = Number(req.params.id);
|
||||
|
||||
if (!isNonEmptyString(userId) || !Number.isInteger(id)) {
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(c2sTunnelPresets)
|
||||
.where(
|
||||
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
|
||||
);
|
||||
if (existing.length === 0) {
|
||||
return res.status(404).json({ error: "Preset not found" });
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(c2sTunnelPresets)
|
||||
.where(
|
||||
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to delete C2S tunnel preset", error);
|
||||
res.status(500).json({ error: "Failed to delete C2S tunnel preset" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
inArray,
|
||||
} from "drizzle-orm";
|
||||
import type { Request, Response } from "express";
|
||||
import axios from "axios";
|
||||
import multer from "multer";
|
||||
import { sshLogger, databaseLogger } from "../../utils/logger.js";
|
||||
import { SimpleDBOps } from "../../utils/simple-db-ops.js";
|
||||
@@ -42,6 +43,32 @@ const router = express.Router();
|
||||
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
|
||||
function notifyStatsHostUpdated(
|
||||
hostId: number,
|
||||
headers: Pick<Request["headers"], "authorization" | "cookie">,
|
||||
operation: string,
|
||||
): void {
|
||||
axios
|
||||
.post(
|
||||
"http://localhost:30005/host-updated",
|
||||
{ hostId },
|
||||
{
|
||||
headers: {
|
||||
Authorization: headers.authorization || "",
|
||||
Cookie: headers.cookie || "",
|
||||
},
|
||||
timeout: 5000,
|
||||
},
|
||||
)
|
||||
.catch((err) => {
|
||||
sshLogger.warn("Failed to notify stats server of host update", {
|
||||
operation,
|
||||
hostId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
@@ -581,29 +608,12 @@ router.post(
|
||||
name,
|
||||
});
|
||||
|
||||
try {
|
||||
const axios = (await import("axios")).default;
|
||||
const statsPort = 30005;
|
||||
await axios.post(
|
||||
`http://localhost:${statsPort}/host-updated`,
|
||||
{ hostId: createdHost.id },
|
||||
{
|
||||
headers: {
|
||||
Authorization: req.headers.authorization || "",
|
||||
Cookie: req.headers.cookie || "",
|
||||
},
|
||||
timeout: 5000,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
sshLogger.warn("Failed to notify stats server of new host", {
|
||||
operation: "host_create",
|
||||
hostId: createdHost.id as number,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
res.json(resolvedHost);
|
||||
notifyStatsHostUpdated(
|
||||
createdHost.id as number,
|
||||
req.headers,
|
||||
"host_create",
|
||||
);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to save SSH host to database", err, {
|
||||
operation: "host_create",
|
||||
@@ -1189,29 +1199,8 @@ router.put(
|
||||
hostId: parseInt(hostId),
|
||||
});
|
||||
|
||||
try {
|
||||
const axios = (await import("axios")).default;
|
||||
const statsPort = 30005;
|
||||
await axios.post(
|
||||
`http://localhost:${statsPort}/host-updated`,
|
||||
{ hostId: parseInt(hostId) },
|
||||
{
|
||||
headers: {
|
||||
Authorization: req.headers.authorization || "",
|
||||
Cookie: req.headers.cookie || "",
|
||||
},
|
||||
timeout: 5000,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
sshLogger.warn("Failed to notify stats server of host update", {
|
||||
operation: "host_update",
|
||||
hostId: parseInt(hostId),
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
res.json(resolvedHost);
|
||||
notifyStatsHostUpdated(parseInt(hostId), req.headers, "host_update");
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to update SSH host in database", err, {
|
||||
operation: "host_update",
|
||||
@@ -3319,7 +3308,7 @@ router.patch(
|
||||
.update(hosts)
|
||||
.set({ statsConfig: JSON.stringify(merged) })
|
||||
.where(and(eq(hosts.id, host.id), eq(hosts.userId, userId)));
|
||||
} catch (e) {
|
||||
} catch {
|
||||
errors.push(`Failed to update statsConfig for host ${host.id}`);
|
||||
}
|
||||
}
|
||||
@@ -5346,7 +5335,7 @@ router.post(
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const hostId = parseInt(req.params.id);
|
||||
const hostId = Number.parseInt(String(req.params.id), 10);
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
try {
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
networkTopology,
|
||||
dashboardPreferences,
|
||||
opksshTokens,
|
||||
apiKeys,
|
||||
} from "../db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import bcrypt from "bcryptjs";
|
||||
@@ -1142,7 +1143,11 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (!isFirstUser) {
|
||||
const oidcAllowRegistration =
|
||||
(process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() ===
|
||||
"true";
|
||||
|
||||
if (!isFirstUser && !oidcAllowRegistration) {
|
||||
try {
|
||||
const regRow = db.$client
|
||||
.prepare(
|
||||
@@ -1321,10 +1326,6 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
const redirectUrl = new URL(frontendOrigin);
|
||||
redirectUrl.searchParams.set("success", "true");
|
||||
|
||||
if (deviceInfo.type === "desktop" || deviceInfo.type === "mobile") {
|
||||
redirectUrl.searchParams.set("token", token);
|
||||
}
|
||||
|
||||
const maxAge =
|
||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
@@ -1576,14 +1577,6 @@ router.post("/login", async (req, res) => {
|
||||
username: userRecord.username,
|
||||
};
|
||||
|
||||
const isElectron =
|
||||
req.headers["x-electron-app"] === "true" ||
|
||||
req.headers["X-Electron-App"] === "true";
|
||||
|
||||
if (isElectron) {
|
||||
response.token = token;
|
||||
}
|
||||
|
||||
const timeoutRow = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'session_timeout_hours'")
|
||||
.get() as { value: string } | undefined;
|
||||
@@ -1621,18 +1614,7 @@ router.post("/logout", authenticateJWT, async (req, res) => {
|
||||
const userId = authReq.userId;
|
||||
|
||||
if (userId) {
|
||||
const token =
|
||||
req.cookies?.jwt || req.headers["authorization"]?.split(" ")[1];
|
||||
let sessionId: string | undefined;
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
sessionId = payload?.sessionId;
|
||||
} catch {
|
||||
// expected - token verification may fail during logout
|
||||
}
|
||||
}
|
||||
const sessionId = authReq.sessionId;
|
||||
|
||||
await authManager.logoutUser(userId, sessionId);
|
||||
authLogger.info("User logged out", {
|
||||
@@ -1693,6 +1675,7 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
|
||||
is_oidc: !!user[0].isOidc,
|
||||
is_dual_auth: isDualAuth,
|
||||
totp_enabled: !!user[0].totpEnabled,
|
||||
data_unlocked: authManager.isUserUnlocked(userId),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get username", err);
|
||||
@@ -3396,10 +3379,6 @@ router.post("/totp/verify-login", async (req, res) => {
|
||||
deviceInfo: deviceInfo.deviceInfo,
|
||||
});
|
||||
|
||||
const isElectron =
|
||||
req.headers["x-electron-app"] === "true" ||
|
||||
req.headers["X-Electron-App"] === "true";
|
||||
|
||||
authLogger.success("TOTP verification successful", {
|
||||
operation: "totp_verify_success",
|
||||
userId: userRecord.id,
|
||||
@@ -3416,10 +3395,6 @@ router.post("/totp/verify-login", async (req, res) => {
|
||||
totp_enabled: !!userRecord.totpEnabled,
|
||||
};
|
||||
|
||||
if (isElectron) {
|
||||
response.token = token;
|
||||
}
|
||||
|
||||
const timeoutRow = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'session_timeout_hours'")
|
||||
.get() as { value: string } | undefined;
|
||||
@@ -3560,9 +3535,14 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => {
|
||||
* description: Failed to unlock data.
|
||||
*/
|
||||
router.post("/unlock-data", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const userId = authReq.userId;
|
||||
const { password } = req.body;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
return res.status(400).json({ error: "Password is required" });
|
||||
}
|
||||
@@ -3570,6 +3550,19 @@ router.post("/unlock-data", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const unlocked = await authManager.authenticateUser(userId, password);
|
||||
if (unlocked) {
|
||||
const refreshedSession =
|
||||
userId && authReq.sessionId
|
||||
? await authManager.refreshSessionToken(userId, authReq.sessionId)
|
||||
: null;
|
||||
|
||||
if (refreshedSession) {
|
||||
res.cookie(
|
||||
"jwt",
|
||||
refreshedSession.token,
|
||||
authManager.getSecureCookieOptions(req, refreshedSession.maxAge),
|
||||
);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Data unlocked successfully",
|
||||
@@ -3608,9 +3601,10 @@ router.get("/data-status", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
try {
|
||||
const unlocked = authManager.isUserUnlocked(userId);
|
||||
res.json({
|
||||
unlocked: true,
|
||||
message: "Data is unlocked",
|
||||
unlocked,
|
||||
message: unlocked ? "Data is unlocked" : "Data is locked",
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to check data status", err, {
|
||||
@@ -3638,7 +3632,9 @@ router.get("/data-status", authenticateJWT, async (req, res) => {
|
||||
* description: Failed to get sessions.
|
||||
*/
|
||||
router.get("/sessions", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const userId = authReq.userId;
|
||||
const currentSessionId = authReq.sessionId;
|
||||
|
||||
try {
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
@@ -3661,8 +3657,16 @@ router.get("/sessions", authenticateJWT, async (req, res) => {
|
||||
.limit(1);
|
||||
|
||||
return {
|
||||
...session,
|
||||
id: session.id,
|
||||
userId: session.userId,
|
||||
username: sessionUser[0]?.username || "Unknown",
|
||||
deviceType: session.deviceType,
|
||||
deviceInfo: session.deviceInfo,
|
||||
createdAt: session.createdAt,
|
||||
expiresAt: session.expiresAt,
|
||||
lastActiveAt: session.lastActiveAt,
|
||||
isRevoked: session.isRevoked,
|
||||
isCurrentSession: session.id === currentSessionId,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -3670,7 +3674,19 @@ router.get("/sessions", authenticateJWT, async (req, res) => {
|
||||
return res.json({ sessions: enrichedSessions });
|
||||
} else {
|
||||
sessionList = await authManager.getUserSessions(userId);
|
||||
return res.json({ sessions: sessionList });
|
||||
return res.json({
|
||||
sessions: sessionList.map((session) => ({
|
||||
id: session.id,
|
||||
userId: session.userId,
|
||||
deviceType: session.deviceType,
|
||||
deviceInfo: session.deviceInfo,
|
||||
createdAt: session.createdAt,
|
||||
expiresAt: session.expiresAt,
|
||||
lastActiveAt: session.lastActiveAt,
|
||||
isRevoked: session.isRevoked,
|
||||
isCurrentSession: session.id === currentSessionId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get sessions", err);
|
||||
@@ -3812,12 +3828,7 @@ router.post("/sessions/revoke-all", authenticateJWT, async (req, res) => {
|
||||
|
||||
let currentSessionId: string | undefined;
|
||||
if (exceptCurrent) {
|
||||
const token =
|
||||
req.cookies?.jwt || req.headers?.authorization?.split(" ")[1];
|
||||
if (token) {
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
currentSessionId = payload?.sessionId;
|
||||
}
|
||||
currentSessionId = (req as AuthenticatedRequest).sessionId;
|
||||
}
|
||||
|
||||
const revokedCount = await authManager.revokeAllUserSessions(
|
||||
@@ -4205,7 +4216,7 @@ router.post("/unlink-oidc-from-password", authenticateJWT, async (req, res) => {
|
||||
* 500:
|
||||
* description: Failed to get guacamole settings.
|
||||
*/
|
||||
router.get("/guacamole-settings", async (req, res) => {
|
||||
router.get("/guacamole-settings", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const enabledRow = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'guac_enabled'")
|
||||
@@ -4305,7 +4316,7 @@ router.patch("/guacamole-settings", authenticateJWT, async (req, res) => {
|
||||
* 200:
|
||||
* description: Current log level.
|
||||
*/
|
||||
router.get("/log-level", async (_req, res) => {
|
||||
router.get("/log-level", authenticateJWT, async (_req, res) => {
|
||||
try {
|
||||
const row = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'log_level'")
|
||||
@@ -4374,7 +4385,7 @@ router.patch("/log-level", authenticateJWT, async (req, res) => {
|
||||
* 200:
|
||||
* description: Current session timeout hours.
|
||||
*/
|
||||
router.get("/session-timeout", async (_req, res) => {
|
||||
router.get("/session-timeout", authenticateJWT, async (_req, res) => {
|
||||
try {
|
||||
const row = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'session_timeout_hours'")
|
||||
@@ -4433,4 +4444,209 @@ router.patch("/session-timeout", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/api-keys:
|
||||
* post:
|
||||
* summary: Create an API key (admin only)
|
||||
* description: Creates a new API key scoped to a specific user. The full token is returned only once.
|
||||
* tags:
|
||||
* - API Keys
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - name
|
||||
* - userId
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* description: Human-readable name for the key.
|
||||
* userId:
|
||||
* type: string
|
||||
* description: ID of the user this key is scoped to.
|
||||
* expiresAt:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: Optional expiration date. Null means the key never expires.
|
||||
* responses:
|
||||
* 201:
|
||||
* description: API key created. Contains the full token (shown only once).
|
||||
* 400:
|
||||
* description: Invalid input.
|
||||
* 403:
|
||||
* description: Admin access required.
|
||||
* 404:
|
||||
* description: Target user not found.
|
||||
* 500:
|
||||
* description: Failed to create API key.
|
||||
*/
|
||||
router.post("/api-keys", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { name, userId: targetUserId, expiresAt } = req.body;
|
||||
|
||||
if (typeof name !== "string" || !name.trim()) {
|
||||
return res.status(400).json({ error: "name is required" });
|
||||
}
|
||||
if (typeof targetUserId !== "string" || !targetUserId.trim()) {
|
||||
return res.status(400).json({ error: "userId is required" });
|
||||
}
|
||||
|
||||
const targetUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, targetUserId))
|
||||
.limit(1);
|
||||
if (targetUser.length === 0) {
|
||||
return res.status(404).json({ error: "Target user not found" });
|
||||
}
|
||||
|
||||
let expiresAtValue: string | null = null;
|
||||
if (expiresAt) {
|
||||
const parsed = new Date(expiresAt);
|
||||
if (isNaN(parsed.getTime())) {
|
||||
return res.status(400).json({ error: "Invalid expiresAt date" });
|
||||
}
|
||||
if (parsed <= new Date()) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "expiresAt must be in the future" });
|
||||
}
|
||||
expiresAtValue = parsed.toISOString();
|
||||
}
|
||||
|
||||
const rawToken = "tmx_" + crypto.randomBytes(32).toString("hex");
|
||||
const tokenPrefix = rawToken.substring(0, 12);
|
||||
const tokenHash = await bcrypt.hash(rawToken, 10);
|
||||
const keyId = nanoid();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await db.insert(apiKeys).values({
|
||||
id: keyId,
|
||||
userId: targetUserId,
|
||||
name: name.trim(),
|
||||
tokenHash,
|
||||
tokenPrefix,
|
||||
createdAt: now,
|
||||
expiresAt: expiresAtValue,
|
||||
lastUsedAt: null,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
return res.status(201).json({
|
||||
id: keyId,
|
||||
name: name.trim(),
|
||||
userId: targetUserId,
|
||||
username: targetUser[0].username,
|
||||
tokenPrefix,
|
||||
createdAt: now,
|
||||
expiresAt: expiresAtValue,
|
||||
token: rawToken,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to create API key", err);
|
||||
return res.status(500).json({ error: "Failed to create API key" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/api-keys:
|
||||
* get:
|
||||
* summary: List all API keys (admin only)
|
||||
* description: Returns all API keys with associated usernames. Token hashes are never returned.
|
||||
* tags:
|
||||
* - API Keys
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of API keys.
|
||||
* 403:
|
||||
* description: Admin access required.
|
||||
* 500:
|
||||
* description: Failed to fetch API keys.
|
||||
*/
|
||||
router.get("/api-keys", requireAdmin, async (_req, res) => {
|
||||
try {
|
||||
const keys = await db
|
||||
.select({
|
||||
id: apiKeys.id,
|
||||
name: apiKeys.name,
|
||||
userId: apiKeys.userId,
|
||||
username: users.username,
|
||||
tokenPrefix: apiKeys.tokenPrefix,
|
||||
createdAt: apiKeys.createdAt,
|
||||
expiresAt: apiKeys.expiresAt,
|
||||
lastUsedAt: apiKeys.lastUsedAt,
|
||||
isActive: apiKeys.isActive,
|
||||
})
|
||||
.from(apiKeys)
|
||||
.leftJoin(users, eq(apiKeys.userId, users.id))
|
||||
.orderBy(apiKeys.createdAt);
|
||||
|
||||
return res.json({ apiKeys: keys });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to list API keys", err);
|
||||
return res.status(500).json({ error: "Failed to fetch API keys" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/api-keys/{keyId}:
|
||||
* delete:
|
||||
* summary: Delete an API key (admin only)
|
||||
* description: Permanently deletes an API key. It can no longer be used to authenticate.
|
||||
* tags:
|
||||
* - API Keys
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: keyId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: The ID of the API key to delete.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: API key deleted.
|
||||
* 403:
|
||||
* description: Admin access required.
|
||||
* 404:
|
||||
* description: API key not found.
|
||||
* 500:
|
||||
* description: Failed to delete API key.
|
||||
*/
|
||||
router.delete("/api-keys/:keyId", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const keyId = String(req.params.keyId);
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(apiKeys)
|
||||
.where(eq(apiKeys.id, keyId))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
return res.status(404).json({ error: "API key not found" });
|
||||
}
|
||||
|
||||
await db.delete(apiKeys).where(eq(apiKeys.id, keyId));
|
||||
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to delete API key", err, {
|
||||
keyId: String(req.params.keyId),
|
||||
});
|
||||
return res.status(500).json({ error: "Failed to delete API key" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
Reference in New Issue
Block a user