Compare commits
12 Commits
472299d4f5
...
90525f9631
| Author | SHA1 | Date | |
|---|---|---|---|
|
90525f9631
|
|||
|
77e1ce2310
|
|||
|
9178ce19ac
|
|||
|
6da8c2092e
|
|||
|
93cd76b9a7
|
|||
|
3da5b9752e
|
|||
|
84974c346f
|
|||
|
643ae53ea0
|
|||
|
72d4c7818d
|
|||
|
6c06586801
|
|||
|
1fd203fe88
|
|||
|
db4fe139df
|
@@ -1,8 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
AlertDescription,
|
|
||||||
AlertIcon,
|
AlertIcon,
|
||||||
AlertTitle,
|
|
||||||
Button,
|
Button,
|
||||||
Container,
|
Container,
|
||||||
Heading,
|
Heading,
|
||||||
@@ -130,11 +128,8 @@ export default function () {
|
|||||||
<Container maxW="container.md">
|
<Container maxW="container.md">
|
||||||
<Alert display={exceededMaxBookings ? undefined : "none"} status="error">
|
<Alert display={exceededMaxBookings ? undefined : "none"} status="error">
|
||||||
<AlertIcon />
|
<AlertIcon />
|
||||||
<AlertTitle>Max pre-bookings exceeded!</AlertTitle>
|
You cannot pre-book more than three events at a time. Please wait until
|
||||||
<AlertDescription>
|
you complete an event before trying again.
|
||||||
You cannot pre-book more than three events. Please wait until you
|
|
||||||
complete an event before trying again.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
<Heading pb="32px">Book an Event</Heading>
|
<Heading pb="32px">Book an Event</Heading>
|
||||||
<Heading mb="8px" size="md">
|
<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,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
@@ -24,6 +24,7 @@ import {
|
|||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { useLoaderData } from "@remix-run/react";
|
import { useLoaderData } from "@remix-run/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { type InactivityNotice } from "../../generated/prisma/client.js";
|
||||||
|
|
||||||
export async function loader({ context }: { context: RequestContext }) {
|
export async function loader({ context }: { context: RequestContext }) {
|
||||||
const { current_user: currentUser } = context.data;
|
const { current_user: currentUser } = context.data;
|
||||||
@@ -57,18 +58,23 @@ export async function loader({ context }: { context: RequestContext }) {
|
|||||||
status: 403,
|
status: 403,
|
||||||
});
|
});
|
||||||
|
|
||||||
const today = new Date().toISOString().split("T").at(0);
|
let results = (
|
||||||
const { results } = await context.env.D1.prepare(
|
await context.data.prisma.$queryRaw<
|
||||||
"SELECT decisions, departments, end, hiatus, id, start, user FROM inactivity_notices WHERE start <= ?1 AND end >= date(?1, '-1 month') ORDER BY end;",
|
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;`
|
||||||
.bind(today)
|
).map((row) => {
|
||||||
.all();
|
return {
|
||||||
|
...row,
|
||||||
for (let i = 0; i < results.length; i++) {
|
decisions: JSON.parse(row.decisions as string) as {
|
||||||
results[i].decisions = JSON.parse(results[i].decisions as string);
|
[k: string]: boolean;
|
||||||
results[i].departments = JSON.parse(results[i].departments as string);
|
},
|
||||||
results[i].user = JSON.parse(results[i].user as string);
|
departments: JSON.parse(row.departments as string) as string[],
|
||||||
}
|
user: JSON.parse(row.user as string) as {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
can_delete: currentUser.permissions & (1 << 0),
|
can_delete: currentUser.permissions & (1 << 0),
|
||||||
@@ -78,13 +84,7 @@ export async function loader({ context }: { context: RequestContext }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return decisionValues.find((d) => d);
|
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>
|
<Tbody>
|
||||||
{data.results.map((row) => (
|
{data.results.map((row) => (
|
||||||
<Tr key={row.id}>
|
<Tr key={row.id}>
|
||||||
<Td>{row.user.username}</Td>
|
<Td>{row.user?.username}</Td>
|
||||||
<Td>{row.user.id}</Td>
|
<Td>{row.user?.id}</Td>
|
||||||
<Td>{row.start}</Td>
|
<Td>{row.start}</Td>
|
||||||
<Td>{row.end}</Td>
|
<Td>{row.end}</Td>
|
||||||
<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 (
|
if (
|
||||||
["PATCH", "POST", "PUT"].includes(context.request.method) &&
|
["PATCH", "POST", "PUT"].includes(context.request.method) &&
|
||||||
!context.request.url.endsWith("/api/infractions/new") &&
|
!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 (
|
if (
|
||||||
!context.request.headers
|
!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,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;
|
||||||
@@ -30,6 +30,7 @@ model AppealBan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model DataRequest {
|
model DataRequest {
|
||||||
|
attachments Json?
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
id String @id @unique
|
id String @id @unique
|
||||||
originating_user Int?
|
originating_user Int?
|
||||||
|
|||||||
Reference in New Issue
Block a user