feat(api): add rate-limiting and /clear-guild-cache (#198)

This commit is contained in:
41666 2021-03-23 22:14:33 -04:00 committed by GitHub
parent 57d83699d5
commit a4fd37d71c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 138 additions and 12 deletions

View file

@ -0,0 +1,50 @@
import { UserGuildPermissions } from '@roleypoly/types';
import { isRoot, withSession } from '../utils/api-tools';
import { getGuild, GuildRateLimiterKey, useGuildRateLimiter } from '../utils/guild';
import {
lowPermissions,
missingParameters,
notFound,
ok,
rateLimited,
} from '../utils/responses';
export const ClearGuildCache = withSession(
(session) => async (request: Request): Promise<Response> => {
const url = new URL(request.url);
const [, , guildID] = url.pathname.split('/');
if (!guildID) {
return missingParameters();
}
const rateLimit = useGuildRateLimiter(
guildID,
GuildRateLimiterKey.cacheClear,
60 * 5
); // 5 minute RL TTL, 288 times per day.
if (!isRoot(session.user.id)) {
const guild = session.guilds.find((guild) => guild.id === guildID);
if (!guild) {
return notFound();
}
if (
guild?.permissionLevel !== UserGuildPermissions.Manager &&
guild?.permissionLevel !== UserGuildPermissions.Admin
) {
return lowPermissions();
}
if (await rateLimit()) {
return rateLimited();
}
}
const result = await getGuild(guildID, { skipCachePull: true });
if (!result) {
return notFound();
}
return ok();
}
);

View file

@ -3,20 +3,32 @@ import {
GuildData as GuildDataT,
UserGuildPermissions,
} from '@roleypoly/types';
import { isRoot, respond, withSession } from '../utils/api-tools';
import { isRoot, withSession } from '../utils/api-tools';
import { GuildRateLimiterKey, useGuildRateLimiter } from '../utils/guild';
import { fetchLegacyServer, transformLegacyGuild } from '../utils/import-from-legacy';
import { GuildData } from '../utils/kv';
const noPermissions = () =>
respond({ error: 'no permissions to this guild' }, { status: 403 });
const notFound = () => respond({ error: 'guild not found' }, { status: 404 });
const alreadyImported = () =>
respond({ error: 'guild already imported' }, { status: 400 });
import {
conflict,
lowPermissions,
missingParameters,
notFound,
ok,
rateLimited,
} from '../utils/responses';
export const SyncFromLegacy = withSession(
(session) => async (request: Request): Promise<Response> => {
const url = new URL(request.url);
const [, , guildID] = url.pathname.split('/');
if (!guildID) {
return missingParameters();
}
const rateLimit = useGuildRateLimiter(
guildID,
GuildRateLimiterKey.legacyImport,
60 * 20
); // 20 minute RL TTL, 72 times per day.
// Allow root users to trigger this too, just in case.
if (!isRoot(session.user.id)) {
@ -26,10 +38,14 @@ export const SyncFromLegacy = withSession(
}
if (
guild?.permissionLevel !== UserGuildPermissions.Manager ||
guild?.permissionLevel !== UserGuildPermissions.Manager &&
guild?.permissionLevel !== UserGuildPermissions.Admin
) {
return noPermissions();
return lowPermissions();
}
if (await rateLimit()) {
return rateLimited();
}
}
@ -44,7 +60,7 @@ export const SyncFromLegacy = withSession(
checkGuild &&
(checkGuild.features & Features.LegacyGuild) === Features.LegacyGuild
) {
return alreadyImported();
return conflict();
}
const legacyGuild = await fetchLegacyServer(guildID);
@ -55,6 +71,6 @@ export const SyncFromLegacy = withSession(
const newGuildData = transformLegacyGuild(legacyGuild);
await GuildData.put(guildID, newGuildData);
return respond({ ok: true });
return ok();
}
);

View file

@ -1,4 +1,5 @@
import { BotJoin } from './handlers/bot-join';
import { ClearGuildCache } from './handlers/clear-guild-cache';
import { CreateRoleypolyData } from './handlers/create-roleypoly-data';
import { GetPickerData } from './handlers/get-picker-data';
import { GetSession } from './handlers/get-session';
@ -28,6 +29,7 @@ router.add('GET', 'get-slug', GetSlug);
router.add('GET', 'get-picker-data', GetPickerData);
router.add('PATCH', 'update-roles', UpdateRoles);
router.add('POST', 'sync-from-legacy', SyncFromLegacy);
router.add('POST', 'clear-guild-cache', ClearGuildCache);
// Root users only
router.add('GET', 'x-create-roleypoly-data', CreateRoleypolyData);

View file

@ -10,6 +10,7 @@ import {
import { AuthType, cacheLayer, discordFetch } from './api-tools';
import { botClientID, botToken } from './config';
import { GuildData, Guilds } from './kv';
import { useRateLimiter } from './rate-limiting';
type APIGuild = {
// Only relevant stuff
@ -161,3 +162,14 @@ const calculateRoleSafety = (role: Role | APIRole, highestBotRolePosition: numbe
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);

View file

@ -0,0 +1,15 @@
import { WrappedKVNamespace } from './kv';
export const useRateLimiter = (
kv: WrappedKVNamespace,
key: string,
timeoutSeconds: number
) => async (): Promise<boolean> => {
const value = await kv.get<boolean>(key);
if (value) {
return true;
}
await kv.put(key, true, timeoutSeconds);
return false;
};

View file

@ -0,0 +1,16 @@
import { respond } from './api-tools';
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 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: 419 });

View file

@ -171,10 +171,23 @@ const rebuild = () =>
const watcher = chokidar.watch(path.resolve(__dirname, basePath), {
ignoreInitial: true,
ignore: '**/{dist,node_modules}',
ignore: '**/dist',
});
let currentlyRebuilding = false;
watcher.on('all', async (type, path) => {
if (path.includes('node_modules') || path.includes('dist')) {
return;
}
if (currentlyRebuilding) {
console.info('change skipped...', { type, path });
return;
}
currentlyRebuilding = true;
if (path.includes('dist')) {
return;
}
@ -183,6 +196,8 @@ watcher.on('all', async (type, path) => {
await rebuild();
reload();
currentlyRebuilding = false;
});
fork(async () => {