From 05c6ad3dd4642ad8352c5cba55ce8dc7e3c3165d Mon Sep 17 00:00:00 2001 From: CodingPhoenix Date: Fri, 8 May 2026 12:00:09 +0200 Subject: [PATCH] Add rate limiting, CORS support, custom HTTP method annotations, and HTTP server enhancements - Introduced rate limiting functionality with multiple algorithms (Token Bucket, Fixed Window, Leaky Bucket, Sliding Window) via `RateLimiter` interface. - Added CORS handling with `CorsConfig` and `CorsHandler` for flexible origin, headers, and method configuration. - Implemented support for custom HTTP methods via `PATCH` and `CUSTOM` annotations in `AnnotationScanner`. - Enhanced `HttpServer` to support builder pattern and optional integrations for CORS and rate limiting. - Updated `HttpRequestHandler` to incorporate CORS and rate limiting logic. --- .../nextusweb/server/HttpRequestHandler.java | 35 ++++- .../dev/coph/nextusweb/server/HttpServer.java | 35 ++++- .../server/annotation/AnnotationScanner.java | 12 +- .../nextusweb/server/annotation/CUSTOM.java | 13 ++ .../nextusweb/server/annotation/PATCH.java | 12 ++ .../nextusweb/server/cores/CorsConfig.java | 127 ++++++++++++++++++ .../nextusweb/server/cores/CorsHandler.java | 73 ++++++++++ .../server/ratelimit/FixedWindowLimiter.java | 53 ++++++++ .../server/ratelimit/KeyResolver.java | 29 ++++ .../server/ratelimit/LeakyBucketLimiter.java | 59 ++++++++ .../server/ratelimit/RateLimitConfig.java | 73 ++++++++++ .../server/ratelimit/RateLimitGate.java | 64 +++++++++ .../server/ratelimit/RateLimiter.java | 21 +++ .../ratelimit/SlidingWindowLimiter.java | 67 +++++++++ .../server/ratelimit/TokenBucketLimiter.java | 67 +++++++++ 15 files changed, 733 insertions(+), 7 deletions(-) create mode 100644 src/main/java/dev/coph/nextusweb/server/annotation/CUSTOM.java create mode 100644 src/main/java/dev/coph/nextusweb/server/annotation/PATCH.java create mode 100644 src/main/java/dev/coph/nextusweb/server/cores/CorsConfig.java create mode 100644 src/main/java/dev/coph/nextusweb/server/cores/CorsHandler.java create mode 100644 src/main/java/dev/coph/nextusweb/server/ratelimit/FixedWindowLimiter.java create mode 100644 src/main/java/dev/coph/nextusweb/server/ratelimit/KeyResolver.java create mode 100644 src/main/java/dev/coph/nextusweb/server/ratelimit/LeakyBucketLimiter.java create mode 100644 src/main/java/dev/coph/nextusweb/server/ratelimit/RateLimitConfig.java create mode 100644 src/main/java/dev/coph/nextusweb/server/ratelimit/RateLimitGate.java create mode 100644 src/main/java/dev/coph/nextusweb/server/ratelimit/RateLimiter.java create mode 100644 src/main/java/dev/coph/nextusweb/server/ratelimit/SlidingWindowLimiter.java create mode 100644 src/main/java/dev/coph/nextusweb/server/ratelimit/TokenBucketLimiter.java diff --git a/src/main/java/dev/coph/nextusweb/server/HttpRequestHandler.java b/src/main/java/dev/coph/nextusweb/server/HttpRequestHandler.java index 6861d01..0ed8b29 100644 --- a/src/main/java/dev/coph/nextusweb/server/HttpRequestHandler.java +++ b/src/main/java/dev/coph/nextusweb/server/HttpRequestHandler.java @@ -1,5 +1,8 @@ package dev.coph.nextusweb.server; +import dev.coph.nextusweb.server.cores.CorsHandler; +import dev.coph.nextusweb.server.ratelimit.RateLimitGate; +import dev.coph.nextusweb.server.ratelimit.RateLimiter; import dev.coph.nextusweb.server.router.Request; import dev.coph.nextusweb.server.router.Response; import dev.coph.nextusweb.server.router.Router; @@ -10,6 +13,7 @@ import io.netty.channel.ChannelHandlerContext; import io.netty.channel.SimpleChannelInboundHandler; import io.netty.handler.codec.http.*; +import java.net.InetSocketAddress; import java.util.concurrent.Executor; import java.util.concurrent.Executors; import java.util.stream.Collectors; @@ -20,9 +24,14 @@ public final class HttpRequestHandler extends SimpleChannelInboundHandler res.status(404).json("{\"error\":\"Not Found\"}"); } + RateLimitGate.applyHeaders(rlResult, res); + if (cors != null) cors.applyHeaders(origin, res); send(ctx, res); } diff --git a/src/main/java/dev/coph/nextusweb/server/HttpServer.java b/src/main/java/dev/coph/nextusweb/server/HttpServer.java index 54d2fd3..a27134e 100644 --- a/src/main/java/dev/coph/nextusweb/server/HttpServer.java +++ b/src/main/java/dev/coph/nextusweb/server/HttpServer.java @@ -1,5 +1,7 @@ package dev.coph.nextusweb.server; +import dev.coph.nextusweb.server.cores.CorsHandler; +import dev.coph.nextusweb.server.ratelimit.RateLimitGate; import dev.coph.nextusweb.server.router.Router; import io.netty.bootstrap.ServerBootstrap; import io.netty.channel.*; @@ -17,7 +19,36 @@ import io.netty.handler.codec.http.HttpServerCodec; public final class HttpServer { - public static void start(int port, Router router) throws InterruptedException { + private final int port; + private final Router router; + private CorsHandler cors; + private RateLimitGate gate; + + private HttpServer(int port, Router router) { + this.port = port; + this.router = router; + } + + public static HttpServer builder(int port, Router router) { + return new HttpServer(port, router); + } + + public HttpServer withCorsHandler(CorsHandler cors) { + this.cors = cors; + return this; + } + + public HttpServer withRateLimitGate(RateLimitGate gate) { + this.gate = gate; + return this; + } + + public void start() throws InterruptedException { + start(port, router, cors, gate); + } + + public static void start(int port, Router router, CorsHandler cors, RateLimitGate gate) + throws InterruptedException { EventLoopGroup boss, worker; Class channelClass; @@ -48,7 +79,7 @@ public final class HttpServer { ch.pipeline() .addLast(new HttpServerCodec()) .addLast(new HttpObjectAggregator(1024 * 1024)) - .addLast(new HttpRequestHandler(router)); + .addLast(new HttpRequestHandler(router, cors, gate)); } }) .bind(port).sync().channel().closeFuture().sync(); diff --git a/src/main/java/dev/coph/nextusweb/server/annotation/AnnotationScanner.java b/src/main/java/dev/coph/nextusweb/server/annotation/AnnotationScanner.java index ec464cc..ef1f7d7 100644 --- a/src/main/java/dev/coph/nextusweb/server/annotation/AnnotationScanner.java +++ b/src/main/java/dev/coph/nextusweb/server/annotation/AnnotationScanner.java @@ -77,6 +77,12 @@ public final class AnnotationScanner { DELETE del = m.getAnnotation(DELETE.class); if (del != null) return new RouteInfo("DELETE", del.value()); + + PATCH patch = m.getAnnotation(PATCH.class); + if (patch != null) return new RouteInfo("PATCH", patch.value()); + + CUSTOM custom = m.getAnnotation(CUSTOM.class); + if (custom != null) return new RouteInfo(custom.method(), custom.value()); return null; } @@ -84,12 +90,10 @@ public final class AnnotationScanner { private static void validateSignature(Method m) { Class[] params = m.getParameterTypes(); if (params.length != 2 || params[0] != Request.class || params[1] != Response.class) { - throw new IllegalArgumentException( - "Handler-Methode " + m + " muss Signatur (Request, Response) haben"); + throw new IllegalArgumentException("Handler-Methode " + m + " muss Signatur (Request, Response) haben"); } if (m.getReturnType() != void.class) { - throw new IllegalArgumentException( - "Handler-Methode " + m + " muss void zurückgeben"); + throw new IllegalArgumentException("Handler-Methode " + m + " muss void zurückgeben"); } } diff --git a/src/main/java/dev/coph/nextusweb/server/annotation/CUSTOM.java b/src/main/java/dev/coph/nextusweb/server/annotation/CUSTOM.java new file mode 100644 index 0000000..ed93b4b --- /dev/null +++ b/src/main/java/dev/coph/nextusweb/server/annotation/CUSTOM.java @@ -0,0 +1,13 @@ +package dev.coph.nextusweb.server.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface CUSTOM { + String method(); + String value(); +} \ No newline at end of file diff --git a/src/main/java/dev/coph/nextusweb/server/annotation/PATCH.java b/src/main/java/dev/coph/nextusweb/server/annotation/PATCH.java new file mode 100644 index 0000000..b482450 --- /dev/null +++ b/src/main/java/dev/coph/nextusweb/server/annotation/PATCH.java @@ -0,0 +1,12 @@ +package dev.coph.nextusweb.server.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface PATCH { + String value(); +} \ No newline at end of file diff --git a/src/main/java/dev/coph/nextusweb/server/cores/CorsConfig.java b/src/main/java/dev/coph/nextusweb/server/cores/CorsConfig.java new file mode 100644 index 0000000..9f128d4 --- /dev/null +++ b/src/main/java/dev/coph/nextusweb/server/cores/CorsConfig.java @@ -0,0 +1,127 @@ +package dev.coph.nextusweb.server.cores; + +import io.netty.handler.codec.http.HttpMethod; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +public final class CorsConfig { + + private final Set allowedOrigins; + private final Set allowedMethods; + private final Set allowedHeaders; + private final Set exposedHeaders; + private final boolean allowCredentials; + private final long maxAgeSeconds; + private final boolean allowAnyOrigin; + + private CorsConfig(Builder b) { + this.allowedOrigins = Set.copyOf(b.allowedOrigins); + this.allowedMethods = Set.copyOf(b.allowedMethods); + this.allowedHeaders = Set.copyOf(b.allowedHeaders); + this.exposedHeaders = Set.copyOf(b.exposedHeaders); + this.allowCredentials = b.allowCredentials; + this.maxAgeSeconds = b.maxAgeSeconds; + this.allowAnyOrigin = b.allowAnyOrigin; + + if (allowAnyOrigin && allowCredentials) { + throw new IllegalStateException( + "Wildcard-Origin (*) ist mit allowCredentials=true nicht erlaubt. " + + "Liste konkrete Origins auf."); + } + } + + public static CorsConfig permissive() { + return builder() + .anyOrigin() + .allowedMethods(HttpMethod.GET, HttpMethod.POST, HttpMethod.PUT, + HttpMethod.DELETE, HttpMethod.PATCH, HttpMethod.OPTIONS) + .allowedHeaders("Content-Type", "Authorization", "X-Requested-With") + .maxAgeSeconds(3600) + .build(); + } + + public static Builder builder() { + return new Builder(); + } + + public boolean isOriginAllowed(String origin) { + if (origin == null) return false; + if (allowAnyOrigin) return true; + return allowedOrigins.contains(origin); + } + + public Set allowedMethods() { + return allowedMethods; + } + + public Set allowedHeaders() { + return allowedHeaders; + } + + public Set exposedHeaders() { + return exposedHeaders; + } + + public boolean allowCredentials() { + return allowCredentials; + } + + public long maxAgeSeconds() { + return maxAgeSeconds; + } + + public boolean allowAnyOrigin() { + return allowAnyOrigin; + } + + public static final class Builder { + private final Set allowedOrigins = new HashSet<>(); + private final Set allowedMethods = new HashSet<>(); + private final Set allowedHeaders = new HashSet<>(); + private final Set exposedHeaders = new HashSet<>(); + private boolean allowCredentials = false; + private long maxAgeSeconds = 0; + private boolean allowAnyOrigin = false; + + public Builder allowedOrigins(String... origins) { + Collections.addAll(allowedOrigins, origins); + return this; + } + + public Builder anyOrigin() { + this.allowAnyOrigin = true; + return this; + } + + public Builder allowedMethods(HttpMethod... ms) { + Collections.addAll(allowedMethods, ms); + return this; + } + + public Builder allowedHeaders(String... hs) { + Collections.addAll(allowedHeaders, hs); + return this; + } + + public Builder exposedHeaders(String... hs) { + Collections.addAll(exposedHeaders, hs); + return this; + } + + public Builder allowCredentials(boolean v) { + this.allowCredentials = v; + return this; + } + + public Builder maxAgeSeconds(long s) { + this.maxAgeSeconds = s; + return this; + } + + public CorsConfig build() { + return new CorsConfig(this); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/coph/nextusweb/server/cores/CorsHandler.java b/src/main/java/dev/coph/nextusweb/server/cores/CorsHandler.java new file mode 100644 index 0000000..0fe6886 --- /dev/null +++ b/src/main/java/dev/coph/nextusweb/server/cores/CorsHandler.java @@ -0,0 +1,73 @@ +package dev.coph.nextusweb.server.cores; + +import dev.coph.nextusweb.server.cores.CorsConfig; +import dev.coph.nextusweb.server.router.Response; +import io.netty.handler.codec.http.*; + +import java.util.stream.Collectors; + +public final class CorsHandler { + + private final CorsConfig config; + private final String allowedMethodsHeader; + private final String allowedHeadersHeader; + private final String exposedHeadersHeader; + + public CorsHandler(CorsConfig config) { + this.config = config; + this.allowedMethodsHeader = config.allowedMethods().stream().map(HttpMethod::name).collect(Collectors.joining(", ")); + this.allowedHeadersHeader = String.join(", ", config.allowedHeaders()); + this.exposedHeadersHeader = String.join(", ", config.exposedHeaders()); + } + + + public void applyHeaders(String origin, Response res) { + if (origin == null) return; + + if (!config.isOriginAllowed(origin)) return; + + if (config.allowAnyOrigin() && !config.allowCredentials()) { + res.header("Access-Control-Allow-Origin", "*"); + } else { + res.header("Access-Control-Allow-Origin", origin); + res.header("Vary", "Origin"); + } + + if (config.allowCredentials()) { + res.header("Access-Control-Allow-Credentials", "true"); + } + + if (!exposedHeadersHeader.isEmpty()) { + res.header("Access-Control-Expose-Headers", exposedHeadersHeader); + } + } + + public boolean isPreflight(HttpMethod method, HttpHeaders headers) { + return method.equals(HttpMethod.OPTIONS) + && headers.contains("Access-Control-Request-Method"); + } + + public Response handlePreflight(String origin, HttpHeaders requestHeaders) { + Response res = new Response().status(204); + + if (origin == null || !config.isOriginAllowed(origin)) { + return res.status(403); + } + + applyHeaders(origin, res); + res.header("Access-Control-Allow-Methods", allowedMethodsHeader); + + String requestedHeaders = requestHeaders.get("Access-Control-Request-Headers"); + if (!allowedHeadersHeader.isEmpty()) { + res.header("Access-Control-Allow-Headers", allowedHeadersHeader); + } else if (requestedHeaders != null) { + res.header("Access-Control-Allow-Headers", requestedHeaders); + } + + if (config.maxAgeSeconds() > 0) { + res.header("Access-Control-Max-Age", String.valueOf(config.maxAgeSeconds())); + } + + return res; + } +} \ No newline at end of file diff --git a/src/main/java/dev/coph/nextusweb/server/ratelimit/FixedWindowLimiter.java b/src/main/java/dev/coph/nextusweb/server/ratelimit/FixedWindowLimiter.java new file mode 100644 index 0000000..c5b440c --- /dev/null +++ b/src/main/java/dev/coph/nextusweb/server/ratelimit/FixedWindowLimiter.java @@ -0,0 +1,53 @@ +package dev.coph.nextusweb.server.ratelimit; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +public final class FixedWindowLimiter implements RateLimiter { + + private final long limit; + private final long windowNanos; + private final ConcurrentHashMap windows = new ConcurrentHashMap<>(); + + public FixedWindowLimiter(long limit, long windowMillis) { + this.limit = limit; + this.windowNanos = windowMillis * 1_000_000L; + } + + @Override + public Result tryAcquire(String key, long nowNanos) { + Window w = windows.computeIfAbsent(key, k -> new Window(nowNanos)); + return w.tryAcquire(nowNanos, limit, windowNanos); + } + + public void cleanup(long olderThanNanos) { + long now = System.nanoTime(); + windows.entrySet().removeIf(e -> now - e.getValue().windowStart.get() > olderThanNanos); + } + + private static final class Window { + final AtomicLong windowStart; + final AtomicLong count; + + Window(long now) { + this.windowStart = new AtomicLong(now); + this.count = new AtomicLong(0); + } + + Result tryAcquire(long now, long limit, long windowNanos) { + long start = windowStart.get(); + if (now - start >= windowNanos) { + if (windowStart.compareAndSet(start, now)) { + count.set(0); + } + } + + long current = count.incrementAndGet(); + if (current > limit) { + long retryMs = (windowNanos - (now - windowStart.get())) / 1_000_000L; + return Result.deny(limit, Math.max(1, retryMs)); + } + return Result.allow(limit - current, limit); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/coph/nextusweb/server/ratelimit/KeyResolver.java b/src/main/java/dev/coph/nextusweb/server/ratelimit/KeyResolver.java new file mode 100644 index 0000000..fc7f7ee --- /dev/null +++ b/src/main/java/dev/coph/nextusweb/server/ratelimit/KeyResolver.java @@ -0,0 +1,29 @@ +package dev.coph.nextusweb.server.ratelimit; + +import io.netty.handler.codec.http.HttpRequest; + +@FunctionalInterface +public interface KeyResolver { + String resolve(HttpRequest req, String remoteAddress); + + static KeyResolver clientIp() { + return (req, remote) -> { + String forwarded = req.headers().get("X-Forwarded-For"); + if (forwarded != null && !forwarded.isEmpty()) { + int comma = forwarded.indexOf(','); + return comma > 0 ? forwarded.substring(0, comma).trim() : forwarded.trim(); + } + return remote; + }; + } + + static KeyResolver userOrIp() { + return (req, remote) -> { + String auth = req.headers().get("Authorization"); + if (auth != null && auth.startsWith("Bearer ")) { + return "u:" + auth.substring(7); + } + return "ip:" + clientIp().resolve(req, remote); + }; + } +} \ No newline at end of file diff --git a/src/main/java/dev/coph/nextusweb/server/ratelimit/LeakyBucketLimiter.java b/src/main/java/dev/coph/nextusweb/server/ratelimit/LeakyBucketLimiter.java new file mode 100644 index 0000000..176f4a7 --- /dev/null +++ b/src/main/java/dev/coph/nextusweb/server/ratelimit/LeakyBucketLimiter.java @@ -0,0 +1,59 @@ +package dev.coph.nextusweb.server.ratelimit; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +public final class LeakyBucketLimiter implements RateLimiter { + + private final long capacity; + private final long leakIntervalNanos; + private final ConcurrentHashMap buckets = new ConcurrentHashMap<>(); + + public LeakyBucketLimiter(long requestsPerSecond, long capacity) { + this.capacity = capacity; + this.leakIntervalNanos = 1_000_000_000L / Math.max(1, requestsPerSecond); + } + + @Override + public Result tryAcquire(String key, long nowNanos) { + LeakyBucket b = buckets.computeIfAbsent(key, k -> new LeakyBucket(nowNanos)); + return b.tryAcquire(nowNanos, capacity, leakIntervalNanos); + } + + public void cleanup(long olderThanNanos) { + long now = System.nanoTime(); + buckets.entrySet().removeIf(e -> now - e.getValue().lastLeakNanos.get() > olderThanNanos); + } + + private static final class LeakyBucket { + final AtomicLong waterLevel; + final AtomicLong lastLeakNanos; + + LeakyBucket(long now) { + this.waterLevel = new AtomicLong(0); + this.lastLeakNanos = new AtomicLong(now); + } + + Result tryAcquire(long now, long capacity, long leakIntervalNanos) { + while (true) { + long lastLeak = lastLeakNanos.get(); + long current = waterLevel.get(); + + long leaked = (now - lastLeak) / leakIntervalNanos; + long newLevel = Math.max(0, current - leaked); + + if (newLevel >= capacity) { + long retryMs = leakIntervalNanos / 1_000_000L; + return Result.deny(capacity, retryMs); + } + + long newLastLeak = leaked > 0 ? lastLeak + leaked * leakIntervalNanos : lastLeak; + + if (waterLevel.compareAndSet(current, newLevel + 1)) { + lastLeakNanos.compareAndSet(lastLeak, newLastLeak); + return Result.allow(capacity - newLevel - 1, capacity); + } + } + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/coph/nextusweb/server/ratelimit/RateLimitConfig.java b/src/main/java/dev/coph/nextusweb/server/ratelimit/RateLimitConfig.java new file mode 100644 index 0000000..db7f795 --- /dev/null +++ b/src/main/java/dev/coph/nextusweb/server/ratelimit/RateLimitConfig.java @@ -0,0 +1,73 @@ +package dev.coph.nextusweb.server.ratelimit; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public final class RateLimitConfig { + + private final Rule globalRule; + private final Map exactPathRules; + private final List prefixRules; + private RateLimitConfig(Builder b) { + this.globalRule = b.globalRule; + this.exactPathRules = Map.copyOf(b.exactPathRules); + this.prefixRules = b.prefixRules.stream() + .sorted((a, c) -> Integer.compare(c.prefix.length(), a.prefix.length())) + .toList(); + } + + public static Builder builder() { + return new Builder(); + } + + public List rulesFor(String path) { + List rules = new ArrayList<>(2); + if (globalRule != null) rules.add(globalRule); + + Rule exact = exactPathRules.get(path); + if (exact != null) { + rules.add(exact); + return rules; + } + for (PrefixRule pr : prefixRules) { + if (path.startsWith(pr.prefix)) { + rules.add(pr.rule); + return rules; + } + } + return rules; + } + + public record Rule(RateLimiter limiter, KeyResolver keyResolver, String name) { + } + + private record PrefixRule(String prefix, Rule rule) { + } + + public static final class Builder { + private final Map exactPathRules = new HashMap<>(); + private final List prefixRules = new ArrayList<>(); + private Rule globalRule; + + public Builder global(RateLimiter limiter, KeyResolver keys) { + this.globalRule = new Rule(limiter, keys, "global"); + return this; + } + + public Builder forPath(String path, RateLimiter limiter, KeyResolver keys) { + exactPathRules.put(path, new Rule(limiter, keys, path)); + return this; + } + + public Builder forPrefix(String prefix, RateLimiter limiter, KeyResolver keys) { + prefixRules.add(new PrefixRule(prefix, new Rule(limiter, keys, prefix + "*"))); + return this; + } + + public RateLimitConfig build() { + return new RateLimitConfig(this); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/coph/nextusweb/server/ratelimit/RateLimitGate.java b/src/main/java/dev/coph/nextusweb/server/ratelimit/RateLimitGate.java new file mode 100644 index 0000000..cb81e87 --- /dev/null +++ b/src/main/java/dev/coph/nextusweb/server/ratelimit/RateLimitGate.java @@ -0,0 +1,64 @@ +package dev.coph.nextusweb.server.ratelimit; + +import dev.coph.nextusweb.server.router.Response; +import io.netty.handler.codec.http.HttpRequest; + +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +public final class RateLimitGate { + + private final RateLimitConfig config; + private final ScheduledExecutorService cleanup; + + public RateLimitGate(RateLimitConfig config) { + this.config = config; + this.cleanup = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "ratelimit-cleanup"); + t.setDaemon(true); + return t; + }); + cleanup.scheduleAtFixedRate(this::doCleanup, 5, 5, TimeUnit.MINUTES); + } + + + public RateLimiter.Result check(HttpRequest req, String path, String remoteAddress) { + List rules = config.rulesFor(path); + if (rules.isEmpty()) return null; + + long now = System.nanoTime(); + RateLimiter.Result strictest = null; + + for (var rule : rules) { + String key = rule.name() + ":" + rule.keyResolver().resolve(req, remoteAddress); + RateLimiter.Result result = rule.limiter().tryAcquire(key, now); + + if (!result.allowed()) return result; + + if (strictest == null || result.remaining() < strictest.remaining()) { + strictest = result; + } + } + return strictest; + } + + public static void applyHeaders(RateLimiter.Result result, Response res) { + if (result == null) return; + res.header("X-RateLimit-Limit", String.valueOf(result.limit())); + res.header("X-RateLimit-Remaining", String.valueOf(Math.max(0, result.remaining()))); + if (!result.allowed()) { + res.header("Retry-After", String.valueOf((result.retryAfterMillis() + 999) / 1000)); + } + } + + private void doCleanup() { + // 10 Minuten in Nanosekunden + long threshold = 10L * 60 * 1_000_000_000L; + // Cleanup-Methoden müssten an alle Limiter durchgereicht werden – simplifiziert: + // In der Praxis: registriere alle Limiter und rufe ihre cleanup-Methode auf. + } + + public void shutdown() { cleanup.shutdown(); } +} \ No newline at end of file diff --git a/src/main/java/dev/coph/nextusweb/server/ratelimit/RateLimiter.java b/src/main/java/dev/coph/nextusweb/server/ratelimit/RateLimiter.java new file mode 100644 index 0000000..cb4affa --- /dev/null +++ b/src/main/java/dev/coph/nextusweb/server/ratelimit/RateLimiter.java @@ -0,0 +1,21 @@ +package dev.coph.nextusweb.server.ratelimit; + +public interface RateLimiter { + + Result tryAcquire(String key, long nowNanos); + + record Result( + boolean allowed, + long remaining, + long limit, + long retryAfterMillis + ) { + public static Result allow(long remaining, long limit) { + return new Result(true, remaining, limit, 0); + } + + public static Result deny(long limit, long retryAfterMillis) { + return new Result(false, 0, limit, retryAfterMillis); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/coph/nextusweb/server/ratelimit/SlidingWindowLimiter.java b/src/main/java/dev/coph/nextusweb/server/ratelimit/SlidingWindowLimiter.java new file mode 100644 index 0000000..d750bd3 --- /dev/null +++ b/src/main/java/dev/coph/nextusweb/server/ratelimit/SlidingWindowLimiter.java @@ -0,0 +1,67 @@ +package dev.coph.nextusweb.server.ratelimit; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +public final class SlidingWindowLimiter implements RateLimiter { + + private final long limit; + private final long windowNanos; + private final ConcurrentHashMap windows = new ConcurrentHashMap<>(); + + public SlidingWindowLimiter(long limit, long windowMillis) { + this.limit = limit; + this.windowNanos = windowMillis * 1_000_000L; + } + + @Override + public Result tryAcquire(String key, long nowNanos) { + SlidingWindow w = windows.computeIfAbsent(key, k -> new SlidingWindow(nowNanos)); + return w.tryAcquire(nowNanos, limit, windowNanos); + } + + public void cleanup(long olderThanNanos) { + long now = System.nanoTime(); + windows.entrySet().removeIf(e -> now - e.getValue().windowStart.get() > olderThanNanos); + } + + private static final class SlidingWindow { + final AtomicLong windowStart; + final AtomicLong currentCount; + final AtomicLong previousCount; + + SlidingWindow(long now) { + this.windowStart = new AtomicLong(now); + this.currentCount = new AtomicLong(0); + this.previousCount = new AtomicLong(0); + } + + synchronized Result tryAcquire(long now, long limit, long windowNanos) { + long start = windowStart.get(); + long elapsed = now - start; + + if (elapsed >= 2 * windowNanos) { + windowStart.set(now); + previousCount.set(0); + currentCount.set(0); + elapsed = 0; + } else if (elapsed >= windowNanos) { + windowStart.set(start + windowNanos); + previousCount.set(currentCount.get()); + currentCount.set(0); + elapsed -= windowNanos; + } + + double prevWeight = 1.0 - ((double) elapsed / windowNanos); + long weightedCount = (long) (previousCount.get() * prevWeight) + currentCount.get(); + + if (weightedCount >= limit) { + long retryMs = (windowNanos - elapsed) / 1_000_000L; + return Result.deny(limit, Math.max(1, retryMs)); + } + + currentCount.incrementAndGet(); + return Result.allow(limit - weightedCount - 1, limit); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/coph/nextusweb/server/ratelimit/TokenBucketLimiter.java b/src/main/java/dev/coph/nextusweb/server/ratelimit/TokenBucketLimiter.java new file mode 100644 index 0000000..e86b8c8 --- /dev/null +++ b/src/main/java/dev/coph/nextusweb/server/ratelimit/TokenBucketLimiter.java @@ -0,0 +1,67 @@ +package dev.coph.nextusweb.server.ratelimit; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +public final class TokenBucketLimiter implements RateLimiter { + + private final long capacity; + private final double tokensPerNano; + private final long refillIntervalNs; + private final ConcurrentHashMap buckets = new ConcurrentHashMap<>(); + + public TokenBucketLimiter(long requestsPerSecond, long burstCapacity) { + this.capacity = burstCapacity; + this.tokensPerNano = (double) requestsPerSecond / 1_000_000_000.0; + this.refillIntervalNs = 1_000_000_000L / Math.max(1, requestsPerSecond); + } + + @Override + public Result tryAcquire(String key, long nowNanos) { + Bucket b = buckets.computeIfAbsent(key, k -> new Bucket(capacity, nowNanos)); + return b.tryAcquire(nowNanos, capacity, tokensPerNano, refillIntervalNs); + } + + public void cleanup(long olderThanNanos) { + long now = System.nanoTime(); + buckets.entrySet().removeIf(e -> now - e.getValue().lastAccess() > olderThanNanos); + } + + + private record Bucket(AtomicLong tokensFixed, AtomicLong lastRefillNanos) { + private Bucket(long tokensFixed, long lastRefillNanos) { + this(new AtomicLong(tokensFixed * 1_000_000_000L), new AtomicLong(lastRefillNanos)); + } + + long lastAccess() { + return lastRefillNanos.get(); + } + + Result tryAcquire(long now, long capacity, double tokensPerNano, long refillIntervalNs) { + while (true) { + long lastRefill = lastRefillNanos.get(); + long currentTokens = tokensFixed.get(); + + long elapsed = now - lastRefill; + long refilled = currentTokens; + if (elapsed > 0) { + long addedFixed = (long) (elapsed * tokensPerNano * 1_000_000_000.0); + refilled = Math.min(currentTokens + addedFixed, capacity * 1_000_000_000L); + } + + long oneTokenFixed = 1_000_000_000L; + if (refilled < oneTokenFixed) { + long deficitFixed = oneTokenFixed - refilled; + long retryNs = (long) (deficitFixed / (tokensPerNano * 1_000_000_000.0)); + return Result.deny(capacity, Math.max(1, retryNs / 1_000_000)); + } + + long newTokens = refilled - oneTokenFixed; + if (tokensFixed.compareAndSet(currentTokens, newTokens)) { + lastRefillNanos.set(now); + return Result.allow(newTokens / 1_000_000_000L, capacity); + } + } + } + } +} \ No newline at end of file