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}