Initial commit, based off of the Panblog project.
This commit is contained in:
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal 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
33
Dockerfile
Normal 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
9
README.md
Normal 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
40
package.json
Normal 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
146
src/config.ts
Normal 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
22
src/index.ts
Normal 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
7
src/mailer_framework.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Markil 3. All rights reserved.
|
||||
*/
|
||||
|
||||
export {
|
||||
|
||||
}
|
||||
46
src/main.ts
Normal file
46
src/main.ts
Normal 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
70
src/requests.ts
Normal 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
213
src/utils.ts
Normal 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
13
tsconfig.json
Normal 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"],
|
||||
}
|
||||
Reference in New Issue
Block a user