Initial commit

This commit is contained in:
2025-02-19 00:34:19 +01:00
commit d75e5b1f11
44 changed files with 5970 additions and 0 deletions

18
src/handlers/config.ts Normal file
View File

@ -0,0 +1,18 @@
import ConfigManager from '../libs/configManager.js';
import __path from './path.js';
import _ from 'lodash';
import log from './log.js';
// Create a new config instance.
const config = new ConfigManager(__path + '/config.json', true, {
db_connection_string: 'mysql://USER:PASSWORD@HOST:3306/DATABASE',
http_listen_address: '0.0.0.0',
http_port: 3000,
http_domain: 'example.org',
http_enable_hsts: false,
devmode: true
});
!config.global.devmode && log.core.error('devmode active! Do NOT use this in prod!');
export default config;

40
src/handlers/db.ts Normal file
View File

@ -0,0 +1,40 @@
import { PrismaClient, Prisma } from '@prisma/client'; // Database
import { Response } from 'express';
import config from './config.js';
import log from './log.js';
// TODO: Add errorhandling with some sort of message.
const prisma = new PrismaClient({
datasources: {
db: {
url: config.global.db_connection_string
}
}
});
// FIXME: any
export function handlePrismaError(errorObj: any, res: Response, source: string) {
log.db.error(source, errorObj);
if (errorObj instanceof Prisma.PrismaClientKnownRequestError) {
switch (errorObj.code) {
// P2002 -> "Unique constraint failed on the {constraint}"
case 'P2002':
res.status(409).json({ status: 'ERROR', errorcode: 'DB_ERROR', message: 'The object needs to be unique', meta: errorObj.meta });
break;
// P2003 -> "Foreign key constraint failed on the field: {field_name}"
case 'P2003':
res.status(404).json({ status: 'ERROR', errorcode: 'DB_ERROR', message: 'Relation object does not exist', meta: errorObj.meta });
break;
default:
res.status(500).json({ status: 'ERROR', errorcode: 'DB_ERROR', message: 'An error occurred during the database operation' });
break;
}
} else {
res.status(500).json({ status: 'ERROR', errorcode: 'DB_ERROR', message: 'If you can read this something went terribly wrong!' });
}
}
export default prisma;

52
src/handlers/log.ts Normal file
View File

@ -0,0 +1,52 @@
import { Logger,ISettingsParam } from "tslog";
function loggerConfig(name: string): ISettingsParam<unknown> {
return {
type: "pretty", // pretty, json, hidden
name: name,
hideLogPositionForProduction: true,
prettyLogTemplate: "{{dateIsoStr}} {{logLevelName}} {{nameWithDelimiterPrefix}} "
}
}
type log = {
core: Logger<unknown>
db: Logger<unknown>
web: Logger<unknown>
S3: Logger<unknown>
auth: Logger<unknown>
api?: Logger<unknown>
frontend?: Logger<unknown>
};
// FIXME: any type
let log: log = {
core: new Logger(loggerConfig("Core")),
db: new Logger(loggerConfig("DB")),
web: new Logger(loggerConfig("Web")),
S3: new Logger(loggerConfig("S3")),
auth: new Logger(loggerConfig("Auth")),
// helper: new Logger(loggerConfig("HELPER")),
};
log["api"] = log.web.getSubLogger({ name: "API" });
log["frontend"] = log.web.getSubLogger({ name: "Frontend" });
// log.core.silly("Hello from core");
//log.api.trace("Hello from api");
//log.frontend.trace("Hello from frontend");
// log.core.debug("Hello from core");
// log.core.info("Hello from core");
// log.core.warn("Hello from core");
// log.core.error("Hello from core");
// log.db.silly("Hello from db");
// log.db.trace("Hello from db");
// log.web.debug("Hello from db");
// log.auth.info("Hello from db");
// log.helper.warn("Hello from db");
// log.db.error("Hello from db");
// log.core.fatal(new Error("I am a pretty Error with a stacktrace."));
export default log;

