mirror of
https://github.com/SileNce5k/discord_bot_mgmt.git
synced 2025-04-19 19:16:20 +02:00
Initial Commit
Just the very very basics are there now. It is not usable at all yet though.
This commit is contained in:
commit
c0af804da1
15 changed files with 2000 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
data/
|
||||||
|
.env
|
||||||
|
node_modules/
|
57
README.md
Normal file
57
README.md
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
# Plans for this discord bot management project
|
||||||
|
|
||||||
|
|
||||||
|
* Use node.js backend
|
||||||
|
* Need the ability to login.
|
||||||
|
* One backend can have multiple bots
|
||||||
|
* The bots will be in a _tab_ or something similar
|
||||||
|
* Control bots:
|
||||||
|
* Restart bot
|
||||||
|
* Update bot
|
||||||
|
* Change config
|
||||||
|
* Backup data/ dir (gzip, then download)
|
||||||
|
* Restore data/ dir (upload gzipped archive)
|
||||||
|
* Check if the server can be put down for maintenance (check if there are timers in the near future (hour))
|
||||||
|
* Add a new bot
|
||||||
|
* Download logs (specify time range & file format (gzip or plain))
|
||||||
|
* Ability to see a log equivalent to `journalctl -fu discord_bot.service`
|
||||||
|
* Statistics (uptime, amount of commands, last error, commands per hour, commands last 24 hours etc..., )
|
||||||
|
|
||||||
|
Bots will have an internal api that they communicate with the backend server over.
|
||||||
|
Using websockets for the follow log is probably best?
|
||||||
|
|
||||||
|
### Misc Thoughts
|
||||||
|
|
||||||
|
* Check if there are any software updates? If there are, send email?
|
||||||
|
|
||||||
|
First priority:
|
||||||
|
* Create a basic html page.
|
||||||
|
|
||||||
|
### SQLITE SCHEMAS
|
||||||
|
|
||||||
|
TODO: Figure out how to implement permissions and stuff in a good way
|
||||||
|
```sql
|
||||||
|
CREATE TABLE users (
|
||||||
|
user_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE,
|
||||||
|
hashed_password TEXT,
|
||||||
|
email TEXT,
|
||||||
|
created_at INTEGER,
|
||||||
|
is_verified INTEGER,
|
||||||
|
is_administrator INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE tokens (
|
||||||
|
token PRIMARY KEY UNIQUE,
|
||||||
|
user_id INTEGER,
|
||||||
|
expires_at INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE discord_bots (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
owner_id INTEGER,
|
||||||
|
is_public INTEGER,
|
||||||
|
|
||||||
|
);
|
||||||
|
|
||||||
|
```
|
187
backend/server.js
Normal file
187
backend/server.js
Normal file
|
@ -0,0 +1,187 @@
|
||||||
|
const express = require('express');
|
||||||
|
const bodyParser = require('body-parser');
|
||||||
|
const app = express();
|
||||||
|
const port = 3000;
|
||||||
|
const path = require('path')
|
||||||
|
const cookieParser = require('cookie-parser')
|
||||||
|
const argon2 = require('argon2')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
|
||||||
|
app.set('view engine', 'ejs');
|
||||||
|
app.use(bodyParser.json());
|
||||||
|
app.use(bodyParser.urlencoded({extended: true}));
|
||||||
|
app.use(cookieParser())
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// app.use((req, res, next) => {
|
||||||
|
// // TODO: This middleware will authenticate users so I don't have to do it in every specific page.
|
||||||
|
// // Use https://expressjs.com/en/5x/api.html#res.locals to pass authenticated user and stuff.
|
||||||
|
// next()
|
||||||
|
// })
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// TODO: Convert to typescript
|
||||||
|
|
||||||
|
const frontendPath = {
|
||||||
|
views: path.join(__dirname, "..", "frontend", "views"),
|
||||||
|
public: path.join(__dirname, "..", "frontend", "public")
|
||||||
|
}
|
||||||
|
|
||||||
|
function _frontendPath(isPublic, file){ // TODO: Improve these.
|
||||||
|
if(isPublic)
|
||||||
|
return path.join(frontendPath.public, file)
|
||||||
|
return path.join(frontendPath.views, file)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(express.static(frontendPath.public));
|
||||||
|
app.set('views', frontendPath.views);
|
||||||
|
|
||||||
|
// TODO: Check if the sql runs fail before doing stuff
|
||||||
|
|
||||||
|
const databasePath = "data/database.db";
|
||||||
|
const db = require('better-sqlite3')(databasePath);
|
||||||
|
|
||||||
|
// CREATE tables
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
user_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE,
|
||||||
|
hashed_password TEXT,
|
||||||
|
email TEXT,
|
||||||
|
created_at INTEGER,
|
||||||
|
is_verified INTEGER,
|
||||||
|
is_administrator INTEGER
|
||||||
|
)`
|
||||||
|
).run();
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
CREATE TABLE IF NOT EXISTS tokens (
|
||||||
|
token PRIMARY KEY UNIQUE,
|
||||||
|
user_id INTEGER,
|
||||||
|
expires_at INTEGER
|
||||||
|
)`
|
||||||
|
).run();
|
||||||
|
|
||||||
|
|
||||||
|
function verifyAuthToken(authToken){
|
||||||
|
const authenticatedUser = db.prepare("SELECT * FROM tokens WHERE token = ?").get(authToken);
|
||||||
|
if(!authenticatedUser) return false;
|
||||||
|
if(authenticatedUser.token !== authToken) return false;
|
||||||
|
return authenticatedUser; // TODO: Check if token has expired (if expires_at has past)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUser(userid){
|
||||||
|
const user = db.prepare("SELECT user_id, username, email, created_at, is_verified FROM users WHERE user_id = ?").get(userid)
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/users/:id/settings', (req, res) => {
|
||||||
|
let authenticatedUser = verifyAuthToken(req.cookies.auth_token)
|
||||||
|
let userId = Number(req.params.id);
|
||||||
|
if(authenticatedUser){
|
||||||
|
if(authenticatedUser.user_id === userId){
|
||||||
|
res.render("user_settings", {id: userId}) // TODO: Finish the settings page.
|
||||||
|
}else {
|
||||||
|
res.redirect(`/users/${authenticatedUser.user_id}/settings`)
|
||||||
|
}
|
||||||
|
}else {
|
||||||
|
res.redirect("/")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/register', (req, res) => {
|
||||||
|
// TODO: Check if logged in first.
|
||||||
|
if(verifyAuthToken(req.cookies.auth_token)){
|
||||||
|
res.redirect("/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.render("register")
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
const authenticatedUser = verifyAuthToken(req.cookies.auth_token);
|
||||||
|
if(authenticatedUser){
|
||||||
|
const user = getUser(authenticatedUser.user_id)
|
||||||
|
const footer = "";
|
||||||
|
res.render("dashboard", {user: user, footer: footer})
|
||||||
|
}else{
|
||||||
|
res.render("invalid_login")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/logout', (req, res) => {
|
||||||
|
|
||||||
|
res.clearCookie("auth_token").redirect("/")
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/login', (req, res) => {
|
||||||
|
if(req.query.invalid === "yes"){
|
||||||
|
res.render("login_incorrect")
|
||||||
|
}else{
|
||||||
|
res.render("login")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/v1/login', async (req, res,) => {
|
||||||
|
const username = req.body.username;
|
||||||
|
const password = req.body.password;
|
||||||
|
|
||||||
|
let user = db.prepare("SELECT user_id, hashed_password FROM users WHERE username = ?").get(username);
|
||||||
|
if(!user){
|
||||||
|
res.redirect("/login?invalid=yes") // TODO: Make it so the url bar still shows /login on this
|
||||||
|
}else {
|
||||||
|
let isVerified = false;
|
||||||
|
try {
|
||||||
|
isVerified = await argon2.verify(user.hashed_password, password)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
res.status(500).send("Internal Server Error")
|
||||||
|
}
|
||||||
|
if(isVerified){
|
||||||
|
const maxAge = 2592000000 // 30 days in milliseconds.
|
||||||
|
const maxAgeTimestamp = new Date().valueOf() + maxAge
|
||||||
|
const token = crypto.randomBytes(128).toString('base64')
|
||||||
|
db.prepare("INSERT INTO tokens ( token, user_id, expires_at ) VALUES (?, ?, ?)").run(token, user.user_id, maxAgeTimestamp) // TODO: Check if this fails before setting cookie.
|
||||||
|
res.cookie("auth_token", token, {maxAge: maxAge, secure: true, httpOnly: true, sameSite: 'lax'}).redirect("/")
|
||||||
|
}else{
|
||||||
|
res.redirect("/login?invalid=yes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/v1/register', async (req, res) => { // TODO: Create checks for requirements like min pw length, min username length. Do some email validation?.
|
||||||
|
let username = req.body.username;
|
||||||
|
const password = req.body.password;
|
||||||
|
const email = req.body.email;
|
||||||
|
if(!username || !password || !email){
|
||||||
|
res.render("register_missing")
|
||||||
|
}else{
|
||||||
|
const hashed_password = await argon2.hash(password);
|
||||||
|
const createdAt = new Date().getTime();
|
||||||
|
const isVerified = 0;
|
||||||
|
// TODO: Check if username already exists, will crash because username has to be unique in database
|
||||||
|
db.prepare("INSERT INTO users (username, hashed_password, email, created_at, is_verified) VALUES (?, ?, ?, ?, ?)").run(username, hashed_password, email, createdAt, isVerified)
|
||||||
|
res.redirect("/login");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`INFO:\tServer started at http://127.0.0.1:${port}`);
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
console.log("Exiting safely...")
|
||||||
|
db.close();
|
||||||
|
process.exit();
|
||||||
|
});
|
78
frontend/public/css/main.css
Normal file
78
frontend/public/css/main.css
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #262626;
|
||||||
|
color: #dedede;
|
||||||
|
font-size: 40px;
|
||||||
|
text-align: center;
|
||||||
|
font-family: "Verdana, Geneva, Tahoma, sans-serif";
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100svh;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style-type: none;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #dedede;
|
||||||
|
}
|
||||||
|
a:visited {
|
||||||
|
color: #dedede;
|
||||||
|
}
|
||||||
|
|
||||||
|
#nav {
|
||||||
|
/*margin-top:20%;*/
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
}
|
||||||
|
#links p {
|
||||||
|
margin-top: 0px;
|
||||||
|
margin-bottom: 0px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#incorrect-login{
|
||||||
|
color: #862121;
|
||||||
|
font-size: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#check01, ul.dropdown-submenu {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.dropdown-label {
|
||||||
|
font-size: 20px;
|
||||||
|
display: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #6a6a6a;
|
||||||
|
width: 100px;
|
||||||
|
color: white;
|
||||||
|
padding: 10px;
|
||||||
|
font-weight: bolder;
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#check01:checked~ul.dropdown-submenu {
|
||||||
|
display: inherit;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown{
|
||||||
|
display:flex;
|
||||||
|
top: 8%;
|
||||||
|
right: 10%;
|
||||||
|
position: absolute;
|
||||||
|
}
|
28
frontend/views/dashboard.ejs
Normal file
28
frontend/views/dashboard.ejs
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Discord Bot Management</title>
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<header class="dashboard">
|
||||||
|
<h1 class="dashboard-title">Dashboard</h1>
|
||||||
|
<div class="user-dropdown">
|
||||||
|
<input id="check01" type="checkbox" name="menu" />
|
||||||
|
<label class="dropdown-label" for="check01">
|
||||||
|
<%= user.username %>
|
||||||
|
</label>
|
||||||
|
<ul class="dropdown-submenu">
|
||||||
|
<li><a href="/users/<%= user.user_id %>/settings">Settings</a></li>
|
||||||
|
<li><a href="/logout">Logout</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<%= footer %>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
15
frontend/views/index.ejs
Normal file
15
frontend/views/index.ejs
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Discord Bot Management</title>
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
14
frontend/views/invalid_login.ejs
Normal file
14
frontend/views/invalid_login.ejs
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Discord Bot Control Panel</title>
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Not logged in</h1>
|
||||||
|
<p>Please <a href="/login">login</a> before you can access the control panel.</p>
|
||||||
|
<p>Register <a href="/register">here</a></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
19
frontend/views/login.ejs
Normal file
19
frontend/views/login.ejs
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>dbot MGMT Login</title>
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<form action="/api/v1/login" method="post">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" name="username" size="29" required>
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" name="password" size="29" required > <br/>
|
||||||
|
<input type="submit" value="Login">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
20
frontend/views/login_incorrect.ejs
Normal file
20
frontend/views/login_incorrect.ejs
Normal 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">
|
||||||
|
<title>dbot MGMT Login</title>
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<form action="/api/v1/login" method="post">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" name="username" size="29" required>
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" name="password" size="29" required ><br/>
|
||||||
|
<input type="submit" value="Login">
|
||||||
|
<p id="incorrect-login">Username or password incorrect</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
15
frontend/views/logout.ejs
Normal file
15
frontend/views/logout.ejs
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Discord Bot Management</title>
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h1>Logging out...</h1>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
21
frontend/views/register.ejs
Normal file
21
frontend/views/register.ejs
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>dbot MGMT Registration</title>
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<form action="/api/v1/register" method="post" >
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" name="username" size="29" required>
|
||||||
|
<label for="email" name="email">Email</label>
|
||||||
|
<input type="email" name="email" size="29" required>
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" name="password" size="29" required >
|
||||||
|
<input type="submit" value="Register">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
22
frontend/views/register_missing.ejs
Normal file
22
frontend/views/register_missing.ejs
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>dbot MGMT Registration</title>
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<form action="/api/v1/register" method="post">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" name="username" size="29" required>
|
||||||
|
<label for="email" name="email">Email</label>
|
||||||
|
<input type="email" name="email" size="29" required>
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" name="password" size="29" required >
|
||||||
|
<p>Missing information, Please enter all the required information.</p>
|
||||||
|
<input type="submit" value="Register">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
15
frontend/views/user_settings.ejs
Normal file
15
frontend/views/user_settings.ejs
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Discord Bot Management</title>
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<p>This will be the user settings user id:<%= id %></p>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
1486
package-lock.json
generated
Normal file
1486
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
20
package.json
Normal file
20
package.json
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"name": "dbot_mgmt",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "A web management for my discord bot https://github.com/silence5k/discord_bot.git",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
|
"start": "node backend/server.js"
|
||||||
|
},
|
||||||
|
"author": "SileNce",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"dependencies": {
|
||||||
|
"argon2": "^0.41.1",
|
||||||
|
"better-sqlite3": "^11.7.0",
|
||||||
|
"body-parser": "^1.20.3",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"ejs": "^3.1.10",
|
||||||
|
"express": "^4.21.2"
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue