diff --git a/backend/api/controllers/quotes.js b/backend/api/controllers/quotes.js new file mode 100644 index 000000000..7e6000831 --- /dev/null +++ b/backend/api/controllers/quotes.js @@ -0,0 +1,40 @@ +const { v4: uuidv4 } = require("uuid"); +const ReportDAO = require("../../dao/report"); +const UserDAO = require("../../dao/user"); +const MonkeyError = require("../../handlers/error"); +const Captcha = require("../../handlers/captcha"); + +class QuotesController { + static async reportQuote(req, res) { + const { uid } = req.decodedToken; + + const user = await UserDAO.getUser(uid); + if (user.cannotReport) { + throw new MonkeyError(403, "You don't have permission to do this."); + } + + const { quoteId, quoteLanguage, reason, comment, captcha } = req.body; + + if (!(await Captcha.verify(captcha))) { + throw new MonkeyError(400, "Captcha check failed."); + } + + const newReport = { + id: uuidv4(), + type: "quote", + timestamp: new Date().getTime(), + uid, + details: { + contentId: `${quoteLanguage}-${quoteId}`, + reason, + comment, + }, + }; + + await ReportDAO.createReport(newReport); + + res.sendStatus(200); + } +} + +module.exports = QuotesController; diff --git a/backend/api/routes/quotes.js b/backend/api/routes/quotes.js index d5e18b984..38fc18d05 100644 --- a/backend/api/routes/quotes.js +++ b/backend/api/routes/quotes.js @@ -3,8 +3,12 @@ const { authenticateRequest } = require("../../middlewares/auth"); const { Router } = require("express"); const NewQuotesController = require("../controllers/new-quotes"); const QuoteRatingsController = require("../controllers/quote-ratings"); +const QuotesController = require("../controllers/quotes"); const RateLimit = require("../../middlewares/rate-limit"); -const { requestValidation } = require("../../middlewares/apiUtils"); +const { + asyncHandlerWrapper, + requestValidation, +} = require("../../middlewares/apiUtils"); const SUPPORTED_QUOTE_LANGUAGES = require("../../constants/quoteLanguages"); const quotesRouter = Router(); @@ -71,11 +75,10 @@ quotesRouter.post( ) .required(), comment: joi.string().allow("").max(250).required(), + captcha: joi.string().required(), }, }), - (req, res) => { - res.sendStatus(200); - } + asyncHandlerWrapper(QuotesController.reportQuote) ); module.exports = quotesRouter; diff --git a/backend/dao/report.js b/backend/dao/report.js new file mode 100644 index 000000000..12a5f026b --- /dev/null +++ b/backend/dao/report.js @@ -0,0 +1,29 @@ +const MonkeyError = require("../handlers/error"); +const { mongoDB } = require("../init/mongodb"); + +const MAX_REPORTS = 100; + +class ReportDAO { + static async createReport(report) { + const reports = await mongoDB().collection("reports").find().toArray(); + + if (reports.length >= MAX_REPORTS) { + throw new MonkeyError( + 503, + "Reports are not being accepted at this time. Please try again later." + ); + } + + const reportAlreadyExists = reports.find((existingReport) => { + return existingReport.details.contentId === report.details.contentId; + }); + + if (reportAlreadyExists) { + throw new MonkeyError(409, "A report for this content already exists."); + } + + await mongoDB().collection("reports").insertOne(report); + } +} + +module.exports = ReportDAO; diff --git a/backend/middlewares/apiUtils.js b/backend/middlewares/apiUtils.js index f8aaaa91d..c4f0308e1 100644 --- a/backend/middlewares/apiUtils.js +++ b/backend/middlewares/apiUtils.js @@ -1,11 +1,33 @@ const joi = require("joi"); const MonkeyError = require("../handlers/error"); +/** + * This utility serves as an alternative to wrapping express handlers with try/catch statements. + * Any routes that use an async handler function should wrap the handler with this function. + * Without this, any errors thrown will not be caught by the error handling middleware, and + * the app will hang! + */ +function asyncHandlerWrapper(handler) { + return async (req, res, next) => { + try { + const handlerData = await handler(req, res); + if (!res.headersSent && handlerData) { + res.json(handlerData); + } + next(); + } catch (error) { + next(error); + } + }; +} + function requestValidation(validationSchema) { return (req, res, next) => { - // In dev environments, as an alternative to token authentication, - // you can pass the authentication middleware by having a user id in the body. - // Inject the user id into the schema so that validation will not fail. + /** + * In dev environments, as an alternative to token authentication, + * you can pass the authentication middleware by having a user id in the body. + * Inject the user id into the schema so that validation will not fail. + */ if (process.env.MODE === "dev") { validationSchema.body = { uid: joi.any(), @@ -28,5 +50,6 @@ function requestValidation(validationSchema) { } module.exports = { + asyncHandlerWrapper, requestValidation, }; diff --git a/gulpfile.js b/gulpfile.js index c1ce6e199..5b67b1fa9 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -142,6 +142,7 @@ const refactoredSrc = [ "./src/js/popups/quote-search-popup.js", "./src/js/popups/quote-submit-popup.js", "./src/js/popups/quote-approve-popup.js", + "./src/js/popups/quote-report-popup.js", "./src/js/popups/rate-quote-popup.js", "./src/js/popups/version-popup.js", "./src/js/popups/support-popup.js", diff --git a/src/js/input-controller.js b/src/js/input-controller.js index 702feb239..85181c477 100644 --- a/src/js/input-controller.js +++ b/src/js/input-controller.js @@ -666,6 +666,7 @@ $(document).keydown((event) => { !$("#quoteSearchPopupWrapper").hasClass("hidden") || !$("#quoteSubmitPopupWrapper").hasClass("hidden") || !$("#quoteApprovePopupWrapper").hasClass("hidden") || + !$("#quoteReportPopupWrapper").hasClass("hidden") || !$("#wordFilterPopupWrapper").hasClass("hidden"); const allowTyping = diff --git a/src/js/popups/quote-report-popup.js b/src/js/popups/quote-report-popup.js new file mode 100644 index 000000000..d17902ede --- /dev/null +++ b/src/js/popups/quote-report-popup.js @@ -0,0 +1,138 @@ +import * as Misc from "./misc"; +import * as Notifications from "./notifications"; +import axiosInstance from "./axios-instance"; +import Config from "./config"; +import * as Loader from "./loader"; + +const CAPTCHA_ID = 1; + +const state = { + previousPopupShowCallback: undefined, + quoteToReport: undefined, +}; + +const defaultOptions = { + quoteId: -1, + previousPopupShowCallback: () => {}, + noAnim: false, +}; + +export async function show(options = defaultOptions) { + if ($("#quoteReportPopupWrapper").hasClass("hidden")) { + const { quoteId, previousPopupShowCallback, noAnim } = options; + + state.previousPopupShowCallback = previousPopupShowCallback; + + const { quotes } = await Misc.getQuotes(Config.language); + state.quoteToReport = quotes.find((quote) => { + return quote.id === quoteId; + }); + + $("#quoteReportPopup .quote").text(state.quoteToReport.text); + $("#quoteReportPopup .reason").val("Grammatical error"); + $("#quoteReportPopup .comment").val(""); + $("#quoteReportPopup .characterCount").text("-"); + $("#quoteReportPopup .reason").select2({ + minimumResultsForSearch: Infinity, + }); + $("#quoteReportPopupWrapper") + .stop(true, true) + .css("opacity", 0) + .removeClass("hidden") + .animate({ opacity: 1 }, noAnim ? 0 : 100, (e) => { + $("#quoteReportPopup textarea").focus().select(); + }); + } +} + +export async function hide() { + if (!$("#quoteReportPopupWrapper").hasClass("hidden")) { + $("#quoteReportPopupWrapper") + .stop(true, true) + .css("opacity", 1) + .animate( + { + opacity: 0, + }, + 100, + (e) => { + grecaptcha.reset(CAPTCHA_ID); + $("#quoteReportPopupWrapper").addClass("hidden"); + if (state.previousPopupShowCallback) { + state.previousPopupShowCallback(); + } + } + ); + } +} + +async function submitReport() { + const captchaResponse = grecaptcha.getResponse(CAPTCHA_ID); + if (!captchaResponse) { + Notifications.add("Please complete the captcha."); + return; + } + + const requestBody = { + quoteId: state.quoteToReport.id.toString(), + quoteLanguage: Config.language, + reason: $("#quoteReportPopup .reason").val(), + comment: $("#quoteReportPopup .comment").val(), + captcha: captchaResponse, + }; + + if (!requestBody.reason) { + Notifications.add("Please select a valid report reason."); + return; + } + + const characterDifference = requestBody.comment.length - 250; + if (characterDifference > 0) { + Notifications.add( + `Report comment is ${characterDifference} character(s) too long.` + ); + return; + } + + Loader.show(); + + let response; + try { + response = await axiosInstance.post("/quotes/report", requestBody); + } catch (e) { + Loader.hide(); + let msg = e?.response?.data?.message ?? e.message; + Notifications.add("Failed to report quote: " + msg, -1); + return; + } + + Loader.hide(); + if (response.status !== 200) { + Notifications.add(response.data.message); + } else { + Notifications.add("Report submitted. Thank you!", 1); + hide(); + } +} + +$("#quoteReportPopupWrapper").on("mousedown", (e) => { + if ($(e.target).attr("id") === "quoteReportPopupWrapper") { + hide(); + } +}); + +$("#quoteReportPopup .comment").on("input", (e) => { + setTimeout(() => { + const len = $("#quoteReportPopup .comment").val().length; + $("#quoteReportPopup .characterCount").text(len); + if (len > 250) { + $("#quoteReportPopup .characterCount").addClass("red"); + } else { + $("#quoteReportPopup .characterCount").removeClass("red"); + } + }, 1); +}); + +$("#quoteReportPopup .submit").on("click", async (e) => { + await submitReport(); +}); diff --git a/src/js/popups/quote-search-popup.js b/src/js/popups/quote-search-popup.js index 28138853a..328a48e1d 100644 --- a/src/js/popups/quote-search-popup.js +++ b/src/js/popups/quote-search-popup.js @@ -5,6 +5,7 @@ import * as ManualRestart from "./manual-restart-tracker"; import * as TestLogic from "./test-logic"; import * as QuoteSubmitPopup from "./quote-submit-popup"; import * as QuoteApprovePopup from "./quote-approve-popup"; +import * as QuoteReportPopup from "./quote-report-popup"; import * as DB from "./db"; import * as TestUI from "./test-ui"; @@ -40,6 +41,8 @@ async function updateResults(searchText) { let resultsList = $("#quoteSearchResults"); let resultListLength = 0; + const isNotAuthed = !firebase.auth().currentUser; + found.forEach(async (quote) => { let lengthDesc; if (quote.length < 101) { @@ -55,10 +58,16 @@ async function updateResults(searchText) { resultsList.append(`