User accounts
Login & registration
pull/2/head
Armored Dragon 2023-09-13 14:56:58 -05:00
parent 38df242ee7
commit c9d13320d5
24 changed files with 4829 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.env
/node_modules
/data
*.code-workspace

36
backend/core/core.js Normal file
View File

@ -0,0 +1,36 @@
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
const crypto = require("crypto");
const sharp = require("sharp");
const { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, ListObjectsCommand, DeleteObjectsCommand } = require("@aws-sdk/client-s3");
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
const s3 = new S3Client({
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY,
secretAccessKey: process.env.S3_SECRET_KEY,
},
region: process.env.S3_REGION,
endpoint: process.env.S3_ENDPOINT,
});
const persistent_setting = require("node-persist");
persistent_setting.init({ dir: "data/" });
async function registerUser(username, password) {
const new_user = await prisma.user.create({ data: { username: username, password: password } });
await persistent_setting.setItem("SETUP_COMPLETE", true);
if (new_user) return { success: true, message: `Successfully created ${new_user.username}` };
}
async function getUser({ id, username } = {}) {
if (id || username) {
let user;
if (id) user = await prisma.user.findUnique({ where: { id: id } });
else if (username) user = await prisma.user.findUnique({ where: { username: username } });
if (!user) return { success: false, message: "No matching user" };
else return { success: true, data: user };
}
}
module.exports = { registerUser, getUser };

View File

View File

@ -0,0 +1,32 @@
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
const core = require("./core");
// Check if user registration is allowed via the settings
function registration() {
return true;
}
async function userRegistration(username, password) {
if (!username) return { success: false, message: "No username provided" };
if (!password) return { success: false, message: "No password provided" };
// TODO: Admin customizable minimum password length
if (password.length < 4) return { success: false, message: "Password not long enough" };
const existing_user = await core.getUser({ username: username });
if (existing_user.success) return { success: false, message: "Username already exists" };
return { success: true };
}
async function userLogin(username, password) {
const existing_user = await core.getUser({ username: username });
if (!existing_user.success) return { success: false, message: "User does not exist" };
if (existing_user.role === "LOCKED") return { success: false, message: "Account is locked: Contact your administrator" };
return { success: true, data: existing_user.data };
}
module.exports = { registration, userRegistration, userLogin };

View File

@ -0,0 +1,22 @@
const validate = require("./form_validation");
const core = require("./core");
async function registerUser(username, password) {
const registration_allowed = validate.registration(); // Check if user registration is allowed
const form_valid = await validate.userRegistration(username, password); // Check form for errors
// Register the user in the database
if (registration_allowed && form_valid.success) return await core.registerUser(username, password);
// Something went wrong!
return { success: false, message: form_valid.message };
}
async function loginUser(username, password) {
const user = await validate.userLogin(username);
if (!user.success) return user;
return { success: true, data: { username: user.data.username, id: user.data.id, password: user.data.password } };
}
module.exports = { registerUser, loginUser };

34
backend/page_scripts.js Normal file
View File

@ -0,0 +1,34 @@
const internal = require("./core/internal_api");
const bcrypt = require("bcrypt");
const persistent_setting = require("node-persist");
persistent_setting.init({ dir: "data/" });
async function index(request, response) {
// Check if the master admin has been created
const is_setup_complete = (await persistent_setting.getItem("SETUP_COMPLETE")) || false;
if (!is_setup_complete) return response.redirect("/register");
response.render("index.ejs", { website_name: process.env.WEBSITE_NAME });
}
function register(request, response) {
response.render("register.ejs", { website_name: process.env.WEBSITE_NAME });
}
function login(request, response) {
response.render("login.ejs", { website_name: process.env.WEBSITE_NAME });
}
async function registerPost(request, response) {
const hashedPassword = await bcrypt.hash(request.body.password, 10); // Hash the password for security :^)
response.json(await internal.registerUser(request.body.username, hashedPassword));
}
async function loginPost(request, response) {
const login = await internal.loginUser(request.body.username, request.body.password);
const password_match = await bcrypt.compare(request.body.password, login.data.password);
if (!password_match) return { success: false, message: "Incorrect password" };
request.session.user = { username: login.data.username, id: login.data.id };
response.json({ success: true });
}
module.exports = { index, register, login, registerPost, loginPost };

1
backend/permissions.js Normal file
View File

@ -0,0 +1 @@
function checkPermissions(role, { minimum = true }) {}

View File

