WebFramework.java

package com.renomad.minum.web;

import com.renomad.minum.logging.ILogger;
import com.renomad.minum.security.ForbiddenUseException;
import com.renomad.minum.security.ITheBrig;
import com.renomad.minum.security.UnderInvestigation;
import com.renomad.minum.state.Constants;
import com.renomad.minum.state.Context;
import com.renomad.minum.utils.*;

import java.io.IOException;
import java.net.SocketException;
import java.net.SocketTimeoutException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;

import static com.renomad.minum.utils.FileUtils.badFilePathPatterns;
import static com.renomad.minum.utils.Invariants.mustBeTrue;
import static com.renomad.minum.web.StatusLine.StatusCode.*;
import static com.renomad.minum.web.WebEngine.HTTP_CRLF;

/**
 * This class is responsible for the HTTP handling after socket connection.
 * <p>
 *     The public methods are for registering endpoints - code that will be
 *     run for a given combination of HTTP method and path.  See documentation
 *     for the methods in this class.
 * </p>
 */
public final class WebFramework {

    private final Constants constants;
    private final UnderInvestigation underInvestigation;
    private final IInputStreamUtils inputStreamUtils;
    private final IBodyProcessor bodyProcessor;
    /**
     * This is a variable storing a pseudo-random (non-secure) number
     * that is shown to users when a serious error occurs, which
     * will also be put in the logs, to make finding it easier.
     */
    private final Random randomErrorCorrelationId;
    private final RequestLine emptyRequestLine;

    public Map<String,String> getSuffixToMimeMappings() {
        return new HashMap<>(fileSuffixToMime);
    }

    /**
     * This is used as a key when registering endpoints
     */
    record MethodPath(RequestLine.Method method, String path) { }

    /**
     * The list of paths that our system is registered to handle.
     */
    private final Map<MethodPath, ThrowingFunction<IRequest, IResponse>> registeredDynamicPaths;

    /**
     * These are registrations for paths that partially match, for example,
     * if the client sends us GET /.well-known/acme-challenge/HGr8U1IeTW4kY_Z6UIyaakzOkyQgPr_7ArlLgtZE8SX
     * and we want to match ".well-known/acme-challenge"
     */
    private final Map<MethodPath, ThrowingFunction<IRequest, IResponse>> registeredPartialPaths;

    /**
     * A function that will be run instead of the ordinary business code. Has
     * provisions for running the business code as well.  See {@link #registerPreHandler(ThrowingFunction)}
     */
    private ThrowingFunction<PreHandlerInputs, IResponse> preHandler;

    /**
     * A function run after the ordinary business code
     */
    private ThrowingFunction<LastMinuteHandlerInputs, IResponse> lastMinuteHandler;

    private final IFileReader fileReader;
    private final Map<String, String> fileSuffixToMime;

    // This is just used for testing.  If it's null, we use the real time.
    private final ZonedDateTime overrideForDateTime;
    private final FullSystem fs;
    private final ILogger logger;

    /**
     * This is the minimum number of bytes in a text response to apply gzip.
     */
    private static final int MINIMUM_NUMBER_OF_BYTES_TO_COMPRESS = 2048;

