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