remove old-src, same some historic pieces

This commit is contained in:
41666 2022-01-30 03:34:41 -05:00
parent 8e21d2e5cc
commit 33df1c7edc
31 changed files with 221 additions and 1414 deletions

View file

@ -0,0 +1,16 @@
import {
InteractionCallbackType,
InteractionRequestCommand,
InteractionResponse,
} from '@roleypoly/types';
export const helloWorld = async (
interaction: InteractionRequestCommand
): Promise<InteractionResponse> => {
return {
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `Hey there, ${interaction.member?.nick || interaction.user?.username}`,
},
};
};

View file

@ -0,0 +1,86 @@
import { selectRole } from '@roleypoly/interactions/utils/api';
import {
asyncPreflightEphemeral,
asyncResponse,
} from '@roleypoly/interactions/utils/interactions';
import { invalid, mustBeInGuild } from '@roleypoly/interactions/utils/responses';
import {
InteractionCallbackType,
InteractionFlags,
InteractionRequestCommand,
InteractionResponse,
} from '@roleypoly/types';
export const pickRole = (mode: 'add' | 'remove') =>
asyncResponse(
async (interaction: InteractionRequestCommand): Promise<InteractionResponse> => {
if (!interaction.guild_id) {
return mustBeInGuild();
}
const userID = interaction.member?.user?.id;
if (!userID) {
return mustBeInGuild();
}
const roleID = interaction.data.options?.find(
(option) => option.name === 'role'
)?.value;
if (!roleID) {
return invalid();
}
const code = await selectRole(mode, interaction.guild_id, userID, roleID);
if (code === 409) {
return {
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `:x: You ${mode === 'add' ? 'already' : "don't"} have that role.`,
flags: InteractionFlags.EPHEMERAL,
},
};
}
if (code === 404) {
return {
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `:x: <@&${roleID}> isn't pickable.`,
flags: InteractionFlags.EPHEMERAL,
},
};
}
if (code === 403) {
return {
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `:x: <@&${roleID}> has unsafe permissions.`,
flags: InteractionFlags.EPHEMERAL,
},
};
}
if (code !== 200) {
return {
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `:x: Something went wrong, please try again later.`,
flags: InteractionFlags.EPHEMERAL,
},
};
}
return {
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `:white_check_mark: You ${
mode === 'add' ? 'got' : 'removed'
} the role: <@&${roleID}>`,
flags: InteractionFlags.EPHEMERAL,
},
};
},
asyncPreflightEphemeral
);

View file

@ -0,0 +1,63 @@
import { getPickableRoles } from '@roleypoly/interactions/utils/api';
import { uiPublicURI } from '@roleypoly/interactions/utils/config';
import {
asyncPreflightEphemeral,
asyncResponse,
} from '@roleypoly/interactions/utils/interactions';
import { mustBeInGuild } from '@roleypoly/interactions/utils/responses';
import {
CategoryType,
Embed,
InteractionCallbackType,
InteractionFlags,
InteractionRequestCommand,
InteractionResponse,
} from '@roleypoly/types';
export const pickableRoles = asyncResponse(
async (interaction: InteractionRequestCommand): Promise<InteractionResponse> => {
if (!interaction.guild_id) {
return mustBeInGuild();
}
const pickableRoles = await getPickableRoles(interaction.guild_id);
const embed: Embed = {
color: 0xab9b9a,
fields: [],
title: 'You can pick any of these roles with /pick-role',
};
for (let categoryName in pickableRoles) {
const { roles, type } = pickableRoles[categoryName];
embed.fields.push({
name: `${categoryName}${type === CategoryType.Single ? ' *(pick one)*' : ''}`,
value: roles.map((role) => `<@&${role}>`).join('\n'),
inline: true,
});
}
return {
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
embeds: [embed],
flags: InteractionFlags.EPHEMERAL,
components: [
{
type: 1,
components: [
// Link to Roleypoly
{
type: 2,
label: 'Pick roles on your browser',
url: `${uiPublicURI}/s/${interaction.guild_id}`,
style: 5,
},
],
},
],
},
};
},
asyncPreflightEphemeral
);

View file

@ -0,0 +1,56 @@
import { uiPublicURI } from '@roleypoly/interactions/utils/config';
import {
Embed,
InteractionCallbackType,
InteractionFlags,
InteractionRequestCommand,
InteractionResponse,
} from '@roleypoly/types';
export const roleypoly = async (
interaction: InteractionRequestCommand
): Promise<InteractionResponse> => {
if (interaction.guild_id) {
return {
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
embeds: [
{
color: 0x453e3d,
title: `:beginner: Hey there, ${
interaction.member?.nick || interaction.member?.user?.username || 'friend'
}!`,
description: `Try these slash commands, or pick roles from your browser!`,
fields: [
{ name: 'See all the roles', value: '/pickable-roles' },
{ name: 'Pick a role', value: '/pick-role' },
{ name: 'Remove a role', value: '/remove-role' },
],
} as Embed,
],
components: [
{
type: 1,
components: [
// Link to Roleypoly
{
type: 2,
label: `Pick roles on ${new URL(uiPublicURI).hostname}`,
url: `${uiPublicURI}/s/${interaction.guild_id}`,
style: 5,
},
],
},
],
flags: InteractionFlags.EPHEMERAL,
},
};
}
return {
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `:beginner: Hey! I don't know what server you're in, so check out ${uiPublicURI}`,
},
};
};

View file

@ -1,40 +0,0 @@
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

@ -1,18 +0,0 @@
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

@ -1,64 +0,0 @@
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

@ -1,13 +0,0 @@
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

@ -1,40 +0,0 @@
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

@ -1,34 +0,0 @@
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

@ -1,160 +0,0 @@
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

@ -1,28 +0,0 @@
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

@ -1,23 +0,0 @@
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

@ -1,51 +0,0 @@
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

@ -1,142 +0,0 @@
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

@ -1,70 +0,0 @@
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

@ -1,52 +0,0 @@
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

@ -1,176 +0,0 @@
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

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

View file

@ -1,16 +0,0 @@
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

@ -1,38 +0,0 @@
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

@ -1,289 +0,0 @@
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

@ -1,62 +0,0 @@
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

@ -1,7 +0,0 @@
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

@ -1,13 +0,0 @@
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

@ -1,25 +0,0 @@
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

@ -1,28 +0,0 @@
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

@ -1,18 +0,0 @@
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'],
};