refactor backend to multiple modules

This commit is contained in:
me 2025-03-15 10:00:24 +02:00
parent d8d32c7c01
commit 27b022876e
6 changed files with 330 additions and 231 deletions

61
backend/src/api.mjs Normal file
View File

@ -0,0 +1,61 @@
import express from 'express';
import utils from './utils.mjs';
import db from './db.mjs';
// server
const router = express.Router();
export default router;
router.use(express.json());
// POST
router.post('/:site/*', utils.limiter(20), (req, res) => {
const site = req.params.site;
const path = req.params[0];
if (!req.body.token || !req.body.message) {
res.status(400).json("הודעה ריקה.");
return;
}
let object = {
user: utils.escapeHtml(req.body.name) || "Anonymous",
user_website: utils.escapeHtml(req.body.website) || null,
message: utils.escapeHtml(req.body.message),
reply_to: req.body.reply_to || null,
site,
path,
};
// validation
const user_token = req.body.token;
const site_info = db.siteInfo(site);
console.log(site_info.comment_token, site_info.message_length_limit);
if (user_token !== site_info.comment_token) {
res.status(403).json("תשובת סינון שגויה.");
} else if (object.user.length > utils.MAX_LENGTHS.username) {
res.status(400).json("שם משתמש ארוך מדי.");
} else if (object.user_website > utils.MAX_LENGTHS.user_website) {
res.status(400).json("כתובת אתר ארוכה מדי.");
} else if (object.message > site_info.message_length_limit) {
res.status(400).json("הודעה ארוכה מדי.");
} else {
const comment = db.insertPageComment(object);
res.json(comment);
}
});
// GET
router.get('/:site/*', utils.limiter(500), (req, res) => {
const site = req.params.site;
const path = req.params[0];
const comments = db.pageComments(site, path);
res.json(comments);
});

141
backend/src/db.mjs Normal file
View File

@ -0,0 +1,141 @@
import Database from 'better-sqlite3';
import { migrate } from '@blackglory/better-sqlite3-migrations';
import utils from './utils.mjs';
// class
export class DB {
constructor() {
this.my_db = createDB();
}
siteInfo(site) {
return getSiteInfo(this.my_db, site);
}
siteComments(site) {
return getSiteComments(this.my_db, site);
}
pageComments(site, page) {
return getPageComments(this.my_db, site, page);
}
insertPageComment(comment) {
insertPageComment(this.my_db, comment);
}
}
const db = new DB();
export default db;
// migrations
function migrations() {
return [
{ version: 1,
up: `
CREATE TABLE site (
id integer primary key autoincrement,
url text not null,
comment_token text not null,
length_limit integer
);
CREATE TABLE comment (
id integer not null,
site integer not null,
path text not null,
user text not null,
user_website text,
message text not null,
published text default (datetime('now')),
reply_to integer,
FOREIGN KEY(site) REFERENCES site(id),
PRIMARY KEY (site, path, id)
);
`,
down: `
DROP TABLE comment;
DROP TABLE site;
`
}
];
}
// setup
function createDB() {
const db = new Database(process.env.DB || utils.DEFAULT_DB_PATH);
db.pragma('journal_mode = WAL');
migrate(db, migrations(), 1);
return db;
}
// queries
function getSiteInfo(db, site) {
return db.prepare(`SELECT comment_token, length_limit as message_length_limit FROM site WHERE url = ?`).get(site);
}
function insertPageComment(db, object) {
const stmt = db.prepare(`
INSERT INTO comment(id, site, path, user, user_website, message, reply_to)
SELECT
( SELECT count(*)
FROM (SELECT * FROM comment WHERE path = @path) c
JOIN (SELECT id FROM site WHERE url = @site) s
ON s.id = c.site
),
( SELECT id FROM site WHERE url = @site ),
@path,
@user,
@user_website,
@message,
@reply_to
RETURNING
id as id,
user,
user_website,
message,
published,
reply_to
;
`);
return stmt.all(object);
}
function getPageComments(db, site, path) {
const stmt = db.prepare(`
SELECT
c.id as id,
user,
user_website,
message,
published,
reply_to
FROM
(SELECT id from site where url = @site) s
JOIN (SELECT * FROM comment WHERE path = @path) c
ON c.site = s.id
ORDER BY c.id
;`);
return stmt.all({ site, path });
}
function getSiteComments(db, site) {
const stmt = db.prepare(`
SELECT
c.id,
s.url as site,
c.path,
c.user,
c.user_website,
c.message,
c.published
FROM
(SELECT id, url from site where url = @site) s
JOIN comment c
ON c.site = s.id
ORDER BY c.published DESC
;
`);
return stmt.all({ site });
}