4
src/handlers/path.ts Normal file
View File

@ -0,0 +1,4 @@
// Return the app directory as an absolute path
const __path = process.argv[1];
export default __path;

View File

@ -0,0 +1,22 @@
/**
* A function to create a sortBy compatible object from a string
*
* @export
* @param {string} SortField
* @param {string} Order
* @returns {object}
*/
export function parseDynamicSortBy(SortField: string, Order: string) {
return JSON.parse(`{ "${SortField}": "${Order}" }`);
}
/**
* Function to parse a string into a number or return undefined if it is not a number
* Deprecated since all empty strings in bodys are now undefined. This happens in api/v1 router
* @export
* @param {string || any} data
* @returns {object}
*/
export function parseIntOrUndefined(data: any) {
return isNaN(parseInt(data)) ? undefined : parseInt(data);
}

109
src/index.ts Normal file
View File

@ -0,0 +1,109 @@
// MARK: Imports
import path from 'node:path';
import __path from './handlers/path.js';
import log from './handlers/log.js';
import db from './handlers/db.js';
import config from './handlers/config.js';
// Express & more
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import session from 'express-session';
import fileUpload from 'express-fileupload';
import bodyParser, { Options } from 'body-parser';
import { Eta } from 'eta';
import passport from 'passport';
import ChildProcess from 'child_process';
import routes from './routes/index.js';
import fs from 'node:fs';
log.core.trace('Running from path: ' + __path);
// MARK: Express
const app = express();
// Versioning
try {
const rawPkg = fs.readFileSync('package.json', 'utf8');
const pkgJson = JSON.parse(rawPkg);
app.locals.version = pkgJson.version;
} catch (error) {
log.core.error('Failed to get version from package.json.');
app.locals.version = '0.0.0';
}
try {
app.locals.versionRevLong = ChildProcess.execSync('git rev-parse HEAD').toString().trim();
app.locals.versionRev = app.locals.versionRevLong.substring(0, 7);
} catch (error) {
log.core.error('Failed to get git revision hash.');
app.locals.versionRev = '0';
app.locals.versionRevLong = '0';
}
try {
app.locals.versionRevLatest = ChildProcess.execSync('git ls-remote --refs -q').toString().trim().split('\t')[0];
} catch (error) {
log.core.error('Failed to get latest git revision hash.');
app.locals.versionRevLatest = '0';
}
app.locals.versionUpdateAvailable = false;
if (app.locals.versionRevLong === app.locals.versionRevLatest) {
log.core.info(`Running Latest Version (${app.locals.versionRevLong}; ${app.locals.version})`);
} else {
log.core.info(`Running Version: ${app.locals.versionRevLong}; ${app.locals.version} (Latest: ${app.locals.versionRevLatest})`);
app.locals.versionUpdateAvailable = true;
}
// ETA Init
const eta = new Eta({ views: path.join(__path, 'views') });
app.engine('eta', buildEtaEngine());
app.set('view engine', 'eta');
// MARK: Express Middleware & Config
app.set('x-powered-by', false); // helmet does this too. But not in devmode
if (!config.global.devmode) {
app.use(
helmet({
strictTransportSecurity: config.global.http_enable_hsts,
contentSecurityPolicy: {
useDefaults: false,
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", config.global.http_domain],
objectSrc: ["'none'"],
upgradeInsecureRequests: config.global.devmode ? null : []
}
}
})
); // Add headers
}
app.use(fileUpload());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
app.use(routes);
// TODO: Remove hardcoded http
app.listen(config.global.http_port, config.global.http_listen_address, () => {
log.web.info(`Listening at http://${config.global.http_listen_address}:${config.global.http_port}`);
});
// MARK: Helper Functions
function buildEtaEngine() {
return (path: string, opts: Options, callback: CallableFunction) => {
try {
const fileContent = eta.readFile(path);
const renderedTemplate = eta.renderString(fileContent, opts);
callback(null, renderedTemplate);
} catch (error) {
callback(error);
}
};
}