    /**
     * This is the brains of how the server responds to web clients.  Whatever
     * code lives here will be inserted into a slot within the server code.
     */
    ThrowingRunnable makePrimaryHttpHandler(ISocketWrapper sw, ITheBrig theBrig) {

        return () -> {
            Thread.currentThread().setName("SocketWrapper thread for " + sw.getRemoteAddr());
            try (sw) {
                dumpIfAttacker(sw, fs);
                final var is = sw.getInputStream();

                // By default, browsers expect the server to run in keep-alive mode.
                // We'll break out later if we find that the browser doesn't do keep-alive
                while (true) {
                    final String rawStartLine = inputStreamUtils.readLine(is);
                    long startMillis = System.currentTimeMillis();
                    if (rawStartLine.isEmpty()) {
                        // here, the client connected, sent nothing, and closed.
                        // nothing to do but return.
                        logger.logTrace(() -> "rawStartLine was empty.  Returning.");
                        break;
                    }
                    final RequestLine sl = getProcessedRequestLine(sw, rawStartLine);

                    if (sl.equals(emptyRequestLine)) {
                        // here, the client sent something we cannot parse.
                        // nothing to do but return.
                        logger.logTrace(() -> "RequestLine was unparseable.  Returning.");
                        break;
                    }
                    // check if the user is seeming to attack us.
                    checkIfSuspiciousPath(sw, sl);

                    // React to what the user requested, generate a result
                    Headers hi = getHeaders(sw);
                    boolean isKeepAlive = determineIfKeepAlive(sl, hi, logger);
                    if (isThereIsABody(hi)) {
                        logger.logTrace(() -> "There is a body. Content-type is " + hi.contentType());
                    }
                    ProcessingResult result = processRequest(sw, sl, hi);
                    IRequest request = result.clientRequest();
                    Response response = (Response)result.resultingResponse();

                    // calculate proper headers for the response
                    StringBuilder headerStringBuilder = addDefaultHeaders(response);
                    addOptionalExtraHeaders(response, headerStringBuilder);
                    addKeepAliveTimeout(isKeepAlive, headerStringBuilder);

                    // inspect the response being sent, see whether we can compress the data.
                    Response adjustedResponse = potentiallyCompress(request.getHeaders(), response, headerStringBuilder);
                    applyContentLength(headerStringBuilder, adjustedResponse.getBodyLength());
                    confirmBodyHasContentType(request, response);

                    // send the headers
                    sw.send(headerStringBuilder.append(HTTP_CRLF).toString());

                    // if the user sent a HEAD request, we send everything back except the body.
                    // even though we skip the body, this requires full processing to get the
                    // numbers right, like content-length.
                    if (request.getRequestLine().getMethod().equals(RequestLine.Method.HEAD)) {
                        logger.logDebug(() -> "client " + request.getRemoteRequester() +
                                " is requesting HEAD for "+ request.getRequestLine().getPathDetails().getIsolatedPath() +
                                ".  Excluding body from response");
                    } else {
                        // send the body
                        adjustedResponse.sendBody(sw);
                    }
                    // print how long this processing took
                    long endMillis = System.currentTimeMillis();
                    logger.logTrace(() -> String.format("full processing (including communication time) of %s %s took %d millis", sw, sl, endMillis - startMillis));
                    if (!isKeepAlive) break;
                }
            } catch (SocketException | SocketTimeoutException ex) {
                handleReadTimedOut(sw, ex, logger);
            } catch (ForbiddenUseException ex) {
                handleForbiddenUse(sw, ex, logger, theBrig, constants.vulnSeekingJailDuration);
            } catch (IOException ex) {
                handleIOException(sw, ex, logger, theBrig, underInvestigation, constants.vulnSeekingJailDuration);
            }
        };
    }


    static void handleIOException(ISocketWrapper sw, IOException ex, ILogger logger, ITheBrig theBrig, UnderInvestigation underInvestigation, int vulnSeekingJailDuration ) {
        logger.logDebug(() -> ex.getMessage() + " (at Server.start)");
        String suspiciousClues = underInvestigation.isClientLookingForVulnerabilities(ex.getMessage());

        if (!suspiciousClues.isEmpty() && theBrig != null) {
            logger.logDebug(() -> sw.getRemoteAddr() + " is looking for vulnerabilities, for this: " + suspiciousClues);
            theBrig.sendToJail(sw.getRemoteAddr() + "_vuln_seeking", vulnSeekingJailDuration);
        }
    }

    static void handleForbiddenUse(ISocketWrapper sw, ForbiddenUseException ex, ILogger logger, ITheBrig theBrig, int vulnSeekingJailDuration) {
        logger.logDebug(() -> sw.getRemoteAddr() + " is looking for vulnerabilities, for this: " + ex.getMessage());
        if (theBrig != null) {
            theBrig.sendToJail(sw.getRemoteAddr() + "_vuln_seeking", vulnSeekingJailDuration);
        } else {
            logger.logDebug(() -> "theBrig is null at handleForbiddenUse, will not store address in database");
        }
    }

    static void handleReadTimedOut(ISocketWrapper sw, IOException ex, ILogger logger) {
        /*
        if we close the application on the server side, there's a good
        likelihood a SocketException will come bubbling through here.
        NOTE:
          it seems that Socket closed is what we get when the client closes the connection in non-SSL, and conversely,
          if we are operating in secure (i.e. SSL/TLS) mode, we get "an established connection..."
        */
        if (ex.getMessage().equals("Read timed out")) {
            logger.logTrace(() -> "Read timed out - remote address: " + sw.getRemoteAddrWithPort());
        } else {
            logger.logDebug(() -> ex.getMessage() + " - remote address: " + sw.getRemoteAddrWithPort());
        }
    }

