diff --git a/app/index.js b/app/index.js index 286d699..60669d5 100755 --- a/app/index.js +++ b/app/index.js @@ -8,6 +8,42 @@ import app from './server/app.js'; import debug from 'debug'; debug('pathtoglory:server'); 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. diff --git a/app/public/stylesheets/style.css b/app/public/stylesheets/style.css index 33c29bd..2642ed7 100644 --- a/app/public/stylesheets/style.css +++ b/app/public/stylesheets/style.css @@ -15,3 +15,7 @@ footer { border-top: 1px solid var(--primary); margin-top: 1rem; } + +li h2 { + display: inline-block; +} diff --git a/app/server/app.js b/app/server/app.js index 8979c01..9df0387 100644 --- a/app/server/app.js +++ b/app/server/app.js @@ -4,9 +4,11 @@ import path from 'path'; import cookieParser from 'cookie-parser'; import logger from 'morgan'; +import authenticateToken from './middlewares/authentication.js'; import indexRouter from './routes/index.js'; import usersRouter from './routes/users.js'; import postsRouter from './routes/posts.js'; +import conclaveRouter from './routes/conclave.js'; var app = express(); @@ -16,13 +18,14 @@ app.set('view engine', 'pug'); app.use(logger('dev')); app.use(express.json()); -app.use(express.urlencoded({ extended: false })); +app.use(express.urlencoded({ extended: true })); app.use(cookieParser()); // app.use(express.static(path.join(import.meta.dirname, 'public'))); app.use('/', indexRouter); app.use('/users', usersRouter); app.use('/posts', postsRouter); +app.use('/conclave', authenticateToken, conclaveRouter); // catch 404 and forward to error handler app.use(function(_req, _res, next) { diff --git a/app/server/controllers/Option.controller.js b/app/server/controllers/Option.controller.js new file mode 100644 index 0000000..67c7961 --- /dev/null +++ b/app/server/controllers/Option.controller.js @@ -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); + } + } +} diff --git a/app/server/controllers/Post.controller.js b/app/server/controllers/Post.controller.js index 9af9497..1f1c1d5 100644 --- a/app/server/controllers/Post.controller.js +++ b/app/server/controllers/Post.controller.js @@ -5,6 +5,8 @@ export default class PostController { let queryText = "SELECT * FROM posts"; if (options && options.order === "asc") { + queryText += ' ORDER BY created_at ASC'; + } else { queryText += ' ORDER BY created_at DESC'; } diff --git a/app/server/db/migrations/2026-03-13_21:00_create_website_infos.sql b/app/server/db/migrations/2026-03-13_21:00_create_website_infos.sql new file mode 100644 index 0000000..351cffe --- /dev/null +++ b/app/server/db/migrations/2026-03-13_21:00_create_website_infos.sql @@ -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; diff --git a/app/server/middlewares/authentication.js b/app/server/middlewares/authentication.js index e8003e2..c475674 100644 --- a/app/server/middlewares/authentication.js +++ b/app/server/middlewares/authentication.js @@ -5,6 +5,7 @@ const authenticateToken = function(req, res, next) { if(!token) { res.redirect("/"); + return; } const data = token.split('.'); diff --git a/app/server/routes/conclave.js b/app/server/routes/conclave.js new file mode 100644 index 0000000..2b22acf --- /dev/null +++ b/app/server/routes/conclave.js @@ -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; diff --git a/app/server/routes/index.js b/app/server/routes/index.js index 3a7d06d..f9cb2be 100644 --- a/app/server/routes/index.js +++ b/app/server/routes/index.js @@ -1,21 +1,12 @@ import express from 'express'; -var router = express.Router(); import PostController from '../controllers/Post.controller.js'; -import authenticateToken from '../middlewares/authentication.js'; + +var router = express.Router(); /* GET home page. */ router.get('/', async function(_, res) { 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; diff --git a/app/server/rss/index.js b/app/server/rss/index.js new file mode 100644 index 0000000..5dfabb8 --- /dev/null +++ b/app/server/rss/index.js @@ -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; +})(); diff --git a/app/server/rss/rssGenerator.js b/app/server/rss/rssGenerator.js new file mode 100644 index 0000000..ceb6eeb --- /dev/null +++ b/app/server/rss/rssGenerator.js @@ -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 += ''; + data += this.#generateChannelInfos(); + data += this.#generateItems(); + data += ''; + + this.#saveFile(data); + return this; + } + + #generateChannelInfos() { + let infos = ''; + + infos += `${this.channel.title}\n`; + infos += `${this.channel.description}\n`; + infos += `${this.channel.link}\n`; + infos += `${RssGenerator.formatDate(this.channel.pubDate)}\n`; + infos += `\n`; + + return infos; + } + + #generateItems() { + let data = ''; + + for (const item of this.items) { + data += '\n'; + data += `${item.title}\n`; + data += `${item.link}\n`; + data += `${item.link}\n`; + data += `${item.description}\n`; + data += '\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); + } + } +} + diff --git a/app/server/rss/template.xml b/app/server/rss/template.xml new file mode 100644 index 0000000..01ca38a --- /dev/null +++ b/app/server/rss/template.xml @@ -0,0 +1,8 @@ + + + + $TITLE + $DESCRIPTION + $LINK + + diff --git a/app/server/views/conclave/admin_panel.pug b/app/server/views/conclave/admin_panel.pug new file mode 100644 index 0000000..6857e5c --- /dev/null +++ b/app/server/views/conclave/admin_panel.pug @@ -0,0 +1,6 @@ +extends ../layout + +block content + nav + a(href="/conclave/new_post") New post + a(href="/conclave/options") Options diff --git a/app/server/views/conclave/options.pug b/app/server/views/conclave/options.pug new file mode 100644 index 0000000..d0af2e1 --- /dev/null +++ b/app/server/views/conclave/options.pug @@ -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} diff --git a/app/server/views/conclave/options_edit.pug b/app/server/views/conclave/options_edit.pug new file mode 100644 index 0000000..c143566 --- /dev/null +++ b/app/server/views/conclave/options_edit.pug @@ -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") diff --git a/compose.yaml b/compose.yaml index 352d464..881478f 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,13 +1,13 @@ services: app: image: node:krypton-alpine - user: "node" restart: no depends_on: - db working_dir: /home/node/app volumes: - ./app:/home/node/app + - rss-data:/home/node/rss environment: DB_USER: myuser DB_PASSWORD: example @@ -50,6 +50,7 @@ services: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx/ssl:/etc/nginx/ssl:ro - ./app/public/:/usr/share/nginx/html:ro + - rss-data:/usr/share/nginx/rss:ro develop: watch: - path: ./app/public @@ -66,3 +67,4 @@ networks: volumes: pgdata: + rss-data: diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 6c79758..35730d3 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -49,6 +49,10 @@ http { try_files $uri @app; } + location /rss.xml { + root /usr/share/nginx/rss; + } + location @app { proxy_pass http://app_servers; proxy_set_header Host $host;