140
src/libs/configManager.ts Normal file
View File

@ -0,0 +1,140 @@
import fs from 'node:fs';
import _ from 'lodash';
import { randomUUID, randomBytes } from 'crypto';
export type configObject = Record<any, any>;
/**
* This class is responsible to save/edit config files.
*
* @export
* @class config
* @typedef {config}
*/
export default class config {
#configPath: string;
//global = {[key: string] : string}
global: configObject;
replaceSecrets: boolean;
/**
* Creates an instance of config.
*
* @constructor
* @param {string} configPath Path to config file.
* @param {object} configPreset Default config object with default values.
*/
constructor(configPath: string, replaceSecrets: boolean, configPreset: object) {
this.#configPath = configPath;
this.global = configPreset;
this.replaceSecrets = replaceSecrets;
try {
// Read config
const data = fs.readFileSync(this.#configPath, 'utf8');
// Extend config with missing parameters from configPreset.
this.global = _.defaultsDeep(JSON.parse(data), this.global);
// Save config.
this.save_config();
} catch (err: any) {
// If file does not exist, create it.
if (err.code === 'ENOENT') {
console.log(`Config file does not exist. Creating it at ${this.#configPath} now.`);
this.save_config();
return;
}
console.error(`Could not read config file at ${this.#configPath} due to: ${err}`);
// Exit process.
process.exit(1);
}
}
/**
* Saves the jsonified config object to the config file.
*/
save_config() {
try {
// If enabled replace tokens defines as "gen" with random token
if (this.replaceSecrets) {
// Replace tokens with value "gen"
this.generate_secrets(this.global, 'gen')
}
fs.writeFileSync(this.#configPath, JSON.stringify(this.global, null, 8));
} catch (err) {
console.error(`Could not write config file at ${this.#configPath} due to: ${err}`);
return;
}
console.log(`Successfully written config file to ${this.#configPath}`);
}
/**
* Replaces each item matching the value of placeholder with a random UUID.
* Thanks to https://stackoverflow.com/questions/8085004/iterate-through-nested-javascript-objects
* @param {configObject} obj
*/
generate_secrets(obj: configObject, placeholder: string) {
const stack = [obj];
while (stack?.length > 0) {
const currentObj:any = stack.pop();
Object.keys(currentObj).forEach((key) => {
if (currentObj[key] === placeholder) {
console.log('Generating secret: ' + key);
currentObj[key] = randomBytes(48).toString('base64').replace(/\W/g, '');
}
if (typeof currentObj[key] === 'object' && currentObj[key] !== null) {
stack.push(currentObj[key]);
}
});
}
}
}
/*
**** Example ****
import ConfigHandlerNG from './assets/configHandlerNG.js';
// Create a new config instance.
export const config = new ConfigHandler(__path + '/config.json', true, {
test1: 't1',
test2: 't2',
test3: 'gen',
test4: 't4',
test5: 'gen',
testObj: {
local: {
active: true,
users: {
user1: 'gen',
user2: 'gen',
user3: 'gen',
user4: 'gen',
}
},
oidc: {
active: false
}
}
});
console.log('Base Config:');
console.log(config.global);
console.log('Add some new key to config and call save_config().');
config.global.NewKey = 'ThisIsANewKey!'
config.save_config()
console.log('This will add a new key with value gen, but gen gets replaced with a random UUID when save_config() is called.');
config.global.someSecret = 'gen'
config.save_config() // global.someSecret is getting replaced with some random UUID since it was set to 'gen'.
console.log('Complete Config:');
console.log(config.global);
*/

11
src/routes/api/index.ts Normal file
View File

@ -0,0 +1,11 @@
import express from 'express';
// Route imports
import v1_routes from './v1/index.js';
// Router base is '/api'
const Router = express.Router({ strict: false });
Router.use('/v1', v1_routes);
export default Router;

View File

@ -0,0 +1,51 @@
import express from 'express';
import passport from 'passport';
// Route imports
import testRoute from './test.js';
import versionRoute from './version.js'
import userRoute from './user.js';
import userRoute_schema from './user_schema.js';
// import content_route from './content.js';
// import content_schema from './content_schema.js';
// import * as content_s3_sub_route from './content_s3_sub.js';
// import * as content_s3_sub_schema from './content_s3_sub_schema.js';
// Router base is '/api/v1'
const Router = express.Router({ strict: false });
// All empty strings are undefined (not null!) values (body)
Router.use('*', function (req, res, next) {
for (let key in req.body) {
if (req.body[key] === '') {
req.body[key] = undefined;
}
}
next();
});
// All api routes lowercase! Yea I know but when strict: true it matters.
Router.route('/user').get(userRoute.get).post(userRoute.post).patch(userRoute.patch).delete(userRoute.del);
Router.route('/user/describe').get(userRoute_schema);
// Router.route('/content').get(content_route.get).post(content_route.post).patch(content_route.patch).delete(content_route.del);
// Router.route('/content/describe').get(content_schema);
// Router.route('/content/downloadurl').get(content_s3_sub_route.get_downloadurl);
// Router.route('/content/uploadurl').get(content_s3_sub_route.get_uploadurl);
// Router.route('/content/downloadurl/describe').get(content_s3_sub_schema.get_describe_downloadurl);
// Router.route('/content/uploadurl/describe').get(content_s3_sub_schema.get_describe_uploadurl);
Router.route('/version').get(versionRoute.get);
//Router.use('/search', search_routes);
Router.route('/test').get(testRoute.get);
export default Router;

View File

@ -0,0 +1,7 @@
import express, { Request, Response } from 'express';
function get(req: Request, res: Response) {
res.status(200).send('API v1 Test successful!');
};
export default { get };

165
src/routes/api/v1/user.ts Normal file
View File

@ -0,0 +1,165 @@
import { Request, Response } from 'express';
import db, { handlePrismaError } from '../../../handlers/db.js'; // Database
import log from '../../../handlers/log.js';
import { parseDynamicSortBy } from '../../../helpers/prisma_helpers.js';
import { schema_get, schema_post, schema_patch, schema_del } from './user_schema.js';
// MARK: GET user
async function get(req: Request, res: Response) {
const { error, value } = schema_get.validate(req.query);
if (error) {
log.api?.debug('GET user Error:', req.query, value, error.details[0].message);
res.status(400).json({ status: 'ERROR', errorcode: 'VALIDATION_ERROR', message: error.details[0].message });
} else {
log.api?.debug('GET user Success:', req.query, value);
if (value.search !== undefined || value.id !== undefined) {
// if search or get by id
await db
.$transaction([
// Same query for count and findMany
db.user.count({
where: {
OR: [{ id: value.id }, { name: { search: value.search } }]
},
orderBy: parseDynamicSortBy(value.sort.toString(), value.order.toString()),
skip: value.skip,
take: value.take
}),
db.user.findMany({
where: {
OR: [{ id: value.id }, { name: { search: value.search } }]
},
orderBy: parseDynamicSortBy(value.sort.toString(), value.order.toString()),
skip: value.skip,
take: value.take
})
])
.then(([count, result]) => {
if (result.length !== 0) {
result.forEach((element: { id: number; name: string; code: string | null | boolean }) => {
// code-> true if code is set
element.code = element.code !== null;
});
res.status(200).json({ count, result });
} else {
res.status(404).json({ status: 'ERROR', errorcode: 'NOT_FOUND', message: 'Could not find specified object' });
}
})
.catch((err) => {
handlePrismaError(err, res, 'GET user');
});
} else {
// get all
await db
.$transaction([
// Same query for count and findMany
db.user.count({
orderBy: parseDynamicSortBy(value.sort.toString(), value.order.toString()),
skip: value.skip,
take: value.take
}),
db.user.findMany({
orderBy: parseDynamicSortBy(value.sort.toString(), value.order.toString()),
skip: value.skip,
take: value.take
})
])
.then(([count, result]) => {
if (result.length !== 0) {
result.forEach((element: { id: number; name: string; code: string | null | boolean }) => {
// code-> true if code is set
element.code = element.code !== null;
});
res.status(200).json({ count, result });
} else {
res.status(404).json({ status: 'ERROR', errorcode: 'NOT_FOUND', message: 'Could not find specified object' });
}
})
.catch((err) => {
handlePrismaError(err, res, 'GET user');
});
}
}
}
// MARK: CREATE user
async function post(req: Request, res: Response) {
const { error, value } = schema_post.validate(req.body);
if (error) {
log.api?.debug('POST user Error:', req.body, value, error.details[0].message);
res.status(400).json({ status: 'ERROR', errorcode: 'VALIDATION_ERROR', message: error.details[0].message });
} else {
log.api?.debug('POST user Success:', req.body, value);
await db.user
.create({
data: {
name: value.name,
code: value.code
},
select: {
id: true
}
})
.then((result) => {
res.status(201).json({ status: 'CREATED', message: 'Successfully created user', id: result.id });
})
.catch((err) => {
handlePrismaError(err, res, 'POST user');
});
}
}
// MARK: UPDATE user
async function patch(req: Request, res: Response) {
const { error, value } = schema_patch.validate(req.body);
if (error) {
log.api?.debug('PATCH user Error:', req.body, value, error.details[0].message);
res.status(400).json({ status: 'ERROR', errorcode: 'VALIDATION_ERROR', message: error.details[0].message });
} else {
log.api?.debug('PATCH user Success:', req.body, value);
await db.user
.update({
where: {
id: value.id
},
data: {
name: value.name,
code: value.code
},
select: {
id: true
}
})
.then((result) => {
res.status(200).json({ status: 'UPDATED', message: 'Successfully updated user', id: result.id });
})
.catch((err) => {
handlePrismaError(err, res, 'PATCH user');
});
}
}
// MARK: DELETE user
async function del(req: Request, res: Response) {
const { error, value } = schema_del.validate(req.body);
if (error) {
log.api?.debug('DEL user Error:', req.body, value, error.details[0].message);
res.status(400).json({ status: 'ERROR', errorcode: 'VALIDATION_ERROR', message: error.details[0].message });
} else {
log.api?.debug('DEL user Success:', req.body, value);
await db.user
.delete({
where: {
id: value.id
}
})
.then((result) => {
res.status(200).json({ status: 'DELETED', message: 'Successfully deleted user', id: result.id });
}).catch((err) => {
handlePrismaError(err, res, 'DEL user');
});
}
}
export default { get, post, patch, del };

View File

@ -0,0 +1,58 @@
import { Request, Response } from 'express';
import validator from 'joi'; // DOCS: https://joi.dev/api
import { Prisma } from '@prisma/client';
// MARK: GET user
const schema_get = validator
.object({
sort: validator
.string()
.valid(...Object.keys(Prisma.UserScalarFieldEnum))
.default('id'),
order: validator.string().valid('asc', 'desc').default('asc'),
take: validator.number().min(1).max(512),
skip: validator.number().min(0),
// This regex ensures that the search string does not contain consecutive asterisks (**) and is at least 3 characters long.
search: validator.string().min(3).max(20).regex(new RegExp('^(?!.*\\*{2,}).*$')),
id: validator.number().positive().precision(0)
})
.nand('id', 'search'); // Allow id or search. not both.
// MARK: CREATE alertContact
const schema_post = validator.object({
name: validator.string().min(1).max(32).required(),
code: validator.string().min(4).max(4).trim().regex(new RegExp('/^[0-9]+$/'))
});
// MARK: UPDATE alertContact
const schema_patch = validator
.object({
id: validator.number().positive().precision(0).required(),
name: validator.string().min(1).max(32),
code: validator.string().min(4).max(4).trim().regex(new RegExp('/^[0-9]+$/'))
})
.or('name', 'code');
// MARK: DELETE alertContact
const schema_del = validator.object({
id: validator.number().positive().precision(0).required()
});
// Describe all schemas
const schema_get_desc = schema_get.describe();
const schema_post_desc = schema_post.describe();
const schema_patch_desc = schema_patch.describe();
const schema_del_desc = schema_del.describe();
// GET route
export default async function get(req: Request, res: Response) {
res.status(200).json({
GET: schema_get_desc,
POST: schema_post_desc,
PATCH: schema_patch_desc,
DELETE: schema_del_desc
});
}
export { schema_get, schema_post, schema_patch, schema_del };

View File

@ -0,0 +1,9 @@
import { Request, Response } from 'express';
function get(req: Request, res: Response) {
res.status(200).send({ version: '1.0.0', commit: req.app.locals.versionRev, updateAvailable: req.app.locals.versionUpdateAvailable });
};
export default { get };
// TODO: FIXME!!!!!!

View File

@ -0,0 +1,7 @@
import express, { Request, Response } from 'express';
function get(req: Request, res: Response) {
res.render("contacts")
}
export default { get };

View File

@ -0,0 +1,7 @@
import express, { Request, Response } from 'express';
function get(req: Request, res: Response) {
res.render("lockscreen", { message: "Hello world from eta!" })
}
export default { get };

View File

@ -0,0 +1,25 @@
import express from 'express';
// Route imports
import dashboardRoute from './dashboard.js';
import testRoute from './test.js';
import contactRoute from './contact.js';
// import itemsRoute from './items.js';
// import manage_routes from './manage/index.js';
// Router base is '/'
const Router = express.Router({ strict: false });
// Router.route('/test').get(testRoute.get);
// Router.route('/items').get(itemsRoute.get);
// Router.route('/:id(\\w{8})').get(skuRoute.get);
// Router.route('/s/:id').get(skuRouteDash.get);
// Router.use('/manage', manage_routes);
Router.route('/').get(dashboardRoute.get);
Router.route('/dbTest').get(testRoute.get);
Router.route('/contact').get(contactRoute.get);
export default Router;

View File

@ -0,0 +1,7 @@
import express, { Request, Response } from 'express';
function get(req: Request, res: Response) {
res.render("test", { message: "Hello world from eta!" })
}
export default { get };

34
src/routes/index.ts Normal file
View File

@ -0,0 +1,34 @@
import express from 'express';
import path from 'node:path';
import __path from "../handlers/path.js";
import log from "../handlers/log.js";
// Route imports
import frontend_routes from './frontend/index.js';
import api_routes from './api/index.js';
const Router = express.Router({ strict: false });
// static / libs routes
Router.use('/static', express.static(__path + '/static'));
Router.use('/libs/bulma', express.static(path.join(__path, 'node_modules', 'bulma', 'css'))); // http://192.168.221.10:3000/libs/bulma/bulma.css
Router.use('/libs/jquery', express.static(path.join(__path, 'node_modules', 'jquery', 'dist')));
Router.use('/libs/bootstrap-icons', express.static(path.join(__path, 'node_modules', 'bootstrap-icons')));
// Other routers
Router.use('/api', api_routes);
Router.use('/', frontend_routes);
// Default route.
Router.all('*', function (req, res) {
// TODO: Respond based on content-type (with req.is('application/json'))
if (req.is('application/json')) {
res.status(404).json({ status: 'ERROR', errorcode: 'NOT_FOUND', message: 'Could not find specified page' });
} else {
res.status(404).render('errors/404', { url: req.originalUrl });
}
});
export default Router;