blob: ad17260d4500e8a468a70c7623ae2ef4ca690ba5 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/*global ActiveXObject, DOMParser */
/*global shindig */
/**
* @fileoverview Provides remote content retrieval facilities.
* Available to every gadget.
*/
/**
* @class Provides remote content retrieval functions.
*/
gadgets.io = function() {
// Ever incrementing Ajax transaction id
var ioTransactionId = 0;
// Object to store ids for the ajax poll to avoid IE memory leak
var ajaxPollQ = {};
/**
* Holds configuration-related data such as proxy urls.
*/
var config = {};
/**
* Holds state for OAuth.
*/
var oauthState;
/**
* Internal facility to create an xhr request.
* @return {XMLHttpRequest}
*/
function makeXhr() {
var x;
if (typeof shindig != 'undefined' &&
shindig.xhrwrapper &&
shindig.xhrwrapper.createXHR) {
return shindig.xhrwrapper.createXHR();
} else if (typeof ActiveXObject != 'undefined') {
try {
x = new ActiveXObject('Msxml2.XMLHTTP');
if (!x) {
x = new ActiveXObject('Microsoft.XMLHTTP');
}
return x;
} catch (e) {} // An exception will be thrown if ActiveX is disabled
}
// The second construct is for the benefit of jsunit...
if (typeof XMLHttpRequest != 'undefined' || window.XMLHttpRequest) {
return new window.XMLHttpRequest();
}
else throw ('no xhr available');
}
/**
* Checks the xobj for errors, may call the callback with an error response
* if the error is fatal.
*
* @param {Object} xobj The XHR object to check.
* @param {function(Object)} callback The callback to call if the error is fatal.
* @return {boolean} true if the xobj is not ready to be processed.
*/
function hadError(xobj, callback) {
if (xobj['readyState'] !== 4) {
return true;
}
try {
if (xobj['status'] !== 200) {
var error = ('' + xobj['status']);
if (xobj['responseText']) {
error = error + ' ' + xobj['responseText'];
}
callback({
'errors': [error],
'rc': xobj['status'],
'text': xobj['responseText']
});
return true;
}
} catch (e) {
callback({
'errors': [e['number'] + ' Error not specified'],
'rc': e['number'],
'text': e['description']
});
return true;
}
return false;
}
/**
* Handles non-proxied XHR callback processing.
*
* @param {string} url
* @param {function(Object)} callback
* @param {Object} params
* @param {Object} xobj
*/
function processNonProxiedResponse(url, callback, params, xobj) {
if (hadError(xobj, callback)) {
return;
}
var data = {
'body': xobj['responseText']
};
callback(transformResponseData(params, data));
}
/**
* Handles XHR callback processing.
*
* @param {string} url
* @param {function(Object)} callback
* @param {Object} params
* @param {Object} xobj
*/
function processResponse(url, callback, params, xobj) {
if (hadError(xobj, callback)) {
return;
}
var txt = xobj['responseText'];
var UNPARSEABLE_CRUFT = config['unparseableCruft'];
// remove unparseable cruft used to prevent cross-site script inclusion
var offset = txt.indexOf(UNPARSEABLE_CRUFT) + UNPARSEABLE_CRUFT.length;
// If no cruft then just return without a callback - avoid JS errors
// TODO craft an error response?
if (offset < UNPARSEABLE_CRUFT.length) return;
txt = txt.substr(offset);
// We are using eval directly here because the outer response comes from a
// trusted source, and json parsing is slow in IE.
var data = eval('(' + txt + ')');
data = data[url];
// Save off any transient OAuth state the server wants back later.
if (data['oauthState']) {
oauthState = data['oauthState'];
}
// Update the security token if the server sent us a new one
if (data['st']) {
shindig.auth.updateSecurityToken(data['st']);
}
callback(transformResponseData(params, data));
}
/**
* @param {Object} params
* @param {Object} data
* @return {Object}
*/
function transformResponseData(params, data) {
// Sometimes rc is not present, generally when used
// by jsonrpccontainer, so assume 200 in its absence.
var resp = {
'text': data['body'],
'rc': data['rc'] || 200,
'headers': data['headers'],
'oauthApprovalUrl': data['oauthApprovalUrl'],
'oauthError': data['oauthError'],
'oauthErrorText': data['oauthErrorText'],
'oauthErrorTrace': data['oauthErrorTrace'],
'oauthErrorUri': data['oauthErrorUri'],
'oauthErrorExplanation': data['oauthErrorExplanation'],
'errors': []
};
if (resp['rc'] < 200 || resp['rc'] >= 400) {
resp['errors'] = [resp['rc'] + ' Error'];
} else if (resp['text']) {
if (resp['rc'] >= 300 && resp['rc'] < 400) {
// Redirect pages will usually contain arbitrary
// HTML which will fail during parsing, inadvertently
// causing a 500 response. Thus we treat as text.
params['CONTENT_TYPE'] = 'TEXT';
}
switch (params['CONTENT_TYPE']) {
case 'JSON':
case 'FEED':
resp['data'] = gadgets.json.parse(resp.text);
if (!resp['data']) {
resp['errors'].push('500 Failed to parse JSON');
resp['rc'] = 500;
resp['data'] = null;
}
break;
case 'DOM':
var dom;
if (typeof DOMParser != 'undefined') {
var parser = new DOMParser();
dom = parser.parseFromString(resp['text'], 'text/xml');
if ('parsererror' === dom.documentElement.nodeName) {
resp['errors'].push('500 Failed to parse XML');
resp['rc'] = 500;
} else {
resp['data'] = dom;
}
} else if (typeof ActiveXObject != 'undefined') {
dom = new ActiveXObject('Microsoft.XMLDOM');
dom.async = false;
dom.validateOnParse = false;
dom.resolveExternals = false;
if (!dom.loadXML(resp['text'])) {
resp['errors'].push('500 Failed to parse XML');
resp['rc'] = 500;
} else {
resp['data'] = dom;
}
} else {
resp['errors'].push('500 Failed to parse XML because no DOM parser was available');
resp['rc'] = 500;
}
break;
default:
resp['data'] = resp['text'];
break;
}
}
return resp;
}
/**
* Sends an XHR post or get request
*
* @param {string} realUrl The url to fetch data from that was requested by the gadget.
* @param {string} proxyUrl The url to proxy through.
* @param {function()} callback The function to call once the data is fetched.
* @param {Object} paramData The params to use when processing the response.
* @param {string} method
* @param {function(string,function(Object),Object,Object)}
* processResponseFunction The function that should process the
* response from the sever before calling the callback.
* @param {Object=} opt_headers - Optional headers including a Content-Type that defaults to
* 'application/x-www-form-urlencoded'.
*/
function makeXhrRequest(realUrl, proxyUrl, callback, paramData, method,
params, processResponseFunction, opt_headers) {
var xhr = makeXhr();
if (proxyUrl.indexOf('//') == 0) {
proxyUrl = document.location.protocol + proxyUrl;
}
xhr.open(method, proxyUrl, true);
if (callback) {
var closureCallback = gadgets.util.makeClosure(null, processResponseFunction, realUrl,
callback, params, xhr);
// check for alternate ajax for onreadystatechange event handler
var shouldPoll = gadgets.util.shouldPollXhrReadyStateChange();
if(shouldPoll) {
handleReadyState(xhr, closureCallback);
}
else {
xhr.onreadystatechange = closureCallback;
}
}
if (typeof opt_headers === 'string') {
// This turned out to come directly from a public API, so we need to
// keep compatibility...
contentType = opt_headers;
opt_headers = {};
}
var headers = opt_headers || {};
if (paramData !== null) {
var contentTypeHeader = 'Content-Type';
var contentType = 'application/x-www-form-urlencoded';
if (!headers[contentTypeHeader]) headers[contentTypeHeader] = contentType;
}
for (var headerName in headers) {
xhr.setRequestHeader(headerName, headers[headerName]);
}
xhr.send(paramData);
}
/**
* Helper function to use poll setInterval to call the callback for Ajax to avoid
* memory leak in certain browsers (eg: IE7) due to circular linking.
*
* The function will create interval polling to poll the XHR object's readyState
* property instead of binding a callback to the onreadystatechange event.
*
* @param {xhr} The Ajax object
* @param {function} The callback function for the Ajax call
* @return void
*/
function handleReadyState(xhr, callback) {
var tempTid = ioTransactionId;
var pollInterval = config['xhrPollIntervalMs'] || 50;
ajaxPollQ[tempTid] = window.setInterval(
function() {
if(xhr && xhr.readyState === 4) {
// Clear the polling interval for the transaction and remove
// the reference from ajaxPollQ
window.clearInterval(ajaxPollQ[tempTid]);
delete ajaxPollQ[tempTid];
// call the callback
if(callback) {
callback();
}
}
}, pollInterval);
ioTransactionId++;
}
/**
* Satisfy a request with data that is prefetched as per the gadget Preload
* directive. The preloader will only satisfy a request for a specific piece
* of content once.
*
* @param {Object} postData The definition of the request to be executed by the proxy.
* @param {Object} params The params to use when processing the response.
* @param {function(Object)} callback The function to call once the data is fetched.
* @return {boolean} true if the request can be satisfied by the preloaded
* content false otherwise.
*/
function respondWithPreload(postData, params, callback) {
if (gadgets.io.preloaded_ && postData.httpMethod === 'GET') {
for (var i = 0; i < gadgets.io.preloaded_.length; i++) {
var preload = gadgets.io.preloaded_[i];
if (preload && (preload.id === postData.url)) {
// Only satisfy once
delete gadgets.io.preloaded_[i];
if (preload['rc'] !== 200) {
callback({'rc': preload['rc'], 'errors': [preload['rc'] + ' Error']});
} else {
if (preload['oauthState']) {
oauthState = preload['oauthState'];
}
var resp = {
'body': preload['body'],
'rc': preload['rc'],
'headers': preload['headers'],
'oauthApprovalUrl': preload['oauthApprovalUrl'],
'oauthError': preload['oauthError'],
'oauthErrorText': preload['oauthErrorText'],
'oauthErrorTrace': preload['oauthErrorTrace'],
'oauthErrorUri': preload['oauthErrorUri'],
'oauthErrorExplanation': preload['oauthErrorExplanation'],
'errors': []
};
callback(transformResponseData(params, resp));
}
return true;
}
}
}
return false;
}
/**
* @param {Object} configuration Configuration settings.
* @private
*/
function init(configuration) {
config = configuration['core.io'] || {};
}
gadgets.config.register('core.io', null, init);
return /** @scope gadgets.io */ {
/**
* Fetches content from the provided URL and feeds that content into the
* callback function.
*
* Example:
* <pre>
* gadgets.io.makeRequest(url, fn,
* {contentType: gadgets.io.ContentType.FEED});
* </pre>
*
* @param {string} url The URL where the content is located.
* @param {function(Object)} callback The function to call with the data from
* the URL once it is fetched.
* @param {Object.<gadgets.io.RequestParameters, Object>=} opt_params
* Additional
* <a href="gadgets.io.RequestParameters.html">parameters</a>
* to pass to the request.
*
* @member gadgets.io
*/
makeRequest: function(url, callback, opt_params) {
// TODO: This method also needs to respect all members of
// gadgets.io.RequestParameters, and validate them.
var params = opt_params || {};
var httpMethod = params['METHOD'] || 'GET';
var refreshInterval = params['REFRESH_INTERVAL'];
// Check if authorization is requested
var auth, st;
if (params['AUTHORIZATION'] && params['AUTHORIZATION'] !== 'NONE') {
auth = params['AUTHORIZATION'].toLowerCase();
st = shindig.auth.getSecurityToken();
}
// Include owner information?
var signOwner = true;
if (typeof params['SIGN_OWNER'] !== 'undefined') {
signOwner = params['SIGN_OWNER'];
}
// Include viewer information?
var signViewer = true;
if (typeof params['SIGN_VIEWER'] !== 'undefined') {
signViewer = params['SIGN_VIEWER'];
}
var headers = params['HEADERS'] || {};
if (httpMethod === 'POST' && !headers['Content-Type']) {
headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
var urlParams = gadgets.util.getUrlParameters();
var paramData = {
'url': url,
'httpMethod': httpMethod,
'headers': gadgets.io.encodeValues(headers, false),
'postData': params['POST_DATA'] || '',
'authz': auth || '',
'st': st || '',
'contentType': params['CONTENT_TYPE'] || 'TEXT',
'numEntries': params['NUM_ENTRIES'] || '3',
'getSummaries': !!params['GET_SUMMARIES'],
'signOwner': signOwner,
'signViewer': signViewer,
'gadget': urlParams['url'],
'container': urlParams['container'] || urlParams['synd'] || 'default',
// should we bypass gadget spec cache (e.g. to read OAuth provider URLs)
'bypassSpecCache': gadgets.util.getUrlParameters()['nocache'] || '',
'getFullHeaders': !!params['GET_FULL_HEADERS']
};
// add the nocache parameter if necessary
// request param NO_CACHE takes precedence over osapi.container.RenderParam.NO_CACHE
if (params.hasOwnProperty('NO_CACHE')) {
paramData['nocache'] = params['NO_CACHE'];
} else if (urlParams.hasOwnProperty('nocache')) {
paramData['nocache'] = urlParams['nocache'];
}
// OAuth goodies
if (auth === 'oauth' || auth === 'signed' || auth === 'oauth2') {
if (gadgets.io.oauthReceivedCallbackUrl_) {
paramData['OAUTH_RECEIVED_CALLBACK'] = gadgets.io.oauthReceivedCallbackUrl_;
gadgets.io.oauthReceivedCallbackUrl_ = null;
}
paramData['oauthState'] = oauthState || '';
// Just copy the OAuth parameters into the req to the server
for (var opt in params) {
if (params.hasOwnProperty(opt)) {
if (opt.indexOf('OAUTH_') === 0 || opt === 'code') {
paramData[opt] = params[opt];
}
}
}
}
// Security token may have been set above
st = st || shindig.auth.getSecurityToken();
var opt_headers = st ? { 'X-Shindig-ST' : st } : {};
var proxyUrl = config['jsonProxyUrl'].replace('%host%', document.location.host);
// FIXME -- processResponse is not used in call
if (!respondWithPreload(paramData, params, callback)) {
if (httpMethod == 'GET' && typeof(refreshInterval) != 'undefined') {
paramData['refresh'] = refreshInterval; // gadget requested cache override.
}
if (httpMethod === 'GET' && !paramData['authz']) {
var extraparams = '?' + gadgets.io.encodeValues(paramData);
makeXhrRequest(url, proxyUrl + extraparams, callback,
null, 'GET', params, processResponse, opt_headers);
} else {
var extraparams = gadgets.io.encodeValues(paramData);
makeXhrRequest(url, proxyUrl, callback,
extraparams, 'POST', params,
processResponse, opt_headers);
}
}
},
/**
* @param {string} relativeUrl url to fetch via xhr.
* @param callback callback to call when response is received or for error.
* @param {Object=} opt_params
* @param {Object=} opt_headers
*
*/
makeNonProxiedRequest: function(relativeUrl, callback, opt_params, opt_headers) {
var params = opt_params || {};
makeXhrRequest(relativeUrl, relativeUrl, callback, params['POST_DATA'],
params['METHOD'], params, processNonProxiedResponse, opt_headers);
},
/**
* Used to clear out the oauthState, for testing only.
*
* @private
*/
clearOAuthState: function() {
oauthState = undefined;
},
/**
* Converts an input object into a URL-encoded data string.
* (key=value&amp;...)
*
* @param {Object} fields The post fields you wish to encode.
* @param {boolean=} opt_noEscaping An optional parameter specifying whether
* to turn off escaping of the parameters. Defaults to false.
* @return {string} The processed post data in www-form-urlencoded format.
*
* @member gadgets.io
*/
encodeValues: function(fields, opt_noEscaping) {
var escape = !opt_noEscaping;
var buf = [];
var first = false;
for (var i in fields) {
if (fields.hasOwnProperty(i) && !/___$/.test(i)) {
if (!first) {
first = true;
} else {
buf.push('&');
}
buf.push(escape ? encodeURIComponent(i) : i);
buf.push('=');
buf.push(escape ? encodeURIComponent(fields[i]) : fields[i]);
}
}
return buf.join('');
},
/**
* Gets the proxy version of the passed-in URL.
*
* @param {string} url The URL to get the proxy URL for.
* @param {Object.<gadgets.io.RequestParameters, Object>=} opt_params Optional Parameter Object.
* The following properties are supported:
* .REFRESH_INTERVAL The number of seconds that this
* content should be cached. Defaults to 3600.
*
* @return {string} The proxied version of the URL.
* @member gadgets.io
*/
getProxyUrl: function(url, opt_params) {
var proxyUrl = config['proxyUrl'];
if (!proxyUrl) {
return proxyUrl;
}
var params = opt_params || {};
var refresh = params['REFRESH_INTERVAL'];
if (typeof refresh == 'undefined') {
refresh = '3600';
}
var urlParams = gadgets.util.getUrlParameters();
var st = shindig.auth.getSecurityToken();
var authz = params[gadgets.io.RequestParameters.AUTHORIZATION];
var serviceName = params[gadgets.io.RequestParameters.OAUTH_SERVICE_NAME];
var rewriteMimeParam =
params['rewriteMime'] ? '&rewriteMime=' + encodeURIComponent(params['rewriteMime']) : '';
var authParam = '';
if(authz) {
if(authz == gadgets.io.AuthorizationType.OAUTH || authz == gadgets.io.AuthorizationType.OAUTH2) {
authParam = '&authz=' + authz.toLowerCase() + '&st=' + encodeURIComponent(st)
+ '&OAUTH_SERVICE_NAME=' + encodeURIComponent(serviceName);
} else {
authParam = '&authz=' + authz.toLowerCase();
}
}
var uri = shindig.uri(url);
var path = uri.getPath();
var fileName = "";
var lSlash = path.lastIndexOf('/');
if (lSlash !== -1) {
fileName = path.substring(lSlash); // include the slash
}
var ret = proxyUrl.replace('%url%', encodeURIComponent(url)).
replace('%host%', document.location.host).
replace('%rawurl%', url).
replace('%filename%', fileName).
replace('%refresh%', encodeURIComponent(refresh)).
replace('%gadget%', encodeURIComponent(urlParams['url'])).
replace('%container%', encodeURIComponent(urlParams['container'] || urlParams['synd'] || 'default')).
replace('%authz%', authParam).
replace('%rewriteMime%', rewriteMimeParam);
if (ret.indexOf('//') == 0) {
ret = window.location.protocol + ret;
}
return ret;
},
/**
* @private
*/
processResponse_: processResponse
};
}();
/**
* @const
**/
gadgets.io.RequestParameters = gadgets.util.makeEnum([
'ALIAS',
'METHOD',
'CONTENT_TYPE',
'POST_DATA',
'HEADERS',
'AUTHORIZATION',
'NUM_ENTRIES',
'GET_SUMMARIES',
'GET_FULL_HEADERS',
'REFRESH_INTERVAL',
'SIGN_OWNER',
'SIGN_VIEWER',
'OAUTH_SERVICE_NAME',
'OAUTH_USE_TOKEN',
'OAUTH_TOKEN_NAME',
'OAUTH_REQUEST_TOKEN',
'OAUTH_REQUEST_TOKEN_SECRET',
'OAUTH_RECEIVED_CALLBACK',
'NO_CACHE'
]);
/**
* @const
*/
gadgets.io.MethodType = gadgets.util.makeEnum([
'GET', 'POST', 'PUT', 'DELETE', 'HEAD'
]);
/**
* @const
*/
gadgets.io.ContentType = gadgets.util.makeEnum([
'TEXT', 'DOM', 'JSON', 'FEED'
]);
/**
* @const
*/
gadgets.io.AuthorizationType = gadgets.util.makeEnum([
'NONE', 'SIGNED', 'OAUTH', "OAUTH2"
]);