miniflare init

This commit is contained in:
41666 2022-01-27 16:54:37 -05:00
parent 8c07ed3123
commit 688954a2e0
52 changed files with 898 additions and 25 deletions

View file

@ -0,0 +1,40 @@
import { Bounce } from '../utils/bounce';
import { botClientID } from '../utils/config';
const validGuildID = /^[0-9]+$/;
type URLParams = {
clientID: string;
permissions: number;
guildID?: string;
scopes: string[];
};
const buildURL = (params: URLParams) => {
let url = `https://discord.com/api/oauth2/authorize?client_id=${
params.clientID
}&scope=${params.scopes.join('%20')}&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,
scopes: ['bot', 'applications.commands'],
})
);
};

View file

@ -0,0 +1,18 @@
import { asEditor, getGuild, GuildRateLimiterKey } from '../utils/guild';
import { notFound, ok } from '../utils/responses';
export const ClearGuildCache = asEditor(
{
rateLimitKey: GuildRateLimiterKey.cacheClear,
rateLimitTimeoutSeconds: 60 * 5,
},
(session, { guildID }) =>
async (request: Request): Promise<Response> => {
const result = await getGuild(guildID, { skipCachePull: true });
if (!result) {
return notFound();
}
return ok();
}
);

View file

@ -0,0 +1,64 @@
import { memberPassesAccessControl } from '@roleypoly/api/utils/access-control';
import { accessControlViolation } from '@roleypoly/api/utils/responses';
import { DiscordUser, GuildSlug, PresentableGuild, SessionData } from '@roleypoly/types';
import { respond } from '@roleypoly/worker-utils';
import { withSession } from '../utils/api-tools';
import { getGuild, getGuildData, getGuildMember } 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'), // TODO: rate limit this
});
if (!guild) {
return fail();
}
const memberP = getGuildMember({
serverID: guildID,
userID,
});
const guildDataP = getGuildData(guildID);
const [guildData, member] = await Promise.all([guildDataP, memberP]);
if (!member) {
return fail();
}
if (!memberPassesAccessControl(checkGuild, member, guildData.accessControl)) {
return accessControlViolation();
}
const presentableGuild: PresentableGuild = {
id: guildID,
guild: checkGuild,
roles: guild.roles,
member: {
roles: member.roles,
},
data: guildData,
};
return respond(presentableGuild);
}
);

View file

@ -0,0 +1,13 @@
import { SessionData } from '@roleypoly/types';
import { respond } from '@roleypoly/worker-utils';
import { withSession } from '../utils/api-tools';
export const GetSession = withSession((session?: SessionData) => (): Response => {
const { user, guilds, sessionID } = session || {};
return respond({
user,
guilds,
sessionID,
});
});

View file

@ -0,0 +1,40 @@
import { GuildSlug } from '@roleypoly/types';
import { respond } from '@roleypoly/worker-utils';
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,
};
return respond(guildSlug);
};

View file

@ -0,0 +1,104 @@
import { CategoryType, Member, RoleSafety } from '@roleypoly/types';
import { AuthType, discordFetch, respond } from '@roleypoly/worker-utils';
import { difference, keyBy } from 'lodash';
import { interactionsEndpoint } from '../utils/api-tools';
import { botToken } from '../utils/config';
import {
getGuild,
getGuildData,
getGuildMember,
updateGuildMember,
} from '../utils/guild';
import { conflict, invalid, notAuthenticated, notFound, ok } from '../utils/responses';
export const InteractionsPickRole = interactionsEndpoint(
async (request: Request): Promise<Response> => {
const mode = request.method === 'PUT' ? 'add' : 'remove';
const reqURL = new URL(request.url);
const [, , guildID, userID, roleID] = reqURL.pathname.split('/');
if (!guildID || !userID || !roleID) {
return invalid();
}
const guildP = getGuild(guildID);
const guildDataP = getGuildData(guildID);
const guildMemberP = getGuildMember(
{ serverID: guildID, userID },
{ skipCachePull: true }
);
const [guild, guildData, guildMember] = await Promise.all([
guildP,
guildDataP,
guildMemberP,
]);
if (!guild || !guildData || !guildMember) {
return notFound();
}
let memberRoles = guildMember.roles;
if (
(mode === 'add' && memberRoles.includes(roleID)) ||
(mode !== 'add' && !memberRoles.includes(roleID))
) {
return conflict();
}
const roleMap = keyBy(guild.roles, 'id');
const category = guildData.categories.find((category) =>
category.roles.includes(roleID)
);
// No category? illegal.
if (!category) {
return notFound();
}
// Category is hidden, this is illegal
if (category.hidden) {
return notFound();
}
// Role is unsafe, super illegal.
if (roleMap[roleID].safety !== RoleSafety.Safe) {
return notAuthenticated();
}
// In add mode, if the category is a single-mode, remove the other roles in the category.
if (mode === 'add' && category.type === CategoryType.Single) {
memberRoles = difference(memberRoles, category.roles);
}
if (mode === 'add') {
memberRoles = [...memberRoles, roleID];
} else {
memberRoles = memberRoles.filter((id) => id !== roleID);
}
const patchMemberRoles = await discordFetch<Member>(
`/guilds/${guildID}/members/${userID}`,
botToken,
AuthType.Bot,
{
method: 'PATCH',
headers: {
'content-type': 'application/json',
'x-audit-log-reason': `Picked their roles via slash command`,
},
body: JSON.stringify({
roles: memberRoles,
}),
}
);
if (!patchMemberRoles) {
return respond({ error: 'discord rejected the request' }, { status: 500 });
}
await updateGuildMember({ serverID: guildID, userID });
return ok();
}
);

View file

@ -0,0 +1,33 @@
import { Category, CategorySlug } from '@roleypoly/types';
import { respond } from '@roleypoly/worker-utils';
import { interactionsEndpoint } from '../utils/api-tools';
import { getGuildData } from '../utils/guild';
import { notFound } from '../utils/responses';
export const InteractionsPickableRoles = interactionsEndpoint(
async (request: Request): Promise<Response> => {
const reqURL = new URL(request.url);
const [, , serverID] = reqURL.pathname.split('/');
const guildData = await getGuildData(serverID);
if (!guildData) {
return notFound();
}
const roleMap: Record<Category['name'], CategorySlug> = {};
for (let category of guildData.categories) {
if (category.hidden) {
continue;
}
// TODO: role safety?
roleMap[category.name] = {
roles: category.roles,
type: category.type,
};
}
return respond(roleMap);
}
);

View file

@ -0,0 +1,34 @@
import { StateSession } from '@roleypoly/types';
import { getQuery } from '@roleypoly/worker-utils';
import { isAllowedCallbackHost, setupStateSession } from '../utils/api-tools';
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&prompt=none&redirect_uri=${encodeURIComponent(
params.redirectURI
)}&state=${params.state}`;
export const LoginBounce = async (request: Request): Promise<Response> => {
const stateSessionData: StateSession = {};
const { cbh: callbackHost } = getQuery(request);
if (callbackHost && isAllowedCallbackHost(callbackHost)) {
stateSessionData.callbackHost = callbackHost;
}
const state = await setupStateSession(stateSessionData);
const redirectURI = `${apiPublicURI}/login-callback`;
const clientID = botClientID;
return Bounce(buildURL({ state, redirectURI, clientID }));
};

View file

@ -0,0 +1,160 @@
import {
AuthTokenResponse,
DiscordUser,
GuildSlug,
SessionData,
StateSession,
} from '@roleypoly/types';
import {
AuthType,
discordAPIBase,
discordFetch,
userAgent,
} from '@roleypoly/worker-utils';
import KSUID from 'ksuid';
import {
formData,
getStateSession,
isAllowedCallbackHost,
parsePermissions,
resolveFailures,
} 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> => {
let bounceBaseUrl = uiPublicURI;
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');
}
const stateSession = await getStateSession<StateSession>(state.string);
if (
stateSession?.callbackHost &&
isAllowedCallbackHost(stateSession.callbackHost)
) {
bounceBaseUrl = stateSession.callbackHost;
}
} 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(discordAPIBase + '/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(bounceBaseUrl + 'machinery/new-session/' + 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;
};