    /**
     * Logic for how to process an incoming request.  For example, did the developer
     * write a function to handle this? Is it a request for a static file, like an image
     * or script?  Did the user provide a "pre" or "post" handler?
     */
    ProcessingResult processRequest(
            ISocketWrapper sw,
            RequestLine requestLine,
            Headers requestHeaders) throws Exception {
        IRequest clientRequest = new Request(requestHeaders, requestLine, sw.getRemoteAddr(), sw, bodyProcessor);
        IResponse response;
        ThrowingFunction<IRequest, IResponse> endpoint = findEndpointForThisStartline(requestLine, requestHeaders);
        if (endpoint == null) {
            response = Response.buildLeanResponse(CODE_404_NOT_FOUND);
        } else {
            long millisAtStart = System.currentTimeMillis();
            try {
                if (preHandler != null) {
                    response = preHandler.apply(new PreHandlerInputs(clientRequest, endpoint, sw));
                } else {
                    response = endpoint.apply(clientRequest);
                }
            } catch (Exception ex) {
                // if an error happens while running an endpoint's code, this is the
                // last-chance handling of that error where we return a 500 and a
                // random code to the client, so a developer can find the detailed
                // information in the logs, which have that same value.
                int randomNumber = randomErrorCorrelationId.nextInt();
                logger.logAsyncError(() -> "error while running endpoint " + endpoint + ". Code: " + randomNumber + ". Error: " + StacktraceUtils.stackTraceToString(ex));
                response = Response.buildResponse(CODE_500_INTERNAL_SERVER_ERROR, Map.of("Content-Type", "text/plain;charset=UTF-8"), "Server error: " + randomNumber);
            }
            long millisAtEnd = System.currentTimeMillis();
            logger.logTrace(() -> String.format("handler processing of %s %s took %d millis", sw, requestLine, millisAtEnd - millisAtStart));
        }

        // if the user has chosen to customize the response based on status code, that will
        // be applied now, and it will override the previous response.
        if (lastMinuteHandler != null) {
            response = lastMinuteHandler.apply(new LastMinuteHandlerInputs(clientRequest, response));
        }

        return new ProcessingResult(clientRequest, response);
    }

    record ProcessingResult(IRequest clientRequest, IResponse resultingResponse) { }

    private Headers getHeaders(ISocketWrapper sw) {
    /*
       next we will read the headers (e.g. Content-Type: foo/bar) one-by-one.

       the headers tell us vital information about the
       body.  If, for example, we're getting a POST and receiving a
       www form url encoded, there will be a header of "content-length"
       that will mention how many bytes to read.  On the other hand, if
       we're receiving a multipart, there will be no content-length, but
       the content-type will include the boundary string.
    */
        List<String> allHeaders = Headers.getAllHeaders(sw.getInputStream(), inputStreamUtils);
        Headers hi = new Headers(allHeaders);
        logger.logTrace(() -> "The headers are: " + hi.getHeaderStrings());
        return hi;
    }

    /**
     * determine if we are in a keep-alive connection
     */
    static boolean determineIfKeepAlive(RequestLine sl, Headers hi, ILogger logger) {
        boolean isKeepAlive = false;
        if (sl.getVersion() == HttpVersion.ONE_DOT_ZERO) {
            isKeepAlive = hi.hasKeepAlive();
        } else if (sl.getVersion() == HttpVersion.ONE_DOT_ONE) {
            isKeepAlive = ! hi.hasConnectionClose();
        }
        boolean finalIsKeepAlive = isKeepAlive;
        logger.logTrace(() -> "Is this a keep-alive connection? " + finalIsKeepAlive);
        return isKeepAlive;
    }

    RequestLine getProcessedRequestLine(ISocketWrapper sw, String rawStartLine) {
        logger.logTrace(() -> sw + ": raw request line received: " + rawStartLine);
        RequestLine rl = new RequestLine(
                RequestLine.Method.NONE,
                PathDetails.empty,
                HttpVersion.NONE,
                "", logger);
        RequestLine extractedRequestLine = rl.extractRequestLine(rawStartLine);
        logger.logTrace(() -> sw + ": RequestLine has been derived: " + extractedRequestLine);
        return extractedRequestLine;
    }

