From 8af2ff13d6faa13986471b5910a3aa3d29485aba Mon Sep 17 00:00:00 2001 From: Markil 3 Date: Mon, 24 Aug 2026 09:53:16 -0600 Subject: [PATCH] Moves the PanBlog code into its own repository. --- Dockerfile | 49 ++ README.md | 168 ++++++ src/blog_framework.js | 1307 +++++++++++++++++++++++++++++++++++++++++ src/blog_list.html | 165 ++++++ src/config.js | 173 ++++++ src/index.js | 23 + src/main.js | 46 ++ src/package.json | 32 + src/requests.js | 352 +++++++++++ src/utils.js | 213 +++++++ 10 files changed, 2528 insertions(+) create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 src/blog_framework.js create mode 100644 src/blog_list.html create mode 100644 src/config.js create mode 100644 src/index.js create mode 100644 src/main.js create mode 100644 src/package.json create mode 100644 src/requests.js create mode 100644 src/utils.js diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..02de06a --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..8fbf597 --- /dev/null +++ b/README.md @@ -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. diff --git a/src/blog_framework.js b/src/blog_framework.js new file mode 100644 index 0000000..f4300ad --- /dev/null +++ b/src/blog_framework.js @@ -0,0 +1,1307 @@ +/* + * Copyright (c) 2026 Markil 3. All rights reserved. + */ +import { promisify } from 'node:util'; +import crypto from 'crypto'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import child_process from 'node:child_process'; +import { parse } from 'node-html-parser'; +import xmlbuilder2 from 'xmlbuilder2'; +import escape from 'escape-html'; +import mime from 'mime'; +import { BLOG_FRONTEND, BLOG_ID, BLOG_TITLE, BLOG_AUTHOR, BLOG_LINK, BLOG_CATEGORIES, BLOG_CONTRIBUTOR, BLOG_ICON, BLOG_LOGO, BLOG_RIGHTS, BLOG_SUBTITLE, UPLOAD_PATH, CACHE_PATH, INDEX_PATH, TMP_PATH, config } from './config.js'; + +const execFile = promisify(child_process.execFile); + +const DATE_PATH_FORMATTER = new Intl.DateTimeFormat('en-US', {}); + +/** + * A Post object contains metadata on a blog post. + */ +class Post +{ + /** + * The file path to the original post upload. + */ + uploadPath; + /** + * The path to the converted HTML version of the post. + */ + cachePath; + /** + * Obtains the title of the blog post. + */ + title; + /** + * The name of the author. + */ + author; + /** + * The date the post was uploaded. + */ + uploadDate; + /** + * A list of keywords for the post. + */ + keywords; + /** + * A description of the post. + */ + description; + /** + * A list of paths that the main blog post uses (images, etc), relative to the main post file. + */ + assets = []; + + constructor(title, author, uploadDate, keywords = [], description = "", assets = [], uploadPath = null, cachePath = null) + { + this.uploadPath = uploadPath; + this.title = title; + this.author = author; + this.uploadDate = uploadDate; + this.keywords = keywords; + this.description = description; + if (!uploadPath) + { + uploadPath = this.calculateCachePath(); + } + this.uploadPath = uploadPath; + if (!cachePath) + { + cachePath = this.calculateCachePath(); + } + this.cachePath = cachePath; + this.assets = assets; + } + + getTitleSlug() + { + return this.title.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + } + + getAuthorSlug() + { + return this.author.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + } + + /** + * Calculates a path where an HTML cache should be stored based on all the other metadata. This path is relative to the cache directory, and points to the directory where index.html is stored. + * @returns The path to the cache directory for this blog post. + */ + calculateCachePath() + { + return `/${String(this.uploadDate.getUTCFullYear()).padStart(4, "0")}/${String(this.uploadDate.getUTCMonth() + 1).padStart(2, "0")}/${String(this.uploadDate.getUTCDate()).padStart(2, "0")}/${this.getAuthorSlug()}-${this.getTitleSlug()}`; + } + + /** + * Sets the cache path to whatever #calculateCachePath determines. + */ + setDefaultCachePath() + { + this.cachePath = this.calculateCachePath(); + } + + /** + * Obtains a default path to access this post. Note that this path does not account for the actual access point the requests setup uses + * (usually /post), so joining this result to that may be needed. + * @returns A default URL for this blog post, built using what elements are found from the post. + */ + getDefaultURL() + { + return `/${String(this.uploadDate.getFullYear()).padStart(4, "0")}/${String(this.uploadDate.getUTCMonth() + 1).padStart(2, "0")}/${String(this.uploadDate.getUTCDate()).padStart(2, "0")}/${this.getAuthorSlug()}/${this.getTitleSlug()}/`; + } + + toJSON() + { + return { + "title": this.title, + "link": this.getDefaultURL(), + "author": this.author, + "uploadDate": this.uploadDate, + "keywords": this.keywords, + "description": this.description, + "assets": this.assets + }; + } + + static fromJSON(metadata, uploadPath = null, cachePath = null) + { + return new Post(metadata.title, metadata.author, new Date(Date.parse(metadata.uploadDate)), metadata.keywords, metadata.description, metadata.assets, uploadPath ?? metadata.uploadPath ?? null, cachePath ?? metadata.cachePath ?? null); + } + + static compare(a, b) + { + if (a.uploadDate.valueOf() == b.uploadDate.valueOf()) + { + if (a.title == b.title) + { + if (a.author == b.author) + { + return 0; + } + return a.author > b.author ? 1 : a.author < b.author ? -1 : 0; + } + return a.title > b.title ? 1 : a.title < b.title ? -1 : 0; + } + return a.uploadDate.valueOf() - b.uploadDate.valueOf(); + } +} + +/** + * Contains a list of posts and some utility functions to help look them up by different mappings. + */ +class PostMapping +{ + dates = new Map(); + titles = new Map(); + authors = new Map(); + keywords = new Map(); + posts = new Array(); + + /** + * Converts a date object to a string that can be added to a mapping. Rounded to the day. + */ + getDateString(date) + { + return `${String(date.getUTCFullYear()).padStart(4, "0")}-${String(date.getUTCMonth()).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`; + } + + /** + * Sorts the posts by date and such. + */ + sortPosts() + { + this.dates.forEach((posts) => { + posts.sort(Post.compare); + }); + this.authors.forEach((posts) => { + posts.sort(Post.compare); + }); + this.keywords.forEach((posts) => { + posts.sort(Post.compare); + }); + this.posts.sort(Post.compare); + } + + /** + * Inserts new posts into the collection. + * @param post... - The posts to add to the mapping. + */ + insertPosts(post) + { + let postDate, postAuthor, postKeyword, i; + + let posts; + if (post instanceof Array) + { + posts = post; + } + else + { + posts = arguments; + } + for (i = 0; i < posts.length; i++) + { + post = posts[i]; + postDate = this.getDateString(post.uploadDate); + if (!this.dates.has(postDate)) + { + this.dates.set(postDate, []); + } + this.dates.get(postDate).push(post); + + this.titles.set(post.getTitleSlug(), post); + + postAuthor = post.getAuthorSlug(); + if (!this.authors.has(postAuthor)) + { + this.authors.set(postAuthor, []); + } + this.authors.get(postAuthor).push(post); + + for (postKeyword of post.keywords) + { + if (!this.keywords.has(postKeyword)) + { + this.keywords.set(postKeyword, []); + } + this.keywords.get(postKeyword).push(post); + } + this.posts.push(post); + } + + this.sortPosts(); + } + + /** + * Searches for posts added on a certain day. + * @param date - The date to search within. Rounded to the day. + * @returns An array of matching posts, or an empty array. + */ + getPostsByDate(date) + { + let postDate = this.getDateString(date); + if (this.dates.has(postDate)) + { + return Array.from(this.dates.get(postDate)); + } + return []; + } + + /** + * Searches for posts written by a specific author. + * @param author - The author that wrote the post. This must be the author slug. + * @returns An array of matching posts, or an empty array. + */ + getPostsByAuthor(author) + { + if (this.authors.has(author)) + { + return Array.from(this.authors.get(author)); + } + return []; + } + + /** + * Searches for posts that match a specific keyword. + * @param keyword - The keyword to search for. + * @returns An array of matching posts, or an empty array. + */ + getPostsByKeyword(keyword) + { + if (this.keywords.has(keyword)) + { + return Array.from(this.keywords.get(keyword)); + } + return []; + } + + /** + * Searches for posts that match the given title. + * @param title - The title of the post. This must be the title slug. + * @returns The matching post, or null. + */ + getPostByTitle(title) + { + if (this.titles.has(title)) + { + return this.titles.get(title); + } + return null; + } + + /** + * Searches for posts that match certain criteria. All parameters are optional, and if none are provided, all posts are returned. + * @param date - The date to search within. Rounded to the day. + * @param author - The author that wrote the post. This must be the author slug. + * @param keywords - An array of keywords to match against. + * @param title - The title of the post. This must be the title slug. + * @returns An array of matching posts, or an empty array. + */ + getPosts(date = null, author = null, keywords = null, title = null) + { + if (title) + { + let post = this.getPostByTitle(title); + if ((!author || post.getAuthorSlug() == author) && (!date || post.uploadDate.getUTCFullYear() == date.getUTCFullYear() && post.uploadDate.getUTCMonth() == date.getUTCMonth() && post.uploadDate.getUTCDate() == date.getUTCDate()) && (!keywords || !keywords.length || keywords.every((keyword) => post.keywords.includes(keyword)))) + { + return [post]; + } + else + { + return []; + } + } + else if (date) + { + return this.getPostsByDate(date).filter((post) => (!author || post.getAuthorSlug() == author) && (!keywords || !keywords.length || keywords.every((keyword) => post.keywords.includes(keyword)))) + } + else if (keywords && keywords.length) + { + return this.getPostsByKeyword(keywords[0]).filter((post) => (!author || post.getAuthorSlug() == author) && (!title || post.getTitleSlug() == title) && (!keywords || keywords.every((keyword) => post.keywords.includes(keyword)))); + } + else if (author) + { + return this.getPostsByAuthor(author); + } + else + { + return Array.from(this.posts); + } + } + + /** + * Obtains the post that corresponds to a specific URL. + * @param url - The url path fragment, relative to the application's base URL. + */ + getPostByURL(url) + { + const RE_DATE_1 = /^\d{4}-\d{2}-\d{2}$/; + const RE_DATE_2 = /^\d{2}-\d{2}-\d{4}$/; + let urlEls = url.split('/'); + while (urlEls[0] == '') + { + urlEls.shift(); + } + + let date = null, author = null, title = null, keyword = [], index = -1, post = null; + while (urlEls.length) + { + if (this.titles.has(urlEls[0])) + { + title = urlEls.shift(); + } + else if (this.keywords.has(urlEls[0])) + { + keyword = [urlEls.shift()]; + } + else if (this.authors.has(urlEls[0])) + { + author = urlEls.shift(); + } + else if (urlEls[0].match(RE_DATE_1) || urlEls[0].match(RE_DATE_2)) + { + date = new Date(Date.parse(urlEls.shift())); + } + else if (urlEls.length >= 3 && urlEls[0].match(/^\d+$/) && urlEls[1].match(/^\d+$/) && urlEls[2].match(/^\d+$/)) + { + date = new Date(Date.parse(urlEls.slice(0, 3).join("/"))); + urlEls.shift(); + urlEls.shift(); + urlEls.shift(); + } + else if (urlEls[0].match(/^\d+$/)) + { + index = (urlEls.shift() - 1); + break; + } + else + { + /* + * We ran out of elements that correspond to search elements. The is treated as a subpath. + */ + break; + } + } + + let posts = this.getPosts(date, author, keyword, title); + if (posts && posts.length && index > -1) + { + index = Math.max(0, Math.min(posts.length - 1, index)); + posts = [posts[index]]; + } + return [ posts, '/' + urlEls.join('/') ]; + } + + toJSON() + { + let idMapping = new Map(); + for (let i = 0; i < this.posts.length; i++) + { + idMapping.set(this.posts[i], i); + } + let postJSON = this.posts.map((post) => post.toJSON()); + let dateJSON = {}; + let authorJSON = {}; + let keywordJSON = {}; + let titleJSON = {}; + this.dates.forEach((posts, dateKey, map) => { + dateJSON[dateKey] = posts.map((post) => idMapping.get(post)); + }); + this.authors.forEach((posts, authorKey, map) => { + authorJSON[authorKey] = posts.map((post) => idMapping.get(post)); + }); + this.keywords.forEach((posts, keywordKey, map) => { + keywordJSON[keywordKey] = posts.map((post) => idMapping.get(post)); + }); + this.titles.forEach((post, titleKey, map) => { + titleJSON[titleKey] = idMapping.get(post); + }); + return { + "posts": postJSON, + "dateMap": dateJSON, + "authorMap": authorJSON, + "keywordMap": keywordJSON, + "titleMap": titleJSON + }; + } + + static fromJSON(data) + { + let mapping = new PostMapping(); + let posts = data.posts.map((postData) => postData instanceof Post ? postData : Post.fromJSON(postData)); + if (data.dateMap && data.authorMap && data.keywordMap && data.titleMap) + { + this.posts = posts; + data.dateMap.keys().forEach((key) => { + this.dates.set(key, date.dateMap.map((postIndex) => this.posts[postIndex])); + }); + data.authorMap.keys().forEach((key) => { + this.authors.set(key, date.authorMap.map((postIndex) => this.posts[postIndex])); + }); + data.keywordMap.keys().forEach((key) => { + this.keywords.set(key, date.keywordMap.map((postIndex) => this.posts[postIndex])); + }); + data.titleMap.keys().forEach((key) => { + this.titles.set(key, this.posts[data.titleMap.get(key)]); + }); + this.sortPosts(); + } + else + { + this.insertPosts(posts); + } + } +} + +class PostCall +{ + static instance; + + /** + * A list of callbacks waiting for the post mapping to be generated. + */ + #mappingCalls = []; + /** + * A map of posts to callbacks waiting for said post to fully generate. + */ + #postCalls = new Map(); + + #mapping; + + constructor() + { + if (PostCall.instance) + { + return PostCall.instance; + } + + PostCall.instance = this; + } + + /** + * Prepares a blog post for publishing, creating the HTML files as needed. + * @param post - The post to prepare. The cachePath field may be modified. + * @returns The file path of the new HTML index. + */ + async #preparePost(post) + { + let destPath = path.join(config[CACHE_PATH], post.calculateCachePath()); + let indexPath = path.format({ + dir: destPath, + name: "index", + ext: "html" + }); + let fragmentPath = path.format({ + dir: destPath, + name: "fragment", + ext: "html" + }); + await fs.mkdir(destPath, {recursive: true}); + /* + * Removes any temporary files. + */ + if (post.cachePath && path.dirname(post.cachePath) == config[TMP_PATH]) + { + try + { + await fs.rm(post.cachePath, {recursive: true}); + } + catch (e) + { + logging.error("Could not delete temporary index file"); + logging.error(e); + } + } + post.cachePath = destPath; + try + { + /* + * Checks to see if the fragment exists before making it. + */ + await fs.access(indexPath); + } + catch (e) + { + /* + * Generates the main index file outself. + */ + await execFile("pandoc", [`--resource-path=${path.dirname(post.uploadPath)}`, `--extract-media=.`, '--standalone', '-o', indexPath, post.uploadPath], {cwd: destPath}); + } + + try + { + /* + * Checks to see if the fragment exists before making it. + */ + await fs.access(fragmentPath); + } + catch (e) + { + /* + * Generates the HTML fragment. + */ + await execFile("pandoc", [`--resource-path=${path.dirname(post.uploadPath)}`, `--extract-media=.`, '-o', fragmentPath, post.uploadPath], {cwd: destPath}); + } + return destPath; + } + + /** + * Generates an HTML version of a blog to a location in the temporary file directory, analyzing metadata as needed. + * @param post - The path of the original upload file. + * @returns A promise that resolves to a Post object containing metadata and the information + */ + async generatePost(uploadPath, format=undefined) + { + let tmpPath = path.format({ + dir: path.resolve(config[TMP_PATH], crypto.randomBytes(16).toString('hex')), + name: "index", + ext: "html" + }); + let stats, fh; + let document; + + let title, author, keywords, description, date, datestr, assets = []; + + let postData; + + stats = await fs.stat(uploadPath); + await fs.mkdir(path.dirname(tmpPath), {recursive: true}); + // Converts the file into HTML that we can parse. + let pandocParams = ['--standalone', '-o', tmpPath, uploadPath]; + if (format) + { + pandocParams.splice(1, 0, "-f", format); + } + let { stdout, stderr } = await execFile("pandoc", pandocParams); + try + { + fh = await fs.open(tmpPath); + document = parse(await fh.readFile()); + let titleEl = document.querySelector("title"); + title = titleEl?.text.trim() ?? path.basename(uploadPath, path.extname(uploadPath)); + let authorEl = document.querySelector('meta[name="author"]'); + if (!authorEl) + { + authorEl = document.querySelector(".author"); + if (authorEl) + { + author = authorEl.text.trim(); + } + else + { + author = "Unknown"; + } + } + else + { + author = authorEl.getAttribute("content"); + } + let keywordEl = document.querySelector('meta[name="keywords"]'); + if (!keywordEl) + { + keywords = []; + } + else + { + keywords = keywordEl.getAttribute("content").split(","); + for (let i = 0; i < keywords.length; i++) + { + keywords[i] = keywords[i].trim(); + } + } + let dateEl = document.querySelector('meta[name="date"]'); + if (!dateEl) + { + dateEl = document.querySelector(".date"); + if (dateEl) + { + datestr = dateEl.text.trim(); + } + } + else + { + datestr = dateEl.getAttribute("content"); + } + if (!datestr) + { + // Should we use birth time (for upload date) or mtime (for when the file was last modified, possibly on another system?) + date = new Date(stats.birthtimeMs); + } + else + { + date = new Date(Date.parse(datestr)); + } + let descriptionEl = document.querySelector('meta[name="description"]'); + if (!descriptionEl) + { + descriptionEl = document.querySelector(".abstract"); + if (descriptionEl) + { + descriptionEl.querySelector(".abstract-title")?.remove(); + description = descriptionEl.text.trim(); + } + } + else + { + description = descriptionEl?.getAttribute("content"); + } + + let linkEls = document.querySelectorAll("[href], [src]"); + + let linkPath, linkType; + for (let linkEl of linkEls) + { + // Ignore links, we only want images and whatnot + if (linkEl.tagName != "A" && linkEl.tagName != "BASE") + { + if (linkEl.getAttribute("src")) + { + linkPath = linkEl.getAttribute("src"); + } + else if (linkEl.getAttribute("href")) + { + linkPath = linkEl.getAttribute("href"); + } + linkType = mime.getType(linkPath); + if (linkPath) + { + if (!path.isAbsolute(linkPath)) + { + assets.push({ + "path": linkPath, + "type": linkType + }); + } + } + } + } + + postData = new Post(title, author, date, keywords, description, assets, uploadPath, path.dirname(tmpPath)); + return postData; + } + finally + { + await fh?.close(); + } + } + + /** + * Reads the file upload folder and generates a list of blog posts found. This does + * not necessarily process any of the files (although some of them may be put in the + * temporary file cache), but is primarily dedicated to look them up. + * @returns A promise resolving to an array of Post objects. + */ + async #structurePosts() + { + let directories = []; + let posts = []; + let subFiles, subFilePath, subFile, subFileBaseName, tmpPath, metadataPath, metadata, post, fh, fhw; + directories.push(config[UPLOAD_PATH]); + while (directories.length > 0) + { + let directory = directories.shift(); + try + { + subFiles = await fs.readdir(directory); + } + catch (e) + { + console.error(`Failed to read directory ${directory}`); + console.error(e); + continue; + } + for (subFilePath of subFiles) + { + subFilePath = path.join(directory, subFilePath); + subFile = await fs.stat(subFilePath); + if (subFile.isDirectory()) + { + directories.push(subFilePath); + } + else if (subFile.isFile()) + { + subFileBaseName = path.basename(subFilePath, path.extname(subFilePath)); + if (!subFilePath.endsWith("~") && ![".json", ".bmp", ".jpg", ".jpeg", ".png", ".gif", ".svg", ".css", ".js"].includes(path.extname(subFilePath))) + { + // We ignore JSON files, as these are metadata files. + // Actually, check to see if this file has a JSON metadata file associated with it. + metadataPath = path.format({ + dir: path.dirname(subFilePath), + name: subFileBaseName, + ext: "json" + }); + fh = undefined; + try + { + fh = await fs.open(metadataPath); + metadata = JSON.parse(await fh.readFile()); + post = Post.fromJSON(metadata, subFile); + post.uploadPath = subFilePath; + } + catch (e) + { + // Does not exist, so we create it by analysing the file. + post = await this.generatePost(subFilePath); + try + { + fhw = await fs.open(metadataPath, 'w'); + await fhw.writeFile(JSON.stringify(post)); + } + catch (e) + { + console.error(`Could not save post metadata to ${metadataPath}`); + console.error(e); + } + finally + { + await fhw?.close(); + fhw = undefined; + } + } + finally + { + await fh?.close(); + fh = undefined; + } + posts.push(post); + } + } + } + } + + return posts; + } + + /** + * Reads posts from the cache index file. + * @returns A promise that resolves to a PostMapping object. + */ + async #readCache() + { + let cacheFile = config[INDEX_PATH]; + let fh; + try + { + fh = await fs.open(cacheFile); + console.info("Reading post mapping from file cache."); + return PostMapping.fromJSON(JSON.parse(await fh.readFile())); + } + finally + { + await fh?.close(); + } + } + + /** + * Create post entries from the file system. + * @returns A promise that resolves to a PostMapping object. + */ + async #buildCache() + { + console.info("Constructing post cache."); + let posts = await this.#structurePosts(); + console.info(`Found ${posts.length} posts`); + let mapping = new PostMapping(); + mapping.insertPosts(posts); + return mapping; + } + + /** + * Writes the mapping cache to the file. + * @param mapping - The cache mapping to write. + * @returns A promise that resolves upon completion of the write. + */ + async #writeCache(mapping) + { + let cacheFile = config[INDEX_PATH]; + let fh; + try + { + fh = await fs.open(cacheFile, 'w'); + await fh.writeFile(JSON.stringify(mapping)); + } + finally + { + await fh?.close(); + } + } + + /** + * Sets up the post mapping for the first time, either reading it from the cache or analyzing the file system. + * @returns A promise that resolves to the post mapping upon the completion of the setup. + */ + #setupMapping() + { + if (this.#mapping && !this.#mappingCalls.length) + { + return Promise.resolve(this.#mapping); + } + return new Promise((resolve, reject) => { + this.#mappingCalls.push([ resolve, reject ]); + + if (this.#mappingCalls.length > 1) + { + /* + * If we somehow let another promise sneak callbacks before we did, return and let that one finish. + */ + return; + } + + this.#readCache().catch((e) => this.#buildCache()).then((builtMapping) => { + this.#mapping = builtMapping; + return this.#mapping; + }).then((mapping) => this.#writeCache(mapping)).then(() => { + while (this.#mappingCalls.length) + { + let [ resolve, reject ] = this.#mappingCalls.shift(); + resolve(this.#mapping); + } + }, (e) => { + while (this.#mappingCalls.length) + { + let [ resolve, reject ] = this.#mappingCalls.shift(); + reject(e); + } + }); + }); + } + + /** + * Saves the current cache to the file system (building it as needed). + * @returns A promise that resolves upon completion. + */ + async saveCache() + { + let mapping = await this.#setupMapping(); + await this.#writeCache(mapping); + } + + /** + * Generates + * @param mapping - The mapping to generate RSS on. + * @returns A promise that resolves to the content of an RSS xml file. + */ + async #generateRSS(mapping) + { + let firstPost; + let latestPost; + let output, parsedOutput; + if (mapping.posts.length) + { + firstPost = mapping.posts[0]; + latestPost = mapping.posts[mapping.posts.length - 1]; + } + let feed = { + feed: { + "@xmlns": "http://www.w3.org/2005/Atom", + "title": config[BLOG_TITLE], + "id": config[BLOG_ID], + } + }; + if (latestPost) + { + feed.feed["updated"] = latestPost.uploadDate.toISOString(); + } + else + { + feed.feed["updated"] = new Date().toISOString(); + } + if (config[BLOG_LINK]) + { + feed.feed["link"] = [{ + "@rel": "self", + "@href": config[BLOG_LINK] + "/rss/" + }, { + "@rel": "alternate", + "@href": config[BLOG_LINK] + }]; + } + if (config[BLOG_AUTHOR]) + { + feed.feed["author"] = []; + for (const author of config[BLOG_AUTHOR].split(",")) + { + feed.feed["author"].push({ + "name": author.trim() + }); + } + } + else if (!mapping.posts.length || !mapping.posts.every((post) => post.author)) + { + /* + * The Atom specification needs an author attribute if not every post has an author. + */ + if (firstPost && firstPost.author) + { + feed.feed["author"] = [{ + "name": firstPost.author + }]; + } + else + { + feed.feed["author"] = [{ + "name": "localhost" + }]; + } + } + if (config[BLOG_CATEGORIES]) + { + feed.feed["category"] = []; + for (const category of config[BLOG_CATEGORIES].split(",")) + { + feed.feed["category"].push({ + "@term": category.trim() + }); + } + } + if (config[BLOG_CONTRIBUTOR]) + { + feed.feed["contributor"] = []; + for (const contributor of config[BLOG_CONTRIBUTOR].split(",")) + { + feed.feed["contributor"].push({ + "name": contributor.trim() + }); + } + } + feed.feed["generator"] = { + "@uri": "/refresh/", + "@version": "1.0", + "#": "Panblog" + }; + if (config[BLOG_ICON]) + { + feed.feed["icon"] = config[BLOG_ICON]; + } + if (config[BLOG_LOGO]) + { + feed.feed["logo"] = config[BLOG_LOGO]; + } + if (config[BLOG_RIGHTS]) + { + feed.feed["rights"] = config[BLOG_RIGHTS]; + } + if (config[BLOG_SUBTITLE]) + { + feed.feed["subtitle"] = config[BLOG_SUBTITLE]; + } + + /* + * Generates all the entry elements. + */ + feed.feed["entry"] = await Promise.all(mapping.posts.map((post) => { + return new Promise((resolve, reject) => { + let url = `${config[BLOG_FRONTEND]}/post${post.getDefaultURL()}`; + let entry = { + "id": url, + "title": post.title, + "updated": post.uploadDate.toISOString(), + "published": post.uploadDate.toISOString(), + "link": [{ + "@rel": "self", + "@href": url + }, { + "@rel": "alternate", + "@href": url + }] + }; + if (post.author) + { + entry.author = [{ + "name": post.author + }]; + } + for (const media of post.assets) + { + entry.link.push({ + "@rel": "enclosure", + "@href": `${url}${media.path}`, + "@type": media.type, + "@length": 1 + }); + } + if (post.description) + { + entry.summary = post.description; + } + this.#preparePost(post).then(async (destPath) => { + let fragmentPath = path.format({ + dir: destPath, + name: "fragment", + ext: "html" + }); + let fh; + try + { + fh = await fs.open(fragmentPath); + return await fh.readFile("utf-8"); + } + finally + { + await fh?.close(); + } + }).then((content) => { + let parsedContent = escape(content); + entry.content = { + "@type": "html", + "#": content + }; + resolve(entry); + }).catch(reject); + }); + })); + + output = xmlbuilder2.create({"version": "1.0", "encoding": "utf-8"}, feed); + parsedOutput = output.end({prettyPrint: true}); + return parsedOutput; + } + + /** + * Writes the mapping cache to the file. + * @param mapping - The cache mapping to write. + * @returns A promise that resolves upon completion of the write. + */ + async #writeRSS(mapping) + { + let rssPath = path.join(config[CACHE_PATH], "atom.xml"); + + let fh, parsedOutput = await this.#generateRSS(mapping); + try + { + fh = await fs.open(rssPath, 'w'); + await fh.writeFile(parsedOutput); + return parsedOutput; + } + finally + { + await fh?.close(); + } + } + + /** + * Saves an ATOM feed + * @returns A promise that resolves to the RSS content when the RSS file is generated. + */ + async saveRSS() + { + let mapping = await this.#setupMapping(); + return await this.#writeRSS(mapping); + } + + /** + * Asynchronously rebuilds the cache. It is not written to the file system. + * @returns A promise that resolves when the cache is rebuilt. + */ + async rebuildCache() + { + let mapping = await this.#buildCache(); + this.#mapping = mapping; + await this.#writeCache(this.#mapping); + await this.#writeRSS(this.#mapping); + } + + /** + * Obtains the metadata for a post based on a provided URL. + * @param url - The url to obtain a post for, relative to the base path. + * @returns A promise that resolves to the post metadata as a Post object, or is rejected if no post is found. + */ + async getPost(url) + { + let mapping = await this.#setupMapping(); + if (mapping) + { + let [ posts, subpath ] = mapping.getPostByURL(url); + return [ posts, subpath ]; + } + return []; + } + + /** + * Obtains the actual filename of a queried resource for a post, whether it be the cached HTML, an + * image, or something else. + * @param post - The post to query. It is assumed that the cache path and upload path are appropriate. + * @param subpath - The path determining which of the post's resources to query. + * @returns A path to a resource on the file system. + */ + resolveCachePath(post, subpath) + { + if (subpath.endsWith(".html")) + { + return path.join(post.cachePath, subpath); + } + else if (subpath.endsWith("metadata.json")) + { + return path.format({ + dir: path.dirname(post.uploadPath), + name: path.basename(post.uploadPath, path.extname(post.uploadPath)), + ext: "json" + }); + } + else if (subpath.substring(subpath.lastIndexOf("/") + 1).startsWith("orig")) + { + return post.uploadPath; + } + else if (subpath.endsWith("/") || subpath.indexOf(".") == -1) + { + return path.join(post.cachePath, "index.html"); + } + else + { + return path.join(post.cachePath, subpath); + } + } + + /** + * Obtains the file path of the HTML/asset file corresponding to a URL. This does not generate any entries in the cache, just finds out where to look for it. + * @param url - The url to request, relative to the base path. + * @returns A promise that resolves to an array containing the post object and the HTML file path. + */ + async getCachePath(url) + { + let mapping = await this.#setupMapping(); + if (mapping) + { + let [ posts, subpath ] = mapping.getPostByURL(url); + if (posts && posts.length) + { + return posts.map((post) => [ post, subpath, this.resolveCachePath(post, subpath) ]); + } + } + return []; + } + + /** + * Obtains the file path of the original blog post or asset file that corresponds to a URL. + * @param url - The url to request, relative to the base path. + * @returns A promise that resolves to an array containing the post object and blog post's file path. + */ + async getPostPath(url) + { + let mapping = await this.#setupMapping(); + if (mapping) + { + let [ posts, subpath ] = mapping.getPostByURL(url); + if (post) + { + if (url.endsWith("/")) + { + return posts.map((post) => [ post, subpath, post.uploadPath ]); + } + else + { + return posts.map((post) => [ post, subpath, path.dirname(post.uploadPath) + subpath ]); + } + } + } + return []; + } + + /** + * Gets a file descriptor for the URL. + * @param url - The url to read. + * @returns A promise that resolves to an array containing the post metdata, the file path, and a read-mode file descriptor. + */ + readURL(url) + { + return this.getCachePath(url).then((results) => { + let subpath; + if (!results || !results.length) + { + console.info(`Could not resolve URL ${url}`); + return Promise.resolve([null, null, null]); + } + else if (results.length > 1) + { + return [ results.map((result) => result[0]), results[0][2], null ]; + } + else + { + let [ post, subpath, cachePath ] = results[0]; + if (!post) + { + console.info(`Could not find post for URL ${url}`); + return Promise.resolve([null, null, null]); + } + let returnedPromise; + if (this.#postCalls.has(post) && !this.#postCalls.get(post).length) + { + try + { + return fs.access(cachePath).then(() => { + return fs.open(cachePath).then((fd) => { + return [ post, cachePath, fd ]; + }); + }, (e) => { + console.error(`Could not access path ${cachePath}`); + console.error(e); + return Promise.resolve([null, null, null]); + }); + } + catch (e) + { + return Promise.reject(e); + } + } + else + { + return new Promise((resolve, reject) => { + if (!this.#postCalls.has(post)) + { + this.#postCalls.set(post, []); + } + this.#postCalls.get(post).push([ resolve, reject ]); + if (this.#postCalls.get(post).length > 1) + { + /* + * If we somehow let another promise sneak callbacks before we did, return and let that one finish. + */ + return; + } + + this.#preparePost(post).then((destPath) => { + cachePath = this.resolveCachePath(post, subpath) + fs.access(cachePath).then(() => { + while (this.#postCalls.get(post).length) + { + let [ resolve, reject ] = this.#postCalls.get(post).shift(); + try + { + fs.open(cachePath).then((fd) => { + resolve([ post, cachePath, fd ]); + }, reject); + } + catch (e) + { + reject(e); + } + } + }, (e) => { + console.error(`Could not access path ${cachePath}`); + console.error(e); + while (this.#postCalls.get(post).length) + { + let [ resolve, reject ] = this.#postCalls.get(post).shift(); + resolve([null, null, null]); + } + }); + }, (e) => { + while (this.#postCalls.get(post).length) + { + let [ resolve, reject ] = this.#postCalls.get(post).shift(); + reject(e); + } + }); + }); + } + } + }); + } +} + +export { + Post, + PostMapping, + PostCall, +} diff --git a/src/blog_list.html b/src/blog_list.html new file mode 100644 index 0000000..d6c22b2 --- /dev/null +++ b/src/blog_list.html @@ -0,0 +1,165 @@ + + + + + + + + A Test + + + +
+

Blog Posts

+
+{list} + + diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..8a75e79 --- /dev/null +++ b/src/config.js @@ -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 +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..5b9d17f --- /dev/null +++ b/src/index.js @@ -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 +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..960268e --- /dev/null +++ b/src/main.js @@ -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(); +}); diff --git a/src/package.json b/src/package.json new file mode 100644 index 0000000..512af5f --- /dev/null +++ b/src/package.json @@ -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" + } +} diff --git a/src/requests.js b/src/requests.js new file mode 100644 index 0000000..8dea2a1 --- /dev/null +++ b/src/requests.js @@ -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('

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); + router.post("/refresh", this.refresh); + router.get("/verify", (request, response, next) => { + response.send('
'); + }); + router.post("/verify", this.multer.single("document"), this.verify); + router.get("/upload", (request, response, next) => { + response.send('
'); + }); + 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("

Success

Refresh Successful

"); + }).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("

Uploaded post.

"); + }).catch(next); + } + else + { + response.status(400).send("

HTTP 400 Error

No file present.

"); + } + } + + 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("

HTTP 400 Error

No file present.

"); + } + } + + 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("

No blog posts found.

"); + 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) => `
  • ${entry.title}
  • `).join('\n '); + listItems = ""; + 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; diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..a7c25fe --- /dev/null +++ b/src/utils.js @@ -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 +};