From 68dadec7ac567e2bf3612b79ab1e912e18366c74 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Mon, 30 Nov 2020 01:25:38 -0500 Subject: [PATCH] Approve/reject skins from the website --- .../skin-database/api/DiscordEventHandler.ts | 44 +++++++-- .../api/__tests__/router.test.ts | 44 ++++++++- packages/skin-database/api/app.ts | 45 ++++++++- packages/skin-database/api/auth.ts | 77 +++++++++++++++ packages/skin-database/api/router.ts | 97 +++++++++++++++++++ packages/skin-database/api/server.ts | 4 +- packages/skin-database/data/UserContext.ts | 10 +- packages/skin-database/data/skins.ts | 21 ++-- packages/skin-database/discord-bot/utils.ts | 8 +- .../20201130002421_reviewer_field.ts | 13 +++ packages/skin-database/package.json | 2 + packages/skin-database/types.ts | 1 + yarn.lock | 51 +++++++--- 13 files changed, 382 insertions(+), 35 deletions(-) create mode 100644 packages/skin-database/api/auth.ts create mode 100644 packages/skin-database/migrations/20201130002421_reviewer_field.ts diff --git a/packages/skin-database/api/DiscordEventHandler.ts b/packages/skin-database/api/DiscordEventHandler.ts index 38428b73..432d0a5b 100644 --- a/packages/skin-database/api/DiscordEventHandler.ts +++ b/packages/skin-database/api/DiscordEventHandler.ts @@ -19,11 +19,9 @@ export default class DiscordEventHandler { return this._clientPromise; } - async getNsfwChannel(): Promise { + async getChannel(channelId: string): Promise { 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({ diff --git a/packages/skin-database/api/__tests__/router.test.ts b/packages/skin-database/api/__tests__/router.test.ts index c64f0e8f..a7948415 100644 --- a/packages/skin-database/api/__tests__/router.test.ts +++ b/packages/skin-database/api/__tests__/router.test.ts @@ -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 = ""; + 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) diff --git a/packages/skin-database/api/app.ts b/packages/skin-database/api/app.ts index 652d85ea..4965432d 100644 --- a/packages/skin-database/api/app.ts +++ b/packages/skin-database/api/app.ts @@ -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); diff --git a/packages/skin-database/api/auth.ts b/packages/skin-database/api/auth.ts new file mode 100644 index 00000000..797bea5b --- /dev/null +++ b/packages/skin-database/api/auth.ts @@ -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 { + 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; +} diff --git a/packages/skin-database/api/router.ts b/packages/skin-database/api/router.ts index b6dda448..fbdcc3c1 100644 --- a/packages/skin-database/api/router.ts +++ b/packages/skin-database/api/router.ts @@ -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(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) => { diff --git a/packages/skin-database/api/server.ts b/packages/skin-database/api/server.ts index 8ca08d17..a4d01f24 100644 --- a/packages/skin-database/api/server.ts +++ b/packages/skin-database/api/server.ts @@ -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. diff --git a/packages/skin-database/data/UserContext.ts b/packages/skin-database/data/UserContext.ts index 6a07b43f..fea1dc03 100644 --- a/packages/skin-database/data/UserContext.ts +++ b/packages/skin-database/data/UserContext.ts @@ -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(factory: () => T) { const cache: WeakMap = new WeakMap(); diff --git a/packages/skin-database/data/skins.ts b/packages/skin-database/data/skins.ts index 169f7fd0..9b3993b8 100644 --- a/packages/skin-database/data/skins.ts +++ b/packages/skin-database/data/skins.ts @@ -136,12 +136,15 @@ export async function markAsTweeted(md5: string, url: string): Promise { } // TODO: Also path actor -export async function markAsNSFW(md5: string): Promise { +export async function markAsNSFW(ctx: UserContext, md5: string): Promise { 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 { - await knex("skin_reviews").insert({ skin_md5: md5, review: "APPROVED" }, []); +export async function approve(ctx: UserContext, md5: string): Promise { + 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 { - await knex("skin_reviews").insert({ skin_md5: md5, review: "REJECTED" }, []); +export async function reject(ctx: UserContext, md5: string): Promise { + await knex("skin_reviews").insert( + { skin_md5: md5, review: "REJECTED", reviewer: ctx.username || "UNKNOWN" }, + [] + ); } export async function getSkinToReview(): Promise<{ diff --git a/packages/skin-database/discord-bot/utils.ts b/packages/skin-database/discord-bot/utils.ts index 48164431..e79b6613 100644 --- a/packages/skin-database/discord-bot/utils.ts +++ b/packages/skin-database/discord-bot/utils.ts @@ -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}` diff --git a/packages/skin-database/migrations/20201130002421_reviewer_field.ts b/packages/skin-database/migrations/20201130002421_reviewer_field.ts new file mode 100644 index 00000000..40b625c5 --- /dev/null +++ b/packages/skin-database/migrations/20201130002421_reviewer_field.ts @@ -0,0 +1,13 @@ +import * as Knex from "knex"; + +export async function up(knex: Knex): Promise { + await knex.schema.table("skin_reviews", function (table) { + table.text("reviewer"); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.table("skin_reviews", function (table) { + table.dropColumn("reviewer"); + }); +} diff --git a/packages/skin-database/package.json b/packages/skin-database/package.json index a2b5d414..30636f25 100644 --- a/packages/skin-database/package.json +++ b/packages/skin-database/package.json @@ -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", diff --git a/packages/skin-database/types.ts b/packages/skin-database/types.ts index bead88df..cb768a66 100644 --- a/packages/skin-database/types.ts +++ b/packages/skin-database/types.ts @@ -20,6 +20,7 @@ export type TweetRow = { export type ReviewRow = { skin_md5: string; review: "APPROVED" | "REJECTED" | "NSFW"; + reviewer: string; }; export type FileRow = { diff --git a/yarn.lock b/yarn.lock index 5c63c513..3661ae7f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"