WIP: Postgres + Redis + Typeorm + Type-GraphQL

This commit is contained in:
Nicolas Meienberger 2022-06-16 23:29:03 +02:00
parent 6715880c01
commit 16f3e3313d
19 changed files with 16543 additions and 163 deletions

1
.gitignore vendored
View file

@ -3,6 +3,7 @@
github.secrets
node_modules/
app-data/*
data/
traefik/ssl/*
!traefik/ssl/.gitkeep
!app-data/.gitkeep

View file

@ -45,7 +45,6 @@ services:
ANONADDY_DOMAIN: ${ANONADDY_DOMAIN}
ANONADDY_SECRET: ${ANONADDY_SECRET}
ANONADDY_ADMIN_USERNAME: ${ANONADDY_USERNAME}
POSTFIX_DEBUG: true
restart: unless-stopped
networks:
- tipi_main_network

View file

@ -1,11 +1,37 @@
version: "3.7"
services:
postgres:
container_name: postgres
image: postgres:latest
restart: on-failure
stop_grace_period: 1m
volumes:
- ./data/postgres:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_USER: ${POSTGRES_USERNAME}
POSTGRES_DB: ${POSTGRES_DBNAME}
networks:
- tipi_main_network
redis:
container_name: redis
image: redis:latest
restart: on-failure
volumes:
- ./data/redis:/data
networks:
- tipi_main_network
api:
build:
context: .
dockerfile: Dockerfile.dev
command: bash -c "cd /api && npm run dev"
depends_on:
- postgres
- redis
container_name: api
ports:
- 3001:3001
@ -16,11 +42,14 @@ services:
- ${PWD}/packages/system-api:/api
- /api/node_modules
environment:
- INTERNAL_IP=${INTERNAL_IP}
- TIPI_VERSION=${TIPI_VERSION}
- JWT_SECRET=${JWT_SECRET}
- ROOT_FOLDER_HOST=${ROOT_FOLDER_HOST}
- NGINX_PORT=${NGINX_PORT}
INTERNAL_IP: ${INTERNAL_IP}
TIPI_VERSION: ${TIPI_VERSION}
JWT_SECRET: ${JWT_SECRET}
ROOT_FOLDER_HOST: ${ROOT_FOLDER_HOST}
NGINX_PORT: ${NGINX_PORT}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_USER: ${POSTGRES_USERNAME}
POSTGRES_DB: ${POSTGRES_DBNAME}
networks:
- tipi_main_network

View file

@ -2,4 +2,5 @@ node_modules/
dist/
# testing
/coverage
coverage/
logs/

15168
packages/system-api/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -22,15 +22,21 @@
"license": "ISC",
"dependencies": {
"@runtipi/common": "file:../common",
"apollo-server-core": "^3.9.0",
"apollo-server-express": "^3.9.0",
"argon2": "^0.28.5",
"axios": "^0.26.1",
"class-validator": "^0.13.2",
"compression": "^1.7.4",
"connect-redis": "^6.1.3",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dotenv": "^16.0.0",
"express": "^4.17.3",
"graphql": "^16.5.0",
"express-session": "^1.17.3",
"graphql": "^15.3.0",
"helmet": "^5.0.2",
"http": "0.0.1-security",
"internal-ip": "^6.0.0",
"jsonwebtoken": "^8.5.1",
"mock-fs": "^5.1.2",
@ -40,16 +46,23 @@
"passport": "^0.5.2",
"passport-cookie": "^1.0.9",
"passport-http-bearer": "^1.0.1",
"pg": "^8.7.3",
"public-ip": "^5.0.0",
"redis": "^4.1.0",
"reflect-metadata": "^0.1.13",
"systeminformation": "^5.11.9",
"tcp-port-used": "^1.0.2",
"type-graphql": "^1.1.1"
"type-graphql": "^1.1.1",
"typeorm": "^0.3.6",
"winston": "^3.7.2"
},
"devDependencies": {
"@types/compression": "^1.7.2",
"@types/connect-redis": "^0.0.18",
"@types/cookie-parser": "^1.4.3",
"@types/cors": "^2.8.12",
"@types/express": "^4.17.13",
"@types/express-session": "^1.17.4",
"@types/jest": "^27.5.0",
"@types/jsonwebtoken": "^8.5.8",
"@types/mock-fs": "^4.13.1",

View file

@ -1,6 +1,16 @@
import * as dotenv from 'dotenv';
import redis from 'redis';
import { DataSourceOptions } from 'typeorm';
import { __prod__ } from './constants/constants';
interface IConfig {
logs: {
LOGS_FOLDER: string;
LOGS_APP: string;
LOGS_ERROR: string;
};
typeorm: DataSourceOptions;
redis: Parameters<typeof redis.createClient>[0];
NODE_ENV: string;
ROOT_FOLDER: string;
JWT_SECRET: string;
@ -11,9 +21,42 @@ interface IConfig {
dotenv.config();
const { NODE_ENV = 'development', JWT_SECRET = '', INTERNAL_IP = '', TIPI_VERSION = '', ROOT_FOLDER_HOST = '', NGINX_PORT = '80' } = process.env;
const {
LOGS_FOLDER = 'logs',
LOGS_APP = 'app.log',
LOGS_ERROR = 'error.log',
NODE_ENV = 'development',
JWT_SECRET = '',
INTERNAL_IP = '',
TIPI_VERSION = '',
ROOT_FOLDER_HOST = '',
NGINX_PORT = '80',
POSTGRES_DB = '',
POSTGRES_USER = '',
POSTGRES_PASSWORD = '',
} = process.env;
const config: IConfig = {
logs: {
LOGS_FOLDER,
LOGS_APP,
LOGS_ERROR,
},
typeorm: {
type: 'postgres',
host: 'postgres',
database: POSTGRES_DB,
username: POSTGRES_USER,
password: POSTGRES_PASSWORD,
port: 5432,
logging: !__prod__,
synchronize: true,
entities: [],
},
redis: {
url: 'redis://redis:6379',
legacyMode: true,
},
NODE_ENV,
ROOT_FOLDER: '/tipi',
JWT_SECRET,

View file

@ -0,0 +1,6 @@
/* eslint-disable @typescript-eslint/naming-convention */
const __prod__ = process.env.NODE_ENV === 'production';
const COOKIE_MAX_AGE = 1000 * 60 * 60 * 24 * 365 * 10;
export { __prod__, COOKIE_MAX_AGE };

