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             if (port != -1) {
278                 httpsConnector.setPort(port);
279             }
280             server.addConnector(httpsConnector);
281             try {
282                 httpsConnector.start();
283             } catch (Exception e) {
284                 throw new IllegalStateException(e);
285             }
286         }
287         return this;
288     }
289 
290     private SslContextFactory.Server createServerSslContextFactory(boolean needClientAuth) {
291         SslContextFactory.Server ssl = new SslContextFactory.Server();
292         ssl.setSniRequired(false);
293         ssl.setKeyStorePath(
294                 HttpTransporterTest.SERVER_STORE_PATH.toAbsolutePath().toString());
295         ssl.setKeyStorePassword("server-pwd");
296         ssl.setNeedClientAuth(needClientAuth);
297         if (needClientAuth) {
298             ssl.setTrustStorePath(
299                     HttpTransporterTest.CLIENT_STORE_PATH.toAbsolutePath().toString());
300             ssl.setTrustStorePassword("client-pwd");
301         }
302         return ssl;
303     }
304 
305     public HttpServer addHttp3Connector(boolean needClientAuth) {
306         return addHttp3Connector(needClientAuth, -1);
307     }
308 
309     public HttpServer addHttp3Connector(boolean needClientAuth, int port) {
310         if (http3Connector == null) {
311             QuicheServerQuicConfiguration serverQuicConfig = HTTP3ServerQuicConfiguration.configure(
312                     new QuicheServerQuicConfiguration(HttpTransporterTest.PEM_QUICHE_SERVER_PATH));
313             http3Connector = new QuicheServerConnector(
314                     server,
315                     createServerSslContextFactory(needClientAuth),
316                     serverQuicConfig,
317                     new HTTP3ServerConnectionFactory());
318             if (port != -1) {
319                 http3Connector.setPort(port);
320             }
321             server.addConnector(http3Connector);
322             try {
323                 http3Connector.start();
324             } catch (Exception e) {
325                 throw new IllegalStateException(e);
326             }
327         }
328         return this;
329     }
330 
331     /**
332      * Finds a port that is free for both TCP and UDP, so that the HTTP/2 (TCP) and HTTP/3 (UDP) connectors can be
333      * bound to the same port number. TCP and UDP port spaces are independent, so an OS-assigned TCP port may have its
334      * same-numbered UDP port already taken by another process, which makes binding HTTP/3 to the HTTPS port flaky.
335      *
336      * @return a port number that is (at probe time) free for both TCP and UDP
337      */
338     int findFreeTcpAndUdpPort() {
339         for (int i = 0; i < 20; i++) {
340             int port;
341             try (ServerSocket serverSocket = new ServerSocket(0)) {
342                 port = serverSocket.getLocalPort();
343             } catch (IOException e) {
344                 throw new IllegalStateException("Failed to find a free TCP port", e);
345             }
346             if (isUdpPortFree(port)) {
347                 return port;
348             }
349         }
350         throw new IllegalStateException("Failed to find a port free for both TCP and UDP");
351     }
352 
353     private static boolean isUdpPortFree(int port) {
354         try (DatagramSocket socket = new DatagramSocket(null)) {
355             socket.bind(new InetSocketAddress(port));
356             return true;
357         } catch (IOException e) {
358             return false;
359         }
360     }
361 
362     public List<LogEntry> getLogEntries() {
363         return logEntries;
364     }
365 
366     public HttpServer setRepoDir(File repoDir) {
367         this.repoDir = repoDir;
368         return this;
369     }
370 
371     public HttpServer setRangeSupport(boolean rangeSupport) {
372         this.rangeSupport = rangeSupport;
373         return this;
374     }
375 
376     public HttpServer setResponseBodyForPut(String body) {
377         this.responseBodyForPut = body;
378         return this;
379     }
380 
381     public HttpServer setWebDav(boolean webDav) {
382         this.webDav = webDav;
383         return this;
384     }
385 
386     public HttpServer setExpectSupport(ExpectContinue expectContinue) {
387         this.expectContinue = expectContinue;
388         return this;
389     }
390 
391     public HttpServer setChecksumHeader(ChecksumHeader checksumHeader) {
392         this.checksumHeader = checksumHeader;
393         return this;
394     }
395 
396     public HttpServer setAuthentication(String username, String password) {
397         this.username = username;
398         this.password = password;
399         return this;
400     }
401 
402     public HttpServer setProxyAuthentication(String username, String password) {
403         proxyUsername = username;
404         proxyPassword = password;
405         return this;
406     }
407 
408     public HttpServer setConnectionsToClose(int connectionsToClose) {
409         this.connectionsToClose.set(connectionsToClose);
410         return this;
411     }
412 
413     public HttpServer setServerErrorsBeforeWorks(int serverErrorsBeforeWorks) {
414         return setServerErrorsBeforeWorks(serverErrorsBeforeWorks, HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
415     }
416 
417     public HttpServer setServerErrorsBeforeWorks(int serverErrorsBeforeWorks, int errorStatusCode) {
418         return setServerErrorsBeforeWorks(serverErrorsBeforeWorks, errorStatusCode, Collections.emptyMap());
419     }
420 
421     public HttpServer setServerErrorsBeforeWorks(
422             int serverErrorsBeforeWorks, int errorStatusCode, Map<String, String> headers) {
423         this.serverErrorsBeforeWorks.set(serverErrorsBeforeWorks);
424         this.serverErrorStatusCode = errorStatusCode;
425         this.serverErrorHeaders = headers;
426         return this;
427     }
428 
429     public HttpServer start() throws Exception {
430         if (server != null) {
431             return this;
432         }
433 
434         server = new Server();
435         httpConnector = new ServerConnector(server);
436         // always add the HTTP 1.1 connector
437         server.addConnector(httpConnector);
438 
439         server.setHandler(new LogHandler(new CompressionEnforcingHandler(new Handler.Sequence(
440                 new ConnectionClosingHandler(),
441                 new ServerErrorHandler(),
442                 new ProxyAuthHandler(),
443                 new AuthHandler(),
444                 new RedirectHandler(),
445                 new RepoHandler(),
446                 new RFC9457Handler()))));
447         server.start();
448 
449         return this;
450     }
451 
452     public void stop() throws Exception {
453         if (server != null) {
454             server.stop();
455             server = null;
456             httpConnector = null;
457             httpsConnector = null;
458         }
459     }
460 
461     public int getNumConnectedEndPoints() {
462         if (server.isStopped()) {
463             throw new IllegalStateException("Server is stopped");
464         }
465         Collection<EndPoint> connectedEndPoints = new ArrayList<>();
466         if (httpConnector != null) {
467             connectedEndPoints.addAll(httpConnector.getConnectedEndPoints());
468         }
469         if (httpsConnector != null) {
470             connectedEndPoints.addAll(httpsConnector.getConnectedEndPoints());
471         }
472         if (http3Connector != null) {
473             // filter out the always present DatagramChannelEndPoint, which is not a real live connection
474             // (https://github.com/jetty/jetty.project/issues/15436)
475             http3Connector.getConnectedEndPoints().stream()
476                     .filter(endPoint -> !(endPoint instanceof DatagramChannelEndPoint))
477                     .forEach(connectedEndPoints::add);
478         }
479         return connectedEndPoints.size();
480     }
481 
482     private class CompressionEnforcingHandler extends CompressionHandler {
483         // duplicate of CompressionHandler.pathConfigs which is private
484         private final PathMappings<CompressionConfig> pathConfigs = new PathMappings<>();
485 
486         CompressionEnforcingHandler(Handler handler) {
487             super(handler);
488             this.putConfiguration(
489                     "/br/*",
490                     CompressionConfig.builder().compressIncludeEncoding("br").build());
491             this.putConfiguration(
492                     "/zstd/*",
493                     CompressionConfig.builder().compressIncludeEncoding("zstd").build());
494             this.putConfiguration(
495                     "/gzip/*",
496                     CompressionConfig.builder().compressIncludeEncoding("gzip").build());
497             this.putConfiguration(
498                     "/deflate/*",
499                     CompressionConfig.builder()
500                             .compressIncludeEncoding("deflate")
501                             .build());
502         }
503 
504         @Override
505         public CompressionConfig putConfiguration(PathSpec pathSpec, CompressionConfig config) {
506             // deliberately not set it in the super class yet
507             return pathConfigs.put(pathSpec, config);
508         }
509 
510         @Override
511         public boolean handle(Request request, Response response, Callback callback) throws Exception {
512             Handler next = getHandler();
513             if (next == null) {
514                 return false;
515             }
516             String pathInContext = Request.getPathInContext(request);
517             MatchedResource<CompressionConfig> matchedConfig = this.pathConfigs.getMatched(pathInContext);
518             if (matchedConfig == null) {
519                 if (LOGGER.isDebugEnabled()) {
520                     LOGGER.debug("skipping compression: path {} has no matching compression config", pathInContext);
521                 }
522                 // No configuration, skip
523                 return next.handle(request, response, callback);
524             }
525 
526             // set the matched config in the super class for further processing, but for all paths
527             // no need to reset it later as this handler is not used among multiple requests
528             super.putConfiguration(PathSpec.from("/*"), matchedConfig.getResource());
529             // first path segment determines the encoding, remove it from the request path for further processing
530             return super.handle(new StripLeadingPathSegmentsRequestWrapper(request, 1), response, callback);
531         }
532     }
533 
534     private static class StripLeadingPathSegmentsRequestWrapper extends Request.Wrapper {
535         private final HttpURI modifiedURI;
536 
537         StripLeadingPathSegmentsRequestWrapper(Request wrapped, int segmentsToStrip) {
538             super(wrapped);
539             this.modifiedURI = stripPathSegments(wrapped.getHttpURI(), segmentsToStrip);
540         }
541 
542         private static HttpURI stripPathSegments(HttpURI originalURI, int segmentsToStrip) {
543             if (segmentsToStrip <= 0) {
544                 return originalURI;
545             }
546 
547             String originalPath = originalURI.getPath();
548             if (originalPath == null || originalPath.isEmpty()) {
549                 return originalURI;
550             }
551 
552             // Split path into segments
553             String[] segments = originalPath.split("/");
554             StringBuilder newPath = new StringBuilder();
555 
556             // Skip empty first segment (from leading /) and the specified number of segments
557             int skipCount = 0;
558             for (int i = 0; i < segments.length; i++) {
559                 if (segments[i].isEmpty() && i == 0) {
560                     // Skip leading empty segment from leading /
561                     continue;
562                 }
563                 if (skipCount < segmentsToStrip) {
564                     skipCount++;
565                     continue;
566                 }
567                 newPath.append("/").append(segments[i]);
568             }
569 
570             // If we stripped everything, return root path
571             if (newPath.isEmpty()) {
572                 newPath.append("/");
573             }
574 
575             // Build new URI with modified path
576             return org.eclipse.jetty.http.HttpURI.build(originalURI)
577                     .path(newPath.toString())
578                     .asImmutable();
579         }
580 
581         @Override
582         public HttpURI getHttpURI() {
583             return modifiedURI;
584         }
585     }
586 
587     private class ConnectionClosingHandler extends Handler.Abstract {
588 
589         @Override
590         public boolean handle(Request request, Response response, Callback callback) throws Exception {
591             if (connectionsToClose.getAndDecrement() > 0) {
592                 request.getConnectionMetaData().getConnection().close();
593             }
594             return false;
595         }
596     }
597 
598     private class ServerErrorHandler extends Handler.Abstract {
599         @Override
600         public boolean handle(Request request, Response response, Callback callback) throws IOException {
601             if (serverErrorsBeforeWorks.getAndDecrement() > 0) {
602                 response.setStatus(serverErrorStatusCode);
603                 for (Map.Entry<String, String> header : serverErrorHeaders.entrySet()) {
604                     response.getHeaders().add(header.getKey(), header.getValue());
605                 }
606                 writeResponseBodyMessage(request, response, "Oops, come back later!");
607                 return true;
608             }
609             return false;
610         }
611     }
612 
613     private class LogHandler extends Handler.Wrapper {
614 
615         LogHandler(Handler handler) {
616             super(handler);
617         }
618 
619         @Override
620         public boolean handle(Request req, Response response, Callback callback) throws Exception {
621 
622             LOGGER.info(
623                     "{} {} {}{}",
624                     req.getConnectionMetaData().getHttpVersion(),
625                     req.getMethod(),
626                     req.getHttpURI().getDecodedPath(),
627                     req.getHttpURI().getQuery() != null ? "?" + req.getHttpURI().getQuery() : "");
628 
629             Map<String, String> requestHeaders =
630                     toUnmodifiableMap(req.getHeaders()); // capture request headers before other handlers modify them
631             LogEntry logEntry = new LogEntry(
632                     req.getConnectionMetaData().getHttpVersion(),
633                     req.getMethod(),
634                     req.getHttpURI().getPathQuery(),
635                     requestHeaders);
636             logEntries.add(logEntry);
637             // prevent closing the response before logging (assume all writes are synchronous for simplicity)
638             boolean result = super.handle(req, response, callback);
639             // capture response headers after other handlers modified them
640             // at this point in time the connection may have been already closed (i.e. last chunk already sent)
641             logEntry.setResponseHeaders(toUnmodifiableMap(response.getHeaders()));
642             if (result) {
643                 callback.succeeded();
644             }
645             return result;
646         }
647 
648         Map<String, String> toUnmodifiableMap(HttpFields headers) {
649             Map<String, String> map = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
650             for (HttpField header : headers) {
651                 map.put(header.getName(), header.getValueList().stream().collect(Collectors.joining(", ")));
652             }
653             return Collections.unmodifiableMap(map);
654         }
655     }
656 
657     private static final Pattern SIMPLE_RANGE = Pattern.compile("bytes=([0-9])+-");
658 
659     private class RepoHandler extends Handler.Abstract {
660         @Override
661         public boolean handle(Request req, Response response, Callback callback) throws Exception {
662             String path = req.getHttpURI().getDecodedPath().substring(1);
663 
664             if (!path.startsWith("repo/")) {
665                 return false;
666             }
667 
668             if (ExpectContinue.FAIL.equals(expectContinue) && req.getHeaders().get(HttpHeader.EXPECT) != null) {
669                 response.setStatus(HttpServletResponse.SC_EXPECTATION_FAILED);
670                 writeResponseBodyMessage(req, response, "Expectation was set to fail");
671                 return true;
672             }
673 
674             File file = new File(repoDir, path.substring(5));
675             if (HttpMethod.GET.is(req.getMethod()) || HttpMethod.HEAD.is(req.getMethod())) {
676                 if (!file.isFile() || path.endsWith("/")) {
677                     response.setStatus(HttpServletResponse.SC_NOT_FOUND);
678                     writeResponseBodyMessage(req, response, "Not found");
679                     return true;
680                 }
681                 long ifUnmodifiedSince = req.getHeaders().getDateField(HttpHeader.IF_UNMODIFIED_SINCE);
682                 if (ifUnmodifiedSince != -1L && file.lastModified() > ifUnmodifiedSince) {
683                     response.setStatus(HttpServletResponse.SC_PRECONDITION_FAILED);
684                     writeResponseBodyMessage(req, response, "Precondition failed");
685                     return true;
686                 }
687                 long offset = 0L;
688                 String range = req.getHeaders().get(HttpHeader.RANGE);
689                 if (range != null && rangeSupport) {
690                     Matcher m = SIMPLE_RANGE.matcher(range);
691                     if (m.matches()) {
692                         offset = Long.parseLong(m.group(1));
693                         if (offset >= file.length()) {
694                             response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
695                             writeResponseBodyMessage(req, response, "Range not satisfiable");
696                             return true;
697                         }
698                     }
699                     String encoding = req.getHeaders().get(HttpHeader.ACCEPT_ENCODING);
700                     if ((encoding != null && !"identity".equals(encoding)) || ifUnmodifiedSince == -1L) {
701                         response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
702                         return true;
703                     }
704                 }
705                 response.setStatus((offset > 0L) ? HttpServletResponse.SC_PARTIAL_CONTENT : HttpServletResponse.SC_OK);
706                 response.getHeaders().add(HttpHeader.LAST_MODIFIED, DateGenerator.formatDate(file.lastModified()));
707                 response.getHeaders().add(HttpHeader.CONTENT_LENGTH, Long.toString(file.length() - offset));
708                 if (offset > 0L) {
709                     response.getHeaders()
710                             .add(
711                                     HttpHeader.CONTENT_RANGE,
712                                     "bytes " + offset + "-" + (file.length() - 1L) + "/" + file.length());
713                 }
714                 if (checksumHeader != null) {
715                     Map<String, String> checksums = ChecksumAlgorithmHelper.calculate(
716                             file, Collections.singletonList(new Sha1ChecksumAlgorithmFactory()));
717                     if (checksumHeader == ChecksumHeader.NEXUS) {
718                         response.getHeaders().add(HttpHeader.ETAG.asString(), "{SHA1{" + checksums.get("SHA-1") + "}}");
719                     } else if (checksumHeader == ChecksumHeader.XCHECKSUM) {
720                         response.getHeaders().add("x-checksum-sha1", checksums.get(Sha1ChecksumAlgorithmFactory.NAME));
721                     } else if (checksumHeader == ChecksumHeader.XCHECKSUM_GOOGLE) {
722                         response.getHeaders()
723                                 .add("x-goog-meta-checksum-sha1", checksums.get(Sha1ChecksumAlgorithmFactory.NAME));
724                     } else if (checksumHeader == ChecksumHeader.XCHECKSUM_AMAZON) {
725                         response.getHeaders()
726                                 .add("x-amz-meta-checksum-sha1", checksums.get(Sha1ChecksumAlgorithmFactory.NAME));
727                     }
728                 }
729                 if (HttpMethod.HEAD.is(req.getMethod())) {
730                     return true;
731                 }
732                 Content.Source contentSource =
733                         Content.Source.from(new ByteBufferPool.Sized(null), file.toPath(), offset, -1);
734                 try (Blocker.Callback fileReadCallback = Blocker.callback()) {
735                     Content.copy(contentSource, response, fileReadCallback);
736                     fileReadCallback.block();
737                 }
738             } else if (HttpMethod.PUT.is(req.getMethod())) {
739                 if (!webDav) {
740                     file.getParentFile().mkdirs();
741                 }
742                 if (file.getParentFile().exists()) {
743                     try (SeekableByteChannel channel = Files.newByteChannel(
744                                     file.toPath(),
745                                     StandardOpenOption.CREATE,
746                                     StandardOpenOption.WRITE,
747                                     StandardOpenOption.TRUNCATE_EXISTING);
748                             Blocker.Callback fileWriteCallback = Blocker.callback()) {
749                         Content.copy(req, Content.Sink.from(channel), fileWriteCallback);
750                         fileWriteCallback.block();
751                     } catch (IOException e) {
752                         LOGGER.warn("Failed to write file {}", file.getAbsolutePath(), e);
753                         file.delete();
754                         throw e;
755                     }
756                     // optionally add some response body to test that the client can handle it, even though Maven
757                     // Repository Protocol doesn't mention it
758                     if (responseBodyForPut != null) {
759                         writeResponseBodyMessage(req, response, responseBodyForPut);
760                         response.setStatus(HttpServletResponse.SC_CREATED);
761                     } else {
762                         response.setStatus(HttpServletResponse.SC_NO_CONTENT);
763                     }
764                 } else {
765                     response.setStatus(HttpServletResponse.SC_FORBIDDEN);
766                 }
767             } else if (HttpMethod.OPTIONS.is(req.getMethod())) {
768                 if (webDav) {
769                     response.getHeaders().add("DAV", "1,2");
770                 }
771                 response.getHeaders().add(HttpHeader.ALLOW, "GET, PUT, HEAD, OPTIONS");
772                 response.setStatus(HttpServletResponse.SC_OK);
773             } else if (webDav && "MKCOL".equals(req.getMethod())) {
774                 if (file.exists()) {
775                     response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
776                 } else if (file.mkdir()) {
777                     response.setStatus(HttpServletResponse.SC_CREATED);
778                 } else {
779                     response.setStatus(HttpServletResponse.SC_CONFLICT);
780                 }
781             } else {
782                 response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
783             }
784             return true;
785         }
786     }
787 
788     private void writeResponseBodyMessage(Request request, Response response, String message) throws IOException {
789         // write synchronously to avoid closing the response too early
790         try (Blocker.Callback callback = Blocker.callback()) {
791             Content.Sink.write(response, false, message, callback);
792             callback.block();
793         }
794     }
795 
796     private class RFC9457Handler extends Handler.Abstract {
797         @Override
798         public boolean handle(Request req, Response response, Callback callback) throws Exception {
799             String path = req.getHttpURI().getPath().substring(1);
800 
801             if (!path.startsWith("rfc9457/")) {
802                 return false;
803             }
804 
805             if (HttpMethod.GET.is(req.getMethod())) {
806                 response.setStatus(HttpServletResponse.SC_FORBIDDEN);
807                 response.getHeaders().add(HttpHeader.CONTENT_TYPE.asString(), "application/problem+json");
808                 RFC9457Payload rfc9457Payload;
809                 if (path.endsWith("missing_fields.txt")) {
810                     rfc9457Payload = new RFC9457Payload(null, null, null, null, null);
811                 } else {
812                     rfc9457Payload = new RFC9457Payload(
813                             URI.create("https://example.com/probs/out-of-credit"),
814                             HttpServletResponse.SC_FORBIDDEN,
815                             "You do not have enough credit.",
816                             "Your current balance is 30, but that costs 50.",
817                             URI.create("/account/12345/msgs/abc"));
818                 }
819                 writeResponseBodyMessage(req, response, buildRFC9457Message(rfc9457Payload));
820             }
821             return true;
822         }
823     }
824 
825     private String buildRFC9457Message(RFC9457Payload payload) {
826         return new Gson().toJson(payload, RFC9457Payload.class);
827     }
828 
829     private class RedirectHandler extends Handler.Abstract {
830         @Override
831         public boolean handle(Request req, Response response, Callback callback) throws Exception {
832             String path = req.getHttpURI().getPath();
833             if (!path.startsWith("/redirect/")) {
834                 return false;
835             }
836             StringBuilder location = new StringBuilder(128);
837             String scheme = Request.getParameters(req).getValue("scheme");
838             location.append(scheme != null ? scheme : req.getHttpURI().getScheme());
839             location.append("://");
840             location.append(Request.getServerName(req));
841             location.append(":");
842             if ("http".equalsIgnoreCase(scheme)) {
843                 location.append(getHttpPort());
844             } else if ("https".equalsIgnoreCase(scheme)) {
845                 location.append(getHttpsPort());
846             } else {
847                 location.append(Request.getServerPort(req));
848             }
849             location.append("/repo").append(path.substring(9));
850             Response.sendRedirect(
851                     req, response, callback, HttpServletResponse.SC_MOVED_PERMANENTLY, location.toString(), false);
852             return true;
853         }
854     }
855 
856     private class AuthHandler extends Handler.Abstract {
857         @Override
858         public boolean handle(Request request, Response response, Callback callback) throws Exception {
859             if (ExpectContinue.BROKEN.equals(expectContinue)
860                     && "100-continue".equalsIgnoreCase(request.getHeaders().get(HttpHeader.EXPECT))) {
861                 // TODO: what is this for?
862                 Request.asInputStream(request);
863             }
864 
865             if (username != null && password != null) {
866                 if (checkBasicAuth(request.getHeaders().get(HttpHeader.AUTHORIZATION), username, password)) {
867                     return false;
868                 }
869                 response.getHeaders().add(HttpHeader.WWW_AUTHENTICATE, "Basic realm=\"Test-Realm\"");
870                 response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
871                 return true;
872             }
873             return false;
874         }
875     }
876 
877     private class ProxyAuthHandler extends Handler.Abstract {
878         @Override
879         public boolean handle(Request req, Response response, Callback callback) throws Exception {
880             if (proxyUsername != null && proxyPassword != null) {
881                 if (checkBasicAuth(
882                         req.getHeaders().get(HttpHeader.PROXY_AUTHORIZATION), proxyUsername, proxyPassword)) {
883                     return false;
884                 }
885                 response.getHeaders().add(HttpHeader.PROXY_AUTHENTICATE, "basic realm=\"Test-Realm\"");
886                 response.setStatus(HttpServletResponse.SC_PROXY_AUTHENTICATION_REQUIRED);
887                 return true;
888             } else {
889                 return false;
890             }
891         }
892     }
893 
894     static boolean checkBasicAuth(String credentials, String username, String password) {
895         if (credentials != null) {
896             int space = credentials.indexOf(' ');
897             if (space > 0) {
898                 String method = credentials.substring(0, space);
899                 if ("basic".equalsIgnoreCase(method)) {
900                     credentials = credentials.substring(space + 1);
901                     credentials = new String(Base64.getDecoder().decode(credentials), StandardCharsets.ISO_8859_1);
902                     int i = credentials.indexOf(':');
903                     if (i > 0) {
904                         String user = credentials.substring(0, i);
905                         String pass = credentials.substring(i + 1);
906                         if (username.equals(user) && password.equals(pass)) {
907                             return true;
908                         }
909                     }
910                 }
911             }
912         }
913         return false;
914     }
915 }