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