mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4d8a52928 | ||
|
|
3d92725ce6 |
No files matched your search
@@ -250,27 +250,6 @@ jobs:
|
||||
path: termix_windows_arm64_portable.zip
|
||||
retention-days: 30
|
||||
|
||||
# The same app with every official plugin, for installs without
|
||||
# internet. Built last so nothing above packs the full set, and with
|
||||
# no update files: a full install updates through the normal build and
|
||||
# downloads the plugins it had on.
|
||||
- name: Build Windows x64 full installer
|
||||
if: inputs.artifact_destination != 'none'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TERMIX_PLUGIN_PROFILE: full
|
||||
run: |
|
||||
npm run build:plugins
|
||||
npx electron-builder --win nsis --x64 --publish never '-c.nsis.artifactName=termix_windows_x64_full_nsis.${ext}'
|
||||
|
||||
- name: Upload Windows x64 full installer
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('release/termix_windows_x64_full_nsis.exe') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_windows_x64_full_nsis
|
||||
path: release/termix_windows_x64_full_nsis.exe
|
||||
retention-days: 30
|
||||
|
||||
build-linux:
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
if: (inputs.build_type == 'all' || inputs.build_type == 'linux' || inputs.build_type == '') && inputs.artifact_destination != 'submit'
|
||||
@@ -509,28 +488,6 @@ jobs:
|
||||
path: release/com.karmaa.termix.flatpakref
|
||||
retention-days: 30
|
||||
|
||||
# Every official plugin, for installs without internet. No update files.
|
||||
- name: Build Linux x64 full packages
|
||||
if: inputs.artifact_destination != 'none'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TERMIX_PLUGIN_PROFILE: full
|
||||
run: |
|
||||
npm run build:plugins
|
||||
npx electron-builder --linux AppImage deb --x64 --publish never \
|
||||
'-c.appImage.artifactName=termix_linux_x64_full_appimage.${ext}' \
|
||||
'-c.deb.artifactName=termix_linux_x64_full_deb.${ext}'
|
||||
|
||||
- name: Upload Linux x64 full packages
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('release/termix_linux_x64_full_appimage.AppImage') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_x64_full
|
||||
path: |
|
||||
release/termix_linux_x64_full_appimage.AppImage
|
||||
release/termix_linux_x64_full_deb.deb
|
||||
retention-days: 30
|
||||
|
||||
build-macos:
|
||||
runs-on: blacksmith-6vcpu-macos-latest
|
||||
if: (inputs.build_type == 'macos' || inputs.build_type == 'all') && inputs.artifact_destination != 'submit'
|
||||
@@ -806,32 +763,6 @@ jobs:
|
||||
|
||||
gh release upload "$RELEASE_TAG" homebrew-generated/termix.rb --repo ${{ github.repository }} --clobber
|
||||
|
||||
# Every official plugin, for installs without internet. Signed like the
|
||||
# normal DMG, with no update files.
|
||||
- name: Build macOS universal full DMG
|
||||
if: inputs.artifact_destination != 'none'
|
||||
env:
|
||||
ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: true
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TERMIX_PLUGIN_PROFILE: full
|
||||
run: |
|
||||
npm run build:plugins
|
||||
npx electron-builder --mac dmg --universal --publish never \
|
||||
'-c.dmg.artifactName=termix_macos_${arch}_full_dmg.${ext}'
|
||||
|
||||
- name: Upload macOS universal full DMG
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('release/termix_macos_universal_full_dmg.dmg') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_macos_universal_full_dmg
|
||||
path: release/termix_macos_universal_full_dmg.dmg
|
||||
retention-days: 30
|
||||
|
||||
- name: Clean up keychains
|
||||
if: always()
|
||||
run: |
|
||||
|
||||
@@ -5,33 +5,6 @@
|
||||
### Added
|
||||
|
||||
- The mobile app can sign in through your browser with any login method, including passkeys and security keys
|
||||
- Plugins can say how they show in the mobile app
|
||||
- The plugin list tells the mobile app which plugin API each plugin needs, so the app can say when it or a plugin needs an update
|
||||
- Hosts can have a color and an icon, which sync to the desktop and mobile apps
|
||||
- A sidebar setting to turn off alternating row colors
|
||||
- TERMIX_NGINX_TEMPLATE, TERMIX_NGINX_CONF, TERMIX_NGINX_PID and TERMIX_APP_DIR let an install outside Docker, like the Proxmox LXC script, switch nginx to HTTPS when a certificate is added
|
||||
- Removing a user or role from a shared folder can also remove their access from every host in the folder and its subfolders
|
||||
- A personal login set once on a folder is used for every host shared with you in it and its subfolders, unless a host has its own
|
||||
|
||||
### Changed
|
||||
|
||||
- body-parser is updated to 2.4.0 for a security fix
|
||||
|
||||
### Fixed
|
||||
|
||||
- The log line for a rejected proxy login now shows the source address, so you can add it to TRUSTED_PROXY_AUTH_TRUSTED_PROXIES
|
||||
- Deleting your own account now removes all of its data, like an admin delete does, instead of leaving some behind
|
||||
- An SSH key with a passphrase is unlocked once and kept unlocked in memory for a short time, so connecting to many hosts with it no longer freezes the server for minutes
|
||||
- A change pushed from one linked desktop or phone now reaches your other linked devices right away, instead of on their next check a few minutes later
|
||||
- The macOS menu bar icon shows the Termix logo instead of an empty square
|
||||
- Cmd+1 (Alt+1 on Windows and Linux) and the other number shortcuts pick the open tabs as numbered in the tab bar, not the home tab
|
||||
- A plugin the app no longer ships and could not download (for example on a locked down network) shows as failed with the reason on the Plugins tab, instead of as installed
|
||||
- The desktop app no longer shows This layout could not be shown when the layout is asked for before its plugin has finished starting; it tries again
|
||||
- On the desktop, a plugin installed from the registry that uses core's packages through require (like ACME SSL and axios) starts instead of failing with Cannot find module
|
||||
- Adding a certificate on an install where Termix cannot manage nginx says what to set instead of only asking for a restart
|
||||
- A login someone shared with you works on a host shared with you, for personal SSH authentication and when the owner shares SSH authentication
|
||||
- Lists in plugins (like the AI Assets table) use their full width on narrow screens, keep cells under the right header and no longer overlap rows
|
||||
- Installing an official plugin from a file works offline: pick the .tmxplug and its .sig from the release together and it installs as official, so plugins like Fleets no longer stop with migration refused. It can also fill in a plugin that was on but could not be downloaded
|
||||
|
||||
## 26.10.5
|
||||
|
||||
|
||||
+34
-34
@@ -17,8 +17,8 @@
|
||||
{
|
||||
"id": "alerts",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.2.0/alerts-1.2.0.tmxplug",
|
||||
"sha256": "488947482eed0306893c2983f701a0e14c55975ca2a0c4bb0799a40fce9db054",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.1.0/alerts-1.1.0.tmxplug",
|
||||
"sha256": "a7ce9e20ce3c15f77c15479b4901563adf3c793e24a3aacd7226950491f0434c",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
@@ -42,8 +42,8 @@
|
||||
{
|
||||
"id": "classic-layout",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.1.0/classic-layout-1.1.0.tmxplug",
|
||||
"sha256": "06a94fdbc2eda2f7cdeb4a6bbdc6adb289d524abfe836d3a32ac9b38ad5f65b9",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.2/classic-layout-1.0.2.tmxplug",
|
||||
"sha256": "cf54f9b2e81c33b7e58cd3a21e203805f08ba37b3bc78412a08eb21b9fef8f14",
|
||||
"tier": "minimal",
|
||||
"defaultLook": true
|
||||
},
|
||||
@@ -72,15 +72,15 @@
|
||||
{
|
||||
"id": "databases",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Databases/releases/download/v1.1.1/databases-1.1.1.tmxplug",
|
||||
"sha256": "316e26c682f5b07b0809e87f8ea94e2b819a9c46fc0e22611b24069c804312d0",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Databases/releases/download/v1.1.0/databases-1.1.0.tmxplug",
|
||||
"sha256": "4d9504cde705688a6f1f7706139d4e4c49b2276eccc67538fdee8251f29920b8",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "docker",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.2.2/docker-1.2.2.tmxplug",
|
||||
"sha256": "2e6ff0a68a70d83267c250547e6a4e4f6282d557b608500309136aca76881abf",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.2.1/docker-1.2.1.tmxplug",
|
||||
"sha256": "b2ca797465d88cfb549a04c3f0161708669def22a5ca73cb107879847c2db8ff",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
@@ -90,8 +90,8 @@
|
||||
{
|
||||
"id": "file-manager",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.2/file-manager-1.1.2.tmxplug",
|
||||
"sha256": "6769e58f23722c06b6e4c187c7ef5ba03f28bbdc19e859104b7a5ead8690c154",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.1/file-manager-1.1.1.tmxplug",
|
||||
"sha256": "ebba0b7d14facb3354f22a00e059e9f5a69e233458cb24985bf4ed5e5f8d2abd",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
@@ -101,8 +101,8 @@
|
||||
{
|
||||
"id": "fleets",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.1.0/fleets-1.1.0.tmxplug",
|
||||
"sha256": "5e0218c6145524ae8776b8fcbae043749c893d9b04fb67a9b3fe262ff76c0573",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.2/fleets-1.0.2.tmxplug",
|
||||
"sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
@@ -115,8 +115,8 @@
|
||||
{
|
||||
"id": "host-metrics",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.3.0/host-metrics-1.3.0.tmxplug",
|
||||
"sha256": "b38352e4adebf4079918cd7e9e9badfb0a4a9811cc53940852ca71aef860dc26",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.2.0/host-metrics-1.2.0.tmxplug",
|
||||
"sha256": "8522cd23b558c3209821cee6d177daeb80f439b8ff652c577c5e4955315cc7a3",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
@@ -140,8 +140,8 @@
|
||||
{
|
||||
"id": "import-export",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.1/import-export-1.0.1.tmxplug",
|
||||
"sha256": "d8f2f865e1e6b226f4fe38501bf69b752f47a643bed1d9d127181083e27da2f0",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.0/import-export-1.0.0.tmxplug",
|
||||
"sha256": "6670b6c147e81eed0ddfd249f9a7c9564f1ab50e45f2771161d2e30c47f6fd58",
|
||||
"tier": "standard"
|
||||
},
|
||||
{
|
||||
@@ -182,15 +182,15 @@
|
||||
{
|
||||
"id": "proxmox",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.2.0/proxmox-1.2.0.tmxplug",
|
||||
"sha256": "154c9bc1535bb1d05ccad3ab2cb6d848a28ddbfeb6dc589c5683cd7e81ccd3c3",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.1.0/proxmox-1.1.0.tmxplug",
|
||||
"sha256": "8937d239ad40033d69fba59085fe8896ee25c61951c21f66ff98531fd4c63cbf",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "remote-desktop",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.3.0/remote-desktop-1.3.0.tmxplug",
|
||||
"sha256": "69177d2a642d549fbd4769833437fdb96065251a4965990b881d1dd583b2832a",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.2.1/remote-desktop-1.2.1.tmxplug",
|
||||
"sha256": "ad244f31016f0ea5df5c303bf6f48af35c6c8e52b12098d381c8296626b73de7",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
@@ -214,8 +214,8 @@
|
||||
{
|
||||
"id": "secret-sources",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.1.0/secret-sources-1.1.0.tmxplug",
|
||||
"sha256": "3ba67666a52a38245284bb0294fd7af52a5613921454e66d3b4972727d7ed5c0",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.2/secret-sources-1.0.2.tmxplug",
|
||||
"sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
@@ -232,8 +232,8 @@
|
||||
{
|
||||
"id": "service-manager",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Service-Manager/releases/download/v1.1.1/service-manager-1.1.1.tmxplug",
|
||||
"sha256": "5731a04336d341a330416cba7b7999f0d8e6215752eabbf978ed58c4543c5036",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Service-Manager/releases/download/v1.1.0/service-manager-1.1.0.tmxplug",
|
||||
"sha256": "82b1c70abd97ed535f217535c667fe6c48e5e01e2bf8037a89b58b80d35206bf",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
@@ -260,8 +260,8 @@
|
||||
{
|
||||
"id": "snippets",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.2.0/snippets-1.2.0.tmxplug",
|
||||
"sha256": "a72c0e1f38e49cf86ed8993b6913a6fbbdad7814ae132d5186c097ee3ea09bc3",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.1.0/snippets-1.1.0.tmxplug",
|
||||
"sha256": "e2d7d1ebe5bdf2986ffb4961ac6f6d32b392b02c30a64a333a7eeb2ce6a46bb3",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
@@ -271,8 +271,8 @@
|
||||
{
|
||||
"id": "ssh-terminal",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.2.0/ssh-terminal-1.2.0.tmxplug",
|
||||
"sha256": "629b522ae6ff77a8e033b89bb2efa675ec60c147c378012548feec1a005f9533",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.1.1/ssh-terminal-1.1.1.tmxplug",
|
||||
"sha256": "5cfb381a2de10b7a6fab108d79645bb1e6e7316fb5e4150c4529a3e76a2193d8",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
@@ -301,8 +301,8 @@
|
||||
{
|
||||
"id": "sync",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.1.0/sync-1.1.0.tmxplug",
|
||||
"sha256": "9023d547f0e0bcef582c6ba7b7a8ab2ef68d8d0b973dde17d9e8c84d92b6429e",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.0.1/sync-1.0.1.tmxplug",
|
||||
"sha256": "66a301fd04fb8d2a3ece18dfb230069cf78e1138ee78941dc511dfbd8ee4f91e",
|
||||
"tier": "standard"
|
||||
},
|
||||
{
|
||||
@@ -351,8 +351,8 @@
|
||||
{
|
||||
"id": "tunnels",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.2.2/tunnels-1.2.2.tmxplug",
|
||||
"sha256": "92d89c5bf7fa43fff790cc5240ae75eaf05c3c9089c6a0dfb68bbc37c517bf04",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.2.1/tunnels-1.2.1.tmxplug",
|
||||
"sha256": "2871a8d31854e1a31416c4afe5839099a4c489e34d9d31573659f73b2515f159",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
@@ -390,8 +390,8 @@
|
||||
{
|
||||
"id": "webauthn",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.2.0/webauthn-1.2.0.tmxplug",
|
||||
"sha256": "4e7e06d05a17b6e474fdb0d45c8fc320f2ea89375bda440f824636cc393d4615",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.1.0/webauthn-1.1.0.tmxplug",
|
||||
"sha256": "83a61a530db0273f9fef2080d6aa027a82ef8374fb4e0f158a8559a2770299de",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE `ssh_data` ADD `color` text;--> statement-breakpoint
|
||||
ALTER TABLE `ssh_data` ADD `icon` text;
|
||||
@@ -1,14 +0,0 @@
|
||||
CREATE TABLE `shared_folder_auth_overrides` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`folder` varchar(255) NOT NULL,
|
||||
`protocol` varchar(255) NOT NULL DEFAULT 'ssh',
|
||||
`credential_id` int NOT NULL,
|
||||
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
CONSTRAINT `shared_folder_auth_overrides_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `shared_folder_auth_overrides_user_folder_protocol_unique` UNIQUE(`user_id`,`folder`,`protocol`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `shared_folder_auth_overrides` ADD CONSTRAINT `shared_folder_auth_overrides_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `shared_folder_auth_overrides` ADD CONSTRAINT `shared_folder_auth_overrides_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE cascade ON UPDATE no action;
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -533,20 +533,6 @@
|
||||
"when": 1792430772041,
|
||||
"tag": "0075_session_policy",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 76,
|
||||
"version": "5",
|
||||
"when": 1792945361809,
|
||||
"tag": "0076_host_color_icon",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 77,
|
||||
"version": "5",
|
||||
"when": 1793304929772,
|
||||
"tag": "0077_shared_folder_auth_overrides",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE "ssh_data" ADD COLUMN "color" text;--> statement-breakpoint
|
||||
ALTER TABLE "ssh_data" ADD COLUMN "icon" text;
|
||||
@@ -1,13 +0,0 @@
|
||||
CREATE TABLE "shared_folder_auth_overrides" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"folder" varchar(255) NOT NULL,
|
||||
"protocol" varchar(255) DEFAULT 'ssh' NOT NULL,
|
||||
"credential_id" integer NOT NULL,
|
||||
"created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "shared_folder_auth_overrides" ADD CONSTRAINT "shared_folder_auth_overrides_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "shared_folder_auth_overrides" ADD CONSTRAINT "shared_folder_auth_overrides_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "shared_folder_auth_overrides_user_folder_protocol_unique" ON "shared_folder_auth_overrides" USING btree ("user_id","folder","protocol");
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -533,20 +533,6 @@
|
||||
"when": 1792430770990,
|
||||
"tag": "0075_session_policy",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 76,
|
||||
"version": "7",
|
||||
"when": 1792945360507,
|
||||
"tag": "0076_host_color_icon",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 77,
|
||||
"version": "7",
|
||||
"when": 1793304928399,
|
||||
"tag": "0077_shared_folder_auth_overrides",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE `ssh_data` ADD `color` text;--> statement-breakpoint
|
||||
ALTER TABLE `ssh_data` ADD `icon` text;
|
||||
@@ -1,13 +0,0 @@
|
||||
CREATE TABLE `shared_folder_auth_overrides` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`folder` text NOT NULL,
|
||||
`protocol` text DEFAULT 'ssh' NOT NULL,
|
||||
`credential_id` integer NOT NULL,
|
||||
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
`updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `shared_folder_auth_overrides_user_folder_protocol_unique` ON `shared_folder_auth_overrides` (`user_id`,`folder`,`protocol`);
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -498,20 +498,6 @@
|
||||
"when": 1792430769899,
|
||||
"tag": "0070_session_policy",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 71,
|
||||
"version": "6",
|
||||
"when": 1792945434651,
|
||||
"tag": "0071_host_color_icon",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 72,
|
||||
"version": "6",
|
||||
"when": 1793304926942,
|
||||
"tag": "0072_shared_folder_auth_overrides",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+1
-2
@@ -1246,9 +1246,8 @@ function createTray() {
|
||||
let trayIcon;
|
||||
if (process.platform === "darwin") {
|
||||
const iconPath = path.join(publicRoot, "icons", "16x16.png");
|
||||
// The logo is full color on a solid tile. As a template image macOS
|
||||
// keeps only its outline, which showed as an empty square.
|
||||
trayIcon = nativeImage.createFromPath(iconPath);
|
||||
trayIcon.setTemplateImage(true);
|
||||
} else if (process.platform === "win32") {
|
||||
trayIcon = path.join(publicRoot, "icon.ico");
|
||||
} else {
|
||||
|
||||
@@ -83,30 +83,6 @@
|
||||
"default": "8443",
|
||||
"description": "Port for HTTPS."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_NGINX_TEMPLATE",
|
||||
"group": "https",
|
||||
"default": "/app/nginx/nginx-https.conf.template",
|
||||
"description": "The HTTPS nginx template Termix renders when a certificate is added. Set it on an install outside Docker."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_NGINX_CONF",
|
||||
"group": "https",
|
||||
"default": "/tmp/nginx/nginx.conf",
|
||||
"description": "Where Termix writes the rendered nginx config before it reloads nginx."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_NGINX_PID",
|
||||
"group": "https",
|
||||
"default": "/tmp/nginx/nginx.pid",
|
||||
"description": "The nginx pid file. Termix only reloads nginx when it exists."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_APP_DIR",
|
||||
"group": "https",
|
||||
"default": "/app",
|
||||
"description": "Where Termix is installed, used in place of /app in the nginx template."
|
||||
},
|
||||
{
|
||||
"name": "SSL_CERT_PATH",
|
||||
"group": "https",
|
||||
|
||||
Generated
+8
-8
@@ -19,7 +19,7 @@
|
||||
"axios": "^1.19.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"body-parser": "^2.4.0",
|
||||
"body-parser": "^2.3.0",
|
||||
"chalk": "^6.0.0",
|
||||
"compression": "^1.8.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
@@ -7470,18 +7470,18 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
|
||||
"integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^2.1.0",
|
||||
"content-type": "^2.0.0",
|
||||
"debug": "^4.4.3",
|
||||
"http-errors": "^2.0.1",
|
||||
"iconv-lite": "^0.7.3",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"on-finished": "^2.4.1",
|
||||
"qs": "^6.16.1",
|
||||
"qs": "^6.15.2",
|
||||
"raw-body": "^3.0.2",
|
||||
"type-is": "^2.1.0"
|
||||
},
|
||||
@@ -15058,7 +15058,7 @@
|
||||
},
|
||||
"packages/plugin-sdk": {
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.0.22",
|
||||
"version": "1.0.13",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@
|
||||
"axios": "^1.19.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"body-parser": "^2.4.0",
|
||||
"body-parser": "^2.3.0",
|
||||
"chalk": "^6.0.0",
|
||||
"compression": "^1.8.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
|
||||
@@ -1,26 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.22
|
||||
|
||||
Plugin API 1.13.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `contributes.mobile` in the manifest says how a plugin shows in the Termix mobile app: `webview`, `native` or `none`, with an optional mobile bundle, screen sizes, views and a note
|
||||
- `termix-plugin build` bundles `src/mobile` into the `contributes.mobile.entry` file, and `validate` checks that it exists
|
||||
- `@termix-ssh/plugin-sdk/frame-host` runs one plugin view outside the shell, with core's bridge, registries and CSS, so the mobile app can show `webview` plugins
|
||||
- SSH transport providers get the host's owner, sync id, connection origin and local only flag in `claims` and `open`, and core's SSH relay takes a `syncId` instead of a host id
|
||||
- `ctx.sync.host.entities.idsOf` maps sync ids to the local ids of the user's own rows of one type
|
||||
- Rail items take `useStatus` for a small ok, warning or error dot on the icon
|
||||
- `shell.editHost(hostId)` opens the editor of a saved host
|
||||
- A sync entity can be read only with `load`, `write` and `erase`, so a plugin can sync rows a user does not own, like a shared library, down to linked desktops
|
||||
- `getFolderAuthOverride` and `setFolderAuthOverride` in `@termix-ssh/plugin-sdk/ui` read and set a user's own login for the shared hosts in a folder
|
||||
|
||||
### Fixed
|
||||
|
||||
- `@termix-ssh/plugin-sdk/ui` is built in production mode, so outside Termix it no longer sends requests to the Vite dev server path
|
||||
- `execCommand` buffers output and decodes it once as strict UTF-8 with a GBK fallback, exposed as `decodeCommandOutput`, so messages from a Windows host on a legacy codepage are readable instead of � characters
|
||||
|
||||
## 1.0.21
|
||||
|
||||
Plugin API 1.12.0.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Termix plugin guide
|
||||
|
||||
SDK 1.0.22 | Plugin API 1.13.0
|
||||
SDK 1.0.21 | Plugin API 1.12.0
|
||||
|
||||
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.
|
||||
@@ -175,49 +175,7 @@ Unknown fields are refused at every level, so a typo fails validation. The schem
|
||||
|
||||
`contributes` holds `tabs`, `panels`, `dashboardCards`, `actions`, `actionSlots`, `permissions`,
|
||||
`settings`, `protocols`, `auth`, `syncEntities`, `keybindingActions`, `http`, `guest`, `guestViews`,
|
||||
`uiPresets`, `uses`, `layouts`, `themes` and `mobile`. Frontend `register*` calls need their ids declared here first.
|
||||
|
||||
### Mobile app
|
||||
|
||||
`contributes.mobile` says how the plugin shows in the Termix mobile app. Leave it out and the app treats the
|
||||
plugin as having nothing to show (official plugins are mapped by the app itself).
|
||||
|
||||
```json
|
||||
"mobile": { "mode": "webview", "views": ["hello"], "sizes": ["phone", "tablet"] }
|
||||
```
|
||||
|
||||
| Field | Notes |
|
||||
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `mode` | `webview` (the app shows your UI from the server), `native` (the app has its own screens) or `none`. |
|
||||
| `entry` | `webview` only. A bundle made for the app, like `dist/mobile.js`, next to `frontend.js`. Unset: your normal frontend runs in a phone sized frame. |
|
||||
| `sizes` | `webview` only. `phone`, `tablet` or both (the default). |
|
||||
| `views` | `webview` only. Ids from your `tabs`, `panels` or `dashboardCards` the app offers. Default: every tab. |
|
||||
| `feature` | `native` only. The app feature that stands in for the plugin. Default: the plugin id. |
|
||||
| `note` | A locale key or plain text the app shows next to the plugin. |
|
||||
|
||||
`build` makes `entry` from `src/mobile/index.tsx`, and it shares `frontend.css`.
|
||||
|
||||
How a `webview` plugin runs in the app:
|
||||
|
||||
- Only your plugin loads, with the same app object, SDK hooks and `/ui` kit as the web app. Each view opens
|
||||
on its own screen: a tab with `host-context-menu` in `openFrom` opens from a host, the rest from the app's
|
||||
plugin list.
|
||||
- Every request and socket goes through the app, which adds the login, proxy headers and client
|
||||
certificate. The app only lets through your own `/plugin-api`, `/plugin-ws` and `/plugin-assets`, your
|
||||
settings and a short list of core reads (hosts, folders, permissions, current user, version, health,
|
||||
activity). Other core routes answer 403, so call your own backend instead.
|
||||
- Images load straight from the server, without proxy headers or a client certificate.
|
||||
- Nothing in `localStorage` survives closing the view. Keep preferences in settings.
|
||||
- Other plugins' views, extensions and components are not there, and neither is the shell (`useShell()`
|
||||
is null). `/ui` names newer than the app's copy of the SDK make the view say it needs a newer app.
|
||||
- The app checks your `engine.api` against the plugin API of its own copy of the SDK. When the app's API
|
||||
is too old for the range, it asks the user to update the app instead of opening the view. When the range
|
||||
is for an older API major, it asks to update the plugin. Ask only for the API you use.
|
||||
- Turning a plugin off on the server hides it in the app the next time the app checks: when it comes back
|
||||
to the front, when the list is pulled down, or when any request answers 503 with your `pluginId`.
|
||||
|
||||
Termix 26.10.5 and older refuse any manifest with `mobile`, so a plugin that declares it needs
|
||||
`"engine": { "termix": ">=26.10.6", "api": "^1.13" }`.
|
||||
`uiPresets`, `uses`, `layouts` and `themes`. Frontend `register*` calls need their ids declared here first.
|
||||
|
||||
Capabilities and permissions are different things. A capability (`hosts:read`, with a colon) is what your
|
||||
code may reach. A permission (`hello.use`, with a dot) is what a user may do.
|
||||
@@ -346,7 +304,7 @@ router.post("/notes", async (req, res) => {
|
||||
- `ctx.rbac.require("manage")` on a single route, `await ctx.rbac.has("manage")` in code. A full id like
|
||||
`hosts.edit` checks a core permission.
|
||||
- Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`.
|
||||
- Transports: with `ssh:transport`, `ctx.ssh.transports.provide({ claims(hostId, host), open(host) })`, where `host` has `id`, `ip`, `port` and on Termix 26.10.6+ also `userId`, `syncId`, `connectionOrigin` and `localOnly`
|
||||
- Transports: with `ssh:transport`, `ctx.ssh.transports.provide({ claims(hostId), open({ id, ip, port }) })`
|
||||
returns a stream the SSH client uses instead of a direct, proxy or jump host connection. Core also serves
|
||||
`/plugin-ws/core/ssh-relay?hostId=` for callers with connect access, so one Termix can relay to another.
|
||||
- Managed hosts: with `hosts:lock`, `ctx.hosts.locks.provide({ isLocked, note, allowManualCreate })` makes
|
||||
@@ -495,23 +453,6 @@ Other storage: `ctx.kv` for a few values, `ctx.settings` for settings, `ctx.secr
|
||||
`ctx.files.dataDir()` for files. To sync a table to a linked desktop, use `ctx.sync.registerEntity` and
|
||||
`contributes.syncEntities`. The entity name is part of the protocol and never changes.
|
||||
|
||||
A registered table syncs the rows each user owns, both ways. For data you keep for the whole server (a shared
|
||||
library, rows other users share), register a read only entity with `table: null`, `readOnly: true` and `load`,
|
||||
`write` and `erase` (Termix 26.10.6+). `load(userId)` returns the rows that user should get, each with a
|
||||
`syncId`. The desktop keeps them with `write` and drops them with `erase`, and never pushes them back:
|
||||
|
||||
```ts
|
||||
ctx.sync.registerEntity({
|
||||
type: "sharedIcons",
|
||||
table: null,
|
||||
readOnly: true,
|
||||
order: 70,
|
||||
load: (userId) => iconsVisibleTo(userId),
|
||||
write: (userId, row) => storeCopy(userId, row),
|
||||
erase: (userId, syncId) => dropCopy(userId, syncId),
|
||||
});
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
```tsx
|
||||
|
||||
@@ -25,43 +25,6 @@ const FRONTEND_ENTRIES = [
|
||||
"src/frontend/index.mjs",
|
||||
"src/frontend/index.js",
|
||||
];
|
||||
const MOBILE_ENTRIES = [
|
||||
"src/mobile/index.tsx",
|
||||
"src/mobile/index.ts",
|
||||
"src/mobile/index.mjs",
|
||||
"src/mobile/index.js",
|
||||
];
|
||||
|
||||
/** Builds a bundle that runs in a browser against core's shared modules. */
|
||||
async function buildBrowserBundle(entry, outfile, outDir) {
|
||||
await esbuild.build({
|
||||
entryPoints: [entry],
|
||||
outfile,
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "browser",
|
||||
target: "es2022",
|
||||
jsx: "automatic",
|
||||
sourcemap: true,
|
||||
logLevel: "warning",
|
||||
external: FRONTEND_EXTERNALS,
|
||||
// Vite replaces these in the dev server; a plugin bundle has to have
|
||||
// them too, or code that reads them throws when the plugin loads.
|
||||
define: {
|
||||
"process.env.NODE_ENV": '"production"',
|
||||
"import.meta.env.DEV": "false",
|
||||
"import.meta.env.PROD": "true",
|
||||
"import.meta.env.MODE": '"production"',
|
||||
"import.meta.env.SSR": "false",
|
||||
"import.meta.env":
|
||||
'{"DEV":false,"PROD":true,"MODE":"production","SSR":false,"BASE_URL":"/"}',
|
||||
},
|
||||
plugins: [
|
||||
externalRequireInterop(FRONTEND_EXTERNALS),
|
||||
staticUrlImports({ outDir }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A bundled CommonJS package that calls require("react/jsx-runtime") (or any
|
||||
@@ -229,12 +192,15 @@ export async function buildTailwind(cwd, outDir) {
|
||||
// build() adds to what a compiler already emitted, so each half gets its own.
|
||||
const compiler = () =>
|
||||
compile(input, { base: path.dirname(THEME_CSS), onDependency: () => {} });
|
||||
// The mobile bundle uses the same stylesheet.
|
||||
const sources = ["frontend", "mobile"]
|
||||
.map((dir) => path.join(cwd, "src", dir))
|
||||
.filter((dir, index) => index === 0 || fs.existsSync(dir))
|
||||
.map((base) => ({ base, pattern: "**/*", negated: false }));
|
||||
const scanner = new Scanner({ sources });
|
||||
const scanner = new Scanner({
|
||||
sources: [
|
||||
{
|
||||
base: path.join(cwd, "src", "frontend"),
|
||||
pattern: "**/*",
|
||||
negated: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
// Flattened and minified the way core's own CSS is, so it does not rely
|
||||
// on native CSS nesting.
|
||||
const emit = async (candidates, options) =>
|
||||
@@ -331,18 +297,33 @@ export async function build({ cwd }) {
|
||||
|
||||
const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES);
|
||||
if (frontendEntry) {
|
||||
await buildBrowserBundle(
|
||||
frontendEntry,
|
||||
path.join(outDir, "frontend.js"),
|
||||
outDir,
|
||||
);
|
||||
}
|
||||
|
||||
// A bundle the mobile app loads in its WebView, next to the frontend one.
|
||||
const mobileEntry = resolveEntry(cwd, MOBILE_ENTRIES);
|
||||
const mobileOut = manifest.contributes?.mobile?.entry;
|
||||
if (mobileEntry && typeof mobileOut === "string") {
|
||||
await buildBrowserBundle(mobileEntry, path.join(cwd, mobileOut), outDir);
|
||||
await esbuild.build({
|
||||
entryPoints: [frontendEntry],
|
||||
outfile: path.join(outDir, "frontend.js"),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "browser",
|
||||
target: "es2022",
|
||||
jsx: "automatic",
|
||||
sourcemap: true,
|
||||
logLevel: "warning",
|
||||
external: FRONTEND_EXTERNALS,
|
||||
// Vite replaces these in the dev server; a plugin bundle has to have
|
||||
// them too, or code that reads them throws when the plugin loads.
|
||||
define: {
|
||||
"process.env.NODE_ENV": '"production"',
|
||||
"import.meta.env.DEV": "false",
|
||||
"import.meta.env.PROD": "true",
|
||||
"import.meta.env.MODE": '"production"',
|
||||
"import.meta.env.SSR": "false",
|
||||
"import.meta.env":
|
||||
'{"DEV":false,"PROD":true,"MODE":"production","SSR":false,"BASE_URL":"/"}',
|
||||
},
|
||||
plugins: [
|
||||
externalRequireInterop(FRONTEND_EXTERNALS),
|
||||
staticUrlImports({ outDir }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (frontendEntry) await buildTailwind(cwd, outDir);
|
||||
|
||||
@@ -28,10 +28,6 @@ export async function validate({ cwd }) {
|
||||
// A theme only plugin has no frontend code.
|
||||
...(isThemeOnly(raw) ? [] : [raw.frontend ?? "dist/frontend.js"]),
|
||||
raw.locales ?? "locales",
|
||||
// Built from src/mobile by termix-plugin build.
|
||||
...(typeof raw.contributes?.mobile?.entry === "string"
|
||||
? [raw.contributes.mobile.entry]
|
||||
: []),
|
||||
];
|
||||
for (const rel of referenced) {
|
||||
if (!fs.existsSync(path.join(cwd, rel))) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.0.22",
|
||||
"version": "1.0.21",
|
||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -102,10 +102,6 @@
|
||||
"./testing-host": {
|
||||
"default": "./dist/host/testing-host.js"
|
||||
},
|
||||
"./frame-host": {
|
||||
"default": "./dist/host/frame-host.js"
|
||||
},
|
||||
"./frame-host.css": "./dist/host/frame-host.css",
|
||||
"./theme.css": "./dist/host/theme.css"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -1239,48 +1239,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"mobile": {
|
||||
"type": "object",
|
||||
"description": "How the plugin shows in the Termix mobile app. Needs plugin API 1.13 (Termix 26.10.6).",
|
||||
"additionalProperties": false,
|
||||
"required": ["mode"],
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["webview", "native", "none"],
|
||||
"description": "webview: the app shows the plugin's UI from the server. native: the app has its own screens. none: nothing to show in the app."
|
||||
},
|
||||
"entry": {
|
||||
"type": "string",
|
||||
"pattern": "\\.js$",
|
||||
"description": "webview only. A bundle built for the app, in the same folder as the frontend bundle. Unset means the normal frontend runs in a phone sized frame."
|
||||
},
|
||||
"sizes": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": { "type": "string", "enum": ["phone", "tablet"] },
|
||||
"description": "webview only. Screen sizes the UI works at. Defaults to both."
|
||||
},
|
||||
"views": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": { "type": "string", "minLength": 1 },
|
||||
"description": "webview only. Tab, panel or card ids the app offers. Defaults to every tab."
|
||||
},
|
||||
"feature": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]{1,39}$",
|
||||
"description": "native only. The app feature that stands in for this plugin. Defaults to the plugin id."
|
||||
},
|
||||
"note": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "i18n key in the plugin's namespace, or plain text, shown in the app."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -177,24 +177,8 @@ export interface SyncWriteEvent {
|
||||
export interface SyncEntityRegistration {
|
||||
/** Stable wire name. Never change it: records on both sides match on it. */
|
||||
type: string;
|
||||
/** The table, as returned by ctx.db.define. Null for an entity with `load`. */
|
||||
/** The table, as returned by ctx.db.define. */
|
||||
table: unknown;
|
||||
/**
|
||||
* Server to desktop only: a desktop never pushes these rows. Required with
|
||||
* `load`.
|
||||
*/
|
||||
readOnly?: boolean;
|
||||
/**
|
||||
* Builds the rows the user gets instead of reading the rows they own in
|
||||
* `table`, for data kept for the whole server (a shared library, rows other
|
||||
* users share with them). Rows are on the wire: plaintext, each with a
|
||||
* syncId. Needs `readOnly: true`, `write` and `erase`.
|
||||
*/
|
||||
load?: (userId: string) => Promise<SyncRow[]>;
|
||||
/** Keeps a copy of an inbound row on the desktop, for an entity with `load`. */
|
||||
write?: (userId: string, wire: SyncRow) => Promise<void>;
|
||||
/** Drops the desktop copy with this syncId, for an entity with `load`. */
|
||||
erase?: (userId: string, syncId: string) => Promise<void>;
|
||||
/** Column holding the owning user id. Defaults to "userId". */
|
||||
userColumn?: string;
|
||||
/** Fields stored encrypted with the owner's data key. Plaintext on the wire. */
|
||||
@@ -486,12 +470,6 @@ export interface PluginSyncHost {
|
||||
ensureCore: () => Promise<void>;
|
||||
onChanged: (listener: () => void) => () => void;
|
||||
onLocalWrite: (listener: () => void) => () => void;
|
||||
/** Local ids of the user's own rows of a type, by sync id. Termix 26.10.6+. */
|
||||
idsOf?: (
|
||||
type: string,
|
||||
userId: string,
|
||||
syncIds: string[],
|
||||
) => Promise<Record<string, number>>;
|
||||
};
|
||||
|
||||
/** Rows and bookkeeping of one local user's replica. */
|
||||
@@ -1835,21 +1813,13 @@ export interface PluginNetwork {
|
||||
* asked for every connection, so it must be quick. `open` returns a stream
|
||||
* the SSH client runs over, like a relayed socket.
|
||||
*/
|
||||
/** The host a transport is asked about. Fields past port come from Termix 26.10.6. */
|
||||
export interface PluginSshTransportHost {
|
||||
id: number;
|
||||
ip: string;
|
||||
port: number;
|
||||
/** Owner of the host row. */
|
||||
userId?: string | null;
|
||||
syncId?: string | null;
|
||||
connectionOrigin?: "local" | "remote" | null;
|
||||
localOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface PluginSshTransportProvider {
|
||||
claims: (hostId: number, host?: PluginSshTransportHost) => boolean;
|
||||
open: (host: PluginSshTransportHost) => Promise<import("node:stream").Duplex>;
|
||||
claims: (hostId: number) => boolean;
|
||||
open: (host: {
|
||||
id: number;
|
||||
ip: string;
|
||||
port: number;
|
||||
}) => Promise<import("node:stream").Duplex>;
|
||||
}
|
||||
|
||||
export interface PluginSshTransports {
|
||||
|
||||
@@ -206,8 +206,6 @@ export interface ShellApi {
|
||||
* user reviews and saves it. Absent in some shells.
|
||||
*/
|
||||
openHostEditor?: (draft?: HostDraft) => void;
|
||||
/** Opens the editor of a saved host. Termix 26.10.6+, absent in some shells. */
|
||||
editHost?: (hostId: number) => void;
|
||||
/** Opens a rail view in the left sidebar. */
|
||||
openRailView: (id: string) => void;
|
||||
/** Closes a rail view wherever it is shown. */
|
||||
@@ -427,11 +425,6 @@ export interface RailItemContribution {
|
||||
* Zero or nothing shows no badge.
|
||||
*/
|
||||
useBadge?: () => number | null | undefined;
|
||||
/**
|
||||
* A hook for a small status dot on the icon: "ok" (green), "warning" or
|
||||
* "error". Nothing shows no dot. Layouts from Termix 26.10.6 draw it.
|
||||
*/
|
||||
useStatus?: () => "ok" | "warning" | "error" | null | undefined;
|
||||
}
|
||||
|
||||
export interface PanelProps {
|
||||
|
||||
@@ -21,30 +21,6 @@ export interface ExecResult {
|
||||
code: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes raw command output. Strict UTF-8 first; when that fails, tries GBK,
|
||||
* which is what a Chinese-locale Windows console sends (its bytes are almost
|
||||
* never valid UTF-8) and keeps it only when it decodes without replacement
|
||||
* characters. Anything else falls back to lossy UTF-8, the old behavior.
|
||||
*/
|
||||
export function decodeCommandOutput(buf: Buffer): string {
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
|
||||
buf,
|
||||
);
|
||||
} catch {
|
||||
// not valid UTF-8, try the legacy codepage
|
||||
}
|
||||
try {
|
||||
// A Node built with small-ICU has no GBK decoder and throws here.
|
||||
const gbk = new TextDecoder("gbk").decode(buf);
|
||||
if (!gbk.includes("�")) return gbk;
|
||||
} catch {
|
||||
// expected - fall through to lossy UTF-8
|
||||
}
|
||||
return buf.toString("utf8");
|
||||
}
|
||||
|
||||
/** Runs one non-interactive command over an existing ssh2 client. */
|
||||
export function execCommand(
|
||||
client: Client,
|
||||
@@ -89,10 +65,8 @@ export function execCommand(
|
||||
}
|
||||
|
||||
stream = _stream;
|
||||
// Chunks are buffered and decoded once on close: a multi-byte character
|
||||
// can be split across chunks, and charset detection needs it whole.
|
||||
const stdout: Buffer[] = [];
|
||||
const stderr: Buffer[] = [];
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let exitCode: number | null = null;
|
||||
|
||||
stream
|
||||
@@ -101,15 +75,11 @@ export function execCommand(
|
||||
settled = true;
|
||||
exitCode = typeof code === "number" ? code : null;
|
||||
cleanup();
|
||||
resolve({
|
||||
stdout: decodeCommandOutput(Buffer.concat(stdout)),
|
||||
stderr: decodeCommandOutput(Buffer.concat(stderr)),
|
||||
code: exitCode,
|
||||
});
|
||||
resolve({ stdout, stderr, code: exitCode });
|
||||
}
|
||||
})
|
||||
.on("data", (data: Buffer) => {
|
||||
stdout.push(data);
|
||||
stdout += data.toString("utf8");
|
||||
})
|
||||
.on("error", (streamErr: Error) => {
|
||||
if (!settled) {
|
||||
@@ -122,7 +92,7 @@ export function execCommand(
|
||||
if (stream.stderr) {
|
||||
stream.stderr
|
||||
.on("data", (data: Buffer) => {
|
||||
stderr.push(data);
|
||||
stderr += data.toString("utf8");
|
||||
})
|
||||
.on("error", (stderrErr: Error) => {
|
||||
if (!settled) {
|
||||
|
||||
@@ -37,7 +37,7 @@ export {
|
||||
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
||||
* version by the server.
|
||||
*/
|
||||
export const PLUGIN_API_VERSION = "1.13.0";
|
||||
export const PLUGIN_API_VERSION = "1.12.0";
|
||||
|
||||
/** The API major, the default engine.api for a new plugin. */
|
||||
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
||||
@@ -526,36 +526,6 @@ export interface PluginContributions {
|
||||
layouts?: PluginLayoutContribution[];
|
||||
/** Themes. Needs the ui:theme capability. */
|
||||
themes?: PluginThemeContribution[];
|
||||
/** How the plugin shows in the Termix mobile app. Plugin API 1.13. */
|
||||
mobile?: PluginMobileContribution;
|
||||
}
|
||||
|
||||
/**
|
||||
* - "webview": the app shows the plugin's UI from the server in a WebView.
|
||||
* - "native": the app has its own screens for this plugin.
|
||||
* - "none": nothing to show in the app.
|
||||
*/
|
||||
export const MOBILE_MODES = ["webview", "native", "none"] as const;
|
||||
export type PluginMobileMode = (typeof MOBILE_MODES)[number];
|
||||
|
||||
export const MOBILE_SIZES = ["phone", "tablet"] as const;
|
||||
export type PluginMobileSize = (typeof MOBILE_SIZES)[number];
|
||||
|
||||
export interface PluginMobileContribution {
|
||||
mode: PluginMobileMode;
|
||||
/**
|
||||
* webview: a bundle built for the app, in the same folder as the frontend
|
||||
* bundle. Unset means the normal frontend runs in a phone sized frame.
|
||||
*/
|
||||
entry?: string;
|
||||
/** webview: screen sizes the UI works at. Defaults to both. */
|
||||
sizes?: PluginMobileSize[];
|
||||
/** webview: tab, panel or card ids the app offers. Defaults to every tab. */
|
||||
views?: string[];
|
||||
/** native: the app feature that stands in for it. Defaults to the plugin id. */
|
||||
feature?: string;
|
||||
/** i18n key in the plugin's namespace, or plain text, shown in the app. */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** A field a protocol login carries besides username and password. */
|
||||
@@ -795,7 +765,6 @@ const ALLOWED_CONTRIBUTES = new Set([
|
||||
"protocols",
|
||||
"layouts",
|
||||
"themes",
|
||||
"mobile",
|
||||
]);
|
||||
|
||||
const ALLOWED_SETTINGS_FIELD = [
|
||||
@@ -977,135 +946,10 @@ export function validateManifest(manifest: unknown): string[] {
|
||||
typeof m.id === "string" ? m.id : undefined,
|
||||
errors,
|
||||
);
|
||||
if (isPlainObject(m.contributes)) {
|
||||
validateMobile(
|
||||
m.contributes,
|
||||
typeof m.frontend === "string" ? m.frontend : DEFAULT_FRONTEND_ENTRY,
|
||||
errors,
|
||||
);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function folderOf(file: string): string {
|
||||
const parts = file.split("/").filter((part) => part !== "" && part !== ".");
|
||||
return parts.slice(0, -1).join("/");
|
||||
}
|
||||
|
||||
function validateMobile(
|
||||
contributes: Record<string, unknown>,
|
||||
frontend: string,
|
||||
errors: string[],
|
||||
): void {
|
||||
const mobile = contributes.mobile;
|
||||
if (mobile === undefined) return;
|
||||
const where = "contributes.mobile";
|
||||
if (!isPlainObject(mobile)) {
|
||||
errors.push(`Field "${where}" must be an object`);
|
||||
return;
|
||||
}
|
||||
rejectUnknown(
|
||||
mobile,
|
||||
["mode", "entry", "sizes", "views", "feature", "note"],
|
||||
`"${where}"`,
|
||||
errors,
|
||||
);
|
||||
const mode = mobile.mode;
|
||||
if (!(MOBILE_MODES as readonly unknown[]).includes(mode)) {
|
||||
errors.push(`${where}.mode must be one of: ${MOBILE_MODES.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
for (const key of ["entry", "sizes", "views"]) {
|
||||
if (mobile[key] !== undefined && mode !== "webview") {
|
||||
errors.push(`${where}.${key} is only for mode "webview"`);
|
||||
}
|
||||
}
|
||||
if (mobile.feature !== undefined && mode !== "native") {
|
||||
errors.push(`${where}.feature is only for mode "native"`);
|
||||
}
|
||||
|
||||
if (
|
||||
mobile.entry !== undefined &&
|
||||
requireString(mobile.entry, `${where}.entry`, errors)
|
||||
) {
|
||||
const before = errors.length;
|
||||
requireRelativePath(mobile.entry, `${where}.entry`, errors);
|
||||
if (!mobile.entry.endsWith(".js")) {
|
||||
errors.push(`${where}.entry must be a .js file`);
|
||||
} else if (
|
||||
errors.length === before &&
|
||||
folderOf(mobile.entry) !== folderOf(frontend)
|
||||
) {
|
||||
// Core only serves the folder the frontend bundle sits in.
|
||||
errors.push(
|
||||
`${where}.entry must sit in the same folder as the frontend bundle (${frontend})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (mobile.sizes !== undefined) {
|
||||
const sizes = mobile.sizes;
|
||||
if (!Array.isArray(sizes) || sizes.length === 0) {
|
||||
errors.push(`${where}.sizes must be a non-empty array`);
|
||||
} else {
|
||||
const seen = new Set<unknown>();
|
||||
for (const size of sizes) {
|
||||
if (!(MOBILE_SIZES as readonly unknown[]).includes(size)) {
|
||||
errors.push(
|
||||
`${where}.sizes entries must be one of: ${MOBILE_SIZES.join(", ")}`,
|
||||
);
|
||||
} else if (seen.has(size)) {
|
||||
errors.push(`${where}.sizes duplicates "${String(size)}"`);
|
||||
}
|
||||
seen.add(size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mobile.views !== undefined) {
|
||||
const views = mobile.views;
|
||||
if (!Array.isArray(views) || views.length === 0) {
|
||||
errors.push(`${where}.views must be a non-empty array`);
|
||||
} else {
|
||||
const declared = new Set<string>();
|
||||
for (const field of ["tabs", "panels", "dashboardCards"]) {
|
||||
const list = contributes[field];
|
||||
if (!Array.isArray(list)) continue;
|
||||
for (const view of list) {
|
||||
if (isPlainObject(view) && typeof view.id === "string") {
|
||||
declared.add(view.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
const seen = new Set<unknown>();
|
||||
for (const view of views) {
|
||||
if (typeof view !== "string" || !declared.has(view)) {
|
||||
errors.push(
|
||||
`${where}.views entries must be ids from contributes.tabs, panels or dashboardCards, got: ${JSON.stringify(view)}`,
|
||||
);
|
||||
} else if (seen.has(view)) {
|
||||
errors.push(`${where}.views duplicates "${view}"`);
|
||||
}
|
||||
seen.add(view);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
mobile.feature !== undefined &&
|
||||
(typeof mobile.feature !== "string" ||
|
||||
!LOOK_ID_PATTERN.test(mobile.feature))
|
||||
) {
|
||||
errors.push(
|
||||
`${where}.feature must match ${LOOK_ID_PATTERN} (lowercase letters, digits, dashes)`,
|
||||
);
|
||||
}
|
||||
if (mobile.note !== undefined) {
|
||||
requireString(mobile.note, `${where}.note`, errors);
|
||||
}
|
||||
}
|
||||
|
||||
function validateFeatures(features: unknown, errors: string[]): void {
|
||||
if (features === undefined) return;
|
||||
if (!Array.isArray(features)) {
|
||||
|
||||
@@ -403,7 +403,6 @@ getDeviceId (value): () => string
|
||||
getErrorMessage (value): (error: unknown, fallback?: string) => string
|
||||
getExternalAutoProvision (value): () => Promise<{ enabled: boolean; }>
|
||||
getFolderAccess (value): (folder: string) => Promise<{ rules: FolderAccessRule[]; }>
|
||||
getFolderAuthOverride (value): (folder: string, protocol: string, sharedCopySyncId?: string) => Promise<{ folder: string; protocol: string; credentialId: number; }>
|
||||
getFolderDefaultsId (value): (name: string) => Promise<number>
|
||||
getHostAccess (value): (hostId: number, syncId?: string) => Promise<{ accessList: AccessRecord[]; isOwner?: boolean; }>
|
||||
getHostAuthOverride (value): (hostId: number, protocol: string, sharedCopySyncId?: string) => Promise<{ protocol: string; credentialId: number; }>
|
||||
@@ -527,7 +526,7 @@ resolveHostTabType (value): (host: Host, preferredType?: TabType) => TabType
|
||||
resolveRemoteHostId (value): (syncId: string) => Promise<number>
|
||||
revokeAllUserSessions (value): (userId: string) => Promise<{ success: boolean; message: string; }>
|
||||
revokeCredentialAccess (value): (credentialId: number, accessId: number) => Promise<void>
|
||||
revokeFolderAccess (value): (ruleId: number, options?: { fromHosts?: boolean; }) => Promise<void>
|
||||
revokeFolderAccess (value): (ruleId: number) => Promise<void>
|
||||
revokeHostAccess (value): (hostId: number, accessId: number, syncId?: string) => Promise<{ success: boolean; }>
|
||||
revokeSession (value): (sessionId: string) => Promise<{ success: boolean; message: string; }>
|
||||
rightDockableIds (value): () => string[]
|
||||
@@ -545,7 +544,6 @@ saveHostTags (value): (tags: string[]) => Promise<string[]>
|
||||
saveUserPreferences (value): (prefs: Partial<UserPreferences>) => Promise<void>
|
||||
setAllPluginChannels (value): (channel: PluginChannel) => Promise<{ changed: string[]; }>
|
||||
setDeveloperMode (value): (enabled: boolean) => Promise<void>
|
||||
setFolderAuthOverride (value): (folder: string, protocol: string, credentialId: number, sharedCopySyncId?: string) => Promise<{ success: boolean; folder: string; protocol: string; credentialId: number; }>
|
||||
setHostAuthOverride (value): (hostId: number, protocol: string, credentialId: number, sharedCopySyncId?: string) => Promise<{ success: boolean; protocol: string; credentialId: number; }>
|
||||
setNotificationPrivateEndpoints (value): (hosts: string[]) => Promise<string[]>
|
||||
setPluginOptions (value): (pluginId: string, options: { autoUpdate?: boolean; pinned?: boolean; channel?: PluginChannel; }) => Promise<{ autoUpdate: boolean; pinnedVersion: string; channel: PluginChannel; }>
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
/**
|
||||
* Builds the pieces of the plugin SDK that come from core:
|
||||
* Builds the two pieces of the plugin SDK that come from core:
|
||||
*
|
||||
* dist/host/ui.js @termix-ssh/plugin-sdk/ui, the shell's UI kit
|
||||
* dist/host/testing-host.js what renderWithApp() renders plugins into
|
||||
* dist/host/core-classes.json the classes core's CSS has rules for
|
||||
* dist/host/frame-host.js runs one plugin outside the shell (mobile app)
|
||||
* dist/host/frame-host.css core's compiled CSS for that frame
|
||||
*
|
||||
* Inside Termix the page's import map points @termix-ssh/plugin-sdk/ui at the
|
||||
* shell's own copy, so ui.js is never loaded there. It exists so a plugin in
|
||||
@@ -48,7 +46,6 @@ await esbuild.build({
|
||||
entryPoints: {
|
||||
ui: path.join(root, "src/ui/plugin-host/sdk-ui.ts"),
|
||||
"testing-host": path.join(root, "src/ui/plugin-host/testing-host.tsx"),
|
||||
"frame-host": path.join(root, "src/ui/plugin-host/frame-host.tsx"),
|
||||
},
|
||||
outdir: outDir,
|
||||
bundle: true,
|
||||
@@ -75,9 +72,6 @@ await esbuild.build({
|
||||
// so the translated locale files it would list are simply none.
|
||||
banner: { js: "const __termixNoGlob = () => ({});" },
|
||||
define: {
|
||||
// esbuild picks "development" for an unminified build, which sends core's
|
||||
// requests to the Vite dev proxy on any server without a port.
|
||||
"process.env.NODE_ENV": '"production"',
|
||||
"import.meta.glob": "__termixNoGlob",
|
||||
"import.meta.env.DEV": "false",
|
||||
"import.meta.env.PROD": "true",
|
||||
@@ -163,16 +157,14 @@ const rootSource =
|
||||
const scanner = new Scanner({
|
||||
sources: [...rootSource, ...compiler.sources],
|
||||
});
|
||||
const coreCss = optimize(compiler.build(scanner.scan()), {
|
||||
minify: true,
|
||||
}).code;
|
||||
const coreClasses = [...classNamesInCss(coreCss)].sort();
|
||||
const coreClasses = [
|
||||
...classNamesInCss(
|
||||
optimize(compiler.build(scanner.scan()), { minify: true }).code,
|
||||
),
|
||||
].sort();
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, "core-classes.json"),
|
||||
`${JSON.stringify(coreClasses)}\n`,
|
||||
);
|
||||
|
||||
// The frame host has no Vite build to load core's CSS, so it gets it here.
|
||||
fs.writeFileSync(path.join(outDir, "frame-host.css"), coreCss);
|
||||
|
||||
console.log(`Built the SDK host modules into ${path.relative(root, outDir)}`);
|
||||
@@ -16,8 +16,6 @@ const SDK = path.join(ROOT, "packages", "plugin-sdk");
|
||||
const INTERNAL_EXPORTS = new Set([
|
||||
"./testing/setup",
|
||||
"./testing-host",
|
||||
"./frame-host",
|
||||
"./frame-host.css",
|
||||
"./tsconfig.plugin.json",
|
||||
"./schema/manifest.schema.json",
|
||||
"./theme.css",
|
||||
|
||||
@@ -426,7 +426,6 @@ const ALLOWED = [
|
||||
"getFolderAccess",
|
||||
"getFolderDefaultsId",
|
||||
"getHostAccess",
|
||||
"getFolderAuthOverride",
|
||||
"getHostAuthOverride",
|
||||
"getHostDefaultsLevel",
|
||||
"getHostEditorSection",
|
||||
@@ -566,7 +565,6 @@ const ALLOWED = [
|
||||
"saveUserPreferences",
|
||||
"setAllPluginChannels",
|
||||
"setDeveloperMode",
|
||||
"setFolderAuthOverride",
|
||||
"setHostAuthOverride",
|
||||
"setNotificationPrivateEndpoints",
|
||||
"setPluginOptions",
|
||||
|
||||
+5
-31
@@ -404,7 +404,6 @@ function watchBackend() {
|
||||
{ cwd: root, stdio: ["ignore", "pipe", "inherit"] },
|
||||
);
|
||||
let first = true;
|
||||
let restartQueued = false;
|
||||
let buffer = "";
|
||||
tsc.stdout.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
@@ -424,21 +423,14 @@ function watchBackend() {
|
||||
? "starting the backend"
|
||||
: "backend changed, restarting it";
|
||||
first = false;
|
||||
if (restartQueued) continue;
|
||||
restartQueued = true;
|
||||
void queue(() => {
|
||||
restartQueued = false;
|
||||
return restartBackend(reason);
|
||||
});
|
||||
void queue(() => restartBackend(reason));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function watchSdk() {
|
||||
let buildStarted = Date.now();
|
||||
const changed = createDebouncer(300, () =>
|
||||
queue(async () => {
|
||||
buildStarted = Date.now();
|
||||
try {
|
||||
await run("npm", ["run", "build:sdk"]);
|
||||
} catch (error) {
|
||||
@@ -448,16 +440,7 @@ function watchSdk() {
|
||||
await restartBackend("SDK changed, restarting the backend");
|
||||
}),
|
||||
);
|
||||
// Windows also reports reads (access time), so only a file saved after the last build counts
|
||||
watch(SDK_SRC, true, (_event, filename) => {
|
||||
try {
|
||||
const stat = fs.statSync(path.join(SDK_SRC, String(filename ?? "")));
|
||||
if (stat.isDirectory() || stat.mtimeMs < buildStarted) return;
|
||||
} catch {
|
||||
// deleted or renamed, still a change
|
||||
}
|
||||
changed("sdk");
|
||||
});
|
||||
watch(SDK_SRC, true, () => changed("sdk"));
|
||||
}
|
||||
|
||||
function watchElectron() {
|
||||
@@ -597,18 +580,9 @@ function watchPlugins(dir) {
|
||||
queue(() => rebuildPlugin(byId.get(id))),
|
||||
);
|
||||
for (const plugin of plugins) {
|
||||
// Only source folders, so node_modules and .git are never watched
|
||||
const onEvent = (prefix) => (_event, filename) => {
|
||||
const name = prefix
|
||||
? path.join(prefix, String(filename ?? ""))
|
||||
: filename;
|
||||
if (isPluginEdit(plugin.repo, name)) changed(plugin.id);
|
||||
};
|
||||
watch(plugin.repo, false, onEvent(""));
|
||||
for (const sub of ["src", "locales", "migrations"]) {
|
||||
const target = path.join(plugin.repo, sub);
|
||||
if (fs.existsSync(target)) watch(target, true, onEvent(sub));
|
||||
}
|
||||
watch(plugin.repo, true, (_event, filename) => {
|
||||
if (isPluginEdit(plugin.repo, filename)) changed(plugin.id);
|
||||
});
|
||||
}
|
||||
log(`watching ${plugins.length} plugin(s) in ${dir}`);
|
||||
}
|
||||
|
||||
@@ -144,32 +144,3 @@ describe("termix-plugin validate layouts and capabilities", () => {
|
||||
expect(output).not.toContain("is declared but");
|
||||
});
|
||||
});
|
||||
|
||||
describe("termix-plugin validate mobile entry", () => {
|
||||
function mobile(built: boolean) {
|
||||
const root = plugin([]);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "manifest.json"),
|
||||
JSON.stringify({
|
||||
id: "fixture",
|
||||
version: "1.0.0",
|
||||
contributes: { mobile: { mode: "webview", entry: "dist/mobile.js" } },
|
||||
}),
|
||||
);
|
||||
if (built) {
|
||||
fs.mkdirSync(path.join(root, "dist"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "dist", "mobile.js"), "");
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
it("reports a mobile entry that was not built", async () => {
|
||||
expect(await problems(mobile(false))).toContain(
|
||||
"manifest references dist/mobile.js, which does not exist",
|
||||
);
|
||||
});
|
||||
|
||||
it("finds a built mobile entry", async () => {
|
||||
expect(await problems(mobile(true))).not.toContain("dist/mobile.js");
|
||||
});
|
||||
});
|
||||
@@ -484,19 +484,6 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_folder_access_owner_folder ON folder_access (owner_user_id, folder);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shared_folder_auth_overrides (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
folder TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL DEFAULT 'ssh',
|
||||
credential_id INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS shared_folder_auth_overrides_user_folder_protocol_unique ON shared_folder_auth_overrides (user_id, folder, protocol);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugins (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
@@ -1329,8 +1316,6 @@ const migrateSchema = () => {
|
||||
{ column: "ssh_port", sql: "ALTER TABLE ssh_data ADD COLUMN ssh_port INTEGER DEFAULT 22" },
|
||||
{ column: "connection_origin", sql: "ALTER TABLE ssh_data ADD COLUMN connection_origin TEXT" },
|
||||
{ column: "parent_host_id", sql: "ALTER TABLE ssh_data ADD COLUMN parent_host_id INTEGER REFERENCES ssh_data(id) ON DELETE SET NULL" },
|
||||
{ column: "color", sql: "ALTER TABLE ssh_data ADD COLUMN color TEXT" },
|
||||
{ column: "icon", sql: "ALTER TABLE ssh_data ADD COLUMN icon TEXT" },
|
||||
];
|
||||
|
||||
for (const migration of sshDataMigrations) {
|
||||
|
||||
@@ -193,9 +193,6 @@ export const hosts = mysqlTable(
|
||||
// legacy algorithms, agent, environment). JSON.
|
||||
sshOptions: text("ssh_options"),
|
||||
notes: text("notes"),
|
||||
// Hex color and Lucide icon name a layout may show next to the host.
|
||||
color: text("color"),
|
||||
icon: text("icon"),
|
||||
enableSsh: boolean("enable_ssh").notNull().default(true),
|
||||
|
||||
sshPort: int("ssh_port").default(22),
|
||||
@@ -490,38 +487,6 @@ export const sharedHostAuthOverrides = mysqlTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* A recipient's own login for the shared hosts in a folder ("Parent / Child")
|
||||
* and its subfolders. A host's own override still wins.
|
||||
*/
|
||||
export const sharedFolderAuthOverrides = mysqlTable(
|
||||
"shared_folder_auth_overrides",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
folder: varchar("folder", { length: 255 }).notNull(),
|
||||
protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"),
|
||||
credentialId: int("credential_id")
|
||||
.notNull()
|
||||
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("shared_folder_auth_overrides_user_folder_protocol_unique").on(
|
||||
table.userId,
|
||||
table.folder,
|
||||
table.protocol,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const sharedHostSecrets = mysqlTable(
|
||||
"shared_host_secrets",
|
||||
{
|
||||
|
||||
@@ -193,9 +193,6 @@ export const hosts = pgTable(
|
||||
// legacy algorithms, agent, environment). JSON.
|
||||
sshOptions: text("ssh_options"),
|
||||
notes: text("notes"),
|
||||
// Hex color and Lucide icon name a layout may show next to the host.
|
||||
color: text("color"),
|
||||
icon: text("icon"),
|
||||
enableSsh: boolean("enable_ssh").notNull().default(true),
|
||||
|
||||
sshPort: integer("ssh_port").default(22),
|
||||
@@ -490,38 +487,6 @@ export const sharedHostAuthOverrides = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* A recipient's own login for the shared hosts in a folder ("Parent / Child")
|
||||
* and its subfolders. A host's own override still wins.
|
||||
*/
|
||||
export const sharedFolderAuthOverrides = pgTable(
|
||||
"shared_folder_auth_overrides",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
folder: varchar("folder", { length: 255 }).notNull(),
|
||||
protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"),
|
||||
credentialId: integer("credential_id")
|
||||
.notNull()
|
||||
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("shared_folder_auth_overrides_user_folder_protocol_unique").on(
|
||||
table.userId,
|
||||
table.folder,
|
||||
table.protocol,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const sharedHostSecrets = pgTable(
|
||||
"shared_host_secrets",
|
||||
{
|
||||
|
||||
@@ -183,9 +183,6 @@ export const hosts = sqliteTable(
|
||||
// legacy algorithms, agent, environment). JSON.
|
||||
sshOptions: text("ssh_options"),
|
||||
notes: text("notes"),
|
||||
// Hex color and Lucide icon name a layout may show next to the host.
|
||||
color: text("color"),
|
||||
icon: text("icon"),
|
||||
enableSsh: integer("enable_ssh", { mode: "boolean" }).notNull().default(true),
|
||||
|
||||
sshPort: integer("ssh_port").default(22),
|
||||
@@ -480,38 +477,6 @@ export const sharedHostAuthOverrides = sqliteTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* A recipient's own login for the shared hosts in a folder ("Parent / Child")
|
||||
* and its subfolders. A host's own override still wins.
|
||||
*/
|
||||
export const sharedFolderAuthOverrides = sqliteTable(
|
||||
"shared_folder_auth_overrides",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
folder: text("folder").notNull(),
|
||||
protocol: text("protocol").notNull().default("ssh"),
|
||||
credentialId: integer("credential_id")
|
||||
.notNull()
|
||||
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("shared_folder_auth_overrides_user_folder_protocol_unique").on(
|
||||
table.userId,
|
||||
table.folder,
|
||||
table.protocol,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const sharedHostSecrets = sqliteTable(
|
||||
"shared_host_secrets",
|
||||
{
|
||||
|
||||
@@ -31,7 +31,6 @@ import { FolderAccessRepository } from "./folder-access-repository.js";
|
||||
import { SettingsRepository } from "./settings-repository.js";
|
||||
import { HostProtocolAuthRepository } from "./host-protocol-auth-repository.js";
|
||||
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
|
||||
import { SharedFolderAuthOverrideRepository } from "./shared-folder-auth-override-repository.js";
|
||||
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
|
||||
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
|
||||
import { TrustedDeviceRepository } from "./trusted-device-repository.js";
|
||||
@@ -317,15 +316,6 @@ export function createCurrentSharedHostAuthOverrideRepository(): SharedHostAuthO
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSharedFolderAuthOverrideRepository(): SharedFolderAuthOverrideRepository {
|
||||
return new SharedFolderAuthOverrideRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook(
|
||||
"shared_folder_auth_override_repository_write",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageRepository {
|
||||
return new SshCredentialUsageRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js";
|
||||
import { sharedFolderAuthOverrides } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { deleteReturning, upsert } from "./returning.js";
|
||||
|
||||
/** "A / B / C" and the folders above it, closest first. */
|
||||
export function folderAndParents(folder: string | null | undefined): string[] {
|
||||
const parts = (folder ?? "")
|
||||
.split(" / ")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
const out: string[] = [];
|
||||
for (let i = parts.length; i > 0; i--) {
|
||||
out.push(parts.slice(0, i).join(" / "));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export class SharedFolderAuthOverrideRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async findCredentialId(
|
||||
userId: string,
|
||||
folder: string,
|
||||
protocol: AuthOverrideProtocol,
|
||||
): Promise<number | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ credentialId: sharedFolderAuthOverrides.credentialId })
|
||||
.from(sharedFolderAuthOverrides)
|
||||
.where(
|
||||
and(
|
||||
eq(sharedFolderAuthOverrides.userId, userId),
|
||||
eq(sharedFolderAuthOverrides.folder, folder),
|
||||
eq(sharedFolderAuthOverrides.protocol, protocol),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0]?.credentialId ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The login set on the host's folder or the closest folder above it, for
|
||||
* one protocol. Null when none is set.
|
||||
*/
|
||||
async findClosest(
|
||||
userId: string,
|
||||
hostFolder: string | null | undefined,
|
||||
protocol: AuthOverrideProtocol,
|
||||
): Promise<{ folder: string; credentialId: number } | null> {
|
||||
const folders = folderAndParents(hostFolder);
|
||||
if (folders.length === 0) return null;
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
folder: sharedFolderAuthOverrides.folder,
|
||||
credentialId: sharedFolderAuthOverrides.credentialId,
|
||||
})
|
||||
.from(sharedFolderAuthOverrides)
|
||||
.where(
|
||||
and(
|
||||
eq(sharedFolderAuthOverrides.userId, userId),
|
||||
eq(sharedFolderAuthOverrides.protocol, protocol),
|
||||
inArray(sharedFolderAuthOverrides.folder, folders),
|
||||
),
|
||||
);
|
||||
for (const folder of folders) {
|
||||
const row = rows.find((candidate) => candidate.folder === folder);
|
||||
if (row) return row;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async setCredential(
|
||||
userId: string,
|
||||
folder: string,
|
||||
protocol: AuthOverrideProtocol,
|
||||
credentialId: number,
|
||||
): Promise<void> {
|
||||
await upsert(
|
||||
this.context,
|
||||
sharedFolderAuthOverrides,
|
||||
{ userId, folder, protocol, credentialId },
|
||||
{
|
||||
target: [
|
||||
sharedFolderAuthOverrides.userId,
|
||||
sharedFolderAuthOverrides.folder,
|
||||
sharedFolderAuthOverrides.protocol,
|
||||
],
|
||||
set: { credentialId, updatedAt: new Date().toISOString() },
|
||||
},
|
||||
);
|
||||
await this.onWrite?.();
|
||||
}
|
||||
|
||||
async clearCredential(
|
||||
userId: string,
|
||||
folder: string,
|
||||
protocol: AuthOverrideProtocol,
|
||||
): Promise<boolean> {
|
||||
const rows = await deleteReturning(
|
||||
this.context,
|
||||
sharedFolderAuthOverrides,
|
||||
and(
|
||||
eq(sharedFolderAuthOverrides.userId, userId),
|
||||
eq(sharedFolderAuthOverrides.folder, folder),
|
||||
eq(sharedFolderAuthOverrides.protocol, protocol),
|
||||
),
|
||||
);
|
||||
if (rows.length > 0) await this.onWrite?.();
|
||||
return rows.length > 0;
|
||||
}
|
||||
}
|
||||
@@ -30,33 +30,6 @@ export function applyHostKeyTypeUpdate(
|
||||
}
|
||||
}
|
||||
|
||||
const HOST_COLOR = /^#[0-9a-fA-F]{6}$/;
|
||||
const HOST_ICON = /^[a-z0-9-]{1,64}$/;
|
||||
|
||||
/**
|
||||
* Color and icon from a host write. A field left out stays as stored, so
|
||||
* editors that don't know about them never clear them. Bad values store null.
|
||||
*/
|
||||
export function normalizeHostLookFields(values: Record<string, unknown>): {
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
} {
|
||||
const result: { color?: string | null; icon?: string | null } = {};
|
||||
if (values.color !== undefined) {
|
||||
result.color =
|
||||
typeof values.color === "string" && HOST_COLOR.test(values.color)
|
||||
? values.color.toLowerCase()
|
||||
: null;
|
||||
}
|
||||
if (values.icon !== undefined) {
|
||||
result.icon =
|
||||
typeof values.icon === "string" && HOST_ICON.test(values.icon)
|
||||
? values.icon
|
||||
: null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const PROTOCOL_ENABLE_FIELDS = ["enableSsh"] as const;
|
||||
|
||||
export function normalizeProtocolEnableFields(
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
isOptionalAuthType,
|
||||
isOptionalBoolean,
|
||||
isValidPort,
|
||||
normalizeHostLookFields,
|
||||
normalizeProtocolEnableFields,
|
||||
OWNER_PRIVATE_AUTH_FIELDS,
|
||||
OWNER_PRIVATE_SSH_OPTION_FIELDS,
|
||||
@@ -336,7 +335,6 @@ router.post(
|
||||
? JSON.stringify(portKnockSequence)
|
||||
: null,
|
||||
...normalizeProtocolEnableFields(hostData),
|
||||
...normalizeHostLookFields(hostData),
|
||||
sshPort: sshPort || port || 22,
|
||||
};
|
||||
|
||||
@@ -914,7 +912,6 @@ router.put(
|
||||
? JSON.stringify(portKnockSequence)
|
||||
: null,
|
||||
...normalizeProtocolEnableFields(hostData),
|
||||
...normalizeHostLookFields(hostData),
|
||||
sshPort: sshPort || port || 22,
|
||||
};
|
||||
|
||||
|
||||
@@ -244,9 +244,7 @@ export async function listPreLoginPlugins(): Promise<
|
||||
* `assetVersion` is its cache key, `css` says a stylesheet sits beside
|
||||
* it, and `locales` lists the languages it ships ("en" and xx_YY).
|
||||
* `dependencies` and `optionalDependencies` let the loader activate
|
||||
* frontends in the same order the server does. `engine` holds the
|
||||
* manifest's `api` and `termix` ranges, so other clients (the mobile
|
||||
* app) can tell whether they understand the plugin.
|
||||
* frontends in the same order the server does.
|
||||
*
|
||||
* On a desktop linked to a server, `runsOnServer` marks plugins that
|
||||
* only run on that server and stay off here on purpose, and, for
|
||||
@@ -305,7 +303,6 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
let features: string[] = [];
|
||||
let docs: string | undefined;
|
||||
let env: PluginEnvVar[] = [];
|
||||
const engine: { api?: string; termix?: string } = {};
|
||||
let desktopMode: unknown = loaded
|
||||
? resolvePluginDesktopMode(loaded.manifest)
|
||||
: undefined;
|
||||
@@ -325,14 +322,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
optionalDependencies?: Record<string, string>;
|
||||
desktop?: unknown;
|
||||
runtime?: unknown;
|
||||
engine?: { api?: unknown; termix?: unknown };
|
||||
};
|
||||
if (typeof manifest?.engine?.api === "string") {
|
||||
engine.api = manifest.engine.api;
|
||||
}
|
||||
if (typeof manifest?.engine?.termix === "string") {
|
||||
engine.termix = manifest.engine.termix;
|
||||
}
|
||||
desktopMode = loaded
|
||||
? resolvePluginDesktopMode(loaded.manifest)
|
||||
: (manifest?.desktop ??
|
||||
@@ -375,7 +365,6 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
? "safe-mode"
|
||||
: (loaded?.state ?? record.state),
|
||||
runsOnServer: linked && desktopMode === "server",
|
||||
engine,
|
||||
contributes,
|
||||
icon,
|
||||
dependencies,
|
||||
@@ -2706,10 +2695,6 @@ router.put(
|
||||
* summary: Upload a .tmxplug to install from a file
|
||||
* description: >
|
||||
* Developer mode only. The body is the raw .tmxplug (at most 50 MB).
|
||||
The release's .sig may come base64 encoded in the
|
||||
X-Termix-Plugin-Signature header; an official one makes the file
|
||||
install as an official plugin, and it may then fill in a plugin
|
||||
whose download failed.
|
||||
* The file is checked (archive, manifest, plugin API) and held for 30
|
||||
* minutes, but nothing is loaded. The answer lists what it asks for so
|
||||
* the admin can agree, then POST /plugins/upload/{token}/install
|
||||
@@ -2755,13 +2740,7 @@ router.post(
|
||||
}
|
||||
try {
|
||||
const { stageUpload } = await import("../../plugins/manage.js");
|
||||
// The release's .sig, base64 encoded, so an official file installs as
|
||||
// official (and may write the tables its 2.8 version left).
|
||||
const header = req.get("x-termix-plugin-signature");
|
||||
const signature = header
|
||||
? Buffer.from(header, "base64").toString("utf8")
|
||||
: null;
|
||||
res.json(await stageUpload(req.body, signature));
|
||||
res.json(await stageUpload(req.body));
|
||||
} catch (error) {
|
||||
sendManageError(
|
||||
res,
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
import { isAuthOverrideProtocol } from "../../hosts/protocol-auth/registry.js";
|
||||
import {
|
||||
assertHostsUnlocked,
|
||||
hostLock,
|
||||
hasHostLocks,
|
||||
requireUnlockedHost,
|
||||
sendHostLocked,
|
||||
@@ -37,10 +36,8 @@ import {
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentRbacAccessRepository,
|
||||
createCurrentRoleRepository,
|
||||
createCurrentSharedFolderAuthOverrideRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import { findUsableCredential } from "../../hosts/usable-credential.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -725,40 +722,14 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/** The host grants that went to the same user or role as a folder rule. */
|
||||
export function grantsForFolderRule<
|
||||
T extends { userId?: string | null; roleId?: number | null },
|
||||
>(entries: T[], rule: { userId?: string | null; roleId?: number | null }): T[] {
|
||||
return entries.filter((entry) =>
|
||||
rule.userId
|
||||
? entry.userId === rule.userId
|
||||
: rule.roleId != null && entry.roleId === rule.roleId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/folder/access/{id}:
|
||||
* delete:
|
||||
* summary: Stop a folder share from applying to hosts added later
|
||||
* description: Grants already fanned out to hosts stay, unless fromHosts=true, which also removes the same user's or role's access from every host in the folder and its subfolders that the caller may manage sharing on.
|
||||
* description: Grants already fanned out to hosts stay; revoke those per host.
|
||||
* tags:
|
||||
* - RBAC
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: query
|
||||
* name: fromHosts
|
||||
* schema:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Removed. removedFromHosts counts the host grants removed.
|
||||
* 404:
|
||||
* description: No such rule.
|
||||
*/
|
||||
router.delete(
|
||||
"/folder/access/:id",
|
||||
@@ -770,35 +741,11 @@ router.delete(
|
||||
}
|
||||
try {
|
||||
const repository = createCurrentFolderAccessRepository();
|
||||
const rule = await repository.findById(ruleId, req.userId!);
|
||||
if (!rule) {
|
||||
if (!(await repository.findById(ruleId, req.userId!))) {
|
||||
return res.status(404).json({ error: "Rule not found" });
|
||||
}
|
||||
await repository.deleteById(ruleId, req.userId!);
|
||||
|
||||
let removedFromHosts = 0;
|
||||
if (req.query.fromHosts === "true") {
|
||||
const hosts =
|
||||
await createCurrentHostFolderRepository().listHostsInFolder(
|
||||
req.userId!,
|
||||
rule.folder,
|
||||
);
|
||||
const access = createCurrentRbacAccessRepository();
|
||||
for (const host of hosts) {
|
||||
if (hostLock(host.id)) continue;
|
||||
if (!(await canManageHostSharing(req.userId!, host.id)).allowed)
|
||||
continue;
|
||||
let changed = false;
|
||||
const entries = await access.listHostAccess(host.id);
|
||||
for (const entry of grantsForFolderRule(entries, rule)) {
|
||||
await access.revokeHostAccess(entry.id, host.id);
|
||||
removedFromHosts++;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) await pruneHostGrants(host.id);
|
||||
}
|
||||
}
|
||||
res.json({ success: true, removedFromHosts });
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to delete folder access rule", error, {
|
||||
operation: "delete_folder_access",
|
||||
@@ -2227,157 +2174,4 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/** A folder path as hosts store it ("Parent / Child"), or null. */
|
||||
function folderParam(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const folder = value
|
||||
.split(" / ")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
return folder && folder.length <= 255 ? folder : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/folder-auth/{protocol}:
|
||||
* get:
|
||||
* summary: Get the current user's personal login for shared hosts in a folder
|
||||
* tags: [RBAC]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: protocol
|
||||
* required: true
|
||||
* schema: { type: string }
|
||||
* - in: query
|
||||
* name: folder
|
||||
* required: true
|
||||
* schema: { type: string }
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The folder, protocol and credential id, or null when none is set.
|
||||
* 400:
|
||||
* description: Invalid folder or protocol.
|
||||
*/
|
||||
router.get(
|
||||
"/folder-auth/:protocol",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const protocol = req.params.protocol;
|
||||
const folder = folderParam(req.query.folder);
|
||||
if (!folder) return res.status(400).json({ error: "Invalid folder" });
|
||||
if (!isAuthOverrideProtocol(protocol)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Invalid authentication protocol" });
|
||||
}
|
||||
const credentialId =
|
||||
await createCurrentSharedFolderAuthOverrideRepository().findCredentialId(
|
||||
userId,
|
||||
folder,
|
||||
protocol,
|
||||
);
|
||||
res.json({ folder, protocol, credentialId });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get folder login", error);
|
||||
res.status(500).json({ error: "Failed to fetch credential" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/folder-auth/{protocol}:
|
||||
* put:
|
||||
* summary: Set the current user's personal login for shared hosts in a folder
|
||||
* description: >
|
||||
* Used for every host shared with the user in this folder and its
|
||||
* subfolders that has no personal login of its own. credentialId null
|
||||
* clears it.
|
||||
* tags: [RBAC]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: protocol
|
||||
* required: true
|
||||
* schema: { type: string }
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [folder, credentialId]
|
||||
* properties:
|
||||
* folder: { type: string }
|
||||
* credentialId: { type: integer, nullable: true }
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Saved.
|
||||
* 400:
|
||||
* description: Invalid folder, protocol or credential id.
|
||||
* 404:
|
||||
* description: The credential is not one the user can use.
|
||||
*/
|
||||
router.put(
|
||||
"/folder-auth/:protocol",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const protocol = req.params.protocol;
|
||||
const folder = folderParam(req.body?.folder);
|
||||
const credentialId = req.body?.credentialId ?? null;
|
||||
if (!folder) return res.status(400).json({ error: "Invalid folder" });
|
||||
if (!isAuthOverrideProtocol(protocol)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Invalid authentication protocol" });
|
||||
}
|
||||
if (
|
||||
credentialId !== null &&
|
||||
(!Number.isInteger(credentialId) || credentialId <= 0)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: "credentialId must be a positive integer or null",
|
||||
});
|
||||
}
|
||||
|
||||
const repository = createCurrentSharedFolderAuthOverrideRepository();
|
||||
if (credentialId === null) {
|
||||
await repository.clearCredential(userId, folder, protocol);
|
||||
} else {
|
||||
if (!(await findUsableCredential(credentialId, userId))) {
|
||||
return res.status(404).json({ error: "Credential not found" });
|
||||
}
|
||||
await repository.setCredential(userId, folder, protocol, credentialId);
|
||||
}
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action:
|
||||
credentialId === null
|
||||
? "clear_shared_folder_auth_override"
|
||||
: "set_shared_folder_auth_override",
|
||||
resourceType: "folder",
|
||||
resourceName: folder,
|
||||
details: JSON.stringify({ protocol, credentialId }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
}).catch(() => undefined);
|
||||
|
||||
res.json({ success: true, folder, protocol, credentialId });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to set folder login", error);
|
||||
res.status(500).json({ error: "Failed to update credential" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -1,95 +0,0 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { RequestHandler, Router } from "express";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { createCurrentUserRepository } from "../repositories/factory.js";
|
||||
import { deleteUserAndRelatedData } from "./delete-user-data.js";
|
||||
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
}
|
||||
|
||||
export function registerUserDeleteAccountRoutes(
|
||||
router: Router,
|
||||
authenticateJWT: RequestHandler,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/delete-account:
|
||||
* delete:
|
||||
* summary: Delete user account
|
||||
* description: Deletes the authenticated user's account and all of their data.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* password:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Account deleted successfully.
|
||||
* 400:
|
||||
* description: Password is required.
|
||||
* 401:
|
||||
* description: Incorrect password.
|
||||
* 403:
|
||||
* description: Cannot delete external authentication accounts or the last admin user.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: Failed to delete account.
|
||||
*/
|
||||
router.delete("/delete-account", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { password } = req.body ?? {};
|
||||
|
||||
if (!isNonEmptyString(password)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Password is required to delete account" });
|
||||
}
|
||||
|
||||
try {
|
||||
const userRepository = createCurrentUserRepository();
|
||||
const userRecord = await userRepository.findById(userId);
|
||||
if (!userRecord) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
if (userRecord.isExternal) {
|
||||
return res.status(403).json({
|
||||
error:
|
||||
"Cannot delete external authentication accounts through this endpoint",
|
||||
});
|
||||
}
|
||||
|
||||
const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
|
||||
if (!isMatch) {
|
||||
authLogger.warn(
|
||||
`Incorrect password provided for account deletion: ${userRecord.username}`,
|
||||
);
|
||||
return res.status(401).json({ error: "Incorrect password" });
|
||||
}
|
||||
|
||||
if (userRecord.isAdmin && (await userRepository.countAdmins()) <= 1) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Cannot delete the last admin user" });
|
||||
}
|
||||
|
||||
// Same cleanup as an admin delete, so plugins hear user.deleted too.
|
||||
await deleteUserAndRelatedData(userId);
|
||||
|
||||
authLogger.success(`User account deleted: ${userRecord.username}`);
|
||||
res.json({ message: "Account deleted successfully" });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to delete user account", err);
|
||||
res.status(500).json({ error: "Failed to delete account" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import { shownIdentity } from "../../sync/shown-identity.js";
|
||||
import { registerUserExternalAccountRoutes } from "./user-external-account-routes.js";
|
||||
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
|
||||
import { registerUserAdminRoutes } from "./user-admin-routes.js";
|
||||
import { registerUserDeleteAccountRoutes } from "./user-delete-account-routes.js";
|
||||
import { registerUserDataAccessRoutes } from "./user-data-access-routes.js";
|
||||
import { listExternalLoginMethods, registerAuthRoutes } from "./auth-routes.js";
|
||||
import { registerAuthCompatRoutes } from "./auth-compat-routes.js";
|
||||
@@ -319,7 +318,7 @@ router.post("/proxy-login", async (req, res) => {
|
||||
try {
|
||||
if (!isTrustedProxyAddress(sourceAddress, config.trustedProxies)) {
|
||||
authLogger.warn(
|
||||
`Rejected proxy authentication from an untrusted source: ${sourceAddress ?? "unknown"}`,
|
||||
"Rejected proxy authentication from an untrusted source",
|
||||
{
|
||||
operation: "trusted_proxy_auth_rejected",
|
||||
sourceAddress,
|
||||
@@ -1230,7 +1229,86 @@ router.patch("/password-reset-allowed", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
registerUserDeleteAccountRoutes(router, authenticateJWT);
|
||||
/**
|
||||
* @openapi
|
||||
* /users/delete-account:
|
||||
* delete:
|
||||
* summary: Delete user account
|
||||
* description: Deletes the authenticated user's account.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* password:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Account deleted successfully.
|
||||
* 400:
|
||||
* description: Password is required.
|
||||
* 401:
|
||||
* description: Incorrect password.
|
||||
* 403:
|
||||
* description: Cannot delete external authentication accounts or the last admin user.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: Failed to delete account.
|
||||
*/
|
||||
router.delete("/delete-account", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { password } = req.body;
|
||||
|
||||
if (!isNonEmptyString(password)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Password is required to delete account" });
|
||||
}
|
||||
|
||||
try {
|
||||
const userRecord = await findCurrentUser(userId);
|
||||
if (!userRecord) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
if (userRecord.isExternal) {
|
||||
return res.status(403).json({
|
||||
error:
|
||||
"Cannot delete external authentication accounts through this endpoint",
|
||||
});
|
||||
}
|
||||
|
||||
const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
|
||||
if (!isMatch) {
|
||||
authLogger.warn(
|
||||
`Incorrect password provided for account deletion: ${userRecord.username}`,
|
||||
);
|
||||
return res.status(401).json({ error: "Incorrect password" });
|
||||
}
|
||||
|
||||
if (userRecord.isAdmin) {
|
||||
const adminCount = await createCurrentUserRepository().countAdmins();
|
||||
if (adminCount <= 1) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Cannot delete the last admin user" });
|
||||
}
|
||||
}
|
||||
|
||||
await createCurrentUserRepository().delete(userId);
|
||||
|
||||
authLogger.success(`User account deleted: ${userRecord.username}`);
|
||||
res.json({ message: "Account deleted successfully" });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to delete user account", err);
|
||||
res.status(500).json({ error: "Failed to delete account" });
|
||||
}
|
||||
});
|
||||
|
||||
registerUserPasswordResetRoutes(router, { authManager });
|
||||
|
||||
|
||||
@@ -7,10 +7,7 @@
|
||||
*/
|
||||
|
||||
import type { Duplex } from "node:stream";
|
||||
import type {
|
||||
PluginSshTransportHost,
|
||||
PluginSshTransportProvider,
|
||||
} from "@termix-ssh/plugin-sdk/backend";
|
||||
import type { PluginSshTransportProvider } from "@termix-ssh/plugin-sdk/backend";
|
||||
|
||||
interface Entry {
|
||||
pluginId: string;
|
||||
@@ -36,13 +33,15 @@ export function clearSshTransports(): void {
|
||||
}
|
||||
|
||||
/** Opens a stream to the host if a provider claims it, else null. */
|
||||
export async function openProvidedTransport(
|
||||
host: PluginSshTransportHost,
|
||||
): Promise<Duplex | null> {
|
||||
export async function openProvidedTransport(host: {
|
||||
id: number;
|
||||
ip: string;
|
||||
port: number;
|
||||
}): Promise<Duplex | null> {
|
||||
for (const { provider } of providers.values()) {
|
||||
let claimed = false;
|
||||
try {
|
||||
claimed = provider.claims(host.id, host);
|
||||
claimed = provider.claims(host.id);
|
||||
} catch {
|
||||
claimed = false;
|
||||
}
|
||||
|
||||
@@ -99,10 +99,6 @@ export async function openSshTransport(
|
||||
id: host.id,
|
||||
ip: host.ip,
|
||||
port: host.port || 22,
|
||||
userId: host.userId ?? null,
|
||||
syncId: host.syncId ?? null,
|
||||
connectionOrigin: host.connectionOrigin ?? null,
|
||||
localOnly: Boolean(host.localOnly),
|
||||
});
|
||||
if (provided) {
|
||||
config.sock = provided;
|
||||
|
||||
@@ -24,9 +24,6 @@ export interface SshConnectHost {
|
||||
port: number;
|
||||
username: string;
|
||||
userId?: string | null;
|
||||
syncId?: string | null;
|
||||
connectionOrigin?: "local" | "remote" | null;
|
||||
localOnly?: boolean | null;
|
||||
authType?: string | null;
|
||||
password?: string | null;
|
||||
key?: string | Buffer | null;
|
||||
|
||||
@@ -73,8 +73,6 @@ async function exportRow(
|
||||
: resolvedHost.tags || [],
|
||||
pin: !!resolvedHost.pin,
|
||||
notes: resolvedHost.notes || null,
|
||||
color: resolvedHost.color || null,
|
||||
icon: resolvedHost.icon || null,
|
||||
// Every plugin's host settings, secrets redacted, for import to hand back.
|
||||
pluginSettings: pluginSettings.get(host.id as number) ?? {},
|
||||
// Each plugin protocol's login, secrets included like the SSH ones.
|
||||
|
||||
@@ -28,7 +28,6 @@ import { applyPluginHostImportSettings } from "../../database/routes/host-plugin
|
||||
import {
|
||||
isNonEmptyString,
|
||||
isValidPort,
|
||||
normalizeHostLookFields,
|
||||
normalizeImportedHost,
|
||||
} from "../../database/routes/host-normalizers.js";
|
||||
import { hasHostLocks, hostLock, manualCreateBlockedBy } from "../locks.js";
|
||||
@@ -572,7 +571,6 @@ export async function runBulkImport(
|
||||
? "true"
|
||||
: "false",
|
||||
notes: hostData.notes || null,
|
||||
...normalizeHostLookFields(hostData as Record<string, unknown>),
|
||||
useSocks5: hostData.useSocks5 ? 1 : 0,
|
||||
socks5Host: hostData.socks5Host || null,
|
||||
socks5Port: hostData.socks5Port || null,
|
||||
|
||||
@@ -87,18 +87,11 @@ export async function resolveHostCredentials(
|
||||
...recipientHost,
|
||||
authOverrides: {
|
||||
...authOverrides,
|
||||
ssh: resolution.folder
|
||||
? {
|
||||
required: false,
|
||||
ownerAuthShared: !!host.shareSshAuth,
|
||||
folder: resolution.folder,
|
||||
folderCredentialId: resolution.credentialId,
|
||||
}
|
||||
: {
|
||||
credentialId: resolution.credentialId,
|
||||
required: false,
|
||||
ownerAuthShared: !!host.shareSshAuth,
|
||||
},
|
||||
ssh: {
|
||||
credentialId: resolution.credentialId,
|
||||
required: false,
|
||||
ownerAuthShared: !!host.shareSshAuth,
|
||||
},
|
||||
},
|
||||
authType:
|
||||
credential.key || credential.privateKey
|
||||
|
||||
@@ -13,7 +13,7 @@ import net from "node:net";
|
||||
import type { Duplex } from "node:stream";
|
||||
import { createWebSocketStream, type WebSocket } from "ws";
|
||||
import type { PluginWebSocketConnection } from "@termix-ssh/plugin-sdk/backend";
|
||||
import { resolveHostById, resolveHostBySyncId } from "./host-resolver.js";
|
||||
import { resolveHostById } from "./host-resolver.js";
|
||||
import { openSshTransport } from "./connect/transport.js";
|
||||
import type { MutableConnectConfig, SshConnectHost } from "./connect/types.js";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
@@ -35,13 +35,12 @@ async function handleRelay(
|
||||
): Promise<void> {
|
||||
const ws = connection.socket as WebSocket;
|
||||
const url = String((connection.request as { url?: string }).url ?? "");
|
||||
const params = new URL(url, "http://relay").searchParams;
|
||||
const hostId = Number(params.get("hostId"));
|
||||
// A linked desktop knows its hosts by sync id, not by this server's id.
|
||||
const syncId = params.get("syncId");
|
||||
const hostId = Number(
|
||||
new URL(url, "http://relay").searchParams.get("hostId"),
|
||||
);
|
||||
const userId = connection.userId;
|
||||
|
||||
if (!userId || (!syncId && (!Number.isSafeInteger(hostId) || hostId <= 0))) {
|
||||
if (!userId || !Number.isSafeInteger(hostId) || hostId <= 0) {
|
||||
close(ws, 1008, "A host is required");
|
||||
return;
|
||||
}
|
||||
@@ -50,9 +49,7 @@ async function handleRelay(
|
||||
return;
|
||||
}
|
||||
|
||||
const host = syncId
|
||||
? await resolveHostBySyncId(syncId, userId)
|
||||
: await resolveHostById(hostId, userId);
|
||||
const host = await resolveHostById(hostId, userId);
|
||||
if (!host) {
|
||||
close(ws, 1008, "Host not found or access denied");
|
||||
return;
|
||||
@@ -96,8 +93,7 @@ async function handleRelay(
|
||||
} catch (error) {
|
||||
sshLogger.warn("A relay could not reach its host", {
|
||||
operation: "ssh_relay_open",
|
||||
hostId: syncId ? undefined : hostId,
|
||||
syncId,
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
finish();
|
||||
|
||||
@@ -38,20 +38,6 @@ export function isUserPluginFile(
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether a failed import from a plugin should try core's copy. */
|
||||
export function shouldUseHostModule(
|
||||
error: unknown,
|
||||
specifier: string,
|
||||
parentURL: string | undefined,
|
||||
pluginsDir: string,
|
||||
): boolean {
|
||||
const code = (error as { code?: string })?.code;
|
||||
// import() says ERR_MODULE_NOT_FOUND, a CommonJS require MODULE_NOT_FOUND.
|
||||
if (code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND")
|
||||
return false;
|
||||
return isBareSpecifier(specifier) && isUserPluginFile(parentURL, pluginsDir);
|
||||
}
|
||||
|
||||
export function installHostModuleFallback(): void {
|
||||
if (installed) return;
|
||||
installed = true;
|
||||
@@ -63,12 +49,9 @@ export function installHostModuleFallback(): void {
|
||||
return nextResolve(specifier, context);
|
||||
} catch (error) {
|
||||
if (
|
||||
!shouldUseHostModule(
|
||||
error,
|
||||
specifier,
|
||||
context.parentURL,
|
||||
getPluginsDir(),
|
||||
)
|
||||
(error as { code?: string })?.code !== "ERR_MODULE_NOT_FOUND" ||
|
||||
!isBareSpecifier(specifier) ||
|
||||
!isUserPluginFile(context.parentURL, getPluginsDir())
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ import {
|
||||
pluginFeatures,
|
||||
type PluginManifest,
|
||||
} from "./manifest.js";
|
||||
import { requireSignedPlugins, verifyAnyTrusted } from "./trust.js";
|
||||
import { requireSignedPlugins } from "./trust.js";
|
||||
import {
|
||||
parseChangelog,
|
||||
parseReleaseNotes,
|
||||
@@ -1048,15 +1048,7 @@ export function restoreMissingPlugins(): Promise<LoadedPlugin[]> {
|
||||
await readUninstalled(),
|
||||
);
|
||||
const restored: LoadedPlugin[] = [];
|
||||
// Shown on the Plugins tab, so a plugin that could not be fetched is not
|
||||
// listed as working. It stays on and is tried again on the next start.
|
||||
const markMissing = (id: string, message: string) =>
|
||||
recordState(
|
||||
id,
|
||||
"failed",
|
||||
`Not in this image and could not be downloaded: ${message}`,
|
||||
).catch(() => {});
|
||||
for (const [index, id] of ids.entries()) {
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const record = await repository.findById(id);
|
||||
const { source, release } = await requireRelease(
|
||||
@@ -1083,14 +1075,11 @@ export function restoreMissingPlugins(): Promise<LoadedPlugin[]> {
|
||||
pluginLogger.warn(`Could not restore ${id}: ${message}`, {
|
||||
operation: "plugin_restore_missing",
|
||||
});
|
||||
await markMissing(id, message);
|
||||
// The registry is down or offline, so the rest would fail the same way.
|
||||
if (
|
||||
error instanceof PluginManageError &&
|
||||
error.code === "REGISTRY_UNREACHABLE"
|
||||
) {
|
||||
for (const rest of ids.slice(index + 1))
|
||||
await markMissing(rest, message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1525,8 +1514,6 @@ interface StagedUpload {
|
||||
file: string;
|
||||
manifest: PluginManifest;
|
||||
expiresAt: number;
|
||||
/** Set when the upload came with a .sig an official key made. */
|
||||
official: boolean;
|
||||
}
|
||||
|
||||
const uploads = new Map<string, StagedUpload>();
|
||||
@@ -1550,29 +1537,6 @@ export interface UploadPreview {
|
||||
capabilities: string[];
|
||||
/** The version of an earlier upload this one replaces. */
|
||||
replaces: string | null;
|
||||
/** The .sig sent with it was made by the official Termix key. */
|
||||
official: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a .sig sent with an upload. An official signature makes the file
|
||||
* the same as a registry download, so it may stand in for one that could not
|
||||
* be fetched (an offline install). Anything else is refused.
|
||||
*/
|
||||
function checkUploadSignature(
|
||||
buffer: Buffer,
|
||||
signature: string,
|
||||
): { official: boolean } {
|
||||
const sha256 = crypto.createHash("sha256").update(buffer).digest("hex");
|
||||
const result = verifyAnyTrusted(buffer, sha256, signature);
|
||||
if (result.ok === false) {
|
||||
throw new PluginManageError(
|
||||
`The signature does not match this file: ${result.reason}`,
|
||||
400,
|
||||
"INVALID_SIGNATURE",
|
||||
);
|
||||
}
|
||||
return { official: result.tier === "official" };
|
||||
}
|
||||
|
||||
async function assertUploadsAllowed(): Promise<void> {
|
||||
@@ -1629,17 +1593,9 @@ function validateUpload(buffer: Buffer): PluginManifest {
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only another upload of the same id can be replaced from a file. An
|
||||
* officially signed file may also fill in a plugin whose code is missing, like
|
||||
* one that was on but could not be downloaded.
|
||||
*/
|
||||
async function assertReplaceable(
|
||||
pluginId: string,
|
||||
official = false,
|
||||
): Promise<string | null> {
|
||||
/** Only another upload of the same id can be replaced from a file. */
|
||||
async function assertReplaceable(pluginId: string): Promise<string | null> {
|
||||
const { loader } = getPluginRuntime();
|
||||
if (official && !loader.get(pluginId)) return null;
|
||||
if (loader.bundledIds().has(pluginId)) {
|
||||
throw new PluginManageError(
|
||||
`${pluginId} ships with Termix and can only be replaced by a signed release`,
|
||||
@@ -1664,30 +1620,21 @@ async function assertReplaceable(
|
||||
* Checks an uploaded .tmxplug and holds it until the admin agrees to what
|
||||
* it asks for. Nothing is loaded yet.
|
||||
*/
|
||||
export async function stageUpload(
|
||||
buffer: Buffer,
|
||||
signature: string | null = null,
|
||||
): Promise<UploadPreview> {
|
||||
export async function stageUpload(buffer: Buffer): Promise<UploadPreview> {
|
||||
await assertUploadsAllowed();
|
||||
await clearUploads(Date.now());
|
||||
const manifest = validateUpload(buffer);
|
||||
const { official } = signature
|
||||
? checkUploadSignature(buffer, signature)
|
||||
: { official: false };
|
||||
const replaces = await assertReplaceable(manifest.id, official);
|
||||
const replaces = await assertReplaceable(manifest.id);
|
||||
|
||||
const token = crypto.randomBytes(16).toString("hex");
|
||||
const dir = getDownloadsDir();
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
const file = path.join(dir, `upload-${token}.tmxplug`);
|
||||
await fs.promises.writeFile(file, buffer);
|
||||
// Next to the file, where the loader looks for it.
|
||||
if (signature) await fs.promises.writeFile(`${file}.sig`, signature);
|
||||
uploads.set(token, {
|
||||
file,
|
||||
manifest,
|
||||
expiresAt: Date.now() + UPLOAD_TTL_MS,
|
||||
official,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -1700,7 +1647,6 @@ export async function stageUpload(
|
||||
features: pluginFeatures(manifest.features),
|
||||
capabilities: [...manifest.capabilities],
|
||||
replaces,
|
||||
official,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1730,7 +1676,7 @@ export function installUpload(
|
||||
);
|
||||
}
|
||||
assertConsent(options.capabilities, manifest.capabilities);
|
||||
await assertReplaceable(pluginId, upload.official);
|
||||
await assertReplaceable(pluginId);
|
||||
|
||||
const { createCurrentPluginRepository } = await repos();
|
||||
const repository = createCurrentPluginRepository();
|
||||
@@ -1743,7 +1689,6 @@ export function installUpload(
|
||||
plugin = await swapIn(pluginId, { upload: upload.file });
|
||||
} catch (error) {
|
||||
await fs.promises.rm(upload.file, { force: true });
|
||||
await fs.promises.rm(`${upload.file}.sig`, { force: true });
|
||||
const { loader } = getPluginRuntime();
|
||||
if (before && wasEnabled && loader.get(pluginId)) {
|
||||
await startAndRecord(pluginId);
|
||||
@@ -1756,7 +1701,7 @@ export function installUpload(
|
||||
}
|
||||
|
||||
await repository.update(pluginId, {
|
||||
tier: upload.official ? "official" : UNVERIFIED_TIER,
|
||||
tier: UNVERIFIED_TIER,
|
||||
registryId: null,
|
||||
pinnedVersion: null,
|
||||
autoUpdate: false,
|
||||
@@ -1780,7 +1725,7 @@ export function installUpload(
|
||||
}
|
||||
const state = await startAndRecord(pluginId);
|
||||
pluginLogger.warn(
|
||||
`Installed ${upload.official ? "officially signed" : "unverified"} plugin ${pluginId}@${manifest.version} from a file`,
|
||||
`Installed unverified plugin ${pluginId}@${manifest.version} from a file`,
|
||||
{ operation: "plugin_install" },
|
||||
);
|
||||
return { id: pluginId, version: manifest.version, state };
|
||||
|
||||
@@ -16,7 +16,7 @@ import type {
|
||||
SyncRow,
|
||||
} from "@termix-ssh/plugin-sdk/backend";
|
||||
|
||||
/** Extras core sets for its own entities. A plugin may set the first four. */
|
||||
/** Core-only extras a plugin registration cannot set. */
|
||||
export interface CoreSyncEntityOptions {
|
||||
/** Server-to-desktop only. A desktop never pushes it. */
|
||||
readOnly?: boolean;
|
||||
@@ -58,54 +58,6 @@ export interface RegisteredSyncEntity
|
||||
encryptedFields: readonly string[];
|
||||
}
|
||||
|
||||
/** What a plugin registration may carry. Anything else is dropped. */
|
||||
const PLUGIN_KEYS = [
|
||||
"type",
|
||||
"table",
|
||||
"userColumn",
|
||||
"encryptedFields",
|
||||
"references",
|
||||
"order",
|
||||
"readOnlyFields",
|
||||
"singleton",
|
||||
"answersTo",
|
||||
"shouldSync",
|
||||
"permissions",
|
||||
"serialize",
|
||||
"deserialize",
|
||||
"afterWrite",
|
||||
"remove",
|
||||
"readOnly",
|
||||
"load",
|
||||
"write",
|
||||
"erase",
|
||||
] as const;
|
||||
|
||||
function pluginEntity(entity: SyncEntityRegistration): SyncEntityRegistration {
|
||||
const picked: Record<string, unknown> = {};
|
||||
for (const key of PLUGIN_KEYS) {
|
||||
if (entity[key] !== undefined) picked[key] = entity[key];
|
||||
}
|
||||
const out = picked as unknown as SyncEntityRegistration;
|
||||
if (out.load || out.write || out.erase) {
|
||||
if (!out.load || !out.write || !out.erase) {
|
||||
throw new Error(
|
||||
`Sync entity "${entity.type}" needs load, write and erase together`,
|
||||
);
|
||||
}
|
||||
// A plugin only ever syncs its own rows both ways; rows it builds for
|
||||
// a user come down as read only copies.
|
||||
if (out.readOnly !== true) {
|
||||
throw new Error(
|
||||
`Sync entity "${entity.type}" with load must be readOnly`,
|
||||
);
|
||||
}
|
||||
} else if (!out.table) {
|
||||
throw new Error(`Sync entity "${entity.type}" needs a table or load`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const entities = new Map<string, RegisteredSyncEntity>();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
@@ -129,7 +81,7 @@ export function registerEntity(
|
||||
}
|
||||
|
||||
const registered: RegisteredSyncEntity = {
|
||||
...(owner === CORE_OWNER ? entity : pluginEntity(entity)),
|
||||
...entity,
|
||||
...(owner === CORE_OWNER ? core : {}),
|
||||
owner,
|
||||
order: entity.order ?? 100,
|
||||
|
||||
@@ -38,7 +38,6 @@ import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||
import {
|
||||
createResolvers,
|
||||
deleteStoredRow,
|
||||
findStoredIds,
|
||||
listStoredRows,
|
||||
loadWireRow,
|
||||
loadWireRows,
|
||||
@@ -272,11 +271,6 @@ export function createSyncHost(): PluginSyncHost {
|
||||
ensureCore: () => ensureCoreEntities(),
|
||||
onChanged: (listener) => onEntitiesChanged(listener),
|
||||
onLocalWrite: (listener) => onLocalWrite(listener),
|
||||
idsOf: async (type, userId, syncIds) => {
|
||||
const entity = getEntity(type);
|
||||
if (!entity || entity.load) return {};
|
||||
return findStoredIds(entity, userId, syncIds);
|
||||
},
|
||||
},
|
||||
|
||||
replica: {
|
||||
|
||||
@@ -161,7 +161,7 @@ async function reconcileListeners(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export function notifyListeners(userId: string): void {
|
||||
function notifyListeners(userId: string): void {
|
||||
const set = listeners.get(userId);
|
||||
if (!set) return;
|
||||
for (const res of set) {
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
} from "../store.js";
|
||||
import { allocateSeq, getRecord, putRecord, recordKey } from "../records.js";
|
||||
import { orderSelfReferences } from "../wire.js";
|
||||
import { markChanged, notifyListeners, reconcileUser } from "./feed.js";
|
||||
import { reconcileUser } from "./feed.js";
|
||||
|
||||
export interface PushOp {
|
||||
entityType: string;
|
||||
@@ -278,12 +278,6 @@ export async function applyPush(
|
||||
|
||||
// Refresh the snapshot so the next pull serves what was just written.
|
||||
await reconcileUser(userId, { force: true });
|
||||
// The reconcile finds nothing new, since the push wrote the records itself,
|
||||
// so tell this user's other clients, and other users who may share a row.
|
||||
if ([...results.values()].some((result) => result.status === "ok")) {
|
||||
notifyListeners(userId);
|
||||
markChanged();
|
||||
}
|
||||
return ops.map(
|
||||
(op) =>
|
||||
results.get(recordKey(op.entityType, op.syncId)) ?? {
|
||||
|
||||
@@ -112,21 +112,6 @@ export async function findStoredRow(
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/** The local ids of the user's own rows, by sync id. Unknown ones are left out. */
|
||||
export async function findStoredIds(
|
||||
entity: RegisteredSyncEntity,
|
||||
userId: string,
|
||||
syncIds: string[],
|
||||
): Promise<Record<string, number>> {
|
||||
const ids: Record<string, number> = {};
|
||||
if (entity.singleton || !entity.table) return ids;
|
||||
for (const syncId of new Set(syncIds)) {
|
||||
const row = await findStoredRow(entity, userId, syncId);
|
||||
if (row && typeof row.id === "number") ids[syncId] = row.id;
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Gives an old row with no syncId one, so it can take part. */
|
||||
async function ensureSyncId(
|
||||
entity: RegisteredSyncEntity,
|
||||
|
||||
-98
@@ -1,98 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
folderAndParents,
|
||||
SharedFolderAuthOverrideRepository,
|
||||
} from "../../../database/repositories/shared-folder-auth-override-repository.js";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
|
||||
describe("folderAndParents", () => {
|
||||
it("lists the folder, then each folder above it", () => {
|
||||
expect(folderAndParents("Prod / Web / EU")).toEqual([
|
||||
"Prod / Web / EU",
|
||||
"Prod / Web",
|
||||
"Prod",
|
||||
]);
|
||||
expect(folderAndParents(" Prod ")).toEqual(["Prod"]);
|
||||
expect(folderAndParents("")).toEqual([]);
|
||||
expect(folderAndParents(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SharedFolderAuthOverrideRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await adapter?.close();
|
||||
adapter = null;
|
||||
});
|
||||
|
||||
async function createRepository(onWrite?: () => void) {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('recipient', 'recipient', 'hash'), ('other', 'other', 'hash');
|
||||
INSERT INTO ssh_credentials (id, user_id, name, auth_type)
|
||||
VALUES (7, 'recipient', 'cred-seven', 'password'),
|
||||
(8, 'recipient', 'cred-eight', 'password');
|
||||
`);
|
||||
return new SharedFolderAuthOverrideRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("sets, updates and clears a login per user, folder and protocol", async () => {
|
||||
let writes = 0;
|
||||
const repository = await createRepository(() => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
await repository.setCredential("recipient", "Prod", "ssh", 7);
|
||||
await repository.setCredential("recipient", "Prod", "ssh", 8);
|
||||
await repository.setCredential("recipient", "Prod", "rdp", 7);
|
||||
await expect(
|
||||
repository.findCredentialId("recipient", "Prod", "ssh"),
|
||||
).resolves.toBe(8);
|
||||
await expect(
|
||||
repository.findCredentialId("other", "Prod", "ssh"),
|
||||
).resolves.toBeNull();
|
||||
|
||||
await expect(
|
||||
repository.clearCredential("recipient", "Prod", "ssh"),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
repository.clearCredential("recipient", "Prod", "ssh"),
|
||||
).resolves.toBe(false);
|
||||
expect(writes).toBe(4);
|
||||
});
|
||||
|
||||
it("finds the login of the closest folder above a host", async () => {
|
||||
const repository = await createRepository();
|
||||
await repository.setCredential("recipient", "Prod", "ssh", 7);
|
||||
await repository.setCredential("recipient", "Prod / Web", "ssh", 8);
|
||||
|
||||
await expect(
|
||||
repository.findClosest("recipient", "Prod / Web / EU", "ssh"),
|
||||
).resolves.toEqual({ folder: "Prod / Web", credentialId: 8 });
|
||||
await expect(
|
||||
repository.findClosest("recipient", "Prod / DB", "ssh"),
|
||||
).resolves.toEqual({ folder: "Prod", credentialId: 7 });
|
||||
await expect(
|
||||
repository.findClosest("recipient", "Staging", "ssh"),
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
repository.findClosest("recipient", "Prod", "rdp"),
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
repository.findClosest("recipient", null, "ssh"),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("drops the login when its credential is deleted", async () => {
|
||||
const repository = await createRepository();
|
||||
await repository.setCredential("recipient", "Prod", "ssh", 7);
|
||||
await adapter!.run(sql`DELETE FROM ssh_credentials WHERE id = 7`);
|
||||
await expect(
|
||||
repository.findCredentialId("recipient", "Prod", "ssh"),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -214,43 +214,3 @@ describe("host.created", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("host color and icon", () => {
|
||||
async function stored(id: number) {
|
||||
const [row] = await ctx.drizzle
|
||||
.select({ color: hosts.color, icon: hosts.icon })
|
||||
.from(hosts)
|
||||
.where(eq(hosts.id, id));
|
||||
return row;
|
||||
}
|
||||
|
||||
it("saves them on create and keeps them when an update leaves them out", async () => {
|
||||
const created = await request(app)
|
||||
.post("/host/db/host")
|
||||
.send({
|
||||
...base,
|
||||
authType: "password",
|
||||
password: "pw",
|
||||
color: "#22C55E",
|
||||
icon: "database",
|
||||
});
|
||||
expect(created.status).toBe(200);
|
||||
expect(await stored(created.body.id)).toEqual({
|
||||
color: "#22c55e",
|
||||
icon: "database",
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.put(`/host/db/host/${created.body.id}`)
|
||||
.send({ ...base, name: "renamed" });
|
||||
expect(await stored(created.body.id)).toEqual({
|
||||
color: "#22c55e",
|
||||
icon: "database",
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.put(`/host/db/host/${created.body.id}`)
|
||||
.send({ ...base, color: null, icon: null });
|
||||
expect(await stored(created.body.id)).toEqual({ color: null, icon: null });
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
keepsStoredSshAuth,
|
||||
isOptionalBoolean,
|
||||
isValidPort,
|
||||
normalizeHostLookFields,
|
||||
normalizeImportedHost,
|
||||
normalizeProtocolEnableFields,
|
||||
renameFolderPath,
|
||||
@@ -530,25 +529,3 @@ describe("impliedSshAuthType", () => {
|
||||
expect(impliedSshAuthType({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeHostLookFields", () => {
|
||||
it("leaves both fields out when the write does not send them", () => {
|
||||
expect(normalizeHostLookFields({ name: "web" })).toEqual({});
|
||||
});
|
||||
|
||||
it("keeps a hex color and a Lucide name", () => {
|
||||
expect(
|
||||
normalizeHostLookFields({ color: "#3B82F6", icon: "server-cog" }),
|
||||
).toEqual({ color: "#3b82f6", icon: "server-cog" });
|
||||
});
|
||||
|
||||
it("stores null for cleared or bad values", () => {
|
||||
expect(normalizeHostLookFields({ color: "red", icon: "<script>" })).toEqual(
|
||||
{ color: null, icon: null },
|
||||
);
|
||||
expect(normalizeHostLookFields({ color: null, icon: "" })).toEqual({
|
||||
color: null,
|
||||
icon: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -488,35 +488,6 @@ describe("plugins route", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("lists a plugin's engine ranges to any user", async () => {
|
||||
state.plugins.set(
|
||||
"sample-plugin",
|
||||
makePlugin({
|
||||
manifestJson: JSON.stringify({
|
||||
engine: { api: "^1.13", termix: ">=26.10.6", extra: 1 },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
state.plugins.set(
|
||||
"bad-engine",
|
||||
makePlugin({
|
||||
id: "bad-engine",
|
||||
manifestJson: JSON.stringify({ engine: { api: 2 } }),
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await fetch(`${baseUrl}/plugins`, {
|
||||
headers: { "x-test-user-id": "regular-user" },
|
||||
});
|
||||
const body = (await res.json()) as Array<{ id: string; engine: unknown }>;
|
||||
|
||||
expect(body.find((p) => p.id === "sample-plugin")?.engine).toEqual({
|
||||
api: "^1.13",
|
||||
termix: ">=26.10.6",
|
||||
});
|
||||
expect(body.find((p) => p.id === "bad-engine")?.engine).toEqual({});
|
||||
});
|
||||
|
||||
it("reports an enabled plugin that safe mode keeps off", async () => {
|
||||
state.plugins.set("sample-plugin", makePlugin({ state: "enabled" }));
|
||||
|
||||
@@ -672,7 +643,6 @@ describe("plugins route", () => {
|
||||
enabled: true,
|
||||
state: "enabled",
|
||||
runsOnServer: false,
|
||||
engine: {},
|
||||
contributes: null,
|
||||
dependencies: {},
|
||||
optionalDependencies: {},
|
||||
|
||||
@@ -8,14 +8,9 @@ const state = vi.hoisted(() => ({
|
||||
isShared: true,
|
||||
isAdminBypass: false,
|
||||
},
|
||||
credentialUsable: true,
|
||||
credentialOwned: true,
|
||||
credentialId: 7 as number | null,
|
||||
writes: [] as Array<{ protocol: string; credentialId: number | null }>,
|
||||
folderWrites: [] as Array<{
|
||||
folder: string;
|
||||
protocol: string;
|
||||
credentialId: number | null;
|
||||
}>,
|
||||
auditCalls: [] as Array<Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
@@ -75,12 +70,11 @@ vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/usable-credential.js", () => ({
|
||||
findUsableCredential: async (credentialId: number) =>
|
||||
state.credentialUsable ? { id: credentialId } : null,
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentCredentialRepository: () => ({
|
||||
findByIdForUser: async () =>
|
||||
state.credentialOwned ? { id: state.credentialId } : null,
|
||||
}),
|
||||
createCurrentSharedHostAuthOverrideRepository: () => ({
|
||||
findCredentialId: async () => state.credentialId,
|
||||
setCredential: async (
|
||||
@@ -102,25 +96,6 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
createCurrentSharedFolderAuthOverrideRepository: () => ({
|
||||
findCredentialId: async () => 7,
|
||||
setCredential: async (
|
||||
_userId: string,
|
||||
folder: string,
|
||||
protocol: string,
|
||||
credentialId: number,
|
||||
) => {
|
||||
state.folderWrites.push({ folder, protocol, credentialId });
|
||||
},
|
||||
clearCredential: async (
|
||||
_userId: string,
|
||||
folder: string,
|
||||
protocol: string,
|
||||
) => {
|
||||
state.folderWrites.push({ folder, protocol, credentialId: null });
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async () => ({ id: "recipient", username: "recipient" }),
|
||||
}),
|
||||
@@ -133,7 +108,6 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
|
||||
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
|
||||
getAuditUsername: async (userId: string) => userId,
|
||||
logAudit: vi.fn(async (entry: Record<string, unknown>) => {
|
||||
state.auditCalls.push(entry);
|
||||
}),
|
||||
@@ -159,8 +133,6 @@ describe("shared host authentication override routes", () => {
|
||||
method: "get" | "put",
|
||||
body: Record<string, unknown> = {},
|
||||
protocol = "ssh",
|
||||
path = "/host-access/:hostId/auth/:protocol",
|
||||
query: Record<string, unknown> = {},
|
||||
): Promise<{ status: number; body: unknown }> {
|
||||
const routeLayer = (
|
||||
router as unknown as {
|
||||
@@ -179,14 +151,15 @@ describe("shared host authentication override routes", () => {
|
||||
}>;
|
||||
}
|
||||
).stack.find(
|
||||
(layer) => layer.route?.path === path && layer.route.methods[method],
|
||||
(layer) =>
|
||||
layer.route?.path === "/host-access/:hostId/auth/:protocol" &&
|
||||
layer.route.methods[method],
|
||||
);
|
||||
if (!routeLayer?.route) throw new Error(`Missing ${method} route`);
|
||||
|
||||
const handlers = routeLayer.route.stack.map((layer) => layer.handle);
|
||||
const req = {
|
||||
params: { hostId: "42", protocol },
|
||||
query,
|
||||
body,
|
||||
headers: {},
|
||||
ip: "127.0.0.1",
|
||||
@@ -233,106 +206,12 @@ describe("shared host authentication override routes", () => {
|
||||
isShared: true,
|
||||
isAdminBypass: false,
|
||||
};
|
||||
state.credentialUsable = true;
|
||||
state.credentialOwned = true;
|
||||
state.credentialId = 7;
|
||||
state.writes = [];
|
||||
state.folderWrites = [];
|
||||
state.auditCalls = [];
|
||||
});
|
||||
|
||||
describe("folder logins", () => {
|
||||
const FOLDER = "/folder-auth/:protocol";
|
||||
|
||||
it("reads the login for a folder", async () => {
|
||||
const response = await invoke("get", {}, "ssh", FOLDER, {
|
||||
folder: " Prod / Web ",
|
||||
});
|
||||
expect(response).toEqual({
|
||||
status: 200,
|
||||
body: { folder: "Prod / Web", protocol: "ssh", credentialId: 7 },
|
||||
});
|
||||
});
|
||||
|
||||
it("sets and clears the login and audits both", async () => {
|
||||
expect(
|
||||
(
|
||||
await invoke(
|
||||
"put",
|
||||
{ folder: "Prod", credentialId: 8 },
|
||||
"ssh",
|
||||
FOLDER,
|
||||
)
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await invoke(
|
||||
"put",
|
||||
{ folder: "Prod", credentialId: null },
|
||||
"ssh",
|
||||
FOLDER,
|
||||
)
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(state.folderWrites).toEqual([
|
||||
{ folder: "Prod", protocol: "ssh", credentialId: 8 },
|
||||
{ folder: "Prod", protocol: "ssh", credentialId: null },
|
||||
]);
|
||||
expect(state.auditCalls.map((call) => call.action)).toEqual([
|
||||
"set_shared_folder_auth_override",
|
||||
"clear_shared_folder_auth_override",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects a bad folder, protocol or credential", async () => {
|
||||
expect(
|
||||
(await invoke("get", {}, "ssh", FOLDER, { folder: "" })).status,
|
||||
).toBe(400);
|
||||
expect(
|
||||
(
|
||||
await invoke(
|
||||
"put",
|
||||
{ folder: "x".repeat(300), credentialId: 8 },
|
||||
"ssh",
|
||||
FOLDER,
|
||||
)
|
||||
).status,
|
||||
).toBe(400);
|
||||
expect(
|
||||
(
|
||||
await invoke(
|
||||
"put",
|
||||
{ folder: "Prod", credentialId: 8 },
|
||||
"smtp",
|
||||
FOLDER,
|
||||
)
|
||||
).status,
|
||||
).toBe(400);
|
||||
expect(
|
||||
(
|
||||
await invoke(
|
||||
"put",
|
||||
{ folder: "Prod", credentialId: -1 },
|
||||
"ssh",
|
||||
FOLDER,
|
||||
)
|
||||
).status,
|
||||
).toBe(400);
|
||||
state.credentialUsable = false;
|
||||
expect(
|
||||
(
|
||||
await invoke(
|
||||
"put",
|
||||
{ folder: "Prod", credentialId: 99 },
|
||||
"ssh",
|
||||
FOLDER,
|
||||
)
|
||||
).status,
|
||||
).toBe(404);
|
||||
expect(state.folderWrites).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the current override for a role-derived shared recipient", async () => {
|
||||
const response = await invoke("get");
|
||||
expect(response).toEqual({
|
||||
@@ -375,7 +254,7 @@ describe("shared host authentication override routes", () => {
|
||||
const invalidResponse = await invoke("put", { credentialId: 0 });
|
||||
expect(invalidResponse.status).toBe(400);
|
||||
|
||||
state.credentialUsable = false;
|
||||
state.credentialOwned = false;
|
||||
const foreignResponse = await invoke("put", { credentialId: 99 });
|
||||
expect(foreignResponse.status).toBe(404);
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const { pruneHostGrants, pruneCredentialGrants, grantsForFolderRule } =
|
||||
const { pruneHostGrants, pruneCredentialGrants } =
|
||||
await import("../../../database/routes/rbac.js");
|
||||
|
||||
const chain = (): Grant[] => [
|
||||
@@ -105,27 +105,3 @@ describe("share pruning", () => {
|
||||
expect(state.credentialGrants.map((g) => g.userId)).toEqual(["dave"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("grantsForFolderRule", () => {
|
||||
const entries = [
|
||||
{ id: 1, userId: "bob", roleId: null },
|
||||
{ id: 2, userId: null, roleId: 7 },
|
||||
{ id: 3, userId: "carol", roleId: null },
|
||||
];
|
||||
|
||||
it("picks the grants of the rule's user or role", () => {
|
||||
expect(
|
||||
grantsForFolderRule(entries, { userId: "bob", roleId: null }).map(
|
||||
(e) => e.id,
|
||||
),
|
||||
).toEqual([1]);
|
||||
expect(
|
||||
grantsForFolderRule(entries, { userId: null, roleId: 7 }).map(
|
||||
(e) => e.id,
|
||||
),
|
||||
).toEqual([2]);
|
||||
expect(
|
||||
grantsForFolderRule(entries, { userId: null, roleId: null }),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { Request, RequestHandler, Response } from "express";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
users: new Map<
|
||||
string,
|
||||
{
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
isExternal: boolean;
|
||||
passwordHash: string;
|
||||
}
|
||||
>(),
|
||||
adminCount: 1,
|
||||
deleted: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/db/index.js", () => ({ db: {} }));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
authLogger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("bcryptjs", () => ({
|
||||
default: {
|
||||
compare: async (password: string, hash: string) =>
|
||||
hash === `hash:${password}`,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async (id: string) => state.users.get(id) ?? null,
|
||||
countAdmins: async () => state.adminCount,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/routes/delete-user-data.js", () => ({
|
||||
deleteUserAndRelatedData: async (userId: string) => {
|
||||
state.deleted.push(userId);
|
||||
},
|
||||
}));
|
||||
|
||||
const { registerUserDeleteAccountRoutes } =
|
||||
await import("../../../database/routes/user-delete-account-routes.js");
|
||||
|
||||
let handler: RequestHandler | null = null;
|
||||
registerUserDeleteAccountRoutes(
|
||||
{
|
||||
delete: (_path: string, ...handlers: RequestHandler[]) => {
|
||||
handler = handlers[handlers.length - 1];
|
||||
},
|
||||
} as unknown as import("express").Router,
|
||||
(_req, _res, next) => next(),
|
||||
);
|
||||
|
||||
async function deleteAccount(userId: string, body: Record<string, unknown>) {
|
||||
const req = { userId, body } as unknown as Request;
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
jsonBody: null as Record<string, unknown> | null,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload: Record<string, unknown>) {
|
||||
this.jsonBody = payload;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
await handler!(req, res as unknown as Response, () => {});
|
||||
return res;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.adminCount = 1;
|
||||
state.deleted = [];
|
||||
state.users = new Map([
|
||||
[
|
||||
"u1",
|
||||
{
|
||||
id: "u1",
|
||||
username: "user",
|
||||
isAdmin: false,
|
||||
isExternal: false,
|
||||
passwordHash: "hash:secret",
|
||||
},
|
||||
],
|
||||
[
|
||||
"admin1",
|
||||
{
|
||||
id: "admin1",
|
||||
username: "admin",
|
||||
isAdmin: true,
|
||||
isExternal: false,
|
||||
passwordHash: "hash:secret",
|
||||
},
|
||||
],
|
||||
[
|
||||
"sso1",
|
||||
{
|
||||
id: "sso1",
|
||||
username: "sso",
|
||||
isAdmin: false,
|
||||
isExternal: true,
|
||||
passwordHash: "hash:secret",
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
describe("DELETE /delete-account", () => {
|
||||
it("runs the full user cleanup", async () => {
|
||||
const res = await deleteAccount("u1", { password: "secret" });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(state.deleted).toEqual(["u1"]);
|
||||
});
|
||||
|
||||
it("needs a password", async () => {
|
||||
const res = await deleteAccount("u1", {});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(state.deleted).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a wrong password", async () => {
|
||||
const res = await deleteAccount("u1", { password: "nope" });
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(state.deleted).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses external accounts", async () => {
|
||||
const res = await deleteAccount("sso1", { password: "secret" });
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(state.deleted).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses the last admin", async () => {
|
||||
const res = await deleteAccount("admin1", { password: "secret" });
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(state.deleted).toEqual([]);
|
||||
});
|
||||
|
||||
it("lets an admin go when another admin is left", async () => {
|
||||
state.adminCount = 2;
|
||||
const res = await deleteAccount("admin1", { password: "secret" });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(state.deleted).toEqual(["admin1"]);
|
||||
});
|
||||
|
||||
it("404s for an unknown user", async () => {
|
||||
const res = await deleteAccount("ghost", { password: "secret" });
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -37,29 +37,6 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("ssh transport providers", () => {
|
||||
it("tells providers the owner, sync id and origin of the host", async () => {
|
||||
const claims = vi.fn(() => false);
|
||||
provideSshTransport("a", { claims, open: vi.fn() });
|
||||
await openSshTransport(
|
||||
{
|
||||
...host,
|
||||
userId: "u1",
|
||||
syncId: "s1",
|
||||
connectionOrigin: "remote",
|
||||
} as never,
|
||||
{} as never,
|
||||
).catch(() => {});
|
||||
expect(claims).toHaveBeenCalledWith(7, {
|
||||
id: 7,
|
||||
ip: "10.0.0.7",
|
||||
port: 22,
|
||||
userId: "u1",
|
||||
syncId: "s1",
|
||||
connectionOrigin: "remote",
|
||||
localOnly: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("opens nothing when no provider claims the host", async () => {
|
||||
provideSshTransport("a", { claims: () => false, open: vi.fn() });
|
||||
expect(await openProvidedTransport(host)).toBeNull();
|
||||
|
||||
@@ -6,7 +6,6 @@ const state = vi.hoisted(() => ({
|
||||
isAdminBypass: false,
|
||||
overrideCredentialId: null as number | null,
|
||||
credentials: new Map<string, Record<string, unknown>>(),
|
||||
sharedCredentials: new Map<string, Record<string, unknown>>(),
|
||||
auditCalls: [] as Record<string, unknown>[],
|
||||
sharedSecret: null as Record<string, unknown> | null,
|
||||
activeHostAccessId: null as number | null,
|
||||
@@ -38,13 +37,6 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../hosts/usable-credential.js", () => ({
|
||||
findUsableCredential: async (credentialId: number, userId: string) =>
|
||||
state.credentials.get(`${credentialId}:${userId}`) ??
|
||||
state.sharedCredentials.get(`${credentialId}:${userId}`) ??
|
||||
null,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (params: Record<string, unknown>) => {
|
||||
state.auditCalls.push(params);
|
||||
@@ -133,7 +125,6 @@ beforeEach(() => {
|
||||
state.isAdminBypass = false;
|
||||
state.overrideCredentialId = null;
|
||||
state.credentials.clear();
|
||||
state.sharedCredentials.clear();
|
||||
state.auditCalls = [];
|
||||
state.sharedSecret = null;
|
||||
state.activeHostAccessId = null;
|
||||
@@ -385,61 +376,6 @@ describe("resolveHostById", () => {
|
||||
expect(host.username).toBe("shared-login");
|
||||
});
|
||||
|
||||
it("uses a credential shared with the recipient as their personal override", async () => {
|
||||
state.overrideCredentialId = 7;
|
||||
state.sharedCredentials.set("7:recipient", {
|
||||
id: 7,
|
||||
username: "shared-cred-user",
|
||||
password: "shared-cred-pass",
|
||||
privateKey: null,
|
||||
key: null,
|
||||
keyPassword: null,
|
||||
keyType: null,
|
||||
});
|
||||
|
||||
const host = (await resolveHostById(42, "recipient")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(host.username).toBe("shared-cred-user");
|
||||
expect(host.password).toBe("shared-cred-pass");
|
||||
});
|
||||
|
||||
it("uses the host's credential when it is also shared with the recipient", async () => {
|
||||
state.host = baseHost({
|
||||
authType: "credential",
|
||||
password: null,
|
||||
credentialId: 1,
|
||||
});
|
||||
state.sharedCredentials.set("1:recipient", {
|
||||
id: 1,
|
||||
username: "deploy",
|
||||
password: null,
|
||||
privateKey: "OWNER-KEY-COPY",
|
||||
key: null,
|
||||
keyPassword: null,
|
||||
keyType: "ssh-ed25519",
|
||||
});
|
||||
|
||||
const host = (await resolveHostById(42, "recipient")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(host.authType).toBe("key");
|
||||
expect(host.key).toBe("OWNER-KEY-COPY");
|
||||
expect(host.username).toBe("deploy");
|
||||
});
|
||||
|
||||
it("denies a recipient when the host's credential is not shared with them", async () => {
|
||||
state.host = baseHost({
|
||||
authType: "credential",
|
||||
password: null,
|
||||
credentialId: 1,
|
||||
});
|
||||
|
||||
expect(await resolveHostById(42, "recipient")).toBeNull();
|
||||
});
|
||||
|
||||
it("denies a non-owner when a secret-bearing host has no personal credential", async () => {
|
||||
expect(await resolveHostById(42, "recipient")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ import { WebSocket, WebSocketServer } from "ws";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolveHostById: vi.fn(),
|
||||
resolveHostBySyncId: vi.fn(),
|
||||
openSshTransport: vi.fn(),
|
||||
handler: null as null | ((connection: unknown) => Promise<void>),
|
||||
}));
|
||||
@@ -18,7 +17,6 @@ vi.mock("../../utils/logger.js", () => {
|
||||
});
|
||||
vi.mock("../../hosts/host-resolver.js", () => ({
|
||||
resolveHostById: mocks.resolveHostById,
|
||||
resolveHostBySyncId: mocks.resolveHostBySyncId,
|
||||
}));
|
||||
vi.mock("../../hosts/connect/transport.js", () => ({
|
||||
openSshTransport: mocks.openSshTransport,
|
||||
@@ -116,23 +114,6 @@ describe("ssh relay", () => {
|
||||
await vi.waitFor(() => expect(end).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("finds the host by sync id for a linked desktop", async () => {
|
||||
mocks.resolveHostBySyncId.mockResolvedValue({
|
||||
id: 9,
|
||||
ip: "127.0.0.1",
|
||||
port: echoPort,
|
||||
});
|
||||
const { socket } = await open("?syncId=abc");
|
||||
const received = new Promise<string>((resolve) =>
|
||||
socket.once("message", (data) => resolve(data.toString())),
|
||||
);
|
||||
socket.send("ping");
|
||||
expect(await received).toBe("ping");
|
||||
expect(mocks.resolveHostBySyncId).toHaveBeenCalledWith("abc", "user-1");
|
||||
expect(mocks.resolveHostById).not.toHaveBeenCalled();
|
||||
socket.close();
|
||||
});
|
||||
|
||||
it("refuses a host the user can't connect to", async () => {
|
||||
mocks.resolveHostById.mockResolvedValue(null);
|
||||
const { closed } = await open("?hostId=5");
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parsePlatformProbe,
|
||||
@@ -8,7 +7,6 @@ import {
|
||||
buildSudoCommand,
|
||||
parseUpgradable,
|
||||
execCommand,
|
||||
decodeCommandOutput,
|
||||
} from "@termix-ssh/plugin-sdk/host-commands";
|
||||
|
||||
describe("parsePlatformProbe", () => {
|
||||
@@ -118,32 +116,6 @@ describe("parseUpgradable", () => {
|
||||
});
|
||||
});
|
||||
|
||||
class FakeStream extends EventEmitter {
|
||||
stderr = new EventEmitter();
|
||||
destroy() {}
|
||||
}
|
||||
|
||||
function fakeClient(script: (stream: FakeStream) => void) {
|
||||
return {
|
||||
exec: (
|
||||
_command: string,
|
||||
_options: unknown,
|
||||
callback: (err: Error | undefined, stream: FakeStream) => void,
|
||||
) => {
|
||||
const stream = new FakeStream();
|
||||
callback(undefined, stream);
|
||||
script(stream);
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
// "The system cannot find the path specified." as the GBK bytes a
|
||||
// Chinese-locale Windows console actually sends.
|
||||
const GBK_PATH_ERROR = Buffer.from(
|
||||
"cfb5cdb3d5d2b2bbb5bdd6b8b6a8b5c4c2b7beb6a1a3",
|
||||
"hex",
|
||||
);
|
||||
|
||||
describe("execCommand", () => {
|
||||
it("leaves the command out of the timeout error", async () => {
|
||||
const client = { exec: () => {} } as never;
|
||||
@@ -152,64 +124,4 @@ describe("execCommand", () => {
|
||||
/^Command timeout after 5ms$/,
|
||||
);
|
||||
});
|
||||
|
||||
it("decodes GBK stderr from a localized Windows host", async () => {
|
||||
const result = await execCommand(
|
||||
fakeClient((stream) => {
|
||||
stream.stderr.emit("data", GBK_PATH_ERROR.subarray(0, 7));
|
||||
stream.stderr.emit("data", GBK_PATH_ERROR.subarray(7));
|
||||
stream.emit("close", 1);
|
||||
}),
|
||||
"dir",
|
||||
);
|
||||
expect(result.stderr).toBe("系统找不到指定的路径。");
|
||||
expect(result.code).toBe(1);
|
||||
});
|
||||
|
||||
it("reassembles a UTF-8 character split across chunks", async () => {
|
||||
const utf8 = Buffer.from("以太网 2", "utf8");
|
||||
const result = await execCommand(
|
||||
fakeClient((stream) => {
|
||||
stream.emit("data", utf8.subarray(0, 4)); // cuts 太 in half
|
||||
stream.emit("data", utf8.subarray(4));
|
||||
stream.emit("close", 0);
|
||||
}),
|
||||
"ipconfig",
|
||||
);
|
||||
expect(result.stdout).toBe("以太网 2");
|
||||
});
|
||||
|
||||
it("passes plain ASCII output through unchanged", async () => {
|
||||
const result = await execCommand(
|
||||
fakeClient((stream) => {
|
||||
stream.emit("data", Buffer.from("ok\n"));
|
||||
stream.stderr.emit("data", Buffer.from("warn\n"));
|
||||
stream.emit("close", 0);
|
||||
}),
|
||||
"echo ok",
|
||||
);
|
||||
expect(result).toEqual({ stdout: "ok\n", stderr: "warn\n", code: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeCommandOutput", () => {
|
||||
it("keeps valid UTF-8, including the empty buffer", () => {
|
||||
expect(decodeCommandOutput(Buffer.from("以太网 2", "utf8"))).toBe(
|
||||
"以太网 2",
|
||||
);
|
||||
expect(decodeCommandOutput(Buffer.alloc(0))).toBe("");
|
||||
});
|
||||
|
||||
it("falls back to GBK when the bytes are not valid UTF-8", () => {
|
||||
// "以太网 2" in GBK, the interface name of the metrics widget issue.
|
||||
expect(decodeCommandOutput(Buffer.from("d2d4ccabcdf82032", "hex"))).toBe(
|
||||
"以太网 2",
|
||||
);
|
||||
expect(decodeCommandOutput(GBK_PATH_ERROR)).toBe("系统找不到指定的路径。");
|
||||
});
|
||||
|
||||
it("keeps lossy UTF-8 when no decoder matches", () => {
|
||||
// 0xff is a valid byte in neither UTF-8 nor GBK.
|
||||
expect(decodeCommandOutput(Buffer.from([0x68, 0x69, 0xff]))).toBe("hi�");
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,6 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
isBareSpecifier,
|
||||
isUserPluginFile,
|
||||
shouldUseHostModule,
|
||||
} from "../../plugins/host-modules.js";
|
||||
|
||||
describe("isBareSpecifier", () => {
|
||||
@@ -47,36 +46,6 @@ describe("isUserPluginFile", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldUseHostModule", () => {
|
||||
const root = path.resolve(os.tmpdir(), "termix-data", "plugins");
|
||||
const parent = pathToFileURL(
|
||||
path.join(root, ".unpacked", "demo", "dist", "backend.js"),
|
||||
).href;
|
||||
|
||||
it("falls back for import and require misses from a plugin", () => {
|
||||
for (const code of ["ERR_MODULE_NOT_FOUND", "MODULE_NOT_FOUND"]) {
|
||||
expect(shouldUseHostModule({ code }, "axios", parent, root)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves other errors and files alone", () => {
|
||||
expect(
|
||||
shouldUseHostModule({ code: "ERR_REQUIRE_ESM" }, "axios", parent, root),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldUseHostModule({ code: "MODULE_NOT_FOUND" }, "./x.js", parent, root),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldUseHostModule(
|
||||
{ code: "MODULE_NOT_FOUND" },
|
||||
"axios",
|
||||
undefined,
|
||||
root,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("installHostModuleFallback", () => {
|
||||
let dataDir: string | null = null;
|
||||
|
||||
|
||||
@@ -902,57 +902,6 @@ describe("installing from a file", () => {
|
||||
expect(db.rows.get("notes")?.tier).toBe(manage.UNVERIFIED_TIER);
|
||||
});
|
||||
|
||||
it("refuses a signature that does not match the file", async () => {
|
||||
await boot();
|
||||
await manage.setDeveloperMode(true);
|
||||
await expect(
|
||||
manage.stageUpload(await artifactBuffer("notes"), "bm90IGEgc2ln"),
|
||||
).rejects.toMatchObject({ code: "INVALID_SIGNATURE" });
|
||||
});
|
||||
|
||||
it("keeps a signature from a non official key unverified", async () => {
|
||||
const { setCustomPluginKeys } = await import("../../plugins/trust.js");
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||
setCustomPluginKeys([
|
||||
{
|
||||
id: "mine",
|
||||
publicKey: publicKey
|
||||
.export({ format: "der", type: "spki" })
|
||||
.subarray(12)
|
||||
.toString("base64"),
|
||||
addedIn: "26.10.6",
|
||||
},
|
||||
]);
|
||||
try {
|
||||
await boot();
|
||||
await manage.setDeveloperMode(true);
|
||||
setCustomPluginKeys([
|
||||
{
|
||||
id: "mine",
|
||||
publicKey: publicKey
|
||||
.export({ format: "der", type: "spki" })
|
||||
.subarray(12)
|
||||
.toString("base64"),
|
||||
addedIn: "26.10.6",
|
||||
},
|
||||
]);
|
||||
const buffer = await artifactBuffer("notes");
|
||||
const digest = crypto.createHash("sha256").update(buffer).digest();
|
||||
const signature = crypto
|
||||
.sign(null, digest, privateKey)
|
||||
.toString("base64");
|
||||
const preview = await manage.stageUpload(buffer, signature);
|
||||
expect(preview.official).toBe(false);
|
||||
await manage.installUpload(preview.token, {
|
||||
userId: "admin",
|
||||
capabilities: preview.capabilities,
|
||||
});
|
||||
expect(db.rows.get("notes")?.tier).toBe(manage.UNVERIFIED_TIER);
|
||||
} finally {
|
||||
setCustomPluginKeys([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a file that is not a plugin archive", async () => {
|
||||
await boot();
|
||||
await manage.setDeveloperMode(true);
|
||||
|
||||
@@ -650,110 +650,6 @@ describe("panels, dashboard cards and guest pages", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("contributes.mobile", () => {
|
||||
const tabs = [
|
||||
{ id: "sample", titleKey: "nav.sample", icon: "Box", openFrom: ["rail"] },
|
||||
];
|
||||
|
||||
it("accepts each mode with its own fields", () => {
|
||||
for (const mobile of [
|
||||
{
|
||||
mode: "webview",
|
||||
entry: "dist/mobile.js",
|
||||
sizes: ["phone", "tablet"],
|
||||
views: ["sample"],
|
||||
note: "mobile.note",
|
||||
},
|
||||
{ mode: "webview" },
|
||||
{ mode: "native", feature: "file-manager" },
|
||||
{ mode: "none", note: "Not on phones" },
|
||||
]) {
|
||||
expect(validateManifest(base({ contributes: { tabs, mobile } }))).toEqual(
|
||||
[],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("needs a known mode and rejects unknown fields", () => {
|
||||
const errors = validateManifest(
|
||||
base({ contributes: { mobile: { mode: "app", width: 3 } } }),
|
||||
).join();
|
||||
expect(errors).toMatch(/Unknown field "width" in "contributes\.mobile"/);
|
||||
expect(errors).toMatch(/contributes\.mobile\.mode must be one of/);
|
||||
expect(
|
||||
validateManifest(base({ contributes: { mobile: "webview" } })).join(),
|
||||
).toMatch(/"contributes\.mobile" must be an object/);
|
||||
});
|
||||
|
||||
it("keeps webview and native fields to their own mode", () => {
|
||||
const errors = validateManifest(
|
||||
base({
|
||||
contributes: {
|
||||
tabs,
|
||||
mobile: {
|
||||
mode: "native",
|
||||
entry: "dist/mobile.js",
|
||||
views: ["sample"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).join();
|
||||
expect(errors).toMatch(/mobile\.entry is only for mode "webview"/);
|
||||
expect(errors).toMatch(/mobile\.views is only for mode "webview"/);
|
||||
expect(
|
||||
validateManifest(
|
||||
base({ contributes: { mobile: { mode: "webview", feature: "x1" } } }),
|
||||
).join(),
|
||||
).toMatch(/mobile\.feature is only for mode "native"/);
|
||||
});
|
||||
|
||||
it("puts the entry beside the frontend bundle", () => {
|
||||
const check = (entry: string, frontend?: string) =>
|
||||
validateManifest(
|
||||
base({
|
||||
...(frontend ? { frontend } : {}),
|
||||
contributes: { mobile: { mode: "webview", entry } },
|
||||
}),
|
||||
).join();
|
||||
expect(check("dist/mobile.js")).toBe("");
|
||||
expect(check("./dist/mobile.js")).toBe("");
|
||||
expect(check("build/mobile.js", "build/frontend.js")).toBe("");
|
||||
expect(check("mobile.js")).toMatch(/same folder as the frontend bundle/);
|
||||
expect(check("dist/mobile.css")).toMatch(/entry must be a \.js file/);
|
||||
expect(check("../dist/mobile.js")).toMatch(/relative path inside/);
|
||||
});
|
||||
|
||||
it("checks sizes, views and the feature id", () => {
|
||||
const errors = validateManifest(
|
||||
base({
|
||||
contributes: {
|
||||
tabs,
|
||||
panels: [{ id: "side", titleKey: "nav.side" }],
|
||||
mobile: {
|
||||
mode: "webview",
|
||||
sizes: ["phone", "phone", "watch"],
|
||||
views: ["sample", "side", "missing"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).join();
|
||||
expect(errors).toMatch(/sizes duplicates "phone"/);
|
||||
expect(errors).toMatch(/sizes entries must be one of: phone, tablet/);
|
||||
expect(errors).toMatch(/views entries must be ids .*"missing"/);
|
||||
expect(errors).not.toMatch(/"side"/);
|
||||
expect(
|
||||
validateManifest(
|
||||
base({ contributes: { mobile: { mode: "webview", sizes: [] } } }),
|
||||
).join(),
|
||||
).toMatch(/sizes must be a non-empty array/);
|
||||
expect(
|
||||
validateManifest(
|
||||
base({ contributes: { mobile: { mode: "native", feature: "Files" } } }),
|
||||
).join(),
|
||||
).toMatch(/mobile\.feature must match/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("named service providers", () => {
|
||||
const provides = (names: unknown) => [
|
||||
{
|
||||
|
||||
@@ -117,56 +117,6 @@ describe("entity aliases", () => {
|
||||
expect(getEntity("thing")?.readOnly).toBeUndefined();
|
||||
expect(getEntity("thing")?.load).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lets a plugin sync rows it builds as read only copies", () => {
|
||||
const load = async () => [];
|
||||
registerEntity("demo", {
|
||||
type: "sharedIcons",
|
||||
table: null,
|
||||
readOnly: true,
|
||||
load,
|
||||
write: async () => undefined,
|
||||
erase: async () => undefined,
|
||||
});
|
||||
expect(getEntity("sharedIcons")).toMatchObject({ readOnly: true, load });
|
||||
});
|
||||
|
||||
it("refuses a plugin entity that loads rows but can be pushed", () => {
|
||||
expect(() =>
|
||||
registerEntity("demo", {
|
||||
type: "pushable",
|
||||
table: null,
|
||||
load: async () => [],
|
||||
write: async () => undefined,
|
||||
erase: async () => undefined,
|
||||
}),
|
||||
).toThrow(/readOnly/);
|
||||
expect(() =>
|
||||
registerEntity("demo", {
|
||||
type: "halfDone",
|
||||
table: null,
|
||||
readOnly: true,
|
||||
load: async () => [],
|
||||
}),
|
||||
).toThrow(/load, write and erase/);
|
||||
expect(() =>
|
||||
registerEntity("demo", { type: "nothing", table: null }),
|
||||
).toThrow(/table or load/);
|
||||
});
|
||||
|
||||
it("drops core only keys a plugin puts on its registration", () => {
|
||||
registerEntity("demo", {
|
||||
type: "sneaky",
|
||||
table: {},
|
||||
covers: () => true,
|
||||
canReference: async () => true,
|
||||
hashIgnore: ["x"],
|
||||
} as never);
|
||||
const entity = getEntity("sneaky")!;
|
||||
expect(entity.covers).toBeUndefined();
|
||||
expect(entity.canReference).toBeUndefined();
|
||||
expect(entity.hashIgnore).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("core sync entities", () => {
|
||||
|
||||
@@ -8,8 +8,6 @@ const mocks = vi.hoisted(() => ({
|
||||
getLink: vi.fn(),
|
||||
generateJWTToken: vi.fn(),
|
||||
revokeSession: vi.fn(),
|
||||
getEntity: vi.fn(),
|
||||
findStoredIds: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/auth-manager.js", () => ({
|
||||
@@ -30,7 +28,7 @@ vi.mock("../../utils/app-version.js", () => ({
|
||||
vi.mock("../../notify/core-notify.js", () => ({ sendCoreAlert: vi.fn() }));
|
||||
vi.mock("../../plugins/index.js", () => ({ getPluginRuntime: vi.fn() }));
|
||||
vi.mock("../../plugins/sync-registry.js", () => ({
|
||||
getEntity: mocks.getEntity,
|
||||
getEntity: vi.fn(),
|
||||
listEntities: () => [{ type: "hosts", owner: "core", order: 10 }],
|
||||
onEntitiesChanged: vi.fn(),
|
||||
}));
|
||||
@@ -57,9 +55,7 @@ vi.mock("../../utils/permission-manager.js", () => ({
|
||||
vi.mock("../../sync/entities.js", () => ({
|
||||
registerCoreSyncEntities: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../sync/store.js", () => ({
|
||||
findStoredIds: mocks.findStoredIds,
|
||||
}));
|
||||
vi.mock("../../sync/store.js", () => ({}));
|
||||
vi.mock("../../sync/wire.js", () => ({}));
|
||||
vi.mock("../../sync/records.js", () => ({
|
||||
listChanges: mocks.listChanges,
|
||||
@@ -219,22 +215,3 @@ describe("host.links", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("host.entities.idsOf", () => {
|
||||
it("maps sync ids to the user's own row ids", async () => {
|
||||
const entity = { type: "snippets", table: {} };
|
||||
mocks.getEntity.mockReturnValue(entity);
|
||||
mocks.findStoredIds.mockResolvedValue({ a: 3 });
|
||||
expect(
|
||||
await createSyncHost().entities.idsOf!("snippets", "u", ["a", "b"]),
|
||||
).toEqual({ a: 3 });
|
||||
expect(mocks.findStoredIds).toHaveBeenCalledWith(entity, "u", ["a", "b"]);
|
||||
});
|
||||
|
||||
it("answers nothing for an unknown or built type", async () => {
|
||||
mocks.getEntity.mockReturnValue(undefined);
|
||||
expect(await createSyncHost().entities.idsOf!("x", "u", ["a"])).toEqual({});
|
||||
mocks.getEntity.mockReturnValue({ type: "y", load: vi.fn() });
|
||||
expect(await createSyncHost().entities.idsOf!("y", "u", ["a"])).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
authLogger: { info: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
const { renderNginxTemplate } = await import("../../tls/nginx-reload.js");
|
||||
|
||||
const TEMPLATE = [
|
||||
"listen ${PORT};",
|
||||
"listen ${SSL_PORT} ssl;",
|
||||
"ssl_certificate ${SSL_CERT_PATH};",
|
||||
" root /app/html;",
|
||||
"pid /tmp/nginx/nginx.pid;",
|
||||
].join("\n");
|
||||
|
||||
const values = {
|
||||
port: "8080",
|
||||
sslPort: "443",
|
||||
certPath: "/data/ssl/termix.crt",
|
||||
keyPath: "/data/ssl/termix.key",
|
||||
};
|
||||
|
||||
describe("renderNginxTemplate", () => {
|
||||
it("fills the ports and certificate", () => {
|
||||
const out = renderNginxTemplate(TEMPLATE, values);
|
||||
expect(out).toContain("listen 8080;");
|
||||
expect(out).toContain("listen 443 ssl;");
|
||||
expect(out).toContain("ssl_certificate /data/ssl/termix.crt;");
|
||||
expect(out).toContain("root /app/html;");
|
||||
});
|
||||
|
||||
it("uses the install folder in place of /app", () => {
|
||||
const out = renderNginxTemplate(TEMPLATE, {
|
||||
...values,
|
||||
appDir: "/opt/termix/",
|
||||
});
|
||||
expect(out).toContain("root /opt/termix/html;");
|
||||
expect(out).toContain("pid /tmp/nginx/nginx.pid;");
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
hostOverride: null as number | null,
|
||||
folderOverride: null as { folder: string; credentialId: number } | null,
|
||||
ownerSecret: null as Record<string, unknown> | null,
|
||||
usable: new Set<number>(),
|
||||
folderLookups: [] as Array<{ folder: unknown; protocol: string }>,
|
||||
}));
|
||||
|
||||
vi.mock("../../hosts/protocol-auth/registry.js", () => ({
|
||||
isAuthOverrideProtocol: (protocol: string) => protocol === "ssh",
|
||||
}));
|
||||
|
||||
vi.mock("../../hosts/usable-credential.js", () => ({
|
||||
findUsableCredential: async (id: number) =>
|
||||
state.usable.has(id) ? { id, username: `user-${id}` } : null,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findHostOwnerId: async () => "owner",
|
||||
}),
|
||||
createCurrentRbacAccessRepository: () => ({
|
||||
findActiveHostAccess: async () => null,
|
||||
}),
|
||||
createCurrentRoleRepository: () => ({ listUserRoleIds: async () => [] }),
|
||||
createCurrentSharedHostAuthOverrideRepository: () => ({
|
||||
findCredentialId: async () => state.hostOverride,
|
||||
}),
|
||||
createCurrentSharedFolderAuthOverrideRepository: () => ({
|
||||
findClosest: async (_userId: string, folder: unknown, protocol: string) => {
|
||||
state.folderLookups.push({ folder, protocol });
|
||||
return state.folderOverride;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
|
||||
SharedHostSecretsManager: {
|
||||
getInstance: () => ({
|
||||
getSecretForUser: async () => state.ownerSecret,
|
||||
snapshotForUser: async () => undefined,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const { resolveRecipientSharedHostAuthentication } =
|
||||
await import("../../utils/shared-host-auth-resolver.js");
|
||||
|
||||
const host = (extra: Record<string, unknown> = {}) =>
|
||||
({
|
||||
id: 42,
|
||||
userId: "owner",
|
||||
folder: "Prod / Web",
|
||||
authType: "credential",
|
||||
credentialId: 99,
|
||||
shareSshAuth: false,
|
||||
...extra,
|
||||
}) as never;
|
||||
|
||||
beforeEach(() => {
|
||||
state.hostOverride = null;
|
||||
state.folderOverride = null;
|
||||
state.ownerSecret = null;
|
||||
state.usable = new Set();
|
||||
state.folderLookups = [];
|
||||
});
|
||||
|
||||
describe("folder logins for shared hosts", () => {
|
||||
it("uses the folder's login when the host has none of its own", async () => {
|
||||
state.folderOverride = { folder: "Prod", credentialId: 7 };
|
||||
state.usable.add(7);
|
||||
const result = await resolveRecipientSharedHostAuthentication(
|
||||
host(),
|
||||
42,
|
||||
"recipient",
|
||||
"ssh",
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
source: "personal-override",
|
||||
credentialId: 7,
|
||||
folder: "Prod",
|
||||
});
|
||||
expect(state.folderLookups).toEqual([
|
||||
{ folder: "Prod / Web", protocol: "ssh" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("lets the host's own login win over the folder's", async () => {
|
||||
state.hostOverride = 5;
|
||||
state.folderOverride = { folder: "Prod", credentialId: 7 };
|
||||
state.usable.add(5).add(7);
|
||||
const result = await resolveRecipientSharedHostAuthentication(
|
||||
host(),
|
||||
42,
|
||||
"recipient",
|
||||
"ssh",
|
||||
);
|
||||
expect(result).toMatchObject({ credentialId: 5 });
|
||||
expect(result).not.toHaveProperty("folder");
|
||||
});
|
||||
|
||||
it("skips a folder login the user can no longer use", async () => {
|
||||
state.folderOverride = { folder: "Prod", credentialId: 7 };
|
||||
state.ownerSecret = { authType: "password", password: "x" };
|
||||
const result = await resolveRecipientSharedHostAuthentication(
|
||||
host({ shareSshAuth: true }),
|
||||
42,
|
||||
"recipient",
|
||||
"ssh",
|
||||
);
|
||||
expect(result).toMatchObject({ source: "owner-shared" });
|
||||
});
|
||||
|
||||
it("asks for a login when nothing is set", async () => {
|
||||
const result = await resolveRecipientSharedHostAuthentication(
|
||||
host(),
|
||||
42,
|
||||
"recipient",
|
||||
"ssh",
|
||||
);
|
||||
expect(result).toEqual({ source: "required" });
|
||||
});
|
||||
});
|
||||
@@ -17,14 +17,8 @@ const state = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../hosts/usable-credential.js", () => ({
|
||||
findUsableCredential: async (credentialId: number, userId: string) => {
|
||||
const credential = state.credentials.get(credentialId);
|
||||
if (!credential) return null;
|
||||
const sharedWith = (credential.sharedWith as string[] | undefined) ?? [];
|
||||
return credential.userId === userId || sharedWith.includes(userId)
|
||||
? credential
|
||||
: null;
|
||||
},
|
||||
findUsableCredential: async (credentialId: number) =>
|
||||
state.credentials.get(credentialId) ?? null,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
@@ -35,10 +29,8 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
findHostById: async (hostId: number) => state.hosts.get(hostId) ?? null,
|
||||
findHostOwnerId: async (hostId: number) =>
|
||||
(state.hosts.get(hostId)?.userId as string) ?? null,
|
||||
findCredentialByIdForUser: async (credentialId: number, userId: string) =>
|
||||
state.credentials.get(credentialId)?.userId === userId
|
||||
? state.credentials.get(credentialId)
|
||||
: null,
|
||||
findCredentialByIdForUser: async (credentialId: number) =>
|
||||
state.credentials.get(credentialId) ?? null,
|
||||
}),
|
||||
createCurrentSharedHostSecretsRepository: () => ({
|
||||
upsert: async (row: Record<string, unknown>) => {
|
||||
@@ -268,39 +260,6 @@ describe("SharedHostSecretsManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("snapshots SSH auth from a credential shared with the host owner", async () => {
|
||||
state.credentials.set(123, {
|
||||
id: 123,
|
||||
userId: "cred-owner",
|
||||
sharedWith: ["owner"],
|
||||
username: "cred-user",
|
||||
authType: "password",
|
||||
password: "cred-pass",
|
||||
privateKey: null,
|
||||
key: null,
|
||||
keyPassword: null,
|
||||
keyType: null,
|
||||
});
|
||||
state.hosts.set(
|
||||
42,
|
||||
baseHost({
|
||||
authType: "credential",
|
||||
credentialId: 123,
|
||||
shareSshAuth: true,
|
||||
password: null,
|
||||
}),
|
||||
);
|
||||
|
||||
await manager.snapshotForUser(7, 42, "target", "owner");
|
||||
|
||||
expect(state.secretRows.map((row) => row.protocol)).toEqual(["ssh"]);
|
||||
expect(await manager.getSecretForUser(42, "target", "ssh")).toMatchObject({
|
||||
username: "cred-user",
|
||||
authType: "password",
|
||||
password: "cred-pass",
|
||||
});
|
||||
});
|
||||
|
||||
it("snapshots a credential-backed protocol login from the credential", async () => {
|
||||
state.credentials.set(55, {
|
||||
id: 55,
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
getFriendlyKeyTypeName,
|
||||
validateKeyPair,
|
||||
isSecurityKeyFile,
|
||||
clearUnlockedKeyCache,
|
||||
} from "../../utils/ssh-key-utils.js";
|
||||
|
||||
const { parsePrivateKey } = sshpk;
|
||||
@@ -227,32 +226,6 @@ describe("PKCS#8 runtime key normalization", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("encrypted OpenSSH keys", () => {
|
||||
const key = sshpk.generatePrivateKey("ed25519");
|
||||
const encrypted = key.toString("openssh", { passphrase: "pw" }).trim();
|
||||
|
||||
it("unlocks once and hands ssh2 a key that needs no passphrase", () => {
|
||||
clearUnlockedKeyCache();
|
||||
const first = preparePrivateKeyForSSH2(encrypted, "pw");
|
||||
const parsed = ssh2.utils.parseKey(first);
|
||||
expect(parsed).not.toBeInstanceOf(Error);
|
||||
expect(parsePrivateKey(first.toString()).fingerprint().toString()).toBe(
|
||||
key.fingerprint().toString(),
|
||||
);
|
||||
expect(preparePrivateKeyForSSH2(encrypted, "pw").toString()).toBe(
|
||||
first.toString(),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the key locked for a wrong or missing passphrase", () => {
|
||||
clearUnlockedKeyCache();
|
||||
expect(preparePrivateKeyForSSH2(encrypted, "wrong").toString()).toBe(
|
||||
encrypted,
|
||||
);
|
||||
expect(preparePrivateKeyForSSH2(encrypted).toString()).toBe(encrypted);
|
||||
});
|
||||
});
|
||||
|
||||
describe("security key files", () => {
|
||||
const str = (v: string | Buffer) => {
|
||||
const b = Buffer.from(v);
|
||||
|
||||
@@ -3,40 +3,9 @@ import { existsSync, readFileSync, writeFileSync } from "fs";
|
||||
import path from "path";
|
||||
import { authLogger } from "../utils/logger.js";
|
||||
|
||||
// The Docker image's paths. An install outside Docker (a Proxmox LXC script,
|
||||
// a source checkout) points these at its own nginx instead.
|
||||
const nginxTemplate = () =>
|
||||
process.env.TERMIX_NGINX_TEMPLATE || "/app/nginx/nginx-https.conf.template";
|
||||
const nginxConf = () =>
|
||||
process.env.TERMIX_NGINX_CONF || "/tmp/nginx/nginx.conf";
|
||||
const nginxPid = () => process.env.TERMIX_NGINX_PID || "/tmp/nginx/nginx.pid";
|
||||
|
||||
/** The template with Termix's own folder in place of the image's /app. */
|
||||
export function renderNginxTemplate(
|
||||
template: string,
|
||||
values: {
|
||||
port: string;
|
||||
sslPort: string;
|
||||
certPath: string;
|
||||
keyPath: string;
|
||||
appDir?: string;
|
||||
},
|
||||
): string {
|
||||
let rendered = template
|
||||
.split("${PORT}")
|
||||
.join(values.port)
|
||||
.split("${SSL_PORT}")
|
||||
.join(values.sslPort)
|
||||
.split("${SSL_CERT_PATH}")
|
||||
.join(values.certPath)
|
||||
.split("${SSL_KEY_PATH}")
|
||||
.join(values.keyPath);
|
||||
const appDir = values.appDir?.replace(/\/+$/, "");
|
||||
if (appDir && appDir !== "/app") {
|
||||
rendered = rendered.replace(/(^|[\s;])\/app\//gm, `$1${appDir}/`);
|
||||
}
|
||||
return rendered;
|
||||
}
|
||||
const NGINX_TEMPLATE = "/app/nginx/nginx-https.conf.template";
|
||||
const NGINX_CONF = "/tmp/nginx/nginx.conf";
|
||||
const NGINX_PID = "/tmp/nginx/nginx.pid";
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || "./db/data";
|
||||
const SSL_DIR = path.join(DATA_DIR, "ssl");
|
||||
@@ -82,13 +51,11 @@ export function persistSSLEnv(
|
||||
* restart. No-ops outside the Docker image (template/binary won't exist).
|
||||
*/
|
||||
export function reloadNginxWithSSL(): { applied: boolean; message: string } {
|
||||
const NGINX_TEMPLATE = nginxTemplate();
|
||||
const NGINX_CONF = nginxConf();
|
||||
if (!existsSync(NGINX_TEMPLATE) || !existsSync(nginxPid())) {
|
||||
if (!existsSync(NGINX_TEMPLATE) || !existsSync(NGINX_PID)) {
|
||||
return {
|
||||
applied: false,
|
||||
message:
|
||||
"Termix could not find the nginx it runs with, so HTTPS is not on yet. In Docker, restart Termix with ENABLE_SSL=true. On another install, set TERMIX_NGINX_TEMPLATE, TERMIX_NGINX_CONF and TERMIX_NGINX_PID (and TERMIX_APP_DIR if it is not /app), or add a listen 443 ssl block with this certificate to your nginx config.",
|
||||
"nginx is not managed by this environment; restart Termix with ENABLE_SSL=true to apply the certificate.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -99,13 +66,16 @@ export function reloadNginxWithSSL(): { applied: boolean; message: string } {
|
||||
const keyPath = process.env.SSL_KEY_PATH || path.join(SSL_DIR, "termix.key");
|
||||
|
||||
try {
|
||||
const rendered = renderNginxTemplate(readFileSync(NGINX_TEMPLATE, "utf8"), {
|
||||
port,
|
||||
sslPort,
|
||||
certPath,
|
||||
keyPath,
|
||||
appDir: process.env.TERMIX_APP_DIR,
|
||||
});
|
||||
const template = readFileSync(NGINX_TEMPLATE, "utf8");
|
||||
const rendered = template
|
||||
.split("${PORT}")
|
||||
.join(port)
|
||||
.split("${SSL_PORT}")
|
||||
.join(sslPort)
|
||||
.split("${SSL_CERT_PATH}")
|
||||
.join(certPath)
|
||||
.split("${SSL_KEY_PATH}")
|
||||
.join(keyPath);
|
||||
|
||||
writeFileSync(NGINX_CONF, rendered);
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { AuthOverrideProtocol } from "../../types/auth-protocols.js";
|
||||
import { isAuthOverrideProtocol } from "../hosts/protocol-auth/registry.js";
|
||||
import {
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentSharedHostAuthOverrideRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { findUsableCredential } from "../hosts/usable-credential.js";
|
||||
import { logAudit } from "./audit-logger.js";
|
||||
import { PermissionManager } from "./permission-manager.js";
|
||||
|
||||
@@ -60,7 +60,11 @@ export class SharedHostAuthOverrideService {
|
||||
await this.requireSharedHostAccess(hostId, userId);
|
||||
|
||||
if (credentialId !== null) {
|
||||
const credential = await findUsableCredential(credentialId, userId);
|
||||
const credential =
|
||||
await createCurrentCredentialRepository().findByIdForUser(
|
||||
userId,
|
||||
credentialId,
|
||||
);
|
||||
if (!credential) {
|
||||
throw new SharedHostAuthOverrideServiceError(
|
||||
"Credential not found",
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentRbacAccessRepository,
|
||||
createCurrentRoleRepository,
|
||||
createCurrentSharedFolderAuthOverrideRepository,
|
||||
createCurrentSharedHostAuthOverrideRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import type {
|
||||
@@ -13,7 +12,6 @@ import type {
|
||||
} from "../database/repositories/host-resolution-repository.js";
|
||||
import { getSshAuthProvider } from "../hosts/connect/auth-provider-registry.js";
|
||||
import { ensureCoreSshAuthProviders } from "../hosts/connect/core-providers.js";
|
||||
import { findUsableCredential } from "../hosts/usable-credential.js";
|
||||
import {
|
||||
SharedHostSecretsManager,
|
||||
type SharedSecretData,
|
||||
@@ -24,8 +22,6 @@ export type RecipientSharedHostAuthResolution =
|
||||
source: "personal-override";
|
||||
credentialId: number;
|
||||
credential: HostResolutionCredentialRecord;
|
||||
/** Set when the login comes from the folder, not the host itself. */
|
||||
folder?: string;
|
||||
}
|
||||
| {
|
||||
source: "owner-shared";
|
||||
@@ -57,8 +53,8 @@ function isOwnerAuthShared(
|
||||
|
||||
/**
|
||||
* Applies the shared-host authentication precedence independently from any
|
||||
* transport: recipient override on the host, then on its folder, shared
|
||||
* owner auth, secretless auth, then "required".
|
||||
* transport: recipient override, shared owner auth, secretless auth, then
|
||||
* "required".
|
||||
*/
|
||||
export async function resolveRecipientSharedHostAuthentication(
|
||||
host: HostResolutionHostRecord,
|
||||
@@ -84,7 +80,10 @@ export async function resolveRecipientSharedHostAuthentication(
|
||||
}
|
||||
|
||||
if (overrideCredentialId) {
|
||||
const credential = await findUsableCredential(overrideCredentialId, userId);
|
||||
const credential = await repository.findCredentialByIdForUser(
|
||||
overrideCredentialId,
|
||||
userId,
|
||||
);
|
||||
if (credential) {
|
||||
return {
|
||||
source: "personal-override",
|
||||
@@ -94,31 +93,6 @@ export async function resolveRecipientSharedHostAuthentication(
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const folderOverride =
|
||||
await createCurrentSharedFolderAuthOverrideRepository().findClosest(
|
||||
userId,
|
||||
host.folder,
|
||||
protocol,
|
||||
);
|
||||
if (folderOverride) {
|
||||
const credential = await findUsableCredential(
|
||||
folderOverride.credentialId,
|
||||
userId,
|
||||
);
|
||||
if (credential) {
|
||||
return {
|
||||
source: "personal-override",
|
||||
credentialId: folderOverride.credentialId,
|
||||
credential,
|
||||
folder: folderOverride.folder,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Same as a host override: an unreadable one counts as none.
|
||||
}
|
||||
|
||||
if (isOwnerAuthShared(host, protocol)) {
|
||||
if (protocol === "ssh" && host.authType === "agent") {
|
||||
return {
|
||||
@@ -171,17 +145,6 @@ export async function resolveRecipientSharedHostAuthentication(
|
||||
}
|
||||
}
|
||||
|
||||
if (protocol === "ssh" && host.credentialId) {
|
||||
const credential = await findUsableCredential(host.credentialId, userId);
|
||||
if (credential) {
|
||||
return {
|
||||
source: "personal-override",
|
||||
credentialId: host.credentialId,
|
||||
credential,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return requiresPersonalHostAuthentication(host, protocol)
|
||||
? { source: "required" }
|
||||
: { source: "secretless" };
|
||||
|
||||
@@ -381,11 +381,12 @@ class SharedHostSecretsManager {
|
||||
host: HostResolutionHostRecord,
|
||||
ownerId: string,
|
||||
): Promise<ProtocolSnapshot[]> {
|
||||
const repository = createCurrentHostResolutionRepository();
|
||||
const snapshots: ProtocolSnapshot[] = [];
|
||||
|
||||
if (sshEnabled(host) && host.shareSshAuth) {
|
||||
if (host.credentialId) {
|
||||
const credential = await findUsableCredential(
|
||||
const credential = await repository.findCredentialByIdForUser(
|
||||
host.credentialId,
|
||||
ownerId,
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createHash, createPrivateKey } from "node:crypto";
|
||||
import { createPrivateKey } from "node:crypto";
|
||||
import sshpk from "sshpk";
|
||||
import { getErrorMessage } from "./error-message.js";
|
||||
import ssh2Pkg from "ssh2";
|
||||
@@ -264,59 +264,12 @@ export function isSecurityKeyFile(privateKeyData: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Unlocking an OpenSSH key runs bcrypt on the main thread (about a second per
|
||||
// try), and every connect used to do it 3 or more times. Keep the unlocked copy
|
||||
// in memory for a short while so a burst of connects pays for it once.
|
||||
const UNLOCKED_KEY_TTL_MS = 15 * 60 * 1000;
|
||||
const UNLOCKED_KEY_MAX = 64;
|
||||
const unlockedKeys = new Map<string, { text: string; expires: number }>();
|
||||
|
||||
function unlockOpenSSHKey(cleanKey: string, passphrase?: string): string {
|
||||
if (
|
||||
!passphrase ||
|
||||
!cleanKey.startsWith("-----BEGIN OPENSSH PRIVATE KEY-----")
|
||||
)
|
||||
return cleanKey;
|
||||
const id = createHash("sha256")
|
||||
.update(cleanKey)
|
||||
.update("\n")
|
||||
.update(passphrase)
|
||||
.digest("hex");
|
||||
const now = Date.now();
|
||||
const hit = unlockedKeys.get(id);
|
||||
if (hit && hit.expires > now) return hit.text;
|
||||
unlockedKeys.delete(id);
|
||||
let text: string;
|
||||
try {
|
||||
text = parsePrivateKey(cleanKey, "openssh", { passphrase }).toString(
|
||||
"openssh",
|
||||
);
|
||||
} catch {
|
||||
// Wrong passphrase or a cipher sshpk lacks; the normal path reports it.
|
||||
return cleanKey;
|
||||
}
|
||||
if (ssh2Utils.parseKey(text) instanceof Error) return cleanKey;
|
||||
if (unlockedKeys.size >= UNLOCKED_KEY_MAX) {
|
||||
const oldest = unlockedKeys.keys().next().value;
|
||||
if (oldest !== undefined) unlockedKeys.delete(oldest);
|
||||
}
|
||||
unlockedKeys.set(id, { text, expires: now + UNLOCKED_KEY_TTL_MS });
|
||||
return text;
|
||||
}
|
||||
|
||||
export function clearUnlockedKeyCache(): void {
|
||||
unlockedKeys.clear();
|
||||
}
|
||||
|
||||
export function preparePrivateKeyForSSH2(
|
||||
privateKeyData: string,
|
||||
passphrase?: string,
|
||||
): Buffer {
|
||||
if (isSecurityKeyFile(privateKeyData)) throw new Error(SECURITY_KEY_ERROR);
|
||||
let cleanKey = unlockOpenSSHKey(
|
||||
normalizePrivateKeyText(privateKeyData),
|
||||
passphrase,
|
||||
);
|
||||
let cleanKey = normalizePrivateKeyText(privateKeyData);
|
||||
if (
|
||||
/^-----BEGIN (?:ENCRYPTED )?PRIVATE KEY-----/.test(cleanKey) &&
|
||||
ssh2Utils.parseKey(cleanKey, passphrase) instanceof Error
|
||||
|
||||
@@ -87,8 +87,6 @@ export interface HostSidebarDisplayPreferences extends HostRowFields {
|
||||
openOnDoubleClick: boolean;
|
||||
/** When false, nested folders hide the parent-path breadcrumb before their name. */
|
||||
showFolderPaths: boolean;
|
||||
/** When false, rows keep one background instead of alternating. */
|
||||
stripedRows: boolean;
|
||||
hostClickBehavior: HostClickBehavior;
|
||||
/** When true, the sidebar closes after a host is opened from it. */
|
||||
closeOnOpen: boolean;
|
||||
@@ -166,7 +164,6 @@ export function defaultHostSidebarPreferences(): HostSidebarPreferences {
|
||||
statusColorScheme: "accent",
|
||||
openOnDoubleClick: false,
|
||||
showFolderPaths: true,
|
||||
stripedRows: true,
|
||||
hostClickBehavior: "newTab",
|
||||
closeOnOpen: false,
|
||||
barActions: {},
|
||||
@@ -275,10 +272,6 @@ export function sanitizeHostSidebarPreferences(
|
||||
typeof displayObj.showFolderPaths === "boolean"
|
||||
? displayObj.showFolderPaths
|
||||
: defaults.display.showFolderPaths,
|
||||
stripedRows:
|
||||
typeof displayObj.stripedRows === "boolean"
|
||||
? displayObj.stripedRows
|
||||
: defaults.display.stripedRows,
|
||||
hostClickBehavior: HOST_CLICK_BEHAVIORS.includes(
|
||||
displayObj.hostClickBehavior as HostClickBehavior,
|
||||
)
|
||||
|
||||
@@ -72,10 +72,6 @@ export type Host = {
|
||||
terminalConfig?: HostTerminalConfig;
|
||||
sshOptions?: HostSshOptions;
|
||||
notes?: string;
|
||||
/** Hex color, like #3b82f6. */
|
||||
color?: string | null;
|
||||
/** Lucide icon name. */
|
||||
icon?: string | null;
|
||||
|
||||
useSocks5?: boolean;
|
||||
socks5Host?: string;
|
||||
@@ -186,10 +182,6 @@ export interface HostData {
|
||||
terminalConfig?: HostTerminalConfig;
|
||||
sshOptions?: HostSshOptions;
|
||||
notes?: string;
|
||||
/** Hex color, like #3b82f6. */
|
||||
color?: string | null;
|
||||
/** Lucide icon name. */
|
||||
icon?: string | null;
|
||||
|
||||
useSocks5?: boolean;
|
||||
socks5Host?: string;
|
||||
|
||||
@@ -643,24 +643,11 @@ export interface PluginUploadPreview {
|
||||
features?: string[];
|
||||
capabilities: string[];
|
||||
replaces: string | null;
|
||||
official?: boolean;
|
||||
}
|
||||
|
||||
export async function uploadPlugin(
|
||||
file: Blob,
|
||||
signature?: string,
|
||||
): Promise<PluginUploadPreview> {
|
||||
export async function uploadPlugin(file: Blob): Promise<PluginUploadPreview> {
|
||||
const response = await rbacApi.post("/plugins/upload", file, {
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
...(signature
|
||||
? {
|
||||
"X-Termix-Plugin-Signature": btoa(
|
||||
String.fromCharCode(...new TextEncoder().encode(signature)),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
+2
-55
@@ -238,14 +238,9 @@ export async function getFolderAccess(
|
||||
}
|
||||
}
|
||||
|
||||
export async function revokeFolderAccess(
|
||||
ruleId: number,
|
||||
options: { fromHosts?: boolean } = {},
|
||||
): Promise<void> {
|
||||
export async function revokeFolderAccess(ruleId: number): Promise<void> {
|
||||
try {
|
||||
await rbacApi.delete(`/rbac/folder/access/${ruleId}`, {
|
||||
params: options.fromHosts ? { fromHosts: "true" } : undefined,
|
||||
});
|
||||
await rbacApi.delete(`/rbac/folder/access/${ruleId}`);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "revoke folder access");
|
||||
}
|
||||
@@ -388,54 +383,6 @@ export async function getHostAuthOverride(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's own login for hosts shared with them in a folder. On a desktop
|
||||
* linked to a server, pass a shared host from the folder so the call goes to
|
||||
* that server.
|
||||
*/
|
||||
export async function getFolderAuthOverride(
|
||||
folder: string,
|
||||
protocol: AuthOverrideProtocol,
|
||||
sharedCopySyncId?: string | null,
|
||||
): Promise<{
|
||||
folder: string;
|
||||
protocol: AuthOverrideProtocol;
|
||||
credentialId: number | null;
|
||||
}> {
|
||||
try {
|
||||
const target = await getSharedCopyTarget(0, sharedCopySyncId);
|
||||
const response = await target.api.get(`/rbac/folder-auth/${protocol}`, {
|
||||
params: { folder },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch folder authentication override");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setFolderAuthOverride(
|
||||
folder: string,
|
||||
protocol: AuthOverrideProtocol,
|
||||
credentialId: number | null,
|
||||
sharedCopySyncId?: string | null,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
folder: string;
|
||||
protocol: AuthOverrideProtocol;
|
||||
credentialId: number | null;
|
||||
}> {
|
||||
try {
|
||||
const target = await getSharedCopyTarget(0, sharedCopySyncId);
|
||||
const response = await target.api.put(`/rbac/folder-auth/${protocol}`, {
|
||||
folder,
|
||||
credentialId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update folder authentication override");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setHostAuthOverride(
|
||||
hostId: number,
|
||||
protocol: AuthOverrideProtocol,
|
||||
|
||||
@@ -14,32 +14,11 @@ export interface DataColumn<T> {
|
||||
/** Any grid track: "1fr", "120px", "minmax(0,2fr)". */
|
||||
width: string;
|
||||
cell: (item: T) => React.ReactNode;
|
||||
/** Hidden when the table itself is narrower than md (48rem) / lg (64rem). */
|
||||
/** Dropped below md, for columns that are nice rather than necessary. */
|
||||
hideBelow?: "md" | "lg";
|
||||
align?: "start" | "end";
|
||||
}
|
||||
|
||||
/** Tracks for the columns visible at each width, so hidden ones take no space. */
|
||||
function listColumnTemplates<T>(columns: DataColumn<T>[]): {
|
||||
base: string;
|
||||
md: string;
|
||||
lg: string;
|
||||
} {
|
||||
return {
|
||||
base:
|
||||
columns
|
||||
.filter((column) => column.hideBelow === undefined)
|
||||
.map((column) => column.width)
|
||||
.join(" ") || "none",
|
||||
md:
|
||||
columns
|
||||
.filter((column) => column.hideBelow !== "lg")
|
||||
.map((column) => column.width)
|
||||
.join(" ") || "none",
|
||||
lg: columns.map((column) => column.width).join(" ") || "none",
|
||||
};
|
||||
}
|
||||
|
||||
export interface GridColumns {
|
||||
base?: number;
|
||||
md?: number;
|
||||
@@ -73,9 +52,6 @@ const XL_COLS: Record<number, string> = {
|
||||
4: "xl:grid-cols-4",
|
||||
};
|
||||
|
||||
const LIST_GRID_CLASS =
|
||||
"grid grid-cols-[var(--data-view-cols)] @min-[48rem]:grid-cols-[var(--data-view-cols-md)] @min-[64rem]:grid-cols-[var(--data-view-cols-lg)] gap-x-2 px-3";
|
||||
|
||||
function gridClass(columns: GridColumns): string {
|
||||
return cn(
|
||||
COLS[columns.base ?? 1],
|
||||
@@ -85,12 +61,6 @@ function gridClass(columns: GridColumns): string {
|
||||
);
|
||||
}
|
||||
|
||||
function columnVisibility(hideBelow?: "md" | "lg"): string {
|
||||
if (hideBelow === "md") return "hidden @min-[48rem]:block";
|
||||
if (hideBelow === "lg") return "hidden @min-[64rem]:block";
|
||||
return "";
|
||||
}
|
||||
|
||||
/** One collection as a card grid or a dense row table. */
|
||||
export function DataView<T>({
|
||||
items,
|
||||
@@ -150,26 +120,14 @@ export function DataView<T>({
|
||||
);
|
||||
}
|
||||
|
||||
const templates = listColumnTemplates(listColumns);
|
||||
const rowHeight = density === "compact" ? "min-h-7" : "min-h-9";
|
||||
const template = listColumns.map((c) => c.width).join(" ");
|
||||
const rowHeight = density === "compact" ? "h-7" : "h-9";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("@container border border-border bg-card", className)}
|
||||
style={
|
||||
{
|
||||
"--data-view-cols": templates.base,
|
||||
"--data-view-cols-md": templates.md,
|
||||
"--data-view-cols-lg": templates.lg,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
role="table"
|
||||
>
|
||||
<div className={cn("border border-border bg-card", className)} role="table">
|
||||
<div
|
||||
className={cn(
|
||||
LIST_GRID_CLASS,
|
||||
"sticky top-0 z-10 items-center border-b border-border bg-card text-[10px] font-semibold uppercase tracking-widest text-muted-foreground",
|
||||
)}
|
||||
className="sticky top-0 z-10 grid items-center border-b border-border bg-card px-3 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"
|
||||
style={{ gridTemplateColumns: template }}
|
||||
>
|
||||
{listColumns.map((column) => (
|
||||
<span
|
||||
@@ -177,7 +135,8 @@ export function DataView<T>({
|
||||
className={cn(
|
||||
"truncate py-2",
|
||||
column.align === "end" && "text-right",
|
||||
columnVisibility(column.hideBelow),
|
||||
column.hideBelow === "md" && "hidden md:block",
|
||||
column.hideBelow === "lg" && "hidden lg:block",
|
||||
)}
|
||||
>
|
||||
{column.header}
|
||||
@@ -193,12 +152,12 @@ export function DataView<T>({
|
||||
key={getKey(item)}
|
||||
onClick={onRowClick ? () => onRowClick(item) : undefined}
|
||||
className={cn(
|
||||
LIST_GRID_CLASS,
|
||||
"items-center border-b border-border/50 text-xs transition-colors last:border-0",
|
||||
"grid items-center gap-x-2 border-b border-border/50 px-3 text-xs transition-colors last:border-0",
|
||||
rowHeight,
|
||||
onRowClick && "cursor-pointer",
|
||||
active ? "bg-accent-brand/10" : "hover:bg-muted/40",
|
||||
)}
|
||||
style={{ gridTemplateColumns: template }}
|
||||
>
|
||||
{listColumns.map((column) => (
|
||||
<div
|
||||
@@ -206,7 +165,8 @@ export function DataView<T>({
|
||||
className={cn(
|
||||
"min-w-0 truncate",
|
||||
column.align === "end" && "text-right",
|
||||
columnVisibility(column.hideBelow),
|
||||
column.hideBelow === "md" && "hidden md:block",
|
||||
column.hideBelow === "lg" && "hidden lg:block",
|
||||
)}
|
||||
>
|
||||
{column.cell(item)}
|
||||
|
||||
@@ -590,8 +590,6 @@
|
||||
"closeOnOpenDesc": "Close the sidebar after you open a host from it",
|
||||
"showFolderPaths": "Show folder paths",
|
||||
"showFolderPathsDesc": "Show the parent folder path above nested subfolder names. Subfolders stay indented either way.",
|
||||
"stripedRows": "Alternating row colors",
|
||||
"stripedRowsDesc": "Shade every other row in the host list",
|
||||
"rowDetailsTitle": "Row details",
|
||||
"rowDetailsDesc": "Choose what shows on each host.",
|
||||
"showAddress": "Address",
|
||||
@@ -777,8 +775,6 @@
|
||||
"sharedBadge": "Shared",
|
||||
"sharedBadgeTooltip": "Shared by {{owner}} ({{level}} access)",
|
||||
"viewOnlyBanner": "This host is shared with you by {{owner}} with view access. The configuration is read-only.",
|
||||
"folderRuleRemoveFromHosts": "Also remove this access from every host in the folder and its subfolders",
|
||||
"sharedCopyBanner": "This host is shared with you by {{owner}}. The desktop keeps a read-only copy, so change it on the server and it syncs here.",
|
||||
"sharedEditBanner": "This host is shared with you by {{owner}} with edit access. Changes apply to the real host; authentication references can only be changed by the owner.",
|
||||
"ownerOnlyControl": "Only the host owner can change this",
|
||||
"ownerAuthPrivate": "The host owner's SSH authentication is private. Use “Set personal SSH authentication” from the host menu to choose your own credential.",
|
||||
|
||||
@@ -590,8 +590,6 @@
|
||||
"closeOnOpenDesc": "Maak die sybalk toe nadat jy 'n gasheer daaruit oopgemaak het",
|
||||
"showFolderPaths": "Wys gidsroetes",
|
||||
"showFolderPathsDesc": "Wys die ouergidsroete bo ingeslote subgidsname. Subgidse bly ingespring in beide gevalle.",
|
||||
"stripedRows": "Wisselende rykleure",
|
||||
"stripedRowsDesc": "Skadu elke tweede ry in die gasheerlys",
|
||||
"rowDetailsTitle": "Rybesonderhede",
|
||||
"rowDetailsDesc": "Kies wat op elke gasheerbediener verskyn.",
|
||||
"showAddress": "Adres",
|
||||
@@ -777,8 +775,6 @@
|
||||
"sharedBadge": "Gedeel",
|
||||
"sharedBadgeTooltip": "Gedeel deur {{owner}} ({{level}}-toegang)",
|
||||
"viewOnlyBanner": "Hierdie gasheer is met jou gedeel deur {{owner}} met lees-toegang. Die konfigurasie is lees-slegs.",
|
||||
"folderRuleRemoveFromHosts": "Verwyder ook hierdie toegang van elke gasheer in die gids en sy subgidse",
|
||||
"sharedCopyBanner": "Hierdie gasheer word met jou gedeel deur {{owner}}. Die werkskoppie hou 'n lees-slegs kopie, so verander dit op die bediener en dit sinkroniseer hier.",
|
||||
"sharedEditBanner": "Hierdie gasheer is met jou gedeel deur {{owner}} met wysig-toegang. Veranderinge word op die werklike gasheer toegepas; autentikasie-verwysings kan slegs deur die eienaar verander word.",
|
||||
"ownerOnlyControl": "Slegs die gasheer-eienaar kan dit verander",
|
||||
"ownerAuthPrivate": "Die gasheer-eienaar se SSH-authentikasie is privaat. Gebruik “Stel persoonlike SSH-authentikasie” in die gasheer-menu om jou eie geloofsbewys te kies.",
|
||||
|
||||
@@ -614,8 +614,6 @@
|
||||
"closeOnOpenDesc": "إغلاق الشريط الجانبي بعد فتح مضيف منه",
|
||||
"showFolderPaths": "إظهار مسارات المجلدات",
|
||||
"showFolderPathsDesc": "إظهار مسار المجلد الأب فوق أسماء المجلدات الفرعية المتداخلة. تظل المجلدات الفرعية مضمنة في كلتا الحالتين.",
|
||||
"stripedRows": "ألوان صفوف متناوبة",
|
||||
"stripedRowsDesc": "تظليل كل صف ثانٍ في قائمة المضيفين",
|
||||
"rowDetailsTitle": "تفاصيل الصف",
|
||||
"rowDetailsDesc": "اختر ما يظهر على كل جهاز.",
|
||||
"showAddress": "العنوان",
|
||||
@@ -825,8 +823,6 @@
|
||||
"sharedBadge": "مشترك",
|
||||
"sharedBadgeTooltip": "مشارك من قبل {{owner}} (وصول {{level}})",
|
||||
"viewOnlyBanner": "هذا المضيف مشارك معك من قبل {{owner}} بصلاحية العرض فقط. التكوين للقراءة فقط.",
|
||||
"folderRuleRemoveFromHosts": "إزالة هذا الوصول أيضاً من كل مضيف في المجلد والمجلدات الفرعية الخاصة به",
|
||||
"sharedCopyBanner": "تم مشاركة هذا المضيف معك بواسطة {{owner}}. يحتفظ سطح المكتب بنسخة للقراءة فقط، لذا قم بتعديله على الخادم وسيتم مزامنته هنا.",
|
||||
"sharedEditBanner": "هذا المضيف مشارك معك من قبل {{owner}} بصلاحية التعديل. التغييرات تنطبق على المضيف الفعلي؛ لا يمكن تغيير مراجع المصادقة إلا من قبل المالك.",
|
||||
"ownerOnlyControl": "يمكن للمالك فقط تغيير هذا",
|
||||
"ownerAuthPrivate": "مصادقة SSH الخاصة بمالك المضيف خاصة. استخدم “تعيين مصادقة SSH شخصية” من قائمة المضيف لاختيار بيانات الاعتماد الخاصة بك.",
|
||||
|
||||
@@ -590,8 +590,6 @@
|
||||
"closeOnOpenDesc": "Затвори страничната лента след като отвориш хост от нея",
|
||||
"showFolderPaths": "Показване на пътищата до папките",
|
||||
"showFolderPathsDesc": "Показване на пътя до родителската папка над имената на вложените подпапки. Подпапките оставят отстъп в двата случая.",
|
||||
"stripedRows": "Чередуване на цветовете на редовете",
|
||||
"stripedRowsDesc": "Оцветяване на всеки втори ред в списъка с хостове",
|
||||
"rowDetailsTitle": "Детайли на реда",
|
||||
"rowDetailsDesc": "Изберете какво се показва за всеки хост.",
|
||||
"showAddress": "Адрес",
|
||||
@@ -777,8 +775,6 @@
|
||||
"sharedBadge": "Споделен",
|
||||
"sharedBadgeTooltip": "Споделен от {{owner}} (достъп: {{level}})",
|
||||
"viewOnlyBanner": "Този хост е споделен с вас от {{owner}} с достъп само за преглед. Конфигурацията е само за четене.",
|
||||
"folderRuleRemoveFromHosts": "Премахване на достъпа и от всички хостове в папката и подпапките ѝ",
|
||||
"sharedCopyBanner": "Този хост е споделен с вас от {{owner}}. Работният плот запазва само за четене копия, така че променете го на сървъра и той ще се синхронизира тук.",
|
||||
"sharedEditBanner": "Този хост е споделен с вас от {{owner}} с достъп за редактиране. Промените се прилагат към реалния хост; референциите за автентикация могат да бъдат променени само от собственика.",
|
||||
"ownerOnlyControl": "Само собственикът на хоста може да промени това",
|
||||
"ownerAuthPrivate": "SSH автентикацията на собственика на хоста е частна. Използвайте „Задай лична SSH автентикация“ от менюто на хоста, за да изберете свои собствени данни за вход.",
|
||||
|
||||
@@ -590,8 +590,6 @@
|
||||
"closeOnOpenDesc": "সাইডবার থেকে কোনো হোস্ট খোলার পর সাইডবার বন্ধ করুন",
|
||||
"showFolderPaths": "ফোল্ডার পথ দেখান",
|
||||
"showFolderPathsDesc": "নেস্টেড সাবফোল্ডার নামের উপরে প্যারেন্ট ফোল্ডার পথ দেখান। সাবফোল্ডার যেকোনো ক্ষেত্রেই ইনডেন্ট থাকবে।",
|
||||
"stripedRows": "বিকল্প সারির রঙ",
|
||||
"stripedRowsDesc": "হোস্ট তালিকায় প্রতিটি বিকল্প সারি রঙিন করুন",
|
||||
"rowDetailsTitle": "সারির বিবরণ",
|
||||
"rowDetailsDesc": "প্রতিটি হোস্টে কী দেখাবে তা নির্বাচন করুন।",
|
||||
"showAddress": "ঠিকানা",
|
||||
@@ -777,8 +775,6 @@
|
||||
"sharedBadge": "শেয়ার করা",
|
||||
"sharedBadgeTooltip": "{{owner}} দ্বারা শেয়ার করা ({{level}} অ্যাক্সেস)",
|
||||
"viewOnlyBanner": "{{owner}} এই হোস্টটি আপনার সাথে ভিউ অ্যাক্সেস সহ শেয়ার করেছেন। কনফিগারেশনটি রিড-অনলি।",
|
||||
"folderRuleRemoveFromHosts": "এই ফোল্ডার এবং এর সাবফোল্ডারে থাকা প্রতিটি হোস্ট থেকেও এই অ্যাক্সেস সরিয়ে দিন",
|
||||
"sharedCopyBanner": "{{owner}} আপনাকে এই হোস্টটি শেয়ার করেছেন। ডেস্কটপটি একটি রিড-অনলি কপি রাখে, তাই সার্ভারে এটি পরিবর্তন করলে এটি এখানে সিঙ্ক হবে।",
|
||||
"sharedEditBanner": "{{owner}} এই হোস্টটি আপনার সাথে এডিট অ্যাক্সেস সহ শেয়ার করেছেন। পরিবর্তনগুলো আসল হোস্টে প্রয়োগ হবে; অথেন্টিকেশন রেফারেন্স শুধুমাত্র মালিক পরিবর্তন করতে পারবেন।",
|
||||
"ownerOnlyControl": "শুধুমাত্র হোস্ট মালিক এটি পরিবর্তন করতে পারেন",
|
||||
"ownerAuthPrivate": "হোস্ট মালিকের SSH অথেন্টিকেশন প্রাইভেট। আপনার নিজস্ব ক্রেডেনশিয়াল নির্বাচন করতে হোস্ট মেনু থেকে “সেট পার্সোনাল SSH অথেন্টিকেশন” ব্যবহার করুন।",
|
||||
|
||||
@@ -596,8 +596,6 @@
|
||||
"closeOnOpenDesc": "Tanca la barra lateral després d'obrir un amfitrió des d'ella",
|
||||
"showFolderPaths": "Mostra les rutes de les carpetes",
|
||||
"showFolderPathsDesc": "Mostra la ruta de la carpeta principal sobre els noms de les subcarpetes anidades. Les subcarpetes es mantenen amb sagnat en qualsevol cas.",
|
||||
"stripedRows": "Colors alternats de les files",
|
||||
"stripedRowsDesc": "Aplicar un color alternat a cada file de la llista d'amfitrions",
|
||||
"rowDetailsTitle": "Detalls de la fila",
|
||||
"rowDetailsDesc": "Tria què es mostra en cada amfitrió.",
|
||||
"showAddress": "Adreça",
|
||||
@@ -789,8 +787,6 @@
|
||||
"sharedBadge": "Compartit",
|
||||
"sharedBadgeTooltip": "Compartit per {{owner}} (accés {{level}})",
|
||||
"viewOnlyBanner": "Aquest host us ha estat compartit per {{owner}} amb accés de visualització. La configuració és de només lectura.",
|
||||
"folderRuleRemoveFromHosts": "També elimina aquest accés de tots els amfitrions de la carpeta i les seves subcarpetes",
|
||||
"sharedCopyBanner": "Aquest amfitrió ha estat compartit amb tu per {{owner}}. L'escriptori manté una còpia de només lectura, de manera que si el modifiques al servidor, els canvis es sincronitzaran aquí.",
|
||||
"sharedEditBanner": "Aquest host us ha estat compartit per {{owner}} amb accés d'edició. Els canvis s'apliquen a l'host real; les referències d'autenticació només les pot modificar el propietari.",
|
||||
"ownerOnlyControl": "Només el propietari de l'host pot canviar això",
|
||||
"ownerAuthPrivate": "L'autenticació SSH del propietari de l'host és privada. Utilitzeu «Establir autenticació SSH personal» al menú de l'host per triar la vostra pròpia credencial.",
|
||||
|
||||
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user