View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.eclipse.aether.internal.test.util.http;
20  
21  import java.io.File;
22  import java.io.IOException;
23  import java.net.DatagramSocket;
24  import java.net.InetSocketAddress;
25  import java.net.ServerSocket;
26  import java.net.URI;
27  import java.nio.channels.SeekableByteChannel;
28  import java.nio.charset.StandardCharsets;
29  import java.nio.file.Files;
30  import java.nio.file.StandardOpenOption;
31  import java.util.ArrayList;
32  import java.util.Base64;
33  import java.util.Collection;
34  import java.util.Collections;
35  import java.util.List;
36  import java.util.Map;
37  import java.util.TreeMap;
38  import java.util.concurrent.CountDownLatch;
39  import java.util.concurrent.atomic.AtomicInteger;
40  import java.util.regex.Matcher;
41  import java.util.regex.Pattern;
42  import java.util.stream.Collectors;
43  
44  import com.google.gson.Gson;
45  import jakarta.servlet.http.HttpServletResponse;
46  import org.eclipse.aether.internal.impl.checksum.Sha1ChecksumAlgorithmFactory;
47  import org.eclipse.aether.spi.connector.checksum.ChecksumAlgorithmHelper;
48  import org.eclipse.aether.spi.connector.transport.http.RFC9457.RFC9457Payload;
49  import org.eclipse.jetty.alpn.server.ALPNServerConnectionFactory;
50  import org.eclipse.jetty.compression.server.CompressionConfig;
51  import org.eclipse.jetty.compression.server.CompressionHandler;
52  import org.eclipse.jetty.http.DateGenerator;
53  import org.eclipse.jetty.http.HttpField;
54  import org.eclipse.jetty.http.HttpFields;
55  import org.eclipse.jetty.http.HttpHeader;
56  import org.eclipse.jetty.http.HttpMethod;
57  import org.eclipse.jetty.http.HttpURI;
58  import org.eclipse.jetty.http.HttpVersion;
59  import org.eclipse.jetty.http.pathmap.MatchedResource;
60  import org.eclipse.jetty.http.pathmap.PathMappings;
61  import org.eclipse.jetty.http.pathmap.PathSpec;
62  import org.eclipse.jetty.http2.server.HTTP2ServerConnectionFactory;
63  import org.eclipse.jetty.http3.server.HTTP3ServerConnectionFactory;
64  import org.eclipse.jetty.http3.server.HTTP3ServerQuicConfiguration;
65  import org.eclipse.jetty.io.ByteBufferPool;
66  import org.eclipse.jetty.io.Content;
67  import org.eclipse.jetty.io.DatagramChannelEndPoint;
68  import org.eclipse.jetty.io.EndPoint;
69  import org.eclipse.jetty.quic.quiche.server.QuicheServerConnector;
70  import org.eclipse.jetty.quic.quiche.server.QuicheServerQuicConfiguration;
71  import org.eclipse.jetty.server.Handler;
72  import org.eclipse.jetty.server.HttpConfiguration;
73  import org.eclipse.jetty.server.HttpConnectionFactory;
74  import org.eclipse.jetty.server.Request;
75  import org.eclipse.jetty.server.Response;
76  import org.eclipse.jetty.server.SecureRequestCustomizer;
77  import org.eclipse.jetty.server.Server;
78  import org.eclipse.jetty.server.ServerConnector;
79  import org.eclipse.jetty.server.SslConnectionFactory;
80  import org.eclipse.jetty.util.Blocker;
81  import org.eclipse.jetty.util.Callback;
82  import org.eclipse.jetty.util.ssl.SslContextFactory;
83  import org.slf4j.Logger;
84  import org.slf4j.LoggerFactory;
85  
86  public class HttpServer {
87  
88      public static class LogEntry {
89          private final HttpVersion version;
90  
91          private final String method;
92  
93          private final String path;
94  
95          private final Map<String, String> requestHeaders;
96  
97          private Map<String, String> responseHeaders;
98  
99          CountDownLatch responseHeadersAvailableSignal = new CountDownLatch(1);
100 
101         public LogEntry(HttpVersion version, String method, String path, Map<String, String> requestHeaders) {
102             this.version = version;
103             this.method = method;
104             this.path = path;
105             this.requestHeaders = requestHeaders;
106         }
107 
108         public HttpVersion getVersion() {
109             return version;
110         }
111 
112         public String getMethod() {
113             return method;
114         }
115 
116         public String getPath() {
117             return path;
118         }
119 
120         public Map<String, String> getRequestHeaders() {
121             return requestHeaders;
122         }
123 
124         /**
125          * This method blocks until the response headers are available.
126          * @return the response headers
127          */
128         public Map<String, String> getResponseHeaders() {
129             try {
130                 if (!responseHeadersAvailableSignal.await(30, java.util.concurrent.TimeUnit.SECONDS)) {
131                     throw new IllegalStateException("Timeout waiting for response headers to be available");
132                 }
133             } catch (InterruptedException e) {
134                 Thread.currentThread().interrupt();
135                 throw new IllegalStateException("Interrupted while waiting for response headers to be available", e);
136             }
137             return responseHeaders;
138         }
139 
140         public void setResponseHeaders(Map<String, String> responseHeaders) {
141             this.responseHeaders = responseHeaders;
142             responseHeadersAvailableSignal.countDown();
143         }
144 
145         @Override
146         public String toString() {
147             return version + " " + method + " " + path;
148         }
149     }
150 
151     public enum ExpectContinue {
152         FAIL,
153         PROPER,
154         BROKEN
155     }
156 
157     public enum ChecksumHeader {
158         NEXUS,
159         XCHECKSUM,
160         XCHECKSUM_GOOGLE,
161         XCHECKSUM_AMAZON
162     }
163 
164     private static final Logger LOGGER = LoggerFactory.getLogger(HttpServer.class);
165 
166     private File repoDir;
167 
168     private boolean rangeSupport = true;
169 
170     private boolean webDav;
171 
172     private ExpectContinue expectContinue = ExpectContinue.PROPER;
173 
174     private ChecksumHeader checksumHeader;
175 
176     private Server server;
177 
178     private ServerConnector httpConnector;
179 
180     private ServerConnector httpsConnector;
181 
182     private QuicheServerConnector http3Connector;
183 
184     private String username;
185 
186     private String password;
187 
188     private String proxyUsername;
189 
190     private String proxyPassword;
191 
192     private final AtomicInteger connectionsToClose = new AtomicInteger(0);
193 
194     private final AtomicInteger serverErrorsBeforeWorks = new AtomicInteger(0);
195 
196     private int serverErrorStatusCode;
197 
198     private final List<LogEntry> logEntries = Collections.synchronizedList(new ArrayList<>());
199 
200     private String responseBodyForPut;
201 
202     private Map<String, String> serverErrorHeaders = Collections.emptyMap();
203 
204     public String getHost() {
205         return "localhost";
206     }
207 
208     public int getHttpPort() {
209         return httpConnector != null ? httpConnector.getLocalPort() : -1;
210     }
211 
212     public int getHttpsPort() {
213         return httpsConnector != null ? httpsConnector.getLocalPort() : -1;
214     }
215 
216     public int getHttp3Port() {
217         return http3Connector != null ? http3Connector.getLocalPort() : -1;
218     }
219 
220     public String getHttpUrl() {
221         return "http://" + getHost() + ":" + getHttpPort();
222     }
223 
224     public String getHttpsUrl() {
225         return "https://" + getHost() + ":" + getHttpsPort();
226     }
227 
228     public String getHttp3Url() {
229         return "https://" + getHost() + ":" + getHttp3Port();
230     }
231 
232     public HttpServer addHttp2ConnectorWithMutualTLS() {
233         return addHttp2Connector(true, true, -1);
234     }
235 
236     public HttpServer addHttp2Connector() {
237         return addHttp2Connector(false, true, -1);
238     }
239 
240     public HttpServer addHttp2OnlyConnector() {
241         return addHttp2Connector(false, false, -1);
242     }
243 
244     public HttpServer addHttp2OnlyConnectorWithMutualTLS() {
245         return addHttp2Connector(true, false, -1);
246     }
247 
248     public HttpServer addHttp2OnlyConnectorWithMutualTLS(int port) {
249         return addHttp2Connector(true, false, port);
250     }
251 
252     private HttpServer addHttp2Connector(boolean needClientAuth, boolean needHttp11, int port) {
253         if (httpsConnector == null) {
254             SslContextFactory.Server ssl = createServerSslContextFactory(needClientAuth);
255 
256             HttpConfiguration httpsConfig = new HttpConfiguration();
257             SecureRequestCustomizer customizer = new SecureRequestCustomizer();
258             customizer.setSniHostCheck(false);
259             httpsConfig.addCustomizer(customizer);
260 
261             HttpConnectionFactory http1 = null;
262             if (needHttp11) {
263                 http1 = new HttpConnectionFactory(httpsConfig);
264             }
265 
266             HTTP2ServerConnectionFactory http2 = new HTTP2ServerConnectionFactory(httpsConfig);
267 
268             ALPNServerConnectionFactory alpn = new ALPNServerConnectionFactory();
269             alpn.setDefaultProtocol(http1 != null ? http1.getProtocol() : http2.getProtocol());
270 
271             SslConnectionFactory tls = new SslConnectionFactory(ssl, alpn.getProtocol());
272             if (http1 != null) {
273                 httpsConnector = new ServerConnector(server, tls, alpn, http2, http1);
274             } else {
275                 httpsConnector = new ServerConnector(server, tls, alpn, http2);
276             }
277 
278             if (port != -1) {
279                 httpsConnector.setPort(port);
280             }
281 
282             server.addConnector(httpsConnector);
283             try {
284                 httpsConnector.start();
285             } catch (Exception e) {
286                 throw new IllegalStateException(e);
287             }
288         }
289         return this;
290     }
291 
292     private SslContextFactory.Server createServerSslContextFactory(boolean needClientAuth) {
293         SslContextFactory.Server ssl = new SslContextFactory.Server();
294         ssl.setSniRequired(false);
295         ssl.setKeyStorePath(
296                 HttpTransporterTest.SERVER_STORE_PATH.toAbsolutePath().toString());
297         ssl.setKeyStorePassword("server-pwd");
298         ssl.setNeedClientAuth(needClientAuth);
299         if (needClientAuth) {
300             ssl.setTrustStorePath(
301                     HttpTransporterTest.CLIENT_STORE_PATH.toAbsolutePath().toString());
302             ssl.setTrustStorePassword("client-pwd");
303         }
304         return ssl;
305     }
306 
307     public HttpServer addHttp3Connector(boolean needClientAuth) {
308         return addHttp3Connector(needClientAuth, -1);
309     }
310 
311     public HttpServer addHttp3Connector(boolean needClientAuth, int port) {
312         if (http3Connector == null) {
313             QuicheServerQuicConfiguration serverQuicConfig = HTTP3ServerQuicConfiguration.configure(
314                     new QuicheServerQuicConfiguration(HttpTransporterTest.PEM_QUICHE_SERVER_PATH));
315             http3Connector = new QuicheServerConnector(
316                     server,
317                     createServerSslContextFactory(needClientAuth),
318                     serverQuicConfig,
319                     new HTTP3ServerConnectionFactory());
320             if (port != -1) {
321                 http3Connector.setPort(port);
322             }
323             server.addConnector(http3Connector);
324             try {
325                 http3Connector.start();
326             } catch (Exception e) {
327                 throw new IllegalStateException(e);
328             }
329         }
330         return this;
331     }
332 
333     /**
334      * Finds a port that is free for both TCP and UDP, so that the HTTP/2 (TCP) and HTTP/3 (UDP) connectors can be
335      * bound to the same port number. TCP and UDP port spaces are independent, so an OS-assigned TCP port may have its
336      * same-numbered UDP port already taken by another process, which makes binding HTTP/3 to the HTTPS port flaky.
337      *
338      * @return a port number that is (at probe time) free for both TCP and UDP
339      */
340     int findFreeTcpAndUdpPort() {
341         // 100 retries: 20 was insufficient on busy CI hosts (MRESOLVER-2142)
342         for (int i = 0; i < 100; i++) {
343             int port;
344             try (ServerSocket serverSocket = new ServerSocket(0)) {
345                 port = serverSocket.getLocalPort();
346             } catch (IOException e) {
347                 throw new IllegalStateException("Failed to find a free TCP port", e);
348             }
349             if (isUdpPortFree(port)) {
350                 return port;
351             }
352         }
353         throw new IllegalStateException("Failed to find a port free for both TCP and UDP");
354     }
355 
356     private static boolean isUdpPortFree(int port) {
357         try (DatagramSocket socket = new DatagramSocket(null)) {
358             socket.bind(new InetSocketAddress(port));
359             return true;
360         } catch (IOException e) {
361             return false;
362         }
363     }
364 
365     public List<LogEntry> getLogEntries() {
366         return logEntries;
367     }
368 
369     public HttpServer setRepoDir(File repoDir) {
370         this.repoDir = repoDir;
371         return this;
372     }
373 
374     public HttpServer setRangeSupport(boolean rangeSupport) {
375         this.rangeSupport = rangeSupport;
376         return this;
377     }
378 
379     public HttpServer setResponseBodyForPut(String body) {
380         this.responseBodyForPut = body;
381         return this;
382     }
383 
384     public HttpServer setWebDav(boolean webDav) {
385         this.webDav = webDav;
386         return this;
387     }
388 
389     public HttpServer setExpectSupport(ExpectContinue expectContinue) {
390         this.expectContinue = expectContinue;
391         return this;
392     }
393 
394     public HttpServer setChecksumHeader(ChecksumHeader checksumHeader) {
395         this.checksumHeader = checksumHeader;
396         return this;
397     }
398 
399     public HttpServer setAuthentication(String username, String password) {
400         this.username = username;
401         this.password = password;
402         return this;
403     }
404 
405     public HttpServer setProxyAuthentication(String username, String password) {
406         proxyUsername = username;
407         proxyPassword = password;
408         return this;
409     }
410 
411     public HttpServer setConnectionsToClose(int connectionsToClose) {
412         this.connectionsToClose.set(connectionsToClose);
413         return this;
414     }
415 
416     public HttpServer setServerErrorsBeforeWorks(int serverErrorsBeforeWorks) {
417         return setServerErrorsBeforeWorks(serverErrorsBeforeWorks, HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
418     }
419 
420     public HttpServer setServerErrorsBeforeWorks(int serverErrorsBeforeWorks, int errorStatusCode) {
421         return setServerErrorsBeforeWorks(serverErrorsBeforeWorks, errorStatusCode, Collections.emptyMap());
422     }
423 
424     public HttpServer setServerErrorsBeforeWorks(
425             int serverErrorsBeforeWorks, int errorStatusCode, Map<String, String> headers) {
426         this.serverErrorsBeforeWorks.set(serverErrorsBeforeWorks);
427         this.serverErrorStatusCode = errorStatusCode;
428         this.serverErrorHeaders = headers;
429         return this;
430     }
431 
432     public HttpServer start() throws Exception {
433         if (server != null) {
434             return this;
435         }
436 
437         server = new Server();
438         httpConnector = new ServerConnector(server);
439         // always add the HTTP 1.1 connector
440         server.addConnector(httpConnector);
441 
442         server.setHandler(new LogHandler(new CompressionEnforcingHandler(new Handler.Sequence(
443                 new ConnectionClosingHandler(),
444                 new ServerErrorHandler(),
445                 new ProxyAuthHandler(),
446                 new AuthHandler(),
447                 new RedirectHandler(),
448                 new RepoHandler(),
449                 new RFC9457Handler()))));
450         server.start();
451 
452         return this;
453     }
454 
455     public void stop() throws Exception {
456         if (server != null) {
457             server.stop();
458             server = null;
459             httpConnector = null;
460             httpsConnector = null;
461         }
462     }
463 
464     public int getNumConnectedEndPoints() {
465         if (server.isStopped()) {
466             throw new IllegalStateException("Server is stopped");
467         }
468         Collection<EndPoint> connectedEndPoints = new ArrayList<>();
469         if (httpConnector != null) {
470             connectedEndPoints.addAll(httpConnector.getConnectedEndPoints());
471         }
472         if (httpsConnector != null) {
473             connectedEndPoints.addAll(httpsConnector.getConnectedEndPoints());
474         }
475         if (http3Connector != null) {
476             // filter out the always present DatagramChannelEndPoint, which is not a real live connection
477             // (https://github.com/jetty/jetty.project/issues/15436)
478             http3Connector.getConnectedEndPoints().stream()
479                     .filter(endPoint -> !(endPoint instanceof DatagramChannelEndPoint))
480                     .forEach(connectedEndPoints::add);
481         }
482         return connectedEndPoints.size();
483     }
484 
485     private class CompressionEnforcingHandler extends CompressionHandler {
486         // duplicate of CompressionHandler.pathConfigs which is private
487         private final PathMappings<CompressionConfig> pathConfigs = new PathMappings<>();
488 
489         CompressionEnforcingHandler(Handler handler) {
490             super(handler);
491             this.putConfiguration(
492                     "/br/*",
493                     CompressionConfig.builder().compressIncludeEncoding("br").build());
494             this.putConfiguration(
495                     "/zstd/*",
496                     CompressionConfig.builder().compressIncludeEncoding("zstd").build());
497             this.putConfiguration(
498                     "/gzip/*",
499                     CompressionConfig.builder().compressIncludeEncoding("gzip").build());
500             this.putConfiguration(
501                     "/deflate/*",
502                     CompressionConfig.builder()
503                             .compressIncludeEncoding("deflate")
504                             .build());
505         }
506 
507         @Override
508         public CompressionConfig putConfiguration(PathSpec pathSpec, CompressionConfig config) {
509             // deliberately not set it in the super class yet
510             return pathConfigs.put(pathSpec, config);
511         }
512 
513         @Override
514         public boolean handle(Request request, Response response, Callback callback) throws Exception {
515             Handler next = getHandler();
516             if (next == null) {
517                 return false;
518             }
519             String pathInContext = Request.getPathInContext(request);
520             MatchedResource<CompressionConfig> matchedConfig = this.pathConfigs.getMatched(pathInContext);
521             if (matchedConfig == null) {
522                 if (LOGGER.isDebugEnabled()) {
523                     LOGGER.debug("skipping compression: path {} has no matching compression config", pathInContext);
524                 }
525                 // No configuration, skip
526                 return next.handle(request, response, callback);
527             }
528 
529             // set the matched config in the super class for further processing, but for all paths
530             // no need to reset it later as this handler is not used among multiple requests
531             super.putConfiguration(PathSpec.from("/*"), matchedConfig.getResource());
532             // first path segment determines the encoding, remove it from the request path for further processing
533             return super.handle(new StripLeadingPathSegmentsRequestWrapper(request, 1), response, callback);
534         }
535     }
536 
537     private static class StripLeadingPathSegmentsRequestWrapper extends Request.Wrapper {
538         private final HttpURI modifiedURI;
539 
540         StripLeadingPathSegmentsRequestWrapper(Request wrapped, int segmentsToStrip) {
541             super(wrapped);
542             this.modifiedURI = stripPathSegments(wrapped.getHttpURI(), segmentsToStrip);
543         }
544 
545         private static HttpURI stripPathSegments(HttpURI originalURI, int segmentsToStrip) {
546             if (segmentsToStrip <= 0) {
547                 return originalURI;
548             }
549 
550             String originalPath = originalURI.getPath();
551             if (originalPath == null || originalPath.isEmpty()) {
552                 return originalURI;
553             }
554 
555             // Split path into segments
556             String[] segments = originalPath.split("/");
557             StringBuilder newPath = new StringBuilder();
558 
559             // Skip empty first segment (from leading /) and the specified number of segments
560             int skipCount = 0;
561             for (int i = 0; i < segments.length; i++) {
562                 if (segments[i].isEmpty() && i == 0) {
563                     // Skip leading empty segment from leading /
564                     continue;
565                 }
566                 if (skipCount < segmentsToStrip) {
567                     skipCount++;
568                     continue;
569                 }
570                 newPath.append("/").append(segments[i]);
571             }
572 
573             // If we stripped everything, return root path
574             if (newPath.isEmpty()) {
575                 newPath.append("/");
576             }
577 
578             // Build new URI with modified path
579             return org.eclipse.jetty.http.HttpURI.build(originalURI)
580                     .path(newPath.toString())
581                     .asImmutable();
582         }
583 
584         @Override
585         public HttpURI getHttpURI() {
586             return modifiedURI;
587         }
588     }
589 
590     private class ConnectionClosingHandler extends Handler.Abstract {
591 
592         @Override
593         public boolean handle(Request request, Response response, Callback callback) throws Exception {
594             if (connectionsToClose.getAndDecrement() > 0) {
595                 request.getConnectionMetaData().getConnection().close();
596             }
597             return false;
598         }
599     }
600 
601     private class ServerErrorHandler extends Handler.Abstract {
602         @Override
603         public boolean handle(Request request, Response response, Callback callback) throws IOException {
604             if (serverErrorsBeforeWorks.getAndDecrement() > 0) {
605                 response.setStatus(serverErrorStatusCode);
606                 for (Map.Entry<String, String> header : serverErrorHeaders.entrySet()) {
607                     response.getHeaders().add(header.getKey(), header.getValue());
608                 }
609                 writeResponseBodyMessage(request, response, "Oops, come back later!");
610                 return true;
611             }
612             return false;
613         }
614     }
615 
616     private class LogHandler extends Handler.Wrapper {
617 
618         LogHandler(Handler handler) {
619             super(handler);
620         }
621 
622         @Override
623         public boolean handle(Request req, Response response, Callback callback) throws Exception {
624 
625             LOGGER.info(
626                     "{} {} {}{}",
627                     req.getConnectionMetaData().getHttpVersion(),
628                     req.getMethod(),
629                     req.getHttpURI().getDecodedPath(),
630                     req.getHttpURI().getQuery() != null ? "?" + req.getHttpURI().getQuery() : "");
631 
632             Map<String, String> requestHeaders =
633                     toUnmodifiableMap(req.getHeaders()); // capture request headers before other handlers modify them
634             LogEntry logEntry = new LogEntry(
635                     req.getConnectionMetaData().getHttpVersion(),
636                     req.getMethod(),
637                     req.getHttpURI().getPathQuery(),
638                     requestHeaders);
639             logEntries.add(logEntry);
640             // prevent closing the response before logging (assume all writes are synchronous for simplicity)
641             boolean result = super.handle(req, response, callback);
642             // capture response headers after other handlers modified them
643             // at this point in time the connection may have been already closed (i.e. last chunk already sent)
644             logEntry.setResponseHeaders(toUnmodifiableMap(response.getHeaders()));
645             if (result) {
646                 callback.succeeded();
647             }
648             return result;
649         }
650 
651         Map<String, String> toUnmodifiableMap(HttpFields headers) {
652             Map<String, String> map = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
653             for (HttpField header : headers) {
654                 map.put(header.getName(), header.getValueList().stream().collect(Collectors.joining(", ")));
655             }
656             return Collections.unmodifiableMap(map);
657         }
658     }
659 
660     private static final Pattern SIMPLE_RANGE = Pattern.compile("bytes=([0-9])+-");
661 
662     private class RepoHandler extends Handler.Abstract {
663         @Override
664         public boolean handle(Request req, Response response, Callback callback) throws Exception {
665             String path = req.getHttpURI().getDecodedPath().substring(1);
666 
667             if (!path.startsWith("repo/")) {
668                 return false;
669             }
670 
671             if (ExpectContinue.FAIL.equals(expectContinue) && req.getHeaders().get(HttpHeader.EXPECT) != null) {
672                 response.setStatus(HttpServletResponse.SC_EXPECTATION_FAILED);
673                 writeResponseBodyMessage(req, response, "Expectation was set to fail");
674                 return true;
675             }
676 
677             File file = new File(repoDir, path.substring(5));
678             if (HttpMethod.GET.is(req.getMethod()) || HttpMethod.HEAD.is(req.getMethod())) {
679                 if (!file.isFile() || path.endsWith("/")) {
680                     response.setStatus(HttpServletResponse.SC_NOT_FOUND);
681                     writeResponseBodyMessage(req, response, "Not found");
682                     return true;
683                 }
684 
685                 long ifUnmodifiedSince = req.getHeaders().getDateField(HttpHeader.IF_UNMODIFIED_SINCE);
686                 if (ifUnmodifiedSince != -1L && file.lastModified() > ifUnmodifiedSince) {
687                     response.setStatus(HttpServletResponse.SC_PRECONDITION_FAILED);
688                     writeResponseBodyMessage(req, response, "Precondition failed");
689                     return true;
690                 }
691 
692                 long offset = 0L;
693                 String range = req.getHeaders().get(HttpHeader.RANGE);
694                 if (range != null && rangeSupport) {
695                     Matcher m = SIMPLE_RANGE.matcher(range);
696                     if (m.matches()) {
697                         offset = Long.parseLong(m.group(1));
698                         if (offset >= file.length()) {
699                             response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
700                             writeResponseBodyMessage(req, response, "Range not satisfiable");
701                             return true;
702                         }
703                     }
704                     String encoding = req.getHeaders().get(HttpHeader.ACCEPT_ENCODING);
705                     if ((encoding != null && !"identity".equals(encoding)) || ifUnmodifiedSince == -1L) {
706                         response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
707                         return true;
708                     }
709                 }
710 
711                 response.setStatus((offset > 0L) ? HttpServletResponse.SC_PARTIAL_CONTENT : HttpServletResponse.SC_OK);
712                 response.getHeaders().add(HttpHeader.LAST_MODIFIED, DateGenerator.formatDate(file.lastModified()));
713                 response.getHeaders().add(HttpHeader.CONTENT_LENGTH, Long.toString(file.length() - offset));
714                 if (offset > 0L) {
715                     response.getHeaders()
716                             .add(
717                                     HttpHeader.CONTENT_RANGE,
718                                     "bytes " + offset + "-" + (file.length() - 1L) + "/" + file.length());
719                 }
720                 if (checksumHeader != null) {
721                     Map<String, String> checksums = ChecksumAlgorithmHelper.calculate(
722                             file, Collections.singletonList(new Sha1ChecksumAlgorithmFactory()));
723                     if (checksumHeader == ChecksumHeader.NEXUS) {
724                         response.getHeaders().add(HttpHeader.ETAG.asString(), "{SHA1{" + checksums.get("SHA-1") + "}}");
725                     } else if (checksumHeader == ChecksumHeader.XCHECKSUM) {
726                         response.getHeaders().add("x-checksum-sha1", checksums.get(Sha1ChecksumAlgorithmFactory.NAME));
727                     } else if (checksumHeader == ChecksumHeader.XCHECKSUM_GOOGLE) {
728                         response.getHeaders()
729                                 .add("x-goog-meta-checksum-sha1", checksums.get(Sha1ChecksumAlgorithmFactory.NAME));
730                     } else if (checksumHeader == ChecksumHeader.XCHECKSUM_AMAZON) {
731                         response.getHeaders()
732                                 .add("x-amz-meta-checksum-sha1", checksums.get(Sha1ChecksumAlgorithmFactory.NAME));
733                     }
734                 }
735                 if (HttpMethod.HEAD.is(req.getMethod())) {
736                     return true;
737                 }
738                 Content.Source contentSource =
739                         Content.Source.from(new ByteBufferPool.Sized(null), file.toPath(), offset, -1);
740                 try (Blocker.Callback fileReadCallback = Blocker.callback()) {
741                     Content.copy(contentSource, response, fileReadCallback);
742                     fileReadCallback.block();
743                 }
744             } else if (HttpMethod.PUT.is(req.getMethod())) {
745                 if (!webDav) {
746                     file.getParentFile().mkdirs();
747                 }
748                 if (file.getParentFile().exists()) {
749                     try (SeekableByteChannel channel = Files.newByteChannel(
750                                     file.toPath(),
751                                     StandardOpenOption.CREATE,
752                                     StandardOpenOption.WRITE,
753                                     StandardOpenOption.TRUNCATE_EXISTING);
754                             Blocker.Callback fileWriteCallback = Blocker.callback()) {
755                         Content.copy(req, Content.Sink.from(channel), fileWriteCallback);
756                         fileWriteCallback.block();
757                     } catch (IOException e) {
758                         LOGGER.warn("Failed to write file {}", file.getAbsolutePath(), e);
759                         file.delete();
760                         throw e;
761                     }
762                     // optionally add some response body to test that the client can handle it, even though Maven
763                     // Repository Protocol doesn't mention it
764                     if (responseBodyForPut != null) {
765                         writeResponseBodyMessage(req, response, responseBodyForPut);
766                         response.setStatus(HttpServletResponse.SC_CREATED);
767                     } else {
768                         response.setStatus(HttpServletResponse.SC_NO_CONTENT);
769                     }
770                 } else {
771                     response.setStatus(HttpServletResponse.SC_FORBIDDEN);
772                 }
773             } else if (HttpMethod.OPTIONS.is(req.getMethod())) {
774                 if (webDav) {
775                     response.getHeaders().add("DAV", "1,2");
776                 }
777                 response.getHeaders().add(HttpHeader.ALLOW, "GET, PUT, HEAD, OPTIONS");
778                 response.setStatus(HttpServletResponse.SC_OK);
779             } else if (webDav && "MKCOL".equals(req.getMethod())) {
780                 if (file.exists()) {
781                     response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
782                 } else if (file.mkdir()) {
783                     response.setStatus(HttpServletResponse.SC_CREATED);
784                 } else {
785                     response.setStatus(HttpServletResponse.SC_CONFLICT);
786                 }
787             } else {
788                 response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
789             }
790             return true;
791         }
792     }
793 
794     private void writeResponseBodyMessage(Request request, Response response, String message) throws IOException {
795         // write synchronously to avoid closing the response too early
796         try (Blocker.Callback callback = Blocker.callback()) {
797             Content.Sink.write(response, false, message, callback);
798             callback.block();
799         }
800     }
801 
802     private class RFC9457Handler extends Handler.Abstract {
803         @Override
804         public boolean handle(Request req, Response response, Callback callback) throws Exception {
805             String path = req.getHttpURI().getPath().substring(1);
806 
807             if (!path.startsWith("rfc9457/")) {
808                 return false;
809             }
810 
811             if (HttpMethod.GET.is(req.getMethod())) {
812                 response.setStatus(HttpServletResponse.SC_FORBIDDEN);
813                 response.getHeaders().add(HttpHeader.CONTENT_TYPE.asString(), "application/problem+json");
814                 RFC9457Payload rfc9457Payload;
815                 if (path.endsWith("missing_fields.txt")) {
816                     rfc9457Payload = new RFC9457Payload(null, null, null, null, null);
817                 } else {
818                     rfc9457Payload = new RFC9457Payload(
819                             URI.create("https://example.com/probs/out-of-credit"),
820                             HttpServletResponse.SC_FORBIDDEN,
821                             "You do not have enough credit.",
822                             "Your current balance is 30, but that costs 50.",
823                             URI.create("/account/12345/msgs/abc"));
824                 }
825                 writeResponseBodyMessage(req, response, buildRFC9457Message(rfc9457Payload));
826             }
827             return true;
828         }
829     }
830 
831     private String buildRFC9457Message(RFC9457Payload payload) {
832         return new Gson().toJson(payload, RFC9457Payload.class);
833     }
834 
835     private class RedirectHandler extends Handler.Abstract {
836         @Override
837         public boolean handle(Request req, Response response, Callback callback) throws Exception {
838             String path = req.getHttpURI().getPath();
839             if (!path.startsWith("/redirect/")) {
840                 return false;
841             }
842             StringBuilder location = new StringBuilder(128);
843             String scheme = Request.getParameters(req).getValue("scheme");
844             location.append(scheme != null ? scheme : req.getHttpURI().getScheme());
845             location.append("://");
846             location.append(Request.getServerName(req));
847             location.append(":");
848             if ("http".equalsIgnoreCase(scheme)) {
849                 location.append(getHttpPort());
850             } else if ("https".equalsIgnoreCase(scheme)) {
851                 location.append(getHttpsPort());
852             } else {
853                 location.append(Request.getServerPort(req));
854             }
855             location.append("/repo").append(path.substring(9));
856             Response.sendRedirect(
857                     req, response, callback, HttpServletResponse.SC_MOVED_PERMANENTLY, location.toString(), false);
858             return true;
859         }
860     }
861 
862     private class AuthHandler extends Handler.Abstract {
863         @Override
864         public boolean handle(Request request, Response response, Callback callback) throws Exception {
865             if (ExpectContinue.BROKEN.equals(expectContinue)
866                     && "100-continue".equalsIgnoreCase(request.getHeaders().get(HttpHeader.EXPECT))) {
867                 // TODO: what is this for?
868                 Request.asInputStream(request);
869             }
870 
871             if (username != null && password != null) {
872                 if (checkBasicAuth(request.getHeaders().get(HttpHeader.AUTHORIZATION), username, password)) {
873                     return false;
874                 }
875                 response.getHeaders().add(HttpHeader.WWW_AUTHENTICATE, "Basic realm=\"Test-Realm\"");
876                 response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
877                 return true;
878             }
879             return false;
880         }
881     }
882 
883     private class ProxyAuthHandler extends Handler.Abstract {
884         @Override
885         public boolean handle(Request req, Response response, Callback callback) throws Exception {
886             if (proxyUsername != null && proxyPassword != null) {
887                 if (checkBasicAuth(
888                         req.getHeaders().get(HttpHeader.PROXY_AUTHORIZATION), proxyUsername, proxyPassword)) {
889                     return false;
890                 }
891                 response.getHeaders().add(HttpHeader.PROXY_AUTHENTICATE, "basic realm=\"Test-Realm\"");
892                 response.setStatus(HttpServletResponse.SC_PROXY_AUTHENTICATION_REQUIRED);
893                 return true;
894             } else {
895                 return false;
896             }
897         }
898     }
899 
900     static boolean checkBasicAuth(String credentials, String username, String password) {
901         if (credentials != null) {
902             int space = credentials.indexOf(' ');
903             if (space > 0) {
904                 String method = credentials.substring(0, space);
905                 if ("basic".equalsIgnoreCase(method)) {
906                     credentials = credentials.substring(space + 1);
907                     credentials = new String(Base64.getDecoder().decode(credentials), StandardCharsets.ISO_8859_1);
908                     int i = credentials.indexOf(':');
909                     if (i > 0) {
910                         String user = credentials.substring(0, i);
911                         String pass = credentials.substring(i + 1);
912                         if (username.equals(user) && password.equals(pass)) {
913                             return true;
914                         }
915                     }
916                 }
917             }
918         }
919         return false;
920     }
921 }