001/*
002 * Licensed to the Apache Software Foundation (ASF) under one
003 * or more contributor license agreements.  See the NOTICE file
004 * distributed with this work for additional information
005 * regarding copyright ownership.  The ASF licenses this file
006 * to you under the Apache License, Version 2.0 (the
007 * "License"); you may not use this file except in compliance
008 * with the License.  You may obtain a copy of the License at
009 *
010 *   http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing,
013 * software distributed under the License is distributed on an
014 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
015 * KIND, either express or implied.  See the License for the
016 * specific language governing permissions and limitations
017 * under the License.
018 */
019package org.eclipse.aether.internal.test.util.http;
020
021import java.io.File;
022import java.io.IOException;
023import java.net.DatagramSocket;
024import java.net.InetSocketAddress;
025import java.net.ServerSocket;
026import java.net.URI;
027import java.nio.channels.SeekableByteChannel;
028import java.nio.charset.StandardCharsets;
029import java.nio.file.Files;
030import java.nio.file.StandardOpenOption;
031import java.util.ArrayList;
032import java.util.Base64;
033import java.util.Collection;
034import java.util.Collections;
035import java.util.List;
036import java.util.Map;
037import java.util.TreeMap;
038import java.util.concurrent.CountDownLatch;
039import java.util.concurrent.atomic.AtomicInteger;
040import java.util.regex.Matcher;
041import java.util.regex.Pattern;
042import java.util.stream.Collectors;
043
044import com.google.gson.Gson;
045import jakarta.servlet.http.HttpServletResponse;
046import org.eclipse.aether.internal.impl.checksum.Sha1ChecksumAlgorithmFactory;
047import org.eclipse.aether.spi.connector.checksum.ChecksumAlgorithmHelper;
048import org.eclipse.aether.spi.connector.transport.http.RFC9457.RFC9457Payload;
049import org.eclipse.jetty.alpn.server.ALPNServerConnectionFactory;
050import org.eclipse.jetty.compression.server.CompressionConfig;
051import org.eclipse.jetty.compression.server.CompressionHandler;
052import org.eclipse.jetty.http.DateGenerator;
053import org.eclipse.jetty.http.HttpField;
054import org.eclipse.jetty.http.HttpFields;
055import org.eclipse.jetty.http.HttpHeader;
056import org.eclipse.jetty.http.HttpMethod;
057import org.eclipse.jetty.http.HttpURI;
058import org.eclipse.jetty.http.HttpVersion;
059import org.eclipse.jetty.http.pathmap.MatchedResource;
060import org.eclipse.jetty.http.pathmap.PathMappings;
061import org.eclipse.jetty.http.pathmap.PathSpec;
062import org.eclipse.jetty.http2.server.HTTP2ServerConnectionFactory;
063import org.eclipse.jetty.http3.server.HTTP3ServerConnectionFactory;
064import org.eclipse.jetty.http3.server.HTTP3ServerQuicConfiguration;
065import org.eclipse.jetty.io.ByteBufferPool;
066import org.eclipse.jetty.io.Content;
067import org.eclipse.jetty.io.DatagramChannelEndPoint;
068import org.eclipse.jetty.io.EndPoint;
069import org.eclipse.jetty.quic.quiche.server.QuicheServerConnector;
070import org.eclipse.jetty.quic.quiche.server.QuicheServerQuicConfiguration;
071import org.eclipse.jetty.server.Handler;
072import org.eclipse.jetty.server.HttpConfiguration;
073import org.eclipse.jetty.server.HttpConnectionFactory;
074import org.eclipse.jetty.server.Request;
075import org.eclipse.jetty.server.Response;
076import org.eclipse.jetty.server.SecureRequestCustomizer;
077import org.eclipse.jetty.server.Server;
078import org.eclipse.jetty.server.ServerConnector;
079import org.eclipse.jetty.server.SslConnectionFactory;
080import org.eclipse.jetty.util.Blocker;
081import org.eclipse.jetty.util.Callback;
082import org.eclipse.jetty.util.ssl.SslContextFactory;
083import org.slf4j.Logger;
084import org.slf4j.LoggerFactory;
085
086public class HttpServer {
087
088    public static class LogEntry {
089        private final HttpVersion version;
090
091        private final String method;
092
093        private final String path;
094
095        private final Map<String, String> requestHeaders;
096
097        private Map<String, String> responseHeaders;
098
099        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}