    void checkIfSuspiciousPath(ISocketWrapper sw, RequestLine requestLine) {
        String suspiciousClues = underInvestigation.isLookingForSuspiciousPaths(
                requestLine.getPathDetails().getIsolatedPath());
        if (!suspiciousClues.isEmpty()) {
            String msg = sw.getRemoteAddr() + " is looking for a vulnerability, for this: " + suspiciousClues;
            throw new ForbiddenUseException(msg);
        }
    }

    /**
     * This code confirms our objects are valid before calling
     * to {@link #dumpIfAttacker(ISocketWrapper, ITheBrig)}.
     * @return true if successfully called to subsequent method, false otherwise.
     */
    boolean dumpIfAttacker(ISocketWrapper sw, FullSystem fs) {
        if (fs == null) {
            return false;
        } else if (fs.getTheBrig() == null) {
            return false;
        } else {
            dumpIfAttacker(sw, fs.getTheBrig());
            return true;
        }
    }

    void dumpIfAttacker(ISocketWrapper sw, ITheBrig theBrig) {
        String remoteClient = sw.getRemoteAddr();
        if (theBrig.isInJail(remoteClient + "_vuln_seeking")) {
            // if this client is a vulnerability seeker, throw an exception,
            // causing them to get dumped unceremoniously
            String message = "closing the socket on " + remoteClient + " due to being found in the brig";
            logger.logDebug(() -> message);
            throw new ForbiddenUseException(message);
        }
    }

    /**
     * Determine whether the headers in this HTTP message indicate that
     * a body is available to read
     */
    static boolean isThereIsABody(Headers hi) {
        // if the client sent us a content-type header at all...
        if (!hi.contentType().isBlank()) {
            // if the content-length is greater than 0, we've got a body
            if (hi.contentLength() > 0) return true;

            // if the transfer-encoding header is set to chunked, we have a body
            List<String> transferEncodingHeaders = hi.valueByKey("transfer-encoding");
            return transferEncodingHeaders != null && transferEncodingHeaders.stream().anyMatch(x -> x.equalsIgnoreCase("chunked"));
        }
        // otherwise, no body we recognize
        return false;
    }

    /**
     * Prepare some of the basic server response headers, like the status code, the
     * date-time stamp, the server name.
     */
    private StringBuilder addDefaultHeaders(IResponse response) {

        String date = Objects.requireNonNullElseGet(overrideForDateTime, () -> ZonedDateTime.now(ZoneId.of("UTC"))).format(DateTimeFormatter.RFC_1123_DATE_TIME);

        // we'll store the status line and headers in this
        StringBuilder headerStringBuilder = new StringBuilder();


        // add the status line
        headerStringBuilder.append("HTTP/1.1 ").append(response.getStatusCode().code).append(" ").append(response.getStatusCode().shortDescription).append(HTTP_CRLF);

        // add a date-timestamp
        headerStringBuilder.append("Date: ").append(date).append(HTTP_CRLF);

        // add the server name
        headerStringBuilder.append("Server: minum").append(HTTP_CRLF);

        return headerStringBuilder;
    }

    /**
     * Add extra headers specified by the business logic (set by the developer)
     */
    private static void addOptionalExtraHeaders(IResponse response, StringBuilder stringBuilder) {
        stringBuilder.append(
                response.getExtraHeaders().entrySet().stream()
                .map(x -> x.getKey() + ": " + x.getValue() + HTTP_CRLF)
                .collect(Collectors.joining()));
    }

    /**
     * If a response body exists, it needs to have a content-type specified, or throw an exception.
     */
    static void confirmBodyHasContentType(IRequest request, Response response) {
        // check the correctness of the content-type header versus the data length (if any data, that is)
        boolean hasContentType = response.getExtraHeaders().entrySet().stream().anyMatch(x -> x.getKey().toLowerCase(Locale.ROOT).equals("content-type"));

        // if there *is* data, we had better be returning a content type
        if (response.getBodyLength() > 0) {
            mustBeTrue(hasContentType, "a Content-Type header must be specified in the Response object if it returns data. Response details: " + response + " Request: " + request);
        }
    }

    /**
     * If this is a keep-alive communication, add a header specifying the
     * socket timeout for the browser.
     */
    private void addKeepAliveTimeout(boolean isKeepAlive, StringBuilder stringBuilder) {
        // if we're a keep-alive connection, reply with a keep-alive header
        if (isKeepAlive) {
            stringBuilder.append("Keep-Alive: timeout=").append(constants.keepAliveTimeoutSeconds).append(HTTP_CRLF);
        }
    }

