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 org.jivesoftware.smack.AbstractConnectionListener; 020import org.jivesoftware.smack.ConnectionConfiguration; 021import org.jivesoftware.smack.ConnectionCreationListener; 022import org.jivesoftware.smack.Manager; 023import org.jivesoftware.smack.SmackException; 024import org.jivesoftware.smack.XMPPConnection; 025import org.jivesoftware.smack.XMPPConnectionRegistry; 026import org.jivesoftware.smack.XMPPException; 027import org.jivesoftware.smackx.disco.ServiceDiscoveryManager; 028import org.jivesoftware.smackx.disco.packet.DiscoverInfo; 029import org.jivesoftware.smackx.httpfileupload.UploadService.Version; 030import org.jivesoftware.smackx.httpfileupload.element.Slot; 031import org.jivesoftware.smackx.httpfileupload.element.SlotRequest; 032import org.jivesoftware.smackx.httpfileupload.element.SlotRequest_V0_2; 033import org.jivesoftware.smackx.xdata.FormField; 034import org.jivesoftware.smackx.xdata.packet.DataForm; 035import org.jxmpp.jid.DomainBareJid; 036 037import java.io.BufferedInputStream; 038import java.io.File; 039import java.io.FileInputStream; 040import java.io.FileNotFoundException; 041import java.io.IOException; 042import java.io.OutputStream; 043import java.net.HttpURLConnection; 044import java.net.URL; 045import java.util.List; 046import java.util.Map; 047import java.util.Map.Entry; 048import java.util.WeakHashMap; 049import java.util.logging.Level; 050import java.util.logging.Logger; 051 052import javax.net.ssl.HttpsURLConnection; 053import javax.net.ssl.SSLContext; 054import javax.net.ssl.SSLSocketFactory; 055 056/** 057 * A manager for XEP-0363: HTTP File Upload. 058 * 059 * @author Grigory Fedorov 060 * @author Florian Schmaus 061 * @see <a href="http://xmpp.org/extensions/xep-0363.html">XEP-0363: HTTP File Upload</a> 062 */ 063public final class HttpFileUploadManager extends Manager { 064 065 public static final String NAMESPACE = "urn:xmpp:http:upload:0"; 066 public static final String NAMESPACE_0_2 = "urn:xmpp:http:upload"; 067 068 private static final Logger LOGGER = Logger.getLogger(HttpFileUploadManager.class.getName()); 069 070 static { 071 XMPPConnectionRegistry.addConnectionCreationListener(new ConnectionCreationListener() { 072 @Override 073 public void connectionCreated(XMPPConnection connection) { 074 getInstanceFor(connection); 075 } 076 }); 077 } 078 079 private static final Map<XMPPConnection, HttpFileUploadManager> INSTANCES = new WeakHashMap<>(); 080 081 private UploadService defaultUploadService; 082 083 private SSLSocketFactory tlsSocketFactory; 084 085 /** 086 * Obtain the HttpFileUploadManager responsible for a connection. 087 * 088 * @param connection the connection object. 089 * @return a HttpFileUploadManager instance 090 */ 091 public static synchronized HttpFileUploadManager getInstanceFor(XMPPConnection connection) { 092 HttpFileUploadManager httpFileUploadManager = INSTANCES.get(connection); 093 094 if (httpFileUploadManager == null) { 095 httpFileUploadManager = new HttpFileUploadManager(connection); 096 INSTANCES.put(connection, httpFileUploadManager); 097 } 098 099 return httpFileUploadManager; 100 } 101 102 private HttpFileUploadManager(XMPPConnection connection) { 103 super(connection); 104 105 connection.addConnectionListener(new AbstractConnectionListener() { 106 @Override 107 public void authenticated(XMPPConnection connection, boolean resumed) { 108 // No need to reset the cache if the connection got resumed. 109 if (resumed) { 110 return; 111 } 112 113 try { 114 discoverUploadService(); 115 } catch (XMPPException.XMPPErrorException | SmackException.NotConnectedException 116 | SmackException.NoResponseException | InterruptedException e) { 117 LOGGER.log(Level.WARNING, "Error during discovering HTTP File Upload service", e); 118 } 119 } 120 }); 121 } 122 123 private static UploadService uploadServiceFrom(DiscoverInfo discoverInfo) { 124 assert(containsHttpFileUploadNamespace(discoverInfo)); 125 126 UploadService.Version version; 127 if (discoverInfo.containsFeature(NAMESPACE)) { 128 version = Version.v0_3; 129 } else if (discoverInfo.containsFeature(NAMESPACE_0_2)) { 130 version = Version.v0_2; 131 } else { 132 throw new AssertionError(); 133 } 134 135 DomainBareJid address = discoverInfo.getFrom().asDomainBareJid(); 136 137 DataForm dataForm = DataForm.from(discoverInfo); 138 if (dataForm == null) { 139 return new UploadService(address, version); 140 } 141 142 FormField field = dataForm.getField("max-file-size"); 143 if (field == null) { 144 return new UploadService(address, version); 145 } 146 147 List<String> values = field.getValues(); 148 if (values.isEmpty()) { 149 return new UploadService(address, version); 150 151 } 152 153 Long maxFileSize = Long.valueOf(values.get(0)); 154 return new UploadService(address, version, maxFileSize); 155 } 156 157 /** 158 * Discover upload service. 159 * 160 * Called automatically when connection is authenticated. 161 * 162 * Note that this is a synchronous call -- Smack must wait for the server response. 163 * 164 * @return true if upload service was discovered 165 166 * @throws XMPPException.XMPPErrorException 167 * @throws SmackException.NotConnectedException 168 * @throws InterruptedException 169 * @throws SmackException.NoResponseException 170 */ 171 public boolean discoverUploadService() throws XMPPException.XMPPErrorException, SmackException.NotConnectedException, 172 InterruptedException, SmackException.NoResponseException { 173 ServiceDiscoveryManager sdm = ServiceDiscoveryManager.getInstanceFor(connection()); 174 List<DiscoverInfo> servicesDiscoverInfo = sdm 175 .findServicesDiscoverInfo(NAMESPACE, true, true); 176 177 if (servicesDiscoverInfo.isEmpty()) { 178 servicesDiscoverInfo = sdm.findServicesDiscoverInfo(NAMESPACE_0_2, true, true); 179 if (servicesDiscoverInfo.isEmpty()) { 180 return false; 181 } 182 } 183 184 DiscoverInfo discoverInfo = servicesDiscoverInfo.get(0); 185 186 defaultUploadService = uploadServiceFrom(discoverInfo); 187 return true; 188 } 189 190 /** 191 * Check if upload service was discovered. 192 * 193 * @return true if upload service was discovered 194 */ 195 public boolean isUploadServiceDiscovered() { 196 return defaultUploadService != null; 197 } 198 199 /** 200 * Get default upload service if it was discovered. 201 * 202 * @return upload service JID or null if not available 203 */ 204 public UploadService getDefaultUploadService() { 205 return defaultUploadService; 206 } 207 208 /** 209 * Request slot and uploaded file to HTTP file upload service. 210 * 211 * You don't need to request slot and upload file separately, this method will do both. 212 * Note that this is a synchronous call -- Smack must wait for the server response. 213 * 214 * @param file file to be uploaded 215 * @return public URL for sharing uploaded file 216 * @throws InterruptedException 217 * @throws XMPPException.XMPPErrorException 218 * @throws SmackException 219 * @throws IOException in case of HTTP upload errors 220 */ 221 public URL uploadFile(File file) throws InterruptedException, XMPPException.XMPPErrorException, 222 SmackException, IOException { 223 return uploadFile(file, null); 224 } 225 226 /** 227 * Request slot and uploaded file to HTTP file upload service with progress callback. 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 * @param listener upload progress listener of null 234 * @return public URL for sharing uploaded file 235 * 236 * @throws InterruptedException 237 * @throws XMPPException.XMPPErrorException 238 * @throws SmackException 239 * @throws IOException 240 */ 241 public URL uploadFile(File file, UploadProgressListener listener) throws InterruptedException, 242 XMPPException.XMPPErrorException, SmackException, IOException { 243 if (!file.isFile()) { 244 throw new FileNotFoundException("The path " + file.getAbsolutePath() + " is not a file"); 245 } 246 final Slot slot = requestSlot(file.getName(), file.length(), "application/octet-stream"); 247 248 uploadFile(file, slot, listener); 249 250 return slot.getGetUrl(); 251 } 252 253 254 /** 255 * Request a new upload slot from default upload service (if discovered). When you get slot you should upload file 256 * to PUT URL and share GET URL. Note that this is a synchronous call -- Smack must wait for the server response. 257 * 258 * @param filename name of file to be uploaded 259 * @param fileSize file size in bytes. 260 * @return file upload Slot in case of success 261 * @throws IllegalArgumentException if fileSize is less than or equal to zero or greater than the maximum size 262 * supported by the service. 263 * @throws InterruptedException 264 * @throws XMPPException.XMPPErrorException 265 * @throws SmackException.NotConnectedException 266 * @throws SmackException.NoResponseException 267 */ 268 public Slot requestSlot(String filename, long fileSize) throws InterruptedException, 269 XMPPException.XMPPErrorException, SmackException { 270 return requestSlot(filename, fileSize, null, null); 271 } 272 273 /** 274 * Request a new upload slot with optional content type from default upload service (if discovered). 275 * 276 * When you get slot you should upload file to PUT URL and share GET URL. 277 * Note that this is a synchronous call -- Smack must wait for the server response. 278 * 279 * @param filename name of file to be uploaded 280 * @param fileSize file size in bytes. 281 * @param contentType file content-type or null 282 * @return file upload Slot in case of success 283 284 * @throws IllegalArgumentException if fileSize is less than or equal to zero or greater than the maximum size 285 * supported by the service. 286 * @throws SmackException.NotConnectedException 287 * @throws InterruptedException 288 * @throws XMPPException.XMPPErrorException 289 * @throws SmackException.NoResponseException 290 */ 291 public Slot requestSlot(String filename, long fileSize, String contentType) throws SmackException, 292 InterruptedException, XMPPException.XMPPErrorException { 293 return requestSlot(filename, fileSize, contentType, null); 294 } 295 296 /** 297 * Request a new upload slot with optional content type from custom upload service. 298 * 299 * When you get slot you should upload file to PUT URL and share GET URL. 300 * Note that this is a synchronous call -- Smack must wait for the server response. 301 * 302 * @param filename name of file to be uploaded 303 * @param fileSize file size in bytes. 304 * @param contentType file content-type or null 305 * @param uploadServiceAddress the address of the upload service to use or null for default one 306 * @return file upload Slot in case of success 307 * @throws IllegalArgumentException if fileSize is less than or equal to zero or greater than the maximum size 308 * supported by the service. 309 * @throws SmackException 310 * @throws InterruptedException 311 * @throws XMPPException.XMPPErrorException 312 */ 313 public Slot requestSlot(String filename, long fileSize, String contentType, DomainBareJid uploadServiceAddress) 314 throws SmackException, InterruptedException, XMPPException.XMPPErrorException { 315 final XMPPConnection connection = connection(); 316 final UploadService defaultUploadService = this.defaultUploadService; 317 318 // The upload service we are going to use. 319 UploadService uploadService; 320 321 if (uploadServiceAddress == null) { 322 uploadService = defaultUploadService; 323 } else { 324 if (defaultUploadService != null && defaultUploadService.getAddress().equals(uploadServiceAddress)) { 325 // Avoid performing a service discovery if we already know about the given service. 326 uploadService = defaultUploadService; 327 } else { 328 DiscoverInfo discoverInfo = ServiceDiscoveryManager.getInstanceFor(connection).discoverInfo(uploadServiceAddress); 329 if (!containsHttpFileUploadNamespace(discoverInfo)) { 330 throw new IllegalArgumentException("There is no HTTP upload service running at the given address '" 331 + uploadServiceAddress + '\''); 332 } 333 uploadService = uploadServiceFrom(discoverInfo); 334 } 335 } 336 337 if (uploadService == null) { 338 throw new SmackException("No upload service specified and also none discovered."); 339 } 340 341 if (!uploadService.acceptsFileOfSize(fileSize)) { 342 throw new IllegalArgumentException( 343 "Requested file size " + fileSize + " is greater than max allowed size " + uploadService.getMaxFileSize()); 344 } 345 346 SlotRequest slotRequest; 347 switch (uploadService.getVersion()) { 348 case v0_3: 349 slotRequest = new SlotRequest(uploadService.getAddress(), filename, fileSize, contentType); 350 break; 351 case v0_2: 352 slotRequest = new SlotRequest_V0_2(uploadService.getAddress(), filename, fileSize, contentType); 353 break; 354 default: 355 throw new AssertionError(); 356 } 357 358 return connection.createStanzaCollectorAndSend(slotRequest).nextResultOrThrow(); 359 } 360 361 public void setTlsContext(SSLContext tlsContext) { 362 if (tlsContext == null) { 363 return; 364 } 365 this.tlsSocketFactory = tlsContext.getSocketFactory(); 366 } 367 368 public void useTlsSettingsFrom(ConnectionConfiguration connectionConfiguration) { 369 SSLContext sslContext = connectionConfiguration.getCustomSSLContext(); 370 setTlsContext(sslContext); 371 } 372 373 private void uploadFile(final File file, final Slot slot, UploadProgressListener listener) throws IOException { 374 final long fileSize = file.length(); 375 // TODO Remove once Smack's minimum Android API level is 19 or higher. See also comment below. 376 if (fileSize >= Integer.MAX_VALUE) { 377 throw new IllegalArgumentException("File size " + fileSize + " must be less than " + Integer.MAX_VALUE); 378 } 379 final int fileSizeInt = (int) fileSize; 380 381 // Construct the FileInputStream first to make sure we can actually read the file. 382 final FileInputStream fis = new FileInputStream(file); 383 384 final URL putUrl = slot.getPutUrl(); 385 386 final HttpURLConnection urlConnection = (HttpURLConnection) putUrl.openConnection(); 387 388 urlConnection.setRequestMethod("PUT"); 389 urlConnection.setUseCaches(false); 390 urlConnection.setDoOutput(true); 391 // TODO Change to using fileSize once Smack's minimum Android API level is 19 or higher. 392 urlConnection.setFixedLengthStreamingMode(fileSizeInt); 393 urlConnection.setRequestProperty("Content-Type", "application/octet-stream;"); 394 for (Entry<String, String> header : slot.getHeaders().entrySet()) { 395 urlConnection.setRequestProperty(header.getKey(), header.getValue()); 396 } 397 398 final SSLSocketFactory tlsSocketFactory = this.tlsSocketFactory; 399 if (tlsSocketFactory != null && urlConnection instanceof HttpsURLConnection) { 400 HttpsURLConnection httpsUrlConnection = (HttpsURLConnection) urlConnection; 401 httpsUrlConnection.setSSLSocketFactory(tlsSocketFactory); 402 } 403 404 try { 405 OutputStream outputStream = urlConnection.getOutputStream(); 406 407 long bytesSend = 0; 408 409 if (listener != null) { 410 listener.onUploadProgress(0, fileSize); 411 } 412 413 BufferedInputStream inputStream = new BufferedInputStream(fis); 414 415 // TODO Factor in extra static method (and re-use e.g. in bytestream code). 416 byte[] buffer = new byte[4096]; 417 int bytesRead; 418 try { 419 while ((bytesRead = inputStream.read(buffer)) != -1) { 420 outputStream.write(buffer, 0, bytesRead); 421 bytesSend += bytesRead; 422 423 if (listener != null) { 424 listener.onUploadProgress(bytesSend, fileSize); 425 } 426 } 427 } 428 finally { 429 try { 430 inputStream.close(); 431 } 432 catch (IOException e) { 433 LOGGER.log(Level.WARNING, "Exception while closing input stream", e); 434 } 435 try { 436 outputStream.close(); 437 } 438 catch (IOException e) { 439 LOGGER.log(Level.WARNING, "Exception while closing output stream", e); 440 } 441 } 442 443 int status = urlConnection.getResponseCode(); 444 switch (status) { 445 case HttpURLConnection.HTTP_OK: 446 case HttpURLConnection.HTTP_CREATED: 447 case HttpURLConnection.HTTP_NO_CONTENT: 448 break; 449 default: 450 throw new IOException("Error response " + status + " from server during file upload: " 451 + urlConnection.getResponseMessage() + ", file size: " + fileSize + ", put URL: " 452 + putUrl); 453 } 454 } 455 finally { 456 urlConnection.disconnect(); 457 } 458 } 459 460 private static boolean containsHttpFileUploadNamespace(DiscoverInfo discoverInfo) { 461 return discoverInfo.containsFeature(NAMESPACE) || discoverInfo.containsFeature(NAMESPACE_0_2); 462 } 463}