Initial commit

This commit is contained in:
2023-10-19 16:49:05 -04:00
commit d731041378
63 changed files with 10037 additions and 0 deletions

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