Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
98b40a1
Initial commit med all kod (#16)
Kathify Feb 10, 2026
c1b5f70
build: configure pom.xml with needed plugin/tools. (#19)
eeebbaandersson Feb 10, 2026
bf4d977
Initial commit för tcp-server (#17)
Kathify Feb 10, 2026
148411e
Issue #12 (#21)
Xeutos Feb 10, 2026
9ac7b57
Feature/docker image builder issue#11 (#25)
Xeutos Feb 11, 2026
875d1ef
Feature/http parse headers (#18)
FeFFe1996 Feb 11, 2026
9289c7d
Feature/http response builder (#24)
JohanHiths Feb 11, 2026
6bdb1ef
Feature/http parse request line (#20)
FeFFe1996 Feb 11, 2026
781e34a
Add Bucket4j dependency in pom file (#40)
gvaguirres Feb 11, 2026
6a424d5
Added MIME detector class and test class
gitnes94 Feb 12, 2026
bf44e60
Added logic for Mime detector class
gitnes94 Feb 12, 2026
47cecbd
Added Unit tests
gitnes94 Feb 12, 2026
cc93f34
Added logic in HttpResponseBuilder and tests to try it out
gitnes94 Feb 12, 2026
511b5ee
Add support for serving static files (#42)
codebyNorthsteep Feb 12, 2026
aaeba6d
Updates pom.xml, with jackson-dependencies, for config file (#48)
fredrikmohlen Feb 12, 2026
524f33c
* Move HTTP handling to a dedicated ConnectionHandler (#50)
donne41 Feb 12, 2026
73447ee
Solves duplicate header issue
gitnes94 Feb 12, 2026
016b324
Removed a file for another issue
gitnes94 Feb 12, 2026
37fe80a
Changed hashmap to Treemap per code rabbits suggestion
gitnes94 Feb 12, 2026
11cdfea
Corrected logic error that was failing tests as per P2P review
gitnes94 Feb 14, 2026
758f8c4
Added more reason phrases and unit testing, also applied code rabbits…
gitnes94 Feb 14, 2026
0de1e14
Added changes to Responsebuilder to make merging easier
gitnes94 Feb 16, 2026
5a685f3
Changed back to earlier commit to hande byte corruption new PR
gitnes94 Feb 16, 2026
8cc69d8
Feature/13 implement config file (#22)
MartinStenhagen Feb 17, 2026
c0e3de6
Enhancement/404 page not found (#53)
codebyNorthsteep Feb 17, 2026
0c8cd60
Merge branch 'main' into feature/mime-type-detection
gitnes94 Feb 17, 2026
5162d75
Added StaticFileHandler from main
gitnes94 Feb 17, 2026
a401ba6
Merge remote-tracking branch 'origin/feature/mime-type-detection' int…
gitnes94 Feb 17, 2026
0e83b32
Added staticFileHandler with binary-safe writing
gitnes94 Feb 17, 2026
5b51837
Fix: Normalize Content-Type charset to uppercase UTF-8
gitnes94 Feb 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Java CI with Maven

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]

jobs:
build:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Get Java Version
run: |
JAVA_VERSION=$(mvn help:evaluate "-Dexpression=maven.compiler.release" -q -DforceStdout)
echo "JAVA_VERSION=$JAVA_VERSION" >> $GITHUB_ENV

- name: Set up JDK ${{ env.JAVA_VERSION }}
uses: actions/setup-java@v4
with:
java-version: ${{ env.JAVA_VERSION }}
distribution: 'temurin'
cache: maven

- name: Compile with Maven
run: mvn -B compile --file pom.xml

- name: Test with Maven
run: mvn -B test --file pom.xml
35 changes: 35 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Publish Docker Image to Github Packages on Release
on:
release:
types:
- published
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v6.0.2
- uses: docker/setup-qemu-action@v3.7.0
- uses: docker/setup-buildx-action@v3.12.0
- name: Log in to GHCR
uses: docker/login-action@v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5.10.0
with:
images: ghcr.io/ithsjava25/webserver
- name: Build and push
uses: docker/build-push-action@v6.18.0
with:
context: .
push: true
platforms: linux/amd64, linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

11 changes: 11 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
FROM maven:3-eclipse-temurin-25-alpine AS build
WORKDIR /build
COPY src/ src/
COPY pom.xml pom.xml
RUN mvn compile

FROM eclipse-temurin:25-jre-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=build /build/target/classes/ /app/
ENTRYPOINT ["java", "-classpath", "/app", "org.example.App"]
Comment on lines +9 to +10
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Classpath includes only compiled classes — runtime dependencies will be missing.

The COPY only brings /build/target/classes/ into the image. If any non-test dependency (e.g., bucket4j) is used at runtime, it won't be on the classpath and the app will fail with ClassNotFoundException. Consider using maven-dependency-plugin:copy-dependencies or packaging as a fat JAR instead.

Alternative: use maven-jar-plugin with dependency copy
-COPY --from=build /build/target/classes/ /app/
-ENTRYPOINT ["java", "-classpath", "/app", "org.example.App"]
+COPY --from=build /build/target/classes/ /app/classes/
+COPY --from=build /build/target/dependency/ /app/lib/
+ENTRYPOINT ["java", "-classpath", "/app/classes:/app/lib/*", "org.example.App"]

In the build stage, add a step to copy dependencies:

 RUN mvn compile
+RUN mvn dependency:copy-dependencies -DoutputDirectory=target/dependency -DincludeScope=runtime
🤖 Prompt for AI Agents
In `@Dockerfile` around lines 9 - 10, The Dockerfile currently copies only
/build/target/classes/ and sets ENTRYPOINT to run org.example.App, which omits
runtime dependencies and leads to ClassNotFoundException; update the build stage
to produce and include runtime dependencies (e.g., run
maven-dependency-plugin:copy-dependencies or build a fat/uber JAR with
maven-shade-plugin or maven-assembly-plugin) and then modify the final image to
either copy the dependencies directory and add them to the classpath (e.g.,
include /build/target/dependency/* on the classpath) or copy the fat JAR and run
it (adjusting ENTRYPOINT to java -jar <fat-jar-name> or java -classpath
"/app:/app/dependency/*" org.example.App) so that classes and all runtime jars
(like bucket4j) are available at runtime.

USER appuser
67 changes: 66 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,21 @@
<version>1.0-SNAPSHOT</version>

<properties>
<maven.compiler.release>23</maven.compiler.release>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<junit.jupiter.version>6.0.2</junit.jupiter.version>
<assertj.core.version>3.27.7</assertj.core.version>
<mockito.version>5.21.0</mockito.version>
<bucket4j.version>8.14.0</bucket4j.version>

</properties>

<dependencies>
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-core</artifactId>
<version>${bucket4j.version}</version>
</dependency>
Comment on lines +22 to +26
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

rg -i 'bucket4j' --type=java

Repository: ithsjava25/ithsjava25-java-project-webserver-maven-java-template

Length of output: 90


Remove bucket4j dependency or defer to the PR that uses it.

This dependency is not used anywhere in the codebase and appears to be added prematurely. If it's intended for future rate-limiting work, add it in the PR where it's actually implemented to keep the dependency tree lean.

🤖 Prompt for AI Agents
In `@pom.xml` around lines 22 - 26, Remove the unused Bucket4j dependency from the
POM: delete the <dependency> block that references groupId "com.bucket4j" and
artifactId "bucket4j_jdk17-core" (version set via ${bucket4j.version}), or move
it out of this PR and add it only in the PR that implements rate-limiting;
ensure no other POM references the ${bucket4j.version} property remains
orphaned.

<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
Expand All @@ -28,12 +36,36 @@
<version>${assertj.core.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<version>4.3.0</version>
<scope>test</scope>
</dependency>

<dependency>
<groupId>tools.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>3.0.3</version>
</dependency>
<dependency>
<groupId>tools.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
<version>3.0.3</version>
</dependency>

</dependencies>
<build>
<plugins>
Expand Down Expand Up @@ -118,6 +150,39 @@
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.22.0</version>
<dependencies>
<dependency>
<groupId>org.pitest</groupId>
<artifactId>pitest-junit5-plugin</artifactId>
<version>1.2.2</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>3.2.1</version>
<configuration>
<java>
<removeUnusedImports/>
<formatAnnotations/>
</java>
</configuration>
<executions>
<execution>
<phase>verify</phase>
<goals>
<goal>
check
</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
4 changes: 2 additions & 2 deletions src/main/java/org/example/App.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

public class App {
public static void main(String[] args) {
System.out.println("Hello There!");
new TcpServer(8080).start();
}
}
}
42 changes: 42 additions & 0 deletions src/main/java/org/example/ConnectionHandler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package org.example;

import org.example.httpparser.HttpParser;

import java.io.IOException;
import java.net.Socket;

public class ConnectionHandler implements AutoCloseable {

Socket client;
String uri;

public ConnectionHandler(Socket client) {
this.client = client;
}

public void runConnectionHandler() throws IOException {
StaticFileHandler sfh = new StaticFileHandler();
HttpParser parser = new HttpParser();
parser.setReader(client.getInputStream());
parser.parseRequest();
parser.parseHttp();
resolveTargetFile(parser.getUri());
sfh.sendGetRequest(client.getOutputStream(), uri);
}

private void resolveTargetFile(String uri) {
if (uri.matches("/$")) { //matches(/)
this.uri = "index.html";
} else if (uri.matches("^(?!.*\\.html$).*$")) {
this.uri = uri.concat(".html");
} else {
this.uri = uri;
}

}

@Override
public void close() throws Exception {
client.close();
}
}
62 changes: 62 additions & 0 deletions src/main/java/org/example/StaticFileHandler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package org.example;

import org.example.http.HttpResponseBuilder;

import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;

public class StaticFileHandler {
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 {
// 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;
}

if (file.exists()) {
fileBytes = Files.readAllBytes(file.toPath());
statusCode = 200;
} else {
File errorFile = new File(WEB_ROOT, "pageNotFound.html");
if (errorFile.exists()) {
fileBytes = Files.readAllBytes(errorFile.toPath());
} else {
fileBytes = "404 Not Found".getBytes();
}
statusCode = 404;
}
}

public void sendGetRequest(OutputStream outputStream, String uri) throws IOException {
handleGetRequest(uri);

HttpResponseBuilder response = new HttpResponseBuilder();
response.setStatusCode(statusCode);
// Use MimeTypeDetector instead of hardcoded text/html
response.setContentTypeFromFilename(uri);
response.setBody(fileBytes);

outputStream.write(response.build());
outputStream.flush();
}
}
36 changes: 36 additions & 0 deletions src/main/java/org/example/TcpServer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package org.example;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;

public class TcpServer {

private final int port;

public TcpServer(int port) {
this.port = port;
}

public void start() {
System.out.println("Starting TCP server on port " + port);

try (ServerSocket serverSocket = new ServerSocket(port)) {
while (true) {
Socket clientSocket = serverSocket.accept(); // block
System.out.println("Client connected: " + clientSocket.getRemoteSocketAddress());
Thread.ofVirtual().start(() -> handleClient(clientSocket));
}
Comment on lines +19 to +23
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use try-with-resources for clientSocket to prevent resource leaks.

If System.out.println on Line 21 throws (e.g., encoding error with getRemoteSocketAddress()), the socket will leak.

Proposed fix
         while (true) {
-                Socket clientSocket = serverSocket.accept(); // block
-                System.out.println("Client connected: " + clientSocket.getRemoteSocketAddress());
-                clientSocket.close();
+                try (Socket clientSocket = serverSocket.accept()) {
+                    System.out.println("Client connected: " + clientSocket.getRemoteSocketAddress());
+                }
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
while (true) {
Socket clientSocket = serverSocket.accept(); // block
System.out.println("Client connected: " + clientSocket.getRemoteSocketAddress());
clientSocket.close();
}
while (true) {
try (Socket clientSocket = serverSocket.accept()) {
System.out.println("Client connected: " + clientSocket.getRemoteSocketAddress());
}
}
🤖 Prompt for AI Agents
In `@src/main/java/org/example/TcpServer.java` around lines 19 - 23, Wrap the
Socket returned by serverSocket.accept() in a try-with-resources block to ensure
clientSocket is always closed even if printing the remote address throws;
specifically replace the current raw accept() + System.out.println(...) +
clientSocket.close() sequence in TcpServer with a try (Socket clientSocket =
serverSocket.accept()) { System.out.println("Client connected: " +
clientSocket.getRemoteSocketAddress()); } so the clientSocket is auto-closed on
exceptions and normal exit.

} catch (IOException e) {
throw new RuntimeException("Failed to start TCP server", e);
}
}

private void handleClient(Socket client) {
try (ConnectionHandler connectionHandler = new ConnectionHandler(client)) {
connectionHandler.runConnectionHandler();
} catch (Exception e) {
throw new RuntimeException("Error handling client connection " + e);
}
}
}
53 changes: 53 additions & 0 deletions src/main/java/org/example/config/AppConfig.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Loading