diff --git a/backend/index.js b/backend/index.js index 17fa9d6..4a264a0 100644 --- a/backend/index.js +++ b/backend/index.js @@ -23,6 +23,7 @@ app.use("/usercontent", require("./routes/usercontent.js")) app.use("/api/v1/usercontent", require("./routes/usercontent.js")) app.use("/api/v1/mediaproxy", require("./routes/mediaproxy.js")) app.use("/api/v1/associations", require("./routes/associations.js")) +app.use("/api/v1/polls", require("./routes/polls.js")) app.get("/api/v1/state", async (req, res) => { res.json({ release: req.app.locals.config.release, diff --git a/backend/lib/errorHandler.js b/backend/lib/errorHandler.js index 5e7609d..28c5d05 100644 --- a/backend/lib/errorHandler.js +++ b/backend/lib/errorHandler.js @@ -1,5 +1,6 @@ let { Sequelize } = require("../models") let Errors = require("./errors") +const multer = require("multer") module.exports = function (err, req, res, next) { if (err instanceof Sequelize.ValidationError) { @@ -10,6 +11,14 @@ module.exports = function (err, req, res, next) { }) } else { console.error(err) + if (err instanceof multer.MulterError) { + if (err.code === "LIMIT_FILE_SIZE") { + return res.status(400).json({ + errors: [Errors.fileTooLarge] + }) + } + } + res.status(500).json({ errors: [Errors.unknown] }) diff --git a/backend/lib/errors.js b/backend/lib/errors.js index 1495f4d..5f472ba 100644 --- a/backend/lib/errors.js +++ b/backend/lib/errors.js @@ -29,7 +29,10 @@ let Errors = { "The file you are trying to upload is not a valid file type.", 400 ], - fileTooLarge: ["The file you are trying to upload is too large.", 400], + fileTooLarge: [ + "The file you are trying to upload is too large. Maximum 50MB.", + 400 + ], invalidPassword: [ "Your password must be at least 8 characters in length.", 400 diff --git a/backend/migrations/20220814053827-polls.js b/backend/migrations/20220814053827-polls.js new file mode 100644 index 0000000..4fb5a65 --- /dev/null +++ b/backend/migrations/20220814053827-polls.js @@ -0,0 +1,68 @@ +"use strict" + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable("Polls", { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.BIGINT + }, + messageId: { + type: Sequelize.BIGINT + }, + userId: { + type: Sequelize.BIGINT + }, + title: { + type: Sequelize.STRING + }, + description: { + type: Sequelize.TEXT + }, + options: { + type: Sequelize.JSON, + defaultValue: [] + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }) + await queryInterface.addIndex("Polls", ["messageId"]) + await queryInterface.addIndex("Polls", ["userId"]) + await queryInterface.createTable("PollAnswers", { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.BIGINT + }, + pollId: { + type: Sequelize.BIGINT + }, + userId: { + type: Sequelize.BIGINT + }, + answer: { + type: Sequelize.STRING + } + }) + await queryInterface.addIndex("PollAnswers", ["pollId"]) + await queryInterface.addIndex("PollAnswers", ["userId"]) + }, + + async down(queryInterface, Sequelize) { + /** + * Add reverting commands here. + * + * Example: + * await queryInterface.dropTable('users'); + */ + } +} diff --git a/backend/migrations/20220814060016-pollsDates.js b/backend/migrations/20220814060016-pollsDates.js new file mode 100644 index 0000000..a01ac0a --- /dev/null +++ b/backend/migrations/20220814060016-pollsDates.js @@ -0,0 +1,23 @@ +"use strict" + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn("PollAnswers", "createdAt", { + type: Sequelize.DATE, + allowNull: false + }) + await queryInterface.addColumn("PollAnswers", "updatedAt", { + type: Sequelize.DATE, + allowNull: false + }) + }, + + async down(queryInterface, Sequelize) { + /** + * Add reverting commands here. + * + * Example: + * await queryInterface.dropTable('users'); + */ + } +} diff --git a/backend/models/chats.js b/backend/models/chats.js index c283ab7..8c673c5 100644 --- a/backend/models/chats.js +++ b/backend/models/chats.js @@ -13,6 +13,10 @@ module.exports = (sequelize, DataTypes) => { foreignKey: "userId", as: "user" }) + Chat.hasOne(models.ChatAssociation, { + foreignKey: "chatId", + as: "association" + }) Chat.hasMany(models.ChatAssociation, { foreignKey: "chatId", as: "associations" diff --git a/backend/models/messages.js b/backend/models/messages.js index f082819..73ae669 100644 --- a/backend/models/messages.js +++ b/backend/models/messages.js @@ -26,6 +26,10 @@ module.exports = (sequelize, DataTypes) => { as: "readReceipts", foreignKey: "lastRead" }) + Message.hasOne(models.Poll, { + as: "poll", + foreignKey: "messageId" + }) } } Message.init( diff --git a/backend/models/pollanswers.js b/backend/models/pollanswers.js new file mode 100644 index 0000000..2b57186 --- /dev/null +++ b/backend/models/pollanswers.js @@ -0,0 +1,46 @@ +"use strict" +const { Model } = require("sequelize") +module.exports = (sequelize, DataTypes) => { + class PollAnswer extends Model { + /** + * Helper method for defining associations. + * This method is not a part of Sequelize lifecycle. + * The `models/index` file will call this method automatically. + */ + // eslint-disable-next-line no-unused-vars + static associate(models) { + PollAnswer.belongsTo(models.Poll, { + as: "poll" + }) + PollAnswer.belongsTo(models.User, { + as: "user" + }) + } + } + PollAnswer.init( + { + pollId: { + type: DataTypes.BIGINT + }, + userId: { + type: DataTypes.BIGINT + }, + answer: { + type: DataTypes.STRING + }, + createdAt: { + allowNull: false, + type: DataTypes.DATE + }, + updatedAt: { + allowNull: false, + type: DataTypes.DATE + } + }, + { + sequelize, + modelName: "PollAnswer" + } + ) + return PollAnswer +} diff --git a/backend/models/polls.js b/backend/models/polls.js new file mode 100644 index 0000000..dd49a1d --- /dev/null +++ b/backend/models/polls.js @@ -0,0 +1,54 @@ +"use strict" +const { Model } = require("sequelize") +module.exports = (sequelize, DataTypes) => { + class Poll extends Model { + /** + * Helper method for defining associations. + * This method is not a part of Sequelize lifecycle. + * The `models/index` file will call this method automatically. + */ + // eslint-disable-next-line no-unused-vars + static associate(models) { + Poll.belongsTo(models.Message, { + as: "message" + }) + Poll.hasMany(models.PollAnswer, { + as: "answers", + foreignKey: "pollId" + }) + } + } + Poll.init( + { + messageId: { + type: DataTypes.BIGINT + }, + userId: { + type: DataTypes.BIGINT + }, + title: { + type: DataTypes.STRING + }, + description: { + type: DataTypes.TEXT + }, + options: { + type: DataTypes.JSON, + defaultValue: [] + }, + createdAt: { + allowNull: false, + type: DataTypes.DATE + }, + updatedAt: { + allowNull: false, + type: DataTypes.DATE + } + }, + { + sequelize, + modelName: "Poll" + } + ) + return Poll +} diff --git a/backend/package.json b/backend/package.json index a2b690c..33a0669 100644 --- a/backend/package.json +++ b/backend/package.json @@ -42,7 +42,8 @@ "sqlite3": "^5.0.10", "ua-parser-js": "^1.0.2", "uglify-js": "^2.6.0", - "umzug": "^3.1.1" + "umzug": "^3.1.1", + "uuid": "^8.3.2" }, "resolutions": { "constantinople": "^3.1.1", diff --git a/backend/routes/communications.js b/backend/routes/communications.js index 0a71928..5ad475c 100644 --- a/backend/routes/communications.js +++ b/backend/routes/communications.js @@ -10,7 +10,9 @@ const { Message, Friend, Attachment, - Nickname + Nickname, + Poll, + PollAnswer } = require("../models") const { Op } = require("sequelize") const rateLimit = require("express-rate-limit") @@ -19,6 +21,7 @@ const cryptoRandomString = require("crypto-random-string") const path = require("path") const fs = require("fs") const FileType = require("file-type") +const { v4: uuidv4 } = require("uuid") const limiter = rateLimit({ windowMs: 10 * 1000, @@ -43,7 +46,7 @@ const storage = multer.diskStorage({ const upload = multer({ storage: storage, - limits: { fileSize: 12 * 1024 * 1024 } + limits: { fileSize: 50 * 1024 * 1024 } }) const resolveEmbeds = require("../lib/resolveEmbeds.js") @@ -93,7 +96,6 @@ async function createMessage(req, type, content, association, userId) { attributes: [ "username", "name", - "avatar", "id", "createdAt", @@ -112,7 +114,6 @@ async function createMessage(req, type, content, association, userId) { attributes: [ "username", "name", - "avatar", "id", "createdAt", @@ -127,7 +128,6 @@ async function createMessage(req, type, content, association, userId) { attributes: [ "username", "name", - "avatar", "id", "createdAt", @@ -1369,11 +1369,61 @@ router.post("/:id/message", auth, limiter, async (req, res, next) => { embeds, replyId: reply.id }) + if (req.body.embeds?.length && !req.user.bot) { + if (req.body.embeds.length > 1) { + throw Errors.invalidParameter("embeds", "Maximum length is 1") + } + for (const embed of req.body.embeds) { + if (embed.type === "poll-v1") { + if (!embed.title) { + throw Errors.invalidParameter("embeds", "title is required") + } + if (embed.options?.length < 2) { + throw Errors.invalidParameter("embeds", "options is required") + } + if (embed.options.length > 4) { + throw Errors.invalidParameter( + "embeds", + "Maximum length is 4 for options" + ) + } + await Poll.create({ + title: embed.title, + description: embed.description, + userId: req.user.id, + messageId: message.id, + options: embed.options.map((option) => { + return { + value: option.toString(), + id: uuidv4() + } + }) + }) + } + } + } const messageLookup = await Message.findOne({ where: { id: message.id }, include: [ + { + model: Poll, + as: "poll", + include: [ + { + model: PollAnswer, + as: "answers", + include: [ + { + model: User, + as: "user", + attributes: ["username", "avatar", "id"] + } + ] + } + ] + }, { model: ChatAssociation, as: "readReceipts", @@ -1611,12 +1661,10 @@ router.get("/:id/messages", auth, async (req, res, next) => { attributes: [ "username", "name", - "avatar", "id", "createdAt", "updatedAt", - "admin", "bot" ] @@ -1653,6 +1701,24 @@ router.get("/:id/messages", auth, async (req, res, next) => { ...or }, include: [ + { + model: Poll, + as: "poll", + include: [ + { + model: PollAnswer, + as: "answers", + include: [ + { + model: User, + as: "user", + required: false, + attributes: ["username", "avatar", "id"] + } + ] + } + ] + }, { model: ChatAssociation, as: "readReceipts", diff --git a/backend/routes/mediaproxy.js b/backend/routes/mediaproxy.js index 5f5171a..cc9d5fd 100644 --- a/backend/routes/mediaproxy.js +++ b/backend/routes/mediaproxy.js @@ -32,6 +32,9 @@ router.get("/:mid/:index/:securityToken", async (req, res, next) => { res.setHeader("cache-control", "public, max-age=604800") res.end(response.data, "binary") }) + .catch(() => { + res.status(404).end() + }) } catch (e) { next(e) } @@ -65,6 +68,9 @@ router.get("/:mid/:index/:securityToken.:extension", async (req, res, next) => { res.setHeader("cache-control", "public, max-age=604800") res.end(response.data, "binary") }) + .catch(() => { + res.status(404).end() + }) } catch (e) { next(e) } diff --git a/backend/routes/polls.js b/backend/routes/polls.js new file mode 100644 index 0000000..612ad3a --- /dev/null +++ b/backend/routes/polls.js @@ -0,0 +1,112 @@ +const express = require("express") +const router = express.Router() +const Errors = require("../lib/errors.js") +const { + User, + Chat, + ChatAssociation, + Poll, + PollAnswer, + Message +} = require("../models") +const auth = require("../lib/authorize") +const rateLimit = require("express-rate-limit") + +const limiter = rateLimit({ + windowMs: 20 * 1000, + max: 8, + message: Errors.rateLimit, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req, res) => req.user?.id || req.ip +}) + +router.post("/:id/vote", auth, limiter, async (req, res, next) => { + try { + const io = req.app.get("io") + const poll = await Poll.findOne({ + where: { + id: req.params.id + }, + include: [ + { + model: Message, + as: "message", + include: [ + { + model: Chat, + as: "chat", + include: [ + { + model: ChatAssociation, + as: "association", + where: { + userId: req.user.id + } + }, + { + model: ChatAssociation, + as: "associations" + } + ] + } + ] + } + ] + }) + if (!poll) throw Errors.invalidParameter("poll id") + let answer = await PollAnswer.findOne({ + where: { + pollId: poll.id, + userId: req.user.id + } + }) + const validate = poll.options.find( + (option) => option.id === req.body.option + ) + if (!validate) throw Errors.invalidParameter("option") + if (answer) { + if (answer?.answer === req.body.option) { + for (const association of poll.message.chat.associations) { + io.to(association.userId).emit(`pollAnswer-${poll.messageId}`, { + poll: poll, + answer: null, + id: answer.id + }) + } + await answer.destroy() + res.sendStatus(204) + return + } + await answer.update({ + answer: req.body.option + }) + for (const association of poll.message.chat.associations) { + io.to(association.userId).emit(`pollAnswer-${poll.messageId}`, { + poll: poll, + answer: answer, + id: answer.id + }) + } + res.sendStatus(204) + } else { + answer = await PollAnswer.create({ + pollId: poll.id, + userId: req.user.id, + answer: req.body.option + }) + for (const association of poll.message.chat.associations) { + io.to(association.userId).emit(`pollAnswer-${poll.messageId}`, { + poll: poll, + answer: answer, + id: answer.id + }) + } + res.sendStatus(204) + } + } catch (e) { + next(e) + } +}) + +module.exports = router diff --git a/frontend/package.json b/frontend/package.json index e22a34a..d7386ef 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "colubrina", - "version": "1.0.22", + "version": "1.0.23", "description": "Simple instant communication.", "private": true, "author": "Troplo ", @@ -50,9 +50,7 @@ "vue-toastification": "^1.7.14", "vue2-ace-editor": "^0.0.15", "vuetify": "^2.6.4", - "vuex": "^3.4.0", - "vue-cli-plugin-electron-builder": "^2.1.1", - "electron-devtools-installer": "^3.1.0" + "vuex": "^3.4.0" }, "devDependencies": { "@babel/plugin-proposal-optional-chaining": "^7.16.7", @@ -75,7 +73,9 @@ "vue-template-compiler": "^2.6.11", "vuetify-loader": "^1.7.0", "webpack-auto-inject-version-next": "1.2.4", - "webpack-bundle-analyzer": "^4.5.0" + "webpack-bundle-analyzer": "^4.5.0", + "vue-cli-plugin-electron-builder": "^2.1.1", + "electron-devtools-installer": "^3.1.0" }, "license": "GPL-3.0", "resolutions": { diff --git a/frontend/src/components/CommsInput.vue b/frontend/src/components/CommsInput.vue index 5bb716c..06c1259 100644 --- a/frontend/src/components/CommsInput.vue +++ b/frontend/src/components/CommsInput.vue @@ -1,5 +1,81 @@ -