May as well push now
Test, Build, Deploy / Test, Build, and Deploy (push) Failing after 1m22s
Test, Build, Deploy / Create Sentry Release (push) Has been skipped

This commit is contained in:
2026-06-30 21:56:15 -04:00
parent 77e1ce2310
commit 90525f9631
2 changed files with 318 additions and 0 deletions
+214
View File
@@ -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<typeof loader>();
const [requestType, setRequestType] = useState("purchase");
const [files, setFiles] = useState<FileList | null>(null);
const [oldIsTerminated, setOldIsTerminated] = useState(false);
const [processingState, setProcessingState] = useState<string | null>(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 (
<Container pt="16vh">
<Card borderRadius="32px" p="4vh">
<VStack alignContent="center" gap="2vh">
<Heading>Verify Old Account</Heading>
<br />
<Text>
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.
</Text>
<br />
<Button
as="a"
borderRadius="24px"
colorScheme="blue"
href={authUrl}
>
Verify Old Account
</Button>
</VStack>
</Card>
</Container>
);
case "complete":
return (
<Container
left="50%"
maxW="container.md"
pos="absolute"
top="50%"
transform="translate(-50%, -50%)"
>
<Flex>
<Spacer />
<svg
width="128"
height="128"
fill="currentColor"
viewBox="0 0 16 16"
>
<path d="M2.5 8a5.5 5.5 0 0 1 8.25-4.764.5.5 0 0 0 .5-.866A6.5 6.5 0 1 0 14.5 8a.5.5 0 0 0-1 0 5.5 5.5 0 1 1-11 0z" />
<path d="M15.354 3.354a.5.5 0 0 0-.708-.708L8 9.293 5.354 6.646a.5.5 0 1 0-.708.708l3 3a.5.5 0 0 0 .708 0l7-7z" />
</svg>
<Spacer />
</Flex>
<br />
<Heading textAlign="center">Request Submitted</Heading>
<br />
<Text textAlign="center">
We have received your request and will begin processing it shortly.
</Text>
</Container>
);
default:
return (
<Container maxW="container.lg">
<Heading pb="32px" size="xl">
Data Requests
</Heading>
<Alert status="info">
<AlertIcon />
You are signed in to {sessionData.username}.{" "}
<Link
onClick={async () => {
await fetch("/api/data-requests/session", {
method: "DELETE",
});
location.assign("/data-request/auth");
}}
textDecor="underline"
>
Wrong account?
</Link>
</Alert>
<VStack alignItems="start" spacing={12}>
<FormControl isRequired>
<FormLabel>Request Type</FormLabel>
<RadioGroup onChange={setRequestType} value={requestType}>
<Stack>
<Radio value="purchase">Missing Purchase</Radio>
<Radio value="transfer">
Transfer Save Data to New Account
</Radio>
<Radio value="reset">Reset Save Data</Radio>
</Stack>
</RadioGroup>
</FormControl>
{requestType === "purchase" ? (
<DataRequestFormChunkPurchases fileStateSet={setFiles} />
) : null}
{requestType === "transfer" ? (
<DataRequestFormChunkTransfers
fileStateSet={setFiles}
isTerminated={oldIsTerminated}
setIsTerminated={setOldIsTerminated}
/>
) : null}
</VStack>
</Container>
);
}
}
+104
View File
@@ -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,
});
}