gb/gb.js

1const config = { 2 port: process.env.GB_PORT || 9080, 3 basePath: process.env.GB_BASE_PATH || "/gb", 4 debug: process.env.GB_DEBUG === "1" || false, 5}; 6 7const http = require("http"); 8const { readFile, appendFile } = require("fs/promises"); 9const { randomUUID } = require("crypto"); 10const querystring = require("querystring"); 11 12// -- muh security? 13const sessionTokens = {}; // 'token': 'expiresOn' 14const isSessionTokenValid = (token) => { 15 return Boolean(sessionTokens[token]); 16}; 17const clearSessionToken = (token) => { 18 delete sessionTokens[token]; 19}; 20const newSessionToken = () => { 21 const count = Object.keys(sessionTokens).length; 22 if (count < 100) { 23 // 100 tokens max 24 const token = randomUUID(); 25 const now = new Date(); 26 const d15MinsLater = new Date(now.getTime() + 60 * 15 * 1000); 27 sessionTokens[token] = d15MinsLater; 28 return token; 29 } 30 return "me get hacked?"; 31}; 32const updateSessionTokenExpiry = (token) => { 33 const now = new Date(); 34 const d15MinsLater = new Date(now.getTime() + 60 * 15 * 1000); 35 sessionTokens[token] = d15MinsLater; 36}; 37const clearExpiredTokens = () => { 38 for (const [key, value] of Object.entries(sessionTokens)) { 39 const expiry = new Date(value); 40 const now = new Date(); 41 if (expiry < now) { 42 delete sessionTokens[key]; 43 } 44 } 45}; 46// ----------------------- 47 48// converts cookie string into an object 49const parseCookies = (cookieHeader) => { 50 const cookies = {}; 51 if (!cookieHeader) return cookies; 52 53 cookieHeader.split(";").forEach((cookie) => { 54 const parts = cookie.split("="); 55 const name = parts.shift().trim(); 56 const value = parts.join("="); 57 cookies[name] = decodeURIComponent(value); 58 }); 59 return cookies; 60}; 61 62// data obj to data line and writes to file 63const writeToDataFile = async (data) => { 64 const time = new Date().getTime(); 65 66 let name = data["name"]; 67 name = name.replaceAll(",", "&#44;"); // commas removed 68 69 let website = data["website"] || ""; 70 website = website.replaceAll(",", "&#44;"); // commas removed 71 72 if (website) { 73 if (!website.startsWith("http:") && !website.startsWith("https:")) { 74 website = "//" + website; 75 } 76 } 77 78 let message = data["message"]; 79 message = message.replaceAll(",", "&#44;"); // commas removed 80 message = message.replace(/<[^>]*>/g, ""); // html-esque tags removed 81 message = message.replace(/\r?\n/g, "<br>"); 82 83 let line = `${time},`; 84 if (website !== "") { 85 line += `<a href="${website}" target="_blank">${name}</a>,`; 86 } else { 87 line += `${name},`; 88 } 89 90 line += `${message}`; // last line 91 92 await appendFile("data.txt", "\n" + line); 93 return; 94}; 95 96// -- page building 97const css = `body{margin:15px auto;padding:0 10px;max-width:650px;line-height:1.45em;color:#555;background-color:#f2f2f2;font-family:sans-serif;font-size:10.5pt}h1,h2,h3{font-family:sans-serif;color:#3d3d3d}h1{font-size:13pt}code{font-family:monospace;font-size:9.5pt}form,form label,form input,form textarea{display:block;width:100%;box-sizing:border-box}input,textarea{background-color:#f2f2f2;border:1px solid #888;color:#555;padding:3px 4px}a{color:#4472c1;text-decoration:none;border-bottom:1px dotted #7098dd}a:hover{color:#7098dd}a:focus{border:2px dotted #7098dd;outline:none}footer{margin-top:2em;border-top:1px solid #aaa}footer ul{list-style:none;text-align:center;padding-left:0}footer ul li{display:inline;padding:1em}.message{border:1px dashed #aaa;padding:3px;margin:8px 0}.message p{margin:2px}@media (prefers-color-scheme:dark){body{color:#bfbfbf;background-color:#1a1a1a}h1,h2,h3{color:#e3e3e3}a{color:skyblue;border-bottom:1px dotted #d7d7d7}a:hover{color:#528b8b}hr{border-top:1px solid #aaa}footer{border-top:1px solid #aaa}input,textarea{background-color:#1a1a1a;color:#bfbfbf}.message{border-color:#666}}@media only screen and (min-width:600px){form{width:50%}}`; 98 99const head = ` 100<!DOCTYPE html> 101<html lang="en"> 102<head> 103 <meta charset="UTF-8"> 104 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 105 <title>aktsbot's guestbook</title> 106 <style>${css}</style> 107</head> 108<body>`; 109 110const tail = `<footer> 111 <ul> 112 <li><a href="mailto:box.ashishk@gmail.com" id="f-email">mail</a></li> 113 <li><a href="/">root</a></li> 114 <li><a href="http://www.wtfpl.net/">license</a></li> 115 </ul> 116 </footer> 117</body> 118</html>`; 119 120// ----------------------- 121 122const makeDate = (d) => { 123 const date = new Date(Number(d)); // 1782646382732 124 const ymd = date.toISOString().split("T")[0]; // "YYYY-MM-DD" 125 return ymd; 126}; 127 128const makeDataHtml = async () => { 129 try { 130 const dataStr = await readFile("data.txt", "utf-8"); 131 const data = dataStr.trim(); 132 let html = ""; 133 134 const lines = data.split("\n").filter(Boolean); 135 // starting from the last element as data file 136 // has the latest comment at the end 137 for (let i = lines.length - 1; i >= 0; i--) { 138 const splits = lines[i].split(","); 139 // 0 - date | 1 - name | 2 - message | 3 - reply? 140 const mHtml = ` 141 <div class="message"> 142 <p><b> 143 <small> 144 #${i + 1} 145 ${splits[1]} - ${makeDate(splits[0])} 146 </small> 147 </b></p> 148 <p>${splits[2]}</p> 149 ${ 150 splits[3] 151 ? `<div class="message" style="margin-left: 12px; margin-bottom: 0px; border-style: dotted;"> 152 ${splits[3]} 153 </div>` 154 : "" 155 } 156 </div>`; 157 158 html += mHtml; 159 } 160 161 return html; 162 } catch (e) { 163 console.error(e); 164 console.log(`error: did you create a data.txt file?`); 165 } 166}; 167 168const makeHtml = async ({ errors }) => { 169 let html = ""; 170 171 html += head; 172 173 html += ` 174 <h1>sign my guestbook?</h1> 175 <p> 176 Hello there traveller! If you've words to share about this little corner of the lands, 177 please write them here in my guestbook. <a href="#f-email">Email me</a> if you wish for 178 the conversation to be private. 179 </p> 180 <p> 181 Please keep the messages in <code>plain-text</code>. &lt;html&gt; shall not pass! 182 </p> 183 <p>Feel free to grab the <a href="/git-web/gb">source code for this guestbook</a>.</p> 184 185 <form action="${config.basePath}" method="POST"> 186 187 <label for="message">message*</label> 188 <textarea name="message" id="message" rows="10" required></textarea> 189 190 <label for="name">name*</label> 191 <input type="text" id="name" name="name" required /> 192 193 <label for="website">website <small>(not your email)</small></label> 194 <input type="text" id="website" name="website" /> 195 196 <label style="position:absolute; left:-5000px"> 197 don't put anything in this field!<br> 198 <input type="text" name="email" style="position:absolute; left:-5000px"> 199 </label> 200 201 <input type="submit" style="margin-top: 8px; cursor: pointer; max-width: 60px;" value="post" /> 202 203 </form> 204 205 <p></p> 206 <p></p> 207 `; 208 209 html += await makeDataHtml(); 210 211 html += tail; 212 213 return html; 214}; 215 216// ---------- SERVER defn -- 217const server = http.createServer(async (req, res) => { 218 const { method, url } = req; 219 220 // clean up 221 clearExpiredTokens(); 222 223 // logging 224 console.log(`${new Date().getTime()} ${method} ${url}`); 225 if (config.debug) { 226 console.log(`sessions : ${JSON.stringify(sessionTokens, null, 2)}`); 227 } 228 229 // home page 230 if (method === "GET" && url === `${config.basePath}`) { 231 const html = await makeHtml({}); 232 233 const cookies = parseCookies(req.headers.cookie); 234 let sessionToken = cookies["gb-session-token"] || newSessionToken(); 235 if (!isSessionTokenValid(sessionToken)) { 236 sessionToken = newSessionToken(); 237 } else { 238 updateSessionTokenExpiry(sessionToken); 239 } 240 241 res.writeHead(200, { 242 "Content-Type": "text/html", 243 "Set-Cookie": `gb-session-token=${sessionToken}; Max-Age=900; HttpOnly; Secure; SameSite=Strict`, 244 }); 245 res.end(html); 246 } 247 // post message page 248 else if (method === "POST" && url === `${config.basePath}`) { 249 // token check 250 const cookies = parseCookies(req.headers.cookie); 251 let sessionToken = cookies["gb-session-token"]; 252 if (!sessionToken || !isSessionTokenValid(sessionToken)) { 253 res.writeHead(400, { "Content-Type": "text/plain" }); 254 res.end("400"); 255 return; 256 } 257 clearSessionToken(sessionToken); 258 259 let body = ""; 260 261 // collect all chunks arriving from the client 262 req.on("data", (chunk) => { 263 body += chunk.toString(); 264 }); 265 266 // when all data chunks are successfully received 267 req.on("end", async () => { 268 try { 269 const parsedData = querystring.parse(body); 270 if (parsedData["email"]) { 271 // a bot has send the value of email in the form? 272 throw new Error("Invalid email entry received"); 273 } 274 await writeToDataFile(parsedData); 275 res.writeHead(301, { 276 Location: `${config.basePath}`, 277 "Set-Cookie": "gb-session-token==; Max-Age=0; HttpOnly", 278 }); 279 res.end(); 280 } catch (error) { 281 res.writeHead(400, { "Content-Type": "text/plain" }); 282 res.end("Invalid payload"); 283 } 284 }); 285 } 286 // 404 287 else { 288 res.writeHead(404, { "Content-Type": "text/plain" }); 289 res.end("404"); 290 } 291}); 292 293// on start up 294clearExpiredTokens(); 295const tokenCleanupInterval = setInterval(() => { 296 clearExpiredTokens(); 297}, 1000 * 60 * 30); // every 30 mins 298 299// registering server stop 300process.on("SIGINT", () => { 301 console.log("SIGINT signal received. Shutting down gracefully..."); 302 clearInterval(tokenCleanupInterval); 303 304 server.close(() => { 305 console.log(` 306[ gb ] 307server closed 308`); 309 process.exit(0); 310 }); 311}); 312 313// ---------- SERVER start -- 314server.listen(config.port, () => { 315 console.log(` 316[ gb ] 317url - http://localhost:${config.port} 318base - ${config.basePath} 319debug - ${config.debug} 320`); 321});