diff --git a/src/Feedback.js b/src/Feedback.js index 3666fdfa..c13cf4bf 100644 --- a/src/Feedback.js +++ b/src/Feedback.js @@ -6,6 +6,25 @@ import { useActionCreator } from "./hooks"; import { useCallback } from "react"; import { useSelector } from "react-redux"; +import { fetchGraphql, gql } from "./utils"; +import { USE_GRAPHQL } from "./constants"; + +async function sendFeedback(variables) { + if (USE_GRAPHQL) { + const mutation = gql` + mutation GiveFeedback($message: String!, $email: String, $url: String) { + send_feedback(message: $message, email: $email, url: $url) + } + `; + await fetchGraphql(mutation, variables); + } else { + await fetch(`${API_URL}/feedback`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(variables), + }); + } +} export default function Feedback() { const close = useActionCreator(Actions.closeFeedbackForm); @@ -15,17 +34,14 @@ export default function Feedback() { const [sent, setSent] = useState(false); const url = useSelector(getUrl); const send = useCallback(async () => { - const body = { message, email, url: "https://skins/webamp.org" + url }; if (message.trim().length === 0) { alert("Please add a message before sending."); return; } + const body = { message, email, url: "https://skins/webamp.org" + url }; setSending(true); - await fetch(`${API_URL}/feedback`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); + await sendFeedback(body); + setSent(true); }, [message, email, url]); diff --git a/src/ReviewPage.js b/src/ReviewPage.js index ade04483..2c393165 100644 --- a/src/ReviewPage.js +++ b/src/ReviewPage.js @@ -1,6 +1,7 @@ import * as Utils from "./utils"; +import { gql } from "./utils"; import TinderCard from "react-tinder-card"; -import { API_URL } from "./constants"; +import { API_URL, USE_GRAPHQL } from "./constants"; import React, { useState, useEffect } from "react"; function warmScreenshotImage(hash) { @@ -8,6 +9,34 @@ function warmScreenshotImage(hash) { new Image().src = screenshotUrl; } +const mutation = gql` + query GetSkinToReview { + skin_to_review { + filename + md5 + } + } +`; + +async function getSkinToReview() { + if (USE_GRAPHQL) { + const data = await Utils.fetchGraphql(mutation); + return data.skin_to_review; + } else { + const response = await fetch( + `${API_URL}/to_review?cacheBust=${Math.random()}`, + { + mode: "cors", + credentials: "include", + } + ); + if (response.status === 403) { + window.location = `${API_URL}/auth`; + } + return response.json(); + } +} + function useQueuedSkin() { const [queue, setQueue] = useState([]); function remove() { @@ -23,24 +52,13 @@ function useQueuedSkin() { return; } let canceled = false; - fetch(`${API_URL}/to_review?cacheBust=${Math.random()}`, { - mode: "cors", - credentials: "include", - }) - .then((response) => { - if (response.status === 403) { - window.location = `${API_URL}/auth`; - } - return response; - }) - .then((response) => response.json()) - .then((response) => { - if (canceled) { - return; - } - warmScreenshotImage(response.md5); - setQueue((queue) => [...queue, response]); - }); + getSkinToReview().then((response) => { + if (canceled) { + return; + } + warmScreenshotImage(response.md5); + setQueue((queue) => [...queue, response]); + }); return () => (canceled = true); }, [queue]); @@ -48,47 +66,75 @@ function useQueuedSkin() { return [queue, remove]; } +async function approveSkin(md5) { + if (USE_GRAPHQL) { + const mutation = gql` + mutation ApproveSkin($md5: String!) { + approve_skin(md5: $md5) + } + `; + await Utils.fetchGraphql(mutation, { md5 }); + } else { + await restReview("approve", md5); + } +} + +async function rejectSkin(md5) { + if (USE_GRAPHQL) { + const mutation = gql` + mutation RejectSkin($md5: String!) { + reject_skin(md5: $md5) + } + `; + await Utils.fetchGraphql(mutation, { md5 }); + } else { + await restReview("reject", md5); + } +} + +async function markSkinNSFW(md5) { + if (USE_GRAPHQL) { + const mutation = gql` + mutation markSkinNSFW($md5: String!) { + mark_skin_nsfw(md5: $md5) + } + `; + await Utils.fetchGraphql(mutation, { md5 }); + } else { + await restReview("nsfw", md5); + } +} + +async function restReview(action, md5) { + const response = await fetch(`${API_URL}/skins/${md5}/${action}`, { + method: "POST", + mode: "cors", + credentials: "include", + }); + + if (response.status === 403) { + window.location = `${API_URL}/auth`; + } +} + export default function ReviewPage() { const [skins, remove] = useQueuedSkin(); async function approve(skin) { remove(); - const response = await fetch(`${API_URL}/skins/${skin.md5}/approve`, { - method: "POST", - mode: "cors", - credentials: "include", - }); - if (response.status === 403) { - window.location = `${API_URL}/auth`; - } + await approveSkin(skin.md5); } async function reject(skin) { remove(); - const response = await fetch(`${API_URL}/skins/${skin.md5}/reject`, { - method: "POST", - mode: "cors", - credentials: "include", - }); - - if (response.status === 403) { - window.location = `${API_URL}/auth`; - } + await rejectSkin(skin.md5); } async function nsfw(skin) { remove(); - const response = await fetch(`${API_URL}/skins/${skin.md5}/nsfw`, { - method: "POST", - mode: "cors", - credentials: "include", - }); - - if (response.status === 403) { - window.location = `${API_URL}/auth`; - } + await markSkinNSFW(skin.md5); } useEffect(() => { - if (skins.lenght === 0) { + if (skins.length === 0) { return; } function handleKeypress(e) { diff --git a/src/constants.js b/src/constants.js index 0adc6824..0a45fe71 100644 --- a/src/constants.js +++ b/src/constants.js @@ -14,15 +14,15 @@ export const REVIEW_PAGE = "REVIEW_PAGE"; * 2. Once processed, the server moves them to https://s3.amazonaws.com/cdn.webampskins.org * 3. There is a VPS setup with Varnish which is a proxy in front of https://s3.amazonaws.com/cdn.webampskins.org * 4. https://mirror.webampskins.org is the CloudFlare CDN wich points the the VPS proxy - * + * * When a request hits CloudFlare it first tries to get it from its local cache. * If it misses, it goes to the VPS. If the VPS is missing the file, it fetches * it from S3. - * + * * If we ever have trouble with the VPS, we can switch to using * https://cdn.webampskins.org which is just CloudFlare in front of AWS * directly. It's more expensive, but more reliable. - * + * */ // export const CDN = "https://s3.amazonaws.com/webamp-uploaded-skins"; @@ -30,7 +30,10 @@ export const REVIEW_PAGE = "REVIEW_PAGE"; export const S3_SCREENSHOT_CDN = "https://s3.amazonaws.com/cdn.webampskins.org"; export const SCREENSHOT_CDN = "https://mirror.webampskins.org"; // mirror. is having some issue with CORs headers that I need to resolve. -export const SKIN_CDN = params.vps ? "https://mirror.webampskins.org" : "https://cdn.webampskins.org"; +export const SKIN_CDN = params.vps + ? "https://mirror.webampskins.org" + : "https://cdn.webampskins.org"; +export const USE_GRAPHQL = Boolean(params.graphql); // Uncomment these if something goes wrong // export const SCREENSHOT_CDN = "https://cdn.webampskins.org"; // export const SKIN_CDN = "https://cdn.webampskins.org"; diff --git a/src/redux/epics.js b/src/redux/epics.js index fa79c6f8..58714829 100644 --- a/src/redux/epics.js +++ b/src/redux/epics.js @@ -3,6 +3,8 @@ import { of, from, EMPTY, concat, timer, defer } from "rxjs"; import * as Actions from "./actionCreators"; import * as Selectors from "./selectors"; import * as Utils from "../utils"; +import { USE_GRAPHQL } from "../constants"; +import { gql } from "../utils"; import { tap, filter, @@ -181,23 +183,56 @@ const chunkState = {}; const unloadedSkinEpic = (actions, states) => actions.pipe( filter((action) => action.type === "REQUEST_UNLOADED_SKIN"), - mergeMap(async ({ index }) => { - const chunk = Math.floor(index / (CHUNK_SIZE - 1)); - - if (chunkState[chunk] != null) { - return null; - } - chunkState[chunk] = "fetching"; - const response = await fetch( - `${API_URL}/skins?offset=${chunk * CHUNK_SIZE}&first=${CHUNK_SIZE}` - ); - - // TODO: Handle 404 - - const body = await response.json(); - return [body, chunk]; + map(({ index }) => { + return Math.floor(index / (CHUNK_SIZE - 1)); + }), + filter((chunk) => chunkState[chunk] == null), + map((chunk) => { + chunkState[chunk] = "fetching"; + const offset = chunk * CHUNK_SIZE; + const first = CHUNK_SIZE; + return { offset, first, chunk }; + }), + mergeMap(({ offset, first, chunk }) => { + if (USE_GRAPHQL) { + const query = ` + query MuseumPage($offset: Int, $first: Int) { + skins(offset: $offset, first: $first, sort: MUSEUM) { + count + nodes { + md5 + filename + nsfw + } + } + } + `; + + return from(Utils.fetchGraphql(query, { offset, first })).pipe( + map((data) => { + // Map GraphQL data into the format previously returned by REST. + return { + skinCount: data.skins.count, + skins: data.skins.nodes.map((skin) => ({ + md5: skin.md5, + fileName: skin.filename, + nsfw: skin.nsfw, + })), + }; + }), + map((payload) => [payload, chunk]) + ); + } + + return from( + fetch( + `${API_URL}/skins?offset=${chunk * CHUNK_SIZE}&first=${CHUNK_SIZE}` + ) + ).pipe( + mergeMap((response) => response.json()), + map((body) => [body, chunk]) + ); }), - filter(Boolean), mergeMap(([body, chunk]) => { return of( { type: "GOT_SKIN_CHUNK", chunk, payload: body.skins }, @@ -430,10 +465,26 @@ const urlEpic = (actions, state) => { return actions.pipe( map(() => Selectors.getUrl(state.value)), distinctUntilChanged(), - startWith(window.location), + startWith(window.location.pathname), tap((url) => { - window.ga("set", "page", url); - window.history.replaceState({}, Selectors.getPageTitle(state), url); + const currentParams = new URLSearchParams(document.location.search); + const proposedUrl = new URL(window.location.origin + url); + + // There are some params that we want to preserve across reloads. + for (const key of ["graphql", "vps"]) { + let current = currentParams.get(key); + if (current == null) { + proposedUrl.searchParams.delete(key); + } else { + proposedUrl.searchParams.set(key, current); + } + } + + const newUrl = proposedUrl.toString(); + + window.ga("set", "page", newUrl); + + window.history.replaceState({}, Selectors.getPageTitle(state), newUrl); }), ignoreElements() ); @@ -449,16 +500,37 @@ const skinDataEpic = (actions, state) => { skinData.fileName == null || skinData.nsfw == null ) { - return from(fetch(`${API_URL}/skins/${hash}`)).pipe( - switchMap((response) => response.json()), - map((body) => { - return Actions.gotSkinData(hash, { - md5: hash, - fileName: body.fileName, - nsfw: body.nsfw, - }); - }) - ); + if (USE_GRAPHQL) { + const QUERY = gql` + query ($md5: String!) { + fetch_skin_by_md5(md5: $md5) { + filename + nsfw + } + } + `; + return from(Utils.fetchGraphql(QUERY, { md5: hash })).pipe( + map((data) => { + const skin = data.fetch_skin_by_md5; + return Actions.gotSkinData(hash, { + md5: hash, + fileName: skin.fileName, + nsfw: skin.nsfw, + }); + }) + ); + } else { + return from(fetch(`${API_URL}/skins/${hash}`)).pipe( + switchMap((response) => response.json()), + map((body) => { + return Actions.gotSkinData(hash, { + md5: hash, + fileName: body.fileName, + nsfw: body.nsfw, + }); + }) + ); + } } return EMPTY; }) @@ -470,12 +542,21 @@ const markNsfwEpic = (actions) => { filter((action) => action.type === "MARK_NSFW"), mergeMap(async ({ hash }) => { try { - const response = await fetch(`${API_URL}/skins/${hash}/report`, { - method: "POST", - mode: "cors", - }); - if (!response.ok) { - throw new Error("Failed to report skin."); + if (USE_GRAPHQL) { + const mutation = gql` + mutation ReportSkin($md5: String!) { + request_nsfw_review_for_skin(md5: $md5) + } + `; + await Utils.fetchGraphql(mutation, { md5: hash }); + } else { + const response = await fetch(`${API_URL}/skins/${hash}/report`, { + method: "POST", + mode: "cors", + }); + if (!response.ok) { + throw new Error("Failed to report skin."); + } } } catch (e) { return Actions.alert( diff --git a/src/upload/uploadUtils.js b/src/upload/uploadUtils.js index 73f6dfbd..a755709d 100644 --- a/src/upload/uploadUtils.js +++ b/src/upload/uploadUtils.js @@ -1,4 +1,5 @@ -import { API_URL } from "../constants"; +import { API_URL, USE_GRAPHQL } from "../constants"; +import { gql, fetchGraphql } from "../utils"; // Upload a skin to S3 and then notify our API that it's ready to process. export async function upload(fileObj) { @@ -38,32 +39,94 @@ export async function upload(fileObj) { // in the DB. For missing skins, we get a URL we can use to upload directly to // S3. export async function getUploadUrls(skins) { - const response = await fetch(`${API_URL}/skins/get_upload_urls`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ skins }), - }); - return response.json(); + if (USE_GRAPHQL) { + const files = Object.entries(skins).map(([md5, filename]) => { + return { + md5, + filename, + }; + }); + const mutation = gql` + mutation GetUploadUrls($files: [uplaodRequst!]!) { + upload { + get_upload_urls(files: $files) { + id + url + md5 + } + } + } + `; + const data = await fetchGraphql(mutation, { files }); + const normalized = {}; + for (const { md5, id, url } in data.upload.get_upload_urls) { + normalized[md5] = { + id, + url, + }; + } + return normalized; + } else { + const response = await fetch(`${API_URL}/skins/get_upload_urls`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ skins }), + }); + return response.json(); + } } // Tell the server that we've uploaded a given skin to S3. export async function reportUploaded(md5, id) { - const url = new URL(`${API_URL}/skins/${md5}/uploaded`); - url.searchParams.append("id", id); - const response = await fetch(url, { method: "POST" }); - if (!response.ok) { - throw new Error("Unable to report skin as uploaded."); + if (USE_GRAPHQL) { + const mutation = gql` + mutation GetUploadUrls($id: String!, $md5: String!) { + upload { + report_skin_uploaded(id: $id, md5: $md5) + } + } + `; + const data = await fetchGraphql(mutation, { id, md5 }); + if (!data.upload.report_skin_uploaded) { + throw new Error("Unable to report skin as uploaded."); + } + } else { + const url = new URL(`${API_URL}/skins/${md5}/uploaded`); + url.searchParams.append("id", id); + const response = await fetch(url, { method: "POST" }); + if (!response.ok) { + throw new Error("Unable to report skin as uploaded."); + } } } // Given a list of md5s, ask the server which ones have been fully screenshot etc. export async function checkMd5sUploadStatus(md5s) { - const response = await fetch(`${API_URL}/skins/status`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ hashes: md5s }), - }); - return response.json(); + if (USE_GRAPHQL) { + const query = gql` + query CheckUploadStatus($md5s: [String!]!) { + upload_statuses_by_md5(md5s: $md5s) { + upload_md5 + status + } + } + `; + const data = await fetchGraphql(query, { md5s }); + return data.upload_statuses_by_md5.map((status) => { + return { + id: status.id, + md5: status.upload_md5, + status: status.status, + }; + }); + } else { + const response = await fetch(`${API_URL}/skins/status`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hashes: md5s }), + }); + return response.json(); + } } export async function hashFile(file) { diff --git a/src/utils.js b/src/utils.js index dd50c6a7..d8a58bba 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,4 +1,4 @@ -import { SKIN_CDN, SCREENSHOT_CDN } from "./constants"; +import { SKIN_CDN, SCREENSHOT_CDN, API_URL } from "./constants"; export function screenshotUrlFromHash(hash) { return `${SCREENSHOT_CDN}/screenshots/${hash}.png`; @@ -91,3 +91,42 @@ export function filenameIsReadme(filename) { ].some((name) => filename.match(new RegExp(name, "i"))) ); } + +// Tools like Prettier can infer that a string is GraphQL if it uses this tagged +// template liteal. +export function gql(strings) { + return strings[0]; +} + +export async function fetchGraphql(query, variables = {}) { + const url = `${API_URL}/graphql`; + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + mode: "cors", + credentials: "include", + body: JSON.stringify({ query, variables }), + }); + if (response.status === 403) { + window.location = `${API_URL}/auth`; + } + if (!response.ok) { + const payload = await response.text(); + throw new Error( + `GraphQL respose error. +URL: ${url} +Status: ${response.status}: +Respones body: +${payload}` + ); + } + + const payload = await response.json(); + if (payload.errors) { + console.warn("GraphQL Response included errors", payload.errors); + } + return payload.data; +}