@ -0,0 +1,112 @@
body {
margin: 0;
background-color: #111;
color: white;
}
.header {
background-color: #222;
width: 100%;
height: 50px;
display: flex;
flex-direction: row;
padding: 0 10px;
box-sizing: border-box;
margin-bottom: 1rem;
}
.header .left {
margin: auto auto auto 0;
font-size: 20px;
height: 100%;
}
.header .right {
margin: auto 0 auto auto;
height: 100%;
display: flex;
flex-direction: row;
}
.header .right a:hover,
.header .right a:focus {
background-color: #333;
}
.header a {
height: 100%;
width: 130px;
margin: auto 0;
display: flex;
text-decoration: none;
transition: background-color ease-in-out 0.1s;
}
.header a div {
margin: auto;
color: white;
}
button {
background-color: #1c478a;
border: 0;
border-radius: 5px;
color: white;
cursor: pointer;
}
button:hover,
button:focus {
background-color: #122d57;
}
.page {
width: 1000px;
min-height: 10px;
margin: 0 auto;
}
.page .horizontal-button-container {
display: flex;
flex-direction: row;
}
.page .horizontal-button-container button {
width: 100px;
min-height: 30px;
}
.center-modal {
margin: auto;
width: 400px;
background-color: #222;
display: flex;
flex-direction: column;
padding: 0 20px 20px 20px;
box-sizing: border-box;
border-radius: 5px;
}
.center-modal .modal-title {
text-align: center;
font-size: 26px;
margin-top: 10px;
}
.center-modal .input-line {
display: flex;
flex-direction: column;
margin-bottom: 20px;
}
.center-modal .input-line div {
margin-bottom: 5px;
font-size: 18px;
}
.center-modal .input-line input {
background-color: #0d0d0d;
border: 0;
padding: 5px;
box-sizing: border-box;
color: white;
}
.center-modal .horizontal-button-container {
flex-direction: row-reverse !important;
}
.center-modal .horizontal-button-container a {
color: white;
margin: auto auto auto 0;
}
.center-modal .horizontal-button-container button {
width: 200px !important;
}

View File

@ -0,0 +1,49 @@
@use "theme";
.center-modal {
margin: auto;
width: 400px;
background-color: theme.$header-color;
display: flex;
flex-direction: column;
padding: 0 20px 20px 20px;
box-sizing: border-box;
border-radius: 5px;
.modal-title {
text-align: center;
font-size: 26px;
margin-top: 10px;
}
.input-line {
display: flex;
flex-direction: column;
margin-bottom: 20px;
div {
margin-bottom: 5px;
font-size: 18px;
}
input {
background-color: #0d0d0d;
border: 0;
padding: 5px;
box-sizing: border-box;
color: white;
}
}
.horizontal-button-container {
flex-direction: row-reverse !important;
a {
color: white;
margin: auto auto auto 0;
}
button {
width: 200px !important;
}
}
}

View File

@ -0,0 +1,79 @@
body {
margin: 0;
background-color: #111;
color: white;
}
.header {
background-color: #222;
width: 100%;
height: 50px;
display: flex;
flex-direction: row;
padding: 0 10px;
box-sizing: border-box;
margin-bottom: 1rem;
}
.header .left {
margin: auto auto auto 0;
font-size: 20px;
height: 100%;
}
.header .left a {
width: inherit;
}
.header .right {
margin: auto 0 auto auto;
height: 100%;
display: flex;
flex-direction: row;
}
.header .right a:hover,
.header .right a:focus {
background-color: #333;
}
.header a {
height: 100%;
width: 130px;
margin: auto 0;
display: flex;
text-decoration: none;
transition: background-color ease-in-out 0.1s;
}
.header a div {
margin: auto;
color: white;
}
button {
background-color: #1c478a;
border: 0;
border-radius: 5px;
color: white;
cursor: pointer;
}
button:hover,
button:focus {
background-color: #122d57;
}
.page {
width: 1000px;
min-height: 10px;
margin: 0 auto;
}
.page .horizontal-button-container {
display: flex;
flex-direction: row;
}
.page .horizontal-button-container button {
width: 100px;
min-height: 30px;
}
@media screen and (max-width: 1010px) {
.page {
width: 100%;
}
}

View File

