// Get the libraries const fs = require('fs'); // For modifying and reading files const express = require('express'); // For running a webserver in nodejs const showdown = require('showdown') // For converting markdown to html on demand, https://showdownjs.com/ const crypto = require('crypto'); // For encrypting passwords, I use sha512 // fromUnixTime(): Create a date from a Unix timestamp (in seconds). Decimal values will be discarded. // format(): Return the formatted date string in the given format. The result may vary by locale. // getUnixTime(): Get the seconds timestamp of the given date. // find out more at https://date-fns.org/ \or docs: https://date-fns.org/docs/Getting-Startedyyyyyyyy const { fromUnixTime, format, getUnixTime } = require("date-fns") // A date utility library // There's only one possible argument, so we can just check if the user passed that one // TODO I plan on adding more such as --help and --post so I should make this more robust at some point if (process.argv[2] == "--first-time") { initialise() // Creates any files such users.js, posts.js, comments.js or config.js if they are not present } // Define the modules now so they are global let users // contains a list of users, each user is an object containing username,prettyname,hash and description let posts // contains a list of posts, let comments // contains a list of comments let config // contains a set of configuration for the site, see example-config.js for an example try { // We're going to try and import the modules, users = require('./users.js'); posts = require('./posts.js'); comments = require('./comments.js'); config = require('./config.js'); } catch (error) { // if they don't all import then // inform the user to pass --first-time and exit with an error code console.log("A file is missing!") console.log("Run with --first-time to initialise the program") console.log(error) process.exit(1) } // https://showdownjs.com/docs/available-options let converter = new showdown.Converter({ simpleLineBreaks: true, // Parse line breaks as
in paragraphs (GitHub-style behavior). tables: true, // Enable support for tables syntax. strikethrough: true, // Enable support for strikethrough: ~~text~~ tasklists: true, // Enable support for GitHub style tasklists. - [x] and - [ ] encodeEmails: true, //Enable automatic obfuscation of email addresses. emails are encoded via character entities headerLevelStart: 3, //Set starting level for the heading tags. }) // The footer div is globale because it's a site wide, so define it here let footer_div = config.site_wide_footer footer_div = replace_format_indicators(footer_div) // Define stuff to do with express (nodejs webserver) const app = express(); app.use(express.urlencoded({ extended: true })); app.use(express.json()); app.use(express.static(config.root_path)); // Initialise the program by creating users.js, comments.js, posts.js and config.js // All require default content in them to start off with // Then exit successfully // returns nothing function initialise() { try { const users = require("./users.js"); } catch (error) { console.log("Creating users file") fs.writeFileSync(`${__dirname}/users.js`, `export const users = []`) } try { const posts = require("./posts.js"); } catch (error) { console.log("Creating posts file") fs.writeFileSync(`${__dirname}/posts.js`, `export const posts = []`) } try { const comments = require("./comments.js"); } catch (error) { console.log("Creating comments file") fs.writeFileSync(`${__dirname}/comments.js`, `export const comments = []\nexport const counter = 0`) } try { const config = require("./config.js"); } catch (error) { console.log("Copying the example config to config.js") console.log("!!! PLEASE MODIFY config.js TO YOUR NEEDS !!!") fs.copyFile('example-config.js', 'config.js', (err) => { console.log("Error copying file") }) } console.log("Successfully initialised") process.exit(0) } // The users are stored as a list of objects [ user_object, user_object, user_object ] // So you cannot easily find the userID (position in list) from the username // This function returns the username for a given userID by looping over every user // if the user is present, it returns the index of the user (integer) // if the user is not present it returns -1 function get_userID(username) { for (let i = 0; i < users.users.length; i++) { // Loop over every user if (users.users[i]['username'] == username) { return i // If the username matches then return the index of that user } } return -1 // If user is not present, return -1 } // The configuration defines a date format using the date-fns (a datetime library) syntax // eg "yyyy-MM-dd" // this converts unix time (an integer) into a string that is formatted according to config.js // uses date-fns's fromUnixTime() and format() functions // returns the formatted date (string) function unix_time_to_date_format(unix_time) { date = fromUnixTime(unix_time) formatted_date = format(date, config.date_format) return formatted_date } // This is similar to the above function, however, instead of formatting to the users // configuration, it formats to RFC-822 which is the date format used by RSS feeds // eg "Mon, 23 May 2025 18:59:59 +0100" // accepts unix time (int) // returns the formatted date (string) function unix_time_to_rss_date(unix_time) { date = fromUnixTime(unix_time) formatted_date = format(date, "EEE, dd MMM yyyy HH:mm:ss") return `${formatted_date} ${config.time_zone}` } // This function accepts a list of strings eg ["string1","string2,"string3"] (any length) // then returns a string of them each pointing to a seperate url // eg "string1, string2, string3" // this is so you can have a list of tags that each point to their individual tag page // returns: string function hyperlink_tags(tags) { string = "" // Initialises the string for (let tag_index = 0; tag_index < tags.length; tag_index++) { // Loop over each tag string += `${tags[tag_index]}` // Adds the tag to the string as a HTML href if (tag_index < tags.length - 1) { // If there are more tags, then insert a comma string += ", "; } } return string } // See the readme format indicators section for a full list of format indicators // This function replaces the format indicators in a template to the content they represent // accepts the template (string), // the post index (int) as an optional paramter to indicate what post is to be used (for replacing things like content and titles of posts) // the tag (strig) as an optional parameter to indicate what tag is being used (for /tag/:tag pages) // the user index (int) is an optional parameter to indicate what user is to be used (for replacng things like the header of the user page) // returns the template with it's format indiactors replaced (string) function replace_format_indicators(template, post_index=-1, tag_name="tag", user_index=-1) { output_string = template // These should always be replaceable .replaceAll("%%", "%") .replaceAll("%J", "/delete_account") .replaceAll("%P", "/post") .replaceAll("%O", `/edit/${post_index}`) .replaceAll("%Q", "/signup") .replaceAll("%R", "/rss") .replaceAll("%Y", config.site_name) .replaceAll("%W", config.site_description) .replaceAll("%Z", config.attribution) .replaceAll("%S", config.seperator) if (post_index >= 0) { // These can only be replaced if a post is specified (by default the post id is -1) post_object = posts.posts[post_index] // Defines the post object for easy reference output_string = output_string .replaceAll("%A", (post_object["tags"])) .replaceAll("%B", (hyperlink_tags(post_object["tags"]))) .replaceAll("%C", converter.makeHtml(post_object["content"])) .replaceAll("%D", unix_time_to_date_format(post_object["pubdate"])) .replaceAll("%E", unix_time_to_date_format(post_object["editdate"])) .replaceAll("%F", users.users[post_object["userID"]]['prettyname']) .replaceAll("%G", tag_name) .replaceAll("%I", converter.makeHtml(users.users[post_object['userID']]['description'])) .replaceAll("%L", `/post/${post_index}`) .replaceAll("%M", return_comments(post_index)) .replaceAll("%N", users.users[post_object["userID"]]['username']) .replaceAll("%S", config.seperator) .replaceAll("%T", post_object["title"]) .replaceAll("%U", `/user/${users.users[post_object["userID"]]['username']}`) .replaceAll("%X", `


