// 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} */ 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} */ 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(`ERR: ${err.message}

500: Internal Server Error

${err.stack.replaceAll('\n', "
")}

`); }); app.listen(config.port, () => { console.log(`Server listening on port ${config.port}`); });