commit 548a9226e2cc126638603f54b0dcd24d4ecd2003 Author: Markil 3 Date: Sat Aug 29 18:33:14 2026 -0600 Initial commit, based off of the Panblog project. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a921c7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/tmp +/out-tsc + +/node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* +/.pnp +.pnp.js + +.vscode/* +.idea/* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3043dd8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# Builds the required node packages. +FROM node:25 AS builder + +WORKDIR /app + +COPY src/package*.json ./ + +RUN npm install + +FROM node:25 AS production + +WORKDIR /app + +COPY package*.json ./ + +COPY src/* ./ + +# Sets users and permissions. +RUN groupadd --system --gid 1001 sysmailer && \ + useradd --uid 1001 --gid 1001 -M -N --system sysmailer + +COPY --from=builder --chown=sysmailer:sysmailer /app /app + +# De-escelates permissions for improved security. +USER sysmailer + +# Defines the expected outputs. +EXPOSE 6868 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:8080', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })" + +CMD ["npm", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..134294b --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# NodeMailer + +NodeMailer is a server-side application that provides mailing list services. + +## Features +* **User Management**: Keeps track of users and their emails, both internally and over LDAP. +* **Mailing Lists**: Maintain an infinite number of mailing lists. +* **Reply Chains**: Keep track of all emails that are replies to a single original email. +* **Database Free**: All data is stored in the file system and kept in memory, without relying on databases. \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..3fb0f50 --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "NodeMailer", + "version": "1.0.0", + "description": "NodeMailer is a server-side application that provides mailing list services.", + "keywords": [ + "nodemailer", + "mail list", + "mail", + "list", + "http", + "ldap" + ], + "license": "ISC", + "author": "Markil 3", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc" + }, + "devDependencies": { + "@types/bcrypt": "^6.0.0", + "@types/cors": "^2.8.19", + "@types/escape-html": "^1.0.4", + "@types/express": "^5.0.6", + "@types/mime": "^3.0.4", + "@types/multer": "^2.2.0", + "@types/node": "^26.4.0", + "typescript": "^6.0.3" + }, + "dependencies": { + "bcrypt": "^6.0.0", + "cors": "^2.8.6", + "escape-html": "^1.0.3", + "express": "^5.2.1", + "mime": "^4.1.0", + "multer": "^2.3.0", + "node-html-parser": "^9.0.2", + "xmlbuilder2": "^4.0.3" + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..fcd11aa --- /dev/null +++ b/src/config.ts @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2026 Markil 3. All rights reserved. + */ +import fs from 'node:fs/promises'; +import http from 'http'; +import {IncomingMessage} from "node:http"; + +const CONFIG_PATH = "config_path"; +const BASE_URL = "base_url"; +const UPLOAD_PATH = "upload_path"; +const CACHE_PATH = "cache_path"; +const INDEX_PATH = "index_path"; +const TMP_PATH = "tmp_path"; +const TMP_UPLOAD_PATH = "tmp_upload_path"; +const MAX_UPLOAD_SIZE = "max_upload_size"; +const AUTH_KEY = "auth_key"; +const AUTH_KEY_HASH = "auth_key_hash"; + +let config: {[key: string]: any} = {}; +config[CONFIG_PATH] = "/etc/node_mailer/config"; +config[BASE_URL] = ""; +config[UPLOAD_PATH] = "/var/lib/node_mailer/uploads"; +config[CACHE_PATH] = "/var/cache/node_mailer/output"; +config[INDEX_PATH] = "/var/cache/node_mailer/index.json"; +config[TMP_PATH] = "/var/cache/node_mailer/tmp"; +config[TMP_UPLOAD_PATH] = "/var/cache/node_mailer/upload"; +config[MAX_UPLOAD_SIZE] = 10485760; + +/** + * Resolves a configuration value, whether read from a file (if + * it has a "file:" prefix) or a URL (if it has a "url:" prefix). + * + * @param value - A raw value from our configuration. + * @returns A promise that resolves to the value. + */ +function resolveValue(value: any): Promise +{ + let path: string; + if (!value) + { + return value?.trim(); + } + if (value.startsWith("file:")) + { + path = value.substring(5); + return fs.open(path).then((fh) => {try {return fh.readFile({encoding: "utf-8"});} finally {fh.close();}}).then((val) => val?.trim()); + } + else if (value.startsWith("url:")) + { + path = value.substring(4); + return new Promise((resolve, reject) => { + http.get(path, (response: IncomingMessage) => { + if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) + { + response.resume(); + reject(new Error(`HTTP ${response.statusCode} from URL ${path}`)); + return; + } + response.setEncoding("utf8"); + let data = ''; + response.on('data', (chunk) => { data += chunk; }); + response.on('end', () => { resolve(data.trim()) }); + response.on('error', reject); + }).on('error', reject); + }); + } + return Promise.resolve(value); +} + +function getEnv(parameter: string): any|undefined +{ + return process.env["PANBLOG_" + parameter.toUpperCase()]; +} + +/** + * Reads configuration first from the environmental variables, followed by the configuration file. + * @returns A promise that resolves to the configuration object. + */ +async function readConfiguration():Promise<{[key: string]: any}> +{ + let param, value; + if (process.env.PANBLOG_CONFIG_PATH) + { + config[CONFIG_PATH] = await resolveValue(process.env.PANBLOG_CONFIG_PATH); + } + let fh; + try + { + fh = await fs.open(config[CONFIG_PATH]); + let i: number = 0; + for await (const line of fh.readLines()) + { + if (line.indexOf("=") == -1) + { + console.error(`Illegal configuration format, line ${i}`); + continue; + } + param = line.substring(0, line.indexOf('=')).trim(); + value = line.substring(line.indexOf('=') + 1).trim(); + config[param] = await resolveValue(value); + } + } + catch (e) + { + console.error("Could not read configuration file"); + console.error(e); + } + finally + { + fh?.close(); + } + + /* + * Get the values from the environmental variables. + */ + for (const parameter of [BASE_URL, UPLOAD_PATH, CACHE_PATH, INDEX_PATH, TMP_PATH, TMP_UPLOAD_PATH, MAX_UPLOAD_SIZE, AUTH_KEY, AUTH_KEY_HASH]) + { + value = await resolveValue(getEnv(parameter)); + if (value) + { + config[parameter] = await resolveValue(getEnv(parameter)); + } + } + + if (config[MAX_UPLOAD_SIZE]) + { + config[MAX_UPLOAD_SIZE] = parseInt(config[MAX_UPLOAD_SIZE]); + } + + return config; +} + +export { + CONFIG_PATH, + BASE_URL, + UPLOAD_PATH, + CACHE_PATH, + INDEX_PATH, + TMP_PATH, + TMP_UPLOAD_PATH, + MAX_UPLOAD_SIZE, + AUTH_KEY, + AUTH_KEY_HASH, + readConfiguration, + config +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..5771d7c --- /dev/null +++ b/src/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Markil 3. All rights reserved. + */ +import http from 'http'; +import RequestFramework from './requests'; +import {Server} from "node:http"; + +const PORT = 6868; + +function startServer(): Server +{ + let app = new RequestFramework(); + let server = http.createServer(app.app); + server.listen(PORT, () => { + console.log(`Starting blog server at http://localhost:${PORT}/`); + }); + return server; +} + +export { + startServer +} diff --git a/src/mailer_framework.ts b/src/mailer_framework.ts new file mode 100644 index 0000000..cdfa6cc --- /dev/null +++ b/src/mailer_framework.ts @@ -0,0 +1,7 @@ +/* + * Copyright (c) 2026 Markil 3. All rights reserved. + */ + +export { + +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..575a5cf --- /dev/null +++ b/src/main.ts @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026 Markil 3. All rights reserved. + */ +import { mkdir } from 'node:fs/promises'; +import { UPLOAD_PATH, CACHE_PATH, TMP_PATH, readConfiguration, config } from './config'; +import { startServer } from './index'; +import {Server} from "node:http"; + +/* + * Sets up the default directories. + */ +mkdir(config[UPLOAD_PATH], {recursive: true}); +mkdir(config[CACHE_PATH], {recursive: true}); +mkdir(config[TMP_PATH], {recursive: true}); + +let server: Server; + +const shutdown = (signal: string|undefined) => { + console.log(`Received OS ${signal}. Shutting down...`); + + if (server) + { + // Close the HTTP server + server.close(() => { + console.log('HTTP server closed'); + + // Cleanup tasks (e.g., close database connections) + console.log('Cleanup complete. Exiting.'); + process.exit(0); // Exit after cleanup + }); + server.closeAllConnections(); + } +}; + +process.on('SIGINT', () => shutdown('SIGINT')); +process.on('SIGTERM', () => shutdown('SIGTERM')); + + +readConfiguration().then((config) => { + console.info("Read configuration"); + server = startServer(); +}).catch((e) => { + console.error("Error reading configuration."); + console.error(e); + shutdown(undefined); +}); diff --git a/src/requests.ts b/src/requests.ts new file mode 100644 index 0000000..39823b2 --- /dev/null +++ b/src/requests.ts @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026 Markil 3. All rights reserved. + */ +import path from 'node:path'; +import fs from 'node:fs/promises'; +import express, {Express, Request, Response, NextFunction} from 'express'; +import cors from 'cors'; +import multer from 'multer'; +import { MulterError } from 'multer'; +import {BASE_URL, UPLOAD_PATH, CACHE_PATH, TMP_UPLOAD_PATH, MAX_UPLOAD_SIZE, config} from "./config"; +import {isAuthenticated, getMime} from "./utils"; + +class RequestFramework +{ + private static instance: RequestFramework; + private multer: multer.Multer; + app: Express; + + constructor() + { + if (RequestFramework.instance) + { + //return RequestFramework.instance; + } + RequestFramework.instance = this; + + this.multer = multer({ dest: config[TMP_UPLOAD_PATH], preservePath: true, limits: {fileSize: config[MAX_UPLOAD_SIZE]} } ); + + this.app = express(); + this.app.use(express.urlencoded()); + this.app.use(cors()); + + let router = express.Router(); + + router.use((request, response, next) => { + isAuthenticated(request).then((valid: boolean) => { + if (valid) + { + next(); + } + else + { + response.status(401).send('

401 Authorization Error

Not authorized

'); + } + }); + }); + + router.all("/", (request, response, next) => { + response.send("

Now witness the power of this fully armed and operational blog station.

"); + }); + router.get("/ping", this.ping); + router.all("/error", this.error); + + this.app.use(config[BASE_URL], router); + } + + ping(request: Request, response: Response, next: NextFunction) + { + response.send("Pong"); + } + + error(request: Request, response: Response, next: NextFunction) + { + let y: number = 5; + let x = y / 0; + next(); + } +} + +export default RequestFramework; \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..911c3fe --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2026 Markil 3. All rights reserved. + */ +import bcrypt from "bcrypt"; +import {Request} from "express"; +import {config, AUTH_KEY, AUTH_KEY_HASH} from './config.js'; + +/** + * Checks to see if a request is allowed, based on whether it has the appropriate authentication headers. + * @param request - The request to verify. + * @return A promise that resolves to True if the headers are valid, false otherwise. + */ +async function isAuthenticated(request: Request): Promise +{ + if (config[AUTH_KEY_HASH] || config[AUTH_KEY]) + { + let authHeader = request.get('Authorization'), encodedCredentials, credentials; + if (!authHeader) + { + return false; + } + if (authHeader.startsWith("Basic ")) + { + encodedCredentials = authHeader.substring('Basic '.length); + if (config[AUTH_KEY_HASH]) + { + return await bcrypt.compare(encodedCredentials, config[AUTH_KEY_HASH]); + } + else + { + return encodedCredentials == config[AUTH_KEY]; + } + } + else + { + return false; + } + } + else + { + /* + * We do not have authentication keys configured. We can let anything through. + */ + return true; + } +} + +function getMime(filePath: string): string +{ + let pathExt; + if (filePath.lastIndexOf('.') > -1) + { + pathExt = filePath.substring(filePath.lastIndexOf('.')).toLowerCase(); + } + else + { + pathExt = ''; + return "text/plain"; + } + if (pathExt == ".html" || pathExt == ".htm") + { + return "text/html"; + } + if (pathExt == ".json") + { + return "application/json"; + } + else if (pathExt == ".css") + { + return "text/css"; + } + else if (pathExt == ".js") + { + return "text/javascript"; + } + else if (pathExt == ".eot") + { + return "application/vnd.ms-fontobject"; + } + else if (pathExt == ".otf") + { + return "font/otf"; + } + else if (pathExt == ".ttf") + { + return "font/ttf"; + } + else if (pathExt == ".woff") + { + return "font/woff"; + } + else if (pathExt == ".woff2") + { + return "font/woff2"; + } + else if (pathExt == ".png") + { + return "image/png"; + } + else if (pathExt == ".apng") + { + return "image/apng"; + } + else if (pathExt == ".jpg" || pathExt == ".jpeg") + { + return "image/jpeg"; + } + else if (pathExt == ".gif") + { + return "image/gif"; + } + else if (pathExt == ".bmp") + { + return "image/bmp"; + } + else if (pathExt == ".webp") + { + return "image/webp"; + } + else if (pathExt == ".avif") + { + return "image/avif"; + } + else if (pathExt == ".ico") + { + return "image/vnd.microsoft.icon"; + } + else if (pathExt == ".svg") + { + return "image/svg+xml"; + } + else if (pathExt == ".aac") + { + return "audio/aac"; + } + else if (pathExt == ".mp3") + { + return "audio/mpeg"; + } + else if (pathExt == ".ogg" || pathExt == ".oga" || pathExt == ".opus") + { + return "audio/ogg"; + } + else if (pathExt == ".wav") + { + return "audio/wav"; + } + else if (pathExt == ".weba") + { + return "audio/webm"; + } + else if (pathExt == ".avi") + { + return "video/x-msvideo"; + } + else if (pathExt == ".mp4") + { + return "video/mp4"; + } + else if (pathExt == ".mpeg") + { + return "video/mpeg"; + } + else if (pathExt == ".ogv") + { + return "video/ogg"; + } + else if (pathExt == ".webm") + { + return "video/webm"; + } + else if (pathExt == ".md") + { + return "text/markdown"; + } + else if (pathExt == ".azw") + { + return "application/vnd.amazon.ebook"; + } + else if (pathExt == ".epub") + { + return "application/epub+zip"; + } + else if (pathExt == ".doc") + { + return "application/msword"; + } + else if (pathExt == ".docx") + { + return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + } + else if (pathExt == ".odt") + { + return "application/vnd.oasis.opendocument.text"; + } + else if (pathExt == ".pdf") + { + return "application/pdf"; + } + else if (pathExt == ".rtf") + { + return "application/rtf"; + } + else + { + return "text/plain"; + } +} + +export { + isAuthenticated, + getMime +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..03e6ea3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "es2016", + "module": "commonjs", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "dist", + "types": ["node", "express", "cors", "multer", "bcrypt", "escape-html", "mime"] + }, + "include": ["src"], +}