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        String maxFileSizeValue = field.getFirstValue();
161        if (maxFileSizeValue == null) {
162            // This is likely an implementation error of the upload component, because the max-file-size form field is
163            // there but has no value set.
164            return new UploadService(address, version);
165
166        }
167
168        Long maxFileSize = Long.valueOf(maxFileSizeValue);
169        return new UploadService(address, version, maxFileSize);
170    }
171
172    /**
173     * Discover upload service.
174     *
175     * Called automatically when connection is authenticated.
176     *
177     * Note that this is a synchronous call -- Smack must wait for the server response.
178     *
179     * @return true if upload service was discovered
180
181     * @throws XMPPException.XMPPErrorException
182     * @throws SmackException.NotConnectedException
183     * @throws InterruptedException
184     * @throws SmackException.NoResponseException
185     */
186    public boolean discoverUploadService() throws XMPPException.XMPPErrorException, SmackException.NotConnectedException,
187            InterruptedException, SmackException.NoResponseException {
188        ServiceDiscoveryManager sdm = ServiceDiscoveryManager.getInstanceFor(connection());
189        List<DiscoverInfo> servicesDiscoverInfo = sdm
190                .findServicesDiscoverInfo(NAMESPACE, true, true);
191
192        if (servicesDiscoverInfo.isEmpty()) {
193            servicesDiscoverInfo = sdm.findServicesDiscoverInfo(NAMESPACE_0_2, true, true);
194            if (servicesDiscoverInfo.isEmpty()) {
195                return false;
196            }
197        }
198
199        DiscoverInfo discoverInfo = servicesDiscoverInfo.get(0);
200
201        defaultUploadService = uploadServiceFrom(discoverInfo);
202        return true;
203    }
204
205    /**
206     * Check if upload service was discovered.
207     *
208     * @return true if upload service was discovered
209     */
210    public boolean isUploadServiceDiscovered() {
211        return defaultUploadService != null;
212    }
213
214    /**
215     * Get default upload service if it was discovered.
216     *
217     * @return upload service JID or null if not available
218     */
219    public UploadService getDefaultUploadService() {
220        return defaultUploadService;
221    }
222
223    /**
224     * Request slot and uploaded file to HTTP file upload service.
225     *
226     * You don't need to request slot and upload file separately, this method will do both.
227     * Note that this is a synchronous call -- Smack must wait for the server response.
228     *
229     * @param file file to be uploaded
230     * @return public URL for sharing uploaded file
231     * @throws InterruptedException
232     * @throws XMPPException.XMPPErrorException
233     * @throws SmackException
234     * @throws IOException in case of HTTP upload errors
235     */
236    public URL uploadFile(File file) throws InterruptedException, XMPPException.XMPPErrorException,
237            SmackException, IOException {
238        return uploadFile(file, null);
239    }
240
241    /**
242     * Request slot and uploaded file to HTTP file upload service with progress callback.
243     *
244     * You don't need to request slot and upload file separately, this method will do both.
245     * Note that this is a synchronous call -- Smack must wait for the server response.
246     *
247     * @param file file to be uploaded
248     * @param listener upload progress listener of null
249     * @return public URL for sharing uploaded file
250     *
251     * @throws InterruptedException
252     * @throws XMPPException.XMPPErrorException
253     * @throws SmackException
254     * @throws IOException
255     */
256    public URL uploadFile(File file, UploadProgressListener listener) throws InterruptedException,
257            XMPPException.XMPPErrorException, SmackException, IOException {
258        if (!file.isFile()) {
259            throw new FileNotFoundException("The path " + file.getAbsolutePath() + " is not a file");
260        }
261        final Slot slot = requestSlot(file.getName(), file.length(), "application/octet-stream");
262
263        uploadFile(file, slot, listener);
264
265        return slot.getGetUrl();
266    }
267
268
269    /**
270     * Request a new upload slot from default upload service (if discovered). When you get slot you should upload file
271     * to PUT URL and share GET URL. Note that this is a synchronous call -- Smack must wait for the server response.
272     *
273     * @param filename name of file to be uploaded
274     * @param fileSize file size in bytes.
275     * @return file upload Slot in case of success
276     * @throws IllegalArgumentException if fileSize is less than or equal to zero or greater than the maximum size
277     *         supported by the service.
278     * @throws InterruptedException
279     * @throws XMPPException.XMPPErrorException
280     * @throws SmackException.NotConnectedException
281     * @throws SmackException.NoResponseException
282     */
283    public Slot requestSlot(String filename, long fileSize) throws InterruptedException,
284            XMPPException.XMPPErrorException, SmackException {
285        return requestSlot(filename, fileSize, null, null);
286    }
287
288    /**
289     * Request a new upload slot with optional content type from default upload service (if discovered).
290     *
291     * When you get slot you should upload file to PUT URL and share GET URL.
292     * Note that this is a synchronous call -- Smack must wait for the server response.
293     *
294     * @param filename name of file to be uploaded
295     * @param fileSize file size in bytes.
296     * @param contentType file content-type or null
297     * @return file upload Slot in case of success
298
299     * @throws IllegalArgumentException if fileSize is less than or equal to zero or greater than the maximum size
300     *         supported by the service.
301     * @throws SmackException.NotConnectedException
302     * @throws InterruptedException
303     * @throws XMPPException.XMPPErrorException
304     * @throws SmackException.NoResponseException
305     */
306    public Slot requestSlot(String filename, long fileSize, String contentType) throws SmackException,
307            InterruptedException, XMPPException.XMPPErrorException {
308        return requestSlot(filename, fileSize, contentType, null);
309    }
310
311    /**
312     * Request a new upload slot with optional content type from custom upload service.
313     *
314     * When you get slot you should upload file to PUT URL and share GET URL.
315     * Note that this is a synchronous call -- Smack must wait for the server response.
316     *
317     * @param filename name of file to be uploaded
318     * @param fileSize file size in bytes.
319     * @param contentType file content-type or null
320     * @param uploadServiceAddress the address of the upload service to use or null for default one
321     * @return file upload Slot in case of success
322     * @throws IllegalArgumentException if fileSize is less than or equal to zero or greater than the maximum size
323     *         supported by the service.
324     * @throws SmackException
325     * @throws InterruptedException
326     * @throws XMPPException.XMPPErrorException
327     */
328    public Slot requestSlot(String filename, long fileSize, String contentType, DomainBareJid uploadServiceAddress)
329            throws SmackException, InterruptedException, XMPPException.XMPPErrorException {
330        final XMPPConnection connection = connection();
331        final UploadService defaultUploadService = this.defaultUploadService;
332
333        // The upload service we are going to use.
334        UploadService uploadService;
335
336        if (uploadServiceAddress == null) {
337            uploadService = defaultUploadService;
338        } else {
339            if (defaultUploadService != null && defaultUploadService.getAddress().equals(uploadServiceAddress)) {
340                // Avoid performing a service discovery if we already know about the given service.
341                uploadService = defaultUploadService;
342            } else {
343                DiscoverInfo discoverInfo = ServiceDiscoveryManager.getInstanceFor(connection).discoverInfo(uploadServiceAddress);
344                if (!containsHttpFileUploadNamespace(discoverInfo)) {
345                    throw new IllegalArgumentException("There is no HTTP upload service running at the given address '"
346                                    + uploadServiceAddress + '\'');
347                }
348                uploadService = uploadServiceFrom(discoverInfo);
349            }
350        }
351
352        if (uploadService == null) {
353            throw new SmackException("No upload service specified and also none discovered.");
354        }
355
356        if (!uploadService.acceptsFileOfSize(fileSize)) {
357            throw new IllegalArgumentException(
358                            "Requested file size " + fileSize + " is greater than max allowed size " + uploadService.getMaxFileSize());
359        }
360
361        SlotRequest slotRequest;
362        switch (uploadService.getVersion()) {
363        case v0_3:
364            slotRequest = new SlotRequest(uploadService.getAddress(), filename, fileSize, contentType);
365            break;
366        case v0_2:
367            slotRequest = new SlotRequest_V0_2(uploadService.getAddress(), filename, fileSize, contentType);
368            break;
369        default:
370            throw new AssertionError();
371        }
372
373        return connection.createStanzaCollectorAndSend(slotRequest).nextResultOrThrow();
374    }
375
376    public void setTlsContext(SSLContext tlsContext) {
377        if (tlsContext == null) {
378            return;
379        }
380        this.tlsSocketFactory = tlsContext.getSocketFactory();
381    }
382
383    public void useTlsSettingsFrom(ConnectionConfiguration connectionConfiguration) {
384        SSLContext sslContext = connectionConfiguration.getCustomSSLContext();
385        setTlsContext(sslContext);
386    }
387
388    private void uploadFile(final File file, final Slot slot, UploadProgressListener listener) throws IOException {
389        final long fileSize = file.length();
390        // TODO Remove once Smack's minimum Android API level is 19 or higher. See also comment below.
391        if (fileSize >= Integer.MAX_VALUE) {
392            throw new IllegalArgumentException("File size " + fileSize + " must be less than " + Integer.MAX_VALUE);
393        }
394        final int fileSizeInt = (int) fileSize;
395
396        // Construct the FileInputStream first to make sure we can actually read the file.
397        final FileInputStream fis = new FileInputStream(file);
398
399        final URL putUrl = slot.getPutUrl();
400
401        final HttpURLConnection urlConnection = (HttpURLConnection) putUrl.openConnection();
402
403        urlConnection.setRequestMethod("PUT");
404        urlConnection.setUseCaches(false);
405        urlConnection.setDoOutput(true);
406        // TODO Change to using fileSize once Smack's minimum Android API level is 19 or higher.
407        urlConnection.setFixedLengthStreamingMode(fileSizeInt);
408        urlConnection.setRequestProperty("Content-Type", "application/octet-stream;");
409        for (Entry<String, String> header : slot.getHeaders().entrySet()) {
410            urlConnection.setRequestProperty(header.getKey(), header.getValue());
411        }
412
413        final SSLSocketFactory tlsSocketFactory = this.tlsSocketFactory;
414        if (tlsSocketFactory != null && urlConnection instanceof HttpsURLConnection) {
415            HttpsURLConnection httpsUrlConnection = (HttpsURLConnection) urlConnection;
416            httpsUrlConnection.setSSLSocketFactory(tlsSocketFactory);
417        }
418
419        try {
420            OutputStream outputStream = urlConnection.getOutputStream();
421
422            long bytesSend = 0;
423
424            if (listener != null) {
425                listener.onUploadProgress(0, fileSize);
426            }
427
428            BufferedInputStream inputStream = new BufferedInputStream(fis);
429
430            // TODO Factor in extra static method (and re-use e.g. in bytestream code).
431            byte[] buffer = new byte[4096];
432            int bytesRead;
433            try {
434                while ((bytesRead = inputStream.read(buffer)) != -1) {
435                    outputStream.write(buffer, 0, bytesRead);
436                    bytesSend += bytesRead;
437
438                    if (listener != null) {
439                        listener.onUploadProgress(bytesSend, fileSize);
440                    }
441                }
442            }
443            finally {
444                try {
445                    inputStream.close();
446                }
447                catch (IOException e) {
448                    LOGGER.log(Level.WARNING, "Exception while closing input stream", e);
449                }
450                try {
451                    outputStream.close();
452                }
453                catch (IOException e) {
454                    LOGGER.log(Level.WARNING, "Exception while closing output stream", e);
455                }
456            }
457
458            int status = urlConnection.getResponseCode();
459            switch (status) {
460            case HttpURLConnection.HTTP_OK:
461            case HttpURLConnection.HTTP_CREATED:
462            case HttpURLConnection.HTTP_NO_CONTENT:
463                break;
464            default:
465                throw new IOException("Error response " + status + " from server during file upload: "
466                                + urlConnection.getResponseMessage() + ", file size: " + fileSize + ", put URL: "
467                                + putUrl);
468            }
469        }
470        finally {
471            urlConnection.disconnect();
472        }
473    }
474
475    public static UploadService.Version namespaceToVersion(String namespace) {
476        UploadService.Version version;
477        switch (namespace) {
478        case NAMESPACE:
479            version = Version.v0_3;
480            break;
481        case NAMESPACE_0_2:
482            version = Version.v0_2;
483            break;
484        default:
485            version = null;
486            break;
487        }
488        return version;
489    }
490
491    private static boolean containsHttpFileUploadNamespace(DiscoverInfo discoverInfo) {
492        return discoverInfo.containsFeature(NAMESPACE) || discoverInfo.containsFeature(NAMESPACE_0_2);
493    }
494}