    /**
     * The rules regarding the content-length header are byzantine.  Even in the cases
     * where you aren't returning anything, servers can use this header to determine when the
     * response is finished.
     * See <a href="https://www.rfc-editor.org/rfc/rfc9110.html#name-content-length">Content-Length in the HTTP spec</a>
     */
    private static void applyContentLength(StringBuilder stringBuilder, long bodyLength) {
        stringBuilder.append("Content-Length: ").append(bodyLength).append(HTTP_CRLF);
    }

    /**
     * This method will examine the request headers and response content-type, and
     * compress the outgoing data if necessary.
     */
    static Response potentiallyCompress(Headers headers, Response response, StringBuilder headerStringBuilder) throws IOException {
        // we may make modifications to the response body at this point, specifically
        // we may compress the data, if the client requested it.
        // see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-encoding
        List<String> acceptEncoding = headers.valueByKey("accept-encoding");

        // regardless of whether the client requests compression in their Accept-Encoding header,
        // if the data we're sending back is not of an appropriate type, we won't bother
        // compressing it.  Basically, we're going to compress plain text.
        Map.Entry<String, String> contentTypeHeader = SearchUtils.findExactlyOne(response.getExtraHeaders().entrySet().stream(), x -> x.getKey().equalsIgnoreCase("content-type"));

        if (contentTypeHeader != null) {
            String contentType = contentTypeHeader.getValue().toLowerCase(Locale.ROOT);
            if (contentType.contains("text/")) {
                return compressBodyIfRequested(response, acceptEncoding, headerStringBuilder, MINIMUM_NUMBER_OF_BYTES_TO_COMPRESS);
            }
        }
        return response;
    }

    /**
     * This method will examine the content-encoding headers, and if "gzip" is
     * requested by the client, we will replace the body bytes with compressed
     * bytes, using the GZIP compression algorithm, as long as the response body
     * is greater than minNumberBytes bytes.
     *
     * @param acceptEncoding headers sent by the client about what compression
     *                       algorithms will be understood.
     * @param stringBuilder  the string we are gradually building up to send back to
     *                       the client for the status line and headers. We'll use it
     *                       here if we need to append a content-encoding - that is,
     *                       if we successfully compress data as gzip.
     * @param minNumberBytes number of bytes must be larger than this to compress.
     */
    static Response compressBodyIfRequested(Response response, List<String> acceptEncoding, StringBuilder stringBuilder, int minNumberBytes) throws IOException {
        String allContentEncodingHeaders = acceptEncoding != null ? String.join(";", acceptEncoding) : "";
        if (response.getBodyLength() >= minNumberBytes && acceptEncoding != null && allContentEncodingHeaders.contains("gzip")) {
            stringBuilder.append("Content-Encoding: gzip" + HTTP_CRLF);
            stringBuilder.append("Vary: accept-encoding" + HTTP_CRLF);
            return response.compressBody();
        }
        return response;
    }

    /**
     * Looks through the mappings of {@link MethodPath} and path to registered endpoints
     * or the static cache and returns the appropriate one (If we
     * do not find anything, return null)
     */
    ThrowingFunction<IRequest, IResponse> findEndpointForThisStartline(RequestLine sl, Headers requestHeaders) {
        ThrowingFunction<IRequest, IResponse> handler;
        logger.logTrace(() -> "Seeking a handler for " + sl);

        // first we check if there's a simple direct match
        String requestedPath = sl.getPathDetails().getIsolatedPath().toLowerCase(Locale.ROOT);

        // if the user is asking for a HEAD request, they want to run a GET command
        // but don't want the body.  We'll simply exclude sending the body, later on, when returning the data
        RequestLine.Method method = sl.getMethod() == RequestLine.Method.HEAD ? RequestLine.Method.GET : sl.getMethod();

        MethodPath key = new MethodPath(method, requestedPath);
        handler = registeredDynamicPaths.get(key);

        if (handler == null) {
            logger.logTrace(() -> "No direct handler found.  looking for a partial match for " + requestedPath);
            handler = findHandlerByPartialMatch(sl);
        }

        if (handler == null) {
            logger.logTrace(() -> "No partial match found, checking files on disk for " + requestedPath );
            handler = findHandlerByFilesOnDisk(sl, requestHeaders);
        }

        // we'll return this, and it could be a null.
        return handler;
    }

