View Javadoc
1   package org.eclipse.aether.transport.http;
2   
3   /*
4    * Licensed to the Apache Software Foundation (ASF) under one
5    * or more contributor license agreements.  See the NOTICE file
6    * distributed with this work for additional information
7    * regarding copyright ownership.  The ASF licenses this file
8    * to you under the Apache License, Version 2.0 (the
9    * "License"); you may not use this file except in compliance
10   * with the License.  You may obtain a copy of the License at
11   * 
12   *  http://www.apache.org/licenses/LICENSE-2.0
13   * 
14   * Unless required by applicable law or agreed to in writing,
15   * software distributed under the License is distributed on an
16   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
17   * KIND, either express or implied.  See the License for the
18   * specific language governing permissions and limitations
19   * under the License.
20   */
21  
22  import org.apache.http.Header;
23  import org.apache.http.HttpEntity;
24  import org.apache.http.HttpEntityEnclosingRequest;
25  import org.apache.http.HttpHeaders;
26  import org.apache.http.HttpHost;
27  import org.apache.http.HttpResponse;
28  import org.apache.http.HttpStatus;
29  import org.apache.http.auth.AuthSchemeProvider;
30  import org.apache.http.auth.AuthScope;
31  import org.apache.http.client.CredentialsProvider;
32  import org.apache.http.client.HttpResponseException;
33  import org.apache.http.client.config.AuthSchemes;
34  import org.apache.http.client.config.RequestConfig;
35  import org.apache.http.client.methods.HttpGet;
36  import org.apache.http.client.methods.HttpHead;
37  import org.apache.http.client.methods.HttpOptions;
38  import org.apache.http.client.methods.HttpPut;
39  import org.apache.http.client.methods.HttpUriRequest;
40  import org.apache.http.client.utils.DateUtils;
41  import org.apache.http.client.utils.URIUtils;
42  import org.apache.http.config.Registry;
43  import org.apache.http.config.RegistryBuilder;
44  import org.apache.http.config.SocketConfig;
45  import org.apache.http.entity.AbstractHttpEntity;
46  import org.apache.http.entity.ByteArrayEntity;
47  import org.apache.http.impl.auth.BasicSchemeFactory;
48  import org.apache.http.impl.auth.DigestSchemeFactory;
49  import org.apache.http.impl.auth.KerberosSchemeFactory;
50  import org.apache.http.impl.auth.NTLMSchemeFactory;
51  import org.apache.http.impl.auth.SPNegoSchemeFactory;
52  import org.apache.http.impl.client.CloseableHttpClient;
53  import org.apache.http.impl.client.HttpClientBuilder;
54  import org.apache.http.util.EntityUtils;
55  import org.eclipse.aether.ConfigurationProperties;
56  import org.eclipse.aether.RepositorySystemSession;
57  import org.eclipse.aether.repository.AuthenticationContext;
58  import org.eclipse.aether.repository.Proxy;
59  import org.eclipse.aether.repository.RemoteRepository;
60  import org.eclipse.aether.spi.connector.transport.AbstractTransporter;
61  import org.eclipse.aether.spi.connector.transport.GetTask;
62  import org.eclipse.aether.spi.connector.transport.PeekTask;
63  import org.eclipse.aether.spi.connector.transport.PutTask;
64  import org.eclipse.aether.spi.connector.transport.TransportTask;
65  import org.eclipse.aether.transfer.NoTransporterException;
66  import org.eclipse.aether.transfer.TransferCancelledException;
67  import org.eclipse.aether.util.ConfigUtils;
68  import org.slf4j.Logger;
69  import org.slf4j.LoggerFactory;
70  
71  import java.io.IOException;
72  import java.io.InputStream;
73  import java.io.InterruptedIOException;
74  import java.io.OutputStream;
75  import java.io.UncheckedIOException;
76  import java.net.URI;
77  import java.net.URISyntaxException;
78  import java.nio.charset.Charset;
79  import java.util.Collections;
80  import java.util.Date;
81  import java.util.List;
82  import java.util.Map;
83  import java.util.regex.Matcher;
84  import java.util.regex.Pattern;
85  
86  /**
87   * A transporter for HTTP/HTTPS.
88   */
89  final class HttpTransporter
90      extends AbstractTransporter
91  {
92  
93      private static final Pattern CONTENT_RANGE_PATTERN =
94          Pattern.compile( "\\s*bytes\\s+([0-9]+)\\s*-\\s*([0-9]+)\\s*/.*" );
95  
96      private static final Logger LOGGER = LoggerFactory.getLogger( HttpTransporter.class );
97  
98      private final AuthenticationContext repoAuthContext;
99  
100     private final AuthenticationContext proxyAuthContext;
101 
102     private final URI baseUri;
103 
104     private final HttpHost server;
105 
106     private final HttpHost proxy;
107 
108     private final CloseableHttpClient client;
109 
110     private final Map<?, ?> headers;
111 
112     private final LocalState state;
113 
114     HttpTransporter( RemoteRepository repository, RepositorySystemSession session )
115         throws NoTransporterException
116     {
117         if ( !"http".equalsIgnoreCase( repository.getProtocol() )
118             && !"https".equalsIgnoreCase( repository.getProtocol() ) )
119         {
120             throw new NoTransporterException( repository );
121         }
122         try
123         {
124             this.baseUri = new URI( repository.getUrl() ).parseServerAuthority();
125             if ( baseUri.isOpaque() )
126             {
127                 throw new URISyntaxException( repository.getUrl(), "URL must not be opaque" );
128             }
129             this.server = URIUtils.extractHost( baseUri );
130             if ( server == null )
131             {
132                 throw new URISyntaxException( repository.getUrl(), "URL lacks host name" );
133             }
134         }
135         catch ( URISyntaxException e )
136         {
137             throw new NoTransporterException( repository, e.getMessage(), e );
138         }
139         this.proxy = toHost( repository.getProxy() );
140 
141         this.repoAuthContext = AuthenticationContext.forRepository( session, repository );
142         this.proxyAuthContext = AuthenticationContext.forProxy( session, repository );
143 
144         this.state = new LocalState( session, repository, new SslConfig( session, repoAuthContext ) );
145 
146         this.headers = ConfigUtils.getMap( session, Collections.emptyMap(),
147                 ConfigurationProperties.HTTP_HEADERS + "." + repository.getId(),
148                 ConfigurationProperties.HTTP_HEADERS );
149 
150         String credentialEncoding = ConfigUtils.getString( session,
151                 ConfigurationProperties.DEFAULT_HTTP_CREDENTIAL_ENCODING,
152                 ConfigurationProperties.HTTP_CREDENTIAL_ENCODING + "." + repository.getId(),
153                 ConfigurationProperties.HTTP_CREDENTIAL_ENCODING );
154         int connectTimeout = ConfigUtils.getInteger( session,
155                 ConfigurationProperties.DEFAULT_CONNECT_TIMEOUT,
156                 ConfigurationProperties.CONNECT_TIMEOUT + "." + repository.getId(),
157                 ConfigurationProperties.CONNECT_TIMEOUT );
158         int requestTimeout = ConfigUtils.getInteger( session,
159                 ConfigurationProperties.DEFAULT_REQUEST_TIMEOUT,
160                 ConfigurationProperties.REQUEST_TIMEOUT + "." + repository.getId(),
161                 ConfigurationProperties.REQUEST_TIMEOUT );
162         String userAgent = ConfigUtils.getString( session,
163                 ConfigurationProperties.DEFAULT_USER_AGENT,
164                 ConfigurationProperties.USER_AGENT );
165 
166         Charset credentialsCharset = Charset.forName( credentialEncoding );
167 
168         Registry<AuthSchemeProvider> authSchemeRegistry = RegistryBuilder.<AuthSchemeProvider>create()
169                 .register( AuthSchemes.BASIC, new BasicSchemeFactory( credentialsCharset ) )
170                 .register( AuthSchemes.DIGEST, new DigestSchemeFactory( credentialsCharset ) )
171                 .register( AuthSchemes.NTLM, new NTLMSchemeFactory() )
172                 .register( AuthSchemes.SPNEGO, new SPNegoSchemeFactory() )
173                 .register( AuthSchemes.KERBEROS, new KerberosSchemeFactory() )
174                 .build();
175 
176         SocketConfig socketConfig = SocketConfig.custom()
177                  .setSoTimeout( requestTimeout ).build();
178 
179         RequestConfig requestConfig = RequestConfig.custom()
180                 .setConnectTimeout( connectTimeout )
181                 .setConnectionRequestTimeout( connectTimeout )
182                 .setSocketTimeout( requestTimeout ).build();
183 
184         this.client = HttpClientBuilder.create()
185                 .setUserAgent( userAgent )
186                 .setDefaultSocketConfig( socketConfig )
187                 .setDefaultRequestConfig( requestConfig )
188                 .setDefaultAuthSchemeRegistry( authSchemeRegistry )
189                 .setConnectionManager( state.getConnectionManager() )
190                 .setConnectionManagerShared( true )
191                 .setDefaultCredentialsProvider(
192                        toCredentialsProvider( server, repoAuthContext, proxy, proxyAuthContext )
193                 )
194                 .setProxy( proxy )
195                 .build();
196     }
197 
198     private static HttpHost toHost( Proxy proxy )
199     {
200         HttpHost host = null;
201         if ( proxy != null )
202         {
203             host = new HttpHost( proxy.getHost(), proxy.getPort() );
204         }
205         return host;
206     }
207 
208     private static CredentialsProvider toCredentialsProvider( HttpHost server, AuthenticationContext serverAuthCtx,
209                                                               HttpHost proxy, AuthenticationContext proxyAuthCtx )
210     {
211         CredentialsProvider provider = toCredentialsProvider( server.getHostName(), AuthScope.ANY_PORT, serverAuthCtx );
212         if ( proxy != null )
213         {
214             CredentialsProvider p = toCredentialsProvider( proxy.getHostName(), proxy.getPort(), proxyAuthCtx );
215             provider = new DemuxCredentialsProvider( provider, p, proxy );
216         }
217         return provider;
218     }
219 
220     private static CredentialsProvider toCredentialsProvider( String host, int port, AuthenticationContext ctx )
221     {
222         DeferredCredentialsProvider provider = new DeferredCredentialsProvider();
223         if ( ctx != null )
224         {
225             AuthScope basicScope = new AuthScope( host, port );
226             provider.setCredentials( basicScope, new DeferredCredentialsProvider.BasicFactory( ctx ) );
227 
228             AuthScope ntlmScope = new AuthScope( host, port, AuthScope.ANY_REALM, "ntlm" );
229             provider.setCredentials( ntlmScope, new DeferredCredentialsProvider.NtlmFactory( ctx ) );
230         }
231         return provider;
232     }
233 
234     LocalState getState()
235     {
236         return state;
237     }
238 
239     private URI resolve( TransportTask task )
240     {
241         return UriUtils.resolve( baseUri, task.getLocation() );
242     }
243 
244     @Override
245     public int classify( Throwable error )
246     {
247         if ( error instanceof HttpResponseException
248             && ( (HttpResponseException) error ).getStatusCode() == HttpStatus.SC_NOT_FOUND )
249         {
250             return ERROR_NOT_FOUND;
251         }
252         return ERROR_OTHER;
253     }
254 
255     @Override
256     protected void implPeek( PeekTask task )
257         throws Exception
258     {
259         HttpHead request = commonHeaders( new HttpHead( resolve( task ) ) );
260         execute( request, null );
261     }
262 
263     @Override
264     protected void implGet( GetTask task )
265         throws Exception
266     {
267         EntityGetter getter = new EntityGetter( task );
268         HttpGet request = commonHeaders( new HttpGet( resolve( task ) ) );
269         resume( request, task );
270         try
271         {
272             execute( request, getter );
273         }
274         catch ( HttpResponseException e )
275         {
276             if ( e.getStatusCode() == HttpStatus.SC_PRECONDITION_FAILED && request.containsHeader( HttpHeaders.RANGE ) )
277             {
278                 request = commonHeaders( new HttpGet( request.getURI() ) );
279                 execute( request, getter );
280                 return;
281             }
282             throw e;
283         }
284     }
285 
286     @Override
287     protected void implPut( PutTask task )
288         throws Exception
289     {
290         PutTaskEntity entity = new PutTaskEntity( task );
291         HttpPut request = commonHeaders( entity( new HttpPut( resolve( task ) ), entity ) );
292         try
293         {
294             execute( request, null );
295         }
296         catch ( HttpResponseException e )
297         {
298             if ( e.getStatusCode() == HttpStatus.SC_EXPECTATION_FAILED && request.containsHeader( HttpHeaders.EXPECT ) )
299             {
300                 state.setExpectContinue( false );
301                 request = commonHeaders( entity( new HttpPut( request.getURI() ), entity ) );
302                 execute( request, null );
303                 return;
304             }
305             throw e;
306         }
307     }
308 
309     private void execute( HttpUriRequest request, EntityGetter getter )
310         throws Exception
311     {
312         try
313         {
314             SharingHttpContext context = new SharingHttpContext( state );
315             prepare( request, context );
316             HttpResponse response = client.execute( server, request, context );
317             try
318             {
319                 context.close();
320                 handleStatus( response );
321                 if ( getter != null )
322                 {
323                     getter.handle( response );
324                 }
325             }
326             finally
327             {
328                 EntityUtils.consumeQuietly( response.getEntity() );
329             }
330         }
331         catch ( IOException e )
332         {
333             if ( e.getCause() instanceof TransferCancelledException )
334             {
335                 throw (Exception) e.getCause();
336             }
337             throw e;
338         }
339     }
340 
341     private void prepare( HttpUriRequest request, SharingHttpContext context )
342     {
343         boolean put = HttpPut.METHOD_NAME.equalsIgnoreCase( request.getMethod() );
344         if ( state.getWebDav() == null && ( put || isPayloadPresent( request ) ) )
345         {
346             try
347             {
348                 HttpOptions req = commonHeaders( new HttpOptions( request.getURI() ) );
349                 HttpResponse response = client.execute( server, req, context );
350                 state.setWebDav( isWebDav( response ) );
351                 EntityUtils.consumeQuietly( response.getEntity() );
352             }
353             catch ( IOException e )
354             {
355                 LOGGER.debug( "Failed to prepare HTTP context", e );
356             }
357         }
358         if ( put && Boolean.TRUE.equals( state.getWebDav() ) )
359         {
360             mkdirs( request.getURI(), context );
361         }
362     }
363 
364     private boolean isWebDav( HttpResponse response )
365     {
366         return response.containsHeader( HttpHeaders.DAV );
367     }
368 
369     @SuppressWarnings( "checkstyle:magicnumber" )
370     private void mkdirs( URI uri, SharingHttpContext context )
371     {
372         List<URI> dirs = UriUtils.getDirectories( baseUri, uri );
373         int index = 0;
374         for ( ; index < dirs.size(); index++ )
375         {
376             try
377             {
378                 HttpResponse response =
379                     client.execute( server, commonHeaders( new HttpMkCol( dirs.get( index ) ) ), context );
380                 try
381                 {
382                     int status = response.getStatusLine().getStatusCode();
383                     if ( status < 300 || status == HttpStatus.SC_METHOD_NOT_ALLOWED )
384                     {
385                         break;
386                     }
387                     else if ( status == HttpStatus.SC_CONFLICT )
388                     {
389                         continue;
390                     }
391                     handleStatus( response );
392                 }
393                 finally
394                 {
395                     EntityUtils.consumeQuietly( response.getEntity() );
396                 }
397             }
398             catch ( IOException e )
399             {
400                 LOGGER.debug( "Failed to create parent directory {}", dirs.get( index ), e );
401                 return;
402             }
403         }
404         for ( index--; index >= 0; index-- )
405         {
406             try
407             {
408                 HttpResponse response =
409                     client.execute( server, commonHeaders( new HttpMkCol( dirs.get( index ) ) ), context );
410                 try
411                 {
412                     handleStatus( response );
413                 }
414                 finally
415                 {
416                     EntityUtils.consumeQuietly( response.getEntity() );
417                 }
418             }
419             catch ( IOException e )
420             {
421                 LOGGER.debug( "Failed to create parent directory {}", dirs.get( index ), e );
422                 return;
423             }
424         }
425     }
426 
427     private <T extends HttpEntityEnclosingRequest> T entity( T request, HttpEntity entity )
428     {
429         request.setEntity( entity );
430         return request;
431     }
432 
433     private boolean isPayloadPresent( HttpUriRequest request )
434     {
435         if ( request instanceof HttpEntityEnclosingRequest )
436         {
437             HttpEntity entity = ( (HttpEntityEnclosingRequest) request ).getEntity();
438             return entity != null && entity.getContentLength() != 0;
439         }
440         return false;
441     }
442 
443     private <T extends HttpUriRequest> T commonHeaders( T request )
444     {
445         request.setHeader( HttpHeaders.CACHE_CONTROL, "no-cache, no-store" );
446         request.setHeader( HttpHeaders.PRAGMA, "no-cache" );
447 
448         if ( state.isExpectContinue() && isPayloadPresent( request ) )
449         {
450             request.setHeader( HttpHeaders.EXPECT, "100-continue" );
451         }
452 
453         for ( Map.Entry<?, ?> entry : headers.entrySet() )
454         {
455             if ( !( entry.getKey() instanceof String ) )
456             {
457                 continue;
458             }
459             if ( entry.getValue() instanceof String )
460             {
461                 request.setHeader( entry.getKey().toString(), entry.getValue().toString() );
462             }
463             else
464             {
465                 request.removeHeaders( entry.getKey().toString() );
466             }
467         }
468 
469         if ( !state.isExpectContinue() )
470         {
471             request.removeHeaders( HttpHeaders.EXPECT );
472         }
473 
474         return request;
475     }
476 
477     @SuppressWarnings( "checkstyle:magicnumber" )
478     private <T extends HttpUriRequest> T resume( T request, GetTask task )
479     {
480         long resumeOffset = task.getResumeOffset();
481         if ( resumeOffset > 0L && task.getDataFile() != null )
482         {
483             request.setHeader( HttpHeaders.RANGE, "bytes=" + resumeOffset + '-' );
484             request.setHeader( HttpHeaders.IF_UNMODIFIED_SINCE,
485                                DateUtils.formatDate( new Date( task.getDataFile().lastModified() - 60L * 1000L ) ) );
486             request.setHeader( HttpHeaders.ACCEPT_ENCODING, "identity" );
487         }
488         return request;
489     }
490 
491     @SuppressWarnings( "checkstyle:magicnumber" )
492     private void handleStatus( HttpResponse response )
493         throws HttpResponseException
494     {
495         int status = response.getStatusLine().getStatusCode();
496         if ( status >= 300 )
497         {
498             throw new HttpResponseException( status, response.getStatusLine().getReasonPhrase() + " (" + status + ")" );
499         }
500     }
501 
502     @Override
503     protected void implClose()
504     {
505         try
506         {
507             client.close();
508         }
509         catch ( IOException e )
510         {
511             throw new UncheckedIOException( e );
512         }
513         AuthenticationContext.close( repoAuthContext );
514         AuthenticationContext.close( proxyAuthContext );
515         state.close();
516     }
517 
518     private class EntityGetter
519     {
520 
521         private final GetTask task;
522 
523         EntityGetter( GetTask task )
524         {
525             this.task = task;
526         }
527 
528         public void handle( HttpResponse response )
529             throws IOException, TransferCancelledException
530         {
531             HttpEntity entity = response.getEntity();
532             if ( entity == null )
533             {
534                 entity = new ByteArrayEntity( new byte[0] );
535             }
536 
537             long offset = 0L, length = entity.getContentLength();
538             String range = getHeader( response, HttpHeaders.CONTENT_RANGE );
539             if ( range != null )
540             {
541                 Matcher m = CONTENT_RANGE_PATTERN.matcher( range );
542                 if ( !m.matches() )
543                 {
544                     throw new IOException( "Invalid Content-Range header for partial download: " + range );
545                 }
546                 offset = Long.parseLong( m.group( 1 ) );
547                 length = Long.parseLong( m.group( 2 ) ) + 1L;
548                 if ( offset < 0L || offset >= length || ( offset > 0L && offset != task.getResumeOffset() ) )
549                 {
550                     throw new IOException( "Invalid Content-Range header for partial download from offset "
551                         + task.getResumeOffset() + ": " + range );
552                 }
553             }
554 
555             InputStream is = entity.getContent();
556             utilGet( task, is, true, length, offset > 0L );
557             extractChecksums( response );
558         }
559 
560         private void extractChecksums( HttpResponse response )
561         {
562             // Nexus-style, ETag: "{SHA1{d40d68ba1f88d8e9b0040f175a6ff41928abd5e7}}"
563             String etag = getHeader( response, HttpHeaders.ETAG );
564             if ( etag != null )
565             {
566                 int start = etag.indexOf( "SHA1{" ), end = etag.indexOf( "}", start + 5 );
567                 if ( start >= 0 && end > start )
568                 {
569                     task.setChecksum( "SHA-1", etag.substring( start + 5, end ) );
570                 }
571             }
572         }
573 
574         private String getHeader( HttpResponse response, String name )
575         {
576             Header header = response.getFirstHeader( name );
577             return ( header != null ) ? header.getValue() : null;
578         }
579 
580     }
581 
582     private class PutTaskEntity
583         extends AbstractHttpEntity
584     {
585 
586         private final PutTask task;
587 
588         PutTaskEntity( PutTask task )
589         {
590             this.task = task;
591         }
592 
593         @Override
594         public boolean isRepeatable()
595         {
596             return true;
597         }
598 
599         @Override
600         public boolean isStreaming()
601         {
602             return false;
603         }
604 
605         @Override
606         public long getContentLength()
607         {
608             return task.getDataLength();
609         }
610 
611         @Override
612         public InputStream getContent()
613             throws IOException
614         {
615             return task.newInputStream();
616         }
617 
618         @Override
619         public void writeTo( OutputStream os )
620             throws IOException
621         {
622             try
623             {
624                 utilPut( task, os, false );
625             }
626             catch ( TransferCancelledException e )
627             {
628                 throw (IOException) new InterruptedIOException().initCause( e );
629             }
630         }
631 
632     }
633 
634 }