From e1d93cf63ec6d1a3960bf0f91ec510b2b5f1d828 Mon Sep 17 00:00:00 2001 From: Amy Date: Sun, 28 Jan 2024 23:57:20 +0200 Subject: [PATCH] Get channel by ID, get channel messages Co-authoried-by: Hakase --- package.json | 1 - src/classes/permissions/PermissionManager.ts | 6 +- src/index.ts | 5 +- src/routes/v3/auth.ts | 94 ++++++++++---------- src/routes/v3/channel.ts | 33 +++++++ src/routes/v3/channel/messages.ts | 48 ++++++++++ src/routes/v3/user.ts | 48 +++++----- src/routes/v3/user/status.ts | 8 +- src/schemas/loginUserSchema.ts | 2 +- src/schemas/messageSchema.ts | 48 +++++++++- src/types/PermissionType.d.ts | 6 +- src/util/Auth.ts | 19 ++-- src/util/PermissionMiddleware.ts | 7 +- src/util/safeUser.ts | 14 +++ yarn.lock | 5 -- 15 files changed, 241 insertions(+), 103 deletions(-) create mode 100644 src/routes/v3/channel.ts create mode 100644 src/routes/v3/channel/messages.ts create mode 100644 src/util/safeUser.ts diff --git a/package.json b/package.json index 105aa13..0ab0baa 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,6 @@ "express": "^4.18.2", "file-type": "^18.0.0", "form-data": "^4.0.0", - "jose": "^4.10.4", "mongoose": "^8.0.0", "sharp": "^0.32.6", "tail": "^2.2.4", diff --git a/src/classes/permissions/PermissionManager.ts b/src/classes/permissions/PermissionManager.ts index f7fb8a9..49ebb37 100755 --- a/src/classes/permissions/PermissionManager.ts +++ b/src/classes/permissions/PermissionManager.ts @@ -40,9 +40,9 @@ export default class PermissionManager { r("EDIT_CHANNEL_NAME", "deny", "Edit channel name", ["READ_CHANNEL"]); r("EDIT_CHANNEL", "deny", "Edit channel information", ["EDIT_CHANNEL_DESCRIPTION", "EDIT_CHANNEL_NAME"]); r("DELETE_CHANNEL", "deny", "Delete a channel", ["EDIT_CHANNEL"]); - r("CREATE_CHANNEL", "deny", "Create channels", [], ["quark"]); - r("CHANNEL_MANAGER", "deny", "Manage channels. Grants all channel management permissions.", ["EDIT_CHANNEL", "DELETE_CHANNEL", "CREATE_CHANNEL"]); - r("CHANNEL_ADMIN", "deny", "Manage channels and messages. Grants Channel Manager and Message Admin permissions.", ["CHANNEL_MANAGER", "MESSAGE_ADMIN"]); + r("CREATE_CHANNEL", "deny", "Create channel", [], ["quark"]); + r("CHANNEL_MANAGER", "deny", "Manage channel. Grants all channel management permissions.", ["EDIT_CHANNEL", "DELETE_CHANNEL", "CREATE_CHANNEL"]); + r("CHANNEL_ADMIN", "deny", "Manage channel and messages. Grants Channel Manager and Message Admin permissions.", ["CHANNEL_MANAGER", "MESSAGE_ADMIN"]); r("CREATE_EMOTE", "deny", "Add new emotes", [], ["quark"]); r("EDIT_EMOTE", "deny", "Edit emotes", [], ["quark"]); diff --git a/src/index.ts b/src/index.ts index 5d20bf7..b45776b 100755 --- a/src/index.ts +++ b/src/index.ts @@ -60,14 +60,15 @@ app.use("/", express.static("public")); import authv3 from './routes/v3/auth.js'; import userv3 from './routes/v3/user.js'; +import channelv3 from './routes/v3/channel.js' app.use("/v3/auth", authv3); app.use("/v3/user", userv3); +app.use("/v3/channel", channelv3); +app.use("/v3/channel", channelv3); import home from './routes/home.js'; app.use("/", home) - - app.all("*", (req, res) => { res.reply(new NotFoundReply("Endpoint not found")); }) diff --git a/src/routes/v3/auth.ts b/src/routes/v3/auth.ts index 223f97b..b9321c2 100644 --- a/src/routes/v3/auth.ts +++ b/src/routes/v3/auth.ts @@ -1,13 +1,8 @@ import express from 'express'; import InvalidReplyMessage from "../../classes/reply/InvalidReplyMessage.js"; import ServerErrorReply from "../../classes/reply/ServerErrorReply.js"; -import NotFoundReply from "../../classes/reply/NotFoundReply.js"; -import crypto from 'crypto'; import Reply from "../../classes/reply/Reply.js"; -import * as jose from "jose"; -import {getNick} from "../../util/getNickname.js"; import Auth from "../../util/Auth.js"; -import {networkInformation} from "../../index.js"; import RequiredProperties from "../../util/RequiredProperties.js"; import Database from "../../db.js"; import BadRequestReply from "../../classes/reply/BadRequestReply.js"; @@ -37,28 +32,33 @@ router.post("/register", RequiredProperties([ maxLength: 64 } ]), async (req, res) => { - if (await database.LoginUsers.findOne({email: req.body.email})) return res.reply(new InvalidReplyMessage("A user with this email already exists")); - // FIXME Some limitations please? Email verification at least?? - let pwd = encryptPassword(req.body.password); - let user = new database.LoginUsers({ - _id: new Types.ObjectId(), - email: req.body.email, - username: req.body.username.trim(), - passwordHash: pwd.hashedPassword, - salt: pwd.salt - }) - await user.save(); - let token = new database.Token({ - access: new AccessToken(new Date(Date.now() + 1000 * 60 * 60 * 8)), // 8 hours - refresh: new RefreshToken(), - user: user._id - }) - await token.save() - return res.reply(new Reply(201, true, { - message: "Account created", - access_token: token.access, - refresh_token: token.refresh - })) + try { + if (await database.LoginUsers.findOne({email: req.body.email})) return res.reply(new InvalidReplyMessage("A user with this email already exists")); + // FIXME Some limitations please? Email verification at least?? + let pwd = encryptPassword(req.body.password); + let user = new database.LoginUsers({ + _id: new Types.ObjectId(), + email: req.body.email, + username: req.body.username.trim(), + passwordHash: pwd.hashedPassword, + salt: pwd.salt + }) + await user.save(); + let token = new database.Token({ + access: new AccessToken(new Date(Date.now() + 1000 * 60 * 60 * 8)), // 8 hours + refresh: new RefreshToken(), + user: user._id + }) + await token.save() + return res.reply(new Reply(201, true, { + message: "Account created", + access_token: token.access, + refresh_token: token.refresh + })) + } catch (e) { + console.error(e); + res.reply(new ServerErrorReply()) + } }) router.post("/token", RequiredProperties([ @@ -72,26 +72,30 @@ router.post("/token", RequiredProperties([ type: "string" } ]), async (req, res) => { - let user = await database.LoginUsers.findOne({email: req.body.email.trim()}) - if (!user) return res.reply(new BadRequestReply("Invalid email and password combination.")) - if (!user.passwordHash || !user.salt) return res.reply(new ServerErrorReply()) - if (!checkPassword(req.body.password, user.passwordHash, user.salt)) return res.reply(new BadRequestReply("Invalid email and password combination.")) - + try { + let user = await database.LoginUsers.findOne({email: req.body.email.trim()}) + if (!user) return res.reply(new BadRequestReply("Invalid email and password combination.")) + if (!user.passwordHash || !user.salt) return res.reply(new ServerErrorReply()) + if (!checkPassword(req.body.password, user.passwordHash, user.salt)) return res.reply(new BadRequestReply("Invalid email and password combination.")) - // Congratulations! The password is correct. - let token = new database.Token({ - access: new AccessToken(new Date(Date.now() + 1000 * 60 * 60 * 8)), // 8 hours - refresh: new RefreshToken(), - user: user._id - }) - await token.save(); + // Congratulations! The password is correct. + let token = new database.Token({ + access: new AccessToken(new Date(Date.now() + 1000 * 60 * 60 * 8)), // 8 hours + refresh: new RefreshToken(), + user: user._id + }) + await token.save(); - return res.json(new Reply(200, true, { - message: "Here are your tokens!", - token_type: "Bearer", - access_token: token.access, - refresh_token: token.refresh - })) + return res.json(new Reply(200, true, { + message: "Here are your tokens!", + token_type: "Bearer", + access_token: token.access, + refresh_token: token.refresh + })) + } catch (e) { + console.error(e); + res.reply(new ServerErrorReply()) + } }) export default router; diff --git a/src/routes/v3/channel.ts b/src/routes/v3/channel.ts new file mode 100644 index 0000000..263a977 --- /dev/null +++ b/src/routes/v3/channel.ts @@ -0,0 +1,33 @@ +import express from 'express'; +import {Auth} from "./auth.js"; +import Database from "../../db.js"; +import P from "../../util/PermissionMiddleware.js"; +import {isValidObjectId} from "mongoose"; +import InvalidReplyMessage from "../../classes/reply/InvalidReplyMessage.js"; +import NotFoundReply from "../../classes/reply/NotFoundReply.js"; +import Reply from "../../classes/reply/Reply.js"; +import ServerErrorReply from "../../classes/reply/ServerErrorReply.js"; +import messages from "./channel/messages.js"; + + +const router = express.Router(); + +const database = new Database() + +router.use(Auth); + +router.use("/:channelId/messages", messages) + +router.get("/:channelId", P("READ_CHANNEL", "channel"), async (req, res) => { + try { + // Validity and existence are checked by permission manager + let channel = await database.Channels.findOne({ _id: req.params.channelId }) + res.reply(new Reply(200, true, { message: "Here is the channel", channel })) + } catch (e) { + console.error(e); + res.reply(new ServerErrorReply()) + } +}) + + +export default router; diff --git a/src/routes/v3/channel/messages.ts b/src/routes/v3/channel/messages.ts new file mode 100644 index 0000000..af4eaca --- /dev/null +++ b/src/routes/v3/channel/messages.ts @@ -0,0 +1,48 @@ +import express from 'express'; +import Database from "../../../db.js"; +import {Auth} from "../auth.js"; +import Reply from "../../../classes/reply/Reply.js"; +import P from "../../../util/PermissionMiddleware.js"; +import ServerErrorReply from "../../../classes/reply/ServerErrorReply.js"; +import InvalidReplyMessage from "../../../classes/reply/InvalidReplyMessage.js"; +import {Types} from "mongoose"; +import messageSchema from "../../../schemas/messageSchema.js"; +import {getNickBulk} from "../../../util/getNickname.js"; + +const router = express.Router({ mergeParams: true }); + +const database = new Database() + +router.use(Auth); + +router.get("/", P(["READ_CHANNEL_HISTORY", "READ_CHANNEL"], "channel"), async (req, res) => { + try { + // Optionally client can provide a timestamp to get messages before or after + let startTimestamp = Infinity; + let endTimestamp = 0; + if (req.query.startTimestamp) startTimestamp = Number(req.query.startTimestamp) + if (req.query.endTimestamp) endTimestamp = Number(req.query.endTimestamp) + if (isNaN(startTimestamp)) return res.status(400).json(new InvalidReplyMessage("Invalid startTimestamp")); + if (isNaN(endTimestamp)) return res.status(400).json(new InvalidReplyMessage("Invalid endTimestamp")); + + let quark = await database.Quarks.findOne({ channels: new Types.ObjectId(req.params.channelId) }); + + // Find messages in specified range, if endTimestamp is specified get messages after that timestamp, otherwise get messages before that timestamp + // The naming is a bit backwards, isn't it? + let messages = await database.Messages.find({ channelId: req.params.channelId, timestamp: { $lt: startTimestamp, $gt: endTimestamp } }).sort({ timestamp: endTimestamp === 0 ? -1 : 1 }).limit(50) + // Replace author usernames with nicknames in this quark + let authorIds = messages.map(message => message.authorId); + let nicknames = await getNickBulk(authorIds, quark._id) + messages = messages.map(message => { + message.author.username = nicknames.find(nick => nick.userId.equals(message.authorId)).nickname || message.author.username; + return message; + }) + res.reply(new Reply(200, true, {message: "Here are the messages", messages})); + } catch (e) { + console.error(e); + return res.status(500).json(new ServerErrorReply()); + } +}) + + +export default router; diff --git a/src/routes/v3/user.ts b/src/routes/v3/user.ts index 6d0457b..72e065d 100644 --- a/src/routes/v3/user.ts +++ b/src/routes/v3/user.ts @@ -5,7 +5,8 @@ import {isValidObjectId} from "mongoose"; import InvalidReplyMessage from "../../classes/reply/InvalidReplyMessage.js"; import Database from "../../db.js"; import NotFoundReply from "../../classes/reply/NotFoundReply.js"; - +import ServerErrorReply from "../../classes/reply/ServerErrorReply.js"; +import {safeUser} from "../../util/safeUser.js"; const router = express.Router(); @@ -14,32 +15,31 @@ const database = new Database() router.use(Auth); router.get("/me", (req, res) => { - res.reply(new Reply(200, true, { - message: "You are signed in. Here is your data", - jwtData: res.locals.user - })) + try { + res.reply(new Reply(200, true, { + message: "You are signed in. Here is your data", + jwtData: res.locals.user + })) + } catch (e) { + console.error(e) + return res.reply(new ServerErrorReply()) + } }) router.get("/:userId", async (req, res) => { - if (!isValidObjectId(req.params.userId)) return res.reply(new InvalidReplyMessage("Invalid user")); - let user = await database.LoginUsers.findOne({_id: req.params.userId}); - if (!user) return res.reply(new NotFoundReply("No such user")) - - res.reply(new Reply(200, true, { - message: "User found", - user: safeUser(user) - })) -}) - -export function safeUser(user) { - return { - username: user.username, - _id: user._id, - admin: user.admin, - isBot: user.isBot, - avatarUri: user.avatarUri, - status: user.status + try { + if (!isValidObjectId(req.params.userId)) return res.reply(new InvalidReplyMessage("Invalid user")); + let user = await database.LoginUsers.findOne({_id: req.params.userId}); + if (!user) return res.reply(new NotFoundReply("No such user")) + + res.reply(new Reply(200, true, { + message: "User found", + user: safeUser(user) + })) + } catch (e) { + console.error(e) + return res.reply(new ServerErrorReply()) } -} +}) export default router; diff --git a/src/routes/v3/user/status.ts b/src/routes/v3/user/status.ts index 1efb994..e8124d4 100644 --- a/src/routes/v3/user/status.ts +++ b/src/routes/v3/user/status.ts @@ -3,12 +3,12 @@ import networkInformation from "../../../networkInformation.js"; /** * Turn status into a status without _id and userId, replace images with links to real images :tm: * @param status + * @param {null|ObjectId} userId */ -export function plainStatus(status) { +export function plainStatus(status, userId = null) { if (!status) return undefined; - status = status.toJSON(); - status.primaryImage = `${networkInformation.baseUrl}/v3/user/${status.userId}/status/primaryImage` - status.secondaryImage = `${networkInformation.baseUrl}/v3/user/${status.userId}/status/secondaryImage` + status.primaryImage = `${networkInformation.baseUrl}/v3/user/${userId || status.userId}/status/primaryImage` + status.secondaryImage = `${networkInformation.baseUrl}/v3/user/${userId || status.userId}/status/secondaryImage` status._id = undefined; status.userId = undefined; status.__v = undefined; diff --git a/src/schemas/loginUserSchema.ts b/src/schemas/loginUserSchema.ts index a5edf70..4b05cc7 100755 --- a/src/schemas/loginUserSchema.ts +++ b/src/schemas/loginUserSchema.ts @@ -54,7 +54,7 @@ const findHook = function (this: any, next: () => void) { _recursed: true }, transform: doc => { - if (doc.type) return plainStatus(doc) // If the document has a "type" property it is probably a status document + if (doc?.type) return plainStatus(doc) // If the document has a "type" property it is probably a status document return doc } }); diff --git a/src/schemas/messageSchema.ts b/src/schemas/messageSchema.ts index 5f44827..a38d0a9 100755 --- a/src/schemas/messageSchema.ts +++ b/src/schemas/messageSchema.ts @@ -1,6 +1,8 @@ import * as Mongoose from "mongoose"; +import {safeUser} from "../util/safeUser.js"; +import {plainStatus} from "../routes/v3/user/status.js"; -export default new Mongoose.Schema({ +const schema = new Mongoose.Schema({ _id: Mongoose.Types.ObjectId, authorId: Mongoose.Types.ObjectId, content: String, @@ -10,4 +12,48 @@ export default new Mongoose.Schema({ edited: { type: Boolean, default: false }, attachments: [String], specialAttributes: [Object] +}, { + toObject: { + virtuals: true + }, + toJSON: { + virtuals: true, + transform: function (doc, ret) { + delete ret.__v; + return ret; + } + }, }); +schema.virtual("author", { + ref: "user", + localField: "authorId", + foreignField: "_id", + justOne: true +}) +const findHook = function (this: any, next: () => void) { + if (this.options._recursed) { + return next(); + } + this.populate({ + path: "author", + options: { + _recursed: true + }, + populate: {path: "avatarDoc"}, + transform: doc => { + // If the document has a "passwordHash" property it is probably a user document + if (doc?.passwordHash) { + // Clean up the status object in the user if present + // (will never be present unless added to populate path) + if (doc?.status) doc.status = plainStatus(doc.status, doc._id) + return safeUser(doc) + } + return doc + } + }); + next() +} +schema.pre('findOne', findHook); +schema.pre('find', findHook); + +export default schema; \ No newline at end of file diff --git a/src/types/PermissionType.d.ts b/src/types/PermissionType.d.ts index 88e95de..1d757dd 100755 --- a/src/types/PermissionType.d.ts +++ b/src/types/PermissionType.d.ts @@ -11,9 +11,9 @@ "EDIT_CHANNEL_NAME" | // Edit channel name. Requires READ_CHANNEL "EDIT_CHANNEL" | // Edit channel information. Requires EDIT_CHANNEL_DESCRIPTION, EDIT_CHANNEL_NAME "DELETE_CHANNEL" | // Delete a channel. Requires EDIT_CHANNEL - "CREATE_CHANNEL" | // Create channels - "CHANNEL_MANAGER" | // Edit, delete, create channels. Requires EDIT_CHANNEL, DELETE_CHANNEL, CREATE_CHANNEL - "CHANNEL_ADMIN" | // Edit, delete, create channels. Requires CHANNEL_MANAGER, MESSAGE_ADMIN + "CREATE_CHANNEL" | // Create channel + "CHANNEL_MANAGER" | // Edit, delete, create channel. Requires EDIT_CHANNEL, DELETE_CHANNEL, CREATE_CHANNEL + "CHANNEL_ADMIN" | // Edit, delete, create channel. Requires CHANNEL_MANAGER, MESSAGE_ADMIN /* "BAN_USER_CHANNEL" | // Ban users from a channel. Requires READ_CHANNEL diff --git a/src/util/Auth.ts b/src/util/Auth.ts index 4d446ef..6005ed8 100644 --- a/src/util/Auth.ts +++ b/src/util/Auth.ts @@ -1,15 +1,10 @@ import UnauthorizedReply from "../classes/reply/UnauthorizedReply.js"; -import * as jose from "jose"; +import Database from "../db.js"; const database = new Database() import ServerErrorReply from "../classes/reply/ServerErrorReply.js"; - -import Database from "../db.js"; -import networkInformation from "../networkInformation.js"; import Reply from "../classes/reply/Reply.js"; import Token from "../classes/Token/Token.js"; -const secret = new TextEncoder().encode(process.env.JWT_SECRET); - export default async function Auth(req, res, next) { if (!req.headers.authorization) return res.reply(new UnauthorizedReply("Missing Authorization header with Bearer token")) if (!req.headers.authorization.startsWith("Bearer")) return res.reply(new UnauthorizedReply("Authorization header does not contain Bearer token")) @@ -41,11 +36,13 @@ export default async function Auth(req, res, next) { export async function WsAuth(jwt) : Promise { try { - const { payload } = await jose.jwtVerify(jwt, secret, { - issuer: networkInformation.baseUrl, - audience: 'Lightquark-client', - }) - return payload; + // const { payload } = await jose.jwtVerify(jwt, secret, { + // issuer: networkInformation.baseUrl, + // audience: 'Lightquark-client', + // }) + // return payload; + return false + // FIXME } catch (e : any) { if (["ERR_JWT_CLAIM_VALIDATION_FAILED", "ERR_JWS_INVALID", "ERR_JWS_SIGNATURE_VERIFICATION_FAILED", "ERR_JWT_EXPIRED"].includes(e.code)) { return false; diff --git a/src/util/PermissionMiddleware.ts b/src/util/PermissionMiddleware.ts index 67e25d0..110bee1 100755 --- a/src/util/PermissionMiddleware.ts +++ b/src/util/PermissionMiddleware.ts @@ -12,7 +12,7 @@ import ServerErrorReply from "../classes/reply/ServerErrorReply.js"; * * Usage: * ```js - * app.get("/channels/:id", P("ADMIN", "channel"), (req, res) => { ... }); + * app.get("/channel/:id", P("ADMIN", "channel"), (req, res) => { ... }); * ``` * * @param permissions {string|string[]} The permission(s) to check for @@ -21,11 +21,12 @@ import ServerErrorReply from "../classes/reply/ServerErrorReply.js"; */ export default function P(permissions : (PermissionType|PermissionType[]), scope: ("channel"|"quark")) : Function { return async (req, res, next) => { - if (!isValidObjectId(req.params.id)) { + let id = req.params.quarkId || req.params.channelId + if (!isValidObjectId(id)) { return res.reply(new InvalidReplyMessage(`Invalid ${scope} ID`)); } try { - let check = await checkPermitted(permissions, { scopeType: scope, scopeId: req.params.id }, res.locals.user._id); + let check = await checkPermitted(permissions, { scopeType: scope, scopeId: id }, res.locals.user._id); if (!check.permitted) { return res.reply(new ForbiddenReply(`Missing permissions ${check.missingPermissions.join(", ")}`)); } diff --git a/src/util/safeUser.ts b/src/util/safeUser.ts new file mode 100644 index 0000000..b9b18bb --- /dev/null +++ b/src/util/safeUser.ts @@ -0,0 +1,14 @@ +/** + * Feed in raw user document, get out user object safe to send to client (email and such removed) + * @param user + */ +export function safeUser(user) { + return { + username: user.username, + _id: user._id, + admin: user.admin, + isBot: user.isBot, + avatarUri: user.avatarUri, + status: user.status + } +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 91b76e2..b1b25c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1046,11 +1046,6 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== -jose@^4.10.4: - version "4.15.4" - resolved "https://registry.yarnpkg.com/jose/-/jose-4.15.4.tgz#02a9a763803e3872cf55f29ecef0dfdcc218cc03" - integrity sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ== - kareem@2.5.1: version "2.5.1" resolved "https://registry.yarnpkg.com/kareem/-/kareem-2.5.1.tgz#7b8203e11819a8e77a34b3517d3ead206764d15d"