View file

@ -0,0 +1,28 @@
import { SessionData } from '@roleypoly/types';
import { discordAPIBase, respond, userAgent } from '@roleypoly/worker-utils';
import { formData, 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(discordAPIBase + '/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 });
}
);

View file

@ -0,0 +1,23 @@
import { asEditor, GuildRateLimiterKey } from '../utils/guild';
import { fetchLegacyServer, transformLegacyGuild } from '../utils/import-from-legacy';
import { GuildData } from '../utils/kv';
import { notFound, ok } from '../utils/responses';
export const SyncFromLegacy = asEditor(
{
rateLimitKey: GuildRateLimiterKey.legacyImport,
rateLimitTimeoutSeconds: 60 * 20,
},
(session, { guildID }) =>
async (request: Request): Promise<Response> => {
const legacyGuild = await fetchLegacyServer(guildID);
if (!legacyGuild) {
return notFound();
}
const newGuildData = transformLegacyGuild(legacyGuild);
await GuildData.put(guildID, newGuildData);
return ok();
}
);

View file

@ -0,0 +1,51 @@
import { sendAuditLog, validateAuditLogWebhook } from '@roleypoly/api/utils/audit-log';
import { GuildDataUpdate, WebhookValidationStatus } from '@roleypoly/types';
import { asEditor, getGuildData } from '../utils/guild';
import { GuildData } from '../utils/kv';
import { invalid, ok } from '../utils/responses';
export const UpdateGuild = asEditor(
{},
(session, { guildID, guild }) =>
async (request: Request): Promise<Response> => {
const guildUpdate = (await request.json()) as GuildDataUpdate;
const oldGuildData = await getGuildData(guildID);
const newGuildData = {
...oldGuildData,
...guildUpdate,
};
if (oldGuildData.auditLogWebhook !== newGuildData.auditLogWebhook) {
try {
const validationStatus = await validateAuditLogWebhook(
guild,
newGuildData.auditLogWebhook
);
if (validationStatus !== WebhookValidationStatus.Ok) {
if (validationStatus === WebhookValidationStatus.NoneSet) {
newGuildData.auditLogWebhook = null;
} else {
return invalid({
what: 'webhookValidationStatus',
webhookValidationStatus: validationStatus,
});
}
}
} catch (e) {
invalid();
}
}
await GuildData.put(guildID, newGuildData);
try {
await sendAuditLog(oldGuildData, guildUpdate, session.user);
} catch (e) {
// Catching errors here because this isn't a critical task, and could simply fail due to operator error.
}
return ok();
}
);

