big overhaul (#474)

* miniflare init

* feat(api): add tests

* chore: more tests, almost 100%

* add sessions/state spec

* add majority of routes and datapaths, start on interactions

* nevermind, no interactions

* nevermind x2, tweetnacl is bad but SubtleCrypto has what we need apparently

* simplify interactions verify

* add brute force interactions tests

* every primary path API route is refactored!

* automatically import from legacy, or die trying.

* check that we only fetch legacy once, ever

* remove old-src, same some historic pieces

* remove interactions & worker-utils package, update misc/types

* update some packages we don't need specific pinning for anymore

* update web references to API routes since they all changed

* fix all linting issues, upgrade most packages

* fix tests, divorce enzyme where-ever possible

* update web, fix integration issues

* pre-build api

* fix tests

* move api pretest to api package.json instead of CI

* remove interactions from terraform, fix deploy side configs

* update to tf 1.1.4

* prevent double writes to worker in GCS, port to newer GCP auth workflow

* fix api.tf var refs, upgrade node action

* change to curl-based script upload for worker script due to terraform provider limitations

* oh no, cloudflare freaked out :(
This commit is contained in:
41666 2022-01-31 20:35:22 -05:00 committed by GitHub
parent b644a38aa7
commit 3291f9aacc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
183 changed files with 9853 additions and 9924 deletions

View file

@ -11,21 +11,10 @@ jobs:
steps: steps:
- uses: actions/checkout@master - uses: actions/checkout@master
- uses: actions/setup-node@v2.1.5 - uses: actions/setup-node@v2.5.1
with: with:
node-version: '16' node-version: '16'
cache: yarn
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- uses: actions/cache@v2.1.6
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- run: yarn install --frozen-lockfile - run: yarn install --frozen-lockfile
@ -42,62 +31,45 @@ jobs:
matrix: matrix:
worker: worker:
- api - api
- interactions
steps: steps:
- uses: actions/checkout@master - uses: actions/checkout@master
- uses: actions/setup-node@v2.1.5 - uses: actions/setup-node@v2.5.1
with: with:
node-version: '16' node-version: '16'
cache: yarn
- id: 'auth'
uses: 'google-github-actions/auth@v0'
with:
credentials_json: '${{ secrets.GCS_TF_KEY }}'
- name: Set up Cloud SDK - name: Set up Cloud SDK
uses: google-github-actions/setup-gcloud@master uses: google-github-actions/setup-gcloud@v0
with: with:
project_id: ${{ secrets.GCS_PROJECT_ID }} project_id: ${{ secrets.GCS_PROJECT_ID }}
service_account_key: ${{ secrets.GCS_TF_KEY }}
export_default_credentials: true export_default_credentials: true
- name: Check if already deployed - name: Check if already deployed
id: check id: check
run: | run: |
gsutil stat gs://roleypoly-artifacts/workers/${{ github.sha }}/${{ matrix.worker }}.js \ gsutil stat gs://roleypoly-artifacts/workers/${{ github.sha }}/index.mjs \
&& echo ::set-output name=skip::1 \ && echo ::set-output name=skip::1 \
|| echo ::set-output name=skip::0 || echo ::set-output name=skip::0
- run: npm i -g @cloudflare/wrangler
if: steps.check.outputs.skip == '0'
- name: Get yarn cache directory path
if: steps.check.outputs.skip == '0'
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- uses: actions/cache@v2.1.6
if: steps.check.outputs.skip == '0'
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- run: yarn install --frozen-lockfile - run: yarn install --frozen-lockfile
if: steps.check.outputs.skip == '0' if: steps.check.outputs.skip == '0'
- run: | - run: |
wrangler init yarn build:api
echo 'webpack_config = "packages/${{ matrix.worker }}/webpack.config.js"' | tee -a wrangler.toml
wrangler build
mv worker/script.js worker/${{ matrix.worker }}.js
if: steps.check.outputs.skip == '0' if: steps.check.outputs.skip == '0'
- id: upload-file - id: upload-file
if: github.event_name == 'push' && steps.check.outputs.skip == '0' if: github.event_name == 'push' && steps.check.outputs.skip == '0'
uses: google-github-actions/upload-cloud-storage@main uses: google-github-actions/upload-cloud-storage@main
with: with:
path: worker/${{ matrix.worker }}.js path: packages/api/dist/index.mjs
destination: roleypoly-artifacts/workers/${{ github.sha }} destination: roleypoly-artifacts/workers/${{ github.sha }}
credentials: ${{ secrets.GCS_TF_KEY }}
docker_build: docker_build:
name: Docker Build & Publish name: Docker Build & Publish
@ -166,7 +138,7 @@ jobs:
needs: needs:
- docker_build - docker_build
- worker_build - worker_build
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/miniflare'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Get Bot digest - name: Get Bot digest

View file

@ -25,10 +25,10 @@ jobs:
- uses: hashicorp/setup-terraform@v1.3.2 - uses: hashicorp/setup-terraform@v1.3.2
with: with:
terraform_version: ^1.0.1 terraform_version: ^1.1.4
- name: Set up Cloud SDK - name: Set up Cloud SDK
uses: google-github-actions/setup-gcloud@master uses: google-github-actions/setup-gcloud@v0
with: with:
project_id: ${{ secrets.GCS_PROJECT_ID }} project_id: ${{ secrets.GCS_PROJECT_ID }}
service_account_key: ${{ secrets.GCS_TF_KEY }} service_account_key: ${{ secrets.GCS_TF_KEY }}
@ -60,7 +60,7 @@ jobs:
working-directory: ./terraform working-directory: ./terraform
run: | run: |
echo \ echo \
'{"bot_tag": "${{github.event.inputs.bot_tag}}", "worker_tag": "${{github.event.inputs.worker_tag}}", "api_path_to_worker": "./worker-dist/api.js", "interactions_path_to_worker": "./worker-dist/interactions.js"}' \ '{"bot_tag": "${{github.event.inputs.bot_tag}}", "worker_tag": "${{github.event.inputs.worker_tag}}", "path_to_worker": "./worker-dist/index.mjs"}' \
| jq . \ | jq . \
| tee tags.auto.tfvars.json | tee tags.auto.tfvars.json

1
.gitignore vendored
View file

@ -4,6 +4,5 @@ node_modules
storybook-static storybook-static
.next .next
worker worker
wrangler.toml
.devdbs .devdbs
dist dist

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
16.13.2

View file

@ -6,3 +6,4 @@ worker
**/dist **/dist
terraform terraform
.husky/_ .husky/_
.mf

View file

@ -1,6 +1,16 @@
{ {
"extends": ["stylelint-config-standard", "stylelint-config-styled-components", "stylelint-prettier/recommended"], "customSyntax": "@stylelint/postcss-css-in-js",
"extends": [
"stylelint-config-recommended",
"stylelint-config-styled-components"
],
"rules": { "rules": {
"value-keyword-case": null "color-function-notation": "modern",
"shorthand-property-no-redundant-values": true,
"font-weight-notation": "numeric",
"alpha-value-notation": "percentage",
"hue-degree-notation": "angle",
"function-calc-no-unspaced-operator": true,
"length-zero-no-unit": true
} }
} }

View file

@ -41,12 +41,14 @@ This is the fastest way to start. You must be using MacOS or Linux (WSL2 is ok!)
- Setup `.env` using [`.env.example`][envexample] as a template and guide. - Setup `.env` using [`.env.example`][envexample] as a template and guide.
- When setting up your Discord Application, be sure to set `http://localhost:6609/login-callback` as the OAuth2 callback URL. - When setting up your Discord Application, be sure to set `http://localhost:6609/login-callback` as the OAuth2 callback URL.
- Run: `yarn install` - Run: `yarn install`
- Run both: `yarn start` - Run: `yarn start`
- This starts the Web UI, Storybook, and API servers in hot-reload dev/emulation mode. All changes to TS/TSX files should be properly captured and reloaded for you! - This starts the Web UI, Storybook, and API servers in hot-reload dev/emulation mode. All changes to TS/TSX files should be properly captured and reloaded for you!
- Develop you a Roleypoly! - Develop you a Roleypoly!
#### Option 3 🐄🤠: Wrangler (No emulation) #### Option 3 🐄🤠: Wrangler (No emulation)
**Outdated. This won't work, but could give you an idea of what to do.**
This is probably extremely painful and requires you to have a Cloudflare account. This is probably extremely painful and requires you to have a Cloudflare account.
- With pre-requisites: - With pre-requisites:
@ -93,26 +95,26 @@ Run:
- `yarn` to install deps - `yarn` to install deps
- `yarn start:design-system` to open storybook - `yarn start:design-system` to open storybook
- `yarn test` to test - `yarn test:design-system` to test
### Developing Web UI ### Developing Web UI
For working with the Next.js frontend components, use the below steps as reference. Code lives in `src/pages` among elsewhere. For working with the Next.js frontend components, use the below steps as reference. Code lives in `src/web` among elsewhere.
Run: Run:
- `yarn` to install deps - `yarn` to install deps
- `yarn start:web` to run Next.js dev server - `yarn start:web` to run Next.js dev server
- `yarn test` to test - `yarn test:web` to test
### Developing API Components ### Developing API Components
For working with the API, use the below steps as reference. Code lives in `src/backend-worker`. For working with the API, use the below steps as reference. Code lives in `src/api`.
Run: Run:
- `yarn` to install deps - `yarn` to install deps
- `yarn start:api` to start an emulated worker - `yarn start:api` to start an emulated worker
- `yarn test` to test - `yarn test:api` to test
[envexample]: .env.example [envexample]: .env.example

View file

@ -1,8 +1,4 @@
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
import Enzyme from 'enzyme';
import enableHooks from 'jest-react-hooks-shallow'; import enableHooks from 'jest-react-hooks-shallow';
Enzyme.configure({ adapter: new Adapter() });
// pass an instance of jest to `enableHooks()` // pass an instance of jest to `enableHooks()`
enableHooks(jest); enableHooks(jest);

View file

@ -18,55 +18,46 @@
], ],
"scripts": { "scripts": {
"build": "run-p -c build:*", "build": "run-p -c build:*",
"build:api": "yarn workspace @roleypoly/api run build",
"build:design-system": "yarn workspace @roleypoly/design-system run build", "build:design-system": "yarn workspace @roleypoly/design-system run build",
"build:web": "yarn workspace @roleypoly/web run build", "build:web": "yarn workspace @roleypoly/web run build",
"create-component": "yarn workspace @roleypoly/design-system run create-component", "create-component": "yarn workspace @roleypoly/design-system run create-component",
"lint": "run-p -c lint:* --", "lint": "run-p -c lint:* --",
"lint:eslint": "eslint", "lint:eslint": "eslint",
"lint:prettier": "cross-env prettier -c '**/*.{ts,tsx,css,yml,yaml,md,json,js,jsx,sh,gitignore,mdx,Dockerfile}'", "lint:prettier": "cross-env prettier -c '**/*.{ts,tsx,css,yml,yaml,md,json,js,jsx,sh,gitignore,mdx,Dockerfile}'",
"lint:stylelint": "cross-env stylelint '**/*.{ts,tsx}'", "lint:stylelint": "cross-env stylelint 'packages/{web,design-system}/**/*.{ts,tsx}'",
"lint:terraform": "terraform fmt -recursive -check ./terraform", "lint:terraform": "terraform fmt -check -recursive",
"lint:types": "tsc --noEmit", "lint:types": "tsc --noEmit",
"lint:types-api": "yarn workspace @roleypoly/api run lint:types", "lint:types-api": "yarn workspace @roleypoly/api run lint:types",
"lint:types-interactions": "yarn workspace @roleypoly/interactions run lint:types",
"lint:types-worker-utils": "yarn workspace @roleypoly/worker-utils run lint:types",
"postinstall": "is-ci || husky install", "postinstall": "is-ci || husky install",
"start": "run-p -c start:*", "start": "run-p -c start:*",
"start:api": "yarn workspace @roleypoly/api start",
"start:bot": "yarn workspace @roleypoly/bot start", "start:bot": "yarn workspace @roleypoly/bot start",
"start:design-system": "yarn workspace @roleypoly/design-system start", "start:design-system": "yarn workspace @roleypoly/design-system start",
"start:interactions": "yarn workspace @roleypoly/interactions start",
"start:web": "yarn workspace @roleypoly/web start", "start:web": "yarn workspace @roleypoly/web start",
"start:worker": "yarn workspace @roleypoly/api start", "test": "run-p -c test:* --",
"test": "jest" "test:api": "yarn workspace @roleypoly/api run test",
"test:design-system": "yarn workspace @roleypoly/design-system run test",
"test:misc-utils": "yarn workspace @roleypoly/misc-utils run test",
"test:web": "yarn workspace @roleypoly/web run test"
}, },
"devDependencies": { "devDependencies": {
"@types/enzyme": "^3.10.9", "@stylelint/postcss-css-in-js": "^0.37.2",
"@types/lodash": "^4.14.171", "husky": "^7.0.4",
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.2", "is-ci": "^3.0.1",
"enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.6",
"husky": "^7.0.1",
"is-ci": "^3.0.0",
"jest": "26.6.0",
"jest-enzyme": "^7.1.2",
"jest-react-hooks-shallow": "^1.5.1", "jest-react-hooks-shallow": "^1.5.1",
"jest-styled-components": "^7.0.4", "lint-staged": "^12.3.2",
"lint-staged": "^11.0.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prettier": "^2.3.2", "postcss": "^8.4.5",
"prettier-plugin-organize-imports": "^2.2.0", "postcss-syntax": "^0.36.2",
"prettier-plugin-pkg": "^0.10.0", "prettier": "^2.5.1",
"prettier-plugin-sh": "^0.7.1", "prettier-plugin-organize-imports": "^2.3.4",
"stylelint": "^13.13.1", "prettier-plugin-pkg": "^0.11.1",
"stylelint-config-prettier": "^8.0.2", "prettier-plugin-sh": "^0.8.1",
"stylelint-config-standard": "^22.0.0", "stylelint": "^14.3.0",
"stylelint-config-recommended": "^6.0.0",
"stylelint-config-styled-components": "^0.1.1", "stylelint-config-styled-components": "^0.1.1",
"stylelint-prettier": "^1.2.0", "typescript": "^4.5.5"
"ts-jest": "^26.0.0",
"typescript": "^4.3.5"
},
"resolutions": {
"webpack": "4.44.2"
}, },
"lint-staged": { "lint-staged": {
"*.{ts,tsx,js,jsx}": [ "*.{ts,tsx,js,jsx}": [

View file

@ -1 +1,2 @@
dist dist
.mf

1
packages/api/.nvmrc Normal file
View file

@ -0,0 +1 @@
17

View file

@ -1,14 +0,0 @@
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 ALLOWED_CALLBACK_HOSTS: string;
const KV_SESSIONS: KVNamespace;
const KV_GUILDS: KVNamespace;
const KV_GUILD_DATA: KVNamespace;
}

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) => {
event.respondWith(router.handle(event));
});

View file

@ -0,0 +1,11 @@
module.exports = {
preset: 'ts-jest/presets/default-esm',
name: 'api',
testEnvironment: 'miniflare',
globals: {
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.test.json',
useESM: true,
},
},
};

View file

@ -1,21 +1,30 @@
{ {
"name": "@roleypoly/api", "name": "@roleypoly/api",
"version": "0.1.0", "version": "0.1.0",
"license": "MIT",
"main": "./src/index.ts",
"scripts": { "scripts": {
"build": "yarn workspace @roleypoly/worker-emulator build --basePath `pwd`", "build": "yarn build:dev --minify",
"build:dev": "esbuild --bundle --sourcemap --platform=node --format=esm --outdir=dist --out-extension:.js=.mjs ./src/index.ts",
"lint:types": "tsc --noEmit", "lint:types": "tsc --noEmit",
"start": "cfw-emulator" "posttest": "rm .env",
"pretest": "cp ../../.env.example .env && yarn build",
"start": "miniflare --watch --debug",
"test": "jest"
}, },
"dependencies": {},
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^2.2.2", "@cloudflare/workers-types": "^3.3.1",
"@roleypoly/misc-utils": "*", "@roleypoly/misc-utils": "*",
"@roleypoly/types": "*", "@roleypoly/types": "*",
"@roleypoly/worker-emulator": "*", "@types/node": "^17.0.13",
"@roleypoly/worker-utils": "*", "esbuild": "^0.14.14",
"@types/deep-equal": "^1.0.1", "itty-router": "^2.4.10",
"deep-equal": "^2.0.5", "jest-environment-miniflare": "^2.2.0",
"ksuid": "^2.0.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"ts-loader": "^8.3.0" "miniflare": "^2.2.0",
"ts-jest": "^27.1.3",
"tweetnacl": "^1.0.3",
"ulid-workers": "^1.1.0"
} }
} }

View file

@ -0,0 +1,5 @@
it('works', () => {
expect(true).toBeTruthy();
});
export {};

View file

@ -0,0 +1 @@
export {};

View file

@ -0,0 +1,231 @@
jest.mock('../utils/discord');
jest.mock('../utils/legacy');
import { CategoryType, Features, GuildData } from '@roleypoly/types';
import { APIGuild, discordFetch } from '../utils/discord';
import {
fetchLegacyServer,
LegacyGuildData,
transformLegacyGuild,
} from '../utils/legacy';
import { configContext } from '../utils/testHelpers';
import { getGuild, getGuildData, getGuildMember } from './getters';
const mockDiscordFetch = discordFetch as jest.Mock;
const mockFetchLegacyServer = fetchLegacyServer as jest.Mock;
const mockTransformLegacyGuild = transformLegacyGuild as jest.Mock;
beforeEach(() => {
mockDiscordFetch.mockReset();
});
describe('getGuild', () => {
it('gets a guild from discord', async () => {
const [config] = configContext();
const guild: APIGuild = {
id: '123',
name: 'test',
icon: 'test',
roles: [],
};
mockDiscordFetch.mockReturnValue(guild);
const result = await getGuild(config, '123');
expect(result).toMatchObject(guild);
});
it('gets a guild from cache automatically', async () => {
const [config] = configContext();
const guild: APIGuild = {
id: '123',
name: 'test',
icon: 'test',
roles: [],
};
await config.kv.guilds.put('guild/123', guild, config.retention.guild);
mockDiscordFetch.mockReturnValue({ ...guild, name: 'test2' });
const result = await getGuild(config, '123');
expect(result).toMatchObject(guild);
expect(result!.name).toBe('test');
});
});
describe('getGuildData', () => {
it('gets guild data from store', async () => {
const [config] = configContext();
const guildData: GuildData = {
id: '123',
message: 'Hello world!',
categories: [],
features: Features.None,
auditLogWebhook: null,
accessControl: {
allowList: [],
blockList: [],
blockPending: true,
},
};
await config.kv.guildData.put('123', guildData);
const result = await getGuildData(config, '123');
expect(result).toMatchObject(guildData);
});
it('adds fields that are missing from the stored data', async () => {
const [config] = configContext();
const guildData: Partial<GuildData> = {
id: '123',
message: 'Hello world!',
categories: [],
features: Features.None,
};
await config.kv.guildData.put('123', guildData);
const result = await getGuildData(config, '123');
expect(result).toMatchObject({
...guildData,
auditLogWebhook: null,
accessControl: expect.any(Object),
});
});
describe('automatic legacy import', () => {
beforeEach(() => {
mockFetchLegacyServer.mockReset();
mockTransformLegacyGuild.mockImplementation(
jest.requireActual('../utils/legacy').transformLegacyGuild
);
});
it('attempts to import guild data from the legacy server', async () => {
const [config] = configContext();
const legacyGuildData: LegacyGuildData = {
id: '123',
message: 'Hello world!',
categories: [
{
id: '123',
name: 'test',
position: 0,
roles: ['role-1', 'role-2'],
hidden: false,
type: 'multi',
},
],
};
mockFetchLegacyServer.mockReturnValue(legacyGuildData);
const expectedGuildData: GuildData = {
id: '123',
message: legacyGuildData.message,
auditLogWebhook: null,
accessControl: {
allowList: [],
blockList: [],
blockPending: true,
},
features: Features.LegacyGuild,
categories: [
{
id: expect.any(String),
name: 'test',
position: 0,
roles: ['role-1', 'role-2'],
hidden: false,
type: CategoryType.Multi,
},
],
};
const currentGuildData = await getGuildData(config, '123');
expect(currentGuildData).toMatchObject(expectedGuildData);
const storedGuildData = await config.kv.guildData.get('123');
expect(storedGuildData).toMatchObject(expectedGuildData);
});
it('fails an import and saves new guild data instead', async () => {
const [config] = configContext();
mockFetchLegacyServer.mockReturnValue(null);
const expectedGuildData: GuildData = {
id: '123',
message: '',
auditLogWebhook: null,
accessControl: {
allowList: [],
blockList: [],
blockPending: true,
},
features: Features.None,
categories: [],
};
const currentGuildData = await getGuildData(config, '123');
expect(currentGuildData).toMatchObject(expectedGuildData);
const storedGuildData = await config.kv.guildData.get('123');
expect(storedGuildData).toMatchObject(expectedGuildData);
});
it('fails an import and prevents re-fetch', async () => {
const [config] = configContext();
mockFetchLegacyServer.mockReturnValue(null);
await getGuildData(config, '123');
await getGuildData(config, '123');
expect(mockFetchLegacyServer).toHaveBeenCalledTimes(1);
});
});
});
describe('getGuildMember', () => {
it('gets a member from discord', async () => {
const [config] = configContext();
const member = {
roles: [],
pending: false,
nick: 'test',
};
mockDiscordFetch.mockReturnValue(member);
const result = await getGuildMember(config, '123', '123');
expect(result).toMatchObject(member);
});
it('gets a member from cache automatically', async () => {
const [config] = configContext();
const member = {
roles: [],
pending: false,
nick: 'test2',
};
await config.kv.guilds.put('member/123/123', member, config.retention.guild);
mockDiscordFetch.mockReturnValue({ ...member, nick: 'test' });
const result = await getGuildMember(config, '123', '123');
expect(result).toMatchObject(member);
expect(result!.nick).toBe('test2');
});
});

View file

@ -0,0 +1,196 @@
import { Config } from '@roleypoly/api/src/utils/config';
import {
APIGuild,
APIMember,
APIRole,
AuthType,
discordFetch,
getHighestRole,
} from '@roleypoly/api/src/utils/discord';
import { fetchLegacyServer, transformLegacyGuild } from '@roleypoly/api/src/utils/legacy';
import { evaluatePermission, permissions } from '@roleypoly/misc-utils/hasPermission';
import {
Features,
Guild,
GuildData,
Member,
OwnRoleInfo,
Role,
RoleSafety,
} from '@roleypoly/types';
export const getGuild = async (
config: Config,
id: string,
forceMiss?: boolean
): Promise<(Guild & OwnRoleInfo) | null> =>
config.kv.guilds.cacheThrough(
`guild/${id}`,
async () => {
const guildRaw = await discordFetch<APIGuild>(
`/guilds/${id}`,
config.botToken,
AuthType.Bot
);
if (!guildRaw) {
return null;
}
const botMemberRoles =
(await getGuildMember(config, id, config.botClientID))?.roles || [];
const highestRolePosition =
getHighestRole(
botMemberRoles
.map((r) => guildRaw.roles.find((r2) => r2.id === r))
.filter((x) => x !== undefined) as APIRole[]
)?.position || -1;
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),
}));
const guild: Guild & OwnRoleInfo = {
id,
name: guildRaw.name,
icon: guildRaw.icon,
roles,
highestRolePosition,
};
return guild;
},
config.retention.guild,
forceMiss
);
export const getGuildData = async (config: Config, id: string): Promise<GuildData> => {
const guildData = await config.kv.guildData.get<GuildData>(id);
const empty = {
id,
message: '',
categories: [],
features: Features.None,
auditLogWebhook: null,
accessControl: {
allowList: [],
blockList: [],
blockPending: true,
},
};
if (!guildData) {
// It's rare for no guild data to exist while also having a guild.
// It's either an actually new guild... or could be imported.
// Let's attempt the import...
const legacyData = await attemptLegacyImport(config, id);
if (legacyData) {
return {
...empty,
...legacyData,
};
}
// So we don't try again, let's set the data.
await config.kv.guildData.put(id, empty);
return empty;
}
return {
...empty,
...guildData,
};
};
export const attemptLegacyImport = async (
config: Config,
id: string
): Promise<GuildData | null> => {
const legacyGuildData = await fetchLegacyServer(config, id);
if (!legacyGuildData) {
// Means there is no legacy data.
return null;
}
const transformed = transformLegacyGuild(legacyGuildData);
await config.kv.guildData.put(id, transformed);
return transformed;
};
export const getGuildMember = async (
config: Config,
serverID: string,
userID: string,
forceMiss?: boolean,
overrideRetention?: number // allows for own-member to be cached as long as it's used.
): Promise<Member | null> =>
config.kv.guilds.cacheThrough(
`member/${serverID}/${userID}`,
async () => {
const discordMember = await discordFetch<APIMember>(
`/guilds/${serverID}/members/${userID}`,
config.botToken,
AuthType.Bot
);
if (!discordMember) {
return null;
}
return {
guildid: serverID,
roles: discordMember.roles,
pending: discordMember.pending,
nick: discordMember.nick,
};
},
overrideRetention || config.retention.member,
forceMiss
);
export const updateGuildMember = async (
config: Config,
serverID: string,
member: APIMember
): Promise<void> => {
config.kv.guilds.put(
`members/${serverID}/${member.user.id}`,
{
guildid: serverID,
roles: member.roles,
pending: member.pending,
nick: member.nick,
},
config.retention.member
);
};
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;
};

View file

@ -0,0 +1,78 @@
import { Router } from 'itty-router';
import { json } from '../utils/response';
import { configContext, makeSession } from '../utils/testHelpers';
import { requireEditor } from './middleware';
describe('requireEditor', () => {
it('continues the request when user is an editor', async () => {
const testFn = jest.fn();
const [config, context] = configContext();
const session = await makeSession(config);
const router = Router();
router.all('*', requireEditor).get('/:guildId', (request, context) => {
testFn();
return json({});
});
const response = await router.handle(
new Request(`http://test.local/${session.guilds[1].id}`, {
headers: {
authorization: `Bearer ${session.sessionID}`,
},
}),
{ ...context, session, params: { guildId: session.guilds[1].id } }
);
expect(response.status).toBe(200);
expect(testFn).toHaveBeenCalledTimes(1);
});
it('403s the request when user is not an editor', async () => {
const testFn = jest.fn();
const [config, context] = configContext();
const session = await makeSession(config);
const router = Router();
router.all('*', requireEditor).get('/:guildId', (request, context) => {
testFn();
return json({});
});
const response = await router.handle(
new Request(`http://test.local/${session.guilds[0].id}`, {
headers: {
authorization: `Bearer ${session.sessionID}`,
},
}),
{ ...context, session, params: { guildId: session.guilds[0].id } }
);
expect(response.status).toBe(403);
expect(testFn).not.toHaveBeenCalled();
});
it('404s the request when the guild isnt in session', async () => {
const testFn = jest.fn();
const [config, context] = configContext();
const session = await makeSession(config);
const router = Router();
router.all('*', requireEditor).get('/:guildId', (request, context) => {
testFn();
return json({});
});
const response = await router.handle(
new Request(`http://test.local/invalid-session-id`, {
headers: {
authorization: `Bearer ${session.sessionID}`,
},
}),
{ ...context, session, params: { guildId: 'invalid-session-id' } }
);
expect(response.status).toBe(404);
expect(testFn).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,47 @@
import { Context, RoleypolyMiddleware } from '@roleypoly/api/src/utils/context';
import {
engineeringProblem,
forbidden,
notFound,
} from '@roleypoly/api/src/utils/response';
import { UserGuildPermissions } from '@roleypoly/types';
export const requireEditor: RoleypolyMiddleware = async (
request: Request,
context: Context
) => {
if (!context.params.guildId) {
return engineeringProblem('params not set up correctly');
}
if (!context.session) {
return engineeringProblem('middleware not set up correctly');
}
const guild = context.session.guilds.find((g) => g.id === context.params.guildId);
if (!guild) {
return notFound(); // 404 because we don't want enumeration of guilds
}
if (guild.permissionLevel === UserGuildPermissions.User) {
return forbidden();
}
};
export const requireMember: RoleypolyMiddleware = async (
request: Request,
context: Context
) => {
if (!context.params.guildId) {
return engineeringProblem('params not set up correctly');
}
if (!context.session) {
return engineeringProblem('middleware not set up correctly');
}
const guild = context.session.guilds.find((g) => g.id === context.params.guildId);
if (!guild) {
return notFound(); // 404 because we don't want enumeration of guilds
}
};

99
packages/api/src/index.ts Normal file
View file

@ -0,0 +1,99 @@
import { requireEditor, requireMember } from '@roleypoly/api/src/guilds/middleware';
import { authBot } from '@roleypoly/api/src/routes/auth/bot';
import { authCallback } from '@roleypoly/api/src/routes/auth/callback';
import { authSessionDelete } from '@roleypoly/api/src/routes/auth/delete-session';
import { authSession } from '@roleypoly/api/src/routes/auth/session';
import { guildsGuild } from '@roleypoly/api/src/routes/guilds/guild';
import { guildsCacheDelete } from '@roleypoly/api/src/routes/guilds/guild-cache-delete';
import { guildsRolesPut } from '@roleypoly/api/src/routes/guilds/guild-roles-put';
import { guildsGuildPatch } from '@roleypoly/api/src/routes/guilds/guilds-patch';
import { guildsSlug } from '@roleypoly/api/src/routes/guilds/slug';
import { handleInteraction } from '@roleypoly/api/src/routes/interactions/interactions';
import {
requireSession,
withAuthMode,
withSession,
} from '@roleypoly/api/src/sessions/middleware';
import { injectParams } from '@roleypoly/api/src/utils/request';
import { Router } from 'itty-router';
import { authBounce } from './routes/auth/bounce';
import { Environment, parseEnvironment } from './utils/config';
import { Context, RoleypolyHandler } from './utils/context';
import { corsHeaders, json, notFound, serverError } from './utils/response';
const router = Router();
router.all('*', withAuthMode);
router.get('/auth/bot', authBot);
router.get('/auth/bounce', authBounce);
router.get('/auth/callback', authCallback);
router.get('/auth/session', withSession, requireSession, authSession);
router.delete('/auth/session', withSession, requireSession, authSessionDelete);
const guildsCommon = [injectParams, withSession, requireSession, requireMember];
router.get('/guilds/:guildId', ...guildsCommon, guildsGuild);
router.patch('/guilds/:guildId', ...guildsCommon, requireEditor, guildsGuildPatch);
router.delete(
'/guilds/:guildId/cache',
...guildsCommon,
requireEditor,
guildsCacheDelete
);
router.put('/guilds/:guildId/roles', ...guildsCommon, guildsRolesPut);
router.get('/guilds/:guildId/slug', injectParams, withSession, guildsSlug);
router.post('/interactions', handleInteraction);
router.get('/', ((request: Request, { config }: Context) =>
json({
__warning: '🦊',
this: 'is',
a: 'fox-based',
web: 'application',
please: 'be',
mindful: 'of',
your: 'surroundings',
warning__: '🦊',
meta: config.uiPublicURI,
version: 2,
})) as RoleypolyHandler);
router.options('*', (request: Request) => {
return new Response(null, {
headers: {
...corsHeaders,
},
});
});
router.all('/*', notFound);
const scrubURL = (urlStr: string) => {
const url = new URL(urlStr);
url.searchParams.delete('code');
url.searchParams.delete('state');
return url.toString();
};
export default {
async fetch(request: Request, env: Environment, event: Context['fetchContext']) {
const config = parseEnvironment(env);
const context: Context = {
config,
fetchContext: {
waitUntil: event.waitUntil.bind(event),
},
authMode: {
type: 'anonymous',
},
params: {},
};
console.log(`${request.method} ${scrubURL(request.url)}`);
return router
.handle(request, context)
.catch((e: Error) => (!e ? notFound() : serverError(e)));
},
};

View file

@ -0,0 +1,25 @@
import { makeRequest } from '../../utils/testHelpers';
describe('GET /auth/bot', () => {
it('redirects to a Discord OAuth bot flow url', async () => {
const response = await makeRequest('GET', '/auth/bot', undefined, {
BOT_CLIENT_ID: 'test123',
});
expect(response.status).toBe(303);
expect(response.headers.get('Location')).toContain(
'https://discord.com/api/oauth2/authorize?client_id=test123&scope=bot%20applications.commands&permissions=268435456'
);
});
it('redirects to a Discord OAuth bot flow url, forcing a guild when set', async () => {
const response = await makeRequest('GET', '/auth/bot?guild=123456', undefined, {
BOT_CLIENT_ID: 'test123',
});
expect(response.status).toBe(303);
expect(response.headers.get('Location')).toContain(
'https://discord.com/api/oauth2/authorize?client_id=test123&scope=bot%20applications.commands&permissions=268435456&guild_id=123456&disable_guild_select=true'
);
});
});

View file

@ -1,5 +1,5 @@
import { Bounce } from '../utils/bounce'; import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
import { botClientID } from '../utils/config'; import { seeOther } from '@roleypoly/api/src/utils/response';
const validGuildID = /^[0-9]+$/; const validGuildID = /^[0-9]+$/;
@ -22,17 +22,20 @@ const buildURL = (params: URLParams) => {
return url; return url;
}; };
export const BotJoin = (request: Request): Response => { export const authBot: RoleypolyHandler = (
request: Request,
{ config }: Context
): Response => {
let guildID = new URL(request.url).searchParams.get('guild') || ''; let guildID = new URL(request.url).searchParams.get('guild') || '';
if (guildID && !validGuildID.test(guildID)) { if (guildID && !validGuildID.test(guildID)) {
guildID = ''; guildID = '';
} }
return Bounce( return seeOther(
buildURL({ buildURL({
clientID: botClientID, clientID: config.botClientID,
permissions: 268435456, permissions: 268435456, // Send messages + manage roles
guildID, guildID,
scopes: ['bot', 'applications.commands'], scopes: ['bot', 'applications.commands'],
}) })

View file

@ -0,0 +1,54 @@
import { StateSession } from '@roleypoly/types';
import { getBindings, makeRequest } from '../../utils/testHelpers';
describe('GET /auth/bounce', () => {
it('should return a redirect to Discord OAuth', async () => {
const response = await makeRequest('GET', '/auth/bounce', undefined, {
BOT_CLIENT_ID: 'test123',
API_PUBLIC_URI: 'http://test.local/',
});
expect(response.status).toBe(303);
expect(response.headers.get('Location')).toContain(
'https://discord.com/api/oauth2/authorize?client_id=test123&response_type=code&scope=identify%20guilds&prompt=none&redirect_uri=http%3A%2F%2Ftest.local%2Fauth%2Fcallback&state='
);
});
it('should store a state-session', async () => {
const response = await makeRequest('GET', '/auth/bounce', undefined, {
BOT_CLIENT_ID: 'test123',
API_PUBLIC_URI: 'http://test.local/',
});
expect(response.status).toBe(303);
const url = new URL(response.headers.get('Location') || '');
const state = url.searchParams.get('state');
const environment = getBindings();
const session = await environment.KV_SESSIONS.get(`state_${state}`, 'json');
expect(session).not.toBeUndefined();
});
test.each([
['http://web.test.local', 'http://web.test.local', 'http://web.test.local'],
['http://*.test.local', 'http://web.test.local', 'http://web.test.local'],
['http://other.test.local', 'http://web.test.local', undefined],
])(
'should process callback hosts when set to %s',
async (allowlist, input, expected) => {
const response = await makeRequest('GET', `/auth/bounce?cbh=${input}`, undefined, {
BOT_CLIENT_ID: 'test123',
API_PUBLIC_URI: 'http://api.test.local',
ALLOWED_CALLBACK_HOSTS: allowlist,
});
expect(response.status).toBe(303);
const url = new URL(response.headers.get('Location') || '');
const state = url.searchParams.get('state');
const environment = getBindings();
const session = (await environment.KV_SESSIONS.get(`state_${state}`, 'json')) as {
data: StateSession;
};
expect(session).not.toBeUndefined();
expect(session?.data.callbackHost).toBe(expected);
}
);
});

View file

@ -0,0 +1,64 @@
import { setupStateSession } from '@roleypoly/api/src/sessions/state';
import { Config } from '@roleypoly/api/src/utils/config';
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
import { getQuery } from '@roleypoly/api/src/utils/request';
import { seeOther } from '@roleypoly/api/src/utils/response';
import { StateSession } from '@roleypoly/types';
type URLParams = {
clientID: string;
redirectURI: string;
state: string;
};
export 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}`;
const hostMatch = (a: string, b: string): boolean => {
const aURL = new URL(a);
const bURL = new URL(b);
return aURL.host === bURL.host && aURL.protocol === bURL.protocol;
};
const wildcardMatch = (wildcard: string, host: string): boolean => {
const aURL = new URL(wildcard);
const bURL = new URL(host);
const regex = new RegExp(aURL.hostname.replace('*', '[a-z0-9-]+'));
return regex.test(bURL.hostname);
};
export const isAllowedCallbackHost = (config: Config, host: string): boolean => {
return (
hostMatch(host, config.apiPublicURI) ||
config.allowedCallbackHosts.some((allowedHost) =>
allowedHost.includes('*')
? wildcardMatch(allowedHost, host)
: hostMatch(allowedHost, host)
)
);
};
export const authBounce: RoleypolyHandler = async (
request: Request,
{ config }: Context
) => {
const stateSessionData: StateSession = {};
const { cbh: callbackHost } = getQuery(request);
if (callbackHost && isAllowedCallbackHost(config, callbackHost)) {
stateSessionData.callbackHost = callbackHost;
}
const state = await setupStateSession(config, stateSessionData);
const redirectURI = `${config.apiPublicURI}/auth/callback`;
const clientID = config.botClientID;
return seeOther(buildURL({ state, redirectURI, clientID }));
};

View file

@ -0,0 +1,93 @@
jest.mock('../../utils/discord');
jest.mock('../../sessions/create');
import { createSession } from '../../sessions/create';
import { setupStateSession } from '../../sessions/state';
import { parseEnvironment } from '../../utils/config';
import { discordFetch } from '../../utils/discord';
import { getBindings, makeRequest } from '../../utils/testHelpers';
const mockDiscordFetch = discordFetch as jest.Mock;
const mockCreateSession = createSession as jest.Mock;
describe('GET /auth/callback', () => {
it('should ask Discord to trade code for tokens', async () => {
const env = getBindings();
const config = parseEnvironment(env);
const stateID = await setupStateSession(config, {});
const tokens = {
access_token: 'test-access-token',
refresh_token: 'test-refresh-token',
expires_in: 3600,
scope: 'identify guilds',
token_type: 'Bearer',
};
mockDiscordFetch.mockReturnValueOnce(tokens);
mockCreateSession.mockReturnValueOnce({
sessionID: 'test-session-id',
tokens,
user: {
id: 'test-user-id',
username: 'test-username',
discriminator: 'test-discriminator',
avatar: 'test-avatar',
bot: false,
},
guilds: [],
});
const response = await makeRequest(
'GET',
`/auth/callback?state=${stateID}&code=1234`,
undefined,
{
BOT_CLIENT_ID: 'test123',
BOT_CLIENT_SECRET: 'test456',
API_PUBLIC_URI: 'http://test.local/',
UI_PUBLIC_URI: 'http://web.test.local/',
}
);
expect(response.status).toBe(303);
expect(mockDiscordFetch).toBeCalledTimes(1);
expect(mockCreateSession).toBeCalledWith(expect.any(Object), tokens);
expect(response.headers.get('Location')).toContain(
'http://web.test.local/machinery/new-session/test-session-id'
);
});
it('will fail if state is invalid', async () => {
const response = await makeRequest(
'GET',
`/auth/callback?state=invalid-state&code=1234`,
undefined,
{
BOT_CLIENT_ID: 'test123',
BOT_CLIENT_SECRET: 'test456',
API_PUBLIC_URI: 'http://test.local/',
UI_PUBLIC_URI: 'http://web.test.local/',
}
);
expect(response.status).toBe(303);
expect(response.headers.get('Location')).toContain(
'http://web.test.local/machinery/error?error_code=authFailure&extra=state invalid'
);
});
it('will fail if state is missing', async () => {
const response = await makeRequest('GET', `/auth/callback?code=1234`, undefined, {
BOT_CLIENT_ID: 'test123',
BOT_CLIENT_SECRET: 'test456',
API_PUBLIC_URI: 'http://test.local/',
UI_PUBLIC_URI: 'http://web.test.local/',
});
expect(response.status).toBe(303);
expect(response.headers.get('Location')).toContain(
'http://web.test.local/machinery/error?error_code=authFailure&extra=state invalid'
);
});
});

View file

@ -0,0 +1,76 @@
import { isAllowedCallbackHost } from '@roleypoly/api/src/routes/auth/bounce';
import { createSession } from '@roleypoly/api/src/sessions/create';
import { getStateSession } from '@roleypoly/api/src/sessions/state';
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
import { AuthType, discordFetch } from '@roleypoly/api/src/utils/discord';
import { dateFromID } from '@roleypoly/api/src/utils/id';
import { formDataRequest, getQuery } from '@roleypoly/api/src/utils/request';
import { seeOther } from '@roleypoly/api/src/utils/response';
import { AuthTokenResponse, StateSession } from '@roleypoly/types';
const authFailure = (uiPublicURI: string, extra?: string) =>
seeOther(
uiPublicURI +
`/machinery/error?error_code=authFailure${extra ? `&extra=${extra}` : ''}`
);
export const authCallback: RoleypolyHandler = async (
request: Request,
{ config }: Context
) => {
let bounceBaseUrl = config.uiPublicURI;
const { state: stateValue, code } = getQuery(request);
if (stateValue === null) {
return authFailure('state missing');
}
try {
const stateTime = dateFromID(stateValue);
const stateExpiry = stateTime + 1000 * config.retention.session;
const currentTime = Date.now();
if (currentTime > stateExpiry) {
return authFailure('state expired');
}
const stateSession = await getStateSession<StateSession>(config, stateValue);
if (
stateSession?.callbackHost &&
isAllowedCallbackHost(config, stateSession.callbackHost)
) {
bounceBaseUrl = stateSession.callbackHost;
}
} catch (e) {
return authFailure(config.uiPublicURI, 'state invalid');
}
if (!code) {
return authFailure(config.uiPublicURI, 'code missing');
}
const response = await discordFetch<AuthTokenResponse>(
`/oauth2/token`,
'',
AuthType.None,
formDataRequest({
client_id: config.botClientID,
client_secret: config.botClientSecret,
grant_type: 'authorization_code',
code,
redirect_uri: config.apiPublicURI + '/auth/callback',
})
);
if (!response) {
return authFailure(config.uiPublicURI, 'code auth failure');
}
const session = await createSession(config, response);
if (!session) {
return authFailure(config.uiPublicURI, 'session setup failure');
}
return seeOther(bounceBaseUrl + '/machinery/new-session/' + session.sessionID);
};

View file

@ -0,0 +1,63 @@
jest.mock('../../utils/discord');
import { SessionData } from '@roleypoly/types';
import { parseEnvironment } from '../../utils/config';
import { AuthType, discordFetch } from '../../utils/discord';
import { formDataRequest } from '../../utils/request';
import { getBindings, makeRequest } from '../../utils/testHelpers';
const mockDiscordFetch = discordFetch as jest.Mock;
describe('DELETE /auth/session', () => {
it('deletes the current user session when it is valid', async () => {
const config = parseEnvironment(getBindings());
const session: SessionData = {
sessionID: 'test-session-id',
user: {
id: 'test-user-id',
username: 'test-username',
discriminator: 'test-discriminator',
avatar: 'test-avatar',
bot: false,
},
guilds: [],
tokens: {
access_token: 'test-access-token',
refresh_token: 'test-refresh-token',
expires_in: 3600,
scope: 'identify guilds',
token_type: 'Bearer',
},
};
await config.kv.sessions.put(session.sessionID, session);
mockDiscordFetch.mockReturnValue(
new Response(null, {
status: 200,
})
);
const response = await makeRequest('DELETE', '/auth/session', {
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
});
expect(response.status).toBe(204);
expect(await config.kv.sessions.get(session.sessionID)).toBeNull();
expect(mockDiscordFetch).toHaveBeenCalledWith(
'/oauth2/token/revoke',
'',
AuthType.None,
expect.objectContaining(
formDataRequest({
client_id: config.botClientID,
client_secret: config.botClientSecret,
token: session.tokens.access_token,
})
)
);
});
});

View file

@ -0,0 +1,27 @@
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
import { AuthType, discordFetch } from '@roleypoly/api/src/utils/discord';
import { formDataRequest } from '@roleypoly/api/src/utils/request';
import { noContent } from '@roleypoly/api/src/utils/response';
export const authSessionDelete: RoleypolyHandler = async (
request: Request,
context: Context
) => {
if (!context.session) {
return noContent();
}
await discordFetch(
'/oauth2/token/revoke',
'',
AuthType.None,
formDataRequest({
client_id: context.config.botClientID,
client_secret: context.config.botClientSecret,
token: context.session.tokens.access_token,
})
);
await context.config.kv.sessions.delete(context.session.sessionID);
return noContent();
};

View file

@ -0,0 +1,53 @@
import { SessionData } from '@roleypoly/types';
import { parseEnvironment } from '../../utils/config';
import { getBindings, makeRequest } from '../../utils/testHelpers';
describe('GET /auth/session', () => {
it('fetches the current user session when it is valid', async () => {
const config = parseEnvironment(getBindings());
const session: SessionData = {
sessionID: 'test-session-id',
user: {
id: 'test-user-id',
username: 'test-username',
discriminator: 'test-discriminator',
avatar: 'test-avatar',
bot: false,
},
guilds: [],
tokens: {
access_token: 'test-access-token',
refresh_token: 'test-refresh-token',
expires_in: 3600,
scope: 'identify guilds',
token_type: 'Bearer',
},
};
await config.kv.sessions.put(session.sessionID, session);
const response = await makeRequest('GET', '/auth/session', {
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
});
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
sessionID: session.sessionID,
user: session.user,
guilds: session.guilds,
});
});
it('returns 401 when session is not valid', async () => {
const response = await makeRequest('GET', '/auth/session', {
headers: {
Authorization: `Bearer invalid-session-id`,
},
});
expect(response.status).toBe(401);
});
});

View file

@ -0,0 +1,17 @@
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
import { json, notFound } from '@roleypoly/api/src/utils/response';
export const authSession: RoleypolyHandler = async (
request: Request,
context: Context
) => {
if (context.session) {
return json({
user: context.session.user,
guilds: context.session.guilds,
sessionID: context.session.sessionID,
});
}
return notFound();
};

View file

@ -0,0 +1,32 @@
jest.mock('../../guilds/getters');
import { UserGuildPermissions } from '@roleypoly/types';
import { getGuild } from '../../guilds/getters';
import { configContext, makeRequest, makeSession } from '../../utils/testHelpers';
const mockGetGuild = getGuild as jest.Mock;
describe('DELETE /guilds/:id/cache', () => {
it('calls getGuilds and returns No Content', async () => {
const [config] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: UserGuildPermissions.Admin,
},
],
});
const response = await makeRequest('DELETE', `/guilds/123/cache`, {
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
});
expect(response.status).toBe(204);
expect(mockGetGuild).toHaveBeenCalledWith(expect.any(Object), '123', true);
});
});

View file

@ -0,0 +1,12 @@
import { getGuild } from '@roleypoly/api/src/guilds/getters';
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
import { noContent } from '@roleypoly/api/src/utils/response';
export const guildsCacheDelete: RoleypolyHandler = async (
request: Request,
context: Context
) => {
await getGuild(context.config, context.params.guildId!, true);
return noContent();
};

View file

@ -0,0 +1,370 @@
jest.mock('../../guilds/getters');
jest.mock('../../utils/discord');
import {
CategoryType,
Features,
Guild,
GuildData,
Member,
OwnRoleInfo,
RoleSafety,
RoleUpdate,
TransactionType,
} from '@roleypoly/types';
import { getGuild, getGuildData, getGuildMember } from '../../guilds/getters';
import { AuthType, discordFetch } from '../../utils/discord';
import { json } from '../../utils/response';
import { configContext, makeRequest, makeSession } from '../../utils/testHelpers';
const mockDiscordFetch = discordFetch as jest.Mock;
const mockGetGuild = getGuild as jest.Mock;
const mockGetGuildMember = getGuildMember as jest.Mock;
const mockGetGuildData = getGuildData as jest.Mock;
beforeEach(() => {
jest.resetAllMocks();
doMock();
});
describe('PUT /guilds/:id/roles', () => {
it('adds member roles when called with valid roles', async () => {
const [config] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: 0,
},
],
});
const update: RoleUpdate = {
knownState: ['role-1'],
transactions: [{ id: 'role-2', action: TransactionType.Add }],
};
mockDiscordFetch.mockReturnValueOnce(
json({
roles: ['role-1', 'role-2'],
})
);
const response = await makeRequest(
'PUT',
`/guilds/123/roles`,
{
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
body: JSON.stringify(update),
},
{
BOT_TOKEN: 'test',
}
);
expect(response.status).toBe(200);
expect(mockDiscordFetch).toHaveBeenCalledWith(
`/guilds/123/members/${session.user.id}`,
'test',
AuthType.Bot,
{
body: JSON.stringify({
roles: ['role-1', 'role-2'],
}),
headers: {
'content-type': 'application/json',
'x-audit-log-reason': `Picked their roles via ${config.uiPublicURI}`,
},
method: 'PATCH',
}
);
});
it('removes member roles when called with valid roles', async () => {
const [config] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: 0,
},
],
});
const update: RoleUpdate = {
knownState: ['role-1'],
transactions: [{ id: 'role-1', action: TransactionType.Remove }],
};
mockDiscordFetch.mockReturnValueOnce(
json({
roles: [],
})
);
const response = await makeRequest(
'PUT',
`/guilds/123/roles`,
{
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
body: JSON.stringify(update),
},
{
BOT_TOKEN: 'test',
}
);
expect(response.status).toBe(200);
expect(mockDiscordFetch).toHaveBeenCalledWith(
`/guilds/123/members/${session.user.id}`,
'test',
AuthType.Bot,
{
body: JSON.stringify({
roles: [],
}),
headers: {
'content-type': 'application/json',
'x-audit-log-reason': `Picked their roles via ${config.uiPublicURI}`,
},
method: 'PATCH',
}
);
});
it('does not update roles when called only with invalid roles', async () => {
const [config] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: 0,
},
],
});
const update: RoleUpdate = {
knownState: ['role-1'],
transactions: [
{ id: 'role-3', action: TransactionType.Add }, // role is in a hidden category
{ id: 'role-5-unsafe', action: TransactionType.Add }, // role is marked unsafe
],
};
const response = await makeRequest(
'PUT',
`/guilds/123/roles`,
{
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
body: JSON.stringify(update),
},
{
BOT_TOKEN: 'test',
}
);
expect(response.status).toBe(400);
expect(mockDiscordFetch).not.toHaveBeenCalled();
});
it('filters roles that are invalid while accepting ones that are valid', async () => {
const [config] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: 0,
},
],
});
const update: RoleUpdate = {
knownState: ['role-1'],
transactions: [
{ id: 'role-3', action: TransactionType.Add }, // role is in a hidden category
{ id: 'role-2', action: TransactionType.Add }, // role is in a hidden category
],
};
const response = await makeRequest(
'PUT',
`/guilds/123/roles`,
{
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
body: JSON.stringify(update),
},
{
BOT_TOKEN: 'test',
}
);
expect(response.status).toBe(200);
expect(mockDiscordFetch).toHaveBeenCalledWith(
`/guilds/123/members/${session.user.id}`,
'test',
AuthType.Bot,
{
body: JSON.stringify({
roles: ['role-1', 'role-2'],
}),
headers: {
'content-type': 'application/json',
'x-audit-log-reason': `Picked their roles via ${config.uiPublicURI}`,
},
method: 'PATCH',
}
);
});
it('400s when no transactions are present', async () => {
const [config] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: 0,
},
],
});
const update: RoleUpdate = {
knownState: ['role-1'],
transactions: [],
};
const response = await makeRequest(
'PUT',
`/guilds/123/roles`,
{
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
body: JSON.stringify(update),
},
{
BOT_TOKEN: 'test',
}
);
expect(response.status).toBe(400);
expect(mockDiscordFetch).not.toHaveBeenCalled();
expect(mockGetGuild).not.toHaveBeenCalled();
expect(mockGetGuildData).not.toHaveBeenCalled();
expect(mockGetGuildMember).not.toHaveBeenCalled();
});
});
const doMock = () => {
const guild: Guild & OwnRoleInfo = {
id: '123',
name: 'test',
icon: 'test',
highestRolePosition: 0,
roles: [
{
id: 'role-1',
name: 'Role 1',
color: 0,
position: 17,
permissions: '',
managed: false,
safety: RoleSafety.Safe,
},
{
id: 'role-2',
name: 'Role 2',
color: 0,
position: 16,
permissions: '',
managed: false,
safety: RoleSafety.Safe,
},
{
id: 'role-3',
name: 'Role 3',
color: 0,
position: 15,
permissions: '',
managed: false,
safety: RoleSafety.Safe,
},
{
id: 'role-4',
name: 'Role 4',
color: 0,
position: 14,
permissions: '',
managed: false,
safety: RoleSafety.Safe,
},
{
id: 'role-5-unsafe',
name: 'Role 5 (Unsafe)',
color: 0,
position: 14,
permissions: '',
managed: false,
safety: RoleSafety.DangerousPermissions,
},
],
};
const member: Member = {
roles: ['role-1'],
pending: false,
nick: '',
};
const guildData: GuildData = {
id: '123',
message: 'test',
categories: [
{
id: 'category-1',
name: 'Category 1',
position: 0,
hidden: false,
type: CategoryType.Multi,
roles: ['role-1', 'role-2'],
},
{
id: 'category-2',
name: 'Category 2',
position: 1,
hidden: true,
type: CategoryType.Multi,
roles: ['role-3'],
},
],
features: Features.None,
auditLogWebhook: null,
accessControl: {
allowList: [],
blockList: [],
blockPending: false,
},
};
mockGetGuild.mockReturnValue(guild);
mockGetGuildMember.mockReturnValue(member);
mockGetGuildData.mockReturnValue(guildData);
mockDiscordFetch.mockReturnValue(json({}));
};

View file

@ -0,0 +1,160 @@
import {
getGuild,
getGuildData,
getGuildMember,
updateGuildMember,
} from '@roleypoly/api/src/guilds/getters';
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
import { APIMember, AuthType, discordFetch } from '@roleypoly/api/src/utils/discord';
import {
engineeringProblem,
invalid,
json,
notFound,
serverError,
} from '@roleypoly/api/src/utils/response';
import {
difference,
isIdenticalArray,
keyBy,
union,
} from '@roleypoly/misc-utils/collection-tools';
import {
GuildData,
Member,
Role,
RoleSafety,
RoleTransaction,
RoleUpdate,
TransactionType,
} from '@roleypoly/types';
export const guildsRolesPut: RoleypolyHandler = async (
request: Request,
context: Context
) => {
if (!request.body) {
return invalid();
}
const updateRequest: RoleUpdate = await request.json();
if (updateRequest.transactions.length === 0) {
return invalid();
}
const guildID = context.params.guildId;
if (!guildID) {
return engineeringProblem('params not set up correctly');
}
const userID = context.session!.user.id;
const [member, guildData, guild] = await Promise.all([
getGuildMember(context.config, guildID, userID),
getGuildData(context.config, guildID),
getGuild(context.config, guildID),
]);
if (!guild || !member) {
return notFound();
}
const newRoles = calculateNewRoles({
currentRoles: member.roles,
guildRoles: guild.roles,
guildData,
updateRequest,
});
if (
isIdenticalArray(member.roles, newRoles) ||
isIdenticalArray(updateRequest.knownState, newRoles)
) {
return invalid();
}
const patchMemberRoles = await discordFetch<APIMember>(
`/guilds/${guildID}/members/${userID}`,
context.config.botToken,
AuthType.Bot,
{
method: 'PATCH',
headers: {
'content-type': 'application/json',
'x-audit-log-reason': `Picked their roles via ${context.config.uiPublicURI}`,
},
body: JSON.stringify({
roles: newRoles,
}),
}
);
if (!patchMemberRoles) {
return serverError(new Error('discord rejected the request'));
}
context.fetchContext.waitUntil(
updateGuildMember(context.config, guildID, patchMemberRoles)
);
const updatedMember: Member = {
roles: patchMemberRoles.roles,
};
return json(updatedMember);
};
export 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 = safeTransactions.reduce<
Record<TransactionType, RoleTransaction[]>
>((group, value, _1, _2, key = value.action) => (group[key].push(value), group), {
[TransactionType.Add]: [],
[TransactionType.Remove]: [],
});
const rolesToAdd = (changesByAction[TransactionType.Add] ?? []).map(
(tx: RoleTransaction) => tx.id
);
const rolesToRemove = (changesByAction[TransactionType.Remove] ?? []).map(
(tx: RoleTransaction) => tx.id
);
const final = union(difference(currentRoles, rolesToRemove), rolesToAdd);
return final;
};

View file

@ -0,0 +1,164 @@
jest.mock('../../guilds/getters');
import { Features, GuildData, PresentableGuild } from '@roleypoly/types';
import { getGuild, getGuildData, getGuildMember } from '../../guilds/getters';
import { APIGuild, APIMember } from '../../utils/discord';
import { configContext, makeRequest, makeSession } from '../../utils/testHelpers';
const mockGetGuild = getGuild as jest.Mock;
const mockGetGuildMember = getGuildMember as jest.Mock;
const mockGetGuildData = getGuildData as jest.Mock;
beforeEach(() => {
mockGetGuildData.mockReset();
mockGetGuild.mockReset();
mockGetGuildMember.mockReset();
});
describe('GET /guilds/:id', () => {
it('returns a presentable guild', async () => {
const guild: APIGuild = {
id: '123',
name: 'test',
icon: 'test',
roles: [
{
id: 'role-1',
name: 'Role 1',
color: 0,
position: 17,
permissions: '',
managed: false,
},
],
};
const member: APIMember = {
roles: ['role-1'],
pending: false,
nick: '',
user: {
id: 'user-1',
},
};
const guildData: GuildData = {
id: '123',
message: 'test',
categories: [],
features: Features.None,
auditLogWebhook: null,
accessControl: {
allowList: [],
blockList: [],
blockPending: false,
},
};
mockGetGuild.mockReturnValue(guild);
mockGetGuildMember.mockReturnValue(member);
mockGetGuildData.mockReturnValue(guildData);
const [config] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: 0,
},
],
});
const response = await makeRequest('GET', `/guilds/${guild.id}`, {
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
id: guild.id,
guild: session.guilds[0],
member: {
roles: member.roles,
},
roles: guild.roles,
data: guildData,
} as PresentableGuild);
});
it('returns a 404 when the guild is not in session', async () => {
const [config, context] = configContext();
const session = await makeSession(config);
const response = await makeRequest('GET', `/guilds/123`, {
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
});
expect(response.status).toBe(404);
});
it('returns 404 when the guild is not fetchable', async () => {
const [config, context] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: 0,
},
],
});
const response = await makeRequest('GET', `/guilds/123`, {
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
});
expect(response.status).toBe(404);
});
it('returns 404 when the member is no longer in the guild', async () => {
const guild: APIGuild = {
id: '123',
name: 'test',
icon: 'test',
roles: [
{
id: 'role-1',
name: 'Role 1',
color: 0,
position: 17,
permissions: '',
managed: false,
},
],
};
mockGetGuild.mockReturnValue(guild);
mockGetGuildMember.mockReturnValue(null);
const [config, context] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: 0,
},
],
});
const response = await makeRequest('GET', `/guilds/${guild.id}`, {
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
});
expect(response.status).toBe(404);
});
});

View file

@ -0,0 +1,45 @@
import {
getGuild,
getGuildData,
getGuildMember,
} from '@roleypoly/api/src/guilds/getters';
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
import { getQuery } from '@roleypoly/api/src/utils/request';
import { json, notFound } from '@roleypoly/api/src/utils/response';
import { PresentableGuild } from '@roleypoly/types';
export const guildsGuild: RoleypolyHandler = async (
request: Request,
context: Context
) => {
const { noCache } = getQuery(request);
const guild = await getGuild(context.config, context.params!.guildId!, !!noCache);
if (!guild) {
return notFound();
}
const member = await getGuildMember(
context.config,
context.params!.guildId!,
context.session!.user.id,
!!noCache
);
if (!member) {
return notFound();
}
const data = await getGuildData(context.config, guild.id);
const presentableGuild: PresentableGuild = {
id: guild.id,
guild: context.session?.guilds.find((g) => g.id === guild.id)!,
roles: guild.roles,
member: {
roles: member.roles,
},
data,
};
return json(presentableGuild);
};

View file

@ -0,0 +1,164 @@
jest.mock('../../guilds/getters');
import {
Features,
GuildData,
GuildDataUpdate,
UserGuildPermissions,
} from '@roleypoly/types';
import { getGuildData } from '../../guilds/getters';
import { configContext, makeRequest, makeSession } from '../../utils/testHelpers';
const mockGetGuildData = getGuildData as jest.Mock;
beforeAll(() => {
jest.resetAllMocks();
});
describe('PATCH /guilds/:id', () => {
it('updates guild data when user is an editor', async () => {
const [config, context] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: UserGuildPermissions.Manager,
},
],
});
mockGetGuildData.mockReturnValue({
id: '123',
message: 'test',
categories: [],
features: Features.None,
auditLogWebhook: null,
accessControl: {
allowList: [],
blockList: [],
blockPending: false,
},
} as GuildData);
const response = await makeRequest('PATCH', `/guilds/123`, {
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
body: JSON.stringify({
message: 'hello test world!',
} as GuildDataUpdate),
});
expect(response.status).toBe(200);
const newGuildData = await config.kv.guildData.get('123');
expect(newGuildData).toMatchObject({
message: 'hello test world!',
});
});
it('ignores extraneous fields sent as updates', async () => {
const [config, context] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: UserGuildPermissions.Manager,
},
],
});
mockGetGuildData.mockReturnValue({
id: '123',
message: 'test',
categories: [],
features: Features.None,
auditLogWebhook: null,
accessControl: {
allowList: [],
blockList: [],
blockPending: false,
},
} as GuildData);
const response = await makeRequest('PATCH', `/guilds/123`, {
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
body: JSON.stringify({
fifteen: 'foxes',
}),
});
expect(response.status).toBe(200);
const newGuildData = await config.kv.guildData.get('123');
expect(newGuildData).not.toMatchObject({
fifteen: 'foxes',
});
});
it('403s when user is not an editor', async () => {
const [config, context] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: UserGuildPermissions.User,
},
],
});
mockGetGuildData.mockReturnValue({
id: '123',
message: 'test',
categories: [],
features: Features.None,
auditLogWebhook: null,
accessControl: {
allowList: [],
blockList: [],
blockPending: false,
},
} as GuildData);
const response = await makeRequest('PATCH', `/guilds/123`, {
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
body: JSON.stringify({
message: 'hello test world!',
} as GuildDataUpdate),
});
expect(response.status).toBe(403);
});
it('400s when no body is present', async () => {
const [config, context] = configContext();
const session = await makeSession(config, {
guilds: [
{
id: '123',
name: 'test',
icon: 'test',
permissionLevel: UserGuildPermissions.Manager,
},
],
});
const response = await makeRequest('PATCH', `/guilds/123`, {
headers: {
Authorization: `Bearer ${session.sessionID}`,
},
});
expect(response.status).toBe(400);
});
});

View file

@ -0,0 +1,37 @@
import { getGuildData } from '@roleypoly/api/src/guilds/getters';
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
import { invalid, json, notFound } from '@roleypoly/api/src/utils/response';
import { GuildData, GuildDataUpdate } from '@roleypoly/types';
export const guildsGuildPatch: RoleypolyHandler = async (
request: Request,
context: Context
) => {
const id = context.params.guildId!;
if (!request.body) {
return invalid();
}
const update: GuildDataUpdate = await request.json();
const oldGuildData = await getGuildData(context.config, id);
if (!oldGuildData) {
return notFound();
}
const newGuildData: GuildData = {
...oldGuildData,
// TODO: validation
message: update.message || oldGuildData.message,
categories: update.categories || oldGuildData.categories,
accessControl: update.accessControl || oldGuildData.accessControl,
// TODO: audit log webhooks
auditLogWebhook: oldGuildData.auditLogWebhook,
};
await context.config.kv.guildData.put(id, newGuildData);
return json(newGuildData);
};

View file

@ -0,0 +1,52 @@
jest.mock('../../guilds/getters');
import { GuildSlug, UserGuildPermissions } from '@roleypoly/types';
import { getGuild } from '../../guilds/getters';
import { APIGuild } from '../../utils/discord';
import { makeRequest } from '../../utils/testHelpers';
const mockGetGuild = getGuild as jest.Mock;
beforeEach(() => {
mockGetGuild.mockReset();
});
describe('GET /guilds/:id/slug', () => {
it('returns a valid slug for a given discord server', async () => {
const guild: APIGuild = {
id: '123',
name: 'test',
icon: 'test',
roles: [
{
id: 'role-1',
name: 'Role 1',
color: 0,
position: 17,
permissions: '',
managed: false,
},
],
};
mockGetGuild.mockReturnValue(guild);
const response = await makeRequest('GET', `/guilds/${guild.id}/slug`);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
id: guild.id,
icon: guild.icon,
name: guild.name,
permissionLevel: UserGuildPermissions.User,
} as GuildSlug);
});
it('returns a 404 if the guild cannot be fetched', async () => {
mockGetGuild.mockReturnValue(null);
const response = await makeRequest('GET', `/guilds/slug/123`);
expect(response.status).toBe(404);
});
});

View file

@ -0,0 +1,31 @@
import { getGuild } from '@roleypoly/api/src/guilds/getters';
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
import { json, notFound } from '@roleypoly/api/src/utils/response';
import { GuildSlug, UserGuildPermissions } from '@roleypoly/types';
export const guildsSlug: RoleypolyHandler = async (
request: Request,
context: Context
) => {
const id = context.params.guildId!;
const guildInSession = context.session?.guilds.find((guild) => guild.id === id);
if (guildInSession) {
return json<GuildSlug>(guildInSession);
}
const guild = await getGuild(context.config, id);
if (!guild) {
return notFound();
}
const slug: GuildSlug = {
id,
name: guild.name,
icon: guild.icon,
permissionLevel: UserGuildPermissions.User,
};
return json<GuildSlug>(slug);
};

View file

@ -0,0 +1,60 @@
jest.mock('../../../utils/discord');
import { discordFetch } from '../../../utils/discord';
import { configContext } from '../../../utils/testHelpers';
import {
extractInteractionResponse,
isDeferred,
isEphemeral,
makeInteractionsRequest,
mockUpdateCall,
} from '../testHelpers';
const mockDiscordFetch = discordFetch as jest.Mock;
it('responds with the username when member.nick is missing', async () => {
const [, context] = configContext();
const response = await makeInteractionsRequest(
context,
{
name: 'hello-world',
},
false,
{
member: {
nick: undefined,
roles: [],
},
}
);
expect(response.status).toBe(200);
const interaction = await extractInteractionResponse(response);
expect(isDeferred(interaction)).toBe(true);
expect(isEphemeral(interaction)).toBe(true);
expect(mockDiscordFetch).toBeCalledWith(
...mockUpdateCall(expect, {
content: 'Hey there, test-user',
})
);
});
it('responds with the nickname when member.nick is set', async () => {
const [, context] = configContext();
const response = await makeInteractionsRequest(context, {
name: 'hello-world',
});
expect(response.status).toBe(200);
const interaction = await extractInteractionResponse(response);
expect(isDeferred(interaction)).toBe(true);
expect(isEphemeral(interaction)).toBe(true);
expect(mockDiscordFetch).toBeCalledWith(
...mockUpdateCall(expect, {
content: 'Hey there, test-user-nick',
})
);
});

View file

@ -0,0 +1,22 @@
import { InteractionHandler } from '@roleypoly/api/src/routes/interactions/helpers';
import { Context } from '@roleypoly/api/src/utils/context';
import {
InteractionCallbackType,
InteractionRequest,
InteractionResponse,
} from '@roleypoly/types';
export const helloWorld: InteractionHandler = (
interaction: InteractionRequest,
context: Context
): InteractionResponse => {
return {
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `Hey there, ${interaction.member?.nick || interaction.user?.username}`,
},
};
};
helloWorld.ephemeral = true;
helloWorld.deferred = true;

View file

@ -0,0 +1,131 @@
import { InteractionRequest, InteractionType } from '@roleypoly/types';
import nacl from 'tweetnacl';
import { configContext } from '../../utils/testHelpers';
import { verifyRequest } from './helpers';
//
// Q: Why tweetnacl when WebCrypto is available?
// A: Discord uses tweetnacl on their end, thus is also
// used in far more examples of Discord Interactions than WebCrypto.
// We don't actually use it in Workers, as SubtleCrypto using NODE-ED25519
// is better in every way, and still gives us the same effect.
//
describe('verifyRequest', () => {
it('validates a successful Discord interactions request', async () => {
const [config, context] = configContext();
const timestamp = String(Date.now());
const body: InteractionRequest = {
id: '123',
type: InteractionType.APPLICATION_COMMAND,
application_id: '123',
token: '123',
version: 1,
};
const { publicKey, secretKey } = nacl.sign.keyPair();
const signature = nacl.sign.detached(
Buffer.from(timestamp + JSON.stringify(body)),
secretKey
);
config.publicKey = Buffer.from(publicKey).toString('hex');
const request = new Request('http://local.test', {
method: 'POST',
body: JSON.stringify(body),
headers: {
'x-signature-timestamp': timestamp,
'x-signature-ed25519': Buffer.from(signature).toString('hex'),
},
});
expect(await verifyRequest(context.config, request, body)).toBe(true);
});
it('fails to validate a headerless Discord interactions request', async () => {
const [config, context] = configContext();
const body: InteractionRequest = {
id: '123',
type: InteractionType.APPLICATION_COMMAND,
application_id: '123',
token: '123',
version: 1,
};
const { publicKey, secretKey } = nacl.sign.keyPair();
config.publicKey = Buffer.from(publicKey).toString('hex');
const request = new Request('http://local.test', {
method: 'POST',
body: JSON.stringify(body),
headers: {},
});
expect(await verifyRequest(context.config, request, body)).toBe(false);
});
it('fails to validate a bad signature from Discord', async () => {
const [config, context] = configContext();
const timestamp = String(Date.now());
const body: InteractionRequest = {
id: '123',
type: InteractionType.APPLICATION_COMMAND,
application_id: '123',
token: '123',
version: 1,
};
const { publicKey } = nacl.sign.keyPair();
const { secretKey: otherKey } = nacl.sign.keyPair();
const signature = nacl.sign.detached(
Buffer.from(timestamp + JSON.stringify(body)),
otherKey
);
config.publicKey = Buffer.from(publicKey).toString('hex');
const request = new Request('http://local.test', {
method: 'POST',
body: JSON.stringify(body),
headers: {
'x-signature-timestamp': timestamp,
'x-signature-ed25519': Buffer.from(signature).toString('hex'),
},
});
expect(await verifyRequest(context.config, request, body)).toBe(false);
});
it('fails to validate when signature differs from data', async () => {
const [config, context] = configContext();
const timestamp = String(Date.now());
const body: InteractionRequest = {
id: '123',
type: InteractionType.APPLICATION_COMMAND,
application_id: '123',
token: '123',
version: 1,
};
const { publicKey, secretKey } = nacl.sign.keyPair();
const signature = nacl.sign.detached(
Buffer.from(timestamp + JSON.stringify({ ...body, id: '456' })),
secretKey
);
config.publicKey = Buffer.from(publicKey).toString('hex');
const request = new Request('http://local.test', {
method: 'POST',
body: JSON.stringify(body),
headers: {
'x-signature-timestamp': timestamp,
'x-signature-ed25519': Buffer.from(signature).toString('hex'),
},
});
expect(await verifyRequest(context.config, request, body)).toBe(false);
});
});

View file

@ -0,0 +1,94 @@
import { Config } from '@roleypoly/api/src/utils/config';
import { Context } from '@roleypoly/api/src/utils/context';
import { AuthType, discordFetch } from '@roleypoly/api/src/utils/discord';
import {
InteractionCallbackType,
InteractionFlags,
InteractionRequest,
InteractionResponse,
} from '@roleypoly/types';
export const verifyRequest = async (
config: Config,
request: Request,
interaction: InteractionRequest
): Promise<boolean> => {
const timestamp = request.headers.get('x-signature-timestamp');
const signature = request.headers.get('x-signature-ed25519');
if (!timestamp || !signature) {
return false;
}
const key = await crypto.subtle.importKey(
'raw',
Buffer.from(config.publicKey, 'hex'),
{ name: 'NODE-ED25519', namedCurve: 'NODE-ED25519', public: true } as any,
false,
['verify']
);
return crypto.subtle.verify(
'NODE-ED25519',
key,
Buffer.from(signature, 'hex'),
Buffer.from(timestamp + JSON.stringify(interaction))
);
};
export type InteractionHandler = ((
interaction: InteractionRequest,
context: Context
) => Promise<InteractionResponse> | InteractionResponse) & {
ephemeral?: boolean;
deferred?: boolean;
};
export const runAsync = async (
handler: InteractionHandler,
interaction: InteractionRequest,
context: Context
): Promise<void> => {
const url = `/webhooks/${interaction.application_id}/${interaction.token}/messages/@original`;
try {
const response = await handler(interaction, context);
await discordFetch(url, '', AuthType.None, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: InteractionCallbackType.DEFERRED_UPDATE_MESSAGE,
data: {
flags: handler.ephemeral ? InteractionFlags.EPHEMERAL : 0,
...response.data,
},
}),
});
} catch (e) {
console.error('/interations runAsync failed', {
e,
interaction: {
data: interaction.data,
user: interaction.user,
guild: interaction.guild_id,
},
});
try {
await discordFetch(url, '', AuthType.None, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: InteractionCallbackType.DEFERRED_UPDATE_MESSAGE,
data: {
content: "I'm sorry, I'm having trouble processing this request.",
flags: InteractionFlags.EPHEMERAL,
},
} as InteractionResponse),
});
} catch (e) {}
}
};

View file

@ -0,0 +1,49 @@
jest.mock('../../utils/discord');
import { InteractionCallbackType, InteractionFlags } from '@roleypoly/types';
import { AuthType, discordFetch } from '../../utils/discord';
import { configContext } from '../../utils/testHelpers';
import { extractInteractionResponse, makeInteractionsRequest } from './testHelpers';
const mockDiscordFetch = discordFetch as jest.Mock;
it('responds with a simple hello-world!', async () => {
const [config, context] = configContext();
const response = await makeInteractionsRequest(context, {
name: 'hello-world',
});
expect(response.status).toBe(200);
const interaction = await extractInteractionResponse(response);
expect(interaction.type).toEqual(
InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE
);
expect(interaction.data).toEqual({
flags: InteractionFlags.EPHEMERAL,
});
expect(mockDiscordFetch).toBeCalledWith(expect.any(String), '', AuthType.None, {
body: JSON.stringify({
type: InteractionCallbackType.DEFERRED_UPDATE_MESSAGE,
data: {
flags: InteractionFlags.EPHEMERAL,
content: 'Hey there, test-user-nick',
},
}),
headers: expect.any(Object),
method: 'PATCH',
});
});
it('does not allow requests that are invalid', async () => {
const [config, context] = configContext();
const response = await makeInteractionsRequest(
context,
{
name: 'hello-world',
},
true
);
expect(response.status).toBe(401);
});

View file

@ -0,0 +1,81 @@
import { helloWorld } from '@roleypoly/api/src/routes/interactions/commands/hello-world';
import {
InteractionHandler,
runAsync,
verifyRequest,
} from '@roleypoly/api/src/routes/interactions/helpers';
import { notImplemented } from '@roleypoly/api/src/routes/interactions/responses';
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
import { invalid, json } from '@roleypoly/api/src/utils/response';
import {
InteractionCallbackType,
InteractionData,
InteractionFlags,
InteractionRequest,
InteractionResponse,
InteractionType,
} from '@roleypoly/types';
const commands: Record<InteractionData['name'], InteractionHandler> = {
'hello-world': helloWorld,
};
export const handleInteraction: RoleypolyHandler = async (
request: Request,
context: Context
) => {
const interaction: InteractionRequest = await request.json();
if (!interaction) {
return invalid();
}
if (!(await verifyRequest(context.config, request, interaction))) {
return new Response('invalid request signature', { status: 401 });
}
if (interaction.type !== InteractionType.APPLICATION_COMMAND) {
if (interaction.type === InteractionType.PING) {
return json({ type: InteractionCallbackType.PONG });
}
return json({ err: 'not implemented' }, { status: 400 });
}
if (!interaction.data) {
return json({ err: 'data missing' }, { status: 400 });
}
const handler = commands[interaction.data.name] || notImplemented;
try {
if (handler.deferred) {
context.fetchContext.waitUntil(runAsync(handler, interaction, context));
return json({
type: InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE,
data: {
flags: handler.ephemeral ? InteractionFlags.EPHEMERAL : 0,
},
} as InteractionResponse);
}
const response = await handler(interaction, context);
return json({
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
flags: handler.ephemeral ? InteractionFlags.EPHEMERAL : 0,
...response.data,
},
});
} catch (e) {
console.error('/interactions error:', {
interaction: {
data: interaction.data,
user: interaction.user,
guild: interaction.guild_id,
},
e,
});
return invalid();
}
};

View file

@ -1,10 +1,11 @@
import { InteractionHandler } from '@roleypoly/api/src/routes/interactions/helpers';
import { import {
InteractionCallbackType, InteractionCallbackType,
InteractionFlags, InteractionFlags,
InteractionResponse, InteractionResponse,
} from '@roleypoly/types'; } from '@roleypoly/types';
export const mustBeInGuild = (): InteractionResponse => ({ export const mustBeInGuild: InteractionHandler = (): InteractionResponse => ({
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: { data: {
content: ':x: This command has to be used in a server.', content: ':x: This command has to be used in a server.',
@ -12,7 +13,7 @@ export const mustBeInGuild = (): InteractionResponse => ({
}, },
}); });
export const invalid = (): InteractionResponse => ({ export const invalid: InteractionHandler = (): InteractionResponse => ({
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: { data: {
content: ':x: You filled that command out wrong...', content: ':x: You filled that command out wrong...',
@ -20,10 +21,18 @@ export const invalid = (): InteractionResponse => ({
}, },
}); });
export const somethingWentWrong = (): InteractionResponse => ({ export const somethingWentWrong: InteractionHandler = (): InteractionResponse => ({
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: { data: {
content: '<a:promareFlame:624850108667789333> Something went terribly wrong.', content: '<a:promareFlame:624850108667789333> Something went terribly wrong.',
flags: InteractionFlags.EPHEMERAL, flags: InteractionFlags.EPHEMERAL,
}, },
}); });
export const notImplemented: InteractionHandler = (): InteractionResponse => ({
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: ':x: This command is not implemented yet.',
flags: InteractionFlags.EPHEMERAL,
},
});

View file

@ -0,0 +1,134 @@
import { handleInteraction } from '@roleypoly/api/src/routes/interactions/interactions';
import { Context } from '@roleypoly/api/src/utils/context';
import { AuthType } from '@roleypoly/api/src/utils/discord';
import { getID } from '@roleypoly/api/src/utils/id';
import {
InteractionCallbackData,
InteractionCallbackType,
InteractionData,
InteractionFlags,
InteractionRequest,
InteractionResponse,
InteractionType,
} from '@roleypoly/types';
import nacl from 'tweetnacl';
const { publicKey, secretKey } = nacl.sign.keyPair();
const hexPublicKey = Buffer.from(publicKey).toString('hex');
export const getSignatureHeaders = (
context: Context,
interaction: InteractionRequest
): {
'x-signature-ed25519': string;
'x-signature-timestamp': string;
} => {
const timestamp = Date.now().toString();
const body = JSON.stringify(interaction);
const signature = nacl.sign.detached(Buffer.from(timestamp + body), secretKey);
return {
'x-signature-ed25519': Buffer.from(signature).toString('hex'),
'x-signature-timestamp': timestamp,
};
};
export const makeInteractionsRequest = async (
context: Context,
interactionData: Partial<InteractionData>,
forceInvalid?: boolean,
topLevelMixin?: Partial<InteractionRequest>
): Promise<Response> => {
context.config.publicKey = hexPublicKey;
const interaction: InteractionRequest = {
data: {
id: getID(),
name: 'hello-world',
...interactionData,
} as InteractionData,
id: '123',
type: InteractionType.APPLICATION_COMMAND,
application_id: context.config.botClientID,
token: getID(),
version: 1,
user: {
id: '123',
username: 'test-user',
discriminator: '1234',
bot: false,
avatar: '',
},
member: {
nick: 'test-user-nick',
roles: [],
},
...topLevelMixin,
};
const request = new Request('http://localhost:3000/interactions', {
method: 'POST',
headers: {
'content-type': 'application/json',
...getSignatureHeaders(context, {
...interaction,
...(forceInvalid ? { id: 'invalid-id' } : {}),
}),
},
body: JSON.stringify(interaction),
});
return handleInteraction(request, context);
};
export const extractInteractionResponse = async (
response: Response
): Promise<InteractionResponse> => {
const body = await response.json();
return body as InteractionResponse;
};
export const isDeferred = (response: InteractionResponse): boolean => {
return response.type === InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE;
};
export const isEphemeral = (response: InteractionResponse): boolean => {
return (
(response.data?.flags || 0 & InteractionFlags.EPHEMERAL) ===
InteractionFlags.EPHEMERAL
);
};
export const interactionData = (
response: InteractionResponse
): Omit<InteractionCallbackData, 'flags'> | undefined => {
const { data } = response;
if (!data) return undefined;
delete data.flags;
return response.data;
};
export const mockUpdateCall = (
expect: any,
data: Omit<InteractionCallbackData, 'flags'>
) => {
return [
expect.any(String),
'',
AuthType.None,
{
body: JSON.stringify({
type: InteractionCallbackType.DEFERRED_UPDATE_MESSAGE,
data: {
flags: InteractionFlags.EPHEMERAL,
...data,
},
}),
headers: {
'Content-Type': 'application/json',
},
method: 'PATCH',
},
];
};

View file

@ -0,0 +1,53 @@
jest.mock('../utils/discord');
import { AuthTokenResponse } from '@roleypoly/types';
import { parseEnvironment } from '../utils/config';
import { getTokenGuilds, getTokenUser } from '../utils/discord';
import { getBindings } from '../utils/testHelpers';
import { createSession } from './create';
const mockGetTokenGuilds = getTokenGuilds as jest.Mock;
const mockGetTokenUser = getTokenUser as jest.Mock;
it('creates a session from tokens', async () => {
const config = parseEnvironment(getBindings());
const tokens: AuthTokenResponse = {
access_token: 'test-access-token',
refresh_token: 'test-refresh-token',
expires_in: 3600,
scope: 'identify guilds',
token_type: 'Bearer',
};
mockGetTokenUser.mockReturnValueOnce({
id: 'test-user-id',
username: 'test-username',
discriminator: 'test-discriminator',
avatar: 'test-avatar',
bot: false,
});
mockGetTokenGuilds.mockReturnValueOnce([]);
const session = await createSession(config, tokens);
expect(session).toEqual({
sessionID: expect.any(String),
user: {
id: 'test-user-id',
discriminator: 'test-discriminator',
avatar: 'test-avatar',
bot: false,
username: 'test-username',
},
guilds: [],
tokens,
});
expect(mockGetTokenUser).toBeCalledWith(tokens.access_token);
expect(mockGetTokenGuilds).toBeCalledWith(tokens.access_token);
const savedSession = await config.kv.sessions.get(session?.sessionID || '');
expect(savedSession).toEqual(session);
});

View file

@ -0,0 +1,31 @@
import { Config } from '@roleypoly/api/src/utils/config';
import { getTokenGuilds, getTokenUser } from '@roleypoly/api/src/utils/discord';
import { getID } from '@roleypoly/api/src/utils/id';
import { AuthTokenResponse, SessionData } from '@roleypoly/types';
export const createSession = async (
config: Config,
tokens: AuthTokenResponse
): Promise<SessionData | null> => {
const [user, guilds] = await Promise.all([
getTokenUser(tokens.access_token),
getTokenGuilds(tokens.access_token),
]);
if (!user) {
return null;
}
const sessionID = getID();
const session: SessionData = {
sessionID,
user,
guilds,
tokens,
};
await config.kv.sessions.put(sessionID, session, config.retention.session);
return session;
};

View file

@ -0,0 +1,172 @@
import { Router } from 'itty-router';
import { Context } from '../utils/context';
import { json } from '../utils/response';
import { configContext, makeSession } from '../utils/testHelpers';
import { requireSession, withAuthMode, withSession } from './middleware';
it('detects anonymous auth mode via middleware', async () => {
const [, context] = configContext();
const router = Router();
const testFn = jest.fn();
router.all('*', withAuthMode).get('/', (request, context) => {
expect(context.authMode.type).toBe('anonymous');
testFn();
return json({});
});
await router.handle(new Request('http://test.local/'), context);
expect(testFn).toHaveBeenCalled();
});
it('detects bearer auth mode via middleware', async () => {
const [, context] = configContext();
const testFn = jest.fn();
const token = 'abc123';
const router = Router();
router.all('*', withAuthMode).get('/', (request, context) => {
expect(context.authMode.type).toBe('bearer');
expect(context.authMode.sessionId).toBe(token);
testFn();
return json({});
});
await router.handle(
new Request('http://test.local/', {
headers: {
authorization: `Bearer ${token}`,
},
}),
context
);
expect(testFn).toHaveBeenCalled();
});
it('detects bot auth mode via middleware', async () => {
const testFn = jest.fn();
const [, context] = configContext();
const token = 'abc123';
const router = Router();
router.all('*', withAuthMode).get('/', (request, context) => {
expect(context.authMode.type).toBe('bot');
expect(context.authMode.identity).toBe(token);
testFn();
return json({});
});
await router.handle(
new Request('http://test.local/', {
headers: {
authorization: `Bot ${token}`,
},
}),
context
);
expect(testFn).toHaveBeenCalled();
});
it('sets Context.session via withSession middleware', async () => {
const testFn = jest.fn();
const [config, context] = configContext();
const session = await makeSession(config);
const router = Router();
router.all('*', withAuthMode, withSession).get('/', (request, context: Context) => {
expect(context.session).toBeDefined();
expect(context.session!.sessionID).toBe(session.sessionID);
testFn();
return json({});
});
await router.handle(
new Request('http://test.local/', {
headers: {
authorization: `Bearer ${session.sessionID}`,
},
}),
context
);
expect(testFn).toHaveBeenCalledTimes(1);
});
it('does not set Context.session when session is invalid', async () => {
const testFn = jest.fn();
const [, context] = configContext();
const router = Router();
router.all('*', withAuthMode, withSession).get('/', (request, context: Context) => {
expect(context.session).not.toBeDefined();
testFn();
return json({});
});
await router.handle(
new Request('http://test.local/', {
headers: {
authorization: `Bearer abc123`,
},
}),
context
);
expect(testFn).toHaveBeenCalledTimes(1);
});
it('errors with 401 when requireSession is coupled with invalid session', async () => {
const [, context] = configContext();
const router = Router();
const testFn = jest.fn();
router
.all('*', withAuthMode, withSession, requireSession)
.get('/', (request, context: Context) => {
testFn();
return json({});
});
const response = await router.handle(
new Request('http://test.local/', {
headers: {
authorization: `Bearer abc123`,
},
}),
context
);
expect(testFn).not.toHaveBeenCalled();
expect(response.status).toBe(401);
});
it('passes through when requireSession is coupled with a valid session', async () => {
const [config, context] = configContext();
const session = await makeSession(config);
const router = Router();
const testFn = jest.fn();
router
.all('*', withAuthMode, withSession, requireSession)
.get('/', (request, context: Context) => {
expect(context.session).toBeDefined();
testFn();
return json({});
});
const response = await router.handle(
new Request('http://test.local/', {
headers: {
authorization: `Bearer ${session.sessionID}`,
},
}),
context
);
expect(response.status).toBe(200);
expect(testFn).toHaveBeenCalled();
});

View file

@ -0,0 +1,67 @@
import { Context, RoleypolyMiddleware } from '@roleypoly/api/src/utils/context';
import { unauthorized } from '@roleypoly/api/src/utils/response';
import { SessionData } from '@roleypoly/types';
export const withSession: RoleypolyMiddleware = async (
request: Request,
context: Context
) => {
if (context.authMode.type !== 'bearer') {
return;
}
const session = await context.config.kv.sessions.get<SessionData>(
context.authMode.sessionId
);
if (!session) {
return;
}
context.session = session;
};
export const requireSession: RoleypolyMiddleware = (
request: Request,
context: Context
) => {
if (context.authMode.type !== 'bearer' || !context.session) {
return unauthorized();
}
};
export const withAuthMode: RoleypolyMiddleware = (request: Request, context: Context) => {
const auth = extractAuthentication(request);
if (auth.authType === 'Bearer') {
context.authMode = {
type: 'bearer',
sessionId: auth.token,
};
return;
}
if (auth.authType === 'Bot') {
context.authMode = {
type: 'bot',
identity: auth.token,
};
return;
}
context.authMode = {
type: 'anonymous',
};
};
export const extractAuthentication = (
request: Request
): { authType: string; token: string } => {
const authHeader = request.headers.get('authorization');
if (!authHeader) {
return { authType: 'None', token: '' };
}
const [authType, token] = authHeader.split(' ');
return { authType, token };
};

View file

@ -0,0 +1,25 @@
import { parseEnvironment } from '../utils/config';
import { getBindings } from '../utils/testHelpers';
import { getStateSession, setupStateSession } from './state';
it('creates and fetches a state session', async () => {
const config = parseEnvironment(getBindings());
const stateID = await setupStateSession(config, {
test: 'test-data',
});
const stateSession = await getStateSession(config, stateID);
expect(stateSession).toEqual({
test: 'test-data',
});
});
it('returns undefined when state is invalid', async () => {
const config = parseEnvironment(getBindings());
const stateSession = await getStateSession(config, 'invalid-state-id');
expect(stateSession).toBeUndefined();
});

View file

@ -0,0 +1,19 @@
import { Config } from '@roleypoly/api/src/utils/config';
import { getID } from '@roleypoly/api/src/utils/id';
export const setupStateSession = async <T>(config: Config, data: T): Promise<string> => {
const stateID = getID();
await config.kv.sessions.put(`state_${stateID}`, { data }, config.retention.session);
return stateID;
};
export const getStateSession = async <T>(
config: Config,
stateID: string
): Promise<T | undefined> => {
const stateSession = await config.kv.sessions.get<{ data: T }>(`state_${stateID}`);
return stateSession?.data;
};

View file

@ -0,0 +1,78 @@
import { WrappedKVNamespace } from './kv';
export type Environment = {
BOT_CLIENT_ID: string;
BOT_CLIENT_SECRET: string;
BOT_TOKEN: string;
UI_PUBLIC_URI: string;
API_PUBLIC_URI: string;
ROOT_USERS: string;
ALLOWED_CALLBACK_HOSTS: string;
BOT_IMPORT_TOKEN: string;
INTERACTIONS_SHARED_KEY: string;
RP_SERVER_ID: string;
RP_HELPER_ROLE_IDS: string;
DISCORD_PUBLIC_KEY: string;
KV_SESSIONS: KVNamespace;
KV_GUILDS: KVNamespace;
KV_GUILD_DATA: KVNamespace;
};
export type Config = {
botClientID: string;
botClientSecret: string;
botToken: string;
publicKey: string;
uiPublicURI: string;
apiPublicURI: string;
rootUsers: string[];
allowedCallbackHosts: string[];
importSharedKey: string;
interactionsSharedKey: string;
roleypolyServerID: string;
helperRoleIDs: string[];
kv: {
sessions: WrappedKVNamespace;
guilds: WrappedKVNamespace;
guildData: WrappedKVNamespace;
};
retention: {
session: number;
sessionState: number;
guild: number;
member: number;
};
_raw: Environment;
};
const toList = (x: string): string[] => String(x).split(',');
const safeURI = (x: string) => String(x).replace(/\/$/, '');
export const parseEnvironment = (env: Environment): Config => {
return {
_raw: env,
botClientID: env.BOT_CLIENT_ID,
botClientSecret: env.BOT_CLIENT_SECRET,
botToken: env.BOT_TOKEN,
publicKey: env.DISCORD_PUBLIC_KEY,
uiPublicURI: safeURI(env.UI_PUBLIC_URI),
apiPublicURI: safeURI(env.API_PUBLIC_URI),
rootUsers: toList(env.ROOT_USERS),
allowedCallbackHosts: toList(env.ALLOWED_CALLBACK_HOSTS),
importSharedKey: env.BOT_IMPORT_TOKEN,
interactionsSharedKey: env.INTERACTIONS_SHARED_KEY,
roleypolyServerID: env.RP_SERVER_ID,
helperRoleIDs: toList(env.RP_HELPER_ROLE_IDS),
kv: {
sessions: new WrappedKVNamespace(env.KV_SESSIONS),
guilds: new WrappedKVNamespace(env.KV_GUILDS),
guildData: new WrappedKVNamespace(env.KV_GUILD_DATA),
},
retention: {
session: 60 * 60 * 6, // 6 hours
sessionState: 60 * 5, // 5 minutes
guild: 60 * 60 * 2, // 2 hours
member: 60 * 5, // 5 minutes
},
};
};

View file

@ -0,0 +1,40 @@
import { Config } from '@roleypoly/api/src/utils/config';
import { SessionData } from '@roleypoly/types';
export type AuthMode =
| {
type: 'anonymous';
}
| {
type: 'bearer';
sessionId: string;
}
| {
type: 'bot';
identity: string;
};
export type Context = {
config: Config;
fetchContext: {
waitUntil: FetchEvent['waitUntil'];
};
authMode: AuthMode;
params: {
guildId?: string;
memberId?: string;
};
// Must include withSession middleware for population
session?: SessionData;
};
export type RoleypolyHandler = (
request: Request,
context: Context
) => Promise<Response> | Response;
export type RoleypolyMiddleware = (
request: Request,
context: Context
) => Promise<Response | void> | Response | void;

View file

@ -0,0 +1,34 @@
import { getHighestRole } from './discord';
describe('getHighestRole', () => {
it('returns the highest role', () => {
const roles = [
{
id: 'role-1',
name: 'Role 1',
color: 0,
position: 17,
permissions: '',
managed: false,
},
{
id: 'role-2',
name: 'Role 2',
color: 0,
position: 2,
permissions: '',
managed: false,
},
{
id: 'role-3',
name: 'Role 3',
color: 0,
position: 19,
permissions: '',
managed: false,
},
];
expect(getHighestRole(roles)).toEqual(roles[2]);
});
});

View file

@ -0,0 +1,153 @@
import {
evaluatePermission,
permissions as Permissions,
} from '@roleypoly/misc-utils/hasPermission';
import {
AuthTokenResponse,
DiscordUser,
GuildSlug,
Role,
UserGuildPermissions,
} from '@roleypoly/types';
export const userAgent =
'DiscordBot (https://github.com/roleypoly/roleypoly, git-main) (+https://roleypoly.com)';
export const discordAPIBase = 'https://discord.com/api/v9';
export enum AuthType {
Bearer = 'Bearer',
Bot = 'Bot',
None = 'None',
}
export const discordFetch = async <T>(
url: string,
auth: string,
authType: AuthType = AuthType.Bearer,
init?: RequestInit
): Promise<T | null> => {
const response = await fetch(discordAPIBase + url, {
...(init || {}),
headers: {
...(init?.headers || {}),
...(authType !== AuthType.None
? {
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 getTokenUser = async (
accessToken: AuthTokenResponse['access_token']
): 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[];
}[];
export const getTokenGuilds = 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;
};
export type APIGuild = {
// Only relevant stuff
id: string;
name: string;
icon: string;
roles: APIRole[];
};
export type APIRole = {
id: string;
name: string;
color: number;
position: number;
permissions: string;
managed: boolean;
};
export type APIMember = {
// Only relevant stuff, again.
roles: string[];
pending: boolean;
nick: string;
user: {
id: string;
};
};
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 getHighestRole = (roles: (Role | APIRole)[]): Role | APIRole => {
return roles.reduce(
(highestRole, role) => (highestRole.position > role.position ? highestRole : role),
roles[0]
);
};

View file

@ -0,0 +1,9 @@
import { dateFromID, getID } from './id';
it('returns an id', () => {
expect(getID()).toBeTruthy();
});
it('outputs a valid millisecond decoded from id', () => {
expect(dateFromID(getID())).toBeCloseTo(Date.now(), Date.now().toString.length - 4);
});

View file

@ -0,0 +1,6 @@
import { decodeTime, monotonicFactory } from 'ulid-workers';
const ulid = monotonicFactory();
export const getID = () => ulid();
export const dateFromID = (id: string) => decodeTime(id);

View file

@ -0,0 +1,91 @@
import { configContext } from './testHelpers';
it('serializes data via get and put', async () => {
const [config] = configContext();
const data = {
foo: 'bar',
baz: 'qux',
};
await config.kv.guilds.put('test-guild-id', data, config.retention.guild);
const result = await config.kv.guilds.get('test-guild-id');
expect(result).toEqual(data);
});
describe('cacheThrough', () => {
it('passes through for data on misses', async () => {
const [config] = configContext();
const data = {
foo: 'bar',
baz: 'qux',
};
const testFn = jest.fn();
const result = await config.kv.guilds.cacheThrough(
'test-guild-id',
async () => {
testFn();
return data;
},
config.retention.guild
);
expect(testFn).toHaveBeenCalledTimes(1);
expect(result).toEqual(data);
});
it('uses cache data on hits', async () => {
const [config] = configContext();
const data = {
foo: 'bar',
baz: 'qux',
};
const testFn = jest.fn();
await config.kv.guilds.put('test-guild-id', data, config.retention.guild);
const result = await config.kv.guilds.cacheThrough(
'test-guild-id',
async () => {
testFn();
return data;
},
config.retention.guild
);
expect(testFn).not.toHaveBeenCalled();
expect(result).toEqual(data);
});
it('skips cache when instructed to miss', async () => {
const [config] = configContext();
const data = {
foo: 'bar',
baz: 'qux',
};
const testFn = jest.fn();
await config.kv.guilds.put('test-guild-id', data, config.retention.guild);
const run = (skip: boolean) => {
return config.kv.guilds.cacheThrough(
'test-guild-id',
async () => {
testFn();
return data;
},
config.retention.guild,
skip
);
};
await run(true);
await run(true);
await run(false); // use cache this time
expect(testFn).toHaveBeenCalledTimes(2);
});
});

View file

@ -0,0 +1,63 @@
export class WrappedKVNamespace {
constructor(private kvNamespace: KVNamespace) {
this.getRaw = kvNamespace.get.bind(kvNamespace);
this.putRaw = kvNamespace.put.bind(kvNamespace);
this.delete = kvNamespace.delete.bind(kvNamespace);
this.list = kvNamespace.list.bind(kvNamespace);
this.getWithMetadata = kvNamespace.getWithMetadata.bind(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,
});
}
async cacheThrough<Data>(
cacheKey: string,
missHandler: () => Promise<Data | null>,
retention?: number,
forceMiss?: boolean
): Promise<Data | null> {
if (!forceMiss) {
const value = await this.get<Data>(cacheKey);
if (value) {
return value;
}
}
const fallbackValue = await missHandler();
if (!fallbackValue) {
return null;
}
await this.put(cacheKey, fallbackValue, retention);
return fallbackValue;
}
public getRaw: (
...args: Parameters<KVNamespace['get']>
) => ReturnType<KVNamespace['get']>;
public putRaw: (
...args: Parameters<KVNamespace['put']>
) => ReturnType<KVNamespace['put']>;
public list: (
...args: Parameters<KVNamespace['list']>
) => ReturnType<KVNamespace['list']>;
public getWithMetadata: (
...args: Parameters<KVNamespace['getWithMetadata']>
) => ReturnType<KVNamespace['getWithMetadata']>;
public delete: (
...args: Parameters<KVNamespace['delete']>
) => ReturnType<KVNamespace['delete']>;
}

View file

@ -1,7 +1,7 @@
import { Config } from '@roleypoly/api/src/utils/config';
import { getID } from '@roleypoly/api/src/utils/id';
import { sortBy } from '@roleypoly/misc-utils/sortBy'; import { sortBy } from '@roleypoly/misc-utils/sortBy';
import { CategoryType, Features, GuildData } from '@roleypoly/types'; import { CategoryType, Features, GuildData } from '@roleypoly/types';
import KSUID from 'ksuid';
import { importSharedKey } from './config';
export type LegacyCategory = { export type LegacyCategory = {
id: string; id: string;
@ -18,12 +18,19 @@ export type LegacyGuildData = {
message: string; message: string;
}; };
export const fetchLegacyServer = async (id: string): Promise<LegacyGuildData | null> => { export const fetchLegacyServer = async (
config: Config,
id: string
): Promise<LegacyGuildData | null> => {
if (!config.importSharedKey) {
return null;
}
const guildDataResponse = await fetch( const guildDataResponse = await fetch(
`https://beta.roleypoly.com/x/import-to-next/${id}`, `https://beta.roleypoly.com/x/import-to-next/${id}`,
{ {
headers: { headers: {
authorization: `Shared ${importSharedKey}`, authorization: `Shared ${config.importSharedKey}`,
}, },
} }
); );
@ -53,7 +60,7 @@ export const transformLegacyGuild = (guild: LegacyGuildData): GuildData => {
categories: sortBy(Object.values(guild.categories), 'position').map( categories: sortBy(Object.values(guild.categories), 'position').map(
(category, idx) => ({ (category, idx) => ({
...category, ...category,
id: KSUID.randomSync().string, id: getID(),
position: idx, // Reset positions by index. May have side-effects but oh well. position: idx, // Reset positions by index. May have side-effects but oh well.
type: category.type === 'multi' ? CategoryType.Multi : CategoryType.Single, type: category.type === 'multi' ? CategoryType.Multi : CategoryType.Single,
}) })

View file

@ -0,0 +1,45 @@
import { formData, formDataRequest, getQuery } from './request';
describe('getQuery', () => {
it('splits query string into object', () => {
const query = getQuery(new Request('http://local.test/?a=1&b=2'));
expect(query).toEqual({
a: '1',
b: '2',
});
});
});
describe('formData & formDataRequest', () => {
it('formats object into form data', () => {
const body = formData({
a: 1,
b: 2,
});
expect(body).toEqual('a=1&b=2');
});
it('formats object into form data with custom headers', () => {
const body = formDataRequest(
{
a: 1,
b: 2,
},
{
headers: {
'content-type': 'application/x-www-form-urlencoded',
},
}
);
expect(body).toEqual({
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded',
},
body: 'a=1&b=2',
});
});
});

View file

@ -0,0 +1,42 @@
import { Context, RoleypolyMiddleware } from '@roleypoly/api/src/utils/context';
export const getQuery = (request: Request): { [x: string]: string } => {
const output: { [x: string]: string } = {};
new URL(request.url).searchParams.forEach((value, key) => {
output[key] = value;
});
return output;
};
export const formData = (obj: Record<string, any>): string => {
return Object.keys(obj)
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(obj[key])}`)
.join('&');
};
export const formDataRequest = (
obj: Record<string, any>,
init?: RequestInit
): RequestInit => {
return {
method: 'POST', // First, so it can be overridden.
...init,
headers: {
...(init?.headers || {}),
'content-type': 'application/x-www-form-urlencoded',
},
body: formData(obj),
};
};
export const injectParams: RoleypolyMiddleware = (
request: Request & { params?: Record<string, string> },
context: Context
) => {
context.params = {
guildId: request.params?.guildId,
memberId: request.params?.memberId,
};
};

View file

@ -0,0 +1,46 @@
export const json = <T>(obj: T, init?: ResponseInit): Response => {
const body = JSON.stringify(obj);
return new Response(body, {
...init,
headers: {
...init?.headers,
'content-type': 'application/json; charset=utf-8',
...corsHeaders,
},
});
};
export const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS, PATCH',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
};
export const noContent = () => new Response(null, { status: 204 });
export const seeOther = (url: string) =>
new Response(
`<!doctype html>If you are not redirected soon, <a href="${url}">click here.</a>`,
{
status: 303,
headers: {
location: url,
'content-type': 'text/html; charset=utf-8',
},
}
);
export const invalid = () => json({ error: 'invalid request' }, { status: 400 });
export const unauthorized = () => json({ error: 'unauthorized' }, { status: 401 });
export const forbidden = () => json({ error: 'forbidden' }, { status: 403 });
export const notFound = () => json({ error: 'not found' }, { status: 404 });
export const serverError = (error: Error) => {
console.error(error);
return json({ error: 'internal server error' }, { status: 500 });
};
export const notImplemented = () => json({ error: 'not implemented' }, { status: 501 });
// Only used to bully you in particular.
// Maybe make better choices.
export const engineeringProblem = (extra?: string) =>
json({ error: 'engineering problem', extra }, { status: 418 });

View file

@ -0,0 +1,102 @@
import { Config, Environment, parseEnvironment } from '@roleypoly/api/src/utils/config';
import { Context } from '@roleypoly/api/src/utils/context';
import { getID } from '@roleypoly/api/src/utils/id';
import { SessionData, UserGuildPermissions } from '@roleypoly/types';
import index from '../index';
export const makeRequest = (
method: string,
path: string,
init?: RequestInit,
env?: Partial<Environment>
): Promise<Response> => {
const request = new Request(`https://localhost:22000${path}`, {
method,
...init,
});
return index.fetch(
request,
{
...getMiniflareBindings(),
...env,
},
{
waitUntil: async (promise: Promise<{}>) => await promise,
}
);
};
export const getBindings = (): Environment => getMiniflareBindings();
export const makeSession = async (
config: Config,
data?: Partial<SessionData>
): Promise<SessionData> => {
const sessionID = getID();
const session: SessionData = {
sessionID,
tokens: {
access_token: 'test-access-token',
refresh_token: 'test-refresh-token',
expires_in: 3600,
scope: 'identify guilds',
token_type: 'Bearer',
},
user: {
id: 'test-user-id',
username: 'test-username',
discriminator: 'test-discriminator',
avatar: 'test-avatar',
bot: false,
},
guilds: [
{
id: 'test-guild-id',
name: 'test-guild-name',
icon: 'test-guild-icon',
permissionLevel: UserGuildPermissions.User,
},
{
id: 'test-guild-id-editor',
name: 'test-guild-name',
icon: 'test-guild-icon',
permissionLevel: UserGuildPermissions.Manager,
},
{
id: 'test-guild-id-admin',
name: 'test-guild-name',
icon: 'test-guild-icon',
permissionLevel: UserGuildPermissions.Manager | UserGuildPermissions.Admin,
},
],
...data,
};
await config.kv.sessions.put(sessionID, session, config.retention.session);
return session;
};
export const configContext = (): [Config, Context] => {
const config = parseEnvironment({
...getBindings(),
BOT_CLIENT_SECRET: 'test-client-secret',
BOT_CLIENT_ID: 'test-client-id',
BOT_TOKEN: 'test-bot-token',
INTERACTIONS_SHARED_KEY: '', // IMPORTANT: setting this properly can have unexpected results.
});
const context: Context = {
config,
fetchContext: {
waitUntil: () => {},
},
authMode: {
type: 'anonymous',
},
params: {},
};
return [config, context];
};

10
packages/api/test/miniflare.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
import { Environment } from '../src/utils/config';
declare global {
function getMiniflareBindings(): Environment;
function getMiniflareDurableObjectStorage(
id: DurableObjectId
): Promise<DurableObjectStorage>;
}
export {};

View file

@ -1,15 +1,14 @@
{ {
"compilerOptions": { "compilerOptions": {
"outDir": "./dist", "outDir": "./dist",
"target": "esnext",
"module": "esnext",
"lib": ["esnext", "webworker", "ES2020.BigInt", "ES2020.Promise"], "lib": ["esnext", "webworker", "ES2020.BigInt", "ES2020.Promise"],
"types": ["@cloudflare/workers-types"], "types": ["@cloudflare/workers-types", "node"],
"target": "ES2019" "esModuleInterop": true,
"moduleResolution": "node"
}, },
"include": [ "include": ["src/**/*", "test/**/*"],
"./*.ts", "exclude": ["./**/*.spec.ts"],
"./**/*.ts",
"../../node_modules/@cloudflare/workers-types/index.d.ts"
],
"exclude": ["./**/*.spec.ts", "./dist/**"],
"extends": "../../tsconfig.json" "extends": "../../tsconfig.json"
} }

View file

@ -0,0 +1,6 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["jest", "@cloudflare/workers-types", "node"]
}
}

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

Some files were not shown because too many files have changed in this diff Show more