View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.eclipse.aether.internal.test.util.http;
20  
21  import java.io.File;
22  import java.io.IOException;
23  import java.net.URI;
24  import java.nio.channels.SeekableByteChannel;
25  import java.nio.charset.StandardCharsets;
26  import java.nio.file.Files;
27  import java.nio.file.StandardOpenOption;
28  import java.util.ArrayList;
29  import java.util.Base64;
30  import java.util.Collections;
31  import java.util.List;
32  import java.util.Map;
33  import java.util.TreeMap;
34  import java.util.concurrent.CountDownLatch;
35  import java.util.concurrent.atomic.AtomicInteger;
36  import java.util.regex.Matcher;
37  import java.util.regex.Pattern;
38  import java.util.stream.Collectors;
39  
40  import com.google.gson.Gson;
41  import jakarta.servlet.http.HttpServletResponse;
42  import org.eclipse.aether.internal.impl.checksum.Sha1ChecksumAlgorithmFactory;
43  import org.eclipse.aether.spi.connector.checksum.ChecksumAlgorithmHelper;
44  import org.eclipse.aether.spi.connector.transport.http.RFC9457.RFC9457Payload;
45  import org.eclipse.jetty.alpn.server.ALPNServerConnectionFactory;
46  import org.eclipse.jetty.compression.server.CompressionConfig;
47  import org.eclipse.jetty.compression.server.CompressionHandler;
48  import org.eclipse.jetty.http.DateGenerator;
49  import org.eclipse.jetty.http.HttpField;
50  import org.eclipse.jetty.http.HttpFields;
51  import org.eclipse.jetty.http.HttpHeader;
52  import org.eclipse.jetty.http.HttpMethod;
53  import org.eclipse.jetty.http.HttpURI;
54  import org.eclipse.jetty.http.pathmap.MatchedResource;
55  import org.eclipse.jetty.http.pathmap.PathMappings;
56  import org.eclipse.jetty.http.pathmap.PathSpec;
57  import org.eclipse.jetty.http2.server.HTTP2ServerConnectionFactory;
58  import org.eclipse.jetty.io.ByteBufferPool;
59  import org.eclipse.jetty.io.Content;
60  import org.eclipse.jetty.server.Handler;
61  import org.eclipse.jetty.server.HttpConfiguration;
62  import org.eclipse.jetty.server.HttpConnectionFactory;
63  import org.eclipse.jetty.server.Request;
64  import org.eclipse.jetty.server.Response;
65  import org.eclipse.jetty.server.SecureRequestCustomizer;
66  import org.eclipse.jetty.server.Server;
67  import org.eclipse.jetty.server.ServerConnector;
68  import org.eclipse.jetty.server.SslConnectionFactory;
69  import org.eclipse.jetty.util.Blocker;
70  import org.eclipse.jetty.util.Callback;
71  import org.eclipse.jetty.util.ssl.SslContextFactory;
72  import org.slf4j.Logger;
73  import org.slf4j.LoggerFactory;
74  
75  public class HttpServer {
76  
77      public static class LogEntry {
78  
79          private final String method;
80  
81          private final String path;
82  
83          private final Map<String, String> requestHeaders;
84  
85          private Map<String, String> responseHeaders;
86  
87          CountDownLatch responseHeadersAvailableSignal = new CountDownLatch(1);
88  
89          public LogEntry(String method, String path, Map<String, String> requestHeaders) {
90              this.method = method;
91              this.path = path;
92              this.requestHeaders = requestHeaders;
93          }
94  
95          public String getMethod() {
96              return method;
97          }
98  
99          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 }