import { Button, CircularProgress, CircularProgressLabel, Container, FormControl, FormLabel, Heading, HStack, Input, Link, Text, Textarea, useToast, } from "@chakra-ui/react"; import { useEffect, useState } from "react"; import { useLoaderData } from "@remix-run/react"; import Success from "../../components/Success.js"; export async function loader({ context, }: { context: RequestContext; }): Promise<{ logged_in: boolean; site_key: string }> { return { logged_in: Boolean(context.data.current_user), site_key: context.env.TURNSTILE_SITEKEY, }; } export function meta() { return [ { title: "Report an Exploiter - Car Crushers", }, ]; } export default function () { const [fileProgress, setFileProgress] = useState(0); const [showSuccess, setShowSuccess] = useState(false); const [supportsRequestStreams, setSupportsRequestStreams] = useState(false); const toast = useToast(); const [uploading, setUploading] = useState(false); const [loading, setLoading] = useState(false); const fileTypes: { [k: string]: string } = { gif: "image/gif", m4v: "video/x-m4v", mkv: "video/x-matroska", mov: "video/mp4", mp4: "video/mp4", webm: "video/webm", wmv: "video/x-ms-wmv", }; useEffect(() => { setSupportsRequestStreams( (() => { let duplexAccessed = false; const hasContentType = new Request("", { body: new ReadableStream(), method: "POST", // @ts-ignore get duplex() { duplexAccessed = true; return "half"; }, }).headers.has("Content-Type"); return duplexAccessed && !hasContentType; })(), ); }, []); const { logged_in, site_key } = useLoaderData<typeof loader>(); async function submit() { setLoading(true); const usernames = ( document.getElementById("usernames") as HTMLInputElement ).value .replaceAll(" ", "") .split(","); const files = (document.getElementById("evidence") as HTMLInputElement) .files; if (!usernames.length) { setLoading(false); return toast({ description: "Must provide at least one username", isClosable: true, status: "error", title: "Error", }); } if (!files?.length) { setLoading(false); return toast({ description: "Must attach at least one file", isClosable: true, status: "error", title: "Error", }); } if (usernames.length > 20) { setLoading(false); return toast({ description: "Only up to twenty users can be reported at a time", isClosable: true, status: "error", title: "Too Many Usernames", }); } let turnstileToken = ""; if (!logged_in) { const tokenElem = document .getElementsByName("cf-turnstile-response") .item(0) as HTMLInputElement; if (!tokenElem.value) { setLoading(false); return toast({ description: "Please complete the captcha and try again", isClosable: true, status: "error", title: "Captcha not completed", }); } turnstileToken = tokenElem.value; } const description = ( document.getElementById("description") as HTMLTextAreaElement ).value; const filelist = []; for (const file of files) { filelist.push({ name: file.name, size: file.size }); } const submitReq = await fetch("/api/reports/submit", { body: JSON.stringify({ bypass: false, description: description || undefined, files: filelist, turnstileResponse: logged_in ? undefined : turnstileToken, usernames, }), headers: { "content-type": "application/json", }, method: "POST", }); if (!submitReq.ok) { setLoading(false); if (!logged_in) { try { // @ts-expect-error turnstile.reset(); } catch {} } return toast({ description: ((await submitReq.json()) as { error: string }).error, isClosable: true, status: "error", title: "Error", }); } const { id, upload_urls }: { id: string; upload_urls: string[] } = await submitReq.json(); const totalSize = filelist.reduce((a, b) => a + b.size, 0); let bytesRead = 0; let shouldRecall = false; setUploading(true); for (let i = 0; i < upload_urls.length; i++) { const reader = files[i].stream().getReader(); try { const uploadReq = await fetch(upload_urls[i], { body: supportsRequestStreams ? new ReadableStream({ async pull(controller) { const chunk = await reader.read(); if (chunk.done) { controller.close(); if (i === upload_urls.length - 1) setUploading(false); return; } controller.enqueue(chunk.value); bytesRead += chunk.value.length; setFileProgress(Math.floor((bytesRead / totalSize) * 100)); }, }) : files[i], // @ts-expect-error duplex: supportsRequestStreams ? "half" : undefined, headers: { "content-type": (files[i].name.split(".").at(-1) as string).toLowerCase() === "mov" ? "video/mp4" : files[i].type || fileTypes[files[i].name.split(".").at(-1) as string], }, method: "PUT", }); if (!uploadReq.ok) { shouldRecall = true; break; } } catch (e) { console.error(e); shouldRecall = true; break; } } if (shouldRecall) { setLoading(false); await fetch("/api/reports/recall", { body: JSON.stringify({ id }), headers: { "content-type": "application/json", }, method: "POST", }); // @ts-expect-error turnstile.reset(); return toast({ description: "Failed to upload file", isClosable: true, status: "error", title: "Error", }); } await fetch("/api/reports/complete", { body: JSON.stringify({ id }), headers: { "content-type": "application/json", }, method: "POST", }); setShowSuccess(true); setLoading(false); } useEffect(() => { if (logged_in) return; const script = document.createElement("script"); script.async = true; script.defer = true; script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js"; document.body.appendChild(script); }, [logged_in]); return showSuccess ? ( <Success heading="Report Submitted" message="We will review it as soon as possible." /> ) : ( <> <Container maxW="container.md" pt="4vh" textAlign="start"> <Heading mb="4vh">Report an Exploiter</Heading> {logged_in ? null : ( <Text> Tip: Log in before submitting this report to have it appear on your data page. </Text> )} <br /> <FormControl isRequired> <FormLabel htmlFor="usernames"> Username(s) - To specify more than one, provide a comma-delimited list (User1, User2, User3...) </FormLabel> <Input id="usernames" placeholder="builderman" /> </FormControl> <br /> <FormControl isRequired> <FormLabel>Your Evidence (Max size per file: 512MB)</FormLabel> <Button colorScheme="blue" mr="8px" onClick={() => document.getElementById("evidence")?.click()} > Select File </Button> <input id="evidence" multiple type="file" /> </FormControl> <br /> <FormControl> <FormLabel>Optional description</FormLabel> <Textarea id="description" maxLength={512} /> </FormControl> <br /> <div className="cf-turnstile" data-error-callback="onTurnstileError" data-sitekey={site_key} ></div> <br /> <Text> By submitting this form, you agree to the{" "} <Link color="#646cff" href="/terms"> Terms of Service </Link>{" "} and{" "} <Link color="#646cff" href="/privacy"> Privacy Policy </Link> . </Text> <br /> <HStack> <Button colorScheme="blue" disabled={uploading} isLoading={loading} loadingText="Submitting" mr="8px" onClick={async () => await submit()} > Submit </Button> <CircularProgress display={uploading ? "" : "none"} isIndeterminate={!supportsRequestStreams} value={supportsRequestStreams ? fileProgress : undefined} > {supportsRequestStreams ? ( <CircularProgressLabel>{fileProgress}%</CircularProgressLabel> ) : null} </CircularProgress> </HStack> </Container> <script> {` function onTurnstileError(code) { const messages = { 110500: "Your browser is too old to complete the captcha, please update it.", 110510: "Something unexpected happened, please try disabling all extensions and refresh the page. If this does not solve the problem, use a different browser.", 110600: "Failed to solve the captcha, please refresh the page to try again.", 200010: "Invalid cache, please clear your cache and site data in your browser's settings.", 200100: "Your device's clock is wrong, please fix it.", }; const message = messages[code]; alert(message ?? \`Unknown error when solving captcha. Error \${code}\`); return true; } `} </script> </> ); }