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