initial commit

This commit is contained in:
Alexander Bass 2023-04-13 15:21:17 -04:00
commit f219a2fe41
23 changed files with 834 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
data.db
.sass-cache
node_modules
package-lock.json
sessions
build

24
README.md Normal file
View file

@ -0,0 +1,24 @@
# Web Forum Experiment
This is a weekend project I made to gain a better understanding of databases.
It's a simple message board forum where users can create threads and messages within those threads.
It is written in JavaScript using the [Express framework](https://expressjs.com/), with SQLite for a database.
I don't intend to develop this any further.
## Installing Dependencies
```
npm install
```
## Running
If you have GNU make installed:
```
make dev
```
or if not:
```
npx nodemon --ignore ./sessions/ -e js,mjs,json,scss index.js
```
Then you can go to `localhost:3333` in your browser to view the forum.
![Screenshot](screenshot.png)

8
buildstyle.js Normal file
View file

@ -0,0 +1,8 @@
import sass from "sass";
import { writeFileSync, } from "fs";
const style = sass.compile("./css/main.scss").css;
writeFileSync("static/out.css", style, err => {
console.log(err);
});

160
css/main.scss Normal file
View file

@ -0,0 +1,160 @@
@import 'variables';
$asideWidth: 150px;
$mainWidth: 1200px;
$borderSize: 5px;
$totalWidth: $asideWidth+$mainWidth+(4*$borderSize);
body {
margin: 0;
background-color: var(--background-color);
background-image: url("/background.png");
background-repeat: repeat;
background-size: 100px 100px;
font-family: $content-font;
}
div#actionBarRight {
margin-left: auto;
}
* {
box-sizing: border-box;
}
div#actionBar {
margin: 0px;
padding: 0px;
display: flex;
border: 5px ridge var(--fourth-color);
border-bottom: none;
background-color: lightgray;
font-family: sans-serif;
align-items: flex-start;
div {
a {
font-size: large;
text-decoration: none;
color: black;
display: block;
padding-inline: 20px;
padding-block: 8px;
}
&:hover {
background-color: var(--fourth-color);
}
}
}
div#content {
margin-inline: auto;
max-width: $mainWidth;
}
a {
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
header {
user-select: none;
font-weight: 600;
text-align: center;
font-size: 4rem;
background: linear-gradient(red, orange);
background-clip: text;
-webkit-text-fill-color: transparent;
-webkit-background-clip: text;
-webkit-text-stroke: 1px red;
margin: 10px;
}
header a:hover {
text-decoration: none;
}
#messageContainer:first-child .messageMeta {
// background: linear-gradient(rgba(255, 0, 0, 1), rgba(255, 166, 0, 1));
padding-block: 10px;
font-size: 15px;
text-decoration: none;
text-decoration-style: none;
}
;
.message {
background-color: var(--tertiary-color);
// padding: 10px;
margin-bottom: 5px;
padding: 0 0 10px 0;
margin-top: 15px;
.messageMeta {
font-family: $clean-font;
user-select: none;
background: rgb(190, 190, 190);
text-decoration: underline;
text-decoration-style: dotted;
font-size: 13px;
padding-block: 5px;
padding-left: 5px;
}
.messageContent {
display: flex;
padding-block: 10px;
.userInfo {
font-family: $clean-font;
display: inline-block;
margin-right: 16px;
max-width: 170px;
font-size: 15px;
border-right: 2px ridge gainsboro;
text-align: center;
#userName {
font-weight: bold;
font-size: 20px;
margin-bottom: 5px;
}
}
.messageBody {
width: 100%;
h3 {
margin-top: 0px;
margin-bottom: 2px;
font-size: 20px;
}
p {
margin: 0px 0px 0px 0px;
word-wrap: break-word;
}
}
}
}
main {
background-color: var(--main-color);
border: 5px ridge var(--border-color);
padding-block: 10px;
padding-inline: 20px;
}

17
css/variables.scss Normal file
View file

