Approve/reject skins from the website

This commit is contained in:
Jordan Eldredge 2020-11-30 01:25:38 -05:00
parent 918fd4b4da
commit 68dadec7ac
13 changed files with 382 additions and 35 deletions

View file

@ -19,11 +19,9 @@ export default class DiscordEventHandler {
return this._clientPromise;
}
async getNsfwChannel(): Promise<TextChannel> {
async getChannel(channelId: string): Promise<TextChannel> {
const client = await this.getClient();
const dest = client.channels.get(
Config.NSFW_SKIN_CHANNEL_ID
) as TextChannel | null;
const dest = client.channels.get(channelId) as TextChannel | null;
if (dest == null) {
throw new Error("Could not get NSFW channel");
}
@ -33,16 +31,50 @@ export default class DiscordEventHandler {
async handle(action: ApiAction, ctx: UserContext) {
switch (action.type) {
case "REVIEW_REQUESTED":
this.requestReview(action.md5, ctx);
await this.requestReview(action.md5, ctx);
break;
case "APPROVED_SKIN":
await this.reportApproved(action.md5, ctx);
break;
case "REJECTED_SKIN":
await this.reportRejected(action.md5, ctx);
break;
}
}
async reportApproved(md5: string, ctx: UserContext) {
const skin = await SkinModel.fromMd5(ctx, md5);
if (skin == null) {
return;
}
const dest = await this.getChannel(Config.TWEET_BOT_CHANNEL_ID);
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.TWEET_BOT_CHANNEL_ID);
await DiscordUtils.postSkin({
md5,
title: (filename) => `Rejected by ${ctx.username}: ${filename}`,
dest,
});
}
async requestReview(md5: string, ctx: UserContext) {
const skin = await SkinModel.fromMd5(ctx, md5);
if (skin == null) {
return;
}
const dest = await this.getNsfwChannel();
const dest = await this.getChannel(Config.NSFW_SKIN_CHANNEL_ID);
const tweetStatus = await skin.getTweetStatus();
if (tweetStatus === "UNREVIEWED") {
await DiscordUtils.postSkin({

View file

@ -2,8 +2,10 @@ import { Application } from "express";
import { knex } from "../../db";
import request from "supertest"; // supertest is a framework that allows to easily test web apis
import { createApp } from "../app";
import SkinModel from "../../data/SkinModel";
import * as S3 from "../../s3";
import { processUserUploads } from "../processUserUploads";
import UserContext from "../../data/UserContext";
jest.mock("../../s3");
jest.mock("../processUserUploads");
@ -13,7 +15,13 @@ const handler = jest.fn();
beforeEach(async () => {
handler.mockReset();
// We ignore the ctx
app = createApp((action, _ctx) => handler(action));
app = createApp({
eventHandler: (action, _ctx) => handler(action),
extraMiddleware: (req, res, next) => {
req.session.username = "<MOCKED>";
next();
},
});
await knex.migrate.latest();
await knex.seed.run();
});
@ -79,7 +87,9 @@ describe("/skins/", () => {
});
test("/skins/a_fake_md5/report", async () => {
const { body } = await request(app).post("/skins/a_fake_md5/report");
const { body } = await request(app)
.post("/skins/a_fake_md5/report")
.expect(200);
expect(handler).toHaveBeenCalledWith({
type: "REVIEW_REQUESTED",
md5: "a_fake_md5",
@ -87,6 +97,36 @@ test("/skins/a_fake_md5/report", async () => {
expect(body).toEqual({}); // TODO: Where does the text response go?
});
test("/skins/a_fake_md5/approve", async () => {
const ctx = new UserContext();
const { body } = await request(app)
.post("/skins/a_fake_md5/approve")
.expect(200);
expect(handler).toHaveBeenCalledWith({
type: "APPROVED_SKIN",
md5: "a_fake_md5",
});
expect(body).toEqual({}); // TODO: Where does the text response go?
const skin = await SkinModel.fromMd5(ctx, "a_fake_md5");
expect(await skin?.getTweetStatus()).toEqual("APPROVED");
});
test("/skins/a_fake_md5/reject", async () => {
const ctx = new UserContext();
const { body } = await request(app)
.post("/skins/a_fake_md5/reject")
.expect(200);
expect(handler).toHaveBeenCalledWith({
type: "REJECTED_SKIN",
md5: "a_fake_md5",
});
expect(body).toEqual({}); // TODO: Where does the text response go?
const skin = await SkinModel.fromMd5(ctx, "a_fake_md5");
expect(await skin?.getTweetStatus()).toEqual("REJECTED");
});
// TODO: Actually upload some skins?
test("/skins/status", async () => {
const { body } = await request(app)

View file

@ -5,11 +5,15 @@ import bodyParser from "body-parser";
import Sentry from "@sentry/node";
import expressSitemapXml from "express-sitemap-xml";
import * as Skins from "../data/skins";
import express from "express";
import express, { Handler } from "express";
import UserContext from "../data/UserContext";
import cookieSession from "cookie-session";
import { SECRET } from "../config";
export type ApiAction =
| { type: "REVIEW_REQUESTED"; md5: string }
| { type: "REJECTED_SKIN"; md5: string }
| { type: "APPROVED_SKIN"; md5: string }
| { type: "SKIN_UPLOADED"; md5: string }
| { type: "ERROR_PROCESSING_UPLOAD"; id: string; message: string };
@ -23,19 +27,46 @@ declare global {
notify(action: ApiAction): void;
log(message: string): void;
logError(message: string): void;
session: {
username: string | undefined;
};
}
}
}
export function createApp(eventHandler?: EventHandler) {
type Options = {
eventHandler?: EventHandler;
extraMiddleware?: Handler;
};
export function createApp({ eventHandler, extraMiddleware }: Options) {
const app = express();
if (Sentry) {
app.use(Sentry.Handlers.requestHandler());
}
// https://expressjs.com/en/guide/behind-proxies.html
// This is needed in order to allow `cookieSession({secure: true})` cookies to be sent.
app.set("trust proxy", "loopback");
app.use(
cookieSession({
secure: true,
sameSite: "none",
httpOnly: false,
name: "session",
secret: SECRET,
maxAge: 24 * 60 * 60 * 1000, // 24 hours
})
);
if (extraMiddleware != null) {
app.use(extraMiddleware);
}
// Add UserContext to request
app.use((req, res, next) => {
req.ctx = new UserContext();
req.ctx = new UserContext(req.session.username);
next();
// TODO: Dispose of context?
});
@ -52,7 +83,12 @@ export function createApp(eventHandler?: EventHandler) {
// Attach logger
app.use((req, res, next) => {
const context = { url: req.url, params: req.params, query: req.query };
const context = {
url: req.url,
params: req.params,
query: req.query,
username: req.ctx.username,
};
req.log = (message) => console.log(message, context);
req.logError = (message) => console.error(message, context);
next();
@ -105,6 +141,7 @@ const allowList = [
];
const corsOptions: CorsOptions = {
credentials: true,
origin: function (origin, callback) {
if (!origin || allowList.some((regex) => regex.test(origin))) {
callback(null, true);

View file

@ -0,0 +1,77 @@
import {
DISCORD_CLIENT_ID,
DISCORD_CLIENT_SECRET,
DISCORD_REDIRECT_URL,
DISCORD_WEBAMP_SERVER_ID,
} from "../config";
import fetch from "node-fetch";
type AuthResponse = {
id: string;
username: string;
avatar: string;
discriminator: string;
public_flags: number;
flags: number;
localse: string;
mfa_enabled: boolean;
premium_type: number;
};
// Given a code from Discord auth, check that it's valid
export async function auth(code: string): Promise<string> {
const data = {
client_id: DISCORD_CLIENT_ID,
client_secret: DISCORD_CLIENT_SECRET,
grant_type: "authorization_code",
redirect_uri: DISCORD_REDIRECT_URL,
code,
scope: "identity",
};
const fetchResponse = await fetch("https://discord.com/api/oauth2/token", {
method: "POST",
body: new URLSearchParams(data),
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
if (!fetchResponse.ok) {
throw new Error("Error getting auth token from Discord");
}
const info = await fetchResponse.json();
const authResponse = await fetch("https://discord.com/api/users/@me", {
headers: {
authorization: `${info.token_type} ${info.access_token}`,
},
});
if (!authResponse.ok) {
throw new Error("Error getting auth data from Discord");
}
const authBody = (await authResponse.json()) as AuthResponse;
const guildsResponse = await fetch(
"https://discord.com/api/users/@me/guilds",
{
headers: {
authorization: `${info.token_type} ${info.access_token}`,
},
}
);
if (!guildsResponse.ok) {
throw new Error("Error getting guild data from Discord");
}
const guildsBody = (await guildsResponse.json()) as any[];
const hasMemership = guildsBody.some(
(guild) => guild.id === DISCORD_WEBAMP_SERVER_ID
);
if (!hasMemership) {
throw new Error("User is not a member of the Webamp server.");
}
return authBody.username;
}

View file

@ -2,10 +2,12 @@ import { Router } from "express";
import asyncHandler from "express-async-handler";
import SkinModel from "../data/SkinModel";
import * as Skins from "../data/skins";
import { DISCORD_CLIENT_ID, DISCORD_REDIRECT_URL } from "../config";
import S3 from "../s3";
import LRU from "lru-cache";
import { MuseumPage } from "../data/skins";
import { processUserUploads } from "./processUserUploads";
import { auth } from "./auth";
const router = Router();
@ -16,6 +18,47 @@ const options = {
let skinCount: number | null = null;
const cache = new LRU<string, MuseumPage>(options);
router.get(
"/auth/",
asyncHandler(async (req, res) => {
res.redirect(
302,
`https://discord.com/api/oauth2/authorize?client_id=${encodeURIComponent(
DISCORD_CLIENT_ID
)}&redirect_uri=${encodeURIComponent(
DISCORD_REDIRECT_URL
)}&response_type=code&scope=identify%20guilds`
);
})
);
router.get(
"/authed/",
asyncHandler(async (req, res) => {
res.json({ username: req.ctx.username });
})
);
router.get(
"/auth/discord",
asyncHandler(async (req, res) => {
const code = req.query.code as string | undefined;
if (code == null) {
// TODO 400
throw new Error("Expected to get a code");
}
const username = await auth(code);
if (username == null) {
throw new Error("Expected to get a username");
}
req.session.username = username;
// TODO: What about dev?
res.redirect(302, `https://skins.webamp.org/review/`);
})
);
router.get(
"/skins/",
asyncHandler(async (req, res) => {
@ -86,6 +129,60 @@ router.get(
})
);
function requireAuthed(req, res, next) {
if (!req.ctx.authed()) {
res.status(403);
res.send("You must be logged in");
} else {
next();
}
}
router.get(
"/to_review",
requireAuthed,
asyncHandler(async (req, res) => {
const { filename, md5 } = await Skins.getSkinToReview();
res.json({ filename, md5 });
})
);
router.post(
"/skins/:md5/reject",
requireAuthed,
asyncHandler(async (req, res) => {
const { md5 } = req.params;
req.log(`Rejecting skin with hash "${md5}"`);
const skin = await SkinModel.fromMd5(req.ctx, md5);
if (skin == null) {
req.log(`No skin skin`);
throw new Error(`Could not locate as skin with md5 ${md5}`);
}
await Skins.reject(req.ctx, md5);
req.notify({ type: "REJECTED_SKIN", md5 });
res.send("The skin has been rejected.");
})
);
router.post(
"/skins/:md5/approve",
requireAuthed,
asyncHandler(async (req, res) => {
if (!req.ctx.authed()) {
throw new Error("Not authenticated");
}
const { md5 } = req.params;
req.log(`Approving skin with hash "${md5}"`);
const skin = await SkinModel.fromMd5(req.ctx, md5);
if (skin == null) {
throw new Error(`Cold not locate as skin with md5 ${md5}`);
}
await Skins.approve(req.ctx, md5);
req.notify({ type: "APPROVED_SKIN", md5 });
res.send("The skin has been approved.");
})
);
router.post(
"/skins/:md5/report",
asyncHandler(async (req, res) => {

View file

@ -7,7 +7,9 @@ const port = process.env.PORT ? Number(process.env.PORT) : 3001;
const handler = new DiscordEventHandler();
// GO!
const app = createApp((action, ctx) => handler.handle(action, ctx));
const app = createApp({
eventHandler: (action, ctx) => handler.handle(action, ctx),
});
app.listen(port, () => console.log(`Example app listening on port ${port}!`));
// Initialize Sentry after we start listening. Any crash at start time will appear in the console and we'll notice.

View file

@ -1,5 +1,13 @@
// Currently only used as a WeakMap key
export default class UserContext {}
export default class UserContext {
username: string | null;
constructor(username?: string) {
this.username = username || null;
}
authed() {
return this.username != null;
}
}
export function ctxWeakMapMemoize<T>(factory: () => T) {
const cache: WeakMap<UserContext, T> = new WeakMap();

View file

@ -136,12 +136,15 @@ export async function markAsTweeted(md5: string, url: string): Promise<void> {
}
// TODO: Also path actor
export async function markAsNSFW(md5: string): Promise<void> {
export async function markAsNSFW(ctx: UserContext, md5: string): Promise<void> {
const index = { objectID: md5, nsfw: true };
// TODO: Await here, but for some reason this never completes
await searchIndex.partialUpdateObjects([index]);
await recordSearchIndexUpdates(md5, Object.keys(index));
await knex("skin_reviews").insert({ skin_md5: md5, review: "NSFW" }, []);
await knex("skin_reviews").insert(
{ skin_md5: md5, review: "NSFW", reviewer: ctx.username || "UNKNOWN" },
[]
);
}
export async function getUploadStatuses(
@ -330,13 +333,19 @@ export async function recordUserUploadRequest(
}
// TODO: Also path actor
export async function approve(md5: string): Promise<void> {
await knex("skin_reviews").insert({ skin_md5: md5, review: "APPROVED" }, []);
export async function approve(ctx: UserContext, md5: string): Promise<void> {
await knex("skin_reviews").insert(
{ skin_md5: md5, review: "APPROVED", reviewer: ctx.username || "UNKNOWN" },
[]
);
}
// TODO: Also path actor
export async function reject(md5: string): Promise<void> {
await knex("skin_reviews").insert({ skin_md5: md5, review: "REJECTED" }, []);
export async function reject(ctx: UserContext, md5: string): Promise<void> {
await knex("skin_reviews").insert(
{ skin_md5: md5, review: "REJECTED", reviewer: ctx.username || "UNKNOWN" },
[]
);
}
export async function getSkinToReview(): Promise<{

View file

@ -121,7 +121,7 @@ export async function postSkin({
switch (vote.emoji.name) {
case "👍":
case "👏":
await Skins.approve(md5);
await Skins.approve(ctx, md5);
logger.info(`${user.username} approved ${md5}`);
await msg.channel.send(
`${canonicalFilename} was approved by ${user.username}`
@ -130,7 +130,7 @@ export async function postSkin({
break;
case "😔":
case "👎":
await Skins.reject(md5);
await Skins.reject(ctx, md5);
logger.info(`${user.username} rejected ${md5}`);
await msg.channel.send(
`${canonicalFilename} was rejected by ${user.username}`
@ -139,11 +139,11 @@ export async function postSkin({
break;
case "🔞":
logger.info(`${user.username} marked ${md5} as NSFW`);
await Skins.markAsNSFW(md5);
await Skins.markAsNSFW(ctx, md5);
await msg.channel.send(
`${canonicalFilename} was marked as NSFW by ${user.username}`
);
await Skins.reject(md5);
await Skins.reject(ctx, md5);
logger.info(`${user.username} rejected ${md5}`);
await msg.channel.send(
`${canonicalFilename} was rejected by ${user.username}`

View file

@ -0,0 +1,13 @@
import * as Knex from "knex";
export async function up(knex: Knex): Promise<any> {
await knex.schema.table("skin_reviews", function (table) {
table.text("reviewer");
});
}
export async function down(knex: Knex): Promise<any> {
await knex.schema.table("skin_reviews", function (table) {
table.dropColumn("reviewer");
});
}

View file

@ -6,6 +6,7 @@
"dependencies": {
"@sentry/node": "^5.27.3",
"@sentry/tracing": "^5.27.3",
"@types/cookie-session": "^2.0.41",
"@types/cors": "^2.8.8",
"@types/express": "^4.17.9",
"@types/express-fileupload": "^1.1.5",
@ -14,6 +15,7 @@
"@types/node-fetch": "^2.5.7",
"algoliasearch": "^4.3.0",
"aws-sdk": "^2.663.0",
"cookie-session": "^1.4.0",
"cors": "^2.8.5",
"dataloader": "^2.0.0",
"discord.js": "^11.4.2",

View file

@ -20,6 +20,7 @@ export type TweetRow = {
export type ReviewRow = {
skin_md5: string;
review: "APPROVED" | "REJECTED" | "NSFW";
reviewer: string;
};
export type FileRow = {

View file

@ -1702,6 +1702,13 @@
dependencies:
"@types/node" "*"
"@types/cookie-session@^2.0.41":
version "2.0.41"
resolved "https://registry.yarnpkg.com/@types/cookie-session/-/cookie-session-2.0.41.tgz#ba1cf00114a505795269bf46c5b14c8e9f9c639a"
dependencies:
"@types/express" "*"
"@types/keygrip" "*"
"@types/cookiejar@*":
version "2.1.2"
resolved "https://registry.yarnpkg.com/@types/cookiejar/-/cookiejar-2.1.2.tgz#66ad9331f63fe8a3d3d9d8c6e3906dd10f6446e8"
@ -1821,6 +1828,10 @@
dependencies:
jszip "*"
"@types/keygrip@*":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@types/keygrip/-/keygrip-1.0.2.tgz#513abfd256d7ad0bf1ee1873606317b33b1b2a72"
"@types/lodash-es@^4.17.1":
version "4.17.3"
resolved "https://registry.yarnpkg.com/@types/lodash-es/-/lodash-es-4.17.3.tgz#87eb0b3673b076b8ee655f1890260a136af09a2d"
@ -3771,6 +3782,14 @@ convert-source-map@^1.4.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0:
dependencies:
safe-buffer "~5.1.1"
cookie-session@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/cookie-session/-/cookie-session-1.4.0.tgz#c325aea685ceb9c8e4fd00b0313a46d547747380"
dependencies:
cookies "0.8.0"
debug "2.6.9"
on-headers "~1.0.2"
cookie-signature@1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
@ -3787,6 +3806,13 @@ cookiejar@^2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.2.tgz#dd8a235530752f988f9a0844f3fc589e3111125c"
cookies@0.8.0:
version "0.8.0"
resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.8.0.tgz#1293ce4b391740a8406e3c9870e828c4b54f3f90"
dependencies:
depd "~2.0.0"
keygrip "~1.1.0"
copy-concurrently@^1.0.0:
version "1.0.5"
resolved "https://registry.yarnpkg.com/copy-concurrently/-/copy-concurrently-1.0.5.tgz#92297398cae34937fcafd6ec8139c18051f0b5e0"
@ -4343,6 +4369,10 @@ depd@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
depd@~2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
des.js@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.0.1.tgz#5382142e1bdc53f85d86d53e5f4aa7deb91e0843"
@ -4849,10 +4879,6 @@ eslint-plugin-prettier@^3.1.0:
dependencies:
prettier-linter-helpers "^1.0.0"
eslint-plugin-react-hooks@^1.5.1:
version "1.7.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-1.7.0.tgz#6210b6d5a37205f0b92858f895a4e827020a7d04"
eslint-plugin-react-hooks@^2.1.2:
version "2.5.1"
resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-2.5.1.tgz#4ef5930592588ce171abeb26f400c7fbcbc23cd0"
@ -8102,6 +8128,12 @@ junk@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/junk/-/junk-3.1.0.tgz#31499098d902b7e98c5d9b9c80f43457a88abfa1"
keygrip@~1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.1.0.tgz#871b1681d5e159c62a445b0c74b615e0917e7226"
dependencies:
tsscmp "1.0.6"
keyv@3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.0.0.tgz#44923ba39e68b12a7cec7df6c3268c031f2ef373"
@ -12235,6 +12267,10 @@ tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3:
version "1.13.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
tsscmp@1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb"
tsutils@^3.17.1:
version "3.17.1"
resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.17.1.tgz#ed719917f11ca0dee586272b2ac49e015a2dd759"
@ -12659,13 +12695,6 @@ wbuf@^1.1.0, wbuf@^1.7.3:
dependencies:
minimalistic-assert "^1.0.0"
webamp@1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/webamp/-/webamp-1.4.0.tgz#36c0ef2621ed3d14835d8cbd58206a408903e602"
dependencies:
eslint-plugin-react-hooks "^1.5.1"
fscreen "^1.0.2"
webidl-conversions@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"