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(",", ","); // commas removed
68
69 let website = data["website"] || "";
70 website = website.replaceAll(",", ","); // 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(",", ","); // 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>. <html> 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});