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 })],
|
||||||
|
});
|