diff --git a/packages/api/handlers/clear-guild-cache.ts b/packages/api/handlers/clear-guild-cache.ts new file mode 100644 index 0000000..c1e05c8 --- /dev/null +++ b/packages/api/handlers/clear-guild-cache.ts @@ -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 => { + 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(); + } +); diff --git a/packages/api/handlers/sync-from-legacy.ts b/packages/api/handlers/sync-from-legacy.ts index 94444f7..a174389 100644 --- a/packages/api/handlers/sync-from-legacy.ts +++ b/packages/api/handlers/sync-from-legacy.ts @@ -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 => { 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(); } ); diff --git a/packages/api/index.ts b/packages/api/index.ts index f854a06..b0669da 100644 --- a/packages/api/index.ts +++ b/packages/api/index.ts @@ -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); diff --git a/packages/api/utils/guild.ts b/packages/api/utils/guild.ts index 562368a..de92f4b 100644 --- a/packages/api/utils/guild.ts +++ b/packages/api/utils/guild.ts @@ -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); diff --git a/packages/api/utils/rate-limiting.ts b/packages/api/utils/rate-limiting.ts new file mode 100644 index 0000000..f3282ee --- /dev/null +++ b/packages/api/utils/rate-limiting.ts @@ -0,0 +1,15 @@ +import { WrappedKVNamespace } from './kv'; + +export const useRateLimiter = ( + kv: WrappedKVNamespace, + key: string, + timeoutSeconds: number +) => async (): Promise => { + const value = await kv.get(key); + if (value) { + return true; + } + + await kv.put(key, true, timeoutSeconds); + return false; +}; diff --git a/packages/api/utils/responses.ts b/packages/api/utils/responses.ts new file mode 100644 index 0000000..7b8936b --- /dev/null +++ b/packages/api/utils/responses.ts @@ -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 }); diff --git a/packages/backend-emulator/main.js b/packages/backend-emulator/main.js index 6f19843..d96c572 100755 --- a/packages/backend-emulator/main.js +++ b/packages/backend-emulator/main.js @@ -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 () => {