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