`) } if (user_index >= 0) { // these should only be replaced if a user is specified (by default the user id is -1) output_string = output_string .replaceAll("%F", users.users[user_index]['prettyname']) .replaceAll("%G", tag_name) .replaceAll("%I", converter.makeHtml(users.users[user_index]['description'])) .replaceAll("%L", `/post/${post_index}`) .replaceAll("%N", users.users[user_index]['username']) .replaceAll("%S", config.seperator) .replaceAll("%U", `/user/${users.users[user_index]['username']}`) } if (config.enable_hitcount == true) { // Finally, the hitcounter should only be replaced if config.enable_hitcount is true output_string = output_string .replaceAll("%H", fs.readFileSync('hitcount.txt')) } return output_string } // This escapes some potentially dangerous HTML characters with their HTML entities // https://www.freeformatter.com/html-entities.html // accepts a string // returns a string with some character replaced by their entities function escape_input(input) { let output = input .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll("\\", "\") .replaceAll('"', """) .replaceAll("'", "'") .replaceAll("/", "/") .replaceAll("%", "%") return output } // TODO make the formatting customisable function return_comments(post_id) { const post_comments = comments.comments[post_id] let comment_content = "" for (let comment_index = 0; comment_index < post_comments.length; comment_index++) { let comment = {...post_comments[comment_index]}; comment['content'] = comment['content'] .replaceAll(/>> ([0-9]*)/g, ">> $1") .replaceAll(/>> ([0-9]*)/g, ">> $1") .replaceAll("\n", "
") comment_content += `
${comment['name']} ${unix_time_to_date_format(comment['pubdate'])} No. ${comment['id']}
${comment['content']}