View file

@ -0,0 +1,142 @@
import {
GuildData,
Member,
Role,
RoleSafety,
RoleTransaction,
RoleUpdate,
SessionData,
TransactionType,
} from '@roleypoly/types';
import { AuthType, discordFetch, respond } from '@roleypoly/worker-utils';
import { difference, groupBy, keyBy, union } from 'lodash';
import { withSession } from '../utils/api-tools';
import { botToken, uiPublicURI } from '../utils/config';
import {
getGuild,
getGuildData,
getGuildMember,
updateGuildMember,
} from '../utils/guild';
const notFound = () => respond({ error: 'guild not found' }, { status: 404 });
export const UpdateRoles = withSession(
({ guilds, user: { id: userID, username, discriminator } }: SessionData) =>
async (request: Request) => {
const updateRequest = (await request.json()) as RoleUpdate;
const url = new URL(request.url);
const [, , guildID] = 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 guildMember = await getGuildMember(
{ serverID: guildID, userID },
{ skipCachePull: true }
);
if (!guildMember) {
return notFound();
}
const guildData = await getGuildData(guildID);
const newRoles = calculateNewRoles({
currentRoles: guildMember.roles,
guildRoles: guild.roles,
guildData,
updateRequest,
});
const patchMemberRoles = await discordFetch<Member>(
`/guilds/${guildID}/members/${userID}`,
botToken,
AuthType.Bot,
{
method: 'PATCH',
headers: {
'content-type': 'application/json',
'x-audit-log-reason': `Picked their roles via ${uiPublicURI}`,
},
body: JSON.stringify({
roles: newRoles,
}),
}
);
if (!patchMemberRoles) {
return respond({ error: 'discord rejected the request' }, { status: 500 });
}
const updatedMember: Member = {
roles: patchMemberRoles.roles,
};
// Delete the cache by re-pulling... might be dangerous :)
await updateGuildMember({ serverID: guildID, userID });
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;
};

View file

@ -0,0 +1,70 @@
import { InteractionsPickRole } from '@roleypoly/api/handlers/interactions-pick-role';
import { InteractionsPickableRoles } from '@roleypoly/api/handlers/interactions-pickable-roles';
import { Router } from '@roleypoly/worker-utils/router';
import { BotJoin } from './handlers/bot-join';
import { ClearGuildCache } from './handlers/clear-guild-cache';
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 { SyncFromLegacy } from './handlers/sync-from-legacy';
import { UpdateGuild } from './handlers/update-guild';
import { UpdateRoles } from './handlers/update-roles';
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);
router.add('PATCH', 'update-guild', UpdateGuild);
router.add('POST', 'sync-from-legacy', SyncFromLegacy);
router.add('POST', 'clear-guild-cache', ClearGuildCache);
// Interactions endpoints
router.add('GET', 'interactions-pickable-roles', InteractionsPickableRoles);
router.add('PUT', 'interactions-pick-role', InteractionsPickRole);
router.add('DELETE', 'interactions-pick-role', InteractionsPickRole);
// 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) => {
router.handle(event));
});

