2025-07-15 02:10:26 +01:00

538 lines
25 KiB
JavaScript
Executable File

// 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 <br/> 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 "<a href="/tag/string1">string1</a>, <a href="/tag/string2">string2</a>, <a href="/tag/string3">string3</a>"
// 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 += `<a href="/tag/${tags[tag_index]}">${tags[tag_index]}</a>` // 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("%%", "&#37;")
.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", `<form method="POST" action="/submit_comment">
<input type="hidden" name="post_index" value="${post_index}">
<input placeholder="username" name="name"><br/>
<textarea placeholder="comment" name="content"></textarea><br/>
<button type="submit">Submit</button>
</form>`)
}
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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\\", "&#92;")
.replaceAll('"', "&#34;")
.replaceAll("'", "&#39;")
.replaceAll("/", "&#47;")
.replaceAll("%", "&#37;")
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, "<a href='#$1'>>> $1</a>")
.replaceAll(/&gt;&gt; ([0-9]*)/g, "<a href='#$1'>>> $1</a>")
.replaceAll("\n", "<br/>")
comment_content += `<div id="${comment['id']}">${comment['name']} ${unix_time_to_date_format(comment['pubdate'])} No. ${comment['id']}<br/>${comment['content']}</div><br/>`
}
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 = `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
<channel>
<title>${config.site_name}</title>
<link>${config.site_url}</link>
<description>${config.site_description}</description>
`
for (let i = posts.posts.length-1; i >= 0; i--) {
rss_content += `
<item>
<title>${posts.posts[i]["title"]}</title>
<link>${config.site_url}/post/${i}</link>
<description><![CDATA[${converter.makeHtml(posts.posts[i]["content"])}]]></description>
<guid isPermaLink="true">${config.site_url}/post/${i}</guid>
<pubDate>${unix_time_to_rss_date(posts.posts[i]['pubdate'])}</pubDate>`
for (let j = 0; j < posts.posts[i]['tags'].length; j++) {
rss_content += `<category><![CDATA[${posts.posts[i]['tags'][j]}]]></category>`
};
rss_content += "</item>"
}
rss_content += `
</channel>
</rss>`
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(`<html><head><meta charset="${config.charset}"><style>${config.css}</style></head><body><div id="header">${header_div}</div><div id="posts">${posts_div}</div></body><footer>${footer_div}</footer></html>`);
}); // /
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(`<html><head><meta charset="${config.charset}"><style>${config.css}</style></head><body><div id="header">${header_div}</div><div id="posts">${posts_div}</div></body><footer>${footer_div}</footer></html>`);
}); // /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(`<html><head><meta charset="${config.charset}"><style>${config.css}</style></head><body><div id="posts">${post_div}</div></body><footer>${footer_div}</footer></html>`);
}); // /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(`<html><style>${config.css}</style><body><div id="header">${header_div}</div><div id="posts">${page_content}</div></body><footer>${footer_div}</footer></html>`);
}); // /tag/:tag
app.get("/post", (req,res) => {
res.send(`</html><head><meta charset="${config.charset}"><style>${config.css}</style></head><form action="/submit_post" method="POST">
<input placeholder="username" required name="username"><br/>
<input placeholder="password" type="password" required id="password" name="password"><br/>
<input placeholder="title" required name="title"><br/>
<textarea placeholder="post content*" required name="content"></textarea><br/>
<input placeholder="Tags (comma seperated)" name="tags"><br/>
<input type="submit" value="Submit"><br/>
<small>* Markdown supported</small>
</form></html>`);
}); // /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(`</html><head><meta charset="${config.charset}"><style>${config.css}</style></head><form action="/submit_signup" method="POST">
<input placeholder="username" required name="username"><br/>
<input placeholder="prettyname" required name="prettyname"><br/>
<input placeholder="password" type="password" required id="password" name="password"><br/>
<textarea placeholder="description (social links, what you do etc), supports markdown" id="description" name="description"></textarea><br/>
<label>${config.signup_agreement}: </label><input type="checkbox" name="agreement" required><br/>
<input type="submit" value="Submit"><br/></form></html>`);
}
// if the server does not allow signup
else if (config.allow_signup == false) {
res.send(`</html><head><meta charset="${config.charset}"><style>${config.css}</style></head><body>${config.signups_unavailable}</body></html>`)
}
// 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(`</html><head><meta charset="${config.charset}"><style>${config.css}</style></head><form action="/submit_delete_account" method="POST">
<input placeholder="username" required name="username"><br/>
<input placeholder="password" type="password" required id="password" name="password"><br/>
<label>${config.delete_account_confirmation}: </label><input type="checkbox" name="agreement" required><br/>
<input type="submit" value="Submit"><br/></form></html>`);
}); // /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(`</html><head><meta charset="${config.charset}"><style>${config.css}</style></head>
<form action="/submit_edit" method="POST" onsubmit="sha512password()">
<input name="userID" type="hidden" value="${post['userID']}">
<input name="postID" type="hidden" value="${post_id}">
<input placeholder="${user['prettyname']}'s password" type="password" required id="password" name="password"><br/>
<input placeholder="title" value="${post['title']}" required name="title"><br/>
<textarea placeholder="content" required name="content">${post['content']}</textarea><br/>
<input placeholder="tags (comma seperated)" value="${post['tags']}" name="tags"><br/>
<label>Delete forever (no undo): </label><input name="delete" type="checkbox"><br/>
<input type="submit" value="Submit"><br/>
<small>* Markdown supported</small>
</form></html>`);
}); // /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(`</html><head><meta charset="${config.charset}"><style>${config.css}</style></head><body>${config.user_exists}</body></html>`)
}
}
else if (config.allow_signup == false) {
res.send(`</html><head><meta charset="${config.charset}"><style>${config.css}</style></head><body>${config.signups_unavailable}</body></html>`)
}
// 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(`</html><head><meta charset="${config.charset}"><style>${config.css}</style></head><body>${config.incorrect_password}</body></html>`)
};
}
else {
res.send(`</html><head><meta charset="${config.charset}"><style>${config.css}</style></head><body>${config.user_doesnt_exist}</body></html>`)
}
}); // /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}`);
});