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