@ -0,0 +1,17 @@
// Light mode colors
:root {
--faded-text-color: #303030;
--background-color: #6d695c;
--main-color: #E0E0E0;
--border-color: white;
--tertiary-color: lightgray;
--fourth-color: darkgray;
--fun-color: #4CAF50;
--angry-color: red;
--text-color: #242424;
--darker-color: #ebebeb;
}
$clean-font: sans-serif;
$content-font: Charter, 'Bitstream Charter', 'Sitka Text', Cambria, serif;

155
index.js Normal file
View file

@ -0,0 +1,155 @@
import bcrypt from "bcrypt";
import sqlite3 from "sqlite3";
import { open } from "sqlite";
import express from 'express';
import session from "express-session";
import sfs from "session-file-store";
let FileStore = sfs(session);
const app = express();
const PORT = 3333;
const SALT_COUNT = 12;
const dbPromise = open({
filename: "data.db",
driver: sqlite3.Database,
});
var fileStoreOptions = {};
app.set('view engine', 'ejs');
app.use(
express.urlencoded({ extended: true }),
express.static('static'),
session({
store: new FileStore(fileStoreOptions),
secret: "process.env.SESSION_SECRET", cookie: { maxAge: 60000 },
resave: true,
saveUninitialized: true
}),
);
app.get('/thread/:threadId/', async (req, res) => {
try {
const db = await dbPromise;
const threadId = req.params.threadId;
const thread = await db.get('SELECT * FROM Thread WHERE Thread.id=?', threadId);
if (!thread) throw "";
const messages = await db.all('SELECT Message.*, User.creationdate as accountCreated, User.username FROM Message LEFT JOIN User WHERE Message.author=User.id AND Message.thread=?', threadId);
res.render('pages/thread', { threadinfo: thread, messages: messages, session: req.session });
} catch {
res.redirect('back');
}
});
app.get('/', async (req, res) => {
const db = await dbPromise;
const threads = await db.all('SELECT * FROM Thread');
res.render('pages/index', {
threads: threads,
session: req.session,
});
});
app.get("/login", async (req, res) => {
if (req.session.logged_in) {
return res.redirect('/');
}
res.render('pages/login');
});
app.get("/logout", async (req, res) => {
req.session.logged_in = false;
req.session.user_id = null;
res.redirect("/");
});
app.post("/login", async (req, res) => {
try {
const db = await dbPromise;
const { username, password } = req.body;
if (!username || !password) { return res.render('pages/login', { error: "Username or password not provided" }); }
const user = await db.get(`SELECT password,id FROM User WHERE username = ?`, username);
if (!user) return res.render('pages/login', { error: "Incorrect username or password" });
const compared = await bcrypt.compare(password, user.password);
if (compared) {
req.session.logged_in = true;
req.session.user_id = user.id;
res.redirect("/");
} else {
return res.render('pages/login', { error: "Incorrect username or password" });
}
} catch (err) {
console.log(err);
return res.render('pages/login', { error: "An error ocurred." });
}
});
app.post("/register", async (req, res) => {
try {
const db = await dbPromise;
const { username, password } = req.body;
if (!username || !password) { return res.render('pages/login', { error: "Username or password not provided" }); }
if (username.length < 4) { return res.render('pages/login', { error: "Username must be at least 4 characters long" }); }
if (password.length < 6) { return res.render('pages/login', { error: "Password must be at least 6 characters long" }); }
const passwordHash = await bcrypt.hash(password, SALT_COUNT);
const result = await db.run('INSERT INTO User (username,password,creationdate) VALUES (?,?,unixepoch())', username, passwordHash);
const userID = result.lastID;
req.session.user_id = userID;
req.session.logged_in = true;
res.redirect("/");
} catch (err) {
console.log(err);
if (err.errno) {
switch (err.errno) {
case 19:
return res.render('pages/login', { error: "Username already exists" });
break;
}
}
return res.render('pages/login', { error: "An error ocurred." });
}
});
app.post("/message/:threadId/", async (req, res) => {
try {
const threadId = req.params.threadId;
const db = await dbPromise;
const thread = await db.get('SELECT * FROM Thread WHERE Thread.id=?', threadId);
const messageText = req.body.messageText;
const subjectText = req.body.subjectText;
const userID = req.session.user_id;
await db.run("INSERT INTO Message (text,author,subject,creationdate,thread) VALUES (?,?,?,unixepoch(),?);", messageText, userID, subjectText, threadId);
} catch (err) {
console.error(err);
}
res.redirect('back');
});
app.post("/newthread", async (req, res) => {
try {
const db = await dbPromise;
const threadName = req.body.threadName;
const threadDesc = req.body.threadDesc;
const userID = req.session.user_id;
await db.run("INSERT INTO Thread (name,author,description,creationdate) VALUES (?,?,?,unixepoch());", threadName, userID, threadDesc);
} catch (err) {
console.error(err);
}
res.redirect("/");
});
const setup = async () => {
const db = await dbPromise;
await db.migrate();
app.listen(PORT, () => {
console.log("listening on http://localhost:" + PORT);
});
};
setup();

