First stab at adopting Grats!

This commit is contained in:
Jordan Eldredge 2023-04-01 19:33:12 -07:00
parent b8438caf42
commit 59a1ed88db
22 changed files with 940 additions and 106 deletions

View file

@ -1,7 +1,9 @@
import { Int } from "grats";
import SkinModel from "../../data/SkinModel";
import { knex } from "../../db";
import ModernSkinResolver from "./resolvers/ModernSkinResolver";
/** @gqlType */
export default class ModernSkinsConnection {
_first: number;
_offset: number;
@ -14,12 +16,14 @@ export default class ModernSkinsConnection {
return query;
}
async count() {
/** @gqlField */
async count(): Promise<Int> {
const count = await this._getQuery().count("*", { as: "count" });
return count[0].count;
return Number(count[0].count);
}
async nodes(_args, ctx) {
/** @gqlField */
async nodes(_args: never, ctx): Promise<ModernSkinResolver[]> {
const skins = await this._getQuery()
.select()
.limit(this._first)

View file

@ -3,6 +3,8 @@ import * as Skins from "../../data/skins";
import { knex } from "../../db";
import SkinResolver from "./resolvers/SkinResolver";
import LRU from "lru-cache";
import { Int } from "grats";
import { ISkin } from "./resolvers/CommonSkinResolver";
const options = {
max: 100,
@ -34,12 +36,13 @@ async function getSkinMuseumPageFromCache(first: number, offset: number) {
return skins;
}
/** @gqlType */
export default class SkinsConnection {
_first: number;
_offset: number;
_sort: string;
_filter: string;
constructor(first: number, offset: number, sort: string, filter: string) {
_sort?: string;
_filter?: string;
constructor(first: number, offset: number, sort?: string, filter?: string) {
this._first = first;
this._offset = offset;
this._filter = filter;
@ -73,16 +76,18 @@ export default class SkinsConnection {
return query;
}
async count() {
/** @gqlField */
async count(): Promise<Int> {
if (this._sort === "MUSEUM") {
// This is the common case, so serve it from cache.
return getMuseumSkinCountFromCache();
}
const count = await this._getQuery().count("*", { as: "count" });
return count[0].count;
return Number(count[0].count);
}
async nodes(args, ctx) {
/** @gqlField */
async nodes(args: never, ctx): Promise<ISkin[]> {
if (this._sort === "MUSEUM") {
if (this._filter) {
throw new Error(

View file

@ -1,12 +1,17 @@
import { Int } from "grats";
import TweetModel from "../../data/TweetModel";
import { knex } from "../../db";
import TweetResolver from "./resolvers/TweetResolver";
/** @gqlEnum */
export type TweetsSortOption = "LIKES" | "RETWEETS";
/** @gqlType */
export default class TweetsConnection {
_first: number;
_offset: number;
_sort: string;
constructor(first: number, offset: number, sort: string) {
_sort?: TweetsSortOption;
constructor(first: number, offset: number, sort?: TweetsSortOption) {
this._first = first;
this._offset = offset;
this._sort = sort;
@ -23,12 +28,20 @@ export default class TweetsConnection {
return query;
}
async count() {
/**
* The total number of tweets
* @gqlField
*/
async count(): Promise<Int> {
const count = await this._getQuery().count("*", { as: "count" });
return count[0].count;
return Number(count[0].count);
}
async nodes(args, ctx) {
/**
* The list of tweets
* @gqlField
*/
async nodes(args: never, ctx): Promise<TweetResolver[]> {
const tweets = await this._getQuery()
.select()
.limit(this._first)

View file

@ -0,0 +1,257 @@
schema {
query: Query
mutation: Mutation
}
directive @exported(filename: String!, functionName: String!) on FIELD_DEFINITION
directive @methodName(name: String!) on FIELD_DEFINITION
type ArchiveFile {
date: String
file_md5: String
filename: String
is_directory: Boolean
size: Int
skin: Skin
text_content: String
url: String
}
type ClassicSkin implements Node & Skin {
archive_files: [ArchiveFile!]
average_color: String
download_url: String
filename(normalize_extension: Boolean = false): String
has_media_library: Boolean
id: ID!
internet_archive_item: InternetArchiveItem
last_algolia_index_update_date: String
md5: String
museum_url: String
nsfw: Boolean
readme_text: String
reviews: [Review!]
screenshot_url: String
transparent_pixels: Int
tweeted: Boolean
tweets: [Tweet!]
webamp_url: String
}
type DatabaseStatistics {
approved_skins_count: Int
nsfw_skins_count: Int
rejected_skins_count: Int
tweetable_skins_count: Int
tweeted_skins_count: Int
unique_classic_skins_count: Int
unreviewed_skins_count: Int
uploads_in_error_state_count: Int
uploads_pending_processing_count: Int
web_uploads_count: Int
}
type InternetArchiveItem {
identifier: String
last_metadata_scrape_date_UNSTABLE: String
metadata_url: String
raw_metadata_json: String
skin: Skin
url: String
}
type ModernSkin implements Node & Skin {
archive_files: [ArchiveFile!]
average_color: String
download_url: String
filename(normalize_extension: Boolean = false): String
id: ID!
internet_archive_item: InternetArchiveItem
md5: String
museum_url: String
nsfw: Boolean
readme_text: String
reviews: [Review!]
screenshot_url: String
tweeted: Boolean
tweets: [Tweet!]
webamp_url: String
}
type ModernSkinsConnection {
count: Int
nodes: [ModernSkin!]
}
type Mutation {
approve_skin(md5: String!): Boolean
mark_skin_nsfw(md5: String!): Boolean
reject_skin(md5: String!): Boolean
request_nsfw_review_for_skin(md5: String!): Boolean
send_feedback(email: String, message: String, url: String): Boolean
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
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!]
skin_to_review: Skin
skins(filter: SkinsFilterOption, first: Int = 10, offset: Int = 0, sort: SkinsSortOption): SkinsConnection
statistics: DatabaseStatistics
"""Tweets tweeted by @winampskins"""
tweets(first: Int = 10, offset: Int = 0, sort: TweetsSortOption): TweetsConnection
upload_statuses(ids: [String!]!): [SkinUpload!]
"""Get the status of a batch of uploads by md5s"""
upload_statuses_by_md5(md5s: [String!]!): [SkinUpload!]
}
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
}
interface Skin {
archive_files: [ArchiveFile!]
average_color: String @deprecated(reason: "Needed for migration to new skin model")
download_url: String
filename(normalize_extension: Boolean = false): String
id: ID!
internet_archive_item: InternetArchiveItem
md5: String
museum_url: String @deprecated(reason: "Needed for migration to new skin model")
nsfw: Boolean @deprecated(reason: "Needed for migration to new skin model")
readme_text: String @deprecated(reason: "Needed for migration to new skin model")
reviews: [Review!]
screenshot_url: String @deprecated(reason: "Needed for migration to new skin model")
tweeted: Boolean
tweets: [Tweet!]
webamp_url: String @deprecated(reason: "Needed for migration to new skin model")
}
type SkinUpload {
id: String
skin: Skin
status: SkinUploadStatus
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
}
type SkinsConnection {
count: Int
nodes: [Skin!]
}
enum SkinsFilterOption {
APPROVED
NSFW
REJECTED
TWEETED
}
enum SkinsSortOption {
MUSEUM
}
type Tweet {
likes: Int
retweets: Int
skin: Skin
url: String
}
type TweetsConnection {
"""The total number of tweets"""
count: Int
"""The list of tweets"""
nodes: [Tweet!]
}
enum TweetsSortOption {
LIKES
RETWEETS
}
type UploadMutations {
get_upload_urls(files: [UploadUrlRequest!]!): [UploadUrl!]
report_skin_uploaded(id: String!, md5: String!): Boolean
}
type UploadUrl {
id: String
md5: String
url: String
}
input UploadUrlRequest {
filename: String!
md5: String!
}
type User {
username: String
}

View file

@ -1,35 +1,49 @@
import { Int } from "grats";
import ArchiveFileModel from "../../../data/ArchiveFileModel";
import SkinModel from "../../../data/SkinModel";
import { ISkin } from "./CommonSkinResolver";
import SkinResolver from "./SkinResolver";
/** @gqlType ArchiveFile */
export default class ArchiveFileResolver {
_model: ArchiveFileModel;
constructor(model: ArchiveFileModel) {
this._model = model;
}
filename() {
/** @gqlField */
filename(): string {
return this._model.getFileName();
}
url() {
/** @gqlField */
url(): string {
return this._model.getUrl();
}
file_md5() {
/** @gqlField */
file_md5(): string {
return this._model.getFileMd5();
}
size() {
/** @gqlField */
size(): Promise<Int | null> {
return this._model.getFileSize();
}
text_content() {
/** @gqlField */
text_content(): Promise<string | null> {
return this._model.getTextContent();
}
is_directory() {
/** @gqlField */
is_directory(): boolean {
return this._model.getIsDirectory();
}
async skin(_, { ctx }) {
/** @gqlField */
async skin(_: never, { ctx }): Promise<ISkin | null> {
const model = await SkinModel.fromMd5Assert(ctx, this._model.getMd5());
return SkinResolver.fromModel(model);
}
date() {
/** @gqlField */
date(): string {
return this._model.getFileDate().toISOString();
}
}

View file

@ -1,49 +1,100 @@
import CommonSkinResolver from "./CommonSkinResolver";
import CommonSkinResolver, { ISkin } from "./CommonSkinResolver";
import { NodeResolver, toId } from "./NodeResolver";
import ReviewResolver from "./ReviewResolver";
import path from "path";
import { ID, Int } from "grats";
import TweetResolver from "./TweetResolver";
import ArchiveFileResolver from "./ArchiveFileResolver";
import InternetArchiveItemResolver from "./InternetArchiveItemResolver";
/** @gqlType ClassicSkin */
export default class ClassicSkinResolver
extends CommonSkinResolver
implements NodeResolver
implements NodeResolver, ISkin
{
__typename = "ClassicSkin";
async id() {
/**
* @gqlField
* @killsParentOnException
*/
id(): ID {
return toId(this.__typename, this.md5());
}
async filename(normalize_extension = false) {
/** @gqlField */
async filename({
normalize_extension = false,
}: {
normalize_extension?: boolean;
}): Promise<string> {
const filename = await this._model.getFileName();
if (normalize_extension) {
return path.parse(filename).name + ".wsz";
}
return filename;
}
museum_url() {
/** @gqlField */
md5(): string {
return super.md5();
}
/** @gqlField */
download_url(): string {
return super.download_url();
}
/** @gqlField */
tweeted(): Promise<boolean> {
return super.tweeted();
}
/** @gqlField */
tweets(): Promise<TweetResolver[]> {
return super.tweets();
}
/** @gqlField */
archive_files(): Promise<ArchiveFileResolver[]> {
return super.archive_files();
}
/** @gqlField */
internet_archive_item(): Promise<InternetArchiveItemResolver | null> {
return super.internet_archive_item();
}
/** @gqlField */
museum_url(): string {
return this._model.getMuseumUrl();
}
webamp_url() {
/** @gqlField */
webamp_url(): string {
return this._model.getWebampUrl();
}
screenshot_url() {
/** @gqlField */
screenshot_url(): string {
return this._model.getScreenshotUrl();
}
readme_text() {
/** @gqlField */
readme_text(): Promise<string | null> {
return this._model.getReadme();
}
nsfw() {
/** @gqlField */
nsfw(): Promise<boolean> {
return this._model.getIsNsfw();
}
average_color() {
/** @gqlField */
average_color(): string {
return this._model.getAverageColor();
}
/** @gqlField */
has_media_library(): Promise<boolean> {
return this._model.hasMediaLibrary();
}
async reviews() {
/** @gqlField */
async reviews(): Promise<ReviewResolver[]> {
const reviews = await this._model.getReviews();
return reviews.map((row) => new ReviewResolver(row));
}
async last_algolia_index_update_date() {
/** @gqlField */
async last_algolia_index_update_date(): Promise<string | null> {
const updates = await this._model.getAlgoliaIndexUpdates(1);
if (updates.length < 1) {
return null;
@ -51,7 +102,8 @@ export default class ClassicSkinResolver
const update = updates[0];
return new Date(update.update_timestamp * 1000).toISOString();
}
transparent_pixels(): Promise<number> {
/** @gqlField */
transparent_pixels(): Promise<Int> {
return this._model.transparentPixels();
}
}

View file

@ -1,33 +1,108 @@
import { ID } from "grats";
import SkinModel from "../../../data/SkinModel";
import ArchiveFileResolver from "./ArchiveFileResolver";
import InternetArchiveItemResolver from "./InternetArchiveItemResolver";
import ReviewResolver from "./ReviewResolver";
import TweetResolver from "./TweetResolver";
/** @gqlInterface Skin */
export interface ISkin {
__typename: string;
/**
* @gqlField
* @killsParentOnException
*/
id(): ID;
/** @gqlField */
filename({
normalize_extension = false,
}: {
normalize_extension?: boolean;
}): Promise<string>;
/** @gqlField */
md5(): string;
/** @gqlField */
download_url(): string;
/** @gqlField */
tweeted(): Promise<boolean>;
/** @gqlField */
tweets(): Promise<TweetResolver[]>;
/** @gqlField */
archive_files(): Promise<ArchiveFileResolver[]>;
/** @gqlField */
internet_archive_item(): Promise<InternetArchiveItemResolver | null>;
/** @gqlField */
reviews(): Promise<ReviewResolver[] | null>;
/**
* @gqlField
* @deprecated Needed for migration to new skin model
*/
museum_url(): string | null;
/**
* @gqlField
* @deprecated Needed for migration to new skin model
*/
webamp_url(): string | null;
/**
* @gqlField
* @deprecated Needed for migration to new skin model
*/
screenshot_url(): string | null;
/**
* @gqlField
* @deprecated Needed for migration to new skin model
*/
readme_text(): Promise<string | null>;
/**
* @gqlField
* @deprecated Needed for migration to new skin model
*/
nsfw(): Promise<boolean | null>;
/**
* @gqlField
* @deprecated Needed for migration to new skin model
*/
average_color(): string | null;
}
export default class CommonSkinResolver {
_model: SkinModel;
constructor(model: SkinModel) {
this._model = model;
}
md5() {
md5(): string {
return this._model.getMd5();
}
download_url() {
download_url(): string {
return this._model.getSkinUrl();
}
tweeted() {
tweeted(): Promise<boolean> {
return this._model.tweeted();
}
async tweets() {
async tweets(): Promise<TweetResolver[]> {
const tweets = await this._model.getTweets();
return tweets.map((tweetModel) => new TweetResolver(tweetModel));
}
async archive_files() {
async archive_files(): Promise<ArchiveFileResolver[]> {
const files = await this._model.getArchiveFiles();
return files.map((file) => new ArchiveFileResolver(file));
}
async internet_archive_item() {
async internet_archive_item(): Promise<InternetArchiveItemResolver | null> {
const item = await this._model.getIaItem();
if (item == null) {
return null;
@ -35,7 +110,7 @@ export default class CommonSkinResolver {
return new InternetArchiveItemResolver(item);
}
async reviews() {
async reviews(): Promise<ReviewResolver[] | null> {
const reviews = await this._model.getReviews();
return reviews.map((row) => new ReviewResolver(row));
}

View file

@ -1,34 +1,46 @@
import { Int } from "grats";
import * as Skins from "../../../data/skins";
/** @gqlType DatabaseStatistics */
export default class DatabaseStatisticsResolver {
unique_classic_skins_count(): Promise<number> {
/** @gqlField */
unique_classic_skins_count(): Promise<Int> {
return Skins.getClassicSkinCount();
}
tweeted_skins_count(): Promise<number> {
/** @gqlField */
tweeted_skins_count(): Promise<Int> {
return Skins.getTweetedSkinCount();
}
approved_skins_count(): Promise<number> {
/** @gqlField */
approved_skins_count(): Promise<Int> {
return Skins.getApprovedSkinCount();
}
rejected_skins_count(): Promise<number> {
/** @gqlField */
rejected_skins_count(): Promise<Int> {
return Skins.getRejectedSkinCount();
}
nsfw_skins_count(): Promise<number> {
/** @gqlField */
nsfw_skins_count(): Promise<Int> {
return Skins.getNsfwSkinCount();
}
unreviewed_skins_count(): Promise<number> {
/** @gqlField */
unreviewed_skins_count(): Promise<Int> {
return Skins.getUnreviewedSkinCount();
}
tweetable_skins_count(): Promise<number> {
/** @gqlField */
tweetable_skins_count(): Promise<Int> {
return Skins.getTweetableSkinCount();
}
uploads_pending_processing_count(): Promise<number> {
/** @gqlField */
uploads_pending_processing_count(): Promise<Int> {
return Skins.getUploadsAwaitingProcessingCount();
}
uploads_in_error_state_count(): Promise<number> {
/** @gqlField */
uploads_in_error_state_count(): Promise<Int> {
return Skins.getUploadsErroredCount();
}
web_uploads_count(): Promise<number> {
/** @gqlField */
web_uploads_count(): Promise<Int> {
return Skins.getWebUploadsCount();
}
}

View file

@ -1,29 +1,37 @@
import IaItemModel from "../../../data/IaItemModel";
import { ISkin } from "./CommonSkinResolver";
import SkinResolver from "./SkinResolver";
/** @gqlType InternetArchiveItem */
export default class InternetArchiveItemResolver {
_model: IaItemModel;
constructor(model: IaItemModel) {
this._model = model;
}
identifier() {
/** @gqlField */
identifier(): string {
return this._model.getIdentifier();
}
url() {
/** @gqlField */
url(): string {
return this._model.getUrl();
}
metadata_url() {
/** @gqlField */
metadata_url(): string {
return this._model.getMetadataUrl();
}
last_metadata_scrape_date_UNSTABLE() {
/** @gqlField */
last_metadata_scrape_date_UNSTABLE(): string | null {
return this._model.getMetadataTimestamp();
}
raw_metadata_json() {
/** @gqlField */
raw_metadata_json(): string | null {
return this._model.getMetadataJSON();
}
async skin() {
/** @gqlField */
async skin(): Promise<ISkin | null> {
const skin = await this._model.getSkin();
if (skin == null) {
return null;

View file

@ -1,22 +1,99 @@
import SkinModel from "../../../data/SkinModel";
import CommonSkinResolver from "./CommonSkinResolver";
import CommonSkinResolver, { ISkin } from "./CommonSkinResolver";
import { NodeResolver, toId } from "./NodeResolver";
import path from "path";
import { ID } from "grats";
import ReviewResolver from "./ReviewResolver";
import InternetArchiveItemResolver from "./InternetArchiveItemResolver";
import ArchiveFileResolver from "./ArchiveFileResolver";
import TweetResolver from "./TweetResolver";
/** @gqlType ModernSkin */
export default class ModernSkinResolver
extends CommonSkinResolver
implements NodeResolver
implements NodeResolver, ISkin
{
_model: SkinModel;
__typename = "ModernSkin";
async id() {
return toId(this.__typename, this.md5());
}
async filename(normalize_extension = false) {
/** @gqlField */
async filename({
normalize_extension = false,
}: {
normalize_extension?: boolean;
}): Promise<string> {
const filename = await this._model.getFileName();
if (normalize_extension) {
return path.parse(filename).name + ".wal";
}
return filename;
}
/* TODO: Get all of these from the parent class/interface */
/**
* @gqlField
* @killsParentOnException
*/
id(): ID {
return toId(this.__typename, this.md5());
}
/** @gqlField */
md5(): string {
return super.md5();
}
/** @gqlField */
download_url(): string {
return super.download_url();
}
/** @gqlField */
tweeted(): Promise<boolean> {
return super.tweeted();
}
/** @gqlField */
async tweets(): Promise<TweetResolver[]> {
return super.tweets();
}
/** @gqlField */
async archive_files(): Promise<ArchiveFileResolver[]> {
return super.archive_files();
}
/** @gqlField */
async internet_archive_item(): Promise<InternetArchiveItemResolver | null> {
return super.internet_archive_item();
}
/** @gqlField */
async reviews(): Promise<ReviewResolver[] | null> {
return super.reviews();
}
/** @gqlField */
museum_url(): string | null {
return null;
}
/** @gqlField */
webamp_url(): string | null {
return null;
}
/** @gqlField */
screenshot_url(): string | null {
return null;
}
/** @gqlField */
async readme_text(): Promise<string | null> {
return null;
}
/** @gqlField */
async nsfw(): Promise<boolean | null> {
return null;
}
/** @gqlField */
average_color(): string | null {
return null;
}
}

View file

@ -5,11 +5,24 @@ import * as Skins from "../../../data/skins";
import { processUserUploads } from "../../processUserUploads";
// We don't use a resolver here, just return the value directly.
type UploadUrl = { id: string; url: string; md5: string };
/** @gqlType */
type UploadUrl = {
/** @gqlField */
id: string;
/** @gqlField */
url: string;
/** @gqlField */
md5: string;
};
/** @gqlInput */
type UploadUrlRequest = { filename: string; md5: string };
/** @gqlType UploadMutations */
class UploadMutationResolver {
/** @gqlField */
async get_upload_urls(
{ files }: { files: { filename: string; md5: string }[] },
{ files }: { files: UploadUrlRequest[] },
{ ctx }
): Promise<UploadUrl[]> {
const missing: UploadUrl[] = [];
@ -28,7 +41,11 @@ class UploadMutationResolver {
return missing;
}
async report_skin_uploaded({ id, md5 }, req) {
/** @gqlField */
async report_skin_uploaded(
{ id, md5 }: { id: string; md5: string },
req
): Promise<boolean> {
// TODO: Validate md5 and id;
await Skins.recordUserUploadComplete(md5, id);
// Don't await, just kick off the task.
@ -47,11 +64,17 @@ function requireAuthed(handler) {
};
}
/** @gqlType Mutation */
export default class MutationResolver {
async upload() {
/** @gqlField */
async upload(): Promise<UploadMutationResolver> {
return new UploadMutationResolver();
}
async send_feedback({ message, email, url }, req) {
/** @gqlField */
async send_feedback(
{ message, email, url }: { message?: string; email?: string; url?: string },
req
): Promise<boolean> {
req.notify({
type: "GOT_FEEDBACK",
url,
@ -61,7 +84,12 @@ export default class MutationResolver {
return true;
}
reject_skin = requireAuthed(async ({ md5 }, req) => {
/** @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) {
@ -72,7 +100,12 @@ export default class MutationResolver {
return true;
});
approve_skin = requireAuthed(async ({ md5 }, req) => {
/** @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) {
@ -83,7 +116,12 @@ export default class MutationResolver {
return true;
});
mark_skin_nsfw = requireAuthed(async ({ md5 }, req) => {
/** @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) {
@ -94,7 +132,11 @@ export default class MutationResolver {
return true;
});
async request_nsfw_review_for_skin({ md5 }, req) {
/** @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);

View file

@ -1,5 +1,18 @@
import { ID } from "grats";
/**
* A globally unique object. The `id` here is intended only for use within
* GraphQL.
* https://graphql.org/learn/global-object-identification/
*
* @gqlInterface Node
*/
export interface NodeResolver {
id({ id }): Promise<string>;
/**
* @gqlField
* @killsParentOnException
*/
id(): ID;
__typename: string;
}

View file

@ -1,14 +1,38 @@
import { ReviewRow } from "../../../types";
import { Rating, ReviewRow } from "../../../types";
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.
* @gqlType Review */
export default class ReviewResolver {
_model: ReviewRow;
constructor(model: ReviewRow) {
this._model = model;
}
reviewer() {
/**
* The skin that was reviewed
* @gqlField
*/
skin(args: never, { ctx }): Promise<ISkin | null> {
return SkinResolver.fromMd5(ctx, this._model.skin_md5);
}
/**
* The user who made the review (if known). **Note:** In the early days we
* didn't track this, so many will be null.
* @gqlField
*/
reviewer(): string {
return this._model.reviewer;
}
rating() {
/**
* The rating that the user gave the skin
* @gqlField
*/
rating(): Rating {
return this._model.review;
}
}

View file

@ -5,9 +5,10 @@ import SkinResolver from "../resolvers/SkinResolver";
import TweetResolver from "../resolvers/TweetResolver";
import UserResolver from "../resolvers/UserResolver";
import SkinsConnection from "../SkinsConnection";
import TweetsConnection from "../TweetsConnection";
import TweetsConnection, { TweetsSortOption } from "../TweetsConnection";
import InternetArchiveItemResolver from "./InternetArchiveItemResolver";
import * as Skins from "../../../data/skins";
import { ID, Int } from "grats";
import algoliasearch from "algoliasearch";
import MutationResolver from "./MutationResolver";
@ -15,16 +16,23 @@ import { knex } from "../../../db";
import ArchiveFileModel from "../../../data/ArchiveFileModel";
import ArchiveFileResolver from "./ArchiveFileResolver";
import DatabaseStatisticsResolver from "./DatabaseStatisticsResolver";
import { fromId } from "./NodeResolver";
import { fromId, NodeResolver } from "./NodeResolver";
import ModernSkinsConnection from "../ModernSkinsConnection";
import ModernSkinResolver from "./ModernSkinResolver";
import { ISkin } from "./CommonSkinResolver";
// 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 {
async node({ id }, { ctx }) {
/**
* 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) {
@ -39,7 +47,15 @@ class RootResolver extends MutationResolver {
}
return null;
}
async fetch_skin_by_md5({ md5 }, { ctx }) {
/**
* 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;
@ -50,21 +66,47 @@ class RootResolver extends MutationResolver {
return SkinResolver.fromModel(skin);
}
}
async fetch_tweet_by_url({ url }, { ctx }) {
/**
* Get a tweet by its URL
* @gqlField
*/
async fetch_tweet_by_url(
{ url }: { url: string },
{ ctx }
): Promise<TweetResolver | null> {
const tweet = await TweetModel.fromAnything(ctx, url);
if (tweet == null) {
return null;
}
return new TweetResolver(tweet);
}
async fetch_internet_archive_item_by_identifier({ identifier }, { ctx }) {
/**
* 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<InternetArchiveItemResolver | null> {
const iaItem = await IaItemModel.fromIdentifier(ctx, identifier);
if (iaItem == null) {
return null;
}
return new InternetArchiveItemResolver(iaItem);
}
async fetch_archive_file_by_md5({ md5 }, { ctx }) {
/**
* 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<ArchiveFileResolver | null> {
const archiveFile = await ArchiveFileModel.fromFileMd5(ctx, md5);
if (archiveFile == null) {
return null;
@ -72,7 +114,20 @@ class RootResolver extends MutationResolver {
return new ArchiveFileResolver(archiveFile);
}
async search_skins({ query, first, offset }, { ctx }) {
/**
* 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<ISkin[]> {
if (first > 1000) {
throw new Error("Can only query 1000 records via search.");
}
@ -91,21 +146,40 @@ class RootResolver extends MutationResolver {
);
}
async skins({ first, offset, sort, filter }) {
/** @gqlField */
skins({
first = 10,
offset = 0,
sort,
filter,
}: {
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);
}
async modern_skins({ first, offset }) {
/** @gqlField */
async modern_skins({
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);
}
async skin_to_review(_args, { ctx }) {
/** @gqlField */
async skin_to_review(_args: never, { ctx }): Promise<ISkin | null> {
if (!ctx.authed()) {
return null;
}
@ -113,20 +187,50 @@ class RootResolver extends MutationResolver {
const model = await SkinModel.fromMd5Assert(ctx, md5);
return SkinResolver.fromModel(model);
}
async tweets({ first, offset, sort }) {
/**
* Tweets tweeted by @winampskins
* @gqlField
*/
async tweets({
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);
}
me() {
/**
* The currently authenticated user, if any.
* @gqlField
*/
me(): UserResolver | null {
return new UserResolver();
}
async upload_statuses_by_md5({ md5s }, { ctx }) {
/**
* Get the status of a batch of uploads by md5s
* @gqlField
*/
async upload_statuses_by_md5(
{ md5s }: { md5s: string[] },
{ ctx }
): Promise<SkinUpload[]> {
return this._upload_statuses({ keyName: "skin_md5", keys: md5s }, ctx);
}
async upload_statuses({ ids }, { ctx }) {
/** @gqlField */
async upload_statuses(
{ ids }: { ids: string[] },
{ ctx }
): Promise<SkinUpload[]> {
return this._upload_statuses({ keyName: "id", keys: ids }, ctx);
}
@ -152,9 +256,83 @@ class RootResolver extends MutationResolver {
);
}
statistics() {
/** @gqlField */
statistics(): DatabaseStatisticsResolver {
return new DatabaseStatisticsResolver();
}
}
/** @gqlType */
type SkinUpload = {
/** @gqlField */
id: string;
/** @gqlField */
status: SkinUploadStatus;
/** @gqlField */
skin: ISkin | null;
/** @gqlField */
upload_md5: string;
};
/** @gqlEnum */
type SkinsSortOption =
/*
the Museum's (https://skins.webamp.org) special sorting rules.
Roughly speaking, it's:
1. The four classic default skins
2. Tweeted skins first (sorted by the number of likes/retweets)
3. Approved, but not tweeted yet, skins
4. Unreviwed skins
5. Rejected skins
6. NSFW skins
*/
"MUSEUM";
/** @gqlEnum */
type SkinsFilterOption =
/*
Only the skins that have been approved for tweeting
*/
| "APPROVED"
/*
Only the skins that have been rejected for tweeting
*/
| "REJECTED"
/*
Only the skins that have been marked NSFW
*/
| "NSFW"
/*
Only the skins that have been tweeted
*/
| "TWEETED";
/**
* 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.
* @gqlEnum
*/
type SkinUploadStatus =
/** The user has requested a URL, but the skin has not yet been processed. */
| "URL_REQUESTED"
/** The user has notified us that the skin has been uploaded, but we haven't yet
processed it. */
| "UPLOAD_REPORTED"
/** An error occured processing the skin. Usually this is a transient error, and
the skin will be retried at a later time. */
| "ERRORED"
/** An error occured processing the skin, but it was the fault of the server. It
will be processed at a later date. */
| "DELAYED"
/** The skin has been successfully added to the Museum.
* @deprecated
*/
| "ARCHIVED";
export default RootResolver;

View file

@ -1,12 +1,21 @@
import SkinModel from "../../../data/SkinModel";
import UserContext from "../../../data/UserContext";
import ClassicSkinResolver from "./ClassicSkinResolver";
import { ISkin } from "./CommonSkinResolver";
import ModernSkinResolver from "./ModernSkinResolver";
export default class SkinResolver {
constructor() {
throw new Error("This is a stub.");
}
static fromModel(model: SkinModel) {
static async fromMd5(ctx: UserContext, md5: string): Promise<ISkin | null> {
const skin = await SkinModel.fromMd5(ctx, md5);
if (skin == null) {
return null;
}
return this.fromModel(skin);
}
static fromModel(model: SkinModel): ISkin {
if (model.getSkinType() === "MODERN") {
return new ModernSkinResolver(model);
} else {

View file

@ -1,22 +1,29 @@
import { Int } from "grats";
import TweetModel from "../../../data/TweetModel";
import { ISkin } from "./CommonSkinResolver";
import SkinResolver from "./SkinResolver";
/** @gqlType Tweet */
export default class TweetResolver {
_model: TweetModel;
constructor(model: TweetModel) {
this._model = model;
}
url() {
/** @gqlField */
url(): string | null {
return this._model.getUrl();
}
likes() {
/** @gqlField */
likes(): Int {
return this._model.getLikes();
}
retweets() {
/** @gqlField */
retweets(): Int {
return this._model.getRetweets();
}
async skin() {
/** @gqlField */
async skin(): Promise<ISkin | null> {
const skin = await this._model.getSkin();
if (skin == null) {
return null;

View file

@ -1,5 +1,7 @@
/** @gqlType User */
export default class UserResolver {
username(_args, { ctx }) {
/** @gqlField */
username(_args: never, { ctx }): string {
return ctx.username;
}
}

View file

@ -599,28 +599,28 @@ type Mutation {
**Note:** Requires being logged in
"""
reject_skin(md5: String): Boolean
reject_skin(md5: String!): Boolean
"""
Approve skin for tweeting
**Note:** Requires being logged in
"""
approve_skin(md5: String): Boolean
approve_skin(md5: String!): Boolean
"""
Mark a skin as NSFW
**Note:** Requires being logged in
"""
mark_skin_nsfw(md5: String): Boolean
mark_skin_nsfw(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
request_nsfw_review_for_skin(md5: String!): Boolean
}
"""
@ -695,7 +695,7 @@ type Query {
https://graphql.org/learn/global-object-identification/
"""
node(id: ID): Node
node(id: ID!): Node
"""
The currently authenticated user, if any.

View file

@ -28,6 +28,7 @@
"express-graphql": "^0.12.0",
"express-sitemap-xml": "^2.0.0",
"graphql": "14.7.0",
"grats": "^0.0.0-main-44ac2e23",
"imagemin": "^7.0.0",
"imagemin-optipng": "^7.0.0",
"knex": "^0.21.1",
@ -54,7 +55,9 @@
"bot": "ts-node --transpile-only ./discord-bot/index.js",
"cli": "ts-node --transpile-only ./cli.ts",
"sync": "ts-node --transpile-only ./tasks/syncWithArchive.ts",
"migrate": "knex migrate:latest"
"migrate": "knex migrate:latest",
"grats": "grats --output=./api/graphql/grats-schema.graphql",
"grats-progress": "yarn grats && graphql-schema-diff --fail-on-all-changes --sort-schema api/graphql/schema.graphql api/graphql/grats-schema.graphql"
},
"prettier": {},
"devDependencies": {

View file

@ -1,4 +1,7 @@
{
"grats": {
"nullableByDefault": true
},
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true,

View file

@ -5,6 +5,9 @@ export type TweetStatus =
| "UNREVIEWED"
| "NSFW";
/** @gqlEnum */
export type Rating = "APPROVED" | "REJECTED" | "NSFW";
export type SkinType = "MODERN" | "CLASSIC";
export type SkinRow = {
@ -32,7 +35,7 @@ export type InstagramPostRow = {
export type ReviewRow = {
skin_md5: string;
review: "APPROVED" | "REJECTED" | "NSFW";
review: Rating;
reviewer: string;
};

View file

@ -2912,6 +2912,14 @@
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"
@ -2923,6 +2931,11 @@
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":
version "9.3.0"
resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb"
@ -10084,6 +10097,11 @@ comma-separated-tokens@^1.0.0:
resolved "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz"
integrity sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==
commander@^10.0.0:
version "10.0.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.0.tgz#71797971162cd3cf65f0b9d24eb28f8d303acdf1"
integrity sha512-zS5PnTI22FIRM6ylNW8G4Ap0IEOyk62fhLSD0+uHRT9McRCLGpkVNvao4bjimpK/GShynyQkFFxHhwMcETmduA==
commander@^2.19.0, commander@^2.20.0, commander@^2.20.3, commander@^2.3.0:
version "2.20.3"
resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz"
@ -14760,6 +14778,21 @@ graphql@14.7.0:
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==
grats@^0.0.0-main-44ac2e23:
version "0.0.0-main-44ac2e23"
resolved "https://registry.yarnpkg.com/grats/-/grats-0.0.0-main-44ac2e23.tgz#b788e44c3d3eaac28588741a7c34ff4c69832183"
integrity sha512-IFVtiWuOtcjw24+TsvuZvYZci4e8jPvs7WiPrEQtsvFkZ4HEJqn+PUsMPq+R5D7Ox4NQSSTZP/r7iFUwsi19TQ==
dependencies:
"@graphql-tools/utils" "^9.2.1"
commander "^10.0.0"
graphql "^16.6.0"
typescript "^4.9.5"
gulp-eslint@^3.0.1:
version "3.0.1"
resolved "https://registry.npmjs.org/gulp-eslint/-/gulp-eslint-3.0.1.tgz"