` } return comment_content } // RSS protocol gets app.get(config.rss_path, (req,res) => { if (config.rss == false) { res.send("Sorry, RSS is disabled!") } else { let rss_content = ` ${config.site_name} ${config.site_url} ${config.site_description} ` for (let i = posts.posts.length-1; i >= 0; i--) { rss_content += ` ${posts.posts[i]["title"]} ${config.site_url}/post/${i} ${config.site_url}/post/${i} ${unix_time_to_rss_date(posts.posts[i]['pubdate'])}` for (let j = 0; j < posts.posts[i]['tags'].length; j++) { rss_content += `` }; rss_content += "" } rss_content += ` ` res.setHeader('content-type', 'application/rss+xml'); res.send(rss_content) }; }); app.get("/", (req,res) => { if (config.enable_hitcount) { let hitcount = parseInt(fs.readFileSync('hitcount.txt')) hitcount += 1 console.log(`/ Is loaded, hitcount: ${hitcount}`) fs.writeFileSync(`${__dirname}/hitcount.txt`, `${hitcount}`, 'utf-8'); } header_div = config.timeline_header header_div = replace_format_indicators(header_div); posts_div = ""; counter = posts.posts.length - 1; while ((counter >= 0) && (counter > (posts.posts.length - (config.timeline_length + 1)))) { let post = config.timeline_post_format; posts_div += replace_format_indicators(post, counter); counter -= 1; } res.send(`
${posts_div}
`); }); // / app.get("/user/:username", (req, res) => { header_div = config.user_page_header header_div = replace_format_indicators(header_div,-1,"tag",get_userID(req.params.username)) posts_div = ""; for (let post_index = posts.posts.length-1; post_index >= 0; post_index--) { if (users.users[posts.posts[post_index]["userID"]]["username"] == req.params.username) { let post = config.user_post_format; posts_div += replace_format_indicators(post, post_index); } } res.send(`
${posts_div}
`); }); // /user/:username app.get("/post/:post_index", (req, res) => { post_div = ""; let post = config.post_page_format; post_div += replace_format_indicators(post, req.params.post_index); res.send(`
${post_div}
`); }); // /post/:post_index app.get("/tag/:tag", (req,res) => { const tag = req.params.tag let header_div = config.tag_page_header header_div = replace_format_indicators(header_div,0,tag) let page_content = "" for (let i = posts.posts.length-1; i >= 0; i--) { if (posts.posts[i]['tags'].includes(tag)) { let post = config.tag_post_format; page_content += replace_format_indicators(post, i); }; }; res.send(`
${page_content}
`); }); // /tag/:tag app.get("/post", (req,res) => { res.send(`






* Markdown supported
`); }); // /post app.get("/signup", (req,res) => { // if the server does allow signup if (config.allow_signup == true) { // Send the page for signing up to the server res.send(`






`); } // if the server does not allow signup else if (config.allow_signup == false) { res.send(`${config.signups_unavailable}`) } // If allow_signup is undefined or not a boolean, error else { res.redirect(301,"/") console.log("Error, invalid value for allow_signup (bool)") } }); // /signup app.get("/delete_account", (req,res) => { res.send(`




`); }); // /delete_account app.get("/edit/:post_id", (req,res) => { const post_id = req.params.post_id const post = posts.posts[post_id] const user = users.users[post['userID']] res.send(`