View file

@ -0,0 +1,52 @@
import { isRoot } from '@roleypoly/api/utils/api-tools';
import {
GuildAccessControl,
GuildSlug,
Member,
UserGuildPermissions,
} from '@roleypoly/types';
import { xor } from 'lodash';
export const memberPassesAccessControl = (
guildSlug: GuildSlug,
member: Member,
accessControl: GuildAccessControl
): boolean => {
return true;
// Root has a bypass
if (isRoot(member.user?.id || '')) {
return true;
}
// Admin and Manager has a bypass
if (guildSlug.permissionLevel !== UserGuildPermissions.User) {
return true;
}
// Block pending members, "Welcome Screen" feature
if (accessControl.blockPending && member.pending) {
return false;
}
// If member has roles in the blockList, block.
// Blocklist takes precedence over allowlist
// We use xor because xor([1, 3], [2, 3]) returns [3]), e.g. present in both lists
if (
accessControl.blockList &&
xor(member.roles, accessControl.blockList).length !== 0
) {
return false;
}
// If there is an allowList, and the member is not in it, block.
// If thew allowList is empty, we bypass this.
if (
accessControl.allowList &&
xor(member.roles, accessControl.allowList).length === 0
) {
return false;
}
return true;
};

View file

@ -0,0 +1,176 @@
import { notAuthenticated } from '@roleypoly/api/utils/responses';
import {
evaluatePermission,
permissions as Permissions,
} from '@roleypoly/misc-utils/hasPermission';
import { SessionData, UserGuildPermissions } from '@roleypoly/types';
import { Handler, HandlerTools, WrappedKVNamespace } from '@roleypoly/worker-utils';
import KSUID from 'ksuid';
import {
allowedCallbackHosts,
apiPublicURI,
interactionsSharedKey,
rootUsers,
} from './config';
import { Sessions } from './kv';
export const formData = (obj: Record<string, any>): string => {
return Object.keys(obj)
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(obj[key])}`)
.join('&');
};
export const respond = (obj: Record<string, any>, init: ResponseInit = {}) =>
new Response(JSON.stringify(obj), 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 type CacheLayerOptions = {
skipCachePull?: boolean;
};
export const cacheLayer =
<Identity, Data>(
kv: WrappedKVNamespace,
keyFactory: (identity: Identity) => string,
missHandler: (identity: Identity) => Promise<Data | null>,
ttlSeconds?: number
) =>
async (identity: Identity, options: CacheLayerOptions = {}): 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, tools: HandlerTools): 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, tools);
};
export const setupStateSession = async <T>(data: T): Promise<string> => {
const stateID = (await KSUID.random()).string;
await Sessions.put(`state_${stateID}`, { data }, 60 * 5);
return stateID;
};
export const getStateSession = async <T>(stateID: string): Promise<T | undefined> => {
const stateSession = await Sessions.get<{ data: T }>(`state_${stateID}`);
return stateSession?.data;
};
export const isRoot = (userID: string): boolean => rootUsers.includes(userID);
export const onlyRootUsers = (handler: Handler): Handler =>
withSession((session) => (request: Request, tools: HandlerTools) => {
if (isRoot(session.user.id)) {
return handler(request, tools);
}
return respond(
{
error: 'not_found',
},
{
status: 404,
}
);
});
export const isAllowedCallbackHost = (host: string): boolean => {
return (
host === apiPublicURI ||
allowedCallbackHosts.includes(host) ||
allowedCallbackHosts
.filter((callbackHost) => callbackHost.includes('*'))
.find((wildcard) => new RegExp(wildcard.replace('*', '[a-z0-9-]+')).test(host)) !==
null
);
};
export const interactionsEndpoint =
(handler: Handler): Handler =>
async (request: Request, tools: HandlerTools): Promise<Response> => {
const authHeader = request.headers.get('authorization') || '';
if (authHeader !== `Shared ${interactionsSharedKey}`) {
return notAuthenticated();
}
return handler(request, tools);
};

View file

@ -0,0 +1,252 @@
import { uiPublicURI } from '@roleypoly/api/utils/config';
import {
Category,
DiscordUser,
Embed,
GuildData,
GuildDataUpdate,
GuildSlug,
WebhookValidationStatus,
} from '@roleypoly/types';
import { userAgent } from '@roleypoly/worker-utils';
import deepEqual from 'deep-equal';
import { sortBy, uniq } from 'lodash';
type WebhookPayload = {
username: string;
avatar_url: string;
embeds: Embed[];
provider: {
name: string;
url: string;
};
};
type ChangeHandler = (
oldValue: GuildDataUpdate[keyof GuildDataUpdate],
newValue: GuildData[keyof GuildDataUpdate]
) => Embed[];
const changeHandlers: Record<keyof GuildDataUpdate, ChangeHandler> = {
message: (oldValue, newValue) => [
{
timestamp: new Date().toISOString(),
color: 0x453e3d,
fields: [
{
name: 'Old Message',
value: oldValue as string,
inline: false,
},
{
name: 'New Message',
value: newValue as string,
inline: false,
},
],
title: `Server message was updated...`,
},
],
auditLogWebhook: (oldValue, newValue) => [
{
timestamp: new Date().toISOString(),
color: 0x5d5352,
fields: [
{
name: 'Old Webhook ID',
value: !oldValue ? '*unset*' : (oldValue as string).split('/')[5],
inline: false,
},
{
name: 'New Webhook ID',
value: !newValue ? '*unset*' : (newValue as string).split('/')[5],
inline: false,
},
],
title: `Audit Log webhook URL was changed...`,
},
],
categories: (oldValue, newValue) => [
{
timestamp: new Date().toISOString(),
color: 0xab9b9a,
fields: [
{
name: 'Changed Categories',
value: getChangedCategories(
oldValue as Category[],
newValue as Category[]
).join('\n'),
inline: false,
},
],
title: `Categories were changed...`,
},
],
accessControl: (oldValue, newValue) => [
{
timestamp: new Date().toISOString(),
color: 0xab9b9a,
fields: [
{
name: 'Changed Access Control',
value: getChangedAccessControl(
oldValue as GuildDataUpdate['accessControl'],
newValue as GuildDataUpdate['accessControl']
).join('\n'),
inline: false,
},
],
title: `Access Control was changed...`,
},
],
};
export const sendAuditLog = async (
guild: GuildData,
guildUpdate: GuildDataUpdate,
user: DiscordUser
) => {
const auditLogWebhooks = uniq([
guild.auditLogWebhook || '',
guildUpdate.auditLogWebhook || '',
]).filter((webhook) => webhook !== '');
if (auditLogWebhooks.length === 0) {
return;
}
const keys = Object.keys(guildUpdate) as (keyof GuildDataUpdate)[];
const webhookPayload: WebhookPayload = {
username: 'Roleypoly (Audit Log)',
avatar_url: `https://next.roleypoly.com/logo192.png`, //TODO: change to roleypoly.com when swapped.
embeds: [
{
fields: [],
timestamp: new Date().toISOString(),
title: `${user.username}#${user.discriminator} has edited Roleypoly settings`,
color: 0x332d2d,
author: {
name: user.username,
icon_url: `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`,
},
},
],
provider: {
name: 'Roleypoly',
url: uiPublicURI,
},
};
for (let key of keys) {
if (deepEqual(guildUpdate[key], guild[key])) {
continue;
}
const handler = changeHandlers[key];
if (!handler) {
continue;
}
const changeFields = handler(guild[key], guildUpdate[key]);
webhookPayload.embeds.push(...changeFields);
}
if (webhookPayload.embeds.length === 1) {
// No changes, don't bother sending
return;
}
// Colors are in order already, so use them to order the embeds.
webhookPayload.embeds = sortBy(webhookPayload.embeds, 'color');
const doWebhook = (webhook: string) =>
fetch(webhook, {
method: 'POST',
body: JSON.stringify(webhookPayload),
headers: {
'content-type': 'application/json',
'user-agent': userAgent,
},
});
await Promise.all(auditLogWebhooks.map((webhook) => doWebhook(webhook)));
};
export const validateAuditLogWebhook = async (
guild: GuildSlug,
webhook: string | null
): Promise<WebhookValidationStatus> => {
if (!webhook) {
return WebhookValidationStatus.NoneSet;
}
const url = new URL(webhook);
if (
url.hostname !== 'discord.com' ||
url.protocol !== 'https:' ||
url.pathname.startsWith('api/webhooks/')
) {
return WebhookValidationStatus.NotDiscordURL;
}
const response = await fetch(webhook, { method: 'GET' });
if (response.status !== 200) {
return WebhookValidationStatus.DoesNotExist;
}
const webhookData = await response.json();
if (webhookData.guild_id !== guild.id) {
return WebhookValidationStatus.NotSameGuild;
}
return WebhookValidationStatus.Ok;
};
const getChangedCategories = (oldCategories: Category[], newCategories: Category[]) => {
const addedCategories = newCategories.filter(
(c) => !oldCategories.find((o) => o.id === c.id)
);
const removedCategories = oldCategories.filter(
(c) => !newCategories.find((o) => o.id === c.id)
);
const changedCategories = newCategories.filter(
(c) =>
oldCategories.find((o) => o.id === c.id) &&
!deepEqual(
oldCategories.find((o) => o.id === c.id),
newCategories.find((o) => o.id === c.id)
)
);
return [
...addedCategories.map((c) => ` **Added** ${c.name}`),
...removedCategories.map((c) => ` **Removed** ${c.name}`),
...changedCategories.map((c) => `🔧 **Changed** ${c.name}`),
];
};
const getChangedAccessControl = (
oldAccessControl: GuildDataUpdate['accessControl'],
newAccessControl: GuildDataUpdate['accessControl']
) => {
const pendingChanged = newAccessControl.blockPending !== oldAccessControl.blockPending;
return [
`✅ Allowed roles: ${
newAccessControl.allowList.map((role) => `<@&${role}>`).join(', ') || `*all roles*`
}`,
`❌ Blocked roles: ${
newAccessControl.blockList.map((role) => `<@&${role}>`).join(', ') || `*no roles*`
}`,
...(pendingChanged
? [
`🔧 Pending/Welcome Screening users are ${
newAccessControl.blockPending ? 'blocked ❌' : 'allowed ✔'
}`,
]
: []),
];
};

