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