diff --git a/src/Server.ts b/src/Server.ts
index 54c8db0d..326fcc5c 100644
--- a/src/Server.ts
+++ b/src/Server.ts
@@ -56,7 +56,7 @@ export class FosscordServer extends Server {
db.collection("emojis").createIndex({ id: 1 }, { unique: true }),
db.collection("invites").createIndex({ code: 1 }, { unique: true }),
db.collection("invites").createIndex({ expires_at: 1 }, { expireAfterSeconds: 0 }), // after 0 seconds of expires_at the invite will get delete
- db.collection("ratelimits").createIndex({ created_at: 1 }, { expireAfterSeconds: 1000 })
+ db.collection("ratelimits").createIndex({ expires_at: 1 }, { expireAfterSeconds: 0 })
]);
}
@@ -69,7 +69,6 @@ export class FosscordServer extends Server {
this.app.use(CORS);
this.app.use(Authentication);
- this.app.use(RateLimit({ count: 10, error: 10, window: 5 }));
this.app.use(BodyParser({ inflate: true, limit: 1024 * 1024 * 2 }));
const languages = await fs.readdir(path.join(__dirname, "..", "locales"));
const namespaces = await fs.readdir(path.join(__dirname, "..", "locales", "en"));
@@ -94,6 +93,7 @@ export class FosscordServer extends Server {
const prefix = Router();
// @ts-ignore
this.app = prefix;
+ prefix.use(RateLimit({ bucket: "global", count: 10, error: 10, window: 5, bot: 250 }));
prefix.use("/guilds/:id", RateLimit({ count: 10, window: 5 }));
prefix.use("/webhooks/:id", RateLimit({ count: 10, window: 5 }));
prefix.use("/channels/:id", RateLimit({ count: 10, window: 5 }));
diff --git a/src/middlewares/RateLimit.ts b/src/middlewares/RateLimit.ts
index ab69113e..93d69236 100644
--- a/src/middlewares/RateLimit.ts
+++ b/src/middlewares/RateLimit.ts
@@ -1,8 +1,16 @@
import { db, MongooseCache, Bucket } from "@fosscord/server-util";
import { NextFunction, Request, Response } from "express";
-import { API_PREFIX, API_PREFIX_TRAILING_SLASH } from "./Authentication";
+import { getIpAdress } from "../util/ipAddress";
+import { API_PREFIX_TRAILING_SLASH } from "./Authentication";
-const Cache = new MongooseCache(db.collection("ratelimits"), [{ $match: { blocked: true } }], { onlyEvents: false, array: true });
+const Cache = new MongooseCache(
+ db.collection("ratelimits"),
+ [
+ // TODO: uncomment $match and fix error: not receiving change events
+ // { $match: { blocked: true } }
+ ],
+ { onlyEvents: false, array: true }
+);
// Docs: https://discord.com/developers/docs/topics/rate-limits
@@ -37,52 +45,86 @@ export default function RateLimit(opts: {
Cache.init(); // will only initalize it once
return async (req: Request, res: Response, next: NextFunction) => {
- const bucket_id = req.path.replace(API_PREFIX_TRAILING_SLASH, "");
- const user_id = req.user_id;
- const max_hits = req.user_bot ? opts.bot : opts.count;
- const offender = Cache.data.find((x: Bucket) => x.user && x.id === bucket_id) as Bucket | null;
+ const bucket_id = opts.bucket || req.path.replace(API_PREFIX_TRAILING_SLASH, "");
+ const user_id = req.user_id || getIpAdress(req);
+ var max_hits = opts.count;
+ if (opts.bot && req.user_bot) max_hits = opts.bot;
+ if (opts.GET && ["GET", "OPTIONS", "HEAD"].includes(req.method)) max_hits = opts.GET;
+ else if (opts.MODIFY && ["POST", "DELETE", "PATCH", "PUT"].includes(req.method)) max_hits = opts.MODIFY;
+
+ const offender = Cache.data?.find((x: Bucket) => x.user_id == user_id && x.id === bucket_id) as Bucket | null;
if (offender && offender.blocked) {
- const reset = offender.created_at.getTime() + opts.window;
+ const reset = offender.expires_at.getTime();
const resetAfterMs = reset - Date.now();
const resetAfterSec = resetAfterMs / 1000;
const global = bucket_id === "global";
+ console.log("blocked", { resetAfterMs });
- return (
- res
- .status(429)
- .set("X-RateLimit-Limit", `${max_hits}`)
- .set("X-RateLimit-Remaining", "0")
- .set("X-RateLimit-Reset", `${reset}`)
- .set("X-RateLimit-Reset-After", `${resetAfterSec}`)
- .set("X-RateLimit-Global", `${global}`)
- .set("Retry-After", `${Math.ceil(resetAfterSec)}`)
- .set("X-RateLimit-Bucket", `${bucket_id}`)
- // TODO: error rate limit message translation
- .send({ message: "You are being rate limited.", retry_after: resetAfterSec, global })
- );
+ if (resetAfterMs > 0) {
+ return (
+ res
+ .status(429)
+ .set("X-RateLimit-Limit", `${max_hits}`)
+ .set("X-RateLimit-Remaining", "0")
+ .set("X-RateLimit-Reset", `${reset}`)
+ .set("X-RateLimit-Reset-After", `${resetAfterSec}`)
+ .set("X-RateLimit-Global", `${global}`)
+ .set("Retry-After", `${Math.ceil(resetAfterSec)}`)
+ .set("X-RateLimit-Bucket", `${bucket_id}`)
+ // TODO: error rate limit message translation
+ .send({ message: "You are being rate limited.", retry_after: resetAfterSec, global })
+ );
+ } else {
+ // mongodb ttl didn't update yet -> manually update/delete
+ db.collection("ratelimits").updateOne(
+ { id: bucket_id, user_id },
+ { $set: { hits: 0, expires_at: new Date(Date.now() + opts.window * 1000), blocked: false } }
+ );
+ }
}
next();
- console.log(req.route);
if (opts.error) {
res.once("finish", () => {
// check if error and increment error rate limit
+ if (res.statusCode >= 400) {
+ // TODO: use config rate limit values
+ return hitRoute({ bucket_id: "error", user_id, max_hits: opts.error as number, window: opts.window });
+ }
});
}
- db.collection("ratelimits").updateOne(
- { bucket: bucket_id },
+ return hitRoute({ user_id, bucket_id, max_hits, window: opts.window });
+ };
+}
+
+function hitRoute(opts: { user_id: string; bucket_id: string; max_hits: number; window: number }) {
+ return db.collection("ratelimits").updateOne(
+ { id: opts.bucket_id, user_id: opts.user_id },
+ [
{
- $set: {
- id: bucket_id,
- user_id,
- created_at: new Date(),
- $cond: { if: { $gt: ["$hits", max_hits] }, then: true, else: false }
- },
- $inc: { hits: 1 }
+ $replaceRoot: {
+ newRoot: {
+ // similar to $setOnInsert
+ $mergeObjects: [
+ {
+ id: opts.bucket_id,
+ user_id: opts.user_id,
+ expires_at: new Date(Date.now() + opts.window * 1000)
+ },
+ "$$ROOT"
+ ]
+ }
+ }
},
- { upsert: true }
- );
- };
+ {
+ $set: {
+ hits: { $sum: [{ $ifNull: ["$hits", 0] }, 1] },
+ blocked: { $gt: ["$hits", opts.max_hits] }
+ }
+ }
+ ],
+ { upsert: true }
+ );
}
|