1 | package com.renomad.minum.web; | |
2 | ||
3 | import com.renomad.minum.logging.ILogger; | |
4 | import com.renomad.minum.security.ForbiddenUseException; | |
5 | import com.renomad.minum.security.ITheBrig; | |
6 | import com.renomad.minum.security.UnderInvestigation; | |
7 | import com.renomad.minum.state.Constants; | |
8 | import com.renomad.minum.state.Context; | |
9 | import com.renomad.minum.utils.*; | |
10 | ||
11 | import java.io.IOException; | |
12 | import java.net.SocketException; | |
13 | import java.net.SocketTimeoutException; | |
14 | import java.nio.file.Files; | |
15 | import java.nio.file.Path; | |
16 | import java.time.ZoneId; | |
17 | import java.time.ZonedDateTime; | |
18 | import java.time.format.DateTimeFormatter; | |
19 | import java.util.*; | |
20 | import java.util.regex.Pattern; | |
21 | import java.util.stream.Collectors; | |
22 | ||
23 | import static com.renomad.minum.utils.FileUtils.*; | |
24 | import static com.renomad.minum.utils.Invariants.mustBeTrue; | |
25 | import static com.renomad.minum.web.StatusLine.StatusCode.*; | |
26 | import static com.renomad.minum.web.WebEngine.HTTP_CRLF; | |
27 | ||
28 | /** | |
29 | * This class is responsible for the HTTP handling after socket connection. | |
30 | * <p> | |
31 | * The public methods are for registering endpoints - code that will be | |
32 | * run for a given combination of HTTP method and path. See documentation | |
33 | * for the methods in this class. | |
34 | * </p> | |
35 | */ | |
36 | public final class WebFramework { | |
37 | ||
38 | private final Constants constants; | |
39 | private final UnderInvestigation underInvestigation; | |
40 | private final IInputStreamUtils inputStreamUtils; | |
41 | private final IBodyProcessor bodyProcessor; | |
42 | /** | |
43 | * This is a variable storing a pseudo-random (non-secure) number | |
44 | * that is shown to users when a serious error occurs, which | |
45 | * will also be put in the logs, to make finding it easier. | |
46 | */ | |
47 | private final Random randomErrorCorrelationId; | |
48 | private final RequestLine emptyRequestLine; | |
49 | ||
50 | public Map<String,String> getSuffixToMimeMappings() { | |
51 |
1
1. getSuffixToMimeMappings : replaced return value with Collections.emptyMap for com/renomad/minum/web/WebFramework::getSuffixToMimeMappings → KILLED |
return new HashMap<>(fileSuffixToMime); |
52 | } | |
53 | ||
54 | /** | |
55 | * This is used as a key when registering endpoints | |
56 | */ | |
57 | record MethodPath(RequestLine.Method method, String path) { } | |
58 | ||
59 | /** | |
60 | * The list of paths that our system is registered to handle. | |
61 | */ | |
62 | private final Map<MethodPath, ThrowingFunction<IRequest, IResponse>> registeredDynamicPaths; | |
63 | ||
64 | /** | |
65 | * These are registrations for paths that partially match, for example, | |
66 | * if the client sends us GET /.well-known/acme-challenge/HGr8U1IeTW4kY_Z6UIyaakzOkyQgPr_7ArlLgtZE8SX | |
67 | * and we want to match ".well-known/acme-challenge" | |
68 | */ | |
69 | private final Map<MethodPath, ThrowingFunction<IRequest, IResponse>> registeredPartialPaths; | |
70 | ||
71 | /** | |
72 | * A function that will be run instead of the ordinary business code. Has | |
73 | * provisions for running the business code as well. See {@link #registerPreHandler(ThrowingFunction)} | |
74 | */ | |
75 | private ThrowingFunction<PreHandlerInputs, IResponse> preHandler; | |
76 | ||
77 | /** | |
78 | * A function run after the ordinary business code | |
79 | */ | |
80 | private ThrowingFunction<LastMinuteHandlerInputs, IResponse> lastMinuteHandler; | |
81 | ||
82 | private final IFileReader fileReader; | |
83 | private final Map<String, String> fileSuffixToMime; | |
84 | ||
85 | // This is just used for testing. If it's null, we use the real time. | |
86 | private final ZonedDateTime overrideForDateTime; | |
87 | private final FullSystem fs; | |
88 | private final ILogger logger; | |
89 | ||
90 | /** | |
91 | * This is the minimum number of bytes in a text response to apply gzip. | |
92 | */ | |
93 | private static final int MINIMUM_NUMBER_OF_BYTES_TO_COMPRESS = 2048; | |
94 | ||
95 | /** | |
96 | * This is the brains of how the server responds to web clients. Whatever | |
97 | * code lives here will be inserted into a slot within the server code. | |
98 | */ | |
99 | ThrowingRunnable makePrimaryHttpHandler(ISocketWrapper sw, ITheBrig theBrig) { | |
100 | ||
101 |
1
1. makePrimaryHttpHandler : replaced return value with null for com/renomad/minum/web/WebFramework::makePrimaryHttpHandler → KILLED |
return () -> { |
102 | Thread.currentThread().setName("SocketWrapper thread for " + sw.getRemoteAddr()); | |
103 | try (sw) { | |
104 | dumpIfAttacker(sw, fs); | |
105 | final var is = sw.getInputStream(); | |
106 | ||
107 | // By default, browsers expect the server to run in keep-alive mode. | |
108 | // We'll break out later if we find that the browser doesn't do keep-alive | |
109 | while (true) { | |
110 | final String rawStartLine = inputStreamUtils.readLine(is); | |
111 | long startMillis = System.currentTimeMillis(); | |
112 |
2
1. lambda$makePrimaryHttpHandler$5 : negated conditional → KILLED 2. lambda$makePrimaryHttpHandler$5 : negated conditional → KILLED |
if (rawStartLine == null || rawStartLine.isEmpty()) { |
113 | // here, the client connected, sent nothing, and closed. | |
114 | // nothing to do but return. | |
115 | logger.logTrace(() -> "rawStartLine was empty. Returning."); | |
116 | break; | |
117 | } | |
118 | final RequestLine sl = getProcessedRequestLine(sw, rawStartLine); | |
119 | ||
120 |
1
1. lambda$makePrimaryHttpHandler$5 : negated conditional → KILLED |
if (sl.equals(emptyRequestLine)) { |
121 | // here, the client sent something we cannot parse. | |
122 | // nothing to do but return. | |
123 | logger.logTrace(() -> "RequestLine was unparseable. Returning."); | |
124 | break; | |
125 | } | |
126 | // check if the user is seeming to attack us. | |
127 |
1
1. lambda$makePrimaryHttpHandler$5 : removed call to com/renomad/minum/web/WebFramework::checkIfSuspiciousPath → KILLED |
checkIfSuspiciousPath(sw, sl); |
128 | ||
129 | // React to what the user requested, generate a result | |
130 | Headers hi = getHeaders(sw); | |
131 | boolean isKeepAlive = determineIfKeepAlive(sl, hi, logger); | |
132 |
1
1. lambda$makePrimaryHttpHandler$5 : negated conditional → KILLED |
if (isThereIsABody(hi)) { |
133 | logger.logTrace(() -> "There is a body. Content-type is " + hi.contentType()); | |
134 | } | |
135 | ProcessingResult result = processRequest(sw, sl, hi); | |
136 | IRequest request = result.clientRequest(); | |
137 | Response response = (Response)result.resultingResponse(); | |
138 | ||
139 | // check that the response is non-null. If it is null, that suggests | |
140 | // the developer made a mistake. | |
141 |
1
1. lambda$makePrimaryHttpHandler$5 : negated conditional → KILLED |
if (response == null) { |
142 | throw new WebServerException("The returned value for the endpoint \"%s\" was null.".formatted(request.getRequestLine().getPathDetails().getIsolatedPath())); | |
143 | } | |
144 | ||
145 | // calculate proper headers for the response | |
146 | StringBuilder headerStringBuilder = addDefaultHeaders(response); | |
147 |
1
1. lambda$makePrimaryHttpHandler$5 : removed call to com/renomad/minum/web/WebFramework::addOptionalExtraHeaders → KILLED |
addOptionalExtraHeaders(response, headerStringBuilder); |
148 |
1
1. lambda$makePrimaryHttpHandler$5 : removed call to com/renomad/minum/web/WebFramework::addKeepAliveTimeout → KILLED |
addKeepAliveTimeout(isKeepAlive, headerStringBuilder); |
149 | ||
150 | // inspect the response being sent, see whether we can compress the data. | |
151 | Response adjustedResponse = potentiallyCompress(request.getHeaders(), response, headerStringBuilder); | |
152 |
1
1. lambda$makePrimaryHttpHandler$5 : removed call to com/renomad/minum/web/WebFramework::applyContentLength → KILLED |
applyContentLength(headerStringBuilder, adjustedResponse.getBodyLength()); |
153 |
1
1. lambda$makePrimaryHttpHandler$5 : removed call to com/renomad/minum/web/WebFramework::confirmBodyHasContentType → SURVIVED |
confirmBodyHasContentType(request, response); |
154 | ||
155 | // send the headers | |
156 |
1
1. lambda$makePrimaryHttpHandler$5 : removed call to com/renomad/minum/web/ISocketWrapper::send → KILLED |
sw.send(headerStringBuilder.append(HTTP_CRLF).toString()); |
157 | ||
158 | // if the user sent a HEAD request, we send everything back except the body. | |
159 | // even though we skip the body, this requires full processing to get the | |
160 | // numbers right, like content-length. | |
161 |
1
1. lambda$makePrimaryHttpHandler$5 : negated conditional → KILLED |
if (request.getRequestLine().getMethod().equals(RequestLine.Method.HEAD)) { |
162 | logger.logDebug(() -> "client " + request.getRemoteRequester() + | |
163 | " is requesting HEAD for "+ request.getRequestLine().getPathDetails().getIsolatedPath() + | |
164 | ". Excluding body from response"); | |
165 | } else { | |
166 | // send the body | |
167 |
1
1. lambda$makePrimaryHttpHandler$5 : removed call to com/renomad/minum/web/Response::sendBody → TIMED_OUT |
adjustedResponse.sendBody(sw); |
168 | } | |
169 | // print how long this processing took | |
170 | long endMillis = System.currentTimeMillis(); | |
171 | logger.logTrace(() -> String.format("full processing (including communication time) of %s %s took %d millis", sw, sl, endMillis - startMillis)); | |
172 |
1
1. lambda$makePrimaryHttpHandler$5 : negated conditional → KILLED |
if (!isKeepAlive) break; |
173 | } | |
174 | } catch (SocketException | SocketTimeoutException ex) { | |
175 |
1
1. lambda$makePrimaryHttpHandler$5 : removed call to com/renomad/minum/web/WebFramework::handleReadTimedOut → KILLED |
handleReadTimedOut(sw, ex, logger); |
176 | } catch (ForbiddenUseException ex) { | |
177 |
1
1. lambda$makePrimaryHttpHandler$5 : removed call to com/renomad/minum/web/WebFramework::handleForbiddenUse → KILLED |
handleForbiddenUse(sw, ex, logger, theBrig, constants.vulnSeekingJailDuration); |
178 | } catch (IOException ex) { | |
179 |
1
1. lambda$makePrimaryHttpHandler$5 : removed call to com/renomad/minum/web/WebFramework::handleIOException → SURVIVED |
handleIOException(sw, ex, logger, theBrig, underInvestigation, constants.vulnSeekingJailDuration); |
180 | } | |
181 | }; | |
182 | } | |
183 | ||
184 | ||
185 | static void handleIOException(ISocketWrapper sw, IOException ex, ILogger logger, ITheBrig theBrig, UnderInvestigation underInvestigation, int vulnSeekingJailDuration ) { | |
186 | logger.logDebug(() -> ex.getMessage() + " (at Server.start)"); | |
187 | String suspiciousClues = underInvestigation.isClientLookingForVulnerabilities(ex.getMessage()); | |
188 | ||
189 |
2
1. handleIOException : negated conditional → KILLED 2. handleIOException : negated conditional → KILLED |
if (!suspiciousClues.isEmpty() && theBrig != null) { |
190 | logger.logDebug(() -> sw.getRemoteAddr() + " is looking for vulnerabilities, for this: " + suspiciousClues); | |
191 | theBrig.sendToJail(sw.getRemoteAddr() + "_vuln_seeking", vulnSeekingJailDuration); | |
192 | } | |
193 | } | |
194 | ||
195 | static void handleForbiddenUse(ISocketWrapper sw, ForbiddenUseException ex, ILogger logger, ITheBrig theBrig, int vulnSeekingJailDuration) { | |
196 | logger.logDebug(() -> sw.getRemoteAddr() + " is looking for vulnerabilities, for this: " + ex.getMessage()); | |
197 |
1
1. handleForbiddenUse : negated conditional → KILLED |
if (theBrig != null) { |
198 | theBrig.sendToJail(sw.getRemoteAddr() + "_vuln_seeking", vulnSeekingJailDuration); | |
199 | } else { | |
200 | logger.logDebug(() -> "theBrig is null at handleForbiddenUse, will not store address in database"); | |
201 | } | |
202 | } | |
203 | ||
204 | static void handleReadTimedOut(ISocketWrapper sw, IOException ex, ILogger logger) { | |
205 | /* | |
206 | if we close the application on the server side, there's a good | |
207 | likelihood a SocketException will come bubbling through here. | |
208 | NOTE: | |
209 | it seems that Socket closed is what we get when the client closes the connection in non-SSL, and conversely, | |
210 | if we are operating in secure (i.e. SSL/TLS) mode, we get "an established connection..." | |
211 | */ | |
212 |
1
1. handleReadTimedOut : negated conditional → SURVIVED |
if (ex.getMessage().equals("Read timed out")) { |
213 | logger.logTrace(() -> "Read timed out - remote address: " + sw.getRemoteAddrWithPort()); | |
214 | } else { | |
215 | logger.logDebug(() -> ex.getMessage() + " - remote address: " + sw.getRemoteAddrWithPort()); | |
216 | } | |
217 | } | |
218 | ||
219 | /** | |
220 | * Logic for how to process an incoming request. For example, did the developer | |
221 | * write a function to handle this? Is it a request for a static file, like an image | |
222 | * or script? Did the user provide a "pre" or "post" handler? | |
223 | */ | |
224 | ProcessingResult processRequest( | |
225 | ISocketWrapper sw, | |
226 | RequestLine requestLine, | |
227 | Headers requestHeaders) throws Exception { | |
228 | IRequest clientRequest = new Request(requestHeaders, requestLine, sw.getRemoteAddr(), sw, bodyProcessor); | |
229 | IResponse response; | |
230 | ThrowingFunction<IRequest, IResponse> endpoint = findEndpointForThisStartline(requestLine, requestHeaders); | |
231 |
1
1. processRequest : negated conditional → KILLED |
if (endpoint == null) { |
232 | response = Response.buildLeanResponse(CODE_404_NOT_FOUND); | |
233 | } else { | |
234 | long millisAtStart = System.currentTimeMillis(); | |
235 | try { | |
236 |
1
1. processRequest : negated conditional → KILLED |
if (preHandler != null) { |
237 | response = preHandler.apply(new PreHandlerInputs(clientRequest, endpoint, sw)); | |
238 | } else { | |
239 | response = endpoint.apply(clientRequest); | |
240 | } | |
241 | } catch (Exception ex) { | |
242 | // if an error happens while running an endpoint's code, this is the | |
243 | // last-chance handling of that error where we return a 500 and a | |
244 | // random code to the client, so a developer can find the detailed | |
245 | // information in the logs, which have that same value. | |
246 | int randomNumber = randomErrorCorrelationId.nextInt(); | |
247 | logger.logAsyncError(() -> "error while running endpoint " + endpoint + ". Code: " + randomNumber + ". Error: " + StacktraceUtils.stackTraceToString(ex)); | |
248 | response = Response.buildResponse(CODE_500_INTERNAL_SERVER_ERROR, Map.of("Content-Type", "text/plain;charset=UTF-8"), "Server error: " + randomNumber); | |
249 | } | |
250 | long millisAtEnd = System.currentTimeMillis(); | |
251 | logger.logTrace(() -> String.format("handler processing of %s %s took %d millis", sw, requestLine, millisAtEnd - millisAtStart)); | |
252 | } | |
253 | ||
254 | // if the user has chosen to customize the response based on status code, that will | |
255 | // be applied now, and it will override the previous response. | |
256 |
1
1. processRequest : negated conditional → KILLED |
if (lastMinuteHandler != null) { |
257 | response = lastMinuteHandler.apply(new LastMinuteHandlerInputs(clientRequest, response)); | |
258 | } | |
259 | ||
260 |
1
1. processRequest : replaced return value with null for com/renomad/minum/web/WebFramework::processRequest → KILLED |
return new ProcessingResult(clientRequest, response); |
261 | } | |
262 | ||
263 | record ProcessingResult(IRequest clientRequest, IResponse resultingResponse) { } | |
264 | ||
265 | private Headers getHeaders(ISocketWrapper sw) { | |
266 | /* | |
267 | next we will read the headers (e.g. Content-Type: foo/bar) one-by-one. | |
268 | ||
269 | the headers tell us vital information about the | |
270 | body. If, for example, we're getting a POST and receiving a | |
271 | www form url encoded, there will be a header of "content-length" | |
272 | that will mention how many bytes to read. On the other hand, if | |
273 | we're receiving a multipart, there will be no content-length, but | |
274 | the content-type will include the boundary string. | |
275 | */ | |
276 | List<String> allHeaders = Headers.getAllHeaders(sw.getInputStream(), inputStreamUtils); | |
277 | Headers hi = new Headers(allHeaders); | |
278 | logger.logTrace(() -> "The headers are: " + hi.getHeaderStrings()); | |
279 |
1
1. getHeaders : replaced return value with null for com/renomad/minum/web/WebFramework::getHeaders → KILLED |
return hi; |
280 | } | |
281 | ||
282 | /** | |
283 | * determine if we are in a keep-alive connection | |
284 | */ | |
285 | static boolean determineIfKeepAlive(RequestLine sl, Headers hi, ILogger logger) { | |
286 | boolean isKeepAlive = false; | |
287 |
1
1. determineIfKeepAlive : negated conditional → KILLED |
if (sl.getVersion() == HttpVersion.ONE_DOT_ZERO) { |
288 | isKeepAlive = hi.hasKeepAlive(); | |
289 |
1
1. determineIfKeepAlive : negated conditional → KILLED |
} else if (sl.getVersion() == HttpVersion.ONE_DOT_ONE) { |
290 |
1
1. determineIfKeepAlive : negated conditional → KILLED |
isKeepAlive = ! hi.hasConnectionClose(); |
291 | } | |
292 | boolean finalIsKeepAlive = isKeepAlive; | |
293 | logger.logTrace(() -> "Is this a keep-alive connection? " + finalIsKeepAlive); | |
294 |
2
1. determineIfKeepAlive : replaced boolean return with false for com/renomad/minum/web/WebFramework::determineIfKeepAlive → KILLED 2. determineIfKeepAlive : replaced boolean return with true for com/renomad/minum/web/WebFramework::determineIfKeepAlive → KILLED |
return isKeepAlive; |
295 | } | |
296 | ||
297 | RequestLine getProcessedRequestLine(ISocketWrapper sw, String rawStartLine) { | |
298 | logger.logTrace(() -> sw + ": raw request line received: " + rawStartLine); | |
299 | RequestLine rl = new RequestLine( | |
300 | RequestLine.Method.NONE, | |
301 | PathDetails.empty, | |
302 | HttpVersion.NONE, | |
303 | "", logger); | |
304 | RequestLine extractedRequestLine = rl.extractRequestLine(rawStartLine); | |
305 | logger.logTrace(() -> sw + ": RequestLine has been derived: " + extractedRequestLine); | |
306 |
1
1. getProcessedRequestLine : replaced return value with null for com/renomad/minum/web/WebFramework::getProcessedRequestLine → KILLED |
return extractedRequestLine; |
307 | } | |
308 | ||
309 | void checkIfSuspiciousPath(ISocketWrapper sw, RequestLine requestLine) { | |
310 | String suspiciousClues = underInvestigation.isLookingForSuspiciousPaths( | |
311 | requestLine.getPathDetails().getIsolatedPath()); | |
312 |
1
1. checkIfSuspiciousPath : negated conditional → KILLED |
if (!suspiciousClues.isEmpty()) { |
313 | String msg = sw.getRemoteAddr() + " is looking for a vulnerability, for this: " + suspiciousClues; | |
314 | throw new ForbiddenUseException(msg); | |
315 | } | |
316 | } | |
317 | ||
318 | /** | |
319 | * This code confirms our objects are valid before calling | |
320 | * to {@link #dumpIfAttacker(ISocketWrapper, ITheBrig)}. | |
321 | * @return true if successfully called to subsequent method, false otherwise. | |
322 | */ | |
323 | boolean dumpIfAttacker(ISocketWrapper sw, FullSystem fs) { | |
324 |
1
1. dumpIfAttacker : negated conditional → KILLED |
if (fs == null) { |
325 |
1
1. dumpIfAttacker : replaced boolean return with true for com/renomad/minum/web/WebFramework::dumpIfAttacker → KILLED |
return false; |
326 |
1
1. dumpIfAttacker : negated conditional → KILLED |
} else if (fs.getTheBrig() == null) { |
327 |
1
1. dumpIfAttacker : replaced boolean return with true for com/renomad/minum/web/WebFramework::dumpIfAttacker → KILLED |
return false; |
328 | } else { | |
329 |
1
1. dumpIfAttacker : removed call to com/renomad/minum/web/WebFramework::dumpIfAttacker → KILLED |
dumpIfAttacker(sw, fs.getTheBrig()); |
330 |
1
1. dumpIfAttacker : replaced boolean return with false for com/renomad/minum/web/WebFramework::dumpIfAttacker → SURVIVED |
return true; |
331 | } | |
332 | } | |
333 | ||
334 | void dumpIfAttacker(ISocketWrapper sw, ITheBrig theBrig) { | |
335 | String remoteClient = sw.getRemoteAddr(); | |
336 |
1
1. dumpIfAttacker : negated conditional → KILLED |
if (theBrig.isInJail(remoteClient + "_vuln_seeking")) { |
337 | // if this client is a vulnerability seeker, throw an exception, | |
338 | // causing them to get dumped unceremoniously | |
339 | String message = "closing the socket on " + remoteClient + " due to being found in the brig"; | |
340 | logger.logDebug(() -> message); | |
341 | throw new ForbiddenUseException(message); | |
342 | } | |
343 | } | |
344 | ||
345 | /** | |
346 | * Determine whether the headers in this HTTP message indicate that | |
347 | * a body is available to read | |
348 | */ | |
349 | static boolean isThereIsABody(Headers hi) { | |
350 | // if the client sent us a content-type header at all... | |
351 |
1
1. isThereIsABody : negated conditional → KILLED |
if (!hi.contentType().isBlank()) { |
352 | // if the content-length is greater than 0, we've got a body | |
353 |
3
1. isThereIsABody : negated conditional → TIMED_OUT 2. isThereIsABody : replaced boolean return with false for com/renomad/minum/web/WebFramework::isThereIsABody → KILLED 3. isThereIsABody : changed conditional boundary → KILLED |
if (hi.contentLength() > 0) return true; |
354 | ||
355 | // if the transfer-encoding header is set to chunked, we have a body | |
356 | List<String> transferEncodingHeaders = hi.valueByKey("transfer-encoding"); | |
357 |
5
1. isThereIsABody : negated conditional → KILLED 2. lambda$isThereIsABody$19 : replaced boolean return with false for com/renomad/minum/web/WebFramework::lambda$isThereIsABody$19 → KILLED 3. lambda$isThereIsABody$19 : replaced boolean return with true for com/renomad/minum/web/WebFramework::lambda$isThereIsABody$19 → KILLED 4. isThereIsABody : replaced boolean return with true for com/renomad/minum/web/WebFramework::isThereIsABody → KILLED 5. isThereIsABody : negated conditional → KILLED |
return transferEncodingHeaders != null && transferEncodingHeaders.stream().anyMatch(x -> x.equalsIgnoreCase("chunked")); |
358 | } | |
359 | // otherwise, no body we recognize | |
360 |
1
1. isThereIsABody : replaced boolean return with true for com/renomad/minum/web/WebFramework::isThereIsABody → KILLED |
return false; |
361 | } | |
362 | ||
363 | /** | |
364 | * Prepare some of the basic server response headers, like the status code, the | |
365 | * date-time stamp, the server name. | |
366 | */ | |
367 | private StringBuilder addDefaultHeaders(IResponse response) { | |
368 | ||
369 |
1
1. lambda$addDefaultHeaders$20 : replaced return value with null for com/renomad/minum/web/WebFramework::lambda$addDefaultHeaders$20 → KILLED |
String date = Objects.requireNonNullElseGet(overrideForDateTime, () -> ZonedDateTime.now(ZoneId.of("UTC"))).format(DateTimeFormatter.RFC_1123_DATE_TIME); |
370 | ||
371 | // we'll store the status line and headers in this | |
372 | StringBuilder headerStringBuilder = new StringBuilder(); | |
373 | ||
374 | ||
375 | // add the status line | |
376 | headerStringBuilder.append("HTTP/1.1 ").append(response.getStatusCode().code).append(" ").append(response.getStatusCode().shortDescription).append(HTTP_CRLF); | |
377 | ||
378 | // add a date-timestamp | |
379 | headerStringBuilder.append("Date: ").append(date).append(HTTP_CRLF); | |
380 | ||
381 | // add the server name | |
382 | headerStringBuilder.append("Server: minum").append(HTTP_CRLF); | |
383 | ||
384 |
1
1. addDefaultHeaders : replaced return value with null for com/renomad/minum/web/WebFramework::addDefaultHeaders → KILLED |
return headerStringBuilder; |
385 | } | |
386 | ||
387 | /** | |
388 | * Add extra headers specified by the business logic (set by the developer) | |
389 | */ | |
390 | private static void addOptionalExtraHeaders(IResponse response, StringBuilder stringBuilder) { | |
391 | stringBuilder.append( | |
392 | response.getExtraHeaders().entrySet().stream() | |
393 |
1
1. lambda$addOptionalExtraHeaders$21 : replaced return value with "" for com/renomad/minum/web/WebFramework::lambda$addOptionalExtraHeaders$21 → KILLED |
.map(x -> x.getKey() + ": " + x.getValue() + HTTP_CRLF) |
394 | .collect(Collectors.joining())); | |
395 | } | |
396 | ||
397 | /** | |
398 | * If a response body exists, it needs to have a content-type specified, or throw an exception. | |
399 | */ | |
400 | static void confirmBodyHasContentType(IRequest request, Response response) { | |
401 | // check the correctness of the content-type header versus the data length (if any data, that is) | |
402 |
2
1. lambda$confirmBodyHasContentType$22 : replaced boolean return with true for com/renomad/minum/web/WebFramework::lambda$confirmBodyHasContentType$22 → KILLED 2. lambda$confirmBodyHasContentType$22 : replaced boolean return with false for com/renomad/minum/web/WebFramework::lambda$confirmBodyHasContentType$22 → KILLED |
boolean hasContentType = response.getExtraHeaders().entrySet().stream().anyMatch(x -> x.getKey().toLowerCase(Locale.ROOT).equals("content-type")); |
403 | ||
404 | // if there *is* data, we had better be returning a content type | |
405 |
2
1. confirmBodyHasContentType : changed conditional boundary → KILLED 2. confirmBodyHasContentType : negated conditional → KILLED |
if (response.getBodyLength() > 0) { |
406 | mustBeTrue(hasContentType, "a Content-Type header must be specified in the Response object if it returns data. Response details: " + response + " Request: " + request); | |
407 | } | |
408 | } | |
409 | ||
410 | /** | |
411 | * If this is a keep-alive communication, add a header specifying the | |
412 | * socket timeout for the browser. | |
413 | */ | |
414 | private void addKeepAliveTimeout(boolean isKeepAlive, StringBuilder stringBuilder) { | |
415 | // if we're a keep-alive connection, reply with a keep-alive header | |
416 |
1
1. addKeepAliveTimeout : negated conditional → KILLED |
if (isKeepAlive) { |
417 | stringBuilder.append("Keep-Alive: timeout=").append(constants.keepAliveTimeoutSeconds).append(HTTP_CRLF); | |
418 | } | |
419 | } | |
420 | ||
421 | /** | |
422 | * The rules regarding the content-length header are byzantine. Even in the cases | |
423 | * where you aren't returning anything, servers can use this header to determine when the | |
424 | * response is finished. | |
425 | * See <a href="https://www.rfc-editor.org/rfc/rfc9110.html#name-content-length">Content-Length in the HTTP spec</a> | |
426 | */ | |
427 | private static void applyContentLength(StringBuilder stringBuilder, long bodyLength) { | |
428 | stringBuilder.append("Content-Length: ").append(bodyLength).append(HTTP_CRLF); | |
429 | } | |
430 | ||
431 | /** | |
432 | * This method will examine the request headers and response content-type, and | |
433 | * compress the outgoing data if necessary. Uses {@link #compressibleMimesPattern} | |
434 | */ | |
435 | static Response potentiallyCompress(Headers requestHeaders, Response response, StringBuilder headerStringBuilder) throws IOException { | |
436 | // we may make modifications to the response body at this point, specifically | |
437 | // we may compress the data, if the client requested it. | |
438 | // see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-encoding | |
439 | List<String> acceptEncoding = requestHeaders.valueByKey("accept-encoding"); | |
440 | ||
441 | // regardless of whether the client requests compression in their Accept-Encoding header, | |
442 | // if the data we're sending back is not of an appropriate type, we won't bother | |
443 | // compressing it. Basically, we're going to compress plain text. | |
444 |
2
1. lambda$potentiallyCompress$23 : replaced boolean return with false for com/renomad/minum/web/WebFramework::lambda$potentiallyCompress$23 → KILLED 2. lambda$potentiallyCompress$23 : replaced boolean return with true for com/renomad/minum/web/WebFramework::lambda$potentiallyCompress$23 → KILLED |
Map.Entry<String, String> contentTypeHeader = SearchUtils.findExactlyOne(response.getExtraHeaders().entrySet().stream(), x -> x.getKey().equalsIgnoreCase("content-type")); |
445 | ||
446 |
1
1. potentiallyCompress : negated conditional → KILLED |
if (contentTypeHeader != null) { |
447 | String contentType = contentTypeHeader.getValue().toLowerCase(Locale.ROOT); | |
448 | boolean hasCompressibleMimeType = determineCompressible(contentType); | |
449 |
1
1. potentiallyCompress : negated conditional → SURVIVED |
if (hasCompressibleMimeType) { |
450 |
1
1. potentiallyCompress : replaced return value with null for com/renomad/minum/web/WebFramework::potentiallyCompress → KILLED |
return compressBodyIfRequested(response, acceptEncoding, headerStringBuilder, MINIMUM_NUMBER_OF_BYTES_TO_COMPRESS); |
451 | } | |
452 | } | |
453 |
1
1. potentiallyCompress : replaced return value with null for com/renomad/minum/web/WebFramework::potentiallyCompress → KILLED |
return response; |
454 | } | |
455 | ||
456 | ||
457 | /** | |
458 | * A regular expression for finding mimes that we can compress | |
459 | */ | |
460 | final static Pattern compressibleMimesPattern = Pattern.compile("^text/|(?:json|\\+?xml)$"); | |
461 | ||
462 | /** | |
463 | * Run a regular expression against the Content-Type header, and return | |
464 | * true if the mime is text-based and thus would benefit from compression. | |
465 | * Uses {@link #compressibleMimesPattern} | |
466 | */ | |
467 | static boolean determineCompressible(String contentType) { | |
468 |
2
1. determineCompressible : replaced boolean return with false for com/renomad/minum/web/WebFramework::determineCompressible → KILLED 2. determineCompressible : replaced boolean return with true for com/renomad/minum/web/WebFramework::determineCompressible → KILLED |
return compressibleMimesPattern.matcher(contentType).find(); |
469 | } | |
470 | ||
471 | /** | |
472 | * This method will examine the content-encoding headers, and if "gzip" is | |
473 | * requested by the client, we will replace the body bytes with compressed | |
474 | * bytes, using the GZIP compression algorithm, as long as the response body | |
475 | * is greater than minNumberBytes bytes. | |
476 | * | |
477 | * @param acceptEncoding headers sent by the client about what compression | |
478 | * algorithms will be understood. | |
479 | * @param stringBuilder the string we are gradually building up to send back to | |
480 | * the client for the status line and headers. We'll use it | |
481 | * here if we need to append a content-encoding - that is, | |
482 | * if we successfully compress data as gzip. | |
483 | * @param minNumberBytes number of bytes must be larger than this to compress. | |
484 | */ | |
485 | static Response compressBodyIfRequested(Response response, List<String> acceptEncoding, StringBuilder stringBuilder, int minNumberBytes) throws IOException { | |
486 |
1
1. compressBodyIfRequested : negated conditional → KILLED |
String allContentEncodingHeaders = acceptEncoding != null ? String.join(";", acceptEncoding) : ""; |
487 |
4
1. compressBodyIfRequested : negated conditional → KILLED 2. compressBodyIfRequested : negated conditional → KILLED 3. compressBodyIfRequested : changed conditional boundary → KILLED 4. compressBodyIfRequested : negated conditional → KILLED |
if (response.getBodyLength() >= minNumberBytes && acceptEncoding != null && allContentEncodingHeaders.contains("gzip")) { |
488 | stringBuilder.append("Content-Encoding: gzip" + HTTP_CRLF); | |
489 | stringBuilder.append("Vary: accept-encoding" + HTTP_CRLF); | |
490 |
1
1. compressBodyIfRequested : replaced return value with null for com/renomad/minum/web/WebFramework::compressBodyIfRequested → KILLED |
return response.compressBody(); |
491 | } | |
492 |
1
1. compressBodyIfRequested : replaced return value with null for com/renomad/minum/web/WebFramework::compressBodyIfRequested → KILLED |
return response; |
493 | } | |
494 | ||
495 | /** | |
496 | * Looks through the mappings of {@link MethodPath} and path to registered endpoints | |
497 | * or the static cache and returns the appropriate one (If we | |
498 | * do not find anything, return null) | |
499 | */ | |
500 | ThrowingFunction<IRequest, IResponse> findEndpointForThisStartline(RequestLine sl, Headers requestHeaders) { | |
501 | ThrowingFunction<IRequest, IResponse> handler; | |
502 | logger.logTrace(() -> "Seeking a handler for " + sl); | |
503 | ||
504 | // first we check if there's a simple direct match | |
505 | String requestedPath = sl.getPathDetails().getIsolatedPath().toLowerCase(Locale.ROOT); | |
506 | ||
507 | // if the user is asking for a HEAD request, they want to run a GET command | |
508 | // but don't want the body. We'll simply exclude sending the body, later on, when returning the data | |
509 |
1
1. findEndpointForThisStartline : negated conditional → KILLED |
RequestLine.Method method = sl.getMethod() == RequestLine.Method.HEAD ? RequestLine.Method.GET : sl.getMethod(); |
510 | ||
511 | MethodPath key = new MethodPath(method, requestedPath); | |
512 | handler = registeredDynamicPaths.get(key); | |
513 | ||
514 |
1
1. findEndpointForThisStartline : negated conditional → KILLED |
if (handler == null) { |
515 | logger.logTrace(() -> "No direct handler found. looking for a partial match for " + requestedPath); | |
516 | handler = findHandlerByPartialMatch(sl); | |
517 | } | |
518 | ||
519 |
1
1. findEndpointForThisStartline : negated conditional → KILLED |
if (handler == null) { |
520 | logger.logTrace(() -> "No partial match found, checking files on disk for " + requestedPath ); | |
521 | handler = findHandlerByFilesOnDisk(sl, requestHeaders); | |
522 | } | |
523 | ||
524 | // we'll return this, and it could be a null. | |
525 |
1
1. findEndpointForThisStartline : replaced return value with null for com/renomad/minum/web/WebFramework::findEndpointForThisStartline → KILLED |
return handler; |
526 | } | |
527 | ||
528 | /** | |
529 | * last ditch effort - look on disk. This response will either | |
530 | * be the file to return, or null if we didn't find anything. | |
531 | */ | |
532 | private ThrowingFunction<IRequest, IResponse> findHandlerByFilesOnDisk(RequestLine sl, Headers requestHeaders) { | |
533 |
2
1. findHandlerByFilesOnDisk : negated conditional → KILLED 2. findHandlerByFilesOnDisk : negated conditional → KILLED |
if (sl.getMethod() != RequestLine.Method.GET && sl.getMethod() != RequestLine.Method.HEAD) { |
534 | return null; | |
535 | } | |
536 | String requestedPath = sl.getPathDetails().getIsolatedPath(); | |
537 | IResponse response = readStaticFile(requestedPath, requestHeaders); | |
538 |
2
1. lambda$findHandlerByFilesOnDisk$27 : replaced return value with null for com/renomad/minum/web/WebFramework::lambda$findHandlerByFilesOnDisk$27 → KILLED 2. findHandlerByFilesOnDisk : replaced return value with null for com/renomad/minum/web/WebFramework::findHandlerByFilesOnDisk → KILLED |
return request -> response; |
539 | } | |
540 | ||
541 | ||
542 | /** | |
543 | * Get a file from a path and create a response for it with a mime type. | |
544 | * <p> | |
545 | * Parent directories are made unavailable by searching the path for | |
546 | * bad characters. See {@link FileUtils#badFilePathPatterns} | |
547 | * </p> | |
548 | * | |
549 | * @return a response with the file contents and caching headers and mime if valid. | |
550 | * if the path has invalid characters, we'll return a "bad request" response. | |
551 | */ | |
552 | IResponse readStaticFile(String path, Headers requestHeaders) { | |
553 | try { | |
554 |
1
1. readStaticFile : removed call to com/renomad/minum/utils/FileUtils::checkForBadFilePatterns → KILLED |
checkForBadFilePatterns(path); |
555 | } catch (Exception ex) { | |
556 | logger.logDebug(() -> String.format("Bad path requested at readStaticFile: %s. Exception: %s", path, ex.getMessage())); | |
557 |
1
1. readStaticFile : replaced return value with null for com/renomad/minum/web/WebFramework::readStaticFile → KILLED |
return Response.buildLeanResponse(CODE_400_BAD_REQUEST); |
558 | } | |
559 | String mimeType = null; | |
560 | ||
561 | try { | |
562 |
1
1. readStaticFile : removed call to com/renomad/minum/utils/FileUtils::checkFileIsWithinDirectory → KILLED |
checkFileIsWithinDirectory(path, constants.staticFilesDirectory); |
563 | } catch (Exception ex) { | |
564 | logger.logDebug(() -> String.format("Unable to find %s in allowed directories", path)); | |
565 |
1
1. readStaticFile : replaced return value with null for com/renomad/minum/web/WebFramework::readStaticFile → KILLED |
return Response.buildLeanResponse(CODE_404_NOT_FOUND); |
566 | } | |
567 | ||
568 | try { | |
569 | Path staticFilePath = Path.of(constants.staticFilesDirectory).resolve(path); | |
570 |
1
1. readStaticFile : negated conditional → KILLED |
if (!Files.isRegularFile(staticFilePath)) { |
571 | logger.logDebug(() -> String.format("No readable regular file found at %s", path)); | |
572 |
1
1. readStaticFile : replaced return value with null for com/renomad/minum/web/WebFramework::readStaticFile → KILLED |
return Response.buildLeanResponse(CODE_404_NOT_FOUND); |
573 | } | |
574 | ||
575 | // if the provided path has a dot in it, use that | |
576 | // to obtain a suffix for determining file type | |
577 | int suffixBeginIndex = path.lastIndexOf('.'); | |
578 |
2
1. readStaticFile : negated conditional → KILLED 2. readStaticFile : changed conditional boundary → KILLED |
if (suffixBeginIndex > 0) { |
579 |
1
1. readStaticFile : Replaced integer addition with subtraction → KILLED |
String suffix = path.substring(suffixBeginIndex+1); |
580 | mimeType = fileSuffixToMime.get(suffix); | |
581 | } | |
582 | ||
583 | // if we don't find any registered mime types for this | |
584 | // suffix, or if it doesn't have a suffix, set the mime type | |
585 | // to application/octet-stream | |
586 |
1
1. readStaticFile : negated conditional → KILLED |
if (mimeType == null) { |
587 | mimeType = "application/octet-stream"; | |
588 | } | |
589 | ||
590 |
2
1. readStaticFile : negated conditional → KILLED 2. readStaticFile : changed conditional boundary → KILLED |
if (Files.size(staticFilePath) < 100_000) { |
591 | var fileContents = fileReader.readFile(staticFilePath.toString()); | |
592 |
1
1. readStaticFile : replaced return value with null for com/renomad/minum/web/WebFramework::readStaticFile → KILLED |
return createOkResponseForStaticFiles(fileContents, mimeType); |
593 | } else { | |
594 |
1
1. readStaticFile : replaced return value with null for com/renomad/minum/web/WebFramework::readStaticFile → KILLED |
return createOkResponseForLargeStaticFiles(mimeType, staticFilePath, requestHeaders); |
595 | } | |
596 | ||
597 | } catch (IOException e) { | |
598 | logger.logAsyncError(() -> String.format("Error while reading file: %s. %s", path, StacktraceUtils.stackTraceToString(e))); | |
599 |
1
1. readStaticFile : replaced return value with null for com/renomad/minum/web/WebFramework::readStaticFile → KILLED |
return Response.buildLeanResponse(CODE_400_BAD_REQUEST); |
600 | } | |
601 | } | |
602 | ||
603 | /** | |
604 | * All static responses will get a cache time of STATIC_FILE_CACHE_TIME seconds | |
605 | */ | |
606 | private IResponse createOkResponseForStaticFiles(byte[] fileContents, String mimeType) { | |
607 | var headers = Map.of( | |
608 | "cache-control", "max-age=" + constants.staticFileCacheTime, | |
609 | "content-type", mimeType); | |
610 | ||
611 |
1
1. createOkResponseForStaticFiles : replaced return value with null for com/renomad/minum/web/WebFramework::createOkResponseForStaticFiles → KILLED |
return Response.buildResponse( |
612 | CODE_200_OK, | |
613 | headers, | |
614 | fileContents); | |
615 | } | |
616 | ||
617 | /** | |
618 | * All static responses will get a cache time of STATIC_FILE_CACHE_TIME seconds | |
619 | */ | |
620 | private IResponse createOkResponseForLargeStaticFiles(String mimeType, Path filePath, Headers requestHeaders) throws IOException { | |
621 | var headers = Map.of( | |
622 | "cache-control", "max-age=" + constants.staticFileCacheTime, | |
623 | "content-type", mimeType, | |
624 | "Accept-Ranges", "bytes" | |
625 | ); | |
626 | ||
627 |
1
1. createOkResponseForLargeStaticFiles : replaced return value with null for com/renomad/minum/web/WebFramework::createOkResponseForLargeStaticFiles → KILLED |
return Response.buildLargeFileResponse( |
628 | headers, | |
629 | filePath.toString(), | |
630 | requestHeaders | |
631 | ); | |
632 | } | |
633 | ||
634 | ||
635 | /** | |
636 | * These are the default starting values for mappings | |
637 | * between file suffixes and appropriate mime types | |
638 | */ | |
639 | private void addDefaultValuesForMimeMap() { | |
640 | fileSuffixToMime.put("css", "text/css"); | |
641 | fileSuffixToMime.put("js", "application/javascript"); | |
642 | fileSuffixToMime.put("webp", "image/webp"); | |
643 | fileSuffixToMime.put("jpg", "image/jpeg"); | |
644 | fileSuffixToMime.put("jpeg", "image/jpeg"); | |
645 | fileSuffixToMime.put("htm", "text/html"); | |
646 | fileSuffixToMime.put("html", "text/html"); | |
647 | } | |
648 | ||
649 | ||
650 | /** | |
651 | * let's see if we can match the registered paths against a **portion** of the startline | |
652 | */ | |
653 | ThrowingFunction<IRequest, IResponse> findHandlerByPartialMatch(RequestLine sl) { | |
654 | String requestedPath = sl.getPathDetails().getIsolatedPath(); | |
655 | var methodPathFunctionEntry = registeredPartialPaths.entrySet().stream() | |
656 |
2
1. lambda$findHandlerByPartialMatch$32 : replaced boolean return with true for com/renomad/minum/web/WebFramework::lambda$findHandlerByPartialMatch$32 → KILLED 2. lambda$findHandlerByPartialMatch$32 : negated conditional → KILLED |
.filter(x -> requestedPath.startsWith(x.getKey().path()) && |
657 |
1
1. lambda$findHandlerByPartialMatch$32 : negated conditional → KILLED |
x.getKey().method().equals(sl.getMethod())) |
658 | .findFirst().orElse(null); | |
659 |
1
1. findHandlerByPartialMatch : negated conditional → KILLED |
if (methodPathFunctionEntry != null) { |
660 |
1
1. findHandlerByPartialMatch : replaced return value with null for com/renomad/minum/web/WebFramework::findHandlerByPartialMatch → KILLED |
return methodPathFunctionEntry.getValue(); |
661 | } else { | |
662 | return null; | |
663 | } | |
664 | } | |
665 | ||
666 | /** | |
667 | * This constructor is used for the real production system | |
668 | */ | |
669 | WebFramework(Context context) { | |
670 | this(context, null, null); | |
671 | } | |
672 | ||
673 | WebFramework(Context context, ZonedDateTime overrideForDateTime) { | |
674 | this(context, overrideForDateTime, null); | |
675 | } | |
676 | ||
677 | /** | |
678 | * This provides the ZonedDateTime as a parameter so we | |
679 | * can set the current date (for testing purposes) | |
680 | * @param overrideForDateTime for those test cases where we need to control the time | |
681 | */ | |
682 | WebFramework(Context context, ZonedDateTime overrideForDateTime, IFileReader fileReader) { | |
683 | this.fs = context.getFullSystem(); | |
684 | this.logger = context.getLogger(); | |
685 | this.constants = context.getConstants(); | |
686 | this.overrideForDateTime = overrideForDateTime; | |
687 | this.registeredDynamicPaths = new HashMap<>(); | |
688 | this.registeredPartialPaths = new HashMap<>(); | |
689 | this.underInvestigation = new UnderInvestigation(constants); | |
690 | this.inputStreamUtils = new InputStreamUtils(constants.maxReadLineSizeBytes); | |
691 | this.bodyProcessor = new BodyProcessor(context); | |
692 | ||
693 | // This random value is purely to help provide correlation between | |
694 | // error messages in the UI and error logs. There are no security concerns. | |
695 | this.randomErrorCorrelationId = new Random(); | |
696 | this.emptyRequestLine = RequestLine.EMPTY; | |
697 | ||
698 | // this allows us to inject a IFileReader for deeper testing | |
699 |
1
1. <init> : negated conditional → KILLED |
if (fileReader != null) { |
700 | this.fileReader = fileReader; | |
701 | } else { | |
702 | this.fileReader = new FileReader( | |
703 | LRUCache.getLruCache(constants.maxElementsLruCacheStaticFiles), | |
704 | constants.useCacheForStaticFiles, | |
705 | logger); | |
706 | } | |
707 | this.fileSuffixToMime = new HashMap<>(); | |
708 |
1
1. <init> : removed call to com/renomad/minum/web/WebFramework::addDefaultValuesForMimeMap → KILLED |
addDefaultValuesForMimeMap(); |
709 |
1
1. <init> : removed call to com/renomad/minum/web/WebFramework::readExtraMimeMappings → SURVIVED |
readExtraMimeMappings(constants.extraMimeMappings); |
710 | } | |
711 | ||
712 | void readExtraMimeMappings(List<String> input) { | |
713 |
2
1. readExtraMimeMappings : negated conditional → KILLED 2. readExtraMimeMappings : negated conditional → KILLED |
if (input == null || input.isEmpty()) return; |
714 | mustBeTrue(input.size() % 2 == 0, "input must be even (key + value = 2 items). Your input: " + input); | |
715 | ||
716 |
2
1. readExtraMimeMappings : negated conditional → KILLED 2. readExtraMimeMappings : changed conditional boundary → KILLED |
for (int i = 0; i < input.size(); i += 2) { |
717 | String fileSuffix = input.get(i); | |
718 |
1
1. readExtraMimeMappings : Replaced integer addition with subtraction → KILLED |
String mime = input.get(i+1); |
719 | logger.logTrace(() -> "Adding mime mapping: " + fileSuffix + " -> " + mime); | |
720 | fileSuffixToMime.put(fileSuffix, mime); | |
721 | } | |
722 | } | |
723 | ||
724 | /** | |
725 | * Add a new handler in the web application for a combination | |
726 | * of a {@link RequestLine.Method}, a path, and then provide | |
727 | * the code to handle a request. | |
728 | * <br> | |
729 | * Note that the path text expected is *after* the first forward slash, | |
730 | * so for example with {@code http://foo.com/mypath}, provide "mypath" as the path. | |
731 | * @throws WebServerException if duplicate paths are registered, or if the path is prefixed with a slash | |
732 | */ | |
733 | public void registerPath(RequestLine.Method method, String pathName, ThrowingFunction<IRequest, IResponse> webHandler) { | |
734 |
2
1. registerPath : negated conditional → KILLED 2. registerPath : negated conditional → KILLED |
if (pathName.startsWith("\\") || pathName.startsWith("/")) { |
735 | throw new WebServerException( | |
736 | String.format("Path should not be prefixed with a slash. Corrected version: registerPath(%s, \"%s\", ... )", method.name(), pathName.substring(1))); | |
737 | } | |
738 | ||
739 | var result = registeredDynamicPaths.put(new MethodPath(method, pathName), webHandler); | |
740 |
1
1. registerPath : negated conditional → KILLED |
if (result != null) { |
741 | throw new WebServerException("Duplicate endpoint registered: " + new MethodPath(method, pathName)); | |
742 | } | |
743 | } | |
744 | ||
745 | /** | |
746 | * Similar to {@link WebFramework#registerPath(RequestLine.Method, String, ThrowingFunction)} except that the paths | |
747 | * registered here may be partially matched. | |
748 | * <p> | |
749 | * For example, if you register {@code .well-known/acme-challenge} then it | |
750 | * can match a client request for {@code .well-known/acme-challenge/HGr8U1IeTW4kY_Z6UIyaakzOkyQgPr_7ArlLgtZE8SX} | |
751 | * </p> | |
752 | * <p> | |
753 | * Be careful here, be thoughtful - partial paths will match a lot, and may | |
754 | * overlap with other URL's for your app, such as endpoints and static files. | |
755 | * </p> | |
756 | * @throws WebServerException if duplicate paths are registered, or if the path is prefixed with a slash | |
757 | */ | |
758 | public void registerPartialPath(RequestLine.Method method, String pathName, ThrowingFunction<IRequest, IResponse> webHandler) { | |
759 |
2
1. registerPartialPath : negated conditional → KILLED 2. registerPartialPath : negated conditional → KILLED |
if (pathName.startsWith("\\") || pathName.startsWith("/")) { |
760 | throw new WebServerException( | |
761 | String.format("Path should not be prefixed with a slash. Corrected version: registerPartialPath(%s, \"%s\", ... )", method.name(), pathName.substring(1))); | |
762 | } | |
763 | var result = registeredPartialPaths.put(new MethodPath(method, pathName), webHandler); | |
764 |
1
1. registerPartialPath : negated conditional → KILLED |
if (result != null) { |
765 | throw new WebServerException("Duplicate partial-path endpoint registered: " + new MethodPath(method, pathName)); | |
766 | } | |
767 | } | |
768 | ||
769 | /** | |
770 | * Sets a handler to process all requests across the board. | |
771 | * <br> | |
772 | * <p> | |
773 | * This is an <b>unusual</b> method. Setting a handler here allows the user to run code of his | |
774 | * choosing before the regular business code is run. Note that by defining this value, the ordinary | |
775 | * call to endpoint.apply(request) will not be run. | |
776 | * </p> | |
777 | * <p>Here is an example</p> | |
778 | * <pre>{@code | |
779 | * | |
780 | * webFramework.registerPreHandler(preHandlerInputs -> preHandlerCode(preHandlerInputs, auth, context)); | |
781 | * | |
782 | * ... | |
783 | * | |
784 | * private IResponse preHandlerCode(PreHandlerInputs preHandlerInputs, AuthUtils auth, Context context) throws Exception { | |
785 | * int secureServerPort = context.getConstants().secureServerPort; | |
786 | * Request request = preHandlerInputs.clientRequest(); | |
787 | * ThrowingFunction<IRequest, IResponse> endpoint = preHandlerInputs.endpoint(); | |
788 | * ISocketWrapper sw = preHandlerInputs.sw(); | |
789 | * | |
790 | * // log all requests | |
791 | * logger.logTrace(() -> String.format("Request: %s by %s", | |
792 | * request.requestLine().getRawValue(), | |
793 | * request.remoteRequester()) | |
794 | * ); | |
795 | * | |
796 | * // redirect to https if they are on the plain-text connection and the path is "login" | |
797 | * | |
798 | * // get the path from the request line | |
799 | * String path = request.getRequestLine().getPathDetails().getIsolatedPath(); | |
800 | * | |
801 | * // redirect to https on the configured secure port if they are on the plain-text connection and the path contains "login" | |
802 | * if (path.contains("login") && | |
803 | * sw.getServerType().equals(HttpServerType.PLAIN_TEXT_HTTP)) { | |
804 | * return Response.redirectTo("https://%s:%d/%s".formatted(sw.getHostName(), secureServerPort, path)); | |
805 | * } | |
806 | * | |
807 | * // adjust behavior if non-authenticated and path includes "secure/" | |
808 | * if (path.contains("secure/")) { | |
809 | * AuthResult authResult = auth.processAuth(request); | |
810 | * if (authResult.isAuthenticated()) { | |
811 | * return endpoint.apply(request); | |
812 | * } else { | |
813 | * return Response.buildLeanResponse(CODE_403_FORBIDDEN); | |
814 | * } | |
815 | * } | |
816 | * | |
817 | * // if the path does not include /secure, just move the request along unchanged. | |
818 | * return endpoint.apply(request); | |
819 | * } | |
820 | * }</pre> | |
821 | */ | |
822 | public void registerPreHandler(ThrowingFunction<PreHandlerInputs, IResponse> preHandler) { | |
823 | this.preHandler = preHandler; | |
824 | } | |
825 | ||
826 | /** | |
827 | * Sets a handler to be executed after running the ordinary handler, just | |
828 | * before sending the response. | |
829 | * <p> | |
830 | * This is an <b>unusual</b> method, so please be aware of its proper use. Its | |
831 | * purpose is to allow the user to inject code to run after ordinary code, across | |
832 | * all requests. | |
833 | * </p> | |
834 | * <p> | |
835 | * For example, if the system would have returned a 404 NOT FOUND response, | |
836 | * code can handle that situation in a switch case and adjust the response according | |
837 | * to your programming. | |
838 | * </p> | |
839 | * <p>Here is an example</p> | |
840 | * <pre>{@code | |
841 | * | |
842 | * | |
843 | * webFramework.registerLastMinuteHandler(TheRegister::lastMinuteHandlerCode); | |
844 | * | |
845 | * ... | |
846 | * | |
847 | * private static IResponse lastMinuteHandlerCode(LastMinuteHandlerInputs inputs) { | |
848 | * switch (inputs.response().statusCode()) { | |
849 | * case CODE_404_NOT_FOUND -> { | |
850 | * return Response.buildResponse( | |
851 | * CODE_404_NOT_FOUND, | |
852 | * Map.of("Content-Type", "text/html; charset=UTF-8"), | |
853 | * "<p>No document was found</p>")); | |
854 | * } | |
855 | * case CODE_500_INTERNAL_SERVER_ERROR -> { | |
856 | * return Response.buildResponse( | |
857 | * CODE_500_INTERNAL_SERVER_ERROR, | |
858 | * Map.of("Content-Type", "text/html; charset=UTF-8"), | |
859 | * "<p>Server error occurred.</p>" )); | |
860 | * } | |
861 | * default -> { | |
862 | * return inputs.response(); | |
863 | * } | |
864 | * } | |
865 | * } | |
866 | * } | |
867 | * </pre> | |
868 | * @param lastMinuteHandler a function that will take a request and return a response, exactly like | |
869 | * we use in the other registration methods for this class. | |
870 | */ | |
871 | public void registerLastMinuteHandler(ThrowingFunction<LastMinuteHandlerInputs, IResponse> lastMinuteHandler) { | |
872 | this.lastMinuteHandler = lastMinuteHandler; | |
873 | } | |
874 | ||
875 | /** | |
876 | * This allows users to add extra mappings | |
877 | * between file suffixes and mime types, in case | |
878 | * a user needs one that was not provided. | |
879 | * <p> | |
880 | * This is made available through the | |
881 | * web framework. | |
882 | * </p> | |
883 | * <p> | |
884 | * Example: | |
885 | * </p> | |
886 | * <pre> | |
887 | * {@code webFramework.addMimeForSuffix().put("foo","text/foo")} | |
888 | * </pre> | |
889 | */ | |
890 | public void addMimeForSuffix(String suffix, String mimeType) { | |
891 | fileSuffixToMime.put(suffix, mimeType); | |
892 | } | |
893 | } | |
Mutations | ||
51 |
1.1 |
|
101 |
1.1 |
|
112 |
1.1 2.2 |
|
120 |
1.1 |
|
127 |
1.1 |
|
132 |
1.1 |
|
141 |
1.1 |
|
147 |
1.1 |
|
148 |
1.1 |
|
152 |
1.1 |
|
153 |
1.1 |
|
156 |
1.1 |
|
161 |
1.1 |
|
167 |
1.1 |
|
172 |
1.1 |
|
175 |
1.1 |
|
177 |
1.1 |
|
179 |
1.1 |
|
189 |
1.1 2.2 |
|
197 |
1.1 |
|
212 |
1.1 |
|
231 |
1.1 |
|
236 |
1.1 |
|
256 |
1.1 |
|
260 |
1.1 |
|
279 |
1.1 |
|
287 |
1.1 |
|
289 |
1.1 |
|
290 |
1.1 |
|
294 |
1.1 2.2 |
|
306 |
1.1 |
|
312 |
1.1 |
|
324 |
1.1 |
|
325 |
1.1 |
|
326 |
1.1 |
|
327 |
1.1 |
|
329 |
1.1 |
|
330 |
1.1 |
|
336 |
1.1 |
|
351 |
1.1 |
|
353 |
1.1 2.2 3.3 |
|
357 |
1.1 2.2 3.3 4.4 5.5 |
|
360 |
1.1 |
|
369 |
1.1 |
|
384 |
1.1 |
|
393 |
1.1 |
|
402 |
1.1 2.2 |
|
405 |
1.1 2.2 |
|
416 |
1.1 |
|
444 |
1.1 2.2 |
|
446 |
1.1 |
|
449 |
1.1 |
|
450 |
1.1 |
|
453 |
1.1 |
|
468 |
1.1 2.2 |
|
486 |
1.1 |
|
487 |
1.1 2.2 3.3 4.4 |
|
490 |
1.1 |
|
492 |
1.1 |
|
509 |
1.1 |
|
514 |
1.1 |
|
519 |
1.1 |
|
525 |
1.1 |
|
533 |
1.1 2.2 |
|
538 |
1.1 2.2 |
|
554 |
1.1 |
|
557 |
1.1 |
|
562 |
1.1 |
|
565 |
1.1 |
|
570 |
1.1 |
|
572 |
1.1 |
|
578 |
1.1 2.2 |
|
579 |
1.1 |
|
586 |
1.1 |
|
590 |
1.1 2.2 |
|
592 |
1.1 |
|
594 |
1.1 |
|
599 |
1.1 |
|
611 |
1.1 |
|
627 |
1.1 |
|
656 |
1.1 2.2 |
|
657 |
1.1 |
|
659 |
1.1 |
|
660 |
1.1 |
|
699 |
1.1 |
|
708 |
1.1 |
|
709 |
1.1 |
|
713 |
1.1 2.2 |
|
716 |
1.1 2.2 |
|
718 |
1.1 |
|
734 |
1.1 2.2 |
|
740 |
1.1 |
|
759 |
1.1 2.2 |
|
764 |
1.1 |