44
backend/src/feed.mjs Normal file
View File

@ -0,0 +1,44 @@
import express from 'express';
import { Feed } from 'feed';
import utils from './utils.mjs';
import db from './db.mjs';
// Feed
const router = express.Router();
export default router;
router.use(express.json());
const domain = process.env.DOMAIN || utils.DEFAULT_DOMAIN;
router.get('/:site', utils.limiter(500), (req, res) => {
const site = req.params.site;
var feed = new Feed({
title: 'UCS',
description: 'תגובות עבור האתר ' + site,
id: domain + '/feed/' + site,
link: domain,
language: 'he'
});
const comments = db.siteComments(site);
for (const comment of comments) {
feed.addItem({
title: `New message by '${comment.user}' on ${comment.path}`,
description: comment.message,
id: `${comment.site}/${comment.path}#comment-${comment.id}`,
link: `${comment.site}/${comment.path}#comment-${comment.id}`,
date: new Date(comment.published)
});
}
var xml = feed.atom1();
res.set('Content-Type', 'text/xml');
res.send(xml);
});

View File

@ -1,247 +1,25 @@
import express from 'express';
import Database from 'better-sqlite3';
import { migrate } from '@blackglory/better-sqlite3-migrations';
import compression from 'compression';
import helmet from 'helmet';
import RateLimit from 'express-rate-limit';
import morgan from 'morgan';
import cors from 'cors';
import { Feed } from 'feed';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// Constants
const MAX_LENGTHS = {
username: 100,
user_website: 200,
message_body: 1000
};
// functions
function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// db
const db = new Database(process.env.DB || 'ucs.db'); // , { verbose: console.log });
db.pragma('journal_mode = WAL');
const migrations = [
{ version: 1,
up: `
CREATE TABLE site (
id integer primary key autoincrement,
url text not null,
comment_token text not null,
length_limit integer
);
CREATE TABLE comment (
id integer not null,
site integer not null,
path text not null,
user text not null,
user_website text,
message text not null,
published text default (datetime('now')),
reply_to integer,
FOREIGN KEY(site) REFERENCES site(id),
PRIMARY KEY (site, path, id)
);
`,
down: `
DROP TABLE comment;
DROP TABLE site;
`
}
];
migrate(db, migrations, 1);
import { app } from './server.mjs';
import db from './db.mjs';
import api from './api.mjs';
import feed from './feed.mjs';
import utils from './utils.mjs';
// server
const app = express();
const port = process.env.PORT || 8080;
app.use(express.urlencoded({ extended: true }));
app.use(compression());
app.use(
helmet.contentSecurityPolicy({
directives: {
"script-src": ["'self'"]
},
})
);
app.use(morgan('combined'));
const corsOptions = {
origin: ['https://alloca.space', 'https://www.alloca.space'],
optionsSuccessStatus: 200
};
app.use(cors(corsOptions));
// POST
const limiter20 = RateLimit({
windowMs: 1 * 60 * 1000,
max: 20,
});
app.use(express.json());
app.post('/api/:site/*', limiter20, (req, res) => {
const site = req.params.site;
const path = req.params[0];
if (!req.body.token || !req.body.message) {
res.status(400).json("הודעה ריקה.");
return;
}
let object = {
user: escapeHtml(req.body.name) || "Anonymous",
user_website: escapeHtml(req.body.website) || null,
message: escapeHtml(req.body.message),
reply_to: req.body.reply_to || null,
site,
path,
};
// validation
const user_token = req.body.token;
const site_info = db.prepare(`SELECT comment_token, length_limit as message_length_limit FROM site WHERE url = ?`).get(site);
console.log(site_info.comment_token, site_info.message_length_limit);
if (user_token !== site_info.comment_token) {
res.status(403).json("תשובת סינון שגויה.");
} else if (object.user.length > MAX_LENGTHS.username) {
res.status(400).json("שם משתמש ארוך מדי.");
} else if (object.user_website > MAX_LENGTHS.user_website) {
res.status(400).json("כתובת אתר ארוכה מדי.");
} else if (object.message > site_info.message_length_limit) {
res.status(400).json("הודעה ארוכה מדי.");
} else {
const stmt = db.prepare(`
INSERT INTO comment(id, site, path, user, user_website, message, reply_to)
SELECT
( SELECT count(*)
FROM (SELECT * FROM comment WHERE path = @path) c
JOIN (SELECT id FROM site WHERE url = @site) s
ON s.id = c.site
),
( SELECT id FROM site WHERE url = @site ),
@path,
@user,
@user_website,
@message,
@reply_to
RETURNING
id as id,
user,
user_website,
message,
published,
reply_to
;
`);
const comment = stmt.all(object);
res.json(comment);
}
});
// GET
const limiter500 = RateLimit({
windowMs: 1 * 60 * 1000,
max: 500,
});
app.use(limiter500);
app.use(utils.limiter(500));
app.use(express.static(path.join(path.dirname(fileURLToPath(import.meta.url)), "../public")));
app.get('/api/:site/*', (req, res) => {
const site = req.params.site;
const path = req.params[0];
const stmt = db.prepare(`
SELECT
c.id as id,
user,
user_website,
message,
published,
reply_to
FROM
(SELECT id from site where url = @site) s
JOIN (SELECT * FROM comment WHERE path = @path) c
ON c.site = s.id
ORDER BY c.id
;`);
const comments = stmt.all({ site, path });
res.json(comments);
});
// Feed
const domain = process.env.DOMAIN || 'comments.alloca.space';
app.get('/atom/:site', (req, res) => {
const site = req.params.site;
/* lets create an rss feed */
var feed = new Feed({
title: 'UCS',
description: 'תגובות עבור האתר ' + site,
id: domain + '/feed/' + site,
link: domain,
language: 'he'
});
const stmt = db.prepare(`
SELECT
c.id,
s.url as site,
c.path,
c.user,
c.user_website,
c.message,
c.published
FROM
(SELECT id, url from site where url = @site) s
JOIN comment c
ON c.site = s.id
ORDER BY c.published DESC
;`);
const comments = stmt.all({ site });
for (const comment of comments) {
feed.addItem({
title: `New message by '${comment.user}' on ${comment.path}`,
description: comment.message,
id: `${comment.site}/${comment.path}#comment-${comment.id}`,
link: `${comment.site}/${comment.path}#comment-${comment.id}`,
date: new Date(comment.published)
});
}
var xml = feed.atom1();
res.set('Content-Type', 'text/xml');
res.send(xml);
});
app.use('/api', api);
app.use('/atom', feed);
// Listen
const port = process.env.PORT || utils.DEFAULT_PORT;
app.listen(port, () => {
console.log(`Listening on port ${port}`);

29
backend/src/server.mjs Normal file
View File

@ -0,0 +1,29 @@
import express from 'express';
import compression from 'compression';
import helmet from 'helmet';
import morgan from 'morgan';
import cors from 'cors';
import utils from './utils.mjs';
// server
export const app = express();
app.use(express.urlencoded({ extended: true }));
app.use(compression());
app.use(
helmet.contentSecurityPolicy({
directives: {
"script-src": ["'self'"]
},
})
);
app.use(morgan('combined'));
const corsOptions = {
origin: utils.DEFAULT_CORS,
optionsSuccessStatus: 200
};
app.use(cors(corsOptions));

46
backend/src/utils.mjs Normal file
View File

@ -0,0 +1,46 @@
import RateLimit from 'express-rate-limit';
// Constants
export const MAX_LENGTHS = {
username: 100,
user_website: 200,
message_body: 1000
};
export const DEFAULT_PORT = 8080;
export const DEFAULT_CORS = ["alloca.space", "www.alloca.space"];
export const DEFAULT_DOMAIN = "comments.alloca.space";
export const DEFAULT_DB_PATH = "ucs.db";
// functions
export function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// limiters
export const limiter = limit => RateLimit({
windowMs: 1 * 60 * 1000,
max: limit,
});
export default {
MAX_LENGTHS,
DEFAULT_PORT,
DEFAULT_CORS,
DEFAULT_DOMAIN,
DEFAULT_DB_PATH,
escapeHtml,
limiter
};