From 98b40a1f09cc59c686898602bbf7a5a46441b111 Mon Sep 17 00:00:00 2001 From: Kathrin Trinh Date: Tue, 10 Feb 2026 14:44:16 +0100 Subject: [PATCH 01/67] Initial commit med all kod (#16) Add ci workflow for github actions. --- .github/workflows/ci.yml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..9610edff --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: Java CI with Maven + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Get Java Version + run: | + JAVA_VERSION=$(mvn help:evaluate "-Dexpression=maven.compiler.release" -q -DforceStdout) + echo "JAVA_VERSION=$JAVA_VERSION" >> $GITHUB_ENV + + - name: Set up JDK ${{ env.JAVA_VERSION }} + uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: 'temurin' + cache: maven + + - name: Compile with Maven + run: mvn -B compile --file pom.xml + + - name: Test with Maven + run: mvn -B test --file pom.xml \ No newline at end of file From c1b5f7009a6f2b19dcf63ea965bf44efd5453d7a Mon Sep 17 00:00:00 2001 From: Ebba Andersson Date: Tue, 10 Feb 2026 14:45:46 +0100 Subject: [PATCH 02/67] build: configure pom.xml with needed plugin/tools. (#19) * build: configure pom.xml with needed plugin/tools. Setup Java 25 environment with JUnit 5, Mockito, JaCoCo, Pitest, and Spotless * fix: add missing test scope for awaitility --- pom.xml | 48 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 6b7ade11..bb03ce76 100644 --- a/pom.xml +++ b/pom.xml @@ -9,12 +9,13 @@ 1.0-SNAPSHOT - 23 + 25 UTF-8 6.0.2 3.27.7 5.21.0 + org.junit.jupiter @@ -28,12 +29,24 @@ ${assertj.core.version} test + + org.mockito + mockito-core + ${mockito.version} + test + org.mockito mockito-junit-jupiter ${mockito.version} test + + org.awaitility + awaitility + 4.3.0 + test + @@ -118,6 +131,39 @@ + + org.pitest + pitest-maven + 1.22.0 + + + org.pitest + pitest-junit5-plugin + 1.2.2 + + + + + com.diffplug.spotless + spotless-maven-plugin + 3.2.1 + + + + + + + + + verify + + + check + + + + + From bf4d977cb9a4b722cfc7f56f168a6afb0b78d1b4 Mon Sep 17 00:00:00 2001 From: Kathrin Trinh Date: Tue, 10 Feb 2026 15:14:19 +0100 Subject: [PATCH 03/67] =?UTF-8?q?Initial=20commit=20f=C3=B6r=20tcp-server?= =?UTF-8?q?=20(#17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/example/App.java | 4 ++-- src/main/java/org/example/TcpServer.java | 28 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/example/TcpServer.java diff --git a/src/main/java/org/example/App.java b/src/main/java/org/example/App.java index 165e5cd5..66c9af10 100644 --- a/src/main/java/org/example/App.java +++ b/src/main/java/org/example/App.java @@ -2,6 +2,6 @@ public class App { public static void main(String[] args) { - System.out.println("Hello There!"); + new TcpServer(8080).start(); } -} +} \ No newline at end of file diff --git a/src/main/java/org/example/TcpServer.java b/src/main/java/org/example/TcpServer.java new file mode 100644 index 00000000..73ba0f27 --- /dev/null +++ b/src/main/java/org/example/TcpServer.java @@ -0,0 +1,28 @@ +package org.example; + +import java.io.IOException; +import java.net.ServerSocket; +import java.net.Socket; + +public class TcpServer { + + private final int port; + + public TcpServer(int port) { + this.port = port; + } + + public void start() { + System.out.println("Starting TCP server on port " + port); + + try (ServerSocket serverSocket = new ServerSocket(port)) { + while (true) { + Socket clientSocket = serverSocket.accept(); // block + System.out.println("Client connected: " + clientSocket.getRemoteSocketAddress()); + clientSocket.close(); + } + } catch (IOException e) { + throw new RuntimeException("Failed to start TCP server", e); + } + } +} \ No newline at end of file From 148411e2d8dda4936f543fbd7a9e74efff668026 Mon Sep 17 00:00:00 2001 From: Elias Lennheimer <47382348+Xeutos@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:33:02 +0100 Subject: [PATCH 04/67] Issue #12 (#21) * release.yml that builds and publishes Docker image to GitHub packages on release. * Fixed unverified commit stopping pull request from being merged --- .github/workflows/release.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..b86973f7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,35 @@ +name: Publish Docker Image to Github Packages on Release +on: + release: + types: + - published +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v6.0.2 + - uses: docker/setup-qemu-action@v3.7.0 + - uses: docker/setup-buildx-action@v3.12.0 + - name: Log in to GHCR + uses: docker/login-action@v3.7.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5.10.0 + with: + images: ghcr.io/ithsjava25/webserver + - name: Build and push + uses: docker/build-push-action@v6.18.0 + with: + context: . + push: true + platforms: linux/amd64, linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + From 9ac7b57f283f45290547bf776c9f2a28a1f0c92b Mon Sep 17 00:00:00 2001 From: Elias Lennheimer <47382348+Xeutos@users.noreply.github.com> Date: Wed, 11 Feb 2026 08:55:53 +0100 Subject: [PATCH 05/67] Feature/docker image builder issue#11 (#25) * Dockerfile that builds an image in a docker container then runs in another docker container Current implementation uses Temporary App.class reference before relevant file is created to start server. * Fixed unverified commit * Set up non-root user and updated Dockerfile to use user. Fixed file path to use /app/ instead of /app/org/example to prevent unnessary nesting of packages. --- Dockerfile | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..d8b69012 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM maven:3-eclipse-temurin-25-alpine AS build +WORKDIR /build +COPY src/ src/ +COPY pom.xml pom.xml +RUN mvn compile + +FROM eclipse-temurin:25-jre-alpine +RUN addgroup -S appgroup && adduser -S appuser -G appgroup +COPY --from=build /build/target/classes/ /app/ +ENTRYPOINT ["java", "-classpath", "/app", "org.example.App"] +USER appuser From 875d1efeb26a36aa6b6d723f5dad99a7f25f7732 Mon Sep 17 00:00:00 2001 From: Felix Eriksson Date: Wed, 11 Feb 2026 13:01:37 +0100 Subject: [PATCH 06/67] Feature/http parse headers (#18) * added parser class for headers * refactoring * refactoring * refactoring, added getHeadersMap * refactoring * refactoring code, adding debug boolean * added unit tests for HttpParser.java class * refactoring and all green tests for class * refactoring the code * refactoring the bufferedReader * refactoring with coderabbit feedback for header put to merge * refactoring with coderabbit feedback for header put to merge, changed headersmap * refactoring from reviewer input * refactoring for code rabbit comments --- .../org/example/httpparser/HttpParser.java | 49 +++++++++++++++++ .../example/httpparser/HttpParserTest.java | 52 +++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 src/main/java/org/example/httpparser/HttpParser.java create mode 100644 src/test/java/org/example/httpparser/HttpParserTest.java diff --git a/src/main/java/org/example/httpparser/HttpParser.java b/src/main/java/org/example/httpparser/HttpParser.java new file mode 100644 index 00000000..0c27d34a --- /dev/null +++ b/src/main/java/org/example/httpparser/HttpParser.java @@ -0,0 +1,49 @@ +package org.example.httpparser; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +public class HttpParser { + private boolean debug = false; + private Map headersMap = new HashMap<>(); + private BufferedReader reader; + public void parseHttp(InputStream in) throws IOException { + if (this.reader == null) { + this.reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8)); + } + + String headerLine; + + while ((headerLine = reader.readLine()) != null) { + if (headerLine.isEmpty()) { + break; + } + + int valueSeparator = headerLine.indexOf(':'); + if (valueSeparator <= 0) { + continue; + } + + String key = headerLine.substring(0, valueSeparator).trim(); + String value = headerLine.substring(valueSeparator + 1).trim(); + + headersMap.merge(key, value, (existing, incoming) -> existing +", " + incoming); + } + if (debug) { + System.out.println("Host: " + headersMap.get("Host")); + for (String key : headersMap.keySet()) { + System.out.println(key + ": " + headersMap.get(key)); + } + } + } + + public Map getHeadersMap() { + return headersMap; + } + +} diff --git a/src/test/java/org/example/httpparser/HttpParserTest.java b/src/test/java/org/example/httpparser/HttpParserTest.java new file mode 100644 index 00000000..91a5af38 --- /dev/null +++ b/src/test/java/org/example/httpparser/HttpParserTest.java @@ -0,0 +1,52 @@ +package org.example.httpparser; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +class HttpParserTest { + private HttpParser httpParser = new HttpParser(); + + @Test + void TestHttpParserForHeaders() throws IOException { + String testInput = "GET /index.html HTTP/1.1\r\nHost: localhost\r\nContent-Type: text/plain\r\nUser-Agent: JUnit5\r\n\r\n"; + InputStream in = new ByteArrayInputStream(testInput.getBytes(StandardCharsets.UTF_8)); + + httpParser.parseHttp(in); + + assertNotNull(httpParser.getHeadersMap()); + assertThat(httpParser.getHeadersMap().size()).isEqualTo(3); + assertThat(httpParser.getHeadersMap().get("Host")).contains("localhost"); + assertThat(httpParser.getHeadersMap().get("Content-Type")).contains("text/plain"); + assertThat(httpParser.getHeadersMap().get("User-Agent")).contains("JUnit5"); + } + + @Test + void testParseHttp_EmptyInput() throws IOException { + InputStream in = new ByteArrayInputStream("".getBytes()); + httpParser.parseHttp(in); + + assertTrue(httpParser.getHeadersMap().isEmpty()); + } + + @Test + void testParseHttp_InvalidHeaderLine() throws IOException { + String rawInput = "Host: localhost\r\n InvalidLineWithoutColon\r\n Accept: */*\r\n\r\n"; + + InputStream in = new ByteArrayInputStream(rawInput.getBytes(StandardCharsets.UTF_8)); + httpParser.parseHttp(in); + + assertEquals(2, httpParser.getHeadersMap().size()); + assertEquals("localhost", httpParser.getHeadersMap().get("Host")); + assertEquals("*/*", httpParser.getHeadersMap().get("Accept")); + } + + +} From 9289c7d5e113976278b870c8e0dfc088b09c7109 Mon Sep 17 00:00:00 2001 From: JohanHiths Date: Wed, 11 Feb 2026 13:28:51 +0100 Subject: [PATCH 07/67] Feature/http response builder (#24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add HttpResponseBuilder and its tests Signed-off-by: JohanHiths * Implement minimal HTTP response builder Signed-off-by: JohanHiths * update coderabbit * more coderabbit fixes Signed-off-by: JohanHiths * Bytte från httptest till http efter rekommendationer från Xeutos * code rabbit fix * fixed more code rabbit problems --------- Signed-off-by: JohanHiths --- .../org/example/http/HttpResponseBuilder.java | 50 +++++++++++++++++++ .../example/http/HttpResponseBuilderTest.java | 45 +++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 src/main/java/org/example/http/HttpResponseBuilder.java create mode 100644 src/test/java/org/example/http/HttpResponseBuilderTest.java diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java new file mode 100644 index 00000000..eb1f77ea --- /dev/null +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -0,0 +1,50 @@ +package org.example.http; +// +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +public class HttpResponseBuilder { + + private static final String PROTOCOL = "HTTP/1.1"; + private int statusCode = 200; + private String body = ""; + private Map headers = new LinkedHashMap<>(); + + private static final String CRLF = "\r\n"; + + + public void setStatusCode(int statusCode) { + this.statusCode = statusCode; + } + + public void setBody(String body) { + this.body = body != null ? body : ""; + } + public void setHeaders(Map headers) { + this.headers = new LinkedHashMap<>(headers); + } + + private static final Map REASON_PHRASES = Map.of( + 200, "OK", + 201, "Created", + 400, "Bad Request", + 404, "Not Found", + 500, "Internal Server Error"); + + public String build(){ + StringBuilder sb = new StringBuilder(); + String reason = REASON_PHRASES.getOrDefault(statusCode, "OK"); + sb.append(PROTOCOL).append(" ").append(statusCode).append(" ").append(reason).append(CRLF); + headers.forEach((k,v) -> sb.append(k).append(": ").append(v).append(CRLF)); + sb.append("Content-Length: ") + .append(body.getBytes(StandardCharsets.UTF_8).length); + sb.append(CRLF); + sb.append("Connection: close").append(CRLF); + sb.append(CRLF); + sb.append(body); + return sb.toString(); + + } + +} diff --git a/src/test/java/org/example/http/HttpResponseBuilderTest.java b/src/test/java/org/example/http/HttpResponseBuilderTest.java new file mode 100644 index 00000000..8b80a7f8 --- /dev/null +++ b/src/test/java/org/example/http/HttpResponseBuilderTest.java @@ -0,0 +1,45 @@ +package org.example.http; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + + class HttpResponseBuilderTest { + + /** + * Verifies that build produces a valid HTTP response string! + * Status line is present + * Content-Length header is generated + * The response body is included + */ + + @Test + void build_returnsValidHttpResponse() { + + HttpResponseBuilder builder = new HttpResponseBuilder(); + + builder.setBody("Hello"); + + String result = builder.build(); + + assertThat(result).contains("HTTP/1.1 200 OK"); + assertThat(result).contains("Content-Length: 5"); + assertThat(result).contains("\r\n\r\n"); + assertThat(result).contains("Hello"); + + } + + // Verifies that Content-Length is calculated using UTF-8 byte length! + // + @Test + void build_handlesUtf8ContentLength() { + HttpResponseBuilder builder = new HttpResponseBuilder(); + + builder.setBody("å"); + + String result = builder.build(); + + assertThat(result).contains("Content-Length: 2"); + + } +} From 6bdb1ef3376b5102baf66b07350f55945c7b8b0b Mon Sep 17 00:00:00 2001 From: Felix Eriksson Date: Wed, 11 Feb 2026 14:31:37 +0100 Subject: [PATCH 08/67] Feature/http parse request line (#20) * added parser class for headers * refactoring * refactoring * refactoring, added getHeadersMap * refactoring * adding class for parsing requestLine (method, path, version). Has getters/setters for the three. * refactoring code, private setters * added tests for throwing error * refactoring the bufferedReader * refactoring, adding check for requestLineArray.length() is less or equals to two with added unit test * refactoring, adding check for requestLineArray.length() is less or equals to two with added unit test * refactoring, adding test for null value of input stream * refactoring from coderabbit input * refactoring test class. * refactoring, adding debug boolean flag for logger lines * refactoring and testing buffered reader stream * refactoring, changed class HttpParser.java to extend HttpParseRequestLine.java and making them share the same buffered reader. Added method to set the reader and then methods to call the HttpParser * refactoring, changed class HttpParser.java to extend HttpParseRequestLine.java and making them share the same buffered reader. Added method to set the reader and then methods to call the HttpParser --- .../httpparser/HttpParseRequestLine.java | 66 +++++++++++++++++ .../org/example/httpparser/HttpParser.java | 15 +++- .../httpparser/HttpParseRequestLineTest.java | 73 +++++++++++++++++++ .../example/httpparser/HttpParserTest.java | 13 ++-- 4 files changed, 159 insertions(+), 8 deletions(-) create mode 100644 src/main/java/org/example/httpparser/HttpParseRequestLine.java create mode 100644 src/test/java/org/example/httpparser/HttpParseRequestLineTest.java diff --git a/src/main/java/org/example/httpparser/HttpParseRequestLine.java b/src/main/java/org/example/httpparser/HttpParseRequestLine.java new file mode 100644 index 00000000..cb97f838 --- /dev/null +++ b/src/main/java/org/example/httpparser/HttpParseRequestLine.java @@ -0,0 +1,66 @@ +package org.example.httpparser; + +import java.io.BufferedReader; +import java.io.IOException; +import java.util.logging.Logger; + +abstract class HttpParseRequestLine { + private String method; + private String uri; + private String version; + private boolean debug = false; + private static final Logger logger = Logger.getLogger(HttpParseRequestLine.class.getName()); + + public void parseHttpRequest(BufferedReader br) throws IOException { + BufferedReader reader = br; + String requestLine = reader.readLine(); + if (requestLine == null || requestLine.isEmpty()) { + throw new IOException("HTTP Request Line is Null or Empty"); + } + + String[] requestLineArray = requestLine.trim().split(" ", 3); + + if (requestLineArray.length <= 2) { + throw new IOException("HTTP Request Line is not long enough"); + } else { + setMethod(requestLineArray[0]); + if (!getMethod().matches("^[A-Z]+$")){ + throw new IOException("Invalid HTTP method"); + } + setUri(requestLineArray[1]); + setVersion(requestLineArray[2]); + } + + if(debug) { + logger.info(getMethod()); + logger.info(getUri()); + logger.info(getVersion()); + } + } + + + + public String getMethod() { + return method; + } + + private void setMethod(String method) { + this.method = method; + } + + public String getUri() { + return uri; + } + + private void setUri(String uri) { + this.uri = uri; + } + + public String getVersion() { + return version; + } + + private void setVersion(String version) { + this.version = version; + } +} diff --git a/src/main/java/org/example/httpparser/HttpParser.java b/src/main/java/org/example/httpparser/HttpParser.java index 0c27d34a..440d2cff 100644 --- a/src/main/java/org/example/httpparser/HttpParser.java +++ b/src/main/java/org/example/httpparser/HttpParser.java @@ -8,15 +8,18 @@ import java.util.HashMap; import java.util.Map; -public class HttpParser { +public class HttpParser extends HttpParseRequestLine { private boolean debug = false; private Map headersMap = new HashMap<>(); private BufferedReader reader; - public void parseHttp(InputStream in) throws IOException { + + protected void setReader(InputStream in) { if (this.reader == null) { this.reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8)); } + } + public void parseHttp() throws IOException { String headerLine; while ((headerLine = reader.readLine()) != null) { @@ -42,8 +45,16 @@ public void parseHttp(InputStream in) throws IOException { } } + + public void parseRequest() throws IOException { + parseHttpRequest(reader); + } + public Map getHeadersMap() { return headersMap; } + public BufferedReader getHeaderReader() { + return reader; + } } diff --git a/src/test/java/org/example/httpparser/HttpParseRequestLineTest.java b/src/test/java/org/example/httpparser/HttpParseRequestLineTest.java new file mode 100644 index 00000000..8ff289f3 --- /dev/null +++ b/src/test/java/org/example/httpparser/HttpParseRequestLineTest.java @@ -0,0 +1,73 @@ +package org.example.httpparser; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.*; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; + +class HttpParseRequestLineTest { + private HttpParser httpParseRequestLine; + + @BeforeEach + void setUp() { + httpParseRequestLine = new HttpParser(); + } + + @Test + void testParserWithTestRequestLine() throws IOException { + String testString = "GET / HTTP/1.1"; + + InputStream in = new ByteArrayInputStream(testString.getBytes()); + httpParseRequestLine.setReader(in); + httpParseRequestLine.parseRequest(); + + assertThat(httpParseRequestLine.getMethod()).isEqualTo("GET"); + assertThat(httpParseRequestLine.getUri()).isEqualTo("/"); + assertThat(httpParseRequestLine.getVersion()).isEqualTo("HTTP/1.1"); + } + + @Test + void testParserThrowErrorWhenNull(){ + assertThatThrownBy(() -> httpParseRequestLine.setReader(null)).isInstanceOf(NullPointerException.class); + } + + + @Test + void testParserThrowErrorWhenEmpty(){ + InputStream in = new ByteArrayInputStream("".getBytes()); + httpParseRequestLine.setReader(in); + Exception exception = assertThrows( + IOException.class, () -> httpParseRequestLine.parseRequest() + ); + + assertThat(exception.getMessage()).isEqualTo("HTTP Request Line is Null or Empty"); + } + + @Test + void testParserThrowErrorWhenMethodIsInvalid(){ + String testString = "get / HTTP/1.1"; + InputStream in = new ByteArrayInputStream(testString.getBytes()); + httpParseRequestLine.setReader(in); + Exception exception = assertThrows( + IOException.class, () -> httpParseRequestLine.parseRequest() + ); + assertThat(exception.getMessage()).isEqualTo("Invalid HTTP method"); + } + + @Test + void testParserThrowErrorWhenArrayLengthLessOrEqualsTwo(){ + String testString = "GET / "; + InputStream in = new ByteArrayInputStream(testString.getBytes()); + httpParseRequestLine.setReader(in); + Exception exception = assertThrows( + IOException.class, () -> httpParseRequestLine.parseRequest() + ); + + assertThat(exception.getMessage()).isEqualTo("HTTP Request Line is not long enough"); + } + +} diff --git a/src/test/java/org/example/httpparser/HttpParserTest.java b/src/test/java/org/example/httpparser/HttpParserTest.java index 91a5af38..a09b7e22 100644 --- a/src/test/java/org/example/httpparser/HttpParserTest.java +++ b/src/test/java/org/example/httpparser/HttpParserTest.java @@ -2,9 +2,7 @@ import org.junit.jupiter.api.Test; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStream; +import java.io.*; import java.nio.charset.StandardCharsets; @@ -19,7 +17,8 @@ void TestHttpParserForHeaders() throws IOException { String testInput = "GET /index.html HTTP/1.1\r\nHost: localhost\r\nContent-Type: text/plain\r\nUser-Agent: JUnit5\r\n\r\n"; InputStream in = new ByteArrayInputStream(testInput.getBytes(StandardCharsets.UTF_8)); - httpParser.parseHttp(in); + httpParser.setReader(in); + httpParser.parseHttp(); assertNotNull(httpParser.getHeadersMap()); assertThat(httpParser.getHeadersMap().size()).isEqualTo(3); @@ -31,7 +30,8 @@ void TestHttpParserForHeaders() throws IOException { @Test void testParseHttp_EmptyInput() throws IOException { InputStream in = new ByteArrayInputStream("".getBytes()); - httpParser.parseHttp(in); + httpParser.setReader(in); + httpParser.parseHttp(); assertTrue(httpParser.getHeadersMap().isEmpty()); } @@ -41,7 +41,8 @@ void testParseHttp_InvalidHeaderLine() throws IOException { String rawInput = "Host: localhost\r\n InvalidLineWithoutColon\r\n Accept: */*\r\n\r\n"; InputStream in = new ByteArrayInputStream(rawInput.getBytes(StandardCharsets.UTF_8)); - httpParser.parseHttp(in); + httpParser.setReader(in); + httpParser.parseHttp(); assertEquals(2, httpParser.getHeadersMap().size()); assertEquals("localhost", httpParser.getHeadersMap().get("Host")); From 781e34a5de2f4737a8a0234f1de4b622f0700579 Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Wed, 11 Feb 2026 15:24:26 +0100 Subject: [PATCH 09/67] Add Bucket4j dependency in pom file (#40) * Add Bucket4j dependency in pom file * Extract Bucket4j version into a property and update version in dependency for consistency --- pom.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pom.xml b/pom.xml index bb03ce76..06adea9e 100644 --- a/pom.xml +++ b/pom.xml @@ -14,9 +14,16 @@ 6.0.2 3.27.7 5.21.0 + 8.14.0 + + + com.bucket4j + bucket4j_jdk17-core + ${bucket4j.version} + org.junit.jupiter junit-jupiter From 511b5eef9de4c3b49af24ecb79272142d3dd8a25 Mon Sep 17 00:00:00 2001 From: Caroline Nordbrandt Date: Thu, 12 Feb 2026 10:57:11 +0100 Subject: [PATCH 10/67] Add support for serving static files (#42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add support for serving static files - Introduced `StaticFileHandler` to serve static files from the `www` directory. - Enhanced `HttpResponseBuilder` to support byte array bodies. - Made `setReader` method public in `HttpParser` to improve flexibility. - Code tested via Insomnia by coding a temporary method for multithreads in TcpServer.java, this is now removed before future implementations of ConnectionHandler Co-authored-by: Daniel Fahlén xsz300@gmail.com * Fix typo in `HttpResponseBuilder` (contentLength) and refine body handling logic Co-authored-by: Daniel Fahlén xsz300@gmail.com * Add basic HTML structure to `index.html` in `www` directory * Make `handleGetRequest` method private in `StaticFileHandler` to encapsulate functionality --- .../java/org/example/StaticFileHandler.java | 34 +++++++++++++++++++ .../org/example/http/HttpResponseBuilder.java | 18 ++++++++-- .../org/example/httpparser/HttpParser.java | 2 +- www/index.html | 12 +++++++ 4 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 src/main/java/org/example/StaticFileHandler.java create mode 100644 www/index.html diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java new file mode 100644 index 00000000..56efc1ed --- /dev/null +++ b/src/main/java/org/example/StaticFileHandler.java @@ -0,0 +1,34 @@ +package org.example; + +import org.example.http.HttpResponseBuilder; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintWriter; +import java.nio.file.Files; +import java.util.Map; + +public class StaticFileHandler { + private static final String WEB_ROOT = "www"; + private byte[] fileBytes; + + public StaticFileHandler(){} + + private void handleGetRequest(String uri) throws IOException { + + File file = new File(WEB_ROOT, uri); + fileBytes = Files.readAllBytes(file.toPath()); + } + + public void sendGetRequest(OutputStream outputStream, String uri) throws IOException{ + handleGetRequest(uri); + HttpResponseBuilder response = new HttpResponseBuilder(); + response.setHeaders(Map.of("Content-Type", "text/html; charset=utf-8")); + response.setBody(fileBytes); + PrintWriter writer = new PrintWriter(outputStream, true); + writer.println(response.build()); + + } + +} diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java index eb1f77ea..afaafcf9 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -9,6 +9,7 @@ public class HttpResponseBuilder { private static final String PROTOCOL = "HTTP/1.1"; private int statusCode = 200; private String body = ""; + private byte[] bytebody; private Map headers = new LinkedHashMap<>(); private static final String CRLF = "\r\n"; @@ -17,10 +18,13 @@ public class HttpResponseBuilder { public void setStatusCode(int statusCode) { this.statusCode = statusCode; } - public void setBody(String body) { this.body = body != null ? body : ""; } + + public void setBody(byte[] body) { + this.bytebody = body; + } public void setHeaders(Map headers) { this.headers = new LinkedHashMap<>(headers); } @@ -31,14 +35,22 @@ public void setHeaders(Map headers) { 400, "Bad Request", 404, "Not Found", 500, "Internal Server Error"); - public String build(){ StringBuilder sb = new StringBuilder(); + int contentLength; + if(body.isEmpty() && bytebody != null){ + contentLength = bytebody.length; + setBody(new String(bytebody, StandardCharsets.UTF_8)); + }else{ + contentLength = body.getBytes(StandardCharsets.UTF_8).length; + } + + String reason = REASON_PHRASES.getOrDefault(statusCode, "OK"); sb.append(PROTOCOL).append(" ").append(statusCode).append(" ").append(reason).append(CRLF); headers.forEach((k,v) -> sb.append(k).append(": ").append(v).append(CRLF)); sb.append("Content-Length: ") - .append(body.getBytes(StandardCharsets.UTF_8).length); + .append(contentLength); sb.append(CRLF); sb.append("Connection: close").append(CRLF); sb.append(CRLF); diff --git a/src/main/java/org/example/httpparser/HttpParser.java b/src/main/java/org/example/httpparser/HttpParser.java index 440d2cff..d08a000b 100644 --- a/src/main/java/org/example/httpparser/HttpParser.java +++ b/src/main/java/org/example/httpparser/HttpParser.java @@ -13,7 +13,7 @@ public class HttpParser extends HttpParseRequestLine { private Map headersMap = new HashMap<>(); private BufferedReader reader; - protected void setReader(InputStream in) { + public void setReader(InputStream in) { if (this.reader == null) { this.reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8)); } diff --git a/www/index.html b/www/index.html new file mode 100644 index 00000000..8293c074 --- /dev/null +++ b/www/index.html @@ -0,0 +1,12 @@ + + + + + Welcome + + +

Website works!

+

Greetings from StaticFileHandler.

+ + + From aaeba6dcad1f725ce89fe5d8b39908b8e1949e63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Mohl=C3=A9n?= Date: Thu, 12 Feb 2026 13:13:01 +0100 Subject: [PATCH 11/67] Updates pom.xml, with jackson-dependencies, for config file (#48) * Updates pom.xml, with jackson-dependencies, for config file * Updates pom.xml, removes jackson-annotations:2.20, because it was apparently unnecessary. --- pom.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pom.xml b/pom.xml index 06adea9e..8a82b235 100644 --- a/pom.xml +++ b/pom.xml @@ -54,6 +54,18 @@ 4.3.0 test
+ + + tools.jackson.core + jackson-databind + 3.0.3 + + + tools.jackson.dataformat + jackson-dataformat-yaml + 3.0.3 + +
From 524f33c821d74407124fe4ab09df3ac199666f33 Mon Sep 17 00:00:00 2001 From: Daniel E Fahlen <229072997+donne41@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:24:09 +0100 Subject: [PATCH 12/67] * Move HTTP handling to a dedicated ConnectionHandler (#50) * * Move HTTP handling logic from TcpServer to a dedicated ConnectionTask class * Implement virtual thread execution for better scalability * Add regex-based URI routing to support clean URLs and default to index.html * Ensure sockets are properly closed Co-authored-by: Caroline Nordbrandt * change thrown exception to runtime instead with appropiet message in tcpServer. --------- Co-authored-by: Caroline Nordbrandt --- .../java/org/example/ConnectionHandler.java | 42 +++++++++++++++++++ .../java/org/example/StaticFileHandler.java | 1 + src/main/java/org/example/TcpServer.java | 12 +++++- 3 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/example/ConnectionHandler.java diff --git a/src/main/java/org/example/ConnectionHandler.java b/src/main/java/org/example/ConnectionHandler.java new file mode 100644 index 00000000..1a0861ff --- /dev/null +++ b/src/main/java/org/example/ConnectionHandler.java @@ -0,0 +1,42 @@ +package org.example; + +import org.example.httpparser.HttpParser; + +import java.io.IOException; +import java.net.Socket; + +public class ConnectionHandler implements AutoCloseable { + + Socket client; + String uri; + + public ConnectionHandler(Socket client) { + this.client = client; + } + + public void runConnectionHandler() throws IOException { + StaticFileHandler sfh = new StaticFileHandler(); + HttpParser parser = new HttpParser(); + parser.setReader(client.getInputStream()); + parser.parseRequest(); + parser.parseHttp(); + resolveTargetFile(parser.getUri()); + sfh.sendGetRequest(client.getOutputStream(), uri); + } + + private void resolveTargetFile(String uri) { + if (uri.matches("/$")) { //matches(/) + this.uri = "index.html"; + } else if (uri.matches("^(?!.*\\.html$).*$")) { + this.uri = uri.concat(".html"); + } else { + this.uri = uri; + } + + } + + @Override + public void close() throws Exception { + client.close(); + } +} diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 56efc1ed..95a0b424 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -19,6 +19,7 @@ private void handleGetRequest(String uri) throws IOException { File file = new File(WEB_ROOT, uri); fileBytes = Files.readAllBytes(file.toPath()); + } public void sendGetRequest(OutputStream outputStream, String uri) throws IOException{ diff --git a/src/main/java/org/example/TcpServer.java b/src/main/java/org/example/TcpServer.java index 73ba0f27..3f96b4d8 100644 --- a/src/main/java/org/example/TcpServer.java +++ b/src/main/java/org/example/TcpServer.java @@ -19,10 +19,18 @@ public void start() { while (true) { Socket clientSocket = serverSocket.accept(); // block System.out.println("Client connected: " + clientSocket.getRemoteSocketAddress()); - clientSocket.close(); + Thread.ofVirtual().start(() -> handleClient(clientSocket)); } } catch (IOException e) { throw new RuntimeException("Failed to start TCP server", e); } } -} \ No newline at end of file + + private void handleClient(Socket client) { + try (ConnectionHandler connectionHandler = new ConnectionHandler(client)) { + connectionHandler.runConnectionHandler(); + } catch (Exception e) { + throw new RuntimeException("Error handling client connection " + e); + } + } +} From b9600f684b6f303fc438b1fa69f552a7199c2eb3 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Tue, 17 Feb 2026 14:11:35 +0100 Subject: [PATCH 13/67] =?UTF-8?q?ska=20vara=20klart=20nu=20har=20gl=C3=B6m?= =?UTF-8?q?t=20att=20commit=20jobbet=20inser=20jag=20dock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/example/FileCache.java | 28 ++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/main/java/org/example/FileCache.java diff --git a/src/main/java/org/example/FileCache.java b/src/main/java/org/example/FileCache.java new file mode 100644 index 00000000..5783ce4d --- /dev/null +++ b/src/main/java/org/example/FileCache.java @@ -0,0 +1,28 @@ +package org.example; + +import java.util.HashMap; +import java.util.Map; + +public class FileCache { + private final Map cache = new HashMap<>(); + + public boolean contains (String key) { + return cache.containsKey(key); + } + + public byte[] get(String key) { + return cache.get(key); + } + + public void put(String key, byte[] value){ + cache.put(key, value); + } + public void clear() { + cache.clear(); + } + + public int size() { + return cache.size(); + } + +} From 07e47bfe4b00b1d7f03c0f443687be07542c4bf9 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Tue, 17 Feb 2026 14:12:17 +0100 Subject: [PATCH 14/67] har lagt till i line 20-25 samt line 29 --- src/main/java/org/example/StaticFileHandler.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 95a0b424..d58b9fce 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -12,14 +12,21 @@ public class StaticFileHandler { private static final String WEB_ROOT = "www"; private byte[] fileBytes; + private final FileCache cache = new FileCache(); public StaticFileHandler(){} private void handleGetRequest(String uri) throws IOException { - + if (cache.contains(uri)) { + System.out.println("✓ Cache hit for: " + uri); + fileBytes = cache.get(uri); + return; + } + System.out.println("✗ Cache miss for: " + uri); File file = new File(WEB_ROOT, uri); fileBytes = Files.readAllBytes(file.toPath()); + cache.put(uri, fileBytes); } public void sendGetRequest(OutputStream outputStream, String uri) throws IOException{ From 8cc69d8c31686e82d74c71925a34aba8f787c0b6 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Tue, 17 Feb 2026 14:12:27 +0100 Subject: [PATCH 15/67] Feature/13 implement config file (#22) * Added basic YAML config-file. * Added class ConfigLoader with static classes for encapsulation * Added static metod loadOnce and step one of static method load * Added static method createMapperFor that checks for YAML or JSON-files before creating an ObjectMapper object. * implement ConfigLoader refs #13 * Added AppConfig.java record for config after coderabbit feedback * Updated ConfigLoader to use AppConfig record and jackson 3 * Added tests for ConfigLoader and reset cached method in ConfigLoader to ensure test isolation with static cache * Removed unused dependency. Minor readability tweaks in AppConfig. * Added check for illegal port numbers to withDefaultsApplied-method. * Added test for illegal port numbers. --- .../java/org/example/config/AppConfig.java | 53 +++++++ .../java/org/example/config/ConfigLoader.java | 71 +++++++++ src/main/resources/application.yml | 6 + .../org/example/config/ConfigLoaderTest.java | 141 ++++++++++++++++++ 4 files changed, 271 insertions(+) create mode 100644 src/main/java/org/example/config/AppConfig.java create mode 100644 src/main/java/org/example/config/ConfigLoader.java create mode 100644 src/main/resources/application.yml create mode 100644 src/test/java/org/example/config/ConfigLoaderTest.java diff --git a/src/main/java/org/example/config/AppConfig.java b/src/main/java/org/example/config/AppConfig.java new file mode 100644 index 00000000..00134b20 --- /dev/null +++ b/src/main/java/org/example/config/AppConfig.java @@ -0,0 +1,53 @@ +package org.example.config; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record AppConfig( + @JsonProperty("server") ServerConfig server, + @JsonProperty("logging") LoggingConfig logging +) { + public static AppConfig defaults() { + return new AppConfig(ServerConfig.defaults(), LoggingConfig.defaults()); + } + + public AppConfig withDefaultsApplied() { + ServerConfig serverConfig = (server == null ? ServerConfig.defaults() : server.withDefaultsApplied()); + LoggingConfig loggingConfig = (logging == null ? LoggingConfig.defaults() : logging.withDefaultsApplied()); + return new AppConfig(serverConfig, loggingConfig); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ServerConfig( + @JsonProperty("port") Integer port, + @JsonProperty("rootDir") String rootDir + ) { + public static ServerConfig defaults() { + return new ServerConfig(8080, "./www"); + } + + public ServerConfig withDefaultsApplied() { + int p = (port == null ? 8080 : port); + if (p < 1 || p > 65535) { + throw new IllegalArgumentException("Invalid port number: " + p + ". Port must be between 1 and 65535"); + } + String rd = (rootDir == null || rootDir.isBlank()) ? "./www" : rootDir; + return new ServerConfig(p, rd); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record LoggingConfig( + @JsonProperty("level") String level + ) { + public static LoggingConfig defaults() { + return new LoggingConfig("INFO"); + } + + public LoggingConfig withDefaultsApplied() { + String lvl = (level == null || level.isBlank()) ? "INFO" : level; + return new LoggingConfig(lvl); + } + } +} diff --git a/src/main/java/org/example/config/ConfigLoader.java b/src/main/java/org/example/config/ConfigLoader.java new file mode 100644 index 00000000..e69f784d --- /dev/null +++ b/src/main/java/org/example/config/ConfigLoader.java @@ -0,0 +1,71 @@ +package org.example.config; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.dataformat.yaml.YAMLFactory; +import tools.jackson.dataformat.yaml.YAMLMapper; + +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; + +public final class ConfigLoader { + + private static volatile AppConfig cached; + + private ConfigLoader() {} + + public static AppConfig loadOnce(Path configPath) { + if (cached != null) return cached; + + synchronized (ConfigLoader.class) { + if (cached == null){ + cached = load(configPath).withDefaultsApplied(); + } + return cached; + } + } + + public static AppConfig get(){ + if (cached == null){ + throw new IllegalStateException("Config not loaded. call ConfigLoader.loadOnce(...) at startup."); + } + return cached; + + } + + public static AppConfig load(Path configPath) { + Objects.requireNonNull(configPath, "configPath"); + + if (!Files.exists(configPath)) { + return AppConfig.defaults(); + } + + ObjectMapper objectMapper = createMapperFor(configPath); + + try (InputStream stream = Files.newInputStream(configPath)){ + AppConfig config = objectMapper.readValue(stream, AppConfig.class); + return config == null ? AppConfig.defaults() : config; + } catch (Exception e){ + throw new IllegalStateException("failed to read config file " + configPath.toAbsolutePath(), e); + } + } + + private static ObjectMapper createMapperFor(Path configPath) { + String name = configPath.getFileName().toString().toLowerCase(); + + if (name.endsWith(".yml") || name.endsWith(".yaml")) { + return YAMLMapper.builder(new YAMLFactory()).build(); + + } else if (name.endsWith(".json")) { + return JsonMapper.builder().build(); + } else { + return YAMLMapper.builder(new YAMLFactory()).build(); + } + } + + static void resetForTests() { + cached = null; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 00000000..8f2ef3a7 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,6 @@ +server: + port: 8080 + rootDir: ./www + +logging: + level: INFO \ No newline at end of file diff --git a/src/test/java/org/example/config/ConfigLoaderTest.java b/src/test/java/org/example/config/ConfigLoaderTest.java new file mode 100644 index 00000000..b694a7af --- /dev/null +++ b/src/test/java/org/example/config/ConfigLoaderTest.java @@ -0,0 +1,141 @@ +package org.example.config; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.*; + +class ConfigLoaderTest { + + @TempDir + Path tempDir; + + @BeforeEach + void reset() { + ConfigLoader.resetForTests(); + } + + @Test + @DisplayName("Should return default configuration when config file is missing") + void load_returns_defaults_when_file_missing() { + Path missing = tempDir.resolve("missing.yml"); + + AppConfig appConfig = ConfigLoader.load(missing).withDefaultsApplied(); + + assertThat(appConfig.server().port()).isEqualTo(8080); + assertThat(appConfig.server().rootDir()).isEqualTo("./www"); + assertThat(appConfig.logging().level()).isEqualTo("INFO"); + } + + @Test + @DisplayName("Should load values from YAML file when file exists") + void loadOnce_reads_yaml_values() throws Exception { + Path configFile = tempDir.resolve("application.yml"); + Files.writeString(configFile, """ + server: + port: 9090 + rootDir: ./public + logging: + level: DEBUG + """); + + AppConfig appConfig = ConfigLoader.loadOnce(configFile); + + assertThat(appConfig.server().port()).isEqualTo(9090); + assertThat(appConfig.server().rootDir()).isEqualTo("./public"); + assertThat(appConfig.logging().level()).isEqualTo("DEBUG"); + } + + @Test + @DisplayName("Should apply default values when sections or fields are missing") + void defaults_applied_when_sections_or_fields_missing() throws Exception { + Path configFile = tempDir.resolve("application.yml"); + Files.writeString(configFile, """ + server: + port: 1234 + """); + + AppConfig cfg = ConfigLoader.loadOnce(configFile); + + assertThat(cfg.server().port()).isEqualTo(1234); + assertThat(cfg.server().rootDir()).isEqualTo("./www"); // default + assertThat(cfg.logging().level()).isEqualTo("INFO"); // default + } + + @Test + @DisplayName("Should ignore unknown fields in configuration file") + void unknown_fields_are_ignored() throws Exception { + Path configFile = tempDir.resolve("application.yml"); + Files.writeString(configFile, """ + server: + port: 8081 + rootDir: ./www + threads: 8 + logging: + level: INFO + json: true + """); + + AppConfig cfg = ConfigLoader.loadOnce(configFile); + + assertThat(cfg.server().port()).isEqualTo(8081); + assertThat(cfg.server().rootDir()).isEqualTo("./www"); + assertThat(cfg.logging().level()).isEqualTo("INFO"); + } + + @Test + @DisplayName("Should return same instance on repeated loadOnce calls") + void loadOnce_caches_same_instance() throws Exception { + Path configFile = tempDir.resolve("application.yml"); + Files.writeString(configFile, """ + server: + port: 8080 + rootDir: ./www + logging: + level: INFO + """); + + AppConfig a = ConfigLoader.loadOnce(configFile); + AppConfig b = ConfigLoader.loadOnce(configFile); + + assertThat(a).isSameAs(b); + } + + @Test + @DisplayName("Should throw exception when get is called before configuration is loaded") + void get_throws_if_not_loaded() { + assertThatThrownBy(ConfigLoader::get) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("not loaded"); + } + + @Test + @DisplayName("Should fail when configuration file is invalid") + void invalid_yaml_fails() throws Exception { + Path configFile = tempDir.resolve("broken.yml"); + Files.writeString(configFile, "server:\n port 8080\n"); // saknar ':' efter port + + assertThatThrownBy(() -> ConfigLoader.load(configFile)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("failed to read config file"); + } + + @Test + @DisplayName("Should fail when port is out of range") + void invalid_port_should_Throw_Exception () throws Exception { + Path configFile = tempDir.resolve("application.yml"); + + Files.writeString(configFile, """ + server: + port: 70000 + """); + + assertThatThrownBy(() -> ConfigLoader.loadOnce(configFile)) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Invalid port number"); + } +} From 8e0ab5096ab4f6fdc47480502e8f2890000c95d5 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Tue, 17 Feb 2026 14:19:27 +0100 Subject: [PATCH 16/67] =?UTF-8?q?tester=20f=C3=B6r=20FileCacheTest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/java/org/example/FileCacheTest.java | 117 +++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 src/test/java/org/example/FileCacheTest.java diff --git a/src/test/java/org/example/FileCacheTest.java b/src/test/java/org/example/FileCacheTest.java new file mode 100644 index 00000000..0a825aee --- /dev/null +++ b/src/test/java/org/example/FileCacheTest.java @@ -0,0 +1,117 @@ +package org.example; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class FileCacheTest { + private FileCache cache; + + @BeforeEach + void setUp() { + cache = new FileCache(); + } + + @Test + void testPutAndGet() { + byte[] fileContent = "Hello World".getBytes(); + cache.put("test.html", fileContent); + + byte[] retrieved = cache.get("test.html"); + assertThat(retrieved).isEqualTo(fileContent); + } + + @Test + void testContainsReturnsTrueAfterPut() { + byte[] fileContent = "test content".getBytes(); + cache.put("file.txt", fileContent); + + assertThat(cache.contains("file.txt")).isTrue(); + } + + @Test + void testContainsReturnsFalseForNonExistentKey() { + assertThat(cache.contains("nonexistent.html")).isFalse(); + } + + @Test + void testGetReturnsNullForNonExistentKey() { + byte[] result = cache.get("missing.txt"); + assertThat(result).isNull(); + } + + @Test + void testMultipleFiles() { + byte[] content1 = "Content 1".getBytes(); + byte[] content2 = "Content 2".getBytes(); + byte[] content3 = "Content 3".getBytes(); + + cache.put("file1.html", content1); + cache.put("file2.css", content2); + cache.put("file3.js", content3); + + assertThat(cache.get("file1.html")).isEqualTo(content1); + assertThat(cache.get("file2.css")).isEqualTo(content2); + assertThat(cache.get("file3.js")).isEqualTo(content3); + } + + @Test + void testClear() { + cache.put("file1.html", "content1".getBytes()); + cache.put("file2.html", "content2".getBytes()); + assertThat(cache.size()).isEqualTo(2); + + cache.clear(); + + assertThat(cache.size()).isEqualTo(0); + assertThat(cache.contains("file1.html")).isFalse(); + assertThat(cache.contains("file2.html")).isFalse(); + } + + @Test + void testSize() { + assertThat(cache.size()).isEqualTo(0); + + cache.put("file1.html", "content".getBytes()); + assertThat(cache.size()).isEqualTo(1); + + cache.put("file2.html", "content".getBytes()); + assertThat(cache.size()).isEqualTo(2); + + cache.put("file3.html", "content".getBytes()); + assertThat(cache.size()).isEqualTo(3); + } + + @Test + void testOverwriteExistingKey() { + byte[] oldContent = "old".getBytes(); + byte[] newContent = "new".getBytes(); + + cache.put("file.html", oldContent); + assertThat(cache.get("file.html")).isEqualTo(oldContent); + + cache.put("file.html", newContent); + assertThat(cache.get("file.html")).isEqualTo(newContent); + assertThat(cache.size()).isEqualTo(1); + } + + @Test + void testLargeFileContent() { + byte[] largeContent = new byte[10000]; + for (int i = 0; i < largeContent.length; i++) { + largeContent[i] = (byte) (i % 256); + } + + cache.put("large.bin", largeContent); + assertThat(cache.get("large.bin")).isEqualTo(largeContent); + } + + @Test + void testEmptyByteArray() { + byte[] emptyContent = new byte[0]; + cache.put("empty.txt", emptyContent); + + assertThat(cache.contains("empty.txt")).isTrue(); + assertThat(cache.get("empty.txt")).isEmpty(); + } +} From c0e3de64d6ddf41604dd12bca529b891f63a6eef Mon Sep 17 00:00:00 2001 From: Caroline Nordbrandt Date: Tue, 17 Feb 2026 14:23:09 +0100 Subject: [PATCH 17/67] Enhancement/404 page not found (#53) * Add 404 error handling to `StaticFileHandler` with fallback page * Add test coverage for `StaticFileHandler` and improve constructor flexibility - Introduced a new constructor in `StaticFileHandler` to support custom web root paths, improving testability. - Added `StaticFileHandlerTest` to validate static file serving and error handling logic. * Add test for 404 handling in `StaticFileHandler` - Added a test to ensure nonexistent files return a 404 response. - Utilized a temporary directory and fallback file for improved test isolation. * Verify `Content-Type` header in `StaticFileHandlerTest` after running Pittest, mutations survived where setHeaders could be removed without test failure. * Remove unused `.btn` styles from `pageNotFound.html` * Improve 404 handling in `StaticFileHandler`: add fallback to plain text response if `pageNotFound.html` is missing, and fix typo in `pageNotFound.html`, after comments from CodeRabbit. --- .../java/org/example/StaticFileHandler.java | 29 ++++++- .../org/example/StaticFileHandlerTest.java | 79 +++++++++++++++++++ www/pageNotFound.html | 55 +++++++++++++ 3 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 src/test/java/org/example/StaticFileHandlerTest.java create mode 100644 www/pageNotFound.html diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 95a0b424..bf9e79bc 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -10,21 +10,42 @@ import java.util.Map; public class StaticFileHandler { - private static final String WEB_ROOT = "www"; + private final String WEB_ROOT; private byte[] fileBytes; + private int statusCode; - public StaticFileHandler(){} + //Constructor for production + public StaticFileHandler() { + WEB_ROOT = "www"; + } + + //Constructor for tests, otherwise the www folder won't be seen + public StaticFileHandler(String webRoot){ + WEB_ROOT = webRoot; + } private void handleGetRequest(String uri) throws IOException { File file = new File(WEB_ROOT, uri); - fileBytes = Files.readAllBytes(file.toPath()); - + if(file.exists()) { + fileBytes = Files.readAllBytes(file.toPath()); + statusCode = 200; + } else { + File errorFile = new File(WEB_ROOT, "pageNotFound.html"); + if(errorFile.exists()) { + fileBytes = Files.readAllBytes(errorFile.toPath()); + } else { + fileBytes = "404 Not Found".getBytes(); + } + statusCode = 404; + } } public void sendGetRequest(OutputStream outputStream, String uri) throws IOException{ handleGetRequest(uri); + HttpResponseBuilder response = new HttpResponseBuilder(); + response.setStatusCode(statusCode); response.setHeaders(Map.of("Content-Type", "text/html; charset=utf-8")); response.setBody(fileBytes); PrintWriter writer = new PrintWriter(outputStream, true); diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java new file mode 100644 index 00000000..871fdb46 --- /dev/null +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -0,0 +1,79 @@ +package org.example; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit test class for verifying the behavior of the StaticFileHandler class. + * + * This test class ensures that StaticFileHandler correctly handles GET requests + * for static files, including both cases where the requested file exists and + * where it does not. Temporary directories and files are utilized in tests to + * ensure no actual file system dependencies during test execution. + * + * Key functional aspects being tested include: + * - Correct response status code and content for an existing file. + * - Correct response status code and fallback behavior for a missing file. + */ +class StaticFileHandlerTest { + + //Junit creates a temporary folder which can be filled with temporary files that gets removed after tests + @TempDir + Path tempDir; + + + @Test + void test_file_that_exists_should_return_200() throws IOException { + //Arrange + Path testFile = tempDir.resolve("test.html"); // Defines the path in the temp directory + Files.writeString(testFile, "Hello Test"); // Creates a text in that file + + //Using the new constructor in StaticFileHandler to reroute so the tests uses the temporary folder instead of the hardcoded www + StaticFileHandler staticFileHandler = new StaticFileHandler(tempDir.toString()); + + //Using ByteArrayOutputStream instead of Outputstream during tests to capture the servers response in memory, fake stream + ByteArrayOutputStream fakeOutput = new ByteArrayOutputStream(); + + //Act + staticFileHandler.sendGetRequest(fakeOutput, "test.html"); //Get test.html and write the answer to fakeOutput + + //Assert + String response = fakeOutput.toString();//Converts the captured byte stream into a String for verification + + assertTrue(response.contains("HTTP/1.1 200 OK")); // Assert the status + assertTrue(response.contains("Hello Test")); //Assert the content in the file + + assertTrue(response.contains("Content-Type: text/html; charset=utf-8")); // Verify the correct Content-type header + + } + + @Test + void test_file_that_does_not_exists_should_return_404() throws IOException { + //Arrange + // Pre-create the mandatory error page in the temp directory to prevent NoSuchFileException + Path testFile = tempDir.resolve("pageNotFound.html"); + Files.writeString(testFile, "Fallback page"); + + //Using the new constructor in StaticFileHandler to reroute so the tests uses the temporary folder instead of the hardcoded www + StaticFileHandler staticFileHandler = new StaticFileHandler(tempDir.toString()); + + //Using ByteArrayOutputStream instead of Outputstream during tests to capture the servers response in memory, fake stream + ByteArrayOutputStream fakeOutput = new ByteArrayOutputStream(); + + //Act + staticFileHandler.sendGetRequest(fakeOutput, "notExistingFile.html"); // Request a file that clearly doesn't exist to trigger the 404 logic + + //Assert + String response = fakeOutput.toString();//Converts the captured byte stream into a String for verification + + assertTrue(response.contains("HTTP/1.1 404 Not Found")); // Assert the status + + } + +} diff --git a/www/pageNotFound.html b/www/pageNotFound.html new file mode 100644 index 00000000..b02f57aa --- /dev/null +++ b/www/pageNotFound.html @@ -0,0 +1,55 @@ + + + + + + 404 - Page Not Found + + + +
+
🚀
+

404

+

Woopsie daisy! Page went to the moon.

+

We cannot find the page you were looking for. Might have been moved, removed, or maybe it was kind of a useless link to begin with.

+
+ + \ No newline at end of file From bcb828c72571870ee642647ea8a8b76c5e59443c Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Wed, 18 Feb 2026 10:41:30 +0100 Subject: [PATCH 18/67] Feature/issue59 run configloader (#61) * Implements configuration loading and ensures that ConfigLoader is invoked during application startup (App.main). * Minor formating. --- src/main/java/org/example/App.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/example/App.java b/src/main/java/org/example/App.java index 66c9af10..408a1942 100644 --- a/src/main/java/org/example/App.java +++ b/src/main/java/org/example/App.java @@ -1,7 +1,16 @@ package org.example; +import org.example.config.AppConfig; +import org.example.config.ConfigLoader; + +import java.nio.file.Path; + public class App { public static void main(String[] args) { - new TcpServer(8080).start(); + Path configPath = Path.of("src/main/resources/application.yml"); + + AppConfig appConfig = ConfigLoader.loadOnce(configPath); + int port = appConfig.server().port(); + new TcpServer(port).start(); } -} \ No newline at end of file +} From 945d32b8c4fd2456f33ed2510d61ee34932aaf53 Mon Sep 17 00:00:00 2001 From: Eric Phu Date: Wed, 18 Feb 2026 17:25:37 +0100 Subject: [PATCH 19/67] 23 define and create filter interface (#46) * initial commit, added interfaces Filter and FilterChain * Added HttpRequest class, groups together all information about a request that the server needs and easier to handle by future filters * added interfaces Filter and FilterChain with Java Servlet Filter architecture. * added FilterChainImpl * Corrected imports from JDKS HttpRequest, to projects HttpRequest class * Changed, params for FilterChain * Updated HttpRequest with attributes, * Revert "Updated HttpRequest with attributes," This reverts commit 0fd490e83e335ceaef61b2acb22aa61d9e810f91. --- src/main/java/org/example/filter/Filter.java | 14 +++++++ .../java/org/example/filter/FilterChain.java | 10 +++++ .../org/example/filter/FilterChainImpl.java | 33 +++++++++++++++ .../org/example/httpparser/HttpRequest.java | 41 +++++++++++++++++++ 4 files changed, 98 insertions(+) create mode 100644 src/main/java/org/example/filter/Filter.java create mode 100644 src/main/java/org/example/filter/FilterChain.java create mode 100644 src/main/java/org/example/filter/FilterChainImpl.java create mode 100644 src/main/java/org/example/httpparser/HttpRequest.java diff --git a/src/main/java/org/example/filter/Filter.java b/src/main/java/org/example/filter/Filter.java new file mode 100644 index 00000000..5bd4eb1c --- /dev/null +++ b/src/main/java/org/example/filter/Filter.java @@ -0,0 +1,14 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; + +import org.example.httpparser.HttpRequest; + +public interface Filter { + void init(); + + void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain); + + void destroy(); + +} diff --git a/src/main/java/org/example/filter/FilterChain.java b/src/main/java/org/example/filter/FilterChain.java new file mode 100644 index 00000000..942da453 --- /dev/null +++ b/src/main/java/org/example/filter/FilterChain.java @@ -0,0 +1,10 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; + + +public interface FilterChain { + + void doFilter(HttpRequest request, HttpResponseBuilder response); +} diff --git a/src/main/java/org/example/filter/FilterChainImpl.java b/src/main/java/org/example/filter/FilterChainImpl.java new file mode 100644 index 00000000..b6c4509f --- /dev/null +++ b/src/main/java/org/example/filter/FilterChainImpl.java @@ -0,0 +1,33 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; + + +import java.util.List; + +/* +* The default class of FilterChain, +* Contains a list of filters. For each of the filter, will execute the doFilter method. +* + */ + +public class FilterChainImpl implements FilterChain { + + private final List filters; + private int index = 0; + + public FilterChainImpl(List filters) { + this.filters = filters; + } + + @Override + public void doFilter(HttpRequest request, HttpResponseBuilder response) { + if (index < filters.size()) { + Filter next = filters.get(index++); + next.doFilter(request, response, this); + } else { + // TODO: when no more filters, should execute the request + } + } +} diff --git a/src/main/java/org/example/httpparser/HttpRequest.java b/src/main/java/org/example/httpparser/HttpRequest.java new file mode 100644 index 00000000..ad65d496 --- /dev/null +++ b/src/main/java/org/example/httpparser/HttpRequest.java @@ -0,0 +1,41 @@ +package org.example.httpparser; + +import java.util.Collections; +import java.util.Map; + +/* +* +*This class groups together all information about a request that the server needs + */ + +public class HttpRequest { + + private final String method; + private final String path; + private final String version; + private final Map headers; + private final String body; + + public HttpRequest(String method, + String path, + String version, + Map headers, + String body) { + this.method = method; + this.path = path; + this.version = version; + this.headers = headers != null ? Map.copyOf(headers) : Collections.emptyMap(); + this.body = body; + } + + public String getMethod() { + return method; } + public String getPath() { + return path; } + public String getVersion() { + return version; } + public Map getHeaders() { + return headers; } + public String getBody() { + return body; } + } From 3128ac77b717c901545521e6a83e1412f7a34a74 Mon Sep 17 00:00:00 2001 From: Younes Ahmad Date: Wed, 18 Feb 2026 23:37:02 +0100 Subject: [PATCH 20/67] Feature/mime type detection #8 (#47) * Added MIME detector class and test class * Added logic for Mime detector class * Added Unit tests * Added logic in HttpResponseBuilder and tests to try it out * Solves duplicate header issue * Removed a file for another issue * Changed hashmap to Treemap per code rabbits suggestion * Corrected logic error that was failing tests as per P2P review * Added more reason phrases and unit testing, also applied code rabbits suggestions! * Added changes to Responsebuilder to make merging easier * Changed back to earlier commit to hande byte corruption new PR * Added StaticFileHandler from main * Added staticFileHandler with binary-safe writing * Fix: Normalize Content-Type charset to uppercase UTF-8 Changed 'charset=utf-8' to 'charset=UTF-8' in StaticFileHandlerTest to match MimeTypeDetector output and align with HTTP RFC standard. Uppercase UTF-8 is the correct format per RFC 2616/7231. --- .../java/org/example/StaticFileHandler.java | 34 +- .../org/example/http/HttpResponseBuilder.java | 111 +++-- .../org/example/http/MimeTypeDetector.java | 77 +++ .../org/example/StaticFileHandlerTest.java | 2 +- .../example/http/HttpResponseBuilderTest.java | 453 +++++++++++++++++- .../example/http/MimeTypeDetectorTest.java | 165 +++++++ 6 files changed, 779 insertions(+), 63 deletions(-) create mode 100644 src/main/java/org/example/http/MimeTypeDetector.java create mode 100644 src/test/java/org/example/http/MimeTypeDetectorTest.java diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index bf9e79bc..58f13379 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -5,34 +5,40 @@ import java.io.File; import java.io.IOException; import java.io.OutputStream; -import java.io.PrintWriter; import java.nio.file.Files; -import java.util.Map; public class StaticFileHandler { private final String WEB_ROOT; private byte[] fileBytes; private int statusCode; - //Constructor for production + // Constructor for production public StaticFileHandler() { WEB_ROOT = "www"; } - //Constructor for tests, otherwise the www folder won't be seen - public StaticFileHandler(String webRoot){ + // Constructor for tests, otherwise the www folder won't be seen + public StaticFileHandler(String webRoot) { WEB_ROOT = webRoot; } private void handleGetRequest(String uri) throws IOException { + // Security: Prevent path traversal attacks (e.g. GET /../../etc/passwd) + File root = new File(WEB_ROOT).getCanonicalFile(); + File file = new File(root, uri).getCanonicalFile(); + + if (!file.toPath().startsWith(root.toPath())) { + fileBytes = "403 Forbidden".getBytes(); + statusCode = 403; + return; + } - File file = new File(WEB_ROOT, uri); - if(file.exists()) { + if (file.exists()) { fileBytes = Files.readAllBytes(file.toPath()); statusCode = 200; } else { File errorFile = new File(WEB_ROOT, "pageNotFound.html"); - if(errorFile.exists()) { + if (errorFile.exists()) { fileBytes = Files.readAllBytes(errorFile.toPath()); } else { fileBytes = "404 Not Found".getBytes(); @@ -41,16 +47,16 @@ private void handleGetRequest(String uri) throws IOException { } } - public void sendGetRequest(OutputStream outputStream, String uri) throws IOException{ + public void sendGetRequest(OutputStream outputStream, String uri) throws IOException { handleGetRequest(uri); HttpResponseBuilder response = new HttpResponseBuilder(); response.setStatusCode(statusCode); - response.setHeaders(Map.of("Content-Type", "text/html; charset=utf-8")); + // Use MimeTypeDetector instead of hardcoded text/html + response.setContentTypeFromFilename(uri); response.setBody(fileBytes); - PrintWriter writer = new PrintWriter(outputStream, true); - writer.println(response.build()); + outputStream.write(response.build()); + outputStream.flush(); } - -} +} \ No newline at end of file diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java index afaafcf9..db6a6958 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -1,8 +1,8 @@ package org.example.http; -// + import java.nio.charset.StandardCharsets; -import java.util.LinkedHashMap; import java.util.Map; +import java.util.TreeMap; public class HttpResponseBuilder { @@ -10,53 +10,108 @@ public class HttpResponseBuilder { private int statusCode = 200; private String body = ""; private byte[] bytebody; - private Map headers = new LinkedHashMap<>(); + private Map headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); private static final String CRLF = "\r\n"; + private static final Map REASON_PHRASES = Map.ofEntries( + Map.entry(200, "OK"), + Map.entry(201, "Created"), + Map.entry(204, "No Content"), + Map.entry(301, "Moved Permanently"), + Map.entry(302, "Found"), + Map.entry(303, "See Other"), + Map.entry(304, "Not Modified"), + Map.entry(307, "Temporary Redirect"), + Map.entry(308, "Permanent Redirect"), + Map.entry(400, "Bad Request"), + Map.entry(401, "Unauthorized"), + Map.entry(403, "Forbidden"), + Map.entry(404, "Not Found"), + Map.entry(500, "Internal Server Error"), + Map.entry(502, "Bad Gateway"), + Map.entry(503, "Service Unavailable") + ); public void setStatusCode(int statusCode) { this.statusCode = statusCode; } public void setBody(String body) { this.body = body != null ? body : ""; + this.bytebody = null; // Clear byte body when setting string body } public void setBody(byte[] body) { this.bytebody = body; + this.body = ""; // Clear string body when setting byte body } + public void setHeaders(Map headers) { - this.headers = new LinkedHashMap<>(headers); + this.headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + this.headers.putAll(headers); } - private static final Map REASON_PHRASES = Map.of( - 200, "OK", - 201, "Created", - 400, "Bad Request", - 404, "Not Found", - 500, "Internal Server Error"); - public String build(){ - StringBuilder sb = new StringBuilder(); + public void setHeader(String name, String value) { + this.headers.put(name, value); + } + + public void setContentTypeFromFilename(String filename) { + String mimeType = MimeTypeDetector.detectMimeType(filename); + setHeader("Content-Type", mimeType); + } + + /* + * Builds the complete HTTP response as a byte array and preserves binary content without corruption. + * @return Complete HTTP response (headers + body) as byte[] + */ + public byte[] build() { + // Determine content body and length + byte[] contentBody; int contentLength; - if(body.isEmpty() && bytebody != null){ + + if (bytebody != null) { + contentBody = bytebody; contentLength = bytebody.length; - setBody(new String(bytebody, StandardCharsets.UTF_8)); - }else{ - contentLength = body.getBytes(StandardCharsets.UTF_8).length; + } else { + contentBody = body.getBytes(StandardCharsets.UTF_8); + contentLength = contentBody.length; } + // Build headers as String + StringBuilder headerBuilder = new StringBuilder(); - String reason = REASON_PHRASES.getOrDefault(statusCode, "OK"); - sb.append(PROTOCOL).append(" ").append(statusCode).append(" ").append(reason).append(CRLF); - headers.forEach((k,v) -> sb.append(k).append(": ").append(v).append(CRLF)); - sb.append("Content-Length: ") - .append(contentLength); - sb.append(CRLF); - sb.append("Connection: close").append(CRLF); - sb.append(CRLF); - sb.append(body); - return sb.toString(); + // Status line + String reason = REASON_PHRASES.getOrDefault(statusCode, ""); + headerBuilder.append(PROTOCOL).append(" ").append(statusCode); + if (!reason.isEmpty()) { + headerBuilder.append(" ").append(reason); + } + headerBuilder.append(CRLF); - } + // User-defined headers + headers.forEach((k, v) -> headerBuilder.append(k).append(": ").append(v).append(CRLF)); -} + // Auto-append Content-Length if not set. + if (!headers.containsKey("Content-Length")) { + headerBuilder.append("Content-Length: ").append(contentLength).append(CRLF); + } + + // Auto-append Connection if not set. + if (!headers.containsKey("Connection")) { + headerBuilder.append("Connection: close").append(CRLF); + } + + // Blank line before body + headerBuilder.append(CRLF); + + // Convert headers to bytes + byte[] headerBytes = headerBuilder.toString().getBytes(StandardCharsets.UTF_8); + + // Combine headers + body into single byte array + byte[] response = new byte[headerBytes.length + contentBody.length]; + System.arraycopy(headerBytes, 0, response, 0, headerBytes.length); + System.arraycopy(contentBody, 0, response, headerBytes.length, contentBody.length); + + return response; + } +} \ No newline at end of file diff --git a/src/main/java/org/example/http/MimeTypeDetector.java b/src/main/java/org/example/http/MimeTypeDetector.java new file mode 100644 index 00000000..9005078a --- /dev/null +++ b/src/main/java/org/example/http/MimeTypeDetector.java @@ -0,0 +1,77 @@ +package org.example.http; + +import java.util.Map; + +/** + * Detects MIME types based on file extensions. + * Used to set the Content-Type header when serving static files. + */ +public final class MimeTypeDetector { + + // Private constructor - utility class + private MimeTypeDetector() { + throw new AssertionError("Utility class - do not instantiate"); + } + + private static final Map MIME_TYPES = Map.ofEntries( + // HTML & Text + Map.entry(".html", "text/html; charset=UTF-8"), + Map.entry(".htm", "text/html; charset=UTF-8"), + Map.entry(".css", "text/css; charset=UTF-8"), + Map.entry(".js", "application/javascript; charset=UTF-8"), + Map.entry(".json", "application/json; charset=UTF-8"), + Map.entry(".xml", "application/xml; charset=UTF-8"), + Map.entry(".txt", "text/plain; charset=UTF-8"), + + // Images + Map.entry(".png", "image/png"), + Map.entry(".jpg", "image/jpeg"), + Map.entry(".jpeg", "image/jpeg"), + Map.entry(".gif", "image/gif"), + Map.entry(".svg", "image/svg+xml"), + Map.entry(".ico", "image/x-icon"), + Map.entry(".webp", "image/webp"), + + // Documents + Map.entry(".pdf", "application/pdf"), + + // Video & Audio + Map.entry(".mp4", "video/mp4"), + Map.entry(".webm", "video/webm"), + Map.entry(".mp3", "audio/mpeg"), + Map.entry(".wav", "audio/wav"), + + // Fonts + Map.entry(".woff", "font/woff"), + Map.entry(".woff2", "font/woff2"), + Map.entry(".ttf", "font/ttf"), + Map.entry(".otf", "font/otf") + ); + + /** + * Detects the MIME type of file based on its extension. + * + * @param filename the name of the file (t.ex., "index.html", "style.css") + * @return the MIME type string (t.ex, "text/html; charset=UTF-8") + */ + + + public static String detectMimeType(String filename) { + + String octet = "application/octet-stream"; + + if (filename == null || filename.isEmpty()) { + return octet; + } + + // Find the last dot to get extension + int lastDot = filename.lastIndexOf('.'); + if (lastDot == -1 || lastDot == filename.length() - 1) { + // No extension or dot at end + return octet; + } + + String extension = filename.substring(lastDot).toLowerCase(); + return MIME_TYPES.getOrDefault(extension, octet); + } +} \ No newline at end of file diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java index 871fdb46..31db5108 100644 --- a/src/test/java/org/example/StaticFileHandlerTest.java +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -49,7 +49,7 @@ void test_file_that_exists_should_return_200() throws IOException { assertTrue(response.contains("HTTP/1.1 200 OK")); // Assert the status assertTrue(response.contains("Hello Test")); //Assert the content in the file - assertTrue(response.contains("Content-Type: text/html; charset=utf-8")); // Verify the correct Content-type header + assertTrue(response.contains("Content-Type: text/html; charset=UTF-8")); // Verify the correct Content-type header } diff --git a/src/test/java/org/example/http/HttpResponseBuilderTest.java b/src/test/java/org/example/http/HttpResponseBuilderTest.java index 8b80a7f8..b278ae19 100644 --- a/src/test/java/org/example/http/HttpResponseBuilderTest.java +++ b/src/test/java/org/example/http/HttpResponseBuilderTest.java @@ -1,45 +1,458 @@ package org.example.http; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Stream; - class HttpResponseBuilderTest { +import static org.assertj.core.api.Assertions.assertThat; - /** - * Verifies that build produces a valid HTTP response string! - * Status line is present - * Content-Length header is generated - * The response body is included - */ +class HttpResponseBuilderTest { + + // Helper method to convert byte[] response to String for assertions + private String asString(byte[] response) { + return new String(response, StandardCharsets.UTF_8); + } @Test void build_returnsValidHttpResponse() { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setBody("Hello"); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr) + .contains("HTTP/1.1 200 OK") + .contains("Content-Length: 5") + .contains("\r\n\r\n") + .contains("Hello"); + } + + // UTF-8 content length för olika strängar + @ParameterizedTest + @CsvSource({ + "å, 2", // 1 char, 2 bytes + "åäö, 6", // 3 chars, 6 bytes + "Hello, 5", // 5 chars, 5 bytes + "'', 0", // Empty string + "€, 3" // Euro sign, 3 bytes + }) + @DisplayName("Should calculate correct Content-Length for various strings") + void build_handlesUtf8ContentLength(String body, int expectedLength) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setBody(body); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr).contains("Content-Length: " + expectedLength); + } + + @Test + @DisplayName("Should set individual header") + void setHeader_addsHeaderToResponse() { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setHeader("Content-Type", "text/html; charset=UTF-8"); + builder.setBody("Hello"); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr).contains("Content-Type: text/html; charset=UTF-8"); + } + + @Test + @DisplayName("Should set multiple headers") + void setHeader_allowsMultipleHeaders() { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setHeader("Content-Type", "application/json"); + builder.setHeader("Cache-Control", "no-cache"); + builder.setBody("{}"); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr) + .contains("Content-Type: application/json") + .contains("Cache-Control: no-cache"); + } + + @ParameterizedTest + @CsvSource({ + "index.html, text/html; charset=UTF-8", + "page.htm, text/html; charset=UTF-8", + "style.css, text/css; charset=UTF-8", + "app.js, application/javascript; charset=UTF-8", + "data.json, application/json; charset=UTF-8", + "logo.png, image/png", + "photo.jpg, image/jpeg", + "image.jpeg, image/jpeg", + "icon.gif, image/gif", + "graphic.svg, image/svg+xml", + "favicon.ico, image/x-icon", + "doc.pdf, application/pdf", + "file.txt, text/plain; charset=UTF-8", + "config.xml, application/xml; charset=UTF-8" + }) + @DisplayName("Should auto-detect Content-Type from filename") + void setContentTypeFromFilename_detectsVariousTypes(String filename, String expectedContentType) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setContentTypeFromFilename(filename); + builder.setBody("test content"); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr).contains("Content-Type: " + expectedContentType); + } + + @ParameterizedTest(name = "{index} - Filename: {0} => Expected: {1}") + @CsvSource(value = { + "index.html, text/html; charset=UTF-8", + "style.css, text/css; charset=UTF-8", + "logo.png, image/png", + "doc.pdf, application/pdf", + "file.xyz, application/octet-stream", + "/var/www/index.html, text/html; charset=UTF-8", + "'', application/octet-stream", + "null, application/octet-stream" + }, nullValues = "null") + @DisplayName("Should detect Content-Type from various filenames and edge cases") + void setContentTypeFromFilename_allCases(String filename, String expectedContentType) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setContentTypeFromFilename(filename); + builder.setBody("test"); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr).contains("Content-Type: " + expectedContentType); + } + + @ParameterizedTest + @MethodSource("provideHeaderDuplicationScenarios") + @DisplayName("Should not duplicate headers when manually set") + void build_doesNotDuplicateHeaders(String headerName, String manualValue, String bodyContent) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setHeader(headerName, manualValue); + builder.setBody(bodyContent); + + byte[] result = builder.build(); + String resultStr = asString(result); + + long count = resultStr.lines() + .filter(line -> line.startsWith(headerName + ":")) + .count(); + + assertThat(count).isEqualTo(1); + assertThat(resultStr).contains(headerName + ": " + manualValue); + } + + private static Stream provideHeaderDuplicationScenarios() { + return Stream.of( + Arguments.of("Content-Length", "999", "Hello"), + Arguments.of("Content-Length", "0", ""), + Arguments.of("Content-Length", "12345", "Test content"), + Arguments.of("Connection", "keep-alive", "Hello"), + Arguments.of("Connection", "upgrade", "WebSocket data"), + Arguments.of("Connection", "close", "Goodbye") + ); + } + + @Test + @DisplayName("setHeaders should preserve case-insensitive behavior") + void setHeaders_preservesCaseInsensitivity() { + HttpResponseBuilder builder = new HttpResponseBuilder(); + + Map headers = new HashMap<>(); + headers.put("content-type", "text/html"); + headers.put("cache-control", "no-cache"); + builder.setHeaders(headers); + + builder.setHeader("Content-Length", "100"); + builder.setBody("Hello"); + + byte[] result = builder.build(); + String resultStr = asString(result); + + long count = resultStr.lines() + .filter(line -> line.toLowerCase().startsWith("content-length:")) + .count(); + + assertThat(count).isEqualTo(1); + } + + @ParameterizedTest + @CsvSource({ + "301, Moved Permanently", + "302, Found", + "304, Not Modified", + "400, Bad Request", + "401, Unauthorized", + "403, Forbidden", + "404, Not Found", + "500, Internal Server Error", + "502, Bad Gateway", + "503, Service Unavailable" + }) + @DisplayName("Should have correct reason phrases for common status codes") + void build_correctReasonPhrases(int statusCode, String expectedReason) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(statusCode); + builder.setBody(""); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr).contains("HTTP/1.1 " + statusCode + " " + expectedReason); + } + + // Redirect status codes + @ParameterizedTest + @CsvSource({ + "301, Moved Permanently, /new-page", + "302, Found, /temporary-page", + "303, See Other, /other-page", + "307, Temporary Redirect, /temp-redirect", + "308, Permanent Redirect, /perm-redirect" + }) + @DisplayName("Should handle redirect status codes correctly") + void build_handlesRedirectStatusCodes(int statusCode, String expectedReason, String location) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(statusCode); + builder.setHeader("Location", location); + builder.setBody(""); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr) + .contains("HTTP/1.1 " + statusCode + " " + expectedReason) + .contains("Location: " + location) + .doesNotContain("OK"); + } + + // Error status codes + @ParameterizedTest + @CsvSource({ + "400, Bad Request", + "401, Unauthorized", + "403, Forbidden", + "404, Not Found", + "500, Internal Server Error", + "502, Bad Gateway", + "503, Service Unavailable" + }) + @DisplayName("Should handle error status codes correctly") + void build_handlesErrorStatusCodes(int statusCode, String expectedReason) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(statusCode); + builder.setBody("Error message"); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr) + .contains("HTTP/1.1 " + statusCode + " " + expectedReason) + .doesNotContain("OK"); + } + + // Unknown status codes + @ParameterizedTest + @ValueSource(ints = {999, 123, 777, 100, 600}) + @DisplayName("Should handle unknown status codes gracefully") + void build_handlesUnknownStatusCodes(int statusCode) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(statusCode); + builder.setBody(""); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr) + .startsWith("HTTP/1.1 " + statusCode) + .doesNotContain("OK"); + } + + @Test + @DisplayName("Should auto-append headers when not manually set") + void build_autoAppendsHeadersWhenNotSet() { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setBody("Hello"); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr) + .contains("Content-Length: 5") + .contains("Connection: close"); + } + + @Test + @DisplayName("Should allow custom headers alongside auto-generated ones") + void build_combinesCustomAndAutoHeaders() { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setHeader("Content-Type", "text/html"); + builder.setHeader("Cache-Control", "no-cache"); + builder.setBody("Hello"); + + byte[] result = builder.build(); + String resultStr = asString(result); + assertThat(resultStr) + .contains("Content-Type: text/html") + .contains("Cache-Control: no-cache") + .contains("Content-Length: 5") + .contains("Connection: close"); + } + + // Case-insensitive header names + @ParameterizedTest + @CsvSource({ + "content-length, 100", + "Content-Length, 100", + "CONTENT-LENGTH, 100", + "CoNtEnT-LeNgTh, 100" + }) + @DisplayName("Should handle case-insensitive header names") + void setHeader_caseInsensitive(String headerName, String headerValue) { HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setHeader(headerName, headerValue); builder.setBody("Hello"); - String result = builder.build(); + byte[] result = builder.build(); + String resultStr = asString(result); + + long count = resultStr.lines() + .filter(line -> line.toLowerCase().contains("content-length")) + .count(); - assertThat(result).contains("HTTP/1.1 200 OK"); - assertThat(result).contains("Content-Length: 5"); - assertThat(result).contains("\r\n\r\n"); - assertThat(result).contains("Hello"); + assertThat(count).isEqualTo(1); + assertThat(resultStr.toLowerCase()).contains("content-length: " + headerValue.toLowerCase()); + } + + // Empty/null body + @ParameterizedTest + @CsvSource(value = { + "'', 0", // Empty string + "null, 0" // Null + }, nullValues = "null") + @DisplayName("Should handle empty and null body") + void build_emptyAndNullBody(String body, int expectedLength) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setBody(body); + + byte[] result = builder.build(); + String resultStr = asString(result); + assertThat(resultStr) + .contains("HTTP/1.1 200 OK") + .contains("Content-Length: " + expectedLength); + } + + // Header override + @ParameterizedTest + @CsvSource({ + "Content-Type, text/plain, text/html", + "Content-Type, application/json, text/xml", + "Connection, keep-alive, close", + "Cache-Control, no-cache, max-age=3600" + }) + @DisplayName("Should override previous header value when set again") + void setHeader_overridesPreviousValue(String headerName, String firstValue, String secondValue) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setHeader(headerName, firstValue); + builder.setHeader(headerName, secondValue); // Override + builder.setBody("Test"); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr) + .contains(headerName + ": " + secondValue) + .doesNotContain(headerName + ": " + firstValue); + } + + // för auto-append behavior + @ParameterizedTest + @MethodSource("provideAutoAppendScenarios") + @DisplayName("Should auto-append specific headers when not manually set") + void build_autoAppendsSpecificHeaders(String body, boolean setContentLength, boolean setConnection, + String expectedContentLength, String expectedConnection) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + + if (setContentLength) { + builder.setHeader("Content-Length", "999"); + } + if (setConnection) { + builder.setHeader("Connection", "keep-alive"); + } + + builder.setBody(body); + byte[] result = builder.build(); + String resultStr = asString(result); + + if (expectedContentLength != null) { + assertThat(resultStr).contains("Content-Length: " + expectedContentLength); + } + if (expectedConnection != null) { + assertThat(resultStr).contains("Connection: " + expectedConnection); + } + } + + private static Stream provideAutoAppendScenarios() { + return Stream.of( + // body, setContentLength, setConnection, expectedContentLength, expectedConnection + Arguments.of("Hello", false, false, "5", "close"), // Auto-append both + Arguments.of("Hello", true, false, "999", "close"), // Manual CL, auto Connection + Arguments.of("Hello", false, true, "5", "keep-alive"), // Auto CL, manual Connection + Arguments.of("Hello", true, true, "999", "keep-alive"), // Both manual + Arguments.of("", false, false, "0", "close") // Empty body + ); } - // Verifies that Content-Length is calculated using UTF-8 byte length! - // @Test - void build_handlesUtf8ContentLength() { + @DisplayName("Should preserve binary content without corruption") + void build_preservesBinaryContent() { HttpResponseBuilder builder = new HttpResponseBuilder(); - builder.setBody("å"); + // Create binary data with non-UTF-8 bytes + byte[] binaryData = new byte[]{ + (byte) 0x89, 0x50, 0x4E, 0x47, // PNG header + (byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0 // Invalid UTF-8 sequences + }; + + builder.setBody(binaryData); + builder.setContentTypeFromFilename("test.png"); + + byte[] result = builder.build(); + + // Extract body from response (everything after \r\n\r\n) + int bodyStart = -1; + for (int i = 0; i < result.length - 3; i++) { + if (result[i] == '\r' && result[i+1] == '\n' && + result[i+2] == '\r' && result[i+3] == '\n') { + bodyStart = i + 4; + break; + } + } - String result = builder.build(); + assertThat(bodyStart).isGreaterThan(0); - assertThat(result).contains("Content-Length: 2"); + // Verify binary data is intact + byte[] actualBody = new byte[binaryData.length]; + System.arraycopy(result, bodyStart, actualBody, 0, binaryData.length); + assertThat(actualBody).isEqualTo(binaryData); } -} +} \ No newline at end of file diff --git a/src/test/java/org/example/http/MimeTypeDetectorTest.java b/src/test/java/org/example/http/MimeTypeDetectorTest.java new file mode 100644 index 00000000..913aeb48 --- /dev/null +++ b/src/test/java/org/example/http/MimeTypeDetectorTest.java @@ -0,0 +1,165 @@ +package org.example.http; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.assertj.core.api.Assertions.*; + +class MimeTypeDetectorTest { + + @Test + @DisplayName("Should detect HTML files") + void detectMimeType_html() { + assertThat(MimeTypeDetector.detectMimeType("index.html")) + .isEqualTo("text/html; charset=UTF-8"); + + assertThat(MimeTypeDetector.detectMimeType("page.htm")) + .isEqualTo("text/html; charset=UTF-8"); + } + + @Test + @DisplayName("Should detect CSS files") + void detectMimeType_css() { + assertThat(MimeTypeDetector.detectMimeType("style.css")) + .isEqualTo("text/css; charset=UTF-8"); + } + + @Test + @DisplayName("Should detect JavaScript files") + void detectMimeType_javascript() { + assertThat(MimeTypeDetector.detectMimeType("app.js")) + .isEqualTo("application/javascript; charset=UTF-8"); + } + + @Test + @DisplayName("Should detect JSON files") + void detectMimeType_json() { + assertThat(MimeTypeDetector.detectMimeType("data.json")) + .isEqualTo("application/json; charset=UTF-8"); + } + + @Test + @DisplayName("Should detect PNG images") + void detectMimeType_png() { + assertThat(MimeTypeDetector.detectMimeType("logo.png")) + .isEqualTo("image/png"); + } + + @Test + @DisplayName("Should detect JPEG images with .jpg extension") + void detectMimeType_jpg() { + assertThat(MimeTypeDetector.detectMimeType("photo.jpg")) + .isEqualTo("image/jpeg"); + } + + @Test + @DisplayName("Should detect JPEG images with .jpeg extension") + void detectMimeType_jpeg() { + assertThat(MimeTypeDetector.detectMimeType("photo.jpeg")) + .isEqualTo("image/jpeg"); + } + + @Test + @DisplayName("Should detect PDF files") + void detectMimeType_pdf() { + assertThat(MimeTypeDetector.detectMimeType("document.pdf")) + .isEqualTo("application/pdf"); + } + + @Test + @DisplayName("Should be case-insensitive") + void detectMimeType_caseInsensitive() { + assertThat(MimeTypeDetector.detectMimeType("INDEX.HTML")) + .isEqualTo("text/html; charset=UTF-8"); + + assertThat(MimeTypeDetector.detectMimeType("Style.CSS")) + .isEqualTo("text/css; charset=UTF-8"); + + assertThat(MimeTypeDetector.detectMimeType("PHOTO.PNG")) + .isEqualTo("image/png"); + } + + @Test + @DisplayName("Should return default MIME type for unknown extensions") + void detectMimeType_unknownExtension() { + assertThat(MimeTypeDetector.detectMimeType("file.xyz")) + .isEqualTo("application/octet-stream"); + + assertThat(MimeTypeDetector.detectMimeType("document.unknown")) + .isEqualTo("application/octet-stream"); + } + + @Test + @DisplayName("Should handle files without extension") + void detectMimeType_noExtension() { + assertThat(MimeTypeDetector.detectMimeType("README")) + .isEqualTo("application/octet-stream"); + + assertThat(MimeTypeDetector.detectMimeType("Makefile")) + .isEqualTo("application/octet-stream"); + } + + @Test + @DisplayName("Should handle null filename") + void detectMimeType_null() { + assertThat(MimeTypeDetector.detectMimeType(null)) + .isEqualTo("application/octet-stream"); + } + + @Test + @DisplayName("Should handle empty filename") + void detectMimeType_empty() { + assertThat(MimeTypeDetector.detectMimeType("")) + .isEqualTo("application/octet-stream"); + } + + @Test + @DisplayName("Should handle filename ending with dot") + void detectMimeType_endsWithDot() { + assertThat(MimeTypeDetector.detectMimeType("file.")) + .isEqualTo("application/octet-stream"); + } + + @Test + @DisplayName("Should handle path with directories") + void detectMimeType_withPath() { + assertThat(MimeTypeDetector.detectMimeType("/var/www/index.html")) + .isEqualTo("text/html; charset=UTF-8"); + + assertThat(MimeTypeDetector.detectMimeType("css/styles/main.css")) + .isEqualTo("text/css; charset=UTF-8"); + } + + @Test + @DisplayName("Should handle multiple dots in filename") + void detectMimeType_multipleDots() { + assertThat(MimeTypeDetector.detectMimeType("jquery.min.js")) + .isEqualTo("application/javascript; charset=UTF-8"); + + assertThat(MimeTypeDetector.detectMimeType("bootstrap.bundle.min.css")) + .isEqualTo("text/css; charset=UTF-8"); + } + + // Parametriserad test för många filtyper på en gång + @ParameterizedTest + @CsvSource({ + "test.html, text/html; charset=UTF-8", + "style.css, text/css; charset=UTF-8", + "app.js, application/javascript; charset=UTF-8", + "data.json, application/json; charset=UTF-8", + "image.png, image/png", + "photo.jpg, image/jpeg", + "doc.pdf, application/pdf", + "icon.svg, image/svg+xml", + "favicon.ico, image/x-icon", + "video.mp4, video/mp4", + "audio.mp3, audio/mpeg" + }) + @DisplayName("Should detect common file types") + void detectMimeType_commonTypes(String filename, String expectedMimeType) { + assertThat(MimeTypeDetector.detectMimeType(filename)) + .isEqualTo(expectedMimeType); + } +} \ No newline at end of file From d4e74816655d48b0f925c0daab463184ed55e059 Mon Sep 17 00:00:00 2001 From: Elias Lennheimer <47382348+Xeutos@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:20:18 +0100 Subject: [PATCH 21/67] Dockerfile update (#52) (#63) * Update Dockerfile Dockerfiles now copies www folder aswell * Added building and copying of dependency jar files * Fix dependency path in Dockerfile and update classpath in ENTRYPOINT configuration. * Fixed typo in Entrypoint configuration * Expose port 8080 in Dockerfile and changed appuser to come before ENTRYPOINT configuration. * Adjust Dockerfile paths for classes and dependencies, update `COPY` targets accordingly. --- Dockerfile | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index d8b69012..ee7e511e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,9 +3,14 @@ WORKDIR /build COPY src/ src/ COPY pom.xml pom.xml RUN mvn compile +RUN mvn dependency:copy-dependencies -DincludeScope=compile FROM eclipse-temurin:25-jre-alpine +EXPOSE 8080 RUN addgroup -S appgroup && adduser -S appuser -G appgroup -COPY --from=build /build/target/classes/ /app/ -ENTRYPOINT ["java", "-classpath", "/app", "org.example.App"] +WORKDIR /app/ +COPY --from=build /build/target/classes/ / +COPY --from=build /build/target/dependency/ /dependencies/ +COPY /www/ /www/ USER appuser +ENTRYPOINT ["java", "-classpath", "/app:/dependencies/*", "org.example.App"] From 5c80eaac8c189f5a4c4e16d2d9f6e137cd76867b Mon Sep 17 00:00:00 2001 From: Younes Ahmad Date: Thu, 19 Feb 2026 10:25:32 +0100 Subject: [PATCH 22/67] Added comprehensive README.MD (#67) * Added comprehensive README.MD * Added formatting recommendations, clearer info --- README.md | 375 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 349 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index d2be0162..490ccc6b 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,372 @@ -# 🚀 Create Your First Java Program +# ⚡ HTTP Web Server ⚡ +### **Class JUV25G** | Lightweight • Configurable • Secure -Java has evolved to become more beginner-friendly. This guide walks you through creating a simple program that prints “Hello World,” using both the classic syntax and the new streamlined approach introduced in Java 21. +
+ +![Java](https://img.shields.io/badge/Java-21+-orange?style=for-the-badge&logo=openjdk) +![HTTP](https://img.shields.io/badge/HTTP-1.1-blue?style=for-the-badge) +![Status](https://img.shields.io/badge/Status-Active-success?style=for-the-badge) + +*A modern, high-performance HTTP web server built from scratch in Java* + +[Features](#features) • [Quick Start](#quick-start) • [Configuration](#configuration) --- -## ✨ Classic Java Approach +
-Traditionally, Java requires a class with a `main` method as the entry point: +## ✨ Features -```java -public class Main { - public static void main(String[] args) { - System.out.println("Hello World"); - } -} +- 🚀 **High Performance** - Virtual threads for handling thousands of concurrent connections +- 📁 **Static File Serving** - HTML, CSS, JavaScript, images, PDFs, fonts, and more +- 🎨 **Smart MIME Detection** - Automatic Content-Type headers for 20+ file types +- ⚙️ **Flexible Configuration** - YAML or JSON config files with sensible defaults +- 🔒 **Security First** - Path traversal protection and input validation +- 🐳 **Docker Ready** - Multi-stage Dockerfile for easy deployment +- ⚡ **HTTP/1.1 Compliant** - Proper status codes, headers, and responses +- 🎯 **Custom Error Pages** - Branded 404 pages and error handling + +## 📋 Requirements + +| Tool | Version | Purpose | +|------|---------|---------| +| ☕ **Java** | 21+ | Runtime environment | +| 📦 **Maven** | 3.6+ | Build tool | +| 🐳 **Docker** | Latest | Optional - for containerization | + +## Quick Start + +``` +┌─────────────────────────────────────────────┐ +│ Ready to launch your web server? │ +│ Follow these simple steps! │ +└─────────────────────────────────────────────┘ ``` -This works across all Java versions and forms the foundation of most Java programs. +### 1. Clone the repository +```bash +git clone git clone https://github.com/ithsjava25/project-webserver-juv25g.git +cd project-webserver +``` ---- +### 2. Build the project +```bash +mvn clean compile +``` + +### 3. Run the server + +**Option A: Run directly with Maven (recommended for development)** +```bash +mvn exec:java@run +``` + +**Option B: Run compiled classes directly** +```bash +mvn clean compile +java -cp target/classes org.example.App +``` + +**Option C: Using Docker** +```bash +docker build -t webserver . +docker run -p 8080:8080 -v $(pwd)/www:/www webserver +``` + +The server will start on the default port **8080** and serve files from the `www/` directory. + +### 4. Access in browser +Open your browser and navigate to: +``` +http://localhost:8080 +``` -## 🆕 Java 25: Unnamed Class with Instance Main Method +## Configuration -In newer versions like **Java 25**, you can use **Unnamed Classes** and an **Instance Main Method**, which allows for a much cleaner syntax: +The server can be configured using a YAML or JSON configuration file located at: +``` +src/main/resources/application.yml +``` + +### Configuration File Format (YAML) + +```yaml +server: + port: 8080 + rootDir: "./www" + +logging: + level: "INFO" +``` + +### Configuration File Format (JSON) -```java -void main() { - System.out.println("Hello World"); +```json +{ + "server": { + "port": 8080, + "rootDir": "./www" + }, + "logging": { + "level": "INFO" + } } ``` -### 💡 Why is this cool? +### Configuration Options + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `server.port` | Integer | `8080` | Port number the server listens on (1-65535) | +| `server.rootDir` | String | `"./www"` | Root directory for serving static files | +| `logging.level` | String | `"INFO"` | Logging level (INFO, DEBUG, WARN, ERROR) | + +### Default Values + +If no configuration file is provided or values are missing, the following defaults are used: + +- **Port:** 8080 +- **Root Directory:** ./www +- **Logging Level:** INFO + +## Directory Structure + +``` +project-webserver/ +├── src/ +│ ├── main/ +│ │ ├── java/ +│ │ │ └── org/example/ +│ │ │ ├── App.java # Main entry point +│ │ │ ├── TcpServer.java # TCP server implementation +│ │ │ ├── ConnectionHandler.java # HTTP request handler +│ │ │ ├── StaticFileHandler.java # Static file server +│ │ │ ├── config/ # Configuration classes +│ │ │ ├── http/ # HTTP response builder & MIME detection +│ │ │ ├── httpparser/ # HTTP request parser +│ │ │ └── filter/ # Filter chain (future feature) +│ │ └── resources/ +│ │ └── application.yml # Configuration file +│ └── test/ # Unit tests +├── www/ # Web root (static files) +│ ├── index.html +│ ├── pageNotFound.html # Custom 404 page +│ └── ... # Other static files +├── pom.xml +└── README.md +``` + +## Serving Static Files + +Place your static files in the `www/` directory (or the directory specified in `server.rootDir`). + +### Supported File Types + +The server automatically detects and serves the correct `Content-Type` for: + +**Text & Markup:** +- HTML (`.html`, `.htm`) +- CSS (`.css`) +- JavaScript (`.js`) +- JSON (`.json`) +- XML (`.xml`) +- Plain text (`.txt`) + +**Images:** +- PNG (`.png`) +- JPEG (`.jpg`, `.jpeg`) +- GIF (`.gif`) +- SVG (`.svg`) +- WebP (`.webp`) +- ICO (`.ico`) + +**Documents:** +- PDF (`.pdf`) + +**Fonts:** +- WOFF (`.woff`) +- WOFF2 (`.woff2`) +- TrueType (`.ttf`) +- OpenType (`.otf`) + +**Media:** +- MP4 video (`.mp4`) +- WebM video (`.webm`) +- MP3 audio (`.mp3`) +- WAV audio (`.wav`) + +Unknown file types are served as `application/octet-stream`. + +## URL Handling + +The server applies the following URL transformations: + +| Request URL | Resolved File | +|-------------|---------------| +| `/` | `index.html` | +| `/about` | `about.html` | +| `/contact` | `contact.html` | +| `/styles.css` | `styles.css` | +| `/page.html` | `page.html` | + +**Note:** URLs ending with `/` are resolved to `index.html`, and URLs without an extension get `.html` appended automatically. + +## Error Pages + +### 404 Not Found + +If a requested file doesn't exist, the server returns: +1. `pageNotFound.html` (if it exists in the web root) +2. Otherwise: Plain text "404 Not Found" + +To customize your 404 page, create `www/pageNotFound.html`. + +### 403 Forbidden + +Returned when a path traversal attack is detected (e.g., `GET /../../etc/passwd`). + +## Security Features + +### Path Traversal Protection + +The server validates all file paths to prevent directory traversal attacks: + +``` +✅ Allowed: /index.html +✅ Allowed: /docs/guide.pdf +❌ Blocked: /../../../etc/passwd +❌ Blocked: /www/../../../secret.txt +``` + +All blocked requests return `403 Forbidden`. -- ✅ No need for a `public class` declaration -- ✅ No `static` keyword required -- ✅ Great for quick scripts and learning +## Running Tests -To compile and run this, use: +```bash +mvn test +``` + +Test coverage includes: +- HTTP request parsing +- Response building +- MIME type detection +- Configuration loading +- Static file serving +- Path traversal security + +## Building for Production + +### Using Docker (recommended) ```bash -java --source 25 HelloWorld.java +docker build -t webserver . +docker run -d -p 8080:8080 -v $(pwd)/www:/www --name my-webserver webserver ``` ---- +### Running on a server + +```bash +# Compile the project +mvn clean compile + +# Run with nohup for background execution +nohup java -cp target/classes org.example.App > server.log 2>&1 & + +# Or use systemd (create /etc/systemd/system/webserver.service) +``` + +## Examples + +### Example 1: Serving a Simple Website + +**Directory structure:** +``` +www/ +├── index.html +├── styles.css +├── app.js +└── images/ + └── logo.png +``` + +**Access:** +- Homepage: `http://localhost:8080/` +- Stylesheet: `http://localhost:8080/styles.css` +- Logo: `http://localhost:8080/images/logo.png` + +### Example 2: Custom Port + +**application.yml:** +```yaml +server: + port: 3000 + rootDir: "./public" +``` + +Access at: `http://localhost:3000/` + +### Example 3: Different Web Root + +**application.yml:** +```yaml +server: + rootDir: "./dist" +``` + +Server will serve files from `dist/` instead of `www/`. + +## Architecture + +### Request Flow + +1. **TcpServer** accepts incoming TCP connections +2. **ConnectionHandler** creates a virtual thread for each request +3. **HttpParser** parses the HTTP request line and headers +4. **StaticFileHandler** resolves the file path and reads the file +5. **HttpResponseBuilder** constructs the HTTP response with correct headers +6. Response is written to the client socket + +### Filter Chain (Future Feature) + +The project includes a filter chain interface for future extensibility: +- Request/response filtering +- Authentication +- Logging +- Compression + +## Troubleshooting + +### Port already in use +``` +Error: Address already in use +``` +**Solution:** Change the port in `application.yml` or kill the process using port 8080: +```bash +# Linux/Mac +lsof -ti:8080 | xargs kill -9 + +# Windows +netstat -ano | findstr :8080 +taskkill /PID /F +``` + +### File not found but file exists +**Solution:** Check that the file is in the correct directory (`www/` by default) and that the filename matches exactly (case-sensitive on Linux/Mac). + +### Binary files (images/PDFs) are corrupted +**Solution:** This should not happen with the current implementation. The server uses `byte[]` internally to preserve binary data. If you see this issue, please report it as a bug. + +## Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/new-feature`) +3. Commit your changes (`git commit -m 'Add new feature'`) +4. Push to the branch (`git push origin feature/new-feature`) +5. Open a Pull Request + +
-## 📚 Learn More +### 👨‍💻 Built by Class JUV25G -This feature is part of Java’s ongoing effort to streamline syntax. You can explore deeper in [Baeldung’s guide to Unnamed Classes and Instance Main Methods](https://www.baeldung.com/java-21-unnamed-class-instance-main). +**Made with ❤️ and ☕ in Sweden** +
From 6950c14057b2747e0b98dc4881b319e8ffed48e2 Mon Sep 17 00:00:00 2001 From: Andreas Kaiberger <91787385+apaegs@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:23:33 +0100 Subject: [PATCH 23/67] Fix: Path traversal vulnerability in StaticFileHandler (#65) * Prevent path traversal and sanitize URI in StaticFileHandler. * Add test for path traversal protection and support 403 responses. * Add tests for URI sanitization and handling of encoded/special URLs. * Add test for null byte injection prevention in StaticFileHandler * Improve StaticFileHandler path traversal handling and test coverage * Improve URI sanitization and add test for 404 response handling in StaticFileHandler --- .../java/org/example/StaticFileHandler.java | 22 ++++--- .../org/example/http/HttpResponseBuilder.java | 14 +---- .../org/example/StaticFileHandlerTest.java | 58 +++++++++++++++++++ 3 files changed, 75 insertions(+), 19 deletions(-) diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 58f13379..756c87fd 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -23,25 +23,33 @@ public StaticFileHandler(String webRoot) { } private void handleGetRequest(String uri) throws IOException { - // Security: Prevent path traversal attacks (e.g. GET /../../etc/passwd) + // Sanitize URI + int q = uri.indexOf('?'); + if (q >= 0) uri = uri.substring(0, q); + int h = uri.indexOf('#'); + if (h >= 0) uri = uri.substring(0, h); + uri = uri.replace("\0", ""); + if (uri.startsWith("/")) uri = uri.substring(1); + + // Path traversal check File root = new File(WEB_ROOT).getCanonicalFile(); File file = new File(root, uri).getCanonicalFile(); - if (!file.toPath().startsWith(root.toPath())) { - fileBytes = "403 Forbidden".getBytes(); + fileBytes = "403 Forbidden".getBytes(java.nio.charset.StandardCharsets.UTF_8); statusCode = 403; return; } - if (file.exists()) { + // Read file + if (file.isFile()) { fileBytes = Files.readAllBytes(file.toPath()); statusCode = 200; } else { File errorFile = new File(WEB_ROOT, "pageNotFound.html"); - if (errorFile.exists()) { + if (errorFile.isFile()) { fileBytes = Files.readAllBytes(errorFile.toPath()); } else { - fileBytes = "404 Not Found".getBytes(); + fileBytes = "404 Not Found".getBytes(java.nio.charset.StandardCharsets.UTF_8); } statusCode = 404; } @@ -49,13 +57,11 @@ private void handleGetRequest(String uri) throws IOException { public void sendGetRequest(OutputStream outputStream, String uri) throws IOException { handleGetRequest(uri); - HttpResponseBuilder response = new HttpResponseBuilder(); response.setStatusCode(statusCode); // Use MimeTypeDetector instead of hardcoded text/html response.setContentTypeFromFilename(uri); response.setBody(fileBytes); - outputStream.write(response.build()); outputStream.flush(); } diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java index db6a6958..8f55ebf2 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -36,14 +36,15 @@ public class HttpResponseBuilder { public void setStatusCode(int statusCode) { this.statusCode = statusCode; } + public void setBody(String body) { this.body = body != null ? body : ""; - this.bytebody = null; // Clear byte body when setting string body + this.bytebody = null; } public void setBody(byte[] body) { this.bytebody = body; - this.body = ""; // Clear string body when setting byte body + this.body = ""; } public void setHeaders(Map headers) { @@ -65,7 +66,6 @@ public void setContentTypeFromFilename(String filename) { * @return Complete HTTP response (headers + body) as byte[] */ public byte[] build() { - // Determine content body and length byte[] contentBody; int contentLength; @@ -77,10 +77,8 @@ public byte[] build() { contentLength = contentBody.length; } - // Build headers as String StringBuilder headerBuilder = new StringBuilder(); - // Status line String reason = REASON_PHRASES.getOrDefault(statusCode, ""); headerBuilder.append(PROTOCOL).append(" ").append(statusCode); if (!reason.isEmpty()) { @@ -88,26 +86,20 @@ public byte[] build() { } headerBuilder.append(CRLF); - // User-defined headers headers.forEach((k, v) -> headerBuilder.append(k).append(": ").append(v).append(CRLF)); - // Auto-append Content-Length if not set. if (!headers.containsKey("Content-Length")) { headerBuilder.append("Content-Length: ").append(contentLength).append(CRLF); } - // Auto-append Connection if not set. if (!headers.containsKey("Connection")) { headerBuilder.append("Connection: close").append(CRLF); } - // Blank line before body headerBuilder.append(CRLF); - // Convert headers to bytes byte[] headerBytes = headerBuilder.toString().getBytes(StandardCharsets.UTF_8); - // Combine headers + body into single byte array byte[] response = new byte[headerBytes.length + contentBody.length]; System.arraycopy(headerBytes, 0, response, 0, headerBytes.length); System.arraycopy(contentBody, 0, response, headerBytes.length, contentBody.length); diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java index 31db5108..1fe4893b 100644 --- a/src/test/java/org/example/StaticFileHandlerTest.java +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -2,6 +2,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -76,4 +78,60 @@ void test_file_that_does_not_exists_should_return_404() throws IOException { } + @Test + void test_path_traversal_should_return_403() throws IOException { + // Arrange + Path secret = tempDir.resolve("secret.txt"); + Files.writeString(secret,"TOP SECRET"); + Path webRoot = tempDir.resolve("www"); + Files.createDirectories(webRoot); + StaticFileHandler handler = new StaticFileHandler(webRoot.toString()); + ByteArrayOutputStream fakeOutput = new ByteArrayOutputStream(); + + // Act + handler.sendGetRequest(fakeOutput, "../secret.txt"); + + // Assert + String response = fakeOutput.toString(); + assertFalse(response.contains("TOP SECRET")); + assertTrue(response.contains("HTTP/1.1 403 Forbidden")); + } + + @ParameterizedTest + @CsvSource({ + "index.html?foo=bar", + "index.html#section", + "/index.html" + }) + void sanitized_uris_should_return_200(String uri) throws IOException { + // Arrange + Path webRoot = tempDir.resolve("www"); + Files.createDirectories(webRoot); + Files.writeString(webRoot.resolve("index.html"), "Hello"); + StaticFileHandler handler = new StaticFileHandler(webRoot.toString()); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + + // Act + handler.sendGetRequest(out, uri); + + // Assert + assertTrue(out.toString().contains("HTTP/1.1 200 OK")); + } + + @Test + void null_byte_injection_should_not_return_200() throws IOException { + // Arrange + Path webRoot = tempDir.resolve("www"); + Files.createDirectories(webRoot); + StaticFileHandler handler = new StaticFileHandler(webRoot.toString()); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + + // Act + handler.sendGetRequest(out, "index.html\0../../etc/passwd"); + + // Assert + String response = out.toString(); + assertFalse(response.contains("HTTP/1.1 200 OK")); + assertTrue(response.contains("HTTP/1.1 404 Not Found")); + } } From fffdebff315c1edf59f6f640621fda62778c425c Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Fri, 20 Feb 2026 16:48:34 +0100 Subject: [PATCH 24/67] updaterat i StaticFileHandler och skapat CacheFilter --- src/main/java/org/example/CacheFilter.java | 23 ++++++++++++++++ .../java/org/example/StaticFileHandler.java | 27 +++++-------------- 2 files changed, 29 insertions(+), 21 deletions(-) create mode 100644 src/main/java/org/example/CacheFilter.java diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java new file mode 100644 index 00000000..19013322 --- /dev/null +++ b/src/main/java/org/example/CacheFilter.java @@ -0,0 +1,23 @@ +package org.example; + +import java.io.IOException; + +public class CacheFilter { + private final FileCache cache = new FileCache(); + + public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { + if (cache.contains(uri)) { + System.out.println("✓ Cache hit for: " + uri); + return cache.get(uri); + } + System.out.println("✗ Cache miss for: " + uri); + byte[] fileBytes = provider.fetch(uri); + cache.put(uri, fileBytes); + return fileBytes; + } + + @FunctionalInterface + public interface FileProvider { + byte[] fetch(String uri) throws IOException; + } +} diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index d58b9fce..8708bdd4 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -11,32 +11,17 @@ public class StaticFileHandler { private static final String WEB_ROOT = "www"; - private byte[] fileBytes; - private final FileCache cache = new FileCache(); + private final CacheFilter cacheFilter = new CacheFilter(); - public StaticFileHandler(){} - - private void handleGetRequest(String uri) throws IOException { - if (cache.contains(uri)) { - System.out.println("✓ Cache hit for: " + uri); - fileBytes = cache.get(uri); - return; - } - System.out.println("✗ Cache miss for: " + uri); - File file = new File(WEB_ROOT, uri); - fileBytes = Files.readAllBytes(file.toPath()); - - cache.put(uri, fileBytes); - } - - public void sendGetRequest(OutputStream outputStream, String uri) throws IOException{ - handleGetRequest(uri); + public void sendGetRequest(OutputStream outputStream, String uri) throws IOException { + byte[] fileBytes = cacheFilter.getOrFetch(uri, + path -> Files.readAllBytes(new File(WEB_ROOT, path).toPath()) + ); + HttpResponseBuilder response = new HttpResponseBuilder(); response.setHeaders(Map.of("Content-Type", "text/html; charset=utf-8")); response.setBody(fileBytes); PrintWriter writer = new PrintWriter(outputStream, true); writer.println(response.build()); - } - } From c69b9dd6f2a31040f5695f43536f3b788bf79a4c Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Tue, 17 Feb 2026 14:11:35 +0100 Subject: [PATCH 25/67] =?UTF-8?q?ska=20vara=20klart=20nu=20har=20gl=C3=B6m?= =?UTF-8?q?t=20att=20commit=20jobbet=20inser=20jag=20dock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/example/FileCache.java | 28 ++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/main/java/org/example/FileCache.java diff --git a/src/main/java/org/example/FileCache.java b/src/main/java/org/example/FileCache.java new file mode 100644 index 00000000..5783ce4d --- /dev/null +++ b/src/main/java/org/example/FileCache.java @@ -0,0 +1,28 @@ +package org.example; + +import java.util.HashMap; +import java.util.Map; + +public class FileCache { + private final Map cache = new HashMap<>(); + + public boolean contains (String key) { + return cache.containsKey(key); + } + + public byte[] get(String key) { + return cache.get(key); + } + + public void put(String key, byte[] value){ + cache.put(key, value); + } + public void clear() { + cache.clear(); + } + + public int size() { + return cache.size(); + } + +} From bfa3129765e3e768cbdb2391dbd85e46b44be09b Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Sat, 21 Feb 2026 17:15:13 +0100 Subject: [PATCH 26/67] har lagt till i line 20-25 samt line 29 # Conflicts: # src/main/java/org/example/StaticFileHandler.java --- .../java/org/example/StaticFileHandler.java | 59 ++++--------------- 1 file changed, 13 insertions(+), 46 deletions(-) diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 756c87fd..95a0b424 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -5,64 +5,31 @@ import java.io.File; import java.io.IOException; import java.io.OutputStream; +import java.io.PrintWriter; import java.nio.file.Files; +import java.util.Map; public class StaticFileHandler { - private final String WEB_ROOT; + private static final String WEB_ROOT = "www"; private byte[] fileBytes; - private int statusCode; - // Constructor for production - public StaticFileHandler() { - WEB_ROOT = "www"; - } - - // Constructor for tests, otherwise the www folder won't be seen - public StaticFileHandler(String webRoot) { - WEB_ROOT = webRoot; - } + public StaticFileHandler(){} private void handleGetRequest(String uri) throws IOException { - // Sanitize URI - int q = uri.indexOf('?'); - if (q >= 0) uri = uri.substring(0, q); - int h = uri.indexOf('#'); - if (h >= 0) uri = uri.substring(0, h); - uri = uri.replace("\0", ""); - if (uri.startsWith("/")) uri = uri.substring(1); - // Path traversal check - File root = new File(WEB_ROOT).getCanonicalFile(); - File file = new File(root, uri).getCanonicalFile(); - if (!file.toPath().startsWith(root.toPath())) { - fileBytes = "403 Forbidden".getBytes(java.nio.charset.StandardCharsets.UTF_8); - statusCode = 403; - return; - } + File file = new File(WEB_ROOT, uri); + fileBytes = Files.readAllBytes(file.toPath()); - // Read file - if (file.isFile()) { - fileBytes = Files.readAllBytes(file.toPath()); - statusCode = 200; - } else { - File errorFile = new File(WEB_ROOT, "pageNotFound.html"); - if (errorFile.isFile()) { - fileBytes = Files.readAllBytes(errorFile.toPath()); - } else { - fileBytes = "404 Not Found".getBytes(java.nio.charset.StandardCharsets.UTF_8); - } - statusCode = 404; - } } - public void sendGetRequest(OutputStream outputStream, String uri) throws IOException { + public void sendGetRequest(OutputStream outputStream, String uri) throws IOException{ handleGetRequest(uri); HttpResponseBuilder response = new HttpResponseBuilder(); - response.setStatusCode(statusCode); - // Use MimeTypeDetector instead of hardcoded text/html - response.setContentTypeFromFilename(uri); + response.setHeaders(Map.of("Content-Type", "text/html; charset=utf-8")); response.setBody(fileBytes); - outputStream.write(response.build()); - outputStream.flush(); + PrintWriter writer = new PrintWriter(outputStream, true); + writer.println(response.build()); + } -} \ No newline at end of file + +} From 3b34b916c941033ba41fc0744854663b70ee457d Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Tue, 17 Feb 2026 14:19:27 +0100 Subject: [PATCH 27/67] =?UTF-8?q?tester=20f=C3=B6r=20FileCacheTest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/java/org/example/FileCacheTest.java | 117 +++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 src/test/java/org/example/FileCacheTest.java diff --git a/src/test/java/org/example/FileCacheTest.java b/src/test/java/org/example/FileCacheTest.java new file mode 100644 index 00000000..0a825aee --- /dev/null +++ b/src/test/java/org/example/FileCacheTest.java @@ -0,0 +1,117 @@ +package org.example; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class FileCacheTest { + private FileCache cache; + + @BeforeEach + void setUp() { + cache = new FileCache(); + } + + @Test + void testPutAndGet() { + byte[] fileContent = "Hello World".getBytes(); + cache.put("test.html", fileContent); + + byte[] retrieved = cache.get("test.html"); + assertThat(retrieved).isEqualTo(fileContent); + } + + @Test + void testContainsReturnsTrueAfterPut() { + byte[] fileContent = "test content".getBytes(); + cache.put("file.txt", fileContent); + + assertThat(cache.contains("file.txt")).isTrue(); + } + + @Test + void testContainsReturnsFalseForNonExistentKey() { + assertThat(cache.contains("nonexistent.html")).isFalse(); + } + + @Test + void testGetReturnsNullForNonExistentKey() { + byte[] result = cache.get("missing.txt"); + assertThat(result).isNull(); + } + + @Test + void testMultipleFiles() { + byte[] content1 = "Content 1".getBytes(); + byte[] content2 = "Content 2".getBytes(); + byte[] content3 = "Content 3".getBytes(); + + cache.put("file1.html", content1); + cache.put("file2.css", content2); + cache.put("file3.js", content3); + + assertThat(cache.get("file1.html")).isEqualTo(content1); + assertThat(cache.get("file2.css")).isEqualTo(content2); + assertThat(cache.get("file3.js")).isEqualTo(content3); + } + + @Test + void testClear() { + cache.put("file1.html", "content1".getBytes()); + cache.put("file2.html", "content2".getBytes()); + assertThat(cache.size()).isEqualTo(2); + + cache.clear(); + + assertThat(cache.size()).isEqualTo(0); + assertThat(cache.contains("file1.html")).isFalse(); + assertThat(cache.contains("file2.html")).isFalse(); + } + + @Test + void testSize() { + assertThat(cache.size()).isEqualTo(0); + + cache.put("file1.html", "content".getBytes()); + assertThat(cache.size()).isEqualTo(1); + + cache.put("file2.html", "content".getBytes()); + assertThat(cache.size()).isEqualTo(2); + + cache.put("file3.html", "content".getBytes()); + assertThat(cache.size()).isEqualTo(3); + } + + @Test + void testOverwriteExistingKey() { + byte[] oldContent = "old".getBytes(); + byte[] newContent = "new".getBytes(); + + cache.put("file.html", oldContent); + assertThat(cache.get("file.html")).isEqualTo(oldContent); + + cache.put("file.html", newContent); + assertThat(cache.get("file.html")).isEqualTo(newContent); + assertThat(cache.size()).isEqualTo(1); + } + + @Test + void testLargeFileContent() { + byte[] largeContent = new byte[10000]; + for (int i = 0; i < largeContent.length; i++) { + largeContent[i] = (byte) (i % 256); + } + + cache.put("large.bin", largeContent); + assertThat(cache.get("large.bin")).isEqualTo(largeContent); + } + + @Test + void testEmptyByteArray() { + byte[] emptyContent = new byte[0]; + cache.put("empty.txt", emptyContent); + + assertThat(cache.contains("empty.txt")).isTrue(); + assertThat(cache.get("empty.txt")).isEmpty(); + } +} From b2ef693a040c848c610c0dfd1568e0d1f001acb5 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Fri, 20 Feb 2026 16:48:34 +0100 Subject: [PATCH 28/67] updaterat i StaticFileHandler och skapat CacheFilter # Conflicts: # src/main/java/org/example/StaticFileHandler.java --- src/main/java/org/example/CacheFilter.java | 23 ++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/main/java/org/example/CacheFilter.java diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java new file mode 100644 index 00000000..19013322 --- /dev/null +++ b/src/main/java/org/example/CacheFilter.java @@ -0,0 +1,23 @@ +package org.example; + +import java.io.IOException; + +public class CacheFilter { + private final FileCache cache = new FileCache(); + + public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { + if (cache.contains(uri)) { + System.out.println("✓ Cache hit for: " + uri); + return cache.get(uri); + } + System.out.println("✗ Cache miss for: " + uri); + byte[] fileBytes = provider.fetch(uri); + cache.put(uri, fileBytes); + return fileBytes; + } + + @FunctionalInterface + public interface FileProvider { + byte[] fetch(String uri) throws IOException; + } +} From 0a53b7fe8e46e5ee3cb8f1ae21b2d926c4420d6b Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Sat, 21 Feb 2026 17:23:23 +0100 Subject: [PATCH 29/67] vad problem med github --- .../java/org/example/StaticFileHandler.java | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 8708bdd4..e80e5a8e 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -10,12 +10,24 @@ import java.util.Map; public class StaticFileHandler { - private static final String WEB_ROOT = "www"; - private final CacheFilter cacheFilter = new CacheFilter(); + private static final String DEFAULT_WEB_ROOT = "www"; + private final String webRoot; + private final CacheFilter cacheFilter; + + // Standardkonstruktor - använder "www" + public StaticFileHandler() { + this(DEFAULT_WEB_ROOT); + } + + // Konstruktor som tar en anpassad webRoot-sökväg (för tester) + public StaticFileHandler(String webRoot) { + this.webRoot = webRoot; + this.cacheFilter = new CacheFilter(); + } public void sendGetRequest(OutputStream outputStream, String uri) throws IOException { byte[] fileBytes = cacheFilter.getOrFetch(uri, - path -> Files.readAllBytes(new File(WEB_ROOT, path).toPath()) + path -> Files.readAllBytes(new File(webRoot, path).toPath()) ); HttpResponseBuilder response = new HttpResponseBuilder(); From ddd7b4097b50bc188458b2c5c826aadd638a8605 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Sat, 21 Feb 2026 17:26:09 +0100 Subject: [PATCH 30/67] vad problem med github --- .../java/org/example/StaticFileHandler.java | 60 +++++++++++++++++-- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index e80e5a8e..defaa537 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -26,13 +26,65 @@ public StaticFileHandler(String webRoot) { } public void sendGetRequest(OutputStream outputStream, String uri) throws IOException { - byte[] fileBytes = cacheFilter.getOrFetch(uri, - path -> Files.readAllBytes(new File(webRoot, path).toPath()) - ); + try { + // Sanera URI: ta bort frågetecken, hashtaggar, ledande snedstreck och null-bytes + String sanitizedUri = sanitizeUri(uri); + + // Kontrollera för sökvägsgenomgång-attacker + if (isPathTraversal(sanitizedUri)) { + sendErrorResponse(outputStream, 403, "Forbidden"); + return; + } + + byte[] fileBytes = cacheFilter.getOrFetch(sanitizedUri, + path -> Files.readAllBytes(new File(webRoot, path).toPath()) + ); + + HttpResponseBuilder response = new HttpResponseBuilder(); + response.setHeaders(Map.of("Content-Type", "text/html; charset=utf-8")); + response.setBody(fileBytes); + PrintWriter writer = new PrintWriter(outputStream, true); + writer.println(response.build()); + + } catch (IOException e) { + // Hantera saknad fil och andra IO-fel + try { + sendErrorResponse(outputStream, 404, "Not Found"); + } catch (IOException ex) { + ex.printStackTrace(); + } + } + } + + private String sanitizeUri(String uri) { + // Ta bort frågesträngar (?) + uri = uri.split("\\?")[0]; + + // Ta bort fragment (#) + uri = uri.split("#")[0]; + + // Ta bort null-bytes + uri = uri.replace("\0", ""); + + // Ta bort ledande snedstreck + while (uri.startsWith("/")) { + uri = uri.substring(1); + } + return uri; + } + + private boolean isPathTraversal(String uri) { + // Kontrollera för kataloggenomgång-försök + return uri.contains("..") || uri.contains("~"); + } + + private void sendErrorResponse(OutputStream outputStream, int statusCode, String statusMessage) throws IOException { HttpResponseBuilder response = new HttpResponseBuilder(); + response.setStatusCode(statusCode); response.setHeaders(Map.of("Content-Type", "text/html; charset=utf-8")); - response.setBody(fileBytes); + String body = "

" + statusCode + " " + statusMessage + "

"; + response.setBody(body); PrintWriter writer = new PrintWriter(outputStream, true); writer.println(response.build()); } From 0c47d58f730e3c17112e22e07ca93166b52b0ee9 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Sat, 21 Feb 2026 17:33:26 +0100 Subject: [PATCH 31/67] nu ska det funka --- .../java/org/example/StaticFileHandler.java | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index defaa537..652b36b7 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -5,8 +5,9 @@ import java.io.File; import java.io.IOException; import java.io.OutputStream; -import java.io.PrintWriter; import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Map; public class StaticFileHandler { @@ -30,21 +31,21 @@ public void sendGetRequest(OutputStream outputStream, String uri) throws IOExcep // Sanera URI: ta bort frågetecken, hashtaggar, ledande snedstreck och null-bytes String sanitizedUri = sanitizeUri(uri); - // Kontrollera för sökvägsgenomgång-attacker + // Kontrollera för sökvägsgenomgång-attacker med Path normalisering if (isPathTraversal(sanitizedUri)) { sendErrorResponse(outputStream, 403, "Forbidden"); return; } - byte[] fileBytes = cacheFilter.getOrFetch(sanitizedUri, + byte[] fileBytes = cacheFilter.getOrFetch(sanitizedUri, path -> Files.readAllBytes(new File(webRoot, path).toPath()) ); HttpResponseBuilder response = new HttpResponseBuilder(); - response.setHeaders(Map.of("Content-Type", "text/html; charset=utf-8")); + response.setHeaders(Map.of("Content-Type", "text/html; charset=UTF-8")); response.setBody(fileBytes); - PrintWriter writer = new PrintWriter(outputStream, true); - writer.println(response.build()); + outputStream.write(response.build()); + outputStream.flush(); } catch (IOException e) { // Hantera saknad fil och andra IO-fel @@ -76,16 +77,26 @@ private String sanitizeUri(String uri) { private boolean isPathTraversal(String uri) { // Kontrollera för kataloggenomgång-försök - return uri.contains("..") || uri.contains("~"); + try { + // Normalisera sökvägen för att detektera traversal-försök + Path webRootPath = Paths.get(webRoot).toRealPath(); + Path requestedPath = webRootPath.resolve(uri).normalize(); + + // Om den normaliserade sökvägen är utanför webRoot, är det path traversal + return !requestedPath.startsWith(webRootPath); + } catch (IOException e) { + // Om något går fel vid normalisering, behandla det som potentiell path traversal + return true; + } } private void sendErrorResponse(OutputStream outputStream, int statusCode, String statusMessage) throws IOException { HttpResponseBuilder response = new HttpResponseBuilder(); response.setStatusCode(statusCode); - response.setHeaders(Map.of("Content-Type", "text/html; charset=utf-8")); + response.setHeaders(Map.of("Content-Type", "text/html; charset=UTF-8")); String body = "

" + statusCode + " " + statusMessage + "

"; response.setBody(body); - PrintWriter writer = new PrintWriter(outputStream, true); - writer.println(response.build()); + outputStream.write(response.build()); + outputStream.flush(); } } From 78f7e21a1fffcf731bd5087425fcd24ed3cbfea4 Mon Sep 17 00:00:00 2001 From: viktorlindell12 Date: Mon, 23 Feb 2026 09:32:43 +0100 Subject: [PATCH 32/67] Resolve port: CLI > config > default (#29) * Resolve port: CLI > config > default * Wire port resolution to AppConfig/ConfigLoader and update docs/tests * Update PortConfigurationGuide.md * Update PortConfigurationGuide.md * Remove ServerPortResolver; use ConfigLoader for port * Update PortConfigurationGuide.md * Update PortConfigurationGuide.md * may be done --- PortConfigurationGuide.md | 49 +++++++++++++++++++ src/main/java/org/example/App.java | 41 +++++++++++++++- .../org/example/AppPortResolutionTest.java | 21 ++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 PortConfigurationGuide.md create mode 100644 src/test/java/org/example/AppPortResolutionTest.java diff --git a/PortConfigurationGuide.md b/PortConfigurationGuide.md new file mode 100644 index 00000000..622b5777 --- /dev/null +++ b/PortConfigurationGuide.md @@ -0,0 +1,49 @@ +# Konfiguration: port (CLI → config-fil → default) + +Det här projektet väljer vilken port servern ska starta på enligt följande prioritet: + +1. **CLI-argument** (`--port `) – högst prioritet +2. **Config-fil** (`application.yml`: `server.port`) +3. **Default** (`8080`) – används om port saknas i config eller om config-filen saknas + +--- + +## 1) Default-värde + +Om varken CLI eller config anger port används: + +- **8080** (default för `server.port` i `AppConfig`) + +--- + +## 2) Config-fil: `application.yml` + +### Var ska filen ligga? +Standard: +- `src/main/resources/application.yml` + +### Exempel +```yaml +server: +port: 9090 +``` + +--- + +## 3) CLI-argument + +CLI kan användas för att override:a config: + +```bash +java -cp target/classes org.example.App --port 8000 +``` + +--- + +## 4) Sammanfattning + +Prioritet: + +1. CLI (`--port`) +2. `application.yml` (`server.port`) +3. Default (`8080`) \ No newline at end of file diff --git a/src/main/java/org/example/App.java b/src/main/java/org/example/App.java index 408a1942..75c2914b 100644 --- a/src/main/java/org/example/App.java +++ b/src/main/java/org/example/App.java @@ -6,11 +6,50 @@ import java.nio.file.Path; public class App { + + private static final String PORT_FLAG = "--port"; + public static void main(String[] args) { Path configPath = Path.of("src/main/resources/application.yml"); AppConfig appConfig = ConfigLoader.loadOnce(configPath); - int port = appConfig.server().port(); + + int port = resolvePort(args, appConfig.server().port()); + new TcpServer(port).start(); } + + static int resolvePort(String[] args, int configPort) { + Integer cliPort = parsePortFromCli(args); + if (cliPort != null) { + return validatePort(cliPort, "CLI argument " + PORT_FLAG); + } + return validatePort(configPort, "configuration server.port"); + } + + static Integer parsePortFromCli(String[] args) { + if (args == null) return null; + + for (int i = 0; i < args.length; i++) { + if (PORT_FLAG.equals(args[i])) { + int valueIndex = i + 1; + if (valueIndex >= args.length) { + throw new IllegalArgumentException("Missing value after " + PORT_FLAG); + } + try { + return Integer.parseInt(args[valueIndex]); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid port value after " + PORT_FLAG + ": " + args[valueIndex], e); + } + } + } + return null; + } + + static int validatePort(int port, String source) { + if (port < 1 || port > 65535) { + throw new IllegalArgumentException("Port out of range (1-65535) from " + source + ": " + port); + } + return port; + } } diff --git a/src/test/java/org/example/AppPortResolutionTest.java b/src/test/java/org/example/AppPortResolutionTest.java new file mode 100644 index 00000000..a406b6f1 --- /dev/null +++ b/src/test/java/org/example/AppPortResolutionTest.java @@ -0,0 +1,21 @@ +package org.example; + + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class AppPortResolutionTest { + + @Test + void cli_port_wins_over_config() { + int port = App.resolvePort(new String[]{"--port", "8000"}, 9090); + assertThat(port).isEqualTo(8000); + } + + @Test + void config_port_used_when_no_cli_arg() { + int port = App.resolvePort(new String[]{}, 9090); + assertThat(port).isEqualTo(9090); + } +} \ No newline at end of file From dd0530ae51895de55d8cdba16c2150246c3b5be7 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Mon, 23 Feb 2026 13:48:08 +0100 Subject: [PATCH 33/67] =?UTF-8?q?tog=20i=20bort=20cache=20git=20f=C3=B6r?= =?UTF-8?q?=20missar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/example/CacheFilter.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java index 19013322..b3aa1162 100644 --- a/src/main/java/org/example/CacheFilter.java +++ b/src/main/java/org/example/CacheFilter.java @@ -7,10 +7,9 @@ public class CacheFilter { public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { if (cache.contains(uri)) { - System.out.println("✓ Cache hit for: " + uri); + System.out.println("Cache hit for: " + uri); return cache.get(uri); } - System.out.println("✗ Cache miss for: " + uri); byte[] fileBytes = provider.fetch(uri); cache.put(uri, fileBytes); return fileBytes; From 7f73baf3818889b91a9e45434db3e860b451745c Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Mon, 23 Feb 2026 13:50:10 +0100 Subject: [PATCH 34/67] la till concurrentHashMap i rad 8 --- src/main/java/org/example/FileCache.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/example/FileCache.java b/src/main/java/org/example/FileCache.java index 5783ce4d..7d2a4cf2 100644 --- a/src/main/java/org/example/FileCache.java +++ b/src/main/java/org/example/FileCache.java @@ -2,9 +2,10 @@ import java.util.HashMap; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; public class FileCache { - private final Map cache = new HashMap<>(); + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); public boolean contains (String key) { return cache.containsKey(key); From 86f2ba725a9362d9929e6a824149a42ea8aade7d Mon Sep 17 00:00:00 2001 From: Ebba Andersson Date: Mon, 23 Feb 2026 14:47:45 +0100 Subject: [PATCH 35/67] Refactor status codes to constants #71 (#77) * refactor: add predefined HTTP status code constants to HttpResponseBuilder * refactor: use status code constants in StaticFileHandler * test: refactor StaticFileHandlerTest to use status code constants * test: refactor HttpResponseBuilderTest to use status code constants and explicit assertations --- .../java/org/example/StaticFileHandler.java | 9 +-- .../org/example/http/HttpResponseBuilder.java | 64 +++++++++++++------ .../org/example/StaticFileHandlerTest.java | 13 ++-- .../example/http/HttpResponseBuilderTest.java | 22 ++++++- 4 files changed, 77 insertions(+), 31 deletions(-) diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 756c87fd..8bcda375 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -1,6 +1,7 @@ package org.example; import org.example.http.HttpResponseBuilder; +import static org.example.http.HttpResponseBuilder.*; import java.io.File; import java.io.IOException; @@ -36,14 +37,14 @@ private void handleGetRequest(String uri) throws IOException { File file = new File(root, uri).getCanonicalFile(); if (!file.toPath().startsWith(root.toPath())) { fileBytes = "403 Forbidden".getBytes(java.nio.charset.StandardCharsets.UTF_8); - statusCode = 403; + statusCode = SC_FORBIDDEN; return; } // Read file if (file.isFile()) { fileBytes = Files.readAllBytes(file.toPath()); - statusCode = 200; + statusCode = SC_OK; } else { File errorFile = new File(WEB_ROOT, "pageNotFound.html"); if (errorFile.isFile()) { @@ -51,7 +52,7 @@ private void handleGetRequest(String uri) throws IOException { } else { fileBytes = "404 Not Found".getBytes(java.nio.charset.StandardCharsets.UTF_8); } - statusCode = 404; + statusCode = SC_NOT_FOUND; } } @@ -65,4 +66,4 @@ public void sendGetRequest(OutputStream outputStream, String uri) throws IOExcep outputStream.write(response.build()); outputStream.flush(); } -} \ No newline at end of file +} diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java index 8f55ebf2..e84579e9 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -6,8 +6,35 @@ public class HttpResponseBuilder { + // SUCCESS + public static final int SC_OK = 200; + public static final int SC_CREATED = 201; + public static final int SC_NO_CONTENT = 204; + + // REDIRECTION + public static final int SC_MOVED_PERMANENTLY = 301; + public static final int SC_FOUND = 302; + public static final int SC_SEE_OTHER = 303; + public static final int SC_NOT_MODIFIED = 304; + public static final int SC_TEMPORARY_REDIRECT = 307; + public static final int SC_PERMANENT_REDIRECT = 308; + + // CLIENT ERROR + public static final int SC_BAD_REQUEST = 400; + public static final int SC_UNAUTHORIZED = 401; + public static final int SC_FORBIDDEN = 403; + public static final int SC_NOT_FOUND = 404; + + // SERVER ERROR + public static final int SC_INTERNAL_SERVER_ERROR = 500; + public static final int SC_BAD_GATEWAY = 502; + public static final int SC_SERVICE_UNAVAILABLE = 503; + public static final int SC_GATEWAY_TIMEOUT = 504; + + + private static final String PROTOCOL = "HTTP/1.1"; - private int statusCode = 200; + private int statusCode = SC_OK; private String body = ""; private byte[] bytebody; private Map headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); @@ -15,22 +42,23 @@ public class HttpResponseBuilder { private static final String CRLF = "\r\n"; private static final Map REASON_PHRASES = Map.ofEntries( - Map.entry(200, "OK"), - Map.entry(201, "Created"), - Map.entry(204, "No Content"), - Map.entry(301, "Moved Permanently"), - Map.entry(302, "Found"), - Map.entry(303, "See Other"), - Map.entry(304, "Not Modified"), - Map.entry(307, "Temporary Redirect"), - Map.entry(308, "Permanent Redirect"), - Map.entry(400, "Bad Request"), - Map.entry(401, "Unauthorized"), - Map.entry(403, "Forbidden"), - Map.entry(404, "Not Found"), - Map.entry(500, "Internal Server Error"), - Map.entry(502, "Bad Gateway"), - Map.entry(503, "Service Unavailable") + Map.entry(SC_OK, "OK"), + Map.entry(SC_CREATED, "Created"), + Map.entry(SC_NO_CONTENT, "No Content"), + Map.entry(SC_MOVED_PERMANENTLY, "Moved Permanently"), + Map.entry(SC_FOUND, "Found"), + Map.entry(SC_SEE_OTHER, "See Other"), + Map.entry(SC_NOT_MODIFIED, "Not Modified"), + Map.entry(SC_TEMPORARY_REDIRECT, "Temporary Redirect"), + Map.entry(SC_PERMANENT_REDIRECT, "Permanent Redirect"), + Map.entry(SC_BAD_REQUEST, "Bad Request"), + Map.entry(SC_UNAUTHORIZED, "Unauthorized"), + Map.entry(SC_FORBIDDEN, "Forbidden"), + Map.entry(SC_NOT_FOUND, "Not Found"), + Map.entry(SC_INTERNAL_SERVER_ERROR, "Internal Server Error"), + Map.entry(SC_BAD_GATEWAY, "Bad Gateway"), + Map.entry(SC_SERVICE_UNAVAILABLE, "Service Unavailable"), + Map.entry(SC_GATEWAY_TIMEOUT, "Gateway Timeout") ); public void setStatusCode(int statusCode) { @@ -106,4 +134,4 @@ public byte[] build() { return response; } -} \ No newline at end of file +} diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java index 1fe4893b..ce6feb7a 100644 --- a/src/test/java/org/example/StaticFileHandlerTest.java +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -10,6 +10,7 @@ import java.nio.file.Files; import java.nio.file.Path; import static org.junit.jupiter.api.Assertions.*; +import static org.example.http.HttpResponseBuilder.*; /** * Unit test class for verifying the behavior of the StaticFileHandler class. @@ -48,7 +49,7 @@ void test_file_that_exists_should_return_200() throws IOException { //Assert String response = fakeOutput.toString();//Converts the captured byte stream into a String for verification - assertTrue(response.contains("HTTP/1.1 200 OK")); // Assert the status + assertTrue(response.contains("HTTP/1.1 " + SC_OK + " OK")); // Assert the status assertTrue(response.contains("Hello Test")); //Assert the content in the file assertTrue(response.contains("Content-Type: text/html; charset=UTF-8")); // Verify the correct Content-type header @@ -74,7 +75,7 @@ void test_file_that_does_not_exists_should_return_404() throws IOException { //Assert String response = fakeOutput.toString();//Converts the captured byte stream into a String for verification - assertTrue(response.contains("HTTP/1.1 404 Not Found")); // Assert the status + assertTrue(response.contains("HTTP/1.1 " + SC_NOT_FOUND + " Not Found")); // Assert the status } @@ -94,7 +95,7 @@ void test_path_traversal_should_return_403() throws IOException { // Assert String response = fakeOutput.toString(); assertFalse(response.contains("TOP SECRET")); - assertTrue(response.contains("HTTP/1.1 403 Forbidden")); + assertTrue(response.contains("HTTP/1.1 " + SC_FORBIDDEN + " Forbidden")); } @ParameterizedTest @@ -115,7 +116,7 @@ void sanitized_uris_should_return_200(String uri) throws IOException { handler.sendGetRequest(out, uri); // Assert - assertTrue(out.toString().contains("HTTP/1.1 200 OK")); + assertTrue(out.toString().contains("HTTP/1.1 " + SC_OK + " OK")); } @Test @@ -131,7 +132,7 @@ void null_byte_injection_should_not_return_200() throws IOException { // Assert String response = out.toString(); - assertFalse(response.contains("HTTP/1.1 200 OK")); - assertTrue(response.contains("HTTP/1.1 404 Not Found")); + assertFalse(response.contains("HTTP/1.1 " + SC_OK + " OK")); + assertTrue(response.contains("HTTP/1.1 " + SC_NOT_FOUND + " Not Found")); } } diff --git a/src/test/java/org/example/http/HttpResponseBuilderTest.java b/src/test/java/org/example/http/HttpResponseBuilderTest.java index b278ae19..5363d5cf 100644 --- a/src/test/java/org/example/http/HttpResponseBuilderTest.java +++ b/src/test/java/org/example/http/HttpResponseBuilderTest.java @@ -14,6 +14,7 @@ import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; +import static org.example.http.HttpResponseBuilder.*; class HttpResponseBuilderTest { @@ -25,13 +26,14 @@ private String asString(byte[] response) { @Test void build_returnsValidHttpResponse() { HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(SC_OK); builder.setBody("Hello"); byte[] result = builder.build(); String resultStr = asString(result); assertThat(resultStr) - .contains("HTTP/1.1 200 OK") + .contains("HTTP/1.1 " + SC_OK + " OK") .contains("Content-Length: 5") .contains("\r\n\r\n") .contains("Hello"); @@ -49,6 +51,7 @@ void build_returnsValidHttpResponse() { @DisplayName("Should calculate correct Content-Length for various strings") void build_handlesUtf8ContentLength(String body, int expectedLength) { HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(SC_OK); builder.setBody(body); byte[] result = builder.build(); @@ -62,6 +65,7 @@ void build_handlesUtf8ContentLength(String body, int expectedLength) { void setHeader_addsHeaderToResponse() { HttpResponseBuilder builder = new HttpResponseBuilder(); builder.setHeader("Content-Type", "text/html; charset=UTF-8"); + builder.setStatusCode(SC_OK); builder.setBody("Hello"); byte[] result = builder.build(); @@ -76,6 +80,7 @@ void setHeader_allowsMultipleHeaders() { HttpResponseBuilder builder = new HttpResponseBuilder(); builder.setHeader("Content-Type", "application/json"); builder.setHeader("Cache-Control", "no-cache"); + builder.setStatusCode(SC_OK); builder.setBody("{}"); byte[] result = builder.build(); @@ -107,6 +112,7 @@ void setHeader_allowsMultipleHeaders() { void setContentTypeFromFilename_detectsVariousTypes(String filename, String expectedContentType) { HttpResponseBuilder builder = new HttpResponseBuilder(); builder.setContentTypeFromFilename(filename); + builder.setStatusCode(SC_OK); builder.setBody("test content"); byte[] result = builder.build(); @@ -130,6 +136,7 @@ void setContentTypeFromFilename_detectsVariousTypes(String filename, String expe void setContentTypeFromFilename_allCases(String filename, String expectedContentType) { HttpResponseBuilder builder = new HttpResponseBuilder(); builder.setContentTypeFromFilename(filename); + builder.setStatusCode(SC_OK); builder.setBody("test"); byte[] result = builder.build(); @@ -143,6 +150,7 @@ void setContentTypeFromFilename_allCases(String filename, String expectedContent @DisplayName("Should not duplicate headers when manually set") void build_doesNotDuplicateHeaders(String headerName, String manualValue, String bodyContent) { HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(SC_OK); builder.setHeader(headerName, manualValue); builder.setBody(bodyContent); @@ -179,6 +187,7 @@ void setHeaders_preservesCaseInsensitivity() { builder.setHeaders(headers); builder.setHeader("Content-Length", "100"); + builder.setStatusCode(SC_OK); builder.setBody("Hello"); byte[] result = builder.build(); @@ -287,6 +296,7 @@ void build_handlesUnknownStatusCodes(int statusCode) { @DisplayName("Should auto-append headers when not manually set") void build_autoAppendsHeadersWhenNotSet() { HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(SC_OK); builder.setBody("Hello"); byte[] result = builder.build(); @@ -303,6 +313,7 @@ void build_combinesCustomAndAutoHeaders() { HttpResponseBuilder builder = new HttpResponseBuilder(); builder.setHeader("Content-Type", "text/html"); builder.setHeader("Cache-Control", "no-cache"); + builder.setStatusCode(SC_OK); builder.setBody("Hello"); byte[] result = builder.build(); @@ -328,6 +339,7 @@ void setHeader_caseInsensitive(String headerName, String headerValue) { HttpResponseBuilder builder = new HttpResponseBuilder(); builder.setHeader(headerName, headerValue); + builder.setStatusCode(SC_OK); builder.setBody("Hello"); byte[] result = builder.build(); @@ -350,13 +362,14 @@ void setHeader_caseInsensitive(String headerName, String headerValue) { @DisplayName("Should handle empty and null body") void build_emptyAndNullBody(String body, int expectedLength) { HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(SC_OK); builder.setBody(body); byte[] result = builder.build(); String resultStr = asString(result); assertThat(resultStr) - .contains("HTTP/1.1 200 OK") + .contains("HTTP/1.1 " + SC_OK + " OK") .contains("Content-Length: " + expectedLength); } @@ -373,6 +386,7 @@ void setHeader_overridesPreviousValue(String headerName, String firstValue, Stri HttpResponseBuilder builder = new HttpResponseBuilder(); builder.setHeader(headerName, firstValue); builder.setHeader(headerName, secondValue); // Override + builder.setStatusCode(SC_OK); builder.setBody("Test"); byte[] result = builder.build(); @@ -390,6 +404,7 @@ void setHeader_overridesPreviousValue(String headerName, String firstValue, Stri void build_autoAppendsSpecificHeaders(String body, boolean setContentLength, boolean setConnection, String expectedContentLength, String expectedConnection) { HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(SC_OK); if (setContentLength) { builder.setHeader("Content-Length", "999"); @@ -425,6 +440,7 @@ private static Stream provideAutoAppendScenarios() { @DisplayName("Should preserve binary content without corruption") void build_preservesBinaryContent() { HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(SC_OK); // Create binary data with non-UTF-8 bytes byte[] binaryData = new byte[]{ @@ -455,4 +471,4 @@ void build_preservesBinaryContent() { assertThat(actualBody).isEqualTo(binaryData); } -} \ No newline at end of file +} From 0a3c08ca155d93b6953c26cd3e84faa57e4a7fb6 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Mon, 23 Feb 2026 15:59:54 +0100 Subject: [PATCH 36/67] refactored caching by removing FileCache completely; implemented an improved CacheFilter with LRU eviction; updated StaticFileHandler to use shared cache; updated and added tests accordingly --- src/main/java/org/example/CacheFilter.java | 173 +++++++++++++++++- src/main/java/org/example/FileCache.java | 29 --- .../java/org/example/StaticFileHandler.java | 34 ++-- src/test/java/org/example/FileCacheTest.java | 117 ------------ .../org/example/StaticFileHandlerTest.java | 106 +++++++++++ 5 files changed, 289 insertions(+), 170 deletions(-) delete mode 100644 src/main/java/org/example/FileCache.java delete mode 100644 src/test/java/org/example/FileCacheTest.java diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java index b3aa1162..82d128bb 100644 --- a/src/main/java/org/example/CacheFilter.java +++ b/src/main/java/org/example/CacheFilter.java @@ -1,20 +1,181 @@ package org.example; import java.io.IOException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +/** + * Thread-safe cache filter using ConcurrentHashMap + * Handles caching with LRU eviction for large files + */ public class CacheFilter { - private final FileCache cache = new FileCache(); + private static final int MAX_CACHE_ENTRIES = 100; + private static final long MAX_CACHE_BYTES = 50 * 1024 * 1024; // 50MB + // Lock-free concurrent cache + private final ConcurrentHashMap cache = + new ConcurrentHashMap<>(16, 0.75f, 16); // 16 segments för concurrency + + private final AtomicLong currentBytes = new AtomicLong(0); + + /** + * Cache entry med metadata för LRU tracking + */ + private static class CacheEntry { + final byte[] data; + final AtomicLong lastAccessTime; + final AtomicLong accessCount; + + CacheEntry(byte[] data) { + this.data = data; + this.lastAccessTime = new AtomicLong(System.currentTimeMillis()); + this.accessCount = new AtomicLong(0); + } + + void recordAccess() { + accessCount.incrementAndGet(); + lastAccessTime.set(System.currentTimeMillis()); + } + } + + /** + * Hämta från cache eller fetch från provider (thread-safe) + */ public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { - if (cache.contains(uri)) { - System.out.println("Cache hit for: " + uri); - return cache.get(uri); + // Kontrollera cache (lock-free read) + if (cache.containsKey(uri)) { + CacheEntry entry = cache.get(uri); + if (entry != null) { + entry.recordAccess(); + System.out.println(" Cache hit for: " + uri); + return entry.data; + } } + + // Cache miss - fetch från provider + System.out.println(" Cache miss for: " + uri); byte[] fileBytes = provider.fetch(uri); - cache.put(uri, fileBytes); + + // Lägg till i cache + addToCache(uri, fileBytes); + return fileBytes; } - + + /** + * Lägg till i cache med eviction om nödvändigt (thread-safe) + */ + private synchronized void addToCache(String uri, byte[] data) { + // Kontrollera om vi måste evicta innan vi lägger till + while (shouldEvict(data)) { + evictLeastRecentlyUsed(); + } + + CacheEntry newEntry = new CacheEntry(data); + CacheEntry oldEntry = cache.put(uri, newEntry); + + // Uppdatera byte-count + if (oldEntry != null) { + currentBytes.addAndGet(-oldEntry.data.length); + } + currentBytes.addAndGet(data.length); + } + + /** + * Kontrollera om vi behöver evicta + */ + private boolean shouldEvict(byte[] newValue) { + return cache.size() >= MAX_CACHE_ENTRIES || + (currentBytes.get() + newValue.length) > MAX_CACHE_BYTES; + } + + /** + * Evicta minst nyligen använd entry + */ + private void evictLeastRecentlyUsed() { + if (cache.isEmpty()) return; + + // Hitta entry med minst senaste access + String keyToRemove = cache.entrySet().stream() + .min((a, b) -> Long.compare( + a.getValue().lastAccessTime.get(), + b.getValue().lastAccessTime.get() + )) + .map(java.util.Map.Entry::getKey) + .orElse(null); + + if (keyToRemove != null) { + CacheEntry removed = cache.remove(keyToRemove); + if (removed != null) { + currentBytes.addAndGet(-removed.data.length); + System.out.println("✗ Evicted from cache: " + keyToRemove + + " (accesses: " + removed.accessCount.get() + ")"); + } + } + } + + // Diagnostik-metoder + public int getCacheSize() { + return cache.size(); + } + + public long getCurrentBytes() { + return currentBytes.get(); + } + + public long getMaxBytes() { + return MAX_CACHE_BYTES; + } + + public double getCacheUtilization() { + return (double) currentBytes.get() / MAX_CACHE_BYTES * 100; + } + + public void clearCache() { + cache.clear(); + currentBytes.set(0); + } + + public CacheStats getStats() { + long totalAccesses = cache.values().stream() + .mapToLong(e -> e.accessCount.get()) + .sum(); + + return new CacheStats( + cache.size(), + currentBytes.get(), + MAX_CACHE_ENTRIES, + MAX_CACHE_BYTES, + totalAccesses + ); + } + + // Stats-klass + public static class CacheStats { + public final int entries; + public final long bytes; + public final int maxEntries; + public final long maxBytes; + public final long totalAccesses; + + CacheStats(int entries, long bytes, int maxEntries, long maxBytes, long totalAccesses) { + this.entries = entries; + this.bytes = bytes; + this.maxEntries = maxEntries; + this.maxBytes = maxBytes; + this.totalAccesses = totalAccesses; + } + + @Override + public String toString() { + return String.format( + "CacheStats{entries=%d/%d, bytes=%d/%d, utilization=%.1f%%, accesses=%d}", + entries, maxEntries, bytes, maxBytes, + (double) bytes / maxBytes * 100, totalAccesses + ); + } + } + @FunctionalInterface public interface FileProvider { byte[] fetch(String uri) throws IOException; diff --git a/src/main/java/org/example/FileCache.java b/src/main/java/org/example/FileCache.java deleted file mode 100644 index 7d2a4cf2..00000000 --- a/src/main/java/org/example/FileCache.java +++ /dev/null @@ -1,29 +0,0 @@ -package org.example; - -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -public class FileCache { - private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); - - public boolean contains (String key) { - return cache.containsKey(key); - } - - public byte[] get(String key) { - return cache.get(key); - } - - public void put(String key, byte[] value){ - cache.put(key, value); - } - public void clear() { - cache.clear(); - } - - public int size() { - return cache.size(); - } - -} diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 652b36b7..ee5030c5 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -12,32 +12,32 @@ public class StaticFileHandler { private static final String DEFAULT_WEB_ROOT = "www"; + + // ✅ EN shared cache för alla threads + private static final CacheFilter SHARED_CACHE = new CacheFilter(); + private final String webRoot; - private final CacheFilter cacheFilter; - // Standardkonstruktor - använder "www" public StaticFileHandler() { this(DEFAULT_WEB_ROOT); } - // Konstruktor som tar en anpassad webRoot-sökväg (för tester) + public StaticFileHandler(String webRoot) { this.webRoot = webRoot; - this.cacheFilter = new CacheFilter(); } public void sendGetRequest(OutputStream outputStream, String uri) throws IOException { try { - // Sanera URI: ta bort frågetecken, hashtaggar, ledande snedstreck och null-bytes String sanitizedUri = sanitizeUri(uri); - // Kontrollera för sökvägsgenomgång-attacker med Path normalisering if (isPathTraversal(sanitizedUri)) { sendErrorResponse(outputStream, 403, "Forbidden"); return; } - byte[] fileBytes = cacheFilter.getOrFetch(sanitizedUri, + // Använd shared cache istället för ny instans + byte[] fileBytes = SHARED_CACHE.getOrFetch(sanitizedUri, path -> Files.readAllBytes(new File(webRoot, path).toPath()) ); @@ -48,7 +48,6 @@ public void sendGetRequest(OutputStream outputStream, String uri) throws IOExcep outputStream.flush(); } catch (IOException e) { - // Hantera saknad fil och andra IO-fel try { sendErrorResponse(outputStream, 404, "Not Found"); } catch (IOException ex) { @@ -58,16 +57,10 @@ public void sendGetRequest(OutputStream outputStream, String uri) throws IOExcep } private String sanitizeUri(String uri) { - // Ta bort frågesträngar (?) uri = uri.split("\\?")[0]; - - // Ta bort fragment (#) uri = uri.split("#")[0]; - - // Ta bort null-bytes uri = uri.replace("\0", ""); - // Ta bort ledande snedstreck while (uri.startsWith("/")) { uri = uri.substring(1); } @@ -76,16 +69,12 @@ private String sanitizeUri(String uri) { } private boolean isPathTraversal(String uri) { - // Kontrollera för kataloggenomgång-försök try { - // Normalisera sökvägen för att detektera traversal-försök Path webRootPath = Paths.get(webRoot).toRealPath(); Path requestedPath = webRootPath.resolve(uri).normalize(); - // Om den normaliserade sökvägen är utanför webRoot, är det path traversal return !requestedPath.startsWith(webRootPath); } catch (IOException e) { - // Om något går fel vid normalisering, behandla det som potentiell path traversal return true; } } @@ -99,4 +88,13 @@ private void sendErrorResponse(OutputStream outputStream, int statusCode, String outputStream.write(response.build()); outputStream.flush(); } + + //Diagnostik-metod + public static CacheFilter.CacheStats getCacheStats() { + return SHARED_CACHE.getStats(); + } + + public static void clearCache() { + SHARED_CACHE.clearCache(); + } } diff --git a/src/test/java/org/example/FileCacheTest.java b/src/test/java/org/example/FileCacheTest.java deleted file mode 100644 index 0a825aee..00000000 --- a/src/test/java/org/example/FileCacheTest.java +++ /dev/null @@ -1,117 +0,0 @@ -package org.example; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import static org.assertj.core.api.Assertions.*; - -class FileCacheTest { - private FileCache cache; - - @BeforeEach - void setUp() { - cache = new FileCache(); - } - - @Test - void testPutAndGet() { - byte[] fileContent = "Hello World".getBytes(); - cache.put("test.html", fileContent); - - byte[] retrieved = cache.get("test.html"); - assertThat(retrieved).isEqualTo(fileContent); - } - - @Test - void testContainsReturnsTrueAfterPut() { - byte[] fileContent = "test content".getBytes(); - cache.put("file.txt", fileContent); - - assertThat(cache.contains("file.txt")).isTrue(); - } - - @Test - void testContainsReturnsFalseForNonExistentKey() { - assertThat(cache.contains("nonexistent.html")).isFalse(); - } - - @Test - void testGetReturnsNullForNonExistentKey() { - byte[] result = cache.get("missing.txt"); - assertThat(result).isNull(); - } - - @Test - void testMultipleFiles() { - byte[] content1 = "Content 1".getBytes(); - byte[] content2 = "Content 2".getBytes(); - byte[] content3 = "Content 3".getBytes(); - - cache.put("file1.html", content1); - cache.put("file2.css", content2); - cache.put("file3.js", content3); - - assertThat(cache.get("file1.html")).isEqualTo(content1); - assertThat(cache.get("file2.css")).isEqualTo(content2); - assertThat(cache.get("file3.js")).isEqualTo(content3); - } - - @Test - void testClear() { - cache.put("file1.html", "content1".getBytes()); - cache.put("file2.html", "content2".getBytes()); - assertThat(cache.size()).isEqualTo(2); - - cache.clear(); - - assertThat(cache.size()).isEqualTo(0); - assertThat(cache.contains("file1.html")).isFalse(); - assertThat(cache.contains("file2.html")).isFalse(); - } - - @Test - void testSize() { - assertThat(cache.size()).isEqualTo(0); - - cache.put("file1.html", "content".getBytes()); - assertThat(cache.size()).isEqualTo(1); - - cache.put("file2.html", "content".getBytes()); - assertThat(cache.size()).isEqualTo(2); - - cache.put("file3.html", "content".getBytes()); - assertThat(cache.size()).isEqualTo(3); - } - - @Test - void testOverwriteExistingKey() { - byte[] oldContent = "old".getBytes(); - byte[] newContent = "new".getBytes(); - - cache.put("file.html", oldContent); - assertThat(cache.get("file.html")).isEqualTo(oldContent); - - cache.put("file.html", newContent); - assertThat(cache.get("file.html")).isEqualTo(newContent); - assertThat(cache.size()).isEqualTo(1); - } - - @Test - void testLargeFileContent() { - byte[] largeContent = new byte[10000]; - for (int i = 0; i < largeContent.length; i++) { - largeContent[i] = (byte) (i % 256); - } - - cache.put("large.bin", largeContent); - assertThat(cache.get("large.bin")).isEqualTo(largeContent); - } - - @Test - void testEmptyByteArray() { - byte[] emptyContent = new byte[0]; - cache.put("empty.txt", emptyContent); - - assertThat(cache.contains("empty.txt")).isTrue(); - assertThat(cache.get("empty.txt")).isEmpty(); - } -} diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java index 1fe4893b..c74f59a9 100644 --- a/src/test/java/org/example/StaticFileHandlerTest.java +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -1,5 +1,6 @@ package org.example; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; @@ -10,6 +11,8 @@ import java.nio.file.Files; import java.nio.file.Path; import static org.junit.jupiter.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; + /** * Unit test class for verifying the behavior of the StaticFileHandler class. @@ -25,12 +28,115 @@ */ class StaticFileHandlerTest { + private StaticFileHandler handler; + //Junit creates a temporary folder which can be filled with temporary files that gets removed after tests @TempDir Path tempDir; + @BeforeEach + void setUp() { + // Rensa cache innan varje test för clean state + StaticFileHandler.clearCache(); + } + @Test + void testCaching_HitOnSecondRequest() throws IOException { + // Arrange + Files.writeString(tempDir.resolve("cached.html"), "Content"); + StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); + + // Act - Första anropet (cache miss) + handler.sendGetRequest(new ByteArrayOutputStream(), "cached.html"); + int sizeAfterFirst = StaticFileHandler.getCacheStats().entries; + + // Act - Andra anropet (cache hit) + handler.sendGetRequest(new ByteArrayOutputStream(), "cached.html"); + int sizeAfterSecond = StaticFileHandler.getCacheStats().entries; + + // Assert - Cache ska innehålla samma entry + assertThat(sizeAfterFirst).isEqualTo(1); + assertThat(sizeAfterSecond).isEqualTo(1); + } + + @Test + void testSanitization_QueryString() throws IOException { + // Arrange + Files.writeString(tempDir.resolve("index.html"), "Home"); + StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + // Act - URI med query string + handler.sendGetRequest(output, "index.html?foo=bar"); + + // Assert + assertThat(output.toString()).contains("HTTP/1.1 200"); + } + + @Test + void testSanitization_LeadingSlash() throws IOException { + // Arrange + Files.writeString(tempDir.resolve("page.html"), "Page"); + StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + // Act + handler.sendGetRequest(output, "/page.html"); + + // Assert + assertThat(output.toString()).contains("HTTP/1.1 200"); + } + + @Test + void testSanitization_NullBytes() throws IOException { + // Arrange + StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + // Act + handler.sendGetRequest(output, "file.html\0../../secret"); + + // Assert + assertThat(output.toString()).contains("HTTP/1.1 404"); + } + + @Test + void testConcurrent_MultipleReads() throws InterruptedException, IOException { + // Arrange + Files.writeString(tempDir.resolve("shared.html"), "Data"); + StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); + + // Förvärmning + handler.sendGetRequest(new ByteArrayOutputStream(), "shared.html"); + + // Act - 10 trådar läser samma fil 50 gånger varje + Thread[] threads = new Thread[10]; + for (int i = 0; i < 10; i++) { + threads[i] = new Thread(() -> { + try { + for (int j = 0; j < 50; j++) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + handler.sendGetRequest(out, "shared.html"); + assertThat(out.toString()).contains("200"); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + threads[i].start(); + } + + // Vänta på alla trådar + for (Thread t : threads) { + t.join(); + } + + // Assert - Cache ska bara ha EN entry + assertThat(StaticFileHandler.getCacheStats().entries).isEqualTo(1); + } + +@Test void test_file_that_exists_should_return_200() throws IOException { //Arrange Path testFile = tempDir.resolve("test.html"); // Defines the path in the temp directory From 56cc3e848ce3ed4a0ad502260b74a40cb4b735b2 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Tue, 24 Feb 2026 12:54:56 +0100 Subject: [PATCH 37/67] removed extra whitespace in MAX_CACHE_BYTES declaration --- src/main/java/org/example/CacheFilter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java index 82d128bb..330c0b24 100644 --- a/src/main/java/org/example/CacheFilter.java +++ b/src/main/java/org/example/CacheFilter.java @@ -10,7 +10,7 @@ */ public class CacheFilter { private static final int MAX_CACHE_ENTRIES = 100; - private static final long MAX_CACHE_BYTES = 50 * 1024 * 1024; // 50MB + private static final long MAX_CACHE_BYTES = 50 * 1024 * 1024;// 50MB // Lock-free concurrent cache private final ConcurrentHashMap cache = From d20bb6fc92e1c31457df0eac8a0d4cae99e6f222 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Tue, 24 Feb 2026 12:57:50 +0100 Subject: [PATCH 38/67] removed things not used in code --- src/main/java/org/example/CacheFilter.java | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java index 330c0b24..ad7504fd 100644 --- a/src/main/java/org/example/CacheFilter.java +++ b/src/main/java/org/example/CacheFilter.java @@ -115,22 +115,6 @@ private void evictLeastRecentlyUsed() { } // Diagnostik-metoder - public int getCacheSize() { - return cache.size(); - } - - public long getCurrentBytes() { - return currentBytes.get(); - } - - public long getMaxBytes() { - return MAX_CACHE_BYTES; - } - - public double getCacheUtilization() { - return (double) currentBytes.get() / MAX_CACHE_BYTES * 100; - } - public void clearCache() { cache.clear(); currentBytes.set(0); From 103178ab24538e2112d35f4a82056f97cfe1cef2 Mon Sep 17 00:00:00 2001 From: Johan Karlsson <93186588+gurkvatten@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:19:33 +0100 Subject: [PATCH 39/67] fixed file path (#86) --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index ee7e511e..2976670e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,8 +9,8 @@ FROM eclipse-temurin:25-jre-alpine EXPOSE 8080 RUN addgroup -S appgroup && adduser -S appuser -G appgroup WORKDIR /app/ -COPY --from=build /build/target/classes/ / -COPY --from=build /build/target/dependency/ /dependencies/ +COPY --from=build /build/target/classes/ /app/ +COPY --from=build /build/target/dependency/ /app/dependencies/ COPY /www/ /www/ USER appuser -ENTRYPOINT ["java", "-classpath", "/app:/dependencies/*", "org.example.App"] +ENTRYPOINT ["java", "-classpath", "/app:/app/dependencies/*", "org.example.App"] From e72f0730df605c758a9d3ac8392e54fa8c056200 Mon Sep 17 00:00:00 2001 From: Caroline Nordbrandt Date: Tue, 24 Feb 2026 14:40:55 +0100 Subject: [PATCH 40/67] Fix path in Dockerfile for `www` directory copy operation (#87) * Fix path in Dockerfile for `www` directory copy operation * Correct relative path for `www` directory in Dockerfile copy operation --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 2976670e..635cbbff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,6 @@ RUN addgroup -S appgroup && adduser -S appuser -G appgroup WORKDIR /app/ COPY --from=build /build/target/classes/ /app/ COPY --from=build /build/target/dependency/ /app/dependencies/ -COPY /www/ /www/ +COPY www/ ./www/ USER appuser ENTRYPOINT ["java", "-classpath", "/app:/app/dependencies/*", "org.example.App"] From ff4cd1260195a7446a638409bf2e852e0b2f488a Mon Sep 17 00:00:00 2001 From: Andreas Kaiberger <91787385+apaegs@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:00:45 +0100 Subject: [PATCH 41/67] Feature/27 ipfilter (#70) * Add IpFilter and corresponding test skeleton Co-authored-by: Rickard Ankar * Extend IpFilter with blocklist mode and add unit tests Co-authored-by: Rickard Ankar * Enhance IpFilter to return 403 for blocked IPs and add corresponding test case Co-authored-by: Rickard Ankar * Extend IpFilter with allowlist mode and add corresponding unit tests Co-authored-by: Rickard Ankar * Refactor IpFilter to support both allowlist and blocklist modes, update logic, and add unit tests for allowlist mode Co-authored-by: Rickard Ankar * Handle missing client IP in IpFilter, return 400 response, and add corresponding test case Co-authored-by: Rickard Ankar * Refactor tests in `IpFilterTest` to use `assertAll` for improved assertion grouping and readability Co-authored-by: Rickard Ankar * Refactor `IpFilter` to use `HttpResponse` instead of `HttpResponseBuilder` and update tests accordingly Co-authored-by: Rickard Ankar * Add unit tests for edge cases in `IpFilter` allowlist and blocklist modes Co-authored-by: Rickard Ankar * Refactor `IpFilter` and tests to use `HttpResponseBuilder` instead of `HttpResponse` Co-authored-by: Rickard Ankar * Handle empty client IP in `IpFilter`, return 400 response, and add corresponding test case Co-authored-by: Rickard Ankar * Add comments to `IpFilter` empty methods, clarifying no action is required Co-authored-by: Rickard Ankar * Fix typos in comments within `IpFilterTest` Co-authored-by: Rickard Ankar * Add Javadoc comments to `IpFilter` and `IpFilterTest` for improved clarity and documentation * Refactor `IpFilter` to use thread-safe collections and normalize IP addresses * Make `mode` field in `IpFilter` volatile to ensure thread safety * Ensure UTF-8 encoding for response string in `IpFilterTest` and add attribute management to `HttpRequest` * Ensure UTF-8 encoding for response string in `IpFilterTest` and add attribute management to `HttpRequest` Co-authored-by: Rickard Ankar * Integrate IP filtering into `ConnectionHandler` and update configuration to support filter settings Co-authored-by: Rickard Ankar * Refactor IP filter check in `ConnectionHandler` to use `Boolean.TRUE.equals` for improved null safety Co-authored-by: Rickard Ankar * Validate null inputs for allowed/blocked IPs in `IpFilter`, enhance test coverage, and fix typographical error in comments Co-authored-by: Rickard Ankar * refactor: extract applyFilters() method using FilterChainImpl Co-authored-by: Andreas Kaiberger * refactor: cache filter list at construction time Co-authored-by: Andreas Kaiberger * refactor: cache filter list at construction time Co-authored-by: Andreas Kaiberger * test: verify GPG signing * Replace hardcoded status codes in `IpFilter` and `ConnectionHandler` with constants from `HttpResponseBuilder` for better maintainability Co-authored-by: Rickard Ankar --------- Co-authored-by: Rickard Ankar --- .../java/org/example/ConnectionHandler.java | 80 ++++++- .../java/org/example/config/AppConfig.java | 32 ++- .../java/org/example/filter/IpFilter.java | 108 ++++++++++ .../org/example/http/HttpResponseBuilder.java | 4 + .../org/example/httpparser/HttpRequest.java | 8 + src/main/resources/application.yml | 8 +- .../java/org/example/filter/IpFilterTest.java | 203 ++++++++++++++++++ 7 files changed, 438 insertions(+), 5 deletions(-) create mode 100644 src/main/java/org/example/filter/IpFilter.java create mode 100644 src/test/java/org/example/filter/IpFilterTest.java diff --git a/src/main/java/org/example/ConnectionHandler.java b/src/main/java/org/example/ConnectionHandler.java index 1a0861ff..9fc219d4 100644 --- a/src/main/java/org/example/ConnectionHandler.java +++ b/src/main/java/org/example/ConnectionHandler.java @@ -1,6 +1,15 @@ package org.example; +import org.example.config.AppConfig; +import org.example.filter.IpFilter; import org.example.httpparser.HttpParser; +import org.example.httpparser.HttpRequest; +import java.util.ArrayList; +import java.util.List; +import org.example.filter.Filter; +import org.example.filter.FilterChainImpl; +import org.example.http.HttpResponseBuilder; +import org.example.config.ConfigLoader; import java.io.IOException; import java.net.Socket; @@ -9,21 +18,66 @@ public class ConnectionHandler implements AutoCloseable { Socket client; String uri; + private final List filters; public ConnectionHandler(Socket client) { this.client = client; + this.filters = buildFilters(); } +private List buildFilters() { + List list = new ArrayList<>(); + AppConfig config = ConfigLoader.get(); + AppConfig.IpFilterConfig ipFilterConfig = config.ipFilter(); + if (Boolean.TRUE.equals(ipFilterConfig.enabled())) { + list.add(createIpFilterFromConfig(ipFilterConfig)); + } + // Add more filters here... + return list; + } + public void runConnectionHandler() throws IOException { - StaticFileHandler sfh = new StaticFileHandler(); HttpParser parser = new HttpParser(); parser.setReader(client.getInputStream()); parser.parseRequest(); parser.parseHttp(); + + HttpRequest request = new HttpRequest( + parser.getMethod(), + parser.getUri(), + parser.getVersion(), + parser.getHeadersMap(), + "" + ); + + String clientIp = client.getInetAddress().getHostAddress(); + request.setAttribute("clientIp", clientIp); + + HttpResponseBuilder response = applyFilters(request); + + int statusCode = response.getStatusCode(); + if (statusCode == HttpResponseBuilder.SC_FORBIDDEN || + statusCode == HttpResponseBuilder.SC_BAD_REQUEST) { + byte[] responseBytes = response.build(); + client.getOutputStream().write(responseBytes); + client.getOutputStream().flush(); + return; + } + resolveTargetFile(parser.getUri()); + StaticFileHandler sfh = new StaticFileHandler(); sfh.sendGetRequest(client.getOutputStream(), uri); } + private HttpResponseBuilder applyFilters(HttpRequest request) { + HttpResponseBuilder response = new HttpResponseBuilder(); + + FilterChainImpl chain = new FilterChainImpl(filters); + chain.doFilter(request, response); + + return response; + } + private void resolveTargetFile(String uri) { if (uri.matches("/$")) { //matches(/) this.uri = "index.html"; @@ -39,4 +93,28 @@ private void resolveTargetFile(String uri) { public void close() throws Exception { client.close(); } + + private IpFilter createIpFilterFromConfig(AppConfig.IpFilterConfig config) { + IpFilter filter = new IpFilter(); + + // Set mode + if ("ALLOWLIST".equalsIgnoreCase(config.mode())) { + filter.setMode(IpFilter.FilterMode.ALLOWLIST); + } else { + filter.setMode(IpFilter.FilterMode.BLOCKLIST); + } + + // Add blocked IPs + for (String ip : config.blockedIps()) { + filter.addBlockedIp(ip); + } + + // Add allowed IPs + for (String ip : config.allowedIps()) { + filter.addAllowedIp(ip); + } + + filter.init(); + return filter; + } } diff --git a/src/main/java/org/example/config/AppConfig.java b/src/main/java/org/example/config/AppConfig.java index 00134b20..db689ed5 100644 --- a/src/main/java/org/example/config/AppConfig.java +++ b/src/main/java/org/example/config/AppConfig.java @@ -6,16 +6,22 @@ @JsonIgnoreProperties(ignoreUnknown = true) public record AppConfig( @JsonProperty("server") ServerConfig server, - @JsonProperty("logging") LoggingConfig logging + @JsonProperty("logging") LoggingConfig logging, + @JsonProperty("ipFilter") IpFilterConfig ipFilter ) { public static AppConfig defaults() { - return new AppConfig(ServerConfig.defaults(), LoggingConfig.defaults()); + return new AppConfig( + ServerConfig.defaults(), + LoggingConfig.defaults(), + IpFilterConfig.defaults() + ); } public AppConfig withDefaultsApplied() { ServerConfig serverConfig = (server == null ? ServerConfig.defaults() : server.withDefaultsApplied()); LoggingConfig loggingConfig = (logging == null ? LoggingConfig.defaults() : logging.withDefaultsApplied()); - return new AppConfig(serverConfig, loggingConfig); + IpFilterConfig ipFilterConfig = (ipFilter == null ? IpFilterConfig.defaults() : ipFilter.withDefaultsApplied()); // ← LÄGG TILL + return new AppConfig(serverConfig, loggingConfig, ipFilterConfig); // ← UPPDATERA DENNA RAD } @JsonIgnoreProperties(ignoreUnknown = true) @@ -50,4 +56,24 @@ public LoggingConfig withDefaultsApplied() { return new LoggingConfig(lvl); } } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record IpFilterConfig( + @JsonProperty("enabled") Boolean enabled, + @JsonProperty("mode") String mode, + @JsonProperty("blockedIps") java.util.List blockedIps, + @JsonProperty("allowedIps") java.util.List allowedIps + ) { + public static IpFilterConfig defaults() { + return new IpFilterConfig(false, "BLOCKLIST", java.util.List.of(), java.util.List.of()); + } + + public IpFilterConfig withDefaultsApplied() { + Boolean e = (enabled == null) ? false : enabled; + String m = (mode == null || mode.isBlank()) ? "BLOCKLIST" : mode; + java.util.List blocked = (blockedIps == null) ? java.util.List.of() : blockedIps; + java.util.List allowed = (allowedIps == null) ? java.util.List.of() : allowedIps; + return new IpFilterConfig(e, m, blocked, allowed); + } + } } diff --git a/src/main/java/org/example/filter/IpFilter.java b/src/main/java/org/example/filter/IpFilter.java new file mode 100644 index 00000000..e9c877f2 --- /dev/null +++ b/src/main/java/org/example/filter/IpFilter.java @@ -0,0 +1,108 @@ +package org.example.filter; + + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * A filter that allows or blocks HTTP requests based on the client's IP address. + * The filter supports two modes: + * ALLOWLIST – only IP addresses in the allowlist are permitted + * BLOCKLIST – all IP addresses are permitted except those in the blocklist + */ +public class IpFilter implements Filter { + + private final Set blockedIps = ConcurrentHashMap.newKeySet(); + private final Set allowedIps = ConcurrentHashMap.newKeySet(); + private volatile FilterMode mode = FilterMode.BLOCKLIST; + + /** + * Defines the filtering mode. + */ + public enum FilterMode { + ALLOWLIST, + BLOCKLIST + } + + @Override + public void init() { + // Intentionally empty - no initialization needed + } + + /** + * Filters incoming HTTP requests based on the client's IP address. + * + * @param request the incoming HTTP request + * @param response the HTTP response builder used when blocking requests + * @param chain the filter chain to continue if the request is allowed + */ + @Override + public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { + String clientIp = normalizeIp((String) request.getAttribute("clientIp")); + + if (clientIp == null || clientIp.trim().isEmpty()) { + response.setStatusCode(HttpResponseBuilder.SC_BAD_REQUEST); + response.setBody("Bad Request: Missing client IP address"); + return; + } + + boolean allowed = isIpAllowed(clientIp); + + if (allowed) { + chain.doFilter(request, response); + } else { + response.setStatusCode(HttpResponseBuilder.SC_FORBIDDEN); + response.setBody("Forbidden: IP address " + clientIp + " is not allowed"); + } + } + + @Override + public void destroy() { + // Intentionally empty - no cleanup needed + } + + /** + * Determines whether an IP address is allowed based on the current filter mode. + * + * @param ip the IP address to check + * @return true if the IP address is allowed, otherwise false + */ + private boolean isIpAllowed(String ip) { + if (mode == FilterMode.ALLOWLIST) { + return allowedIps.contains(ip); + } else { + return !blockedIps.contains(ip); + } + } + + /** + * Trims leading and trailing whitespace from an IP address. + * + * @param ip the IP address + * @return the trimmed IP address, or {@code null} if the input is {@code null} + */ + private String normalizeIp(String ip) { + return ip == null ? null : ip.trim(); + } + + public void setMode(FilterMode mode) { + this.mode = mode; + } + + public void addBlockedIp(String ip) { + if (ip == null) { + throw new IllegalArgumentException("IP address cannot be null"); + } + blockedIps.add(normalizeIp(ip)); + } + + public void addAllowedIp(String ip) { + if (ip == null) { + throw new IllegalArgumentException("IP address cannot be null"); + } + allowedIps.add(normalizeIp(ip)); + } +} diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java index e84579e9..9b6ed2a7 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -65,6 +65,10 @@ public void setStatusCode(int statusCode) { this.statusCode = statusCode; } + public int getStatusCode() { + return this.statusCode; + } + public void setBody(String body) { this.body = body != null ? body : ""; this.bytebody = null; diff --git a/src/main/java/org/example/httpparser/HttpRequest.java b/src/main/java/org/example/httpparser/HttpRequest.java index ad65d496..18f0e561 100644 --- a/src/main/java/org/example/httpparser/HttpRequest.java +++ b/src/main/java/org/example/httpparser/HttpRequest.java @@ -1,6 +1,7 @@ package org.example.httpparser; import java.util.Collections; +import java.util.HashMap; import java.util.Map; /* @@ -15,6 +16,7 @@ public class HttpRequest { private final String version; private final Map headers; private final String body; + private final Map attributes = new HashMap<>(); public HttpRequest(String method, String path, @@ -38,4 +40,10 @@ public Map getHeaders() { return headers; } public String getBody() { return body; } + public void setAttribute(String key, Object value) { + attributes.put(key, value); + } + public Object getAttribute(String key) { + return attributes.get(key); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8f2ef3a7..a57443be 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,4 +3,10 @@ server: rootDir: ./www logging: - level: INFO \ No newline at end of file + level: INFO + +ipFilter: + enabled: false + mode: "BLOCKLIST" + blockedIps: [ ] + allowedIps: [ ] diff --git a/src/test/java/org/example/filter/IpFilterTest.java b/src/test/java/org/example/filter/IpFilterTest.java new file mode 100644 index 00000000..3b556b43 --- /dev/null +++ b/src/test/java/org/example/filter/IpFilterTest.java @@ -0,0 +1,203 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +/** + * Integration tests for IpFilter. + * Verifies behavior in both ALLOWLIST and BLOCKLIST modes. + */ +class IpFilterTest { + + private IpFilter ipFilter; + private HttpResponseBuilder response; + private FilterChain mockChain; + private boolean chainCalled; + + @BeforeEach + void setUp() { + ipFilter = new IpFilter(); + response = new HttpResponseBuilder(); + chainCalled = false; + mockChain = (req, resp) -> chainCalled = true; + } + + @Test + void testBlocklistMode_AllowsUnblockedIp() { + // ARRANGE + ipFilter.setMode(IpFilter.FilterMode.BLOCKLIST); + ipFilter.addBlockedIp("192.168.1.100"); + ipFilter.init(); + + HttpRequest request = createRequestWithIp("192.168.1.50"); + + // ACT + ipFilter.doFilter(request, response, mockChain); + + // ASSERT + assertThat(chainCalled).isTrue(); + } + + @Test + void testBlocklistMode_BlocksBlockedIp() { + // ARRANGE + ipFilter.setMode(IpFilter.FilterMode.BLOCKLIST); + ipFilter.addBlockedIp("192.168.1.100"); + ipFilter.init(); + + HttpRequest request = createRequestWithIp("192.168.1.100"); + + // ACT + ipFilter.doFilter(request, response, mockChain); + + // ASSERT + String result = new String(response.build(), StandardCharsets.UTF_8); + assertAll( + () -> assertThat(chainCalled).isFalse(), + () -> assertThat(result).contains("403"), + () -> assertThat(result).contains("Forbidden") + ); + } + + @Test + void testAllowListMode_AllowsWhitelistedIp() { + // ARRANGE + ipFilter.setMode(IpFilter.FilterMode.ALLOWLIST); + ipFilter.addAllowedIp("10.0.0.1"); + ipFilter.init(); + + HttpRequest request = createRequestWithIp("10.0.0.1"); + + // ACT + ipFilter.doFilter(request, response, mockChain); + + // ASSERT + assertThat(chainCalled).isTrue(); + } + + @Test + void testAllowListMode_BlockNonWhitelistedIp() { + // ARRANGE + ipFilter.setMode(IpFilter.FilterMode.ALLOWLIST); + ipFilter.addAllowedIp("10.0.0.1"); + ipFilter.init(); + + HttpRequest request = createRequestWithIp("10.0.0.2"); + + // ACT + ipFilter.doFilter(request, response, mockChain); + + // ASSERT + assertThat(chainCalled).isFalse(); + + String result = new String(response.build(), StandardCharsets.UTF_8); + assertThat(result).contains("403"); + } + + @Test + void testMissingClientIp_Returns400() { + // ARRANGE + HttpRequest request = new HttpRequest( + "GET", + "/", + "HTTP/1.1", + Collections.emptyMap(), + "" + ); + + // ACT + ipFilter.doFilter(request, response, mockChain); + + // ASSERT + String result = new String(response.build(), StandardCharsets.UTF_8); + assertAll( + () -> assertThat(chainCalled).isFalse(), + () -> assertThat(result).contains("400"), + () -> assertThat(result).contains("Bad Request") + ); + } + + private HttpRequest createRequestWithIp(String ip) { + HttpRequest request = new HttpRequest( + "GET", + "/", + "HTTP/1.1", + Collections.emptyMap(), + "" + ); + request.setAttribute("clientIp", ip); + return request; + } + + // EDGE CASES + + @Test + void testEmptyAllowlist_BlocksAllIps() { + // ARRANGE + ipFilter.setMode(IpFilter.FilterMode.ALLOWLIST); + // Do not add Ip to the list + + // ACT + HttpRequest request = createRequestWithIp("1.2.3.4"); + ipFilter.doFilter(request, response, mockChain); + + // ASSERT + assertThat(chainCalled).isFalse(); + } + + @Test + void testEmptyBlocklist_AllowAllIps() { + // ARRANGE + ipFilter.setMode(IpFilter.FilterMode.BLOCKLIST); + // Do not add Ip to the list + + // ACT + HttpRequest request = createRequestWithIp("1.2.3.4"); + ipFilter.doFilter(request, response, mockChain); + + // ASSERT + assertThat(chainCalled).isTrue(); + } + + @Test + void testEmptyStringIp() { + // ARRANGE + ipFilter.setMode(IpFilter.FilterMode.BLOCKLIST); + HttpRequest request = createRequestWithIp(""); + + // ACT + ipFilter.doFilter(request, response, mockChain); + + // ASSERT + String result = new String(response.build(), StandardCharsets.UTF_8); + assertAll( + () -> assertThat(chainCalled).isFalse(), + () -> assertThat(result).contains("400"), + () -> assertThat(result).contains("Bad Request") + ); + } + + @Test + void testAddBlockedIp_ThrowsOnNull() { + assertThatThrownBy(() -> ipFilter.addBlockedIp(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot be null"); + } + + @Test + void testAddAllowedIp_ThrowsOnNull() { + assertThatThrownBy(() -> ipFilter.addAllowedIp(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot be null"); + } + +} From 7652687af167b0ad9be4b1a83f26fd3d3c619b48 Mon Sep 17 00:00:00 2001 From: AntonAhlqvist Date: Wed, 25 Feb 2026 08:48:17 +0100 Subject: [PATCH 42/67] Feature/LocaleFilter (#81) * Re-commit LocaleFilter + tests to clean branch for PR * Update LocaleFilter to handle quality weights and improve javadoc * Fix: rename test method to reflect actual headers scenario * Fix: ensure resolveLocale never returns empty string; strip quality weights --- .../java/org/example/filter/LocaleFilter.java | 98 +++++++++++++++++++ .../org/example/filter/LocaleFilterTest.java | 96 ++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 src/main/java/org/example/filter/LocaleFilter.java create mode 100644 src/test/java/org/example/filter/LocaleFilterTest.java diff --git a/src/main/java/org/example/filter/LocaleFilter.java b/src/main/java/org/example/filter/LocaleFilter.java new file mode 100644 index 00000000..c02f634e --- /dev/null +++ b/src/main/java/org/example/filter/LocaleFilter.java @@ -0,0 +1,98 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; + +import java.util.Map; + +/** + * Filter that extracts the preferred locale from the Accept-Language header of an HTTP request. + *

+ * If the Accept-Language header is missing, blank, or malformed, the filter defaults to "en-US". + * The selected locale is stored in a ThreadLocal variable so it can be accessed during the request. + *

+ * This filter does not modify the response or stop the filter chain; it simply sets the + * current locale and forwards the request to the next filter in the chain. + *

+ * ThreadLocal cleanup is performed after the filter chain completes to prevent memory leaks. + */ +public class LocaleFilter implements Filter { + + private static final String DEFAULT_LOCALE = "en-US"; + private static final ThreadLocal currentLocale = new ThreadLocal<>(); + + @Override + public void init() { + } + + @Override + public void doFilter(HttpRequest request, + HttpResponseBuilder response, + FilterChain chain) { + try { + String locale = resolveLocale(request); + currentLocale.set(locale); + + chain.doFilter(request, response); + } finally { + currentLocale.remove(); + } + } + + @Override + public void destroy() { + } + + public static String getCurrentLocale() { + String locale = currentLocale.get(); + if (locale != null) { + return locale; + } else { + return DEFAULT_LOCALE; + } + } + + /** + * Determines the preferred locale from the Accept-Language header of the request. + * If the header is missing, blank, or malformed, this method returns the default locale "en-US". + * The first language tag is used, and any optional quality value (e.g., ";q=0.9") is stripped. + * If the request itself is null, the default locale is also returned. + */ + private String resolveLocale(HttpRequest request) { + + if (request == null) { + return DEFAULT_LOCALE; + } + + Map headers = request.getHeaders(); + if (headers == null || headers.isEmpty()) { + return DEFAULT_LOCALE; + } + + String acceptLanguage = null; + + for (Map.Entry entry : headers.entrySet()) { + if (entry.getKey() != null && + entry.getKey().equalsIgnoreCase("Accept-Language")) { + acceptLanguage = entry.getValue(); + break; + } + } + + if (acceptLanguage == null || acceptLanguage.isBlank()) { + return DEFAULT_LOCALE; + } + + String[] parts = acceptLanguage.split(","); + if (parts[0].isBlank()) { + return DEFAULT_LOCALE; + } + + String locale = parts[0].split(";")[0].trim(); + if (locale.isEmpty()) { + return DEFAULT_LOCALE; + } else { + return locale; + } + } +} diff --git a/src/test/java/org/example/filter/LocaleFilterTest.java b/src/test/java/org/example/filter/LocaleFilterTest.java new file mode 100644 index 00000000..98e626b3 --- /dev/null +++ b/src/test/java/org/example/filter/LocaleFilterTest.java @@ -0,0 +1,96 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class LocaleFilterTest { + + @Test + void shouldUseFirstLanguageFromHeader() { + Map headers = new HashMap<>(); + headers.put("Accept-Language", "sv-SE,sv;q=0.9,en;q=0.8"); + + HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null); + HttpResponseBuilder response = new HttpResponseBuilder(); + + LocaleFilter filter = new LocaleFilter(); + + filter.doFilter(request, response, (req, res) -> { + assertEquals("sv-SE", LocaleFilter.getCurrentLocale()); + }); + + assertEquals("en-US", LocaleFilter.getCurrentLocale()); + } + + @Test + void shouldUseDefaultWhenHeaderMissing() { + Map headers = new HashMap<>(); + + HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null); + HttpResponseBuilder response = new HttpResponseBuilder(); + + LocaleFilter filter = new LocaleFilter(); + + filter.doFilter(request, response, (req, res) -> { + assertEquals("en-US", LocaleFilter.getCurrentLocale()); + }); + } + + @Test + void shouldUseDefaultWhenHeaderBlank() { + Map headers = new HashMap<>(); + headers.put("Accept-Language", " "); + + HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null); + HttpResponseBuilder response = new HttpResponseBuilder(); + + LocaleFilter filter = new LocaleFilter(); + + filter.doFilter(request, response, (req, res) -> { + assertEquals("en-US", LocaleFilter.getCurrentLocale()); + }); + } + + @Test + void shouldHandleCaseInsensitiveHeader() { + Map headers = new HashMap<>(); + headers.put("accept-language", "fr-FR"); + + HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null); + HttpResponseBuilder response = new HttpResponseBuilder(); + + LocaleFilter filter = new LocaleFilter(); + + filter.doFilter(request, response, (req, res) -> { + assertEquals("fr-FR", LocaleFilter.getCurrentLocale()); + }); + } + + @Test + void shouldUseDefaultWhenRequestIsNull() { + LocaleFilter filter = new LocaleFilter(); + HttpResponseBuilder response = new HttpResponseBuilder(); + + filter.doFilter(null, response, (req, res) -> { + assertEquals("en-US", LocaleFilter.getCurrentLocale()); + }); + } + + @Test + void shouldUseDefaultWhenHeadersAreEmpty() { + HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", null, null); + HttpResponseBuilder response = new HttpResponseBuilder(); + + LocaleFilter filter = new LocaleFilter(); + + filter.doFilter(request, response, (req, res) -> { + assertEquals("en-US", LocaleFilter.getCurrentLocale()); + }); + } +} From 3231ee1b851c2f770c0ca21c286254bfd1d3841f Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 11:38:35 +0100 Subject: [PATCH 43/67] =?UTF-8?q?=C3=A4ndringar=20som=20kodrabbit=20f?= =?UTF-8?q?=C3=B6rstog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/example/CacheFilter.java | 129 +++++++++++++-------- 1 file changed, 80 insertions(+), 49 deletions(-) diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java index ad7504fd..d9ed9a34 100644 --- a/src/main/java/org/example/CacheFilter.java +++ b/src/main/java/org/example/CacheFilter.java @@ -1,3 +1,4 @@ + package org.example; import java.io.IOException; @@ -11,12 +12,13 @@ public class CacheFilter { private static final int MAX_CACHE_ENTRIES = 100; private static final long MAX_CACHE_BYTES = 50 * 1024 * 1024;// 50MB - + // Lock-free concurrent cache - private final ConcurrentHashMap cache = - new ConcurrentHashMap<>(16, 0.75f, 16); // 16 segments för concurrency - + private final ConcurrentHashMap cache = + new ConcurrentHashMap<>(16, 0.75f, 16); + private final AtomicLong currentBytes = new AtomicLong(0); + private final Object writeLock = new Object(); // För atomära operationer /** * Cache entry med metadata för LRU tracking @@ -40,40 +42,65 @@ void recordAccess() { /** * Hämta från cache eller fetch från provider (thread-safe) + * Använder double-checked locking för att undvika TOCTOU-race */ public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { - // Kontrollera cache (lock-free read) - if (cache.containsKey(uri)) { - CacheEntry entry = cache.get(uri); + // First check - lock-free read (snabb väg) + CacheEntry entry = cache.get(uri); + if (entry != null) { + entry.recordAccess(); + System.out.println("✓ Cache hit for: " + uri); + return entry.data; + } + + // Cache miss - fetch från provider under lock + synchronized (writeLock) { + // Second check - verifierar att ingen annan tråd fetchade medan vi väntade på lock + entry = cache.get(uri); if (entry != null) { entry.recordAccess(); - System.out.println(" Cache hit for: " + uri); + System.out.println("✓ Cache hit for: " + uri + " (from concurrent fetch)"); return entry.data; } - } - // Cache miss - fetch från provider - System.out.println(" Cache miss for: " + uri); - byte[] fileBytes = provider.fetch(uri); - - // Lägg till i cache - addToCache(uri, fileBytes); - - return fileBytes; + // Fetch och cachelagra + System.out.println("✗ Cache miss for: " + uri); + byte[] fileBytes = provider.fetch(uri); + + if (fileBytes != null) { + addToCacheUnsafe(uri, fileBytes); + } + + return fileBytes; + } } /** - * Lägg till i cache med eviction om nödvändigt (thread-safe) + * Lägg till i cache med eviction om nödvändigt (MÅSTE VARA UNDER LOCK) */ - private synchronized void addToCache(String uri, byte[] data) { - // Kontrollera om vi måste evicta innan vi lägger till - while (shouldEvict(data)) { - evictLeastRecentlyUsed(); + private void addToCacheUnsafe(String uri, byte[] data) { + // Guard mot oversized entries som kan blockera eviction + if (data.length > MAX_CACHE_BYTES) { + System.out.println("⚠️ Skipping cache for oversized file: " + uri + + " (" + (data.length / 1024 / 1024) + "MB > " + + (MAX_CACHE_BYTES / 1024 / 1024) + "MB)"); + return; + } + + // Evicta medan nödvändigt (med empty-check för infinite loop) + while (shouldEvict(data) && !cache.isEmpty()) { + evictLeastRecentlyUsedUnsafe(); + } + + // Om cache fortfarande är full efter eviction, hoppa över + if (shouldEvict(data)) { + System.out.println("⚠️ Cache full, skipping: " + uri); + return; } CacheEntry newEntry = new CacheEntry(data); CacheEntry oldEntry = cache.put(uri, newEntry); - + // Uppdatera byte-count if (oldEntry != null) { currentBytes.addAndGet(-oldEntry.data.length); @@ -85,52 +112,56 @@ private synchronized void addToCache(String uri, byte[] data) { * Kontrollera om vi behöver evicta */ private boolean shouldEvict(byte[] newValue) { - return cache.size() >= MAX_CACHE_ENTRIES || - (currentBytes.get() + newValue.length) > MAX_CACHE_BYTES; + return cache.size() >= MAX_CACHE_ENTRIES || + (currentBytes.get() + newValue.length) > MAX_CACHE_BYTES; } /** - * Evicta minst nyligen använd entry + * Evicta minst nyligen använd entry (MÅSTE VARA UNDER LOCK) */ - private void evictLeastRecentlyUsed() { + private void evictLeastRecentlyUsedUnsafe() { if (cache.isEmpty()) return; // Hitta entry med minst senaste access String keyToRemove = cache.entrySet().stream() - .min((a, b) -> Long.compare( - a.getValue().lastAccessTime.get(), - b.getValue().lastAccessTime.get() - )) - .map(java.util.Map.Entry::getKey) - .orElse(null); + .min((a, b) -> Long.compare( + a.getValue().lastAccessTime.get(), + b.getValue().lastAccessTime.get() + )) + .map(java.util.Map.Entry::getKey) + .orElse(null); if (keyToRemove != null) { CacheEntry removed = cache.remove(keyToRemove); if (removed != null) { currentBytes.addAndGet(-removed.data.length); - System.out.println("✗ Evicted from cache: " + keyToRemove + - " (accesses: " + removed.accessCount.get() + ")"); + System.out.println("✗ Evicted from cache: " + keyToRemove + + " (accesses: " + removed.accessCount.get() + ")"); } } } - // Diagnostik-metoder + /** + * Rensa cache atomärt + */ public void clearCache() { - cache.clear(); - currentBytes.set(0); + synchronized (writeLock) { + cache.clear(); + currentBytes.set(0); + } } public CacheStats getStats() { long totalAccesses = cache.values().stream() - .mapToLong(e -> e.accessCount.get()) - .sum(); - + .mapToLong(e -> e.accessCount.get()) + .sum(); + return new CacheStats( - cache.size(), - currentBytes.get(), - MAX_CACHE_ENTRIES, - MAX_CACHE_BYTES, - totalAccesses + cache.size(), + currentBytes.get(), + MAX_CACHE_ENTRIES, + MAX_CACHE_BYTES, + totalAccesses ); } @@ -153,9 +184,9 @@ public static class CacheStats { @Override public String toString() { return String.format( - "CacheStats{entries=%d/%d, bytes=%d/%d, utilization=%.1f%%, accesses=%d}", - entries, maxEntries, bytes, maxBytes, - (double) bytes / maxBytes * 100, totalAccesses + "CacheStats{entries=%d/%d, bytes=%d/%d, utilization=%.1f%%, accesses=%d}", + entries, maxEntries, bytes, maxBytes, + (double) bytes / maxBytes * 100, totalAccesses ); } } From 99f5fd7765cc8c2e7a1376a69832ba81b52f6a96 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 11:39:37 +0100 Subject: [PATCH 44/67] =?UTF-8?q?=C3=A4ndringar=20som=20kodrabbit=20f?= =?UTF-8?q?=C3=B6rstog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/example/CacheFilter.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java index d9ed9a34..bafecf4e 100644 --- a/src/main/java/org/example/CacheFilter.java +++ b/src/main/java/org/example/CacheFilter.java @@ -2,6 +2,7 @@ package org.example; import java.io.IOException; +import java.util.Comparator; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; @@ -124,10 +125,7 @@ private void evictLeastRecentlyUsedUnsafe() { // Hitta entry med minst senaste access String keyToRemove = cache.entrySet().stream() - .min((a, b) -> Long.compare( - a.getValue().lastAccessTime.get(), - b.getValue().lastAccessTime.get() - )) + .min(Comparator.comparingLong(e -> e.getValue().lastAccessTime.get())) .map(java.util.Map.Entry::getKey) .orElse(null); @@ -141,6 +139,7 @@ private void evictLeastRecentlyUsedUnsafe() { } } + /** * Rensa cache atomärt */ From 04cba977995693cbfe67312fafa5d03d8258369f Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 11:49:45 +0100 Subject: [PATCH 45/67] =?UTF-8?q?=C3=A4ndringar=20som=20kodrabbit=20f?= =?UTF-8?q?=C3=B6rstog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/example/CacheFilter.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java index bafecf4e..2a77e23a 100644 --- a/src/main/java/org/example/CacheFilter.java +++ b/src/main/java/org/example/CacheFilter.java @@ -139,7 +139,6 @@ private void evictLeastRecentlyUsedUnsafe() { } } - /** * Rensa cache atomärt */ From c1586778c43720748f3c9d4a160b99d1baaf554f Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 11:56:02 +0100 Subject: [PATCH 46/67] refactored StaticFileHandler to improve error handling, enhance sanitization logic, and optimize shared cache usage; streamlined test cases for clarity --- .../java/org/example/StaticFileHandler.java | 52 ++++++++-------- .../org/example/StaticFileHandlerTest.java | 59 +++++++------------ 2 files changed, 47 insertions(+), 64 deletions(-) diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index ee5030c5..c549a735 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -13,7 +13,7 @@ public class StaticFileHandler { private static final String DEFAULT_WEB_ROOT = "www"; - // ✅ EN shared cache för alla threads + // EN shared cache för alla threads private static final CacheFilter SHARED_CACHE = new CacheFilter(); private final String webRoot; @@ -28,46 +28,44 @@ public StaticFileHandler(String webRoot) { } public void sendGetRequest(OutputStream outputStream, String uri) throws IOException { + String sanitizedUri = sanitizeUri(uri); + + if (isPathTraversal(sanitizedUri)) { + sendErrorResponse(outputStream, 403, "Forbidden"); + return; + } + try { - String sanitizedUri = sanitizeUri(uri); - - if (isPathTraversal(sanitizedUri)) { - sendErrorResponse(outputStream, 403, "Forbidden"); - return; - } - - // Använd shared cache istället för ny instans byte[] fileBytes = SHARED_CACHE.getOrFetch(sanitizedUri, - path -> Files.readAllBytes(new File(webRoot, path).toPath()) + path -> Files.readAllBytes(new File(webRoot, path).toPath()) ); - + HttpResponseBuilder response = new HttpResponseBuilder(); - response.setHeaders(Map.of("Content-Type", "text/html; charset=UTF-8")); + response.setContentTypeFromFilename(sanitizedUri); response.setBody(fileBytes); outputStream.write(response.build()); outputStream.flush(); - + } catch (IOException e) { - try { - sendErrorResponse(outputStream, 404, "Not Found"); - } catch (IOException ex) { - ex.printStackTrace(); - } + sendErrorResponse(outputStream, 404, "Not Found"); } } private String sanitizeUri(String uri) { - uri = uri.split("\\?")[0]; - uri = uri.split("#")[0]; - uri = uri.replace("\0", ""); - - while (uri.startsWith("/")) { - uri = uri.substring(1); - } - + // Entydlig: ta bort query string och fragment + int queryIndex = uri.indexOf('?'); + int fragmentIndex = uri.indexOf('#'); + int endIndex = Math.min( + queryIndex > 0 ? queryIndex : uri.length(), + fragmentIndex > 0 ? fragmentIndex : uri.length() + ); + + uri = uri.substring(0, endIndex) + .replace("\0", "") + .replaceAll("^/+", ""); // Bort med leading slashes + return uri; } - private boolean isPathTraversal(String uri) { try { Path webRootPath = Paths.get(webRoot).toRealPath(); diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java index c74f59a9..262ecc25 100644 --- a/src/test/java/org/example/StaticFileHandlerTest.java +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -28,7 +28,17 @@ */ class StaticFileHandlerTest { - private StaticFileHandler handler; + private StaticFileHandler createHandler(){ + return new StaticFileHandler(tempDir.toString()); + } + + private String sendRequest(String uri) throws IOException { + StaticFileHandler handler = createHandler(); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + handler.sendGetRequest(output, uri); + return output.toString(); + } + //Junit creates a temporary folder which can be filled with temporary files that gets removed after tests @TempDir @@ -45,60 +55,35 @@ void setUp() { void testCaching_HitOnSecondRequest() throws IOException { // Arrange Files.writeString(tempDir.resolve("cached.html"), "Content"); - StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); - // Act - Första anropet (cache miss) - handler.sendGetRequest(new ByteArrayOutputStream(), "cached.html"); + // Act + sendRequest("cached.html"); int sizeAfterFirst = StaticFileHandler.getCacheStats().entries; - - // Act - Andra anropet (cache hit) - handler.sendGetRequest(new ByteArrayOutputStream(), "cached.html"); + sendRequest("cached.html"); int sizeAfterSecond = StaticFileHandler.getCacheStats().entries; - // Assert - Cache ska innehålla samma entry - assertThat(sizeAfterFirst).isEqualTo(1); - assertThat(sizeAfterSecond).isEqualTo(1); + // Assert + assertThat(sizeAfterFirst).isEqualTo(sizeAfterSecond).isEqualTo(1); } + @Test void testSanitization_QueryString() throws IOException { - // Arrange Files.writeString(tempDir.resolve("index.html"), "Home"); - StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); - ByteArrayOutputStream output = new ByteArrayOutputStream(); - - // Act - URI med query string - handler.sendGetRequest(output, "index.html?foo=bar"); - - // Assert - assertThat(output.toString()).contains("HTTP/1.1 200"); + assertThat(sendRequest("index.html?foo=bar")).contains("HTTP/1.1 200"); } + @Test void testSanitization_LeadingSlash() throws IOException { - // Arrange Files.writeString(tempDir.resolve("page.html"), "Page"); - StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); - ByteArrayOutputStream output = new ByteArrayOutputStream(); - - // Act - handler.sendGetRequest(output, "/page.html"); - - // Assert - assertThat(output.toString()).contains("HTTP/1.1 200"); + assertThat(sendRequest("/page.html")).contains("HTTP/1.1 200"); } + @Test void testSanitization_NullBytes() throws IOException { - // Arrange - StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); - ByteArrayOutputStream output = new ByteArrayOutputStream(); - - // Act - handler.sendGetRequest(output, "file.html\0../../secret"); - - // Assert - assertThat(output.toString()).contains("HTTP/1.1 404"); + assertThat(sendRequest("file.html\0../../secret")).contains("HTTP/1.1 404"); } @Test From 811ccce7629b103a117754d30e6120bfe190029a Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 12:33:19 +0100 Subject: [PATCH 47/67] =?UTF-8?q?fixa=20lite=20och=20hade=20gl=C3=B6mt=20a?= =?UTF-8?q?tt=20commit=20vissa=20saker=20efter=20=C3=A4ndringar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/example/CacheFilter.java | 22 ++++--- src/main/java/org/example/FileCache.java | 66 +++++++++++++++++++ .../java/org/example/StaticFileHandler.java | 42 ++++++++++-- .../org/example/StaticFileHandlerTest.java | 19 ++++-- 4 files changed, 133 insertions(+), 16 deletions(-) create mode 100644 src/main/java/org/example/FileCache.java diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java index 2a77e23a..22958a84 100644 --- a/src/main/java/org/example/CacheFilter.java +++ b/src/main/java/org/example/CacheFilter.java @@ -5,12 +5,16 @@ import java.util.Comparator; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Logger; +import java.util.logging.Level; /** - * Thread-safe cache filter using ConcurrentHashMap + * Thread-safe in-memory cache filter using ConcurrentHashMap * Handles caching with LRU eviction for large files + * Implements the FileCache interface for pluggable cache implementations */ -public class CacheFilter { +public class CacheFilter implements FileCache { + private static final Logger LOGGER = Logger.getLogger(CacheFilter.class.getName()); private static final int MAX_CACHE_ENTRIES = 100; private static final long MAX_CACHE_BYTES = 50 * 1024 * 1024;// 50MB @@ -45,12 +49,13 @@ void recordAccess() { * Hämta från cache eller fetch från provider (thread-safe) * Använder double-checked locking för att undvika TOCTOU-race */ + @Override public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { // First check - lock-free read (snabb väg) CacheEntry entry = cache.get(uri); if (entry != null) { entry.recordAccess(); - System.out.println("✓ Cache hit for: " + uri); + LOGGER.log(Level.FINE, "✓ Cache hit for: " + uri); return entry.data; } @@ -60,12 +65,12 @@ public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { entry = cache.get(uri); if (entry != null) { entry.recordAccess(); - System.out.println("✓ Cache hit for: " + uri + " (from concurrent fetch)"); + LOGGER.log(Level.FINE, "✓ Cache hit for: " + uri + " (from concurrent fetch)"); return entry.data; } // Fetch och cachelagra - System.out.println("✗ Cache miss for: " + uri); + LOGGER.log(Level.FINE, "✗ Cache miss for: " + uri); byte[] fileBytes = provider.fetch(uri); if (fileBytes != null) { @@ -82,7 +87,7 @@ public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { private void addToCacheUnsafe(String uri, byte[] data) { // Guard mot oversized entries som kan blockera eviction if (data.length > MAX_CACHE_BYTES) { - System.out.println("⚠️ Skipping cache for oversized file: " + uri + + LOGGER.log(Level.WARNING, "⚠️ Skipping cache for oversized file: " + uri + " (" + (data.length / 1024 / 1024) + "MB > " + (MAX_CACHE_BYTES / 1024 / 1024) + "MB)"); return; @@ -95,7 +100,7 @@ private void addToCacheUnsafe(String uri, byte[] data) { // Om cache fortfarande är full efter eviction, hoppa över if (shouldEvict(data)) { - System.out.println("⚠️ Cache full, skipping: " + uri); + LOGGER.log(Level.WARNING, "⚠️ Cache full, skipping: " + uri); return; } @@ -133,7 +138,7 @@ private void evictLeastRecentlyUsedUnsafe() { CacheEntry removed = cache.remove(keyToRemove); if (removed != null) { currentBytes.addAndGet(-removed.data.length); - System.out.println("✗ Evicted from cache: " + keyToRemove + + LOGGER.log(Level.FINE, "✗ Evicted from cache: " + keyToRemove + " (accesses: " + removed.accessCount.get() + ")"); } } @@ -142,6 +147,7 @@ private void evictLeastRecentlyUsedUnsafe() { /** * Rensa cache atomärt */ + @Override public void clearCache() { synchronized (writeLock) { cache.clear(); diff --git a/src/main/java/org/example/FileCache.java b/src/main/java/org/example/FileCache.java new file mode 100644 index 00000000..8547b388 --- /dev/null +++ b/src/main/java/org/example/FileCache.java @@ -0,0 +1,66 @@ +package org.example; + +import java.io.IOException; + +/** + * Interface for pluggable file caching implementations. + * Allows for different cache backends (in-memory, Redis, Caffeine, etc.) + */ +public interface FileCache { + /** + * Retrieves cached file or fetches from provider if not cached. + * Implementation must be thread-safe. + * + * @param cacheKey Unique cache key (typically includes webRoot + uri) + * @param provider Function to fetch file bytes if not cached + * @return File bytes, or null if file not found + * @throws IOException if fetch fails + */ + byte[] getOrFetch(String cacheKey, FileProvider provider) throws IOException; + + /** + * Clear all cached entries. + */ + void clearCache(); + + /** + * Get cache statistics. + */ + CacheStats getStats(); + + /** + * Functional interface for file fetching providers. + */ + @FunctionalInterface + interface FileProvider { + byte[] fetch(String uri) throws IOException; + } + + /** + * Cache statistics record. + */ + class CacheStats { + public final int entries; + public final long bytes; + public final int maxEntries; + public final long maxBytes; + public final long totalAccesses; + + public CacheStats(int entries, long bytes, int maxEntries, long maxBytes, long totalAccesses) { + this.entries = entries; + this.bytes = bytes; + this.maxEntries = maxEntries; + this.maxBytes = maxBytes; + this.totalAccesses = totalAccesses; + } + + @Override + public String toString() { + return String.format( + "CacheStats{entries=%d/%d, bytes=%d/%d, utilization=%.1f%%, accesses=%d}", + entries, maxEntries, bytes, maxBytes, + (double) bytes / maxBytes * 100, totalAccesses + ); + } + } +} diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index c549a735..90641768 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -5,16 +5,21 @@ import java.io.File; import java.io.IOException; import java.io.OutputStream; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Map; +import java.util.logging.Logger; +import java.util.logging.Level; public class StaticFileHandler { private static final String DEFAULT_WEB_ROOT = "www"; + private static final Logger LOGGER = Logger.getLogger(StaticFileHandler.class.getName()); // EN shared cache för alla threads - private static final CacheFilter SHARED_CACHE = new CacheFilter(); + private static final FileCache SHARED_CACHE = new CacheFilter(); private final String webRoot; @@ -36,8 +41,10 @@ public void sendGetRequest(OutputStream outputStream, String uri) throws IOExcep } try { - byte[] fileBytes = SHARED_CACHE.getOrFetch(sanitizedUri, - path -> Files.readAllBytes(new File(webRoot, path).toPath()) + // Cache-nyckel inkluderar nu webRoot för att undvika collisions + String cacheKey = generateCacheKey(sanitizedUri); + byte[] fileBytes = SHARED_CACHE.getOrFetch(cacheKey, + path -> Files.readAllBytes(new File(webRoot, sanitizedUri).toPath()) ); HttpResponseBuilder response = new HttpResponseBuilder(); @@ -47,10 +54,23 @@ public void sendGetRequest(OutputStream outputStream, String uri) throws IOExcep outputStream.flush(); } catch (IOException e) { + LOGGER.log(Level.FINE, "File not found or read error: " + uri, e); sendErrorResponse(outputStream, 404, "Not Found"); } } + /** + * Generates a unique cache key that includes webRoot to prevent collisions + * between different handler instances + */ + private String generateCacheKey(String sanitizedUri) { + return webRoot + ":" + sanitizedUri; + } + + /** + * Sanitizes URI by removing query strings, fragments, null bytes, and leading slashes. + * Also performs URL-decoding to normalize percent-encoded sequences. + */ private String sanitizeUri(String uri) { // Entydlig: ta bort query string och fragment int queryIndex = uri.indexOf('?'); @@ -64,8 +84,21 @@ private String sanitizeUri(String uri) { .replace("\0", "") .replaceAll("^/+", ""); // Bort med leading slashes + // URL-decode to normalize percent-encoded sequences (e.g., %2e%2e -> ..) + try { + uri = URLDecoder.decode(uri, StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + LOGGER.log(Level.WARNING, "Invalid URL encoding in URI: " + uri); + // Return as-is if decoding fails; isPathTraversal will handle it + } + return uri; } + + /** + * Checks if the requested path attempts to traverse outside the web root. + * Uses path normalization after decoding to catch traversal attempts. + */ private boolean isPathTraversal(String uri) { try { Path webRootPath = Paths.get(webRoot).toRealPath(); @@ -73,6 +106,7 @@ private boolean isPathTraversal(String uri) { return !requestedPath.startsWith(webRootPath); } catch (IOException e) { + LOGGER.log(Level.WARNING, "Path traversal check failed for: " + uri, e); return true; } } @@ -88,7 +122,7 @@ private void sendErrorResponse(OutputStream outputStream, int statusCode, String } //Diagnostik-metod - public static CacheFilter.CacheStats getCacheStats() { + public static FileCache.CacheStats getCacheStats() { return SHARED_CACHE.getStats(); } diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java index 262ecc25..9345ecaa 100644 --- a/src/test/java/org/example/StaticFileHandlerTest.java +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -86,6 +86,7 @@ void testSanitization_NullBytes() throws IOException { assertThat(sendRequest("file.html\0../../secret")).contains("HTTP/1.1 404"); } + @Test void testConcurrent_MultipleReads() throws InterruptedException, IOException { // Arrange @@ -97,6 +98,8 @@ void testConcurrent_MultipleReads() throws InterruptedException, IOException { // Act - 10 trådar läser samma fil 50 gånger varje Thread[] threads = new Thread[10]; + final AssertionError[] assertionErrors = new AssertionError[1]; + for (int i = 0; i < 10; i++) { threads[i] = new Thread(() -> { try { @@ -107,21 +110,29 @@ void testConcurrent_MultipleReads() throws InterruptedException, IOException { } } catch (IOException e) { throw new RuntimeException(e); + } catch (AssertionError e) { + // Capture assertion errors from child thread + synchronized (threads) { + assertionErrors[0] = e; + } + throw e; } }); threads[i].start(); } - // Vänta på alla trådar for (Thread t : threads) { t.join(); } - - // Assert - Cache ska bara ha EN entry + // Assert - Check if any child thread had assertion failures + if (assertionErrors[0] != null) { + throw assertionErrors[0]; + } // Assert - Cache ska bara ha EN entry assertThat(StaticFileHandler.getCacheStats().entries).isEqualTo(1); } -@Test + + @Test void test_file_that_exists_should_return_200() throws IOException { //Arrange Path testFile = tempDir.resolve("test.html"); // Defines the path in the temp directory From 1df428696225e1ffde5a809057ed71d20c74b8ba Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 12:35:23 +0100 Subject: [PATCH 48/67] =?UTF-8?q?gl=C3=B6mde=20av=20att=20fixa=20problem?= =?UTF-8?q?=20i=20cachefilter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/example/CacheFilter.java | 32 +--------------------- 1 file changed, 1 insertion(+), 31 deletions(-) diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java index 22958a84..c4fb20cb 100644 --- a/src/main/java/org/example/CacheFilter.java +++ b/src/main/java/org/example/CacheFilter.java @@ -155,6 +155,7 @@ public void clearCache() { } } + @Override public CacheStats getStats() { long totalAccesses = cache.values().stream() .mapToLong(e -> e.accessCount.get()) @@ -168,35 +169,4 @@ public CacheStats getStats() { totalAccesses ); } - - // Stats-klass - public static class CacheStats { - public final int entries; - public final long bytes; - public final int maxEntries; - public final long maxBytes; - public final long totalAccesses; - - CacheStats(int entries, long bytes, int maxEntries, long maxBytes, long totalAccesses) { - this.entries = entries; - this.bytes = bytes; - this.maxEntries = maxEntries; - this.maxBytes = maxBytes; - this.totalAccesses = totalAccesses; - } - - @Override - public String toString() { - return String.format( - "CacheStats{entries=%d/%d, bytes=%d/%d, utilization=%.1f%%, accesses=%d}", - entries, maxEntries, bytes, maxBytes, - (double) bytes / maxBytes * 100, totalAccesses - ); - } - } - - @FunctionalInterface - public interface FileProvider { - byte[] fetch(String uri) throws IOException; - } } From 19d0a48fdc6c6e4e4834a74a020934e85582cc50 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 12:57:59 +0100 Subject: [PATCH 49/67] updated CacheFilter to clean dead comments and import Paths/Path; improved StaticFileHandler path traversal logic for better security and simpler validation --- src/main/java/org/example/CacheFilter.java | 7 ++++--- .../java/org/example/StaticFileHandler.java | 17 +++++++++++------ 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java index c4fb20cb..6928fc48 100644 --- a/src/main/java/org/example/CacheFilter.java +++ b/src/main/java/org/example/CacheFilter.java @@ -2,6 +2,8 @@ package org.example; import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Comparator; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; @@ -68,6 +70,8 @@ public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { LOGGER.log(Level.FINE, "✓ Cache hit for: " + uri + " (from concurrent fetch)"); return entry.data; } + + // Fetch och cachelagra LOGGER.log(Level.FINE, "✗ Cache miss for: " + uri); @@ -81,9 +85,6 @@ public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { } } - /** - * Lägg till i cache med eviction om nödvändigt (MÅSTE VARA UNDER LOCK) - */ private void addToCacheUnsafe(String uri, byte[] data) { // Guard mot oversized entries som kan blockera eviction if (data.length > MAX_CACHE_BYTES) { diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 90641768..153ffcbb 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -96,17 +96,22 @@ private String sanitizeUri(String uri) { } /** - * Checks if the requested path attempts to traverse outside the web root. - * Uses path normalization after decoding to catch traversal attempts. + /** + * Kontrollerar om den begärda sökvägen försöker traversera utanför webroten. + * Använder sökvägsnormalisering efter avkodning för att fånga traversalförsök. */ private boolean isPathTraversal(String uri) { try { - Path webRootPath = Paths.get(webRoot).toRealPath(); + // Använd absolutsökväg + normalisera istället för toRealPath() för att undvika + // krav på att katalogen existerar och för att hantera symboliska länkar säkert + Path webRootPath = Paths.get(webRoot).toAbsolutePath().normalize(); Path requestedPath = webRootPath.resolve(uri).normalize(); - + + // Returnera true om den begärda sökvägen inte ligger under webroten return !requestedPath.startsWith(webRootPath); - } catch (IOException e) { - LOGGER.log(Level.WARNING, "Path traversal check failed for: " + uri, e); + } catch (Exception e) { + // Om något går fel under sökvägsvalideringen, tillåt inte åtkomst (säker utgång) + LOGGER.log(Level.WARNING, "Sökvägstraversalkontroll misslyckades för: " + uri, e); return true; } } From d4227b0f8c8d1a3efd3b353cb2f2166d3c65d645 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 13:01:50 +0100 Subject: [PATCH 50/67] =?UTF-8?q?fixat=20s=C3=A5=20problem=20som=20kommer?= =?UTF-8?q?=20up=20vid=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/example/CacheFilter.java | 2 -- src/main/java/org/example/StaticFileHandler.java | 3 ++- src/test/java/org/example/StaticFileHandlerTest.java | 2 -- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java index 6928fc48..f2fc26a2 100644 --- a/src/main/java/org/example/CacheFilter.java +++ b/src/main/java/org/example/CacheFilter.java @@ -2,8 +2,6 @@ package org.example; import java.io.IOException; -import java.nio.file.Path; -import java.nio.file.Paths; import java.util.Comparator; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 153ffcbb..caf30671 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -44,9 +44,10 @@ public void sendGetRequest(OutputStream outputStream, String uri) throws IOExcep // Cache-nyckel inkluderar nu webRoot för att undvika collisions String cacheKey = generateCacheKey(sanitizedUri); byte[] fileBytes = SHARED_CACHE.getOrFetch(cacheKey, - path -> Files.readAllBytes(new File(webRoot, sanitizedUri).toPath()) + ignoredPath -> Files.readAllBytes(new File(webRoot, sanitizedUri).toPath()) ); + HttpResponseBuilder response = new HttpResponseBuilder(); response.setContentTypeFromFilename(sanitizedUri); response.setBody(fileBytes); diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java index 9345ecaa..1b7937ae 100644 --- a/src/test/java/org/example/StaticFileHandlerTest.java +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -16,12 +16,10 @@ /** * Unit test class for verifying the behavior of the StaticFileHandler class. - * * This test class ensures that StaticFileHandler correctly handles GET requests * for static files, including both cases where the requested file exists and * where it does not. Temporary directories and files are utilized in tests to * ensure no actual file system dependencies during test execution. - * * Key functional aspects being tested include: * - Correct response status code and content for an existing file. * - Correct response status code and fallback behavior for a missing file. From bb0d51a5c254d055dfe57cf2d13259397d65d0fc Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 13:18:27 +0100 Subject: [PATCH 51/67] =?UTF-8?q?kom=20up=20problem=20n=C3=A4r=20github=20?= =?UTF-8?q?k=C3=B6r=20ska=20ha=20fixat=20det=20tror=20jag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/example/StaticFileHandlerTest.java | 31 +++++++------------ 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java index e9cce24a..fbad4adf 100644 --- a/src/test/java/org/example/StaticFileHandlerTest.java +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -39,6 +39,7 @@ private String sendRequest(String uri) throws IOException { //Junit creates a temporary folder which can be filled with temporary files that gets removed after tests + // Junit creates a temporary folder which can be filled with temporary files that gets removed after tests @TempDir Path tempDir; @@ -148,7 +149,7 @@ void test_file_that_exists_should_return_200() throws IOException { //Assert String response = fakeOutput.toString();//Converts the captured byte stream into a String for verification - assertTrue(response.contains("HTTP/1.1 " + SC_OK + " OK")); // Assert the status + assertTrue(response.contains("HTTP/1.1 200 OK")); // Assert the status assertTrue(response.contains("Hello Test")); //Assert the content in the file assertTrue(response.contains("Content-Type: text/html; charset=UTF-8")); // Verify the correct Content-type header @@ -157,24 +158,16 @@ void test_file_that_exists_should_return_200() throws IOException { @Test void test_file_that_does_not_exists_should_return_404() throws IOException { - //Arrange - // Pre-create the mandatory error page in the temp directory to prevent NoSuchFileException - Path testFile = tempDir.resolve("pageNotFound.html"); - Files.writeString(testFile, "Fallback page"); - - //Using the new constructor in StaticFileHandler to reroute so the tests uses the temporary folder instead of the hardcoded www + // Arrange StaticFileHandler staticFileHandler = new StaticFileHandler(tempDir.toString()); - - //Using ByteArrayOutputStream instead of Outputstream during tests to capture the servers response in memory, fake stream ByteArrayOutputStream fakeOutput = new ByteArrayOutputStream(); - //Act - staticFileHandler.sendGetRequest(fakeOutput, "notExistingFile.html"); // Request a file that clearly doesn't exist to trigger the 404 logic - - //Assert - String response = fakeOutput.toString();//Converts the captured byte stream into a String for verification + // Act + staticFileHandler.sendGetRequest(fakeOutput, "notExistingFile.html"); - assertTrue(response.contains("HTTP/1.1 " + SC_NOT_FOUND + " Not Found")); // Assert the status + // Assert + String response = fakeOutput.toString(); + assertTrue(response.contains("HTTP/1.1 404 Not Found")); } @@ -194,7 +187,7 @@ void test_path_traversal_should_return_403() throws IOException { // Assert String response = fakeOutput.toString(); assertFalse(response.contains("TOP SECRET")); - assertTrue(response.contains("HTTP/1.1 " + SC_FORBIDDEN + " Forbidden")); + assertTrue(response.contains("HTTP/1.1 403 Forbidden")); } @ParameterizedTest @@ -215,7 +208,7 @@ void sanitized_uris_should_return_200(String uri) throws IOException { handler.sendGetRequest(out, uri); // Assert - assertTrue(out.toString().contains("HTTP/1.1 " + SC_OK + " OK")); + assertTrue(out.toString().contains("HTTP/1.1 200 OK")); } @Test @@ -231,7 +224,7 @@ void null_byte_injection_should_not_return_200() throws IOException { // Assert String response = out.toString(); - assertFalse(response.contains("HTTP/1.1 " + SC_OK + " OK")); - assertTrue(response.contains("HTTP/1.1 " + SC_NOT_FOUND + " Not Found")); + assertFalse(response.contains("HTTP/1.1 200 OK")); + assertTrue(response.contains("HTTP/1.1 404 Not Found")); } } From f5b2f44253dd25e062b919adb09557fae5a7b77e Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 13:21:45 +0100 Subject: [PATCH 52/67] =?UTF-8?q?kom=20up=20problem=20n=C3=A4r=20github=20?= =?UTF-8?q?k=C3=B6r=20ska=20ha=20fixat=20det=20tror=20jag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/example/StaticFileHandlerTest.java | 51 ++++++++----------- 1 file changed, 20 insertions(+), 31 deletions(-) diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java index fbad4adf..e1810ec6 100644 --- a/src/test/java/org/example/StaticFileHandlerTest.java +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -13,20 +13,21 @@ import static org.junit.jupiter.api.Assertions.*; import static org.assertj.core.api.Assertions.assertThat; - /** * Unit test class for verifying the behavior of the StaticFileHandler class. + * * This test class ensures that StaticFileHandler correctly handles GET requests * for static files, including both cases where the requested file exists and * where it does not. Temporary directories and files are utilized in tests to * ensure no actual file system dependencies during test execution. + * * Key functional aspects being tested include: * - Correct response status code and content for an existing file. * - Correct response status code and fallback behavior for a missing file. */ class StaticFileHandlerTest { - private StaticFileHandler createHandler(){ + private StaticFileHandler createHandler() { return new StaticFileHandler(tempDir.toString()); } @@ -37,9 +38,6 @@ private String sendRequest(String uri) throws IOException { return output.toString(); } - - //Junit creates a temporary folder which can be filled with temporary files that gets removed after tests - // Junit creates a temporary folder which can be filled with temporary files that gets removed after tests @TempDir Path tempDir; @@ -49,7 +47,6 @@ void setUp() { StaticFileHandler.clearCache(); } - @Test void testCaching_HitOnSecondRequest() throws IOException { // Arrange @@ -65,27 +62,23 @@ void testCaching_HitOnSecondRequest() throws IOException { assertThat(sizeAfterFirst).isEqualTo(sizeAfterSecond).isEqualTo(1); } - @Test void testSanitization_QueryString() throws IOException { Files.writeString(tempDir.resolve("index.html"), "Home"); assertThat(sendRequest("index.html?foo=bar")).contains("HTTP/1.1 200"); } - @Test void testSanitization_LeadingSlash() throws IOException { Files.writeString(tempDir.resolve("page.html"), "Page"); assertThat(sendRequest("/page.html")).contains("HTTP/1.1 200"); } - @Test void testSanitization_NullBytes() throws IOException { assertThat(sendRequest("file.html\0../../secret")).contains("HTTP/1.1 404"); } - @Test void testConcurrent_MultipleReads() throws InterruptedException, IOException { // Arrange @@ -105,7 +98,7 @@ void testConcurrent_MultipleReads() throws InterruptedException, IOException { for (int j = 0; j < 50; j++) { ByteArrayOutputStream out = new ByteArrayOutputStream(); handler.sendGetRequest(out, "shared.html"); - assertThat(out.toString()).contains("200"); + assertThat(out.toString()).contains("HTTP/1.1 200"); } } catch (IOException e) { throw new RuntimeException(e); @@ -119,41 +112,38 @@ void testConcurrent_MultipleReads() throws InterruptedException, IOException { }); threads[i].start(); } + // Vänta på alla trådar for (Thread t : threads) { t.join(); } + // Assert - Check if any child thread had assertion failures if (assertionErrors[0] != null) { throw assertionErrors[0]; - } // Assert - Cache ska bara ha EN entry + } + + // Assert - Cache ska bara ha EN entry assertThat(StaticFileHandler.getCacheStats().entries).isEqualTo(1); } - @Test void test_file_that_exists_should_return_200() throws IOException { - //Arrange - Path testFile = tempDir.resolve("test.html"); // Defines the path in the temp directory - Files.writeString(testFile, "Hello Test"); // Creates a text in that file + // Arrange + Path testFile = tempDir.resolve("test.html"); + Files.writeString(testFile, "Hello Test"); - //Using the new constructor in StaticFileHandler to reroute so the tests uses the temporary folder instead of the hardcoded www StaticFileHandler staticFileHandler = new StaticFileHandler(tempDir.toString()); - - //Using ByteArrayOutputStream instead of Outputstream during tests to capture the servers response in memory, fake stream ByteArrayOutputStream fakeOutput = new ByteArrayOutputStream(); - //Act - staticFileHandler.sendGetRequest(fakeOutput, "test.html"); //Get test.html and write the answer to fakeOutput - - //Assert - String response = fakeOutput.toString();//Converts the captured byte stream into a String for verification - - assertTrue(response.contains("HTTP/1.1 200 OK")); // Assert the status - assertTrue(response.contains("Hello Test")); //Assert the content in the file - - assertTrue(response.contains("Content-Type: text/html; charset=UTF-8")); // Verify the correct Content-type header + // Act + staticFileHandler.sendGetRequest(fakeOutput, "test.html"); + // Assert + String response = fakeOutput.toString(); + assertTrue(response.contains("HTTP/1.1 200 OK")); + assertTrue(response.contains("Hello Test")); + assertTrue(response.contains("Content-Type: text/html; charset=UTF-8")); } @Test @@ -168,14 +158,13 @@ void test_file_that_does_not_exists_should_return_404() throws IOException { // Assert String response = fakeOutput.toString(); assertTrue(response.contains("HTTP/1.1 404 Not Found")); - } @Test void test_path_traversal_should_return_403() throws IOException { // Arrange Path secret = tempDir.resolve("secret.txt"); - Files.writeString(secret,"TOP SECRET"); + Files.writeString(secret, "TOP SECRET"); Path webRoot = tempDir.resolve("www"); Files.createDirectories(webRoot); StaticFileHandler handler = new StaticFileHandler(webRoot.toString()); From d6f1d2602c82da38d3415f11005a456338291b68 Mon Sep 17 00:00:00 2001 From: Anna Ziafar <229710576+AnnaZiafar@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:48:39 +0100 Subject: [PATCH 53/67] Create LoggFilter (#83) * Add LoggFilter and update HttpResponseBuilder with method getStatusCode() in order to use it in logging * Refactor code * Change magic number to constans in httpresponsebuilder * Add LoggFilter and update HttpResponseBuilder with method getStatusCode() in order to use it in logging * Refactor code * Added tests -> LoggingFilterTest * Trigger rebuild * Git is hallucinating. Trying to fix problem * Final version (hopefully) --- .../org/example/filter/LoggingFilter.java | 42 +++++++++ .../org/example/filter/LoggingFilterTest.java | 91 +++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 src/main/java/org/example/filter/LoggingFilter.java create mode 100644 src/test/java/org/example/filter/LoggingFilterTest.java diff --git a/src/main/java/org/example/filter/LoggingFilter.java b/src/main/java/org/example/filter/LoggingFilter.java new file mode 100644 index 00000000..a978e108 --- /dev/null +++ b/src/main/java/org/example/filter/LoggingFilter.java @@ -0,0 +1,42 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; + +import java.util.logging.Logger; + +public class LoggingFilter implements Filter { + + private static final Logger logg = Logger.getLogger(LoggingFilter.class.getName()); + + @Override + public void init() { + //No initialization needed + } + + @Override + public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { + long startTime = System.nanoTime(); + + try { + chain.doFilter(request, response); + } catch (Exception e) { + if(response.getStatusCode() == HttpResponseBuilder.SC_OK) + response.setStatusCode(HttpResponseBuilder.SC_INTERNAL_SERVER_ERROR); + } finally { + long endTime = System.nanoTime(); + long processingTimeInMs = (endTime - startTime) / 1000000; + + String message = String.format("REQUEST: %s %s | STATUS: %s | TIME: %dms", + request.getMethod(), request.getPath(), response.getStatusCode(), processingTimeInMs); + + logg.info(message); + } + + } + + @Override + public void destroy() { + //No initialization needed + } +} diff --git a/src/test/java/org/example/filter/LoggingFilterTest.java b/src/test/java/org/example/filter/LoggingFilterTest.java new file mode 100644 index 00000000..3e9e248d --- /dev/null +++ b/src/test/java/org/example/filter/LoggingFilterTest.java @@ -0,0 +1,91 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.logging.Handler; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class LoggingFilterTest { + + @Mock Handler handler; + @Mock HttpRequest request; + @Mock HttpResponseBuilder response; + @Mock FilterChain chain; + + LoggingFilter filter = new LoggingFilter(); + Logger logger; + + @BeforeEach + void setup(){ + logger = Logger.getLogger(LoggingFilter.class.getName()); + logger.addHandler(handler); + + when(request.getMethod()).thenReturn("GET"); + when(request.getPath()).thenReturn("/index.html"); + } + + @AfterEach + void tearDown(){ + logger.removeHandler(handler); + } + + @Test + void loggingWorksWhenChainWorks(){ + when(response.getStatusCode()).thenReturn(HttpResponseBuilder.SC_OK); + + filter.doFilter(request, response, chain); + + verifyLogContent("REQUEST: GET /index.html | STATUS: 200 | TIME: "); + } + + @Test + void loggingWorksWhenErrorOccurs(){ + when(response.getStatusCode()).thenReturn(HttpResponseBuilder.SC_NOT_FOUND); + + doThrow(new RuntimeException()).when(chain).doFilter(request, response); + + filter.doFilter(request, response, chain); + + verifyLogContent("REQUEST: GET /index.html | STATUS: 404 | TIME: "); + } + + @Test + void statusChangesFrom200To500WhenErrorOccurs(){ + //Return status 200 first time. + //When error is thrown status should switch to 500 (if it was originally 200) + when(response.getStatusCode()) + .thenReturn(HttpResponseBuilder.SC_OK) + .thenReturn(HttpResponseBuilder.SC_INTERNAL_SERVER_ERROR); + + doThrow(new RuntimeException()).when(chain).doFilter(request, response); + + filter.doFilter(request, response, chain); + verify(response).setStatusCode(HttpResponseBuilder.SC_INTERNAL_SERVER_ERROR); + + verifyLogContent("REQUEST: GET /index.html | STATUS: 500 | TIME: "); + } + + private void verifyLogContent(String expectedMessage){ + //Use ArgumentCaptor to capture the actual message in the log + ArgumentCaptor logCaptor = ArgumentCaptor.forClass(LogRecord.class); + verify(handler).publish(logCaptor.capture()); + + String message = logCaptor.getValue().getMessage(); + + assertThat(message).contains(expectedMessage); + } + +} From 704f8d8498583401ad7c78be2f0880fa2759f565 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 13:52:20 +0100 Subject: [PATCH 54/67] =?UTF-8?q?Gjort=20det=20coderabbit=20b=C3=A4tt=20mi?= =?UTF-8?q?g=20om?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/example/CacheFilter.java | 99 +++++++++++++++---- src/main/java/org/example/FileCache.java | 14 ++- .../java/org/example/StaticFileHandler.java | 15 +-- .../org/example/StaticFileHandlerTest.java | 33 ++++--- 4 files changed, 119 insertions(+), 42 deletions(-) diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java index f2fc26a2..2e9f95b3 100644 --- a/src/main/java/org/example/CacheFilter.java +++ b/src/main/java/org/example/CacheFilter.java @@ -1,4 +1,3 @@ - package org.example; import java.io.IOException; @@ -7,6 +6,8 @@ import java.util.concurrent.atomic.AtomicLong; import java.util.logging.Logger; import java.util.logging.Level; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; /** * Thread-safe in-memory cache filter using ConcurrentHashMap @@ -55,7 +56,7 @@ public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { CacheEntry entry = cache.get(uri); if (entry != null) { entry.recordAccess(); - LOGGER.log(Level.FINE, "✓ Cache hit for: " + uri); + LOGGER.log(Level.FINE, " Cache hit for: " + uri); return entry.data; } @@ -65,14 +66,14 @@ public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { entry = cache.get(uri); if (entry != null) { entry.recordAccess(); - LOGGER.log(Level.FINE, "✓ Cache hit for: " + uri + " (from concurrent fetch)"); + LOGGER.log(Level.FINE, "Cache hit for: " + uri + " (from concurrent fetch)"); return entry.data; } // Fetch och cachelagra - LOGGER.log(Level.FINE, "✗ Cache miss for: " + uri); + LOGGER.log(Level.FINE, "Cache miss for: " + uri); byte[] fileBytes = provider.fetch(uri); if (fileBytes != null) { @@ -86,7 +87,7 @@ public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { private void addToCacheUnsafe(String uri, byte[] data) { // Guard mot oversized entries som kan blockera eviction if (data.length > MAX_CACHE_BYTES) { - LOGGER.log(Level.WARNING, "⚠️ Skipping cache for oversized file: " + uri + + LOGGER.log(Level.WARNING, "Skipping cache for oversized file: " + uri + " (" + (data.length / 1024 / 1024) + "MB > " + (MAX_CACHE_BYTES / 1024 / 1024) + "MB)"); return; @@ -99,7 +100,7 @@ private void addToCacheUnsafe(String uri, byte[] data) { // Om cache fortfarande är full efter eviction, hoppa över if (shouldEvict(data)) { - LOGGER.log(Level.WARNING, "⚠️ Cache full, skipping: " + uri); + LOGGER.log(Level.WARNING, " Cache full, skipping: " + uri); return; } @@ -137,7 +138,7 @@ private void evictLeastRecentlyUsedUnsafe() { CacheEntry removed = cache.remove(keyToRemove); if (removed != null) { currentBytes.addAndGet(-removed.data.length); - LOGGER.log(Level.FINE, "✗ Evicted from cache: " + keyToRemove + + LOGGER.log(Level.FINE, " Evicted from cache: " + keyToRemove + " (accesses: " + removed.accessCount.get() + ")"); } } @@ -153,19 +154,79 @@ public void clearCache() { currentBytes.set(0); } } - + /** + * Cache statistics record. + */ @Override - public CacheStats getStats() { - long totalAccesses = cache.values().stream() - .mapToLong(e -> e.accessCount.get()) - .sum(); - - return new CacheStats( - cache.size(), - currentBytes.get(), - MAX_CACHE_ENTRIES, - MAX_CACHE_BYTES, - totalAccesses + public FileCache.CacheStats getStats() { + return null; + } + + + class CacheStats { + public final int entries; + public final long bytes; + public final int maxEntries; + public final long maxBytes; + public final long totalAccesses; + + public CacheStats(int entries, long bytes, int maxEntries, long maxBytes, long totalAccesses) { + this.entries = entries; + this.bytes = bytes; + this.maxEntries = maxEntries; + this.maxBytes = maxBytes; + this.totalAccesses = totalAccesses; + } + + @Override + public String toString() { + String bytesFormatted = formatBytes(bytes); + String maxBytesFormatted = formatBytes(maxBytes); + + return String.format( + "CacheStats{entries=%d/%d, bytes=%s/%s, utilization=%.1f%%, accesses=%d}", + entries, maxEntries, bytesFormatted, maxBytesFormatted, + (double) bytes / maxBytes * 100, totalAccesses + ); + } + + private static String formatBytes(long bytes) { + if (bytes <= 0) return "0 B"; + final String[] units = new String[]{"B", "KB", "MB", "GB"}; + int digitGroups = (int) (Math.log10(bytes) / Math.log10(1024)); + return String.format("%.1f %s", bytes / Math.pow(1024, digitGroups), units[digitGroups]); + } + } + + /** + * Sanitizes URI by removing query strings, fragments, null bytes, and leading slashes. + * Also performs URL-decoding to normalize percent-encoded sequences. + */ + private String sanitizeUri(String uri) { + if (uri == null || uri.isEmpty()) { + return "index.html"; + } + + // Ta bort query string och fragment + int queryIndex = uri.indexOf('?'); + int fragmentIndex = uri.indexOf('#'); + int endIndex = Math.min( + queryIndex > 0 ? queryIndex : uri.length(), + fragmentIndex > 0 ? fragmentIndex : uri.length() ); + + uri = uri.substring(0, endIndex) + .replace("\0", "") + .replaceAll("^/+", ""); // Bort med leading slashes + + // URL-decode för att normalisera percent-encoded sequences (t.ex. %2e%2e -> ..) + try { + uri = URLDecoder.decode(uri, StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + LOGGER.log(Level.WARNING, "Ogiltig URL-kodning i URI: " + uri); + // Returna som den är om avkodning misslyckas; isPathTraversal kommer hantera det + } + + return uri.isEmpty() ? "index.html" : uri; } } diff --git a/src/main/java/org/example/FileCache.java b/src/main/java/org/example/FileCache.java index 8547b388..881c58c1 100644 --- a/src/main/java/org/example/FileCache.java +++ b/src/main/java/org/example/FileCache.java @@ -56,11 +56,21 @@ public CacheStats(int entries, long bytes, int maxEntries, long maxBytes, long t @Override public String toString() { + String bytesFormatted = formatBytes(bytes); + String maxBytesFormatted = formatBytes(maxBytes); + return String.format( - "CacheStats{entries=%d/%d, bytes=%d/%d, utilization=%.1f%%, accesses=%d}", - entries, maxEntries, bytes, maxBytes, + "CacheStats{entries=%d/%d, bytes=%s/%s, utilization=%.1f%%, accesses=%d}", + entries, maxEntries, bytesFormatted, maxBytesFormatted, (double) bytes / maxBytes * 100, totalAccesses ); } + + private static String formatBytes(long bytes) { + if (bytes <= 0) return "0 B"; + final String[] units = new String[]{"B", "KB", "MB", "GB"}; + int digitGroups = (int) (Math.log10(bytes) / Math.log10(1024)); + return String.format("%.1f %s", bytes / Math.pow(1024, digitGroups), units[digitGroups]); + } } } diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 3067361e..dcc5a5b9 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -74,7 +74,11 @@ private String generateCacheKey(String sanitizedUri) { * Also performs URL-decoding to normalize percent-encoded sequences. */ private String sanitizeUri(String uri) { - // Entydlig: ta bort query string och fragment + if (uri == null || uri.isEmpty()) { + return "index.html"; + } + + // Ta bort query string och fragment int queryIndex = uri.indexOf('?'); int fragmentIndex = uri.indexOf('#'); int endIndex = Math.min( @@ -86,18 +90,17 @@ private String sanitizeUri(String uri) { .replace("\0", "") .replaceAll("^/+", ""); // Bort med leading slashes - // URL-decode to normalize percent-encoded sequences (e.g., %2e%2e -> ..) + // URL-decode för att normalisera percent-encoded sequences (t.ex. %2e%2e -> ..) try { uri = URLDecoder.decode(uri, StandardCharsets.UTF_8); } catch (IllegalArgumentException e) { - LOGGER.log(Level.WARNING, "Invalid URL encoding in URI: " + uri); - // Return as-is if decoding fails; isPathTraversal will handle it + LOGGER.log(Level.WARNING, "Ogiltig URL-kodning i URI: " + uri); + // Returna som den är om avkodning misslyckas; isPathTraversal kommer hantera det } - return uri; + return uri.isEmpty() ? "index.html" : uri; } - /** /** * Kontrollerar om den begärda sökvägen försöker traversera utanför webroten. * Använder sökvägsnormalisering efter avkodning för att fånga traversalförsök. diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java index e1810ec6..dee16f4b 100644 --- a/src/test/java/org/example/StaticFileHandlerTest.java +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -85,12 +85,12 @@ void testConcurrent_MultipleReads() throws InterruptedException, IOException { Files.writeString(tempDir.resolve("shared.html"), "Data"); StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); - // Förvärmning + // Förvärmning - ladda filen i cache handler.sendGetRequest(new ByteArrayOutputStream(), "shared.html"); - // Act - 10 trådar läser samma fil 50 gånger varje + // Act - 10 trådar läser samma fil 50 gånger varje = 500 totala läsningar Thread[] threads = new Thread[10]; - final AssertionError[] assertionErrors = new AssertionError[1]; + final Exception[] threadError = new Exception[1]; for (int i = 0; i < 10; i++) { threads[i] = new Thread(() -> { @@ -98,16 +98,17 @@ void testConcurrent_MultipleReads() throws InterruptedException, IOException { for (int j = 0; j < 50; j++) { ByteArrayOutputStream out = new ByteArrayOutputStream(); handler.sendGetRequest(out, "shared.html"); - assertThat(out.toString()).contains("HTTP/1.1 200"); + String response = out.toString(); + + // Validera att svaret är korrekt + if (!response.contains("HTTP/1.1 200") || !response.contains("Data")) { + throw new AssertionError("Oväntad response: " + response.substring(0, Math.min(100, response.length()))); + } } - } catch (IOException e) { - throw new RuntimeException(e); - } catch (AssertionError e) { - // Capture assertion errors from child thread + } catch (Exception e) { synchronized (threads) { - assertionErrors[0] = e; + threadError[0] = e; } - throw e; } }); threads[i].start(); @@ -118,13 +119,15 @@ void testConcurrent_MultipleReads() throws InterruptedException, IOException { t.join(); } - // Assert - Check if any child thread had assertion failures - if (assertionErrors[0] != null) { - throw assertionErrors[0]; + // Assert - Kontrollera om någon tråd hade fel + if (threadError[0] != null) { + throw new AssertionError("Tråd-fel: " + threadError[0].getMessage(), threadError[0]); } - // Assert - Cache ska bara ha EN entry - assertThat(StaticFileHandler.getCacheStats().entries).isEqualTo(1); + // Assert - Cache ska bara ha EN entry för shared.html + FileCache.CacheStats stats = StaticFileHandler.getCacheStats(); + assertThat(stats.entries).isEqualTo(1); + assertThat(stats.totalAccesses).isGreaterThanOrEqualTo(500); } @Test From 3f107c6463b4330aa31767a559a1c55688d3085f Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 14:40:09 +0100 Subject: [PATCH 55/67] fixa problem --- src/main/java/org/example/CacheFilter.java | 84 +++---------------- src/main/java/org/example/FileCache.java | 5 ++ .../java/org/example/StaticFileHandler.java | 3 +- 3 files changed, 18 insertions(+), 74 deletions(-) diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java index 2e9f95b3..02bbb8c0 100644 --- a/src/main/java/org/example/CacheFilter.java +++ b/src/main/java/org/example/CacheFilter.java @@ -6,8 +6,6 @@ import java.util.concurrent.atomic.AtomicLong; import java.util.logging.Logger; import java.util.logging.Level; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; /** * Thread-safe in-memory cache filter using ConcurrentHashMap @@ -154,79 +152,19 @@ public void clearCache() { currentBytes.set(0); } } - /** - * Cache statistics record. - */ + @Override public FileCache.CacheStats getStats() { - return null; - } - - - class CacheStats { - public final int entries; - public final long bytes; - public final int maxEntries; - public final long maxBytes; - public final long totalAccesses; - - public CacheStats(int entries, long bytes, int maxEntries, long maxBytes, long totalAccesses) { - this.entries = entries; - this.bytes = bytes; - this.maxEntries = maxEntries; - this.maxBytes = maxBytes; - this.totalAccesses = totalAccesses; - } - - @Override - public String toString() { - String bytesFormatted = formatBytes(bytes); - String maxBytesFormatted = formatBytes(maxBytes); - - return String.format( - "CacheStats{entries=%d/%d, bytes=%s/%s, utilization=%.1f%%, accesses=%d}", - entries, maxEntries, bytesFormatted, maxBytesFormatted, - (double) bytes / maxBytes * 100, totalAccesses - ); - } - - private static String formatBytes(long bytes) { - if (bytes <= 0) return "0 B"; - final String[] units = new String[]{"B", "KB", "MB", "GB"}; - int digitGroups = (int) (Math.log10(bytes) / Math.log10(1024)); - return String.format("%.1f %s", bytes / Math.pow(1024, digitGroups), units[digitGroups]); - } - } - - /** - * Sanitizes URI by removing query strings, fragments, null bytes, and leading slashes. - * Also performs URL-decoding to normalize percent-encoded sequences. - */ - private String sanitizeUri(String uri) { - if (uri == null || uri.isEmpty()) { - return "index.html"; - } - - // Ta bort query string och fragment - int queryIndex = uri.indexOf('?'); - int fragmentIndex = uri.indexOf('#'); - int endIndex = Math.min( - queryIndex > 0 ? queryIndex : uri.length(), - fragmentIndex > 0 ? fragmentIndex : uri.length() + long totalAccesses = cache.values().stream() + .mapToLong(e -> e.accessCount.get()) + .sum(); + + return new FileCache.CacheStats( + cache.size(), + currentBytes.get(), + MAX_CACHE_ENTRIES, + MAX_CACHE_BYTES, + totalAccesses ); - - uri = uri.substring(0, endIndex) - .replace("\0", "") - .replaceAll("^/+", ""); // Bort med leading slashes - - // URL-decode för att normalisera percent-encoded sequences (t.ex. %2e%2e -> ..) - try { - uri = URLDecoder.decode(uri, StandardCharsets.UTF_8); - } catch (IllegalArgumentException e) { - LOGGER.log(Level.WARNING, "Ogiltig URL-kodning i URI: " + uri); - // Returna som den är om avkodning misslyckas; isPathTraversal kommer hantera det - } - - return uri.isEmpty() ? "index.html" : uri; } } diff --git a/src/main/java/org/example/FileCache.java b/src/main/java/org/example/FileCache.java index 881c58c1..c8f8eda0 100644 --- a/src/main/java/org/example/FileCache.java +++ b/src/main/java/org/example/FileCache.java @@ -46,6 +46,11 @@ class CacheStats { public final long maxBytes; public final long totalAccesses; + // Default-konstruktor för empty stats + public CacheStats() { + this(0, 0, 100, 50 * 1024 * 1024, 0); + } + public CacheStats(int entries, long bytes, int maxEntries, long maxBytes, long totalAccesses) { this.entries = entries; this.bytes = bytes; diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index dcc5a5b9..a485655a 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -133,7 +133,8 @@ private void sendErrorResponse(OutputStream outputStream, int statusCode, String //Diagnostik-metod public static FileCache.CacheStats getCacheStats() { - return SHARED_CACHE.getStats(); + FileCache.CacheStats stats = SHARED_CACHE.getStats(); + return stats != null ? stats : new FileCache.CacheStats(); } public static void clearCache() { From 27e627c7f9726a0a3440ebe24a7078a98fe23ff2 Mon Sep 17 00:00:00 2001 From: Anna Ziafar <229710576+AnnaZiafar@users.noreply.github.com> Date: Wed, 25 Feb 2026 15:13:15 +0100 Subject: [PATCH 56/67] Return status code 500 (#79) * Add error handling for when client request fail -> return internal server error 500 * Create ConnectionFactory in order to be able to test the TcpServer. Implement factory into TcpServer and App. * Create test to check whether handleClient throws internal server error when there is an exception * Modify test * Update logic -> seperate handleClient and processRequest in order to have open socket when handling internal server error * Exchange Printwriter to Outputstream in handleInternalServerError * Add error handling for when client request fail -> return internal server error 500 * Create ConnectionFactory in order to be able to test the TcpServer. Implement factory into TcpServer and App. * Create test to check whether handleClient throws internal server error when there is an exception * Modify test * Update logic -> seperate handleClient and processRequest in order to have open socket when handling internal server error * Exchange Printwriter to Outputstream in handleInternalServerError * Rebase main onto bransch. Update App -> add Connectionhandler::new Also update handleInternalServerError --- src/main/java/org/example/App.java | 3 +- .../java/org/example/ConnectionFactory.java | 7 +++ src/main/java/org/example/TcpServer.java | 49 +++++++++++++++++-- src/test/java/org/example/TcpServerTest.java | 40 +++++++++++++++ 4 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 src/main/java/org/example/ConnectionFactory.java create mode 100644 src/test/java/org/example/TcpServerTest.java diff --git a/src/main/java/org/example/App.java b/src/main/java/org/example/App.java index 75c2914b..966e9563 100644 --- a/src/main/java/org/example/App.java +++ b/src/main/java/org/example/App.java @@ -3,6 +3,7 @@ import org.example.config.AppConfig; import org.example.config.ConfigLoader; +import java.net.Socket; import java.nio.file.Path; public class App { @@ -16,7 +17,7 @@ public static void main(String[] args) { int port = resolvePort(args, appConfig.server().port()); - new TcpServer(port).start(); + new TcpServer(port, ConnectionHandler::new).start(); } static int resolvePort(String[] args, int configPort) { diff --git a/src/main/java/org/example/ConnectionFactory.java b/src/main/java/org/example/ConnectionFactory.java new file mode 100644 index 00000000..5f9ac7dc --- /dev/null +++ b/src/main/java/org/example/ConnectionFactory.java @@ -0,0 +1,7 @@ +package org.example; + +import java.net.Socket; + +public interface ConnectionFactory { + ConnectionHandler create(Socket socket); +} diff --git a/src/main/java/org/example/TcpServer.java b/src/main/java/org/example/TcpServer.java index 3f96b4d8..e0a3655d 100644 --- a/src/main/java/org/example/TcpServer.java +++ b/src/main/java/org/example/TcpServer.java @@ -1,15 +1,23 @@ package org.example; +import org.example.http.HttpResponseBuilder; + import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintWriter; import java.net.ServerSocket; import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.Map; public class TcpServer { private final int port; + private final ConnectionFactory connectionFactory; - public TcpServer(int port) { + public TcpServer(int port, ConnectionFactory connectionFactory) { this.port = port; + this.connectionFactory = connectionFactory; } public void start() { @@ -26,11 +34,42 @@ public void start() { } } - private void handleClient(Socket client) { - try (ConnectionHandler connectionHandler = new ConnectionHandler(client)) { - connectionHandler.runConnectionHandler(); + protected void handleClient(Socket client) { + try(client){ + processRequest(client); + } catch (Exception e) { + throw new RuntimeException("Failed to close socket", e); + } + } + + private void processRequest(Socket client) throws Exception { + ConnectionHandler handler = null; + try{ + handler = connectionFactory.create(client); + handler.runConnectionHandler(); } catch (Exception e) { - throw new RuntimeException("Error handling client connection " + e); + handleInternalServerError(client); + } finally { + if(handler != null) + handler.close(); + } + } + + + private void handleInternalServerError(Socket client){ + HttpResponseBuilder response = new HttpResponseBuilder(); + response.setStatusCode(HttpResponseBuilder.SC_INTERNAL_SERVER_ERROR); + response.setHeaders(Map.of("Content-Type", "text/plain; charset=utf-8")); + response.setBody("⚠️ Internal Server Error 500 ⚠️"); + + if (!client.isClosed()) { + try { + OutputStream out = client.getOutputStream(); + out.write(response.build()); + out.flush(); + } catch (IOException e) { + System.err.println("Failed to send 500 response: " + e.getMessage()); + } } } } diff --git a/src/test/java/org/example/TcpServerTest.java b/src/test/java/org/example/TcpServerTest.java new file mode 100644 index 00000000..a62b3e95 --- /dev/null +++ b/src/test/java/org/example/TcpServerTest.java @@ -0,0 +1,40 @@ +package org.example; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import java.net.Socket; + +class TcpServerTest { + + + @Test + void failedClientRequestShouldReturnError500() throws Exception{ + ConnectionFactory mockFactory = Mockito.mock(ConnectionFactory.class); + ConnectionHandler mockHandler = Mockito.mock(ConnectionHandler.class); + TcpServer server = new TcpServer(0, mockFactory); + + Socket mockSocket = Mockito.mock(Socket.class); + java.io.ByteArrayOutputStream outputStream = new java.io.ByteArrayOutputStream(); + + when(mockSocket.getOutputStream()).thenReturn(outputStream); + when(mockFactory.create(any(Socket.class))).thenReturn(mockHandler); + + Mockito.doThrow(new RuntimeException("Simulated Crash")) + .when(mockHandler).runConnectionHandler(); + + server.handleClient(mockSocket); + + String response = outputStream.toString(); + assertAll( + () -> assertTrue(response.contains("500")), + () -> assertTrue(response.contains("Internal Server Error 500")), + () -> assertTrue(response.contains("Content-Type: text/plain")) + ); + } +} From db0c574b39d9d91613913a902f3932fcb7b34280 Mon Sep 17 00:00:00 2001 From: AntonAhlqvist Date: Wed, 25 Feb 2026 17:56:57 +0100 Subject: [PATCH 57/67] Feature/LocaleFilterCookie (#92) * Re-commit LocaleFilterWithCookie + tests to clean branch for PR * Fix: make header lookups case-insensitive to avoid incorrect default locale fallback. --- .../filter/LocaleFilterWithCookie.java | 138 ++++++++++++++++++ .../filter/LocaleFilterWithCookieTest.java | 96 ++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 src/main/java/org/example/filter/LocaleFilterWithCookie.java create mode 100644 src/test/java/org/example/filter/LocaleFilterWithCookieTest.java diff --git a/src/main/java/org/example/filter/LocaleFilterWithCookie.java b/src/main/java/org/example/filter/LocaleFilterWithCookie.java new file mode 100644 index 00000000..28f452ca --- /dev/null +++ b/src/main/java/org/example/filter/LocaleFilterWithCookie.java @@ -0,0 +1,138 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; + +import java.util.Map; + +/** + * Filter that determines the preferred locale for an HTTP request using cookies and headers. + *

+ * First, it checks for a locale set in a cookie named "user-lang". If the cookie is missing, + * blank, or malformed, it falls back to the Accept-Language header. If neither is present + * or valid, the filter defaults to "en-US". + *

+ * The selected locale is stored in a ThreadLocal variable so it can be accessed throughout + * the processing of the request. + *

+ * This filter does not modify the response or stop the filter chain; it only sets the + * current locale and forwards the request to the next filter. + *

+ * ThreadLocal cleanup is performed after the filter chain completes to prevent memory leaks. + */ +public class LocaleFilterWithCookie implements Filter { + + private static final String DEFAULT_LOCALE = "en-US"; + private static final String LOCALE_COOKIE_NAME = "user-lang"; + private static final ThreadLocal currentLocale = new ThreadLocal<>(); + + @Override + public void init() { + } + + @Override + public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { + try { + String locale = resolveLocale(request); + currentLocale.set(locale); + + chain.doFilter(request, response); + } finally { + currentLocale.remove(); + } + } + + @Override + public void destroy() { + } + + public static String getCurrentLocale() { + String locale = currentLocale.get(); + if (locale != null) { + return locale; + } else { + return DEFAULT_LOCALE; + } + } + + private String resolveLocale(HttpRequest request) { + String cookieLocale = extractLocaleFromCookie(request); + if (cookieLocale != null && !cookieLocale.isBlank()) { + return cookieLocale; + } + + String headerLocale = extractLocaleFromHeader(request); + if (headerLocale != null && !headerLocale.isBlank()) { + return headerLocale; + } + + return DEFAULT_LOCALE; + } + + /** + * Extracts the locale from the "user-lang" cookie if present. + *

+ * If the cookie header is missing, blank, or malformed, returns null. + */ + private String extractLocaleFromCookie(HttpRequest request) { + Map headers = request.getHeaders(); + if (headers == null) { + return null; + } + + String cookieHeader = headers.entrySet().stream() + .filter(e -> e.getKey() != null && e.getKey().equalsIgnoreCase("Cookie")) + .map(Map.Entry::getValue) + .findFirst() + .orElse(null); + + if (cookieHeader == null || cookieHeader.isBlank()) { + return null; + } + + String[] cookies = cookieHeader.split(";"); + for (String cookie : cookies) { + String[] parts = cookie.trim().split("=", 2); + if (parts.length == 2) { + String name = parts[0].trim(); + String value = parts[1].trim(); + if (LOCALE_COOKIE_NAME.equals(name) && !value.isBlank()) { + return value; + } + } + } + + return null; + } + + /** + * Extracts the preferred locale from the Accept-Language header of the request. + *

+ * If the header is missing, blank, or malformed, returns null. + * The first language tag is used and any optional quality value (e.g., ";q=0.9") is stripped. + */ + private String extractLocaleFromHeader(HttpRequest request) { + Map headers = request.getHeaders(); + if (headers == null) { + return null; + } + + String acceptLanguage = headers.entrySet().stream() + .filter(e -> e.getKey() != null && e.getKey().equalsIgnoreCase("Accept-Language")) + .map(Map.Entry::getValue) + .findFirst() + .orElse(null); + + if (acceptLanguage == null || acceptLanguage.isBlank()) { + return null; + } + + String[] parts = acceptLanguage.split(","); + if (parts.length == 0 || parts[0].isBlank()) { + return null; + } + + String locale = parts[0].split(";")[0].trim(); + return locale.isEmpty() ? null : locale; + } +} diff --git a/src/test/java/org/example/filter/LocaleFilterWithCookieTest.java b/src/test/java/org/example/filter/LocaleFilterWithCookieTest.java new file mode 100644 index 00000000..e83603b9 --- /dev/null +++ b/src/test/java/org/example/filter/LocaleFilterWithCookieTest.java @@ -0,0 +1,96 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class LocaleFilterWithCookieTest { + + @Test + void testDefaultLocaleWhenNoHeaderOrCookie() { + HttpRequest request = new HttpRequest( + "GET", + "/", + "HTTP/1.1", + Map.of(), + null + ); + + LocaleFilterWithCookie filter = new LocaleFilterWithCookie(); + + filter.doFilter(request, new HttpResponseBuilder(), (req, res) -> { + assertEquals("en-US", LocaleFilterWithCookie.getCurrentLocale()); + }); + } + + @Test + void testLocaleFromHeader() { + HttpRequest request = new HttpRequest( + "GET", + "/", + "HTTP/1.1", + Map.of("Accept-Language", "fr-FR,fr;q=0.9"), + null + ); + + LocaleFilterWithCookie filter = new LocaleFilterWithCookie(); + + filter.doFilter(request, new HttpResponseBuilder(), (req, res) -> { + assertEquals("fr-FR", LocaleFilterWithCookie.getCurrentLocale()); + }); + } + + @Test + void testLocaleFromCookie() { + HttpRequest request = new HttpRequest( + "GET", + "/", + "HTTP/1.1", + Map.of("Cookie", "user-lang=es-ES; other=val"), + null + ); + + LocaleFilterWithCookie filter = new LocaleFilterWithCookie(); + + filter.doFilter(request, new HttpResponseBuilder(), (req, res) -> { + assertEquals("es-ES", LocaleFilterWithCookie.getCurrentLocale()); + }); + } + + @Test + void testBlankCookieFallsBackToHeader() { + HttpRequest request = new HttpRequest( + "GET", "/", "HTTP/1.1", + Map.of( + "Cookie", "user-lang=; other=value", + "Accept-Language", "fr-FR,fr;q=0.9" + ), + null + ); + + LocaleFilterWithCookie filter = new LocaleFilterWithCookie(); + filter.doFilter(request, new HttpResponseBuilder(), (req, res) -> { + assertEquals("fr-FR", LocaleFilterWithCookie.getCurrentLocale()); + }); + } + + @Test + void testCookieWithWhitespaceOnly() { + HttpRequest request = new HttpRequest( + "GET", "/", "HTTP/1.1", + Map.of( + "Cookie", "user-lang= " + ), + null + ); + + LocaleFilterWithCookie filter = new LocaleFilterWithCookie(); + filter.doFilter(request, new HttpResponseBuilder(), (req, res) -> { + assertEquals("en-US", LocaleFilterWithCookie.getCurrentLocale()); + }); + } +} From b7154fa8d01ccd46fb0add4f14f1fd7087208417 Mon Sep 17 00:00:00 2001 From: Johan Karlsson <93186588+gurkvatten@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:54:34 +0100 Subject: [PATCH 58/67] added brotli4j (#94) --- pom.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pom.xml b/pom.xml index 8a82b235..e747e6aa 100644 --- a/pom.xml +++ b/pom.xml @@ -65,6 +65,11 @@ jackson-dataformat-yaml 3.0.3 + + com.aayushatharva.brotli4j + brotli4j + 1.20.0 + From d87171b7c5a951b7774b322826efbfa75ab312bf Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 20:37:45 +0100 Subject: [PATCH 59/67] fixat mer --- src/main/java/org/example/CacheFilter.java | 170 ------------------ src/main/java/org/example/FileCache.java | 87 ++------- .../java/org/example/StaticFileHandler.java | 93 ++-------- .../org/example/StaticFileHandlerTest.java | 35 +--- 4 files changed, 43 insertions(+), 342 deletions(-) delete mode 100644 src/main/java/org/example/CacheFilter.java diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java deleted file mode 100644 index 02bbb8c0..00000000 --- a/src/main/java/org/example/CacheFilter.java +++ /dev/null @@ -1,170 +0,0 @@ -package org.example; - -import java.io.IOException; -import java.util.Comparator; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicLong; -import java.util.logging.Logger; -import java.util.logging.Level; - -/** - * Thread-safe in-memory cache filter using ConcurrentHashMap - * Handles caching with LRU eviction for large files - * Implements the FileCache interface for pluggable cache implementations - */ -public class CacheFilter implements FileCache { - private static final Logger LOGGER = Logger.getLogger(CacheFilter.class.getName()); - private static final int MAX_CACHE_ENTRIES = 100; - private static final long MAX_CACHE_BYTES = 50 * 1024 * 1024;// 50MB - - // Lock-free concurrent cache - private final ConcurrentHashMap cache = - new ConcurrentHashMap<>(16, 0.75f, 16); - - private final AtomicLong currentBytes = new AtomicLong(0); - private final Object writeLock = new Object(); // För atomära operationer - - /** - * Cache entry med metadata för LRU tracking - */ - private static class CacheEntry { - final byte[] data; - final AtomicLong lastAccessTime; - final AtomicLong accessCount; - - CacheEntry(byte[] data) { - this.data = data; - this.lastAccessTime = new AtomicLong(System.currentTimeMillis()); - this.accessCount = new AtomicLong(0); - } - - void recordAccess() { - accessCount.incrementAndGet(); - lastAccessTime.set(System.currentTimeMillis()); - } - } - - /** - * Hämta från cache eller fetch från provider (thread-safe) - * Använder double-checked locking för att undvika TOCTOU-race - */ - @Override - public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { - // First check - lock-free read (snabb väg) - CacheEntry entry = cache.get(uri); - if (entry != null) { - entry.recordAccess(); - LOGGER.log(Level.FINE, " Cache hit for: " + uri); - return entry.data; - } - - // Cache miss - fetch från provider under lock - synchronized (writeLock) { - // Second check - verifierar att ingen annan tråd fetchade medan vi väntade på lock - entry = cache.get(uri); - if (entry != null) { - entry.recordAccess(); - LOGGER.log(Level.FINE, "Cache hit for: " + uri + " (from concurrent fetch)"); - return entry.data; - } - - - - // Fetch och cachelagra - LOGGER.log(Level.FINE, "Cache miss for: " + uri); - byte[] fileBytes = provider.fetch(uri); - - if (fileBytes != null) { - addToCacheUnsafe(uri, fileBytes); - } - - return fileBytes; - } - } - - private void addToCacheUnsafe(String uri, byte[] data) { - // Guard mot oversized entries som kan blockera eviction - if (data.length > MAX_CACHE_BYTES) { - LOGGER.log(Level.WARNING, "Skipping cache for oversized file: " + uri + - " (" + (data.length / 1024 / 1024) + "MB > " + - (MAX_CACHE_BYTES / 1024 / 1024) + "MB)"); - return; - } - - // Evicta medan nödvändigt (med empty-check för infinite loop) - while (shouldEvict(data) && !cache.isEmpty()) { - evictLeastRecentlyUsedUnsafe(); - } - - // Om cache fortfarande är full efter eviction, hoppa över - if (shouldEvict(data)) { - LOGGER.log(Level.WARNING, " Cache full, skipping: " + uri); - return; - } - - CacheEntry newEntry = new CacheEntry(data); - CacheEntry oldEntry = cache.put(uri, newEntry); - - // Uppdatera byte-count - if (oldEntry != null) { - currentBytes.addAndGet(-oldEntry.data.length); - } - currentBytes.addAndGet(data.length); - } - - /** - * Kontrollera om vi behöver evicta - */ - private boolean shouldEvict(byte[] newValue) { - return cache.size() >= MAX_CACHE_ENTRIES || - (currentBytes.get() + newValue.length) > MAX_CACHE_BYTES; - } - - /** - * Evicta minst nyligen använd entry (MÅSTE VARA UNDER LOCK) - */ - private void evictLeastRecentlyUsedUnsafe() { - if (cache.isEmpty()) return; - - // Hitta entry med minst senaste access - String keyToRemove = cache.entrySet().stream() - .min(Comparator.comparingLong(e -> e.getValue().lastAccessTime.get())) - .map(java.util.Map.Entry::getKey) - .orElse(null); - - if (keyToRemove != null) { - CacheEntry removed = cache.remove(keyToRemove); - if (removed != null) { - currentBytes.addAndGet(-removed.data.length); - LOGGER.log(Level.FINE, " Evicted from cache: " + keyToRemove + - " (accesses: " + removed.accessCount.get() + ")"); - } - } - } - - /** - * Rensa cache atomärt - */ - @Override - public void clearCache() { - synchronized (writeLock) { - cache.clear(); - currentBytes.set(0); - } - } - - @Override - public FileCache.CacheStats getStats() { - long totalAccesses = cache.values().stream() - .mapToLong(e -> e.accessCount.get()) - .sum(); - - return new FileCache.CacheStats( - cache.size(), - currentBytes.get(), - MAX_CACHE_ENTRIES, - MAX_CACHE_BYTES, - totalAccesses - ); - } -} diff --git a/src/main/java/org/example/FileCache.java b/src/main/java/org/example/FileCache.java index c8f8eda0..df7895b7 100644 --- a/src/main/java/org/example/FileCache.java +++ b/src/main/java/org/example/FileCache.java @@ -1,81 +1,24 @@ package org.example; -import java.io.IOException; +import java.util.concurrent.ConcurrentHashMap; -/** - * Interface for pluggable file caching implementations. - * Allows for different cache backends (in-memory, Redis, Caffeine, etc.) - */ -public interface FileCache { - /** - * Retrieves cached file or fetches from provider if not cached. - * Implementation must be thread-safe. - * - * @param cacheKey Unique cache key (typically includes webRoot + uri) - * @param provider Function to fetch file bytes if not cached - * @return File bytes, or null if file not found - * @throws IOException if fetch fails - */ - byte[] getOrFetch(String cacheKey, FileProvider provider) throws IOException; +public class FileCache { + private static final ConcurrentHashMap cache = new ConcurrentHashMap<>(); - /** - * Clear all cached entries. - */ - void clearCache(); - - /** - * Get cache statistics. - */ - CacheStats getStats(); - - /** - * Functional interface for file fetching providers. - */ - @FunctionalInterface - interface FileProvider { - byte[] fetch(String uri) throws IOException; + public static byte[] get(String key) { + return cache.get(key); } - /** - * Cache statistics record. - */ - class CacheStats { - public final int entries; - public final long bytes; - public final int maxEntries; - public final long maxBytes; - public final long totalAccesses; - - // Default-konstruktor för empty stats - public CacheStats() { - this(0, 0, 100, 50 * 1024 * 1024, 0); - } - - public CacheStats(int entries, long bytes, int maxEntries, long maxBytes, long totalAccesses) { - this.entries = entries; - this.bytes = bytes; - this.maxEntries = maxEntries; - this.maxBytes = maxBytes; - this.totalAccesses = totalAccesses; - } - - @Override - public String toString() { - String bytesFormatted = formatBytes(bytes); - String maxBytesFormatted = formatBytes(maxBytes); - - return String.format( - "CacheStats{entries=%d/%d, bytes=%s/%s, utilization=%.1f%%, accesses=%d}", - entries, maxEntries, bytesFormatted, maxBytesFormatted, - (double) bytes / maxBytes * 100, totalAccesses - ); - } + public static void put(String key, byte[] content) { + cache.put(key, content); + } - private static String formatBytes(long bytes) { - if (bytes <= 0) return "0 B"; - final String[] units = new String[]{"B", "KB", "MB", "GB"}; - int digitGroups = (int) (Math.log10(bytes) / Math.log10(1024)); - return String.format("%.1f %s", bytes / Math.pow(1024, digitGroups), units[digitGroups]); - } + public static void clear() { + cache.clear(); + } + public static void clearCache() { + FileCache.clear(); } + } + diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index a485655a..ca4e9b42 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -1,34 +1,22 @@ + package org.example; import org.example.http.HttpResponseBuilder; -import static org.example.http.HttpResponseBuilder.*; - import java.io.File; import java.io.IOException; import java.io.OutputStream; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.Map; -import java.util.logging.Logger; -import java.util.logging.Level; public class StaticFileHandler { private static final String DEFAULT_WEB_ROOT = "www"; - private static final Logger LOGGER = Logger.getLogger(StaticFileHandler.class.getName()); - - // EN shared cache för alla threads - private static final FileCache SHARED_CACHE = new CacheFilter(); - private final String webRoot; public StaticFileHandler() { this(DEFAULT_WEB_ROOT); } - public StaticFileHandler(String webRoot) { this.webRoot = webRoot; } @@ -37,17 +25,18 @@ public void sendGetRequest(OutputStream outputStream, String uri) throws IOExcep String sanitizedUri = sanitizeUri(uri); if (isPathTraversal(sanitizedUri)) { - sendErrorResponse(outputStream, 403, "Forbidden"); + writeResponse(outputStream, 403, "Forbidden"); return; } try { - // Cache-nyckel inkluderar nu webRoot för att undvika collisions - String cacheKey = generateCacheKey(sanitizedUri); - byte[] fileBytes = SHARED_CACHE.getOrFetch(cacheKey, - ignoredPath -> Files.readAllBytes(new File(webRoot, sanitizedUri).toPath()) - ); + String cacheKey = webRoot + ":" + sanitizedUri; + byte[] fileBytes = FileCache.get(cacheKey); + if (fileBytes == null) { + fileBytes = Files.readAllBytes(new File(webRoot, sanitizedUri).toPath()); + FileCache.put(cacheKey, fileBytes); + } HttpResponseBuilder response = new HttpResponseBuilder(); response.setContentTypeFromFilename(sanitizedUri); @@ -56,88 +45,44 @@ public void sendGetRequest(OutputStream outputStream, String uri) throws IOExcep outputStream.flush(); } catch (IOException e) { - LOGGER.log(Level.FINE, "File not found or read error: " + uri, e); - sendErrorResponse(outputStream, 404, "Not Found"); + writeResponse(outputStream, 404, "Not Found"); } } - /** - * Generates a unique cache key that includes webRoot to prevent collisions - * between different handler instances - */ - private String generateCacheKey(String sanitizedUri) { - return webRoot + ":" + sanitizedUri; - } - - /** - * Sanitizes URI by removing query strings, fragments, null bytes, and leading slashes. - * Also performs URL-decoding to normalize percent-encoded sequences. - */ private String sanitizeUri(String uri) { - if (uri == null || uri.isEmpty()) { - return "index.html"; - } + if (uri == null || uri.isEmpty()) return "index.html"; - // Ta bort query string och fragment - int queryIndex = uri.indexOf('?'); - int fragmentIndex = uri.indexOf('#'); int endIndex = Math.min( - queryIndex > 0 ? queryIndex : uri.length(), - fragmentIndex > 0 ? fragmentIndex : uri.length() + uri.indexOf('?') < 0 ? uri.length() : uri.indexOf('?'), + uri.indexOf('#') < 0 ? uri.length() : uri.indexOf('#') ); - uri = uri.substring(0, endIndex) + return uri.substring(0, endIndex) .replace("\0", "") - .replaceAll("^/+", ""); // Bort med leading slashes - - // URL-decode för att normalisera percent-encoded sequences (t.ex. %2e%2e -> ..) - try { - uri = URLDecoder.decode(uri, StandardCharsets.UTF_8); - } catch (IllegalArgumentException e) { - LOGGER.log(Level.WARNING, "Ogiltig URL-kodning i URI: " + uri); - // Returna som den är om avkodning misslyckas; isPathTraversal kommer hantera det - } - - return uri.isEmpty() ? "index.html" : uri; + .replaceAll("^/+", "") + .replaceAll("^$", "index.html"); } - /** - * Kontrollerar om den begärda sökvägen försöker traversera utanför webroten. - * Använder sökvägsnormalisering efter avkodning för att fånga traversalförsök. - */ private boolean isPathTraversal(String uri) { try { - // Använd absolutsökväg + normalisera istället för toRealPath() för att undvika - // krav på att katalogen existerar och för att hantera symboliska länkar säkert Path webRootPath = Paths.get(webRoot).toAbsolutePath().normalize(); Path requestedPath = webRootPath.resolve(uri).normalize(); - - // Returnera true om den begärda sökvägen inte ligger under webroten return !requestedPath.startsWith(webRootPath); } catch (Exception e) { - // Om något går fel under sökvägsvalideringen, tillåt inte åtkomst (säker utgång) - LOGGER.log(Level.WARNING, "Sökvägstraversalkontroll misslyckades för: " + uri, e); return true; } } - private void sendErrorResponse(OutputStream outputStream, int statusCode, String statusMessage) throws IOException { + private void writeResponse(OutputStream outputStream, int statusCode, String statusMessage) throws IOException { HttpResponseBuilder response = new HttpResponseBuilder(); response.setStatusCode(statusCode); - response.setHeaders(Map.of("Content-Type", "text/html; charset=UTF-8")); - String body = "

" + statusCode + " " + statusMessage + "

"; - response.setBody(body); + response.setHeader("Content-Type", "text/html; charset=UTF-8"); + response.setBody(String.format("

%d %s

", statusCode, statusMessage)); outputStream.write(response.build()); outputStream.flush(); } - //Diagnostik-metod - public static FileCache.CacheStats getCacheStats() { - FileCache.CacheStats stats = SHARED_CACHE.getStats(); - return stats != null ? stats : new FileCache.CacheStats(); - } - public static void clearCache() { - SHARED_CACHE.clearCache(); + FileCache.clear(); } } diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java index dee16f4b..1b35d5d5 100644 --- a/src/test/java/org/example/StaticFileHandlerTest.java +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -27,7 +27,7 @@ */ class StaticFileHandlerTest { - private StaticFileHandler createHandler() { + private StaticFileHandler createHandler() throws IOException { return new StaticFileHandler(tempDir.toString()); } @@ -53,15 +53,15 @@ void testCaching_HitOnSecondRequest() throws IOException { Files.writeString(tempDir.resolve("cached.html"), "Content"); // Act - sendRequest("cached.html"); - int sizeAfterFirst = StaticFileHandler.getCacheStats().entries; - sendRequest("cached.html"); - int sizeAfterSecond = StaticFileHandler.getCacheStats().entries; + String response1 = sendRequest("cached.html"); + String response2 = sendRequest("cached.html"); // Assert - assertThat(sizeAfterFirst).isEqualTo(sizeAfterSecond).isEqualTo(1); + assertThat(response1).contains("HTTP/1.1 200"); + assertThat(response2).contains("HTTP/1.1 200"); } + @Test void testSanitization_QueryString() throws IOException { Files.writeString(tempDir.resolve("index.html"), "Home"); @@ -85,10 +85,9 @@ void testConcurrent_MultipleReads() throws InterruptedException, IOException { Files.writeString(tempDir.resolve("shared.html"), "Data"); StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); - // Förvärmning - ladda filen i cache handler.sendGetRequest(new ByteArrayOutputStream(), "shared.html"); - // Act - 10 trådar läser samma fil 50 gånger varje = 500 totala läsningar + // Act - 10 trådar läser samma fil 50 gånger varje Thread[] threads = new Thread[10]; final Exception[] threadError = new Exception[1]; @@ -99,10 +98,9 @@ void testConcurrent_MultipleReads() throws InterruptedException, IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); handler.sendGetRequest(out, "shared.html"); String response = out.toString(); - - // Validera att svaret är korrekt + if (!response.contains("HTTP/1.1 200") || !response.contains("Data")) { - throw new AssertionError("Oväntad response: " + response.substring(0, Math.min(100, response.length()))); + throw new AssertionError("Oväntad response"); } } } catch (Exception e) { @@ -113,21 +111,6 @@ void testConcurrent_MultipleReads() throws InterruptedException, IOException { }); threads[i].start(); } - - // Vänta på alla trådar - for (Thread t : threads) { - t.join(); - } - - // Assert - Kontrollera om någon tråd hade fel - if (threadError[0] != null) { - throw new AssertionError("Tråd-fel: " + threadError[0].getMessage(), threadError[0]); - } - - // Assert - Cache ska bara ha EN entry för shared.html - FileCache.CacheStats stats = StaticFileHandler.getCacheStats(); - assertThat(stats.entries).isEqualTo(1); - assertThat(stats.totalAccesses).isGreaterThanOrEqualTo(500); } @Test From c74b88b6c4954728a3dd7927cefc6f6161982a55 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 20:57:53 +0100 Subject: [PATCH 60/67] =?UTF-8?q?fixat=20mer=20fr=C3=A5n=20coderabbit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/example/FileCache.java | 6 ++---- src/main/java/org/example/StaticFileHandler.java | 7 ++++++- src/test/java/org/example/StaticFileHandlerTest.java | 12 ++++++++++-- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/example/FileCache.java b/src/main/java/org/example/FileCache.java index df7895b7..9f4b8826 100644 --- a/src/main/java/org/example/FileCache.java +++ b/src/main/java/org/example/FileCache.java @@ -5,6 +5,8 @@ public class FileCache { private static final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + private FileCache() {} + public static byte[] get(String key) { return cache.get(key); } @@ -16,9 +18,5 @@ public static void put(String key, byte[] content) { public static void clear() { cache.clear(); } - public static void clearCache() { - FileCache.clear(); - } - } diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index ca4e9b42..67cf5454 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -8,6 +8,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.file.NoSuchFileException; + public class StaticFileHandler { private static final String DEFAULT_WEB_ROOT = "www"; @@ -44,11 +46,14 @@ public void sendGetRequest(OutputStream outputStream, String uri) throws IOExcep outputStream.write(response.build()); outputStream.flush(); - } catch (IOException e) { + } catch (NoSuchFileException e) { writeResponse(outputStream, 404, "Not Found"); + } catch (IOException e) { + writeResponse(outputStream, 500, "Internal Server Error"); } } + private String sanitizeUri(String uri) { if (uri == null || uri.isEmpty()) return "index.html"; diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java index 1b35d5d5..49c62bf5 100644 --- a/src/test/java/org/example/StaticFileHandlerTest.java +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -87,7 +87,7 @@ void testConcurrent_MultipleReads() throws InterruptedException, IOException { handler.sendGetRequest(new ByteArrayOutputStream(), "shared.html"); - // Act - 10 trådar läser samma fil 50 gånger varje + // Act - 10 threads reading same file 50 times each Thread[] threads = new Thread[10]; final Exception[] threadError = new Exception[1]; @@ -100,7 +100,7 @@ void testConcurrent_MultipleReads() throws InterruptedException, IOException { String response = out.toString(); if (!response.contains("HTTP/1.1 200") || !response.contains("Data")) { - throw new AssertionError("Oväntad response"); + throw new AssertionError("Unexpected response"); } } } catch (Exception e) { @@ -111,7 +111,15 @@ void testConcurrent_MultipleReads() throws InterruptedException, IOException { }); threads[i].start(); } + + // Wait for all threads to complete + for (Thread thread : threads) { + thread.join(); } + + // Assert + assertNull(threadError[0], "Thread threw exception: " + (threadError[0] != null ? threadError[0].getMessage() : "")); +} @Test void test_file_that_exists_should_return_200() throws IOException { From baa39811b63bc9781229aabd07573fab943d613e Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 21:38:45 +0100 Subject: [PATCH 61/67] =?UTF-8?q?daniel=20har=20hj=C3=A4lp=20mig=20med=20?= =?UTF-8?q?=C3=A4ndringar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/example/ConnectionHandler.java | 63 +++++++++++-------- 1 file changed, 38 insertions(+), 25 deletions(-) diff --git a/src/main/java/org/example/ConnectionHandler.java b/src/main/java/org/example/ConnectionHandler.java index 9fc219d4..2e6d0cb7 100644 --- a/src/main/java/org/example/ConnectionHandler.java +++ b/src/main/java/org/example/ConnectionHandler.java @@ -15,26 +15,23 @@ import java.net.Socket; public class ConnectionHandler implements AutoCloseable { - - Socket client; - String uri; + private final Socket client; private final List filters; public ConnectionHandler(Socket client) { this.client = client; this.filters = buildFilters(); } -private List buildFilters() { + + private List buildFilters() { List list = new ArrayList<>(); AppConfig config = ConfigLoader.get(); AppConfig.IpFilterConfig ipFilterConfig = config.ipFilter(); if (Boolean.TRUE.equals(ipFilterConfig.enabled())) { list.add(createIpFilterFromConfig(ipFilterConfig)); } - // Add more filters here... return list; - } - + } public void runConnectionHandler() throws IOException { HttpParser parser = new HttpParser(); @@ -53,6 +50,7 @@ public void runConnectionHandler() throws IOException { String clientIp = client.getInetAddress().getHostAddress(); request.setAttribute("clientIp", clientIp); + // Apply security filters HttpResponseBuilder response = applyFilters(request); int statusCode = response.getStatusCode(); @@ -64,31 +62,49 @@ public void runConnectionHandler() throws IOException { return; } - resolveTargetFile(parser.getUri()); + // Sanitize URI here (clean it) + String sanitizedUri = sanitizeUri(parser.getUri()); + String cacheKey = "www:" + sanitizedUri; + + // Check cache FIRST + byte[] cachedBytes = FileCache.get(cacheKey); + if (cachedBytes != null) { + System.out.println("✓ Cache HIT: " + sanitizedUri); + HttpResponseBuilder cachedResp = new HttpResponseBuilder(); + cachedResp.setContentTypeFromFilename(sanitizedUri); + cachedResp.setBody(cachedBytes); + client.getOutputStream().write(cachedResp.build()); + client.getOutputStream().flush(); + return; + } + + // Cache miss - StaticFileHandler reads and caches + System.out.println("✗ Cache MISS: " + sanitizedUri); StaticFileHandler sfh = new StaticFileHandler(); - sfh.sendGetRequest(client.getOutputStream(), uri); + sfh.sendGetRequest(client.getOutputStream(), sanitizedUri); + } + + private String sanitizeUri(String uri) { + if (uri == null || uri.isEmpty()) return "index.html"; + + int endIndex = Math.min( + uri.indexOf('?') < 0 ? uri.length() : uri.indexOf('?'), + uri.indexOf('#') < 0 ? uri.length() : uri.indexOf('#') + ); + + return uri.substring(0, endIndex) + .replace("\0", "") + .replaceAll("^/+", "") + .replaceAll("^$", "index.html"); } private HttpResponseBuilder applyFilters(HttpRequest request) { HttpResponseBuilder response = new HttpResponseBuilder(); - FilterChainImpl chain = new FilterChainImpl(filters); chain.doFilter(request, response); - return response; } - private void resolveTargetFile(String uri) { - if (uri.matches("/$")) { //matches(/) - this.uri = "index.html"; - } else if (uri.matches("^(?!.*\\.html$).*$")) { - this.uri = uri.concat(".html"); - } else { - this.uri = uri; - } - - } - @Override public void close() throws Exception { client.close(); @@ -97,19 +113,16 @@ public void close() throws Exception { private IpFilter createIpFilterFromConfig(AppConfig.IpFilterConfig config) { IpFilter filter = new IpFilter(); - // Set mode if ("ALLOWLIST".equalsIgnoreCase(config.mode())) { filter.setMode(IpFilter.FilterMode.ALLOWLIST); } else { filter.setMode(IpFilter.FilterMode.BLOCKLIST); } - // Add blocked IPs for (String ip : config.blockedIps()) { filter.addBlockedIp(ip); } - // Add allowed IPs for (String ip : config.allowedIps()) { filter.addAllowedIp(ip); } From fa1599ab6823fb91c2f67e9f45b0f0b3d87ae21b Mon Sep 17 00:00:00 2001 From: Rickard Ankar Date: Thu, 26 Feb 2026 09:09:24 +0100 Subject: [PATCH 62/67] Issue/69 remove html concat (#73) * Remove .html concat to support all files * Add test for jpg handling in ConnectionHandler (TDD:RED), related to #69 * Make ConnectionHandler testable with webroot parameter * Fix: Strip leading slash from URI to prevent absolute path (coderabbit) --- .../java/org/example/ConnectionHandler.java | 32 ++++-- .../java/org/example/config/ConfigLoader.java | 2 +- src/main/resources/test.jpg | Bin 0 -> 5819 bytes .../org/example/ConnectionHandlerTest.java | 107 ++++++++++++++++++ www/test.jpg | Bin 0 -> 5819 bytes 5 files changed, 130 insertions(+), 11 deletions(-) create mode 100644 src/main/resources/test.jpg create mode 100644 src/test/java/org/example/ConnectionHandlerTest.java create mode 100644 www/test.jpg diff --git a/src/main/java/org/example/ConnectionHandler.java b/src/main/java/org/example/ConnectionHandler.java index 9fc219d4..1fcc29fb 100644 --- a/src/main/java/org/example/ConnectionHandler.java +++ b/src/main/java/org/example/ConnectionHandler.java @@ -19,12 +19,21 @@ public class ConnectionHandler implements AutoCloseable { Socket client; String uri; private final List filters; + String webRoot; public ConnectionHandler(Socket client) { this.client = client; this.filters = buildFilters(); + this.webRoot = null; } -private List buildFilters() { + + public ConnectionHandler(Socket client, String webRoot) { + this.client = client; + this.webRoot = webRoot; + this.filters = buildFilters(); + } + + private List buildFilters() { List list = new ArrayList<>(); AppConfig config = ConfigLoader.get(); AppConfig.IpFilterConfig ipFilterConfig = config.ipFilter(); @@ -33,10 +42,17 @@ private List buildFilters() { } // Add more filters here... return list; - } - + } public void runConnectionHandler() throws IOException { + StaticFileHandler sfh; + + if (webRoot != null) { + sfh = new StaticFileHandler(webRoot); + } else { + sfh = new StaticFileHandler(); + } + HttpParser parser = new HttpParser(); parser.setReader(client.getInputStream()); parser.parseRequest(); @@ -65,7 +81,6 @@ public void runConnectionHandler() throws IOException { } resolveTargetFile(parser.getUri()); - StaticFileHandler sfh = new StaticFileHandler(); sfh.sendGetRequest(client.getOutputStream(), uri); } @@ -79,14 +94,11 @@ private HttpResponseBuilder applyFilters(HttpRequest request) { } private void resolveTargetFile(String uri) { - if (uri.matches("/$")) { //matches(/) + if (uri == null || "/".equals(uri)) { this.uri = "index.html"; - } else if (uri.matches("^(?!.*\\.html$).*$")) { - this.uri = uri.concat(".html"); } else { - this.uri = uri; + this.uri = uri.startsWith("/") ? uri.substring(1) : uri; } - } @Override @@ -117,4 +129,4 @@ private IpFilter createIpFilterFromConfig(AppConfig.IpFilterConfig config) { filter.init(); return filter; } -} +} \ No newline at end of file diff --git a/src/main/java/org/example/config/ConfigLoader.java b/src/main/java/org/example/config/ConfigLoader.java index e69f784d..96274158 100644 --- a/src/main/java/org/example/config/ConfigLoader.java +++ b/src/main/java/org/example/config/ConfigLoader.java @@ -65,7 +65,7 @@ private static ObjectMapper createMapperFor(Path configPath) { } } - static void resetForTests() { + public static void resetForTests() { cached = null; } } diff --git a/src/main/resources/test.jpg b/src/main/resources/test.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8bf506459723345f8af76c7a828047d568b67ba9 GIT binary patch literal 5819 zcmcgwcQjmWw;x^f(M5|vbctxuiJlO_h!(th3xeoGLV^eqCQ)N1dYeHQWAy0Vs3U6h zZuH)+y!ZRQ_q%t!f84v)U3Z_ge$P33KW9IC?dLgr{m#Dnc{Kx|d8nbS0l>op0PwCa zz|{mm6+lElNJvOPbPYsAM8qT%q$JluO-_D;0!U3u3#0}D=@{9V=;&`U0D;V0%s1KD zIXOA$m_R%r4jwiRPLAJB@UCAaAts?BC8gq^2hwx=ujT3o07wd$1)!Q7cO&(9B$|c}Ks5+iGp>=@Wzw z+C2$@WviHn=W0$3E9+MPkNhiuS&?hw((ij{$Myo0 z?xbo2P`0*v{&+*^KxO?D^+97ci{s%!>_`_#G(9TnbyO7TU!X;B&x=j}xool1rMyuz zPYaV)P);s}+t*|^%`tz`RgY!>5;bSu|8NN#-L}}fMGqghELWDLjtF!KHi|8fYW|TB zJ;pzk#Ghb21mkzSZAC7Q4yqGcEuxz7>vXDAo2Xb4D|5Da)dM~!{K&GP*#m&e$t+bP z>?IJQ4B5<9)ZpiqYgyyajJyyYO^7J1T-NJfgY>D(GJW2YbIbP=mg!P>b`$*bpF$uf zrXM*3dgV{#UUBR(u7+zd$C=92T?UdldKsEyf=a1ab+<7Ut^N-S>89Un#(893oJ?$&N2Wea(Cnq8QA7^DR z(l-`?j1jFL3q!@hmPv-=45GG0v8~DLG!;o?D5QnUYkWTdAms$F4jh=bC#k;bqmW^c zsIapK3>+^(1!6w-_4q4P26HwgDQyb=8m+kk6dmN$Tdq1j->*#jOp9C^Z3iEsgH$X6 zg;vX-AS7)EecG7@jb_hPrGqV;xM^Drl&SI~<>UHUQK?$L2H9j)_*k)i6o%Hl0$^=* z=IF$Aa@W+hnAA?TWhrI(V)xNy(NV)bU%@&(X;-pM$$_1w#?4SToGo3)PK?wBbiy@O6 z$B#?$I)Y%SSmd2=(k7z)Hf+`kB1ZP&Z$BfS&>m;`Pjej6NPU$Bun;te*WBR~v5~Hn zo)g1L*CENjYEn8;1-k6A@-|Q=+q0abhBg*Y%|A=Y;O9t|4T^|m+$S4&GUgC8!8@?w zyD~n6{OQff|A|dg8SoO9#&lmuj1^WL%zIl%3Wkn~?tR1wzI4d16U{>k8no9d+T1ZW zI>Z>c7o7L%2U_Skfpb0v2e(+FiZ-F;^LynS)NWIPAQ8&gB*Q+Rwg0wpBWnSDb;bot zo7NV&)r$8`6s-81GA^I$9VqQL#se+jYHEaP%KrkDgE3LMaSt$)%ab8}RU-x+m3gV8 zw(Z&E*<>(Swtvv?_R(|pCp;Z)#KvKhr(V2Yqg~Wn3(WoWZr`-qeRt>FiC-~t_BN zae>wC1Nm@1MX0KX#xugVlrVlL1y}YS&l|~ev|*i-@N~_t#OnnNQx^Bzg>ItD0I&A-5)1)^Ynti9v%0*0#=O zGo+#zapYWW#Gf%%EyixP^9EE^jC6^LvpraRx$>{P|G5JyN3#=>?KD;b!l-C(eG6kg z@94W;EV~JM$^cqDWum7y75YV~no)<~IexB&XW>%03E832AETZ-dJ=pnSA48Kdmvgm z)NLNx0ud`o#bale;SQ224}Kfq^~X_hn%Rc87kZv$+f7pE(>S6x2CUX@CwSXibuW|= zDf5j=XHV|Po?K^qva~&6v=cH9Ut@)2_Yd>5EYC1^`!Pba?j4HC&8sSn{y{yM{p3>R z9vglzXZflUvY6_)A9L`NV84K_UPAoB7jPk{PQ7rS=+tni+fbiDQbNOYDaiJHEbH_U zTPn%tPjoXEFzt&ZpTvPI*CBhK)Umh4x^LfbW#{F6z6;ekROyzFK3yx#CCzzrz+mD* z)kse4k?@e&3>(LjxWJ?viNGoVosv_-u#N6{;@GYqQiYB(4jREZeVquBEW4KiblS|g zD?l7>3sF0aHjH%CQ)}m#CkOlXZ!{zYu_LjF@B!bntzm_X1Gkf1vqYV1J+57W zLaq@5U^}uu9}2t_dTEha`$)qsoO+z2xl^Iyq9gobC5mJsHL8q_X~?ytxZ)|!3w!=R3DdB7kCVgwog$Re!xPKm9G!mNqeSm-?!h{eJS&S@b6 zcuqlPQEFTn)Ws#4!&LZQ>GvJ9R~Xgy)00dQYoXP`45V!(S)nFcEJ^C_4bUEf_3N)L z0C13fV5uUl&BUBx&L7k~{6bHJMi{!qatUdYTCCde=u=Q4@wGe9uGJGspF^2c^3I2% zG_ETu2y_bs!v76qkp>O)JJfK~F``&kN{l7nepC%9w!6S8+CLI2yb;z`5wToCcdBOb z)`kAi6v9-GA&!dPv^g5DHD~b|66J`{h}0y+6#53+#9}uLTWQoypG^8rpJ@Zdq}uN`L6uRg~p{>aHubuHD~&ZTF;(Tw2ESHQbf z&76&jjFQoWYDP|`X8C&}?)$0Gu`ca44(aI&Tf_0Hr{jO4RAov}J`Q!8G6M;r|Xv^Sr* zuMGe#zW=liW!Y4OkvNT5x$5=2TgDiKEJ@y){Tn3()W>#C0jVKm)=!+cvL8ZZQMl?1 z&5cH7KR!(jAfD8;lb&HiMQog?tO@a|n?Dt~nOHPhrZttuCZC}-uJ>a!nCLybb2Xl@0siSjl=N+|CX9b(7R|U%H%Q8NHsCH_ z^9P$ocY0l4V&Vf+>1Y-5^@8B|73f6e@@?!^d>3Y00X5($&bcAC`xkAYwz4#ZO z3ril(L=;!p1X8pO`_Sla1X=#N=1@DPugdB5vWARywg}1M2{)#?D1<$*gEi>}cX}(F z6%MPQtPIof{gvWZ04XI(STzra0aMX|7;K-+O|eZ~(jnrFusC!IKQ`B1io z-FD9ADA>54ntDd5SzAHv&d@25=I{svVGEx2U%kN3T63A_i^+kFIwD4G=bs5I30@8h z27*OvIB(ACi5VYe7!EE|zm=H{c>)3zt_vA)*dwMPh7&T{_nX_!e!*RWj0H!HXsO<9 z&alrFW^<0pX#02Opu0{-DWa;tVX|jMpTz>kAd!btBOp+Xd1=$7_;`pS)j(lA7M4Pe zfo7lexTyS{l0|;4y(P#gMjvQV3dZI7M?XwspC`v~y~y}d0sa9C>lCMUTBCA*W&A}< zq|(s%p~M$&S8tpKhAWh!rGN3D>XEQ}LL{BVTv9sLy^}pjhl!h93WRQD4^n0=f8`ch zw-Im%A8-#`s(?kJ{HP<`0^~ZUtdcIV%+>93*|Lpt?X)1!rv5+vfJ2@J#ad);YWsYH zRE_BOby}Y98EQ^5mN% z0vrc#kf}6`;SyPq*rHyFeaTWb7(Jrn0N8KlHL3B(J_AlZ-v6SyIrX0A=JvmxfKKdR zr6CB?X1V-7D+js5eS7SWDQiu>ZY}1!s?9bWE;@A|C0_wT&Dwk-OSL2SD+6N69mr1P zF29~Hb*K1M&8ciyMTC2n%qV^ma5tg?O&G{wYpJ5XYUrJh0=8~(=NMFn4y;RrzqNrQ znX_x3AbTGiqMr|o{a{kB=!BFJbrWcNMDXGX?da$=#ptyH4Rvr_!d4$l70US-u{BvERI1Hml z=sS6wv)E8O^r)uj^{Nb!jaAWx8E8Ox%R$0$qTno+O;koU*E_F2u^%>PnRG+GJjL5&mi*NT!oHXD)`R`MNgqR#{)bQ-5qMX7_A~ zEqDFuP;8Q+WHt8~z$M)s_D1VZJ1bv4_K$1X?^9sG1-s7;C5GI}w-3PzwPIUexHa{9 z7FLcR?WddsN+YXGH|nUo@Ef(p4MR&f1E=(8usBCN+9;%U3H~a4=z2nJ2QI8`p$X zA7W!_ay6jF0gqHXs@?=evpbgV{ggYSu$T?iiqOjD*fl>8}HXW)@bq8fl?vlT!=o_Cf*k=0qMwGBe9xRbeww;0a;((#UaMl~X%5 z)EaJDCxmBKbu!a+T-o^ zEoBb|sh^BnS^-u$nP!rnn-s^Xa4=6k%%^OVoCJE!$WhCOY!#=a7xu|K*|t0iNX zQ9vlS$3bkB4YsKgn1J7F6`xzREbWMkei=ihNn+v6#!P)V${CH|4xy^Pgohq@sG7pr z6VIr)Ful!}YoRP~){F3{P-Bv_qUm5*q?KssVQI3&qwEyecMj?*$CDrmj-&fTxNS?s zPiVcfYH~gEq~hM>XGMN|8Zl8J4Q_>tZ2= zvD>rVM7&7$roaN7t=)*%*zm=1^4>)l3hM32xC+c2CA?3qiWXU5Mts(lsp^ySVeqm) zHBc)0Lb3$pw#uMqf vZHS4Q>^_X$3n$i8+1GeJ-L?KFff%2=+dJAzNSxX=&HIZm{XgE3T#fw)ThPDt literal 0 HcmV?d00001 diff --git a/src/test/java/org/example/ConnectionHandlerTest.java b/src/test/java/org/example/ConnectionHandlerTest.java new file mode 100644 index 00000000..ee366fa2 --- /dev/null +++ b/src/test/java/org/example/ConnectionHandlerTest.java @@ -0,0 +1,107 @@ +package org.example; + +import org.example.config.ConfigLoader; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.net.InetAddress; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ConnectionHandlerTest { + + @Mock + private Socket socket; + + @TempDir + Path tempDir; + + @BeforeAll + static void setupConfig() { + ConfigLoader.resetForTests(); + ConfigLoader.loadOnce(Path.of("nonexistent-test-config.yml")); + } + + @Test + void test_jpg_file_should_return_200_not_404() throws Exception { + // Arrange + byte[] imageContent = "fake-image-data".getBytes(StandardCharsets.UTF_8); + Files.write(tempDir.resolve("test.jpg"), imageContent); + + String request = "GET /test.jpg HTTP/1.1\r\nHost: localhost\r\n\r\n"; + ByteArrayInputStream inputStream = new ByteArrayInputStream(request.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + when(socket.getInputStream()).thenReturn(inputStream); + when(socket.getOutputStream()).thenReturn(outputStream); + when(socket.getInetAddress()).thenReturn(InetAddress.getByName("127.0.0.1")); + + // Act + try (ConnectionHandler handler = new ConnectionHandler(socket, tempDir.toString())) { + handler.runConnectionHandler(); + } + + // Assert + String response = outputStream.toString(); + assertThat(response).contains("HTTP/1.1 200 OK"); + assertThat(response).doesNotContain("404"); + } + + @Test + void test_root_path_should_serve_index_html() throws Exception { + // Arrange + byte[] indexContent = "Hello".getBytes(StandardCharsets.UTF_8); + Files.write(tempDir.resolve("index.html"), indexContent); + + String request = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"; + ByteArrayInputStream inputStream = new ByteArrayInputStream(request.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + when(socket.getInputStream()).thenReturn(inputStream); + when(socket.getOutputStream()).thenReturn(outputStream); + when(socket.getInetAddress()).thenReturn(InetAddress.getByName("127.0.0.1")); + + // Act + try (ConnectionHandler handler = new ConnectionHandler(socket, tempDir.toString())) { + handler.runConnectionHandler(); + } + + // Assert + String response = outputStream.toString(); + assertThat(response).contains("HTTP/1.1 200 OK"); + assertThat(response).doesNotContain("404"); + } + + @Test + void test_missing_file_should_return_404() throws Exception { + // Arrange — no file written to tempDir + String request = "GET /doesnotexist.html HTTP/1.1\r\nHost: localhost\r\n\r\n"; + ByteArrayInputStream inputStream = new ByteArrayInputStream(request.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + when(socket.getInputStream()).thenReturn(inputStream); + when(socket.getOutputStream()).thenReturn(outputStream); + when(socket.getInetAddress()).thenReturn(InetAddress.getByName("127.0.0.1")); + + // Act + try (ConnectionHandler handler = new ConnectionHandler(socket, tempDir.toString())) { + handler.runConnectionHandler(); + } + + // Assert + String response = outputStream.toString(); + assertThat(response).contains("404"); + } +} \ No newline at end of file diff --git a/www/test.jpg b/www/test.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8bf506459723345f8af76c7a828047d568b67ba9 GIT binary patch literal 5819 zcmcgwcQjmWw;x^f(M5|vbctxuiJlO_h!(th3xeoGLV^eqCQ)N1dYeHQWAy0Vs3U6h zZuH)+y!ZRQ_q%t!f84v)U3Z_ge$P33KW9IC?dLgr{m#Dnc{Kx|d8nbS0l>op0PwCa zz|{mm6+lElNJvOPbPYsAM8qT%q$JluO-_D;0!U3u3#0}D=@{9V=;&`U0D;V0%s1KD zIXOA$m_R%r4jwiRPLAJB@UCAaAts?BC8gq^2hwx=ujT3o07wd$1)!Q7cO&(9B$|c}Ks5+iGp>=@Wzw z+C2$@WviHn=W0$3E9+MPkNhiuS&?hw((ij{$Myo0 z?xbo2P`0*v{&+*^KxO?D^+97ci{s%!>_`_#G(9TnbyO7TU!X;B&x=j}xool1rMyuz zPYaV)P);s}+t*|^%`tz`RgY!>5;bSu|8NN#-L}}fMGqghELWDLjtF!KHi|8fYW|TB zJ;pzk#Ghb21mkzSZAC7Q4yqGcEuxz7>vXDAo2Xb4D|5Da)dM~!{K&GP*#m&e$t+bP z>?IJQ4B5<9)ZpiqYgyyajJyyYO^7J1T-NJfgY>D(GJW2YbIbP=mg!P>b`$*bpF$uf zrXM*3dgV{#UUBR(u7+zd$C=92T?UdldKsEyf=a1ab+<7Ut^N-S>89Un#(893oJ?$&N2Wea(Cnq8QA7^DR z(l-`?j1jFL3q!@hmPv-=45GG0v8~DLG!;o?D5QnUYkWTdAms$F4jh=bC#k;bqmW^c zsIapK3>+^(1!6w-_4q4P26HwgDQyb=8m+kk6dmN$Tdq1j->*#jOp9C^Z3iEsgH$X6 zg;vX-AS7)EecG7@jb_hPrGqV;xM^Drl&SI~<>UHUQK?$L2H9j)_*k)i6o%Hl0$^=* z=IF$Aa@W+hnAA?TWhrI(V)xNy(NV)bU%@&(X;-pM$$_1w#?4SToGo3)PK?wBbiy@O6 z$B#?$I)Y%SSmd2=(k7z)Hf+`kB1ZP&Z$BfS&>m;`Pjej6NPU$Bun;te*WBR~v5~Hn zo)g1L*CENjYEn8;1-k6A@-|Q=+q0abhBg*Y%|A=Y;O9t|4T^|m+$S4&GUgC8!8@?w zyD~n6{OQff|A|dg8SoO9#&lmuj1^WL%zIl%3Wkn~?tR1wzI4d16U{>k8no9d+T1ZW zI>Z>c7o7L%2U_Skfpb0v2e(+FiZ-F;^LynS)NWIPAQ8&gB*Q+Rwg0wpBWnSDb;bot zo7NV&)r$8`6s-81GA^I$9VqQL#se+jYHEaP%KrkDgE3LMaSt$)%ab8}RU-x+m3gV8 zw(Z&E*<>(Swtvv?_R(|pCp;Z)#KvKhr(V2Yqg~Wn3(WoWZr`-qeRt>FiC-~t_BN zae>wC1Nm@1MX0KX#xugVlrVlL1y}YS&l|~ev|*i-@N~_t#OnnNQx^Bzg>ItD0I&A-5)1)^Ynti9v%0*0#=O zGo+#zapYWW#Gf%%EyixP^9EE^jC6^LvpraRx$>{P|G5JyN3#=>?KD;b!l-C(eG6kg z@94W;EV~JM$^cqDWum7y75YV~no)<~IexB&XW>%03E832AETZ-dJ=pnSA48Kdmvgm z)NLNx0ud`o#bale;SQ224}Kfq^~X_hn%Rc87kZv$+f7pE(>S6x2CUX@CwSXibuW|= zDf5j=XHV|Po?K^qva~&6v=cH9Ut@)2_Yd>5EYC1^`!Pba?j4HC&8sSn{y{yM{p3>R z9vglzXZflUvY6_)A9L`NV84K_UPAoB7jPk{PQ7rS=+tni+fbiDQbNOYDaiJHEbH_U zTPn%tPjoXEFzt&ZpTvPI*CBhK)Umh4x^LfbW#{F6z6;ekROyzFK3yx#CCzzrz+mD* z)kse4k?@e&3>(LjxWJ?viNGoVosv_-u#N6{;@GYqQiYB(4jREZeVquBEW4KiblS|g zD?l7>3sF0aHjH%CQ)}m#CkOlXZ!{zYu_LjF@B!bntzm_X1Gkf1vqYV1J+57W zLaq@5U^}uu9}2t_dTEha`$)qsoO+z2xl^Iyq9gobC5mJsHL8q_X~?ytxZ)|!3w!=R3DdB7kCVgwog$Re!xPKm9G!mNqeSm-?!h{eJS&S@b6 zcuqlPQEFTn)Ws#4!&LZQ>GvJ9R~Xgy)00dQYoXP`45V!(S)nFcEJ^C_4bUEf_3N)L z0C13fV5uUl&BUBx&L7k~{6bHJMi{!qatUdYTCCde=u=Q4@wGe9uGJGspF^2c^3I2% zG_ETu2y_bs!v76qkp>O)JJfK~F``&kN{l7nepC%9w!6S8+CLI2yb;z`5wToCcdBOb z)`kAi6v9-GA&!dPv^g5DHD~b|66J`{h}0y+6#53+#9}uLTWQoypG^8rpJ@Zdq}uN`L6uRg~p{>aHubuHD~&ZTF;(Tw2ESHQbf z&76&jjFQoWYDP|`X8C&}?)$0Gu`ca44(aI&Tf_0Hr{jO4RAov}J`Q!8G6M;r|Xv^Sr* zuMGe#zW=liW!Y4OkvNT5x$5=2TgDiKEJ@y){Tn3()W>#C0jVKm)=!+cvL8ZZQMl?1 z&5cH7KR!(jAfD8;lb&HiMQog?tO@a|n?Dt~nOHPhrZttuCZC}-uJ>a!nCLybb2Xl@0siSjl=N+|CX9b(7R|U%H%Q8NHsCH_ z^9P$ocY0l4V&Vf+>1Y-5^@8B|73f6e@@?!^d>3Y00X5($&bcAC`xkAYwz4#ZO z3ril(L=;!p1X8pO`_Sla1X=#N=1@DPugdB5vWARywg}1M2{)#?D1<$*gEi>}cX}(F z6%MPQtPIof{gvWZ04XI(STzra0aMX|7;K-+O|eZ~(jnrFusC!IKQ`B1io z-FD9ADA>54ntDd5SzAHv&d@25=I{svVGEx2U%kN3T63A_i^+kFIwD4G=bs5I30@8h z27*OvIB(ACi5VYe7!EE|zm=H{c>)3zt_vA)*dwMPh7&T{_nX_!e!*RWj0H!HXsO<9 z&alrFW^<0pX#02Opu0{-DWa;tVX|jMpTz>kAd!btBOp+Xd1=$7_;`pS)j(lA7M4Pe zfo7lexTyS{l0|;4y(P#gMjvQV3dZI7M?XwspC`v~y~y}d0sa9C>lCMUTBCA*W&A}< zq|(s%p~M$&S8tpKhAWh!rGN3D>XEQ}LL{BVTv9sLy^}pjhl!h93WRQD4^n0=f8`ch zw-Im%A8-#`s(?kJ{HP<`0^~ZUtdcIV%+>93*|Lpt?X)1!rv5+vfJ2@J#ad);YWsYH zRE_BOby}Y98EQ^5mN% z0vrc#kf}6`;SyPq*rHyFeaTWb7(Jrn0N8KlHL3B(J_AlZ-v6SyIrX0A=JvmxfKKdR zr6CB?X1V-7D+js5eS7SWDQiu>ZY}1!s?9bWE;@A|C0_wT&Dwk-OSL2SD+6N69mr1P zF29~Hb*K1M&8ciyMTC2n%qV^ma5tg?O&G{wYpJ5XYUrJh0=8~(=NMFn4y;RrzqNrQ znX_x3AbTGiqMr|o{a{kB=!BFJbrWcNMDXGX?da$=#ptyH4Rvr_!d4$l70US-u{BvERI1Hml z=sS6wv)E8O^r)uj^{Nb!jaAWx8E8Ox%R$0$qTno+O;koU*E_F2u^%>PnRG+GJjL5&mi*NT!oHXD)`R`MNgqR#{)bQ-5qMX7_A~ zEqDFuP;8Q+WHt8~z$M)s_D1VZJ1bv4_K$1X?^9sG1-s7;C5GI}w-3PzwPIUexHa{9 z7FLcR?WddsN+YXGH|nUo@Ef(p4MR&f1E=(8usBCN+9;%U3H~a4=z2nJ2QI8`p$X zA7W!_ay6jF0gqHXs@?=evpbgV{ggYSu$T?iiqOjD*fl>8}HXW)@bq8fl?vlT!=o_Cf*k=0qMwGBe9xRbeww;0a;((#UaMl~X%5 z)EaJDCxmBKbu!a+T-o^ zEoBb|sh^BnS^-u$nP!rnn-s^Xa4=6k%%^OVoCJE!$WhCOY!#=a7xu|K*|t0iNX zQ9vlS$3bkB4YsKgn1J7F6`xzREbWMkei=ihNn+v6#!P)V${CH|4xy^Pgohq@sG7pr z6VIr)Ful!}YoRP~){F3{P-Bv_qUm5*q?KssVQI3&qwEyecMj?*$CDrmj-&fTxNS?s zPiVcfYH~gEq~hM>XGMN|8Zl8J4Q_>tZ2= zvD>rVM7&7$roaN7t=)*%*zm=1^4>)l3hM32xC+c2CA?3qiWXU5Mts(lsp^ySVeqm) zHBc)0Lb3$pw#uMqf vZHS4Q>^_X$3n$i8+1GeJ-L?KFff%2=+dJAzNSxX=&HIZm{XgE3T#fw)ThPDt literal 0 HcmV?d00001 From f3bb1acbe06c7cf56838e319f69050df88e1a6b7 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Thu, 26 Feb 2026 11:20:04 +0100 Subject: [PATCH 63/67] =?UTF-8?q?daniel=20har=20hj=C3=A4lp=20mig=20med=20?= =?UTF-8?q?=C3=A4ndringar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/example/ConnectionHandler.java | 31 ++++++++++++++----- .../java/org/example/StaticFileHandler.java | 25 +++------------ 2 files changed, 27 insertions(+), 29 deletions(-) diff --git a/src/main/java/org/example/ConnectionHandler.java b/src/main/java/org/example/ConnectionHandler.java index 2e6d0cb7..78f424bd 100644 --- a/src/main/java/org/example/ConnectionHandler.java +++ b/src/main/java/org/example/ConnectionHandler.java @@ -4,6 +4,10 @@ import org.example.filter.IpFilter; import org.example.httpparser.HttpParser; import org.example.httpparser.HttpRequest; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; import java.util.ArrayList; import java.util.List; import org.example.filter.Filter; @@ -69,19 +73,30 @@ public void runConnectionHandler() throws IOException { // Check cache FIRST byte[] cachedBytes = FileCache.get(cacheKey); if (cachedBytes != null) { - System.out.println("✓ Cache HIT: " + sanitizedUri); - HttpResponseBuilder cachedResp = new HttpResponseBuilder(); - cachedResp.setContentTypeFromFilename(sanitizedUri); - cachedResp.setBody(cachedBytes); - client.getOutputStream().write(cachedResp.build()); + System.out.println(" Cache HIT: " + sanitizedUri); + response.setContentTypeFromFilename(sanitizedUri); + response.setBody(cachedBytes); + client.getOutputStream().write(response.build()); client.getOutputStream().flush(); return; } // Cache miss - StaticFileHandler reads and caches - System.out.println("✗ Cache MISS: " + sanitizedUri); - StaticFileHandler sfh = new StaticFileHandler(); - sfh.sendGetRequest(client.getOutputStream(), sanitizedUri); + System.out.println(" Cache MISS: " + sanitizedUri); + try { + byte[] fileBytes = Files.readAllBytes(new File("www", sanitizedUri).toPath()); + FileCache.put(cacheKey, fileBytes); // ← SPARAR I CACHEN HÄR + + response.setContentTypeFromFilename(sanitizedUri); + response.setBody(fileBytes); + client.getOutputStream().write(response.build()); + client.getOutputStream().flush(); + } catch (NoSuchFileException e) { + response.setStatusCode(HttpResponseBuilder.SC_NOT_FOUND); + response.setBody("404 Not Found"); + client.getOutputStream().write(response.build()); + client.getOutputStream().flush(); + } } private String sanitizeUri(String uri) { diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 67cf5454..8f7e693f 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -24,24 +24,22 @@ public StaticFileHandler(String webRoot) { } public void sendGetRequest(OutputStream outputStream, String uri) throws IOException { - String sanitizedUri = sanitizeUri(uri); - - if (isPathTraversal(sanitizedUri)) { + if (isPathTraversal(uri)) { writeResponse(outputStream, 403, "Forbidden"); return; } try { - String cacheKey = webRoot + ":" + sanitizedUri; + String cacheKey = "www:" + uri; byte[] fileBytes = FileCache.get(cacheKey); if (fileBytes == null) { - fileBytes = Files.readAllBytes(new File(webRoot, sanitizedUri).toPath()); + fileBytes = Files.readAllBytes(new File(webRoot, uri).toPath()); FileCache.put(cacheKey, fileBytes); } HttpResponseBuilder response = new HttpResponseBuilder(); - response.setContentTypeFromFilename(sanitizedUri); + response.setContentTypeFromFilename(uri); response.setBody(fileBytes); outputStream.write(response.build()); outputStream.flush(); @@ -53,21 +51,6 @@ public void sendGetRequest(OutputStream outputStream, String uri) throws IOExcep } } - - private String sanitizeUri(String uri) { - if (uri == null || uri.isEmpty()) return "index.html"; - - int endIndex = Math.min( - uri.indexOf('?') < 0 ? uri.length() : uri.indexOf('?'), - uri.indexOf('#') < 0 ? uri.length() : uri.indexOf('#') - ); - - return uri.substring(0, endIndex) - .replace("\0", "") - .replaceAll("^/+", "") - .replaceAll("^$", "index.html"); - } - private boolean isPathTraversal(String uri) { try { Path webRootPath = Paths.get(webRoot).toAbsolutePath().normalize(); From 2f8312b0359d4972919c52216adff7e2baa74ca5 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Thu, 26 Feb 2026 11:43:54 +0100 Subject: [PATCH 64/67] =?UTF-8?q?fixat=20s=C3=A5=20uri=20rensas=20och=20?= =?UTF-8?q?=C3=A5teranv=C3=A4nds,=20lade=20till=20ny=20metod=20f=C3=B6r=20?= =?UTF-8?q?sanering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/example/ConnectionHandler.java | 28 ------------------- .../java/org/example/StaticFileHandler.java | 25 ++++++++++++++--- 2 files changed, 21 insertions(+), 32 deletions(-) diff --git a/src/main/java/org/example/ConnectionHandler.java b/src/main/java/org/example/ConnectionHandler.java index 78f424bd..9fb2c7f6 100644 --- a/src/main/java/org/example/ConnectionHandler.java +++ b/src/main/java/org/example/ConnectionHandler.java @@ -69,34 +69,6 @@ public void runConnectionHandler() throws IOException { // Sanitize URI here (clean it) String sanitizedUri = sanitizeUri(parser.getUri()); String cacheKey = "www:" + sanitizedUri; - - // Check cache FIRST - byte[] cachedBytes = FileCache.get(cacheKey); - if (cachedBytes != null) { - System.out.println(" Cache HIT: " + sanitizedUri); - response.setContentTypeFromFilename(sanitizedUri); - response.setBody(cachedBytes); - client.getOutputStream().write(response.build()); - client.getOutputStream().flush(); - return; - } - - // Cache miss - StaticFileHandler reads and caches - System.out.println(" Cache MISS: " + sanitizedUri); - try { - byte[] fileBytes = Files.readAllBytes(new File("www", sanitizedUri).toPath()); - FileCache.put(cacheKey, fileBytes); // ← SPARAR I CACHEN HÄR - - response.setContentTypeFromFilename(sanitizedUri); - response.setBody(fileBytes); - client.getOutputStream().write(response.build()); - client.getOutputStream().flush(); - } catch (NoSuchFileException e) { - response.setStatusCode(HttpResponseBuilder.SC_NOT_FOUND); - response.setBody("404 Not Found"); - client.getOutputStream().write(response.build()); - client.getOutputStream().flush(); - } } private String sanitizeUri(String uri) { diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 8f7e693f..098e779e 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -24,22 +24,24 @@ public StaticFileHandler(String webRoot) { } public void sendGetRequest(OutputStream outputStream, String uri) throws IOException { - if (isPathTraversal(uri)) { + String sanitizedUri = sanitizeUri(uri); + + if (isPathTraversal(sanitizedUri)) { writeResponse(outputStream, 403, "Forbidden"); return; } try { - String cacheKey = "www:" + uri; + String cacheKey = "www:" + sanitizedUri; byte[] fileBytes = FileCache.get(cacheKey); if (fileBytes == null) { - fileBytes = Files.readAllBytes(new File(webRoot, uri).toPath()); + fileBytes = Files.readAllBytes(new File(webRoot, sanitizedUri).toPath()); FileCache.put(cacheKey, fileBytes); } HttpResponseBuilder response = new HttpResponseBuilder(); - response.setContentTypeFromFilename(uri); + response.setContentTypeFromFilename(sanitizedUri); response.setBody(fileBytes); outputStream.write(response.build()); outputStream.flush(); @@ -51,6 +53,21 @@ public void sendGetRequest(OutputStream outputStream, String uri) throws IOExcep } } + + private String sanitizeUri(String uri) { + if (uri == null || uri.isEmpty()) return "index.html"; + + int endIndex = Math.min( + uri.indexOf('?') < 0 ? uri.length() : uri.indexOf('?'), + uri.indexOf('#') < 0 ? uri.length() : uri.indexOf('#') + ); + + return uri.substring(0, endIndex) + .replace("\0", "") + .replaceAll("^/+", "") + .replaceAll("^$", "index.html"); + } + private boolean isPathTraversal(String uri) { try { Path webRootPath = Paths.get(webRoot).toAbsolutePath().normalize(); From 331615af4a91fee0a7a0d0e457fd783431fa470d Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Thu, 26 Feb 2026 12:23:56 +0100 Subject: [PATCH 65/67] fixa compleir issue --- src/main/java/org/example/ConnectionHandler.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/example/ConnectionHandler.java b/src/main/java/org/example/ConnectionHandler.java index 75858ec0..9561a761 100644 --- a/src/main/java/org/example/ConnectionHandler.java +++ b/src/main/java/org/example/ConnectionHandler.java @@ -82,9 +82,9 @@ public void runConnectionHandler() throws IOException { return; } - // Sanitize URI here (clean it) - String sanitizedUri = sanitizeUri(parser.getUri()); - String cacheKey = "www:" + sanitizedUri; + // Let StaticFileHandler handle everything + StaticFileHandler sfh = new StaticFileHandler(); + sfh.sendGetRequest(client.getOutputStream(), parser.getUri()); } private String sanitizeUri(String uri) { From 1be780f0c857a2be4fa288d297a2136f1168e9ee Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Thu, 26 Feb 2026 12:32:46 +0100 Subject: [PATCH 66/67] =?UTF-8?q?beh=C3=B6vde=20fixa=20saker=20efter=20en?= =?UTF-8?q?=20rebase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/example/ConnectionHandler.java | 42 ++----------------- 1 file changed, 3 insertions(+), 39 deletions(-) diff --git a/src/main/java/org/example/ConnectionHandler.java b/src/main/java/org/example/ConnectionHandler.java index 9561a761..11cd5060 100644 --- a/src/main/java/org/example/ConnectionHandler.java +++ b/src/main/java/org/example/ConnectionHandler.java @@ -4,10 +4,6 @@ import org.example.filter.IpFilter; import org.example.httpparser.HttpParser; import org.example.httpparser.HttpRequest; - -import java.io.File; -import java.nio.file.Files; -import java.nio.file.NoSuchFileException; import java.util.ArrayList; import java.util.List; import org.example.filter.Filter; @@ -21,12 +17,12 @@ public class ConnectionHandler implements AutoCloseable { private final Socket client; private final List filters; - String webRoot; + private final String webRoot; public ConnectionHandler(Socket client) { this.client = client; + this.webRoot = "www"; this.filters = buildFilters(); - this.webRoot = null; } public ConnectionHandler(Socket client, String webRoot) { @@ -46,14 +42,6 @@ private List buildFilters() { } public void runConnectionHandler() throws IOException { - StaticFileHandler sfh; - - if (webRoot != null) { - sfh = new StaticFileHandler(webRoot); - } else { - sfh = new StaticFileHandler(); - } - HttpParser parser = new HttpParser(); parser.setReader(client.getInputStream()); parser.parseRequest(); @@ -83,26 +71,10 @@ public void runConnectionHandler() throws IOException { } // Let StaticFileHandler handle everything - StaticFileHandler sfh = new StaticFileHandler(); + StaticFileHandler sfh = new StaticFileHandler(webRoot); sfh.sendGetRequest(client.getOutputStream(), parser.getUri()); } - private String sanitizeUri(String uri) { - if (uri == null || uri.isEmpty()) return "index.html"; - - int endIndex = Math.min( - uri.indexOf('?') < 0 ? uri.length() : uri.indexOf('?'), - uri.indexOf('#') < 0 ? uri.length() : uri.indexOf('#') - ); - - return uri.substring(0, endIndex) - .replace("\0", "") - .replaceAll("^/+", "") - .replaceAll("^$", "index.html"); - resolveTargetFile(parser.getUri()); - sfh.sendGetRequest(client.getOutputStream(), uri); - } - private HttpResponseBuilder applyFilters(HttpRequest request) { HttpResponseBuilder response = new HttpResponseBuilder(); FilterChainImpl chain = new FilterChainImpl(filters); @@ -110,14 +82,6 @@ private HttpResponseBuilder applyFilters(HttpRequest request) { return response; } - private void resolveTargetFile(String uri) { - if (uri == null || "/".equals(uri)) { - this.uri = "index.html"; - } else { - this.uri = uri.startsWith("/") ? uri.substring(1) : uri; - } - } - @Override public void close() throws Exception { client.close(); From 56ba5201b03b70c17b72aa83554c685a6786be37 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Sat, 28 Feb 2026 15:41:10 +0100 Subject: [PATCH 67/67] should have made it according to the mall given --- src/main/java/org/example/App.java | 3 +- .../java/org/example/ConnectionHandler.java | 84 ++++++++----- src/main/java/org/example/FileCache.java | 28 +++-- .../java/org/example/StaticFileHandler.java | 85 ++++--------- src/main/java/org/example/TcpServer.java | 17 +-- .../org/example/http/HttpResponseBuilder.java | 12 ++ .../org/example/ConnectionHandlerTest.java | 30 ++++- .../org/example/StaticFileHandlerTest.java | 117 +++++++----------- src/test/java/org/example/TcpServerTest.java | 4 +- 9 files changed, 188 insertions(+), 192 deletions(-) diff --git a/src/main/java/org/example/App.java b/src/main/java/org/example/App.java index 966e9563..5b035a1e 100644 --- a/src/main/java/org/example/App.java +++ b/src/main/java/org/example/App.java @@ -17,7 +17,8 @@ public static void main(String[] args) { int port = resolvePort(args, appConfig.server().port()); - new TcpServer(port, ConnectionHandler::new).start(); + FileCache fileCache = new FileCache(10); + new TcpServer(port, socket -> new ConnectionHandler(socket, fileCache)).start(); } static int resolvePort(String[] args, int configPort) { diff --git a/src/main/java/org/example/ConnectionHandler.java b/src/main/java/org/example/ConnectionHandler.java index 11cd5060..6f3111a4 100644 --- a/src/main/java/org/example/ConnectionHandler.java +++ b/src/main/java/org/example/ConnectionHandler.java @@ -14,20 +14,23 @@ import java.io.IOException; import java.net.Socket; +import java.io.OutputStream; +import java.nio.file.NoSuchFileException; + public class ConnectionHandler implements AutoCloseable { private final Socket client; private final List filters; private final String webRoot; + private final FileCache fileCache; - public ConnectionHandler(Socket client) { - this.client = client; - this.webRoot = "www"; - this.filters = buildFilters(); + public ConnectionHandler(Socket client, FileCache fileCache) { + this(client, "www", fileCache); } - public ConnectionHandler(Socket client, String webRoot) { + public ConnectionHandler(Socket client, String webRoot, FileCache fileCache) { this.client = client; this.webRoot = webRoot; + this.fileCache = fileCache; this.filters = buildFilters(); } @@ -42,44 +45,67 @@ private List buildFilters() { } public void runConnectionHandler() throws IOException { + HttpParser parser = parseRequest(); + HttpRequest request = buildHttpRequest(parser); + + if (isForbiddenByFilters(request)) return; + if (isPathTraversal(parser.getUri())) return; + + serveFile(parser.getUri()); + } + + private HttpParser parseRequest() throws IOException { HttpParser parser = new HttpParser(); parser.setReader(client.getInputStream()); parser.parseRequest(); parser.parseHttp(); + return parser; + } + private HttpRequest buildHttpRequest(HttpParser parser) { HttpRequest request = new HttpRequest( - parser.getMethod(), - parser.getUri(), - parser.getVersion(), - parser.getHeadersMap(), - "" + parser.getMethod(), parser.getUri(), parser.getVersion(), + parser.getHeadersMap(), "" ); + request.setAttribute("clientIp", client.getInetAddress().getHostAddress()); + return request; + } - String clientIp = client.getInetAddress().getHostAddress(); - request.setAttribute("clientIp", clientIp); - - // Apply security filters - HttpResponseBuilder response = applyFilters(request); + private boolean isForbiddenByFilters(HttpRequest request) throws IOException { + HttpResponseBuilder response = new HttpResponseBuilder(); + new FilterChainImpl(filters).doFilter(request, response); - int statusCode = response.getStatusCode(); - if (statusCode == HttpResponseBuilder.SC_FORBIDDEN || - statusCode == HttpResponseBuilder.SC_BAD_REQUEST) { - byte[] responseBytes = response.build(); - client.getOutputStream().write(responseBytes); + int status = response.getStatusCode(); + if (status == HttpResponseBuilder.SC_FORBIDDEN || status == HttpResponseBuilder.SC_BAD_REQUEST) { + client.getOutputStream().write(response.build()); client.getOutputStream().flush(); - return; + return true; + } + return false; + } + + private boolean isPathTraversal(String uri) throws IOException { + if (uri.contains("..")) { + sendErrorResponse(client.getOutputStream(), 403, "Forbidden"); + return true; } + return false; + } - // Let StaticFileHandler handle everything - StaticFileHandler sfh = new StaticFileHandler(webRoot); - sfh.sendGetRequest(client.getOutputStream(), parser.getUri()); + private void serveFile(String uri) throws IOException { + StaticFileHandler sfh = new StaticFileHandler(webRoot, fileCache); + try { + sfh.sendGetRequest(client.getOutputStream(), uri); + } catch (NoSuchFileException e) { + sendErrorResponse(client.getOutputStream(), 404, "Not Found"); + } catch (Exception e) { + sendErrorResponse(client.getOutputStream(), 500, "Internal Server Error"); + } } - private HttpResponseBuilder applyFilters(HttpRequest request) { - HttpResponseBuilder response = new HttpResponseBuilder(); - FilterChainImpl chain = new FilterChainImpl(filters); - chain.doFilter(request, response); - return response; + private void sendErrorResponse(OutputStream out, int statusCode, String message) throws IOException { + out.write(HttpResponseBuilder.createErrorResponse(statusCode, message)); + out.flush(); } @Override diff --git a/src/main/java/org/example/FileCache.java b/src/main/java/org/example/FileCache.java index 9f4b8826..166053d3 100644 --- a/src/main/java/org/example/FileCache.java +++ b/src/main/java/org/example/FileCache.java @@ -1,22 +1,24 @@ package org.example; -import java.util.concurrent.ConcurrentHashMap; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; public class FileCache { - private static final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + private final Map cache; - private FileCache() {} - - public static byte[] get(String key) { - return cache.get(key); - } - - public static void put(String key, byte[] content) { - cache.put(key, content); + public FileCache(int maxSize) { + this.cache = Collections.synchronizedMap(new LinkedHashMap(maxSize, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > maxSize; + } + }); } - public static void clear() { - cache.clear(); - } + public byte[] get(String key) { return cache.get(key); } + public void put(String key, byte[] content) { cache.put(key, content); } + public void clear() { cache.clear(); } + public int size() { return cache.size(); } } diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 098e779e..e9023a51 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -2,92 +2,57 @@ package org.example; import org.example.http.HttpResponseBuilder; -import java.io.File; import java.io.IOException; import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.NoSuchFileException; - public class StaticFileHandler { private static final String DEFAULT_WEB_ROOT = "www"; private final String webRoot; + private final FileCache fileCache; - public StaticFileHandler() { - this(DEFAULT_WEB_ROOT); + public StaticFileHandler(FileCache fileCache) { + this(DEFAULT_WEB_ROOT, fileCache); } - public StaticFileHandler(String webRoot) { + public StaticFileHandler(String webRoot, FileCache fileCache) { this.webRoot = webRoot; + this.fileCache = fileCache; } public void sendGetRequest(OutputStream outputStream, String uri) throws IOException { String sanitizedUri = sanitizeUri(uri); + Path filePath = Path.of(webRoot, sanitizedUri); - if (isPathTraversal(sanitizedUri)) { - writeResponse(outputStream, 403, "Forbidden"); - return; - } - - try { - String cacheKey = "www:" + sanitizedUri; - byte[] fileBytes = FileCache.get(cacheKey); - - if (fileBytes == null) { - fileBytes = Files.readAllBytes(new File(webRoot, sanitizedUri).toPath()); - FileCache.put(cacheKey, fileBytes); - } - - HttpResponseBuilder response = new HttpResponseBuilder(); - response.setContentTypeFromFilename(sanitizedUri); - response.setBody(fileBytes); - outputStream.write(response.build()); - outputStream.flush(); + // Använd fullständig sökväg som cache-nyckel + String cacheKey = filePath.toString(); + byte[] fileBytes = fileCache.get(cacheKey); - } catch (NoSuchFileException e) { - writeResponse(outputStream, 404, "Not Found"); - } catch (IOException e) { - writeResponse(outputStream, 500, "Internal Server Error"); + if (fileBytes == null) { + fileBytes = Files.readAllBytes(filePath); + fileCache.put(cacheKey, fileBytes); } - } + HttpResponseBuilder response = new HttpResponseBuilder(); + response.setContentTypeFromFilename(sanitizedUri); + response.setBody(fileBytes); + outputStream.write(response.build()); + outputStream.flush(); + } private String sanitizeUri(String uri) { - if (uri == null || uri.isEmpty()) return "index.html"; - - int endIndex = Math.min( - uri.indexOf('?') < 0 ? uri.length() : uri.indexOf('?'), - uri.indexOf('#') < 0 ? uri.length() : uri.indexOf('#') - ); + if (uri == null || uri.isEmpty() || uri.equals("/")) return "index.html"; - return uri.substring(0, endIndex) - .replace("\0", "") + // Ta bort query, anchors, start-snedstreck och null-bytes + String cleanUri = uri.split("[?#]")[0] .replaceAll("^/+", "") - .replaceAll("^$", "index.html"); - } + .replace("\0", ""); - private boolean isPathTraversal(String uri) { - try { - Path webRootPath = Paths.get(webRoot).toAbsolutePath().normalize(); - Path requestedPath = webRootPath.resolve(uri).normalize(); - return !requestedPath.startsWith(webRootPath); - } catch (Exception e) { - return true; - } - } - - private void writeResponse(OutputStream outputStream, int statusCode, String statusMessage) throws IOException { - HttpResponseBuilder response = new HttpResponseBuilder(); - response.setStatusCode(statusCode); - response.setHeader("Content-Type", "text/html; charset=UTF-8"); - response.setBody(String.format("

%d %s

", statusCode, statusMessage)); - outputStream.write(response.build()); - outputStream.flush(); + return cleanUri.isEmpty() ? "index.html" : cleanUri; } - public static void clearCache() { - FileCache.clear(); + public void clearCache() { + fileCache.clear(); } } diff --git a/src/main/java/org/example/TcpServer.java b/src/main/java/org/example/TcpServer.java index e0a3655d..b6cc8cad 100644 --- a/src/main/java/org/example/TcpServer.java +++ b/src/main/java/org/example/TcpServer.java @@ -4,11 +4,8 @@ import java.io.IOException; import java.io.OutputStream; -import java.io.PrintWriter; import java.net.ServerSocket; import java.net.Socket; -import java.nio.charset.StandardCharsets; -import java.util.Map; public class TcpServer { @@ -43,29 +40,19 @@ protected void handleClient(Socket client) { } private void processRequest(Socket client) throws Exception { - ConnectionHandler handler = null; - try{ - handler = connectionFactory.create(client); + try (ConnectionHandler handler = connectionFactory.create(client)) { handler.runConnectionHandler(); } catch (Exception e) { handleInternalServerError(client); - } finally { - if(handler != null) - handler.close(); } } private void handleInternalServerError(Socket client){ - HttpResponseBuilder response = new HttpResponseBuilder(); - response.setStatusCode(HttpResponseBuilder.SC_INTERNAL_SERVER_ERROR); - response.setHeaders(Map.of("Content-Type", "text/plain; charset=utf-8")); - response.setBody("⚠️ Internal Server Error 500 ⚠️"); - if (!client.isClosed()) { try { OutputStream out = client.getOutputStream(); - out.write(response.build()); + out.write(HttpResponseBuilder.createErrorResponse(500, "Internal Server Error")); out.flush(); } catch (IOException e) { System.err.println("Failed to send 500 response: " + e.getMessage()); diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java index 9b6ed2a7..53050526 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -93,6 +93,18 @@ public void setContentTypeFromFilename(String filename) { setHeader("Content-Type", mimeType); } + public void setError(int statusCode, String message) { + setStatusCode(statusCode); + setHeader("Content-Type", "text/html; charset=UTF-8"); + setBody(String.format("

%d %s

", statusCode, message)); + } + + public static byte[] createErrorResponse(int statusCode, String message) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setError(statusCode, message); + return builder.build(); + } + /* * Builds the complete HTTP response as a byte array and preserves binary content without corruption. * @return Complete HTTP response (headers + body) as byte[] diff --git a/src/test/java/org/example/ConnectionHandlerTest.java b/src/test/java/org/example/ConnectionHandlerTest.java index ee366fa2..b55ddca8 100644 --- a/src/test/java/org/example/ConnectionHandlerTest.java +++ b/src/test/java/org/example/ConnectionHandlerTest.java @@ -28,6 +28,8 @@ class ConnectionHandlerTest { @TempDir Path tempDir; + private final FileCache cache = new FileCache(10); + @BeforeAll static void setupConfig() { ConfigLoader.resetForTests(); @@ -49,7 +51,7 @@ void test_jpg_file_should_return_200_not_404() throws Exception { when(socket.getInetAddress()).thenReturn(InetAddress.getByName("127.0.0.1")); // Act - try (ConnectionHandler handler = new ConnectionHandler(socket, tempDir.toString())) { + try (ConnectionHandler handler = new ConnectionHandler(socket, tempDir.toString(), cache)) { handler.runConnectionHandler(); } @@ -74,7 +76,7 @@ void test_root_path_should_serve_index_html() throws Exception { when(socket.getInetAddress()).thenReturn(InetAddress.getByName("127.0.0.1")); // Act - try (ConnectionHandler handler = new ConnectionHandler(socket, tempDir.toString())) { + try (ConnectionHandler handler = new ConnectionHandler(socket, tempDir.toString(), cache)) { handler.runConnectionHandler(); } @@ -96,7 +98,7 @@ void test_missing_file_should_return_404() throws Exception { when(socket.getInetAddress()).thenReturn(InetAddress.getByName("127.0.0.1")); // Act - try (ConnectionHandler handler = new ConnectionHandler(socket, tempDir.toString())) { + try (ConnectionHandler handler = new ConnectionHandler(socket, tempDir.toString(), cache)) { handler.runConnectionHandler(); } @@ -104,4 +106,26 @@ void test_missing_file_should_return_404() throws Exception { String response = outputStream.toString(); assertThat(response).contains("404"); } + + @Test + void test_path_traversal_should_return_403() throws Exception { + // Arrange + String request = "GET /../secret.txt HTTP/1.1\r\nHost: localhost\r\n\r\n"; + ByteArrayInputStream inputStream = new ByteArrayInputStream(request.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + when(socket.getInputStream()).thenReturn(inputStream); + when(socket.getOutputStream()).thenReturn(outputStream); + when(socket.getInetAddress()).thenReturn(InetAddress.getByName("127.0.0.1")); + + // Act + try (ConnectionHandler handler = new ConnectionHandler(socket, tempDir.toString(), cache)) { + handler.runConnectionHandler(); + } + + // Assert + String response = outputStream.toString(); + assertThat(response).contains("403"); + assertThat(response).contains("Forbidden"); + } } \ No newline at end of file diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java index 49c62bf5..0ac47a1f 100644 --- a/src/test/java/org/example/StaticFileHandlerTest.java +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -3,12 +3,11 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.file.Files; +import java.nio.file.NoSuchFileException; import java.nio.file.Path; import static org.junit.jupiter.api.Assertions.*; import static org.assertj.core.api.Assertions.assertThat; @@ -27,8 +26,18 @@ */ class StaticFileHandlerTest { - private StaticFileHandler createHandler() throws IOException { - return new StaticFileHandler(tempDir.toString()); + @TempDir + Path tempDir; + + private FileCache cache; + + @BeforeEach + void setUp() { + cache = new FileCache(10); + } + + private StaticFileHandler createHandler(){ + return new StaticFileHandler(tempDir.toString(), cache); } private String sendRequest(String uri) throws IOException { @@ -38,15 +47,6 @@ private String sendRequest(String uri) throws IOException { return output.toString(); } - @TempDir - Path tempDir; - - @BeforeEach - void setUp() { - // Rensa cache innan varje test för clean state - StaticFileHandler.clearCache(); - } - @Test void testCaching_HitOnSecondRequest() throws IOException { // Arrange @@ -75,15 +75,17 @@ void testSanitization_LeadingSlash() throws IOException { } @Test - void testSanitization_NullBytes() throws IOException { - assertThat(sendRequest("file.html\0../../secret")).contains("HTTP/1.1 404"); + void testSanitization_NullBytes() { + assertThrows(NoSuchFileException.class, () -> { + sendRequest("file.html\0../../secret"); + }); } @Test void testConcurrent_MultipleReads() throws InterruptedException, IOException { // Arrange Files.writeString(tempDir.resolve("shared.html"), "Data"); - StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); + StaticFileHandler handler = new StaticFileHandler(tempDir.toString(), cache); handler.sendGetRequest(new ByteArrayOutputStream(), "shared.html"); @@ -127,7 +129,7 @@ void test_file_that_exists_should_return_200() throws IOException { Path testFile = tempDir.resolve("test.html"); Files.writeString(testFile, "Hello Test"); - StaticFileHandler staticFileHandler = new StaticFileHandler(tempDir.toString()); + StaticFileHandler staticFileHandler = new StaticFileHandler(tempDir.toString(), cache); ByteArrayOutputStream fakeOutput = new ByteArrayOutputStream(); // Act @@ -141,73 +143,50 @@ void test_file_that_exists_should_return_200() throws IOException { } @Test - void test_file_that_does_not_exists_should_return_404() throws IOException { + void test_file_that_does_not_exists_should_throw_exception() { // Arrange - StaticFileHandler staticFileHandler = new StaticFileHandler(tempDir.toString()); + StaticFileHandler staticFileHandler = new StaticFileHandler(tempDir.toString(), cache); ByteArrayOutputStream fakeOutput = new ByteArrayOutputStream(); - // Act - staticFileHandler.sendGetRequest(fakeOutput, "notExistingFile.html"); - - // Assert - String response = fakeOutput.toString(); - assertTrue(response.contains("HTTP/1.1 404 Not Found")); + // Act & Assert + assertThrows(NoSuchFileException.class, () -> { + staticFileHandler.sendGetRequest(fakeOutput, "notExistingFile.html"); + }); } @Test - void test_path_traversal_should_return_403() throws IOException { + void null_byte_injection_should_throw_exception() throws IOException { // Arrange - Path secret = tempDir.resolve("secret.txt"); - Files.writeString(secret, "TOP SECRET"); Path webRoot = tempDir.resolve("www"); Files.createDirectories(webRoot); - StaticFileHandler handler = new StaticFileHandler(webRoot.toString()); - ByteArrayOutputStream fakeOutput = new ByteArrayOutputStream(); - - // Act - handler.sendGetRequest(fakeOutput, "../secret.txt"); - - // Assert - String response = fakeOutput.toString(); - assertFalse(response.contains("TOP SECRET")); - assertTrue(response.contains("HTTP/1.1 403 Forbidden")); - } - - @ParameterizedTest - @CsvSource({ - "index.html?foo=bar", - "index.html#section", - "/index.html" - }) - void sanitized_uris_should_return_200(String uri) throws IOException { - // Arrange - Path webRoot = tempDir.resolve("www"); - Files.createDirectories(webRoot); - Files.writeString(webRoot.resolve("index.html"), "Hello"); - StaticFileHandler handler = new StaticFileHandler(webRoot.toString()); + StaticFileHandler handler = new StaticFileHandler(webRoot.toString(), cache); ByteArrayOutputStream out = new ByteArrayOutputStream(); - // Act - handler.sendGetRequest(out, uri); - - // Assert - assertTrue(out.toString().contains("HTTP/1.1 200 OK")); + // Act & Assert + assertThrows(NoSuchFileException.class, () -> { + handler.sendGetRequest(out, "index.html\0../../etc/passwd"); + }); } @Test - void null_byte_injection_should_not_return_200() throws IOException { - // Arrange - Path webRoot = tempDir.resolve("www"); - Files.createDirectories(webRoot); - StaticFileHandler handler = new StaticFileHandler(webRoot.toString()); - ByteArrayOutputStream out = new ByteArrayOutputStream(); + void testMaxCacheSize() throws IOException { + FileCache limitedCache = new FileCache(2); + StaticFileHandler handler = new StaticFileHandler(tempDir.toString(), limitedCache); - // Act - handler.sendGetRequest(out, "index.html\0../../etc/passwd"); + Files.writeString(tempDir.resolve("1.html"), "1"); + Files.writeString(tempDir.resolve("2.html"), "2"); + Files.writeString(tempDir.resolve("3.html"), "3"); - // Assert - String response = out.toString(); - assertFalse(response.contains("HTTP/1.1 200 OK")); - assertTrue(response.contains("HTTP/1.1 404 Not Found")); + handler.sendGetRequest(new ByteArrayOutputStream(), "1.html"); + handler.sendGetRequest(new ByteArrayOutputStream(), "2.html"); + assertEquals(2, limitedCache.size()); + + handler.sendGetRequest(new ByteArrayOutputStream(), "3.html"); + assertEquals(2, limitedCache.size()); + + // 1.html bör ha tagits bort eftersom det var äldst (LRU) + assertNull(limitedCache.get(tempDir.resolve("1.html").toString())); + assertNotNull(limitedCache.get(tempDir.resolve("2.html").toString())); + assertNotNull(limitedCache.get(tempDir.resolve("3.html").toString())); } } diff --git a/src/test/java/org/example/TcpServerTest.java b/src/test/java/org/example/TcpServerTest.java index a62b3e95..9b62f19e 100644 --- a/src/test/java/org/example/TcpServerTest.java +++ b/src/test/java/org/example/TcpServerTest.java @@ -33,8 +33,8 @@ void failedClientRequestShouldReturnError500() throws Exception{ String response = outputStream.toString(); assertAll( () -> assertTrue(response.contains("500")), - () -> assertTrue(response.contains("Internal Server Error 500")), - () -> assertTrue(response.contains("Content-Type: text/plain")) + () -> assertTrue(response.contains("Internal Server Error")), + () -> assertTrue(response.contains("Content-Type: text/html")) ); } }