feat: add blog_options and rss feed generation
This commit is contained in:
36
app/index.js
36
app/index.js
@@ -8,6 +8,42 @@ import app from './server/app.js';
|
|||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
debug('pathtoglory:server');
|
debug('pathtoglory:server');
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
|
import rssGenerator from './server/rss/index.js';
|
||||||
|
import PostController from './server/controllers/Post.controller.js';
|
||||||
|
import OptionController from './server/controllers/Option.controller.js';
|
||||||
|
|
||||||
|
// Check needed options
|
||||||
|
Promise.all([
|
||||||
|
OptionController.get('blog_name'),
|
||||||
|
OptionController.get('blog_description'),
|
||||||
|
`https://${process.env.RP_ID}/`,
|
||||||
|
PostController.getAll()
|
||||||
|
])
|
||||||
|
.then(([name, description, link, posts]) => {
|
||||||
|
rssGenerator.setChannel(
|
||||||
|
name || process.env.RP_NAME,
|
||||||
|
description || '',
|
||||||
|
link,
|
||||||
|
posts[0] ? new Date(posts[0].updated_at) : new Date()
|
||||||
|
);
|
||||||
|
|
||||||
|
posts
|
||||||
|
.map(post => {
|
||||||
|
return {
|
||||||
|
title: post.title,
|
||||||
|
description: post.content.slice(0, 255),
|
||||||
|
link: `${link}/posts/${post.id}`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.forEach(post => {
|
||||||
|
rssGenerator.addItem(post);
|
||||||
|
});
|
||||||
|
|
||||||
|
rssGenerator.generateFile();
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get port from environment and store in Express.
|
* Get port from environment and store in Express.
|
||||||
|
|||||||
@@ -15,3 +15,7 @@ footer {
|
|||||||
border-top: 1px solid var(--primary);
|
border-top: 1px solid var(--primary);
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
li h2 {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import path from 'path';
|
|||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import logger from 'morgan';
|
import logger from 'morgan';
|
||||||
|
|
||||||
|
import authenticateToken from './middlewares/authentication.js';
|
||||||
import indexRouter from './routes/index.js';
|
import indexRouter from './routes/index.js';
|
||||||
import usersRouter from './routes/users.js';
|
import usersRouter from './routes/users.js';
|
||||||
import postsRouter from './routes/posts.js';
|
import postsRouter from './routes/posts.js';
|
||||||
|
import conclaveRouter from './routes/conclave.js';
|
||||||
|
|
||||||
var app = express();
|
var app = express();
|
||||||
|
|
||||||
@@ -16,13 +18,14 @@ app.set('view engine', 'pug');
|
|||||||
|
|
||||||
app.use(logger('dev'));
|
app.use(logger('dev'));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.urlencoded({ extended: false }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
// app.use(express.static(path.join(import.meta.dirname, 'public')));
|
// app.use(express.static(path.join(import.meta.dirname, 'public')));
|
||||||
|
|
||||||
app.use('/', indexRouter);
|
app.use('/', indexRouter);
|
||||||
app.use('/users', usersRouter);
|
app.use('/users', usersRouter);
|
||||||
app.use('/posts', postsRouter);
|
app.use('/posts', postsRouter);
|
||||||
|
app.use('/conclave', authenticateToken, conclaveRouter);
|
||||||
|
|
||||||
// catch 404 and forward to error handler
|
// catch 404 and forward to error handler
|
||||||
app.use(function(_req, _res, next) {
|
app.use(function(_req, _res, next) {
|
||||||
|
|||||||
60
app/server/controllers/Option.controller.js
Normal file
60
app/server/controllers/Option.controller.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import dbClient from "../db/index.js";
|
||||||
|
|
||||||
|
export default class OptionController {
|
||||||
|
static async getAll() {
|
||||||
|
let queryText = 'SELECT * FROM blog_options';
|
||||||
|
try {
|
||||||
|
const res = await dbClient.query(queryText);
|
||||||
|
return res.rows;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async get(name) {
|
||||||
|
const res = await dbClient.query({
|
||||||
|
text: 'SELECT value FROM blog_options WHERE name=$1',
|
||||||
|
values: [name]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.rows[0]) {
|
||||||
|
return res.rows[0].value;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[OptionController] cannot find option ', name);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async create(name, value) {
|
||||||
|
const res = await dbClient.query({
|
||||||
|
text: 'INSERT INTO blog_options(name, value) VALUES($1, $2)',
|
||||||
|
values: [name, value]
|
||||||
|
});
|
||||||
|
console.log('[OptionController] create:', res.rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async update(name, value) {
|
||||||
|
try {
|
||||||
|
const res = await dbClient.query({
|
||||||
|
text: 'UPDATE blog_options SET value = $2 WHERE name = $1',
|
||||||
|
values: [name, value]
|
||||||
|
});
|
||||||
|
return res.rows;
|
||||||
|
} catch(err) {
|
||||||
|
console.error('[OptionController] update error: ', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async delete(name) {
|
||||||
|
try {
|
||||||
|
const res = dbClient.query({
|
||||||
|
text: 'DELETE FROM blog_options WHERE name=$1',
|
||||||
|
values: [name]
|
||||||
|
});
|
||||||
|
return res.rows;
|
||||||
|
} catch(err) {
|
||||||
|
console.error('[OptionController] delete error: ', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ export default class PostController {
|
|||||||
let queryText = "SELECT * FROM posts";
|
let queryText = "SELECT * FROM posts";
|
||||||
|
|
||||||
if (options && options.order === "asc") {
|
if (options && options.order === "asc") {
|
||||||
|
queryText += ' ORDER BY created_at ASC';
|
||||||
|
} else {
|
||||||
queryText += ' ORDER BY created_at DESC';
|
queryText += ' ORDER BY created_at DESC';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE blog_options (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
value TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX blog_options_name_index ON blog_options (name);
|
||||||
|
|
||||||
|
-- Remove useless index
|
||||||
|
DROP INDEX index_passkeys;
|
||||||
@@ -5,6 +5,7 @@ const authenticateToken = function(req, res, next) {
|
|||||||
|
|
||||||
if(!token) {
|
if(!token) {
|
||||||
res.redirect("/");
|
res.redirect("/");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = token.split('.');
|
const data = token.split('.');
|
||||||
|
|||||||
63
app/server/routes/conclave.js
Normal file
63
app/server/routes/conclave.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import PostController from '../controllers/Post.controller.js';
|
||||||
|
import OptionController from '../controllers/Option.controller.js';
|
||||||
|
|
||||||
|
var router = express.Router();
|
||||||
|
|
||||||
|
function handleOptionOperation (option) {
|
||||||
|
switch(option.action) {
|
||||||
|
case 'create':
|
||||||
|
OptionController.create(option.name, option.value);
|
||||||
|
break;
|
||||||
|
case 'update':
|
||||||
|
OptionController.update(option.name, option.value);
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
OptionController.delete(option.name);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/', function (_, res) {
|
||||||
|
res.render('conclave/admin_panel');
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/new_post', async function(_, res) {
|
||||||
|
res.render('conclave');
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/posts/new', async function(req, res) {
|
||||||
|
await PostController.create(req.body.title, req.body.content);
|
||||||
|
res.redirect('/conclave');
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/options', async function(_, res) {
|
||||||
|
const options = await OptionController.getAll();
|
||||||
|
res.render('conclave/options', { options });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/options/edit', async function (_, res) {
|
||||||
|
const options = await OptionController.getAll();
|
||||||
|
res.render('conclave/options_edit', { options });
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: currently we send the whole options even the one that arent changed. we need to update the front-end to only send the updated ones. I think we will need javascript.
|
||||||
|
router.post('/options/edit', async function (req, res) {
|
||||||
|
if (Array.isArray(req.body.name)) {
|
||||||
|
const options = req.body.name.map((name, index) => {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
action: req.body.action[index],
|
||||||
|
value: req.body.value[index]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
options.forEach(handleOptionOperation);
|
||||||
|
} else {
|
||||||
|
handleOptionOperation(req.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.redirect('/conclave/options');
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,21 +1,12 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
var router = express.Router();
|
|
||||||
import PostController from '../controllers/Post.controller.js';
|
import PostController from '../controllers/Post.controller.js';
|
||||||
import authenticateToken from '../middlewares/authentication.js';
|
|
||||||
|
var router = express.Router();
|
||||||
|
|
||||||
/* GET home page. */
|
/* GET home page. */
|
||||||
router.get('/', async function(_, res) {
|
router.get('/', async function(_, res) {
|
||||||
const posts = await PostController.getAll();
|
const posts = await PostController.getAll();
|
||||||
res.render('index', { title: 'Path to glory', posts: posts });
|
res.render('index', { posts: posts });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/conclave', authenticateToken, async function(_, res) {
|
|
||||||
res.render('conclave');
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/conclave/new', authenticateToken, async function(req, res) {
|
|
||||||
console.log(req.body);
|
|
||||||
await PostController.create(req.body.title, req.body.content);
|
|
||||||
res.redirect('/conclave');
|
|
||||||
});
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
13
app/server/rss/index.js
Normal file
13
app/server/rss/index.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { RssGenerator } from "./rssGenerator.js";
|
||||||
|
|
||||||
|
let generator = null;
|
||||||
|
|
||||||
|
export default (() => {
|
||||||
|
if (!generator) {
|
||||||
|
generator = Object.freeze(new RssGenerator({
|
||||||
|
filePath: '/home/node/rss/rss.xml',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return generator;
|
||||||
|
})();
|
||||||
123
app/server/rss/rssGenerator.js
Normal file
123
app/server/rss/rssGenerator.js
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
export class RssGenerator {
|
||||||
|
maxItem = 10;
|
||||||
|
items = [];
|
||||||
|
channel = {};
|
||||||
|
|
||||||
|
constructor(options) {
|
||||||
|
this.options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: move to pass an option parameter and iterate over the key/values
|
||||||
|
setChannel (title, description, link, pubDate) {
|
||||||
|
this.channel.title = title;
|
||||||
|
this.channel.description = description;
|
||||||
|
this.channel.link = link;
|
||||||
|
this.channel.pubDate = pubDate;
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
addItem (item) {
|
||||||
|
if (!this.isItemValid(item)) {
|
||||||
|
console.error('[RssGenerator] addItem: item invalid.', item);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.items.push(item);
|
||||||
|
|
||||||
|
if (this.items.lenght > this.maxItem) {
|
||||||
|
this.items.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
isItemValid(item) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
generateFile() {
|
||||||
|
let data = '';
|
||||||
|
|
||||||
|
data += '<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\"><channel>';
|
||||||
|
data += this.#generateChannelInfos();
|
||||||
|
data += this.#generateItems();
|
||||||
|
data += '</channel></rss>';
|
||||||
|
|
||||||
|
this.#saveFile(data);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
#generateChannelInfos() {
|
||||||
|
let infos = '';
|
||||||
|
|
||||||
|
infos += `<title>${this.channel.title}</title>\n`;
|
||||||
|
infos += `<description>${this.channel.description}</description>\n`;
|
||||||
|
infos += `<link>${this.channel.link}</link>\n`;
|
||||||
|
infos += `<pubDate>${RssGenerator.formatDate(this.channel.pubDate)}</pubDate>\n`;
|
||||||
|
infos += `<atom:link href="${this.channel.link}/rss.xml" rel="self" type="application/rss+xml" />\n`;
|
||||||
|
|
||||||
|
return infos;
|
||||||
|
}
|
||||||
|
|
||||||
|
#generateItems() {
|
||||||
|
let data = '';
|
||||||
|
|
||||||
|
for (const item of this.items) {
|
||||||
|
data += '<item>\n';
|
||||||
|
data += `<title>${item.title}</title>\n`;
|
||||||
|
data += `<link>${item.link}</link>\n`;
|
||||||
|
data += `<guid>${item.link}</guid>\n`;
|
||||||
|
data += `<description>${item.description}</description>\n`;
|
||||||
|
data += '</item>\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
static formatDate(date) {
|
||||||
|
return new Intl.DateTimeFormat("en-GB", {
|
||||||
|
hour12: false,
|
||||||
|
weekday: "short",
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
timeZone: "Europe/London",
|
||||||
|
}).formatToParts(date)
|
||||||
|
.filter(el => el.type !== "literal")
|
||||||
|
.reduce((acc, cur) => {
|
||||||
|
switch (cur.type) {
|
||||||
|
case "weekday":
|
||||||
|
acc += `${cur.value}, `;
|
||||||
|
break;
|
||||||
|
case "hour":
|
||||||
|
case "minute":
|
||||||
|
acc += `${cur.value}:`;
|
||||||
|
break;
|
||||||
|
case "timeZoneName":
|
||||||
|
acc += cur.value;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
acc += `${cur.value} `;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, '')
|
||||||
|
.concat(" GMT");
|
||||||
|
}
|
||||||
|
|
||||||
|
#saveFile(data) {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(this.options.filePath, data, { encoding: 'utf8' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[RssGenerator] saveFile - ERROR');
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
8
app/server/rss/template.xml
Normal file
8
app/server/rss/template.xml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>$TITLE</title>
|
||||||
|
<description>$DESCRIPTION</description>
|
||||||
|
<link>$LINK</link>
|
||||||
|
<channel>
|
||||||
|
</rss>
|
||||||
6
app/server/views/conclave/admin_panel.pug
Normal file
6
app/server/views/conclave/admin_panel.pug
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
extends ../layout
|
||||||
|
|
||||||
|
block content
|
||||||
|
nav
|
||||||
|
a(href="/conclave/new_post") New post
|
||||||
|
a(href="/conclave/options") Options
|
||||||
11
app/server/views/conclave/options.pug
Normal file
11
app/server/views/conclave/options.pug
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
extends ../layout
|
||||||
|
|
||||||
|
block content
|
||||||
|
a(href='/conclave/options/edit') Edit
|
||||||
|
case options.length
|
||||||
|
when 0
|
||||||
|
p There is no options at the moment
|
||||||
|
default
|
||||||
|
ul
|
||||||
|
each option in options
|
||||||
|
li #{option.name}: #{option.value}
|
||||||
16
app/server/views/conclave/options_edit.pug
Normal file
16
app/server/views/conclave/options_edit.pug
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
extends ../layout
|
||||||
|
|
||||||
|
block content
|
||||||
|
form(method="POST" action="/conclave/options/edit")
|
||||||
|
each option in options
|
||||||
|
fieldset(id=option.name)
|
||||||
|
input(value=option.name name="name" readonly type="text")
|
||||||
|
input(value="update" name="action" readonly type="hidden")
|
||||||
|
input(value=option.value name="value" type="text")
|
||||||
|
|
||||||
|
fieldset(id="new-one")
|
||||||
|
input(value="" name="name" type="text")
|
||||||
|
input(value="create" name="action" readonly type="hidden")
|
||||||
|
input(value="" name="value" type="text")
|
||||||
|
|
||||||
|
input(type="submit" value="Save")
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: node:krypton-alpine
|
image: node:krypton-alpine
|
||||||
user: "node"
|
|
||||||
restart: no
|
restart: no
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
working_dir: /home/node/app
|
working_dir: /home/node/app
|
||||||
volumes:
|
volumes:
|
||||||
- ./app:/home/node/app
|
- ./app:/home/node/app
|
||||||
|
- rss-data:/home/node/rss
|
||||||
environment:
|
environment:
|
||||||
DB_USER: myuser
|
DB_USER: myuser
|
||||||
DB_PASSWORD: example
|
DB_PASSWORD: example
|
||||||
@@ -50,6 +50,7 @@ services:
|
|||||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
- ./nginx/ssl:/etc/nginx/ssl:ro
|
- ./nginx/ssl:/etc/nginx/ssl:ro
|
||||||
- ./app/public/:/usr/share/nginx/html:ro
|
- ./app/public/:/usr/share/nginx/html:ro
|
||||||
|
- rss-data:/usr/share/nginx/rss:ro
|
||||||
develop:
|
develop:
|
||||||
watch:
|
watch:
|
||||||
- path: ./app/public
|
- path: ./app/public
|
||||||
@@ -66,3 +67,4 @@ networks:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
rss-data:
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ http {
|
|||||||
try_files $uri @app;
|
try_files $uri @app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /rss.xml {
|
||||||
|
root /usr/share/nginx/rss;
|
||||||
|
}
|
||||||
|
|
||||||
location @app {
|
location @app {
|
||||||
proxy_pass http://app_servers;
|
proxy_pass http://app_servers;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
Reference in New Issue
Block a user