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