17
makefile Normal file
View file

@ -0,0 +1,17 @@
dev:
npx nodemon --ignore ./sessions/ -e js,mjs,json,scss index.js
build: style
- mkdir build
cp -r static build/
cp -r views build/
cp index.js build/index.js
clean:
-rm -r .sass-cache
-rm -r sessions
-rm -r build
-rm data.db
style:
node buildstyle.js

View file

@ -0,0 +1,32 @@
-- Up
CREATE TABLE Thread(
id INTEGER PRIMARY KEY,
author INTEGER NOT NULL,
name STRING NOT NULL,
creationdate INTEGER,
description STRING
);
CREATE TABLE Message (
id INTEGER PRIMARY KEY,
text STRING NOT NULL,
subject STRING,
creationdate INTEGER NOT NULL,
thread INTEGER NOT NULL,
author INTEGER NOT NULL
);
CREATE TABLE User (
id INTEGER PRIMARY KEY,
username STRING UNIQUE NOT NULL,
password STRING NOT NULL,
creationdate INTEGER NOT NULL
);
-- Down
DROP TABLE Message;
DROP TABLE User;
DROP TABLE Thread;

24
package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "forum",
"version": "0.0.1",
"type": "module",
"main": "index.js",
"author": "Alexander Bass",
"license": "GPL-3.0",
"dependencies": {
"bcrypt": "^5.1.0",
"cookie-parser": "^1.4.6",
"dotenv": "^16.0.3",
"ejs": "^3.1.9",
"esm": "^3.2.25",
"express": "^4.18.2",
"express-session": "^1.17.3",
"session-file-store": "^1.5.0",
"sqlite": "^4.1.2",
"sqlite3": "^5.1.6"
},
"devDependencies": {
"nodemon": "^2.0.22",
"sass": "^1.61.0"
}
}

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

BIN
static/background.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

158
static/out.css Normal file
View file

