ht-web-framework/index.js
2025-04-24 15:10:31 +00:00

350 lines
14 KiB
JavaScript

// HT-Web Server (possibly the new Web Framework)
import fs from 'fs';
import express from 'express';
import superagent from 'superagent';
import { containsCidr } from 'cidr-tools';
import { join, resolve, dirname } from 'path';
import { parse } from "jsonc-parser";
import morgan from 'morgan';
import { fileURLToPath } from 'url';
import { readFile } from 'fs/promises';
import { mustache } from "consolidate";
import { renderFile, render as ejsRender } from 'ejs';
import { engine as handlebars, create } from 'express-handlebars';
import NodeCache from 'node-cache';
import config from './config.json' with {
type: "json"
};
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// allow comments in pages file
const pages = parse(fs.readFileSync(config.webconfig).toString());
const cache = new NodeCache({
stdTTL: 3600,
useClones: false
});
const app = express();
app.use(morgan('dev')); // development and console logging
if (config.logging) {
// Ensure log file directory exists
const logDir = dirname(join(process.cwd(), config.logFile));
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
fs.appendFileSync(join(process.cwd(), config.logfile), `Server started at ${new Date().toUTCString()}\n`);
app.use(morgan('combined', {
stream: fs.createWriteStream(join(process.cwd(), config.logfile), { flags: 'a' })
})); // production
process.once('SIGINT', () => {
fs.appendFileSync(join(process.cwd(), config.logfile), `Server stopped at ${new Date().toUTCString()}\n`);
process.exit();
});
}
app.use((req, res, next) => {
res.setHeader('Server', "HT Web-Framework V2");
res.setHeader('X-Powered-By', "HT Web-Framework V2");
next();
});
app.use((req, res, next) => {
// Enable for choosing framing options, not migrated yet
// if (config.management.embedSite) {
// res.setHeader('X-Frame-Options', 'ALLOW-FROM ' + config.management.embedSite);
// res.setHeader('Content-Security-Policy', 'frame-ancestors ' + config.management.embedSite);
// } else {
// res.setHeader('X-Frame-Options', 'DENY');
// res.setHeader('Content-Security-Policy', 'frame-ancestors \'none\'');
// }
res.setHeader('X-Content-Type-Options', 'nosniff');
// res.setHeader('Content-Security-Policy', 'default-src \'self\'');
res.setHeader('Referrer-Policy', 'same-origin');
res.setHeader('Feature-Policy', 'geolocation \'none\'; microphone \'none\'; camera \'none\'; speaker \'none\'; vibrate \'none\'; payment \'none\'; usb \'none\';');
res.setHeader('X-Permitted-Cross-Domain-Policies', 'none');
next();
});
// real IP assignment
app.use(async (req, res, next) => {
let ip = "0.0.0.0/0";
if ("x-forwarded-for" in req.headers) {
if (containsCidr(["127.0.0.1", "::1", ...config.trustedProxies], req.ip)) {
ip = req.headers['x-forwarded-for'] || req.ip;
} else {
console.warn("Proxy IP not in list:", req.ip);
return res.sendStatus(403);
}
} else if ("cf-connecting-ip" in req.headers){
if (!cache.has("cfcidrList")) {
cache.set("cfcidrList", (await superagent.get("https://api.cloudflare.com/client/v4/ips")).body);
}
const cfcidrList = cache.get("cfcidrList");
if (!cfcidrList.success) {
return next(cfcidrList.errors.join(", "));
}
if (containsCidr([...cfcidrList.result.ipv4_cidrs, ...cfcidrList.result.ipv6_cidrs], req.ip)) {
ip = req.headers['cf-connecting-ip'] || req.ip;
} else {
console.warn("CF IP not in list:", req.ip);
return res.sendStatus(403);
}
} else {
ip = req.ip; // Do nothing
}
req.realIp = ip;
next();
});
app.set('views', join(__dirname, 'views'));
app.engine('mustache', mustache);
app.engine('ejs', renderFile);
app.engine('handlebars', handlebars());
app.set('view engine', pages.settings.defaultType);
const extension = "." + pages.settings.defaultType;
const hbs = create({
extname: ".handlebars",
// defaultLayout: 'base.mustache',
defaultLayout: false,
layoutsDir: join(__dirname, "private", "templates"),
partialsDir: join(__dirname, "private", "templates"),
});
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use("/static", express.static(join(process.cwd(), "public")));
/**
* Send a page to the client
* @param {import('express').Request} req Express Request
* @param {import('express').Response} res Express Response
* @param {string} view Name of the view/file to render
* @param {object} context context to pass to the template, can be modified
* @param {Function} [cb] (optional) callback
* @returns {Promise<void>}
*/
async function render(req, res, view, context, cb) {
// Inject Servers
context.pages = [...pages.navpages];
// Active Detection
context.pages.forEach(element => {
element.active = req.url == element.url;
});
if (context.type == "html") {
console.debug("Rendering HTML");
res.sendFile(join(viewsDir, view + ".html"), { headers: { "Content-Type": "text/html" } });
return;
} else if (context.type == "ejs") {
console.debug("Rendering EJS");
const ejsResult = await renderFile(join(viewsDir, view + ".ejs"), context);
partials['view'] = join(partialsDir, 'ejs.mustache');
res.render(partials['base'], { ...context, partials: partials, ejs: ejsResult }, cb);
return;
} else if (context.type == "handlebars") {
console.debug("Rendering Handlebars");
const handlebarsResult = await hbs.renderView(join(viewsDir, view + ".handlebars"), context);
partials['view'] = join(partialsDir, 'handlebars.mustache');
res.render(partials['base'], { ...context, partials: partials, handlebars: handlebarsResult }, cb);
return;
} else if (context.type == "mustache") {
// Add current view
partials['view'] = join(viewsDir, view + ".mustache");
res.render(partials['base'], { ...context, partials: partials }, cb);
} else {
throw new Error("Unknown template type: " + context.type);
}
}
/**
* check for site access here, if allowed, call {@link render()}
* @param {import('express').Request} req Express Request
* @param {import('express').Response} res Express Response
* @param {string} view Name of the view/file to render
* @param {object} context context to pass to the template, can be modified
* @param {string[]} perms Array of permissions that need to be fulfilled by the user
* @param {Function} [cb] (optional) callback
* @returns {Promise<void>}
*/
async function renderRestricted(req, res, view, context, perms, cb) {
res.sendStatus(500);
throw new Error("Not implemented yet");
}
function isJSON(str) {
try {
return JSON.stringify(str) && !!str;
} catch (e) {
return false;
}
}
// Read partial templates
const partialsDir = join(__dirname, "private", "templates");
const viewsDir = join(__dirname, "private", "views");
// Map Partials
const partials = {}
fs.readdirSync(partialsDir).map(part => partials[part.replace(extension, '')] = join(partialsDir, part));
// Generate Routes
for (const path in pages.paths.get) {
if (Object.hasOwn(pages.paths.get, path)) {
const element = pages.paths.get[path];
if (element.file) {
app.get(path, async (req, res, next) => {
let patches = {
type: element.type ?? pages.settings.defaultType,
}
if (element.scripts) {
try {
for await (const file of element.scripts) {
const module = await import("file://" + resolve(join("private", "scripts", file + ".js")));
let result = module.get ? await module.get(element, { req, res, next }, config) : await module.default(element, { req, res, next }, config);
if (isJSON(result) && !(result?.done ?? false)) {
patches = { ...patches, ...result }
} else {
console.debug("Request already handled, returning...")
return;
}
}
} catch (error) {
return next(error);
}
}
if (element.settings && element.settings.subtitle) {
patches.title = element.settings.subtitle + " | " + settings.title;
}
const context = { ...settings, ...element.settings, ...patches };
if (element.restriction) {
renderRestricted(req, res, element.file, context, element.restriction);
} else {
// TODO: add way to handle the script already handling requests and then skip this
try {
await render(req, res, element.file, context);
} catch (error) {
// TODO: check why errors are just ignored
console.debug(error);
// return next(error);
}
}
})
} else {
app.get(path, async (req, res, next) => {
if (element.scripts) {
try {
for await (const file of element.scripts) {
const module = await import("file://" + resolve(join('web', 'scripts', file + ".js")));
module.get ? await module.get(element, { req, res, next }, config) : await module.default(element, { req, res, next }, config);
}
} catch (error) {
return next(error);
}
} else {
next();
}
})
}
}
}
for (const path in pages.paths.post) {
if (Object.hasOwn(pages.paths.post, path)) {
const element = pages.paths.post[path];
if (element.file) {
app.post(path, async (req, res, next) => {
let patches = {
type: element.type ?? pages.settings.defaultType,
}
if (element.scripts) {
try {
for await (const file of element.scripts) {
const module = await import("file://" + resolve(join('web', 'scripts', file + ".js")));
let result = module.post ? await module.post(element, { req, res, next }, config) : await module.default(element, { req, res, next }, config);
if (isJSON(result) && !(result?.done ?? false)) {
patches = { ...patches, ...result }
} else {
console.debug("Request already handled, returning...")
return;
}
}
} catch (error) {
return next(error);
}
}
if (element.settings && element.settings.subtitle) {
patches.title = element.settings.subtitle + " | " + settings.title;
}
const context = { ...settings, ...element.settings, ...patches };
if (element.restriction) {
renderRestricted(req, res, element.file, context, element.restriction);
} else {
// TODO: add way to handle the script already handling requests and then skip this
try {
await render(req, res, element.file, context);
} catch (error) {
// TODO: check why errors are just ignored
console.debug(error);
// return next(error);
}
}
})
} else {
app.post(path, async (req, res, next) => {
if (element.scripts) {
try {
for await (const file of element.scripts) {
const module = await import("file://" + resolve(join('web', 'scripts', file + ".js")));
module.post ? await module.post(element, { req, res, next }, config) : await module.default(element, { req, res, next }, config);
}
} catch (error) {
return next(error);
}
} else {
next();
}
})
}
}
}
let settings = {
...pages.settings,
pages: [...pages.navpages],
};
app.get('/', async (req, res) => {
try {
const currentConfigFile = await readFile(join(process.cwd(), 'config.json'), 'utf-8');
const currentConfig = JSON.parse(currentConfigFile);
res.json(currentConfig);
} catch (error) {
console.error('Error reading config file:', error);
res.status(500).json({ error: 'Failed to read configuration' });
}
});
// Error handling middleware
app.use((err, req, res, next) => {
console.error('Error:', err);
// res.status(500).json({
// error: 'Internal Server Error',
// message: err.message,
// stack: err.stack
// });
res.status(500).send(`<title>ERR: ${err.message}</title><h1>500: Internal Server Error</h1><p>${err.stack.replaceAll('\n', "<br />")}</p>`);
});
app.listen(config.port, () => {
console.log(`Server listening on port ${config.port}`);
});