@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 +93 -3
- package/Formatter.js +18 -10
- package/package.json +1 -1
- package/styles.min.css +1 -1
- package/styles.min.css.map +1 -1
- package/test/server.test.js +33 -2
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
|
-
|
|
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
|
-
|
|
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
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 */
|
package/styles.min.css.map
CHANGED
|
@@ -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,
|
|
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"]}
|
package/test/server.test.js
CHANGED
|
@@ -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:
|
|
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);
|