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