Initial commit, based off of the Panblog project.

This commit is contained in:
2026-08-29 18:33:14 -06:00
commit 548a9226e2
11 changed files with 611 additions and 0 deletions

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
/tmp
/out-tsc
/node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
/.pnp
.pnp.js
.vscode/*
.idea/*

33
Dockerfile Normal file
View File

@@ -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"]

9
README.md Normal file
View File

@@ -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.

40
package.json Normal file
View File

@@ -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"
}
}

146
src/config.ts Normal file
View File

@@ -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<any|undefined>
{
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
}

22
src/index.ts Normal file
View File

@@ -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
}

7
src/mailer_framework.ts Normal file
View File

@@ -0,0 +1,7 @@
/*
* Copyright (c) 2026 Markil 3. All rights reserved.
*/
export {
}

46
src/main.ts Normal file
View File

@@ -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);
});

70
src/requests.ts Normal file
View File

@@ -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('<!DOCTYPE html><html><body><h1>401 Authorization Error</h1><p>Not authorized</p></body></html>');
}
});
});
router.all("/", (request, response, next) => {
response.send("<!DOCTYPE html><html><body><p>Now witness the power of this fully armed and operational blog station.</p></body></html>");
});
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;

213
src/utils.ts Normal file
View File

@@ -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<boolean>
{
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
};

13
tsconfig.json Normal file
View File

@@ -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"],
}