Compare commits

..

12 Commits

Author SHA1 Message Date
regalijan 90525f9631 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
2026-06-30 21:56:15 -04:00
regalijan 77e1ce2310 Add same-account detect page for transfers 2026-06-30 21:55:52 -04:00
regalijan 9178ce19ac Add rtbf webhook 2026-06-30 21:54:41 -04:00
regalijan 6da8c2092e Fix prebook max alert 2026-06-30 20:54:26 -04:00
regalijan 93cd76b9a7 Create data request session management endpoint 2026-06-03 01:17:27 -04:00
regalijan 3da5b9752e Oops we forgot the scope 2026-06-03 00:03:19 -04:00
regalijan 84974c346f Add data request auth page 2026-06-02 23:57:27 -04:00
regalijan 643ae53ea0 Create data request form chunks 2026-06-02 23:42:50 -04:00
regalijan 72d4c7818d Add data request start endpoint to content-type exception list 2026-06-02 23:42:07 -04:00
regalijan 6c06586801 Move inactivities page to prisma 2026-06-02 23:41:42 -04:00
regalijan 1fd203fe88 Merge remote-tracking branch 'origin/main' 2026-06-02 23:40:40 -04:00
regalijan db4fe139df Add attachments column to data requests table 2026-05-16 03:15:45 -04:00
13 changed files with 692 additions and 29 deletions
+2 -7
View File
@@ -1,8 +1,6 @@
import {
Alert,
AlertDescription,
AlertIcon,
AlertTitle,
Button,
Container,
Heading,
@@ -130,11 +128,8 @@ export default function () {
<Container maxW="container.md">
<Alert display={exceededMaxBookings ? undefined : "none"} status="error">
<AlertIcon />
<AlertTitle>Max pre-bookings exceeded!</AlertTitle>
<AlertDescription>
You cannot pre-book more than three events. Please wait until you
complete an event before trying again.
</AlertDescription>
You cannot pre-book more than three events at a time. Please wait until
you complete an event before trying again.
</Alert>
<Heading pb="32px">Book an Event</Heading>
<Heading mb="8px" size="md">
+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>
);
}
}
+35
View File
@@ -0,0 +1,35 @@
import { Button, Card, Container, Heading, VStack } from "@chakra-ui/react";
import { useLoaderData } from "@remix-run/react";
export async function loader({ context }: { context: RequestContext }) {
const url = new URL(context.request.url);
url.pathname = "/api/data-requests/session";
return {
client_id: context.env.ROBLOX_OAUTH_CLIENT_ID,
url: encodeURIComponent(url.href),
};
}
export default function () {
const { client_id, url } = useLoaderData<typeof loader>();
return (
<Container pt="16vh">
<Card borderRadius="32px" p="4vh">
<VStack alignContent="center" gap="2vh">
<Heading>Log in to Car Crushers</Heading>
<br />
<Button
as="a"
borderRadius="24px"
colorScheme="blue"
href={`https://apis.roblox.com/oauth/v1/authorize?client_id=${client_id}&redirect_uri=${url}&response_type=code&scope=openid%20profile`}
>
Log in with Roblox
</Button>
</VStack>
</Card>
</Container>
);
}
@@ -0,0 +1,24 @@
import { Button, Card, Container, Heading, Text, VStack } from "@chakra-ui/react";
export default function () {
return (
<Container pt="16vh">
<Card borderRadius="32px" p="4vh">
<VStack alignContent="center" gap="2vh">
<Heading>Transfer Failed</Heading>
<br />
<Text>You verified with the same account that you submitted this request from. Please try again, and remember to switch accounts.</Text>
<br />
<Button
as="a"
borderRadius="24px"
colorScheme="blue"
href="/api/data-transfers/verify"
>
Try Again
</Button>
</VStack>
</Card>
</Container>
);
}
+21 -21
View File
@@ -24,6 +24,7 @@ import {
} from "@chakra-ui/react";
import { useLoaderData } from "@remix-run/react";
import { useState } from "react";
import { type InactivityNotice } from "../../generated/prisma/client.js";
export async function loader({ context }: { context: RequestContext }) {
const { current_user: currentUser } = context.data;
@@ -57,18 +58,23 @@ export async function loader({ context }: { context: RequestContext }) {
status: 403,
});
const today = new Date().toISOString().split("T").at(0);
const { results } = await context.env.D1.prepare(
"SELECT decisions, departments, end, hiatus, id, start, user FROM inactivity_notices WHERE start <= ?1 AND end >= date(?1, '-1 month') ORDER BY end;",
)
.bind(today)
.all();
for (let i = 0; i < results.length; i++) {
results[i].decisions = JSON.parse(results[i].decisions as string);
results[i].departments = JSON.parse(results[i].departments as string);
results[i].user = JSON.parse(results[i].user as string);
}
let results = (
await context.data.prisma.$queryRaw<
InactivityNotice[]
>`SELECT decisions, departments, end, hiatus, id, start, user FROM inactivity_notices WHERE start <= CURRENT_TIMESTAMP AND end >= date(CURRENT_TIMESTAMP, '-1 month') ORDER BY end;`
).map((row) => {
return {
...row,
decisions: JSON.parse(row.decisions as string) as {
[k: string]: boolean;
},
departments: JSON.parse(row.departments as string) as string[],
user: JSON.parse(row.user as string) as {
id: string;
username: string;
},
};
});
return {
can_delete: currentUser.permissions & (1 << 0),
@@ -78,13 +84,7 @@ export async function loader({ context }: { context: RequestContext }) {
);
return decisionValues.find((d) => d);
}) as unknown as {
decisions: { [k: string]: boolean };
end: string;
id: string;
start: string;
user: { email?: string; id: string; username: string };
}[],
}),
};
}
@@ -213,8 +213,8 @@ export default function () {
<Tbody>
{data.results.map((row) => (
<Tr key={row.id}>
<Td>{row.user.username}</Td>
<Td>{row.user.id}</Td>
<Td>{row.user?.username}</Td>
<Td>{row.user?.id}</Td>
<Td>{row.start}</Td>
<Td>{row.end}</Td>
<Td>
@@ -0,0 +1,41 @@
import {
FormControl,
FormHelperText,
FormLabel,
Input,
useMultiStyleConfig,
} from "@chakra-ui/react";
import { type Dispatch, type SetStateAction } from "react";
export default function (props: {
fileStateSet: Dispatch<SetStateAction<FileList | null>>;
}) {
const inputSelectorProps = useMultiStyleConfig("Button", {
colorScheme: "blue",
});
return (
<FormControl isRequired>
<FormLabel>Proof of Purchase</FormLabel>
<Input
accept="image/*"
border="none"
multiple={true}
onChange={(e) => {
props.fileStateSet(e.target.files);
}}
sx={{
"::file-selector-button": {
border: "none",
outline: "none",
...inputSelectorProps,
},
}}
type="file"
/>
<FormHelperText>
Select both the Roblox transaction entry and proof of you not receiving
your purchase.
</FormHelperText>
</FormControl>
);
}
@@ -0,0 +1,58 @@
import {
FormControl,
FormLabel,
Input,
Radio,
RadioGroup,
Stack,
useMultiStyleConfig,
} from "@chakra-ui/react";
import { type Dispatch, type SetStateAction, useState } from "react";
export default function (props: {
fileStateSet: Dispatch<SetStateAction<FileList | null>>;
isTerminated: boolean;
setIsTerminated: Dispatch<SetStateAction<boolean>>;
}) {
const inputSelectorProps = useMultiStyleConfig("Button", {
colorScheme: "blue",
});
return (
<>
<FormControl isRequired>
<FormLabel>
Is the account you are transferring from terminated?
</FormLabel>
<RadioGroup
onChange={(v) => props.setIsTerminated(JSON.parse(v))}
value={JSON.stringify(props.isTerminated)}
>
<Stack>
<Radio value="true">Yes</Radio>
<Radio value="false">No</Radio>
</Stack>
</RadioGroup>
</FormControl>
{props.isTerminated ? (
<FormControl isRequired>
<FormLabel>Proof of Termination</FormLabel>
<Input
accept="image/*"
border="none"
multiple={true}
onChange={(e) => props.fileStateSet(e.target.files)}
sx={{
"::file-selector-button": {
border: "none",
outline: "none",
...inputSelectorProps,
},
}}
type="file"
/>
</FormControl>
) : null}
</>
);
}
+2 -1
View File
@@ -322,7 +322,8 @@ async function setBody(context: RequestContext) {
if (
["PATCH", "POST", "PUT"].includes(context.request.method) &&
!context.request.url.endsWith("/api/infractions/new") &&
!context.request.url.endsWith("/api/st")
!context.request.url.endsWith("/api/st") &&
!context.request.url.endsWith("/api/data-requests/start")
) {
if (
!context.request.headers
+93
View File
@@ -0,0 +1,93 @@
import { jsonError } from "../../common.js";
export async function onRequestDelete(context: RequestContext) {
const cookie = context.request.headers.get("cookie");
if (!cookie) return jsonError("Invalid session", 401);
const cookies = cookie.split(/; */g);
let session = cookies.find((c) => c.startsWith("dr-auth="));
if (!session) return jsonError("Invalid session", 401);
session = session.replace("dr-auth=", "");
await context.env.DATA.delete(`data_request_session_${session}`);
return new Response(null, {
headers: {
"set-cookie":
"dr-auth=gone; HttpOnly; Max-Age=0; SameSite=Strict; Secure",
},
status: 204,
});
}
export async function onRequestGet(context: RequestContext) {
const url = new URL(context.request.url);
const code = url.searchParams.get("code");
if (!code) return jsonError("No code provided", 400);
const basicToken = btoa(
`${context.env.ROBLOX_OAUTH_CLIENT_ID}:${context.env.ROBLOX_OAUTH_CLIENT_SECRET}`,
);
const userResponse = await fetch("https://apis.roblox.com/oauth/v1/token", {
body: `code=${code}&grant_type=authorization_code`,
headers: {
authorization: `Basic ${basicToken}`,
"content-type": "application/x-www-form-urlencoded",
},
method: "POST",
});
if (!userResponse.ok) return jsonError("Failed to redeem code", 500);
const {
id_token,
refresh_token,
}: { id_token: string; refresh_token: string } = await userResponse.json();
const tokenPayload = id_token.split(".")[1];
let decodedToken: { [k: string]: any };
try {
decodedToken = JSON.parse(
atob(tokenPayload.replaceAll("-", "+").replaceAll("_", "/")),
);
} catch {
return jsonError("Failed to decode ID token", 500);
}
const sessionId = crypto.randomUUID().replace(/-/g, "");
await context.env.DATA.put(
`data_request_session_${sessionId}`,
JSON.stringify({
id: parseInt(decodedToken.sub),
username: decodedToken.preferred_username,
}),
{
expirationTtl: 3600,
},
);
try {
await fetch("https://apis.roblox.com/oauth/v1/token/revoke", {
body: `token=${refresh_token}`,
headers: {
authorization: `Basic ${basicToken}`,
"content-type": "application/x-www-form-urlencoded",
},
method: "POST",
});
} catch {}
return new Response(null, {
headers: {
location: "/data-request",
"set-cookie": `dr-auth=${sessionId}; HttpOnly; Max-Age=3600; SameSite=Strict; Secure`,
},
status: 302,
});
}
+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,
});
}
+95
View File
@@ -0,0 +1,95 @@
import { jsonError } from "../common.js";
import { Report } from "../../generated/prisma/client.js";
import { getBanList, setBanList } from "../roblox-open-cloud.js";
export async function onRequestPost(context: RequestContext) {
const body = await context.request.text();
const secret = context.env.ROBLOX_WEBHOOK_SECRET;
if (!secret) return jsonError("This endpoint is currently disabled", 503);
const sigHeader = context.request.headers.get("roblox-signature");
if (!sigHeader) return jsonError("Missing signature", 401);
const [timestamp, sig] = sigHeader.split(",");
if (
parseInt(timestamp.replace("t=", "")) <
Math.floor(Date.now() / 1000) - 600
)
return jsonError("This request is stale", 406);
const textEncode = (text: string) => new TextEncoder().encode(text);
const key = await crypto.subtle.importKey(
"raw",
textEncode(secret),
{
hash: "SHA-256",
name: "HMAC",
},
false,
["verify"],
);
if (
!(await crypto.subtle.verify(
"HMAC",
key,
Uint8Array.from(atob(sig.replace("v1=", "")), (c) => c.charCodeAt(0)),
textEncode(`${timestamp.replace("t=", "")}.${body}`),
))
)
return jsonError("Invalid signature", 403);
const data = JSON.parse(body);
if (data.EventType === "SampleNotification")
return new Response(null, {
status: 204,
});
if (data.EventType !== "RightToErasureRequest")
return jsonError("Invalid event type", 400);
const { prisma } = context.data;
const userId = data.EventPrisma.UserId;
if (typeof userId !== "number") return jsonError("Invalid user id", 400);
await prisma.gameModLog.deleteMany({
where: {
target: userId,
},
});
const associatedReports = await prisma.$queryRaw<
Report[]
>`SELECT attachments, id FROM reports WHERE EXISTS (SELECT 1 FROM json_each(target_ids) WHERE VALUE = ${userId});`;
const idsToDelete: string[] = [];
for (const report of associatedReports) {
for (const attachment of report.attachments as string[]) {
await context.env.R2.delete(attachment);
}
idsToDelete.push(report.id);
}
await prisma.report.deleteMany({
where: {
id: {
in: idsToDelete,
},
},
});
const { etag, value } = await getBanList(context);
delete value[userId];
await setBanList(context, value, etag);
return new Response(null, {
status: 204,
});
}
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "data_requests" ADD COLUMN "attachments" JSONB;
+1
View File
@@ -30,6 +30,7 @@ model AppealBan {
}
model DataRequest {
attachments Json?
created_at DateTime @default(now())
id String @id @unique
originating_user Int?