    /**
     * last ditch effort - look on disk.  This response will either
     * be the file to return, or null if we didn't find anything.
     */
    private ThrowingFunction<IRequest, IResponse> findHandlerByFilesOnDisk(RequestLine sl, Headers requestHeaders) {
        if (sl.getMethod() != RequestLine.Method.GET && sl.getMethod() != RequestLine.Method.HEAD) {
            return null;
        }
        String requestedPath = sl.getPathDetails().getIsolatedPath();
        IResponse response = readStaticFile(requestedPath, requestHeaders);
        return request -> response;
    }


    /**
     * Get a file from a path and create a response for it with a mime type.
     * <p>
     *     Parent directories are made unavailable by searching the path for
     *     bad characters.  See {@link FileUtils#badFilePathPatterns}
     * </p>
     *
     * @return a response with the file contents and caching headers and mime if valid.
     *  if the path has invalid characters, we'll return a "bad request" response.
     */
    IResponse readStaticFile(String path, Headers requestHeaders) {
        if (badFilePathPatterns.matcher(path).find()) {
            logger.logDebug(() -> String.format("Bad path requested at readStaticFile: %s", path));
            return Response.buildLeanResponse(CODE_400_BAD_REQUEST);
        }
        String mimeType = null;

        try {
            Path staticFilePath = Path.of(constants.staticFilesDirectory).resolve(path);
            if (!Files.isRegularFile(staticFilePath)) {
                logger.logDebug(() -> String.format("No readable file found at %s", path));
                return Response.buildLeanResponse(CODE_404_NOT_FOUND);
            }

            // if the provided path has a dot in it, use that
            // to obtain a suffix for determining file type
            int suffixBeginIndex = path.lastIndexOf('.');
            if (suffixBeginIndex > 0) {
                String suffix = path.substring(suffixBeginIndex+1);
                mimeType = fileSuffixToMime.get(suffix);
            }

            // if we don't find any registered mime types for this
            // suffix, or if it doesn't have a suffix, set the mime type
            // to application/octet-stream
            if (mimeType == null) {
                mimeType = "application/octet-stream";
            }

            if (Files.size(staticFilePath) < 100_000) {
                var fileContents = fileReader.readFile(staticFilePath.toString());
                return createOkResponseForStaticFiles(fileContents, mimeType);
            } else {
                return createOkResponseForLargeStaticFiles(mimeType, staticFilePath, requestHeaders);
            }

        } catch (IOException e) {
            logger.logAsyncError(() -> String.format("Error while reading file: %s. %s", path, StacktraceUtils.stackTraceToString(e)));
            return Response.buildLeanResponse(CODE_400_BAD_REQUEST);
        }
    }

    /**
     * All static responses will get a cache time of STATIC_FILE_CACHE_TIME seconds
     */
    private IResponse createOkResponseForStaticFiles(byte[] fileContents, String mimeType) {
        var headers = Map.of(
                "cache-control", "max-age=" + constants.staticFileCacheTime,
                "content-type", mimeType);

        return Response.buildResponse(
                CODE_200_OK,
                headers,
                fileContents);
    }

    /**
     * All static responses will get a cache time of STATIC_FILE_CACHE_TIME seconds
     */
    private IResponse createOkResponseForLargeStaticFiles(String mimeType, Path filePath, Headers requestHeaders) throws IOException {
        var headers = Map.of(
                "cache-control", "max-age=" + constants.staticFileCacheTime,
                "content-type", mimeType,
                "Accept-Ranges", "bytes"
                );

        return Response.buildLargeFileResponse(
                headers,
                filePath.toString(),
                requestHeaders
                );
    }


    /**
     * These are the default starting values for mappings
     * between file suffixes and appropriate mime types
     */
    private void addDefaultValuesForMimeMap() {
        fileSuffixToMime.put("css", "text/css");
        fileSuffixToMime.put("js", "application/javascript");
        fileSuffixToMime.put("webp", "image/webp");
        fileSuffixToMime.put("jpg", "image/jpeg");
        fileSuffixToMime.put("jpeg", "image/jpeg");
        fileSuffixToMime.put("htm", "text/html");
        fileSuffixToMime.put("html", "text/html");
    }


