Initial commit
This commit is contained in:
113
functions/api/appeals/submit.ts
Normal file
113
functions/api/appeals/submit.ts
Normal file
@ -0,0 +1,113 @@
|
||||
export async function onRequestPost(context: RequestContext) {
|
||||
const { learned, whyBanned, whyUnban } = context.data.body;
|
||||
|
||||
if (
|
||||
typeof learned !== "string" ||
|
||||
typeof whyBanned !== "string" ||
|
||||
typeof whyUnban !== "string" ||
|
||||
!learned.length ||
|
||||
learned.length > 2000 ||
|
||||
!whyBanned.length ||
|
||||
whyBanned.length > 500 ||
|
||||
!whyUnban.length ||
|
||||
whyUnban.length > 2000
|
||||
)
|
||||
return new Response(
|
||||
'{"error":"One or more fields are missing or invalid"}',
|
||||
{
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
status: 400,
|
||||
}
|
||||
);
|
||||
|
||||
const { current_user: currentUser } = context.data;
|
||||
|
||||
if (!currentUser.email)
|
||||
return new Response('{"error":"No email for this session"}', {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
status: 403,
|
||||
});
|
||||
|
||||
const existingAppeals = await context.env.DATA.list({
|
||||
prefix: `appeal_${currentUser.id}`,
|
||||
});
|
||||
const existingBlockedAppeal = await context.env.DATA.get(
|
||||
`blockedappeal_${currentUser.id}`
|
||||
);
|
||||
|
||||
if (
|
||||
existingBlockedAppeal ||
|
||||
existingAppeals.keys.find(
|
||||
(appeal) => (appeal.metadata as { [k: string]: any })?.open
|
||||
)
|
||||
)
|
||||
return new Response('{"error":"Appeal already submitted"}', {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
status: 403,
|
||||
});
|
||||
|
||||
if (await context.env.DATA.get(`appealban_${currentUser.id}`)) {
|
||||
await context.env.DATA.put(`blockedappeal_${currentUser.id}`, "1", {
|
||||
metadata: { email: currentUser.email },
|
||||
});
|
||||
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
});
|
||||
}
|
||||
|
||||
const appealId = `${currentUser.id}${Date.now()}${crypto
|
||||
.randomUUID()
|
||||
.replaceAll("-", "")}`;
|
||||
|
||||
await context.env.DATA.put(
|
||||
`appeal_${appealId}`,
|
||||
JSON.stringify({
|
||||
learned,
|
||||
user: currentUser,
|
||||
whyBanned,
|
||||
whyUnban,
|
||||
}),
|
||||
{
|
||||
expirationTtl: 94608000,
|
||||
metadata: {
|
||||
created_at: Date.now(),
|
||||
id: currentUser.id,
|
||||
open: true,
|
||||
tag: `${currentUser.id}#${currentUser.discriminator}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await fetch(context.env.APPEALS_WEBHOOK, {
|
||||
body: JSON.stringify({
|
||||
embeds: [
|
||||
{
|
||||
title: "Appeal Submitted",
|
||||
color: 3756250,
|
||||
description: `View this appeal at https://carcrushers.cc/mod-queue?id=${appealId}&type=appeal`,
|
||||
fields: [
|
||||
{
|
||||
name: "Submitter",
|
||||
value: `${currentUser.username}#${currentUser.discriminator} (${currentUser.id})`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
});
|
||||
}
|
21
functions/api/appeals/toggle.ts
Normal file
21
functions/api/appeals/toggle.ts
Normal file
@ -0,0 +1,21 @@
|
||||
export async function onRequestPost(context: RequestContext) {
|
||||
const { active } = context.data.body;
|
||||
|
||||
if (typeof active !== "boolean")
|
||||
return new Response('{"error":"Active property must be a boolean"}', {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
status: 400,
|
||||
});
|
||||
|
||||
if (active) {
|
||||
await context.env.DATA.delete("appeal_disabled");
|
||||
} else {
|
||||
await context.env.DATA.put("appeal_disabled", "1");
|
||||
}
|
||||
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
});
|
||||
}
|
20
functions/api/auth/oauth.ts
Normal file
20
functions/api/auth/oauth.ts
Normal file
@ -0,0 +1,20 @@
|
||||
export async function onRequestGet(context: RequestContext) {
|
||||
const { env, request } = context;
|
||||
const { host, protocol } = new URL(request.url);
|
||||
let returnPath = "/";
|
||||
const referer = request.headers.get("referer");
|
||||
|
||||
if (referer) returnPath = new URL(referer).pathname;
|
||||
|
||||
const state = crypto.randomUUID().replaceAll("-", "");
|
||||
|
||||
await env.DATA.put(`state_${state}`, returnPath, { expirationTtl: 300 });
|
||||
|
||||
return Response.redirect(
|
||||
`https://discord.com/oauth2/authorize?client_id=${
|
||||
env.DISCORD_ID
|
||||
}&redirect_uri=${encodeURIComponent(
|
||||
`${protocol}//${host}/api/auth/session`
|
||||
)}&response_type=code&scope=identify%20email%20guilds.members.read&state=${state}`
|
||||
);
|
||||
}
|
207
functions/api/auth/session.ts
Normal file
207
functions/api/auth/session.ts
Normal file
@ -0,0 +1,207 @@
|
||||
import GetPermissions from "../../permissions";
|
||||
|
||||
async function generateTokenHash(token: string): Promise<string> {
|
||||
const hash = await crypto.subtle.digest(
|
||||
"SHA-512",
|
||||
new TextEncoder().encode(token)
|
||||
);
|
||||
return btoa(String.fromCharCode(...new Uint8Array(hash)))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
}
|
||||
|
||||
function response(body: string, status: number) {
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
export async function onRequestDelete(context: RequestContext) {
|
||||
const cookies = context.request.headers.get("cookie")?.split("; ");
|
||||
|
||||
if (!cookies) return response('{"error":"Not logged in"}', 401);
|
||||
|
||||
for (const cookie of cookies) {
|
||||
const [name, value] = cookie.split("=");
|
||||
|
||||
if (name !== "_s") continue;
|
||||
|
||||
await context.env.DATA.delete(`auth_${await generateTokenHash(value)}`);
|
||||
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"set-cookie": "_s=; Max-Age=0",
|
||||
},
|
||||
status: 204,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function onRequestGet(context: RequestContext) {
|
||||
const { host, protocol, searchParams } = new URL(context.request.url);
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
|
||||
if (!code) return response('{"error":"Missing code"}', 400);
|
||||
if (!state) return response('{"error":"Missing state"}', 400);
|
||||
|
||||
const stateRedirect = await context.env.DATA.get(`state_${state}`);
|
||||
|
||||
if (!stateRedirect) return response('{"error":"Invalid state"}', 400);
|
||||
|
||||
const tokenReq = await fetch("https://discord.com/api/oauth2/token", {
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: `${protocol}//${host}/api/auth/session`,
|
||||
}).toString(),
|
||||
headers: {
|
||||
authorization: `Basic ${btoa(
|
||||
context.env.DISCORD_ID + ":" + context.env.DISCORD_SECRET
|
||||
)}`,
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!tokenReq.ok) {
|
||||
console.log(await tokenReq.text());
|
||||
|
||||
return response('{"error":"Failed to redeem code"}', 500);
|
||||
}
|
||||
|
||||
const tokenData: {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
refresh_token: string;
|
||||
scope: string;
|
||||
token_type: string;
|
||||
} = await tokenReq.json();
|
||||
|
||||
if (tokenData.scope.search("guilds.members.read") === -1)
|
||||
return response('{"error":"Do not touch the scopes!"}', 400);
|
||||
|
||||
let userData: { [k: string]: any } = {
|
||||
...tokenData,
|
||||
refresh_at: Date.now() + tokenData.expires_in * 1000 - 86400000,
|
||||
};
|
||||
|
||||
const userReq = await fetch("https://discord.com/api/v10/users/@me", {
|
||||
headers: {
|
||||
authorization: `Bearer ${tokenData.access_token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userReq.ok) {
|
||||
console.log(await userReq.text());
|
||||
return response('{"error":"Failed to retrieve user"}', 500);
|
||||
}
|
||||
|
||||
const apiUser: { [k: string]: any } = await userReq.json();
|
||||
userData = {
|
||||
...userData,
|
||||
...apiUser,
|
||||
};
|
||||
|
||||
const serverMemberReq = await fetch(
|
||||
"https://discord.com/api/v10/users/@me/guilds/242263977986359297/member",
|
||||
{
|
||||
headers: {
|
||||
authorization: `Bearer ${tokenData.access_token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const memberData: { [k: string]: any } = await serverMemberReq.json();
|
||||
|
||||
if (serverMemberReq.ok) {
|
||||
userData.permissions = GetPermissions(userData.id, memberData.roles);
|
||||
userData.roles = memberData.roles;
|
||||
} else {
|
||||
userData.permissions = GetPermissions(userData.id);
|
||||
}
|
||||
|
||||
const tokenPrefixes = [
|
||||
"ABOVE-THE-SKY",
|
||||
"BANDITO",
|
||||
"BE-CONCERNED",
|
||||
"CAR-RADIO",
|
||||
"CHEESE",
|
||||
"CHLORINE",
|
||||
"CRAZY-EQUALS-GENIUS",
|
||||
"CUBICLES",
|
||||
"DEAD",
|
||||
"DEMOLITION-LOVERS",
|
||||
"DEVIL-DOGS",
|
||||
"DOUBT",
|
||||
"DREADNOUGHT",
|
||||
"DYING-IN-LA",
|
||||
"FAIRLY-LOCAL",
|
||||
"FORMIDABLE",
|
||||
"GATES-OF-GLORY",
|
||||
"GIRLS-GIRLS-BOYS",
|
||||
"GONER",
|
||||
"HEATHENS",
|
||||
"HEAVYDIRTYSOUL",
|
||||
"HELENA",
|
||||
"HYDRA",
|
||||
"I-WRITE-SINS-NOT-TRAGEDIES",
|
||||
"KITCHEN-SINK",
|
||||
"LEVITATE",
|
||||
"LOCAL-GOD",
|
||||
"MAGGIE",
|
||||
"MAMA",
|
||||
"MONTANA",
|
||||
"NERO-FORTE",
|
||||
"NOOB",
|
||||
"NOT-TODAY",
|
||||
"NO-CHANCES",
|
||||
"POLARIZE",
|
||||
"PSYCHO",
|
||||
"ROMANCE",
|
||||
"SAD-CLOWN",
|
||||
"SATURDAY",
|
||||
"SAY-IT-LOUDER",
|
||||
"SEMI-AUTOMATIC",
|
||||
"TEENAGERS",
|
||||
"THUNDERSWORD",
|
||||
"TOKYO-DRIFTING",
|
||||
"TRAPDOOR",
|
||||
"TREES",
|
||||
"UMA-THURMAN",
|
||||
"UNSAINTED",
|
||||
"VERMILION",
|
||||
"VERSAILLES",
|
||||
"VICTORIOUS",
|
||||
"VIVA-LAS-VENGEANCE",
|
||||
"XIX",
|
||||
];
|
||||
|
||||
const selectedTokenStart =
|
||||
tokenPrefixes[Math.round(Math.random() * (tokenPrefixes.length - 1))] + "_";
|
||||
|
||||
const authToken =
|
||||
selectedTokenStart +
|
||||
`${crypto.randomUUID()}${crypto.randomUUID()}${crypto.randomUUID()}${crypto.randomUUID()}`.replaceAll(
|
||||
"-",
|
||||
""
|
||||
);
|
||||
|
||||
const tokenHash = await generateTokenHash(authToken);
|
||||
|
||||
await context.env.DATA.put(`auth_${tokenHash}`, JSON.stringify(userData), {
|
||||
expirationTtl: tokenData.expires_in,
|
||||
});
|
||||
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
location: stateRedirect,
|
||||
"set-cookie": `_s=${authToken}; HttpOnly; Max-Age=${tokenData.expires_in}; Path=/; SameSite=Lax; Secure`,
|
||||
},
|
||||
status: 302,
|
||||
});
|
||||
}
|
54
functions/api/reports/complete.ts
Normal file
54
functions/api/reports/complete.ts
Normal file
@ -0,0 +1,54 @@
|
||||
export async function onRequestPost(context: RequestContext) {
|
||||
const { id } = context.data.body;
|
||||
|
||||
if (!id)
|
||||
return new Response('{"error":"No ID provided"}', {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
status: 400,
|
||||
});
|
||||
|
||||
const userId = await context.env.DATA.get(`reportprocessing_${id}`);
|
||||
|
||||
if (!userId || userId !== context.data.current_user.id)
|
||||
return new Response('{"error":"No report with that ID is processing"}', {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
status: 404,
|
||||
});
|
||||
|
||||
await context.env.DATA.delete(`reportprocessing_${id}`);
|
||||
|
||||
const {
|
||||
metadata,
|
||||
value,
|
||||
}: KVNamespaceGetWithMetadataResult<string, { [k: string]: any }> =
|
||||
await context.env.DATA.getWithMetadata(`report_${id}`);
|
||||
|
||||
delete metadata?.p;
|
||||
await context.env.DATA.put(`report_${id}`, value as string, { metadata });
|
||||
|
||||
if (context.env.REPORTS_WEBHOOK) {
|
||||
await fetch(context.env.REPORTS_WEBHOOK, {
|
||||
body: JSON.stringify({
|
||||
embeds: [
|
||||
{
|
||||
title: "Report Submitted",
|
||||
color: 3756250,
|
||||
description: `View this report at https://carcrushers.cc/mod-queue?id=${id}&type=game-report`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
});
|
||||
}
|
27
functions/api/reports/recall.ts
Normal file
27
functions/api/reports/recall.ts
Normal file
@ -0,0 +1,27 @@
|
||||
export async function onRequestPost(context: RequestContext) {
|
||||
const { id } = context.data.body;
|
||||
|
||||
if (!id)
|
||||
return new Response('{"error":"No ID provided"}', {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
status: 400,
|
||||
});
|
||||
|
||||
const reportUserId = await context.env.DATA.get(`reportprocessing_${id}`);
|
||||
|
||||
if (!reportUserId || context.data.current_user.id !== reportUserId)
|
||||
return new Response('{"error":"No processing report with that ID found"}', {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
status: 404,
|
||||
});
|
||||
|
||||
await context.env.DATA.delete(`report_${id}`);
|
||||
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
});
|
||||
}
|
154
functions/api/reports/submit.ts
Normal file
154
functions/api/reports/submit.ts
Normal file
@ -0,0 +1,154 @@
|
||||
import { GenerateUploadURL } from "../../gcloud";
|
||||
|
||||
function errorResponse(error: string, status: number): Response {
|
||||
return new Response(JSON.stringify({ error }), {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
export async function onRequestPost(context: RequestContext) {
|
||||
const { filename, filesize, usernames } = context.data.body;
|
||||
|
||||
if (!Array.isArray(usernames))
|
||||
return errorResponse("Usernames must be type of array", 400);
|
||||
|
||||
if (typeof filename !== "string")
|
||||
return errorResponse("Invalid file name", 400);
|
||||
|
||||
if (typeof filesize !== "number" || filesize < 0 || filesize > 536870912)
|
||||
return errorResponse("Invalid file size", 400);
|
||||
|
||||
if (!usernames.length || usernames.length > 20)
|
||||
return errorResponse(
|
||||
"Number of usernames provided must be between 1 and 20",
|
||||
400
|
||||
);
|
||||
|
||||
for (const username of usernames) {
|
||||
if (
|
||||
username.length < 3 ||
|
||||
username.length > 20 ||
|
||||
username.match(/_/g)?.length > 1
|
||||
)
|
||||
return errorResponse(`Username "${username}" is invalid`, 400);
|
||||
}
|
||||
|
||||
const rbxSearchReq = await fetch(
|
||||
"https://users.roblox.com/v1/usernames/users",
|
||||
{
|
||||
body: JSON.stringify({
|
||||
usernames,
|
||||
excludeBannedUsers: true,
|
||||
}),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
|
||||
if (!rbxSearchReq.ok)
|
||||
return errorResponse(
|
||||
"Failed to locate Roblox users due to upstream error",
|
||||
500
|
||||
);
|
||||
|
||||
const rbxSearchData: { data: { [k: string]: any }[] } =
|
||||
await rbxSearchReq.json();
|
||||
|
||||
if (rbxSearchData.data.length < usernames.length) {
|
||||
const missingUsers = [];
|
||||
|
||||
for (const userData of rbxSearchData.data) {
|
||||
if (!usernames.includes(userData.requestedUsername))
|
||||
missingUsers.push(userData.requestedUsername);
|
||||
}
|
||||
|
||||
return errorResponse(
|
||||
`The following users do not exist or are banned from Roblox: ${missingUsers.toString()}`,
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const metaIDs = [];
|
||||
const metaNames = [];
|
||||
|
||||
for (const data of rbxSearchData.data) {
|
||||
metaIDs.push(data.id);
|
||||
metaNames.push(data.name);
|
||||
}
|
||||
|
||||
const fileParts = filename.split(".");
|
||||
let fileExt = fileParts[fileParts.length - 1];
|
||||
|
||||
if (
|
||||
fileParts.length < 2 ||
|
||||
![
|
||||
"mkv",
|
||||
"mp4",
|
||||
"wmv",
|
||||
"jpg",
|
||||
"png",
|
||||
"m4v",
|
||||
"jpeg",
|
||||
"jfif",
|
||||
"gif",
|
||||
"webm",
|
||||
"heif",
|
||||
"heic",
|
||||
"webp",
|
||||
"mov",
|
||||
].includes(fileExt.toLowerCase())
|
||||
)
|
||||
return errorResponse("This type of file cannot be uploaded", 415);
|
||||
|
||||
const fileKey = `${crypto.randomUUID().replaceAll("-", "")}/${crypto
|
||||
.randomUUID()
|
||||
.replaceAll("-", "")}${context.request.headers.get("cf-ray")}${Date.now()}`;
|
||||
|
||||
const reportId = `${Date.now()}${context.request.headers.get(
|
||||
"cf-ray"
|
||||
)}${crypto.randomUUID().replaceAll("-", "")}`;
|
||||
|
||||
const uploadUrl = await GenerateUploadURL(
|
||||
context.env,
|
||||
fileKey,
|
||||
filesize,
|
||||
fileExt
|
||||
);
|
||||
|
||||
await context.env.DATA.put(
|
||||
`reportprocessing_${reportId}`,
|
||||
context.data.current_user.id,
|
||||
{ expirationTtl: 3600 }
|
||||
);
|
||||
await context.env.DATA.put(
|
||||
`report_${reportId}`,
|
||||
JSON.stringify({
|
||||
attachment: `${fileKey}.${
|
||||
["mkv", "mov", "wmv"].includes(fileExt.toLowerCase()) ? "mp4" : fileExt
|
||||
}`,
|
||||
reporter: context.data.current_user,
|
||||
target_ids: metaIDs,
|
||||
target_usernames: metaNames,
|
||||
}),
|
||||
{
|
||||
metadata: {
|
||||
i: context.data.current_user.id,
|
||||
r: metaIDs.toString(),
|
||||
p: true,
|
||||
s: `${context.data.current_user.username}#${context.data.current_user.discriminator}`,
|
||||
u: metaNames.toString(),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return new Response(JSON.stringify({ id: reportId, upload_url: uploadUrl }), {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
Reference in New Issue
Block a user