/* * Funambol is a mobile platform developed by Funambol, Inc. * Copyright (C) 2010 Funambol, Inc. * * This program is free software; you can redistribute it and/or modify it under * the terms of the GNU Affero General Public License version 3 as published by * the Free Software Foundation with the addition of the following permission * added to Section 15 as permitted in Section 7(a): FOR ANY PART OF THE COVERED * WORK IN WHICH THE COPYRIGHT IS OWNED BY FUNAMBOL, FUNAMBOL DISCLAIMS THE * WARRANTY OF NON INFRINGEMENT OF THIRD PARTY RIGHTS. * * This program is distributed in the hope that it will be useful, but WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more * details. * * You should have received a copy of the GNU Affero General Public License * along with this program; if not, see http://www.gnu.org/licenses or write to * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, * MA 02110-1301 USA. * * You can contact Funambol, Inc. headquarters at 643 Bair Island Road, Suite * 305, Redwood City, CA 94063, USA, or at email address info@funambol.com. * * The interactive user interfaces in modified source and object code versions * of this program must display Appropriate Legal Notices, as required under * Section 5 of the GNU Affero General Public License version 3. * * In accordance with Section 7(b) of the GNU Affero General Public License * version 3, these Appropriate Legal Notices must retain the display of the * "Powered by Funambol" logo. If the display of the logo is not reasonably * feasible for technical reasons, the Appropriate Legal Notices must display * the words "Powered by Funambol". */ package com.funambol.sapisync.sapi; import java.util.Vector; import java.util.Hashtable; import java.util.Enumeration; import java.io.IOException; import java.io.InputStream; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.OutputStream; import java.io.UnsupportedEncodingException; import com.funambol.org.json.me.JSONException; import com.funambol.org.json.me.JSONObject; import com.funambol.platform.HttpConnectionAdapter; import com.funambol.sapisync.NotSupportedCallException; import com.funambol.sapisync.NotAuthorizedCallException; import com.funambol.util.Base64; import com.funambol.util.StringUtil; import com.funambol.util.ConnectionManager; import com.funambol.util.Log; /** * This class is a utility to perform SAPI requests. It provides some basic * mechanism to authentication, and url encoding. */ public class SapiHandler { private static final String TAG_LOG = "SapiHandler"; public static final int AUTH_NONE = -1; public static final int AUTH_IN_QUERY_STRING = 0; public static final int AUTH_IN_HTTP_HEADER = 1; private static final String JSESSIONID_PARAM = "jsessionid"; private static final String ACTION_PARAM = "action"; private static final String AUTH_HEADER = "Authorization"; private static final String AUTH_BASIC = "Basic"; private static final String CONTENT_TYPE_HEADER = "Content-Type"; private static final String CONTENT_LENGTH_HEADER = "Content-Length"; private static final String JSESSIONID_HEADER = "JSESSIONID"; private static final String SET_COOKIE_HEADER = "Set-Cookie"; private static final String COOKIE_HEADER = "Cookie"; private static final int DEFAULT_CHUNK_SIZE = 4096; private String baseUrl; private String user; private String pwd; private int authMethod = AUTH_IN_QUERY_STRING; private boolean jsessionAuthEnabled = false; private String jsessionId = null; protected ConnectionManager connectionManager = ConnectionManager.getInstance(); private SapiQueryListener listener = null; /** * This is the flag used to indicate that the current query shall be * cancelled */ private boolean cancel = false; public SapiHandler(String baseUrl, String user, String pwd) { this.baseUrl = baseUrl; this.user = user; this.pwd = pwd; } public SapiHandler(String baseUrl) { this(baseUrl, null, null); setAuthenticationMethod(AUTH_NONE); } public void setAuthenticationMethod(int authMethod) { this.authMethod = authMethod; } public void enableJSessionAuthentication(boolean value) { this.jsessionAuthEnabled = value; } public void forceJSessionId(String jsessionId) { this.jsessionId = jsessionId; } public void setConnectionManager(ConnectionManager connectionManager) { this.connectionManager = connectionManager; } public void setSapiRequestListener(SapiQueryListener listener) { this.listener = listener; } public JSONObject query(String name, String action, Vector params, Hashtable headers, JSONObject request) throws NotSupportedCallException, IOException, JSONException { ByteArrayInputStream s = null; int contentLength = 0; if (request != null) { byte[] r = request.toString().getBytes("UTF-8"); contentLength = r.length; s = new ByteArrayInputStream(r); } return query(name, action, params, headers, s, contentLength, null); } public JSONObject query(String name, String action, Vector params, Hashtable headers, InputStream requestIs, long contentLength, String testItemName) throws NotSupportedCallException, IOException, JSONException { return query(name, action, params, headers, requestIs, "application/octet-stream", contentLength, 0, testItemName); } public synchronized JSONObject query(String name, String action, Vector params, Hashtable headers, InputStream requestIs, String contentType, long contentLength, long fromByte, String testItemName) throws NotSupportedCallException, IOException, JSONException { String url = createUrl(name, action, params); HttpConnectionAdapter conn; long uploadContentLength = contentLength - fromByte; try { // Open the connection with a given size to prevent the output // stream from buffering all data if (Log.isLoggable(Log.INFO)) { Log.info(TAG_LOG, "Requesting url: " + url); } String extras = null; if(testItemName != null) { extras = "key," + testItemName + ",phase,sending"; } conn = connectionManager.openHttpConnection(url, extras); conn.setRequestMethod(HttpConnectionAdapter.POST); if(contentLength > 0) { if (Log.isLoggable(Log.DEBUG)) { Log.debug(TAG_LOG, "Setting content type to: " + contentType); } conn.setRequestProperty(CONTENT_TYPE_HEADER, contentType); } if (Log.isLoggable(Log.DEBUG)) { Log.debug(TAG_LOG, "Setting content length to " + uploadContentLength); } conn.setRequestProperty(CONTENT_LENGTH_HEADER, String.valueOf(uploadContentLength)); // Set the authentication if we have no jsessionid if (jsessionId != null && jsessionAuthEnabled) { if (Log.isLoggable(Log.DEBUG)) { Log.debug(TAG_LOG, "Authorization is specified via jsessionid"); } conn.setRequestProperty(COOKIE_HEADER, JSESSIONID_HEADER + "=" + jsessionId); } else if (authMethod == AUTH_IN_HTTP_HEADER) { String token = user + ":" + pwd; String authToken = new String(Base64.encode(token.getBytes())); String authParam = AUTH_BASIC + " " + authToken; if (Log.isLoggable(Log.DEBUG)) { Log.debug(TAG_LOG, "Setting auth header to: " + authParam); } conn.setRequestProperty(AUTH_HEADER, authParam); } // Add custom headers if (headers != null) { Enumeration keys = headers.keys(); while(keys.hasMoreElements()) { String key = (String)keys.nextElement(); String value = (String)headers.get(key); conn.setRequestProperty(key, value); } } } catch (IOException ioe) { Log.error(TAG_LOG, "Cannot open http connection", ioe); throw ioe; } // Set chunked streaming mode in order to avoid buffering conn.setChunkedStreamingMode(DEFAULT_CHUNK_SIZE); InputStream is = null; if(listener != null) { listener.queryStarted((int)contentLength); } try { // In case of SAPI that require a body, this must be written here // Note that the length is not handled here because we don't know // the length of the stream. Callers shall put it in the custom // headers if it is required. if (requestIs != null) { int total = 0; if(fromByte > 0) { if (Log.isLoggable(Log.TRACE)) { Log.trace(TAG_LOG, "Skip " + fromByte + " bytes from request InputStream"); } requestIs.skip(fromByte); total += fromByte; } SapiInputStream sapiIs = new SapiInputStream(requestIs, total, listener, (int)contentLength); conn.execute(sapiIs, uploadContentLength); if(isQueryCancelled()) { Log.debug(TAG_LOG, "Query cancelled"); throw new IOException("Query cancelled"); } } else { conn.execute(null, -1); } if (Log.isLoggable(Log.TRACE)) { Log.trace(TAG_LOG, "Response code is: " + conn.getResponseCode()); } // Now check the HTTP response, in case of success we set the item // status to OK ByteArrayOutputStream response = new ByteArrayOutputStream(4096); if (conn.getResponseCode() == HttpConnectionAdapter.HTTP_OK) { // Log server headers if (Log.isLoggable(Log.TRACE)) { int h = 0; String headerKey = conn.getHeaderFieldKey(h++); while (headerKey != null) { String headerValue = conn.getHeaderField(headerKey); Log.trace(TAG_LOG, "Header key: " + headerKey + "=" + headerValue); headerKey = conn.getHeaderFieldKey(h++); } } // Open the input stream and read the response is = conn.openInputStream(); // Read until we have data int responseLength = conn.getLength(); if(responseLength > 0) { if (Log.isLoggable(Log.TRACE)) { Log.trace(TAG_LOG, "response length is known " + responseLength); } // Read the input stream according to the response // content-length header int b; byte data[] = new byte[1024]; do { b = is.read(data); responseLength -= b; if (b != -1) { response.write(data,0,b); } } while(b != -1 && responseLength > 0); if (responseLength > 0) { Log.error(TAG_LOG, "Content length mismatch"); } } else if(responseLength < 0) { if (Log.isLoggable(Log.TRACE)) { Log.trace(TAG_LOG, "response length is unknown (probably chunked encoding)"); } try { int b; byte data[] = new byte[1024]; do { b = is.read(data); if (b != -1) { response.write(data,0,b); } } while(b != -1); } catch(IOException ex) { // The end of the stream is reached, ignore exception } } } else if (conn.getResponseCode() == HttpConnectionAdapter.HTTP_NOT_FOUND) { Log.error(TAG_LOG, "SAPI not found: " + name); throw new NotSupportedCallException(); } else if (conn.getResponseCode() == HttpConnectionAdapter.HTTP_NOT_IMPLEMENTED) { Log.error(TAG_LOG, "SAPI not implemented: " + name); throw new NotSupportedCallException(); } else if (conn.getResponseCode() == HttpConnectionAdapter.HTTP_UNAUTHORIZED || conn.getResponseCode() == HttpConnectionAdapter.HTTP_FORBIDDEN) { Log.error(TAG_LOG, "SAPI not authorized: " + name); throw new NotAuthorizedCallException(); } else { // The request failed Log.error(TAG_LOG, "SAPI query error: " + conn.getResponseCode()); throw new IOException("HTTP error code: " + conn.getResponseCode()); } String r; try { r = new String(response.toByteArray(), "UTF-8"); } catch (UnsupportedEncodingException uee) { Log.error(TAG_LOG, "Cannot convert SAPI response into UTF-8 encoding"); r = new String(response.toByteArray()); } if (Log.isLoggable(Log.TRACE)) { Log.trace(TAG_LOG, "response is:" + r); } // This code handles JSESSION ID authentication. If we don't have a // valid jsession id, then we check if the server sent one if (jsessionId == null) { try { String cookies = conn.getHeaderField(SET_COOKIE_HEADER); if (cookies != null) { if (Log.isLoggable(Log.DEBUG)) { Log.debug(TAG_LOG, "Set-Cookie from server: " + cookies); } int jsidx = cookies.indexOf(JSESSIONID_HEADER); if (jsidx >= 0) { String tmpjsessionId = cookies.substring(jsidx); int equalidx = tmpjsessionId.indexOf("="); int idx = tmpjsessionId.indexOf(";"); if (equalidx >= 0) { if(idx > 0) { jsessionId = tmpjsessionId.substring(equalidx+1, idx); } else { jsessionId = tmpjsessionId.substring(equalidx+1); } if (Log.isLoggable(Log.DEBUG)) { Log.debug(TAG_LOG, "Found jsessionid = " + jsessionId); } } } } } catch (Exception e) { Log.error(TAG_LOG, "Cannot get jsessionid", e); } } if(listener != null) { listener.queryEnded(); } // Prepare the response if(!StringUtil.isNullOrEmpty(r)) { JSONObject res = new JSONObject(r); return res; } else { return null; } } catch (IOException ioe) { // If we get a non authorized error and we used a jsession id, then // we invalidate the jsessionId and throw the exception Log.error(TAG_LOG, "Error while performing SAPI", ioe); if (conn != null) { try { if (jsessionId != null && conn.getResponseCode() == HttpConnectionAdapter.HTTP_FORBIDDEN) { if (Log.isLoggable(Log.INFO)) { Log.info(TAG_LOG, "Invalidating jsession id"); } jsessionId = null; } } catch (IOException ioe2) {} } throw ioe; } finally { if (is != null) { try { is.close(); } catch (IOException e) {} is = null; } if (requestIs != null) { try { requestIs.close(); } catch (IOException e) {} requestIs = null; } if (conn != null) { try { conn.close(); } catch (IOException ioe) {} conn = null; } } } public long getMediaPartialUploadLength(String name, String guid, long size) throws NotSupportedCallException, IOException { String url = createUrl("upload/" + name, "save", null); HttpConnectionAdapter conn = null; OutputStream os = null; try { // Open the connection with a given size to prevent the output // stream from buffering all data if (Log.isLoggable(Log.INFO)) { Log.info(TAG_LOG, "Requesting url: " + url); } conn = connectionManager.openHttpConnection(url, null); conn.setRequestMethod(HttpConnectionAdapter.POST); conn.setRequestProperty(CONTENT_LENGTH_HEADER, "0"); // Set the authentication if we have no jsessionid if (jsessionId != null && jsessionAuthEnabled) { if (Log.isLoggable(Log.DEBUG)) { Log.debug(TAG_LOG, "Authorization is specified via jsessionid"); } conn.setRequestProperty(COOKIE_HEADER, JSESSIONID_HEADER + "=" + jsessionId); } else if (authMethod == AUTH_IN_HTTP_HEADER) { String token = user + ":" + pwd; String authToken = new String(Base64.encode(token.getBytes())); String authParam = AUTH_BASIC + " " + authToken; if (Log.isLoggable(Log.DEBUG)) { Log.debug(TAG_LOG, "Setting auth header to: " + authParam); } conn.setRequestProperty(AUTH_HEADER, authParam); } conn.setRequestProperty("x-funambol-id", guid); //conn.setRequestProperty("x-funambol-file-size", Long.toString(size)); // Ask for the current length conn.setRequestProperty("Content-Range", "bytes */" + size); conn.execute(null, -1); if (conn.getResponseCode() == HttpConnectionAdapter.HTTP_OK) { // We have uploaded the item completely or the SAPI returned an // error. We must distinguish the two cases int responseLength = conn.getLength(); if (responseLength == 0) { return size; } InputStream is = conn.openInputStream(); // Read the input stream according to the response // content-length header int b; StringBuffer response = new StringBuffer(); do { b = is.read(); if (b != -1) { response.append((char)b); } } while(b != -1); if (response.length() > 0) { if (Log.isLoggable(Log.DEBUG)) { Log.debug(TAG_LOG, "Response is: " + response.toString()); } try { JSONObject resp = new JSONObject(response.toString()); if (resp.has("error")) { if (Log.isLoggable(Log.INFO)) { Log.info(TAG_LOG, "Cannot get item partial upload size"); } return 0; } else { return size; } } catch (JSONException jse) { Log.error(TAG_LOG, "Cannot parse server response", jse); return 0; } } else { return size; } } else if (conn.getResponseCode() == 308) { String length = conn.getHeaderField("Range"); if (length == null) { Log.error(TAG_LOG, "Server did not return a valid range"); return 0; } // The range is expected as 0-length int minusIdx = length.indexOf("-"); if (minusIdx == -1) { Log.error(TAG_LOG, "Server returned a range in unknown format " + length); return 0; } length = length.substring(minusIdx+1).trim(); if (Log.isLoggable(Log.TRACE)) { Log.trace(TAG_LOG, "Partial content length is: " + length); } try { long res = Long.parseLong(length); return res; } catch (Exception e) { Log.error(TAG_LOG, "Server returned a range which is not an integer value " + length); return 0; } } else if (conn.getResponseCode() == HttpConnectionAdapter.HTTP_NOT_FOUND) { Log.error(TAG_LOG, "SAPI not found: " + name); throw new NotSupportedCallException(); } else if (conn.getResponseCode() == HttpConnectionAdapter.HTTP_NOT_IMPLEMENTED) { Log.error(TAG_LOG, "SAPI not implemented: " + name); throw new NotSupportedCallException(); } else { Log.error(TAG_LOG, "Range request failed with HTTP code " + conn.getResponseCode()); return 0; } } catch (IOException ioe) { Log.error(TAG_LOG, "Cannot open http connection", ioe); throw ioe; } finally { if (conn != null) { try { conn.close(); } catch (IOException ioe) {} conn = null; } } } /** * Cancels the current query */ public void cancel() { if (Log.isLoggable(Log.DEBUG)) { Log.debug(TAG_LOG, "Cancelling current query"); } cancel = true; } public boolean getConnectionsReuse() { return HttpConnectionAdapter.getConnectionsReuse(); } private boolean isQueryCancelled() { return cancel; } protected String encodeURLString(String s) { if (s != null) { StringBuffer tmp = new StringBuffer(); try { for(int i=0;i<s.length();++i) { int b = (int)s.charAt(i); if ((b>=0x30 && b<=0x39) || (b>=0x41 && b<=0x5A) || (b>=0x61 && b<=0x7A)) { tmp.append((char)b); } else if (b == 32) { tmp.append("+"); } else { tmp.append("%"); if (b <= 0xf) tmp.append("0"); tmp.append(Integer.toHexString(b)); } } } catch (Exception e) { Log.error(TAG_LOG, "Cannot encode URL " + s, e); } return tmp.toString(); } return null; } protected String createUrl(String name, String action, Vector params) { // Prepare the URL StringBuffer url = new StringBuffer(StringUtil.extractAddressFromUrl(baseUrl)); url.append("/").append("sapi/").append(name /* no need to encode the SAPI name */); if (jsessionId != null && jsessionAuthEnabled) { if (Log.isLoggable(Log.DEBUG)) { Log.debug(TAG_LOG, "Authorization is specified via jsessionid"); } url.append(";jsessionid=").append(jsessionId); } // Append the Params url.append("?").append(ACTION_PARAM).append("=").append(encodeURLString(action)); // Credentials in the query string if (authMethod == AUTH_IN_QUERY_STRING) { url.append("&login=").append(encodeURLString(user)) .append("&password=").append(encodeURLString(pwd)); } if (params != null) { for(int i=0;i<params.size();++i) { String param = (String)params.elementAt(i); int eqIndex = param.indexOf('='); if(eqIndex > 0) { String paramName = param.substring(0, eqIndex); String paramValue = param.substring(eqIndex + 1); url.append("&").append(encodeURLString(paramName)) .append("=").append(encodeURLString(paramValue)); } else { url.append("&").append(encodeURLString(param)); } } } return url.toString(); } /** * Used to monitor a SAPI query */ public interface SapiQueryListener { /** * Reports that a new query operation started. * @param totalSize the total size of bytes to send */ public void queryStarted(int totalSize); /** * Reports the progress of a query operation. * @param size the total number of bytes sent from the beginning */ public void queryProgress(int size); /** * Reports that a query operation ended. */ public void queryEnded(); } private class SapiInputStream extends InputStream { private InputStream is; private SapiQueryListener listener; private int offset; private int current; private int contentLength; public SapiInputStream(InputStream is, int offset, SapiQueryListener listener, int contentLength) { this.is = is; this.offset = offset; this.listener = listener; this.contentLength = contentLength; if (contentLength != 0) { current = (offset * 100) / contentLength; } else { current = 0; } } public int read() throws IOException { if(isQueryCancelled()) { Log.debug(TAG_LOG, "Query cancelled"); throw new IOException("Query cancelled"); } int res = is.read(); if (listener != null) { offset++; int newCurrent; if (contentLength != 0) { newCurrent = (offset * 100) / contentLength; } else { newCurrent = 0; } if (newCurrent != current) { listener.queryProgress(offset++); current = newCurrent; } } return res; } public void close() throws IOException { is.close(); } public int available() throws IOException { return is.available(); } public void mark(int readlimit) { is.mark(readlimit); } public boolean markSupported() { return is.markSupported(); } public void reset() throws IOException { is.reset(); } public long skip(long n) throws IOException { return is.skip(n); } } }