Add query param to use the graphql endpoint

This commit is contained in:
Jordan Eldredge 2022-02-21 09:54:44 -08:00
parent 1bf510ec0d
commit 4af0a37011
6 changed files with 358 additions and 110 deletions

View file

@ -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]);

View file

@ -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) {

View file

@ -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";

View file

@ -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(

View file

@ -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) {

View file

@ -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;
}