Initial commit
26
.gitignore
vendored
Normal file
@ -0,0 +1,26 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
*.env
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
1
.node-version
Normal file
@ -0,0 +1 @@
|
||||
v16.19.0
|
93
OFL.txt
Normal file
@ -0,0 +1,93 @@
|
||||
Copyright 2020 The Plus Jakarta Sans Project Authors (https://github.com/tokotype/PlusJakartaSans)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
44
components/Fallback.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { Component, type ReactNode } from "react";
|
||||
import Navigation from "./Navigation";
|
||||
import { Code, Container, Heading, Text } from "@chakra-ui/react";
|
||||
|
||||
interface ErrorState {
|
||||
error: string | null;
|
||||
errored: boolean;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
[k: string]: any;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export default class Fallback extends Component<Props, ErrorState> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { error: null, errored: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error, errored: true };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.errored) return this.props.children;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navigation />
|
||||
<Container maxW="container.xl" pb="100px">
|
||||
<Heading>Oops! Something broke.</Heading>
|
||||
<Text fontSize="xl">See the details below</Text>
|
||||
<br />
|
||||
<Text>{this.state.error?.toString()}</Text>
|
||||
</Container>
|
||||
<Container maxW="container.xl">
|
||||
{/* @ts-expect-error The stack property should always exist */}
|
||||
<Code>{this.state.error.stack}</Code>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
15
components/Forbidden.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { Container, Heading, Link, Text } from "@chakra-ui/react";
|
||||
|
||||
export default function () {
|
||||
return (
|
||||
<Container maxW="container.lg" mt="8vh" textAlign="left">
|
||||
<Heading size="4xl">403</Heading>
|
||||
<br />
|
||||
<Text fontSize="xl">Sorry, but you aren't allowed to access that.</Text>
|
||||
<br />
|
||||
<Link color="#646cff" onClick={() => history.go(-1)}>
|
||||
Go back
|
||||
</Link>
|
||||
</Container>
|
||||
);
|
||||
}
|
195
components/FormGenerator.tsx
Normal file
@ -0,0 +1,195 @@
|
||||
import {
|
||||
Checkbox,
|
||||
CheckboxGroup,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
Heading,
|
||||
HStack,
|
||||
Input,
|
||||
NumberDecrementStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Select,
|
||||
Textarea,
|
||||
} from "@chakra-ui/react";
|
||||
import { type Dispatch, type SetStateAction, useState } from "react";
|
||||
|
||||
interface component {
|
||||
id: string;
|
||||
max_length?: number;
|
||||
options?: { default?: boolean; value: string }[];
|
||||
required: boolean;
|
||||
title: string;
|
||||
type: string;
|
||||
value?: number | string | string[];
|
||||
}
|
||||
|
||||
export default function ({
|
||||
components,
|
||||
read_only = true,
|
||||
}: {
|
||||
components: { [k: number]: component[] };
|
||||
read_only: boolean;
|
||||
}) {
|
||||
function isNumberElemInvalid(e: HTMLInputElement): boolean {
|
||||
return !(
|
||||
e.value ||
|
||||
e.valueAsNumber <= Number.MAX_SAFE_INTEGER ||
|
||||
e.valueAsNumber >= Number.MIN_SAFE_INTEGER
|
||||
);
|
||||
}
|
||||
|
||||
function updateState(
|
||||
state: { [k: string]: string | string[] },
|
||||
setState: Dispatch<SetStateAction<{}>>,
|
||||
id: string,
|
||||
value: string
|
||||
) {
|
||||
const newState = { ...state };
|
||||
newState[id] = value;
|
||||
|
||||
setState(newState);
|
||||
}
|
||||
|
||||
function renderCheckboxOptions(
|
||||
c: component,
|
||||
state: { [k: string]: string | string[] },
|
||||
setState: Dispatch<SetStateAction<{}>>
|
||||
) {
|
||||
if (!c.options) throw new Error("Options for checkbox are undefined");
|
||||
|
||||
const boxes = [];
|
||||
const checkedBoxes = [];
|
||||
|
||||
for (const option of c.options) {
|
||||
if (
|
||||
option.default ||
|
||||
(read_only && Array.isArray(c.value) && c.value.includes(option.value))
|
||||
)
|
||||
checkedBoxes.push(option.value);
|
||||
|
||||
boxes.push(
|
||||
<Checkbox
|
||||
isReadOnly={read_only}
|
||||
onChange={(e) => {
|
||||
const newState = { ...state };
|
||||
const groupValues = newState[c.id] ?? [];
|
||||
|
||||
if (!Array.isArray(groupValues))
|
||||
throw new Error("Expected CheckboxGroup values to be an array");
|
||||
|
||||
e.target.checked
|
||||
? groupValues.push(e.target.value)
|
||||
: groupValues.splice(
|
||||
groupValues.findIndex((v) => v === e.target.value),
|
||||
1
|
||||
);
|
||||
|
||||
newState[c.id] = groupValues;
|
||||
setState(newState);
|
||||
}}
|
||||
value={option.value}
|
||||
>
|
||||
{option.value}
|
||||
</Checkbox>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CheckboxGroup defaultValue={checkedBoxes}>
|
||||
<HStack spacing={5}>{boxes}</HStack>
|
||||
</CheckboxGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function generateReactComponents(
|
||||
components: component[],
|
||||
state: { [k: string]: string | string[] },
|
||||
setState: Dispatch<SetStateAction<{}>>
|
||||
): JSX.Element[] {
|
||||
const fragmentsList = [];
|
||||
|
||||
for (const component of components) {
|
||||
fragmentsList.push(
|
||||
<Heading size="md">{component.title}</Heading>,
|
||||
<br />
|
||||
);
|
||||
|
||||
switch (component.type) {
|
||||
case "checkbox":
|
||||
fragmentsList.push(renderCheckboxOptions(component, state, setState));
|
||||
break;
|
||||
|
||||
case "input":
|
||||
fragmentsList.push(
|
||||
<FormControl
|
||||
isInvalid={
|
||||
!(document.getElementById(component.id) as HTMLInputElement)
|
||||
.value.length
|
||||
}
|
||||
isReadOnly={read_only}
|
||||
>
|
||||
<Input
|
||||
id={component.id}
|
||||
maxLength={component.max_length}
|
||||
onChange={(e) =>
|
||||
updateState(state, setState, component.id, e.target.value)
|
||||
}
|
||||
placeholder="Your response"
|
||||
value={component.value}
|
||||
/>
|
||||
<FormErrorMessage>Field is required</FormErrorMessage>
|
||||
</FormControl>
|
||||
);
|
||||
break;
|
||||
|
||||
case "number":
|
||||
fragmentsList.push(
|
||||
<NumberInput
|
||||
isInvalid={isNumberElemInvalid(
|
||||
document.getElementById(component.id) as HTMLInputElement
|
||||
)}
|
||||
isReadOnly={read_only}
|
||||
>
|
||||
<NumberInputField
|
||||
id={component.id}
|
||||
onChange={(e) =>
|
||||
updateState(state, setState, component.id, e.target.value)
|
||||
}
|
||||
value={component.value}
|
||||
/>
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
fragmentsList.push(<br />, <br />, <br />);
|
||||
}
|
||||
|
||||
return fragmentsList;
|
||||
}
|
||||
|
||||
const pages = [];
|
||||
const [responses, setResponses] = useState({});
|
||||
|
||||
for (const [page, componentList] of Object.entries(components)) {
|
||||
pages.push(
|
||||
<div
|
||||
id={`form-page-${page}`}
|
||||
style={{ display: page ? "none" : undefined }}
|
||||
>
|
||||
{generateReactComponents(componentList, responses, setResponses)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <></>;
|
||||
}
|
20
components/Login.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { Button, Card, Container, Heading, VStack } from "@chakra-ui/react";
|
||||
|
||||
export default function () {
|
||||
return (
|
||||
<Container pt="16vh">
|
||||
<Card p="4vh">
|
||||
<VStack alignContent="center" gap="2vh">
|
||||
<Heading>Log in to Car Crushers</Heading>
|
||||
<br />
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={() => location.assign("/api/auth/oauth")}
|
||||
>
|
||||
Log in with Discord
|
||||
</Button>
|
||||
</VStack>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
}
|
9
components/ModWrapper.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import { Container, Tab, TabList } from "@chakra-ui/react";
|
||||
|
||||
export default function () {
|
||||
return (
|
||||
<Container maxW="container.lg" pt="4vh">
|
||||
|
||||
</Container>
|
||||
)
|
||||
}
|
190
components/Navigation.tsx
Normal file
@ -0,0 +1,190 @@
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
CloseButton,
|
||||
Container,
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerOverlay,
|
||||
Flex,
|
||||
HStack,
|
||||
Link,
|
||||
Spacer,
|
||||
Text,
|
||||
useBreakpointValue,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
function getAvatarUrl(userData: { [k: string]: any }): string {
|
||||
const BASE = "https://cdn.discordapp.com/";
|
||||
|
||||
if (!userData.id) return "";
|
||||
|
||||
if (!userData.avatar)
|
||||
return BASE + `embed/avatars/${parseInt(userData.discriminator) % 5}.png`;
|
||||
|
||||
return BASE + `avatars/${userData.id}/${userData.avatar}`;
|
||||
}
|
||||
|
||||
export default function (props: {
|
||||
avatar?: string;
|
||||
discriminator?: string;
|
||||
email?: string;
|
||||
id?: string;
|
||||
permissions?: number;
|
||||
username?: string;
|
||||
}) {
|
||||
const isDesktop = useBreakpointValue({ base: false, lg: true });
|
||||
const { isOpen, onClose, onOpen } = useDisclosure();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box as="section" pb={{ base: "6" }}>
|
||||
<Box as="nav" boxSizing="unset">
|
||||
<Container maxW="container.xl" py={{ base: "6" }}>
|
||||
<Container
|
||||
alignItems="center"
|
||||
display={isDesktop ? "none" : "flex"}
|
||||
justifyContent="space-between"
|
||||
p="0"
|
||||
w="calc(100vw - 6rem)"
|
||||
>
|
||||
<a href="/">
|
||||
<img
|
||||
src="/files/logo192.png"
|
||||
alt="Car Crushers Logo"
|
||||
style={{ width: "36px" }}
|
||||
/>
|
||||
</a>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
onClick={onOpen}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M2.5 12a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5z"
|
||||
/>
|
||||
</svg>
|
||||
</Container>
|
||||
<Flex
|
||||
alignSelf="center"
|
||||
display={isDesktop ? "flex" : "none"}
|
||||
gap="0.5rem"
|
||||
justifyContent="space-between"
|
||||
p="0"
|
||||
textAlign="center"
|
||||
>
|
||||
<a href="/" style={{ alignSelf: "center" }}>
|
||||
<img
|
||||
src="/files/logo192.png"
|
||||
width="32"
|
||||
alt="Car Crushers Logo"
|
||||
/>
|
||||
</a>
|
||||
<Spacer />
|
||||
<Spacer />
|
||||
<Center gap="1.25rem" whiteSpace="nowrap">
|
||||
<Button variant="ghost">
|
||||
<Link href="/about">About Us</Link>
|
||||
</Button>
|
||||
<Button variant="ghost">
|
||||
<Link href="/team">Our Team</Link>
|
||||
</Button>
|
||||
<Button variant="ghost">
|
||||
<Link href="/support">Support</Link>
|
||||
</Button>
|
||||
<Button variant="ghost">
|
||||
<Link href="/mdn">Moderation</Link>
|
||||
</Button>
|
||||
</Center>
|
||||
<Spacer />
|
||||
<Spacer />
|
||||
{props.id ? (
|
||||
<HStack spacing="3">
|
||||
<Avatar
|
||||
display={props.id ? "flex" : "none"}
|
||||
src={getAvatarUrl(props)}
|
||||
/>
|
||||
<Text>
|
||||
{props.id ? `${props.username}#${props.discriminator}` : ""}
|
||||
</Text>
|
||||
<Button
|
||||
size="md"
|
||||
style={{ display: props.id ? "block" : "none" }}
|
||||
variant="ghost"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 12.5a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 .5.5v2a.5.5 0 0 0 1 0v-2A1.5 1.5 0 0 0 9.5 2h-8A1.5 1.5 0 0 0 0 3.5v9A1.5 1.5 0 0 0 1.5 14h8a1.5 1.5 0 0 0 1.5-1.5v-2a.5.5 0 0 0-1 0v2z"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M15.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 0 0-.708.708L14.293 7.5H5.5a.5.5 0 0 0 0 1h8.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</HStack>
|
||||
) : (
|
||||
<Button>Log In</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Container>
|
||||
</Box>
|
||||
</Box>
|
||||
<Drawer isOpen={isOpen} onClose={onClose} placement="left">
|
||||
<DrawerOverlay />
|
||||
<DrawerContent gap="1.5vh" p="1.5vh">
|
||||
<CloseButton onClick={onClose} />
|
||||
<hr />
|
||||
<Link href="/about">About Us</Link>
|
||||
<Link href="/team">Our Team</Link>
|
||||
<Link href="/support">Support</Link>
|
||||
<Link href="/mdn">Moderation</Link>
|
||||
<hr />
|
||||
<Flex alignItems="center" gap="1rem">
|
||||
<Avatar
|
||||
display={props.id ? "" : "none"}
|
||||
src={getAvatarUrl(props)}
|
||||
/>
|
||||
<Text align="center" style={{ overflowWrap: "anywhere" }}>
|
||||
{props.id ? `${props.username}#${props.discriminator}` : ""}
|
||||
</Text>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
display: props.id ? "block" : "none",
|
||||
}}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 12.5a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 .5.5v2a.5.5 0 0 0 1 0v-2A1.5 1.5 0 0 0 9.5 2h-8A1.5 1.5 0 0 0 0 3.5v9A1.5 1.5 0 0 0 1.5 14h8a1.5 1.5 0 0 0 1.5-1.5v-2a.5.5 0 0 0-1 0v2z"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M15.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 0 0-.708.708L14.293 7.5H5.5a.5.5 0 0 0 0 1h8.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z"
|
||||
/>
|
||||
</svg>
|
||||
</Flex>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
38
components/Success.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { Container, Flex, Heading, Spacer, Text } from "@chakra-ui/react";
|
||||
|
||||
export default function ({
|
||||
heading,
|
||||
message,
|
||||
}: {
|
||||
heading: string;
|
||||
message: string;
|
||||
}) {
|
||||
return (
|
||||
<Container
|
||||
left="50%"
|
||||
maxW="container.md"
|
||||
pos="absolute"
|
||||
top="50%"
|
||||
transform="translate(-50%, -50%)"
|
||||
>
|
||||
<Flex>
|
||||
<Spacer />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/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>{heading}</Heading>
|
||||
<br />
|
||||
<Text>{message}</Text>
|
||||
</Container>
|
||||
);
|
||||
}
|
67
data/team.json
Normal file
@ -0,0 +1,67 @@
|
||||
[
|
||||
{
|
||||
"id": "165594923586945025",
|
||||
"tag": "Panwellz#5923",
|
||||
"position": "Game Creator"
|
||||
},
|
||||
{
|
||||
"id": "110502920339881984",
|
||||
"tag": "spooks#1523",
|
||||
"position": "Scripter"
|
||||
},
|
||||
{
|
||||
"id": "289372404541554689",
|
||||
"tag": "SkilledOn#0001",
|
||||
"position": "Community Manager/Developer"
|
||||
},
|
||||
{
|
||||
"id": "247766460359901194",
|
||||
"tag": "ImpartialRpr#1504",
|
||||
"position": "CM Advisor"
|
||||
},
|
||||
{
|
||||
"id": "396347223736057866",
|
||||
"tag": "Wolftallemo#0666",
|
||||
"position": "Head of Forum Moderation"
|
||||
},
|
||||
{
|
||||
"id": "165047934113677312",
|
||||
"tag": "Virt#0001",
|
||||
"position": "Director of Discord Moderation"
|
||||
},
|
||||
{
|
||||
"id": "135418199083581440",
|
||||
"tag": "perry the platypus™#0001",
|
||||
"position": "Head of Discord Moderation"
|
||||
},
|
||||
{
|
||||
"id": "385572238331478016",
|
||||
"tag": "Nahu#4092",
|
||||
"position": "Head of Discord Moderation"
|
||||
},
|
||||
{
|
||||
"id": "151098768807165952",
|
||||
"tag": "Redious#2066",
|
||||
"position": "Director of Wall Moderation"
|
||||
},
|
||||
{
|
||||
"id": "430533277384704010",
|
||||
"tag": ".Mason#1207",
|
||||
"position": "Head of Wall Moderation"
|
||||
},
|
||||
{
|
||||
"id": "392947931336146945",
|
||||
"tag": "3_Row#0001",
|
||||
"position": "Head of Wall Moderation"
|
||||
},
|
||||
{
|
||||
"id": "704247919259156521",
|
||||
"tag": "Charlemagne#3953",
|
||||
"position": "Events Coordinator"
|
||||
},
|
||||
{
|
||||
"id": "256995590599081985",
|
||||
"tag": "43#0317",
|
||||
"position": "Events Coordinator"
|
||||
}
|
||||
]
|
122
functions/_middleware.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { renderPage } from "vite-plugin-ssr";
|
||||
|
||||
async function constructHTML(context: RequestContext) {
|
||||
const { pathname } = new URL(context.request.url);
|
||||
|
||||
if (pathname.startsWith("/api/")) return await context.next();
|
||||
|
||||
if (
|
||||
pathname.startsWith("/assets/") ||
|
||||
["/app.webmanifest", "/favicon.ico", "/robots.txt"].includes(pathname) ||
|
||||
pathname.startsWith("/files/")
|
||||
)
|
||||
return await context.env.ASSETS.fetch(context.request);
|
||||
|
||||
const { httpResponse, status } = await renderPage({
|
||||
current_user: context.data.current_user,
|
||||
kv: context.env.DATA,
|
||||
status: 200,
|
||||
urlOriginal: context.request.url,
|
||||
});
|
||||
|
||||
return new Response(httpResponse?.getReadableWebStream(), {
|
||||
headers: {
|
||||
"content-type": httpResponse?.contentType ?? "text/html;charset=utf-8",
|
||||
},
|
||||
status: [200, 404, 500].includes(status)
|
||||
? httpResponse?.statusCode
|
||||
: status,
|
||||
});
|
||||
}
|
||||
|
||||
async function generateTokenHash(token: string) {
|
||||
const hash = await crypto.subtle.digest(
|
||||
"SHA-512",
|
||||
new TextEncoder().encode(token)
|
||||
);
|
||||
return btoa(String.fromCharCode(...new Uint8Array(hash)))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
}
|
||||
|
||||
async function setAuth(context: RequestContext) {
|
||||
const cookies = context.request.headers.get("cookie");
|
||||
|
||||
if (!cookies) return await context.next();
|
||||
|
||||
const cookieList = cookies.split(/; /);
|
||||
|
||||
for (const c of cookieList) {
|
||||
const [name, value] = c.split("=");
|
||||
|
||||
if (name !== "_s") continue;
|
||||
|
||||
const userData = await context.env.DATA.get(
|
||||
`auth_${await generateTokenHash(value)}`
|
||||
);
|
||||
|
||||
if (userData) context.data.current_user = JSON.parse(userData);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return await context.next();
|
||||
}
|
||||
|
||||
async function setBody(context: RequestContext) {
|
||||
if (context.request.method === "POST") {
|
||||
if (context.request.headers.get("content-type") !== "application/json")
|
||||
return new Response('{"error":"Invalid content-type"}', {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
status: 400,
|
||||
});
|
||||
|
||||
let body: { [k: string]: any };
|
||||
|
||||
try {
|
||||
body = await context.request.json();
|
||||
} catch {
|
||||
return new Response('{"error":"Invalid JSON"}', {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
context.data.body = body;
|
||||
}
|
||||
|
||||
return await context.next();
|
||||
}
|
||||
|
||||
async function setHeaders(context: RequestContext) {
|
||||
const response = await context.next();
|
||||
|
||||
const rtvValues = [
|
||||
"Aldaria",
|
||||
"Altadena",
|
||||
"DEMA",
|
||||
"Dragonborn",
|
||||
"Hollywood",
|
||||
"Parkway East",
|
||||
"Parkway North",
|
||||
"Parkway West",
|
||||
"Tokyo",
|
||||
"Wintervale",
|
||||
];
|
||||
|
||||
response.headers.set(
|
||||
"RTV",
|
||||
rtvValues[Math.round(Math.random() * (rtvValues.length - 1))]
|
||||
);
|
||||
response.headers.set("X-Frame-Options", "DENY");
|
||||
response.headers.set("X-XSS-Protection", "1; mode=block");
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export const onRequest = [setAuth, constructHTML, setBody, setHeaders];
|
113
functions/api/appeals/submit.ts
Normal file
@ -0,0 +1,113 @@
|
||||
export async function onRequestPost(context: RequestContext) {
|
||||
const { learned, whyBanned, whyUnban } = context.data.body;
|
||||
|
||||
if (
|
||||
typeof learned !== "string" ||
|
||||
typeof whyBanned !== "string" ||
|
||||
typeof whyUnban !== "string" ||
|
||||
!learned.length ||
|
||||
learned.length > 2000 ||
|
||||
!whyBanned.length ||
|
||||
whyBanned.length > 500 ||
|
||||
!whyUnban.length ||
|
||||
whyUnban.length > 2000
|
||||
)
|
||||
return new Response(
|
||||
'{"error":"One or more fields are missing or invalid"}',
|
||||
{
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
status: 400,
|
||||
}
|
||||
);
|
||||
|
||||
const { current_user: currentUser } = context.data;
|
||||
|
||||
if (!currentUser.email)
|
||||
return new Response('{"error":"No email for this session"}', {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
status: 403,
|
||||
});
|
||||
|
||||
const existingAppeals = await context.env.DATA.list({
|
||||
prefix: `appeal_${currentUser.id}`,
|
||||
});
|
||||
const existingBlockedAppeal = await context.env.DATA.get(
|
||||
`blockedappeal_${currentUser.id}`
|
||||
);
|
||||
|
||||
if (
|
||||
existingBlockedAppeal ||
|
||||
existingAppeals.keys.find(
|
||||
(appeal) => (appeal.metadata as { [k: string]: any })?.open
|
||||
)
|
||||
)
|
||||
return new Response('{"error":"Appeal already submitted"}', {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
status: 403,
|
||||
});
|
||||
|
||||
if (await context.env.DATA.get(`appealban_${currentUser.id}`)) {
|
||||
await context.env.DATA.put(`blockedappeal_${currentUser.id}`, "1", {
|
||||
metadata: { email: currentUser.email },
|
||||
});
|
||||
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
});
|
||||
}
|
||||
|
||||
const appealId = `${currentUser.id}${Date.now()}${crypto
|
||||
.randomUUID()
|
||||
.replaceAll("-", "")}`;
|
||||
|
||||
await context.env.DATA.put(
|
||||
`appeal_${appealId}`,
|
||||
JSON.stringify({
|
||||
learned,
|
||||
user: currentUser,
|
||||
whyBanned,
|
||||
whyUnban,
|
||||
}),
|
||||
{
|
||||
expirationTtl: 94608000,
|
||||
metadata: {
|
||||
created_at: Date.now(),
|
||||
id: currentUser.id,
|
||||
open: true,
|
||||
tag: `${currentUser.id}#${currentUser.discriminator}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await fetch(context.env.APPEALS_WEBHOOK, {
|
||||
body: JSON.stringify({
|
||||
embeds: [
|
||||
{
|
||||
title: "Appeal Submitted",
|
||||
color: 3756250,
|
||||
description: `View this appeal at https://carcrushers.cc/mod-queue?id=${appealId}&type=appeal`,
|
||||
fields: [
|
||||
{
|
||||
name: "Submitter",
|
||||
value: `${currentUser.username}#${currentUser.discriminator} (${currentUser.id})`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
});
|
||||
}
|
21
functions/api/appeals/toggle.ts
Normal file
@ -0,0 +1,21 @@
|
||||
export async function onRequestPost(context: RequestContext) {
|
||||
const { active } = context.data.body;
|
||||
|
||||
if (typeof active !== "boolean")
|
||||
return new Response('{"error":"Active property must be a boolean"}', {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
status: 400,
|
||||
});
|
||||
|
||||
if (active) {
|
||||
await context.env.DATA.delete("appeal_disabled");
|
||||
} else {
|
||||
await context.env.DATA.put("appeal_disabled", "1");
|
||||
}
|
||||
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
});
|
||||
}
|
20
functions/api/auth/oauth.ts
Normal file
@ -0,0 +1,20 @@
|
||||
export async function onRequestGet(context: RequestContext) {
|
||||
const { env, request } = context;
|
||||
const { host, protocol } = new URL(request.url);
|
||||
let returnPath = "/";
|
||||
const referer = request.headers.get("referer");
|
||||
|
||||
if (referer) returnPath = new URL(referer).pathname;
|
||||
|
||||
const state = crypto.randomUUID().replaceAll("-", "");
|
||||
|
||||
await env.DATA.put(`state_${state}`, returnPath, { expirationTtl: 300 });
|
||||
|
||||
return Response.redirect(
|
||||
`https://discord.com/oauth2/authorize?client_id=${
|
||||
env.DISCORD_ID
|
||||
}&redirect_uri=${encodeURIComponent(
|
||||
`${protocol}//${host}/api/auth/session`
|
||||
)}&response_type=code&scope=identify%20email%20guilds.members.read&state=${state}`
|
||||
);
|
||||
}
|
207
functions/api/auth/session.ts
Normal file
@ -0,0 +1,207 @@
|
||||
import GetPermissions from "../../permissions";
|
||||
|
||||
async function generateTokenHash(token: string): Promise<string> {
|
||||
const hash = await crypto.subtle.digest(
|
||||
"SHA-512",
|
||||
new TextEncoder().encode(token)
|
||||
);
|
||||
return btoa(String.fromCharCode(...new Uint8Array(hash)))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
}
|
||||
|
||||
function response(body: string, status: number) {
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
export async function onRequestDelete(context: RequestContext) {
|
||||
const cookies = context.request.headers.get("cookie")?.split("; ");
|
||||
|
||||
if (!cookies) return response('{"error":"Not logged in"}', 401);
|
||||
|
||||
for (const cookie of cookies) {
|
||||
const [name, value] = cookie.split("=");
|
||||
|
||||
if (name !== "_s") continue;
|
||||
|
||||
await context.env.DATA.delete(`auth_${await generateTokenHash(value)}`);
|
||||
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"set-cookie": "_s=; Max-Age=0",
|
||||
},
|
||||
status: 204,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function onRequestGet(context: RequestContext) {
|
||||
const { host, protocol, searchParams } = new URL(context.request.url);
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
|
||||
if (!code) return response('{"error":"Missing code"}', 400);
|
||||
if (!state) return response('{"error":"Missing state"}', 400);
|
||||
|
||||
const stateRedirect = await context.env.DATA.get(`state_${state}`);
|
||||
|
||||
if (!stateRedirect) return response('{"error":"Invalid state"}', 400);
|
||||
|
||||
const tokenReq = await fetch("https://discord.com/api/oauth2/token", {
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: `${protocol}//${host}/api/auth/session`,
|
||||
}).toString(),
|
||||
headers: {
|
||||
authorization: `Basic ${btoa(
|
||||
context.env.DISCORD_ID + ":" + context.env.DISCORD_SECRET
|
||||
)}`,
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!tokenReq.ok) {
|
||||
console.log(await tokenReq.text());
|
||||
|
||||
return response('{"error":"Failed to redeem code"}', 500);
|
||||
}
|
||||
|
||||
const tokenData: {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
refresh_token: string;
|
||||
scope: string;
|
||||
token_type: string;
|
||||
} = await tokenReq.json();
|
||||
|
||||
if (tokenData.scope.search("guilds.members.read") === -1)
|
||||
return response('{"error":"Do not touch the scopes!"}', 400);
|
||||
|
||||
let userData: { [k: string]: any } = {
|
||||
...tokenData,
|
||||
refresh_at: Date.now() + tokenData.expires_in * 1000 - 86400000,
|
||||
};
|
||||
|
||||
const userReq = await fetch("https://discord.com/api/v10/users/@me", {
|
||||
headers: {
|
||||
authorization: `Bearer ${tokenData.access_token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userReq.ok) {
|
||||
console.log(await userReq.text());
|
||||
return response('{"error":"Failed to retrieve user"}', 500);
|
||||
}
|
||||
|
||||
const apiUser: { [k: string]: any } = await userReq.json();
|
||||
userData = {
|
||||
...userData,
|
||||
...apiUser,
|
||||
};
|
||||
|
||||
const serverMemberReq = await fetch(
|
||||
"https://discord.com/api/v10/users/@me/guilds/242263977986359297/member",
|
||||
{
|
||||
headers: {
|
||||
authorization: `Bearer ${tokenData.access_token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const memberData: { [k: string]: any } = await serverMemberReq.json();
|
||||
|
||||
if (serverMemberReq.ok) {
|
||||
userData.permissions = GetPermissions(userData.id, memberData.roles);
|
||||
userData.roles = memberData.roles;
|
||||
} else {
|
||||
userData.permissions = GetPermissions(userData.id);
|
||||
}
|
||||
|
||||
const tokenPrefixes = [
|
||||
"ABOVE-THE-SKY",
|
||||
"BANDITO",
|
||||
"BE-CONCERNED",
|
||||
"CAR-RADIO",
|
||||
"CHEESE",
|
||||
"CHLORINE",
|
||||
"CRAZY-EQUALS-GENIUS",
|
||||
"CUBICLES",
|
||||
"DEAD",
|
||||
"DEMOLITION-LOVERS",
|
||||
"DEVIL-DOGS",
|
||||
"DOUBT",
|
||||
"DREADNOUGHT",
|
||||
"DYING-IN-LA",
|
||||
"FAIRLY-LOCAL",
|
||||
"FORMIDABLE",
|
||||
"GATES-OF-GLORY",
|
||||
"GIRLS-GIRLS-BOYS",
|
||||
"GONER",
|
||||
"HEATHENS",
|
||||
"HEAVYDIRTYSOUL",
|
||||
"HELENA",
|
||||
"HYDRA",
|
||||
"I-WRITE-SINS-NOT-TRAGEDIES",
|
||||
"KITCHEN-SINK",
|
||||
"LEVITATE",
|
||||
"LOCAL-GOD",
|
||||
"MAGGIE",
|
||||
"MAMA",
|
||||
"MONTANA",
|
||||
"NERO-FORTE",
|
||||
"NOOB",
|
||||
"NOT-TODAY",
|
||||
"NO-CHANCES",
|
||||
"POLARIZE",
|
||||
"PSYCHO",
|
||||
"ROMANCE",
|
||||
"SAD-CLOWN",
|
||||
"SATURDAY",
|
||||
"SAY-IT-LOUDER",
|
||||
"SEMI-AUTOMATIC",
|
||||
"TEENAGERS",
|
||||
"THUNDERSWORD",
|
||||
"TOKYO-DRIFTING",
|
||||
"TRAPDOOR",
|
||||
"TREES",
|
||||
"UMA-THURMAN",
|
||||
"UNSAINTED",
|
||||
"VERMILION",
|
||||
"VERSAILLES",
|
||||
"VICTORIOUS",
|
||||
"VIVA-LAS-VENGEANCE",
|
||||
"XIX",
|
||||
];
|
||||
|
||||
const selectedTokenStart =
|
||||
tokenPrefixes[Math.round(Math.random() * (tokenPrefixes.length - 1))] + "_";
|
||||
|
||||
const authToken =
|
||||
selectedTokenStart +
|
||||
`${crypto.randomUUID()}${crypto.randomUUID()}${crypto.randomUUID()}${crypto.randomUUID()}`.replaceAll(
|
||||
"-",
|
||||
""
|
||||
);
|
||||
|
||||
const tokenHash = await generateTokenHash(authToken);
|
||||
|
||||
await context.env.DATA.put(`auth_${tokenHash}`, JSON.stringify(userData), {
|
||||
expirationTtl: tokenData.expires_in,
|
||||
});
|
||||
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
location: stateRedirect,
|
||||
"set-cookie": `_s=${authToken}; HttpOnly; Max-Age=${tokenData.expires_in}; Path=/; SameSite=Lax; Secure`,
|
||||
},
|
||||
status: 302,
|
||||
});
|
||||
}
|
54
functions/api/reports/complete.ts
Normal file
@ -0,0 +1,54 @@
|
||||
export async function onRequestPost(context: RequestContext) {
|
||||
const { id } = context.data.body;
|
||||
|
||||
if (!id)
|
||||
return new Response('{"error":"No ID provided"}', {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
status: 400,
|
||||
});
|
||||
|
||||
const userId = await context.env.DATA.get(`reportprocessing_${id}`);
|
||||
|
||||
if (!userId || userId !== context.data.current_user.id)
|
||||
return new Response('{"error":"No report with that ID is processing"}', {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
status: 404,
|
||||
});
|
||||
|
||||
await context.env.DATA.delete(`reportprocessing_${id}`);
|
||||
|
||||
const {
|
||||
metadata,
|
||||
value,
|
||||
}: KVNamespaceGetWithMetadataResult<string, { [k: string]: any }> =
|
||||
await context.env.DATA.getWithMetadata(`report_${id}`);
|
||||
|
||||
delete metadata?.p;
|
||||
await context.env.DATA.put(`report_${id}`, value as string, { metadata });
|
||||
|
||||
if (context.env.REPORTS_WEBHOOK) {
|
||||
await fetch(context.env.REPORTS_WEBHOOK, {
|
||||
body: JSON.stringify({
|
||||
embeds: [
|
||||
{
|
||||
title: "Report Submitted",
|
||||
color: 3756250,
|
||||
description: `View this report at https://carcrushers.cc/mod-queue?id=${id}&type=game-report`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
});
|
||||
}
|
27
functions/api/reports/recall.ts
Normal file
@ -0,0 +1,27 @@
|
||||
export async function onRequestPost(context: RequestContext) {
|
||||
const { id } = context.data.body;
|
||||
|
||||
if (!id)
|
||||
return new Response('{"error":"No ID provided"}', {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
status: 400,
|
||||
});
|
||||
|
||||
const reportUserId = await context.env.DATA.get(`reportprocessing_${id}`);
|
||||
|
||||
if (!reportUserId || context.data.current_user.id !== reportUserId)
|
||||
return new Response('{"error":"No processing report with that ID found"}', {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
status: 404,
|
||||
});
|
||||
|
||||
await context.env.DATA.delete(`report_${id}`);
|
||||
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
});
|
||||
}
|
154
functions/api/reports/submit.ts
Normal file
@ -0,0 +1,154 @@
|
||||
import { GenerateUploadURL } from "../../gcloud";
|
||||
|
||||
function errorResponse(error: string, status: number): Response {
|
||||
return new Response(JSON.stringify({ error }), {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
export async function onRequestPost(context: RequestContext) {
|
||||
const { filename, filesize, usernames } = context.data.body;
|
||||
|
||||
if (!Array.isArray(usernames))
|
||||
return errorResponse("Usernames must be type of array", 400);
|
||||
|
||||
if (typeof filename !== "string")
|
||||
return errorResponse("Invalid file name", 400);
|
||||
|
||||
if (typeof filesize !== "number" || filesize < 0 || filesize > 536870912)
|
||||
return errorResponse("Invalid file size", 400);
|
||||
|
||||
if (!usernames.length || usernames.length > 20)
|
||||
return errorResponse(
|
||||
"Number of usernames provided must be between 1 and 20",
|
||||
400
|
||||
);
|
||||
|
||||
for (const username of usernames) {
|
||||
if (
|
||||
username.length < 3 ||
|
||||
username.length > 20 ||
|
||||
username.match(/_/g)?.length > 1
|
||||
)
|
||||
return errorResponse(`Username "${username}" is invalid`, 400);
|
||||
}
|
||||
|
||||
const rbxSearchReq = await fetch(
|
||||
"https://users.roblox.com/v1/usernames/users",
|
||||
{
|
||||
body: JSON.stringify({
|
||||
usernames,
|
||||
excludeBannedUsers: true,
|
||||
}),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
|
||||
if (!rbxSearchReq.ok)
|
||||
return errorResponse(
|
||||
"Failed to locate Roblox users due to upstream error",
|
||||
500
|
||||
);
|
||||
|
||||
const rbxSearchData: { data: { [k: string]: any }[] } =
|
||||
await rbxSearchReq.json();
|
||||
|
||||
if (rbxSearchData.data.length < usernames.length) {
|
||||
const missingUsers = [];
|
||||
|
||||
for (const userData of rbxSearchData.data) {
|
||||
if (!usernames.includes(userData.requestedUsername))
|
||||
missingUsers.push(userData.requestedUsername);
|
||||
}
|
||||
|
||||
return errorResponse(
|
||||
`The following users do not exist or are banned from Roblox: ${missingUsers.toString()}`,
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const metaIDs = [];
|
||||
const metaNames = [];
|
||||
|
||||
for (const data of rbxSearchData.data) {
|
||||
metaIDs.push(data.id);
|
||||
metaNames.push(data.name);
|
||||
}
|
||||
|
||||
const fileParts = filename.split(".");
|
||||
let fileExt = fileParts[fileParts.length - 1];
|
||||
|
||||
if (
|
||||
fileParts.length < 2 ||
|
||||
![
|
||||
"mkv",
|
||||
"mp4",
|
||||
"wmv",
|
||||
"jpg",
|
||||
"png",
|
||||
"m4v",
|
||||
"jpeg",
|
||||
"jfif",
|
||||
"gif",
|
||||
"webm",
|
||||
"heif",
|
||||
"heic",
|
||||
"webp",
|
||||
"mov",
|
||||
].includes(fileExt.toLowerCase())
|
||||
)
|
||||
return errorResponse("This type of file cannot be uploaded", 415);
|
||||
|
||||
const fileKey = `${crypto.randomUUID().replaceAll("-", "")}/${crypto
|
||||
.randomUUID()
|
||||
.replaceAll("-", "")}${context.request.headers.get("cf-ray")}${Date.now()}`;
|
||||
|
||||
const reportId = `${Date.now()}${context.request.headers.get(
|
||||
"cf-ray"
|
||||
)}${crypto.randomUUID().replaceAll("-", "")}`;
|
||||
|
||||
const uploadUrl = await GenerateUploadURL(
|
||||
context.env,
|
||||
fileKey,
|
||||
filesize,
|
||||
fileExt
|
||||
);
|
||||
|
||||
await context.env.DATA.put(
|
||||
`reportprocessing_${reportId}`,
|
||||
context.data.current_user.id,
|
||||
{ expirationTtl: 3600 }
|
||||
);
|
||||
await context.env.DATA.put(
|
||||
`report_${reportId}`,
|
||||
JSON.stringify({
|
||||
attachment: `${fileKey}.${
|
||||
["mkv", "mov", "wmv"].includes(fileExt.toLowerCase()) ? "mp4" : fileExt
|
||||
}`,
|
||||
reporter: context.data.current_user,
|
||||
target_ids: metaIDs,
|
||||
target_usernames: metaNames,
|
||||
}),
|
||||
{
|
||||
metadata: {
|
||||
i: context.data.current_user.id,
|
||||
r: metaIDs.toString(),
|
||||
p: true,
|
||||
s: `${context.data.current_user.username}#${context.data.current_user.discriminator}`,
|
||||
u: metaNames.toString(),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return new Response(JSON.stringify({ id: reportId, upload_url: uploadUrl }), {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
109
functions/gcloud.ts
Normal file
@ -0,0 +1,109 @@
|
||||
function arrBufToB64Url(buf: ArrayBuffer) {
|
||||
const b64data = btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||
return b64data.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
||||
}
|
||||
|
||||
function stringToBuffer(str: string) {
|
||||
const buffer = new ArrayBuffer(str.length);
|
||||
const ui8 = new Uint8Array(buffer);
|
||||
for (let i = 0, bufLen = str.length; i < bufLen; i++) {
|
||||
ui8[i] = str.charCodeAt(i);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export async function GenerateUploadURL(
|
||||
env: Env,
|
||||
path: string,
|
||||
size: number,
|
||||
fileExt: string
|
||||
): Promise<string> {
|
||||
const accessToken = await GetAccessToken(env);
|
||||
const contentTypes: { [k: string]: string } = {
|
||||
gif: "image/gif",
|
||||
heic: "image/heic",
|
||||
heif: "image/heif",
|
||||
jfif: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
jpg: "image/jpeg",
|
||||
m4v: "video/x-m4v",
|
||||
mkv: "video/x-matroska",
|
||||
mov: "video/quicktime",
|
||||
mp4: "video/mp4",
|
||||
png: "image/png",
|
||||
webp: "image/webp",
|
||||
webm: "video/webm",
|
||||
wmv: "video/x-ms-wmv",
|
||||
};
|
||||
|
||||
const resumableUploadReq = await fetch(
|
||||
`https://storage.googleapis.com/upload/storage/v1/b/portal-carcrushers-cc/o?uploadType=resumable&name=${encodeURIComponent(
|
||||
path
|
||||
)}`,
|
||||
{
|
||||
headers: {
|
||||
authorization: `Bearer ${accessToken}`,
|
||||
"x-upload-content-type": contentTypes[fileExt],
|
||||
"x-upload-content-length": size.toString(),
|
||||
},
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
|
||||
if (!resumableUploadReq.ok)
|
||||
throw new Error(
|
||||
`Failed to create resumable upload: ${await resumableUploadReq.text()}`
|
||||
);
|
||||
|
||||
const url = resumableUploadReq.headers.get("location");
|
||||
|
||||
if (!url) throw new Error("No location header returned");
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
async function GetAccessToken(env: Env): Promise<string> {
|
||||
const claimSet = btoa(
|
||||
JSON.stringify({
|
||||
aud: "https://oauth2.googleapis.com/token",
|
||||
exp: Math.floor(Date.now() / 1000) + 120,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
iss: env.WORKER_GSERVICEACCOUNT,
|
||||
scope:
|
||||
"https://www.googleapis.com/auth/datastore https://www.googleapis.com/auth/devstorage.read_write",
|
||||
})
|
||||
)
|
||||
.replaceAll("+", "-")
|
||||
.replaceAll("/", "_")
|
||||
.replaceAll("=", "");
|
||||
|
||||
const signingKey = await crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
stringToBuffer(
|
||||
atob(env.STORAGE_ACCOUNT_KEY.replace(/(\r\n|\n|\r)/gm, ""))
|
||||
),
|
||||
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"]
|
||||
);
|
||||
const signature = await crypto.subtle.sign(
|
||||
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
||||
signingKey,
|
||||
stringToBuffer(`eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.${claimSet}`)
|
||||
);
|
||||
const assertion = `eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.${claimSet}.${arrBufToB64Url(
|
||||
signature
|
||||
)}`;
|
||||
const tokenRequest = await fetch("https://oauth2.googleapis.com/token", {
|
||||
body: `grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=${assertion}`,
|
||||
headers: {
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!tokenRequest.ok)
|
||||
throw new Error(`Failed to get access token: ${await tokenRequest.text()}`);
|
||||
|
||||
return ((await tokenRequest.json()) as { [k: string]: any }).access_token;
|
||||
}
|
36
functions/permissions.ts
Normal file
@ -0,0 +1,36 @@
|
||||
export default function (userid: string, roles?: string[]): number {
|
||||
let permissions = 0;
|
||||
|
||||
if (roles?.includes("374851061233614849")) permissions |= 1 << 0; // Administration
|
||||
if (!roles) permissions |= 1 << 1;
|
||||
if (roles?.includes("593209890949038082")) permissions |= 1 << 2; // Discord Moderator
|
||||
if (roles?.includes("391986108763996160")) permissions |= 1 << 3; // Events Team
|
||||
if (roles?.includes("607697704419852289")) permissions |= 1 << 4; // Events Team Management
|
||||
if (
|
||||
[
|
||||
"165594923586945025",
|
||||
"289372404541554689",
|
||||
"320758924850757633",
|
||||
"396347223736057866",
|
||||
"704247919259156521",
|
||||
"891710683371167795",
|
||||
].includes(userid)
|
||||
)
|
||||
permissions |= 1 << 5;
|
||||
if (
|
||||
roles?.includes("542750631161626636") ||
|
||||
roles?.includes("542750839291248640")
|
||||
)
|
||||
permissions |= 1 << 6; // Head of Wall Moderation
|
||||
if (roles?.includes("684406593214742548")) permissions |= 1 << 7; // Head of Forum Moderation
|
||||
if (roles?.includes("784870326990405672")) permissions |= 1 << 8; // Data Team
|
||||
if (roles?.includes("298438715380858881")) permissions |= 1 << 9; // Wall Moderator
|
||||
if (roles?.includes("681632342346825879")) permissions |= 1 << 10; // Forum Moderator
|
||||
if (
|
||||
roles?.includes("321710070519955457") ||
|
||||
roles?.includes("338102086095077376")
|
||||
)
|
||||
permissions |= 1 << 11; // Head of Discord Moderation
|
||||
|
||||
return permissions;
|
||||
}
|
79
index.css
Normal file
@ -0,0 +1,79 @@
|
||||
:root {
|
||||
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
::file-selector-button {
|
||||
display: none;
|
||||
}
|
24
index.d.ts
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import { type PageContextBuiltIn } from "vite-plugin-ssr";
|
||||
|
||||
declare global {
|
||||
interface Env {
|
||||
ASSETS: Fetcher;
|
||||
DATA: KVNamespace;
|
||||
[k: string]: string;
|
||||
}
|
||||
|
||||
type RequestContext = EventContext<Env, string, { [k: string]: any }>;
|
||||
interface PageContext extends PageContextBuiltIn {
|
||||
current_user?: { [k: string]: any };
|
||||
kv: KVNamespace;
|
||||
pageProps: {
|
||||
[k: string]: any;
|
||||
};
|
||||
requireAuth?: boolean;
|
||||
status: number;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
7000
package-lock.json
generated
Normal file
33
package.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "car-crushers-portal",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^2.4.9",
|
||||
"@emotion/react": "^11.10.5",
|
||||
"@emotion/styled": "^11.10.5",
|
||||
"@fontsource/plus-jakarta-sans": "^4.5.11",
|
||||
"@sentry/react": "^7.34.0",
|
||||
"@sentry/tracing": "^7.34.0",
|
||||
"framer-motion": "^8.5.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20230115.0",
|
||||
"@types/react": "^18.0.27",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"@vitejs/plugin-react": "^3.0.1",
|
||||
"esbuild": "^0.17.5",
|
||||
"prettier": "^2.8.3",
|
||||
"typescript": "^4.9.4",
|
||||
"vite": "^4.0.4",
|
||||
"vite-plugin-ssr": "^0.4.71"
|
||||
}
|
||||
}
|
43
pages/appeals.page.server.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
export async function onBeforeRender(pageContext: PageContext) {
|
||||
if (!pageContext.current_user)
|
||||
return {
|
||||
pageContext: {
|
||||
pageProps: {
|
||||
logged_in: false,
|
||||
},
|
||||
status: 401,
|
||||
},
|
||||
};
|
||||
|
||||
const blockedAppeal = await pageContext.kv?.get(
|
||||
`blockedappeal_${pageContext.current_user.id}`
|
||||
);
|
||||
const disabledStatus = await pageContext.kv?.get("appeal_disabled");
|
||||
const openAppeals = await pageContext.kv?.list({
|
||||
prefix: `appeal_${pageContext.current_user.id}`,
|
||||
});
|
||||
|
||||
return {
|
||||
pageContext: {
|
||||
pageProps: {
|
||||
can_appeal:
|
||||
!Boolean(disabledStatus) &&
|
||||
!Boolean(blockedAppeal) &&
|
||||
!Boolean(
|
||||
openAppeals.keys.find(
|
||||
(appeal) => (appeal.metadata as { [k: string]: any }).open
|
||||
)
|
||||
),
|
||||
can_toggle:
|
||||
pageContext.current_user?.permissions & (1 << 0) ||
|
||||
pageContext.current_user?.permissions & (1 << 11),
|
||||
disabled: Boolean(disabledStatus),
|
||||
logged_in: true,
|
||||
},
|
||||
status: pageContext.current_user ? 200 : 401,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const description = "Appeal your Discord ban here.";
|
||||
export const title = "Appeals - Car Crushers";
|
205
pages/appeals.page.tsx
Normal file
@ -0,0 +1,205 @@
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Flex,
|
||||
Heading,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Spacer,
|
||||
Text,
|
||||
Textarea,
|
||||
useDisclosure,
|
||||
useToast,
|
||||
} from "@chakra-ui/react";
|
||||
import { useState } from "react";
|
||||
import Login from "../components/Login";
|
||||
import Success from "../components/Success";
|
||||
|
||||
export function Page(pageProps: { [p: string]: any }) {
|
||||
if (!pageProps.logged_in) return <Login />;
|
||||
|
||||
const { isOpen, onClose, onOpen } = useDisclosure();
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
async function submit() {
|
||||
const learned = (document.getElementById("learned") as HTMLInputElement)
|
||||
.value;
|
||||
const whyBanned = (document.getElementById("whyBanned") as HTMLInputElement)
|
||||
.value;
|
||||
const whyUnban = (document.getElementById("whyUnban") as HTMLInputElement)
|
||||
.value;
|
||||
|
||||
const submitReq = await fetch("/api/appeals/submit", {
|
||||
body: JSON.stringify({
|
||||
learned,
|
||||
whyBanned,
|
||||
whyUnban,
|
||||
}),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
}).catch(() => {});
|
||||
|
||||
if (!submitReq)
|
||||
return toast({
|
||||
description: "Please check your internet and try again",
|
||||
duration: 10000,
|
||||
isClosable: true,
|
||||
status: "error",
|
||||
title: "Request Failed",
|
||||
});
|
||||
|
||||
if (!submitReq.ok)
|
||||
return toast({
|
||||
description: ((await submitReq.json()) as { error: string }).error,
|
||||
duration: 10000,
|
||||
isClosable: true,
|
||||
status: "error",
|
||||
title: "Error",
|
||||
});
|
||||
|
||||
setShowSuccess(true);
|
||||
}
|
||||
|
||||
async function toggle(active: boolean) {
|
||||
const toggleReq = await fetch("/api/appeals/toggle", {
|
||||
body: JSON.stringify({ active }),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!toggleReq.ok)
|
||||
return toast({
|
||||
description: ((await toggleReq.json()) as { error: string }).error,
|
||||
duration: 10000,
|
||||
isClosable: true,
|
||||
status: "error",
|
||||
title: "Error",
|
||||
});
|
||||
|
||||
toast({
|
||||
description: `The appeals form is now ${active ? "opened" : "closed"}.`,
|
||||
isClosable: true,
|
||||
status: "success",
|
||||
title: `Appeals ${active ? "enabled" : "disabled"}`,
|
||||
});
|
||||
|
||||
onClose();
|
||||
await new Promise((p) => setTimeout(p, 5000));
|
||||
}
|
||||
|
||||
return showSuccess ? (
|
||||
<Success
|
||||
heading="Appeal Submitted"
|
||||
message="You will receive an email when we reach a decision."
|
||||
/>
|
||||
) : (
|
||||
<Container maxW="container.md" pt="4vh" textAlign="start">
|
||||
<Alert
|
||||
borderRadius="8px"
|
||||
display={pageProps.disabled ? "flex" : "none"}
|
||||
mb="16px"
|
||||
status="error"
|
||||
>
|
||||
<AlertIcon />
|
||||
<Box>
|
||||
<AlertTitle>Appeals Closed</AlertTitle>
|
||||
<AlertDescription>
|
||||
We are currently not accepting appeals.
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
<Flex>
|
||||
<Spacer />
|
||||
<Button display={pageProps.can_toggle ? "" : "none"} onClick={onOpen}>
|
||||
{pageProps.disabled ? "Enable" : "Disable"} Appeals
|
||||
</Button>
|
||||
</Flex>
|
||||
<br />
|
||||
<Modal isCentered isOpen={isOpen} onClose={onClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Toggle appeals?</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<Text>
|
||||
Are you sure you want to{" "}
|
||||
{pageProps.disabled ? "enable" : "disable"} appeals?
|
||||
</Text>
|
||||
</ModalBody>
|
||||
<ModalFooter style={{ gap: "8px" }}>
|
||||
<Button onClick={onClose} variant="ghost">
|
||||
No
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => await toggle(pageProps.disabled)}
|
||||
variant="danger"
|
||||
>
|
||||
Yes
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<Heading size="xl">Discord Appeals</Heading>
|
||||
<br />
|
||||
<Text fontSize="md">
|
||||
This is for Discord bans only! See the support page if you were banned
|
||||
from the game.
|
||||
</Text>
|
||||
<br />
|
||||
<br />
|
||||
<Heading size="md">Why were you banned?</Heading>
|
||||
<br />
|
||||
<Textarea
|
||||
disabled={!pageProps.can_appeal}
|
||||
id="whyBanned"
|
||||
maxLength={500}
|
||||
placeholder="Your response"
|
||||
/>
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<Heading size="md">Why should we unban you?</Heading>
|
||||
<br />
|
||||
<Textarea
|
||||
disabled={!pageProps.can_appeal}
|
||||
id="whyUnban"
|
||||
maxLength={2000}
|
||||
placeholder="Your response"
|
||||
/>
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<Heading size="md">What have you learned from your mistake?</Heading>
|
||||
<br />
|
||||
<Textarea
|
||||
disabled={!pageProps.can_appeal}
|
||||
id="learned"
|
||||
maxLength={2000}
|
||||
placeholder="Your response"
|
||||
/>
|
||||
<br />
|
||||
<br />
|
||||
<Button
|
||||
disabled={pageProps.disabled || pageProps.already_submitted}
|
||||
onClick={async () => await submit()}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Container>
|
||||
);
|
||||
}
|
18
pages/index.page.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { Box, Container, Text } from "@chakra-ui/react";
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<>
|
||||
<Box alignContent="left">
|
||||
<Container maxW="container.lg" paddingTop="8vh" textAlign="left">
|
||||
<Text>
|
||||
srfidukjghdiuftgrteutgrtsu,k jhsrte h hjgtsredbfdgns srthhfg h fgdyh
|
||||
y
|
||||
</Text>
|
||||
</Container>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const title = "Home - Car Crushers";
|
20
pages/mod-queue.page.server.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
export async function onBeforeRender(pageContext: PageContext) {
|
||||
const typePermissions = {
|
||||
appeal: [1 << 0, 1 << 1],
|
||||
gma: [1 << 5],
|
||||
report: [1 << 5],
|
||||
};
|
||||
const { searchParams } = new URL(
|
||||
pageContext.urlOriginal,
|
||||
"http://localhost:8788"
|
||||
);
|
||||
const includeClosed = searchParams.get("includeClosed");
|
||||
const type = searchParams.get("type");
|
||||
const sort = searchParams.get("sort") ?? "asc";
|
||||
|
||||
return {
|
||||
pageContext: {
|
||||
pageProps: {},
|
||||
},
|
||||
};
|
||||
}
|
40
pages/mod-queue.page.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Flex,
|
||||
Select,
|
||||
useBreakpointValue,
|
||||
VStack,
|
||||
} from "@chakra-ui/react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function Page(pageProps: { [p: string]: any }) {
|
||||
const isDesktop = useBreakpointValue({ base: false, lg: true });
|
||||
const entryTypes = [];
|
||||
|
||||
for (const type of pageProps.entry_types)
|
||||
entryTypes.push(<option value={type.value}>{type.name}</option>)
|
||||
|
||||
useEffect(() => {
|
||||
(async function () {
|
||||
const queueRequest = await fetch("/api/mod-queue")
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Container maxW="container.xl">
|
||||
<Flex>
|
||||
<VStack>
|
||||
|
||||
</VStack>
|
||||
<Box display={ isDesktop ? undefined : "none" } w="250px">
|
||||
<Select placeholder="Entry Type">
|
||||
<option value="">All</option>
|
||||
{entryTypes}
|
||||
</Select>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
export const title = "Mod Queue - Car Crushers";
|
238
pages/privacy.page.tsx
Normal file
@ -0,0 +1,238 @@
|
||||
import { Container, Heading, Link, Text } from "@chakra-ui/react";
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<Container maxW="container.lg" pb="8vh" pt="4vh" textAlign="start">
|
||||
<Heading>Privacy Policy</Heading>
|
||||
<br />
|
||||
<Text>Last Updated: 2023-01-07</Text>
|
||||
<br />
|
||||
<hr />
|
||||
<br />
|
||||
<Heading size="lg">Information We Collect</Heading>
|
||||
<br />
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Discord Profile Information</strong>: We receive account
|
||||
information from Discord, Inc. when you sign in such as your username,
|
||||
discriminator, avatar, and banner in order to authenticate you and
|
||||
authorize requests. A list of information available can be found at{" "}
|
||||
<Link
|
||||
color="#646cff"
|
||||
href="https://discord.com/developers/docs/resources/use"
|
||||
target="_blank"
|
||||
>
|
||||
Discord's Developer Portal
|
||||
</Link>
|
||||
.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Email Addresses</strong>: We receive your email address from
|
||||
your Discord account in order to allow us to contact you as necessary.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Member Information from Our Discord Server</strong>: We
|
||||
receive a list of your roles within our Discord Server in order to
|
||||
determine your ability to access certain parts of this site.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Error Reports</strong>: We collect error reports to aid in
|
||||
fixing bugs and ensuring site stability.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Uploaded Files and Reports</strong>: We store uploaded files
|
||||
and user reports to aid in moderating exploiters from Car Crushers 2.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Appeals</strong>: We store appeal requests to review them.
|
||||
</li>
|
||||
</ul>
|
||||
<br />
|
||||
<Heading size="lg">
|
||||
Legal Basis for Processing of Your Personal Data
|
||||
</Heading>
|
||||
<br />
|
||||
<ul>
|
||||
<li>
|
||||
<strong>To fulfill contractual commitments</strong>: E.g allow you to
|
||||
use the site as intended.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Legitimate interests</strong>: In some cases, we continue to
|
||||
process data on the grounds that our legitimate interests override the
|
||||
interests or rights and freedoms of affected individuals. These
|
||||
interests may include but are not limited to: Protecting ourselves and
|
||||
our users, and preventing spam.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Consent</strong>: Where required by law, and in some other
|
||||
cases, we handle personal data on the basis of your implied or express
|
||||
consent.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Legal Requirements</strong>: We need to use and disclose
|
||||
personal data in certain ways to comply with our legal obligations.
|
||||
</li>
|
||||
</ul>
|
||||
<br />
|
||||
<Heading size="lg">Disclosure of Your Information</Heading>
|
||||
<br />
|
||||
<Text>
|
||||
While we have no intention of giving your personal data to Mark
|
||||
Zuckerberg, in certain circumstances we may share your information with
|
||||
third parties, as set forth below.
|
||||
</Text>
|
||||
<br />
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Business Transfers</strong>: In the event of a corporate sale,
|
||||
merger, reorganization, bankruptcy, dissolution or etc., your
|
||||
information may be part of the transferred assets.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Consent</strong>: We may transfer your information with your
|
||||
consent.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Agents and Related Third Parties</strong>: We enlist the help
|
||||
of other entities to perform certain tasks related to this site, such
|
||||
as: Data Storage, Error Tracking and Reporting, and Hosting.
|
||||
</li>
|
||||
</ul>
|
||||
<br />
|
||||
<Heading size="lg">Our Partners</Heading>
|
||||
<br />
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Cloudflare, Inc. (San Francisco, CA)</strong>: Hosting and
|
||||
data storage provider.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Google, LLC. (Mountain View, CA)</strong>: File storage.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Mailgun Technologies, Inc. (San Antonio, TX)</strong>: Email
|
||||
solutions.
|
||||
</li>
|
||||
<li>
|
||||
<strong>
|
||||
Functional Software, Inc. d/b/a Sentry (San Francisco, CA)
|
||||
</strong>
|
||||
: Error reporting.
|
||||
</li>
|
||||
</ul>
|
||||
<br />
|
||||
<Heading size="lg">Security</Heading>
|
||||
<br />
|
||||
<Text>
|
||||
We take reasonable steps to protect the information provided via the
|
||||
Services from loss, misuse, and unauthorized access, disclosure,
|
||||
alteration, or destruction. However, no Internet or email transmission
|
||||
is ever fully secure or error free. In particular, email sent to or from
|
||||
the Services may not be secure. Therefore, you should take special care
|
||||
in deciding what information you send to us via email or forms. Please
|
||||
keep this in mind when disclosing any information via the Internet.
|
||||
</Text>
|
||||
<br />
|
||||
<Heading size="lg">Cookies</Heading>
|
||||
<br />
|
||||
<Text>
|
||||
All cookies by this site are necessary for the site to function. We do
|
||||
not set any marketing or tracking cookies.
|
||||
</Text>
|
||||
<br />
|
||||
<Heading size="lg">Children</Heading>
|
||||
<br />
|
||||
<Text>
|
||||
Our services are meant for users who meet or exceed the age of digital
|
||||
consent under relevant laws (such as COPPA and GDPR).
|
||||
</Text>
|
||||
<br />
|
||||
<Heading size="lg">Links to Other Websites</Heading>
|
||||
<br />
|
||||
<Text>
|
||||
This privacy policy only applies to this site and not other sites.
|
||||
</Text>
|
||||
<br />
|
||||
<Heading size="lg">Data Retention</Heading>
|
||||
<br />
|
||||
<Text>
|
||||
All data is retained as stated in the section below and is necessary to
|
||||
conduct operations.
|
||||
</Text>
|
||||
<br />
|
||||
<Heading size="lg">Data Retention Periods</Heading>
|
||||
<br />
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Session Data</strong>: Maximum of one week (or on logout).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Email Addresses</strong>: From the time that you submit a form
|
||||
to the time that it is marked as closed (i.e when your appeal is
|
||||
reviewed).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Profile, Server, and Permission Data</strong>: See the Session
|
||||
Data entry.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Error Reports</strong>: Error reports collected by Sentry are
|
||||
deleted after 90 days. Sensitive information such as IP addresses are
|
||||
not retained with logs.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Uploaded Files and Report Submissions</strong>: Report
|
||||
submissions and uploaded media as part of a report submission may be
|
||||
retained indefinitely. Email addresses are removed after report
|
||||
review.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Appeals</strong>: Appeal submissions are retained for 3 years
|
||||
after closure date.
|
||||
</li>
|
||||
</ul>
|
||||
<br />
|
||||
<Heading size="lg">Data Rights and Choices</Heading>
|
||||
<br />
|
||||
<Text>You have the right to:</Text>
|
||||
<br />
|
||||
<ul>
|
||||
<li>Access your personal information</li>
|
||||
<li>Delete your personal information</li>
|
||||
<li>
|
||||
Request restrictions on the processing of your personal information
|
||||
</li>
|
||||
<li>Lodge a complaint with a supervisory authority</li>
|
||||
</ul>
|
||||
<br />
|
||||
<Text>
|
||||
Some information as specified in the Data Retention section may be
|
||||
retained even after deleting your data. The rights and options listed
|
||||
above are subject to limitations based on applicable law.
|
||||
</Text>
|
||||
<br />
|
||||
<Heading size="lg">Contact Us</Heading>
|
||||
<br />
|
||||
<Text>
|
||||
By Email:{" "}
|
||||
<Link color="#646cff" href="mailto:privacy@ccdiscussion.com">
|
||||
privacy@ccdiscussion.com
|
||||
</Link>
|
||||
</Text>
|
||||
<br />
|
||||
<Text>
|
||||
Discord:{" "}
|
||||
<Link
|
||||
color="#646cff"
|
||||
href="https://discord.com/invite/carcrushers"
|
||||
target="_blank"
|
||||
>
|
||||
https://discord.com/invite/carcrushers
|
||||
</Link>
|
||||
</Text>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export const title = "Privacy - Car Crushers";
|
13
pages/report.page.server.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
export async function onBeforeRender(pageContext: PageContext) {
|
||||
return {
|
||||
pageContext: {
|
||||
pageProps: {
|
||||
logged_in: Boolean(pageContext.current_user),
|
||||
},
|
||||
status: pageContext.current_user ? 200 : 401,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const description = "Found a cheater in Car Crushers 2? Report them here.";
|
||||
export const title = "Report - Car Crushers";
|
230
pages/report.page.tsx
Normal file
@ -0,0 +1,230 @@
|
||||
import {
|
||||
Button,
|
||||
CircularProgress,
|
||||
CircularProgressLabel,
|
||||
Container,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Heading,
|
||||
HStack,
|
||||
Input,
|
||||
Link,
|
||||
Text,
|
||||
useToast,
|
||||
} from "@chakra-ui/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Login from "../components/Login";
|
||||
import Success from "../components/Success";
|
||||
|
||||
export function Page(pageProps: { [p: string]: any }) {
|
||||
if (!pageProps.logged_in) return <Login />;
|
||||
|
||||
const [fileProgress, setFileProgress] = useState(0);
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const [supportsRequestStreams, setSupportsRequestStreams] = useState(false);
|
||||
const toast = useToast();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionStorage.getItem("REPORT_SUCCESS")) {
|
||||
sessionStorage.removeItem("REPORT_SUCCESS");
|
||||
return setShowSuccess(true);
|
||||
}
|
||||
|
||||
setSupportsRequestStreams(
|
||||
(() => {
|
||||
let duplexAccessed = false;
|
||||
|
||||
const hasContentType = new Request("", {
|
||||
body: new ReadableStream(),
|
||||
method: "POST",
|
||||
// @ts-ignore
|
||||
get duplex() {
|
||||
duplexAccessed = true;
|
||||
return "half";
|
||||
},
|
||||
}).headers.has("Content-Type");
|
||||
|
||||
return duplexAccessed && !hasContentType;
|
||||
})()
|
||||
);
|
||||
}, []);
|
||||
|
||||
async function submit() {
|
||||
const usernames = (
|
||||
document.getElementById("usernames") as HTMLInputElement
|
||||
).value
|
||||
.replaceAll(" ", "")
|
||||
.split(",");
|
||||
const file = (
|
||||
document.getElementById("evidence") as HTMLInputElement
|
||||
).files?.item(0);
|
||||
|
||||
if (!usernames.length)
|
||||
return toast({
|
||||
description: "Must provide at least one username",
|
||||
isClosable: true,
|
||||
status: "error",
|
||||
title: "Error",
|
||||
});
|
||||
|
||||
if (!file)
|
||||
return toast({
|
||||
description: "Must attach a file",
|
||||
isClosable: true,
|
||||
status: "error",
|
||||
title: "Error",
|
||||
});
|
||||
|
||||
if (usernames.length > 20)
|
||||
return toast({
|
||||
description: "Only up to twenty users can be reported at a time",
|
||||
isClosable: true,
|
||||
status: "error",
|
||||
title: "Too Many Usernames",
|
||||
});
|
||||
|
||||
const submitReq = await fetch("/api/reports/submit", {
|
||||
body: JSON.stringify({
|
||||
filename: file.name,
|
||||
filesize: file.size,
|
||||
usernames,
|
||||
}),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!submitReq.ok)
|
||||
return toast({
|
||||
description: ((await submitReq.json()) as { error: string }).error,
|
||||
isClosable: true,
|
||||
status: "error",
|
||||
title: "Error",
|
||||
});
|
||||
|
||||
const { id, upload_url }: { id: string; upload_url: string } =
|
||||
await submitReq.json();
|
||||
|
||||
setUploading(true);
|
||||
const reader = file.stream().getReader();
|
||||
let bytesRead = 0;
|
||||
|
||||
const uploadReq = await fetch(upload_url, {
|
||||
body: supportsRequestStreams
|
||||
? new ReadableStream({
|
||||
async pull(controller) {
|
||||
const chunk = await reader.read();
|
||||
|
||||
if (chunk.done) {
|
||||
controller.close();
|
||||
setUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
controller.enqueue(chunk.value);
|
||||
bytesRead += chunk.value.length;
|
||||
setFileProgress(Math.floor((bytesRead / file.size) * 100));
|
||||
},
|
||||
})
|
||||
: file,
|
||||
// @ts-expect-error
|
||||
duplex: supportsRequestStreams ? "half" : undefined,
|
||||
headers: {
|
||||
"content-type": file.type,
|
||||
},
|
||||
method: "PUT",
|
||||
}).catch(console.error);
|
||||
|
||||
if (!uploadReq?.ok) {
|
||||
await fetch("/api/reports/recall", {
|
||||
body: JSON.stringify({ id }),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
return toast({
|
||||
description: "Failed to upload file",
|
||||
isClosable: true,
|
||||
status: "error",
|
||||
title: "Error",
|
||||
});
|
||||
}
|
||||
|
||||
await fetch("/api/reports/complete", {
|
||||
body: JSON.stringify({ id }),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
sessionStorage.setItem("REPORT_SUCCESS", "1");
|
||||
}
|
||||
|
||||
return showSuccess ? (
|
||||
<Success
|
||||
heading="Report Submitted"
|
||||
message="We will review it as soon as possible."
|
||||
/>
|
||||
) : (
|
||||
<Container maxW="container.md" pt="4vh" textAlign="start">
|
||||
<Heading mb="4vh">Report an Exploiter</Heading>
|
||||
<br />
|
||||
<FormControl isRequired>
|
||||
<FormLabel>
|
||||
Username(s) - To specify more than one, provide a comma-delimited list
|
||||
(User1, User2, User3...)
|
||||
</FormLabel>
|
||||
<Input id="usernames" placeholder="builderman" />
|
||||
</FormControl>
|
||||
<br />
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Your Evidence (Max Size: 512MB)</FormLabel>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
mr="8px"
|
||||
onClick={() => document.getElementById("evidence")?.click()}
|
||||
>
|
||||
Select File
|
||||
</Button>
|
||||
<input id="evidence" type="file" />
|
||||
</FormControl>
|
||||
<br />
|
||||
<br />
|
||||
<Text>
|
||||
By submitting this form, you agree to the{" "}
|
||||
<Link color="#646cff" href="/terms">
|
||||
Terms of Service
|
||||
</Link>{" "}
|
||||
and{" "}
|
||||
<Link color="#646cff" href="/privacy">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
<br />
|
||||
<HStack>
|
||||
<Button
|
||||
disabled={uploading}
|
||||
mr="8px"
|
||||
onClick={async () => await submit()}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
<CircularProgress
|
||||
display={uploading ? "" : "none"}
|
||||
isIndeterminate={!supportsRequestStreams}
|
||||
value={supportsRequestStreams ? fileProgress : undefined}
|
||||
>
|
||||
{supportsRequestStreams ? (
|
||||
<CircularProgressLabel>{fileProgress}%</CircularProgressLabel>
|
||||
) : null}
|
||||
</CircularProgress>
|
||||
</HStack>
|
||||
</Container>
|
||||
);
|
||||
}
|
138
pages/support.page.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionButton,
|
||||
AccordionIcon,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Box,
|
||||
Container,
|
||||
Heading,
|
||||
Link,
|
||||
Spacer,
|
||||
VStack,
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<Container
|
||||
borderRadius="12px"
|
||||
borderWidth="1px"
|
||||
maxW="container.md"
|
||||
mt="8vh"
|
||||
>
|
||||
<VStack w="100%" spacing={3}>
|
||||
<Spacer />
|
||||
<Heading alignSelf="start" pl="2.5%" size="md">
|
||||
What do you need help with?
|
||||
</Heading>
|
||||
<Spacer />
|
||||
<Accordion textAlign="left" w="100%">
|
||||
<AccordionItem>
|
||||
<h2>
|
||||
<AccordionButton>
|
||||
<Box as="span" flex="1" textAlign="left">
|
||||
I want to report someone exploiting
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
<AccordionPanel>
|
||||
To report a player,{" "}
|
||||
<Link color="#646cff" href="/report">
|
||||
head to our report page.
|
||||
</Link>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem>
|
||||
<h2>
|
||||
<AccordionButton>
|
||||
<Box as="span" flex="1" textAlign="left">
|
||||
I want a data rollback or transfer
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
<AccordionPanel>
|
||||
Please join our{" "}
|
||||
<Link
|
||||
color="#646cff"
|
||||
href="https://discord.com/invite/carcrushers"
|
||||
>
|
||||
Discord server
|
||||
</Link>{" "}
|
||||
and contact ModMail.
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem>
|
||||
<h2>
|
||||
<AccordionButton>
|
||||
<Box as="span" flex="1" textAlign="left">
|
||||
I want to appeal my ban
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
<AccordionPanel>
|
||||
If you were banned from our Discord server,{" "}
|
||||
<Link color="#646cff" href="/appeals">
|
||||
use this form
|
||||
</Link>
|
||||
. If you were banned from the game,{" "}
|
||||
<Link
|
||||
color="#646cff"
|
||||
href="https://www.roblox.com/games/527921900/Car-Crushers-2-Appeals"
|
||||
>
|
||||
fill out the form here
|
||||
</Link>
|
||||
.
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem>
|
||||
<h2>
|
||||
<AccordionButton>
|
||||
<Box as="span" flex="1" textAlign="left">
|
||||
I want to apply for a staff position
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
<AccordionPanel>
|
||||
Most staff position openings will be announced in our{" "}
|
||||
<Link
|
||||
color="#646cff"
|
||||
href="https://discord.com/invite/carcrushers"
|
||||
>
|
||||
Discord server
|
||||
</Link>
|
||||
. Forum mod openings are generally announced on the forum.
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem>
|
||||
<h2>
|
||||
<AccordionButton>
|
||||
<Box as="span" flex="1" textAlign="left">
|
||||
My problem is not listed
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
<AccordionPanel>
|
||||
Join our{" "}
|
||||
<Link
|
||||
color="#646cff"
|
||||
href="https://discord.com/invite/carcrushers"
|
||||
>
|
||||
Discord server
|
||||
</Link>{" "}
|
||||
and open a ticket with ModMail.
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<Spacer />
|
||||
<Spacer />
|
||||
</VStack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export const title = "Support - Car Crushers";
|
56
pages/team.page.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import {
|
||||
Card,
|
||||
CardFooter,
|
||||
Code,
|
||||
Container,
|
||||
Divider,
|
||||
Heading,
|
||||
Image,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@chakra-ui/react";
|
||||
import team from "../data/team.json";
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<Container maxW="container.xl" pt="4vh">
|
||||
<Heading textAlign="start">Our Team</Heading>
|
||||
<br />
|
||||
<Text textAlign="start">
|
||||
Please respect our staff, and <u>do not send direct messages</u> or
|
||||
friend requests in place of official channels.
|
||||
</Text>
|
||||
<br />
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: "1rem",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(16rem, 1fr))",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{team.map((member) => (
|
||||
<Card key={member.id} maxW="xs" p="12px">
|
||||
<Image
|
||||
alt={member.tag + "'s avatar"}
|
||||
borderRadius="50%"
|
||||
src={`/files/avatars/${member.id}.webp`}
|
||||
/>
|
||||
<Stack mb="8" mt="6" spacing="3">
|
||||
<b>
|
||||
<Heading size="md">{member.tag}</Heading>
|
||||
</b>
|
||||
<Text>{member.position}</Text>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<CardFooter justifyContent="center">
|
||||
<Code>ID: {member.id}</Code>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export const title = "Team - Car Crushers";
|
122
pages/terms.page.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import { Container, Heading, Link, Text } from "@chakra-ui/react";
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<Container maxW="container.lg" pb="8vh" pt="4vh" textAlign="start">
|
||||
<Heading>Terms and Conditions</Heading>
|
||||
<br />
|
||||
<Text>Last Updated: 2023-01-07</Text>
|
||||
<br />
|
||||
<Text>Yes, we know this shit is boring to read, but it's important.</Text>
|
||||
<br />
|
||||
<hr />
|
||||
<br />
|
||||
<Text>These terms govern your use of the Car Crushers website.</Text>
|
||||
<br />
|
||||
<Text>
|
||||
You would think people have common sense but sadly many don't.
|
||||
</Text>
|
||||
<br />
|
||||
<Text>For this reason, we have to create this document.</Text>
|
||||
<br />
|
||||
<br />
|
||||
<Heading size="lg">Definitions</Heading>
|
||||
<br />
|
||||
<ul>
|
||||
<li>We, Us: Car Crushers (the operator of this website)</li>
|
||||
<li>You: The person currently reading this document</li>
|
||||
</ul>
|
||||
<br />
|
||||
<br />
|
||||
<Heading size="lg">General Rules</Heading>
|
||||
<br />
|
||||
<ul>
|
||||
<li>Do not upload malicious files to this site</li>
|
||||
<li>Do not submit spam using forms on this site</li>
|
||||
<li>Do not upload any content illegal under the laws of Sweden</li>
|
||||
<li>You must be at least 13 years old to use this site</li>
|
||||
<li>
|
||||
You may not automate access to this site by any means (except a public
|
||||
search crawler if you operate one)
|
||||
</li>
|
||||
<li>
|
||||
You may not falsely imply that you are affiliated with or endorsed by
|
||||
Car Crushers
|
||||
</li>
|
||||
<li>
|
||||
<Link color="#646cff" href="/files/why.jpg" target="_blank">
|
||||
All visitors from New Jersey must explain why
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
<br />
|
||||
<br />
|
||||
<Heading size="lg">Enforcement</Heading>
|
||||
<br />
|
||||
<Text>
|
||||
We may investigate and prosecute violations of these terms to the
|
||||
fullest legal extent. We may notify and cooperate with law enforcement
|
||||
authorities in prosecuting violations of the law and these terms.
|
||||
</Text>
|
||||
<br />
|
||||
<br />
|
||||
<Heading size="lg">Your Content</Heading>
|
||||
<br />
|
||||
<Text>
|
||||
Nothing in these terms grant us ownership rights to any content that you
|
||||
submit to this site. Nothing in these terms grants you ownership rights
|
||||
to our intellectual property either. Any content you submit to this site
|
||||
is your responsibility. Content you submit to us belongs to you. But at
|
||||
a minimum, you license us to store the content and display it to
|
||||
authorized users.
|
||||
</Text>
|
||||
<br />
|
||||
<br />
|
||||
<Heading size="lg">Warranty and Disclaimer</Heading>
|
||||
<br />
|
||||
<Text>
|
||||
WE DO NOT GUARANTEE A BUG-FREE SITE, THAT IS IMPOSSIBLE. THERE IS
|
||||
ABSOLUTELY NO WARRANTY WHATSOEVER, EXPRESS OR IMPLIED. YOUR USE OF THIS
|
||||
SITE IS AT YOUR OWN RISK. THERE ARE NO GUARANTEES ON ANYTHING, NOT EVEN
|
||||
THAT THIS SITE WILL EXIST TOMORROW.
|
||||
</Text>
|
||||
<br />
|
||||
<br />
|
||||
<Heading size="lg">Termination</Heading>
|
||||
<br />
|
||||
<Text>
|
||||
We may terminate or suspend your access to the Service immediately,
|
||||
without prior notice or liability, under our sole discretion, for any
|
||||
reason whatsoever and without limitation, including but not limited to a
|
||||
breach of the Terms.
|
||||
</Text>
|
||||
<br />
|
||||
<Text>
|
||||
The contract is fully terminated when all user data (including every
|
||||
copy of every file uploaded) is fully deleted from our service.
|
||||
</Text>
|
||||
<br />
|
||||
<br />
|
||||
<Heading size="lg">Indemnification</Heading>
|
||||
<br />
|
||||
<Text>
|
||||
You agree to defend, indemnify and hold harmless Car Crushers and its
|
||||
licensee and licensors, and their employees, contractors, agents,
|
||||
officers and directors, from and against any and all claims, damages,
|
||||
obligations, losses, liabilities, costs or debt, and expenses (including
|
||||
but not limited to attorney's fees), resulting from or arising out of a)
|
||||
your use and access of the Service, or b) a breach of these Terms.
|
||||
</Text>
|
||||
<br />
|
||||
<br />
|
||||
<Heading size="lg">Governing Law</Heading>
|
||||
<br />
|
||||
<Text>
|
||||
These terms shall be governed and construed in accordance with the laws
|
||||
of Västmanland, Sweden.
|
||||
</Text>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export const title = "Terms - Car Crushers";
|
25
public/app.webmanifest
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"short_name": "CC",
|
||||
"name": "Car Crushers",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#00a8f8",
|
||||
"background_color": "#ffffff"
|
||||
}
|
BIN
public/favicon.ico
Normal file
After Width: | Height: | Size: 17 KiB |
BIN
public/files/avatars/110502920339881984.webp
Normal file
After Width: | Height: | Size: 62 KiB |
BIN
public/files/avatars/135418199083581440.webp
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
public/files/avatars/151098768807165952.webp
Normal file
After Width: | Height: | Size: 22 KiB |
BIN
public/files/avatars/165047934113677312.webp
Normal file
After Width: | Height: | Size: 665 KiB |
BIN
public/files/avatars/165594923586945025.webp
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
public/files/avatars/247766460359901194.webp
Normal file
After Width: | Height: | Size: 105 KiB |
BIN
public/files/avatars/256995590599081985.webp
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
public/files/avatars/289372404541554689.webp
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
public/files/avatars/385572238331478016.webp
Normal file
After Width: | Height: | Size: 84 KiB |
BIN
public/files/avatars/392947931336146945.webp
Normal file
After Width: | Height: | Size: 48 KiB |
BIN
public/files/avatars/396347223736057866.webp
Normal file
After Width: | Height: | Size: 833 KiB |
BIN
public/files/avatars/430533277384704010.webp
Normal file
After Width: | Height: | Size: 52 KiB |
BIN
public/files/avatars/704247919259156521.webp
Normal file
After Width: | Height: | Size: 27 KiB |
BIN
public/files/logo192.png
Normal file
After Width: | Height: | Size: 5.7 KiB |
BIN
public/files/logo512.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
public/files/oof.png
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
public/files/why.jpg
Normal file
After Width: | Height: | Size: 132 KiB |
4
public/robots.txt
Normal file
@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Disallow: /api
|
||||
Disallow: /mod
|
||||
Disallow: /my-data
|
41
renderer/_default.page.client.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot, hydrateRoot } from "react-dom/client";
|
||||
import { ChakraProvider } from "@chakra-ui/react";
|
||||
import "../index.css";
|
||||
import "@fontsource/plus-jakarta-sans";
|
||||
import theme from "../theme";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { Integrations } from "@sentry/tracing";
|
||||
import Fallback from "../components/Fallback";
|
||||
import Navigation from "../components/Navigation";
|
||||
|
||||
Sentry.init({
|
||||
dsn: import.meta.env.VITE_DSN,
|
||||
integrations: [new Integrations.BrowserTracing()],
|
||||
tracesSampleRate: import.meta.env.VITE_SAMPLE_RATE
|
||||
? parseFloat(import.meta.env.VITE_SAMPLE_RATE)
|
||||
: 0.1,
|
||||
});
|
||||
|
||||
export async function render(pageContext: PageContext) {
|
||||
const { Page, pageProps } = pageContext;
|
||||
const root = document.getElementById("root") as HTMLElement;
|
||||
const reactRoot = (
|
||||
<StrictMode>
|
||||
<ChakraProvider theme={theme}>
|
||||
<div className="App">
|
||||
<Fallback>
|
||||
<Navigation {...pageContext.current_user} />
|
||||
<Page {...pageProps} />
|
||||
</Fallback>
|
||||
</div>
|
||||
</ChakraProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
if (root.innerHTML === "") {
|
||||
createRoot(root).render(reactRoot);
|
||||
} else {
|
||||
hydrateRoot(root, reactRoot);
|
||||
}
|
||||
}
|
61
renderer/_default.page.server.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import ReactDOMServer from "react-dom/server";
|
||||
import { StrictMode } from "react";
|
||||
import { dangerouslySkipEscape, escapeInject } from "vite-plugin-ssr";
|
||||
import theme from "../theme";
|
||||
import { ChakraProvider } from "@chakra-ui/react";
|
||||
import Fallback from "../components/Fallback";
|
||||
import Navigation from "../components/Navigation";
|
||||
import Login from "../components/Login";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
|
||||
export const passToClient = ["current_user", "pageProps"];
|
||||
|
||||
export async function render(
|
||||
pageContext: PageContext & { pageProps: { [k: string]: any } }
|
||||
) {
|
||||
const { exports, Page, pageProps, status } = pageContext;
|
||||
|
||||
const reactHTML = Page
|
||||
? ReactDOMServer.renderToString(
|
||||
<StrictMode>
|
||||
<ChakraProvider theme={theme}>
|
||||
<div className="App">
|
||||
<Fallback>
|
||||
<Navigation {...pageContext.current_user} />
|
||||
{status === 200 ? (
|
||||
Page ? (
|
||||
<Page {...pageProps} />
|
||||
) : (
|
||||
""
|
||||
)
|
||||
) : (
|
||||
{ 401: <Login />, 403: <Forbidden /> }[status]
|
||||
)}
|
||||
</Fallback>
|
||||
</div>
|
||||
</ChakraProvider>
|
||||
</StrictMode>
|
||||
)
|
||||
: "";
|
||||
|
||||
return escapeInject`<!DOCTYPE html>
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="theme-color" content="#00a8f8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" type="image/png" href="/files/logo192.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="${
|
||||
(exports.description as string) ?? "Car Crushers Website"
|
||||
}" />
|
||||
<meta property="og:description" content="${
|
||||
(exports.description as string | null) ?? "Car Crushers Website"
|
||||
}" />
|
||||
<title>${(exports.title as string | null) ?? "Car Crushers"}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">${dangerouslySkipEscape(reactHTML)}</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
33
renderer/_error.page.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { Container, Heading, Link, Text } from "@chakra-ui/react";
|
||||
import { PageContextBuiltIn } from "vite-plugin-ssr";
|
||||
|
||||
export function Page(pageProps: PageContextBuiltIn) {
|
||||
if (pageProps.is404)
|
||||
return (
|
||||
<Container maxW="container.lg" pt="8vh" textAlign="left">
|
||||
<Heading size="4xl">404</Heading>
|
||||
<br />
|
||||
<Text fontSize="xl">There is nothing to find here.</Text>
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<Link color="#646cff" onClick={() => history.go(-1)}>
|
||||
Go back
|
||||
</Link>
|
||||
</Container>
|
||||
);
|
||||
|
||||
return (
|
||||
<Container maxW="container.lg" pt="8vh" textAlign="left">
|
||||
<Heading size="4xl">500</Heading>
|
||||
<br />
|
||||
<Text fontSize="xl">S̶̡͈̠̗̠͖͙̭o̶̶͕͚̥͍̪̤m̸̨͏͈͔̖͚̖̰̱͞e҉̵͖͚͇̀t̕͟͠͏͎̺̯̲̱̣̤̠̟͙̠̙̫̬ḩ̸̭͓̬͎̙̀į̞̮͉͖̰̥̹͚̫̙̪̗̜̳̕ͅn҉͔̯̪̗̝̝͖̲͇͍͎̲̲̤̖̫͈̪͡g̴̰̻̙̝͉̭͇̖̰̝̙͕̼͙͘͜ ̵̶̫̥̳̲̘̻̗͈͕̭̲͇̘̜̺̟̥̖̥b̴̙̭̹͕̞͠r̞͎̠̩͈̖̰̞̯̯͢͢͠ͅo̝̯̗̹̳͍̰͉͕̘̰̠̺̥̰͔̕ͅk̵̸̻̠͕̺̦̦͖̲̺̦̞̝̞͞͡e̶͏̤̼̼͔̘̰̰̭͈̀͞͡</Text>
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<Link color="#646cff" onClick={() => location.reload()}>
|
||||
Reload
|
||||
</Link>
|
||||
</Container>
|
||||
);
|
||||
}
|
15
theme.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { extendTheme } from "@chakra-ui/react";
|
||||
|
||||
const fontString =
|
||||
'"Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;';
|
||||
|
||||
export default extendTheme({
|
||||
config: {
|
||||
initialColorMode: "dark",
|
||||
useSystemColorMode: true,
|
||||
},
|
||||
fonts: {
|
||||
body: fontString,
|
||||
heading: fontString,
|
||||
},
|
||||
});
|
21
tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["@cloudflare/workers-types"]
|
||||
},
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
9
tsconfig.node.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
8
vite.config.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import ssr from "vite-plugin-ssr/plugin";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), ssr({ prerender: true })],
|
||||
});
|