diff --git a/src/client/css/admin.css b/src/client/css/admin.css
new file mode 100644
index 000000000..dcc4f0cbb
--- /dev/null
+++ b/src/client/css/admin.css
@@ -0,0 +1,23 @@
+* {
+ background-color: rgb(20, 20, 20);
+ color: white;
+}
+body {
+ padding: 30px 40px;
+}
+textarea {
+ width: 100%;
+ height: 80vh;
+}
+.inputContainer {
+ display: flex;
+ flex-direction: row;
+ width: 100%;
+}
+input {
+ width: 400px;
+}
+button {
+ width: 150px;
+ margin-left: 4px;
+}
\ No newline at end of file
diff --git a/src/client/scripts/esm/util/jsutil.js b/src/client/scripts/esm/util/jsutil.js
index b2c8e91eb..15a749dad 100644
--- a/src/client/scripts/esm/util/jsutil.js
+++ b/src/client/scripts/esm/util/jsutil.js
@@ -149,6 +149,8 @@ function copyPropertiesToObject(objSrc, objDest) {
/**
* O(1) method of checking if an object/dict is empty
+ * I think??? I may be wrong. I think before the first iteration of
+ * a for-in loop the program still has to calculate the keys...
* @param {Object} obj
* @returns {Boolean}
*/
diff --git a/src/client/scripts/esm/views/admin.ts b/src/client/scripts/esm/views/admin.ts
new file mode 100644
index 000000000..68133d8d8
--- /dev/null
+++ b/src/client/scripts/esm/views/admin.ts
@@ -0,0 +1,31 @@
+const commandInput = document.getElementById("commandInput")! as HTMLInputElement;
+const commandHistory = document.getElementById("commandHistory")! as HTMLTextAreaElement;
+const sendCommandButton = document.getElementById("sendButton")! as HTMLButtonElement;
+
+async function sendCommand() {
+ const commandString: string = commandInput.value;
+ if (commandString.length === 0) return; // Don't send command if the input box is empty
+ commandInput.value = "";
+ const response = await fetch("command/" + commandString);
+ commandHistory.textContent += commandString + '\n' + await response.text() + "\n\n";
+ scrollToBottom(commandHistory);
+}
+
+function clickSubmitIfReturnPressed(event: any) {
+ // 13 is the key code for Enter key
+ if (event.keyCode === 13) sendCommandButton.click();
+}
+
+/**
+ * Automatically scrolls to the bottom of the container.
+ * @param container - The container to scroll.
+ */
+function scrollToBottom(container: HTMLElement) {
+ container.scrollTo({
+ top: container.scrollHeight,
+ behavior: 'smooth',
+ });
+}
+
+sendCommandButton.addEventListener("click", sendCommand);
+commandInput.addEventListener('keyup', clickSubmitIfReturnPressed);
\ No newline at end of file
diff --git a/src/client/views/admin.html b/src/client/views/admin.html
new file mode 100644
index 000000000..4691de8f5
--- /dev/null
+++ b/src/client/views/admin.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/server/api/AdminPanel.ts b/src/server/api/AdminPanel.ts
new file mode 100644
index 000000000..4edbbb110
--- /dev/null
+++ b/src/server/api/AdminPanel.ts
@@ -0,0 +1,269 @@
+
+/**
+ * This script handles all incoming commands send from the admin console page
+ * /admin
+ */
+
+import { manuallyVerifyUser } from "../controllers/verifyAccountController.js";
+// @ts-ignore
+import { getMemberDataByCriteria } from "../database/memberManager.js";
+// @ts-ignore
+import { logEvents } from "../middleware/logEvents.js";
+// @ts-ignore
+import { deleteAccount } from "../controllers/deleteAccountController.js";
+// @ts-ignore
+import { deleteAllSessionsOfUser } from "../controllers/authenticationTokens/sessionManager.js";
+// @ts-ignore
+import { areRolesHigherInPriority } from "../controllers/roles.js";
+
+import type { CustomRequest } from "../../types.js";
+import type { Response } from "express";
+
+
+
+const validCommands = [
+ "ban",
+ "delete",
+ "username",
+ "logout",
+ "verify",
+ "post",
+ "invites",
+ "announce",
+ "userinfo",
+ "help"
+];
+
+function processCommand(req: CustomRequest, res: Response): void {
+ const command = req.params["command"]!;
+
+ const commandAndArgs = parseArgumentsFromCommand(command);
+
+ if (!req.memberInfo.signedIn) {
+ res.status(401).send("Cannot send commands while logged out.");
+ return;
+ }
+ if (!(req.memberInfo.roles?.includes("admin") ?? false)) {
+ res.status(403).send("Cannot send commands without the admin role");
+ return;
+ }
+ // TODO prevent affecting accounts with equal or higher roles
+ switch (commandAndArgs[0]) {
+ case "ban":
+ return;
+ case "delete":
+ deleteCommand(command, commandAndArgs, req, res);
+ return;
+ case "username":
+ usernameCommand(command, commandAndArgs, req, res);
+ return;
+ case "logout":
+ logoutUser(command, commandAndArgs, req, res);
+ return;
+ case "verify":
+ verify(command, commandAndArgs, req, res);
+ return;
+ case "post":
+ return;
+ case "invites":
+ return;
+ case "announce":
+ return;
+ case "userinfo":
+ getUserInfo(command, commandAndArgs, req, res);
+ return;
+ case "help":
+ helpCommand(commandAndArgs, res);
+ return;
+ default:
+ res.status(422).send("Unknown command.");
+ return;
+ }
+}
+
+function parseArgumentsFromCommand(command: string): string[] {
+ // Parse command
+ const commandAndArgs: string[] = [];
+ let inQuote: boolean = false;
+ let temp: string = "";
+ for (let i = 0; i < command.length; i++) {
+ if (command[i] === '"') {
+ if (i === 0 || command[i - 1] !== '\\') {
+ inQuote = !inQuote;
+ }
+ else {
+ temp += '"';
+ }
+ }
+ else if (command[i] === ' ' && !inQuote) {
+ commandAndArgs.push(temp);
+ temp = "";
+ }
+ else if (inQuote || (command[i] !== '"' && command[i] !== ' ')) {
+ temp += command[i];
+ }
+ }
+ commandAndArgs.push(temp);
+
+ return commandAndArgs;
+}
+
+function deleteCommand(command: string, commandAndArgs: string[], req: CustomRequest, res: Response) {
+ if (commandAndArgs.length < 3) {
+ res.status(422).send("Invalid number of arguments, expected 2, got " + (commandAndArgs.length - 1) + ".");
+ return;
+ }
+ // Valid Syntax
+ logCommand(command, req);
+ const reason = commandAndArgs[2];
+ const usernameArgument = commandAndArgs[1];
+ const { user_id, username, roles } = getMemberDataByCriteria(["user_id","username","roles"], "username", usernameArgument, { skipErrorLogging: true });
+ if (user_id === undefined) return sendAndLogResponse(res, 404, "User " + usernameArgument + " does not exist.");
+ // They were found...
+ const adminsRoles = req.memberInfo.signedIn ? req.memberInfo.roles : null;
+ const rolesOfAffectedUser = JSON.parse(roles);
+ // Don't delete them if they are equal or higher than your status
+ if (!areRolesHigherInPriority(adminsRoles, rolesOfAffectedUser)) return sendAndLogResponse(res, 403, "Forbidden to delete " + username + ".");
+ if (!deleteAccount(user_id, reason)) return sendAndLogResponse(res, 500, "Failed to delete " + username + ".");
+ sendAndLogResponse(res, 200, "Successfully deleted user " + username + ".");
+}
+
+function usernameCommand(command: string, commandAndArgs: string[], req: CustomRequest, res: Response) {
+ if (commandAndArgs[1] === "get") {
+ if (commandAndArgs.length < 3) {
+ res.status(422).send("Invalid number of arguments, expected 2, got " + (commandAndArgs.length - 1) + ".");
+ return;
+ }
+ const parsedId = Number.parseInt(commandAndArgs[2]!);
+ if (Number.isNaN(parsedId)) {
+ res.status(422).send("User id must be an integer.");
+ return;
+ }
+ // Valid Syntax
+ logCommand(command, req);
+ const { username } = getMemberDataByCriteria(["username"], "user_id", parsedId, { skipErrorLogging: true });
+ if (username === undefined) sendAndLogResponse(res, 404, "User with id " + parsedId + " does not exist.");
+ else sendAndLogResponse(res, 200, username);
+ }
+ else if (commandAndArgs[1] === "set") {
+ if (commandAndArgs.length < 4) {
+ res.status(422).send("Invalid number of arguments, expected 3, got " + (commandAndArgs.length - 1) + ".");
+ return;
+ }
+ // TODO add username changing logic
+ res.status(503).send("Changing usernames is not yet supported.");
+ }
+ else if (commandAndArgs[1] === undefined) {
+ res.status(422).send("Expected either get or set as a subcommand.");
+ }
+ else {
+ res.status(422).send("Invalid subcommand, expected either get or set, got " + commandAndArgs[1] + ".");
+ }
+}
+
+function logoutUser(command: string, commandAndArgs: string[], req: CustomRequest, res: Response) {
+ if (commandAndArgs.length < 2) {
+ res.status(422).send("Invalid number of arguments, expected 1, got " + (commandAndArgs.length - 1) + ".");
+ return;
+ }
+ // Valid Syntax
+ logCommand(command, req);
+ const usernameArgument = commandAndArgs[1];
+ const { user_id, username } = getMemberDataByCriteria(["user_id","username"], "username", usernameArgument, { skipErrorLogging: true });
+ if (user_id !== undefined) {
+ deleteAllSessionsOfUser(user_id);
+ sendAndLogResponse(res, 200, "User " + username + " successfully logged out."); // Use their case-sensitive username
+ }
+ else {
+ sendAndLogResponse(res, 404, "User " + usernameArgument + " does not exist.");
+ }
+}
+
+function verify(command: string, commandAndArgs: string[], req: CustomRequest, res: Response) {
+ if (commandAndArgs.length < 2) {
+ res.status(422).send("Invalid number of arguments, expected 1, got " + (commandAndArgs.length - 1) + ".");
+ return;
+ }
+ // Valid Syntax
+ logCommand(command, req);
+ const usernameArgument = commandAndArgs[1];
+ // This method works without us having to confirm they exist first
+ const result = manuallyVerifyUser(usernameArgument!); // { success, username, reason }
+ if (result.success) sendAndLogResponse(res, 200, "User " + result.username + " has been verified!");
+ else sendAndLogResponse(res, 500, result.reason); // Failure message
+}
+
+function getUserInfo(command: string, commandAndArgs: string[], req: CustomRequest, res: Response) {
+ if (commandAndArgs.length < 2) {
+ res.status(422).send("Invalid number of arguments, expected 1, got " + (commandAndArgs.length - 1) + ".");
+ return;
+ }
+ // Valid Syntax
+ logCommand(command, req);
+ const username = commandAndArgs[1];
+ const memberData = getMemberDataByCriteria(["user_id", "username", "roles", "joined", "last_seen", "preferences", "verification", "username_history"], "username", username, { skipErrorLogging: true });
+ if (Object.keys(memberData).length === 0) { // Empty (member not found)
+ sendAndLogResponse(res, 404, "User " + username + " does not exist.");
+ }
+ else {
+ sendAndLogResponse(res, 200, JSON.stringify(memberData));
+ }
+}
+
+function helpCommand(commandAndArgs: string[], res: Response) {
+ if (commandAndArgs.length === 1) {
+ res.status(200).send("Commands: " + validCommands.join(", ") + "\nUse help to get more information about a command.");
+ return;
+ }
+ switch (commandAndArgs[1]) {
+ case "ban":
+ res.status(200).send("Syntax: ban [days]\nBans a user for a duration or permanently.");
+ return;
+ case "unban":
+ res.status(200).send("Syntax: unban \nUnbans the given email.");
+ return;
+ case "delete":
+ res.status(200).send("Syntax: delete [reason]\nDeletes the given user's account for an optional reason.");
+ return;
+ case "username":
+ res.status(200).send("Syntax: username get \n username set \nGets or sets the username of the account with the given userid");
+ return;
+ case "logout":
+ res.status(200).send("Syntax: logout \nLogs out all sessions of the account with the given username.");
+ return;
+ case "verify":
+ res.status(200).send("Syntax: verify \nVerifies the account of the given username.");
+ return;
+ case "post":
+ return;
+ case "invites":
+ return;
+ case "announce":
+ return;
+ case "userinfo":
+ res.status(200).send("Syntax: userinfo \nPrints info about a user.");
+ return;
+ case "help":
+ res.status(200).send("Syntax: help [command]\nPrints the list of commands or information about a command.");
+ return;
+ default:
+ res.status(422).send("Unknown command.");
+ return;
+ }
+}
+
+function logCommand(command: string, req: CustomRequest) {
+ if (req.memberInfo.signedIn) {
+ logEvents(`Command executed by admin "${req.memberInfo.username}" of id "${req.memberInfo.user_id}": ` + command, "adminCommands.txt", { print: true });
+ } else throw new Error('Admin SHOULD have been logged in by this point. DANGEROUS');
+}
+
+function sendAndLogResponse(res: Response, code: number, message: any) {
+ res.status(code).send(message);
+ // Also log the sent response
+ logEvents("Result: " + message + "\n", "adminCommands.txt", { print: true });
+}
+
+export {
+ processCommand
+};
\ No newline at end of file
diff --git a/src/server/config/setupDev.js b/src/server/config/setupDev.js
index 016ba9274..70ca5a169 100644
--- a/src/server/config/setupDev.js
+++ b/src/server/config/setupDev.js
@@ -18,6 +18,7 @@ async function createDevelopmentAccounts() {
if (!doesMemberOfUsernameExist("owner")) {
const user_id = await generateAccount({ username: "Owner", email: "email1", password: "1", autoVerify: true });
giveRole(user_id, "owner");
+ giveRole(user_id, "admin");
}
if (!doesMemberOfUsernameExist("patron")) {
const user_id = await generateAccount({ username: "Patron", email: "email2", password: "1", autoVerify: true });
diff --git a/src/server/controllers/roles.js b/src/server/controllers/roles.js
index 2cf7007d9..aab86a7ef 100644
--- a/src/server/controllers/roles.js
+++ b/src/server/controllers/roles.js
@@ -6,7 +6,11 @@
import { logEvents } from "../middleware/logEvents.js";
import { getMemberDataByCriteria, updateMemberColumns } from "../database/memberManager.js";
-const validRoles = ['owner','patron'];
+/**
+ * All possible roles, IN ORDER FROM LEAST TO MOST IMPORTANCE!
+ * The ordering determines admin's capabilities in the admin console.
+ */
+const validRoles = ['patron', 'admin', 'owner'];
/**
@@ -50,6 +54,39 @@ function removeAllRoles(userId) {
}
// removeAllRoles(11784992);
+/**
+ * Returns true if roles1 contains atleast one role that is higher in priority than the highest role in roles2.
+ *
+ * If so, the user with roles1 would be able to perform destructive commands on user with roles2.
+ * @param {string[] | null} roles1
+ * @param {string[] | null} roles2
+ */
+function areRolesHigherInPriority(roles1, roles2) {
+ // Make sure they are not null
+ roles1 = roles1 || [];
+ roles2 = roles2 || [];
+
+ let roles1HighestPriority = -1; // -1 is the same as someone with zero roles
+ roles1.forEach(role => {
+ const priorityOfRole = validRoles.indexOf(role);
+ if (priorityOfRole > roles1HighestPriority) roles1HighestPriority = priorityOfRole;
+ });
+
+ let roles2HighestPriority = -1; // -1 is the same as someone with zero roles
+ roles2.forEach(role => {
+ const priorityOfRole = validRoles.indexOf(role);
+ if (priorityOfRole > roles2HighestPriority) roles2HighestPriority = priorityOfRole;
+ });
+
+ // console.log('roles1 highest role: ' + roles1HighestRoles);
+ // console.log('roles2 highest role: ' + roles2HighestRoles);
+
+ return roles1HighestPriority > roles2HighestPriority;
+}
+
+
+
export {
giveRole,
+ areRolesHigherInPriority,
};
\ No newline at end of file
diff --git a/src/server/controllers/verifyAccountController.ts b/src/server/controllers/verifyAccountController.ts
index ab1de0218..d6db7c316 100644
--- a/src/server/controllers/verifyAccountController.ts
+++ b/src/server/controllers/verifyAccountController.ts
@@ -134,14 +134,14 @@ function getNewVerificationAfterVerifying(): Verification { // { verified, notif
* Manually verifies a user by the provided name.
*
* DOES NOT CHECK IF YOU HAVE THE REQUIRED PERMISSIONS.
- * @param username
+ * @param usernameCaseInsensitive
* @returns A success object: `{ success (boolean}, reason (string, if failed) }`
*/
-function manuallyVerifyUser(username: string): { success: true } | { success: false, reason: string } {
- const { user_id, verification: stringifiedVerificationOrNull } = getMemberDataByCriteria(['user_id', 'username', 'verification'], 'username', username, { skipErrorLogging: true });
+function manuallyVerifyUser(usernameCaseInsensitive: string): { success: true, username: string } | { success: false, reason: string } {
+ const { user_id, username, verification: stringifiedVerificationOrNull } = getMemberDataByCriteria(['user_id', 'username', 'verification'], 'username', usernameCaseInsensitive, { skipErrorLogging: true });
if (user_id === undefined) { // User not found
- logEvents(`Cannot manually verify user "${username}" when they don't exist.`, 'errLog.txt', { print: true });
- return { success: false, reason: `User "${username}" doesn't exist.` };
+ logEvents(`Cannot manually verify user "${usernameCaseInsensitive}" when they don't exist.`, 'errLog.txt', { print: true });
+ return { success: false, reason: `User "${usernameCaseInsensitive}" doesn't exist.` };
}
// The verification is stringified in the database. We need to parse it here.
@@ -164,7 +164,7 @@ function manuallyVerifyUser(username: string): { success: true } | { success: fa
}
logEvents(`Manually verified member ${username}'s account! ID ${user_id}`, 'loginAttempts.txt', { print: true });
- return { success: true };
+ return { success: true, username };
}
diff --git a/src/server/middleware/middleware.js b/src/server/middleware/middleware.js
index 33c25b1f0..8f1b7551d 100644
--- a/src/server/middleware/middleware.js
+++ b/src/server/middleware/middleware.js
@@ -37,6 +37,7 @@ import { handleLogin } from '../controllers/loginController.js';
import { checkEmailAssociated, checkUsernameAvailable, createNewMember } from '../controllers/createAccountController.js';
import { removeAccount } from '../controllers/deleteAccountController.js';
import { assignOrRenewBrowserID } from '../controllers/browserIDManager.js';
+import { processCommand } from "../api/AdminPanel.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
@@ -146,6 +147,8 @@ function configureMiddleware(app) {
app.get("/logout", handleLogout);
+ app.get("/command/:command", processCommand);
+
// Member routes that do require authentication
app.get('/member/:member/data', getMemberData);
app.get('/member/:member/send-email', requestConfirmEmail);
diff --git a/src/server/middleware/verifyJWT.js b/src/server/middleware/verifyJWT.js
index ba5d9749f..283cb6d80 100644
--- a/src/server/middleware/verifyJWT.js
+++ b/src/server/middleware/verifyJWT.js
@@ -61,7 +61,8 @@ function verifyAccessToken(req, res) {
console.log("A valid access token was used! :D :D");
const { user_id, username, roles, allowed_actions } = result;
- req.memberInfo = { signedIn: true, user_id, username, roles, allowed_actions }; // Username was our payload when we generated the access token
+ const parsedRoles = JSON.parse(roles);
+ req.memberInfo = { signedIn: true, user_id, username, roles: parsedRoles, allowed_actions }; // Username was our payload when we generated the access token
return true; // true if they have a valid ACCESS token
}
@@ -91,7 +92,8 @@ function verifyRefreshToken(req, res) {
// Valid! Set their req.memberInfo property!
const { user_id, username, roles } = result;
- req.memberInfo = { signedIn: true, user_id, username, roles }; // Username was our payload when we generated the access token
+ const parsedRoles = JSON.parse(roles);
+ req.memberInfo = { signedIn: true, user_id, username, roles: parsedRoles }; // Username was our payload when we generated the access token
return true; // true if they have a valid REFRESH token
};
@@ -132,7 +134,8 @@ function verifyRefreshToken_WebSocket(ws) {
}
const { user_id, username, roles } = result;
- ws.metadata.memberInfo = { signedIn: true, user_id, username, roles }; // Username was our payload when we generated the access token
+ const parsedRoles = JSON.parse(roles);
+ ws.metadata.memberInfo = { signedIn: true, user_id, username, roles: parsedRoles }; // Username was our payload when we generated the access token
}
export {
diff --git a/src/server/routes/root.js b/src/server/routes/root.js
index a3c934c7e..4e79bc1be 100644
--- a/src/server/routes/root.js
+++ b/src/server/routes/root.js
@@ -11,11 +11,11 @@ const htmlDirectory = path.join(__dirname, "../../../dist/client/views");
/**
* Serves an HTML file based on the requested path and language.
* @param {string} filePath - The relative file path to serve.
- * @param {boolean} [isError=false] - If the file is an error page.
+ * @param {boolean} [localized=true] - If the file is not localized to other languages.
* @returns {Function} Express middleware handler.
*/
-const serveFile = (filePath) => (req, res) => {
- const language = getLanguageToServe(req);
+const serveFile = (filePath, localized = true) => (req, res) => {
+ const language = localized ? getLanguageToServe(req) : "";
const file = path.join(htmlDirectory, language, filePath);
/**
* sendFile() will AUTOMATICALLY check if the file's Last-Modified
@@ -39,6 +39,7 @@ router.get("/login(.html)?", serveFile("login.html"));
router.get("/createaccount(.html)?", serveFile("createaccount.html"));
router.get("/termsofservice(.html)?", serveFile("termsofservice.html"));
router.get("/member(.html)?/:member", serveFile("member.html"));
+router.get("/admin(.html)?", serveFile("admin.html", false));
// Error pages
router.get("/400(.html)?", serveFile("errors/400.html", true));
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 000000000..b6ff37935
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,19 @@
+import { Request } from "express";
+
+interface CustomRequest extends Request {
+ memberInfo: MemberInfo
+}
+
+type MemberInfo = {
+ signedIn: true,
+ user_id: number,
+ username: string,
+ roles: string[] | null
+} | {
+ signedIn: false
+}
+
+export type {
+ CustomRequest,
+ MemberInfo
+};
\ No newline at end of file