WIP - App Service with GraphQL

This commit is contained in:
Nicolas Meienberger 2022-06-20 21:04:42 +02:00
parent ac712013da
commit ce615a40f1
22 changed files with 292 additions and 15442 deletions

View file

@ -1,13 +1,15 @@
version: "3.7"
services:
postgres:
container_name: postgres
tipi-db:
container_name: tipi-db
image: postgres:latest
restart: on-failure
stop_grace_period: 1m
volumes:
- ./data/postgres:/var/lib/postgresql/data
ports:
- 5432:5432
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_USER: ${POSTGRES_USERNAME}
@ -21,7 +23,7 @@ services:
dockerfile: Dockerfile.dev
command: bash -c "cd /api && npm run dev"
depends_on:
- postgres
- tipi-db
container_name: api
ports:
- 3001:3001

View file

@ -4,3 +4,4 @@ dist/
# testing
coverage/
logs/
sessions/

File diff suppressed because it is too large Load diff

View file

@ -34,6 +34,7 @@
"express": "^4.17.3",
"express-session": "^1.17.3",
"graphql": "^15.3.0",
"graphql-type-json": "^0.3.2",
"helmet": "^5.0.2",
"http": "0.0.1-security",
"internal-ip": "^6.0.0",

View file

@ -1,6 +1,7 @@
import * as dotenv from 'dotenv';
import { DataSourceOptions } from 'typeorm';
import App from '../modules/apps/app.entity';
import User from '../modules/auth/user.entity';
import { __prod__ } from './constants/constants';
interface IConfig {
@ -43,14 +44,14 @@ const config: IConfig = {
},
typeorm: {
type: 'postgres',
host: 'postgres',
host: 'tipi-db',
database: POSTGRES_DB,
username: POSTGRES_USER,
password: POSTGRES_PASSWORD,
port: 5432,
logging: !__prod__,
synchronize: true,
entities: [App],
entities: [App, User],
},
NODE_ENV,
ROOT_FOLDER: '/tipi',

View file

@ -0,0 +1,21 @@
import { ObjectType, Field } from 'type-graphql';
@ObjectType()
class FieldError {
@Field()
code!: number;
@Field()
message!: string;
@Field({ nullable: true })
field?: string;
}
@ObjectType()
class ErrorResponse {
@Field(() => [FieldError], { nullable: true })
errors?: FieldError[];
}
export { FieldError, ErrorResponse };

View file

@ -9,16 +9,12 @@ class App extends BaseEntity {
@Column({ type: 'varchar', primary: true, unique: true })
id!: string;
@Field(() => Boolean)
@Column({ type: 'boolean', default: false })
installed!: boolean;
@Field(() => String)
@Column({ type: 'enum', enum: AppStatusEnum, default: AppStatusEnum.STOPPED, nullable: false })
status!: AppStatusEnum;
@Field(() => Date)
@Column({ type: 'date', nullable: true })
@Column({ type: 'timestamptz', nullable: true, default: () => 'CURRENT_TIMESTAMP' })
lastOpened!: Date;
@Field(() => Number)

View file

@ -40,11 +40,11 @@ export const checkEnvFile = (appName: string) => {
const configFile: AppConfig = readJsonFile(`/apps/${appName}/config.json`);
const envMap = getEnvMap(appName);
Object.keys(configFile.form_fields).forEach((key) => {
const envVar = configFile.form_fields[key].env_variable;
configFile.form_fields.forEach((field) => {
const envVar = field.env_variable;
const envVarValue = envMap.get(envVar);
if (!envVarValue && configFile.form_fields[key].required) {
if (!envVarValue && field.required) {
throw new Error('New info needed. App config needs to be updated');
}
});
@ -109,14 +109,14 @@ export const generateEnvFile = (appName: string, form: Record<string, string>) =
const baseEnvFile = readFile('/.env').toString();
let envFile = `${baseEnvFile}\nAPP_PORT=${configFile.port}\n`;
Object.keys(configFile.form_fields).forEach((key) => {
const value = form[key];
configFile.form_fields.forEach((field) => {
const formValue = form[field.env_variable];
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`);
if (formValue) {
const envVar = field.env_variable;
envFile += `${envVar}=${formValue}\n`;
} else if (field.required) {
throw new Error(`Variable ${field.env_variable} is required`);
}
});

View file

@ -1,6 +1,6 @@
import { Arg, Authorized, Query, Resolver } from 'type-graphql';
import { Arg, Authorized, Mutation, Query, Resolver } from 'type-graphql';
import AppsService from './apps.service';
import { AppConfig, ListAppsResonse } from './apps.types';
import { AppConfig, AppInputType, ListAppsResonse } from './apps.types';
import App from './app.entity';
@Resolver()
@ -20,4 +20,38 @@ export default class AppsResolver {
async installedApps(): Promise<App[]> {
return App.find();
}
@Authorized()
@Mutation(() => App)
async installApp(@Arg('input', () => AppInputType) input: AppInputType): Promise<App> {
const { id, form } = input;
return AppsService.installApp(id, form);
}
@Authorized()
@Mutation(() => App)
async startApp(@Arg('id', () => String) id: string): Promise<App> {
return AppsService.startApp(id);
}
@Authorized()
@Mutation(() => App)
async stopApp(@Arg('id', () => String) id: string): Promise<App> {
return AppsService.stopApp(id);
}
@Authorized()
@Mutation(() => App)
async uninstallApp(@Arg('id', () => String) id: string): Promise<boolean> {
return AppsService.uninstallApp(id);
}
@Authorized()
@Mutation(() => App)
async updateAppConfig(@Arg('input', () => AppInputType) input: AppInputType): Promise<App> {
const { id, form } = input;
return AppsService.updateAppConfig(id, form);
}
}

View file

@ -1,27 +1,37 @@
import si from 'systeminformation';
import { AppStatusEnum } from '@runtipi/common';
import { createFolder, fileExists, readFile, readJsonFile } from '../fs/fs.helpers';
import { checkAppExists, checkAppRequirements, checkEnvFile, ensureAppState, generateEnvFile, getAvailableApps, getInitalFormValues, getStateFile, runAppScript } from './apps.helpers';
import { createFolder, readFile, readJsonFile } from '../fs/fs.helpers';
import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps, getInitalFormValues, getStateFile, runAppScript } from './apps.helpers';
import { AppConfig, ListAppsResonse } from './apps.types';
import App from './app.entity';
const startApp = async (appName: string): Promise<App> => {
let app = await App.findOne({ where: { id: appName } });
if (!app) {
throw new Error(`App ${appName} not found`);
}
const startApp = async (appName: string): Promise<void> => {
checkAppExists(appName);
checkEnvFile(appName);
// Regenerate env file
const form = getInitalFormValues(appName);
generateEnvFile(appName, form);
await App.update({ id: appName }, { status: AppStatusEnum.STARTING });
// Run script
await runAppScript(['start', appName]);
await App.update({ id: appName }, { status: AppStatusEnum.RUNNING });
ensureAppState(appName, true);
app = (await App.findOne({ where: { id: appName } })) as App;
return app;
};
const installApp = async (id: string, form: Record<string, string>): Promise<void> => {
const appExists = fileExists(`/app-data/${id}`);
const installApp = async (id: string, form: Record<string, string>): Promise<App> => {
let app = await App.findOne({ where: { id } });
if (appExists) {
if (app) {
await startApp(id);
} else {
const appIsValid = await checkAppRequirements(id);
@ -35,13 +45,16 @@ const installApp = async (id: string, form: Record<string, string>): Promise<voi
// Create env file
generateEnvFile(id, form);
ensureAppState(id, true);
await App.create({ id, status: AppStatusEnum.INSTALLING }).save();
// Run script
await runAppScript(['install', id]);
}
return Promise.resolve();
await App.update({ id }, { status: AppStatusEnum.RUNNING });
app = (await App.findOne({ where: { id } })) as App;
return app;
};
const listApps = async (): Promise<ListAppsResonse> => {
@ -82,23 +95,45 @@ const getAppInfo = async (id: string): Promise<AppConfig> => {
return configFile;
};
const updateAppConfig = async (id: string, form: Record<string, string>): Promise<void> => {
checkAppExists(id);
const updateAppConfig = async (id: string, form: Record<string, string>): Promise<App> => {
const app = await App.findOne({ where: { id } });
if (!app) {
throw new Error(`App ${id} not found`);
}
generateEnvFile(id, form);
return app;
};
const stopApp = async (id: string): Promise<void> => {
checkAppExists(id);
const stopApp = async (id: string): Promise<App> => {
let app = await App.findOne({ where: { id } });
// Run script
await App.update({ id }, { status: AppStatusEnum.STOPPING });
await runAppScript(['stop', id]);
await App.update({ id }, { status: AppStatusEnum.STOPPED });
app = (await App.findOne({ where: { id } })) as App;
return app;
};
const uninstallApp = async (id: string): Promise<void> => {
checkAppExists(id);
ensureAppState(id, false);
const uninstallApp = async (id: string): Promise<boolean> => {
let app = await App.findOne({ where: { id } });
if (!app) {
throw new Error(`App ${id} not found`);
}
if (app.status === AppStatusEnum.RUNNING) {
await stopApp(id);
}
await App.update({ id }, { status: AppStatusEnum.UNINSTALLING });
// Run script
await runAppScript(['uninstall', id]);
await App.delete({ id });
return true;
};
export default { installApp, startApp, listApps, getAppInfo, updateAppConfig, stopApp, uninstallApp };

View file

@ -1,5 +1,6 @@
import { AppCategoriesEnum, AppStatusEnum, FieldTypes } from '@runtipi/common';
import { Field, ObjectType } from 'type-graphql';
import { Field, InputType, ObjectType } from 'type-graphql';
import { GraphQLJSONObject } from 'graphql-type-json';
@ObjectType()
class FormField {
@ -82,4 +83,13 @@ class ListAppsResonse {
total!: number;
}
export { ListAppsResonse, AppConfig };
@InputType()
class AppInputType {
@Field(() => String)
id!: string;
@Field(() => GraphQLJSONObject)
form!: Record<string, string>;
}
export { ListAppsResonse, AppConfig, AppInputType };

View file

@ -1,70 +0,0 @@
import { NextFunction, Request, Response } from 'express';
import { IUser } from '../../config/types';
import { readJsonFile } from '../fs/fs.helpers';
import AuthService from './auth.service';
const login = async (req: Request, res: Response, next: NextFunction) => {
try {
const { email, password } = req.body;
if (!email || !password) {
throw new Error('Missing id or password');
}
const token = await AuthService.login(email, password);
res.cookie('tipi_token', token, {
httpOnly: false,
secure: false,
maxAge: 1000 * 60 * 60 * 24 * 7,
});
res.status(200).json({ token });
} catch (e) {
next(e);
}
};
const register = async (req: Request, res: Response, next: NextFunction) => {
try {
const { email, password, name } = req.body;
const token = await AuthService.register(email, password, name);
res.cookie('tipi_token', token, {
httpOnly: false,
secure: false,
maxAge: 1000 * 60 * 60 * 24 * 7,
});
res.status(200).json({ token });
} catch (e) {
next(e);
}
};
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 });
}
} catch (e) {
next(e);
}
};
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 });
} catch (e) {
next(e);
}
};
export default { login, me, register, isConfigured };

View file

@ -3,6 +3,7 @@ import * as argon2 from 'argon2';
import { IUser, Maybe } from '../../config/types';
import { readJsonFile } from '../fs/fs.helpers';
import config from '../../config';
import User from './user.entity';
const getUser = (email: string): Maybe<IUser> => {
const savedUser: IUser[] = readJsonFile('/state/users.json');
@ -14,12 +15,12 @@ const compareHashPassword = (password: string, hash = ''): Promise<boolean> => {
return argon2.verify(hash, password);
};
const getJwtToken = async (user: IUser, password: string) => {
const getJwtToken = async (user: User, password: string) => {
const validPassword = await compareHashPassword(password, user.password);
if (validPassword) {
if (config.JWT_SECRET) {
return jsonwebtoken.sign({ email: user.email }, config.JWT_SECRET, {
return jsonwebtoken.sign({ email: user.username }, config.JWT_SECRET, {
expiresIn: '7d',
});
} else {

View file

@ -0,0 +1,53 @@
import { Arg, Authorized, Ctx, Mutation, Query, Resolver } from 'type-graphql';
import { MyContext } from '../../types';
import { UsernamePasswordInput, UserResponse } from './auth.types';
import AuthService from './auth.service';
import User from './user.entity';
@Resolver()
export default class AuthResolver {
@Query(() => User, { nullable: true })
async me(@Ctx() ctx: MyContext): Promise<User | null> {
const user = await AuthService.me(ctx.req.session.userId);
return user;
}
@Mutation(() => UserResponse)
async register(@Arg('input', () => UsernamePasswordInput) input: UsernamePasswordInput, @Ctx() { req }: MyContext): Promise<UserResponse> {
const { user } = await AuthService.register(input);
if (user) {
req.session.userId = user.id;
}
return { user };
}
@Mutation(() => UserResponse)
async login(@Arg('input', () => UsernamePasswordInput) input: UsernamePasswordInput, @Ctx() { req }: MyContext): Promise<UserResponse> {
const { user } = await AuthService.login(input);
if (user) {
req.session.userId = user.id;
}
return { user };
}
@Authorized()
@Mutation(() => Boolean)
logout(@Ctx() { req }: MyContext): boolean {
req.session.userId = undefined;
return true;
}
@Query(() => Boolean)
async isConfigured(): Promise<boolean> {
const users = await User.find();
return users.length > 0;
}
}

View file

@ -1,11 +0,0 @@
import { Router } from 'express';
import AuthController from './auth.controller';
const router = Router();
router.route('/login').post(AuthController.login);
router.route('/me').get(AuthController.me);
router.route('/configured').get(AuthController.isConfigured);
router.route('/register').post(AuthController.register);
export default router;

View file

@ -1,42 +1,52 @@
import * as argon2 from 'argon2';
import { IUser } from '../../config/types';
import { readJsonFile, writeFile } from '../fs/fs.helpers';
import AuthHelpers from './auth.helpers';
import { UsernamePasswordInput, UserResponse } from './auth.types';
import User from './user.entity';
const login = async (email: string, password: string) => {
const user = AuthHelpers.getUser(email);
const login = async (input: UsernamePasswordInput): Promise<UserResponse> => {
const { password, username } = input;
const user = await User.findOne({ where: { username: username.trim().toLowerCase() } });
if (!user) {
throw new Error('User not found');
}
return AuthHelpers.getJwtToken(user, password);
};
const isPasswordValid = await argon2.verify(user.password, password);
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 (!isPasswordValid) {
throw new Error('Wrong password');
}
if (!email || !password) {
return { user };
};
const register = async (input: UsernamePasswordInput): Promise<UserResponse> => {
const { password, username } = input;
if (!username || !password) {
throw new Error('Missing email or password');
}
const hash = await argon2.hash(password);
const newuser: IUser = { email, name, password: hash };
const newUser = await User.create({ username: username.trim().toLowerCase(), password: hash }).save();
const token = await AuthHelpers.getJwtToken(newuser, password);
return { user: newUser };
};
writeFile('/state/users.json', JSON.stringify([newuser]));
const me = async (userId?: number): Promise<User | null> => {
if (!userId) return null;
return token;
const user = await User.findOne({ where: { id: userId } });
if (!user) return null;
return user;
};
const AuthService = {
login,
register,
me,
};
export default AuthService;

View file

@ -0,0 +1,19 @@
import { Field, InputType, ObjectType } from 'type-graphql';
import User from './user.entity';
@InputType()
class UsernamePasswordInput {
@Field(() => String)
username!: string;
@Field(() => String)
password!: string;
}
@ObjectType()
class UserResponse {
@Field(() => User, { nullable: true })
user?: User;
}
export { UsernamePasswordInput, UserResponse };

View file

@ -0,0 +1,28 @@
/* eslint-disable import/no-cycle */
import { Field, ID, ObjectType } from 'type-graphql';
import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
import { IsEmail } from 'class-validator';
@ObjectType()
@Entity()
export default class User extends BaseEntity {
@Field(() => ID)
@PrimaryGeneratedColumn()
id!: number;
@Field(() => String)
@IsEmail()
@Column({ type: 'varchar', unique: true })
username!: string;
@Column({ type: 'varchar', nullable: false })
password!: string;
@Field(() => Date)
@CreateDateColumn()
createdAt!: Date;
@Field(() => Date)
@UpdateDateColumn()
updatedAt!: Date;
}

View file

@ -17,7 +17,11 @@ export const fileExists = (path: string): boolean => fs.existsSync(getAbsolutePa
export const writeFile = (path: string, data: any) => fs.writeFileSync(getAbsolutePath(path), data);
export const createFolder = (path: string) => fs.mkdirSync(getAbsolutePath(path));
export const createFolder = (path: string) => {
if (!fileExists(path)) {
fs.mkdirSync(getAbsolutePath(path));
}
};
export const deleteFolder = (path: string) => fs.rmSync(getAbsolutePath(path), { recursive: true });
export const copyFile = (source: string, destination: string) => fs.copyFileSync(getAbsolutePath(source), getAbsolutePath(destination));

View file

@ -2,10 +2,11 @@ import { GraphQLSchema } from 'graphql';
import { buildSchema } from 'type-graphql';
import { customAuthChecker } from './core/middlewares/authChecker';
import AppsResolver from './modules/apps/apps.resolver';
import AuthResolver from './modules/auth/auth.resolver';
const createSchema = (): Promise<GraphQLSchema> =>
buildSchema({
resolvers: [AppsResolver],
resolvers: [AppsResolver, AuthResolver],
validate: true,
authChecker: customAuthChecker,
});

View file

@ -9,6 +9,7 @@ import { ApolloLogs } from './config/logger/apollo.logger';
import { createServer } from 'http';
import logger from './config/logger/logger';
import getSessionMiddleware from './core/middlewares/sessionMiddleware';
import { MyContext } from './types';
const main = async () => {
try {
@ -27,6 +28,7 @@ const main = async () => {
const apolloServer = new ApolloServer({
schema,
context: ({ req, res }): MyContext => ({ req, res }),
plugins: [Playground({ settings: { 'request.credentials': 'include' } }), ApolloLogs],
});
await apolloServer.start();

View file

@ -160,6 +160,7 @@ importers:
express: ^4.17.3
express-session: ^1.17.3
graphql: ^15.3.0
graphql-type-json: ^0.3.2
helmet: ^5.0.2
http: 0.0.1-security
internal-ip: ^6.0.0
@ -200,6 +201,7 @@ importers:
express: 4.18.1
express-session: 1.17.3
graphql: 15.8.0
graphql-type-json: 0.3.2_graphql@15.8.0
helmet: 5.0.2
http: 0.0.1-security
internal-ip: 6.2.0
@ -4683,7 +4685,7 @@ packages:
eslint-import-resolver-webpack:
optional: true
dependencies:
'@typescript-eslint/parser': 5.22.0_uhoeudlwl7kc47h4kncsfowede
'@typescript-eslint/parser': 5.22.0_hcfsmds2fshutdssjqluwm76uu
debug: 3.2.7
eslint-import-resolver-node: 0.3.6
find-up: 2.1.0
@ -5573,6 +5575,14 @@ packages:
tslib: 2.4.0
dev: false
/graphql-type-json/0.3.2_graphql@15.8.0:
resolution: {integrity: sha512-J+vjof74oMlCWXSvt0DOf2APEdZOCdubEvGDUAlqH//VBYcOYsGgRW7Xzorr44LvkjiuvecWc8fChxuZZbChtg==}
peerDependencies:
graphql: '>=0.8.0'
dependencies:
graphql: 15.8.0
dev: false
/graphql/15.8.0:
resolution: {integrity: sha512-5gghUc24tP9HRznNpV2+FIoq3xKkj5dTQqf4v0CpdPbFVwFkWoxOM+o+2OC9ZSvjEMTjfmG9QT+gcvggTwW1zw==}
engines: {node: '>= 10.x'}