feat: add basic blog features
This commit is contained in:
64
.gitignore
vendored
Normal file
64
.gitignore
vendored
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Typescript v1 declaration files
|
||||||
|
typings/
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variables file
|
||||||
|
.env
|
||||||
|
|
||||||
|
# next.js build output
|
||||||
|
.next
|
||||||
|
|
||||||
|
#postgres stuff
|
||||||
|
postgresql
|
||||||
44
app.js
Normal file
44
app.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import createError from 'http-errors';
|
||||||
|
import express from 'express';
|
||||||
|
import path from 'path';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import logger from 'morgan';
|
||||||
|
|
||||||
|
import indexRouter from './routes/index.js';
|
||||||
|
import usersRouter from './routes/users.js';
|
||||||
|
import postsRouter from './routes/posts.js';
|
||||||
|
|
||||||
|
const __dirname = import.meta.dirname;
|
||||||
|
var app = express();
|
||||||
|
|
||||||
|
// view engine setup
|
||||||
|
app.set('views', path.join(__dirname, 'views'));
|
||||||
|
app.set('view engine', 'pug');
|
||||||
|
|
||||||
|
app.use(logger('dev'));
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: false }));
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
app.use('/', indexRouter);
|
||||||
|
app.use('/users', usersRouter);
|
||||||
|
app.use('/posts', postsRouter);
|
||||||
|
|
||||||
|
// catch 404 and forward to error handler
|
||||||
|
app.use(function(req, res, next) {
|
||||||
|
next(createError(404));
|
||||||
|
});
|
||||||
|
|
||||||
|
// error handler
|
||||||
|
app.use(function(err, req, res, next) {
|
||||||
|
// set locals, only providing error in development
|
||||||
|
res.locals.message = err.message;
|
||||||
|
res.locals.error = req.app.get('env') === 'development' ? err : {};
|
||||||
|
|
||||||
|
// render the error page
|
||||||
|
res.status(err.status || 500);
|
||||||
|
res.render('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
24
controllers/Post.controller.js
Normal file
24
controllers/Post.controller.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import client from "../db/index.js";
|
||||||
|
|
||||||
|
export default class PostController {
|
||||||
|
static async getAll() {
|
||||||
|
const res = await client.query("SELECT * FROM posts");
|
||||||
|
return res.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async get(id) {
|
||||||
|
const res = await client.query({
|
||||||
|
text: "SELECT * FROM posts WHERE id=$1",
|
||||||
|
values: [id]
|
||||||
|
});
|
||||||
|
return res.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
static async create(title, content) {
|
||||||
|
const res = await client.query({
|
||||||
|
text: "INSERT INTO posts(title, content) VALUES($1, $2)",
|
||||||
|
values: [title, content]
|
||||||
|
});
|
||||||
|
console.log(res.rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
db/index.js
Normal file
15
db/index.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Pool } from 'pg';
|
||||||
|
|
||||||
|
const db = new Pool({
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
port: process.env.PORT,
|
||||||
|
database: process.env.DB_DATABASE,
|
||||||
|
max: 20,
|
||||||
|
idleTimeoutMillis: 30_000,
|
||||||
|
connectionTimeoutMillis: 2_000,
|
||||||
|
maxLifetimeSeconds: 60
|
||||||
|
});
|
||||||
|
|
||||||
|
export default db;
|
||||||
68
db/migrate.js
Normal file
68
db/migrate.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import { readdir } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import dbClient from "./index.js";
|
||||||
|
|
||||||
|
const __dirname = import.meta.dirname;
|
||||||
|
|
||||||
|
const createMigrationTable = () => {
|
||||||
|
return dbClient.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS migrations (
|
||||||
|
id VARCHAR(255) PRIMARY KEY,
|
||||||
|
name VARCHAR(255),
|
||||||
|
executed_at TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMigrationsFiles = () => {
|
||||||
|
try {
|
||||||
|
return readdir(path.resolve(__dirname, "./migrations"));
|
||||||
|
} catch(err) {
|
||||||
|
return new Error("[Migration] getMigrations err: ", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runMigrations = async () => {
|
||||||
|
try {
|
||||||
|
// Create migration table if needed
|
||||||
|
await createMigrationTable();
|
||||||
|
|
||||||
|
const migrationFiles = await getMigrationsFiles();
|
||||||
|
const migrationsDb = await dbClient.query("SELECT name FROM migrations WHERE executed_at IS NOT NULL");
|
||||||
|
const migrationsToRun = migrationFiles.filter(m => !migrationsDb.rows.map(m => m.name).includes(m));
|
||||||
|
|
||||||
|
console.log("[Migration] migrations to run: ", migrationsToRun);
|
||||||
|
|
||||||
|
for (const migration of migrationsToRun) {
|
||||||
|
await dbClient.query("BEGIN");
|
||||||
|
const sqlQuery = fs.readFileSync(path.resolve(__dirname, `./migrations/${migration}`), "utf-8");
|
||||||
|
try {
|
||||||
|
await dbClient.query(sqlQuery);
|
||||||
|
|
||||||
|
await dbClient.query({
|
||||||
|
text: "INSERT INTO migrations(id, name, executed_at) VALUES($1, $2, $3)",
|
||||||
|
values: [migration.slice(0, -4), migration, new Date().toISOString().slice(0, 19).replace('T', ' ')]
|
||||||
|
});
|
||||||
|
|
||||||
|
await dbClient.query("COMMIT");
|
||||||
|
} catch(err) {
|
||||||
|
console.log(err);
|
||||||
|
await dbClient.query("ROLLBACK");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[Migration]: SUCCESS");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error", err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run this from CLI
|
||||||
|
if (import.meta.main) {
|
||||||
|
console.log("Run migrations");
|
||||||
|
runMigrations()
|
||||||
|
.then(() => process.exit(0))
|
||||||
|
.catch(() => process.exit(1));
|
||||||
|
}
|
||||||
9
db/migrations/2026-02-22_18:00_create_blog_tables.sql
Normal file
9
db/migrations/2026-02-22_18:00_create_blog_tables.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE XTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
CREATE TABLE posts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
title VARCHAR(255) NULL UNIQUE,
|
||||||
|
content TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
29
db/migrations/2026-03-04_15:23_create_user_tables.sql
Normal file
29
db/migrations/2026-03-04_15:23_create_user_tables.sql
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
CREATE TABLE users (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL UNIQUE,
|
||||||
|
username VARCHAR(255)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE passkeys (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL UNIQUE,
|
||||||
|
public_key BYTEA,
|
||||||
|
webauthn_user__id TEXT UNIQUE,
|
||||||
|
counter BIGINT,
|
||||||
|
device_type VARCHAR(32),
|
||||||
|
transports VARCHAR(255)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- User/passkey junction table
|
||||||
|
CREATE TABLE user_passkeys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
passkey_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- Foreign key constraints
|
||||||
|
CONSTRAINT fk_user_passkeys_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_user_passkeys_passkeys FOREIGN KEY (passkey_id) REFERENCES passkeys(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Prevent duplicates
|
||||||
|
CONSTRAINT unique_user_passkeys UNIQUE (user_id, passkey_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX index_passkeys ON passkeys (id, webauthn_user__id);
|
||||||
16
entities/post/Post.model.js
Normal file
16
entities/post/Post.model.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export default class Post {
|
||||||
|
id;
|
||||||
|
title;
|
||||||
|
content;
|
||||||
|
created_at;
|
||||||
|
updated_at;
|
||||||
|
|
||||||
|
constructor(data) {
|
||||||
|
this.title = data.title;
|
||||||
|
this.content = data.content;
|
||||||
|
|
||||||
|
this.id = data.id || undefined;
|
||||||
|
this.created_at = data.created_at || undefined;
|
||||||
|
this.updated_at = data.updated_at || undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
98
index.js
Executable file
98
index.js
Executable file
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module dependencies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import app from './app.js';
|
||||||
|
import debug from 'debug';
|
||||||
|
debug('pathtoglory:server');
|
||||||
|
import https from 'https';
|
||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
const sslOptions = {
|
||||||
|
key: fs.readFileSync(path.join(import.meta.dirname, 'localhost-key.pem')),
|
||||||
|
cert: fs.readFileSync(path.join(import.meta.dirname, 'localhost.pem'))
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get port from environment and store in Express.
|
||||||
|
*/
|
||||||
|
|
||||||
|
var port = normalizePort(process.env.PORT || '1234');
|
||||||
|
app.set('port', port);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create HTTP server.
|
||||||
|
*/
|
||||||
|
|
||||||
|
var server = https.createServer(sslOptions, app);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen on provided port, on all network interfaces.
|
||||||
|
*/
|
||||||
|
|
||||||
|
server.listen(port);
|
||||||
|
server.on('error', onError);
|
||||||
|
server.on('listening', onListening);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a port into a number, string, or false.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function normalizePort(val) {
|
||||||
|
var port = parseInt(val, 10);
|
||||||
|
|
||||||
|
if (isNaN(port)) {
|
||||||
|
// named pipe
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (port >= 0) {
|
||||||
|
// port number
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event listener for HTTP server "error" event.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function onError(error) {
|
||||||
|
if (error.syscall !== 'listen') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bind = typeof port === 'string'
|
||||||
|
? 'Pipe ' + port
|
||||||
|
: 'Port ' + port;
|
||||||
|
|
||||||
|
// handle specific listen errors with friendly messages
|
||||||
|
switch (error.code) {
|
||||||
|
case 'EACCES':
|
||||||
|
console.error(bind + ' requires elevated privileges');
|
||||||
|
process.exit(1);
|
||||||
|
break;
|
||||||
|
case 'EADDRINUSE':
|
||||||
|
console.error(bind + ' is already in use');
|
||||||
|
process.exit(1);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event listener for HTTP server "listening" event.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function onListening() {
|
||||||
|
var addr = server.address();
|
||||||
|
var bind = typeof addr === 'string'
|
||||||
|
? 'pipe ' + addr
|
||||||
|
: 'port ' + addr.port;
|
||||||
|
debug('Listening on ' + bind);
|
||||||
|
}
|
||||||
28
localhost-key.pem
Normal file
28
localhost-key.pem
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCiYoJCZdHcVH/w
|
||||||
|
GFQZUhY59jxiJ6GlKbo2cu7ozp34WYyIKun5cPXyWxWUtY0yuf6jUd8+JtL/T0uo
|
||||||
|
6qBMdnznE8uP0Ol7XmiozZ156celbbhO4VFUDu0wesXGktAPQllmIF7tB3zUhb2R
|
||||||
|
6PUao8lzn/Um6kLD7FFriTqOJL4X2d9cpAVpvTmjl5YqNRwiPQPOhPH1yyOv6iin
|
||||||
|
aRjklk00krWlp1Ezd4gitTqzMdGc72cUIVvC3JWLFUUHG4Xjkl7va4FPWCz6P7Di
|
||||||
|
kq3gadnOE+sWuQNNyVEjnU4hwTUZ8AYEVM23Siq2+7IkKu/oiX+zeBCfZhLNgilf
|
||||||
|
eTMxxUyhAgMBAAECggEAReo1/VKLhdrX7s76vqAqM7CCFRzNKyiJJVJc7N2xBDHC
|
||||||
|
IQqhDKYHLt7qrslwTsvoB/eDL+ZVaFmC0OqcM++8HV3XgkdHj7d5RlypFcmDDQXt
|
||||||
|
mgDHHHMEyp/BsZqafEdr6F29oT5dD7+5fC4aAetNHDxdt/Ca6HJCKBPAo1zMf8W+
|
||||||
|
HXepvb3eTTIDG+msclY9b6gCPxuJU3rM+JzkohwClQJJe40lBu4qyMeiGYynjHY2
|
||||||
|
/98M21dja02RFBP66S+S9UaPQ5/h1LRMz2vI+njX3qgAWjdTsazoa0jzZsqbIl/u
|
||||||
|
6NnGlScIGDAQaBpkb2YhhCJxR7Q3f9PGd2leGo1zsQKBgQDITbw05KaKU1hZ0R+0
|
||||||
|
196rurSUDbsFmamwFouzRWSwTCMwJlGcoMQ0mChfwwWKAXiNJRlMf0biywIGZisD
|
||||||
|
CApjgpRNuOdIqy3rU4iDVxwj45XNNgobfF1ZKAHum5q9KjIchUZsI8htC7KgH0S6
|
||||||
|
zMsPbxk56ueKGXF6kyZpoW4Z1QKBgQDPiZYkPyk153J72O+V+kwOTOdGoBMETXdY
|
||||||
|
wPf8gd/j5oNwuQV7DWxMyq4ku4uc9fJn9rAxymHlbFqWce8h2jYVPOfak0Om6N2z
|
||||||
|
IDjlLKWzvjzpiUNZ8QsODfm4rFYni3sEhC0K3IkDzEDtSr3UakL66Uqjsqg4cn7S
|
||||||
|
Ik4u0r8hnQKBgQCq+l/7LmpSjQ5PrMjJz7LNGBRohMft4dsM6lHZdxSZwIQQ58Sm
|
||||||
|
VDznQDLGe2xQ/yxuHwrXV5WkpfFWkQOKFOT5SE9bgMg8KZKK28Udh9AHeo82mjhK
|
||||||
|
egAcyJ/Nk5mke05HNiSEzo6ZNnEFaWt7oLB8vjLkU3XNViadoNobNKcM+QKBgAku
|
||||||
|
KeESli0XPt4xm2+D8edUCYr7O7wd/SCE8LNPv2qiYMAUvyRRVLAU6x0e2q8nxgBJ
|
||||||
|
TkP1kt0GLP+orI5Py8Kmvg7SItT4Sg5JZ5rjnbTUvncKJluNKRMHFTvRC8KWDewG
|
||||||
|
OMPZO4pad6jHfJwv0ySsOywAlCZjEi8Ta2fw1JmVAoGASSUtaNsjsTJ5ikRXPZTQ
|
||||||
|
MS0Vb+leH5+M9umbV4sxqsY4oz6l3Dv7fEdigJmrrBTKlH5uRKFxqE91FcOYbyH4
|
||||||
|
C9HyzJgiujvfWoFqF5H0EoIpsHvtydGMr+JkHbwDSZfeRelFnUEFLhTQSu/fyHeW
|
||||||
|
FX/eT/HtOo4D1NKZ5UDG1O8=
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
24
localhost.pem
Normal file
24
localhost.pem
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIID/jCCAmagAwIBAgIRAIckhgeUs09k09IblD8x28EwDQYJKoZIhvcNAQELBQAw
|
||||||
|
VTEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMRUwEwYDVQQLDAxhcnRo
|
||||||
|
dXJAYmVhY2gxHDAaBgNVBAMME21rY2VydCBhcnRodXJAYmVhY2gwHhcNMjYwMzA0
|
||||||
|
MTY0OTQwWhcNMjgwNjA0MTU0OTQwWjBAMScwJQYDVQQKEx5ta2NlcnQgZGV2ZWxv
|
||||||
|
cG1lbnQgY2VydGlmaWNhdGUxFTATBgNVBAsMDGFydGh1ckBiZWFjaDCCASIwDQYJ
|
||||||
|
KoZIhvcNAQEBBQADggEPADCCAQoCggEBAKJigkJl0dxUf/AYVBlSFjn2PGInoaUp
|
||||||
|
ujZy7ujOnfhZjIgq6flw9fJbFZS1jTK5/qNR3z4m0v9PS6jqoEx2fOcTy4/Q6Xte
|
||||||
|
aKjNnXnpx6VtuE7hUVQO7TB6xcaS0A9CWWYgXu0HfNSFvZHo9RqjyXOf9SbqQsPs
|
||||||
|
UWuJOo4kvhfZ31ykBWm9OaOXlio1HCI9A86E8fXLI6/qKKdpGOSWTTSStaWnUTN3
|
||||||
|
iCK1OrMx0ZzvZxQhW8LclYsVRQcbheOSXu9rgU9YLPo/sOKSreBp2c4T6xa5A03J
|
||||||
|
USOdTiHBNRnwBgRUzbdKKrb7siQq7+iJf7N4EJ9mEs2CKV95MzHFTKECAwEAAaNe
|
||||||
|
MFwwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB8GA1UdIwQY
|
||||||
|
MBaAFNLXA4V6orI5Z7VVA/5i5ga0AjShMBQGA1UdEQQNMAuCCWxvY2FsaG9zdDAN
|
||||||
|
BgkqhkiG9w0BAQsFAAOCAYEAqJ/+hvJ/xFQ7GBzPteLNcfBtfokSJ5siynMokU3b
|
||||||
|
mOoKl+jR9r+4/X5aaSe5sOXAaE0kB3dliYxF9J4FMa5y4E5yluYAdt6vFYOI2u4/
|
||||||
|
zOuo1VStMhkStXc8h41jd2jfApoiZaicQYv8LwdJ34oHNq+phbUmxxggFLSd2tsD
|
||||||
|
oaVKUjMe15QvI8HsB+czmpPJUMO8u9ajHUxJZOKyYvTOjdefGMpjEhUpBwOCcFxd
|
||||||
|
TTybDD/+FmV3h/0m1U0CVrK+TN+hGje97DNK+bV7OxYL/RhoCFm99TOdKJxmV78X
|
||||||
|
AEIVb6360GVo7ySWxsFRXHu3vzBBoDPwWIieEksqCyfA7jhkaCKRVWZLmwUl4lrg
|
||||||
|
wGBYJCzibjFZihlgslGAavxBk1cL2Eu5KANkp+ybmP4assbzoXrTZGfY3Va10q3r
|
||||||
|
5u8oovVVTcVlthVGjpP90qSBogwdlDjtDYFq8PpTyIcqJQTneJ/F4KDf9gawOSqF
|
||||||
|
1KEt58J/uzK159CrXzJVIXJq
|
||||||
|
-----END CERTIFICATE-----
|
||||||
21
middlewares/authentication.js
Normal file
21
middlewares/authentication.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
const authenticateToken = function(req, res, next) {
|
||||||
|
const token = req.cookies.auth_token;
|
||||||
|
|
||||||
|
if(!token) {
|
||||||
|
res.redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = token.split('.');
|
||||||
|
|
||||||
|
const hash = crypto.createHmac('sha256', process.env.AUTH_JWT_SECRET).update(data[0] + '.' + data[1]).digest('base64url');
|
||||||
|
|
||||||
|
if (hash === data[2]) {
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
res.redirect('/users/login');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default authenticateToken;
|
||||||
12
middlewares/limitedUsers.js
Normal file
12
middlewares/limitedUsers.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import dbClient from '../db/index.js';
|
||||||
|
|
||||||
|
async function limitedUsers(_, res, next) {
|
||||||
|
const countQuery = await dbClient.query('SELECT COUNT(*) FROM users');
|
||||||
|
if(countQuery.rows[0].count > 0) {
|
||||||
|
res.redirect('/users/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default limitedUsers;
|
||||||
2211
package-lock.json
generated
Normal file
2211
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
package.json
Normal file
21
package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "pathtoglory",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node --env-file=.env ./index.js",
|
||||||
|
"db:migrate": "node db/migrate.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@simplewebauthn/server": "^13.2.3",
|
||||||
|
"cookie-parser": "~1.4.4",
|
||||||
|
"debug": "~2.6.9",
|
||||||
|
"express": "^4.22.1",
|
||||||
|
"http-errors": "~1.6.3",
|
||||||
|
"morgan": "^1.10.1",
|
||||||
|
"pg": "^8.18.0",
|
||||||
|
"pug": "^2.0.4",
|
||||||
|
"rb26": "^1.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
25
public/javascripts/user-login.js
Normal file
25
public/javascripts/user-login.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
fetch('/users/generate-auth-options/arthur')
|
||||||
|
.then(resp => resp.json())
|
||||||
|
.then((optionsJSON) => {
|
||||||
|
console.log(optionsJSON);
|
||||||
|
return SimpleWebAuthnBrowser.startAuthentication({ optionsJSON })
|
||||||
|
})
|
||||||
|
.then(authResp => {
|
||||||
|
console.log("verify");
|
||||||
|
return fetch("/users/verify-auth", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
authResp: authResp,
|
||||||
|
username: "arthur"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(res => {
|
||||||
|
console.log(res);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
45
public/javascripts/users.js
Normal file
45
public/javascripts/users.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
window.addEventListener("DOMContentLoaded", function() {
|
||||||
|
const registerBtn = document.querySelector("#register");
|
||||||
|
|
||||||
|
registerBtn?.addEventListener("click", async function() {
|
||||||
|
await fetch("/users/register/setup", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: "arthur"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
return data.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
console.log("we received:", data);
|
||||||
|
return SimpleWebAuthnBrowser.startRegistration({ optionsJSON: data });
|
||||||
|
})
|
||||||
|
.then((attResp) => {
|
||||||
|
return fetch("/users/register/verify", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
registration: attResp,
|
||||||
|
username: 'arthur'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
console.log("Le finish");
|
||||||
|
console.log(data);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Something went wrong");
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
17
public/stylesheets/style.css
Normal file
17
public/stylesheets/style.css
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
:root {
|
||||||
|
--primary: #fd5e53;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
padding: 50px;
|
||||||
|
font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #00B7FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
border-top: 1px solid var(--primary);
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
21
routes/index.js
Normal file
21
routes/index.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import express from 'express';
|
||||||
|
var router = express.Router();
|
||||||
|
import PostController from '../controllers/Post.controller.js';
|
||||||
|
import authenticateToken from '../middlewares/authentication.js';
|
||||||
|
|
||||||
|
/* GET home page. */
|
||||||
|
router.get('/', async function(req, res, next) {
|
||||||
|
const posts = await PostController.getAll();
|
||||||
|
res.render('index', { title: 'Path to glory', posts: posts });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/conclave', authenticateToken, async function(req, res, next) {
|
||||||
|
res.render('conclave');
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/conclave/new', authenticateToken, async function(req, res, next) {
|
||||||
|
console.log(req.body);
|
||||||
|
await PostController.create(req.body.title, req.body.content);
|
||||||
|
res.redirect('/conclave');
|
||||||
|
});
|
||||||
|
export default router;
|
||||||
14
routes/posts.js
Normal file
14
routes/posts.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import express from "express";
|
||||||
|
import convertToHTML from "rb26";
|
||||||
|
|
||||||
|
import PostController from "./../controllers/Post.controller.js";
|
||||||
|
|
||||||
|
var router = express.Router();
|
||||||
|
|
||||||
|
router.get("/:id", async function (req, res, next) {
|
||||||
|
const post = await PostController.get(req.params.id);
|
||||||
|
console.log(post);
|
||||||
|
res.render("post", { post: post, html: convertToHTML(post.content) });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
219
routes/users.js
Normal file
219
routes/users.js
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import {
|
||||||
|
generateRegistrationOptions,
|
||||||
|
verifyRegistrationResponse,
|
||||||
|
generateAuthenticationOptions,
|
||||||
|
verifyAuthenticationResponse,
|
||||||
|
} from '@simplewebauthn/server';
|
||||||
|
import dbClient from './../db/index.js';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import limitedUsers from '../middlewares/limitedUsers.js';
|
||||||
|
|
||||||
|
var router = express.Router();
|
||||||
|
|
||||||
|
const userPasskeys = [];
|
||||||
|
const origin = `https://${process.env.RP_ID}`;
|
||||||
|
|
||||||
|
const challenges = {};
|
||||||
|
const users = {};
|
||||||
|
const currentLogin = {};
|
||||||
|
|
||||||
|
router.get("/register", limitedUsers, function(_, res) {
|
||||||
|
res.render("users/register");
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/login", function(_, res) {
|
||||||
|
res.render("users/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/generate-auth-options/:username", async function(req, res, next) {
|
||||||
|
dbClient.query({
|
||||||
|
text: 'SELECT * FROM users WHERE username=$1',
|
||||||
|
values: [req.params.username]
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
const user = result.rows[0];
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("ya pas de user wesh");
|
||||||
|
}
|
||||||
|
return dbClient.query({
|
||||||
|
text: `
|
||||||
|
SELECT *
|
||||||
|
FROM user_passkeys up
|
||||||
|
JOIN passkeys ON up.passkey_id = passkeys.id
|
||||||
|
WHERE up.user_id = $1
|
||||||
|
`,
|
||||||
|
values: [user.id]
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(async (passkeyData) => {
|
||||||
|
const options = await generateAuthenticationOptions({
|
||||||
|
rpID: process.env.RP_ID,
|
||||||
|
allowCredentials: passkeyData.rows.map(passkey => ({
|
||||||
|
id: passkey.id,
|
||||||
|
transports: passkey.transports,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
currentLogin[req.params.username] = options;
|
||||||
|
return options;
|
||||||
|
})
|
||||||
|
.then((options) => {
|
||||||
|
res.json(options);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Error in login");
|
||||||
|
console.error(err);
|
||||||
|
next(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/verify-auth/", function(req, res) {
|
||||||
|
console.log("Verify auth");
|
||||||
|
const { authResp, username } = req.body;
|
||||||
|
|
||||||
|
dbClient.query({
|
||||||
|
text: 'SELECT * FROM users WHERE username=$1',
|
||||||
|
values: [username]
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
const user = result.rows[0];
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("ya pas de user wesh");
|
||||||
|
}
|
||||||
|
return dbClient.query({
|
||||||
|
text: `
|
||||||
|
SELECT *
|
||||||
|
FROM passkeys
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
values: [authResp.id]
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(async (passkeyData) => {
|
||||||
|
const passkey = passkeyData.rows[0];
|
||||||
|
|
||||||
|
return verifyAuthenticationResponse({
|
||||||
|
response: authResp,
|
||||||
|
expectedChallenge: currentLogin[username].challenge,
|
||||||
|
expectedOrigin: origin,
|
||||||
|
expectedRPID: process.env.RP_ID,
|
||||||
|
credential: {
|
||||||
|
id: passkey.id,
|
||||||
|
publicKey: new Uint8Array(passkey['public_key']),
|
||||||
|
counter: passkey.counter,
|
||||||
|
transports: passkey.transports,
|
||||||
|
},
|
||||||
|
equireUserVerification: false,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(verification => {
|
||||||
|
const header = Buffer.from(JSON.stringify({
|
||||||
|
'alg': 'HS256',
|
||||||
|
'type': "JWT"
|
||||||
|
})).toString('base64url');
|
||||||
|
const payload = Buffer.from(JSON.stringify({
|
||||||
|
verified: true,
|
||||||
|
username: username
|
||||||
|
})).toString('base64url');
|
||||||
|
|
||||||
|
const hash = crypto.createHmac('sha256', process.env.AUTH_JWT_SECRET);
|
||||||
|
hash.update(header + "." + payload);
|
||||||
|
const token = header + "." + payload + "." + hash.digest('base64url');
|
||||||
|
|
||||||
|
res.cookie('auth_token', token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: 'Strict'
|
||||||
|
});
|
||||||
|
|
||||||
|
res.send(verification);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.log("ya erreur")
|
||||||
|
console.error(err);
|
||||||
|
res.status(400).send({ error: err.message });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/register/setup", limitedUsers, async function(req, res, next) {
|
||||||
|
const { username } = req.body;
|
||||||
|
|
||||||
|
generateRegistrationOptions({
|
||||||
|
rpName: process.env.RP_NAME,
|
||||||
|
rpID: process.env.RP_ID,
|
||||||
|
userName: username,
|
||||||
|
attestationType: 'none',
|
||||||
|
excludeCredentials: userPasskeys.map(passkey => ({
|
||||||
|
id: passkey.id,
|
||||||
|
transports: passkey.transports,
|
||||||
|
})),
|
||||||
|
authenticatorSelection: {
|
||||||
|
residentKey: 'preferred',
|
||||||
|
userVerification: 'preferred',
|
||||||
|
authenticatorAttachment: 'platform',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((options) => {
|
||||||
|
challenges[username] = options.challenge;
|
||||||
|
users[username] = options.user;
|
||||||
|
res.send(options);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Something went wrong");
|
||||||
|
console.error(err);
|
||||||
|
next(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/register/verify", limitedUsers, function(req, res, next) {
|
||||||
|
const { registration, username } = req.body;
|
||||||
|
verifyRegistrationResponse({
|
||||||
|
response: registration,
|
||||||
|
expectedChallenge: challenges[username],
|
||||||
|
expectedOrigin: origin,
|
||||||
|
expectedRPID: process.env.RP_ID,
|
||||||
|
})
|
||||||
|
.then(async (verification) => {
|
||||||
|
const user = users[username];
|
||||||
|
|
||||||
|
await dbClient.query("BEGIN");
|
||||||
|
|
||||||
|
await dbClient.query({
|
||||||
|
text: 'INSERT INTO users(id, username) VALUES($1, $2)',
|
||||||
|
values: [user.id, user.name],
|
||||||
|
});
|
||||||
|
|
||||||
|
await dbClient.query({
|
||||||
|
text: 'INSERT INTO passkeys(id, public_key, counter, transports) VALUES($1, $2, $3, $4)',
|
||||||
|
values: [
|
||||||
|
verification.registrationInfo.credential.id,
|
||||||
|
Buffer.from(verification.registrationInfo.credential.publicKey),
|
||||||
|
verification.registrationInfo.credential.counter,
|
||||||
|
verification.registrationInfo.credential.transports.join(',')
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
await dbClient.query({
|
||||||
|
text: 'INSERT INTO user_passkeys(user_id, passkey_id) VALUES($1, $2)',
|
||||||
|
values: [user.id, verification.registrationInfo.credential.id]
|
||||||
|
});
|
||||||
|
|
||||||
|
dbClient.query("COMMIT");
|
||||||
|
res.send(verification);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
dbClient.query("ROLLBACK");
|
||||||
|
console.error("Something went wrong")
|
||||||
|
console.error(err);
|
||||||
|
next(err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
delete challenges[username];
|
||||||
|
delete users[username];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export default router;
|
||||||
8
views/conclave.pug
Normal file
8
views/conclave.pug
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
extends layout
|
||||||
|
|
||||||
|
block content
|
||||||
|
h1 Conclave
|
||||||
|
form(method="POST" action="/conclave/new")
|
||||||
|
input(type="text" name="title" placeholder="Title")
|
||||||
|
textarea(name="content")
|
||||||
|
button(type="submit") Publish
|
||||||
6
views/error.pug
Normal file
6
views/error.pug
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
extends layout
|
||||||
|
|
||||||
|
block content
|
||||||
|
h1= message
|
||||||
|
h2= error.status
|
||||||
|
pre #{error.stack}
|
||||||
9
views/index.pug
Normal file
9
views/index.pug
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
extends layout
|
||||||
|
|
||||||
|
block content
|
||||||
|
section
|
||||||
|
ul
|
||||||
|
each post in posts
|
||||||
|
li
|
||||||
|
a(href=`/posts/${post.id}`)
|
||||||
|
h2= post.title
|
||||||
16
views/layout.pug
Normal file
16
views/layout.pug
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
doctype html
|
||||||
|
html
|
||||||
|
head
|
||||||
|
title= title
|
||||||
|
link(rel='stylesheet', href='/stylesheets/style.css')
|
||||||
|
block head
|
||||||
|
body
|
||||||
|
header
|
||||||
|
h1
|
||||||
|
a(href="/") Pathtoglory.quest
|
||||||
|
p I'm on a quest to learn and understand things.
|
||||||
|
|
||||||
|
block content
|
||||||
|
|
||||||
|
footer
|
||||||
|
p Pathtoglory.quest No copyright. Copy and paste me.
|
||||||
6
views/post.pug
Normal file
6
views/post.pug
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
extends layout
|
||||||
|
|
||||||
|
block content
|
||||||
|
article
|
||||||
|
h1= post.title
|
||||||
|
div!= html
|
||||||
12
views/users/login.pug
Normal file
12
views/users/login.pug
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
extends ../layout
|
||||||
|
|
||||||
|
block head
|
||||||
|
script(defer src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js")
|
||||||
|
script(defer src="/javascripts/user-login.js")
|
||||||
|
|
||||||
|
block content
|
||||||
|
h2 Login
|
||||||
|
form
|
||||||
|
label(for="username") Username
|
||||||
|
input(type="text" name="username" autocomplete="webauth" placeholder="Username")
|
||||||
|
input(type="submit" value="Login with passkey")
|
||||||
13
views/users/register.pug
Normal file
13
views/users/register.pug
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
extends ../layout
|
||||||
|
|
||||||
|
block head
|
||||||
|
script(defer src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js")
|
||||||
|
script(defer src="/javascripts/users.js")
|
||||||
|
|
||||||
|
block content
|
||||||
|
h2 Register new user
|
||||||
|
form
|
||||||
|
label(for="username") Username
|
||||||
|
input(type="text" name="username" autocomplete="username webauth" placeholder="Username")
|
||||||
|
|
||||||
|
button(id="register") Register
|
||||||
Reference in New Issue
Block a user