Upgrade grats

This commit is contained in:
Jordan Eldredge 2024-03-01 23:26:06 -08:00
parent 0f431d9e05
commit 1c8b4e0478
18 changed files with 1997 additions and 1210 deletions

View file

@ -0,0 +1 @@
api/graphql/schema.ts

View file

@ -231,7 +231,7 @@ describe("Query.skins", () => {
});
// TODO: Upgrade Grats
test.skip("Query.fetch_skin_by_md5 (debug data)", async () => {
test("Query.fetch_skin_by_md5 (debug data)", async () => {
const { data } = await graphQLRequest(
gql`
query MyQuery($md5: String!) {

View file

@ -21,7 +21,12 @@ export type ApiAction =
| { type: "CLASSIC_SKIN_UPLOADED"; md5: string }
| { type: "MODERN_SKIN_UPLOADED"; md5: string }
| { type: "SKIN_UPLOAD_ERROR"; uploadId: string; message: string }
| { type: "GOT_FEEDBACK"; message: string; email?: string; url?: string }
| {
type: "GOT_FEEDBACK";
message: string;
email?: string | null;
url?: string | null;
}
| { type: "SYNCED_TO_ARCHIVE"; successes: number; errors: number }
| { type: "STARTED_SYNC_TO_ARCHIVE"; count: number }
| {

View file

@ -2,6 +2,7 @@ import { Int } from "grats";
import SkinModel from "../../data/SkinModel";
import { knex } from "../../db";
import ModernSkinResolver from "./resolvers/ModernSkinResolver";
import { Ctx } from ".";
/**
* A collection of "modern" Winamp skins
@ -29,7 +30,10 @@ export default class ModernSkinsConnection {
/**
* The list of skins
* @gqlField */
async nodes(_args: never, ctx): Promise<Array<ModernSkinResolver | null>> {
async nodes(
_args: unknown,
{ ctx }: Ctx
): Promise<Array<ModernSkinResolver | null>> {
const skins = await this._getQuery()
.select()
.limit(this._first)

View file

@ -5,6 +5,7 @@ import SkinResolver from "./resolvers/SkinResolver";
import LRU from "lru-cache";
import { Int } from "grats";
import { ISkin } from "./resolvers/CommonSkinResolver";
import { Ctx } from ".";
const options = {
max: 100,
@ -43,9 +44,14 @@ async function getSkinMuseumPageFromCache(first: number, offset: number) {
export default class SkinsConnection {
_first: number;
_offset: number;
_sort?: string;
_filter?: string;
constructor(first: number, offset: number, sort?: string, filter?: string) {
_sort?: string | null;
_filter?: string | null;
constructor(
first: number,
offset: number,
sort?: string | null,
filter?: string | null
) {
this._first = first;
this._offset = offset;
this._filter = filter;
@ -96,7 +102,7 @@ export default class SkinsConnection {
* The list of skins
* @gqlField
*/
async nodes(args: never, ctx): Promise<Array<ISkin | null>> {
async nodes(args: unknown, { ctx }: Ctx): Promise<Array<ISkin | null>> {
if (this._sort === "MUSEUM") {
if (this._filter) {
throw new Error(

View file

@ -12,8 +12,8 @@ export type TweetsSortOption = "LIKES" | "RETWEETS";
export default class TweetsConnection {
_first: number;
_offset: number;
_sort?: TweetsSortOption;
constructor(first: number, offset: number, sort?: TweetsSortOption) {
_sort?: TweetsSortOption | null;
constructor(first: number, offset: number, sort?: TweetsSortOption | null) {
this._first = first;
this._offset = offset;
this._sort = sort;
@ -43,7 +43,7 @@ export default class TweetsConnection {
* The list of tweets
* @gqlField
*/
async nodes(args: never, ctx): Promise<Array<TweetModel | null>> {
async nodes(): Promise<Array<TweetModel | null>> {
return this._getQuery().select().limit(this._first).offset(this._offset);
}
}

View file

@ -1,497 +0,0 @@
schema {
query: Query
mutation: Mutation
}
directive @exported(filename: String!, functionName: String!) on FIELD_DEFINITION
directive @methodName(name: String!) on FIELD_DEFINITION
"""A file found within a Winamp Skin's .wsz archive"""
type ArchiveFile {
"""
The date on the file inside the archive. Given in simplified extended ISO
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.
It may not work for all files.
"""
url: String
}
"""A classic Winamp skin"""
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.
"""
filename(
"""
If true, the the correct file extension (.wsz or .wal) will be .
Otherwise, the original user-uploaded file extension will be used.
"""
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
}
"""Statistics about the contents of the Museum's database."""
type DatabaseStatistics {
"""
The number of skins that have been approved for tweeting. This includes both
tweeted and untweeted skins.
**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.
**Note:** Skins can be approved and rejected by different users.
**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.
**Note:** Skins can be both approved and rejected by different users.
**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.
"""
web_uploads_count: Int
}
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
}
"""
A "modern" Winamp skin. These skins use the `.wal` file extension and are free-form.
Most functionality in the Winamp Skin Museum is centered around "classic" skins,
which are currently called just `Skin` in this schema.
"""
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.
"""
filename(
"""
If true, the the correct file extension (.wsz or .wal) will be .
Otherwise, the original user-uploaded file extension will be used.
"""
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 @deprecated(reason: "Needed for migration")
"""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")
}
"""A collection of "modern" Winamp skins"""
type ModernSkinsConnection {
"""The total number of skins matching the filter"""
count: Int
"""The list of skins"""
nodes: [ModernSkin]
}
type Mutation {
"""
Approve skin for tweeting
**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
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
}
"""
A globally unique object. The `id` here is intended only for use within
GraphQL.
https://graphql.org/learn/global-object-identification/
"""
interface Node {
id: ID!
}
type Query {
"""
Fetch archive file by it's MD5 hash
Get information about a file found within a skin's wsz/wal/zip archive.
"""
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/<identifier>/
"""
fetch_internet_archive_item_by_identifier(identifier: String!): InternetArchiveItem
"""Get a skin by its MD5 hash"""
fetch_skin_by_md5(md5: String!): Skin
"""Get a tweet by its URL"""
fetch_tweet_by_url(url: String!): Tweet
"""The currently authenticated user, if any."""
me: User
"""All modern skins in the database"""
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
"""
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]
"""A random skin that needs to be reviewed"""
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
"""A namespace for statistics about the database"""
statistics: DatabaseStatistics
"""Tweets tweeted by @winampskins"""
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]
"""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.")
}
"""The judgement made about a skin by a moderator"""
enum Rating {
APPROVED
NSFW
REJECTED
}
"""
A review of a skin. Done either on the Museum's Tinder-style
reivew 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
}
"""
A Winamp skin. Could be modern or classic.
**Note**: At some point in the future, this might be renamed to `Skin`.
"""
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.
"""
filename(
"""
If true, the the correct file extension (.wsz or .wal) will be .
Otherwise, the original user-uploaded file extension will be used.
"""
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 @deprecated(reason: "Needed for migration")
"""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")
}
"""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
}
"""
The current status of a pending upload.
**Note:** Expect more values here as we try to be more transparent about
the status of a pending uploads.
"""
enum SkinUploadStatus {
ARCHIVED
DELAYED
ERRORED
UPLOAD_REPORTED
URL_REQUESTED
}
"""A collection of classic Winamp skins"""
type SkinsConnection {
"""The total number of skins matching the filter"""
count: Int
"""The list of skins"""
nodes: [Skin]
}
enum SkinsFilterOption {
APPROVED
NSFW
REJECTED
TWEETED
}
enum SkinsSortOption {
MUSEUM
}
"""A tweet made by @winampskins mentioning a Winamp skin"""
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
don't always know the URL of the tweet.
"""
url: String
}
"""A collection of tweets made by the @winampskins bot"""
type TweetsConnection {
"""The total number of tweets"""
count: Int
"""The list of tweets"""
nodes: [Tweet]
}
enum TweetsSortOption {
LIKES
RETWEETS
}
"""
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.
"""
type UploadMutations {
"""
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.
"""
get_upload_urls(files: [UploadUrlRequest!]!): [UploadUrl]
"""Notify the server that the user is done uploading."""
report_skin_uploaded(id: String!, md5: String!): Boolean
}
"""
A URL that the client can use to upload a skin to S3, and then notify the server
when they're done.
"""
type UploadUrl {
id: String
md5: String
url: String
}
"""Input object used for a user to request an UploadUrl"""
input UploadUrlRequest {
filename: String!
md5: String!
}
type User {
username: String
}

View file

@ -1,14 +1,10 @@
import { RequestHandler, 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";
import { getSchema } from "./schema";
const schemaPath = path.join(__dirname, "./schema.graphql");
const schema = buildSchema(fs.readFileSync(schemaPath, "utf8"));
export type Ctx = Express.Request;
const router = Router();
@ -44,8 +40,7 @@ const extensions = ({
router.use(
"/",
graphqlHTTP({
schema: schema,
rootValue: new RootResolver(),
schema: getSchema(),
graphiql: {
defaultQuery: DEFAULT_QUERY,
},

View file

@ -3,6 +3,7 @@ import SkinModel from "../../../data/SkinModel";
import * as S3 from "../../../s3";
import * as Skins from "../../../data/skins";
import { processUserUploads } from "../../processUserUploads";
import { Ctx } from "..";
// We don't use a resolver here, just return the value directly.
/**
@ -45,7 +46,7 @@ class UploadMutationResolver {
*/
async get_upload_urls(
{ files }: { files: UploadUrlRequest[] },
{ ctx }
{ ctx }: Ctx
): Promise<Array<UploadUrl | null>> {
const missing: UploadUrl[] = [];
await Parallel.each(
@ -69,7 +70,7 @@ class UploadMutationResolver {
*/
async report_skin_uploaded(
{ id, md5 }: { id: string; md5: string },
req
req: Ctx
): Promise<boolean> {
// TODO: Validate md5 and id;
await Skins.recordUserUploadComplete(md5, id);
@ -80,7 +81,7 @@ class UploadMutationResolver {
}
function requireAuthed(handler) {
return (args, req) => {
return (args, req: Ctx) => {
if (!req.ctx.authed()) {
throw new Error("You must be logged in to read this field.");
} else {
@ -92,102 +93,120 @@ function requireAuthed(handler) {
/**
*
* @gqlType Mutation */
export default class MutationResolver {
/**
* Mutations for the upload flow
* @gqlField */
async upload(): Promise<UploadMutationResolver> {
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<boolean> {
req.notify({
type: "GOT_FEEDBACK",
url,
message,
email,
});
return true;
}
type Mutation = unknown;
/**
* Reject skin for tweeting
*
* **Note:** Requires being logged in
* @gqlField */
reject_skin(args: { md5: string }, req): Promise<boolean> {
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;
});
/**
* Approve skin for tweeting
*
* **Note:** Requires being logged in
* @gqlField */
approve_skin(args: { md5: string }, req): Promise<boolean> {
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<boolean> {
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<boolean> {
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;
}
/**
* Mutations for the upload flow
* @gqlField */
export async function upload(_: Mutation): Promise<UploadMutationResolver> {
return new UploadMutationResolver();
}
/**
* Send a message to the admin of the site. Currently this appears in Discord.
* @gqlField */
export async function send_feedback(
_: Mutation,
{
message,
email,
url,
}: { message: string; email?: string | null; url?: string | null },
req: Ctx
): Promise<boolean> {
req.notify({
type: "GOT_FEEDBACK",
url,
message,
email,
});
return true;
}
/**
* Reject skin for tweeting
*
* **Note:** Requires being logged in
* @gqlField */
export function reject_skin(
_: Mutation,
args: { md5: string },
req: Ctx
): Promise<boolean> {
return _reject_skin(args, req);
}
const _reject_skin = requireAuthed(async ({ md5 }, req: Ctx) => {
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(
_: Mutation,
args: { md5: string },
req: Ctx
): Promise<boolean> {
return _approve_skin(args, req);
}
const _approve_skin = requireAuthed(async ({ md5 }, req: Ctx) => {
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 */
export function mark_skin_nsfw(
_: Mutation,
args: { md5: string },
req: Ctx
): Promise<boolean> {
return _mark_skin_nsfw(args, req);
}
const _mark_skin_nsfw = requireAuthed(async ({ md5 }, req: Ctx) => {
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 */
export async function request_nsfw_review_for_skin(
_: Mutation,
{ md5 }: { md5: string },
req: Ctx
): Promise<boolean> {
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;
}

View file

@ -1,3 +1,4 @@
import { Ctx } from "..";
import { Rating, ReviewRow } from "../../../types";
import { ISkin } from "./CommonSkinResolver";
import SkinResolver from "./SkinResolver";
@ -16,7 +17,7 @@ export default class ReviewResolver {
* The skin that was reviewed
* @gqlField
*/
skin(args: never, { ctx }): Promise<ISkin | null> {
skin(args: unknown, { ctx }: Ctx): Promise<ISkin | null> {
return SkinResolver.fromMd5(ctx, this._model.skin_md5);
}

View file

@ -9,132 +9,144 @@ import * as Skins from "../../../data/skins";
import { ID, Int } from "grats";
import algoliasearch from "algoliasearch";
import MutationResolver from "./MutationResolver";
import { knex } from "../../../db";
import ArchiveFileModel from "../../../data/ArchiveFileModel";
import DatabaseStatisticsResolver from "./DatabaseStatisticsResolver";
import { fromId, NodeResolver } from "./NodeResolver";
import ModernSkinsConnection from "../ModernSkinsConnection";
import { ISkin } from "./CommonSkinResolver";
import { Ctx } from "..";
// These keys are already in the web client, so they are not secret at all.
const client = algoliasearch("HQ9I5Z6IM5", "6466695ec3f624a5fccf46ec49680e51");
const index = client.initIndex("Skins");
/** @gqlType Query */
class RootResolver extends MutationResolver {
/**
* Get a globally unique object by its ID.
*
* https://graphql.org/learn/global-object-identification/
* @gqlField
*/
async node({ id }: { id: ID }, { ctx }): Promise<NodeResolver | null> {
const { graphqlType, id: localId } = fromId(id);
// TODO Use typeResolver
switch (graphqlType) {
case "ClassicSkin":
case "ModernSkin": {
const skin = await SkinModel.fromMd5(ctx, localId);
if (skin == null) {
return null;
}
return SkinResolver.fromModel(skin);
type Query = unknown;
/**
* Get a globally unique object by its ID.
*
* https://graphql.org/learn/global-object-identification/
* @gqlField
*/
export async function node(
_: Query,
{ id }: { id: ID },
{ ctx }: Ctx
): Promise<NodeResolver | null> {
const { graphqlType, id: localId } = fromId(id);
// TODO Use typeResolver
switch (graphqlType) {
case "ClassicSkin":
case "ModernSkin": {
const skin = await SkinModel.fromMd5(ctx, localId);
if (skin == null) {
return null;
}
return SkinResolver.fromModel(skin);
}
}
return null;
}
/**
* Get a skin by its MD5 hash
* @gqlField
*/
export async function fetch_skin_by_md5(
_: Query,
{ md5 }: { md5: string },
{ ctx }: Ctx
): Promise<ISkin | null> {
const skin = await SkinModel.fromMd5(ctx, md5);
if (skin == null) {
return null;
}
return SkinResolver.fromModel(skin);
}
/**
* Get a skin by its MD5 hash
* @gqlField
*/
async fetch_skin_by_md5(
{ md5 }: { md5: string },
{ ctx }
): Promise<ISkin | null> {
const skin = await SkinModel.fromMd5(ctx, md5);
if (skin == null) {
return null;
}
return SkinResolver.fromModel(skin);
/**
* Get a tweet by its URL
* @gqlField
*/
export async function fetch_tweet_by_url(
_: Query,
{ url }: { url: string },
{ ctx }: Ctx
): Promise<TweetModel | null> {
return TweetModel.fromAnything(ctx, url);
}
/**
* Get an archive.org item by its identifier. You can find this in the URL:
*
* https://archive.org/details/<identifier>/
* @gqlField
*/
export async function fetch_internet_archive_item_by_identifier(
_: Query,
{ identifier }: { identifier: string },
{ ctx }: Ctx
): Promise<IaItemModel | null> {
return IaItemModel.fromIdentifier(ctx, identifier);
}
/**
* Fetch archive file by it's MD5 hash
*
* Get information about a file found within a skin's wsz/wal/zip archive.
* @gqlField
*/
export async function fetch_archive_file_by_md5(
_: Query,
{ md5 }: { md5: string },
{ ctx }: Ctx
): Promise<ArchiveFileModel | null> {
return ArchiveFileModel.fromFileMd5(ctx, md5);
}
/**
* Search the database using the Algolia search index used by the Museum.
*
* Useful for locating a particular skin.
* @gqlField
*/
export async function search_skins(
_: Query,
{
query,
first = 10,
offset = 0,
}: { query: string; first?: Int; offset?: Int },
{ ctx }: Ctx
): Promise<Array<ISkin | null>> {
if (first > 1000) {
throw new Error("Can only query 1000 records via search.");
}
/**
* Get a tweet by its URL
* @gqlField
*/
async fetch_tweet_by_url(
{ url }: { url: string },
{ ctx }
): Promise<TweetModel | null> {
return TweetModel.fromAnything(ctx, url);
}
const results: { hits: { md5: string }[] } = await index.search(query, {
attributesToRetrieve: ["md5"],
length: first,
offset,
});
/**
* Get an archive.org item by its identifier. You can find this in the URL:
*
* https://archive.org/details/<identifier>/
* @gqlField
*/
async fetch_internet_archive_item_by_identifier(
{ identifier }: { identifier: string },
{ ctx }
): Promise<IaItemModel | null> {
return IaItemModel.fromIdentifier(ctx, identifier);
}
return Promise.all(
results.hits.map(async (hit) => {
const model = await SkinModel.fromMd5Assert(ctx, hit.md5);
return SkinResolver.fromModel(model);
})
);
}
/**
* Fetch archive file by it's MD5 hash
*
* Get information about a file found within a skin's wsz/wal/zip archive.
* @gqlField
*/
async fetch_archive_file_by_md5(
{ md5 }: { md5: string },
{ ctx }
): Promise<ArchiveFileModel | null> {
return ArchiveFileModel.fromFileMd5(ctx, md5);
}
/**
* Search the database using the Algolia search index used by the Museum.
*
* Useful for locating a particular skin.
* @gqlField
*/
async search_skins(
{
query,
first = 10,
offset = 0,
}: { query: string; first?: Int; offset?: Int },
{ ctx }
): Promise<Array<ISkin | null>> {
if (first > 1000) {
throw new Error("Can only query 1000 records via search.");
}
const results: { hits: { md5: string }[] } = await index.search(query, {
attributesToRetrieve: ["md5"],
length: first,
offset,
});
return Promise.all(
results.hits.map(async (hit) => {
const model = await SkinModel.fromMd5Assert(ctx, hit.md5);
return SkinResolver.fromModel(model);
})
);
}
/**
* All classic skins in the database
*
* **Note:** We don't currently support combining sorting and filtering.
* @gqlField */
skins({
/**
* All classic skins in the database
*
* **Note:** We don't currently support combining sorting and filtering.
* @gqlField */
export function skins(
_: Query,
{
first = 10,
offset = 0,
sort,
@ -142,120 +154,131 @@ class RootResolver extends MutationResolver {
}: {
first?: Int;
offset?: Int;
sort?: SkinsSortOption;
filter?: SkinsFilterOption;
}): SkinsConnection {
if (first > 1000) {
throw new Error("Maximum limit is 1000");
}
return new SkinsConnection(first, offset, sort, filter);
sort?: SkinsSortOption | null;
filter?: SkinsFilterOption | null;
}
): SkinsConnection {
if (first > 1000) {
throw new Error("Maximum limit is 1000");
}
return new SkinsConnection(first, offset, sort, filter);
}
/**
* All modern skins in the database
* @gqlField */
async modern_skins({
/**
* All modern skins in the database
* @gqlField */
export async function modern_skins(
_: Query,
{
first = 10,
offset = 0,
}: {
first?: Int;
offset?: Int;
}): Promise<ModernSkinsConnection> {
if (first > 1000) {
throw new Error("Maximum limit is 1000");
}
return new ModernSkinsConnection(first, offset);
}
/**
* A random skin that needs to be reviewed
* @gqlField */
async skin_to_review(_args: never, { ctx }): Promise<ISkin | null> {
if (!ctx.authed()) {
return null;
}
const { md5 } = await Skins.getSkinToReview();
const model = await SkinModel.fromMd5Assert(ctx, md5);
return SkinResolver.fromModel(model);
): Promise<ModernSkinsConnection> {
if (first > 1000) {
throw new Error("Maximum limit is 1000");
}
return new ModernSkinsConnection(first, offset);
}
/**
* Tweets tweeted by @winampskins
* @gqlField
*/
async tweets({
/**
* A random skin that needs to be reviewed
* @gqlField */
export async function skin_to_review(
_: Query,
_args: unknown,
{ ctx }: Ctx
): Promise<ISkin | null> {
if (!ctx.authed()) {
return null;
}
const { md5 } = await Skins.getSkinToReview();
const model = await SkinModel.fromMd5Assert(ctx, md5);
return SkinResolver.fromModel(model);
}
/**
* Tweets tweeted by @winampskins
* @gqlField
*/
export async function tweets(
_: Query,
{
first = 10,
offset = 0,
sort,
}: {
first?: Int;
offset?: Int;
sort?: TweetsSortOption;
}): Promise<TweetsConnection> {
if (first > 1000) {
throw new Error("Maximum limit is 1000");
}
return new TweetsConnection(first, offset, sort);
sort?: TweetsSortOption | null;
}
/**
* The currently authenticated user, if any.
* @gqlField
*/
me(): UserResolver | null {
return new UserResolver();
): Promise<TweetsConnection> {
if (first > 1000) {
throw new Error("Maximum limit is 1000");
}
return new TweetsConnection(first, offset, sort);
}
/**
* Get the status of a batch of uploads by md5s
* @gqlField
* @deprecated Prefer `upload_statuses` instead, were we operate on ids.
*/
async upload_statuses_by_md5(
{ md5s }: { md5s: string[] },
{ ctx }
): Promise<Array<SkinUpload | null>> {
return this._upload_statuses({ keyName: "skin_md5", keys: md5s }, ctx);
}
/**
* The currently authenticated user, if any.
* @gqlField
*/
export function me(_: Query): UserResolver | null {
return new UserResolver();
}
/**
* Get the status of a batch of uploads by ids
* @gqlField */
async upload_statuses(
{ ids }: { ids: string[] },
{ ctx }
): Promise<Array<SkinUpload | null>> {
return this._upload_statuses({ keyName: "id", keys: ids }, ctx);
}
/**
* Get the status of a batch of uploads by md5s
* @gqlField
* @deprecated Prefer `upload_statuses` instead, were we operate on ids.
*/
export async function upload_statuses_by_md5(
_: Query,
{ md5s }: { md5s: string[] },
{ ctx }: Ctx
): Promise<Array<SkinUpload | null>> {
return _upload_statuses({ keyName: "skin_md5", keys: md5s }, ctx);
}
// Shared implementation for upload_statuses and upload_statuses_by_md5
async _upload_statuses({ keyName, keys }, ctx) {
const skins = await knex("skin_uploads")
.whereIn(keyName, keys)
.orderBy("id", "desc")
.select("id", "skin_md5", "status");
/**
* Get the status of a batch of uploads by ids
* @gqlField */
export async function upload_statuses(
_: Query,
{ ids }: { ids: string[] },
{ ctx }: Ctx
): Promise<Array<SkinUpload | null>> {
return _upload_statuses({ keyName: "id", keys: ids }, ctx);
}
return Promise.all(
skins.map(async ({ id, skin_md5, status }) => {
// TODO: Could we avoid fetching the skin if it's not read?
const skinModel = await SkinModel.fromMd5(ctx, skin_md5);
const skin =
skinModel == null ? null : SkinResolver.fromModel(skinModel);
// Most of the time when a skin fails to process, it's due to some infa
// issue on our side, and we can recover. For now, we'll always tell the user
// That processing is just delayed.
status = status === "ERRORED" ? "DELAYED" : status;
return { id, skin, status, upload_md5: skin_md5 };
})
);
}
// Shared implementation for upload_statuses and upload_statuses_by_md5
async function _upload_statuses({ keyName, keys }, ctx) {
const skins = await knex("skin_uploads")
.whereIn(keyName, keys)
.orderBy("id", "desc")
.select("id", "skin_md5", "status");
/**
* A namespace for statistics about the database
* @gqlField */
statistics(): DatabaseStatisticsResolver {
return new DatabaseStatisticsResolver();
}
return Promise.all(
skins.map(async ({ id, skin_md5, status }) => {
// TODO: Could we avoid fetching the skin if it's not read?
const skinModel = await SkinModel.fromMd5(ctx, skin_md5);
const skin = skinModel == null ? null : SkinResolver.fromModel(skinModel);
// Most of the time when a skin fails to process, it's due to some infa
// issue on our side, and we can recover. For now, we'll always tell the user
// That processing is just delayed.
status = status === "ERRORED" ? "DELAYED" : status;
return { id, skin, status, upload_md5: skin_md5 };
})
);
}
/**
* A namespace for statistics about the database
* @gqlField */
export function statistics(_: Query): DatabaseStatisticsResolver {
return new DatabaseStatisticsResolver();
}
/**
@ -341,5 +364,3 @@ type SkinUploadStatus =
* @deprecated
*/
| "ARCHIVED";
export default RootResolver;

View file

@ -1,7 +1,9 @@
import { Ctx } from "..";
/** @gqlType User */
export default class UserResolver {
/** @gqlField */
username(_args: never, { ctx }): string {
username(_args: unknown, { ctx }: Ctx): string | null {
return ctx.username;
}
}

View file

@ -1,252 +1,40 @@
schema {
query: Query
mutation: Mutation
}
directive @exported(filename: String!, functionName: String!) on FIELD_DEFINITION
directive @methodName(name: String!) on FIELD_DEFINITION
"""A file found within a Winamp Skin's .wsz archive"""
type ArchiveFile {
"""
The date on the file inside the archive. Given in simplified extended ISO
format (ISO 8601).
"""
date: String @methodName(name: "getIsoDate")
"""The md5 hash of the file within the archive"""
file_md5: String @methodName(name: "getFileMd5")
"""Filename of the file within the archive"""
filename: String @methodName(name: "getFileName")
"""Is the file a directory?"""
is_directory: Boolean @methodName(name: "getIsDirectory")
"""
The uncompressed size of the file in bytes.
**Note:** Will be `null` for directories
"""
size: Int @methodName(name: "getFileSize")
"""The skin in which this file was found"""
skin: Skin
"""The content of the file, if it's a text file"""
text_content: String @methodName(name: "getTextContent")
"""
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.
It may not work for all files.
"""
url: String @methodName(name: "getUrl")
}
"""A classic Winamp skin"""
type ClassicSkin implements Node & Skin {
"""List of files contained within the skin's .wsz archive"""
archive_files: [ArchiveFile] @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/CommonSkinResolver.js", functionName: "archive_files")
"""String representation (rgb usually) of the skin's average color"""
average_color: String @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/CommonSkinResolver.js", functionName: "average_color")
"""URL to download the skin"""
download_url: String @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/CommonSkinResolver.js", functionName: "download_url")
"""
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.
"""
filename(
"""
If true, the the correct file extension (.wsz or .wal) will be .
Otherwise, the original user-uploaded file extension will be used.
"""
normalize_extension: Boolean = false
): String @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/CommonSkinResolver.js", functionName: "filename")
"""Does the skin include sprite sheets for the media library?"""
has_media_library: Boolean
"""GraphQL ID of the skin"""
id: ID! @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/CommonSkinResolver.js", functionName: "id")
"""The skin's "item" at archive.org"""
internet_archive_item: InternetArchiveItem @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/CommonSkinResolver.js", functionName: "internet_archive_item")
"""
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 @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/CommonSkinResolver.js", functionName: "md5")
"""URL of the skin on the Winamp Skin Museum"""
museum_url: String @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/CommonSkinResolver.js", functionName: "museum_url")
"""
Has the skin been flagged as "not safe for work"?"
"""
nsfw: Boolean @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/CommonSkinResolver.js", functionName: "nsfw")
"""Text of the readme file extracted from the skin"""
readme_text: String @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/CommonSkinResolver.js", functionName: "readme_text")
"""
Times that the skin has been reviewed either on the Museum's Tinder-style
review page, or via the Discord bot.
"""
reviews: [Review] @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/CommonSkinResolver.js", functionName: "reviews")
"""URL of a screenshot of the skin"""
screenshot_url: String @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/CommonSkinResolver.js", functionName: "screenshot_url")
"""The number of transparent pixels rendered by the skin."""
transparent_pixels: Int
"""Has the skin been tweeted?"""
tweeted: Boolean @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/CommonSkinResolver.js", functionName: "tweeted")
"""List of @winampskins tweets that mentioned the skin."""
tweets: [Tweet] @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/CommonSkinResolver.js", functionName: "tweets")
"""URL of webamp.org with the skin loaded"""
webamp_url: String @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/CommonSkinResolver.js", functionName: "webamp_url")
}
"""Statistics about the contents of the Museum's database."""
type DatabaseStatistics {
"""
The number of skins that have been approved for tweeting. This includes both
tweeted and untweeted skins.
**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.
**Note:** Skins can be approved and rejected by different users.
**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.
**Note:** Skins can be both approved and rejected by different users.
**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."""
web_uploads_count: Int
}
type InternetArchiveItem {
"""The Internet Archive's unique identifier for this item"""
identifier: String @methodName(name: "getIdentifier")
"""
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 @methodName(name: "getMetadataTimestamp")
"""URL to get the Internet Archive's metadata for this item in JSON form."""
metadata_url: String @methodName(name: "getMetadataUrl")
"""Our cached version of the metadata available at \`metadata_url\` (above)"""
raw_metadata_json: String @methodName(name: "getMetadataJSON")
"""The skin that this item contains"""
skin: Skin
"""The URL where this item can be viewed on the Internet Archive"""
url: String @methodName(name: "getUrl")
# Schema generated by Grats (https://grats.capt.dev)
# Do not manually edit. Regenerate by running `npx grats`.
"""The judgement made about a skin by a moderator"""
enum Rating {
APPROVED
NSFW
REJECTED
}
"""
A "modern" Winamp skin. These skins use the `.wal` file extension and are free-form.
The current status of a pending upload.
Most functionality in the Winamp Skin Museum is centered around "classic" skins,
which are currently called just `Skin` in this schema.
**Note:** Expect more values here as we try to be more transparent about
the status of a pending uploads.
"""
type ModernSkin implements Node & Skin {
"""List of files contained within the skin's .wsz archive"""
archive_files: [ArchiveFile] @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/CommonSkinResolver.js", functionName: "archive_files")
"""String representation (rgb usually) of the skin's average color"""
average_color: String @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/CommonSkinResolver.js", functionName: "average_color")
"""URL to download the skin"""
download_url: String @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/CommonSkinResolver.js", functionName: "download_url")
"""
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.
"""
filename(
"""
If true, the the correct file extension (.wsz or .wal) will be .
Otherwise, the original user-uploaded file extension will be used.
"""
normalize_extension: Boolean = false
): String @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/CommonSkinResolver.js", functionName: "filename")
"""GraphQL ID of the skin"""
id: ID! @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/CommonSkinResolver.js", functionName: "id")
"""The skin's "item" at archive.org"""
internet_archive_item: InternetArchiveItem @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/CommonSkinResolver.js", functionName: "internet_archive_item")
"""MD5 hash of the skin's file"""
md5: String @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/CommonSkinResolver.js", functionName: "md5")
"""URL of the skin on the Winamp Skin Museum"""
museum_url: String @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/CommonSkinResolver.js", functionName: "museum_url")
"""
Has the skin been flagged as "not safe for work"?"
"""
nsfw: Boolean @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/CommonSkinResolver.js", functionName: "nsfw")
"""Text of the readme file extracted from the skin"""
readme_text: String @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/CommonSkinResolver.js", functionName: "readme_text")
"""
Times that the skin has been reviewed either on the Museum's Tinder-style
review page, or via the Discord bot.
"""
reviews: [Review] @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/CommonSkinResolver.js", functionName: "reviews")
"""URL of a screenshot of the skin"""
screenshot_url: String @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/CommonSkinResolver.js", functionName: "screenshot_url")
"""Has the skin been tweeted?"""
tweeted: Boolean @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/CommonSkinResolver.js", functionName: "tweeted")
"""List of @winampskins tweets that mentioned the skin."""
tweets: [Tweet] @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/CommonSkinResolver.js", functionName: "tweets")
"""URL of webamp.org with the skin loaded"""
webamp_url: String @exported(filename: "../../../../packages/skin-database/dist/api/graphql/resolvers/CommonSkinResolver.js", functionName: "webamp_url")
enum SkinUploadStatus {
ARCHIVED
DELAYED
ERRORED
UPLOAD_REPORTED
URL_REQUESTED
}
"""A collection of "modern" Winamp skins"""
type ModernSkinsConnection {
"""The total number of skins matching the filter"""
count: Int
"""The list of skins"""
nodes: [ModernSkin]
enum SkinsFilterOption {
APPROVED
NSFW
REJECTED
TWEETED
}
type Mutation {
"""
Approve skin for tweeting
**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
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
enum SkinsSortOption {
MUSEUM
}
enum TweetsSortOption {
LIKES
RETWEETS
}
"""
@ -258,80 +46,6 @@ interface Node {
id: ID!
}
type Query {
"""
Fetch archive file by it's MD5 hash
Get information about a file found within a skin's wsz/wal/zip archive.
"""
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/<identifier>/
"""
fetch_internet_archive_item_by_identifier(identifier: String!): InternetArchiveItem
"""Get a skin by its MD5 hash"""
fetch_skin_by_md5(md5: String!): Skin
"""Get a tweet by its URL"""
fetch_tweet_by_url(url: String!): Tweet
"""The currently authenticated user, if any."""
me: User
"""All modern skins in the database"""
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
"""
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]
"""A random skin that needs to be reviewed"""
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
"""A namespace for statistics about the database"""
statistics: DatabaseStatistics
"""Tweets tweeted by @winampskins"""
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]
"""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.")
}
"""The judgement made about a skin by a moderator"""
enum Rating {
APPROVED
NSFW
REJECTED
}
"""
A review of a skin. Done either on the Museum's Tinder-style
reivew 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
}
"""
A Winamp skin. Could be modern or classic.
@ -378,29 +92,333 @@ interface Skin {
screenshot_url: String
"""Has the skin been tweeted?"""
tweeted: Boolean
"""List of @winampskins tweets that mentioned the skin."""
"""List of"""
tweets: [Tweet]
"""URL of webamp.org with the skin loaded"""
webamp_url: String
}
"""A collection of classic Winamp skins"""
type SkinsConnection {
"""Input object used for a user to request an UploadUrl"""
input UploadUrlRequest {
filename: String!
md5: String!
}
"""A file found within a Winamp Skin's .wsz archive"""
type ArchiveFile {
"""
The date on the file inside the archive. Given in simplified extended ISO
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.
It may not work for all files.
"""
url: String
}
"""A classic Winamp skin"""
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.
"""
filename(
"""
If true, the the correct file extension (.wsz or .wal) will be .
Otherwise, the original user-uploaded file extension will be used.
"""
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 work"?"
"""
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
review 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"""
tweets: [Tweet]
"""URL of webamp.org with the skin loaded"""
webamp_url: String
}
"""Statistics about the contents of the Museum's database."""
type DatabaseStatistics {
"""
The number of skins that have been approved for tweeting. This includes both
tweeted and untweeted skins.
**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.
**Note:** Skins can be approved and rejected by different users.
**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.
**Note:** Skins can be both approved and rejected by different users.
**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"""
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.
"""
web_uploads_count: Int
}
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 available 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
}
"""
A "modern" Winamp skin. These skins use the `.wal` file extension and are free-form.
Most functionality in the Winamp Skin Museum is centered around "classic" skins,
which are currently called just `Skin` in this schema.
"""
type ModernSkin 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.
"""
filename(
"""
If true, the the correct file extension (.wsz or .wal) will be .
Otherwise, the original user-uploaded file extension will be used.
"""
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
"""URL of the skin on the Winamp Skin Museum"""
museum_url: String
"""
Has the skin been flagged as "not safe for work"?"
"""
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
review page, or via the Discord bot.
"""
reviews: [Review]
"""URL of a screenshot of the skin"""
screenshot_url: String
"""Has the skin been tweeted?"""
tweeted: Boolean
"""List of"""
tweets: [Tweet]
"""URL of webamp.org with the skin loaded"""
webamp_url: String
}
"""A collection of "modern" Winamp skins"""
type ModernSkinsConnection {
"""The total number of skins matching the filter"""
count: Int
"""The list of skins"""
nodes: [Skin]
nodes: [ModernSkin]
}
enum SkinsFilterOption {
APPROVED
NSFW
REJECTED
TWEETED
type Mutation {
"""
Approve skin for tweeting
**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
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
}
enum SkinsSortOption {
MUSEUM
type Query {
"""
Fetch archive file by it's MD5 hash
Get information about a file found within a skin's wsz/wal/zip archive.
"""
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/<identifier>/
"""
fetch_internet_archive_item_by_identifier(identifier: String!): InternetArchiveItem
"""Get a skin by its MD5 hash"""
fetch_skin_by_md5(md5: String!): Skin
"""Get a tweet by its URL"""
fetch_tweet_by_url(url: String!): Tweet
"""The currently authenticated user, if any."""
me: User
"""All modern skins in the database"""
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
"""
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]
"""A random skin that needs to be reviewed"""
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
"""A namespace for statistics about the database"""
statistics: DatabaseStatistics
"""Tweets tweeted by"""
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]
"""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.")
}
"""
A review of a skin. Done either on the Museum's Tinder-style
reivew 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
}
"""Information about an attempt to upload a skin to the Museum."""
@ -416,26 +434,24 @@ type SkinUpload {
upload_md5: String
}
"""
The current status of a pending upload.
**Note:** Expect more values here as we try to be more transparent about
the status of a pending uploads.
"""
enum SkinUploadStatus {
ARCHIVED
DELAYED
ERRORED
UPLOAD_REPORTED
URL_REQUESTED
"""A collection of classic Winamp skins"""
type SkinsConnection {
"""The total number of skins matching the filter"""
count: Int
"""The list of skins"""
nodes: [Skin]
}
"""A tweet made by @winampskins mentioning a Winamp skin"""
"""A tweet made by"""
type Tweet {
"""Number of likes the tweet has received. Updated nightly. (Note: Recent likes on older tweets may not be reflected here)"""
likes: Int @methodName(name: "getLikes")
"""Number of retweets the tweet has received. Updated nightly. (Note: Recent retweets on older tweets may not be reflected here)"""
retweets: Int @methodName(name: "getRetweets")
"""
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
"""
@ -443,10 +459,10 @@ type Tweet {
_which_ skins were tweeted, not any info about the actual tweet. This means we
don't always know the URL of the tweet.
"""
url: String @methodName(name: "getUrl")
url: String
}
"""A collection of tweets made by the @winampskins bot"""
"""A collection of tweets made by the"""
type TweetsConnection {
"""The total number of tweets"""
count: Int
@ -454,11 +470,6 @@ type TweetsConnection {
nodes: [Tweet]
}
enum TweetsSortOption {
LIKES
RETWEETS
}
"""
Mutations for the upload flow
@ -490,12 +501,6 @@ type UploadUrl {
url: String
}
"""Input object used for a user to request an UploadUrl"""
input UploadUrlRequest {
filename: String!
md5: String!
}
type User {
username: String
}

File diff suppressed because it is too large Load diff

View file

@ -21,8 +21,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-b47472ff",
"graphql": "^16.8.1",
"imagemin": "^7.0.0",
"imagemin-optipng": "^7.0.0",
"knex": "^0.21.1",
@ -50,13 +49,13 @@
"cli": "ts-node --transpile-only ./cli.ts",
"sync": "ts-node --transpile-only ./tasks/syncWithArchive.ts",
"migrate": "knex migrate:latest",
"grats": "grats --output=./api/graphql/schema.graphql"
"grats": "grats"
},
"prettier": {},
"devDependencies": {
"@babel/preset-typescript": "^7.10.1",
"@types/cookie-session": "^2.0.48",
"@types/body-parser": "^1.19.5",
"@types/cookie-session": "^2.0.48",
"@types/cors": "^2.8.17",
"@types/express": "4.17.9",
"@types/express-fileupload": "^1.4.4",
@ -66,10 +65,11 @@
"@types/supertest": "^2.0.10",
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.1.0",
"grats": "^0.0.23",
"supertest": "^6.0.1",
"typescript": "^5.3.3"
},
"resolutions": {
"graphql": "15.3.0"
"graphql": "16.8.1"
}
}

View file

@ -172,7 +172,6 @@ function putTemp(fileName, buffer) {
...acl,
},
(err) => {
console.log("Hello...");
if (err) {
rejectPromise(err);
return;

View file

@ -1,8 +1,11 @@
{
"grats": {
"nullableByDefault": true
"nullableByDefault": true,
"graphqlSchema": "./api/graphql/schema.graphql",
"tsSchema": "./api/graphql/schema.ts"
},
"compilerOptions": {
"plugins": [{ "name": "grats-ts-plugin" }],
"module": "commonjs",
"esModuleInterop": true,
"target": "es6",

View file

@ -3140,14 +3140,6 @@
dependencies:
tslib "^2.4.0"
"@graphql-tools/utils@^9.2.1":
version "9.2.1"
resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-9.2.1.tgz#1b3df0ef166cfa3eae706e3518b17d5922721c57"
integrity sha512-WUw506Ql6xzmOORlriNrD6Ugx+HjVgYxt9KCXD9mHAak+eaXSwuGGPyE60hy9xaDEoXKBsG7SkG69ybitaVl6A==
dependencies:
"@graphql-typed-document-node/core" "^3.1.1"
tslib "^2.4.0"
"@graphql-tools/wrap@9.2.1":
version "9.2.1"
resolved "https://registry.npmjs.org/@graphql-tools/wrap/-/wrap-9.2.1.tgz"
@ -3159,11 +3151,6 @@
tslib "^2.4.0"
value-or-promise "1.0.11"
"@graphql-typed-document-node/core@^3.1.1":
version "3.2.0"
resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.2.0.tgz#5f3d96ec6b2354ad6d8a28bf216a1d97b5426861"
integrity sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==
"@hapi/hoek@^9.0.0", "@hapi/hoek@^9.3.0":
version "9.3.0"
resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb"
@ -15662,27 +15649,19 @@ 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:
graphql@16.8.1, graphql@^16.6.0, graphql@^16.8.1:
version "16.8.1"
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.8.1.tgz#1930a965bef1170603702acdb68aedd3f3cf6f07"
integrity sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==
grats@0.0.0-main-b47472ff:
version "0.0.0-main-b47472ff"
resolved "https://registry.yarnpkg.com/grats/-/grats-0.0.0-main-b47472ff.tgz#ca8d7f9bad66fcf7011090c681207105c4542f13"
integrity sha512-k+O8Jh3rqhCp80lZcYrXY5oWEf4FzwAtvLuTQtFskKPU8y6V/mlzL6per643uFSnisv7lmFrxY+8aBIbUDEFfg==
grats@^0.0.23:
version "0.0.23"
resolved "https://registry.yarnpkg.com/grats/-/grats-0.0.23.tgz#5fee4448c1b26b8f9ee0dfa3df49dba6f1353fd4"
integrity sha512-LqkHsw7OXXWkDAumMZ2AhV/SnDkrYgIEMhYm29ndSGmgFOP3PBfAZXgWcKrwKEU4FkWQ3JfN9PtpddwPN4t/Ig==
dependencies:
"@graphql-tools/utils" "^9.2.1"
commander "^10.0.0"
graphql "^16.6.0"
typescript "^4.9.5"
typescript "^5.0.2"
gulp-eslint@^3.0.1:
version "3.0.1"
@ -17446,11 +17425,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"
@ -27598,7 +27572,7 @@ typescript@^4.9.5:
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a"
integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==
typescript@^5.3.3:
typescript@^5.0.2, typescript@^5.3.3:
version "5.3.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37"
integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==