mirror of https://github.com/requarks/wiki
parent
1105cf4b5e
commit
14e1efae41
@ -0,0 +1,180 @@
|
|||||||
|
import { actorFrom, mayOnPage, unlockedFor } from './pages.ts'
|
||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page being watched, as this requester is allowed to see it.
|
||||||
|
*
|
||||||
|
* Watching a page is a thing done TO a page, so it goes through the same gate as reading one: an
|
||||||
|
* anonymous requester never gets here at all, and a page somebody may not read is answered as though
|
||||||
|
* it were not there. A password is not part of it — the watcher is asking to be told when the page
|
||||||
|
* changes, not to read what it says.
|
||||||
|
*/
|
||||||
|
async function loadWatchablePage(req: FastifyRequest, siteId: string, pageId: string) {
|
||||||
|
const page = await WIKI.models.pages.getPage({
|
||||||
|
siteId,
|
||||||
|
id: pageId,
|
||||||
|
unlocked: (id: string) => unlockedFor(req, id)
|
||||||
|
})
|
||||||
|
if (!page || !mayOnPage(req, 'read:pages', page)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return page
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user doing the watching, or a refusal.
|
||||||
|
*
|
||||||
|
* Watching belongs to an account: it is a list somebody comes back to, and a row has to point at a
|
||||||
|
* person for a notification to ever have a recipient. There is no permission for it beyond being
|
||||||
|
* logged in — anybody who may read a page may ask to hear about it.
|
||||||
|
*/
|
||||||
|
function watcherOf(req: FastifyRequest, reply: FastifyReply): string | null {
|
||||||
|
const actor = actorFrom(req)
|
||||||
|
if (!actor) {
|
||||||
|
reply.unauthorized('Watching a page requires a logged in user.')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return actor.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageParams = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
siteId: { type: 'string', format: 'uuid' },
|
||||||
|
pageId: { type: 'string', format: 'uuid' }
|
||||||
|
},
|
||||||
|
required: ['siteId', 'pageId']
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page Watching API Routes
|
||||||
|
*
|
||||||
|
* Who has asked to be told when a page changes. Nothing is sent yet — notifications are not built —
|
||||||
|
* so these keep the list: the bell on a page writes to it, and the inbox reads it back.
|
||||||
|
*/
|
||||||
|
async function routes(app: FastifyInstance) {
|
||||||
|
/**
|
||||||
|
* WATCH A PAGE
|
||||||
|
*/
|
||||||
|
app.put<{ Params: { siteId: string; pageId: string } }>(
|
||||||
|
'/sites/:siteId/pages/:pageId/watch',
|
||||||
|
{
|
||||||
|
/*
|
||||||
|
No route-level `permissions`: this is decided per page, by whether the caller may read it —
|
||||||
|
which comes from a group's rules and not from the group-wide list that hook consults.
|
||||||
|
*/
|
||||||
|
schema: {
|
||||||
|
summary: 'Watch a page',
|
||||||
|
description:
|
||||||
|
'Records that the caller wants to hear about changes to this page. Watching a page already watched changes nothing and still answers 200, so the button can be pressed twice without it meaning anything different.',
|
||||||
|
tags: ['Pages'],
|
||||||
|
params: pageParams,
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
description: 'The page is being watched',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
ok: { type: 'boolean' },
|
||||||
|
isWatching: { type: 'boolean' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (req, reply) => {
|
||||||
|
const userId = watcherOf(req, reply)
|
||||||
|
if (!userId) {
|
||||||
|
return reply
|
||||||
|
}
|
||||||
|
const page = await loadWatchablePage(req, req.params.siteId, req.params.pageId)
|
||||||
|
if (!page) {
|
||||||
|
return reply.notFound('This page does not exist.')
|
||||||
|
}
|
||||||
|
await WIKI.models.pageWatching.watch({
|
||||||
|
siteId: req.params.siteId,
|
||||||
|
pageId: page.id,
|
||||||
|
userId
|
||||||
|
})
|
||||||
|
return { ok: true, isWatching: true }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UNWATCH A PAGE
|
||||||
|
*/
|
||||||
|
app.delete<{ Params: { siteId: string; pageId: string } }>(
|
||||||
|
'/sites/:siteId/pages/:pageId/watch',
|
||||||
|
{
|
||||||
|
// -> Same as above: readable is the test, and it is per page
|
||||||
|
schema: {
|
||||||
|
summary: 'Stop watching a page',
|
||||||
|
description:
|
||||||
|
'Forgets that the caller wanted to hear about this page. A page that was not being watched answers the same way, since the outcome asked for — no longer watching it — already holds.',
|
||||||
|
tags: ['Pages'],
|
||||||
|
params: pageParams,
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
description: 'The page is no longer being watched',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
ok: { type: 'boolean' },
|
||||||
|
isWatching: { type: 'boolean' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (req, reply) => {
|
||||||
|
const userId = watcherOf(req, reply)
|
||||||
|
if (!userId) {
|
||||||
|
return reply
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
The page is NOT loaded first. Unwatching has to keep working for a page that has since been
|
||||||
|
made unreadable, or the row would be stuck there with nothing in the interface able to remove
|
||||||
|
it — and there is nothing to protect anyway: this only ever deletes the caller's own row.
|
||||||
|
*/
|
||||||
|
await WIKI.models.pageWatching.unwatch({ pageId: req.params.pageId, userId })
|
||||||
|
return { ok: true, isWatching: false }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LIST WATCHED PAGES
|
||||||
|
*/
|
||||||
|
app.get<{ Params: { siteId: string } }>(
|
||||||
|
'/sites/:siteId/watching',
|
||||||
|
{
|
||||||
|
// -> Everything it returns is the caller's own, so being logged in is the whole of the check
|
||||||
|
schema: {
|
||||||
|
summary: 'List the pages the caller is watching',
|
||||||
|
description:
|
||||||
|
'The watch list of the caller on this site, most recently watched first. Titles and paths come from the pages themselves, so a page that has been renamed or moved is listed where it is now.',
|
||||||
|
tags: ['Pages'],
|
||||||
|
params: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
siteId: { type: 'string', format: 'uuid' }
|
||||||
|
},
|
||||||
|
required: ['siteId']
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
description: 'Watched pages',
|
||||||
|
type: 'array',
|
||||||
|
items: { $ref: 'WatchedPage#' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (req, reply) => {
|
||||||
|
const userId = watcherOf(req, reply)
|
||||||
|
if (!userId) {
|
||||||
|
return reply
|
||||||
|
}
|
||||||
|
return WIKI.models.pageWatching.listForUser(req.params.siteId, userId)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default routes
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE "pageWatching" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"pageId" uuid NOT NULL,
|
||||||
|
"siteId" uuid NOT NULL,
|
||||||
|
"userId" uuid NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "pageWatching_user_site_idx" ON "pageWatching" ("userId","siteId");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "pageWatching_page_user_idx" ON "pageWatching" ("pageId","userId");--> statement-breakpoint
|
||||||
|
ALTER TABLE "pageWatching" ADD CONSTRAINT "pageWatching_pageId_pages_id_fkey" FOREIGN KEY ("pageId") REFERENCES "pages"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "pageWatching" ADD CONSTRAINT "pageWatching_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id");--> statement-breakpoint
|
||||||
|
ALTER TABLE "pageWatching" ADD CONSTRAINT "pageWatching_userId_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE;
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE "rateLimits" (
|
||||||
|
"key" varchar(255) PRIMARY KEY,
|
||||||
|
"hits" integer DEFAULT 0 NOT NULL,
|
||||||
|
"windowStartedAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"bannedUntil" timestamp,
|
||||||
|
"updatedAt" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "rateLimits_updatedAt_idx" ON "rateLimits" ("updatedAt");
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,71 @@
|
|||||||
|
import { durationToSeconds } from './common.ts'
|
||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify'
|
||||||
|
import type { RateLimitPolicy } from '../models/rateLimits.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defaults for the limit on the authentication endpoints, used until an administrator saves their own
|
||||||
|
* and whenever a stored value is missing or unusable. Ten attempts in five minutes is far more than a
|
||||||
|
* person signing in needs and far less than guessing a password takes.
|
||||||
|
*/
|
||||||
|
const AUTH_DEFAULTS: RateLimitPolicy = {
|
||||||
|
max: 10,
|
||||||
|
windowSeconds: 300,
|
||||||
|
banSeconds: 900
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The configured policy.
|
||||||
|
*
|
||||||
|
* Every field falls back on its own, so one unusable value leaves the rest of the limit standing
|
||||||
|
* rather than turning it off — which is the failure mode worth avoiding here. The two durations are
|
||||||
|
* stored as an operator wrote them (`5m`, `15m`, `1d`), the way the JWT settings beside them are.
|
||||||
|
*/
|
||||||
|
function authPolicy(): RateLimitPolicy {
|
||||||
|
const security = WIKI.config.security ?? {}
|
||||||
|
const max = Number(security.authRateLimitMax)
|
||||||
|
return {
|
||||||
|
max: Number.isFinite(max) && max > 0 ? Math.floor(max) : AUTH_DEFAULTS.max,
|
||||||
|
windowSeconds: durationToSeconds(security.authRateLimitWindow, AUTH_DEFAULTS.windowSeconds),
|
||||||
|
banSeconds: durationToSeconds(security.authRateLimitBan, AUTH_DEFAULTS.banSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuse an attempt at an authentication endpoint once a client has made too many.
|
||||||
|
*
|
||||||
|
* Written as a per-route `onRequest` hook — `{ onRequest: limitAuthAttempts, schema: … }` — so that it
|
||||||
|
* runs before the body is even parsed, and so that the routes it guards say so where they are declared
|
||||||
|
* rather than in a list somewhere else. The endpoints that carry it are the ones where the request
|
||||||
|
* IS the guess: signing in, answering a second factor, changing a password from the login screen,
|
||||||
|
* a passkey ceremony, and unlocking a page.
|
||||||
|
*
|
||||||
|
* One counter per client address, shared by all of them: an attacker working through passwords on two
|
||||||
|
* of these endpoints is one attacker, and splitting the count per endpoint would let them have the
|
||||||
|
* limit twice over. `req.ip` is what the client is identified by, which behind a proxy means the
|
||||||
|
* `trustProxy` security setting has to be on for this to see anything but the proxy.
|
||||||
|
*
|
||||||
|
* Attempts are counted whether or not they succeed. A limit on failures only would leave the endpoint
|
||||||
|
* open to being hammered with valid credentials, and the numbers are set for a person signing in, who
|
||||||
|
* does not come close to them.
|
||||||
|
*/
|
||||||
|
export async function limitAuthAttempts(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||||
|
if (WIKI.config.security?.authRateLimitEnabled === false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const verdict = await WIKI.models.rateLimits.consume(`auth:${req.ip}`, authPolicy())
|
||||||
|
if (verdict.allowed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WIKI.models.flags.authDebug(
|
||||||
|
`Rate limit: refused ${req.method} ${req.url} from ${req.ip}, ${verdict.retryAfter}s left of its ban.`
|
||||||
|
)
|
||||||
|
/*
|
||||||
|
429 rather than 403, and with `Retry-After`: this is not a refusal to serve the client, it is the
|
||||||
|
same answer as before with a time on it — which is what a legitimate user locked out by a shared
|
||||||
|
address needs to be told.
|
||||||
|
*/
|
||||||
|
reply.header('Retry-After', String(verdict.retryAfter))
|
||||||
|
return reply.tooManyRequests(
|
||||||
|
`Too many attempts. Try again in ${Math.ceil(verdict.retryAfter / 60)} minute(s).`
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,102 @@
|
|||||||
|
import { and, desc, eq } from 'drizzle-orm'
|
||||||
|
import { pageWatching as watchingTable, pages as pagesTable } from '../db/schema.ts'
|
||||||
|
|
||||||
|
/** A watched page, as the inbox lists it. */
|
||||||
|
export interface WatchedPage {
|
||||||
|
pageId: string
|
||||||
|
path: string
|
||||||
|
locale: string
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
icon: string | null
|
||||||
|
/** When the page itself last changed, which is what a watcher is watching FOR. */
|
||||||
|
updatedAt: Date
|
||||||
|
/** When this person started watching, i.e. how long they have been asking to be told. */
|
||||||
|
watchedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page watching model
|
||||||
|
*
|
||||||
|
* Who has asked to be told about which pages. Nothing is sent yet — notifications are not built — so
|
||||||
|
* this is the list and nothing more: the bell on a page reads it, the inbox lists it, and whatever
|
||||||
|
* delivers the news later has one place to ask who wants it.
|
||||||
|
*/
|
||||||
|
class PageWatching {
|
||||||
|
/**
|
||||||
|
* Whether this user is watching this page.
|
||||||
|
*
|
||||||
|
* Answered for the page view, which asks about every page it draws, so it is a single indexed lookup
|
||||||
|
* on the pair — and not asked at all for a guest, who cannot watch anything.
|
||||||
|
*/
|
||||||
|
async isWatching(pageId: string, userId: string | null): Promise<boolean> {
|
||||||
|
if (!userId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const rows = await WIKI.db
|
||||||
|
.select({ id: watchingTable.id })
|
||||||
|
.from(watchingTable)
|
||||||
|
.where(and(eq(watchingTable.pageId, pageId), eq(watchingTable.userId, userId)))
|
||||||
|
.limit(1)
|
||||||
|
return rows.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start watching a page.
|
||||||
|
*
|
||||||
|
* Idempotent: watching a page one is already watching is what the reader asked for, and the unique
|
||||||
|
* index turns the second row into nothing rather than into an error.
|
||||||
|
*/
|
||||||
|
async watch({
|
||||||
|
siteId,
|
||||||
|
pageId,
|
||||||
|
userId
|
||||||
|
}: {
|
||||||
|
siteId: string
|
||||||
|
pageId: string
|
||||||
|
userId: string
|
||||||
|
}): Promise<void> {
|
||||||
|
await WIKI.db
|
||||||
|
.insert(watchingTable)
|
||||||
|
.values({ siteId, pageId, userId })
|
||||||
|
.onConflictDoNothing({ target: [watchingTable.pageId, watchingTable.userId] })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop watching a page. Also idempotent, for the same reason: the outcome asked for is that no row
|
||||||
|
* exists, and it does not.
|
||||||
|
*/
|
||||||
|
async unwatch({ pageId, userId }: { pageId: string; userId: string }): Promise<void> {
|
||||||
|
await WIKI.db
|
||||||
|
.delete(watchingTable)
|
||||||
|
.where(and(eq(watchingTable.pageId, pageId), eq(watchingTable.userId, userId)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pages this user watches on a site, most recently watched first.
|
||||||
|
*
|
||||||
|
* Joined to the pages rather than storing a copy of the title and the path, so a page that is
|
||||||
|
* renamed or moved is listed where it is now — which is the point of watching it. A deleted page
|
||||||
|
* takes its rows with it through the foreign key, so nothing here can point at one that is gone.
|
||||||
|
*/
|
||||||
|
async listForUser(siteId: string, userId: string): Promise<WatchedPage[]> {
|
||||||
|
const rows = await WIKI.db
|
||||||
|
.select({
|
||||||
|
pageId: pagesTable.id,
|
||||||
|
path: pagesTable.path,
|
||||||
|
locale: pagesTable.locale,
|
||||||
|
title: pagesTable.title,
|
||||||
|
description: pagesTable.description,
|
||||||
|
icon: pagesTable.icon,
|
||||||
|
updatedAt: pagesTable.updatedAt,
|
||||||
|
watchedAt: watchingTable.createdAt
|
||||||
|
})
|
||||||
|
.from(watchingTable)
|
||||||
|
.innerJoin(pagesTable, eq(pagesTable.id, watchingTable.pageId))
|
||||||
|
.where(and(eq(watchingTable.userId, userId), eq(watchingTable.siteId, siteId)))
|
||||||
|
.orderBy(desc(watchingTable.createdAt))
|
||||||
|
return rows as WatchedPage[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pageWatching = new PageWatching()
|
||||||
@ -0,0 +1,121 @@
|
|||||||
|
import { lt, sql } from 'drizzle-orm'
|
||||||
|
import { rateLimits as rateLimitsTable } from '../db/schema.ts'
|
||||||
|
|
||||||
|
/** How a limit is configured: what it allows, over how long, and what it costs to exceed. */
|
||||||
|
export interface RateLimitPolicy {
|
||||||
|
/** Attempts allowed within the window. The one that exceeds it is what earns the ban. */
|
||||||
|
max: number
|
||||||
|
/** Length of the window, in seconds. */
|
||||||
|
windowSeconds: number
|
||||||
|
/** How long the ban lasts once earned, in seconds. */
|
||||||
|
banSeconds: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the caller does about an attempt. */
|
||||||
|
export interface RateLimitVerdict {
|
||||||
|
/** Whether the attempt may proceed. */
|
||||||
|
allowed: boolean
|
||||||
|
/** Attempts made in the current window, the one just counted included. */
|
||||||
|
hits: number
|
||||||
|
/** Seconds until the client may try again. Zero while it is still allowed. */
|
||||||
|
retryAfter: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate limits model
|
||||||
|
*
|
||||||
|
* A fixed window per key, with a ban for going over it — the counter that stands behind the login and
|
||||||
|
* the other endpoints where guessing is the attack (`helpers/rateLimit.ts` is what applies it).
|
||||||
|
*
|
||||||
|
* Every attempt is one statement, and the statement decides everything: whether the window has rolled
|
||||||
|
* over, whether the ban has lifted, what the count now is, and whether this attempt has just earned a
|
||||||
|
* ban. Doing it in the database rather than in the process is the whole point — two instances behind
|
||||||
|
* a load balancer share one counter, and a ban issued by one is honoured by both.
|
||||||
|
*/
|
||||||
|
class RateLimits {
|
||||||
|
/**
|
||||||
|
* Count an attempt against a key, and say whether it may proceed.
|
||||||
|
*
|
||||||
|
* The row is read, rolled over, incremented and possibly banned in a single upsert, so concurrent
|
||||||
|
* attempts cannot both read the same count and both decide they are under the limit. What comes
|
||||||
|
* back is the row as it now stands.
|
||||||
|
*
|
||||||
|
* A banned key stops counting: its ban runs for exactly as long as it was set for, rather than
|
||||||
|
* being pushed further out by every attempt made during it. When the ban lifts, the window starts
|
||||||
|
* again from nothing — having served it, a client is not one attempt away from serving another.
|
||||||
|
*
|
||||||
|
* @param key What is being limited and who by, e.g. `auth:203.0.113.4`
|
||||||
|
*/
|
||||||
|
async consume(key: string, policy: RateLimitPolicy): Promise<RateLimitVerdict> {
|
||||||
|
const window = sql`make_interval(secs => ${policy.windowSeconds})`
|
||||||
|
/*
|
||||||
|
The three cases, in the order the CASE arms decide them:
|
||||||
|
|
||||||
|
1. still banned -- nothing changes; the attempt is refused
|
||||||
|
2. window rolled over, or a ban has just expired -- the row starts again at this attempt
|
||||||
|
3. otherwise -- one more attempt, and a ban if that is one too many
|
||||||
|
|
||||||
|
`"rateLimits"` rather than the aliased `excluded`: these have to read the row AS IT WAS, and
|
||||||
|
`excluded` is the row this statement proposed.
|
||||||
|
*/
|
||||||
|
const rolledOver = sql`"rateLimits"."bannedUntil" is not null or "rateLimits"."windowStartedAt" <= now() - ${window}`
|
||||||
|
const stillBanned = sql`"rateLimits"."bannedUntil" > now()`
|
||||||
|
const rows = await WIKI.db.execute(sql`
|
||||||
|
insert into "rateLimits" ("key", "hits", "windowStartedAt", "updatedAt")
|
||||||
|
values (${key}, 1, now(), now())
|
||||||
|
on conflict ("key") do update set
|
||||||
|
"hits" = case
|
||||||
|
when ${stillBanned} then "rateLimits"."hits"
|
||||||
|
when ${rolledOver} then 1
|
||||||
|
else "rateLimits"."hits" + 1
|
||||||
|
end,
|
||||||
|
"windowStartedAt" = case
|
||||||
|
when ${stillBanned} then "rateLimits"."windowStartedAt"
|
||||||
|
when ${rolledOver} then now()
|
||||||
|
else "rateLimits"."windowStartedAt"
|
||||||
|
end,
|
||||||
|
"bannedUntil" = case
|
||||||
|
when ${stillBanned} then "rateLimits"."bannedUntil"
|
||||||
|
when ${rolledOver} then null
|
||||||
|
when "rateLimits"."hits" + 1 > ${policy.max} then now() + make_interval(secs => ${policy.banSeconds})
|
||||||
|
else null
|
||||||
|
end,
|
||||||
|
"updatedAt" = now()
|
||||||
|
returning
|
||||||
|
"hits",
|
||||||
|
"bannedUntil" > now() as "isBanned",
|
||||||
|
greatest(0, ceil(extract(epoch from coalesce("bannedUntil", now()) - now())))::int as "retryAfter"
|
||||||
|
`)
|
||||||
|
const row = (rows as any).rows?.[0] ?? (rows as any)[0]
|
||||||
|
return {
|
||||||
|
allowed: !row?.isBanned,
|
||||||
|
hits: Number(row?.hits ?? 0),
|
||||||
|
retryAfter: row?.isBanned ? Number(row.retryAfter) : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forget a key, e.g. once the attempt it was counting has succeeded.
|
||||||
|
*/
|
||||||
|
async reset(key: string): Promise<void> {
|
||||||
|
await WIKI.db.delete(rateLimitsTable).where(sql`${rateLimitsTable.key} = ${key}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop rows nothing has touched for a day.
|
||||||
|
*
|
||||||
|
* Only ever about reclaiming space: a stale row is already harmless, since the next attempt on that
|
||||||
|
* key rolls its window over before reading it. A key nobody has used in a day is one nobody is
|
||||||
|
* being limited by.
|
||||||
|
*
|
||||||
|
* @returns How many rows were dropped
|
||||||
|
*/
|
||||||
|
async purgeStale(): Promise<number> {
|
||||||
|
const result = await WIKI.db
|
||||||
|
.delete(rateLimitsTable)
|
||||||
|
.where(lt(rateLimitsTable.updatedAt, sql`now() - interval '1 day'`))
|
||||||
|
return result.rowCount ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rateLimits = new RateLimits()
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
export async function task(): Promise<void> {
|
||||||
|
WIKI.logger.info('Purging stale rate limit counters...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const purged = await WIKI.models.rateLimits.purgeStale()
|
||||||
|
|
||||||
|
WIKI.logger.info(`Purged ${purged} stale rate limit counters: [ COMPLETED ]`)
|
||||||
|
} catch (err: any) {
|
||||||
|
WIKI.logger.error('Purging stale rate limit counters: [ FAILED ]')
|
||||||
|
WIKI.logger.error(err.message)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in new issue