Refactor node packages to yarn workspaces & ditch next.js for CRA. (#161)

* chore: restructure project into yarn workspaces, remove next

* fix tests, remove webapp from terraform

* remove more ui deployment bits

* remove pages, fix FUNDING.yml

* remove isomorphism

* remove next providers

* fix linting issues

* feat: start basis of new web ui system on CRA

* chore: move types to @roleypoly/types package

* chore: move src/common/utils to @roleypoly/misc-utils

* chore: remove roleypoly/ path remappers

* chore: renmove vercel config

* chore: re-add worker-types to api package

* chore: fix type linting scope for api

* fix(web): craco should include all of packages dir

* fix(ci): change api webpack path for wrangler

* chore: remove GAR actions from CI

* chore: update codeql job

* chore: test better github dar matcher in lint-staged
This commit is contained in:
41666 2021-03-12 18:04:49 -05:00 committed by GitHub
parent 49e308507e
commit 2ff6588030
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
328 changed files with 16624 additions and 3525 deletions

View file

@ -0,0 +1,178 @@
import {
evaluatePermission,
permissions as Permissions,
} from '@roleypoly/misc-utils/hasPermission';
import { SessionData, UserGuildPermissions } from '@roleypoly/types';
import { Handler } from '../router';
import { rootUsers, uiPublicURI } from './config';
import { Sessions, WrappedKVNamespace } from './kv';
export const formData = (obj: Record<string, any>): string => {
return Object.keys(obj)
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(obj[key])}`)
.join('&');
};
export const addCORS = (init: ResponseInit = {}) => ({
...init,
headers: {
...(init.headers || {}),
'access-control-allow-origin': uiPublicURI,
'access-control-allow-methods': '*',
'access-control-allow-headers': '*',
},
});
export const respond = (obj: Record<string, any>, init: ResponseInit = {}) =>
new Response(JSON.stringify(obj), addCORS(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 const userAgent =
'DiscordBot (https://github.com/roleypoly/roleypoly, git-main) (+https://roleypoly.com)';
export enum AuthType {
Bearer = 'Bearer',
Bot = 'Bot',
}
export const discordFetch = async <T>(
url: string,
auth: string,
authType: AuthType = AuthType.Bearer,
init?: RequestInit
): Promise<T | null> => {
const response = await fetch('https://discord.com/api/v8' + 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 const cacheLayer = <Identity, Data>(
kv: WrappedKVNamespace,
keyFactory: (identity: Identity) => string,
missHandler: (identity: Identity) => Promise<Data | null>,
ttlSeconds?: number
) => async (
identity: Identity,
options: { skipCachePull?: boolean } = {}
): 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): 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);
};
export const isRoot = (userID: string): boolean => rootUsers.includes(userID);
export const onlyRootUsers = (handler: Handler): Handler =>
withSession((session) => (request: Request) => {
if (isRoot(session.user.id)) {
return handler(request);
}
return respond(
{
error: 'not_found',
},
{
status: 404,
}
);
});

View file

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

View file

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

163
packages/api/utils/guild.ts Normal file
View file

@ -0,0 +1,163 @@
import { evaluatePermission, permissions } from '@roleypoly/misc-utils/hasPermission';
import {
Features,
Guild,
GuildData as GuildDataT,
OwnRoleInfo,
Role,
RoleSafety,
} from '@roleypoly/types';
import { AuthType, cacheLayer, discordFetch } from './api-tools';
import { botClientID, botToken } from './config';
import { GuildData, Guilds } from './kv';
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 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),
}));
// 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[];
};
const guildMemberRolesIdentity = ({ serverID, userID }: GuildMemberIdentity) =>
`guilds/${serverID}/members/${userID}/roles`;
export const getGuildMemberRoles = cacheLayer<GuildMemberIdentity, Role['id'][]>(
Guilds,
guildMemberRolesIdentity,
async ({ serverID, userID }) => {
const discordMember = await discordFetch<APIMember>(
`/guilds/${serverID}/members/${userID}`,
botToken,
AuthType.Bot
);
if (!discordMember) {
return null;
}
return discordMember.roles;
},
60 * 5 // 5 minute TTL
);
export const updateGuildMemberRoles = async (
identity: GuildMemberIdentity,
roles: Role['id'][]
) => {
await Guilds.put(guildMemberRolesIdentity(identity), roles, 60 * 5);
};
export const getGuildData = async (id: string): Promise<GuildDataT> => {
const guildData = await GuildData.get<GuildDataT>(id);
if (!guildData) {
return {
id,
message: '',
categories: [],
features: Features.None,
};
}
return guildData;
};
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;
};

90
packages/api/utils/kv.ts Normal file
View file

@ -0,0 +1,90 @@
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,
});
}
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<string, any> = new Map();
async get<T>(key: string): Promise<T | null> {
if (!this.data.has(key)) {
return null;
}
return this.data.get(key);
}
async getWithMetadata<T, Metadata = unknown>(
key: string
): KVValueWithMetadata<T, Metadata> {
return {
value: await this.get<T>(key),
metadata: {} as Metadata,
};
}
async put(key: string, value: string | ReadableStream<any> | 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();
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));