Compare commits
17 Commits
472299d4f5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
3041d8cd27
|
|||
|
4451c192d2
|
|||
|
b33bf757e2
|
|||
|
10c1e741a9
|
|||
|
b3e5a46a2e
|
|||
|
90525f9631
|
|||
|
77e1ce2310
|
|||
|
9178ce19ac
|
|||
|
6da8c2092e
|
|||
|
93cd76b9a7
|
|||
|
3da5b9752e
|
|||
|
84974c346f
|
|||
|
643ae53ea0
|
|||
|
72d4c7818d
|
|||
|
6c06586801
|
|||
|
1fd203fe88
|
|||
|
db4fe139df
|
+1
-1
@@ -1 +1 @@
|
||||
v24.15.0
|
||||
v24.18.0
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,34 @@
|
||||
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
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
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 header", 400);
|
||||
|
||||
const [timestamp, sig] = sigHeader.split(",");
|
||||
|
||||
if (
|
||||
parseInt(timestamp.replace("t=", "")) <
|
||||
Math.floor(Date.now() / 1000) - 600
|
||||
)
|
||||
return jsonError("This request is stale", 406);
|
||||
|
||||
if (!sig) return jsonError("Missing signature value", 401);
|
||||
|
||||
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", 401);
|
||||
|
||||
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.EventPayload.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;
|
||||
Generated
+469
-883
File diff suppressed because it is too large
Load Diff
+15
-15
@@ -10,37 +10,37 @@
|
||||
"publish": "remix build --sourcemap && wrangler pages deploy --upload-source-maps public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^2.10.9",
|
||||
"@chakra-ui/react": "^2.10.10",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fontsource-variable/plus-jakarta-sans": "^5.2.8",
|
||||
"@prisma/adapter-d1": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@remix-run/cloudflare": "^2.17.4",
|
||||
"@remix-run/cloudflare-pages": "^2.17.4",
|
||||
"@remix-run/react": "^2.17.4",
|
||||
"@sentry/cloudflare": "^10.52.0",
|
||||
"@sentry/remix": "^10.52.0",
|
||||
"@remix-run/cloudflare": "^2.17.5",
|
||||
"@remix-run/cloudflare-pages": "^2.17.5",
|
||||
"@remix-run/react": "^2.17.5",
|
||||
"@sentry/cloudflare": "^10.63.0",
|
||||
"@sentry/remix": "^10.63.0",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"dayjs": "^1.11.20",
|
||||
"framer-motion": "^12.38.0",
|
||||
"dayjs": "^1.11.21",
|
||||
"framer-motion": "^12.42.2",
|
||||
"react": "^18.3.1",
|
||||
"react-big-calendar": "^1.19.4",
|
||||
"react-big-calendar": "^1.20.0",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@remix-run/dev": "^2.17.4",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/react": "^18.3.28",
|
||||
"@remix-run/dev": "^2.17.5",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^18.3.31",
|
||||
"@types/react-big-calendar": "^1.16.3",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"dotenv": "^17.4.1",
|
||||
"prettier": "^3.8.3",
|
||||
"dotenv": "^17.4.2",
|
||||
"prettier": "^3.9.4",
|
||||
"prisma": "^7.8.0",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"overrides": {
|
||||
"@cloudflare/workers-types": "^4.20260511.1"
|
||||
"@cloudflare/workers-types": "^4.20260702.1"
|
||||
},
|
||||
"prettier": {
|
||||
"endOfLine": "auto"
|
||||
|
||||
@@ -30,6 +30,7 @@ model AppealBan {
|
||||
}
|
||||
|
||||
model DataRequest {
|
||||
attachments Json?
|
||||
created_at DateTime @default(now())
|
||||
id String @id @unique
|
||||
originating_user Int?
|
||||
|
||||
Reference in New Issue
Block a user