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