diff --git a/demos/linkedin.html b/demos/linkedin.html index 12b2c56..b1cc133 100644 --- a/demos/linkedin.html +++ b/demos/linkedin.html @@ -1,32 +1,219 @@ - - - - - - - - - - -hello( linkedin ) -

hello( linkedin )

- - -
- + + + + + + + + + + + + + + + + + + + + +hello( linkedin ) + +

hello( linkedin )

+ + + +
+ +ℹ️ LinkedIn API v2 Notice: + +

This demo uses LinkedIn API v2 which has these changes:

+ + + +

Learn more: LinkedIn Authentication

+ +
+ + + + + +
+ +
+ + + + + + + +

Initialize with your LinkedIn Client ID:

+ + + + + + + +

Logout

+ + \ No newline at end of file diff --git a/dist/hello.all.js b/dist/hello.all.js index ba2d384..4c2b775 100644 --- a/dist/hello.all.js +++ b/dist/hello.all.js @@ -257,6 +257,8 @@ hello.utils.extend(hello, { // Ths could be problematic if the redirect_uri is indeed the final place, // Typically this circumvents the problem of the redirect_url being a dumb relay page. page_uri: window.location.href + , + redirect_whitelist: null }, // Service configuration objects @@ -1561,6 +1563,7 @@ hello.utils.extend(hello.utils, { // Loading the redirect.html before triggering the OAuth Flow seems to fix it. else if ('oauth_redirect' in p) { var url = decodeURIComponent(p.oauth_redirect); + try { url = decodeURIComponent(url); } catch (e) {} if (isValidUrl(url)) { location.assign(url); @@ -1571,14 +1574,24 @@ hello.utils.extend(hello.utils, { function isValidUrl(url) { var regexp = /^https?:/; - return regexp.test(url) + if (!regexp.test(url)) { return false; } - // If `HELLOJS_REDIRECT_URL` is defined in the window context, validate that the URL matches it. - && ( - !Object.prototype.hasOwnProperty.call(window, 'HELLOJS_REDIRECT_URL') - || - url.match(window.HELLOJS_REDIRECT_URL) - ); + if (Object.prototype.hasOwnProperty.call(window, 'HELLOJS_REDIRECT_URL') && !url.match(window.HELLOJS_REDIRECT_URL)) { + return false; + } + + var wl = (typeof hello !== 'undefined' && hello.settings) ? hello.settings.redirect_whitelist : null; + if (wl) { + var matchOne = function(w) { + if (typeof w === 'string') { return url.indexOf(w) === 0; } + if (w instanceof RegExp) { return w.test(url); } + return false; + }; + if (Array.isArray(wl)) { return wl.some(matchOne); } + return matchOne(wl); + } + + return true; } // Trigger a callback to authenticate @@ -5959,4 +5972,4 @@ if (typeof define === 'function' && define.amd) { // CommonJS module for browserify if (typeof module === 'object' && module.exports) { module.exports = hello; -} +} \ No newline at end of file diff --git a/dist/hello.js b/dist/hello.js index 9cc9dcb..ce05048 100644 --- a/dist/hello.js +++ b/dist/hello.js @@ -257,6 +257,8 @@ hello.utils.extend(hello, { // Ths could be problematic if the redirect_uri is indeed the final place, // Typically this circumvents the problem of the redirect_url being a dumb relay page. page_uri: window.location.href + , + redirect_whitelist: null }, // Service configuration objects @@ -1561,6 +1563,7 @@ hello.utils.extend(hello.utils, { // Loading the redirect.html before triggering the OAuth Flow seems to fix it. else if ('oauth_redirect' in p) { var url = decodeURIComponent(p.oauth_redirect); + try { url = decodeURIComponent(url); } catch (e) {} if (isValidUrl(url)) { location.assign(url); @@ -1571,14 +1574,24 @@ hello.utils.extend(hello.utils, { function isValidUrl(url) { var regexp = /^https?:/; - return regexp.test(url) + if (!regexp.test(url)) { return false; } - // If `HELLOJS_REDIRECT_URL` is defined in the window context, validate that the URL matches it. - && ( - !Object.prototype.hasOwnProperty.call(window, 'HELLOJS_REDIRECT_URL') - || - url.match(window.HELLOJS_REDIRECT_URL) - ); + if (Object.prototype.hasOwnProperty.call(window, 'HELLOJS_REDIRECT_URL') && !url.match(window.HELLOJS_REDIRECT_URL)) { + return false; + } + + var wl = (typeof hello !== 'undefined' && hello.settings) ? hello.settings.redirect_whitelist : null; + if (wl) { + var matchOne = function(w) { + if (typeof w === 'string') { return url.indexOf(w) === 0; } + if (w instanceof RegExp) { return w.test(url); } + return false; + }; + if (Array.isArray(wl)) { return wl.some(matchOne); } + return matchOne(wl); + } + + return true; } // Trigger a callback to authenticate @@ -3076,4 +3089,4 @@ if (typeof define === 'function' && define.amd) { // CommonJS module for browserify if (typeof module === 'object' && module.exports) { module.exports = hello; -} +} \ No newline at end of file diff --git a/src/hello.js b/src/hello.js index a310208..5822dce 100644 --- a/src/hello.js +++ b/src/hello.js @@ -104,6 +104,9 @@ hello.utils.extend(hello, { // Ths could be problematic if the redirect_uri is indeed the final place, // Typically this circumvents the problem of the redirect_url being a dumb relay page. page_uri: window.location.href + , + // Optional whitelist for redirect targets. Can be string, RegExp, or array of either + redirect_whitelist: null }, // Service configuration objects @@ -1409,6 +1412,12 @@ hello.utils.extend(hello.utils, { else if ('oauth_redirect' in p) { var url = decodeURIComponent(p.oauth_redirect); + // Attempt an additional decode to counter double-encoding + try { + url = decodeURIComponent(url); + } + catch (e) {} + if (isValidUrl(url)) { location.assign(url); } @@ -1418,14 +1427,36 @@ hello.utils.extend(hello.utils, { function isValidUrl(url) { var regexp = /^https?:/; - return regexp.test(url) + if (!regexp.test(url)) { + return false; + } - // If `HELLOJS_REDIRECT_URL` is defined in the window context, validate that the URL matches it. - && ( - !Object.prototype.hasOwnProperty.call(window, 'HELLOJS_REDIRECT_URL') - || - url.match(window.HELLOJS_REDIRECT_URL) - ); + // Optional global regex whitelist + if (Object.prototype.hasOwnProperty.call(window, 'HELLOJS_REDIRECT_URL') && !url.match(window.HELLOJS_REDIRECT_URL)) { + return false; + } + + // Optional settings-based whitelist + var wl = (typeof hello !== 'undefined' && hello.settings) ? hello.settings.redirect_whitelist : null; + if (wl) { + var matchOne = function(w) { + if (typeof w === 'string') { + return url.indexOf(w) === 0; + } + if (w instanceof RegExp) { + return w.test(url); + } + return false; + }; + + if (Array.isArray(wl)) { + return wl.some(matchOne); + } + + return matchOne(wl); + } + + return true; } // Trigger a callback to authenticate @@ -2690,4 +2721,4 @@ hello.utils.extend(hello.utils, { // ///////////////////////////////////// -hello.utils.responseHandler(window, window.opener || window.parent); +hello.utils.responseHandler(window, window.opener || window.parent); \ No newline at end of file diff --git a/src/modules/linkedin.js b/src/modules/linkedin.js index 12672e4..a29ef18 100644 --- a/src/modules/linkedin.js +++ b/src/modules/linkedin.js @@ -1,201 +1,411 @@ -(function(hello) { - - hello.init({ - - linkedin: { - - oauth: { - version: 2, - response_type: 'code', - auth: 'https://www.linkedin.com/uas/oauth2/authorization', - grant: 'https://www.linkedin.com/uas/oauth2/accessToken' - }, - - // Refresh the access_token once expired - refresh: true, - - scope: { - basic: 'r_basicprofile', - email: 'r_emailaddress', - files: '', - friends: '', - photos: '', - publish: 'w_share', - publish_files: 'w_share', - share: '', - videos: '', - offline_access: '' - }, - scope_delim: ' ', - - base: 'https://api.linkedin.com/v1/', - - get: { - me: 'people/~:(picture-url,first-name,last-name,id,formatted-name,email-address)', - - // See: http://developer.linkedin.com/documents/get-network-updates-and-statistics-api - 'me/share': 'people/~/network/updates?count=@{limit|250}' - }, - - post: { - - // See: https://developer.linkedin.com/documents/api-requests-json - 'me/share': function(p, callback) { - var data = { - visibility: { - code: 'anyone' - } - }; - - if (p.data.id) { - - data.attribution = { - share: { - id: p.data.id - } - }; - - } - else { - data.comment = p.data.message; - if (p.data.picture && p.data.link) { - data.content = { - 'submitted-url': p.data.link, - 'submitted-image-url': p.data.picture - }; - } - } - - p.data = JSON.stringify(data); - - callback('people/~/shares?format=json'); - }, - - 'me/like': like - }, - - del: { - 'me/like': like - }, - - wrap: { - me: function(o) { - formatError(o); - formatUser(o); - return o; - }, - - 'me/friends': formatFriends, - 'me/following': formatFriends, - 'me/followers': formatFriends, - 'me/share': function(o) { - formatError(o); - paging(o); - if (o.values) { - o.data = o.values.map(formatUser); - o.data.forEach(function(item) { - item.message = item.headline; - }); - - delete o.values; - } - - return o; - }, - - 'default': function(o, headers) { - formatError(o); - empty(o, headers); - paging(o); - } - }, - - jsonp: function(p, qs) { - formatQuery(qs); - if (p.method === 'get') { - qs.format = 'jsonp'; - qs['error-callback'] = p.callbackID; - } - }, - - xhr: function(p, qs) { - if (p.method !== 'get') { - formatQuery(qs); - p.headers['Content-Type'] = 'application/json'; - - // Note: x-li-format ensures error responses are not returned in XML - p.headers['x-li-format'] = 'json'; - p.proxy = true; - return true; - } - - return false; - } - } - }); - - function formatError(o) { - if (o && 'errorCode' in o) { - o.error = { - code: o.status, - message: o.message - }; - } - } - - function formatUser(o) { - if (o.error) { - return; - } - - o.first_name = o.firstName; - o.last_name = o.lastName; - o.name = o.formattedName || (o.first_name + ' ' + o.last_name); - o.thumbnail = o.pictureUrl; - o.email = o.emailAddress; - return o; - } - - function formatFriends(o) { - formatError(o); - paging(o); - if (o.values) { - o.data = o.values.map(formatUser); - delete o.values; - } - - return o; - } - - function paging(res) { - if ('_count' in res && '_start' in res && (res._count + res._start) < res._total) { - res.paging = { - next: '?start=' + (res._start + res._count) + '&count=' + res._count - }; - } - } - - function empty(o, headers) { - if (JSON.stringify(o) === '{}' && headers.statusCode === 200) { - o.success = true; - } - } - - function formatQuery(qs) { - // LinkedIn signs requests with the parameter 'oauth2_access_token' - // ... yeah another one who thinks they should be different! - if (qs.access_token) { - qs.oauth2_access_token = qs.access_token; - delete qs.access_token; - } - } - - function like(p, callback) { - p.headers['x-li-format'] = 'json'; - var id = p.data.id; - p.data = (p.method !== 'delete').toString(); - p.method = 'put'; - callback('people/~/network/updates/key=' + id + '/is-liked'); - } - -})(hello); +(function(hello) { + + + +hello.init({ + + + +linkedin: { + + + +oauth: { + +version: 2, + +response_type: 'code', + +// Updated OAuth v2 endpoints (old /uas/ endpoints are deprecated) + +auth: 'https://www.linkedin.com/oauth/v2/authorization', + +grant: 'https://www.linkedin.com/oauth/v2/accessToken' + +}, + + + +// Refresh the access_token once expired + +refresh: true, + + + +scope: { + +// LinkedIn API v2 scopes + +// See: https://docs.microsoft.com/en-us/linkedin/shared/authentication/authentication + +basic: 'r_liteprofile', + +email: 'r_emailaddress', + +files: '', + +friends: '', + +photos: '', + +publish: 'w_member_social', + +publish_files: 'w_member_social', + +share: '', + +videos: '', + +offline_access: '' + +}, + +scope_delim: ' ', + + + +// LinkedIn API v2 base URL + +base: 'https://api.linkedin.com/v2/', + + + +get: { + +// Get user profile using v2 API + +// See: https://docs.microsoft.com/en-us/linkedin/shared/integrations/people/profile-api + +me: 'me', + + + +// Email address requires separate endpoint in v2 + +'me/email': 'emailAddress?q=members&projection=(elements*(handle~))', + + + +// Note: LinkedIn v2 API has very limited access to network updates + +// The old 'me/share' endpoint (network updates) is not available in v2 + +// Only your own posts can be retrieved via UGC Post API + +'me/share': function() { + +// This endpoint is deprecated and not available in API v2 + +return false; + +} + +}, + + + +post: { + +// Share content using UGC Post API + +// See: https://docs.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/ugc-post-api + +'me/share': function(p, callback) { + +var data = { + +author: 'urn:li:person:' + p.data.authorId, // Requires person ID + +lifecycleState: 'PUBLISHED', + +specificContent: { + +'com.linkedin.ugc.ShareContent': { + +shareCommentary: { + +text: p.data.message || '' + +}, + +shareMediaCategory: 'NONE' + +} + +}, + +visibility: { + +'com.linkedin.ugc.MemberNetworkVisibility': 'PUBLIC' + +} + +}; + + + +// Add link if provided + +if (p.data.link) { + +data.specificContent['com.linkedin.ugc.ShareContent'].shareMediaCategory = 'ARTICLE'; + +data.specificContent['com.linkedin.ugc.ShareContent'].media = [{ + +status: 'READY', + +originalUrl: p.data.link, + +title: { + +text: p.data.name || '' + +} + +}]; + +} + + + +p.data = JSON.stringify(data); + +p.headers['Content-Type'] = 'application/json'; + +p.headers['X-Restli-Protocol-Version'] = '2.0.0'; + + + +callback('ugcPosts'); + +} + +}, + + + +// LinkedIn v2 API doesn't support liking via simple API + +// Removed del object as it's not supported + + + +wrap: { + +me: function(o) { + +formatError(o); + + + +if (o && !o.error) { + +// LinkedIn API v2 response format + +// Map v2 fields to hello.js standard format + +if (o.localizedFirstName && o.localizedLastName) { + +o.first_name = o.localizedFirstName; + +o.last_name = o.localizedLastName; + +o.name = o.first_name + ' ' + o.last_name; + +} + + + +// Profile picture in v2 is more complex + +if (o.profilePicture && o.profilePicture['displayImage~']) { + +var images = o.profilePicture['displayImage~'].elements; + +if (images && images.length > 0) { + +// Get the largest image + +var largestImage = images[images.length - 1]; + +if (largestImage.identifiers && largestImage.identifiers.length > 0) { + +o.thumbnail = largestImage.identifiers[0].identifier; + +} + +} + +} + +} + + + +return o; + +}, + + + +'me/email': function(o) { + +formatError(o); + + + +if (o && !o.error && o.elements && o.elements.length > 0) { + +var emailData = o.elements[0]['handle~']; + +if (emailData) { + +return { + +email: emailData.emailAddress + +}; + +} + +} + + + +return o; + +}, + + + +'me/share': function(o) { + +formatError(o); + +// v2 API doesn't provide network updates easily + +// Return empty data structure + +return { + +data: [], + +paging: null + +}; + +}, + + + +'default': function(o, headers) { + +formatError(o); + +empty(o, headers); + +} + +}, + + + +xhr: function(p, qs) { + +// LinkedIn v2 API requires different parameter name + +formatQuery(qs); + + + +if (p.method !== 'get') { + +p.headers['Content-Type'] = 'application/json'; + +p.headers['X-Restli-Protocol-Version'] = '2.0.0'; + +p.proxy = true; + +return true; + +} + + + +return false; + +} + +} + +}); + + + +function formatError(o) { + +// LinkedIn v2 API error format + +if (o && o.status && o.status >= 400) { + +o.error = { + +code: o.status, + +message: o.message || 'Request failed' + +}; + +} + +// Handle serviceErrorCode format + +else if (o && o.serviceErrorCode) { + +o.error = { + +code: o.serviceErrorCode, + +message: o.message || 'Service error' + +}; + +} + +} + + + +function empty(o, headers) { + +if (JSON.stringify(o) === '{}' && headers && headers.statusCode === 200) { + +o.success = true; + +} else if (JSON.stringify(o) === '{}' && headers && headers.statusCode === 201) { + +o.success = true; + +} + +} + + + +function formatQuery(qs) { + +// LinkedIn v2 still uses oauth2_access_token for some requests + +if (qs.access_token) { + +qs.oauth2_access_token = qs.access_token; + +delete qs.access_token; + +} + +} + + + +})(hello); \ No newline at end of file diff --git a/src/modules/twitter.js b/src/modules/twitter.js index 829fbb1..345b4d1 100644 --- a/src/modules/twitter.js +++ b/src/modules/twitter.js @@ -135,8 +135,8 @@ }, xhr: function(p) { - // Rely on the proxy for non-GET requests. - return (p.method !== 'get'); + // Twitter OAuth1 requires ALL requests to be proxied for signing + return true; } } }); diff --git a/src/modules/twitter.js.bak b/src/modules/twitter.js.bak new file mode 100644 index 0000000..d640100 --- /dev/null +++ b/src/modules/twitter.js.bak @@ -0,0 +1,224 @@ +(function(hello) { + +var base = 'https://api.twitter.com/'; + +hello.init({ + + twitter: { + + // Ensure that you define an oauth_proxy + oauth: { + version: '1.0a', + auth: base + 'oauth/authenticate', + request: base + 'oauth/request_token', + token: base + 'oauth/access_token' + }, + + login: function(p) { + // Reauthenticate + // https://dev.twitter.com/oauth/reference/get/oauth/authenticate + var prefix = '?force_login=true'; + this.oauth.auth = this.oauth.auth.replace(prefix, '') + (p.options.force ? prefix : ''); + }, + + base: base + '1.1/', + + get: { + me: 'account/verify_credentials.json', + 'me/friends': 'friends/list.json?count=@{limit|200}', + 'me/following': 'friends/list.json?count=@{limit|200}', + 'me/followers': 'followers/list.json?count=@{limit|200}', + + // Https://dev.twitter.com/docs/api/1.1/get/statuses/user_timeline + 'me/share': 'statuses/user_timeline.json?count=@{limit|200}', + + // Https://dev.twitter.com/rest/reference/get/favorites/list + 'me/like': 'favorites/list.json?count=@{limit|200}' + }, + + post: { + 'me/share': function(p, callback) { + + var data = p.data; + p.data = null; + + var status = []; + + // Change message to status + if (data.message) { + status.push(data.message); + delete data.message; + } + + // If link is given + if (data.link) { + status.push(data.link); + delete data.link; + } + + if (data.picture) { + status.push(data.picture); + delete data.picture; + } + + // Compound all the components + if (status.length) { + data.status = status.join(' '); + } + + // Tweet media + if (data.file) { + data['media[]'] = data.file; + delete data.file; + p.data = data; + callback('statuses/update_with_media.json'); + } + + // Retweet? + else if ('id' in data) { + callback('statuses/retweet/' + data.id + '.json'); + } + + // Tweet + else { + // Assign the post body to the query parameters + hello.utils.extend(p.query, data); + callback('statuses/update.json?include_entities=1'); + } + }, + + // See: https://dev.twitter.com/rest/reference/post/favorites/create + 'me/like': function(p, callback) { + var id = p.data.id; + p.data = null; + callback('favorites/create.json?id=' + id); + } + }, + + del: { + + // See: https://dev.twitter.com/rest/reference/post/favorites/destroy + 'me/like': function(p, callback) { + p.method = 'post'; + var id = p.data.id; + p.data = null; + callback('favorites/destroy.json?id=' + id); + } + }, + + wrap: { + me: function(res) { + formatError(res); + formatUser(res); + return res; + }, + + 'me/friends': formatFriends, + 'me/followers': formatFriends, + 'me/following': formatFriends, + + 'me/share': function(res) { + formatError(res); + paging(res); + if (!res.error && 'length' in res) { + return {data: res}; + } + + return res; + }, + + 'default': function(res) { + res = arrayToDataResponse(res); + paging(res); + return res; + } + }, + xhr: function(p) { + + // Rely on the proxy for non-GET requests. + // Twitter OAuth1 requires ALL requests to be proxied for signing + return true; + } + } +}); + +function formatUser(o) { + if (o.id) { + if (o.name) { + var m = o.name.split(' '); + o.first_name = m.shift(); + o.last_name = m.join(' '); + } + + // See: https://dev.twitter.com/overview/general/user-profile-images-and-banners + o.thumbnail = o.profile_image_url_https || o.profile_image_url; + } + + return o; +} + +function formatFriends(o) { + formatError(o); + paging(o); + if (o.users) { + o.data = o.users.map(formatUser); + delete o.users; + } + + return o; +} + +function formatError(o) { + if (o.errors) { + var e = o.errors[0]; + o.error = { + code: 'request_failed', + message: e.message + }; + } +} + +// Take a cursor and add it to the path +function paging(res) { + // Does the response include a 'next_cursor_string' + if ('next_cursor_str' in res) { + // See: https://dev.twitter.com/docs/misc/cursoring + res.paging = { + next: '?cursor=' + res.next_cursor_str + }; + } +} + +function arrayToDataResponse(res) { + return Array.isArray(res) ? {data: res} : res; +} + +/** +// The documentation says to define user in the request +// Although its not actually required. + +var user_id; + +function withUserId(callback){ + if(user_id){ + callback(user_id); + } + else{ + hello.api('twitter:/me', function(o){ + user_id = o.id; + callback(o.id); + }); + } +} + +function sign(url){ + return function(p, callback){ + withUserId(function(user_id){ + callback(url+'?user_id='+user_id); + }); + }; +} +*/ + + +})(hello); \ No newline at end of file