View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.eclipse.aether.transport.http;
20  
21  import javax.servlet.http.HttpServletRequest;
22  import javax.servlet.http.HttpServletResponse;
23  
24  import java.io.File;
25  import java.io.FileInputStream;
26  import java.io.FileOutputStream;
27  import java.io.IOException;
28  import java.io.OutputStream;
29  import java.net.URI;
30  import java.nio.charset.StandardCharsets;
31  import java.util.ArrayList;
32  import java.util.Collections;
33  import java.util.Enumeration;
34  import java.util.List;
35  import java.util.Map;
36  import java.util.TreeMap;
37  import java.util.concurrent.atomic.AtomicInteger;
38  import java.util.regex.Matcher;
39  import java.util.regex.Pattern;
40  
41  import com.google.gson.Gson;
42  import org.eclipse.aether.transport.http.RFC9457.RFC9457Payload;
43  import org.eclipse.aether.util.ChecksumUtils;
44  import org.eclipse.jetty.http.HttpHeader;
45  import org.eclipse.jetty.http.HttpMethod;
46  import org.eclipse.jetty.server.Request;
47  import org.eclipse.jetty.server.Response;
48  import org.eclipse.jetty.server.Server;
49  import org.eclipse.jetty.server.ServerConnector;
50  import org.eclipse.jetty.server.handler.AbstractHandler;
51  import org.eclipse.jetty.server.handler.HandlerList;
52  import org.eclipse.jetty.util.B64Code;
53  import org.eclipse.jetty.util.IO;
54  import org.eclipse.jetty.util.StringUtil;
55  import org.eclipse.jetty.util.URIUtil;
56  import org.eclipse.jetty.util.ssl.SslContextFactory;
57  import org.slf4j.Logger;
58  import org.slf4j.LoggerFactory;
59  
60  @SuppressWarnings("checkstyle:VisibilityModifier")
61  public class HttpServer {
62  
63      public static class LogEntry {
64  
65          public final String method;
66  
67          public final String path;
68  
69          public final Map<String, String> headers;
70  
71          public LogEntry(String method, String path, Map<String, String> headers) {
72              this.method = method;
73              this.path = path;
74              this.headers = headers;
75          }
76  
77          @Override
78          public String toString() {
79              return method + " " + path;
80          }
81      }
82  
83      public enum ExpectContinue {
84          FAIL,
85          PROPER,
86          BROKEN
87      }
88  
89      public enum ChecksumHeader {
90          NEXUS,
91          XCHECKSUM
92      }
93  
94      private static final Logger LOGGER = LoggerFactory.getLogger(HttpServer.class);
95  
96      private static final Pattern SIMPLE_RANGE = Pattern.compile("bytes=([0-9])+-");
97  
98      private File repoDir;
99  
100     private boolean rangeSupport = true;
101 
102     private boolean webDav;
103 
104     private ExpectContinue expectContinue = ExpectContinue.PROPER;
105 
106     private ChecksumHeader checksumHeader;
107 
108     private Server server;
109 
110     private ServerConnector httpConnector;
111 
112     private ServerConnector httpsConnector;
113 
114     private String username;
115 
116     private String password;
117 
118     private String proxyUsername;
119 
120     private String proxyPassword;
121 
122     private final AtomicInteger connectionsToClose = new AtomicInteger(0);
123 
124     private final List<LogEntry> logEntries = Collections.synchronizedList(new ArrayList<>());
125 
126     public String getHost() {
127         return "localhost";
128     }
129 
130     public int getHttpPort() {
131         return httpConnector != null ? httpConnector.getLocalPort() : -1;
132     }
133 
134     public int getHttpsPort() {
135         return httpsConnector != null ? httpsConnector.getLocalPort() : -1;
136     }
137 
138     public String getHttpUrl() {
139         return "http://" + getHost() + ":" + getHttpPort();
140     }
141 
142     public String getHttpsUrl() {
143         return "https://" + getHost() + ":" + getHttpsPort();
144     }
145 
146     public HttpServer addSslConnector() {
147         return addSslConnector(true);
148     }
149 
150     public HttpServer addSelfSignedSslConnector() {
151         return addSslConnector(false);
152     }
153 
154     private HttpServer addSslConnector(boolean needClientAuth) {
155         if (httpsConnector == null) {
156             SslContextFactory.Server ssl = new SslContextFactory.Server();
157             if (needClientAuth) {
158                 ssl.setNeedClientAuth(true);
159                 ssl.setKeyStorePath(new File("src/test/resources/ssl/server-store").getAbsolutePath());
160                 ssl.setKeyStorePassword("server-pwd");
161                 ssl.setTrustStorePath(new File("src/test/resources/ssl/client-store").getAbsolutePath());
162                 ssl.setTrustStorePassword("client-pwd");
163             } else {
164                 ssl.setNeedClientAuth(false);
165                 ssl.setKeyStorePath(new File("src/test/resources/ssl/server-store-selfsigned").getAbsolutePath());
166                 ssl.setKeyStorePassword("server-pwd");
167             }
168             httpsConnector = new ServerConnector(server, ssl);
169             server.addConnector(httpsConnector);
170             try {
171                 httpsConnector.start();
172             } catch (Exception e) {
173                 throw new IllegalStateException(e);
174             }
175         }
176         return this;
177     }
178 
179     public List<LogEntry> getLogEntries() {
180         return logEntries;
181     }
182 
183     public HttpServer setRepoDir(File repoDir) {
184         this.repoDir = repoDir;
185         return this;
186     }
187 
188     public HttpServer setRangeSupport(boolean rangeSupport) {
189         this.rangeSupport = rangeSupport;
190         return this;
191     }
192 
193     public HttpServer setWebDav(boolean webDav) {
194         this.webDav = webDav;
195         return this;
196     }
197 
198     public HttpServer setExpectSupport(ExpectContinue expectContinue) {
199         this.expectContinue = expectContinue;
200         return this;
201     }
202 
203     public HttpServer setChecksumHeader(ChecksumHeader checksumHeader) {
204         this.checksumHeader = checksumHeader;
205         return this;
206     }
207 
208     public HttpServer setAuthentication(String username, String password) {
209         this.username = username;
210         this.password = password;
211         return this;
212     }
213 
214     public HttpServer setProxyAuthentication(String username, String password) {
215         proxyUsername = username;
216         proxyPassword = password;
217         return this;
218     }
219 
220     public HttpServer setConnectionsToClose(int connectionsToClose) {
221         this.connectionsToClose.set(connectionsToClose);
222         return this;
223     }
224 
225     public HttpServer start() throws Exception {
226         if (server != null) {
227             return this;
228         }
229 
230         HandlerList handlers = new HandlerList();
231         handlers.addHandler(new ConnectionClosingHandler());
232         handlers.addHandler(new LogHandler());
233         handlers.addHandler(new ProxyAuthHandler());
234         handlers.addHandler(new AuthHandler());
235         handlers.addHandler(new RedirectHandler());
236         handlers.addHandler(new RepoHandler());
237         handlers.addHandler(new RFC9457Handler());
238 
239         server = new Server();
240         httpConnector = new ServerConnector(server);
241         server.addConnector(httpConnector);
242         server.setHandler(handlers);
243         server.start();
244 
245         return this;
246     }
247 
248     public void stop() throws Exception {
249         if (server != null) {
250             server.stop();
251             server = null;
252             httpConnector = null;
253             httpsConnector = null;
254         }
255     }
256 
257     private class ConnectionClosingHandler extends AbstractHandler {
258         public void handle(String target, Request req, HttpServletRequest request, HttpServletResponse response) {
259             if (connectionsToClose.getAndDecrement() > 0) {
260                 Response jettyResponse = (Response) response;
261                 jettyResponse.getHttpChannel().getConnection().close();
262             }
263         }
264     }
265 
266     private class LogHandler extends AbstractHandler {
267 
268         public void handle(String target, Request req, HttpServletRequest request, HttpServletResponse response) {
269             LOGGER.info(
270                     "{} {}{}",
271                     req.getMethod(),
272                     req.getRequestURL(),
273                     req.getQueryString() != null ? "?" + req.getQueryString() : "");
274 
275             Map<String, String> headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
276             for (Enumeration<String> en = req.getHeaderNames(); en.hasMoreElements(); ) {
277                 String name = en.nextElement();
278                 StringBuilder buffer = new StringBuilder(128);
279                 for (Enumeration<String> ien = req.getHeaders(name); ien.hasMoreElements(); ) {
280                     if (buffer.length() > 0) {
281                         buffer.append(", ");
282                     }
283                     buffer.append(ien.nextElement());
284                 }
285                 headers.put(name, buffer.toString());
286             }
287             logEntries.add(new LogEntry(req.getMethod(), req.getOriginalURI(), Collections.unmodifiableMap(headers)));
288         }
289     }
290 
291     private class RepoHandler extends AbstractHandler {
292 
293         public void handle(String target, Request req, HttpServletRequest request, HttpServletResponse response)
294                 throws IOException {
295             String path = req.getPathInfo().substring(1);
296 
297             if (!path.startsWith("repo/")) {
298                 return;
299             }
300             req.setHandled(true);
301 
302             if (ExpectContinue.FAIL.equals(expectContinue) && request.getHeader(HttpHeader.EXPECT.asString()) != null) {
303                 response.setStatus(HttpServletResponse.SC_EXPECTATION_FAILED);
304                 return;
305             }
306 
307             File file = new File(repoDir, path.substring(5));
308             if (HttpMethod.GET.is(req.getMethod()) || HttpMethod.HEAD.is(req.getMethod())) {
309                 if (!file.isFile() || path.endsWith(URIUtil.SLASH)) {
310                     response.setStatus(HttpServletResponse.SC_NOT_FOUND);
311                     return;
312                 }
313                 long ifUnmodifiedSince = request.getDateHeader(HttpHeader.IF_UNMODIFIED_SINCE.asString());
314                 if (ifUnmodifiedSince != -1L && file.lastModified() > ifUnmodifiedSince) {
315                     response.setStatus(HttpServletResponse.SC_PRECONDITION_FAILED);
316                     return;
317                 }
318                 long offset = 0L;
319                 String range = request.getHeader(HttpHeader.RANGE.asString());
320                 if (range != null && rangeSupport) {
321                     Matcher m = SIMPLE_RANGE.matcher(range);
322                     if (m.matches()) {
323                         offset = Long.parseLong(m.group(1));
324                         if (offset >= file.length()) {
325                             response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
326                             return;
327                         }
328                     }
329                     String encoding = request.getHeader(HttpHeader.ACCEPT_ENCODING.asString());
330                     if ((encoding != null && !"identity".equals(encoding)) || ifUnmodifiedSince == -1L) {
331                         response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
332                         return;
333                     }
334                 }
335                 response.setStatus((offset > 0L) ? HttpServletResponse.SC_PARTIAL_CONTENT : HttpServletResponse.SC_OK);
336                 response.setDateHeader(HttpHeader.LAST_MODIFIED.asString(), file.lastModified());
337                 response.setHeader(HttpHeader.CONTENT_LENGTH.asString(), Long.toString(file.length() - offset));
338                 if (offset > 0L) {
339                     response.setHeader(
340                             HttpHeader.CONTENT_RANGE.asString(),
341                             "bytes " + offset + "-" + (file.length() - 1L) + "/" + file.length());
342                 }
343                 if (checksumHeader != null) {
344                     Map<String, Object> checksums = ChecksumUtils.calc(file, Collections.singleton("SHA-1"));
345                     if (checksumHeader == ChecksumHeader.NEXUS) {
346                         response.setHeader(HttpHeader.ETAG.asString(), "{SHA1{" + checksums.get("SHA-1") + "}}");
347                     } else if (checksumHeader == ChecksumHeader.XCHECKSUM) {
348                         response.setHeader(
349                                 "x-checksum-sha1", checksums.get("SHA-1").toString());
350                     }
351                 }
352                 if (HttpMethod.HEAD.is(req.getMethod())) {
353                     return;
354                 }
355                 FileInputStream is = null;
356                 try {
357                     is = new FileInputStream(file);
358                     if (offset > 0L) {
359                         long skipped = is.skip(offset);
360                         while (skipped < offset && is.read() >= 0) {
361                             skipped++;
362                         }
363                     }
364                     IO.copy(is, response.getOutputStream());
365                     is.close();
366                     is = null;
367                 } finally {
368                     try {
369                         if (is != null) {
370                             is.close();
371                         }
372                     } catch (final IOException e) {
373                         // Suppressed due to an exception already thrown in the try block.
374                     }
375                 }
376             } else if (HttpMethod.PUT.is(req.getMethod())) {
377                 if (!webDav) {
378                     file.getParentFile().mkdirs();
379                 }
380                 if (file.getParentFile().exists()) {
381                     try {
382                         FileOutputStream os = null;
383                         try {
384                             os = new FileOutputStream(file);
385                             IO.copy(request.getInputStream(), os);
386                             os.close();
387                             os = null;
388                         } finally {
389                             try {
390                                 if (os != null) {
391                                     os.close();
392                                 }
393                             } catch (final IOException e) {
394                                 // Suppressed due to an exception already thrown in the try block.
395                             }
396                         }
397                     } catch (IOException e) {
398                         file.delete();
399                         throw e;
400                     }
401                     response.setStatus(HttpServletResponse.SC_NO_CONTENT);
402                 } else {
403                     response.setStatus(HttpServletResponse.SC_FORBIDDEN);
404                 }
405             } else if (HttpMethod.OPTIONS.is(req.getMethod())) {
406                 if (webDav) {
407                     response.setHeader("DAV", "1,2");
408                 }
409                 response.setHeader(HttpHeader.ALLOW.asString(), "GET, PUT, HEAD, OPTIONS");
410                 response.setStatus(HttpServletResponse.SC_OK);
411             } else if (webDav && "MKCOL".equals(req.getMethod())) {
412                 if (file.exists()) {
413                     response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
414                 } else if (file.mkdir()) {
415                     response.setStatus(HttpServletResponse.SC_CREATED);
416                 } else {
417                     response.setStatus(HttpServletResponse.SC_CONFLICT);
418                 }
419             } else {
420                 response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
421             }
422         }
423     }
424 
425     private class RedirectHandler extends AbstractHandler {
426 
427         public void handle(String target, Request req, HttpServletRequest request, HttpServletResponse response) {
428             String path = req.getPathInfo();
429             if (!path.startsWith("/redirect/")) {
430                 return;
431             }
432             req.setHandled(true);
433             StringBuilder location = new StringBuilder(128);
434             String scheme = req.getParameter("scheme");
435             location.append(scheme != null ? scheme : req.getScheme());
436             location.append("://");
437             location.append(req.getServerName());
438             location.append(":");
439             if ("http".equalsIgnoreCase(scheme)) {
440                 location.append(getHttpPort());
441             } else if ("https".equalsIgnoreCase(scheme)) {
442                 location.append(getHttpsPort());
443             } else {
444                 location.append(req.getServerPort());
445             }
446             location.append("/repo").append(path.substring(9));
447             response.setStatus(HttpServletResponse.SC_MOVED_PERMANENTLY);
448             response.setHeader(HttpHeader.LOCATION.asString(), location.toString());
449         }
450     }
451 
452     private class AuthHandler extends AbstractHandler {
453 
454         public void handle(String target, Request req, HttpServletRequest request, HttpServletResponse response)
455                 throws IOException {
456             if (ExpectContinue.BROKEN.equals(expectContinue)
457                     && "100-continue".equalsIgnoreCase(request.getHeader(HttpHeader.EXPECT.asString()))) {
458                 request.getInputStream();
459             }
460 
461             if (username != null && password != null) {
462                 if (checkBasicAuth(request.getHeader(HttpHeader.AUTHORIZATION.asString()), username, password)) {
463                     return;
464                 }
465                 req.setHandled(true);
466                 response.setHeader(HttpHeader.WWW_AUTHENTICATE.asString(), "basic realm=\"Test-Realm\"");
467                 response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
468             }
469         }
470     }
471 
472     private class ProxyAuthHandler extends AbstractHandler {
473 
474         public void handle(String target, Request req, HttpServletRequest request, HttpServletResponse response) {
475             if (proxyUsername != null && proxyPassword != null) {
476                 if (checkBasicAuth(
477                         request.getHeader(HttpHeader.PROXY_AUTHORIZATION.asString()), proxyUsername, proxyPassword)) {
478                     return;
479                 }
480                 req.setHandled(true);
481                 response.setHeader(HttpHeader.PROXY_AUTHENTICATE.asString(), "basic realm=\"Test-Realm\"");
482                 response.setStatus(HttpServletResponse.SC_PROXY_AUTHENTICATION_REQUIRED);
483             }
484         }
485     }
486 
487     private class RFC9457Handler extends AbstractHandler {
488         @Override
489         public void handle(
490                 final String target,
491                 final Request req,
492                 final HttpServletRequest request,
493                 final HttpServletResponse response)
494                 throws IOException {
495             String path = req.getPathInfo().substring(1);
496 
497             if (!path.startsWith("rfc9457/")) {
498                 return;
499             }
500             req.setHandled(true);
501 
502             if (HttpMethod.GET.is(req.getMethod())) {
503                 response.setStatus(HttpServletResponse.SC_FORBIDDEN);
504                 response.setHeader(HttpHeader.CONTENT_TYPE.asString(), "application/problem+json");
505                 RFC9457Payload rfc9457Payload;
506                 if (path.endsWith("missing_fields.txt")) {
507                     rfc9457Payload = new RFC9457Payload(null, null, null, null, null);
508                 } else {
509                     rfc9457Payload = new RFC9457Payload(
510                             URI.create("https://example.com/probs/out-of-credit"),
511                             HttpServletResponse.SC_FORBIDDEN,
512                             "You do not have enough credit.",
513                             "Your current balance is 30, but that costs 50.",
514                             URI.create("/account/12345/msgs/abc"));
515                 }
516                 try (OutputStream outputStream = response.getOutputStream()) {
517                     outputStream.write(buildRFC9457Message(rfc9457Payload).getBytes(StandardCharsets.UTF_8));
518                 }
519             }
520         }
521     }
522 
523     private String buildRFC9457Message(RFC9457Payload payload) {
524         return new Gson().toJson(payload, RFC9457Payload.class);
525     }
526 
527     static boolean checkBasicAuth(String credentials, String username, String password) {
528         if (credentials != null) {
529             int space = credentials.indexOf(' ');
530             if (space > 0) {
531                 String method = credentials.substring(0, space);
532                 if ("basic".equalsIgnoreCase(method)) {
533                     credentials = credentials.substring(space + 1);
534                     credentials = B64Code.decode(credentials, StringUtil.__ISO_8859_1);
535                     int i = credentials.indexOf(':');
536                     if (i > 0) {
537                         String user = credentials.substring(0, i);
538                         String pass = credentials.substring(i + 1);
539                         if (username.equals(user) && password.equals(pass)) {
540                             return true;
541                         }
542                     }
543                 }
544             }
545         }
546         return false;
547     }
548 }