WebServer.java
/*
Copyright (C) 2025 Steve Flasby
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.flasby.thymeleaf;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.Socket;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NonNull;
import lombok.extern.log4j.Log4j2;
import org.thymeleaf.context.Context;
@Log4j2
public class WebServer extends SimpleServer {
@Data
@EqualsAndHashCode
public static class Request {
private final String method;
private final URI uri;
private final String protocol;
private final Map<String, String> headers;
public Request(String method, URI uri, String protocol, Map<String, String> headers) {
this.method = method;
this.uri = uri;
this.protocol = protocol;
this.headers = Collections.unmodifiableMap(headers);
}
// public Map<String, String> headers() {
// return Collections.unmodifiableMap(headers);
// }
}
;
private final App app;
private final Context context;
public WebServer(App app, Context context, int port) throws IOException {
super(port);
this.app = app;
// Sigh, no copy constructor so do it the hard way.
Context c = new Context(context.getLocale());
for (String variable : context.getVariableNames()) {
LOG.debug(
"Copying variable {}:{} to the WebServer context",
variable,
context.getVariable(variable));
c.setVariable(variable, context.getVariable(variable));
}
this.context = c;
}
@Override
protected Instance create(final Socket s) {
return new Instance(s) {
@Override
public void start() throws StartFailedException {
try (BufferedReader r =
new BufferedReader(new InputStreamReader(s.getInputStream(), StandardCharsets.UTF_8))) {
Request req = parseRequest(r);
OutputStream os = s.getOutputStream();
sendResponse(req, os);
os.flush();
} catch (IOException e) {
throw new StartFailedException(e);
}
}
};
}
public void sendResponse(@NonNull Request req, OutputStream os) throws IOException {
LOG.debug("Request {} ", req);
String mimeType = MimeType.getMimeTypeFor(req.uri);
String file = getFileName(req.uri);
if (file.startsWith("/static/")) {
LOG.debug("Non-Thymeleaf thing ({}), just return it", file);
byte[] content = this.getClass().getResourceAsStream(file).readAllBytes();
byte[] header =
("HTTP/1.1 200 OK\nContent-Type: "
+ mimeType
+ "\nCache-Control: no-store, must-revalidate\n"
+ "Content-Length: "
+ content.length
+ "\n"
+ "accept-ranges: bytes\n"
+ "\n")
.getBytes(StandardCharsets.UTF_8);
os.write(header);
os.write(content);
return;
} else {
String body = app.process(file, context);
String header =
"HTTP/1.1 200 OK\nContent-Type: "
+ mimeType
+ "\nCache-Control: no-store, must-revalidate\n"
+ "Content-Length: "
+ body.toCharArray().length
+ "\n"
+ "accept-ranges: bytes\n"
+ "\n";
os.write((header + body).getBytes(StandardCharsets.UTF_8));
}
}
public static Request parseRequest(BufferedReader r) throws IOException {
String line = r.readLine();
if (line == null) {
throw new IOException("No data to parse in this request, are you cheating?");
}
String[] parts = line.split(" ");
URI uri = URI.create(parts[1]);
return new Request(parts[0], uri, parts[2], parseHeaders(r));
}
/**
* parse the incoming headers and put them in a suitable map. Note that this is an incomplete
* implementation as the same header can e present multiple times on a request.
*/
public static Map<String, String> parseHeaders(BufferedReader r) throws IOException {
Map<String, String> headers = new HashMap<>();
String line = null;
while (null != (line = r.readLine())) {
if (line.length() == 0) {
LOG.debug(
"Empty line - should be the end of the headers. I got {} headers in total.",
headers.size());
break;
} else {
LOG.debug("Header: {}", line);
String[] h = line.split(": ");
if (h.length > 1) {
headers.put(h[0], h[1]);
} else {
LOG.warn("Unexpected header: {}", Arrays.toString(h));
}
}
}
return headers;
}
public static String getFileName(URI request) {
LOG.info("Serving {}", request);
if (request.getPath().equals("/")) {
LOG.debug("Serving root");
return "index.html";
}
return request.getPath();
}
}