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();
  }
}