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