View file

@ -0,0 +1,7 @@
export const Bounce = (url: string): Response =>
new Response(null, {
status: 303,
headers: {
location: url,
},
});

View file

@ -0,0 +1,16 @@
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'));
export const allowedCallbackHosts = list(env('ALLOWED_CALLBACK_HOSTS'));
export const importSharedKey = env('BOT_IMPORT_TOKEN');
export const interactionsSharedKey = env('INTERACTIONS_SHARED_KEY');

View file

@ -0,0 +1,38 @@
import { hasFeature } from '@roleypoly/misc-utils/hasFeature';
import { Features, Guild, GuildData } from '@roleypoly/types';
const flagPercents: Record<Features, { percent: number; rotation: number }> = {
[Features.AuditLogging]: { percent: 0, rotation: 0 },
[Features.AccessControl]: { percent: 0, rotation: 33 },
};
const testingGroup: Guild['id'][] = [
'386659935687147521', // Roleypoly
];
const ONE_HUNDRED = BigInt(100);
export const getFeatureFlags = (
feature: Features,
guildData: GuildData
): Record<Features, boolean> => {
const flags = Object.entries(flagPercents).map(([flag, value]) => {
const intFlag = Number(flag);
const intGuildID = BigInt(guildData.id);
const rotation = BigInt(value.rotation);
const percent = BigInt(value.percent);
if (testingGroup.includes(guildData.id)) {
return [intFlag, true];
}
const percentValue = (intGuildID + rotation) % ONE_HUNDRED;
if (percentValue >= percent) {
return [intFlag, true];
}
return [intFlag, hasFeature(feature, intFlag)];
});
return Object.fromEntries(flags);
};

