/* * Kontalk Java client * Copyright (C) 2016 Kontalk Devteam <devteam@kontalk.org> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package org.kontalk.client; import javax.net.ssl.SSLContext; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.net.URI; import java.nio.file.Path; import java.nio.file.Paths; import java.security.KeyManagementException; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.UnrecoverableKeyException; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.apache.commons.io.output.CountingOutputStream; import org.apache.commons.lang.StringUtils; import org.apache.http.Header; import org.apache.http.HttpEntity; import org.apache.http.HttpStatus; import org.apache.http.client.config.RequestConfig; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpPut; import org.apache.http.client.methods.HttpRequestBase; import org.apache.http.client.utils.HttpClientUtils; import org.apache.http.entity.InputStreamEntity; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.impl.client.HttpClients; import org.apache.http.util.EntityUtils; import org.kontalk.misc.KonException; import org.kontalk.util.EncodingUtils; import org.kontalk.util.MediaUtils; import org.kontalk.util.TrustUtils; /** * HTTP file transfer client. * @author Alexander Bikadorov {@literal <bikaejkb@mail.tu-berlin.de>} */ public class HTTPFileClient { private static final Logger LOGGER = Logger.getLogger(HTTPFileClient.class.getName()); /** Regex used to parse content-disposition headers for download. */ private static final Pattern CONTENT_DISPOSITION_PATTERN = Pattern .compile("attachment;\\s*filename\\s*=\\s*\"([^\"]*)\""); /** Message flags header for upload. */ private static final String HEADER_MESSAGE_FLAGS = "X-Message-Flags"; private final PrivateKey mPrivateKey; private final X509Certificate mCertificate; private final boolean mValidateCertificate; private HttpRequestBase mCurrentRequest; private CloseableHttpClient mHTTPClient = null; private ProgressListener mCurrentListener = null; public HTTPFileClient(PrivateKey privateKey, X509Certificate bridgeCert, boolean validateCertificate) { mPrivateKey = privateKey; mCertificate = bridgeCert; mValidateCertificate = validateCertificate; } // TODO unused public void abort() { if (mCurrentRequest != null){ mCurrentRequest.abort(); mCurrentRequest = null; } if (mCurrentListener != null) { mCurrentListener.updateProgress(-3); mCurrentListener = null; } } /** * Download file to directory. * @param url URL of file * @param base base directory in which the download is saved * @return absolute path of downloaded file, empty if download failed */ public synchronized Path download(URI url, Path base, ProgressListener listener) throws KonException { if (mHTTPClient == null) { mHTTPClient = httpClientOrNull(mPrivateKey, mCertificate, mValidateCertificate); if (mHTTPClient == null) throw new KonException(KonException.Error.DOWNLOAD_CREATE); } LOGGER.config("from URL=" + url+ " ..."); mCurrentRequest = new HttpGet(url); mCurrentListener = listener; // execute request CloseableHttpResponse response = null; try { try { response = mHTTPClient.execute(mCurrentRequest); } catch (IOException ex) { LOGGER.log(Level.WARNING, "can't execute request", ex); throw new KonException(KonException.Error.DOWNLOAD_EXECUTE); } int code = response.getStatusLine().getStatusCode(); if (code != HttpStatus.SC_OK) { LOGGER.warning("unexpected response code: " + code); throw new KonException(KonException.Error.DOWNLOAD_RESPONSE); } HttpEntity entity = response.getEntity(); if (entity == null) { LOGGER.warning("no download response entity"); throw new KonException(KonException.Error.DOWNLOAD_RESPONSE); } // try getting filename from header String filename = ""; Header dispHeader = response.getFirstHeader("Content-Disposition"); if (dispHeader != null) { filename = parseContentDisposition(dispHeader.getValue()); // never trust incoming data filename = Paths.get(filename).getFileName().toString(); if (filename.isEmpty()) { LOGGER.warning("can't parse filename in content: "+dispHeader.getValue()); } } // NOTE: could try getting the extension (and filename) from URL, security? if (filename.isEmpty()) { // fallback String type = StringUtils.defaultString(entity.getContentType().getValue()); String ext = MediaUtils.extensionForMIME(type); filename = "att_" + EncodingUtils.randomString(4) + "." + ext; } // get file size long s = -1; Header lengthHeader = response.getFirstHeader("Content-Length"); if (lengthHeader == null) { LOGGER.warning("no length header"); } else { try { s = Long.parseLong(lengthHeader.getValue()); } catch (NumberFormatException ex) { LOGGER.log(Level.WARNING, "can' parse file size", ex); } } final long fileSize = s; mCurrentListener.updateProgress(s < 0 ? -2 : 0); File outFile = MediaUtils.nonExistingFileForPath(Paths.get(base.toString(), filename)); try (FileOutputStream out = new FileOutputStream(outFile)){ CountingOutputStream cOut = new CountingOutputStream(out) { @Override protected synchronized void afterWrite(int n) { if (fileSize <= 0) return; // inform listener mCurrentListener.updateProgress( (int) (this.getByteCount() /(fileSize * 1.0) * 100)); } }; entity.writeTo(cOut); } catch (IOException ex) { LOGGER.log(Level.WARNING, "can't download file", ex); throw new KonException(KonException.Error.DOWNLOAD_WRITE); } // release http connection resource EntityUtils.consumeQuietly(entity); return outFile.toPath(); } finally { HttpClientUtils.closeQuietly(response); mCurrentRequest = null; mCurrentListener = null; } } /** * Upload file using a PUT request. */ public synchronized void upload(File file, URI uploadURL, String mime, boolean encrypted) throws KonException { if (mHTTPClient == null) { mHTTPClient = httpClientOrNull(mPrivateKey, mCertificate, mValidateCertificate); if (mHTTPClient == null) throw new KonException(KonException.Error.UPLOAD_CREATE); } // request HttpPut req = new HttpPut(uploadURL); req.setHeader("Content-Type", mime); if (encrypted) req.addHeader(HEADER_MESSAGE_FLAGS, "encrypted"); LOGGER.config("to URL=" + uploadURL+ " ..."); // execute request CloseableHttpResponse response = null; try { try(FileInputStream in = new FileInputStream(file)) { req.setEntity(new InputStreamEntity(in, file.length())); mCurrentRequest = req; //response = execute(currentRequest); response = mHTTPClient.execute(mCurrentRequest); } catch (IOException ex) { LOGGER.log(Level.WARNING, "can't upload file", ex); throw new KonException(KonException.Error.UPLOAD_EXECUTE); } int code = response.getStatusLine().getStatusCode(); if (code != HttpStatus.SC_OK) { LOGGER.warning("unexpected response code: " + code); throw new KonException(KonException.Error.UPLOAD_RESPONSE); } } finally { HttpClientUtils.closeQuietly(response); mCurrentRequest = null; } } private static CloseableHttpClient httpClientOrNull(PrivateKey privateKey, X509Certificate certificate, boolean validateCertificate) { HttpClientBuilder clientBuilder = HttpClients.custom(); try { SSLContext sslContext = TrustUtils.getCustomSSLContext(privateKey, certificate, validateCertificate); clientBuilder.setSslcontext(sslContext); } catch (KeyStoreException | NoSuchAlgorithmException | CertificateException | IOException | KeyManagementException | UnrecoverableKeyException ex) { LOGGER.log(Level.WARNING, "unable to set SSL context", ex); return null; } RequestConfig requestConfig = RequestConfig.custom() // handle redirects :) TODO ? .setRedirectsEnabled(true) // HttpClient bug caused by Lighttpd .setExpectContinueEnabled(false) .setConnectTimeout(10 * 1000) .setSocketTimeout(10 * 1000) .build(); clientBuilder.setDefaultRequestConfig(requestConfig); // create connection manager //ClientConnectionManager connMgr = new SingleClientConnManager(params, registry); return clientBuilder.build(); } /* * Parse the Content-Disposition HTTP Header. The format of the header * is defined here: http://www.w3.org/Protocols/rfc2616/rfc2616-sec19.html * This header provides a filename for content that is going to be * downloaded to the file system. We only support the attachment type. */ private static String parseContentDisposition(String contentDisposition) { Matcher m = CONTENT_DISPOSITION_PATTERN.matcher(contentDisposition); if (m.find()) return StringUtils.defaultString(m.group(1)); return ""; } public interface ProgressListener { void updateProgress(int percent); } }