001/**
002 *
003 * Copyright © 2017 Grigory Fedorov
004 *
005 * Licensed under the Apache License, Version 2.0 (the "License");
006 * you may not use this file except in compliance with the License.
007 * You may obtain a copy of the License at
008 *
009 *     http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.jivesoftware.smackx.httpfileupload;
018
019import java.io.BufferedInputStream;
020import java.io.File;
021import java.io.FileInputStream;
022import java.io.FileNotFoundException;
023import java.io.IOException;
024import java.io.InputStream;
025import java.io.OutputStream;
026import java.net.HttpURLConnection;
027import java.net.URL;
028import java.util.List;
029import java.util.Map;
030import java.util.Objects;
031import java.util.WeakHashMap;
032import java.util.logging.Level;
033import java.util.logging.Logger;
034
035import javax.net.ssl.HttpsURLConnection;
036import javax.net.ssl.SSLContext;
037import javax.net.ssl.SSLSocketFactory;
038
039import org.jivesoftware.smack.AbstractXMPPConnection;
040import org.jivesoftware.smack.ConnectionCreationListener;
041import org.jivesoftware.smack.ConnectionListener;
042import org.jivesoftware.smack.Manager;
043import org.jivesoftware.smack.SmackException;
044import org.jivesoftware.smack.XMPPConnection;
045import org.jivesoftware.smack.XMPPConnectionRegistry;
046import org.jivesoftware.smack.XMPPException;
047import org.jivesoftware.smack.XMPPException.XMPPErrorException;
048
049import org.jivesoftware.smack.proxy.ProxyInfo;
050import org.jivesoftware.smackx.disco.ServiceDiscoveryManager;
051import org.jivesoftware.smackx.disco.packet.DiscoverInfo;
052import org.jivesoftware.smackx.httpfileupload.UploadService.Version;
053import org.jivesoftware.smackx.httpfileupload.element.Slot;
054import org.jivesoftware.smackx.httpfileupload.element.SlotRequest;
055import org.jivesoftware.smackx.httpfileupload.element.SlotRequest_V0_2;
056import org.jivesoftware.smackx.xdata.FormField;
057import org.jivesoftware.smackx.xdata.packet.DataForm;
058
059import org.jxmpp.jid.DomainBareJid;
060
061/**
062 * A manager for XEP-0363: HTTP File Upload.
063 *
064 * @author Grigory Fedorov
065 * @author Florian Schmaus
066 * @see <a href="http://xmpp.org/extensions/xep-0363.html">XEP-0363: HTTP File Upload</a>
067 */
068public final class HttpFileUploadManager extends Manager {
069
070    /**
071     * Namespace of XEP-0363 v0.4 or higher. Constant value {@value #NAMESPACE}.
072     *
073     * @see <a href="https://xmpp.org/extensions/attic/xep-0363-0.4.0.html">XEP-0363 v0.4.0</a>
074     */
075    public static final String NAMESPACE = "urn:xmpp:http:upload:0";
076
077    /**
078     * Namespace of XEP-0363 v0.2 or lower. Constant value {@value #NAMESPACE_0_2}.
079     *
080     * @see <a href="https://xmpp.org/extensions/attic/xep-0363-0.2.5.html">XEP-0363 v0.2.5</a>
081     */
082    public static final String NAMESPACE_0_2 = "urn:xmpp:http:upload";
083
084    private static final Logger LOGGER = Logger.getLogger(HttpFileUploadManager.class.getName());
085
086    static {
087        XMPPConnectionRegistry.addConnectionCreationListener(new ConnectionCreationListener() {
088            @Override
089            public void connectionCreated(XMPPConnection connection) {
090                getInstanceFor(connection);
091            }
092        });
093    }
094
095    private static final Map<XMPPConnection, HttpFileUploadManager> INSTANCES = new WeakHashMap<>();
096
097    private UploadService defaultUploadService;
098
099    private SSLSocketFactory tlsSocketFactory;
100
101    /**
102     * Obtain the HttpFileUploadManager responsible for a connection.
103     *
104     * @param connection the connection object.
105     * @return a HttpFileUploadManager instance
106     */
107    public static synchronized HttpFileUploadManager getInstanceFor(XMPPConnection connection) {
108        HttpFileUploadManager httpFileUploadManager = INSTANCES.get(connection);
109
110        if (httpFileUploadManager == null) {
111            httpFileUploadManager = new HttpFileUploadManager(connection);
112            INSTANCES.put(connection, httpFileUploadManager);
113        }
114
115        return httpFileUploadManager;
116    }
117
118    private HttpFileUploadManager(XMPPConnection connection) {
119        super(connection);
120
121        connection.addConnectionListener(new ConnectionListener() {
122            @Override
123            public void authenticated(XMPPConnection connection, boolean resumed) {
124                // No need to reset the cache if the connection got resumed.
125                if (resumed) {
126                    return;
127                }
128
129                try {
130                    discoverUploadService();
131                } catch (XMPPException.XMPPErrorException | SmackException.NotConnectedException
132                        | SmackException.NoResponseException | InterruptedException e) {
133                    LOGGER.log(Level.WARNING, "Error during discovering HTTP File Upload service", e);
134                }
135            }
136        });
137    }
138
139    private static UploadService uploadServiceFrom(DiscoverInfo discoverInfo) {
140        assert containsHttpFileUploadNamespace(discoverInfo);
141
142        UploadService.Version version;
143        if (discoverInfo.containsFeature(NAMESPACE)) {
144            version = Version.v0_3;
145        } else if (discoverInfo.containsFeature(NAMESPACE_0_2)) {
146            version = Version.v0_2;
147        } else {
148            throw new AssertionError();
149        }
150
151        DomainBareJid address = discoverInfo.getFrom().asDomainBareJid();
152
153        DataForm dataForm = DataForm.from(discoverInfo);
154        if (dataForm == null) {
155            return new UploadService(address, version);
156        }
157
158        FormField field = dataForm.getField("max-file-size");
159        if (field == null) {
160            return new UploadService(address, version);
161        }
162
163        String maxFileSizeValue = field.getFirstValue();
164        if (maxFileSizeValue == null) {
165            // This is likely an implementation error of the upload component, because the max-file-size form field is
166            // there but has no value set.
167            return new UploadService(address, version);
168
169        }
170
171        Long maxFileSize = Long.valueOf(maxFileSizeValue);
172        return new UploadService(address, version, maxFileSize);
173    }
174
175    /**
176     * Discover upload service.
177     *
178     * Called automatically when connection is authenticated.
179     *
180     * Note that this is a synchronous call -- Smack must wait for the server response.
181     *
182     * @return true if upload service was discovered
183
184     * @throws XMPPException.XMPPErrorException if there was an XMPP error returned.
185     * @throws SmackException.NotConnectedException if the XMPP connection is not connected.
186     * @throws InterruptedException if the calling thread was interrupted.
187     * @throws SmackException.NoResponseException if there was no response from the remote entity.
188     */
189    public boolean discoverUploadService() throws XMPPException.XMPPErrorException, SmackException.NotConnectedException,
190            InterruptedException, SmackException.NoResponseException {
191        ServiceDiscoveryManager sdm = ServiceDiscoveryManager.getInstanceFor(connection());
192        List<DiscoverInfo> servicesDiscoverInfo = sdm
193                .findServicesDiscoverInfo(NAMESPACE, true, true);
194
195        if (servicesDiscoverInfo.isEmpty()) {
196            servicesDiscoverInfo = sdm.findServicesDiscoverInfo(NAMESPACE_0_2, true, true);
197            if (servicesDiscoverInfo.isEmpty()) {
198                return false;
199            }
200        }
201
202        DiscoverInfo discoverInfo = servicesDiscoverInfo.get(0);
203
204        defaultUploadService = uploadServiceFrom(discoverInfo);
205        return true;
206    }
207
208    /**
209     * Check if upload service was discovered.
210     *
211     * @return true if upload service was discovered
212     */
213    public boolean isUploadServiceDiscovered() {
214        return defaultUploadService != null;
215    }
216
217    /**
218     * Get default upload service if it was discovered.
219     *
220     * @return upload service JID or null if not available
221     */
222    public UploadService getDefaultUploadService() {
223        return defaultUploadService;
224    }
225
226    /**
227     * Request slot and uploaded file to HTTP file upload service.
228     *
229     * You don't need to request slot and upload file separately, this method will do both.
230     * Note that this is a synchronous call -- Smack must wait for the server response.
231     *
232     * @param file file to be uploaded
233     * @return public URL for sharing uploaded file
234     * @throws InterruptedException if the calling thread was interrupted.
235     * @throws XMPPException.XMPPErrorException if there was an XMPP error returned.
236     * @throws SmackException if Smack detected an exceptional situation.
237     * @throws IOException in case of HTTP upload errors
238     */
239    public URL uploadFile(File file) throws InterruptedException, XMPPException.XMPPErrorException,
240            SmackException, IOException {
241        return uploadFile(file, null);
242    }
243
244    /**
245     * Request slot and uploaded file to HTTP file upload service with progress callback.
246     *
247     * You don't need to request slot and upload file separately, this method will do both.
248     * Note that this is a synchronous call -- Smack must wait for the server response.
249     *
250     * @param file file to be uploaded
251     * @param listener Upload progress listener or null
252     * @return public URL for sharing uploaded file
253     *
254     * @throws InterruptedException if the calling thread was interrupted.
255     * @throws XMPPException.XMPPErrorException if there was an XMPP error returned.
256     * @throws SmackException if Smack detected an exceptional situation.
257     * @throws IOException if an I/O error occurred.
258     */
259    public URL uploadFile(File file, UploadProgressListener listener) throws InterruptedException,
260            XMPPException.XMPPErrorException, SmackException, IOException {
261        if (!file.isFile()) {
262            throw new FileNotFoundException("The path " + file.getAbsolutePath() + " is not a file");
263        }
264        final Slot slot = requestSlot(file.getName(), file.length(), "application/octet-stream");
265        final long fileSize = file.length();
266        // Construct the FileInputStream first to make sure we can actually read the file.
267        final FileInputStream fis = new FileInputStream(file);
268        upload(fis, fileSize, slot, listener);
269        return slot.getGetUrl();
270    }
271
272    /**
273     * Request slot and uploaded stream to HTTP upload service.
274     *
275     * You don't need to request slot and upload input stream separately, this method will do both.
276     * Note that this is a synchronous call -- Smack must wait for the server response.
277     *
278     * @param inputStream Input stream used for the upload.
279     * @param fileName Name of the file.
280     * @param fileSize Size of the file.
281     * @return public URL for sharing uploaded file
282     * @throws XMPPErrorException XMPPErrorException if there was an XMPP error returned.
283     * @throws InterruptedException If the calling thread was interrupted.
284     * @throws SmackException If Smack detected an exceptional situation.
285     * @throws IOException If an I/O error occurred.
286     */
287    public URL uploadFile(InputStream inputStream, String fileName, long fileSize) throws XMPPErrorException, InterruptedException, SmackException, IOException {
288        return uploadFile(inputStream, fileName, fileSize, null);
289    }
290
291    /**
292     * Request slot and uploaded stream to HTTP upload service.
293     *
294     * You don't need to request slot and upload input stream separately, this method will do both.
295     * Note that this is a synchronous call -- Smack must wait for the server response.
296     *
297     * @param inputStream Input stream used for the upload.
298     * @param fileName Name of the file.
299     * @param fileSize file size in bytes.
300     * @param listener upload progress listener or null.
301     * @return public URL for sharing uploaded file
302     * @throws XMPPErrorException XMPPErrorException if there was an XMPP error returned.
303     * @throws InterruptedException If the calling thread was interrupted.
304     * @throws SmackException If Smack detected an exceptional situation.
305     * @throws IOException If an I/O error occurred.
306     */
307    public URL uploadFile(InputStream inputStream, String fileName, long fileSize, UploadProgressListener listener) throws XMPPErrorException, InterruptedException, SmackException, IOException {
308        Objects.requireNonNull(inputStream, "Input Stream cannot be null");
309        Objects.requireNonNull(fileName, "Filename Stream cannot be null");
310        if (fileSize < 0) {
311            throw new IllegalArgumentException("File size cannot be negative");
312        }
313        final Slot slot = requestSlot(fileName, fileSize, "application/octet-stream");
314        upload(inputStream, fileSize, slot, listener);
315        return slot.getGetUrl();
316    }
317
318    /**
319     * Request a new upload slot from default upload service (if discovered). When you get slot you should upload file
320     * to PUT URL and share GET URL. Note that this is a synchronous call -- Smack must wait for the server response.
321     *
322     * @param filename name of file to be uploaded
323     * @param fileSize file size in bytes.
324     * @return file upload Slot in case of success
325     * @throws IllegalArgumentException if fileSize is less than or equal to zero or greater than the maximum size
326     *         supported by the service.
327     * @throws InterruptedException if the calling thread was interrupted.
328     * @throws XMPPException.XMPPErrorException if there was an XMPP error returned.
329     * @throws SmackException if smack exception.
330     */
331    public Slot requestSlot(String filename, long fileSize) throws InterruptedException,
332            XMPPException.XMPPErrorException, SmackException {
333        return requestSlot(filename, fileSize, null, null);
334    }
335
336    /**
337     * Request a new upload slot with optional content type from default upload service (if discovered).
338     *
339     * When you get slot you should upload file to PUT URL and share GET URL.
340     * Note that this is a synchronous call -- Smack must wait for the server response.
341     *
342     * @param filename name of file to be uploaded
343     * @param fileSize file size in bytes.
344     * @param contentType file content-type or null
345     * @return file upload Slot in case of success
346
347     * @throws IllegalArgumentException if fileSize is less than or equal to zero or greater than the maximum size
348     *         supported by the service.
349     * @throws SmackException.NotConnectedException if the XMPP connection is not connected.
350     * @throws InterruptedException if the calling thread was interrupted.
351     * @throws XMPPException.XMPPErrorException if there was an XMPP error returned.
352     * @throws SmackException if smack exception.
353     */
354    public Slot requestSlot(String filename, long fileSize, String contentType) throws SmackException,
355            InterruptedException, XMPPException.XMPPErrorException {
356        return requestSlot(filename, fileSize, contentType, null);
357    }
358
359    /**
360     * Request a new upload slot with optional content type from custom upload service.
361     *
362     * When you get slot you should upload file to PUT URL and share GET URL.
363     * Note that this is a synchronous call -- Smack must wait for the server response.
364     *
365     * @param filename name of file to be uploaded
366     * @param fileSize file size in bytes.
367     * @param contentType file content-type or null
368     * @param uploadServiceAddress the address of the upload service to use or null for default one
369     * @return file upload Slot in case of success
370     * @throws IllegalArgumentException if fileSize is less than or equal to zero or greater than the maximum size
371     *         supported by the service.
372     * @throws SmackException if Smack detected an exceptional situation.
373     * @throws InterruptedException if the calling thread was interrupted.
374     * @throws XMPPException.XMPPErrorException if there was an XMPP error returned.
375     */
376    public Slot requestSlot(String filename, long fileSize, String contentType, DomainBareJid uploadServiceAddress)
377            throws SmackException, InterruptedException, XMPPException.XMPPErrorException {
378        final XMPPConnection connection = connection();
379        final UploadService defaultUploadService = this.defaultUploadService;
380
381        // The upload service we are going to use.
382        UploadService uploadService;
383
384        if (uploadServiceAddress == null) {
385            uploadService = defaultUploadService;
386        } else {
387            if (defaultUploadService != null && defaultUploadService.getAddress().equals(uploadServiceAddress)) {
388                // Avoid performing a service discovery if we already know about the given service.
389                uploadService = defaultUploadService;
390            } else {
391                DiscoverInfo discoverInfo = ServiceDiscoveryManager.getInstanceFor(connection).discoverInfo(uploadServiceAddress);
392                if (!containsHttpFileUploadNamespace(discoverInfo)) {
393                    throw new IllegalArgumentException("There is no HTTP upload service running at the given address '"
394                                    + uploadServiceAddress + '\'');
395                }
396                uploadService = uploadServiceFrom(discoverInfo);
397            }
398        }
399
400        if (uploadService == null) {
401            throw new SmackException.SmackMessageException("No upload service specified and also none discovered.");
402        }
403
404        if (!uploadService.acceptsFileOfSize(fileSize)) {
405            throw new IllegalArgumentException(
406                            "Requested file size " + fileSize + " is greater than max allowed size " + uploadService.getMaxFileSize());
407        }
408
409        SlotRequest slotRequest;
410        switch (uploadService.getVersion()) {
411        case v0_3:
412            slotRequest = new SlotRequest(uploadService.getAddress(), filename, fileSize, contentType);
413            break;
414        case v0_2:
415            slotRequest = new SlotRequest_V0_2(uploadService.getAddress(), filename, fileSize, contentType);
416            break;
417        default:
418            throw new AssertionError();
419        }
420
421        return connection.createStanzaCollectorAndSend(slotRequest).nextResultOrThrow();
422    }
423
424    public void setTlsContext(SSLContext tlsContext) {
425        if (tlsContext == null) {
426            return;
427        }
428        this.tlsSocketFactory = tlsContext.getSocketFactory();
429    }
430
431    private void upload(InputStream iStream, long fileSize, Slot slot, UploadProgressListener listener) throws IOException {
432        final URL putUrl = slot.getPutUrl();
433        final XMPPConnection connection = connection();
434        final HttpURLConnection urlConnection = createURLConnection(connection, putUrl);
435
436        urlConnection.setRequestMethod("PUT");
437        urlConnection.setUseCaches(false);
438        urlConnection.setDoOutput(true);
439        urlConnection.setFixedLengthStreamingMode(fileSize);
440        urlConnection.setRequestProperty("Content-Type", "application/octet-stream");
441        for (Map.Entry<String, String> header : slot.getHeaders().entrySet()) {
442            urlConnection.setRequestProperty(header.getKey(), header.getValue());
443        }
444
445        final SSLSocketFactory tlsSocketFactory = this.tlsSocketFactory;
446        if (tlsSocketFactory != null && urlConnection instanceof HttpsURLConnection) {
447            HttpsURLConnection httpsUrlConnection = (HttpsURLConnection) urlConnection;
448            httpsUrlConnection.setSSLSocketFactory(tlsSocketFactory);
449        }
450
451        try {
452            OutputStream outputStream = urlConnection.getOutputStream();
453
454            long bytesSend = 0;
455
456            if (listener != null) {
457                listener.onUploadProgress(0, fileSize);
458            }
459
460            BufferedInputStream inputStream = new BufferedInputStream(iStream);
461
462            // TODO Factor in extra static method (and re-use e.g. in bytestream code).
463            byte[] buffer = new byte[4096];
464            int bytesRead;
465            try {
466                while ((bytesRead = inputStream.read(buffer)) != -1) {
467                    outputStream.write(buffer, 0, bytesRead);
468                    bytesSend += bytesRead;
469
470                    if (listener != null) {
471                        listener.onUploadProgress(bytesSend, fileSize);
472                    }
473                }
474            }
475            finally {
476                try {
477                    inputStream.close();
478                }
479                catch (IOException e) {
480                    LOGGER.log(Level.WARNING, "Exception while closing input stream", e);
481                }
482                try {
483                    outputStream.close();
484                }
485                catch (IOException e) {
486                    LOGGER.log(Level.WARNING, "Exception while closing output stream", e);
487                }
488            }
489
490            int status = urlConnection.getResponseCode();
491            switch (status) {
492            case HttpURLConnection.HTTP_OK:
493            case HttpURLConnection.HTTP_CREATED:
494            case HttpURLConnection.HTTP_NO_CONTENT:
495                break;
496            default:
497                throw new IOException("Error response " + status + " from server during file upload: "
498                                + urlConnection.getResponseMessage() + ", file size: " + fileSize + ", put URL: "
499                                + putUrl);
500            }
501        }
502        finally {
503            urlConnection.disconnect();
504        }
505    }
506
507    private static HttpURLConnection createURLConnection(XMPPConnection connection, URL putUrl) throws IOException {
508        Objects.requireNonNull(connection);
509        Objects.requireNonNull(putUrl);
510        ProxyInfo proxyInfo = fetchProxyInfo(connection);
511        if (proxyInfo != null) {
512            return createProxiedURLConnection(proxyInfo, putUrl);
513        }
514        return (HttpURLConnection) putUrl.openConnection();
515    }
516
517    private static HttpURLConnection createProxiedURLConnection(ProxyInfo proxyInfo, URL putUrl) throws IOException {
518        Objects.requireNonNull(proxyInfo);
519        Objects.requireNonNull(putUrl);
520        return (HttpURLConnection) putUrl.openConnection(proxyInfo.toJavaProxy());
521    }
522
523    private static ProxyInfo fetchProxyInfo(XMPPConnection connection) {
524        if (!(connection instanceof AbstractXMPPConnection)) {
525            return null;
526        }
527        AbstractXMPPConnection xmppConnection = (AbstractXMPPConnection) connection;
528        return xmppConnection.getConfiguration().getProxyInfo();
529    }
530
531    public static UploadService.Version namespaceToVersion(String namespace) {
532        UploadService.Version version;
533        switch (namespace) {
534        case NAMESPACE:
535            version = Version.v0_3;
536            break;
537        case NAMESPACE_0_2:
538            version = Version.v0_2;
539            break;
540        default:
541            version = null;
542            break;
543        }
544        return version;
545    }
546
547    private static boolean containsHttpFileUploadNamespace(DiscoverInfo discoverInfo) {
548        return discoverInfo.containsFeature(NAMESPACE) || discoverInfo.containsFeature(NAMESPACE_0_2);
549    }
550}