miniflare init

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

View file

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

1
packages/api/.nvmrc Normal file
View file

@ -0,0 +1 @@
17

View file

@ -1,14 +0,0 @@
export {};
declare global {
const BOT_CLIENT_ID: string;
const BOT_CLIENT_SECRET: string;
const UI_PUBLIC_URI: string;
const API_PUBLIC_URI: string;
const ROOT_USERS: string;
const ALLOWED_CALLBACK_HOSTS: string;
const KV_SESSIONS: KVNamespace;
const KV_GUILDS: KVNamespace;
const KV_GUILD_DATA: KVNamespace;
}

View file

@ -66,5 +66,5 @@ router.addFallback('root', () => {
});
addEventListener('fetch', (event: FetchEvent) => {
event.respondWith(router.handle(event));
router.handle(event));
});

View file

@ -1,11 +1,13 @@
{
"name": "@roleypoly/api",
"version": "0.1.0",
"main": "./src/index.ts",
"scripts": {
"build": "yarn workspace @roleypoly/worker-emulator build --basePath `pwd`",
"lint:types": "tsc --noEmit",
"start": "cfw-emulator"
"build": "esbuild --bundle --sourcemap --platform=node --format=esm --outdir=dist --out-extension:.js=.mjs ./src/index.ts",
"dev": "miniflare --watch --debug",
"lint:types": "tsc --noEmit"
},
"dependencies": {},
"devDependencies": {
"@cloudflare/workers-types": "^2.2.2",
"@roleypoly/misc-utils": "*",
@ -14,8 +16,13 @@
"@roleypoly/worker-utils": "*",
"@types/deep-equal": "^1.0.1",
"deep-equal": "^2.0.5",
"esbuild": "^0.14.13",
"exponential-backoff": "^3.1.0",
"itty-router": "^2.4.10",
"ksuid": "^2.0.0",
"lodash": "^4.17.21",
"ts-loader": "^8.3.0"
"miniflare": "^2.2.0",
"ts-loader": "^8.3.0",
"ulid-workers": "^1.1.0"
}
}

View file

@ -0,0 +1,64 @@
import { WrappedKVNamespace } from '@roleypoly/api/src/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;
KV_SESSIONS: KVNamespace;
KV_GUILDS: KVNamespace;
KV_GUILD_DATA: KVNamespace;
};
export type Config = {
botClientID: string;
botClientSecret: string;
botToken: string;
uiPublicURI: string;
apiPublicURI: string;
rootUsers: string[];
allowedCallbackHosts: string[];
importSharedKey: string;
interactionsSharedKey: string;
roleypolyServerID: string;
helperRoleIDs: string[];
kv: {
sessions: WrappedKVNamespace;
guilds: WrappedKVNamespace;
guildData: WrappedKVNamespace;
};
_raw: Environment;
};
const toList = (x: string): string[] => x.split(',');
const safeURI = (x: 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,
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),
},
};
};

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

@ -0,0 +1,32 @@
// @ts-ignore
import { authBounce } from '@roleypoly/api/src/routes/auth/bounce';
import { json, notFound } from '@roleypoly/api/src/utils/response';
import { Router } from 'itty-router';
import { Config, Environment, parseEnvironment } from './config';
const router = Router();
router.get('/auth/bounce', authBounce);
router.get('/', (request: Request, config: Config) =>
json({
__warning: '🦊',
this: 'is',
a: 'fox-based',
web: 'application',
please: 'be',
mindful: 'of',
your: 'surroundings',
warning__: '🦊',
meta: config.uiPublicURI,
})
);
router.get('*', () => notFound());
export default {
async fetch(request: Request, env: Environment, ctx: FetchEvent) {
const config = parseEnvironment(env);
return router.handle(request, config, ctx);
},
};

23
packages/api/src/kv.ts Normal file
View file

@ -0,0 +1,23 @@
export class WrappedKVNamespace {
constructor(private kvNamespace: 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,
});
}
getRaw = this.kvNamespace.get;
list = this.kvNamespace.list;
getWithMetadata = this.kvNamespace.getWithMetadata;
delete = this.kvNamespace.delete;
}

View file