View file

@ -0,0 +1,18 @@
/* eslint-disable require-await */
import { PluginDefinition } from 'apollo-server-core';
import { __prod__ } from '../constants/constants';
import logger from './logger';
const ApolloLogs: PluginDefinition = {
requestDidStart: async () => {
return {
async didEncounterErrors(errors) {
if (!__prod__) {
logger.error(JSON.stringify(errors.errors));
}
},
};
},
};
export { ApolloLogs };

View file

@ -0,0 +1,62 @@
import fs from 'fs';
import path from 'path';
import { createLogger, format, transports } from 'winston';
import config from '..';
const { align, printf, timestamp, combine, colorize } = format;
// Create the logs directory if it does not exist
if (!fs.existsSync(config.logs.LOGS_FOLDER)) {
fs.mkdirSync(config.logs.LOGS_FOLDER);
}
/**
* Production logger format
*/
const combinedLogFormat = combine(
timestamp(),
printf((info) => `${info.timestamp} > ${info.message}`),
);
/**
* Development logger format
*/
const combinedLogFormatDev = combine(
colorize(),
align(),
printf((info) => `${info.level}: ${info.message}`),
);
const Logger = createLogger({
level: 'info',
format: combinedLogFormat,
transports: [
//
// - Write to all logs with level `info` and below to `app.log`
// - Write all logs error (and below) to `error.log`.
//
new transports.File({
filename: path.join(config.logs.LOGS_FOLDER, config.logs.LOGS_ERROR),
level: 'error',
}),
new transports.File({
filename: path.join(config.logs.LOGS_FOLDER, config.logs.LOGS_APP),
}),
],
exceptionHandlers: [new transports.File({ filename: path.join(config.logs.LOGS_FOLDER, config.logs.LOGS_ERROR) })],
});
//
// If we're not in production then log to the `console
//
const LoggerDev = createLogger({
level: 'debug',
format: combinedLogFormatDev,
transports: [
new transports.Console({
level: 'debug',
}),
],
});
export default config.NODE_ENV === 'production' ? Logger : LoggerDev;

View file

@ -0,0 +1,13 @@
import { AuthChecker } from 'type-graphql';
import { MyContext } from '../../types';
export const customAuthChecker: AuthChecker<MyContext> = ({ context }) => {
// here we can read the user from context
// and check his permission in the db against the `roles` argument
// that comes from the `@Authorized` decorator, eg. ["ADMIN", "MODERATOR"]
if (!context.req?.session?.userId) {
return false;
}
return true;
};

View file

@ -0,0 +1,24 @@
import connectRedis from 'connect-redis';
import session from 'express-session';
import { createClient } from 'redis';
import config from '../../config';
import { COOKIE_MAX_AGE, __prod__ } from '../../config/constants/constants';
const getSessionMiddleware = async (): Promise<any> => {
const RedisStore = connectRedis(session);
const redisClient = createClient(config.redis);
await redisClient.connect();
return session({
name: 'qid',
store: new RedisStore({ client: redisClient as any, disableTouch: true }),
cookie: { maxAge: COOKIE_MAX_AGE, secure: __prod__, sameSite: 'lax', httpOnly: true },
secret: config.JWT_SECRET,
resave: false,
saveUninitialized: false,
});
};
export default getSessionMiddleware;

View file

@ -0,0 +1,29 @@
import { AppStatusEnum } from '@runtipi/common';
import { Field, ObjectType } from 'type-graphql';
import { BaseEntity, Column, CreateDateColumn, Entity, UpdateDateColumn } from 'typeorm';
@ObjectType()
@Entity()
class App extends BaseEntity {
@Field(() => String)
@Column({ primary: true, unique: true })
id!: string;
@Field(() => Boolean)
@Column({ type: 'boolean', default: false })
installed!: boolean;
@Field(() => AppStatusEnum)
@Column({ type: 'enum', enum: AppStatusEnum, default: AppStatusEnum.STOPPED, nullable: false })
status!: AppStatusEnum;
@Field(() => Date)
@CreateDateColumn()
createdAt!: Date;
@Field(() => Date)
@UpdateDateColumn()
updatedAt!: Date;
}
export default App;

