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