diff --git a/.buildpacks b/.buildpacks new file mode 100644 index 0000000000..b57b4bd3b0 --- /dev/null +++ b/.buildpacks @@ -0,0 +1,2 @@ +https://github.com/heroku/heroku-buildpack-nodejs.git +https://github.com/stomita/heroku-buildpack-phantomjs.git diff --git a/bower.json b/bower.json index b00287d040..5819739e29 100644 --- a/bower.json +++ b/bower.json @@ -16,16 +16,15 @@ "dependencies": { "jquery": "~2.1.0", "jquery.cookie": "~1.4.0", - "angular": "1.3.0-beta.11", + "angular": "1.3.3", "angular-ui": "~0.4.0", - "angular-sanitize": "1.3.0-beta.11", - "angular-resource": "1.3.0-beta.11", + "angular-sanitize": "1.3.3", + "angular-resource": "1.3.3", "angular-ui-utils": "~0.1.0", "angular-ui-select2": "git://github.com/angular-ui/ui-select2.git", - "angular-bootstrap": "~0.10.0", + "angular-bootstrap": "~0.12.0", "angular-ui-router": "git://github.com/HabitRPG/ui-router.git#habitrpg", - "angular-loading-bar": "~0.3.0", - "angular-bindonce": "~0.2.1", + "angular-loading-bar": "~0.6.0", "bootstrap": "~3.1.0", "bootstrap-growl": "git://github.com/ifightcrime/bootstrap-growl.git#master", "bootstrap-tour": "~0.8.1", @@ -34,18 +33,22 @@ "github-buttons": "git://github.com/mdo/github-buttons.git", "marked": "~0.2.9", "Angular-At-Directive": "git://github.com/snicker/Angular-At-Directive#master", - "js-emoji": "git://github.com/snicker/js-emoji#master", + "js-emoji": "https://github.com/iamcal/js-emoji.git", "sticky": "*", "swagger-ui": "git://github.com/wordnik/swagger-ui.git", "ngInfiniteScroll": "1.0.0", "jquery-colorbox": "~1.4.36", "pnotify": "~1.3.1", - "jquery-ui": "~1.10.3" + "jquery-ui": "~1.10.3", + "hello": "~1.3.1", + "css-social-buttons": "https://github.com/samcollins/css-social-buttons.git", + "angular-filter": "~0.5.1" }, "devDependencies": { - "angular-mocks": "1.3.0-beta.11" + "angular-mocks": "1.3.3" }, "resolutions": { - "angular": "1.3.0-beta.11" + "angular": "1.3.3", + "jquery": ">=1.9.0" } } diff --git a/config.json.example b/config.json.example index 821bb91fc3..a9e7d5f4ac 100644 --- a/config.json.example +++ b/config.json.example @@ -20,10 +20,20 @@ "NEW_RELIC_APPLICATION_ID":"NEW_RELIC_APPLICATION_ID", "NEW_RELIC_API_KEY":"NEW_RELIC_API_KEY", "GA_ID": "GA_ID", - "PAYPAL_USERNAME": "PAYPAL_USERNAME", - "PAYPAL_PASSWORD": "PAYPAL_PASSWORD", - "PAYPAL_SIGNATURE": "PAYPAL_SIGNATURE", - "EMAIL_SERVER_URL": "http://example.com", - "EMAIL_SERVER_AUTH_USER": "user", - "EMAIL_SERVER_AUTH_PASSWORD": "password" + "EMAIL_SERVER": { + "url": "http://example.com", + "authUser": "user", + "authPassword": "password" + }, + "S3":{ + "bucket":"bucket", + "accessKeyId":"accessKeyId", + "secretAccessKey":"secretAccessKey" + }, + "PAYPAL":{ + "billing_plan_id":"billing_plan_id", + "mode":"sandbox", + "client_id":"client_id", + "client_secret":"client_secret" + } } diff --git a/migrations/20141126_turkey_mounts.js b/migrations/20141126_turkey_mounts.js new file mode 100644 index 0000000000..1270390c06 --- /dev/null +++ b/migrations/20141126_turkey_mounts.js @@ -0,0 +1,11 @@ +db.users.update( + {'items.pets.Turkey-Base':{$ne:null}}, + {$set:{'items.mounts.Turkey-Base':true}}, + {multi:1} +) + +db.users.update( + {'items.pets.Turkey-Base':null}, + {$set:{'items.pets.Turkey-Base':5}}, + {multi:1} +) \ No newline at end of file diff --git a/migrations/mysteryitems.js b/migrations/mysteryitems.js index 1531f8fb0f..b222047bda 100644 --- a/migrations/mysteryitems.js +++ b/migrations/mysteryitems.js @@ -2,7 +2,7 @@ var _id = ''; var update = { $push: { 'purchased.plan.mysteryItems':{ - $each:['back_mystery_201410','armor_mystery_201410'] + $each:['head_mystery_201411','weapon_mystery_201411'] } } }; diff --git a/package.json b/package.json index 3a8e57c1ce..fc3edb043b 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "main": "./src/server.js", "dependencies": { "async": "~0.9.0", + "aws-sdk": "^2.0.25", "bower": "~1.3.12", "coffee-script": "1.6.x", "connect-ratelimit": "0.0.7", @@ -25,24 +26,26 @@ "grunt-nodemon": "~0.3.0", "habitrpg-shared": "git://github.com/HabitRPG/habitrpg-shared#develop", "icalendar": "git://github.com/lefnire/node-icalendar#master", + "jade": "~1.7.0", "js2xmlparser": "~0.1.2", "lodash": "~2.4.1", - "jade": "~1.7.0", "method-override": "~2.2.0", - "mongoose": "~3.8.17", "moment": "~2.8.3", + "mongoose": "~3.8.17", "mongoose-id-autoinc": "~2013.7.14-4", "nconf": "~0.6.9", "newrelic": "~1.11.2", "nib": "~1.0.1", "nodemailer": "~0.5.2", + "pageres": "^1.0.1", "passport": "~0.2.1", - "passport-facebook": "~1.0.2", - "paypal-express-checkout": "git://github.com/HabitRPG/node-paypal-express-checkout#habitrpg", - "paypal-ipn": "~1.0.1", - "paypal-recurring": "git://github.com/jaybryant/paypal-recurring#656b496f43440893c984700191666a5c5c535dca", + "passport-facebook": "Fonger/passport-facebook#a8f98adcddad99caa9a918bc7b76462c92c5c9fd", + "paypal-ipn": "2.1.0", + "paypal-rest-sdk": "^1.2.1", "pretty-data": "git://github.com/vkiryukhin/pretty-data#master", + "qs": "^2.3.2", "request": "~2.44.0", + "s3-upload-stream": "^1.0.6", "stripe": "*", "swagger-node-express": "git://github.com/lefnire/swagger-node-express#habitrpg", "universal-analytics": "~0.3.2", diff --git a/public/css/avatar.styl b/public/css/avatar.styl index 10432e1c77..f5132b070f 100644 --- a/public/css/avatar.styl +++ b/public/css/avatar.styl @@ -9,9 +9,13 @@ Seeing as the sprites might change drastically in the future re: pets and whatnot, this is just temporary. ---------------------------------------------------- */ -.profile-modal-header - width:560px - margin: 0px auto +// see http://stackoverflow.com/questions/24166568/set-bootstrap-modal-body-height-by-percentage +.profile-modal + .modal-dialog,.modal-content + height: 96% + .modal-body + max-height: calc(100% - 150px) //100% = dialog height, 150px = header + footer + overflow-y: scroll .herobox // Base styles @@ -73,7 +77,17 @@ future re: pets and whatnot, this is just temporary. &.isUser background: $color-herobox // Set a different background color for the current user &:hover, &:focus - background: lighten($color-herobox, 16.18%) + background: lighten($color-herobox, 16.18%) + + .addthis_pill_style + width: 50px !important + .addthis_native_toolbox + position: absolute + top:2px + right:2px + opacity:0 + &:hover .addthis_native_toolbox + opacity:1 //Need to find a way to indicate who is leader without using background-color as it won't work when a background image is applied //.herobox.isLeader.noBackgroundImage diff --git a/public/css/filters.styl b/public/css/filters.styl index 9fbc791b80..ca0245c97a 100644 --- a/public/css/filters.styl +++ b/public/css/filters.styl @@ -8,7 +8,7 @@ list-style: none margin-right: 1em .filters-controls - @extend $hrpg-button-bar + @extend $hrpg-button-bar-with-heading hrpg-button-bar-mixin($color-options-menu) li @extend $hrpg-button-master @@ -18,11 +18,14 @@ .filters-edit margin-bottom: 0.382em .filters-tags - @extend $hrpg-tag-master - hrpg-tag-color-mixin($color-filter-tag) + @extend $hrpg-button + hrpg-button-color-mixin($color-filter-tag) margin-bottom: 0.618em + margin-right: 0.618em &.challenge - hrpg-tag-color-mixin($color-filter-tag-challenge) + hrpg-button-color-mixin($color-filter-tag-challenge) + span + margin-right: 0.382em form display: none markdown diff --git a/public/css/global-colors.styl b/public/css/global-colors.styl index 488f460565..60dfa28db7 100644 --- a/public/css/global-colors.styl +++ b/public/css/global-colors.styl @@ -24,8 +24,8 @@ $color-button-style-one = $best // Task background $color-tasks = lighten($color-herobox, 65%) // Task filter colors -$color-filter-tag = lighten($good,90%) -$color-filter-tag-challenge = lighten($color-purple,90%) +$color-filter-tag = lighten($good,60%) +$color-filter-tag-challenge = lighten($color-purple,70%) // Contributor colors $color-contributor-one = #f57a9d $color-contributor-two = #b93030 diff --git a/public/css/global-modules.styl b/public/css/global-modules.styl index e341d2a92f..7bed317b44 100644 --- a/public/css/global-modules.styl +++ b/public/css/global-modules.styl @@ -17,13 +17,26 @@ hrpg-button-color-mixin($hrpg-button-color) > a, > button background-color: $hrpg-button-color !important &:active - background-color: darken($hrpg-button-color, 10%) !important + background-color: darken($hrpg-button-color, 61.18%) !important @media screen and (min-width:768px) &:hover background-color: darken($hrpg-button-color, 2.36%) !important > a, > button, > input color: darken($hrpg-button-color, 70%) !important border-color: darken($hrpg-button-color, 16.18%) !important + > input + &:hover + border-color: darken($hrpg-button-color, 32.8%) !important + &:focus + border-color: darken($hrpg-button-color, 61.8%) !important; + outline: none; + > input + button + &:focus + border-color: darken($hrpg-button-color, 32.8%) !important; + background-color: darken($hrpg-button-color, 6.18%) !important; + outline: none; + &:active + background-color: darken($hrpg-button-color, 16.18%) !important; > a:nth-of-type(2) border-left: 1px solid darken($hrpg-button-color, 3.82%) !important > div @@ -48,7 +61,14 @@ hrpg-button-color-mixin($hrpg-button-color) &.active a, button background-color: darken($hrpg-button-color, 3.82%) !important - border-color: darken($hrpg-button-color, 38.2%) !important + border-color: darken($hrpg-button-color, 50%) !important + &.active.filters-tags + a, button + background-color: darken($hrpg-button-color, 32.8%) !important + border-color: $hrpg-button-color !important + color: #fff !important; + span + color: #fff !important; $hrpg-button-master list-style: none > a, > button, > input @@ -61,11 +81,9 @@ $hrpg-button-master .glyphicon position: relative top: 0.132em + > a, > button &:active background-color: #aaa !important - @media screen and (min-width:768px) - &:hover - background-color: #eee !important $hrpg-button @extend $hrpg-button-master > a, > button, > input @@ -95,8 +113,11 @@ $hrpg-button-with-input border: 1px solid #ccc border-radius: 0.382em 0em 0em 0.382em padding-left: 0.618em - &:hover - background-color: #f5f5f5 !important + background-color: #fff !important + -webkit-appearance: none + -moz-appearance: none + appearance: none + box-shadow: none //remove red glow in Firefox a, button border-width: 1px border-color: #ccc @@ -105,31 +126,44 @@ $hrpg-button-with-input border-bottom-style: solid border-left: none border-radius: 0em 0.382em 0.382em 0em + outline: none //Button bar hrpg-button-bar-mixin($hrpg-button-bar-color) border-color: darken($hrpg-button-bar-color, 16.18%) li - border-right-color: darken($hrpg-button-bar-color,3.82%) + border-right-color: darken($hrpg-button-bar-color,6.18%) + &.active + a + box-shadow: 0 0 0 1px darken($hrpg-button-bar-color,23.8%) !important li:first-of-type - background-color: darken($hrpg-button-bar-color,6.18%) - color: darken($hrpg-button-bar-color,70%) - border-right-color: darken($hrpg-button-bar-color,16.18%) + color: darken($hrpg-button-bar-color,50%); + //border-right-color: darken($hrpg-button-bar-color,16.18%) + $hrpg-button-bar + list-style: none + display: inline-block border: 1px solid darken(#fff,16.18%) border-radius: 0.382em margin-bottom: 0.618em + @extend $clearfix li border-right: 1px solid darken(#fff,3.82%) float:left - li:first-of-type - color: darken(#fff,61.8%) - padding: 0.25em 0.618em + li:nth-of-type(2) border-radius: 0.382em 0em 0em 0.382em - border-right: 1px solid darken(#fff,6.18%) + li:first-of-type + border-radius: 0.382em 0em 0em 0.382em + a + border-radius: 0.382em 0em 0em 0.382em li:last-of-type border-right: none a border-radius: 0em 0.382em 0.382em 0em + +$hrpg-button-bar-with-heading + @extend $hrpg-button-bar + li:first-of-type + padding: 0.25em 0.618em // Labels hrpg-label-color-mixin($hrpg-label-color) background-color: $hrpg-label-color !important diff --git a/public/css/tasks.styl b/public/css/tasks.styl index 602d4ff42b..fd1a56e15c 100644 --- a/public/css/tasks.styl +++ b/public/css/tasks.styl @@ -67,53 +67,20 @@ for $stage in $stages max-height: 18.6em overflow-y: scroll -// add new task form +// "Add new task" form // ----------------- -.addtask-form - margin-bottom: 0.75em - outline: 1px solid rgba(0,0,0,0.15) - outline-offset: -1px - background-color: white - // box-shadow: 0 0 3px rgba(0,0,0,0.15) - position: relative - -// the input field -.addtask-field - display: block - width: 100% +.task-add + margin-top: 1.618em + @extend $hrpg-button-with-input + hrpg-button-color-mixin($color-options-submenu) + input, button + height: 2.618em input - font-family: 'Lato', sans-serif - border: 0 - outline: 0 - border-radius: 0 - box-shadow: none - background-color: white - height: 3em - padding: 0 3.3em 0 0.5em - width: 100% - &:focus - box-shadow: inset 0 0 3px darken($best, 20%),inset -1px 0 1px darken($best, 30%) - -// the button -.addtask-btn - position: absolute - right: 0 - top: 0 - // important for overriding bootstrap, remove later - width: 1.81818em !important - height: 1.88em - padding: 0 - font-size: 1.61em - line-height: 1.7 - outline: 0 - border: 0 - border-radius: 0 //required to nuke BB-playbook user agent styles - background-color: darken($best, 15%) - &:hover, &:focus - background-color: darken($best, 20%) - &:active - background-color: darken($best, 30%) - + width: 80% + button + width: 20% + span + font-size: 0.8em // an individual task entry // ------------------------ @@ -428,22 +395,6 @@ form // todos ui // -------- -// nav tabs -.nav-tabs - margin-top: 1.5em -.nav-tabs > li - &.active > a - color: #333 - &:hover - margin-top: 1px - > a - border-radius: 0 !important - margin-top: 1px - color: #333 - &:hover - border-top: 0 - margin-top: 2px - // Checklists // -------- .checklist-form @@ -475,4 +426,34 @@ form margin: 0px 10px 3px 0px width: 4% li:hover .checklist-icons - opacity:1 \ No newline at end of file + opacity:1 + +// Task filters +// -------- +.task-filter + margin: 1.618em 0 1em 0 + hrpg-button-bar-mixin($color-tasks) + @extend $hrpg-button-bar + width: 100% + li + @extend $hrpg-button-master + hrpg-button-color-mixin($color-tasks) + width: 33.333% + text-align: center + &:nth-child(3n + 1):nth-last-child(2), + &:nth-child(3n + 1):nth-last-child(2) + li + width: 50%; + a + width: 100% + height: 100% +.rewards + .task-filter + li:nth-child(3n + 1):nth-last-child(2) + width: 33.333% + li:nth-child(3n + 1):nth-last-child(2) + li + width: 66.666% + +.repeat-days + a + border-radius: 0.382em + \ No newline at end of file diff --git a/public/js/app.js b/public/js/app.js index a5c04bdf80..adbcd0f6f8 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -3,7 +3,7 @@ window.habitrpg = angular.module('habitrpg', ['ngResource', 'ngSanitize', 'userServices', 'groupServices', 'memberServices', 'challengeServices', 'authServices', 'notificationServices', 'guideServices', 'authCtrl', - 'ui.bootstrap', 'ui.keypress', 'ui.router', 'chieffancypants.loadingBar', 'At', 'pasvaz.bindonce', 'infinite-scroll', 'ui.select2']) + 'ui.bootstrap', 'ui.keypress', 'ui.router', 'chieffancypants.loadingBar', 'At', 'infinite-scroll', 'ui.select2', 'angular.filter']) // @see https://github.com/angular-ui/ui-router/issues/110 and https://github.com/HabitRPG/habitrpg/issues/1705 // temporary hack until they have a better solution @@ -75,6 +75,11 @@ window.habitrpg = angular.module('habitrpg', templateUrl: "partials/options.social.html" }) + .state('options.social.inbox', { + url: "/inbox", + templateUrl: "partials/options.social.inbox.html" + }) + .state('options.social.tavern', { url: "/tavern", templateUrl: "partials/options.social.tavern.html", diff --git a/public/js/controllers/authCtrl.js b/public/js/controllers/authCtrl.js index 64bf201a9f..bacc41c28f 100644 --- a/public/js/controllers/authCtrl.js +++ b/public/js/controllers/authCtrl.js @@ -7,24 +7,13 @@ angular.module('authCtrl', []) .controller("AuthCtrl", ['$scope', '$rootScope', 'User', '$http', '$location', '$window','ApiUrlService', '$modal', function($scope, $rootScope, User, $http, $location, $window, ApiUrlService, $modal) { - var runAuth; - var showedFacebookMessage; - - $scope.useUUID = false; - $scope.toggleUUID = function() { - if (showedFacebookMessage === false) { - alert(window.env.t('untilNoFace')); - showedFacebookMessage = true; - } - $scope.useUUID = !$scope.useUUID; - }; $scope.logout = function() { localStorage.clear(); window.location.href = '/logout'; }; - runAuth = function(id, token) { + var runAuth = function(id, token) { User.authenticate(id, token, function(err) { $window.location.href = '/'; }); @@ -59,14 +48,10 @@ angular.module('authCtrl', []) username: $scope.loginUsername || $('#login-tab input[name="username"]').val(), password: $scope.loginPassword || $('#login-tab input[name="password"]').val() }; - if ($scope.useUUID) { - runAuth($scope.loginUsername, $scope.loginPassword); - } else { - $http.post(ApiUrlService.get() + "/api/v2/user/auth/local", data) - .success(function(data, status, headers, config) { - runAuth(data.id, data.token); - }).error(errorAlert); - } + $http.post(ApiUrlService.get() + "/api/v2/user/auth/local", data) + .success(function(data, status, headers, config) { + runAuth(data.id, data.token); + }).error(errorAlert); }; $scope.playButtonClick = function(){ @@ -126,5 +111,22 @@ angular.module('authCtrl', []) $scope.hasNoNotifications = function() { return selectNotificationValue(false, false, false, false, true); } + + // ------ Social ---------- + + hello.init({ + facebook : window.env.FACEBOOK_KEY, + }); + + $scope.socialLogin = function(network){ + hello(network).login({scope:'email'}).then(function(auth){ + $http.post(ApiUrlService.get() + "/api/v2/user/auth/social", auth) + .success(function(data, status, headers, config) { + runAuth(data.id, data.token); + }).error(errorAlert); + }, function( e ){ + alert("Signin error: " + e.error.message ); + }); + } } ]); diff --git a/public/js/controllers/footerCtrl.js b/public/js/controllers/footerCtrl.js index 613ea531b0..9017602aec 100644 --- a/public/js/controllers/footerCtrl.js +++ b/public/js/controllers/footerCtrl.js @@ -31,7 +31,7 @@ (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) })(window,document,'script','//www.google-analytics.com/analytics.js','ga'); - ga('create', window.env.GA_ID, 'habitrpg.com'); + ga('create', window.env.GA_ID, {userId:User.user._id}); ga('require', 'displayfeatures'); ga('send', 'pageview'); } @@ -39,7 +39,17 @@ // Scripts only for desktop if (!window.env.IS_MOBILE) { // Add This - $.getScript("//s7.addthis.com/js/250/addthis_widget.js#pubid=lefnire"); + //$.getScript("//s7.addthis.com/js/300/addthis_widget.js#pubid=ra-5016f6cc44ad68a4"); //FIXME why isn't this working when here? instead it's now in
+ var addthisServices = 'facebook,twitter,googleplus,tumblr,'+window.env.BASE_URL.replace('https://','').replace('http://',''); + window.addthis_config = { + services_custom:{ + name: "Download", + url: window.env.BASE_URL+"/export/avatar-"+User.user._id+".png", + icon: window.env.BASE_URL+"/favicon.ico" + }, + services_expanded:addthisServices, + services_compact:addthisServices + }; // Google Charts $.getScript("//www.google.com/jsapi", function() { diff --git a/public/js/controllers/groupsCtrl.js b/public/js/controllers/groupsCtrl.js index 335de42aa4..42ec3a421e 100644 --- a/public/js/controllers/groupsCtrl.js +++ b/public/js/controllers/groupsCtrl.js @@ -1,7 +1,7 @@ "use strict"; -habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', '$http', '$q', 'User', 'Members', '$state', - function($scope, $rootScope, Shared, Groups, $http, $q, User, Members, $state) { +habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', '$http', '$q', 'User', 'Members', '$state', 'Notification', + function($scope, $rootScope, Shared, Groups, $http, $q, User, Members, $state, Notification) { $scope.isMemberOfPendingQuest = function(userid, group) { if (!group.quest || !group.quest.members) return false; if (group.quest.active) return false; // quest is started, not pending @@ -45,8 +45,9 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' } else { // We need the member information up top here, but then we pass it down to the modal controller // down below. Better way of handling this? - Members.selectMember(uid); - $rootScope.openModal('member', {controller:'MemberModalCtrl'}); + Members.selectMember(uid, function(){ + $rootScope.openModal('member', {controller:'MemberModalCtrl', windowClass:'profile-modal', size:'lg'}); + }); } } @@ -63,7 +64,7 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' } } - // ------ Invites ------ + // ------ Invites ------ $scope.invite = function(group){ Groups.Group.invite({gid: group._id, uuid: group.invitee}, undefined, function(){ @@ -72,11 +73,42 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' group.invitee = ''; }); } + + //var serializeQs = function(obj, prefix){ + // var str = []; + // for(var p in obj) { + // if (obj.hasOwnProperty(p)) { + // var k = prefix ? prefix + "[" + p + "]" : p, v = obj[p]; + // str.push(typeof v == "object" ? + // serializeQs(v, k) : + // encodeURIComponent(k) + "=" + encodeURIComponent(v)); + // } + // } + // return str.join("&"); + //} + // + //$scope.inviteLink = function(obj){ + // return window.env.BASE_URL + '?' + serializeQs({partyInvite: obj}); + //} + $scope.emails = [{name:"",email:""},{name:"",email:""}]; + $scope.inviter = User.user.profile.name; + $scope.inviteEmails = function(inviter, emails){ + $http.post('/api/v2/user/social/invite-friends', {inviter:inviter, emails:emails}).success(function(){ + Notification.text("Invitations sent!"); + $scope.emails = [{name:'',email:''},{name:'',email:''}]; + }); + } + + $scope.quickReply = function(uid) { + Members.selectMember(uid, function(){ + $rootScope.openModal('private-message',{controller:'MemberModalCtrl'}); + }); + } } ]) - .controller("MemberModalCtrl", ['$scope', '$rootScope', 'Members', 'Shared', - function($scope, $rootScope, Members, Shared) { + .controller("MemberModalCtrl", ['$scope', '$rootScope', 'Members', 'Shared', '$http', 'Notification', + function($scope, $rootScope, Members, Shared, $http, Notification) { $scope.timestamp = function(timestamp){ return moment(timestamp).format('MM/DD/YYYY'); } @@ -86,6 +118,13 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' member.petCount = Shared.countPets(null, member.items.pets); $scope.profile = member; }); + $scope.sendPrivateMessage = function(uuid, message){ + $http.post('/api/v2/members/'+uuid+'/message',{message:message}).success(function(){ + Notification.text(window.env.t('messageSentAlert')); + $rootScope.User.sync(); + $scope.$close(); + }); + } } ]) diff --git a/public/js/controllers/rootCtrl.js b/public/js/controllers/rootCtrl.js index 1ef4aaeebc..4e27c81b52 100644 --- a/public/js/controllers/rootCtrl.js +++ b/public/js/controllers/rootCtrl.js @@ -16,6 +16,10 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ $rootScope.$on('$stateChangeSuccess', function(event, toState, toParams, fromState, fromParams){ if (!!fromState.name) window.ga && ga('send', 'pageview', {page: '/#/'+toState.name}); + // clear inbox when entering or exiting inbox tab + if (fromState.name=='options.social.inbox' || toState.name=='options.social.inbox') { + User.user.ops.update && User.set({'inbox.newMessages':0}); + } }); $rootScope.User = User; @@ -113,8 +117,9 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ controller: options.controller, // optional scope: options.scope, // optional keyboard: (options.keyboard === undefined ? true : options.keyboard), // optional - backdrop: (options.backdrop === undefined ? true : options.backdrop) // optional - + backdrop: (options.backdrop === undefined ? true : options.backdrop), // optional + size: options.size, // optional, 'sm' or 'lg' + windowClass: options.windowClass // optional }); } diff --git a/public/js/controllers/settingsCtrl.js b/public/js/controllers/settingsCtrl.js index b7a7aca592..0c0123d1f3 100644 --- a/public/js/controllers/settingsCtrl.js +++ b/public/js/controllers/settingsCtrl.js @@ -2,8 +2,8 @@ // Make user and settings available for everyone through root scope. habitrpg.controller('SettingsCtrl', - ['$scope', 'User', '$rootScope', '$http', 'ApiUrlService', 'Guide', '$location', '$timeout', 'Notification', - function($scope, User, $rootScope, $http, ApiUrlService, Guide, $location, $timeout, Notification) { + ['$scope', 'User', '$rootScope', '$http', 'ApiUrlService', 'Guide', '$location', '$timeout', 'Notification', 'Shared', + function($scope, User, $rootScope, $http, ApiUrlService, Guide, $location, $timeout, Notification, Shared) { // FIXME we have this re-declared everywhere, figure which is the canonical version and delete the rest // $scope.auth = function (id, token) { @@ -162,5 +162,22 @@ habitrpg.controller('SettingsCtrl', User.user.ops.release2({}); $rootScope.$state.go('tasks'); } + + // ---- Webhooks ------ + $scope._newWebhook = {url:''}; + $scope.$watch('user.preferences.webhooks',function(webhooks){ + $scope.hasWebhooks = _.size(webhooks); + }) + $scope.addWebhook = function(url) { + User.user.ops.addWebhook({body:{url:url, id:Shared.uuid()}}); + $scope._newWebhook.url = ''; + } + $scope.saveWebhook = function(id,webhook) { + delete webhook._editing; + User.user.ops.updateWebhook({params:{id:id}, body:webhook}); + } + $scope.deleteWebhook = function(id) { + User.user.ops.deleteWebhook({params:{id:id}}); + } } ]); diff --git a/public/js/services/memberServices.js b/public/js/services/memberServices.js index c24eaa5981..bb3a4c5ac5 100644 --- a/public/js/services/memberServices.js +++ b/public/js/services/memberServices.js @@ -57,7 +57,7 @@ angular.module('memberServices', ['ngResource', 'sharedServices']). * either gets them or fetches if not available * @param uid */ - selectMember: function(uid) { + selectMember: function(uid, cb) { var self = this; // Fetch from cache if we can. For guild members, only their uname will have been fetched on initial load, // check if they have full fields (eg, check profile.items and an item inside @@ -67,11 +67,13 @@ angular.module('memberServices', ['ngResource', 'sharedServices']). if (members[uid] && members[uid].items && members[uid].items.weapon) { Shared.wrap(members[uid],false); self.selectedMember = members[uid]; + cb(); } else { Member.get({uid: uid}, function(member){ self.populate(member); // lazy load for later Shared.wrap(member,false); self.selectedMember = members[member._id]; + cb(); }); } } diff --git a/public/js/static.js b/public/js/static.js index 84894ec5ab..860dbf5587 100644 --- a/public/js/static.js +++ b/public/js/static.js @@ -6,13 +6,29 @@ window.habitrpgStatic = angular.module('habitrpgStatic', ['notificationServices' .constant("STORAGE_SETTINGS_ID", 'habit-mobile-settings') .constant("MOBILE_APP", false) -habitrpgStatic.controller("PlansCtrl", ['$rootScope', +.controller("RootCtrl", ['$scope', '$location', '$modal', '$http', function($scope, $location, $modal, $http){ + // must be #?memberId=xx, see https://github.com/angular/angular.js/issues/7239 + var memberId = $location.search()['memberId']; + if (memberId) { + $http.get('/api/v2/members/'+memberId).success(function(data, status, headers, config){ + $scope.profile = data; + $scope.Content = window.habitrpgShared.content; + $modal.open({ + templateUrl: 'modals/member.html', + scope: $scope + }); + }) + } + }]) + +.controller("PlansCtrl", ['$rootScope', function($rootScope) { $rootScope.clickContact = function(){ window.ga && ga('send', 'event', 'button', 'click', 'Contact Us (Plans)'); } } ]) + .controller('AboutCtrl',[function(){ $(document).ready(function(){ $('a.gallery').colorbox({ diff --git a/public/manifest.json b/public/manifest.json index e44d2b2d0a..2e8a5b4ee1 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -15,12 +15,13 @@ "bower_components/angular-loading-bar/build/loading-bar.js", "bower_components/Angular-At-Directive/src/at.js", "bower_components/Angular-At-Directive/src/caret.js", - "bower_components/angular-bindonce/bindonce.js", "bower_components/js-emoji/emoji.js", "bower_components/sticky/jquery.sticky.js", "bower_components/ngInfiniteScroll/build/ng-infinite-scroll.min.js", "bower_components/select2/select2.js", "bower_components/angular-ui-select2/src/select2.js", + "bower_components/hello/dist/hello.all.min.js", + "bower_components/angular-filter/dist/angular-filter.min.js", "bower_components/angular-bootstrap/ui-bootstrap.js", "bower_components/angular-bootstrap/ui-bootstrap-tpls.js", @@ -66,6 +67,7 @@ ], "css": [ "bower_components/bootstrap/dist/css/bootstrap.css", + "bower_components/css-social-buttons/css/zocial.css", "app.css", "bower_components/pnotify/jquery.pnotify.default.css", "bower_components/pnotify/jquery.pnotify.default.icons.css", @@ -83,6 +85,7 @@ "bower_components/angular-bootstrap/ui-bootstrap-tpls.js", "bower_components/bootstrap/dist/js/bootstrap.js", "bower_components/jquery-colorbox/jquery.colorbox-min.js", + "bower_components/hello/dist/hello.all.min.js", "bower_components/angular-loading-bar/build/loading-bar.js", "js/env.js", @@ -94,7 +97,10 @@ ], "css": [ "bower_components/bootstrap/dist/css/bootstrap.css", + "bower_components/css-social-buttons/css/zocial.css", "bower_components/jquery-colorbox/example1/colorbox.css", + "app.css", + "bower_components/habitrpg-shared/dist/habitrpg-shared.css", "static.css" ] } diff --git a/src/controllers/auth.js b/src/controllers/auth.js index eae795a3ea..8cf4b9a840 100644 --- a/src/controllers/auth.js +++ b/src/controllers/auth.js @@ -24,31 +24,6 @@ var accountSuspended = function(uuid){ }; } -var emailUser = function(name, email, emailType){ - request({ - url: nconf.get('EMAIL_SERVER_URL') + '/job', - method: 'POST', - auth: { - user: nconf.get('EMAIL_SERVER_AUTH_USER'), - pass: nconf.get('EMAIL_SERVER_AUTH_PASSWORD') - }, - json: { - type: 'email', - data: { - emailType: emailType, - to: { - name: name, - email: email - } - }, - options: { - attemps: 5, - backoff: {delay: 10*60*1000, type: 'fixed'} - } - } - }); -} - api.auth = function(req, res, next) { var uid = req.headers['x-api-user']; var token = req.headers['x-api-key']; @@ -128,7 +103,7 @@ api.registerUser = function(req, res, next) { } user.save(cb); - if(isProd) emailUser(username, email, 'welcome'); + if(isProd) utils.txnEmail({name:username, email:email}, 'welcome'); ga.event('register', 'Local').send() } ], function(err, saved) { @@ -166,26 +141,58 @@ api.loginLocal = function(req, res, next) { }; /* - POST /user/auth/facebook + POST /user/auth/social */ +api.loginSocial = function(req, res, next) { + var access_token = req.body.authResponse.access_token, + network = req.body.network; + if (network!=='facebook') + return res.json(401, {err:"Only Facebook supported currently."}); + async.waterfall([ + function(cb){ + passport._strategies[network].userProfile(access_token, cb); + }, + function(profile, cb) { + var q = {};q['auth.'+network+'.id'] = profile.id; + User.findOne(q, {_id:1, apiToken:1, auth:1}, function(err, user){ + if (err) return cb(err); + cb(null, {user:user, profile:profile}); + }); + }, + function(data, cb){ + if (data.user) return cb(null, data.user); + // Create new user + var prof = data.profile; + var user = { + preferences: { + language: req.language // User language detected from browser, not saved + }, + auth: { + timestamps: {created: +new Date(), loggedIn: +new Date()} + } + }; + user.auth[network] = prof; + user = new User(user); + user.save(cb); - -api.loginFacebook = function(req, res, next) { - var facebook_id = req.body.facebook_id; - if (!facebook_id) return res.json(401, {err: 'No facebook id provided'}); - User.findOne({'auth.facebook.id': facebook_id}, function(err, user) { - if (err) { - return res.json(401, {err: err}); - } else if (user) { - if (user.auth.blocked) return res.json(401, accountSuspended(user._id)); - return res.json(200, {id: user.id,token: user.apiToken}); - } else { - /* FIXME: create a new user instead*/ - return res.json(403, {err: "Please register with Facebook on https://habitrpg.com, then come back here and log in."}); + if(isProd && prof.emails && prof.emails[0] && prof.emails[0].value){ + utils.txnEmail({name:prof.displayName || prof.username, email:prof.emails[0].value}, 'welcome'); + } + ga.event('register', network).send(); } - }); + ], function(err, user){ + if (err) return res.json(401, {err: err.toString ? err.toString() : err}); + if (user.auth.blocked) return res.json(401, accountSuspended(user._id)); + return res.json(200, {id: user.id, token:user.apiToken}); + }) }; +/** + * DELETE /user/auth/social + * TODO implement + */ +api.deleteSocial = function(req,res,next){next()} + api.resetPassword = function(req, res, next){ var email = req.body.email, salt = utils.makeSalt(), @@ -271,66 +278,4 @@ api.setupPassport = function(router) { res.redirect('/'); }) - // GET /auth/facebook - // Use passport.authenticate() as route middleware to authenticate the - // request. The first step in Facebook authentication will involve - // redirecting the user to facebook.com. After authorization, Facebook will - // redirect the user back to this application at /auth/facebook/callback - router.get('/auth/facebook', - passport.authenticate('facebook', {scope: 'email'}), - i18n.getUserLanguage, - function(req, res){ - // The request will be redirected to Facebook for authentication, so this - // function will not be called. - }); - - // GET /auth/facebook/callback - // Use passport.authenticate() as route middleware to authenticate the - // request. If authentication fails, the user will be redirected back to the - // login page. Otherwise, the primary route function function will be called, - // which, in this example, will redirect the user to the home page. - router.get('/auth/facebook/callback', - passport.authenticate('facebook', { failureRedirect: '/login' }), - i18n.getUserLanguage, - function(req, res) { - //res.redirect('/'); - - async.waterfall([ - function(cb){ - User.findOne({'auth.facebook.id':req.user.id}, cb) - }, - function(user, cb){ - if (user) return cb(null, user); - - user = new User({ - preferences: { - language: req.language // User language detected from browser, not saved - }, - auth: { - facebook: req.user, - timestamps: {created: +new Date(), loggedIn: +new Date()} - } - }); - user.save(cb); - if(isProd && req.user.emails && req.user.emails[0] && req.user.emails[0].value){ - emailUser((req.user.displayName || req.user.username), req.user.emails[0].value, 'welcome'); - } - ga.event('register', 'Facebook').send() - } - ], function(err, saved){ - if (err) return res.redirect('/static/front?err=' + err); - req.session.userId = saved._id; - res.redirect('/static/front?_id='+saved._id+'&apiToken='+saved.apiToken); - }) - }); - - // Simple route middleware to ensure user is authenticated. - // Use this route middleware on any resource that needs to be protected. If - // the request is authenticated (typically via a persistent login session), - // the request will proceed. Otherwise, the user will be redirected to the - // login page. -// function ensureAuthenticated(req, res, next) { -// if (req.isAuthenticated()) { return next(); } -// res.redirect('/login') -// } }; diff --git a/src/controllers/dataexport.js b/src/controllers/dataexport.js index 48a08b1d38..8a48ee1638 100644 --- a/src/controllers/dataexport.js +++ b/src/controllers/dataexport.js @@ -8,6 +8,13 @@ var js2xmlparser = require("js2xmlparser"); var pd = require('pretty-data').pd; var User = require('../models/user').model; +// Avatar screenshot/static-page includes +var Pageres = require('pageres'); //https://github.com/sindresorhus/pageres +var AWS = require('aws-sdk'); +AWS.config.update({accessKeyId: nconf.get("S3:accessKeyId"), secretAccessKey: nconf.get("S3:secretAccessKey")}); +var s3Stream = require('s3-upload-stream')(new AWS.S3()); //https://github.com/nathanpeck/s3-upload-stream +var bucket = nconf.get("S3:bucket"); +var request = require('request'); /* ------------------------------------------------------------------------ @@ -83,3 +90,49 @@ expressres.jsonstring = function(obj, headers, status) { body = pd.json(JSON.stringify(obj)); return this.send(body, headers, status); }; + +/* + ------------------------------------------------------------------------ + Static page and image screenshot of avatar + ------------------------------------------------------------------------ + */ + + +dataexport.avatarPage = function(req, res) { + User.findById(req.params.uuid).select('stats profile items achievements preferences backer contributor').exec(function(err, user){ + res.render('avatar-static', { + title: user.profile.name, + env: _.defaults({user:user},res.locals.habitrpg) + }); + }) +}; + +dataexport.avatarImage = function(req, res, next) { + var filename = 'avatar-'+req.params.uuid+'.png'; + request.head('https://'+bucket+'.s3.amazonaws.com/'+filename, function(err,response,body) { + // cache images for 10 minutes on aws, else upload a new one + if (response.statusCode==200 && moment().diff(response.headers['last-modified'], 'minutes') < 10) + return res.redirect(301, 'https://' + bucket + '.s3.amazonaws.com/' + filename); + new Pageres()//{delay:1} + .src(nconf.get('BASE_URL') + '/export/avatar-' + req.params.uuid + '.html', ['140x147'], {crop: true, filename: filename.replace('.png', '')}) + .run(function (err, file) { + if (err) return next(err); + // see http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#createMultipartUpload-property + var upload = s3Stream.upload({ + Bucket: bucket, + Key: filename, + ACL: "public-read", + StorageClass: "REDUCED_REDUNDANCY", + ContentType: "image/png", + Expires: +moment().add({minutes: 3}) + }); + upload.on('error', function (err) { + next(err); + }); + upload.on('uploaded', function (details) { + res.redirect(details.Location); + }); + file[0].pipe(upload); + }); + }) +}; diff --git a/src/controllers/groups.js b/src/controllers/groups.js index cd7c768fad..50272cdab2 100644 --- a/src/controllers/groups.js +++ b/src/controllers/groups.js @@ -19,7 +19,7 @@ var api = module.exports; ------------------------------------------------------------------------ */ -var partyFields = 'profile preferences stats achievements party backer contributor auth.timestamps items'; +var partyFields = api.partyFields = 'profile preferences stats achievements party backer contributor auth.timestamps items'; var nameFields = 'profile.name'; var challengeFields = '_id name'; var guildPopulate = {path: 'members', select: nameFields, options: {limit: 15} }; @@ -44,15 +44,6 @@ var populateQuery = function(type, q){ return q; } - -api.getMember = function(req, res, next) { - User.findById(req.params.uid).select(partyFields).exec(function(err, user){ - if (err) return next(err); - if (!user) return res.json(400,{err:'User not found'}); - res.json(user); - }) -} - /** * Fetch groups list. This no longer returns party or tavern, as those can be requested indivdually * as /groups/party or /groups/tavern @@ -300,7 +291,8 @@ api.join = function(req, res, next) { group = res.locals.group; if (group.type == 'party' && group._id == (user.invitations && user.invitations.party && user.invitations.party.id)) { - user.invitations.party = undefined; + User.update({_id:user.invitations.party.inviter}, {$inc:{'items.quests.basilist':1}}).exec(); // Reward inviter + user.invitations.party = undefined; // Clear invite user.save(); // invite new user to pending quest if (group.quest.key && !group.quest.active) { @@ -439,10 +431,10 @@ api.invite = function(req, res, next) { function sendInvite (){ if(group.type === 'guild'){ - invite.invitations.guilds.push({id: group._id, name: group.name}); + invite.invitations.guilds.push({id: group._id, name: group.name, inviter:res.locals.user._id}); }else{ //req.body.type in 'guild', 'party' - invite.invitations.party = {id: group._id, name: group.name} + invite.invitations.party = {id: group._id, name: group.name, inviter:res.locals.user._id}; } group.invites.push(invite._id); diff --git a/src/controllers/hall.js b/src/controllers/hall.js index 6fc104b0a1..cd8eba7744 100644 --- a/src/controllers/hall.js +++ b/src/controllers/hall.js @@ -55,12 +55,12 @@ api.updateHero = function(req,res,next) { function(member, cb){ if (!member) return res.json(404, {err: "User not found"}); member.balance = req.body.balance || 0; - newTier = req.body.contributor.level; // tier = level in this context - oldTier = member.contributor && member.contributor.level || 0; + var newTier = req.body.contributor.level; // tier = level in this context + var oldTier = member.contributor && member.contributor.level || 0; if (newTier > oldTier) { member.flags.contributor = true; - gemsPerTier = {1:3, 2:3, 3:3, 4:4, 5:4, 6:4, 7:4, 8:0, 9:0}; // e.g., tier 5 gives 4 gems. Tier 8 = moderator. Tier 9 = staff - tierDiff = newTier - oldTier; // can be 2+ tier increases at once + var gemsPerTier = {1:3, 2:3, 3:3, 4:4, 5:4, 6:4, 7:4, 8:0, 9:0}; // e.g., tier 5 gives 4 gems. Tier 8 = moderator. Tier 9 = staff + var tierDiff = newTier - oldTier; // can be 2+ tier increases at once while (tierDiff) { member.balance += gemsPerTier[newTier] / 4; // balance is in $ tierDiff--; @@ -71,7 +71,7 @@ api.updateHero = function(req,res,next) { member.purchased.ads = req.body.purchased.ads; if (member.contributor.level >= 6) member.items.pets['Dragon-Hydra'] = 5; if (req.body.itemPath && req.body.itemVal - && req.body.itemPath.indexOf('items.')===0 + && req.body.itemPath.indexOf('items.') === 0 && User.schema.paths[req.body.itemPath]) { shared.dotSet(member, req.body.itemPath, req.body.itemVal); // Sanitization at 5c30944 (deemed unnecessary) } diff --git a/src/controllers/members.js b/src/controllers/members.js new file mode 100644 index 0000000000..9d3017960f --- /dev/null +++ b/src/controllers/members.js @@ -0,0 +1,50 @@ +var User = require('mongoose').model('User'); +var groups = require('../models/group'); +var partyFields = require('./groups').partyFields +var api = module.exports; +var async = require('async'); +var _ = require('lodash'); +var shared = require('habitrpg-shared'); + +api.getMember = function(req, res, next) { + User.findById(req.params.uuid).select(partyFields).exec(function(err, user){ + if (err) return next(err); + if (!user) return res.json(400,{err:'User not found'}); + res.json(user); + }) +} + +api.sendPrivateMessage = function(req,res,next){ + async.waterfall([ + function(cb){ + User.findById(req.params.uuid, cb); + }, + function(member, cb){ + if (!member) return cb({code:404, err: 'User not found'}); + if (~member.inbox.blocks.indexOf(res.locals.user._id) // can't send message if that user blocked me + || ~res.locals.user.inbox.blocks.indexOf(member._id) // or if I blocked them + || member.inbox.optOut) { // or if they've opted out of messaging + return cb({code:401, err: "Can't send message to this user."}); + } + + var message = groups.chatDefaults(req.body.message, res.locals.user); + shared.refPush(member.inbox.messages, message); + member.inbox.newMessages++; + member._v++; + member.markModified('inbox.messages'); + + var message = groups.chatDefaults(req.body.message, member); + shared.refPush(res.locals.user.inbox.messages, _.defaults({sent:true},message)); + res.locals.user.markModified('inbox.messages'); + + member.save(cb); + }, + function(a,b,cb){ + res.locals.user.save(cb); + } + ], function(err){ + if (err) return err.code ? res.json(err.code,{err:err.err}) : err; + res.send(200); + }) + +} \ No newline at end of file diff --git a/src/controllers/payments.js b/src/controllers/payments.js deleted file mode 100644 index 47aad0e85d..0000000000 --- a/src/controllers/payments.js +++ /dev/null @@ -1,324 +0,0 @@ -/* @see ./routes.coffee for routing*/ - -var _ = require('lodash'); -var logger = require('../logging'); -var ipn = require('paypal-ipn'); -var shared = require('habitrpg-shared'); -var nconf = require('nconf'); -var async = require('async'); -var User = require('./../models/user').model; -var ga = require('./../utils').ga; -var logging = require('./../logging'); -var userAPI = require('./user'); -var request = require('request'); -var moment = require('moment'); -var api = module.exports; -var isProduction = nconf.get("NODE_ENV") === "production"; -var stripe = require("stripe")(nconf.get('STRIPE_API_KEY')); - -var PaypalRecurring = require('paypal-recurring'); -var paypalRecurring = new PaypalRecurring({ - username: nconf.get('PAYPAL_USERNAME'), - password: nconf.get('PAYPAL_PASSWORD'), - signature: nconf.get('PAYPAL_SIGNATURE') -}, isProduction ? "production" : "sandbox"); -var paypalCheckout = require('paypal-express-checkout') - .init(nconf.get('PAYPAL_USERNAME'), nconf.get('PAYPAL_PASSWORD'), nconf.get('PAYPAL_SIGNATURE'), nconf.get('BASE_URL'), nconf.get('BASE_URL'), !isProduction); - -function revealMysteryItems(user) { - _.each(shared.content.gear.flat, function(item) { - if ( - item.klass === 'mystery' && - moment().isAfter(item.mystery.start) && - moment().isBefore(item.mystery.end) && - !user.items.gear.owned[item.key] && - !~user.purchased.plan.mysteryItems.indexOf(item.key) - ) { - user.purchased.plan.mysteryItems.push(item.key); - } - }); -} - -function getMailingInfo(user) { - var email, name; - if(user.auth.local){ - email = user.auth.local.email; - name = user.profile.name || user.auth.local.username; - }else if(user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0] && user.auth.facebook.emails[0].value){ - email = user.auth.facebook.emails[0].value; - name = user.auth.facebook.displayName || user.auth.facebook.username; - } - return {'email': email, 'name': name}; -} - -function emailUser(user, emailType) { - var mailingInfo = getMailingInfo(user); - if(mailingInfo.email){ - request({ - url: nconf.get('EMAIL_SERVER_URL') + '/job', - method: 'POST', - auth: { - user: nconf.get('EMAIL_SERVER_AUTH_USER'), - pass: nconf.get('EMAIL_SERVER_AUTH_PASSWORD') - }, - json: { - type: 'email', - data: { - emailType: emailType, - to: { - name: mailingInfo.name, - email: mailingInfo.email - } - }, - options: { - attemps: 5, - backoff: {delay: (10*60*1000), type: 'fixed'} - } - } - }); - } -} - -function createSubscription(user, data) { - if (!user.purchased.plan) user.purchased.plan = {}; - _(user.purchased.plan) - .merge({ // override with these values - planId:'basic_earned', - customerId: data.customerId, - dateUpdated: new Date(), - gemsBought: 0, - paymentMethod: data.paymentMethod, - dateTerminated: null - }) - .defaults({ // allow non-override if a plan was previously used - dateCreated: new Date(), - mysteryItems: [] - }); - revealMysteryItems(user); - if(isProduction) emailUser(user, 'subscription-begins'); - user.purchased.txnCount++; - ga.event('subscribe', data.paymentMethod).send(); - ga.transaction(data.customerId, 5).item(5, 1, data.paymentMethod.toLowerCase() + '-subscription', data.paymentMethod + " > Stripe").send(); -} - -/** - * Sets their subscription to be cancelled later - */ -function cancelSubscription(user, data){ - var du = user.purchased.plan.dateUpdated, now = moment(); - if(isProduction) emailUser(user, 'cancel-subscription'); - user.purchased.plan.dateTerminated = - moment( now.format('MM') + '/' + moment(du).format('DD') + '/' + now.format('YYYY') ) - .add({months:1}) - .toDate(); - ga.event('unsubscribe', 'Stripe').send(); - -} - -function buyGems(user, data) { - user.balance += 5; - user.purchased.txnCount++; - if(isProduction) emailUser(user, 'donation'); - ga.event('checkout', data.paymentMethod).send(); - ga.transaction(data.customerId, 5).item(5, 1, data.paymentMethod.toLowerCase() + "-checkout", "Gems > " + data.paymentMethod).send(); -} - -// Expose some functions for tests -if (nconf.get('NODE_ENV')==='testing') { - api.cancelSubscription = cancelSubscription; - api.createSubscription = createSubscription; -} - -/* - Setup Stripe response when posting payment - */ -api.stripeCheckout = function(req, res, next) { - var token = req.body.id; - var user = res.locals.user; - - async.waterfall([ - function(cb){ - if (req.query.plan) { - stripe.customers.create({ - email: req.body.email, - metadata: {uuid: res.locals.user._id}, - card: token, - plan: req.query.plan, - }, cb); - } else { - stripe.charges.create({ - amount: "500", // $5 - currency: "usd", - card: token - }, cb); - } - }, - function(response, cb) { - if (req.query.plan) { - createSubscription(user, {customerId: response.id, paymentMethod: 'Stripe'}); - } else { - buyGems(user, {customerId: response.id, paymentMethod: 'Stripe'}); - } - user.save(cb); - } - ], function(err, saved){ - if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors - res.send(200); - user = token = null; - }); -}; - -api.stripeSubscribeCancel = function(req, res, next) { - var user = res.locals.user; - if (!user.purchased.plan.customerId) - return res.json(401, {err: "User does not have a plan subscription"}); - - async.waterfall([ - function(cb) { - stripe.customers.del(user.purchased.plan.customerId, cb); - }, - function(response, cb) { - cancelSubscription(user); - user.save(cb); - } - ], function(err, saved){ - if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors - res.redirect('/'); - user = null; - }); -}; - -api.stripeSubscribeEdit = function(req, res, next) { - var token = req.body.id; - var user = res.locals.user; - var user_id = user.purchased.plan.customerId; - var sub_id; - - async.waterfall([ - function(cb){ - stripe.customers.listSubscriptions(user_id, cb); - }, - function(response, cb) { - sub_id = response.data[0].id; - console.warn(sub_id); - console.warn([user_id, sub_id, { card: token }]); - stripe.customers.updateSubscription(user_id, sub_id, { card: token }, cb); - }, - function(response, cb) { - user.save(cb); - } - ], function(err, saved){ - if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors - res.send(200); - token = user = user_id = sub_id; - }); -}; - -api.paypalSubscribe = function(req,res,next) { - // Authenticate a future subscription of ~5 USD - paypalRecurring.authenticate({ - RETURNURL: nconf.get('BASE_URL') + '/paypal/subscribe/success?uuid=' + res.locals.user._id, - CANCELURL: nconf.get("BASE_URL"), - PAYMENTREQUEST_0_AMT: 5, - L_BILLINGAGREEMENTDESCRIPTION0: "HabitRPG Subscription" - }, function(err, data, url) { - // Redirect the user if everything went well with - // a HTTP 302 according to PayPal's guidelines - if (err) return next(err); - res.redirect(302, url); - }); -}; - -api.paypalSubscribeSuccess = function(req,res,next) { - // Create a subscription of 10 USD every month - var uuid = req.query.uuid; - if (!uuid) return next("UUID required"); - paypalRecurring.createSubscription(req.query.token, req.query.PayerID,{ - AMT: 5, - DESC: "HabitRPG Subscription", - BILLINGPERIOD: "Month", - BILLINGFREQUENCY: 1, - }, function(err, data) { - if (err) return res.next(err); - User.findById(uuid, function(err,user){ - if (err) return next(err); - createSubscription(user, {customerId: data.PROFILEID, paymentMethod: 'Paypal'}); - user.save(function(err,saved){ - res.redirect('/'); - }) - }) - }); -}; - -api.paypalSubscribeCancel = function(req, res, next) { - var user = res.locals.user; - if (!user.purchased.plan.customerId) - return res.json(401, {err: "User does not have a plan subscription"}); - async.waterfall([ - function(cb) { - paypalRecurring.modifySubscription(user.purchased.plan.customerId, 'cancel', cb); - }, - function(response, cb) { - cancelSubscription(user); - user.save(cb); - } - ], function(err, saved){ - if (err) return next(err); - res.redirect('/'); - user = null; - }); -}; - -api.paypalCheckout = function(req, res, next) { - var opts = {RETURNURL:nconf.get('BASE_URL') + '/paypal/checkout/success?uuid=' + res.locals.user._id}; - paypalCheckout.pay(+new Date(), 5, 'HabitRPG Gems', 'USD', opts, function(err, url) { - if (err) return next(err); - res.redirect(url); - }); -}; - -api.paypalCheckoutSuccess = function(req,res,next) { - paypalCheckout.detail(req.query.token, req.query.PayerID, function(err, data, invoiceNumber, price) { - // see `data` vars at https://github.com/petersirka/node-paypal-express-checkout#paypal-account - //if (err) return next('PayPal Error: ' + msg); - if (err) return next(err); - if (data.ACK !== 'Success') return next('PayPal transaction failed, please try again'); - - var uuid = req.query.uuid; //, apiToken = query.apiToken; - User.findById(uuid , function(err, user) { - if (_.isEmpty(user)) err = "user not found with uuid " + uuid + " when completing paypal transaction"; - if (err) return next(err); - buyGems(user, {customerId:req.query.PayerID, paymentMethod:'Paypal'}); - user.save(function(){ - if (err) return next(err); - res.redirect('/'); - uuid = null; - }); - }); - }); -}; - -/** - * General IPN handler. We could use this for all paypal transaction handling (instead of the above functions), but I've - * found it extremely unreliable. Instead, here we'll cancel HabitRPG subscriptions for users who manually cancel their - * recurring paypal payments. - */ -api.paypalIPN = function(req, res, next) { - // Must respond to PayPal IPN request with an empty 200 first, if using Express uncomment the following: - res.send(200); - ipn.verify(req.body, function callback(err, msg) { - if (err) return logger.error(msg); - switch (req.body.txn_type) { - // TODO what's the diff b/w the two data.txn_types below? The docs recommend subscr_cancel, but I'm getting the other one instead... - case 'recurring_payment_profile_cancel': - case 'subscr_cancel': - User.findOne({'purchased.plan.customerId':req.body.recurring_payment_id},function(err, user){ - if (err) return logger.error(err); - if (_.isEmpty(user)) return; // looks like the cancellation was already handled properly above (see api.paypalSubscribeCancel) - cancelSubscription(user); - user.save(); - }); - break; - } - }); -}; diff --git a/src/controllers/payments/index.js b/src/controllers/payments/index.js new file mode 100644 index 0000000000..ba6359d4f9 --- /dev/null +++ b/src/controllers/payments/index.js @@ -0,0 +1,77 @@ +/* @see ./routes.coffee for routing*/ +var _ = require('lodash'); +var shared = require('habitrpg-shared'); +var nconf = require('nconf'); +var utils = require('./../../utils'); +var moment = require('moment'); +var isProduction = nconf.get("NODE_ENV") === "production"; +var stripe = require('./stripe'); +var paypal = require('./paypal'); + +function revealMysteryItems(user) { + _.each(shared.content.gear.flat, function(item) { + if ( + item.klass === 'mystery' && + moment().isAfter(item.mystery.start) && + moment().isBefore(item.mystery.end) && + !user.items.gear.owned[item.key] && + !~user.purchased.plan.mysteryItems.indexOf(item.key) + ) { + user.purchased.plan.mysteryItems.push(item.key); + } + }); +} + +exports.createSubscription = function(user, data) { + if (!user.purchased.plan) user.purchased.plan = {}; + _(user.purchased.plan) + .merge({ // override with these values + planId:'basic_earned', + customerId: data.customerId, + dateUpdated: new Date(), + gemsBought: 0, + paymentMethod: data.paymentMethod, + dateTerminated: null + }) + .defaults({ // allow non-override if a plan was previously used + dateCreated: new Date(), + mysteryItems: [] + }); + revealMysteryItems(user); + if(isProduction) utils.txnEmail(user, 'subscription-begins'); + user.purchased.txnCount++; + utils.ga.event('subscribe', data.paymentMethod).send(); + utils.ga.transaction(data.customerId, 5).item(5, 1, data.paymentMethod.toLowerCase() + '-subscription', data.paymentMethod + " > Stripe").send(); +} + +/** + * Sets their subscription to be cancelled later + */ +exports.cancelSubscription = function(user, data) { + var du = user.purchased.plan.dateUpdated, now = moment(); + if(isProduction) utils.txnEmail(user, 'cancel-subscription'); + user.purchased.plan.dateTerminated = + moment( now.format('MM') + '/' + moment(du).format('DD') + '/' + now.format('YYYY') ) + .add({months:1}) + .toDate(); + utils.ga.event('unsubscribe', 'Stripe').send(); +} + +exports.buyGems = function(user, data) { + user.balance += 5; + user.purchased.txnCount++; + if(isProduction) utils.txnEmail(user, 'donation'); + utils.ga.event('checkout', data.paymentMethod).send(); + utils.ga.transaction(data.customerId, 5).item(5, 1, data.paymentMethod.toLowerCase() + "-checkout", "Gems > " + data.paymentMethod).send(); +} + +exports.stripeCheckout = stripe.checkout; +exports.stripeSubscribeCancel = stripe.subscribeCancel; +exports.stripeSubscribeEdit = stripe.subscribeEdit; + +exports.paypalSubscribe = paypal.createBillingAgreement; +exports.paypalSubscribeSuccess = paypal.executeBillingAgreement; +exports.paypalSubscribeCancel = paypal.cancelSubscription; +exports.paypalCheckout = paypal.createPayment; +exports.paypalCheckoutSuccess = paypal.executePayment; +exports.paypalIPN = paypal.ipn; \ No newline at end of file diff --git a/src/controllers/payments/paypal.js b/src/controllers/payments/paypal.js new file mode 100644 index 0000000000..cfc0b8d4d2 --- /dev/null +++ b/src/controllers/payments/paypal.js @@ -0,0 +1,166 @@ +var nconf = require('nconf'); +var moment = require('moment'); +var async = require('async'); +var _ = require('lodash'); +var url = require('url'); +var mongoose = require('mongoose'); +var payments = require('./index'); +var logger = require('../../logging'); +var ipn = require('paypal-ipn'); +var paypal = require('paypal-rest-sdk'); + +// This is the plan.id for paypal subscriptions. You have to set up billing plans via their REST sdk (they don't have +// a web interface for billing-plan creation), see ./paypalBillingSetup.js for how. After the billing plan is created +// there, get it's plan.id and store it in config.json +var billingPlanID = nconf.get('PAYPAL:billing_plan_id'); + +paypal.configure({ + 'mode': nconf.get("PAYPAL:mode"), //sandbox or live + 'client_id': nconf.get("PAYPAL:client_id"), + 'client_secret': nconf.get("PAYPAL:client_secret") +}); + +var parseErr = function(err){ + return (err.response && err.response.message || err.response.details[0].issue) || err; +} + +exports.createBillingAgreement = function(req,res,next){ + var billingPlanTitle ="HabitRPG subscription ($5 month-to-month)"; + var billingAgreementAttributes = { + "name": billingPlanTitle, + "description": billingPlanTitle, + "start_date": moment().add({seconds:5}).format(), + "plan": { + "id": billingPlanID + }, + "payer": { + "payment_method": "paypal" + } + }; + paypal.billingAgreement.create(billingAgreementAttributes, function (err, billingAgreement) { + if (err) return next(parseErr(err)); + // For approving subscription via Paypal, first redirect user to: approval_url + var approval_url = _.find(billingAgreement.links, {rel:'approval_url'}).href; + res.redirect(approval_url); + }); +} + +exports.executeBillingAgreement = function(req,res,next){ + async.waterfall([ + function(cb){ + paypal.billingAgreement.execute(req.query.token, {}, cb); + }, + function(billingAgreement, cb){ + mongoose.model('User').findById(req.session.userId, function(err, user){ + if (err) return cb(err); + cb(null, {billingAgreement:billingAgreement, user:user}); + }); + }, + function(data, cb){ + payments.createSubscription(data.user, {customerId: data.billingAgreement.id, paymentMethod: 'Paypal'}); + data.user.save(cb); + } + ],function(err){ + if (err) return next(parseErr(err)); + res.redirect('/'); + }) +} + +exports.createPayment = function(req, res, next) { + var create_payment = { + "intent": "sale", + "payer": { + "payment_method": "paypal" + }, + "redirect_urls": { + "return_url": nconf.get('BASE_URL') + '/paypal/checkout/success', + "cancel_url": nconf.get('BASE_URL') + }, + "transactions": [{ + "item_list": { + "items": [{ + "name": "HabitRPG Gems", + //"sku": "1", + "price": "5.00", + "currency": "USD", + "quantity": 1 + }] + }, + "amount": { + "currency": "USD", + "total": "5.00" + }, + "description": "HabitRPG Gems" + }] + }; + paypal.payment.create(create_payment, function (err, payment) { + if (err) return next(parseErr(err)); + var link = _.find(payment.links, {rel: 'approval_url'}).href; + res.redirect(link); + }); +} + +exports.executePayment = function(req, res, next) { + var paymentId = req.query.paymentId, + PayerID = req.query.PayerID; + async.waterfall([ + function(cb){ + paypal.payment.execute(paymentId, {payer_id: PayerID}, cb); + }, + function(payment, cb){ + mongoose.model('User').findById(req.session.userId, cb); + }, + function(user, cb){ + if (_.isEmpty(user)) return cb("user not found when completing paypal transaction"); + payments.buyGems(user, {customerId:PayerID, paymentMethod:'Paypal'}); + user.save(cb); + } + ],function(err, saved){ + if (err) return next(parseErr(err)); + res.redirect('/'); + }) +} + +exports.cancelSubscription = function(req, res, next){ + var user = res.locals.user; + if (!user.purchased.plan.customerId) + return res.json(401, {err: "User does not have a plan subscription"}); + async.waterfall([ + function(cb) { + paypal.billingAgreement.cancel(user.purchased.plan.customerId, {note: "Canceling the subscription"}, cb); + }, + function(response, cb) { + payments.cancelSubscription(user); + user.save(cb); + } + ], function(err, saved){ + if (err) return next(parseErr(err)); + res.redirect('/'); + user = null; + }); +} + +/** + * General IPN handler. We catch cancelled HabitRPG subscriptions for users who manually cancel their + * recurring paypal payments in their paypal dashboard. Remove this when we can move to webhooks or some other solution + */ +exports.ipn = function(req, res, next) { + console.log('IPN Called'); + res.send(200); // Must respond to PayPal IPN request with an empty 200 first + ipn.verify(req.body, function(err, msg) { + if (err) return logger.error(msg); + switch (req.body.txn_type) { + // TODO what's the diff b/w the two data.txn_types below? The docs recommend subscr_cancel, but I'm getting the other one instead... + case 'recurring_payment_profile_cancel': + case 'subscr_cancel': + mongoose.model('User').findOne({'purchased.plan.customerId':req.body.recurring_payment_id},function(err, user){ + if (err) return logger.error(err); + if (_.isEmpty(user)) return; // looks like the cancellation was already handled properly above (see api.paypalSubscribeCancel) + payments.cancelSubscription(user); + user.save(); + }); + break; + } + }); +}; + diff --git a/src/controllers/payments/paypalBillingSetup.js b/src/controllers/payments/paypalBillingSetup.js new file mode 100644 index 0000000000..d4d7f8e167 --- /dev/null +++ b/src/controllers/payments/paypalBillingSetup.js @@ -0,0 +1,68 @@ +// This file is used for creating paypal billing plans. PayPal doesn't have a web interface for setting up recurring +// payment plan definitions, instead you have to create it via their REST SDK and keep it updated the same way. So this +// file will be used once for initing your billing plan (then you get the resultant plan.id to store in config.json), +// and once for any time you need to edit the plan thereafter + +var path = require('path'); +var nconf = require('nconf'); +nconf.argv().env().file('user', path.join(path.resolve(__dirname, '../../../config.json'))); +var paypal = require('paypal-rest-sdk'); +var OP = "list"; // list create update remove + +paypal.configure({ + 'mode': nconf.get("PAYPAL:mode"), //sandbox or live + 'client_id': nconf.get("PAYPAL:client_id"), + 'client_secret': nconf.get("PAYPAL:client_secret") +}); + +var billingPlanTitle ="HabitRPG subscription ($5 month-to-month)"; +// https://developer.paypal.com/docs/api/#billing-plans-and-agreements +var billingPlanAttributes = { + "name": billingPlanTitle, + "description": billingPlanTitle, + "type": "INFINITE", + "merchant_preferences": { + "auto_bill_amount": "yes", + "cancel_url": nconf.get("BASE_URL"), + "return_url": nconf.get('BASE_URL') + '/paypal/subscribe/success' + }, + "payment_definitions": [{ + "name": billingPlanTitle, + "type": "REGULAR", + "frequency_interval": "1", + "frequency": "MONTH", + "cycles": "0", + "amount": { + "currency": "USD", + "value": "5" + } + }] +}; + +switch(OP) { + case "list": + paypal.billingPlan.list({status: 'ACTIVE'}, function(err, plans){ + console.log({err:err, plans:plans}); + }); + break; + case "update": + break; + case "create": + paypal.billingPlan.create(billingPlanAttributes, function(err,plan){ + if (plan.state == "ACTIVE") + return console.log({err:err, plan:plan}); + var billingPlanUpdateAttributes = [{ + "op": "replace", + "path": "/", + "value": { + "state": "ACTIVE" + } + }]; + // Activate the plan by changing status to Active + paypal.billingPlan.update(plan.id, billingPlanUpdateAttributes, function(err, response){ + console.log({err:err, response:response}); + }); + }); + case "remove": + break; +} \ No newline at end of file diff --git a/src/controllers/payments/stripe.js b/src/controllers/payments/stripe.js new file mode 100644 index 0000000000..4f9a52afeb --- /dev/null +++ b/src/controllers/payments/stripe.js @@ -0,0 +1,89 @@ +var nconf = require('nconf'); +var stripe = require("stripe")(nconf.get('STRIPE_API_KEY')); +var async = require('async'); +var payments = require('./index'); + +/* + Setup Stripe response when posting payment + */ +exports.checkout = function(req, res, next) { + var token = req.body.id; + var user = res.locals.user; + + async.waterfall([ + function(cb){ + if (req.query.plan) { + stripe.customers.create({ + email: req.body.email, + metadata: {uuid: res.locals.user._id}, + card: token, + plan: req.query.plan + }, cb); + } else { + stripe.charges.create({ + amount: "500", // $5 + currency: "usd", + card: token + }, cb); + } + }, + function(response, cb) { + if (req.query.plan) { + payments.createSubscription(user, {customerId: response.id, paymentMethod: 'Stripe'}); + } else { + payments.buyGems(user, {customerId: response.id, paymentMethod: 'Stripe'}); + } + user.save(cb); + } + ], function(err, saved){ + if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors + res.send(200); + user = token = null; + }); +}; + +exports.subscribeCancel = function(req, res, next) { + var user = res.locals.user; + if (!user.purchased.plan.customerId) + return res.json(401, {err: "User does not have a plan subscription"}); + + async.waterfall([ + function(cb) { + stripe.customers.del(user.purchased.plan.customerId, cb); + }, + function(response, cb) { + payments.cancelSubscription(user); + user.save(cb); + } + ], function(err, saved){ + if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors + res.redirect('/'); + user = null; + }); +}; + +exports.subscribeEdit = function(req, res, next) { + var token = req.body.id; + var user = res.locals.user; + var user_id = user.purchased.plan.customerId; + var sub_id; + + async.waterfall([ + function(cb){ + stripe.customers.listSubscriptions(user_id, cb); + }, + function(response, cb) { + sub_id = response.data[0].id; + console.warn(sub_id); + console.warn([user_id, sub_id, { card: token }]); + stripe.customers.updateSubscription(user_id, sub_id, { card: token }, cb); + }, + function(response, cb) { + user.save(cb); + } + ], function(err, saved){ + if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors + res.send(200); + token = user = user_id = sub_id; + }); +}; \ No newline at end of file diff --git a/src/controllers/user.js b/src/controllers/user.js index e2e7ec6b38..a1c71908a0 100644 --- a/src/controllers/user.js +++ b/src/controllers/user.js @@ -7,13 +7,16 @@ var nconf = require('nconf'); var async = require('async'); var shared = require('habitrpg-shared'); var User = require('./../models/user').model; -var ga = require('./../utils').ga; +var utils = require('./../utils'); +var ga = utils.ga; var Group = require('./../models/group').model; var Challenge = require('./../models/challenge').model; var moment = require('moment'); var logging = require('./../logging'); var acceptablePUTPaths; var api = module.exports; +var qs = require('qs'); +var request = require('request'); // api.purchase // Shared.ops @@ -106,6 +109,15 @@ api.score = function(req, res, next) { _tmp: user._tmp }, saved.toJSON().stats)); + // Webhooks + _.each(user.preferences.webhooks, function(h){ + request.post({ + url: h.url, + //form: {task: task, delta: delta, user: _.pick(user, ['stats', '_tmp'])} // this is causing "Maximum Call Stack Exceeded" + body: {direction:direction, task: task, delta: delta, user: _.pick(user, ['_id', 'stats', '_tmp'])}, json:true + }); + }); + if ( (!task.challenge || !task.challenge.id || task.challenge.broken) // If it's a challenge task, sync the score. Do it in the background, we've already sent down a response and the user doesn't care what happens back there || (task.type == 'reward') // we don't want to update the reward GP cost @@ -204,7 +216,7 @@ api.getUser = function(req, res, next) { * FIXME - one-by-one we want to widdle down this list, instead replacing each needed set path with API operations */ acceptablePUTPaths = _.reduce(require('./../models/user').schema.paths, function(m,v,leaf){ - var found= _.find('achievements filters flags invitations lastCron party preferences profile stats'.split(' '), function(root){ + var found= _.find('achievements filters flags invitations lastCron party preferences profile stats inbox'.split(' '), function(root){ return leaf.indexOf(root) == 0; }); if (found) m[leaf]=true; @@ -393,6 +405,51 @@ api.cast = function(req, res, next) { } } +/** + * POST /user/invite-friends + */ +api.inviteFriends = function(req, res, next) { + Group.findOne({type:'party', members:{'$in': [res.locals.user._id]}}).select('_id name').exec(function(err,party){ + if (err) return next(err); + var link = nconf.get('BASE_URL')+'?partyInvite='+ utils.encrypt(JSON.stringify({id:party._id, inviter:res.locals.user._id, name:party.name})); + _.each(req.body.emails, function(invite){ + if (invite.email) { + var variables = [ + {name: 'LINK', content: link}, + {name: 'INVITER', content: req.body.inviter || res.locals.user.profile.name}, + {name: 'INVITEE', content: invite.name} + ]; + // TODO implement "users can only be invited once" + utils.txnEmail(invite, 'invite-friend', variables); + } + }); + res.send(200); + }) +} + +api.sessionPartyInvite = function(req,res,next){ + if (!req.session.partyInvite) return next(); + var inv = res.locals.user.invitations; + if (inv.party && inv.party.id) return next(); // already invited to a party + async.waterfall([ + function(cb){ + Group.findOne({_id:req.session.partyInvite.id, type:'party', members:{$in:[req.session.partyInvite.inviter]}}) + .select('invites members').exec(cb); + }, + function(group, cb){ + if (!group) return cb("Inviter not in party"); + inv.party = req.session.partyInvite; + delete req.session.partyInvite; + if (!~group.invites.indexOf(res.locals.user._id)) + group.invites.push(res.locals.user._id); //$addToSt + group.save(cb); + }, + function(saved, cb){ + res.locals.user.save(cb); + } + ], next); +} + /** * All other user.ops which can easily be mapped to habitrpg-shared/index.coffee, not requiring custom API-wrapping */ diff --git a/src/middleware.js b/src/middleware.js index 1781ef2394..ba5ed01f07 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -12,6 +12,7 @@ var shared = require('habitrpg-shared'); var request = require('request'); var os = require('os'); var moment = require('moment'); +var utils = require('./utils'); module.exports.apiThrottle = function(app) { if (nconf.get('NODE_ENV') !== 'production') return; @@ -192,10 +193,16 @@ module.exports.locals = function(req, res, next) { siteVersion: siteVersion, Content: shared.content, mods: require('./models/user').mods, + FACEBOOK_KEY: nconf.get('FACEBOOK_KEY'), tavern: tavern, // for world boss worldDmg: (tavern && tavern.quest && tavern.quest.extra && tavern.quest.extra.worldDmg) || {} - } + }; + + // Put query-string party invitations into session to be handled later + try{ + req.session.partyInvite = JSON.parse(utils.decrypt(req.query.partyInvite)) + } catch(e){} next(); } diff --git a/src/models/group.js b/src/models/group.js index 2f8f6674b9..fd2518b8ed 100644 --- a/src/models/group.js +++ b/src/models/group.js @@ -93,7 +93,7 @@ GroupSchema.methods.toJSON = function(){ return doc; } -var chatDefaults = function(message,user){ +var chatDefaults = module.exports.chatDefaults = function(message,user){ var message = { id: shared.uuid(), text: message, diff --git a/src/models/user.js b/src/models/user.js index 263207d48f..2be3e9fd95 100644 --- a/src/models/user.js +++ b/src/models/user.js @@ -117,6 +117,9 @@ var UserSchema = new Schema({ freeRebirth: {type: Boolean, 'default': false}, levelDrops: {type:Schema.Types.Mixed, 'default':{}}, chatRevoked: Boolean, + // Used to track the status of recapture emails sent to each user, + // can be 0 - no email sent - 1, 2 or 3 - 3 means no more email will be sent to the user + recaptureEmailsPhase: {type: Number, 'default': 0}, communityGuidelinesAccepted: {type: Boolean, 'default': false} }, history: { @@ -274,7 +277,8 @@ var UserSchema = new Schema({ tagsCollapsed: {type: Boolean, 'default': false}, advancedCollapsed: {type: Boolean, 'default': false}, toolbarCollapsed: {type:Boolean, 'default':false}, - background: String + background: String, + webhooks: {type: Schema.Types.Mixed, 'default': {}} }, profile: { blurb: String, @@ -322,6 +326,13 @@ var UserSchema = new Schema({ challenges: [{type: 'String', ref:'Challenge'}], + inbox: { + newMessages: {type:Number, 'default':0}, + blocks: {type:Array, 'default':[]}, + messages: {type:Schema.Types.Mixed, 'default':{}}, //reflist + optOut: {type:Boolean, 'default':false} + }, + habits: {type:[TaskSchemas.HabitSchema]}, dailys: {type:[TaskSchemas.DailySchema]}, todos: {type:[TaskSchemas.TodoSchema]}, @@ -460,6 +471,10 @@ UserSchema.methods.unlink = function(options, cb) { module.exports.schema = UserSchema; module.exports.model = mongoose.model("User", UserSchema); -mongoose.model("User").find({$query:{'contributor.admin':true}, $orderby:{'contributor.level':-1, 'backer.npc':-1, 'profile.name':1}},function(err,mods){ - module.exports.mods = mods +mongoose.model("User") + .find({'contributor.admin':true}) + .sort('-contributor.level -backer.npc profile.name') + .select('profile contributor backer') + .exec(function(err,mods){ + module.exports.mods = mods }); diff --git a/src/routes/apiv2.coffee b/src/routes/apiv2.coffee index edacbdc243..00191a52b1 100644 --- a/src/routes/apiv2.coffee +++ b/src/routes/apiv2.coffee @@ -10,6 +10,7 @@ $ mocha test/user.mocha.coffee user = require("../controllers/user") groups = require("../controllers/groups") +members = require("../controllers/members") auth = require("../controllers/auth") hall = require("../controllers/hall") challenges = require("../controllers/challenges") @@ -320,7 +321,7 @@ module.exports = (swagger, v2) -> parameters:[ body '','The array of batch-operations to perform','object' ] - middleware: [middleware.forceRefresh, auth.auth, i18n.getUserLanguage, cron] + middleware: [middleware.forceRefresh, auth.auth, i18n.getUserLanguage, cron, user.sessionPartyInvite] action: user.batchUpdate # Tags @@ -364,6 +365,46 @@ module.exports = (swagger, v2) -> ] action: user.deleteTag + "/user/social/invite-friends": + spec: + method: 'POST' + description: 'Invite friends via email' + parameters: [ + body 'invites','Array of [{name:"Friend\'s Name", email:"friends@email.com"}] to invite to play in your party','object' + ] + action: user.inviteFriends + + # Webhooks + "/user/webhooks": + spec: + method: 'POST' + description: 'Create a new webhook' + parameters: [ + body '','New Webhook {url:"webhook endpoint (required)", id:"id of webhook (shared.uuid(), optional)", enabled:"whether webhook is enabled (true by default, optional)"}','object' + ] + action: user.addWebhook + + "/user/webhooks/{id}:PUT": + spec: + path: '/user/webhooks/{id}' + method: 'PUT' + description: "Edit a webhook" + parameters: [ + path 'id','The id of the webhook to edit','string' + body '','New Webhook {url:"webhook endpoint (required)", id:"id of webhook (shared.uuid(), optional)", enabled:"whether webhook is enabled (true by default, optional)"}','object' + ] + action: user.updateWebhook + + "/user/webhooks/{id}:DELETE": + spec: + path: '/user/webhooks/{id}' + method: 'DELETE' + description: 'Delete a webhook' + parameters: [ + path 'id','Id of webhook to delete','string' + ] + action: user.deleteWebhook + # --------------------------------- # Groups # --------------------------------- @@ -539,9 +580,28 @@ module.exports = (swagger, v2) -> # --------------------------------- # Members # --------------------------------- - "/members/{uid}": + "/members/{uuid}": spec:{} - action: groups.getMember + action: members.getMember + "/members/{uuid}/message": + spec: + method: 'POST' + description: 'Send a private message to a member' + parameters: [ + path 'uuid', 'The UUID of the member to message', 'string' + body '', '{message: "The private message to send"}', 'object' + ] + middleware: [auth.auth] + action: members.sendPrivateMessage + "/members/{uuid}/block": + spec: + method: 'POST' + description: 'Block a member from sending private messages' + parameters: [ + path 'uuid', 'The UUID of the member to message', 'string' + ] + middleware: [auth.auth] + action: user.blockUser # --------------------------------- # Hall of Heroes / Patrons diff --git a/src/routes/auth.js b/src/routes/auth.js index 616f9cf7d6..0405154db1 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -7,13 +7,13 @@ var router = new express.Router(); auth.setupPassport(router); //FIXME make this consistent with the others router.post('/api/v2/register', i18n.getUserLanguage, auth.registerUser); router.post('/api/v2/user/auth/local', i18n.getUserLanguage, auth.loginLocal); -router.post('/api/v2/user/auth/facebook', i18n.getUserLanguage, auth.loginFacebook); +router.post('/api/v2/user/auth/social', i18n.getUserLanguage, auth.loginSocial); router.post('/api/v2/user/reset-password', i18n.getUserLanguage, auth.resetPassword); router.post('/api/v2/user/change-password', i18n.getUserLanguage, auth.auth, auth.changePassword); router.post('/api/v2/user/change-username', i18n.getUserLanguage, auth.auth, auth.changeUsername); router.post('/api/v1/register', i18n.getUserLanguage, auth.registerUser); router.post('/api/v1/user/auth/local', i18n.getUserLanguage, auth.loginLocal); -router.post('/api/v1/user/auth/facebook', i18n.getUserLanguage, auth.loginFacebook); +router.post('/api/v1/user/auth/social', i18n.getUserLanguage, auth.loginSocial); module.exports = router; \ No newline at end of file diff --git a/src/routes/dataexport.js b/src/routes/dataexport.js index ad2e2797d0..ba27957124 100644 --- a/src/routes/dataexport.js +++ b/src/routes/dataexport.js @@ -3,11 +3,14 @@ var router = new express.Router(); var dataexport = require('../controllers/dataexport'); var auth = require('../controllers/auth'); var nconf = require('nconf'); -var i18n = require('../i18n') +var i18n = require('../i18n'); +var middleware = require('../middleware.js'); /* Data export */ router.get('/history.csv',auth.authWithSession,i18n.getUserLanguage,dataexport.history); //[todo] encode data output options in the data controller and use these to build routes router.get('/userdata.xml',auth.authWithSession,i18n.getUserLanguage,dataexport.leanuser,dataexport.userdata.xml); router.get('/userdata.json',auth.authWithSession,i18n.getUserLanguage,dataexport.leanuser,dataexport.userdata.json); +router.get('/avatar-:uuid.html', i18n.getUserLanguage, middleware.locals, dataexport.avatarPage); +router.get('/avatar-:uuid.png', i18n.getUserLanguage, middleware.locals, dataexport.avatarImage); module.exports = router; diff --git a/src/server.js b/src/server.js index 064b88d5a6..aed0b524b6 100644 --- a/src/server.js +++ b/src/server.js @@ -10,9 +10,8 @@ var isDev = nconf.get('NODE_ENV') === 'development'; if (cluster.isMaster && (isDev || isProd)) { // Fork workers. - _.times(_.min([require('os').cpus().length,2]), function(){ - cluster.fork(); - }); + var cpus = require('os').cpus(); + _.times(nconf.get("HEROKU_PROD") ? (cpus.length-1 || 1) : (_.min([cpus.length,2])), cluster.fork); cluster.on('disconnect', function(worker, code, signal) { var w = cluster.fork(); // replace the dead worker @@ -26,6 +25,7 @@ if (cluster.isMaster && (isDev || isProd)) { var path = require("path"); var swagger = require("swagger-node-express"); var autoinc = require('mongoose-id-autoinc'); + var shared = require('habitrpg-shared'); // Setup translations var i18n = require('./i18n'); @@ -65,32 +65,23 @@ if (cluster.isMaster && (isDev || isProd)) { // have a database of user records, the complete Facebook profile is serialized // and deserialized. passport.serializeUser(function(user, done) { - done(null, user); + done(null, user); }); passport.deserializeUser(function(obj, done) { - done(null, obj); + done(null, obj); }); - // Use the FacebookStrategy within Passport. - // Strategies in Passport require a `verify` function, which accept - // credentials (in this case, an accessToken, refreshToken, and Facebook - // profile), and invoke a callback with a user object. + // FIXME + // This auth strategy is no longer used. It's just kept around for auth.js#loginFacebook() (passport._strategies.facebook.userProfile) + // The proper fix would be to move to a general OAuth module simply to verify accessTokens passport.use(new FacebookStrategy({ - clientID: nconf.get("FACEBOOK_KEY"), - clientSecret: nconf.get("FACEBOOK_SECRET"), - callbackURL: nconf.get("BASE_URL") + "/auth/facebook/callback" + clientID: nconf.get("FACEBOOK_KEY"), + clientSecret: nconf.get("FACEBOOK_SECRET"), + //callbackURL: nconf.get("BASE_URL") + "/auth/facebook/callback" }, function(accessToken, refreshToken, profile, done) { - // asynchronous verification, for effect... - //process.nextTick(function () { - - // To keep the example simple, the user's Facebook profile is returned to - // represent the logged-in user. In a typical application, you would want - // to associate the Facebook account with a user record in your database, - // and return that user instead. - return done(null, profile); - //}); + done(null, profile); } )); diff --git a/src/utils.js b/src/utils.js index b5f51ccb91..ba8dd47f95 100644 --- a/src/utils.js +++ b/src/utils.js @@ -2,6 +2,7 @@ var nodemailer = require('nodemailer'); var nconf = require('nconf'); var crypto = require('crypto'); var path = require("path"); +var request = require('request'); module.exports.ga = undefined; // set Google Analytics on nconf init @@ -21,6 +22,46 @@ module.exports.sendEmail = function(mailData) { }); } +function getMailingInfo(user) { + var email, name; + if(user.auth.local && user.auth.local.email){ + email = user.auth.local.email; + name = user.profile.name || user.auth.local.username; + }else if(user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0] && user.auth.facebook.emails[0].value){ + email = user.auth.facebook.emails[0].value; + name = user.auth.facebook.displayName || user.auth.facebook.username; + } + return {email: email, name: name}; +} + +module.exports.txnEmail = function(mailingInfo, emailType, variables){ + if (mailingInfo._id) mailingInfo = getMailingInfo(mailingInfo); + if (!mailingInfo.email) return; + request({ + url: nconf.get('EMAIL_SERVER:url') + '/job', + method: 'POST', + auth: { + user: nconf.get('EMAIL_SERVER:authUser'), + pass: nconf.get('EMAIL_SERVER:authPassword') + }, + json: { + type: 'email', + data: { + emailType: emailType, + to: { + name: mailingInfo.name, + email: mailingInfo.email + }, + variables: variables + }, + options: { + attemps: 5, + backoff: {delay: 10*60*1000, type: 'fixed'} + } + } + }); +} + // Encryption using http://dailyjs.com/2010/12/06/node-tutorial-5/ // Note: would use [password-hash](https://github.com/davidwood/node-password-hash), but we need to run // model.query().equals(), so it's a PITA to work in their verify() function @@ -49,4 +90,19 @@ module.exports.setupConfig = function(){ require('newrelic'); module.exports.ga = require('universal-analytics')(nconf.get('GA_ID')); -}; \ No newline at end of file +}; + +var algorithm = 'aes-256-ctr'; +module.exports.encrypt = function(text){ + var cipher = crypto.createCipher(algorithm,nconf.get('SESSION_SECRET')) + var crypted = cipher.update(text,'utf8','hex') + crypted += cipher.final('hex'); + return crypted; +} + +module.exports.decrypt = function(text){ + var decipher = crypto.createDecipher(algorithm,nconf.get('SESSION_SECRET')) + var dec = decipher.update(text,'hex','utf8') + dec += decipher.final('utf8'); + return dec; +} \ No newline at end of file diff --git a/test/api.mocha.coffee b/test/api.mocha.coffee index a1e75ec11e..28b2a97c3d 100644 --- a/test/api.mocha.coffee +++ b/test/api.mocha.coffee @@ -606,7 +606,7 @@ describe "API", -> user = _user done() - it.skip "Handles unsubscription", (done) -> + it "Handles unsubscription", (done) -> cron = -> user.lastCron = moment().subtract("d", 1) user.fns.cron() diff --git a/views/avatar-static.jade b/views/avatar-static.jade new file mode 100644 index 0000000000..9223081dba --- /dev/null +++ b/views/avatar-static.jade @@ -0,0 +1,28 @@ +doctype html +html(ng-app="habitrpg") + head + title=title + link(rel='shortcut icon', href='#{env.getBuildUrl("favicon.ico")}?v=3') + + meta(charset='utf-8') + meta(name='viewport', content='width=device-width, initial-scale=1.0') + meta(name='apple-mobile-web-app-capable', content='yes') + + script(type='text/javascript'). + window.env = !{JSON.stringify(env)}; + + != env.getManifestFiles("app") + + script(type='text/javascript'). + window.habitrpg + .controller('StaticAvatarCtrl', ['$scope', function($scope){ + $scope.profile = window.env.user; + }]) + + //webfonts + link(href='//fonts.googleapis.com/css?family=Lato:300,400,700,400italic,700italic', rel='stylesheet', type='text/css') + + body(ng-cloak) + include ./shared/header/avatar + div(ng-controller='StaticAvatarCtrl') + +herobox({main:true}) diff --git a/views/index.jade b/views/index.jade index 802e3873eb..bd9f42f9f5 100644 --- a/views/index.jade +++ b/views/index.jade @@ -10,6 +10,9 @@ html(ng-app="habitrpg", ng-controller="RootCtrl", ng-class='{"applying-action":a meta(name='viewport', content='width=device-width, initial-scale=1.0') meta(name='apple-mobile-web-app-capable', content='yes') + //FIXME for some reason this won't load when in footerCtrl.js#deferredScripts() + script(type="text/javascript", src="//s7.addthis.com/js/300/addthis_widget.js#pubid=ra-5016f6cc44ad68a4", async="async") + script(type='text/javascript'). window.env = !{JSON.stringify(env)}; diff --git a/views/options/inventory/inventory.jade b/views/options/inventory/inventory.jade index 9f5f912e22..e4e8574b89 100644 --- a/views/options/inventory/inventory.jade +++ b/views/options/inventory/inventory.jade @@ -11,7 +11,7 @@ script(type='text/ng-template', id='partials/options.inventory.equipment.html') h3.equipment-title.hint(popover-trigger='mouseenter', popover-placement='right', popover=env.t('costumeText'))=env.t('costume') .checkbox.equipment-title label - input(type="checkbox", ng-model="user.preferences.costume", ng-click='set({"preferences.costume":!user.preferences.costume})') + input(type="checkbox", ng-model="user.preferences.costume", ng-change='set({"preferences.costume":user.preferences.costume ? true : false})') | =env.t('useCostume') li.customize-menu(ng-if='user.preferences.costume') @@ -106,7 +106,7 @@ script(type='text/ng-template', id='partials/options.inventory.drops.html') | {{::egg.value}} span.Pet_Currency_Gem1x.inline-gems //- buyable quest eggs - each egg,quest in {gryphon:'Gryphon',hedgehog:'Hedgehog',ghost_stag:'Deer',rat:'Rat',octopus:'Octopus',dilatory_derby:'Seahorse',harpy:'Parrot',rooster:'Rooster',spider:'Spider'} + each egg,quest in {gryphon:'Gryphon',hedgehog:'Hedgehog',ghost_stag:'Deer',rat:'Rat',octopus:'Octopus',dilatory_derby:'Seahorse',harpy:'Parrot',rooster:'Rooster',spider:'Spider',owl:'Owl'} div(ng-show='user.achievements.quests.#{quest} > 1') button.customize-option(popover='{{::Content.eggs.#{egg}.notes()}}', popover-title!=env.t("egg", {eggType: "{{::Content.eggs.#{egg}.text()}}"}), popover-trigger='mouseenter', popover-placement='left', ng-click='purchase("eggs", Content.eggs.#{egg})', class='Pet_Egg_#{egg}') p diff --git a/views/options/inventory/stable.jade b/views/options/inventory/stable.jade index 0bfeaa09fe..6fa5b75aa8 100644 --- a/views/options/inventory/stable.jade +++ b/views/options/inventory/stable.jade @@ -54,8 +54,6 @@ script(type='text/ng-template', id='partials/options.inventory.mounts.html') each t,k in env.Content.specialMounts - var animal = k.split('-')[0], color = k.split('-')[1] button(ng-if='user.items.mounts["#{animal}-#{color}"]', class="pet-button Mount_Head_#{animal}-#{color}", ng-class='{active: user.items.currentMount == "#{animal}-#{color}"}', ng-click='chooseMount("#{animal}", "#{color}")', popover=env.t(t), popover-trigger='mouseenter', popover-placement='bottom') - button(class="pet-button pet-not-owned", ng-hide='user.items.mounts["#{mount}"]') - .PixelPaw script(type='text/ng-template', id='partials/options.inventory.pets.html') .container-fluid @@ -99,7 +97,7 @@ script(type='text/ng-template', id='partials/options.inventory.pets.html') li.customize-menu menu.pets-menu(label=env.t('food')) div(ng-repeat='(food,points) in ownedItems(user.items.food)') - button.customize-option(popover-append-to-body='true', popover='{{:: Content.food[food].notes()}}', popover-title='{{:: Content.food[food].text()}}', popover-trigger='mouseenter', popover-placement='left', ng-click='chooseFood(food)', class='Pet_Food_{{::food}}') + button.customize-option(popover-append-to-body='true', popover='{{:: Content.food[food].notes()}}', popover-title='{{:: Content.food[food].text()}}', popover-trigger='mouseenter', popover-placement='top', ng-click='chooseFood(food)', class='Pet_Food_{{::food}}') .badge.badge-info.stack-count {{points}} // Remove this once we have images in p {{:: Content.food[food].text()}} diff --git a/views/options/profile.jade b/views/options/profile.jade index 5920b9df78..ec620e135a 100644 --- a/views/options/profile.jade +++ b/views/options/profile.jade @@ -4,19 +4,21 @@ mixin gemCost(cost) = ' ' + env.t('locked') block +-var showPath = function(path, items, joiner) { return path+'["'+items.join('"] '+joiner+' '+path+'["')+'"]'; } +-var unlockPath = function(path, items) { return 'unlock("'+path+'.'+items.join(','+path+'.')+'")'; } // Make it a mixin so we can call it from mobile mixin customizeProfile(mobile) mixin buyPref(path,colors,title,status) - li.customize-menu(ng-if='#{status=="disabled" ? "user.purchased."+path+"."+colors.join(" || user.purchased."+path+".") : true}', class=~["limited","seasonal"].indexOf(status) ? "well limited-edition" : "") + li.customize-menu(ng-if='#{status=="disabled" ? showPath("user.purchased."+path, colors, "||") : true}', class=~["limited","seasonal"].indexOf(status) ? "well limited-edition" : "") if ~['limited','seasonal'].indexOf(status) .label.label-info.pull-right.hint(popover=limited, popover-title=env.t(status+'Edition'), popover-placement='right', popover-trigger='mouseenter')=env.t(status+'Edition') menu(label=env.t(title)) +gemCost(2) - button.btn.btn-xs(ng-hide='#{status=="disabled"} || user.purchased.#{path}.#{colors.join(" && user.purchased."+path+".")}', ng-click='unlock("#{path}.#{colors.join(","+path+".")}")')!= env.t('unlockSet',{cost:5}) + ' ' + button.btn.btn-xs(ng-hide='#{status=="disabled"} || #{showPath("user.purchased."+path, colors, "||")}', ng-click='#{unlockPath(path, colors)}')!= env.t('unlockSet',{cost:5}) + ' ' each color in colors - button.customize-option(type='button', class='#{path=="skin" ? "skin_"+color : "customize-option hair hair_bangs_1_"+color}', ng-class='{locked: !user.purchased.#{path}.#{color}}', ng-if='#{status!="disabled"} || user.purchased.#{path}.#{color}', ng-click='unlock("#{path}.#{color}")') + button.customize-option(type='button', class='#{path=="skin" ? "skin_"+color : "customize-option hair hair_bangs_1_"+color}', ng-class='{locked: !user.purchased.#{path}["#{color}"]}', ng-if='#{status!="disabled"} || user.purchased.#{path}["#{color}"]', ng-click='unlock("#{path}.#{color}")') div(class=mobile ? 'padding' : 'container-fluid row') .col-md-4 @@ -35,9 +37,10 @@ mixin customizeProfile(mobile) button.customize-option(class='{{user.preferences.size}}_shirt_'+shirt, type='button', ng-click='set({"preferences.shirt":"'+shirt+'"})') menu(label=env.t('specialShirts')) + - var specialShirts = ['convict', 'cross', 'fire', 'horizon', 'ocean', 'purple', 'rainbow', 'redblue', 'thunder', 'tropical', 'zombie'] +gemCost(2) - button.btn.btn-xs(ng-hide="user.purchased.shirt.convict && user.purchased.shirt.cross && user.purchased.shirt.fire && user.purchased.shirt.horizon && user.purchased.shirt.ocean && user.purchased.shirt.purple && user.purchased.shirt.rainbow && user.purchased.shirt.redblue && user.purchased.shirt.thunder && user.purchased.shirt.tropical && user.purchased.shirt.zombie", ng-click='unlock("shirt.convict,shirt.cross,shirt.fire,shirt.horizon,shirt.ocean,shirt.purple,shirt.rainbow,shirt.redblue,shirt.thunder,shirt.tropical,shirt.zombie")')!= env.t('unlockSet',{cost:5}) + ' ' - each shirt in ['convict', 'cross', 'fire', 'horizon', 'ocean', 'purple', 'rainbow', 'redblue', 'thunder', 'tropical', 'zombie'] + button.btn.btn-xs(ng-hide='#{showPath("user.purchased.shirt", specialShirts, "&&")}', ng-click='#{unlockPath("shirt",specialShirts)}')!= env.t('unlockSet',{cost:5}) + ' ' + each shirt in specialShirts button.customize-option(type='button', class='{{user.preferences.size}}_shirt_'+shirt, ng-class='{locked: !user.purchased.shirt.'+shirt+'}', ng-click='unlock("shirt.'+shirt+'")') @@ -76,10 +79,11 @@ mixin customizeProfile(mobile) // Purchasable hairstyles menu(label=env.t('hairSet1')) + - var colors = [2,4,5,6,7,8] +gemCost(2) - button.btn.btn-xs(ng-hide='user.purchased.hair.base.2 && user.purchased.hair.base.4 && user.purchased.hair.base.5 && user.purchased.hair.base.6 && user.purchased.hair.base.7 && user.purchased.hair.base.8', ng-click='unlock("hair.base.2,hair.base.4,hair.base.5,hair.base.6,hair.base.7,hair.base.8")')!= env.t('unlockSet',{cost:5}) + ' ' - each num in [2,4,5,6,7,8] - button(class='hair_base_#{num}_{{user.preferences.hair.color}} customize-option', type='button', ng-class='{locked: !user.purchased.hair.base.#{num}}', ng-click='unlock("hair.base.#{num}")') + button.btn.btn-xs(ng-hide='#{showPath("user.purchased.hair", colors, "&&")}', ng-click='#{unlockPath("hair.base",colors)}')!= env.t('unlockSet',{cost:5}) + ' ' + each num in colors + button(class='hair_base_#{num}_{{user.preferences.hair.color}} customize-option', type='button', ng-class='{locked: !user.purchased.hair.base["#{num}"]}', ng-click='unlock("hair.base.#{num}")') // Flower li.customize-menu @@ -90,21 +94,20 @@ mixin customizeProfile(mobile) li.customize-menu h5=env.t('bodyFacialHair') - +gemCost(2) - button.btn.btn-xs(ng-hide='user.purchased.hair.mustache.1 && user.purchased.hair.mustache.2 && user.purchased.hair.beard.1 && user.purchased.hair.beard.2 && user.purchased.hair.beard.3', ng-click='unlock("hair.mustache.1,hair.mustache.2,hair.beard.1,hair.beard.2,hair.beard.3")')!= env.t('unlockSet',{cost:5}) + ' ' + button.btn.btn-xs(ng-hide='user.purchased.hair.mustache["1"] && user.purchased.hair.mustache["2"] && user.purchased.hair.beard["1"] && user.purchased.hair.beard["2"] && user.purchased.hair.beard["3"]', ng-click='unlock("hair.mustache.1,hair.mustache.2,hair.beard.1,hair.beard.2,hair.beard.3")')!= env.t('unlockSet',{cost:5}) + ' ' // Beard menu(label=env.t('beard')) button(class='head_0 customize-option', type='button', ng-click='set({"preferences.hair.beard":0})') each num in [1,2,3] - button(class='hair_beard_#{num}_{{user.preferences.hair.color}} customize-option', type='button', ng-class='{locked: !user.purchased.hair.beard.#{num}}', ng-click='unlock("hair.beard.#{num}")') + button(class='hair_beard_#{num}_{{user.preferences.hair.color}} customize-option', type='button', ng-class='{locked: !user.purchased.hair.beard["#{num}"]}', ng-click='unlock("hair.beard.#{num}")') // Mustache menu(label=env.t('mustache')) button(class='head_0 customize-option', type='button', ng-click='set({"preferences.hair.mustache":0})') each num in [1,2] - button(class='hair_mustache_#{num}_{{user.preferences.hair.color}} customize-option', type='button', ng-class='{locked: !user.purchased.hair.mustache.#{num}}', ng-click='unlock("hair.mustache.#{num}")') + button(class='hair_mustache_#{num}_{{user.preferences.hair.color}} customize-option', type='button', ng-class='{locked: !user.purchased.hair.mustache["#{num}"]}', ng-click='unlock("hair.mustache.#{num}")') .col-md-4 h3(class=mobile?'item item-divider':'')=env.t('bodySkin') diff --git a/views/options/settings.jade b/views/options/settings.jade index f8f186c1fd..99c94f4dc2 100644 --- a/views/options/settings.jade +++ b/views/options/settings.jade @@ -63,6 +63,7 @@ script(type='text/ng-template', id='partials/options.settings.settings.html') button.btn.btn-default(ng-click='showTour()', popover-placement='right', popover-trigger='mouseenter', popover=env.t('restartTour'))= env.t('showTour') button.btn.btn-default(ng-click='showBailey()', popover-trigger='mouseenter', popover-placement='right', popover=env.t('showBaileyPop'))= env.t('showBailey') button.btn.btn-default(ng-click='openRestoreModal()', popover-trigger='mouseenter', popover-placement='right', popover=env.t('fixValPop'))= env.t('fixVal') + button.btn.btn-default(ng-click="openModal('invite-friends', {controller:'GroupsCtrl'})") Invite Friends button.btn.btn-default(ng-if='user.preferences.disableClasses==true', ng-click='user.ops.changeClass({})', popover-trigger='mouseenter', popover-placement='right', popover=env.t('enableClassPop'))= env.t('enableClass') button.btn.btn-default(ng-if='!user.preferences.disableClasses && user.flags.classSelected', ng-click='showClassesTour()', popover-trigger='mouseenter', popover-placement='right', popover=env.t('classTourPop'))= env.t('showClass') @@ -150,6 +151,33 @@ script(type='text/ng-template', id='partials/options.settings.api.html') h6=env.t('qrCode') img(src='https://chart.googleapis.com/chart?cht=qr&chs=200x200&chl=%7B%22address%22%3A%22https%3A%2F%2Fhabitrpg.com%22%2C%22user%22%3A%22{{user.id}}%22%2C%22key%22%3A%22{{user.apiToken}}%22%7D&choe=UTF-8&chld=L', alt='qrcode') + hr + + h2 Webhooks + table.table.table-striped + thead(ng-if='hasWebhooks') + tr + th Enabled + th Webhook URL + th + tbody + tr(ng-repeat="webhook in user.preferences.webhooks | toArray:true | orderBy:'sort'") + td + input(type='checkbox', ng-model='webhook.enabled', ng-change='saveWebhook(webhook.$key,webhook)') + td + input.form-control(type='url', ng-model='webhook.url', ng-change='webhook._editing=true', ui-keyup="{13:'saveWebhook(webhook.$key,webhook)'}") + td + span.pull-left(ng-show='webhook._editing') * + a.checklist-icons(ng-click='deleteWebhook(webhook.$key)') + span.glyphicon.glyphicon-trash(tooltip=env.t('delete')) + tr + td(colspan=2) + form.form-horizontal(ng-submit='addWebhook(_newWebhook.url)') + .form-group.col-sm-10 + input.form-control(type='url', ng-model='_newWebhook.url', placeholder='Webhook URL') + .col-sm-2 + button.btn.btn-sm.btn-primary(type='submit') Add + script(id='partials/options.settings.export.html', type="text/ng-template") .container-fluid .row @@ -194,25 +222,21 @@ script(id='partials/feature-matrix-check.html',type='text/ng-template') label script(id='partials/options.settings.subscription.html',type='text/ng-template') - .well - h2=env.t('individualSub') - div(ng-if='!user.purchased.plan.customerId') - div(ng-include="'partials/options.settings.subscription.perks.html'") - p - small.muted Payment Methods: - .btn.btn-primary(ng-click='showStripe(true)') Card - //a.btn.btn-warning(ng-click='paypalSubscribe()') PayPal - a.btn.btn-warning(href='/paypal/subscribe?_id={{user._id}}&apiToken={{user.apiToken}}') PayPal - div(ng-if='user.purchased.plan.customerId') - p.alert.alert-warning(ng-if='user.purchased.plan.dateTerminated') - i.glyphicon.glyphicon-time - | - =env.t('subCanceled') - | {{moment(user.purchased.plan.dateTerminated).format('MM/DD/YYYY')}} - p.lead - =env.t('subscribed') - | - span.glyphicon.glyphicon-ok - div(ng-include="'partials/options.settings.subscription.perks.html'") - .btn.btn-primary(ng-if=':: !user.purchased.plan.dateTerminated && user.purchased.plan.paymentMethod=="Stripe"', ng-click='showStripeEdit()') Update Card - .btn.btn-sm.btn-danger(ng-if=':: !user.purchased.plan.dateTerminated', ng-click='cancelSubscription()')=env.t('cancelSub') + .well(ng-init="p = user.purchased.plan") + div(ng-if='p.customerId') + p.alert.alert-warning(ng-if='p.dateTerminated') + i.glyphicon.glyphicon-time + | #{env.t('subCanceled')} {{moment(p.dateTerminated).format('MM/DD/YYYY')}} + p.alert.alert-success(ng-if='!p.dateTerminated')=env.t('subscribed') + + h2=env.t('individualSub') + div(ng-include="'partials/options.settings.subscription.perks.html'") + + div(ng-if='!p.customerId || (p.customerId && p.dateTerminated)') + h3(ng-if='(p.customerId && p.dateTerminated)') Resubscribe + a.btn.btn-primary(ng-click='showStripe(true)') Card + //a.btn.btn-warning(ng-click='paypalSubscribe()') PayPal + a.btn.btn-warning(href='/paypal/subscribe?_id={{user._id}}&apiToken={{user.apiToken}}') PayPal + div(ng-if='p.customerId') + .btn.btn-primary(ng-if='!p.dateTerminated && p.paymentMethod=="Stripe"', ng-click='showStripeEdit()') Update Card + .btn.btn-sm.btn-danger(ng-if='!p.dateTerminated', ng-click='cancelSubscription()')=env.t('cancelSub') diff --git a/views/options/social/chat-box.jade b/views/options/social/chat-box.jade index 7953e7b1b6..3b55ddb8ec 100644 --- a/views/options/social/chat-box.jade +++ b/views/options/social/chat-box.jade @@ -1,18 +1,16 @@ -//div.chat-form.guidelines-not-accepted(ng-if='!user.flags.communityGuidelinesAccepted') -// p If you would like to post messages in the Tavern or any party or guild chat, please first read our -// | -// a(target='_blank', href='/static/community-guidelines')=env.t('communityGuidelines') -// | and then click the button below to indicate that you accept them. -// .chat-controls -// div -// button.btn.btn-warning(ng-click='acceptCommunityGuidelines()')=env.t('iAcceptCommunityGuidelines') -// .chat-buttons -// button(type="button", ng-click='sync(group)', tooltip=env.t('toolTipMsg')) -// span.glyphicon.glyphicon-refresh +div.chat-form.guidelines-not-accepted(ng-if='!user.flags.communityGuidelinesAccepted') + p If you would like to post messages in the Tavern or any party or guild chat, please first read our + | + a(target='_blank', href='/static/community-guidelines')=env.t('communityGuidelines') + | and then click the button below to indicate that you accept them. + .chat-controls + div + button.btn.btn-warning(ng-click='acceptCommunityGuidelines()')=env.t('iAcceptCommunityGuidelines') + .chat-buttons + button(type="button", ng-click='sync(group)', tooltip=env.t('toolTipMsg')) + span.glyphicon.glyphicon-refresh -//form.chat-form(ng-if='user.flags.communityGuidelinesAccepted' ng-submit='postChat(group,message.content)') -//////////// When we want to block the ability to chat until the Community Guidelines have been accepted, delete the one line immediately below this comment and un-comment all lines above. -form.chat-form(ng-submit='postChat(group,message.content)') +form.chat-form(ng-if='user.flags.communityGuidelinesAccepted' ng-submit='postChat(group,message.content)') div(ng-controller='AutocompleteCtrl') textarea.form-control(rows=4, ui-keypress='{13:"postChat(group,message.content)"}', ng-model='message.content', updateinterval='250', flag='@', at-user, auto-complete) span.user-list(ng-show='!isAtListHidden') @@ -24,5 +22,4 @@ form.chat-form(ng-submit='postChat(group,message.content)') include ../../shared/formatting-help .chat-buttons input(type='submit', value=env.t('sendChat'), ng-class='{disabled: _sending == true}') - button(type="button", ng-click='sync(group)', tooltip=env.t('toolTipMsg')) - span.glyphicon.glyphicon-refresh + button(type="button", ng-click='sync(group)')=env.t('toolTipMsg') diff --git a/views/options/social/chat-message.jade b/views/options/social/chat-message.jade index d7507908a5..27ecf66f70 100644 --- a/views/options/social/chat-message.jade +++ b/views/options/social/chat-message.jade @@ -1,19 +1,32 @@ -li.chat-message(ng-repeat='message in group.chat track by message.id', ng-class=':: {highlight: isUserMentioned(user,message) || message.uuid=="system", "own-message": user._id == message.uuid}') - .scrollable-message - span(ng-if='::message.user') - a.label.label-default.chat-message.hidden-label - span {{::message.user}} - span(ng-class='userAdminGlyphiconStyleFromLevel(message.contributor.level)') - // this invisible username label is here to push the message text far enough right that the visible label can be floated to this point without covering up any of the text - markdown(ng-model='::message.text') - | - - span.muted.time(from-now='::message.timestamp') - span - a.label.label-default(ng-show='countExists(message.likes)', ng-class='{"label-success":message.likes[user._id]}', ng-click='likeChatMessage(group,message)') +{{countExists(message.likes)}} - a.chat-plus-one.muted(ng-show='!countExists(message.likes)', ng-click='likeChatMessage(group,message)') +1 - | - a(ng-if=':: user.contributor.admin || message.uuid == user.id', ng-click='deleteChatMessage(group, message)') - span.glyphicon.glyphicon-trash(tooltip=env.t('delete')) - a.label.label-default.chat-message(ng-if=':: message.user', class='float-label', ng-class='userLevelStyleFromLevel(message.contributor.level, message.backer.npc, style)', ng-click='clickMember(message.uuid, true)') - span(tooltip='{{::contribText(message.contributor, message.backer)}}') {{::message.user}} - span(ng-class='userAdminGlyphiconStyleFromLevel(message.contributor.level)') +mixin chatMessages(inbox) + ul.list-unstyled.tavern-chat + - var ngRepeat = inbox ? 'message in user.inbox.messages | toArray:true | orderBy:"sort":true' : 'message in group.chat track by message.id' + li.chat-message(ng-repeat=ngRepeat, ng-class=':: {highlight: isUserMentioned(user,message) || message.uuid=="system", "own-message": user._id == message.uuid}', style='{{::message.sent ? "opacity:0.5" : ""}}') + .scrollable-message + span(ng-if='::message.user') + a.label.label-default.chat-message.hidden-label + span.glyphicon.glyphicon-arrow-right(ng-if='::message.sent') + span {{::message.user}} + span(ng-class='userAdminGlyphiconStyleFromLevel(message.contributor.level)') + // this invisible username label is here to push the message text far enough right that the visible label can be floated to this point without covering up any of the text + markdown(ng-model='::message.text') + | - + span.muted.time(from-now='::message.timestamp') + unless inbox + span + a.label.label-default(ng-show='countExists(message.likes)', ng-class='{"label-success":message.likes[user._id]}', ng-click='likeChatMessage(group,message)') +{{countExists(message.likes)}} + a.chat-plus-one.muted(ng-show='!countExists(message.likes)', ng-click='likeChatMessage(group, message)') +1 + | + a(ng-click="quickReply(message.uuid)") + span.glyphicon.glyphicon-envelope(tooltip=env.t('sendPM')) + if inbox + a(ng-click="quickReply(message.uuid)") + span.glyphicon.glyphicon-share-alt(tooltip=env.t('pm-reply')) + | + a(ng-click='#{inbox? "user.ops.deletePM({params:{id:message.$key}})" : "deleteChatMessage(group, message)"}', ng-if='#{inbox ? "true" : ":: user.contributor.admin || message.uuid == user.id"}') + span.glyphicon.glyphicon-trash(tooltip=env.t('delete')) + span.float-label + a.label.label-default.chat-message(ng-if=':: message.user', ng-class='::userLevelStyleFromLevel(message.contributor.level, message.backer.npc, style)', ng-click='clickMember(message.uuid, true)') + span.glyphicon.glyphicon-arrow-right(ng-if='::message.sent') + span(tooltip='{{::contribText(message.contributor, message.backer)}}') {{::message.user}} + span(ng-class='::userAdminGlyphiconStyleFromLevel(message.contributor.level)') diff --git a/views/options/social/group.jade b/views/options/social/group.jade index df056b816b..ec36aea8b4 100644 --- a/views/options/social/group.jade +++ b/views/options/social/group.jade @@ -48,9 +48,11 @@ a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter // ------ Members ------- .panel.panel-default .panel-heading - h3.panel-title=env.t('members') + h3.panel-title + =env.t('members') + button.pull-right.btn.btn-primary(ng-click="openModal('invite-friends', {controller:'GroupsCtrl'})", ng-if='::group.type=="party"') Invite Friends .panel-body.modal-fixed-height - div(ng-if='group.type=="party"') + div(ng-if='::group.type=="party"') =env.t('partyList') br select#partyOrder( @@ -86,7 +88,7 @@ a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter a.media-body span(ng-click='clickMember(invite._id, true)') | {{invite.profile.name}} - .panel-footer + .panel-footer(ng-if='::group.type!="party"') form.form-inline(ng-submit='invite(group)') //.alert.alert-danger(ng-show='_groupError') {{_groupError}} .form-group @@ -112,5 +114,4 @@ a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter h3=env.t('chat') include ./chat-box - ul.list-unstyled.tavern-chat - include ./chat-message + +chatMessages() diff --git a/views/options/social/index.jade b/views/options/social/index.jade index b632b1c6d9..b0fb4053c8 100644 --- a/views/options/social/index.jade +++ b/views/options/social/index.jade @@ -1,9 +1,22 @@ // FIXME note, due to https://github.com/angular-ui/bootstrap/issues/783 we can't use nested angular-bootstrap tabs // Subscribe to that ticket & change this when they fix -include ./challenges.jade -include ./hall.jade -include ./boss.jade +include ./challenges +include ./hall +include ./boss +include ./chat-message + +script(type='text/ng-template', id='partials/options.social.inbox.html') + .container-fluid + .row + .col-md-12 + +chatMessages('inbox') + .form-inline + a.btn.btn-xs.btn-danger(popover=env.t('clearAllPopover'), popover-trigger='mouseenter', ng-click='user.ops.clearPMs({})', popover-placement='right')=env.t('clearAll') + .checkbox + label + input(type='checkbox', ng-model='user.inbox.optOut', ng-change='set({"inbox.optOut": user.inbox.optOut?true: false})') + span.hint(popover-trigger='mouseenter', popover-placement='right', popover=env.t('optOutPopover'))=env.t('optOut') script(type='text/ng-template', id='partials/options.social.tavern.html') include ./tavern @@ -78,6 +91,10 @@ script(type='text/ng-template', id='partials/options.social.guilds.html') script(type='text/ng-template', id='partials/options.social.html') ul.options-menu + li(ng-class="{ active: $state.includes('options.social.inbox') }") + a(ui-sref='options.social.inbox') + | Inbox + span.badge.badge-danger(ng-if='user.inbox.newMessages') {{user.inbox.newMessages}} li(ng-class="{ active: $state.includes('options.social.tavern') }") a(ui-sref='options.social.tavern') =env.t('tavern') diff --git a/views/options/social/tavern.jade b/views/options/social/tavern.jade index 91b5d8caa3..753dd02c45 100644 --- a/views/options/social/tavern.jade +++ b/views/options/social/tavern.jade @@ -149,10 +149,10 @@ != ' ' + env.t('tavernAlert1') + ' ' + env.t('tavernAlert2') + '.