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