commit
bf2192a624
44 changed files with 2714 additions and 4749 deletions
49
.github/workflows/ci.yml
vendored
Normal file
49
.github/workflows/ci.yml
vendored
Normal file
|
@ -0,0 +1,49 @@
|
|||
name: Tipi CI
|
||||
on:
|
||||
push:
|
||||
|
||||
env:
|
||||
ROOT_FOLDER: /test
|
||||
JWT_SECRET: "secret"
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
- uses: pnpm/action-setup@v2.0.1
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 7
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
|
||||
|
||||
- uses: actions/cache@v3
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm -r lint
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm -r test
|
||||
|
32
.github/workflows/release.yml
vendored
Normal file
32
.github/workflows/release.yml
vendored
Normal file
|
@ -0,0 +1,32 @@
|
|||
|
||||
name: Build & Deploy
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'release/**'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
|
||||
- name: Create Tag
|
||||
id: create_tag
|
||||
uses: jaywcjlove/create-tag-action@v1.1.5
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
package-path: ./package.json
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: actions/create-release@latest
|
||||
if: steps.create_tag.outputs.successful
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ steps.create_tag.outputs.version }}
|
||||
release_name: ${{ steps.create_tag.outputs.version }}
|
||||
draft: false
|
||||
prerelease: false
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
|||
.pnpm-debug.log
|
||||
.env
|
||||
.env*
|
||||
node_modules/
|
||||
|
|
5
.husky/pre-commit
Executable file
5
.husky/pre-commit
Executable file
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
pnpm -r test
|
||||
pnpm -r lint
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "File Browser",
|
||||
"available": true,
|
||||
"port": 8096,
|
||||
"id": "filebrowser",
|
||||
"description": "Reliable and Performant File Management Desktop Sync and File Sharing",
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "FreshRSS",
|
||||
"available": true,
|
||||
"port": 8086,
|
||||
"id": "freshrss",
|
||||
"description": "FreshRSS is a self-hosted RSS feed aggregator like Leed or Kriss Feed.\nIt is lightweight, easy to work with, powerful, and customizable.\n\nIt is a multi-user application with an anonymous reading mode. It supports custom tags. There is an API for (mobile) clients, and a Command-Line Interface.\n\nThanks to the WebSub standard (formerly PubSubHubbub), FreshRSS is able to receive instant push notifications from compatible sources, such as Mastodon, Friendica, WordPress, Blogger, FeedBurner, etc.\n\nFreshRSS natively supports basic Web scraping, based on XPath, for Web sites not providing any RSS / Atom feed.\n\nFinally, it supports extensions for further tuning.",
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "Invidious",
|
||||
"available": true,
|
||||
"port": 8095,
|
||||
"id": "invidious",
|
||||
"description": "",
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "Jackett",
|
||||
"available": true,
|
||||
"port": 8097,
|
||||
"id": "jackett",
|
||||
"description": "Jackett works as a proxy server: it translates queries from apps (Sonarr, Radarr, SickRage, CouchPotato, Mylar3, Lidarr, DuckieTV, qBittorrent, Nefarious etc.) into tracker-site-specific http queries, parses the html or json response, and then sends results back to the requesting software. This allows for getting recent uploads (like RSS) and performing searches.",
|
||||
|
@ -7,7 +8,5 @@
|
|||
"author": "",
|
||||
"source": "https://github.com/Jackett/Jackett",
|
||||
"image": "https://avatars.githubusercontent.com/u/15383019?s=200&v=4",
|
||||
"form_fields": {
|
||||
|
||||
}
|
||||
"form_fields": {}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "Jellyfin",
|
||||
"available": true,
|
||||
"port": 8091,
|
||||
"id": "jellyfin",
|
||||
"description": "",
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "Joplin Server",
|
||||
"available": true,
|
||||
"port": 8099,
|
||||
"id": "joplin",
|
||||
"description": "",
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "n8n",
|
||||
"available": true,
|
||||
"port": 8094,
|
||||
"id": "n8n",
|
||||
"description": "n8n is an extendable workflow automation tool. With a fair-code distribution model, n8n will always have visible source code, be available to self-host, and allow you to add your own custom functions, logic and apps. n8n's node-based approach makes it highly versatile, enabling you to connect anything to everything.",
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "Nextcloud",
|
||||
"available": true,
|
||||
"port": 8083,
|
||||
"id": "nextcloud",
|
||||
"description": "Nextcloud is a self-hosted, open source, and fully-featured cloud storage solution for your personal files, office documents, and photos.",
|
||||
|
|
|
@ -1,23 +1,24 @@
|
|||
{
|
||||
"name": "PiHole",
|
||||
"port": 8081,
|
||||
"requirements": {
|
||||
"ports": [53]
|
||||
},
|
||||
"id": "pihole",
|
||||
"description": "",
|
||||
"short_desc": "",
|
||||
"author": "",
|
||||
"source": "",
|
||||
"image": "https://avatars.githubusercontent.com/u/16827203?s=200&v=4",
|
||||
"form_fields": {
|
||||
"password": {
|
||||
"type": "password",
|
||||
"label": "Password",
|
||||
"max": 50,
|
||||
"min": 3,
|
||||
"required": true,
|
||||
"env_variable": "APP_PASSWORD"
|
||||
}
|
||||
"name": "PiHole",
|
||||
"available": true,
|
||||
"port": 8081,
|
||||
"requirements": {
|
||||
"ports": [53]
|
||||
},
|
||||
"id": "pihole",
|
||||
"description": "",
|
||||
"short_desc": "",
|
||||
"author": "",
|
||||
"source": "",
|
||||
"image": "https://avatars.githubusercontent.com/u/16827203?s=200&v=4",
|
||||
"form_fields": {
|
||||
"password": {
|
||||
"type": "password",
|
||||
"label": "Password",
|
||||
"max": 50,
|
||||
"min": 3,
|
||||
"required": true,
|
||||
"env_variable": "APP_PASSWORD"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "Radarr",
|
||||
"available": true,
|
||||
"port": 8088,
|
||||
"id": "radarr",
|
||||
"description": "",
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "Sonarr",
|
||||
"available": true,
|
||||
"port": 8098,
|
||||
"id": "sonarr",
|
||||
"description": "",
|
||||
|
@ -7,7 +8,5 @@
|
|||
"author": "",
|
||||
"source": "",
|
||||
"image": "https://avatars.githubusercontent.com/u/1082903?s=200&v=4",
|
||||
"form_fields": {
|
||||
|
||||
}
|
||||
"form_fields": {}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "Syncthing",
|
||||
"available": true,
|
||||
"port": 8090,
|
||||
"id": "syncthing",
|
||||
"description": "Syncthing is a peer-to-peer continuous file synchronization program. It synchronizes files between two or more computers in real time, safely protected from prying eyes. Your data is your data alone and you deserve to choose where it is stored, whether it is shared with some third party, and how it's transmitted over the internet.\n\nInstall the Syncthing app on your Umbrel and pair it with the Syncthing app on your phone or computer for a self hosted peer-to-peer backup solution.",
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "Tailscale",
|
||||
"available": true,
|
||||
"port": 8093,
|
||||
"id": "tailscale",
|
||||
"description": "",
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "Transmission",
|
||||
"available": true,
|
||||
"port": 8089,
|
||||
"requirements": {
|
||||
"ports": [51413]
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "Wireguard",
|
||||
"available": true,
|
||||
"port": 8082,
|
||||
"requirements": {
|
||||
"ports": [51820]
|
||||
|
|
4
clean.sh
4
clean.sh
|
@ -1,4 +0,0 @@
|
|||
# Script to clean up the setup
|
||||
./scripts/stop.sh
|
||||
|
||||
sudo rm -rf app-data/**
|
41
package.json
Normal file
41
package.json
Normal file
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"name": "runtipi",
|
||||
"version": "0.0.1",
|
||||
"description": "A homeserver for everyone",
|
||||
"scripts": {
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"dependencies": {
|
||||
"eslint": "^8.15.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-config-airbnb-typescript": "^17.0.0",
|
||||
"eslint-config-next": "^12.1.4",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-import-resolver-node": "^0.3.4",
|
||||
"eslint-import-resolver-typescript": "^2.4.0",
|
||||
"eslint-module-utils": "^2.7.3",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.5.1",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"eslint-plugin-react": "^7.29.1",
|
||||
"eslint-plugin-react-hooks": "^4.3.0",
|
||||
"eslint-scope": "^7.1.1",
|
||||
"eslint-utils": "^3.0.0",
|
||||
"eslint-visitor-keys": "^3.3.0",
|
||||
"prettier": "^2.6.2",
|
||||
"prettier-linter-helpers": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"husky": "^8.0.1"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/meienberger/runtipi.git"
|
||||
},
|
||||
"author": "",
|
||||
"license": "GNU General Public License v3.0",
|
||||
"bugs": {
|
||||
"url": "https://github.com/meienberger/runtipi/issues"
|
||||
},
|
||||
"homepage": "https://github.com/meienberger/runtipi#readme"
|
||||
}
|
|
@ -30,12 +30,15 @@
|
|||
"zustand": "^3.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.0.0",
|
||||
"@types/js-cookie": "^3.0.2",
|
||||
"@types/node": "17.0.31",
|
||||
"@types/react": "18.0.8",
|
||||
"@types/react-dom": "18.0.3",
|
||||
"@types/validator": "^13.7.2",
|
||||
"@typescript-eslint/eslint-plugin": "^5.18.0",
|
||||
"@typescript-eslint/parser": "^5.0.0",
|
||||
"eslint-plugin-import": "^2.25.3",
|
||||
"autoprefixer": "^10.4.4",
|
||||
"eslint": "8.12.0",
|
||||
"eslint-config-airbnb-typescript": "^17.0.0",
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.cjs
|
||||
*.cjs
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
module.exports = {
|
||||
env: { node: true },
|
||||
env: { node: true, jest: true },
|
||||
extends: ['airbnb-typescript', 'eslint:recommended', 'plugin:import/typescript'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
|
|
9
packages/system-api/__mocks__/child_process.ts
Normal file
9
packages/system-api/__mocks__/child_process.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
const childProcess: { execFile: typeof execFile } = jest.genMockFromModule('child_process');
|
||||
|
||||
const execFile = (path: string, args: string[], thing: any, callback: Function) => {
|
||||
callback();
|
||||
};
|
||||
|
||||
childProcess.execFile = execFile;
|
||||
|
||||
module.exports = childProcess;
|
86
packages/system-api/__mocks__/fs.ts
Normal file
86
packages/system-api/__mocks__/fs.ts
Normal file
|
@ -0,0 +1,86 @@
|
|||
import path from 'path';
|
||||
const fs: {
|
||||
__createMockFiles: typeof createMockFiles;
|
||||
readFileSync: typeof readFileSync;
|
||||
existsSync: typeof existsSync;
|
||||
writeFileSync: typeof writeFileSync;
|
||||
mkdirSync: typeof mkdirSync;
|
||||
rmSync: typeof rmSync;
|
||||
readdirSync: typeof readdirSync;
|
||||
copyFileSync: typeof copyFileSync;
|
||||
} = jest.genMockFromModule('fs');
|
||||
|
||||
let mockFiles = Object.create(null);
|
||||
|
||||
const createMockFiles = (newMockFiles: Record<string, string>) => {
|
||||
mockFiles = Object.create(null);
|
||||
|
||||
// Create folder tree
|
||||
for (const file in newMockFiles) {
|
||||
const dir = path.dirname(file);
|
||||
|
||||
if (!mockFiles[dir]) {
|
||||
mockFiles[dir] = [];
|
||||
}
|
||||
|
||||
mockFiles[dir].push(path.basename(file));
|
||||
mockFiles[file] = newMockFiles[file];
|
||||
}
|
||||
};
|
||||
|
||||
const readFileSync = (p: string) => {
|
||||
return mockFiles[p];
|
||||
};
|
||||
|
||||
const existsSync = (p: string) => {
|
||||
return mockFiles[p] !== undefined;
|
||||
};
|
||||
|
||||
const writeFileSync = (p: string, data: any) => {
|
||||
mockFiles[p] = data;
|
||||
};
|
||||
|
||||
const mkdirSync = (p: string) => {
|
||||
mockFiles[p] = Object.create(null);
|
||||
};
|
||||
|
||||
const rmSync = (p: string, options: { recursive: boolean }) => {
|
||||
if (options.recursive) {
|
||||
delete mockFiles[p];
|
||||
} else {
|
||||
delete mockFiles[p][Object.keys(mockFiles[p])[0]];
|
||||
}
|
||||
};
|
||||
|
||||
const readdirSync = (p: string) => {
|
||||
const files: string[] = [];
|
||||
|
||||
const depth = p.split('/').length;
|
||||
|
||||
Object.keys(mockFiles).forEach((file) => {
|
||||
if (file.startsWith(p)) {
|
||||
const fileDepth = file.split('/').length;
|
||||
|
||||
if (fileDepth === depth + 1) {
|
||||
files.push(file.split('/').pop() || '');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
const copyFileSync = (source: string, destination: string) => {
|
||||
mockFiles[destination] = mockFiles[source];
|
||||
};
|
||||
|
||||
fs.readdirSync = readdirSync;
|
||||
fs.existsSync = existsSync;
|
||||
fs.readFileSync = readFileSync;
|
||||
fs.writeFileSync = writeFileSync;
|
||||
fs.mkdirSync = mkdirSync;
|
||||
fs.rmSync = rmSync;
|
||||
fs.copyFileSync = copyFileSync;
|
||||
fs.__createMockFiles = createMockFiles;
|
||||
|
||||
module.exports = fs;
|
7
packages/system-api/jest.config.cjs
Normal file
7
packages/system-api/jest.config.cjs
Normal file
|
@ -0,0 +1,7 @@
|
|||
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/__tests__/**/*.test.ts'],
|
||||
setupFiles: ['<rootDir>/tests/dotenv-config.ts'],
|
||||
};
|
|
@ -2,12 +2,16 @@
|
|||
"name": "system-api",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "src/server.ts",
|
||||
"exports": "./dist/server.js",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rimraf dist",
|
||||
"lint": "eslint . --ext .ts",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"build-prod": "esbuild --bundle src/server.ts --outdir=dist --allow-overwrite --sourcemap --platform=node --minify --analyze=verbose --external:./node_modules/* --format=esm",
|
||||
"build:watch": "esbuild --bundle src/server.ts --outdir=dist --allow-overwrite --sourcemap --platform=node --external:./node_modules/* --format=esm --watch",
|
||||
"start:dev": "NODE_ENV=development nodemon --trace-deprecation --trace-warnings --watch dist dist/server.js",
|
||||
|
@ -17,6 +21,7 @@
|
|||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"argon2": "^0.28.5",
|
||||
"bcrypt": "^5.0.1",
|
||||
"compression": "^1.7.4",
|
||||
"cookie-parser": "^1.4.6",
|
||||
|
@ -24,8 +29,9 @@
|
|||
"dotenv": "^16.0.0",
|
||||
"express": "^4.17.3",
|
||||
"helmet": "^5.0.2",
|
||||
"internal-ip": "^7.0.0",
|
||||
"internal-ip": "^6.0.0",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"mock-fs": "^5.1.2",
|
||||
"node-port-scanner": "^3.0.1",
|
||||
"p-iteration": "^1.1.8",
|
||||
"passport": "^0.5.2",
|
||||
|
@ -41,23 +47,26 @@
|
|||
"@types/cookie-parser": "^1.4.3",
|
||||
"@types/cors": "^2.8.12",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/jest": "^27.5.0",
|
||||
"@types/jsonwebtoken": "^8.5.8",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/passport": "^1.0.7",
|
||||
"@types/passport-http-bearer": "^1.0.37",
|
||||
"@types/tcp-port-used": "^1.0.1",
|
||||
"@types/validator": "^13.7.2",
|
||||
"@typescript-eslint/eslint-plugin": "^5.18.0",
|
||||
"@typescript-eslint/parser": "^5.22.0",
|
||||
"concurrently": "^7.1.0",
|
||||
"esbuild": "^0.14.32",
|
||||
"eslint": "^8.13.0",
|
||||
"eslint-config-airbnb-typescript": "^17.0.0",
|
||||
"eslint-config-hardcore": "^24.5.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-config-react": "^1.1.7",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"eslint-plugin-react": "^7.29.4",
|
||||
"eslint-plugin-unicorn": "^42.0.0",
|
||||
"jest": "^28.1.0",
|
||||
"nodemon": "^2.0.15",
|
||||
"prettier": "2.6.2"
|
||||
"prettier": "2.6.2",
|
||||
"ts-jest": "^28.0.2",
|
||||
"typescript": "4.6.4"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ export type Maybe<T> = T | null | undefined;
|
|||
|
||||
export interface AppConfig {
|
||||
id: string;
|
||||
available: boolean;
|
||||
port: number;
|
||||
name: string;
|
||||
requirements?: {
|
||||
|
|
|
@ -0,0 +1,258 @@
|
|||
import AppsService from '../apps.service';
|
||||
import fs from 'fs';
|
||||
import config from '../../../config';
|
||||
import { AppConfig, FieldTypes } from '../../../config/types';
|
||||
import childProcess from 'child_process';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('child_process');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
const testApp: Partial<AppConfig> = {
|
||||
id: 'test-app',
|
||||
port: 3000,
|
||||
available: true,
|
||||
form_fields: {
|
||||
test: {
|
||||
type: FieldTypes.text,
|
||||
label: 'Test field',
|
||||
required: true,
|
||||
env_variable: 'TEST_FIELD',
|
||||
},
|
||||
test2: {
|
||||
type: FieldTypes.text,
|
||||
label: 'Test field 2',
|
||||
required: false,
|
||||
env_variable: 'TEST_FIELD_2',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const testApp2: Partial<AppConfig> = {
|
||||
available: true,
|
||||
id: 'test-app2',
|
||||
};
|
||||
|
||||
const MOCK_FILE_EMPTY = {
|
||||
[`${config.ROOT_FOLDER}/apps/test-app/config.json`]: JSON.stringify(testApp),
|
||||
[`${config.ROOT_FOLDER}/.env`]: 'TEST=test',
|
||||
[`${config.ROOT_FOLDER}/state/apps.json`]: '{"installed": ""}',
|
||||
};
|
||||
|
||||
const MOCK_FILE_INSTALLED = {
|
||||
[`${config.ROOT_FOLDER}/apps/test-app/config.json`]: JSON.stringify(testApp),
|
||||
[`${config.ROOT_FOLDER}/apps/test-app2/config.json`]: JSON.stringify(testApp2),
|
||||
[`${config.ROOT_FOLDER}/.env`]: 'TEST=test',
|
||||
[`${config.ROOT_FOLDER}/state/apps.json`]: '{"installed": "test-app"}',
|
||||
[`${config.ROOT_FOLDER}/app-data/test-app`]: '',
|
||||
[`${config.ROOT_FOLDER}/app-data/test-app/app.env`]: 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test',
|
||||
};
|
||||
|
||||
describe('Install app', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MOCK_FILE_EMPTY);
|
||||
});
|
||||
|
||||
it('Should correctly generate env file for app', async () => {
|
||||
await AppsService.installApp('test-app', { test: 'test' });
|
||||
|
||||
const envFile = fs.readFileSync(`${config.ROOT_FOLDER}/app-data/test-app/app.env`).toString();
|
||||
|
||||
expect(envFile.trim()).toBe('TEST=test\nAPP_PORT=3000\nTEST_FIELD=test');
|
||||
});
|
||||
|
||||
it('Should add app to state file', async () => {
|
||||
await AppsService.installApp('test-app', { test: 'test' });
|
||||
|
||||
const stateFile = JSON.parse(fs.readFileSync(`${config.ROOT_FOLDER}/state/apps.json`).toString());
|
||||
|
||||
expect(stateFile.installed).toBe(' test-app');
|
||||
});
|
||||
|
||||
it('Should correctly run app script', async () => {
|
||||
const spy = jest.spyOn(childProcess, 'execFile');
|
||||
|
||||
await AppsService.installApp('test-app', { test: 'test' });
|
||||
|
||||
expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['install', 'test-app'], {}, expect.any(Function)]);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('Should start app if already installed', async () => {
|
||||
const spy = jest.spyOn(childProcess, 'execFile');
|
||||
|
||||
await AppsService.installApp('test-app', { test: 'test' });
|
||||
await AppsService.installApp('test-app', { test: 'test' });
|
||||
|
||||
expect(spy.mock.calls.length).toBe(2);
|
||||
expect(spy.mock.calls[0]).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['install', 'test-app'], {}, expect.any(Function)]);
|
||||
expect(spy.mock.calls[1]).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['start', 'test-app'], {}, expect.any(Function)]);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('Should throw if required form fields are missing', async () => {
|
||||
await expect(AppsService.installApp('test-app', {})).rejects.toThrowError('Variable test is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Uninstall app', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MOCK_FILE_INSTALLED);
|
||||
});
|
||||
|
||||
it('Should correctly remove app from state file', async () => {
|
||||
await AppsService.uninstallApp('test-app');
|
||||
|
||||
const stateFile = JSON.parse(fs.readFileSync(`${config.ROOT_FOLDER}/state/apps.json`).toString());
|
||||
|
||||
expect(stateFile.installed).toBe('');
|
||||
});
|
||||
|
||||
it('Should correctly run app script', async () => {
|
||||
const spy = jest.spyOn(childProcess, 'execFile');
|
||||
|
||||
await AppsService.uninstallApp('test-app');
|
||||
|
||||
expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['uninstall', 'test-app'], {}, expect.any(Function)]);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('Should throw if app is not installed', async () => {
|
||||
await expect(AppsService.uninstallApp('test-app-2')).rejects.toThrowError('App test-app-2 not installed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Start app', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MOCK_FILE_INSTALLED);
|
||||
});
|
||||
|
||||
it('Should correctly run app script', async () => {
|
||||
const spy = jest.spyOn(childProcess, 'execFile');
|
||||
|
||||
await AppsService.startApp('test-app');
|
||||
|
||||
expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['start', 'test-app'], {}, expect.any(Function)]);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('Should throw if app is not installed', async () => {
|
||||
await expect(AppsService.startApp('test-app-2')).rejects.toThrowError('App test-app-2 not installed');
|
||||
});
|
||||
|
||||
it('Should restart if app is already running', async () => {
|
||||
const spy = jest.spyOn(childProcess, 'execFile');
|
||||
|
||||
await AppsService.startApp('test-app');
|
||||
expect(spy.mock.calls.length).toBe(1);
|
||||
await AppsService.startApp('test-app');
|
||||
expect(spy.mock.calls.length).toBe(2);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('Should throw if app is not installed', async () => {
|
||||
await expect(AppsService.startApp('test-app-2')).rejects.toThrowError('App test-app-2 not installed');
|
||||
});
|
||||
|
||||
it('Regenerate env file', async () => {
|
||||
fs.writeFile(`${config.ROOT_FOLDER}/app-data/test-app/app.env`, 'TEST=test\nAPP_PORT=3000', () => {});
|
||||
|
||||
await AppsService.startApp('test-app');
|
||||
|
||||
const envFile = fs.readFileSync(`${config.ROOT_FOLDER}/app-data/test-app/app.env`).toString();
|
||||
|
||||
expect(envFile.trim()).toBe('TEST=test\nAPP_PORT=3000\nTEST_FIELD=test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stop app', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MOCK_FILE_INSTALLED);
|
||||
});
|
||||
|
||||
it('Should correctly run app script', async () => {
|
||||
const spy = jest.spyOn(childProcess, 'execFile');
|
||||
|
||||
await AppsService.stopApp('test-app');
|
||||
|
||||
expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['stop', 'test-app'], {}, expect.any(Function)]);
|
||||
});
|
||||
|
||||
it('Should throw if app is not installed', async () => {
|
||||
await expect(AppsService.stopApp('test-app-2')).rejects.toThrowError('App test-app-2 not installed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Update app config', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MOCK_FILE_INSTALLED);
|
||||
});
|
||||
|
||||
it('Should correctly update app config', async () => {
|
||||
await AppsService.updateAppConfig('test-app', { test: 'test', test2: 'test2' });
|
||||
|
||||
const envFile = fs.readFileSync(`${config.ROOT_FOLDER}/app-data/test-app/app.env`).toString();
|
||||
|
||||
expect(envFile.trim()).toBe('TEST=test\nAPP_PORT=3000\nTEST_FIELD=test\nTEST_FIELD_2=test2');
|
||||
});
|
||||
|
||||
it('Should throw if app is not installed', async () => {
|
||||
await expect(AppsService.updateAppConfig('test-app-2', { test: 'test' })).rejects.toThrowError('App test-app-2 not installed');
|
||||
});
|
||||
|
||||
it('Should throw if required form fields are missing', async () => {
|
||||
await expect(AppsService.updateAppConfig('test-app', {})).rejects.toThrowError('Variable test is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Get app config', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MOCK_FILE_INSTALLED);
|
||||
});
|
||||
|
||||
it('Should correctly get app config', async () => {
|
||||
const appconfig = await AppsService.getAppInfo('test-app');
|
||||
|
||||
expect(appconfig).toEqual({ ...testApp, installed: true, status: 'stopped' });
|
||||
});
|
||||
|
||||
it('Should have installed false if app is not installed', async () => {
|
||||
const appconfig = await AppsService.getAppInfo('test-app2');
|
||||
|
||||
expect(appconfig).toEqual({ ...testApp2, installed: false, status: 'stopped' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('List apps', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MOCK_FILE_INSTALLED);
|
||||
});
|
||||
|
||||
it('Should correctly list apps', async () => {
|
||||
const apps = await AppsService.listApps();
|
||||
|
||||
expect(apps).toEqual([
|
||||
{ ...testApp, installed: true, status: 'stopped' },
|
||||
{ ...testApp2, installed: false, status: 'stopped' },
|
||||
]);
|
||||
expect(apps.length).toBe(2);
|
||||
expect(apps[0].id).toBe('test-app');
|
||||
expect(apps[1].id).toBe('test-app2');
|
||||
});
|
||||
});
|
|
@ -1,34 +1,7 @@
|
|||
import { NextFunction, Request, Response } from 'express';
|
||||
import si from 'systeminformation';
|
||||
import { appNames } from '../../config/apps';
|
||||
import AppsService from './apps.service';
|
||||
import { AppConfig } from '../../config/types';
|
||||
import { createFolder, fileExists, readJsonFile, writeFile, readFile } from '../fs/fs.helpers';
|
||||
import { checkAppExists, checkAppRequirements, checkEnvFile, ensureAppState, getInitalFormValues, runAppScript } from './apps.helpers';
|
||||
|
||||
type AppsState = { installed: string };
|
||||
|
||||
const getStateFile = (): AppsState => {
|
||||
return readJsonFile('/state/apps.json');
|
||||
};
|
||||
|
||||
const generateEnvFile = (appName: string, form: Record<string, string>) => {
|
||||
const configFile: AppConfig = readJsonFile(`/apps/${appName}/config.json`);
|
||||
const baseEnvFile = readFile('/.env').toString();
|
||||
let envFile = `${baseEnvFile}\nAPP_PORT=${configFile.port}\n`;
|
||||
|
||||
Object.keys(configFile.form_fields).forEach((key) => {
|
||||
const value = form[key];
|
||||
|
||||
if (value) {
|
||||
const envVar = configFile.form_fields[key].env_variable;
|
||||
envFile += `${envVar}=${value}\n`;
|
||||
} else if (configFile.form_fields[key].required) {
|
||||
throw new Error(`Variable ${key} is required`);
|
||||
}
|
||||
});
|
||||
|
||||
writeFile(`/app-data/${appName}/app.env`, envFile);
|
||||
};
|
||||
import { getInitalFormValues } from './apps.helpers';
|
||||
|
||||
const uninstallApp = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
|
@ -38,11 +11,7 @@ const uninstallApp = async (req: Request, res: Response, next: NextFunction) =>
|
|||
throw new Error('App name is required');
|
||||
}
|
||||
|
||||
checkAppExists(appName);
|
||||
ensureAppState(appName, false);
|
||||
|
||||
// Run script
|
||||
await runAppScript(['uninstall', appName]);
|
||||
await AppsService.uninstallApp(appName);
|
||||
|
||||
res.status(200).json({ message: 'App uninstalled successfully' });
|
||||
} catch (e) {
|
||||
|
@ -58,9 +27,7 @@ const stopApp = async (req: Request, res: Response, next: NextFunction) => {
|
|||
throw new Error('App name is required');
|
||||
}
|
||||
|
||||
checkAppExists(appName);
|
||||
// Run script
|
||||
await runAppScript(['stop', appName]);
|
||||
await AppsService.stopApp(appName);
|
||||
|
||||
res.status(200).json({ message: 'App stopped successfully' });
|
||||
} catch (e) {
|
||||
|
@ -77,8 +44,7 @@ const updateAppConfig = async (req: Request, res: Response, next: NextFunction)
|
|||
throw new Error('App name is required');
|
||||
}
|
||||
|
||||
checkAppExists(appName);
|
||||
generateEnvFile(appName, form);
|
||||
AppsService.updateAppConfig(appName, form);
|
||||
|
||||
res.status(200).json({ message: 'App updated successfully' });
|
||||
} catch (e) {
|
||||
|
@ -94,15 +60,9 @@ const getAppInfo = async (req: Request, res: Response<AppConfig>, next: NextFunc
|
|||
throw new Error('App name is required');
|
||||
}
|
||||
|
||||
const dockerContainers = await si.dockerContainers();
|
||||
const configFile: AppConfig = readJsonFile(`/apps/${id}/config.json`);
|
||||
const appInfo = await AppsService.getAppInfo(id);
|
||||
|
||||
const state = getStateFile();
|
||||
const installed: string[] = state.installed.split(' ').filter(Boolean);
|
||||
configFile.installed = installed.includes(id);
|
||||
configFile.status = (dockerContainers.find((container) => container.name === `${id}`)?.state as 'running') || 'stopped';
|
||||
|
||||
res.status(200).json(configFile);
|
||||
res.status(200).json(appInfo);
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
|
@ -110,25 +70,7 @@ const getAppInfo = async (req: Request, res: Response<AppConfig>, next: NextFunc
|
|||
|
||||
const listApps = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const apps = appNames
|
||||
.map((app) => {
|
||||
try {
|
||||
return readJsonFile(`/apps/${app}/config.json`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const dockerContainers = await si.dockerContainers();
|
||||
|
||||
const state = getStateFile();
|
||||
const installed: string[] = state.installed.split(' ').filter(Boolean);
|
||||
|
||||
apps.forEach((app) => {
|
||||
app.installed = installed.includes(app.id);
|
||||
app.status = dockerContainers.find((container) => container.name === `${app.id}`)?.state || 'stopped';
|
||||
});
|
||||
const apps = await AppsService.listApps();
|
||||
|
||||
res.status(200).json(apps);
|
||||
} catch (e) {
|
||||
|
@ -138,23 +80,13 @@ const listApps = async (req: Request, res: Response, next: NextFunction) => {
|
|||
|
||||
const startApp = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id: appName } = req.params;
|
||||
const { id } = req.params;
|
||||
|
||||
if (!appName) {
|
||||
if (!id) {
|
||||
throw new Error('App name is required');
|
||||
}
|
||||
|
||||
checkAppExists(appName);
|
||||
checkEnvFile(appName);
|
||||
|
||||
// Regenerate env file
|
||||
const form = getInitalFormValues(appName);
|
||||
generateEnvFile(appName, form);
|
||||
|
||||
// Run script
|
||||
await runAppScript(['start', appName]);
|
||||
|
||||
ensureAppState(appName, true);
|
||||
await AppsService.startApp(id);
|
||||
|
||||
res.status(200).json({ message: 'App started successfully' });
|
||||
} catch (e) {
|
||||
|
@ -171,35 +103,7 @@ const installApp = async (req: Request, res: Response, next: NextFunction) => {
|
|||
throw new Error('App name is required');
|
||||
}
|
||||
|
||||
const appIsAvailable = appNames.includes(id);
|
||||
|
||||
if (!appIsAvailable) {
|
||||
throw new Error(`App ${id} not available`);
|
||||
}
|
||||
|
||||
const appExists = fileExists(`/app-data/${id}`);
|
||||
|
||||
if (appExists) {
|
||||
await startApp(req, res, next);
|
||||
} else {
|
||||
const appIsValid = await checkAppRequirements(id);
|
||||
|
||||
if (!appIsValid) {
|
||||
throw new Error(`App ${id} requirements not met`);
|
||||
}
|
||||
|
||||
// Create app folder
|
||||
createFolder(`/app-data/${id}`);
|
||||
|
||||
// Create env file
|
||||
generateEnvFile(id, form);
|
||||
ensureAppState(id, true);
|
||||
|
||||
// Run script
|
||||
await runAppScript(['install', id]);
|
||||
|
||||
res.status(200).json({ message: 'App installed successfully' });
|
||||
}
|
||||
await AppsService.installApp(id, form);
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import portUsed from 'tcp-port-used';
|
||||
import p from 'p-iteration';
|
||||
import { AppConfig } from '../../config/types';
|
||||
import { fileExists, readFile, readJsonFile, runScript, writeFile } from '../fs/fs.helpers';
|
||||
import { internalIpV4 } from 'internal-ip';
|
||||
import { fileExists, readdirSync, readFile, readJsonFile, runScript, writeFile } from '../fs/fs.helpers';
|
||||
import InternalIp from 'internal-ip';
|
||||
|
||||
type AppsState = { installed: string };
|
||||
|
||||
export const checkAppRequirements = async (appName: string) => {
|
||||
let valid = true;
|
||||
|
@ -10,7 +12,7 @@ export const checkAppRequirements = async (appName: string) => {
|
|||
|
||||
if (configFile.requirements?.ports) {
|
||||
await p.forEachSeries(configFile.requirements.ports, async (port: number) => {
|
||||
const ip = await internalIpV4();
|
||||
const ip = await InternalIp.v4();
|
||||
const used = await portUsed.check(port, ip);
|
||||
|
||||
if (used) valid = false;
|
||||
|
@ -94,8 +96,49 @@ export const ensureAppState = (appName: string, installed: boolean) => {
|
|||
}
|
||||
} else {
|
||||
if (state.installed.indexOf(appName) !== -1) {
|
||||
state.installed = state.installed.replace(` ${appName}`, '');
|
||||
state.installed = state.installed.replace(`${appName}`, '');
|
||||
writeFile('/state/apps.json', JSON.stringify(state));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const generateEnvFile = (appName: string, form: Record<string, string>) => {
|
||||
const configFile: AppConfig = readJsonFile(`/apps/${appName}/config.json`);
|
||||
const baseEnvFile = readFile('/.env').toString();
|
||||
let envFile = `${baseEnvFile}\nAPP_PORT=${configFile.port}\n`;
|
||||
|
||||
Object.keys(configFile.form_fields).forEach((key) => {
|
||||
const value = form[key];
|
||||
|
||||
if (value) {
|
||||
const envVar = configFile.form_fields[key].env_variable;
|
||||
envFile += `${envVar}=${value}\n`;
|
||||
} else if (configFile.form_fields[key].required) {
|
||||
throw new Error(`Variable ${key} is required`);
|
||||
}
|
||||
});
|
||||
|
||||
writeFile(`/app-data/${appName}/app.env`, envFile);
|
||||
};
|
||||
|
||||
export const getStateFile = (): AppsState => {
|
||||
return readJsonFile('/state/apps.json');
|
||||
};
|
||||
|
||||
export const getAvailableApps = (): string[] => {
|
||||
const apps: string[] = [];
|
||||
|
||||
const appsDir = readdirSync('/apps');
|
||||
|
||||
appsDir.forEach((app) => {
|
||||
if (fileExists(`/apps/${app}/config.json`)) {
|
||||
const configFile: AppConfig = readJsonFile(`/apps/${app}/config.json`);
|
||||
|
||||
if (configFile.available) {
|
||||
apps.push(app);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return apps;
|
||||
};
|
||||
|
|
101
packages/system-api/src/modules/apps/apps.service.ts
Normal file
101
packages/system-api/src/modules/apps/apps.service.ts
Normal file
|
@ -0,0 +1,101 @@
|
|||
import si from 'systeminformation';
|
||||
import { AppConfig } from '../../config/types';
|
||||
import { createFolder, fileExists, readJsonFile } from '../fs/fs.helpers';
|
||||
import { checkAppExists, checkAppRequirements, checkEnvFile, ensureAppState, generateEnvFile, getAvailableApps, getInitalFormValues, getStateFile, runAppScript } from './apps.helpers';
|
||||
|
||||
const startApp = async (appName: string): Promise<void> => {
|
||||
checkAppExists(appName);
|
||||
checkEnvFile(appName);
|
||||
|
||||
// Regenerate env file
|
||||
const form = getInitalFormValues(appName);
|
||||
generateEnvFile(appName, form);
|
||||
|
||||
// Run script
|
||||
await runAppScript(['start', appName]);
|
||||
|
||||
ensureAppState(appName, true);
|
||||
};
|
||||
|
||||
const installApp = async (id: string, form: Record<string, string>): Promise<void> => {
|
||||
const appExists = fileExists(`/app-data/${id}`);
|
||||
|
||||
if (appExists) {
|
||||
await startApp(id);
|
||||
} else {
|
||||
const appIsValid = await checkAppRequirements(id);
|
||||
|
||||
if (!appIsValid) {
|
||||
throw new Error(`App ${id} requirements not met`);
|
||||
}
|
||||
|
||||
// Create app folder
|
||||
createFolder(`/app-data/${id}`);
|
||||
|
||||
// Create env file
|
||||
generateEnvFile(id, form);
|
||||
ensureAppState(id, true);
|
||||
|
||||
// Run script
|
||||
await runAppScript(['install', id]);
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
const listApps = async (): Promise<AppConfig[]> => {
|
||||
const apps: AppConfig[] = getAvailableApps()
|
||||
.map((app) => {
|
||||
try {
|
||||
return readJsonFile(`/apps/${app}/config.json`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const dockerContainers = await si.dockerContainers();
|
||||
|
||||
const state = getStateFile();
|
||||
const installed: string[] = state.installed.split(' ').filter(Boolean);
|
||||
|
||||
apps.forEach((app) => {
|
||||
app.installed = installed.includes(app.id);
|
||||
app.status = (dockerContainers.find((container) => container.name === `${app.id}`)?.state as 'running') || 'stopped';
|
||||
});
|
||||
|
||||
return apps;
|
||||
};
|
||||
|
||||
const getAppInfo = async (id: string): Promise<AppConfig> => {
|
||||
const dockerContainers = await si.dockerContainers();
|
||||
const configFile: AppConfig = readJsonFile(`/apps/${id}/config.json`);
|
||||
|
||||
const state = getStateFile();
|
||||
const installed: string[] = state.installed.split(' ').filter(Boolean);
|
||||
configFile.installed = installed.includes(id);
|
||||
configFile.status = (dockerContainers.find((container) => container.name === `${id}`)?.state as 'running') || 'stopped';
|
||||
|
||||
return configFile;
|
||||
};
|
||||
|
||||
const updateAppConfig = async (id: string, form: Record<string, string>): Promise<void> => {
|
||||
checkAppExists(id);
|
||||
generateEnvFile(id, form);
|
||||
};
|
||||
|
||||
const stopApp = async (id: string): Promise<void> => {
|
||||
checkAppExists(id);
|
||||
// Run script
|
||||
await runAppScript(['stop', id]);
|
||||
};
|
||||
|
||||
const uninstallApp = async (id: string): Promise<void> => {
|
||||
checkAppExists(id);
|
||||
ensureAppState(id, false);
|
||||
|
||||
// Run script
|
||||
await runAppScript(['uninstall', id]);
|
||||
};
|
||||
|
||||
export default { installApp, startApp, listApps, getAppInfo, updateAppConfig, stopApp, uninstallApp };
|
|
@ -0,0 +1,147 @@
|
|||
import { Request, Response } from 'express';
|
||||
import fs from 'fs';
|
||||
import * as argon2 from 'argon2';
|
||||
import config from '../../../config';
|
||||
import AuthController from '../auth.controller';
|
||||
|
||||
let user: any;
|
||||
|
||||
jest.mock('fs');
|
||||
|
||||
const next = jest.fn();
|
||||
|
||||
const MOCK_USER_REGISTERED = () => ({
|
||||
[`${config.ROOT_FOLDER}/state/users.json`]: `[${user}]`,
|
||||
});
|
||||
|
||||
const MOCK_NO_USER = {
|
||||
[`${config.ROOT_FOLDER}/state/users.json`]: '[]',
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const hash = await argon2.hash('password');
|
||||
user = JSON.stringify({
|
||||
email: 'username',
|
||||
password: hash,
|
||||
});
|
||||
});
|
||||
|
||||
describe('Login', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MOCK_USER_REGISTERED());
|
||||
});
|
||||
|
||||
it('Should put cookie in response after login', async () => {
|
||||
const json = jest.fn();
|
||||
const res = { cookie: jest.fn(), status: jest.fn(() => ({ json })), json: jest.fn() } as unknown as Response;
|
||||
const req = { body: { email: 'username', password: 'password' } } as Request;
|
||||
|
||||
await AuthController.login(req, res, next);
|
||||
|
||||
expect(res.cookie).toHaveBeenCalledWith('tipi_token', expect.any(String), expect.any(Object));
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(json).toHaveBeenCalledWith({ token: expect.any(String) });
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Should throw if username is not provided in request', async () => {
|
||||
const res = { cookie: jest.fn(), status: jest.fn(), json: jest.fn() } as unknown as Response;
|
||||
const req = { body: { password: 'password' } } as Request;
|
||||
|
||||
await AuthController.login(req, res, next);
|
||||
|
||||
expect(res.cookie).not.toHaveBeenCalled();
|
||||
expect(next).toHaveBeenCalledWith(expect.any(Error));
|
||||
});
|
||||
|
||||
it('Should throw if password is not provided in request', async () => {
|
||||
const res = { cookie: jest.fn(), status: jest.fn(), json: jest.fn() } as unknown as Response;
|
||||
const req = { body: { email: 'username' } } as Request;
|
||||
|
||||
await AuthController.login(req, res, next);
|
||||
|
||||
expect(res.cookie).not.toHaveBeenCalled();
|
||||
expect(next).toHaveBeenCalledWith(expect.any(Error));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Register', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MOCK_NO_USER);
|
||||
});
|
||||
|
||||
it('Should put cookie in response after register', async () => {
|
||||
const json = jest.fn();
|
||||
const res = { cookie: jest.fn(), status: jest.fn(() => ({ json })), json: jest.fn() } as unknown as Response;
|
||||
const req = { body: { email: 'username', password: 'password', name: 'name' } } as Request;
|
||||
|
||||
await AuthController.register(req, res, next);
|
||||
|
||||
expect(res.cookie).toHaveBeenCalledWith('tipi_token', expect.any(String), expect.any(Object));
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(json).toHaveBeenCalledWith({ token: expect.any(String) });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Me', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MOCK_USER_REGISTERED());
|
||||
});
|
||||
|
||||
it('Should return user if present in request', async () => {
|
||||
const json = jest.fn();
|
||||
const res = { status: jest.fn(() => ({ json })) } as unknown as Response;
|
||||
const req = { user } as Request;
|
||||
|
||||
await AuthController.me(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(json).toHaveBeenCalledWith({ user });
|
||||
});
|
||||
|
||||
it('Should return null if user is not present in request', async () => {
|
||||
const json = jest.fn();
|
||||
const res = { status: jest.fn(() => ({ json })) } as unknown as Response;
|
||||
const req = {} as Request;
|
||||
|
||||
await AuthController.me(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(json).toHaveBeenCalledWith({ user: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('isConfigured', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MOCK_NO_USER);
|
||||
});
|
||||
|
||||
it('Should return false if no user is registered', async () => {
|
||||
const json = jest.fn();
|
||||
const res = { status: jest.fn(() => ({ json })) } as unknown as Response;
|
||||
const req = {} as Request;
|
||||
|
||||
await AuthController.isConfigured(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(json).toHaveBeenCalledWith({ configured: false });
|
||||
});
|
||||
|
||||
it('Should return true if user is registered', async () => {
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MOCK_USER_REGISTERED());
|
||||
|
||||
const json = jest.fn();
|
||||
const res = { status: jest.fn(() => ({ json })) } as unknown as Response;
|
||||
const req = { user } as Request;
|
||||
|
||||
await AuthController.isConfigured(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(json).toHaveBeenCalledWith({ configured: true });
|
||||
});
|
||||
});
|
|
@ -0,0 +1,71 @@
|
|||
import * as argon2 from 'argon2';
|
||||
import fs from 'fs';
|
||||
import config from '../../../config';
|
||||
import { IUser } from '../../../config/types';
|
||||
import AuthHelpers from '../auth.helpers';
|
||||
|
||||
let user: IUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
const hash = await argon2.hash('password');
|
||||
user = { email: 'username', password: hash, name: 'name' };
|
||||
});
|
||||
|
||||
jest.mock('fs');
|
||||
|
||||
const MOCK_USER_REGISTERED = () => ({
|
||||
[`${config.ROOT_FOLDER}/state/users.json`]: `[${JSON.stringify(user)}]`,
|
||||
});
|
||||
|
||||
describe('TradeTokenForUser', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MOCK_USER_REGISTERED());
|
||||
});
|
||||
|
||||
it('Should return null if token is invalid', () => {
|
||||
const result = AuthHelpers.tradeTokenForUser('invalid token');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('Should return user if token is valid', async () => {
|
||||
const token = await AuthHelpers.getJwtToken(user, 'password');
|
||||
const result = AuthHelpers.tradeTokenForUser(token);
|
||||
|
||||
expect(result).toEqual(user);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetJwtToken', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MOCK_USER_REGISTERED());
|
||||
});
|
||||
|
||||
it('Should return token if user and password are valid', async () => {
|
||||
const token = await AuthHelpers.getJwtToken(user, 'password');
|
||||
expect(token).toBeDefined();
|
||||
});
|
||||
|
||||
it('Should throw if password is invalid', async () => {
|
||||
await expect(AuthHelpers.getJwtToken(user, 'invalid password')).rejects.toThrow('Wrong password');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUser', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MOCK_USER_REGISTERED());
|
||||
});
|
||||
|
||||
it('Should return null if user is not found', () => {
|
||||
const result = AuthHelpers.getUser('invalid token');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Should return user if token is valid', async () => {
|
||||
const result = AuthHelpers.getUser('username');
|
||||
|
||||
expect(result).toEqual(user);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,103 @@
|
|||
import fs from 'fs';
|
||||
// import bcrypt from 'bcrypt';
|
||||
import jsonwebtoken from 'jsonwebtoken';
|
||||
import * as argon2 from 'argon2';
|
||||
import config from '../../../config';
|
||||
import AuthService from '../auth.service';
|
||||
import { IUser } from '../../../config/types';
|
||||
|
||||
jest.mock('fs');
|
||||
|
||||
let user: any;
|
||||
|
||||
const MOCK_USER_REGISTERED = () => ({
|
||||
[`${config.ROOT_FOLDER}/state/users.json`]: `[${user}]`,
|
||||
});
|
||||
|
||||
const MOCK_NO_USER = {
|
||||
[`${config.ROOT_FOLDER}/state/users.json`]: '[]',
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const hash = await argon2.hash('password');
|
||||
user = JSON.stringify({
|
||||
email: 'username',
|
||||
password: hash,
|
||||
});
|
||||
});
|
||||
|
||||
describe('Login', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MOCK_USER_REGISTERED());
|
||||
});
|
||||
|
||||
it('Should return token after login', async () => {
|
||||
const token = await AuthService.login('username', 'password');
|
||||
|
||||
const { email } = jsonwebtoken.verify(token, config.JWT_SECRET) as { email: string };
|
||||
|
||||
expect(token).toBeDefined();
|
||||
expect(email).toBe('username');
|
||||
});
|
||||
|
||||
it('Should throw if user does not exist', async () => {
|
||||
await expect(AuthService.login('username1', 'password')).rejects.toThrowError('User not found');
|
||||
});
|
||||
|
||||
it('Should throw if password is incorrect', async () => {
|
||||
await expect(AuthService.login('username', 'password1')).rejects.toThrowError('Wrong password');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Register', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MOCK_NO_USER);
|
||||
});
|
||||
|
||||
it('Should return token after register', async () => {
|
||||
const token = await AuthService.register('username', 'password', 'name');
|
||||
|
||||
const { email } = jsonwebtoken.verify(token, config.JWT_SECRET) as { email: string };
|
||||
|
||||
expect(token).toBeDefined();
|
||||
expect(email).toBe('username');
|
||||
});
|
||||
|
||||
it('Should correctly write user to file', async () => {
|
||||
await AuthService.register('username', 'password', 'name');
|
||||
|
||||
const users: IUser[] = JSON.parse(fs.readFileSync(`${config.ROOT_FOLDER}/state/users.json`, 'utf8'));
|
||||
|
||||
expect(users.length).toBe(1);
|
||||
expect(users[0].email).toBe('username');
|
||||
expect(users[0].name).toBe('name');
|
||||
|
||||
const valid = await argon2.verify(users[0].password, 'password');
|
||||
|
||||
expect(valid).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Should throw if user already exists', async () => {
|
||||
await AuthService.register('username', 'password', 'name');
|
||||
|
||||
await expect(AuthService.register('username', 'password', 'name')).rejects.toThrowError('There is already an admin user');
|
||||
});
|
||||
|
||||
it('Should throw if email is not provided', async () => {
|
||||
await expect(AuthService.register('', 'password', 'name')).rejects.toThrowError('Missing email or password');
|
||||
});
|
||||
|
||||
it('Should throw if password is not provided', async () => {
|
||||
await expect(AuthService.register('username', '', 'name')).rejects.toThrowError('Missing email or password');
|
||||
});
|
||||
|
||||
it('Does not throw if name is not provided', async () => {
|
||||
await AuthService.register('username', 'password', '');
|
||||
|
||||
const users: IUser[] = JSON.parse(fs.readFileSync(`${config.ROOT_FOLDER}/state/users.json`, 'utf8'));
|
||||
|
||||
expect(users.length).toBe(1);
|
||||
});
|
||||
});
|
|
@ -1,8 +1,7 @@
|
|||
import { NextFunction, Request, Response } from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { IUser } from '../../config/types';
|
||||
import { readJsonFile, writeFile } from '../fs/fs.helpers';
|
||||
import { getJwtToken, getUser } from './auth.helpers';
|
||||
import { readJsonFile } from '../fs/fs.helpers';
|
||||
import AuthService from './auth.service';
|
||||
|
||||
const login = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
|
@ -12,13 +11,7 @@ const login = async (req: Request, res: Response, next: NextFunction) => {
|
|||
throw new Error('Missing id or password');
|
||||
}
|
||||
|
||||
const user = getUser(email);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const token = await getJwtToken(user, password);
|
||||
const token = await AuthService.login(email, password);
|
||||
|
||||
res.cookie('tipi_token', token, {
|
||||
httpOnly: false,
|
||||
|
@ -34,26 +27,9 @@ const login = async (req: Request, res: Response, next: NextFunction) => {
|
|||
|
||||
const register = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const users: IUser[] = readJsonFile('/state/users.json');
|
||||
|
||||
if (users.length > 0) {
|
||||
throw new Error('There is already an admin user');
|
||||
}
|
||||
|
||||
const { email, password, name } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
throw new Error('Missing email or password');
|
||||
}
|
||||
|
||||
if (users.find((user) => user.email === email)) {
|
||||
throw new Error('User already exists');
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
const newuser: IUser = { email, name, password: hash };
|
||||
|
||||
const token = await getJwtToken(newuser, password);
|
||||
const token = await AuthService.register(email, password, name);
|
||||
|
||||
res.cookie('tipi_token', token, {
|
||||
httpOnly: false,
|
||||
|
@ -61,28 +37,34 @@ const register = async (req: Request, res: Response, next: NextFunction) => {
|
|||
maxAge: 1000 * 60 * 60 * 24 * 7,
|
||||
});
|
||||
|
||||
writeFile('/state/users.json', JSON.stringify([newuser]));
|
||||
|
||||
res.status(200).json({ token });
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
};
|
||||
|
||||
const me = async (req: Request, res: Response) => {
|
||||
const { user } = req;
|
||||
const me = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { user } = req;
|
||||
|
||||
if (user) {
|
||||
res.status(200).json({ user });
|
||||
} else {
|
||||
res.status(200).json({ user: null });
|
||||
if (user) {
|
||||
res.status(200).json({ user });
|
||||
} else {
|
||||
res.status(200).json({ user: null });
|
||||
}
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
};
|
||||
|
||||
const isConfigured = async (req: Request, res: Response) => {
|
||||
const users: IUser[] = readJsonFile('/state/users.json');
|
||||
const isConfigured = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const users: IUser[] = readJsonFile('/state/users.json');
|
||||
|
||||
res.status(200).json({ configured: users.length > 0 });
|
||||
res.status(200).json({ configured: users.length > 0 });
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
};
|
||||
|
||||
export default { login, me, register, isConfigured };
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import jsonwebtoken from 'jsonwebtoken';
|
||||
import bcrypt from 'bcrypt';
|
||||
import * as argon2 from 'argon2';
|
||||
import { IUser, Maybe } from '../../config/types';
|
||||
import { readJsonFile } from '../fs/fs.helpers';
|
||||
import config from '../../config';
|
||||
|
||||
export const getUser = (email: string): Maybe<IUser> => {
|
||||
const getUser = (email: string): Maybe<IUser> => {
|
||||
const savedUser: IUser[] = readJsonFile('/state/users.json');
|
||||
|
||||
const user = savedUser.find((u) => u.email === email);
|
||||
|
@ -13,17 +13,19 @@ export const getUser = (email: string): Maybe<IUser> => {
|
|||
};
|
||||
|
||||
const compareHashPassword = (password: string, hash = ''): Promise<boolean> => {
|
||||
return bcrypt.compare(password, hash || '');
|
||||
return argon2.verify(hash, password);
|
||||
};
|
||||
|
||||
const getJwtToken = async (user: IUser, password: string) => {
|
||||
const validPassword = await compareHashPassword(password, user.password || '');
|
||||
const validPassword = await compareHashPassword(password, user.password);
|
||||
|
||||
if (validPassword) {
|
||||
if (config.JWT_SECRET) {
|
||||
return jsonwebtoken.sign({ email: user.email }, config.JWT_SECRET, {
|
||||
expiresIn: '7d',
|
||||
});
|
||||
} else {
|
||||
throw new Error('JWT_SECRET is not set');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -33,6 +35,7 @@ const getJwtToken = async (user: IUser, password: string) => {
|
|||
const tradeTokenForUser = (token: string): Maybe<IUser> => {
|
||||
try {
|
||||
const { email } = jsonwebtoken.verify(token, config.JWT_SECRET) as { email: string };
|
||||
|
||||
const users: IUser[] = readJsonFile('/state/users.json');
|
||||
|
||||
return users.find((user) => user.email === email);
|
||||
|
@ -41,4 +44,4 @@ const tradeTokenForUser = (token: string): Maybe<IUser> => {
|
|||
}
|
||||
};
|
||||
|
||||
export { tradeTokenForUser, getJwtToken };
|
||||
export default { tradeTokenForUser, getJwtToken, getUser };
|
||||
|
|
48
packages/system-api/src/modules/auth/auth.service.ts
Normal file
48
packages/system-api/src/modules/auth/auth.service.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
import * as argon2 from 'argon2';
|
||||
import { IUser } from '../../config/types';
|
||||
import { readJsonFile, writeFile } from '../fs/fs.helpers';
|
||||
import AuthHelpers from './auth.helpers';
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
const user = AuthHelpers.getUser(email);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const token = await AuthHelpers.getJwtToken(user, password);
|
||||
|
||||
return token;
|
||||
};
|
||||
|
||||
const register = async (email: string, password: string, name: string) => {
|
||||
const users: IUser[] = readJsonFile('/state/users.json');
|
||||
|
||||
if (users.length > 0) {
|
||||
throw new Error('There is already an admin user');
|
||||
}
|
||||
|
||||
if (!email || !password) {
|
||||
throw new Error('Missing email or password');
|
||||
}
|
||||
|
||||
if (users.find((user) => user.email === email)) {
|
||||
throw new Error('User already exists');
|
||||
}
|
||||
|
||||
const hash = await argon2.hash(password); // bcrypt.hash(password, 10);
|
||||
const newuser: IUser = { email, name, password: hash };
|
||||
|
||||
const token = await AuthHelpers.getJwtToken(newuser, password);
|
||||
|
||||
writeFile('/state/users.json', JSON.stringify([newuser]));
|
||||
|
||||
return token;
|
||||
};
|
||||
|
||||
const AuthService = {
|
||||
login,
|
||||
register,
|
||||
};
|
||||
|
||||
export default AuthService;
|
|
@ -11,6 +11,8 @@ export const readJsonFile = (path: string): any => {
|
|||
|
||||
export const readFile = (path: string): string => fs.readFileSync(getAbsolutePath(path)).toString();
|
||||
|
||||
export const readdirSync = (path: string): string[] => fs.readdirSync(getAbsolutePath(path));
|
||||
|
||||
export const fileExists = (path: string): boolean => fs.existsSync(getAbsolutePath(path));
|
||||
|
||||
export const writeFile = (path: string, data: any) => fs.writeFileSync(getAbsolutePath(path), data);
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Request, Response } from 'express';
|
||||
import publicIp from 'public-ip';
|
||||
import portScanner from 'node-port-scanner';
|
||||
import { internalIpV4 } from 'internal-ip';
|
||||
import internalIp from 'internal-ip';
|
||||
|
||||
const isPortOpen = async (req: Request, res: Response<boolean>) => {
|
||||
const { port } = req.params;
|
||||
|
@ -14,7 +14,7 @@ const isPortOpen = async (req: Request, res: Response<boolean>) => {
|
|||
};
|
||||
|
||||
const getInternalIp = async (req: Request, res: Response<string>) => {
|
||||
const ip = await internalIpV4();
|
||||
const ip = await internalIp.v4();
|
||||
|
||||
res.status(200).send(ip);
|
||||
};
|
||||
|
|
3
packages/system-api/tests/dotenv-config.ts
Normal file
3
packages/system-api/tests/dotenv-config.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import * as dotenv from 'dotenv';
|
||||
|
||||
dotenv.config({ path: '.env.test' });
|
|
@ -8,13 +8,13 @@
|
|||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"isolatedModules": false,
|
||||
"jsx": "preserve",
|
||||
"incremental": true
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.cjs"],
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.cjs", "jest.config.cjs"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|
6144
pnpm-lock.yaml
6144
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue