Compare commits
46 Commits
release-1.
...
release-1.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7fa40393d | ||
|
|
b91627d91b | ||
|
|
451c96868a | ||
|
|
d508ea2afc | ||
|
|
457b768c14 | ||
|
|
a2b9b1d59f | ||
|
|
6d20e3fcac | ||
|
|
cc0d6e0eb2 | ||
|
|
5c9e1c0d75 | ||
|
|
8ee63dbe9e | ||
|
|
cc3384f8e2 | ||
|
|
d92867223c | ||
|
|
2ac2787ec5 | ||
|
|
f11c0906e1 | ||
|
|
42e7ab8141 | ||
|
|
ced52ddd81 | ||
|
|
b3a986d826 | ||
|
|
7be6f4203e | ||
|
|
6a04a5d430 | ||
|
|
ee09a6808a | ||
|
|
0f75cd4d16 | ||
|
|
ab69661751 | ||
|
|
a31e855c36 | ||
|
|
d4dc310c93 | ||
|
|
046a3d6855 | ||
|
|
5cd9de9ac5 | ||
|
|
d85fb26a5d | ||
|
|
7756dc6a4c | ||
|
|
ff76137339 | ||
|
|
61db35daad | ||
|
|
26c1cacc9d | ||
|
|
8dddbaa86d | ||
|
|
d46fafb421 | ||
|
|
25178928a0 | ||
|
|
2fe9c0f854 | ||
|
|
2f68dc018e | ||
|
|
8b8e77214c | ||
|
|
89589dcf9f | ||
|
|
250ad975d4 | ||
|
|
b649e73c80 | ||
|
|
839e36adb9 | ||
|
|
f02c0c3163 | ||
|
|
83c41751ea | ||
|
|
8058ffd217 | ||
|
|
a3db62b0f8 | ||
|
|
dc43bf1329 |
129
.dockerignore
Normal file
@@ -0,0 +1,129 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Build outputs
|
||||
dist
|
||||
build
|
||||
.next
|
||||
.nuxt
|
||||
|
||||
# Development files
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDE and editor files
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
README-CN.md
|
||||
CONTRIBUTING.md
|
||||
LICENSE
|
||||
|
||||
# Docker files (avoid copying docker files into docker)
|
||||
# docker/ - commented out to allow entrypoint.sh to be copied
|
||||
|
||||
# Repository images
|
||||
repo-images/
|
||||
|
||||
# Uploads directory
|
||||
uploads/
|
||||
|
||||
# Electron files (not needed for Docker)
|
||||
electron/
|
||||
electron-builder.json
|
||||
|
||||
# Development and build artifacts
|
||||
*.log
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Font files (we'll optimize these in Dockerfile)
|
||||
# public/fonts/*.ttf
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Dependency directories
|
||||
jspm_packages/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# next.js build output
|
||||
.next
|
||||
|
||||
# nuxt.js build output
|
||||
.nuxt
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
2
.github/FUNDING.yml
vendored
@@ -1 +1 @@
|
||||
github: [LukeGus]
|
||||
github: [LukeGus]
|
||||
|
||||
82
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
name: Bug report
|
||||
description: Create a report to help Termix improve
|
||||
title: "[BUG]"
|
||||
labels: [bug]
|
||||
assignees: []
|
||||
body:
|
||||
- type: input
|
||||
id: title
|
||||
attributes:
|
||||
label: Title
|
||||
description: Brief, descriptive title for the bug
|
||||
placeholder: "Brief description of the bug"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: Platform
|
||||
description: How are you using Termix?
|
||||
options:
|
||||
- Website - Firefox
|
||||
- Website - Safari
|
||||
- Website - Chrome
|
||||
- Website - Other Browser
|
||||
- App - Windows
|
||||
- App - Linux
|
||||
- App - iOS
|
||||
- App - Android
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: server-installation-method
|
||||
attributes:
|
||||
label: Server Installation Method
|
||||
description: How is the Termix server installed?
|
||||
options:
|
||||
- Docker
|
||||
- Manual Build
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: Find your version in the User Profile tab
|
||||
placeholder: "e.g., 1.7.0"
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: troubleshooting
|
||||
attributes:
|
||||
label: Troubleshooting
|
||||
description: Please check all that apply
|
||||
options:
|
||||
- label: I have examined logs and tried to find the issue
|
||||
- label: I have reviewed opened and closed issues
|
||||
- label: I have tried restarting the application
|
||||
- type: textarea
|
||||
id: problem-description
|
||||
attributes:
|
||||
label: The Problem
|
||||
description: Describe the bug in detail. Include as much information as possible with screenshots if applicable.
|
||||
placeholder: "Describe what went wrong..."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduction-steps
|
||||
attributes:
|
||||
label: How to Reproduce
|
||||
description: Use as few steps as possible to reproduce the issue
|
||||
placeholder: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Any other context about the problem
|
||||
placeholder: "Add any other context about the problem here..."
|
||||
36
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Feature request
|
||||
description: Suggest an idea for Termix
|
||||
title: "[FEATURE]"
|
||||
labels: [enhancement]
|
||||
assignees: []
|
||||
body:
|
||||
- type: input
|
||||
id: title
|
||||
attributes:
|
||||
label: Title
|
||||
description: Brief, descriptive title for the feature request
|
||||
placeholder: "Brief description of the feature"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: related-issue
|
||||
attributes:
|
||||
label: Is it related to an issue?
|
||||
description: Describe the problem this feature would solve
|
||||
placeholder: "Describe what problem this feature would solve..."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: The Solution
|
||||
description: Describe your proposed solution in detail
|
||||
placeholder: "Describe how you envision this feature working..."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Any other context or screenshots about the feature request
|
||||
placeholder: "Add any other context about the feature request here..."
|
||||
4
.github/dependabot.yml
vendored
@@ -21,7 +21,7 @@ updates:
|
||||
dependency-type: "production"
|
||||
update-types:
|
||||
- "minor"
|
||||
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/docker"
|
||||
schedule:
|
||||
@@ -37,4 +37,4 @@ updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: "weekly"
|
||||
|
||||
23
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# Overview
|
||||
_Short summary of what this PR does_
|
||||
- [ ] Added: ...
|
||||
- [ ] Updated: ...
|
||||
- [ ] Removed: ...
|
||||
- [ ] Fixed: ...
|
||||
|
||||
# Changes Made
|
||||
_Detailed explanation of changes (if needed)_
|
||||
- ...
|
||||
|
||||
# Related Issues
|
||||
_Link any issues this PR addresses_
|
||||
- Closes #ISSUE_NUMBER
|
||||
- Related to #ISSUE_NUMBER
|
||||
|
||||
# Screenshots / Demos
|
||||
_(Optional: add before/after screenshots, GIFs, or console output)_
|
||||
|
||||
# Checklist
|
||||
- [ ] Code follows project style guidelines
|
||||
- [ ] Supports mobile and desktop UI/app (if applicable)
|
||||
- [ ] I have read [Contributing.md](https://github.com/LukeGus/Termix/blob/main/CONTRIBUTING.md)
|
||||
43
.github/workflows/docker-image.yml
vendored
@@ -1,18 +1,20 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- development
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- '.gitignore'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag_name:
|
||||
description: "Custom tag name for the Docker image"
|
||||
required: false
|
||||
default: ""
|
||||
registry:
|
||||
description: "Docker registry to push to"
|
||||
required: true
|
||||
default: "ghcr"
|
||||
type: choice
|
||||
options:
|
||||
- "ghcr"
|
||||
- "dockerhub"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -56,16 +58,26 @@ jobs:
|
||||
${{ runner.os }}-buildx-${{ github.ref_name }}-
|
||||
${{ runner.os }}-buildx-
|
||||
|
||||
- name: Login to Docker Registry
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event.inputs.registry != 'dockerhub'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: github.event.inputs.registry == 'dockerhub'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: bugattiguy527
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Determine Docker image tag
|
||||
run: |
|
||||
echo "REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||
REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')
|
||||
echo "REPO_OWNER=$REPO_OWNER" >> $GITHUB_ENV
|
||||
|
||||
if [ "${{ github.event.inputs.tag_name }}" != "" ]; then
|
||||
IMAGE_TAG="${{ github.event.inputs.tag_name }}"
|
||||
elif [ "${{ github.ref }}" == "refs/heads/main" ]; then
|
||||
@@ -73,10 +85,19 @@ jobs:
|
||||
elif [ "${{ github.ref }}" == "refs/heads/development" ]; then
|
||||
IMAGE_TAG="development-latest"
|
||||
else
|
||||
IMAGE_TAG="${{ github.ref_name }}-development-latest"
|
||||
IMAGE_TAG="${{ github.ref_name }}"
|
||||
fi
|
||||
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
|
||||
|
||||
# Determine registry and image name
|
||||
if [ "${{ github.event.inputs.registry }}" == "dockerhub" ]; then
|
||||
echo "REGISTRY=docker.io" >> $GITHUB_ENV
|
||||
echo "IMAGE_NAME=bugattiguy527/termix" >> $GITHUB_ENV
|
||||
else
|
||||
echo "REGISTRY=ghcr.io" >> $GITHUB_ENV
|
||||
echo "IMAGE_NAME=$REPO_OWNER/termix" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Build and Push Multi-Arch Docker Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
@@ -84,7 +105,7 @@ jobs:
|
||||
file: ./docker/Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ghcr.io/${{ env.REPO_OWNER }}/termix:${{ env.IMAGE_TAG }}
|
||||
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
|
||||
labels: |
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
@@ -101,7 +122,7 @@ jobs:
|
||||
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
||||
|
||||
- name: Delete all untagged image versions
|
||||
if: success()
|
||||
if: success() && github.event.inputs.registry != 'dockerhub'
|
||||
uses: quartx-analytics/ghcr-cleaner@v1
|
||||
with:
|
||||
owner-type: user
|
||||
|
||||
93
.github/workflows/electron-build.yml
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
name: Build Electron App
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_type:
|
||||
description: "Build type to run"
|
||||
required: true
|
||||
default: "all"
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- windows
|
||||
- linux
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
if: github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'windows' || github.event.inputs.build_type == ''
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Windows Portable
|
||||
run: npm run build:win-portable
|
||||
|
||||
- name: Build Windows Installer
|
||||
run: npm run build:win-installer
|
||||
|
||||
- name: Create Windows Portable zip
|
||||
run: |
|
||||
Compress-Archive -Path "release/win-unpacked/*" -DestinationPath "Termix-Windows-Portable.zip"
|
||||
|
||||
- name: Upload Windows Portable Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Termix-Windows-Portable
|
||||
path: Termix-Windows-Portable.zip
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Windows Installer Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Termix-Windows-Installer
|
||||
path: release/*.exe
|
||||
retention-days: 30
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'linux' || github.event.inputs.build_type == ''
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Linux Portable
|
||||
run: npm run build:linux-portable
|
||||
|
||||
- name: Create Linux Portable zip
|
||||
run: |
|
||||
cd release/linux-unpacked
|
||||
zip -r ../../Termix-Linux-Portable.zip *
|
||||
cd ../..
|
||||
|
||||
- name: Upload Linux Portable Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Termix-Linux-Portable
|
||||
path: Termix-Linux-Portable.zip
|
||||
retention-days: 30
|
||||
4
.gitignore
vendored
@@ -23,3 +23,7 @@ dist-ssr
|
||||
*.sln
|
||||
*.sw?
|
||||
/db/
|
||||
/release/
|
||||
/.claude/
|
||||
/ssl/
|
||||
.env
|
||||
|
||||
3
.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
||||
# Ignore artifacts:
|
||||
build
|
||||
coverage
|
||||
1
.prettierrc
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
128
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
mail@termix.site.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
106
CONTRIBUTING.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Contributing
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/en/download/) (built with v24)
|
||||
- [NPM](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
|
||||
## Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```sh
|
||||
git clone https://github.com/LukeGus/Termix
|
||||
```
|
||||
2. Install the dependencies:
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
## Running the development server
|
||||
|
||||
Run the following commands:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
npm run dev:backend
|
||||
```
|
||||
|
||||
This will start the backend and the frontend Vite server. You can access Termix by going to `http://localhost:5174/`.
|
||||
|
||||
## Contributing
|
||||
|
||||
1. **Fork the repository**: Click the "Fork" button at the top right of
|
||||
the [repository page](https://github.com/LukeGus/Termix).
|
||||
2. **Create a new branch**:
|
||||
```sh
|
||||
git checkout -b feature/my-new-feature
|
||||
```
|
||||
3. **Make your changes**: Implement your feature, fix, or improvement.
|
||||
4. **Commit your changes**:
|
||||
```sh
|
||||
git commit -m "Feature request my new feature"
|
||||
```
|
||||
5. **Push to your fork**:
|
||||
```sh
|
||||
git push origin feature/my-feature-request
|
||||
```
|
||||
6. **Open a pull request**: Go to the original repository and create a PR with a clear description.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Follow the existing code style. Use Tailwind CSS with shadcn components.
|
||||
- Use the below color scheme with the respective CSS variable placed in the `className` of a div/component.
|
||||
- Place all API routes in the `main-axios.ts` file. Updating the `openapi.json` is unneeded.
|
||||
- Include meaningful commit messages.
|
||||
- Link related issues when applicable.
|
||||
- `MobileApp.tsx` renders when the users screen width is less than 768px, otherwise it loads the usual `DesktopApp.tsx`.
|
||||
|
||||
## Color Scheme
|
||||
|
||||
### Background Colors
|
||||
|
||||
| CSS Variable | Color Value | Usage | Description |
|
||||
| ----------------------------- | ----------- | --------------------------- | ---------------------------------------- |
|
||||
| `--color-dark-bg` | `#18181b` | Main dark background | Primary dark background color |
|
||||
| `--color-dark-bg-darker` | `#0e0e10` | Darker backgrounds | Darker variant for panels and containers |
|
||||
| `--color-dark-bg-darkest` | `#09090b` | Darkest backgrounds | Darkest background (terminal) |
|
||||
| `--color-dark-bg-light` | `#141416` | Light dark backgrounds | Lighter variant of dark background |
|
||||
| `--color-dark-bg-very-light` | `#101014` | Very light dark backgrounds | Very light variant of dark background |
|
||||
| `--color-dark-bg-panel` | `#1b1b1e` | Panel backgrounds | Background for panels and cards |
|
||||
| `--color-dark-bg-panel-hover` | `#232327` | Panel hover states | Background for panels on hover |
|
||||
|
||||
### Element-Specific Backgrounds
|
||||
|
||||
| CSS Variable | Color Value | Usage | Description |
|
||||
| ------------------------ | ----------- | ------------------ | --------------------------------------------- |
|
||||
| `--color-dark-bg-input` | `#222225` | Input fields | Background for input fields and form elements |
|
||||
| `--color-dark-bg-button` | `#23232a` | Button backgrounds | Background for buttons and clickable elements |
|
||||
| `--color-dark-bg-active` | `#1d1d1f` | Active states | Background for active/selected elements |
|
||||
| `--color-dark-bg-header` | `#131316` | Header backgrounds | Background for headers and navigation bars |
|
||||
|
||||
### Border Colors
|
||||
|
||||
| CSS Variable | Color Value | Usage | Description |
|
||||
| ---------------------------- | ----------- | --------------- | ---------------------------------------- |
|
||||
| `--color-dark-border` | `#303032` | Default borders | Standard border color |
|
||||
| `--color-dark-border-active` | `#2d2d30` | Active borders | Border color for active elements |
|
||||
| `--color-dark-border-hover` | `#434345` | Hover borders | Border color on hover states |
|
||||
| `--color-dark-border-light` | `#5a5a5d` | Light borders | Lighter border color for subtle elements |
|
||||
| `--color-dark-border-medium` | `#373739` | Medium borders | Medium weight border color |
|
||||
| `--color-dark-border-panel` | `#222224` | Panel borders | Border color for panels and cards |
|
||||
|
||||
### Interactive States
|
||||
|
||||
| CSS Variable | Color Value | Usage | Description |
|
||||
| ------------------------ | ----------- | ----------------- | --------------------------------------------- |
|
||||
| `--color-dark-hover` | `#2d2d30` | Hover states | Background color for hover effects |
|
||||
| `--color-dark-active` | `#2a2a2c` | Active states | Background color for active elements |
|
||||
| `--color-dark-pressed` | `#1a1a1c` | Pressed states | Background color for pressed/clicked elements |
|
||||
| `--color-dark-hover-alt` | `#2a2a2d` | Alternative hover | Alternative hover state color |
|
||||
|
||||
## Support
|
||||
|
||||
If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support
|
||||
channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues)
|
||||
repo.
|
||||
115
README-CN.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# 仓库统计
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> 英文</a> |
|
||||
<img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文
|
||||
</p>
|
||||
|
||||

|
||||

|
||||

|
||||
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720"></a>
|
||||
|
||||
<p align="center">
|
||||
<img src="./repo-images/RepoOfTheDay.png" alt="Repo of the Day Achievement" style="width: 300px; height: auto;">
|
||||
<br>
|
||||
<small style="color: #666;">2025年9月1日获得</small>
|
||||
</p>
|
||||
|
||||
#### 核心技术
|
||||
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
|
||||
<br />
|
||||
<p align="center">
|
||||
<a href="https://github.com/LukeGus/Termix">
|
||||
<img alt="Termix Banner" src=./repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
|
||||
</p>
|
||||
|
||||
如果你愿意,可以在这里支持这个项目!\
|
||||
[](https://github.com/sponsors/LukeGus)
|
||||
|
||||
# 概览
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/LukeGus/Termix">
|
||||
<img alt="Termix Banner" src=./public/icon.svg style="width: 250px; height: 250px;"> </a>
|
||||
</p>
|
||||
|
||||
Termix 是一个开源、永久免费、自托管的一体化服务器管理平台。它提供了一个基于网页的解决方案,通过一个直观的界面管理你的服务器和基础设施。Termix
|
||||
提供 SSH 终端访问、SSH 隧道功能以及远程文件编辑,还会陆续添加更多工具。
|
||||
|
||||
# 功能
|
||||
|
||||
- **SSH 终端访问** - 功能完整的终端,支持分屏(最多 4 个面板)和标签系统
|
||||
- **SSH 隧道管理** - 创建和管理 SSH 隧道,支持自动重连和健康监控
|
||||
- **远程文件编辑器** - 直接在远程服务器编辑文件,支持语法高亮和文件管理功能(上传、删除、重命名等)
|
||||
- **SSH 主机管理器** - 保存、组织和管理 SSH 连接,支持标签和文件夹
|
||||
- **服务器统计** - 查看任意 SSH 服务器的 CPU、内存和硬盘使用情况
|
||||
- **用户认证** - 安全的用户管理,支持管理员控制、OIDC 和双因素认证(TOTP)
|
||||
- **现代化界面** - 使用 React、Tailwind CSS 和 Shadcn 构建的简洁界面
|
||||
- **语言支持** - 内置中英文支持
|
||||
|
||||
# 计划功能
|
||||
|
||||
- **增强管理员控制** - 提供更精细的用户和管理员权限控制、共享主机等功能
|
||||
- **主题定制** - 修改所有工具的主题风格
|
||||
- **增强终端支持** - 添加更多终端协议,如 VNC 和 RDP(有类似 Apache Guacamole 的 RDP 集成经验者请通过创建 issue 联系我)
|
||||
- **移动端支持** - 支持移动应用或 Termix 网站移动版,让你在手机上管理服务器
|
||||
|
||||
# 安装
|
||||
|
||||
访问 Termix [文档](https://docs.termix.site/install) 获取安装信息。或者可以参考以下示例 docker-compose 文件:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
termix:
|
||||
image: ghcr.io/lukegus/termix:latest
|
||||
container_name: termix
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- termix-data:/app/data
|
||||
environment:
|
||||
PORT: "8080"
|
||||
|
||||
volumes:
|
||||
termix-data:
|
||||
driver: local
|
||||
```
|
||||
|
||||
# 支持
|
||||
|
||||
如果你需要 Termix 的帮助,可以加入 [Discord](https://discord.gg/jVQGdvHDrf)
|
||||
服务器并访问支持频道。你也可以在 [GitHub](https://github.com/LukeGus/Termix/issues) 仓库提交 issue 或 pull request。
|
||||
|
||||
# 展示
|
||||
|
||||
<p align="center">
|
||||
<img src="./repo-images/Image 1.png" width="400" alt="Termix Demo 1"/>
|
||||
<img src="./repo-images/Image 2.png" width="400" alt="Termix Demo 2"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./repo-images/Image 3.png" width="250" alt="Termix Demo 3"/>
|
||||
<img src="./repo-images/Image 4.png" width="250" alt="Termix Demo 4"/>
|
||||
<img src="./repo-images/Image 5.png" width="250" alt="Termix Demo 5"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<video src="https://github.com/user-attachments/assets/f9caa061-10dc-4173-ae7d-c6d42f05cf56" width="800" controls>
|
||||
你的浏览器不支持 video 标签。
|
||||
</video>
|
||||
</p>
|
||||
|
||||
# 许可证
|
||||
|
||||
根据 Apache 2.0 许可证发布。更多信息请参见 LICENSE。
|
||||
73
README.md
@@ -1,9 +1,23 @@
|
||||
# Repo Stats
|
||||
|
||||
<p align="center">
|
||||
<img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English |
|
||||
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a>
|
||||
</p>
|
||||
|
||||

|
||||

|
||||

|
||||
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720"></a>
|
||||
|
||||
<p align="center">
|
||||
<img src="./repo-images/RepoOfTheDay.png" alt="Repo of the Day Achievement" style="width: 300px; height: auto;">
|
||||
<br>
|
||||
<small style="color: #666;">Achieved on September 1st, 2025</small>
|
||||
</p>
|
||||
|
||||
#### Top Technologies
|
||||
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
@@ -29,26 +43,43 @@ If you would like, you can support the project here!\
|
||||
<img alt="Termix Banner" src=./public/icon.svg style="width: 250px; height: 250px;"> </a>
|
||||
</p>
|
||||
|
||||
Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a web-based solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal access, SSH tunneling capabilities, and remote file editing, with many more tools to come.
|
||||
Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a web-based
|
||||
solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal
|
||||
access, SSH tunneling capabilities, remote file management, with many more tools to come.
|
||||
|
||||
# Features
|
||||
|
||||
- **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) and tab system
|
||||
- **SSH Tunnel Management** - Create and manage SSH tunnels with automatic reconnection and health monitoring
|
||||
- **Remote File Editor** - Edit files directly on remote servers with syntax highlighting, file management features (uploading, removing, renaming, deleting files)
|
||||
- **SSH Host Manager** - Save, organize, and manage your SSH connections with tags and folders
|
||||
- **Remote File Manager** - Manage files directly on remote servers with support for viewing and editing code, images, audio, and video. Upload, download, rename, delete, and move files seamlessly.
|
||||
- **SSH Host Manager** - Save, organize, and manage your SSH connections with tags and folders and easily save reusable login info while being able to automate the deploying of SSH keys
|
||||
- **Server Stats** - View CPU, memory, and HDD usage on any SSH server
|
||||
- **User Authentication** - Secure user management with admin controls and OIDC support with more auth types planned
|
||||
- **Modern UI** - Clean interface built with React, Tailwind CSS, and Shadcn
|
||||
- **User Authentication** - Secure user management with admin controls and OIDC and 2FA (TOTP) support
|
||||
- **Database Encryption** - SQLite database files encrypted at rest with automatic encryption/decryption
|
||||
- **Data Export/Import** - Export and import SSH hosts, credentials, and file manager data with incremental sync
|
||||
- **Automatic SSL Setup** - Built-in SSL certificate generation and management with HTTPS redirects
|
||||
- **Modern UI** - Clean desktop/mobile friendly interface built with React, Tailwind CSS, and Shadcn
|
||||
- **Languages** - Built-in support for English and Chinese
|
||||
- **Platform Support** - Available as a web app, desktop application (Windows & Linux), and dedicated mobile app for iOS and Android (coming in a few days)
|
||||
|
||||
# Planned Features
|
||||
- **Improved Admin Control** - Give more fine-grained control over user and admin permissions, share hosts, etc
|
||||
- **More auth types** - Add 2FA, TOTP, etc
|
||||
- **Theming** - Modify themeing for all tools
|
||||
- **Improved Terminal Support** - Add more terminal protocols such as VNC and RDP (anyone who has experience in integrating RDP into a web-application similar to Apache Guacamole, please contact me by creating an issue)
|
||||
- **Mobile Support** - Support a mobile app or version of the Termix website to manage servers from your phone
|
||||
|
||||
See [Projects](https://github.com/users/LukeGus/projects/3) for all planned features. If you are looking to contribute, see [Contributing](https://github.com/LukeGus/Termix/blob/main/CONTRIBUTING.md).
|
||||
|
||||
# Installation
|
||||
Visit the Termix [Docs](https://docs.termix.site/docs) for more information on how to install Termix. Otherwise, view a sample docker-compose file here:
|
||||
|
||||
Supported Devices:
|
||||
|
||||
- Website (any modern browser like Google, Safari, and Firefox)
|
||||
- Windows (app)
|
||||
- Linux (app)
|
||||
- iOS (coming in a few days)
|
||||
- Android (coming in a few days)
|
||||
- iPadOS and macOS are in progress
|
||||
|
||||
Visit the Termix [Docs](https://docs.termix.site/install) for more information on how to install Termix on all platforms. Otherwise, view
|
||||
a sample docker-compose file here:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
termix:
|
||||
@@ -64,11 +95,14 @@ services:
|
||||
|
||||
volumes:
|
||||
termix-data:
|
||||
driver: local
|
||||
driver: local
|
||||
```
|
||||
|
||||
# Support
|
||||
If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues) repo.
|
||||
|
||||
If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support
|
||||
channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues)
|
||||
repo.
|
||||
|
||||
# Show-off
|
||||
|
||||
@@ -78,16 +112,21 @@ If you need help with Termix, you can join the [Discord](https://discord.gg/jVQG
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./repo-images/Image 3.png" width="250" alt="Termix Demo 3"/>
|
||||
<img src="./repo-images/Image 4.png" width="250" alt="Termix Demo 4"/>
|
||||
<img src="./repo-images/Image 5.png" width="250" alt="Termix Demo 5"/>
|
||||
<img src="./repo-images/Image 3.png" width="400" alt="Termix Demo 3"/>
|
||||
<img src="./repo-images/Image 4.png" width="400" alt="Termix Demo 4"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<video src="https://github.com/user-attachments/assets/f9caa061-10dc-4173-ae7d-c6d42f05cf56" width="800" controls>
|
||||
<img src="./repo-images/Image 5.png" width="400" alt="Termix Demo 5"/>
|
||||
<img src="./repo-images/Image 6.png" width="400" alt="Termix Demo 6"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<video src="https://github.com/user-attachments/assets/88936e0d-2399-4122-8eee-c255c25da48c" width="800" controls>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</p>
|
||||
|
||||
# License
|
||||
|
||||
Distributed under the Apache License Version 2.0. See LICENSE for more information.
|
||||
|
||||
5
SECURITY.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report any vulnerabilities to [GitHub Security](https://github.com/LukeGus/Termix/security/advisories).
|
||||
@@ -18,4 +18,4 @@
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
# Stage 1: Install dependencies and build frontend
|
||||
FROM node:24-alpine AS deps
|
||||
# Stage 1: Install dependencies
|
||||
FROM node:22-slim AS deps
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache python3 make g++
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm ci --force && \
|
||||
ENV npm_config_target_platform=linux
|
||||
ENV npm_config_target_arch=x64
|
||||
ENV npm_config_target_libc=glibc
|
||||
|
||||
RUN rm -rf node_modules package-lock.json && \
|
||||
npm install --force && \
|
||||
npm cache clean --force
|
||||
|
||||
# Stage 2: Build frontend
|
||||
@@ -15,65 +20,70 @@ WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
RUN find public/fonts -name "*.ttf" ! -name "*Regular.ttf" ! -name "*Bold.ttf" ! -name "*Italic.ttf" -delete
|
||||
|
||||
# Stage 3: Build backend TypeScript
|
||||
RUN npm cache clean --force && \
|
||||
npm run build
|
||||
|
||||
# Stage 3: Build backend
|
||||
FROM deps AS backend-builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
ENV npm_config_target_platform=linux
|
||||
ENV npm_config_target_arch=x64
|
||||
ENV npm_config_target_libc=glibc
|
||||
|
||||
RUN npm rebuild better-sqlite3 --force
|
||||
|
||||
RUN npm run build:backend
|
||||
|
||||
# Stage 4: Production dependencies
|
||||
FROM node:24-alpine AS production-deps
|
||||
# Stage 4: Production dependencies only
|
||||
FROM node:22-slim AS production-deps
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
ENV npm_config_target_platform=linux
|
||||
ENV npm_config_target_arch=x64
|
||||
ENV npm_config_target_libc=glibc
|
||||
|
||||
RUN npm ci --only=production --ignore-scripts --force && \
|
||||
npm rebuild better-sqlite3 bcryptjs --force && \
|
||||
npm cache clean --force
|
||||
|
||||
# Stage 5: Build native modules
|
||||
FROM node:24-alpine AS native-builder
|
||||
# Stage 5: Final optimized image
|
||||
FROM node:22-slim
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm ci --only=production bcryptjs better-sqlite3 --force && \
|
||||
npm cache clean --force
|
||||
|
||||
# Stage 6: Final image
|
||||
FROM node:24-alpine
|
||||
ENV DATA_DIR=/app/data \
|
||||
PORT=8080 \
|
||||
NODE_ENV=production
|
||||
|
||||
RUN apk add --no-cache nginx gettext su-exec && \
|
||||
mkdir -p /app/data && \
|
||||
chown -R node:node /app/data
|
||||
RUN apt-get update && apt-get install -y nginx gettext-base openssl && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
mkdir -p /app/data /app/uploads && \
|
||||
chown -R node:node /app/data /app/uploads && \
|
||||
useradd -r -s /bin/false nginx
|
||||
|
||||
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY --from=frontend-builder /app/dist /usr/share/nginx/html
|
||||
RUN chown -R nginx:nginx /usr/share/nginx/html
|
||||
COPY docker/nginx-https.conf /etc/nginx/nginx-https.conf
|
||||
|
||||
WORKDIR /app
|
||||
COPY --chown=nginx:nginx --from=frontend-builder /app/dist /usr/share/nginx/html
|
||||
COPY --chown=nginx:nginx --from=frontend-builder /app/src/locales /usr/share/nginx/html/locales
|
||||
COPY --chown=nginx:nginx --from=frontend-builder /app/public/fonts /usr/share/nginx/html/fonts
|
||||
|
||||
COPY --from=production-deps /app/node_modules /app/node_modules
|
||||
COPY --from=native-builder /app/node_modules/bcryptjs /app/node_modules/bcryptjs
|
||||
COPY --from=native-builder /app/node_modules/better-sqlite3 /app/node_modules/better-sqlite3
|
||||
COPY --from=backend-builder /app/dist/backend ./dist/backend
|
||||
|
||||
COPY package.json ./
|
||||
COPY .env ./.env
|
||||
RUN chown -R node:node /app
|
||||
COPY --chown=node:node --from=production-deps /app/node_modules /app/node_modules
|
||||
COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend
|
||||
COPY --chown=node:node package.json ./
|
||||
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
EXPOSE ${PORT} 8081 8082 8083 8084 8085
|
||||
EXPOSE ${PORT} 30001 30002 30003 30004 30005
|
||||
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
CMD ["/entrypoint.sh"]
|
||||
CMD ["/entrypoint.sh"]
|
||||
@@ -1,15 +0,0 @@
|
||||
services:
|
||||
termix:
|
||||
image: ghcr.io/lukegus/termix:latest
|
||||
container_name: termix
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- termix-data:/app/data
|
||||
environment:
|
||||
PORT: "8080"
|
||||
|
||||
volumes:
|
||||
termix-data:
|
||||
driver: local
|
||||
@@ -2,14 +2,95 @@
|
||||
set -e
|
||||
|
||||
export PORT=${PORT:-8080}
|
||||
export ENABLE_SSL=${ENABLE_SSL:-false}
|
||||
export SSL_PORT=${SSL_PORT:-8443}
|
||||
export SSL_CERT_PATH=${SSL_CERT_PATH:-/app/data/ssl/termix.crt}
|
||||
export SSL_KEY_PATH=${SSL_KEY_PATH:-/app/data/ssl/termix.key}
|
||||
|
||||
echo "Configuring web UI to run on port: $PORT"
|
||||
|
||||
envsubst '${PORT}' < /etc/nginx/nginx.conf > /etc/nginx/nginx.conf.tmp
|
||||
if [ "$ENABLE_SSL" = "true" ]; then
|
||||
echo "SSL enabled - using HTTPS configuration with redirect"
|
||||
NGINX_CONF_SOURCE="/etc/nginx/nginx-https.conf"
|
||||
else
|
||||
echo "SSL disabled - using HTTP-only configuration (default)"
|
||||
NGINX_CONF_SOURCE="/etc/nginx/nginx.conf"
|
||||
fi
|
||||
|
||||
envsubst '${PORT} ${SSL_PORT} ${SSL_CERT_PATH} ${SSL_KEY_PATH}' < $NGINX_CONF_SOURCE > /etc/nginx/nginx.conf.tmp
|
||||
mv /etc/nginx/nginx.conf.tmp /etc/nginx/nginx.conf
|
||||
|
||||
mkdir -p /app/data
|
||||
chown -R node:node /app/data
|
||||
chmod 755 /app/data
|
||||
mkdir -p /app/data /app/uploads
|
||||
chown -R node:node /app/data /app/uploads
|
||||
chmod 755 /app/data /app/uploads
|
||||
|
||||
if [ "$ENABLE_SSL" = "true" ]; then
|
||||
echo "Checking SSL certificate configuration..."
|
||||
mkdir -p /app/data/ssl
|
||||
chown -R node:node /app/data/ssl
|
||||
chmod 755 /app/data/ssl
|
||||
|
||||
DOMAIN=${SSL_DOMAIN:-localhost}
|
||||
|
||||
if [ -f "/app/data/ssl/termix.crt" ] && [ -f "/app/data/ssl/termix.key" ]; then
|
||||
echo "SSL certificates found, checking validity..."
|
||||
|
||||
if openssl x509 -in /app/data/ssl/termix.crt -checkend 2592000 -noout >/dev/null 2>&1; then
|
||||
echo "SSL certificates are valid and will be reused for domain: $DOMAIN"
|
||||
else
|
||||
echo "SSL certificate is expired or expiring soon, regenerating..."
|
||||
rm -f /app/data/ssl/termix.crt /app/data/ssl/termix.key
|
||||
fi
|
||||
else
|
||||
echo "SSL certificates not found, will generate new ones..."
|
||||
fi
|
||||
|
||||
if [ ! -f "/app/data/ssl/termix.crt" ] || [ ! -f "/app/data/ssl/termix.key" ]; then
|
||||
echo "Generating SSL certificates for domain: $DOMAIN"
|
||||
|
||||
cat > /app/data/ssl/openssl.conf << EOF
|
||||
[req]
|
||||
default_bits = 2048
|
||||
prompt = no
|
||||
default_md = sha256
|
||||
distinguished_name = dn
|
||||
req_extensions = v3_req
|
||||
|
||||
[dn]
|
||||
C=US
|
||||
ST=State
|
||||
L=City
|
||||
O=Termix
|
||||
OU=IT Department
|
||||
CN=$DOMAIN
|
||||
|
||||
[v3_req]
|
||||
basicConstraints = CA:FALSE
|
||||
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
|
||||
subjectAltName = @alt_names
|
||||
|
||||
[alt_names]
|
||||
DNS.1 = $DOMAIN
|
||||
DNS.2 = localhost
|
||||
DNS.3 = 127.0.0.1
|
||||
IP.1 = 127.0.0.1
|
||||
IP.2 = ::1
|
||||
IP.3 = 0.0.0.0
|
||||
EOF
|
||||
|
||||
openssl genrsa -out /app/data/ssl/termix.key 2048
|
||||
|
||||
openssl req -new -x509 -key /app/data/ssl/termix.key -out /app/data/ssl/termix.crt -days 365 -config /app/data/ssl/openssl.conf -extensions v3_req
|
||||
|
||||
chmod 600 /app/data/ssl/termix.key
|
||||
chmod 644 /app/data/ssl/termix.crt
|
||||
chown node:node /app/data/ssl/termix.key /app/data/ssl/termix.crt
|
||||
|
||||
rm -f /app/data/ssl/openssl.conf
|
||||
|
||||
echo "SSL certificates generated successfully for domain: $DOMAIN"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Starting nginx..."
|
||||
nginx
|
||||
@@ -18,10 +99,21 @@ echo "Starting backend services..."
|
||||
cd /app
|
||||
export NODE_ENV=production
|
||||
|
||||
if command -v su-exec > /dev/null 2>&1; then
|
||||
su-exec node node dist/backend/starter.js
|
||||
if [ -f "package.json" ]; then
|
||||
VERSION=$(grep '"version"' package.json | sed 's/.*"version": *"\([^"]*\)".*/\1/')
|
||||
if [ -n "$VERSION" ]; then
|
||||
export VERSION
|
||||
else
|
||||
echo "Warning: Could not extract version from package.json"
|
||||
fi
|
||||
else
|
||||
su -s /bin/sh node -c "node dist/backend/starter.js"
|
||||
echo "Warning: package.json not found"
|
||||
fi
|
||||
|
||||
if command -v su-exec > /dev/null 2>&1; then
|
||||
su-exec node node dist/backend/backend/starter.js
|
||||
else
|
||||
su -s /bin/sh node -c "node dist/backend/backend/starter.js"
|
||||
fi
|
||||
|
||||
echo "All services started"
|
||||
|
||||
266
docker/nginx-https.conf
Normal file
@@ -0,0 +1,266 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
client_header_timeout 300s;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
server {
|
||||
listen ${PORT};
|
||||
server_name _;
|
||||
|
||||
return 301 https://$host:${SSL_PORT}$request_uri;
|
||||
}
|
||||
|
||||
# HTTPS Server
|
||||
server {
|
||||
listen ${SSL_PORT} ssl;
|
||||
server_name _;
|
||||
|
||||
ssl_certificate ${SSL_CERT_PATH};
|
||||
ssl_certificate_key ${SSL_KEY_PATH};
|
||||
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
}
|
||||
|
||||
# Handle missing source map files gracefully
|
||||
location ~* \.map$ {
|
||||
return 404;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
|
||||
location ~ ^/users(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/version(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/releases(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/alerts(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/credentials(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
location ~ ^/database(/.*)?$ {
|
||||
client_max_body_size 5G;
|
||||
client_body_timeout 300s;
|
||||
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location ~ ^/db(/.*)?$ {
|
||||
client_max_body_size 5G;
|
||||
client_body_timeout 300s;
|
||||
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location ~ ^/encryption(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/websocket/ {
|
||||
proxy_pass http://127.0.0.1:30002/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_connect_timeout 10s;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||
}
|
||||
|
||||
location /ssh/tunnel/ {
|
||||
proxy_pass http://127.0.0.1:30003;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/file_manager/recent {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/file_manager/pinned {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/file_manager/shortcuts {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/file_manager/ssh/ {
|
||||
client_max_body_size 5G;
|
||||
client_body_timeout 300s;
|
||||
|
||||
proxy_pass http://127.0.0.1:30004;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /health {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/status(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30005;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/metrics(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30005;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,18 +8,36 @@ http {
|
||||
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
client_header_timeout 300s;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
server {
|
||||
listen ${PORT};
|
||||
server_name localhost;
|
||||
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
}
|
||||
|
||||
location /users/ {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
# Handle missing source map files gracefully
|
||||
location ~* \.map$ {
|
||||
return 404;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
|
||||
location ~ ^/users(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -27,8 +45,8 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /version/ {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
location ~ ^/version(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -36,8 +54,8 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /releases/ {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
location ~ ^/releases(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -45,8 +63,68 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /alerts/ {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
location ~ ^/alerts(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/credentials(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
location ~ ^/database(/.*)?$ {
|
||||
client_max_body_size 5G;
|
||||
client_body_timeout 300s;
|
||||
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location ~ ^/db(/.*)?$ {
|
||||
client_max_body_size 5G;
|
||||
client_body_timeout 300s;
|
||||
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location ~ ^/encryption(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -55,7 +133,7 @@ http {
|
||||
}
|
||||
|
||||
location /ssh/ {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -64,28 +142,30 @@ http {
|
||||
}
|
||||
|
||||
location /ssh/websocket/ {
|
||||
proxy_pass http://127.0.0.1:8082/;
|
||||
proxy_pass http://127.0.0.1:30002/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_connect_timeout 75s;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_connect_timeout 10s;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||
}
|
||||
|
||||
location /ssh/tunnel/ {
|
||||
proxy_pass http://127.0.0.1:8083;
|
||||
proxy_pass http://127.0.0.1:30003;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -94,7 +174,7 @@ http {
|
||||
}
|
||||
|
||||
location /ssh/file_manager/recent {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -103,7 +183,7 @@ http {
|
||||
}
|
||||
|
||||
location /ssh/file_manager/pinned {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -112,7 +192,7 @@ http {
|
||||
}
|
||||
|
||||
location /ssh/file_manager/shortcuts {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -121,7 +201,26 @@ http {
|
||||
}
|
||||
|
||||
location /ssh/file_manager/ssh/ {
|
||||
proxy_pass http://127.0.0.1:8084;
|
||||
client_max_body_size 5G;
|
||||
client_body_timeout 300s;
|
||||
|
||||
proxy_pass http://127.0.0.1:30004;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /health {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -129,8 +228,8 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /status/ {
|
||||
proxy_pass http://127.0.0.1:8085;
|
||||
location ~ ^/status(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30005;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -138,8 +237,8 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /metrics/ {
|
||||
proxy_pass http://127.0.0.1:8085;
|
||||
location ~ ^/metrics(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30005;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
44
electron-builder.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"appId": "com.termix.app",
|
||||
"productName": "Termix",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"electron/**/*",
|
||||
"public/**/*",
|
||||
"!**/node_modules/**/*",
|
||||
"!src/**/*",
|
||||
"!*.md",
|
||||
"!tsconfig*.json",
|
||||
"!vite.config.ts",
|
||||
"!eslint.config.js"
|
||||
],
|
||||
"asarUnpack": ["node_modules/node-fetch/**/*"],
|
||||
"extraMetadata": {
|
||||
"main": "electron/main.cjs"
|
||||
},
|
||||
"buildDependenciesFromSource": false,
|
||||
"nodeGypRebuild": false,
|
||||
"npmRebuild": false,
|
||||
"win": {
|
||||
"target": "nsis",
|
||||
"icon": "public/icon.ico",
|
||||
"executableName": "Termix"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"artifactName": "${productName}-Setup-${version}.${ext}",
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true,
|
||||
"shortcutName": "Termix",
|
||||
"uninstallDisplayName": "Termix"
|
||||
},
|
||||
"linux": {
|
||||
"target": "AppImage",
|
||||
"icon": "public/icon.png",
|
||||
"category": "Development"
|
||||
}
|
||||
}
|
||||
487
electron/main.cjs
Normal file
@@ -0,0 +1,487 @@
|
||||
const { app, BrowserWindow, shell, ipcMain, dialog } = require("electron");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
|
||||
app.commandLine.appendSwitch("--ignore-certificate-errors");
|
||||
app.commandLine.appendSwitch("--ignore-ssl-errors");
|
||||
app.commandLine.appendSwitch("--ignore-certificate-errors-spki-list");
|
||||
app.commandLine.appendSwitch("--enable-features=NetworkService");
|
||||
|
||||
let mainWindow = null;
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
|
||||
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
if (!gotTheLock) {
|
||||
console.log("Another instance is already running, quitting...");
|
||||
app.quit();
|
||||
process.exit(0);
|
||||
} else {
|
||||
app.on("second-instance", (event, commandLine, workingDirectory) => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.focus();
|
||||
mainWindow.show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
title: "Termix",
|
||||
icon: isDev
|
||||
? path.join(__dirname, "..", "public", "icon.png")
|
||||
: path.join(process.resourcesPath, "public", "icon.png"),
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
webSecurity: true,
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
},
|
||||
show: false,
|
||||
});
|
||||
|
||||
if (process.platform !== "darwin") {
|
||||
mainWindow.setMenuBarVisibility(false);
|
||||
}
|
||||
|
||||
if (isDev) {
|
||||
mainWindow.loadURL("http://localhost:5173");
|
||||
mainWindow.webContents.openDevTools();
|
||||
} else {
|
||||
const indexPath = path.join(__dirname, "..", "dist", "index.html");
|
||||
mainWindow.loadFile(indexPath);
|
||||
}
|
||||
|
||||
mainWindow.once("ready-to-show", () => {
|
||||
mainWindow.show();
|
||||
});
|
||||
|
||||
mainWindow.webContents.on(
|
||||
"did-fail-load",
|
||||
(event, errorCode, errorDescription, validatedURL) => {
|
||||
console.error(
|
||||
"Failed to load:",
|
||||
errorCode,
|
||||
errorDescription,
|
||||
validatedURL,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
mainWindow.webContents.on("did-finish-load", () => {
|
||||
console.log("Frontend loaded successfully");
|
||||
});
|
||||
|
||||
mainWindow.on("close", (event) => {
|
||||
if (process.platform === "darwin") {
|
||||
event.preventDefault();
|
||||
mainWindow.hide();
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
}
|
||||
|
||||
ipcMain.handle("get-app-version", () => {
|
||||
return app.getVersion();
|
||||
});
|
||||
|
||||
const GITHUB_API_BASE = "https://api.github.com";
|
||||
const REPO_OWNER = "LukeGus";
|
||||
const REPO_NAME = "Termix";
|
||||
|
||||
const githubCache = new Map();
|
||||
const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
async function fetchGitHubAPI(endpoint, cacheKey) {
|
||||
const cached = githubCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
|
||||
return {
|
||||
data: cached.data,
|
||||
cached: true,
|
||||
cache_age: Date.now() - cached.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
let fetch;
|
||||
try {
|
||||
fetch = globalThis.fetch || require("node-fetch");
|
||||
} catch (e) {
|
||||
const https = require("https");
|
||||
const http = require("http");
|
||||
const { URL } = require("url");
|
||||
|
||||
fetch = (url, options = {}) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const urlObj = new URL(url);
|
||||
const isHttps = urlObj.protocol === "https:";
|
||||
const client = isHttps ? https : http;
|
||||
|
||||
const requestOptions = {
|
||||
method: options.method || "GET",
|
||||
headers: options.headers || {},
|
||||
timeout: options.timeout || 10000,
|
||||
};
|
||||
|
||||
if (isHttps) {
|
||||
requestOptions.rejectUnauthorized = false;
|
||||
requestOptions.agent = new https.Agent({
|
||||
rejectUnauthorized: false,
|
||||
secureProtocol: "TLSv1_2_method",
|
||||
checkServerIdentity: () => undefined,
|
||||
ciphers: "ALL:!ADH:!LOW:!EXP:!MD5:@STRENGTH",
|
||||
honorCipherOrder: true,
|
||||
});
|
||||
}
|
||||
|
||||
const req = client.request(url, requestOptions, (res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => {
|
||||
resolve({
|
||||
ok: res.statusCode >= 200 && res.statusCode < 300,
|
||||
status: res.statusCode,
|
||||
text: () => Promise.resolve(data),
|
||||
json: () => Promise.resolve(JSON.parse(data)),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject(new Error("Request timeout"));
|
||||
});
|
||||
|
||||
if (options.body) {
|
||||
req.write(options.body);
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": "TermixElectronUpdateChecker/1.0",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`GitHub API error: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
githubCache.set(cacheKey, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return {
|
||||
data: data,
|
||||
cached: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch from GitHub API:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle("check-electron-update", async () => {
|
||||
try {
|
||||
const localVersion = app.getVersion();
|
||||
|
||||
const releaseData = await fetchGitHubAPI(
|
||||
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
|
||||
"latest_release_electron",
|
||||
);
|
||||
|
||||
const rawTag = releaseData.data.tag_name || releaseData.data.name || "";
|
||||
const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/);
|
||||
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
|
||||
|
||||
if (!remoteVersion) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Remote version not found",
|
||||
localVersion,
|
||||
};
|
||||
}
|
||||
|
||||
const isUpToDate = localVersion === remoteVersion;
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
status: isUpToDate ? "up_to_date" : "requires_update",
|
||||
localVersion: localVersion,
|
||||
remoteVersion: remoteVersion,
|
||||
latest_release: {
|
||||
tag_name: releaseData.data.tag_name,
|
||||
name: releaseData.data.name,
|
||||
published_at: releaseData.data.published_at,
|
||||
html_url: releaseData.data.html_url,
|
||||
body: releaseData.data.body,
|
||||
},
|
||||
cached: releaseData.cached,
|
||||
cache_age: releaseData.cache_age,
|
||||
};
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
localVersion: app.getVersion(),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("get-platform", () => {
|
||||
return process.platform;
|
||||
});
|
||||
|
||||
ipcMain.handle("get-server-config", () => {
|
||||
try {
|
||||
const userDataPath = app.getPath("userData");
|
||||
const configPath = path.join(userDataPath, "server-config.json");
|
||||
|
||||
if (fs.existsSync(configPath)) {
|
||||
const configData = fs.readFileSync(configPath, "utf8");
|
||||
return JSON.parse(configData);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Error reading server config:", error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("save-server-config", (event, config) => {
|
||||
try {
|
||||
const userDataPath = app.getPath("userData");
|
||||
const configPath = path.join(userDataPath, "server-config.json");
|
||||
|
||||
if (!fs.existsSync(userDataPath)) {
|
||||
fs.mkdirSync(userDataPath, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error saving server config:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("test-server-connection", async (event, serverUrl) => {
|
||||
try {
|
||||
const https = require("https");
|
||||
const http = require("http");
|
||||
const { URL } = require("url");
|
||||
|
||||
const fetch = (url, options = {}) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const urlObj = new URL(url);
|
||||
const isHttps = urlObj.protocol === "https:";
|
||||
const client = isHttps ? https : http;
|
||||
|
||||
const requestOptions = {
|
||||
method: options.method || "GET",
|
||||
headers: options.headers || {},
|
||||
timeout: options.timeout || 10000,
|
||||
};
|
||||
|
||||
if (isHttps) {
|
||||
requestOptions.rejectUnauthorized = false;
|
||||
requestOptions.agent = new https.Agent({
|
||||
rejectUnauthorized: false,
|
||||
secureProtocol: "TLSv1_2_method",
|
||||
checkServerIdentity: () => undefined,
|
||||
ciphers: "ALL:!ADH:!LOW:!EXP:!MD5:@STRENGTH",
|
||||
honorCipherOrder: true,
|
||||
});
|
||||
}
|
||||
|
||||
const req = client.request(url, requestOptions, (res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => {
|
||||
resolve({
|
||||
ok: res.statusCode >= 200 && res.statusCode < 300,
|
||||
status: res.statusCode,
|
||||
text: () => Promise.resolve(data),
|
||||
json: () => Promise.resolve(JSON.parse(data)),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject(new Error("Request timeout"));
|
||||
});
|
||||
|
||||
if (options.body) {
|
||||
req.write(options.body);
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
};
|
||||
|
||||
const normalizedServerUrl = serverUrl.replace(/\/$/, "");
|
||||
|
||||
const healthUrl = `${normalizedServerUrl}/health`;
|
||||
|
||||
try {
|
||||
const response = await fetch(healthUrl, {
|
||||
method: "GET",
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.text();
|
||||
|
||||
if (
|
||||
data.includes("<html") ||
|
||||
data.includes("<!DOCTYPE") ||
|
||||
data.includes("<head>") ||
|
||||
data.includes("<body>")
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Server returned HTML instead of JSON. This does not appear to be a Termix server.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const healthData = JSON.parse(data);
|
||||
if (
|
||||
healthData &&
|
||||
(healthData.status === "ok" ||
|
||||
healthData.status === "healthy" ||
|
||||
healthData.healthy === true ||
|
||||
healthData.database === "connected")
|
||||
) {
|
||||
return {
|
||||
success: true,
|
||||
status: response.status,
|
||||
testedUrl: healthUrl,
|
||||
};
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.log("Health endpoint did not return valid JSON");
|
||||
}
|
||||
}
|
||||
} catch (urlError) {
|
||||
console.error("Health check failed:", urlError);
|
||||
}
|
||||
|
||||
try {
|
||||
const versionUrl = `${normalizedServerUrl}/version`;
|
||||
const response = await fetch(versionUrl, {
|
||||
method: "GET",
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.text();
|
||||
|
||||
if (
|
||||
data.includes("<html") ||
|
||||
data.includes("<!DOCTYPE") ||
|
||||
data.includes("<head>") ||
|
||||
data.includes("<body>")
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Server returned HTML instead of JSON. This does not appear to be a Termix server.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const versionData = JSON.parse(data);
|
||||
if (
|
||||
versionData &&
|
||||
(versionData.status === "up_to_date" ||
|
||||
versionData.status === "requires_update" ||
|
||||
(versionData.localVersion &&
|
||||
versionData.version &&
|
||||
versionData.latest_release))
|
||||
) {
|
||||
return {
|
||||
success: true,
|
||||
status: response.status,
|
||||
testedUrl: versionUrl,
|
||||
warning:
|
||||
"Health endpoint not available, but server appears to be running",
|
||||
};
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.log("Version endpoint did not return valid JSON");
|
||||
}
|
||||
}
|
||||
} catch (versionError) {
|
||||
console.error("Version check failed:", versionError);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Server is not responding or does not appear to be a valid Termix server. Please ensure the server is running and accessible.",
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createWindow();
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
} else if (mainWindow) {
|
||||
mainWindow.show();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("will-quit", () => {
|
||||
console.log("App will quit...");
|
||||
});
|
||||
|
||||
process.on("uncaughtException", (error) => {
|
||||
console.error("Uncaught Exception:", error);
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
console.error("Unhandled Rejection at:", promise, "reason:", reason);
|
||||
});
|
||||
28
electron/preload.js
Normal file
@@ -0,0 +1,28 @@
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
getAppVersion: () => ipcRenderer.invoke("get-app-version"),
|
||||
getPlatform: () => ipcRenderer.invoke("get-platform"),
|
||||
checkElectronUpdate: () => ipcRenderer.invoke("check-electron-update"),
|
||||
|
||||
getServerConfig: () => ipcRenderer.invoke("get-server-config"),
|
||||
saveServerConfig: (config) =>
|
||||
ipcRenderer.invoke("save-server-config", config),
|
||||
testServerConnection: (serverUrl) =>
|
||||
ipcRenderer.invoke("test-server-connection", serverUrl),
|
||||
|
||||
showSaveDialog: (options) => ipcRenderer.invoke("show-save-dialog", options),
|
||||
showOpenDialog: (options) => ipcRenderer.invoke("show-open-dialog", options),
|
||||
|
||||
onUpdateAvailable: (callback) => ipcRenderer.on("update-available", callback),
|
||||
onUpdateDownloaded: (callback) =>
|
||||
ipcRenderer.on("update-downloaded", callback),
|
||||
|
||||
removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel),
|
||||
isElectron: true,
|
||||
isDev: process.env.NODE_ENV === "development",
|
||||
|
||||
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
|
||||
});
|
||||
|
||||
window.IS_ELECTRON = true;
|
||||
@@ -1,18 +1,18 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { globalIgnores } from "eslint/config";
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
globalIgnores(["dist"]),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactHooks.configs["recommended-latest"],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
@@ -20,4 +20,4 @@ export default tseslint.config([
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
]);
|
||||
|
||||
2305
openapi.json
Normal file
10720
package-lock.json
generated
64
package.json
@@ -1,22 +1,35 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.7.0",
|
||||
"description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
|
||||
"author": "Karmaa",
|
||||
"main": "electron/main.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "npx prettier . --write",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build": "vite build && tsc -p tsconfig.node.json",
|
||||
"build:backend": "tsc -p tsconfig.node.json",
|
||||
"dev:backend": "tsc -p tsconfig.node.json && node ./dist/backend/starter.js",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"dev:backend": "tsc -p tsconfig.node.json && node ./dist/backend/backend/starter.js",
|
||||
"preview": "vite preview",
|
||||
"electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:5173 && electron .\"",
|
||||
"build:win-portable": "npm run build && electron-builder --win --dir",
|
||||
"build:win-installer": "npm run build && electron-builder --win --publish=never",
|
||||
"build:linux-portable": "npm run build && electron-builder --linux --dir",
|
||||
"test:encryption": "tsc -p tsconfig.node.json && node ./dist/backend/backend/utils/encryption-test.js",
|
||||
"migrate:encryption": "tsc -p tsconfig.node.json && node ./dist/backend/backend/utils/encryption-migration.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.18.7",
|
||||
"@codemirror/commands": "^6.3.3",
|
||||
"@codemirror/search": "^6.5.11",
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"@codemirror/view": "^6.23.1",
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
@@ -25,28 +38,28 @@
|
||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slider": "^1.3.5",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cookie-parser": "^1.4.9",
|
||||
"@types/jszip": "^3.4.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@uiw/codemirror-extensions-hyper-link": "^4.24.1",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/speakeasy": "^2.0.10",
|
||||
"@uiw/codemirror-extensions-langs": "^4.24.1",
|
||||
"@uiw/codemirror-themes": "^4.24.1",
|
||||
"@uiw/react-codemirror": "^4.24.1",
|
||||
"@xterm/addon-attach": "^0.11.0",
|
||||
"@xterm/addon-clipboard": "^0.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-unicode11": "^0.8.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"axios": "^1.10.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"body-parser": "^1.20.2",
|
||||
"chalk": "^4.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -55,23 +68,37 @@
|
||||
"dotenv": "^17.2.0",
|
||||
"drizzle-orm": "^0.44.3",
|
||||
"express": "^5.1.0",
|
||||
"i18next": "^25.4.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"jose": "^5.2.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.525.0",
|
||||
"multer": "^2.0.2",
|
||||
"nanoid": "^5.1.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-fetch": "^3.3.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-h5-audio-player": "^3.10.1",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"react-i18next": "^15.7.3",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-pdf": "^10.1.0",
|
||||
"react-photo-view": "^1.2.7",
|
||||
"react-player": "^3.3.3",
|
||||
"react-resizable-panels": "^3.0.3",
|
||||
"react-simple-keyboard": "^3.8.120",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"react-xtermjs": "^1.0.10",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"speakeasy": "^2.0.0",
|
||||
"ssh2": "^1.16.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"validator": "^13.15.15",
|
||||
"wait-on": "^9.0.1",
|
||||
"ws": "^8.18.3",
|
||||
"zod": "^4.0.5"
|
||||
},
|
||||
@@ -87,15 +114,16 @@
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"concurrently": "^9.2.1",
|
||||
"electron": "^38.0.0",
|
||||
"electron-builder": "^26.0.12",
|
||||
"eslint": "^9.34.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "~5.9.2",
|
||||
"typescript-eslint": "^8.40.0",
|
||||
"vite": "^7.1.3"
|
||||
"vite": "^7.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 168 KiB |
BIN
public/icon.icns
Normal file
BIN
public/icon.ico
Normal file
|
After Width: | Height: | Size: 353 KiB |
BIN
public/icon.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 6.1 KiB |
BIN
public/icons/1024x1024.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
public/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
public/icons/16x16.png
Normal file
|
After Width: | Height: | Size: 483 B |
BIN
public/icons/24x24.png
Normal file
|
After Width: | Height: | Size: 749 B |
BIN
public/icons/256x256.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
public/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 986 B |
BIN
public/icons/48x48.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/icons/512x512.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
public/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
public/icons/icon.icns
Normal file
BIN
public/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 353 KiB |
58128
public/pdf.worker.min.js
vendored
Normal file
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 776 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 309 KiB |
|
Before Width: | Height: | Size: 311 KiB After Width: | Height: | Size: 418 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 780 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 305 KiB |
BIN
repo-images/Image 6.png
Normal file
|
After Width: | Height: | Size: 360 KiB |
BIN
repo-images/RepoOfTheDay.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
103
scripts/enable-ssl.sh
Normal file
@@ -0,0 +1,103 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
ENV_FILE="$PROJECT_ROOT/.env"
|
||||
|
||||
log_info() {
|
||||
echo -e "${BLUE}[SSL Setup]${NC} $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[SSL Setup]${NC} $1"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[SSL Setup]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[SSL Setup]${NC} $1"
|
||||
}
|
||||
|
||||
log_header() {
|
||||
echo -e "${CYAN}$1${NC}"
|
||||
}
|
||||
|
||||
generate_keys() {
|
||||
log_info "Generating security keys..."
|
||||
|
||||
JWT_SECRET=$(openssl rand -hex 32)
|
||||
log_success "Generated JWT secret"
|
||||
|
||||
DATABASE_KEY=$(openssl rand -hex 32)
|
||||
log_success "Generated database encryption key"
|
||||
|
||||
echo "JWT_SECRET=$JWT_SECRET" >> "$ENV_FILE"
|
||||
echo "DATABASE_KEY=$DATABASE_KEY" >> "$ENV_FILE"
|
||||
|
||||
log_success "Security keys added to .env file"
|
||||
}
|
||||
|
||||
setup_env_file() {
|
||||
log_info "Setting up environment configuration..."
|
||||
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
log_warn ".env file already exists, creating backup..."
|
||||
cp "$ENV_FILE" "$ENV_FILE.backup.$(date +%s)"
|
||||
fi
|
||||
|
||||
cat > "$ENV_FILE" << EOF
|
||||
# Termix SSL Configuration - Auto-generated $(date)
|
||||
|
||||
# SSL/TLS Configuration
|
||||
ENABLE_SSL=true
|
||||
SSL_PORT=8443
|
||||
SSL_DOMAIN=localhost
|
||||
PORT=8080
|
||||
|
||||
# Node environment
|
||||
NODE_ENV=production
|
||||
|
||||
# CORS configuration
|
||||
ALLOWED_ORIGINS=*
|
||||
|
||||
EOF
|
||||
|
||||
generate_keys
|
||||
|
||||
log_success "Environment configuration created at $ENV_FILE"
|
||||
}
|
||||
|
||||
setup_ssl_certificates() {
|
||||
log_info "Setting up SSL certificates..."
|
||||
|
||||
if [[ -f "$SCRIPT_DIR/setup-ssl.sh" ]]; then
|
||||
bash "$SCRIPT_DIR/setup-ssl.sh"
|
||||
else
|
||||
log_error "SSL setup script not found at $SCRIPT_DIR/setup-ssl.sh"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
if ! command -v openssl &> /dev/null; then
|
||||
log_error "OpenSSL is not installed. Please install OpenSSL first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
setup_env_file
|
||||
setup_ssl_certificates
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
121
scripts/setup-ssl.sh
Normal file
@@ -0,0 +1,121 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
SSL_DIR="$(dirname "$0")/../ssl"
|
||||
CERT_FILE="$SSL_DIR/termix.crt"
|
||||
KEY_FILE="$SSL_DIR/termix.key"
|
||||
DAYS_VALID=365
|
||||
|
||||
DOMAIN=${SSL_DOMAIN:-"localhost"}
|
||||
ALT_NAMES=${SSL_ALT_NAMES:-"DNS:localhost,DNS:127.0.0.1,DNS:*.localhost,IP:127.0.0.1"}
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
log_info() {
|
||||
echo -e "${BLUE}[SSL Setup]${NC} $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[SSL Setup]${NC} $1"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[SSL Setup]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[SSL Setup]${NC} $1"
|
||||
}
|
||||
|
||||
check_existing_cert() {
|
||||
if [[ -f "$CERT_FILE" && -f "$KEY_FILE" ]]; then
|
||||
if openssl x509 -in "$CERT_FILE" -checkend 2592000 -noout 2>/dev/null; then
|
||||
log_success "Valid SSL certificate already exists"
|
||||
|
||||
local expiry=$(openssl x509 -in "$CERT_FILE" -noout -enddate 2>/dev/null | cut -d= -f2)
|
||||
log_info "Expires: $expiry"
|
||||
return 0
|
||||
else
|
||||
log_warn "Existing certificate is expired or expiring soon"
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
generate_certificate() {
|
||||
log_info "Generating new SSL certificate for domain: $DOMAIN"
|
||||
|
||||
mkdir -p "$SSL_DIR"
|
||||
|
||||
local config_file="$SSL_DIR/openssl.conf"
|
||||
cat > "$config_file" << EOF
|
||||
[req]
|
||||
default_bits = 2048
|
||||
prompt = no
|
||||
default_md = sha256
|
||||
distinguished_name = dn
|
||||
req_extensions = v3_req
|
||||
|
||||
[dn]
|
||||
C=US
|
||||
ST=State
|
||||
L=City
|
||||
O=Termix
|
||||
OU=IT Department
|
||||
CN=$DOMAIN
|
||||
|
||||
[v3_req]
|
||||
basicConstraints = CA:FALSE
|
||||
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
|
||||
subjectAltName = @alt_names
|
||||
|
||||
[alt_names]
|
||||
DNS.1 = localhost
|
||||
DNS.2 = 127.0.0.1
|
||||
DNS.3 = *.localhost
|
||||
IP.1 = 127.0.0.1
|
||||
EOF
|
||||
|
||||
if [[ -n "$SSL_ALT_NAMES" ]]; then
|
||||
local counter=2
|
||||
IFS=',' read -ra NAMES <<< "$SSL_ALT_NAMES"
|
||||
for name in "${NAMES[@]}"; do
|
||||
name=$(echo "$name" | xargs)
|
||||
if [[ "$name" == DNS:* ]]; then
|
||||
echo "DNS.$((counter++)) = ${name#DNS:}" >> "$config_file"
|
||||
elif [[ "$name" == IP:* ]]; then
|
||||
echo "IP.$((counter++)) = ${name#IP:}" >> "$config_file"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
log_info "Generating private key..."
|
||||
openssl genrsa -out "$KEY_FILE" 2048
|
||||
|
||||
log_info "Generating certificate..."
|
||||
openssl req -new -x509 -key "$KEY_FILE" -out "$CERT_FILE" -days $DAYS_VALID -config "$config_file" -extensions v3_req
|
||||
|
||||
chmod 600 "$KEY_FILE"
|
||||
chmod 644 "$CERT_FILE"
|
||||
|
||||
rm -f "$config_file"
|
||||
|
||||
log_success "SSL certificate generated successfully"
|
||||
log_info "Valid for: $DAYS_VALID days"
|
||||
}
|
||||
|
||||
main() {
|
||||
if ! command -v openssl &> /dev/null; then
|
||||
log_error "OpenSSL is not installed. Please install OpenSSL first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
generate_certificate
|
||||
}
|
||||
|
||||
main "$@"
|
||||
212
src/App.tsx
@@ -1,212 +0,0 @@
|
||||
import React, {useState, useEffect} from "react"
|
||||
import {LeftSidebar} from "@/ui/Navigation/LeftSidebar.tsx"
|
||||
import {Homepage} from "@/ui/Homepage/Homepage.tsx"
|
||||
import {AppView} from "@/ui/Navigation/AppView.tsx"
|
||||
import {HostManager} from "@/ui/apps/Host Manager/HostManager.tsx"
|
||||
import {TabProvider, useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx"
|
||||
import {TopNavbar} from "@/ui/Navigation/TopNavbar.tsx";
|
||||
import { AdminSettings } from "@/ui/Admin/AdminSettings";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { getUserInfo } from "@/ui/main-axios.ts";
|
||||
|
||||
function getCookie(name: string) {
|
||||
return document.cookie.split('; ').reduce((r, v) => {
|
||||
const parts = v.split('=');
|
||||
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
|
||||
}, "");
|
||||
}
|
||||
|
||||
function setCookie(name: string, value: string, days = 7) {
|
||||
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
||||
}
|
||||
|
||||
function AppContent() {
|
||||
const [view, setView] = useState<string>("homepage")
|
||||
const [mountedViews, setMountedViews] = useState<Set<string>>(new Set(["homepage"]))
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
const [username, setUsername] = useState<string | null>(null)
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
const [authLoading, setAuthLoading] = useState(true)
|
||||
const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(true)
|
||||
const {currentTab, tabs} = useTabs();
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
const jwt = getCookie("jwt");
|
||||
if (jwt) {
|
||||
setAuthLoading(true);
|
||||
getUserInfo()
|
||||
.then((meRes) => {
|
||||
setIsAuthenticated(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
})
|
||||
.catch((err) => {
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
})
|
||||
.finally(() => setAuthLoading(false));
|
||||
} else {
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setAuthLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
checkAuth()
|
||||
|
||||
const handleStorageChange = () => checkAuth()
|
||||
window.addEventListener('storage', handleStorageChange)
|
||||
|
||||
return () => window.removeEventListener('storage', handleStorageChange)
|
||||
}, [])
|
||||
|
||||
const handleSelectView = (nextView: string) => {
|
||||
setMountedViews((prev) => {
|
||||
if (prev.has(nextView)) return prev
|
||||
const next = new Set(prev)
|
||||
next.add(nextView)
|
||||
return next
|
||||
})
|
||||
setView(nextView)
|
||||
}
|
||||
|
||||
const handleAuthSuccess = (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => {
|
||||
setIsAuthenticated(true)
|
||||
setIsAdmin(authData.isAdmin)
|
||||
setUsername(authData.username)
|
||||
}
|
||||
|
||||
const currentTabData = tabs.find(tab => tab.id === currentTab);
|
||||
const showTerminalView = currentTabData?.type === 'terminal' || currentTabData?.type === 'server' || currentTabData?.type === 'file_manager';
|
||||
const showHome = currentTabData?.type === 'home';
|
||||
const showSshManager = currentTabData?.type === 'ssh_manager';
|
||||
const showAdmin = currentTabData?.type === 'admin';
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!isAuthenticated && !authLoading && (
|
||||
<div>
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: `linear-gradient(
|
||||
135deg,
|
||||
transparent 0%,
|
||||
transparent 49%,
|
||||
rgba(255, 255, 255, 0.03) 49%,
|
||||
rgba(255, 255, 255, 0.03) 51%,
|
||||
transparent 51%,
|
||||
transparent 100%
|
||||
)`,
|
||||
backgroundSize: '80px 80px'
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAuthenticated && !authLoading && (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[10000]">
|
||||
<Homepage
|
||||
onSelectView={handleSelectView}
|
||||
isAuthenticated={isAuthenticated}
|
||||
authLoading={authLoading}
|
||||
onAuthSuccess={handleAuthSuccess}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAuthenticated && (
|
||||
<LeftSidebar
|
||||
onSelectView={handleSelectView}
|
||||
disabled={!isAuthenticated || authLoading}
|
||||
isAdmin={isAdmin}
|
||||
username={username}
|
||||
>
|
||||
<div
|
||||
className="h-screen w-full"
|
||||
style={{
|
||||
visibility: showTerminalView ? "visible" : "hidden",
|
||||
pointerEvents: showTerminalView ? "auto" : "none",
|
||||
height: showTerminalView ? "100vh" : 0,
|
||||
width: showTerminalView ? "100%" : 0,
|
||||
position: showTerminalView ? "static" : "absolute",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<AppView isTopbarOpen={isTopbarOpen} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h-screen w-full"
|
||||
style={{
|
||||
visibility: showHome ? "visible" : "hidden",
|
||||
pointerEvents: showHome ? "auto" : "none",
|
||||
height: showHome ? "100vh" : 0,
|
||||
width: showHome ? "100%" : 0,
|
||||
position: showHome ? "static" : "absolute",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Homepage
|
||||
onSelectView={handleSelectView}
|
||||
isAuthenticated={isAuthenticated}
|
||||
authLoading={authLoading}
|
||||
onAuthSuccess={handleAuthSuccess}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h-screen w-full"
|
||||
style={{
|
||||
visibility: showSshManager ? "visible" : "hidden",
|
||||
pointerEvents: showSshManager ? "auto" : "none",
|
||||
height: showSshManager ? "100vh" : 0,
|
||||
width: showSshManager ? "100%" : 0,
|
||||
position: showSshManager ? "static" : "absolute",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<HostManager onSelectView={handleSelectView} isTopbarOpen={isTopbarOpen} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h-screen w-full"
|
||||
style={{
|
||||
visibility: showAdmin ? "visible" : "hidden",
|
||||
pointerEvents: showAdmin ? "auto" : "none",
|
||||
height: showAdmin ? "100vh" : 0,
|
||||
width: showAdmin ? "100%" : 0,
|
||||
position: showAdmin ? "static" : "absolute",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<AdminSettings isTopbarOpen={isTopbarOpen} />
|
||||
</div>
|
||||
|
||||
<TopNavbar isTopbarOpen={isTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}/>
|
||||
</LeftSidebar>
|
||||
)}
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
richColors={false}
|
||||
closeButton
|
||||
duration={5000}
|
||||
offset={20}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<TabProvider>
|
||||
<AppContent />
|
||||
</TabProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -1,450 +1,544 @@
|
||||
import {drizzle} from 'drizzle-orm/better-sqlite3';
|
||||
import Database from 'better-sqlite3';
|
||||
import * as schema from './schema.js';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import Database from "better-sqlite3";
|
||||
import * as schema from "./schema.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { DatabaseFileEncryption } from "../../utils/database-file-encryption.js";
|
||||
import { SystemCrypto } from "../../utils/system-crypto.js";
|
||||
import { DatabaseMigration } from "../../utils/database-migration.js";
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
|
||||
const dbIconSymbol = '🗄️';
|
||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${dbIconSymbol}]`)} ${message}`;
|
||||
};
|
||||
const logger = {
|
||||
info: (msg: string): void => {
|
||||
console.log(formatMessage('info', chalk.cyan, msg));
|
||||
},
|
||||
warn: (msg: string): void => {
|
||||
console.warn(formatMessage('warn', chalk.yellow, msg));
|
||||
},
|
||||
error: (msg: string, err?: unknown): void => {
|
||||
console.error(formatMessage('error', chalk.redBright, msg));
|
||||
if (err) console.error(err);
|
||||
},
|
||||
success: (msg: string): void => {
|
||||
console.log(formatMessage('success', chalk.greenBright, msg));
|
||||
},
|
||||
debug: (msg: string): void => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.debug(formatMessage('debug', chalk.magenta, msg));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dataDir = process.env.DATA_DIR || './db/data';
|
||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||
const dbDir = path.resolve(dataDir);
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, {recursive: true});
|
||||
databaseLogger.info(`Creating database directory`, {
|
||||
operation: "db_init",
|
||||
path: dbDir,
|
||||
});
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
const dbPath = path.join(dataDir, 'db.sqlite');
|
||||
const sqlite = new Database(dbPath);
|
||||
const enableFileEncryption = process.env.DB_FILE_ENCRYPTION !== "false";
|
||||
const dbPath = path.join(dataDir, "db.sqlite");
|
||||
const encryptedDbPath = `${dbPath}.encrypted`;
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users
|
||||
(
|
||||
id
|
||||
TEXT
|
||||
PRIMARY
|
||||
KEY,
|
||||
username
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
password_hash
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
is_admin
|
||||
INTEGER
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
0,
|
||||
let actualDbPath = ":memory:";
|
||||
let memoryDatabase: Database.Database;
|
||||
let isNewDatabase = false;
|
||||
let sqlite: Database.Database;
|
||||
|
||||
is_oidc
|
||||
INTEGER
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
0,
|
||||
client_id
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
client_secret
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
issuer_url
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
authorization_url
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
token_url
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
redirect_uri
|
||||
TEXT,
|
||||
identifier_path
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
name_path
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
scopes
|
||||
TEXT
|
||||
NOT
|
||||
NULL
|
||||
async function initializeDatabaseAsync(): Promise<void> {
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
|
||||
const dbKey = await systemCrypto.getDatabaseKey();
|
||||
if (enableFileEncryption) {
|
||||
try {
|
||||
if (DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)) {
|
||||
const decryptedBuffer =
|
||||
await DatabaseFileEncryption.decryptDatabaseToBuffer(encryptedDbPath);
|
||||
|
||||
memoryDatabase = new Database(decryptedBuffer);
|
||||
} else {
|
||||
const migration = new DatabaseMigration(dataDir);
|
||||
const migrationStatus = migration.checkMigrationStatus();
|
||||
|
||||
if (migrationStatus.needsMigration) {
|
||||
const migrationResult = await migration.migrateDatabase();
|
||||
|
||||
if (migrationResult.success) {
|
||||
migration.cleanupOldBackups();
|
||||
|
||||
if (
|
||||
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)
|
||||
) {
|
||||
const decryptedBuffer =
|
||||
await DatabaseFileEncryption.decryptDatabaseToBuffer(
|
||||
encryptedDbPath,
|
||||
);
|
||||
memoryDatabase = new Database(decryptedBuffer);
|
||||
isNewDatabase = false;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Migration completed but encrypted database file not found",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
databaseLogger.error("Automatic database migration failed", null, {
|
||||
operation: "auto_migration_failed",
|
||||
error: migrationResult.error,
|
||||
migratedTables: migrationResult.migratedTables,
|
||||
migratedRows: migrationResult.migratedRows,
|
||||
duration: migrationResult.duration,
|
||||
backupPath: migrationResult.backupPath,
|
||||
});
|
||||
throw new Error(
|
||||
`Database migration failed: ${migrationResult.error}. Backup available at: ${migrationResult.backupPath}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
memoryDatabase = new Database(":memory:");
|
||||
isNewDatabase = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to initialize memory database", error, {
|
||||
operation: "db_memory_init_failed",
|
||||
errorMessage: error instanceof Error ? error.message : "Unknown error",
|
||||
errorStack: error instanceof Error ? error.stack : undefined,
|
||||
encryptedDbExists:
|
||||
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
|
||||
databaseKeyAvailable: !!process.env.DATABASE_KEY,
|
||||
databaseKeyLength: process.env.DATABASE_KEY?.length || 0,
|
||||
});
|
||||
|
||||
throw new Error(
|
||||
`Database decryption failed: ${error instanceof Error ? error.message : "Unknown error"}. This prevents data loss.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
memoryDatabase = new Database(":memory:");
|
||||
isNewDatabase = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeCompleteDatabase(): Promise<void> {
|
||||
await initializeDatabaseAsync();
|
||||
|
||||
databaseLogger.info(`Initializing SQLite database`, {
|
||||
operation: "db_init",
|
||||
path: actualDbPath,
|
||||
encrypted:
|
||||
enableFileEncryption &&
|
||||
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
|
||||
inMemory: true,
|
||||
isNewDatabase,
|
||||
});
|
||||
|
||||
sqlite = memoryDatabase;
|
||||
|
||||
db = drizzle(sqlite, { schema });
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0,
|
||||
oidc_identifier TEXT,
|
||||
client_id TEXT,
|
||||
client_secret TEXT,
|
||||
issuer_url TEXT,
|
||||
authorization_url TEXT,
|
||||
token_url TEXT,
|
||||
identifier_path TEXT,
|
||||
name_path TEXT,
|
||||
scopes TEXT DEFAULT 'openid email profile',
|
||||
totp_secret TEXT,
|
||||
totp_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
totp_backup_codes TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings
|
||||
(
|
||||
key
|
||||
TEXT
|
||||
PRIMARY
|
||||
KEY,
|
||||
value
|
||||
TEXT
|
||||
NOT
|
||||
NULL
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_data
|
||||
(
|
||||
id
|
||||
INTEGER
|
||||
PRIMARY
|
||||
KEY
|
||||
AUTOINCREMENT,
|
||||
user_id
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
name
|
||||
TEXT,
|
||||
ip
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
port
|
||||
INTEGER
|
||||
NOT
|
||||
NULL,
|
||||
username
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
folder
|
||||
TEXT,
|
||||
tags
|
||||
TEXT,
|
||||
pin
|
||||
INTEGER
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
0,
|
||||
auth_type
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
password
|
||||
TEXT,
|
||||
key
|
||||
TEXT,
|
||||
key_password
|
||||
TEXT,
|
||||
key_type
|
||||
TEXT,
|
||||
enable_terminal
|
||||
INTEGER
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
1,
|
||||
enable_tunnel
|
||||
INTEGER
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
1,
|
||||
tunnel_connections
|
||||
TEXT,
|
||||
enable_file_manager
|
||||
INTEGER
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
1,
|
||||
default_path
|
||||
TEXT,
|
||||
created_at
|
||||
TEXT
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
CURRENT_TIMESTAMP,
|
||||
updated_at
|
||||
TEXT
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
CURRENT_TIMESTAMP,
|
||||
FOREIGN
|
||||
KEY
|
||||
(
|
||||
user_id
|
||||
) REFERENCES users
|
||||
(
|
||||
id
|
||||
)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
ip TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
pin INTEGER NOT NULL DEFAULT 0,
|
||||
auth_type TEXT NOT NULL,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||
tunnel_connections TEXT,
|
||||
enable_file_manager INTEGER NOT NULL DEFAULT 1,
|
||||
default_path TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_manager_recent
|
||||
(
|
||||
id
|
||||
INTEGER
|
||||
PRIMARY
|
||||
KEY
|
||||
AUTOINCREMENT,
|
||||
user_id
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
host_id
|
||||
INTEGER
|
||||
NOT
|
||||
NULL,
|
||||
name
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
path
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
last_opened
|
||||
TEXT
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
CURRENT_TIMESTAMP,
|
||||
FOREIGN
|
||||
KEY
|
||||
(
|
||||
user_id
|
||||
) REFERENCES users
|
||||
(
|
||||
id
|
||||
),
|
||||
FOREIGN KEY
|
||||
(
|
||||
host_id
|
||||
) REFERENCES ssh_data
|
||||
(
|
||||
id
|
||||
)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS file_manager_recent (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_manager_pinned
|
||||
(
|
||||
id
|
||||
INTEGER
|
||||
PRIMARY
|
||||
KEY
|
||||
AUTOINCREMENT,
|
||||
user_id
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
host_id
|
||||
INTEGER
|
||||
NOT
|
||||
NULL,
|
||||
name
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
path
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
pinned_at
|
||||
TEXT
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
CURRENT_TIMESTAMP,
|
||||
FOREIGN
|
||||
KEY
|
||||
(
|
||||
user_id
|
||||
) REFERENCES users
|
||||
(
|
||||
id
|
||||
),
|
||||
FOREIGN KEY
|
||||
(
|
||||
host_id
|
||||
) REFERENCES ssh_data
|
||||
(
|
||||
id
|
||||
)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS file_manager_pinned (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_manager_shortcuts
|
||||
(
|
||||
id
|
||||
INTEGER
|
||||
PRIMARY
|
||||
KEY
|
||||
AUTOINCREMENT,
|
||||
user_id
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
host_id
|
||||
INTEGER
|
||||
NOT
|
||||
NULL,
|
||||
name
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
path
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
created_at
|
||||
TEXT
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
CURRENT_TIMESTAMP,
|
||||
FOREIGN
|
||||
KEY
|
||||
(
|
||||
user_id
|
||||
) REFERENCES users
|
||||
(
|
||||
id
|
||||
),
|
||||
FOREIGN KEY
|
||||
(
|
||||
host_id
|
||||
) REFERENCES ssh_data
|
||||
(
|
||||
id
|
||||
)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS file_manager_shortcuts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dismissed_alerts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
alert_id TEXT NOT NULL,
|
||||
dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
auth_type TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_used TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_credential_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
credential_id INTEGER NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id),
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dismissed_alerts
|
||||
(
|
||||
id
|
||||
INTEGER
|
||||
PRIMARY
|
||||
KEY
|
||||
AUTOINCREMENT,
|
||||
user_id
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
alert_id
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
dismissed_at
|
||||
TEXT
|
||||
NOT
|
||||
NULL
|
||||
DEFAULT
|
||||
CURRENT_TIMESTAMP,
|
||||
FOREIGN
|
||||
KEY
|
||||
(
|
||||
user_id
|
||||
) REFERENCES users
|
||||
(
|
||||
id
|
||||
)
|
||||
);
|
||||
`);
|
||||
|
||||
const addColumnIfNotExists = (table: string, column: string, definition: string) => {
|
||||
try {
|
||||
sqlite.prepare(`SELECT ${column}
|
||||
FROM ${table} LIMIT 1`).get();
|
||||
} catch (e) {
|
||||
try {
|
||||
sqlite.exec(`ALTER TABLE ${table}
|
||||
ADD COLUMN ${column} ${definition};`);
|
||||
} catch (alterError) {
|
||||
logger.warn(`Failed to add column ${column} to ${table}: ${alterError}`);
|
||||
}
|
||||
migrateSchema();
|
||||
|
||||
try {
|
||||
const row = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
|
||||
.get();
|
||||
if (!row) {
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')",
|
||||
)
|
||||
.run();
|
||||
}
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not initialize default settings", {
|
||||
operation: "db_init",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const addColumnIfNotExists = (
|
||||
table: string,
|
||||
column: string,
|
||||
definition: string,
|
||||
) => {
|
||||
try {
|
||||
sqlite
|
||||
.prepare(
|
||||
`SELECT ${column}
|
||||
FROM ${table} LIMIT 1`,
|
||||
)
|
||||
.get();
|
||||
} catch (e) {
|
||||
try {
|
||||
sqlite.exec(`ALTER TABLE ${table}
|
||||
ADD COLUMN ${column} ${definition};`);
|
||||
} catch (alterError) {
|
||||
databaseLogger.warn(`Failed to add column ${column} to ${table}`, {
|
||||
operation: "schema_migration",
|
||||
table,
|
||||
column,
|
||||
error: alterError,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const migrateSchema = () => {
|
||||
logger.info('Checking for schema updates...');
|
||||
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
||||
|
||||
addColumnIfNotExists('users', 'is_admin', 'INTEGER NOT NULL DEFAULT 0');
|
||||
addColumnIfNotExists("users", "is_oidc", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists("users", "oidc_identifier", "TEXT");
|
||||
addColumnIfNotExists("users", "client_id", "TEXT");
|
||||
addColumnIfNotExists("users", "client_secret", "TEXT");
|
||||
addColumnIfNotExists("users", "issuer_url", "TEXT");
|
||||
addColumnIfNotExists("users", "authorization_url", "TEXT");
|
||||
addColumnIfNotExists("users", "token_url", "TEXT");
|
||||
|
||||
addColumnIfNotExists('users', 'is_oidc', 'INTEGER NOT NULL DEFAULT 0');
|
||||
addColumnIfNotExists('users', 'oidc_identifier', 'TEXT');
|
||||
addColumnIfNotExists('users', 'client_id', 'TEXT');
|
||||
addColumnIfNotExists('users', 'client_secret', 'TEXT');
|
||||
addColumnIfNotExists('users', 'issuer_url', 'TEXT');
|
||||
addColumnIfNotExists('users', 'authorization_url', 'TEXT');
|
||||
addColumnIfNotExists('users', 'token_url', 'TEXT');
|
||||
try {
|
||||
sqlite.prepare(`ALTER TABLE users DROP COLUMN redirect_uri`).run();
|
||||
} catch (e) {
|
||||
}
|
||||
addColumnIfNotExists("users", "identifier_path", "TEXT");
|
||||
addColumnIfNotExists("users", "name_path", "TEXT");
|
||||
addColumnIfNotExists("users", "scopes", "TEXT");
|
||||
|
||||
addColumnIfNotExists('users', 'identifier_path', 'TEXT');
|
||||
addColumnIfNotExists('users', 'name_path', 'TEXT');
|
||||
addColumnIfNotExists('users', 'scopes', 'TEXT');
|
||||
addColumnIfNotExists("users", "totp_secret", "TEXT");
|
||||
addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists("users", "totp_backup_codes", "TEXT");
|
||||
|
||||
addColumnIfNotExists('ssh_data', 'name', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'folder', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'tags', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'pin', 'INTEGER NOT NULL DEFAULT 0');
|
||||
addColumnIfNotExists('ssh_data', 'auth_type', 'TEXT NOT NULL DEFAULT "password"');
|
||||
addColumnIfNotExists('ssh_data', 'password', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'key', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'key_password', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'key_type', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'enable_terminal', 'INTEGER NOT NULL DEFAULT 1');
|
||||
addColumnIfNotExists('ssh_data', 'enable_tunnel', 'INTEGER NOT NULL DEFAULT 1');
|
||||
addColumnIfNotExists('ssh_data', 'tunnel_connections', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'enable_file_manager', 'INTEGER NOT NULL DEFAULT 1');
|
||||
addColumnIfNotExists('ssh_data', 'default_path', 'TEXT');
|
||||
addColumnIfNotExists('ssh_data', 'created_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
|
||||
addColumnIfNotExists('ssh_data', 'updated_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
|
||||
addColumnIfNotExists("ssh_data", "name", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "folder", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "tags", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "pin", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"auth_type",
|
||||
'TEXT NOT NULL DEFAULT "password"',
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "key", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "key_password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "key_type", "TEXT");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"enable_terminal",
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
);
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"enable_tunnel",
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "tunnel_connections", "TEXT");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"enable_file_manager",
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "default_path", "TEXT");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"created_at",
|
||||
"TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||
);
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"updated_at",
|
||||
"TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||
);
|
||||
|
||||
addColumnIfNotExists('file_manager_recent', 'host_id', 'INTEGER NOT NULL');
|
||||
addColumnIfNotExists('file_manager_pinned', 'host_id', 'INTEGER NOT NULL');
|
||||
addColumnIfNotExists('file_manager_shortcuts', 'host_id', 'INTEGER NOT NULL');
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"credential_id",
|
||||
"INTEGER REFERENCES ssh_credentials(id)",
|
||||
);
|
||||
|
||||
logger.success('Schema migration completed');
|
||||
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT");
|
||||
|
||||
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT");
|
||||
|
||||
addColumnIfNotExists("file_manager_recent", "host_id", "INTEGER NOT NULL");
|
||||
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
|
||||
addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
|
||||
|
||||
databaseLogger.success("Schema migration completed", {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
};
|
||||
|
||||
migrateSchema();
|
||||
async function saveMemoryDatabaseToFile() {
|
||||
if (!memoryDatabase) return;
|
||||
|
||||
try {
|
||||
const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
|
||||
if (!row) {
|
||||
sqlite.prepare("INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')").run();
|
||||
try {
|
||||
const buffer = memoryDatabase.serialize();
|
||||
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Could not initialize default settings');
|
||||
|
||||
if (enableFileEncryption) {
|
||||
await DatabaseFileEncryption.encryptDatabaseFromBuffer(
|
||||
buffer,
|
||||
encryptedDbPath,
|
||||
);
|
||||
} else {
|
||||
fs.writeFileSync(dbPath, buffer);
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to save in-memory database", error, {
|
||||
operation: "memory_db_save_failed",
|
||||
enableFileEncryption,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const db = drizzle(sqlite, {schema});
|
||||
async function handlePostInitFileEncryption() {
|
||||
if (!enableFileEncryption) return;
|
||||
|
||||
try {
|
||||
if (memoryDatabase) {
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
setInterval(saveMemoryDatabaseToFile, 15 * 1000);
|
||||
|
||||
DatabaseSaveTrigger.initialize(saveMemoryDatabaseToFile);
|
||||
}
|
||||
|
||||
try {
|
||||
const migration = new DatabaseMigration(dataDir);
|
||||
migration.cleanupOldBackups();
|
||||
} catch (cleanupError) {
|
||||
databaseLogger.warn("Failed to cleanup old migration files", {
|
||||
operation: "migration_cleanup_startup_failed",
|
||||
error:
|
||||
cleanupError instanceof Error
|
||||
? cleanupError.message
|
||||
: "Unknown error",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to handle database file encryption setup",
|
||||
error,
|
||||
{
|
||||
operation: "db_encrypt_setup_failed",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeDatabase(): Promise<void> {
|
||||
await initializeCompleteDatabase();
|
||||
await handlePostInitFileEncryption();
|
||||
}
|
||||
|
||||
export { initializeDatabase };
|
||||
|
||||
async function cleanupDatabase() {
|
||||
if (memoryDatabase) {
|
||||
try {
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to save in-memory database before shutdown",
|
||||
error,
|
||||
{
|
||||
operation: "shutdown_save_failed",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (sqlite) {
|
||||
sqlite.close();
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Error closing database connection", {
|
||||
operation: "db_close_error",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const tempDir = path.join(dataDir, ".temp");
|
||||
if (fs.existsSync(tempDir)) {
|
||||
const files = fs.readdirSync(tempDir);
|
||||
for (const file of files) {
|
||||
try {
|
||||
fs.unlinkSync(path.join(tempDir, file));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
try {
|
||||
fs.rmdirSync(tempDir);
|
||||
} catch {}
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
process.on("exit", () => {
|
||||
if (sqlite) {
|
||||
try {
|
||||
sqlite.close();
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
databaseLogger.info("Received SIGINT, cleaning up...", {
|
||||
operation: "shutdown",
|
||||
});
|
||||
await cleanupDatabase();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
databaseLogger.info("Received SIGTERM, cleaning up...", {
|
||||
operation: "shutdown",
|
||||
});
|
||||
await cleanupDatabase();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
let db: ReturnType<typeof drizzle<typeof schema>>;
|
||||
|
||||
export function getDb(): ReturnType<typeof drizzle<typeof schema>> {
|
||||
if (!db) {
|
||||
throw new Error(
|
||||
"Database not initialized. Ensure initializeDatabase() is called before accessing db.",
|
||||
);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export function getSqlite(): Database.Database {
|
||||
if (!sqlite) {
|
||||
throw new Error(
|
||||
"SQLite not initialized. Ensure initializeDatabase() is called before accessing sqlite.",
|
||||
);
|
||||
}
|
||||
return sqlite;
|
||||
}
|
||||
|
||||
export { db };
|
||||
export { DatabaseFileEncryption };
|
||||
export const databasePaths = {
|
||||
main: actualDbPath,
|
||||
encrypted: encryptedDbPath,
|
||||
directory: dbDir,
|
||||
inMemory: true,
|
||||
};
|
||||
|
||||
export { saveMemoryDatabaseToFile };
|
||||
|
||||
export { DatabaseSaveTrigger };
|
||||
|
||||
@@ -1,83 +1,174 @@
|
||||
import {sqliteTable, text, integer} from 'drizzle-orm/sqlite-core';
|
||||
import {sql} from 'drizzle-orm';
|
||||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export const users = sqliteTable('users', {
|
||||
id: text('id').primaryKey(),
|
||||
username: text('username').notNull(),
|
||||
password_hash: text('password_hash').notNull(),
|
||||
is_admin: integer('is_admin', {mode: 'boolean'}).notNull().default(false),
|
||||
export const users = sqliteTable("users", {
|
||||
id: text("id").primaryKey(),
|
||||
username: text("username").notNull(),
|
||||
password_hash: text("password_hash").notNull(),
|
||||
is_admin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
|
||||
|
||||
is_oidc: integer('is_oidc', {mode: 'boolean'}).notNull().default(false),
|
||||
oidc_identifier: text('oidc_identifier'),
|
||||
client_id: text('client_id'),
|
||||
client_secret: text('client_secret'),
|
||||
issuer_url: text('issuer_url'),
|
||||
authorization_url: text('authorization_url'),
|
||||
token_url: text('token_url'),
|
||||
identifier_path: text('identifier_path'),
|
||||
name_path: text('name_path'),
|
||||
scopes: text().default("openid email profile"),
|
||||
is_oidc: integer("is_oidc", { mode: "boolean" }).notNull().default(false),
|
||||
oidc_identifier: text("oidc_identifier"),
|
||||
client_id: text("client_id"),
|
||||
client_secret: text("client_secret"),
|
||||
issuer_url: text("issuer_url"),
|
||||
authorization_url: text("authorization_url"),
|
||||
token_url: text("token_url"),
|
||||
identifier_path: text("identifier_path"),
|
||||
name_path: text("name_path"),
|
||||
scopes: text().default("openid email profile"),
|
||||
|
||||
totp_secret: text("totp_secret"),
|
||||
totp_enabled: integer("totp_enabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
totp_backup_codes: text("totp_backup_codes"),
|
||||
});
|
||||
|
||||
export const settings = sqliteTable('settings', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value').notNull(),
|
||||
export const settings = sqliteTable("settings", {
|
||||
key: text("key").primaryKey(),
|
||||
value: text("value").notNull(),
|
||||
});
|
||||
|
||||
export const sshData = sqliteTable('ssh_data', {
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
name: text('name'),
|
||||
ip: text('ip').notNull(),
|
||||
port: integer('port').notNull(),
|
||||
username: text('username').notNull(),
|
||||
folder: text('folder'),
|
||||
tags: text('tags'),
|
||||
pin: integer('pin', {mode: 'boolean'}).notNull().default(false),
|
||||
authType: text('auth_type').notNull(),
|
||||
password: text('password'),
|
||||
key: text('key', {length: 8192}),
|
||||
keyPassword: text('key_password'),
|
||||
keyType: text('key_type'),
|
||||
enableTerminal: integer('enable_terminal', {mode: 'boolean'}).notNull().default(true),
|
||||
enableTunnel: integer('enable_tunnel', {mode: 'boolean'}).notNull().default(true),
|
||||
tunnelConnections: text('tunnel_connections'),
|
||||
enableFileManager: integer('enable_file_manager', {mode: 'boolean'}).notNull().default(true),
|
||||
defaultPath: text('default_path'),
|
||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
export const sshData = sqliteTable("ssh_data", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
name: text("name"),
|
||||
ip: text("ip").notNull(),
|
||||
port: integer("port").notNull(),
|
||||
username: text("username").notNull(),
|
||||
folder: text("folder"),
|
||||
tags: text("tags"),
|
||||
pin: integer("pin", { mode: "boolean" }).notNull().default(false),
|
||||
authType: text("auth_type").notNull(),
|
||||
|
||||
password: text("password"),
|
||||
key: text("key", { length: 8192 }),
|
||||
keyPassword: text("key_password"),
|
||||
keyType: text("key_type"),
|
||||
|
||||
autostartPassword: text("autostart_password"),
|
||||
autostartKey: text("autostart_key", { length: 8192 }),
|
||||
autostartKeyPassword: text("autostart_key_password"),
|
||||
|
||||
credentialId: integer("credential_id").references(() => sshCredentials.id),
|
||||
enableTerminal: integer("enable_terminal", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
enableTunnel: integer("enable_tunnel", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
tunnelConnections: text("tunnel_connections"),
|
||||
enableFileManager: integer("enable_file_manager", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
defaultPath: text("default_path"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const fileManagerRecent = sqliteTable('file_manager_recent', {
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
hostId: integer('host_id').notNull().references(() => sshData.id),
|
||||
name: text('name').notNull(),
|
||||
path: text('path').notNull(),
|
||||
lastOpened: text('last_opened').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
export const fileManagerRecent = sqliteTable("file_manager_recent", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id),
|
||||
name: text("name").notNull(),
|
||||
path: text("path").notNull(),
|
||||
lastOpened: text("last_opened")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const fileManagerPinned = sqliteTable('file_manager_pinned', {
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
hostId: integer('host_id').notNull().references(() => sshData.id),
|
||||
name: text('name').notNull(),
|
||||
path: text('path').notNull(),
|
||||
pinnedAt: text('pinned_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
export const fileManagerPinned = sqliteTable("file_manager_pinned", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id),
|
||||
name: text("name").notNull(),
|
||||
path: text("path").notNull(),
|
||||
pinnedAt: text("pinned_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const fileManagerShortcuts = sqliteTable('file_manager_shortcuts', {
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
hostId: integer('host_id').notNull().references(() => sshData.id),
|
||||
name: text('name').notNull(),
|
||||
path: text('path').notNull(),
|
||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
export const fileManagerShortcuts = sqliteTable("file_manager_shortcuts", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id),
|
||||
name: text("name").notNull(),
|
||||
path: text("path").notNull(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const dismissedAlerts = sqliteTable('dismissed_alerts', {
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
alertId: text('alert_id').notNull(),
|
||||
dismissedAt: text('dismissed_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
export const dismissedAlerts = sqliteTable("dismissed_alerts", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
alertId: text("alert_id").notNull(),
|
||||
dismissedAt: text("dismissed_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const sshCredentials = sqliteTable("ssh_credentials", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
folder: text("folder"),
|
||||
tags: text("tags"),
|
||||
authType: text("auth_type").notNull(),
|
||||
username: text("username").notNull(),
|
||||
password: text("password"),
|
||||
key: text("key", { length: 16384 }),
|
||||
privateKey: text("private_key", { length: 16384 }),
|
||||
publicKey: text("public_key", { length: 4096 }),
|
||||
keyPassword: text("key_password"),
|
||||
keyType: text("key_type"),
|
||||
detectedKeyType: text("detected_key_type"),
|
||||
usageCount: integer("usage_count").notNull().default(0),
|
||||
lastUsed: text("last_used"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const sshCredentialUsage = sqliteTable("ssh_credential_usage", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
credentialId: integer("credential_id")
|
||||
.notNull()
|
||||
.references(() => sshCredentials.id),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
usedAt: text("used_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
@@ -1,270 +1,239 @@
|
||||
import express from 'express';
|
||||
import {db} from '../db/index.js';
|
||||
import {dismissedAlerts} from '../db/schema.js';
|
||||
import {eq, and} from 'drizzle-orm';
|
||||
import chalk from 'chalk';
|
||||
import fetch from 'node-fetch';
|
||||
import type {Request, Response, NextFunction} from 'express';
|
||||
|
||||
const dbIconSymbol = '🚨';
|
||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#dc2626')(`[${dbIconSymbol}]`)} ${message}`;
|
||||
};
|
||||
const logger = {
|
||||
info: (msg: string): void => {
|
||||
console.log(formatMessage('info', chalk.cyan, msg));
|
||||
},
|
||||
warn: (msg: string): void => {
|
||||
console.warn(formatMessage('warn', chalk.yellow, msg));
|
||||
},
|
||||
error: (msg: string, err?: unknown): void => {
|
||||
console.error(formatMessage('error', chalk.redBright, msg));
|
||||
if (err) console.error(err);
|
||||
},
|
||||
success: (msg: string): void => {
|
||||
console.log(formatMessage('success', chalk.greenBright, msg));
|
||||
},
|
||||
debug: (msg: string): void => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.debug(formatMessage('debug', chalk.magenta, msg));
|
||||
}
|
||||
}
|
||||
};
|
||||
import express from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import { dismissedAlerts } from "../db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import fetch from "node-fetch";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
|
||||
interface CacheEntry {
|
||||
data: any;
|
||||
timestamp: number;
|
||||
expiresAt: number;
|
||||
data: any;
|
||||
timestamp: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
class AlertCache {
|
||||
private cache: Map<string, CacheEntry> = new Map();
|
||||
private readonly CACHE_DURATION = 5 * 60 * 1000;
|
||||
private cache: Map<string, CacheEntry> = new Map();
|
||||
private readonly CACHE_DURATION = 5 * 60 * 1000;
|
||||
|
||||
set(key: string, data: any): void {
|
||||
const now = Date.now();
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: now,
|
||||
expiresAt: now + this.CACHE_DURATION
|
||||
});
|
||||
set(key: string, data: any): void {
|
||||
const now = Date.now();
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: now,
|
||||
expiresAt: now + this.CACHE_DURATION,
|
||||
});
|
||||
}
|
||||
|
||||
get(key: string): any | null {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
get(key: string): any | null {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data;
|
||||
}
|
||||
}
|
||||
|
||||
const alertCache = new AlertCache();
|
||||
|
||||
const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com';
|
||||
const REPO_OWNER = 'LukeGus';
|
||||
const REPO_NAME = 'Termix-Docs';
|
||||
const ALERTS_FILE = 'main/termix-alerts.json';
|
||||
const GITHUB_RAW_BASE = "https://raw.githubusercontent.com";
|
||||
const REPO_OWNER = "LukeGus";
|
||||
const REPO_NAME = "Termix-Docs";
|
||||
const ALERTS_FILE = "main/termix-alerts.json";
|
||||
|
||||
interface TermixAlert {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
expiresAt: string;
|
||||
priority?: 'low' | 'medium' | 'high' | 'critical';
|
||||
type?: 'info' | 'warning' | 'error' | 'success';
|
||||
actionUrl?: string;
|
||||
actionText?: string;
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
expiresAt: string;
|
||||
priority?: "low" | "medium" | "high" | "critical";
|
||||
type?: "info" | "warning" | "error" | "success";
|
||||
actionUrl?: string;
|
||||
actionText?: string;
|
||||
}
|
||||
|
||||
async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
|
||||
const cacheKey = 'termix_alerts';
|
||||
const cachedData = alertCache.get(cacheKey);
|
||||
if (cachedData) {
|
||||
return cachedData;
|
||||
const cacheKey = "termix_alerts";
|
||||
const cachedData = alertCache.get(cacheKey);
|
||||
if (cachedData) {
|
||||
return cachedData;
|
||||
}
|
||||
try {
|
||||
const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"User-Agent": "TermixAlertChecker/1.0",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
authLogger.warn("GitHub API returned error status", {
|
||||
operation: "alerts_fetch",
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
throw new Error(
|
||||
`GitHub raw content error: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`;
|
||||
const alerts: TermixAlert[] = (await response.json()) as TermixAlert[];
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'TermixAlertChecker/1.0'
|
||||
}
|
||||
});
|
||||
const now = new Date();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub raw content error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const validAlerts = alerts.filter((alert) => {
|
||||
const expiryDate = new Date(alert.expiresAt);
|
||||
const isValid = expiryDate > now;
|
||||
return isValid;
|
||||
});
|
||||
|
||||
const alerts: TermixAlert[] = await response.json() as TermixAlert[];
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const validAlerts = alerts.filter(alert => {
|
||||
const expiryDate = new Date(alert.expiresAt);
|
||||
const isValid = expiryDate > now;
|
||||
return isValid;
|
||||
});
|
||||
|
||||
alertCache.set(cacheKey, validAlerts);
|
||||
return validAlerts;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch alerts from GitHub', error);
|
||||
return [];
|
||||
}
|
||||
alertCache.set(cacheKey, validAlerts);
|
||||
return validAlerts;
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to fetch alerts from GitHub", {
|
||||
operation: "alerts_fetch",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Route: Get all active alerts
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
// Route: Get alerts for the authenticated user (excluding dismissed ones)
|
||||
// GET /alerts
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const alerts = await fetchAlertsFromGitHub();
|
||||
res.json({
|
||||
alerts,
|
||||
cached: alertCache.get('termix_alerts') !== null,
|
||||
total_count: alerts.length
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get alerts', error);
|
||||
res.status(500).json({error: 'Failed to fetch alerts'});
|
||||
}
|
||||
router.get("/", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const userId = (req as any).userId;
|
||||
|
||||
const allAlerts = await fetchAlertsFromGitHub();
|
||||
|
||||
const dismissedAlertRecords = await db
|
||||
.select({ alertId: dismissedAlerts.alertId })
|
||||
.from(dismissedAlerts)
|
||||
.where(eq(dismissedAlerts.userId, userId));
|
||||
|
||||
const dismissedAlertIds = new Set(
|
||||
dismissedAlertRecords.map((record) => record.alertId),
|
||||
);
|
||||
|
||||
const activeAlertsForUser = allAlerts.filter(
|
||||
(alert) => !dismissedAlertIds.has(alert.id),
|
||||
);
|
||||
|
||||
res.json({
|
||||
alerts: activeAlertsForUser,
|
||||
cached: alertCache.get("termix_alerts") !== null,
|
||||
total_count: activeAlertsForUser.length,
|
||||
});
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to get user alerts", error);
|
||||
res.status(500).json({ error: "Failed to fetch alerts" });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Get alerts for a specific user (excluding dismissed ones)
|
||||
// GET /alerts/user/:userId
|
||||
router.get('/user/:userId', async (req, res) => {
|
||||
try {
|
||||
const {userId} = req.params;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({error: 'User ID is required'});
|
||||
}
|
||||
|
||||
const allAlerts = await fetchAlertsFromGitHub();
|
||||
|
||||
const dismissedAlertRecords = await db
|
||||
.select({alertId: dismissedAlerts.alertId})
|
||||
.from(dismissedAlerts)
|
||||
.where(eq(dismissedAlerts.userId, userId));
|
||||
|
||||
const dismissedAlertIds = new Set(dismissedAlertRecords.map(record => record.alertId));
|
||||
|
||||
const userAlerts = allAlerts.filter(alert => !dismissedAlertIds.has(alert.id));
|
||||
|
||||
res.json({
|
||||
alerts: userAlerts,
|
||||
total_count: userAlerts.length,
|
||||
dismissed_count: dismissedAlertIds.size
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get user alerts', error);
|
||||
res.status(500).json({error: 'Failed to fetch user alerts'});
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Dismiss an alert for a user
|
||||
// Route: Dismiss an alert for the authenticated user
|
||||
// POST /alerts/dismiss
|
||||
router.post('/dismiss', async (req, res) => {
|
||||
try {
|
||||
const {userId, alertId} = req.body;
|
||||
router.post("/dismiss", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const { alertId } = req.body;
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!userId || !alertId) {
|
||||
logger.warn('Missing userId or alertId in dismiss request');
|
||||
return res.status(400).json({error: 'User ID and Alert ID are required'});
|
||||
}
|
||||
|
||||
const existingDismissal = await db
|
||||
.select()
|
||||
.from(dismissedAlerts)
|
||||
.where(and(
|
||||
eq(dismissedAlerts.userId, userId),
|
||||
eq(dismissedAlerts.alertId, alertId)
|
||||
));
|
||||
|
||||
if (existingDismissal.length > 0) {
|
||||
logger.warn(`Alert ${alertId} already dismissed by user ${userId}`);
|
||||
return res.status(409).json({error: 'Alert already dismissed'});
|
||||
}
|
||||
|
||||
const result = await db.insert(dismissedAlerts).values({
|
||||
userId,
|
||||
alertId
|
||||
});
|
||||
|
||||
logger.success(`Alert ${alertId} dismissed by user ${userId}. Insert result: ${JSON.stringify(result)}`);
|
||||
res.json({message: 'Alert dismissed successfully'});
|
||||
} catch (error) {
|
||||
logger.error('Failed to dismiss alert', error);
|
||||
res.status(500).json({error: 'Failed to dismiss alert'});
|
||||
if (!alertId) {
|
||||
authLogger.warn("Missing alertId in dismiss request", { userId });
|
||||
return res.status(400).json({ error: "Alert ID is required" });
|
||||
}
|
||||
|
||||
const existingDismissal = await db
|
||||
.select()
|
||||
.from(dismissedAlerts)
|
||||
.where(
|
||||
and(
|
||||
eq(dismissedAlerts.userId, userId),
|
||||
eq(dismissedAlerts.alertId, alertId),
|
||||
),
|
||||
);
|
||||
|
||||
if (existingDismissal.length > 0) {
|
||||
authLogger.warn(`Alert ${alertId} already dismissed by user ${userId}`);
|
||||
return res.status(409).json({ error: "Alert already dismissed" });
|
||||
}
|
||||
|
||||
const result = await db.insert(dismissedAlerts).values({
|
||||
userId,
|
||||
alertId,
|
||||
});
|
||||
|
||||
res.json({ message: "Alert dismissed successfully" });
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to dismiss alert", error);
|
||||
res.status(500).json({ error: "Failed to dismiss alert" });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Get dismissed alerts for a user
|
||||
// GET /alerts/dismissed/:userId
|
||||
router.get('/dismissed/:userId', async (req, res) => {
|
||||
try {
|
||||
const {userId} = req.params;
|
||||
router.get("/dismissed", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({error: 'User ID is required'});
|
||||
}
|
||||
const dismissedAlertRecords = await db
|
||||
.select({
|
||||
alertId: dismissedAlerts.alertId,
|
||||
dismissedAt: dismissedAlerts.dismissedAt,
|
||||
})
|
||||
.from(dismissedAlerts)
|
||||
.where(eq(dismissedAlerts.userId, userId));
|
||||
|
||||
const dismissedAlertRecords = await db
|
||||
.select({
|
||||
alertId: dismissedAlerts.alertId,
|
||||
dismissedAt: dismissedAlerts.dismissedAt
|
||||
})
|
||||
.from(dismissedAlerts)
|
||||
.where(eq(dismissedAlerts.userId, userId));
|
||||
|
||||
res.json({
|
||||
dismissed_alerts: dismissedAlertRecords,
|
||||
total_count: dismissedAlertRecords.length
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get dismissed alerts', error);
|
||||
res.status(500).json({error: 'Failed to fetch dismissed alerts'});
|
||||
}
|
||||
res.json({
|
||||
dismissed_alerts: dismissedAlertRecords,
|
||||
total_count: dismissedAlertRecords.length,
|
||||
});
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to get dismissed alerts", error);
|
||||
res.status(500).json({ error: "Failed to fetch dismissed alerts" });
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Undismiss an alert for a user (remove from dismissed list)
|
||||
// Route: Undismiss an alert for the authenticated user (remove from dismissed list)
|
||||
// DELETE /alerts/dismiss
|
||||
router.delete('/dismiss', async (req, res) => {
|
||||
try {
|
||||
const {userId, alertId} = req.body;
|
||||
router.delete("/dismiss", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const { alertId } = req.body;
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!userId || !alertId) {
|
||||
return res.status(400).json({error: 'User ID and Alert ID are required'});
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.delete(dismissedAlerts)
|
||||
.where(and(
|
||||
eq(dismissedAlerts.userId, userId),
|
||||
eq(dismissedAlerts.alertId, alertId)
|
||||
));
|
||||
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({error: 'Dismissed alert not found'});
|
||||
}
|
||||
|
||||
logger.success(`Alert ${alertId} undismissed by user ${userId}`);
|
||||
res.json({message: 'Alert undismissed successfully'});
|
||||
} catch (error) {
|
||||
logger.error('Failed to undismiss alert', error);
|
||||
res.status(500).json({error: 'Failed to undismiss alert'});
|
||||
if (!alertId) {
|
||||
return res.status(400).json({ error: "Alert ID is required" });
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.delete(dismissedAlerts)
|
||||
.where(
|
||||
and(
|
||||
eq(dismissedAlerts.userId, userId),
|
||||
eq(dismissedAlerts.alertId, alertId),
|
||||
),
|
||||
);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({ error: "Dismissed alert not found" });
|
||||
}
|
||||
res.json({ message: "Alert undismissed successfully" });
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to undismiss alert", error);
|
||||
res.status(500).json({ error: "Failed to undismiss alert" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
1604
src/backend/database/routes/credentials.ts
Normal file
@@ -1,56 +1,141 @@
|
||||
// npx tsc -p tsconfig.node.json
|
||||
// node ./dist/backend/starter.js
|
||||
|
||||
import './database/database.js'
|
||||
import './ssh/terminal.js';
|
||||
import './ssh/tunnel.js';
|
||||
import './ssh/file-manager.js';
|
||||
import './ssh/server-stats.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
const fixedIconSymbol = '🚀';
|
||||
|
||||
const getTimeStamp = (): string => {
|
||||
return chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
};
|
||||
|
||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${fixedIconSymbol}]`)} ${message}`;
|
||||
};
|
||||
|
||||
const logger = {
|
||||
info: (msg: string): void => {
|
||||
console.log(formatMessage('info', chalk.cyan, msg));
|
||||
},
|
||||
warn: (msg: string): void => {
|
||||
console.warn(formatMessage('warn', chalk.yellow, msg));
|
||||
},
|
||||
error: (msg: string, err?: unknown): void => {
|
||||
console.error(formatMessage('error', chalk.redBright, msg));
|
||||
if (err) console.error(err);
|
||||
},
|
||||
success: (msg: string): void => {
|
||||
console.log(formatMessage('success', chalk.greenBright, msg));
|
||||
},
|
||||
debug: (msg: string): void => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.debug(formatMessage('debug', chalk.magenta, msg));
|
||||
}
|
||||
}
|
||||
};
|
||||
import dotenv from "dotenv";
|
||||
import { promises as fs } from "fs";
|
||||
import { readFileSync } from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { AutoSSLSetup } from "./utils/auto-ssl-setup.js";
|
||||
import { AuthManager } from "./utils/auth-manager.js";
|
||||
import { DataCrypto } from "./utils/data-crypto.js";
|
||||
import { SystemCrypto } from "./utils/system-crypto.js";
|
||||
import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
dotenv.config({ quiet: true });
|
||||
|
||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||
const envPath = path.join(dataDir, ".env");
|
||||
try {
|
||||
logger.info("Starting all backend servers...");
|
||||
await fs.access(envPath);
|
||||
const persistentConfig = dotenv.config({ path: envPath, quiet: true });
|
||||
if (persistentConfig.parsed) {
|
||||
Object.assign(process.env, persistentConfig.parsed);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
logger.success("All servers started successfully");
|
||||
let version = "unknown";
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info("Shutting down servers...");
|
||||
process.exit(0);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to start servers:", error);
|
||||
process.exit(1);
|
||||
const versionSources = [
|
||||
() => process.env.VERSION,
|
||||
() => {
|
||||
try {
|
||||
const packageJsonPath = path.join(process.cwd(), "package.json");
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(packageJsonPath, "utf-8"),
|
||||
);
|
||||
return packageJson.version;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
() => {
|
||||
try {
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const packageJsonPath = path.join(
|
||||
path.dirname(__filename),
|
||||
"../../../package.json",
|
||||
);
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(packageJsonPath, "utf-8"),
|
||||
);
|
||||
return packageJson.version;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
() => {
|
||||
try {
|
||||
const packageJsonPath = path.join("/app", "package.json");
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(packageJsonPath, "utf-8"),
|
||||
);
|
||||
return packageJson.version;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
for (const getVersion of versionSources) {
|
||||
try {
|
||||
const foundVersion = getVersion();
|
||||
if (foundVersion && foundVersion !== "unknown") {
|
||||
version = foundVersion;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
})();
|
||||
versionLogger.info(`Termix Backend starting - Version: ${version}`, {
|
||||
operation: "startup",
|
||||
version: version,
|
||||
});
|
||||
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
await systemCrypto.initializeJWTSecret();
|
||||
await systemCrypto.initializeDatabaseKey();
|
||||
await systemCrypto.initializeInternalAuthToken();
|
||||
|
||||
await AutoSSLSetup.initialize();
|
||||
|
||||
const dbModule = await import("./database/db/index.js");
|
||||
await dbModule.initializeDatabase();
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
await authManager.initialize();
|
||||
DataCrypto.initialize();
|
||||
|
||||
await import("./database/database.js");
|
||||
|
||||
await import("./ssh/terminal.js");
|
||||
await import("./ssh/tunnel.js");
|
||||
await import("./ssh/file-manager.js");
|
||||
await import("./ssh/server-stats.js");
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
systemLogger.info(
|
||||
"Received SIGINT signal, initiating graceful shutdown...",
|
||||
{ operation: "shutdown" },
|
||||
);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
systemLogger.info(
|
||||
"Received SIGTERM signal, initiating graceful shutdown...",
|
||||
{ operation: "shutdown" },
|
||||
);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("uncaughtException", (error) => {
|
||||
systemLogger.error("Uncaught exception occurred", error, {
|
||||
operation: "error_handling",
|
||||
});
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
systemLogger.error("Unhandled promise rejection", reason, {
|
||||
operation: "error_handling",
|
||||
});
|
||||
process.exit(1);
|
||||
});
|
||||
} catch (error) {
|
||||
systemLogger.error("Failed to initialize backend services", error, {
|
||||
operation: "startup_failed",
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
|
||||
300
src/backend/utils/auth-manager.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import { UserCrypto } from "./user-crypto.js";
|
||||
import { SystemCrypto } from "./system-crypto.js";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
|
||||
interface AuthenticationResult {
|
||||
success: boolean;
|
||||
token?: string;
|
||||
userId?: string;
|
||||
isAdmin?: boolean;
|
||||
username?: string;
|
||||
requiresTOTP?: boolean;
|
||||
tempToken?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface JWTPayload {
|
||||
userId: string;
|
||||
pendingTOTP?: boolean;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
class AuthManager {
|
||||
private static instance: AuthManager;
|
||||
private systemCrypto: SystemCrypto;
|
||||
private userCrypto: UserCrypto;
|
||||
private invalidatedTokens: Set<string> = new Set();
|
||||
|
||||
private constructor() {
|
||||
this.systemCrypto = SystemCrypto.getInstance();
|
||||
this.userCrypto = UserCrypto.getInstance();
|
||||
|
||||
this.userCrypto.setSessionExpiredCallback((userId: string) => {
|
||||
this.invalidateUserTokens(userId);
|
||||
});
|
||||
}
|
||||
|
||||
static getInstance(): AuthManager {
|
||||
if (!this.instance) {
|
||||
this.instance = new AuthManager();
|
||||
}
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
await this.systemCrypto.initializeJWTSecret();
|
||||
}
|
||||
|
||||
async registerUser(userId: string, password: string): Promise<void> {
|
||||
await this.userCrypto.setupUserEncryption(userId, password);
|
||||
}
|
||||
|
||||
async registerOIDCUser(userId: string): Promise<void> {
|
||||
await this.userCrypto.setupOIDCUserEncryption(userId);
|
||||
}
|
||||
|
||||
async authenticateOIDCUser(userId: string): Promise<boolean> {
|
||||
const authenticated = await this.userCrypto.authenticateOIDCUser(userId);
|
||||
|
||||
if (authenticated) {
|
||||
await this.performLazyEncryptionMigration(userId);
|
||||
}
|
||||
|
||||
return authenticated;
|
||||
}
|
||||
|
||||
async authenticateUser(userId: string, password: string): Promise<boolean> {
|
||||
const authenticated = await this.userCrypto.authenticateUser(
|
||||
userId,
|
||||
password,
|
||||
);
|
||||
|
||||
if (authenticated) {
|
||||
await this.performLazyEncryptionMigration(userId);
|
||||
}
|
||||
|
||||
return authenticated;
|
||||
}
|
||||
|
||||
private async performLazyEncryptionMigration(userId: string): Promise<void> {
|
||||
try {
|
||||
const userDataKey = this.getUserDataKey(userId);
|
||||
if (!userDataKey) {
|
||||
databaseLogger.warn(
|
||||
"Cannot perform lazy encryption migration - user data key not available",
|
||||
{
|
||||
operation: "lazy_encryption_migration_no_key",
|
||||
userId,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { getSqlite, saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
|
||||
const sqlite = getSqlite();
|
||||
|
||||
const migrationResult = await DataCrypto.migrateUserSensitiveFields(
|
||||
userId,
|
||||
userDataKey,
|
||||
sqlite,
|
||||
);
|
||||
|
||||
if (migrationResult.migrated) {
|
||||
await saveMemoryDatabaseToFile();
|
||||
} else {
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("Lazy encryption migration failed", error, {
|
||||
operation: "lazy_encryption_migration_error",
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async generateJWTToken(
|
||||
userId: string,
|
||||
options: { expiresIn?: string; pendingTOTP?: boolean } = {},
|
||||
): Promise<string> {
|
||||
const jwtSecret = await this.systemCrypto.getJWTSecret();
|
||||
|
||||
const payload: JWTPayload = { userId };
|
||||
if (options.pendingTOTP) {
|
||||
payload.pendingTOTP = true;
|
||||
}
|
||||
|
||||
return jwt.sign(payload, jwtSecret, {
|
||||
expiresIn: options.expiresIn || "24h",
|
||||
} as jwt.SignOptions);
|
||||
}
|
||||
|
||||
async verifyJWTToken(token: string): Promise<JWTPayload | null> {
|
||||
try {
|
||||
if (this.invalidatedTokens.has(token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const jwtSecret = await this.systemCrypto.getJWTSecret();
|
||||
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
|
||||
return payload;
|
||||
} catch (error) {
|
||||
databaseLogger.warn("JWT verification failed", {
|
||||
operation: "jwt_verify_failed",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
invalidateJWTToken(token: string): void {
|
||||
this.invalidatedTokens.add(token);
|
||||
}
|
||||
|
||||
invalidateUserTokens(userId: string): void {
|
||||
databaseLogger.info("User tokens invalidated due to data lock", {
|
||||
operation: "user_tokens_invalidate",
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
getSecureCookieOptions(req: any, maxAge: number = 24 * 60 * 60 * 1000) {
|
||||
return {
|
||||
httpOnly: false,
|
||||
secure: req.secure || req.headers["x-forwarded-proto"] === "https",
|
||||
sameSite: "strict" as const,
|
||||
maxAge: maxAge,
|
||||
path: "/",
|
||||
};
|
||||
}
|
||||
|
||||
createAuthMiddleware() {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
let token = req.cookies?.jwt;
|
||||
|
||||
if (!token) {
|
||||
const authHeader = req.headers["authorization"];
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
token = authHeader.split(" ")[1];
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: "Missing authentication token" });
|
||||
}
|
||||
|
||||
const payload = await this.verifyJWTToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return res.status(401).json({ error: "Invalid token" });
|
||||
}
|
||||
|
||||
(req as any).userId = payload.userId;
|
||||
(req as any).pendingTOTP = payload.pendingTOTP;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
createDataAccessMiddleware() {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
const userId = (req as any).userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
const dataKey = this.userCrypto.getUserDataKey(userId);
|
||||
if (!dataKey) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
(req as any).dataKey = dataKey;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
createAdminMiddleware() {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
const authHeader = req.headers["authorization"];
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
return res.status(401).json({ error: "Missing Authorization header" });
|
||||
}
|
||||
|
||||
const token = authHeader.split(" ")[1];
|
||||
const payload = await this.verifyJWTToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return res.status(401).json({ error: "Invalid token" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { db } = await import("../database/db/index.js");
|
||||
const { users } = await import("../database/db/schema.js");
|
||||
const { eq } = await import("drizzle-orm");
|
||||
|
||||
const user = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, payload.userId));
|
||||
|
||||
if (!user || user.length === 0 || !user[0].is_admin) {
|
||||
databaseLogger.warn(
|
||||
"Non-admin user attempted to access admin endpoint",
|
||||
{
|
||||
operation: "admin_access_denied",
|
||||
userId: payload.userId,
|
||||
endpoint: req.path,
|
||||
},
|
||||
);
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
|
||||
(req as any).userId = payload.userId;
|
||||
(req as any).pendingTOTP = payload.pendingTOTP;
|
||||
next();
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to verify admin privileges", error, {
|
||||
operation: "admin_check_failed",
|
||||
userId: payload.userId,
|
||||
});
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to verify admin privileges" });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
logoutUser(userId: string): void {
|
||||
this.userCrypto.logoutUser(userId);
|
||||
}
|
||||
|
||||
getUserDataKey(userId: string): Buffer | null {
|
||||
return this.userCrypto.getUserDataKey(userId);
|
||||
}
|
||||
|
||||
isUserUnlocked(userId: string): boolean {
|
||||
return this.userCrypto.isUserUnlocked(userId);
|
||||
}
|
||||
|
||||
async changeUserPassword(
|
||||
userId: string,
|
||||
oldPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<boolean> {
|
||||
return await this.userCrypto.changeUserPassword(
|
||||
userId,
|
||||
oldPassword,
|
||||
newPassword,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { AuthManager, type AuthenticationResult, type JWTPayload };
|
||||
280
src/backend/utils/auto-ssl-setup.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import { execSync } from "child_process";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
import { systemLogger } from "./logger.js";
|
||||
|
||||
export class AutoSSLSetup {
|
||||
private static readonly DATA_DIR = process.env.DATA_DIR || "./db/data";
|
||||
private static readonly SSL_DIR = path.join(AutoSSLSetup.DATA_DIR, "ssl");
|
||||
private static readonly CERT_FILE = path.join(
|
||||
AutoSSLSetup.SSL_DIR,
|
||||
"termix.crt",
|
||||
);
|
||||
private static readonly KEY_FILE = path.join(
|
||||
AutoSSLSetup.SSL_DIR,
|
||||
"termix.key",
|
||||
);
|
||||
private static readonly ENV_FILE = path.join(AutoSSLSetup.DATA_DIR, ".env");
|
||||
|
||||
static async initialize(): Promise<void> {
|
||||
if (process.env.ENABLE_SSL !== "true") {
|
||||
systemLogger.info("SSL not enabled - skipping certificate generation", {
|
||||
operation: "ssl_disabled_default",
|
||||
enable_ssl: process.env.ENABLE_SSL || "undefined",
|
||||
note: "Set ENABLE_SSL=true to enable SSL certificate generation",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (await this.isSSLConfigured()) {
|
||||
await this.logCertificateInfo();
|
||||
await this.setupEnvironmentVariables();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(this.CERT_FILE);
|
||||
await fs.access(this.KEY_FILE);
|
||||
|
||||
systemLogger.info("SSL certificates found from entrypoint script", {
|
||||
operation: "ssl_cert_found_entrypoint",
|
||||
cert_path: this.CERT_FILE,
|
||||
key_path: this.KEY_FILE,
|
||||
});
|
||||
|
||||
await this.logCertificateInfo();
|
||||
await this.setupEnvironmentVariables();
|
||||
return;
|
||||
} catch {
|
||||
await this.generateSSLCertificates();
|
||||
await this.setupEnvironmentVariables();
|
||||
}
|
||||
} catch (error) {
|
||||
systemLogger.error("Failed to initialize SSL configuration", error, {
|
||||
operation: "ssl_auto_init_failed",
|
||||
});
|
||||
|
||||
systemLogger.warn("Falling back to HTTP-only mode", {
|
||||
operation: "ssl_fallback_http",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static async isSSLConfigured(): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(this.CERT_FILE);
|
||||
await fs.access(this.KEY_FILE);
|
||||
|
||||
execSync(
|
||||
`openssl x509 -in "${this.CERT_FILE}" -checkend 2592000 -noout`,
|
||||
{
|
||||
stdio: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("checkend")) {
|
||||
systemLogger.warn(
|
||||
"SSL certificate is expired or expiring soon, will regenerate",
|
||||
{
|
||||
operation: "ssl_cert_expired",
|
||||
cert_path: this.CERT_FILE,
|
||||
error: error.message,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
systemLogger.info(
|
||||
"SSL certificate not found or invalid, will generate new one",
|
||||
{
|
||||
operation: "ssl_cert_missing",
|
||||
cert_path: this.CERT_FILE,
|
||||
},
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static async generateSSLCertificates(): Promise<void> {
|
||||
try {
|
||||
try {
|
||||
execSync("openssl version", { stdio: "pipe" });
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
"OpenSSL is not installed or not available in PATH. Please install OpenSSL to enable SSL certificate generation.",
|
||||
);
|
||||
}
|
||||
|
||||
await fs.mkdir(this.SSL_DIR, { recursive: true });
|
||||
|
||||
const configFile = path.join(this.SSL_DIR, "openssl.conf");
|
||||
const opensslConfig = `
|
||||
[req]
|
||||
default_bits = 2048
|
||||
prompt = no
|
||||
default_md = sha256
|
||||
distinguished_name = dn
|
||||
req_extensions = v3_req
|
||||
|
||||
[dn]
|
||||
C=US
|
||||
ST=State
|
||||
L=City
|
||||
O=Termix
|
||||
OU=IT Department
|
||||
CN=localhost
|
||||
|
||||
[v3_req]
|
||||
basicConstraints = CA:FALSE
|
||||
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
|
||||
subjectAltName = @alt_names
|
||||
|
||||
[alt_names]
|
||||
DNS.1 = localhost
|
||||
DNS.2 = 127.0.0.1
|
||||
DNS.3 = *.localhost
|
||||
DNS.4 = termix.local
|
||||
DNS.5 = *.termix.local
|
||||
IP.1 = 127.0.0.1
|
||||
IP.2 = ::1
|
||||
IP.3 = 0.0.0.0
|
||||
`.trim();
|
||||
|
||||
await fs.writeFile(configFile, opensslConfig);
|
||||
|
||||
execSync(`openssl genrsa -out "${this.KEY_FILE}" 2048`, {
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
execSync(
|
||||
`openssl req -new -x509 -key "${this.KEY_FILE}" -out "${this.CERT_FILE}" -days 365 -config "${configFile}" -extensions v3_req`,
|
||||
{
|
||||
stdio: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
await fs.chmod(this.KEY_FILE, 0o600);
|
||||
await fs.chmod(this.CERT_FILE, 0o644);
|
||||
|
||||
await fs.unlink(configFile);
|
||||
|
||||
systemLogger.success("SSL certificates generated successfully", {
|
||||
operation: "ssl_cert_generated",
|
||||
cert_path: this.CERT_FILE,
|
||||
key_path: this.KEY_FILE,
|
||||
valid_days: 365,
|
||||
});
|
||||
|
||||
await this.logCertificateInfo();
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`SSL certificate generation failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static async logCertificateInfo(): Promise<void> {
|
||||
try {
|
||||
const subject = execSync(
|
||||
`openssl x509 -in "${this.CERT_FILE}" -noout -subject`,
|
||||
{ stdio: "pipe" },
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
const issuer = execSync(
|
||||
`openssl x509 -in "${this.CERT_FILE}" -noout -issuer`,
|
||||
{ stdio: "pipe" },
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
const notAfter = execSync(
|
||||
`openssl x509 -in "${this.CERT_FILE}" -noout -enddate`,
|
||||
{ stdio: "pipe" },
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
const notBefore = execSync(
|
||||
`openssl x509 -in "${this.CERT_FILE}" -noout -startdate`,
|
||||
{ stdio: "pipe" },
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
systemLogger.info("SSL Certificate Information:", {
|
||||
operation: "ssl_cert_info",
|
||||
subject: subject.replace("subject=", ""),
|
||||
issuer: issuer.replace("issuer=", ""),
|
||||
valid_from: notBefore.replace("notBefore=", ""),
|
||||
valid_until: notAfter.replace("notAfter=", ""),
|
||||
note: "Certificate will auto-renew 30 days before expiration",
|
||||
});
|
||||
} catch (error) {
|
||||
systemLogger.warn("Could not retrieve certificate information", {
|
||||
operation: "ssl_cert_info_error",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static async setupEnvironmentVariables(): Promise<void> {
|
||||
const certPath = this.CERT_FILE;
|
||||
const keyPath = this.KEY_FILE;
|
||||
|
||||
const sslEnvVars = {
|
||||
ENABLE_SSL: "false",
|
||||
SSL_PORT: process.env.SSL_PORT || "8443",
|
||||
SSL_CERT_PATH: certPath,
|
||||
SSL_KEY_PATH: keyPath,
|
||||
SSL_DOMAIN: "localhost",
|
||||
};
|
||||
|
||||
let envContent = "";
|
||||
try {
|
||||
envContent = await fs.readFile(this.ENV_FILE, "utf8");
|
||||
} catch {}
|
||||
|
||||
let updatedContent = envContent;
|
||||
let hasChanges = false;
|
||||
|
||||
for (const [key, value] of Object.entries(sslEnvVars)) {
|
||||
const regex = new RegExp(`^${key}=.*$`, "m");
|
||||
|
||||
if (regex.test(updatedContent)) {
|
||||
updatedContent = updatedContent.replace(regex, `${key}=${value}`);
|
||||
} else {
|
||||
if (!updatedContent.includes(`# SSL Configuration`)) {
|
||||
updatedContent += `\n# SSL Configuration (Auto-generated)\n`;
|
||||
}
|
||||
updatedContent += `${key}=${value}\n`;
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges || !envContent) {
|
||||
await fs.writeFile(this.ENV_FILE, updatedContent.trim() + "\n");
|
||||
|
||||
systemLogger.info("SSL environment variables configured", {
|
||||
operation: "ssl_env_configured",
|
||||
file: this.ENV_FILE,
|
||||
variables: Object.keys(sslEnvVars),
|
||||
});
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(sslEnvVars)) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
static getSSLConfig() {
|
||||
return {
|
||||
enabled: process.env.ENABLE_SSL === "true",
|
||||
port: parseInt(process.env.SSL_PORT || "8443"),
|
||||
certPath: process.env.SSL_CERT_PATH || this.CERT_FILE,
|
||||
keyPath: process.env.SSL_KEY_PATH || this.KEY_FILE,
|
||||
domain: process.env.SSL_DOMAIN || "localhost",
|
||||
};
|
||||
}
|
||||
}
|
||||
284
src/backend/utils/data-crypto.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { FieldCrypto } from "./field-crypto.js";
|
||||
import { LazyFieldEncryption } from "./lazy-field-encryption.js";
|
||||
import { UserCrypto } from "./user-crypto.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
class DataCrypto {
|
||||
private static userCrypto: UserCrypto;
|
||||
|
||||
static initialize() {
|
||||
this.userCrypto = UserCrypto.getInstance();
|
||||
}
|
||||
|
||||
static encryptRecord(
|
||||
tableName: string,
|
||||
record: any,
|
||||
userId: string,
|
||||
userDataKey: Buffer,
|
||||
): any {
|
||||
const encryptedRecord = { ...record };
|
||||
const recordId = record.id || "temp-" + Date.now();
|
||||
|
||||
for (const [fieldName, value] of Object.entries(record)) {
|
||||
if (FieldCrypto.shouldEncryptField(tableName, fieldName) && value) {
|
||||
encryptedRecord[fieldName] = FieldCrypto.encryptField(
|
||||
value as string,
|
||||
userDataKey,
|
||||
recordId,
|
||||
fieldName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return encryptedRecord;
|
||||
}
|
||||
|
||||
static decryptRecord(
|
||||
tableName: string,
|
||||
record: any,
|
||||
userId: string,
|
||||
userDataKey: Buffer,
|
||||
): any {
|
||||
if (!record) return record;
|
||||
|
||||
const decryptedRecord = { ...record };
|
||||
const recordId = record.id;
|
||||
|
||||
for (const [fieldName, value] of Object.entries(record)) {
|
||||
if (FieldCrypto.shouldEncryptField(tableName, fieldName) && value) {
|
||||
decryptedRecord[fieldName] = LazyFieldEncryption.safeGetFieldValue(
|
||||
value as string,
|
||||
userDataKey,
|
||||
recordId,
|
||||
fieldName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return decryptedRecord;
|
||||
}
|
||||
|
||||
static decryptRecords(
|
||||
tableName: string,
|
||||
records: any[],
|
||||
userId: string,
|
||||
userDataKey: Buffer,
|
||||
): any[] {
|
||||
if (!Array.isArray(records)) return records;
|
||||
return records.map((record) =>
|
||||
this.decryptRecord(tableName, record, userId, userDataKey),
|
||||
);
|
||||
}
|
||||
|
||||
static async migrateUserSensitiveFields(
|
||||
userId: string,
|
||||
userDataKey: Buffer,
|
||||
db: any,
|
||||
): Promise<{
|
||||
migrated: boolean;
|
||||
migratedTables: string[];
|
||||
migratedFieldsCount: number;
|
||||
}> {
|
||||
let migrated = false;
|
||||
const migratedTables: string[] = [];
|
||||
let migratedFieldsCount = 0;
|
||||
|
||||
try {
|
||||
const { needsMigration, plaintextFields } =
|
||||
await LazyFieldEncryption.checkUserNeedsMigration(
|
||||
userId,
|
||||
userDataKey,
|
||||
db,
|
||||
);
|
||||
|
||||
if (!needsMigration) {
|
||||
return { migrated: false, migratedTables: [], migratedFieldsCount: 0 };
|
||||
}
|
||||
|
||||
const sshDataRecords = db
|
||||
.prepare("SELECT * FROM ssh_data WHERE user_id = ?")
|
||||
.all(userId);
|
||||
for (const record of sshDataRecords) {
|
||||
const sensitiveFields =
|
||||
LazyFieldEncryption.getSensitiveFieldsForTable("ssh_data");
|
||||
const { updatedRecord, migratedFields, needsUpdate } =
|
||||
LazyFieldEncryption.migrateRecordSensitiveFields(
|
||||
record,
|
||||
sensitiveFields,
|
||||
userDataKey,
|
||||
record.id.toString(),
|
||||
);
|
||||
|
||||
if (needsUpdate) {
|
||||
const updateQuery = `
|
||||
UPDATE ssh_data
|
||||
SET password = ?, key = ?, key_password = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`;
|
||||
db.prepare(updateQuery).run(
|
||||
updatedRecord.password || null,
|
||||
updatedRecord.key || null,
|
||||
updatedRecord.key_password || null,
|
||||
record.id,
|
||||
);
|
||||
|
||||
migratedFieldsCount += migratedFields.length;
|
||||
if (!migratedTables.includes("ssh_data")) {
|
||||
migratedTables.push("ssh_data");
|
||||
}
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
|
||||
const sshCredentialsRecords = db
|
||||
.prepare("SELECT * FROM ssh_credentials WHERE user_id = ?")
|
||||
.all(userId);
|
||||
for (const record of sshCredentialsRecords) {
|
||||
const sensitiveFields =
|
||||
LazyFieldEncryption.getSensitiveFieldsForTable("ssh_credentials");
|
||||
const { updatedRecord, migratedFields, needsUpdate } =
|
||||
LazyFieldEncryption.migrateRecordSensitiveFields(
|
||||
record,
|
||||
sensitiveFields,
|
||||
userDataKey,
|
||||
record.id.toString(),
|
||||
);
|
||||
|
||||
if (needsUpdate) {
|
||||
const updateQuery = `
|
||||
UPDATE ssh_credentials
|
||||
SET password = ?, key = ?, key_password = ?, private_key = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`;
|
||||
db.prepare(updateQuery).run(
|
||||
updatedRecord.password || null,
|
||||
updatedRecord.key || null,
|
||||
updatedRecord.key_password || null,
|
||||
updatedRecord.private_key || null,
|
||||
record.id,
|
||||
);
|
||||
|
||||
migratedFieldsCount += migratedFields.length;
|
||||
if (!migratedTables.includes("ssh_credentials")) {
|
||||
migratedTables.push("ssh_credentials");
|
||||
}
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
|
||||
const userRecord = db
|
||||
.prepare("SELECT * FROM users WHERE id = ?")
|
||||
.get(userId);
|
||||
if (userRecord) {
|
||||
const sensitiveFields =
|
||||
LazyFieldEncryption.getSensitiveFieldsForTable("users");
|
||||
const { updatedRecord, migratedFields, needsUpdate } =
|
||||
LazyFieldEncryption.migrateRecordSensitiveFields(
|
||||
userRecord,
|
||||
sensitiveFields,
|
||||
userDataKey,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (needsUpdate) {
|
||||
const updateQuery = `
|
||||
UPDATE users
|
||||
SET totp_secret = ?, totp_backup_codes = ?
|
||||
WHERE id = ?
|
||||
`;
|
||||
db.prepare(updateQuery).run(
|
||||
updatedRecord.totp_secret || null,
|
||||
updatedRecord.totp_backup_codes || null,
|
||||
userId,
|
||||
);
|
||||
|
||||
migratedFieldsCount += migratedFields.length;
|
||||
if (!migratedTables.includes("users")) {
|
||||
migratedTables.push("users");
|
||||
}
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { migrated, migratedTables, migratedFieldsCount };
|
||||
} catch (error) {
|
||||
databaseLogger.error("User sensitive fields migration failed", error, {
|
||||
operation: "user_sensitive_migration_failed",
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
|
||||
return { migrated: false, migratedTables: [], migratedFieldsCount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
static getUserDataKey(userId: string): Buffer | null {
|
||||
return this.userCrypto.getUserDataKey(userId);
|
||||
}
|
||||
|
||||
static validateUserAccess(userId: string): Buffer {
|
||||
const userDataKey = this.getUserDataKey(userId);
|
||||
if (!userDataKey) {
|
||||
throw new Error(`User ${userId} data not unlocked`);
|
||||
}
|
||||
return userDataKey;
|
||||
}
|
||||
|
||||
static encryptRecordForUser(
|
||||
tableName: string,
|
||||
record: any,
|
||||
userId: string,
|
||||
): any {
|
||||
const userDataKey = this.validateUserAccess(userId);
|
||||
return this.encryptRecord(tableName, record, userId, userDataKey);
|
||||
}
|
||||
|
||||
static decryptRecordForUser(
|
||||
tableName: string,
|
||||
record: any,
|
||||
userId: string,
|
||||
): any {
|
||||
const userDataKey = this.validateUserAccess(userId);
|
||||
return this.decryptRecord(tableName, record, userId, userDataKey);
|
||||
}
|
||||
|
||||
static decryptRecordsForUser(
|
||||
tableName: string,
|
||||
records: any[],
|
||||
userId: string,
|
||||
): any[] {
|
||||
const userDataKey = this.validateUserAccess(userId);
|
||||
return this.decryptRecords(tableName, records, userId, userDataKey);
|
||||
}
|
||||
|
||||
static canUserAccessData(userId: string): boolean {
|
||||
return this.userCrypto.isUserUnlocked(userId);
|
||||
}
|
||||
|
||||
static testUserEncryption(userId: string): boolean {
|
||||
try {
|
||||
const userDataKey = this.getUserDataKey(userId);
|
||||
if (!userDataKey) return false;
|
||||
|
||||
const testData = "test-" + Date.now();
|
||||
const encrypted = FieldCrypto.encryptField(
|
||||
testData,
|
||||
userDataKey,
|
||||
"test-record",
|
||||
"test-field",
|
||||
);
|
||||
const decrypted = FieldCrypto.decryptField(
|
||||
encrypted,
|
||||
userDataKey,
|
||||
"test-record",
|
||||
"test-field",
|
||||
);
|
||||
|
||||
return decrypted === testData;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { DataCrypto };
|
||||
400
src/backend/utils/database-file-encryption.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
import crypto from "crypto";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
import { SystemCrypto } from "./system-crypto.js";
|
||||
|
||||
interface EncryptedFileMetadata {
|
||||
iv: string;
|
||||
tag: string;
|
||||
version: string;
|
||||
fingerprint: string;
|
||||
algorithm: string;
|
||||
keySource?: string;
|
||||
salt?: string;
|
||||
}
|
||||
|
||||
class DatabaseFileEncryption {
|
||||
private static readonly VERSION = "v2";
|
||||
private static readonly ALGORITHM = "aes-256-gcm";
|
||||
private static readonly ENCRYPTED_FILE_SUFFIX = ".encrypted";
|
||||
private static readonly METADATA_FILE_SUFFIX = ".meta";
|
||||
private static systemCrypto = SystemCrypto.getInstance();
|
||||
|
||||
static async encryptDatabaseFromBuffer(
|
||||
buffer: Buffer,
|
||||
targetPath: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const key = await this.systemCrypto.getDatabaseKey();
|
||||
|
||||
const iv = crypto.randomBytes(16);
|
||||
|
||||
const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv) as any;
|
||||
const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
const metadata: EncryptedFileMetadata = {
|
||||
iv: iv.toString("hex"),
|
||||
tag: tag.toString("hex"),
|
||||
version: this.VERSION,
|
||||
fingerprint: "termix-v2-systemcrypto",
|
||||
algorithm: this.ALGORITHM,
|
||||
keySource: "SystemCrypto",
|
||||
};
|
||||
|
||||
const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`;
|
||||
fs.writeFileSync(targetPath, encrypted);
|
||||
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
|
||||
|
||||
return targetPath;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to encrypt database buffer", error, {
|
||||
operation: "database_buffer_encryption_failed",
|
||||
targetPath,
|
||||
});
|
||||
throw new Error(
|
||||
`Database buffer encryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static async encryptDatabaseFile(
|
||||
sourcePath: string,
|
||||
targetPath?: string,
|
||||
): Promise<string> {
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
throw new Error(`Source database file does not exist: ${sourcePath}`);
|
||||
}
|
||||
|
||||
const encryptedPath =
|
||||
targetPath || `${sourcePath}${this.ENCRYPTED_FILE_SUFFIX}`;
|
||||
const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`;
|
||||
|
||||
try {
|
||||
const sourceData = fs.readFileSync(sourcePath);
|
||||
|
||||
const key = await this.systemCrypto.getDatabaseKey();
|
||||
|
||||
const iv = crypto.randomBytes(16);
|
||||
|
||||
const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv) as any;
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(sourceData),
|
||||
cipher.final(),
|
||||
]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
const metadata: EncryptedFileMetadata = {
|
||||
iv: iv.toString("hex"),
|
||||
tag: tag.toString("hex"),
|
||||
version: this.VERSION,
|
||||
fingerprint: "termix-v2-systemcrypto",
|
||||
algorithm: this.ALGORITHM,
|
||||
keySource: "SystemCrypto",
|
||||
};
|
||||
|
||||
fs.writeFileSync(encryptedPath, encrypted);
|
||||
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
|
||||
|
||||
databaseLogger.info("Database file encrypted successfully", {
|
||||
operation: "database_file_encryption",
|
||||
sourcePath,
|
||||
encryptedPath,
|
||||
fileSize: sourceData.length,
|
||||
encryptedSize: encrypted.length,
|
||||
fingerprintPrefix: metadata.fingerprint,
|
||||
});
|
||||
|
||||
return encryptedPath;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to encrypt database file", error, {
|
||||
operation: "database_file_encryption_failed",
|
||||
sourcePath,
|
||||
targetPath: encryptedPath,
|
||||
});
|
||||
throw new Error(
|
||||
`Database file encryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static async decryptDatabaseToBuffer(encryptedPath: string): Promise<Buffer> {
|
||||
if (!fs.existsSync(encryptedPath)) {
|
||||
throw new Error(
|
||||
`Encrypted database file does not exist: ${encryptedPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`;
|
||||
if (!fs.existsSync(metadataPath)) {
|
||||
throw new Error(`Metadata file does not exist: ${metadataPath}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const metadataContent = fs.readFileSync(metadataPath, "utf8");
|
||||
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
|
||||
|
||||
const encryptedData = fs.readFileSync(encryptedPath);
|
||||
|
||||
let key: Buffer;
|
||||
if (metadata.version === "v2") {
|
||||
key = await this.systemCrypto.getDatabaseKey();
|
||||
} else if (metadata.version === "v1") {
|
||||
databaseLogger.warn(
|
||||
"Decrypting legacy v1 encrypted database - consider upgrading",
|
||||
{
|
||||
operation: "decrypt_legacy_v1",
|
||||
path: encryptedPath,
|
||||
},
|
||||
);
|
||||
if (!metadata.salt) {
|
||||
throw new Error("v1 encrypted file missing required salt field");
|
||||
}
|
||||
const salt = Buffer.from(metadata.salt, "hex");
|
||||
const fixedSeed =
|
||||
process.env.DB_FILE_KEY || "termix-database-file-encryption-seed-v1";
|
||||
key = crypto.pbkdf2Sync(fixedSeed, salt, 100000, 32, "sha256");
|
||||
} else {
|
||||
throw new Error(`Unsupported encryption version: ${metadata.version}`);
|
||||
}
|
||||
|
||||
const decipher = crypto.createDecipheriv(
|
||||
metadata.algorithm,
|
||||
key,
|
||||
Buffer.from(metadata.iv, "hex"),
|
||||
) as any;
|
||||
decipher.setAuthTag(Buffer.from(metadata.tag, "hex"));
|
||||
|
||||
const decryptedBuffer = Buffer.concat([
|
||||
decipher.update(encryptedData),
|
||||
decipher.final(),
|
||||
]);
|
||||
|
||||
return decryptedBuffer;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to decrypt database to buffer", error, {
|
||||
operation: "database_buffer_decryption_failed",
|
||||
encryptedPath,
|
||||
});
|
||||
throw new Error(
|
||||
`Database buffer decryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static async decryptDatabaseFile(
|
||||
encryptedPath: string,
|
||||
targetPath?: string,
|
||||
): Promise<string> {
|
||||
if (!fs.existsSync(encryptedPath)) {
|
||||
throw new Error(
|
||||
`Encrypted database file does not exist: ${encryptedPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`;
|
||||
if (!fs.existsSync(metadataPath)) {
|
||||
throw new Error(`Metadata file does not exist: ${metadataPath}`);
|
||||
}
|
||||
|
||||
const decryptedPath =
|
||||
targetPath || encryptedPath.replace(this.ENCRYPTED_FILE_SUFFIX, "");
|
||||
|
||||
try {
|
||||
const metadataContent = fs.readFileSync(metadataPath, "utf8");
|
||||
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
|
||||
|
||||
const encryptedData = fs.readFileSync(encryptedPath);
|
||||
|
||||
let key: Buffer;
|
||||
if (metadata.version === "v2") {
|
||||
key = await this.systemCrypto.getDatabaseKey();
|
||||
} else if (metadata.version === "v1") {
|
||||
databaseLogger.warn(
|
||||
"Decrypting legacy v1 encrypted database - consider upgrading",
|
||||
{
|
||||
operation: "decrypt_legacy_v1",
|
||||
path: encryptedPath,
|
||||
},
|
||||
);
|
||||
if (!metadata.salt) {
|
||||
throw new Error("v1 encrypted file missing required salt field");
|
||||
}
|
||||
const salt = Buffer.from(metadata.salt, "hex");
|
||||
const fixedSeed =
|
||||
process.env.DB_FILE_KEY || "termix-database-file-encryption-seed-v1";
|
||||
key = crypto.pbkdf2Sync(fixedSeed, salt, 100000, 32, "sha256");
|
||||
} else {
|
||||
throw new Error(`Unsupported encryption version: ${metadata.version}`);
|
||||
}
|
||||
|
||||
const decipher = crypto.createDecipheriv(
|
||||
metadata.algorithm,
|
||||
key,
|
||||
Buffer.from(metadata.iv, "hex"),
|
||||
) as any;
|
||||
decipher.setAuthTag(Buffer.from(metadata.tag, "hex"));
|
||||
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(encryptedData),
|
||||
decipher.final(),
|
||||
]);
|
||||
|
||||
fs.writeFileSync(decryptedPath, decrypted);
|
||||
|
||||
databaseLogger.info("Database file decrypted successfully", {
|
||||
operation: "database_file_decryption",
|
||||
encryptedPath,
|
||||
decryptedPath,
|
||||
encryptedSize: encryptedData.length,
|
||||
decryptedSize: decrypted.length,
|
||||
fingerprintPrefix: metadata.fingerprint,
|
||||
});
|
||||
|
||||
return decryptedPath;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to decrypt database file", error, {
|
||||
operation: "database_file_decryption_failed",
|
||||
encryptedPath,
|
||||
targetPath: decryptedPath,
|
||||
});
|
||||
throw new Error(
|
||||
`Database file decryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static isEncryptedDatabaseFile(filePath: string): boolean {
|
||||
const metadataPath = `${filePath}${this.METADATA_FILE_SUFFIX}`;
|
||||
|
||||
if (!fs.existsSync(filePath) || !fs.existsSync(metadataPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const metadataContent = fs.readFileSync(metadataPath, "utf8");
|
||||
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
|
||||
return (
|
||||
metadata.version === this.VERSION &&
|
||||
metadata.algorithm === this.ALGORITHM
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static getEncryptedFileInfo(encryptedPath: string): {
|
||||
version: string;
|
||||
algorithm: string;
|
||||
fingerprint: string;
|
||||
isCurrentHardware: boolean;
|
||||
fileSize: number;
|
||||
} | null {
|
||||
if (!this.isEncryptedDatabaseFile(encryptedPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`;
|
||||
const metadataContent = fs.readFileSync(metadataPath, "utf8");
|
||||
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
|
||||
|
||||
const fileStats = fs.statSync(encryptedPath);
|
||||
const currentFingerprint = "termix-v1-file";
|
||||
|
||||
return {
|
||||
version: metadata.version,
|
||||
algorithm: metadata.algorithm,
|
||||
fingerprint: metadata.fingerprint,
|
||||
isCurrentHardware: true,
|
||||
fileSize: fileStats.size,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static async createEncryptedBackup(
|
||||
databasePath: string,
|
||||
backupDir: string,
|
||||
): Promise<string> {
|
||||
if (!fs.existsSync(databasePath)) {
|
||||
throw new Error(`Database file does not exist: ${databasePath}`);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(backupDir)) {
|
||||
fs.mkdirSync(backupDir, { recursive: true });
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const backupFileName = `database-backup-${timestamp}.sqlite.encrypted`;
|
||||
const backupPath = path.join(backupDir, backupFileName);
|
||||
|
||||
try {
|
||||
const encryptedPath = await this.encryptDatabaseFile(
|
||||
databasePath,
|
||||
backupPath,
|
||||
);
|
||||
|
||||
return encryptedPath;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to create encrypted backup", error, {
|
||||
operation: "database_backup_failed",
|
||||
sourcePath: databasePath,
|
||||
backupDir,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async restoreFromEncryptedBackup(
|
||||
backupPath: string,
|
||||
targetPath: string,
|
||||
): Promise<string> {
|
||||
if (!this.isEncryptedDatabaseFile(backupPath)) {
|
||||
throw new Error("Invalid encrypted backup file");
|
||||
}
|
||||
|
||||
try {
|
||||
const restoredPath = await this.decryptDatabaseFile(
|
||||
backupPath,
|
||||
targetPath,
|
||||
);
|
||||
|
||||
return restoredPath;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to restore from encrypted backup", error, {
|
||||
operation: "database_restore_failed",
|
||||
backupPath,
|
||||
targetPath,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static cleanupTempFiles(basePath: string): void {
|
||||
try {
|
||||
const tempFiles = [
|
||||
`${basePath}.tmp`,
|
||||
`${basePath}${this.ENCRYPTED_FILE_SUFFIX}`,
|
||||
`${basePath}${this.ENCRYPTED_FILE_SUFFIX}${this.METADATA_FILE_SUFFIX}`,
|
||||
];
|
||||
|
||||
for (const tempFile of tempFiles) {
|
||||
if (fs.existsSync(tempFile)) {
|
||||
fs.unlinkSync(tempFile);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Failed to clean up temporary files", {
|
||||
operation: "temp_cleanup_failed",
|
||||
basePath,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { DatabaseFileEncryption };
|
||||
export type { EncryptedFileMetadata };
|
||||
404
src/backend/utils/database-migration.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
import Database from "better-sqlite3";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
import { DatabaseFileEncryption } from "./database-file-encryption.js";
|
||||
|
||||
export interface MigrationResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
migratedTables: number;
|
||||
migratedRows: number;
|
||||
backupPath?: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface MigrationStatus {
|
||||
needsMigration: boolean;
|
||||
hasUnencryptedDb: boolean;
|
||||
hasEncryptedDb: boolean;
|
||||
unencryptedDbSize: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export class DatabaseMigration {
|
||||
private dataDir: string;
|
||||
private unencryptedDbPath: string;
|
||||
private encryptedDbPath: string;
|
||||
|
||||
constructor(dataDir: string) {
|
||||
this.dataDir = dataDir;
|
||||
this.unencryptedDbPath = path.join(dataDir, "db.sqlite");
|
||||
this.encryptedDbPath = `${this.unencryptedDbPath}.encrypted`;
|
||||
}
|
||||
|
||||
checkMigrationStatus(): MigrationStatus {
|
||||
const hasUnencryptedDb = fs.existsSync(this.unencryptedDbPath);
|
||||
const hasEncryptedDb = DatabaseFileEncryption.isEncryptedDatabaseFile(
|
||||
this.encryptedDbPath,
|
||||
);
|
||||
|
||||
let unencryptedDbSize = 0;
|
||||
if (hasUnencryptedDb) {
|
||||
try {
|
||||
unencryptedDbSize = fs.statSync(this.unencryptedDbPath).size;
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Could not get unencrypted database file size", {
|
||||
operation: "migration_status_check",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let needsMigration = false;
|
||||
let reason = "";
|
||||
|
||||
if (hasEncryptedDb && hasUnencryptedDb) {
|
||||
const unencryptedSize = fs.statSync(this.unencryptedDbPath).size;
|
||||
const encryptedSize = fs.statSync(this.encryptedDbPath).size;
|
||||
|
||||
if (unencryptedSize === 0) {
|
||||
needsMigration = false;
|
||||
reason =
|
||||
"Empty unencrypted database found alongside encrypted database. Removing empty file.";
|
||||
try {
|
||||
fs.unlinkSync(this.unencryptedDbPath);
|
||||
databaseLogger.info("Removed empty unencrypted database file", {
|
||||
operation: "migration_cleanup_empty",
|
||||
path: this.unencryptedDbPath,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Failed to remove empty unencrypted database", {
|
||||
operation: "migration_cleanup_empty_failed",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
needsMigration = false;
|
||||
reason =
|
||||
"Both encrypted and unencrypted databases exist. Skipping migration for safety. Manual intervention may be required.";
|
||||
}
|
||||
} else if (hasEncryptedDb && !hasUnencryptedDb) {
|
||||
needsMigration = false;
|
||||
reason = "Only encrypted database exists. No migration needed.";
|
||||
} else if (!hasEncryptedDb && hasUnencryptedDb) {
|
||||
needsMigration = true;
|
||||
reason =
|
||||
"Unencrypted database found. Migration to encrypted format required.";
|
||||
} else {
|
||||
needsMigration = false;
|
||||
reason = "No existing database found. This is a fresh installation.";
|
||||
}
|
||||
|
||||
return {
|
||||
needsMigration,
|
||||
hasUnencryptedDb,
|
||||
hasEncryptedDb,
|
||||
unencryptedDbSize,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
private createBackup(): string {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const backupPath = `${this.unencryptedDbPath}.migration-backup-${timestamp}`;
|
||||
|
||||
try {
|
||||
fs.copyFileSync(this.unencryptedDbPath, backupPath);
|
||||
|
||||
const originalSize = fs.statSync(this.unencryptedDbPath).size;
|
||||
const backupSize = fs.statSync(backupPath).size;
|
||||
|
||||
if (originalSize !== backupSize) {
|
||||
throw new Error(
|
||||
`Backup size mismatch: original=${originalSize}, backup=${backupSize}`,
|
||||
);
|
||||
}
|
||||
|
||||
return backupPath;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to create migration backup", error, {
|
||||
operation: "migration_backup_failed",
|
||||
source: this.unencryptedDbPath,
|
||||
backup: backupPath,
|
||||
});
|
||||
throw new Error(
|
||||
`Backup creation failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async verifyMigration(
|
||||
originalDb: Database.Database,
|
||||
memoryDb: Database.Database,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
memoryDb.exec("PRAGMA foreign_keys = OFF");
|
||||
|
||||
const originalTables = originalDb
|
||||
.prepare(
|
||||
`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
||||
ORDER BY name
|
||||
`,
|
||||
)
|
||||
.all() as { name: string }[];
|
||||
|
||||
const memoryTables = memoryDb
|
||||
.prepare(
|
||||
`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
||||
ORDER BY name
|
||||
`,
|
||||
)
|
||||
.all() as { name: string }[];
|
||||
|
||||
if (originalTables.length !== memoryTables.length) {
|
||||
databaseLogger.error(
|
||||
"Table count mismatch during migration verification",
|
||||
null,
|
||||
{
|
||||
operation: "migration_verify_failed",
|
||||
originalCount: originalTables.length,
|
||||
memoryCount: memoryTables.length,
|
||||
},
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
let totalOriginalRows = 0;
|
||||
let totalMemoryRows = 0;
|
||||
|
||||
for (const table of originalTables) {
|
||||
const originalCount = originalDb
|
||||
.prepare(`SELECT COUNT(*) as count FROM ${table.name}`)
|
||||
.get() as { count: number };
|
||||
const memoryCount = memoryDb
|
||||
.prepare(`SELECT COUNT(*) as count FROM ${table.name}`)
|
||||
.get() as { count: number };
|
||||
|
||||
totalOriginalRows += originalCount.count;
|
||||
totalMemoryRows += memoryCount.count;
|
||||
|
||||
if (originalCount.count !== memoryCount.count) {
|
||||
databaseLogger.error(
|
||||
"Row count mismatch for table during migration verification",
|
||||
null,
|
||||
{
|
||||
operation: "migration_verify_table_failed",
|
||||
table: table.name,
|
||||
originalRows: originalCount.count,
|
||||
memoryRows: memoryCount.count,
|
||||
},
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
memoryDb.exec("PRAGMA foreign_keys = ON");
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Migration verification failed", error, {
|
||||
operation: "migration_verify_error",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async migrateDatabase(): Promise<MigrationResult> {
|
||||
const startTime = Date.now();
|
||||
let backupPath: string | undefined;
|
||||
let migratedTables = 0;
|
||||
let migratedRows = 0;
|
||||
|
||||
try {
|
||||
backupPath = this.createBackup();
|
||||
|
||||
const originalDb = new Database(this.unencryptedDbPath, {
|
||||
readonly: true,
|
||||
});
|
||||
|
||||
const memoryDb = new Database(":memory:");
|
||||
|
||||
try {
|
||||
const tables = originalDb
|
||||
.prepare(
|
||||
`
|
||||
SELECT name, sql FROM sqlite_master
|
||||
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
||||
`,
|
||||
)
|
||||
.all() as { name: string; sql: string }[];
|
||||
|
||||
for (const table of tables) {
|
||||
memoryDb.exec(table.sql);
|
||||
migratedTables++;
|
||||
}
|
||||
|
||||
memoryDb.exec("PRAGMA foreign_keys = OFF");
|
||||
|
||||
for (const table of tables) {
|
||||
const rows = originalDb.prepare(`SELECT * FROM ${table.name}`).all();
|
||||
|
||||
if (rows.length > 0) {
|
||||
const columns = Object.keys(rows[0]);
|
||||
const placeholders = columns.map(() => "?").join(", ");
|
||||
const insertStmt = memoryDb.prepare(
|
||||
`INSERT INTO ${table.name} (${columns.join(", ")}) VALUES (${placeholders})`,
|
||||
);
|
||||
|
||||
const insertTransaction = memoryDb.transaction(
|
||||
(dataRows: any[]) => {
|
||||
for (const row of dataRows) {
|
||||
const values = columns.map((col) => row[col]);
|
||||
insertStmt.run(values);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
insertTransaction(rows);
|
||||
migratedRows += rows.length;
|
||||
}
|
||||
}
|
||||
|
||||
memoryDb.exec("PRAGMA foreign_keys = ON");
|
||||
|
||||
const fkCheckResult = memoryDb
|
||||
.prepare("PRAGMA foreign_key_check")
|
||||
.all();
|
||||
if (fkCheckResult.length > 0) {
|
||||
databaseLogger.error(
|
||||
"Foreign key constraints violations detected after migration",
|
||||
null,
|
||||
{
|
||||
operation: "migration_fk_check_failed",
|
||||
violations: fkCheckResult,
|
||||
},
|
||||
);
|
||||
throw new Error(
|
||||
`Foreign key violations detected: ${JSON.stringify(fkCheckResult)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const verificationPassed = await this.verifyMigration(
|
||||
originalDb,
|
||||
memoryDb,
|
||||
);
|
||||
if (!verificationPassed) {
|
||||
throw new Error("Migration integrity verification failed");
|
||||
}
|
||||
|
||||
const buffer = memoryDb.serialize();
|
||||
|
||||
await DatabaseFileEncryption.encryptDatabaseFromBuffer(
|
||||
buffer,
|
||||
this.encryptedDbPath,
|
||||
);
|
||||
|
||||
if (
|
||||
!DatabaseFileEncryption.isEncryptedDatabaseFile(this.encryptedDbPath)
|
||||
) {
|
||||
throw new Error("Encrypted database file verification failed");
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const migratedPath = `${this.unencryptedDbPath}.migrated-${timestamp}`;
|
||||
|
||||
fs.renameSync(this.unencryptedDbPath, migratedPath);
|
||||
|
||||
databaseLogger.success("Database migration completed successfully", {
|
||||
operation: "migration_complete",
|
||||
migratedTables,
|
||||
migratedRows,
|
||||
duration: Date.now() - startTime,
|
||||
backupPath,
|
||||
migratedPath,
|
||||
encryptedDbPath: this.encryptedDbPath,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
migratedTables,
|
||||
migratedRows,
|
||||
backupPath,
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
} finally {
|
||||
originalDb.close();
|
||||
memoryDb.close();
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
|
||||
databaseLogger.error("Database migration failed", error, {
|
||||
operation: "migration_failed",
|
||||
migratedTables,
|
||||
migratedRows,
|
||||
duration: Date.now() - startTime,
|
||||
backupPath,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
migratedTables,
|
||||
migratedRows,
|
||||
backupPath,
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
cleanupOldBackups(): void {
|
||||
try {
|
||||
const backupPattern =
|
||||
/\.migration-backup-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/;
|
||||
const migratedPattern =
|
||||
/\.migrated-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/;
|
||||
|
||||
const files = fs.readdirSync(this.dataDir);
|
||||
|
||||
const backupFiles = files
|
||||
.filter((f) => backupPattern.test(f))
|
||||
.map((f) => ({
|
||||
name: f,
|
||||
path: path.join(this.dataDir, f),
|
||||
mtime: fs.statSync(path.join(this.dataDir, f)).mtime,
|
||||
}))
|
||||
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
||||
|
||||
const migratedFiles = files
|
||||
.filter((f) => migratedPattern.test(f))
|
||||
.map((f) => ({
|
||||
name: f,
|
||||
path: path.join(this.dataDir, f),
|
||||
mtime: fs.statSync(path.join(this.dataDir, f)).mtime,
|
||||
}))
|
||||
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
||||
|
||||
const backupsToDelete = backupFiles.slice(3);
|
||||
const migratedToDelete = migratedFiles.slice(3);
|
||||
|
||||
for (const file of [...backupsToDelete, ...migratedToDelete]) {
|
||||
try {
|
||||
fs.unlinkSync(file.path);
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Failed to cleanup old migration file", {
|
||||
operation: "migration_cleanup_failed",
|
||||
file: file.name,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Migration cleanup failed", {
|
||||
operation: "migration_cleanup_error",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
118
src/backend/utils/database-save-trigger.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
export class DatabaseSaveTrigger {
|
||||
private static saveFunction: (() => Promise<void>) | null = null;
|
||||
private static isInitialized = false;
|
||||
private static pendingSave = false;
|
||||
private static saveTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
static initialize(saveFunction: () => Promise<void>): void {
|
||||
this.saveFunction = saveFunction;
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
static async triggerSave(
|
||||
reason: string = "data_modification",
|
||||
): Promise<void> {
|
||||
if (!this.isInitialized || !this.saveFunction) {
|
||||
databaseLogger.warn("Database save trigger not initialized", {
|
||||
operation: "db_save_trigger_not_init",
|
||||
reason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
}
|
||||
|
||||
this.saveTimeout = setTimeout(async () => {
|
||||
if (this.pendingSave) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingSave = true;
|
||||
|
||||
try {
|
||||
await this.saveFunction!();
|
||||
} catch (error) {
|
||||
databaseLogger.error("Database save failed", error, {
|
||||
operation: "db_save_trigger_failed",
|
||||
reason,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
this.pendingSave = false;
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
static async forceSave(reason: string = "critical_operation"): Promise<void> {
|
||||
if (!this.isInitialized || !this.saveFunction) {
|
||||
databaseLogger.warn(
|
||||
"Database save trigger not initialized for force save",
|
||||
{
|
||||
operation: "db_save_trigger_force_not_init",
|
||||
reason,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
this.saveTimeout = null;
|
||||
}
|
||||
|
||||
if (this.pendingSave) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingSave = true;
|
||||
|
||||
try {
|
||||
databaseLogger.info("Force saving database", {
|
||||
operation: "db_save_trigger_force_start",
|
||||
reason,
|
||||
});
|
||||
|
||||
await this.saveFunction();
|
||||
} catch (error) {
|
||||
databaseLogger.error("Database force save failed", error, {
|
||||
operation: "db_save_trigger_force_failed",
|
||||
reason,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
this.pendingSave = false;
|
||||
}
|
||||
}
|
||||
|
||||
static getStatus(): {
|
||||
initialized: boolean;
|
||||
pendingSave: boolean;
|
||||
hasPendingTimeout: boolean;
|
||||
} {
|
||||
return {
|
||||
initialized: this.isInitialized,
|
||||
pendingSave: this.pendingSave,
|
||||
hasPendingTimeout: this.saveTimeout !== null,
|
||||
};
|
||||
}
|
||||
|
||||
static cleanup(): void {
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
this.saveTimeout = null;
|
||||
}
|
||||
|
||||
this.pendingSave = false;
|
||||
this.isInitialized = false;
|
||||
this.saveFunction = null;
|
||||
|
||||
databaseLogger.info("Database save trigger cleaned up", {
|
||||
operation: "db_save_trigger_cleanup",
|
||||
});
|
||||
}
|
||||
}
|
||||
108
src/backend/utils/field-crypto.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
interface EncryptedData {
|
||||
data: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
salt: string;
|
||||
recordId: string;
|
||||
}
|
||||
|
||||
class FieldCrypto {
|
||||
private static readonly ALGORITHM = "aes-256-gcm";
|
||||
private static readonly KEY_LENGTH = 32;
|
||||
private static readonly IV_LENGTH = 16;
|
||||
private static readonly SALT_LENGTH = 32;
|
||||
|
||||
private static readonly ENCRYPTED_FIELDS = {
|
||||
users: new Set([
|
||||
"password_hash",
|
||||
"client_secret",
|
||||
"totp_secret",
|
||||
"totp_backup_codes",
|
||||
"oidc_identifier",
|
||||
]),
|
||||
ssh_data: new Set(["password", "key", "keyPassword"]),
|
||||
ssh_credentials: new Set([
|
||||
"password",
|
||||
"privateKey",
|
||||
"keyPassword",
|
||||
"key",
|
||||
"publicKey",
|
||||
]),
|
||||
};
|
||||
|
||||
static encryptField(
|
||||
plaintext: string,
|
||||
masterKey: Buffer,
|
||||
recordId: string,
|
||||
fieldName: string,
|
||||
): string {
|
||||
if (!plaintext) return "";
|
||||
|
||||
const salt = crypto.randomBytes(this.SALT_LENGTH);
|
||||
const context = `${recordId}:${fieldName}`;
|
||||
const fieldKey = Buffer.from(
|
||||
crypto.hkdfSync("sha256", masterKey, salt, context, this.KEY_LENGTH),
|
||||
);
|
||||
|
||||
const iv = crypto.randomBytes(this.IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(this.ALGORITHM, fieldKey, iv) as any;
|
||||
|
||||
let encrypted = cipher.update(plaintext, "utf8", "hex");
|
||||
encrypted += cipher.final("hex");
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
const encryptedData: EncryptedData = {
|
||||
data: encrypted,
|
||||
iv: iv.toString("hex"),
|
||||
tag: tag.toString("hex"),
|
||||
salt: salt.toString("hex"),
|
||||
recordId: recordId,
|
||||
};
|
||||
|
||||
return JSON.stringify(encryptedData);
|
||||
}
|
||||
|
||||
static decryptField(
|
||||
encryptedValue: string,
|
||||
masterKey: Buffer,
|
||||
recordId: string,
|
||||
fieldName: string,
|
||||
): string {
|
||||
if (!encryptedValue) return "";
|
||||
|
||||
const encrypted: EncryptedData = JSON.parse(encryptedValue);
|
||||
const salt = Buffer.from(encrypted.salt, "hex");
|
||||
|
||||
if (!encrypted.recordId) {
|
||||
throw new Error(
|
||||
`Encrypted field missing recordId context - data corruption or legacy format not supported`,
|
||||
);
|
||||
}
|
||||
const context = `${encrypted.recordId}:${fieldName}`;
|
||||
const fieldKey = Buffer.from(
|
||||
crypto.hkdfSync("sha256", masterKey, salt, context, this.KEY_LENGTH),
|
||||
);
|
||||
|
||||
const decipher = crypto.createDecipheriv(
|
||||
this.ALGORITHM,
|
||||
fieldKey,
|
||||
Buffer.from(encrypted.iv, "hex"),
|
||||
) as any;
|
||||
decipher.setAuthTag(Buffer.from(encrypted.tag, "hex"));
|
||||
|
||||
let decrypted = decipher.update(encrypted.data, "hex", "utf8");
|
||||
decrypted += decipher.final("utf8");
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
static shouldEncryptField(tableName: string, fieldName: string): boolean {
|
||||
const fields =
|
||||
this.ENCRYPTED_FIELDS[tableName as keyof typeof this.ENCRYPTED_FIELDS];
|
||||
return fields ? fields.has(fieldName) : false;
|
||||
}
|
||||
}
|
||||
|
||||
export { FieldCrypto, type EncryptedData };
|
||||
243
src/backend/utils/lazy-field-encryption.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { FieldCrypto } from "./field-crypto.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
export class LazyFieldEncryption {
|
||||
static isPlaintextField(value: string): boolean {
|
||||
if (!value) return false;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
parsed.data &&
|
||||
parsed.iv &&
|
||||
parsed.tag &&
|
||||
parsed.salt &&
|
||||
parsed.recordId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (jsonError) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
static safeGetFieldValue(
|
||||
fieldValue: string,
|
||||
userKEK: Buffer,
|
||||
recordId: string,
|
||||
fieldName: string,
|
||||
): string {
|
||||
if (!fieldValue) return "";
|
||||
|
||||
if (this.isPlaintextField(fieldValue)) {
|
||||
return fieldValue;
|
||||
} else {
|
||||
try {
|
||||
const decrypted = FieldCrypto.decryptField(
|
||||
fieldValue,
|
||||
userKEK,
|
||||
recordId,
|
||||
fieldName,
|
||||
);
|
||||
return decrypted;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to decrypt field", error, {
|
||||
operation: "lazy_encryption_decrypt_failed",
|
||||
recordId,
|
||||
fieldName,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static migrateFieldToEncrypted(
|
||||
fieldValue: string,
|
||||
userKEK: Buffer,
|
||||
recordId: string,
|
||||
fieldName: string,
|
||||
): { encrypted: string; wasPlaintext: boolean } {
|
||||
if (!fieldValue) {
|
||||
return { encrypted: "", wasPlaintext: false };
|
||||
}
|
||||
|
||||
if (this.isPlaintextField(fieldValue)) {
|
||||
try {
|
||||
const encrypted = FieldCrypto.encryptField(
|
||||
fieldValue,
|
||||
userKEK,
|
||||
recordId,
|
||||
fieldName,
|
||||
);
|
||||
|
||||
return { encrypted, wasPlaintext: true };
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to encrypt plaintext field", error, {
|
||||
operation: "lazy_encryption_migrate_failed",
|
||||
recordId,
|
||||
fieldName,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
return { encrypted: fieldValue, wasPlaintext: false };
|
||||
}
|
||||
}
|
||||
|
||||
static migrateRecordSensitiveFields(
|
||||
record: any,
|
||||
sensitiveFields: string[],
|
||||
userKEK: Buffer,
|
||||
recordId: string,
|
||||
): {
|
||||
updatedRecord: any;
|
||||
migratedFields: string[];
|
||||
needsUpdate: boolean;
|
||||
} {
|
||||
const updatedRecord = { ...record };
|
||||
const migratedFields: string[] = [];
|
||||
let needsUpdate = false;
|
||||
|
||||
for (const fieldName of sensitiveFields) {
|
||||
const fieldValue = record[fieldName];
|
||||
|
||||
if (fieldValue && this.isPlaintextField(fieldValue)) {
|
||||
try {
|
||||
const { encrypted } = this.migrateFieldToEncrypted(
|
||||
fieldValue,
|
||||
userKEK,
|
||||
recordId,
|
||||
fieldName,
|
||||
);
|
||||
|
||||
updatedRecord[fieldName] = encrypted;
|
||||
migratedFields.push(fieldName);
|
||||
needsUpdate = true;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to migrate record field", error, {
|
||||
operation: "lazy_encryption_record_field_failed",
|
||||
recordId,
|
||||
fieldName,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { updatedRecord, migratedFields, needsUpdate };
|
||||
}
|
||||
|
||||
static getSensitiveFieldsForTable(tableName: string): string[] {
|
||||
const sensitiveFieldsMap: Record<string, string[]> = {
|
||||
ssh_data: ["password", "key", "key_password"],
|
||||
ssh_credentials: ["password", "key", "key_password", "private_key"],
|
||||
users: ["totp_secret", "totp_backup_codes"],
|
||||
};
|
||||
|
||||
return sensitiveFieldsMap[tableName] || [];
|
||||
}
|
||||
|
||||
static async checkUserNeedsMigration(
|
||||
userId: string,
|
||||
userKEK: Buffer,
|
||||
db: any,
|
||||
): Promise<{
|
||||
needsMigration: boolean;
|
||||
plaintextFields: Array<{
|
||||
table: string;
|
||||
recordId: string;
|
||||
fields: string[];
|
||||
}>;
|
||||
}> {
|
||||
const plaintextFields: Array<{
|
||||
table: string;
|
||||
recordId: string;
|
||||
fields: string[];
|
||||
}> = [];
|
||||
let needsMigration = false;
|
||||
|
||||
try {
|
||||
const sshHosts = db
|
||||
.prepare("SELECT * FROM ssh_data WHERE user_id = ?")
|
||||
.all(userId);
|
||||
for (const host of sshHosts) {
|
||||
const sensitiveFields = this.getSensitiveFieldsForTable("ssh_data");
|
||||
const hostPlaintextFields: string[] = [];
|
||||
|
||||
for (const field of sensitiveFields) {
|
||||
if (host[field] && this.isPlaintextField(host[field])) {
|
||||
hostPlaintextFields.push(field);
|
||||
needsMigration = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hostPlaintextFields.length > 0) {
|
||||
plaintextFields.push({
|
||||
table: "ssh_data",
|
||||
recordId: host.id.toString(),
|
||||
fields: hostPlaintextFields,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sshCredentials = db
|
||||
.prepare("SELECT * FROM ssh_credentials WHERE user_id = ?")
|
||||
.all(userId);
|
||||
for (const credential of sshCredentials) {
|
||||
const sensitiveFields =
|
||||
this.getSensitiveFieldsForTable("ssh_credentials");
|
||||
const credentialPlaintextFields: string[] = [];
|
||||
|
||||
for (const field of sensitiveFields) {
|
||||
if (credential[field] && this.isPlaintextField(credential[field])) {
|
||||
credentialPlaintextFields.push(field);
|
||||
needsMigration = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (credentialPlaintextFields.length > 0) {
|
||||
plaintextFields.push({
|
||||
table: "ssh_credentials",
|
||||
recordId: credential.id.toString(),
|
||||
fields: credentialPlaintextFields,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(userId);
|
||||
if (user) {
|
||||
const sensitiveFields = this.getSensitiveFieldsForTable("users");
|
||||
const userPlaintextFields: string[] = [];
|
||||
|
||||
for (const field of sensitiveFields) {
|
||||
if (user[field] && this.isPlaintextField(user[field])) {
|
||||
userPlaintextFields.push(field);
|
||||
needsMigration = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (userPlaintextFields.length > 0) {
|
||||
plaintextFields.push({
|
||||
table: "users",
|
||||
recordId: userId,
|
||||
fields: userPlaintextFields,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { needsMigration, plaintextFields };
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to check user migration needs", error, {
|
||||
operation: "lazy_encryption_user_check_failed",
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
|
||||
return { needsMigration: false, plaintextFields: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
257
src/backend/utils/logger.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import chalk from "chalk";
|
||||
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error" | "success";
|
||||
|
||||
export interface LogContext {
|
||||
service?: string;
|
||||
operation?: string;
|
||||
userId?: string;
|
||||
hostId?: number;
|
||||
tunnelName?: string;
|
||||
sessionId?: string;
|
||||
requestId?: string;
|
||||
duration?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const SENSITIVE_FIELDS = [
|
||||
"password",
|
||||
"passphrase",
|
||||
"key",
|
||||
"privateKey",
|
||||
"publicKey",
|
||||
"token",
|
||||
"secret",
|
||||
"clientSecret",
|
||||
"keyPassword",
|
||||
"autostartPassword",
|
||||
"autostartKey",
|
||||
"autostartKeyPassword",
|
||||
"credentialId",
|
||||
"authToken",
|
||||
"jwt",
|
||||
"session",
|
||||
"cookie",
|
||||
];
|
||||
|
||||
const TRUNCATE_FIELDS = ["data", "content", "body", "response", "request"];
|
||||
|
||||
class Logger {
|
||||
private serviceName: string;
|
||||
private serviceIcon: string;
|
||||
private serviceColor: string;
|
||||
private logCounts = new Map<string, { count: number; lastLog: number }>();
|
||||
private readonly RATE_LIMIT_WINDOW = 60000;
|
||||
private readonly RATE_LIMIT_MAX = 10;
|
||||
|
||||
constructor(serviceName: string, serviceIcon: string, serviceColor: string) {
|
||||
this.serviceName = serviceName;
|
||||
this.serviceIcon = serviceIcon;
|
||||
this.serviceColor = serviceColor;
|
||||
}
|
||||
|
||||
private getTimeStamp(): string {
|
||||
return chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
}
|
||||
|
||||
private sanitizeContext(context: LogContext): LogContext {
|
||||
const sanitized = { ...context };
|
||||
|
||||
for (const field of SENSITIVE_FIELDS) {
|
||||
if (sanitized[field] !== undefined) {
|
||||
if (
|
||||
typeof sanitized[field] === "string" &&
|
||||
sanitized[field].length > 0
|
||||
) {
|
||||
sanitized[field] = "[MASKED]";
|
||||
} else if (typeof sanitized[field] === "boolean") {
|
||||
sanitized[field] = sanitized[field] ? "[PRESENT]" : "[ABSENT]";
|
||||
} else {
|
||||
sanitized[field] = "[MASKED]";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of TRUNCATE_FIELDS) {
|
||||
if (
|
||||
sanitized[field] &&
|
||||
typeof sanitized[field] === "string" &&
|
||||
sanitized[field].length > 100
|
||||
) {
|
||||
sanitized[field] = sanitized[field].substring(0, 100) + "...";
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
private formatMessage(
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
context?: LogContext,
|
||||
): string {
|
||||
const timestamp = this.getTimeStamp();
|
||||
const levelColor = this.getLevelColor(level);
|
||||
const serviceTag = chalk.hex(this.serviceColor)(`[${this.serviceIcon}]`);
|
||||
const levelTag = levelColor(`[${level.toUpperCase()}]`);
|
||||
|
||||
let contextStr = "";
|
||||
if (context) {
|
||||
const sanitizedContext = this.sanitizeContext(context);
|
||||
const contextParts = [];
|
||||
if (sanitizedContext.operation)
|
||||
contextParts.push(`op:${sanitizedContext.operation}`);
|
||||
if (sanitizedContext.userId)
|
||||
contextParts.push(`user:${sanitizedContext.userId}`);
|
||||
if (sanitizedContext.hostId)
|
||||
contextParts.push(`host:${sanitizedContext.hostId}`);
|
||||
if (sanitizedContext.tunnelName)
|
||||
contextParts.push(`tunnel:${sanitizedContext.tunnelName}`);
|
||||
if (sanitizedContext.sessionId)
|
||||
contextParts.push(`session:${sanitizedContext.sessionId}`);
|
||||
if (sanitizedContext.requestId)
|
||||
contextParts.push(`req:${sanitizedContext.requestId}`);
|
||||
if (sanitizedContext.duration)
|
||||
contextParts.push(`duration:${sanitizedContext.duration}ms`);
|
||||
|
||||
if (contextParts.length > 0) {
|
||||
contextStr = chalk.gray(` [${contextParts.join(",")}]`);
|
||||
}
|
||||
}
|
||||
|
||||
return `${timestamp} ${levelTag} ${serviceTag} ${message}${contextStr}`;
|
||||
}
|
||||
|
||||
private getLevelColor(level: LogLevel): chalk.Chalk {
|
||||
switch (level) {
|
||||
case "debug":
|
||||
return chalk.magenta;
|
||||
case "info":
|
||||
return chalk.cyan;
|
||||
case "warn":
|
||||
return chalk.yellow;
|
||||
case "error":
|
||||
return chalk.redBright;
|
||||
case "success":
|
||||
return chalk.greenBright;
|
||||
default:
|
||||
return chalk.white;
|
||||
}
|
||||
}
|
||||
|
||||
private shouldLog(level: LogLevel, message: string): boolean {
|
||||
if (level === "debug" && process.env.NODE_ENV === "production") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const logKey = `${level}:${message}`;
|
||||
const logInfo = this.logCounts.get(logKey);
|
||||
|
||||
if (logInfo) {
|
||||
if (now - logInfo.lastLog < this.RATE_LIMIT_WINDOW) {
|
||||
logInfo.count++;
|
||||
if (logInfo.count > this.RATE_LIMIT_MAX) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
logInfo.count = 1;
|
||||
logInfo.lastLog = now;
|
||||
}
|
||||
} else {
|
||||
this.logCounts.set(logKey, { count: 1, lastLog: now });
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
debug(message: string, context?: LogContext): void {
|
||||
if (!this.shouldLog("debug", message)) return;
|
||||
console.debug(this.formatMessage("debug", message, context));
|
||||
}
|
||||
|
||||
info(message: string, context?: LogContext): void {
|
||||
if (!this.shouldLog("info", message)) return;
|
||||
console.log(this.formatMessage("info", message, context));
|
||||
}
|
||||
|
||||
warn(message: string, context?: LogContext): void {
|
||||
if (!this.shouldLog("warn", message)) return;
|
||||
console.warn(this.formatMessage("warn", message, context));
|
||||
}
|
||||
|
||||
error(message: string, error?: unknown, context?: LogContext): void {
|
||||
if (!this.shouldLog("error", message)) return;
|
||||
console.error(this.formatMessage("error", message, context));
|
||||
if (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
success(message: string, context?: LogContext): void {
|
||||
if (!this.shouldLog("success", message)) return;
|
||||
console.log(this.formatMessage("success", message, context));
|
||||
}
|
||||
|
||||
auth(message: string, context?: LogContext): void {
|
||||
this.info(`AUTH: ${message}`, { ...context, operation: "auth" });
|
||||
}
|
||||
|
||||
db(message: string, context?: LogContext): void {
|
||||
this.info(`DB: ${message}`, { ...context, operation: "database" });
|
||||
}
|
||||
|
||||
ssh(message: string, context?: LogContext): void {
|
||||
this.info(`SSH: ${message}`, { ...context, operation: "ssh" });
|
||||
}
|
||||
|
||||
tunnel(message: string, context?: LogContext): void {
|
||||
this.info(`TUNNEL: ${message}`, { ...context, operation: "tunnel" });
|
||||
}
|
||||
|
||||
file(message: string, context?: LogContext): void {
|
||||
this.info(`FILE: ${message}`, { ...context, operation: "file" });
|
||||
}
|
||||
|
||||
api(message: string, context?: LogContext): void {
|
||||
this.info(`API: ${message}`, { ...context, operation: "api" });
|
||||
}
|
||||
|
||||
request(message: string, context?: LogContext): void {
|
||||
this.info(`REQUEST: ${message}`, { ...context, operation: "request" });
|
||||
}
|
||||
|
||||
response(message: string, context?: LogContext): void {
|
||||
this.info(`RESPONSE: ${message}`, { ...context, operation: "response" });
|
||||
}
|
||||
|
||||
connection(message: string, context?: LogContext): void {
|
||||
this.info(`CONNECTION: ${message}`, {
|
||||
...context,
|
||||
operation: "connection",
|
||||
});
|
||||
}
|
||||
|
||||
disconnect(message: string, context?: LogContext): void {
|
||||
this.info(`DISCONNECT: ${message}`, {
|
||||
...context,
|
||||
operation: "disconnect",
|
||||
});
|
||||
}
|
||||
|
||||
retry(message: string, context?: LogContext): void {
|
||||
this.warn(`RETRY: ${message}`, { ...context, operation: "retry" });
|
||||
}
|
||||
}
|
||||
|
||||
export const databaseLogger = new Logger("DATABASE", "🗄️", "#6366f1");
|
||||
export const sshLogger = new Logger("SSH", "🖥️", "#0ea5e9");
|
||||
export const tunnelLogger = new Logger("TUNNEL", "📡", "#a855f7");
|
||||
export const fileLogger = new Logger("FILE", "📁", "#f59e0b");
|
||||
export const statsLogger = new Logger("STATS", "📊", "#22c55e");
|
||||
export const apiLogger = new Logger("API", "🌐", "#3b82f6");
|
||||
export const authLogger = new Logger("AUTH", "🔐", "#ef4444");
|
||||
export const systemLogger = new Logger("SYSTEM", "🚀", "#14b8a6");
|
||||
export const versionLogger = new Logger("VERSION", "📦", "#8b5cf6");
|
||||
|
||||
export const logger = systemLogger;
|
||||
157
src/backend/utils/simple-db-ops.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { getDb, DatabaseSaveTrigger } from "../database/db/index.js";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
||||
|
||||
type TableName = "users" | "ssh_data" | "ssh_credentials";
|
||||
|
||||
class SimpleDBOps {
|
||||
static async insert<T extends Record<string, any>>(
|
||||
table: SQLiteTable<any>,
|
||||
tableName: TableName,
|
||||
data: T,
|
||||
userId: string,
|
||||
): Promise<T> {
|
||||
const userDataKey = DataCrypto.validateUserAccess(userId);
|
||||
|
||||
const tempId = data.id || `temp-${userId}-${Date.now()}`;
|
||||
const dataWithTempId = { ...data, id: tempId };
|
||||
|
||||
const encryptedData = DataCrypto.encryptRecord(
|
||||
tableName,
|
||||
dataWithTempId,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
if (!data.id) {
|
||||
delete encryptedData.id;
|
||||
}
|
||||
|
||||
const result = await getDb()
|
||||
.insert(table)
|
||||
.values(encryptedData)
|
||||
.returning();
|
||||
|
||||
DatabaseSaveTrigger.triggerSave(`insert_${tableName}`);
|
||||
|
||||
const decryptedResult = DataCrypto.decryptRecord(
|
||||
tableName,
|
||||
result[0],
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
return decryptedResult as T;
|
||||
}
|
||||
|
||||
static async select<T extends Record<string, any>>(
|
||||
query: any,
|
||||
tableName: TableName,
|
||||
userId: string,
|
||||
): Promise<T[]> {
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results = await query;
|
||||
|
||||
const decryptedResults = DataCrypto.decryptRecords(
|
||||
tableName,
|
||||
results,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
return decryptedResults;
|
||||
}
|
||||
|
||||
static async selectOne<T extends Record<string, any>>(
|
||||
query: any,
|
||||
tableName: TableName,
|
||||
userId: string,
|
||||
): Promise<T | undefined> {
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = await query;
|
||||
if (!result) return undefined;
|
||||
|
||||
const decryptedResult = DataCrypto.decryptRecord(
|
||||
tableName,
|
||||
result,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
return decryptedResult;
|
||||
}
|
||||
|
||||
static async update<T extends Record<string, any>>(
|
||||
table: SQLiteTable<any>,
|
||||
tableName: TableName,
|
||||
where: any,
|
||||
data: Partial<T>,
|
||||
userId: string,
|
||||
): Promise<T[]> {
|
||||
const userDataKey = DataCrypto.validateUserAccess(userId);
|
||||
|
||||
const encryptedData = DataCrypto.encryptRecord(
|
||||
tableName,
|
||||
data,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
const result = await getDb()
|
||||
.update(table)
|
||||
.set(encryptedData)
|
||||
.where(where)
|
||||
.returning();
|
||||
|
||||
DatabaseSaveTrigger.triggerSave(`update_${tableName}`);
|
||||
|
||||
const decryptedResults = DataCrypto.decryptRecords(
|
||||
tableName,
|
||||
result,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
return decryptedResults as T[];
|
||||
}
|
||||
|
||||
static async delete(
|
||||
table: SQLiteTable<any>,
|
||||
tableName: TableName,
|
||||
where: any,
|
||||
userId: string,
|
||||
): Promise<any[]> {
|
||||
const result = await getDb().delete(table).where(where).returning();
|
||||
|
||||
DatabaseSaveTrigger.triggerSave(`delete_${tableName}`);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static async healthCheck(userId: string): Promise<boolean> {
|
||||
return DataCrypto.canUserAccessData(userId);
|
||||
}
|
||||
|
||||
static isUserDataUnlocked(userId: string): boolean {
|
||||
return DataCrypto.getUserDataKey(userId) !== null;
|
||||
}
|
||||
|
||||
static async selectEncrypted(
|
||||
query: any,
|
||||
tableName: TableName,
|
||||
): Promise<any[]> {
|
||||
const results = await query;
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
export { SimpleDBOps, type TableName };
|
||||
418
src/backend/utils/ssh-key-utils.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
import ssh2Pkg from "ssh2";
|
||||
const ssh2Utils = ssh2Pkg.utils;
|
||||
|
||||
function detectKeyTypeFromContent(keyContent: string): string {
|
||||
const content = keyContent.trim();
|
||||
|
||||
if (content.includes("-----BEGIN OPENSSH PRIVATE KEY-----")) {
|
||||
if (
|
||||
content.includes("ssh-ed25519") ||
|
||||
content.includes("AAAAC3NzaC1lZDI1NTE5")
|
||||
) {
|
||||
return "ssh-ed25519";
|
||||
}
|
||||
if (content.includes("ssh-rsa") || content.includes("AAAAB3NzaC1yc2E")) {
|
||||
return "ssh-rsa";
|
||||
}
|
||||
if (content.includes("ecdsa-sha2-nistp256")) {
|
||||
return "ecdsa-sha2-nistp256";
|
||||
}
|
||||
if (content.includes("ecdsa-sha2-nistp384")) {
|
||||
return "ecdsa-sha2-nistp384";
|
||||
}
|
||||
if (content.includes("ecdsa-sha2-nistp521")) {
|
||||
return "ecdsa-sha2-nistp521";
|
||||
}
|
||||
|
||||
try {
|
||||
const base64Content = content
|
||||
.replace("-----BEGIN OPENSSH PRIVATE KEY-----", "")
|
||||
.replace("-----END OPENSSH PRIVATE KEY-----", "")
|
||||
.replace(/\s/g, "");
|
||||
|
||||
const decoded = Buffer.from(base64Content, "base64").toString("binary");
|
||||
|
||||
if (decoded.includes("ssh-rsa")) {
|
||||
return "ssh-rsa";
|
||||
}
|
||||
if (decoded.includes("ssh-ed25519")) {
|
||||
return "ssh-ed25519";
|
||||
}
|
||||
if (decoded.includes("ecdsa-sha2-nistp256")) {
|
||||
return "ecdsa-sha2-nistp256";
|
||||
}
|
||||
if (decoded.includes("ecdsa-sha2-nistp384")) {
|
||||
return "ecdsa-sha2-nistp384";
|
||||
}
|
||||
if (decoded.includes("ecdsa-sha2-nistp521")) {
|
||||
return "ecdsa-sha2-nistp521";
|
||||
}
|
||||
|
||||
return "ssh-rsa";
|
||||
} catch (error) {
|
||||
return "ssh-rsa";
|
||||
}
|
||||
}
|
||||
|
||||
if (content.includes("-----BEGIN RSA PRIVATE KEY-----")) {
|
||||
return "ssh-rsa";
|
||||
}
|
||||
if (content.includes("-----BEGIN DSA PRIVATE KEY-----")) {
|
||||
return "ssh-dss";
|
||||
}
|
||||
if (content.includes("-----BEGIN EC PRIVATE KEY-----")) {
|
||||
return "ecdsa-sha2-nistp256";
|
||||
}
|
||||
|
||||
if (content.includes("-----BEGIN PRIVATE KEY-----")) {
|
||||
try {
|
||||
const base64Content = content
|
||||
.replace("-----BEGIN PRIVATE KEY-----", "")
|
||||
.replace("-----END PRIVATE KEY-----", "")
|
||||
.replace(/\s/g, "");
|
||||
|
||||
const decoded = Buffer.from(base64Content, "base64");
|
||||
const decodedString = decoded.toString("binary");
|
||||
|
||||
if (decodedString.includes("1.2.840.113549.1.1.1")) {
|
||||
return "ssh-rsa";
|
||||
} else if (decodedString.includes("1.2.840.10045.2.1")) {
|
||||
if (decodedString.includes("1.2.840.10045.3.1.7")) {
|
||||
return "ecdsa-sha2-nistp256";
|
||||
}
|
||||
return "ecdsa-sha2-nistp256";
|
||||
} else if (decodedString.includes("1.3.101.112")) {
|
||||
return "ssh-ed25519";
|
||||
}
|
||||
} catch (error) {}
|
||||
|
||||
if (content.length < 800) {
|
||||
return "ssh-ed25519";
|
||||
} else if (content.length > 1600) {
|
||||
return "ssh-rsa";
|
||||
} else {
|
||||
return "ecdsa-sha2-nistp256";
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
|
||||
const content = publicKeyContent.trim();
|
||||
|
||||
if (content.startsWith("ssh-rsa ")) {
|
||||
return "ssh-rsa";
|
||||
}
|
||||
if (content.startsWith("ssh-ed25519 ")) {
|
||||
return "ssh-ed25519";
|
||||
}
|
||||
if (content.startsWith("ecdsa-sha2-nistp256 ")) {
|
||||
return "ecdsa-sha2-nistp256";
|
||||
}
|
||||
if (content.startsWith("ecdsa-sha2-nistp384 ")) {
|
||||
return "ecdsa-sha2-nistp384";
|
||||
}
|
||||
if (content.startsWith("ecdsa-sha2-nistp521 ")) {
|
||||
return "ecdsa-sha2-nistp521";
|
||||
}
|
||||
if (content.startsWith("ssh-dss ")) {
|
||||
return "ssh-dss";
|
||||
}
|
||||
|
||||
if (content.includes("-----BEGIN PUBLIC KEY-----")) {
|
||||
try {
|
||||
const base64Content = content
|
||||
.replace("-----BEGIN PUBLIC KEY-----", "")
|
||||
.replace("-----END PUBLIC KEY-----", "")
|
||||
.replace(/\s/g, "");
|
||||
|
||||
const decoded = Buffer.from(base64Content, "base64");
|
||||
const decodedString = decoded.toString("binary");
|
||||
|
||||
if (decodedString.includes("1.2.840.113549.1.1.1")) {
|
||||
return "ssh-rsa";
|
||||
} else if (decodedString.includes("1.2.840.10045.2.1")) {
|
||||
if (decodedString.includes("1.2.840.10045.3.1.7")) {
|
||||
return "ecdsa-sha2-nistp256";
|
||||
}
|
||||
return "ecdsa-sha2-nistp256";
|
||||
} else if (decodedString.includes("1.3.101.112")) {
|
||||
return "ssh-ed25519";
|
||||
}
|
||||
} catch (error) {}
|
||||
|
||||
if (content.length < 400) {
|
||||
return "ssh-ed25519";
|
||||
} else if (content.length > 600) {
|
||||
return "ssh-rsa";
|
||||
} else {
|
||||
return "ecdsa-sha2-nistp256";
|
||||
}
|
||||
}
|
||||
|
||||
if (content.includes("-----BEGIN RSA PUBLIC KEY-----")) {
|
||||
return "ssh-rsa";
|
||||
}
|
||||
|
||||
if (content.includes("AAAAB3NzaC1yc2E")) {
|
||||
return "ssh-rsa";
|
||||
}
|
||||
if (content.includes("AAAAC3NzaC1lZDI1NTE5")) {
|
||||
return "ssh-ed25519";
|
||||
}
|
||||
if (content.includes("AAAAE2VjZHNhLXNoYTItbmlzdHAyNTY")) {
|
||||
return "ecdsa-sha2-nistp256";
|
||||
}
|
||||
if (content.includes("AAAAE2VjZHNhLXNoYTItbmlzdHAzODQ")) {
|
||||
return "ecdsa-sha2-nistp384";
|
||||
}
|
||||
if (content.includes("AAAAE2VjZHNhLXNoYTItbmlzdHA1MjE")) {
|
||||
return "ecdsa-sha2-nistp521";
|
||||
}
|
||||
if (content.includes("AAAAB3NzaC1kc3M")) {
|
||||
return "ssh-dss";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export interface KeyInfo {
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
keyType: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PublicKeyInfo {
|
||||
publicKey: string;
|
||||
keyType: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface KeyPairValidationResult {
|
||||
isValid: boolean;
|
||||
privateKeyType: string;
|
||||
publicKeyType: string;
|
||||
generatedPublicKey?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function parseSSHKey(
|
||||
privateKeyData: string,
|
||||
passphrase?: string,
|
||||
): KeyInfo {
|
||||
try {
|
||||
let keyType = "unknown";
|
||||
let publicKey = "";
|
||||
let useSSH2 = false;
|
||||
|
||||
if (ssh2Utils && typeof ssh2Utils.parseKey === "function") {
|
||||
try {
|
||||
const parsedKey = ssh2Utils.parseKey(privateKeyData, passphrase);
|
||||
|
||||
if (!(parsedKey instanceof Error)) {
|
||||
if (parsedKey.type) {
|
||||
keyType = parsedKey.type;
|
||||
}
|
||||
|
||||
try {
|
||||
const publicKeyBuffer = parsedKey.getPublicSSH();
|
||||
|
||||
if (Buffer.isBuffer(publicKeyBuffer)) {
|
||||
const base64Data = publicKeyBuffer.toString("base64");
|
||||
|
||||
if (keyType === "ssh-rsa") {
|
||||
publicKey = `ssh-rsa ${base64Data}`;
|
||||
} else if (keyType === "ssh-ed25519") {
|
||||
publicKey = `ssh-ed25519 ${base64Data}`;
|
||||
} else if (keyType.startsWith("ecdsa-")) {
|
||||
publicKey = `${keyType} ${base64Data}`;
|
||||
} else {
|
||||
publicKey = `${keyType} ${base64Data}`;
|
||||
}
|
||||
} else {
|
||||
publicKey = "";
|
||||
}
|
||||
} catch (error) {
|
||||
publicKey = "";
|
||||
}
|
||||
|
||||
useSSH2 = true;
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
if (!useSSH2) {
|
||||
keyType = detectKeyTypeFromContent(privateKeyData);
|
||||
|
||||
publicKey = "";
|
||||
}
|
||||
|
||||
return {
|
||||
privateKey: privateKeyData,
|
||||
publicKey,
|
||||
keyType,
|
||||
success: keyType !== "unknown",
|
||||
};
|
||||
} catch (error) {
|
||||
try {
|
||||
const fallbackKeyType = detectKeyTypeFromContent(privateKeyData);
|
||||
if (fallbackKeyType !== "unknown") {
|
||||
return {
|
||||
privateKey: privateKeyData,
|
||||
publicKey: "",
|
||||
keyType: fallbackKeyType,
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
} catch (fallbackError) {}
|
||||
|
||||
return {
|
||||
privateKey: privateKeyData,
|
||||
publicKey: "",
|
||||
keyType: "unknown",
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Unknown error parsing key",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePublicKey(publicKeyData: string): PublicKeyInfo {
|
||||
try {
|
||||
const keyType = detectPublicKeyTypeFromContent(publicKeyData);
|
||||
|
||||
return {
|
||||
publicKey: publicKeyData,
|
||||
keyType,
|
||||
success: keyType !== "unknown",
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
publicKey: publicKeyData,
|
||||
keyType: "unknown",
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unknown error parsing public key",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function detectKeyType(privateKeyData: string): string {
|
||||
try {
|
||||
const parsedKey = ssh2Utils.parseKey(privateKeyData);
|
||||
if (parsedKey instanceof Error) {
|
||||
return "unknown";
|
||||
}
|
||||
return parsedKey.type || "unknown";
|
||||
} catch (error) {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export function getFriendlyKeyTypeName(keyType: string): string {
|
||||
const keyTypeMap: Record<string, string> = {
|
||||
"ssh-rsa": "RSA",
|
||||
"ssh-ed25519": "Ed25519",
|
||||
"ecdsa-sha2-nistp256": "ECDSA P-256",
|
||||
"ecdsa-sha2-nistp384": "ECDSA P-384",
|
||||
"ecdsa-sha2-nistp521": "ECDSA P-521",
|
||||
"ssh-dss": "DSA",
|
||||
"rsa-sha2-256": "RSA-SHA2-256",
|
||||
"rsa-sha2-512": "RSA-SHA2-512",
|
||||
unknown: "Unknown",
|
||||
};
|
||||
|
||||
return keyTypeMap[keyType] || keyType;
|
||||
}
|
||||
|
||||
export function validateKeyPair(
|
||||
privateKeyData: string,
|
||||
publicKeyData: string,
|
||||
passphrase?: string,
|
||||
): KeyPairValidationResult {
|
||||
try {
|
||||
const privateKeyInfo = parseSSHKey(privateKeyData, passphrase);
|
||||
const publicKeyInfo = parsePublicKey(publicKeyData);
|
||||
|
||||
if (!privateKeyInfo.success) {
|
||||
return {
|
||||
isValid: false,
|
||||
privateKeyType: privateKeyInfo.keyType,
|
||||
publicKeyType: publicKeyInfo.keyType,
|
||||
error: `Invalid private key: ${privateKeyInfo.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!publicKeyInfo.success) {
|
||||
return {
|
||||
isValid: false,
|
||||
privateKeyType: privateKeyInfo.keyType,
|
||||
publicKeyType: publicKeyInfo.keyType,
|
||||
error: `Invalid public key: ${publicKeyInfo.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (privateKeyInfo.keyType !== publicKeyInfo.keyType) {
|
||||
return {
|
||||
isValid: false,
|
||||
privateKeyType: privateKeyInfo.keyType,
|
||||
publicKeyType: publicKeyInfo.keyType,
|
||||
error: `Key type mismatch: private key is ${privateKeyInfo.keyType}, public key is ${publicKeyInfo.keyType}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (privateKeyInfo.publicKey && privateKeyInfo.publicKey.trim()) {
|
||||
const generatedPublicKey = privateKeyInfo.publicKey.trim();
|
||||
const providedPublicKey = publicKeyData.trim();
|
||||
|
||||
const generatedKeyParts = generatedPublicKey.split(" ");
|
||||
const providedKeyParts = providedPublicKey.split(" ");
|
||||
|
||||
if (generatedKeyParts.length >= 2 && providedKeyParts.length >= 2) {
|
||||
const generatedKeyData =
|
||||
generatedKeyParts[0] + " " + generatedKeyParts[1];
|
||||
const providedKeyData = providedKeyParts[0] + " " + providedKeyParts[1];
|
||||
|
||||
if (generatedKeyData === providedKeyData) {
|
||||
return {
|
||||
isValid: true,
|
||||
privateKeyType: privateKeyInfo.keyType,
|
||||
publicKeyType: publicKeyInfo.keyType,
|
||||
generatedPublicKey: generatedPublicKey,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
isValid: false,
|
||||
privateKeyType: privateKeyInfo.keyType,
|
||||
publicKeyType: publicKeyInfo.keyType,
|
||||
generatedPublicKey: generatedPublicKey,
|
||||
error: "Public key does not match the private key",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
privateKeyType: privateKeyInfo.keyType,
|
||||
publicKeyType: publicKeyInfo.keyType,
|
||||
error: "Unable to verify key pair match, but key types are compatible",
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
isValid: false,
|
||||
privateKeyType: "unknown",
|
||||
publicKeyType: "unknown",
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unknown error during validation",
|
||||
};
|
||||
}
|
||||
}
|
||||
263
src/backend/utils/system-crypto.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import crypto from "crypto";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
class SystemCrypto {
|
||||
private static instance: SystemCrypto;
|
||||
private jwtSecret: string | null = null;
|
||||
private databaseKey: Buffer | null = null;
|
||||
private internalAuthToken: string | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): SystemCrypto {
|
||||
if (!this.instance) {
|
||||
this.instance = new SystemCrypto();
|
||||
}
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
async initializeJWTSecret(): Promise<void> {
|
||||
try {
|
||||
const envSecret = process.env.JWT_SECRET;
|
||||
if (envSecret && envSecret.length >= 64) {
|
||||
this.jwtSecret = envSecret;
|
||||
return;
|
||||
}
|
||||
|
||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||
const envPath = path.join(dataDir, ".env");
|
||||
|
||||
try {
|
||||
const envContent = await fs.readFile(envPath, "utf8");
|
||||
const jwtMatch = envContent.match(/^JWT_SECRET=(.+)$/m);
|
||||
if (jwtMatch && jwtMatch[1] && jwtMatch[1].length >= 64) {
|
||||
this.jwtSecret = jwtMatch[1];
|
||||
process.env.JWT_SECRET = jwtMatch[1];
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
await this.generateAndGuideUser();
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to initialize JWT secret", error, {
|
||||
operation: "jwt_init_failed",
|
||||
});
|
||||
throw new Error("JWT secret initialization failed");
|
||||
}
|
||||
}
|
||||
|
||||
async getJWTSecret(): Promise<string> {
|
||||
if (!this.jwtSecret) {
|
||||
await this.initializeJWTSecret();
|
||||
}
|
||||
return this.jwtSecret!;
|
||||
}
|
||||
|
||||
async initializeDatabaseKey(): Promise<void> {
|
||||
try {
|
||||
const envKey = process.env.DATABASE_KEY;
|
||||
if (envKey && envKey.length >= 64) {
|
||||
this.databaseKey = Buffer.from(envKey, "hex");
|
||||
return;
|
||||
}
|
||||
|
||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||
const envPath = path.join(dataDir, ".env");
|
||||
|
||||
try {
|
||||
const envContent = await fs.readFile(envPath, "utf8");
|
||||
const dbKeyMatch = envContent.match(/^DATABASE_KEY=(.+)$/m);
|
||||
if (dbKeyMatch && dbKeyMatch[1] && dbKeyMatch[1].length >= 64) {
|
||||
this.databaseKey = Buffer.from(dbKeyMatch[1], "hex");
|
||||
process.env.DATABASE_KEY = dbKeyMatch[1];
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
await this.generateAndGuideDatabaseKey();
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to initialize database key", error, {
|
||||
operation: "db_key_init_failed",
|
||||
});
|
||||
throw new Error("Database key initialization failed");
|
||||
}
|
||||
}
|
||||
|
||||
async getDatabaseKey(): Promise<Buffer> {
|
||||
if (!this.databaseKey) {
|
||||
await this.initializeDatabaseKey();
|
||||
}
|
||||
return this.databaseKey!;
|
||||
}
|
||||
|
||||
async initializeInternalAuthToken(): Promise<void> {
|
||||
try {
|
||||
const envToken = process.env.INTERNAL_AUTH_TOKEN;
|
||||
if (envToken && envToken.length >= 32) {
|
||||
this.internalAuthToken = envToken;
|
||||
return;
|
||||
}
|
||||
|
||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||
const envPath = path.join(dataDir, ".env");
|
||||
|
||||
try {
|
||||
const envContent = await fs.readFile(envPath, "utf8");
|
||||
const tokenMatch = envContent.match(/^INTERNAL_AUTH_TOKEN=(.+)$/m);
|
||||
if (tokenMatch && tokenMatch[1] && tokenMatch[1].length >= 32) {
|
||||
this.internalAuthToken = tokenMatch[1];
|
||||
process.env.INTERNAL_AUTH_TOKEN = tokenMatch[1];
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
await this.generateAndGuideInternalAuthToken();
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to initialize internal auth token", error, {
|
||||
operation: "internal_auth_init_failed",
|
||||
});
|
||||
throw new Error("Internal auth token initialization failed");
|
||||
}
|
||||
}
|
||||
|
||||
async getInternalAuthToken(): Promise<string> {
|
||||
if (!this.internalAuthToken) {
|
||||
await this.initializeInternalAuthToken();
|
||||
}
|
||||
return this.internalAuthToken!;
|
||||
}
|
||||
|
||||
private async generateAndGuideUser(): Promise<void> {
|
||||
const newSecret = crypto.randomBytes(32).toString("hex");
|
||||
const instanceId = crypto.randomBytes(8).toString("hex");
|
||||
|
||||
this.jwtSecret = newSecret;
|
||||
|
||||
await this.updateEnvFile("JWT_SECRET", newSecret);
|
||||
|
||||
databaseLogger.success("JWT secret auto-generated and saved to .env", {
|
||||
operation: "jwt_auto_generated",
|
||||
instanceId,
|
||||
envVarName: "JWT_SECRET",
|
||||
note: "Ready for use - no restart required",
|
||||
});
|
||||
}
|
||||
|
||||
private async generateAndGuideDatabaseKey(): Promise<void> {
|
||||
const newKey = crypto.randomBytes(32);
|
||||
const newKeyHex = newKey.toString("hex");
|
||||
const instanceId = crypto.randomBytes(8).toString("hex");
|
||||
|
||||
this.databaseKey = newKey;
|
||||
|
||||
await this.updateEnvFile("DATABASE_KEY", newKeyHex);
|
||||
|
||||
databaseLogger.success("Database key auto-generated and saved to .env", {
|
||||
operation: "db_key_auto_generated",
|
||||
instanceId,
|
||||
envVarName: "DATABASE_KEY",
|
||||
note: "Ready for use - no restart required",
|
||||
});
|
||||
}
|
||||
|
||||
private async generateAndGuideInternalAuthToken(): Promise<void> {
|
||||
const newToken = crypto.randomBytes(32).toString("hex");
|
||||
const instanceId = crypto.randomBytes(8).toString("hex");
|
||||
|
||||
this.internalAuthToken = newToken;
|
||||
|
||||
await this.updateEnvFile("INTERNAL_AUTH_TOKEN", newToken);
|
||||
|
||||
databaseLogger.success(
|
||||
"Internal auth token auto-generated and saved to .env",
|
||||
{
|
||||
operation: "internal_auth_auto_generated",
|
||||
instanceId,
|
||||
envVarName: "INTERNAL_AUTH_TOKEN",
|
||||
note: "Ready for use - no restart required",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async validateJWTSecret(): Promise<boolean> {
|
||||
try {
|
||||
const secret = await this.getJWTSecret();
|
||||
if (!secret || secret.length < 32) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const jwt = await import("jsonwebtoken");
|
||||
const testPayload = { test: true, timestamp: Date.now() };
|
||||
const token = jwt.default.sign(testPayload, secret, { expiresIn: "1s" });
|
||||
const decoded = jwt.default.verify(token, secret);
|
||||
|
||||
return !!decoded;
|
||||
} catch (error) {
|
||||
databaseLogger.error("JWT secret validation failed", error, {
|
||||
operation: "jwt_validation_failed",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getSystemKeyStatus() {
|
||||
const isValid = await this.validateJWTSecret();
|
||||
const hasSecret = this.jwtSecret !== null;
|
||||
|
||||
const hasEnvVar = !!(
|
||||
process.env.JWT_SECRET && process.env.JWT_SECRET.length >= 64
|
||||
);
|
||||
|
||||
return {
|
||||
hasSecret,
|
||||
isValid,
|
||||
storage: {
|
||||
environment: hasEnvVar,
|
||||
},
|
||||
algorithm: "HS256",
|
||||
note: "Using simplified key management without encryption layers",
|
||||
};
|
||||
}
|
||||
|
||||
private async updateEnvFile(key: string, value: string): Promise<void> {
|
||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||
const envPath = path.join(dataDir, ".env");
|
||||
|
||||
try {
|
||||
await fs.mkdir(dataDir, { recursive: true });
|
||||
|
||||
let envContent = "";
|
||||
|
||||
try {
|
||||
envContent = await fs.readFile(envPath, "utf8");
|
||||
} catch {
|
||||
envContent = "# Termix Auto-generated Configuration\n\n";
|
||||
}
|
||||
|
||||
const keyRegex = new RegExp(`^${key}=.*$`, "m");
|
||||
|
||||
if (keyRegex.test(envContent)) {
|
||||
envContent = envContent.replace(keyRegex, `${key}=${value}`);
|
||||
} else {
|
||||
if (!envContent.includes("# Security Keys")) {
|
||||
envContent += "\n# Security Keys (Auto-generated)\n";
|
||||
}
|
||||
envContent += `${key}=${value}\n`;
|
||||
}
|
||||
|
||||
await fs.writeFile(envPath, envContent);
|
||||
|
||||
process.env[key] = value;
|
||||
} catch (error) {
|
||||
databaseLogger.error(`Failed to update .env file with ${key}`, error, {
|
||||
operation: "env_file_update_failed",
|
||||
key,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { SystemCrypto };
|
||||
443
src/backend/utils/user-crypto.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
import crypto from "crypto";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { settings, users } from "../database/db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
interface KEKSalt {
|
||||
salt: string;
|
||||
iterations: number;
|
||||
algorithm: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface EncryptedDEK {
|
||||
data: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
algorithm: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface UserSession {
|
||||
dataKey: Buffer;
|
||||
lastActivity: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
class UserCrypto {
|
||||
private static instance: UserCrypto;
|
||||
private userSessions: Map<string, UserSession> = new Map();
|
||||
private sessionExpiredCallback?: (userId: string) => void;
|
||||
|
||||
private static readonly PBKDF2_ITERATIONS = 100000;
|
||||
private static readonly KEK_LENGTH = 32;
|
||||
private static readonly DEK_LENGTH = 32;
|
||||
private static readonly SESSION_DURATION = 24 * 60 * 60 * 1000;
|
||||
private static readonly MAX_INACTIVITY = 6 * 60 * 60 * 1000;
|
||||
|
||||
private constructor() {
|
||||
setInterval(
|
||||
() => {
|
||||
this.cleanupExpiredSessions();
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
}
|
||||
|
||||
static getInstance(): UserCrypto {
|
||||
if (!this.instance) {
|
||||
this.instance = new UserCrypto();
|
||||
}
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
setSessionExpiredCallback(callback: (userId: string) => void): void {
|
||||
this.sessionExpiredCallback = callback;
|
||||
}
|
||||
|
||||
async setupUserEncryption(userId: string, password: string): Promise<void> {
|
||||
const kekSalt = await this.generateKEKSalt();
|
||||
await this.storeKEKSalt(userId, kekSalt);
|
||||
|
||||
const KEK = this.deriveKEK(password, kekSalt);
|
||||
const DEK = crypto.randomBytes(UserCrypto.DEK_LENGTH);
|
||||
const encryptedDEK = this.encryptDEK(DEK, KEK);
|
||||
await this.storeEncryptedDEK(userId, encryptedDEK);
|
||||
|
||||
KEK.fill(0);
|
||||
DEK.fill(0);
|
||||
}
|
||||
|
||||
async setupOIDCUserEncryption(userId: string): Promise<void> {
|
||||
const DEK = crypto.randomBytes(UserCrypto.DEK_LENGTH);
|
||||
|
||||
const now = Date.now();
|
||||
this.userSessions.set(userId, {
|
||||
dataKey: Buffer.from(DEK),
|
||||
lastActivity: now,
|
||||
expiresAt: now + UserCrypto.SESSION_DURATION,
|
||||
});
|
||||
|
||||
DEK.fill(0);
|
||||
}
|
||||
|
||||
async authenticateUser(userId: string, password: string): Promise<boolean> {
|
||||
try {
|
||||
const kekSalt = await this.getKEKSalt(userId);
|
||||
if (!kekSalt) return false;
|
||||
|
||||
const KEK = this.deriveKEK(password, kekSalt);
|
||||
const encryptedDEK = await this.getEncryptedDEK(userId);
|
||||
if (!encryptedDEK) {
|
||||
KEK.fill(0);
|
||||
return false;
|
||||
}
|
||||
|
||||
const DEK = this.decryptDEK(encryptedDEK, KEK);
|
||||
KEK.fill(0);
|
||||
|
||||
if (!DEK || DEK.length === 0) {
|
||||
databaseLogger.error("DEK is empty or invalid after decryption", {
|
||||
operation: "user_crypto_auth_debug",
|
||||
userId,
|
||||
dekLength: DEK ? DEK.length : 0,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const oldSession = this.userSessions.get(userId);
|
||||
if (oldSession) {
|
||||
oldSession.dataKey.fill(0);
|
||||
}
|
||||
|
||||
this.userSessions.set(userId, {
|
||||
dataKey: Buffer.from(DEK),
|
||||
lastActivity: now,
|
||||
expiresAt: now + UserCrypto.SESSION_DURATION,
|
||||
});
|
||||
|
||||
DEK.fill(0);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
databaseLogger.warn("User authentication failed", {
|
||||
operation: "user_crypto_auth_failed",
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : "Unknown",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async authenticateOIDCUser(userId: string): Promise<boolean> {
|
||||
try {
|
||||
const kekSalt = await this.getKEKSalt(userId);
|
||||
if (!kekSalt) {
|
||||
await this.setupOIDCUserEncryption(userId);
|
||||
return true;
|
||||
}
|
||||
|
||||
const systemKey = this.deriveOIDCSystemKey(userId);
|
||||
const encryptedDEK = await this.getEncryptedDEK(userId);
|
||||
if (!encryptedDEK) {
|
||||
systemKey.fill(0);
|
||||
await this.setupOIDCUserEncryption(userId);
|
||||
return true;
|
||||
}
|
||||
|
||||
const DEK = this.decryptDEK(encryptedDEK, systemKey);
|
||||
systemKey.fill(0);
|
||||
|
||||
if (!DEK || DEK.length === 0) {
|
||||
await this.setupOIDCUserEncryption(userId);
|
||||
return true;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const oldSession = this.userSessions.get(userId);
|
||||
if (oldSession) {
|
||||
oldSession.dataKey.fill(0);
|
||||
}
|
||||
|
||||
this.userSessions.set(userId, {
|
||||
dataKey: Buffer.from(DEK),
|
||||
lastActivity: now,
|
||||
expiresAt: now + UserCrypto.SESSION_DURATION,
|
||||
});
|
||||
|
||||
DEK.fill(0);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
await this.setupOIDCUserEncryption(userId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
getUserDataKey(userId: string): Buffer | null {
|
||||
const session = this.userSessions.get(userId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
if (now > session.expiresAt) {
|
||||
this.userSessions.delete(userId);
|
||||
session.dataKey.fill(0);
|
||||
if (this.sessionExpiredCallback) {
|
||||
this.sessionExpiredCallback(userId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (now - session.lastActivity > UserCrypto.MAX_INACTIVITY) {
|
||||
this.userSessions.delete(userId);
|
||||
session.dataKey.fill(0);
|
||||
if (this.sessionExpiredCallback) {
|
||||
this.sessionExpiredCallback(userId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
session.lastActivity = now;
|
||||
return session.dataKey;
|
||||
}
|
||||
|
||||
logoutUser(userId: string): void {
|
||||
const session = this.userSessions.get(userId);
|
||||
if (session) {
|
||||
session.dataKey.fill(0);
|
||||
this.userSessions.delete(userId);
|
||||
}
|
||||
}
|
||||
|
||||
isUserUnlocked(userId: string): boolean {
|
||||
return this.getUserDataKey(userId) !== null;
|
||||
}
|
||||
|
||||
async changeUserPassword(
|
||||
userId: string,
|
||||
oldPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const isValid = await this.validatePassword(userId, oldPassword);
|
||||
if (!isValid) return false;
|
||||
|
||||
const kekSalt = await this.getKEKSalt(userId);
|
||||
if (!kekSalt) return false;
|
||||
|
||||
const oldKEK = this.deriveKEK(oldPassword, kekSalt);
|
||||
const encryptedDEK = await this.getEncryptedDEK(userId);
|
||||
if (!encryptedDEK) return false;
|
||||
|
||||
const DEK = this.decryptDEK(encryptedDEK, oldKEK);
|
||||
|
||||
const newKekSalt = await this.generateKEKSalt();
|
||||
const newKEK = this.deriveKEK(newPassword, newKekSalt);
|
||||
const newEncryptedDEK = this.encryptDEK(DEK, newKEK);
|
||||
|
||||
await this.storeKEKSalt(userId, newKekSalt);
|
||||
await this.storeEncryptedDEK(userId, newEncryptedDEK);
|
||||
|
||||
oldKEK.fill(0);
|
||||
newKEK.fill(0);
|
||||
DEK.fill(0);
|
||||
|
||||
this.logoutUser(userId);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async validatePassword(
|
||||
userId: string,
|
||||
password: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const kekSalt = await this.getKEKSalt(userId);
|
||||
if (!kekSalt) return false;
|
||||
|
||||
const KEK = this.deriveKEK(password, kekSalt);
|
||||
const encryptedDEK = await this.getEncryptedDEK(userId);
|
||||
if (!encryptedDEK) return false;
|
||||
|
||||
const DEK = this.decryptDEK(encryptedDEK, KEK);
|
||||
|
||||
KEK.fill(0);
|
||||
DEK.fill(0);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupExpiredSessions(): void {
|
||||
const now = Date.now();
|
||||
const expiredUsers: string[] = [];
|
||||
|
||||
for (const [userId, session] of this.userSessions.entries()) {
|
||||
if (
|
||||
now > session.expiresAt ||
|
||||
now - session.lastActivity > UserCrypto.MAX_INACTIVITY
|
||||
) {
|
||||
session.dataKey.fill(0);
|
||||
expiredUsers.push(userId);
|
||||
}
|
||||
}
|
||||
|
||||
expiredUsers.forEach((userId) => {
|
||||
this.userSessions.delete(userId);
|
||||
});
|
||||
}
|
||||
|
||||
private async generateKEKSalt(): Promise<KEKSalt> {
|
||||
return {
|
||||
salt: crypto.randomBytes(32).toString("hex"),
|
||||
iterations: UserCrypto.PBKDF2_ITERATIONS,
|
||||
algorithm: "pbkdf2-sha256",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private deriveKEK(password: string, kekSalt: KEKSalt): Buffer {
|
||||
return crypto.pbkdf2Sync(
|
||||
password,
|
||||
Buffer.from(kekSalt.salt, "hex"),
|
||||
kekSalt.iterations,
|
||||
UserCrypto.KEK_LENGTH,
|
||||
"sha256",
|
||||
);
|
||||
}
|
||||
|
||||
private deriveOIDCSystemKey(userId: string): Buffer {
|
||||
const systemSecret =
|
||||
process.env.OIDC_SYSTEM_SECRET || "termix-oidc-system-secret-default";
|
||||
const salt = Buffer.from(userId, "utf8");
|
||||
return crypto.pbkdf2Sync(
|
||||
systemSecret,
|
||||
salt,
|
||||
100000,
|
||||
UserCrypto.KEK_LENGTH,
|
||||
"sha256",
|
||||
);
|
||||
}
|
||||
|
||||
private encryptDEK(dek: Buffer, kek: Buffer): EncryptedDEK {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv("aes-256-gcm", kek, iv);
|
||||
|
||||
let encrypted = cipher.update(dek);
|
||||
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return {
|
||||
data: encrypted.toString("hex"),
|
||||
iv: iv.toString("hex"),
|
||||
tag: tag.toString("hex"),
|
||||
algorithm: "aes-256-gcm",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private decryptDEK(encryptedDEK: EncryptedDEK, kek: Buffer): Buffer {
|
||||
const decipher = crypto.createDecipheriv(
|
||||
"aes-256-gcm",
|
||||
kek,
|
||||
Buffer.from(encryptedDEK.iv, "hex"),
|
||||
);
|
||||
|
||||
decipher.setAuthTag(Buffer.from(encryptedDEK.tag, "hex"));
|
||||
let decrypted = decipher.update(Buffer.from(encryptedDEK.data, "hex"));
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
private async storeKEKSalt(userId: string, kekSalt: KEKSalt): Promise<void> {
|
||||
const key = `user_kek_salt_${userId}`;
|
||||
const value = JSON.stringify(kekSalt);
|
||||
|
||||
const existing = await getDb()
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, key));
|
||||
|
||||
if (existing.length > 0) {
|
||||
await getDb()
|
||||
.update(settings)
|
||||
.set({ value })
|
||||
.where(eq(settings.key, key));
|
||||
} else {
|
||||
await getDb().insert(settings).values({ key, value });
|
||||
}
|
||||
}
|
||||
|
||||
private async getKEKSalt(userId: string): Promise<KEKSalt | null> {
|
||||
try {
|
||||
const key = `user_kek_salt_${userId}`;
|
||||
const result = await getDb()
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, key));
|
||||
|
||||
if (result.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(result[0].value);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async storeEncryptedDEK(
|
||||
userId: string,
|
||||
encryptedDEK: EncryptedDEK,
|
||||
): Promise<void> {
|
||||
const key = `user_encrypted_dek_${userId}`;
|
||||
const value = JSON.stringify(encryptedDEK);
|
||||
|
||||
const existing = await getDb()
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, key));
|
||||
|
||||
if (existing.length > 0) {
|
||||
await getDb()
|
||||
.update(settings)
|
||||
.set({ value })
|
||||
.where(eq(settings.key, key));
|
||||
} else {
|
||||
await getDb().insert(settings).values({ key, value });
|
||||
}
|
||||
}
|
||||
|
||||
private async getEncryptedDEK(userId: string): Promise<EncryptedDEK | null> {
|
||||
try {
|
||||
const key = `user_encrypted_dek_${userId}`;
|
||||
const result = await getDb()
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, key));
|
||||
|
||||
if (result.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(result[0].value);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { UserCrypto, type KEKSalt, type EncryptedDEK };
|
||||
281
src/backend/utils/user-data-export.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import {
|
||||
users,
|
||||
sshData,
|
||||
sshCredentials,
|
||||
fileManagerRecent,
|
||||
fileManagerPinned,
|
||||
fileManagerShortcuts,
|
||||
dismissedAlerts,
|
||||
} from "../database/db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
interface UserExportData {
|
||||
version: string;
|
||||
exportedAt: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
userData: {
|
||||
sshHosts: any[];
|
||||
sshCredentials: any[];
|
||||
fileManagerData: {
|
||||
recent: any[];
|
||||
pinned: any[];
|
||||
shortcuts: any[];
|
||||
};
|
||||
dismissedAlerts: any[];
|
||||
};
|
||||
metadata: {
|
||||
totalRecords: number;
|
||||
encrypted: boolean;
|
||||
exportType: "user_data" | "system_config" | "all";
|
||||
};
|
||||
}
|
||||
|
||||
class UserDataExport {
|
||||
private static readonly EXPORT_VERSION = "v2.0";
|
||||
|
||||
static async exportUserData(
|
||||
userId: string,
|
||||
options: {
|
||||
format?: "encrypted" | "plaintext";
|
||||
scope?: "user_data" | "all";
|
||||
includeCredentials?: boolean;
|
||||
} = {},
|
||||
): Promise<UserExportData> {
|
||||
const {
|
||||
format = "encrypted",
|
||||
scope = "user_data",
|
||||
includeCredentials = true,
|
||||
} = options;
|
||||
|
||||
try {
|
||||
const user = await getDb()
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId));
|
||||
if (!user || user.length === 0) {
|
||||
throw new Error(`User not found: ${userId}`);
|
||||
}
|
||||
|
||||
const userRecord = user[0];
|
||||
|
||||
let userDataKey: Buffer | null = null;
|
||||
if (format === "plaintext") {
|
||||
userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) {
|
||||
throw new Error(
|
||||
"User data not unlocked - password required for plaintext export",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const sshHosts = await getDb()
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(eq(sshData.userId, userId));
|
||||
const processedSshHosts =
|
||||
format === "plaintext" && userDataKey
|
||||
? sshHosts.map((host) =>
|
||||
DataCrypto.decryptRecord("ssh_data", host, userId, userDataKey!),
|
||||
)
|
||||
: sshHosts;
|
||||
|
||||
let sshCredentialsData: any[] = [];
|
||||
if (includeCredentials) {
|
||||
const credentials = await getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.userId, userId));
|
||||
sshCredentialsData =
|
||||
format === "plaintext" && userDataKey
|
||||
? credentials.map((cred) =>
|
||||
DataCrypto.decryptRecord(
|
||||
"ssh_credentials",
|
||||
cred,
|
||||
userId,
|
||||
userDataKey!,
|
||||
),
|
||||
)
|
||||
: credentials;
|
||||
}
|
||||
|
||||
const [recentFiles, pinnedFiles, shortcuts] = await Promise.all([
|
||||
getDb()
|
||||
.select()
|
||||
.from(fileManagerRecent)
|
||||
.where(eq(fileManagerRecent.userId, userId)),
|
||||
getDb()
|
||||
.select()
|
||||
.from(fileManagerPinned)
|
||||
.where(eq(fileManagerPinned.userId, userId)),
|
||||
getDb()
|
||||
.select()
|
||||
.from(fileManagerShortcuts)
|
||||
.where(eq(fileManagerShortcuts.userId, userId)),
|
||||
]);
|
||||
|
||||
const alerts = await getDb()
|
||||
.select()
|
||||
.from(dismissedAlerts)
|
||||
.where(eq(dismissedAlerts.userId, userId));
|
||||
|
||||
const exportData: UserExportData = {
|
||||
version: this.EXPORT_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
userId: userRecord.id,
|
||||
username: userRecord.username,
|
||||
userData: {
|
||||
sshHosts: processedSshHosts,
|
||||
sshCredentials: sshCredentialsData,
|
||||
fileManagerData: {
|
||||
recent: recentFiles,
|
||||
pinned: pinnedFiles,
|
||||
shortcuts: shortcuts,
|
||||
},
|
||||
dismissedAlerts: alerts,
|
||||
},
|
||||
metadata: {
|
||||
totalRecords:
|
||||
processedSshHosts.length +
|
||||
sshCredentialsData.length +
|
||||
recentFiles.length +
|
||||
pinnedFiles.length +
|
||||
shortcuts.length +
|
||||
alerts.length,
|
||||
encrypted: format === "encrypted",
|
||||
exportType: scope,
|
||||
},
|
||||
};
|
||||
|
||||
databaseLogger.success("User data export completed", {
|
||||
operation: "user_data_export_complete",
|
||||
userId,
|
||||
totalRecords: exportData.metadata.totalRecords,
|
||||
format,
|
||||
sshHosts: processedSshHosts.length,
|
||||
sshCredentials: sshCredentialsData.length,
|
||||
});
|
||||
|
||||
return exportData;
|
||||
} catch (error) {
|
||||
databaseLogger.error("User data export failed", error, {
|
||||
operation: "user_data_export_failed",
|
||||
userId,
|
||||
format,
|
||||
scope,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async exportUserDataToJSON(
|
||||
userId: string,
|
||||
options: {
|
||||
format?: "encrypted" | "plaintext";
|
||||
scope?: "user_data" | "all";
|
||||
includeCredentials?: boolean;
|
||||
pretty?: boolean;
|
||||
} = {},
|
||||
): Promise<string> {
|
||||
const { pretty = true } = options;
|
||||
const exportData = await this.exportUserData(userId, options);
|
||||
return JSON.stringify(exportData, null, pretty ? 2 : 0);
|
||||
}
|
||||
|
||||
static validateExportData(data: any): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!data || typeof data !== "object") {
|
||||
errors.push("Export data must be an object");
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
if (!data.version) {
|
||||
errors.push("Missing version field");
|
||||
}
|
||||
|
||||
if (!data.userId) {
|
||||
errors.push("Missing userId field");
|
||||
}
|
||||
|
||||
if (!data.userData || typeof data.userData !== "object") {
|
||||
errors.push("Missing or invalid userData field");
|
||||
}
|
||||
|
||||
if (!data.metadata || typeof data.metadata !== "object") {
|
||||
errors.push("Missing or invalid metadata field");
|
||||
}
|
||||
|
||||
if (data.userData) {
|
||||
const requiredFields = [
|
||||
"sshHosts",
|
||||
"sshCredentials",
|
||||
"fileManagerData",
|
||||
"dismissedAlerts",
|
||||
];
|
||||
for (const field of requiredFields) {
|
||||
if (
|
||||
!Array.isArray(data.userData[field]) &&
|
||||
!(
|
||||
field === "fileManagerData" &&
|
||||
typeof data.userData[field] === "object"
|
||||
)
|
||||
) {
|
||||
errors.push(`Missing or invalid userData.${field} field`);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
data.userData.fileManagerData &&
|
||||
typeof data.userData.fileManagerData === "object"
|
||||
) {
|
||||
const fmFields = ["recent", "pinned", "shortcuts"];
|
||||
for (const field of fmFields) {
|
||||
if (!Array.isArray(data.userData.fileManagerData[field])) {
|
||||
errors.push(
|
||||
`Missing or invalid userData.fileManagerData.${field} field`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
static getExportStats(data: UserExportData): {
|
||||
version: string;
|
||||
exportedAt: string;
|
||||
username: string;
|
||||
totalRecords: number;
|
||||
breakdown: {
|
||||
sshHosts: number;
|
||||
sshCredentials: number;
|
||||
fileManagerItems: number;
|
||||
dismissedAlerts: number;
|
||||
};
|
||||
encrypted: boolean;
|
||||
} {
|
||||
return {
|
||||
version: data.version,
|
||||
exportedAt: data.exportedAt,
|
||||
username: data.username,
|
||||
totalRecords: data.metadata.totalRecords,
|
||||
breakdown: {
|
||||
sshHosts: data.userData.sshHosts.length,
|
||||
sshCredentials: data.userData.sshCredentials.length,
|
||||
fileManagerItems:
|
||||
data.userData.fileManagerData.recent.length +
|
||||
data.userData.fileManagerData.pinned.length +
|
||||
data.userData.fileManagerData.shortcuts.length,
|
||||
dismissedAlerts: data.userData.dismissedAlerts.length,
|
||||
},
|
||||
encrypted: data.metadata.encrypted,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { UserDataExport, type UserExportData };
|
||||
434
src/backend/utils/user-data-import.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import {
|
||||
users,
|
||||
sshData,
|
||||
sshCredentials,
|
||||
fileManagerRecent,
|
||||
fileManagerPinned,
|
||||
fileManagerShortcuts,
|
||||
dismissedAlerts,
|
||||
} from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import { UserDataExport, type UserExportData } from "./user-data-export.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
interface ImportOptions {
|
||||
replaceExisting?: boolean;
|
||||
skipCredentials?: boolean;
|
||||
skipFileManagerData?: boolean;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
interface ImportResult {
|
||||
success: boolean;
|
||||
summary: {
|
||||
sshHostsImported: number;
|
||||
sshCredentialsImported: number;
|
||||
fileManagerItemsImported: number;
|
||||
dismissedAlertsImported: number;
|
||||
skippedItems: number;
|
||||
errors: string[];
|
||||
};
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
class UserDataImport {
|
||||
static async importUserData(
|
||||
targetUserId: string,
|
||||
exportData: UserExportData,
|
||||
options: ImportOptions = {},
|
||||
): Promise<ImportResult> {
|
||||
const {
|
||||
replaceExisting = false,
|
||||
skipCredentials = false,
|
||||
skipFileManagerData = false,
|
||||
dryRun = false,
|
||||
} = options;
|
||||
|
||||
try {
|
||||
const targetUser = await getDb()
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, targetUserId));
|
||||
if (!targetUser || targetUser.length === 0) {
|
||||
throw new Error(`Target user not found: ${targetUserId}`);
|
||||
}
|
||||
|
||||
const validation = UserDataExport.validateExportData(exportData);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid export data: ${validation.errors.join(", ")}`);
|
||||
}
|
||||
|
||||
let userDataKey: Buffer | null = null;
|
||||
if (exportData.metadata.encrypted) {
|
||||
userDataKey = DataCrypto.getUserDataKey(targetUserId);
|
||||
if (!userDataKey) {
|
||||
throw new Error(
|
||||
"Target user data not unlocked - password required for encrypted import",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result: ImportResult = {
|
||||
success: false,
|
||||
summary: {
|
||||
sshHostsImported: 0,
|
||||
sshCredentialsImported: 0,
|
||||
fileManagerItemsImported: 0,
|
||||
dismissedAlertsImported: 0,
|
||||
skippedItems: 0,
|
||||
errors: [],
|
||||
},
|
||||
dryRun,
|
||||
};
|
||||
|
||||
if (
|
||||
exportData.userData.sshHosts &&
|
||||
exportData.userData.sshHosts.length > 0
|
||||
) {
|
||||
const importStats = await this.importSshHosts(
|
||||
targetUserId,
|
||||
exportData.userData.sshHosts,
|
||||
{ replaceExisting, dryRun, userDataKey },
|
||||
);
|
||||
result.summary.sshHostsImported = importStats.imported;
|
||||
result.summary.skippedItems += importStats.skipped;
|
||||
result.summary.errors.push(...importStats.errors);
|
||||
}
|
||||
|
||||
if (
|
||||
!skipCredentials &&
|
||||
exportData.userData.sshCredentials &&
|
||||
exportData.userData.sshCredentials.length > 0
|
||||
) {
|
||||
const importStats = await this.importSshCredentials(
|
||||
targetUserId,
|
||||
exportData.userData.sshCredentials,
|
||||
{ replaceExisting, dryRun, userDataKey },
|
||||
);
|
||||
result.summary.sshCredentialsImported = importStats.imported;
|
||||
result.summary.skippedItems += importStats.skipped;
|
||||
result.summary.errors.push(...importStats.errors);
|
||||
}
|
||||
|
||||
if (!skipFileManagerData && exportData.userData.fileManagerData) {
|
||||
const importStats = await this.importFileManagerData(
|
||||
targetUserId,
|
||||
exportData.userData.fileManagerData,
|
||||
{ replaceExisting, dryRun },
|
||||
);
|
||||
result.summary.fileManagerItemsImported = importStats.imported;
|
||||
result.summary.skippedItems += importStats.skipped;
|
||||
result.summary.errors.push(...importStats.errors);
|
||||
}
|
||||
|
||||
if (
|
||||
exportData.userData.dismissedAlerts &&
|
||||
exportData.userData.dismissedAlerts.length > 0
|
||||
) {
|
||||
const importStats = await this.importDismissedAlerts(
|
||||
targetUserId,
|
||||
exportData.userData.dismissedAlerts,
|
||||
{ replaceExisting, dryRun },
|
||||
);
|
||||
result.summary.dismissedAlertsImported = importStats.imported;
|
||||
result.summary.skippedItems += importStats.skipped;
|
||||
result.summary.errors.push(...importStats.errors);
|
||||
}
|
||||
|
||||
result.success = result.summary.errors.length === 0;
|
||||
|
||||
databaseLogger.success("User data import completed", {
|
||||
operation: "user_data_import_complete",
|
||||
targetUserId,
|
||||
dryRun,
|
||||
...result.summary,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
databaseLogger.error("User data import failed", error, {
|
||||
operation: "user_data_import_failed",
|
||||
targetUserId,
|
||||
dryRun,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static async importSshHosts(
|
||||
targetUserId: string,
|
||||
sshHosts: any[],
|
||||
options: {
|
||||
replaceExisting: boolean;
|
||||
dryRun: boolean;
|
||||
userDataKey: Buffer | null;
|
||||
},
|
||||
) {
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const host of sshHosts) {
|
||||
try {
|
||||
if (options.dryRun) {
|
||||
imported++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const tempId = `import-ssh-${targetUserId}-${Date.now()}-${imported}`;
|
||||
const newHostData = {
|
||||
...host,
|
||||
id: tempId,
|
||||
userId: targetUserId,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
let processedHostData = newHostData;
|
||||
if (options.userDataKey) {
|
||||
processedHostData = DataCrypto.encryptRecord(
|
||||
"ssh_data",
|
||||
newHostData,
|
||||
targetUserId,
|
||||
options.userDataKey,
|
||||
);
|
||||
}
|
||||
|
||||
delete processedHostData.id;
|
||||
|
||||
await getDb().insert(sshData).values(processedHostData);
|
||||
imported++;
|
||||
} catch (error) {
|
||||
errors.push(
|
||||
`SSH host import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
return { imported, skipped, errors };
|
||||
}
|
||||
|
||||
private static async importSshCredentials(
|
||||
targetUserId: string,
|
||||
credentials: any[],
|
||||
options: {
|
||||
replaceExisting: boolean;
|
||||
dryRun: boolean;
|
||||
userDataKey: Buffer | null;
|
||||
},
|
||||
) {
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const credential of credentials) {
|
||||
try {
|
||||
if (options.dryRun) {
|
||||
imported++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const tempCredId = `import-cred-${targetUserId}-${Date.now()}-${imported}`;
|
||||
const newCredentialData = {
|
||||
...credential,
|
||||
id: tempCredId,
|
||||
userId: targetUserId,
|
||||
usageCount: 0,
|
||||
lastUsed: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
let processedCredentialData = newCredentialData;
|
||||
if (options.userDataKey) {
|
||||
processedCredentialData = DataCrypto.encryptRecord(
|
||||
"ssh_credentials",
|
||||
newCredentialData,
|
||||
targetUserId,
|
||||
options.userDataKey,
|
||||
);
|
||||
}
|
||||
|
||||
delete processedCredentialData.id;
|
||||
|
||||
await getDb().insert(sshCredentials).values(processedCredentialData);
|
||||
imported++;
|
||||
} catch (error) {
|
||||
errors.push(
|
||||
`SSH credential import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
return { imported, skipped, errors };
|
||||
}
|
||||
|
||||
private static async importFileManagerData(
|
||||
targetUserId: string,
|
||||
fileManagerData: any,
|
||||
options: { replaceExisting: boolean; dryRun: boolean },
|
||||
) {
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
if (fileManagerData.recent && Array.isArray(fileManagerData.recent)) {
|
||||
for (const item of fileManagerData.recent) {
|
||||
try {
|
||||
if (!options.dryRun) {
|
||||
const newItem = {
|
||||
...item,
|
||||
id: undefined,
|
||||
userId: targetUserId,
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
await getDb().insert(fileManagerRecent).values(newItem);
|
||||
}
|
||||
imported++;
|
||||
} catch (error) {
|
||||
errors.push(
|
||||
`Recent file import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fileManagerData.pinned && Array.isArray(fileManagerData.pinned)) {
|
||||
for (const item of fileManagerData.pinned) {
|
||||
try {
|
||||
if (!options.dryRun) {
|
||||
const newItem = {
|
||||
...item,
|
||||
id: undefined,
|
||||
userId: targetUserId,
|
||||
pinnedAt: new Date().toISOString(),
|
||||
};
|
||||
await getDb().insert(fileManagerPinned).values(newItem);
|
||||
}
|
||||
imported++;
|
||||
} catch (error) {
|
||||
errors.push(
|
||||
`Pinned file import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
fileManagerData.shortcuts &&
|
||||
Array.isArray(fileManagerData.shortcuts)
|
||||
) {
|
||||
for (const item of fileManagerData.shortcuts) {
|
||||
try {
|
||||
if (!options.dryRun) {
|
||||
const newItem = {
|
||||
...item,
|
||||
id: undefined,
|
||||
userId: targetUserId,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await getDb().insert(fileManagerShortcuts).values(newItem);
|
||||
}
|
||||
imported++;
|
||||
} catch (error) {
|
||||
errors.push(
|
||||
`Shortcut import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(
|
||||
`File manager data import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { imported, skipped, errors };
|
||||
}
|
||||
|
||||
private static async importDismissedAlerts(
|
||||
targetUserId: string,
|
||||
alerts: any[],
|
||||
options: { replaceExisting: boolean; dryRun: boolean },
|
||||
) {
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const alert of alerts) {
|
||||
try {
|
||||
if (options.dryRun) {
|
||||
imported++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = await getDb()
|
||||
.select()
|
||||
.from(dismissedAlerts)
|
||||
.where(
|
||||
and(
|
||||
eq(dismissedAlerts.userId, targetUserId),
|
||||
eq(dismissedAlerts.alertId, alert.alertId),
|
||||
),
|
||||
);
|
||||
|
||||
if (existing.length > 0 && !options.replaceExisting) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const newAlert = {
|
||||
...alert,
|
||||
id: undefined,
|
||||
userId: targetUserId,
|
||||
dismissedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (existing.length > 0 && options.replaceExisting) {
|
||||
await getDb()
|
||||
.update(dismissedAlerts)
|
||||
.set(newAlert)
|
||||
.where(eq(dismissedAlerts.id, existing[0].id));
|
||||
} else {
|
||||
await getDb().insert(dismissedAlerts).values(newAlert);
|
||||
}
|
||||
|
||||
imported++;
|
||||
} catch (error) {
|
||||
errors.push(
|
||||
`Dismissed alert import failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
return { imported, skipped, errors };
|
||||
}
|
||||
|
||||
static async importUserDataFromJSON(
|
||||
targetUserId: string,
|
||||
jsonData: string,
|
||||
options: ImportOptions = {},
|
||||
): Promise<ImportResult> {
|
||||
try {
|
||||
const exportData: UserExportData = JSON.parse(jsonData);
|
||||
return await this.importUserData(targetUserId, exportData, options);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new Error("Invalid JSON format in import data");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { UserDataImport, type ImportOptions, type ImportResult };
|
||||
@@ -1,73 +1,73 @@
|
||||
import {createContext, useContext, useEffect, useState} from "react"
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type Theme = "dark" | "light" | "system"
|
||||
type Theme = "dark" | "light" | "system";
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
defaultTheme?: Theme
|
||||
storageKey?: string
|
||||
}
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
storageKey?: string;
|
||||
};
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
};
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
}
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
};
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||
)
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
root.classList.remove("light", "dark")
|
||||
root.classList.remove("light", "dark");
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light"
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
root.classList.add(systemTheme)
|
||||
return
|
||||
}
|
||||
|
||||
root.classList.add(theme)
|
||||
}, [theme])
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme)
|
||||
setTheme(theme)
|
||||
},
|
||||
root.classList.add(systemTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext)
|
||||
const context = useContext(ThemeProviderContext);
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider")
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
|
||||
return context
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
@@ -20,7 +20,7 @@ function AccordionItem({
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
@@ -34,7 +34,7 @@ function AccordionTrigger({
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -42,7 +42,7 @@ function AccordionTrigger({
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
@@ -58,7 +58,7 @@ function AccordionContent({
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
@@ -16,8 +16,8 @@ const alertVariants = cva(
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
@@ -31,7 +31,7 @@ function Alert({
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -40,11 +40,11 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 font-medium tracking-tight whitespace-normal break-words",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
@@ -56,11 +56,11 @@ function AlertDescription({
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
@@ -22,8 +22,8 @@ const badgeVariants = cva(
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
@@ -32,7 +32,7 @@ function Badge({
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
const Comp = asChild ? Slot : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -40,7 +40,7 @@ function Badge({
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
export { Badge, badgeVariants };
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
import { Children, ReactElement, cloneElement, isValidElement } from 'react';
|
||||
import { Children, ReactElement, cloneElement, isValidElement } from "react";
|
||||
|
||||
import { ButtonProps } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type ButtonProps } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ButtonGroupProps {
|
||||
className?: string;
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
orientation?: "horizontal" | "vertical";
|
||||
children: ReactElement<ButtonProps>[] | React.ReactNode;
|
||||
}
|
||||
|
||||
export const ButtonGroup = ({
|
||||
className,
|
||||
orientation = 'horizontal',
|
||||
orientation = "horizontal",
|
||||
children,
|
||||
}: ButtonGroupProps) => {
|
||||
const isHorizontal = orientation === 'horizontal';
|
||||
const isVertical = orientation === 'vertical';
|
||||
const isHorizontal = orientation === "horizontal";
|
||||
const isVertical = orientation === "vertical";
|
||||
|
||||
// Normalize and filter only valid React elements
|
||||
const childArray = Children.toArray(children).filter((child): child is ReactElement<ButtonProps> =>
|
||||
isValidElement(child)
|
||||
const childArray = Children.toArray(children).filter(
|
||||
(child): child is ReactElement<ButtonProps> => isValidElement(child),
|
||||
);
|
||||
const totalButtons = childArray.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex',
|
||||
"flex",
|
||||
{
|
||||
'flex-col': isVertical,
|
||||
'w-fit': isVertical,
|
||||
"flex-col": isVertical,
|
||||
"w-fit": isVertical,
|
||||
},
|
||||
className
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{childArray.map((child, index) => {
|
||||
@@ -41,18 +41,18 @@ export const ButtonGroup = ({
|
||||
return cloneElement(child, {
|
||||
className: cn(
|
||||
{
|
||||
'rounded-l-none': isHorizontal && !isFirst,
|
||||
'rounded-r-none': isHorizontal && !isLast,
|
||||
'border-l-0': isHorizontal && !isFirst,
|
||||
"rounded-l-none": isHorizontal && !isFirst,
|
||||
"rounded-r-none": isHorizontal && !isLast,
|
||||
"border-l-0": isHorizontal && !isFirst,
|
||||
|
||||
'rounded-t-none': isVertical && !isFirst,
|
||||
'rounded-b-none': isVertical && !isLast,
|
||||
'border-t-0': isVertical && !isFirst,
|
||||
"rounded-t-none": isVertical && !isFirst,
|
||||
"rounded-b-none": isVertical && !isLast,
|
||||
"border-t-0": isVertical && !isFirst,
|
||||
},
|
||||
child.props.className
|
||||
child.props.className,
|
||||
),
|
||||
});
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
@@ -32,8 +32,14 @@ const buttonVariants = cva(
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ComponentProps<"button">,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
function Button({
|
||||
className,
|
||||
@@ -41,11 +47,8 @@ function Button({
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
}: ButtonProps) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -53,7 +56,7 @@ function Button({
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button, buttonVariants, type ButtonProps };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
@@ -8,11 +8,11 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -21,11 +21,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -35,7 +35,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -45,7 +45,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -54,11 +54,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -68,7 +68,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -78,7 +78,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -89,4 +89,4 @@ export {
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
@@ -13,7 +13,7 @@ function Checkbox({
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -24,7 +24,7 @@ function Checkbox({
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
export { Checkbox };
|
||||
|
||||
198
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { CheckIcon, ChevronRightIcon, Circle } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"focus:bg-accent data-[state=open]:bg-accent flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-4 w-4 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("bg-muted -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
@@ -9,23 +9,23 @@ import {
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
} from "react-hook-form";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const Form = FormProvider
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
{} as FormFieldContextValue,
|
||||
);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
@@ -37,21 +37,21 @@ const FormField = <
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState } = useFormContext();
|
||||
const formState = useFormState({ name: fieldContext.name });
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
throw new Error("useFormField should be used within <FormField>");
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -60,19 +60,19 @@ const useFormField = () => {
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
{} as FormItemContextValue,
|
||||
);
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId()
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
@@ -82,14 +82,14 @@ function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
@@ -99,11 +99,12 @@ function FormLabel({
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||
useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
@@ -117,11 +118,11 @@ function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
@@ -130,15 +131,15 @@ function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : props.children
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message ?? "") : props.children;
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -150,7 +151,7 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -162,4 +163,4 @@ export {
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
@@ -11,11 +11,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Input }
|
||||
export { Input };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Label({
|
||||
className,
|
||||
@@ -12,11 +12,11 @@ function Label({
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Label }
|
||||
export { Label };
|
||||
|
||||
41
src/components/ui/password-input.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PasswordInputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
export const PasswordInput = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
PasswordInputProps
|
||||
>(({ className, ...props }, ref) => {
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
ref={ref}
|
||||
type={showPassword ? "text" : "password"}
|
||||
className={cn("h-11 text-base pr-12", className)} // extra padding-right
|
||||
{...props}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition"
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
PasswordInput.displayName = "PasswordInput";
|
||||
@@ -1,18 +1,18 @@
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
@@ -29,18 +29,18 @@ function PopoverContent({
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
import * as React from "react";
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
@@ -13,7 +13,7 @@ function Progress({
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -23,7 +23,7 @@ function Progress({
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
export { Progress };
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import { GripVerticalIcon } from "lucide-react"
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
import * as React from "react";
|
||||
import { GripVerticalIcon } from "lucide-react";
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
@@ -13,17 +13,17 @@ function ResizablePanelGroup({
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ResizablePanel({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
@@ -31,24 +31,24 @@ function ResizableHandle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean
|
||||
withHandle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"relative flex w-1 items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-1 data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90 bg-[#434345] hover:bg-[#2a2a2c] active:bg-[#1a1a1c] transition-colors duration-150",
|
||||
className
|
||||
"relative flex w-1 items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-1 data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90 bg-dark-border-hover hover:bg-dark-active active:bg-dark-pressed transition-colors duration-150",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="bg-[#434345] hover:bg-[#2a2a2c] active:bg-[#1a1a1c] z-10 flex h-4 w-3 items-center justify-center rounded-xs border transition-colors duration-150">
|
||||
<div className="bg-dark-border-hover hover:bg-dark-active active:bg-dark-pressed z-10 flex h-4 w-3 items-center justify-center rounded-xs border transition-colors duration-150">
|
||||
<GripVerticalIcon className="size-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||
|
||||