@ -0,0 +1,88 @@
$header-color: #222;
$button-generic: #1c478a;
body {
margin: 0;
background-color: #111;
color: white;
}
.header {
background-color: $header-color;
width: 100%;
height: 50px;
display: flex;
flex-direction: row;
padding: 0 10px;
box-sizing: border-box;
margin-bottom: 1rem;
.left {
margin: auto auto auto 0;
font-size: 20px;
height: 100%;
a {
width: inherit;
}
}
.right {
margin: auto 0 auto auto;
height: 100%;
display: flex;
flex-direction: row;
a:hover,
a:focus {
background-color: #333;
}
}
a {
height: 100%;
width: 130px;
margin: auto 0;
display: flex;
text-decoration: none;
transition: background-color ease-in-out 0.1s;
div {
margin: auto;
color: white;
}
}
}
button {
background-color: $button-generic;
border: 0;
border-radius: 5px;
color: white;
cursor: pointer;
}
button:hover,
button:focus {
background-color: #122d57;
}
.page {
width: 1000px;
min-height: 10px;
margin: 0 auto;
.horizontal-button-container {
display: flex;
flex-direction: row;
button {
width: 100px;
min-height: 30px;
}
}
}
@media screen and (max-width: 1010px) {
.page {
width: 100%;
}
}

View File

@ -0,0 +1,11 @@
// Quick document selectors
const qs = (selector) => document.querySelector(selector);
const qsa = (selector) => document.querySelectorAll(selector);
async function request(url, method, body) {
const response = await fetch(url, {
method: method,
body: body,
});
return { status: response.status, body: await response.json() };
}

View File

@ -0,0 +1,21 @@
async function requestLogin() {
const account_information = {
username: qs("#username").value,
password: qs("#password").value,
};
const form = new FormData();
form.append("username", account_information.username);
form.append("password", account_information.password);
const account_response = await request("/login", "POST", form);
// Check response for errors
// If success, return to account
console.log(account_response);
if (account_response.body.success) {
location.href = "/";
}
}

View File

@ -0,0 +1,21 @@
async function requestRegister() {
const account_information = {
username: qs("#username").value,
password: qs("#password").value,
};
const form = new FormData();
form.append("username", account_information.username);
form.append("password", account_information.password);
const account_response = await request("/register", "POST", form);
// Check response for errors
// If success, return to account
console.log(account_response);
if (account_response.body.success) {
location.href = "/login";
}
}

20
frontend/views/index.ejs Normal file
View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" type="text/css" href="/css/index.css" />
<link rel="stylesheet" type="text/css" href="/css/theme.css" />
<script src="/js/generic.js"></script>
<title><%= website_name %> | Home</title>
</head>
<body>
<%- include("partials/header.ejs", {selected: 'home'}) %>
<div class="page"></div>
<%- include("partials/footer.ejs") %>
</body>
</html>
<script defer src="/js/login.js"></script>

36
frontend/views/login.ejs Normal file
View File

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" type="text/css" href="/css/signin.css" />
<link rel="stylesheet" type="text/css" href="/css/theme.css" />
<script src="/js/generic.js"></script>
<title><%= website_name %> | Login</title>
</head>
<body>
<%- include("partials/header.ejs", {selected: 'home'}) %>
<div class="page">
<div class="center-modal">
<div class="modal-title">Login</div>
<div class="input-line">
<div>Username</div>
<input id="username" type="text" />
</div>
<div class="input-line">
<div>Password</div>
<input id="password" type="password" />
</div>
<div class="horizontal-button-container">
<button onclick="requestLogin()"><div>Login</div></button>
<a href="/register">Register</a>
</div>
</div>
</div>
<%- include("partials/footer.ejs") %>
</body>
</html>
<script defer src="/js/login.js"></script>

View File

View File

@ -0,0 +1,15 @@
<div class="header">
<div class="left">
<a class="nav-button" href="/">
<div><%= website_name %></div>
</a>
</div>
<div class="right">
<a class="nav-button" href="/blog">
<div>Blog</div>
</a>
<a class="nav-button" href="/projects">
<div>Projects</div>
</a>
</div>
</div>

View File

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" type="text/css" href="/css/signin.css" />
<link rel="stylesheet" type="text/css" href="/css/theme.css" />
<script src="/js/generic.js"></script>
<title><%= website_name %> | Register</title>
</head>
<body>
<%- include("partials/header.ejs", {selected: 'home'}) %>
<div class="page">
<div class="center-modal">
<div class="modal-title">Register</div>
<div class="input-line">
<div>Username</div>
<input id="username" type="text" />
</div>
<div class="input-line">
<div>Password</div>
<input id="password" type="password" />
</div>
<div class="horizontal-button-container">
<button onclick="requestRegister()"><div>Register</div></button>
<a href="/login">Login</a>
</div>
</div>
</div>
<%- include("partials/footer.ejs") %>
</body>
</html>
<script defer src="/js/register.js"></script>

4019
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
package.json Normal file
View File

