diff --git a/src/main/java/org/example/filter/redirect/RedirectFilter.java b/src/main/java/org/example/filter/redirect/RedirectFilter.java new file mode 100644 index 00000000..1a02f42b --- /dev/null +++ b/src/main/java/org/example/filter/redirect/RedirectFilter.java @@ -0,0 +1,54 @@ +package org.example.filter.redirect; + +import org.example.filter.Filter; +import org.example.filter.FilterChain; +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; + +import java.util.List; +import java.util.Objects; +import java.util.logging.Logger; + +public final class RedirectFilter implements Filter { + private static final Logger LOG = Logger.getLogger(RedirectFilter.class.getName()); + private final List rules; + + public RedirectFilter(List rules) { + this.rules = List.copyOf(Objects.requireNonNull(rules, "rules")); + } + + @Override + public void init() { + // no-op + } + + @Override + public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { + String path = request.getPath(); + + if (path == null) { + chain.doFilter(request, response); + return; + } + + for (RedirectRule rule : rules) { + if (rule.matches(path)) { // <-- DENNA RADEN SKA FINNAS + final String sanitizedPath = path.replaceAll("[\\r\\n]", "_"); + LOG.info(() -> "Redirecting " + sanitizedPath + " -> " + + rule.getTargetUrl() + " (" + rule.getStatusCode() + ")"); + + response.setStatusCode(rule.getStatusCode()); + response.setHeader("Location", rule.getTargetUrl()); + return; // STOP pipeline + } + } + + chain.doFilter(request, response); +} + + + @Override + public void destroy() { + // no-op + } +} diff --git a/src/main/java/org/example/filter/redirect/RedirectRule.java b/src/main/java/org/example/filter/redirect/RedirectRule.java new file mode 100644 index 00000000..946f804c --- /dev/null +++ b/src/main/java/org/example/filter/redirect/RedirectRule.java @@ -0,0 +1,44 @@ +package org.example.filter.redirect; + +import java.util.Objects; +import java.util.regex.Pattern; + +public final class RedirectRule { + private final Pattern sourcePattern; + private final String targetUrl; + private final int statusCode; + + public RedirectRule(Pattern sourcePattern, String targetUrl, int statusCode) { + this.sourcePattern = Objects.requireNonNull(sourcePattern, "sourcePattern"); + this.targetUrl = Objects.requireNonNull(targetUrl, "targetUrl"); + if (this.targetUrl.isBlank()) { + throw new IllegalArgumentException("targetUrl must not be blank"); + } + if (this.targetUrl.indexOf('\r') >= 0 || this.targetUrl.indexOf('\n') >= 0 + || this.targetUrl.indexOf('\0') >= 0) { + throw new IllegalArgumentException("targetUrl must not contain CR/LF or null byte"); + } + + if (statusCode != 301 && statusCode != 302) { + throw new IllegalArgumentException("statusCode must be 301 or 302"); + } + this.statusCode = statusCode; + } + + public Pattern getSourcePattern() { return sourcePattern; } + public String getTargetUrl() { return targetUrl; } + public int getStatusCode() { return statusCode; } + + public boolean matches(String requestPath) { + return requestPath != null && sourcePattern.matcher(requestPath).matches(); + } + + @Override + public String toString() { + return "RedirectRule{" + + "sourcePattern=" + sourcePattern + + ", targetUrl='" + targetUrl + '\'' + + ", statusCode=" + statusCode + + '}'; + } +} diff --git a/src/main/java/org/example/filter/redirect/RedirectRulesLoader.java b/src/main/java/org/example/filter/redirect/RedirectRulesLoader.java new file mode 100644 index 00000000..779cf5d4 --- /dev/null +++ b/src/main/java/org/example/filter/redirect/RedirectRulesLoader.java @@ -0,0 +1,46 @@ +package org.example.filter.redirect; + +import java.util.regex.Pattern; + +public final class RedirectRulesLoader { + private static final String REGEX_PREFIX = "regex:"; + + private RedirectRulesLoader() {} + + public static Pattern compileSourcePattern(String sourcePath) { + if (sourcePath == null || sourcePath.isBlank()) { + throw new IllegalArgumentException("sourcePath must not be blank"); + } + + String trimmed = sourcePath.trim(); + + if (trimmed.startsWith(REGEX_PREFIX)) { + String rawRegex = trimmed.substring(REGEX_PREFIX.length()).strip(); + if (rawRegex.isBlank()) { + throw new IllegalArgumentException("regex sourcePath must not be blank"); + } + return Pattern.compile(rawRegex); + } + + String regex; + if (trimmed.contains("*")) { + regex = wildcardToRegex(trimmed); + } else { + regex = Pattern.quote(trimmed); + } + return Pattern.compile("^" + regex + "$"); + } + + private static String wildcardToRegex(String wildcard) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < wildcard.length(); i++) { + char c = wildcard.charAt(i); + if (c == '*') { + sb.append("[^/]*"); + } else { + sb.append(Pattern.quote(String.valueOf(c))); + } + } + return sb.toString(); + } +} diff --git a/src/main/resources/.gitkeep b/src/main/resources/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/src/test/java/org/example/filter/redirect/RedirectFilterTest.java b/src/test/java/org/example/filter/redirect/RedirectFilterTest.java new file mode 100644 index 00000000..9748ac64 --- /dev/null +++ b/src/test/java/org/example/filter/redirect/RedirectFilterTest.java @@ -0,0 +1,109 @@ +package org.example.filter.redirect; + +import org.example.filter.FilterChain; +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class RedirectFilterTest { + + private static HttpRequest request(String path) { + return new HttpRequest("GET", path, "HTTP/1.1", Map.of(), null); + } + + private static String responseAsString(HttpResponseBuilder response) { + return new String(response.build(), StandardCharsets.UTF_8); + } + + @Test + void returns_301_redirect_and_stops_pipeline() { + RedirectFilter filter = new RedirectFilter(List.of( + new RedirectRule(Pattern.compile("^/old-page$"), "/new-page", 301) + )); + + AtomicBoolean chainCalled = new AtomicBoolean(false); + FilterChain chain = (req, res) -> chainCalled.set(true); + + HttpResponseBuilder res = new HttpResponseBuilder(); + + filter.doFilter(request("/old-page"), res, chain); + + String raw = responseAsString(res); + assertThat(raw).contains("HTTP/1.1 301 Moved Permanently"); + assertThat(raw).contains("Location: /new-page"); + assertThat(chainCalled.get()).isFalse(); + } + + @Test + void returns_302_redirect_and_stops_pipeline() { + RedirectFilter filter = new RedirectFilter(List.of( + new RedirectRule(Pattern.compile("^/temp$"), "https://example.com/temporary", 302) + )); + + AtomicBoolean chainCalled = new AtomicBoolean(false); + FilterChain chain = (req, res) -> chainCalled.set(true); + + HttpResponseBuilder res = new HttpResponseBuilder(); + + filter.doFilter(request("/temp"), res, chain); + + String raw = responseAsString(res); + assertThat(raw).contains("HTTP/1.1 302 Found"); + assertThat(raw).contains("Location: https://example.com/temporary"); + assertThat(chainCalled.get()).isFalse(); + } + + @Test + void no_matching_rule_calls_next_in_chain() { + RedirectFilter filter = new RedirectFilter(List.of( + new RedirectRule(Pattern.compile("^/old-page$"), "/new-page", 301) + )); + + AtomicBoolean chainCalled = new AtomicBoolean(false); + FilterChain chain = (req, res) -> { + chainCalled.set(true); + res.setStatusCode(200); + res.setBody("terminal"); + }; + + HttpResponseBuilder res = new HttpResponseBuilder(); + + filter.doFilter(request("/nope"), res, chain); + + String raw = responseAsString(res); + assertThat(chainCalled.get()).isTrue(); + assertThat(raw).contains("HTTP/1.1 200 OK"); + assertThat(raw).doesNotContain("Location:"); + } + + @Test + void wildcard_matching_docs_star() { + var p = RedirectRulesLoader.compileSourcePattern("/docs/*"); + assertThat(p.matcher("/docs/test").matches()).isTrue(); + assertThat(p.matcher("/docs/any/path").matches()).isFalse(); + assertThat(p.matcher("/doc/test").matches()).isFalse(); + } + + @Test + void regex_matching_via_loader_prefix() { + var p = RedirectRulesLoader.compileSourcePattern("regex:^/docs/(v1|v2)$"); + assertThat(p.matcher("/docs/v1").matches()).isTrue(); + assertThat(p.matcher("/docs/v2").matches()).isTrue(); + assertThat(p.matcher("/docs/v3").matches()).isFalse(); + } + + @Test + void redirect_rule_rejects_invalid_status_code() { + assertThatThrownBy(() -> new RedirectRule(Pattern.compile("^/x$"), "/y", 307)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/resources/.gitkeep b/src/test/resources/.gitkeep deleted file mode 100644 index e69de29b..00000000