mirror of
https://github.com/roleypoly/roleypoly.git
synced 2025-06-15 17:19:10 +00:00
Refactor node packages to yarn workspaces & ditch next.js for CRA. (#161)
* chore: restructure project into yarn workspaces, remove next * fix tests, remove webapp from terraform * remove more ui deployment bits * remove pages, fix FUNDING.yml * remove isomorphism * remove next providers * fix linting issues * feat: start basis of new web ui system on CRA * chore: move types to @roleypoly/types package * chore: move src/common/utils to @roleypoly/misc-utils * chore: remove roleypoly/ path remappers * chore: renmove vercel config * chore: re-add worker-types to api package * chore: fix type linting scope for api * fix(web): craco should include all of packages dir * fix(ci): change api webpack path for wrangler * chore: remove GAR actions from CI * chore: update codeql job * chore: test better github dar matcher in lint-staged
This commit is contained in:
parent
49e308507e
commit
2ff6588030
328 changed files with 16624 additions and 3525 deletions
1
packages/api/.gitignore
vendored
Normal file
1
packages/api/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
dist
|
13
packages/api/bindings.d.ts
vendored
Normal file
13
packages/api/bindings.d.ts
vendored
Normal file
|
@ -0,0 +1,13 @@
|
|||
export {};
|
||||
|
||||
declare global {
|
||||
const BOT_CLIENT_ID: string;
|
||||
const BOT_CLIENT_SECRET: string;
|
||||
const UI_PUBLIC_URI: string;
|
||||
const API_PUBLIC_URI: string;
|
||||
const ROOT_USERS: string;
|
||||
|
||||
const KV_SESSIONS: KVNamespace;
|
||||
const KV_GUILDS: KVNamespace;
|
||||
const KV_GUILD_DATA: KVNamespace;
|
||||
}
|
36
packages/api/handlers/bot-join.ts
Normal file
36
packages/api/handlers/bot-join.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { Bounce } from '../utils/bounce';
|
||||
import { botClientID } from '../utils/config';
|
||||
|
||||
const validGuildID = /^[0-9]+$/;
|
||||
|
||||
type URLParams = {
|
||||
clientID: string;
|
||||
permissions: number;
|
||||
guildID?: string;
|
||||
};
|
||||
|
||||
const buildURL = (params: URLParams) => {
|
||||
let url = `https://discord.com/api/oauth2/authorize?client_id=${params.clientID}&scope=bot&permissions=${params.permissions}`;
|
||||
|
||||
if (params.guildID) {
|
||||
url += `&guild_id=${params.guildID}&disable_guild_select=true`;
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
export const BotJoin = (request: Request): Response => {
|
||||
let guildID = new URL(request.url).searchParams.get('guild') || '';
|
||||
|
||||
if (guildID && !validGuildID.test(guildID)) {
|
||||
guildID = '';
|
||||
}
|
||||
|
||||
return Bounce(
|
||||
buildURL({
|
||||
clientID: botClientID,
|
||||
permissions: 268435456,
|
||||
guildID,
|
||||
})
|
||||
);
|
||||
};
|
94
packages/api/handlers/create-roleypoly-data.ts
Normal file
94
packages/api/handlers/create-roleypoly-data.ts
Normal file
|
@ -0,0 +1,94 @@
|
|||
import { CategoryType, Features, GuildData as GuildDataT } from '@roleypoly/types';
|
||||
import KSUID from 'ksuid';
|
||||
import { onlyRootUsers, respond } from '../utils/api-tools';
|
||||
import { GuildData } from '../utils/kv';
|
||||
|
||||
// Temporary use.
|
||||
export const CreateRoleypolyData = onlyRootUsers(
|
||||
async (request: Request): Promise<Response> => {
|
||||
const data: GuildDataT = {
|
||||
id: '386659935687147521',
|
||||
message:
|
||||
'Hey, this is kind of a demo setup so features/use cases can be shown off.\n\nThanks for using Roleypoly <3',
|
||||
features: Features.Preview,
|
||||
categories: [
|
||||
{
|
||||
id: KSUID.randomSync().string,
|
||||
name: 'Demo Roles',
|
||||
type: CategoryType.Multi,
|
||||
hidden: false,
|
||||
position: 0,
|
||||
roles: [
|
||||
'557825026406088717',
|
||||
'557824994269200384',
|
||||
'557824893241131029',
|
||||
'557812915386843170',
|
||||
'557812901717737472',
|
||||
'557812805546541066',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: KSUID.randomSync().string,
|
||||
name: 'Colors',
|
||||
type: CategoryType.Single,
|
||||
hidden: false,
|
||||
position: 1,
|
||||
roles: [
|
||||
'394060232893923349',
|
||||
'394060145799331851',
|
||||
'394060192846839809',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: KSUID.randomSync().string,
|
||||
name: 'Test Roles',
|
||||
type: CategoryType.Multi,
|
||||
hidden: false,
|
||||
position: 5,
|
||||
roles: [
|
||||
'558104828216213505',
|
||||
'558103534453653514',
|
||||
'558297233582194728',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: KSUID.randomSync().string,
|
||||
name: 'Region',
|
||||
type: CategoryType.Multi,
|
||||
hidden: false,
|
||||
position: 3,
|
||||
roles: [
|
||||
'397296181803483136',
|
||||
'397296137066774529',
|
||||
'397296218809827329',
|
||||
'397296267283267605',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: KSUID.randomSync().string,
|
||||
name: 'Opt-in Channels',
|
||||
type: CategoryType.Multi,
|
||||
hidden: false,
|
||||
position: 4,
|
||||
roles: ['414514823959674890', '764230661904007219'],
|
||||
},
|
||||
{
|
||||
id: KSUID.randomSync().string,
|
||||
name: 'Pronouns',
|
||||
type: CategoryType.Multi,
|
||||
hidden: false,
|
||||
position: 2,
|
||||
roles: [
|
||||
'485916566790340608',
|
||||
'485916566941335583',
|
||||
'485916566311927808',
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await GuildData.put(data.id, data);
|
||||
|
||||
return respond({ ok: true });
|
||||
}
|
||||
);
|
56
packages/api/handlers/get-picker-data.ts
Normal file
56
packages/api/handlers/get-picker-data.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
import { DiscordUser, GuildSlug, PresentableGuild, SessionData } from '@roleypoly/types';
|
||||
import { respond, withSession } from '../utils/api-tools';
|
||||
import { getGuild, getGuildData, getGuildMemberRoles } from '../utils/guild';
|
||||
|
||||
const fail = () => respond({ error: 'guild not found' }, { status: 404 });
|
||||
|
||||
export const GetPickerData = withSession(
|
||||
(session: SessionData) => async (request: Request): Promise<Response> => {
|
||||
const url = new URL(request.url);
|
||||
const [, , guildID] = url.pathname.split('/');
|
||||
|
||||
if (!guildID) {
|
||||
return respond({ error: 'missing guild id' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { id: userID } = session.user as DiscordUser;
|
||||
const guilds = session.guilds as GuildSlug[];
|
||||
|
||||
// Save a Discord API request by checking if this user is a member by session first
|
||||
const checkGuild = guilds.find((guild) => guild.id === guildID);
|
||||
if (!checkGuild) {
|
||||
return fail();
|
||||
}
|
||||
|
||||
const guild = await getGuild(guildID, {
|
||||
skipCachePull: url.searchParams.has('__no_cache'),
|
||||
});
|
||||
if (!guild) {
|
||||
return fail();
|
||||
}
|
||||
|
||||
const memberRolesP = getGuildMemberRoles({
|
||||
serverID: guildID,
|
||||
userID,
|
||||
});
|
||||
|
||||
const guildDataP = getGuildData(guildID);
|
||||
|
||||
const [guildData, memberRoles] = await Promise.all([guildDataP, memberRolesP]);
|
||||
if (!memberRoles) {
|
||||
return fail();
|
||||
}
|
||||
|
||||
const presentableGuild: PresentableGuild = {
|
||||
id: guildID,
|
||||
guild: checkGuild,
|
||||
roles: guild.roles,
|
||||
member: {
|
||||
roles: memberRoles,
|
||||
},
|
||||
data: guildData,
|
||||
};
|
||||
|
||||
return respond(presentableGuild);
|
||||
}
|
||||
);
|
12
packages/api/handlers/get-session.ts
Normal file
12
packages/api/handlers/get-session.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { SessionData } from '@roleypoly/types';
|
||||
import { respond, withSession } from '../utils/api-tools';
|
||||
|
||||
export const GetSession = withSession((session?: SessionData) => (): Response => {
|
||||
const { user, guilds, sessionID } = session || {};
|
||||
|
||||
return respond({
|
||||
user,
|
||||
guilds,
|
||||
sessionID,
|
||||
});
|
||||
});
|
41
packages/api/handlers/get-slug.ts
Normal file
41
packages/api/handlers/get-slug.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { GuildSlug } from '@roleypoly/types';
|
||||
import { respond } from '../utils/api-tools';
|
||||
import { getGuild } from '../utils/guild';
|
||||
|
||||
export const GetSlug = async (request: Request): Promise<Response> => {
|
||||
const reqURL = new URL(request.url);
|
||||
const [, , serverID] = reqURL.pathname.split('/');
|
||||
|
||||
if (!serverID) {
|
||||
return respond(
|
||||
{
|
||||
error: 'missing server ID',
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const guild = await getGuild(serverID);
|
||||
if (!guild) {
|
||||
return respond(
|
||||
{
|
||||
error: 'guild not found',
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const { id, name, icon } = guild;
|
||||
const guildSlug: GuildSlug = {
|
||||
id,
|
||||
name,
|
||||
icon,
|
||||
permissionLevel: 0,
|
||||
};
|
||||
console.log({ guildSlug });
|
||||
return respond(guildSlug);
|
||||
};
|
24
packages/api/handlers/login-bounce.ts
Normal file
24
packages/api/handlers/login-bounce.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import KSUID from 'ksuid';
|
||||
import { Bounce } from '../utils/bounce';
|
||||
import { apiPublicURI, botClientID } from '../utils/config';
|
||||
|
||||
type URLParams = {
|
||||
clientID: string;
|
||||
redirectURI: string;
|
||||
state: string;
|
||||
};
|
||||
|
||||
const buildURL = (params: URLParams) =>
|
||||
`https://discord.com/api/oauth2/authorize?client_id=${
|
||||
params.clientID
|
||||
}&response_type=code&scope=identify%20guilds&redirect_uri=${encodeURIComponent(
|
||||
params.redirectURI
|
||||
)}&state=${params.state}`;
|
||||
|
||||
export const LoginBounce = async (request: Request): Promise<Response> => {
|
||||
const state = await KSUID.random();
|
||||
const redirectURI = `${apiPublicURI}/login-callback`;
|
||||
const clientID = botClientID;
|
||||
|
||||
return Bounce(buildURL({ state: state.string, redirectURI, clientID }));
|
||||
};
|
142
packages/api/handlers/login-callback.ts
Normal file
142
packages/api/handlers/login-callback.ts
Normal file
|
@ -0,0 +1,142 @@
|
|||
import { AuthTokenResponse, DiscordUser, GuildSlug, SessionData } from '@roleypoly/types';
|
||||
import KSUID from 'ksuid';
|
||||
import {
|
||||
AuthType,
|
||||
discordFetch,
|
||||
formData,
|
||||
parsePermissions,
|
||||
resolveFailures,
|
||||
userAgent,
|
||||
} from '../utils/api-tools';
|
||||
import { Bounce } from '../utils/bounce';
|
||||
import { apiPublicURI, botClientID, botClientSecret, uiPublicURI } from '../utils/config';
|
||||
import { Sessions } from '../utils/kv';
|
||||
|
||||
const AuthErrorResponse = (extra?: string) =>
|
||||
Bounce(
|
||||
uiPublicURI +
|
||||
`/machinery/error?error_code=authFailure${extra ? `&extra=${extra}` : ''}`
|
||||
);
|
||||
|
||||
export const LoginCallback = resolveFailures(
|
||||
AuthErrorResponse,
|
||||
async (request: Request): Promise<Response> => {
|
||||
const query = new URL(request.url).searchParams;
|
||||
|
||||
const stateValue = query.get('state');
|
||||
|
||||
if (stateValue === null) {
|
||||
return AuthErrorResponse('state missing');
|
||||
}
|
||||
|
||||
try {
|
||||
const state = KSUID.parse(stateValue);
|
||||
const stateExpiry = state.date.getTime() + 1000 * 60 * 5;
|
||||
const currentTime = Date.now();
|
||||
|
||||
if (currentTime > stateExpiry) {
|
||||
return AuthErrorResponse('state expired');
|
||||
}
|
||||
} catch (e) {
|
||||
return AuthErrorResponse('state invalid');
|
||||
}
|
||||
|
||||
const code = query.get('code');
|
||||
if (!code) {
|
||||
return AuthErrorResponse('code missing');
|
||||
}
|
||||
|
||||
const tokenRequest = {
|
||||
client_id: botClientID,
|
||||
client_secret: botClientSecret,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: apiPublicURI + '/login-callback',
|
||||
scope: 'identify guilds',
|
||||
code,
|
||||
};
|
||||
|
||||
const tokenFetch = await fetch('https://discord.com/api/v8/oauth2/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
'user-agent': userAgent,
|
||||
},
|
||||
body: formData(tokenRequest),
|
||||
});
|
||||
|
||||
const tokens = (await tokenFetch.json()) as AuthTokenResponse;
|
||||
|
||||
if (!tokens.access_token) {
|
||||
return AuthErrorResponse('token response invalid');
|
||||
}
|
||||
|
||||
const [sessionID, user, guilds] = await Promise.all([
|
||||
KSUID.random(),
|
||||
getUser(tokens.access_token),
|
||||
getGuilds(tokens.access_token),
|
||||
]);
|
||||
|
||||
if (!user) {
|
||||
return AuthErrorResponse('failed to fetch user');
|
||||
}
|
||||
|
||||
const sessionData: SessionData = {
|
||||
tokens,
|
||||
sessionID: sessionID.string,
|
||||
user,
|
||||
guilds,
|
||||
};
|
||||
|
||||
await Sessions.put(sessionID.string, sessionData, 60 * 60 * 6);
|
||||
|
||||
return Bounce(
|
||||
uiPublicURI + '/machinery/new-session?session_id=' + sessionID.string
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const getUser = async (accessToken: string): Promise<DiscordUser | null> => {
|
||||
const user = await discordFetch<DiscordUser>(
|
||||
'/users/@me',
|
||||
accessToken,
|
||||
AuthType.Bearer
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { id, username, discriminator, bot, avatar } = user;
|
||||
|
||||
return { id, username, discriminator, bot, avatar };
|
||||
};
|
||||
|
||||
type UserGuildsPayload = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
owner: boolean;
|
||||
permissions: number;
|
||||
features: string[];
|
||||
}[];
|
||||
|
||||
const getGuilds = async (accessToken: string) => {
|
||||
const guilds = await discordFetch<UserGuildsPayload>(
|
||||
'/users/@me/guilds',
|
||||
accessToken,
|
||||
AuthType.Bearer
|
||||
);
|
||||
|
||||
if (!guilds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const guildSlugs = guilds.map<GuildSlug>((guild) => ({
|
||||
id: guild.id,
|
||||
name: guild.name,
|
||||
icon: guild.icon,
|
||||
permissionLevel: parsePermissions(BigInt(guild.permissions), guild.owner),
|
||||
}));
|
||||
|
||||
return guildSlugs;
|
||||
};
|
27
packages/api/handlers/revoke-session.ts
Normal file
27
packages/api/handlers/revoke-session.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { SessionData } from '@roleypoly/types';
|
||||
import { formData, respond, userAgent, withSession } from '../utils/api-tools';
|
||||
import { botClientID, botClientSecret } from '../utils/config';
|
||||
import { Sessions } from '../utils/kv';
|
||||
|
||||
export const RevokeSession = withSession(
|
||||
(session: SessionData) => async (request: Request) => {
|
||||
const tokenRequest = {
|
||||
token: session.tokens.access_token,
|
||||
client_id: botClientID,
|
||||
client_secret: botClientSecret,
|
||||
};
|
||||
|
||||
await fetch('https://discord.com/api/v8/oauth2/token/revoke', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
'user-agent': userAgent,
|
||||
},
|
||||
body: formData(tokenRequest),
|
||||
});
|
||||
|
||||
await Sessions.delete(session.sessionID);
|
||||
|
||||
return respond({ ok: true });
|
||||
}
|
||||
);
|
141
packages/api/handlers/update-roles.ts
Normal file
141
packages/api/handlers/update-roles.ts
Normal file
|
@ -0,0 +1,141 @@
|
|||
import {
|
||||
GuildData,
|
||||
Member,
|
||||
Role,
|
||||
RoleSafety,
|
||||
RoleTransaction,
|
||||
RoleUpdate,
|
||||
SessionData,
|
||||
TransactionType,
|
||||
} from '@roleypoly/types';
|
||||
import { difference, groupBy, keyBy, union } from 'lodash';
|
||||
import { AuthType, discordFetch, respond, withSession } from '../utils/api-tools';
|
||||
import { botToken } from '../utils/config';
|
||||
import {
|
||||
getGuild,
|
||||
getGuildData,
|
||||
getGuildMemberRoles,
|
||||
updateGuildMemberRoles,
|
||||
} from '../utils/guild';
|
||||
|
||||
const notFound = () => respond({ error: 'guild not found' }, { status: 404 });
|
||||
|
||||
export const UpdateRoles = withSession(
|
||||
({ guilds, user: { id: userID } }: SessionData) => async (request: Request) => {
|
||||
const updateRequest = (await request.json()) as RoleUpdate;
|
||||
const [, , guildID] = new URL(request.url).pathname.split('/');
|
||||
|
||||
if (!guildID) {
|
||||
return respond({ error: 'guild ID missing from URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (updateRequest.transactions.length === 0) {
|
||||
return respond(
|
||||
{ error: 'must have as least one transaction' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const guildCheck = guilds.find((guild) => guild.id === guildID);
|
||||
if (!guildCheck) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const guild = await getGuild(guildID);
|
||||
if (!guild) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const guildMemberRoles = await getGuildMemberRoles(
|
||||
{ serverID: guildID, userID },
|
||||
{ skipCachePull: true }
|
||||
);
|
||||
if (!guildMemberRoles) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const newRoles = calculateNewRoles({
|
||||
currentRoles: guildMemberRoles,
|
||||
guildRoles: guild.roles,
|
||||
guildData: await getGuildData(guildID),
|
||||
updateRequest,
|
||||
});
|
||||
|
||||
const patchMemberRoles = await discordFetch<Member>(
|
||||
`/guilds/${guildID}/members/${userID}`,
|
||||
botToken,
|
||||
AuthType.Bot,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
roles: newRoles,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!patchMemberRoles) {
|
||||
return respond({ error: 'discord rejected the request' }, { status: 500 });
|
||||
}
|
||||
|
||||
const updatedMember: Member = {
|
||||
roles: patchMemberRoles.roles,
|
||||
};
|
||||
|
||||
await updateGuildMemberRoles(
|
||||
{ serverID: guildID, userID },
|
||||
patchMemberRoles.roles
|
||||
);
|
||||
|
||||
return respond(updatedMember);
|
||||
}
|
||||
);
|
||||
|
||||
const calculateNewRoles = ({
|
||||
currentRoles,
|
||||
guildData,
|
||||
guildRoles,
|
||||
updateRequest,
|
||||
}: {
|
||||
currentRoles: string[];
|
||||
guildRoles: Role[];
|
||||
guildData: GuildData;
|
||||
updateRequest: RoleUpdate;
|
||||
}): string[] => {
|
||||
const roleMap = keyBy(guildRoles, 'id');
|
||||
|
||||
// These roles were ones changed between knownState (role picker page load/cache) and current (fresh from discord).
|
||||
// We could cause issues, so we'll re-add them later.
|
||||
// const diffRoles = difference(updateRequest.knownState, currentRoles);
|
||||
|
||||
// Only these are safe
|
||||
const allSafeRoles = guildData.categories.reduce<string[]>(
|
||||
(categorizedRoles, category) =>
|
||||
!category.hidden
|
||||
? [
|
||||
...categorizedRoles,
|
||||
...category.roles.filter(
|
||||
(roleID) => roleMap[roleID]?.safety === RoleSafety.Safe
|
||||
),
|
||||
]
|
||||
: categorizedRoles,
|
||||
[]
|
||||
);
|
||||
|
||||
const safeTransactions = updateRequest.transactions.filter((tx: RoleTransaction) =>
|
||||
allSafeRoles.includes(tx.id)
|
||||
);
|
||||
|
||||
const changesByAction = groupBy(safeTransactions, 'action');
|
||||
|
||||
const rolesToAdd = (changesByAction[TransactionType.Add] ?? []).map((tx) => tx.id);
|
||||
const rolesToRemove = (changesByAction[TransactionType.Remove] ?? []).map(
|
||||
(tx) => tx.id
|
||||
);
|
||||
|
||||
const final = union(difference(currentRoles, rolesToRemove), rolesToAdd);
|
||||
|
||||
return final;
|
||||
};
|
61
packages/api/index.ts
Normal file
61
packages/api/index.ts
Normal file
|
@ -0,0 +1,61 @@
|
|||
import { BotJoin } from './handlers/bot-join';
|
||||
import { CreateRoleypolyData } from './handlers/create-roleypoly-data';
|
||||
import { GetPickerData } from './handlers/get-picker-data';
|
||||
import { GetSession } from './handlers/get-session';
|
||||
import { GetSlug } from './handlers/get-slug';
|
||||
import { LoginBounce } from './handlers/login-bounce';
|
||||
import { LoginCallback } from './handlers/login-callback';
|
||||
import { RevokeSession } from './handlers/revoke-session';
|
||||
import { UpdateRoles } from './handlers/update-roles';
|
||||
import { Router } from './router';
|
||||
import { respond } from './utils/api-tools';
|
||||
import { uiPublicURI } from './utils/config';
|
||||
|
||||
const router = new Router();
|
||||
|
||||
// OAuth
|
||||
router.add('GET', 'bot-join', BotJoin);
|
||||
router.add('GET', 'login-bounce', LoginBounce);
|
||||
router.add('GET', 'login-callback', LoginCallback);
|
||||
|
||||
// Session
|
||||
router.add('GET', 'get-session', GetSession);
|
||||
router.add('POST', 'revoke-session', RevokeSession);
|
||||
|
||||
// Main biz logic
|
||||
router.add('GET', 'get-slug', GetSlug);
|
||||
router.add('GET', 'get-picker-data', GetPickerData);
|
||||
router.add('PATCH', 'update-roles', UpdateRoles);
|
||||
|
||||
// Root users only
|
||||
router.add('GET', 'x-create-roleypoly-data', CreateRoleypolyData);
|
||||
|
||||
// Tester Routes
|
||||
router.add('GET', 'x-headers', (request) => {
|
||||
const headers: { [x: string]: string } = {};
|
||||
|
||||
for (let [key, value] of request.headers.entries()) {
|
||||
headers[key] = value;
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(headers));
|
||||
});
|
||||
|
||||
// Root Zen <3
|
||||
router.addFallback('root', () => {
|
||||
return respond({
|
||||
__warning: '🦊',
|
||||
this: 'is',
|
||||
a: 'fox-based',
|
||||
web: 'application',
|
||||
please: 'be',
|
||||
mindful: 'of',
|
||||
your: 'surroundings',
|
||||
warning__: '🦊',
|
||||
meta: uiPublicURI,
|
||||
});
|
||||
});
|
||||
|
||||
addEventListener('fetch', (event: FetchEvent) => {
|
||||
event.respondWith(router.handle(event.request));
|
||||
});
|
19
packages/api/package.json
Normal file
19
packages/api/package.json
Normal file
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "@roleypoly/api",
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"build": "yarn workspace @roleypoly/worker-emulator build --basePath `pwd`",
|
||||
"lint:types": "tsc --noEmit",
|
||||
"start": "yarn workspace @roleypoly/worker-emulator start --basePath `pwd`"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^2.1.0",
|
||||
"@roleypoly/misc-utils": "*",
|
||||
"@roleypoly/types": "*",
|
||||
"@roleypoly/worker-emulator": "*",
|
||||
"ksuid": "^2.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"ts-loader": "^8.0.18",
|
||||
"tsconfig-paths-webpack-plugin": "^3.3.0"
|
||||
}
|
||||
}
|
84
packages/api/router.ts
Normal file
84
packages/api/router.ts
Normal file
|
@ -0,0 +1,84 @@
|
|||
import { addCORS } from './utils/api-tools';
|
||||
import { uiPublicURI } from './utils/config';
|
||||
|
||||
export type Handler = (request: Request) => Promise<Response> | Response;
|
||||
|
||||
type RoutingTree = {
|
||||
[method: string]: {
|
||||
[path: string]: Handler;
|
||||
};
|
||||
};
|
||||
|
||||
type Fallbacks = {
|
||||
root: Handler;
|
||||
404: Handler;
|
||||
500: Handler;
|
||||
};
|
||||
|
||||
export class Router {
|
||||
private routingTree: RoutingTree = {};
|
||||
private fallbacks: Fallbacks = {
|
||||
root: this.respondToRoot,
|
||||
404: this.notFound,
|
||||
500: this.serverError,
|
||||
};
|
||||
|
||||
private uiURL = new URL(uiPublicURI);
|
||||
|
||||
addFallback(which: keyof Fallbacks, handler: Handler) {
|
||||
this.fallbacks[which] = handler;
|
||||
}
|
||||
|
||||
add(method: string, rootPath: string, handler: Handler) {
|
||||
const lowerMethod = method.toLowerCase();
|
||||
|
||||
if (!this.routingTree[lowerMethod]) {
|
||||
this.routingTree[lowerMethod] = {};
|
||||
}
|
||||
|
||||
this.routingTree[lowerMethod][rootPath] = handler;
|
||||
}
|
||||
|
||||
async handle(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === '/' || url.pathname === '') {
|
||||
return this.fallbacks.root(request);
|
||||
}
|
||||
const lowerMethod = request.method.toLowerCase();
|
||||
const rootPath = url.pathname.split('/')[1];
|
||||
const handler = this.routingTree[lowerMethod]?.[rootPath];
|
||||
|
||||
if (handler) {
|
||||
try {
|
||||
const response = await handler(request);
|
||||
return response;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return this.fallbacks[500](request);
|
||||
}
|
||||
}
|
||||
|
||||
if (lowerMethod === 'options') {
|
||||
return new Response(null, addCORS({}));
|
||||
}
|
||||
|
||||
return this.fallbacks[404](request);
|
||||
}
|
||||
|
||||
private respondToRoot(): Response {
|
||||
return new Response('Hi there!');
|
||||
}
|
||||
|
||||
private notFound(): Response {
|
||||
return new Response(JSON.stringify({ error: 'not_found' }), {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
private serverError(): Response {
|
||||
return new Response(JSON.stringify({ error: 'internal_server_error' }), {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
15
packages/api/tsconfig.json
Normal file
15
packages/api/tsconfig.json
Normal file
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"lib": ["esnext", "webworker", "ES2020.BigInt", "ES2020.Promise"],
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"target": "ES2019"
|
||||
},
|
||||
"include": [
|
||||
"./*.ts",
|
||||
"./**/*.ts",
|
||||
"../../node_modules/@cloudflare/workers-types/index.d.ts"
|
||||
],
|
||||
"exclude": ["./**/*.spec.ts", "./dist/**"],
|
||||
"extends": "../../tsconfig.json"
|
||||
}
|
178
packages/api/utils/api-tools.ts
Normal file
178
packages/api/utils/api-tools.ts
Normal file
|
@ -0,0 +1,178 @@
|
|||
import {
|
||||
evaluatePermission,
|
||||
permissions as Permissions,
|
||||
} from '@roleypoly/misc-utils/hasPermission';
|
||||
import { SessionData, UserGuildPermissions } from '@roleypoly/types';
|
||||
import { Handler } from '../router';
|
||||
import { rootUsers, uiPublicURI } from './config';
|
||||
import { Sessions, WrappedKVNamespace } from './kv';
|
||||
|
||||
export const formData = (obj: Record<string, any>): string => {
|
||||
return Object.keys(obj)
|
||||
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(obj[key])}`)
|
||||
.join('&');
|
||||
};
|
||||
|
||||
export const addCORS = (init: ResponseInit = {}) => ({
|
||||
...init,
|
||||
headers: {
|
||||
...(init.headers || {}),
|
||||
'access-control-allow-origin': uiPublicURI,
|
||||
'access-control-allow-methods': '*',
|
||||
'access-control-allow-headers': '*',
|
||||
},
|
||||
});
|
||||
|
||||
export const respond = (obj: Record<string, any>, init: ResponseInit = {}) =>
|
||||
new Response(JSON.stringify(obj), addCORS(init));
|
||||
|
||||
export const resolveFailures = (
|
||||
handleWith: () => Response,
|
||||
handler: (request: Request) => Promise<Response> | Response
|
||||
) => async (request: Request): Promise<Response> => {
|
||||
try {
|
||||
return handler(request);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return (
|
||||
handleWith() || respond({ error: 'internal server error' }, { status: 500 })
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const parsePermissions = (
|
||||
permissions: bigint,
|
||||
owner: boolean = false
|
||||
): UserGuildPermissions => {
|
||||
if (owner || evaluatePermission(permissions, Permissions.ADMINISTRATOR)) {
|
||||
return UserGuildPermissions.Admin;
|
||||
}
|
||||
|
||||
if (evaluatePermission(permissions, Permissions.MANAGE_ROLES)) {
|
||||
return UserGuildPermissions.Manager;
|
||||
}
|
||||
|
||||
return UserGuildPermissions.User;
|
||||
};
|
||||
|
||||
export const getSessionID = (request: Request): { type: string; id: string } | null => {
|
||||
const sessionID = request.headers.get('authorization');
|
||||
if (!sessionID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [type, id] = sessionID.split(' ');
|
||||
if (type !== 'Bearer') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { type, id };
|
||||
};
|
||||
|
||||
export const userAgent =
|
||||
'DiscordBot (https://github.com/roleypoly/roleypoly, git-main) (+https://roleypoly.com)';
|
||||
|
||||
export enum AuthType {
|
||||
Bearer = 'Bearer',
|
||||
Bot = 'Bot',
|
||||
}
|
||||
|
||||
export const discordFetch = async <T>(
|
||||
url: string,
|
||||
auth: string,
|
||||
authType: AuthType = AuthType.Bearer,
|
||||
init?: RequestInit
|
||||
): Promise<T | null> => {
|
||||
const response = await fetch('https://discord.com/api/v8' + url, {
|
||||
...(init || {}),
|
||||
headers: {
|
||||
...(init?.headers || {}),
|
||||
authorization: `${AuthType[authType]} ${auth}`,
|
||||
'user-agent': userAgent,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status >= 400) {
|
||||
console.error('discordFetch failed', {
|
||||
url,
|
||||
authType,
|
||||
payload: await response.text(),
|
||||
});
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
return (await response.json()) as T;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const cacheLayer = <Identity, Data>(
|
||||
kv: WrappedKVNamespace,
|
||||
keyFactory: (identity: Identity) => string,
|
||||
missHandler: (identity: Identity) => Promise<Data | null>,
|
||||
ttlSeconds?: number
|
||||
) => async (
|
||||
identity: Identity,
|
||||
options: { skipCachePull?: boolean } = {}
|
||||
): Promise<Data | null> => {
|
||||
const key = keyFactory(identity);
|
||||
|
||||
if (!options.skipCachePull) {
|
||||
const value = await kv.get<Data>(key);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackValue = await missHandler(identity);
|
||||
if (!fallbackValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await kv.put(key, fallbackValue, ttlSeconds);
|
||||
|
||||
return fallbackValue;
|
||||
};
|
||||
|
||||
const NotAuthenticated = (extra?: string) =>
|
||||
respond(
|
||||
{
|
||||
error: extra || 'not authenticated',
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
|
||||
export const withSession = (
|
||||
wrappedHandler: (session: SessionData) => Handler
|
||||
): Handler => async (request: Request): Promise<Response> => {
|
||||
const sessionID = getSessionID(request);
|
||||
if (!sessionID) {
|
||||
return NotAuthenticated('missing authentication');
|
||||
}
|
||||
|
||||
const session = await Sessions.get<SessionData>(sessionID.id);
|
||||
if (!session) {
|
||||
return NotAuthenticated('authentication expired or not found');
|
||||
}
|
||||
|
||||
return await wrappedHandler(session)(request);
|
||||
};
|
||||
|
||||
export const isRoot = (userID: string): boolean => rootUsers.includes(userID);
|
||||
|
||||
export const onlyRootUsers = (handler: Handler): Handler =>
|
||||
withSession((session) => (request: Request) => {
|
||||
if (isRoot(session.user.id)) {
|
||||
return handler(request);
|
||||
}
|
||||
|
||||
return respond(
|
||||
{
|
||||
error: 'not_found',
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
}
|
||||
);
|
||||
});
|
7
packages/api/utils/bounce.ts
Normal file
7
packages/api/utils/bounce.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
export const Bounce = (url: string): Response =>
|
||||
new Response(null, {
|
||||
status: 303,
|
||||
headers: {
|
||||
location: url,
|
||||
},
|
||||
});
|
13
packages/api/utils/config.ts
Normal file
13
packages/api/utils/config.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
const self = (global as any) as Record<string, string>;
|
||||
|
||||
const env = (key: string) => self[key] ?? '';
|
||||
|
||||
const safeURI = (x: string) => x.replace(/\/$/, '');
|
||||
const list = (x: string) => x.split(',');
|
||||
|
||||
export const botClientID = env('BOT_CLIENT_ID');
|
||||
export const botClientSecret = env('BOT_CLIENT_SECRET');
|
||||
export const botToken = env('BOT_TOKEN');
|
||||
export const uiPublicURI = safeURI(env('UI_PUBLIC_URI'));
|
||||
export const apiPublicURI = safeURI(env('API_PUBLIC_URI'));
|
||||
export const rootUsers = list(env('ROOT_USERS'));
|
163
packages/api/utils/guild.ts
Normal file
163
packages/api/utils/guild.ts
Normal file
|
@ -0,0 +1,163 @@
|
|||
import { evaluatePermission, permissions } from '@roleypoly/misc-utils/hasPermission';
|
||||
import {
|
||||
Features,
|
||||
Guild,
|
||||
GuildData as GuildDataT,
|
||||
OwnRoleInfo,
|
||||
Role,
|
||||
RoleSafety,
|
||||
} from '@roleypoly/types';
|
||||
import { AuthType, cacheLayer, discordFetch } from './api-tools';
|
||||
import { botClientID, botToken } from './config';
|
||||
import { GuildData, Guilds } from './kv';
|
||||
|
||||
type APIGuild = {
|
||||
// Only relevant stuff
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
roles: APIRole[];
|
||||
};
|
||||
|
||||
type APIRole = {
|
||||
id: string;
|
||||
name: string;
|
||||
color: number;
|
||||
position: number;
|
||||
permissions: string;
|
||||
managed: boolean;
|
||||
};
|
||||
|
||||
export const getGuild = cacheLayer(
|
||||
Guilds,
|
||||
(id: string) => `guilds/${id}`,
|
||||
async (id: string) => {
|
||||
const guildRaw = await discordFetch<APIGuild>(
|
||||
`/guilds/${id}`,
|
||||
botToken,
|
||||
AuthType.Bot
|
||||
);
|
||||
|
||||
if (!guildRaw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const botMemberRoles =
|
||||
(await getGuildMemberRoles({
|
||||
serverID: id,
|
||||
userID: botClientID,
|
||||
})) || [];
|
||||
|
||||
const highestRolePosition = botMemberRoles.reduce<number>((highest, roleID) => {
|
||||
const role = guildRaw.roles.find((guildRole) => guildRole.id === roleID);
|
||||
if (!role) {
|
||||
return highest;
|
||||
}
|
||||
|
||||
// If highest is a bigger number, it stays the highest.
|
||||
if (highest > role.position) {
|
||||
return highest;
|
||||
}
|
||||
|
||||
return role.position;
|
||||
}, 0);
|
||||
|
||||
const roles = guildRaw.roles.map<Role>((role) => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
color: role.color,
|
||||
managed: role.managed,
|
||||
position: role.position,
|
||||
permissions: role.permissions,
|
||||
safety: calculateRoleSafety(role, highestRolePosition),
|
||||
}));
|
||||
|
||||
// Filters the raw guild data into data we actually want
|
||||
const guild: Guild & OwnRoleInfo = {
|
||||
id: guildRaw.id,
|
||||
name: guildRaw.name,
|
||||
icon: guildRaw.icon,
|
||||
roles,
|
||||
highestRolePosition,
|
||||
};
|
||||
|
||||
return guild;
|
||||
},
|
||||
60 * 60 * 2 // 2 hour TTL
|
||||
);
|
||||
|
||||
type GuildMemberIdentity = {
|
||||
serverID: string;
|
||||
userID: string;
|
||||
};
|
||||
|
||||
type APIMember = {
|
||||
// Only relevant stuff, again.
|
||||
roles: string[];
|
||||
};
|
||||
|
||||
const guildMemberRolesIdentity = ({ serverID, userID }: GuildMemberIdentity) =>
|
||||
`guilds/${serverID}/members/${userID}/roles`;
|
||||
|
||||
export const getGuildMemberRoles = cacheLayer<GuildMemberIdentity, Role['id'][]>(
|
||||
Guilds,
|
||||
guildMemberRolesIdentity,
|
||||
async ({ serverID, userID }) => {
|
||||
const discordMember = await discordFetch<APIMember>(
|
||||
`/guilds/${serverID}/members/${userID}`,
|
||||
botToken,
|
||||
AuthType.Bot
|
||||
);
|
||||
|
||||
if (!discordMember) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return discordMember.roles;
|
||||
},
|
||||
60 * 5 // 5 minute TTL
|
||||
);
|
||||
|
||||
export const updateGuildMemberRoles = async (
|
||||
identity: GuildMemberIdentity,
|
||||
roles: Role['id'][]
|
||||
) => {
|
||||
await Guilds.put(guildMemberRolesIdentity(identity), roles, 60 * 5);
|
||||
};
|
||||
|
||||
export const getGuildData = async (id: string): Promise<GuildDataT> => {
|
||||
const guildData = await GuildData.get<GuildDataT>(id);
|
||||
|
||||
if (!guildData) {
|
||||
return {
|
||||
id,
|
||||
message: '',
|
||||
categories: [],
|
||||
features: Features.None,
|
||||
};
|
||||
}
|
||||
|
||||
return guildData;
|
||||
};
|
||||
|
||||
const calculateRoleSafety = (role: Role | APIRole, highestBotRolePosition: number) => {
|
||||
let safety = RoleSafety.Safe;
|
||||
|
||||
if (role.managed) {
|
||||
safety |= RoleSafety.ManagedRole;
|
||||
}
|
||||
|
||||
if (role.position > highestBotRolePosition) {
|
||||
safety |= RoleSafety.HigherThanBot;
|
||||
}
|
||||
|
||||
const permBigInt = BigInt(role.permissions);
|
||||
if (
|
||||
evaluatePermission(permBigInt, permissions.ADMINISTRATOR) ||
|
||||
evaluatePermission(permBigInt, permissions.MANAGE_ROLES)
|
||||
) {
|
||||
safety |= RoleSafety.DangerousPermissions;
|
||||
}
|
||||
|
||||
return safety;
|
||||
};
|
90
packages/api/utils/kv.ts
Normal file
90
packages/api/utils/kv.ts
Normal file
|
@ -0,0 +1,90 @@
|
|||
export class WrappedKVNamespace {
|
||||
constructor(private kvNamespace: KVNamespace) {}
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const data = await this.kvNamespace.get(key, 'text');
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(data) as T;
|
||||
}
|
||||
|
||||
async put<T>(key: string, value: T, ttlSeconds?: number) {
|
||||
await this.kvNamespace.put(key, JSON.stringify(value), {
|
||||
expirationTtl: ttlSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
list = this.kvNamespace.list;
|
||||
getWithMetadata = this.kvNamespace.getWithMetadata;
|
||||
delete = this.kvNamespace.delete;
|
||||
}
|
||||
|
||||
class EmulatedKV implements KVNamespace {
|
||||
constructor() {
|
||||
console.warn('EmulatedKV used. Data will be lost.');
|
||||
}
|
||||
|
||||
private data: Map<string, any> = new Map();
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
if (!this.data.has(key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.data.get(key);
|
||||
}
|
||||
|
||||
async getWithMetadata<T, Metadata = unknown>(
|
||||
key: string
|
||||
): KVValueWithMetadata<T, Metadata> {
|
||||
return {
|
||||
value: await this.get<T>(key),
|
||||
metadata: {} as Metadata,
|
||||
};
|
||||
}
|
||||
|
||||
async put(key: string, value: string | ReadableStream<any> | ArrayBuffer | FormData) {
|
||||
this.data.set(key, value);
|
||||
}
|
||||
|
||||
async delete(key: string) {
|
||||
this.data.delete(key);
|
||||
}
|
||||
|
||||
async list(options?: {
|
||||
prefix?: string;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}): Promise<{
|
||||
keys: { name: string; expiration?: number; metadata?: unknown }[];
|
||||
list_complete: boolean;
|
||||
cursor: string;
|
||||
}> {
|
||||
let keys: { name: string }[] = [];
|
||||
|
||||
for (let key of this.data.keys()) {
|
||||
if (options?.prefix && !key.startsWith(options.prefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
keys.push({ name: key });
|
||||
}
|
||||
|
||||
return {
|
||||
keys,
|
||||
cursor: '0',
|
||||
list_complete: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const kvOrLocal = (namespace: KVNamespace | null): KVNamespace =>
|
||||
namespace || new EmulatedKV();
|
||||
|
||||
const self = (global as any) as Record<string, any>;
|
||||
|
||||
export const Sessions = new WrappedKVNamespace(kvOrLocal(self.KV_SESSIONS ?? null));
|
||||
export const GuildData = new WrappedKVNamespace(kvOrLocal(self.KV_GUILD_DATA ?? null));
|
||||
export const Guilds = new WrappedKVNamespace(kvOrLocal(self.KV_GUILDS ?? null));
|
28
packages/api/webpack.config.js
Normal file
28
packages/api/webpack.config.js
Normal file
|
@ -0,0 +1,28 @@
|
|||
const path = require('path');
|
||||
|
||||
const mode = process.env.NODE_ENV || 'production';
|
||||
|
||||
module.exports = {
|
||||
target: 'webworker',
|
||||
entry: path.join(__dirname, 'index.ts'),
|
||||
output: {
|
||||
filename: `worker.${mode}.js`,
|
||||
path: path.join(__dirname, 'dist'),
|
||||
},
|
||||
mode,
|
||||
resolve: {
|
||||
extensions: ['.ts', '.tsx', '.js'],
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
transpileOnly: true,
|
||||
configFile: path.join(__dirname, 'tsconfig.json'),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
15
packages/api/worker.config.js
Normal file
15
packages/api/worker.config.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
const reexportEnv = (keys = []) => {
|
||||
return keys.reduce((acc, key) => ({ ...acc, [key]: process.env[key] }), {});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
environment: reexportEnv([
|
||||
'BOT_CLIENT_ID',
|
||||
'BOT_CLIENT_SECRET',
|
||||
'BOT_TOKEN',
|
||||
'UI_PUBLIC_URI',
|
||||
'API_PUBLIC_URI',
|
||||
'ROOT_USERS',
|
||||
]),
|
||||
kv: ['KV_SESSIONS', 'KV_GUILDS', 'KV_GUILD_DATA'],
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue