mirror of
https://github.com/roleypoly/roleypoly.git
synced 2025-04-24 11:29:12 +00:00
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:
parent
b644a38aa7
commit
3291f9aacc
183 changed files with 9853 additions and 9924 deletions
56
.github/workflows/build.yml
vendored
56
.github/workflows/build.yml
vendored
|
@ -11,21 +11,10 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@master
|
||||
|
||||
- uses: actions/setup-node@v2.1.5
|
||||
- uses: actions/setup-node@v2.5.1
|
||||
with:
|
||||
node-version: '16'
|
||||
|
||||
- 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-
|
||||
cache: yarn
|
||||
|
||||
- run: yarn install --frozen-lockfile
|
||||
|
||||
|
@ -42,62 +31,45 @@ jobs:
|
|||
matrix:
|
||||
worker:
|
||||
- api
|
||||
- interactions
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
|
||||
- uses: actions/setup-node@v2.1.5
|
||||
- uses: actions/setup-node@v2.5.1
|
||||
with:
|
||||
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
|
||||
uses: google-github-actions/setup-gcloud@master
|
||||
uses: google-github-actions/setup-gcloud@v0
|
||||
with:
|
||||
project_id: ${{ secrets.GCS_PROJECT_ID }}
|
||||
service_account_key: ${{ secrets.GCS_TF_KEY }}
|
||||
export_default_credentials: true
|
||||
|
||||
- name: Check if already deployed
|
||||
id: check
|
||||
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::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
|
||||
if: steps.check.outputs.skip == '0'
|
||||
|
||||
- run: |
|
||||
wrangler init
|
||||
echo 'webpack_config = "packages/${{ matrix.worker }}/webpack.config.js"' | tee -a wrangler.toml
|
||||
wrangler build
|
||||
mv worker/script.js worker/${{ matrix.worker }}.js
|
||||
yarn build:api
|
||||
if: steps.check.outputs.skip == '0'
|
||||
|
||||
- id: upload-file
|
||||
if: github.event_name == 'push' && steps.check.outputs.skip == '0'
|
||||
uses: google-github-actions/upload-cloud-storage@main
|
||||
with:
|
||||
path: worker/${{ matrix.worker }}.js
|
||||
path: packages/api/dist/index.mjs
|
||||
destination: roleypoly-artifacts/workers/${{ github.sha }}
|
||||
credentials: ${{ secrets.GCS_TF_KEY }}
|
||||
|
||||
docker_build:
|
||||
name: Docker Build & Publish
|
||||
|
@ -166,7 +138,7 @@ jobs:
|
|||
needs:
|
||||
- docker_build
|
||||
- worker_build
|
||||
if: github.ref == 'refs/heads/main'
|
||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/miniflare'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get Bot digest
|
||||
|
|
6
.github/workflows/deploy.yml
vendored
6
.github/workflows/deploy.yml
vendored
|
@ -25,10 +25,10 @@ jobs:
|
|||
|
||||
- uses: hashicorp/setup-terraform@v1.3.2
|
||||
with:
|
||||
terraform_version: ^1.0.1
|
||||
terraform_version: ^1.1.4
|
||||
|
||||
- name: Set up Cloud SDK
|
||||
uses: google-github-actions/setup-gcloud@master
|
||||
uses: google-github-actions/setup-gcloud@v0
|
||||
with:
|
||||
project_id: ${{ secrets.GCS_PROJECT_ID }}
|
||||
service_account_key: ${{ secrets.GCS_TF_KEY }}
|
||||
|
@ -60,7 +60,7 @@ jobs:
|
|||
working-directory: ./terraform
|
||||
run: |
|
||||
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 . \
|
||||
| tee tags.auto.tfvars.json
|
||||
|
||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -4,6 +4,5 @@ node_modules
|
|||
storybook-static
|
||||
.next
|
||||
worker
|
||||
wrangler.toml
|
||||
.devdbs
|
||||
dist
|
||||
|
|
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
@ -0,0 +1 @@
|
|||
16.13.2
|
|
@ -6,3 +6,4 @@ worker
|
|||
**/dist
|
||||
terraform
|
||||
.husky/_
|
||||
.mf
|
14
.stylelintrc
14
.stylelintrc
|
@ -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": {
|
||||
"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
|
||||
}
|
||||
}
|
14
README.md
14
README.md
|
@ -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.
|
||||
- When setting up your Discord Application, be sure to set `http://localhost:6609/login-callback` as the OAuth2 callback URL.
|
||||
- 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!
|
||||
- Develop you a Roleypoly!
|
||||
|
||||
#### 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.
|
||||
|
||||
- With pre-requisites:
|
||||
|
@ -93,26 +95,26 @@ Run:
|
|||
|
||||
- `yarn` to install deps
|
||||
- `yarn start:design-system` to open storybook
|
||||
- `yarn test` to test
|
||||
- `yarn test:design-system` to test
|
||||
|
||||
### 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:
|
||||
|
||||
- `yarn` to install deps
|
||||
- `yarn start:web` to run Next.js dev server
|
||||
- `yarn test` to test
|
||||
- `yarn test:web` to test
|
||||
|
||||
### 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:
|
||||
|
||||
- `yarn` to install deps
|
||||
- `yarn start:api` to start an emulated worker
|
||||
- `yarn test` to test
|
||||
- `yarn test:api` to test
|
||||
|
||||
[envexample]: .env.example
|
||||
|
|
|
@ -1,8 +1,4 @@
|
|||
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
|
||||
import Enzyme from 'enzyme';
|
||||
import enableHooks from 'jest-react-hooks-shallow';
|
||||
|
||||
Enzyme.configure({ adapter: new Adapter() });
|
||||
|
||||
// pass an instance of jest to `enableHooks()`
|
||||
enableHooks(jest);
|
||||
|
|
53
package.json
53
package.json
|
@ -18,55 +18,46 @@
|
|||
],
|
||||
"scripts": {
|
||||
"build": "run-p -c build:*",
|
||||
"build:api": "yarn workspace @roleypoly/api run build",
|
||||
"build:design-system": "yarn workspace @roleypoly/design-system run build",
|
||||
"build:web": "yarn workspace @roleypoly/web run build",
|
||||
"create-component": "yarn workspace @roleypoly/design-system run create-component",
|
||||
"lint": "run-p -c lint:* --",
|
||||
"lint:eslint": "eslint",
|
||||
"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:terraform": "terraform fmt -recursive -check ./terraform",
|
||||
"lint:stylelint": "cross-env stylelint 'packages/{web,design-system}/**/*.{ts,tsx}'",
|
||||
"lint:terraform": "terraform fmt -check -recursive",
|
||||
"lint:types": "tsc --noEmit",
|
||||
"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",
|
||||
"start": "run-p -c start:*",
|
||||
"start:api": "yarn workspace @roleypoly/api start",
|
||||
"start:bot": "yarn workspace @roleypoly/bot 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:worker": "yarn workspace @roleypoly/api start",
|
||||
"test": "jest"
|
||||
"test": "run-p -c test:* --",
|
||||
"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": {
|
||||
"@types/enzyme": "^3.10.9",
|
||||
"@types/lodash": "^4.14.171",
|
||||
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.2",
|
||||
"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",
|
||||
"@stylelint/postcss-css-in-js": "^0.37.2",
|
||||
"husky": "^7.0.4",
|
||||
"is-ci": "^3.0.1",
|
||||
"jest-react-hooks-shallow": "^1.5.1",
|
||||
"jest-styled-components": "^7.0.4",
|
||||
"lint-staged": "^11.0.0",
|
||||
"lint-staged": "^12.3.2",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^2.3.2",
|
||||
"prettier-plugin-organize-imports": "^2.2.0",
|
||||
"prettier-plugin-pkg": "^0.10.0",
|
||||
"prettier-plugin-sh": "^0.7.1",
|
||||
"stylelint": "^13.13.1",
|
||||
"stylelint-config-prettier": "^8.0.2",
|
||||
"stylelint-config-standard": "^22.0.0",
|
||||
"postcss": "^8.4.5",
|
||||
"postcss-syntax": "^0.36.2",
|
||||
"prettier": "^2.5.1",
|
||||
"prettier-plugin-organize-imports": "^2.3.4",
|
||||
"prettier-plugin-pkg": "^0.11.1",
|
||||
"prettier-plugin-sh": "^0.8.1",
|
||||
"stylelint": "^14.3.0",
|
||||
"stylelint-config-recommended": "^6.0.0",
|
||||
"stylelint-config-styled-components": "^0.1.1",
|
||||
"stylelint-prettier": "^1.2.0",
|
||||
"ts-jest": "^26.0.0",
|
||||
"typescript": "^4.3.5"
|
||||
},
|
||||
"resolutions": {
|
||||
"webpack": "4.44.2"
|
||||
"typescript": "^4.5.5"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,jsx}": [
|
||||
|
|
1
packages/api/.gitignore
vendored
1
packages/api/.gitignore
vendored
|
@ -1 +1,2 @@
|
|||
dist
|
||||
.mf
|
||||
|
|
1
packages/api/.nvmrc
Normal file
1
packages/api/.nvmrc
Normal file
|
@ -0,0 +1 @@
|
|||
17
|
14
packages/api/bindings.d.ts
vendored
14
packages/api/bindings.d.ts
vendored
|
@ -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;
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
);
|
|
@ -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);
|
||||
}
|
||||
);
|
|
@ -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,
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
};
|
|
@ -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 }));
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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 });
|
||||
}
|
||||
);
|
|
@ -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();
|
||||
}
|
||||
);
|
|
@ -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();
|
||||
}
|
||||
);
|
|
@ -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;
|
||||
};
|
|
@ -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));
|
||||
});
|
11
packages/api/jest.config.js
Normal file
11
packages/api/jest.config.js
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
|
@ -1,21 +1,30 @@
|
|||
{
|
||||
"name": "@roleypoly/api",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"main": "./src/index.ts",
|
||||
"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",
|
||||
"start": "cfw-emulator"
|
||||
"posttest": "rm .env",
|
||||
"pretest": "cp ../../.env.example .env && yarn build",
|
||||
"start": "miniflare --watch --debug",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^2.2.2",
|
||||
"@cloudflare/workers-types": "^3.3.1",
|
||||
"@roleypoly/misc-utils": "*",
|
||||
"@roleypoly/types": "*",
|
||||
"@roleypoly/worker-emulator": "*",
|
||||
"@roleypoly/worker-utils": "*",
|
||||
"@types/deep-equal": "^1.0.1",
|
||||
"deep-equal": "^2.0.5",
|
||||
"ksuid": "^2.0.0",
|
||||
"@types/node": "^17.0.13",
|
||||
"esbuild": "^0.14.14",
|
||||
"itty-router": "^2.4.10",
|
||||
"jest-environment-miniflare": "^2.2.0",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
5
packages/api/src/guilds/audit-logging.spec.ts
Normal file
5
packages/api/src/guilds/audit-logging.spec.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
it('works', () => {
|
||||
expect(true).toBeTruthy();
|
||||
});
|
||||
|
||||
export {};
|
1
packages/api/src/guilds/audit-logging.ts
Normal file
1
packages/api/src/guilds/audit-logging.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export {};
|
231
packages/api/src/guilds/getters.spec.ts
Normal file
231
packages/api/src/guilds/getters.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
196
packages/api/src/guilds/getters.ts
Normal file
196
packages/api/src/guilds/getters.ts
Normal 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;
|
||||
};
|
78
packages/api/src/guilds/middleware.spec.ts
Normal file
78
packages/api/src/guilds/middleware.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
47
packages/api/src/guilds/middleware.ts
Normal file
47
packages/api/src/guilds/middleware.ts
Normal 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
99
packages/api/src/index.ts
Normal 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)));
|
||||
},
|
||||
};
|
25
packages/api/src/routes/auth/bot.spec.ts
Normal file
25
packages/api/src/routes/auth/bot.spec.ts
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
|
@ -1,5 +1,5 @@
|
|||
import { Bounce } from '../utils/bounce';
|
||||
import { botClientID } from '../utils/config';
|
||||
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
|
||||
import { seeOther } from '@roleypoly/api/src/utils/response';
|
||||
|
||||
const validGuildID = /^[0-9]+$/;
|
||||
|
||||
|
@ -22,17 +22,20 @@ const buildURL = (params: URLParams) => {
|
|||
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') || '';
|
||||
|
||||
if (guildID && !validGuildID.test(guildID)) {
|
||||
guildID = '';
|
||||
}
|
||||
|
||||
return Bounce(
|
||||
return seeOther(
|
||||
buildURL({
|
||||
clientID: botClientID,
|
||||
permissions: 268435456,
|
||||
clientID: config.botClientID,
|
||||
permissions: 268435456, // Send messages + manage roles
|
||||
guildID,
|
||||
scopes: ['bot', 'applications.commands'],
|
||||
})
|
54
packages/api/src/routes/auth/bounce.spec.ts
Normal file
54
packages/api/src/routes/auth/bounce.spec.ts
Normal 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);
|
||||
}
|
||||
);
|
||||
});
|
64
packages/api/src/routes/auth/bounce.ts
Normal file
64
packages/api/src/routes/auth/bounce.ts
Normal 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 }));
|
||||
};
|
93
packages/api/src/routes/auth/callback.spec.ts
Normal file
93
packages/api/src/routes/auth/callback.spec.ts
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
76
packages/api/src/routes/auth/callback.ts
Normal file
76
packages/api/src/routes/auth/callback.ts
Normal 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);
|
||||
};
|
63
packages/api/src/routes/auth/delete-session.spec.ts
Normal file
63
packages/api/src/routes/auth/delete-session.spec.ts
Normal 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,
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
27
packages/api/src/routes/auth/delete-session.ts
Normal file
27
packages/api/src/routes/auth/delete-session.ts
Normal 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();
|
||||
};
|
53
packages/api/src/routes/auth/session.spec.ts
Normal file
53
packages/api/src/routes/auth/session.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
17
packages/api/src/routes/auth/session.ts
Normal file
17
packages/api/src/routes/auth/session.ts
Normal 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();
|
||||
};
|
32
packages/api/src/routes/guilds/guild-cache-delete.spec.ts
Normal file
32
packages/api/src/routes/guilds/guild-cache-delete.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
12
packages/api/src/routes/guilds/guild-cache-delete.ts
Normal file
12
packages/api/src/routes/guilds/guild-cache-delete.ts
Normal 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();
|
||||
};
|
370
packages/api/src/routes/guilds/guild-roles-put.spec.ts
Normal file
370
packages/api/src/routes/guilds/guild-roles-put.spec.ts
Normal 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({}));
|
||||
};
|
160
packages/api/src/routes/guilds/guild-roles-put.ts
Normal file
160
packages/api/src/routes/guilds/guild-roles-put.ts
Normal 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;
|
||||
};
|
164
packages/api/src/routes/guilds/guild.spec.ts
Normal file
164
packages/api/src/routes/guilds/guild.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
45
packages/api/src/routes/guilds/guild.ts
Normal file
45
packages/api/src/routes/guilds/guild.ts
Normal 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);
|
||||
};
|
164
packages/api/src/routes/guilds/guilds-patch.spec.ts
Normal file
164
packages/api/src/routes/guilds/guilds-patch.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
37
packages/api/src/routes/guilds/guilds-patch.ts
Normal file
37
packages/api/src/routes/guilds/guilds-patch.ts
Normal 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);
|
||||
};
|
52
packages/api/src/routes/guilds/slug.spec.ts
Normal file
52
packages/api/src/routes/guilds/slug.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
31
packages/api/src/routes/guilds/slug.ts
Normal file
31
packages/api/src/routes/guilds/slug.ts
Normal 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);
|
||||
};
|
|
@ -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',
|
||||
})
|
||||
);
|
||||
});
|
22
packages/api/src/routes/interactions/commands/hello-world.ts
Normal file
22
packages/api/src/routes/interactions/commands/hello-world.ts
Normal 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;
|
131
packages/api/src/routes/interactions/helpers.spec.ts
Normal file
131
packages/api/src/routes/interactions/helpers.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
94
packages/api/src/routes/interactions/helpers.ts
Normal file
94
packages/api/src/routes/interactions/helpers.ts
Normal 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) {}
|
||||
}
|
||||
};
|
49
packages/api/src/routes/interactions/interactions.spec.ts
Normal file
49
packages/api/src/routes/interactions/interactions.spec.ts
Normal 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);
|
||||
});
|
81
packages/api/src/routes/interactions/interactions.ts
Normal file
81
packages/api/src/routes/interactions/interactions.ts
Normal 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();
|
||||
}
|
||||
};
|
|
@ -1,10 +1,11 @@
|
|||
import { InteractionHandler } from '@roleypoly/api/src/routes/interactions/helpers';
|
||||
import {
|
||||
InteractionCallbackType,
|
||||
InteractionFlags,
|
||||
InteractionResponse,
|
||||
} from '@roleypoly/types';
|
||||
|
||||
export const mustBeInGuild = (): InteractionResponse => ({
|
||||
export const mustBeInGuild: InteractionHandler = (): InteractionResponse => ({
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
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,
|
||||
data: {
|
||||
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,
|
||||
data: {
|
||||
content: '<a:promareFlame:624850108667789333> Something went terribly wrong.',
|
||||
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,
|
||||
},
|
||||
});
|
134
packages/api/src/routes/interactions/testHelpers.ts
Normal file
134
packages/api/src/routes/interactions/testHelpers.ts
Normal 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',
|
||||
},
|
||||
];
|
||||
};
|
53
packages/api/src/sessions/create.spec.ts
Normal file
53
packages/api/src/sessions/create.spec.ts
Normal 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);
|
||||
});
|
31
packages/api/src/sessions/create.ts
Normal file
31
packages/api/src/sessions/create.ts
Normal 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;
|
||||
};
|
172
packages/api/src/sessions/middleware.spec.ts
Normal file
172
packages/api/src/sessions/middleware.spec.ts
Normal 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();
|
||||
});
|
67
packages/api/src/sessions/middleware.ts
Normal file
67
packages/api/src/sessions/middleware.ts
Normal 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 };
|
||||
};
|
25
packages/api/src/sessions/state.spec.ts
Normal file
25
packages/api/src/sessions/state.spec.ts
Normal 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();
|
||||
});
|
19
packages/api/src/sessions/state.ts
Normal file
19
packages/api/src/sessions/state.ts
Normal 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;
|
||||
};
|
78
packages/api/src/utils/config.ts
Normal file
78
packages/api/src/utils/config.ts
Normal 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
|
||||
},
|
||||
};
|
||||
};
|
40
packages/api/src/utils/context.ts
Normal file
40
packages/api/src/utils/context.ts
Normal 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;
|
34
packages/api/src/utils/discord.spec.ts
Normal file
34
packages/api/src/utils/discord.spec.ts
Normal 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]);
|
||||
});
|
||||
});
|
153
packages/api/src/utils/discord.ts
Normal file
153
packages/api/src/utils/discord.ts
Normal 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]
|
||||
);
|
||||
};
|
9
packages/api/src/utils/id.spec.ts
Normal file
9
packages/api/src/utils/id.spec.ts
Normal 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);
|
||||
});
|
6
packages/api/src/utils/id.ts
Normal file
6
packages/api/src/utils/id.ts
Normal 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);
|
91
packages/api/src/utils/kv.spec.ts
Normal file
91
packages/api/src/utils/kv.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
63
packages/api/src/utils/kv.ts
Normal file
63
packages/api/src/utils/kv.ts
Normal 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']>;
|
||||
}
|
|
@ -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 { CategoryType, Features, GuildData } from '@roleypoly/types';
|
||||
import KSUID from 'ksuid';
|
||||
import { importSharedKey } from './config';
|
||||
|
||||
export type LegacyCategory = {
|
||||
id: string;
|
||||
|
@ -18,12 +18,19 @@ export type LegacyGuildData = {
|
|||
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(
|
||||
`https://beta.roleypoly.com/x/import-to-next/${id}`,
|
||||
{
|
||||
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(
|
||||
(category, idx) => ({
|
||||
...category,
|
||||
id: KSUID.randomSync().string,
|
||||
id: getID(),
|
||||
position: idx, // Reset positions by index. May have side-effects but oh well.
|
||||
type: category.type === 'multi' ? CategoryType.Multi : CategoryType.Single,
|
||||
})
|
45
packages/api/src/utils/request.spec.ts
Normal file
45
packages/api/src/utils/request.spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
42
packages/api/src/utils/request.ts
Normal file
42
packages/api/src/utils/request.ts
Normal 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,
|
||||
};
|
||||
};
|
46
packages/api/src/utils/response.ts
Normal file
46
packages/api/src/utils/response.ts
Normal 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 });
|
102
packages/api/src/utils/testHelpers.ts
Normal file
102
packages/api/src/utils/testHelpers.ts
Normal 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
10
packages/api/test/miniflare.d.ts
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { Environment } from '../src/utils/config';
|
||||
|
||||
declare global {
|
||||
function getMiniflareBindings(): Environment;
|
||||
function getMiniflareDurableObjectStorage(
|
||||
id: DurableObjectId
|
||||
): Promise<DurableObjectStorage>;
|
||||
}
|
||||
|
||||
export {};
|
|
@ -1,15 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"lib": ["esnext", "webworker", "ES2020.BigInt", "ES2020.Promise"],
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"target": "ES2019"
|
||||
"types": ["@cloudflare/workers-types", "node"],
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"include": [
|
||||
"./*.ts",
|
||||
"./**/*.ts",
|
||||
"../../node_modules/@cloudflare/workers-types/index.d.ts"
|
||||
],
|
||||
"exclude": ["./**/*.spec.ts", "./dist/**"],
|
||||
"include": ["src/**/*", "test/**/*"],
|
||||
"exclude": ["./**/*.spec.ts"],
|
||||
"extends": "../../tsconfig.json"
|
||||
}
|
||||
|
|
6
packages/api/tsconfig.test.json
Normal file
6
packages/api/tsconfig.test.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["jest", "@cloudflare/workers-types", "node"]
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
};
|
|
@ -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);
|
||||
};
|
|
@ -1,7 +0,0 @@
|
|||
export const Bounce = (url: string): Response =>
|
||||
new Response(null, {
|
||||
status: 303,
|
||||
headers: {
|
||||
location: url,
|
||||
},
|
||||
});
|
|
@ -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');
|
|
@ -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);
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
);
|
|
@ -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));
|
|
@ -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;
|
||||
};
|
|
@ -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 });
|
|
@ -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'),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
|
@ -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
Loading…
Add table
Reference in a new issue