Initial commit

This commit is contained in:
regalijan 2023-10-19 16:49:05 -04:00
commit d731041378
Signed by: regalijan
GPG Key ID: 5D4196DA269EF520
63 changed files with 10037 additions and 0 deletions

26
.gitignore vendored Normal file
View 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
View File

@ -0,0 +1 @@
v16.19.0

93
OFL.txt Normal file
View 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
View 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
View 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>
);
}

View 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
View 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>
);
}

View 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
View 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
View 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
View 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
View 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];

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

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

View 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}`
);
}

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

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

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

View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View 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"
}
}

View 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
View 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
View 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";

View 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
View 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
View 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";

View 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
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 833 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
public/files/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

BIN
public/files/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
public/files/oof.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
public/files/why.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

4
public/robots.txt Normal file
View File

@ -0,0 +1,4 @@
User-agent: *
Disallow: /api
Disallow: /mod
Disallow: /my-data

View 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);
}
}

View 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
View 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
View 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
View 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
View File

@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

8
vite.config.ts Normal file
View 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 })],
});