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