diff --git a/src/main/java/org/juv25d/config/CompressionConfig.java b/src/main/java/org/juv25d/config/CompressionConfig.java new file mode 100644 index 00000000..c6618206 --- /dev/null +++ b/src/main/java/org/juv25d/config/CompressionConfig.java @@ -0,0 +1,22 @@ +package org.juv25d.config; + +import org.juv25d.util.ConfigLoader; + +public class CompressionConfig { + private final boolean enabled; + private final int minCompressSize; + + public CompressionConfig() { + ConfigLoader config = ConfigLoader.getInstance(); + this.enabled = config.isCompressionEnabled(); + this.minCompressSize = config.getMinCompressSize(); + } + + public boolean isEnabled() { + return enabled; + } + + public int getMinCompressSize() { + return minCompressSize; + } +} diff --git a/src/main/java/org/juv25d/filter/CompressionFilter.java b/src/main/java/org/juv25d/filter/CompressionFilter.java new file mode 100644 index 00000000..273ae6f9 --- /dev/null +++ b/src/main/java/org/juv25d/filter/CompressionFilter.java @@ -0,0 +1,116 @@ +package org.juv25d.filter; + +import org.juv25d.config.CompressionConfig; +import org.juv25d.filter.annotation.Global; +import org.juv25d.http.HttpRequest; +import org.juv25d.http.HttpResponse; +import org.juv25d.logging.ServerLogging; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Arrays; +import java.util.Map; +import java.util.logging.Logger; +import java.util.zip.GZIPOutputStream; + + +@Global(order = 10) +public class CompressionFilter implements Filter{ + private static final Logger LOGGER = ServerLogging.getLogger(); + + private final boolean enabled; + private final int minCompressSize; + + @Override + public void doFilter(HttpRequest req, HttpResponse res, FilterChain chain) throws IOException { + if (!enabled) { + chain.doFilter(req, res); + return; + } + + if (!acceptsGzip(req)) { + chain.doFilter(req, res); + return; + } + + chain.doFilter(req, res); + + byte[] body = res.body(); + if (body.length < minCompressSize) { + return; + } + + String existingEncoding = res.getHeader("Content-Encoding"); + if (existingEncoding != null && !existingEncoding.isBlank()) { + return; + } + + byte[] compressed = compress(body); + res.setBody(compressed); + res.setHeader("Content-Encoding", "gzip"); + + String existingVary = res.getHeader("Vary"); + if (existingVary == null || existingVary.isBlank()) { + res.setHeader("Vary", "Accept-Encoding"); + } else if (Arrays.stream(existingVary.split(",")) + .map(String::trim) + .noneMatch(v -> v.equalsIgnoreCase("Accept-Encoding"))) { + res.setHeader("Vary", existingVary + ", Accept-Encoding"); + } + + LOGGER.info("Compressed " + body.length + " bytes to " + compressed.length + " bytes"); + } + + public CompressionFilter() { + CompressionConfig config = new CompressionConfig(); + this.enabled = config.isEnabled(); + this.minCompressSize = config.getMinCompressSize(); + } + + public CompressionFilter(boolean enabled, int minCompressSize) { + this.enabled = enabled; + this.minCompressSize = minCompressSize; + } + + private boolean acceptsGzip(HttpRequest req) { + String acceptEncoding = req.headers().entrySet().stream() + .filter(e -> e.getKey().equalsIgnoreCase("Accept-Encoding")) + .map(Map.Entry::getValue) + .findFirst() + .orElse(null); + + if (acceptEncoding == null || acceptEncoding.isEmpty()) { + return false; + } + + return Arrays.stream(acceptEncoding.split(",")) + .map(String::trim) + .filter(this::isGzipWithQualityAboveZero) + .anyMatch(e -> e.split(";")[0].trim().equalsIgnoreCase("gzip")); + } + + private boolean isGzipWithQualityAboveZero(String encoding) { + String[] parts = encoding.split(";"); + String name = parts[0].trim(); + if (!name.equalsIgnoreCase("gzip")) return false; + + if (parts.length > 1) { + String q = parts[1].trim(); + if (q.startsWith("q=")) { + try { + double quality = Double.parseDouble(q.substring(2)); + return quality > 0; + } catch (NumberFormatException ignored) {} + } + } + return true; + } + + private byte [] compress(byte [] data) throws IOException { + ByteArrayOutputStream byteStream = new ByteArrayOutputStream(); + try (GZIPOutputStream gzipstream = new GZIPOutputStream(byteStream)) { + gzipstream.write(data); + } + return byteStream.toByteArray(); + } +} diff --git a/src/main/java/org/juv25d/util/ConfigLoader.java b/src/main/java/org/juv25d/util/ConfigLoader.java index 6ca73026..a0580c84 100644 --- a/src/main/java/org/juv25d/util/ConfigLoader.java +++ b/src/main/java/org/juv25d/util/ConfigLoader.java @@ -10,12 +10,14 @@ public class ConfigLoader { @Nullable private static ConfigLoader instance; private int port; + private int minCompressSize; private String logLevel = "INFO"; private String rootDirectory = "static"; private long requestsPerMinute; private long burstCapacity; private long maxBodySizeMb; private boolean rateLimitingEnabled; + private boolean compressionEnabled; private boolean requestBodySizeEnabled; private List trustedProxies; private List proxyRoutes = new ArrayList<>(); @@ -56,6 +58,8 @@ private void loadConfiguration(InputStream input) { this.rootDirectory = "static"; this.logLevel = "INFO"; this.trustedProxies = List.of(); + this.compressionEnabled = false; + this.minCompressSize = 1024; this.allowedOrigins = List.of(); this.allowedMethods = List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"); @@ -120,6 +124,17 @@ private void loadConfiguration(InputStream input) { Long.parseLong(String.valueOf(rateLimitingConfig.getOrDefault("burst-capacity", 100L))); } + Object compressionObj = config.get("compression"); + if (compressionObj != null) { + Map compressionConfig = asStringObjectMap(compressionObj); + this.compressionEnabled = + Boolean.parseBoolean(String.valueOf(compressionConfig.getOrDefault("enabled", false))); + + int parsedMinCompressSize = + Integer.parseInt(String.valueOf(compressionConfig.getOrDefault("min-compress-size", 1024))); + this.minCompressSize = Math.max(100, parsedMinCompressSize); + } + //Cors Object corsObj = config.get("cors"); if (corsObj != null) { @@ -223,6 +238,13 @@ public List getProxyRoutes() { return Collections.unmodifiableList(proxyRoutes); } + public boolean isCompressionEnabled() { + return compressionEnabled; + } + + public int getMinCompressSize() { + return minCompressSize; + } public List getAllowedOrigins() { return allowedOrigins; } diff --git a/src/main/resources/application-properties.yml b/src/main/resources/application-properties.yml index 0a80f49d..36396a10 100644 --- a/src/main/resources/application-properties.yml +++ b/src/main/resources/application-properties.yml @@ -16,6 +16,10 @@ rate-limiting: requests-per-minute: 60 burst-capacity: 100 +compression: + enabled: true + min-compress-size: 1024 + cors: allowed-origins: - http://localhost:3000 diff --git a/src/test/java/org/juv25d/filter/CompressionFilterTest.java b/src/test/java/org/juv25d/filter/CompressionFilterTest.java new file mode 100644 index 00000000..009db1d2 --- /dev/null +++ b/src/test/java/org/juv25d/filter/CompressionFilterTest.java @@ -0,0 +1,174 @@ +package org.juv25d.filter; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.juv25d.http.HttpRequest; +import org.juv25d.http.HttpResponse; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Map; +import java.util.zip.GZIPInputStream; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class CompressionFilterTest { + + @Mock + private HttpRequest req; + @Mock + private HttpResponse res; + @Mock + private FilterChain chain; + + @Test + void shouldNotCompress_whenDisabled() throws IOException { + CompressionFilter filter = new CompressionFilter(false, 1024); + + filter.doFilter(req, res, chain); + + verify(chain).doFilter(req, res); + verifyNoMoreInteractions(res); + } + + @Test + void shouldNotCompress_whenNoAcceptEncoding() throws IOException { + CompressionFilter filter = new CompressionFilter(true, 1024); + when(req.headers()).thenReturn(Map.of()); + + filter.doFilter(req, res, chain); + + verify(chain).doFilter(req, res); + verify(res, never()).setBody(any()); + } + + @Test + void shouldNotCompress_whenAcceptEncodingIsNotGzip() throws IOException { + CompressionFilter filter = new CompressionFilter(true, 1024); + when(req.headers()).thenReturn(Map.of( + "Accept-Encoding", "deflate, br" + )); + + filter.doFilter(req, res, chain); + + verify(chain).doFilter(req, res); + verify(res, never()).setBody(any()); + } + + @Test + void shouldCompress_whenAcceptEncodingIsGzip() throws IOException { + CompressionFilter filter = new CompressionFilter(true, 100); + when(req.headers()).thenReturn(Map.of( + "Accept-Encoding", "gzip, deflate" + )); + + byte[] body = "Hello, world!".repeat(100).getBytes(); + when(res.body()).thenReturn(body); + + filter.doFilter(req, res, chain); + + ArgumentCaptor captor = ArgumentCaptor.forClass(byte[].class); + verify(res).setBody(captor.capture()); + byte[] decompressed = gunzip(captor.getValue()); + assertArrayEquals(body, decompressed); + verify(res).setHeader("Content-Encoding", "gzip"); + verify(res).setHeader("Vary", "Accept-Encoding"); + } + + @Test + void shouldNotCompress_whenBodyIsSmallerThanThreshold() throws IOException { + CompressionFilter filter = new CompressionFilter(true, 1024); + when(req.headers()).thenReturn(Map.of( + "Accept-Encoding", "gzip" + )); + + when(res.body()).thenReturn("small".getBytes()); + + filter.doFilter(req, res, chain); + + verify(chain).doFilter(req, res); + verify(res, never()).setBody(any()); + } + + @Test + void shouldCompress_whenAcceptEncodingIsUpperCase() throws IOException { + CompressionFilter filter = new CompressionFilter(true, 100); + when(req.headers()).thenReturn(Map.of( + "Accept-Encoding", "GZIP" + )); + + byte[] body = "Hello, world!".repeat(100).getBytes(); + when(res.body()).thenReturn(body); + + filter.doFilter(req, res, chain); + + ArgumentCaptor captor = ArgumentCaptor.forClass(byte[].class); + verify(res).setBody(captor.capture()); + byte[] decompressed = gunzip(captor.getValue()); + assertArrayEquals(body, decompressed); + verify(res).setHeader("Content-Encoding", "gzip"); + } + + @Test + void shouldCompress_whenBodyIsExactlyThreshold() throws IOException { + CompressionFilter filter = new CompressionFilter(true, 5); + when(req.headers()).thenReturn(Map.of( + "Accept-Encoding", "gzip" + )); + + when(res.body()).thenReturn("Hello".getBytes()); + + filter.doFilter(req, res, chain); + + ArgumentCaptor captor = ArgumentCaptor.forClass(byte[].class); + verify(res).setBody(captor.capture()); + byte[] decompressed = gunzip(captor.getValue()); + assertArrayEquals("Hello".getBytes(), decompressed); + verify(res).setHeader("Content-Encoding", "gzip"); + } + + @Test + void shouldNotCompress_whenGzipWithQualityZero() throws IOException { + CompressionFilter filter = new CompressionFilter(true, 100); + when(req.headers()).thenReturn(Map.of( + "Accept-Encoding", "gzip;q=0" + )); + + filter.doFilter(req, res, chain); + + verify(chain).doFilter(req, res); + verify(res, never()).setBody(any()); + } + + @Test + void shouldCompress_whenGzipWithQualityAboveZero() throws IOException { + CompressionFilter filter = new CompressionFilter(true, 100); + when(req.headers()).thenReturn(Map.of( + "Accept-Encoding", "gzip;q=0.5" + )); + byte[] body = "Hello, world!".repeat(100).getBytes(); + when(res.body()).thenReturn(body); + + filter.doFilter(req, res, chain); + + ArgumentCaptor captor = ArgumentCaptor.forClass(byte[].class); + verify(res).setBody(captor.capture()); + byte[] decompressed = gunzip(captor.getValue()); + assertArrayEquals(body, decompressed); + verify(res).setHeader("Content-Encoding", "gzip"); + } + + private byte[] gunzip(byte[] gz) throws IOException { + try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(gz)); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + in.transferTo(out); + return out.toByteArray(); + } + } +}