@ -0,0 +1,45 @@
import { Config } from '@roleypoly/api/src/config';
import { setupStateSession } from '@roleypoly/api/src/sessions/state';
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}`;
export const isAllowedCallbackHost = (config: Config, host: string): boolean => {
return (
host === config.apiPublicURI ||
config.allowedCallbackHosts.includes(host) ||
config.allowedCallbackHosts
.filter((callbackHost) => callbackHost.includes('*'))
.find((wildcard) => new RegExp(wildcard.replace('*', '[a-z0-9-]+')).test(host)) !==
null
);
};
export const authBounce = async (request: Request, config: Config) => {
const stateSessionData: StateSession = {};
const { cbh: callbackHost } = getQuery(request);
if (callbackHost && isAllowedCallbackHost(config, callbackHost)) {
stateSessionData.callbackHost = callbackHost;
}
const state = await setupStateSession(config.kv.sessions, stateSessionData);
const redirectURI = `${config.apiPublicURI}/login-callback`;
const clientID = config.botClientID;
return seeOther(buildURL({ state, redirectURI, clientID }));
};

View file

@ -0,0 +1,75 @@
import { Config } from '@roleypoly/api/src/config';
import { isAllowedCallbackHost } from '@roleypoly/api/src/routes/auth/bounce';
import { getStateSession } from '@roleypoly/api/src/sessions/state';
import { getQuery, seeOther } from '@roleypoly/api/src/utils';
import { AuthType, discordAPIBase, discordFetch } from '@roleypoly/api/src/utils/discord';
import { formData } from '@roleypoly/api/src/utils/request';
import { AuthTokenResponse, StateSession } from '@roleypoly/types';
import { decodeTime, monotonicFactory } from 'ulid-workers';
const ulid = monotonicFactory();
const authFailure = (uiPublicURI: string, extra?: string) =>
seeOther(
uiPublicURI +
`/machinery/error?error_code=authFailure${extra ? `&extra=${extra}` : ''}`
);
export const authCallback = async (request: Request, config: Config) => {
let bounceBaseUrl = config.uiPublicURI;
const { state: stateValue, code } = getQuery(request);
if (stateValue === null) {
return authFailure('state missing');
}
try {
const stateTime = decodeTime(stateValue);
const stateExpiry = stateTime + 1000 * 60 * 5;
const currentTime = Date.now();
if (currentTime > stateExpiry) {
return authFailure('state expired');
}
const stateSession = await getStateSession<StateSession>(
config.kv.sessions,
stateValue
);
if (
stateSession?.callbackHost &&
isAllowedCallbackHost(config, stateSession.callbackHost)
) {
bounceBaseUrl = stateSession.callbackHost;
}
} catch (e) {
return authFailure('state invalid');
}
if (!code) {
return authFailure('code missing');
}
const response = await discordFetch<AuthTokenResponse>(
`${discordAPIBase}/oauth2/token`,
'',
AuthType.None,
{
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded',
},
body: formData({
client_id: config.botClientID,
client_secret: config.botClientSecret,
grant_type: 'authorization_code',
code,
redirect_uri: config.apiPublicURI + '/auth/callback',
}),
}
);
if (!response) {
return authFailure('code auth failure');
}
};

View file

@ -0,0 +1,8 @@
import { Config } from "@roleypoly/api/src/config";
import { AuthTokenResponse } from "@roleypoly/types";
export const createSession = async (
config: Config,
sessionId: string,
tokens: AuthTokenResponse
)

View file

@ -0,0 +1,5 @@
import { SessionData, SessionFlags } from '@roleypoly/types';
export const getSessionFlags = async (session: SessionData): Promise<SessionFlags> => {
return SessionFlags.None;
};

View file

@ -0,0 +1,23 @@
import { WrappedKVNamespace } from '@roleypoly/api/src/kv';
import { monotonicFactory } from 'ulid-workers';
const ulid = monotonicFactory();
export const setupStateSession = async <T>(
Sessions: WrappedKVNamespace,
data: T
): Promise<string> => {
const stateID = ulid();
await Sessions.put(`state_${stateID}`, { data }, 60 * 5);
return stateID;
};
export const getStateSession = async <T>(
Sessions: WrappedKVNamespace,
stateID: string
): Promise<T | undefined> => {
const stateSession = await Sessions.get<{ data: T }>(`state_${stateID}`);
return stateSession?.data;
};

View file

@ -0,0 +1,123 @@
import { Config } from '@roleypoly/api/src/config';
import {
evaluatePermission,
permissions as Permissions,
} from '@roleypoly/misc-utils/hasPermission';
import {
AuthTokenResponse,
DiscordUser,
GuildSlug,
UserGuildPermissions,
} from '@roleypoly/types';
export const userAgent =
'DiscordBot (https://github.com/roleypoly/roleypoly, git-main) (+https://roleypoly.com)';
export const discordAPIBase = 'https://discordapp.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, config: Config) => {
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,
config.roleypolyServerID
),
}));
return guildSlugs;
};
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;
};

View file

@ -0,0 +1,15 @@
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 formData = (obj: Record<string, any>): string => {
return Object.keys(obj)
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(obj[key])}`)
.join('&');
};