View file

@ -1,29 +1,29 @@
import { AppCategoriesEnum, AppStatusEnum, FieldTypes } from '@runtipi/common';
import { AppCategoriesEnum, AppStatusEnum } from '@runtipi/common';
import { Field, ObjectType } from 'type-graphql';
@ObjectType()
class FormField {
@Field()
type!: FieldTypes;
// @ObjectType(() => FormField)
// class FormField {
// @Field()
// type!: FieldTypes;
@Field()
label!: string;
// @Field()
// label!: string;
@Field({ nullable: true })
max?: number;
// @Field({ nullable: true })
// max?: number;
@Field({ nullable: true })
min?: number;
// @Field({ nullable: true })
// min?: number;
@Field({ nullable: true })
hint?: string;
// @Field({ nullable: true })
// hint?: string;
@Field({ nullable: true })
required?: boolean;
// @Field({ nullable: true })
// required?: boolean;
@Field()
env_variable!: string;
}
// @Field()
// env_variable!: string;
// }
@ObjectType()
class App {
@ -69,8 +69,8 @@ class App {
@Field(() => String, { nullable: true })
url_suffix?: string;
@Field(() => [FormField])
form_fields?: FormField[];
// @Field(() => [FormField])
// form_fields?: FormField[];
}
@ObjectType()

View file

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

View file

@ -1,73 +1,44 @@
/* eslint-disable no-unused-vars */
import express, { NextFunction, Request, Response } from 'express';
import compression from 'compression';
import helmet from 'helmet';
import cors from 'cors';
import { isProd } from './constants/constants';
import appsRoutes from './modules/apps/apps.routes';
import systemRoutes from './modules/system/system.routes';
import authRoutes from './modules/auth/auth.routes';
import AuthHelpers from './modules/auth/auth.helpers';
import cookieParser from 'cookie-parser';
import 'reflect-metadata';
import express from 'express';
import { ApolloServerPluginLandingPageGraphQLPlayground as Playground } from 'apollo-server-core';
import config from './config';
import { DataSource } from 'typeorm';
import { ApolloServer } from 'apollo-server-express';
import { createSchema } from './schema';
import { ApolloLogs } from './config/logger/apollo.logger';
import { createServer } from 'http';
import logger from './config/logger/logger';
import getSessionMiddleware from './core/middlewares/sessionMiddleware';
const app = express();
const port = 3001;
const main = async () => {
try {
const app = express();
const port = 3001;
app.use(express.json());
app.use(cookieParser());
const sessionMiddleware = await getSessionMiddleware();
app.use(sessionMiddleware);
if (isProd) {
app.use(compression());
app.use(helmet());
}
const AppDataSource = new DataSource(config.typeorm);
await AppDataSource.initialize();
app.use(
cors({
credentials: true,
origin: function (origin, callback) {
// allow requests with no origin
if (!origin) return callback(null, true);
const schema = await createSchema();
if (config.CLIENT_URLS.indexOf(origin) === -1) {
const message = "The CORS policy for this origin doesn't allow access from the particular origin.";
return callback(new Error(message), false);
}
const httpServer = createServer(app);
return callback(null, true);
},
}),
);
const apolloServer = new ApolloServer({
schema,
plugins: [Playground({ settings: { 'request.credentials': 'include' } }), ApolloLogs],
});
await apolloServer.start();
apolloServer.applyMiddleware({ app });
// Get user from token
app.use((req, _res, next) => {
let user = null;
if (req?.cookies?.tipi_token) {
user = AuthHelpers.tradeTokenForUser(req.cookies.tipi_token);
if (user) req.user = user;
}
next();
});
const restrict = (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
res.status(401).json({ error: 'Unauthorized' });
} else {
next();
httpServer.listen(port, () => {
logger.info(`Server running on port ${port}`);
});
} catch (error) {
console.log(error);
logger.error(error);
}
};
app.use('/auth', authRoutes);
app.use('/system', restrict, systemRoutes);
app.use('/apps', restrict, appsRoutes);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
app.use((err: Error, _req: Request, res: Response, _: NextFunction) => {
res.status(200).json({ error: err.message });
});
app.listen(port, () => {
console.log(`System API listening on port ${port}`);
});
main();

View file

@ -0,0 +1,13 @@
import { Request, Response } from 'express';
import 'express-session';
declare module 'express-session' {
interface SessionData {
userId: number;
}
}
export type MyContext = {
req: Request;
res: Response;
};

View file

@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "es6",
"lib": ["dom", "dom.iterable", "esnext"],
"target": "es2018",
"lib": ["es2018", "esnext.asynciterable"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@ -14,6 +14,7 @@
"isolatedModules": false,
"jsx": "preserve",
"incremental": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.cjs", "jest.config.cjs"],

1123
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff