// 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-Started const { fromUnixTime, format, getUnixTime } = require("date-fns") // A date utility library const ejs = require("ejs") const func = require("./functions.js") // 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 let ejs_templates try { // We're going to try and import the modules, users = require('./users.json'); posts = require('./posts.json'); comments = require('./comments.json'); config = require('./config.json'); ejs_templates = require("./ejs-templates.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)); // set the view engine to ejs app.set('view engine', 'ejs'); // 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.json`, `{\n"users": []\n}`) } try { const posts = require("./posts.json"); } catch (error) { console.log("Creating posts file") fs.writeFileSync(`${__dirname}/posts.json`, `{\n"posts": []\n}`) } try { const comments = require("./comments.json"); } catch (error) { console.log("Creating comments file") fs.writeFileSync(`${__dirname}/comments.json`, `{\n"comments": [],\n"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.length; i++) { // Loop over every user if (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 } // 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[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[post_object["userID"]]['prettyname']) .replaceAll("%G", tag_name) .replaceAll("%I", converter.makeHtml(users[post_object['userID']]['description'])) .replaceAll("%L", `/post/${post_index}`) .replaceAll("%M", return_comments(post_index)) .replaceAll("%N", users[post_object["userID"]]['username']) .replaceAll("%S", config.seperator) .replaceAll("%T", post_object["title"]) .replaceAll("%U", `/user/${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[user_index]['prettyname']) .replaceAll("%G", tag_name) .replaceAll("%I", converter.makeHtml(users[user_index]['description'])) .replaceAll("%L", `/post/${post_index}`) .replaceAll("%N", users[user_index]['username']) .replaceAll("%S", config.seperator) .replaceAll("%U", `/user/${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_url, (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.length-1; i >= 0; i--) { rss_content += ` ${posts[i]["title"]} ${config.site_url}/post/${i} ${config.site_url}/post/${i} ${unix_time_to_rss_date(posts[i]['pubdate'])}` for (let j = 0; j < 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) => { // Increment the hitcount 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'); } res.render("pages/timeline", { config: config, posts: posts, users: users, comments: comments.comments, hitcount: fs.readFileSync("hitcount.txt"), fromUnixTime: fromUnixTime, format: format, getUnixTime: getUnixTime, hyperlink_tags: hyperlink_tags, }) }); // / app.get("/user/:username", (req, res) => { const userID = get_userID(req.params.username) console.log(userID) console.log(users[userID].prettyname) res.render("pages/user", { config: config, posts: posts, user: users[userID], userID: userID, comments: comments.comments, fromUnixTime: fromUnixTime, format: format, getUnixTime: getUnixTime, hyperlink_tags: hyperlink_tags, }) }); // /user/:username app.get("/post/:post_index", (req, res) => { const postID = req.params.post_index res.render("pages/post", { config, post: posts[postID], postID: postID, user: users[posts[postID].userID], comments: comments.comments[postID], fromUnixTime, format, getUnixTime, hyperlink_tags, }) }); // /post/:post_index app.get("/tag/:tag", (req,res) => { const tag = req.params.tag res.render("pages/tag", { config: config, tag: tag, posts: posts, users: users, comments: comments.comments, fromUnixTime: fromUnixTime, format: format, getUnixTime: getUnixTime, hyperlink_tags: hyperlink_tags, }) }); // /tag/:tag app.get(config.new_post_url, (req,res) => { res.send(`






* Markdown supported
`); }); // /post app.get(config.signup_url, (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(config.delete_account_url, (req,res) => { res.send(`




`); }); // /delete_account app.get(`${config.edit_post_base_url}/:post_id`, (req,res) => { const post_id = req.params.post_id const post = posts[post_id] const user = 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_commenter_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.json`, `${JSON.stringify(comments)}`, '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()) if (get_userID(username) == -1) { res.send(`${config.user_doesnt_exit}`) } else if (users[get_userID(username)]['hash'] == password) { // Password matches console.log(username, "is submitting a post titled:", title); posts.push({ "userID": get_userID(username), "title": title, "content": content, "pubdate": unix_timestamp, "editdate": unix_timestamp, "tags": tags, }) fs.writeFileSync(`${__dirname}/posts.json`, `${JSON.stringify(posts)}`, 'utf-8'); comments.comments.push([]) fs.writeFileSync(`${__dirname}/comments.json`, `${JSON.stringify(comments)}`) res.redirect(302, "/"); } else { res.send(`${config.incorrect_password}`) } }); // /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.push({ "username": username, "prettyname": prettyname, "hash": password, "description": description, }) fs.writeFileSync(`${__dirname}/users.json`, `${JSON.stringify(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[userID]['hash']) { // password matches console.log(username, "(userID:", userID, ") is trying deleting their account") // Delete the user users.splice(userID,1) // Delete all their posts for (let postid = 0; postid < posts.length; postid++) { // loop over all posts if (posts[postid]['userID'] == userID) { // if userID matches 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.json`, `${JSON.stringify(users)}`, 'utf-8'); fs.writeFileSync(`${__dirname}/posts.json`, `${JSON.stringify(posts)}`, 'utf-8'); fs.writeFileSync(`${__dirname}/comments.json`, `${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[userID]['prettyname'], "is editting the post titled:", title); if (users[userID]['hash'] == password) { // password matches let post = 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.splice(postID,1) comments.comments.splice(postID,1) fs.writeFileSync(`${__dirname}/comments.json`, `${JSON.stringify(comments.comments)}\nexport const counter = ${comments.counter}`, 'utf-8'); } fs.writeFileSync(`${__dirname}/posts.json`, `${JSON.stringify(posts)}`, 'utf-8'); res.redirect(302, "/"); } else { res.send(`Invalid Password for user`,users[userID]['prettyname']); } }); // /submit_edit app.listen(config.port, () => { console.log(`Server is running at http://localhost:${config.port} webroot: ${config.root_path}`); console.log("Running in: ", __dirname) });