diff --git a/packages/skin-database/__mocks__/algolia.ts b/packages/skin-database/__mocks__/algolia.ts new file mode 100644 index 00000000..4475432b --- /dev/null +++ b/packages/skin-database/__mocks__/algolia.ts @@ -0,0 +1,3 @@ +export const searchIndex = { + partialUpdateObjects: jest.fn(), +}; diff --git a/packages/skin-database/api/DiscordEventHandler.ts b/packages/skin-database/api/DiscordEventHandler.ts index 95bf05c4..7f395044 100644 --- a/packages/skin-database/api/DiscordEventHandler.ts +++ b/packages/skin-database/api/DiscordEventHandler.ts @@ -34,37 +34,44 @@ export default class DiscordEventHandler { await this.requestReview(action.md5, ctx); break; case "APPROVED_SKIN": - await this.reportApproved(action.md5, ctx); + await this.reportSkin( + action.md5, + ctx, + (filename) => `Approved by ${ctx.username}: ${filename}` + ); break; case "REJECTED_SKIN": - await this.reportRejected(action.md5, ctx); + await this.reportSkin( + action.md5, + ctx, + (filename) => `Rejected by ${ctx.username}: ${filename}` + ); + break; + case "MARKED_SKIN_NSFW": + await this.reportSkin( + action.md5, + ctx, + (filename) => `Marked NSFW by ${ctx.username}: ${filename}`, + Config.NSFW_SKIN_CHANNEL_ID + ); break; } } - async reportApproved(md5: string, ctx: UserContext) { + async reportSkin( + md5: string, + ctx: UserContext, + getFilename: (filename: string) => string, + channelId = Config.SKIN_REVIEW_CHANNEL_ID + ) { const skin = await SkinModel.fromMd5(ctx, md5); if (skin == null) { return; } - const dest = await this.getChannel(Config.SKIN_REVIEW_CHANNEL_ID); + const dest = await this.getChannel(channelId); await DiscordUtils.postSkin({ md5, - title: (filename) => `Approved by ${ctx.username}: ${filename}`, - dest, - }); - } - - async reportRejected(md5: string, ctx: UserContext) { - console.log("Report rejected"); - const skin = await SkinModel.fromMd5(ctx, md5); - if (skin == null) { - return; - } - const dest = await this.getChannel(Config.SKIN_REVIEW_CHANNEL_ID); - await DiscordUtils.postSkin({ - md5, - title: (filename) => `Rejected by ${ctx.username}: ${filename}`, + title: getFilename, dest, }); } diff --git a/packages/skin-database/api/__tests__/router.test.ts b/packages/skin-database/api/__tests__/router.test.ts index 39793177..98e46f22 100644 --- a/packages/skin-database/api/__tests__/router.test.ts +++ b/packages/skin-database/api/__tests__/router.test.ts @@ -7,7 +7,9 @@ import * as S3 from "../../s3"; import * as Auth from "../auth"; import { processUserUploads } from "../processUserUploads"; import UserContext from "../../data/UserContext"; +import { searchIndex } from "../../algolia"; jest.mock("../../s3"); +jest.mock("../../algolia"); jest.mock("../processUserUploads"); jest.mock("../auth"); @@ -155,7 +157,7 @@ test("/skins/a_fake_md5/approve", async () => { type: "APPROVED_SKIN", md5: "a_fake_md5", }); - expect(body).toEqual({}); // TODO: Where does the text response go? + expect(body).toEqual({ message: "The skin has been approved." }); const skin = await SkinModel.fromMd5(ctx, "a_fake_md5"); expect(await skin?.getTweetStatus()).toEqual("APPROVED"); @@ -195,7 +197,7 @@ test("/skins/a_fake_md5/reject", async () => { type: "REJECTED_SKIN", md5: "a_fake_md5", }); - expect(body).toEqual({}); // TODO: Where does the text response go? + expect(body).toEqual({ message: "The skin has been rejected." }); // TODO: Where does the text response go? const skin = await SkinModel.fromMd5(ctx, "a_fake_md5"); expect(await skin?.getTweetStatus()).toEqual("REJECTED"); @@ -210,6 +212,24 @@ test("/skins/a_md5_that_does_not_exist/reject (404)", async () => { expect(handler).not.toHaveBeenCalled(); }); +test.only("/skins/a_fake_md5/nsfw", async () => { + const ctx = new UserContext(); + const { body } = await request(app) + .post("/skins/a_fake_md5/nsfw") + .expect(200); + expect(handler).toHaveBeenCalledWith({ + type: "MARKED_SKIN_NSFW", + md5: "a_fake_md5", + }); + expect(searchIndex.partialUpdateObjects).toHaveBeenCalledWith([ + { nsfw: true, objectID: "a_fake_md5" }, + ]); + expect(body).toEqual({ message: "The skin has been marked as NSFW." }); + const skin = await SkinModel.fromMd5(ctx, "a_fake_md5"); + + expect(await skin?.getTweetStatus()).toEqual("NSFW"); +}); + // TODO: Actually upload some skins? test("/skins/status", async () => { const { body } = await request(app) diff --git a/packages/skin-database/api/app.ts b/packages/skin-database/api/app.ts index a8ab320f..04360953 100644 --- a/packages/skin-database/api/app.ts +++ b/packages/skin-database/api/app.ts @@ -14,6 +14,7 @@ export type ApiAction = | { type: "REVIEW_REQUESTED"; md5: string } | { type: "REJECTED_SKIN"; md5: string } | { type: "APPROVED_SKIN"; md5: string } + | { type: "MARKED_SKIN_NSFW"; md5: string } | { type: "SKIN_UPLOADED"; md5: string } | { type: "ERROR_PROCESSING_UPLOAD"; id: string; message: string }; diff --git a/packages/skin-database/api/router.ts b/packages/skin-database/api/router.ts index bc66a64c..bc650074 100644 --- a/packages/skin-database/api/router.ts +++ b/packages/skin-database/api/router.ts @@ -165,7 +165,7 @@ router.post( } await Skins.reject(req.ctx, md5); req.notify({ type: "REJECTED_SKIN", md5 }); - res.send("The skin has been rejected."); + res.send({ message: "The skin has been rejected." }); }) ); @@ -182,7 +182,26 @@ router.post( } await Skins.approve(req.ctx, md5); req.notify({ type: "APPROVED_SKIN", md5 }); - res.send("The skin has been approved."); + res.send({ message: "The skin has been approved." }); + }) +); + +// Unlike /report, this marks the skin NSFW right away without sending to +// Discord. Because of this, it requires auth. +router.post( + "/skins/:md5/nsfw", + requireAuthed, + asyncHandler(async (req, res) => { + const { md5 } = req.params; + req.log(`Approving skin with hash "${md5}"`); + const skin = await SkinModel.fromMd5(req.ctx, md5); + if (skin == null) { + res.status(404).send("Skin not found"); + return; + } + await Skins.markAsNSFW(req.ctx, md5); + req.notify({ type: "MARKED_SKIN_NSFW", md5 }); + res.send({ message: "The skin has been marked as NSFW." }); }) ); diff --git a/packages/skin-database/data/SkinModel.ts b/packages/skin-database/data/SkinModel.ts index a123bca8..9e62e99a 100644 --- a/packages/skin-database/data/SkinModel.ts +++ b/packages/skin-database/data/SkinModel.ts @@ -77,6 +77,9 @@ export default class SkinModel { } const reviewRows = await this.getReviews(); const reviews = new Set(reviewRows.map((row) => row.review)); + if (reviews.has("NSFW")) { + return "NSFW"; + } if (reviews.has("REJECTED")) { return "REJECTED"; } diff --git a/packages/skin-database/discord-bot/utils.ts b/packages/skin-database/discord-bot/utils.ts index e79b6613..dad6ec19 100644 --- a/packages/skin-database/discord-bot/utils.ts +++ b/packages/skin-database/discord-bot/utils.ts @@ -162,6 +162,8 @@ function getPrettyTwitterStatus(status: TweetStatus): string { switch (status) { case "APPROVED": return "Approved ✅"; + case "NSFW": + return "Rejected (NSFW) ❌"; case "REJECTED": return "Rejected ❌"; case "UNREVIEWED": diff --git a/packages/skin-database/tasks/tweet.ts b/packages/skin-database/tasks/tweet.ts index 07844abf..e15c95f2 100644 --- a/packages/skin-database/tasks/tweet.ts +++ b/packages/skin-database/tasks/tweet.ts @@ -32,7 +32,7 @@ export async function tweet(discordClient: Client, anything: string | null) { await tweetBotChannel.send(`Oops! This skin has alraedy been tweeted.`); return; } - if (tweetStatus == "REJECTED") { + if (tweetStatus == "REJECTED" || tweetStatus == "NSFW") { // @ts-ignore await tweetBotChannel.send(`Oops! Can't tweet a rejected skin.`); return; diff --git a/packages/skin-database/types.ts b/packages/skin-database/types.ts index cb768a66..936087f4 100644 --- a/packages/skin-database/types.ts +++ b/packages/skin-database/types.ts @@ -1,4 +1,10 @@ -export type TweetStatus = "APPROVED" | "REJECTED" | "TWEETED" | "UNREVIEWED"; +export type TweetStatus = + | "APPROVED" + | "REJECTED" + | "TWEETED" + | "UNREVIEWED" + | "NSFW"; + export type SkinType = "MODERN" | "CLASSIC"; export type SkinRow = {