diff --git a/packages/skin-database/.prettierignore b/packages/skin-database/.prettierignore new file mode 100644 index 00000000..804359d8 --- /dev/null +++ b/packages/skin-database/.prettierignore @@ -0,0 +1 @@ +api/graphql/schema.ts \ No newline at end of file diff --git a/packages/skin-database/api/graphql/GqlCtx.ts b/packages/skin-database/api/graphql/GqlCtx.ts new file mode 100644 index 00000000..bc7ac2c6 --- /dev/null +++ b/packages/skin-database/api/graphql/GqlCtx.ts @@ -0,0 +1 @@ +export type GqlCtx = Express.Request; diff --git a/packages/skin-database/api/graphql/ModernSkinsConnection.ts b/packages/skin-database/api/graphql/ModernSkinsConnection.ts index e5894c2d..cf4dc672 100644 --- a/packages/skin-database/api/graphql/ModernSkinsConnection.ts +++ b/packages/skin-database/api/graphql/ModernSkinsConnection.ts @@ -4,6 +4,7 @@ import { knex } from "../../db"; import ModernSkinResolver from "./resolvers/ModernSkinResolver"; import { Root } from "aws-sdk/clients/organizations"; import RootResolver from "./resolvers/RootResolver"; +import { GqlCtx } from "./GqlCtx"; /** * A collection of "modern" Winamp skins @@ -31,7 +32,10 @@ export default class ModernSkinsConnection { /** * The list of skins * @gqlField */ - async nodes(_args: never, ctx): Promise> { + async nodes( + _args: unknown, + ctx: GqlCtx + ): Promise> { const skins = await this._getQuery() .select() .limit(this._first) diff --git a/packages/skin-database/api/graphql/SkinsConnection.ts b/packages/skin-database/api/graphql/SkinsConnection.ts index 7febf767..497e9831 100644 --- a/packages/skin-database/api/graphql/SkinsConnection.ts +++ b/packages/skin-database/api/graphql/SkinsConnection.ts @@ -6,6 +6,7 @@ import LRU from "lru-cache"; import { Int } from "grats"; import { ISkin } from "./resolvers/CommonSkinResolver"; import RootResolver from "./resolvers/RootResolver"; +import { GqlCtx } from "./GqlCtx"; const options = { max: 100, @@ -97,7 +98,7 @@ export default class SkinsConnection { * The list of skins * @gqlField */ - async nodes(args: never, ctx): Promise> { + async nodes(args: unknown, ctx: GqlCtx): Promise> { if (this._sort === "MUSEUM") { if (this._filter) { throw new Error( diff --git a/packages/skin-database/api/graphql/TweetsConnection.ts b/packages/skin-database/api/graphql/TweetsConnection.ts index c48b859e..b61cf4a4 100644 --- a/packages/skin-database/api/graphql/TweetsConnection.ts +++ b/packages/skin-database/api/graphql/TweetsConnection.ts @@ -3,6 +3,7 @@ import TweetModel from "../../data/TweetModel"; import { knex } from "../../db"; import TweetResolver from "./resolvers/TweetResolver"; import RootResolver from "./resolvers/RootResolver"; +import { GqlCtx } from "./GqlCtx"; /** @gqlEnum */ export type TweetsSortOption = "LIKES" | "RETWEETS"; @@ -45,7 +46,10 @@ export default class TweetsConnection { * The list of tweets * @gqlField */ - async nodes(args: never, ctx): Promise> { + async nodes( + args: unknown, + ctx: GqlCtx + ): Promise> { const tweets = await this._getQuery() .select() .limit(this._first) diff --git a/packages/skin-database/api/graphql/index.ts b/packages/skin-database/api/graphql/index.ts index 9a9ed6af..cc0644ce 100644 --- a/packages/skin-database/api/graphql/index.ts +++ b/packages/skin-database/api/graphql/index.ts @@ -1,14 +1,8 @@ import { Router } from "express"; import { graphqlHTTP } from "express-graphql"; -import RootResolver from "./resolvers/RootResolver"; import DEFAULT_QUERY from "./defaultQuery"; -import { buildSchema } from "graphql"; -import fs from "fs"; -import path from "path"; - -const schemaPath = path.join(__dirname, "./schema.graphql"); -const schema = buildSchema(fs.readFileSync(schemaPath, "utf8")); +import { schema } from "./schema"; const router = Router(); @@ -44,8 +38,7 @@ const extensions = ({ router.use( "/", graphqlHTTP({ - schema: schema, - rootValue: new RootResolver(), + schema, graphiql: { defaultQuery: DEFAULT_QUERY, }, diff --git a/packages/skin-database/api/graphql/resolvers/ArchiveFileResolver.ts b/packages/skin-database/api/graphql/resolvers/ArchiveFileResolver.ts index 67e06c7b..3ba48106 100644 --- a/packages/skin-database/api/graphql/resolvers/ArchiveFileResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/ArchiveFileResolver.ts @@ -4,6 +4,7 @@ import SkinModel from "../../../data/SkinModel"; import { ISkin } from "./CommonSkinResolver"; import SkinResolver from "./SkinResolver"; import RootResolver from "./RootResolver"; +import { GqlCtx } from "../GqlCtx"; /** * A file found within a Winamp Skin's .wsz archive @@ -66,7 +67,7 @@ export default class ArchiveFileResolver { * The skin in which this file was found * @gqlField */ - async skin(_: never, { ctx }): Promise { + async skin(_: unknown, { ctx }: GqlCtx): Promise { const model = await SkinModel.fromMd5Assert(ctx, this._model.getMd5()); return SkinResolver.fromModel(model); } @@ -90,7 +91,7 @@ export default class ArchiveFileResolver { export async function fetch_archive_file_by_md5( _: RootResolver, { md5 }: { md5: string }, - { ctx } + { ctx }: GqlCtx ): Promise { const archiveFile = await ArchiveFileModel.fromFileMd5(ctx, md5); if (archiveFile == null) { diff --git a/packages/skin-database/api/graphql/resolvers/InternetArchiveItemResolver.ts b/packages/skin-database/api/graphql/resolvers/InternetArchiveItemResolver.ts index 804b4a5f..cae714ee 100644 --- a/packages/skin-database/api/graphql/resolvers/InternetArchiveItemResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/InternetArchiveItemResolver.ts @@ -1,4 +1,5 @@ import IaItemModel from "../../../data/IaItemModel"; +import { GqlCtx } from "../GqlCtx"; import { ISkin } from "./CommonSkinResolver"; import RootResolver from "./RootResolver"; import SkinResolver from "./SkinResolver"; @@ -70,7 +71,7 @@ export default class InternetArchiveItemResolver { export async function fetch_internet_archive_item_by_identifier( _: RootResolver, { identifier }: { identifier: string }, - { ctx } + { ctx }: GqlCtx ): Promise { const iaItem = await IaItemModel.fromIdentifier(ctx, identifier); if (iaItem == null) { diff --git a/packages/skin-database/api/graphql/resolvers/ModernSkinResolver.ts b/packages/skin-database/api/graphql/resolvers/ModernSkinResolver.ts index c3e43b3c..88e326d8 100644 --- a/packages/skin-database/api/graphql/resolvers/ModernSkinResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/ModernSkinResolver.ts @@ -10,6 +10,7 @@ import ArchiveFileResolver from "./ArchiveFileResolver"; import TweetResolver from "./TweetResolver"; import { XMLParser } from "fast-xml-parser"; import RootResolver from "./RootResolver"; +import { GqlCtx } from "../GqlCtx"; /** * A "modern" Winamp skin. These skins use the `.wal` file extension and are free-form. @@ -179,7 +180,7 @@ export default class ModernSkinResolver export async function fetch_skin_by_md5( _: RootResolver, { md5 }: { md5: string }, - { ctx } + { ctx }: GqlCtx ): Promise { const skin = await SkinModel.fromMd5(ctx, md5); if (skin == null) { diff --git a/packages/skin-database/api/graphql/resolvers/MutationResolver.ts b/packages/skin-database/api/graphql/resolvers/MutationResolver.ts index 85a8abb2..2bc3d2fa 100644 --- a/packages/skin-database/api/graphql/resolvers/MutationResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/MutationResolver.ts @@ -1,85 +1,6 @@ -import * as Parallel from "async-parallel"; -import SkinModel from "../../../data/SkinModel"; -import * as S3 from "../../../s3"; -import * as Skins from "../../../data/skins"; -import { processUserUploads } from "../../processUserUploads"; +import { GqlCtx } from "../GqlCtx"; -// We don't use a resolver here, just return the value directly. -/** - * A URL that the client can use to upload a skin to S3, and then notify the server - * when they're done. - * @gqlType - */ -type UploadUrl = { - /** @gqlField */ - id: string; - /** @gqlField */ - url: string; - /** @gqlField */ - md5: string; -}; - -/** - * Input object used for a user to request an UploadUrl - * @gqlInput - */ -type UploadUrlRequest = { filename: string; md5: string }; - -/** - * Mutations for the upload flow - * - * 1. The user finds the md5 hash of their local files. - * 2. (`get_upload_urls`) The user requests upload URLs for each of their files. - * 3. The server returns upload URLs for each of their files which are not already in the collection. - * 4. The user uploads each of their files to the URLs returned in step 3. - * 5. (`report_skin_uploaded`) The user notifies the server that they're done uploading. - * 6. (TODO) The user polls for the status of their uploads. - * - * @gqlType UploadMutations */ -class UploadMutationResolver { - /** - * Get a (possibly incompelte) list of UploadUrls for each of the files. If an - * UploadUrl is not returned for a given hash, it means the file is already in - * the collection. - * @gqlField - */ - async get_upload_urls( - { files }: { files: UploadUrlRequest[] }, - { ctx } - ): Promise> { - const missing: UploadUrl[] = []; - await Parallel.each( - files, - async ({ md5, filename }) => { - if (!(await SkinModel.exists(ctx, md5))) { - const id = await Skins.recordUserUploadRequest(md5, filename); - const url = S3.getSkinUploadUrl(md5, id); - missing.push({ id, url, md5 }); - } - }, - 5 - ); - - return missing; - } - - /** - * Notify the server that the user is done uploading. - * @gqlField - */ - async report_skin_uploaded( - { id, md5 }: { id: string; md5: string }, - req - ): Promise { - // TODO: Validate md5 and id; - await Skins.recordUserUploadComplete(md5, id); - // Don't await, just kick off the task. - processUserUploads(req.notify); - return true; - } -} - -function requireAuthed(handler) { +export function requireAuthed(handler) { return (args, req) => { if (!req.ctx.authed()) { throw new Error("You must be logged in to read this field."); @@ -89,105 +10,22 @@ function requireAuthed(handler) { }; } +/** @gqlType Mutation */ +export type MutationResolver = unknown; + /** - * - * @gqlType Mutation */ -export default class MutationResolver { - /** - * Mutations for the upload flow - * @gqlField */ - async upload(): Promise { - return new UploadMutationResolver(); - } - /** - * Send a message to the admin of the site. Currently this appears in Discord. - * @gqlField */ - async send_feedback( - { message, email, url }: { message: string; email?: string; url?: string }, - req - ): Promise { - req.notify({ - type: "GOT_FEEDBACK", - url, - message, - email, - }); - return true; - } - - /** - * Reject skin for tweeting - * - * **Note:** Requires being logged in - * @gqlField */ - reject_skin(args: { md5: string }, req): Promise { - return this._reject_skin(args, req); - } - - _reject_skin = requireAuthed(async ({ md5 }, req) => { - req.log(`Rejecting skin with hash "${md5}"`); - const skin = await SkinModel.fromMd5Assert(req.ctx, md5); - if (skin == null) { - return false; - } - await Skins.reject(req.ctx, md5); - req.notify({ type: "REJECTED_SKIN", md5 }); - return true; + * Send a message to the admin of the site. Currently this appears in Discord. + * @gqlField */ +export async function send_feedback( + _: MutationResolver, + { message, email, url }: { message: string; email?: string; url?: string }, + req: GqlCtx +): Promise { + req.notify({ + type: "GOT_FEEDBACK", + url, + message, + email, }); - - /** - * Approve skin for tweeting - * - * **Note:** Requires being logged in - * @gqlField */ - approve_skin(args: { md5: string }, req): Promise { - return this._approve_skin(args, req); - } - - _approve_skin = requireAuthed(async ({ md5 }, req) => { - req.log(`Approving skin with hash "${md5}"`); - const skin = await SkinModel.fromMd5(req.ctx, md5); - if (skin == null) { - return false; - } - await Skins.approve(req.ctx, md5); - req.notify({ type: "APPROVED_SKIN", md5 }); - return true; - }); - - /** - * Mark a skin as NSFW - * - * **Note:** Requires being logged in - * @gqlField */ - mark_skin_nsfw(args: { md5: string }, req): Promise { - return this._mark_skin_nsfw(args, req); - } - - _mark_skin_nsfw = requireAuthed(async ({ md5 }, req) => { - req.log(`Approving skin with hash "${md5}"`); - const skin = await SkinModel.fromMd5(req.ctx, md5); - if (skin == null) { - return false; - } - await Skins.markAsNSFW(req.ctx, md5); - req.notify({ type: "MARKED_SKIN_NSFW", md5 }); - return true; - }); - - /** - * Request that an admin check if this skin is NSFW. - * Unlike other review mutaiton endpoints, this one does not require being logged - * in. - * @gqlField */ - async request_nsfw_review_for_skin( - { md5 }: { md5: string }, - req - ): Promise { - req.log(`Reporting skin with hash "${md5}"`); - // Blow up if there is no skin with this hash - await SkinModel.fromMd5Assert(req.ctx, md5); - req.notify({ type: "REVIEW_REQUESTED", md5 }); - return true; - } + return true; } diff --git a/packages/skin-database/api/graphql/resolvers/NodeResolver.ts b/packages/skin-database/api/graphql/resolvers/NodeResolver.ts index 75350d56..8bdbfc28 100644 --- a/packages/skin-database/api/graphql/resolvers/NodeResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/NodeResolver.ts @@ -3,6 +3,7 @@ import { ID } from "grats"; import SkinModel from "../../../data/SkinModel"; import SkinResolver from "../resolvers/SkinResolver"; import RootResolver from "./RootResolver"; +import { GqlCtx } from "../GqlCtx"; /** * A globally unique object. The `id` here is intended only for use within @@ -29,7 +30,7 @@ export interface NodeResolver { export async function node( _: RootResolver, { id }: { id: ID }, - { ctx } + { ctx }: GqlCtx ): Promise { const { graphqlType, id: localId } = fromId(id); // TODO Use typeResolver diff --git a/packages/skin-database/api/graphql/resolvers/ReviewResolver.ts b/packages/skin-database/api/graphql/resolvers/ReviewResolver.ts index a91196e6..07d6e93e 100644 --- a/packages/skin-database/api/graphql/resolvers/ReviewResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/ReviewResolver.ts @@ -1,10 +1,11 @@ import { Rating, ReviewRow } from "../../../types"; +import { GqlCtx } from "../GqlCtx"; import { ISkin } from "./CommonSkinResolver"; import SkinResolver from "./SkinResolver"; /** * A review of a skin. Done either on the Museum's Tinder-style - * reivew page, or via the Discord bot. + * review page, or via the Discord bot. * @gqlType Review */ export default class ReviewResolver { _model: ReviewRow; @@ -16,7 +17,7 @@ export default class ReviewResolver { * The skin that was reviewed * @gqlField */ - skin(args: never, { ctx }): Promise { + skin(args: unknown, { ctx }: GqlCtx): Promise { return SkinResolver.fromMd5(ctx, this._model.skin_md5); } diff --git a/packages/skin-database/api/graphql/resolvers/RootResolver.ts b/packages/skin-database/api/graphql/resolvers/RootResolver.ts index 90dc10f1..4b63d4c3 100644 --- a/packages/skin-database/api/graphql/resolvers/RootResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/RootResolver.ts @@ -1,7 +1,4 @@ -import MutationResolver from "./MutationResolver"; -import { ISkin } from "./CommonSkinResolver"; - /** @gqlType Query */ -class RootResolver extends MutationResolver {} +type RootResolver = unknown; export default RootResolver; diff --git a/packages/skin-database/api/graphql/resolvers/SkinResolver.ts b/packages/skin-database/api/graphql/resolvers/SkinResolver.ts index ab929464..eb5f0a4a 100644 --- a/packages/skin-database/api/graphql/resolvers/SkinResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/SkinResolver.ts @@ -8,6 +8,8 @@ import RootResolver from "./RootResolver"; import { Int } from "grats"; import algoliasearch from "algoliasearch"; +import { requireAuthed, MutationResolver } from "./MutationResolver"; +import { GqlCtx } from "../GqlCtx"; // These keys are already in the web client, so they are not secret at all. const client = algoliasearch("HQ9I5Z6IM5", "6466695ec3f624a5fccf46ec49680e51"); @@ -46,7 +48,7 @@ export async function search_skins( first = 10, offset = 0, }: { query: string; first?: Int; offset?: Int }, - { ctx } + { ctx }: GqlCtx ): Promise> { if (first > 1000) { throw new Error("Can only query 1000 records via search."); @@ -71,8 +73,8 @@ export async function search_skins( * @gqlField */ export async function skin_to_review( _: RootResolver, - _args: never, - { ctx } + _args: unknown, + { ctx }: GqlCtx ): Promise { if (!ctx.authed()) { return null; @@ -81,3 +83,92 @@ export async function skin_to_review( const model = await SkinModel.fromMd5Assert(ctx, md5); return SkinResolver.fromModel(model); } + +/** + * Request that an admin check if this skin is NSFW. + * Unlike other review mutation endpoints, this one does not require being logged + * in. + * @gqlField */ +export async function request_nsfw_review_for_skin( + _: MutationResolver, + { md5 }: { md5: string }, + req: GqlCtx +): Promise { + req.log(`Reporting skin with hash "${md5}"`); + // Blow up if there is no skin with this hash + await SkinModel.fromMd5Assert(req.ctx, md5); + req.notify({ type: "REVIEW_REQUESTED", md5 }); + return true; +} + +/** + * Mark a skin as NSFW + * + * **Note:** Requires being logged in + * @gqlField */ +export function mark_skin_nsfw( + _: MutationResolver, + args: { md5: string }, + req: GqlCtx +): Promise { + return _mark_skin_nsfw(args, req); +} + +const _mark_skin_nsfw = requireAuthed(async ({ md5 }, req) => { + req.log(`Approving skin with hash "${md5}"`); + const skin = await SkinModel.fromMd5(req.ctx, md5); + if (skin == null) { + return false; + } + await Skins.markAsNSFW(req.ctx, md5); + req.notify({ type: "MARKED_SKIN_NSFW", md5 }); + return true; +}); + +/** + * Reject skin for tweeting + * + * **Note:** Requires being logged in + * @gqlField */ +export function reject_skin( + _: MutationResolver, + args: { md5: string }, + req: GqlCtx +): Promise { + return _reject_skin(args, req); +} + +const _reject_skin = requireAuthed(async ({ md5 }, req) => { + req.log(`Rejecting skin with hash "${md5}"`); + const skin = await SkinModel.fromMd5Assert(req.ctx, md5); + if (skin == null) { + return false; + } + await Skins.reject(req.ctx, md5); + req.notify({ type: "REJECTED_SKIN", md5 }); + return true; +}); + +/** + * Approve skin for tweeting + * + * **Note:** Requires being logged in + * @gqlField */ +export function approve_skin( + _: MutationResolver, + args: { md5: string }, + req: GqlCtx +): Promise { + return _approve_skin(args, req); +} + +const _approve_skin = requireAuthed(async ({ md5 }, req) => { + req.log(`Approving skin with hash "${md5}"`); + const skin = await SkinModel.fromMd5(req.ctx, md5); + if (skin == null) { + return false; + } + await Skins.approve(req.ctx, md5); + req.notify({ type: "APPROVED_SKIN", md5 }); + return true; +}); diff --git a/packages/skin-database/api/graphql/resolvers/SkinUploadResolver.ts b/packages/skin-database/api/graphql/resolvers/SkinUploadResolver.ts index 7da07446..dfc36415 100644 --- a/packages/skin-database/api/graphql/resolvers/SkinUploadResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/SkinUploadResolver.ts @@ -3,6 +3,7 @@ import SkinResolver from "../resolvers/SkinResolver"; import { knex } from "../../../db"; import { ISkin } from "./CommonSkinResolver"; import RootResolver from "./RootResolver"; +import { GqlCtx } from "../GqlCtx"; /** * Information about an attempt to upload a skin to the Museum. @@ -58,7 +59,7 @@ type SkinUploadStatus = export async function upload_statuses_by_md5( _: RootResolver, { md5s }: { md5s: string[] }, - { ctx } + { ctx }: GqlCtx ): Promise> { return _upload_statuses({ keyName: "skin_md5", keys: md5s }, ctx); } @@ -69,7 +70,7 @@ export async function upload_statuses_by_md5( export async function upload_statuses( _: RootResolver, { ids }: { ids: string[] }, - { ctx } + { ctx }: GqlCtx ): Promise> { return _upload_statuses({ keyName: "id", keys: ids }, ctx); } diff --git a/packages/skin-database/api/graphql/resolvers/TweetResolver.ts b/packages/skin-database/api/graphql/resolvers/TweetResolver.ts index e59ecab5..eacbd93a 100644 --- a/packages/skin-database/api/graphql/resolvers/TweetResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/TweetResolver.ts @@ -3,6 +3,7 @@ import TweetModel from "../../../data/TweetModel"; import { ISkin } from "./CommonSkinResolver"; import SkinResolver from "./SkinResolver"; import RootResolver from "./RootResolver"; +import { GqlCtx } from "../GqlCtx"; /** * A tweet made by @winampskins mentioning a Winamp skin @@ -57,7 +58,7 @@ export default class TweetResolver { export async function fetch_tweet_by_url( _: RootResolver, { url }: { url: string }, - { ctx } + { ctx }: GqlCtx ): Promise { const tweet = await TweetModel.fromAnything(ctx, url); if (tweet == null) { diff --git a/packages/skin-database/api/graphql/resolvers/UploadMutationResolver.ts b/packages/skin-database/api/graphql/resolvers/UploadMutationResolver.ts new file mode 100644 index 00000000..1428781e --- /dev/null +++ b/packages/skin-database/api/graphql/resolvers/UploadMutationResolver.ts @@ -0,0 +1,91 @@ +import * as Parallel from "async-parallel"; +import SkinModel from "../../../data/SkinModel"; +import * as S3 from "../../../s3"; +import * as Skins from "../../../data/skins"; +import { MutationResolver } from "./MutationResolver"; +import { processUserUploads } from "../../processUserUploads"; +import { GqlCtx } from "../GqlCtx"; + +// We don't use a resolver here, just return the value directly. +/** + * A URL that the client can use to upload a skin to S3, and then notify the server + * when they're done. + * @gqlType + */ +type UploadUrl = { + /** @gqlField */ + id: string; + /** @gqlField */ + url: string; + /** @gqlField */ + md5: string; +}; + +/** + * Input object used for a user to request an UploadUrl + * @gqlInput + */ +type UploadUrlRequest = { filename: string; md5: string }; + +/** + * Mutations for the upload flow + * + * 1. The user finds the md5 hash of their local files. + * 2. (`get_upload_urls`) The user requests upload URLs for each of their files. + * 3. The server returns upload URLs for each of their files which are not already in the collection. + * 4. The user uploads each of their files to the URLs returned in step 3. + * 5. (`report_skin_uploaded`) The user notifies the server that they're done uploading. + * 6. (TODO) The user polls for the status of their uploads. + * + * @gqlType UploadMutations */ +class UploadMutationResolver { + /** + * Get a (possibly incomplete) list of UploadUrls for each of the files. If an + * UploadUrl is not returned for a given hash, it means the file is already in + * the collection. + * @gqlField + */ + async get_upload_urls( + { files }: { files: UploadUrlRequest[] }, + { ctx }: GqlCtx + ): Promise> { + const missing: UploadUrl[] = []; + await Parallel.each( + files, + async ({ md5, filename }) => { + if (!(await SkinModel.exists(ctx, md5))) { + const id = await Skins.recordUserUploadRequest(md5, filename); + const url = S3.getSkinUploadUrl(md5, id); + missing.push({ id, url, md5 }); + } + }, + 5 + ); + + return missing; + } + + /** + * Notify the server that the user is done uploading. + * @gqlField + */ + async report_skin_uploaded( + { id, md5 }: { id: string; md5: string }, + req: GqlCtx + ): Promise { + // TODO: Validate md5 and id; + await Skins.recordUserUploadComplete(md5, id); + // Don't await, just kick off the task. + processUserUploads(req.notify); + return true; + } +} + +/** + * Mutations for the upload flow + * @gqlField */ +export async function upload( + _: MutationResolver +): Promise { + return new UploadMutationResolver(); +} diff --git a/packages/skin-database/api/graphql/resolvers/UserResolver.ts b/packages/skin-database/api/graphql/resolvers/UserResolver.ts index f3f50bad..8fd82025 100644 --- a/packages/skin-database/api/graphql/resolvers/UserResolver.ts +++ b/packages/skin-database/api/graphql/resolvers/UserResolver.ts @@ -1,9 +1,10 @@ +import { GqlCtx } from "../GqlCtx"; import RootResolver from "./RootResolver"; /** @gqlType User */ export default class UserResolver { /** @gqlField */ - username(_args: never, { ctx }): string { + username(_args: unknown, { ctx }: GqlCtx): string | null { return ctx.username; } } diff --git a/packages/skin-database/api/graphql/schema.graphql b/packages/skin-database/api/graphql/schema.graphql index a2d63337..94ac655e 100644 --- a/packages/skin-database/api/graphql/schema.graphql +++ b/packages/skin-database/api/graphql/schema.graphql @@ -1,12 +1,6 @@ -schema { - query: Query - mutation: Mutation -} - -directive @exported(filename: String!, functionName: String!) on FIELD_DEFINITION - -directive @methodName(name: String!) on FIELD_DEFINITION - +# Schema generated by Grats (https://grats.capt.dev) +# Do not manually edit. Regenerate by running `npx grats`. +# @generated """A file found within a Winamp Skin's .wsz archive""" type ArchiveFile { """ @@ -14,22 +8,29 @@ type ArchiveFile { format (ISO 8601). """ date: String + """The md5 hash of the file within the archive""" file_md5: String + """Filename of the file within the archive""" filename: String + """Is the file a directory?""" is_directory: Boolean + """ The uncompressed size of the file in bytes. **Note:** Will be `null` for directories """ size: Int + """The skin in which this file was found""" skin: Skin + """The content of the file, if it's a text file""" text_content: String + """ A URL to download the file. **Note:** This is powered by a little serverless Cloudflare function which tries to exctact the file on the fly. @@ -42,10 +43,13 @@ type ArchiveFile { type ClassicSkin implements Node & Skin { """List of files contained within the skin's .wsz archive""" archive_files: [ArchiveFile] + """String representation (rgb usually) of the skin's average color""" average_color: String + """URL to download the skin""" download_url: String + """ Filename of skin when uploaded to the Museum. Note: In some cases a skin has been uploaded under multiple names. Here we just pick one. @@ -57,38 +61,52 @@ type ClassicSkin implements Node & Skin { """ normalize_extension: Boolean = false ): String + """Does the skin include sprite sheets for the media library?""" has_media_library: Boolean + """GraphQL ID of the skin""" id: ID! + """The skin's "item" at archive.org""" internet_archive_item: InternetArchiveItem + """ The date on which this skin was last updated in the Algolia search index. Given in simplified extended ISO format (ISO 8601). """ last_algolia_index_update_date: String + """MD5 hash of the skin's file""" md5: String + """URL of the skin on the Winamp Skin Museum""" museum_url: String + """Has the skin been flagged as "not safe for wrok"?""" nsfw: Boolean + """Text of the readme file extracted from the skin""" readme_text: String + """ Times that the skin has been reviewed either on the Museum's Tinder-style reivew page, or via the Discord bot. """ reviews: [Review] + """URL of a screenshot of the skin""" screenshot_url: String + """The number of transparent pixels rendered by the skin.""" transparent_pixels: Int + """Has the skin been tweeted?""" tweeted: Boolean + """List of @winampskins tweets that mentioned the skin.""" tweets: [Tweet] + """URL of webamp.org with the skin loaded""" webamp_url: String } @@ -102,6 +120,7 @@ type DatabaseStatistics { **Note:** Skins can be both approved and rejected by different users. """ approved_skins_count: Int + """ The number of skins that have been marked as NSFW. @@ -109,6 +128,7 @@ type DatabaseStatistics { **Note:** Generally skins that have been marked NSFW are also marked as rejected. """ nsfw_skins_count: Int + """ The number of skins that have been rejected for tweeting. @@ -116,26 +136,33 @@ type DatabaseStatistics { **Note:** Generally skins that have been marked NSFW are also marked as rejected. """ rejected_skins_count: Int + """ The number of skins that have been approved for tweeting, but not yet tweeted. """ tweetable_skins_count: Int + """ The number of skins in the Museum that have been tweeted by @winampskins """ tweeted_skins_count: Int + """The total number of classic skins in the Museum's database""" unique_classic_skins_count: Int + """The number of skins that have never been reviewed.""" unreviewed_skins_count: Int + """Skins uploads that have errored during processing.""" uploads_in_error_state_count: Int + """ Skins uplaods awaiting processing. This can happen when there are a large number of skin uplaods at the same time, or when the skin uploading processing pipeline gets stuck. """ uploads_pending_processing_count: Int + """ Number of skins that have been uploaded to the Museum via the web interface. """ @@ -145,20 +172,25 @@ type DatabaseStatistics { type InternetArchiveItem { """The Internet Archive's unique identifier for this item""" identifier: String + """ The date and time that we last scraped this item's metadata. **Note:** This field is temporary and will be removed in the future. The date format is just what we get from the database, and it's ambiguous. """ last_metadata_scrape_date_UNSTABLE: String + """URL to get the Internet Archive's metadata for this item in JSON form.""" metadata_url: String + """ Our cached version of the metadata avaliable at \`metadata_url\` (above) """ raw_metadata_json: String + """The skin that this item contains""" skin: Skin + """The URL where this item can be viewed on the Internet Archive""" url: String } @@ -173,8 +205,10 @@ type ModernSkin implements Node & Skin { """List of files contained within the skin's .wsz archive""" archive_files: [ArchiveFile] average_color: String @deprecated(reason: "Needed for migration") + """URL to download the skin""" download_url: String + """ Filename of skin when uploaded to the Museum. Note: In some cases a skin has been uploaded under multiple names. Here we just pick one. @@ -186,23 +220,29 @@ type ModernSkin implements Node & Skin { """ normalize_extension: Boolean = false ): String + """GraphQL ID of the skin""" id: ID! + """The skin's "item" at archive.org""" internet_archive_item: InternetArchiveItem + """MD5 hash of the skin's file""" md5: String museum_url: String @deprecated(reason: "Needed for migration") nsfw: Boolean @deprecated(reason: "Needed for migration") readme_text: String @deprecated(reason: "Needed for migration") + """ Times that the skin has been reviewed either on the Museum's Tinder-style reivew page, or via the Discord bot. """ reviews: [Review] screenshot_url: String + """Has the skin been tweeted?""" tweeted: Boolean + """List of @winampskins tweets that mentioned the skin.""" tweets: [Tweet] webamp_url: String @deprecated(reason: "Needed for migration") @@ -212,6 +252,7 @@ type ModernSkin implements Node & Skin { type ModernSkinsConnection { """The total number of skins matching the filter""" count: Int + """The list of skins""" nodes: [ModernSkin] } @@ -223,28 +264,33 @@ type Mutation { **Note:** Requires being logged in """ approve_skin(md5: String!): Boolean + """ Mark a skin as NSFW **Note:** Requires being logged in """ mark_skin_nsfw(md5: String!): Boolean + """ Reject skin for tweeting **Note:** Requires being logged in """ reject_skin(md5: String!): Boolean + """ Request that an admin check if this skin is NSFW. - Unlike other review mutaiton endpoints, this one does not require being logged + Unlike other review mutation endpoints, this one does not require being logged in. """ request_nsfw_review_for_skin(md5: String!): Boolean + """ Send a message to the admin of the site. Currently this appears in Discord. """ send_feedback(email: String, message: String!, url: String): Boolean + """Mutations for the upload flow""" upload: UploadMutations } @@ -264,49 +310,62 @@ type Query { Get information about a file found within a skin's wsz/wal/zip archive. """ - fetch_archive_file_by_md5(md5: String!): ArchiveFile @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/ArchiveFileResolver.js", functionName: "fetch_archive_file_by_md5") + fetch_archive_file_by_md5(md5: String!): ArchiveFile + """ Get an archive.org item by its identifier. You can find this in the URL: https://archive.org/details// """ - fetch_internet_archive_item_by_identifier(identifier: String!): InternetArchiveItem @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/InternetArchiveItemResolver.js", functionName: "fetch_internet_archive_item_by_identifier") + fetch_internet_archive_item_by_identifier(identifier: String!): InternetArchiveItem + """Get a skin by its MD5 hash""" - fetch_skin_by_md5(md5: String!): Skin @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/ModernSkinResolver.js", functionName: "fetch_skin_by_md5") + fetch_skin_by_md5(md5: String!): Skin + """Get a tweet by its URL""" - fetch_tweet_by_url(url: String!): Tweet @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/TweetResolver.js", functionName: "fetch_tweet_by_url") + fetch_tweet_by_url(url: String!): Tweet + """The currently authenticated user, if any.""" - me: User @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/UserResolver.js", functionName: "me") + me: User + """All modern skins in the database""" - modern_skins(first: Int = 10, offset: Int = 0): ModernSkinsConnection @exported(filename: "../../../../packages/skin-database/dist/api/graphql/ModernSkinsConnection.js", functionName: "modern_skins") + modern_skins(first: Int = 10, offset: Int = 0): ModernSkinsConnection + """ Get a globally unique object by its ID. https://graphql.org/learn/global-object-identification/ """ - node(id: ID!): Node @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/NodeResolver.js", functionName: "node") + node(id: ID!): Node + """ Search the database using the Algolia search index used by the Museum. Useful for locating a particular skin. """ - search_skins(first: Int = 10, offset: Int = 0, query: String!): [Skin] @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/SkinResolver.js", functionName: "search_skins") + search_skins(first: Int = 10, offset: Int = 0, query: String!): [Skin] + """A random skin that needs to be reviewed""" - skin_to_review: Skin @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/SkinResolver.js", functionName: "skin_to_review") + skin_to_review: Skin + """ All classic skins in the database **Note:** We don't currently support combining sorting and filtering. """ - skins(filter: SkinsFilterOption, first: Int = 10, offset: Int = 0, sort: SkinsSortOption): SkinsConnection @exported(filename: "../../../../packages/skin-database/dist/api/graphql/SkinsConnection.js", functionName: "skins") + skins(filter: SkinsFilterOption, first: Int = 10, offset: Int = 0, sort: SkinsSortOption): SkinsConnection + """A namespace for statistics about the database""" - statistics: DatabaseStatistics @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/DatabaseStatisticsResolver.js", functionName: "statistics") + statistics: DatabaseStatistics + """Tweets tweeted by @winampskins""" - tweets(first: Int = 10, offset: Int = 0, sort: TweetsSortOption): TweetsConnection @exported(filename: "../../../../packages/skin-database/dist/api/graphql/TweetsConnection.js", functionName: "tweets") + tweets(first: Int = 10, offset: Int = 0, sort: TweetsSortOption): TweetsConnection + """Get the status of a batch of uploads by ids""" - upload_statuses(ids: [String!]!): [SkinUpload] @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/SkinUploadResolver.js", functionName: "upload_statuses") + upload_statuses(ids: [String!]!): [SkinUpload] + """Get the status of a batch of uploads by md5s""" - upload_statuses_by_md5(md5s: [String!]!): [SkinUpload] @deprecated(reason: "Prefer `upload_statuses` instead, were we operate on ids.") @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/SkinUploadResolver.js", functionName: "upload_statuses_by_md5") + upload_statuses_by_md5(md5s: [String!]!): [SkinUpload] @deprecated(reason: "Prefer `upload_statuses` instead, were we operate on ids.") } """The judgement made about a skin by a moderator""" @@ -318,16 +377,18 @@ enum Rating { """ A review of a skin. Done either on the Museum's Tinder-style -reivew page, or via the Discord bot. +review page, or via the Discord bot. """ type Review { """The rating that the user gave the skin""" rating: Rating + """ The user who made the review (if known). **Note:** In the early days we didn't track this, so many will be null. """ reviewer: String + """The skin that was reviewed""" skin: Skin } @@ -341,8 +402,10 @@ interface Skin { """List of files contained within the skin's .wsz archive""" archive_files: [ArchiveFile] average_color: String @deprecated(reason: "Needed for migration") + """URL to download the skin""" download_url: String + """ Filename of skin when uploaded to the Museum. Note: In some cases a skin has been uploaded under multiple names. Here we just pick one. @@ -354,23 +417,29 @@ interface Skin { """ normalize_extension: Boolean = false ): String + """GraphQL ID of the skin""" id: ID! + """The skin's "item" at archive.org""" internet_archive_item: InternetArchiveItem + """MD5 hash of the skin's file""" md5: String museum_url: String @deprecated(reason: "Needed for migration") nsfw: Boolean @deprecated(reason: "Needed for migration") readme_text: String @deprecated(reason: "Needed for migration") + """ Times that the skin has been reviewed either on the Museum's Tinder-style reivew page, or via the Discord bot. """ reviews: [Review] screenshot_url: String + """Has the skin been tweeted?""" tweeted: Boolean + """List of @winampskins tweets that mentioned the skin.""" tweets: [Tweet] webamp_url: String @deprecated(reason: "Needed for migration") @@ -379,12 +448,14 @@ interface Skin { """Information about an attempt to upload a skin to the Museum.""" type SkinUpload { id: String + """ Skin that was uploaded. **Note:** This is null if the skin has not yet been fully processed. (status == ARCHIVED) """ skin: Skin status: SkinUploadStatus + """Md5 hash given when requesting the upload URL.""" upload_md5: String } @@ -407,6 +478,7 @@ enum SkinUploadStatus { type SkinsConnection { """The total number of skins matching the filter""" count: Int + """The list of skins""" nodes: [Skin] } @@ -428,12 +500,15 @@ type Tweet { Number of likes the tweet has received. Updated nightly. (Note: Recent likes on older tweets may not be reflected here) """ likes: Int + """ Number of retweets the tweet has received. Updated nightly. (Note: Recent retweets on older tweets may not be reflected here) """ retweets: Int + """The skin featured in this Tweet""" skin: Skin + """ URL of the tweet. **Note:** Early on in the bot's life we just recorded _which_ skins were tweeted, not any info about the actual tweet. This means we @@ -446,6 +521,7 @@ type Tweet { type TweetsConnection { """The total number of tweets""" count: Int + """The list of tweets""" nodes: [Tweet] } @@ -467,11 +543,12 @@ Mutations for the upload flow """ type UploadMutations { """ - Get a (possibly incompelte) list of UploadUrls for each of the files. If an + Get a (possibly incomplete) list of UploadUrls for each of the files. If an UploadUrl is not returned for a given hash, it means the file is already in the collection. """ get_upload_urls(files: [UploadUrlRequest!]!): [UploadUrl] + """Notify the server that the user is done uploading.""" report_skin_uploaded(id: String!, md5: String!): Boolean } diff --git a/packages/skin-database/api/graphql/schema.ts b/packages/skin-database/api/graphql/schema.ts new file mode 100644 index 00000000..2cc34bfb --- /dev/null +++ b/packages/skin-database/api/graphql/schema.ts @@ -0,0 +1,1071 @@ +/** + * Executable schema generated by Grats (https://grats.capt.dev) + * Do not manually edit. Regenerate by running `npx grats`. + * @generated + */ +import { node as queryNodeResolver } from "./resolvers/NodeResolver"; +import { fetch_tweet_by_url as queryFetch_tweet_by_urlResolver } from "./resolvers/TweetResolver"; +import { fetch_skin_by_md5 as queryFetch_skin_by_md5Resolver } from "./resolvers/ModernSkinResolver"; +import { search_skins as querySearch_skinsResolver } from "./resolvers/SkinResolver"; +import { skin_to_review as querySkin_to_reviewResolver } from "./resolvers/SkinResolver"; +import { fetch_internet_archive_item_by_identifier as queryFetch_internet_archive_item_by_identifierResolver } from "./resolvers/InternetArchiveItemResolver"; +import { fetch_archive_file_by_md5 as queryFetch_archive_file_by_md5Resolver } from "./resolvers/ArchiveFileResolver"; +import { modern_skins as queryModern_skinsResolver } from "./ModernSkinsConnection"; +import { skins as querySkinsResolver } from "./SkinsConnection"; +import { tweets as queryTweetsResolver } from "./TweetsConnection"; +import { statistics as queryStatisticsResolver } from "./resolvers/DatabaseStatisticsResolver"; +import { upload_statuses_by_md5 as queryUpload_statuses_by_md5Resolver } from "./resolvers/SkinUploadResolver"; +import { upload_statuses as queryUpload_statusesResolver } from "./resolvers/SkinUploadResolver"; +import { me as queryMeResolver } from "./resolvers/UserResolver"; +import { send_feedback as mutationSend_feedbackResolver } from "./resolvers/MutationResolver"; +import { request_nsfw_review_for_skin as mutationRequest_nsfw_review_for_skinResolver } from "./resolvers/SkinResolver"; +import { mark_skin_nsfw as mutationMark_skin_nsfwResolver } from "./resolvers/SkinResolver"; +import { reject_skin as mutationReject_skinResolver } from "./resolvers/SkinResolver"; +import { approve_skin as mutationApprove_skinResolver } from "./resolvers/SkinResolver"; +import { upload as mutationUploadResolver } from "./resolvers/UploadMutationResolver"; +import { GraphQLSchema, GraphQLObjectType, GraphQLInterfaceType, GraphQLNonNull, GraphQLID, GraphQLString, GraphQLInt, GraphQLBoolean, GraphQLList, GraphQLEnumType, GraphQLInputObjectType } from "graphql"; +const NodeType: GraphQLInterfaceType = new GraphQLInterfaceType({ + description: "A globally unique object. The `id` here is intended only for use within\nGraphQL.\nhttps://graphql.org/learn/global-object-identification/", + name: "Node", + fields() { + return { + id: { + name: "id", + type: new GraphQLNonNull(GraphQLID) + } + }; + } +}); +const ArchiveFileType: GraphQLObjectType = new GraphQLObjectType({ + name: "ArchiveFile", + description: "A file found within a Winamp Skin's .wsz archive", + fields() { + return { + filename: { + description: "Filename of the file within the archive", + name: "filename", + type: GraphQLString + }, + url: { + description: "A URL to download the file. **Note:** This is powered by a little\nserverless Cloudflare function which tries to exctact the file on the fly.\nIt may not work for all files.", + name: "url", + type: GraphQLString + }, + file_md5: { + description: "The md5 hash of the file within the archive", + name: "file_md5", + type: GraphQLString + }, + size: { + description: "The uncompressed size of the file in bytes.\n\n**Note:** Will be `null` for directories", + name: "size", + type: GraphQLInt + }, + text_content: { + description: "The content of the file, if it's a text file", + name: "text_content", + type: GraphQLString + }, + is_directory: { + description: "Is the file a directory?", + name: "is_directory", + type: GraphQLBoolean + }, + skin: { + description: "The skin in which this file was found", + name: "skin", + type: SkinType + }, + date: { + description: "The date on the file inside the archive. Given in simplified extended ISO\nformat (ISO 8601).", + name: "date", + type: GraphQLString + } + }; + } +}); +const InternetArchiveItemType: GraphQLObjectType = new GraphQLObjectType({ + name: "InternetArchiveItem", + fields() { + return { + identifier: { + description: "The Internet Archive's unique identifier for this item", + name: "identifier", + type: GraphQLString + }, + url: { + description: "The URL where this item can be viewed on the Internet Archive", + name: "url", + type: GraphQLString + }, + metadata_url: { + description: "URL to get the Internet Archive's metadata for this item in JSON form.", + name: "metadata_url", + type: GraphQLString + }, + last_metadata_scrape_date_UNSTABLE: { + description: "The date and time that we last scraped this item's metadata.\n**Note:** This field is temporary and will be removed in the future.\nThe date format is just what we get from the database, and it's ambiguous.", + name: "last_metadata_scrape_date_UNSTABLE", + type: GraphQLString + }, + raw_metadata_json: { + description: "Our cached version of the metadata avaliable at \\`metadata_url\\` (above)", + name: "raw_metadata_json", + type: GraphQLString + }, + skin: { + description: "The skin that this item contains", + name: "skin", + type: SkinType + } + }; + } +}); +const RatingType: GraphQLEnumType = new GraphQLEnumType({ + description: "The judgement made about a skin by a moderator", + name: "Rating", + values: { + APPROVED: { + value: "APPROVED" + }, + REJECTED: { + value: "REJECTED" + }, + NSFW: { + value: "NSFW" + } + } +}); +const ReviewType: GraphQLObjectType = new GraphQLObjectType({ + name: "Review", + description: "A review of a skin. Done either on the Museum's Tinder-style\nreview page, or via the Discord bot.", + fields() { + return { + skin: { + description: "The skin that was reviewed", + name: "skin", + type: SkinType + }, + reviewer: { + description: "The user who made the review (if known). **Note:** In the early days we didn't\ntrack this, so many will be null.", + name: "reviewer", + type: GraphQLString + }, + rating: { + description: "The rating that the user gave the skin", + name: "rating", + type: RatingType + } + }; + } +}); +const SkinType: GraphQLInterfaceType = new GraphQLInterfaceType({ + description: "A Winamp skin. Could be modern or classic.\n\n**Note**: At some point in the future, this might be renamed to `Skin`.", + name: "Skin", + fields() { + return { + id: { + description: "GraphQL ID of the skin", + name: "id", + type: new GraphQLNonNull(GraphQLID) + }, + filename: { + description: "Filename of skin when uploaded to the Museum. Note: In some cases a skin\nhas been uploaded under multiple names. Here we just pick one.", + name: "filename", + type: GraphQLString, + args: { + normalize_extension: { + description: "If true, the the correct file extension (.wsz or .wal) will be .\nOtherwise, the original user-uploaded file extension will be used.", + name: "normalize_extension", + type: GraphQLBoolean, + defaultValue: false + } + } + }, + md5: { + description: "MD5 hash of the skin's file", + name: "md5", + type: GraphQLString + }, + download_url: { + description: "URL to download the skin", + name: "download_url", + type: GraphQLString + }, + tweeted: { + description: "Has the skin been tweeted?", + name: "tweeted", + type: GraphQLBoolean + }, + tweets: { + description: "List of @winampskins tweets that mentioned the skin.", + name: "tweets", + type: new GraphQLList(TweetType) + }, + archive_files: { + description: "List of files contained within the skin's .wsz archive", + name: "archive_files", + type: new GraphQLList(ArchiveFileType) + }, + internet_archive_item: { + description: "The skin's \"item\" at archive.org", + name: "internet_archive_item", + type: InternetArchiveItemType + }, + reviews: { + description: "Times that the skin has been reviewed either on the Museum's Tinder-style\nreivew page, or via the Discord bot.", + name: "reviews", + type: new GraphQLList(ReviewType) + }, + museum_url: { + name: "museum_url", + type: GraphQLString + }, + webamp_url: { + name: "webamp_url", + type: GraphQLString + }, + screenshot_url: { + name: "screenshot_url", + type: GraphQLString + }, + readme_text: { + name: "readme_text", + type: GraphQLString + }, + nsfw: { + name: "nsfw", + type: GraphQLBoolean + }, + average_color: { + name: "average_color", + type: GraphQLString + } + }; + } +}); +const TweetType: GraphQLObjectType = new GraphQLObjectType({ + name: "Tweet", + description: "A tweet made by @winampskins mentioning a Winamp skin", + fields() { + return { + url: { + description: "URL of the tweet. **Note:** Early on in the bot's life we just recorded\n_which_ skins were tweeted, not any info about the actual tweet. This means we\ndon't always know the URL of the tweet.", + name: "url", + type: GraphQLString + }, + likes: { + description: "Number of likes the tweet has received. Updated nightly. (Note: Recent likes on older tweets may not be reflected here)", + name: "likes", + type: GraphQLInt + }, + retweets: { + description: "Number of retweets the tweet has received. Updated nightly. (Note: Recent retweets on older tweets may not be reflected here)", + name: "retweets", + type: GraphQLInt + }, + skin: { + description: "The skin featured in this Tweet", + name: "skin", + type: SkinType + } + }; + } +}); +const ModernSkinType: GraphQLObjectType = new GraphQLObjectType({ + name: "ModernSkin", + description: "A \"modern\" Winamp skin. These skins use the `.wal` file extension and are free-form.\n\nMost functionality in the Winamp Skin Museum is centered around \"classic\" skins,\nwhich are currently called just `Skin` in this schema.", + fields() { + return { + filename: { + description: "Filename of skin when uploaded to the Museum. Note: In some cases a skin\nhas been uploaded under multiple names. Here we just pick one.", + name: "filename", + type: GraphQLString, + args: { + normalize_extension: { + description: "If true, the the correct file extension (.wsz or .wal) will be .\nOtherwise, the original user-uploaded file extension will be used.", + name: "normalize_extension", + type: GraphQLBoolean, + defaultValue: false + } + } + }, + id: { + description: "GraphQL ID of the skin", + name: "id", + type: new GraphQLNonNull(GraphQLID) + }, + md5: { + description: "MD5 hash of the skin's file", + name: "md5", + type: GraphQLString + }, + download_url: { + description: "URL to download the skin", + name: "download_url", + type: GraphQLString + }, + tweeted: { + description: "Has the skin been tweeted?", + name: "tweeted", + type: GraphQLBoolean + }, + tweets: { + description: "List of @winampskins tweets that mentioned the skin.", + name: "tweets", + type: new GraphQLList(TweetType) + }, + archive_files: { + description: "List of files contained within the skin's .wsz archive", + name: "archive_files", + type: new GraphQLList(ArchiveFileType) + }, + internet_archive_item: { + description: "The skin's \"item\" at archive.org", + name: "internet_archive_item", + type: InternetArchiveItemType + }, + reviews: { + description: "Times that the skin has been reviewed either on the Museum's Tinder-style\nreivew page, or via the Discord bot.", + name: "reviews", + type: new GraphQLList(ReviewType) + }, + museum_url: { + name: "museum_url", + type: GraphQLString + }, + webamp_url: { + name: "webamp_url", + type: GraphQLString + }, + screenshot_url: { + name: "screenshot_url", + type: GraphQLString + }, + readme_text: { + name: "readme_text", + type: GraphQLString + }, + nsfw: { + name: "nsfw", + type: GraphQLBoolean + }, + average_color: { + name: "average_color", + type: GraphQLString + } + }; + }, + interfaces() { + return [NodeType, SkinType]; + } +}); +const ModernSkinsConnectionType: GraphQLObjectType = new GraphQLObjectType({ + name: "ModernSkinsConnection", + description: "A collection of \"modern\" Winamp skins", + fields() { + return { + count: { + description: "The total number of skins matching the filter", + name: "count", + type: GraphQLInt + }, + nodes: { + description: "The list of skins", + name: "nodes", + type: new GraphQLList(ModernSkinType) + } + }; + } +}); +const SkinsConnectionType: GraphQLObjectType = new GraphQLObjectType({ + name: "SkinsConnection", + description: "A collection of classic Winamp skins", + fields() { + return { + count: { + description: "The total number of skins matching the filter", + name: "count", + type: GraphQLInt + }, + nodes: { + description: "The list of skins", + name: "nodes", + type: new GraphQLList(SkinType) + } + }; + } +}); +const SkinsSortOptionType: GraphQLEnumType = new GraphQLEnumType({ + name: "SkinsSortOption", + values: { + MUSEUM: { + value: "MUSEUM" + } + } +}); +const SkinsFilterOptionType: GraphQLEnumType = new GraphQLEnumType({ + name: "SkinsFilterOption", + values: { + APPROVED: { + value: "APPROVED" + }, + REJECTED: { + value: "REJECTED" + }, + NSFW: { + value: "NSFW" + }, + TWEETED: { + value: "TWEETED" + } + } +}); +const TweetsConnectionType: GraphQLObjectType = new GraphQLObjectType({ + name: "TweetsConnection", + description: "A collection of tweets made by the @winampskins bot", + fields() { + return { + count: { + description: "The total number of tweets", + name: "count", + type: GraphQLInt + }, + nodes: { + description: "The list of tweets", + name: "nodes", + type: new GraphQLList(TweetType) + } + }; + } +}); +const TweetsSortOptionType: GraphQLEnumType = new GraphQLEnumType({ + name: "TweetsSortOption", + values: { + LIKES: { + value: "LIKES" + }, + RETWEETS: { + value: "RETWEETS" + } + } +}); +const DatabaseStatisticsType: GraphQLObjectType = new GraphQLObjectType({ + name: "DatabaseStatistics", + description: "Statistics about the contents of the Museum's database.", + fields() { + return { + unique_classic_skins_count: { + description: "The total number of classic skins in the Museum's database", + name: "unique_classic_skins_count", + type: GraphQLInt + }, + tweeted_skins_count: { + description: "The number of skins in the Museum that have been tweeted by @winampskins", + name: "tweeted_skins_count", + type: GraphQLInt + }, + approved_skins_count: { + description: "The number of skins that have been approved for tweeting. This includes both\ntweeted and untweeted skins.\n\n**Note:** Skins can be both approved and rejected by different users.", + name: "approved_skins_count", + type: GraphQLInt + }, + rejected_skins_count: { + description: "The number of skins that have been rejected for tweeting.\n\n**Note:** Skins can be both approved and rejected by different users.\n**Note:** Generally skins that have been marked NSFW are also marked as rejected.", + name: "rejected_skins_count", + type: GraphQLInt + }, + nsfw_skins_count: { + description: "The number of skins that have been marked as NSFW.\n\n**Note:** Skins can be approved and rejected by different users.\n**Note:** Generally skins that have been marked NSFW are also marked as rejected.", + name: "nsfw_skins_count", + type: GraphQLInt + }, + unreviewed_skins_count: { + description: "The number of skins that have never been reviewed.", + name: "unreviewed_skins_count", + type: GraphQLInt + }, + tweetable_skins_count: { + description: "The number of skins that have been approved for tweeting, but not yet tweeted.", + name: "tweetable_skins_count", + type: GraphQLInt + }, + uploads_pending_processing_count: { + description: "Skins uplaods awaiting processing. This can happen when there are a large\nnumber of skin uplaods at the same time, or when the skin uploading processing\npipeline gets stuck.", + name: "uploads_pending_processing_count", + type: GraphQLInt + }, + uploads_in_error_state_count: { + description: "Skins uploads that have errored during processing.", + name: "uploads_in_error_state_count", + type: GraphQLInt + }, + web_uploads_count: { + description: "Number of skins that have been uploaded to the Museum via the web interface.", + name: "web_uploads_count", + type: GraphQLInt + } + }; + } +}); +const SkinUploadStatusType: GraphQLEnumType = new GraphQLEnumType({ + description: "The current status of a pending upload.\n\n**Note:** Expect more values here as we try to be more transparent about\nthe status of a pending uploads.", + name: "SkinUploadStatus", + values: { + URL_REQUESTED: { + value: "URL_REQUESTED" + }, + UPLOAD_REPORTED: { + value: "UPLOAD_REPORTED" + }, + ERRORED: { + value: "ERRORED" + }, + DELAYED: { + value: "DELAYED" + }, + ARCHIVED: { + value: "ARCHIVED" + } + } +}); +const SkinUploadType: GraphQLObjectType = new GraphQLObjectType({ + name: "SkinUpload", + description: "Information about an attempt to upload a skin to the Museum.", + fields() { + return { + id: { + name: "id", + type: GraphQLString + }, + status: { + name: "status", + type: SkinUploadStatusType + }, + skin: { + description: "Skin that was uploaded. **Note:** This is null if the skin has not yet been\nfully processed. (status == ARCHIVED)", + name: "skin", + type: SkinType + }, + upload_md5: { + description: "Md5 hash given when requesting the upload URL.", + name: "upload_md5", + type: GraphQLString + } + }; + } +}); +const UserType: GraphQLObjectType = new GraphQLObjectType({ + name: "User", + fields() { + return { + username: { + name: "username", + type: GraphQLString + } + }; + } +}); +const QueryType: GraphQLObjectType = new GraphQLObjectType({ + name: "Query", + fields() { + return { + node: { + description: "Get a globally unique object by its ID.\n\nhttps://graphql.org/learn/global-object-identification/", + name: "node", + type: NodeType, + args: { + id: { + name: "id", + type: new GraphQLNonNull(GraphQLID) + } + }, + resolve(source, args, context) { + return queryNodeResolver(source, args, context); + } + }, + fetch_tweet_by_url: { + description: "Get a tweet by its URL", + name: "fetch_tweet_by_url", + type: TweetType, + args: { + url: { + name: "url", + type: new GraphQLNonNull(GraphQLString) + } + }, + resolve(source, args, context) { + return queryFetch_tweet_by_urlResolver(source, args, context); + } + }, + fetch_skin_by_md5: { + description: "Get a skin by its MD5 hash", + name: "fetch_skin_by_md5", + type: SkinType, + args: { + md5: { + name: "md5", + type: new GraphQLNonNull(GraphQLString) + } + }, + resolve(source, args, context) { + return queryFetch_skin_by_md5Resolver(source, args, context); + } + }, + search_skins: { + description: "Search the database using the Algolia search index used by the Museum.\n\nUseful for locating a particular skin.", + name: "search_skins", + type: new GraphQLList(SkinType), + args: { + query: { + name: "query", + type: new GraphQLNonNull(GraphQLString) + }, + first: { + name: "first", + type: GraphQLInt, + defaultValue: 10 + }, + offset: { + name: "offset", + type: GraphQLInt, + defaultValue: 0 + } + }, + resolve(source, args, context) { + return querySearch_skinsResolver(source, args, context); + } + }, + skin_to_review: { + description: "A random skin that needs to be reviewed", + name: "skin_to_review", + type: SkinType, + resolve(source, args, context) { + return querySkin_to_reviewResolver(source, args, context); + } + }, + fetch_internet_archive_item_by_identifier: { + description: "Get an archive.org item by its identifier. You can find this in the URL:\n\nhttps://archive.org/details//", + name: "fetch_internet_archive_item_by_identifier", + type: InternetArchiveItemType, + args: { + identifier: { + name: "identifier", + type: new GraphQLNonNull(GraphQLString) + } + }, + resolve(source, args, context) { + return queryFetch_internet_archive_item_by_identifierResolver(source, args, context); + } + }, + fetch_archive_file_by_md5: { + description: "Fetch archive file by it's MD5 hash\n\nGet information about a file found within a skin's wsz/wal/zip archive.", + name: "fetch_archive_file_by_md5", + type: ArchiveFileType, + args: { + md5: { + name: "md5", + type: new GraphQLNonNull(GraphQLString) + } + }, + resolve(source, args, context) { + return queryFetch_archive_file_by_md5Resolver(source, args, context); + } + }, + modern_skins: { + description: "All modern skins in the database", + name: "modern_skins", + type: ModernSkinsConnectionType, + args: { + first: { + name: "first", + type: GraphQLInt, + defaultValue: 10 + }, + offset: { + name: "offset", + type: GraphQLInt, + defaultValue: 0 + } + }, + resolve(source, args) { + return queryModern_skinsResolver(source, args); + } + }, + skins: { + description: "All classic skins in the database\n\n**Note:** We don't currently support combining sorting and filtering.", + name: "skins", + type: SkinsConnectionType, + args: { + first: { + name: "first", + type: GraphQLInt, + defaultValue: 10 + }, + offset: { + name: "offset", + type: GraphQLInt, + defaultValue: 0 + }, + sort: { + name: "sort", + type: SkinsSortOptionType + }, + filter: { + name: "filter", + type: SkinsFilterOptionType + } + }, + resolve(source, args) { + return querySkinsResolver(source, args); + } + }, + tweets: { + description: "Tweets tweeted by @winampskins", + name: "tweets", + type: TweetsConnectionType, + args: { + first: { + name: "first", + type: GraphQLInt, + defaultValue: 10 + }, + offset: { + name: "offset", + type: GraphQLInt, + defaultValue: 0 + }, + sort: { + name: "sort", + type: TweetsSortOptionType + } + }, + resolve(source, args) { + return queryTweetsResolver(source, args); + } + }, + statistics: { + description: "A namespace for statistics about the database", + name: "statistics", + type: DatabaseStatisticsType, + resolve(source) { + return queryStatisticsResolver(source); + } + }, + upload_statuses_by_md5: { + description: "Get the status of a batch of uploads by md5s", + name: "upload_statuses_by_md5", + type: new GraphQLList(SkinUploadType), + args: { + md5s: { + name: "md5s", + type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLString))) + } + }, + resolve(source, args, context) { + return queryUpload_statuses_by_md5Resolver(source, args, context); + } + }, + upload_statuses: { + description: "Get the status of a batch of uploads by ids", + name: "upload_statuses", + type: new GraphQLList(SkinUploadType), + args: { + ids: { + name: "ids", + type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLString))) + } + }, + resolve(source, args, context) { + return queryUpload_statusesResolver(source, args, context); + } + }, + me: { + description: "The currently authenticated user, if any.", + name: "me", + type: UserType, + resolve(source) { + return queryMeResolver(source); + } + } + }; + } +}); +const UploadUrlType: GraphQLObjectType = new GraphQLObjectType({ + name: "UploadUrl", + description: "A URL that the client can use to upload a skin to S3, and then notify the server\nwhen they're done.", + fields() { + return { + id: { + name: "id", + type: GraphQLString + }, + url: { + name: "url", + type: GraphQLString + }, + md5: { + name: "md5", + type: GraphQLString + } + }; + } +}); +const UploadUrlRequestType: GraphQLInputObjectType = new GraphQLInputObjectType({ + description: "Input object used for a user to request an UploadUrl", + name: "UploadUrlRequest", + fields() { + return { + filename: { + name: "filename", + type: new GraphQLNonNull(GraphQLString) + }, + md5: { + name: "md5", + type: new GraphQLNonNull(GraphQLString) + } + }; + } +}); +const UploadMutationsType: GraphQLObjectType = new GraphQLObjectType({ + name: "UploadMutations", + description: "Mutations for the upload flow\n\n1. The user finds the md5 hash of their local files.\n2. (`get_upload_urls`) The user requests upload URLs for each of their files.\n3. The server returns upload URLs for each of their files which are not already in the collection.\n4. The user uploads each of their files to the URLs returned in step 3.\n5. (`report_skin_uploaded`) The user notifies the server that they're done uploading.\n6. (TODO) The user polls for the status of their uploads.", + fields() { + return { + get_upload_urls: { + description: "Get a (possibly incomplete) list of UploadUrls for each of the files. If an\nUploadUrl is not returned for a given hash, it means the file is already in\nthe collection.", + name: "get_upload_urls", + type: new GraphQLList(UploadUrlType), + args: { + files: { + name: "files", + type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(UploadUrlRequestType))) + } + } + }, + report_skin_uploaded: { + description: "Notify the server that the user is done uploading.", + name: "report_skin_uploaded", + type: GraphQLBoolean, + args: { + id: { + name: "id", + type: new GraphQLNonNull(GraphQLString) + }, + md5: { + name: "md5", + type: new GraphQLNonNull(GraphQLString) + } + } + } + }; + } +}); +const MutationType: GraphQLObjectType = new GraphQLObjectType({ + name: "Mutation", + fields() { + return { + send_feedback: { + description: "Send a message to the admin of the site. Currently this appears in Discord.", + name: "send_feedback", + type: GraphQLBoolean, + args: { + message: { + name: "message", + type: new GraphQLNonNull(GraphQLString) + }, + email: { + name: "email", + type: GraphQLString + }, + url: { + name: "url", + type: GraphQLString + } + }, + resolve(source, args, context) { + return mutationSend_feedbackResolver(source, args, context); + } + }, + request_nsfw_review_for_skin: { + description: "Request that an admin check if this skin is NSFW.\nUnlike other review mutation endpoints, this one does not require being logged\nin.", + name: "request_nsfw_review_for_skin", + type: GraphQLBoolean, + args: { + md5: { + name: "md5", + type: new GraphQLNonNull(GraphQLString) + } + }, + resolve(source, args, context) { + return mutationRequest_nsfw_review_for_skinResolver(source, args, context); + } + }, + mark_skin_nsfw: { + description: "Mark a skin as NSFW\n\n**Note:** Requires being logged in", + name: "mark_skin_nsfw", + type: GraphQLBoolean, + args: { + md5: { + name: "md5", + type: new GraphQLNonNull(GraphQLString) + } + }, + resolve(source, args, context) { + return mutationMark_skin_nsfwResolver(source, args, context); + } + }, + reject_skin: { + description: "Reject skin for tweeting\n\n**Note:** Requires being logged in", + name: "reject_skin", + type: GraphQLBoolean, + args: { + md5: { + name: "md5", + type: new GraphQLNonNull(GraphQLString) + } + }, + resolve(source, args, context) { + return mutationReject_skinResolver(source, args, context); + } + }, + approve_skin: { + description: "Approve skin for tweeting\n\n**Note:** Requires being logged in", + name: "approve_skin", + type: GraphQLBoolean, + args: { + md5: { + name: "md5", + type: new GraphQLNonNull(GraphQLString) + } + }, + resolve(source, args, context) { + return mutationApprove_skinResolver(source, args, context); + } + }, + upload: { + description: "Mutations for the upload flow", + name: "upload", + type: UploadMutationsType, + resolve(source) { + return mutationUploadResolver(source); + } + } + }; + } +}); +const ClassicSkinType: GraphQLObjectType = new GraphQLObjectType({ + name: "ClassicSkin", + description: "A classic Winamp skin", + fields() { + return { + id: { + description: "GraphQL ID of the skin", + name: "id", + type: new GraphQLNonNull(GraphQLID) + }, + filename: { + description: "Filename of skin when uploaded to the Museum. Note: In some cases a skin\nhas been uploaded under multiple names. Here we just pick one.", + name: "filename", + type: GraphQLString, + args: { + normalize_extension: { + description: "If true, the the correct file extension (.wsz or .wal) will be .\nOtherwise, the original user-uploaded file extension will be used.", + name: "normalize_extension", + type: GraphQLBoolean, + defaultValue: false + } + } + }, + md5: { + description: "MD5 hash of the skin's file", + name: "md5", + type: GraphQLString + }, + download_url: { + description: "URL to download the skin", + name: "download_url", + type: GraphQLString + }, + tweeted: { + description: "Has the skin been tweeted?", + name: "tweeted", + type: GraphQLBoolean + }, + tweets: { + description: "List of @winampskins tweets that mentioned the skin.", + name: "tweets", + type: new GraphQLList(TweetType) + }, + archive_files: { + description: "List of files contained within the skin's .wsz archive", + name: "archive_files", + type: new GraphQLList(ArchiveFileType) + }, + internet_archive_item: { + description: "The skin's \"item\" at archive.org", + name: "internet_archive_item", + type: InternetArchiveItemType + }, + museum_url: { + description: "URL of the skin on the Winamp Skin Museum", + name: "museum_url", + type: GraphQLString + }, + webamp_url: { + description: "URL of webamp.org with the skin loaded", + name: "webamp_url", + type: GraphQLString + }, + screenshot_url: { + description: "URL of a screenshot of the skin", + name: "screenshot_url", + type: GraphQLString + }, + readme_text: { + description: "Text of the readme file extracted from the skin", + name: "readme_text", + type: GraphQLString + }, + nsfw: { + description: "Has the skin been flagged as \"not safe for wrok\"?", + name: "nsfw", + type: GraphQLBoolean + }, + average_color: { + description: "String representation (rgb usually) of the skin's average color", + name: "average_color", + type: GraphQLString + }, + has_media_library: { + description: "Does the skin include sprite sheets for the media library?", + name: "has_media_library", + type: GraphQLBoolean + }, + reviews: { + description: "Times that the skin has been reviewed either on the Museum's Tinder-style\nreivew page, or via the Discord bot.", + name: "reviews", + type: new GraphQLList(ReviewType) + }, + last_algolia_index_update_date: { + description: "The date on which this skin was last updated in the Algolia search index.\nGiven in simplified extended ISO format (ISO 8601).", + name: "last_algolia_index_update_date", + type: GraphQLString + }, + transparent_pixels: { + description: "The number of transparent pixels rendered by the skin.", + name: "transparent_pixels", + type: GraphQLInt + } + }; + }, + interfaces() { + return [NodeType, SkinType]; + } +}); +const schema = new GraphQLSchema({ + query: QueryType, + mutation: MutationType, + types: [RatingType, QueryType, NodeType, ReviewType, TweetType, ClassicSkinType, ModernSkinType, MutationType, InternetArchiveItemType, SkinType, ArchiveFileType, ModernSkinsConnectionType, SkinsConnectionType, SkinsSortOptionType, SkinsFilterOptionType, TweetsSortOptionType, TweetsConnectionType, DatabaseStatisticsType, SkinUploadType, SkinUploadStatusType, UserType, UploadUrlType, UploadUrlRequestType, UploadMutationsType] +}); +export { schema }; diff --git a/packages/skin-database/package.json b/packages/skin-database/package.json index af6e43b4..df0b99b3 100644 --- a/packages/skin-database/package.json +++ b/packages/skin-database/package.json @@ -28,8 +28,7 @@ "express-graphql": "^0.12.0", "express-sitemap-xml": "^2.0.0", "fast-xml-parser": "^4.2.2", - "graphql": "14.7.0", - "grats": "0.0.0-main-ded5e481", + "graphql": "16.6.0", "imagemin": "^7.0.0", "imagemin-optipng": "^7.0.0", "knex": "^0.21.1", @@ -65,6 +64,7 @@ "@types/supertest": "^2.0.10", "@typescript-eslint/eslint-plugin": "^5.15.0", "@typescript-eslint/parser": "^5.15.0", + "grats": "0.0.0-main-bfb182a3", "supertest": "^6.0.1", "typescript": "^3.8.3" }, diff --git a/packages/skin-database/tsconfig.json b/packages/skin-database/tsconfig.json index 155e79e1..9e9d3398 100644 --- a/packages/skin-database/tsconfig.json +++ b/packages/skin-database/tsconfig.json @@ -1,6 +1,7 @@ { "grats": { - "nullableByDefault": true + "nullableByDefault": true, + "EXPERIMENTAL_codegenPath": "./api/graphql/schema.ts" }, "compilerOptions": { "module": "commonjs", diff --git a/yarn.lock b/yarn.lock index d06df8ef..fe171131 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14778,22 +14778,20 @@ graphql-ws@^5.4.1: resolved "https://registry.npmjs.org/graphql-ws/-/graphql-ws-5.11.1.tgz" integrity sha512-AlOO/Gt0fXuSHXe/Weo6o3rIQVnH5MW7ophzeYzL+vYNlkf0NbWRJ6IIFgtSLcv9JpTlQdxSpB3t0SnM47/BHA== -graphql@14.7.0: - version "14.7.0" - resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.7.0.tgz#7fa79a80a69be4a31c27dda824dc04dac2035a72" - integrity sha512-l0xWZpoPKpppFzMfvVyFmp9vLN7w/ZZJPefUicMCepfJeQ8sMcztloGYY9DfjVPo6tIUDzU5Hw3MUbIjj9AVVA== - dependencies: - iterall "^1.2.2" +graphql@16.6.0: + version "16.6.0" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.6.0.tgz#c2dcffa4649db149f6282af726c8c83f1c7c5fdb" + integrity sha512-KPIBPDlW7NxrbT/eh4qPXz5FiFdL5UbaA0XUNz2Rp3Z3hqBSkbj0GVjwFDztsWVauZUWsbKHgMg++sk8UX0bkw== graphql@^16.6.0: version "16.8.1" resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.8.1.tgz#1930a965bef1170603702acdb68aedd3f3cf6f07" integrity sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw== -grats@0.0.0-main-ded5e481: - version "0.0.0-main-ded5e481" - resolved "https://registry.yarnpkg.com/grats/-/grats-0.0.0-main-ded5e481.tgz#19f7545aef96c4b5ddb7567532de9e1e96eca018" - integrity sha512-WrL/irmHE9fETYdlC0kqAunlZNSn9ufebBZQYS+ORiI3/o6B2VeZ+KF64GFEjuxsoW3L29z1IZNCs+2Td2ro4Q== +grats@0.0.0-main-bfb182a3: + version "0.0.0-main-bfb182a3" + resolved "https://registry.yarnpkg.com/grats/-/grats-0.0.0-main-bfb182a3.tgz#c566889fce58016d24b976c3274d0dda143366c6" + integrity sha512-cFsUXkmqo5TsbV51danyyH3pFLWduTMSg6RNwskEOofgV/+GiKFdbmaLWsgI2B5/RcBkzPjdDGRN5B8u99pR7g== dependencies: "@graphql-tools/utils" "^9.2.1" commander "^10.0.0" @@ -16513,11 +16511,6 @@ isurl@^1.0.0-alpha5: has-to-string-tag-x "^1.2.0" is-object "^1.0.1" -iterall@^1.2.2: - version "1.3.0" - resolved "https://registry.yarnpkg.com/iterall/-/iterall-1.3.0.tgz#afcb08492e2915cbd8a0884eb93a8c94d0d72fea" - integrity sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg== - jake@^10.8.5: version "10.8.5" resolved "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz"