diff --git a/README.md b/README.md index 778e86c..f113d51 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Please don't use this yet, it's not finished
See the software in action: [deadvey.com](https://deadvey.com)
# Confiuration -Read the [configuation guide](CONFIG.md) for configuration help (in config.json) +Read the [configuation guide](docs/CONFIG.md) for configuration help (in config.json) # Features * post creation via the web frontend (no need to remote to your server to make a post) @@ -18,23 +18,21 @@ Read the [configuation guide](CONFIG.md) for configuration help (in config.json) * Commenting on posts * sign up and delete account * ejs +* custom CSS _file_ # Bugs * probably scales like shit * probably insecure as hell # Planned features/todo list -* custom CSS _file_ -* custom strings use format indicators -* seperate functions into modules +* URGENT give each post and user a hard postID to prevent potential issues +* edit user (could be on instead of the delete_account page) * user specific RSS feeds * atom * federation (looks tricky) * All strings (including in edit and post page) customisable * formatable custom strings -* split code into files to tidy it up a bit * inline comments and docs -* give each post a hard postID to prevent potential issues * clean up code a bit * comment pages? diff --git a/app.js b/app.js deleted file mode 100755 index 4006924..0000000 --- a/app.js +++ /dev/null @@ -1,552 +0,0 @@ -// 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) -}); diff --git a/custom.css b/custom.css deleted file mode 120000 index ecac342..0000000 --- a/custom.css +++ /dev/null @@ -1 +0,0 @@ -example-custom.css \ No newline at end of file diff --git a/data/example-config.json b/data/example-config.json new file mode 100755 index 0000000..6f0005c --- /dev/null +++ b/data/example-config.json @@ -0,0 +1,33 @@ +{ + "seperator": "
", + "site_name": "My Blog", + "site_url": "https://example.com", + "language": "en", + "port": 8080, + "allow_signup": true, + "site_description": "Read my blogs!", + "timeline_length": 20, + "enable_hitcount": true, + "charset": "UTF-8", + "root_path": "/home/deadvey/code/web/blogger-webroot/", + "delete_account_url": "/delete_account", + "new_post_url": "/post", + "signup_url": "/signup", + "edit_post_base_url": "/edit", + "default_comenter_username": "Anon", + "rss": true, + "rss_url": "/rss", + "date_format": "yyyy-MM-dd", + "time_zone": "+0000", + "string": { + "signup_agreement": "I agree to not post illegal or hateful content", + "signups_unavailable": "Sorry, this server does not allow signups", + "user_exists": "Sorry, this user already exists, try a different username", + "user_doesnt_exist": "Sorry, this user does not exist", + "delete_account_confirmation": "I agree that my account and all of my posts will be permanently deleted instantly", + "incorrect_password": "Incorrect Password", + "rss_disabled": "Sorry, RSS is disabled", + "attribution": "Powered by blogger-nodejs: Source Code, license (WTFPL)" + }, + "css": "" +} diff --git a/package.json b/data/package.json similarity index 100% rename from package.json rename to data/package.json diff --git a/CONFIG.md b/docs/CONFIG.md similarity index 96% rename from CONFIG.md rename to docs/CONFIG.md index 2621703..1b40035 100755 --- a/CONFIG.md +++ b/docs/CONFIG.md @@ -46,7 +46,8 @@ Read more at [date-fns](https://date-fns.org/v4.1.0/docs/format)
## Advanced Customisation * /views/* files are EJS files (used for formatting HTML) and can be editted to your liking, you might want to read [the EJS docs](https://ejs.co/#docs) for help. * "css": "body { background: red; }"
- String. Custom CSS to be applied to all pages, if you want more complex css, you can edit custom.css. + String. Custom CSS to be applied to all pages, if you want more complex css, you can edit custom.css.
+ You can also edit the custom.css file in the webroot, as by default this is linked in the global header. ## Custom Strings All of these values are of type String diff --git a/ejs-templates.js b/ejs-templates.js deleted file mode 100644 index c893a17..0000000 --- a/ejs-templates.js +++ /dev/null @@ -1 +0,0 @@ -export let timeline = '
${posts_div}
' diff --git a/example-config.json b/example-config.json index e2ed85b..f61ab21 100755 --- a/example-config.json +++ b/example-config.json @@ -2,13 +2,14 @@ "seperator": "
", "site_name": "My Blog", "site_url": "https://example.com", + "language": "en", "port": 8080, "allow_signup": true, "site_description": "Read my blogs!", "timeline_length": 20, "enable_hitcount": true, "charset": "UTF-8", - "root_path": "/path/to/root/of/website", + "root_path": "/path/to/blogger-webroot", "delete_account_url": "/delete_account", "new_post_url": "/post", "signup_url": "/signup", @@ -18,20 +19,15 @@ "rss_url": "/rss", "date_format": "yyyy-MM-dd", "time_zone": "+0000", - "timeline_header": "

%Y

%W

Create Post
RSS Feed
Sign Up
Delete Account
Hit count: %H%S", - "user_page_header": "

%F's posts:

%I%S", - "tag_page_header": "

Posts tagged: %G

%S", - "user_post_format": "

%T

%C

%B
Permalink
%X%M%S", - "post_page_format": "

%T

%C

%B
By %N
Edit Post
Posted: %D
Edited: %E%S%X%M%S", - "timeline_post_format": "

%T

%C

Permalink
By %N%X%M%S", - "tag_post_format": "

%T

%C

%B
Permalink
By %N%S", - "site_wide_footer": "Site is ran by DeaDvey
%Z", - "signup_agreement": "I agree to not post illegal or hateful content", - "signups_unavailable": "Sorry, this server does not allow signups", - "user_exists": "Sorry, this user already exists, try a different username", - "user_doesnt_exist": "Sorry, this user does not exist", - "delete_account_confirmation": "I agree that my account and all of my posts will be permanently deleted instantly", - "incorrect_password": "Incorrect Password", - "css": "", - "attribution": "Powered by blogger-nodejs: Source Code, license (WTFPL)" + "string": { + "signup_agreement": "I agree to not post illegal or hateful content", + "signups_unavailable": "Sorry, this server does not allow signups", + "user_exists": "Sorry, this user already exists, try a different username", + "user_doesnt_exist": "Sorry, this user does not exist", + "delete_account_confirmation": "I agree that my account and all of my posts will be permanently deleted instantly", + "incorrect_password": "Incorrect Password", + "rss_disabled": "Sorry, RSS is disabled", + "attribution": "Powered by blogger-nodejs: Source Code, license (WTFPL)" + }, + "css": "" } diff --git a/example-custom.css b/example-custom.css deleted file mode 100644 index 5e2752a..0000000 --- a/example-custom.css +++ /dev/null @@ -1,3 +0,0 @@ -body { - /* Put your custom css here */ -} diff --git a/functions.js b/functions.js deleted file mode 100644 index 46a182a..0000000 --- a/functions.js +++ /dev/null @@ -1,10 +0,0 @@ -// 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) -export function unix_time_to_date_format(unix_time, format) { - date = fromUnixTime(unix_time) - formatted_date = format(date, format) - return formatted_date -} diff --git a/src/functions.js b/src/functions.js new file mode 100644 index 0000000..cc5f6e8 --- /dev/null +++ b/src/functions.js @@ -0,0 +1,71 @@ +import { createRequire } from 'module'; +const require = createRequire(import.meta.url) + +// 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) +export function unix_time_to_date_format(unix_time) { + const { fromUnixTime, format, getUnixTime } = require("date-fns") // A date utility library + const config = require("../config.json") + let date = fromUnixTime(unix_time) + let 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) +export function unix_time_to_rss_date(unix_time) { + const { fromUnixTime, format, getUnixTime } = require("date-fns") // A date utility library + const config = require("../config.json") + let date = fromUnixTime(unix_time) + let 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 +export function hyperlink_tags(tags) { + let 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 +} +// 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 +export function get_userID(username) { + const users = require("../data/users.json") + 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 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 +export function escape_input(input) { + let output = input + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\\", "\") + .replaceAll('"', """) + .replaceAll("'", "'") + .replaceAll("/", "/") + .replaceAll("%", "%") + return output +} diff --git a/src/initialise.js b/src/initialise.js new file mode 100644 index 0000000..6bc1f21 --- /dev/null +++ b/src/initialise.js @@ -0,0 +1,39 @@ +// 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("../data/users.js"); + } + catch (error) { + console.log("Creating users file") + fs.writeFileSync(`../data/users.json`, `{\n"users": []\n}`) + } + try { + const posts = require("../data/posts.json"); + } + catch (error) { + console.log("Creating posts file") + fs.writeFileSync(`../data/posts.json`, `{\n"posts": []\n}`) + } + try { + const comments = require("../data/comments.json"); + } + catch (error) { + console.log("Creating comments file") + fs.writeFileSync(`../data/comments.json`, `{\n"comments": [],\n"counter": 0}`) + } + try { + const config = require("../data/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) +} diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..f653a12 --- /dev/null +++ b/src/server.js @@ -0,0 +1,389 @@ +// 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") +const init = require("./initialise.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") { + init.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('../data/users.json'); + posts = require('../data/posts.json'); + comments = require('../data/comments.json'); + config = require('../config.json'); +} +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. +}) + +// 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'); +app.set('views', '../views') + +////////////////////// SYNDICATION //////////////////////// +// RSS protocol gets +app.get(config.rss_url, (req,res) => { + if (config.rss == false) { + res.render("partials/message", { + message: config.string.rss_disabled, + config: config, + }) + } + 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} + ${func.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) + }; +}); + + +///////////////////// Standard Pages ////////////////////// +app.get("/", (req,res) => { + // Increment the hitcount + if (config.enable_hitcount) { + let hitcount = parseInt(fs.readFileSync('../data/hitcount.txt')) + hitcount += 1 + console.log(`/ Is loaded, hitcount: ${hitcount}`) + fs.writeFileSync(`../data/hitcount.txt`, `${hitcount}`, 'utf-8'); + } + + res.render("pages/timeline", + { + config, + posts, + users, + comments: comments.comments, + hitcount: fs.readFileSync("../data/hitcount.txt"), + fromUnixTime, + format, + getUnixTime, + func, + }) +}); // / +app.get("/user/:username", (req, res) => { + const userID = func.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, + func, + }) +}); // /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, + func, + }) +}); // /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, + func, + }) +}); // /tag/:tag + + +///////////////////// Form pages //////////////////////////// +app.get(config.new_post_url, (req,res) => { + res.render("forms/new_post", { + config + }); +}); // /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.render("forms/signup", { + config + }); + } + // if the server does not allow signup + else if (config.allow_signup == false) { + res.render("partials/message", { + message: config.string.signups_unavailable, + config, + }) + } + // 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.render("forms/delete_account", { config }); +}); // /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.render("forms/edit_post", { + config, + post, + post_id, + user, + }); +}); // /edit/:post_id + + +////////////////////// Form actions ///////////////////////// +app.post("/submit_comment", (req,res) => { + const unix_timestamp = getUnixTime(new Date()) + let name = func.escape_input(req.body.name) + if (name == "") { + name = config.default_commenter_username + } + new_comment = { + "name": name, + "content": func.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(`../data/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 = func.escape_input(req.body.username) + const title = func.escape_input(req.body.title) + const content = func.escape_input(req.body.content) + const tags = func.escape_input(req.body.tags).split(','); + const unix_timestamp = getUnixTime(new Date()) + + if (func.get_userID(username) == -1) { + res.render("partials/message", { + message: config.string.user_doesnt_exit, + config, + }) + } + + else if (users[func.get_userID(username)]['hash'] == password) { // Password matches + console.log(username, "is submitting a post titled:", title); + posts.push({ + "userID": func.get_userID(username), + "title": title, + "content": content, + "pubdate": unix_timestamp, + "editdate": unix_timestamp, + "tags": tags, + }) + fs.writeFileSync(`../data/posts.json`, `${JSON.stringify(posts)}`, 'utf-8'); + comments.comments.push([]) + fs.writeFileSync(`../data/comments.json`, `${JSON.stringify(comments)}`) + res.redirect(302, "/"); + } + else { + res.render("partials/message", { + message: config.string.incorrect_password, + config, + }) + } +}); // /submit_post +app.post("/submit_signup", (req,res) => { + const password = crypto.createHash('sha512').update(req.body.password).digest('hex'); + const username = func.escape_input(req.body.username) + const prettyname = func.escape_input(req.body.prettyname) + const description = func.escape_input(req.body.description) + + // Check that signups are allowed + if (config.allow_signup == true) { + // func.get_userID will return -1 if the user does not exist + // so this checks that the user does not exist + if (func.get_userID(username) == -1) { + users.push({ + "username": username, + "prettyname": prettyname, + "hash": password, + "description": description, + }) + fs.writeFileSync(`../data/users.json`, `${JSON.stringify(users)}`, 'utf-8'); + res.redirect(301, `/user/${username}`) + } + // if the user does exist then + else { + res.render("partials/message", { + message: config.string.user_exists, + config, + }) + } + } + else if (config.allow_signup == false) { + res.render("partials/message", { + message: config.string.signups_unavailable, + config, + }) + } + // 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 = func.escape_input(req.body.username) + // get the userID + const userID = func.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(`../data/users.json`, `${JSON.stringify(users)}`, 'utf-8'); + fs.writeFileSync(`../data/posts.json`, `${JSON.stringify(posts)}`, 'utf-8'); + fs.writeFileSync(`../data/comments.json`, `${JSON.stringify(comments.comments)}\nexport const counter = ${comments.counter}`, 'utf-8'); + res.redirect(301,"/") + } + else { // password does not match + res.render("partials/message", { + message: config.string.incorrect_password, + config + } + ) + }; + } + else { + res.render("partials/message", { + message: config.string.user_doesnt_exist, + config, + }) + } +}); // /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(`../data/comments.json`, `${JSON.stringify(comments.comments)}\nexport const counter = ${comments.counter}`, 'utf-8'); + } + fs.writeFileSync(`../data/posts.json`, `${JSON.stringify(posts)}`, 'utf-8'); + res.redirect(302, "/"); + } + else { + res.render("partials/message", { + message: config.string.incorrect_password, + config, + }) + } +}); // /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) +}); diff --git a/views/forms/delete_account.ejs b/views/forms/delete_account.ejs new file mode 100644 index 0000000..b65ce4d --- /dev/null +++ b/views/forms/delete_account.ejs @@ -0,0 +1,14 @@ + + + + +
+
+
+
+
+
+ + diff --git a/views/forms/edit_post.ejs b/views/forms/edit_post.ejs new file mode 100644 index 0000000..a2ac191 --- /dev/null +++ b/views/forms/edit_post.ejs @@ -0,0 +1,19 @@ + + + + <%- include("../partials/head") %> + + +
+ + +
+
+
+
+
+
+ * Markdown supported +
+ + diff --git a/views/forms/new_post.ejs b/views/forms/new_post.ejs new file mode 100644 index 0000000..465eb9a --- /dev/null +++ b/views/forms/new_post.ejs @@ -0,0 +1,16 @@ + + + + <%- include('../partials/head.ejs') %> + + +
+
+
+
+
+
+
+ * Markdown supported + +
diff --git a/views/forms/signup.ejs b/views/forms/signup.ejs new file mode 100644 index 0000000..b56c328 --- /dev/null +++ b/views/forms/signup.ejs @@ -0,0 +1,16 @@ + + + + <%- include("../partials/head") %> + + +
+
+
+
+
+
+
+
+ + diff --git a/views/headers/timeline.ejs b/views/headers/timeline.ejs index 159fe9e..ac93ec0 100644 --- a/views/headers/timeline.ejs +++ b/views/headers/timeline.ejs @@ -5,6 +5,7 @@ <%- config.site_description %> RSS Feed
+New post
Sign Up
Delete Account
<% if (config.enable_hitcount == true) { %> diff --git a/views/partials/comment.ejs b/views/partials/comment.ejs index 7f94274..4354b1d 100644 --- a/views/partials/comment.ejs +++ b/views/partials/comment.ejs @@ -1,2 +1,2 @@ -<%= comment.name %> <%= format(fromUnixTime(comment.pubdate), config.date_format) %> No. <%= comment.id %>:
+<%= comment.name %> <%= func.unix_time_to_date_format(comment.pubdate) %> No. <%= comment.id %>:
<%= comment.content %> diff --git a/views/partials/footer.ejs b/views/partials/footer.ejs index 8710c02..0055b91 100644 --- a/views/partials/footer.ejs +++ b/views/partials/footer.ejs @@ -1,2 +1,2 @@ Site is ran by deadvey
-<%- config.attribution %> +<%- config.string.attribution %> diff --git a/views/partials/head.ejs b/views/partials/head.ejs index 9d29ced..f29bc9e 100644 --- a/views/partials/head.ejs +++ b/views/partials/head.ejs @@ -5,4 +5,4 @@ - + diff --git a/views/partials/message.ejs b/views/partials/message.ejs new file mode 100644 index 0000000..a8c76d0 --- /dev/null +++ b/views/partials/message.ejs @@ -0,0 +1,10 @@ + +"><%= user.username %>

-<%- hyperlink_tags(post.tags) %>
+<%- func.hyperlink_tags(post.tags) %>
Edit
-Published: <%= format(fromUnixTime(post.pubdate), config.date_format) %>
-Last Modified: <%= format(fromUnixTime(post.editdate), config.date_format) %>
+Published: <%= func.unix_time_to_date_format(post.pubdate) %>
+Last Modified: <%= func.unix_time_to_date_format(post.pubdate) %>
<%- config.seperator %> diff --git a/views/posts/tag.ejs b/views/posts/tag.ejs index e1ea9c7..9b468fb 100644 --- a/views/posts/tag.ejs +++ b/views/posts/tag.ejs @@ -3,7 +3,7 @@ <%= post.content %>
Permalink
-<%- hyperlink_tags(post.tags) %> +<%- func.hyperlink_tags(post.tags) %>