View file

@ -0,0 +1,289 @@
import {
lowPermissions,
missingParameters,
notFound,
rateLimited,
} from '@roleypoly/api/utils/responses';
import { evaluatePermission, permissions } from '@roleypoly/misc-utils/hasPermission';
import {
Features,
Guild,
GuildData as GuildDataT,
GuildSlug,
Member,
OwnRoleInfo,
Role,
RoleSafety,
SessionData,
UserGuildPermissions,
} from '@roleypoly/types';
import { AuthType, discordFetch, Handler, HandlerTools } from '@roleypoly/worker-utils';
import { cacheLayer, CacheLayerOptions, isRoot, withSession } from './api-tools';
import { botClientID, botToken } from './config';
import { GuildData, Guilds } from './kv';
import { useRateLimiter } from './rate-limiting';
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 guildData = await getGuildData(id);
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, guildData),
}));
// 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[];
pending: boolean;
};
export const getGuildMemberRoles = async (
{ serverID, userID }: GuildMemberIdentity,
opts?: CacheLayerOptions
) => (await getGuildMember({ serverID, userID }, opts))?.roles;
const guildMemberIdentity = ({ serverID, userID }: GuildMemberIdentity) =>
`guilds/${serverID}/members/${userID}`;
export const getGuildMember = cacheLayer<GuildMemberIdentity, Member>(
Guilds,
guildMemberIdentity,
async ({ serverID, userID }) => {
const discordMember = await discordFetch<APIMember>(
`/guilds/${serverID}/members/${userID}`,
botToken,
AuthType.Bot
);
if (!discordMember) {
return null;
}
return {
roles: discordMember.roles,
pending: discordMember.pending,
};
},
60 * 5 // 5 minute TTL
);
export const updateGuildMember = async (identity: GuildMemberIdentity) => {
await getGuildMember(identity, { skipCachePull: true });
};
export const getGuildData = async (id: string): Promise<GuildDataT> => {
const guildData = await GuildData.get<GuildDataT>(id);
const empty = {
id,
message: '',
categories: [],
features: Features.None,
auditLogWebhook: null,
accessControl: {
allowList: [],
blockList: [],
blockPending: true,
},
};
if (!guildData) {
return empty;
}
return {
...empty,
...guildData,
};
};
const calculateRoleSafety = (
role: Role | APIRole,
highestBotRolePosition: number,
guildData: GuildDataT
) => {
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;
}
if (
guildData.accessControl.allowList.includes(role.id) ||
guildData.accessControl.blockList.includes(role.id)
) {
safety |= RoleSafety.AccessControl;
}
return safety;
};
export enum GuildRateLimiterKey {
legacyImport = 'legacyImport',
cacheClear = 'cacheClear',
}
export const useGuildRateLimiter = (
guildID: string,
key: GuildRateLimiterKey,
timeoutSeconds: number
) => useRateLimiter(Guilds, `guilds/${guildID}/rate-limit/${key}`, timeoutSeconds);
type AsEditorOptions = {
rateLimitKey?: GuildRateLimiterKey;
rateLimitTimeoutSeconds?: number;
};
type UserGuildContext = {
guildID: string;
guild: GuildSlug;
url: URL;
};
export const asEditor = (
options: AsEditorOptions = {},
wrappedHandler: (session: SessionData, userGuildContext: UserGuildContext) => Handler
): Handler =>
withSession(
(session: SessionData) =>
async (request: Request, tools: HandlerTools): Promise<Response> => {
const { rateLimitKey, rateLimitTimeoutSeconds } = options;
const url = new URL(request.url);
const [, , guildID] = url.pathname.split('/');
if (!guildID) {
return missingParameters();
}
let rateLimit: null | ReturnType<typeof useGuildRateLimiter> = null;
if (rateLimitKey) {
rateLimit = await useGuildRateLimiter(
guildID,
rateLimitKey,
rateLimitTimeoutSeconds || 60
);
}
const userIsRoot = isRoot(session.user.id);
let guild = session.guilds.find((guild) => guild.id === guildID);
if (!guild) {
if (!userIsRoot) {
return notFound();
}
const fullGuild = await getGuild(guildID);
if (!fullGuild) {
return notFound();
}
guild = {
id: fullGuild.id,
name: fullGuild.name,
icon: fullGuild.icon,
permissionLevel: UserGuildPermissions.Admin, // root will always be considered admin
};
}
const userIsManager = guild.permissionLevel === UserGuildPermissions.Manager;
const userIsAdmin = guild.permissionLevel === UserGuildPermissions.Admin;
if (!userIsAdmin && !userIsManager) {
return lowPermissions();
}
if (!userIsRoot && rateLimit && (await rateLimit())) {
return rateLimited();
}
return await wrappedHandler(session, {
guildID,
guild,
url,
})(request, tools);
}
);

