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}