364 lines
8.9 KiB
TypeScript
364 lines
8.9 KiB
TypeScript
import getPermissions from "./permissions.js";
|
|
import { jsonError } from "./common.js";
|
|
|
|
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);
|
|
|
|
return await context.next();
|
|
}
|
|
|
|
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 refreshAuth(context: RequestContext) {
|
|
const { current_user: currentUser } = context.data;
|
|
|
|
if (!currentUser || currentUser.refresh_at > Date.now())
|
|
return await context.next();
|
|
|
|
const oauthData = await context.env.DATA.get(
|
|
`oauthcredentials_${currentUser.id}`,
|
|
{ type: "json" },
|
|
);
|
|
|
|
if (!oauthData) return await context.next();
|
|
|
|
const refreshedTokenResponse = await fetch(
|
|
"https://discord.com/api/v10/oauth2/token",
|
|
{
|
|
body: `grant_type=refresh_token&refresh_token=${currentUser.refresh_token}`,
|
|
headers: {
|
|
authorization: `Basic ${btoa(
|
|
context.env.DISCORD_ID + ":" + context.env.DISCORD_SECRET,
|
|
)}`,
|
|
"content-type": "application/x-www-form-urlencoded",
|
|
},
|
|
method: "POST",
|
|
},
|
|
);
|
|
|
|
if (!refreshedTokenResponse.ok) return await context.next();
|
|
|
|
const accessData: { [k: string]: any } = await refreshedTokenResponse.json();
|
|
|
|
let tokenData: { [k: string]: any } = {
|
|
...accessData,
|
|
refresh_at: Date.now() + 3600000,
|
|
};
|
|
|
|
const newDiscordData = await fetch("https://discord.com/api/v10/users/@me", {
|
|
headers: {
|
|
authorization: `Bearer ${accessData.access_token}`,
|
|
},
|
|
});
|
|
|
|
if (!newDiscordData.ok) return await context.next();
|
|
|
|
const userData: { [k: string]: any } = await newDiscordData.json();
|
|
|
|
const updatedServerMemberReq = await fetch(
|
|
"https://discord.com/api/v10/users/@me/guilds/242263977986359297/member",
|
|
{
|
|
headers: {
|
|
authorization: `Bearer ${accessData.access_token}`,
|
|
},
|
|
},
|
|
);
|
|
|
|
userData.permissions = await getPermissions(
|
|
userData.id,
|
|
context,
|
|
updatedServerMemberReq.ok
|
|
? (
|
|
(await updatedServerMemberReq.json()) as {
|
|
[k: string]: any;
|
|
}
|
|
).roles
|
|
: undefined,
|
|
);
|
|
|
|
const tokenHash = await generateTokenHash(context.data.sid);
|
|
|
|
await context.env.DATA.put(`auth_${tokenHash}`, JSON.stringify(userData), {
|
|
expirationTtl: accessData.expires_in,
|
|
});
|
|
await context.env.DATA.put(
|
|
`oauthcredentials_${userData.id}`,
|
|
JSON.stringify(tokenData),
|
|
{
|
|
expirationTtl: 1209600000,
|
|
},
|
|
);
|
|
|
|
delete context.data.sid;
|
|
|
|
const jwtPayload = context.request.headers
|
|
.get("authorization")
|
|
?.replace("Bearer ", "")
|
|
.split(".")
|
|
.at(1);
|
|
|
|
if (jwtPayload) {
|
|
let jwtData: { [k: string]: any };
|
|
|
|
try {
|
|
jwtData = JSON.parse(
|
|
atob(jwtPayload.replaceAll("-", "+").replaceAll("_", "/")),
|
|
);
|
|
} catch {
|
|
return jsonError("JWT is malformed", 400);
|
|
}
|
|
|
|
jwtData.email = userData.email;
|
|
jwtData.exp = Math.floor(Date.now() / 1000) + userData.expires_in;
|
|
jwtData.iat = Math.floor(Date.now() / 1000);
|
|
jwtData.name = userData.username;
|
|
jwtData.permissions = userData.permissions;
|
|
jwtData.picture =
|
|
userData.avatar ?? "https://carcrushers.cc/files/logo192.png";
|
|
|
|
const key = await crypto.subtle.importKey(
|
|
"raw",
|
|
// @ts-expect-error
|
|
Uint8Array.from(
|
|
atob(
|
|
context.env.JWT_SIGNING_KEY.replaceAll("-", "+").replaceAll("_", "/"),
|
|
),
|
|
(m) => m.codePointAt(0),
|
|
),
|
|
{ hash: "SHA-256", name: "HMAC" },
|
|
false,
|
|
["sign"],
|
|
);
|
|
|
|
const jwtBase = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(
|
|
JSON.stringify(jwtData),
|
|
)
|
|
.replaceAll("+", "-")
|
|
.replaceAll("/", "_")
|
|
.replaceAll("=", "")}`;
|
|
|
|
const signature = btoa(
|
|
String.fromCodePoint(
|
|
...new Uint8Array(
|
|
await crypto.subtle.sign(
|
|
"HMAC",
|
|
key,
|
|
new TextEncoder().encode(jwtBase),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
.replaceAll("+", "-")
|
|
.replace("/", "_")
|
|
.replaceAll("=", "");
|
|
|
|
const response = await context.next();
|
|
|
|
response.headers.set("refreshed-token", `${jwtBase}.${signature}`);
|
|
|
|
return response;
|
|
}
|
|
|
|
return await context.next();
|
|
}
|
|
|
|
async function setAuth(context: RequestContext) {
|
|
const cookies = context.request.headers.get("cookie");
|
|
const auth = context.request.headers.get("authorization");
|
|
|
|
if (auth) {
|
|
const jwtSegments = auth.replace("Bearer ", "").split(".");
|
|
|
|
if (jwtSegments.length !== 3) return jsonError("Malformed token", 401);
|
|
|
|
const { alg } = JSON.parse(atob(jwtSegments[0]));
|
|
|
|
if (alg !== "HS256") return jsonError("Invalid token", 400);
|
|
|
|
const key = await crypto.subtle.importKey(
|
|
"raw",
|
|
// @ts-expect-error
|
|
Uint8Array.from(
|
|
atob(
|
|
context.env.JWT_SIGNING_KEY.replaceAll("-", "+").replaceAll("_", "/"),
|
|
),
|
|
(m) => m.codePointAt(0),
|
|
),
|
|
{ hash: "SHA-256", name: "HMAC" },
|
|
false,
|
|
["verify"],
|
|
);
|
|
|
|
if (
|
|
!(await crypto.subtle.verify(
|
|
"HMAC",
|
|
key,
|
|
// @ts-expect-error
|
|
Uint8Array.from(
|
|
atob(jwtSegments[2].replaceAll("-", "+").replaceAll("_", "/")),
|
|
(m) => m.codePointAt(0),
|
|
),
|
|
new TextEncoder().encode(`${jwtSegments[0]}.${jwtSegments[1]}`),
|
|
))
|
|
)
|
|
return jsonError("Token could not be verified", 401);
|
|
|
|
const {
|
|
jti: sessionToken,
|
|
}: {
|
|
jti: string;
|
|
} = JSON.parse(
|
|
atob(jwtSegments[1].replaceAll("-", "+").replaceAll("_", "/")),
|
|
);
|
|
|
|
const linkedSessionData = await context.env.DATA.get(
|
|
`auth_${await generateTokenHash(sessionToken)}`,
|
|
);
|
|
|
|
if (linkedSessionData) {
|
|
context.data.current_user = JSON.parse(linkedSessionData);
|
|
context.data.sid = sessionToken;
|
|
|
|
return await context.next();
|
|
} else return jsonError("Session is invalid or expired", 401);
|
|
}
|
|
|
|
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);
|
|
context.data.sid = value;
|
|
} else
|
|
context.request.headers.append(
|
|
"set-cookie",
|
|
"_s=; HttpOnly; Max-Age=0; Path=/; Secure;",
|
|
);
|
|
|
|
break;
|
|
}
|
|
|
|
return await context.next();
|
|
}
|
|
|
|
async function setBody(context: RequestContext) {
|
|
if (
|
|
context.request.method === "POST" &&
|
|
!context.request.url.endsWith("/api/infractions/new")
|
|
) {
|
|
if (
|
|
!context.request.headers
|
|
.get("content-type")
|
|
?.startsWith("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",
|
|
"Heaven, Iowa",
|
|
"Hollywood",
|
|
"Parkway East",
|
|
"Parkway North",
|
|
"Parkway West",
|
|
"Tokyo",
|
|
"Wintervale",
|
|
];
|
|
|
|
response.headers.set("Permissions-Policy", "clipboard-write=(self)");
|
|
response.headers.set("Referrer-Policy", "same-origin");
|
|
response.headers.set(
|
|
"RTV",
|
|
rtvValues[Math.round(Math.random() * (rtvValues.length - 1))],
|
|
);
|
|
response.headers.set("X-Frame-Options", "SAMEORIGIN");
|
|
|
|
return response;
|
|
}
|
|
|
|
async function setTheme(context: RequestContext) {
|
|
const cookies = context.request.headers.get("cookie");
|
|
|
|
if (!cookies) {
|
|
context.data.theme = "dark";
|
|
return await context.next();
|
|
}
|
|
|
|
const cookieList = cookies.split("; ");
|
|
|
|
const themeCookie = cookieList.find((c) =>
|
|
c.startsWith("chakra-ui-color-mode"),
|
|
);
|
|
const theme = themeCookie?.split("=").at(1);
|
|
|
|
if (!theme || !["dark", "light"].includes(theme)) context.data.theme = "dark";
|
|
else context.data.theme = theme;
|
|
|
|
return await context.next();
|
|
}
|
|
|
|
export const onRequest = [
|
|
setAuth,
|
|
refreshAuth,
|
|
setTheme,
|
|
constructHTML,
|
|
setBody,
|
|
setHeaders
|
|
];
|