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.URI;
024import java.nio.channels.SeekableByteChannel;
025import java.nio.charset.StandardCharsets;
026import java.nio.file.Files;
027import java.nio.file.StandardOpenOption;
028import java.util.ArrayList;
029import java.util.Base64;
030import java.util.Collection;
031import java.util.Collections;
032import java.util.List;
033import java.util.Map;
034import java.util.TreeMap;
035import java.util.concurrent.CountDownLatch;
036import java.util.concurrent.atomic.AtomicInteger;
037import java.util.regex.Matcher;
038import java.util.regex.Pattern;
039import java.util.stream.Collectors;
040
041import com.google.gson.Gson;
042import jakarta.servlet.http.HttpServletResponse;
043import org.eclipse.aether.internal.impl.checksum.Sha1ChecksumAlgorithmFactory;
044import org.eclipse.aether.spi.connector.checksum.ChecksumAlgorithmHelper;
045import org.eclipse.aether.spi.connector.transport.http.RFC9457.RFC9457Payload;
046import org.eclipse.jetty.alpn.server.ALPNServerConnectionFactory;
047import org.eclipse.jetty.compression.server.CompressionConfig;
048import org.eclipse.jetty.compression.server.CompressionHandler;
049import org.eclipse.jetty.http.DateGenerator;
050import org.eclipse.jetty.http.HttpField;
051import org.eclipse.jetty.http.HttpFields;
052import org.eclipse.jetty.http.HttpHeader;
053import org.eclipse.jetty.http.HttpMethod;
054import org.eclipse.jetty.http.HttpURI;
055import org.eclipse.jetty.http.HttpVersion;
056import org.eclipse.jetty.http.pathmap.MatchedResource;
057import org.eclipse.jetty.http.pathmap.PathMappings;
058import org.eclipse.jetty.http.pathmap.PathSpec;
059import org.eclipse.jetty.http2.server.HTTP2ServerConnectionFactory;
060import org.eclipse.jetty.http3.server.HTTP3ServerConnectionFactory;
061import org.eclipse.jetty.http3.server.HTTP3ServerQuicConfiguration;
062import org.eclipse.jetty.io.ByteBufferPool;
063import org.eclipse.jetty.io.Content;
064import org.eclipse.jetty.io.DatagramChannelEndPoint;
065import org.eclipse.jetty.io.EndPoint;
066import org.eclipse.jetty.quic.quiche.server.QuicheServerConnector;
067import org.eclipse.jetty.quic.quiche.server.QuicheServerQuicConfiguration;
068import org.eclipse.jetty.server.Handler;
069import org.eclipse.jetty.server.HttpConfiguration;
070import org.eclipse.jetty.server.HttpConnectionFactory;
071import org.eclipse.jetty.server.Request;
072import org.eclipse.jetty.server.Response;
073import org.eclipse.jetty.server.SecureRequestCustomizer;
074import org.eclipse.jetty.server.Server;
075import org.eclipse.jetty.server.ServerConnector;
076import org.eclipse.jetty.server.SslConnectionFactory;
077import org.eclipse.jetty.util.Blocker;
078import org.eclipse.jetty.util.Callback;
079import org.eclipse.jetty.util.ssl.SslContextFactory;
080import org.slf4j.Logger;
081import org.slf4j.LoggerFactory;
082
083public class HttpServer {
084
085    public static class LogEntry {
086        private final HttpVersion version;
087
088        private final String method;
089
090        private final String path;
091
092        private final Map<String, String> requestHeaders;
093
094        private Map<String, String> responseHeaders;
095
096        CountDownLatch responseHeadersAvailableSignal = new CountDownLatch(1);
097
098        public LogEntry(HttpVersion version, String method, String path, Map<String, String> requestHeaders) {
099            this.version = version;
100            this.method = method;
101            this.path = path;
102            this.requestHeaders = requestHeaders;
103        }
104
105        public HttpVersion getVersion() {
106            return version;
107        }
108
109        public String getMethod() {
110            return method;
111        }
112
113        public String getPath() {
114            return path;
115        }
116
117        public Map<String, String> getRequestHeaders() {
118            return requestHeaders;
119        }
120
121        /**
122         * This method blocks until the response headers are available.
123         * @return the response headers
124         */
125        public Map<String, String> getResponseHeaders() {
126            try {
127                if (!responseHeadersAvailableSignal.await(30, java.util.concurrent.TimeUnit.SECONDS)) {
128                    throw new IllegalStateException("Timeout waiting for response headers to be available");
129                }
130            } catch (InterruptedException e) {
131                Thread.currentThread().interrupt();
132                throw new IllegalStateException("Interrupted while waiting for response headers to be available", e);
133            }
134            return responseHeaders;
135        }
136
137        public void setResponseHeaders(Map<String, String> responseHeaders) {
138            this.responseHeaders = responseHeaders;
139            responseHeadersAvailableSignal.countDown();
140        }
141
142        @Override
143        public String toString() {
144            return version + " " + method + " " + path;
145        }
146    }
147
148    public enum ExpectContinue {
149        FAIL,
150        PROPER,
151        BROKEN
152    }
153
154    public enum ChecksumHeader {
155        NEXUS,
156        XCHECKSUM
157    }
158
159    private static final Logger LOGGER = LoggerFactory.getLogger(HttpServer.class);
160
161    private File repoDir;
162
163    private boolean rangeSupport = true;
164
165    private boolean webDav;
166
167    private ExpectContinue expectContinue = ExpectContinue.PROPER;
168
169    private ChecksumHeader checksumHeader;
170
171    private Server server;
172
173    private ServerConnector httpConnector;
174
175    private ServerConnector httpsConnector;
176
177    private QuicheServerConnector http3Connector;
178
179    private String username;
180
181    private String password;
182
183    private String proxyUsername;
184
185    private String proxyPassword;
186
187    private final AtomicInteger connectionsToClose = new AtomicInteger(0);
188
189    private final AtomicInteger serverErrorsBeforeWorks = new AtomicInteger(0);
190
191    private int serverErrorStatusCode;
192
193    private final List<LogEntry> logEntries = Collections.synchronizedList(new ArrayList<>());
194
195    private String responseBodyForPut;
196
197    public String getHost() {
198        return "localhost";
199    }
200
201    public int getHttpPort() {
202        return httpConnector != null ? httpConnector.getLocalPort() : -1;
203    }
204
205    public int getHttpsPort() {
206        return httpsConnector != null ? httpsConnector.getLocalPort() : -1;
207    }
208
209    public int getHttp3Port() {
210        return http3Connector != null ? http3Connector.getLocalPort() : -1;
211    }
212
213    public String getHttpUrl() {
214        return "http://" + getHost() + ":" + getHttpPort();
215    }
216
217    public String getHttpsUrl() {
218        return "https://" + getHost() + ":" + getHttpsPort();
219    }
220
221    public String getHttp3Url() {
222        return "https://" + getHost() + ":" + getHttp3Port();
223    }
224
225    public HttpServer addHttp2ConnectorWithMutualTLS() {
226        return addHttp2Connector(true, true);
227    }
228
229    public HttpServer addHttp2Connector() {
230        return addHttp2Connector(false, true);
231    }
232
233    public HttpServer addHttp2OnlyConnector() {
234        return addHttp2Connector(false, false);
235    }
236
237    public HttpServer addHttp2OnlyConnectorWithMutualTLS() {
238        return addHttp2Connector(true, false);
239    }
240
241    private HttpServer addHttp2Connector(boolean needClientAuth, boolean needHttp11) {
242        if (httpsConnector == null) {
243            SslContextFactory.Server ssl = createServerSslContextFactory(needClientAuth);
244
245            HttpConfiguration httpsConfig = new HttpConfiguration();
246            SecureRequestCustomizer customizer = new SecureRequestCustomizer();
247            customizer.setSniHostCheck(false);
248            httpsConfig.addCustomizer(customizer);
249
250            HttpConnectionFactory http1 = null;
251            if (needHttp11) {
252                http1 = new HttpConnectionFactory(httpsConfig);
253            }
254
255            HTTP2ServerConnectionFactory http2 = new HTTP2ServerConnectionFactory(httpsConfig);
256
257            ALPNServerConnectionFactory alpn = new ALPNServerConnectionFactory();
258            alpn.setDefaultProtocol(http1 != null ? http1.getProtocol() : http2.getProtocol());
259
260            SslConnectionFactory tls = new SslConnectionFactory(ssl, alpn.getProtocol());
261            if (http1 != null) {
262                httpsConnector = new ServerConnector(server, tls, alpn, http2, http1);
263            } else {
264                httpsConnector = new ServerConnector(server, tls, alpn, http2);
265            }
266            server.addConnector(httpsConnector);
267            try {
268                httpsConnector.start();
269            } catch (Exception e) {
270                throw new IllegalStateException(e);
271            }
272        }
273        return this;
274    }
275
276    private SslContextFactory.Server createServerSslContextFactory(boolean needClientAuth) {
277        SslContextFactory.Server ssl = new SslContextFactory.Server();
278        ssl.setSniRequired(false);
279        ssl.setKeyStorePath(
280                HttpTransporterTest.SERVER_STORE_PATH.toAbsolutePath().toString());
281        ssl.setKeyStorePassword("server-pwd");
282        ssl.setNeedClientAuth(needClientAuth);
283        if (needClientAuth) {
284            ssl.setTrustStorePath(
285                    HttpTransporterTest.CLIENT_STORE_PATH.toAbsolutePath().toString());
286            ssl.setTrustStorePassword("client-pwd");
287        }
288        return ssl;
289    }
290
291    public HttpServer addHttp3Connector(boolean needClientAuth) {
292        return addHttp3Connector(needClientAuth, -1);
293    }
294
295    public HttpServer addHttp3Connector(boolean needClientAuth, int port) {
296        if (http3Connector == null) {
297            QuicheServerQuicConfiguration serverQuicConfig = HTTP3ServerQuicConfiguration.configure(
298                    new QuicheServerQuicConfiguration(HttpTransporterTest.PEM_QUICHE_SERVER_PATH));
299            http3Connector = new QuicheServerConnector(
300                    server,
301                    createServerSslContextFactory(needClientAuth),
302                    serverQuicConfig,
303                    new HTTP3ServerConnectionFactory());
304            if (port != -1) {
305                http3Connector.setPort(port);
306            }
307            server.addConnector(http3Connector);
308            try {
309                http3Connector.start();
310            } catch (Exception e) {
311                throw new IllegalStateException(e);
312            }
313        }
314        return this;
315    }
316
317    public List<LogEntry> getLogEntries() {
318        return logEntries;
319    }
320
321    public HttpServer setRepoDir(File repoDir) {
322        this.repoDir = repoDir;
323        return this;
324    }
325
326    public HttpServer setRangeSupport(boolean rangeSupport) {
327        this.rangeSupport = rangeSupport;
328        return this;
329    }
330
331    public HttpServer setResponseBodyForPut(String body) {
332        this.responseBodyForPut = body;
333        return this;
334    }
335
336    public HttpServer setWebDav(boolean webDav) {
337        this.webDav = webDav;
338        return this;
339    }
340
341    public HttpServer setExpectSupport(ExpectContinue expectContinue) {
342        this.expectContinue = expectContinue;
343        return this;
344    }
345
346    public HttpServer setChecksumHeader(ChecksumHeader checksumHeader) {
347        this.checksumHeader = checksumHeader;
348        return this;
349    }
350
351    public HttpServer setAuthentication(String username, String password) {
352        this.username = username;
353        this.password = password;
354        return this;
355    }
356
357    public HttpServer setProxyAuthentication(String username, String password) {
358        proxyUsername = username;
359        proxyPassword = password;
360        return this;
361    }
362
363    public HttpServer setConnectionsToClose(int connectionsToClose) {
364        this.connectionsToClose.set(connectionsToClose);
365        return this;
366    }
367
368    public HttpServer setServerErrorsBeforeWorks(int serverErrorsBeforeWorks) {
369        return setServerErrorsBeforeWorks(serverErrorsBeforeWorks, HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
370    }
371
372    public HttpServer setServerErrorsBeforeWorks(int serverErrorsBeforeWorks, int errorStatusCode) {
373        this.serverErrorsBeforeWorks.set(serverErrorsBeforeWorks);
374        this.serverErrorStatusCode = errorStatusCode;
375        return this;
376    }
377
378    public HttpServer start() throws Exception {
379        if (server != null) {
380            return this;
381        }
382
383        server = new Server();
384        httpConnector = new ServerConnector(server);
385        // always add the HTTP 1.1 connector
386        server.addConnector(httpConnector);
387
388        server.setHandler(new LogHandler(new CompressionEnforcingHandler(new Handler.Sequence(
389                new ConnectionClosingHandler(),
390                new ServerErrorHandler(),
391                new ProxyAuthHandler(),
392                new AuthHandler(),
393                new RedirectHandler(),
394                new RepoHandler(),
395                new RFC9457Handler()))));
396        server.start();
397
398        return this;
399    }
400
401    public void stop() throws Exception {
402        if (server != null) {
403            server.stop();
404            server = null;
405            httpConnector = null;
406            httpsConnector = null;
407        }
408    }
409
410    public int getNumConnectedEndPoints() {
411        if (server.isStopped()) {
412            throw new IllegalStateException("Server is stopped");
413        }
414        Collection<EndPoint> connectedEndPoints = new ArrayList<>();
415        if (httpConnector != null) {
416            connectedEndPoints.addAll(httpConnector.getConnectedEndPoints());
417        }
418        if (httpsConnector != null) {
419            connectedEndPoints.addAll(httpsConnector.getConnectedEndPoints());
420        }
421        if (http3Connector != null) {
422            // filter out the always present DatagramChannelEndPoint, which is not a real live connection
423            // (https://github.com/jetty/jetty.project/issues/15436)
424            http3Connector.getConnectedEndPoints().stream()
425                    .filter(endPoint -> !(endPoint instanceof DatagramChannelEndPoint))
426                    .forEach(connectedEndPoints::add);
427        }
428        return connectedEndPoints.size();
429    }
430
431    private class CompressionEnforcingHandler extends CompressionHandler {
432        // duplicate of CompressionHandler.pathConfigs which is private
433        private final PathMappings<CompressionConfig> pathConfigs = new PathMappings<>();
434
435        CompressionEnforcingHandler(Handler handler) {
436            super(handler);
437            this.putConfiguration(
438                    "/br/*",
439                    CompressionConfig.builder().compressIncludeEncoding("br").build());
440            this.putConfiguration(
441                    "/zstd/*",
442                    CompressionConfig.builder().compressIncludeEncoding("zstd").build());
443            this.putConfiguration(
444                    "/gzip/*",
445                    CompressionConfig.builder().compressIncludeEncoding("gzip").build());
446            this.putConfiguration(
447                    "/deflate/*",
448                    CompressionConfig.builder()
449                            .compressIncludeEncoding("deflate")
450                            .build());
451        }
452
453        @Override
454        public CompressionConfig putConfiguration(PathSpec pathSpec, CompressionConfig config) {
455            // deliberately not set it in the super class yet
456            return pathConfigs.put(pathSpec, config);
457        }
458
459        @Override
460        public boolean handle(Request request, Response response, Callback callback) throws Exception {
461            Handler next = getHandler();
462            if (next == null) {
463                return false;
464            }
465            String pathInContext = Request.getPathInContext(request);
466            MatchedResource<CompressionConfig> matchedConfig = this.pathConfigs.getMatched(pathInContext);
467            if (matchedConfig == null) {
468                if (LOGGER.isDebugEnabled()) {
469                    LOGGER.debug("skipping compression: path {} has no matching compression config", pathInContext);
470                }
471                // No configuration, skip
472                return next.handle(request, response, callback);
473            }
474
475            // set the matched config in the super class for further processing, but for all paths
476            // no need to reset it later as this handler is not used among multiple requests
477            super.putConfiguration(PathSpec.from("/*"), matchedConfig.getResource());
478            // first path segment determines the encoding, remove it from the request path for further processing
479            return super.handle(new StripLeadingPathSegmentsRequestWrapper(request, 1), response, callback);
480        }
481    }
482
483    private static class StripLeadingPathSegmentsRequestWrapper extends Request.Wrapper {
484        private final HttpURI modifiedURI;
485
486        StripLeadingPathSegmentsRequestWrapper(Request wrapped, int segmentsToStrip) {
487            super(wrapped);
488            this.modifiedURI = stripPathSegments(wrapped.getHttpURI(), segmentsToStrip);
489        }
490
491        private static HttpURI stripPathSegments(HttpURI originalURI, int segmentsToStrip) {
492            if (segmentsToStrip <= 0) {
493                return originalURI;
494            }
495
496            String originalPath = originalURI.getPath();
497            if (originalPath == null || originalPath.isEmpty()) {
498                return originalURI;
499            }
500
501            // Split path into segments
502            String[] segments = originalPath.split("/");
503            StringBuilder newPath = new StringBuilder();
504
505            // Skip empty first segment (from leading /) and the specified number of segments
506            int skipCount = 0;
507            for (int i = 0; i < segments.length; i++) {
508                if (segments[i].isEmpty() && i == 0) {
509                    // Skip leading empty segment from leading /
510                    continue;
511                }
512                if (skipCount < segmentsToStrip) {
513                    skipCount++;
514                    continue;
515                }
516                newPath.append("/").append(segments[i]);
517            }
518
519            // If we stripped everything, return root path
520            if (newPath.isEmpty()) {
521                newPath.append("/");
522            }
523
524            // Build new URI with modified path
525            return org.eclipse.jetty.http.HttpURI.build(originalURI)
526                    .path(newPath.toString())
527                    .asImmutable();
528        }
529
530        @Override
531        public HttpURI getHttpURI() {
532            return modifiedURI;
533        }
534    }
535
536    private class ConnectionClosingHandler extends Handler.Abstract {
537
538        @Override
539        public boolean handle(Request request, Response response, Callback callback) throws Exception {
540            if (connectionsToClose.getAndDecrement() > 0) {
541                request.getConnectionMetaData().getConnection().close();
542            }
543            return false;
544        }
545    }
546
547    private class ServerErrorHandler extends Handler.Abstract {
548        @Override
549        public boolean handle(Request request, Response response, Callback callback) throws IOException {
550            if (serverErrorsBeforeWorks.getAndDecrement() > 0) {
551                response.setStatus(serverErrorStatusCode);
552                writeResponseBodyMessage(request, response, "Oops, come back later!");
553                return true;
554            }
555            return false;
556        }
557    }
558
559    private class LogHandler extends Handler.Wrapper {
560
561        LogHandler(Handler handler) {
562            super(handler);
563        }
564
565        @Override
566        public boolean handle(Request req, Response response, Callback callback) throws Exception {
567
568            LOGGER.info(
569                    "{} {} {}{}",
570                    req.getConnectionMetaData().getHttpVersion(),
571                    req.getMethod(),
572                    req.getHttpURI().getDecodedPath(),
573                    req.getHttpURI().getQuery() != null ? "?" + req.getHttpURI().getQuery() : "");
574
575            Map<String, String> requestHeaders =
576                    toUnmodifiableMap(req.getHeaders()); // capture request headers before other handlers modify them
577            LogEntry logEntry = new LogEntry(
578                    req.getConnectionMetaData().getHttpVersion(),
579                    req.getMethod(),
580                    req.getHttpURI().getPathQuery(),
581                    requestHeaders);
582            logEntries.add(logEntry);
583            // prevent closing the response before logging (assume all writes are synchronous for simplicity)
584            boolean result = super.handle(req, response, callback);
585            // capture response headers after other handlers modified them
586            // at this point in time the connection may have been already closed (i.e. last chunk already sent)
587            logEntry.setResponseHeaders(toUnmodifiableMap(response.getHeaders()));
588            if (result) {
589                callback.succeeded();
590            }
591            return result;
592        }
593
594        Map<String, String> toUnmodifiableMap(HttpFields headers) {
595            Map<String, String> map = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
596            for (HttpField header : headers) {
597                map.put(header.getName(), header.getValueList().stream().collect(Collectors.joining(", ")));
598            }
599            return Collections.unmodifiableMap(map);
600        }
601    }
602
603    private static final Pattern SIMPLE_RANGE = Pattern.compile("bytes=([0-9])+-");
604
605    private class RepoHandler extends Handler.Abstract {
606        @Override
607        public boolean handle(Request req, Response response, Callback callback) throws Exception {
608            String path = req.getHttpURI().getDecodedPath().substring(1);
609
610            if (!path.startsWith("repo/")) {
611                return false;
612            }
613
614            if (ExpectContinue.FAIL.equals(expectContinue) && req.getHeaders().get(HttpHeader.EXPECT) != null) {
615                response.setStatus(HttpServletResponse.SC_EXPECTATION_FAILED);
616                writeResponseBodyMessage(req, response, "Expectation was set to fail");
617                return true;
618            }
619
620            File file = new File(repoDir, path.substring(5));
621            if (HttpMethod.GET.is(req.getMethod()) || HttpMethod.HEAD.is(req.getMethod())) {
622                if (!file.isFile() || path.endsWith("/")) {
623                    response.setStatus(HttpServletResponse.SC_NOT_FOUND);
624                    writeResponseBodyMessage(req, response, "Not found");
625                    return true;
626                }
627                long ifUnmodifiedSince = req.getHeaders().getDateField(HttpHeader.IF_UNMODIFIED_SINCE);
628                if (ifUnmodifiedSince != -1L && file.lastModified() > ifUnmodifiedSince) {
629                    response.setStatus(HttpServletResponse.SC_PRECONDITION_FAILED);
630                    writeResponseBodyMessage(req, response, "Precondition failed");
631                    return true;
632                }
633                long offset = 0L;
634                String range = req.getHeaders().get(HttpHeader.RANGE);
635                if (range != null && rangeSupport) {
636                    Matcher m = SIMPLE_RANGE.matcher(range);
637                    if (m.matches()) {
638                        offset = Long.parseLong(m.group(1));
639                        if (offset >= file.length()) {
640                            response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
641                            writeResponseBodyMessage(req, response, "Range not satisfiable");
642                            return true;
643                        }
644                    }
645                    String encoding = req.getHeaders().get(HttpHeader.ACCEPT_ENCODING);
646                    if ((encoding != null && !"identity".equals(encoding)) || ifUnmodifiedSince == -1L) {
647                        response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
648                        return true;
649                    }
650                }
651                response.setStatus((offset > 0L) ? HttpServletResponse.SC_PARTIAL_CONTENT : HttpServletResponse.SC_OK);
652                response.getHeaders().add(HttpHeader.LAST_MODIFIED, DateGenerator.formatDate(file.lastModified()));
653                response.getHeaders().add(HttpHeader.CONTENT_LENGTH, Long.toString(file.length() - offset));
654                if (offset > 0L) {
655                    response.getHeaders()
656                            .add(
657                                    HttpHeader.CONTENT_RANGE,
658                                    "bytes " + offset + "-" + (file.length() - 1L) + "/" + file.length());
659                }
660                if (checksumHeader != null) {
661                    Map<String, String> checksums = ChecksumAlgorithmHelper.calculate(
662                            file, Collections.singletonList(new Sha1ChecksumAlgorithmFactory()));
663                    if (checksumHeader == ChecksumHeader.NEXUS) {
664                        response.getHeaders().add(HttpHeader.ETAG.asString(), "{SHA1{" + checksums.get("SHA-1") + "}}");
665                    } else if (checksumHeader == ChecksumHeader.XCHECKSUM) {
666                        response.getHeaders().add("x-checksum-sha1", checksums.get(Sha1ChecksumAlgorithmFactory.NAME));
667                    }
668                }
669                if (HttpMethod.HEAD.is(req.getMethod())) {
670                    return true;
671                }
672                Content.Source contentSource =
673                        Content.Source.from(new ByteBufferPool.Sized(null), file.toPath(), offset, -1);
674                try (Blocker.Callback fileReadCallback = Blocker.callback()) {
675                    Content.copy(contentSource, response, fileReadCallback);
676                    fileReadCallback.block();
677                }
678            } else if (HttpMethod.PUT.is(req.getMethod())) {
679                if (!webDav) {
680                    file.getParentFile().mkdirs();
681                }
682                if (file.getParentFile().exists()) {
683                    try (SeekableByteChannel channel = Files.newByteChannel(
684                                    file.toPath(),
685                                    StandardOpenOption.CREATE,
686                                    StandardOpenOption.WRITE,
687                                    StandardOpenOption.TRUNCATE_EXISTING);
688                            Blocker.Callback fileWriteCallback = Blocker.callback()) {
689                        Content.copy(req, Content.Sink.from(channel), fileWriteCallback);
690                        fileWriteCallback.block();
691                    } catch (IOException e) {
692                        LOGGER.warn("Failed to write file {}", file.getAbsolutePath(), e);
693                        file.delete();
694                        throw e;
695                    }
696                    // optionally add some response body to test that the client can handle it, even though Maven
697                    // Repository Protocol doesn't mention it
698                    if (responseBodyForPut != null) {
699                        writeResponseBodyMessage(req, response, responseBodyForPut);
700                        response.setStatus(HttpServletResponse.SC_CREATED);
701                    } else {
702                        response.setStatus(HttpServletResponse.SC_NO_CONTENT);
703                    }
704                } else {
705                    response.setStatus(HttpServletResponse.SC_FORBIDDEN);
706                }
707            } else if (HttpMethod.OPTIONS.is(req.getMethod())) {
708                if (webDav) {
709                    response.getHeaders().add("DAV", "1,2");
710                }
711                response.getHeaders().add(HttpHeader.ALLOW, "GET, PUT, HEAD, OPTIONS");
712                response.setStatus(HttpServletResponse.SC_OK);
713            } else if (webDav && "MKCOL".equals(req.getMethod())) {
714                if (file.exists()) {
715                    response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
716                } else if (file.mkdir()) {
717                    response.setStatus(HttpServletResponse.SC_CREATED);
718                } else {
719                    response.setStatus(HttpServletResponse.SC_CONFLICT);
720                }
721            } else {
722                response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
723            }
724            return true;
725        }
726    }
727
728    private void writeResponseBodyMessage(Request request, Response response, String message) throws IOException {
729        // write synchronously to avoid closing the response too early
730        try (Blocker.Callback callback = Blocker.callback()) {
731            Content.Sink.write(response, false, message, callback);
732            callback.block();
733        }
734    }
735
736    private class RFC9457Handler extends Handler.Abstract {
737        @Override
738        public boolean handle(Request req, Response response, Callback callback) throws Exception {
739            String path = req.getHttpURI().getPath().substring(1);
740
741            if (!path.startsWith("rfc9457/")) {
742                return false;
743            }
744
745            if (HttpMethod.GET.is(req.getMethod())) {
746                response.setStatus(HttpServletResponse.SC_FORBIDDEN);
747                response.getHeaders().add(HttpHeader.CONTENT_TYPE.asString(), "application/problem+json");
748                RFC9457Payload rfc9457Payload;
749                if (path.endsWith("missing_fields.txt")) {
750                    rfc9457Payload = new RFC9457Payload(null, null, null, null, null);
751                } else {
752                    rfc9457Payload = new RFC9457Payload(
753                            URI.create("https://example.com/probs/out-of-credit"),
754                            HttpServletResponse.SC_FORBIDDEN,
755                            "You do not have enough credit.",
756                            "Your current balance is 30, but that costs 50.",
757                            URI.create("/account/12345/msgs/abc"));
758                }
759                writeResponseBodyMessage(req, response, buildRFC9457Message(rfc9457Payload));
760            }
761            return true;
762        }
763    }
764
765    private String buildRFC9457Message(RFC9457Payload payload) {
766        return new Gson().toJson(payload, RFC9457Payload.class);
767    }
768
769    private class RedirectHandler extends Handler.Abstract {
770        @Override
771        public boolean handle(Request req, Response response, Callback callback) throws Exception {
772            String path = req.getHttpURI().getPath();
773            if (!path.startsWith("/redirect/")) {
774                return false;
775            }
776            StringBuilder location = new StringBuilder(128);
777            String scheme = Request.getParameters(req).getValue("scheme");
778            location.append(scheme != null ? scheme : req.getHttpURI().getScheme());
779            location.append("://");
780            location.append(Request.getServerName(req));
781            location.append(":");
782            if ("http".equalsIgnoreCase(scheme)) {
783                location.append(getHttpPort());
784            } else if ("https".equalsIgnoreCase(scheme)) {
785                location.append(getHttpsPort());
786            } else {
787                location.append(Request.getServerPort(req));
788            }
789            location.append("/repo").append(path.substring(9));
790            Response.sendRedirect(
791                    req, response, callback, HttpServletResponse.SC_MOVED_PERMANENTLY, location.toString(), false);
792            return true;
793        }
794    }
795
796    private class AuthHandler extends Handler.Abstract {
797        @Override
798        public boolean handle(Request request, Response response, Callback callback) throws Exception {
799            if (ExpectContinue.BROKEN.equals(expectContinue)
800                    && "100-continue".equalsIgnoreCase(request.getHeaders().get(HttpHeader.EXPECT))) {
801                // TODO: what is this for?
802                Request.asInputStream(request);
803            }
804
805            if (username != null && password != null) {
806                if (checkBasicAuth(request.getHeaders().get(HttpHeader.AUTHORIZATION), username, password)) {
807                    return false;
808                }
809                response.getHeaders().add(HttpHeader.WWW_AUTHENTICATE, "Basic realm=\"Test-Realm\"");
810                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
811                return true;
812            }
813            return false;
814        }
815    }
816
817    private class ProxyAuthHandler extends Handler.Abstract {
818        @Override
819        public boolean handle(Request req, Response response, Callback callback) throws Exception {
820            if (proxyUsername != null && proxyPassword != null) {
821                if (checkBasicAuth(
822                        req.getHeaders().get(HttpHeader.PROXY_AUTHORIZATION), proxyUsername, proxyPassword)) {
823                    return false;
824                }
825                response.getHeaders().add(HttpHeader.PROXY_AUTHENTICATE, "basic realm=\"Test-Realm\"");
826                response.setStatus(HttpServletResponse.SC_PROXY_AUTHENTICATION_REQUIRED);
827                return true;
828            } else {
829                return false;
830            }
831        }
832    }
833
834    static boolean checkBasicAuth(String credentials, String username, String password) {
835        if (credentials != null) {
836            int space = credentials.indexOf(' ');
837            if (space > 0) {
838                String method = credentials.substring(0, space);
839                if ("basic".equalsIgnoreCase(method)) {
840                    credentials = credentials.substring(space + 1);
841                    credentials = new String(Base64.getDecoder().decode(credentials), StandardCharsets.ISO_8859_1);
842                    int i = credentials.indexOf(':');
843                    if (i > 0) {
844                        String user = credentials.substring(0, i);
845                        String pass = credentials.substring(i + 1);
846                        if (username.equals(user) && password.equals(pass)) {
847                            return true;
848                        }
849                    }
850                }
851            }
852        }
853        return false;
854    }
855}