Skip to content

Commit

Permalink
ratelimits for telemetry
Browse files Browse the repository at this point in the history
  • Loading branch information
0x7d8 committed Jan 5, 2025
1 parent 310324c commit 0b0db9a
Show file tree
Hide file tree
Showing 7 changed files with 154 additions and 12 deletions.
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
REDIS_URL="redis://localhost"
DATABASE_URL="postgresql://blueprint_api:local@db:5432/blueprint_api"

UPDATE_PRICES=true
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "api",
"version": "1.4.1",
"version": "1.4.2",
"scripts": {
"build": "rm -rf lib && esbuild `find src \\( -name '*.ts' -o -name '*.tsx' \\)` --platform='node' --sourcemap --ignore-annotations --format='cjs' --target='es2022' --outdir='lib' && esbuild src/index.ts --platform='node' --sourcemap --ignore-annotations --format='cjs' --target='es2022' --outdir='lib' --banner:js='require(\"module-alias\").addAlias(\"@\", __dirname);'",
"kit": "drizzle-kit",
Expand All @@ -18,6 +18,7 @@
"ansi-colors": "^4.1.3",
"drizzle-kit": "^0.28.1",
"drizzle-orm": "^0.36.4",
"ioredis": "^5.4.2",
"module-alias": "^2.2.3",
"node-cron": "^3.0.3",
"pg": "^8.13.1",
Expand Down
67 changes: 67 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/api/routes/global/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ export = new globalAPIRouter.Path('/')
const [ data, error ] = await ctr.bindBody(ctr["@"].telemetry.telemetrySchema)
if (!data) return ctr.status(ctr.$status.BAD_REQUEST).print({ errors: error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`) })

ctr["@"].telemetry.log(ctr.client.ip, data)
const telemetry = await ctr["@"].telemetry.log(ctr.client.ip, data, ctr.headers)
if (!telemetry) return ctr.status(ctr.$status.TOO_MANY_REQUESTS).print({ errors: ['You are making too many requests! Slow down.'] })

return ctr.print({})
})
Expand Down
59 changes: 51 additions & 8 deletions src/globals/cache.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,62 @@
import { Redis } from "ioredis"
import { version } from "ioredis/package.json"
import logger from "@/globals/logger"
import env from "@/globals/env"
import { time } from "@rjweb/utils"

const localCache = new Map<string, any>()
const startTime = performance.now(),
localCache = new Map<string, any>()

export default {
const redis = env.REDIS_MODE === 'redis'
? new Redis(env.REDIS_URL)
: new Redis({
sentinels: env.REDIS_SENTINEL_NODES.map(([host, port]) => ({ host, port })),
name: 'mymaster'
})

redis.once('connect', () => {
logger()
.text('Cache', (c) => c.yellow)
.text(`(${version}) Connection established!`)
.text(`(${(performance.now() - startTime).toFixed(1)}ms)`, (c) => c.gray)
.info()
})

export default Object.assign(redis, {
async use<Run extends () => Promise<any> | any>(key: string, run: Run, expire: number = time(3).s()): Promise<Awaited<ReturnType<Run>>> {
const mapResult = localCache.get(`internal-middlewares::cache::${key}`)
if (mapResult) return mapResult

const redisResult = await redis.get(`internal-middlewares::cache::${key}`)
if (redisResult) return JSON.parse(redisResult)

const runResult = await Promise.resolve(run())
localCache.set(`internal-middlewares::cache::${key}`, runResult)
if (!expire) await redis.set(`internal-middlewares::cache::${key}`, JSON.stringify(runResult))
else if (expire > time(15).s()) await redis.set(`internal-middlewares::cache::${key}`, JSON.stringify(runResult), 'EX', Math.ceil(expire / 1000))
else {
localCache.set(`internal-middlewares::cache::${key}`, runResult)

setTimeout(() => {
localCache.delete(`internal-middlewares::cache::${key}`)
}, expire)
setTimeout(() => {
localCache.delete(`internal-middlewares::cache::${key}`)
}, expire)
}

return runResult
}
}
},

local: {
use<Run extends () => any>(key: string, run: Run, expire: number = time(3).s()): ReturnType<Run> {
const mapResult = localCache.get(`internal-middlewares::cache::${key}`)
if (mapResult) return mapResult

const runResult = run()
localCache.set(`internal-middlewares::cache::${key}`, runResult)

if (!!expire) setTimeout(() => {
localCache.delete(`internal-middlewares::cache::${key}`)
}, expire)

return runResult
}
} as const
})
14 changes: 13 additions & 1 deletion src/globals/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ try {
}
}

const infos = z.object({
const base = z.object({
DATABASE_URL: z.string(),
DATABASE_URL_PRIMARY: z.string().optional(),
SENTRY_URL: z.string().optional(),

UPDATE_PRICES: z.enum(['true', 'false']).transform((str) => str === 'true'),

PORT: z.string().transform((str) => parseInt(str)).optional(),
RATELIMIT_PER_DAY: z.string().transform((str) => parseInt(str)).optional().default('2'),
INTERNAL_KEY: z.string(),

SXC_TOKEN: z.string().optional(),
Expand All @@ -32,6 +33,17 @@ const infos = z.object({
SERVER_NAME: z.string().optional()
})

const infos = z.union([
z.object({
REDIS_MODE: z.literal('redis').default('redis'),
REDIS_URL: z.string()
}).merge(base),
z.object({
REDIS_MODE: z.literal('sentinel'),
REDIS_SENTINEL_NODES: z.string().transform((str) => str.split(',').map((node) => node.trim().split(':').map((part, i) => i === 1 ? parseInt(part) : part)) as [string, number][]),
}).merge(base)
])

export type Environment = z.infer<typeof infos>

export default infos.parse(env)
19 changes: 18 additions & 1 deletion src/globals/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { network, object, string, time } from "@rjweb/utils"
import * as schema from "@/schema"
import { lookup } from "@/globals/ip"
import cache from "@/globals/cache"
import env from "@/globals/env"
import { z } from "zod"
import { Content, ValueCollection } from "rjweb-server"

export const telemetrySchema = z.object({
id: z.string().uuid(),
Expand Down Expand Up @@ -63,7 +65,22 @@ const processing: Telemetry[] = []
/**
* Log a new Telemetry
* @since 1.0.0
*/ export function log(ip: network.IPAddress, telemetry: z.infer<typeof telemetrySchema>): Telemetry {
*/ export async function log(ip: network.IPAddress, telemetry: z.infer<typeof telemetrySchema>, headers: ValueCollection<string, string, Content>): Promise<Telemetry | null> {
let ratelimitKey = 'ratelimit::'
if (ip['type'] === 4) ratelimitKey += ip.long()
else ratelimitKey += ip.rawData.slice(0, 4).join(':')

const count = await cache.incr(ratelimitKey)
if (count === 1) await cache.expire(ratelimitKey, Math.floor(time(1).d() / 1000))

const expires = await cache.ttl(ratelimitKey)

headers.set('X-RateLimit-Limit', env.RATELIMIT_PER_DAY)
headers.set('X-RateLimit-Remaining', env.RATELIMIT_PER_DAY - count)
headers.set('X-RateLimit-Reset', expires)

if (count > env.RATELIMIT_PER_DAY) return null

const data: Telemetry = {
panelId: telemetry.id,
telemetryVersion: telemetry.telemetry_version,
Expand Down

0 comments on commit 0b0db9a

Please sign in to comment.