@ -0,0 +1,158 @@
:root {
--faded-text-color: #303030;
--background-color: #6d695c;
--main-color: #E0E0E0;
--border-color: white;
--tertiary-color: lightgray;
--fourth-color: darkgray;
--fun-color: #4CAF50;
--angry-color: red;
--text-color: #242424;
--link-color: #2980b9;
--link-visited-color: #9b59b6;
--darker-color: #ebebeb;
}
body {
margin: 0;
background-color: var(--background-color);
background-image: url("/background.png");
background-repeat: repeat;
background-size: 100px 100px;
font-family: Charter, "Bitstream Charter", "Sitka Text", Cambria, serif;
}
aside {
width: 150px;
margin-right: 10px;
}
footer {
text-align: center;
background: linear-gradient(red, orange);
background-clip: text;
-webkit-text-fill-color: transparent;
-webkit-background-clip: text;
font-size: large;
}
div#actionBarRight {
margin-left: auto;
}
* {
box-sizing: border-box;
}
div#actionBar {
margin: 0px;
padding: 0px;
display: flex;
border: 5px ridge var(--fourth-color);
border-bottom: none;
background-color: lightgray;
font-family: sans-serif;
align-items: flex-start;
}
div#actionBar div a {
font-size: large;
text-decoration: none;
color: black;
display: block;
padding-inline: 20px;
padding-block: 8px;
}
div#actionBar div:hover {
background-color: var(--fourth-color);
}
div#content {
margin-inline: auto;
max-width: 1200px;
}
a {
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
header {
user-select: none;
font-weight: 600;
text-align: center;
font-size: 4rem;
background: linear-gradient(red, orange);
background-clip: text;
-webkit-text-fill-color: transparent;
-webkit-background-clip: text;
-webkit-text-stroke: 1px red;
margin: 10px;
}
header a:hover {
text-decoration: none;
}
#messageContainer:first-child .messageMeta {
padding-block: 10px;
font-size: 15px;
text-decoration: none;
text-decoration-style: none;
}
.message {
background-color: var(--tertiary-color);
margin-bottom: 5px;
padding: 0 0 10px 0;
margin-top: 15px;
}
.message .messageMeta {
font-family: sans-serif;
user-select: none;
background: rgb(190, 190, 190);
text-decoration: underline;
text-decoration-style: dotted;
font-size: 13px;
padding-block: 5px;
padding-left: 5px;
}
.message .messageContent {
display: flex;
padding-block: 10px;
}
.message .messageContent .userInfo {
font-family: sans-serif;
display: inline-block;
margin-right: 16px;
max-width: 170px;
font-size: 15px;
border-right: 2px ridge gainsboro;
text-align: center;
}
.message .messageContent .userInfo #userName {
font-weight: bold;
font-size: 20px;
margin-bottom: 5px;
}
.message .messageContent .messageBody {
width: 100%;
}
.message .messageContent .messageBody h3 {
margin-top: 0px;
margin-bottom: 2px;
font-size: 20px;
}
.message .messageContent .messageBody p {
margin: 0px 0px 0px 0px;
word-wrap: break-word;
}
main {
background-color: var(--main-color);
border: 5px ridge var(--border-color);
padding-block: 10px;
padding-inline: 20px;
}

46
views/pages/board.ejs Normal file
View file

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<%- include('../partials/head'); %>
</head>
<body>
<%- include('../partials/header') -%>
<div id="content">
<%- include('../partials/top'); %>
<main>
<%
if(locals.login){
%>
<div style="background-color: green;">You are logged in</div>
<% } else {; %>
<div style="background-color:red">You are not logged in</div>
<% } %>
<% messages.forEach((m) => { %>
<strong><%= m.username %></strong>:
<%= m.text %>
<br>
<% }); %>
</ul>
<%
if(locals.login){
%>
<form id="submitForm" action="/message" method="post">
<label for="subjectText">Subject:</label>
<input type="text" name="subjectText" id="">
<label for="messageText">message:</label>
<textarea name="messageText" id="" cols="30" rows="10"></textarea>
<button type="submit">send</button>
</form>
<% }; %>
</main>
</div>
<footer>
<%- include('../partials/footer'); %>
</footer>
</body>
</html>

34
views/pages/index.ejs Normal file
View file

@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<%- include('../partials/head'); %>
</head>
<body>
<%- include('../partials/header') -%>
<div id="content">
<%- include('../partials/top'); %>
<main>
<% threads.forEach((t) => { %>
<h2><a href="/thread/<%= t.id %>/"><%= t.name %></a></h2>
<%= t.description %>
<br>
<%- include('../partials/format_date',{format_date:t.creationdate}); %>
<br>
<% }); %>
<%- include('../partials/create_thread'); %>
</main>
</div>
<footer>
<%- include('../partials/footer'); %>
</footer>
</body>
</html>

53
views/pages/login.ejs Normal file
View file

