mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-04-14 11:38:31 -04:00
Pre commit
This commit is contained in:
@@ -174,11 +174,63 @@ export type APIHandler<N extends APIPath> = (
|
||||
req: Request
|
||||
) => Promise<APIResponseOptionalContinue<N>>
|
||||
|
||||
// Simple in-memory fixed-window rate limiter keyed by auth uid (or IP if unauthenticated)
|
||||
// Not suitable for multi-instance deployments without a shared store, but provides basic protection.
|
||||
// Limits are configurable via env:
|
||||
// API_RATE_LIMIT_PER_MIN_AUTHED (default 120)
|
||||
// API_RATE_LIMIT_PER_MIN_UNAUTHED (default 30)
|
||||
// Endpoints can be exempted by adding their name to RATE_LIMIT_EXEMPT (comma-separated)
|
||||
const __rateLimitState: Map<string, { windowStart: number; count: number }> = new Map()
|
||||
|
||||
function getRateLimitConfig() {
|
||||
const authed = Number(process.env.API_RATE_LIMIT_PER_MIN_AUTHED ?? 30)
|
||||
const unAuthed = Number(process.env.API_RATE_LIMIT_PER_MIN_UNAUTHED ?? 30)
|
||||
return {authedLimit: authed, unAuthLimit: unAuthed}
|
||||
}
|
||||
|
||||
function rateLimitKey(name: string, req: Request, auth?: AuthedUser) {
|
||||
if (auth) return `uid:${auth.uid}`
|
||||
// fallback to IP for unauthenticated requests
|
||||
return `ip:${req.ip}`
|
||||
}
|
||||
|
||||
function checkRateLimit(name: string, req: Request, res: Response, auth?: AuthedUser) {
|
||||
const {authedLimit, unAuthLimit} = getRateLimitConfig()
|
||||
|
||||
const key = rateLimitKey(name, req, auth)
|
||||
const limit = auth ? authedLimit : unAuthLimit
|
||||
const now = Date.now()
|
||||
const windowMs = 60_000
|
||||
const windowStart = Math.floor(now / windowMs) * windowMs
|
||||
|
||||
let state = __rateLimitState.get(key)
|
||||
if (!state || state.windowStart !== windowStart) {
|
||||
state = {windowStart, count: 0}
|
||||
__rateLimitState.set(key, state)
|
||||
}
|
||||
state.count += 1
|
||||
|
||||
const remaining = Math.max(0, limit - state.count)
|
||||
const reset = Math.ceil((state.windowStart + windowMs - now) / 1000)
|
||||
|
||||
// Set standard-ish rate limit headers
|
||||
res.setHeader('X-RateLimit-Limit', String(limit))
|
||||
res.setHeader('X-RateLimit-Remaining', String(Math.max(0, remaining)))
|
||||
res.setHeader('X-RateLimit-Reset', String(reset))
|
||||
|
||||
// console.log(`Rate limit check for ${key} on ${name}: ${state.count}/${limit} (remaining: ${remaining}, resets in ${reset}s)`)
|
||||
|
||||
if (state.count > limit) {
|
||||
res.setHeader('Retry-After', String(reset))
|
||||
throw new APIError(429, 'Too Many Requests: rate limit exceeded.')
|
||||
}
|
||||
}
|
||||
|
||||
export const typedEndpoint = <N extends APIPath>(
|
||||
name: N,
|
||||
handler: APIHandler<N>
|
||||
) => {
|
||||
const {props: propSchema, authed: authRequired, method} = API[name]
|
||||
const {props: propSchema, authed: authRequired, rateLimited = false, method} = API[name] as APISchema<N>
|
||||
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
let authUser: AuthedUser | undefined = undefined
|
||||
@@ -188,6 +240,15 @@ export const typedEndpoint = <N extends APIPath>(
|
||||
if (authRequired) return next(e)
|
||||
}
|
||||
|
||||
// Apply rate limiting before invoking the handler
|
||||
if (rateLimited) {
|
||||
try {
|
||||
checkRateLimit(String(name), req, res, authUser)
|
||||
} catch (e) {
|
||||
return next(e)
|
||||
}
|
||||
}
|
||||
|
||||
const props = {
|
||||
...(method === 'GET' ? req.query : req.body),
|
||||
...req.params,
|
||||
|
||||
@@ -28,6 +28,8 @@ type APIGenericSchema = {
|
||||
method: 'GET' | 'POST' | 'PUT'
|
||||
// whether the endpoint requires authentication
|
||||
authed: boolean
|
||||
// whether the endpoint requires authentication
|
||||
rateLimited?: boolean
|
||||
// zod schema for the request body (or for params for GET requests)
|
||||
props: z.ZodType
|
||||
// note this has to be JSON serializable
|
||||
@@ -80,6 +82,7 @@ export const API = (_apiTypeCheck = {
|
||||
// TODO rest
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as { user: User; privateUser: PrivateUser },
|
||||
props: z
|
||||
.object({
|
||||
@@ -91,12 +94,14 @@ export const API = (_apiTypeCheck = {
|
||||
'create-profile': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as Row<'profiles'>,
|
||||
props: baseProfilesSchema,
|
||||
},
|
||||
report: {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z
|
||||
.object({
|
||||
contentOwnerId: z.string(),
|
||||
@@ -112,6 +117,7 @@ export const API = (_apiTypeCheck = {
|
||||
me: {
|
||||
method: 'GET',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
cache: DEFAULT_CACHE_STRATEGY,
|
||||
props: z.object({}),
|
||||
returns: {} as FullUser,
|
||||
@@ -119,6 +125,7 @@ export const API = (_apiTypeCheck = {
|
||||
'me/update': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({
|
||||
name: z.string().trim().min(1).optional(),
|
||||
username: z.string().trim().min(1).optional(),
|
||||
@@ -146,6 +153,7 @@ export const API = (_apiTypeCheck = {
|
||||
'update-profile': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: combinedLoveUsersSchema.partial(),
|
||||
returns: {} as ProfileRow,
|
||||
},
|
||||
@@ -161,6 +169,7 @@ export const API = (_apiTypeCheck = {
|
||||
'me/delete': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({
|
||||
username: z.string(), // just so you're sure
|
||||
}),
|
||||
@@ -168,6 +177,7 @@ export const API = (_apiTypeCheck = {
|
||||
'me/private': {
|
||||
method: 'GET',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({}),
|
||||
returns: {} as PrivateUser,
|
||||
},
|
||||
@@ -201,20 +211,22 @@ export const API = (_apiTypeCheck = {
|
||||
},
|
||||
'search-users': {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
cache: DEFAULT_CACHE_STRATEGY,
|
||||
returns: [] as FullUser[],
|
||||
props: z
|
||||
.object({
|
||||
term: z.string(),
|
||||
limit: z.coerce.number().gte(0).lte(1000).default(500),
|
||||
limit: z.coerce.number().gte(0).lte(20).default(500),
|
||||
page: z.coerce.number().gte(0).default(0),
|
||||
})
|
||||
.strict(),
|
||||
},
|
||||
'compatible-profiles': {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({userId: z.string()}),
|
||||
returns: {} as {
|
||||
profile: Profile
|
||||
@@ -227,6 +239,7 @@ export const API = (_apiTypeCheck = {
|
||||
'remove-pinned-photo': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {success: true},
|
||||
props: z
|
||||
.object({
|
||||
@@ -236,7 +249,8 @@ export const API = (_apiTypeCheck = {
|
||||
},
|
||||
'get-compatibility-questions': {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({}),
|
||||
returns: {} as {
|
||||
status: 'success'
|
||||
@@ -249,6 +263,7 @@ export const API = (_apiTypeCheck = {
|
||||
'like-profile': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({
|
||||
targetUserId: z.string(),
|
||||
remove: z.boolean().optional(),
|
||||
@@ -260,6 +275,7 @@ export const API = (_apiTypeCheck = {
|
||||
'ship-profiles': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({
|
||||
targetUserId1: z.string(),
|
||||
targetUserId2: z.string(),
|
||||
@@ -271,7 +287,8 @@ export const API = (_apiTypeCheck = {
|
||||
},
|
||||
'get-likes-and-ships': {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z
|
||||
.object({
|
||||
userId: z.string(),
|
||||
@@ -287,6 +304,7 @@ export const API = (_apiTypeCheck = {
|
||||
'has-free-like': {
|
||||
method: 'GET',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({}).strict(),
|
||||
returns: {} as {
|
||||
status: 'success'
|
||||
@@ -296,6 +314,7 @@ export const API = (_apiTypeCheck = {
|
||||
'star-profile': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({
|
||||
targetUserId: z.string(),
|
||||
remove: z.boolean().optional(),
|
||||
@@ -307,9 +326,10 @@ export const API = (_apiTypeCheck = {
|
||||
'get-profiles': {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
rateLimited: true,
|
||||
props: z
|
||||
.object({
|
||||
limit: z.coerce.number().optional().default(20),
|
||||
limit: z.coerce.number().gt(0).lte(20).optional().default(20),
|
||||
after: z.string().optional(),
|
||||
// Search and filter parameters
|
||||
name: z.string().optional(),
|
||||
@@ -337,7 +357,8 @@ export const API = (_apiTypeCheck = {
|
||||
},
|
||||
'get-profile-answers': {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({userId: z.string()}).strict(),
|
||||
returns: {} as {
|
||||
status: 'success'
|
||||
@@ -347,6 +368,7 @@ export const API = (_apiTypeCheck = {
|
||||
'create-comment': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({
|
||||
userId: z.string(),
|
||||
content: contentSchema,
|
||||
@@ -357,6 +379,7 @@ export const API = (_apiTypeCheck = {
|
||||
'hide-comment': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({
|
||||
commentId: z.string(),
|
||||
hide: z.boolean(),
|
||||
@@ -366,6 +389,7 @@ export const API = (_apiTypeCheck = {
|
||||
'get-channel-memberships': {
|
||||
method: 'GET',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({
|
||||
channelId: z.coerce.number().optional(),
|
||||
createdTime: z.string().optional(),
|
||||
@@ -380,6 +404,7 @@ export const API = (_apiTypeCheck = {
|
||||
'get-channel-messages': {
|
||||
method: 'GET',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z.object({
|
||||
channelId: z.coerce.number(),
|
||||
limit: z.coerce.number(),
|
||||
@@ -424,6 +449,7 @@ export const API = (_apiTypeCheck = {
|
||||
'create-private-user-message': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
content: contentSchema,
|
||||
@@ -433,6 +459,7 @@ export const API = (_apiTypeCheck = {
|
||||
'create-private-user-message-channel': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
userIds: z.array(z.string()),
|
||||
@@ -441,6 +468,7 @@ export const API = (_apiTypeCheck = {
|
||||
'update-private-user-message-channel': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
channelId: z.number(),
|
||||
@@ -450,6 +478,7 @@ export const API = (_apiTypeCheck = {
|
||||
'leave-private-user-message-channel': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
channelId: z.number(),
|
||||
@@ -458,6 +487,7 @@ export const API = (_apiTypeCheck = {
|
||||
'create-compatibility-question': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
question: z.string().min(1).max(MAX_COMPATIBILITY_QUESTION_LENGTH),
|
||||
@@ -466,7 +496,8 @@ export const API = (_apiTypeCheck = {
|
||||
},
|
||||
'search-location': {
|
||||
method: 'POST',
|
||||
authed: false,
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
term: z.string(),
|
||||
@@ -475,7 +506,8 @@ export const API = (_apiTypeCheck = {
|
||||
},
|
||||
'search-near-city': {
|
||||
method: 'POST',
|
||||
authed: false,
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
returns: {} as any,
|
||||
props: z.object({
|
||||
cityId: z.string(),
|
||||
@@ -484,14 +516,14 @@ export const API = (_apiTypeCheck = {
|
||||
},
|
||||
'get-messages-count': {
|
||||
method: 'GET',
|
||||
authed: false,
|
||||
authed: true,
|
||||
props: z.object({}),
|
||||
returns: {} as { count: number },
|
||||
},
|
||||
} as const)
|
||||
|
||||
export type APIPath = keyof typeof API
|
||||
export type APISchema<N extends APIPath> = (typeof API)[N]
|
||||
export type APISchema<N extends APIPath> = (typeof API)[N] & { rateLimited?: boolean }
|
||||
|
||||
export type APIParams<N extends APIPath> = z.input<APISchema<N>['props']>
|
||||
export type ValidatedAPIParams<N extends APIPath> = z.output<
|
||||
|
||||
Reference in New Issue
Block a user