Skip to content

Commit 3128ac7

Browse files
authored
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.
1 parent 945d32b commit 3128ac7

6 files changed

Lines changed: 779 additions & 63 deletions

File tree

src/main/java/org/example/StaticFileHandler.java

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,34 +5,40 @@
55
import java.io.File;
66
import java.io.IOException;
77
import java.io.OutputStream;
8-
import java.io.PrintWriter;
98
import java.nio.file.Files;
10-
import java.util.Map;
119

1210
public class StaticFileHandler {
1311
private final String WEB_ROOT;
1412
private byte[] fileBytes;
1513
private int statusCode;
1614

17-
//Constructor for production
15+
// Constructor for production
1816
public StaticFileHandler() {
1917
WEB_ROOT = "www";
2018
}
2119

22-
//Constructor for tests, otherwise the www folder won't be seen
23-
public StaticFileHandler(String webRoot){
20+
// Constructor for tests, otherwise the www folder won't be seen
21+
public StaticFileHandler(String webRoot) {
2422
WEB_ROOT = webRoot;
2523
}
2624

2725
private void handleGetRequest(String uri) throws IOException {
26+
// Security: Prevent path traversal attacks (e.g. GET /../../etc/passwd)
27+
File root = new File(WEB_ROOT).getCanonicalFile();
28+
File file = new File(root, uri).getCanonicalFile();
29+
30+
if (!file.toPath().startsWith(root.toPath())) {
31+
fileBytes = "403 Forbidden".getBytes();
32+
statusCode = 403;
33+
return;
34+
}
2835

29-
File file = new File(WEB_ROOT, uri);
30-
if(file.exists()) {
36+
if (file.exists()) {
3137
fileBytes = Files.readAllBytes(file.toPath());
3238
statusCode = 200;
3339
} else {
3440
File errorFile = new File(WEB_ROOT, "pageNotFound.html");
35-
if(errorFile.exists()) {
41+
if (errorFile.exists()) {
3642
fileBytes = Files.readAllBytes(errorFile.toPath());
3743
} else {
3844
fileBytes = "404 Not Found".getBytes();
@@ -41,16 +47,16 @@ private void handleGetRequest(String uri) throws IOException {
4147
}
4248
}
4349

44-
public void sendGetRequest(OutputStream outputStream, String uri) throws IOException{
50+
public void sendGetRequest(OutputStream outputStream, String uri) throws IOException {
4551
handleGetRequest(uri);
4652

4753
HttpResponseBuilder response = new HttpResponseBuilder();
4854
response.setStatusCode(statusCode);
49-
response.setHeaders(Map.of("Content-Type", "text/html; charset=utf-8"));
55+
// Use MimeTypeDetector instead of hardcoded text/html
56+
response.setContentTypeFromFilename(uri);
5057
response.setBody(fileBytes);
51-
PrintWriter writer = new PrintWriter(outputStream, true);
52-
writer.println(response.build());
5358

59+
outputStream.write(response.build());
60+
outputStream.flush();
5461
}
55-
56-
}
62+
}
Lines changed: 83 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,117 @@
11
package org.example.http;
2-
//
2+
33
import java.nio.charset.StandardCharsets;
4-
import java.util.LinkedHashMap;
54
import java.util.Map;
5+
import java.util.TreeMap;
66

77
public class HttpResponseBuilder {
88

99
private static final String PROTOCOL = "HTTP/1.1";
1010
private int statusCode = 200;
1111
private String body = "";
1212
private byte[] bytebody;
13-
private Map<String, String> headers = new LinkedHashMap<>();
13+
private Map<String, String> headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
1414

1515
private static final String CRLF = "\r\n";
1616

17+
private static final Map<Integer, String> REASON_PHRASES = Map.ofEntries(
18+
Map.entry(200, "OK"),
19+
Map.entry(201, "Created"),
20+
Map.entry(204, "No Content"),
21+
Map.entry(301, "Moved Permanently"),
22+
Map.entry(302, "Found"),
23+
Map.entry(303, "See Other"),
24+
Map.entry(304, "Not Modified"),
25+
Map.entry(307, "Temporary Redirect"),
26+
Map.entry(308, "Permanent Redirect"),
27+
Map.entry(400, "Bad Request"),
28+
Map.entry(401, "Unauthorized"),
29+
Map.entry(403, "Forbidden"),
30+
Map.entry(404, "Not Found"),
31+
Map.entry(500, "Internal Server Error"),
32+
Map.entry(502, "Bad Gateway"),
33+
Map.entry(503, "Service Unavailable")
34+
);
1735

1836
public void setStatusCode(int statusCode) {
1937
this.statusCode = statusCode;
2038
}
2139
public void setBody(String body) {
2240
this.body = body != null ? body : "";
41+
this.bytebody = null; // Clear byte body when setting string body
2342
}
2443

2544
public void setBody(byte[] body) {
2645
this.bytebody = body;
46+
this.body = ""; // Clear string body when setting byte body
2747
}
48+
2849
public void setHeaders(Map<String, String> headers) {
29-
this.headers = new LinkedHashMap<>(headers);
50+
this.headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
51+
this.headers.putAll(headers);
3052
}
3153

32-
private static final Map<Integer, String> REASON_PHRASES = Map.of(
33-
200, "OK",
34-
201, "Created",
35-
400, "Bad Request",
36-
404, "Not Found",
37-
500, "Internal Server Error");
38-
public String build(){
39-
StringBuilder sb = new StringBuilder();
54+
public void setHeader(String name, String value) {
55+
this.headers.put(name, value);
56+
}
57+
58+
public void setContentTypeFromFilename(String filename) {
59+
String mimeType = MimeTypeDetector.detectMimeType(filename);
60+
setHeader("Content-Type", mimeType);
61+
}
62+
63+
/*
64+
* Builds the complete HTTP response as a byte array and preserves binary content without corruption.
65+
* @return Complete HTTP response (headers + body) as byte[]
66+
*/
67+
public byte[] build() {
68+
// Determine content body and length
69+
byte[] contentBody;
4070
int contentLength;
41-
if(body.isEmpty() && bytebody != null){
71+
72+
if (bytebody != null) {
73+
contentBody = bytebody;
4274
contentLength = bytebody.length;
43-
setBody(new String(bytebody, StandardCharsets.UTF_8));
44-
}else{
45-
contentLength = body.getBytes(StandardCharsets.UTF_8).length;
75+
} else {
76+
contentBody = body.getBytes(StandardCharsets.UTF_8);
77+
contentLength = contentBody.length;
4678
}
4779

80+
// Build headers as String
81+
StringBuilder headerBuilder = new StringBuilder();
4882

49-
String reason = REASON_PHRASES.getOrDefault(statusCode, "OK");
50-
sb.append(PROTOCOL).append(" ").append(statusCode).append(" ").append(reason).append(CRLF);
51-
headers.forEach((k,v) -> sb.append(k).append(": ").append(v).append(CRLF));
52-
sb.append("Content-Length: ")
53-
.append(contentLength);
54-
sb.append(CRLF);
55-
sb.append("Connection: close").append(CRLF);
56-
sb.append(CRLF);
57-
sb.append(body);
58-
return sb.toString();
83+
// Status line
84+
String reason = REASON_PHRASES.getOrDefault(statusCode, "");
85+
headerBuilder.append(PROTOCOL).append(" ").append(statusCode);
86+
if (!reason.isEmpty()) {
87+
headerBuilder.append(" ").append(reason);
88+
}
89+
headerBuilder.append(CRLF);
5990

60-
}
91+
// User-defined headers
92+
headers.forEach((k, v) -> headerBuilder.append(k).append(": ").append(v).append(CRLF));
6193

62-
}
94+
// Auto-append Content-Length if not set.
95+
if (!headers.containsKey("Content-Length")) {
96+
headerBuilder.append("Content-Length: ").append(contentLength).append(CRLF);
97+
}
98+
99+
// Auto-append Connection if not set.
100+
if (!headers.containsKey("Connection")) {
101+
headerBuilder.append("Connection: close").append(CRLF);
102+
}
103+
104+
// Blank line before body
105+
headerBuilder.append(CRLF);
106+
107+
// Convert headers to bytes
108+
byte[] headerBytes = headerBuilder.toString().getBytes(StandardCharsets.UTF_8);
109+
110+
// Combine headers + body into single byte array
111+
byte[] response = new byte[headerBytes.length + contentBody.length];
112+
System.arraycopy(headerBytes, 0, response, 0, headerBytes.length);
113+
System.arraycopy(contentBody, 0, response, headerBytes.length, contentBody.length);
114+
115+
return response;
116+
}
117+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package org.example.http;
2+
3+
import java.util.Map;
4+
5+
/**
6+
* Detects MIME types based on file extensions.
7+
* Used to set the Content-Type header when serving static files.
8+
*/
9+
public final class MimeTypeDetector {
10+
11+
// Private constructor - utility class
12+
private MimeTypeDetector() {
13+
throw new AssertionError("Utility class - do not instantiate");
14+
}
15+
16+
private static final Map<String, String> MIME_TYPES = Map.ofEntries(
17+
// HTML & Text
18+
Map.entry(".html", "text/html; charset=UTF-8"),
19+
Map.entry(".htm", "text/html; charset=UTF-8"),
20+
Map.entry(".css", "text/css; charset=UTF-8"),
21+
Map.entry(".js", "application/javascript; charset=UTF-8"),
22+
Map.entry(".json", "application/json; charset=UTF-8"),
23+
Map.entry(".xml", "application/xml; charset=UTF-8"),
24+
Map.entry(".txt", "text/plain; charset=UTF-8"),
25+
26+
// Images
27+
Map.entry(".png", "image/png"),
28+
Map.entry(".jpg", "image/jpeg"),
29+
Map.entry(".jpeg", "image/jpeg"),
30+
Map.entry(".gif", "image/gif"),
31+
Map.entry(".svg", "image/svg+xml"),
32+
Map.entry(".ico", "image/x-icon"),
33+
Map.entry(".webp", "image/webp"),
34+
35+
// Documents
36+
Map.entry(".pdf", "application/pdf"),
37+
38+
// Video & Audio
39+
Map.entry(".mp4", "video/mp4"),
40+
Map.entry(".webm", "video/webm"),
41+
Map.entry(".mp3", "audio/mpeg"),
42+
Map.entry(".wav", "audio/wav"),
43+
44+
// Fonts
45+
Map.entry(".woff", "font/woff"),
46+
Map.entry(".woff2", "font/woff2"),
47+
Map.entry(".ttf", "font/ttf"),
48+
Map.entry(".otf", "font/otf")
49+
);
50+
51+
/**
52+
* Detects the MIME type of file based on its extension.
53+
*
54+
* @param filename the name of the file (t.ex., "index.html", "style.css")
55+
* @return the MIME type string (t.ex, "text/html; charset=UTF-8")
56+
*/
57+
58+
59+
public static String detectMimeType(String filename) {
60+
61+
String octet = "application/octet-stream";
62+
63+
if (filename == null || filename.isEmpty()) {
64+
return octet;
65+
}
66+
67+
// Find the last dot to get extension
68+
int lastDot = filename.lastIndexOf('.');
69+
if (lastDot == -1 || lastDot == filename.length() - 1) {
70+
// No extension or dot at end
71+
return octet;
72+
}
73+
74+
String extension = filename.substring(lastDot).toLowerCase();
75+
return MIME_TYPES.getOrDefault(extension, octet);
76+
}
77+
}

src/test/java/org/example/StaticFileHandlerTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ void test_file_that_exists_should_return_200() throws IOException {
4949
assertTrue(response.contains("HTTP/1.1 200 OK")); // Assert the status
5050
assertTrue(response.contains("Hello Test")); //Assert the content in the file
5151

52-
assertTrue(response.contains("Content-Type: text/html; charset=utf-8")); // Verify the correct Content-type header
52+
assertTrue(response.contains("Content-Type: text/html; charset=UTF-8")); // Verify the correct Content-type header
5353

5454
}
5555

0 commit comments

Comments
 (0)