Moves the PanBlog code into its own repository.

This commit is contained in:
2026-08-24 09:53:16 -06:00
commit 8af2ff13d6
10 changed files with 2528 additions and 0 deletions

49
Dockerfile Normal file
View File

@@ -0,0 +1,49 @@
# Builds the required node packages.
FROM node:25 AS builder
WORKDIR /app
COPY src/package*.json ./
RUN npm install
# Builds the Pandoc application.
FROM pandoc/minimal:3.8 AS pandoc
FROM node:25 AS production
# Copies the output from the two building stages above.
COPY --from=pandoc --chmod=555 /usr/local/bin/pandoc /usr/local/bin/pandoc
COPY --from=pandoc --chmod=555 /usr/local/bin/pandoc /usr/local/share/
# Prepares the required folders.
RUN mkdir -p /var/lib/panblog/uploads && mkdir -p /var/cache/panblog/output && mkdir -p /var/cache/panblog/tmp && mkdir -p /var/cache/panblog/upload
WORKDIR /app
COPY src/package*.json ./
COPY src/* ./
# Sets users and permissions.
RUN groupadd --system --gid 1001 sysblog && \
useradd --uid 1001 --gid 1001 -M -N --system sysblog && \
chown -R sysblog:sysblog /var/lib/panblog && \
chown -R sysblog:sysblog /var/cache/panblog
COPY --from=builder --chown=sysblog:sysblog /app /app
# De-escelates permissions for improved security.
USER sysblog
# Defines the expected outputs.
EXPOSE 6868
VOLUME /var/lib/panblog
VOLUME /var/cache/panblog
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 ["node", "main.js"]

168
README.md Normal file
View File

@@ -0,0 +1,168 @@
# Panblog
This Node.JS application serves as a system to store and manage blog posts. Powered by the [pandoc](pandoc.org) application, it will accept and store files in a variety of formats, and then return HTML versions upon request.
Post files of a variety of formats (markdown, org files, Microsoft Word files, etc.) can be stored in the upload folder (defaults to `/var/lib/panblog/uploads`). Then, the application will automatically convert them to HTML (storing the output in various subfolders of `/var/cache/panblog`) and then return them when HTTP requests to `http://localhost/post` are made. Posts can also be uploaded by submitting the files to `http://localhost/upload`.
## Notes on Security
This application has little support for security built in. By default, it will accept any and all requests without further validation, and only supports basic header authorization. It also has no support for SSL. It is not designed to be directly exposed to the wider internet. Instead, it should be put behind a more feature-filled server application (i.e. a PHP web server) that will work as a middle-man.
## Requirements
This application requires that [pandoc](pandoc.org) is installed on the machine. If you utilize the docker image, Pandoc will be included in the image.
## Filesystem
The application expects full usage of two directories and read-access to one file. The exact paths can be configured (see [below](#configuration)), but the paths and permissions should be prepared before launching.
* `/var/lib/panblog` - Where uploads will be read from and metadata will be stored.
* `/var/cache/panblog` - Where HTML output is written to.
* `/etc/panblog/config` - A configuration file. May be read-only.
## Paths
The application provides the following paths:
#### `/ping`
A GET path to test the application. Returns the string "pong."
#### `/error`
A GET path to test the application's error handling.
#### `/refresh`
A POST path to re-read the raw posts and recalculate the index.
#### `/verify`
A POST path to get metadata of a post file, returning a JSON string.
If a GET request is sent to the path, a basic HTML form will be returned that will allow for uploading a file to this path. This is only meant for testing purposes.
#### `/upload`
A POST path to submit a new blog post to the application. Upon success, a basic web page will be returned.
If a GET request is sent to the path, a basic HTML form will be returned that will allow for uploading a file to this path. This is only meant for testing purposes.
#### `/feed`
Returns the RSS feed for the blog, calculating it as needed.
#### `/feed.xml`
An alias for [/feed.xml](#feed)
#### `/feed.atom`
An alias for [/feed.xml](#feed)
#### `/feed.rss`
An alias for [/feed.xml](#feed)
#### `/atom`
An alias for [/feed.xml](#feed)
#### `/atom.xml`
An alias for [/feed.xml](#feed)
#### `/rss`
An alias for [/feed.xml](#feed)
#### `/rss.atom`
An alias for [/feed.xml](#feed)
#### `/rss.xml`
An alias for [/feed.xml](#feed)
#### `/post`
One of the more complex paths, GET requests here allow for viewing of a post. If a request is send to just `/post`, then a JSON list of uploaded posts is returned. However, by specifying further subpath elements, a specific post can be searched for and returned.
Here are parameters you can add:
##### author
The slug (lower-case, all non-alphanumeric characters replaced by a dash) of the author that uploaded the post.
##### date
The date the post was drafted, either in the format of "yyyy-mm-dd" or as a series of subpaths like "/yyyy/mm/dd." Alternate orderings are acceptable.
##### keyword
A keyword that applies to the post.
##### title
The slug of the post title.
If the search only results in a single post, the HTML page of that post is returned. Otherwise, a JSON list of different posts is returned. To obtain a specific post, an index number can be appended.
By default, if a single post is found, an example HTML page is returned. However, by specifying a file name as the final element of the path, other files can be returned. This will usually be image files or other assets uploaded alongside the blog post. However, a few file paths return special output:
###### `metadata.json`
Returns the JSON of the post metadata.
###### `fragment.html`
The HTML version of the post without surrounding tags. Useful to be called by the frontend for embedding into a template.
###### `index.html`
The HTML version of the post using a default template.
#####
## Configuration
The application pulls configuration details from two sources. The first one is from the configuration file. It will read each line, expecting the format of "key = value" and use the equal sign (with optional whitespace included) as the delimiter. The configuration file is by default expected at `/etc/panblog/config`, but this can be configured by setting the environment variable `PANBLOG_CONFIG_PATH`.
The second source is from environment variables. Any variable name with the `PANBLOG_` prefix will be read into the configuration. In the event that a configuration key is specified in both the configuration file and an environmental variable, the value stored in the environmental variable takes precedence.
Note that as well as raw values, configuration values can store a reference to a seperate location where the resolved value is stored. If a configuration value begins with a `file:` prefix, the resolved value will be read from the local file system. Likewise, if a configuration value begins with a `url:` prefix, the configuration value will be read from the results of a GET query to that value. For example, if "`auth_key`" is set to "`file:/run/secrets/blog-secret`," then the application will pull the "`auth_key`" value from the file "`/run/secrets/blog-secret`."
These are the following configuration values:
#### `blog_frontend`
This is the URL that will lead the user to the website that has the blog. This is used when generating the Atom feed, so be sure to include any subpaths leading up to the blog. It should not end in a forward slash. Defaults to the operating system host name.
#### `blog_id`
The URI identifier of the blog. This can usually just be the URL of the website. Defaults to a random UUID formatted as "`urn:uuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`."
#### `blog_title`
The human-readable title of the website. Defaults to "New Blog."
#### `blog_author`
The name of the primary author of the blog. Multiple authors can be specified by separating each one with a comma.
#### `blog_link`
The URL to the blog. Will usually be the same as `BLOG_FRONTEND`. It should not end in a forward slash. Optional.
#### `blog_categories`
A comma-separated list of keywords that apply to the blog as a whole. Optional.
#### `blog_contributor`
A comma-separated list of names of people who contributed to the blog. Optional.
#### `blog_icon`
The URL to the blog favicon. Optional.
#### `blog_logo`
The URL to the blog logo image. Optional.
#### `blog_rights`
A copyright string for the blog. Optional.
#### `blog_subtitle`
A subtitle for the blog. Optional.
#### `base_url`
By default, all paths for this application are accessible from directly under the top-level domain this is hosted on. Specifying this variable will set the application to listen for these paths as a subpath of what is specified here. For example, if `base_url` is set to "`/blog`," then posts must be accessed by querying "`http://localhost/blog/post`", as opposed to just "`http://localhost/post`."
#### `upload_path`
The path to the directory that all post uploads are stored. It should not end in a forward slash. Defaults to "`/var/lib/panblog/uploads`."
#### `cache_path`
The path to the directory that all processed post uploads will be sent to. It should not end in a forward slash. Defaults to "`/var/cache/panblog/output`."
#### `index_path`
The path to the file that the blog post index should be written to. Defaults to "`/var/cache/panblog/index.json`."
#### `tmp_path`
The path to the directory that temporary files will be written to. It should not end in a forward slash. Defaults to "`/var/cache/panblog/tmp`."
#### `tmp_upload_path`
This variable controls where the files uploaded through the `/upload` path are sent for processing. It should not end in a forward slash. Defaults to "`/var/cache/panblog/upload`" (note the lack of the plural).
#### `max_upload_size`
This variables controls the maximum payload size of anything sent through the `/upload` and `/verify` paths, in bytes. Defaults to 10 MiB.
#### `auth_key`
By default, all valid requests are accepted by the application. If an auth key is specified, Any requests that do not have this header present as a "Authentication: Basic " http header will be rejected with a 401 error.
#### `auth_key_hash`
Identical to [`auth_key`](#auth_key), but instead stores the bcrypt hash of the authentication key you want to use.

1307
src/blog_framework.js Normal file

File diff suppressed because it is too large Load Diff

165
src/blog_list.html Normal file
View File

@@ -0,0 +1,165 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="generator" content="pandoc" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" />
<meta name="author" content="Markil 3" />
<title>A Test</title>
<style>
html {
line-height: 1.5;
font-family: Georgia, serif;
font-size: 20px;
color: #1a1a1a;
background-color: #fdfdfd;
}
body {
margin: 0 auto;
max-width: 36em;
padding-left: 50px;
padding-right: 50px;
padding-top: 50px;
padding-bottom: 50px;
hyphens: auto;
overflow-wrap: break-word;
text-rendering: optimizeLegibility;
font-kerning: normal;
}
@media (max-width: 600px) {
body {
font-size: 0.9em;
padding: 1em;
}
h1 {
font-size: 1.8em;
}
}
@media print {
body {
background-color: transparent;
color: black;
font-size: 12pt;
}
p, h2, h3 {
orphans: 3;
widows: 3;
}
h2, h3, h4 {
page-break-after: avoid;
}
}
p {
margin: 1em 0;
}
a {
color: #1a1a1a;
}
a:visited {
color: #1a1a1a;
}
img {
max-width: 100%;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 1.4em;
}
h5, h6 {
font-size: 1em;
font-style: italic;
}
h6 {
font-weight: normal;
}
ol, ul {
padding-left: 1.7em;
margin-top: 1em;
}
li > ol, li > ul {
margin-top: 0;
}
blockquote {
margin: 1em 0 1em 1.7em;
padding-left: 1em;
border-left: 2px solid #e6e6e6;
color: #606060;
}
code {
font-family: Menlo, Monaco, 'Lucida Console', Consolas, monospace;
font-size: 85%;
margin: 0;
}
pre {
margin: 1em 0;
overflow: auto;
}
pre code {
padding: 0;
overflow: visible;
overflow-wrap: normal;
}
.sourceCode {
background-color: transparent;
overflow: visible;
}
hr {
background-color: #1a1a1a;
border: none;
height: 1px;
margin: 1em 0;
}
table {
margin: 1em 0;
border-collapse: collapse;
width: 100%;
overflow-x: auto;
display: block;
font-variant-numeric: lining-nums tabular-nums;
}
table caption {
margin-bottom: 0.75em;
}
tbody {
margin-top: 0.5em;
border-top: 1px solid #1a1a1a;
border-bottom: 1px solid #1a1a1a;
}
th {
border-top: 1px solid #1a1a1a;
padding: 0.25em 0.5em 0.25em 0.5em;
}
td {
padding: 0.125em 0.5em 0.25em 0.5em;
}
header {
margin-bottom: 4em;
text-align: center;
}
#TOC li {
list-style: none;
}
#TOC ul {
padding-left: 1.3em;
}
#TOC > ul {
padding-left: 0;
}
#TOC a:not(:hover) {
text-decoration: none;
}
code{white-space: pre-wrap;}
span.smallcaps{font-variant: small-caps;}
span.underline{text-decoration: underline;}
div.column{display: inline-block; vertical-align: top; width: 50%;}
div.hanging-indent{margin-left: 1.5em; text-indent: -1.5em;}
ul.task-list{list-style: none;}
.display.math{display: block; text-align: center; margin: 0.5rem auto;}
</style>
</head>
<body>
<header id="title-block-header">
<h1 class="title">Blog Posts</h1>
</header>
{list}
</body>
</html>

173
src/config.js Normal file
View File

@@ -0,0 +1,173 @@
/*
* Copyright (c) 2026 Markil 3. All rights reserved.
*/
import os from 'os';
import crypto from 'crypto';
import fs from 'node:fs/promises';
import http from 'http';
const CONFIG_PATH = "config_path";
const BLOG_FRONTEND = "blog_frontend";
const BLOG_ID = "blog_id";
const BLOG_TITLE = "blog_title";
const BLOG_AUTHOR = "blog_author";
const BLOG_LINK = "blog_link";
const BLOG_CATEGORIES = "blog_categories";
const BLOG_CONTRIBUTOR = "blog_contributor";
const BLOG_ICON = "blog_icon";
const BLOG_LOGO = "blog_logo";
const BLOG_RIGHTS = "blog_rights";
const BLOG_SUBTITLE = "blog_subtitle";
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 = {};
config[BLOG_FRONTEND] = os.hostname();
config[CONFIG_PATH] = "/etc/panblog/config";
config[BLOG_ID] = "urn:uuid:" + crypto.randomUUID();
config[BLOG_TITLE] = "New Blog";
config[BASE_URL] = "";
config[UPLOAD_PATH] = "/var/lib/panblog/uploads";
config[CACHE_PATH] = "/var/cache/panblog/output";
config[INDEX_PATH] = "/var/cache/panblog/index.json";
config[TMP_PATH] = "/var/cache/panblog/tmp";
config[TMP_UPLOAD_PATH] = "/var/cache/panblog/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)
{
let path;
let fh;
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) => {
if (response.statusCode < 200 || response.statusCode >= 300)
{
res.resume();
reject(new ValueError(`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)
{
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()
{
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 = 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 [BLOG_FRONTEND, BLOG_ID, BLOG_TITLE, BLOG_AUTHOR, BLOG_LINK, BLOG_CATEGORIES, BLOG_CONTRIBUTOR, BLOG_ICON, BLOG_LOGO, BLOG_RIGHTS, BLOG_SUBTITLE, 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,
BLOG_FRONTEND,
BLOG_ID,
BLOG_TITLE,
BLOG_AUTHOR,
BLOG_LINK,
BLOG_CATEGORIES,
BLOG_CONTRIBUTOR,
BLOG_ICON,
BLOG_LOGO,
BLOG_RIGHTS,
BLOG_SUBTITLE,
BASE_URL,
UPLOAD_PATH,
CACHE_PATH,
INDEX_PATH,
TMP_PATH,
TMP_UPLOAD_PATH,
MAX_UPLOAD_SIZE,
AUTH_KEY,
AUTH_KEY_HASH,
readConfiguration,
config
}

23
src/index.js Normal file
View File

@@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 Markil 3. All rights reserved.
*/
import http from 'http';
import express from 'express';
import RequestFramework from './requests.js';
import { config } from './config.js';
const PORT = 6868;
function startServer()
{
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
}

46
src/main.js Normal file
View File

@@ -0,0 +1,46 @@
/*
* Copyright (c) 2026 Markil 3. All rights reserved.
*/
import path from 'node:path';
import { access, constants, mkdir, open, readdir, rename, rm, stat } from 'node:fs/promises';
import { UPLOAD_PATH, CACHE_PATH, TMP_PATH, readConfiguration, config } from './config.js';
import { startServer } from './index.js';
/*
* 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;
const shutdown = (signal) => {
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();
});

32
src/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "panblog",
"version": "1.0.0",
"description": "A backend that stores raw files and uses pandoc to dynamically convert them to hostable HTML files.",
"keywords": [
"panblog",
"blog",
"html",
"http",
"pandoc"
],
"license": "ISC",
"author": "Markil 3",
"type": "module",
"main": "index.js",
"bin": {
"panblog": "main.js"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"xmlbuilder2": ">=4.0.0",
"node-html-parser": ">=7.0.0",
"express": ">=5.0.0",
"cors": ">=2.8.0",
"multer": ">=2.1.0",
"bcrypt": ">=6.0.0",
"escape-html": ">=1.0.0",
"mime": ">=4.0.0"
}
}

352
src/requests.js Normal file
View File

@@ -0,0 +1,352 @@
/*
* Copyright (c) 2026 Markil 3. All rights reserved.
*/
import path from 'node:path';
import fs from 'node:fs/promises';
const __dirname = import.meta.dirname;
import express 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.js";
import {isAuthenticated, getMime} from "./utils.js";
import {Post, PostCall} from "./blog_framework.js";
class RequestFramework
{
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) => {
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);
router.post("/refresh", this.refresh);
router.get("/verify", (request, response, next) => {
response.send('<!DOCTYPE html><html><body><form method="post" enctype="multipart/form-data"><input name="document" id="document" type="file" accept="*"><input type="submit" value="Upload"/></form></body></html>');
});
router.post("/verify", this.multer.single("document"), this.verify);
router.get("/upload", (request, response, next) => {
response.send('<!DOCTYPE html><html><body><form method="post" enctype="multipart/form-data"><input name="documents" id="documents" type="file" accept="*" multiple><input type="submit" value="Upload"/></form></body></html>');
});
router.post("/upload", this.multer.any(), this.upload).use((err, request, response, next) => {if (err instanceof MulterError && err.field) {err.message += ` "${err.field}"`;} next(err);});
router.get("/atom", this.rss);
router.get("/atom.xml", this.rss);
router.get("/rss", this.rss);
router.get("/rss.atom", this.rss);
router.get("/rss.xml", this.rss);
router.get("/feed", this.rss);
router.get("/feed.xml", this.rss);
router.get("/feed.atom", this.rss);
router.get("/feed.rss", this.rss);
/*
* We need a lot of flexibility with /post, but express does not provide this.
* Thus, we add every possible pattern and then handle the URL parsing within
* the callback.
*/
router.get("/post", this.post);
router.get("/post/:index", this.post);
router.get("/post/:index/:file", this.post);
router.get("/post/:date", this.post);
router.get("/post/:date/:index", this.post);
router.get("/post/:date/:index/:file", this.post);
router.get("/post/:year/:month/:day", this.post);
router.get("/post/:year/:month/:day/:index", this.post);
router.get("/post/:year/:month/:day/:index/:file", this.post);
router.get("/post/:author", this.post);
router.get("/post/:author/:index", this.post);
router.get("/post/:author/:index/:file", this.post);
router.get("/post/:author/:year/:month/:day", this.post);
router.get("/post/:author/:year/:month/:day/:index", this.post);
router.get("/post/:author/:year/:month/:day/:index/:file", this.post);
router.get("/post/:author/:year/:month/:day/:title", this.post);
router.get("/post/:author/:year/:month/:day/:title/:index", this.post);
router.get("/post/:author/:year/:month/:day/:title/:index/:file", this.post);
router.get("/post/:author/:date", this.post);
router.get("/post/:author/:date/:index", this.post);
router.get("/post/:author/:date/:index/:file", this.post);
router.get("/post/:keyword", this.post);
router.get("/post/:keyword/:index", this.post);
router.get("/post/:keyword/:index/:file", this.post);
this.app.use(config[BASE_URL], router);
}
ping(request, response)
{
response.send("Pong");
}
error(request, response, next)
{
let x = y / 0;
next();
}
refresh(request, response, next)
{
let responseOb;
let call = new PostCall();
call.rebuildCache().then(() => {
response.status(200).set("Content-Type", "text/html").send("<!DOCTYPE html><html><body><h1>Success</h1><p>Refresh Successful</p></body></html>");
}).catch(next);
}
upload(request, response, next)
{
let call = new PostCall();
if (request.files && request.files.length)
{
let postFile = request.files[0];
let postTitle;
if (postFile.originalname.lastIndexOf(".") > -1)
{
postTitle = postFile.originalname.substring(0, postFile.originalname.lastIndexOf("."));
}
else
{
postTitle = postFile.originalName;
}
let postMetadata;
let postAssets;
if (request.files.length > 1)
{
if (request.files[1].originalname.endsWith(".json"))
{
postMetadata = request.files[1];
postAssets = request.files.slice(2);
}
else if (request.files[request.files.length - 1].originalname.endsWith(".json"))
{
postMetadata = request.files[request.files.length - 1];
postAssets = request.files.slice(1, request.files.length - 1);
}
else
{
postAssets = request.files.slice(1);
}
}
else
{
postAssets = [];
}
let uploadPath = "/" + postTitle;
fs.mkdir(path.join(config[UPLOAD_PATH], uploadPath), {recursive: true}).then(() => {
return Promise.all(request.files.map(async (file) => {
await fs.cp(file.path, path.join(config[UPLOAD_PATH], uploadPath, file.originalname));
await fs.rm(file.path);
}));
}).then(() => call.rebuildCache()).then(() => {
response.set("Access-Control-Allow-Origin", "*").set("Access-Control-Allow-Method", "GET,POST").send("<!DOCTYPE html><html><body><p>Uploaded post.</p></body></html>");
}).catch(next);
}
else
{
response.status(400).send("<!DOCTYPE html><html><body><h1>HTTP 400 Error</h1><p>No file present.</p></body></html>");
}
}
verify(request, response, next)
{
let call = new PostCall();
if (request.file)
{
console.info(JSON.stringify(request.file));
let format = request.file.originalname.substring(request.file.originalname.lastIndexOf(".") + 1);
if (format == "md")
{
format = "markdown";
}
call.generatePost(request.file.path, format).then((post) => {
response.set("Access-Control-Allow-Origin", request.get("Referer")).json(post);
fs.rm(request.file.path);
next();
}).catch((e) => {
fs.rm(request.file.path);
next(e)
});
}
else
{
response.status(400).send("<!DOCTYPE html><html><body><h1>HTTP 400 Error</h1><p>No file present.</p></body></html>");
}
}
async rss(request, response, next)
{
let call = new PostCall();
let rssPath = path.join(config[CACHE_PATH], "atom.xml");
let fh;
let content;
try
{
fh = await fs.open(rssPath);
try
{
content = await fh.readFile();
}
catch (e)
{
console.error(e);
next(e);
return;
}
}
catch (e)
{
console.error(e);
console.info("Generatings RSS Feed");
/*
* The RSS feed does not exist. Let us create it.
*/
try
{
content = await call.saveRSS();
}
catch (e)
{
next(e);
return;
}
}
finally
{
fh?.close();
}
response.set("Content-Type", "application/atom+xml").send(content);
next();
}
post(request, response, next)
{
let url = request.path;
let call = new PostCall();
if (!url.endsWith("/") && url.substring(url.lastIndexOf("/")).indexOf(".") == -1)
{
// Redirect to the "folder" path.
response.redirect(config[BASE_URL] + url + "/");
return;
}
else if (url.endsWith("/") && url.substring(url.length - 1).substring(url.lastIndexOf("/")).indexOf(".") > -1)
{
// Redirect to the "folder" path.
response.redirect(config[BASE_URL] + url.substring(url.length - 1));
return;
}
if (url.startsWith(config[BASE_URL]))
{
url = url.substring(config[BASE_URL].length);
}
if (url.startsWith("/post"))
{
url = url.substring("/post".length);
}
call.readURL(url).then((data) => {
let responseOb, post, filePath, fh;
try
{
[ post, filePath, fh ] = data;
if (!post || post instanceof Array && !post.length)
{
response.status(404).send("<!DOCTYPE html><html><body><p>No blog posts found.</p></body></html>");
return;
}
else if (post instanceof Array)
{
if (url.endsWith("metadata.json"))
{
let newData = [];
/*
* Modifies the link as needed.
*/
for (let i = 0; i < post.length; i++)
{
newData.push(post[i].toJSON());
newData[i]["link"] = request.protocol + "://" + request.host + config[BASE_URL] + "/post" + newData[i]["link"];
}
response.status(200).json(newData);
return;
}
else// if (url.endsWith(".html") || url.endsWith("/") || url.substring(url.lastIndexOf("/")).indexOf(".") == -1)
{
let urlDir;
if (url.substring(url.lastIndexOf("/")).indexOf(".") != -1)
{
urlDir = url.substring(0, url.lastIndexOf("/"));
}
else
{
urlDir = url;
}
while (urlDir.endsWith("/"))
{
urlDir = urlDir.substring(0, urlDir.length - 1);
}
let listItems = post.map((entry, index) => `<li><a href="${config[BASE_URL] + "/post" + urlDir + '/' + (index + 1) + url.substring(url.lastIndexOf("/"))}">${entry.title}</a></li>`).join('\n ');
listItems = "<ul>\n " + listItems + "\n</ul>";
if (!url.endsWith("fragment.html"))
{
fs.open(path.resolve(__dirname, "blog_list.html")).then((fh) => {try {return fh.readFile("utf-8")} finally {fh.close()}}).then((contents) => {
response.set("Content-Type", "text/html").send(contents.replace("{list}", listItems));
}).catch(next);
}
else
{
response.set("Content-Type", "text/html").send(listItems);
}
}
}
else if (url.endsWith("metadata.json"))
{
let newData = post.toJSON();
newData["link"] = request.protocol + "://" + request.host + config[BASE_URL] + "/post" + newData["link"];
response.status(200).json(newData);
}
else
{
// TODO - Caching
fh.readFile().then((contents) => {
response.set("Content-Type", getMime(filePath)).send(contents);
}).catch(next);
}
}
finally
{
fh?.close();
}
}).catch(next);
}
}
export default RequestFramework;

213
src/utils.js Normal file
View File

@@ -0,0 +1,213 @@
/*
* Copyright (c) 2026 Markil 3. All rights reserved.
*/
import fs from "fs";
import bcrypt from "bcrypt";
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)
{
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)
{
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
};