@lexho111/plainblog 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Blog.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import http from "http";
2
+ import crypto from "crypto";
2
3
  import fs from "fs";
3
4
  import { URLSearchParams } from "url";
4
5
  import { Readable, Transform, Writable, pipeline } from "stream";
@@ -10,7 +11,7 @@ import DatabaseModel from "./model/DatabaseModel.js";
10
11
  import { fetchData, postData } from "./model/APIModel.js";
11
12
  import { save as saveToFile, load as loadFromFile } from "./model/fileModel.js";
12
13
  import { formatHTML, formatMarkdown, validate } from "./Formatter.js";
13
- import pkg from "./package.json" with { type: "json" };;
14
+ import pkg from "./package.json" with { type: "json" };
14
15
  import path from "path";
15
16
  import { fileURLToPath } from "url";
16
17
 
@@ -29,6 +30,7 @@ export default class Blog {
29
30
  host: "localhost",
30
31
  dbname: "blog",
31
32
  };
33
+ this.sessions = new Set();
32
34
 
33
35
  const version = pkg.version;
34
36
  console.log(`version: ${version}`);
@@ -84,6 +86,53 @@ export default class Blog {
84
86
  setStyle(style) {
85
87
  this.styles = style;
86
88
  }
89
+ isAuthenticated(req) {
90
+ if (!req.headers.cookie) return false;
91
+ const params = new URLSearchParams(req.headers.cookie.replace(/; /g, "&"));
92
+ return this.sessions.has(params.get("session"));
93
+ }
94
+
95
+ async handleLogin(req, res) {
96
+ const body$ = fromEvent(req, "data").pipe(
97
+ map((chunk) => chunk.toString()),
98
+ scan((acc, chunk) => acc + chunk, ""),
99
+ takeUntil(fromEvent(req, "end")),
100
+ last(),
101
+ defaultIfEmpty("")
102
+ );
103
+ const body = await firstValueFrom(body$);
104
+ const params = new URLSearchParams(body);
105
+
106
+ if (params.get("password") === "admin") {
107
+ const id = crypto.randomUUID();
108
+ this.sessions.add(id);
109
+ res.writeHead(303, {
110
+ "Set-Cookie": `session=${id}; HttpOnly; Path=/`,
111
+ Location: "/",
112
+ });
113
+ res.end();
114
+ } else {
115
+ res.writeHead(401, { "Content-Type": "text/html" });
116
+ res.end(`<style>${this.styles}</style><h1>Unauthorized</h1><p>Please enter the password.<form method="POST">
117
+ <input type="password" name="password" placeholder="Password" />
118
+ <button style="margin: 2px;">Login</button></form>`);
119
+ }
120
+ }
121
+
122
+ handleLogout(req, res) {
123
+ if (req.headers.cookie) {
124
+ const params = new URLSearchParams(req.headers.cookie.replace(/; /g, "&"));
125
+ const sessionId = params.get("session");
126
+ if (this.sessions.has(sessionId)) {
127
+ this.sessions.delete(sessionId);
128
+ }
129
+ }
130
+ res.writeHead(303, {
131
+ "Set-Cookie": "session=; HttpOnly; Path=/; Max-Age=0",
132
+ Location: "/",
133
+ });
134
+ res.end();
135
+ }
87
136
 
88
137
  /** initializes database */
89
138
  async init() {
@@ -159,8 +208,30 @@ export default class Blog {
159
208
  }
160
209
 
161
210
  // Web Page Routes
211
+ if (req.url === "/login") {
212
+ if (req.method === "GET") {
213
+ res.writeHead(200, { "Content-Type": "text/html" });
214
+ res.end(`<style>${this.styles}</style><h1>Login</h1><form method="POST">
215
+ <input type="password" name="password" placeholder="Password" />
216
+ <button style="margin: 2px;">Login</button></form>`);
217
+ return;
218
+ } else if (req.method === "POST") {
219
+ await this.handleLogin(req, res);
220
+ return;
221
+ }
222
+ }
223
+
224
+ if (req.url === "/logout") {
225
+ this.handleLogout(req, res);
226
+ return;
227
+ }
162
228
  // POST new article
163
229
  if (req.method === "POST" && req.url === "/") {
230
+ if (!this.isAuthenticated(req)) {
231
+ res.writeHead(403, { "Content-Type": "text/plain" });
232
+ res.end("Forbidden");
233
+ return;
234
+ }
164
235
  await this.#databaseModel.updateBlogTitle(this.title);
165
236
  await this.postArticle(req, res);
166
237
  // GET artciles
@@ -170,7 +241,16 @@ export default class Blog {
170
241
  // reload styles and scripts on (every) request
171
242
  //this.loadScripts(); this.loadStyles();
172
243
 
173
- const html = await this.toHTML(); // render this blog to HTML
244
+ let loggedin = false;
245
+ if (!this.isAuthenticated(req)) {
246
+ // login
247
+ loggedin = false;
248
+ } else {
249
+ // logout
250
+ loggedin = true;
251
+ }
252
+
253
+ const html = await this.toHTML(loggedin); // render this blog to HTML
174
254
  res.writeHead(200, { "Content-Type": "text/html; charset=UTF-8" });
175
255
  res.end(html);
176
256
  } else {
@@ -266,6 +346,11 @@ export default class Blog {
266
346
 
267
347
  // POST a new article
268
348
  } else if (req.method === "POST" && req.url === "/api/articles") {
349
+ if (!this.isAuthenticated(req)) {
350
+ res.writeHead(403, { "Content-Type": "application/json" });
351
+ res.end(JSON.stringify({ error: "Forbidden" }));
352
+ return;
353
+ }
269
354
  let body = "";
270
355
  req.on("data", (chunk) => (body += chunk.toString()));
271
356
  req.on("end", async () => {
@@ -318,12 +403,17 @@ export default class Blog {
318
403
  }
319
404
 
320
405
  /** render this blog content to valid html */
321
- async toHTML() {
406
+ async toHTML(loggedin) {
322
407
  const data = {
323
408
  title: this.title,
324
409
  articles: this.articles,
410
+ loggedin,
411
+ login: ""
325
412
  };
326
413
 
414
+ if(loggedin) data.login = `<a href="/logout">logout</a>`;
415
+ else data.login = `<a href="/login">login</a>`;
416
+
327
417
  return new Promise((resolve, reject) => {
328
418
  let htmlResult = "";
329
419
  pipeline(
package/Formatter.js CHANGED
@@ -3,6 +3,20 @@ export function formatHTML(data, script, style) {
3
3
  //export function formatHTML(data) {
4
4
  //const button = `<button type="button" onClick="fillWithContent();" style="margin: 4px;">generate random text</button>`;
5
5
  const button = "";
6
+ let form1 = "";
7
+ if (data.loggedin) {
8
+ form1 = `<form action="/" method="POST">
9
+ <h3>Add a New Article</h3>
10
+ <input type="text" id="title" name="title" placeholder="Article Title" required style="display: block; width: 300px; margin-bottom: 10px;">
11
+ <textarea id="content" name="content" placeholder="Article Content" required style="display: block; width: 300px; height: 100px; margin-bottom: 10px;"></textarea>
12
+ <button type="submit">Add Article</button>${button}
13
+ <script>
14
+ ${script}
15
+ </script>
16
+ </form>
17
+ <hr>`;
18
+ }
19
+ const form = form1;
6
20
  return `<!DOCTYPE html>
7
21
  <html lang="de">
8
22
  <head>
@@ -11,18 +25,12 @@ export function formatHTML(data, script, style) {
11
25
  <style>${style}</style>
12
26
  </head>
13
27
  <body>
28
+ <nav>
29
+ ${data.login}
30
+ </nav>
14
31
  <h1>${data.title}</h1>
15
32
  <div style="width: 500px;">
16
- <form action="/" method="POST">
17
- <h3>Add a New Article</h3>
18
- <input type="text" id="title" name="title" placeholder="Article Title" required style="display: block; width: 300px; margin-bottom: 10px;">
19
- <textarea id="content" name="content" placeholder="Article Content" required style="display: block; width: 300px; height: 100px; margin-bottom: 10px;"></textarea>
20
- <button type="submit">Add Article</button>${button}
21
- <script>
22
- ${script}
23
- </script>
24
- </form>
25
- <hr>
33
+ ${form}
26
34
  <section class="grid">
27
35
  ${data.articles
28
36
  .map(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lexho111/plainblog",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "A tool for creating and serving a minimalist, single-page blog.",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/styles.min.css CHANGED
@@ -1,2 +1,2 @@
1
- .grid{border:0 solid #000;display:grid;gap:.25rem;grid-template-columns:1fr}.grid article{border:0 solid #ccc;border-radius:4px;min-width:0;overflow-wrap:break-word;padding:.25rem}body{background-color:#fdfdfd;font-family:Arial}
1
+ .grid{border:0 solid #000;display:grid;gap:.25rem;grid-template-columns:1fr}.grid article{border:0 solid #ccc;border-radius:4px;min-width:0;overflow-wrap:break-word;padding:.25rem}nav a:visited{color:#3b40c1;text-decoration-color:#3b40c1}body{background-color:#fdfdfd;font-family:Arial}nav a{color:#3b40c1;font-size:20px;text-decoration:underline}
2
2
  /*# sourceMappingURL=styles.min.css.map */
@@ -1 +1 @@
1
- {"version":3,"sources":["styles-compiled.css","styles.css"],"names":[],"mappings":"AAAA,MAIA,mBAAA,CAHA,YAAA,CAEA,UAAA,CADA,yBAGA,CACA,cAEA,mBAAA,CACA,iBAAA,CACA,WAAA,CACA,wBAAA,CAJA,cAKA,CCZA,KACA,wBAAA,CACA,iBACA","file":"styles.min.css","sourcesContent":[".grid {\n display: grid;\n grid-template-columns: 1fr;\n gap: 0.25rem;\n border: 0px solid black;\n}\n.grid article {\n padding: 0.25rem;\n border: 0px solid #ccc;\n border-radius: 4px;\n min-width: 0; /* Allow grid items to shrink */\n overflow-wrap: break-word; /* Break long words */\n}","body {\n background-color: rgb(253, 253, 253);\n font-family: Arial;\n}\n"]}
1
+ {"version":3,"sources":["styles-compiled.css","styles.css"],"names":[],"mappings":"AAAA,MAIA,mBAAA,CAHA,YAAA,CAEA,UAAA,CADA,yBAGA,CACA,cAEA,mBAAA,CACA,iBAAA,CACA,WAAA,CACA,wBAAA,CAJA,cAKA,CAOA,cACA,aAAA,CACA,6BACA,CCtBA,KACA,wBAAA,CACA,iBACA,CAEA,MAEA,aAAA,CACA,cAAA,CAFA,yBAGA","file":"styles.min.css","sourcesContent":[".grid {\n display: grid;\n grid-template-columns: 1fr;\n gap: 0.25rem;\n border: 0px solid black;\n}\n.grid article {\n padding: 0.25rem;\n border: 0px solid #ccc;\n border-radius: 4px;\n min-width: 0; /* Allow grid items to shrink */\n overflow-wrap: break-word; /* Break long words */\n}\n\nnav a {\n text-decoration: underline;\n color: rgb(59, 64, 193);\n font-size: 20px;\n}\nnav a:visited {\n color: rgb(59, 64, 193);\n text-decoration-color: rgb(59, 64, 193);\n}","body {\n background-color: rgb(253, 253, 253);\n font-family: Arial;\n}\n\nnav a {\n text-decoration: underline;\n color: rgb(59, 64, 193);\n font-size: 20px;\n}\n"]}
@@ -57,19 +57,50 @@ describe("server test", () => {
57
57
  expect(responseData.title).toBe("My Blog");
58
58
  });
59
59
 
60
- test("should post a new article via /api/articles", async () => {
60
+ test("should post a new article via /api/articles not logged in", async () => {
61
+ const content = "This is the content of the test article." + new Date();
61
62
  const newArticle = {
62
63
  title: "Test Title from Jest",
63
- content: "This is the content of the test article.",
64
+ content: content,
64
65
  };
65
66
 
66
67
  // post new article to the blog
68
+ // WITHOUT Login
69
+
67
70
  const response = await fetch(`${apiBaseUrl}/api/articles`, {
68
71
  method: "POST",
69
72
  headers: { "Content-Type": "application/json" },
70
73
  body: JSON.stringify(newArticle),
71
74
  });
72
75
 
76
+ expect(response.status).not.toBe(201);
77
+ const responseData = await response.json();
78
+ expect(responseData).not.toEqual(newArticle);
79
+
80
+ expect(await blog.toHTML()).not.toContain(newArticle.content); // does blog contain my new article?
81
+ });
82
+
83
+ test("should post a new article via /api/articles logged in", async () => {
84
+ const newArticle = {
85
+ title: "Test Title from Jest",
86
+ content: "This is the content of the test article.",
87
+ };
88
+
89
+ // post new article to the blog
90
+ // Login to get session cookie
91
+ const loginResponse = await fetch(`${apiBaseUrl}/login`, {
92
+ method: "POST",
93
+ body: "password=admin",
94
+ redirect: "manual",
95
+ });
96
+ const cookie = loginResponse.headers.get("set-cookie");
97
+
98
+ const response = await fetch(`${apiBaseUrl}/api/articles`, {
99
+ method: "POST",
100
+ headers: { "Content-Type": "application/json", Cookie: cookie },
101
+ body: JSON.stringify(newArticle),
102
+ });
103
+
73
104
  expect(response.status).toBe(201);
74
105
  const responseData = await response.json();
75
106
  expect(responseData).toEqual(newArticle);