From b9600f684b6f303fc438b1fa69f552a7199c2eb3 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Tue, 17 Feb 2026 14:11:35 +0100 Subject: [PATCH 01/27] =?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 02/27] 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 8e0ab5096ab4f6fdc47480502e8f2890000c95d5 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Tue, 17 Feb 2026 14:19:27 +0100 Subject: [PATCH 03/27] =?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 fffdebff315c1edf59f6f640621fda62778c425c Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Fri, 20 Feb 2026 16:48:34 +0100 Subject: [PATCH 04/27] 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 6df10391088f013b65451484adc015b523dd1ece Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Sun, 22 Feb 2026 19:39:14 +0100 Subject: [PATCH 05/27] =?UTF-8?q?code=20funkar=20ska=20l=C3=A4gga=20till?= =?UTF-8?q?=20tester=20till=20StaticFileHandler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/example/StaticFileHandler.java | 51 ++++++++++++++++--- .../org/example/http/HttpResponseBuilder.java | 39 ++++++++++---- 2 files changed, 73 insertions(+), 17 deletions(-) diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 8708bdd4..c968fcb9 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -4,6 +4,7 @@ import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.io.OutputStream; import java.io.PrintWriter; import java.nio.file.Files; @@ -11,17 +12,55 @@ public class StaticFileHandler { private static final String WEB_ROOT = "www"; + private static final long FILE_SIZE_THRESHOLD = 1024 * 1024; // 1MB + private static final int BUFFER_SIZE = 8192; // 8KB + private final CacheFilter 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()) - ); + File file = new File(WEB_ROOT, uri); + + // Skicka headers först + sendHttpHeaders(outputStream, file); + // Skicka body baserat på filstorlek + long fileSize = file.length(); + if (fileSize < FILE_SIZE_THRESHOLD) { + sendSmallFile(outputStream, uri, file); + } else { + sendLargeFile(outputStream, file); + } + } + + private void sendHttpHeaders(OutputStream outputStream, File file) throws IOException { 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()); + response.setContentLength(file.length()); + + PrintWriter writer = new PrintWriter(outputStream, false); + writer.print(response.buildHeaders()); + writer.flush(); + } + + private void sendSmallFile(OutputStream outputStream, String uri, File file) throws IOException { + byte[] fileBytes = cacheFilter.getOrFetch(uri, + path -> Files.readAllBytes(file.toPath()) + ); + + outputStream.write(fileBytes); + outputStream.flush(); + } + + private void sendLargeFile(OutputStream outputStream, File file) throws IOException { + try (InputStream fileInputStream = Files.newInputStream(file.toPath())) { + byte[] buffer = new byte[BUFFER_SIZE]; + int bytesRead; + + while ((bytesRead = fileInputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + + outputStream.flush(); + } } } diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java index afaafcf9..aada43b0 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -11,13 +11,14 @@ public class HttpResponseBuilder { private String body = ""; private byte[] bytebody; private Map headers = new LinkedHashMap<>(); + private long contentLength = 0; 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 : ""; } @@ -25,38 +26,54 @@ public void setBody(String body) { public void setBody(byte[] body) { this.bytebody = body; } + public void setHeaders(Map headers) { this.headers = new LinkedHashMap<>(headers); } + public void setContentLength(long contentLength) { + this.contentLength = contentLength; + } + 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(){ + + // Ny metod: skicka endast headers (för streaming) + public String buildHeaders() { + 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(contentLength).append(CRLF); + sb.append("Connection: close").append(CRLF); + sb.append(CRLF); + + return sb.toString(); + } + + // Befintlig metod för bakåtkompatibilitet + public String build() { StringBuilder sb = new StringBuilder(); int contentLength; - if(body.isEmpty() && bytebody != null){ + if (body.isEmpty() && bytebody != null) { contentLength = bytebody.length; setBody(new String(bytebody, StandardCharsets.UTF_8)); - }else{ + } 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(contentLength); - sb.append(CRLF); + headers.forEach((k, v) -> sb.append(k).append(": ").append(v).append(CRLF)); + sb.append("Content-Length: ").append(contentLength).append(CRLF); sb.append("Connection: close").append(CRLF); sb.append(CRLF); sb.append(body); return sb.toString(); - } - } From 8f240f992ef1046db3d0fd62a1dfb2a89993c07c Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Sun, 22 Feb 2026 19:52:35 +0100 Subject: [PATCH 06/27] =?UTF-8?q?lagt=20till=20en=20webrott=20som=20beh?= =?UTF-8?q?=C3=B6vs=20f=C3=B6r=20testerna=20jag=20jobbar=20p=C3=A5=20=3D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/example/StaticFileHandler.java | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index c968fcb9..be693abe 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -14,11 +14,23 @@ public class StaticFileHandler { private static final String WEB_ROOT = "www"; private static final long FILE_SIZE_THRESHOLD = 1024 * 1024; // 1MB private static final int BUFFER_SIZE = 8192; // 8KB + private static final String DEFAULT_WEB_ROOT = "www"; + private final String webRoot; private final CacheFilter cacheFilter = new CacheFilter(); + // Konstruktor för produktion + public StaticFileHandler() { + this.webRoot = DEFAULT_WEB_ROOT; + } + + // Konstruktor för testning + public StaticFileHandler(String webRoot) { + this.webRoot = webRoot; + } + public void sendGetRequest(OutputStream outputStream, String uri) throws IOException { - File file = new File(WEB_ROOT, uri); + File file = new File(webRoot, uri); // Skicka headers först sendHttpHeaders(outputStream, file); From cb5e5addbe625615092a32308a2cdbfe0aa780b6 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Sun, 22 Feb 2026 19:59:14 +0100 Subject: [PATCH 07/27] =?UTF-8?q?lagt=20till=20tester=20f=C3=B6r=20StaticF?= =?UTF-8?q?ileHandler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/example/StaticFileHandler.java | 18 +-- .../org/example/StaticFileHandlerTest.java | 134 ++++++++++++++++++ 2 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 src/test/java/org/example/StaticFileHandlerTest.java diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index be693abe..8371f7e7 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -1,3 +1,4 @@ + package org.example; import org.example.http.HttpResponseBuilder; @@ -11,11 +12,10 @@ import java.util.Map; public class StaticFileHandler { - private static final String WEB_ROOT = "www"; private static final long FILE_SIZE_THRESHOLD = 1024 * 1024; // 1MB private static final int BUFFER_SIZE = 8192; // 8KB private static final String DEFAULT_WEB_ROOT = "www"; - + private final String webRoot; private final CacheFilter cacheFilter = new CacheFilter(); @@ -31,10 +31,10 @@ public StaticFileHandler(String webRoot) { public void sendGetRequest(OutputStream outputStream, String uri) throws IOException { File file = new File(webRoot, uri); - + // Skicka headers först sendHttpHeaders(outputStream, file); - + // Skicka body baserat på filstorlek long fileSize = file.length(); if (fileSize < FILE_SIZE_THRESHOLD) { @@ -48,7 +48,7 @@ private void sendHttpHeaders(OutputStream outputStream, File file) throws IOExce HttpResponseBuilder response = new HttpResponseBuilder(); response.setHeaders(Map.of("Content-Type", "text/html; charset=utf-8")); response.setContentLength(file.length()); - + PrintWriter writer = new PrintWriter(outputStream, false); writer.print(response.buildHeaders()); writer.flush(); @@ -56,9 +56,9 @@ private void sendHttpHeaders(OutputStream outputStream, File file) throws IOExce private void sendSmallFile(OutputStream outputStream, String uri, File file) throws IOException { byte[] fileBytes = cacheFilter.getOrFetch(uri, - path -> Files.readAllBytes(file.toPath()) + path -> Files.readAllBytes(file.toPath()) ); - + outputStream.write(fileBytes); outputStream.flush(); } @@ -67,11 +67,11 @@ private void sendLargeFile(OutputStream outputStream, File file) throws IOExcept try (InputStream fileInputStream = Files.newInputStream(file.toPath())) { byte[] buffer = new byte[BUFFER_SIZE]; int bytesRead; - + while ((bytesRead = fileInputStream.read(buffer)) != -1) { outputStream.write(buffer, 0, bytesRead); } - + outputStream.flush(); } } diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java new file mode 100644 index 00000000..b2e99fc2 --- /dev/null +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -0,0 +1,134 @@ +package org.example; + +import org.junit.jupiter.api.BeforeEach; +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.assertj.core.api.Assertions.*; + +class StaticFileHandlerTest { + + private StaticFileHandler handler; + + @TempDir + Path tempDir; + + @BeforeEach + void setUp() { + // Injicera temporär katalog för testning + handler = new StaticFileHandler(tempDir.toString()); + } + + @Test + void testSendSmallFile() throws IOException { + // Skapa en liten testfil (< 1MB) + String smallContent = "Hello World"; + Path testFile = tempDir.resolve("small.html"); + Files.writeString(testFile, smallContent); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + handler.sendGetRequest(output, "small.html"); + + String result = output.toString(); + assertThat(result) + .contains("HTTP/1.1 200 OK") + .contains("Content-Type: text/html"); + } + + @Test + void testSendMediumFile() throws IOException { + // Testa med 500KB fil (mellan små och stora) + byte[] content = new byte[500 * 1024]; // 500KB + for (int i = 0; i < content.length; i++) { + content[i] = (byte) (i % 256); + } + + Path testFile = tempDir.resolve("medium.bin"); + Files.write(testFile, content); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + handler.sendGetRequest(output, "medium.bin"); + + String result = output.toString(); + assertThat(result) + .contains("HTTP/1.1 200 OK") + .contains("Content-Length: 512000"); + } + + @Test + void testStreamingLargeFile() throws IOException { + // Testa streaming med större fil (2MB) + byte[] content = new byte[2 * 1024 * 1024]; // 2MB + for (int i = 0; i < content.length; i++) { + content[i] = (byte) (i % 256); + } + + Path testFile = tempDir.resolve("large.bin"); + Files.write(testFile, content); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + // Denna ska streaming och inte kasta OutOfMemoryError + assertThatCode(() -> handler.sendGetRequest(output, "large.bin")) + .doesNotThrowAnyException(); + + String result = output.toString(); + assertThat(result) + .contains("HTTP/1.1 200 OK") + .contains("Content-Length: " + content.length); + } + + @Test + void testHttpHeadersFormatting() throws IOException { + String content = "Test Content"; + Path testFile = tempDir.resolve("test.html"); + Files.writeString(testFile, content); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + handler.sendGetRequest(output, "test.html"); + + String result = output.toString(); + assertThat(result) + .contains("HTTP/1.1 200 OK") + .contains("Content-Type: text/html; charset=utf-8") + .contains("Connection: close") + .contains("Content-Length:"); + } + + @Test + void testEmptyFile() throws IOException { + Path testFile = tempDir.resolve("empty.html"); + Files.createFile(testFile); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + handler.sendGetRequest(output, "empty.html"); + + String result = output.toString(); + assertThat(result) + .contains("HTTP/1.1 200 OK") + .contains("Content-Length: 0"); + } + + @Test + void testSmallFileUsesCaching() throws IOException { + // Testa att små filer cachas + String content = "Cached Content"; + Path testFile = tempDir.resolve("cached.html"); + Files.writeString(testFile, content); + + ByteArrayOutputStream output1 = new ByteArrayOutputStream(); + handler.sendGetRequest(output1, "cached.html"); + + ByteArrayOutputStream output2 = new ByteArrayOutputStream(); + handler.sendGetRequest(output2, "cached.html"); + + // Båda anrop ska fungera + assertThat(output1.toString()).contains("HTTP/1.1 200 OK"); + assertThat(output2.toString()).contains("HTTP/1.1 200 OK"); + } +} \ No newline at end of file From c79c1a62031df4acc4618e0ead3de71f0a9a1cdc Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 11:08:16 +0100 Subject: [PATCH 08/27] =?UTF-8?q?f=C3=B6rb=C3=A4ttrat=20FileCache=20med=20?= =?UTF-8?q?LRU-policy=20och=20tr=C3=A5d-s=C3=A4kerhet,=20lagt=20till=20b?= =?UTF-8?q?=C3=A4ttre=20validering=20och=20beroendeinjektion=20i=20StaticF?= =?UTF-8?q?ileHandler,=20samt=20uppdaterat=20hantering=20i=20CacheFilter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/example/CacheFilter.java | 26 ++++- src/main/java/org/example/FileCache.java | 100 ++++++++++++++++-- .../java/org/example/StaticFileHandler.java | 82 ++++++++++++-- 3 files changed, 187 insertions(+), 21 deletions(-) diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java index 19013322..9c1c8def 100644 --- a/src/main/java/org/example/CacheFilter.java +++ b/src/main/java/org/example/CacheFilter.java @@ -1,21 +1,37 @@ package org.example; import java.io.IOException; +import java.util.Objects; public class CacheFilter { private final FileCache cache = new FileCache(); - + public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { + Objects.requireNonNull(uri, "URI kan inte vara null"); + Objects.requireNonNull(provider, "Provider kan inte vara null"); + if (cache.contains(uri)) { - System.out.println("✓ Cache hit for: " + uri); + logCacheHit(uri); return cache.get(uri); } - System.out.println("✗ Cache miss for: " + uri); + + logCacheMiss(uri); byte[] fileBytes = provider.fetch(uri); - cache.put(uri, fileBytes); + + if (fileBytes != null) { + cache.put(uri, fileBytes); + } return fileBytes; } - + + private void logCacheHit(String uri) { + System.out.println("Cache hit for: " + uri); + } + + private void logCacheMiss(String uri) { + System.out.println("Cache miss for: " + uri); + } + @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 index 5783ce4d..2f087093 100644 --- a/src/main/java/org/example/FileCache.java +++ b/src/main/java/org/example/FileCache.java @@ -1,28 +1,110 @@ + package org.example; -import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; +import java.util.Objects; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +/** + * Thread-safe file cache med LRU eviction policy. + * Lagrar upp till MAX_CACHE_SIZE bytes totalt. + */ public class FileCache { - private final Map cache = new HashMap<>(); + private static final long MAX_CACHE_SIZE = 50 * 1024 * 1024; // 50MB + private static final int MAX_ENTRIES = 1000; + + private final Map cache; + private final ReadWriteLock lock = new ReentrantReadWriteLock(); + private long currentSize = 0; + + public FileCache() { + // LinkedHashMap för att kunna implementera LRU + this.cache = new LinkedHashMap(16, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return cache.size() > MAX_ENTRIES || currentSize > MAX_CACHE_SIZE; + } + }; + } - public boolean contains (String key) { - return cache.containsKey(key); + public boolean contains(String key) { + Objects.requireNonNull(key, "Key kan inte vara null"); + lock.readLock().lock(); + try { + return cache.containsKey(key); + } finally { + lock.readLock().unlock(); + } } public byte[] get(String key) { - return cache.get(key); + Objects.requireNonNull(key, "Key kan inte vara null"); + lock.readLock().lock(); + try { + CacheEntry entry = cache.get(key); + return entry != null ? entry.data : null; + } finally { + lock.readLock().unlock(); + } } - public void put(String key, byte[] value){ - cache.put(key, value); + public void put(String key, byte[] value) { + Objects.requireNonNull(key, "Key kan inte vara null"); + Objects.requireNonNull(value, "Value kan inte vara null"); + + lock.writeLock().lock(); + try { + // Ta bort gamla posten om den finns för att uppdatera storlek + CacheEntry oldEntry = cache.remove(key); + if (oldEntry != null) { + currentSize -= oldEntry.data.length; + } + + // Lägg till ny post + cache.put(key, new CacheEntry(value)); + currentSize += value.length; + } finally { + lock.writeLock().unlock(); + } } + public void clear() { - cache.clear(); + lock.writeLock().lock(); + try { + cache.clear(); + currentSize = 0; + } finally { + lock.writeLock().unlock(); + } } public int size() { - return cache.size(); + lock.readLock().lock(); + try { + return cache.size(); + } finally { + lock.readLock().unlock(); + } + } + + public long getCurrentSizeInBytes() { + lock.readLock().lock(); + try { + return currentSize; + } finally { + lock.readLock().unlock(); + } } + private static class CacheEntry { + final byte[] data; + final long timestamp; + + CacheEntry(byte[] data) { + this.data = data; + this.timestamp = System.currentTimeMillis(); + } + } } diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 8371f7e7..b345be00 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -4,12 +4,15 @@ import org.example.http.HttpResponseBuilder; import java.io.File; +import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.PrintWriter; import java.nio.file.Files; +import java.nio.file.Path; import java.util.Map; +import java.util.Objects; public class StaticFileHandler { private static final long FILE_SIZE_THRESHOLD = 1024 * 1024; // 1MB @@ -17,23 +20,39 @@ public class StaticFileHandler { private static final String DEFAULT_WEB_ROOT = "www"; private final String webRoot; - private final CacheFilter cacheFilter = new CacheFilter(); + private final CacheFilter cacheFilter; // Konstruktor för produktion public StaticFileHandler() { - this.webRoot = DEFAULT_WEB_ROOT; + this(DEFAULT_WEB_ROOT, new CacheFilter()); } // Konstruktor för testning public StaticFileHandler(String webRoot) { - this.webRoot = webRoot; + this(webRoot, new CacheFilter()); + } + + // Konstruktor för dependency injection + public StaticFileHandler(String webRoot, CacheFilter cacheFilter) { + this.webRoot = Objects.requireNonNull(webRoot, "webRoot kan inte vara null"); + this.cacheFilter = Objects.requireNonNull(cacheFilter, "cacheFilter kan inte vara null"); } public void sendGetRequest(OutputStream outputStream, String uri) throws IOException { + Objects.requireNonNull(outputStream, "outputStream kan inte vara null"); + Objects.requireNonNull(uri, "uri kan inte vara null"); + + // Säkerhetskontroll - förhindra directory traversal + validateUri(uri); + File file = new File(webRoot, uri); + // Verifiera att filen finns och är inom webRoot + verifyFileExists(file); + verifyFileIsWithinWebRoot(file); + // Skicka headers först - sendHttpHeaders(outputStream, file); + sendHttpHeaders(outputStream, file, uri); // Skicka body baserat på filstorlek long fileSize = file.length(); @@ -44,9 +63,31 @@ public void sendGetRequest(OutputStream outputStream, String uri) throws IOExcep } } - private void sendHttpHeaders(OutputStream outputStream, File file) throws IOException { + private void validateUri(String uri) throws IOException { + if (uri.contains("..") || uri.contains("//")) { + throw new IOException("Illegal URI: " + uri); + } + } + + private void verifyFileExists(File file) throws FileNotFoundException { + if (!file.exists() || !file.isFile()) { + throw new FileNotFoundException("File not found: " + file.getAbsolutePath()); + } + } + + private void verifyFileIsWithinWebRoot(File file) throws IOException { + String webRootPath = new File(webRoot).getCanonicalPath(); + String filePath = file.getCanonicalPath(); + + if (!filePath.startsWith(webRootPath)) { + throw new IOException("Access denied: " + filePath); + } + } + + private void sendHttpHeaders(OutputStream outputStream, File file, String uri) throws IOException { HttpResponseBuilder response = new HttpResponseBuilder(); - response.setHeaders(Map.of("Content-Type", "text/html; charset=utf-8")); + String contentType = getContentType(uri); + response.setHeaders(Map.of("Content-Type", contentType)); response.setContentLength(file.length()); PrintWriter writer = new PrintWriter(outputStream, false); @@ -54,12 +95,39 @@ private void sendHttpHeaders(OutputStream outputStream, File file) throws IOExce writer.flush(); } + private String getContentType(String uri) { + if (uri.endsWith(".html") || uri.endsWith(".htm")) { + return "text/html; charset=utf-8"; + } else if (uri.endsWith(".css")) { + return "text/css; charset=utf-8"; + } else if (uri.endsWith(".js")) { + return "application/javascript; charset=utf-8"; + } else if (uri.endsWith(".json")) { + return "application/json; charset=utf-8"; + } else if (uri.endsWith(".png")) { + return "image/png"; + } else if (uri.endsWith(".jpg") || uri.endsWith(".jpeg")) { + return "image/jpeg"; + } else if (uri.endsWith(".gif")) { + return "image/gif"; + } else if (uri.endsWith(".svg")) { + return "image/svg+xml"; + } else if (uri.endsWith(".pdf")) { + return "application/pdf"; + } else if (uri.endsWith(".txt")) { + return "text/plain; charset=utf-8"; + } + return "application/octet-stream"; + } + private void sendSmallFile(OutputStream outputStream, String uri, File file) throws IOException { byte[] fileBytes = cacheFilter.getOrFetch(uri, path -> Files.readAllBytes(file.toPath()) ); - outputStream.write(fileBytes); + if (fileBytes != null) { + outputStream.write(fileBytes); + } outputStream.flush(); } From 1c57ca3fb8227024063422c58b921cb200955012 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 11:27:03 +0100 Subject: [PATCH 09/27] Fixar problem --- .../java/org/example/StaticFileHandler.java | 7 +- .../org/example/StaticFileHandlerTest.java | 115 +++++++++++++++++- 2 files changed, 117 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index b345be00..3017b44d 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -10,7 +10,6 @@ import java.io.OutputStream; import java.io.PrintWriter; import java.nio.file.Files; -import java.nio.file.Path; import java.util.Map; import java.util.Objects; @@ -127,8 +126,8 @@ private void sendSmallFile(OutputStream outputStream, String uri, File file) thr if (fileBytes != null) { outputStream.write(fileBytes); + outputStream.flush(); } - outputStream.flush(); } private void sendLargeFile(OutputStream outputStream, File file) throws IOException { @@ -138,8 +137,10 @@ private void sendLargeFile(OutputStream outputStream, File file) throws IOExcept while ((bytesRead = fileInputStream.read(buffer)) != -1) { outputStream.write(buffer, 0, bytesRead); + if (bytesRead == BUFFER_SIZE){ + outputStream.flush(); + } } - outputStream.flush(); } } diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java index b2e99fc2..863537b8 100644 --- a/src/test/java/org/example/StaticFileHandlerTest.java +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -63,7 +63,7 @@ void testSendMediumFile() throws IOException { @Test void testStreamingLargeFile() throws IOException { // Testa streaming med större fil (2MB) - byte[] content = new byte[2 * 1024 * 1024]; // 2MB + byte[] content = new byte[2 * 1024 * 1024]; for (int i = 0; i < content.length; i++) { content[i] = (byte) (i % 256); } @@ -83,6 +83,95 @@ void testStreamingLargeFile() throws IOException { .contains("Content-Length: " + content.length); } + @Test + void testStreamingLargeFileIntegrity() throws IOException { + // Testa att all data strömmas korrekt (2MB) + byte[] originalContent = new byte[2 * 1024 * 1024]; + for (int i = 0; i < originalContent.length; i++) { + originalContent[i] = (byte) (i % 256); + } + + Path testFile = tempDir.resolve("large_integrity.bin"); + Files.write(testFile, originalContent); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + handler.sendGetRequest(output, "large_integrity.bin"); + + byte[] outputBytes = output.toByteArray(); + + // Sök efter HTTP-header separator (\r\n\r\n) i byte-array + int separatorIndex = findHeaderBodySeparator(outputBytes); + assertThat(separatorIndex).isGreaterThanOrEqualTo(0) + .withFailMessage("Kunde inte hitta HTTP-header separator"); + + // Extrahera body från HTTP-response + byte[] receivedBody = new byte[outputBytes.length - separatorIndex - 4]; // -4 för \r\n\r\n + System.arraycopy(outputBytes, separatorIndex + 4, receivedBody, 0, receivedBody.length); + + assertThat(receivedBody).isEqualTo(originalContent); + } + + private int findHeaderBodySeparator(byte[] data) { + // Sök efter \r\n\r\n (0x0D 0x0A 0x0D 0x0A) + for (int i = 0; i < data.length - 3; i++) { + if (data[i] == '\r' && data[i + 1] == '\n' && + data[i + 2] == '\r' && data[i + 3] == '\n') { + return i; + } + } + return -1; + } + + @Test + void testVeryLargeFile() throws IOException { + // Testa streaming med mycket större fil (10MB) + byte[] content = new byte[10 * 1024 * 1024]; // 10MB + for (int i = 0; i < content.length; i++) { + content[i] = (byte) (i % 256); + } + + Path testFile = tempDir.resolve("very_large.bin"); + Files.write(testFile, content); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + // Denna ska streaming och inte kasta OutOfMemoryError + assertThatCode(() -> handler.sendGetRequest(output, "very_large.bin")) + .doesNotThrowAnyException(); + + String result = output.toString(); + assertThat(result) + .contains("HTTP/1.1 200 OK") + .contains("Content-Length: " + content.length); + } + + @Test + void testStreamingFilesDoNotExceedMemoryThreshold() throws IOException { + // Testa att streaming inte använder mer än ~20MB för en 15MB fil + byte[] content = new byte[15 * 1024 * 1024]; // 15MB + for (int i = 0; i < content.length; i++) { + content[i] = (byte) (i % 256); + } + + Path testFile = tempDir.resolve("memory_test.bin"); + Files.write(testFile, content); + + Runtime runtime = Runtime.getRuntime(); + long memoryBefore = runtime.totalMemory() - runtime.freeMemory(); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + handler.sendGetRequest(output, "memory_test.bin"); + + long memoryAfter = runtime.totalMemory() - runtime.freeMemory(); + long memoryIncrease = memoryAfter - memoryBefore; + + // Memory increase should be much less than file size + // (due to streaming with 8KB buffer) + assertThat(memoryIncrease).isLessThan(50 * 1024 * 1024); // Less than 50MB + } + + + @Test void testHttpHeadersFormatting() throws IOException { String content = "Test Content"; @@ -131,4 +220,26 @@ void testSmallFileUsesCaching() throws IOException { assertThat(output1.toString()).contains("HTTP/1.1 200 OK"); assertThat(output2.toString()).contains("HTTP/1.1 200 OK"); } -} \ No newline at end of file + + @Test + void testLargeFileNotCached() throws IOException { + // Testa att stora filer INTE cachas (de streamas istället) + byte[] content = new byte[2 * 1024 * 1024]; // 2MB + for (int i = 0; i < content.length; i++) { + content[i] = (byte) (i % 256); + } + + Path testFile = tempDir.resolve("not_cached.bin"); + Files.write(testFile, content); + + ByteArrayOutputStream output1 = new ByteArrayOutputStream(); + handler.sendGetRequest(output1, "not_cached.bin"); + + ByteArrayOutputStream output2 = new ByteArrayOutputStream(); + handler.sendGetRequest(output2, "not_cached.bin"); + + // Båda anrop ska fungera utan cachning + assertThat(output1.toString()).contains("HTTP/1.1 200 OK"); + assertThat(output2.toString()).contains("HTTP/1.1 200 OK"); + } +} From 919be7432b08412e1e9485f1e840bacb9105feff Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Tue, 17 Feb 2026 14:12:27 +0100 Subject: [PATCH 10/27] 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 a8868e0f7f79f31642919d7c586c58d4ae3ddb67 Mon Sep 17 00:00:00 2001 From: Caroline Nordbrandt Date: Tue, 17 Feb 2026 14:23:09 +0100 Subject: [PATCH 11/27] 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 | 145 ++-------- .../org/example/StaticFileHandlerTest.java | 256 +++--------------- www/pageNotFound.html | 55 ++++ 3 files changed, 127 insertions(+), 329 deletions(-) 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 3017b44d..bf9e79bc 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -1,147 +1,56 @@ - package org.example; import org.example.http.HttpResponseBuilder; import java.io.File; -import java.io.FileNotFoundException; import java.io.IOException; -import java.io.InputStream; import java.io.OutputStream; import java.io.PrintWriter; import java.nio.file.Files; import java.util.Map; -import java.util.Objects; public class StaticFileHandler { - private static final long FILE_SIZE_THRESHOLD = 1024 * 1024; // 1MB - private static final int BUFFER_SIZE = 8192; // 8KB - private static final String DEFAULT_WEB_ROOT = "www"; - - private final String webRoot; - private final CacheFilter cacheFilter; + private final String WEB_ROOT; + private byte[] fileBytes; + private int statusCode; - // Konstruktor för produktion + //Constructor for production public StaticFileHandler() { - this(DEFAULT_WEB_ROOT, new CacheFilter()); - } - - // Konstruktor för testning - public StaticFileHandler(String webRoot) { - this(webRoot, new CacheFilter()); + WEB_ROOT = "www"; } - // Konstruktor för dependency injection - public StaticFileHandler(String webRoot, CacheFilter cacheFilter) { - this.webRoot = Objects.requireNonNull(webRoot, "webRoot kan inte vara null"); - this.cacheFilter = Objects.requireNonNull(cacheFilter, "cacheFilter kan inte vara null"); + //Constructor for tests, otherwise the www folder won't be seen + public StaticFileHandler(String webRoot){ + WEB_ROOT = webRoot; } - public void sendGetRequest(OutputStream outputStream, String uri) throws IOException { - Objects.requireNonNull(outputStream, "outputStream kan inte vara null"); - Objects.requireNonNull(uri, "uri kan inte vara null"); - - // Säkerhetskontroll - förhindra directory traversal - validateUri(uri); - - File file = new File(webRoot, uri); - - // Verifiera att filen finns och är inom webRoot - verifyFileExists(file); - verifyFileIsWithinWebRoot(file); - - // Skicka headers först - sendHttpHeaders(outputStream, file, uri); + private void handleGetRequest(String uri) throws IOException { - // Skicka body baserat på filstorlek - long fileSize = file.length(); - if (fileSize < FILE_SIZE_THRESHOLD) { - sendSmallFile(outputStream, uri, file); + File file = new File(WEB_ROOT, uri); + if(file.exists()) { + fileBytes = Files.readAllBytes(file.toPath()); + statusCode = 200; } else { - sendLargeFile(outputStream, file); - } - } - - private void validateUri(String uri) throws IOException { - if (uri.contains("..") || uri.contains("//")) { - throw new IOException("Illegal URI: " + uri); - } - } - - private void verifyFileExists(File file) throws FileNotFoundException { - if (!file.exists() || !file.isFile()) { - throw new FileNotFoundException("File not found: " + file.getAbsolutePath()); + File errorFile = new File(WEB_ROOT, "pageNotFound.html"); + if(errorFile.exists()) { + fileBytes = Files.readAllBytes(errorFile.toPath()); + } else { + fileBytes = "404 Not Found".getBytes(); + } + statusCode = 404; } } - private void verifyFileIsWithinWebRoot(File file) throws IOException { - String webRootPath = new File(webRoot).getCanonicalPath(); - String filePath = file.getCanonicalPath(); + public void sendGetRequest(OutputStream outputStream, String uri) throws IOException{ + handleGetRequest(uri); - if (!filePath.startsWith(webRootPath)) { - throw new IOException("Access denied: " + filePath); - } - } - - private void sendHttpHeaders(OutputStream outputStream, File file, String uri) throws IOException { HttpResponseBuilder response = new HttpResponseBuilder(); - String contentType = getContentType(uri); - response.setHeaders(Map.of("Content-Type", contentType)); - response.setContentLength(file.length()); - - PrintWriter writer = new PrintWriter(outputStream, false); - writer.print(response.buildHeaders()); - writer.flush(); - } - - private String getContentType(String uri) { - if (uri.endsWith(".html") || uri.endsWith(".htm")) { - return "text/html; charset=utf-8"; - } else if (uri.endsWith(".css")) { - return "text/css; charset=utf-8"; - } else if (uri.endsWith(".js")) { - return "application/javascript; charset=utf-8"; - } else if (uri.endsWith(".json")) { - return "application/json; charset=utf-8"; - } else if (uri.endsWith(".png")) { - return "image/png"; - } else if (uri.endsWith(".jpg") || uri.endsWith(".jpeg")) { - return "image/jpeg"; - } else if (uri.endsWith(".gif")) { - return "image/gif"; - } else if (uri.endsWith(".svg")) { - return "image/svg+xml"; - } else if (uri.endsWith(".pdf")) { - return "application/pdf"; - } else if (uri.endsWith(".txt")) { - return "text/plain; charset=utf-8"; - } - return "application/octet-stream"; - } - - private void sendSmallFile(OutputStream outputStream, String uri, File file) throws IOException { - byte[] fileBytes = cacheFilter.getOrFetch(uri, - path -> Files.readAllBytes(file.toPath()) - ); + response.setStatusCode(statusCode); + response.setHeaders(Map.of("Content-Type", "text/html; charset=utf-8")); + response.setBody(fileBytes); + PrintWriter writer = new PrintWriter(outputStream, true); + writer.println(response.build()); - if (fileBytes != null) { - outputStream.write(fileBytes); - outputStream.flush(); - } } - private void sendLargeFile(OutputStream outputStream, File file) throws IOException { - try (InputStream fileInputStream = Files.newInputStream(file.toPath())) { - byte[] buffer = new byte[BUFFER_SIZE]; - int bytesRead; - - while ((bytesRead = fileInputStream.read(buffer)) != -1) { - outputStream.write(buffer, 0, bytesRead); - if (bytesRead == BUFFER_SIZE){ - outputStream.flush(); - } - } - outputStream.flush(); - } - } } diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java index 863537b8..871fdb46 100644 --- a/src/test/java/org/example/StaticFileHandlerTest.java +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -1,6 +1,5 @@ package org.example; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -8,238 +7,73 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; - -import static org.assertj.core.api.Assertions.*; - +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 { - - 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() { - // Injicera temporär katalog för testning - handler = new StaticFileHandler(tempDir.toString()); - } - - @Test - void testSendSmallFile() throws IOException { - // Skapa en liten testfil (< 1MB) - String smallContent = "Hello World"; - Path testFile = tempDir.resolve("small.html"); - Files.writeString(testFile, smallContent); - - ByteArrayOutputStream output = new ByteArrayOutputStream(); - handler.sendGetRequest(output, "small.html"); - - String result = output.toString(); - assertThat(result) - .contains("HTTP/1.1 200 OK") - .contains("Content-Type: text/html"); - } - - @Test - void testSendMediumFile() throws IOException { - // Testa med 500KB fil (mellan små och stora) - byte[] content = new byte[500 * 1024]; // 500KB - for (int i = 0; i < content.length; i++) { - content[i] = (byte) (i % 256); - } - - Path testFile = tempDir.resolve("medium.bin"); - Files.write(testFile, content); - - ByteArrayOutputStream output = new ByteArrayOutputStream(); - handler.sendGetRequest(output, "medium.bin"); - - String result = output.toString(); - assertThat(result) - .contains("HTTP/1.1 200 OK") - .contains("Content-Length: 512000"); - } - @Test - void testStreamingLargeFile() throws IOException { - // Testa streaming med större fil (2MB) - byte[] content = new byte[2 * 1024 * 1024]; - for (int i = 0; i < content.length; i++) { - content[i] = (byte) (i % 256); - } - - Path testFile = tempDir.resolve("large.bin"); - Files.write(testFile, content); - - ByteArrayOutputStream output = new ByteArrayOutputStream(); - - // Denna ska streaming och inte kasta OutOfMemoryError - assertThatCode(() -> handler.sendGetRequest(output, "large.bin")) - .doesNotThrowAnyException(); - - String result = output.toString(); - assertThat(result) - .contains("HTTP/1.1 200 OK") - .contains("Content-Length: " + content.length); - } @Test - void testStreamingLargeFileIntegrity() throws IOException { - // Testa att all data strömmas korrekt (2MB) - byte[] originalContent = new byte[2 * 1024 * 1024]; - for (int i = 0; i < originalContent.length; i++) { - originalContent[i] = (byte) (i % 256); - } + 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 - Path testFile = tempDir.resolve("large_integrity.bin"); - Files.write(testFile, originalContent); + //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()); - ByteArrayOutputStream output = new ByteArrayOutputStream(); - handler.sendGetRequest(output, "large_integrity.bin"); + //Using ByteArrayOutputStream instead of Outputstream during tests to capture the servers response in memory, fake stream + ByteArrayOutputStream fakeOutput = new ByteArrayOutputStream(); - byte[] outputBytes = output.toByteArray(); + //Act + staticFileHandler.sendGetRequest(fakeOutput, "test.html"); //Get test.html and write the answer to fakeOutput - // Sök efter HTTP-header separator (\r\n\r\n) i byte-array - int separatorIndex = findHeaderBodySeparator(outputBytes); - assertThat(separatorIndex).isGreaterThanOrEqualTo(0) - .withFailMessage("Kunde inte hitta HTTP-header separator"); + //Assert + String response = fakeOutput.toString();//Converts the captured byte stream into a String for verification - // Extrahera body från HTTP-response - byte[] receivedBody = new byte[outputBytes.length - separatorIndex - 4]; // -4 för \r\n\r\n - System.arraycopy(outputBytes, separatorIndex + 4, receivedBody, 0, receivedBody.length); + assertTrue(response.contains("HTTP/1.1 200 OK")); // Assert the status + assertTrue(response.contains("Hello Test")); //Assert the content in the file - assertThat(receivedBody).isEqualTo(originalContent); - } - - private int findHeaderBodySeparator(byte[] data) { - // Sök efter \r\n\r\n (0x0D 0x0A 0x0D 0x0A) - for (int i = 0; i < data.length - 3; i++) { - if (data[i] == '\r' && data[i + 1] == '\n' && - data[i + 2] == '\r' && data[i + 3] == '\n') { - return i; - } - } - return -1; - } + assertTrue(response.contains("Content-Type: text/html; charset=utf-8")); // Verify the correct Content-type header - @Test - void testVeryLargeFile() throws IOException { - // Testa streaming med mycket större fil (10MB) - byte[] content = new byte[10 * 1024 * 1024]; // 10MB - for (int i = 0; i < content.length; i++) { - content[i] = (byte) (i % 256); - } - - Path testFile = tempDir.resolve("very_large.bin"); - Files.write(testFile, content); - - ByteArrayOutputStream output = new ByteArrayOutputStream(); - - // Denna ska streaming och inte kasta OutOfMemoryError - assertThatCode(() -> handler.sendGetRequest(output, "very_large.bin")) - .doesNotThrowAnyException(); - - String result = output.toString(); - assertThat(result) - .contains("HTTP/1.1 200 OK") - .contains("Content-Length: " + content.length); } @Test - void testStreamingFilesDoNotExceedMemoryThreshold() throws IOException { - // Testa att streaming inte använder mer än ~20MB för en 15MB fil - byte[] content = new byte[15 * 1024 * 1024]; // 15MB - for (int i = 0; i < content.length; i++) { - content[i] = (byte) (i % 256); - } + 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"); - Path testFile = tempDir.resolve("memory_test.bin"); - Files.write(testFile, content); + //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()); - Runtime runtime = Runtime.getRuntime(); - long memoryBefore = runtime.totalMemory() - runtime.freeMemory(); + //Using ByteArrayOutputStream instead of Outputstream during tests to capture the servers response in memory, fake stream + ByteArrayOutputStream fakeOutput = new ByteArrayOutputStream(); - ByteArrayOutputStream output = new ByteArrayOutputStream(); - handler.sendGetRequest(output, "memory_test.bin"); + //Act + staticFileHandler.sendGetRequest(fakeOutput, "notExistingFile.html"); // Request a file that clearly doesn't exist to trigger the 404 logic - long memoryAfter = runtime.totalMemory() - runtime.freeMemory(); - long memoryIncrease = memoryAfter - memoryBefore; + //Assert + String response = fakeOutput.toString();//Converts the captured byte stream into a String for verification - // Memory increase should be much less than file size - // (due to streaming with 8KB buffer) - assertThat(memoryIncrease).isLessThan(50 * 1024 * 1024); // Less than 50MB - } - - - - @Test - void testHttpHeadersFormatting() throws IOException { - String content = "Test Content"; - Path testFile = tempDir.resolve("test.html"); - Files.writeString(testFile, content); - - ByteArrayOutputStream output = new ByteArrayOutputStream(); - handler.sendGetRequest(output, "test.html"); - - String result = output.toString(); - assertThat(result) - .contains("HTTP/1.1 200 OK") - .contains("Content-Type: text/html; charset=utf-8") - .contains("Connection: close") - .contains("Content-Length:"); - } - - @Test - void testEmptyFile() throws IOException { - Path testFile = tempDir.resolve("empty.html"); - Files.createFile(testFile); - - ByteArrayOutputStream output = new ByteArrayOutputStream(); - handler.sendGetRequest(output, "empty.html"); - - String result = output.toString(); - assertThat(result) - .contains("HTTP/1.1 200 OK") - .contains("Content-Length: 0"); - } + assertTrue(response.contains("HTTP/1.1 404 Not Found")); // Assert the status - @Test - void testSmallFileUsesCaching() throws IOException { - // Testa att små filer cachas - String content = "Cached Content"; - Path testFile = tempDir.resolve("cached.html"); - Files.writeString(testFile, content); - - ByteArrayOutputStream output1 = new ByteArrayOutputStream(); - handler.sendGetRequest(output1, "cached.html"); - - ByteArrayOutputStream output2 = new ByteArrayOutputStream(); - handler.sendGetRequest(output2, "cached.html"); - - // Båda anrop ska fungera - assertThat(output1.toString()).contains("HTTP/1.1 200 OK"); - assertThat(output2.toString()).contains("HTTP/1.1 200 OK"); } - @Test - void testLargeFileNotCached() throws IOException { - // Testa att stora filer INTE cachas (de streamas istället) - byte[] content = new byte[2 * 1024 * 1024]; // 2MB - for (int i = 0; i < content.length; i++) { - content[i] = (byte) (i % 256); - } - - Path testFile = tempDir.resolve("not_cached.bin"); - Files.write(testFile, content); - - ByteArrayOutputStream output1 = new ByteArrayOutputStream(); - handler.sendGetRequest(output1, "not_cached.bin"); - - ByteArrayOutputStream output2 = new ByteArrayOutputStream(); - handler.sendGetRequest(output2, "not_cached.bin"); - - // Båda anrop ska fungera utan cachning - assertThat(output1.toString()).contains("HTTP/1.1 200 OK"); - assertThat(output2.toString()).contains("HTTP/1.1 200 OK"); - } } 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 21079fbc52db43379476c376620b69ea8ef0e902 Mon Sep 17 00:00:00 2001 From: Martin Stenhagen Date: Wed, 18 Feb 2026 10:41:30 +0100 Subject: [PATCH 12/27] 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 d121cfa4de96b2ea3ecc44bb47ebbc6f501d14e4 Mon Sep 17 00:00:00 2001 From: Eric Phu Date: Wed, 18 Feb 2026 17:25:37 +0100 Subject: [PATCH 13/27] 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 ad9e1c402234b357c74db8a88a0ccb6b45f2adf7 Mon Sep 17 00:00:00 2001 From: Younes Ahmad Date: Wed, 18 Feb 2026 23:37:02 +0100 Subject: [PATCH 14/27] 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 | 122 +++-- .../org/example/http/MimeTypeDetector.java | 77 +++ .../org/example/StaticFileHandlerTest.java | 2 +- .../example/http/HttpResponseBuilderTest.java | 453 +++++++++++++++++- .../example/http/MimeTypeDetectorTest.java | 165 +++++++ 6 files changed, 776 insertions(+), 77 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 aada43b0..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,70 +10,108 @@ public class HttpResponseBuilder { private int statusCode = 200; private String body = ""; private byte[] bytebody; - private Map headers = new LinkedHashMap<>(); - private long contentLength = 0; + 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); } - public void setContentLength(long contentLength) { - this.contentLength = contentLength; + public void setHeader(String name, String value) { + this.headers.put(name, value); } - private static final Map REASON_PHRASES = Map.of( - 200, "OK", - 201, "Created", - 400, "Bad Request", - 404, "Not Found", - 500, "Internal Server Error"); - - // Ny metod: skicka endast headers (för streaming) - public String buildHeaders() { - 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(contentLength).append(CRLF); - sb.append("Connection: close").append(CRLF); - sb.append(CRLF); - - return sb.toString(); + public void setContentTypeFromFilename(String filename) { + String mimeType = MimeTypeDetector.detectMimeType(filename); + setHeader("Content-Type", mimeType); } - // Befintlig metod för bakåtkompatibilitet - public String build() { - StringBuilder sb = new StringBuilder(); + /* + * 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; + contentBody = body.getBytes(StandardCharsets.UTF_8); + contentLength = contentBody.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(contentLength).append(CRLF); - sb.append("Connection: close").append(CRLF); - sb.append(CRLF); - sb.append(body); - return sb.toString(); + // 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()) { + 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 d12df617a53c63fd0ac965aa28c1a3412003455a 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 15/27] 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 5514dfc74bb81f22b2c11d4a8d78967d0d3366c1 Mon Sep 17 00:00:00 2001 From: Younes Ahmad Date: Thu, 19 Feb 2026 10:25:32 +0100 Subject: [PATCH 16/27] 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 4bc2b13833bae17fc65de2ff5b031c0b32339973 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 17/27] 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 34febcd54e05d1a710ae665f4dbddf4f5289c9e6 Mon Sep 17 00:00:00 2001 From: viktorlindell12 Date: Mon, 23 Feb 2026 09:32:43 +0100 Subject: [PATCH 18/27] 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 64742ca342893eb01cee56d137f159775db12ff0 Mon Sep 17 00:00:00 2001 From: Ebba Andersson Date: Mon, 23 Feb 2026 14:47:45 +0100 Subject: [PATCH 19/27] 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 4b79dd4ab3c9c4f32d37285d8afa1ea5e00700a1 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 20/27] 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 1231b1a7056ad30ecbcf9d5f9830bb8000159bcc Mon Sep 17 00:00:00 2001 From: Caroline Nordbrandt Date: Tue, 24 Feb 2026 14:40:55 +0100 Subject: [PATCH 21/27] 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 304a0e9226f88e6a1e07a9494f799305cd19fac8 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 22/27] 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 94630dcfe17f7ba9fde6cddf5e5d549ede8fab31 Mon Sep 17 00:00:00 2001 From: AntonAhlqvist Date: Wed, 25 Feb 2026 08:48:17 +0100 Subject: [PATCH 23/27] 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 654449d766b3cce6bcd6b293d56e4d462bd8292d Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Fri, 20 Feb 2026 16:48:34 +0100 Subject: [PATCH 24/27] updaterat i StaticFileHandler och skapat CacheFilter --- src/main/java/org/example/CacheFilter.java | 26 +++++----------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java index 9c1c8def..19013322 100644 --- a/src/main/java/org/example/CacheFilter.java +++ b/src/main/java/org/example/CacheFilter.java @@ -1,37 +1,21 @@ package org.example; import java.io.IOException; -import java.util.Objects; public class CacheFilter { private final FileCache cache = new FileCache(); - + public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { - Objects.requireNonNull(uri, "URI kan inte vara null"); - Objects.requireNonNull(provider, "Provider kan inte vara null"); - if (cache.contains(uri)) { - logCacheHit(uri); + System.out.println("✓ Cache hit for: " + uri); return cache.get(uri); } - - logCacheMiss(uri); + System.out.println("✗ Cache miss for: " + uri); byte[] fileBytes = provider.fetch(uri); - - if (fileBytes != null) { - cache.put(uri, fileBytes); - } + cache.put(uri, fileBytes); return fileBytes; } - - private void logCacheHit(String uri) { - System.out.println("Cache hit for: " + uri); - } - - private void logCacheMiss(String uri) { - System.out.println("Cache miss for: " + uri); - } - + @FunctionalInterface public interface FileProvider { byte[] fetch(String uri) throws IOException; From 028bf601fb17c1753c61ace437fc8b30ec911955 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 11:08:16 +0100 Subject: [PATCH 25/27] =?UTF-8?q?f=C3=B6rb=C3=A4ttrat=20FileCache=20med=20?= =?UTF-8?q?LRU-policy=20och=20tr=C3=A5d-s=C3=A4kerhet,=20lagt=20till=20b?= =?UTF-8?q?=C3=A4ttre=20validering=20och=20beroendeinjektion=20i=20StaticF?= =?UTF-8?q?ileHandler,=20samt=20uppdaterat=20hantering=20i=20CacheFilter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/example/CacheFilter.java | 26 +++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java index 19013322..9c1c8def 100644 --- a/src/main/java/org/example/CacheFilter.java +++ b/src/main/java/org/example/CacheFilter.java @@ -1,21 +1,37 @@ package org.example; import java.io.IOException; +import java.util.Objects; public class CacheFilter { private final FileCache cache = new FileCache(); - + public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { + Objects.requireNonNull(uri, "URI kan inte vara null"); + Objects.requireNonNull(provider, "Provider kan inte vara null"); + if (cache.contains(uri)) { - System.out.println("✓ Cache hit for: " + uri); + logCacheHit(uri); return cache.get(uri); } - System.out.println("✗ Cache miss for: " + uri); + + logCacheMiss(uri); byte[] fileBytes = provider.fetch(uri); - cache.put(uri, fileBytes); + + if (fileBytes != null) { + cache.put(uri, fileBytes); + } return fileBytes; } - + + private void logCacheHit(String uri) { + System.out.println("Cache hit for: " + uri); + } + + private void logCacheMiss(String uri) { + System.out.println("Cache miss for: " + uri); + } + @FunctionalInterface public interface FileProvider { byte[] fetch(String uri) throws IOException; From b2f045a3c4bf8474f5ff9f099e0c546ea330b949 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 12:03:23 +0100 Subject: [PATCH 26/27] =?UTF-8?q?f=C3=B6rb=C3=A4ttrat=20StaticFileHandler?= =?UTF-8?q?=20med=20fil-str=C3=B6mning=20f=C3=B6r=20stora=20filer,=20minne?= =?UTF-8?q?soptimerad=20hantering=20av=20sm=C3=A5=20filer,=20f=C3=B6rb?= =?UTF-8?q?=C3=A4ttrad=20URI-s=C3=A4kerhet,=20samt=20lagt=20till=20omfatta?= =?UTF-8?q?nde=20tester?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/example/StaticFileHandler.java | 109 +++++++--- .../org/example/StaticFileHandlerTest.java | 188 +++++++++++------- 2 files changed, 194 insertions(+), 103 deletions(-) diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 8bcda375..2a9a15cf 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -3,67 +3,120 @@ 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.io.*; import java.nio.file.Files; +import java.nio.file.Path; public class StaticFileHandler { + private static final long FILE_SIZE_THRESHOLD = 1024 * 1024; // 1MB + private static final int BUFFER_SIZE = 8192; // 8KB + private final String WEB_ROOT; - 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; } - private void handleGetRequest(String uri) throws IOException { + public void sendGetRequest(OutputStream outputStream, 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); + uri = sanitizeUri(uri); // 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 = SC_FORBIDDEN; + sendErrorResponse(outputStream, SC_FORBIDDEN, "403 Forbidden", uri); return; } - // Read file + // Send file or error if (file.isFile()) { - fileBytes = Files.readAllBytes(file.toPath()); - statusCode = SC_OK; + sendFile(outputStream, file, uri); } else { File errorFile = new File(WEB_ROOT, "pageNotFound.html"); if (errorFile.isFile()) { - fileBytes = Files.readAllBytes(errorFile.toPath()); + sendErrorFile(outputStream, errorFile); } else { - fileBytes = "404 Not Found".getBytes(java.nio.charset.StandardCharsets.UTF_8); + sendErrorResponse(outputStream, SC_NOT_FOUND, "404 Not Found", uri); } - statusCode = SC_NOT_FOUND; } } - public void sendGetRequest(OutputStream outputStream, String uri) throws IOException { - handleGetRequest(uri); + private String sanitizeUri(String uri) { + // Remove query string and fragment + int q = uri.indexOf('?'); + if (q >= 0) uri = uri.substring(0, q); + int h = uri.indexOf('#'); + if (h >= 0) uri = uri.substring(0, h); + + // Remove null bytes and leading slashes + uri = uri.replace("\0", "").replaceAll("^/+", ""); + + return uri; + } + + private void sendFile(OutputStream out, File file, String uri) throws IOException { + long fileSize = file.length(); + + // Send headers + HttpResponseBuilder response = new HttpResponseBuilder(); + response.setStatusCode(SC_OK); + response.setContentTypeFromFilename(uri); + response.setContentLength(fileSize); + + byte[] headers = response.buildHeaders(); + out.write(headers); + + // Send body: stream large files, cache small files + if (fileSize < FILE_SIZE_THRESHOLD) { + streamSmallFile(out, file); + } else { + streamLargeFile(out, file); + } + } + + private void streamSmallFile(OutputStream out, File file) throws IOException { + byte[] fileBytes = Files.readAllBytes(file.toPath()); + out.write(fileBytes); + out.flush(); + } + + private void streamLargeFile(OutputStream out, File file) throws IOException { + try (InputStream in = Files.newInputStream(file.toPath())) { + byte[] buffer = new byte[BUFFER_SIZE]; + int bytesRead; + + while ((bytesRead = in.read(buffer)) != -1) { + out.write(buffer, 0, bytesRead); + } + out.flush(); + } + } + + private void sendErrorFile(OutputStream out, File errorFile) throws IOException { + byte[] errorBytes = Files.readAllBytes(errorFile.toPath()); + + HttpResponseBuilder response = new HttpResponseBuilder(); + response.setStatusCode(SC_NOT_FOUND); + response.setContentTypeFromFilename("error.html"); + response.setBody(errorBytes); + + out.write(response.build()); + out.flush(); + } + + private void sendErrorResponse(OutputStream out, int statusCode, String message, String uri) throws IOException { 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(); + response.setBody(message.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + + out.write(response.build()); + out.flush(); } } diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java index ce6feb7a..e4fac378 100644 --- a/src/test/java/org/example/StaticFileHandlerTest.java +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -4,97 +4,68 @@ import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import static org.example.http.HttpResponseBuilder.*; +import static org.junit.jupiter.api.Assertions.*; 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.*; -import static org.example.http.HttpResponseBuilder.*; -/** - * 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 " + SC_OK + " OK")); // Assert the status - assertTrue(response.contains("Hello Test")); //Assert the content in the file + // Arrange + Files.writeString(tempDir.resolve("test.html"), "Hello Test"); + StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); + ByteArrayOutputStream output = new ByteArrayOutputStream(); - assertTrue(response.contains("Content-Type: text/html; charset=UTF-8")); // Verify the correct Content-type header + // Act + handler.sendGetRequest(output, "test.html"); + // Assert + String response = output.toString(); + assertTrue(response.contains("HTTP/1.1 " + SC_OK + " OK")); + assertTrue(response.contains("Hello Test")); + assertTrue(response.contains("Content-Type: text/html; charset=UTF-8")); } @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 + void test_file_that_does_not_exist_should_return_404() throws IOException { + // Arrange + Files.writeString(tempDir.resolve("pageNotFound.html"), "Not Found"); + StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); + ByteArrayOutputStream output = new ByteArrayOutputStream(); - assertTrue(response.contains("HTTP/1.1 " + SC_NOT_FOUND + " Not Found")); // Assert the status + // Act + handler.sendGetRequest(output, "missing.html"); + // Assert + String response = output.toString(); + assertTrue(response.contains("HTTP/1.1 " + SC_NOT_FOUND + " Not Found")); } @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); + Files.writeString(tempDir.resolve("secret.txt"), "SECRET"); + Files.writeString(webRoot.resolve("index.html"), "Public"); + StaticFileHandler handler = new StaticFileHandler(webRoot.toString()); - ByteArrayOutputStream fakeOutput = new ByteArrayOutputStream(); + ByteArrayOutputStream output = new ByteArrayOutputStream(); // Act - handler.sendGetRequest(fakeOutput, "../secret.txt"); + handler.sendGetRequest(output, "../secret.txt"); // Assert - String response = fakeOutput.toString(); - assertFalse(response.contains("TOP SECRET")); + String response = output.toString(); + assertFalse(response.contains("SECRET")); assertTrue(response.contains("HTTP/1.1 " + SC_FORBIDDEN + " Forbidden")); } @@ -106,33 +77,100 @@ void test_path_traversal_should_return_403() throws IOException { }) 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(); + Files.writeString(tempDir.resolve("index.html"), "Home"); + StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); + ByteArrayOutputStream output = new ByteArrayOutputStream(); // Act - handler.sendGetRequest(out, uri); + handler.sendGetRequest(output, uri); // Assert - assertTrue(out.toString().contains("HTTP/1.1 " + SC_OK + " OK")); + assertTrue(output.toString().contains("HTTP/1.1 " + SC_OK + " OK")); } @Test - void null_byte_injection_should_not_return_200() throws IOException { + void null_byte_injection_should_return_403() throws IOException { // Arrange - Path webRoot = tempDir.resolve("www"); - Files.createDirectories(webRoot); - StaticFileHandler handler = new StaticFileHandler(webRoot.toString()); - ByteArrayOutputStream out = new ByteArrayOutputStream(); + StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); + ByteArrayOutputStream output = new ByteArrayOutputStream(); // Act - handler.sendGetRequest(out, "index.html\0../../etc/passwd"); + handler.sendGetRequest(output, "index.html\0../../etc/passwd"); // 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")); + String response = output.toString(); + assertFalse(response.contains("OK")); + assertTrue(response.contains("HTTP/1.1 " + SC_FORBIDDEN + " Forbidden")); + } + + @Test + void small_file_should_be_loaded_in_memory() throws IOException { + // Arrange - 500KB fil (under 1MB threshold) + byte[] smallContent = new byte[500 * 1024]; + for (int i = 0; i < smallContent.length; i++) { + smallContent[i] = (byte) (i % 256); + } + Files.write(tempDir.resolve("small.bin"), smallContent); + + StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + // Act + handler.sendGetRequest(output, "small.bin"); + + // Assert - Bör innehålla hela filen + String response = output.toString(); + assertTrue(response.contains("HTTP/1.1 " + SC_OK + " OK")); + assertTrue(response.contains("Content-Length: 512000")); + } + + @Test + void large_file_should_be_streamed() throws IOException { + // Arrange - 2MB fil (över 1MB threshold) + byte[] largeContent = new byte[2 * 1024 * 1024]; + for (int i = 0; i < largeContent.length; i++) { + largeContent[i] = (byte) (i % 256); + } + Files.write(tempDir.resolve("large.bin"), largeContent); + + StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + // Act - Bör inte kasta OutOfMemoryError + assertDoesNotThrow(() -> handler.sendGetRequest(output, "large.bin")); + + // Assert + String response = output.toString(); + assertTrue(response.contains("HTTP/1.1 " + SC_OK + " OK")); + assertTrue(response.contains("Content-Length: " + largeContent.length)); + } + + @Test + void very_large_file_memory_should_stay_constant() throws IOException { + // Arrange - 10MB fil + byte[] veryLargeContent = new byte[10 * 1024 * 1024]; + for (int i = 0; i < veryLargeContent.length; i++) { + veryLargeContent[i] = (byte) (i % 256); + } + Files.write(tempDir.resolve("huge.bin"), veryLargeContent); + + StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); + + // Get memory before + Runtime runtime = Runtime.getRuntime(); + long memBefore = runtime.totalMemory() - runtime.freeMemory(); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + // Act + handler.sendGetRequest(output, "huge.bin"); + + // Get memory after + long memAfter = runtime.totalMemory() - runtime.freeMemory(); + long memIncrease = memAfter - memBefore; + + // Assert - Memory increase should be << file size (due to 8KB buffering) + assertTrue(memIncrease < 50 * 1024 * 1024, // Less than 50MB increase + "Memory increased by " + (memIncrease / 1024 / 1024) + "MB for 10MB file"); } } From 754e7fea7dd4e5d1640c3b42012a585879f10bd7 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 12:25:47 +0100 Subject: [PATCH 27/27] =?UTF-8?q?f=C3=B6rb=C3=A4ttrat=20HttpResponseBuilde?= =?UTF-8?q?r=20med=20st=C3=B6d=20f=C3=B6r=20header-bygge,=20optimerad=20ca?= =?UTF-8?q?che-hantering=20i=20FileCache=20med=20evictions=20och=20samtidi?= =?UTF-8?q?ghet,=20uppdaterad=20StaticFileHandler=20med=20b=C3=A4ttre=20s?= =?UTF-8?q?=C3=A4kerhet,=20logik=20f=C3=B6r=20streaming=20av=20stora=20fil?= =?UTF-8?q?er,=20samt=20f=C3=B6rb=C3=A4ttrade=20tester?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/example/CacheFilter.java | 7 +- src/main/java/org/example/FileCache.java | 70 +++++++++++--- .../java/org/example/StaticFileHandler.java | 95 +++++++++---------- .../org/example/http/HttpResponseBuilder.java | 25 +++++ .../org/example/StaticFileHandlerTest.java | 63 ++++++++---- 5 files changed, 178 insertions(+), 82 deletions(-) diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java index 9c1c8def..deeb569d 100644 --- a/src/main/java/org/example/CacheFilter.java +++ b/src/main/java/org/example/CacheFilter.java @@ -10,9 +10,12 @@ public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { Objects.requireNonNull(uri, "URI kan inte vara null"); Objects.requireNonNull(provider, "Provider kan inte vara null"); - if (cache.contains(uri)) { + // Atomic operation: get() returnerar null om entry inte finns + byte[] cached = cache.get(uri); + + if (cached != null) { logCacheHit(uri); - return cache.get(uri); + return cached; } logCacheMiss(uri); diff --git a/src/main/java/org/example/FileCache.java b/src/main/java/org/example/FileCache.java index 2f087093..8bc6c225 100644 --- a/src/main/java/org/example/FileCache.java +++ b/src/main/java/org/example/FileCache.java @@ -20,26 +20,31 @@ public class FileCache { private long currentSize = 0; public FileCache() { - // LinkedHashMap för att kunna implementera LRU - this.cache = new LinkedHashMap(16, 0.75f, true) { - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - return cache.size() > MAX_ENTRIES || currentSize > MAX_CACHE_SIZE; - } - }; + // LinkedHashMap med access-order LRU + // accessOrder=true betyder: get() och put() uppdaterar ordningen + this.cache = new LinkedHashMap(16, 0.75f, true); } - public boolean contains(String key) { + /** + * Hämta data från cache OCH uppdatera LRU-ordningen. + * MÅSTE ha write-lock eftersom get() modifierar LinkedHashMap's internal state. + */ + public byte[] get(String key) { Objects.requireNonNull(key, "Key kan inte vara null"); - lock.readLock().lock(); + lock.writeLock().lock(); // ← WRITE-LOCK, inte read-lock! try { - return cache.containsKey(key); + CacheEntry entry = cache.get(key); + return entry != null ? entry.data : null; } finally { - lock.readLock().unlock(); + lock.writeLock().unlock(); } } - public byte[] get(String key) { + /** + * Hämta data utan att uppdatera LRU-ordningen (för diagnostik). + * Kan använda read-lock eftersom vi inte modifierar LinkedHashMap's state. + */ + public byte[] peek(String key) { Objects.requireNonNull(key, "Key kan inte vara null"); lock.readLock().lock(); try { @@ -56,12 +61,26 @@ public void put(String key, byte[] value) { lock.writeLock().lock(); try { + // Guard mot filer större än cache + if (value.length > MAX_CACHE_SIZE) { + System.out.println("⚠️ Skipping oversized entry: " + key + + " (" + (value.length / 1024 / 1024) + "MB > " + + (MAX_CACHE_SIZE / 1024 / 1024) + "MB)"); + return; + } + // Ta bort gamla posten om den finns för att uppdatera storlek CacheEntry oldEntry = cache.remove(key); if (oldEntry != null) { currentSize -= oldEntry.data.length; } + // Evicta medan nödvändigt INNAN vi lägger till ny post + while ((currentSize + value.length > MAX_CACHE_SIZE || + cache.size() >= MAX_ENTRIES) && !cache.isEmpty()) { + evictLeastRecentlyUsedUnsafe(); + } + // Lägg till ny post cache.put(key, new CacheEntry(value)); currentSize += value.length; @@ -70,6 +89,24 @@ public void put(String key, byte[] value) { } } + /** + * Evicta minst nyligen använd entry (MÅSTE VARA UNDER WRITE LOCK) + */ + private void evictLeastRecentlyUsedUnsafe() { + // LinkedHashMap är sorterad efter access order (LRU) + // Första entry är den minst nyligen använda + Map.Entry eldest = cache.entrySet().iterator().next(); + + String key = eldest.getKey(); + CacheEntry entry = eldest.getValue(); + + cache.remove(key); + currentSize -= entry.data.length; + + System.out.println("✗ Evicted from cache: " + key + + " (" + (entry.data.length / 1024) + "KB)"); + } + public void clear() { lock.writeLock().lock(); try { @@ -98,6 +135,15 @@ public long getCurrentSizeInBytes() { } } + public boolean contains(String key) { + lock.readLock().lock(); + try { + return cache.containsKey(key); + } finally { + lock.readLock().unlock(); + } + } + private static class CacheEntry { final byte[] data; final long timestamp; diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 2a9a15cf..03e45a4a 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -5,12 +5,12 @@ import java.io.*; import java.nio.file.Files; -import java.nio.file.Path; +import java.util.Objects; public class StaticFileHandler { private static final long FILE_SIZE_THRESHOLD = 1024 * 1024; // 1MB private static final int BUFFER_SIZE = 8192; // 8KB - + private final String WEB_ROOT; public StaticFileHandler() { @@ -22,15 +22,24 @@ public StaticFileHandler(String webRoot) { } public void sendGetRequest(OutputStream outputStream, String uri) throws IOException { - // Sanitize URI + Objects.requireNonNull(outputStream, "outputStream kan inte vara null"); + Objects.requireNonNull(uri, "uri kan inte vara null"); + + // Null-byte detection BEFORE sanitizing + if (uri.contains("\0")) { + sendErrorResponse(outputStream, SC_FORBIDDEN, "403 Forbidden"); + return; + } + + // Sanitize URI (now safe - no null-bytes to remove) uri = sanitizeUri(uri); // Path traversal check File root = new File(WEB_ROOT).getCanonicalFile(); File file = new File(root, uri).getCanonicalFile(); - + if (!file.toPath().startsWith(root.toPath())) { - sendErrorResponse(outputStream, SC_FORBIDDEN, "403 Forbidden", uri); + sendErrorResponse(outputStream, SC_FORBIDDEN, "403 Forbidden"); return; } @@ -38,59 +47,61 @@ public void sendGetRequest(OutputStream outputStream, String uri) throws IOExcep if (file.isFile()) { sendFile(outputStream, file, uri); } else { - File errorFile = new File(WEB_ROOT, "pageNotFound.html"); - if (errorFile.isFile()) { - sendErrorFile(outputStream, errorFile); - } else { - sendErrorResponse(outputStream, SC_NOT_FOUND, "404 Not Found", uri); - } + // Return 404, NOT the error file with 200 OK + sendErrorResponse(outputStream, SC_NOT_FOUND, "404 Not Found"); } } private String sanitizeUri(String uri) { - // Remove query string and fragment int q = uri.indexOf('?'); if (q >= 0) uri = uri.substring(0, q); int h = uri.indexOf('#'); if (h >= 0) uri = uri.substring(0, h); - - // Remove null bytes and leading slashes - uri = uri.replace("\0", "").replaceAll("^/+", ""); - + + uri = uri.replaceAll("^/+", ""); // Removed .replace("\0", "") since we check earlier return uri; } private void sendFile(OutputStream out, File file, String uri) throws IOException { long fileSize = file.length(); - - // Send headers - HttpResponseBuilder response = new HttpResponseBuilder(); - response.setStatusCode(SC_OK); - response.setContentTypeFromFilename(uri); - response.setContentLength(fileSize); - - byte[] headers = response.buildHeaders(); - out.write(headers); - - // Send body: stream large files, cache small files + + // For small files, use cached approach if (fileSize < FILE_SIZE_THRESHOLD) { - streamSmallFile(out, file); + sendFileWithCache(out, file, uri); } else { - streamLargeFile(out, file); + // For large files, stream with headers + sendFileStreamed(out, file, uri, fileSize); } } - private void streamSmallFile(OutputStream out, File file) throws IOException { + private void sendFileWithCache(OutputStream out, File file, String uri) throws IOException { byte[] fileBytes = Files.readAllBytes(file.toPath()); - out.write(fileBytes); + + HttpResponseBuilder response = new HttpResponseBuilder(); + response.setStatusCode(SC_OK); + response.setContentTypeFromFilename(uri); + response.setBody(fileBytes); + + out.write(response.build()); out.flush(); } - private void streamLargeFile(OutputStream out, File file) throws IOException { + private void sendFileStreamed(OutputStream out, File file, String uri, long fileSize) throws IOException { + // Send headers first + HttpResponseBuilder response = new HttpResponseBuilder(); + response.setStatusCode(SC_OK); + response.setContentTypeFromFilename(uri); + response.setHeader("Content-Length", String.valueOf(fileSize)); + response.setHeader("Connection", "close"); + + byte[] headers = response.buildHeaders(); + out.write(headers); + + // Stream body try (InputStream in = Files.newInputStream(file.toPath())) { byte[] buffer = new byte[BUFFER_SIZE]; int bytesRead; - + while ((bytesRead = in.read(buffer)) != -1) { out.write(buffer, 0, bytesRead); } @@ -98,24 +109,12 @@ private void streamLargeFile(OutputStream out, File file) throws IOException { } } - private void sendErrorFile(OutputStream out, File errorFile) throws IOException { - byte[] errorBytes = Files.readAllBytes(errorFile.toPath()); - - HttpResponseBuilder response = new HttpResponseBuilder(); - response.setStatusCode(SC_NOT_FOUND); - response.setContentTypeFromFilename("error.html"); - response.setBody(errorBytes); - - out.write(response.build()); - out.flush(); - } - - private void sendErrorResponse(OutputStream out, int statusCode, String message, String uri) throws IOException { + private void sendErrorResponse(OutputStream out, int statusCode, String message) throws IOException { HttpResponseBuilder response = new HttpResponseBuilder(); response.setStatusCode(statusCode); - response.setContentTypeFromFilename(uri); + response.setContentTypeFromFilename("error.html"); response.setBody(message.getBytes(java.nio.charset.StandardCharsets.UTF_8)); - + out.write(response.build()); out.flush(); } diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java index 9b6ed2a7..0bc0b495 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -93,6 +93,31 @@ public void setContentTypeFromFilename(String filename) { setHeader("Content-Type", mimeType); } + public byte[] buildHeaders() { + StringBuilder headerBuilder = new StringBuilder(); + + String reason = REASON_PHRASES.getOrDefault(statusCode, ""); + headerBuilder.append(PROTOCOL).append(" ").append(statusCode); + if (!reason.isEmpty()) { + headerBuilder.append(" ").append(reason); + } + headerBuilder.append(CRLF); + + headers.forEach((k, v) -> headerBuilder.append(k).append(": ").append(v).append(CRLF)); + + if (!headers.containsKey("Content-Length")) { + headerBuilder.append("Content-Length: ").append(0).append(CRLF); + } + + if (!headers.containsKey("Connection")) { + headerBuilder.append("Connection: close").append(CRLF); + } + + headerBuilder.append(CRLF); + + return headerBuilder.toString().getBytes(StandardCharsets.UTF_8); + } + /* * 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/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java index e4fac378..2fbadcdd 100644 --- a/src/test/java/org/example/StaticFileHandlerTest.java +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -9,6 +9,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; @@ -101,6 +102,7 @@ void null_byte_injection_should_return_403() throws IOException { String response = output.toString(); assertFalse(response.contains("OK")); assertTrue(response.contains("HTTP/1.1 " + SC_FORBIDDEN + " Forbidden")); + } @Test @@ -146,31 +148,52 @@ void large_file_should_be_streamed() throws IOException { } @Test - void very_large_file_memory_should_stay_constant() throws IOException { - // Arrange - 10MB fil - byte[] veryLargeContent = new byte[10 * 1024 * 1024]; - for (int i = 0; i < veryLargeContent.length; i++) { - veryLargeContent[i] = (byte) (i % 256); + void large_file_does_not_load_entire_content_in_memory() throws IOException { + // Arrange - 5MB fil för att testa streaming utan att crasha + byte[] largeContent = new byte[5 * 1024 * 1024]; // 5MB + for (int i = 0; i < largeContent.length; i++) { + largeContent[i] = (byte) (i % 256); } - Files.write(tempDir.resolve("huge.bin"), veryLargeContent); + Files.write(tempDir.resolve("streaming.bin"), largeContent); StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); - // Get memory before - Runtime runtime = Runtime.getRuntime(); - long memBefore = runtime.totalMemory() - runtime.freeMemory(); - - ByteArrayOutputStream output = new ByteArrayOutputStream(); + // Använd NullOutputStream för att ej buffra allt i minnet + NullOutputStream nullOut = new NullOutputStream(); - // Act - handler.sendGetRequest(output, "huge.bin"); - - // Get memory after - long memAfter = runtime.totalMemory() - runtime.freeMemory(); - long memIncrease = memAfter - memBefore; + // Act - Bör inte kastas OutOfMemoryError + assertDoesNotThrow(() -> handler.sendGetRequest(nullOut, "streaming.bin")); + } - // Assert - Memory increase should be << file size (due to 8KB buffering) - assertTrue(memIncrease < 50 * 1024 * 1024, // Less than 50MB increase - "Memory increased by " + (memIncrease / 1024 / 1024) + "MB for 10MB file"); + /** + * Dummy OutputStream som slänger allt - för att testa streaming utan minnesöverbelastning + */ + private static class NullOutputStream extends java.io.OutputStream { + @Override + public void write(int b) throws IOException { + // Discard + } + + @Override + public void write(byte[] b) throws IOException { + // Discard + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + // Discard + } } + + private int findHeaderBodySeparator(byte[] data) { + // Sök efter \r\n\r\n (0x0D 0x0A 0x0D 0x0A) + for (int i = 0; i < data.length - 3; i++) { + if (data[i] == '\r' && data[i + 1] == '\n' && + data[i + 2] == '\r' && data[i + 3] == '\n') { + return i; + } + } + return -1; + } + }