package org.sonatype.nexus.proxy.storage.remote.ahc; import java.net.MalformedURLException; import java.net.URL; import org.codehaus.plexus.component.annotations.Component; import org.codehaus.plexus.component.annotations.Requirement; import org.sonatype.nexus.ahc.AhcProvider; import org.sonatype.nexus.proxy.ItemNotFoundException; import org.sonatype.nexus.proxy.RemoteAccessDeniedException; import org.sonatype.nexus.proxy.RemoteAccessException; import org.sonatype.nexus.proxy.RemoteAuthenticationNeededException; import org.sonatype.nexus.proxy.RemoteStorageException; import org.sonatype.nexus.proxy.ResourceStoreRequest; import org.sonatype.nexus.proxy.item.AbstractStorageItem; import org.sonatype.nexus.proxy.item.DefaultStorageFileItem; import org.sonatype.nexus.proxy.item.PreparedContentLocator; import org.sonatype.nexus.proxy.item.RepositoryItemUid; import org.sonatype.nexus.proxy.item.StorageFileItem; import org.sonatype.nexus.proxy.item.StorageItem; import org.sonatype.nexus.proxy.repository.ProxyRepository; import org.sonatype.nexus.proxy.storage.UnsupportedStorageOperationException; import org.sonatype.nexus.proxy.storage.remote.AbstractRemoteRepositoryStorage; import org.sonatype.nexus.proxy.storage.remote.DefaultRemoteStorageContext.BooleanFlagHolder; import org.sonatype.nexus.proxy.storage.remote.RemoteRepositoryStorage; import org.sonatype.nexus.proxy.storage.remote.RemoteStorageContext; import com.ning.http.client.AsyncHttpClient; import com.ning.http.client.AsyncHttpClientConfig; import com.ning.http.client.BodyDeferringAsyncHandler.BodyDeferringInputStream; import com.ning.http.client.Response; /** * AsyncHttpClient powered RemoteRepositoryStorage. * * @author cstamas */ @Component( role = RemoteRepositoryStorage.class, hint = AhcRemoteRepositoryStorage.PROVIDER_STRING ) public class AhcRemoteRepositoryStorage extends AbstractRemoteRepositoryStorage { public static final String PROVIDER_STRING = "async-http-client"; private static final String CTX_KEY = PROVIDER_STRING; private static final String CTX_KEY_CLIENT = CTX_KEY + ".client"; private static final String CTX_KEY_S3_FLAG = CTX_KEY + ".remoteIsAmazonS3"; @Requirement private AhcProvider ahcProvider; @Override public String getProviderId() { return PROVIDER_STRING; } @Override public void validateStorageUrl( String url ) throws RemoteStorageException { try { URL u = new URL( url ); if ( !"http".equals( u.getProtocol().toLowerCase() ) && !"https".equals( u.getProtocol().toLowerCase() ) ) { throw new RemoteStorageException( "Unsupported protocol, only HTTP/HTTPS protocols are supported: " + u.getProtocol().toLowerCase() ); } } catch ( MalformedURLException e ) { throw new RemoteStorageException( "Malformed URL", e ); } } @Override public boolean isReachable( ProxyRepository repository, ResourceStoreRequest request ) throws RemoteAccessException, RemoteStorageException { boolean result = false; try { request.pushRequestPath( RepositoryItemUid.PATH_ROOT ); try { result = checkRemoteAvailability( 0, repository, request, false ); } catch ( RemoteAccessDeniedException e ) { // NEXUS-3338: we have to swallow this on S3 if ( isRemotePeerAmazonS3Storage( repository ) ) { // this is S3 remote, and we got 403: just say all is well (for now) return true; } else { throw e; } } } finally { request.popRequestPath(); } return result; } @Override public boolean containsItem( long newerThen, ProxyRepository repository, ResourceStoreRequest request ) throws RemoteAccessException, RemoteStorageException { return checkRemoteAvailability( newerThen, repository, request, true ); } @Override public AbstractStorageItem retrieveItem( ProxyRepository repository, ResourceStoreRequest request, String baseUrl ) throws ItemNotFoundException, RemoteAccessException, RemoteStorageException { final URL remoteURL = getAbsoluteUrlFromBase( baseUrl, request.getRequestPath() ); final String itemUrl = remoteURL.toString(); final AsyncHttpClient client = getClient( repository ); try { BodyDeferringInputStream ris = AHCUtils.fetchContent( client, itemUrl ); // this blocks until response headers arrived Response response = ris.getAsapResponse(); // expected: 200 OK validateResponse( repository, request, "GET", itemUrl, response, 200 ); long length = AHCUtils.getContentLength( response, -1 ); long lastModified = AHCUtils.getLastModified( response, System.currentTimeMillis() ); // non-reusable simplest content locator, the ris InputStream is ready to be consumed PreparedContentLocator contentLocator = new PreparedContentLocator( ris, response.getContentType() ); DefaultStorageFileItem result = new DefaultStorageFileItem( repository, request, true /* canRead */, true /* canWrite */, contentLocator ); result.setLength( length ); result.setModified( lastModified ); result.setCreated( result.getModified() ); result.setRemoteUrl( itemUrl ); result.getItemContext().setParentContext( request.getRequestContext() ); return result; } catch ( ItemNotFoundException e ) { throw e; } catch ( RemoteStorageException e ) { throw e; } catch ( Exception e ) { throw new RemoteStorageException( e ); } } @Override public void storeItem( ProxyRepository repository, StorageItem item ) throws UnsupportedStorageOperationException, RemoteAccessException, RemoteStorageException { if ( !( item instanceof StorageFileItem ) ) { throw new UnsupportedStorageOperationException( "Storing of non-files remotely is not supported!" ); } final StorageFileItem fItem = (StorageFileItem) item; final ResourceStoreRequest request = new ResourceStoreRequest( item ); final URL remoteURL = getAbsoluteUrlFromBase( repository, request ); final String itemUrl = remoteURL.toString(); final AsyncHttpClient client = getClient( repository ); try { Response response = client.preparePut( itemUrl ).setBody( new StorageFileItemBodyGenerator( fItem ) ).execute().get(); // expected: 200 OK, 201 CREATED, 202 ACCEPTED, 204 NO_CONTENT validateResponse( repository, request, "PUT", itemUrl, response, 200, 201, 202, 204 ); } catch ( ItemNotFoundException e ) { // rather unexpected response throw new RemoteStorageException( e ); } catch ( RemoteStorageException e ) { throw e; } catch ( Exception e ) { throw new RemoteStorageException( e ); } } @Override public void deleteItem( ProxyRepository repository, ResourceStoreRequest request ) throws ItemNotFoundException, UnsupportedStorageOperationException, RemoteAccessException, RemoteStorageException { final URL remoteURL = getAbsoluteUrlFromBase( repository, request ); final String itemUrl = remoteURL.toString(); final AsyncHttpClient client = getClient( repository ); try { Response response = client.prepareDelete( itemUrl ).execute().get(); // expected: 200 OK, 202 ACCEPTED, 204 NO_CONTENT validateResponse( repository, request, "DELETE", itemUrl, response, 200, 202, 204 ); } catch ( ItemNotFoundException e ) { throw e; } catch ( RemoteStorageException e ) { throw e; } catch ( Exception e ) { throw new RemoteStorageException( e ); } } protected void validateResponse( final ProxyRepository repository, final ResourceStoreRequest request, final String method, final String remoteUrl, final Response response, int... expectedCodes ) throws ItemNotFoundException, RemoteStorageException { // maintain the S3 flag checkForRemotePeerAmazonS3Storage( repository, response ); if ( response.isRedirected() ) { getLogger().info( String.format( "Proxy repository %s (id=%s) got redirected from %s, please verify your remoteUrl is up-to-date!", repository.getName(), repository.getId(), remoteUrl ) ); } if ( AHCUtils.isAnyOfTheseStatusCodes( response, expectedCodes ) ) { // good, an expected one return; } // 404 NotFound if ( 404 == response.getStatusCode() ) { throw new ItemNotFoundException( request, repository ); } // 401 Unauthorized if ( 401 == response.getStatusCode() ) { throw new RemoteAuthenticationNeededException( repository, remoteUrl, response.getStatusText() ); } // 403 Forbidden if ( 403 == response.getStatusCode() ) { throw new RemoteAccessDeniedException( repository, remoteUrl, response.getStatusText() ); } // anything else "unexpected"? throw new RemoteStorageException( String.format( "Coult not perform %s against Url %s, unexpected response is %s", method, remoteUrl, response.getStatusText() ) ); } protected AsyncHttpClient getClient( final ProxyRepository repository ) throws RemoteStorageException { RemoteStorageContext ctx = getRemoteStorageContext( repository ); AsyncHttpClient httpClient = (AsyncHttpClient) ctx.getContextObject( CTX_KEY_CLIENT ); return httpClient; } @Override protected void updateContext( ProxyRepository repository, RemoteStorageContext context ) throws RemoteStorageException { getLogger().info( "Remote storage settings change detected for ProxyRepository ID=\"" + repository.getId() + "\" (\"" + repository.getName() + "\"), updating HTTP transport..." ); if ( context.hasContextObject( CTX_KEY_CLIENT ) ) { // proper shutdown of AHC, but cannot call getClient() here that would result in endless loop! AsyncHttpClient oldClient = (AsyncHttpClient) context.getContextObject( CTX_KEY_CLIENT ); // TODO: AHC-26: current solution would kill ongoing downloads, be smarter oldClient.close(); } final AsyncHttpClientConfig.Builder clientConfigBuilder = ahcProvider.getAsyncHttpClientConfigBuilder( repository, context ); final AsyncHttpClient client = new AsyncHttpClient( clientConfigBuilder.build() ); context.putContextObject( CTX_KEY_CLIENT, client ); context.putContextObject( CTX_KEY_S3_FLAG, new BooleanFlagHolder() ); } /** * Initially, this method is here only to share the code for "availability check" and for "contains" check. * Unfortunately, the "availability" check cannot be done at RemoteStorage level, since it is completely repository * layout unaware and is able to tell only about the existence of remote server and that the URI on it exists. This * "availability" check will have to be moved upper into repository, since it is aware of "what it holds". * Ultimately, this method will check is the remote server "present" and is responding or not. But nothing more. * * @param newerThen * @param repository * @param context * @param path * @param relaxedCheck * @return * @throws RemoteAuthenticationNeededException * @throws RemoteAccessException * @throws RemoteStorageException */ protected boolean checkRemoteAvailability( long newerThen, ProxyRepository repository, ResourceStoreRequest request, boolean isStrict ) throws RemoteAuthenticationNeededException, RemoteAccessException, RemoteStorageException { final URL remoteURL = getAbsoluteUrlFromBase( repository, request ); final String itemUrl = remoteURL.toString(); final AsyncHttpClient client = getClient( repository ); if ( getLogger().isDebugEnabled() ) { getLogger().debug( String.format( "Checking remote availability of proxy repository \"%s\" (id=%s) on URL %s", repository.getName(), repository.getId(), itemUrl ) ); } // artifactory hack, it pukes on HEAD so we will try with GET if HEAD fails boolean doGet = false; Response responseObject = null; int response = 400; try { responseObject = client.prepareHead( itemUrl ).execute().get(); response = responseObject.getStatusCode(); validateResponse( repository, request, "HEAD", itemUrl, responseObject, 200 ); } catch ( ItemNotFoundException e ) { return false; } catch ( RemoteStorageException e ) { // If HEAD failed, attempt a GET. Some repos may not support HEAD method doGet = true; getLogger().debug( "HEAD method failed, will attempt GET. Exception: " + e.getMessage(), e ); } catch ( Exception e ) { throw new RemoteStorageException( e ); } finally { // HEAD returned error, but not exception, try GET before failing if ( !doGet && response != 200 ) { // try with GET unless some known to fail responses are in doGet = ( response != 401 ) && ( response != 403 ); getLogger().debug( "HEAD method failed, will attempt GET. Status: " + response ); } } if ( doGet ) { try { responseObject = client.prepareGet( itemUrl ).execute().get(); response = responseObject.getStatusCode(); validateResponse( repository, request, "GET", itemUrl, responseObject, 200 ); } catch ( ItemNotFoundException e ) { return false; } catch ( Exception e ) { throw new RemoteStorageException( e ); } } // if we are not strict and remote is S3 if ( !isStrict && isRemotePeerAmazonS3Storage( repository ) ) { // if we are relaxed, we will accept any HTTP response code below 500. This means anyway the HTTP // transaction succeeded. This method was never really detecting that the remoteUrl really denotes a root of // repository (how could we do that?) // this "relaxed" check will help us to "pass" S3 remote storage. return response >= 200 && response < 500; } else { // non relaxed check is strict, and will select only the OK response if ( response == 200 ) { // we have it // we have newer if this below is true return AHCUtils.getLastModified( responseObject, System.currentTimeMillis() ) > newerThen; } else if ( ( response >= 300 && response < 400 ) || response == 404 ) { return false; } else { throw new RemoteStorageException( "Unexpected response code while executing GET" + " method [repositoryId=\"" + repository.getId() + "\", requestPath=\"" + request.getRequestPath() + "\", remoteUrl=\"" + itemUrl + "\"]. Expected: \"SUCCESS (200)\". Received: " + response + " : " + responseObject.getStatusText() ); } } } /** * Returns {@code true} if only and only if we are positive that remote peer (remote URL of passed in * ProxyRepository) points to a remote repository that is hosted by Amazon S3 Storage. This method will return false * as long as we don't make very 1st HTTP request to remote peer. After that 1st request, we retain the status until * ProxyRepository configuration changes. See {@link https://issues.sonatype.org/browse/NEXUS-3338} for more. * * @param repository that needs to be checked. * @return true only if we know that ProxyRepository in question points to Amazon S3 storage. * @throws RemoteStorageException in case of some error. */ public boolean isRemotePeerAmazonS3Storage( ProxyRepository repository ) throws RemoteStorageException { BooleanFlagHolder flag = (BooleanFlagHolder) getRemoteStorageContext( repository ).getContextObject( CTX_KEY_S3_FLAG ); return flag.isFlag(); } /** * Checks is remote a S3 server and puts a Boolean into remote storage context, thus preventing any further checks * (we check only once). * * @param repository * @param response * @throws RemoteStorageException */ protected void checkForRemotePeerAmazonS3Storage( ProxyRepository repository, Response response ) throws RemoteStorageException { RemoteStorageContext ctx = getRemoteStorageContext( repository ); // we already know the result, do nothing if ( !( (BooleanFlagHolder) ctx.getContextObject( CTX_KEY_S3_FLAG ) ).isNull() ) { return; } // for now, we check the HTTP response header "Server: AmazonS3" String hdr = response.getHeader( "server" ); boolean isAmazonS3 = ( hdr != null ) && ( hdr.toLowerCase().contains( "amazons3" ) ); BooleanFlagHolder holder = (BooleanFlagHolder) ctx.getContextObject( CTX_KEY_S3_FLAG ); if ( isAmazonS3 ) { holder.setFlag( Boolean.TRUE ); getLogger().warn( "The proxy repository \"" + repository.getName() + "\" (ID=" + repository.getId() + ") is backed by Amazon S3 service. This means that Nexus can't reliably detect the validity of your setup (baseUrl of proxy repository)!" ); } else { holder.setFlag( Boolean.FALSE ); } } }