Skip to content

Commit d00d7fd

Browse files
authored
Merge branch 'main' into filter-measure-time
2 parents 7bf012c + 74de92e commit d00d7fd

File tree

17 files changed

+835
-10
lines changed

17 files changed

+835
-10
lines changed

.dockerignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
target/
2+
.git/
3+
.mvn/
4+
mvnw
5+
mvnw.cmd
6+
.editorconfig
7+
.gitignore
8+
*.md

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,5 @@ FROM eclipse-temurin:25-jre-alpine
1313
WORKDIR /app
1414

1515
# might need to update this later when we have our explicit class names
16-
COPY --from=build /app/target/*.jar app.jar
16+
COPY --from=build /app/target/app.jar app.jar
1717
ENTRYPOINT ["java", "-jar", "app.jar"]

pom.xml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,24 @@
4040
<artifactId>snakeyaml</artifactId>
4141
<version>2.5</version>
4242
</dependency>
43+
<dependency>
44+
<groupId>com.bucket4j</groupId>
45+
<artifactId>bucket4j_jdk17-core</artifactId>
46+
<version>8.16.1</version>
47+
<scope>compile</scope>
48+
</dependency>
49+
<dependency>
50+
<groupId>org.testcontainers</groupId>
51+
<artifactId>testcontainers</artifactId>
52+
<version>2.0.3</version>
53+
<scope>test</scope>
54+
</dependency>
55+
<dependency>
56+
<groupId>org.testcontainers</groupId>
57+
<artifactId>junit-jupiter</artifactId>
58+
<version>1.21.4</version>
59+
<scope>test</scope>
60+
</dependency>
4361
</dependencies>
4462
<build>
4563
<plugins>

src/main/java/org/juv25d/App.java

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
package org.juv25d;
22

3-
import org.juv25d.filter.IpFilter;
4-
import org.juv25d.filter.LoggingFilter;
5-
import org.juv25d.filter.TimingFilter;
3+
import org.juv25d.filter.*;
64
import org.juv25d.logging.ServerLogging;
75
import org.juv25d.http.HttpParser;
86
import org.juv25d.plugin.NotFoundPlugin; // New import
97
import org.juv25d.plugin.StaticFilesPlugin;
108
import org.juv25d.router.SimpleRouter; // New import
119
import org.juv25d.util.ConfigLoader;
1210

11+
import java.util.List;
12+
1313
import java.util.Set;
1414
import java.util.logging.Logger;
1515

@@ -21,15 +21,33 @@ public static void main(String[] args) {
2121

2222
Pipeline pipeline = new Pipeline();
2323

24+
pipeline.addGlobalFilter(new SecurityHeadersFilter(), 0);
25+
26+
// Configure redirect rules
27+
List<RedirectRule> redirectRules = List.of(
28+
new RedirectRule("/old-page", "/new-page", 301),
29+
new RedirectRule("/temp", "https://example.com/temporary", 302),
30+
new RedirectRule("/docs/*", "/documentation/", 301)
31+
);
32+
pipeline.addGlobalFilter(new RedirectFilter(redirectRules), 0);
33+
2434
// IP filter is enabled but configured with open access during development
2535
// White/blacklist can be tightened when specific IP restrictions are decided
2636
pipeline.addGlobalFilter(new IpFilter(
2737
Set.of(),
2838
Set.of()
2939
), 0);
40+
3041
pipeline.addGlobalFilter(new LoggingFilter(), 0);
3142
pipeline.addGlobalFilter(new TimingFilter(), 0);
3243

44+
if (config.isRateLimitingEnabled()) {
45+
pipeline.addGlobalFilter(new RateLimitingFilter(
46+
config.getRequestsPerMinute(),
47+
config.getBurstCapacity()
48+
), 0);
49+
}
50+
3351
// Initialize and configure SimpleRouter
3452
SimpleRouter router = new SimpleRouter();
3553
router.registerPlugin("/", new StaticFilesPlugin()); // Register StaticFilesPlugin for the root path
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package org.juv25d.filter;
2+
3+
import io.github.bucket4j.Bandwidth;
4+
import io.github.bucket4j.Bucket;
5+
import io.github.bucket4j.Refill;
6+
import org.juv25d.http.HttpRequest;
7+
import org.juv25d.http.HttpResponse;
8+
import org.juv25d.logging.ServerLogging;
9+
10+
import java.io.IOException;
11+
import java.nio.charset.StandardCharsets;
12+
import java.time.Duration;
13+
import java.util.Map;
14+
import java.util.concurrent.ConcurrentHashMap;
15+
import java.util.logging.Logger;
16+
17+
/**
18+
* A filter that implements rate limiting for incoming HTTP requests.
19+
* It uses a token bucket algorithm via Bucket4J to limit the number of requests per client IP.
20+
*/
21+
public class RateLimitingFilter implements Filter {
22+
23+
private static final Logger logger = ServerLogging.getLogger();
24+
25+
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
26+
27+
private final long capacity;
28+
private final long refillTokens;
29+
private final Duration refillPeriod;
30+
31+
/**
32+
* Constructs a new RateLimitingFilter.
33+
*
34+
* @param requestsPerMinute the number of requests allowed per minute for each IP
35+
* @param burstCapacity the maximum number of requests that can be handled in a burst
36+
* @throws IllegalArgumentException if requestsPerMinute or burstCapacity is not positive
37+
*/
38+
public RateLimitingFilter(long requestsPerMinute, long burstCapacity) {
39+
if (requestsPerMinute <= 0) {
40+
throw new IllegalArgumentException("requestsPerMinute must be positive");
41+
}
42+
if (burstCapacity <= 0) {
43+
throw new IllegalArgumentException("burstCapacity must be positive");
44+
}
45+
46+
this.capacity = burstCapacity;
47+
this.refillTokens = requestsPerMinute;
48+
this.refillPeriod = Duration.ofMinutes(1);
49+
50+
logger.info(String.format(
51+
"RateLimitingFilter initialized - Limit: %d req/min, Burst: %d",
52+
requestsPerMinute, burstCapacity
53+
));
54+
}
55+
56+
/**
57+
* Applies the rate limiting logic to the incoming request.
58+
* If the rate limit is exceeded, a 429 Too Many Requests response is sent.
59+
*
60+
* @param req the HTTP request
61+
* @param res the HTTP response
62+
* @param chain the filter chain
63+
* @throws IOException if an I/O error occurs
64+
*/
65+
@Override
66+
public void doFilter(HttpRequest req, HttpResponse res, FilterChain chain) throws IOException {
67+
String clientIp = getClientIp(req);
68+
69+
Bucket bucket = buckets.computeIfAbsent(clientIp, k -> createBucket());
70+
71+
if (bucket.tryConsume(1)) {
72+
chain.doFilter(req, res);
73+
} else {
74+
logRateLimitExceeded(clientIp, req.method(), req.path());
75+
sendTooManyRequests(res, clientIp);
76+
}
77+
}
78+
79+
private String getClientIp(HttpRequest req) {
80+
return req.remoteIp();
81+
}
82+
83+
private Bucket createBucket() {
84+
Bandwidth limit = Bandwidth.classic(
85+
capacity,
86+
Refill.intervally(refillTokens, refillPeriod));
87+
88+
return Bucket.builder()
89+
.addLimit(limit)
90+
.build();
91+
}
92+
93+
/**
94+
* Returns the number of currently tracked IP addresses.
95+
*
96+
* @return the number of tracked IP addresses
97+
*/
98+
public int getTrackedIpCount() {
99+
return buckets.size();
100+
}
101+
102+
private void logRateLimitExceeded(String ip, String method, String path) {
103+
logger.warning(String.format(
104+
"Rate limit exceeded - IP: %s, Method: %s, Path: %s",
105+
ip, method, path
106+
));
107+
}
108+
109+
private void sendTooManyRequests(HttpResponse res, String ip) {
110+
byte[] body = ("429 Too Many Requests: Rate limit exceeded for IP " + ip + "\n")
111+
.getBytes(StandardCharsets.UTF_8);
112+
113+
res.setStatusCode(429);
114+
res.setStatusText("Too Many Requests");
115+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
116+
res.setHeader("Content-Length", String.valueOf(body.length));
117+
res.setHeader("Retry-After", "60");
118+
res.setBody(body);
119+
}
120+
121+
/**
122+
* Clears all tracked rate limiting buckets.
123+
*/
124+
@Override
125+
public void destroy() {
126+
buckets.clear();
127+
}
128+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package org.juv25d.filter;
2+
3+
import org.juv25d.http.HttpRequest;
4+
import org.juv25d.http.HttpResponse;
5+
6+
import java.io.IOException;
7+
import java.util.List;
8+
import java.util.logging.Logger;
9+
10+
/**
11+
* Filter that handles URL redirects based on configurable rules.
12+
*
13+
* When a request matches a redirect rule:
14+
* - The pipeline is stopped (no further processing)
15+
* - A redirect response is returned with the appropriate Location header
16+
* - Status code is either 301 (Moved Permanently) or 302 (Found/Temporary)
17+
*
18+
* Rules are evaluated in order - the first matching rule wins.
19+
*
20+
* Example usage:
21+
* <pre>
22+
* List<RedirectRule> rules = List.of(
23+
* new RedirectRule("/old-page", "/new-page", 301),
24+
* new RedirectRule("/temp", "https://example.com", 302),
25+
* new RedirectRule("/docs/*", "/documentation/", 301)
26+
* );
27+
* pipeline.addFilter(new RedirectFilter(rules));
28+
* </pre>
29+
*/
30+
public class RedirectFilter implements Filter {
31+
private final List<RedirectRule> rules;
32+
private final Logger logger;
33+
34+
/**
35+
* Creates a redirect filter with the given rules.
36+
*
37+
* @param rules List of redirect rules (evaluated in order)
38+
*/
39+
public RedirectFilter(List<RedirectRule> rules) {
40+
this.rules = rules;
41+
this.logger = Logger.getLogger(RedirectFilter.class.getName());
42+
}
43+
44+
@Override
45+
public void doFilter(HttpRequest request, HttpResponse response, FilterChain chain) throws IOException {
46+
String requestPath = request.path();
47+
48+
// Check each rule in order - first match wins
49+
for (RedirectRule rule : rules) {
50+
if (rule.matches(requestPath)) {
51+
logger.info("Redirecting: " + requestPath + " -> " + rule.getTargetUrl()
52+
+ " (" + rule.getStatusCode() + ")");
53+
54+
performRedirect(response, rule);
55+
56+
// Stop pipeline - don't call chain.doFilter()
57+
return;
58+
}
59+
}
60+
61+
// No matching rule - continue pipeline
62+
chain.doFilter(request, response);
63+
}
64+
65+
/**
66+
* Sets up the redirect response with appropriate headers.
67+
*
68+
* @param response The HTTP response to modify
69+
* @param rule The redirect rule to apply
70+
*/
71+
private void performRedirect(HttpResponse response, RedirectRule rule) {
72+
int statusCode = rule.getStatusCode();
73+
String statusText = statusCode == 301 ? "Moved Permanently" : "Found";
74+
75+
response.setStatusCode(statusCode);
76+
response.setStatusText(statusText);
77+
response.setHeader("Location", rule.getTargetUrl());
78+
response.setHeader("Content-Length", "0");
79+
80+
// Empty body for redirects
81+
response.setBody(new byte[0]);
82+
}
83+
}

0 commit comments

Comments
 (0)