mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
Compare commits
61
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9706426421 | ||
|
|
0f949c5e24 | ||
|
|
6f387f1058 | ||
|
|
5582087025 | ||
|
|
363e61961c | ||
|
|
9a8f0ba888 | ||
|
|
0f39ce6369 | ||
|
|
bf67f56c51 | ||
|
|
c129666d7f | ||
|
|
703e8cd037 | ||
|
|
14d4128266 | ||
|
|
50f1882fa9 | ||
|
|
6277d15c2a | ||
|
|
ef4e66659c | ||
|
|
19d4d91eee | ||
|
|
6323459af2 | ||
|
|
6406c3a923 | ||
|
|
f848dee343 | ||
|
|
302ac19e6c | ||
|
|
82143946c7 | ||
|
|
5f55289e00 | ||
|
|
32d77fc6d0 | ||
|
|
0ab7cf2ab8 | ||
|
|
8d0bcb3b1f | ||
|
|
ae9cce4de3 | ||
|
|
c17134a2a4 | ||
|
|
404608867f | ||
|
|
672f5ba80b | ||
|
|
8260af2d57 | ||
|
|
c51c3a9449 | ||
|
|
dc47c4ca86 | ||
|
|
81d79cc89b | ||
|
|
d35458f78b | ||
|
|
f06d540466 | ||
|
|
f3a1087f51 | ||
|
|
69002e6416 | ||
|
|
fafd428072 | ||
|
|
f3e09d4cbd | ||
|
|
cc68fe580f | ||
|
|
488e3f014b | ||
|
|
e5ea61e28a | ||
|
|
f0cb81c3b5 | ||
|
|
2de9bb236b | ||
|
|
30d72554fc | ||
|
|
c4c9b51294 | ||
|
|
b3cc66efdf | ||
|
|
c476ef6b3b | ||
|
|
20eca69d56 | ||
|
|
26757813c8 | ||
|
|
fa0fa7f836 | ||
|
|
ad266956cc | ||
|
|
cad7520c9f | ||
|
|
9ee50d624a | ||
|
|
433ede0b0d | ||
|
|
5e76aec2bf | ||
|
|
f1226b1f1b | ||
|
|
0a9086fb79 | ||
|
|
c42cd40a2c | ||
|
|
780cfb58e2 | ||
|
|
a4b61cc27f | ||
|
|
b5d13c3664 |
@@ -427,6 +427,7 @@ jobs:
|
|||||||
npm ci
|
npm ci
|
||||||
npm install --force @rollup/rollup-darwin-arm64
|
npm install --force @rollup/rollup-darwin-arm64
|
||||||
npm install dmg-license
|
npm install dmg-license
|
||||||
|
node scripts/install-macos-sharp.cjs
|
||||||
|
|
||||||
- name: Check for Code Signing Certificates
|
- name: Check for Code Signing Certificates
|
||||||
id: check_certs
|
id: check_certs
|
||||||
@@ -538,6 +539,14 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
npx electron-builder --mac dmg --universal --x64 --arm64 --publish never
|
npx electron-builder --mac dmg --universal --x64 --arm64 --publish never
|
||||||
|
|
||||||
|
- name: Verify macOS sharp packaging
|
||||||
|
run: |
|
||||||
|
artifacts=(release/termix_macos_*_dmg.dmg)
|
||||||
|
if [ -f release/termix_macos_universal_mas.pkg ]; then
|
||||||
|
artifacts+=(release/termix_macos_universal_mas.pkg)
|
||||||
|
fi
|
||||||
|
node scripts/verify-macos-sharp.cjs "${artifacts[@]}"
|
||||||
|
|
||||||
- name: Upload macOS MAS PKG
|
- name: Upload macOS MAS PKG
|
||||||
if: steps.check_certs.outputs.has_certs == 'true' && hashFiles('release/termix_macos_universal_mas.pkg') != '' && (inputs.artifact_destination == 'file' || inputs.artifact_destination == 'release' || inputs.artifact_destination == 'submit')
|
if: steps.check_certs.outputs.has_certs == 'true' && hashFiles('release/termix_macos_universal_mas.pkg') != '' && (inputs.artifact_destination == 'file' || inputs.artifact_destination == 'release' || inputs.artifact_destination == 'submit')
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
@@ -923,6 +932,9 @@ jobs:
|
|||||||
echo "dmg_name=$DMG_NAME" >> $GITHUB_OUTPUT
|
echo "dmg_name=$DMG_NAME" >> $GITHUB_OUTPUT
|
||||||
echo "checksum=$CHECKSUM" >> $GITHUB_OUTPUT
|
echo "checksum=$CHECKSUM" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Verify macOS sharp packaging
|
||||||
|
run: node scripts/verify-macos-sharp.cjs release_asset/termix_macos_universal_dmg.dmg
|
||||||
|
|
||||||
- name: Prepare Homebrew submission files
|
- name: Prepare Homebrew submission files
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ steps.package-version.outputs.version }}"
|
VERSION="${{ steps.package-version.outputs.version }}"
|
||||||
@@ -1013,6 +1025,7 @@ jobs:
|
|||||||
npm ci
|
npm ci
|
||||||
npm install --force @rollup/rollup-darwin-arm64
|
npm install --force @rollup/rollup-darwin-arm64
|
||||||
npm install dmg-license
|
npm install dmg-license
|
||||||
|
node scripts/install-macos-sharp.cjs
|
||||||
|
|
||||||
- name: Check for Code Signing Certificates
|
- name: Check for Code Signing Certificates
|
||||||
id: check_certs
|
id: check_certs
|
||||||
@@ -1154,6 +1167,10 @@ jobs:
|
|||||||
BUILD_VERSION="${{ steps.build_number.outputs.build_version || github.run_number }}"
|
BUILD_VERSION="${{ steps.build_number.outputs.build_version || github.run_number }}"
|
||||||
npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION"
|
npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION"
|
||||||
|
|
||||||
|
- name: Verify macOS sharp packaging
|
||||||
|
if: steps.check_certs.outputs.has_certs == 'true'
|
||||||
|
run: node scripts/verify-macos-sharp.cjs release/termix_macos_universal_mas.pkg
|
||||||
|
|
||||||
- name: Generate App Store release notes
|
- name: Generate App Store release notes
|
||||||
id: asc_notes
|
id: asc_notes
|
||||||
if: steps.check_certs.outputs.has_certs == 'true' && steps.check_asc_creds.outputs.has_credentials == 'true'
|
if: steps.check_certs.outputs.has_certs == 'true' && steps.check_asc_creds.outputs.has_credentials == 'true'
|
||||||
|
|||||||
@@ -331,6 +331,11 @@ services:
|
|||||||
- termix-data:/app/data
|
- termix-data:/app/data
|
||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
|
GUACD_HOST: "guacd"
|
||||||
|
GUACD_TUNNEL_HOST: "termix"
|
||||||
|
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
|
||||||
|
# guacd, not the Termix container, reads and writes redirected-drive files.
|
||||||
|
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
|
||||||
depends_on:
|
depends_on:
|
||||||
- guacd
|
- guacd
|
||||||
networks:
|
networks:
|
||||||
@@ -340,8 +345,10 @@ services:
|
|||||||
image: guacamole/guacd:1.6.0
|
image: guacamole/guacd:1.6.0
|
||||||
container_name: guacd
|
container_name: guacd
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
volumes:
|
||||||
- "4822:4822"
|
# The official guacd image runs as a non-root user. Keep the drive path
|
||||||
|
# in this writable shared volume instead of bind-mounting /drive.
|
||||||
|
- termix-data:/termix-data
|
||||||
networks:
|
networks:
|
||||||
- termix-net
|
- termix-net
|
||||||
|
|
||||||
@@ -354,6 +361,15 @@ networks:
|
|||||||
driver: bridge
|
driver: bridge
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For multiple Termix backend instances, set the same `REDIS_URL` and optional
|
||||||
|
`TERMIX_REDIS_PREFIX` on every instance. Redis synchronizes collaboration room
|
||||||
|
presence, control requests, controller state, and events. It also routes Step CA
|
||||||
|
OAuth callbacks back to the instance holding the user's terminal; the optional
|
||||||
|
`TERMIX_STEP_CA_REDIS_PREFIX` isolates those short-lived encrypted messages.
|
||||||
|
Keep WebSocket session affinity enabled because live SSH and remote desktop
|
||||||
|
transports remain attached to the backend instance that opened them. A single
|
||||||
|
instance needs no Redis.
|
||||||
|
|
||||||
### Command Line Interface
|
### Command Line Interface
|
||||||
|
|
||||||
Termix also has a CLI, so you can manage your servers from a terminal and use Termix in your own scripts.
|
Termix also has a CLI, so you can manage your servers from a terminal and use Termix in your own scripts.
|
||||||
|
|||||||
+18
@@ -3,3 +3,21 @@
|
|||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
Please report any vulnerabilities to [GitHub Security](https://github.com/Termix-SSH/Termix/security/advisories).
|
Please report any vulnerabilities to [GitHub Security](https://github.com/Termix-SSH/Termix/security/advisories).
|
||||||
|
|
||||||
|
## External secret storage
|
||||||
|
|
||||||
|
By default, a single-container installation generates its keys in the Termix
|
||||||
|
data directory for ease of recovery. Production deployments that keep backups
|
||||||
|
or database files outside a trusted encrypted volume should set
|
||||||
|
`TERMIX_REQUIRE_EXTERNAL_SECRETS=true` and provide all four keys through a
|
||||||
|
secret manager:
|
||||||
|
|
||||||
|
- `JWT_SECRET` (at least 64 characters)
|
||||||
|
- `DATABASE_KEY` (64 hexadecimal characters)
|
||||||
|
- `ENCRYPTION_KEY` (64 hexadecimal characters)
|
||||||
|
- `INTERNAL_AUTH_TOKEN` (at least 32 characters)
|
||||||
|
|
||||||
|
Each value can instead be mounted as a Docker or Kubernetes secret and supplied
|
||||||
|
with its corresponding `_FILE` variable, such as `ENCRYPTION_KEY_FILE`.
|
||||||
|
Hardened mode fails closed instead of writing a replacement key beside the
|
||||||
|
encrypted database.
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ env:
|
|||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
DATA_DIR: /app/data
|
DATA_DIR: /app/data
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
TERMIX_REQUIRE_EXTERNAL_SECRETS: "false"
|
||||||
GUACD_RECORDING_PATH: /termix-data/session_recordings/guacamole
|
GUACD_RECORDING_PATH: /termix-data/session_recordings/guacamole
|
||||||
GUACD_RECORDING_BACKEND_PATH: /app/data/session_recordings/guacamole
|
GUACD_RECORDING_BACKEND_PATH: /app/data/session_recordings/guacamole
|
||||||
|
|
||||||
@@ -86,6 +87,9 @@ secrets:
|
|||||||
name: ""
|
name: ""
|
||||||
data:
|
data:
|
||||||
JWT_SECRET: ""
|
JWT_SECRET: ""
|
||||||
|
DATABASE_KEY: ""
|
||||||
|
ENCRYPTION_KEY: ""
|
||||||
|
INTERNAL_AUTH_TOKEN: ""
|
||||||
DATABASE_URL: ""
|
DATABASE_URL: ""
|
||||||
GUACAMOLE_ENCRYPTION_KEY: ""
|
GUACAMOLE_ENCRYPTION_KEY: ""
|
||||||
|
|
||||||
|
|||||||
+10
-7
@@ -1,5 +1,5 @@
|
|||||||
# Stage 1: Install dependencies
|
# Stage 1: Install dependencies
|
||||||
FROM node:24-slim AS deps
|
FROM node:26-slim AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||||
@@ -38,25 +38,28 @@ RUN rm -rf node_modules/better-sqlite3/prebuilds && \
|
|||||||
RUN npm run build:backend
|
RUN npm run build:backend
|
||||||
|
|
||||||
# Stage 4: Download OPKSSH binary for the target platform so the image works offline
|
# Stage 4: Download OPKSSH binary for the target platform so the image works offline
|
||||||
FROM node:24-slim AS opkssh-downloader
|
FROM node:26-slim AS opkssh-downloader
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
ARG OPKSSH_VERSION=v0.16.0
|
ARG OPKSSH_VERSION=v0.16.0
|
||||||
|
ARG OPKSSH_SHA256_AMD64=c018c3e7baf98612b923e742dd87be38650bf61e3b755fb2bc90de177568b1bf
|
||||||
|
ARG OPKSSH_SHA256_ARM64=9dd10c2b6ce99cde18e52c054877ca014134b291fd82afe71741c68db4f83d44
|
||||||
WORKDIR /opkssh
|
WORKDIR /opkssh
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y curl ca-certificates && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y curl ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN case "$TARGETARCH" in \
|
RUN case "$TARGETARCH" in \
|
||||||
amd64) OPKSSH_ARCH=amd64 ;; \
|
amd64) OPKSSH_ARCH=amd64; OPKSSH_SHA256="$OPKSSH_SHA256_AMD64" ;; \
|
||||||
arm64) OPKSSH_ARCH=arm64 ;; \
|
arm64) OPKSSH_ARCH=arm64; OPKSSH_SHA256="$OPKSSH_SHA256_ARM64" ;; \
|
||||||
*) echo "Unsupported architecture: $TARGETARCH" && exit 1 ;; \
|
*) echo "Unsupported architecture: $TARGETARCH" && exit 1 ;; \
|
||||||
esac && \
|
esac && \
|
||||||
curl -fSL -o "opkssh-linux-${OPKSSH_ARCH}" \
|
curl -fSL -o "opkssh-linux-${OPKSSH_ARCH}" \
|
||||||
"https://github.com/openpubkey/opkssh/releases/download/${OPKSSH_VERSION}/opkssh-linux-${OPKSSH_ARCH}" && \
|
"https://github.com/openpubkey/opkssh/releases/download/${OPKSSH_VERSION}/opkssh-linux-${OPKSSH_ARCH}" && \
|
||||||
|
echo "$OPKSSH_SHA256 opkssh-linux-${OPKSSH_ARCH}" | sha256sum -c - && \
|
||||||
chmod 755 "opkssh-linux-${OPKSSH_ARCH}" && \
|
chmod 755 "opkssh-linux-${OPKSSH_ARCH}" && \
|
||||||
echo -n "$OPKSSH_VERSION" > version.txt
|
echo -n "$OPKSSH_VERSION" > version.txt
|
||||||
|
|
||||||
# Stage 5: Production dependencies only
|
# Stage 5: Production dependencies only
|
||||||
FROM node:24-slim AS production-deps
|
FROM node:26-slim AS production-deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||||
@@ -76,7 +79,7 @@ RUN npm ci --omit=dev --ignore-scripts && \
|
|||||||
npm cache clean --force
|
npm cache clean --force
|
||||||
|
|
||||||
# Stage 6: Final optimized image
|
# Stage 6: Final optimized image
|
||||||
FROM node:24-slim
|
FROM node:26-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV DATA_DIR=/app/data \
|
ENV DATA_DIR=/app/data \
|
||||||
@@ -106,7 +109,7 @@ COPY --chown=node:node drizzle ./drizzle
|
|||||||
|
|
||||||
VOLUME ["/app/data"]
|
VOLUME ["/app/data"]
|
||||||
|
|
||||||
EXPOSE ${PORT} 30001 30002 30003 30004 30005 30006 30007 30008 30009 30010 30011 30012
|
EXPOSE ${PORT}
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||||
CMD wget -q -O /dev/null http://localhost:30001/health || exit 1
|
CMD wget -q -O /dev/null http://localhost:30001/health || exit 1
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ services:
|
|||||||
GUACD_HOST: "guacd-dev"
|
GUACD_HOST: "guacd-dev"
|
||||||
GUACD_TUNNEL_HOST: "termix-dev"
|
GUACD_TUNNEL_HOST: "termix-dev"
|
||||||
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
|
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
|
||||||
|
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
|
||||||
|
# REDIS_URL: "redis://redis:6379"
|
||||||
|
# TERMIX_REDIS_PREFIX: "termix:collab"
|
||||||
|
# TERMIX_STEP_CA_REDIS_PREFIX: "termix:step-ca"
|
||||||
depends_on:
|
depends_on:
|
||||||
- guacd-dev
|
- guacd-dev
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -12,6 +12,19 @@ services:
|
|||||||
GUACD_HOST: "guacd"
|
GUACD_HOST: "guacd"
|
||||||
GUACD_TUNNEL_HOST: "termix"
|
GUACD_TUNNEL_HOST: "termix"
|
||||||
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
|
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
|
||||||
|
# Where guacd stores files for RDP drive redirection (one folder per
|
||||||
|
# user is created underneath). Must be writable by guacd's user.
|
||||||
|
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
|
||||||
|
# Multi-instance deployments should point every backend at the same
|
||||||
|
# Redis. Without it, collaboration rooms keep their single-instance
|
||||||
|
# in-memory fallback.
|
||||||
|
# REDIS_URL: "redis://redis:6379"
|
||||||
|
# TERMIX_REDIS_PREFIX: "termix:collab"
|
||||||
|
# TERMIX_STEP_CA_REDIS_PREFIX: "termix:step-ca"
|
||||||
|
# Hardened deployments can require keys from environment variables or
|
||||||
|
# Docker secrets mounted through JWT_SECRET_FILE, DATABASE_KEY_FILE,
|
||||||
|
# ENCRYPTION_KEY_FILE and INTERNAL_AUTH_TOKEN_FILE.
|
||||||
|
# TERMIX_REQUIRE_EXTERNAL_SECRETS: "true"
|
||||||
# Trusted reverse-proxy authentication is disabled by default. When
|
# Trusted reverse-proxy authentication is disabled by default. When
|
||||||
# enabled, do not expose this container directly to untrusted clients.
|
# enabled, do not expose this container directly to untrusted clients.
|
||||||
# TRUSTED_PROXY_AUTH_ENABLED: "true"
|
# TRUSTED_PROXY_AUTH_ENABLED: "true"
|
||||||
|
|||||||
@@ -172,7 +172,14 @@ if [ -n "$BASE_PATH" ]; then
|
|||||||
echo "Injecting BASE_PATH: $BASE_PATH"
|
echo "Injecting BASE_PATH: $BASE_PATH"
|
||||||
# Strip trailing slash for use as a path prefix
|
# Strip trailing slash for use as a path prefix
|
||||||
CLEAN_BASE_PATH="${BASE_PATH%/}"
|
CLEAN_BASE_PATH="${BASE_PATH%/}"
|
||||||
find /app/html -name "index.html" -exec sed -i "s|window.__TERMIX_BASE_PATH__ = \"\"|window.__TERMIX_BASE_PATH__ = \"$CLEAN_BASE_PATH\"|g" {} \;
|
case "$CLEAN_BASE_PATH" in
|
||||||
|
/*) ;;
|
||||||
|
*) echo "BASE_PATH must start with /" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
case "$CLEAN_BASE_PATH" in
|
||||||
|
*[!A-Za-z0-9_./~-]*) echo "BASE_PATH contains unsupported characters" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
find /app/html -name "index.html" -exec sed -i "s|name=\"termix-base-path\" content=\"\"|name=\"termix-base-path\" content=\"$CLEAN_BASE_PATH\"|g" {} \;
|
||||||
# Patch sw.js static asset paths with the base path prefix
|
# Patch sw.js static asset paths with the base path prefix
|
||||||
find /app/html -name "sw.js" -exec sed -i "s|__TERMIX_SW_BASE_PATH__|$CLEAN_BASE_PATH|g" {} \;
|
find /app/html -name "sw.js" -exec sed -i "s|__TERMIX_SW_BASE_PATH__|$CLEAN_BASE_PATH|g" {} \;
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -161,6 +161,10 @@ http {
|
|||||||
root /app/html;
|
root /app/html;
|
||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
expires off;
|
expires off;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
add_header X-Content-Type-Options nosniff always;
|
add_header X-Content-Type-Options nosniff always;
|
||||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
|
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
|
||||||
|
|||||||
@@ -142,6 +142,10 @@ http {
|
|||||||
root /app/html;
|
root /app/html;
|
||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
expires off;
|
expires off;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
add_header X-Content-Type-Options nosniff always;
|
add_header X-Content-Type-Options nosniff always;
|
||||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
|
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
@@ -350,6 +350,13 @@ networks:
|
|||||||
driver: bridge
|
driver: bridge
|
||||||
```
|
```
|
||||||
|
|
||||||
|
部署多个 Termix 后端实例时,请为所有实例配置相同的 `REDIS_URL`,并可选配置
|
||||||
|
`TERMIX_REDIS_PREFIX`。Redis 会同步协作房间的在线成员、控制请求、控制权和事件,
|
||||||
|
也会把 Step CA OAuth 回调路由回持有用户终端的实例;可通过
|
||||||
|
`TERMIX_STEP_CA_REDIS_PREFIX` 隔离这些短期加密消息。实时 SSH 与远程桌面传输仍
|
||||||
|
依附于创建连接的后端实例,因此负载均衡器需要保持 WebSocket 会话亲和性。
|
||||||
|
单实例部署无需 Redis。
|
||||||
|
|
||||||
### 命令行工具
|
### 命令行工具
|
||||||
|
|
||||||
Termix 还提供命令行工具,你可以在终端里管理服务器,也可以把 Termix 用在自己的脚本中。
|
Termix 还提供命令行工具,你可以在终端里管理服务器,也可以把 Termix 用在自己的脚本中。
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
CREATE TABLE `collab_room_members` (
|
||||||
|
`id` int AUTO_INCREMENT NOT NULL,
|
||||||
|
`room_id` varchar(255) NOT NULL,
|
||||||
|
`user_id` varchar(255) NOT NULL,
|
||||||
|
`room_role` text NOT NULL DEFAULT ('member'),
|
||||||
|
`added_by` varchar(255),
|
||||||
|
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
CONSTRAINT `collab_room_members_id` PRIMARY KEY(`id`),
|
||||||
|
CONSTRAINT `idx_collab_room_members_room_user` UNIQUE(`room_id`,`user_id`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `collab_rooms` (
|
||||||
|
`id` varchar(255) NOT NULL,
|
||||||
|
`name` varchar(255) NOT NULL,
|
||||||
|
`owner_user_id` varchar(255) NOT NULL,
|
||||||
|
`persistent` boolean NOT NULL DEFAULT false,
|
||||||
|
`presenter_user_id` varchar(255),
|
||||||
|
`stage_protocol` text,
|
||||||
|
`stage_host_id` int,
|
||||||
|
`stage_share_id` varchar(255),
|
||||||
|
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
`ended_at` text,
|
||||||
|
CONSTRAINT `collab_rooms_id` PRIMARY KEY(`id`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `collab_room_members` ADD CONSTRAINT `collab_room_members_room_id_collab_rooms_id_fk` FOREIGN KEY (`room_id`) REFERENCES `collab_rooms`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `collab_room_members` ADD CONSTRAINT `collab_room_members_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `collab_room_members` ADD CONSTRAINT `collab_room_members_added_by_users_id_fk` FOREIGN KEY (`added_by`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `collab_rooms` ADD CONSTRAINT `collab_rooms_owner_user_id_users_id_fk` FOREIGN KEY (`owner_user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `collab_rooms` ADD CONSTRAINT `collab_rooms_presenter_user_id_users_id_fk` FOREIGN KEY (`presenter_user_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `collab_rooms` ADD CONSTRAINT `collab_rooms_stage_host_id_ssh_data_id_fk` FOREIGN KEY (`stage_host_id`) REFERENCES `ssh_data`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `collab_rooms` ADD CONSTRAINT `collab_rooms_stage_share_id_session_shares_id_fk` FOREIGN KEY (`stage_share_id`) REFERENCES `session_shares`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_collab_room_members_user` ON `collab_room_members` (`user_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_collab_rooms_owner` ON `collab_rooms` (`owner_user_id`);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `collab_rooms` ADD `guest_link_token` varchar(255);--> statement-breakpoint
|
||||||
|
ALTER TABLE `collab_rooms` ADD CONSTRAINT `idx_collab_rooms_guest_token` UNIQUE(`guest_link_token`);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE `secret_sources` (
|
||||||
|
`id` varchar(255) NOT NULL,
|
||||||
|
`user_id` varchar(255) NOT NULL,
|
||||||
|
`name` varchar(255) NOT NULL,
|
||||||
|
`kind` text NOT NULL DEFAULT ('onepassword-connect'),
|
||||||
|
`base_url` text NOT NULL,
|
||||||
|
`token` text NOT NULL,
|
||||||
|
`shared` boolean NOT NULL DEFAULT false,
|
||||||
|
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
`updated_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
CONSTRAINT `secret_sources_id` PRIMARY KEY(`id`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `secret_sources` ADD CONSTRAINT `secret_sources_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_secret_sources_user` ON `secret_sources` (`user_id`);
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
CREATE TABLE `credential_access` (
|
||||||
|
`id` int AUTO_INCREMENT NOT NULL,
|
||||||
|
`credential_id` int NOT NULL,
|
||||||
|
`user_id` varchar(255),
|
||||||
|
`role_id` int,
|
||||||
|
`granted_by` varchar(255) NOT NULL,
|
||||||
|
`permission_level` text NOT NULL DEFAULT ('use'),
|
||||||
|
`expires_at` varchar(255),
|
||||||
|
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
CONSTRAINT `credential_access_id` PRIMARY KEY(`id`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `shared_credential_secrets` (
|
||||||
|
`id` int AUTO_INCREMENT NOT NULL,
|
||||||
|
`credential_access_id` int NOT NULL,
|
||||||
|
`target_user_id` varchar(255) NOT NULL,
|
||||||
|
`credential_id` int NOT NULL,
|
||||||
|
`encrypted_username` text,
|
||||||
|
`auth_type` text NOT NULL DEFAULT ('password'),
|
||||||
|
`encrypted_password` text,
|
||||||
|
`encrypted_key` text,
|
||||||
|
`encrypted_key_password` text,
|
||||||
|
`key_type` text,
|
||||||
|
`public_key` text,
|
||||||
|
`cert_public_key` text,
|
||||||
|
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
`updated_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
CONSTRAINT `shared_credential_secrets_id` PRIMARY KEY(`id`),
|
||||||
|
CONSTRAINT `idx_shared_credential_secrets_scope` UNIQUE(`credential_access_id`,`target_user_id`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `credential_access` ADD CONSTRAINT `credential_access_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `credential_access` ADD CONSTRAINT `credential_access_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `credential_access` ADD CONSTRAINT `credential_access_role_id_roles_id_fk` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `credential_access` ADD CONSTRAINT `credential_access_granted_by_users_id_fk` FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `shared_credential_secrets` ADD CONSTRAINT `shared_cred_secrets_access_id_fk` FOREIGN KEY (`credential_access_id`) REFERENCES `credential_access`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `shared_credential_secrets` ADD CONSTRAINT `shared_credential_secrets_target_user_id_users_id_fk` FOREIGN KEY (`target_user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `shared_credential_secrets` ADD CONSTRAINT `shared_credential_secrets_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_credential_access_user_id` ON `credential_access` (`user_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_credential_access_role_id` ON `credential_access` (`role_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_credential_access_credential_id` ON `credential_access` (`credential_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_shared_credential_secrets_target` ON `shared_credential_secrets` (`target_user_id`,`credential_id`);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
CREATE TABLE `folder_access` (
|
||||||
|
`id` int AUTO_INCREMENT NOT NULL,
|
||||||
|
`owner_user_id` varchar(255) NOT NULL,
|
||||||
|
`folder` varchar(255) NOT NULL,
|
||||||
|
`user_id` varchar(255),
|
||||||
|
`role_id` int,
|
||||||
|
`granted_by` varchar(255) NOT NULL,
|
||||||
|
`permission_level` text NOT NULL DEFAULT ('connect'),
|
||||||
|
`expires_at` varchar(255),
|
||||||
|
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
CONSTRAINT `folder_access_id` PRIMARY KEY(`id`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `ssh_data` MODIFY COLUMN `folder` varchar(255);--> statement-breakpoint
|
||||||
|
ALTER TABLE `snippets` MODIFY COLUMN `folder` varchar(255);--> statement-breakpoint
|
||||||
|
ALTER TABLE `ssh_credentials` MODIFY COLUMN `folder` varchar(255);--> statement-breakpoint
|
||||||
|
ALTER TABLE `vault_profiles` MODIFY COLUMN `folder` varchar(255);--> statement-breakpoint
|
||||||
|
ALTER TABLE `folder_access` ADD CONSTRAINT `folder_access_owner_user_id_users_id_fk` FOREIGN KEY (`owner_user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `folder_access` ADD CONSTRAINT `folder_access_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `folder_access` ADD CONSTRAINT `folder_access_role_id_roles_id_fk` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `folder_access` ADD CONSTRAINT `folder_access_granted_by_users_id_fk` FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_folder_access_owner_folder` ON `folder_access` (`owner_user_id`,`folder`);
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,41 @@
|
|||||||
"when": 1786757023790,
|
"when": 1786757023790,
|
||||||
"tag": "0014_bitter_nextwave",
|
"tag": "0014_bitter_nextwave",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1787576997080,
|
||||||
|
"tag": "0015_red_cobalt_man",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1787581983770,
|
||||||
|
"tag": "0016_unusual_tyrannus",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1787596414390,
|
||||||
|
"tag": "0017_spicy_proteus",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 18,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1787600445146,
|
||||||
|
"tag": "0018_fancy_barracuda",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 19,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1787602480861,
|
||||||
|
"tag": "0019_certain_archangel",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
CREATE TABLE "collab_room_members" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"room_id" varchar(255) NOT NULL,
|
||||||
|
"user_id" varchar(255) NOT NULL,
|
||||||
|
"room_role" text DEFAULT 'member' NOT NULL,
|
||||||
|
"added_by" varchar(255),
|
||||||
|
"created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "collab_rooms" (
|
||||||
|
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"owner_user_id" varchar(255) NOT NULL,
|
||||||
|
"persistent" boolean DEFAULT false NOT NULL,
|
||||||
|
"presenter_user_id" varchar(255),
|
||||||
|
"stage_protocol" text,
|
||||||
|
"stage_host_id" integer,
|
||||||
|
"stage_share_id" varchar(255),
|
||||||
|
"created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"ended_at" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "collab_room_members" ADD CONSTRAINT "collab_room_members_room_id_collab_rooms_id_fk" FOREIGN KEY ("room_id") REFERENCES "public"."collab_rooms"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "collab_room_members" ADD CONSTRAINT "collab_room_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "collab_room_members" ADD CONSTRAINT "collab_room_members_added_by_users_id_fk" FOREIGN KEY ("added_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "collab_rooms" ADD CONSTRAINT "collab_rooms_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "collab_rooms" ADD CONSTRAINT "collab_rooms_presenter_user_id_users_id_fk" FOREIGN KEY ("presenter_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "collab_rooms" ADD CONSTRAINT "collab_rooms_stage_host_id_ssh_data_id_fk" FOREIGN KEY ("stage_host_id") REFERENCES "public"."ssh_data"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "collab_rooms" ADD CONSTRAINT "collab_rooms_stage_share_id_session_shares_id_fk" FOREIGN KEY ("stage_share_id") REFERENCES "public"."session_shares"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "idx_collab_room_members_room_user" ON "collab_room_members" USING btree ("room_id","user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_collab_room_members_user" ON "collab_room_members" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_collab_rooms_owner" ON "collab_rooms" USING btree ("owner_user_id");
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "collab_rooms" ADD COLUMN "guest_link_token" varchar(255);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "idx_collab_rooms_guest_token" ON "collab_rooms" USING btree ("guest_link_token");
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE "secret_sources" (
|
||||||
|
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" varchar(255) NOT NULL,
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"kind" text DEFAULT 'onepassword-connect' NOT NULL,
|
||||||
|
"base_url" text NOT NULL,
|
||||||
|
"token" text NOT NULL,
|
||||||
|
"shared" boolean DEFAULT false NOT NULL,
|
||||||
|
"created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updated_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "secret_sources" ADD CONSTRAINT "secret_sources_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_secret_sources_user" ON "secret_sources" USING btree ("user_id");
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
CREATE TABLE "credential_access" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"credential_id" integer NOT NULL,
|
||||||
|
"user_id" varchar(255),
|
||||||
|
"role_id" integer,
|
||||||
|
"granted_by" varchar(255) NOT NULL,
|
||||||
|
"permission_level" text DEFAULT 'use' NOT NULL,
|
||||||
|
"expires_at" varchar(255),
|
||||||
|
"created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "shared_credential_secrets" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"credential_access_id" integer NOT NULL,
|
||||||
|
"target_user_id" varchar(255) NOT NULL,
|
||||||
|
"credential_id" integer NOT NULL,
|
||||||
|
"encrypted_username" text,
|
||||||
|
"auth_type" text DEFAULT 'password' NOT NULL,
|
||||||
|
"encrypted_password" text,
|
||||||
|
"encrypted_key" text,
|
||||||
|
"encrypted_key_password" text,
|
||||||
|
"key_type" text,
|
||||||
|
"public_key" text,
|
||||||
|
"cert_public_key" text,
|
||||||
|
"created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updated_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "credential_access" ADD CONSTRAINT "credential_access_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "credential_access" ADD CONSTRAINT "credential_access_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "credential_access" ADD CONSTRAINT "credential_access_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "credential_access" ADD CONSTRAINT "credential_access_granted_by_users_id_fk" FOREIGN KEY ("granted_by") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "shared_credential_secrets" ADD CONSTRAINT "shared_credential_secrets_credential_access_id_credential_access_id_fk" FOREIGN KEY ("credential_access_id") REFERENCES "public"."credential_access"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "shared_credential_secrets" ADD CONSTRAINT "shared_credential_secrets_target_user_id_users_id_fk" FOREIGN KEY ("target_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "shared_credential_secrets" ADD CONSTRAINT "shared_credential_secrets_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 INDEX "idx_credential_access_user_id" ON "credential_access" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_credential_access_role_id" ON "credential_access" USING btree ("role_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_credential_access_credential_id" ON "credential_access" USING btree ("credential_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "idx_shared_credential_secrets_scope" ON "shared_credential_secrets" USING btree ("credential_access_id","target_user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_shared_credential_secrets_target" ON "shared_credential_secrets" USING btree ("target_user_id","credential_id");
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
CREATE TABLE "folder_access" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"owner_user_id" varchar(255) NOT NULL,
|
||||||
|
"folder" varchar(255) NOT NULL,
|
||||||
|
"user_id" varchar(255),
|
||||||
|
"role_id" integer,
|
||||||
|
"granted_by" varchar(255) NOT NULL,
|
||||||
|
"permission_level" text DEFAULT 'connect' NOT NULL,
|
||||||
|
"expires_at" varchar(255),
|
||||||
|
"created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "ssh_data" ALTER COLUMN "folder" SET DATA TYPE varchar(255);--> statement-breakpoint
|
||||||
|
ALTER TABLE "snippets" ALTER COLUMN "folder" SET DATA TYPE varchar(255);--> statement-breakpoint
|
||||||
|
ALTER TABLE "ssh_credentials" ALTER COLUMN "folder" SET DATA TYPE varchar(255);--> statement-breakpoint
|
||||||
|
ALTER TABLE "vault_profiles" ALTER COLUMN "folder" SET DATA TYPE varchar(255);--> statement-breakpoint
|
||||||
|
ALTER TABLE "folder_access" ADD CONSTRAINT "folder_access_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "folder_access" ADD CONSTRAINT "folder_access_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "folder_access" ADD CONSTRAINT "folder_access_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "folder_access" ADD CONSTRAINT "folder_access_granted_by_users_id_fk" FOREIGN KEY ("granted_by") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_folder_access_owner_folder" ON "folder_access" USING btree ("owner_user_id","folder");
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,41 @@
|
|||||||
"when": 1786757021444,
|
"when": 1786757021444,
|
||||||
"tag": "0014_unusual_maelstrom",
|
"tag": "0014_unusual_maelstrom",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787576994553,
|
||||||
|
"tag": "0015_early_spitfire",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787581981251,
|
||||||
|
"tag": "0016_slippery_anita_blake",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787596411353,
|
||||||
|
"tag": "0017_curved_pet_avengers",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 18,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787600442827,
|
||||||
|
"tag": "0018_puzzling_night_nurse",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 19,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787602478567,
|
||||||
|
"tag": "0019_oval_puff_adder",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
CREATE TABLE `collab_room_members` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`room_id` text NOT NULL,
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
`room_role` text DEFAULT 'member' NOT NULL,
|
||||||
|
`added_by` text,
|
||||||
|
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
FOREIGN KEY (`room_id`) REFERENCES `collab_rooms`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`added_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `idx_collab_room_members_room_user` ON `collab_room_members` (`room_id`,`user_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_collab_room_members_user` ON `collab_room_members` (`user_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `collab_rooms` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`owner_user_id` text NOT NULL,
|
||||||
|
`persistent` integer DEFAULT false NOT NULL,
|
||||||
|
`presenter_user_id` text,
|
||||||
|
`stage_protocol` text,
|
||||||
|
`stage_host_id` integer,
|
||||||
|
`stage_share_id` text,
|
||||||
|
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
`ended_at` text,
|
||||||
|
FOREIGN KEY (`owner_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`presenter_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null,
|
||||||
|
FOREIGN KEY (`stage_host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE set null,
|
||||||
|
FOREIGN KEY (`stage_share_id`) REFERENCES `session_shares`(`id`) ON UPDATE no action ON DELETE set null
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_collab_rooms_owner` ON `collab_rooms` (`owner_user_id`);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `collab_rooms` ADD `guest_link_token` text;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `idx_collab_rooms_guest_token` ON `collab_rooms` (`guest_link_token`);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE `secret_sources` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`kind` text DEFAULT 'onepassword-connect' NOT NULL,
|
||||||
|
`base_url` text NOT NULL,
|
||||||
|
`token` text NOT NULL,
|
||||||
|
`shared` integer DEFAULT false 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
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_secret_sources_user` ON `secret_sources` (`user_id`);
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
CREATE TABLE `credential_access` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`credential_id` integer NOT NULL,
|
||||||
|
`user_id` text,
|
||||||
|
`role_id` integer,
|
||||||
|
`granted_by` text NOT NULL,
|
||||||
|
`permission_level` text DEFAULT 'use' NOT NULL,
|
||||||
|
`expires_at` text,
|
||||||
|
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_credential_access_user_id` ON `credential_access` (`user_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_credential_access_role_id` ON `credential_access` (`role_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_credential_access_credential_id` ON `credential_access` (`credential_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `shared_credential_secrets` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`credential_access_id` integer NOT NULL,
|
||||||
|
`target_user_id` text NOT NULL,
|
||||||
|
`credential_id` integer NOT NULL,
|
||||||
|
`encrypted_username` text,
|
||||||
|
`auth_type` text DEFAULT 'password' NOT NULL,
|
||||||
|
`encrypted_password` text,
|
||||||
|
`encrypted_key` text(16384),
|
||||||
|
`encrypted_key_password` text,
|
||||||
|
`key_type` text,
|
||||||
|
`public_key` text(4096),
|
||||||
|
`cert_public_key` text(8192),
|
||||||
|
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
`updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
FOREIGN KEY (`credential_access_id`) REFERENCES `credential_access`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`target_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 `idx_shared_credential_secrets_scope` ON `shared_credential_secrets` (`credential_access_id`,`target_user_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_shared_credential_secrets_target` ON `shared_credential_secrets` (`target_user_id`,`credential_id`);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
CREATE TABLE `folder_access` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`owner_user_id` text NOT NULL,
|
||||||
|
`folder` text NOT NULL,
|
||||||
|
`user_id` text,
|
||||||
|
`role_id` integer,
|
||||||
|
`granted_by` text NOT NULL,
|
||||||
|
`permission_level` text DEFAULT 'connect' NOT NULL,
|
||||||
|
`expires_at` text,
|
||||||
|
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
FOREIGN KEY (`owner_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_folder_access_owner_folder` ON `folder_access` (`owner_user_id`,`folder`);
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,41 @@
|
|||||||
"when": 1786757019248,
|
"when": 1786757019248,
|
||||||
"tag": "0010_mean_queen_noir",
|
"tag": "0010_mean_queen_noir",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 11,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1787576992255,
|
||||||
|
"tag": "0011_wakeful_titanium_man",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1787581978969,
|
||||||
|
"tag": "0012_yellow_firedrake",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1787596405780,
|
||||||
|
"tag": "0013_tranquil_master_mold",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1787600440707,
|
||||||
|
"tag": "0014_lean_doctor_octopus",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1787602476319,
|
||||||
|
"tag": "0015_glossy_shotgun",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+36
-8
@@ -21,7 +21,7 @@ const net = require("net");
|
|||||||
const tls = require("tls");
|
const tls = require("tls");
|
||||||
const zlib = require("zlib");
|
const zlib = require("zlib");
|
||||||
const crypto = require("crypto");
|
const crypto = require("crypto");
|
||||||
const { URL } = require("url");
|
const { URL, pathToFileURL } = require("url");
|
||||||
const { fork, spawn } = require("child_process");
|
const { fork, spawn } = require("child_process");
|
||||||
const pty = require("node-pty");
|
const pty = require("node-pty");
|
||||||
const WebSocket = require("ws");
|
const WebSocket = require("ws");
|
||||||
@@ -536,6 +536,16 @@ function httpFetch(url, options = {}) {
|
|||||||
// Node's http/https modules never auto-decompress, so an unhandled
|
// Node's http/https modules never auto-decompress, so an unhandled
|
||||||
// content-encoding here silently turns the body into garbage bytes.
|
// content-encoding here silently turns the body into garbage bytes.
|
||||||
let stream = res;
|
let stream = res;
|
||||||
|
const maxResponseBytes = options.maxResponseBytes || 10 * 1024 * 1024;
|
||||||
|
let responseBytes = 0;
|
||||||
|
let settled = false;
|
||||||
|
const fail = (error) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
stream.destroy();
|
||||||
|
req.destroy();
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
const encoding = (res.headers["content-encoding"] || "")
|
const encoding = (res.headers["content-encoding"] || "")
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.trim();
|
.trim();
|
||||||
@@ -552,8 +562,17 @@ function httpFetch(url, options = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
stream.on("data", (chunk) => chunks.push(chunk));
|
stream.on("data", (chunk) => {
|
||||||
|
responseBytes += chunk.length;
|
||||||
|
if (responseBytes > maxResponseBytes) {
|
||||||
|
fail(new Error(`Response exceeds ${maxResponseBytes} bytes`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
stream.on("end", () => {
|
stream.on("end", () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
const data = Buffer.concat(chunks).toString("utf8");
|
const data = Buffer.concat(chunks).toString("utf8");
|
||||||
resolve({
|
resolve({
|
||||||
ok: res.statusCode >= 200 && res.statusCode < 300,
|
ok: res.statusCode >= 200 && res.statusCode < 300,
|
||||||
@@ -562,7 +581,7 @@ function httpFetch(url, options = {}) {
|
|||||||
json: () => Promise.resolve(JSON.parse(data)),
|
json: () => Promise.resolve(JSON.parse(data)),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
stream.on("error", reject);
|
stream.on("error", fail);
|
||||||
});
|
});
|
||||||
|
|
||||||
req.on("error", reject);
|
req.on("error", reject);
|
||||||
@@ -1193,11 +1212,12 @@ function createWindow() {
|
|||||||
webPreferences: {
|
webPreferences: {
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
webSecurity: false,
|
sandbox: true,
|
||||||
|
webSecurity: true,
|
||||||
preload: path.join(__dirname, "preload.js"),
|
preload: path.join(__dirname, "preload.js"),
|
||||||
partition: termixSessionPartition,
|
partition: termixSessionPartition,
|
||||||
allowRunningInsecureContent: true,
|
allowRunningInsecureContent: false,
|
||||||
webviewTag: true,
|
webviewTag: false,
|
||||||
offscreen: false,
|
offscreen: false,
|
||||||
},
|
},
|
||||||
show: true,
|
show: true,
|
||||||
@@ -1377,6 +1397,13 @@ function createWindow() {
|
|||||||
}
|
}
|
||||||
return { action: "deny" };
|
return { action: "deny" };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mainWindow.webContents.on("will-navigate", (event, url) => {
|
||||||
|
const allowedUrl = isDev
|
||||||
|
? url.startsWith("http://localhost:5173/")
|
||||||
|
: url === pathToFileURL(path.join(appRoot, "dist", "index.html")).href;
|
||||||
|
if (!allowedUrl) event.preventDefault();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ipcMain.handle("get-app-version", () => {
|
ipcMain.handle("get-app-version", () => {
|
||||||
@@ -1646,7 +1673,7 @@ ipcMain.handle("clear-remote-sync-config", async () => {
|
|||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("save-remote-sync-jwt", (_event, token) => {
|
ipcMain.handle("save-remote-sync-jwt", async (_event, token) => {
|
||||||
const result = remoteSync.saveRemoteSyncJwt(token);
|
const result = remoteSync.saveRemoteSyncJwt(token);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
remoteSync.getRemoteSyncEngine()?.updateStatus({
|
remoteSync.getRemoteSyncEngine()?.updateStatus({
|
||||||
@@ -1654,7 +1681,8 @@ ipcMain.handle("save-remote-sync-jwt", (_event, token) => {
|
|||||||
needsReauth: false,
|
needsReauth: false,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
});
|
});
|
||||||
remoteSync.getRemoteSyncEngine()?.syncNow();
|
const status = await remoteSync.getRemoteSyncEngine()?.syncNow();
|
||||||
|
return { ...result, status: status || null };
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|||||||
+24
-1
@@ -1,5 +1,28 @@
|
|||||||
const { contextBridge, ipcRenderer } = require("electron");
|
const { contextBridge, ipcRenderer } = require("electron");
|
||||||
|
|
||||||
|
const ALLOWED_INVOKE_CHANNELS = new Set([
|
||||||
|
"check-electron-update",
|
||||||
|
"clear-remote-sync-config",
|
||||||
|
"get-desktop-settings",
|
||||||
|
"get-legacy-server-config",
|
||||||
|
"get-remote-sync-config",
|
||||||
|
"get-remote-sync-jwt",
|
||||||
|
"get-remote-sync-status",
|
||||||
|
"get-remote-sync-user-info",
|
||||||
|
"remote-sync-now",
|
||||||
|
"save-desktop-settings",
|
||||||
|
"save-remote-sync-config",
|
||||||
|
"save-remote-sync-jwt",
|
||||||
|
"test-server-connection",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function invokeAllowed(channel, ...args) {
|
||||||
|
if (!ALLOWED_INVOKE_CHANNELS.has(channel)) {
|
||||||
|
return Promise.reject(new Error(`IPC channel is not allowed: ${channel}`));
|
||||||
|
}
|
||||||
|
return ipcRenderer.invoke(channel, ...args);
|
||||||
|
}
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("electronAPI", {
|
contextBridge.exposeInMainWorld("electronAPI", {
|
||||||
getAppVersion: () => ipcRenderer.invoke("get-app-version"),
|
getAppVersion: () => ipcRenderer.invoke("get-app-version"),
|
||||||
getPlatform: () => ipcRenderer.invoke("get-platform"),
|
getPlatform: () => ipcRenderer.invoke("get-platform"),
|
||||||
@@ -103,7 +126,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
return () => ipcRenderer.removeListener(channel, listener);
|
return () => ipcRenderer.removeListener(channel, listener);
|
||||||
},
|
},
|
||||||
|
|
||||||
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
|
invoke: invokeAllowed,
|
||||||
});
|
});
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("electronClipboard", {
|
contextBridge.exposeInMainWorld("electronClipboard", {
|
||||||
|
|||||||
@@ -37,7 +37,20 @@ function writeJson(filePath, value) {
|
|||||||
if (!fs.existsSync(userDataPath)) {
|
if (!fs.existsSync(userDataPath)) {
|
||||||
fs.mkdirSync(userDataPath, { recursive: true });
|
fs.mkdirSync(userDataPath, { recursive: true });
|
||||||
}
|
}
|
||||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2));
|
const temporaryPath = `${filePath}.${process.pid}-${Date.now()}.tmp`;
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(temporaryPath, JSON.stringify(value, null, 2), {
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
fs.renameSync(temporaryPath, filePath);
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(temporaryPath);
|
||||||
|
} catch {
|
||||||
|
// already absent
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDesktopSettingsPath() {
|
function getDesktopSettingsPath() {
|
||||||
@@ -191,6 +204,7 @@ class RemoteSyncEngine {
|
|||||||
this.getMainWindow = getMainWindow;
|
this.getMainWindow = getMainWindow;
|
||||||
this.localJwt = null;
|
this.localJwt = null;
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
|
this.startupTimer = null;
|
||||||
this.syncing = false;
|
this.syncing = false;
|
||||||
this.status = {
|
this.status = {
|
||||||
connected: false,
|
connected: false,
|
||||||
@@ -220,11 +234,15 @@ class RemoteSyncEngine {
|
|||||||
const config = getRemoteSyncConfig();
|
const config = getRemoteSyncConfig();
|
||||||
this.status.connected = !!config?.serverUrl;
|
this.status.connected = !!config?.serverUrl;
|
||||||
if (this.timer) clearInterval(this.timer);
|
if (this.timer) clearInterval(this.timer);
|
||||||
|
if (this.startupTimer) clearTimeout(this.startupTimer);
|
||||||
this.timer = setInterval(() => this.syncNow(), SYNC_INTERVAL_MS);
|
this.timer = setInterval(() => this.syncNow(), SYNC_INTERVAL_MS);
|
||||||
if (config?.serverUrl) {
|
if (config?.serverUrl) {
|
||||||
// Fire an initial sync shortly after startup rather than waiting a
|
// Fire an initial sync shortly after startup rather than waiting a
|
||||||
// full interval, but don't block app boot on it.
|
// full interval, but don't block app boot on it.
|
||||||
setTimeout(() => this.syncNow(), 5000);
|
this.startupTimer = setTimeout(() => {
|
||||||
|
this.startupTimer = null;
|
||||||
|
this.syncNow();
|
||||||
|
}, 5000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +251,10 @@ class RemoteSyncEngine {
|
|||||||
clearInterval(this.timer);
|
clearInterval(this.timer);
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
}
|
}
|
||||||
|
if (this.startupTimer) {
|
||||||
|
clearTimeout(this.startupTimer);
|
||||||
|
this.startupTimer = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async syncNow() {
|
async syncNow() {
|
||||||
|
|||||||
+1
-3
@@ -9,6 +9,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<meta name="theme-color" content="#09090b" />
|
<meta name="theme-color" content="#09090b" />
|
||||||
|
<meta name="termix-base-path" content="" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta
|
<meta
|
||||||
name="apple-mobile-web-app-status-bar-style"
|
name="apple-mobile-web-app-status-bar-style"
|
||||||
@@ -68,9 +69,6 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script>
|
|
||||||
window.__TERMIX_BASE_PATH__ = "";
|
|
||||||
</script>
|
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Generated
+387
-277
File diff suppressed because it is too large
Load Diff
+21
-20
@@ -49,12 +49,12 @@
|
|||||||
"@anthropic-ai/sdk": "^0.116.0",
|
"@anthropic-ai/sdk": "^0.116.0",
|
||||||
"@simplewebauthn/browser": "^13.3.0",
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
"@simplewebauthn/server": "^13.3.2",
|
"@simplewebauthn/server": "^13.3.2",
|
||||||
"@tanstack/react-virtual": "^3.14.9",
|
"@tanstack/react-virtual": "^3.14.10",
|
||||||
"@types/compression": "^1.8.1",
|
"@types/compression": "^1.8.1",
|
||||||
"@types/ldapjs": "^3.0.6",
|
"@types/ldapjs": "^3.0.6",
|
||||||
"axios": "^1.19.0",
|
"axios": "^1.19.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^13.0.2",
|
"better-sqlite3": "^13.0.3",
|
||||||
"body-parser": "^2.3.0",
|
"body-parser": "^2.3.0",
|
||||||
"chalk": "^6.0.0",
|
"chalk": "^6.0.0",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
@@ -64,33 +64,34 @@
|
|||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"guacamole-lite": "^1.2.0",
|
"guacamole-lite": "^1.2.0",
|
||||||
"jose": "^6.2.8",
|
"jose": "^6.2.9",
|
||||||
"js-yaml": "^5.2.3",
|
"js-yaml": "^5.2.3",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"ldapjs": "^3.0.7",
|
"ldapjs": "^3.0.7",
|
||||||
"motion": "^12.43.0",
|
"motion": "^13.1.1",
|
||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
"mysql2": "^3.23.2",
|
"mysql2": "^3.23.4",
|
||||||
"nanoid": "^6.0.1",
|
"nanoid": "^6.0.1",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
"pg": "^8.22.0",
|
"pg": "^8.22.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
"redis": "^6.2.1",
|
||||||
"serialport": "^13.0.0",
|
"serialport": "^13.0.0",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.3",
|
||||||
"socks": "^2.8.7",
|
"socks": "^2.8.7",
|
||||||
"speakeasy": "^2.0.0",
|
"speakeasy": "^2.0.0",
|
||||||
"ssh2": "^1.17.0",
|
"ssh2": "^1.17.0",
|
||||||
"undici": "^8.10.0",
|
"undici": "^8.10.0",
|
||||||
"ws": "^8.21.1"
|
"ws": "^8.21.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@codemirror/autocomplete": "^6.20.3",
|
"@codemirror/autocomplete": "^6.20.3",
|
||||||
"@codemirror/commands": "^6.10.4",
|
"@codemirror/commands": "^6.10.4",
|
||||||
"@codemirror/search": "^6.7.1",
|
"@codemirror/search": "^6.7.1",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.43.7",
|
"@codemirror/view": "^6.43.9",
|
||||||
"@commitlint/cli": "^21.2.1",
|
"@commitlint/cli": "^21.2.2",
|
||||||
"@commitlint/config-conventional": "^21.2.0",
|
"@commitlint/config-conventional": "^21.2.0",
|
||||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||||
"@electron/notarize": "^3.1.1",
|
"@electron/notarize": "^3.1.1",
|
||||||
@@ -119,9 +120,9 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.16",
|
"@radix-ui/react-tooltip": "^1.2.16",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^7.0.0",
|
"@testing-library/jest-dom": "^7.0.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.5",
|
||||||
"@types/better-sqlite3": "^9.6.0",
|
"@types/better-sqlite3": "^9.6.0",
|
||||||
"@types/cookie-parser": "^1.4.10",
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
@@ -142,8 +143,8 @@
|
|||||||
"@uiw/codemirror-theme-github": "^4.25.11",
|
"@uiw/codemirror-theme-github": "^4.25.11",
|
||||||
"@uiw/react-codemirror": "^4.25.11",
|
"@uiw/react-codemirror": "^4.25.11",
|
||||||
"@vitejs/plugin-react": "^6.0.5",
|
"@vitejs/plugin-react": "^6.0.5",
|
||||||
"@vitest/coverage-v8": "^4.1.10",
|
"@vitest/coverage-v8": "^4.1.11",
|
||||||
"@vitest/ui": "^4.1.10",
|
"@vitest/ui": "^4.1.11",
|
||||||
"@xterm/addon-clipboard": "^0.2.0",
|
"@xterm/addon-clipboard": "^0.2.0",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-search": "^0.16.0",
|
"@xterm/addon-search": "^0.16.0",
|
||||||
@@ -153,14 +154,14 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"concurrently": "^10.0.4",
|
"concurrently": "^10.0.5",
|
||||||
"cytoscape": "^3.34.0",
|
"cytoscape": "^3.34.1",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"electron": "^43.2.0",
|
"electron": "^43.2.0",
|
||||||
"electron-builder": "^26.15.3",
|
"electron-builder": "^26.15.3",
|
||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.1",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.3",
|
"eslint-plugin-react-refresh": "^0.5.4",
|
||||||
"eslint-plugin-unused-imports": "^4.4.1",
|
"eslint-plugin-unused-imports": "^4.4.1",
|
||||||
"globals": "^17.8.0",
|
"globals": "^17.8.0",
|
||||||
"guacamole-common-js": "^1.5.0",
|
"guacamole-common-js": "^1.5.0",
|
||||||
@@ -177,7 +178,7 @@
|
|||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-h5-audio-player": "^3.10.2",
|
"react-h5-audio-player": "^3.10.2",
|
||||||
"react-hook-form": "^7.84.0",
|
"react-hook-form": "^7.84.0",
|
||||||
"react-i18next": "^17.0.11",
|
"react-i18next": "^17.0.12",
|
||||||
"react-icons": "^5.7.0",
|
"react-icons": "^5.7.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-pdf": "^10.4.1",
|
"react-pdf": "^10.4.1",
|
||||||
@@ -185,15 +186,15 @@
|
|||||||
"react-syntax-highlighter": "^16.1.1",
|
"react-syntax-highlighter": "^16.1.1",
|
||||||
"react-xtermjs": "^1.0.10",
|
"react-xtermjs": "^1.0.10",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.8",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "~6.0.3",
|
"typescript": "~6.0.3",
|
||||||
"typescript-eslint": "^8.66.0",
|
"typescript-eslint": "^8.66.0",
|
||||||
"vite": "^8.2.0",
|
"vite": "^8.2.2",
|
||||||
"vite-plugin-svgr": "^5.2.0",
|
"vite-plugin-svgr": "^5.2.0",
|
||||||
"vitest": "^4.1.10"
|
"vitest": "^4.1.11"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{ts,tsx}": [
|
"*.{ts,tsx}": [
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const main = readFileSync("electron/main.cjs", "utf8");
|
||||||
|
const preload = readFileSync("electron/preload.js", "utf8");
|
||||||
|
|
||||||
|
describe("Electron security boundary", () => {
|
||||||
|
it("keeps the renderer sandbox and browser security enabled", () => {
|
||||||
|
expect(main).toContain("sandbox: true");
|
||||||
|
expect(main).toContain("webSecurity: true");
|
||||||
|
expect(main).toContain("allowRunningInsecureContent: false");
|
||||||
|
expect(main).toContain("webviewTag: false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not expose an unrestricted IPC invoke primitive", () => {
|
||||||
|
expect(preload).toContain("invoke: invokeAllowed");
|
||||||
|
expect(preload).not.toContain(
|
||||||
|
"invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -171,8 +171,8 @@ function transform(source, dialect) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const imports = isPg
|
const imports = isPg
|
||||||
? `import {\n pgTable,\n text,\n varchar,\n integer,\n serial,\n boolean,\n doublePrecision,\n index,\n uniqueIndex,\n type AnyPgColumn,\n} from "drizzle-orm/pg-core";`
|
? `import {\n pgTable,\n text,\n varchar,\n integer,\n serial,\n boolean,\n doublePrecision,\n index,\n uniqueIndex,\n foreignKey,\n type AnyPgColumn,\n} from "drizzle-orm/pg-core";`
|
||||||
: `import {\n mysqlTable,\n text,\n varchar,\n int,\n boolean,\n double,\n index,\n uniqueIndex,\n type AnyMySqlColumn,\n} from "drizzle-orm/mysql-core";`;
|
: `import {\n mysqlTable,\n text,\n varchar,\n int,\n boolean,\n double,\n index,\n uniqueIndex,\n foreignKey,\n type AnyMySqlColumn,\n} from "drizzle-orm/mysql-core";`;
|
||||||
|
|
||||||
out = out.replace(
|
out = out.replace(
|
||||||
/import\s*\{[^}]*\}\s*from\s*"drizzle-orm\/sqlite-core";/,
|
/import\s*\{[^}]*\}\s*from\s*"drizzle-orm\/sqlite-core";/,
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { execFileSync } = require("node:child_process");
|
||||||
|
|
||||||
|
function findPackage(start) {
|
||||||
|
let directory = path.dirname(start);
|
||||||
|
while (directory !== path.dirname(directory)) {
|
||||||
|
const manifest = path.join(directory, "package.json");
|
||||||
|
if (fs.existsSync(manifest)) return JSON.parse(fs.readFileSync(manifest));
|
||||||
|
directory = path.dirname(directory);
|
||||||
|
}
|
||||||
|
throw new Error("Could not locate the installed sharp package manifest");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharpPackage = findPackage(require.resolve("sharp"));
|
||||||
|
const packages = [
|
||||||
|
"@img/sharp-darwin-arm64",
|
||||||
|
"@img/sharp-darwin-x64",
|
||||||
|
"@img/sharp-libvips-darwin-arm64",
|
||||||
|
"@img/sharp-libvips-darwin-x64",
|
||||||
|
].map((name) => `${name}@${sharpPackage.optionalDependencies[name]}`);
|
||||||
|
|
||||||
|
execFileSync(
|
||||||
|
process.platform === "win32" ? "npm.cmd" : "npm",
|
||||||
|
["install", "--force", "--no-save", ...packages],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
@@ -25,11 +25,20 @@ const clientConnectionPath = path.join(
|
|||||||
"lib",
|
"lib",
|
||||||
"ClientConnection.js",
|
"ClientConnection.js",
|
||||||
);
|
);
|
||||||
|
const serverPath = path.join(
|
||||||
|
__dirname,
|
||||||
|
"..",
|
||||||
|
"node_modules",
|
||||||
|
"guacamole-lite",
|
||||||
|
"lib",
|
||||||
|
"Server.js",
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!fs.existsSync(guacdClientPath) ||
|
!fs.existsSync(guacdClientPath) ||
|
||||||
!fs.existsSync(cryptPath) ||
|
!fs.existsSync(cryptPath) ||
|
||||||
!fs.existsSync(clientConnectionPath)
|
!fs.existsSync(clientConnectionPath) ||
|
||||||
|
!fs.existsSync(serverPath)
|
||||||
) {
|
) {
|
||||||
console.log("[patch-guacamole-lite] File not found, skipping");
|
console.log("[patch-guacamole-lite] File not found, skipping");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -52,6 +61,7 @@ function missingAnchor(patch) {
|
|||||||
let guacdClientContent = fs.readFileSync(guacdClientPath, "utf8");
|
let guacdClientContent = fs.readFileSync(guacdClientPath, "utf8");
|
||||||
let cryptContent = fs.readFileSync(cryptPath, "utf8");
|
let cryptContent = fs.readFileSync(cryptPath, "utf8");
|
||||||
let clientConnectionContent = fs.readFileSync(clientConnectionPath, "utf8");
|
let clientConnectionContent = fs.readFileSync(clientConnectionPath, "utf8");
|
||||||
|
let serverContent = fs.readFileSync(serverPath, "utf8");
|
||||||
|
|
||||||
// Patch 1: protocol version negotiation.
|
// Patch 1: protocol version negotiation.
|
||||||
// guacamole-lite originally only accepted 1.0.0/1.1.0. Support the protocol
|
// guacamole-lite originally only accepted 1.0.0/1.1.0. Support the protocol
|
||||||
@@ -358,6 +368,27 @@ if (!clientConnectionContent.includes("compiledSettings.readOnly")) {
|
|||||||
patched = true;
|
patched = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Patch 9: ClientConnection closes malformed-token WebSockets in its
|
||||||
|
// constructor, but Server.newConnection still called connect() afterwards.
|
||||||
|
// That dereferenced the absent connection settings and turned one bad token
|
||||||
|
// into an unhandled rejection that could terminate the backend process.
|
||||||
|
const oldConnectionSetup =
|
||||||
|
" newConnection.on('ready', async (clientConnection) => {";
|
||||||
|
const newConnectionSetup =
|
||||||
|
" if (!newConnection.connectionSettings || !newConnection.connectionSettings.connection) {\n" +
|
||||||
|
" return;\n" +
|
||||||
|
" }\n" +
|
||||||
|
"\n" +
|
||||||
|
oldConnectionSetup;
|
||||||
|
|
||||||
|
if (!serverContent.includes("!newConnection.connectionSettings.connection")) {
|
||||||
|
if (!serverContent.includes(oldConnectionSetup)) {
|
||||||
|
missingAnchor("invalid-token connection guard");
|
||||||
|
}
|
||||||
|
serverContent = serverContent.replace(oldConnectionSetup, newConnectionSetup);
|
||||||
|
patched = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (!patched) {
|
if (!patched) {
|
||||||
console.log("[patch-guacamole-lite] Already patched");
|
console.log("[patch-guacamole-lite] Already patched");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -366,6 +397,7 @@ if (!patched) {
|
|||||||
fs.writeFileSync(guacdClientPath, guacdClientContent);
|
fs.writeFileSync(guacdClientPath, guacdClientContent);
|
||||||
fs.writeFileSync(cryptPath, cryptContent);
|
fs.writeFileSync(cryptPath, cryptContent);
|
||||||
fs.writeFileSync(clientConnectionPath, clientConnectionContent);
|
fs.writeFileSync(clientConnectionPath, clientConnectionContent);
|
||||||
|
fs.writeFileSync(serverPath, serverContent);
|
||||||
console.log(
|
console.log(
|
||||||
"[patch-guacamole-lite] Patched protocol VERSION_1_3_0/1_5_0 support, name handshake, required arguments, UTF-8 token decrypt, and read-only join input filtering",
|
"[patch-guacamole-lite] Patched protocol negotiation, name handshake, required arguments, UTF-8 token decrypt, read-only joins, and malformed-token handling",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const GuacdClient = require("../node_modules/guacamole-lite/lib/GuacdClient.js");
|
const GuacdClient = require("../node_modules/guacamole-lite/lib/GuacdClient.js");
|
||||||
|
const GuacamoleLite = require("../node_modules/guacamole-lite/lib/Server.js");
|
||||||
|
|
||||||
type PatchedGuacdClient = {
|
type PatchedGuacdClient = {
|
||||||
connectionSettings: Record<string, unknown>;
|
connectionSettings: Record<string, unknown>;
|
||||||
@@ -26,6 +27,50 @@ function createPatchedClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("patch-guacamole-lite", () => {
|
describe("patch-guacamole-lite", () => {
|
||||||
|
it("rejects a malformed token without starting or retaining a connection", async () => {
|
||||||
|
const server = Object.assign(Object.create(GuacamoleLite.prototype), {
|
||||||
|
connectionsCount: 0,
|
||||||
|
clientOptions: {
|
||||||
|
crypt: {
|
||||||
|
cypher: "AES-256-CBC",
|
||||||
|
key: Buffer.alloc(32, 7),
|
||||||
|
},
|
||||||
|
log: {
|
||||||
|
level: 0,
|
||||||
|
stdLog: vi.fn(),
|
||||||
|
errorLog: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
callbacks: {
|
||||||
|
processConnectionSettings: vi.fn(),
|
||||||
|
},
|
||||||
|
extractGuacdOptions: vi.fn(async () => ({
|
||||||
|
guacdOptions: { host: "127.0.0.1", port: 4822 },
|
||||||
|
connectionInfo: null,
|
||||||
|
isJoin: false,
|
||||||
|
targetSessionId: null,
|
||||||
|
})),
|
||||||
|
activeConnections: new Map(),
|
||||||
|
emit: vi.fn(),
|
||||||
|
});
|
||||||
|
const webSocket = {
|
||||||
|
OPEN: 1,
|
||||||
|
readyState: 1,
|
||||||
|
send: vi.fn(),
|
||||||
|
close: vi.fn(),
|
||||||
|
on: vi.fn(),
|
||||||
|
removeAllListeners: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await server.newConnection(webSocket, {
|
||||||
|
url: "/guacamole/websocket/?token=not-an-encrypted-token",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(webSocket.close).toHaveBeenCalledOnce();
|
||||||
|
expect(server.activeConnections.size).toBe(0);
|
||||||
|
expect(server.emit).not.toHaveBeenCalledWith("open", expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
it("handles guacd dynamic argument requests", () => {
|
it("handles guacd dynamic argument requests", () => {
|
||||||
const guacdClientPath = path.join(
|
const guacdClientPath = path.join(
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { execFileSync, spawnSync } = require("node:child_process");
|
||||||
|
|
||||||
|
const architectures = {
|
||||||
|
x64: ["x64"],
|
||||||
|
arm64: ["arm64"],
|
||||||
|
universal: ["x64", "arm64"],
|
||||||
|
};
|
||||||
|
|
||||||
|
function expectedArchitecture(artifact) {
|
||||||
|
const name = path.basename(artifact);
|
||||||
|
if (name.includes("_x64_")) return "x64";
|
||||||
|
if (name.includes("_arm64_")) return "arm64";
|
||||||
|
if (name.includes("_universal_")) return "universal";
|
||||||
|
throw new Error(`Cannot determine architecture from artifact name: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findApp(root) {
|
||||||
|
const pending = [root];
|
||||||
|
while (pending.length) {
|
||||||
|
const current = pending.pop();
|
||||||
|
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||||
|
const candidate = path.join(current, entry.name);
|
||||||
|
if (entry.isDirectory() && entry.name.endsWith(".app")) return candidate;
|
||||||
|
if (entry.isDirectory()) pending.push(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`No .app bundle found below ${root}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsFile(root, suffix) {
|
||||||
|
const pending = [root];
|
||||||
|
while (pending.length) {
|
||||||
|
const current = pending.pop();
|
||||||
|
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||||
|
const candidate = path.join(current, entry.name);
|
||||||
|
if (entry.isDirectory()) pending.push(candidate);
|
||||||
|
if (entry.isFile() && entry.name.endsWith(suffix)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyApp(app, architecture, runtimeCheck) {
|
||||||
|
const modules = path.join(
|
||||||
|
app,
|
||||||
|
"Contents/Resources/app.asar.unpacked/node_modules",
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const arch of architectures[architecture]) {
|
||||||
|
for (const [packageName, nativeSuffix] of [
|
||||||
|
[`sharp-darwin-${arch}`, ".node"],
|
||||||
|
[`sharp-libvips-darwin-${arch}`, ".dylib"],
|
||||||
|
]) {
|
||||||
|
const packagePath = path.join(modules, "@img", packageName);
|
||||||
|
if (
|
||||||
|
!fs.existsSync(packagePath) ||
|
||||||
|
!containsFile(packagePath, nativeSuffix)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`${path.basename(app)} is missing the native binary from @img/${packageName}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!runtimeCheck) return;
|
||||||
|
|
||||||
|
const executable = path.join(app, "Contents/MacOS/Termix");
|
||||||
|
const sharpPath = path.join(modules, "sharp");
|
||||||
|
const smoke = [
|
||||||
|
"const sharp = require(process.argv[1]);",
|
||||||
|
"sharp({create:{width:1,height:1,channels:4,background:'#000'}})",
|
||||||
|
".png().toBuffer().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1); });",
|
||||||
|
].join("");
|
||||||
|
|
||||||
|
for (const arch of architectures[architecture]) {
|
||||||
|
const result = spawnSync(
|
||||||
|
"arch",
|
||||||
|
[
|
||||||
|
arch === "x64" ? "-x86_64" : "-arm64",
|
||||||
|
executable,
|
||||||
|
"-e",
|
||||||
|
smoke,
|
||||||
|
sharpPath,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
encoding: "utf8",
|
||||||
|
env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`${path.basename(app)} failed the ${arch} sharp runtime smoke test:\n${result.stderr || result.stdout}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyArtifact(artifact) {
|
||||||
|
const architecture = expectedArchitecture(artifact);
|
||||||
|
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "termix-sharp-"));
|
||||||
|
let mountedAt;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (artifact.endsWith(".dmg")) {
|
||||||
|
mountedAt = path.join(temporaryRoot, "mounted");
|
||||||
|
fs.mkdirSync(mountedAt);
|
||||||
|
execFileSync("hdiutil", [
|
||||||
|
"attach",
|
||||||
|
artifact,
|
||||||
|
"-readonly",
|
||||||
|
"-nobrowse",
|
||||||
|
"-mountpoint",
|
||||||
|
mountedAt,
|
||||||
|
]);
|
||||||
|
verifyApp(findApp(mountedAt), architecture, true);
|
||||||
|
} else if (artifact.endsWith(".pkg")) {
|
||||||
|
const expanded = path.join(temporaryRoot, "expanded");
|
||||||
|
execFileSync("pkgutil", ["--expand-full", artifact, expanded]);
|
||||||
|
verifyApp(findApp(expanded), architecture, false);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported macOS artifact: ${artifact}`);
|
||||||
|
}
|
||||||
|
console.log(`Verified macOS sharp packaging: ${path.basename(artifact)}`);
|
||||||
|
} finally {
|
||||||
|
if (mountedAt) {
|
||||||
|
spawnSync("hdiutil", ["detach", mountedAt], { stdio: "ignore" });
|
||||||
|
}
|
||||||
|
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { expectedArchitecture, verifyApp };
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
if (process.platform !== "darwin") {
|
||||||
|
throw new Error("macOS sharp artifact verification must run on macOS");
|
||||||
|
}
|
||||||
|
if (process.argv.length < 3) {
|
||||||
|
throw new Error(
|
||||||
|
"Usage: node scripts/verify-macos-sharp.cjs <artifact> [...]",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const artifact of process.argv.slice(2))
|
||||||
|
verifyArtifact(path.resolve(artifact));
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { expectedArchitecture, verifyApp } = require("./verify-macos-sharp.cjs");
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
function createApp(architectures: string[]) {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-sharp-test-"));
|
||||||
|
temporaryDirectories.push(root);
|
||||||
|
const app = path.join(root, "Termix.app");
|
||||||
|
const modules = path.join(
|
||||||
|
app,
|
||||||
|
"Contents/Resources/app.asar.unpacked/node_modules/@img",
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const architecture of architectures) {
|
||||||
|
const sharp = path.join(modules, `sharp-darwin-${architecture}/lib`);
|
||||||
|
const libvips = path.join(
|
||||||
|
modules,
|
||||||
|
`sharp-libvips-darwin-${architecture}/lib`,
|
||||||
|
);
|
||||||
|
fs.mkdirSync(sharp, { recursive: true });
|
||||||
|
fs.mkdirSync(libvips, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(sharp, `sharp-darwin-${architecture}.node`), "");
|
||||||
|
fs.writeFileSync(path.join(libvips, "libvips.dylib"), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const directory of temporaryDirectories.splice(0)) {
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("macOS sharp artifact verification", () => {
|
||||||
|
it("derives the expected architecture from artifact names", () => {
|
||||||
|
expect(expectedArchitecture("termix_macos_x64_dmg.dmg")).toBe("x64");
|
||||||
|
expect(expectedArchitecture("termix_macos_arm64_dmg.dmg")).toBe("arm64");
|
||||||
|
expect(expectedArchitecture("termix_macos_universal_mas.pkg")).toBe(
|
||||||
|
"universal",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a universal app with both sharp architectures", () => {
|
||||||
|
expect(() =>
|
||||||
|
verifyApp(createApp(["x64", "arm64"]), "universal", false),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an x64 app containing only arm64 sharp binaries", () => {
|
||||||
|
expect(() => verifyApp(createApp(["arm64"]), "x64", false)).toThrow(
|
||||||
|
/sharp-darwin-x64/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,8 +51,7 @@ function normalizeHost(hostname: string): string {
|
|||||||
/**
|
/**
|
||||||
* True when the URL names a destination the SSRF guard would refuse. A bare
|
* True when the URL names a destination the SSRF guard would refuse. A bare
|
||||||
* hostname that is not an IP literal (e.g. "ollama.internal") is treated as
|
* hostname that is not an IP literal (e.g. "ollama.internal") is treated as
|
||||||
* private only if it is "localhost" -- anything else resolves through DNS and
|
* private only if it is "localhost" -- anything else needs DNS resolution.
|
||||||
* is caught at connect time by the guard instead.
|
|
||||||
*/
|
*/
|
||||||
export function isPrivateDestination(rawUrl: string): boolean {
|
export function isPrivateDestination(rawUrl: string): boolean {
|
||||||
let url: URL;
|
let url: URL;
|
||||||
@@ -98,12 +97,18 @@ export function evaluateEgress(
|
|||||||
|
|
||||||
const host = normalizeHost(url.hostname);
|
const host = normalizeHost(url.hostname);
|
||||||
const isPrivate = isPrivateDestination(rawUrl);
|
const isPrivate = isPrivateDestination(rawUrl);
|
||||||
|
const normalized = allowlist.map((entry) => entry.trim().toLowerCase());
|
||||||
|
|
||||||
|
// An explicitly allowlisted hostname may resolve to a private address. It
|
||||||
|
// must use the private fetch path; sending it through safeOutboundFetch
|
||||||
|
// would reject it after DNS resolution and make hostname allowlist entries
|
||||||
|
// ineffective. Only administrators can write this list.
|
||||||
|
if (normalized.includes(host)) {
|
||||||
|
return { allowed: true, isPrivate: true };
|
||||||
|
}
|
||||||
|
|
||||||
if (!isPrivate) return { allowed: true, isPrivate: false };
|
if (!isPrivate) return { allowed: true, isPrivate: false };
|
||||||
|
|
||||||
const normalized = allowlist.map((entry) => entry.trim().toLowerCase());
|
|
||||||
if (normalized.includes(host)) return { allowed: true, isPrivate: true };
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getErrorMessage } from "../utils/error-message.js";
|
import { getErrorMessage } from "../utils/error-message.js";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||||
|
import { PermissionManager } from "../utils/permission-manager.js";
|
||||||
import { AuthManager } from "../utils/auth-manager.js";
|
import { AuthManager } from "../utils/auth-manager.js";
|
||||||
import { databaseLogger } from "../utils/logger.js";
|
import { databaseLogger } from "../utils/logger.js";
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +37,7 @@ import { applyProposal } from "./tools/executor.js";
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
|
const permissionManager = PermissionManager.getInstance();
|
||||||
const authenticateJWT = authManager.createAuthMiddleware();
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||||
const aiGate = createAiGate();
|
const aiGate = createAiGate();
|
||||||
@@ -97,6 +99,7 @@ router.get("/status", authenticateJWT, async (req, res) => {
|
|||||||
router.get(
|
router.get(
|
||||||
"/providers",
|
"/providers",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("ai.use"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
aiGate,
|
aiGate,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -147,6 +150,7 @@ router.get(
|
|||||||
router.post(
|
router.post(
|
||||||
"/providers",
|
"/providers",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("ai.manage_providers"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
aiGate,
|
aiGate,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -224,6 +228,7 @@ router.post(
|
|||||||
router.patch(
|
router.patch(
|
||||||
"/providers/:id",
|
"/providers/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("ai.manage_providers"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
aiGate,
|
aiGate,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -286,6 +291,7 @@ router.patch(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/providers/:id",
|
"/providers/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("ai.manage_providers"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
aiGate,
|
aiGate,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -358,6 +364,7 @@ router.delete(
|
|||||||
router.post(
|
router.post(
|
||||||
"/probe-models",
|
"/probe-models",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("ai.manage_providers"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
aiGate,
|
aiGate,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -423,6 +430,7 @@ router.post(
|
|||||||
router.get(
|
router.get(
|
||||||
"/providers/:id/models",
|
"/providers/:id/models",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("ai.use"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
aiGate,
|
aiGate,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -470,6 +478,7 @@ router.get(
|
|||||||
router.get(
|
router.get(
|
||||||
"/conversations",
|
"/conversations",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("ai.use"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
aiGate,
|
aiGate,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -510,6 +519,7 @@ router.get(
|
|||||||
router.get(
|
router.get(
|
||||||
"/conversations/:id",
|
"/conversations/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("ai.use"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
aiGate,
|
aiGate,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -560,6 +570,7 @@ router.get(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/conversations/:id",
|
"/conversations/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("ai.use"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
aiGate,
|
aiGate,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -622,6 +633,7 @@ router.delete(
|
|||||||
router.post(
|
router.post(
|
||||||
"/chat/stream",
|
"/chat/stream",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("ai.use"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
aiGate,
|
aiGate,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -840,6 +852,7 @@ router.post(
|
|||||||
router.post(
|
router.post(
|
||||||
"/proposals/:id/apply",
|
"/proposals/:id/apply",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("ai.apply_proposals"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
aiGate,
|
aiGate,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -931,6 +944,7 @@ router.post(
|
|||||||
router.post(
|
router.post(
|
||||||
"/proposals/:id/reject",
|
"/proposals/:id/reject",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("ai.use"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
aiGate,
|
aiGate,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getFetchDispatcher } from "../../utils/proxy-agent.js";
|
import { fetchWithProxy } from "../../utils/proxy-agent.js";
|
||||||
import { safeOutboundFetch } from "../../utils/safe-outbound-fetch.js";
|
import { safeOutboundFetch } from "../../utils/safe-outbound-fetch.js";
|
||||||
import { evaluateEgress, readPrivateAllowlist } from "../egress.js";
|
import { evaluateEgress, readPrivateAllowlist } from "../egress.js";
|
||||||
import { AiProviderError } from "./types.js";
|
import { AiProviderError } from "./types.js";
|
||||||
@@ -9,8 +9,8 @@ import { AiProviderError } from "./types.js";
|
|||||||
*
|
*
|
||||||
* Public hosts use safeOutboundFetch, which re-checks the resolved address at
|
* Public hosts use safeOutboundFetch, which re-checks the resolved address at
|
||||||
* connect time. Allowlisted private hosts cannot use it (its whole job is to
|
* connect time. Allowlisted private hosts cannot use it (its whole job is to
|
||||||
* refuse them), so they fall back to plain fetch with the proxy dispatcher --
|
* refuse them), so they use the installed Undici fetch implementation with
|
||||||
* still respecting corporate proxy configuration.
|
* its matching proxy dispatcher -- still respecting proxy configuration.
|
||||||
*/
|
*/
|
||||||
export async function providerFetch(
|
export async function providerFetch(
|
||||||
url: string,
|
url: string,
|
||||||
@@ -24,10 +24,7 @@ export async function providerFetch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (decision.isPrivate) {
|
if (decision.isPrivate) {
|
||||||
return fetch(url, {
|
return fetchWithProxy(url, init);
|
||||||
...init,
|
|
||||||
dispatcher: getFetchDispatcher(url),
|
|
||||||
} as RequestInit);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return safeOutboundFetch(url, init) as unknown as Promise<Response>;
|
return safeOutboundFetch(url, init) as unknown as Promise<Response>;
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ import type { AiTool, ToolDefinitionShape } from "./types.js";
|
|||||||
/**
|
/**
|
||||||
* The allowlist, and the security boundary for the whole feature.
|
* The allowlist, and the security boundary for the whole feature.
|
||||||
*
|
*
|
||||||
* A model can only ever invoke what appears here. This matters more than usual
|
* A model can only ever invoke what appears here. Route-level RBAC gates the
|
||||||
* in this codebase: PermissionManager.requirePermission exists but is currently
|
* HTTP API, but tools run in-process with the calling user's identity and never
|
||||||
* mounted on zero routes, so RBAC strings are a vocabulary for the admin role
|
* pass through a router, so "the assistant cannot reach credentials or user
|
||||||
* editor rather than route enforcement. "The assistant cannot reach credentials
|
* administration" is a property of this list, not of the permission system.
|
||||||
* or user administration" is therefore a property of this list, not of the
|
|
||||||
* permission system.
|
|
||||||
*
|
*
|
||||||
* Anything touching credentials, vaults, RBAC, users, identity, certificates,
|
* Anything touching credentials, vaults, RBAC, users, identity, certificates,
|
||||||
* SSO or instance settings is deliberately absent and must stay absent.
|
* SSO or instance settings is deliberately absent and must stay absent.
|
||||||
@@ -51,6 +49,8 @@ export const FORBIDDEN_DOMAINS = [
|
|||||||
"identity",
|
"identity",
|
||||||
"certificate",
|
"certificate",
|
||||||
"opkssh",
|
"opkssh",
|
||||||
|
"stepca",
|
||||||
|
"step_ca",
|
||||||
"acme",
|
"acme",
|
||||||
"ssl",
|
"ssl",
|
||||||
"audit",
|
"audit",
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
|
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
|
||||||
|
import { readNotificationPrivateAllowlist } from "../utils/notification-egress.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Outbound HTTP for automation steps and notification channels.
|
* Outbound HTTP for automation steps and notification channels.
|
||||||
*
|
*
|
||||||
* safeOutboundFetch refuses private and loopback addresses, which is the right
|
* safeOutboundFetch refuses private and loopback addresses, which is the right
|
||||||
* default against SSRF but also blocks the self-hosted ntfy or Gotify sitting
|
* default against SSRF but also blocks the self-hosted ntfy or Gotify sitting
|
||||||
* on a LAN that many installs actually use. Rather than weaken the guard
|
* on a LAN that many installs actually use. Private delivery therefore needs
|
||||||
* globally, a destination can opt in explicitly; everything else about the
|
* both a channel opt-in and an exact host in the administrator allowlist;
|
||||||
* guard (scheme, embedded credentials, no redirects) still applies.
|
* scheme validation, DNS pinning and redirect refusal remain in force.
|
||||||
*/
|
*/
|
||||||
export interface AutomationFetchOptions {
|
export interface AutomationFetchOptions {
|
||||||
method?: string;
|
method?: string;
|
||||||
@@ -52,8 +53,8 @@ export async function automationFetch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The opt-in path. Keeps the parts of the guard that are always right and
|
* The opt-in path still goes through the guarded resolver. Only an exact host
|
||||||
* drops only the address blocklist.
|
* authorized by an administrator may resolve to a private address.
|
||||||
*/
|
*/
|
||||||
async function privateNetworkFetch(
|
async function privateNetworkFetch(
|
||||||
rawUrl: string,
|
rawUrl: string,
|
||||||
@@ -73,5 +74,6 @@ async function privateNetworkFetch(
|
|||||||
throw new Error("URLs with embedded credentials are not allowed");
|
throw new Error("URLs with embedded credentials are not allowed");
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetch(rawUrl, { ...init, redirect: "error" });
|
const allowlist = await readNotificationPrivateAllowlist();
|
||||||
|
return safeOutboundFetch(rawUrl, init, allowlist);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import terminalRoutes from "./routes/terminal.js";
|
|||||||
import sessionLogRoutes from "./routes/session-log-routes.js";
|
import sessionLogRoutes from "./routes/session-log-routes.js";
|
||||||
import guacamoleRoutes from "../hosts/guacamole/routes.js";
|
import guacamoleRoutes from "../hosts/guacamole/routes.js";
|
||||||
import sessionSharingRoutes from "../hosts/session-sharing/routes.js";
|
import sessionSharingRoutes from "../hosts/session-sharing/routes.js";
|
||||||
|
import collabRoutes from "../hosts/collab/routes.js";
|
||||||
import networkTopologyRoutes from "./routes/network-topology.js";
|
import networkTopologyRoutes from "./routes/network-topology.js";
|
||||||
import rbacRoutes from "./routes/rbac.js";
|
import rbacRoutes from "./routes/rbac.js";
|
||||||
import openTabsRoutes from "./routes/open-tabs.js";
|
import openTabsRoutes from "./routes/open-tabs.js";
|
||||||
@@ -29,6 +30,7 @@ import termixIdRoutes from "./routes/termix-id.js";
|
|||||||
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
|
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
|
||||||
import { registerTailscaleRoutes } from "./routes/tailscale-routes.js";
|
import { registerTailscaleRoutes } from "./routes/tailscale-routes.js";
|
||||||
import vaultRoutes from "./routes/vault.js";
|
import vaultRoutes from "./routes/vault.js";
|
||||||
|
import secretSourceRoutes from "./routes/secret-sources.js";
|
||||||
import alertRulesRoutes from "./routes/alert-rules-routes.js";
|
import alertRulesRoutes from "./routes/alert-rules-routes.js";
|
||||||
import aiRoutes from "../ai/index.js";
|
import aiRoutes from "../ai/index.js";
|
||||||
import automationsRoutes from "./routes/automations.js";
|
import automationsRoutes from "./routes/automations.js";
|
||||||
@@ -73,7 +75,7 @@ const __dirname = path.dirname(__filename);
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.set("trust proxy", true);
|
app.set("trust proxy", "loopback");
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
const authenticateJWT = authManager.createAuthMiddleware();
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
@@ -259,9 +261,8 @@ async function fetchGitHubAPI<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use(bodyParser.json({ limit: "1gb" }));
|
app.use(bodyParser.json({ limit: "2mb" }));
|
||||||
app.use(bodyParser.urlencoded({ limit: "1gb", extended: true }));
|
app.use(bodyParser.urlencoded({ limit: "2mb", extended: true }));
|
||||||
app.use(bodyParser.raw({ limit: "5gb", type: "application/octet-stream" }));
|
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.use((_req, res, next) => {
|
app.use((_req, res, next) => {
|
||||||
res.setHeader("Cache-Control", "no-store");
|
res.setHeader("Cache-Control", "no-store");
|
||||||
@@ -1753,6 +1754,7 @@ app.use("/terminal", terminalRoutes);
|
|||||||
app.use("/session_logs", sessionLogRoutes);
|
app.use("/session_logs", sessionLogRoutes);
|
||||||
app.use("/guacamole", guacamoleRoutes);
|
app.use("/guacamole", guacamoleRoutes);
|
||||||
app.use("/session-sharing", sessionSharingRoutes);
|
app.use("/session-sharing", sessionSharingRoutes);
|
||||||
|
app.use("/collab", collabRoutes);
|
||||||
app.use("/network-topology", networkTopologyRoutes);
|
app.use("/network-topology", networkTopologyRoutes);
|
||||||
app.use("/rbac", rbacRoutes);
|
app.use("/rbac", rbacRoutes);
|
||||||
app.use("/open-tabs", openTabsRoutes);
|
app.use("/open-tabs", openTabsRoutes);
|
||||||
@@ -1765,6 +1767,7 @@ app.use("/termix-id", termixIdRoutes);
|
|||||||
registerAuditLogRoutes(app, authenticateJWT);
|
registerAuditLogRoutes(app, authenticateJWT);
|
||||||
registerTailscaleRoutes(app, authenticateJWT);
|
registerTailscaleRoutes(app, authenticateJWT);
|
||||||
app.use("/vault", vaultRoutes);
|
app.use("/vault", vaultRoutes);
|
||||||
|
app.use("/secret-sources", secretSourceRoutes);
|
||||||
// Before the alert routes, which are mounted at the root and would otherwise
|
// Before the alert routes, which are mounted at the root and would otherwise
|
||||||
// have first claim on the path.
|
// have first claim on the path.
|
||||||
app.use("/automations", automationsRoutes);
|
app.use("/automations", automationsRoutes);
|
||||||
@@ -2035,7 +2038,7 @@ httpServer.on("error", (err: NodeJS.ErrnoException) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const serverReady = new Promise<void>((resolve) => {
|
export const serverReady = new Promise<void>((resolve) => {
|
||||||
httpServer.listen(HTTP_PORT, async () => {
|
httpServer.listen(HTTP_PORT, "127.0.0.1", async () => {
|
||||||
if (!fs.existsSync(uploadsDir)) {
|
if (!fs.existsSync(uploadsDir)) {
|
||||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||||
}
|
}
|
||||||
@@ -2084,7 +2087,7 @@ if (
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
httpsServer.listen(sslConfig.port, () => {
|
httpsServer.listen(sslConfig.port, "127.0.0.1", () => {
|
||||||
databaseLogger.success(
|
databaseLogger.success(
|
||||||
`Backend is now also listening for HTTPS directly`,
|
`Backend is now also listening for HTTPS directly`,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from "../../utils/data-dir-guard.js";
|
} from "../../utils/data-dir-guard.js";
|
||||||
import { getDefaultGuacdUrl } from "../../utils/guacd-config.js";
|
import { getDefaultGuacdUrl } from "../../utils/guacd-config.js";
|
||||||
import { resolveDatabaseDialect, type DatabaseDialect } from "./dialect.js";
|
import { resolveDatabaseDialect, type DatabaseDialect } from "./dialect.js";
|
||||||
|
import { SYSTEM_ROLE_DEFAULTS } from "../../utils/permission-catalog.js";
|
||||||
import { connectRemoteDatabase } from "./connect.js";
|
import { connectRemoteDatabase } from "./connect.js";
|
||||||
import { runRemoteMigrations } from "./migrate.js";
|
import { runRemoteMigrations } from "./migrate.js";
|
||||||
import type { PortableDatabase } from "../repositories/database-context.js";
|
import type { PortableDatabase } from "../repositories/database-context.js";
|
||||||
@@ -577,6 +578,107 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS collab_rooms (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
owner_user_id TEXT NOT NULL,
|
||||||
|
persistent INTEGER NOT NULL DEFAULT 0,
|
||||||
|
presenter_user_id TEXT,
|
||||||
|
stage_protocol TEXT,
|
||||||
|
stage_host_id INTEGER,
|
||||||
|
stage_share_id TEXT,
|
||||||
|
guest_link_token TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ended_at TEXT,
|
||||||
|
FOREIGN KEY (owner_user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (presenter_user_id) REFERENCES users (id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (stage_host_id) REFERENCES ssh_data (id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (stage_share_id) REFERENCES session_shares (id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS collab_room_members (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
room_role TEXT NOT NULL DEFAULT 'member',
|
||||||
|
added_by TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (room_id, user_id),
|
||||||
|
FOREIGN KEY (room_id) REFERENCES collab_rooms (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (added_by) REFERENCES users (id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS secret_sources (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'onepassword-connect',
|
||||||
|
base_url TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
shared INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS credential_access (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
credential_id INTEGER NOT NULL,
|
||||||
|
user_id TEXT,
|
||||||
|
role_id INTEGER,
|
||||||
|
granted_by TEXT NOT NULL,
|
||||||
|
permission_level TEXT NOT NULL DEFAULT 'use',
|
||||||
|
expires_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_credential_access_user_id ON credential_access (user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_credential_access_role_id ON credential_access (role_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_credential_access_credential_id ON credential_access (credential_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS shared_credential_secrets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
credential_access_id INTEGER NOT NULL,
|
||||||
|
target_user_id TEXT NOT NULL,
|
||||||
|
credential_id INTEGER NOT NULL,
|
||||||
|
encrypted_username TEXT,
|
||||||
|
auth_type TEXT NOT NULL DEFAULT 'password',
|
||||||
|
encrypted_password TEXT,
|
||||||
|
encrypted_key TEXT,
|
||||||
|
encrypted_key_password TEXT,
|
||||||
|
key_type TEXT,
|
||||||
|
public_key TEXT,
|
||||||
|
cert_public_key TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (credential_access_id, target_user_id),
|
||||||
|
FOREIGN KEY (credential_access_id) REFERENCES credential_access (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shared_credential_secrets_target ON shared_credential_secrets (target_user_id, credential_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS folder_access (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
owner_user_id TEXT NOT NULL,
|
||||||
|
folder TEXT NOT NULL,
|
||||||
|
user_id TEXT,
|
||||||
|
role_id INTEGER,
|
||||||
|
granted_by TEXT NOT NULL,
|
||||||
|
permission_level TEXT NOT NULL DEFAULT 'connect',
|
||||||
|
expires_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (owner_user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_folder_access_owner_folder ON folder_access (owner_user_id, folder);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS api_keys (
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
@@ -1953,20 +2055,24 @@ const migrateSchema = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemRoles = [
|
const systemRoles = Object.entries(SYSTEM_ROLE_DEFAULTS).map(
|
||||||
{
|
([name, defaults]) => ({
|
||||||
name: "admin",
|
name,
|
||||||
displayName: "rbac.roles.admin",
|
displayName: `rbac.roles.${name}`,
|
||||||
description: "Administrator with full access",
|
description: defaults.description,
|
||||||
permissions: null,
|
permissions: JSON.stringify(defaults.permissions),
|
||||||
},
|
}),
|
||||||
{
|
);
|
||||||
name: "user",
|
|
||||||
displayName: "rbac.roles.user",
|
// Route-level RBAC needs the permission lists to exist; roles seeded by
|
||||||
description: "Regular user",
|
// earlier versions carried NULL there. Backfill only NULL so an admin's
|
||||||
permissions: null,
|
// edits to these roles are never overwritten.
|
||||||
},
|
const backfillPermissions = sqlite.prepare(
|
||||||
];
|
"UPDATE roles SET permissions = ? WHERE name = ? AND is_system = 1 AND permissions IS NULL",
|
||||||
|
);
|
||||||
|
for (const role of systemRoles) {
|
||||||
|
backfillPermissions.run(role.permissions, role.name);
|
||||||
|
}
|
||||||
|
|
||||||
for (const role of systemRoles) {
|
for (const role of systemRoles) {
|
||||||
const existingRole = sqlite.prepare("SELECT id FROM roles WHERE name = ?").get(role.name);
|
const existingRole = sqlite.prepare("SELECT id FROM roles WHERE name = ?").get(role.name);
|
||||||
@@ -2035,6 +2141,10 @@ const migrateSchema = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addColumnIfNotExists("users", "sso_provider_id", "INTEGER");
|
addColumnIfNotExists("users", "sso_provider_id", "INTEGER");
|
||||||
|
addColumnIfNotExists("collab_rooms", "guest_link_token", "TEXT");
|
||||||
|
sqlite.exec(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_collab_rooms_guest_token ON collab_rooms (guest_link_token)",
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const usersTableInfo = sqlite.prepare("PRAGMA table_info(users)").all() as Array<{
|
const usersTableInfo = sqlite.prepare("PRAGMA table_info(users)").all() as Array<{
|
||||||
@@ -2986,6 +3096,12 @@ async function initializeRemoteDatabase(
|
|||||||
await primeCurrentSettingsCache();
|
await primeCurrentSettingsCache();
|
||||||
startSettingsCacheRefresh();
|
startSettingsCacheRefresh();
|
||||||
|
|
||||||
|
// The SQLite bootstrap seeds system roles inline below; migrations for the
|
||||||
|
// remote dialects never did, and route-level RBAC denies a user with no
|
||||||
|
// usable role, so they are seeded (and backfilled) here.
|
||||||
|
const { ensureSystemRoles } = await import("../../utils/system-roles.js");
|
||||||
|
await ensureSystemRoles();
|
||||||
|
|
||||||
databaseLogger.info(`${dialect} database ready`, {
|
databaseLogger.info(`${dialect} database ready`, {
|
||||||
operation: "db_init_complete",
|
operation: "db_init_complete",
|
||||||
dialect,
|
dialect,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
double,
|
double,
|
||||||
index,
|
index,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
|
foreignKey,
|
||||||
type AnyMySqlColumn,
|
type AnyMySqlColumn,
|
||||||
} from "drizzle-orm/mysql-core";
|
} from "drizzle-orm/mysql-core";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
@@ -150,7 +151,7 @@ export const hosts = mysqlTable(
|
|||||||
ip: text("ip").notNull(),
|
ip: text("ip").notNull(),
|
||||||
port: int("port").notNull(),
|
port: int("port").notNull(),
|
||||||
username: text("username").notNull(),
|
username: text("username").notNull(),
|
||||||
folder: text("folder"),
|
folder: varchar("folder", { length: 255 }),
|
||||||
// Sub-host nesting: a host acting as an organizational parent for other
|
// Sub-host nesting: a host acting as an organizational parent for other
|
||||||
// hosts, mutually exclusive with folder (see host route validation).
|
// hosts, mutually exclusive with folder (see host route validation).
|
||||||
parentHostId: int("parent_host_id").references(
|
parentHostId: int("parent_host_id").references(
|
||||||
@@ -439,7 +440,7 @@ export const sshCredentials = mysqlTable(
|
|||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
folder: text("folder"),
|
folder: varchar("folder", { length: 255 }),
|
||||||
tags: text("tags"),
|
tags: text("tags"),
|
||||||
pin: boolean("pin").notNull().default(false),
|
pin: boolean("pin").notNull().default(false),
|
||||||
// Manual drag-to-reorder position within a folder. Null means the
|
// Manual drag-to-reorder position within a folder. Null means the
|
||||||
@@ -505,7 +506,7 @@ export const snippets = mysqlTable(
|
|||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
content: text("content").notNull(),
|
content: text("content").notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
folder: text("folder"),
|
folder: varchar("folder", { length: 255 }),
|
||||||
order: int("order").notNull().default(0),
|
order: int("order").notNull().default(0),
|
||||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||||
createdAt: varchar("created_at", { length: 255 })
|
createdAt: varchar("created_at", { length: 255 })
|
||||||
@@ -1022,7 +1023,7 @@ export const vaultProfiles = mysqlTable("vault_profiles", {
|
|||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
folder: text("folder"),
|
folder: varchar("folder", { length: 255 }),
|
||||||
tags: text("tags"),
|
tags: text("tags"),
|
||||||
// Vault server connection (non-secret)
|
// Vault server connection (non-secret)
|
||||||
vaultAddr: text("vault_addr").notNull(),
|
vaultAddr: text("vault_addr").notNull(),
|
||||||
@@ -1939,3 +1940,239 @@ export const aiProposals = mysqlTable(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
// --- ai end ---
|
// --- ai end ---
|
||||||
|
|
||||||
|
// --- collab rooms ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A collaboration room: a group of users watching one "stage" - the live
|
||||||
|
* session the current presenter is showing. The stage points at a
|
||||||
|
* shareType="room" row in session_shares, so transport, gating, recording and
|
||||||
|
* expiry all reuse the session-sharing machinery.
|
||||||
|
*/
|
||||||
|
export const collabRooms = mysqlTable(
|
||||||
|
"collab_rooms",
|
||||||
|
{
|
||||||
|
id: varchar("id", { length: 255 }).primaryKey(),
|
||||||
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
ownerUserId: varchar("owner_user_id", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
// Persistent rooms survive being emptied and can be re-used; one-off
|
||||||
|
// rooms are ended explicitly and never listed again.
|
||||||
|
persistent: boolean("persistent")
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
|
||||||
|
presenterUserId: varchar("presenter_user_id", { length: 255 }).references(() => users.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
stageProtocol: text("stage_protocol"),
|
||||||
|
stageHostId: int("stage_host_id").references(() => hosts.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
stageShareId: varchar("stage_share_id", { length: 255 }).references(() => sessionShares.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Set = anonymous guests may watch the stage through this token.
|
||||||
|
guestLinkToken: varchar("guest_link_token", { length: 255 }),
|
||||||
|
|
||||||
|
createdAt: varchar("created_at", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||||
|
endedAt: text("ended_at"),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_collab_rooms_owner").on(table.ownerUserId),
|
||||||
|
uniqueIndex("idx_collab_rooms_guest_token").on(table.guestLinkToken),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const collabRoomMembers = mysqlTable(
|
||||||
|
"collab_room_members",
|
||||||
|
{
|
||||||
|
id: int("id").autoincrement().primaryKey(),
|
||||||
|
roomId: varchar("room_id", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => collabRooms.id, { onDelete: "cascade" }),
|
||||||
|
userId: varchar("user_id", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
// "host" runs the room: invites, force-switches the presenter, ends it.
|
||||||
|
roomRole: text("room_role").notNull().default("member"),
|
||||||
|
addedBy: varchar("added_by", { length: 255 }).references(() => users.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
createdAt: varchar("created_at", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("idx_collab_room_members_room_user").on(
|
||||||
|
table.roomId,
|
||||||
|
table.userId,
|
||||||
|
),
|
||||||
|
index("idx_collab_room_members_user").on(table.userId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- secret sources ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An external password manager Termix pulls secrets from at connect time,
|
||||||
|
* instead of storing them. Only the access token is secret; it is encrypted
|
||||||
|
* with the owner's data key under the row id. Hosts and credentials refer to
|
||||||
|
* entries by reference ("op://vault/item/field") in their secret fields.
|
||||||
|
*/
|
||||||
|
export const secretSources = mysqlTable(
|
||||||
|
"secret_sources",
|
||||||
|
{
|
||||||
|
id: varchar("id", { length: 255 }).primaryKey(),
|
||||||
|
userId: varchar("user_id", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
// "onepassword-connect" for now; the reference syntax is per kind.
|
||||||
|
kind: text("kind").notNull().default("onepassword-connect"),
|
||||||
|
baseUrl: text("base_url").notNull(),
|
||||||
|
token: text("token").notNull(),
|
||||||
|
// Visible to every user; secrets still decrypt with the owner's key.
|
||||||
|
shared: boolean("shared").notNull().default(false),
|
||||||
|
createdAt: varchar("created_at", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||||
|
updatedAt: varchar("updated_at", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||||
|
},
|
||||||
|
(table) => [index("idx_secret_sources_user").on(table.userId)],
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- credential sharing ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who may use or manage someone else's credential. Same shape as
|
||||||
|
* snippet_access; "use" attaches it to hosts and connects, "manage" also
|
||||||
|
* edits and re-shares it.
|
||||||
|
*/
|
||||||
|
export const credentialAccess = mysqlTable(
|
||||||
|
"credential_access",
|
||||||
|
{
|
||||||
|
id: int("id").autoincrement().primaryKey(),
|
||||||
|
credentialId: int("credential_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
roleId: int("role_id").references(() => roles.id, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
|
||||||
|
grantedBy: varchar("granted_by", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
permissionLevel: text("permission_level").notNull().default("use"),
|
||||||
|
|
||||||
|
expiresAt: varchar("expires_at", { length: 255 }),
|
||||||
|
|
||||||
|
createdAt: varchar("created_at", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_credential_access_user_id").on(table.userId),
|
||||||
|
index("idx_credential_access_role_id").on(table.roleId),
|
||||||
|
index("idx_credential_access_credential_id").on(table.credentialId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A recipient's copy of a shared credential's secrets, re-encrypted under
|
||||||
|
* the recipient's data key (the owner's key cannot be used by anyone else).
|
||||||
|
* Rebuilt whenever the owner edits the credential; one row per grant and
|
||||||
|
* recipient, like shared_host_secrets.
|
||||||
|
*/
|
||||||
|
export const sharedCredentialSecrets = mysqlTable(
|
||||||
|
"shared_credential_secrets",
|
||||||
|
{
|
||||||
|
id: int("id").autoincrement().primaryKey(),
|
||||||
|
credentialAccessId: int("credential_access_id").notNull(),
|
||||||
|
targetUserId: varchar("target_user_id", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
credentialId: int("credential_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
encryptedUsername: text("encrypted_username"),
|
||||||
|
authType: text("auth_type").notNull().default("password"),
|
||||||
|
encryptedPassword: text("encrypted_password"),
|
||||||
|
encryptedKey: text("encrypted_key"),
|
||||||
|
encryptedKeyPassword: text("encrypted_key_password"),
|
||||||
|
keyType: text("key_type"),
|
||||||
|
publicKey: text("public_key"),
|
||||||
|
certPublicKey: text("cert_public_key"),
|
||||||
|
|
||||||
|
createdAt: varchar("created_at", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||||
|
updatedAt: varchar("updated_at", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.credentialAccessId],
|
||||||
|
foreignColumns: [credentialAccess.id],
|
||||||
|
name: "shared_cred_secrets_access_id_fk",
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
uniqueIndex("idx_shared_credential_secrets_scope").on(
|
||||||
|
table.credentialAccessId,
|
||||||
|
table.targetUserId,
|
||||||
|
),
|
||||||
|
index("idx_shared_credential_secrets_target").on(
|
||||||
|
table.targetUserId,
|
||||||
|
table.credentialId,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- folder access rules ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A standing share on a host folder. Sharing a folder fans out host_access
|
||||||
|
* grants to the hosts in it today; this row is what makes hosts created in
|
||||||
|
* or moved into the folder later inherit the same access.
|
||||||
|
*/
|
||||||
|
export const folderAccess = mysqlTable(
|
||||||
|
"folder_access",
|
||||||
|
{
|
||||||
|
id: int("id").autoincrement().primaryKey(),
|
||||||
|
ownerUserId: varchar("owner_user_id", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
// The folder path as stored on hosts ("Parent / Child"); subfolders inherit.
|
||||||
|
folder: varchar("folder", { length: 255 }).notNull(),
|
||||||
|
|
||||||
|
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
roleId: int("role_id").references(() => roles.id, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
|
||||||
|
grantedBy: varchar("granted_by", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
permissionLevel: text("permission_level").notNull().default("connect"),
|
||||||
|
expiresAt: varchar("expires_at", { length: 255 }),
|
||||||
|
|
||||||
|
createdAt: varchar("created_at", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_folder_access_owner_folder").on(table.ownerUserId, table.folder),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
doublePrecision,
|
doublePrecision,
|
||||||
index,
|
index,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
|
foreignKey,
|
||||||
type AnyPgColumn,
|
type AnyPgColumn,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
@@ -151,7 +152,7 @@ export const hosts = pgTable(
|
|||||||
ip: text("ip").notNull(),
|
ip: text("ip").notNull(),
|
||||||
port: integer("port").notNull(),
|
port: integer("port").notNull(),
|
||||||
username: text("username").notNull(),
|
username: text("username").notNull(),
|
||||||
folder: text("folder"),
|
folder: varchar("folder", { length: 255 }),
|
||||||
// Sub-host nesting: a host acting as an organizational parent for other
|
// Sub-host nesting: a host acting as an organizational parent for other
|
||||||
// hosts, mutually exclusive with folder (see host route validation).
|
// hosts, mutually exclusive with folder (see host route validation).
|
||||||
parentHostId: integer("parent_host_id").references(
|
parentHostId: integer("parent_host_id").references(
|
||||||
@@ -440,7 +441,7 @@ export const sshCredentials = pgTable(
|
|||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
folder: text("folder"),
|
folder: varchar("folder", { length: 255 }),
|
||||||
tags: text("tags"),
|
tags: text("tags"),
|
||||||
pin: boolean("pin").notNull().default(false),
|
pin: boolean("pin").notNull().default(false),
|
||||||
// Manual drag-to-reorder position within a folder. Null means the
|
// Manual drag-to-reorder position within a folder. Null means the
|
||||||
@@ -506,7 +507,7 @@ export const snippets = pgTable(
|
|||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
content: text("content").notNull(),
|
content: text("content").notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
folder: text("folder"),
|
folder: varchar("folder", { length: 255 }),
|
||||||
order: integer("order").notNull().default(0),
|
order: integer("order").notNull().default(0),
|
||||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||||
createdAt: varchar("created_at", { length: 255 })
|
createdAt: varchar("created_at", { length: 255 })
|
||||||
@@ -1023,7 +1024,7 @@ export const vaultProfiles = pgTable("vault_profiles", {
|
|||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
folder: text("folder"),
|
folder: varchar("folder", { length: 255 }),
|
||||||
tags: text("tags"),
|
tags: text("tags"),
|
||||||
// Vault server connection (non-secret)
|
// Vault server connection (non-secret)
|
||||||
vaultAddr: text("vault_addr").notNull(),
|
vaultAddr: text("vault_addr").notNull(),
|
||||||
@@ -1940,3 +1941,239 @@ export const aiProposals = pgTable(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
// --- ai end ---
|
// --- ai end ---
|
||||||
|
|
||||||
|
// --- collab rooms ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A collaboration room: a group of users watching one "stage" - the live
|
||||||
|
* session the current presenter is showing. The stage points at a
|
||||||
|
* shareType="room" row in session_shares, so transport, gating, recording and
|
||||||
|
* expiry all reuse the session-sharing machinery.
|
||||||
|
*/
|
||||||
|
export const collabRooms = pgTable(
|
||||||
|
"collab_rooms",
|
||||||
|
{
|
||||||
|
id: varchar("id", { length: 255 }).primaryKey(),
|
||||||
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
ownerUserId: varchar("owner_user_id", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
// Persistent rooms survive being emptied and can be re-used; one-off
|
||||||
|
// rooms are ended explicitly and never listed again.
|
||||||
|
persistent: boolean("persistent")
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
|
||||||
|
presenterUserId: varchar("presenter_user_id", { length: 255 }).references(() => users.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
stageProtocol: text("stage_protocol"),
|
||||||
|
stageHostId: integer("stage_host_id").references(() => hosts.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
stageShareId: varchar("stage_share_id", { length: 255 }).references(() => sessionShares.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Set = anonymous guests may watch the stage through this token.
|
||||||
|
guestLinkToken: varchar("guest_link_token", { length: 255 }),
|
||||||
|
|
||||||
|
createdAt: varchar("created_at", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
endedAt: text("ended_at"),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_collab_rooms_owner").on(table.ownerUserId),
|
||||||
|
uniqueIndex("idx_collab_rooms_guest_token").on(table.guestLinkToken),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const collabRoomMembers = pgTable(
|
||||||
|
"collab_room_members",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
roomId: varchar("room_id", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => collabRooms.id, { onDelete: "cascade" }),
|
||||||
|
userId: varchar("user_id", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
// "host" runs the room: invites, force-switches the presenter, ends it.
|
||||||
|
roomRole: text("room_role").notNull().default("member"),
|
||||||
|
addedBy: varchar("added_by", { length: 255 }).references(() => users.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
createdAt: varchar("created_at", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("idx_collab_room_members_room_user").on(
|
||||||
|
table.roomId,
|
||||||
|
table.userId,
|
||||||
|
),
|
||||||
|
index("idx_collab_room_members_user").on(table.userId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- secret sources ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An external password manager Termix pulls secrets from at connect time,
|
||||||
|
* instead of storing them. Only the access token is secret; it is encrypted
|
||||||
|
* with the owner's data key under the row id. Hosts and credentials refer to
|
||||||
|
* entries by reference ("op://vault/item/field") in their secret fields.
|
||||||
|
*/
|
||||||
|
export const secretSources = pgTable(
|
||||||
|
"secret_sources",
|
||||||
|
{
|
||||||
|
id: varchar("id", { length: 255 }).primaryKey(),
|
||||||
|
userId: varchar("user_id", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
// "onepassword-connect" for now; the reference syntax is per kind.
|
||||||
|
kind: text("kind").notNull().default("onepassword-connect"),
|
||||||
|
baseUrl: text("base_url").notNull(),
|
||||||
|
token: text("token").notNull(),
|
||||||
|
// Visible to every user; secrets still decrypt with the owner's key.
|
||||||
|
shared: boolean("shared").notNull().default(false),
|
||||||
|
createdAt: varchar("created_at", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
updatedAt: varchar("updated_at", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
},
|
||||||
|
(table) => [index("idx_secret_sources_user").on(table.userId)],
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- credential sharing ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who may use or manage someone else's credential. Same shape as
|
||||||
|
* snippet_access; "use" attaches it to hosts and connects, "manage" also
|
||||||
|
* edits and re-shares it.
|
||||||
|
*/
|
||||||
|
export const credentialAccess = pgTable(
|
||||||
|
"credential_access",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
credentialId: integer("credential_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
roleId: integer("role_id").references(() => roles.id, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
|
||||||
|
grantedBy: varchar("granted_by", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
permissionLevel: text("permission_level").notNull().default("use"),
|
||||||
|
|
||||||
|
expiresAt: varchar("expires_at", { length: 255 }),
|
||||||
|
|
||||||
|
createdAt: varchar("created_at", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_credential_access_user_id").on(table.userId),
|
||||||
|
index("idx_credential_access_role_id").on(table.roleId),
|
||||||
|
index("idx_credential_access_credential_id").on(table.credentialId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A recipient's copy of a shared credential's secrets, re-encrypted under
|
||||||
|
* the recipient's data key (the owner's key cannot be used by anyone else).
|
||||||
|
* Rebuilt whenever the owner edits the credential; one row per grant and
|
||||||
|
* recipient, like shared_host_secrets.
|
||||||
|
*/
|
||||||
|
export const sharedCredentialSecrets = pgTable(
|
||||||
|
"shared_credential_secrets",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
credentialAccessId: integer("credential_access_id").notNull(),
|
||||||
|
targetUserId: varchar("target_user_id", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
credentialId: integer("credential_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
encryptedUsername: text("encrypted_username"),
|
||||||
|
authType: text("auth_type").notNull().default("password"),
|
||||||
|
encryptedPassword: text("encrypted_password"),
|
||||||
|
encryptedKey: text("encrypted_key"),
|
||||||
|
encryptedKeyPassword: text("encrypted_key_password"),
|
||||||
|
keyType: text("key_type"),
|
||||||
|
publicKey: text("public_key"),
|
||||||
|
certPublicKey: text("cert_public_key"),
|
||||||
|
|
||||||
|
createdAt: varchar("created_at", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
updatedAt: varchar("updated_at", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.credentialAccessId],
|
||||||
|
foreignColumns: [credentialAccess.id],
|
||||||
|
name: "shared_cred_secrets_access_id_fk",
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
uniqueIndex("idx_shared_credential_secrets_scope").on(
|
||||||
|
table.credentialAccessId,
|
||||||
|
table.targetUserId,
|
||||||
|
),
|
||||||
|
index("idx_shared_credential_secrets_target").on(
|
||||||
|
table.targetUserId,
|
||||||
|
table.credentialId,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- folder access rules ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A standing share on a host folder. Sharing a folder fans out host_access
|
||||||
|
* grants to the hosts in it today; this row is what makes hosts created in
|
||||||
|
* or moved into the folder later inherit the same access.
|
||||||
|
*/
|
||||||
|
export const folderAccess = pgTable(
|
||||||
|
"folder_access",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
ownerUserId: varchar("owner_user_id", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
// The folder path as stored on hosts ("Parent / Child"); subfolders inherit.
|
||||||
|
folder: varchar("folder", { length: 255 }).notNull(),
|
||||||
|
|
||||||
|
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
roleId: integer("role_id").references(() => roles.id, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
|
||||||
|
grantedBy: varchar("granted_by", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
permissionLevel: text("permission_level").notNull().default("connect"),
|
||||||
|
expiresAt: varchar("expires_at", { length: 255 }),
|
||||||
|
|
||||||
|
createdAt: varchar("created_at", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_folder_access_owner_folder").on(table.ownerUserId, table.folder),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
real,
|
real,
|
||||||
index,
|
index,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
|
foreignKey,
|
||||||
type AnySQLiteColumn,
|
type AnySQLiteColumn,
|
||||||
} from "drizzle-orm/sqlite-core";
|
} from "drizzle-orm/sqlite-core";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
@@ -1936,3 +1937,239 @@ export const aiProposals = sqliteTable(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
// --- ai end ---
|
// --- ai end ---
|
||||||
|
|
||||||
|
// --- collab rooms ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A collaboration room: a group of users watching one "stage" - the live
|
||||||
|
* session the current presenter is showing. The stage points at a
|
||||||
|
* shareType="room" row in session_shares, so transport, gating, recording and
|
||||||
|
* expiry all reuse the session-sharing machinery.
|
||||||
|
*/
|
||||||
|
export const collabRooms = sqliteTable(
|
||||||
|
"collab_rooms",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
ownerUserId: text("owner_user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
// Persistent rooms survive being emptied and can be re-used; one-off
|
||||||
|
// rooms are ended explicitly and never listed again.
|
||||||
|
persistent: integer("persistent", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
|
||||||
|
presenterUserId: text("presenter_user_id").references(() => users.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
stageProtocol: text("stage_protocol"),
|
||||||
|
stageHostId: integer("stage_host_id").references(() => hosts.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
stageShareId: text("stage_share_id").references(() => sessionShares.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Set = anonymous guests may watch the stage through this token.
|
||||||
|
guestLinkToken: text("guest_link_token"),
|
||||||
|
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
endedAt: text("ended_at"),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_collab_rooms_owner").on(table.ownerUserId),
|
||||||
|
uniqueIndex("idx_collab_rooms_guest_token").on(table.guestLinkToken),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const collabRoomMembers = sqliteTable(
|
||||||
|
"collab_room_members",
|
||||||
|
{
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
roomId: text("room_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => collabRooms.id, { onDelete: "cascade" }),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
// "host" runs the room: invites, force-switches the presenter, ends it.
|
||||||
|
roomRole: text("room_role").notNull().default("member"),
|
||||||
|
addedBy: text("added_by").references(() => users.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("idx_collab_room_members_room_user").on(
|
||||||
|
table.roomId,
|
||||||
|
table.userId,
|
||||||
|
),
|
||||||
|
index("idx_collab_room_members_user").on(table.userId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- secret sources ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An external password manager Termix pulls secrets from at connect time,
|
||||||
|
* instead of storing them. Only the access token is secret; it is encrypted
|
||||||
|
* with the owner's data key under the row id. Hosts and credentials refer to
|
||||||
|
* entries by reference ("op://vault/item/field") in their secret fields.
|
||||||
|
*/
|
||||||
|
export const secretSources = sqliteTable(
|
||||||
|
"secret_sources",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
// "onepassword-connect" for now; the reference syntax is per kind.
|
||||||
|
kind: text("kind").notNull().default("onepassword-connect"),
|
||||||
|
baseUrl: text("base_url").notNull(),
|
||||||
|
token: text("token").notNull(),
|
||||||
|
// Visible to every user; secrets still decrypt with the owner's key.
|
||||||
|
shared: integer("shared", { mode: "boolean" }).notNull().default(false),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
updatedAt: text("updated_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
},
|
||||||
|
(table) => [index("idx_secret_sources_user").on(table.userId)],
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- credential sharing ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who may use or manage someone else's credential. Same shape as
|
||||||
|
* snippet_access; "use" attaches it to hosts and connects, "manage" also
|
||||||
|
* edits and re-shares it.
|
||||||
|
*/
|
||||||
|
export const credentialAccess = sqliteTable(
|
||||||
|
"credential_access",
|
||||||
|
{
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
credentialId: integer("credential_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
roleId: integer("role_id").references(() => roles.id, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
|
||||||
|
grantedBy: text("granted_by")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
permissionLevel: text("permission_level").notNull().default("use"),
|
||||||
|
|
||||||
|
expiresAt: text("expires_at"),
|
||||||
|
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_credential_access_user_id").on(table.userId),
|
||||||
|
index("idx_credential_access_role_id").on(table.roleId),
|
||||||
|
index("idx_credential_access_credential_id").on(table.credentialId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A recipient's copy of a shared credential's secrets, re-encrypted under
|
||||||
|
* the recipient's data key (the owner's key cannot be used by anyone else).
|
||||||
|
* Rebuilt whenever the owner edits the credential; one row per grant and
|
||||||
|
* recipient, like shared_host_secrets.
|
||||||
|
*/
|
||||||
|
export const sharedCredentialSecrets = sqliteTable(
|
||||||
|
"shared_credential_secrets",
|
||||||
|
{
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
credentialAccessId: integer("credential_access_id").notNull(),
|
||||||
|
targetUserId: text("target_user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
credentialId: integer("credential_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
encryptedUsername: text("encrypted_username"),
|
||||||
|
authType: text("auth_type").notNull().default("password"),
|
||||||
|
encryptedPassword: text("encrypted_password"),
|
||||||
|
encryptedKey: text("encrypted_key", { length: 16384 }),
|
||||||
|
encryptedKeyPassword: text("encrypted_key_password"),
|
||||||
|
keyType: text("key_type"),
|
||||||
|
publicKey: text("public_key", { length: 4096 }),
|
||||||
|
certPublicKey: text("cert_public_key", { length: 8192 }),
|
||||||
|
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
updatedAt: text("updated_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.credentialAccessId],
|
||||||
|
foreignColumns: [credentialAccess.id],
|
||||||
|
name: "shared_cred_secrets_access_id_fk",
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
uniqueIndex("idx_shared_credential_secrets_scope").on(
|
||||||
|
table.credentialAccessId,
|
||||||
|
table.targetUserId,
|
||||||
|
),
|
||||||
|
index("idx_shared_credential_secrets_target").on(
|
||||||
|
table.targetUserId,
|
||||||
|
table.credentialId,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- folder access rules ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A standing share on a host folder. Sharing a folder fans out host_access
|
||||||
|
* grants to the hosts in it today; this row is what makes hosts created in
|
||||||
|
* or moved into the folder later inherit the same access.
|
||||||
|
*/
|
||||||
|
export const folderAccess = sqliteTable(
|
||||||
|
"folder_access",
|
||||||
|
{
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
ownerUserId: text("owner_user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
// The folder path as stored on hosts ("Parent / Child"); subfolders inherit.
|
||||||
|
folder: text("folder").notNull(),
|
||||||
|
|
||||||
|
userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
roleId: integer("role_id").references(() => roles.id, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
|
||||||
|
grantedBy: text("granted_by")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
permissionLevel: text("permission_level").notNull().default("connect"),
|
||||||
|
expiresAt: text("expires_at"),
|
||||||
|
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_folder_access_owner_folder").on(table.ownerUserId, table.folder),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||||
|
import { collabRoomMembers, collabRooms, users } from "../db/schema.js";
|
||||||
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
import { insertReturning } from "./returning.js";
|
||||||
|
import { rowsAffected } from "./mutation-result.js";
|
||||||
|
|
||||||
|
export type CollabRoomRecord = typeof collabRooms.$inferSelect;
|
||||||
|
export type CollabRoomMemberRecord = typeof collabRoomMembers.$inferSelect;
|
||||||
|
|
||||||
|
export type CollabRoomRole = "host" | "member";
|
||||||
|
|
||||||
|
export interface CollabRoomMemberWithUser {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
roomRole: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollabRoomStage {
|
||||||
|
presenterUserId: string | null;
|
||||||
|
stageProtocol: string | null;
|
||||||
|
stageHostId: number | null;
|
||||||
|
stageShareId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CollabRoomRepository {
|
||||||
|
constructor(
|
||||||
|
private readonly context: DatabaseContext,
|
||||||
|
private readonly onWrite?: () => void | Promise<void>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createRoom(input: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
ownerUserId: string;
|
||||||
|
persistent: boolean;
|
||||||
|
}): Promise<CollabRoomRecord> {
|
||||||
|
const [created] = await insertReturning(this.context, collabRooms, {
|
||||||
|
id: input.id,
|
||||||
|
name: input.name,
|
||||||
|
ownerUserId: input.ownerUserId,
|
||||||
|
persistent: input.persistent,
|
||||||
|
});
|
||||||
|
await this.afterWrite();
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<CollabRoomRecord | null> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(collabRooms)
|
||||||
|
.where(eq(collabRooms.id, id))
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The live room whose stage points at this share, if any. */
|
||||||
|
async findByStageShareId(shareId: string): Promise<CollabRoomRecord | null> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(collabRooms)
|
||||||
|
.where(
|
||||||
|
and(eq(collabRooms.stageShareId, shareId), isNull(collabRooms.endedAt)),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByGuestToken(token: string): Promise<CollabRoomRecord | null> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(collabRooms)
|
||||||
|
.where(
|
||||||
|
and(eq(collabRooms.guestLinkToken, token), isNull(collabRooms.endedAt)),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setGuestToken(roomId: string, token: string | null): Promise<void> {
|
||||||
|
await this.context.drizzle
|
||||||
|
.update(collabRooms)
|
||||||
|
.set({ guestLinkToken: token })
|
||||||
|
.where(eq(collabRooms.id, roomId));
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
async listForUser(userId: string): Promise<CollabRoomRecord[]> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select({ room: collabRooms })
|
||||||
|
.from(collabRoomMembers)
|
||||||
|
.innerJoin(collabRooms, eq(collabRoomMembers.roomId, collabRooms.id))
|
||||||
|
.where(
|
||||||
|
and(eq(collabRoomMembers.userId, userId), isNull(collabRooms.endedAt)),
|
||||||
|
)
|
||||||
|
.orderBy(desc(collabRooms.createdAt));
|
||||||
|
return rows.map((row) => row.room);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findMember(
|
||||||
|
roomId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<CollabRoomMemberRecord | null> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(collabRoomMembers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(collabRoomMembers.roomId, roomId),
|
||||||
|
eq(collabRoomMembers.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMember(input: {
|
||||||
|
roomId: string;
|
||||||
|
userId: string;
|
||||||
|
roomRole: CollabRoomRole;
|
||||||
|
addedBy: string | null;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
if (await this.findMember(input.roomId, input.userId)) return false;
|
||||||
|
await this.context.drizzle.insert(collabRoomMembers).values({
|
||||||
|
roomId: input.roomId,
|
||||||
|
userId: input.userId,
|
||||||
|
roomRole: input.roomRole,
|
||||||
|
addedBy: input.addedBy,
|
||||||
|
});
|
||||||
|
await this.afterWrite();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeMember(roomId: string, userId: string): Promise<void> {
|
||||||
|
await this.context.drizzle
|
||||||
|
.delete(collabRoomMembers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(collabRoomMembers.roomId, roomId),
|
||||||
|
eq(collabRoomMembers.userId, userId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
async listMembers(roomId: string): Promise<CollabRoomMemberWithUser[]> {
|
||||||
|
return this.context.drizzle
|
||||||
|
.select({
|
||||||
|
userId: collabRoomMembers.userId,
|
||||||
|
username: users.username,
|
||||||
|
roomRole: collabRoomMembers.roomRole,
|
||||||
|
createdAt: collabRoomMembers.createdAt,
|
||||||
|
})
|
||||||
|
.from(collabRoomMembers)
|
||||||
|
.innerJoin(users, eq(collabRoomMembers.userId, users.id))
|
||||||
|
.where(eq(collabRoomMembers.roomId, roomId))
|
||||||
|
.orderBy(users.username);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStage(roomId: string, stage: CollabRoomStage): Promise<void> {
|
||||||
|
await this.context.drizzle
|
||||||
|
.update(collabRooms)
|
||||||
|
.set(stage)
|
||||||
|
.where(eq(collabRooms.id, roomId));
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
async replaceStage(
|
||||||
|
roomId: string,
|
||||||
|
expectedShareId: string | null,
|
||||||
|
stage: CollabRoomStage,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await this.context.drizzle
|
||||||
|
.update(collabRooms)
|
||||||
|
.set(stage)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(collabRooms.id, roomId),
|
||||||
|
expectedShareId
|
||||||
|
? eq(collabRooms.stageShareId, expectedShareId)
|
||||||
|
: isNull(collabRooms.stageShareId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const changed = rowsAffected(result) > 0;
|
||||||
|
if (changed) await this.afterWrite();
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearStage(roomId: string): Promise<void> {
|
||||||
|
return this.updateStage(roomId, {
|
||||||
|
presenterUserId: null,
|
||||||
|
stageProtocol: null,
|
||||||
|
stageHostId: null,
|
||||||
|
stageShareId: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async endRoom(roomId: string): Promise<void> {
|
||||||
|
await this.context.drizzle
|
||||||
|
.update(collabRooms)
|
||||||
|
.set({
|
||||||
|
endedAt: new Date().toISOString(),
|
||||||
|
presenterUserId: null,
|
||||||
|
stageProtocol: null,
|
||||||
|
stageHostId: null,
|
||||||
|
stageShareId: null,
|
||||||
|
})
|
||||||
|
.where(eq(collabRooms.id, roomId));
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRoom(roomId: string): Promise<void> {
|
||||||
|
await this.context.drizzle
|
||||||
|
.delete(collabRooms)
|
||||||
|
.where(eq(collabRooms.id, roomId));
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async afterWrite(): Promise<void> {
|
||||||
|
await this.onWrite?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import { and, eq, gte, inArray, isNull, or, type SQL } from "drizzle-orm";
|
||||||
|
import { alias } from "drizzle-orm/sqlite-core";
|
||||||
|
import {
|
||||||
|
credentialAccess,
|
||||||
|
roles,
|
||||||
|
sshCredentials,
|
||||||
|
users,
|
||||||
|
} from "../db/schema.js";
|
||||||
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
import { insertReturning } from "./returning.js";
|
||||||
|
|
||||||
|
export type CredentialAccessRecord = typeof credentialAccess.$inferSelect;
|
||||||
|
export type CredentialPermissionLevel = "use" | "manage";
|
||||||
|
|
||||||
|
export interface CredentialAccessListItem extends CredentialAccessRecord {
|
||||||
|
targetType: "user" | "role";
|
||||||
|
username: string | null;
|
||||||
|
roleName: string | null;
|
||||||
|
roleDisplayName: string | null;
|
||||||
|
grantedByUsername: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CredentialAccessTarget =
|
||||||
|
| { targetType: "user"; targetUserId: string }
|
||||||
|
| { targetType: "role"; targetRoleId: number };
|
||||||
|
|
||||||
|
export interface UpsertCredentialAccessInput {
|
||||||
|
credentialId: number;
|
||||||
|
grantedBy: string;
|
||||||
|
permissionLevel: CredentialPermissionLevel;
|
||||||
|
expiresAt: string | null;
|
||||||
|
target: CredentialAccessTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A credential shared with the caller, with the grant that admits them. */
|
||||||
|
export interface SharedCredentialGrant {
|
||||||
|
accessId: number;
|
||||||
|
credentialId: number;
|
||||||
|
ownerId: string;
|
||||||
|
permissionLevel: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeFilter(now: string): SQL {
|
||||||
|
return or(
|
||||||
|
isNull(credentialAccess.expiresAt),
|
||||||
|
gte(credentialAccess.expiresAt, now),
|
||||||
|
)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function granteeFilter(userId: string, roleIds: number[]): SQL {
|
||||||
|
return roleIds.length === 0
|
||||||
|
? eq(credentialAccess.userId, userId)
|
||||||
|
: or(
|
||||||
|
eq(credentialAccess.userId, userId),
|
||||||
|
inArray(credentialAccess.roleId, roleIds),
|
||||||
|
)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CredentialAccessRepository {
|
||||||
|
constructor(
|
||||||
|
private readonly context: DatabaseContext,
|
||||||
|
private readonly onWrite?: () => void | Promise<void>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async listForCredential(
|
||||||
|
credentialId: number,
|
||||||
|
): Promise<CredentialAccessListItem[]> {
|
||||||
|
const granter = alias(users, "granter");
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select({
|
||||||
|
access: credentialAccess,
|
||||||
|
username: users.username,
|
||||||
|
roleName: roles.name,
|
||||||
|
roleDisplayName: roles.displayName,
|
||||||
|
grantedByUsername: granter.username,
|
||||||
|
})
|
||||||
|
.from(credentialAccess)
|
||||||
|
.leftJoin(users, eq(credentialAccess.userId, users.id))
|
||||||
|
.leftJoin(roles, eq(credentialAccess.roleId, roles.id))
|
||||||
|
.leftJoin(granter, eq(credentialAccess.grantedBy, granter.id))
|
||||||
|
.where(eq(credentialAccess.credentialId, credentialId));
|
||||||
|
return rows.map((row) => ({
|
||||||
|
...row.access,
|
||||||
|
targetType: row.access.roleId ? ("role" as const) : ("user" as const),
|
||||||
|
username: row.username,
|
||||||
|
roleName: row.roleName,
|
||||||
|
roleDisplayName: row.roleDisplayName,
|
||||||
|
grantedByUsername: row.grantedByUsername,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsert(
|
||||||
|
input: UpsertCredentialAccessInput,
|
||||||
|
): Promise<{ id: number; created: boolean }> {
|
||||||
|
const targetFilter =
|
||||||
|
input.target.targetType === "user"
|
||||||
|
? eq(credentialAccess.userId, input.target.targetUserId)
|
||||||
|
: eq(credentialAccess.roleId, input.target.targetRoleId);
|
||||||
|
const existing = await this.context.drizzle
|
||||||
|
.select({ id: credentialAccess.id })
|
||||||
|
.from(credentialAccess)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(credentialAccess.credentialId, input.credentialId),
|
||||||
|
targetFilter,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing[0]) {
|
||||||
|
await this.context.drizzle
|
||||||
|
.update(credentialAccess)
|
||||||
|
.set({
|
||||||
|
permissionLevel: input.permissionLevel,
|
||||||
|
expiresAt: input.expiresAt,
|
||||||
|
grantedBy: input.grantedBy,
|
||||||
|
})
|
||||||
|
.where(eq(credentialAccess.id, existing[0].id));
|
||||||
|
await this.afterWrite();
|
||||||
|
return { id: existing[0].id, created: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [created] = await insertReturning(this.context, credentialAccess, {
|
||||||
|
credentialId: input.credentialId,
|
||||||
|
userId:
|
||||||
|
input.target.targetType === "user" ? input.target.targetUserId : null,
|
||||||
|
roleId:
|
||||||
|
input.target.targetType === "role" ? input.target.targetRoleId : null,
|
||||||
|
grantedBy: input.grantedBy,
|
||||||
|
permissionLevel: input.permissionLevel,
|
||||||
|
expiresAt: input.expiresAt,
|
||||||
|
});
|
||||||
|
await this.afterWrite();
|
||||||
|
return { id: created.id, created: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(
|
||||||
|
accessId: number,
|
||||||
|
credentialId: number,
|
||||||
|
): Promise<CredentialAccessRecord | null> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(credentialAccess)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(credentialAccess.id, accessId),
|
||||||
|
eq(credentialAccess.credentialId, credentialId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async revoke(accessId: number, credentialId: number): Promise<void> {
|
||||||
|
await this.context.drizzle
|
||||||
|
.delete(credentialAccess)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(credentialAccess.id, accessId),
|
||||||
|
eq(credentialAccess.credentialId, credentialId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The strongest unexpired grant admitting this user to the credential. */
|
||||||
|
async findActiveGrant(
|
||||||
|
credentialId: number,
|
||||||
|
userId: string,
|
||||||
|
roleIds: number[],
|
||||||
|
now = new Date().toISOString(),
|
||||||
|
): Promise<CredentialAccessRecord | null> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(credentialAccess)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(credentialAccess.credentialId, credentialId),
|
||||||
|
granteeFilter(userId, roleIds),
|
||||||
|
activeFilter(now),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
rows.find((row) => row.permissionLevel === "manage") ?? rows[0] ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every credential shared with this user (directly or via a role). */
|
||||||
|
async listSharedWithUser(
|
||||||
|
userId: string,
|
||||||
|
roleIds: number[],
|
||||||
|
now = new Date().toISOString(),
|
||||||
|
): Promise<SharedCredentialGrant[]> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select({
|
||||||
|
accessId: credentialAccess.id,
|
||||||
|
credentialId: credentialAccess.credentialId,
|
||||||
|
ownerId: sshCredentials.userId,
|
||||||
|
permissionLevel: credentialAccess.permissionLevel,
|
||||||
|
expiresAt: credentialAccess.expiresAt,
|
||||||
|
})
|
||||||
|
.from(credentialAccess)
|
||||||
|
.innerJoin(
|
||||||
|
sshCredentials,
|
||||||
|
eq(credentialAccess.credentialId, sshCredentials.id),
|
||||||
|
)
|
||||||
|
.where(and(granteeFilter(userId, roleIds), activeFilter(now)));
|
||||||
|
// Direct and role grants can overlap; keep the strongest per credential.
|
||||||
|
const best = new Map<number, SharedCredentialGrant>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.ownerId === userId) continue;
|
||||||
|
const current = best.get(row.credentialId);
|
||||||
|
if (!current || row.permissionLevel === "manage")
|
||||||
|
best.set(row.credentialId, row);
|
||||||
|
}
|
||||||
|
return Array.from(best.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Active grants on one credential, expanded to user and role targets. */
|
||||||
|
async listActiveGrants(
|
||||||
|
credentialId: number,
|
||||||
|
now = new Date().toISOString(),
|
||||||
|
): Promise<CredentialAccessRecord[]> {
|
||||||
|
return this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(credentialAccess)
|
||||||
|
.where(
|
||||||
|
and(eq(credentialAccess.credentialId, credentialId), activeFilter(now)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grants a role holds, with the credential owner - for new-member snapshots. */
|
||||||
|
async listRoleGrants(
|
||||||
|
roleId: number,
|
||||||
|
): Promise<
|
||||||
|
Array<{ accessId: number; credentialId: number; ownerId: string }>
|
||||||
|
> {
|
||||||
|
return this.context.drizzle
|
||||||
|
.select({
|
||||||
|
accessId: credentialAccess.id,
|
||||||
|
credentialId: credentialAccess.credentialId,
|
||||||
|
ownerId: sshCredentials.userId,
|
||||||
|
})
|
||||||
|
.from(credentialAccess)
|
||||||
|
.innerJoin(
|
||||||
|
sshCredentials,
|
||||||
|
eq(credentialAccess.credentialId, sshCredentials.id),
|
||||||
|
)
|
||||||
|
.where(eq(credentialAccess.roleId, roleId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteForUserReferences(userId: string): Promise<void> {
|
||||||
|
await this.context.drizzle
|
||||||
|
.delete(credentialAccess)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
eq(credentialAccess.userId, userId),
|
||||||
|
eq(credentialAccess.grantedBy, userId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
async reassignGrantedBy(fromUserId: string, toUserId: string): Promise<void> {
|
||||||
|
await this.context.drizzle
|
||||||
|
.update(credentialAccess)
|
||||||
|
.set({ grantedBy: toUserId })
|
||||||
|
.where(eq(credentialAccess.grantedBy, fromUserId));
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async afterWrite(): Promise<void> {
|
||||||
|
await this.onWrite?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -348,6 +348,54 @@ export class CredentialRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-keys every credential of one user to another: decrypt under the old
|
||||||
|
* owner's key, encrypt under the new one, change the owner. Used when an
|
||||||
|
* account is deleted and its data is inherited rather than dropped.
|
||||||
|
*/
|
||||||
|
async transferAllToUser(
|
||||||
|
fromUserId: string,
|
||||||
|
toUserId: string,
|
||||||
|
): Promise<number[]> {
|
||||||
|
const fromKey = DataCrypto.validateUserAccess(fromUserId);
|
||||||
|
const toKey = DataCrypto.validateUserAccess(toUserId);
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(eq(sshCredentials.userId, fromUserId));
|
||||||
|
const moved: number[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const plain = DataCrypto.decryptRecord(
|
||||||
|
"ssh_credentials",
|
||||||
|
row,
|
||||||
|
fromUserId,
|
||||||
|
fromKey,
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
id: _id,
|
||||||
|
userId: _userId,
|
||||||
|
...fields
|
||||||
|
} = plain as Record<string, unknown>;
|
||||||
|
const encrypted = DataCrypto.encryptRecord(
|
||||||
|
"ssh_credentials",
|
||||||
|
{ ...fields, id: row.id },
|
||||||
|
toUserId,
|
||||||
|
toKey,
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
delete encrypted.id;
|
||||||
|
await this.context.drizzle
|
||||||
|
.update(sshCredentials)
|
||||||
|
.set({
|
||||||
|
...(encrypted as Partial<NewCredentialRecord>),
|
||||||
|
userId: toUserId,
|
||||||
|
})
|
||||||
|
.where(eq(sshCredentials.id, row.id));
|
||||||
|
moved.push(row.id);
|
||||||
|
}
|
||||||
|
if (moved.length) await this.afterWrite();
|
||||||
|
return moved;
|
||||||
|
}
|
||||||
|
|
||||||
private encryptCredentialRecordForWrite<T extends Record<string, unknown>>(
|
private encryptCredentialRecordForWrite<T extends Record<string, unknown>>(
|
||||||
record: T,
|
record: T,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ import { RoleRepository } from "./role-repository.js";
|
|||||||
import { SessionRecordingRepository } from "./session-recording-repository.js";
|
import { SessionRecordingRepository } from "./session-recording-repository.js";
|
||||||
import { SessionRepository } from "./session-repository.js";
|
import { SessionRepository } from "./session-repository.js";
|
||||||
import { SessionShareRepository } from "./session-share-repository.js";
|
import { SessionShareRepository } from "./session-share-repository.js";
|
||||||
|
import { CollabRoomRepository } from "./collab-room-repository.js";
|
||||||
|
import { SecretSourceRepository } from "./secret-source-repository.js";
|
||||||
|
import { CredentialAccessRepository } from "./credential-access-repository.js";
|
||||||
|
import { SharedCredentialSecretsRepository } from "./shared-credential-secrets-repository.js";
|
||||||
|
import { FolderAccessRepository } from "./folder-access-repository.js";
|
||||||
import { SettingsRepository } from "./settings-repository.js";
|
import { SettingsRepository } from "./settings-repository.js";
|
||||||
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
|
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
|
||||||
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
|
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
|
||||||
@@ -395,6 +400,13 @@ export function createCurrentSessionRepository(): SessionRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createCurrentCollabRoomRepository(): CollabRoomRepository {
|
||||||
|
return new CollabRoomRepository(
|
||||||
|
createCurrentRepositoryContext(),
|
||||||
|
createCurrentRepositoryWriteHook("collab_room_repository_write"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function createCurrentSessionShareRepository(): SessionShareRepository {
|
export function createCurrentSessionShareRepository(): SessionShareRepository {
|
||||||
return new SessionShareRepository(
|
return new SessionShareRepository(
|
||||||
createCurrentRepositoryContext(),
|
createCurrentRepositoryContext(),
|
||||||
@@ -499,6 +511,36 @@ export function createCurrentUserRepository(): UserRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createCurrentFolderAccessRepository(): FolderAccessRepository {
|
||||||
|
return new FolderAccessRepository(
|
||||||
|
createCurrentRepositoryContext(),
|
||||||
|
createCurrentRepositoryWriteHook("folder_access_repository_write"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCurrentCredentialAccessRepository(): CredentialAccessRepository {
|
||||||
|
return new CredentialAccessRepository(
|
||||||
|
createCurrentRepositoryContext(),
|
||||||
|
createCurrentRepositoryWriteHook("credential_access_repository_write"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCurrentSharedCredentialSecretsRepository(): SharedCredentialSecretsRepository {
|
||||||
|
return new SharedCredentialSecretsRepository(
|
||||||
|
createCurrentRepositoryContext(),
|
||||||
|
createCurrentRepositoryWriteHook(
|
||||||
|
"shared_credential_secrets_repository_write",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCurrentSecretSourceRepository(): SecretSourceRepository {
|
||||||
|
return new SecretSourceRepository(
|
||||||
|
createCurrentRepositoryContext(),
|
||||||
|
createCurrentRepositoryWriteHook("secret_source_repository_write"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function createCurrentVaultProfileRepository(): VaultProfileRepository {
|
export function createCurrentVaultProfileRepository(): VaultProfileRepository {
|
||||||
return new VaultProfileRepository(
|
return new VaultProfileRepository(
|
||||||
createCurrentRepositoryContext(),
|
createCurrentRepositoryContext(),
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { and, eq, inArray, like, or } from "drizzle-orm";
|
||||||
|
import { folderAccess, roles, users } from "../db/schema.js";
|
||||||
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
import { insertReturning } from "./returning.js";
|
||||||
|
|
||||||
|
export type FolderAccessRecord = typeof folderAccess.$inferSelect;
|
||||||
|
|
||||||
|
export interface FolderAccessListItem extends FolderAccessRecord {
|
||||||
|
targetType: "user" | "role";
|
||||||
|
username: string | null;
|
||||||
|
roleName: string | null;
|
||||||
|
roleDisplayName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FolderAccessTarget =
|
||||||
|
| { targetType: "user"; targetUserId: string }
|
||||||
|
| { targetType: "role"; targetRoleId: number };
|
||||||
|
|
||||||
|
/** "A / B / C" → ["A", "A / B", "A / B / C"]: a host inherits rules on every ancestor. */
|
||||||
|
export function folderAncestors(folder: string): string[] {
|
||||||
|
const parts = folder.split(" / ");
|
||||||
|
return parts.map((_, i) => parts.slice(0, i + 1).join(" / "));
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FolderAccessRepository {
|
||||||
|
constructor(
|
||||||
|
private readonly context: DatabaseContext,
|
||||||
|
private readonly onWrite?: () => void | Promise<void>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async upsert(input: {
|
||||||
|
ownerUserId: string;
|
||||||
|
folder: string;
|
||||||
|
grantedBy: string;
|
||||||
|
permissionLevel: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
target: FolderAccessTarget;
|
||||||
|
}): Promise<FolderAccessRecord> {
|
||||||
|
const targetFilter =
|
||||||
|
input.target.targetType === "user"
|
||||||
|
? eq(folderAccess.userId, input.target.targetUserId)
|
||||||
|
: eq(folderAccess.roleId, input.target.targetRoleId);
|
||||||
|
const existing = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(folderAccess)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(folderAccess.ownerUserId, input.ownerUserId),
|
||||||
|
eq(folderAccess.folder, input.folder),
|
||||||
|
targetFilter,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (existing[0]) {
|
||||||
|
await this.context.drizzle
|
||||||
|
.update(folderAccess)
|
||||||
|
.set({
|
||||||
|
permissionLevel: input.permissionLevel,
|
||||||
|
expiresAt: input.expiresAt,
|
||||||
|
grantedBy: input.grantedBy,
|
||||||
|
})
|
||||||
|
.where(eq(folderAccess.id, existing[0].id));
|
||||||
|
await this.afterWrite();
|
||||||
|
return {
|
||||||
|
...existing[0],
|
||||||
|
permissionLevel: input.permissionLevel,
|
||||||
|
expiresAt: input.expiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const [created] = await insertReturning(this.context, folderAccess, {
|
||||||
|
ownerUserId: input.ownerUserId,
|
||||||
|
folder: input.folder,
|
||||||
|
userId:
|
||||||
|
input.target.targetType === "user" ? input.target.targetUserId : null,
|
||||||
|
roleId:
|
||||||
|
input.target.targetType === "role" ? input.target.targetRoleId : null,
|
||||||
|
grantedBy: input.grantedBy,
|
||||||
|
permissionLevel: input.permissionLevel,
|
||||||
|
expiresAt: input.expiresAt,
|
||||||
|
});
|
||||||
|
await this.afterWrite();
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rules that apply to a host in this folder: the folder's own and its ancestors'. */
|
||||||
|
async listApplicable(
|
||||||
|
ownerUserId: string,
|
||||||
|
folder: string,
|
||||||
|
now = new Date().toISOString(),
|
||||||
|
): Promise<FolderAccessRecord[]> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(folderAccess)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(folderAccess.ownerUserId, ownerUserId),
|
||||||
|
inArray(folderAccess.folder, folderAncestors(folder)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return rows.filter((row) => !row.expiresAt || row.expiresAt >= now);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listForFolder(
|
||||||
|
ownerUserId: string,
|
||||||
|
folder: string,
|
||||||
|
): Promise<FolderAccessListItem[]> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select({
|
||||||
|
rule: folderAccess,
|
||||||
|
username: users.username,
|
||||||
|
roleName: roles.name,
|
||||||
|
roleDisplayName: roles.displayName,
|
||||||
|
})
|
||||||
|
.from(folderAccess)
|
||||||
|
.leftJoin(users, eq(folderAccess.userId, users.id))
|
||||||
|
.leftJoin(roles, eq(folderAccess.roleId, roles.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(folderAccess.ownerUserId, ownerUserId),
|
||||||
|
eq(folderAccess.folder, folder),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return rows.map((row) => ({
|
||||||
|
...row.rule,
|
||||||
|
targetType: row.rule.roleId ? ("role" as const) : ("user" as const),
|
||||||
|
username: row.username,
|
||||||
|
roleName: row.roleName,
|
||||||
|
roleDisplayName: row.roleDisplayName,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(
|
||||||
|
id: number,
|
||||||
|
ownerUserId: string,
|
||||||
|
): Promise<FolderAccessRecord | null> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(folderAccess)
|
||||||
|
.where(
|
||||||
|
and(eq(folderAccess.id, id), eq(folderAccess.ownerUserId, ownerUserId)),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteById(id: number, ownerUserId: string): Promise<void> {
|
||||||
|
await this.context.drizzle
|
||||||
|
.delete(folderAccess)
|
||||||
|
.where(
|
||||||
|
and(eq(folderAccess.id, id), eq(folderAccess.ownerUserId, ownerUserId)),
|
||||||
|
);
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Follows a folder rename, including subfolders. */
|
||||||
|
async renameFolder(
|
||||||
|
ownerUserId: string,
|
||||||
|
oldName: string,
|
||||||
|
newName: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const prefix = `${oldName} / `;
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select({ id: folderAccess.id, folder: folderAccess.folder })
|
||||||
|
.from(folderAccess)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(folderAccess.ownerUserId, ownerUserId),
|
||||||
|
or(
|
||||||
|
eq(folderAccess.folder, oldName),
|
||||||
|
like(folderAccess.folder, `${prefix}%`),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const row of rows) {
|
||||||
|
const renamed =
|
||||||
|
row.folder === oldName
|
||||||
|
? newName
|
||||||
|
: `${newName} / ${row.folder.slice(prefix.length)}`;
|
||||||
|
await this.context.drizzle
|
||||||
|
.update(folderAccess)
|
||||||
|
.set({ folder: renamed })
|
||||||
|
.where(eq(folderAccess.id, row.id));
|
||||||
|
}
|
||||||
|
if (rows.length) await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async afterWrite(): Promise<void> {
|
||||||
|
await this.onWrite?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -168,6 +168,47 @@ export class HostRepository {
|
|||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Re-keys every host of one user to another; see CredentialRepository.transferAllToUser. */
|
||||||
|
async transferAllToUser(
|
||||||
|
fromUserId: string,
|
||||||
|
toUserId: string,
|
||||||
|
): Promise<number[]> {
|
||||||
|
const fromKey = DataCrypto.validateUserAccess(fromUserId);
|
||||||
|
const toKey = DataCrypto.validateUserAccess(toUserId);
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(hosts)
|
||||||
|
.where(eq(hosts.userId, fromUserId));
|
||||||
|
const moved: number[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const plain = DataCrypto.decryptRecord(
|
||||||
|
"ssh_data",
|
||||||
|
row,
|
||||||
|
fromUserId,
|
||||||
|
fromKey,
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
id: _id,
|
||||||
|
userId: _userId,
|
||||||
|
...fields
|
||||||
|
} = plain as Record<string, unknown>;
|
||||||
|
const encrypted = DataCrypto.encryptRecord(
|
||||||
|
"ssh_data",
|
||||||
|
{ ...fields, id: row.id },
|
||||||
|
toUserId,
|
||||||
|
toKey,
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
delete encrypted.id;
|
||||||
|
await this.context.drizzle
|
||||||
|
.update(hosts)
|
||||||
|
.set({ ...(encrypted as Partial<HostUpdate>), userId: toUserId })
|
||||||
|
.where(eq(hosts.id, row.id));
|
||||||
|
moved.push(row.id);
|
||||||
|
}
|
||||||
|
if (moved.length) await this.afterWrite();
|
||||||
|
return moved;
|
||||||
|
}
|
||||||
|
|
||||||
async updateEncryptedForUser(
|
async updateEncryptedForUser(
|
||||||
userId: string,
|
userId: string,
|
||||||
hostId: number,
|
hostId: number,
|
||||||
|
|||||||
@@ -207,6 +207,18 @@ export class RbacAccessRepository {
|
|||||||
return rowsAffected(result);
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Grants the departing user handed out now count as the successor's. */
|
||||||
|
async reassignHostAccessGrantedBy(
|
||||||
|
fromUserId: string,
|
||||||
|
toUserId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.context.drizzle
|
||||||
|
.update(hostAccess)
|
||||||
|
.set({ grantedBy: toUserId })
|
||||||
|
.where(eq(hostAccess.grantedBy, fromUserId));
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
async deleteHostAccessForUserReferences(userId: string): Promise<number> {
|
async deleteHostAccessForUserReferences(userId: string): Promise<number> {
|
||||||
const directResult = await this.context.drizzle
|
const directResult = await this.context.drizzle
|
||||||
.delete(hostAccess)
|
.delete(hostAccess)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import { hostAccess, roles, userRoles } from "../db/schema.js";
|
import { hostAccess, roles, userRoles, users } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
import { rowsAffected } from "./mutation-result.js";
|
import { rowsAffected } from "./mutation-result.js";
|
||||||
import { deleteReturning, insertReturning } from "./returning.js";
|
import { deleteReturning, insertReturning } from "./returning.js";
|
||||||
@@ -8,7 +8,7 @@ export type RoleRecord = typeof roles.$inferSelect;
|
|||||||
export type NewRoleRecord = typeof roles.$inferInsert;
|
export type NewRoleRecord = typeof roles.$inferInsert;
|
||||||
export type RoleUpdate = Pick<
|
export type RoleUpdate = Pick<
|
||||||
Partial<NewRoleRecord>,
|
Partial<NewRoleRecord>,
|
||||||
"displayName" | "description" | "updatedAt"
|
"displayName" | "description" | "permissions" | "updatedAt"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type UserRoleWithRole = {
|
export type UserRoleWithRole = {
|
||||||
@@ -21,6 +21,13 @@ export type UserRoleWithRole = {
|
|||||||
grantedAt: string;
|
grantedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RoleMemberRecord = {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
grantedAt: string;
|
||||||
|
grantedBy: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type UserRolePermissionRecord = {
|
export type UserRolePermissionRecord = {
|
||||||
permissions: string | null;
|
permissions: string | null;
|
||||||
};
|
};
|
||||||
@@ -226,6 +233,20 @@ export class RoleRepository {
|
|||||||
return rows.map((row) => row.userId);
|
return rows.map((row) => row.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listRoleMembers(roleId: number): Promise<RoleMemberRecord[]> {
|
||||||
|
return this.context.drizzle
|
||||||
|
.select({
|
||||||
|
userId: userRoles.userId,
|
||||||
|
username: users.username,
|
||||||
|
grantedAt: userRoles.grantedAt,
|
||||||
|
grantedBy: userRoles.grantedBy,
|
||||||
|
})
|
||||||
|
.from(userRoles)
|
||||||
|
.innerJoin(users, eq(userRoles.userId, users.id))
|
||||||
|
.where(eq(userRoles.roleId, roleId))
|
||||||
|
.orderBy(users.username);
|
||||||
|
}
|
||||||
|
|
||||||
async listUserRolePermissions(
|
async listUserRolePermissions(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<UserRolePermissionRecord[]> {
|
): Promise<UserRolePermissionRecord[]> {
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { desc, eq, or } from "drizzle-orm";
|
||||||
|
import { secretSources } from "../db/schema.js";
|
||||||
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
import { insertReturning } from "./returning.js";
|
||||||
|
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||||
|
import { FieldCrypto } from "../../utils/field-crypto.js";
|
||||||
|
|
||||||
|
export type SecretSourceRecord = typeof secretSources.$inferSelect;
|
||||||
|
export type SecretSourceKind = "onepassword-connect";
|
||||||
|
|
||||||
|
/** A row with the token still encrypted - safe to hand to the API. */
|
||||||
|
export type SecretSourcePublic = Omit<SecretSourceRecord, "token"> & {
|
||||||
|
hasToken: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SecretSourceCreateInput {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
kind: SecretSourceKind;
|
||||||
|
baseUrl: string;
|
||||||
|
token: string;
|
||||||
|
shared: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecretSourceUpdateInput {
|
||||||
|
name?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
token?: string;
|
||||||
|
shared?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toPublicSecretSource(
|
||||||
|
row: SecretSourceRecord,
|
||||||
|
): SecretSourcePublic {
|
||||||
|
const { token, ...rest } = row;
|
||||||
|
return { ...rest, hasToken: token.length > 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The token is encrypted with the owner's data key under the row id, like
|
||||||
|
* vault_tokens - so a shared source only decrypts while its owner's key is
|
||||||
|
* loaded, which the resolver reports as a clear error.
|
||||||
|
*/
|
||||||
|
export class SecretSourceRepository {
|
||||||
|
constructor(
|
||||||
|
private readonly context: DatabaseContext,
|
||||||
|
private readonly onWrite?: () => void | Promise<void>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private encryptToken(id: string, ownerId: string, token: string): string {
|
||||||
|
const key = DataCrypto.validateUserAccess(ownerId);
|
||||||
|
return FieldCrypto.encryptField(token, key, id, "token");
|
||||||
|
}
|
||||||
|
|
||||||
|
decryptToken(row: SecretSourceRecord): string {
|
||||||
|
const key = DataCrypto.getUserDataKey(row.userId);
|
||||||
|
if (!key) {
|
||||||
|
throw new Error(
|
||||||
|
"The secret source owner's data is locked; they need to sign in first",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return FieldCrypto.decryptField(row.token, key, row.id, "token");
|
||||||
|
}
|
||||||
|
|
||||||
|
async listVisibleToUser(userId: string): Promise<SecretSourceRecord[]> {
|
||||||
|
return this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(secretSources)
|
||||||
|
.where(
|
||||||
|
or(eq(secretSources.userId, userId), eq(secretSources.shared, true)),
|
||||||
|
)
|
||||||
|
.orderBy(desc(secretSources.updatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<SecretSourceRecord | null> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(secretSources)
|
||||||
|
.where(eq(secretSources.id, id))
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: SecretSourceCreateInput): Promise<SecretSourceRecord> {
|
||||||
|
const [created] = await insertReturning(this.context, secretSources, {
|
||||||
|
id: input.id,
|
||||||
|
userId: input.userId,
|
||||||
|
name: input.name,
|
||||||
|
kind: input.kind,
|
||||||
|
baseUrl: input.baseUrl,
|
||||||
|
token: this.encryptToken(input.id, input.userId, input.token),
|
||||||
|
shared: input.shared,
|
||||||
|
});
|
||||||
|
await this.afterWrite();
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
row: SecretSourceRecord,
|
||||||
|
input: SecretSourceUpdateInput,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.context.drizzle
|
||||||
|
.update(secretSources)
|
||||||
|
.set({
|
||||||
|
...(input.name !== undefined ? { name: input.name } : {}),
|
||||||
|
...(input.baseUrl !== undefined ? { baseUrl: input.baseUrl } : {}),
|
||||||
|
...(input.shared !== undefined ? { shared: input.shared } : {}),
|
||||||
|
...(input.token !== undefined
|
||||||
|
? { token: this.encryptToken(row.id, row.userId, input.token) }
|
||||||
|
: {}),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.where(eq(secretSources.id, row.id));
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteById(id: string): Promise<void> {
|
||||||
|
await this.context.drizzle
|
||||||
|
.delete(secretSources)
|
||||||
|
.where(eq(secretSources.id, id));
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteByUserId(userId: string): Promise<void> {
|
||||||
|
await this.context.drizzle
|
||||||
|
.delete(secretSources)
|
||||||
|
.where(eq(secretSources.userId, userId));
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async afterWrite(): Promise<void> {
|
||||||
|
await this.onWrite?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,9 @@ export type SessionShareRecord = typeof sessionShares.$inferSelect;
|
|||||||
export type SessionShareParticipantRecord =
|
export type SessionShareParticipantRecord =
|
||||||
typeof sessionShareParticipants.$inferSelect;
|
typeof sessionShareParticipants.$inferSelect;
|
||||||
|
|
||||||
export type SessionShareType = "link" | "user";
|
// "room" shares are minted internally by the collab-room routes and are
|
||||||
|
// joinable by any member of the room whose stage references them.
|
||||||
|
export type SessionShareType = "link" | "user" | "room";
|
||||||
export type SessionSharePermissionLevel = "read-only" | "read-write";
|
export type SessionSharePermissionLevel = "read-only" | "read-write";
|
||||||
|
|
||||||
export interface SessionShareCreateInput {
|
export interface SessionShareCreateInput {
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import { credentialAccess, sharedCredentialSecrets } from "../db/schema.js";
|
||||||
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
|
||||||
|
export type SharedCredentialSecretRecord =
|
||||||
|
typeof sharedCredentialSecrets.$inferSelect;
|
||||||
|
export type NewSharedCredentialSecretRecord =
|
||||||
|
typeof sharedCredentialSecrets.$inferInsert;
|
||||||
|
|
||||||
|
export class SharedCredentialSecretsRepository {
|
||||||
|
constructor(
|
||||||
|
private readonly context: DatabaseContext,
|
||||||
|
private readonly onWrite?: () => void | Promise<void>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async upsert(record: NewSharedCredentialSecretRecord): Promise<void> {
|
||||||
|
const existing = await this.context.drizzle
|
||||||
|
.select({ id: sharedCredentialSecrets.id })
|
||||||
|
.from(sharedCredentialSecrets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
sharedCredentialSecrets.credentialAccessId,
|
||||||
|
record.credentialAccessId,
|
||||||
|
),
|
||||||
|
eq(sharedCredentialSecrets.targetUserId, record.targetUserId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (existing[0]) {
|
||||||
|
await this.context.drizzle
|
||||||
|
.update(sharedCredentialSecrets)
|
||||||
|
.set({ ...record, updatedAt: new Date().toISOString() })
|
||||||
|
.where(eq(sharedCredentialSecrets.id, existing[0].id));
|
||||||
|
} else {
|
||||||
|
await this.context.drizzle.insert(sharedCredentialSecrets).values(record);
|
||||||
|
}
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The recipient's snapshot for a credential, whichever grant produced it. */
|
||||||
|
async findForCredentialUser(
|
||||||
|
credentialId: number,
|
||||||
|
targetUserId: string,
|
||||||
|
): Promise<SharedCredentialSecretRecord | null> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(sharedCredentialSecrets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sharedCredentialSecrets.credentialId, credentialId),
|
||||||
|
eq(sharedCredentialSecrets.targetUserId, targetUserId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteForRoleMember(
|
||||||
|
roleId: number,
|
||||||
|
targetUserId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const grants = await this.context.drizzle
|
||||||
|
.select({ id: credentialAccess.id })
|
||||||
|
.from(credentialAccess)
|
||||||
|
.where(eq(credentialAccess.roleId, roleId));
|
||||||
|
if (grants.length === 0) return;
|
||||||
|
await this.context.drizzle.delete(sharedCredentialSecrets).where(
|
||||||
|
and(
|
||||||
|
inArray(
|
||||||
|
sharedCredentialSecrets.credentialAccessId,
|
||||||
|
grants.map((g) => g.id),
|
||||||
|
),
|
||||||
|
eq(sharedCredentialSecrets.targetUserId, targetUserId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteByTargetUserId(userId: string): Promise<void> {
|
||||||
|
await this.context.drizzle
|
||||||
|
.delete(sharedCredentialSecrets)
|
||||||
|
.where(eq(sharedCredentialSecrets.targetUserId, userId));
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async afterWrite(): Promise<void> {
|
||||||
|
await this.onWrite?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,33 @@ export class SharedHostAuthOverrideRepository {
|
|||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Recipient overrides for a host keyed by protocol, in one query. */
|
||||||
|
async listCredentialIds(
|
||||||
|
hostId: number,
|
||||||
|
userId: string,
|
||||||
|
): Promise<Partial<Record<AuthOverrideProtocol, number>>> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select({
|
||||||
|
protocol: sharedHostAuthOverrides.protocol,
|
||||||
|
credentialId: sharedHostAuthOverrides.credentialId,
|
||||||
|
})
|
||||||
|
.from(sharedHostAuthOverrides)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sharedHostAuthOverrides.hostId, hostId),
|
||||||
|
eq(sharedHostAuthOverrides.userId, userId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result: Partial<Record<AuthOverrideProtocol, number>> = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.credentialId) {
|
||||||
|
result[row.protocol as AuthOverrideProtocol] = row.credentialId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
async findCredentialId(
|
async findCredentialId(
|
||||||
hostId: number,
|
hostId: number,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { and, asc, eq, sql } from "drizzle-orm";
|
import { and, asc, eq, like, or, sql } from "drizzle-orm";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { snippetFolders, snippets } from "../db/schema.js";
|
import { snippetFolders, snippets } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
@@ -108,6 +108,25 @@ export class SnippetRepository {
|
|||||||
return this.listFolders(userId);
|
return this.listFolders(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Snippets in a folder or any of its subfolders ("Parent / Child"). */
|
||||||
|
async listOwnedSnippetsInFolder(
|
||||||
|
userId: string,
|
||||||
|
folder: string,
|
||||||
|
): Promise<SnippetRecord[]> {
|
||||||
|
return this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(snippets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(snippets.userId, userId),
|
||||||
|
or(
|
||||||
|
eq(snippets.folder, folder),
|
||||||
|
like(snippets.folder, `${folder} / %`),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async listOwnedSnippets(userId: string): Promise<SnippetRecord[]> {
|
async listOwnedSnippets(userId: string): Promise<SnippetRecord[]> {
|
||||||
return this.context.drizzle
|
return this.context.drizzle
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
Trigger,
|
Trigger,
|
||||||
} from "../../../types/automations.js";
|
} from "../../../types/automations.js";
|
||||||
import { AUTOMATION_DEFINITION_VERSION } from "../../../types/automations.js";
|
import { AUTOMATION_DEFINITION_VERSION } from "../../../types/automations.js";
|
||||||
|
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||||
import { AuthManager } from "../../utils/auth-manager.js";
|
import { AuthManager } from "../../utils/auth-manager.js";
|
||||||
import { databaseLogger } from "../../utils/logger.js";
|
import { databaseLogger } from "../../utils/logger.js";
|
||||||
import {
|
import {
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
|
const permissionManager = PermissionManager.getInstance();
|
||||||
const authenticateJWT = authManager.createAuthMiddleware();
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||||
|
|
||||||
@@ -244,6 +246,7 @@ async function syncSchedule(
|
|||||||
router.get(
|
router.get(
|
||||||
"/",
|
"/",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("automations.view"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -282,6 +285,7 @@ router.get(
|
|||||||
router.get(
|
router.get(
|
||||||
"/:id",
|
"/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("automations.view"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -333,6 +337,7 @@ router.get(
|
|||||||
router.post(
|
router.post(
|
||||||
"/",
|
"/",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("automations.create"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -423,6 +428,7 @@ router.post(
|
|||||||
router.put(
|
router.put(
|
||||||
"/:id",
|
"/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("automations.edit"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -531,6 +537,7 @@ router.put(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/:id",
|
"/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("automations.delete"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -600,6 +607,7 @@ router.delete(
|
|||||||
router.post(
|
router.post(
|
||||||
"/:id/run",
|
"/:id/run",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("automations.run"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -673,6 +681,7 @@ router.post(
|
|||||||
router.get(
|
router.get(
|
||||||
"/runs/history",
|
"/runs/history",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("automations.view"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -715,6 +724,7 @@ router.get(
|
|||||||
router.get(
|
router.get(
|
||||||
"/runs/:runId/steps",
|
"/runs/:runId/steps",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("automations.view"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { createCurrentCredentialRepository } from "../repositories/factory.js";
|
|||||||
export function registerCredentialBulkRoutes(
|
export function registerCredentialBulkRoutes(
|
||||||
router: Router,
|
router: Router,
|
||||||
authenticateJWT: RequestHandler,
|
authenticateJWT: RequestHandler,
|
||||||
|
requireEditPermission: RequestHandler,
|
||||||
|
requireDataAccess: RequestHandler,
|
||||||
): void {
|
): void {
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
@@ -42,6 +44,8 @@ export function registerCredentialBulkRoutes(
|
|||||||
router.put(
|
router.put(
|
||||||
"/reorder",
|
"/reorder",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireEditPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { positions } = req.body as {
|
const { positions } = req.body as {
|
||||||
|
|||||||
@@ -359,6 +359,9 @@ async function deploySSHKeyToHost(
|
|||||||
export function registerCredentialDeployRoutes(
|
export function registerCredentialDeployRoutes(
|
||||||
router: Router,
|
router: Router,
|
||||||
authenticateJWT: RequestHandler,
|
authenticateJWT: RequestHandler,
|
||||||
|
requireCredentialViewPermission: RequestHandler,
|
||||||
|
requireHostEditPermission: RequestHandler,
|
||||||
|
requireDataAccess: RequestHandler,
|
||||||
): void {
|
): void {
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
@@ -398,6 +401,9 @@ export function registerCredentialDeployRoutes(
|
|||||||
router.post(
|
router.post(
|
||||||
"/:id/deploy-to-host",
|
"/:id/deploy-to-host",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireCredentialViewPermission,
|
||||||
|
requireHostEditPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const id = Array.isArray(req.params.id)
|
const id = Array.isArray(req.params.id)
|
||||||
? req.params.id[0]
|
? req.params.id[0]
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ function generateSSHKeyPair(
|
|||||||
export function registerCredentialKeyRoutes(
|
export function registerCredentialKeyRoutes(
|
||||||
router: Router,
|
router: Router,
|
||||||
authenticateJWT: RequestHandler,
|
authenticateJWT: RequestHandler,
|
||||||
|
requireCredentialViewPermission: RequestHandler,
|
||||||
|
requireDataAccess: RequestHandler,
|
||||||
): void {
|
): void {
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
@@ -94,6 +96,8 @@ export function registerCredentialKeyRoutes(
|
|||||||
router.post(
|
router.post(
|
||||||
"/detect-key-type",
|
"/detect-key-type",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireCredentialViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const { privateKey, keyPassword } = req.body;
|
const { privateKey, keyPassword } = req.body;
|
||||||
|
|
||||||
@@ -150,6 +154,8 @@ export function registerCredentialKeyRoutes(
|
|||||||
router.post(
|
router.post(
|
||||||
"/detect-public-key-type",
|
"/detect-public-key-type",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireCredentialViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const { publicKey } = req.body;
|
const { publicKey } = req.body;
|
||||||
|
|
||||||
@@ -209,6 +215,8 @@ export function registerCredentialKeyRoutes(
|
|||||||
router.post(
|
router.post(
|
||||||
"/validate-key-pair",
|
"/validate-key-pair",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireCredentialViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const { privateKey, publicKey, keyPassword } = req.body;
|
const { privateKey, publicKey, keyPassword } = req.body;
|
||||||
|
|
||||||
@@ -274,6 +282,8 @@ export function registerCredentialKeyRoutes(
|
|||||||
router.post(
|
router.post(
|
||||||
"/generate-key-pair",
|
"/generate-key-pair",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireCredentialViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const { keyType = "ssh-ed25519", keySize = 2048, passphrase } = req.body;
|
const { keyType = "ssh-ed25519", keySize = 2048, passphrase } = req.body;
|
||||||
|
|
||||||
@@ -339,6 +349,8 @@ export function registerCredentialKeyRoutes(
|
|||||||
router.post(
|
router.post(
|
||||||
"/generate-public-key",
|
"/generate-public-key",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireCredentialViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const { privateKey, keyPassword } = req.body;
|
const { privateKey, keyPassword } = req.body;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { getErrorMessage } from "../../utils/error-message.js";
|
|||||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
import express, { type Request, type Response } from "express";
|
import express, { type Request, type Response } from "express";
|
||||||
import { authLogger } from "../../utils/logger.js";
|
import { authLogger } from "../../utils/logger.js";
|
||||||
|
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||||
import { AuthManager } from "../../utils/auth-manager.js";
|
import { AuthManager } from "../../utils/auth-manager.js";
|
||||||
import { parseSSHKey } from "../../utils/ssh-key-utils.js";
|
import { parseSSHKey } from "../../utils/ssh-key-utils.js";
|
||||||
import { registerCredentialKeyRoutes } from "./credential-key-routes.js";
|
import { registerCredentialKeyRoutes } from "./credential-key-routes.js";
|
||||||
@@ -14,6 +15,9 @@ import {
|
|||||||
} from "../../utils/audit-logger.js";
|
} from "../../utils/audit-logger.js";
|
||||||
import {
|
import {
|
||||||
createCurrentCredentialRepository,
|
createCurrentCredentialRepository,
|
||||||
|
createCurrentUserRepository,
|
||||||
|
createCurrentCredentialAccessRepository,
|
||||||
|
createCurrentRoleRepository,
|
||||||
createCurrentHostResolutionRepository,
|
createCurrentHostResolutionRepository,
|
||||||
createCurrentHostRepository,
|
createCurrentHostRepository,
|
||||||
createCurrentSyncTombstoneRepository,
|
createCurrentSyncTombstoneRepository,
|
||||||
@@ -26,6 +30,7 @@ function isNonEmptyString(val: unknown): val is string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
|
const permissionManager = PermissionManager.getInstance();
|
||||||
const authenticateJWT = authManager.createAuthMiddleware();
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||||
|
|
||||||
@@ -78,6 +83,7 @@ const requireDataAccess = authManager.createDataAccessMiddleware();
|
|||||||
router.post(
|
router.post(
|
||||||
"/",
|
"/",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("credentials.create"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -250,6 +256,7 @@ router.post(
|
|||||||
router.get(
|
router.get(
|
||||||
"/",
|
"/",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("credentials.view"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -262,8 +269,11 @@ router.get(
|
|||||||
try {
|
try {
|
||||||
const credentials =
|
const credentials =
|
||||||
await createCurrentCredentialRepository().listDecryptedByUserId(userId);
|
await createCurrentCredentialRepository().listDecryptedByUserId(userId);
|
||||||
|
const own = credentials.map((cred) => formatCredentialOutput(cred));
|
||||||
|
|
||||||
res.json(credentials.map((cred) => formatCredentialOutput(cred)));
|
// Credentials shared with this user, read from their own snapshots.
|
||||||
|
const shared = await listSharedCredentialsForUser(userId);
|
||||||
|
res.json([...own, ...shared]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
authLogger.error("Failed to fetch credentials", err);
|
authLogger.error("Failed to fetch credentials", err);
|
||||||
res.status(500).json({ error: "Failed to fetch credentials" });
|
res.status(500).json({ error: "Failed to fetch credentials" });
|
||||||
@@ -290,6 +300,7 @@ router.get(
|
|||||||
router.get(
|
router.get(
|
||||||
"/folders",
|
"/folders",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("credentials.view"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -311,7 +322,12 @@ router.get(
|
|||||||
// Registered here (before the PUT /:id route below) so the literal
|
// Registered here (before the PUT /:id route below) so the literal
|
||||||
// "/reorder" path segment is matched before Express falls through to the
|
// "/reorder" path segment is matched before Express falls through to the
|
||||||
// PUT /:id param route and treats "reorder" as an id.
|
// PUT /:id param route and treats "reorder" as an id.
|
||||||
registerCredentialBulkRoutes(router, authenticateJWT);
|
registerCredentialBulkRoutes(
|
||||||
|
router,
|
||||||
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("credentials.edit"),
|
||||||
|
requireDataAccess,
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
@@ -340,6 +356,7 @@ registerCredentialBulkRoutes(router, authenticateJWT);
|
|||||||
router.get(
|
router.get(
|
||||||
"/:id",
|
"/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("credentials.view"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -351,11 +368,14 @@ router.get(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const credential =
|
const credentialRepository = createCurrentCredentialRepository();
|
||||||
await createCurrentCredentialRepository().findDecryptedByIdForUser(
|
const ownCredential = await credentialRepository.findDecryptedByIdForUser(
|
||||||
userId,
|
userId,
|
||||||
parseInt(id),
|
parseInt(id),
|
||||||
);
|
);
|
||||||
|
const credential =
|
||||||
|
ownCredential ??
|
||||||
|
(await findSharedCredentialForUser(parseInt(id), userId));
|
||||||
|
|
||||||
if (!credential) {
|
if (!credential) {
|
||||||
return res.status(404).json({ error: "Credential not found" });
|
return res.status(404).json({ error: "Credential not found" });
|
||||||
@@ -432,6 +452,7 @@ router.get(
|
|||||||
router.post(
|
router.post(
|
||||||
"/:id/duplicate",
|
"/:id/duplicate",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("credentials.create"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -585,6 +606,7 @@ router.post(
|
|||||||
router.put(
|
router.put(
|
||||||
"/:id",
|
"/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("credentials.edit"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -604,13 +626,21 @@ router.put(
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existingCredential =
|
// A recipient holding "manage" edits the owner's row on the owner's
|
||||||
await createCurrentCredentialRepository().findDecryptedByIdForUser(
|
// behalf; the row stays encrypted under the owner's key and every
|
||||||
userId,
|
// recipient's snapshot is rebuilt below.
|
||||||
|
const editableOwnerId = await resolveEditableCredentialOwner(
|
||||||
credentialId,
|
credentialId,
|
||||||
|
userId,
|
||||||
);
|
);
|
||||||
|
const existingCredential = editableOwnerId
|
||||||
|
? await createCurrentCredentialRepository().findDecryptedByIdForUser(
|
||||||
|
editableOwnerId,
|
||||||
|
credentialId,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
if (!existingCredential) {
|
if (!existingCredential || !editableOwnerId) {
|
||||||
return res.status(404).json({ error: "Credential not found" });
|
return res.status(404).json({ error: "Credential not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -673,14 +703,14 @@ router.put(
|
|||||||
|
|
||||||
const credentialRepository = createCurrentCredentialRepository();
|
const credentialRepository = createCurrentCredentialRepository();
|
||||||
const updated = await credentialRepository.updateEncryptedForUser(
|
const updated = await credentialRepository.updateEncryptedForUser(
|
||||||
userId,
|
editableOwnerId,
|
||||||
credentialId,
|
credentialId,
|
||||||
updateFields,
|
updateFields,
|
||||||
);
|
);
|
||||||
const updatedCredential =
|
const updatedCredential =
|
||||||
updated ??
|
updated ??
|
||||||
(await credentialRepository.findDecryptedByIdForUser(
|
(await credentialRepository.findDecryptedByIdForUser(
|
||||||
userId,
|
editableOwnerId,
|
||||||
credentialId,
|
credentialId,
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -688,7 +718,13 @@ router.put(
|
|||||||
await import("../../utils/shared-host-secrets-manager.js");
|
await import("../../utils/shared-host-secrets-manager.js");
|
||||||
await SharedHostSecretsManager.getInstance().resyncHostsForCredential(
|
await SharedHostSecretsManager.getInstance().resyncHostsForCredential(
|
||||||
credentialId,
|
credentialId,
|
||||||
userId,
|
editableOwnerId,
|
||||||
|
);
|
||||||
|
const { SharedCredentialSecretsManager } =
|
||||||
|
await import("../../utils/shared-credential-secrets-manager.js");
|
||||||
|
await SharedCredentialSecretsManager.getInstance().resyncCredential(
|
||||||
|
credentialId,
|
||||||
|
editableOwnerId,
|
||||||
);
|
);
|
||||||
|
|
||||||
authLogger.success("SSH credential updated", {
|
authLogger.success("SSH credential updated", {
|
||||||
@@ -747,6 +783,7 @@ router.put(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/:id",
|
"/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("credentials.delete"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -878,6 +915,7 @@ router.delete(
|
|||||||
router.post(
|
router.post(
|
||||||
"/:id/apply-to-host/:hostId",
|
"/:id/apply-to-host/:hostId",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("credentials.edit"),
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const credentialId = Array.isArray(req.params.id)
|
const credentialId = Array.isArray(req.params.id)
|
||||||
@@ -958,6 +996,7 @@ router.post(
|
|||||||
router.get(
|
router.get(
|
||||||
"/:id/hosts",
|
"/:id/hosts",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("credentials.view"),
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const credentialId = Array.isArray(req.params.id)
|
const credentialId = Array.isArray(req.params.id)
|
||||||
@@ -986,6 +1025,78 @@ router.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** The owner id if the caller may edit this credential (owner or "manage"), else null. */
|
||||||
|
async function resolveEditableCredentialOwner(
|
||||||
|
credentialId: number,
|
||||||
|
userId: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const row = await createCurrentCredentialRepository().findById(credentialId);
|
||||||
|
if (!row) return null;
|
||||||
|
if (row.userId === userId) return userId;
|
||||||
|
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
|
||||||
|
const grant = await createCurrentCredentialAccessRepository().findActiveGrant(
|
||||||
|
credentialId,
|
||||||
|
userId,
|
||||||
|
roleIds,
|
||||||
|
);
|
||||||
|
return grant?.permissionLevel === "manage" ? row.userId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findSharedCredentialForUser(
|
||||||
|
credentialId: number,
|
||||||
|
userId: string,
|
||||||
|
): Promise<Record<string, unknown> | null> {
|
||||||
|
const shared = (await listSharedCredentialsForUser(userId)).find(
|
||||||
|
(cred) => cred.id === credentialId,
|
||||||
|
);
|
||||||
|
return shared ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared credentials shaped like the caller's own, plus who shared them. */
|
||||||
|
async function listSharedCredentialsForUser(
|
||||||
|
userId: string,
|
||||||
|
): Promise<Record<string, unknown>[]> {
|
||||||
|
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
|
||||||
|
const grants =
|
||||||
|
await createCurrentCredentialAccessRepository().listSharedWithUser(
|
||||||
|
userId,
|
||||||
|
roleIds,
|
||||||
|
);
|
||||||
|
if (grants.length === 0) return [];
|
||||||
|
const credentialRepository = createCurrentCredentialRepository();
|
||||||
|
const userRepository = createCurrentUserRepository();
|
||||||
|
const { findUsableCredential } =
|
||||||
|
await import("../../hosts/usable-credential.js");
|
||||||
|
const results: Record<string, unknown>[] = [];
|
||||||
|
for (const grant of grants) {
|
||||||
|
const row = await credentialRepository.findById(grant.credentialId);
|
||||||
|
if (!row) continue;
|
||||||
|
let secrets: Record<string, unknown> | null = null;
|
||||||
|
try {
|
||||||
|
secrets = (await findUsableCredential(
|
||||||
|
grant.credentialId,
|
||||||
|
userId,
|
||||||
|
)) as Record<string, unknown> | null;
|
||||||
|
} catch {
|
||||||
|
secrets = null;
|
||||||
|
}
|
||||||
|
const owner = await userRepository.findById(grant.ownerId);
|
||||||
|
results.push({
|
||||||
|
...formatCredentialOutput({
|
||||||
|
...row,
|
||||||
|
username: secrets?.username ?? row.username,
|
||||||
|
publicKey: secrets?.publicKey ?? null,
|
||||||
|
certPublicKey: secrets?.certPublicKey ?? null,
|
||||||
|
}),
|
||||||
|
isShared: true,
|
||||||
|
ownerUsername: owner?.username ?? null,
|
||||||
|
permissionLevel: grant.permissionLevel,
|
||||||
|
sharedExpiresAt: grant.expiresAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
function formatCredentialOutput(
|
function formatCredentialOutput(
|
||||||
credential: Record<string, unknown>,
|
credential: Record<string, unknown>,
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
@@ -1076,6 +1187,7 @@ function formatSSHHostOutput(
|
|||||||
router.put(
|
router.put(
|
||||||
"/folders/rename",
|
"/folders/rename",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("credentials.edit"),
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { oldName, newName } = req.body;
|
const { oldName, newName } = req.body;
|
||||||
@@ -1107,8 +1219,19 @@ router.put(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
registerCredentialKeyRoutes(router, authenticateJWT);
|
registerCredentialKeyRoutes(
|
||||||
|
router,
|
||||||
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("credentials.view"),
|
||||||
|
requireDataAccess,
|
||||||
|
);
|
||||||
|
|
||||||
registerCredentialDeployRoutes(router, authenticateJWT);
|
registerCredentialDeployRoutes(
|
||||||
|
router,
|
||||||
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("credentials.view"),
|
||||||
|
permissionManager.requirePermission("hosts.edit"),
|
||||||
|
requireDataAccess,
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -39,14 +39,34 @@ import {
|
|||||||
createCurrentUserRepository,
|
createCurrentUserRepository,
|
||||||
createCurrentTransferRecentRepository,
|
createCurrentTransferRecentRepository,
|
||||||
createCurrentVaultProfileRepository,
|
createCurrentVaultProfileRepository,
|
||||||
|
createCurrentSecretSourceRepository,
|
||||||
|
createCurrentSharedCredentialSecretsRepository,
|
||||||
|
createCurrentCredentialAccessRepository,
|
||||||
createCurrentVaultTokenRepository,
|
createCurrentVaultTokenRepository,
|
||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
|
|
||||||
export async function deleteUserAndRelatedData(userId: string): Promise<void> {
|
export async function deleteUserAndRelatedData(
|
||||||
|
userId: string,
|
||||||
|
options: { successorUserId?: string } = {},
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
// With a successor, hosts and credentials (and the shares on them)
|
||||||
|
// change owner instead of disappearing with the account.
|
||||||
|
if (options.successorUserId) {
|
||||||
|
const { transferOwnership } =
|
||||||
|
await import("../../utils/transfer-ownership.js");
|
||||||
|
await transferOwnership(userId, options.successorUserId);
|
||||||
|
}
|
||||||
|
|
||||||
await createCurrentSharedHostSecretsRepository().deleteByTargetUserId(
|
await createCurrentSharedHostSecretsRepository().deleteByTargetUserId(
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
await createCurrentSharedCredentialSecretsRepository().deleteByTargetUserId(
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
await createCurrentCredentialAccessRepository().deleteForUserReferences(
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
// Retained rather than deleted: these outlive the account by design.
|
// Retained rather than deleted: these outlive the account by design.
|
||||||
// See anonymizeByUserId on each repository.
|
// See anonymizeByUserId on each repository.
|
||||||
@@ -98,6 +118,7 @@ export async function deleteUserAndRelatedData(userId: string): Promise<void> {
|
|||||||
await createCurrentOpksshTokenRepository().deleteByUserId(userId);
|
await createCurrentOpksshTokenRepository().deleteByUserId(userId);
|
||||||
await createCurrentVaultTokenRepository().deleteByUserId(userId);
|
await createCurrentVaultTokenRepository().deleteByUserId(userId);
|
||||||
await createCurrentVaultProfileRepository().deleteByUserId(userId);
|
await createCurrentVaultProfileRepository().deleteByUserId(userId);
|
||||||
|
await createCurrentSecretSourceRepository().deleteByUserId(userId);
|
||||||
await createCurrentTermixIdentityCaRepository().deleteByUserId(userId);
|
await createCurrentTermixIdentityCaRepository().deleteByUserId(userId);
|
||||||
await createCurrentTermixIdentityRepository().deleteByUserId(userId);
|
await createCurrentTermixIdentityRepository().deleteByUserId(userId);
|
||||||
await createCurrentTmuxSessionTagRepository().deleteByUserId(userId);
|
await createCurrentTmuxSessionTagRepository().deleteByUserId(userId);
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ export function extractBearerOrCookieToken(req: Request): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isNativeTokenExportRequest(req: Request): boolean {
|
||||||
|
const userAgent = req.get("user-agent") || "";
|
||||||
|
return /^(Termix-Mobile|Termix-Desktop)\//.test(userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decides who the desktop auto-session endpoint should silently log in as.
|
* Decides who the desktop auto-session endpoint should silently log in as.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const faviconCache = new Map<
|
|||||||
>();
|
>();
|
||||||
const CACHE_SIZE = 100;
|
const CACHE_SIZE = 100;
|
||||||
const CACHE_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours
|
const CACHE_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours
|
||||||
|
const MAX_FAVICON_BYTES = 512 * 1024;
|
||||||
|
|
||||||
function evictIfNeeded() {
|
function evictIfNeeded() {
|
||||||
if (faviconCache.size >= CACHE_SIZE) {
|
if (faviconCache.size >= CACHE_SIZE) {
|
||||||
@@ -25,14 +26,31 @@ function fetchUrl(url: string): Promise<{ data: Buffer; contentType: string }> {
|
|||||||
const mod = url.startsWith("https") ? https : http;
|
const mod = url.startsWith("https") ? https : http;
|
||||||
const req = mod.get(url, { timeout: 5000 }, (res) => {
|
const req = mod.get(url, { timeout: 5000 }, (res) => {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
let bytes = 0;
|
||||||
|
let settled = false;
|
||||||
|
const fail = (error: Error) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
res.destroy();
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
res.on("data", (chunk: Buffer) => {
|
||||||
|
bytes += chunk.length;
|
||||||
|
if (bytes > MAX_FAVICON_BYTES) {
|
||||||
|
fail(new Error("Favicon response too large"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
res.on("end", () => {
|
res.on("end", () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
resolve({
|
resolve({
|
||||||
data: Buffer.concat(chunks),
|
data: Buffer.concat(chunks),
|
||||||
contentType: res.headers["content-type"] || "image/x-icon",
|
contentType: res.headers["content-type"] || "image/x-icon",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
res.on("error", reject);
|
res.on("error", fail);
|
||||||
});
|
});
|
||||||
req.on("error", reject);
|
req.on("error", reject);
|
||||||
req.on("timeout", () => {
|
req.on("timeout", () => {
|
||||||
@@ -95,7 +113,7 @@ homepageFaviconRouter.get("/", async (req: Request, res: Response) => {
|
|||||||
res.setHeader("Content-Type", contentType);
|
res.setHeader("Content-Type", contentType);
|
||||||
res.setHeader("Cache-Control", "public, max-age=86400");
|
res.setHeader("Cache-Control", "public, max-age=86400");
|
||||||
res.send(data);
|
res.send(data);
|
||||||
} catch (err) {
|
} catch {
|
||||||
homepageLogger.warn("Failed to fetch favicon", { domain });
|
homepageLogger.warn("Failed to fetch favicon", { domain });
|
||||||
res.status(500).json({ error: "Failed to fetch favicon" });
|
res.status(500).json({ error: "Failed to fetch favicon" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { getErrorMessage } from "../../utils/error-message.js";
|
import { getErrorMessage } from "../../utils/error-message.js";
|
||||||
import express, { type Request, type Response } from "express";
|
import express, { type Request, type Response } from "express";
|
||||||
import https from "https";
|
|
||||||
import http from "http";
|
|
||||||
import { lookup } from "dns/promises";
|
|
||||||
import { isIP } from "net";
|
|
||||||
import { homepageLogger } from "../../utils/logger.js";
|
import { homepageLogger } from "../../utils/logger.js";
|
||||||
import { isBlockedAddress } from "../../utils/safe-outbound-fetch.js";
|
import {
|
||||||
|
readResponseTextLimited,
|
||||||
|
safeOutboundFetch,
|
||||||
|
} from "../../utils/safe-outbound-fetch.js";
|
||||||
|
|
||||||
export const homepageProxyRouter = express.Router();
|
export const homepageProxyRouter = express.Router();
|
||||||
|
|
||||||
@@ -17,68 +16,20 @@ interface ProxyCacheEntry {
|
|||||||
const proxyCache = new Map<string, ProxyCacheEntry>();
|
const proxyCache = new Map<string, ProxyCacheEntry>();
|
||||||
const CACHE_SIZE = 50;
|
const CACHE_SIZE = 50;
|
||||||
const FETCH_TIMEOUT_MS = 8000;
|
const FETCH_TIMEOUT_MS = 8000;
|
||||||
|
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
|
||||||
async function resolvePublicUrl(rawUrl: string): Promise<{
|
const MAX_CACHE_TTL_SECONDS = 24 * 60 * 60;
|
||||||
url: URL;
|
|
||||||
address: string;
|
|
||||||
}> {
|
|
||||||
const url = new URL(rawUrl);
|
|
||||||
if (
|
|
||||||
!["http:", "https:"].includes(url.protocol) ||
|
|
||||||
url.username ||
|
|
||||||
url.password
|
|
||||||
) {
|
|
||||||
throw new Error("Invalid URL");
|
|
||||||
}
|
|
||||||
|
|
||||||
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
|
||||||
const addresses = isIP(hostname)
|
|
||||||
? [{ address: hostname }]
|
|
||||||
: await lookup(hostname, { all: true, verbatim: true });
|
|
||||||
if (
|
|
||||||
addresses.length === 0 ||
|
|
||||||
addresses.some(({ address }) => isBlockedAddress(address))
|
|
||||||
) {
|
|
||||||
throw new Error("Private destinations are not allowed");
|
|
||||||
}
|
|
||||||
|
|
||||||
return { url, address: addresses[0].address };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchJson(rawUrl: string): Promise<unknown> {
|
async function fetchJson(rawUrl: string): Promise<unknown> {
|
||||||
const { url, address } = await resolvePublicUrl(rawUrl);
|
const response = await safeOutboundFetch(rawUrl, {
|
||||||
return new Promise((resolve, reject) => {
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||||
const mod = url.protocol === "https:" ? https : http;
|
});
|
||||||
const req = mod.get(
|
if (!response.ok) throw new Error(`Proxy fetch failed: ${response.status}`);
|
||||||
{
|
const text = await readResponseTextLimited(response, MAX_RESPONSE_BYTES);
|
||||||
protocol: url.protocol,
|
|
||||||
hostname: address,
|
|
||||||
port: url.port || undefined,
|
|
||||||
path: `${url.pathname}${url.search}`,
|
|
||||||
headers: { Host: url.host },
|
|
||||||
servername: url.protocol === "https:" ? url.hostname : undefined,
|
|
||||||
timeout: FETCH_TIMEOUT_MS,
|
|
||||||
},
|
|
||||||
(res) => {
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
||||||
res.on("end", () => {
|
|
||||||
try {
|
try {
|
||||||
const text = Buffer.concat(chunks).toString("utf-8");
|
return JSON.parse(text);
|
||||||
resolve(JSON.parse(text));
|
|
||||||
} catch {
|
} catch {
|
||||||
reject(new Error("Response is not valid JSON"));
|
throw new Error("Response is not valid JSON");
|
||||||
}
|
}
|
||||||
});
|
|
||||||
res.on("error", reject);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
req.on("error", reject);
|
|
||||||
req.on("timeout", () => {
|
|
||||||
req.destroy();
|
|
||||||
reject(new Error("Fetch timeout"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -109,7 +60,9 @@ async function fetchJson(rawUrl: string): Promise<unknown> {
|
|||||||
*/
|
*/
|
||||||
homepageProxyRouter.get("/", async (req: Request, res: Response) => {
|
homepageProxyRouter.get("/", async (req: Request, res: Response) => {
|
||||||
const targetUrl = req.query.url as string;
|
const targetUrl = req.query.url as string;
|
||||||
const ttl = Math.max(10, Number(req.query.ttl) || 60) * 1000;
|
const ttl =
|
||||||
|
Math.min(MAX_CACHE_TTL_SECONDS, Math.max(10, Number(req.query.ttl) || 60)) *
|
||||||
|
1000;
|
||||||
|
|
||||||
if (!targetUrl) return res.status(400).json({ error: "url is required" });
|
if (!targetUrl) return res.status(400).json({ error: "url is required" });
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import express, { type Request, type Response } from "express";
|
import express, { type Request, type Response } from "express";
|
||||||
import { homepageLogger } from "../../utils/logger.js";
|
import { homepageLogger } from "../../utils/logger.js";
|
||||||
import { safeOutboundFetch } from "../../utils/safe-outbound-fetch.js";
|
import {
|
||||||
|
readResponseTextLimited,
|
||||||
|
safeOutboundFetch,
|
||||||
|
} from "../../utils/safe-outbound-fetch.js";
|
||||||
|
|
||||||
export const homepageRssRouter = express.Router();
|
export const homepageRssRouter = express.Router();
|
||||||
|
|
||||||
@@ -8,6 +11,7 @@ const rssCache = new Map<string, { data: RssItem[]; expires: number }>();
|
|||||||
const CACHE_TTL_MS = 1000 * 60 * 15; // 15 minutes
|
const CACHE_TTL_MS = 1000 * 60 * 15; // 15 minutes
|
||||||
const CACHE_SIZE = 50;
|
const CACHE_SIZE = 50;
|
||||||
const FETCH_TIMEOUT_MS = 8000;
|
const FETCH_TIMEOUT_MS = 8000;
|
||||||
|
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
interface RssItem {
|
interface RssItem {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -21,7 +25,7 @@ function fetchXml(url: string): Promise<string> {
|
|||||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||||
}).then(async (res) => {
|
}).then(async (res) => {
|
||||||
if (!res.ok) throw new Error(`RSS fetch failed: ${res.status}`);
|
if (!res.ok) throw new Error(`RSS fetch failed: ${res.status}`);
|
||||||
return res.text();
|
return readResponseTextLimited(res, MAX_RESPONSE_BYTES);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +135,7 @@ homepageRssRouter.get("/", async (req: Request, res: Response) => {
|
|||||||
rssCache.set(feedUrl, { data: items, expires: Date.now() + CACHE_TTL_MS });
|
rssCache.set(feedUrl, { data: items, expires: Date.now() + CACHE_TTL_MS });
|
||||||
|
|
||||||
res.json(items.slice(0, max));
|
res.json(items.slice(0, max));
|
||||||
} catch (err) {
|
} catch {
|
||||||
homepageLogger.warn("Failed to fetch RSS feed", { feedUrl });
|
homepageLogger.warn("Failed to fetch RSS feed", { feedUrl });
|
||||||
res.status(500).json({ error: "Failed to fetch feed" });
|
res.status(500).json({ error: "Failed to fetch feed" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,19 @@ import { createCurrentHostRepository } from "../repositories/factory.js";
|
|||||||
|
|
||||||
type HostAutostartRoutesDeps = {
|
type HostAutostartRoutesDeps = {
|
||||||
authenticateJWT: RequestHandler;
|
authenticateJWT: RequestHandler;
|
||||||
|
requireViewPermission: RequestHandler;
|
||||||
|
requireEditPermission: RequestHandler;
|
||||||
requireDataAccess: RequestHandler;
|
requireDataAccess: RequestHandler;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function registerHostAutostartRoutes(
|
export function registerHostAutostartRoutes(
|
||||||
router: Router,
|
router: Router,
|
||||||
{ authenticateJWT, requireDataAccess }: HostAutostartRoutesDeps,
|
{
|
||||||
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireEditPermission,
|
||||||
|
requireDataAccess,
|
||||||
|
}: HostAutostartRoutesDeps,
|
||||||
): void {
|
): void {
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
@@ -44,6 +51,7 @@ export function registerHostAutostartRoutes(
|
|||||||
router.post(
|
router.post(
|
||||||
"/autostart/enable",
|
"/autostart/enable",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireEditPermission,
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -204,6 +212,8 @@ export function registerHostAutostartRoutes(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/autostart/disable",
|
"/autostart/disable",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireEditPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { sshConfigId } = req.body;
|
const { sshConfigId } = req.body;
|
||||||
@@ -259,6 +269,8 @@ export function registerHostAutostartRoutes(
|
|||||||
router.get(
|
router.get(
|
||||||
"/autostart/status",
|
"/autostart/status",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
|
||||||
|
|||||||
@@ -105,9 +105,22 @@ export function parseSSHConfig(content: string): SSHConfigHost[] {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function importedHostUsername(
|
||||||
|
connectionType: string,
|
||||||
|
authType: unknown,
|
||||||
|
username: unknown,
|
||||||
|
): string | null {
|
||||||
|
if (isNonEmptyString(username)) return username;
|
||||||
|
if (connectionType !== "ssh" || authType === "credential") return "";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function registerHostBulkRoutes(
|
export function registerHostBulkRoutes(
|
||||||
router: Router,
|
router: Router,
|
||||||
authenticateJWT: RequestHandler,
|
authenticateJWT: RequestHandler,
|
||||||
|
requireCreatePermission: RequestHandler,
|
||||||
|
requireEditPermission: RequestHandler,
|
||||||
|
requireDataAccess: RequestHandler,
|
||||||
): void {
|
): void {
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
@@ -172,6 +185,8 @@ export function registerHostBulkRoutes(
|
|||||||
router.patch(
|
router.patch(
|
||||||
"/bulk-update",
|
"/bulk-update",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireEditPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { hostIds, updates } = req.body;
|
const { hostIds, updates } = req.body;
|
||||||
@@ -376,6 +391,8 @@ export function registerHostBulkRoutes(
|
|||||||
router.put(
|
router.put(
|
||||||
"/reorder",
|
"/reorder",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireEditPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { positions } = req.body as {
|
const { positions } = req.body as {
|
||||||
@@ -422,6 +439,9 @@ export function registerHostBulkRoutes(
|
|||||||
router.post(
|
router.post(
|
||||||
"/bulk-import",
|
"/bulk-import",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireCreatePermission,
|
||||||
|
requireEditPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const {
|
const {
|
||||||
@@ -558,10 +578,12 @@ export function registerHostBulkRoutes(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const username = importedHostUsername(
|
||||||
effectiveConnectionType === "ssh" &&
|
effectiveConnectionType,
|
||||||
!isNonEmptyString(hostData.username)
|
hostData.authType,
|
||||||
) {
|
hostData.username,
|
||||||
|
);
|
||||||
|
if (username === null) {
|
||||||
results.failed++;
|
results.failed++;
|
||||||
results.errors.push(
|
results.errors.push(
|
||||||
`Host ${i + 1}: Username required for SSH connections`,
|
`Host ${i + 1}: Username required for SSH connections`,
|
||||||
@@ -578,13 +600,14 @@ export function registerHostBulkRoutes(
|
|||||||
"credential",
|
"credential",
|
||||||
"none",
|
"none",
|
||||||
"opkssh",
|
"opkssh",
|
||||||
|
"stepca",
|
||||||
"tailscale",
|
"tailscale",
|
||||||
"vault",
|
"vault",
|
||||||
].includes(hostData.authType)
|
].includes(hostData.authType)
|
||||||
) {
|
) {
|
||||||
results.failed++;
|
results.failed++;
|
||||||
results.errors.push(
|
results.errors.push(
|
||||||
`Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', 'none', 'opkssh', 'tailscale', or 'vault'`,
|
`Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', 'none', 'opkssh', 'stepca', 'tailscale', or 'vault'`,
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -660,12 +683,12 @@ export function registerHostBulkRoutes(
|
|||||||
const sshDataObj: Record<string, unknown> = {
|
const sshDataObj: Record<string, unknown> = {
|
||||||
userId: userId,
|
userId: userId,
|
||||||
connectionType: effectiveConnectionType,
|
connectionType: effectiveConnectionType,
|
||||||
name: hostData.name || `${hostData.username || ""}@${hostData.ip}`,
|
name: hostData.name || `${username}@${hostData.ip}`,
|
||||||
folder: hostData.folder || "Default",
|
folder: hostData.folder || "Default",
|
||||||
tags: Array.isArray(hostData.tags) ? hostData.tags.join(",") : "",
|
tags: Array.isArray(hostData.tags) ? hostData.tags.join(",") : "",
|
||||||
ip: hostData.ip,
|
ip: hostData.ip,
|
||||||
port: hostData.port,
|
port: hostData.port,
|
||||||
username: hostData.username || null,
|
username,
|
||||||
pin: hostData.pin || false,
|
pin: hostData.pin || false,
|
||||||
enableTerminal: hostData.enableTerminal !== false,
|
enableTerminal: hostData.enableTerminal !== false,
|
||||||
enableTunnel: hostData.enableTunnel !== false,
|
enableTunnel: hostData.enableTunnel !== false,
|
||||||
@@ -832,6 +855,9 @@ export function registerHostBulkRoutes(
|
|||||||
router.post(
|
router.post(
|
||||||
"/ssh-config-import",
|
"/ssh-config-import",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireCreatePermission,
|
||||||
|
requireEditPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { content, overwrite } = req.body;
|
const { content, overwrite } = req.body;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { isNonEmptyString } from "./host-normalizers.js";
|
|||||||
export function registerHostCommandHistoryRoutes(
|
export function registerHostCommandHistoryRoutes(
|
||||||
router: Router,
|
router: Router,
|
||||||
authenticateJWT: RequestHandler,
|
authenticateJWT: RequestHandler,
|
||||||
|
requireViewPermission: RequestHandler,
|
||||||
|
requireDataAccess: RequestHandler,
|
||||||
): void {
|
): void {
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
@@ -33,6 +35,8 @@ export function registerHostCommandHistoryRoutes(
|
|||||||
router.get(
|
router.get(
|
||||||
"/command-history/:hostId",
|
"/command-history/:hostId",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const hostIdParam = Array.isArray(req.params.hostId)
|
const hostIdParam = Array.isArray(req.params.hostId)
|
||||||
@@ -98,6 +102,8 @@ export function registerHostCommandHistoryRoutes(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/command-history",
|
"/command-history",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { hostId, command } = req.body;
|
const { hostId, command } = req.body;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { isNonEmptyString } from "./host-normalizers.js";
|
|||||||
export function registerHostFileManagerBookmarkRoutes(
|
export function registerHostFileManagerBookmarkRoutes(
|
||||||
router: Router,
|
router: Router,
|
||||||
authenticateJWT: RequestHandler,
|
authenticateJWT: RequestHandler,
|
||||||
|
requireViewPermission: RequestHandler,
|
||||||
|
requireDataAccess: RequestHandler,
|
||||||
): void {
|
): void {
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
@@ -33,6 +35,8 @@ export function registerHostFileManagerBookmarkRoutes(
|
|||||||
router.get(
|
router.get(
|
||||||
"/file_manager/recent",
|
"/file_manager/recent",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const hostIdQuery = Array.isArray(req.query.hostId)
|
const hostIdQuery = Array.isArray(req.query.hostId)
|
||||||
@@ -98,6 +102,8 @@ export function registerHostFileManagerBookmarkRoutes(
|
|||||||
router.post(
|
router.post(
|
||||||
"/file_manager/recent",
|
"/file_manager/recent",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { hostId, path, name } = req.body;
|
const { hostId, path, name } = req.body;
|
||||||
@@ -155,6 +161,8 @@ export function registerHostFileManagerBookmarkRoutes(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/file_manager/recent",
|
"/file_manager/recent",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { hostId, path } = req.body;
|
const { hostId, path } = req.body;
|
||||||
@@ -203,6 +211,8 @@ export function registerHostFileManagerBookmarkRoutes(
|
|||||||
router.get(
|
router.get(
|
||||||
"/file_manager/pinned",
|
"/file_manager/pinned",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const hostIdQuery = Array.isArray(req.query.hostId)
|
const hostIdQuery = Array.isArray(req.query.hostId)
|
||||||
@@ -269,6 +279,8 @@ export function registerHostFileManagerBookmarkRoutes(
|
|||||||
router.post(
|
router.post(
|
||||||
"/file_manager/pinned",
|
"/file_manager/pinned",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { hostId, path, name } = req.body;
|
const { hostId, path, name } = req.body;
|
||||||
@@ -327,6 +339,8 @@ export function registerHostFileManagerBookmarkRoutes(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/file_manager/pinned",
|
"/file_manager/pinned",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { hostId, path } = req.body;
|
const { hostId, path } = req.body;
|
||||||
@@ -375,6 +389,8 @@ export function registerHostFileManagerBookmarkRoutes(
|
|||||||
router.get(
|
router.get(
|
||||||
"/file_manager/shortcuts",
|
"/file_manager/shortcuts",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const hostIdQuery = Array.isArray(req.query.hostId)
|
const hostIdQuery = Array.isArray(req.query.hostId)
|
||||||
@@ -441,6 +457,8 @@ export function registerHostFileManagerBookmarkRoutes(
|
|||||||
router.post(
|
router.post(
|
||||||
"/file_manager/shortcuts",
|
"/file_manager/shortcuts",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { hostId, path, name } = req.body;
|
const { hostId, path, name } = req.body;
|
||||||
@@ -499,6 +517,8 @@ export function registerHostFileManagerBookmarkRoutes(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/file_manager/shortcuts",
|
"/file_manager/shortcuts",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { hostId, path } = req.body;
|
const { hostId, path } = req.body;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
createCurrentCredentialRepository,
|
createCurrentCredentialRepository,
|
||||||
createCurrentFileManagerBookmarkRepository,
|
createCurrentFileManagerBookmarkRepository,
|
||||||
createCurrentHostFolderRepository,
|
createCurrentHostFolderRepository,
|
||||||
|
createCurrentFolderAccessRepository,
|
||||||
createCurrentRecentActivityRepository,
|
createCurrentRecentActivityRepository,
|
||||||
createCurrentRbacAccessRepository,
|
createCurrentRbacAccessRepository,
|
||||||
createCurrentSshCredentialUsageRepository,
|
createCurrentSshCredentialUsageRepository,
|
||||||
@@ -17,12 +18,25 @@ import { isNonEmptyString } from "./host-normalizers.js";
|
|||||||
|
|
||||||
type HostFolderRoutesDeps = {
|
type HostFolderRoutesDeps = {
|
||||||
authenticateJWT: RequestHandler;
|
authenticateJWT: RequestHandler;
|
||||||
|
requireViewPermission: RequestHandler;
|
||||||
|
requireEditPermission: RequestHandler;
|
||||||
|
requireDeletePermission: RequestHandler;
|
||||||
|
requireCredentialEditPermission: RequestHandler;
|
||||||
|
requireDataAccess: RequestHandler;
|
||||||
statsServerUrl: string;
|
statsServerUrl: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function registerHostFolderRoutes(
|
export function registerHostFolderRoutes(
|
||||||
router: Router,
|
router: Router,
|
||||||
{ authenticateJWT, statsServerUrl }: HostFolderRoutesDeps,
|
{
|
||||||
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireEditPermission,
|
||||||
|
requireDeletePermission,
|
||||||
|
requireCredentialEditPermission,
|
||||||
|
requireDataAccess,
|
||||||
|
statsServerUrl,
|
||||||
|
}: HostFolderRoutesDeps,
|
||||||
): void {
|
): void {
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
@@ -54,6 +68,9 @@ export function registerHostFolderRoutes(
|
|||||||
router.put(
|
router.put(
|
||||||
"/folders/rename",
|
"/folders/rename",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireEditPermission,
|
||||||
|
requireCredentialEditPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { oldName, newName } = req.body;
|
const { oldName, newName } = req.body;
|
||||||
@@ -76,6 +93,11 @@ export function registerHostFolderRoutes(
|
|||||||
oldName,
|
oldName,
|
||||||
newName,
|
newName,
|
||||||
);
|
);
|
||||||
|
await createCurrentFolderAccessRepository().renameFolder(
|
||||||
|
userId,
|
||||||
|
oldName,
|
||||||
|
newName,
|
||||||
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: "Folder renamed successfully",
|
message: "Folder renamed successfully",
|
||||||
@@ -113,6 +135,8 @@ export function registerHostFolderRoutes(
|
|||||||
router.get(
|
router.get(
|
||||||
"/folders",
|
"/folders",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
|
||||||
@@ -170,6 +194,8 @@ export function registerHostFolderRoutes(
|
|||||||
router.put(
|
router.put(
|
||||||
"/folders/metadata",
|
"/folders/metadata",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireEditPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { name, color, icon, credentialId } = req.body;
|
const { name, color, icon, credentialId } = req.body;
|
||||||
@@ -275,6 +301,8 @@ export function registerHostFolderRoutes(
|
|||||||
router.put(
|
router.put(
|
||||||
"/folders/reorder",
|
"/folders/reorder",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireEditPermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { positions } = req.body as {
|
const { positions } = req.body as {
|
||||||
@@ -346,6 +374,8 @@ export function registerHostFolderRoutes(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/folders/:name/hosts",
|
"/folders/:name/hosts",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireDeletePermission,
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const folderName = Array.isArray(req.params.name)
|
const folderName = Array.isArray(req.params.name)
|
||||||
|
|||||||
@@ -7,12 +7,17 @@ import { createCurrentHostResolutionRepository } from "../repositories/factory.j
|
|||||||
|
|
||||||
interface HostNetworkRoutesDeps {
|
interface HostNetworkRoutesDeps {
|
||||||
authenticateJWT: RequestHandler;
|
authenticateJWT: RequestHandler;
|
||||||
|
requireViewPermission: RequestHandler;
|
||||||
requireDataAccess: RequestHandler;
|
requireDataAccess: RequestHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerHostNetworkRoutes(
|
export function registerHostNetworkRoutes(
|
||||||
router: Router,
|
router: Router,
|
||||||
{ authenticateJWT, requireDataAccess }: HostNetworkRoutesDeps,
|
{
|
||||||
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
|
requireDataAccess,
|
||||||
|
}: HostNetworkRoutesDeps,
|
||||||
): void {
|
): void {
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
@@ -62,6 +67,7 @@ export function registerHostNetworkRoutes(
|
|||||||
router.post(
|
router.post(
|
||||||
"/db/proxy/test",
|
"/db/proxy/test",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -93,6 +99,7 @@ export function registerHostNetworkRoutes(
|
|||||||
router.post(
|
router.post(
|
||||||
"/db/host/:id/wake",
|
"/db/host/:id/wake",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission,
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const hostId = Number.parseInt(String(req.params.id), 10);
|
const hostId = Number.parseInt(String(req.params.id), 10);
|
||||||
|
|||||||
@@ -14,6 +14,25 @@ export function isOptionalBoolean(
|
|||||||
return value === undefined || typeof value === "boolean";
|
return value === undefined || typeof value === "boolean";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PROTOCOL_ENABLE_FIELDS = [
|
||||||
|
"enableSsh",
|
||||||
|
"enableRdp",
|
||||||
|
"enableVnc",
|
||||||
|
"enableTelnet",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function normalizeProtocolEnableFields(
|
||||||
|
values: Record<string, unknown>,
|
||||||
|
): Partial<Record<(typeof PROTOCOL_ENABLE_FIELDS)[number], 0 | 1>> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
PROTOCOL_ENABLE_FIELDS.flatMap((field) =>
|
||||||
|
typeof values[field] === "boolean"
|
||||||
|
? [[field, values[field] ? 1 : 0]]
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const OWNER_PRIVATE_AUTH_FIELDS = {
|
export const OWNER_PRIVATE_AUTH_FIELDS = {
|
||||||
ssh: [
|
ssh: [
|
||||||
"authType",
|
"authType",
|
||||||
@@ -346,6 +365,7 @@ const CONNECT_LEVEL_FIELDS = new Set([
|
|||||||
"enableTelnet",
|
"enableTelnet",
|
||||||
"sshPort",
|
"sshPort",
|
||||||
"rdpPort",
|
"rdpPort",
|
||||||
|
"rdpAuthType",
|
||||||
"vncPort",
|
"vncPort",
|
||||||
"telnetPort",
|
"telnetPort",
|
||||||
"defaultPath",
|
"defaultPath",
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Request, Response, Router } from "express";
|
||||||
|
import { escapeHtml } from "./opkssh-html.js";
|
||||||
|
|
||||||
|
function resultPage(ok: boolean, message: string): string {
|
||||||
|
return `<!doctype html><html><head><meta charset="utf-8"><title>Termix</title>
|
||||||
|
<style>body{font-family:system-ui,sans-serif;background:#111;color:#eee;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
||||||
|
main{max-width:28rem;padding:2rem;border:1px solid #333;background:#181818}h1{font-size:1.1rem;margin:0 0 .5rem}p{margin:0;color:#aaa}</style></head>
|
||||||
|
<body><main><h1>${ok ? "Signed in" : "Sign-in failed"}</h1><p>${escapeHtml(message)}</p></main></body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The OIDC redirect target for Step CA sign-ins. Unauthenticated on purpose:
|
||||||
|
* the browser that finishes the sign-in may not be the one running Termix,
|
||||||
|
* so the request is matched to its session by the OAuth state.
|
||||||
|
*/
|
||||||
|
export function registerHostStepCaRoutes(router: Router): void {
|
||||||
|
router.get("/step-ca-callback", async (req: Request, res: Response) => {
|
||||||
|
const { completeStepCaAuth } = await import("../../hosts/step-ca-auth.js");
|
||||||
|
const stringQuery = (name: string): string | undefined => {
|
||||||
|
const value = req.query[name];
|
||||||
|
return typeof value === "string" ? value : undefined;
|
||||||
|
};
|
||||||
|
const result = await completeStepCaAuth({
|
||||||
|
state: stringQuery("state"),
|
||||||
|
code: stringQuery("code"),
|
||||||
|
error: stringQuery("error"),
|
||||||
|
error_description: stringQuery("error_description"),
|
||||||
|
});
|
||||||
|
res
|
||||||
|
.status(result.ok ? 200 : 400)
|
||||||
|
.type("html")
|
||||||
|
.send(resultPage(result.ok, result.message));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { getErrorMessage } from "../../utils/error-message.js";
|
import { getErrorMessage } from "../../utils/error-message.js";
|
||||||
|
import { applyFolderAccessRules } from "../../utils/folder-access-inheritance.js";
|
||||||
|
import { findUsableCredential } from "../../hosts/usable-credential.js";
|
||||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
import express, { type Request, type Response } from "express";
|
import express, { type Request, type Response } from "express";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -28,12 +30,14 @@ import {
|
|||||||
createCurrentHostRepository,
|
createCurrentHostRepository,
|
||||||
createCurrentUserRepository,
|
createCurrentUserRepository,
|
||||||
createCurrentSyncTombstoneRepository,
|
createCurrentSyncTombstoneRepository,
|
||||||
|
createCurrentSharedHostAuthOverrideRepository,
|
||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
import {
|
import {
|
||||||
containsOwnerPrivateAuthUpdate,
|
containsOwnerPrivateAuthUpdate,
|
||||||
isNonEmptyString,
|
isNonEmptyString,
|
||||||
isOptionalBoolean,
|
isOptionalBoolean,
|
||||||
isValidPort,
|
isValidPort,
|
||||||
|
normalizeProtocolEnableFields,
|
||||||
OWNER_PRIVATE_AUTH_FIELDS,
|
OWNER_PRIVATE_AUTH_FIELDS,
|
||||||
OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS,
|
OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS,
|
||||||
sanitizeHostForRecipient,
|
sanitizeHostForRecipient,
|
||||||
@@ -42,6 +46,7 @@ import {
|
|||||||
} from "./host-normalizers.js";
|
} from "./host-normalizers.js";
|
||||||
import { validateParentHostId } from "./host-parent-validation.js";
|
import { validateParentHostId } from "./host-parent-validation.js";
|
||||||
import { registerHostOpksshRoutes } from "./host-opkssh-routes.js";
|
import { registerHostOpksshRoutes } from "./host-opkssh-routes.js";
|
||||||
|
import { registerHostStepCaRoutes } from "./host-step-ca-routes.js";
|
||||||
import { registerHostFolderRoutes } from "./host-folder-routes.js";
|
import { registerHostFolderRoutes } from "./host-folder-routes.js";
|
||||||
import { registerHostFileManagerBookmarkRoutes } from "./host-file-manager-bookmark-routes.js";
|
import { registerHostFileManagerBookmarkRoutes } from "./host-file-manager-bookmark-routes.js";
|
||||||
import { registerHostCommandHistoryRoutes } from "./host-command-history-routes.js";
|
import { registerHostCommandHistoryRoutes } from "./host-command-history-routes.js";
|
||||||
@@ -62,6 +67,7 @@ import type {
|
|||||||
HostResolutionCredentialRecord,
|
HostResolutionCredentialRecord,
|
||||||
HostResolutionHostRecord,
|
HostResolutionHostRecord,
|
||||||
} from "../repositories/host-resolution-repository.js";
|
} from "../repositories/host-resolution-repository.js";
|
||||||
|
import { AUTH_PROTOCOL_METADATA } from "../../../types/auth-protocols.js";
|
||||||
import {
|
import {
|
||||||
requiresPersonalHostAuthentication,
|
requiresPersonalHostAuthentication,
|
||||||
resolveRecipientSharedHostAuthentication,
|
resolveRecipientSharedHostAuthentication,
|
||||||
@@ -125,6 +131,7 @@ registerHostInternalRoutes(router);
|
|||||||
router.post(
|
router.post(
|
||||||
["/db/host", "/enroll"],
|
["/db/host", "/enroll"],
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("hosts.create"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
requireHostEnrollmentAccessForPath,
|
requireHostEnrollmentAccessForPath,
|
||||||
upload.single("key"),
|
upload.single("key"),
|
||||||
@@ -261,7 +268,8 @@ router.post(
|
|||||||
!isNonEmptyString(userId) ||
|
!isNonEmptyString(userId) ||
|
||||||
!isNonEmptyString(ip) ||
|
!isNonEmptyString(ip) ||
|
||||||
!isValidPort(port) ||
|
!isValidPort(port) ||
|
||||||
!isOptionalBoolean(shareSshAuth)
|
!isOptionalBoolean(shareSshAuth) ||
|
||||||
|
![enableSsh, enableRdp, enableVnc, enableTelnet].every(isOptionalBoolean)
|
||||||
) {
|
) {
|
||||||
sshLogger.warn("Invalid SSH data input validation failed", {
|
sshLogger.warn("Invalid SSH data input validation failed", {
|
||||||
operation: "host_create",
|
operation: "host_create",
|
||||||
@@ -392,10 +400,7 @@ router.post(
|
|||||||
portKnockSequence: portKnockSequence
|
portKnockSequence: portKnockSequence
|
||||||
? JSON.stringify(portKnockSequence)
|
? JSON.stringify(portKnockSequence)
|
||||||
: null,
|
: null,
|
||||||
enableSsh: enableSsh ? 1 : 0,
|
...normalizeProtocolEnableFields(hostData),
|
||||||
enableRdp: enableRdp ? 1 : 0,
|
|
||||||
enableVnc: enableVnc ? 1 : 0,
|
|
||||||
enableTelnet: enableTelnet ? 1 : 0,
|
|
||||||
sshPort: sshPort || port || 22,
|
sshPort: sshPort || port || 22,
|
||||||
rdpPort: rdpPort || 3389,
|
rdpPort: rdpPort || 3389,
|
||||||
vncPort: vncPort || 5900,
|
vncPort: vncPort || 5900,
|
||||||
@@ -498,6 +503,20 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createdHost = result;
|
const createdHost = result;
|
||||||
|
// Standing folder shares apply to the newcomer.
|
||||||
|
try {
|
||||||
|
await applyFolderAccessRules(
|
||||||
|
createdHost.id,
|
||||||
|
userId!,
|
||||||
|
createdHost.folder,
|
||||||
|
);
|
||||||
|
} catch (folderAccessError) {
|
||||||
|
sshLogger.warn("Failed to inherit folder access on host create", {
|
||||||
|
operation: "host_create_folder_access",
|
||||||
|
hostId: createdHost.id,
|
||||||
|
error: getErrorMessage(folderAccessError),
|
||||||
|
});
|
||||||
|
}
|
||||||
const baseHost = transformHostResponse(createdHost);
|
const baseHost = transformHostResponse(createdHost);
|
||||||
|
|
||||||
const resolvedHost =
|
const resolvedHost =
|
||||||
@@ -712,11 +731,7 @@ router.post(
|
|||||||
let resolvedUsername = username;
|
let resolvedUsername = username;
|
||||||
|
|
||||||
if (authType === "credential" && credentialId) {
|
if (authType === "credential" && credentialId) {
|
||||||
const cred =
|
const cred = await findUsableCredential(Number(credentialId), userId);
|
||||||
await createCurrentHostResolutionRepository().findCredentialByIdForUser(
|
|
||||||
Number(credentialId),
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!cred) {
|
if (!cred) {
|
||||||
return res.status(404).json({ error: "Credential not found" });
|
return res.status(404).json({ error: "Credential not found" });
|
||||||
@@ -817,6 +832,7 @@ router.post(
|
|||||||
router.put(
|
router.put(
|
||||||
"/db/host/:id",
|
"/db/host/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("hosts.edit"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
upload.single("key"),
|
upload.single("key"),
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
@@ -954,6 +970,9 @@ router.put(
|
|||||||
!isNonEmptyString(ip) ||
|
!isNonEmptyString(ip) ||
|
||||||
!isValidPort(port) ||
|
!isValidPort(port) ||
|
||||||
!isOptionalBoolean(shareSshAuth) ||
|
!isOptionalBoolean(shareSshAuth) ||
|
||||||
|
![enableSsh, enableRdp, enableVnc, enableTelnet].every(
|
||||||
|
isOptionalBoolean,
|
||||||
|
) ||
|
||||||
!hostId
|
!hostId
|
||||||
) {
|
) {
|
||||||
sshLogger.warn("Invalid SSH data input validation failed for update", {
|
sshLogger.warn("Invalid SSH data input validation failed for update", {
|
||||||
@@ -1085,10 +1104,7 @@ router.put(
|
|||||||
portKnockSequence: portKnockSequence
|
portKnockSequence: portKnockSequence
|
||||||
? JSON.stringify(portKnockSequence)
|
? JSON.stringify(portKnockSequence)
|
||||||
: null,
|
: null,
|
||||||
enableSsh: enableSsh ? 1 : 0,
|
...normalizeProtocolEnableFields(hostData),
|
||||||
enableRdp: enableRdp ? 1 : 0,
|
|
||||||
enableVnc: enableVnc ? 1 : 0,
|
|
||||||
enableTelnet: enableTelnet ? 1 : 0,
|
|
||||||
sshPort: sshPort || port || 22,
|
sshPort: sshPort || port || 22,
|
||||||
rdpPort: rdpPort || 3389,
|
rdpPort: rdpPort || 3389,
|
||||||
vncPort: vncPort || 5900,
|
vncPort: vncPort || 5900,
|
||||||
@@ -1370,6 +1386,21 @@ router.put(
|
|||||||
sshDataObj,
|
sshDataObj,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A host that moved into a folder inherits that folder's standing shares.
|
||||||
|
try {
|
||||||
|
await applyFolderAccessRules(
|
||||||
|
Number(hostId),
|
||||||
|
ownerId,
|
||||||
|
sshDataObj.folder as string | null | undefined,
|
||||||
|
);
|
||||||
|
} catch (folderAccessError) {
|
||||||
|
sshLogger.warn("Failed to inherit folder access on host update", {
|
||||||
|
operation: "host_update_folder_access",
|
||||||
|
hostId: parseInt(hostId),
|
||||||
|
error: getErrorMessage(folderAccessError),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Keep every recipient's re-encrypted secret snapshots in sync with
|
// Keep every recipient's re-encrypted secret snapshots in sync with
|
||||||
// the updated host record.
|
// the updated host record.
|
||||||
try {
|
try {
|
||||||
@@ -1455,9 +1486,94 @@ router.put(
|
|||||||
* 500:
|
* 500:
|
||||||
* description: Failed to fetch SSH data.
|
* description: Failed to fetch SSH data.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /host/db/host/{id}/terminal-config:
|
||||||
|
* patch:
|
||||||
|
* summary: Update a host's terminal behaviour flags
|
||||||
|
* description: Merges the given flags into the host's terminalConfig. Owner or a recipient with edit access. Currently supports autoTmux; used by the "enable Auto-Tmux" action shown when a persisted session expires.
|
||||||
|
* tags:
|
||||||
|
* - Hosts
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* autoTmux:
|
||||||
|
* type: boolean
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Updated.
|
||||||
|
* 403:
|
||||||
|
* description: No edit access.
|
||||||
|
* 404:
|
||||||
|
* description: Host not found.
|
||||||
|
*/
|
||||||
|
router.patch(
|
||||||
|
"/db/host/:id/terminal-config",
|
||||||
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("hosts.edit"),
|
||||||
|
requireDataAccess,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId!;
|
||||||
|
const hostId = parseInt(String(req.params.id), 10);
|
||||||
|
const { autoTmux } = req.body ?? {};
|
||||||
|
|
||||||
|
if (isNaN(hostId)) {
|
||||||
|
return res.status(400).json({ error: "Invalid host ID" });
|
||||||
|
}
|
||||||
|
if (typeof autoTmux !== "boolean") {
|
||||||
|
return res.status(400).json({ error: "autoTmux must be a boolean" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const access = await permissionManager.canAccessHost(
|
||||||
|
userId,
|
||||||
|
hostId,
|
||||||
|
"edit",
|
||||||
|
);
|
||||||
|
if (!access.hasAccess) {
|
||||||
|
return res.status(403).json({ error: "Access denied to host" });
|
||||||
|
}
|
||||||
|
const ownerId =
|
||||||
|
await createCurrentHostResolutionRepository().findHostOwnerId(hostId);
|
||||||
|
const host = ownerId
|
||||||
|
? await createCurrentHostRepository().findByIdForUser(ownerId, hostId)
|
||||||
|
: null;
|
||||||
|
if (!host || !ownerId) {
|
||||||
|
return res.status(404).json({ error: "Host not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let terminalConfig: Record<string, unknown> = {};
|
||||||
|
if (host.terminalConfig) {
|
||||||
|
try {
|
||||||
|
terminalConfig = JSON.parse(host.terminalConfig);
|
||||||
|
} catch {
|
||||||
|
terminalConfig = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await createCurrentHostRepository().updateForUser(ownerId, hostId, {
|
||||||
|
terminalConfig: JSON.stringify({ ...terminalConfig, autoTmux }),
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, autoTmux });
|
||||||
|
} catch (error) {
|
||||||
|
sshLogger.error("Failed to update host terminal config", error, {
|
||||||
|
operation: "host_terminal_config_update",
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to update terminal config" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
"/db/host",
|
"/db/host",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("hosts.view"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -1601,6 +1717,7 @@ router.get(
|
|||||||
router.get(
|
router.get(
|
||||||
"/db/host/:id",
|
"/db/host/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("hosts.view"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const hostId = Array.isArray(req.params.id)
|
const hostId = Array.isArray(req.params.id)
|
||||||
@@ -1695,6 +1812,78 @@ router.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the minimum authentication material needed by the desktop app to
|
||||||
|
* connect to a shared host from the recipient's own network. The response is
|
||||||
|
* deliberately transient: callers must not persist it or include it in logs.
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/db/host/:id/local-connection-auth",
|
||||||
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("hosts.view"),
|
||||||
|
requireDataAccess,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const hostId = Number(req.params.id);
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId) || !Number.isInteger(hostId) || hostId <= 0) {
|
||||||
|
return res.status(400).json({ error: "Invalid userId or hostId" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const access = await permissionManager.canAccessHost(
|
||||||
|
userId,
|
||||||
|
hostId,
|
||||||
|
"connect",
|
||||||
|
);
|
||||||
|
if (!access.hasAccess || !access.isShared) {
|
||||||
|
return res.status(404).json({ error: "Shared host not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const repository = createCurrentHostResolutionRepository();
|
||||||
|
const ownerId = await repository.findHostOwnerId(hostId);
|
||||||
|
const host = ownerId
|
||||||
|
? await repository.findHostById(hostId, ownerId)
|
||||||
|
: null;
|
||||||
|
if (!host) {
|
||||||
|
return res.status(404).json({ error: "Shared host not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = await resolveHostCredentials(
|
||||||
|
{
|
||||||
|
...transformHostResponse(host),
|
||||||
|
isShared: true,
|
||||||
|
permissionLevel: access.permissionLevel,
|
||||||
|
},
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
res.setHeader("Cache-Control", "no-store");
|
||||||
|
return res.json({
|
||||||
|
username: resolved.username,
|
||||||
|
authType: resolved.authType,
|
||||||
|
password: resolved.password || null,
|
||||||
|
key: resolved.key || null,
|
||||||
|
keyPassword: resolved.keyPassword || null,
|
||||||
|
keyType: resolved.keyType || null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
sshLogger.error(
|
||||||
|
"Failed to resolve shared host local authentication",
|
||||||
|
error,
|
||||||
|
{
|
||||||
|
operation: "shared_host_local_auth_resolve",
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: "Failed to resolve shared host authentication" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /host/db/host/{id}/password:
|
* /host/db/host/{id}/password:
|
||||||
@@ -2223,6 +2412,7 @@ router.get(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/db/host/:id",
|
"/db/host/:id",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("hosts.delete"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -2354,11 +2544,18 @@ router.delete(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
registerHostFileManagerBookmarkRoutes(router, authenticateJWT);
|
registerHostFileManagerBookmarkRoutes(
|
||||||
|
router,
|
||||||
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("hosts.view"),
|
||||||
|
requireDataAccess,
|
||||||
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
"/transfer/recent",
|
"/transfer/recent",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("hosts.view"),
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const sourceHostIdQuery = Array.isArray(req.query.sourceHostId)
|
const sourceHostIdQuery = Array.isArray(req.query.sourceHostId)
|
||||||
@@ -2395,6 +2592,8 @@ router.get(
|
|||||||
router.post(
|
router.post(
|
||||||
"/transfer/recent",
|
"/transfer/recent",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("hosts.view"),
|
||||||
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { sourceHostId, destHostId, destPath, destPathLabel } = req.body;
|
const { sourceHostId, destHostId, destPath, destPathLabel } = req.body;
|
||||||
@@ -2426,7 +2625,12 @@ router.post(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
registerHostCommandHistoryRoutes(router, authenticateJWT);
|
registerHostCommandHistoryRoutes(
|
||||||
|
router,
|
||||||
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("hosts.view"),
|
||||||
|
requireDataAccess,
|
||||||
|
);
|
||||||
|
|
||||||
async function resolveHostCredentials(
|
async function resolveHostCredentials(
|
||||||
host: Record<string, unknown>,
|
host: Record<string, unknown>,
|
||||||
@@ -2455,6 +2659,24 @@ async function resolveHostCredentials(
|
|||||||
required: needsPersonalCredential,
|
required: needsPersonalCredential,
|
||||||
ownerAuthShared: !!host.shareSshAuth,
|
ownerAuthShared: !!host.shareSshAuth,
|
||||||
};
|
};
|
||||||
|
// Owner auth for the remote desktop protocols is always snapshotted
|
||||||
|
// for recipients; only their own override credential varies per user.
|
||||||
|
const authOverrides: Record<string, unknown> = {
|
||||||
|
ssh: baseSshOverrideState,
|
||||||
|
};
|
||||||
|
const overrideCredentialIds =
|
||||||
|
await createCurrentSharedHostAuthOverrideRepository().listCredentialIds(
|
||||||
|
host.id,
|
||||||
|
requestingUserId,
|
||||||
|
);
|
||||||
|
for (const protocol of ["rdp", "vnc", "telnet"] as const) {
|
||||||
|
if (!host[AUTH_PROTOCOL_METADATA[protocol].enableField]) continue;
|
||||||
|
authOverrides[protocol] = {
|
||||||
|
credentialId: overrideCredentialIds[protocol],
|
||||||
|
required: false,
|
||||||
|
ownerAuthShared: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
const recipientHost: Record<string, unknown> = {
|
const recipientHost: Record<string, unknown> = {
|
||||||
...host,
|
...host,
|
||||||
credentialId: null,
|
credentialId: null,
|
||||||
@@ -2462,9 +2684,7 @@ async function resolveHostCredentials(
|
|||||||
key: null,
|
key: null,
|
||||||
keyPassword: null,
|
keyPassword: null,
|
||||||
keyType: null,
|
keyType: null,
|
||||||
authOverrides: {
|
authOverrides,
|
||||||
ssh: baseSshOverrideState,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -2480,6 +2700,7 @@ async function resolveHostCredentials(
|
|||||||
return {
|
return {
|
||||||
...recipientHost,
|
...recipientHost,
|
||||||
authOverrides: {
|
authOverrides: {
|
||||||
|
...authOverrides,
|
||||||
ssh: {
|
ssh: {
|
||||||
credentialId: resolution.credentialId,
|
credentialId: resolution.credentialId,
|
||||||
required: false,
|
required: false,
|
||||||
@@ -2505,6 +2726,7 @@ async function resolveHostCredentials(
|
|||||||
return {
|
return {
|
||||||
...recipientHost,
|
...recipientHost,
|
||||||
authOverrides: {
|
authOverrides: {
|
||||||
|
...authOverrides,
|
||||||
ssh: {
|
ssh: {
|
||||||
required: false,
|
required: false,
|
||||||
ownerAuthShared: true,
|
ownerAuthShared: true,
|
||||||
@@ -2524,6 +2746,7 @@ async function resolveHostCredentials(
|
|||||||
return {
|
return {
|
||||||
...recipientHost,
|
...recipientHost,
|
||||||
authOverrides: {
|
authOverrides: {
|
||||||
|
...authOverrides,
|
||||||
ssh: {
|
ssh: {
|
||||||
required: false,
|
required: false,
|
||||||
ownerAuthShared: true,
|
ownerAuthShared: true,
|
||||||
@@ -2547,6 +2770,7 @@ async function resolveHostCredentials(
|
|||||||
return {
|
return {
|
||||||
...recipientHost,
|
...recipientHost,
|
||||||
authOverrides: {
|
authOverrides: {
|
||||||
|
...authOverrides,
|
||||||
ssh: {
|
ssh: {
|
||||||
required: false,
|
required: false,
|
||||||
ownerAuthShared: !!host.shareSshAuth,
|
ownerAuthShared: !!host.shareSshAuth,
|
||||||
@@ -2567,10 +2791,7 @@ async function resolveHostCredentials(
|
|||||||
|
|
||||||
const credential =
|
const credential =
|
||||||
preloadedCredentials?.get(credentialId) ??
|
preloadedCredentials?.get(credentialId) ??
|
||||||
(await createCurrentHostResolutionRepository().findCredentialByIdForUser(
|
(await findUsableCredential(credentialId, credentialOwnerId));
|
||||||
credentialId,
|
|
||||||
credentialOwnerId,
|
|
||||||
));
|
|
||||||
|
|
||||||
if (credential) {
|
if (credential) {
|
||||||
const resolvedHost: Record<string, unknown> = {
|
const resolvedHost: Record<string, unknown> = {
|
||||||
@@ -2605,13 +2826,27 @@ async function resolveHostCredentials(
|
|||||||
|
|
||||||
registerHostFolderRoutes(router, {
|
registerHostFolderRoutes(router, {
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission: permissionManager.requirePermission("hosts.view"),
|
||||||
|
requireEditPermission: permissionManager.requirePermission("hosts.edit"),
|
||||||
|
requireDeletePermission: permissionManager.requirePermission("hosts.delete"),
|
||||||
|
requireCredentialEditPermission:
|
||||||
|
permissionManager.requirePermission("credentials.edit"),
|
||||||
|
requireDataAccess,
|
||||||
statsServerUrl: STATS_SERVER_URL,
|
statsServerUrl: STATS_SERVER_URL,
|
||||||
});
|
});
|
||||||
|
|
||||||
registerHostBulkRoutes(router, authenticateJWT);
|
registerHostBulkRoutes(
|
||||||
|
router,
|
||||||
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("hosts.create"),
|
||||||
|
permissionManager.requirePermission("hosts.edit"),
|
||||||
|
requireDataAccess,
|
||||||
|
);
|
||||||
|
|
||||||
registerHostAutostartRoutes(router, {
|
registerHostAutostartRoutes(router, {
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission: permissionManager.requirePermission("hosts.view"),
|
||||||
|
requireEditPermission: permissionManager.requirePermission("hosts.edit"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2656,6 +2891,8 @@ registerHostAutostartRoutes(router, {
|
|||||||
router.get(
|
router.get(
|
||||||
"/ssh/opkssh/token/:hostId",
|
"/ssh/opkssh/token/:hostId",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("hosts.view"),
|
||||||
|
requireDataAccess,
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const userId = req.userId;
|
const userId = req.userId;
|
||||||
@@ -2727,6 +2964,8 @@ router.get(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/ssh/opkssh/token/:hostId",
|
"/ssh/opkssh/token/:hostId",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
permissionManager.requirePermission("hosts.edit"),
|
||||||
|
requireDataAccess,
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const userId = req.userId;
|
const userId = req.userId;
|
||||||
@@ -2756,9 +2995,11 @@ router.delete(
|
|||||||
);
|
);
|
||||||
|
|
||||||
registerHostOpksshRoutes(router);
|
registerHostOpksshRoutes(router);
|
||||||
|
registerHostStepCaRoutes(router);
|
||||||
|
|
||||||
registerHostNetworkRoutes(router, {
|
registerHostNetworkRoutes(router, {
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
|
requireViewPermission: permissionManager.requirePermission("hosts.view"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ const VALID_ACTION_TYPES = [
|
|||||||
"sendControlCode",
|
"sendControlCode",
|
||||||
"sendText",
|
"sendText",
|
||||||
"runSnippet",
|
"runSnippet",
|
||||||
|
"nextTab",
|
||||||
|
"previousTab",
|
||||||
|
"openCommandPalette",
|
||||||
];
|
];
|
||||||
|
|
||||||
export function isValidKeyCombo(combo: unknown): boolean {
|
export function isValidKeyCombo(combo: unknown): boolean {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user