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.
This commit is contained in:
CodingPhoenix
2026-05-08 12:00:09 +02:00
parent 392658d54e
commit 05c6ad3dd4
15 changed files with 733 additions and 7 deletions
@@ -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<String, Window> 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);
}
}
}
@@ -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);
};
}
}
@@ -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<String, LeakyBucket> 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);
}
}
}
}
}
@@ -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<String, Rule> exactPathRules;
private final List<PrefixRule> 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<Rule> rulesFor(String path) {
List<Rule> 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<String, Rule> exactPathRules = new HashMap<>();
private final List<PrefixRule> 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);
}
}
}
@@ -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<RateLimitConfig.Rule> 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(); }
}
@@ -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);
}
}
}
@@ -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<String, SlidingWindow> 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);
}
}
}
@@ -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<String, Bucket> 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);
}
}
}
}
}