diff --git a/app/routes/data-request.tsx b/app/routes/data-request.tsx new file mode 100644 index 0000000..88faa42 --- /dev/null +++ b/app/routes/data-request.tsx @@ -0,0 +1,214 @@ +import { + Alert, + AlertIcon, + Button, + Card, + Container, + Flex, + FormControl, + FormLabel, + Heading, + Link, + Radio, + RadioGroup, + Spacer, + Stack, + Text, + useToast, + VStack, +} from "@chakra-ui/react"; +import { lazy, useState } from "react"; +import { useLoaderData } from "@remix-run/react"; +const DataRequestFormChunkPurchases = lazy( + () => import("../../components/DataRequestFormChunkPurchases.js"), +); +const DataRequestFormChunkTransfers = lazy( + () => import("../../components/DataRequestFormChunkTransfers.js"), +); + +export async function loader({ context }: { context: RequestContext }) { + const cookies = context.request.headers.get("cookie")?.split(/; */); + const dtSession = cookies?.find((c) => c.startsWith("dt-auth=")); + + if (!cookies || !dtSession) + throw Response.redirect("/data-request/auth", 303); + + const sessionId = dtSession.replace("dr-auth=", ""); + const sessionData = await context.env.DATA.get( + `data_request_session_${sessionId}`, + ); + + if (!sessionData) throw Response.redirect("/data-request/auth", 303); + + return JSON.parse(sessionData) as { id: number; username: string }; +} + +export function meta() { + return [ + { + title: "Data Request - Car Crushers", + }, + ]; +} + +export default function () { + const sessionData = useLoaderData(); + const [requestType, setRequestType] = useState("purchase"); + const [files, setFiles] = useState(null); + const [oldIsTerminated, setOldIsTerminated] = useState(false); + const [processingState, setProcessingState] = useState(null); + const [authUrl, setAuthUrl] = useState(""); + const toast = useToast(); + + async function startRequest() { + const body = new FormData(); + + body.append("type", requestType); + + if (files instanceof FileList) { + for (const file of files) { + body.append("file", file); + } + } + + const requestStartResponse = await fetch("/api/data-requests/start", { + body, + method: "POST", + redirect: "manual", + }); + + if (!requestStartResponse.ok) { + let error = "An unknown error has occurred"; + + try { + error = ((await requestStartResponse.json()) as { error: string }) + .error; + } catch {} + + toast({ + description: error, + status: "error", + title: "Failed to create request", + }); + + return; + } + + const { url } = (await requestStartResponse.json()) as { url?: string }; + + if (url) { + setAuthUrl(url); + setProcessingState("auth"); + } else { + setProcessingState("complete"); + } + } + + switch (processingState) { + case "auth": + return ( + + + + Verify Old Account +
+ + In order to process your transfer request, you must sign in to + your old Roblox account to verify account ownership. Remember to + switch to the correct account at the prompt. + +
+ +
+
+
+ ); + + case "complete": + return ( + + + + + + + + + +
+ Request Submitted +
+ + We have received your request and will begin processing it shortly. + +
+ ); + + default: + return ( + + + Data Requests + + + + You are signed in to {sessionData.username}.{" "} + { + await fetch("/api/data-requests/session", { + method: "DELETE", + }); + + location.assign("/data-request/auth"); + }} + textDecor="underline" + > + Wrong account? + + + + + Request Type + + + Missing Purchase + + Transfer Save Data to New Account + + Reset Save Data + + + + {requestType === "purchase" ? ( + + ) : null} + {requestType === "transfer" ? ( + + ) : null} + + + ); + } +} diff --git a/functions/api/data-requests/start.ts b/functions/api/data-requests/start.ts new file mode 100644 index 0000000..4494601 --- /dev/null +++ b/functions/api/data-requests/start.ts @@ -0,0 +1,104 @@ +import { jsonError, jsonResponse } from "../../common.js"; + +export async function onRequestPost(context: RequestContext) { + let formData: FormData; + + try { + formData = await context.request.formData(); + } catch { + return jsonError("Malformed form data", 400); + } + + const files: File[] = []; + + for (const file of formData.getAll("file")) { + if (typeof file === "string") { + return jsonError("'file' must be a file", 400); + } + + if (file.type === "image") { + } + + files.push(file); + } + + let bytes = 0; + for (const file of files) { + bytes += new Blob([file]).size; + } + + if (bytes > 26214400) return jsonError("Files too large (max 25MB)", 413); + + const type = formData.get("type"); + + if ( + typeof type !== "string" || + ["purchase", "reset", "transfer"].includes(type) + ) + return jsonError( + "'type' must be one of ('purchase', 'reset', 'transfer')", + 400, + ); + + const { R2 } = context.env; + const { prisma } = context.data; + const requestId = `${Date.now()}${crypto.randomUUID().replaceAll("-", "")}`; + + await prisma.dataRequest.create({ + data: { + id: requestId, + status: type === "transfer" ? "verification_required" : "pending", + target_user: 0, + type, + }, + }); + + const attachmentIds = []; + + for (let i = 0; i < files.length; i++) { + attachmentIds.push( + `${Date.now()}${crypto.randomUUID().replaceAll("-", "")}`, + ); + } + + const uploadPromises = []; + + for (let i = 0; i < attachmentIds.length; i++) { + uploadPromises.push(R2.put(`dt/${attachmentIds[i]}`, files[i])); + } + + const settledUploads = (await Promise.allSettled(uploadPromises)).filter( + (p) => p.status === "fulfilled", + ); + const attachmentList = settledUploads.map((u) => + (u.value as R2Object).key.replace("dt/", ""), + ); + + if (attachmentList.length > 0) { + await prisma.dataRequest.update({ + data: { + attachments: attachmentList, + }, + where: { + id: requestId, + }, + }); + + const url = new URL(context.request.url); + url.pathname = "/data-transfers/verify"; + + return jsonResponse( + JSON.stringify({ + url: `https://apis.roblox.com/oauth/v1/authorize?client_id=${context.env.ROBLOX_OAUTH_CLIENT_ID}&redirect_uri=${encodeURIComponent(url.href)}&response_type=code&scope=openid&state=${requestId}`, + }), + ); + } + + return new Response("{}", { + headers: { + "Content-Type": "application/json", + Location: `/data-request/status/${requestId}`, + }, + status: 201, + }); +}