Merge develop

This commit is contained in:
Nicolas Meienberger 2022-05-09 11:06:53 +02:00
parent c73308e08c
commit 96555d884b
14 changed files with 1697 additions and 4605 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
.pnpm-debug.log
.env
.env*
node_modules/

View file

@ -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",

View file

@ -1,3 +1,3 @@
node_modules/
dist/
*.cjs
*.cjs

View file

@ -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: {

View file

@ -0,0 +1,7 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/__tests__/**/*.test.ts'],
setupFiles: ['dotenv/config'],
};

View file

@ -2,12 +2,15 @@
"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",
"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",
@ -24,7 +27,7 @@
"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",
"node-port-scanner": "^3.0.1",
"p-iteration": "^1.1.8",
@ -41,23 +44,25 @@
"@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/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"
}
}

View file

@ -7,7 +7,11 @@ interface IConfig {
CLIENT_URLS: string[];
}
dotenv.config();
if (process.env.NODE_ENV === 'test') {
dotenv.config({ path: '.env.test' });
} else {
dotenv.config();
}
const { NODE_ENV = 'development', ROOT_FOLDER = '', JWT_SECRET = '', INTERNAL_IP = '' } = process.env;

View file

@ -0,0 +1,7 @@
import AppsService from '../apps.service';
describe('Install app', () => {
it('Should throw when app is not available', () => {
expect(AppsService.installApp('not-available', {})).rejects.toThrow('App not-available not available');
});
});

View file

@ -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);
}

View file

@ -2,7 +2,9 @@ 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 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;
@ -99,3 +101,26 @@ export const ensureAppState = (appName: string, installed: boolean) => {
}
}
};
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');
};

View file

@ -0,0 +1,106 @@
import si from 'systeminformation';
import { appNames } from '../../config/apps';
import { AppConfig } from '../../config/types';
import { createFolder, fileExists, readJsonFile } from '../fs/fs.helpers';
import { checkAppExists, checkAppRequirements, checkEnvFile, ensureAppState, generateEnvFile, 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 appIsAvailable = appNames.includes(id);
if (!appIsAvailable) {
throw new Error(`App ${id} not available`);
}
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]);
}
};
const listApps = async (): Promise<AppConfig[]> => {
const apps: AppConfig[] = 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 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 };

View file

@ -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);
};

View file

@ -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"]
}

File diff suppressed because it is too large Load diff