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:
@@ -1,5 +1,8 @@
|
|||||||
package dev.coph.nextusweb.server;
|
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.Request;
|
||||||
import dev.coph.nextusweb.server.router.Response;
|
import dev.coph.nextusweb.server.router.Response;
|
||||||
import dev.coph.nextusweb.server.router.Router;
|
import dev.coph.nextusweb.server.router.Router;
|
||||||
@@ -10,6 +13,7 @@ import io.netty.channel.ChannelHandlerContext;
|
|||||||
import io.netty.channel.SimpleChannelInboundHandler;
|
import io.netty.channel.SimpleChannelInboundHandler;
|
||||||
import io.netty.handler.codec.http.*;
|
import io.netty.handler.codec.http.*;
|
||||||
|
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
import java.util.concurrent.Executor;
|
import java.util.concurrent.Executor;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
@@ -20,9 +24,14 @@ public final class HttpRequestHandler extends SimpleChannelInboundHandler<FullHt
|
|||||||
Executors.newVirtualThreadPerTaskExecutor();
|
Executors.newVirtualThreadPerTaskExecutor();
|
||||||
|
|
||||||
private final Router router;
|
private final Router router;
|
||||||
|
private final CorsHandler cors;
|
||||||
|
private final RateLimitGate rateLimit;
|
||||||
|
|
||||||
public HttpRequestHandler(Router router) {
|
|
||||||
|
public HttpRequestHandler(Router router, CorsHandler cors, RateLimitGate rateLimit) {
|
||||||
this.router = router;
|
this.router = router;
|
||||||
|
this.cors = cors;
|
||||||
|
this.rateLimit = rateLimit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -38,7 +47,29 @@ public final class HttpRequestHandler extends SimpleChannelInboundHandler<FullHt
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void handle(ChannelHandlerContext ctx, FullHttpRequest raw) {
|
private void handle(ChannelHandlerContext ctx, FullHttpRequest raw) {
|
||||||
|
String origin = raw.headers().get("Origin");
|
||||||
|
|
||||||
|
if (cors != null && cors.isPreflight(raw.method(), raw.headers())) {
|
||||||
|
send(ctx, cors.handlePreflight(origin, raw.headers()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
String path = new QueryStringDecoder(raw.uri()).path();
|
String path = new QueryStringDecoder(raw.uri()).path();
|
||||||
|
|
||||||
|
RateLimiter.Result rlResult = null;
|
||||||
|
if (rateLimit != null) {
|
||||||
|
String remote = ((InetSocketAddress) ctx.channel().remoteAddress()).getAddress().getHostAddress();
|
||||||
|
rlResult = rateLimit.check(raw, path, remote);
|
||||||
|
if (rlResult != null && !rlResult.allowed()) {
|
||||||
|
Response res = new Response().status(429).json("{\"error\":\"Too Many Requests\"}");
|
||||||
|
RateLimitGate.applyHeaders(rlResult, res);
|
||||||
|
if (cors != null) cors.applyHeaders(origin, res);
|
||||||
|
send(ctx, res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Router.Resolution resolution = router.resolve(raw.method(), path);
|
Router.Resolution resolution = router.resolve(raw.method(), path);
|
||||||
|
|
||||||
Response res = new Response();
|
Response res = new Response();
|
||||||
@@ -67,6 +98,8 @@ public final class HttpRequestHandler extends SimpleChannelInboundHandler<FullHt
|
|||||||
case Router.Resolution.NotFound nf -> res.status(404).json("{\"error\":\"Not Found\"}");
|
case Router.Resolution.NotFound nf -> res.status(404).json("{\"error\":\"Not Found\"}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RateLimitGate.applyHeaders(rlResult, res);
|
||||||
|
if (cors != null) cors.applyHeaders(origin, res);
|
||||||
send(ctx, res);
|
send(ctx, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package dev.coph.nextusweb.server;
|
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 dev.coph.nextusweb.server.router.Router;
|
||||||
import io.netty.bootstrap.ServerBootstrap;
|
import io.netty.bootstrap.ServerBootstrap;
|
||||||
import io.netty.channel.*;
|
import io.netty.channel.*;
|
||||||
@@ -17,7 +19,36 @@ import io.netty.handler.codec.http.HttpServerCodec;
|
|||||||
|
|
||||||
public final class HttpServer {
|
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;
|
EventLoopGroup boss, worker;
|
||||||
Class<? extends ServerChannel> channelClass;
|
Class<? extends ServerChannel> channelClass;
|
||||||
|
|
||||||
@@ -48,7 +79,7 @@ public final class HttpServer {
|
|||||||
ch.pipeline()
|
ch.pipeline()
|
||||||
.addLast(new HttpServerCodec())
|
.addLast(new HttpServerCodec())
|
||||||
.addLast(new HttpObjectAggregator(1024 * 1024))
|
.addLast(new HttpObjectAggregator(1024 * 1024))
|
||||||
.addLast(new HttpRequestHandler(router));
|
.addLast(new HttpRequestHandler(router, cors, gate));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.bind(port).sync().channel().closeFuture().sync();
|
.bind(port).sync().channel().closeFuture().sync();
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ public final class AnnotationScanner {
|
|||||||
|
|
||||||
DELETE del = m.getAnnotation(DELETE.class);
|
DELETE del = m.getAnnotation(DELETE.class);
|
||||||
if (del != null) return new RouteInfo("DELETE", del.value());
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -84,12 +90,10 @@ public final class AnnotationScanner {
|
|||||||
private static void validateSignature(Method m) {
|
private static void validateSignature(Method m) {
|
||||||
Class<?>[] params = m.getParameterTypes();
|
Class<?>[] params = m.getParameterTypes();
|
||||||
if (params.length != 2 || params[0] != Request.class || params[1] != Response.class) {
|
if (params.length != 2 || params[0] != Request.class || params[1] != Response.class) {
|
||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException("Handler-Methode " + m + " muss Signatur (Request, Response) haben");
|
||||||
"Handler-Methode " + m + " muss Signatur (Request, Response) haben");
|
|
||||||
}
|
}
|
||||||
if (m.getReturnType() != void.class) {
|
if (m.getReturnType() != void.class) {
|
||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException("Handler-Methode " + m + " muss void zurückgeben");
|
||||||
"Handler-Methode " + m + " muss void zurückgeben");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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<String> allowedOrigins;
|
||||||
|
private final Set<HttpMethod> allowedMethods;
|
||||||
|
private final Set<String> allowedHeaders;
|
||||||
|
private final Set<String> 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<HttpMethod> allowedMethods() {
|
||||||
|
return allowedMethods;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<String> allowedHeaders() {
|
||||||
|
return allowedHeaders;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<String> 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<String> allowedOrigins = new HashSet<>();
|
||||||
|
private final Set<HttpMethod> allowedMethods = new HashSet<>();
|
||||||
|
private final Set<String> allowedHeaders = new HashSet<>();
|
||||||
|
private final Set<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user