    /**
     * let's see if we can match the registered paths against a **portion** of the startline
     */
    ThrowingFunction<IRequest, IResponse> findHandlerByPartialMatch(RequestLine sl) {
        String requestedPath = sl.getPathDetails().getIsolatedPath();
        var methodPathFunctionEntry = registeredPartialPaths.entrySet().stream()
                .filter(x -> requestedPath.startsWith(x.getKey().path()) &&
                        x.getKey().method().equals(sl.getMethod()))
                .findFirst().orElse(null);
        if (methodPathFunctionEntry != null) {
            return methodPathFunctionEntry.getValue();
        } else {
            return null;
        }
    }

    /**
     * This constructor is used for the real production system
     */
    WebFramework(Context context) {
        this(context, null, null);
    }

    WebFramework(Context context, ZonedDateTime overrideForDateTime) {
        this(context, overrideForDateTime, null);
    }

    /**
     * This provides the ZonedDateTime as a parameter so we
     * can set the current date (for testing purposes)
     * @param overrideForDateTime for those test cases where we need to control the time
     */
    WebFramework(Context context, ZonedDateTime overrideForDateTime, IFileReader fileReader) {
        this.fs = context.getFullSystem();
        this.logger = context.getLogger();
        this.constants = context.getConstants();
        this.overrideForDateTime = overrideForDateTime;
        this.registeredDynamicPaths = new HashMap<>();
        this.registeredPartialPaths = new HashMap<>();
        this.underInvestigation = new UnderInvestigation(constants);
        this.inputStreamUtils = new InputStreamUtils(constants.maxReadLineSizeBytes);
        this.bodyProcessor = new BodyProcessor(context);

        // This random value is purely to help provide correlation between
        // error messages in the UI and error logs.  There are no security concerns.
        this.randomErrorCorrelationId = new Random();
        this.emptyRequestLine = RequestLine.EMPTY;

        // this allows us to inject a IFileReader for deeper testing
        if (fileReader != null) {
            this.fileReader = fileReader;
        } else {
            this.fileReader = new FileReader(
                    LRUCache.getLruCache(constants.maxElementsLruCacheStaticFiles),
                    constants.useCacheForStaticFiles,
                    logger);
        }
        this.fileSuffixToMime = new HashMap<>();
        addDefaultValuesForMimeMap();
        readExtraMimeMappings(constants.extraMimeMappings);
    }

    void readExtraMimeMappings(List<String> input) {
        if (input == null || input.isEmpty()) return;
        mustBeTrue(input.size() % 2 == 0, "input must be even (key + value = 2 items). Your input: " + input);

        for (int i = 0; i < input.size(); i += 2) {
            String fileSuffix = input.get(i);
            String mime = input.get(i+1);
            logger.logTrace(() -> "Adding mime mapping: " + fileSuffix + " -> " + mime);
            fileSuffixToMime.put(fileSuffix, mime);
        }
    }

    /**
     * Add a new handler in the web application for a combination
     * of a {@link RequestLine.Method}, a path, and then provide
     * the code to handle a request.
     * <br>
     * Note that the path text expected is *after* the first forward slash,
     * so for example with {@code http://foo.com/mypath}, provide "mypath" as the path.
     */
    public void registerPath(RequestLine.Method method, String pathName, ThrowingFunction<IRequest, IResponse> webHandler) {
        registeredDynamicPaths.put(new MethodPath(method, pathName), webHandler);
    }

    /**
     * Similar to {@link WebFramework#registerPath(RequestLine.Method, String, ThrowingFunction)} except that the paths
     * registered here may be partially matched.
     * <p>
     *     For example, if you register {@code .well-known/acme-challenge} then it
     *     can match a client request for {@code .well-known/acme-challenge/HGr8U1IeTW4kY_Z6UIyaakzOkyQgPr_7ArlLgtZE8SX}
     * </p>
     * <p>
     *     Be careful here, be thoughtful - partial paths will match a lot, and may
     *     overlap with other URL's for your app, such as endpoints and static files.
     * </p>
     */
    public void registerPartialPath(RequestLine.Method method, String pathName, ThrowingFunction<IRequest, IResponse> webHandler) {
        registeredPartialPaths.put(new MethodPath(method, pathName), webHandler);
    }

