diff --git a/.env.example b/.env.example index f9a7549..f6ac453 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ BOT_CLIENT_ID=000000000000000000 BOT_CLIENT_SECRET=RnX8pXXXXXXXXXXXXXXXXXXXXXXXXXu- BOT_TOKEN=Mzk2MjI3MTM0MjI3NXXXXXXXXXXXXXXXXXXXXXPUlYoARXXXXXXXXXXXXXX +DISCORD_PUBLIC_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXx # Comma separated; put your user ID here. Gives elevated permissions to everything. ROOT_USERS=62601275618889728 diff --git a/packages/api/handlers/bot-join.ts b/packages/api/handlers/bot-join.ts index 3a6cc9e..30670fe 100644 --- a/packages/api/handlers/bot-join.ts +++ b/packages/api/handlers/bot-join.ts @@ -7,10 +7,13 @@ type URLParams = { clientID: string; permissions: number; guildID?: string; + scopes: string[]; }; const buildURL = (params: URLParams) => { - let url = `https://discord.com/api/oauth2/authorize?client_id=${params.clientID}&scope=bot&permissions=${params.permissions}`; + let url = `https://discord.com/api/oauth2/authorize?client_id=${ + params.clientID + }&scope=${params.scopes.join('%20')}&permissions=${params.permissions}`; if (params.guildID) { url += `&guild_id=${params.guildID}&disable_guild_select=true`; @@ -31,6 +34,7 @@ export const BotJoin = (request: Request): Response => { clientID: botClientID, permissions: 268435456, guildID, + scopes: ['bot', 'application.commands'], }) ); }; diff --git a/packages/api/handlers/get-picker-data.ts b/packages/api/handlers/get-picker-data.ts index a85c146..786f237 100644 --- a/packages/api/handlers/get-picker-data.ts +++ b/packages/api/handlers/get-picker-data.ts @@ -1,7 +1,8 @@ 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, withSession } from '../utils/api-tools'; +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 }); diff --git a/packages/api/handlers/get-session.ts b/packages/api/handlers/get-session.ts index 9b44b19..3684ff7 100644 --- a/packages/api/handlers/get-session.ts +++ b/packages/api/handlers/get-session.ts @@ -1,5 +1,6 @@ import { SessionData } from '@roleypoly/types'; -import { respond, withSession } from '../utils/api-tools'; +import { respond } from '@roleypoly/worker-utils'; +import { withSession } from '../utils/api-tools'; export const GetSession = withSession((session?: SessionData) => (): Response => { const { user, guilds, sessionID } = session || {}; diff --git a/packages/api/handlers/get-slug.ts b/packages/api/handlers/get-slug.ts index f8b505b..860231c 100644 --- a/packages/api/handlers/get-slug.ts +++ b/packages/api/handlers/get-slug.ts @@ -1,5 +1,5 @@ import { GuildSlug } from '@roleypoly/types'; -import { respond } from '../utils/api-tools'; +import { respond } from '@roleypoly/worker-utils'; import { getGuild } from '../utils/guild'; export const GetSlug = async (request: Request): Promise => { diff --git a/packages/api/handlers/login-bounce.ts b/packages/api/handlers/login-bounce.ts index 00c959b..bbfb5e1 100644 --- a/packages/api/handlers/login-bounce.ts +++ b/packages/api/handlers/login-bounce.ts @@ -1,5 +1,6 @@ import { StateSession } from '@roleypoly/types'; -import { getQuery, isAllowedCallbackHost, setupStateSession } from '../utils/api-tools'; +import { getQuery } from '@roleypoly/worker-utils'; +import { isAllowedCallbackHost, setupStateSession } from '../utils/api-tools'; import { Bounce } from '../utils/bounce'; import { apiPublicURI, botClientID } from '../utils/config'; diff --git a/packages/api/handlers/login-callback.ts b/packages/api/handlers/login-callback.ts index 59c9876..4258f1d 100644 --- a/packages/api/handlers/login-callback.ts +++ b/packages/api/handlers/login-callback.ts @@ -5,25 +5,22 @@ import { SessionData, StateSession, } from '@roleypoly/types'; -import KSUID from 'ksuid'; import { AuthType, + discordAPIBase, discordFetch, + userAgent, +} from '@roleypoly/worker-utils'; +import KSUID from 'ksuid'; +import { formData, getStateSession, isAllowedCallbackHost, parsePermissions, resolveFailures, - userAgent, } from '../utils/api-tools'; import { Bounce } from '../utils/bounce'; -import { - apiPublicURI, - botClientID, - botClientSecret, - discordAPIBase, - uiPublicURI, -} from '../utils/config'; +import { apiPublicURI, botClientID, botClientSecret, uiPublicURI } from '../utils/config'; import { Sessions } from '../utils/kv'; const AuthErrorResponse = (extra?: string) => diff --git a/packages/api/handlers/revoke-session.ts b/packages/api/handlers/revoke-session.ts index 57593cf..c80acce 100644 --- a/packages/api/handlers/revoke-session.ts +++ b/packages/api/handlers/revoke-session.ts @@ -1,6 +1,7 @@ import { SessionData } from '@roleypoly/types'; -import { formData, respond, userAgent, withSession } from '../utils/api-tools'; -import { botClientID, botClientSecret, discordAPIBase } from '../utils/config'; +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( diff --git a/packages/api/handlers/update-roles.ts b/packages/api/handlers/update-roles.ts index 2ab9888..cf8256d 100644 --- a/packages/api/handlers/update-roles.ts +++ b/packages/api/handlers/update-roles.ts @@ -10,8 +10,9 @@ import { SessionData, TransactionType, } from '@roleypoly/types'; +import { AuthType, discordFetch, respond } from '@roleypoly/worker-utils'; import { difference, groupBy, keyBy, union } from 'lodash'; -import { AuthType, discordFetch, respond, withSession } from '../utils/api-tools'; +import { withSession } from '../utils/api-tools'; import { botToken } from '../utils/config'; import { getGuild, diff --git a/packages/api/index.ts b/packages/api/index.ts index cd221d7..e5bd0bf 100644 --- a/packages/api/index.ts +++ b/packages/api/index.ts @@ -1,3 +1,4 @@ +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'; @@ -9,7 +10,6 @@ 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 { Router } from './router'; import { respond } from './utils/api-tools'; import { uiPublicURI } from './utils/config'; diff --git a/packages/api/package.json b/packages/api/package.json index b5d0aee..6316916 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -11,6 +11,7 @@ "@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", diff --git a/packages/api/utils/api-tools.ts b/packages/api/utils/api-tools.ts index 55f6b13..4d00ff2 100644 --- a/packages/api/utils/api-tools.ts +++ b/packages/api/utils/api-tools.ts @@ -3,10 +3,10 @@ import { permissions as Permissions, } from '@roleypoly/misc-utils/hasPermission'; import { SessionData, UserGuildPermissions } from '@roleypoly/types'; +import { Handler, WrappedKVNamespace } from '@roleypoly/worker-utils'; import KSUID from 'ksuid'; -import { Handler } from '../router'; -import { allowedCallbackHosts, apiPublicURI, discordAPIBase, rootUsers } from './config'; -import { Sessions, WrappedKVNamespace } from './kv'; +import { allowedCallbackHosts, apiPublicURI, rootUsers } from './config'; +import { Sessions } from './kv'; export const formData = (obj: Record): string => { return Object.keys(obj) @@ -14,18 +14,8 @@ export const formData = (obj: Record): string => { .join('&'); }; -export const addCORS = (init: ResponseInit = {}) => ({ - ...init, - headers: { - ...(init.headers || {}), - 'access-control-allow-origin': '*', - 'access-control-allow-methods': '*', - 'access-control-allow-headers': '*', - }, -}); - export const respond = (obj: Record, init: ResponseInit = {}) => - new Response(JSON.stringify(obj), addCORS(init)); + new Response(JSON.stringify(obj), init); export const resolveFailures = ( @@ -70,44 +60,6 @@ export const getSessionID = (request: Request): { type: string; id: string } | n return { type, id }; }; -export const userAgent = - 'DiscordBot (https://github.com/roleypoly/roleypoly, git-main) (+https://roleypoly.com)'; - -export enum AuthType { - Bearer = 'Bearer', - Bot = 'Bot', -} - -export const discordFetch = async ( - url: string, - auth: string, - authType: AuthType = AuthType.Bearer, - init?: RequestInit -): Promise => { - const response = await fetch(discordAPIBase + url, { - ...(init || {}), - headers: { - ...(init?.headers || {}), - authorization: `${AuthType[authType]} ${auth}`, - 'user-agent': userAgent, - }, - }); - - if (response.status >= 400) { - console.error('discordFetch failed', { - url, - authType, - payload: await response.text(), - }); - } - - if (response.ok) { - return (await response.json()) as T; - } else { - return null; - } -}; - export type CacheLayerOptions = { skipCachePull?: boolean; }; @@ -195,16 +147,6 @@ export const onlyRootUsers = (handler: Handler): Handler => ); }); -export const getQuery = (request: Request): { [x: string]: string } => { - const output: { [x: string]: string } = {}; - - for (let [key, value] of new URL(request.url).searchParams.entries()) { - output[key] = value; - } - - return output; -}; - export const isAllowedCallbackHost = (host: string): boolean => { return ( host === apiPublicURI || diff --git a/packages/api/utils/audit-log.ts b/packages/api/utils/audit-log.ts index 5e1869e..90a5a14 100644 --- a/packages/api/utils/audit-log.ts +++ b/packages/api/utils/audit-log.ts @@ -1,4 +1,3 @@ -import { userAgent } from '@roleypoly/api/utils/api-tools'; import { uiPublicURI } from '@roleypoly/api/utils/config'; import { Category, @@ -8,6 +7,7 @@ import { GuildSlug, WebhookValidationStatus, } from '@roleypoly/types'; +import { userAgent } from '@roleypoly/worker-utils'; import deepEqual from 'deep-equal'; import { sortBy, uniq } from 'lodash'; diff --git a/packages/api/utils/config.ts b/packages/api/utils/config.ts index 5194504..935bebd 100644 --- a/packages/api/utils/config.ts +++ b/packages/api/utils/config.ts @@ -13,5 +13,3 @@ 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 discordAPIBase = 'https://discordapp.com/api/v9'; diff --git a/packages/api/utils/guild.ts b/packages/api/utils/guild.ts index b7edc87..d3633e0 100644 --- a/packages/api/utils/guild.ts +++ b/packages/api/utils/guild.ts @@ -1,4 +1,3 @@ -import { Handler } from '@roleypoly/api/router'; import { lowPermissions, missingParameters, @@ -18,14 +17,8 @@ import { SessionData, UserGuildPermissions, } from '@roleypoly/types'; -import { - AuthType, - cacheLayer, - CacheLayerOptions, - discordFetch, - isRoot, - withSession, -} from './api-tools'; +import { AuthType, discordFetch, Handler } 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'; diff --git a/packages/api/utils/kv.ts b/packages/api/utils/kv.ts index db18ef5..33c75c9 100644 --- a/packages/api/utils/kv.ts +++ b/packages/api/utils/kv.ts @@ -1,83 +1,4 @@ -export class WrappedKVNamespace { - constructor(private kvNamespace: KVNamespace) {} - - async get(key: string): Promise { - const data = await this.kvNamespace.get(key, 'text'); - if (!data) { - return null; - } - - return JSON.parse(data) as T; - } - - async put(key: string, value: T, ttlSeconds?: number) { - await this.kvNamespace.put(key, JSON.stringify(value), { - expirationTtl: ttlSeconds, - }); - } - - list = this.kvNamespace.list; - getWithMetadata = this.kvNamespace.getWithMetadata; - delete = this.kvNamespace.delete; -} - -class EmulatedKV implements KVNamespace { - constructor() { - console.warn('EmulatedKV used. Data will be lost.'); - } - - private data: Map = new Map(); - - async get(key: string): Promise { - if (!this.data.has(key)) { - return null; - } - - return this.data.get(key); - } - - async getWithMetadata( - key: string - ): KVValueWithMetadata { - return { - value: await this.get(key), - metadata: {} as Metadata, - }; - } - - async put(key: string, value: string | ReadableStream | ArrayBuffer | FormData) { - this.data.set(key, value); - } - - async delete(key: string) { - this.data.delete(key); - } - - async list(options?: { prefix?: string; limit?: number; cursor?: string }): Promise<{ - keys: { name: string; expiration?: number; metadata?: unknown }[]; - list_complete: boolean; - cursor: string; - }> { - let keys: { name: string }[] = []; - - for (let key of this.data.keys()) { - if (options?.prefix && !key.startsWith(options.prefix)) { - continue; - } - - keys.push({ name: key }); - } - - return { - keys, - cursor: '0', - list_complete: true, - }; - } -} - -const kvOrLocal = (namespace: KVNamespace | null): KVNamespace => - namespace || new EmulatedKV(); +import { kvOrLocal, WrappedKVNamespace } from '@roleypoly/worker-utils'; const self = global as any as Record; diff --git a/packages/api/utils/responses.ts b/packages/api/utils/responses.ts index 0ec921d..72bb764 100644 --- a/packages/api/utils/responses.ts +++ b/packages/api/utils/responses.ts @@ -1,4 +1,4 @@ -import { respond } from './api-tools'; +import { respond } from '@roleypoly/worker-utils'; export const ok = () => respond({ ok: true }); diff --git a/packages/interactions/handlers/healthz.ts b/packages/interactions/handlers/healthz.ts new file mode 100644 index 0000000..dc90791 --- /dev/null +++ b/packages/interactions/handlers/healthz.ts @@ -0,0 +1,5 @@ +import { respond } from '@roleypoly/worker-utils'; + +export const healthz = async (request: Request): Promise => { + return respond({ ok: true }); +}; diff --git a/packages/interactions/handlers/interaction.ts b/packages/interactions/handlers/interaction.ts new file mode 100644 index 0000000..adf1aac --- /dev/null +++ b/packages/interactions/handlers/interaction.ts @@ -0,0 +1,49 @@ +import { helloWorld } from '@roleypoly/interactions/handlers/interactions/hello-world'; +import { verifyRequest } from '@roleypoly/interactions/utils/interactions'; +import { + InteractionData, + InteractionRequest, + InteractionRequestCommand, + InteractionResponse, + InteractionType, +} from '@roleypoly/types'; +import { respond } from '@roleypoly/worker-utils'; + +const commands: Record< + InteractionData['name'], + (request: InteractionRequestCommand) => Promise +> = { + 'hello-world': helloWorld, +}; + +export const interactionHandler = async (request: Request): Promise => { + if (!(await verifyRequest(request))) { + return new Response('invalid request signature', { status: 401 }); + } + + const interaction = (await request.json()) as InteractionRequest; + + if (interaction.type === InteractionType.PING) { + return respond({ type: 1 }); + } + + if (interaction.type !== InteractionType.APPLICATION_COMMAND) { + return respond({ err: 'not implemented' }, { status: 400 }); + } + + if (!interaction.data) { + return respond({ err: 'data missing' }, { status: 400 }); + } + + const handler = commands[interaction.data.name]; + if (!handler) { + return respond({ err: 'not implemented' }, { status: 400 }); + } + + try { + const response = await handler(interaction as InteractionRequestCommand); + return respond(response); + } catch (e) { + return respond({ err: 'command errored' }, { status: 500 }); + } +}; diff --git a/packages/interactions/handlers/interactions/hello-world.ts b/packages/interactions/handlers/interactions/hello-world.ts new file mode 100644 index 0000000..990ffe7 --- /dev/null +++ b/packages/interactions/handlers/interactions/hello-world.ts @@ -0,0 +1,16 @@ +import { + InteractionCallbackType, + InteractionRequestCommand, + InteractionResponse, +} from '@roleypoly/types'; + +export const helloWorld = async ( + interaction: InteractionRequestCommand +): Promise => { + return { + type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `Hey there, ${interaction.member?.nick || interaction.user?.username}`, + }, + }; +}; diff --git a/packages/interactions/index.ts b/packages/interactions/index.ts new file mode 100644 index 0000000..a76af6e --- /dev/null +++ b/packages/interactions/index.ts @@ -0,0 +1,27 @@ +import { respond } from '@roleypoly/worker-utils'; +import { Router } from '@roleypoly/worker-utils/router'; +import { healthz } from './handlers/healthz'; +import { uiPublicURI } from './utils/config'; + +const router = new Router(); + +router.add('GET', '/_healthz', healthz); + +// Root Zen <3 +router.addFallback('root', () => { + return respond({ + __warning: '🦊', + this: 'is', + a: 'fox-based', + web: 'application', + please: 'be', + mindful: 'of', + your: 'surroundings', + warning__: '🦊', + meta: uiPublicURI, + }); +}); + +addEventListener('fetch', (event: FetchEvent) => { + event.respondWith(router.handle(event.request)); +}); diff --git a/packages/interactions/package.json b/packages/interactions/package.json new file mode 100644 index 0000000..4378ddb --- /dev/null +++ b/packages/interactions/package.json @@ -0,0 +1,15 @@ +{ + "name": "@roleypoly/interactions", + "version": "0.1.0", + "scripts": { + "build": "yarn workspace @roleypoly/worker-emulator build --basePath `pwd`", + "lint:types": "tsc --noEmit", + "start": "cfw-emulator" + }, + "devDependencies": { + "@cloudflare/workers-types": "^2.2.2", + "@roleypoly/types": "*", + "@roleypoly/worker-utils": "*", + "tweetnacl": "^1.0.3" + } +} diff --git a/packages/interactions/tsconfig.json b/packages/interactions/tsconfig.json new file mode 100644 index 0000000..1b989a0 --- /dev/null +++ b/packages/interactions/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "outDir": "./dist", + "lib": ["esnext", "webworker", "ES2020.BigInt", "ES2020.Promise"], + "types": ["@cloudflare/workers-types"], + "target": "ES2019" + }, + "include": [ + "./*.ts", + "./**/*.ts", + "../../node_modules/@cloudflare/workers-types/index.d.ts" + ], + "exclude": ["./**/*.spec.ts", "./dist/**"], + "extends": "../../tsconfig.json" +} diff --git a/packages/interactions/utils/config.ts b/packages/interactions/utils/config.ts new file mode 100644 index 0000000..9064e79 --- /dev/null +++ b/packages/interactions/utils/config.ts @@ -0,0 +1,10 @@ +const self = global as any as Record; + +const env = (key: string) => self[key] ?? ''; + +const safeURI = (x: string) => x.replace(/\/$/, ''); +const list = (x: string) => x.split(','); + +export const uiPublicURI = safeURI(env('UI_PUBLIC_URI')); +export const apiPublicURI = safeURI(env('API_PUBLIC_URI')); +export const publicKey = safeURI(env('DISCORD_PUBLIC_KEY')); diff --git a/packages/interactions/utils/interactions.ts b/packages/interactions/utils/interactions.ts new file mode 100644 index 0000000..4c94f01 --- /dev/null +++ b/packages/interactions/utils/interactions.ts @@ -0,0 +1,25 @@ +import { publicKey } from '@roleypoly/interactions/utils/config'; +import nacl from 'tweetnacl'; + +export const verifyRequest = async (request: Request): Promise => { + const timestamp = request.headers.get('x-signature-timestamp'); + const signature = request.headers.get('x-signature-ed25519'); + + if (!timestamp || !signature) { + return false; + } + + const body = await request.json(); + + if ( + !nacl.sign.detached.verify( + Buffer.from(timestamp + JSON.stringify(body)), + Buffer.from(signature, 'hex'), + Buffer.from(publicKey, 'hex') + ) + ) { + return false; + } + + return true; +}; diff --git a/packages/interactions/webpack.config.js b/packages/interactions/webpack.config.js new file mode 100644 index 0000000..bc319c2 --- /dev/null +++ b/packages/interactions/webpack.config.js @@ -0,0 +1,28 @@ +const path = require('path'); + +const mode = process.env.NODE_ENV || 'production'; + +module.exports = { + target: 'webworker', + entry: path.join(__dirname, 'index.ts'), + output: { + filename: `worker.${mode}.js`, + path: path.join(__dirname, 'dist'), + }, + mode, + resolve: { + extensions: ['.ts', '.tsx', '.js'], + }, + module: { + rules: [ + { + test: /\.tsx?$/, + loader: 'ts-loader', + options: { + transpileOnly: true, + configFile: path.join(__dirname, 'tsconfig.json'), + }, + }, + ], + }, +}; diff --git a/packages/interactions/worker.config.js b/packages/interactions/worker.config.js new file mode 100644 index 0000000..ec8f372 --- /dev/null +++ b/packages/interactions/worker.config.js @@ -0,0 +1,7 @@ +const reexportEnv = (keys = []) => { + return keys.reduce((acc, key) => ({ ...acc, [key]: process.env[key] }), {}); +}; + +module.exports = { + environment: reexportEnv(['DISCORD_PUBLIC_KEY', 'UI_PUBLIC_URI', 'API_PUBLIC_URI']), +}; diff --git a/packages/types/Interactions.ts b/packages/types/Interactions.ts new file mode 100644 index 0000000..8ae3e00 --- /dev/null +++ b/packages/types/Interactions.ts @@ -0,0 +1,56 @@ +import { DiscordUser, Member } from '@roleypoly/types/User'; + +export enum InteractionType { + PING = 1, + APPLICATION_COMMAND = 2, + MESSAGE_COMPONENT = 3, +} + +export type InteractionRequest = { + id: string; + application_id: string; + token: string; + version: 1; + type: InteractionType; + data?: InteractionData; + guild_id?: string; + channel_id?: string; + member?: Member; + user?: DiscordUser; + message?: {}; +}; + +export type InteractionRequestCommand = InteractionRequest & { + data: InteractionData; +}; + +export type InteractionData = { + id: string; + name: string; + resolved?: {}; + options?: {}[]; + custom_id: string; + component_type: string; +}; + +export enum InteractionCallbackType { + PONG = 1, + CHANNEL_MESSAGE_WITH_SOURCE = 4, + DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE = 5, + DEFERRED_UPDATE_MESSAGE = 6, + UPDATE_MESSAGE = 7, +} + +export type InteractionCallbackData = { + tts?: boolean; + content?: string; + embeds?: {}; + allowed_mentions?: {}; + flags?: number; + components?: {}[]; +}; + +export type InteractionResponse = { + type: InteractionCallbackType; + data?: InteractionCallbackData; +}; diff --git a/packages/types/index.ts b/packages/types/index.ts index 6d88788..f61a524 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -1,5 +1,6 @@ export * from './Category'; export * from './Guild'; +export * from './Interactions'; export * from './Role'; export * from './Session'; export * from './User'; diff --git a/packages/worker-utils/api.ts b/packages/worker-utils/api.ts new file mode 100644 index 0000000..26c7491 --- /dev/null +++ b/packages/worker-utils/api.ts @@ -0,0 +1,21 @@ +export const respond = (obj: Record, init: ResponseInit = {}) => + new Response(JSON.stringify(obj), { + ...init, + headers: { + ...(init.headers || {}), + 'content-type': 'application/json', + }, + }); + +export const userAgent = + 'DiscordBot (https://github.com/roleypoly/roleypoly, git-main) (+https://roleypoly.com)'; + +export const getQuery = (request: Request): { [x: string]: string } => { + const output: { [x: string]: string } = {}; + + for (let [key, value] of new URL(request.url).searchParams.entries()) { + output[key] = value; + } + + return output; +}; diff --git a/packages/worker-utils/discord.ts b/packages/worker-utils/discord.ts new file mode 100644 index 0000000..4fef623 --- /dev/null +++ b/packages/worker-utils/discord.ts @@ -0,0 +1,38 @@ +import { userAgent } from './api'; + +export const discordAPIBase = 'https://discordapp.com/api/v9'; + +export enum AuthType { + Bearer = 'Bearer', + Bot = 'Bot', +} + +export const discordFetch = async ( + url: string, + auth: string, + authType: AuthType = AuthType.Bearer, + init?: RequestInit +): Promise => { + const response = await fetch(discordAPIBase + url, { + ...(init || {}), + headers: { + ...(init?.headers || {}), + authorization: `${AuthType[authType]} ${auth}`, + 'user-agent': userAgent, + }, + }); + + if (response.status >= 400) { + console.error('discordFetch failed', { + url, + authType, + payload: await response.text(), + }); + } + + if (response.ok) { + return (await response.json()) as T; + } else { + return null; + } +}; diff --git a/packages/worker-utils/index.ts b/packages/worker-utils/index.ts new file mode 100644 index 0000000..1bfa41f --- /dev/null +++ b/packages/worker-utils/index.ts @@ -0,0 +1,4 @@ +export * from './api'; +export * from './discord'; +export * from './kv'; +export * from './router'; diff --git a/packages/worker-utils/kv.ts b/packages/worker-utils/kv.ts new file mode 100644 index 0000000..7c9339b --- /dev/null +++ b/packages/worker-utils/kv.ts @@ -0,0 +1,80 @@ +export class WrappedKVNamespace { + constructor(private kvNamespace: KVNamespace) {} + + async get(key: string): Promise { + const data = await this.kvNamespace.get(key, 'text'); + if (!data) { + return null; + } + + return JSON.parse(data) as T; + } + + async put(key: string, value: T, ttlSeconds?: number) { + await this.kvNamespace.put(key, JSON.stringify(value), { + expirationTtl: ttlSeconds, + }); + } + + list = this.kvNamespace.list; + getWithMetadata = this.kvNamespace.getWithMetadata; + delete = this.kvNamespace.delete; +} + +class EmulatedKV implements KVNamespace { + constructor() { + console.warn('EmulatedKV used. Data will be lost.'); + } + + private data: Map = new Map(); + + async get(key: string): Promise { + if (!this.data.has(key)) { + return null; + } + + return this.data.get(key); + } + + async getWithMetadata( + key: string + ): KVValueWithMetadata { + return { + value: await this.get(key), + metadata: {} as Metadata, + }; + } + + async put(key: string, value: string | ReadableStream | ArrayBuffer | FormData) { + this.data.set(key, value); + } + + async delete(key: string) { + this.data.delete(key); + } + + async list(options?: { prefix?: string; limit?: number; cursor?: string }): Promise<{ + keys: { name: string; expiration?: number; metadata?: unknown }[]; + list_complete: boolean; + cursor: string; + }> { + let keys: { name: string }[] = []; + + for (let key of this.data.keys()) { + if (options?.prefix && !key.startsWith(options.prefix)) { + continue; + } + + keys.push({ name: key }); + } + + return { + keys, + cursor: '0', + list_complete: true, + }; + } +} + +export const kvOrLocal = (namespace: KVNamespace | null): KVNamespace => + namespace || new EmulatedKV(); diff --git a/packages/worker-utils/package.json b/packages/worker-utils/package.json new file mode 100644 index 0000000..8e37e28 --- /dev/null +++ b/packages/worker-utils/package.json @@ -0,0 +1,7 @@ +{ + "name": "@roleypoly/worker-utils", + "version": "0.1.0", + "devDependencies": { + "@cloudflare/workers-types": "^2.2.2" + } +} diff --git a/packages/api/router.ts b/packages/worker-utils/router.ts similarity index 65% rename from packages/api/router.ts rename to packages/worker-utils/router.ts index dbd1313..8e841ad 100644 --- a/packages/api/router.ts +++ b/packages/worker-utils/router.ts @@ -1,6 +1,3 @@ -import { addCORS } from './utils/api-tools'; -import { uiPublicURI } from './utils/config'; - export type Handler = (request: Request) => Promise | Response; type RoutingTree = { @@ -23,7 +20,11 @@ export class Router { 500: this.serverError, }; - private uiURL = new URL(uiPublicURI); + private corsOrigins: string[] = []; + + addCORSOrigins(origins: string[]) { + this.corsOrigins = [...this.corsOrigins, ...origins]; + } addFallback(which: keyof Fallbacks, handler: Handler) { this.fallbacks[which] = handler; @@ -40,6 +41,12 @@ export class Router { } async handle(request: Request): Promise { + const response = await this.processRequest(request); + this.injectCORSHeaders(request, response.headers); + return response; + } + + private async processRequest(request: Request): Promise { const url = new URL(request.url); if (url.pathname === '/' || url.pathname === '') { @@ -60,7 +67,7 @@ export class Router { } if (lowerMethod === 'options') { - return new Response(null, addCORS({})); + return new Response(null, {}); } return this.fallbacks[404](request); @@ -81,4 +88,24 @@ export class Router { status: 500, }); } + + private injectCORSHeaders(request: Request, headers: Headers) { + headers.set('access-control-allow-methods', '*'); + headers.set('access-control-allow-headers', '*'); + + if (this.corsOrigins.length === 0) { + headers.set('access-control-allow-origin', '*'); + return; + } + + const originHeader = request.headers.get('origin'); + if (!originHeader) { + return; + } + + const originHostname = new URL(originHeader).hostname; + if (this.corsOrigins.includes(originHostname)) { + headers.set('access-control-allow-origin', originHostname); + } + } } diff --git a/packages/worker-utils/tsconfig.json b/packages/worker-utils/tsconfig.json new file mode 100644 index 0000000..1b989a0 --- /dev/null +++ b/packages/worker-utils/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "outDir": "./dist", + "lib": ["esnext", "webworker", "ES2020.BigInt", "ES2020.Promise"], + "types": ["@cloudflare/workers-types"], + "target": "ES2019" + }, + "include": [ + "./*.ts", + "./**/*.ts", + "../../node_modules/@cloudflare/workers-types/index.d.ts" + ], + "exclude": ["./**/*.spec.ts", "./dist/**"], + "extends": "../../tsconfig.json" +}