mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cc65d87cd | ||
|
|
e166994ec4 | ||
|
|
3a851ad189 | ||
|
|
d2851982a6 | ||
|
|
cc5dce18e2 | ||
|
|
6084ed4d3c | ||
|
|
c3f6db69e9 | ||
|
|
8e2221cb8f | ||
|
|
7324982f47 | ||
|
|
f20d64b4f2 | ||
|
|
aa0b32a142 | ||
|
|
c8a907538e | ||
|
|
dd5573cc10 | ||
|
|
071711dafc | ||
|
|
bc74fba3f5 | ||
|
|
694dc34b19 | ||
|
|
b3b08b4ff2 | ||
|
|
9b0af07607 | ||
|
|
c044dbde87 | ||
|
|
1396b3a70c | ||
|
|
4c9ee5923e | ||
|
|
25e58a6425 | ||
|
|
7d13b2a9fe | ||
|
|
e610aba0e5 | ||
|
|
3941dd4ecc | ||
|
|
872e5eba73 | ||
|
|
8c3f816407 | ||
|
|
13f7aa95a3 | ||
|
|
8e5a50623c | ||
|
|
d791e52d82 | ||
|
|
fb04edc497 |
No files matched your search
@@ -1,5 +1,40 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 26.10.2
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Hide sidebar after opening, in Customize Sidebar, closes the host list once you open a host
|
||||||
|
- Sort the dashboard's Host Status card by name, IP address or status
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The side menu now opens over the page when it expands on hover, so open sessions like remote desktop are not resized each time. Turn on Push content when it expands in Settings to get the old behavior back
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Picking a section lower down in the host editor, like Warpgate, stuttered on the way and sometimes stopped before it got there
|
||||||
|
- Sorting hosts by IP address put 10.0.0.210 before 10.0.0.3
|
||||||
|
- The local terminal failed to start in the universal macOS build
|
||||||
|
- Hosts shared with you never showed up on a linked desktop
|
||||||
|
- A linked desktop could never turn on an unsigned plugin the server had enabled
|
||||||
|
- Creating, cloning or deleting hosts failed with "no such table: main.p_vault_profiles" after the Vault plugin was removed
|
||||||
|
- Opening a user in Settings, Users blocked the other admin sections until you went back to the user list
|
||||||
|
- A linked desktop could keep showing the same sync conflicts, or delete a local host, when the server could not send its copy
|
||||||
|
- Usernames are now unique
|
||||||
|
- Importing user data keeps foreign keys on
|
||||||
|
- Trusted proxy login checks the address nginx saw, not nginx itself
|
||||||
|
- The desktop login frame only trusts the server's own origin
|
||||||
|
- External login URLs and callback ports are checked on the desktop app
|
||||||
|
- A changed jump host key is no longer accepted without asking
|
||||||
|
- Synced plugins signed only by a key the server sent are no longer turned on by themselves
|
||||||
|
- Two connects to the same host at once could overwrite each other's connection
|
||||||
|
- Indonesian uses the same technical terms everywhere
|
||||||
|
- When the server was down or still starting, the login page said it could not connect to the database and stopped trying. It now says the server is not responding and continues on its own once the server is back
|
||||||
|
- nginx answered 404 instead of 502 while the backend was down
|
||||||
|
- A client tunnel that could not start always said "Endpoint SSH connection closed" on the desktop app instead of the real reason, like the target refusing the connection
|
||||||
|
- The Docker server crashed and restarted every few hours in ssh2's native crypto on Node 24
|
||||||
|
|
||||||
## 26.10.1
|
## 26.10.1
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+4
-1
@@ -74,12 +74,15 @@ COPY vendor ./vendor
|
|||||||
|
|
||||||
COPY scripts/patch-ssh2-agent.cjs ./scripts/
|
COPY scripts/patch-ssh2-agent.cjs ./scripts/
|
||||||
|
|
||||||
|
# ssh2's native crypto crashes Node 24 when a cipher is freed, so ssh2 uses
|
||||||
|
# OpenSSL instead (chacha20-poly1305 is then left out).
|
||||||
RUN npm ci --omit=dev --ignore-scripts && \
|
RUN npm ci --omit=dev --ignore-scripts && \
|
||||||
node scripts/patch-ssh2-agent.cjs && \
|
node scripts/patch-ssh2-agent.cjs && \
|
||||||
rm -rf node_modules/better-sqlite3/prebuilds && \
|
rm -rf node_modules/better-sqlite3/prebuilds && \
|
||||||
npm run build-release --prefix node_modules/better-sqlite3 && \
|
npm run build-release --prefix node_modules/better-sqlite3 && \
|
||||||
test -f node_modules/better-sqlite3/build/Release/better_sqlite3.node && \
|
test -f node_modules/better-sqlite3/build/Release/better_sqlite3.node && \
|
||||||
npm rebuild bcryptjs ssh2 && \
|
npm rebuild bcryptjs && \
|
||||||
|
rm -rf node_modules/ssh2/lib/protocol/crypto/build && \
|
||||||
npm cache clean --force
|
npm cache clean --force
|
||||||
|
|
||||||
# Stage 6: Final optimized image
|
# Stage 6: Final optimized image
|
||||||
|
|||||||
@@ -28,7 +28,11 @@ services:
|
|||||||
# Trusted reverse-proxy authentication is disabled by default. When
|
# Trusted reverse-proxy authentication is disabled by default. When
|
||||||
# enabled, do not expose this container directly to untrusted clients.
|
# enabled, do not expose this container directly to untrusted clients.
|
||||||
# TRUSTED_PROXY_AUTH_ENABLED: "true"
|
# TRUSTED_PROXY_AUTH_ENABLED: "true"
|
||||||
# TRUSTED_PROXY_AUTH_TRUSTED_PROXIES: "172.16.0.0/12"
|
# List the address your authenticating proxy connects from, as this
|
||||||
|
# container sees it (for a proxy container on termix-net, its IP there).
|
||||||
|
# Keep it narrow: published ports can reach the container from the
|
||||||
|
# Docker gateway, so do not trust whole private ranges.
|
||||||
|
# TRUSTED_PROXY_AUTH_TRUSTED_PROXIES: "172.20.0.10"
|
||||||
# TRUSTED_PROXY_AUTH_ROLE_MAP: '{"operators":["user"]}'
|
# TRUSTED_PROXY_AUTH_ROLE_MAP: '{"operators":["user"]}'
|
||||||
# TRUSTED_PROXY_AUTH_USERNAME_HEADER: "x-forwarded-username"
|
# TRUSTED_PROXY_AUTH_USERNAME_HEADER: "x-forwarded-username"
|
||||||
# TRUSTED_PROXY_AUTH_ROLE_HEADER: "x-forwarded-role"
|
# TRUSTED_PROXY_AUTH_ROLE_HEADER: "x-forwarded-role"
|
||||||
|
|||||||
@@ -200,6 +200,9 @@ http {
|
|||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
# The TCP peer before set_real_ip_from rewrites $remote_addr from
|
||||||
|
# X-Forwarded-For; trusted proxy login checks this address.
|
||||||
|
proxy_set_header X-Termix-Proxy-Peer $realip_remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||||
@@ -473,9 +476,12 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
error_page 500 502 503 504 /50x.html;
|
error_page 500 502 503 504 /50x.html;
|
||||||
|
# Only nginx's own errors land here (the backend is down or too slow).
|
||||||
|
# There is no 50x page, so answer directly instead of a 404.
|
||||||
location = /50x.html {
|
location = /50x.html {
|
||||||
root /app/html;
|
|
||||||
internal;
|
internal;
|
||||||
|
default_type application/json;
|
||||||
|
return 502 '{"error":"The Termix server is not responding"}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+7
-1
@@ -169,6 +169,9 @@ http {
|
|||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
# The TCP peer before set_real_ip_from rewrites $remote_addr from
|
||||||
|
# X-Forwarded-For; trusted proxy login checks this address.
|
||||||
|
proxy_set_header X-Termix-Proxy-Peer $realip_remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||||
@@ -442,9 +445,12 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
error_page 500 502 503 504 /50x.html;
|
error_page 500 502 503 504 /50x.html;
|
||||||
|
# Only nginx's own errors land here (the backend is down or too slow).
|
||||||
|
# There is no 50x page, so answer directly instead of a 404.
|
||||||
location = /50x.html {
|
location = /50x.html {
|
||||||
root /app/html;
|
|
||||||
internal;
|
internal;
|
||||||
|
default_type application/json;
|
||||||
|
return 502 '{"error":"The Termix server is not responding"}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Choose an unused prefix from N + 1 disjoint prefixes for N existing users.
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT id, username,
|
||||||
|
ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence
|
||||||
|
FROM users
|
||||||
|
), candidates AS (
|
||||||
|
SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked
|
||||||
|
), available AS (
|
||||||
|
SELECT MIN(candidate) AS candidate FROM candidates
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM ranked WHERE username LIKE CONCAT(CONCAT('termix-recovered-', candidate, '-'), '%')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
UPDATE users JOIN ranked ON ranked.id = users.id CROSS JOIN available
|
||||||
|
SET users.username = CONCAT('termix-recovered-', available.candidate, '-', ranked.sequence)
|
||||||
|
WHERE ranked.occurrence > 1;--> statement-breakpoint
|
||||||
|
ALTER TABLE `users` MODIFY COLUMN `username` varchar(255) NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `users` ADD CONSTRAINT `idx_users_username` UNIQUE(`username`);
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -505,6 +505,13 @@
|
|||||||
"when": 1791755801066,
|
"when": 1791755801066,
|
||||||
"tag": "0071_drop_install_count_source",
|
"tag": "0071_drop_install_count_source",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 72,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1791935283211,
|
||||||
|
"tag": "0072_unique_username",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Choose an unused prefix from N + 1 disjoint prefixes for N existing users.
|
||||||
|
WITH ranked AS MATERIALIZED (
|
||||||
|
SELECT id, username,
|
||||||
|
ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence
|
||||||
|
FROM users
|
||||||
|
), candidates AS (
|
||||||
|
SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked
|
||||||
|
), available AS (
|
||||||
|
SELECT MIN(candidate) AS candidate FROM candidates
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM ranked WHERE username LIKE 'termix-recovered-' || candidate || '-' || '%'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
UPDATE users SET username = 'termix-recovered-' || (SELECT candidate FROM available) || '-' || ranked.sequence
|
||||||
|
FROM ranked WHERE ranked.id = users.id AND ranked.occurrence > 1;--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ALTER COLUMN "username" SET DATA TYPE varchar(255);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "idx_users_username" ON "users" USING btree ("username");
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -505,6 +505,13 @@
|
|||||||
"when": 1791755800419,
|
"when": 1791755800419,
|
||||||
"tag": "0071_drop_install_count_source",
|
"tag": "0071_drop_install_count_source",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 72,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1791935282340,
|
||||||
|
"tag": "0072_unique_username",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Choose an unused prefix from N + 1 disjoint prefixes for N existing users.
|
||||||
|
WITH ranked AS MATERIALIZED (
|
||||||
|
SELECT id, username,
|
||||||
|
ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence
|
||||||
|
FROM users
|
||||||
|
), candidates AS (
|
||||||
|
SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked
|
||||||
|
), available AS (
|
||||||
|
SELECT MIN(candidate) AS candidate FROM candidates
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM ranked WHERE username LIKE 'termix-recovered-' || candidate || '-' || '%'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
UPDATE users SET username = 'termix-recovered-' || (SELECT candidate FROM available) || '-' || ranked.sequence
|
||||||
|
FROM ranked WHERE ranked.id = users.id AND ranked.occurrence > 1;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `idx_users_username` ON `users` (`username`);
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -470,6 +470,13 @@
|
|||||||
"when": 1791755799726,
|
"when": 1791755799726,
|
||||||
"tag": "0066_drop_install_count_source",
|
"tag": "0066_drop_install_count_source",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 67,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1791935281343,
|
||||||
|
"tag": "0067_unique_username",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
// Saved by the file manager after the user picks an editor in a file dialog.
|
||||||
|
const EXTERNAL_EDITOR_SETTING = "fileManager.externalEditorPath";
|
||||||
|
|
||||||
|
// Extensions the OS would run rather than open in an editor. With no editor
|
||||||
|
// configured the temp copy goes to shell.openPath, and its name comes from
|
||||||
|
// the remote server.
|
||||||
|
const LAUNCHABLE_EXTENSIONS = new Set([
|
||||||
|
// Windows
|
||||||
|
"application",
|
||||||
|
"appref-ms",
|
||||||
|
"bat",
|
||||||
|
"chm",
|
||||||
|
"cmd",
|
||||||
|
"com",
|
||||||
|
"cpl",
|
||||||
|
"exe",
|
||||||
|
"gadget",
|
||||||
|
"hta",
|
||||||
|
"inf",
|
||||||
|
"jar",
|
||||||
|
"js",
|
||||||
|
"jse",
|
||||||
|
"library-ms",
|
||||||
|
"lnk",
|
||||||
|
"msc",
|
||||||
|
"msi",
|
||||||
|
"msp",
|
||||||
|
"mst",
|
||||||
|
"pif",
|
||||||
|
"ps1",
|
||||||
|
"psd1",
|
||||||
|
"psm1",
|
||||||
|
"py",
|
||||||
|
"pyw",
|
||||||
|
"reg",
|
||||||
|
"scf",
|
||||||
|
"scr",
|
||||||
|
"search-ms",
|
||||||
|
"settingcontent-ms",
|
||||||
|
"url",
|
||||||
|
"vbe",
|
||||||
|
"vbs",
|
||||||
|
"ws",
|
||||||
|
"wsf",
|
||||||
|
"wsh",
|
||||||
|
"xll",
|
||||||
|
// macOS
|
||||||
|
"app",
|
||||||
|
"command",
|
||||||
|
"pkg",
|
||||||
|
"terminal",
|
||||||
|
"tool",
|
||||||
|
"workflow",
|
||||||
|
// Linux
|
||||||
|
"appimage",
|
||||||
|
"desktop",
|
||||||
|
"run",
|
||||||
|
"sh",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isLaunchableFileName(fileName) {
|
||||||
|
// Windows drops trailing dots and spaces, so "a.exe. " is saved as a.exe.
|
||||||
|
const name = path.basename(String(fileName || "")).replace(/[. ]+$/, "");
|
||||||
|
const extension = path.extname(name).slice(1).toLowerCase();
|
||||||
|
return LAUNCHABLE_EXTENSIONS.has(extension);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { EXTERNAL_EDITOR_SETTING, isLaunchableFileName };
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* The login URL a server hands the desktop app goes to shell.openExternal,
|
||||||
|
* so only web pages pass: any other scheme (file:, smb:, ms-msdt:, ...)
|
||||||
|
* would launch whatever OS handler is registered for it.
|
||||||
|
*/
|
||||||
|
function isExternalLoginUrl(value) {
|
||||||
|
try {
|
||||||
|
const { protocol } = new URL(value);
|
||||||
|
return protocol === "https:" || protocol === "http:";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A non-privileged port for the loopback callback listener. */
|
||||||
|
function isCallbackPort(value) {
|
||||||
|
return Number.isInteger(value) && value >= 1024 && value <= 65535;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { isExternalLoginUrl, isCallbackPort };
|
||||||
@@ -39,7 +39,11 @@ function resolveLocalShell(
|
|||||||
* how many helpers were fixed.
|
* how many helpers were fixed.
|
||||||
*/
|
*/
|
||||||
function repairSpawnHelpers(ptyDir, fsImpl = fs) {
|
function repairSpawnHelpers(ptyDir, fsImpl = fs) {
|
||||||
const dir = ptyDir.replace(/app\.asar(?!\.unpacked)/, "app.asar.unpacked");
|
// A universal macOS build ships app-arm64.asar and app-x64.asar.
|
||||||
|
const dir = ptyDir.replace(
|
||||||
|
/\b(app(?:-[a-z0-9]+)?)\.asar(?!\.unpacked)/,
|
||||||
|
"$1.asar.unpacked",
|
||||||
|
);
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.join(dir, "build", "Release", "spawn-helper"),
|
path.join(dir, "build", "Release", "spawn-helper"),
|
||||||
path.join(
|
path.join(
|
||||||
|
|||||||
+54
-104
@@ -36,6 +36,11 @@ const { quitApp } = require("./app-quit.cjs");
|
|||||||
const { selectLinuxPasswordStore } = require("./linux-password-store.cjs");
|
const { selectLinuxPasswordStore } = require("./linux-password-store.cjs");
|
||||||
const { resolveLocalShell, repairSpawnHelpers } = require("./local-shell.cjs");
|
const { resolveLocalShell, repairSpawnHelpers } = require("./local-shell.cjs");
|
||||||
const { registerLocalFileHandlers } = require("./local-files.cjs");
|
const { registerLocalFileHandlers } = require("./local-files.cjs");
|
||||||
|
const {
|
||||||
|
EXTERNAL_EDITOR_SETTING,
|
||||||
|
isLaunchableFileName,
|
||||||
|
} = require("./external-editor.cjs");
|
||||||
|
const { isExternalLoginUrl, isCallbackPort } = require("./external-login.cjs");
|
||||||
|
|
||||||
const localTerminalSessions = new Map();
|
const localTerminalSessions = new Map();
|
||||||
|
|
||||||
@@ -700,6 +705,9 @@ let tray = null;
|
|||||||
let isQuitting = false;
|
let isQuitting = false;
|
||||||
const tempFiles = new Map();
|
const tempFiles = new Map();
|
||||||
const externalEditorSessions = new Map();
|
const externalEditorSessions = new Map();
|
||||||
|
// Paths the user picked in an open dialog this session. Only these may be
|
||||||
|
// saved as the external editor, so the renderer cannot name an executable.
|
||||||
|
const dialogChosenPaths = new Set();
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
|
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
|
||||||
const appRoot = isDev ? process.cwd() : path.join(__dirname, "..");
|
const appRoot = isDev ? process.cwd() : path.join(__dirname, "..");
|
||||||
@@ -1663,6 +1671,12 @@ ipcMain.handle("get-embedded-server-status", () => {
|
|||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
"external-browser-login",
|
"external-browser-login",
|
||||||
async (_event, authUrl, callbackPort) => {
|
async (_event, authUrl, callbackPort) => {
|
||||||
|
if (!isExternalLoginUrl(authUrl)) {
|
||||||
|
return { success: false, error: "Invalid authentication URL" };
|
||||||
|
}
|
||||||
|
if (!isCallbackPort(callbackPort)) {
|
||||||
|
return { success: false, error: "Invalid callback port" };
|
||||||
|
}
|
||||||
const http = require("http");
|
const http = require("http");
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -2375,6 +2389,8 @@ async function testC2SRelay(
|
|||||||
if (runtime) runtime.authSocket = ws;
|
if (runtime) runtime.authSocket = ws;
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
|
// The relay sends its reason and then closes; keep the reason.
|
||||||
|
let relayError = null;
|
||||||
const settle = (result) => {
|
const settle = (result) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
@@ -2411,6 +2427,10 @@ async function testC2SRelay(
|
|||||||
settle({ success: true });
|
settle({ success: true });
|
||||||
} else if (message.type === "error") {
|
} else if (message.type === "error") {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
|
relayError = normalizeC2SErrorMessage(
|
||||||
|
message.error,
|
||||||
|
"Tunnel test failed",
|
||||||
|
);
|
||||||
settle(createC2SFailure(message.error, "Tunnel test failed"));
|
settle(createC2SFailure(message.error, "Tunnel test failed"));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2424,7 +2444,10 @@ async function testC2SRelay(
|
|||||||
});
|
});
|
||||||
ws.on("close", () => {
|
ws.on("close", () => {
|
||||||
if (runtime && c2sTunnelRuntimes.get(tunnel.name) === runtime) {
|
if (runtime && c2sTunnelRuntimes.get(tunnel.name) === runtime) {
|
||||||
setC2STunnelError(tunnel.name, "Endpoint SSH connection closed");
|
setC2STunnelError(
|
||||||
|
tunnel.name,
|
||||||
|
relayError || "Endpoint SSH connection closed",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
settle({ success: false, error: "Tunnel test connection closed" });
|
settle({ success: false, error: "Tunnel test connection closed" });
|
||||||
@@ -3156,18 +3179,16 @@ ipcMain.handle("get-c2s-tunnel-preset-default-name", () => {
|
|||||||
return `[${date}] ${computerName} (${platform} ${release})`;
|
return `[${date}] ${computerName} (${platform} ${release})`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function readSetting(key) {
|
||||||
|
const settingsPath = path.join(app.getPath("userData"), "settings.json");
|
||||||
|
if (!fs.existsSync(settingsPath)) return null;
|
||||||
|
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
||||||
|
return settings[key] !== undefined ? settings[key] : null;
|
||||||
|
}
|
||||||
|
|
||||||
ipcMain.handle("get-setting", (event, key) => {
|
ipcMain.handle("get-setting", (event, key) => {
|
||||||
try {
|
try {
|
||||||
const userDataPath = app.getPath("userData");
|
return readSetting(key);
|
||||||
const settingsPath = path.join(userDataPath, "settings.json");
|
|
||||||
|
|
||||||
if (!fs.existsSync(settingsPath)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const settingsData = fs.readFileSync(settingsPath, "utf8");
|
|
||||||
const settings = JSON.parse(settingsData);
|
|
||||||
return settings[key] !== undefined ? settings[key] : null;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error reading setting:", error);
|
console.error("Error reading setting:", error);
|
||||||
return null;
|
return null;
|
||||||
@@ -3175,6 +3196,16 @@ ipcMain.handle("get-setting", (event, key) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("set-setting", (event, key, value) => {
|
ipcMain.handle("set-setting", (event, key, value) => {
|
||||||
|
if (typeof key !== "string") {
|
||||||
|
return { success: false, error: "Invalid setting key" };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
key === EXTERNAL_EDITOR_SETTING &&
|
||||||
|
value &&
|
||||||
|
!dialogChosenPaths.has(value)
|
||||||
|
) {
|
||||||
|
return { success: false, error: "Choose the editor in the file dialog" };
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const userDataPath = app.getPath("userData");
|
const userDataPath = app.getPath("userData");
|
||||||
const settingsPath = path.join(userDataPath, "settings.json");
|
const settingsPath = path.join(userDataPath, "settings.json");
|
||||||
@@ -3198,94 +3229,6 @@ ipcMain.handle("set-setting", (event, key, value) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("get-session-cookie", async (_event, name, targetUrl) => {
|
|
||||||
try {
|
|
||||||
const ses = mainWindow?.webContents?.session;
|
|
||||||
if (!ses) return null;
|
|
||||||
const cookies = await ses.cookies.get({
|
|
||||||
name,
|
|
||||||
...(targetUrl ? { url: targetUrl } : {}),
|
|
||||||
});
|
|
||||||
const cookie = cookies.find((candidate) =>
|
|
||||||
cookieMatchesUrl(candidate, targetUrl),
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
cookie?.value ||
|
|
||||||
getRememberedElectronAuthCookie(name, targetUrl)?.value ||
|
|
||||||
null
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to get session cookie:", error);
|
|
||||||
return getRememberedElectronAuthCookie(name, targetUrl)?.value || null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function cookieMatchesUrl(cookie, targetUrl) {
|
|
||||||
if (!targetUrl) return true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const targetHost = new URL(targetUrl).hostname;
|
|
||||||
const cookieDomain = (cookie.domain || "").replace(/^\./, "");
|
|
||||||
|
|
||||||
return (
|
|
||||||
cookieDomain === targetHost ||
|
|
||||||
targetHost.endsWith(`.${cookieDomain}`) ||
|
|
||||||
(!cookieDomain && targetHost === "localhost")
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ipcMain.handle(
|
|
||||||
"wait-session-cookie",
|
|
||||||
async (_event, name, targetUrl, previousValue, timeoutMs = 5000) => {
|
|
||||||
const ses = mainWindow?.webContents?.session;
|
|
||||||
if (!ses) return { success: false, error: "No Electron session" };
|
|
||||||
|
|
||||||
const existingCookies = await ses.cookies.get({
|
|
||||||
name,
|
|
||||||
...(targetUrl ? { url: targetUrl } : {}),
|
|
||||||
});
|
|
||||||
const existingCookie = existingCookies.find((cookie) =>
|
|
||||||
cookieMatchesUrl(cookie, targetUrl),
|
|
||||||
);
|
|
||||||
if (existingCookie?.value && existingCookie.value !== previousValue) {
|
|
||||||
return { success: true, value: existingCookie.value };
|
|
||||||
}
|
|
||||||
|
|
||||||
const rememberedCookie = getRememberedElectronAuthCookie(name, targetUrl);
|
|
||||||
if (rememberedCookie?.value && rememberedCookie.value !== previousValue) {
|
|
||||||
return { success: true, value: rememberedCookie.value };
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
ses.cookies.off("changed", onCookieChanged);
|
|
||||||
resolve({ success: false, error: "Timed out waiting for cookie" });
|
|
||||||
}, timeoutMs);
|
|
||||||
|
|
||||||
function onCookieChanged(_event, cookie, _cause, removed) {
|
|
||||||
if (
|
|
||||||
removed ||
|
|
||||||
cookie.name !== name ||
|
|
||||||
!cookie.value ||
|
|
||||||
cookie.value === previousValue ||
|
|
||||||
!cookieMatchesUrl(cookie, targetUrl)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTimeout(timeout);
|
|
||||||
ses.cookies.off("changed", onCookieChanged);
|
|
||||||
resolve({ success: true, value: cookie.value });
|
|
||||||
}
|
|
||||||
|
|
||||||
ses.cookies.on("changed", onCookieChanged);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
ipcMain.handle("clear-session-cookies", async () => {
|
ipcMain.handle("clear-session-cookies", async () => {
|
||||||
try {
|
try {
|
||||||
clearPersistedElectronAuthCookies();
|
clearPersistedElectronAuthCookies();
|
||||||
@@ -3413,7 +3356,9 @@ ipcMain.handle("show-save-dialog", async (_event, options) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("show-open-dialog", async (_event, options) => {
|
ipcMain.handle("show-open-dialog", async (_event, options) => {
|
||||||
return dialog.showOpenDialog(mainWindow, options || {});
|
const result = await dialog.showOpenDialog(mainWindow, options || {});
|
||||||
|
for (const filePath of result.filePaths) dialogChosenPaths.add(filePath);
|
||||||
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
function toLocalFileType(stat) {
|
function toLocalFileType(stat) {
|
||||||
@@ -3736,6 +3681,15 @@ ipcMain.handle("cleanup-temp-file", (_event, tempId) => {
|
|||||||
|
|
||||||
ipcMain.handle("open-external-editor", async (_event, fileData) => {
|
ipcMain.handle("open-external-editor", async (_event, fileData) => {
|
||||||
try {
|
try {
|
||||||
|
// The editor comes from the saved setting, never from the caller.
|
||||||
|
const editorPath = readSetting(EXTERNAL_EDITOR_SETTING) || null;
|
||||||
|
if (!editorPath && isLaunchableFileName(fileData?.fileName)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Choose an external editor to open this file type",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const result = createManagedTempFile(
|
const result = createManagedTempFile(
|
||||||
fileData?.fileName,
|
fileData?.fileName,
|
||||||
fileData?.content,
|
fileData?.content,
|
||||||
@@ -3757,10 +3711,6 @@ ipcMain.handle("open-external-editor", async (_event, fileData) => {
|
|||||||
lastMtimeMs: stat.mtimeMs,
|
lastMtimeMs: stat.mtimeMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
const editorPath =
|
|
||||||
typeof fileData?.editorPath === "string" && fileData.editorPath.trim()
|
|
||||||
? fileData.editorPath.trim()
|
|
||||||
: null;
|
|
||||||
const openError = await openPathWithEditor(result.path, editorPath);
|
const openError = await openPathWithEditor(result.path, editorPath);
|
||||||
if (openError) {
|
if (openError) {
|
||||||
closeExternalEditorSession(editId);
|
closeExternalEditorSession(editId);
|
||||||
|
|||||||
@@ -87,16 +87,6 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
},
|
},
|
||||||
|
|
||||||
clearSessionCookies: () => ipcRenderer.invoke("clear-session-cookies"),
|
clearSessionCookies: () => ipcRenderer.invoke("clear-session-cookies"),
|
||||||
getSessionCookie: (name, targetUrl) =>
|
|
||||||
ipcRenderer.invoke("get-session-cookie", name, targetUrl),
|
|
||||||
waitForSessionCookie: (name, targetUrl, previousValue, timeoutMs) =>
|
|
||||||
ipcRenderer.invoke(
|
|
||||||
"wait-session-cookie",
|
|
||||||
name,
|
|
||||||
targetUrl,
|
|
||||||
previousValue,
|
|
||||||
timeoutMs,
|
|
||||||
),
|
|
||||||
|
|
||||||
externalBrowserLogin: (authUrl, callbackPort) =>
|
externalBrowserLogin: (authUrl, callbackPort) =>
|
||||||
ipcRenderer.invoke("external-browser-login", authUrl, callbackPort),
|
ipcRenderer.invoke("external-browser-login", authUrl, callbackPort),
|
||||||
|
|||||||
Generated
+1
-1
@@ -15058,7 +15058,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin-sdk": {
|
"packages/plugin-sdk": {
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"name": "@termix-ssh/plugin-sdk",
|
||||||
"version": "1.0.6",
|
"version": "1.0.8",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.0.8
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `publicRateLimit` on `ctx.http.router()`, for a plugin whose public routes serve a proxied page
|
||||||
|
|
||||||
|
## 1.0.7
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A migration file, or a part of one, holding only comments made core fail to start the plugin
|
||||||
|
|
||||||
## 1.0.6
|
## 1.0.6
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Termix plugin guide
|
# Termix plugin guide
|
||||||
|
|
||||||
SDK 1.0.6 | Plugin API 1.2.0
|
SDK 1.0.8 | Plugin API 1.2.0
|
||||||
|
|
||||||
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
|
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
|
||||||
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
|
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
|
||||||
@@ -283,7 +283,8 @@ router.post("/notes", async (req, res) => {
|
|||||||
- The `@openapi` path is the full path, starting with `/plugin-api/<id>/`. `build` turns these into
|
- The `@openapi` path is the full path, starting with `/plugin-api/<id>/`. `build` turns these into
|
||||||
`dist/openapi.json` and the docs site renders them.
|
`dist/openapi.json` and the docs site renders them.
|
||||||
- `ctx.http.router({ public: ["/webhook/:token"], bodyLimit: "20mb", rawBody: false })`. Public routes
|
- `ctx.http.router({ public: ["/webhook/:token"], bodyLimit: "20mb", rawBody: false })`. Public routes
|
||||||
have no actor: check their token yourself and rate limit them.
|
have no actor: check their token yourself. Core allows 120 public requests a minute per address;
|
||||||
|
`publicRateLimit` raises that for a proxied page that loads its own assets.
|
||||||
- `ctx.http.baseUrl(req)` is the public address, for callback URLs.
|
- `ctx.http.baseUrl(req)` is the public address, for callback URLs.
|
||||||
- `ctx.ws.route("/live", (socket, conn) => ...)`. Long lived sockets should check
|
- `ctx.ws.route("/live", (socket, conn) => ...)`. Long lived sockets should check
|
||||||
`conn.isDataUnlocked()` per message.
|
`conn.isDataUnlocked()` per message.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"name": "@termix-ssh/plugin-sdk",
|
||||||
"version": "1.0.6",
|
"version": "1.0.8",
|
||||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -357,6 +357,13 @@ export interface PluginRouterOptions {
|
|||||||
* (multer, a raw body for signature checking, a proxied stream).
|
* (multer, a raw body for signature checking, a proxied stream).
|
||||||
*/
|
*/
|
||||||
rawBody?: boolean;
|
rawBody?: boolean;
|
||||||
|
/**
|
||||||
|
* Requests per minute one address may make to this plugin's public routes.
|
||||||
|
* Defaults to 120, which a proxied page loading its own scripts and images
|
||||||
|
* can pass on its own. Capped at 10000, and an admin's
|
||||||
|
* TERMIX_PLUGIN_PUBLIC_RATE_LIMIT wins over it.
|
||||||
|
*/
|
||||||
|
publicRateLimit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -309,17 +309,27 @@ export function splitStatements(source: string): string[] {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (char === ";") {
|
if (char === ";") {
|
||||||
if (current.trim()) statements.push(current.trim());
|
if (hasSql(current)) statements.push(current.trim());
|
||||||
current = "";
|
current = "";
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
current += char;
|
current += char;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (current.trim()) statements.push(current.trim());
|
if (hasSql(current)) statements.push(current.trim());
|
||||||
return statements;
|
return statements;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** False for a chunk that is only comments, which a driver refuses to run. */
|
||||||
|
function hasSql(chunk: string): boolean {
|
||||||
|
return (
|
||||||
|
chunk
|
||||||
|
.replace(/--[^\n]*/g, "")
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||||
|
.trim() !== ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** DROP statement for a definition, for removePluginData. */
|
/** DROP statement for a definition, for removePluginData. */
|
||||||
export function dropTableSql(
|
export function dropTableSql(
|
||||||
dialect: SqlDialect,
|
dialect: SqlDialect,
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { isLaunchableFileName } = require("../electron/external-editor.cjs") as {
|
||||||
|
isLaunchableFileName: (fileName: unknown) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Electron external editor", () => {
|
||||||
|
it("refuses remote file names the OS would run instead of open", () => {
|
||||||
|
for (const name of [
|
||||||
|
"payload.exe",
|
||||||
|
"run.BAT",
|
||||||
|
"script.ps1",
|
||||||
|
"a.exe. ",
|
||||||
|
"a.cmd...",
|
||||||
|
"../../evil.vbs",
|
||||||
|
"start.command",
|
||||||
|
"launch.desktop",
|
||||||
|
"install.sh",
|
||||||
|
]) {
|
||||||
|
expect(isLaunchableFileName(name)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens ordinary text files", () => {
|
||||||
|
for (const name of [
|
||||||
|
"nginx.conf",
|
||||||
|
"README.md",
|
||||||
|
"Dockerfile",
|
||||||
|
".bashrc",
|
||||||
|
"config.json",
|
||||||
|
"index.ts",
|
||||||
|
"",
|
||||||
|
undefined,
|
||||||
|
]) {
|
||||||
|
expect(isLaunchableFileName(name)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { isExternalLoginUrl, isCallbackPort } =
|
||||||
|
require("../electron/external-login.cjs") as {
|
||||||
|
isExternalLoginUrl: (value: unknown) => boolean;
|
||||||
|
isCallbackPort: (value: unknown) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Electron external login", () => {
|
||||||
|
it.each([
|
||||||
|
"https://idp.example.com/authorize?client_id=termix",
|
||||||
|
"http://keycloak.lan:8080/realms/home/protocol/openid-connect/auth",
|
||||||
|
])("opens web login URLs: %s", (url) => {
|
||||||
|
expect(isExternalLoginUrl(url)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"file:///C:/Windows/System32/calc.exe",
|
||||||
|
"smb://attacker/share",
|
||||||
|
"ms-msdt:/id PCWDiagnostic",
|
||||||
|
"javascript:alert(1)",
|
||||||
|
"not a url",
|
||||||
|
undefined,
|
||||||
|
])("refuses other schemes: %s", (url) => {
|
||||||
|
expect(isExternalLoginUrl(url)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts the ports the login screen picks", () => {
|
||||||
|
expect(isCallbackPort(17832)).toBe(true);
|
||||||
|
expect(isCallbackPort(17931)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([0, 80, 65536, 17832.5, "17832", NaN, undefined])(
|
||||||
|
"refuses callback port %s",
|
||||||
|
(port) => {
|
||||||
|
expect(isCallbackPort(port)).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -32,9 +32,62 @@ describe("Electron security boundary", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("never hands the session cookie to the renderer over IPC", () => {
|
||||||
|
for (const channel of ["get-session-cookie", "wait-session-cookie"]) {
|
||||||
|
expect(main).not.toContain(`"${channel}"`);
|
||||||
|
expect(preload).not.toContain(`"${channel}"`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("launches only the editor saved from a file dialog", () => {
|
||||||
|
expect(main).not.toContain("fileData?.editorPath");
|
||||||
|
expect(main).toContain("readSetting(EXTERNAL_EDITOR_SETTING)");
|
||||||
|
expect(main).toContain("dialogChosenPaths.has(value)");
|
||||||
|
});
|
||||||
|
|
||||||
it("scopes proxy headers and basic auth to the linked origin", () => {
|
it("scopes proxy headers and basic auth to the linked origin", () => {
|
||||||
expect(main).toContain('"sync-proxy-config"');
|
expect(main).toContain('"sync-proxy-config"');
|
||||||
expect(main).toContain("linkedServer.applyLinkHeaders(");
|
expect(main).toContain("linkedServer.applyLinkHeaders(");
|
||||||
expect(main).toContain("linkedServer.answerLogin(");
|
expect(main).toContain("linkedServer.answerLogin(");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects setting keys that coerce to the protected editor key", async () => {
|
||||||
|
const { runInNewContext } = await import("node:vm");
|
||||||
|
const { vi } = await import("vitest");
|
||||||
|
const writeFileSync = vi.fn();
|
||||||
|
let handler: (event: unknown, key: unknown, value: unknown) => unknown;
|
||||||
|
runInNewContext(
|
||||||
|
main.slice(
|
||||||
|
main.indexOf('ipcMain.handle("set-setting"'),
|
||||||
|
main.indexOf('ipcMain.handle("clear-session-cookies"'),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
ipcMain: {
|
||||||
|
handle: (_: string, callback: typeof handler) => {
|
||||||
|
handler = callback;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
EXTERNAL_EDITOR_SETTING: "fileManager.externalEditorPath",
|
||||||
|
dialogChosenPaths: new Set(["/chosen/editor"]),
|
||||||
|
app: { getPath: () => "/settings" },
|
||||||
|
path: { join: (...parts: string[]) => parts.join("/") },
|
||||||
|
fs: { existsSync: () => false, mkdirSync: vi.fn(), writeFileSync },
|
||||||
|
console,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
for (const key of [["fileManager.externalEditorPath"], null, {}, 42]) {
|
||||||
|
expect(handler!({}, key, "/unapproved/editor")).toEqual({
|
||||||
|
success: false,
|
||||||
|
error: "Invalid setting key",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(writeFileSync).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
handler!({}, "fileManager.externalEditorPath", "/unapproved/editor"),
|
||||||
|
).toEqual({ success: false, error: "Choose the editor in the file dialog" });
|
||||||
|
expect(
|
||||||
|
handler!({}, "fileManager.externalEditorPath", "/chosen/editor"),
|
||||||
|
).toEqual({ success: true });
|
||||||
|
expect(writeFileSync).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
+48
-34
@@ -3,43 +3,45 @@ const path = require("node:path");
|
|||||||
|
|
||||||
const nodePtyDir = path.join(__dirname, "..", "node_modules", "node-pty");
|
const nodePtyDir = path.join(__dirname, "..", "node_modules", "node-pty");
|
||||||
|
|
||||||
if (!fs.existsSync(nodePtyDir)) {
|
|
||||||
console.log("[patch-node-pty] node-pty not found, skipping");
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const unixTerminalPath = path.join(nodePtyDir, "lib", "unixTerminal.js");
|
const unixTerminalPath = path.join(nodePtyDir, "lib", "unixTerminal.js");
|
||||||
|
|
||||||
const originalHelperRewrite = [
|
// Older installs may still carry the first version of this patch.
|
||||||
"helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
|
const knownHelperRewrites = [
|
||||||
"helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
|
[
|
||||||
].join("\n");
|
"helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
|
||||||
|
"helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
|
||||||
|
].join("\n"),
|
||||||
|
[
|
||||||
|
"if (!helperPath.includes('app.asar.unpacked')) {",
|
||||||
|
" helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
|
||||||
|
"}",
|
||||||
|
"if (!helperPath.includes('node_modules.asar.unpacked')) {",
|
||||||
|
" helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
|
||||||
|
"}",
|
||||||
|
].join("\n"),
|
||||||
|
];
|
||||||
|
|
||||||
const patchedHelperRewrite = [
|
// A universal macOS build ships app-arm64.asar and app-x64.asar, not app.asar.
|
||||||
"if (!helperPath.includes('app.asar.unpacked')) {",
|
const patchedHelperRewrite =
|
||||||
" helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
|
"helperPath = helperPath.replace(/\\b(app(?:-[a-z0-9]+)?|node_modules)\\.asar(?!\\.unpacked)/g, '$1.asar.unpacked');";
|
||||||
"}",
|
|
||||||
"if (!helperPath.includes('node_modules.asar.unpacked')) {",
|
|
||||||
" helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
|
|
||||||
"}",
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
function patchUnixTerminalHelperRewrite() {
|
function patchUnixTerminalHelperRewrite(file = unixTerminalPath) {
|
||||||
if (!fs.existsSync(unixTerminalPath)) {
|
if (!fs.existsSync(file)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const source = fs.readFileSync(unixTerminalPath, "utf8");
|
const source = fs.readFileSync(file, "utf8");
|
||||||
if (source.includes(patchedHelperRewrite)) {
|
if (source.includes(patchedHelperRewrite)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!source.includes(originalHelperRewrite)) {
|
const known = knownHelperRewrites.find((rewrite) => source.includes(rewrite));
|
||||||
|
if (!known) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
unixTerminalPath,
|
file,
|
||||||
source.replace(originalHelperRewrite, patchedHelperRewrite),
|
source.replace(known, () => patchedHelperRewrite),
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -67,19 +69,31 @@ function chmodSpawnHelpers(rootDir) {
|
|||||||
return fixed;
|
return fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
const helperRewritePatched = patchUnixTerminalHelperRewrite();
|
// Tests and afterPack require this file; only a direct run patches node_modules.
|
||||||
const helpersChmodded = chmodSpawnHelpers(nodePtyDir);
|
function main() {
|
||||||
|
if (!fs.existsSync(nodePtyDir)) {
|
||||||
|
console.log("[patch-node-pty] node-pty not found, skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (helperRewritePatched) {
|
const helperRewritePatched = patchUnixTerminalHelperRewrite();
|
||||||
console.log("[patch-node-pty] Patched unixTerminal helper path rewrite");
|
const helpersChmodded = chmodSpawnHelpers(nodePtyDir);
|
||||||
|
|
||||||
|
if (helperRewritePatched) {
|
||||||
|
console.log("[patch-node-pty] Patched unixTerminal helper path rewrite");
|
||||||
|
}
|
||||||
|
if (helpersChmodded > 0) {
|
||||||
|
console.log(
|
||||||
|
`[patch-node-pty] Restored execute bit on ${helpersChmodded} spawn-helper binary(ies)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!helperRewritePatched && helpersChmodded === 0) {
|
||||||
|
console.log("[patch-node-pty] Already patched or target files not found");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (helpersChmodded > 0) {
|
|
||||||
console.log(
|
if (require.main === module) {
|
||||||
`[patch-node-pty] Restored execute bit on ${helpersChmodded} spawn-helper binary(ies)`,
|
main();
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!helperRewritePatched && helpersChmodded === 0) {
|
|
||||||
console.log("[patch-node-pty] Already patched or target files not found");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { chmodSpawnHelpers, patchUnixTerminalHelperRewrite };
|
module.exports = { chmodSpawnHelpers, patchUnixTerminalHelperRewrite };
|
||||||
+213
-222
@@ -79,7 +79,6 @@ import {
|
|||||||
createCurrentSshCredentialUsageRepository,
|
createCurrentSshCredentialUsageRepository,
|
||||||
createCurrentUserRepository,
|
createCurrentUserRepository,
|
||||||
} from "./repositories/factory.js";
|
} from "./repositories/factory.js";
|
||||||
import { withCurrentSqliteForeignKeysDisabled } from "./repositories/sqlite-foreign-keys.js";
|
|
||||||
import { applyPluginHostImportSettings } from "./routes/host-plugin-settings.js";
|
import { applyPluginHostImportSettings } from "./routes/host-plugin-settings.js";
|
||||||
import {
|
import {
|
||||||
selectExportableSettings,
|
selectExportableSettings,
|
||||||
@@ -1235,227 +1234,218 @@ app.post(
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withCurrentSqliteForeignKeysDisabled(async () => {
|
// The file's credential ids belong to the server that wrote it.
|
||||||
try {
|
const credentialIds = new Map<number, number>();
|
||||||
const importedHosts = importDb
|
try {
|
||||||
.prepare("SELECT * FROM ssh_data")
|
const importedCreds = importDb
|
||||||
.all();
|
.prepare("SELECT * FROM ssh_credentials")
|
||||||
const importedHostIds: number[] = [];
|
.all();
|
||||||
for (const host of importedHosts) {
|
for (const cred of importedCreds) {
|
||||||
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,
|
|
||||||
credentialId: host.credential_id || null,
|
|
||||||
overrideCredentialUsername: Boolean(
|
|
||||||
host.override_credential_username,
|
|
||||||
),
|
|
||||||
jumpHosts: host.jump_hosts,
|
|
||||||
...legacyStatusCheck(host),
|
|
||||||
// Exports from before 2.9.0 carry these in terminal_config.
|
|
||||||
sshOptions:
|
|
||||||
host.ssh_options ??
|
|
||||||
sshOptionsForWrite({
|
|
||||||
terminalConfig: host.terminal_config,
|
|
||||||
}) ??
|
|
||||||
null,
|
|
||||||
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(),
|
|
||||||
};
|
|
||||||
|
|
||||||
(hostData as Record<string, unknown>).defaultOverrides =
|
|
||||||
JSON.stringify(
|
|
||||||
await applyHostDefaultsToWrite({
|
|
||||||
ownerId: userId,
|
|
||||||
hostId: null,
|
|
||||||
columns: hostData as Record<string, unknown>,
|
|
||||||
body: hostData as Record<string, unknown>,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const created = await hostRepository.createEncryptedForUser(
|
|
||||||
userId,
|
|
||||||
hostData,
|
|
||||||
);
|
|
||||||
await applyPluginHostImportSettings(
|
|
||||||
Number(created.id),
|
|
||||||
importedHostPluginSettings(importDb, host),
|
|
||||||
);
|
|
||||||
importedHostIds.push(Number(created.id));
|
|
||||||
await importHostProtocolLogins(
|
|
||||||
importDb,
|
|
||||||
host,
|
|
||||||
Number(created.id),
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
result.summary.sshHostsImported++;
|
|
||||||
} catch (hostError) {
|
|
||||||
result.summary.errors.push(
|
|
||||||
`SSH host import error: ${hostError.message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await applyDefaultsAfterHostWrites(importedHostIds);
|
|
||||||
} 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 pluginRows = await importUserPluginRows(importDb, userId);
|
|
||||||
result.summary.pluginItemsImported += pluginRows.imported;
|
|
||||||
result.summary.skippedItems += pluginRows.skipped;
|
|
||||||
result.summary.errors.push(...pluginRows.errors);
|
|
||||||
|
|
||||||
const hostDefaults = await importHostDefaults(importDb, userId);
|
|
||||||
result.summary.skippedItems += hostDefaults.skipped;
|
|
||||||
if (hostDefaults.imported > 0) {
|
|
||||||
await recompute({ userIds: [userId] });
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetUser = await userRepository.findById(userId);
|
|
||||||
if (targetUser?.isAdmin) {
|
|
||||||
try {
|
try {
|
||||||
const importedSettings = readImportedSettings(importDb);
|
const credentialRepository = createCurrentCredentialRepository();
|
||||||
for (const setting of importedSettings) {
|
const existingId =
|
||||||
try {
|
await credentialRepository.findIdForImportIdentity(
|
||||||
await upsertImportedSetting(setting);
|
userId,
|
||||||
result.summary.settingsImported++;
|
cred.name,
|
||||||
} catch (settingError) {
|
cred.username,
|
||||||
result.summary.errors.push(
|
);
|
||||||
`Setting import error (${setting.key}): ${settingError.message}`,
|
|
||||||
);
|
if (existingId !== null) {
|
||||||
}
|
credentialIds.set(cred.id, existingId);
|
||||||
|
result.summary.skippedItems++;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
apiLogger.info(
|
const credData = {
|
||||||
"settings table not found in import file, skipping",
|
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(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const created = await credentialRepository.createEncryptedForUser(
|
||||||
|
userId,
|
||||||
|
credData,
|
||||||
|
);
|
||||||
|
credentialIds.set(cred.id, Number(created.id));
|
||||||
|
result.summary.sshCredentialsImported++;
|
||||||
|
} catch (credError) {
|
||||||
|
result.summary.errors.push(
|
||||||
|
`SSH credential import error: ${credError.message}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
apiLogger.info(
|
|
||||||
"Settings import skipped - only admin users can import settings",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
apiLogger.info(
|
||||||
|
"ssh_credentials table not found in import file, skipping",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
result.success = true;
|
try {
|
||||||
|
const importedHosts = importDb
|
||||||
try {
|
.prepare("SELECT * FROM ssh_data")
|
||||||
await DatabaseSaveTrigger.forceSave("database_import");
|
.all();
|
||||||
} catch (saveError) {
|
const importedHostIds: number[] = [];
|
||||||
apiLogger.error(
|
for (const host of importedHosts) {
|
||||||
"Failed to persist imported data to disk",
|
try {
|
||||||
saveError,
|
const hostRepository = createCurrentHostRepository();
|
||||||
{
|
const exists = await hostRepository.existsForImportIdentity(
|
||||||
operation: "import_force_save_failed",
|
|
||||||
userId,
|
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,
|
||||||
|
credentialId: credentialIds.get(host.credential_id) ?? null,
|
||||||
|
overrideCredentialUsername: Boolean(
|
||||||
|
host.override_credential_username,
|
||||||
|
),
|
||||||
|
jumpHosts: host.jump_hosts,
|
||||||
|
...legacyStatusCheck(host),
|
||||||
|
// Exports from before 2.9.0 carry these in terminal_config.
|
||||||
|
sshOptions:
|
||||||
|
host.ssh_options ??
|
||||||
|
sshOptionsForWrite({
|
||||||
|
terminalConfig: host.terminal_config,
|
||||||
|
}) ??
|
||||||
|
null,
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
|
||||||
|
(hostData as Record<string, unknown>).defaultOverrides =
|
||||||
|
JSON.stringify(
|
||||||
|
await applyHostDefaultsToWrite({
|
||||||
|
ownerId: userId,
|
||||||
|
hostId: null,
|
||||||
|
columns: hostData as Record<string, unknown>,
|
||||||
|
body: hostData as Record<string, unknown>,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const created = await hostRepository.createEncryptedForUser(
|
||||||
|
userId,
|
||||||
|
hostData,
|
||||||
|
);
|
||||||
|
await applyPluginHostImportSettings(
|
||||||
|
Number(created.id),
|
||||||
|
importedHostPluginSettings(importDb, host),
|
||||||
|
);
|
||||||
|
importedHostIds.push(Number(created.id));
|
||||||
|
await importHostProtocolLogins(
|
||||||
|
importDb,
|
||||||
|
host,
|
||||||
|
Number(created.id),
|
||||||
|
userId,
|
||||||
|
credentialIds,
|
||||||
|
);
|
||||||
|
result.summary.sshHostsImported++;
|
||||||
|
} catch (hostError) {
|
||||||
|
result.summary.errors.push(
|
||||||
|
`SSH host import error: ${hostError.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
await applyDefaultsAfterHostWrites(importedHostIds);
|
||||||
|
} catch {
|
||||||
|
apiLogger.info("ssh_data table not found in import file, skipping");
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginRows = await importUserPluginRows(importDb, userId);
|
||||||
|
result.summary.pluginItemsImported += pluginRows.imported;
|
||||||
|
result.summary.skippedItems += pluginRows.skipped;
|
||||||
|
result.summary.errors.push(...pluginRows.errors);
|
||||||
|
|
||||||
|
const hostDefaults = await importHostDefaults(importDb, userId);
|
||||||
|
result.summary.skippedItems += hostDefaults.skipped;
|
||||||
|
if (hostDefaults.imported > 0) {
|
||||||
|
await recompute({ userIds: [userId] });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
} finally {
|
||||||
if (importDb) {
|
if (importDb) {
|
||||||
importDb.close();
|
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({
|
res.json({
|
||||||
success: result.success,
|
success: result.success,
|
||||||
message: result.success
|
message: result.success
|
||||||
@@ -1472,17 +1462,6 @@ app.post(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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, {
|
apiLogger.error("SQLite import failed", error, {
|
||||||
operation: "sqlite_import_api_failed",
|
operation: "sqlite_import_api_failed",
|
||||||
userId: (req as AuthenticatedRequest).userId,
|
userId: (req as AuthenticatedRequest).userId,
|
||||||
@@ -1491,6 +1470,17 @@ app.post(
|
|||||||
error: "Failed to import SQLite data",
|
error: "Failed to import SQLite data",
|
||||||
details: getErrorMessage(error),
|
details: getErrorMessage(error),
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
if (req.file) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(req.file.path, { force: true });
|
||||||
|
} catch {
|
||||||
|
apiLogger.warn("Failed to clean up uploaded file", {
|
||||||
|
operation: "file_cleanup_warning",
|
||||||
|
filePath: req.file.path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -2012,14 +2002,15 @@ function legacyStatusCheck(row: Record<string, unknown>): {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A host's plugin protocol logins from an export. The file's credential ids
|
* A host's plugin protocol logins from an export. The file's credential ids
|
||||||
* belong to the server that wrote it, so a login keeps one only when it is
|
* belong to the server that wrote it, so a login keeps one only when it maps
|
||||||
* a credential the importing user can use.
|
* to a credential imported alongside it.
|
||||||
*/
|
*/
|
||||||
async function importHostProtocolLogins(
|
async function importHostProtocolLogins(
|
||||||
importDb: Database.Database,
|
importDb: Database.Database,
|
||||||
host: Record<string, unknown>,
|
host: Record<string, unknown>,
|
||||||
hostId: number,
|
hostId: number,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
credentialIds: Map<number, number>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let rows: Array<Record<string, unknown>>;
|
let rows: Array<Record<string, unknown>>;
|
||||||
try {
|
try {
|
||||||
@@ -2040,7 +2031,7 @@ async function importHostProtocolLogins(
|
|||||||
}
|
}
|
||||||
protocolAuth[String(row.protocol)] = {
|
protocolAuth[String(row.protocol)] = {
|
||||||
authType: row.auth_type,
|
authType: row.auth_type,
|
||||||
credentialId: row.credential_id,
|
credentialId: credentialIds.get(row.credential_id as number) ?? null,
|
||||||
username: row.username,
|
username: row.username,
|
||||||
password: row.password,
|
password: row.password,
|
||||||
fields,
|
fields,
|
||||||
|
|||||||
@@ -925,6 +925,38 @@ const dropRetiredColumns = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Registration checked for a taken username and then inserted with nothing
|
||||||
|
// enforcing it, so concurrent sign-ups could create two accounts with one
|
||||||
|
// name, and every lookup by username picked between them arbitrarily. Later
|
||||||
|
// duplicates are renamed rather than deleted, so no account is lost.
|
||||||
|
const enforceUniqueUsernames = () => {
|
||||||
|
try {
|
||||||
|
sqlite.exec(`
|
||||||
|
WITH ranked AS MATERIALIZED (
|
||||||
|
SELECT id, username,
|
||||||
|
ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence
|
||||||
|
FROM users
|
||||||
|
), candidates AS (
|
||||||
|
SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked
|
||||||
|
), available AS (
|
||||||
|
SELECT MIN(candidate) AS candidate FROM candidates
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM ranked WHERE username LIKE 'termix-recovered-' || candidate || '-' || '%'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
UPDATE users SET username = 'termix-recovered-' || (SELECT candidate FROM available) || '-' || ranked.sequence
|
||||||
|
FROM ranked WHERE ranked.id = users.id AND ranked.occurrence > 1;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users (username);
|
||||||
|
`);
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.warn("Failed to enforce unique usernames", {
|
||||||
|
operation: "schema_migration",
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const migrateSchema = () => {
|
const migrateSchema = () => {
|
||||||
repairSnippetsColumns(sqlite);
|
repairSnippetsColumns(sqlite);
|
||||||
addColumnIfNotExists("user_preferences", "theme", "TEXT");
|
addColumnIfNotExists("user_preferences", "theme", "TEXT");
|
||||||
@@ -996,6 +1028,7 @@ const migrateSchema = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
enforceUniqueUsernames();
|
||||||
addColumnIfNotExists("sessions", "external_session_ref", "TEXT");
|
addColumnIfNotExists("sessions", "external_session_ref", "TEXT");
|
||||||
|
|
||||||
addColumnIfNotExists("ssh_data", "name", "TEXT");
|
addColumnIfNotExists("ssh_data", "name", "TEXT");
|
||||||
@@ -1893,6 +1926,23 @@ async function moveRemoteLegacySudoPasswords(remote: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function dropMysqlForeignKeys(
|
||||||
|
remote: { execute: (query: SQL) => Promise<unknown> },
|
||||||
|
table: string,
|
||||||
|
column: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const keys = resultRows<{ name: string }>(
|
||||||
|
await remote.execute(
|
||||||
|
sql`SELECT CONSTRAINT_NAME AS name FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ${table} AND COLUMN_NAME = ${column} AND REFERENCED_TABLE_NAME IS NOT NULL`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const key of keys) {
|
||||||
|
await remote.execute(
|
||||||
|
sql`ALTER TABLE ${sql.identifier(table)} DROP FOREIGN KEY ${sql.identifier(key.name)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The columns 2.9.0 left in place without a drizzle migration, so drizzle no
|
* The columns 2.9.0 left in place without a drizzle migration, so drizzle no
|
||||||
* longer knows them. Dropped by hand, only where they still exist.
|
* longer knows them. Dropped by hand, only where they still exist.
|
||||||
@@ -1928,6 +1978,10 @@ async function dropRemoteRetired(
|
|||||||
for (const column of columns) {
|
for (const column of columns) {
|
||||||
if (!existing.has(`${table}.${column}`)) continue;
|
if (!existing.has(`${table}.${column}`)) continue;
|
||||||
try {
|
try {
|
||||||
|
// Postgres drops a column's foreign keys with it; MySQL refuses.
|
||||||
|
if (dialect === "mysql") {
|
||||||
|
await dropMysqlForeignKeys(remote, table, column);
|
||||||
|
}
|
||||||
await remote.execute(
|
await remote.execute(
|
||||||
sql`ALTER TABLE ${sql.identifier(table)} DROP COLUMN ${sql.identifier(column)}`,
|
sql`ALTER TABLE ${sql.identifier(table)} DROP COLUMN ${sql.identifier(column)}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,8 +5,11 @@
|
|||||||
*
|
*
|
||||||
* Kept on purpose: columns a move only reads once the owner's data key opens
|
* Kept on purpose: columns a move only reads once the owner's data key opens
|
||||||
* at a password login (users.totp_*, the rdp, vnc and telnet logins and
|
* at a password login (users.totp_*, the rdp, vnc and telnet logins and
|
||||||
* domain), so a user who never signed in to 2.9 still gets them, and
|
* domain), so a user who never signed in to 2.9 still gets them.
|
||||||
* ssh_data.vault_profile_id, whose foreign key SQLite cannot drop in place.
|
*
|
||||||
|
* ssh_data.vault_profile_id goes because its foreign key follows the vault
|
||||||
|
* plugin's rename of vault_profiles: once the plugin is uninstalled, SQLite
|
||||||
|
* refuses every write to ssh_data and the other engines refuse the uninstall.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Columns drizzle never knew were removed, dropped by hand on every engine. */
|
/** Columns drizzle never knew were removed, dropped by hand on every engine. */
|
||||||
@@ -52,6 +55,7 @@ export const REMOTE_RETIRED_COLUMNS: Record<string, string[]> = {
|
|||||||
"telnet_port",
|
"telnet_port",
|
||||||
"tunnel_connections",
|
"tunnel_connections",
|
||||||
"use_warpgate",
|
"use_warpgate",
|
||||||
|
"vault_profile_id",
|
||||||
"vnc_port",
|
"vnc_port",
|
||||||
"web_ui_config",
|
"web_ui_config",
|
||||||
"wol_broadcast_address",
|
"wol_broadcast_address",
|
||||||
|
|||||||
@@ -21,13 +21,19 @@ import {
|
|||||||
} from "drizzle-orm/mysql-core";
|
} from "drizzle-orm/mysql-core";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
export const users = mysqlTable("users", {
|
export const users = mysqlTable(
|
||||||
id: varchar("id", { length: 255 }).primaryKey(),
|
"users",
|
||||||
username: text("username").notNull(),
|
{
|
||||||
passwordHash: text("password_hash").notNull(),
|
id: varchar("id", { length: 255 }).primaryKey(),
|
||||||
isAdmin: boolean("is_admin").notNull().default(false),
|
username: varchar("username", { length: 255 }).notNull(),
|
||||||
registeredAt: text("registered_at").notNull().default(sql`(CURRENT_TIMESTAMP)`),
|
passwordHash: text("password_hash").notNull(),
|
||||||
});
|
isAdmin: boolean("is_admin").notNull().default(false),
|
||||||
|
registeredAt: text("registered_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||||
|
},
|
||||||
|
(table) => [uniqueIndex("idx_users_username").on(table.username)],
|
||||||
|
);
|
||||||
|
|
||||||
export const settings = mysqlTable("settings", {
|
export const settings = mysqlTable("settings", {
|
||||||
key: varchar("key", { length: 255 }).primaryKey(),
|
key: varchar("key", { length: 255 }).primaryKey(),
|
||||||
|
|||||||
@@ -21,13 +21,19 @@ import {
|
|||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
export const users = pgTable("users", {
|
export const users = pgTable(
|
||||||
id: varchar("id", { length: 255 }).primaryKey(),
|
"users",
|
||||||
username: text("username").notNull(),
|
{
|
||||||
passwordHash: text("password_hash").notNull(),
|
id: varchar("id", { length: 255 }).primaryKey(),
|
||||||
isAdmin: boolean("is_admin").notNull().default(false),
|
username: varchar("username", { length: 255 }).notNull(),
|
||||||
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
passwordHash: text("password_hash").notNull(),
|
||||||
});
|
isAdmin: boolean("is_admin").notNull().default(false),
|
||||||
|
registeredAt: text("registered_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
},
|
||||||
|
(table) => [uniqueIndex("idx_users_username").on(table.username)],
|
||||||
|
);
|
||||||
|
|
||||||
export const settings = pgTable("settings", {
|
export const settings = pgTable("settings", {
|
||||||
key: varchar("key", { length: 255 }).primaryKey(),
|
key: varchar("key", { length: 255 }).primaryKey(),
|
||||||
|
|||||||
@@ -9,13 +9,19 @@ import {
|
|||||||
} from "drizzle-orm/sqlite-core";
|
} from "drizzle-orm/sqlite-core";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
export const users = sqliteTable("users", {
|
export const users = sqliteTable(
|
||||||
id: text("id").primaryKey(),
|
"users",
|
||||||
username: text("username").notNull(),
|
{
|
||||||
passwordHash: text("password_hash").notNull(),
|
id: text("id").primaryKey(),
|
||||||
isAdmin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
|
username: text("username").notNull(),
|
||||||
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
passwordHash: text("password_hash").notNull(),
|
||||||
});
|
isAdmin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
|
||||||
|
registeredAt: text("registered_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
},
|
||||||
|
(table) => [uniqueIndex("idx_users_username").on(table.username)],
|
||||||
|
);
|
||||||
|
|
||||||
export const settings = sqliteTable("settings", {
|
export const settings = sqliteTable("settings", {
|
||||||
key: text("key").primaryKey(),
|
key: text("key").primaryKey(),
|
||||||
|
|||||||
@@ -103,11 +103,11 @@ export class CredentialRepository {
|
|||||||
.orderBy(desc(sshCredentials.updatedAt));
|
.orderBy(desc(sshCredentials.updatedAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
async existsForImportIdentity(
|
async findIdForImportIdentity(
|
||||||
userId: string,
|
userId: string,
|
||||||
name: string,
|
name: string,
|
||||||
username: string | null,
|
username: string | null,
|
||||||
): Promise<boolean> {
|
): Promise<number | null> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await this.context.drizzle
|
||||||
.select({ id: sshCredentials.id })
|
.select({ id: sshCredentials.id })
|
||||||
.from(sshCredentials)
|
.from(sshCredentials)
|
||||||
@@ -120,7 +120,7 @@ export class CredentialRepository {
|
|||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
return rows.length > 0;
|
return rows[0]?.id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findDecryptedByIdForUser(
|
async findDecryptedByIdForUser(
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import { getCurrentRepositorySqlite } from "./factory.js";
|
|
||||||
import { needsExplicitPersist, resolveDatabaseDialect } from "../db/dialect.js";
|
|
||||||
|
|
||||||
export interface SqliteForeignKeyClient {
|
|
||||||
exec(sql: string): unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function withSqliteForeignKeysDisabled<T>(
|
|
||||||
sqlite: SqliteForeignKeyClient,
|
|
||||||
operation: () => Promise<T>,
|
|
||||||
): Promise<T> {
|
|
||||||
sqlite.exec("PRAGMA foreign_keys = OFF");
|
|
||||||
try {
|
|
||||||
return await operation();
|
|
||||||
} finally {
|
|
||||||
sqlite.exec("PRAGMA foreign_keys = ON");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs a bulk import with foreign keys relaxed.
|
|
||||||
*
|
|
||||||
* Backup restore writes tables in an order that is not dependency-safe, so the
|
|
||||||
* constraints have to stand down for the duration.
|
|
||||||
*
|
|
||||||
* Postgres and MySQL keep their constraints enabled. The portable importer
|
|
||||||
* writes through repositories and handles individual row failures, so it must
|
|
||||||
* still be allowed to run there; only SQLite needs this connection-local
|
|
||||||
* relaxation for legacy backups whose rows are not dependency ordered.
|
|
||||||
*/
|
|
||||||
export async function withCurrentSqliteForeignKeysDisabled<T>(
|
|
||||||
operation: () => Promise<T>,
|
|
||||||
): Promise<T> {
|
|
||||||
const dialect = resolveDatabaseDialect();
|
|
||||||
if (!needsExplicitPersist(dialect)) {
|
|
||||||
return operation();
|
|
||||||
}
|
|
||||||
|
|
||||||
return withSqliteForeignKeysDisabled(getCurrentRepositorySqlite(), operation);
|
|
||||||
}
|
|
||||||
@@ -24,6 +24,27 @@ const userColumns = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The unique-violation codes of better-sqlite3, node-postgres and mysql2.
|
||||||
|
const UNIQUE_VIOLATION_CODES = new Set([
|
||||||
|
"SQLITE_CONSTRAINT_UNIQUE",
|
||||||
|
"23505",
|
||||||
|
"ER_DUP_ENTRY",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether creating a user failed because the username is taken. The id is
|
||||||
|
* generated, so the username index is the only one a new row can collide on.
|
||||||
|
* drizzle wraps driver errors, so the code may sit on a cause.
|
||||||
|
*/
|
||||||
|
export function isUsernameTaken(error: unknown): boolean {
|
||||||
|
for (let e = error; e instanceof Error; e = e.cause) {
|
||||||
|
if (UNIQUE_VIOLATION_CODES.has((e as { code?: string }).code ?? "")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export class UserRepository {
|
export class UserRepository {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly context: DatabaseContext,
|
private readonly context: DatabaseContext,
|
||||||
|
|||||||
@@ -243,7 +243,9 @@ export async function listPreLoginPlugins(): Promise<
|
|||||||
* frontends in the same order the server does.
|
* frontends in the same order the server does.
|
||||||
*
|
*
|
||||||
* On a desktop linked to a server, `runsOnServer` marks plugins that
|
* On a desktop linked to a server, `runsOnServer` marks plugins that
|
||||||
* only run on that server and stay off here on purpose.
|
* only run on that server and stay off here on purpose, and, for
|
||||||
|
* admins, `managedByServer` marks plugins whose on/off state the server
|
||||||
|
* decides.
|
||||||
* tags:
|
* tags:
|
||||||
* - Plugins
|
* - Plugins
|
||||||
* responses:
|
* responses:
|
||||||
@@ -370,6 +372,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
features,
|
features,
|
||||||
env,
|
env,
|
||||||
signedBy: loaded?.signedBy ?? null,
|
signedBy: loaded?.signedBy ?? null,
|
||||||
|
managedByServer: linked && (await isManagedByLinkedServer(record.id)),
|
||||||
capabilities,
|
capabilities,
|
||||||
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
|
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
|
||||||
lastError: loaded?.lastError ?? record.lastError ?? null,
|
lastError: loaded?.lastError ?? record.lastError ?? null,
|
||||||
@@ -393,9 +396,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function isManagedByLinkedServer(pluginId: string): Promise<boolean> {
|
async function isManagedByLinkedServer(pluginId: string): Promise<boolean> {
|
||||||
if (process.env.ELECTRON_EMBEDDED !== "true") return false;
|
if (!(await isLinkedDesktop())) return false;
|
||||||
const { getLink } = await import("../../sync/client/link-store.js");
|
|
||||||
if (!(await getLink())) return false;
|
|
||||||
const { isServerManaged } = await import("../../sync/client/plugins.js");
|
const { isServerManaged } = await import("../../sync/client/plugins.js");
|
||||||
return isServerManaged(pluginId);
|
return isServerManaged(pluginId);
|
||||||
}
|
}
|
||||||
@@ -500,7 +501,7 @@ router.get(
|
|||||||
* /plugins/onboarding/apply:
|
* /plugins/onboarding/apply:
|
||||||
* post:
|
* post:
|
||||||
* summary: Apply the onboarding plugin picker
|
* summary: Apply the onboarding plugin picker
|
||||||
* description: Sets every listed plugin to enabled, disabled or removed in one pass. Choices are first raised so that every kept plugin still has its hard dependencies (the adjustments list says which and why). Enabling runs dependencies first, disabling and removing run dependents first, and one plugin failing does not stop the rest. Removing a bundled plugin deletes it and its data; it can be reinstalled from the registry later. Clears the fresh-install pending flag. With dryRun nothing changes and the response lists what would.
|
* description: Sets every listed plugin to enabled, disabled or removed in one pass. Choices are first raised so that every kept plugin still has its hard dependencies (the adjustments list says which and why). Enabling runs dependencies first, disabling and removing run dependents first, and one plugin failing does not stop the rest. Removing a bundled plugin deletes it and its data; it can be reinstalled from the registry later. Clears the fresh-install pending flag. With dryRun nothing changes and the response lists what would. On a linked desktop, locally managed plugins require version and capabilities matching the reviewed manifest when enabling.
|
||||||
* tags:
|
* tags:
|
||||||
* - Plugins
|
* - Plugins
|
||||||
* requestBody:
|
* requestBody:
|
||||||
@@ -887,7 +888,7 @@ router.delete(
|
|||||||
* /plugins/{id}/state:
|
* /plugins/{id}/state:
|
||||||
* patch:
|
* patch:
|
||||||
* summary: Enable or disable a plugin
|
* summary: Enable or disable a plugin
|
||||||
* description: Persists the new state and starts or stops the plugin immediately. Disabling releases everything the plugin owns, including any port it was listening on. Enabling also starts any installed hard dependency that is off, and disabling also stops the running plugins that depend on it. With dryRun nothing changes and the response lists what would.
|
* description: Persists the new state and starts or stops the plugin immediately. Disabling releases everything the plugin owns, including any port it was listening on. Enabling also starts any installed hard dependency that is off, and disabling also stops the running plugins that depend on it. With dryRun nothing changes and the response lists what would. On a linked desktop, locally managed plugins require version and capabilities matching the reviewed manifest when enabling.
|
||||||
* tags:
|
* tags:
|
||||||
* - Plugins
|
* - Plugins
|
||||||
* parameters:
|
* parameters:
|
||||||
@@ -909,6 +910,12 @@ router.delete(
|
|||||||
* properties:
|
* properties:
|
||||||
* enabled:
|
* enabled:
|
||||||
* type: boolean
|
* type: boolean
|
||||||
|
* version:
|
||||||
|
* type: string
|
||||||
|
* capabilities:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: The plugin's new state.
|
* description: The plugin's new state.
|
||||||
@@ -962,7 +969,26 @@ router.patch(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await setPluginState(pluginId, enabled);
|
let result;
|
||||||
|
if (enabled && (await isLinkedDesktop())) {
|
||||||
|
const version = readVersion(req.body);
|
||||||
|
const capabilities = readCapabilities(req.body);
|
||||||
|
if (version === null || capabilities === null) {
|
||||||
|
res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Invalid plugin version or capabilities" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { enablePluginWithConsent } =
|
||||||
|
await import("../../plugins/manage.js");
|
||||||
|
result = await enablePluginWithConsent(pluginId, {
|
||||||
|
version,
|
||||||
|
capabilities,
|
||||||
|
userId: (req as AuthenticatedRequest).userId as string,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
result = await setPluginState(pluginId, enabled);
|
||||||
|
}
|
||||||
await auditPluginAction(
|
await auditPluginAction(
|
||||||
req,
|
req,
|
||||||
enabled ? "enable_plugin" : "disable_plugin",
|
enabled ? "enable_plugin" : "disable_plugin",
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ import {
|
|||||||
listUserSecondFactors,
|
listUserSecondFactors,
|
||||||
resetUserSecondFactors,
|
resetUserSecondFactors,
|
||||||
} from "../../auth/second-factor-admin.js";
|
} from "../../auth/second-factor-admin.js";
|
||||||
import type {
|
import {
|
||||||
UserRecord,
|
isUsernameTaken,
|
||||||
UserRepository,
|
type UserRecord,
|
||||||
|
type UserRepository,
|
||||||
} from "../repositories/user-repository.js";
|
} from "../repositories/user-repository.js";
|
||||||
|
|
||||||
function isNonEmptyString(val: unknown): val is string {
|
function isNonEmptyString(val: unknown): val is string {
|
||||||
@@ -541,6 +542,9 @@ export function registerUserAdminRoutes(
|
|||||||
toast: { type: "success", message: `User created: ${username}` },
|
toast: { type: "success", message: `User created: ${username}` },
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (isUsernameTaken(err)) {
|
||||||
|
return res.status(409).json({ error: "Username already exists" });
|
||||||
|
}
|
||||||
authLogger.error("Failed to admin-create user", err);
|
authLogger.error("Failed to admin-create user", err);
|
||||||
res.status(500).json({ error: "Failed to create user" });
|
res.status(500).json({ error: "Failed to create user" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,9 +41,13 @@ import {
|
|||||||
createCurrentUserAuthRepository,
|
createCurrentUserAuthRepository,
|
||||||
createCurrentUserRepository,
|
createCurrentUserRepository,
|
||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
import type { UserRecord } from "../repositories/user-repository.js";
|
import {
|
||||||
|
isUsernameTaken,
|
||||||
|
type UserRecord,
|
||||||
|
} from "../repositories/user-repository.js";
|
||||||
import {
|
import {
|
||||||
getTrustedProxyAuthConfig,
|
getTrustedProxyAuthConfig,
|
||||||
|
getTrustedProxySourceAddress,
|
||||||
isTrustedProxyAddress,
|
isTrustedProxyAddress,
|
||||||
isTrustedProxyAuthEnabled,
|
isTrustedProxyAuthEnabled,
|
||||||
resolveTrustedProxyRoles,
|
resolveTrustedProxyRoles,
|
||||||
@@ -264,6 +268,10 @@ router.post("/create", async (req, res) => {
|
|||||||
toast: { type: "success", message: `User created: ${username}` },
|
toast: { type: "success", message: `User created: ${username}` },
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// Lost a race with a concurrent sign-up for the same name.
|
||||||
|
if (isUsernameTaken(err)) {
|
||||||
|
return res.status(409).json({ error: "Username already exists" });
|
||||||
|
}
|
||||||
authLogger.error("Failed to create user", err);
|
authLogger.error("Failed to create user", err);
|
||||||
res.status(500).json({ error: "Failed to create user" });
|
res.status(500).json({ error: "Failed to create user" });
|
||||||
}
|
}
|
||||||
@@ -304,7 +312,7 @@ router.post("/proxy-login", async (req, res) => {
|
|||||||
}
|
}
|
||||||
if (!config.enabled) return res.json({ enabled: false });
|
if (!config.enabled) return res.json({ enabled: false });
|
||||||
|
|
||||||
const sourceAddress = req.socket.remoteAddress;
|
const sourceAddress = getTrustedProxySourceAddress(req);
|
||||||
try {
|
try {
|
||||||
if (!isTrustedProxyAddress(sourceAddress, config.trustedProxies)) {
|
if (!isTrustedProxyAddress(sourceAddress, config.trustedProxies)) {
|
||||||
authLogger.warn(
|
authLogger.warn(
|
||||||
|
|||||||
@@ -263,28 +263,6 @@ export class SSHHostKeyVerifier {
|
|||||||
changeCount: host.hostKeyChangedCount || 0,
|
changeCount: host.hostKeyChangedCount || 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isJumpHost) {
|
|
||||||
await this.updateHostKey(
|
|
||||||
hostId,
|
|
||||||
fingerprint,
|
|
||||||
keyType,
|
|
||||||
algorithm,
|
|
||||||
host.hostKeyChangedCount || 0,
|
|
||||||
);
|
|
||||||
sshLogger.warn("Jump host key changed - auto-accepted", {
|
|
||||||
operation: "host_key_updated",
|
|
||||||
hostId,
|
|
||||||
ip,
|
|
||||||
port,
|
|
||||||
fingerprint,
|
|
||||||
keyType,
|
|
||||||
userId,
|
|
||||||
isJumpHost: true,
|
|
||||||
});
|
|
||||||
verify(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ws) {
|
if (!ws) {
|
||||||
sshLogger.error(
|
sshLogger.error(
|
||||||
"Host key changed - please connect via Terminal to verify the new key",
|
"Host key changed - please connect via Terminal to verify the new key",
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ export class SSHConnectionPool {
|
|||||||
private async createPooledClient(
|
private async createPooledClient(
|
||||||
key: string,
|
key: string,
|
||||||
factory: () => Promise<Client>,
|
factory: () => Promise<Client>,
|
||||||
existing: PooledConnection[],
|
|
||||||
): Promise<Client> {
|
): Promise<Client> {
|
||||||
const generation = this.generations.get(key) || 0;
|
const generation = this.generations.get(key) || 0;
|
||||||
this.pendingConnections.set(
|
this.pendingConnections.set(
|
||||||
@@ -109,8 +108,11 @@ export class SSHConnectionPool {
|
|||||||
inUse: true,
|
inUse: true,
|
||||||
hostKey: key,
|
hostKey: key,
|
||||||
};
|
};
|
||||||
existing.push(pooled);
|
// Re-read after the await: concurrent connects and removals may have
|
||||||
this.connections.set(key, existing);
|
// replaced this key's array in the meantime.
|
||||||
|
const connections = this.connections.get(key) ?? [];
|
||||||
|
connections.push(pooled);
|
||||||
|
this.connections.set(key, connections);
|
||||||
|
|
||||||
client.on("end", () => {
|
client.on("end", () => {
|
||||||
this.removeConnection(key, client);
|
this.removeConnection(key, client);
|
||||||
@@ -187,7 +189,7 @@ export class SSHConnectionPool {
|
|||||||
if (queue.length === 0) this.waiters.delete(key);
|
if (queue.length === 0) this.waiters.delete(key);
|
||||||
else this.waiters.set(key, queue);
|
else this.waiters.set(key, queue);
|
||||||
clearTimeout(waiter.timer);
|
clearTimeout(waiter.timer);
|
||||||
this.createPooledClient(key, waiter.factory, connections)
|
this.createPooledClient(key, waiter.factory)
|
||||||
.then(waiter.resolve)
|
.then(waiter.resolve)
|
||||||
.catch(waiter.reject);
|
.catch(waiter.reject);
|
||||||
return;
|
return;
|
||||||
@@ -225,7 +227,7 @@ export class SSHConnectionPool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.hasCapacity(key, connections)) {
|
if (this.hasCapacity(key, connections)) {
|
||||||
return this.createPooledClient(key, factory, connections);
|
return this.createPooledClient(key, factory);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.enqueueWaiter(key, factory);
|
return this.enqueueWaiter(key, factory);
|
||||||
|
|||||||
@@ -1,50 +1,59 @@
|
|||||||
|
import path from "node:path";
|
||||||
import zlib from "node:zlib";
|
import zlib from "node:zlib";
|
||||||
|
import * as tar from "tar";
|
||||||
|
|
||||||
const MAX_UNPACKED_BYTES = 512 * 1024 * 1024;
|
const MAX_UNPACKED_BYTES = 512 * 1024 * 1024;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads one file out of a .tmxplug (a gzipped tar) without unpacking it to
|
* Reads every copy of one file out of a .tmxplug (a gzipped tar) without
|
||||||
* disk. Returns null when the archive has no such file.
|
* unpacking it to disk. Uses the same tar parser as the loader's unpack, so
|
||||||
|
* pax and long-name headers resolve the same way, and only plain files count
|
||||||
|
* because they are the only kind the unpack writes. Names compare
|
||||||
|
* case-insensitively since a case-insensitive disk folds them together.
|
||||||
*/
|
*/
|
||||||
export function readArtifactFile(
|
export function readArtifactFiles(buffer: Buffer, wanted: string): Buffer[] {
|
||||||
buffer: Buffer,
|
const archive = zlib.gunzipSync(buffer, {
|
||||||
wanted: string,
|
maxOutputLength: MAX_UNPACKED_BYTES,
|
||||||
): Buffer | null {
|
});
|
||||||
const tar = zlib.gunzipSync(buffer, { maxOutputLength: MAX_UNPACKED_BYTES });
|
const key = wanted.toLowerCase();
|
||||||
let offset = 0;
|
const found: Buffer[] = [];
|
||||||
while (offset + 512 <= tar.length) {
|
const parser = new tar.Parser({
|
||||||
const header = tar.subarray(offset, offset + 512);
|
strict: true,
|
||||||
if (header.every((byte) => byte === 0)) break;
|
onReadEntry: (entry) => {
|
||||||
const field = (start: number, length: number) =>
|
const name = path.posix.normalize(entry.path.replace(/\\/g, "/"));
|
||||||
header
|
if (entry.type !== "File" || name.toLowerCase() !== key) {
|
||||||
.subarray(start, start + length)
|
entry.resume();
|
||||||
.toString("utf8")
|
return;
|
||||||
.replace(/\0.*$/s, "");
|
}
|
||||||
const prefix = field(345, 155);
|
const chunks: Buffer[] = [];
|
||||||
const name = (
|
entry.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||||
prefix ? `${prefix}/${field(0, 100)}` : field(0, 100)
|
entry.on("end", () => found.push(Buffer.concat(chunks)));
|
||||||
).replace(/^\.\//, "");
|
},
|
||||||
const size = Number.parseInt(field(124, 12).trim() || "0", 8);
|
});
|
||||||
if (!Number.isFinite(size) || size < 0) break;
|
// The parser runs synchronously on an in-memory buffer.
|
||||||
const start = offset + 512;
|
parser.end(archive);
|
||||||
if (name === wanted) return tar.subarray(start, start + size);
|
return found;
|
||||||
offset = start + Math.ceil(size / 512) * 512;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The manifest.json packed in a .tmxplug, parsed but not validated. */
|
/**
|
||||||
|
* The manifest.json packed in a .tmxplug, parsed but not validated. An
|
||||||
|
* archive with more than one is refused: the unpack keeps the last copy, so
|
||||||
|
* whichever one this read the other could carry different capabilities.
|
||||||
|
*/
|
||||||
export function readArtifactManifest(buffer: Buffer): Record<string, unknown> {
|
export function readArtifactManifest(buffer: Buffer): Record<string, unknown> {
|
||||||
let raw: Buffer | null;
|
let copies: Buffer[];
|
||||||
try {
|
try {
|
||||||
raw = readArtifactFile(buffer, "manifest.json");
|
copies = readArtifactFiles(buffer, "manifest.json");
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error("the file is not a valid .tmxplug archive");
|
throw new Error("the file is not a valid .tmxplug archive");
|
||||||
}
|
}
|
||||||
if (!raw) throw new Error("the archive has no manifest.json");
|
if (copies.length === 0) throw new Error("the archive has no manifest.json");
|
||||||
|
if (copies.length > 1) {
|
||||||
|
throw new Error("the archive has more than one manifest.json");
|
||||||
|
}
|
||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(raw.toString("utf8"));
|
parsed = JSON.parse(copies[0].toString("utf8"));
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error("manifest.json is not valid JSON");
|
throw new Error("manifest.json is not valid JSON");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,9 +42,16 @@ const routers = new Map<string, Router>();
|
|||||||
* Requests per minute one address may make to one plugin's public routes.
|
* Requests per minute one address may make to one plugin's public routes.
|
||||||
* They run without a login, so they are the cheap way to hammer a plugin.
|
* They run without a login, so they are the cheap way to hammer a plugin.
|
||||||
*/
|
*/
|
||||||
function publicRateLimit(): number {
|
const MAX_PLUGIN_PUBLIC_RATE_LIMIT = 10_000;
|
||||||
|
|
||||||
|
/** The admin's limit when set, else the plugin's own, else 120. */
|
||||||
|
function publicRateLimit(pluginLimit?: number): number {
|
||||||
const parsed = Number(process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT);
|
const parsed = Number(process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT);
|
||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 120;
|
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||||
|
if (Number.isFinite(pluginLimit) && (pluginLimit as number) > 0) {
|
||||||
|
return Math.min(pluginLimit as number, MAX_PLUGIN_PUBLIC_RATE_LIMIT);
|
||||||
|
}
|
||||||
|
return 120;
|
||||||
}
|
}
|
||||||
const PUBLIC_RATE_WINDOW_MS = 60_000;
|
const PUBLIC_RATE_WINDOW_MS = 60_000;
|
||||||
const publicHits = new Map<string, { count: number; resetAt: number }>();
|
const publicHits = new Map<string, { count: number; resetAt: number }>();
|
||||||
@@ -54,6 +61,7 @@ export function overPublicRateLimit(
|
|||||||
pluginId: string,
|
pluginId: string,
|
||||||
ip: string,
|
ip: string,
|
||||||
now = Date.now(),
|
now = Date.now(),
|
||||||
|
pluginLimit?: number,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (publicHits.size > 10_000) {
|
if (publicHits.size > 10_000) {
|
||||||
for (const [hitKey, entry] of publicHits) {
|
for (const [hitKey, entry] of publicHits) {
|
||||||
@@ -67,7 +75,7 @@ export function overPublicRateLimit(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
entry.count++;
|
entry.count++;
|
||||||
return entry.count > publicRateLimit();
|
return entry.count > publicRateLimit(pluginLimit);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test seam. */
|
/** Test seam. */
|
||||||
@@ -319,7 +327,9 @@ export function createPluginRouter({
|
|||||||
outer.use((req: Request, res: Response, next: NextFunction) => {
|
outer.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
if (isPublicRequest(req, publicPaths, inner)) {
|
if (isPublicRequest(req, publicPaths, inner)) {
|
||||||
const ip = req.ip || req.socket?.remoteAddress || "unknown";
|
const ip = req.ip || req.socket?.remoteAddress || "unknown";
|
||||||
if (overPublicRateLimit(pluginId, ip)) {
|
if (
|
||||||
|
overPublicRateLimit(pluginId, ip, Date.now(), options?.publicRateLimit)
|
||||||
|
) {
|
||||||
res.status(429).json({ error: "Too many requests" });
|
res.status(429).json({ error: "Too many requests" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -715,6 +715,28 @@ export async function planStateChange(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A local admin consents to the exact mirrored version before granting it access. */
|
||||||
|
export function enablePluginWithConsent(
|
||||||
|
pluginId: string,
|
||||||
|
options: { version?: string; capabilities?: string[]; userId: string },
|
||||||
|
): Promise<StateChangePlan & { state: string }> {
|
||||||
|
return serialized(async () => {
|
||||||
|
const plugin = getPluginRuntime().loader.get(pluginId);
|
||||||
|
if (!plugin)
|
||||||
|
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
|
||||||
|
if (options.version !== plugin.manifest.version) {
|
||||||
|
throw new PluginManageError(
|
||||||
|
"The plugin version changed; review it again",
|
||||||
|
409,
|
||||||
|
"CONSENT_MISMATCH",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertConsent(options.capabilities, plugin.manifest.capabilities);
|
||||||
|
await grantDeclared(plugin, options.userId);
|
||||||
|
return setPluginStateUnlocked(pluginId, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function setPluginState(
|
export function setPluginState(
|
||||||
pluginId: string,
|
pluginId: string,
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ import {
|
|||||||
type RegistrySource,
|
type RegistrySource,
|
||||||
} from "./registry-index.js";
|
} from "./registry-index.js";
|
||||||
import {
|
import {
|
||||||
|
COMMUNITY_PLUGIN_KEYS,
|
||||||
|
TRUSTED_PLUGIN_KEYS,
|
||||||
|
getCustomPluginKeys,
|
||||||
isPluginPublicKey,
|
isPluginPublicKey,
|
||||||
pluginKeyId,
|
pluginKeyId,
|
||||||
setCustomPluginKeys,
|
setCustomPluginKeys,
|
||||||
@@ -28,6 +31,8 @@ import {
|
|||||||
const CONFIG_KEY = "plugin_registries";
|
const CONFIG_KEY = "plugin_registries";
|
||||||
/** Keys a linked desktop copied from its server, so mirrored plugins load. */
|
/** Keys a linked desktop copied from its server, so mirrored plugins load. */
|
||||||
const SYNCED_KEYS_KEY = "plugin_registry_synced_keys";
|
const SYNCED_KEYS_KEY = "plugin_registry_synced_keys";
|
||||||
|
/** `addedIn` of a key only the linked server vouches for. */
|
||||||
|
const SYNCED = "synced";
|
||||||
const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
||||||
const MAX_ID_LENGTH = 40;
|
const MAX_ID_LENGTH = 40;
|
||||||
const MAX_NAME_LENGTH = 80;
|
const MAX_NAME_LENGTH = 80;
|
||||||
@@ -117,8 +122,8 @@ async function writeRegistriesConfig(config: RegistriesConfig): Promise<void> {
|
|||||||
await applyCustomKeys(config);
|
await applyCustomKeys(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toKey(publicKey: string): TrustedPluginKey {
|
function toKey(publicKey: string, addedIn = "admin"): TrustedPluginKey {
|
||||||
return { id: pluginKeyId(publicKey), publicKey, addedIn: "admin" };
|
return { id: pluginKeyId(publicKey), publicKey, addedIn };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readSyncedKeys(): Promise<string[]> {
|
async function readSyncedKeys(): Promise<string[]> {
|
||||||
@@ -134,19 +139,32 @@ async function readSyncedKeys(): Promise<string[]> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Trusts the key of every custom registry, on or off: a plugin installed
|
* Trusts the key of every custom registry, on or off: a plugin installed
|
||||||
* from a registry that was later turned off still has to load.
|
* from a registry that was later turned off still has to load. A key added
|
||||||
|
* here wins over the same key synced from a server.
|
||||||
*/
|
*/
|
||||||
async function applyCustomKeys(config: RegistriesConfig): Promise<void> {
|
async function applyCustomKeys(config: RegistriesConfig): Promise<void> {
|
||||||
const keys = new Map<string, TrustedPluginKey>();
|
const keys = new Map<string, TrustedPluginKey>();
|
||||||
|
for (const publicKey of await readSyncedKeys()) {
|
||||||
|
keys.set(pluginKeyId(publicKey), toKey(publicKey, SYNCED));
|
||||||
|
}
|
||||||
for (const entry of config.custom) {
|
for (const entry of config.custom) {
|
||||||
keys.set(entry.keyId, toKey(entry.publicKey));
|
keys.set(entry.keyId, toKey(entry.publicKey));
|
||||||
}
|
}
|
||||||
for (const publicKey of await readSyncedKeys()) {
|
|
||||||
keys.set(pluginKeyId(publicKey), toKey(publicKey));
|
|
||||||
}
|
|
||||||
setCustomPluginKeys([...keys.values()]);
|
setCustomPluginKeys([...keys.values()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether this app trusts the key independently of its linked server. */
|
||||||
|
export function isLocallyTrustedPluginKey(keyId: string): boolean {
|
||||||
|
return (
|
||||||
|
[...TRUSTED_PLUGIN_KEYS, ...COMMUNITY_PLUGIN_KEYS].some(
|
||||||
|
(key) => key.id === keyId,
|
||||||
|
) ||
|
||||||
|
getCustomPluginKeys().some(
|
||||||
|
(key) => key.id === keyId && key.addedIn !== SYNCED,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Called before the boot scan so plugins from custom registries load. */
|
/** Called before the boot scan so plugins from custom registries load. */
|
||||||
export async function loadCustomRegistryKeys(): Promise<void> {
|
export async function loadCustomRegistryKeys(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -351,7 +351,10 @@ export class SyncEngine {
|
|||||||
const key = recordKey(entity.type, row.syncId);
|
const key = recordKey(entity.type, row.syncId);
|
||||||
const record = records.get(key);
|
const record = records.get(key);
|
||||||
if (record && !record.deleted && record.hash === row.hash) continue;
|
if (record && !record.deleted && record.hash === row.hash) continue;
|
||||||
if (record?.errorHash === row.hash) continue;
|
// Unavailable server rows can recover without another local edit.
|
||||||
|
if (record?.errorHash === row.hash && record.error !== "unavailable") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
ops.push({
|
ops.push({
|
||||||
entityType: entity.type,
|
entityType: entity.type,
|
||||||
syncId: row.syncId,
|
syncId: row.syncId,
|
||||||
@@ -367,7 +370,12 @@ export class SyncEngine {
|
|||||||
if (record.revision === 0) continue;
|
if (record.revision === 0) continue;
|
||||||
if (rows.has(record.syncId) || failed.has(record.syncId)) continue;
|
if (rows.has(record.syncId) || failed.has(record.syncId)) continue;
|
||||||
if (entity.covers && !entity.covers(record.syncId)) continue;
|
if (entity.covers && !entity.covers(record.syncId)) continue;
|
||||||
if (record.errorHash === DELETED_HASH) continue;
|
if (
|
||||||
|
record.errorHash === DELETED_HASH &&
|
||||||
|
record.error !== "unavailable"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
ops.push({
|
ops.push({
|
||||||
entityType: entity.type,
|
entityType: entity.type,
|
||||||
syncId: record.syncId,
|
syncId: record.syncId,
|
||||||
@@ -433,6 +441,9 @@ export class SyncEngine {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A live row the server could not send says nothing; keep ours to retry.
|
||||||
|
if (!result.deleted && !result.row) return;
|
||||||
|
|
||||||
// Conflict: the server moved on since this device last saw the row. Its
|
// Conflict: the server moved on since this device last saw the row. Its
|
||||||
// version wins here, and a local edit it replaced is kept for the user.
|
// version wins here, and a local edit it replaced is kept for the user.
|
||||||
if (op.op === "upsert" && op.row) {
|
if (op.op === "upsert" && op.row) {
|
||||||
@@ -445,7 +456,7 @@ export class SyncEngine {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const resolvers = createResolvers(userId);
|
const resolvers = createResolvers(userId);
|
||||||
if (result.deleted || !result.row) {
|
if (result.deleted) {
|
||||||
await deleteStoredRow(entity, userId, op.syncId);
|
await deleteStoredRow(entity, userId, op.syncId);
|
||||||
await putRecord(userId, op.entityType, op.syncId, {
|
await putRecord(userId, op.entityType, op.syncId, {
|
||||||
revision: result.revision,
|
revision: result.revision,
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
* For every plugin in "mirror" mode, the server decides: a plugin installed
|
* For every plugin in "mirror" mode, the server decides: a plugin installed
|
||||||
* there is installed here, on or off to match, with the capabilities an
|
* there is installed here, on or off to match, with the capabilities an
|
||||||
* admin granted it there. Plugins marked "local" are left to the desktop and
|
* admin granted it there. Plugins marked "local" are left to the desktop and
|
||||||
* "server" ones never run on a linked desktop.
|
* "server" ones never run on a linked desktop. A user plugin no key trusted
|
||||||
|
* here signed is only ever switched off; the desktop's admin turns it on.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
@@ -21,7 +22,10 @@ import {
|
|||||||
unloadPlugin,
|
unloadPlugin,
|
||||||
} from "../../plugins/index.js";
|
} from "../../plugins/index.js";
|
||||||
import { getPluginsDir } from "../../plugins/paths.js";
|
import { getPluginsDir } from "../../plugins/paths.js";
|
||||||
import { setSyncedRegistryKeys } from "../../plugins/registries.js";
|
import {
|
||||||
|
isLocallyTrustedPluginKey,
|
||||||
|
setSyncedRegistryKeys,
|
||||||
|
} from "../../plugins/registries.js";
|
||||||
import type { LoadedPlugin } from "../../plugins/loader.js";
|
import type { LoadedPlugin } from "../../plugins/loader.js";
|
||||||
import {
|
import {
|
||||||
createCurrentPluginPermissionGrantRepository,
|
createCurrentPluginPermissionGrantRepository,
|
||||||
@@ -57,6 +61,14 @@ async function isEnabledLocally(pluginId: string): Promise<boolean> {
|
|||||||
return record?.state === "enabled";
|
return record?.state === "enabled";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signed by a key this desktop trusts on its own. A key the server sent
|
||||||
|
* would let the server's admin sign anything, so it does not count.
|
||||||
|
*/
|
||||||
|
function vouchedHere(local: LoadedPlugin): boolean {
|
||||||
|
return !!local.signedBy && isLocallyTrustedPluginKey(local.signedBy);
|
||||||
|
}
|
||||||
|
|
||||||
const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
||||||
|
|
||||||
/** Downloads next to the installed copy, which stays until the swap. */
|
/** Downloads next to the installed copy, which stays until the swap. */
|
||||||
@@ -112,6 +124,10 @@ async function replaceWithDownload(
|
|||||||
if (previous) await unloadPlugin(pluginId);
|
if (previous) await unloadPlugin(pluginId);
|
||||||
try {
|
try {
|
||||||
const installed = await installPluginArtifact(file);
|
const installed = await installPluginArtifact(file);
|
||||||
|
// Approval belongs to the reviewed version, regardless of remote metadata.
|
||||||
|
if (wasEnabled && installed.source === "user" && !vouchedHere(installed)) {
|
||||||
|
await setPluginEnabled(pluginId, false);
|
||||||
|
}
|
||||||
await fs.promises.rm(backup, { force: true });
|
await fs.promises.rm(backup, { force: true });
|
||||||
await fs.promises.rm(`${backup}.sig`, { force: true });
|
await fs.promises.rm(`${backup}.sig`, { force: true });
|
||||||
return installed;
|
return installed;
|
||||||
@@ -155,7 +171,7 @@ async function matchGrants(plugin: RemotePlugin): Promise<void> {
|
|||||||
if (!local || local.source !== "user") return;
|
if (!local || local.source !== "user") return;
|
||||||
// Grants are only copied for code a trusted key signed. Anything else the
|
// Grants are only copied for code a trusted key signed. Anything else the
|
||||||
// desktop's own admin has to review, as with any plugin dropped on disk.
|
// desktop's own admin has to review, as with any plugin dropped on disk.
|
||||||
if (!local.signedBy) return;
|
if (!vouchedHere(local)) return;
|
||||||
const declared = new Set(local.manifest.capabilities);
|
const declared = new Set(local.manifest.capabilities);
|
||||||
const wanted = new Set(plugin.granted.filter((cap) => declared.has(cap)));
|
const wanted = new Set(plugin.granted.filter((cap) => declared.has(cap)));
|
||||||
const existing = await repository.listByPlugin(plugin.id);
|
const existing = await repository.listByPlugin(plugin.id);
|
||||||
@@ -196,7 +212,7 @@ async function alertUnsigned(plugin: RemotePlugin): Promise<void> {
|
|||||||
alertedVersions.add(key);
|
alertedVersions.add(key);
|
||||||
await sendCoreAlert({
|
await sendCoreAlert({
|
||||||
title: "Review a feature from your server",
|
title: "Review a feature from your server",
|
||||||
body: `${plugin.name} (${plugin.version}) was copied from your server but is not signed, so it stays off here until you turn it on.`,
|
body: `${plugin.name} (${plugin.version}) was copied from your server but is not signed by a key this app trusts, so it stays off here until you turn it on.`,
|
||||||
severity: "warning",
|
severity: "warning",
|
||||||
category: "termix.sync.unsigned_plugin",
|
category: "termix.sync.unsigned_plugin",
|
||||||
dedupeKey: `sync:${key}`,
|
dedupeKey: `sync:${key}`,
|
||||||
@@ -239,7 +255,9 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise<void> {
|
|||||||
await alertOlderDesktop(plugin);
|
await alertOlderDesktop(plugin);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
if (wasEnabled) await setPluginEnabled(plugin.id, true);
|
if (wasEnabled && local.source === "bundled") {
|
||||||
|
await setPluginEnabled(plugin.id, true);
|
||||||
|
}
|
||||||
} else if (newer) {
|
} else if (newer) {
|
||||||
await alertOlderDesktop(plugin);
|
await alertOlderDesktop(plugin);
|
||||||
}
|
}
|
||||||
@@ -263,19 +281,19 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await matchGrants(plugin);
|
await matchGrants(plugin);
|
||||||
// Unsigned code is only ever switched off from here, never on.
|
|
||||||
if (!local.signedBy) {
|
|
||||||
const enabledHere = await isEnabledLocally(plugin.id);
|
|
||||||
if (!plugin.enabled && enabledHere) {
|
|
||||||
await setPluginEnabled(plugin.id, false);
|
|
||||||
} else if (plugin.enabled && !enabledHere) {
|
|
||||||
await alertUnsigned(plugin);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!local) return;
|
if (!local) return;
|
||||||
|
// Remote metadata cannot confer bundled trust on a locally loaded user plugin.
|
||||||
|
if (local.source === "user" && !vouchedHere(local)) {
|
||||||
|
const enabledHere = await isEnabledLocally(plugin.id);
|
||||||
|
if (!plugin.enabled && enabledHere) {
|
||||||
|
await setPluginEnabled(plugin.id, false);
|
||||||
|
} else if (plugin.enabled && !enabledHere) {
|
||||||
|
await alertUnsigned(plugin);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (plugin.enabled !== (await isEnabledLocally(plugin.id))) {
|
if (plugin.enabled !== (await isEnabledLocally(plugin.id))) {
|
||||||
await setPluginEnabled(plugin.id, plugin.enabled);
|
await setPluginEnabled(plugin.id, plugin.enabled);
|
||||||
} else if (plugin.enabled && local.state !== "active") {
|
} else if (plugin.enabled && local.state !== "active") {
|
||||||
@@ -284,6 +302,7 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function mirrorPlugins(link: SyncLink): Promise<void> {
|
export async function mirrorPlugins(link: SyncLink): Promise<void> {
|
||||||
|
const { serialized } = await import("../../plugins/manage.js");
|
||||||
const { plugins, registryKeys } = await remoteJson<{
|
const { plugins, registryKeys } = await remoteJson<{
|
||||||
plugins: RemotePlugin[];
|
plugins: RemotePlugin[];
|
||||||
registryKeys?: unknown[];
|
registryKeys?: unknown[];
|
||||||
@@ -295,7 +314,8 @@ export async function mirrorPlugins(link: SyncLink): Promise<void> {
|
|||||||
|
|
||||||
for (const plugin of plugins) {
|
for (const plugin of plugins) {
|
||||||
try {
|
try {
|
||||||
await mirrorOne(link, plugin);
|
// Share the consent/enable lock so an update cannot swap code mid-approval.
|
||||||
|
await serialized(() => mirrorOne(link, plugin));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
syncLogger.warn("Could not match a feature to the server", {
|
syncLogger.warn("Could not match a feature to the server", {
|
||||||
operation: "sync_plugin_mirror",
|
operation: "sync_plugin_mirror",
|
||||||
@@ -315,8 +335,13 @@ export async function mirrorPlugins(link: SyncLink): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether a plugin's on/off state belongs to the linked server. */
|
/**
|
||||||
|
* Whether a plugin's on/off state belongs to the linked server. Code no key
|
||||||
|
* trusted here vouches for is the desktop's own admin's call to turn on.
|
||||||
|
*/
|
||||||
export function isServerManaged(pluginId: string): boolean {
|
export function isServerManaged(pluginId: string): boolean {
|
||||||
const local = getPluginRuntime().loader.get(pluginId);
|
const local = getPluginRuntime().loader.get(pluginId);
|
||||||
return (local?.manifest.desktop ?? "mirror") !== "local";
|
const mode = local?.manifest.desktop ?? "mirror";
|
||||||
|
if (mode !== "mirror") return mode === "server";
|
||||||
|
return local?.source !== "user" || vouchedHere(local);
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
* an entity in without renumbering; anything referenced syncs first.
|
* an entity in without renumbering; anything referenced syncs first.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, getTableColumns } from "drizzle-orm";
|
||||||
import type { SyncRow } from "@termix-ssh/plugin-sdk/backend";
|
import type { SyncRow } from "@termix-ssh/plugin-sdk/backend";
|
||||||
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
||||||
import {
|
import {
|
||||||
@@ -493,6 +493,25 @@ const SHARED_HOST_DROP = [
|
|||||||
...HOST_LOCAL_FIELDS,
|
...HOST_LOCAL_FIELDS,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const HOST_TEXT_COLUMNS = new Set(
|
||||||
|
Object.entries(getTableColumns(hosts))
|
||||||
|
.filter(([, column]) => column.dataType === "string")
|
||||||
|
.map(([field]) => field),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A resolved host field back in the form its column stores, as an owned host
|
||||||
|
* sends it: the resolver parses JSON columns and turns the "true"/"false" of
|
||||||
|
* forceKeyboardInteractive into a boolean, which a text column cannot bind.
|
||||||
|
*/
|
||||||
|
function storedForm(field: string, value: unknown): unknown {
|
||||||
|
if (value !== null && typeof value === "object") return JSON.stringify(value);
|
||||||
|
if (typeof value === "boolean" && HOST_TEXT_COLUMNS.has(field)) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadSharedHosts(userId: string): Promise<SyncRow[]> {
|
async function loadSharedHosts(userId: string): Promise<SyncRow[]> {
|
||||||
const {
|
const {
|
||||||
createCurrentRbacAccessRepository,
|
createCurrentRbacAccessRepository,
|
||||||
@@ -540,10 +559,7 @@ async function loadSharedHosts(userId: string): Promise<SyncRow[]> {
|
|||||||
const wire: SyncRow = {};
|
const wire: SyncRow = {};
|
||||||
for (const [field, value] of Object.entries(host)) {
|
for (const [field, value] of Object.entries(host)) {
|
||||||
if (SHARED_HOST_DROP.includes(field)) continue;
|
if (SHARED_HOST_DROP.includes(field)) continue;
|
||||||
wire[field] =
|
wire[field] = storedForm(field, value);
|
||||||
value !== null && typeof value === "object"
|
|
||||||
? JSON.stringify(value)
|
|
||||||
: value;
|
|
||||||
}
|
}
|
||||||
wire.jumpHosts = await jumpHostsToSyncIds(host.jumpHosts);
|
wire.jumpHosts = await jumpHostsToSyncIds(host.jumpHosts);
|
||||||
wire.pluginSettings = await exportHostPluginSettings(hostId, {
|
wire.pluginSettings = await exportHostPluginSettings(hostId, {
|
||||||
|
|||||||
@@ -134,12 +134,23 @@ export async function applyPush(
|
|||||||
const record = await getRecord(userId, op.entityType, op.syncId);
|
const record = await getRecord(userId, op.entityType, op.syncId);
|
||||||
const currentRevision = record?.revision ?? 0;
|
const currentRevision = record?.revision ?? 0;
|
||||||
if (op.baseRevision !== currentRevision) {
|
if (op.baseRevision !== currentRevision) {
|
||||||
|
const row = record?.deleted ? null : (wire.get(key) ?? null);
|
||||||
|
// A live record with no row failed to serialize; a conflict without a
|
||||||
|
// row would read as a delete on the desktop.
|
||||||
|
if (record && !record.deleted && !row) {
|
||||||
|
results.set(key, {
|
||||||
|
...base,
|
||||||
|
status: "rejected",
|
||||||
|
reason: "unavailable",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
results.set(key, {
|
results.set(key, {
|
||||||
...base,
|
...base,
|
||||||
status: "conflict",
|
status: "conflict",
|
||||||
revision: currentRevision,
|
revision: currentRevision,
|
||||||
deleted: !!record?.deleted,
|
deleted: !!record?.deleted,
|
||||||
row: record?.deleted ? null : (wire.get(key) ?? null),
|
row,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { createCurrentRepositoryContext } from "../database/repositories/factory
|
|||||||
import { DataCrypto } from "../utils/data-crypto.js";
|
import { DataCrypto } from "../utils/data-crypto.js";
|
||||||
import { SystemCrypto } from "../utils/system-crypto.js";
|
import { SystemCrypto } from "../utils/system-crypto.js";
|
||||||
import { DatabaseSaveTrigger } from "../utils/database-save-trigger.js";
|
import { DatabaseSaveTrigger } from "../utils/database-save-trigger.js";
|
||||||
|
import { syncLogger } from "../utils/logger.js";
|
||||||
import {
|
import {
|
||||||
getEntity,
|
getEntity,
|
||||||
type RegisteredSyncEntity,
|
type RegisteredSyncEntity,
|
||||||
@@ -229,7 +230,13 @@ export async function loadWireRows(
|
|||||||
);
|
);
|
||||||
const wire = toWire(entity, serialized, syncId);
|
const wire = toWire(entity, serialized, syncId);
|
||||||
rows.set(syncId, { syncId, wire, hash: hashWire(entity, wire, key) });
|
rows.set(syncId, { syncId, wire, hash: hashWire(entity, wire, key) });
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
syncLogger.warn("Could not serialize a sync row", {
|
||||||
|
operation: "sync_serialize",
|
||||||
|
entityType: entity.type,
|
||||||
|
syncId,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
failed.add(syncId);
|
failed.add(syncId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import os from "os";
|
||||||
|
import path from "path";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registration checked for a taken username before inserting, with nothing in
|
||||||
|
* the schema to back it up, so two concurrent sign-ups could both get the same
|
||||||
|
* name. Startup renames the later duplicates instead of deleting anyone, then
|
||||||
|
* makes the name unique.
|
||||||
|
*/
|
||||||
|
describe("users.username uniqueness", () => {
|
||||||
|
let dataDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-unique-user-"));
|
||||||
|
vi.resetModules();
|
||||||
|
process.env.DATA_DIR = dataDir;
|
||||||
|
process.env.DB_FILE_ENCRYPTION = "false";
|
||||||
|
process.env.ALLOW_EMPTY_DATA_DIR = "true";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.DATA_DIR;
|
||||||
|
delete process.env.DB_FILE_ENCRYPTION;
|
||||||
|
delete process.env.ALLOW_EMPTY_DATA_DIR;
|
||||||
|
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function writeDatabaseWithDuplicates(): void {
|
||||||
|
const seed = new Database(":memory:");
|
||||||
|
seed.exec(`
|
||||||
|
CREATE TABLE users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
registered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO users (id, username, password_hash, registered_at) VALUES
|
||||||
|
('late', 'alice', 'hash', '2025-03-01 00:00:00'),
|
||||||
|
('first', 'alice', 'hash', '2025-01-01 00:00:00'),
|
||||||
|
('tie-b', 'bob', 'hash', '2025-01-01 00:00:00'),
|
||||||
|
('tie-a', 'bob', 'hash', '2025-01-01 00:00:00'),
|
||||||
|
('only', 'Alice', 'hash', '2025-02-01 00:00:00');
|
||||||
|
`);
|
||||||
|
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize());
|
||||||
|
seed.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootAndGetSqlite(): Promise<Database.Database> {
|
||||||
|
const db = await import("../../../database/db/index.js");
|
||||||
|
await db.initializeDatabase();
|
||||||
|
return db.getSqlite();
|
||||||
|
}
|
||||||
|
|
||||||
|
it("keeps the oldest holder of a name and renames the rest", async () => {
|
||||||
|
writeDatabaseWithDuplicates();
|
||||||
|
|
||||||
|
const sqlite = await bootAndGetSqlite();
|
||||||
|
|
||||||
|
const names = Object.fromEntries(
|
||||||
|
sqlite
|
||||||
|
.prepare("SELECT id, username FROM users")
|
||||||
|
.raw()
|
||||||
|
.all() as [string, string][],
|
||||||
|
);
|
||||||
|
expect(names).toEqual({
|
||||||
|
first: "alice",
|
||||||
|
late: "termix-recovered-0-5",
|
||||||
|
"tie-a": "bob",
|
||||||
|
"tie-b": "termix-recovered-0-3",
|
||||||
|
only: "Alice",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a second user with a taken name", async () => {
|
||||||
|
const sqlite = await bootAndGetSqlite();
|
||||||
|
|
||||||
|
const insert = sqlite.prepare(
|
||||||
|
"INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)",
|
||||||
|
);
|
||||||
|
insert.run("u1", "alice", "hash");
|
||||||
|
|
||||||
|
expect(() => insert.run("u2", "alice", "hash")).toThrow(/UNIQUE/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run the checked-in migration itself, including occupied recovery prefixes.
|
||||||
|
it("migrates duplicate names without colliding with existing names", () => {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
try {
|
||||||
|
db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL, registered_at TEXT NOT NULL);
|
||||||
|
INSERT INTO users VALUES
|
||||||
|
('first', 'alice', '2025-01-01'),
|
||||||
|
('late', 'alice', '2025-02-01'),
|
||||||
|
('other', 'alice-late', '2025-01-01'),
|
||||||
|
('occupied', 'termix-recovered-0-1', '2025-01-01');`);
|
||||||
|
db.exec(fs.readFileSync("drizzle/sqlite/0067_unique_username.sql", "utf8"));
|
||||||
|
const rows = db.prepare("SELECT username FROM users").pluck().all();
|
||||||
|
expect(new Set(rows).size).toBe(4);
|
||||||
|
expect(rows).toContain("alice");
|
||||||
|
expect(rows).toContain("alice-late");
|
||||||
|
expect(rows).toContain("termix-recovered-0-1");
|
||||||
|
expect(() => db.exec("INSERT INTO users VALUES ('new', 'alice', '2026-01-01')")).toThrow(/UNIQUE/i);
|
||||||
|
} finally { db.close(); }
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import os from "os";
|
||||||
|
import path from "path";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// ssh_data.vault_profile_id points at vault_profiles. The vault plugin renames
|
||||||
|
// that table to p_vault_profiles, which SQLite follows into ssh_data's foreign
|
||||||
|
// key, so uninstalling the plugin used to leave every host write failing with
|
||||||
|
// "no such table: main.p_vault_profiles".
|
||||||
|
describe("ssh_data and the vault plugin's tables", () => {
|
||||||
|
let dataDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-vault-fk-"));
|
||||||
|
vi.resetModules();
|
||||||
|
vi.stubEnv("DATA_DIR", dataDir);
|
||||||
|
vi.stubEnv("DB_FILE_ENCRYPTION", "false");
|
||||||
|
vi.stubEnv("ALLOW_EMPTY_DATA_DIR", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function seed(afterAdoption: string): void {
|
||||||
|
const seed = new Database(":memory:");
|
||||||
|
seed.exec(
|
||||||
|
fs.readFileSync(
|
||||||
|
new URL("../../fixtures/sqlite-2.8-schema.sql", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
seed.exec(`
|
||||||
|
CREATE TABLE plugin_migrations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
plugin_id TEXT NOT NULL,
|
||||||
|
migration_id TEXT NOT NULL,
|
||||||
|
checksum TEXT NOT NULL,
|
||||||
|
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (plugin_id, migration_id),
|
||||||
|
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO users (id, username, password_hash)
|
||||||
|
VALUES ('owner', 'alice', 'hash');
|
||||||
|
INSERT INTO ssh_data (id, user_id, ip, port, username, auth_type)
|
||||||
|
VALUES (1, 'owner', '192.0.2.1', 22, 'root', 'password');
|
||||||
|
ALTER TABLE vault_profiles RENAME TO p_vault_profiles;
|
||||||
|
ALTER TABLE vault_tokens RENAME TO p_vault_tokens;
|
||||||
|
${afterAdoption}
|
||||||
|
`);
|
||||||
|
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize());
|
||||||
|
seed.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function boot() {
|
||||||
|
const db = await import("../../../database/db/index.js");
|
||||||
|
await db.initializeDatabase();
|
||||||
|
return db.getSqlite();
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeHosts(sqlite: Database.Database): void {
|
||||||
|
sqlite
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO ssh_data (user_id, ip, port, username, auth_type) VALUES ('owner', '192.0.2.2', 22, 'root', 'password')",
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
sqlite.prepare("DELETE FROM ssh_data WHERE id = 1").run();
|
||||||
|
}
|
||||||
|
|
||||||
|
it("repairs a database the plugin was already uninstalled from", async () => {
|
||||||
|
seed("DROP TABLE p_vault_tokens; DROP TABLE p_vault_profiles;");
|
||||||
|
const sqlite = await boot();
|
||||||
|
|
||||||
|
expect(() => writeHosts(sqlite)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves nothing in ssh_data pointing at the plugin", async () => {
|
||||||
|
seed("");
|
||||||
|
const sqlite = await boot();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
sqlite.prepare("PRAGMA foreign_key_list(ssh_data)").all(),
|
||||||
|
).not.toContainEqual(expect.objectContaining({ table: "p_vault_profiles" }));
|
||||||
|
sqlite.exec("DROP TABLE p_vault_tokens; DROP TABLE p_vault_profiles;");
|
||||||
|
expect(() => writeHosts(sqlite)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import * as fs from "node:fs";
|
||||||
|
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { runInNewContext } from "node:vm";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import ts from "typescript";
|
||||||
|
import { expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const source = readFileSync(
|
||||||
|
new URL("../../database/database.ts", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const ast = ts.createSourceFile(
|
||||||
|
"database.ts",
|
||||||
|
source,
|
||||||
|
ts.ScriptTarget.Latest,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const route = ast.statements.find(
|
||||||
|
(s) =>
|
||||||
|
ts.isExpressionStatement(s) &&
|
||||||
|
ts.isCallExpression(s.expression) &&
|
||||||
|
s.expression.arguments[0]?.getText(ast) === '"/database/import"',
|
||||||
|
) as ts.ExpressionStatement;
|
||||||
|
const batched = (route.expression as ts.CallExpression)
|
||||||
|
.arguments[3] as ts.CallExpression;
|
||||||
|
const callback = batched.arguments[0];
|
||||||
|
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "termix-import-route-"));
|
||||||
|
|
||||||
|
function exportFile(): string {
|
||||||
|
const path = join(dir, `export-${Math.random()}.db`);
|
||||||
|
const db = new Database(path);
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE ssh_credentials (id INTEGER, name TEXT, username TEXT);
|
||||||
|
CREATE TABLE ssh_data (id INTEGER, ip TEXT, port INTEGER, username TEXT, credential_id INTEGER);
|
||||||
|
INSERT INTO ssh_credentials VALUES (7, 'fresh', 'root'), (8, 'kept', 'admin');
|
||||||
|
INSERT INTO ssh_data VALUES
|
||||||
|
(1, '10.0.0.1', 22, 'root', 7),
|
||||||
|
(2, '10.0.0.2', 22, 'root', 8),
|
||||||
|
(3, '10.0.0.3', 22, 'root', 99),
|
||||||
|
(4, '10.0.0.4', 22, 'root', NULL);
|
||||||
|
`);
|
||||||
|
db.close();
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invoke(path: string, user: unknown = { isExternal: false }) {
|
||||||
|
const writes: string[] = [];
|
||||||
|
const hosts: Array<Record<string, unknown>> = [];
|
||||||
|
const importHostProtocolLogins = vi.fn();
|
||||||
|
const handler = runInNewContext(
|
||||||
|
ts.transpileModule(`(${callback.getText(ast)})`, {
|
||||||
|
compilerOptions: { target: ts.ScriptTarget.ES2022 },
|
||||||
|
}).outputText,
|
||||||
|
{
|
||||||
|
fs,
|
||||||
|
Buffer,
|
||||||
|
Database,
|
||||||
|
parseUserAgent: () => ({ type: "web" }),
|
||||||
|
createCurrentUserRepository: () => ({ findById: async () => user }),
|
||||||
|
DataCrypto: { getUserDataKey: () => Buffer.from("key") },
|
||||||
|
apiLogger: {
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
},
|
||||||
|
createCurrentCredentialRepository: () => ({
|
||||||
|
findIdForImportIdentity: async (_: string, name: string) =>
|
||||||
|
name === "kept" ? 42 : null,
|
||||||
|
createEncryptedForUser: async () => {
|
||||||
|
writes.push("credential");
|
||||||
|
return { id: 100 };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
createCurrentHostRepository: () => ({
|
||||||
|
existsForImportIdentity: async () => false,
|
||||||
|
createEncryptedForUser: async (_: string, host: never) => {
|
||||||
|
writes.push("host");
|
||||||
|
hosts.push(host);
|
||||||
|
return { id: 500 + hosts.length };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
legacyStatusCheck: () => ({}),
|
||||||
|
sshOptionsForWrite: () => null,
|
||||||
|
applyHostDefaultsToWrite: async () => ({}),
|
||||||
|
applyPluginHostImportSettings: async () => {},
|
||||||
|
importedHostPluginSettings: () => ({}),
|
||||||
|
importHostProtocolLogins,
|
||||||
|
applyDefaultsAfterHostWrites: async () => {},
|
||||||
|
importUserPluginRows: async () => ({
|
||||||
|
imported: 0,
|
||||||
|
skipped: 0,
|
||||||
|
errors: [],
|
||||||
|
}),
|
||||||
|
importHostDefaults: async () => ({ imported: 0, skipped: 0 }),
|
||||||
|
DatabaseSaveTrigger: { forceSave: async () => {} },
|
||||||
|
getErrorMessage: (error: Error) => error.message,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const res = { json: vi.fn(), status: vi.fn() };
|
||||||
|
res.status.mockReturnValue(res);
|
||||||
|
await handler(
|
||||||
|
{ userId: "user-1", file: { path, originalname: "export.db" } },
|
||||||
|
res,
|
||||||
|
);
|
||||||
|
return { res, writes, hosts, importHostProtocolLogins };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("points imported hosts at the credentials imported with them", async () => {
|
||||||
|
const { res, writes, hosts, importHostProtocolLogins } =
|
||||||
|
await invoke(exportFile());
|
||||||
|
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
expect(writes).toEqual(["credential", "host", "host", "host", "host"]);
|
||||||
|
expect(hosts.map((host) => host.credentialId)).toEqual([100, 42, null, null]);
|
||||||
|
expect([...importHostProtocolLogins.mock.calls[0][4]]).toEqual([
|
||||||
|
[7, 100],
|
||||||
|
[8, 42],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the uploaded file when the user is gone", async () => {
|
||||||
|
const path = exportFile();
|
||||||
|
const { res } = await invoke(path, null);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(404);
|
||||||
|
expect(existsSync(path)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the uploaded file when it is not a SQLite database", async () => {
|
||||||
|
const path = join(dir, "not-sqlite.db");
|
||||||
|
writeFileSync(path, "definitely not a database");
|
||||||
|
const { res } = await invoke(path);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(existsSync(path)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the uploaded file after a successful import", async () => {
|
||||||
|
const path = exportFile();
|
||||||
|
await invoke(path);
|
||||||
|
|
||||||
|
expect(existsSync(path)).toBe(false);
|
||||||
|
});
|
||||||
@@ -213,7 +213,7 @@ describe("HostRepository and CredentialRepository", () => {
|
|||||||
it("checks credential import identity", async () => {
|
it("checks credential import identity", async () => {
|
||||||
const repo = await createRepositories();
|
const repo = await createRepositories();
|
||||||
|
|
||||||
await repo.credentials.create({
|
const created = await repo.credentials.create({
|
||||||
userId: "user-1",
|
userId: "user-1",
|
||||||
name: "primary",
|
name: "primary",
|
||||||
authType: "password",
|
authType: "password",
|
||||||
@@ -221,11 +221,11 @@ describe("HostRepository and CredentialRepository", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
repo.credentials.existsForImportIdentity("user-1", "primary", "root"),
|
repo.credentials.findIdForImportIdentity("user-1", "primary", "root"),
|
||||||
).resolves.toBe(true);
|
).resolves.toBe(created.id);
|
||||||
await expect(
|
await expect(
|
||||||
repo.credentials.existsForImportIdentity("user-1", "primary", "admin"),
|
repo.credentials.findIdForImportIdentity("user-1", "primary", "admin"),
|
||||||
).resolves.toBe(false);
|
).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renames credential folders through the write boundary", async () => {
|
it("renames credential folders through the write boundary", async () => {
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const factory = vi.hoisted(() => ({
|
|
||||||
getCurrentRepositorySqlite: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../../database/repositories/factory.js", () => factory);
|
|
||||||
|
|
||||||
import {
|
|
||||||
withCurrentSqliteForeignKeysDisabled,
|
|
||||||
withSqliteForeignKeysDisabled,
|
|
||||||
} from "../../../database/repositories/sqlite-foreign-keys.js";
|
|
||||||
|
|
||||||
const previousDatabaseDialect = process.env.DATABASE_DIALECT;
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (previousDatabaseDialect === undefined)
|
|
||||||
delete process.env.DATABASE_DIALECT;
|
|
||||||
else process.env.DATABASE_DIALECT = previousDatabaseDialect;
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("withSqliteForeignKeysDisabled", () => {
|
|
||||||
it("restores foreign keys after an import", async () => {
|
|
||||||
const sqlite = { exec: vi.fn() };
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
withSqliteForeignKeysDisabled(sqlite, async () => "imported"),
|
|
||||||
).resolves.toBe("imported");
|
|
||||||
expect(sqlite.exec.mock.calls).toEqual([
|
|
||||||
["PRAGMA foreign_keys = OFF"],
|
|
||||||
["PRAGMA foreign_keys = ON"],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("withCurrentSqliteForeignKeysDisabled", () => {
|
|
||||||
it.each(["postgres", "mysql"])(
|
|
||||||
"runs portable imports with constraints enabled on %s",
|
|
||||||
async (dialect) => {
|
|
||||||
process.env.DATABASE_DIALECT = dialect;
|
|
||||||
const operation = vi.fn().mockResolvedValue("imported");
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
withCurrentSqliteForeignKeysDisabled(operation),
|
|
||||||
).resolves.toBe("imported");
|
|
||||||
expect(operation).toHaveBeenCalledOnce();
|
|
||||||
expect(factory.getCurrentRepositorySqlite).not.toHaveBeenCalled();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { expect, it } from "vitest";
|
||||||
|
import { TestSqliteDatabase, testDialect } from "./test-support.js";
|
||||||
|
|
||||||
|
it("migrates duplicate usernames without colliding with occupied recovery names", async () => {
|
||||||
|
const adapter = new TestSqliteDatabase();
|
||||||
|
await adapter.connect();
|
||||||
|
try {
|
||||||
|
await adapter.exec(`CREATE TABLE username_migration_fixture (
|
||||||
|
id varchar(255) PRIMARY KEY, username TEXT NOT NULL, registered_at varchar(255) NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO username_migration_fixture VALUES
|
||||||
|
('first', 'alice', '2025-01-01'),
|
||||||
|
('late', 'alice', '2025-02-01'),
|
||||||
|
('other', 'alice-late', '2025-01-01'),
|
||||||
|
('occupied', 'termix-recovered-0-1', '2025-01-01');`);
|
||||||
|
const dialect = testDialect();
|
||||||
|
const number = dialect === "sqlite" ? "0067" : "0072";
|
||||||
|
const migration = fs
|
||||||
|
.readFileSync(`drizzle/${dialect}/${number}_unique_username.sql`, "utf8")
|
||||||
|
.replace(/\busers\b/g, "username_migration_fixture")
|
||||||
|
.replaceAll("idx_users_username", "idx_fixture_username");
|
||||||
|
for (const statement of migration.split("--> statement-breakpoint")) {
|
||||||
|
if (statement.trim()) await adapter.run(sql.raw(statement));
|
||||||
|
}
|
||||||
|
const rows = await adapter.query<{ username: string }>(
|
||||||
|
sql`SELECT username FROM username_migration_fixture`,
|
||||||
|
);
|
||||||
|
const names = rows.map((row) => row.username);
|
||||||
|
expect(new Set(names).size).toBe(4);
|
||||||
|
expect(names).toEqual(
|
||||||
|
expect.arrayContaining(["alice", "alice-late", "termix-recovered-0-1"]),
|
||||||
|
);
|
||||||
|
await expect(async () => {
|
||||||
|
await adapter.run(
|
||||||
|
sql`INSERT INTO username_migration_fixture VALUES ('new', 'alice', '2026-01-01')`,
|
||||||
|
);
|
||||||
|
}).rejects.toThrow();
|
||||||
|
} finally {
|
||||||
|
await adapter.exec("DROP TABLE IF EXISTS username_migration_fixture");
|
||||||
|
await adapter.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { TestSqliteDatabase } from "./test-support.js";
|
import { TestSqliteDatabase } from "./test-support.js";
|
||||||
import { UserRepository } from "../../../database/repositories/user-repository.js";
|
import {
|
||||||
|
UserRepository,
|
||||||
|
isUsernameTaken,
|
||||||
|
} from "../../../database/repositories/user-repository.js";
|
||||||
|
|
||||||
describe("UserRepository.listPage", () => {
|
describe("UserRepository.listPage", () => {
|
||||||
let adapter: TestSqliteDatabase | null = null;
|
let adapter: TestSqliteDatabase | null = null;
|
||||||
@@ -93,3 +96,27 @@ describe("UserRepository.listPage", () => {
|
|||||||
expect(page.total).toBe(5);
|
expect(page.total).toBe(5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("UserRepository.create", () => {
|
||||||
|
let adapter: TestSqliteDatabase | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (adapter) {
|
||||||
|
await adapter.close();
|
||||||
|
adapter = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a taken username in a way callers can recognise", async () => {
|
||||||
|
adapter = new TestSqliteDatabase();
|
||||||
|
const repo = new UserRepository(await adapter.connect());
|
||||||
|
await repo.create({ id: "u1", username: "alice", passwordHash: "hash" });
|
||||||
|
|
||||||
|
const error = await repo
|
||||||
|
.create({ id: "u2", username: "alice", passwordHash: "hash" })
|
||||||
|
.catch((e: unknown) => e);
|
||||||
|
|
||||||
|
expect(isUsernameTaken(error)).toBe(true);
|
||||||
|
expect(isUsernameTaken(new Error("unrelated"))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -176,6 +176,7 @@ const manageMock = vi.hoisted(() => ({
|
|||||||
deletePluginData: vi.fn(),
|
deletePluginData: vi.fn(),
|
||||||
planStateChange: vi.fn(),
|
planStateChange: vi.fn(),
|
||||||
setPluginState: vi.fn(),
|
setPluginState: vi.fn(),
|
||||||
|
enablePluginWithConsent: vi.fn(),
|
||||||
getDeveloperMode: vi.fn(),
|
getDeveloperMode: vi.fn(),
|
||||||
setDeveloperMode: vi.fn(),
|
setDeveloperMode: vi.fn(),
|
||||||
stageUpload: vi.fn(),
|
stageUpload: vi.fn(),
|
||||||
@@ -215,6 +216,12 @@ const onboardingMock = vi.hoisted(() => ({
|
|||||||
|
|
||||||
vi.mock("../../../plugins/onboarding.js", () => onboardingMock);
|
vi.mock("../../../plugins/onboarding.js", () => onboardingMock);
|
||||||
|
|
||||||
|
const syncPluginsMock = vi.hoisted(() => ({
|
||||||
|
isServerManaged: vi.fn((_id: string) => true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../sync/client/plugins.js", () => syncPluginsMock);
|
||||||
|
|
||||||
const pluginRoutes = (await import("../../../database/routes/plugins.js"))
|
const pluginRoutes = (await import("../../../database/routes/plugins.js"))
|
||||||
.default;
|
.default;
|
||||||
|
|
||||||
@@ -1139,6 +1146,65 @@ describe("plugins route", () => {
|
|||||||
expect(manageMock.installPlugin).not.toHaveBeenCalled();
|
expect(manageMock.installPlugin).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("on a linked desktop", () => {
|
||||||
|
const switchOn = (id: string) =>
|
||||||
|
fetch(`${baseUrl}/plugins/${id}/state`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
enabled: true,
|
||||||
|
version: "1.0.0",
|
||||||
|
capabilities: ["kv:own"],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
manageMock.isLinkedDesktop.mockResolvedValue(true);
|
||||||
|
state.plugins.set("sample-plugin", makePlugin({ state: "disabled" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
syncPluginsMock.isServerManaged.mockImplementation(() => true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to switch a plugin the server decides", async () => {
|
||||||
|
const res = await switchOn("sample-plugin");
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect((await res.json()).code).toBe("MANAGED_BY_SERVER");
|
||||||
|
expect(manageMock.setPluginState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the desktop's admin switch one the server does not", async () => {
|
||||||
|
syncPluginsMock.isServerManaged.mockImplementation(() => false);
|
||||||
|
manageMock.enablePluginWithConsent.mockResolvedValue({
|
||||||
|
enable: [],
|
||||||
|
disable: [],
|
||||||
|
});
|
||||||
|
const res = await switchOn("sample-plugin");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(manageMock.enablePluginWithConsent).toHaveBeenCalledWith(
|
||||||
|
"sample-plugin",
|
||||||
|
expect.objectContaining({
|
||||||
|
version: "1.0.0",
|
||||||
|
capabilities: ["kv:own"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says per plugin whether the server decides it", async () => {
|
||||||
|
state.plugins.set("other", makePlugin({ id: "other" }));
|
||||||
|
syncPluginsMock.isServerManaged.mockImplementation(
|
||||||
|
(id) => id === "other",
|
||||||
|
);
|
||||||
|
const listed = (await (
|
||||||
|
await fetch(`${baseUrl}/plugins`)
|
||||||
|
).json()) as Array<{ id: string; managedByServer: boolean }>;
|
||||||
|
expect(
|
||||||
|
Object.fromEntries(listed.map((p) => [p.id, p.managedByServer])),
|
||||||
|
).toEqual({ "sample-plugin": false, other: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("previews a state change without making it", async () => {
|
it("previews a state change without making it", async () => {
|
||||||
state.plugins.set("sample-plugin", makePlugin());
|
state.plugins.set("sample-plugin", makePlugin());
|
||||||
manageMock.planStateChange.mockResolvedValue({
|
manageMock.planStateChange.mockResolvedValue({
|
||||||
|
|||||||
@@ -145,6 +145,35 @@ describe("client tunnel runtime authentication", () => {
|
|||||||
expect(sockets).toHaveLength(1);
|
expect(sockets).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the relay's reason when it closes after an error", async () => {
|
||||||
|
const { context, runtimes, sockets } = setup();
|
||||||
|
const runtime = { status: { connected: true } };
|
||||||
|
runtimes.set("web", runtime);
|
||||||
|
const test = context.testC2SRelay(
|
||||||
|
{ name: "web" },
|
||||||
|
"127.0.0.1",
|
||||||
|
8443,
|
||||||
|
null,
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
await vi.waitFor(() => expect(sockets).toHaveLength(1));
|
||||||
|
sockets[0].emit(
|
||||||
|
"message",
|
||||||
|
Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
error: "Target 127.0.0.1:8443 refused",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
sockets[0].close();
|
||||||
|
expect(await test).toMatchObject({ success: false });
|
||||||
|
expect(runtimes.get("web").status.reason).toBe(
|
||||||
|
"Target 127.0.0.1:8443 refused",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps the tunnel connected when one of its connections fails", async () => {
|
it("keeps the tunnel connected when one of its connections fails", async () => {
|
||||||
const { context, runtimes, sockets } = setup();
|
const { context, runtimes, sockets } = setup();
|
||||||
runtimes.set("web", { status: { connected: true, status: "CONNECTED" } });
|
runtimes.set("web", { status: { connected: true, status: "CONNECTED" } });
|
||||||
|
|||||||
@@ -80,4 +80,20 @@ describe("repairSpawnHelpers", () => {
|
|||||||
expect(chmods[0][1] & 0o111).not.toBe(0);
|
expect(chmods[0][1] & 0o111).not.toBe(0);
|
||||||
expect(chmods[0][0]).toContain("app.asar.unpacked");
|
expect(chmods[0][0]).toContain("app.asar.unpacked");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(["app-arm64.asar", "app-x64.asar"])(
|
||||||
|
"repairs the helper in the unpacked copy of a universal build's %s",
|
||||||
|
(asar) => {
|
||||||
|
const chmods: string[] = [];
|
||||||
|
const fsImpl = {
|
||||||
|
statSync: () => ({ mode: 0o644 }),
|
||||||
|
chmodSync: (file: string) => {
|
||||||
|
chmods.push(file.split(require("node:path").sep).join("/"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const dir = `/Applications/Termix.app/Contents/Resources/${asar}/node_modules/node-pty`;
|
||||||
|
repairSpawnHelpers(dir, fsImpl);
|
||||||
|
expect(chmods[0]).toContain(`/${asar}.unpacked/node_modules/node-pty/`);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
@@ -11,7 +11,48 @@ import { tmpdir } from "node:os";
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const { chmodSpawnHelpers } = require("../../../../scripts/patch-node-pty.cjs");
|
const {
|
||||||
|
chmodSpawnHelpers,
|
||||||
|
patchUnixTerminalHelperRewrite,
|
||||||
|
} = require("../../../../scripts/patch-node-pty.cjs");
|
||||||
|
|
||||||
|
const upstreamRewrite = [
|
||||||
|
"helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
|
||||||
|
"helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const previouslyPatchedRewrite = [
|
||||||
|
"if (!helperPath.includes('app.asar.unpacked')) {",
|
||||||
|
" helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
|
||||||
|
"}",
|
||||||
|
"if (!helperPath.includes('node_modules.asar.unpacked')) {",
|
||||||
|
" helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
|
||||||
|
"}",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const resources = "/Applications/Termix.app/Contents/Resources";
|
||||||
|
const helperPaths = [
|
||||||
|
["app.asar", "app.asar.unpacked"],
|
||||||
|
["app-arm64.asar", "app-arm64.asar.unpacked"],
|
||||||
|
["app-x64.asar", "app-x64.asar.unpacked"],
|
||||||
|
["app.asar.unpacked", "app.asar.unpacked"],
|
||||||
|
["app-arm64.asar.unpacked", "app-arm64.asar.unpacked"],
|
||||||
|
].map(([from, to]) => [
|
||||||
|
`${resources}/${from}/node_modules/node-pty/build/Release/spawn-helper`,
|
||||||
|
`${resources}/${to}/node_modules/node-pty/build/Release/spawn-helper`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
function writeUnixTerminal(rewrite: string) {
|
||||||
|
const file = join(
|
||||||
|
tmpdir(),
|
||||||
|
`termix-unix-terminal-${Date.now()}-${Math.random()}.cjs`,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
file,
|
||||||
|
`module.exports = function (helperPath) {\n${rewrite}\nreturn helperPath;\n};\n`,
|
||||||
|
);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
// chmod's execute bit is a POSIX concept; Windows/NTFS has no equivalent mode bit.
|
// chmod's execute bit is a POSIX concept; Windows/NTFS has no equivalent mode bit.
|
||||||
const itPosix = process.platform === "win32" ? it.skip : it;
|
const itPosix = process.platform === "win32" ? it.skip : it;
|
||||||
@@ -30,4 +71,22 @@ describe("patch-node-pty", () => {
|
|||||||
expect(chmodSpawnHelpers(root)).toBe(0);
|
expect(chmodSpawnHelpers(root)).toBe(0);
|
||||||
expect(existsSync(helper)).toBe(true);
|
expect(existsSync(helper)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["upstream", upstreamRewrite],
|
||||||
|
["previously patched", previouslyPatchedRewrite],
|
||||||
|
])(
|
||||||
|
"points the %s helper path at the unpacked copy of any app asar",
|
||||||
|
(_, rewrite) => {
|
||||||
|
const file = writeUnixTerminal(rewrite);
|
||||||
|
|
||||||
|
expect(patchUnixTerminalHelperRewrite(file)).toBe(true);
|
||||||
|
expect(patchUnixTerminalHelperRewrite(file)).toBe(false);
|
||||||
|
|
||||||
|
const rewriteHelperPath = require(file);
|
||||||
|
for (const [from, to] of helperPaths) {
|
||||||
|
expect(rewriteHelperPath(from)).toBe(to);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
@@ -106,6 +106,31 @@ describe("applyHostDefaultsToWrite", () => {
|
|||||||
expect(overrides.core).toEqual(["statusCheckEnabled", "username"]);
|
expect(overrides.core).toEqual(["statusCheckEnabled", "username"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a changed auth type on an update without a list", async () => {
|
||||||
|
userDefault("auth", { authType: "credential", credentialId: 3 });
|
||||||
|
const stored = {
|
||||||
|
id: 5,
|
||||||
|
userId: "u1",
|
||||||
|
authType: "credential",
|
||||||
|
credentialId: 3,
|
||||||
|
defaultOverrides: JSON.stringify({ core: ["username"] }),
|
||||||
|
};
|
||||||
|
const columns: Record<string, unknown> = {
|
||||||
|
authType: "vault",
|
||||||
|
credentialId: null,
|
||||||
|
overrideCredentialUsername: 0,
|
||||||
|
};
|
||||||
|
const overrides = await applyHostDefaultsToWrite({
|
||||||
|
ownerId: "u1",
|
||||||
|
hostId: 5,
|
||||||
|
columns,
|
||||||
|
body: { authType: "vault", credentialId: 0 },
|
||||||
|
stored: stored as never,
|
||||||
|
});
|
||||||
|
expect(columns.authType).toBe("vault");
|
||||||
|
expect(overrides.core).toContain("auth");
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps a locked key's state for a shared editor", async () => {
|
it("keeps a locked key's state for a shared editor", async () => {
|
||||||
const overrides = await applyHostDefaultsToWrite({
|
const overrides = await applyHostDefaultsToWrite({
|
||||||
ownerId: "u1",
|
ownerId: "u1",
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import type { WebSocket } from "ws";
|
import type { WebSocket } from "ws";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { SSHHostKeyVerifier } from "../../hosts/host-key-verifier.js";
|
import { SSHHostKeyVerifier } from "../../hosts/host-key-verifier.js";
|
||||||
|
|
||||||
|
const repo = vi.hoisted(() => ({
|
||||||
|
updateHostKey: vi.fn(async () => {}),
|
||||||
|
storeHostKey: vi.fn(async () => {}),
|
||||||
|
touchHostKeyLastVerified: vi.fn(async () => {}),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../../database/repositories/factory.js", () => ({
|
vi.mock("../../database/repositories/factory.js", () => ({
|
||||||
createCurrentHostResolutionRepository: () => ({}),
|
createCurrentHostResolutionRepository: () => repo,
|
||||||
}));
|
}));
|
||||||
vi.mock("../../utils/logger.js", () => ({
|
vi.mock("../../utils/logger.js", () => ({
|
||||||
sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||||
@@ -57,3 +63,50 @@ describe("quick connect host keys", () => {
|
|||||||
expect(await verifyUnsaved(null)).toBe(true);
|
expect(await verifyUnsaved(null)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const pinned = {
|
||||||
|
hostKeyFingerprint: Buffer.from("old-key").toString("hex"),
|
||||||
|
hostKeyType: "ssh-ed25519",
|
||||||
|
hostKeyAlgorithm: "sha256",
|
||||||
|
hostKeyChangedCount: 0,
|
||||||
|
name: "bastion",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function verifyJumpHop(socket: WebSocket | null, key: string) {
|
||||||
|
const verifier = await SSHHostKeyVerifier.createHostVerifier(
|
||||||
|
7,
|
||||||
|
"10.0.0.1",
|
||||||
|
22,
|
||||||
|
socket,
|
||||||
|
"user",
|
||||||
|
true,
|
||||||
|
pinned,
|
||||||
|
);
|
||||||
|
return new Promise<boolean>((resolve) => verifier(Buffer.from(key), resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("jump host key changes", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
repo.updateHostKey.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a changed key and keeps the pin when nobody can be asked", async () => {
|
||||||
|
expect(await verifyJumpHop(null, "new-key")).toBe(false);
|
||||||
|
expect(repo.updateHostKey).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks the user and only replaces the pin when they accept", async () => {
|
||||||
|
const no = socketAnswering("reject");
|
||||||
|
expect(await verifyJumpHop(no.socket, "new-key")).toBe(false);
|
||||||
|
expect(no.sent[0]).toContain("host_key_changed");
|
||||||
|
expect(repo.updateHostKey).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const yes = socketAnswering("accept");
|
||||||
|
expect(await verifyJumpHop(yes.socket, "new-key")).toBe(true);
|
||||||
|
expect(repo.updateHostKey).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still accepts the pinned key without asking", async () => {
|
||||||
|
expect(await verifyJumpHop(null, "old-key")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -42,6 +42,52 @@ describe("SSHConnectionPool", () => {
|
|||||||
pool.destroy();
|
pool.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("tracks every client from concurrent connects to a new host", async () => {
|
||||||
|
const pool = new SSHConnectionPool();
|
||||||
|
const clients = Array.from({ length: 3 }, deferredClient);
|
||||||
|
let factoryCalls = 0;
|
||||||
|
const factory = vi.fn(() => clients[factoryCalls++].promise);
|
||||||
|
|
||||||
|
const requests = Array.from({ length: 3 }, () =>
|
||||||
|
pool.getConnection("new-host", factory),
|
||||||
|
);
|
||||||
|
for (const pending of clients) pending.resolve(pending.client);
|
||||||
|
const connected = await Promise.all(requests);
|
||||||
|
|
||||||
|
for (const client of connected) pool.releaseConnection("new-host", client);
|
||||||
|
const reused = await Promise.all(
|
||||||
|
Array.from({ length: 3 }, () => pool.getConnection("new-host", factory)),
|
||||||
|
);
|
||||||
|
expect(new Set(reused)).toEqual(new Set(connected));
|
||||||
|
expect(factory).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
pool.clearKeyConnections("new-host");
|
||||||
|
for (const pending of clients) {
|
||||||
|
expect(pending.client.end).toHaveBeenCalledOnce();
|
||||||
|
}
|
||||||
|
pool.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not resurrect a connection removed while another connects", async () => {
|
||||||
|
const pool = new SSHConnectionPool();
|
||||||
|
const first = deferredClient();
|
||||||
|
const second = deferredClient();
|
||||||
|
first.resolve(first.client);
|
||||||
|
await pool.getConnection("host", () => first.promise);
|
||||||
|
|
||||||
|
const request = pool.getConnection("host", () => second.promise);
|
||||||
|
first.client.emit("close");
|
||||||
|
second.resolve(second.client);
|
||||||
|
await expect(request).resolves.toBe(second.client);
|
||||||
|
|
||||||
|
pool.releaseConnection("host", first.client);
|
||||||
|
pool.releaseConnection("host", second.client);
|
||||||
|
await expect(
|
||||||
|
pool.getConnection("host", () => deferredClient().promise),
|
||||||
|
).resolves.toBe(second.client);
|
||||||
|
pool.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
it("discards a pending connection when its host pool is cleared", async () => {
|
it("discards a pending connection when its host pool is cleared", async () => {
|
||||||
const pool = new SSHConnectionPool();
|
const pool = new SSHConnectionPool();
|
||||||
const pending = deferredClient();
|
const pending = deferredClient();
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { describe, expect, it } from "vitest";
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import zlib from "node:zlib";
|
||||||
import * as tar from "tar";
|
import * as tar from "tar";
|
||||||
import {
|
import {
|
||||||
readArtifactFile,
|
readArtifactFiles,
|
||||||
readArtifactManifest,
|
readArtifactManifest,
|
||||||
sameCapabilities,
|
sameCapabilities,
|
||||||
} from "../../plugins/artifact-manifest.js";
|
} from "../../plugins/artifact-manifest.js";
|
||||||
@@ -27,20 +28,42 @@ function pack(files: Record<string, string>, prefix = ""): Buffer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("readArtifactFile", () => {
|
/** Two archives' entries back to back, so a name can appear twice. */
|
||||||
|
function concat(first: Buffer, second: Buffer): Buffer {
|
||||||
|
const entries = (buffer: Buffer) => {
|
||||||
|
const raw = zlib.gunzipSync(buffer);
|
||||||
|
// Drop the two zero blocks that end every tar.
|
||||||
|
return raw.subarray(0, raw.length - 1024);
|
||||||
|
};
|
||||||
|
return zlib.gzipSync(
|
||||||
|
Buffer.concat([entries(first), entries(second), Buffer.alloc(1024)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("readArtifactFiles", () => {
|
||||||
it("finds a file by name, with or without a ./ prefix", () => {
|
it("finds a file by name, with or without a ./ prefix", () => {
|
||||||
const plain = pack({ "manifest.json": "{}", "dist/a.js": "x" });
|
const plain = pack({ "manifest.json": "{}", "dist/a.js": "x" });
|
||||||
expect(readArtifactFile(plain, "dist/a.js")?.toString()).toBe("x");
|
expect(
|
||||||
|
readArtifactFiles(plain, "dist/a.js").map((b) => b.toString()),
|
||||||
|
).toEqual(["x"]);
|
||||||
const dotted = pack({ "manifest.json": '{"id":"a"}' }, "./");
|
const dotted = pack({ "manifest.json": '{"id":"a"}' }, "./");
|
||||||
expect(readArtifactFile(dotted, "manifest.json")?.toString()).toBe(
|
expect(
|
||||||
'{"id":"a"}',
|
readArtifactFiles(dotted, "manifest.json").map((b) => b.toString()),
|
||||||
|
).toEqual(['{"id":"a"}']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing for a missing file", () => {
|
||||||
|
expect(readArtifactFiles(pack({ "a.txt": "a" }), "manifest.json")).toEqual(
|
||||||
|
[],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for a missing file", () => {
|
it("resolves names that need a pax header", () => {
|
||||||
expect(readArtifactFile(pack({ "a.txt": "a" }), "manifest.json")).toBe(
|
const long = `dist/${"x".repeat(150)}.js`;
|
||||||
null,
|
const archive = pack({ "manifest.json": "{}", [long]: "x" });
|
||||||
);
|
expect(readArtifactFiles(archive, long).map((b) => b.toString())).toEqual([
|
||||||
|
"x",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,6 +91,18 @@ describe("readArtifactManifest", () => {
|
|||||||
readArtifactManifest(pack({ "manifest.json": "[1]" })),
|
readArtifactManifest(pack({ "manifest.json": "[1]" })),
|
||||||
).toThrow(/not an object/);
|
).toThrow(/not an object/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("refuses an archive with two manifests", () => {
|
||||||
|
const first = pack({ "manifest.json": '{"capabilities":[]}' });
|
||||||
|
const second = pack({ "manifest.json": '{"capabilities":["ssh"]}' });
|
||||||
|
expect(() => readArtifactManifest(concat(first, second))).toThrow(
|
||||||
|
/more than one manifest.json/,
|
||||||
|
);
|
||||||
|
const upper = pack({ "MANIFEST.JSON": '{"capabilities":["ssh"]}' });
|
||||||
|
expect(() => readArtifactManifest(concat(first, upper))).toThrow(
|
||||||
|
/more than one manifest.json/,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sameCapabilities", () => {
|
describe("sameCapabilities", () => {
|
||||||
|
|||||||
@@ -461,6 +461,19 @@ describe("public route rate limit", () => {
|
|||||||
expect(http.overPublicRateLimit("other", "1.2.3.4", now)).toBe(false);
|
expect(http.overPublicRateLimit("other", "1.2.3.4", now)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lets a plugin raise its own limit, capped, unless the admin set one", () => {
|
||||||
|
const hits = (limit: number, count: number) =>
|
||||||
|
Array.from({ length: count }, () =>
|
||||||
|
http.overPublicRateLimit("proxy", "ip", 0, limit),
|
||||||
|
).filter(Boolean).length;
|
||||||
|
expect(hits(500, 500)).toBe(0);
|
||||||
|
http.resetPublicRateLimits();
|
||||||
|
expect(hits(50_000, 10_001)).toBe(1);
|
||||||
|
http.resetPublicRateLimits();
|
||||||
|
process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT = "2";
|
||||||
|
expect(hits(500, 3)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("starts a new window after a minute", () => {
|
it("starts a new window after a minute", () => {
|
||||||
process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT = "1";
|
process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT = "1";
|
||||||
expect(http.overPublicRateLimit("demo", "ip", 0)).toBe(false);
|
expect(http.overPublicRateLimit("demo", "ip", 0)).toBe(false);
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ vi.mock("../../plugins/data.js", () => ({
|
|||||||
forgetTables: () => {},
|
forgetTables: () => {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../plugins/permissions.js", () => ({
|
vi.mock("../../plugins/permissions.js", async (importOriginal) => ({
|
||||||
invalidatePluginPermissionCache: () => {},
|
...(await importOriginal<typeof import("../../plugins/permissions.js")>()),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../plugins/registry-index.js", async (importOriginal) => {
|
vi.mock("../../plugins/registry-index.js", async (importOriginal) => {
|
||||||
@@ -1084,3 +1084,48 @@ describe("more registries", () => {
|
|||||||
).rejects.toMatchObject({ code: "KEY_CHANGED" });
|
).rejects.toMatchObject({ code: "KEY_CHANGED" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requires exact consent before enabling a mirrored plugin with capabilities", async () => {
|
||||||
|
createFixturePlugin({
|
||||||
|
id: "manual-mirror",
|
||||||
|
root: path.join(data, "plugins"),
|
||||||
|
capabilities: ["kv:own"],
|
||||||
|
backendSource:
|
||||||
|
'export async function activate(ctx) { await ctx.kv.set("probe", "value"); }',
|
||||||
|
});
|
||||||
|
await boot();
|
||||||
|
const options = {
|
||||||
|
version: "1.0.0",
|
||||||
|
capabilities: ["kv:own"],
|
||||||
|
userId: "admin",
|
||||||
|
};
|
||||||
|
await expect(
|
||||||
|
manage.enablePluginWithConsent("manual-mirror", {
|
||||||
|
...options,
|
||||||
|
version: "0.9.0",
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "CONSENT_MISMATCH" });
|
||||||
|
await expect(
|
||||||
|
manage.enablePluginWithConsent("manual-mirror", {
|
||||||
|
...options,
|
||||||
|
capabilities: [],
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "CONSENT_MISMATCH" });
|
||||||
|
await expect(
|
||||||
|
manage.enablePluginWithConsent("manual-mirror", {
|
||||||
|
version: "1.0.0",
|
||||||
|
userId: "admin",
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "CONSENT_REQUIRED" });
|
||||||
|
expect(db.grants).toEqual([]);
|
||||||
|
expect(db.rows.get("manual-mirror")?.state).toBe("disabled");
|
||||||
|
const result = await manage.enablePluginWithConsent("manual-mirror", options);
|
||||||
|
expect(result.state).toBe("active");
|
||||||
|
expect(db.grants).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
pluginId: "manual-mirror",
|
||||||
|
capability: "kv:own",
|
||||||
|
grantedBy: "admin",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -256,4 +256,25 @@ describe("keys from a linked server", () => {
|
|||||||
await registries.setSyncedRegistryKeys([]);
|
await registries.setSyncedRegistryKeys([]);
|
||||||
expect(getCustomPluginKeys()).toEqual([]);
|
expect(getCustomPluginKeys()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("marks them as synced unless an admin added the same key here", async () => {
|
||||||
|
const synced = newKey();
|
||||||
|
const shared = newKey();
|
||||||
|
serve(URL_A, { registry: "acme", publicKey: shared });
|
||||||
|
await registries.addRegistry(URL_A, pluginKeyId(shared));
|
||||||
|
await registries.setSyncedRegistryKeys([synced, shared]);
|
||||||
|
|
||||||
|
expect(registries.isLocallyTrustedPluginKey(pluginKeyId(synced))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(registries.isLocallyTrustedPluginKey(pluginKeyId(shared))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(registries.isLocallyTrustedPluginKey("ec40e29bcc4986b2")).toBe(true);
|
||||||
|
await registries.setSyncedRegistryKeys([]);
|
||||||
|
expect(registries.isLocallyTrustedPluginKey(pluginKeyId(synced))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(registries.isLocallyTrustedPluginKey("unknown-key")).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -308,4 +308,11 @@ describe("adoptTableSql", () => {
|
|||||||
const joined = adoptTableSql("sqlite", "fleets", adopted).join("\n");
|
const joined = adoptTableSql("sqlite", "fleets", adopted).join("\n");
|
||||||
expect(splitStatements(joined)).toHaveLength(3);
|
expect(splitStatements(joined)).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("drops a chunk that is only comments", () => {
|
||||||
|
expect(splitStatements("-- nothing to do here.\n")).toEqual([]);
|
||||||
|
expect(
|
||||||
|
splitStatements("/* note */;\n-- why\nSELECT 1;\n-- trailing"),
|
||||||
|
).toEqual(["-- why\nSELECT 1"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -11,6 +11,8 @@ const state = vi.hoisted(() => ({
|
|||||||
fetch: vi.fn(),
|
fetch: vi.fn(),
|
||||||
loadBundled: vi.fn(),
|
loadBundled: vi.fn(),
|
||||||
syncedKeys: vi.fn(async (_keys: unknown[]) => {}),
|
syncedKeys: vi.fn(async (_keys: unknown[]) => {}),
|
||||||
|
syncedOnlyKeys: new Set<string>(),
|
||||||
|
grants: [] as string[],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../../utils/logger.js", () => ({
|
vi.mock("../../../utils/logger.js", () => ({
|
||||||
@@ -28,10 +30,16 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
|||||||
state: state.enabled.has(id) ? "enabled" : "disabled",
|
state: state.enabled.has(id) ? "enabled" : "disabled",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
createCurrentPluginPermissionGrantRepository: () => ({}),
|
createCurrentPluginPermissionGrantRepository: () => ({
|
||||||
|
listByPlugin: async () => [],
|
||||||
|
grant: async ({ capability }: { capability: string }) =>
|
||||||
|
void state.grants.push(capability),
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
vi.mock("../../../plugins/registries.js", () => ({
|
vi.mock("../../../plugins/registries.js", () => ({
|
||||||
setSyncedRegistryKeys: state.syncedKeys,
|
setSyncedRegistryKeys: state.syncedKeys,
|
||||||
|
isLocallyTrustedPluginKey: (keyId: string) =>
|
||||||
|
keyId === "local-key" && !state.syncedOnlyKeys.has(keyId),
|
||||||
}));
|
}));
|
||||||
vi.mock("../../../sync/client/http.js", () => ({
|
vi.mock("../../../sync/client/http.js", () => ({
|
||||||
remoteFetch: state.fetch,
|
remoteFetch: state.fetch,
|
||||||
@@ -56,8 +64,10 @@ vi.mock("../../../plugins/index.js", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { mirrorPlugins } = await import("../../../sync/client/plugins.js");
|
const { mirrorPlugins, isServerManaged } =
|
||||||
|
await import("../../../sync/client/plugins.js");
|
||||||
const { remoteJson } = await import("../../../sync/client/http.js");
|
const { remoteJson } = await import("../../../sync/client/http.js");
|
||||||
|
const { sendCoreAlert } = await import("../../../notify/core-notify.js");
|
||||||
|
|
||||||
const bundled = {
|
const bundled = {
|
||||||
id: "ssh-terminal",
|
id: "ssh-terminal",
|
||||||
@@ -92,6 +102,9 @@ beforeEach(() => {
|
|||||||
state.install.mockReset();
|
state.install.mockReset();
|
||||||
state.fetch.mockReset();
|
state.fetch.mockReset();
|
||||||
state.loadBundled.mockReset();
|
state.loadBundled.mockReset();
|
||||||
|
state.syncedOnlyKeys = new Set();
|
||||||
|
state.grants = [];
|
||||||
|
vi.mocked(sendCoreAlert).mockClear();
|
||||||
state.loadBundled.mockImplementation(async (id: string) => {
|
state.loadBundled.mockImplementation(async (id: string) => {
|
||||||
const plugin = { ...bundled, id };
|
const plugin = { ...bundled, id };
|
||||||
state.loaded.set(id, plugin);
|
state.loaded.set(id, plugin);
|
||||||
@@ -171,4 +184,226 @@ describe("mirrorPlugins", () => {
|
|||||||
expect(state.loaded.has("ssh-terminal")).toBe(true);
|
expect(state.loaded.has("ssh-terminal")).toBe(true);
|
||||||
expect(state.enabled.has("ssh-terminal")).toBe(true);
|
expect(state.enabled.has("ssh-terminal")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("a user plugin the server has on", () => {
|
||||||
|
function serverRunsSignedBy(
|
||||||
|
keyId: string,
|
||||||
|
enabled = true,
|
||||||
|
version = keyId === "removed-key" ? "1.0.1" : "1.0.0",
|
||||||
|
) {
|
||||||
|
state.loaded.set("extra", {
|
||||||
|
id: "extra",
|
||||||
|
source: "user",
|
||||||
|
state: "loaded",
|
||||||
|
signedBy: keyId,
|
||||||
|
manifest: { id: "extra", version, capabilities: ["net"] },
|
||||||
|
});
|
||||||
|
vi.mocked(remoteJson).mockResolvedValue({
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
id: "extra",
|
||||||
|
name: "Extra",
|
||||||
|
version,
|
||||||
|
source: "user",
|
||||||
|
artifact: true,
|
||||||
|
enabled,
|
||||||
|
active: enabled,
|
||||||
|
desktop: "mirror",
|
||||||
|
syncEntities: [],
|
||||||
|
granted: ["net"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("turns it on when a key this desktop trusts signed it", async () => {
|
||||||
|
serverRunsSignedBy("local-key");
|
||||||
|
|
||||||
|
await mirrorPlugins({} as never);
|
||||||
|
|
||||||
|
expect(state.enabled.has("extra")).toBe(true);
|
||||||
|
expect(state.grants).toEqual(["net"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["server-key", "removed-key"])(
|
||||||
|
"leaves it off for untrusted key %s",
|
||||||
|
async (keyId) => {
|
||||||
|
serverRunsSignedBy(keyId);
|
||||||
|
state.syncedOnlyKeys.add("server-key");
|
||||||
|
|
||||||
|
await mirrorPlugins({} as never);
|
||||||
|
|
||||||
|
expect(state.enabled.has("extra")).toBe(false);
|
||||||
|
expect(state.grants).toEqual([]);
|
||||||
|
expect(sendCoreAlert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ category: "termix.sync.unsigned_plugin" }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("leaves the server in charge of one a trusted key signed", () => {
|
||||||
|
serverRunsSignedBy("local-key");
|
||||||
|
expect(isServerManaged("extra")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["server-key", "removed-key"])(
|
||||||
|
"lets this desktop's admin decide on one signed by %s",
|
||||||
|
async (keyId) => {
|
||||||
|
serverRunsSignedBy(keyId);
|
||||||
|
state.syncedOnlyKeys.add("server-key");
|
||||||
|
expect(isServerManaged("extra")).toBe(false);
|
||||||
|
|
||||||
|
state.enabled.add("extra");
|
||||||
|
await mirrorPlugins({} as never);
|
||||||
|
expect(state.enabled.has("extra")).toBe(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("still switches an untrusted one off when the server does", async () => {
|
||||||
|
serverRunsSignedBy("server-key", false);
|
||||||
|
state.syncedOnlyKeys.add("server-key");
|
||||||
|
state.enabled.add("extra");
|
||||||
|
|
||||||
|
await mirrorPlugins({} as never);
|
||||||
|
|
||||||
|
expect(state.enabled.has("extra")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["server-key", false],
|
||||||
|
["local-key", true],
|
||||||
|
])(
|
||||||
|
"after an update signed by %s, keeps it on: %s",
|
||||||
|
async (keyId, stays) => {
|
||||||
|
serverRunsSignedBy(keyId, true, "2.0.0");
|
||||||
|
state.syncedOnlyKeys.add("server-key");
|
||||||
|
const reviewed = state.loaded.get("extra")!;
|
||||||
|
state.loaded.set("extra", {
|
||||||
|
...reviewed,
|
||||||
|
manifest: { id: "extra", version: "1.0.0", capabilities: ["net"] },
|
||||||
|
});
|
||||||
|
state.enabled.add("extra");
|
||||||
|
state.fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
arrayBuffer: async () => new ArrayBuffer(4),
|
||||||
|
headers: new Headers(),
|
||||||
|
});
|
||||||
|
state.install.mockImplementation(async () => {
|
||||||
|
const plugin = { ...reviewed };
|
||||||
|
state.loaded.set("extra", plugin);
|
||||||
|
return plugin;
|
||||||
|
});
|
||||||
|
|
||||||
|
await mirrorPlugins({} as never);
|
||||||
|
|
||||||
|
expect(state.install).toHaveBeenCalled();
|
||||||
|
expect(state.enabled.has("extra")).toBe(stays);
|
||||||
|
expect(vi.mocked(sendCoreAlert).mock.calls.length > 0).toBe(!stays);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("server bundled label must not enable a synced-only user plugin", async () => {
|
||||||
|
state.syncedOnlyKeys.add("server-key");
|
||||||
|
state.loaded.set("extra", {
|
||||||
|
id: "extra",
|
||||||
|
source: "user",
|
||||||
|
state: "loaded",
|
||||||
|
signedBy: "server-key",
|
||||||
|
manifest: { id: "extra", version: "1.0.0", capabilities: [] },
|
||||||
|
});
|
||||||
|
vi.mocked(remoteJson).mockResolvedValue({
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
id: "extra",
|
||||||
|
name: "Extra",
|
||||||
|
version: "1.0.0",
|
||||||
|
source: "bundled",
|
||||||
|
artifact: true,
|
||||||
|
enabled: true,
|
||||||
|
active: true,
|
||||||
|
desktop: "mirror",
|
||||||
|
syncEntities: [],
|
||||||
|
granted: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await mirrorPlugins({} as never);
|
||||||
|
expect(state.enabled.has("extra")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remote bundled label cannot preserve approval across an untrusted update", async () => {
|
||||||
|
const reviewed = {
|
||||||
|
id: "extra",
|
||||||
|
source: "user",
|
||||||
|
state: "loaded",
|
||||||
|
signedBy: "server-key",
|
||||||
|
manifest: { id: "extra", version: "1.0.0", capabilities: [] },
|
||||||
|
};
|
||||||
|
state.loaded.set("extra", reviewed);
|
||||||
|
state.syncedOnlyKeys.add("server-key");
|
||||||
|
state.enabled.add("extra");
|
||||||
|
vi.mocked(remoteJson).mockResolvedValue({
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
id: "extra",
|
||||||
|
name: "Extra",
|
||||||
|
version: "2.0.0",
|
||||||
|
source: "bundled",
|
||||||
|
artifact: true,
|
||||||
|
enabled: true,
|
||||||
|
active: true,
|
||||||
|
desktop: "mirror",
|
||||||
|
syncEntities: [],
|
||||||
|
granted: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
state.fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
arrayBuffer: async () => new ArrayBuffer(4),
|
||||||
|
headers: new Headers(),
|
||||||
|
});
|
||||||
|
state.install.mockImplementation(async () => {
|
||||||
|
const plugin = {
|
||||||
|
...reviewed,
|
||||||
|
manifest: { ...reviewed.manifest, version: "2.0.0" },
|
||||||
|
};
|
||||||
|
state.loaded.set("extra", plugin);
|
||||||
|
return plugin;
|
||||||
|
});
|
||||||
|
await mirrorPlugins({} as never);
|
||||||
|
expect(state.install).toHaveBeenCalledOnce();
|
||||||
|
expect(state.enabled.has("extra")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for an in-flight local plugin change before applying a remote update", async () => {
|
||||||
|
const { serialized } = await import("../../../plugins/manage.js");
|
||||||
|
let release!: () => void;
|
||||||
|
let entered!: () => void;
|
||||||
|
const ready = new Promise<void>((resolve) => {
|
||||||
|
entered = resolve;
|
||||||
|
});
|
||||||
|
const barrier = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
const localChange = serialized(async () => {
|
||||||
|
entered();
|
||||||
|
await barrier;
|
||||||
|
});
|
||||||
|
await ready;
|
||||||
|
serverHas("1.1.0");
|
||||||
|
state.fetch.mockResolvedValue({ ok: false, status: 502 });
|
||||||
|
vi.mocked(remoteJson).mockClear();
|
||||||
|
const syncing = mirrorPlugins({} as never);
|
||||||
|
try {
|
||||||
|
await vi.waitFor(() => expect(remoteJson).toHaveBeenCalled());
|
||||||
|
expect(state.fetch).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
await localChange;
|
||||||
|
await syncing;
|
||||||
|
expect(state.fetch).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
@@ -14,6 +14,7 @@ const state = vi.hoisted(() => ({
|
|||||||
current: null as null | { drizzle: unknown },
|
current: null as null | { drizzle: unknown },
|
||||||
server: null as null | { drizzle: unknown },
|
server: null as null | { drizzle: unknown },
|
||||||
allow: true,
|
allow: true,
|
||||||
|
sharedHostIds: new Set<number>(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const SERVER_USER = "srv-user";
|
const SERVER_USER = "srv-user";
|
||||||
@@ -38,13 +39,18 @@ vi.mock("../../utils/system-crypto.js", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
vi.mock("../../utils/data-crypto.js", () => ({
|
vi.mock("../../utils/data-crypto.js", () => ({
|
||||||
DataCrypto: { getUserDataKey: () => Buffer.alloc(32, 3) },
|
DataCrypto: {
|
||||||
|
getUserDataKey: () => Buffer.alloc(32, 3),
|
||||||
|
decryptRecord: (_table: string, record: unknown) => record,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
vi.mock("../../utils/permission-manager.js", () => ({
|
vi.mock("../../utils/permission-manager.js", () => ({
|
||||||
PermissionManager: {
|
PermissionManager: {
|
||||||
getInstance: () => ({
|
getInstance: () => ({
|
||||||
hasPermission: async () => state.allow,
|
hasPermission: async () => state.allow,
|
||||||
canAccessHost: async () => ({ hasAccess: false }),
|
canAccessHost: async (_userId: string, hostId: number) => ({
|
||||||
|
hasAccess: state.sharedHostIds.has(hostId),
|
||||||
|
}),
|
||||||
getUserPermissions: async () => [],
|
getUserPermissions: async () => [],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -147,11 +153,14 @@ const { default: syncRouter } = await import("../../sync/server/routes.js");
|
|||||||
const { SyncEngine } = await import("../../sync/client/engine.js");
|
const { SyncEngine } = await import("../../sync/client/engine.js");
|
||||||
const { createLink, getLink, updateLink } =
|
const { createLink, getLink, updateLink } =
|
||||||
await import("../../sync/client/link-store.js");
|
await import("../../sync/client/link-store.js");
|
||||||
const { markChanged, resetFeed } = await import("../../sync/server/feed.js");
|
const { markChanged, reconcileUser, resetFeed } =
|
||||||
|
await import("../../sync/server/feed.js");
|
||||||
const { resetSeqAllocator, listConflicts, getConflict, deleteConflict } =
|
const { resetSeqAllocator, listConflicts, getConflict, deleteConflict } =
|
||||||
await import("../../sync/records.js");
|
await import("../../sync/records.js");
|
||||||
const { createResolvers, writeWireRow } = await import("../../sync/store.js");
|
const { createResolvers, writeWireRow } = await import("../../sync/store.js");
|
||||||
const { getEntity } = await import("../../plugins/sync-registry.js");
|
const { getEntity } = await import("../../plugins/sync-registry.js");
|
||||||
|
const { FieldCrypto } = await import("../../utils/field-crypto.js");
|
||||||
|
const { syncLogger } = await import("../../utils/logger.js");
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json({ limit: "5mb" }));
|
app.use(express.json({ limit: "5mb" }));
|
||||||
@@ -239,6 +248,7 @@ beforeEach(async () => {
|
|||||||
resetFeed();
|
resetFeed();
|
||||||
resetSeqAllocator();
|
resetSeqAllocator();
|
||||||
state.allow = true;
|
state.allow = true;
|
||||||
|
state.sharedHostIds.clear();
|
||||||
server = await side(SERVER_USER);
|
server = await side(SERVER_USER);
|
||||||
state.server = server.ctx;
|
state.server = server.ctx;
|
||||||
desktops = [await side("desk-a"), await side("desk-b")];
|
desktops = [await side("desk-a"), await side("desk-b")];
|
||||||
@@ -480,6 +490,181 @@ describe("after linking", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("a server row that cannot be read", () => {
|
||||||
|
async function recordOf(where: Side, syncId: string) {
|
||||||
|
const [record] = await on(where, () =>
|
||||||
|
db(where)
|
||||||
|
.select()
|
||||||
|
.from(schema.syncRecords)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.syncRecords.entityType, "hosts"),
|
||||||
|
eq(schema.syncRecords.syncId, syncId),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
it.each(["upsert", "delete"] as const)(
|
||||||
|
"retries a local %s after the server row becomes readable",
|
||||||
|
async (op) => {
|
||||||
|
const [desk] = desktops;
|
||||||
|
await on(server, () => addHost(server, { name: "a" }));
|
||||||
|
const engine = await linkDesktop(desk);
|
||||||
|
await pass(desk, engine);
|
||||||
|
|
||||||
|
await on(desk, () =>
|
||||||
|
(op === "delete"
|
||||||
|
? db(desk).delete(schema.hosts)
|
||||||
|
: db(desk).update(schema.hosts).set({ ip: "desk-edit" })
|
||||||
|
).where(eq(schema.hosts.syncId, "host-a")),
|
||||||
|
);
|
||||||
|
await on(server, async () => {
|
||||||
|
await db(server)
|
||||||
|
.update(schema.hosts)
|
||||||
|
.set({ ip: "server-edit" })
|
||||||
|
.where(eq(schema.hosts.syncId, "host-a"));
|
||||||
|
await reconcileUser(SERVER_USER, { force: true });
|
||||||
|
// Then its secret stops decrypting, as under a different key.
|
||||||
|
const [host] = await hostsOf(server);
|
||||||
|
await db(server)
|
||||||
|
.update(schema.hosts)
|
||||||
|
.set({
|
||||||
|
password: FieldCrypto.encryptField(
|
||||||
|
"s3cret",
|
||||||
|
Buffer.alloc(32, 9),
|
||||||
|
String(host.id),
|
||||||
|
"password",
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.where(eq(schema.hosts.id, host.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
const warn = vi.spyOn(syncLogger, "warn");
|
||||||
|
try {
|
||||||
|
await pass(desk, engine);
|
||||||
|
expect(warn).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
entityType: "hosts",
|
||||||
|
syncId: "host-a",
|
||||||
|
error: expect.any(String),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
warn.mockRestore();
|
||||||
|
}
|
||||||
|
|
||||||
|
const [local] = await on(desk, () => hostsOf(desk));
|
||||||
|
expect(local?.ip).toBe(op === "upsert" ? "desk-edit" : undefined);
|
||||||
|
expect(await on(desk, () => listConflicts(desk.userId))).toEqual([]);
|
||||||
|
expect((await recordOf(desk, "host-a")).error).toBe("unavailable");
|
||||||
|
|
||||||
|
// Restore the same server content without another local edit or a new
|
||||||
|
// server revision. The failed operation must still be retried.
|
||||||
|
await on(server, () =>
|
||||||
|
db(server)
|
||||||
|
.update(schema.hosts)
|
||||||
|
.set({ password: null })
|
||||||
|
.where(eq(schema.hosts.syncId, "host-a")),
|
||||||
|
);
|
||||||
|
await on(server, () => reconcileUser(SERVER_USER, { force: true }));
|
||||||
|
await pass(desk, engine);
|
||||||
|
const [after] = await on(desk, () => hostsOf(desk));
|
||||||
|
expect(after?.ip).toBe("server-edit");
|
||||||
|
expect((await recordOf(desk, "host-a")).error).toBeNull();
|
||||||
|
const conflicts = await on(desk, () => listConflicts(desk.userId));
|
||||||
|
expect(conflicts.map((c) => JSON.parse(c.localRow).ip)).toEqual(
|
||||||
|
op === "upsert" ? ["desk-edit"] : [],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("keeps the local row when a conflict carries no row", async () => {
|
||||||
|
const [desk] = desktops;
|
||||||
|
await on(server, () => addHost(server, { name: "a" }));
|
||||||
|
const engine = await linkDesktop(desk);
|
||||||
|
await pass(desk, engine);
|
||||||
|
|
||||||
|
await on(desk, async () => {
|
||||||
|
const link = (await getLink())!;
|
||||||
|
const settle = (
|
||||||
|
engine as unknown as {
|
||||||
|
settle: (...args: unknown[]) => Promise<void>;
|
||||||
|
}
|
||||||
|
).settle.bind(engine);
|
||||||
|
await settle(
|
||||||
|
link,
|
||||||
|
{
|
||||||
|
entityType: "hosts",
|
||||||
|
syncId: "host-a",
|
||||||
|
baseRevision: 1,
|
||||||
|
op: "upsert",
|
||||||
|
row: { syncId: "host-a", name: "a" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
entityType: "hosts",
|
||||||
|
syncId: "host-a",
|
||||||
|
status: "conflict",
|
||||||
|
revision: 2,
|
||||||
|
deleted: false,
|
||||||
|
row: null,
|
||||||
|
},
|
||||||
|
"hash",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await on(desk, () => hostsOf(desk))).map((h) => h.name)).toEqual([
|
||||||
|
"a",
|
||||||
|
]);
|
||||||
|
const record = await recordOf(desk, "host-a");
|
||||||
|
expect(record.deleted).toBe(false);
|
||||||
|
expect(record.revision).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hosts shared with the account", () => {
|
||||||
|
it("arrive on the desktop with their text columns as stored", async () => {
|
||||||
|
const [desk] = desktops;
|
||||||
|
await on(server, async () => {
|
||||||
|
await server.db.run(
|
||||||
|
sql`INSERT INTO users (id, username, password_hash, is_admin) VALUES ('owner', 'owner', '', 0)`,
|
||||||
|
);
|
||||||
|
await db(server).insert(schema.hosts).values({
|
||||||
|
userId: "owner",
|
||||||
|
name: "theirs",
|
||||||
|
ip: "10.0.0.5",
|
||||||
|
port: 22,
|
||||||
|
username: "root",
|
||||||
|
authType: "none",
|
||||||
|
forceKeyboardInteractive: "true",
|
||||||
|
syncId: "host-theirs",
|
||||||
|
});
|
||||||
|
const [host] = await db(server).select().from(schema.hosts);
|
||||||
|
await db(server).insert(schema.hostAccess).values({
|
||||||
|
hostId: host.id,
|
||||||
|
userId: SERVER_USER,
|
||||||
|
grantedBy: "owner",
|
||||||
|
permissionLevel: "view",
|
||||||
|
});
|
||||||
|
state.sharedHostIds.add(host.id);
|
||||||
|
markChanged();
|
||||||
|
});
|
||||||
|
|
||||||
|
const engine = await linkDesktop(desk);
|
||||||
|
await pass(desk, engine);
|
||||||
|
|
||||||
|
const [copy] = await on(desk, () => hostsOf(desk));
|
||||||
|
expect(copy?.name).toBe("theirs");
|
||||||
|
expect(copy.forceKeyboardInteractive).toBe("true");
|
||||||
|
expect(JSON.parse(copy.sharedSource!)).toMatchObject({
|
||||||
|
owner: "owner",
|
||||||
|
permissionLevel: "view",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("two desktops on one account", () => {
|
describe("two desktops on one account", () => {
|
||||||
it("converge on edits, creates and deletes made offline on each", async () => {
|
it("converge on edits, creates and deletes made offline on each", async () => {
|
||||||
const [a, b] = desktops;
|
const [a, b] = desktops;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
getTrustedProxyAuthConfig,
|
getTrustedProxyAuthConfig,
|
||||||
|
getTrustedProxySourceAddress,
|
||||||
isTrustedProxyAddress,
|
isTrustedProxyAddress,
|
||||||
parseTrustedProxyRoleMap,
|
parseTrustedProxyRoleMap,
|
||||||
resolveTrustedProxyRoles,
|
resolveTrustedProxyRoles,
|
||||||
@@ -28,6 +29,35 @@ describe("trusted proxy authentication config", () => {
|
|||||||
expect(isTrustedProxyAddress("2001:db8::5", trusted)).toBe(true);
|
expect(isTrustedProxyAddress("2001:db8::5", trusted)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("judges requests relayed by the bundled nginx by nginx's own peer", () => {
|
||||||
|
const viaNginx = (peer?: string) => ({
|
||||||
|
headers: peer ? { "x-termix-proxy-peer": peer } : {},
|
||||||
|
socket: { remoteAddress: "::ffff:127.0.0.1" },
|
||||||
|
});
|
||||||
|
expect(getTrustedProxySourceAddress(viaNginx("172.20.0.10"))).toBe(
|
||||||
|
"172.20.0.10",
|
||||||
|
);
|
||||||
|
// A client reaching nginx directly is reported as itself, so trusting
|
||||||
|
// the auth proxy no longer means trusting nginx on loopback.
|
||||||
|
expect(
|
||||||
|
isTrustedProxyAddress(
|
||||||
|
getTrustedProxySourceAddress(viaNginx("203.0.113.7")),
|
||||||
|
["127.0.0.1"],
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
// A local reverse proxy that does not set the header keeps working.
|
||||||
|
expect(getTrustedProxySourceAddress(viaNginx())).toBe("::ffff:127.0.0.1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores the peer header on connections that are not from loopback", () => {
|
||||||
|
expect(
|
||||||
|
getTrustedProxySourceAddress({
|
||||||
|
headers: { "x-termix-proxy-peer": "172.20.0.10" },
|
||||||
|
socket: { remoteAddress: "198.51.100.9" },
|
||||||
|
}),
|
||||||
|
).toBe("198.51.100.9");
|
||||||
|
});
|
||||||
|
|
||||||
it("fails closed when a supplied external role is not mapped", () => {
|
it("fails closed when a supplied external role is not mapped", () => {
|
||||||
const roleMap = parseTrustedProxyRoleMap(
|
const roleMap = parseTrustedProxyRoleMap(
|
||||||
JSON.stringify({ operators: ["operator"], viewers: "readonly" }),
|
JSON.stringify({ operators: ["operator"], viewers: "readonly" }),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { IncomingHttpHeaders } from "node:http";
|
||||||
import { BlockList, isIP } from "node:net";
|
import { BlockList, isIP } from "node:net";
|
||||||
|
|
||||||
export interface TrustedProxyAuthConfig {
|
export interface TrustedProxyAuthConfig {
|
||||||
@@ -108,6 +109,31 @@ export function isTrustedProxyAddress(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isLoopbackAddress(address: string): boolean {
|
||||||
|
const normalized = normalizeAddress(address);
|
||||||
|
return normalized === "::1" || normalized.startsWith("127.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The address checked against the allowlist. The bundled nginx always
|
||||||
|
* connects from loopback and sets X-Termix-Proxy-Peer to its own TCP peer,
|
||||||
|
* overwriting whatever the client sent, so behind it the proxy that counts
|
||||||
|
* is that peer rather than nginx. Any other connection is judged by its
|
||||||
|
* socket address.
|
||||||
|
*/
|
||||||
|
export function getTrustedProxySourceAddress(req: {
|
||||||
|
headers: IncomingHttpHeaders;
|
||||||
|
socket: { remoteAddress?: string };
|
||||||
|
}): string | undefined {
|
||||||
|
const socketAddress = req.socket.remoteAddress;
|
||||||
|
const peer = req.headers["x-termix-proxy-peer"];
|
||||||
|
return socketAddress &&
|
||||||
|
isLoopbackAddress(socketAddress) &&
|
||||||
|
typeof peer === "string"
|
||||||
|
? peer
|
||||||
|
: socketAddress;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveTrustedProxyRoles(
|
export function resolveTrustedProxyRoles(
|
||||||
header: string,
|
header: string,
|
||||||
roleMap: Map<string, string[]>,
|
roleMap: Map<string, string[]>,
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ export interface HostSidebarDisplayPreferences extends HostRowFields {
|
|||||||
/** When false, nested folders hide the parent-path breadcrumb before their name. */
|
/** When false, nested folders hide the parent-path breadcrumb before their name. */
|
||||||
showFolderPaths: boolean;
|
showFolderPaths: boolean;
|
||||||
hostClickBehavior: HostClickBehavior;
|
hostClickBehavior: HostClickBehavior;
|
||||||
|
/** When true, the sidebar closes after a host is opened from it. */
|
||||||
|
closeOnOpen: boolean;
|
||||||
/**
|
/**
|
||||||
* Host action id to whether it shows in the row's connect bar. An action
|
* Host action id to whether it shows in the row's connect bar. An action
|
||||||
* missing here follows its own default. Every action stays in the menu.
|
* missing here follows its own default. Every action stays in the menu.
|
||||||
@@ -163,6 +165,7 @@ export function defaultHostSidebarPreferences(): HostSidebarPreferences {
|
|||||||
openOnDoubleClick: false,
|
openOnDoubleClick: false,
|
||||||
showFolderPaths: true,
|
showFolderPaths: true,
|
||||||
hostClickBehavior: "newTab",
|
hostClickBehavior: "newTab",
|
||||||
|
closeOnOpen: false,
|
||||||
barActions: {},
|
barActions: {},
|
||||||
...defaultHostRowFields(),
|
...defaultHostRowFields(),
|
||||||
},
|
},
|
||||||
@@ -274,6 +277,10 @@ export function sanitizeHostSidebarPreferences(
|
|||||||
)
|
)
|
||||||
? (displayObj.hostClickBehavior as HostClickBehavior)
|
? (displayObj.hostClickBehavior as HostClickBehavior)
|
||||||
: defaults.display.hostClickBehavior,
|
: defaults.display.hostClickBehavior,
|
||||||
|
closeOnOpen:
|
||||||
|
typeof displayObj.closeOnOpen === "boolean"
|
||||||
|
? displayObj.closeOnOpen
|
||||||
|
: defaults.display.closeOnOpen,
|
||||||
barActions: sanitizeBarActions(displayObj.barActions),
|
barActions: sanitizeBarActions(displayObj.barActions),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2698,6 +2698,7 @@ export function AppShell({
|
|||||||
if (isMobile) setSidebarOpen(false);
|
if (isMobile) setSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
onEditHost={editHostInManager}
|
onEditHost={editHostInManager}
|
||||||
|
onCloseSidebar={() => setSidebarOpen(false)}
|
||||||
hostTree={realHostTree ?? undefined}
|
hostTree={realHostTree ?? undefined}
|
||||||
loading={hostsLoading}
|
loading={hostsLoading}
|
||||||
onEditingChange={panelEditingSetter("hosts")}
|
onEditingChange={panelEditingSetter("hosts")}
|
||||||
|
|||||||
@@ -263,6 +263,8 @@ export interface PluginSummary {
|
|||||||
/** Environment variables the plugin reads. Admins only. */
|
/** Environment variables the plugin reads. Admins only. */
|
||||||
env?: PluginEnvVar[];
|
env?: PluginEnvVar[];
|
||||||
signedBy?: string | null;
|
signedBy?: string | null;
|
||||||
|
/** A linked server decides whether it runs here. Admins only. */
|
||||||
|
managedByServer?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPlugins(): Promise<PluginSummary[]> {
|
export async function getPlugins(): Promise<PluginSummary[]> {
|
||||||
@@ -510,10 +512,12 @@ export async function previewPluginState(
|
|||||||
export async function setPluginState(
|
export async function setPluginState(
|
||||||
pluginId: string,
|
pluginId: string,
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
|
consent?: { version: string; capabilities: string[] },
|
||||||
): Promise<PluginStateChange> {
|
): Promise<PluginStateChange> {
|
||||||
try {
|
try {
|
||||||
const response = await rbacApi.patch(`${pluginPath(pluginId)}/state`, {
|
const response = await rbacApi.patch(`${pluginPath(pluginId)}/state`, {
|
||||||
enabled,
|
enabled,
|
||||||
|
...consent,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -434,10 +434,11 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
// Past the first few tries, show the error but keep checking, so
|
||||||
|
// the page moves on by itself once the server is back.
|
||||||
if (attemptNumber >= maxAttempts) {
|
if (attemptNumber >= maxAttempts) {
|
||||||
setDbConnectionFailed(true);
|
setDbConnectionFailed(true);
|
||||||
setDbHealthChecking(false);
|
setDbHealthChecking(false);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const delay = Math.min(500 * 2 ** attemptNumber, 5000);
|
const delay = Math.min(500 * 2 ** attemptNumber, 5000);
|
||||||
retryTimer = setTimeout(() => {
|
retryTimer = setTimeout(() => {
|
||||||
|
|||||||
@@ -73,11 +73,21 @@ export function ElectronLoginForm({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// postMessage from server Auth.tsx after the backend has set the HttpOnly cookie.
|
// postMessage from server Auth.tsx after the backend has set the HttpOnly cookie.
|
||||||
// Uses '*' as target origin because the iframe may be cross-origin (e.g. remote Docker server).
|
// Only the server's own origin is trusted: the iframe can navigate away
|
||||||
|
// (identity provider, redirects), and those pages must not hand us a token,
|
||||||
|
// start a system browser login, or receive the replies.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let serverOrigin: string;
|
||||||
|
try {
|
||||||
|
serverOrigin = new URL(serverUrl).origin;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const handleMessage = async (event: MessageEvent) => {
|
const handleMessage = async (event: MessageEvent) => {
|
||||||
try {
|
try {
|
||||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||||
|
if (event.origin !== serverOrigin) return;
|
||||||
if (!event.data || typeof event.data !== "object") return;
|
if (!event.data || typeof event.data !== "object") return;
|
||||||
const {
|
const {
|
||||||
type,
|
type,
|
||||||
@@ -115,7 +125,7 @@ export function ElectronLoginForm({
|
|||||||
providerId,
|
providerId,
|
||||||
...result,
|
...result,
|
||||||
},
|
},
|
||||||
"*",
|
serverOrigin,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const electronAPI = (
|
const electronAPI = (
|
||||||
@@ -181,14 +191,14 @@ export function ElectronLoginForm({
|
|||||||
success: false,
|
success: false,
|
||||||
error,
|
error,
|
||||||
},
|
},
|
||||||
"*",
|
serverOrigin,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("message", handleMessage);
|
window.addEventListener("message", handleMessage);
|
||||||
return () => window.removeEventListener("message", handleMessage);
|
return () => window.removeEventListener("message", handleMessage);
|
||||||
}, [handleAuthSuccess, t]);
|
}, [handleAuthSuccess, serverUrl, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const iframe = iframeRef.current;
|
const iframe = iframeRef.current;
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ import {
|
|||||||
} from "@/lib/ServerStatusContext";
|
} from "@/lib/ServerStatusContext";
|
||||||
import { withLiveHostStatus } from "@/sidebar/live-host-status";
|
import { withLiveHostStatus } from "@/sidebar/live-host-status";
|
||||||
import { sshHostToHost } from "@/sidebar/HostManagerData";
|
import { sshHostToHost } from "@/sidebar/HostManagerData";
|
||||||
|
import { sortHostTree, type SortKey } from "@/sidebar/host-sort";
|
||||||
|
import { Select2 } from "@/components/select2";
|
||||||
import {
|
import {
|
||||||
CountersStrip,
|
CountersStrip,
|
||||||
HostStatusList,
|
HostStatusList,
|
||||||
@@ -66,6 +68,17 @@ import { docsUrl } from "@/lib/docs";
|
|||||||
|
|
||||||
export { HostStatusList as HostStatusCard } from "./dashboard-core-cards";
|
export { HostStatusList as HostStatusCard } from "./dashboard-core-cards";
|
||||||
|
|
||||||
|
const HOST_STATUS_SORT_LABELS: Partial<Record<SortKey, string>> = {
|
||||||
|
default: "hosts.sortDefault",
|
||||||
|
"name-asc": "hosts.sortNameAsc",
|
||||||
|
"name-desc": "hosts.sortNameDesc",
|
||||||
|
"ip-asc": "hosts.sortIpAsc",
|
||||||
|
"ip-desc": "hosts.sortIpDesc",
|
||||||
|
"status-online": "hosts.sortOnlineFirst",
|
||||||
|
"status-offline": "hosts.sortOfflineFirst",
|
||||||
|
};
|
||||||
|
const HOST_STATUS_SORTS = Object.keys(HOST_STATUS_SORT_LABELS) as SortKey[];
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type PanelId = "main" | "side";
|
type PanelId = "main" | "side";
|
||||||
@@ -460,7 +473,20 @@ export function DashboardTab({
|
|||||||
const [dbHealth, setDbHealth] = useState<"healthy" | "error">("healthy");
|
const [dbHealth, setDbHealth] = useState<"healthy" | "error">("healthy");
|
||||||
const [credentialCount, setCredentialCount] = useState(0);
|
const [credentialCount, setCredentialCount] = useState(0);
|
||||||
const [activity, setActivity] = useState<RecentActivityItem[]>([]);
|
const [activity, setActivity] = useState<RecentActivityItem[]>([]);
|
||||||
const statusCheckHosts = hosts.filter(isStatusCheckEnabled);
|
const [hostStatusSort, setHostStatusSort] = useState<SortKey>(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem("dashboardTab.hostStatusSort");
|
||||||
|
return HOST_STATUS_SORTS.includes(saved as SortKey)
|
||||||
|
? (saved as SortKey)
|
||||||
|
: "default";
|
||||||
|
} catch {
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const statusCheckHosts = sortHostTree(
|
||||||
|
{ name: "", children: hosts.filter(isStatusCheckEnabled) },
|
||||||
|
hostStatusSort,
|
||||||
|
).children as Host[];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
@@ -580,7 +606,28 @@ export function DashboardTab({
|
|||||||
icon: core.icon,
|
icon: core.icon,
|
||||||
frame: core.frame,
|
frame: core.frame,
|
||||||
action:
|
action:
|
||||||
id === "recent_activity" && activity.length > 0 ? (
|
id === "host_status" && statusCheckHosts.length > 1 ? (
|
||||||
|
<Select2
|
||||||
|
value={hostStatusSort}
|
||||||
|
aria-label={t("hosts.sortHosts")}
|
||||||
|
onChange={(e) => {
|
||||||
|
const key = e.target.value as SortKey;
|
||||||
|
setHostStatusSort(key);
|
||||||
|
try {
|
||||||
|
localStorage.setItem("dashboardTab.hostStatusSort", key);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-6 border border-border bg-background px-1.5 text-[10px] outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
>
|
||||||
|
{HOST_STATUS_SORTS.map((key) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{t(HOST_STATUS_SORT_LABELS[key]!)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select2>
|
||||||
|
) : id === "recent_activity" && activity.length > 0 ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleClearActivity()}
|
onClick={() => void handleClearActivity()}
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ export function getDatabaseTransferUrl(
|
|||||||
return `${serverUrl.replace(/\/$/, "")}/database/${operation}`;
|
return `${serverUrl.replace(/\/$/, "")}/database/${operation}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const development = location.port === "5173";
|
// Vite moves off 5173 when another dev server holds it.
|
||||||
|
const development =
|
||||||
|
process.env.NODE_ENV === "development" || location.port === "5173";
|
||||||
|
|
||||||
if (development) {
|
if (development) {
|
||||||
return `http://localhost:30001/database/${operation}`;
|
return `http://localhost:30001/database/${operation}`;
|
||||||
|
|||||||
@@ -16,9 +16,14 @@ export function isInMobileWebView(): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Being framed alone is not enough: any site could frame this page and
|
||||||
|
// receive the token posted to window.parent. The desktop app tags every
|
||||||
|
// request and frame with its own user agent, which a web page cannot fake
|
||||||
|
// in someone else's browser.
|
||||||
export function isInElectronWebView(): boolean {
|
export function isInElectronWebView(): boolean {
|
||||||
if (isInMobileWebView()) return false;
|
if (isInMobileWebView()) return false;
|
||||||
if ((window as EmbeddedFrameWindow).IS_ELECTRON_WEBVIEW) return true;
|
if ((window as EmbeddedFrameWindow).IS_ELECTRON_WEBVIEW) return true;
|
||||||
|
if (!/Termix-Desktop\//.test(navigator.userAgent)) return false;
|
||||||
try {
|
try {
|
||||||
if (window.self !== window.top) return true;
|
if (window.self !== window.top) return true;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -175,11 +175,10 @@ export async function pluginWsUrl(
|
|||||||
const protocols = websocketAuthProtocols(token);
|
const protocols = websocketAuthProtocols(token);
|
||||||
|
|
||||||
// Dev without a configured API host goes through Vite's proxy, which is
|
// Dev without a configured API host goes through Vite's proxy, which is
|
||||||
// reached on the page's own origin rather than the backend port.
|
// reached on the page's own origin rather than the backend port. Vite moves
|
||||||
|
// to another port when 5173 is taken, so the port says nothing here.
|
||||||
const devProxy =
|
const devProxy =
|
||||||
process.env.NODE_ENV === "development" &&
|
process.env.NODE_ENV === "development" && !import.meta.env.VITE_API_HOST;
|
||||||
!import.meta.env.VITE_API_HOST &&
|
|
||||||
(window.location.port === "3000" || window.location.port === "5173");
|
|
||||||
|
|
||||||
const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
|
const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||||
|
|
||||||
|
|||||||
@@ -644,6 +644,8 @@
|
|||||||
"actionsClick": "Click to expand/collapse",
|
"actionsClick": "Click to expand/collapse",
|
||||||
"openOnDoubleClick": "Open on double click",
|
"openOnDoubleClick": "Open on double click",
|
||||||
"openOnDoubleClickDesc": "Require a double click to open a host session instead of a single click",
|
"openOnDoubleClickDesc": "Require a double click to open a host session instead of a single click",
|
||||||
|
"closeOnOpen": "Hide sidebar after opening",
|
||||||
|
"closeOnOpenDesc": "Close the sidebar after you open a host from it",
|
||||||
"showFolderPaths": "Show folder paths",
|
"showFolderPaths": "Show folder paths",
|
||||||
"showFolderPathsDesc": "Show the parent folder path above nested subfolder names. Subfolders stay indented either way.",
|
"showFolderPathsDesc": "Show the parent folder path above nested subfolder names. Subfolders stay indented either way.",
|
||||||
"rowDetailsTitle": "Row details",
|
"rowDetailsTitle": "Row details",
|
||||||
@@ -1097,7 +1099,7 @@
|
|||||||
"acceptNewKey": "Accept New Key & Continue"
|
"acceptNewKey": "Accept New Key & Continue"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"databaseConnection": "Could not connect to the database",
|
"databaseConnection": "The Termix server is not responding",
|
||||||
"embeddedServerFailed": "Termix's local server could not start",
|
"embeddedServerFailed": "Termix's local server could not start",
|
||||||
"unknownError": "Unknown error",
|
"unknownError": "Unknown error",
|
||||||
"syncedHostMissing": "The synced host does not exist on the linked server",
|
"syncedHostMissing": "The synced host does not exist on the linked server",
|
||||||
@@ -1124,7 +1126,7 @@
|
|||||||
"messages": {
|
"messages": {
|
||||||
"registrationDisabled": "You have no Termix account yet. Ask an admin to turn on Auto-create external accounts.",
|
"registrationDisabled": "You have no Termix account yet. Ask an admin to turn on Auto-create external accounts.",
|
||||||
"userNotAllowed": "Your account is not authorized to register. Please contact an administrator.",
|
"userNotAllowed": "Your account is not authorized to register. Please contact an administrator.",
|
||||||
"databaseConnectionFailed": "Failed to connect to the database server",
|
"databaseConnectionFailed": "It may still be starting, or it stopped. This page keeps trying and continues on its own once the server is back.",
|
||||||
"embeddedServerPortInUse": "Port {{port}} is already in use by another program. This is usually a Termix Docker container or a leftover Termix process from a previous session. Close it, then restart Termix.",
|
"embeddedServerPortInUse": "Port {{port}} is already in use by another program. This is usually a Termix Docker container or a leftover Termix process from a previous session. Close it, then restart Termix.",
|
||||||
"embeddedServerPortInUseUnknownPort": "One of the ports Termix needs is already in use by another program. Close whatever is using it, then restart Termix.",
|
"embeddedServerPortInUseUnknownPort": "One of the ports Termix needs is already in use by another program. Close whatever is using it, then restart Termix.",
|
||||||
"embeddedServerOldDatabase": "Your local data is from Termix 2.8 or older and has to pass through 2.9 first. Install Termix 2.9.1, open it once so it can move your data, then update to this version again.",
|
"embeddedServerOldDatabase": "Your local data is from Termix 2.8 or older and has to pass through 2.9 first. Install Termix 2.9.1, open it once so it can move your data, then update to this version again.",
|
||||||
@@ -1672,6 +1674,8 @@
|
|||||||
"pinAppRailDesc": "Keep the left sidebar app rail always expanded instead of expanding on hover",
|
"pinAppRailDesc": "Keep the left sidebar app rail always expanded instead of expanding on hover",
|
||||||
"expandAppRailOnHover": "Expand App Rail on Hover",
|
"expandAppRailOnHover": "Expand App Rail on Hover",
|
||||||
"expandAppRailOnHoverDesc": "Allow the left sidebar app rail to expand when the pointer moves over it",
|
"expandAppRailOnHoverDesc": "Allow the left sidebar app rail to expand when the pointer moves over it",
|
||||||
|
"appRailHoverPushesContent": "Push content when it expands",
|
||||||
|
"appRailHoverPushesContentDesc": "Make room for the expanded rail instead of opening it over the page. Open sessions, like remote desktop, resize each time",
|
||||||
"showPinAppRailButton": "Show Pin App Rail Button",
|
"showPinAppRailButton": "Show Pin App Rail Button",
|
||||||
"showPinAppRailButtonDesc": "Show a button in the app rail to pin it open. Off by default.",
|
"showPinAppRailButtonDesc": "Show a button in the app rail to pin it open. Off by default.",
|
||||||
"settingsNavigation": "Navigation",
|
"settingsNavigation": "Navigation",
|
||||||
@@ -1967,6 +1971,7 @@
|
|||||||
"syncEntities_other": "Desktop sync"
|
"syncEntities_other": "Desktop sync"
|
||||||
},
|
},
|
||||||
"consent": {
|
"consent": {
|
||||||
|
"enableTitle": "Enable {{name}}?",
|
||||||
"installTitle": "Install {{name}}",
|
"installTitle": "Install {{name}}",
|
||||||
"updateTitle": "Update {{name}}",
|
"updateTitle": "Update {{name}}",
|
||||||
"agreeUpdate": "Agree and update",
|
"agreeUpdate": "Agree and update",
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
"deleteFolder": "Hapus folder",
|
"deleteFolder": "Hapus folder",
|
||||||
"deleteCredentialConfirm": "Hapus kredensial \"{{name}}\"?",
|
"deleteCredentialConfirm": "Hapus kredensial \"{{name}}\"?",
|
||||||
"deletedCredential": "{{name}} telah dihapus",
|
"deletedCredential": "{{name}} telah dihapus",
|
||||||
"deploySSHKeyTitle": "Terapkan kunci ke host",
|
"deploySSHKeyTitle": "Deploy kunci ke host",
|
||||||
"loadingCredentials": "Memuat kredensial...",
|
"loadingCredentials": "Memuat kredensial...",
|
||||||
"noCredentialsFound": "Tidak ada kredensial ditemukan",
|
"noCredentialsFound": "Tidak ada kredensial ditemukan",
|
||||||
"noCredentialsMatchSearch": "Tidak ada kredensial yang cocok dengan pencarian Anda",
|
"noCredentialsMatchSearch": "Tidak ada kredensial yang cocok dengan pencarian Anda",
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
"failedToReorder": "Gagal mengurutkan ulang kredensial",
|
"failedToReorder": "Gagal mengurutkan ulang kredensial",
|
||||||
"failedToMoveCredential": "Gagal memindahkan kredensial",
|
"failedToMoveCredential": "Gagal memindahkan kredensial",
|
||||||
"sortManual": "Manual (seret untuk mengurutkan)",
|
"sortManual": "Manual (seret untuk mengurutkan)",
|
||||||
"customizeSidebar": "Sesuaikan Bilah Samping",
|
"customizeSidebar": "Sesuaikan Sidebar",
|
||||||
"customizeSidebarDescription": "Pilih tampilan dan perilaku daftar kredensial.",
|
"customizeSidebarDescription": "Pilih tampilan dan perilaku daftar kredensial.",
|
||||||
"customizeDensityTitle": "Kepadatan",
|
"customizeDensityTitle": "Kepadatan",
|
||||||
"densityComfortableDesc": "Satu kredensial per baris, dengan detail tambahan.",
|
"densityComfortableDesc": "Satu kredensial per baris, dengan detail tambahan.",
|
||||||
@@ -108,20 +108,20 @@
|
|||||||
"actionsOnly": "Hanya sambung, lainnya saat diklik",
|
"actionsOnly": "Hanya sambung, lainnya saat diklik",
|
||||||
"actionsHover": "Tampilkan saat kursor di atas",
|
"actionsHover": "Tampilkan saat kursor di atas",
|
||||||
"actionsClick": "Tampilkan saat diklik",
|
"actionsClick": "Tampilkan saat diklik",
|
||||||
"customizeSortHint": "Urutan pengurutan ada di menu urutkan pada bilah alat.",
|
"customizeSortHint": "Urutan pengurutan ada di menu urutkan pada toolbar.",
|
||||||
"expandActions": "Bentangkan tindakan",
|
"expandActions": "Bentangkan tindakan",
|
||||||
"collapseActions": "Ciutkan tindakan",
|
"collapseActions": "Ciutkan tindakan",
|
||||||
"backToHosts": "Kembali ke Host",
|
"backToHosts": "Kembali ke Host",
|
||||||
"backToCredentials": "Kembali ke Kredensial",
|
"backToCredentials": "Kembali ke Kredensial",
|
||||||
"searchCredentialsPlaceholder": "Cari kredensial…",
|
"searchCredentialsPlaceholder": "Cari kredensial…",
|
||||||
"deployDialogTitle": "Terapkan Kunci SSH",
|
"deployDialogTitle": "Deploy Kunci SSH",
|
||||||
"deployDialogDesc": "Terapkan {{name}} ke authorized_keys host.",
|
"deployDialogDesc": "Deploy {{name}} ke authorized_keys host.",
|
||||||
"targetHostLabel": "Host Target",
|
"targetHostLabel": "Host Target",
|
||||||
"selectHostOption": "Pilih host...",
|
"selectHostOption": "Pilih host...",
|
||||||
"deployingBtn": "Menerapkan...",
|
"deployingBtn": "Men-deploy...",
|
||||||
"deployBtn": "Terapkan",
|
"deployBtn": "Deploy",
|
||||||
"keyDeployedSuccess": "Kunci berhasil diterapkan",
|
"keyDeployedSuccess": "Kunci berhasil di-deploy",
|
||||||
"failedToDeployKey": "Gagal menerapkan kunci",
|
"failedToDeployKey": "Gagal men-deploy kunci",
|
||||||
"folderRenamedTo": "Folder diubah namanya menjadi \"{{name}}\"",
|
"folderRenamedTo": "Folder diubah namanya menjadi \"{{name}}\"",
|
||||||
"failedToRenameFolder": "Gagal mengubah nama folder",
|
"failedToRenameFolder": "Gagal mengubah nama folder",
|
||||||
"arrangeUnlockedSwitchedToManual": "Beralih ke pengurutan manual agar urutan Anda tetap terjaga",
|
"arrangeUnlockedSwitchedToManual": "Beralih ke pengurutan manual agar urutan Anda tetap terjaga",
|
||||||
@@ -181,9 +181,9 @@
|
|||||||
"add": "Tambah",
|
"add": "Tambah",
|
||||||
"update": "Perbarui",
|
"update": "Perbarui",
|
||||||
"noResults": "Tidak ada hasil ditemukan",
|
"noResults": "Tidak ada hasil ditemukan",
|
||||||
"sidebar": "Bilah sisi",
|
"sidebar": "Sidebar",
|
||||||
"mobileSidebarDescription": "Menampilkan bilah sisi mobile.",
|
"mobileSidebarDescription": "Menampilkan sidebar mobile.",
|
||||||
"toggleSidebar": "Alihkan Bilah Sisi",
|
"toggleSidebar": "Alihkan Sidebar",
|
||||||
"search": "Cari...",
|
"search": "Cari...",
|
||||||
"new": "Baru",
|
"new": "Baru",
|
||||||
"docs": "Dokumentasi"
|
"docs": "Dokumentasi"
|
||||||
@@ -225,7 +225,7 @@
|
|||||||
"securityTitle": "Amankan akun Anda",
|
"securityTitle": "Amankan akun Anda",
|
||||||
"securityIntro": "Tambahkan passkey atau aplikasi autentikator ke akun Anda. Anda juga dapat melakukan ini nanti di Pengaturan, di bawah Keamanan.",
|
"securityIntro": "Tambahkan passkey atau aplikasi autentikator ke akun Anda. Anda juga dapat melakukan ini nanti di Pengaturan, di bawah Keamanan.",
|
||||||
"desktopTitle": "Bagaimana Anda ingin menggunakan Termix?",
|
"desktopTitle": "Bagaimana Anda ingin menggunakan Termix?",
|
||||||
"desktopBody": "Anda dapat mengubah ini kapan saja dari Sinkronisasi di bilah sisi.",
|
"desktopBody": "Anda dapat mengubah ini kapan saja dari Sinkronisasi di sidebar.",
|
||||||
"desktopLocalTitle": "Hanya di perangkat ini",
|
"desktopLocalTitle": "Hanya di perangkat ini",
|
||||||
"desktopLocalBody": "Semuanya disimpan di sini. Tidak perlu pengaturan.",
|
"desktopLocalBody": "Semuanya disimpan di sini. Tidak perlu pengaturan.",
|
||||||
"desktopLinkTitle": "Dengan server Termix saya",
|
"desktopLinkTitle": "Dengan server Termix saya",
|
||||||
@@ -297,8 +297,8 @@
|
|||||||
"fullscreenUnsupported": "Layar penuh tidak didukung oleh browser ini",
|
"fullscreenUnsupported": "Layar penuh tidak didukung oleh browser ini",
|
||||||
"fullscreenFailed": "Gagal mengubah mode layar penuh",
|
"fullscreenFailed": "Gagal mengubah mode layar penuh",
|
||||||
"resetSidebarWidth": "Atur ulang lebar",
|
"resetSidebarWidth": "Atur ulang lebar",
|
||||||
"collapseSidebar": "Ciutkan bilah sisi",
|
"collapseSidebar": "Ciutkan sidebar",
|
||||||
"openSidebar": "Buka bilah sisi",
|
"openSidebar": "Buka sidebar",
|
||||||
"confirmCloseHost": "Tutup {{host}}?",
|
"confirmCloseHost": "Tutup {{host}}?",
|
||||||
"enterFullscreen": "Masuk layar penuh (Ctrl+Shift+F)",
|
"enterFullscreen": "Masuk layar penuh (Ctrl+Shift+F)",
|
||||||
"exitFullscreen": "Keluar layar penuh (Ctrl+Shift+F)",
|
"exitFullscreen": "Keluar layar penuh (Ctrl+Shift+F)",
|
||||||
@@ -310,11 +310,11 @@
|
|||||||
"searchEverything": "Cari host, tab, dan tindakan",
|
"searchEverything": "Cari host, tab, dan tindakan",
|
||||||
"search": "Cari",
|
"search": "Cari",
|
||||||
"hiddenCount_other": "{{count}} disembunyikan",
|
"hiddenCount_other": "{{count}} disembunyikan",
|
||||||
"showInRail": "Tampilkan di bilah sisi",
|
"showInRail": "Tampilkan di sidebar",
|
||||||
"moveUp": "Pindah ke atas",
|
"moveUp": "Pindah ke atas",
|
||||||
"moveDown": "Pindah ke bawah",
|
"moveDown": "Pindah ke bawah",
|
||||||
"resetRailOrder": "Atur ulang urutan",
|
"resetRailOrder": "Atur ulang urutan",
|
||||||
"hideFromRail": "Sembunyikan dari bilah sisi",
|
"hideFromRail": "Sembunyikan dari sidebar",
|
||||||
"openSettings": "Buka pengaturan",
|
"openSettings": "Buka pengaturan",
|
||||||
"settings": "Pengaturan",
|
"settings": "Pengaturan",
|
||||||
"manage": "Kelola",
|
"manage": "Kelola",
|
||||||
@@ -359,7 +359,7 @@
|
|||||||
"tabSharing": "Berbagi",
|
"tabSharing": "Berbagi",
|
||||||
"tabAuthentication": "Autentikasi",
|
"tabAuthentication": "Autentikasi",
|
||||||
"terminal": "Terminal",
|
"terminal": "Terminal",
|
||||||
"tunnel": "Terowongan",
|
"tunnel": "Tunnel",
|
||||||
"serverStats": "Metrik Host",
|
"serverStats": "Metrik Host",
|
||||||
"status": {
|
"status": {
|
||||||
"online": "Online",
|
"online": "Online",
|
||||||
@@ -377,8 +377,8 @@
|
|||||||
"editHostTooltip": "Edit host",
|
"editHostTooltip": "Edit host",
|
||||||
"behavior": "Perilaku",
|
"behavior": "Perilaku",
|
||||||
"scrollbackBuffer": "Buffer Gulir",
|
"scrollbackBuffer": "Buffer Gulir",
|
||||||
"sshAgentForwarding": "Penerusan Agen SSH",
|
"sshAgentForwarding": "SSH Agent Forwarding",
|
||||||
"selectSnippet": "Pilih cuplikan",
|
"selectSnippet": "Pilih snippet",
|
||||||
"forceKeyboardInteractive": "Paksa Keyboard-Interaktif",
|
"forceKeyboardInteractive": "Paksa Keyboard-Interaktif",
|
||||||
"overrideCredentialUsername": "Timpa Nama Pengguna Kredensial",
|
"overrideCredentialUsername": "Timpa Nama Pengguna Kredensial",
|
||||||
"overrideCredentialUsernameDesc": "Gunakan nama pengguna yang ditentukan di atas daripada nama pengguna kredensial",
|
"overrideCredentialUsernameDesc": "Gunakan nama pengguna yang ditentukan di atas daripada nama pengguna kredensial",
|
||||||
@@ -474,8 +474,8 @@
|
|||||||
"insecure": "Tidak Aman",
|
"insecure": "Tidak Aman",
|
||||||
"syntaxHighlightingCategories": "Kategori Sorotan",
|
"syntaxHighlightingCategories": "Kategori Sorotan",
|
||||||
"syntaxHighlightingCategoriesDesc": "Pilih jenis konten yang akan diberi warna",
|
"syntaxHighlightingCategoriesDesc": "Pilih jenis konten yang akan diberi warna",
|
||||||
"sshAgentForwardingLabel": "Penerusan Agen SSH",
|
"sshAgentForwardingLabel": "SSH Agent Forwarding",
|
||||||
"sshAgentForwardingShortDesc": "Izinkan host jarak jauh ini menggunakan agen SSH lokal Anda untuk koneksi lebih lanjut. Ini tidak diperlukan untuk autentikasi Agen.",
|
"sshAgentForwardingShortDesc": "Izinkan host jarak jauh ini menggunakan SSH agent lokal Anda untuk koneksi lebih lanjut. Ini tidak diperlukan untuk autentikasi Agent.",
|
||||||
"sudoPasswordAutoFillShortDesc": "Sediakan otomatis kata sandi sudo saat diminta",
|
"sudoPasswordAutoFillShortDesc": "Sediakan otomatis kata sandi sudo saat diminta",
|
||||||
"sudoPasswordLabel": "Kata Sandi Sudo",
|
"sudoPasswordLabel": "Kata Sandi Sudo",
|
||||||
"sudoPasswordSaved": "Kata sandi disimpan, ketik untuk mengubah",
|
"sudoPasswordSaved": "Kata sandi disimpan, ketik untuk mengubah",
|
||||||
@@ -615,13 +615,13 @@
|
|||||||
"GroupByProtocol": "Protokol",
|
"GroupByProtocol": "Protokol",
|
||||||
"GroupByAuth": "Jenis autentikasi",
|
"GroupByAuth": "Jenis autentikasi",
|
||||||
"groupUngrouped": "Tidak dikelompokkan",
|
"groupUngrouped": "Tidak dikelompokkan",
|
||||||
"customizeSidebar": "Sesuaikan bilah sisi",
|
"customizeSidebar": "Sesuaikan sidebar",
|
||||||
"customizeSidebarDescription": "Pilih tampilan dan perilaku daftar host.",
|
"customizeSidebarDescription": "Pilih tampilan dan perilaku daftar host.",
|
||||||
"customizeDensityTitle": "Kepadatan",
|
"customizeDensityTitle": "Kepadatan",
|
||||||
"customizeBehaviorTitle": "Perilaku",
|
"customizeBehaviorTitle": "Perilaku",
|
||||||
"connectBarTitle": "Bilah koneksi",
|
"connectBarTitle": "Bilah koneksi",
|
||||||
"connectBarDesc": "Pilih tindakan mana yang ditampilkan sebagai tombol pada host. Semua tindakan tetap ada di menu host.",
|
"connectBarDesc": "Pilih tindakan mana yang ditampilkan sebagai tombol pada host. Semua tindakan tetap ada di menu host.",
|
||||||
"customizeSortHint": "Urutan pengurutan dan pengelompokan terdapat di menu urutkan dan kelompokkan pada bilah alat.",
|
"customizeSortHint": "Urutan pengurutan dan pengelompokan terdapat di menu urutkan dan kelompokkan pada toolbar.",
|
||||||
"densityComfortableDesc": "Detail lebih banyak per host: alamat, tag, dan penggunaan sumber daya.",
|
"densityComfortableDesc": "Detail lebih banyak per host: alamat, tag, dan penggunaan sumber daya.",
|
||||||
"densityCompactDesc": "Satu baris per host, untuk daftar yang lebih panjang.",
|
"densityCompactDesc": "Satu baris per host, untuk daftar yang lebih panjang.",
|
||||||
"display": "Tampilan",
|
"display": "Tampilan",
|
||||||
@@ -725,7 +725,7 @@
|
|||||||
"groupNotes": "Catatan",
|
"groupNotes": "Catatan",
|
||||||
"groupTags": "Tag & sematan",
|
"groupTags": "Tag & sematan",
|
||||||
"groupProxy": "Proxy",
|
"groupProxy": "Proxy",
|
||||||
"groupJumpHosts": "Host lompat",
|
"groupJumpHosts": "Jump host",
|
||||||
"groupFeatureFlags": "Pengaturan plugin",
|
"groupFeatureFlags": "Pengaturan plugin",
|
||||||
"groupAdvanced": "Konfigurasi lanjutan",
|
"groupAdvanced": "Konfigurasi lanjutan",
|
||||||
"preview": "Pratinjau",
|
"preview": "Pratinjau",
|
||||||
@@ -804,7 +804,7 @@
|
|||||||
"levels": {
|
"levels": {
|
||||||
"connect": {
|
"connect": {
|
||||||
"label": "Sambungkan",
|
"label": "Sambungkan",
|
||||||
"description": "Hanya membuka sesi: terminal, desktop jarak jauh, pengelola file, terowongan, dan Docker. Tidak ada akses ke konfigurasi host."
|
"description": "Hanya membuka sesi: terminal, remote desktop, file manager, tunnel, dan Docker. Tidak ada akses ke konfigurasi host."
|
||||||
},
|
},
|
||||||
"view": {
|
"view": {
|
||||||
"label": "Lihat",
|
"label": "Lihat",
|
||||||
@@ -872,14 +872,14 @@
|
|||||||
"arrangeUnlockedSwitchedToManual": "Beralih ke pengurutan manual agar urutan Anda tetap",
|
"arrangeUnlockedSwitchedToManual": "Beralih ke pengurutan manual agar urutan Anda tetap",
|
||||||
"dragToRearrange": "Seret untuk mengatur ulang",
|
"dragToRearrange": "Seret untuk mengatur ulang",
|
||||||
"arrangeUnlocked": "Buka kunci seret untuk mengatur ulang",
|
"arrangeUnlocked": "Buka kunci seret untuk mengatur ulang",
|
||||||
"webUiBindHostUnreachable": "Browser Anda tidak dapat menjangkau 127.0.0.1 pada mesin yang menjalankan Termix, sehingga titik akhir ini tidak akan dimuat. Gunakan alamat yang dijawab oleh mesin tersebut, seperti 0.0.0.0.",
|
"webUiBindHostUnreachable": "Browser Anda tidak dapat menjangkau 127.0.0.1 pada mesin yang menjalankan Termix, sehingga endpoint ini tidak akan dimuat. Gunakan alamat yang dijawab oleh mesin tersebut, seperti 0.0.0.0.",
|
||||||
"webUiBindHostSharesSessionCookie": "Dari browser ini, terowongan akan dijangkau di nama host yang sama tempat Termix disajikan, sehingga layanan terowongan akan menerima sesi Termix Anda. Cookie mengabaikan port. Titik akhir ini akan menolak untuk dibuka di sini; ini berfungsi dari aplikasi desktop Termix, atau ketika Termix dibuka di localhost.",
|
"webUiBindHostSharesSessionCookie": "Dari browser ini, forward akan dijangkau di nama host yang sama tempat Termix disajikan, sehingga layanan yang di-tunnel akan menerima sesi Termix Anda. Cookie mengabaikan port. Endpoint ini akan menolak untuk dibuka di sini; ini berfungsi dari aplikasi desktop Termix, atau ketika Termix dibuka di localhost.",
|
||||||
"webUiErrorLabelRequired": "Titik akhir {{row}} memerlukan label.",
|
"webUiErrorLabelRequired": "Endpoint {{row}} memerlukan label.",
|
||||||
"webUiErrorLabelDuplicate": "Setiap titik akhir memerlukan nama yang berbeda.",
|
"webUiErrorLabelDuplicate": "Setiap endpoint memerlukan nama yang berbeda.",
|
||||||
"webUiErrorPortRange": "Titik akhir {{row}} memerlukan port antara 1 dan 65535.",
|
"webUiErrorPortRange": "Endpoint {{row}} memerlukan port antara 1 dan 65535.",
|
||||||
"webUiErrorPathInvalid": "Titik akhir {{row}} memiliki jalur yang tidak valid. Gunakan jalur seperti /admin.",
|
"webUiErrorPathInvalid": "Endpoint {{row}} memiliki jalur yang tidak valid. Gunakan jalur seperti /admin.",
|
||||||
"copyViewUrlAction": "Salin tautan {{name}}",
|
"copyViewUrlAction": "Salin tautan {{name}}",
|
||||||
"filterAuthAgent": "Agen SSH",
|
"filterAuthAgent": "SSH Agent",
|
||||||
"authTypeNeedsPlugin": "Host ini menggunakan {{type}}, yang memerlukan {{plugin}}. Fitur ini dinonaktifkan di server ini.",
|
"authTypeNeedsPlugin": "Host ini menggunakan {{type}}, yang memerlukan {{plugin}}. Fitur ini dinonaktifkan di server ini.",
|
||||||
"authTypeUnknown": "Host ini menggunakan {{type}}, yang tidak tersedia di server ini.",
|
"authTypeUnknown": "Host ini menggunakan {{type}}, yang tidak tersedia di server ini.",
|
||||||
"authFieldsAfterSave": "Simpan host terlebih dahulu, lalu isi bidang-bidang ini.",
|
"authFieldsAfterSave": "Simpan host terlebih dahulu, lalu isi bidang-bidang ini.",
|
||||||
@@ -932,7 +932,7 @@
|
|||||||
"newPaneRight": "Panel baru di sebelah kanan",
|
"newPaneRight": "Panel baru di sebelah kanan",
|
||||||
"newPaneBelow": "Panel baru di bawah",
|
"newPaneBelow": "Panel baru di bawah",
|
||||||
"emptyTitle": "Panel kosong",
|
"emptyTitle": "Panel kosong",
|
||||||
"emptyHint": "Pilih sesuatu untuk ditampilkan di sini. Saat panel ini dipilih, apa pun yang Anda buka dari bilah sisi juga akan terbuka di sini.",
|
"emptyHint": "Pilih sesuatu untuk ditampilkan di sini. Saat panel ini dipilih, apa pun yang Anda buka dari sidebar juga akan terbuka di sini.",
|
||||||
"openTabs": "Buka tab",
|
"openTabs": "Buka tab",
|
||||||
"noOpenTabs": "Tidak ada tab lain yang terbuka",
|
"noOpenTabs": "Tidak ada tab lain yang terbuka",
|
||||||
"hosts": "Host",
|
"hosts": "Host",
|
||||||
@@ -972,7 +972,7 @@
|
|||||||
},
|
},
|
||||||
"connections": {
|
"connections": {
|
||||||
"noConnections": "Tidak ada koneksi",
|
"noConnections": "Tidak ada koneksi",
|
||||||
"noConnectionsDesc": "Buka terminal, manajer file, atau remote desktop untuk melihat koneksi di sini",
|
"noConnectionsDesc": "Buka terminal, file manager, atau remote desktop untuk melihat koneksi di sini",
|
||||||
"connectedFor": "Terhubung selama {{duration}}",
|
"connectedFor": "Terhubung selama {{duration}}",
|
||||||
"connected": "Terhubung",
|
"connected": "Terhubung",
|
||||||
"disconnected": "Terputus",
|
"disconnected": "Terputus",
|
||||||
@@ -1038,7 +1038,7 @@
|
|||||||
"sshTimeoutDescription": "Upaya autentikasi habis waktu. Silakan coba lagi.",
|
"sshTimeoutDescription": "Upaya autentikasi habis waktu. Silakan coba lagi.",
|
||||||
"sshProvideCredentialsDescription": "Silakan masukkan kredensial SSH Anda untuk terhubung ke server ini.",
|
"sshProvideCredentialsDescription": "Silakan masukkan kredensial SSH Anda untuk terhubung ke server ini.",
|
||||||
"sshPasswordDescription": "Masukkan kata sandi untuk koneksi SSH ini.",
|
"sshPasswordDescription": "Masukkan kata sandi untuk koneksi SSH ini.",
|
||||||
"sshKeyPasswordDescription": "Jika kunci SSH Anda terenkripsi, masukkan frasa sandi di sini.",
|
"sshKeyPasswordDescription": "Jika kunci SSH Anda terenkripsi, masukkan passphrase di sini.",
|
||||||
"passphraseRequired": "Passphrase Diperlukan",
|
"passphraseRequired": "Passphrase Diperlukan",
|
||||||
"passphraseRequiredDescription": "Kunci SSH terenkripsi. Masukkan passphrase untuk membukanya.",
|
"passphraseRequiredDescription": "Kunci SSH terenkripsi. Masukkan passphrase untuk membukanya.",
|
||||||
"external": "Eksternal",
|
"external": "Eksternal",
|
||||||
@@ -1401,7 +1401,7 @@
|
|||||||
"manageTabDanger": "Bahaya",
|
"manageTabDanger": "Bahaya",
|
||||||
"manageEditorBack": "Kembali ke data {{username}}",
|
"manageEditorBack": "Kembali ke data {{username}}",
|
||||||
"dataLockedBadge": "TERKUNCI",
|
"dataLockedBadge": "TERKUNCI",
|
||||||
"dataLockedNotice": "Data {{username}} tetap terkunci hingga mereka masuk berikutnya. Host, kredensial, dan cuplikan mereka tidak dapat dilihat atau diedit hingga saat itu.",
|
"dataLockedNotice": "Data {{username}} tetap terkunci hingga mereka masuk berikutnya. Host, kredensial, dan snippet mereka tidak dapat dilihat atau diedit hingga saat itu.",
|
||||||
"resetPasswordTitle": "Reset Kata Sandi",
|
"resetPasswordTitle": "Reset Kata Sandi",
|
||||||
"resetPasswordExternalOnly": "Pengguna ini masuk melalui penyedia eksternal dan tidak memiliki kata sandi.",
|
"resetPasswordExternalOnly": "Pengguna ini masuk melalui penyedia eksternal dan tidak memiliki kata sandi.",
|
||||||
"resetPasswordPlaceholder": "Kata sandi baru",
|
"resetPasswordPlaceholder": "Kata sandi baru",
|
||||||
@@ -1410,14 +1410,14 @@
|
|||||||
"resetPasswordSuccess": "Kata sandi direset",
|
"resetPasswordSuccess": "Kata sandi direset",
|
||||||
"resetPasswordSuccessWiped": "Kata sandi direset. Data terenkripsi pengguna dihapus.",
|
"resetPasswordSuccessWiped": "Kata sandi direset. Data terenkripsi pengguna dihapus.",
|
||||||
"resetPasswordFailed": "Gagal mereset kata sandi",
|
"resetPasswordFailed": "Gagal mereset kata sandi",
|
||||||
"resetPasswordConfirmWipe": "{{username}} belum masuk sejak peningkatan enkripsi, sehingga data mereka tidak dapat dipulihkan. Mereset sekarang akan menghapus host, kredensial, dan cuplikan mereka. Lanjutkan?",
|
"resetPasswordConfirmWipe": "{{username}} belum masuk sejak peningkatan enkripsi, sehingga data mereka tidak dapat dipulihkan. Mereset sekarang akan menghapus host, kredensial, dan snippet mereka. Lanjutkan?",
|
||||||
"manageApiKeys": "Kunci API",
|
"manageApiKeys": "Kunci API",
|
||||||
"apiKeyNamePlaceholder": "Nama kunci",
|
"apiKeyNamePlaceholder": "Nama kunci",
|
||||||
"apiKeyCopyNotice": "Salin kunci ini sekarang, karena tidak akan ditampilkan lagi.",
|
"apiKeyCopyNotice": "Salin kunci ini sekarang, karena tidak akan ditampilkan lagi.",
|
||||||
"noApiKeysForUser": "Tidak ada kunci API",
|
"noApiKeysForUser": "Tidak ada kunci API",
|
||||||
"apiKeyDeleteFailed": "Gagal menghapus kunci API",
|
"apiKeyDeleteFailed": "Gagal menghapus kunci API",
|
||||||
"exportUserData": "Ekspor Data",
|
"exportUserData": "Ekspor Data",
|
||||||
"exportUserDataDesc": "Unduh host, kredensial, dan data manajer file pengguna ini sebagai JSON. Rahasia telah dienkripsi.",
|
"exportUserDataDesc": "Unduh host, kredensial, dan data file manager pengguna ini sebagai JSON. Rahasia sudah didekripsi.",
|
||||||
"exportUserDataSuccess": "Data pengguna berhasil diekspor",
|
"exportUserDataSuccess": "Data pengguna berhasil diekspor",
|
||||||
"exportUserDataFailed": "Gagal mengekspor data pengguna",
|
"exportUserDataFailed": "Gagal mengekspor data pengguna",
|
||||||
"hostsCount_other": "{{count}} host",
|
"hostsCount_other": "{{count}} host",
|
||||||
@@ -1438,7 +1438,7 @@
|
|||||||
"deleteSuccessorMe": "Saya (admin yang menghapus)",
|
"deleteSuccessorMe": "Saya (admin yang menghapus)",
|
||||||
"deleteSuccessorNone": "Tidak ada, hapus saja",
|
"deleteSuccessorNone": "Tidak ada, hapus saja",
|
||||||
"deleteSuccessorDesc": "Semua yang dibagikan pengguna ini akan tetap berfungsi di bawah pemilik baru; memilih tidak ada akan menghapus host, kredensial, dan berbagi mereka.",
|
"deleteSuccessorDesc": "Semua yang dibagikan pengguna ini akan tetap berfungsi di bawah pemilik baru; memilih tidak ada akan menghapus host, kredensial, dan berbagi mereka.",
|
||||||
"deleteUserDangerDesc": "Hapus {{username}} dan semua datanya (host, kredensial, cuplikan, riwayat) secara permanen. Tindakan ini tidak dapat dibatalkan.",
|
"deleteUserDangerDesc": "Hapus {{username}} dan semua datanya (host, kredensial, snippet, riwayat) secara permanen. Tindakan ini tidak dapat dibatalkan.",
|
||||||
"deleteUserConfirm": "Hapus {{username}} dan semua datanya secara permanen?",
|
"deleteUserConfirm": "Hapus {{username}} dan semua datanya secara permanen?",
|
||||||
"deleteUserAdminBlocked": "Hapus status admin sebelum menghapus pengguna ini.",
|
"deleteUserAdminBlocked": "Hapus status admin sebelum menghapus pengguna ini.",
|
||||||
"createRoleRequired": "Nama dan nama tampilan wajib diisi",
|
"createRoleRequired": "Nama dan nama tampilan wajib diisi",
|
||||||
@@ -1482,7 +1482,7 @@
|
|||||||
"secondFactorsResetFailed": "Gagal mereset faktor kedua",
|
"secondFactorsResetFailed": "Gagal mereset faktor kedua",
|
||||||
"passwordLoginForced": "Login dengan kata sandi dimatikan, tetapi tidak ada metode login lain yang diaktifkan, jadi tetap menyala untuk saat ini. Tidak ada yang terkunci keluar.",
|
"passwordLoginForced": "Login dengan kata sandi dimatikan, tetapi tidak ada metode login lain yang diaktifkan, jadi tetap menyala untuk saat ini. Tidak ada yang terkunci keluar.",
|
||||||
"sslNotRenewed": "Tidak ada yang memperbarui sertifikat ini. Sertifikat ini kedaluwarsa pada {{date}}. Aktifkan perpanjangan otomatis di bagian Sertifikat ACME, atau unggah yang baru sebelum waktu itu.",
|
"sslNotRenewed": "Tidak ada yang memperbarui sertifikat ini. Sertifikat ini kedaluwarsa pada {{date}}. Aktifkan perpanjangan otomatis di bagian Sertifikat ACME, atau unggah yang baru sebelum waktu itu.",
|
||||||
"sslSelfSigned": "Sertifikat tanda tangan sendiri yang dibuat oleh Termix. Peramban akan memperingatkan tentang hal ini.",
|
"sslSelfSigned": "Sertifikat tanda tangan sendiri yang dibuat oleh Termix. Browser akan memperingatkan tentang hal ini.",
|
||||||
"sslRenewedBy": "Diperbarui secara otomatis oleh {{plugin}}",
|
"sslRenewedBy": "Diperbarui secara otomatis oleh {{plugin}}",
|
||||||
"sslIssuer": "Diterbitkan oleh {{issuer}}",
|
"sslIssuer": "Diterbitkan oleh {{issuer}}",
|
||||||
"sslNames": "Meliputi {{names}}",
|
"sslNames": "Meliputi {{names}}",
|
||||||
@@ -1527,8 +1527,8 @@
|
|||||||
"defaultsHeading": "Pintasan Bawaan",
|
"defaultsHeading": "Pintasan Bawaan",
|
||||||
"fixedHeading": "Pintasan Aplikasi",
|
"fixedHeading": "Pintasan Aplikasi",
|
||||||
"fixed": {
|
"fixed": {
|
||||||
"splitRight": "Pembagian ke kanan",
|
"splitRight": "Bagi ke kanan",
|
||||||
"splitDown": "Pembagian ke bawah",
|
"splitDown": "Bagi ke bawah",
|
||||||
"zoomPane": "Perbesar panel yang difokuskan",
|
"zoomPane": "Perbesar panel yang difokuskan",
|
||||||
"navigatePane": "Pindah antar panel",
|
"navigatePane": "Pindah antar panel",
|
||||||
"nextTab": "Tab berikutnya",
|
"nextTab": "Tab berikutnya",
|
||||||
@@ -1588,7 +1588,7 @@
|
|||||||
"presetOverridesTitle": "Disesuaikan",
|
"presetOverridesTitle": "Disesuaikan",
|
||||||
"presetRevertArea": "Kembalikan",
|
"presetRevertArea": "Kembalikan",
|
||||||
"presetConfirmTitle": "Beralih ke {{preset}}?",
|
"presetConfirmTitle": "Beralih ke {{preset}}?",
|
||||||
"presetConfirmDesc": "Ini akan mengembalikan sidebar, navigasi, tata letak dasbor, bilah alat terminal, dan tampilan manajer file Anda. Host, kredensial, dan beranda Anda tidak akan tersentuh.",
|
"presetConfirmDesc": "Ini akan mengembalikan sidebar, navigasi, tata letak dasbor, toolbar terminal, dan tampilan file manager Anda. Host, kredensial, dan beranda Anda tidak akan tersentuh.",
|
||||||
"presetConfirmApply": "Terapkan preset",
|
"presetConfirmApply": "Terapkan preset",
|
||||||
"runSetupAgainDesc": "Lakukan pengaturan awal lagi.",
|
"runSetupAgainDesc": "Lakukan pengaturan awal lagi.",
|
||||||
"runSetupAgainButton": "Jalankan pengaturan",
|
"runSetupAgainButton": "Jalankan pengaturan",
|
||||||
@@ -1598,7 +1598,7 @@
|
|||||||
"uiArea_rail": "Navigasi",
|
"uiArea_rail": "Navigasi",
|
||||||
"uiArea_dashboard": "Dasbor",
|
"uiArea_dashboard": "Dasbor",
|
||||||
"uiArea_terminal": "Terminal",
|
"uiArea_terminal": "Terminal",
|
||||||
"uiArea_fileManager": "Manajer file",
|
"uiArea_fileManager": "File manager",
|
||||||
"uiArea_hostEditor": "Editor host",
|
"uiArea_hostEditor": "Editor host",
|
||||||
"sectionAppearance": "Tampilan",
|
"sectionAppearance": "Tampilan",
|
||||||
"sectionSecurity": "Keamanan",
|
"sectionSecurity": "Keamanan",
|
||||||
|
|||||||
@@ -119,17 +119,45 @@ export function useScrollSpy(
|
|||||||
let lastHeight = area.scrollHeight;
|
let lastHeight = area.scrollHeight;
|
||||||
area.scrollTo?.({ top: lastTop, behavior: "smooth" });
|
area.scrollTo?.({ top: lastTop, behavior: "smooth" });
|
||||||
// Sections above can still be mounting, which moves the target or
|
// Sections above can still be mounting, which moves the target or
|
||||||
// cuts the scroll short. Follow it until the layout settles.
|
// cuts the scroll short. Follow it until the scroll lands and the
|
||||||
|
// layout stops changing, unless the user scrolls first.
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
|
let changedAt = started;
|
||||||
|
let userScrolled = false;
|
||||||
|
const onUser = () => {
|
||||||
|
userScrolled = true;
|
||||||
|
};
|
||||||
|
area.addEventListener("wheel", onUser, { passive: true });
|
||||||
|
area.addEventListener("touchstart", onUser, { passive: true });
|
||||||
|
const done = () => {
|
||||||
|
area.removeEventListener("wheel", onUser);
|
||||||
|
area.removeEventListener("touchstart", onUser);
|
||||||
|
};
|
||||||
const follow = () => {
|
const follow = () => {
|
||||||
if (!el.isConnected || Date.now() - started > 1500) return;
|
const now = Date.now();
|
||||||
|
if (userScrolled || !el.isConnected || now - started > 5000) {
|
||||||
|
return done();
|
||||||
|
}
|
||||||
const top = target();
|
const top = target();
|
||||||
|
// Keep the spy quiet for the whole trip, not just the first scroll.
|
||||||
|
lockUntil.current = now + 600;
|
||||||
if (Math.abs(top - lastTop) > 4 || area.scrollHeight !== lastHeight) {
|
if (Math.abs(top - lastTop) > 4 || area.scrollHeight !== lastHeight) {
|
||||||
lastTop = top;
|
lastTop = top;
|
||||||
lastHeight = area.scrollHeight;
|
lastHeight = area.scrollHeight;
|
||||||
lockUntil.current = Date.now() + 600;
|
changedAt = now;
|
||||||
area.scrollTo?.({ top, behavior: "smooth" });
|
area.scrollTo?.({ top, behavior: "smooth" });
|
||||||
}
|
}
|
||||||
|
const reachable = Math.min(
|
||||||
|
lastTop,
|
||||||
|
area.scrollHeight - area.clientHeight,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
Math.abs(area.scrollTop - reachable) <= 2 &&
|
||||||
|
now - changedAt > 400
|
||||||
|
) {
|
||||||
|
setActive(id);
|
||||||
|
return done();
|
||||||
|
}
|
||||||
requestAnimationFrame(follow);
|
requestAnimationFrame(follow);
|
||||||
};
|
};
|
||||||
requestAnimationFrame(follow);
|
requestAnimationFrame(follow);
|
||||||
@@ -137,6 +165,24 @@ export function useScrollSpy(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolves once the element's height holds still for a few frames. */
|
||||||
|
function untilLayoutSettles(el: HTMLElement | null, max = 800): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (!el || typeof requestAnimationFrame === "undefined") return resolve();
|
||||||
|
const started = Date.now();
|
||||||
|
let last = -1;
|
||||||
|
let still = 0;
|
||||||
|
const check = () => {
|
||||||
|
const height = el.scrollHeight;
|
||||||
|
still = height === last ? still + 1 : 0;
|
||||||
|
last = height;
|
||||||
|
if (still >= 3 || Date.now() - started > max) return resolve();
|
||||||
|
requestAnimationFrame(check);
|
||||||
|
};
|
||||||
|
requestAnimationFrame(check);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Wraps a section so the nav can find it. */
|
/** Wraps a section so the nav can find it. */
|
||||||
export function EditorSection({
|
export function EditorSection({
|
||||||
id,
|
id,
|
||||||
@@ -253,7 +299,15 @@ export function EditorPane({
|
|||||||
const ids = visibleBands.flatMap((band) => band.items.map((item) => item.id));
|
const ids = visibleBands.flatMap((band) => band.items.map((item) => item.id));
|
||||||
const { active, scrollTo } = useScrollSpy(scrollRef, ids);
|
const { active, scrollTo } = useScrollSpy(scrollRef, ids);
|
||||||
const showNav = bands.flatMap((band) => band.items).length > 1;
|
const showNav = bands.flatMap((band) => band.items).length > 1;
|
||||||
if (revealRef) revealRef.current = scrollTo;
|
// A jump mounts every lazy section first and scrolls once the form stops
|
||||||
|
// growing. Mounting them on the way down made the scroll stall and restart.
|
||||||
|
const [mountAll, setMountAll] = useState(false);
|
||||||
|
const jump = (id: string) => {
|
||||||
|
if (mountAll) return scrollTo(id);
|
||||||
|
setMountAll(true);
|
||||||
|
void untilLayoutSettles(scrollRef.current).then(() => scrollTo(id));
|
||||||
|
};
|
||||||
|
if (revealRef) revealRef.current = jump;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex min-h-0 flex-1", className)}>
|
<div className={cn("flex min-h-0 flex-1", className)}>
|
||||||
@@ -272,7 +326,7 @@ export function EditorPane({
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
aria-current={on ? "true" : undefined}
|
aria-current={on ? "true" : undefined}
|
||||||
onClick={() => scrollTo(item.id)}
|
onClick={() => jump(item.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-2 border-l-2 py-1.5 pl-2 pr-2 text-left transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
|
"flex w-full items-center gap-2 border-l-2 py-1.5 pl-2 pr-2 text-left transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
|
||||||
on
|
on
|
||||||
@@ -320,7 +374,7 @@ export function EditorPane({
|
|||||||
band.items.map((item) => ({ id: item.id, label: item.label })),
|
band.items.map((item) => ({ id: item.id, label: item.label })),
|
||||||
)}
|
)}
|
||||||
activeTab={active}
|
activeTab={active}
|
||||||
onTabChange={scrollTo}
|
onTabChange={jump}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -336,7 +390,7 @@ export function EditorPane({
|
|||||||
className={`mx-auto flex max-w-3xl flex-col ${PANEL.gap} pb-6`}
|
className={`mx-auto flex max-w-3xl flex-col ${PANEL.gap} pb-6`}
|
||||||
>
|
>
|
||||||
{banner}
|
{banner}
|
||||||
<SearchingContext.Provider value={searching}>
|
<SearchingContext.Provider value={searching || mountAll}>
|
||||||
{children}
|
{children}
|
||||||
</SearchingContext.Provider>
|
</SearchingContext.Provider>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { capabilityRisk, orderByRisk, type PluginEntry } from "./plugin-model";
|
|||||||
|
|
||||||
export interface ConsentRequest {
|
export interface ConsentRequest {
|
||||||
plugin: PluginEntry;
|
plugin: PluginEntry;
|
||||||
mode: "install" | "update" | "upload";
|
mode: "install" | "update" | "upload" | "enable";
|
||||||
version: string;
|
version: string;
|
||||||
/** What the prompt lists: all of them to install, only the new ones to update. */
|
/** What the prompt lists: all of them to install, only the new ones to update. */
|
||||||
capabilities: string[];
|
capabilities: string[];
|
||||||
@@ -45,17 +45,19 @@ export function PluginConsentPrompt({
|
|||||||
onCancel={busy ? undefined : onCancel}
|
onCancel={busy ? undefined : onCancel}
|
||||||
icon={<PluginIconBox name={plugin.icon} size="sm" />}
|
icon={<PluginIconBox name={plugin.icon} size="sm" />}
|
||||||
title={
|
title={
|
||||||
mode === "update"
|
mode === "enable"
|
||||||
? t("plugins.manager.consent.updateTitle", { name: plugin.name })
|
? t("plugins.manager.consent.enableTitle", { name: plugin.name })
|
||||||
: mode === "upload"
|
: mode === "update"
|
||||||
? t("plugins.manager.consent.uploadTitle", { name: plugin.name })
|
? t("plugins.manager.consent.updateTitle", { name: plugin.name })
|
||||||
: t("plugins.manager.consent.installTitle", { name: plugin.name })
|
: mode === "upload"
|
||||||
|
? t("plugins.manager.consent.uploadTitle", { name: plugin.name })
|
||||||
|
: t("plugins.manager.consent.installTitle", { name: plugin.name })
|
||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
<Facts>
|
<Facts>
|
||||||
{plugin.author && <span className="truncate">{plugin.author}</span>}
|
{plugin.author && <span className="truncate">{plugin.author}</span>}
|
||||||
<span className="shrink-0">
|
<span className="shrink-0">
|
||||||
{mode !== "install" && plugin.version
|
{(mode === "update" || mode === "upload") && plugin.version
|
||||||
? t("plugins.manager.versionChange", {
|
? t("plugins.manager.versionChange", {
|
||||||
from: plugin.version,
|
from: plugin.version,
|
||||||
to: version,
|
to: version,
|
||||||
@@ -75,9 +77,11 @@ export function PluginConsentPrompt({
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
|
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
|
||||||
>
|
>
|
||||||
{mode === "update"
|
{mode === "enable"
|
||||||
? t("plugins.manager.consent.agreeUpdate")
|
? t("plugins.manager.enable")
|
||||||
: t("plugins.manager.install")}
|
: mode === "update"
|
||||||
|
? t("plugins.manager.consent.agreeUpdate")
|
||||||
|
: t("plugins.manager.install")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export function PluginDetail({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={busy || locked}
|
disabled={busy || plugin.managedByServer}
|
||||||
onClick={() => void manager.toggle(plugin)}
|
onClick={() => void manager.toggle(plugin)}
|
||||||
>
|
>
|
||||||
{plugin.enabled
|
{plugin.enabled
|
||||||
|
|||||||
@@ -793,7 +793,7 @@ function InstalledCard({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="xs"
|
size="xs"
|
||||||
disabled={busy || locked}
|
disabled={busy || plugin.managedByServer}
|
||||||
onClick={() => void manager.toggle(plugin)}
|
onClick={() => void manager.toggle(plugin)}
|
||||||
>
|
>
|
||||||
{plugin.enabled
|
{plugin.enabled
|
||||||
@@ -907,7 +907,7 @@ function InstalledRowActions({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="xs"
|
size="xs"
|
||||||
disabled={busy || locked}
|
disabled={busy || plugin.managedByServer}
|
||||||
onClick={() => void manager.toggle(plugin)}
|
onClick={() => void manager.toggle(plugin)}
|
||||||
>
|
>
|
||||||
{plugin.enabled
|
{plugin.enabled
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ export interface PluginEntry {
|
|||||||
/** The registry it is listed in or was installed from. */
|
/** The registry it is listed in or was installed from. */
|
||||||
registryId: string | null;
|
registryId: string | null;
|
||||||
registryName: string | null;
|
registryName: string | null;
|
||||||
|
/** A linked server decides whether it runs here. */
|
||||||
|
managedByServer: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PluginSource =
|
export type PluginSource =
|
||||||
@@ -165,6 +167,7 @@ export function mergePlugins(
|
|||||||
unverified: pluginSource(summary) === "unverified",
|
unverified: pluginSource(summary) === "unverified",
|
||||||
registryId: entry?.registryId ?? summary.registryId ?? null,
|
registryId: entry?.registryId ?? summary.registryId ?? null,
|
||||||
registryName: entry?.registryName ?? null,
|
registryName: entry?.registryName ?? null,
|
||||||
|
managedByServer: summary.managedByServer ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,6 +211,7 @@ export function mergePlugins(
|
|||||||
unverified: false,
|
unverified: false,
|
||||||
registryId: entry.registryId ?? "official",
|
registryId: entry.registryId ?? "official",
|
||||||
registryName: entry.registryName ?? null,
|
registryName: entry.registryName ?? null,
|
||||||
|
managedByServer: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +252,7 @@ export function uploadEntry(preview: PluginUploadPreview): PluginEntry {
|
|||||||
unverified: true,
|
unverified: true,
|
||||||
registryId: null,
|
registryId: null,
|
||||||
registryName: null,
|
registryName: null,
|
||||||
|
managedByServer: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -342,18 +342,42 @@ export function usePluginsManager() {
|
|||||||
[failToast],
|
[failToast],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const doEnable = useCallback(
|
||||||
|
async (request: ConsentRequest) =>
|
||||||
|
run(request.plugin.id, async () => {
|
||||||
|
try {
|
||||||
|
const result = await setPluginState(request.plugin.id, true, {
|
||||||
|
version: request.version,
|
||||||
|
capabilities: request.capabilities,
|
||||||
|
});
|
||||||
|
if (result.state === "failed" || result.state === "blocked") {
|
||||||
|
toast.error(
|
||||||
|
t("plugins.manager.toast.failedToStart", {
|
||||||
|
name: request.plugin.name,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
failToast(error, "plugins.manager.errors.state", request.plugin.name);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[run, t, failToast],
|
||||||
|
);
|
||||||
|
|
||||||
const confirmConsent = useCallback(async () => {
|
const confirmConsent = useCallback(async () => {
|
||||||
if (!consent) return;
|
if (!consent) return;
|
||||||
const request = consent;
|
const request = consent;
|
||||||
setConsent(null);
|
setConsent(null);
|
||||||
if (request.mode === "upload" && request.uploadToken) {
|
if (request.mode === "enable") {
|
||||||
|
await doEnable(request);
|
||||||
|
} else if (request.mode === "upload" && request.uploadToken) {
|
||||||
await doInstallUpload(request);
|
await doInstallUpload(request);
|
||||||
} else if (request.mode === "install") {
|
} else if (request.mode === "install") {
|
||||||
await doInstall(request.plugin, request.version, request.capabilities);
|
await doInstall(request.plugin, request.version, request.capabilities);
|
||||||
} else {
|
} else {
|
||||||
await doUpdate(request.plugin, request.version, request.capabilities);
|
await doUpdate(request.plugin, request.version, request.capabilities);
|
||||||
}
|
}
|
||||||
}, [consent, doInstall, doUpdate, doInstallUpload]);
|
}, [consent, doInstall, doUpdate, doInstallUpload, doEnable]);
|
||||||
|
|
||||||
const nameOf = useCallback(
|
const nameOf = useCallback(
|
||||||
(id: string) => plugins.find((p) => p.id === id)?.name ?? id,
|
(id: string) => plugins.find((p) => p.id === id)?.name ?? id,
|
||||||
@@ -402,6 +426,15 @@ export function usePluginsManager() {
|
|||||||
failToast(error, "plugins.manager.errors.state", plugin.name);
|
failToast(error, "plugins.manager.errors.state", plugin.name);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (enable && registry?.managedByServer) {
|
||||||
|
setConsent({
|
||||||
|
plugin,
|
||||||
|
mode: "enable",
|
||||||
|
version: plugin.version!,
|
||||||
|
capabilities: plugin.capabilities,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
await run(plugin.id, async () => {
|
await run(plugin.id, async () => {
|
||||||
try {
|
try {
|
||||||
const result = await setPluginState(plugin.id, enable);
|
const result = await setPluginState(plugin.id, enable);
|
||||||
@@ -415,7 +448,7 @@ export function usePluginsManager() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[confirm, t, run, failToast, nameOf],
|
[confirm, t, run, failToast, nameOf, registry?.managedByServer],
|
||||||
);
|
);
|
||||||
|
|
||||||
const retry = useCallback(
|
const retry = useCallback(
|
||||||
|
|||||||
@@ -323,6 +323,8 @@ export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
|
|||||||
],
|
],
|
||||||
"appearance": [
|
"appearance": [
|
||||||
"newUi.sidebar.userProfile.accentColorLabel",
|
"newUi.sidebar.userProfile.accentColorLabel",
|
||||||
|
"newUi.sidebar.userProfile.appRailHoverPushesContent",
|
||||||
|
"newUi.sidebar.userProfile.appRailHoverPushesContentDesc",
|
||||||
"newUi.sidebar.userProfile.colorPickerTooltip",
|
"newUi.sidebar.userProfile.colorPickerTooltip",
|
||||||
"newUi.sidebar.userProfile.commandPalette",
|
"newUi.sidebar.userProfile.commandPalette",
|
||||||
"newUi.sidebar.userProfile.commandPaletteDesc",
|
"newUi.sidebar.userProfile.commandPaletteDesc",
|
||||||
|
|||||||
@@ -217,6 +217,12 @@ export function AdminSettingsPanel({
|
|||||||
setUserPage(0);
|
setUserPage(0);
|
||||||
}, [userSearch]);
|
}, [userSearch]);
|
||||||
|
|
||||||
|
// The panel stays mounted across Settings pages, so an open user view
|
||||||
|
// would otherwise replace whichever section is picked next.
|
||||||
|
useEffect(() => {
|
||||||
|
setManageUser(null);
|
||||||
|
}, [section]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onEditingChange?.(manageUser !== null);
|
onEditingChange?.(manageUser !== null);
|
||||||
return () => onEditingChange?.(false);
|
return () => onEditingChange?.(false);
|
||||||
|
|||||||
+252
-228
@@ -159,6 +159,9 @@ export function AppRail({
|
|||||||
const [showPinButton, setShowPinButton] = useState(() =>
|
const [showPinButton, setShowPinButton] = useState(() =>
|
||||||
readRailPreference("showPinAppRailButton"),
|
readRailPreference("showPinAppRailButton"),
|
||||||
);
|
);
|
||||||
|
const [hoverPushes, setHoverPushes] = useState(() =>
|
||||||
|
readRailPreference("appRailHoverPushesContent"),
|
||||||
|
);
|
||||||
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null);
|
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null);
|
||||||
const [menuTarget, setMenuTarget] = useState<MenuTarget | null>(null);
|
const [menuTarget, setMenuTarget] = useState<MenuTarget | null>(null);
|
||||||
const [managing, setManaging] = useState(false);
|
const [managing, setManaging] = useState(false);
|
||||||
@@ -170,8 +173,11 @@ export function AppRail({
|
|||||||
setExpandOnHover(readRailPreference("expandAppRailOnHover"));
|
setExpandOnHover(readRailPreference("expandAppRailOnHover"));
|
||||||
const showPinButtonHandler = () =>
|
const showPinButtonHandler = () =>
|
||||||
setShowPinButton(readRailPreference("showPinAppRailButton"));
|
setShowPinButton(readRailPreference("showPinAppRailButton"));
|
||||||
|
const pushHandler = () =>
|
||||||
|
setHoverPushes(readRailPreference("appRailHoverPushesContent"));
|
||||||
window.addEventListener("pinAppRailChanged", pinHandler);
|
window.addEventListener("pinAppRailChanged", pinHandler);
|
||||||
window.addEventListener("expandAppRailOnHoverChanged", hoverHandler);
|
window.addEventListener("expandAppRailOnHoverChanged", hoverHandler);
|
||||||
|
window.addEventListener("appRailHoverPushesContentChanged", pushHandler);
|
||||||
window.addEventListener(
|
window.addEventListener(
|
||||||
"showPinAppRailButtonChanged",
|
"showPinAppRailButtonChanged",
|
||||||
showPinButtonHandler,
|
showPinButtonHandler,
|
||||||
@@ -179,6 +185,10 @@ export function AppRail({
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("pinAppRailChanged", pinHandler);
|
window.removeEventListener("pinAppRailChanged", pinHandler);
|
||||||
window.removeEventListener("expandAppRailOnHoverChanged", hoverHandler);
|
window.removeEventListener("expandAppRailOnHoverChanged", hoverHandler);
|
||||||
|
window.removeEventListener(
|
||||||
|
"appRailHoverPushesContentChanged",
|
||||||
|
pushHandler,
|
||||||
|
);
|
||||||
window.removeEventListener(
|
window.removeEventListener(
|
||||||
"showPinAppRailButtonChanged",
|
"showPinAppRailButtonChanged",
|
||||||
showPinButtonHandler,
|
showPinButtonHandler,
|
||||||
@@ -312,255 +322,269 @@ export function AppRail({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Expanding on hover draws over the content instead of pushing it, so open
|
||||||
|
// sessions like remote desktop are not resized each time, unless the user
|
||||||
|
// asked for the old behavior.
|
||||||
|
const takesSpace = hoverPushes ? railExpanded : pinned || managing;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
// The desktop app keeps the rail however narrow its window gets.
|
// The desktop app keeps the rail however narrow its window gets.
|
||||||
className={`${isElectron() ? "flex" : "hidden md:flex"} flex-col items-stretch bg-sidebar border-r border-border shrink-0 overflow-hidden pt-2 gap-1 transition-[width] duration-200 min-h-0`}
|
className={`${isElectron() ? "flex" : "hidden md:flex"} relative shrink-0 transition-[width] duration-200 min-h-0`}
|
||||||
style={{ width: rem(railExpanded ? 172 : 40) }}
|
style={{ width: rem(takesSpace ? 172 : 40) }}
|
||||||
onMouseEnter={() => setHovered(true)}
|
|
||||||
onMouseLeave={() => setHovered(false)}
|
|
||||||
onContextMenu={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
// Keep the menu on screen when right-clicking near the viewport edges
|
|
||||||
const MENU_W = 200;
|
|
||||||
const MENU_H = 180;
|
|
||||||
setMenuPos({
|
|
||||||
x: Math.min(e.clientX, window.innerWidth - MENU_W - 8),
|
|
||||||
y: Math.min(e.clientY, window.innerHeight - MENU_H - 8),
|
|
||||||
});
|
|
||||||
if (!(e.target as HTMLElement).closest("[data-rail-item]")) {
|
|
||||||
setMenuTarget(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{onOpenPalette && (
|
<div
|
||||||
<>
|
className="absolute inset-y-0 left-0 z-40 flex flex-col items-stretch bg-sidebar border-r border-border overflow-hidden pt-2 gap-1 transition-[width] duration-200 min-h-0"
|
||||||
<button
|
style={{ width: rem(railExpanded ? 172 : 40) }}
|
||||||
type="button"
|
onMouseEnter={() => setHovered(true)}
|
||||||
onClick={onOpenPalette}
|
onMouseLeave={() => setHovered(false)}
|
||||||
title={t("nav.searchEverything")}
|
onContextMenu={(e) => {
|
||||||
aria-label={t("nav.searchEverything")}
|
e.preventDefault();
|
||||||
style={btnStyle}
|
// Keep the menu on screen when right-clicking near the viewport edges
|
||||||
className={`${btnBase} ${idle}`}
|
const MENU_W = 200;
|
||||||
>
|
const MENU_H = 180;
|
||||||
<RailIcon>
|
setMenuPos({
|
||||||
<Search size={16} />
|
x: Math.min(e.clientX, window.innerWidth - MENU_W - 8),
|
||||||
</RailIcon>
|
y: Math.min(e.clientY, window.innerHeight - MENU_H - 8),
|
||||||
<RailLabel expanded={railExpanded}>{t("nav.search")}</RailLabel>
|
});
|
||||||
</button>
|
if (!(e.target as HTMLElement).closest("[data-rail-item]")) {
|
||||||
<Rule expanded={railExpanded} />
|
setMenuTarget(null);
|
||||||
</>
|
}
|
||||||
)}
|
}}
|
||||||
|
>
|
||||||
<div className="flex flex-col flex-1 gap-1 overflow-y-auto scrollbar-none min-h-0">
|
{onOpenPalette && (
|
||||||
{!pluginsSettled
|
|
||||||
? Array.from({ length: mainItems.length || 8 }, (_, i) => (
|
|
||||||
<div
|
|
||||||
key={`rail-skeleton-${i}`}
|
|
||||||
style={btnStyle}
|
|
||||||
className="flex items-center h-7 shrink-0"
|
|
||||||
>
|
|
||||||
<Skeleton className="size-4 shrink-0" />
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
: bands.map((band, i) => (
|
|
||||||
<div key={band.group} className="flex flex-col gap-1">
|
|
||||||
{i > 0 && (
|
|
||||||
<GroupDivider group={band.group} expanded={railExpanded} />
|
|
||||||
)}
|
|
||||||
{band.items.map(renderItem)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{pluginsSettled && hiddenItems.length > 0 && (
|
|
||||||
<>
|
|
||||||
<Rule expanded={railExpanded} />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setManaging((v) => !v)}
|
|
||||||
aria-expanded={managing}
|
|
||||||
title={t("nav.hiddenCount", { count: hiddenItems.length })}
|
|
||||||
style={btnStyle}
|
|
||||||
className={`${btnBase} ${managing ? activeClass : idle}`}
|
|
||||||
>
|
|
||||||
<RailIcon>
|
|
||||||
<EyeOff size={16} />
|
|
||||||
</RailIcon>
|
|
||||||
<RailLabel expanded={railExpanded}>
|
|
||||||
{t("nav.hiddenCount", { count: hiddenItems.length })}
|
|
||||||
</RailLabel>
|
|
||||||
</button>
|
|
||||||
{managing &&
|
|
||||||
hiddenItems.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={`hidden-${item.id}`}
|
|
||||||
type="button"
|
|
||||||
onClick={() => void toggleHiddenRailTab(item.id)}
|
|
||||||
title={t("nav.showInRail")}
|
|
||||||
style={btnStyle}
|
|
||||||
className={`${btnBase} text-muted-foreground/70 hover:text-foreground hover:bg-muted/60`}
|
|
||||||
>
|
|
||||||
<RailIcon>
|
|
||||||
<Icon size={16} />
|
|
||||||
</RailIcon>
|
|
||||||
<span className="min-w-0 truncate text-xs font-medium whitespace-nowrap">
|
|
||||||
{t(item.labelKey)}
|
|
||||||
</span>
|
|
||||||
<Eye className="ml-auto size-3 shrink-0 opacity-60" />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="shrink-0 flex flex-col gap-1 border-t border-border pt-1 pb-1">
|
|
||||||
{showPinButton && (
|
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setRailPreference("pinAppRail", !pinned)}
|
onClick={onOpenPalette}
|
||||||
|
title={t("nav.searchEverything")}
|
||||||
|
aria-label={t("nav.searchEverything")}
|
||||||
style={btnStyle}
|
style={btnStyle}
|
||||||
title={
|
className={`${btnBase} ${idle}`}
|
||||||
pinned ? t("nav.collapseSideMenu") : t("nav.keepSideMenuOpen")
|
|
||||||
}
|
|
||||||
className={`${btnBase} ${pinned ? activeClass : idle}`}
|
|
||||||
>
|
>
|
||||||
<RailIcon>
|
<RailIcon>
|
||||||
<Pin size={16} />
|
<Search size={16} />
|
||||||
</RailIcon>
|
</RailIcon>
|
||||||
<RailLabel expanded={railExpanded}>
|
<RailLabel expanded={railExpanded}>{t("nav.search")}</RailLabel>
|
||||||
{pinned ? t("nav.collapseSideMenu") : t("nav.keepSideMenuOpen")}
|
|
||||||
</RailLabel>
|
|
||||||
</button>
|
</button>
|
||||||
<Rule expanded={railExpanded} />
|
<Rule expanded={railExpanded} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{footerItems.map(renderItem)}
|
|
||||||
{footerItems.length > 0 && !isElectron() && (
|
|
||||||
<Rule expanded={railExpanded} />
|
|
||||||
)}
|
|
||||||
{/* The desktop signs in to its own local profile by itself. */}
|
|
||||||
{!isElectron() && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onLogout({ manual: true })}
|
|
||||||
style={btnStyle}
|
|
||||||
title={t("common.logout")}
|
|
||||||
aria-label={t("common.logout")}
|
|
||||||
className={`${btnBase} text-muted-foreground hover:text-destructive hover:bg-destructive/10`}
|
|
||||||
>
|
|
||||||
<RailIcon>
|
|
||||||
<LogOut size={16} />
|
|
||||||
</RailIcon>
|
|
||||||
<RailLabel expanded={railExpanded}>{t("common.logout")}</RailLabel>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="shrink-0 border-t border-border">
|
<div className="flex flex-col flex-1 gap-1 overflow-y-auto scrollbar-none min-h-0">
|
||||||
<button
|
{!pluginsSettled
|
||||||
type="button"
|
? Array.from({ length: mainItems.length || 8 }, (_, i) => (
|
||||||
onClick={onOpenSettings}
|
<div
|
||||||
title={t("nav.openSettings")}
|
key={`rail-skeleton-${i}`}
|
||||||
aria-label={t("nav.openSettings")}
|
style={btnStyle}
|
||||||
className="flex items-center gap-2.5 w-full h-10 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors focus-visible:ring-1 focus-visible:ring-ring"
|
className="flex items-center h-7 shrink-0"
|
||||||
style={{ padding: `0 ${rem(8)}` }}
|
>
|
||||||
>
|
<Skeleton className="size-4 shrink-0" />
|
||||||
<div
|
</div>
|
||||||
className="rounded-full bg-accent-brand/20 border border-accent-brand/30 flex items-center justify-center font-bold text-accent-brand shrink-0"
|
))
|
||||||
style={{ width: rem(24), height: rem(24), fontSize: rem(11) }}
|
: bands.map((band, i) => (
|
||||||
>
|
<div key={band.group} className="flex flex-col gap-1">
|
||||||
{username.charAt(0).toUpperCase() || "U"}
|
{i > 0 && (
|
||||||
</div>
|
<GroupDivider group={band.group} expanded={railExpanded} />
|
||||||
<div
|
)}
|
||||||
className={`flex flex-col items-start overflow-hidden transition-opacity duration-150 ${
|
{band.items.map(renderItem)}
|
||||||
railExpanded ? "opacity-100 delay-75" : "opacity-0"
|
</div>
|
||||||
}`}
|
))}
|
||||||
>
|
|
||||||
<span className="text-xs font-semibold leading-tight whitespace-nowrap">
|
|
||||||
{username || t("nav.roleUser")}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-muted-foreground leading-tight whitespace-nowrap">
|
|
||||||
{isAdmin ? t("nav.roleAdministrator") : t("nav.roleUser")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{menuPos && (
|
{pluginsSettled && hiddenItems.length > 0 && (
|
||||||
<div
|
|
||||||
data-rail-context-menu
|
|
||||||
role="menu"
|
|
||||||
style={{ position: "fixed", left: menuPos.x, top: menuPos.y }}
|
|
||||||
className="z-[10000] bg-popover border border-border shadow-lg py-1 min-w-[200px]"
|
|
||||||
>
|
|
||||||
{menuTarget && (
|
|
||||||
<>
|
<>
|
||||||
{menuTarget.promotable && (
|
<Rule expanded={railExpanded} />
|
||||||
<MenuItem
|
<button
|
||||||
icon={<SquareArrowOutUpRight className="size-3" />}
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => setManaging((v) => !v)}
|
||||||
onOpenTab?.(menuTarget.id as TabType);
|
aria-expanded={managing}
|
||||||
setMenuPos(null);
|
title={t("nav.hiddenCount", { count: hiddenItems.length })}
|
||||||
}}
|
style={btnStyle}
|
||||||
>
|
className={`${btnBase} ${managing ? activeClass : idle}`}
|
||||||
{t("nav.openAsTab")}
|
>
|
||||||
</MenuItem>
|
<RailIcon>
|
||||||
)}
|
<EyeOff size={16} />
|
||||||
{menuTarget.rightDockable && (
|
</RailIcon>
|
||||||
<MenuItem
|
<RailLabel expanded={railExpanded}>
|
||||||
icon={<PanelRight className="size-3" />}
|
{t("nav.hiddenCount", { count: hiddenItems.length })}
|
||||||
onClick={() => {
|
</RailLabel>
|
||||||
onOpenInRightDock?.(menuTarget.id);
|
</button>
|
||||||
setMenuPos(null);
|
{managing &&
|
||||||
}}
|
hiddenItems.map((item) => {
|
||||||
>
|
const Icon = item.icon;
|
||||||
{t("nav.openInRightDock")}
|
return (
|
||||||
</MenuItem>
|
<button
|
||||||
)}
|
key={`hidden-${item.id}`}
|
||||||
{menuTarget.hideable && (
|
type="button"
|
||||||
<MenuItem
|
onClick={() => void toggleHiddenRailTab(item.id)}
|
||||||
icon={<EyeOff className="size-3" />}
|
title={t("nav.showInRail")}
|
||||||
onClick={() => {
|
style={btnStyle}
|
||||||
void toggleHiddenRailTab(menuTarget.id);
|
className={`${btnBase} text-muted-foreground/70 hover:text-foreground hover:bg-muted/60`}
|
||||||
setMenuPos(null);
|
>
|
||||||
}}
|
<RailIcon>
|
||||||
>
|
<Icon size={16} />
|
||||||
{t("nav.hideFromRail")}
|
</RailIcon>
|
||||||
</MenuItem>
|
<span className="min-w-0 truncate text-xs font-medium whitespace-nowrap">
|
||||||
)}
|
{t(item.labelKey)}
|
||||||
<div className="h-px bg-border my-1" />
|
</span>
|
||||||
|
<Eye className="ml-auto size-3 shrink-0 opacity-60" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<MenuItem
|
|
||||||
icon={pinned ? <Check className="size-3" /> : null}
|
|
||||||
onClick={togglePinned}
|
|
||||||
checked={pinned}
|
|
||||||
>
|
|
||||||
{t("newUi.sidebar.userProfile.pinAppRail")}
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem
|
|
||||||
icon={expandOnHover ? <Check className="size-3" /> : null}
|
|
||||||
onClick={toggleExpandOnHover}
|
|
||||||
checked={expandOnHover}
|
|
||||||
>
|
|
||||||
{t("newUi.sidebar.userProfile.expandAppRailOnHover")}
|
|
||||||
</MenuItem>
|
|
||||||
<div className="h-px bg-border my-1" />
|
|
||||||
<MenuItem
|
|
||||||
icon={<SlidersHorizontal className="size-3" />}
|
|
||||||
onClick={() => {
|
|
||||||
onOpenNavigationSettings();
|
|
||||||
setMenuPos(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("nav.openSettings")}
|
|
||||||
</MenuItem>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
<div className="shrink-0 flex flex-col gap-1 border-t border-border pt-1 pb-1">
|
||||||
|
{showPinButton && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRailPreference("pinAppRail", !pinned)}
|
||||||
|
style={btnStyle}
|
||||||
|
title={
|
||||||
|
pinned ? t("nav.collapseSideMenu") : t("nav.keepSideMenuOpen")
|
||||||
|
}
|
||||||
|
className={`${btnBase} ${pinned ? activeClass : idle}`}
|
||||||
|
>
|
||||||
|
<RailIcon>
|
||||||
|
<Pin size={16} />
|
||||||
|
</RailIcon>
|
||||||
|
<RailLabel expanded={railExpanded}>
|
||||||
|
{pinned
|
||||||
|
? t("nav.collapseSideMenu")
|
||||||
|
: t("nav.keepSideMenuOpen")}
|
||||||
|
</RailLabel>
|
||||||
|
</button>
|
||||||
|
<Rule expanded={railExpanded} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{footerItems.map(renderItem)}
|
||||||
|
{footerItems.length > 0 && !isElectron() && (
|
||||||
|
<Rule expanded={railExpanded} />
|
||||||
|
)}
|
||||||
|
{/* The desktop signs in to its own local profile by itself. */}
|
||||||
|
{!isElectron() && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onLogout({ manual: true })}
|
||||||
|
style={btnStyle}
|
||||||
|
title={t("common.logout")}
|
||||||
|
aria-label={t("common.logout")}
|
||||||
|
className={`${btnBase} text-muted-foreground hover:text-destructive hover:bg-destructive/10`}
|
||||||
|
>
|
||||||
|
<RailIcon>
|
||||||
|
<LogOut size={16} />
|
||||||
|
</RailIcon>
|
||||||
|
<RailLabel expanded={railExpanded}>
|
||||||
|
{t("common.logout")}
|
||||||
|
</RailLabel>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0 border-t border-border">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenSettings}
|
||||||
|
title={t("nav.openSettings")}
|
||||||
|
aria-label={t("nav.openSettings")}
|
||||||
|
className="flex items-center gap-2.5 w-full h-10 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
style={{ padding: `0 ${rem(8)}` }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="rounded-full bg-accent-brand/20 border border-accent-brand/30 flex items-center justify-center font-bold text-accent-brand shrink-0"
|
||||||
|
style={{ width: rem(24), height: rem(24), fontSize: rem(11) }}
|
||||||
|
>
|
||||||
|
{username.charAt(0).toUpperCase() || "U"}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`flex flex-col items-start overflow-hidden transition-opacity duration-150 ${
|
||||||
|
railExpanded ? "opacity-100 delay-75" : "opacity-0"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-semibold leading-tight whitespace-nowrap">
|
||||||
|
{username || t("nav.roleUser")}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground leading-tight whitespace-nowrap">
|
||||||
|
{isAdmin ? t("nav.roleAdministrator") : t("nav.roleUser")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{menuPos && (
|
||||||
|
<div
|
||||||
|
data-rail-context-menu
|
||||||
|
role="menu"
|
||||||
|
style={{ position: "fixed", left: menuPos.x, top: menuPos.y }}
|
||||||
|
className="z-[10000] bg-popover border border-border shadow-lg py-1 min-w-[200px]"
|
||||||
|
>
|
||||||
|
{menuTarget && (
|
||||||
|
<>
|
||||||
|
{menuTarget.promotable && (
|
||||||
|
<MenuItem
|
||||||
|
icon={<SquareArrowOutUpRight className="size-3" />}
|
||||||
|
onClick={() => {
|
||||||
|
onOpenTab?.(menuTarget.id as TabType);
|
||||||
|
setMenuPos(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("nav.openAsTab")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{menuTarget.rightDockable && (
|
||||||
|
<MenuItem
|
||||||
|
icon={<PanelRight className="size-3" />}
|
||||||
|
onClick={() => {
|
||||||
|
onOpenInRightDock?.(menuTarget.id);
|
||||||
|
setMenuPos(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("nav.openInRightDock")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{menuTarget.hideable && (
|
||||||
|
<MenuItem
|
||||||
|
icon={<EyeOff className="size-3" />}
|
||||||
|
onClick={() => {
|
||||||
|
void toggleHiddenRailTab(menuTarget.id);
|
||||||
|
setMenuPos(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("nav.hideFromRail")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
<div className="h-px bg-border my-1" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<MenuItem
|
||||||
|
icon={pinned ? <Check className="size-3" /> : null}
|
||||||
|
onClick={togglePinned}
|
||||||
|
checked={pinned}
|
||||||
|
>
|
||||||
|
{t("newUi.sidebar.userProfile.pinAppRail")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
icon={expandOnHover ? <Check className="size-3" /> : null}
|
||||||
|
onClick={toggleExpandOnHover}
|
||||||
|
checked={expandOnHover}
|
||||||
|
>
|
||||||
|
{t("newUi.sidebar.userProfile.expandAppRailOnHover")}
|
||||||
|
</MenuItem>
|
||||||
|
<div className="h-px bg-border my-1" />
|
||||||
|
<MenuItem
|
||||||
|
icon={<SlidersHorizontal className="size-3" />}
|
||||||
|
onClick={() => {
|
||||||
|
onOpenNavigationSettings();
|
||||||
|
setMenuPos(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("nav.openSettings")}
|
||||||
|
</MenuItem>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -223,6 +223,20 @@ export function CustomizeSidebarPanel({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
label={t("hosts.closeOnOpen")}
|
||||||
|
description={t("hosts.closeOnOpenDesc")}
|
||||||
|
>
|
||||||
|
<FakeSwitch
|
||||||
|
checked={preferences.display.closeOnOpen}
|
||||||
|
onChange={(v) =>
|
||||||
|
update((prev) => ({
|
||||||
|
...prev,
|
||||||
|
display: { ...prev.display, closeOnOpen: v },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label={t("newUi.sidebar.userProfile.statusColors")}
|
label={t("newUi.sidebar.userProfile.statusColors")}
|
||||||
description={t("newUi.sidebar.userProfile.statusColorsDesc")}
|
description={t("newUi.sidebar.userProfile.statusColorsDesc")}
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ export function HostsPanel({
|
|||||||
hostTree,
|
hostTree,
|
||||||
loading,
|
loading,
|
||||||
active = true,
|
active = true,
|
||||||
|
onCloseSidebar,
|
||||||
}: {
|
}: {
|
||||||
onOpenTab: (
|
onOpenTab: (
|
||||||
host: Host,
|
host: Host,
|
||||||
@@ -217,6 +218,7 @@ export function HostsPanel({
|
|||||||
/** Unused since editing moved to the Manage tab. */
|
/** Unused since editing moved to the Manage tab. */
|
||||||
onEditingChange?: (editing: boolean) => void;
|
onEditingChange?: (editing: boolean) => void;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
onCloseSidebar?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { statuses, getStatus } = useServerStatus();
|
const { statuses, getStatus } = useServerStatus();
|
||||||
@@ -1044,7 +1046,10 @@ export function HostsPanel({
|
|||||||
).children
|
).children
|
||||||
: []
|
: []
|
||||||
}
|
}
|
||||||
onOpenTab={onOpenTab}
|
onOpenTab={(host, type, options) => {
|
||||||
|
onOpenTab(host, type, options);
|
||||||
|
if (sidebarPrefs.display.closeOnOpen) onCloseSidebar?.();
|
||||||
|
}}
|
||||||
onEditHost={onEditHost}
|
onEditHost={onEditHost}
|
||||||
onShareHost={(host) => setShareModalHost(host)}
|
onShareHost={(host) => setShareModalHost(host)}
|
||||||
query={hostSearch.trim().toLowerCase()}
|
query={hostSearch.trim().toLowerCase()}
|
||||||
|
|||||||
@@ -553,6 +553,9 @@ export function UserProfilePanel({
|
|||||||
const [expandAppRailOnHover, setExpandAppRailOnHover] = useState(() =>
|
const [expandAppRailOnHover, setExpandAppRailOnHover] = useState(() =>
|
||||||
readRailPreference("expandAppRailOnHover"),
|
readRailPreference("expandAppRailOnHover"),
|
||||||
);
|
);
|
||||||
|
const [appRailHoverPushes, setAppRailHoverPushes] = useState(() =>
|
||||||
|
readRailPreference("appRailHoverPushesContent"),
|
||||||
|
);
|
||||||
const [showPinAppRailButton, setShowPinAppRailButton] = useState(() =>
|
const [showPinAppRailButton, setShowPinAppRailButton] = useState(() =>
|
||||||
readRailPreference("showPinAppRailButton"),
|
readRailPreference("showPinAppRailButton"),
|
||||||
);
|
);
|
||||||
@@ -1684,6 +1687,21 @@ export function UserProfilePanel({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
label={t("newUi.sidebar.userProfile.appRailHoverPushesContent")}
|
||||||
|
description={t(
|
||||||
|
"newUi.sidebar.userProfile.appRailHoverPushesContentDesc",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FakeSwitch
|
||||||
|
checked={appRailHoverPushes}
|
||||||
|
disabled={!expandAppRailOnHover}
|
||||||
|
onChange={(v) => {
|
||||||
|
setAppRailHoverPushes(v);
|
||||||
|
setRailPreference("appRailHoverPushesContent", v);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label={t("newUi.sidebar.userProfile.showPinAppRailButton")}
|
label={t("newUi.sidebar.userProfile.showPinAppRailButton")}
|
||||||
description={t(
|
description={t(
|
||||||
|
|||||||
@@ -70,9 +70,9 @@ export function sortHostTree(
|
|||||||
case "name-desc":
|
case "name-desc":
|
||||||
return hostB.name.localeCompare(hostA.name);
|
return hostB.name.localeCompare(hostA.name);
|
||||||
case "ip-asc":
|
case "ip-asc":
|
||||||
return hostA.ip.localeCompare(hostB.ip);
|
return hostA.ip.localeCompare(hostB.ip, undefined, { numeric: true });
|
||||||
case "ip-desc":
|
case "ip-desc":
|
||||||
return hostB.ip.localeCompare(hostA.ip);
|
return hostB.ip.localeCompare(hostA.ip, undefined, { numeric: true });
|
||||||
case "status-online":
|
case "status-online":
|
||||||
return Number(hostB.online) - Number(hostA.online);
|
return Number(hostB.online) - Number(hostA.online);
|
||||||
case "status-offline":
|
case "status-offline":
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
import { getUserPreferences, saveUserPreferences } from "@/api/open-tabs-api";
|
import { getUserPreferences, saveUserPreferences } from "@/api/open-tabs-api";
|
||||||
|
|
||||||
export type RailPreference =
|
export type RailPreference =
|
||||||
"pinAppRail" | "expandAppRailOnHover" | "showPinAppRailButton";
|
| "pinAppRail"
|
||||||
|
| "expandAppRailOnHover"
|
||||||
|
| "showPinAppRailButton"
|
||||||
|
| "appRailHoverPushesContent";
|
||||||
|
|
||||||
const CHANGE_EVENT: Record<RailPreference, string> = {
|
const CHANGE_EVENT: Record<RailPreference, string> = {
|
||||||
pinAppRail: "pinAppRailChanged",
|
pinAppRail: "pinAppRailChanged",
|
||||||
expandAppRailOnHover: "expandAppRailOnHoverChanged",
|
expandAppRailOnHover: "expandAppRailOnHoverChanged",
|
||||||
showPinAppRailButton: "showPinAppRailButtonChanged",
|
showPinAppRailButton: "showPinAppRailButtonChanged",
|
||||||
|
appRailHoverPushesContent: "appRailHoverPushesContentChanged",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Kept on this device only; the server has no column for it. */
|
||||||
|
const LOCAL_ONLY: ReadonlySet<RailPreference> = new Set([
|
||||||
|
"appRailHoverPushesContent",
|
||||||
|
]);
|
||||||
|
|
||||||
// pinAppRail and showPinAppRailButton default off, expandAppRailOnHover
|
// pinAppRail and showPinAppRailButton default off, expandAppRailOnHover
|
||||||
// defaults on, so each key needs its own read rather than a shared
|
// defaults on, so each key needs its own read rather than a shared
|
||||||
// === "true" check.
|
// === "true" check.
|
||||||
@@ -28,6 +37,7 @@ export function readRailPreference(key: RailPreference): boolean {
|
|||||||
export function setRailPreference(key: RailPreference, value: boolean): void {
|
export function setRailPreference(key: RailPreference, value: boolean): void {
|
||||||
localStorage.setItem(key, String(value));
|
localStorage.setItem(key, String(value));
|
||||||
window.dispatchEvent(new Event(CHANGE_EVENT[key]));
|
window.dispatchEvent(new Event(CHANGE_EVENT[key]));
|
||||||
|
if (LOCAL_ONLY.has(key)) return;
|
||||||
void getUserPreferences()
|
void getUserPreferences()
|
||||||
.then((preferences) => {
|
.then((preferences) => {
|
||||||
if (preferences.storageMode === "cloud") {
|
if (preferences.storageMode === "cloud") {
|
||||||
|
|||||||
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user