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:
+
+
+
+- OAuth endpoints updated from /uas/ to /oauth/v2/
+
+- Updated scopes: r_liteprofile (basic info) and r_emailaddress (email)
+
+- Profile picture requires profile picture decorations in API call
+
+- Network updates (feed) access is very limited in v2
+
+
+
+
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