build: setup docker containers for development
This commit is contained in:
43
app/server/app.js
Normal file
43
app/server/app.js
Normal file
@@ -0,0 +1,43 @@
|
||||
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';
|
||||
|
||||
var app = express();
|
||||
|
||||
// view engine setup
|
||||
app.set('views', path.join(import.meta.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(import.meta.dirname, 'public')));
|
||||
|
||||
app.use('/', indexRouter);
|
||||
app.use('/users', usersRouter);
|
||||
app.use('/posts', postsRouter);
|
||||
|
||||
// catch 404 and forward to error handler
|
||||
app.use(function(_, _, next) {
|
||||
next(createError(404));
|
||||
});
|
||||
|
||||
// error handler
|
||||
app.use(function(err, req, res) {
|
||||
// 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
app/server/controllers/Post.controller.js
Normal file
24
app/server/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
app/server/db/index.js
Normal file
15
app/server/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.DB_PORT,
|
||||
database: process.env.DB_DATABASE,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30_000,
|
||||
connectionTimeoutMillis: 2_000,
|
||||
maxLifetimeSeconds: 60
|
||||
});
|
||||
|
||||
export default db;
|
||||
68
app/server/db/migrate.js
Normal file
68
app/server/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));
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE EXTENSION 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
|
||||
);
|
||||
@@ -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);
|
||||
21
app/server/middlewares/authentication.js
Normal file
21
app/server/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
app/server/middlewares/limitedUsers.js
Normal file
12
app/server/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;
|
||||
21
app/server/routes/index.js
Normal file
21
app/server/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
app/server/routes/posts.js
Normal file
14
app/server/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
app/server/routes/users.js
Normal file
219
app/server/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
app/server/views/conclave.pug
Normal file
8
app/server/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
app/server/views/error.pug
Normal file
6
app/server/views/error.pug
Normal file
@@ -0,0 +1,6 @@
|
||||
extends layout
|
||||
|
||||
block content
|
||||
h1= message
|
||||
h2= error.status
|
||||
pre #{error.stack}
|
||||
9
app/server/views/index.pug
Normal file
9
app/server/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
app/server/views/layout.pug
Normal file
16
app/server/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
app/server/views/post.pug
Normal file
6
app/server/views/post.pug
Normal file
@@ -0,0 +1,6 @@
|
||||
extends layout
|
||||
|
||||
block content
|
||||
article
|
||||
h1= post.title
|
||||
div!= html
|
||||
12
app/server/views/users/login.pug
Normal file
12
app/server/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
app/server/views/users/register.pug
Normal file
13
app/server/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