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