View file

@ -0,0 +1,24 @@
export const json = (obj: any, init?: ResponseInit): Response => {
const body = JSON.stringify(obj);
return new Response(body, {
...init,
headers: {
...init?.headers,
'content-type': 'application/json; charset=utf-8',
},
});
};
export const notFound = () => json({ error: 'not found' }, { status: 404 });
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',
},
}
);

View file

@ -3,7 +3,9 @@
"outDir": "./dist",
"lib": ["esnext", "webworker", "ES2020.BigInt", "ES2020.Promise"],
"types": ["@cloudflare/workers-types"],
"target": "ES2019"
"target": "ES2019",
"esModuleInterop": true,
"module": "commonjs"
},
"include": [
"./*.ts",

View file

@ -0,0 +1,29 @@
# THIS DOES NOT WORK WITH WRANGLER BY DEFAULT.
# BE EXTREMELY AWARE OF THIS CAVEAT.
name = "api"
type = "javascript"
account_id = ""
workers_dev = true
route = ""
zone_id = ""
kv_namespaces = [
{ binding = "KV_SESSIONS", id = "", preview_id = "" },
{ binding = "KV_GUILDS", id = "", preview_id = "" },
{ binding = "KV_GUILD_DATA", id = "", preview_id = "" },
]
[build]
command = "yarn build"
[build.upload]
format = "modules"
dir = "dist"
main = "index.mjs"
[miniflare]
host = "0.0.0.0"
port = 6609
watch = true
env_path = "../../.env"
kv_persist = true

View file

@ -354,8 +354,8 @@ export const LunarNewYear: Variant = {
// Feb 1, 2022
// Jan 22, 2023
activeIf: (currentDate?: Date) =>
matchDay(new Date('2022-Jan-30'), new Date('2022-Feb-3'), currentDate, true) ||
matchDay(new Date('2023-Jan-20'), new Date('2023-Jan-24'), currentDate, true),
matchDay(new Date('2022-Jan-27'), new Date('2022-Feb-10'), currentDate, true) ||
matchDay(new Date('2023-Jan-17'), new Date('2023-Jan-29'), currentDate, true),
sharedProps: {
circleFill: palette.red200,
circleOuterFill: palette.gold400,

View file

@ -50,6 +50,7 @@ export enum UserGuildPermissions {
User,
Manager = 1 << 1,
Admin = 1 << 2,
RoleypolySupport = 1 << 3,
}
export type GuildSlug = {

View file

@ -9,12 +9,19 @@ export type AuthTokenResponse = {
scope: string;
};
export enum SessionFlags {
None = 0,
IsRoot = 1 << 0,
IsSupport = 1 << 1,
}
export type SessionData = {
/** sessionID is a KSUID */
sessionID: string;
tokens: AuthTokenResponse;
user: DiscordUser;
guilds: GuildSlug[];
flags: SessionFlags;
};
export type StateSession = {

View file

@ -32,6 +32,7 @@
"@types/react-helmet": "^6.1.2",
"babel-loader": "8.1.0",
"cross-env": "7.0.3",
"exponential-backoff": "^3.1.0",
"ts-loader": "^8.3.0",
"webpack": "4.44.2"
},

View file

@ -160,6 +160,7 @@ export const SessionContextProvider = (props: { children: React.ReactNode }) =>
setLock(false);
} catch (e) {
console.error('syncSession failed', e);
deleteSessionKey();
setLock(false);
}
};
@ -179,7 +180,7 @@ const saveSessionKey = (key: string) => localStorage.setItem('rp_session_key', k
const deleteSessionKey = () => localStorage.removeItem('rp_session_key');
const getSessionKey = () => localStorage.getItem('rp_session_key');
type ServerSession = Omit<SessionData, 'tokens'>;
type ServerSession = Omit<Omit<SessionData, 'tokens'>, 'flags'>;
const fetchSession = async (
authedFetch: SessionContextT['authedFetch']
): Promise<ServerSession | null> => {