APIv2: add paths /api/v2/* and /api/v1/*. v1 has limited deprecated routes (only the things I know currently work), and we'll notify 3rd-partyists to migrate to apiv2 once it's documented and tested

This commit is contained in:
Tyler Renelle
2013-12-15 17:22:35 -07:00
parent 91e1287d06
commit 259f9033c4
21 changed files with 213 additions and 117 deletions

4
API.md Normal file
View File

@@ -0,0 +1,4 @@
## APIv2
## APIv1 (Deprecated)
Make sure to send `PUT /api/v1/user` request bodies as `{'set.this.path':value}` instead of `{set:{this:{path:value}}}`

View File

@@ -2,7 +2,7 @@
habitrpg.controller("AdminCtrl", ['$scope', '$rootScope', 'User', 'Notification', 'API_URL', '$resource', habitrpg.controller("AdminCtrl", ['$scope', '$rootScope', 'User', 'Notification', 'API_URL', '$resource',
function($scope, $rootScope, User, Notification, API_URL, $resource) { function($scope, $rootScope, User, Notification, API_URL, $resource) {
var Contributor = $resource(API_URL + '/api/v1/admin/members/:uid', {uid:'@_id'}); var Contributor = $resource(API_URL + '/api/v2/admin/members/:uid', {uid:'@_id'});
$scope.profile = undefined; $scope.profile = undefined;
$scope.loadUser = function(uuid){ $scope.loadUser = function(uuid){

View File

@@ -37,7 +37,7 @@ habitrpg.controller("AuthCtrl", ['$scope', '$rootScope', 'User', '$http', '$loca
if ($scope.registrationForm.$invalid) { if ($scope.registrationForm.$invalid) {
return; return;
} }
$http.post(API_URL + "/api/v1/register", $scope.registerVals).success(function(data, status, headers, config) { $http.post(API_URL + "/api/v2/register", $scope.registerVals).success(function(data, status, headers, config) {
runAuth(data.id, data.apiToken); runAuth(data.id, data.apiToken);
}).error(function(data, status, headers, config) { }).error(function(data, status, headers, config) {
if (status === 0) { if (status === 0) {
@@ -68,7 +68,7 @@ habitrpg.controller("AuthCtrl", ['$scope', '$rootScope', 'User', '$http', '$loca
if ($scope.useUUID) { if ($scope.useUUID) {
runAuth($scope.loginUsername, $scope.loginPassword); runAuth($scope.loginUsername, $scope.loginPassword);
} else { } else {
$http.post(API_URL + "/api/v1/user/auth/local", data) $http.post(API_URL + "/api/v2/user/auth/local", data)
.success(function(data, status, headers, config) { .success(function(data, status, headers, config) {
runAuth(data.id, data.token); runAuth(data.id, data.token);
}).error(errorAlert); }).error(errorAlert);
@@ -84,7 +84,7 @@ habitrpg.controller("AuthCtrl", ['$scope', '$rootScope', 'User', '$http', '$loca
} }
$scope.passwordReset = function(email){ $scope.passwordReset = function(email){
$http.post(API_URL + '/api/v1/user/reset-password', {email:email}) $http.post(API_URL + '/api/v2/user/reset-password', {email:email})
.success(function(){ .success(function(){
alert('New password sent.'); alert('New password sent.');
}) })

View File

@@ -51,7 +51,7 @@ habitrpg.controller("FooterCtrl", ['$scope', '$rootScope', 'User', '$http', 'Not
Notification.text('-1 day, remember to refresh'); Notification.text('-1 day, remember to refresh');
} }
$scope.addTenGems = function(){ $scope.addTenGems = function(){
$http.post(API_URL + '/api/v1/user/addTenGems').success(function(){ $http.post(API_URL + '/api/v2/user/addTenGems').success(function(){
User.log({}); User.log({});
}) })
} }

View File

@@ -81,7 +81,7 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$
panelLabel: "Checkout", panelLabel: "Checkout",
token: function(data) { token: function(data) {
$scope.$apply(function(){ $scope.$apply(function(){
$http.post("/api/v1/user/buy-gems", data) $http.post("/api/v2/user/buy-gems", data)
.success(function() { .success(function() {
window.location.href = "/"; window.location.href = "/";
}).error(function(err) { }).error(function(err) {
@@ -158,7 +158,7 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$
$scope.castEnd = function(target, type, $event){ $scope.castEnd = function(target, type, $event){
if ($scope.spell.target != type) return Notification.text("Invalid target"); if ($scope.spell.target != type) return Notification.text("Invalid target");
$scope.spell.cast(User.user, target); $scope.spell.cast(User.user, target);
$http.post('/api/v1/user/cast/' + $scope.spell.name, {target:target, type:type}).success(function(){ $http.post('/api/v2/user/cast/' + $scope.spell.name, {target:target, type:type}).success(function(){
var msg = "You cast " + $scope.spell.text; var msg = "You cast " + $scope.spell.text;
switch (type) { switch (type) {
case 'task': msg += ' on ' + target.text;break; case 'task': msg += ' on ' + target.text;break;

View File

@@ -54,7 +54,7 @@ habitrpg.controller('SettingsCtrl',
if (!changePass.oldPassword || !changePass.newPassword || !changePass.confirmNewPassword) { if (!changePass.oldPassword || !changePass.newPassword || !changePass.confirmNewPassword) {
return alert("Please fill out all fields"); return alert("Please fill out all fields");
} }
$http.post(API_URL + '/api/v1/user/change-password', changePass) $http.post(API_URL + '/api/v2/user/change-password', changePass)
.success(function(){ .success(function(){
alert("Password successfully changed"); alert("Password successfully changed");
$scope.changePass = {}; $scope.changePass = {};
@@ -92,7 +92,7 @@ habitrpg.controller('SettingsCtrl',
} }
$scope['delete'] = function(){ $scope['delete'] = function(){
$http['delete'](API_URL + '/api/v1/user') $http['delete'](API_URL + '/api/v2/user')
.success(function(){ .success(function(){
localStorage.clear(); localStorage.clear();
window.location.href = '/logout'; window.location.href = '/logout';

View File

@@ -64,7 +64,7 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N
$scope.unlink = function(task, keep) { $scope.unlink = function(task, keep) {
// TODO move this to userServices, turn userSerivces.user into ng-resource // TODO move this to userServices, turn userSerivces.user into ng-resource
$http.post(API_URL + '/api/v1/user/tasks/' + task.id + '/unlink?keep=' + keep) $http.post(API_URL + '/api/v2/user/tasks/' + task.id + '/unlink?keep=' + keep)
.success(function(){ .success(function(){
User.log({}); User.log({});
}); });

View File

@@ -29,7 +29,7 @@ factory('Facebook',
email: response.email email: response.email
} }
$http.post(API_URL + '/api/v1/user/auth/facebook', data).success(function(data, status, headers, config) { $http.post(API_URL + '/api/v2/user/auth/facebook', data).success(function(data, status, headers, config) {
User.authenticate(data.id, data.token, function(err) { User.authenticate(data.id, data.token, function(err) {
if (!err) { if (!err) {
alert('Login successful!'); alert('Login successful!');

View File

@@ -7,14 +7,14 @@
angular.module('challengeServices', ['ngResource']). angular.module('challengeServices', ['ngResource']).
factory('Challenges', ['API_URL', '$resource', 'User', '$q', 'Members', factory('Challenges', ['API_URL', '$resource', 'User', '$q', 'Members',
function(API_URL, $resource, User, $q, Members) { function(API_URL, $resource, User, $q, Members) {
var Challenge = $resource(API_URL + '/api/v1/challenges/:cid', var Challenge = $resource(API_URL + '/api/v2/challenges/:cid',
{cid:'@_id'}, {cid:'@_id'},
{ {
//'query': {method: "GET", isArray:false} //'query': {method: "GET", isArray:false}
join: {method: "POST", url: API_URL + '/api/v1/challenges/:cid/join'}, join: {method: "POST", url: API_URL + '/api/v2/challenges/:cid/join'},
leave: {method: "POST", url: API_URL + '/api/v1/challenges/:cid/leave'}, leave: {method: "POST", url: API_URL + '/api/v2/challenges/:cid/leave'},
close: {method: "POST", params: {uid:''}, url: API_URL + '/api/v1/challenges/:cid/close'}, close: {method: "POST", params: {uid:''}, url: API_URL + '/api/v2/challenges/:cid/close'},
getMember: {method: "GET", url: API_URL + '/api/v1/challenges/:cid/member/:uid'} getMember: {method: "GET", url: API_URL + '/api/v2/challenges/:cid/member/:uid'}
}); });
//var challenges = []; //var challenges = [];

View File

@@ -7,16 +7,16 @@
angular.module('groupServices', ['ngResource']). angular.module('groupServices', ['ngResource']).
factory('Groups', ['API_URL', '$resource', '$q', factory('Groups', ['API_URL', '$resource', '$q',
function(API_URL, $resource, $q) { function(API_URL, $resource, $q) {
var Group = $resource(API_URL + '/api/v1/groups/:gid', var Group = $resource(API_URL + '/api/v2/groups/:gid',
{gid:'@_id', messageId: '@_messageId'}, {gid:'@_id', messageId: '@_messageId'},
{ {
//query: {method: "GET", isArray:false}, //query: {method: "GET", isArray:false},
postChat: {method: "POST", url: API_URL + '/api/v1/groups/:gid/chat'}, postChat: {method: "POST", url: API_URL + '/api/v2/groups/:gid/chat'},
deleteChatMessage: {method: "DELETE", url: API_URL + '/api/v1/groups/:gid/chat/:messageId'}, deleteChatMessage: {method: "DELETE", url: API_URL + '/api/v2/groups/:gid/chat/:messageId'},
join: {method: "POST", url: API_URL + '/api/v1/groups/:gid/join'}, join: {method: "POST", url: API_URL + '/api/v2/groups/:gid/join'},
leave: {method: "POST", url: API_URL + '/api/v1/groups/:gid/leave'}, leave: {method: "POST", url: API_URL + '/api/v2/groups/:gid/leave'},
invite: {method: "POST", url: API_URL + '/api/v1/groups/:gid/invite'}, invite: {method: "POST", url: API_URL + '/api/v2/groups/:gid/invite'},
removeMember: {method: "POST", url: API_URL + '/api/v1/groups/:gid/removeMember'} removeMember: {method: "POST", url: API_URL + '/api/v2/groups/:gid/removeMember'}
}); });
// Defer loading everything until they're requested // Defer loading everything until they're requested

View File

@@ -8,7 +8,7 @@ angular.module('memberServices', ['ngResource']).
factory('Members', ['$rootScope', 'API_URL', '$resource', factory('Members', ['$rootScope', 'API_URL', '$resource',
function($rootScope, API_URL, $resource) { function($rootScope, API_URL, $resource) {
var members = {}; var members = {};
var Member = $resource(API_URL + '/api/v1/members/:uid', {uid:'@_id'}); var Member = $resource(API_URL + '/api/v2/members/:uid', {uid:'@_id'});
var memberServices = { var memberServices = {
Member: Member, Member: Member,

View File

@@ -56,7 +56,7 @@ angular.module('userServices', []).
sent.push(queue.shift()); sent.push(queue.shift());
}); });
$http.post(API_URL + '/api/v1/user/batch-update', sent, {params: {data:+new Date, _v:user._v, siteVersion: $window.env && $window.env.siteVersion}}) $http.post(API_URL + '/api/v2/user/batch-update', sent, {params: {data:+new Date, _v:user._v, siteVersion: $window.env && $window.env.siteVersion}})
.success(function (data, status, heacreatingders, config) { .success(function (data, status, heacreatingders, config) {
//make sure there are no pending actions to sync. If there are any it is not safe to apply model from server as we may overwrite user data. //make sure there are no pending actions to sync. If there are any it is not safe to apply model from server as we may overwrite user data.
if (!queue.length) { if (!queue.length) {

View File

@@ -1,6 +1,6 @@
"use strict"; "use strict";
window.habitrpg = angular.module('habitrpg', ['userServices', 'chieffancypants.loadingBar']) window.habitrpg = angular.module('habitrpg', ['notificationServices', 'userServices', 'chieffancypants.loadingBar'])
.constant("API_URL", "") .constant("API_URL", "")
.constant("STORAGE_USER_ID", 'habitrpg-user') .constant("STORAGE_USER_ID", 'habitrpg-user')
.constant("STORAGE_SETTINGS_ID", 'habit-mobile-settings') .constant("STORAGE_SETTINGS_ID", 'habit-mobile-settings')

View File

@@ -72,8 +72,8 @@
"bower_components/angular-loading-bar/build/loading-bar.js", "bower_components/angular-loading-bar/build/loading-bar.js",
"js/static.js", "js/static.js",
"js/services/userServices.js",
"js/services/notificationServices.js", "js/services/notificationServices.js",
"js/services/userServices.js",
"js/controllers/authCtrl.js" "js/controllers/authCtrl.js"
], ],
"css": [ "css": [

View File

@@ -1,76 +0,0 @@
var express = require('express');
var router = new express.Router();
var _ = require('lodash');
var icalendar = require('icalendar');
var api = require('./user');
var auth = require('./auth');
/* ---------- Deprecated Paths ------------*/
var deprecatedMessage = 'This API is no longer supported, see https://github.com/lefnire/habitrpg/wiki/API for new protocol';
router.get('/:uid/up/:score?', function(req, res) {
return res.send(500, deprecatedMessage);
});
router.get('/:uid/down/:score?', function(req, res) {
return res.send(500, deprecatedMessage);
});
router.post('/users/:uid/tasks/:taskId/:direction', function(req, res) {
return res.send(500, deprecatedMessage);
});
/* Redirect to new API*/
var initDeprecated = function(req, res, next) {
req.headers['x-api-user'] = req.params.uid;
req.headers['x-api-key'] = req.body.apiToken;
return next();
};
router.post('/v1/users/:uid/tasks/:taskId/:direction', initDeprecated, auth.auth, api.score);
router.get('/v1/users/:uid/calendar.ics', function(req, res, next) {
return next() //disable for now
var apiToken, model, query, uid;
uid = req.params.uid;
apiToken = req.query.apiToken;
model = req.getModel();
query = model.query('users').withIdAndToken(uid, apiToken);
return query.fetch(function(err, result) {
var formattedIcal, ical, tasks, tasksWithDates;
if (err) {
return res.send(500, err);
}
tasks = result.get('tasks');
/* tasks = result[0].tasks*/
tasksWithDates = _.filter(tasks, function(task) {
return !!task.date;
});
if (_.isEmpty(tasksWithDates)) {
return res.send(500, "No events found");
}
ical = new icalendar.iCalendar();
ical.addProperty('NAME', 'HabitRPG');
_.each(tasksWithDates, function(task) {
var d, event;
event = new icalendar.VEvent(task.id);
event.setSummary(task.text);
d = new Date(task.date);
d.date_only = true;
event.setDate(d);
ical.addComponent(event);
return true;
});
res.type('text/calendar');
formattedIcal = ical.toString().replace(/DTSTART\:/g, 'DTSTART;VALUE=DATE:');
return res.send(200, formattedIcal);
});
});
module.exports = router;

