diff --git a/backend/src/api/controllers/user.ts b/backend/src/api/controllers/user.ts index a2bcef7ce..757f9694c 100644 --- a/backend/src/api/controllers/user.ts +++ b/backend/src/api/controllers/user.ts @@ -16,6 +16,9 @@ import * as LeaderboardsDAL from "../../dal/leaderboards"; import { purgeUserFromDailyLeaderboards } from "../../utils/daily-leaderboards"; import { randomBytes } from "crypto"; import * as RedisClient from "../../init/redis"; +import { v4 as uuidv4 } from "uuid"; +import { ObjectId } from "mongodb"; +import * as ReportDAL from "../../dal/report"; async function verifyCaptcha(captcha: string): Promise { if (!(await verify(captcha))) { @@ -665,3 +668,31 @@ export async function updateInbox( return new MonkeyResponse("Inbox updated"); } + +export async function reportUser( + req: MonkeyTypes.Request +): Promise { + const { uid } = req.ctx.decodedToken; + const { + reporting: { maxReports, contentReportLimit }, + } = req.ctx.configuration.quotes; + + const { uid: uidToReport, reason, comment, captcha } = req.body; + + await verifyCaptcha(captcha); + + const newReport: MonkeyTypes.Report = { + _id: new ObjectId(), + id: uuidv4(), + type: "user", + timestamp: new Date().getTime(), + uid, + contentId: `${uidToReport}`, + reason, + comment, + }; + + await ReportDAL.createReport(newReport, maxReports, contentReportLimit); + + return new MonkeyResponse("User reported"); +} diff --git a/backend/src/api/routes/users.ts b/backend/src/api/routes/users.ts index 1709c6a7c..e237e8470 100644 --- a/backend/src/api/routes/users.ts +++ b/backend/src/api/routes/users.ts @@ -6,6 +6,7 @@ import { asyncHandler, validateRequest, validateConfiguration, + checkUserPermissions, } from "../../middlewares/api-utils"; import * as RateLimit from "../../middlewares/rate-limit"; import { withApeRateLimiter } from "../../middlewares/ape-rate-limit"; @@ -530,4 +531,46 @@ router.patch( asyncHandler(UserController.updateInbox) ); +const withCustomMessages = joi.string().messages({ + "string.pattern.base": "Invalid parameter format", +}); + +router.post( + "/report", + validateConfiguration({ + criteria: (configuration) => { + return configuration.quotes.reporting.enabled; + }, + invalidMessage: "User reporting is unavailable.", + }), + authenticateRequest(), + RateLimit.quoteReportSubmit, + validateRequest({ + body: { + uid: withCustomMessages.regex(/^\w+$/).required(), + reason: joi + .string() + .valid( + "Inappropriate name", + "Inappropriate bio", + "Inappropriate social links", + "Suspected cheating" + ) + .required(), + comment: withCustomMessages + .allow("") + .regex(/^([.]|[^/<>])+$/) + .max(250) + .required(), + captcha: withCustomMessages.regex(/[\w-_]+/).required(), + }, + }), + checkUserPermissions({ + criteria: (user) => { + return !user.cannotReport; + }, + }), + asyncHandler(UserController.reportUser) +); + export default router; diff --git a/backend/src/types/types.d.ts b/backend/src/types/types.d.ts index d0ae3be8b..46525b18f 100644 --- a/backend/src/types/types.d.ts +++ b/backend/src/types/types.d.ts @@ -432,7 +432,7 @@ declare namespace MonkeyTypes { level?: number; } - type ReportTypes = "quote"; + type ReportTypes = "quote" | "user"; interface Report { _id: ObjectId; diff --git a/frontend/src/styles/popups.scss b/frontend/src/styles/popups.scss index 85e0b6d17..14502d990 100644 --- a/frontend/src/styles/popups.scss +++ b/frontend/src/styles/popups.scss @@ -1131,6 +1131,60 @@ } } +#userReportPopupWrapper { + #userReportPopup { + background: var(--bg-color); + border-radius: var(--roundness); + padding: 2rem; + display: grid; + gap: 1rem; + grid-template-rows: auto auto auto auto auto auto auto auto auto; + height: auto; + max-height: 40rem; + overflow-y: scroll; + width: calc(100% - 4rem); + margin-left: 2rem; + max-width: 800px; + + label { + color: var(--sub-color); + margin-bottom: -1rem; + } + + .text { + // color: var(--sub-color); + } + + .user { + font-size: 1.5rem; + } + + .title { + font-size: 1.5rem; + color: var(--sub-color); + } + + textarea { + resize: vertical; + width: 100%; + padding: 10px; + line-height: 1.2rem; + min-height: 5rem; + } + + .characterCount { + position: absolute; + top: -1.25rem; + right: 0.25rem; + color: var(--sub-color); + user-select: none; + &.red { + color: var(--error-color); + } + } + } +} + #resultEditTagsPanelWrapper { #resultEditTagsPanel { background: var(--bg-color); diff --git a/frontend/src/styles/profile.scss b/frontend/src/styles/profile.scss index c6eaf4f86..f9adf0eb4 100644 --- a/frontend/src/styles/profile.scss +++ b/frontend/src/styles/profile.scss @@ -232,6 +232,7 @@ padding: 1rem; border-radius: var(--roundness); align-content: center; + padding-right: 3rem; // grid-template-columns: 15rem auto 15rem auto 2fr auto auto; diff --git a/frontend/src/ts/ape/endpoints/users.ts b/frontend/src/ts/ape/endpoints/users.ts index 6e48b23c9..afeafa3d0 100644 --- a/frontend/src/ts/ape/endpoints/users.ts +++ b/frontend/src/ts/ape/endpoints/users.ts @@ -225,4 +225,20 @@ export default class Users { }; return await this.httpClient.patch(`${BASE_PATH}/inbox`, { payload }); } + + async report( + uid: string, + reason: string, + comment: string, + captcha: string + ): Ape.EndpointData { + const payload = { + uid, + reason, + comment, + captcha, + }; + + return await this.httpClient.post(`${BASE_PATH}/report`, { payload }); + } } diff --git a/frontend/src/ts/elements/profile.ts b/frontend/src/ts/elements/profile.ts index a4d678206..e5270d465 100644 --- a/frontend/src/ts/elements/profile.ts +++ b/frontend/src/ts/elements/profile.ts @@ -11,6 +11,7 @@ type ProfileViewPaths = "profile" | "account"; interface ProfileData extends MonkeyTypes.Snapshot { allTimeLbs: MonkeyTypes.LeaderboardMemory; + uid: string; } export async function update( @@ -21,6 +22,9 @@ export async function update( const profileElement = $(`.page${elementClass} .profile`); const details = $(`.page${elementClass} .profile .details`); + profileElement.attr("uid", profile.uid ?? ""); + profileElement.attr("name", profile.name ?? ""); + // ============================================================================ // DO FREAKING NOT USE .HTML OR .APPEND HERE - USER INPUT!!!!!! // ============================================================================ diff --git a/frontend/src/ts/pages/profile.ts b/frontend/src/ts/pages/profile.ts index fcb114db6..a8f68dbe0 100644 --- a/frontend/src/ts/pages/profile.ts +++ b/frontend/src/ts/pages/profile.ts @@ -4,6 +4,7 @@ import * as Profile from "../elements/profile"; import * as PbTables from "../account/pb-tables"; import * as Notifications from "../elements/notifications"; import { checkIfGetParameterExists } from "../utils/misc"; +import * as UserReportPopup from "../popups/user-report-popup"; function reset(): void { $(".page.pageProfile .preloader").removeClass("hidden"); @@ -64,6 +65,15 @@ function reset(): void {
socials
-
+
+
+ +
+
All-Time English Leaderboards
@@ -181,6 +191,13 @@ async function update(options: UpdateOptions): Promise { } } +$(".page.pageProfile").on("click", ".profile .userReportButton", () => { + const uid = $(".page.pageProfile .profile").attr("uid") ?? ""; + const name = $(".page.pageProfile .profile").attr("name") ?? ""; + + UserReportPopup.show({ uid, name }); +}); + export const page = new Page( "profile", $(".page.pageProfile"), diff --git a/frontend/src/ts/popups/user-report-popup.ts b/frontend/src/ts/popups/user-report-popup.ts new file mode 100644 index 000000000..c4b16eac7 --- /dev/null +++ b/frontend/src/ts/popups/user-report-popup.ts @@ -0,0 +1,126 @@ +import Ape from "../ape"; +import * as Loader from "../elements/loader"; +import * as Notifications from "../elements/notifications"; +import * as CaptchaController from "../controllers/captcha-controller"; + +interface State { + userUid?: string; +} + +const state: State = { + userUid: undefined, +}; + +interface ShowOptions { + uid: string; + name: string; +} + +export async function show(options: ShowOptions): Promise { + if ($("#userReportPopupWrapper").hasClass("hidden")) { + CaptchaController.render( + document.querySelector("#userReportPopup .g-recaptcha") as HTMLElement, + "userReportPopup" + ); + + const { name } = options; + state.userUid = options.uid; + + $("#userReportPopup .user").text(name); + $("#userReportPopup .reason").val("Inappropriate name"); + $("#userReportPopup .comment").val(""); + $("#userReportPopup .characterCount").text("-"); + $("#userReportPopup .reason").select2({ + minimumResultsForSearch: Infinity, + }); + $("#userReportPopupWrapper") + .stop(true, true) + .css("opacity", 0) + .removeClass("hidden") + .animate({ opacity: 1 }, 100, () => { + $("#userReportPopup textarea").trigger("focus").trigger("select"); + }); + } +} + +export async function hide(): Promise { + if (!$("#userReportPopupWrapper").hasClass("hidden")) { + $("#userReportPopupWrapper") + .stop(true, true) + .css("opacity", 1) + .animate( + { + opacity: 0, + }, + 100, + () => { + CaptchaController.reset("userReportPopup"); + $("#userReportPopupWrapper").addClass("hidden"); + } + ); + } +} + +async function submitReport(): Promise { + const captchaResponse = CaptchaController.getResponse("userReportPopup"); + if (!captchaResponse) { + return Notifications.add("Please complete the captcha"); + } + + const reason = $("#userReportPopup .reason").val() as string; + const comment = $("#userReportPopup .comment").val() as string; + const captcha = captchaResponse as string; + + if (!reason) { + return Notifications.add("Please select a valid report reason"); + } + + if (!comment) { + return Notifications.add("Please provide a comment"); + } + + const characterDifference = comment.length - 250; + if (characterDifference > 0) { + return Notifications.add( + `Report comment is ${characterDifference} character(s) too long` + ); + } + + Loader.show(); + const response = await Ape.users.report( + state.userUid as string, + reason, + comment, + captcha + ); + Loader.hide(); + + if (response.status !== 200) { + return Notifications.add("Failed to report user: " + response.message, -1); + } + + Notifications.add("Report submitted. Thank you!", 1); + hide(); +} + +$("#userReportPopupWrapper").on("mousedown", (e) => { + if ($(e.target).attr("id") === "userReportPopupWrapper") { + hide(); + } +}); + +$("#userReportPopup .comment").on("input", () => { + setTimeout(() => { + const len = ($("#userReportPopup .comment").val() as string).length; + $("#userReportPopup .characterCount").text(len); + if (len > 250) { + $("#userReportPopup .characterCount").addClass("red"); + } else { + $("#userReportPopup .characterCount").removeClass("red"); + } + }, 1); +}); + +$("#userReportPopup .submit").on("click", async () => { + await submitReport(); +}); diff --git a/frontend/static/html/pages/profile.html b/frontend/static/html/pages/profile.html index 4f89a0b02..784abfc8e 100644 --- a/frontend/static/html/pages/profile.html +++ b/frontend/static/html/pages/profile.html @@ -74,6 +74,15 @@
socials
-
+
+
+ +
+
All-Time English Leaderboards
diff --git a/frontend/static/html/popups.html b/frontend/static/html/popups.html index e34cd537a..f2aea80fc 100644 --- a/frontend/static/html/popups.html +++ b/frontend/static/html/popups.html @@ -736,6 +736,36 @@
Report
+