@lexho111/plainblog 0.6.10 → 0.6.11
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 - Kopie.js +979 -0
- package/Blog.js +52 -52
- package/package.json +1 -1
- package/public/styles.min.css +2 -2
- package/router.js +6 -7
- package/src/styles.css +16 -5
package/Blog - Kopie.js
ADDED
|
@@ -0,0 +1,979 @@
|
|
|
1
|
+
import http from "http";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import { URLSearchParams } from "url";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
import pkg from "./package.json" with { type: "json" };
|
|
9
|
+
import Article from "./Article.js";
|
|
10
|
+
import DatabaseModel from "./model/DatabaseModel.js";
|
|
11
|
+
import { fetchData, postData } from "./model/APIModel.js";
|
|
12
|
+
import { formatHTML, header, formatMarkdown, validate } from "./Formatter.js";
|
|
13
|
+
import { compileStyles, mergeStyles } from "./build-styles.js";
|
|
14
|
+
import { compileScripts } from "./build-scripts.js";
|
|
15
|
+
import FileAdapter from "./model/FileAdapter.js";
|
|
16
|
+
import DataModel from "./model/DataModel.js";
|
|
17
|
+
import { BinarySearchTreeHashMap } from "./model/datastructures/BinarySearchTreeHashMap.js";
|
|
18
|
+
import createDebug from "./debug-loader.js";
|
|
19
|
+
import { readFile } from "node:fs/promises";
|
|
20
|
+
import { login, logout, api, new1, getPages } from "./router.js";
|
|
21
|
+
|
|
22
|
+
// Initialize the debugger with a specific namespace
|
|
23
|
+
const debug = createDebug("plainblog:Blog");
|
|
24
|
+
|
|
25
|
+
export default class Blog {
|
|
26
|
+
#makeDataModel() {
|
|
27
|
+
return new BinarySearchTreeHashMap();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
setDataModel(datamodel) {
|
|
31
|
+
this.#articles = datamodel;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
constructor() {
|
|
35
|
+
this.database = {
|
|
36
|
+
type: "file",
|
|
37
|
+
username: "user",
|
|
38
|
+
password: "password",
|
|
39
|
+
host: "localhost",
|
|
40
|
+
dbname: "articles.txt", // x
|
|
41
|
+
};
|
|
42
|
+
this.#title = "";
|
|
43
|
+
this.#articles = new DataModel(this.#makeDataModel());
|
|
44
|
+
this.#server = null;
|
|
45
|
+
this.#password = "admin";
|
|
46
|
+
this.#styles = "body { font-family: Arial; }";
|
|
47
|
+
//this.scripts = "";
|
|
48
|
+
this.compiledStyles = "";
|
|
49
|
+
//this.compiledScripts = "";
|
|
50
|
+
this.reloadStylesOnGET = false;
|
|
51
|
+
this.angular = false;
|
|
52
|
+
this.sessions = new Set();
|
|
53
|
+
|
|
54
|
+
this.#version = pkg.version;
|
|
55
|
+
console.log(`version: ${this.#version}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @returns a json representation of the blog */
|
|
59
|
+
json() {
|
|
60
|
+
const serverInfo = this.#server
|
|
61
|
+
? {
|
|
62
|
+
listening: this.#server.listening,
|
|
63
|
+
address: this.#server.address(),
|
|
64
|
+
}
|
|
65
|
+
: null;
|
|
66
|
+
|
|
67
|
+
const json = {
|
|
68
|
+
version: this.#version,
|
|
69
|
+
title: this.#title,
|
|
70
|
+
articles: this.#articles.getAllArticles(),
|
|
71
|
+
server: serverInfo,
|
|
72
|
+
compiledStyles: this.compiledStyles,
|
|
73
|
+
sessions: this.sessions,
|
|
74
|
+
database: this.database,
|
|
75
|
+
password: this.#password,
|
|
76
|
+
styles: this.#styles,
|
|
77
|
+
reloadStylesOnGET: this.reloadStylesOnGET,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return JSON.parse(JSON.stringify(json)); // make json read-only
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Private fields
|
|
84
|
+
#version = null;
|
|
85
|
+
#server = null;
|
|
86
|
+
#password = null;
|
|
87
|
+
#databaseModel;
|
|
88
|
+
#isExternalAPI = false;
|
|
89
|
+
#apiUrl = "";
|
|
90
|
+
#title = "";
|
|
91
|
+
#articles = [];
|
|
92
|
+
#styles = "";
|
|
93
|
+
#stylesHash = "";
|
|
94
|
+
#scriptsHash = "";
|
|
95
|
+
#stylesheetPath = "";
|
|
96
|
+
compilestyle = false;
|
|
97
|
+
#initPromise = null;
|
|
98
|
+
#publicDir = path.join(process.cwd(), "public");
|
|
99
|
+
|
|
100
|
+
setTitle(title) {
|
|
101
|
+
this.#title = title;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
setPassword(password) {
|
|
105
|
+
this.#password = password;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Appends CSS rules to the \<style\>-tag.
|
|
110
|
+
* @param {string} style - A string containing CSS rules.
|
|
111
|
+
*/
|
|
112
|
+
setStyle(style) {
|
|
113
|
+
this.#styles += style;
|
|
114
|
+
this.compilestyle = true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
set title(t) {
|
|
118
|
+
this.#title = t;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
get title() {
|
|
122
|
+
return this.#title;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
set password(x) {
|
|
126
|
+
this.#password = x;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* allows you to inject a specific database implementation
|
|
131
|
+
* @param {*} adapter a database adapter like PostgresAdapter or SqliteAdapter
|
|
132
|
+
*/
|
|
133
|
+
setDatabaseAdapter(adapter) {
|
|
134
|
+
if (!this.#databaseModel) {
|
|
135
|
+
if (this.database.type === "file") {
|
|
136
|
+
const adapter = new FileAdapter(this.database);
|
|
137
|
+
this.#databaseModel = new DatabaseModel(adapter);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
this.#databaseModel.setDatabaseAdapter(adapter);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Appends CSS rules to the \<style\>-tag.
|
|
145
|
+
* @param {string} style - A string containing CSS rules.
|
|
146
|
+
*/
|
|
147
|
+
set style(style) {
|
|
148
|
+
this.#styles += style;
|
|
149
|
+
this.compilestyle = true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Sets the path(s) to custom CSS or SCSS files to be compiled and used by the blog.
|
|
154
|
+
* @param {string|string[]} files - A single file path or an array of file paths.
|
|
155
|
+
*/
|
|
156
|
+
set stylesheetPath(files) {
|
|
157
|
+
this.#stylesheetPath = files;
|
|
158
|
+
console.log(`this.#stylesheetPath: ${this.#stylesheetPath}`);
|
|
159
|
+
this.compilestyle = true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
addArticle(article) {
|
|
163
|
+
this.#articles.insert(article);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
#isAuthenticated(req) {
|
|
167
|
+
if (!req.headers.cookie) return false;
|
|
168
|
+
const match = req.headers.cookie.match(/(?:^|;\s*)session=([^;]*)/);
|
|
169
|
+
return match ? this.sessions.has(match[1]) : false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async #handleLogin(req, res) {
|
|
173
|
+
debug("handle login");
|
|
174
|
+
const body = await new Promise((resolve, reject) => {
|
|
175
|
+
let data = "";
|
|
176
|
+
req.on("data", (chunk) => (data += chunk.toString()));
|
|
177
|
+
req.on("end", () => resolve(data));
|
|
178
|
+
req.on("error", reject);
|
|
179
|
+
});
|
|
180
|
+
const params = new URLSearchParams(body);
|
|
181
|
+
|
|
182
|
+
if (params.get("password") === this.#password) {
|
|
183
|
+
const id = crypto.randomUUID();
|
|
184
|
+
this.sessions.add(id);
|
|
185
|
+
res.writeHead(303, {
|
|
186
|
+
"Set-Cookie": `session=${id}; HttpOnly; Path=/`,
|
|
187
|
+
Location: "/",
|
|
188
|
+
});
|
|
189
|
+
res.end();
|
|
190
|
+
} else {
|
|
191
|
+
debug("login failed");
|
|
192
|
+
debug("password did not match %s", params.get("password"));
|
|
193
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
194
|
+
|
|
195
|
+
// Send the JSON string
|
|
196
|
+
res.end(
|
|
197
|
+
JSON.stringify({
|
|
198
|
+
error: "unauthorized",
|
|
199
|
+
message: "Please enter the correct password.",
|
|
200
|
+
}),
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
#handleLogout(req, res) {
|
|
206
|
+
debug("handle logout");
|
|
207
|
+
if (req.headers.cookie) {
|
|
208
|
+
const params = new URLSearchParams(
|
|
209
|
+
req.headers.cookie.replace(/; /g, "&"),
|
|
210
|
+
);
|
|
211
|
+
const sessionId = params.get("session");
|
|
212
|
+
if (this.sessions.has(sessionId)) {
|
|
213
|
+
this.sessions.delete(sessionId);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
res.writeHead(303, {
|
|
217
|
+
"Set-Cookie": "session=; HttpOnly; Path=/; Max-Age=0",
|
|
218
|
+
Location: "/",
|
|
219
|
+
});
|
|
220
|
+
res.end();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** initializes database */
|
|
224
|
+
async init() {
|
|
225
|
+
if (this.#initPromise) return this.#initPromise;
|
|
226
|
+
this.#initPromise = (async () => {
|
|
227
|
+
//this.loadStyles();
|
|
228
|
+
//this.loadScripts();
|
|
229
|
+
// if there is a stylesheet path provided, process it
|
|
230
|
+
if (this.#stylesheetPath != null && this.compilestyle) {
|
|
231
|
+
// read file from stylesheet path, compare checksums and write to public/styles.min.css
|
|
232
|
+
await this.#processStylesheets(this.#stylesheetPath);
|
|
233
|
+
}
|
|
234
|
+
if (!this.#stylesheetPath) {
|
|
235
|
+
// this.#styles
|
|
236
|
+
// src/styles.css
|
|
237
|
+
// compile and merge hardcoded styles in "this.#styles" with "src/styles.css" and write to file "styles.min.css"
|
|
238
|
+
// which will be imported by webbrowser via '<link rel="stylesheet" href="styles.min.css"...'
|
|
239
|
+
|
|
240
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
241
|
+
const __dirname = path.dirname(__filename);
|
|
242
|
+
const srcStylePath = path.join(__dirname, "src", "styles.css");
|
|
243
|
+
const publicStylePath = path.join(this.#publicDir, "styles.min.css");
|
|
244
|
+
|
|
245
|
+
let publicHash = null;
|
|
246
|
+
let srcStyles = "";
|
|
247
|
+
|
|
248
|
+
await Promise.all([
|
|
249
|
+
fs.promises
|
|
250
|
+
.readFile(publicStylePath, "utf8")
|
|
251
|
+
.then((publicCSS) => {
|
|
252
|
+
const match = publicCSS.match(
|
|
253
|
+
/\/\* source-hash: ([a-f0-9]{64}) \*\//,
|
|
254
|
+
);
|
|
255
|
+
if (match) publicHash = match[1];
|
|
256
|
+
})
|
|
257
|
+
.catch((err) => console.error(err)), // public/styles.min.css doesn't exist, will be created.
|
|
258
|
+
fs.promises
|
|
259
|
+
.readFile(srcStylePath, "utf8")
|
|
260
|
+
.then((content) => {
|
|
261
|
+
srcStyles = content;
|
|
262
|
+
})
|
|
263
|
+
.catch((err) => {
|
|
264
|
+
if (err.code !== "ENOENT") console.error(err);
|
|
265
|
+
}), // ignore if src/styles.css doesn't exist
|
|
266
|
+
]);
|
|
267
|
+
|
|
268
|
+
const combinedStyles = this.#styles + srcStyles;
|
|
269
|
+
const srcHash = crypto
|
|
270
|
+
.createHash("sha256")
|
|
271
|
+
.update(combinedStyles)
|
|
272
|
+
.digest("hex");
|
|
273
|
+
|
|
274
|
+
if (srcHash !== publicHash && this.compilestyle) {
|
|
275
|
+
console.log("Styles have changed. Recompiling...");
|
|
276
|
+
const finalStyles = await mergeStyles(this.#styles, srcStyles);
|
|
277
|
+
try {
|
|
278
|
+
await fs.promises.mkdir(path.dirname(publicStylePath), {
|
|
279
|
+
recursive: true,
|
|
280
|
+
});
|
|
281
|
+
await fs.promises.writeFile(
|
|
282
|
+
publicStylePath,
|
|
283
|
+
finalStyles + `\n/* source-hash: ${srcHash} */`,
|
|
284
|
+
);
|
|
285
|
+
} catch (err) {
|
|
286
|
+
console.error("Failed to write styles to public folder:", err);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Process Scripts
|
|
292
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
293
|
+
const __dirname = path.dirname(__filename);
|
|
294
|
+
const srcScriptPath = path.join(__dirname, "src", "fetchData.js");
|
|
295
|
+
await this.#processScripts(srcScriptPath);
|
|
296
|
+
|
|
297
|
+
if (this.#isExternalAPI) {
|
|
298
|
+
console.log("external API");
|
|
299
|
+
await this.#loadFromAPI();
|
|
300
|
+
} else {
|
|
301
|
+
console.log(`database: ${this.database.type}`);
|
|
302
|
+
if (!this.#databaseModel) {
|
|
303
|
+
if (this.database.type === "file") {
|
|
304
|
+
const adapter = new FileAdapter(this.database);
|
|
305
|
+
this.#databaseModel = new DatabaseModel(adapter);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
console.log(`connected to database`);
|
|
309
|
+
await this.#databaseModel.initialize();
|
|
310
|
+
this.#articles.setDatabase(this.#databaseModel);
|
|
311
|
+
const [dbTitle, dbArticles] = await Promise.all([
|
|
312
|
+
this.#databaseModel.getBlogTitle(),
|
|
313
|
+
this.#databaseModel.findAll(),
|
|
314
|
+
]);
|
|
315
|
+
|
|
316
|
+
debug("dbArticles.size(): %d", dbArticles.length);
|
|
317
|
+
//log(filename, "dbArticles.size(): " + dbArticles.length);
|
|
318
|
+
//log(filename, "dbArticles.size(): " + dbArticles.length, "Blog.js");
|
|
319
|
+
debug("all articles in Blog after loading from db");
|
|
320
|
+
|
|
321
|
+
// Displays a beautiful table in the console
|
|
322
|
+
//table(dbArticles)
|
|
323
|
+
|
|
324
|
+
if (dbArticles.length == 0) {
|
|
325
|
+
dbArticles.push(
|
|
326
|
+
new Article(
|
|
327
|
+
1,
|
|
328
|
+
"Sample Entry #1",
|
|
329
|
+
"Prow scuttle parrel provost Sail ho shrouds spirits boom mizzenmast yardarm. Pinnace holystone mizzenmast quarter crow's nest nipperkin grog yardarm hempen halter furl. Swab barque interloper chantey doubloon starboard grog black jack gangway rutters.",
|
|
330
|
+
new Date(),
|
|
331
|
+
),
|
|
332
|
+
);
|
|
333
|
+
dbArticles.push(
|
|
334
|
+
new Article(
|
|
335
|
+
2,
|
|
336
|
+
"Sample Entry #2",
|
|
337
|
+
"Deadlights jack lad schooner scallywag dance the hempen jig carouser broadside cable strike colors. Bring a spring upon her cable holystone blow the man down spanker Shiver me timbers to go on account lookout wherry doubloon chase. Belay yo-ho-ho keelhaul squiffy black spot yardarm spyglass sheet transom heave to.",
|
|
338
|
+
new Date(),
|
|
339
|
+
),
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
if (this.reloadStylesOnGET)
|
|
343
|
+
console.log("reload scripts and styles on GET-Request");
|
|
344
|
+
let title = "";
|
|
345
|
+
if (this.#title != null && this.#title.length > 0)
|
|
346
|
+
title = this.#title; // use blog title if set
|
|
347
|
+
else title = dbTitle; // use title from the database
|
|
348
|
+
const responseData = { title: title, articles: dbArticles };
|
|
349
|
+
this.#applyBlogData(responseData);
|
|
350
|
+
}
|
|
351
|
+
})();
|
|
352
|
+
return this.#initPromise;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async postArticle(newArticle) {
|
|
356
|
+
try {
|
|
357
|
+
// Save the new article to the database via the ApiServer
|
|
358
|
+
const promises = [];
|
|
359
|
+
//if (this.#databaseModel)
|
|
360
|
+
// promises.push(this.#databaseModel.save(newArticle));
|
|
361
|
+
if (this.#isExternalAPI)
|
|
362
|
+
promises.push(postData(this.#apiUrl, newArticle));
|
|
363
|
+
await Promise.all(promises);
|
|
364
|
+
const title = newArticle.title;
|
|
365
|
+
const content = newArticle.content;
|
|
366
|
+
// Add the article to the local list for immediate display
|
|
367
|
+
this.#articles.insert(Article.createNew(title, content));
|
|
368
|
+
// remove sample entries
|
|
369
|
+
this.#articles.remove(1); // "Sample Entry #1"
|
|
370
|
+
this.#articles.remove(2); // "Sample Entry #2"
|
|
371
|
+
} catch (error) {
|
|
372
|
+
console.error("Failed to post new article to API:", error);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** start a http server with default port 8080 */
|
|
377
|
+
async startServer(port = 8080) {
|
|
378
|
+
await this.init();
|
|
379
|
+
|
|
380
|
+
const server = http.createServer(async (req, res) => {
|
|
381
|
+
//debug("query %s", req.url);
|
|
382
|
+
await login(
|
|
383
|
+
req,
|
|
384
|
+
res,
|
|
385
|
+
async (req, res) => {
|
|
386
|
+
await this.#handleLogin(req, res);
|
|
387
|
+
},
|
|
388
|
+
async (req, res) => {
|
|
389
|
+
res.end(`${header("My Blog")}<body>
|
|
390
|
+
<form class="loginform" id="loginForm">
|
|
391
|
+
<h1>Blog</h1>
|
|
392
|
+
<!-- Message container -->
|
|
393
|
+
<div id="statusMessage"></div>
|
|
394
|
+
<label for="username">Username</label>
|
|
395
|
+
<input type="username" class="form_element" name="username" placeholder="username" required />
|
|
396
|
+
<label for="password">Password</label>
|
|
397
|
+
<input type="password" class="form_element" name="password" placeholder="password" required />
|
|
398
|
+
<button class="btn" type="submit">Login</button>
|
|
399
|
+
</form>
|
|
400
|
+
|
|
401
|
+
<script>
|
|
402
|
+
const loginForm = document.getElementById('loginForm');
|
|
403
|
+
const statusDiv = document.getElementById('statusMessage');
|
|
404
|
+
|
|
405
|
+
loginForm.addEventListener('submit', async (e) => {
|
|
406
|
+
e.preventDefault(); // Prevent page reload
|
|
407
|
+
|
|
408
|
+
// Clear previous messages
|
|
409
|
+
statusDiv.innerHTML = '';
|
|
410
|
+
|
|
411
|
+
const formData = new FormData(loginForm);
|
|
412
|
+
const body = new URLSearchParams(formData); // Replicates form-urlencoded
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
const response = await fetch('/login', {
|
|
416
|
+
method: 'POST',
|
|
417
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
418
|
+
body: body.toString()
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
if (response.ok) {
|
|
422
|
+
window.location.href = '/'; // Redirect on success
|
|
423
|
+
} else if (response.status === 401) {
|
|
424
|
+
// Handle Unauthorized status
|
|
425
|
+
statusDiv.innerHTML = '<h2>Unauthorized</h2><p>Please enter the correct password.</p>';
|
|
426
|
+
} else {
|
|
427
|
+
statusDiv.innerHTML = '<p>Something went wrong. Please try again.</p>';
|
|
428
|
+
}
|
|
429
|
+
} catch (error) {
|
|
430
|
+
console.error('Network Error:', error);
|
|
431
|
+
statusDiv.innerHTML = '<p>Unable to connect to server.</p>';
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
</script>
|
|
435
|
+
</body></html>`);
|
|
436
|
+
},
|
|
437
|
+
);
|
|
438
|
+
if (res.headersSent) return;
|
|
439
|
+
|
|
440
|
+
await logout(req, res, async (req, res) => {
|
|
441
|
+
this.#handleLogout(req, res);
|
|
442
|
+
});
|
|
443
|
+
if (res.headersSent) return;
|
|
444
|
+
|
|
445
|
+
await api(req, res, async (req, res) => {
|
|
446
|
+
await this.#jsonAPI(req, res);
|
|
447
|
+
});
|
|
448
|
+
if (res.headersSent) return;
|
|
449
|
+
|
|
450
|
+
await new1(req, res, (req, res) => {
|
|
451
|
+
return new Promise((resolve, reject) => {
|
|
452
|
+
if (!this.#isAuthenticated(req)) {
|
|
453
|
+
debug("not authenticated");
|
|
454
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
455
|
+
res.end(JSON.stringify({ error: "Forbidden" }));
|
|
456
|
+
resolve();
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
let body = "";
|
|
460
|
+
|
|
461
|
+
// 1. Collect data chunks
|
|
462
|
+
req.on("data", (chunk) => {
|
|
463
|
+
body += chunk.toString();
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
req.on("end", async () => {
|
|
467
|
+
try {
|
|
468
|
+
// 2. Parse x-www-form-urlencoded using URLSearchParams
|
|
469
|
+
const params = new URLSearchParams(body);
|
|
470
|
+
|
|
471
|
+
// 3. Convert to a plain object
|
|
472
|
+
const articleData = Object.fromEntries(params.entries());
|
|
473
|
+
|
|
474
|
+
console.log("New Article Data:", articleData);
|
|
475
|
+
// local
|
|
476
|
+
await this.#databaseModel.save(articleData); // --> to api server
|
|
477
|
+
this.postArticle(articleData);
|
|
478
|
+
// external
|
|
479
|
+
|
|
480
|
+
// Success response
|
|
481
|
+
res.writeHead(302, { Location: "/" });
|
|
482
|
+
res.end();
|
|
483
|
+
resolve();
|
|
484
|
+
} catch (err) {
|
|
485
|
+
if (!res.headersSent) {
|
|
486
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
487
|
+
res.end(JSON.stringify({ error: "Failed to parse form data" }));
|
|
488
|
+
}
|
|
489
|
+
resolve();
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
req.on("error", reject);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
/*await this.#handleLogin(req, res, async () => {
|
|
496
|
+
await this.#handleLogin(req, res);
|
|
497
|
+
})*/
|
|
498
|
+
});
|
|
499
|
+
if (res.headersSent) return;
|
|
500
|
+
|
|
501
|
+
await getPages(
|
|
502
|
+
req,
|
|
503
|
+
res,
|
|
504
|
+
async (req, res) => {
|
|
505
|
+
// reload styles and scripts on (every) request
|
|
506
|
+
if (this.reloadStylesOnGET) {
|
|
507
|
+
if (this.#stylesheetPath != null && this.compilestyle) {
|
|
508
|
+
await this.#processStylesheets(this.#stylesheetPath);
|
|
509
|
+
}
|
|
510
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
511
|
+
const __dirname = path.dirname(__filename);
|
|
512
|
+
const srcScriptPath = path.join(__dirname, "src", "fetchData.js");
|
|
513
|
+
await this.#processScripts(srcScriptPath);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
let loggedin = false;
|
|
517
|
+
if (!this.#isAuthenticated(req)) {
|
|
518
|
+
// login
|
|
519
|
+
loggedin = false;
|
|
520
|
+
} else {
|
|
521
|
+
// logout
|
|
522
|
+
loggedin = true;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (this.angular) {
|
|
526
|
+
// use angular frontend
|
|
527
|
+
const filePath = path.join(this.#publicDir, "index.html");
|
|
528
|
+
|
|
529
|
+
debug("%s", filePath);
|
|
530
|
+
try {
|
|
531
|
+
const data = await readFile(filePath);
|
|
532
|
+
// Manual MIME type detection (simplified)
|
|
533
|
+
const ext = path.extname(filePath);
|
|
534
|
+
const mimeTypes = {
|
|
535
|
+
".html": "text/html",
|
|
536
|
+
".css": "text/css",
|
|
537
|
+
".js": "text/javascript",
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
res.writeHead(200, {
|
|
541
|
+
"Content-Type": mimeTypes[ext] || "application/octet-stream",
|
|
542
|
+
});
|
|
543
|
+
res.end(data);
|
|
544
|
+
} catch (err) {
|
|
545
|
+
if (err) {
|
|
546
|
+
res.writeHead(404);
|
|
547
|
+
return res.end("Index-File Not Found");
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
} else {
|
|
551
|
+
// use built in view engine
|
|
552
|
+
try {
|
|
553
|
+
const html = await this.toHTML(loggedin); // render this blog to HTML
|
|
554
|
+
res.writeHead(200, {
|
|
555
|
+
"Content-Type": "text/html; charset=UTF-8",
|
|
556
|
+
});
|
|
557
|
+
res.end(html);
|
|
558
|
+
return;
|
|
559
|
+
} catch (err) {
|
|
560
|
+
console.error(err);
|
|
561
|
+
if (!res.headersSent) {
|
|
562
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
563
|
+
res.end("Internal Server Error");
|
|
564
|
+
}
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
},
|
|
569
|
+
this.#publicDir,
|
|
570
|
+
);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
this.#server = server;
|
|
574
|
+
|
|
575
|
+
return new Promise((resolve, reject) => {
|
|
576
|
+
const errorHandler = (err) => reject(err);
|
|
577
|
+
this.#server.once("error", errorHandler);
|
|
578
|
+
this.#server.listen(port, "0.0.0.0", () => {
|
|
579
|
+
// <-- for docker 0.0.0.0, localhost 127.0.0.1
|
|
580
|
+
this.#server.removeListener("error", errorHandler);
|
|
581
|
+
console.log(`server running at http://localhost:${port}/`);
|
|
582
|
+
resolve(); // Resolve the promise when the server is listening
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
} // http server
|
|
586
|
+
|
|
587
|
+
async closeServer() {
|
|
588
|
+
return new Promise((resolve, reject) => {
|
|
589
|
+
if (this.#server) {
|
|
590
|
+
// if server is running
|
|
591
|
+
this.#server.close((err) => {
|
|
592
|
+
if (err && err.code !== "ERR_SERVER_NOT_RUNNING") return reject(err);
|
|
593
|
+
console.log("Server closed.");
|
|
594
|
+
resolve();
|
|
595
|
+
});
|
|
596
|
+
} else {
|
|
597
|
+
// server is not running
|
|
598
|
+
resolve(); // Nothing to close
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Populates the blog's title and articles from a data object. */
|
|
604
|
+
#applyBlogData(data) {
|
|
605
|
+
debug("applyBlogData");
|
|
606
|
+
if (this.#articles.storage.clear) {
|
|
607
|
+
this.#articles.storage.clear();
|
|
608
|
+
} else {
|
|
609
|
+
//this.#articles.setDataModel(this.#makeDataModel()); // Fallback if clear() isn't implemented
|
|
610
|
+
}
|
|
611
|
+
this.#title = data.title;
|
|
612
|
+
// Assuming data contains a title and an array of articles with title and content
|
|
613
|
+
if (this.#articles && data.articles && Array.isArray(data.articles)) {
|
|
614
|
+
if (this.#articles.getStorageName().includes("BinarySearchTree")) {
|
|
615
|
+
debug("using special insert method for BST");
|
|
616
|
+
const insertBalanced = (start, end) => {
|
|
617
|
+
if (start > end) return;
|
|
618
|
+
const mid = Math.floor((start + end) / 2);
|
|
619
|
+
const articleData = data.articles[mid];
|
|
620
|
+
const article = new Article(
|
|
621
|
+
articleData.id,
|
|
622
|
+
articleData.title,
|
|
623
|
+
articleData.content,
|
|
624
|
+
articleData.createdAt,
|
|
625
|
+
);
|
|
626
|
+
this.addArticle(article);
|
|
627
|
+
insertBalanced(start, mid - 1);
|
|
628
|
+
insertBalanced(mid + 1, end);
|
|
629
|
+
};
|
|
630
|
+
insertBalanced(0, data.articles.length - 1);
|
|
631
|
+
} else {
|
|
632
|
+
for (const articleData of data.articles) {
|
|
633
|
+
const article = new Article(
|
|
634
|
+
articleData.id,
|
|
635
|
+
articleData.title,
|
|
636
|
+
articleData.content,
|
|
637
|
+
articleData.createdAt,
|
|
638
|
+
);
|
|
639
|
+
this.addArticle(article);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
async #loadFromAPI() {
|
|
646
|
+
const data = await fetchData(this.#apiUrl);
|
|
647
|
+
if (data) {
|
|
648
|
+
this.#applyBlogData(data);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// controller
|
|
653
|
+
/** everything that happens in /api */
|
|
654
|
+
async #jsonAPI(req, res) {
|
|
655
|
+
const origin = req.headers.origin;
|
|
656
|
+
if (origin) {
|
|
657
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
658
|
+
res.setHeader("Access-Control-Allow-Credentials", "true");
|
|
659
|
+
res.setHeader(
|
|
660
|
+
"Access-Control-Allow-Methods",
|
|
661
|
+
"GET, POST, PUT, DELETE, OPTIONS",
|
|
662
|
+
);
|
|
663
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
664
|
+
}
|
|
665
|
+
if (req.method === "OPTIONS") {
|
|
666
|
+
res.writeHead(204);
|
|
667
|
+
res.end();
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
|
|
671
|
+
const pathname = url.pathname;
|
|
672
|
+
|
|
673
|
+
if (req.method === "GET") {
|
|
674
|
+
if (pathname === "/api" || pathname === "/api/") {
|
|
675
|
+
debug("GET /api");
|
|
676
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
677
|
+
const data = {
|
|
678
|
+
title: this.title,
|
|
679
|
+
};
|
|
680
|
+
res.end(JSON.stringify(data));
|
|
681
|
+
}
|
|
682
|
+
// Search
|
|
683
|
+
if (url.searchParams.has("q")) {
|
|
684
|
+
debug("GET search article by query");
|
|
685
|
+
const query = url.searchParams.get("q");
|
|
686
|
+
const pLimit = parseInt(url.searchParams.get("limit"));
|
|
687
|
+
const limit = !isNaN(pLimit) ? pLimit : null;
|
|
688
|
+
debug("GET search article by query %s with limit %s", query, limit);
|
|
689
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
690
|
+
const results = this.#articles.search(query, limit);
|
|
691
|
+
res.end(JSON.stringify({ articles: results }));
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
// GET article by ID
|
|
695
|
+
// /api/articles/1
|
|
696
|
+
const match = pathname.match(/^\/api\/articles\/(\d+)$/);
|
|
697
|
+
if (match) {
|
|
698
|
+
debug("GET article by id");
|
|
699
|
+
const id = parseInt(match[1]);
|
|
700
|
+
debug("GET article by id %d", id);
|
|
701
|
+
//console.log(this.#articles.getAllArticles());
|
|
702
|
+
const article = this.#articles.get(id);
|
|
703
|
+
if (article) {
|
|
704
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
705
|
+
res.end(JSON.stringify(article));
|
|
706
|
+
} else {
|
|
707
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
708
|
+
res.end(JSON.stringify({ error: "Not Found" }));
|
|
709
|
+
}
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
// GET all blog data
|
|
713
|
+
if (pathname === "/api/articles") {
|
|
714
|
+
debug("GET all articles");
|
|
715
|
+
// Use 'offset' param as startId (filter) to get items starting at ID
|
|
716
|
+
const pLimit = parseInt(url.searchParams.get("limit"));
|
|
717
|
+
const limit = !isNaN(pLimit) ? pLimit : null;
|
|
718
|
+
|
|
719
|
+
const start = url.searchParams.get("startdate");
|
|
720
|
+
const end = url.searchParams.get("enddate");
|
|
721
|
+
|
|
722
|
+
debug("startdate: %d, enddate: %d, limit: %d", start, end, limit);
|
|
723
|
+
|
|
724
|
+
//const parsedStart = parseDateParam(qStartdate);
|
|
725
|
+
//const parsedEnd = parseDateParam(qEnddate, true);
|
|
726
|
+
|
|
727
|
+
//const effectiveStart = parsedStart !== null ? parsedStart : startID;
|
|
728
|
+
//const effectiveEnd = parsedEnd !== null ? parsedEnd : endID;
|
|
729
|
+
|
|
730
|
+
// controller
|
|
731
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
732
|
+
const dbArticles = await this.#articles.findAll(start, end, limit);
|
|
733
|
+
const responseData = {
|
|
734
|
+
title: this.title, // Keep the title from the original constant
|
|
735
|
+
articles: dbArticles,
|
|
736
|
+
};
|
|
737
|
+
res.end(JSON.stringify(responseData));
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// POST a new article
|
|
741
|
+
} else if (req.method === "POST" && pathname === "/api/articles") {
|
|
742
|
+
debug("POST an article");
|
|
743
|
+
if (!this.#isAuthenticated(req)) {
|
|
744
|
+
debug("not authenticated");
|
|
745
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
746
|
+
res.end(JSON.stringify({ error: "Forbidden" }));
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
const body = await new Promise((resolve, reject) => {
|
|
750
|
+
let data = "";
|
|
751
|
+
req.on("data", (chunk) => (data += chunk.toString()));
|
|
752
|
+
req.on("end", () => resolve(data));
|
|
753
|
+
req.on("error", reject);
|
|
754
|
+
});
|
|
755
|
+
const newArticle = JSON.parse(body);
|
|
756
|
+
debug("new article: %s", newArticle.title);
|
|
757
|
+
// local
|
|
758
|
+
await this.#databaseModel.save(newArticle); // --> to api server
|
|
759
|
+
this.postArticle(newArticle);
|
|
760
|
+
// external
|
|
761
|
+
res.writeHead(201, { "Content-Type": "application/json" });
|
|
762
|
+
res.end(JSON.stringify(newArticle));
|
|
763
|
+
} else if (req.method === "DELETE") {
|
|
764
|
+
debug("DELETE an article");
|
|
765
|
+
const match = pathname.match(/^\/api\/articles\/(\d+)$/);
|
|
766
|
+
if (pathname === "/api/articles" || match) {
|
|
767
|
+
if (!this.#isAuthenticated(req)) {
|
|
768
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
769
|
+
res.end(JSON.stringify({ error: "Forbidden" }));
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
const id = match ? match[1] : url.searchParams.get("id");
|
|
773
|
+
debug("delete an article by id $d", id);
|
|
774
|
+
if (id) {
|
|
775
|
+
this.#articles.remove(parseInt(id));
|
|
776
|
+
if (this.#databaseModel) {
|
|
777
|
+
await this.#databaseModel.remove(parseInt(id));
|
|
778
|
+
}
|
|
779
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
780
|
+
res.end(JSON.stringify({ status: "deleted", id }));
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
} else if (req.method === "PUT") {
|
|
784
|
+
debug("PUT an article");
|
|
785
|
+
if (!this.#isAuthenticated(req)) {
|
|
786
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
787
|
+
res.end(JSON.stringify({ error: "Forbidden" }));
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
const match = pathname.match(/^\/api\/articles\/(\d+)$/);
|
|
791
|
+
if (pathname === "/api/articles" || match) {
|
|
792
|
+
const id = match ? match[1] : url.searchParams.get("id");
|
|
793
|
+
debug("PUT article id: %d", id);
|
|
794
|
+
const body = await new Promise((resolve) => {
|
|
795
|
+
let data = "";
|
|
796
|
+
req.on("data", (chunk) => (data += chunk));
|
|
797
|
+
req.on("end", () => resolve(data));
|
|
798
|
+
});
|
|
799
|
+
const { title, content } = JSON.parse(body);
|
|
800
|
+
this.#articles.update(parseInt(id), title, content);
|
|
801
|
+
if (this.#databaseModel) {
|
|
802
|
+
await this.#databaseModel.update(parseInt(id), { title, content });
|
|
803
|
+
}
|
|
804
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
805
|
+
res.end(JSON.stringify({ status: "updated", id }));
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/** set external API */
|
|
811
|
+
setAPI(APIUrl) {
|
|
812
|
+
this.#apiUrl = APIUrl;
|
|
813
|
+
this.#isExternalAPI = true;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** print markdown to the console */
|
|
817
|
+
print() {
|
|
818
|
+
const data = {
|
|
819
|
+
title: this.title,
|
|
820
|
+
articles: this.#articles.getAllArticles(),
|
|
821
|
+
};
|
|
822
|
+
const markdown = formatMarkdown(data);
|
|
823
|
+
console.log(markdown);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** render this blog content to valid html */
|
|
827
|
+
async toHTML(loggedin) {
|
|
828
|
+
const articles = this.#articles.getAllArticles();
|
|
829
|
+
const articles_after = articles.slice(0, 50);
|
|
830
|
+
// prettier-ignore
|
|
831
|
+
debug("slice articles from %d to %d", articles.length, articles_after.length);
|
|
832
|
+
const data = {
|
|
833
|
+
title: this.title,
|
|
834
|
+
articles: articles_after,
|
|
835
|
+
loggedin,
|
|
836
|
+
login: "",
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
if (loggedin)
|
|
840
|
+
data.login = `<form action="/logout" method="POST" style="display:inline;">
|
|
841
|
+
<button type="submit" class="btn">
|
|
842
|
+
logout
|
|
843
|
+
</button>
|
|
844
|
+
</form>`;
|
|
845
|
+
else data.login = `<a class="btn login" href="/login">login</a>`;
|
|
846
|
+
|
|
847
|
+
//debug("typeof data: %o", typeof data);
|
|
848
|
+
//debug("typeof data.articles: %o", typeof data.articles);
|
|
849
|
+
//debug("typeof data.articles: %O", data.articles);
|
|
850
|
+
const article = data.articles[0];
|
|
851
|
+
debug("first article: ");
|
|
852
|
+
debug("%d %s %s", article.id, article.title, article.getContentVeryShort());
|
|
853
|
+
const html = formatHTML(data);
|
|
854
|
+
if (validate(html)) return html;
|
|
855
|
+
throw new Error("Error. Invalid HTML!");
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* read files, compare checksums, compile and write to public/styles.min.css
|
|
860
|
+
* @param {string[]} files - Array of css/scss file paths to process.
|
|
861
|
+
*/
|
|
862
|
+
async #processStylesheets(files) {
|
|
863
|
+
console.log("process stylesheets");
|
|
864
|
+
|
|
865
|
+
// Normalize input to array (handles string or array)
|
|
866
|
+
// "file1.css" --> ["file1.css"]
|
|
867
|
+
// ["file1.css", "file2.css",...]
|
|
868
|
+
const fileList = Array.isArray(files) ? files : [files];
|
|
869
|
+
const styleFiles = fileList.filter(
|
|
870
|
+
(f) =>
|
|
871
|
+
typeof f === "string" &&
|
|
872
|
+
(f.endsWith(".scss") || f.endsWith(".css")) &&
|
|
873
|
+
!f.endsWith(".min.css"),
|
|
874
|
+
);
|
|
875
|
+
//const scriptFiles = files.filter((f) => f.endsWith(".js") && !f.endsWith(".min.js"));
|
|
876
|
+
|
|
877
|
+
// --- Process Styles ---
|
|
878
|
+
if (styleFiles.length > 0) {
|
|
879
|
+
// read file
|
|
880
|
+
const fileData = await Promise.all(
|
|
881
|
+
styleFiles.sort().map(async (f) => {
|
|
882
|
+
const content = await fs.promises.readFile(f, "utf-8");
|
|
883
|
+
if (content == "") throw new Error("Invalid Filepath or empty file!");
|
|
884
|
+
return { path: f, content };
|
|
885
|
+
}),
|
|
886
|
+
);
|
|
887
|
+
|
|
888
|
+
// compute hash
|
|
889
|
+
const currentHash = crypto
|
|
890
|
+
.createHash("sha256")
|
|
891
|
+
.update(
|
|
892
|
+
fileData
|
|
893
|
+
.map((f) =>
|
|
894
|
+
crypto.createHash("sha256").update(f.content).digest("hex"),
|
|
895
|
+
)
|
|
896
|
+
.join(""),
|
|
897
|
+
)
|
|
898
|
+
.digest("hex");
|
|
899
|
+
|
|
900
|
+
// check if hash matches
|
|
901
|
+
if (currentHash !== this.#stylesHash && this.compilestyle) {
|
|
902
|
+
console.log("Style assets have changed. Recompiling...");
|
|
903
|
+
this.#stylesHash = currentHash;
|
|
904
|
+
|
|
905
|
+
// Compile styles using the standalone script from build-styles.js
|
|
906
|
+
this.compiledStyles = await compileStyles(fileData);
|
|
907
|
+
|
|
908
|
+
// generate a file
|
|
909
|
+
await fs.promises.mkdir(this.#publicDir, { recursive: true });
|
|
910
|
+
await fs.promises.writeFile(
|
|
911
|
+
path.join(this.#publicDir, "styles.min.css"),
|
|
912
|
+
this.compiledStyles + `\n/* source-hash: ${currentHash} */`,
|
|
913
|
+
);
|
|
914
|
+
} else {
|
|
915
|
+
console.log("styles are up-to-date");
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* read files, compare checksums, compile and write to public/scripts.min.js
|
|
922
|
+
* @param {string|string[]} files - File path(s) to process.
|
|
923
|
+
*/
|
|
924
|
+
async #processScripts(files) {
|
|
925
|
+
// Normalize input to array
|
|
926
|
+
const fileList = Array.isArray(files) ? files : [files];
|
|
927
|
+
const scriptFiles = fileList.filter(
|
|
928
|
+
(f) =>
|
|
929
|
+
typeof f === "string" && f.endsWith(".js") && !f.endsWith(".min.js"),
|
|
930
|
+
);
|
|
931
|
+
|
|
932
|
+
if (scriptFiles.length > 0) {
|
|
933
|
+
const fileData = await Promise.all(
|
|
934
|
+
scriptFiles.map(async (f) => {
|
|
935
|
+
const content = await fs.promises.readFile(f, "utf-8");
|
|
936
|
+
if (content == "") throw new Error("Invalid Filepath or empty file!");
|
|
937
|
+
return { path: f, content };
|
|
938
|
+
}),
|
|
939
|
+
);
|
|
940
|
+
|
|
941
|
+
const currentHash = crypto
|
|
942
|
+
.createHash("sha256")
|
|
943
|
+
.update(
|
|
944
|
+
fileData
|
|
945
|
+
.map((f) =>
|
|
946
|
+
crypto.createHash("sha256").update(f.content).digest("hex"),
|
|
947
|
+
)
|
|
948
|
+
.join(""),
|
|
949
|
+
)
|
|
950
|
+
.digest("hex");
|
|
951
|
+
|
|
952
|
+
if (!this.#scriptsHash) {
|
|
953
|
+
try {
|
|
954
|
+
const existing = await fs.promises.readFile(
|
|
955
|
+
path.join(this.#publicDir, "scripts.min.js"),
|
|
956
|
+
"utf-8",
|
|
957
|
+
);
|
|
958
|
+
const match = existing.match(/\/\* source-hash: ([a-f0-9]{64}) \*\//);
|
|
959
|
+
if (match) this.#scriptsHash = match[1];
|
|
960
|
+
} catch (err) {
|
|
961
|
+
// ignore
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
if (currentHash !== this.#scriptsHash) {
|
|
966
|
+
console.log("Script assets have changed. Recompiling...");
|
|
967
|
+
this.#scriptsHash = currentHash;
|
|
968
|
+
|
|
969
|
+
const compiledScripts = await compileScripts(fileData);
|
|
970
|
+
|
|
971
|
+
await fs.promises.mkdir(this.#publicDir, { recursive: true });
|
|
972
|
+
await fs.promises.writeFile(
|
|
973
|
+
path.join(this.#publicDir, "scripts.min.js"),
|
|
974
|
+
compiledScripts + `\n/* source-hash: ${currentHash} */`,
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|
package/Blog.js
CHANGED
|
@@ -169,6 +169,15 @@ export default class Blog {
|
|
|
169
169
|
return match ? this.sessions.has(match[1]) : false;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
async #readBody(req) {
|
|
173
|
+
return new Promise((resolve, reject) => {
|
|
174
|
+
let data = "";
|
|
175
|
+
req.on("data", (chunk) => (data += chunk.toString()));
|
|
176
|
+
req.on("end", () => resolve(data));
|
|
177
|
+
req.on("error", reject);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
172
181
|
async #handleLogin(req, res) {
|
|
173
182
|
debug("handle login");
|
|
174
183
|
const body = await new Promise((resolve, reject) => {
|
|
@@ -379,6 +388,11 @@ export default class Blog {
|
|
|
379
388
|
|
|
380
389
|
const server = http.createServer(async (req, res) => {
|
|
381
390
|
//debug("query %s", req.url);
|
|
391
|
+
await api(req, res, async (req, res) => {
|
|
392
|
+
await this.#jsonAPI(req, res);
|
|
393
|
+
});
|
|
394
|
+
if (res.headersSent) return;
|
|
395
|
+
|
|
382
396
|
await login(
|
|
383
397
|
req,
|
|
384
398
|
res,
|
|
@@ -442,59 +456,34 @@ export default class Blog {
|
|
|
442
456
|
});
|
|
443
457
|
if (res.headersSent) return;
|
|
444
458
|
|
|
445
|
-
await
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
459
|
+
await new1(req, res, async (req, res) => {
|
|
460
|
+
if (!this.#isAuthenticated(req)) {
|
|
461
|
+
debug("not authenticated");
|
|
462
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
463
|
+
res.end(JSON.stringify({ error: "Forbidden" }));
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
449
466
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
467
|
+
try {
|
|
468
|
+
const body = await this.#readBody(req);
|
|
469
|
+
const params = new URLSearchParams(body);
|
|
470
|
+
const articleData = Object.fromEntries(params.entries());
|
|
471
|
+
|
|
472
|
+
console.log("New Article Data:", articleData);
|
|
473
|
+
// local
|
|
474
|
+
await this.#databaseModel.save(articleData); // --> to api server
|
|
475
|
+
this.postArticle(articleData);
|
|
476
|
+
// external
|
|
477
|
+
|
|
478
|
+
// Success response
|
|
479
|
+
res.writeHead(302, { Location: "/" });
|
|
480
|
+
res.end();
|
|
481
|
+
} catch (err) {
|
|
482
|
+
if (!res.headersSent) {
|
|
483
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
484
|
+
res.end(JSON.stringify({ error: "Failed to parse form data" }));
|
|
458
485
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
// 1. Collect data chunks
|
|
462
|
-
req.on("data", (chunk) => {
|
|
463
|
-
body += chunk.toString();
|
|
464
|
-
});
|
|
465
|
-
|
|
466
|
-
req.on("end", async () => {
|
|
467
|
-
try {
|
|
468
|
-
// 2. Parse x-www-form-urlencoded using URLSearchParams
|
|
469
|
-
const params = new URLSearchParams(body);
|
|
470
|
-
|
|
471
|
-
// 3. Convert to a plain object
|
|
472
|
-
const articleData = Object.fromEntries(params.entries());
|
|
473
|
-
|
|
474
|
-
console.log("New Article Data:", articleData);
|
|
475
|
-
// local
|
|
476
|
-
await this.#databaseModel.save(articleData); // --> to api server
|
|
477
|
-
this.postArticle(articleData);
|
|
478
|
-
// external
|
|
479
|
-
|
|
480
|
-
// Success response
|
|
481
|
-
res.writeHead(302, { Location: "/" });
|
|
482
|
-
res.end();
|
|
483
|
-
resolve();
|
|
484
|
-
} catch (err) {
|
|
485
|
-
if (!res.headersSent) {
|
|
486
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
487
|
-
res.end(JSON.stringify({ error: "Failed to parse form data" }));
|
|
488
|
-
}
|
|
489
|
-
resolve();
|
|
490
|
-
}
|
|
491
|
-
});
|
|
492
|
-
req.on("error", reject);
|
|
493
|
-
});
|
|
494
|
-
|
|
495
|
-
/*await this.#handleLogin(req, res, async () => {
|
|
496
|
-
await this.#handleLogin(req, res);
|
|
497
|
-
})*/
|
|
486
|
+
}
|
|
498
487
|
});
|
|
499
488
|
if (res.headersSent) return;
|
|
500
489
|
|
|
@@ -568,6 +557,12 @@ export default class Blog {
|
|
|
568
557
|
},
|
|
569
558
|
this.#publicDir,
|
|
570
559
|
);
|
|
560
|
+
if (res.headersSent) return;
|
|
561
|
+
|
|
562
|
+
// If no route was matched, send a 404
|
|
563
|
+
debug("unmatched route: %s %s", req.method, req.url);
|
|
564
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
565
|
+
res.end("Not Found");
|
|
571
566
|
});
|
|
572
567
|
|
|
573
568
|
this.#server = server;
|
|
@@ -849,7 +844,12 @@ export default class Blog {
|
|
|
849
844
|
//debug("typeof data.articles: %O", data.articles);
|
|
850
845
|
const article = data.articles[0];
|
|
851
846
|
debug("first article: ");
|
|
852
|
-
debug(
|
|
847
|
+
debug(
|
|
848
|
+
"%d %s %s...",
|
|
849
|
+
article.id,
|
|
850
|
+
article.title,
|
|
851
|
+
article.getContentVeryShort(),
|
|
852
|
+
);
|
|
853
853
|
const html = formatHTML(data);
|
|
854
854
|
if (validate(html)) return html;
|
|
855
855
|
throw new Error("Error. Invalid HTML!");
|
package/package.json
CHANGED
package/public/styles.min.css
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
body{font-family:Arial,sans-serif}h1{color:#333}:root{--black:#111;--clearwhite:#fefefe;--white:#eee;--darkgray:rgba(54,54,54,.5);--text-primary:var(--black);--text-secondary:var(--clearwhite)}body,html{margin:0}html{font-size:clamp(.85rem,1vw + .5rem,1.1rem)}body{background:var(--white_darker);color:var(--text-primary);font-family:Arial}nav{align-items:center;background:#ebebeb;display:flex;margin-top:10px;max-width:
|
|
2
|
-
/* source-hash:
|
|
1
|
+
body{font-family:Arial,sans-serif}h1{color:#333}:root{--black:#111;--clearwhite:#fefefe;--white:#eee;--darkgray:rgba(54,54,54,.5);--text-primary:var(--black);--text-secondary:var(--clearwhite);--max-width:600px}body,html{margin:0;overflow-x:hidden}html{font-size:clamp(.85rem,1vw + .5rem,1.1rem)}body{background:var(--white_darker);color:var(--text-primary);font-family:Arial}nav{align-items:center;background:#ebebeb;box-sizing:border-box;display:flex;margin-top:10px;max-width:var(--max-width);padding:5px}button,form input,form textarea{box-sizing:border-box;padding:5px 10px}button{width:-moz-fit-content;width:fit-content;field-sizing:content;padding:10px}input,textarea{border:1px solid #ababab}input,input:focus,textarea,textarea:focus{box-shadow:6px 6px 1px 1px rgba(50,50,50,.2)}input:focus,textarea:focus{background-color:var(--clearwhite);border:2px solid #3b40c1;outline:none}.form_element{display:block;font-family:monospace;font-size:103%;margin-bottom:10px;padding:4px}.wide{width:100%}.password{width:250px}.new_title{height:35px;padding:10px}.new_content{height:300px;padding:10px;resize:none}#createNew{max-width:var(--max-width);width:100%}#search{border:1px solid var(--text-primary);box-shadow:none;font-size:1rem;margin-left:auto;padding:.4rem 1rem}hr{margin:40px 0}.articles,hr{max-width:var(--max-width)}.articles{border:0 solid #000;display:grid;gap:.25rem;grid-template-columns:1fr}.articles article{border:2px solid #a9a9a9;border-radius:4px;margin-bottom:10px;min-width:0;overflow-wrap:break-word;padding:.4rem}.articles article h2{color:#353535;margin-bottom:5px}.articles article .datetime{color:#757575;margin:0}.articles article p{margin-bottom:0;margin-top:10px}article a,article a:visited,h1{color:#696969}h2{border:0 solid #000;margin-top:0}nav a{color:#3b40c1;font-size:20px;text-decoration:underline}nav a:visited{color:#3b40c1;text-decoration-color:#3b40c1}.loginform{margin-left:25px}#wrapper{margin-left:0;max-width:1200px;padding:0 20px}#wrapper,.buttons{box-sizing:border-box;width:100%}.buttons{align-items:center;border:0 solid #000;display:flex;gap:5px;height:25px;list-style:none;margin:0 0 16px;padding:15px 15px 15px 0}.btn{border:none;border:1px solid var(--text-primary);border-radius:0;box-shadow:3px 2px 2px var(--darkgray);color:var(--text-primary);cursor:pointer;font-size:1rem;font-weight:500;padding:.4rem 1rem;text-decoration:none;width:-moz-fit-content;width:fit-content}.btn:hover{background-color:#fff;border:2px solid var(--black);color:#000}.light{background:var(--clearwhite);color:var(--black)}.light:hover{background:var(--black);color:var(--clearwhite)}.login{font-weight:600}.hide-image{display:none}.edit{background-color:blue}.delete,.edit{color:var(--clearwhite)}.delete{background-color:red}@media screen and (min-width:1000px){#wrapper,body{font-size:.75rem;margin:0 auto;max-width:1400px;padding:0 40px;width:90%}.articles article p,.form_element{font-size:105%}}
|
|
2
|
+
/* source-hash: 9dc9009bca6e59acfb055334bd4573d078c478e8fc31b6ca204010e282075c24 */
|
package/router.js
CHANGED
|
@@ -61,13 +61,12 @@ export async function handleLogin(req, res, cb) {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
export async function getPages(req, res, cb, publicDir) {
|
|
64
|
-
if (req.method === "GET"
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
//await cb2(req, res);
|
|
64
|
+
if (req.method === "GET") {
|
|
65
|
+
if (req.url === "/") {
|
|
66
|
+
debug(`${req.method} ${req.url}`);
|
|
67
|
+
await cb(req, res);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
71
70
|
// Try to serve static files from public folder
|
|
72
71
|
// Normalize path to prevent directory traversal attacks
|
|
73
72
|
const safePath = path.normalize(req.url).replace(/^(\.\.[\/\\])+/, "");
|
package/src/styles.css
CHANGED
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
--darkgray: rgba(54, 54, 54, 0.5);
|
|
6
6
|
--text-primary: var(--black);
|
|
7
7
|
--text-secondary: var(--clearwhite);
|
|
8
|
+
--max-width: 600px;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
html,
|
|
11
12
|
body {
|
|
12
13
|
margin: 0;
|
|
14
|
+
overflow-x: hidden;
|
|
13
15
|
}
|
|
14
16
|
html {
|
|
15
17
|
font-size: clamp(0.85rem, 1vw + 0.5rem, 1.1rem);
|
|
@@ -23,14 +25,19 @@ nav {
|
|
|
23
25
|
margin-top: 10px;
|
|
24
26
|
display: flex;
|
|
25
27
|
align-items: center;
|
|
26
|
-
max-width:
|
|
28
|
+
max-width: var(--max-width);
|
|
27
29
|
background: hsl(0deg 0% 92.27%);
|
|
28
30
|
padding: 5px;
|
|
31
|
+
box-sizing: border-box;
|
|
29
32
|
}
|
|
30
33
|
nav,
|
|
31
34
|
#header h1 {
|
|
32
35
|
}
|
|
33
36
|
|
|
37
|
+
#header {
|
|
38
|
+
max-width: var(--max-width);
|
|
39
|
+
}
|
|
40
|
+
|
|
34
41
|
form input,
|
|
35
42
|
form textarea,
|
|
36
43
|
button {
|
|
@@ -44,6 +51,10 @@ button {
|
|
|
44
51
|
padding: 10px;
|
|
45
52
|
}
|
|
46
53
|
|
|
54
|
+
img {
|
|
55
|
+
max-width: 100%;
|
|
56
|
+
}
|
|
57
|
+
|
|
47
58
|
input,
|
|
48
59
|
textarea {
|
|
49
60
|
box-shadow: 6px 6px 1px 1px rgb(50 50 50 / 0.2);
|
|
@@ -87,7 +98,7 @@ textarea:focus {
|
|
|
87
98
|
|
|
88
99
|
#createNew {
|
|
89
100
|
width: 100%;
|
|
90
|
-
max-width:
|
|
101
|
+
max-width: var(--max-width);
|
|
91
102
|
}
|
|
92
103
|
|
|
93
104
|
#search {
|
|
@@ -99,7 +110,7 @@ textarea:focus {
|
|
|
99
110
|
}
|
|
100
111
|
|
|
101
112
|
hr {
|
|
102
|
-
max-width:
|
|
113
|
+
max-width: var(--max-width);
|
|
103
114
|
margin-left: 0;
|
|
104
115
|
margin: 40px 0;
|
|
105
116
|
}
|
|
@@ -109,7 +120,7 @@ hr {
|
|
|
109
120
|
display: grid;
|
|
110
121
|
gap: 0.25rem;
|
|
111
122
|
grid-template-columns: 1fr;
|
|
112
|
-
max-width:
|
|
123
|
+
max-width: var(--max-width);
|
|
113
124
|
}
|
|
114
125
|
.articles article {
|
|
115
126
|
border: 0 solid #ccc;
|
|
@@ -172,7 +183,7 @@ nav a:visited {
|
|
|
172
183
|
/*margin: 0 auto; /* Centers the layout on PC */
|
|
173
184
|
padding: 0 20px; /* Consistent spacing on edges */
|
|
174
185
|
width: 100%;
|
|
175
|
-
margin-left:
|
|
186
|
+
margin-left: 0;
|
|
176
187
|
box-sizing: border-box;
|
|
177
188
|
}
|
|
178
189
|
|