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