View file

@ -0,0 +1,62 @@
import { sortBy } from '@roleypoly/misc-utils/sortBy';
import { CategoryType, Features, GuildData } from '@roleypoly/types';
import KSUID from 'ksuid';
import { importSharedKey } from './config';
export type LegacyCategory = {
id: string;
name: string;
roles: string[];
hidden: boolean;
type: 'single' | 'multi';
position: number;
};
export type LegacyGuildData = {
id: string;
categories: LegacyCategory[];
message: string;
};
export const fetchLegacyServer = async (id: string): Promise<LegacyGuildData | null> => {
const guildDataResponse = await fetch(
`https://beta.roleypoly.com/x/import-to-next/${id}`,
{
headers: {
authorization: `Shared ${importSharedKey}`,
},
}
);
if (guildDataResponse.status === 404) {
return null;
}
if (guildDataResponse.status !== 200) {
throw new Error('Guild data fetch failed');
}
return await guildDataResponse.json();
};
export const transformLegacyGuild = (guild: LegacyGuildData): GuildData => {
return {
id: guild.id,
message: guild.message,
features: Features.LegacyGuild,
auditLogWebhook: null,
accessControl: {
allowList: [],
blockList: [],
blockPending: true,
},
categories: sortBy(Object.values(guild.categories), 'position').map(
(category, idx) => ({
...category,
id: KSUID.randomSync().string,
position: idx, // Reset positions by index. May have side-effects but oh well.
type: category.type === 'multi' ? CategoryType.Multi : CategoryType.Single,
})
),
};
};