@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="en">
<head>
<%- include('../partials/head'); %>
</head>
<body>
<header>
<%- include('../partials/header'); %>
</header>
<div id="content">
<%- include('../partials/top'); %>
<main>
<%
if(locals.error){
%>
<div style="background-color: red;">Could not login: <%= error %></div>
<% }; %>
<%
if(locals.success){
%>
<div style="background-color: green; color:white;"><%= success %></div>
<% }; %>
<h1>Login</h1>
<form id="loginForm" action="/login" method="post">
<label for="usernameField">Username:</label>
<input type="text" name="username" id="usernameField">
<label for="passwordField">Password</label>
<input type="text" name="password" id="passwordField">
<button type="submit">Login</button>
</form>
<br>
<hr>
<h1>Register</h1>
<form id="registerForm" action="/register" method="post">
<label for="usernameField">Username:</label>
<input type="text" name="username" id="usernameField">
<label for="passwordField">Password</label>
<input type="text" name="password" id="passwordField">
<button type="submit">Register</button>
</form>
</main>
</div>
<footer>
<%- include('../partials/footer'); %>
</footer>
</body>
</html>

42
views/pages/thread.ejs Normal file
View file

@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<%- include('../partials/head'); %>
</head>
<body>
<%- include('../partials/header') -%>
<div id="content">
<%- include('../partials/top'); %>
<main>
<h1><%= threadinfo.name %></h1>
<h2><%= threadinfo.description %> <%- include('../partials/format_date',{format_date:threadinfo.creationdate}); %></h2>
<% messages.forEach((m) => { %>
<div id="messageContainer">
<%- include('../partials/message',{m:m}); %>
</div>
<% }); %>
<%
if(locals.session.logged_in){
%>
<form id="submitForm" action="/message/<%= threadinfo.id %>" method="post">
<label for="subjectText">Subject:</label>
<input type="text" name="subjectText" id="">
<label for="messageText">message:</label>
<textarea name="messageText" id="" cols="30" rows="10"></textarea>
<button type="submit">send</button>
</form>
<% } else {; %>
<p>You must login to post a message.</p>
<% } %>
</main>
</div>
<footer>
<%- include('../partials/footer'); %>
</footer>
</body>
</html>

View file

@ -0,0 +1,13 @@
<%
if(locals.session.logged_in){
%>
<form id="submitForm" action="/newthread" method="post">
<label for="threadName">Name:</label>
<input type="text" name="threadName" id="">
<label for="threadDesc">Description</label>
<textarea name="threadDesc" id="" cols="30" rows="10"></textarea>
<button type="submit">Create New Thread</button>
</form>
<% } else {; %>
<p>You must login to create a thread.</p>
<% } %>

View file

View file

@ -0,0 +1,7 @@
<% const months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; %>
<%
const date = new Date(format_date*1000);
const year = date.getFullYear();
const month = date.getMonth();
const day = date.getDate();
%><%= months[month] %> <%= `${day}` %> <%= year %>

3
views/partials/head.ejs Normal file
View file

@ -0,0 +1,3 @@
<meta charset="UTF-8">
<title>ForumExperiment.Example</title>
<link rel="stylesheet" href="/out.css">

View file

@ -0,0 +1,3 @@
<header>
<a href="/">Forum Experiment</a>
</header>

View file

@ -0,0 +1,23 @@
<div class="message">
<div class="messageMeta">
<div id="messageDate"><%- include('./format_date',{format_date:m.creationdate}); %></div>
</div>
<div class="messageContent">
<div class="userInfo">
<div id="userName">
<%= m.username %>
</div>
Member since <%- include('./format_date',{format_date:m.accountCreated}); %>
</div>
<div class="messageBody">
<% if (m.subject){ %>
<h3><%= m.subject %></h3>
<% }; %>
<p>
<%= m.text %>
</p>
</div>
</div>
</div>

9
views/partials/top.ejs Normal file
View file

@ -0,0 +1,9 @@
<div id="actionBar">
<div><a href="/">Home</a> </div>
<% if(locals.session?.logged_in){ %>
<div id="actionBarRight"><a href="/logout">logout</a> </div>
<% } else { %>
<div id="actionBarRight"><a href="/login">Login</a> </div>
<% }; %>
</ul>
</div>