    /**
     * Sets a handler to process all requests across the board.
     * <br>
     * <p>
     *     This is an <b>unusual</b> method.  Setting a handler here allows the user to run code of his
     * choosing before the regular business code is run.  Note that by defining this value, the ordinary
     * call to endpoint.apply(request) will not be run.
     * </p>
     * <p>Here is an example</p>
     * <pre>{@code
     *
     *      webFramework.registerPreHandler(preHandlerInputs -> preHandlerCode(preHandlerInputs, auth, context));
     *
     *      ...
     *
     *      private IResponse preHandlerCode(PreHandlerInputs preHandlerInputs, AuthUtils auth, Context context) throws Exception {
     *          int secureServerPort = context.getConstants().secureServerPort;
     *          Request request = preHandlerInputs.clientRequest();
     *          ThrowingFunction<IRequest, IResponse> endpoint = preHandlerInputs.endpoint();
     *          ISocketWrapper sw = preHandlerInputs.sw();
     *
     *          // log all requests
     *          logger.logTrace(() -> String.format("Request: %s by %s",
     *              request.requestLine().getRawValue(),
     *              request.remoteRequester())
     *          );
     *
     *          // redirect to https if they are on the plain-text connection and the path is "login"
     *
     *          // get the path from the request line
     *          String path = request.getRequestLine().getPathDetails().getIsolatedPath();
     *
     *          // redirect to https on the configured secure port if they are on the plain-text connection and the path contains "login"
     *          if (path.contains("login") &&
     *              sw.getServerType().equals(HttpServerType.PLAIN_TEXT_HTTP)) {
     *              return Response.redirectTo("https://%s:%d/%s".formatted(sw.getHostName(), secureServerPort, path));
     *          }
     *
     *          // adjust behavior if non-authenticated and path includes "secure/"
     *          if (path.contains("secure/")) {
     *              AuthResult authResult = auth.processAuth(request);
     *              if (authResult.isAuthenticated()) {
     *                  return endpoint.apply(request);
     *              } else {
     *                  return Response.buildLeanResponse(CODE_403_FORBIDDEN);
     *              }
     *          }
     *
     *          // if the path does not include /secure, just move the request along unchanged.
     *          return endpoint.apply(request);
     *      }
     * }</pre>
     */
        public void registerPreHandler(ThrowingFunction<PreHandlerInputs, IResponse> preHandler) {
        this.preHandler = preHandler;
    }

    /**
     * Sets a handler to be executed after running the ordinary handler, just
     * before sending the response.
     * <p>
     *     This is an <b>unusual</b> method, so please be aware of its proper use. Its
     *     purpose is to allow the user to inject code to run after ordinary code, across
     *     all requests.
     * </p>
     * <p>
     *     For example, if the system would have returned a 404 NOT FOUND response,
     *     code can handle that situation in a switch case and adjust the response according
     *     to your programming.
     * </p>
     * <p>Here is an example</p>
     * <pre>{@code
     *
     *
     *      webFramework.registerLastMinuteHandler(TheRegister::lastMinuteHandlerCode);
     *
     * ...
     *
     *     private static IResponse lastMinuteHandlerCode(LastMinuteHandlerInputs inputs) {
     *         switch (inputs.response().statusCode()) {
     *             case CODE_404_NOT_FOUND -> {
     *                 return Response.buildResponse(
     *                         CODE_404_NOT_FOUND,
     *                         Map.of("Content-Type", "text/html; charset=UTF-8"),
     *                         "<p>No document was found</p>"));
     *             }
     *             case CODE_500_INTERNAL_SERVER_ERROR -> {
     *                 return Response.buildResponse(
     *                         CODE_500_INTERNAL_SERVER_ERROR,
     *                         Map.of("Content-Type", "text/html; charset=UTF-8"),
     *                         "<p>Server error occurred.</p>" ));
     *             }
     *             default -> {
     *                 return inputs.response();
     *             }
     *         }
     *     }
     * }
     * </pre>
     * @param lastMinuteHandler a function that will take a request and return a response, exactly like
     *                   we use in the other registration methods for this class.
     */
    public void registerLastMinuteHandler(ThrowingFunction<LastMinuteHandlerInputs, IResponse> lastMinuteHandler) {
        this.lastMinuteHandler = lastMinuteHandler;
    }

    /**
     * This allows users to add extra mappings
     * between file suffixes and mime types, in case
     * a user needs one that was not provided.
     * <p>
     *     This is made available through the
     *     web framework.
     * </p>
     * <p>
     *     Example:
     * </p>
     * <pre>
     * {@code webFramework.addMimeForSuffix().put("foo","text/foo")}
     * </pre>
     */
    public void addMimeForSuffix(String suffix, String mimeType) {
        fileSuffixToMime.put(suffix, mimeType);
    }
}