View file

@ -0,0 +1,7 @@
import { kvOrLocal, WrappedKVNamespace } from '@roleypoly/worker-utils';
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));

View file

@ -0,0 +1,13 @@
import { WrappedKVNamespace } from '@roleypoly/worker-utils';
export const useRateLimiter =
(kv: WrappedKVNamespace, key: string, timeoutSeconds: number) =>
async (): Promise<boolean> => {
const value = await kv.get<boolean>(key);
if (value) {
return true;
}
await kv.put(key, true, timeoutSeconds);
return false;
};

View file

@ -0,0 +1,25 @@
import { respond } from '@roleypoly/worker-utils';
export const ok = () => respond({ ok: true });
export const missingParameters = () =>
respond({ error: 'missing parameters' }, { status: 400 });
export const lowPermissions = () =>
respond({ error: 'no permissions for this action' }, { status: 403 });
export const accessControlViolation = () =>
respond({ error: 'member fails access control requirements' }, { status: 403 });
export const notFound = () => respond({ error: 'not found' }, { status: 404 });
export const conflict = () => respond({ error: 'conflict' }, { status: 409 });
export const rateLimited = () =>
respond({ error: 'rate limit hit, enhance your calm' }, { status: 429 });
export const invalid = (obj: any = {}) =>
respond({ err: 'client sent something invalid', data: obj }, { status: 400 });
export const notAuthenticated = () =>
respond({ err: 'not authenticated' }, { status: 403 });

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

View file

@ -0,0 +1,18 @@
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',
'BOT_IMPORT_TOKEN',
'UI_PUBLIC_URI',
'API_PUBLIC_URI',
'ROOT_USERS',
'ALLOWED_CALLBACK_HOSTS',
'INTERACTIONS_SHARED_KEY',
]),
kv: ['KV_SESSIONS', 'KV_GUILDS', 'KV_GUILD_DATA'],
};