* Markdown supported
`); }); // /edit/:post_id app.post("/submit_comment", (req,res) => { const unix_timestamp = getUnixTime(new Date()) let name = escape_input(req.body.name) if (name == "") { name = config.default_username } new_comment = { "name": name, "content": escape_input(req.body.content), "id": comments.counter, "pubdate": unix_timestamp }; let counter = comments.counter+1; comments.comments[req.body.post_index].push(new_comment); fs.writeFileSync(`${__dirname}/comments.js`, `export const comments = ${JSON.stringify(comments.comments)}\nexport const counter = ${counter}`, 'utf-8'); res.redirect(301,`/post/${req.body.post_index}`) }); // /submit_comment app.post("/submit_post", (req,res) => { const password = crypto.createHash('sha512').update(req.body.password).digest('hex'); const username = escape_input(req.body.username) const title = escape_input(req.body.title) const content = escape_input(req.body.content) const tags = escape_input(req.body.tags).split(','); const unix_timestamp = getUnixTime(new Date()) console.log(username, "is submitting a post titled:", title); if (get_userID(username) == -1) { res.send("User does not exist") } else if (users.users[get_userID(username)]['hash'] == password) { // Password matches posts.posts.push({ "userID": get_userID(username), "title": title, "content": content, "pubdate": unix_timestamp, "editdate": unix_timestamp, "tags": tags, }) fs.writeFileSync(`${__dirname}/posts.js`, `export const posts = ${JSON.stringify(posts.posts)}`, 'utf-8'); comments.comments.push([]) fs.writeFileSync(`${__dirname}/comments.js`, `export const comments = ${JSON.stringify(comments.comments)}\nexport const counter = ${comments.counter}`) res.redirect(302, "/"); } else { res.send(`Invalid Password for user`,username); } }); // /submit_post app.post("/submit_signup", (req,res) => { const password = crypto.createHash('sha512').update(req.body.password).digest('hex'); const username = escape_input(req.body.username) const prettyname = escape_input(req.body.prettyname) const description = escape_input(req.body.description) // Check that signups are allowed if (config.allow_signup == true) { // get_userID will return -1 if the user does not exist // so this checks that the user does not exist if (get_userID(username) == -1) { users.users.push({ "username": username, "prettyname": prettyname, "hash": password, "description": description, }) fs.writeFileSync(`${__dirname}/users.js`, `export const users = ${JSON.stringify(users.users)}`, 'utf-8'); res.redirect(301, `/user/${username}`) } // if the user does exist then else { res.send(`${config.user_exists}`) } } else if (config.allow_signup == false) { res.send(`${config.signups_unavailable}`) } // If allow_signup is undefined or not a boolean, error else { res.redirect(301,"/") console.log("Error, invalid value for allow_signup (bool)") } }); // /submit_signup app.post("/submit_delete_account", (req,res) => { // Get the form info const password = crypto.createHash("sha512").update(req.body.password).digest("hex"); const username = escape_input(req.body.username) // get the userID const userID = get_userID(username) if (userID >= 0) { // The user exists if (password == users.users[userID]['hash']) { // password matches console.log(username, "(userID:", userID, ") is trying deleting their account") // Delete the user users.users.splice(userID,1) // Delete all their posts for (let postid = 0; postid < posts.posts.length; postid++) { // loop over all posts if (posts.posts[postid]['userID'] == userID) { // if userID matches posts.posts.splice(postid,1) // delete the post comments.comments.splice(postid,1) // the comments for this post should also be delete } }; // Write these changes fs.writeFileSync(`${__dirname}/users.js`, `export const users = ${JSON.stringify(users.users)}`, 'utf-8'); fs.writeFileSync(`${__dirname}/posts.js`, `export const posts = ${JSON.stringify(posts.posts)}`, 'utf-8'); fs.writeFileSync(`${__dirname}/comments.js`, `export const comments = ${JSON.stringify(comments.comments)}\nexport const counter = ${comments.counter}`, 'utf-8'); res.redirect(301,"/") } else { // password does not match res.send(`${config.incorrect_password}`) }; } else { res.send(`${config.user_doesnt_exist}`) } }); // /submit_delete_account app.post("/submit_edit", (req,res) => { const password = crypto.createHash('sha512').update(req.body.password).digest('hex'); const postID = req.body.postID const userID = req.body.userID const title = req.body.title const content = req.body.content const tags = req.body.tags.split(','); const delete_bool = req.body.delete const unix_timestamp = getUnixTime(new Date()) console.log(users.users[userID]['prettyname'], "is editting the post titled:", title); if (users.users[userID]['hash'] == password) { // password matches let post = posts.posts[postID] post['title'] = title post['content'] = content post['tags'] = tags post['editdate'] = unix_timestamp if (typeof delete_bool != "undefined") { console.log("Deleting post!") posts.posts.splice(postID,1) comments.comments.splice(postID,1) fs.writeFileSync(`${__dirname}/comments.js`, `export const comments = ${JSON.stringify(comments.comments)}\nexport const counter = ${comments.counter}`, 'utf-8'); } fs.writeFileSync(`${__dirname}/posts.js`, `export const posts = ${JSON.stringify(posts.posts)}`, 'utf-8'); res.redirect(302, "/"); } else { res.send(`Invalid Password for user`,users.users[userID]['prettyname']); } }); // /submit_edit app.listen(config.port, () => { console.log(`Server is running at http://localhost:${config.port} in ${config.root_path}`); });