@ -0,0 +1,41 @@
{
"name": "yet-another-blog",
"version": "0.0.1",
"description": "A open source blogging website made for both personal blogs and big projects.",
"main": "yab.js",
"scripts": {
"devstart": "nodemon -r dotenv/config yab.js dotenv_config_path=.env"
},
"repository": {
"type": "git",
"url": "https://git.armoreddragon.com/ArmoredDragon/yet-another-blog"
},
"keywords": [
"blog",
"personal-blog",
"rss"
],
"author": "Armored Dragon",
"license": "GPL-3.0",
"devDependencies": {
"dotenv": "^16.3.1",
"nodemon": "^3.0.1",
"prisma": "^5.2.0",
"ts-node": "^10.9.1",
"typescript": "^5.2.2"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.410.0",
"@aws-sdk/s3-request-presigner": "^3.410.0",
"@prisma/client": "^5.2.0",
"bcrypt": "^5.1.1",
"body-parser": "^1.20.2",
"ejs": "^3.1.9",
"express": "^4.18.2",
"express-session": "^1.17.3",
"markdown-it": "^13.0.1",
"multer": "^1.4.5-lts.1",
"node-persist": "^3.1.3",
"sharp": "^0.32.5"
}
}

70
prisma/schema.prisma Normal file
View File

@ -0,0 +1,70 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @unique @default(uuid())
username String @unique
password String
role Role @default(VISITOR)
blog_posts BlogPost[]
edits Edit[]
@@index([username, role])
}
model BlogPost {
id String @id @unique @default(uuid())
title String?
short_description String?
content String?
thumbnail String?
tags String[]
status PostStatus @default(DRAFT)
group Role @default(AUTHOR)
owner User? @relation(fields: [ownerid], references: [id])
ownerid String?
edits Edit[]
// Dates
publish_date DateTime?
created_date DateTime @default(now())
}
model Edit {
id String @id @unique @default(uuid())
owner User @relation(fields: [ownerid], references: [id])
ownerid String
reason String? @default("No reason provided.")
blogpost BlogPost? @relation(fields: [blogpost_id], references: [id], onDelete: Cascade)
blogpost_id String?
}
enum Role {
LOCKED
VISITOR
AUTHOR
ADMIN
}
enum PostStatus {
DRAFT
PUBLISHED
}
enum ProjectStatus {
DELAYED
IN_PROGRESS
SUCCESS
FAILURE
}

14
template.env Normal file
View File

@ -0,0 +1,14 @@
ENVIRONMENT='production'
# The URL your site is hosted on. (example: https://myawesomeblog.com)
BASE_URL=""
# PostgreSQL database url
DATABASE_URL=""
# S3 bucket settings used to store images and videos
S3_BUCKET_NAME=''
S3_ACCESS_KEY=""
S3_SECRET_KEY=""
S3_REGION=""
S3_ENDPOINT=""

68
yab.js Normal file
View File

@ -0,0 +1,68 @@
// Multer file handling
// REVIEW: Look for a way to drop this dependency?
const multer = require("multer");
const multer_storage = multer.memoryStorage();
const upload = multer({ storage: multer_storage });
// Express
const express = require("express");
const session = require("express-session");
const app = express();
const path = require("path");
// Security and encryption
const bcrypt = require("bcrypt");
const crypto = require("crypto");
// Local modules
const page_scripts = require("./backend/page_scripts");
// Express settings
app.set("view-engine", "ejs");
app.set("views", path.join(__dirname, "frontend/views"));
app.use(express.static(path.join(__dirname, "frontend/public")));
const bodyParser = require("body-parser");
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(upload.array());
app.use(
session({
secret: crypto.randomBytes(128).toString("base64"),
resave: false,
saveUninitialized: false,
})
);
// Account Creation Endpoints
app.get("/login", page_scripts.login);
app.post("/login", checkNotAuthenticated, page_scripts.loginPost);
app.get("/register", checkNotAuthenticated, page_scripts.register);
app.post("/register", checkNotAuthenticated, page_scripts.registerPost);
// Account Required Endpoints
// app.get("/blog/new", checkAuthenticated, page_scripts.blogNew);
// app.post("/blog", checkAuthenticated, page_scripts.postBlog);
// app.delete("/logout", page_scripts.logout);
// Image Endpoints
// app.post("/api/image", checkAuthenticated, upload.fields([{ name: "image" }]), page_scripts.uploadImage);
// Endpoints
app.get("/", page_scripts.index);
// app.get("/blog", page_scripts.blogList);
// app.get("/blog/:id", page_scripts.blogSingle);
// app.get("/projects", page_scripts.projectList);
function checkAuthenticated(req, res, next) {
if (req.session.user) return next();
res.redirect("/login");
}
function checkNotAuthenticated(req, res, next) {
if (req.session.user) return res.redirect("/");
next();
}
app.listen(8080);