tickets/index.js

612 lines
18 KiB
JavaScript

const config = require("./config.json");
const express = require("express");
const app = express();
const port = config.port;
const session = require("express-session");
const bcrypt = require("bcrypt");
const sqlite3 = require("sqlite3").verbose();
const db = new sqlite3.Database("database.db");
const SQLiteStore = require('connect-sqlite3')(session);
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(session({ secret: config.security.sessionSecret, resave: false, saveUninitialized: true, store: new SQLiteStore({ db: "sessions.db" }) }));
app.set("view engine", "ejs");
app.set("views", "views");
// Custom middleware to update the session user data if the user is updated or deleted
app.use((req, res, next) => {
if (req.session.authenticated) {
getUser(req.session.username)
.then((user) => {
if (!user) {
req.session.destroy();
return res.redirect("/login");
}
req.session.userData = { username: user.username, id: user.id, authLevel: user.authLevel };
next();
})
.catch((err) => {
console.error(err);
res.status(500).send("Internal Server Error");
});
} else {
next();
}
});
// Helper functions
// basic password generator
const genPass = () => {
return Array.from({ length: 24 }, () => {
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;:,.<>?";
return charset[Math.floor(Math.random() * charset.length)];
}).join("");
}
// NTFY
const ntfyPublish = (data) => {
const fetch = require('node-fetch');
data.topic = config.ntfy.topic;
fetch(config.ntfy.server, {
method: 'POST',
body: JSON.stringify(data)
})
console.log(data)
}
// Create user
const createUser = (username, password, authLevel) => {
return new Promise((resolve, reject) => {
bcrypt.hash(password, config.security.saltRounds, (err, hash) => {
if (err) {
console.error(err);
reject(err);
return;
}
db.run(
"INSERT INTO users (username, password, authLevel) VALUES (?, ?, ?)",
[username, hash, authLevel],
(err) => {
if (err) {
console.error(err);
reject(err);
return;
}
resolve();
}
);
});
});
};
// Delete user
const deleteUser = (username) => {
return new Promise((resolve, reject) => {
db.run("DELETE FROM users WHERE username = ?", [username], (err) => {
if (err) {
console.error(err);
reject(err);
return;
}
resolve();
});
});
};
// Update user
const updateUser = (username, password, authLevel) => {
return new Promise((resolve, reject) => {
bcrypt.hash(password, config.security.saltRounds, (err, hash) => {
if (err) {
console.error(err);
reject(err);
return;
}
db.run(
"UPDATE users SET password = ?, authLevel = ? WHERE username = ?",
[hash, authLevel, username],
(err) => {
if (err) {
console.error(err);
reject(err);
return;
}
resolve();
}
);
});
});
};
// Get user
const getUser = (username) => {
return new Promise((resolve, reject) => {
db.get("SELECT * FROM users WHERE username = ?", [username], (err, row) => {
if (err) {
console.error(err);
reject(err);
return;
}
resolve(row);
});
});
};
// Check if user is authenticated
const isAuthenticated = (req, res, next) => {
if (req.session.authenticated) {
// Check that the user still exists // TODO: Move this to a helper function
// TODO: Check that the user and session are still valid, in case the user was deleted or the session was invalidated
return next();
}
res.redirect("/login");
};
// Create a ticket
const createTicket = (title, description, status, user, priority) => {
// createdTimestamp is right now, updated will be null
// messages will contain the description as the first message. this is an array of objects
/* message obj
{
timestamp: put_timestamp_here,
user: {
username: "name",
id: X, // user id
authLevel: X // 0 or 1
},
message: put_message_here
}
*/
return new Promise((resolve, reject) => {
const timestamp = Date.now();
const messages = JSON.stringify([{ timestamp, user, message: description }]);
db.run(
"INSERT INTO tickets (title, description, status, user, priority, createdTimestamp, updatedTimestamp, messages) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
[title, description, status, JSON.stringify(user), priority, timestamp, null, messages],
function (err) {
if (err) {
console.error(err);
reject(err);
return;
}
if (config.ntfy) ntfyPublish({title: `New Ticket`, message: `${title}: ${description}`, priority: Number(priority), tags: [user.username, new Date(timestamp).toISOString()]});
resolve(this.lastID);
}
);
});
};
// Delete a ticket (probably won't be used other than debugging)
const deleteTicket = (id) => {
return new Promise((resolve, reject) => {
db.run("DELETE FROM tickets WHERE id = ?", [id], (err) => {
if (err) {
console.error(err);
reject(err);
return;
}
if (config.ntfy) ntfyPublish({title: `Ticket Deleted`, message: `Ticket ${id} has been deleted`, tags: [new Date().toISOString()]});
resolve();
});
});
};
// sortTickets, sort tickets by status (closed last, then pending, then open), then by priority (1-5), then by createdTimestamp
const sortTickets = (tickets) => {
return tickets.sort((a, b) => {
if (a.status === b.status) {
if (a.priority === b.priority) {
return b.createdTimestamp - a.createdTimestamp;
}
return b.priority - a.priority;
}
return b.status - a.status;
});
};
// Routes
app.get("/", (req, res) => {
res.redirect("/dashboard");
});
app.get("/login", (req, res) => {
res.render("login");
});
app.post("/login", (req, res) => {
const { username, password } = req.body;
db.get("SELECT * FROM users WHERE username = ?", [username], (err, row) => {
if (err) {
console.error(err);
return res.status(500).send("Internal Server Error");
}
if (!row) {
return res.status(401).send("Invalid username or password");
}
bcrypt.compare(password, row.password, (err, result) => {
if (err) {
console.error(err);
return res.status(500).send("Internal Server Error");
}
if (!result) {
return res.status(401).send("Invalid username or password");
}
req.session.authenticated = true;
req.session.username = username;
req.session.uid = row.id;
req.session.authLevel = row.authLevel;
req.session.userData = { username, id: row.id, authLevel: row.authLevel };
res.redirect("/dashboard");
});
});
});
app.get("/dashboard", isAuthenticated, (req, res) => {
// Auth 0 Only show own tickets, Auth 1 show all tickets
switch (req.session.authLevel) {
case 0:
// get all tickets, then filter out the ones that aren't the user's
db.all("SELECT * FROM tickets", (err, rows) => {
if (err) {
console.error(err);
return res.status(500).send("Internal Server Error");
}
const userTickets = rows.filter((ticket) => {
return JSON.parse(ticket.user).id === req.session.userData.id;
});
res.render("dashboard", { username: req.session.username, tickets: sortTickets(userTickets) });
});
break;
case 1:
// If ?user=X is set, only show tickets from that user
if (req.query.user) {
db.all("SELECT * FROM tickets WHERE user = ?", [req.query.user], (err, rows) => {
if (err) {
console.error(err);
return res.status(500).send("Internal Server Error");
}
res.render("dashboard", { username: req.session.username, tickets: sortTickets(rows) });
});
return;
} else {
db.all("SELECT * FROM tickets", (err, rows) => {
if (err) {
console.error(err);
return res.status(500).send("Internal Server Error");
}
res.render("dashboard", { username: req.session.username, tickets: sortTickets(rows) });
});
}
break;
}
});
app.get("/logout", isAuthenticated, (req, res) => {
req.session.destroy();
res.redirect("/login");
});
// Ticket manipulation
// Create a ticket
app.get("/ticket/create", isAuthenticated, (req, res) => {
res.render("createTicket");
});
app.post("/ticket/create", isAuthenticated, (req, res) => {
const { title, description, priority } = req.body;
createTicket(title, description, 0, req.session.userData, priority)
.then((ticId) => {
res.redirect(`/ticket/${ticId}`);
})
.catch((err) => {
console.error(err);
res.status(500).send("Internal Server Error");
});
});
// View a ticket
app.get("/ticket/:id", isAuthenticated, (req, res) => {
// if not admin or ticket owner, return 403
db.get("SELECT * FROM tickets WHERE id = ?", [req.params.id], (err, row) => {
if (err) {
console.error(err);
return res.status(500).send("Internal Server Error");
}
if (!row) {
return res.status(404).send("Ticket not found");
}
// Check if user is allowed to view ticket
if (req.session.authLevel === 0 && JSON.parse(row.user).id !== req.session.userData.id) {
return res.status(403).send("Forbidden");
}
// Parse user and messages from JSON
row.user = JSON.parse(row.user);
row.messages = JSON.parse(row.messages);
res.render("viewTicket", { ticket: row });
});
});
// Update a ticket (/ticket/:id/:action)
app.post("/ticket/:id/:action", isAuthenticated, (req, res) => {
// if not admin or ticket owner, return 403
// if action is not valid, return 400
// if action is not valid for user, return 403
// if action is not valid for ticket, return 400
// if action is valid, update ticket and redirect to ticket, unless delete, then go to dashboard
// Check if ticket exists
db.get("SELECT * FROM tickets WHERE id = ?", [req.params.id], (err, row) => {
if (err) {
console.error(err);
return res.status(500).send("Internal Server Error");
}
if (!row) {
return res.status(404).send("Ticket not found");
}
// Parse user and messages from JSON
row.user = JSON.parse(row.user);
row.messages = JSON.parse(row.messages);
if (row.user.id !== req.session.userData.id && req.session.authLevel === 0) {
return res.status(403).send("Forbidden");
}
// Check if action is valid
switch (req.params.action) {
case "delete":
// Delete ticket
deleteTicket(req.params.id)
.then(() => {
res.redirect("/dashboard");
})
.catch((err) => {
console.error(err);
res.status(500).send("Internal Server Error");
});
break;
case "add-message":
// Add message
const timestamp = Date.now();
row.messages.push({ timestamp, user: req.session.userData, message: req.body.message });
db.run("UPDATE tickets SET messages = ? WHERE id = ?", [JSON.stringify(row.messages), req.params.id], (err) => {
if (err) {
console.error(err);
return res.status(500).send("Internal Server Error");
}
// Update last updated timestamp
db.run("UPDATE tickets SET updatedTimestamp = ? WHERE id = ?", [timestamp, req.params.id], (err) => {
if (err) {
console.error(err);
return res.status(500).send("Internal Server Error");
}
if (config.ntfy) ntfyPublish({title: `New Message on ticket ${req.params.id}`, message: `${req.body.message}`, tags: [req.session.userData.username, new Date(timestamp).toISOString()]});
res.redirect(`/ticket/${req.params.id}`);
});
});
break;
case "status": // Update status, 0 1 or 2
// Check if status is valid
if (req.body.status < 0 || req.body.status > 2) {
return res.status(400).send("Invalid status");
}
// Update status
db.run("UPDATE tickets SET status = ? WHERE id = ?", [req.body.status, req.params.id], (err) => {
if (err) {
console.error(err);
return res.status(500).send("Internal Server Error");
}
// Update timestamp
db.run("UPDATE tickets SET updatedTimestamp = ? WHERE id = ?", [Date.now(), req.params.id], (err) => {
if (err) {
console.error(err);
return res.status(500).send("Internal Server Error");
}
if(config.ntfy) ntfyPublish({title: `Ticket ${req.params.id} status changed`, message: `Status changed to ${req.body.status}`, tags: [req.session.userData.username, new Date(Date.now()).toISOString()]});
res.redirect(`/ticket/${req.params.id}`);
});
});
break;
case "priority": // Update priority, 1 thru 5
// Check if priority is valid
if (req.body.priority < 1 || req.body.priority > 5) {
return res.status(400).send("Invalid priority");
}
// Update priority
db.run("UPDATE tickets SET priority = ? WHERE id = ?", [req.body.priority, req.params.id], (err) => {
if (err) {
console.error(err);
return res.status(500).send("Internal Server Error");
}
// Update timestamp
db.run("UPDATE tickets SET updatedTimestamp = ? WHERE id = ?", [Date.now(), req.params.id], (err) => {
if (err) {
console.error(err);
return res.status(500).send("Internal Server Error");
}
if(config.ntfy) ntfyPublish({title: `Ticket ${req.params.id} priority changed`, message: `Priority changed to ${req.body.priority}`, tags: [req.session.userData.username, new Date(Date.now()).toISOString()]});
res.redirect(`/ticket/${req.params.id}`);
});
});
break;
}
});
});
// User manipulation
// Non admins can change own password, admins can create user, delete user, change own password, change other user password
// Create user
app.get("/settings", isAuthenticated, (req, res) => {
res.render("settings");
});
app.post("/user/create", isAuthenticated, (req, res) => {
if (req.session.authLevel === 0) {
return res.status(403).send("Forbidden");
}
const { username, password, authLevel } = req.body;
createUser(username, password, authLevel)
.then(() => {
res.redirect("/dashboard");
})
.catch((err) => {
console.error(err);
res.status(500).send("Internal Server Error");
});
});
app.post("/user/delete", isAuthenticated, (req, res) => {
if (req.session.authLevel === 0) {
return res.status(403).send("Forbidden");
}
const { username } = req.body;
deleteUser(username)
.then(() => {
res.redirect("/dashboard");
})
.catch((err) => {
console.error(err);
res.status(500).send("Internal Server Error");
});
});
app.post("/user/update", isAuthenticated, (req, res) => {
// TODO: Allow user to update their own password
});
app.get("/admin/createUser", isAuthenticated, (req, res) => {
// Check if user is admin
if (req.session.authLevel !== 1) {
return res.status(403).send("Forbidden");
}
res.render("admin/createUser");
});
app.post("/admin/createUser", isAuthenticated, (req, res) => {
// Check if user is admin
if (req.session.authLevel !== 1) {
return res.status(403).send("Forbidden");
}
const { username, password, authLevel } = req.body;
createUser(username, password, authLevel)
.then(() => {
res.redirect("/dashboard");
})
.catch((err) => {
console.error(err);
res.status(500).send("Internal Server Error");
});
});
app.get("/admin/deleteUser", isAuthenticated, (req, res) => {
// Check if user is admin
if (req.session.authLevel !== 1) {
return res.status(403).send("Forbidden");
}
res.render("admin/deleteUser");
});
app.post("/admin/deleteUser", isAuthenticated, (req, res) => {
// Check if user is admin
if (req.session.authLevel !== 1) {
return res.status(403).send("Forbidden");
}
const { username } = req.body;
deleteUser(username)
.then(() => {
res.redirect("/dashboard");
})
.catch((err) => {
console.error(err);
res.status(500).send("Internal Server Error");
});
});
app.get("/admin/updateUser", isAuthenticated, (req, res) => {
// Check if user is admin
if (req.session.authLevel !== 1) {
return res.status(403).send("Forbidden");
}
res.render("admin/updateUser");
})
app.post("/admin/updateUser", isAuthenticated, (req, res) => { // username is only required field, all others are optional. Get the users data, and update the fields that are set
// Check if user is admin
if (req.session.authLevel !== 1) {
return res.status(403).send("Forbidden");
}
const { username, password, authLevel } = req.body;
updateUser(username, password, authLevel)
.then(() => {
res.redirect("/dashboard");
})
.catch((err) => {
console.error(err);
res.status(500).send("Internal Server Error");
});
});
app.get("/admin/listUsers", isAuthenticated, (req, res) => {
// Check if user is admin
if (req.session.authLevel !== 1) {
return res.status(403).send("Forbidden");
}
db.all("SELECT * FROM users", (err, rows) => {
if (err) {
console.error(err);
return res.status(500).send("Internal Server Error");
}
res.render("admin/listUsers", { users: rows });
});
});
(async () => {
await new Promise((resolve, reject) => {
db.run(
// Create users table
"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, username TEXT, password TEXT, authLevel INTEGER)",
(err) => {
if (err) reject(err);
else resolve();
}
);
});
await new Promise((resolve, reject) => {
db.run(
// Create tickets table
"CREATE TABLE IF NOT EXISTS tickets (id INTEGER PRIMARY KEY, title TEXT, description TEXT, status INTEGER, user INTEGER, priority INTEGER(1), createdTimestamp INTEGER, updatedTimestamp INTEGER, messages TEXT, FOREIGN KEY(user) REFERENCES users(id))",
(err) => {
if (err) reject(err);
else resolve();
}
);
});
// Start the server
app.listen(port, () => {
console.log(`Server listening on port ${port}`);
// If users table is empty create an admin user
db.get("SELECT * FROM users", (err, row) => {
if (err) {
console.error(err);
return;
}
if (!row) {
let pass = genPass();
createUser("admin", pass, 1)
.then(() => {
console.log(`Admin user created. Username: admin, Password: ${pass}`);
})
.catch((err) => {
console.error(err);
});
}
});
});
})();