View File

@@ -43,19 +43,16 @@ findTask = function(req, res) {
Export it also so we can call it from deprecated.coffee Export it also so we can call it from deprecated.coffee
*/ */
api.score = function(req, res, next) { api.score = function(req, res, next) {
var task = findTask(req,res);
if (!task) return res.json(404, {err: "No task found."});
var id = req.params.id, var id = req.params.id,
direction = req.params.direction, direction = req.params.direction,
user = res.locals.user, user = res.locals.user,
task; task;
// Send error responses for improper API call // Send error responses for improper API call
if (!id) return res.json(500, {err: ':id required'}); if (!id) return res.json(400, {err: ':id required'});
if (direction !== 'up' && direction !== 'down') { if (direction !== 'up' && direction !== 'down') {
if (direction == 'unlink') return next(); if (direction == 'unlink') return next();
return res.json(500, {err: ":direction must be 'up' or 'down'"}); return res.json(400, {err: ":direction must be 'up' or 'down'"});
} }
// If exists already, score it // If exists already, score it
if (task = user.tasks[id]) { if (task = user.tasks[id]) {
@@ -181,7 +178,7 @@ api.update = function(req, res, next) {
if (acceptablePUTPaths[k]) if (acceptablePUTPaths[k])
user.fns.dotSet(k, v); user.fns.dotSet(k, v);
else else
errors.push("path `" + k + "` was not saved, as it's a protected path. Make sure to send `PUT /api/v1/user` request bodies as `{'set.this.path':value}` instead of `{set:{this:{path:value}}}`"); errors.push("path `" + k + "` was not saved, as it's a protected path. See https://github.com/HabitRPG/habitrpg/blob/develop/API.md for PUT /api/v2/user.");
return true; return true;
}); });
user.save(function(err) { user.save(function(err) {

171
src/routes/apiv1.js Normal file
View File

@@ -0,0 +1,171 @@
var express = require('express');
var router = new express.Router();
var _ = require('lodash');
var async = require('async');
var icalendar = require('icalendar');
var api = require('./../controllers/user');
var auth = require('./../controllers/auth');
var middleware = require('../middleware');
/* ---------- Deprecated API ------------*/
var initDeprecated = function(req, res, next) {
req.headers['x-api-user'] = req.params.uid;
req.headers['x-api-key'] = req.body.apiToken;
return next();
};
router.post('/v1/users/:uid/tasks/:taskId/:direction', initDeprecated, auth.auth, api.score);
// FIXME add this back in
router.get('/v1/users/:uid/calendar.ics', function(req, res, next) {
return next() //disable for now
var apiToken, model, query, uid;
uid = req.params.uid;
apiToken = req.query.apiToken;
model = req.getModel();
query = model.query('users').withIdAndToken(uid, apiToken);
return query.fetch(function(err, result) {
var formattedIcal, ical, tasks, tasksWithDates;
if (err) {
return res.send(500, err);
}
tasks = result.get('tasks');
/* tasks = result[0].tasks*/
tasksWithDates = _.filter(tasks, function(task) {
return !!task.date;
});
if (_.isEmpty(tasksWithDates)) {
return res.send(500, "No events found");
}
ical = new icalendar.iCalendar();
ical.addProperty('NAME', 'HabitRPG');
_.each(tasksWithDates, function(task) {
var d, event;
event = new icalendar.VEvent(task.id);
event.setSummary(task.text);
d = new Date(task.date);
d.date_only = true;
event.setDate(d);
ical.addComponent(event);
return true;
});
res.type('text/calendar');
formattedIcal = ical.toString().replace(/DTSTART\:/g, 'DTSTART;VALUE=DATE:');
return res.send(200, formattedIcal);
});
});
/*
------------------------------------------------------------------------
Batch Update
This is super-deprecated, and will be removed once apiv2 is running against mobile for a while
------------------------------------------------------------------------
*/
var batchUpdate = function(req, res, next) {
var user = res.locals.user;
var oldSend = res.send;
var oldJson = res.json;
var performAction = function(action, cb) {
// req.body=action.data; delete action.data; _.defaults(req.params, action)
// Would require changing action.dir on mobile app
req.params.id = action.data && action.data.id;
req.params.direction = action.dir;
req.params.type = action.type;
req.body = action.data;
res.send = res.json = function(code, data) {
if (_.isNumber(code) && code >= 400) {
console.error({
code: code,
data: data
});
}
//FIXME send error messages down
return cb();
};
switch (action.op) {
case "score":
api.score(req, res);
break;
case "addTask":
api.addTask(req, res);
break;
case "delTask":
api.deleteTask(req, res);
break;
case "revive":
api.revive(req, res);
break;
default:
cb();
break;
}
};
// Setup the array of functions we're going to call in parallel with async
var actions = _.transform(req.body || [], function(result, action) {
if (!_.isEmpty(action)) {
result.push(function(cb) {
performAction(action, cb);
});
}
});
// call all the operations, then return the user object to the requester
async.series(actions, function(err) {
res.json = oldJson;
res.send = oldSend;
if (err) return res.json(500, {err: err});
var response = user.toJSON();
response.wasModified = res.locals.wasModified;
if (response._tmp && response._tmp.drop){
res.json(200, {_tmp: {drop: response._tmp.drop}, _v: response._v});
}else if(response.wasModified){
res.json(200, response);
}else{
res.json(200, {_v: response._v});
}
});
};
/*
------------------------------------------------------------------------
API v1 Routes
------------------------------------------------------------------------
*/
var cron = api.cron;
router.get('/status', function(req, res) {
return res.json({
status: 'up'
});
});
// Scoring
router.post('/user/task/:id/:direction', auth.auth, cron, api.score);
router.post('/user/tasks/:id/:direction', auth.auth, cron, api.score);
// Tasks
router.get('/user/tasks', auth.auth, cron, api.getTasks);
router.get('/user/task/:id', auth.auth, cron, api.getTask);
router["delete"]('/user/task/:id', auth.auth, cron, api.deleteTask);
router.post('/user/task', auth.auth, cron, api.addTask);
// User
router.get('/user', auth.auth, cron, api.getUser);
router.post('/user/revive', auth.auth, cron, api.revive);
router.post('/user/batch-update', middleware.forceRefresh, auth.auth, cron, batchUpdate);
function deprecated(req, res) {
res.json(404, {err:'API v1 is no longer supported, please use API v2 instead (https://github.com/HabitRPG/habitrpg/blob/develop/API.md)'});
}
router.get('*', deprecated);
router.post('*', deprecated);
router.put('*', deprecated);
module.exports = router;

View File

@@ -4,10 +4,10 @@ var router = new express.Router();
/* auth.auth*/ /* auth.auth*/
auth.setupPassport(router); //FIXME make this consistent with the others auth.setupPassport(router); //FIXME make this consistent with the others
router.post('/api/v1/register', auth.registerUser); router.post('/api/v2/register', auth.registerUser);
router.post('/api/v1/user/auth/local', auth.loginLocal); router.post('/api/v2/user/auth/local', auth.loginLocal);
router.post('/api/v1/user/auth/facebook', auth.loginFacebook); router.post('/api/v2/user/auth/facebook', auth.loginFacebook);
router.post('/api/v1/user/reset-password', auth.resetPassword); router.post('/api/v2/user/reset-password', auth.resetPassword);
router.post('/api/v1/user/change-password', auth.auth, auth.changePassword); router.post('/api/v2/user/change-password', auth.auth, auth.changePassword);
module.exports = router; module.exports = router;

View File

@@ -113,9 +113,9 @@ if ("development" === app.get("env")) {
// Custom Directives // Custom Directives
app.use(require('./routes/pages').middleware); app.use(require('./routes/pages').middleware);
app.use(require('./routes/auth').middleware); app.use(require('./routes/auth').middleware);
app.use('/api/v1', require('./routes/api').middleware); app.use('/api/v2', require('./routes/apiv2').middleware);
app.use('/api/v1', require('./routes/apiv1').middleware);
app.use('/export', require('./routes/dataexport').middleware); app.use('/export', require('./routes/dataexport').middleware);
app.use(require('./controllers/deprecated').middleware);
server = http.createServer(app).listen(app.get("port"), function() { server = http.createServer(app).listen(app.get("port"), function() {
return console.log("Express server listening on port " + app.get("port")); return console.log("Express server listening on port " + app.get("port"));
}); });

View File

@@ -26,7 +26,7 @@ div(modal='modals.buyGems')
.btn.btn-primary(ng-click='showStripe()') Pay with Card .btn.btn-primary(ng-click='showStripe()') Pay with Card
.span6.well .span6.well
h3 Pay with PayPal h3 Pay with PayPal
script(src='/bower_components/JavaScriptButtons/dist/paypal-button.min.js?merchant=#{env.PAYPAL_MERCHANT}', data-button='buynow', data-name='20 Gems, Disable Ads, Donation to the Developers', data-quantity='1', data-amount='5', data-currency='USD', data-tax='0', data-callback='#{env.BASE_URL}/api/v1/user/buy-gems/paypal-ipn', data-env="#{env.NODE_ENV == 'production' ? '' : 'sandbox'}", data-custom='?uid={{user._id}}&apiToken={{user.apiToken}}', data-return='#{env.BASE_URL}', data-rm='1', data-no_shipping='1') script(src='/bower_components/JavaScriptButtons/dist/paypal-button.min.js?merchant=#{env.PAYPAL_MERCHANT}', data-button='buynow', data-name='20 Gems, Disable Ads, Donation to the Developers', data-quantity='1', data-amount='5', data-currency='USD', data-tax='0', data-callback='#{env.BASE_URL}/api/v2/user/buy-gems/paypal-ipn', data-env="#{env.NODE_ENV == 'production' ? '' : 'sandbox'}", data-custom='?uid={{user._id}}&apiToken={{user.apiToken}}', data-return='#{env.BASE_URL}', data-rm='1', data-no_shipping='1')
.modal-footer .modal-footer
button.btn.btn-default.cancel(ng-click='modals.buyGems = false') Cancel button.btn.btn-default.cancel(ng-click='modals.buyGems = false') Cancel