This commit is contained in:
Matteo Pagliazzi
2013-11-11 21:20:12 +01:00
1132 changed files with 10739 additions and 240703 deletions
-74
View File
@@ -1,74 +0,0 @@
XP = 15
HP = 2
priorityValue = module.exports.priorityValue = (priority='!') ->
switch priority
when '!' then 1
when '!!' then 1.5
when '!!!' then 2
else 1
module.exports.tnl = (level) ->
if level >= 100
value = 0
else
value = Math.round(((Math.pow(level,2)*0.25)+(10 * level) + 139.75)/10)*10 # round to nearest 10
return value
###
Calculates Exp modificaiton based on level and weapon strength
{value} task.value for exp gain
{weaponStrength) weapon strength
{level} current user level
{priority} user-defined priority multiplier
###
module.exports.expModifier = (value, weaponStr, level, priority='!') ->
str = (level-1) / 2 # ultimately get this from user
totalStr = (str + weaponStr) / 100
strMod = 1 + totalStr
exp = value * XP * strMod * priorityValue(priority)
return Math.round(exp)
###
Calculates HP modification based on level and armor defence
{value} task.value for hp loss
{armorDefense} defense from armor
{helmDefense} defense from helm
{level} current user level
{priority} user-defined priority multiplier
###
module.exports.hpModifier = (value, armorDef, helmDef, shieldDef, level, priority='!') ->
def = (level-1) / 2 # ultimately get this from user?
totalDef = (def + armorDef + helmDef + shieldDef) / 100 #ultimate get this from user
defMod = 1 - totalDef
hp = value * HP * defMod * priorityValue(priority)
return Math.round(hp * 10)/10 # round to 1dp
###
Future use
{priority} user-defined priority multiplier
###
module.exports.gpModifier = (value, modifier, priority='!', streak, model) ->
val = value * modifier * priorityValue(priority)
if streak and model
streakBonus = streak / 100 + 1 # eg, 1-day streak is 1.1, 2-day is 1.2, etc
afterStreak = val * streakBonus
model.set('_streakBonus', afterStreak - val) if (val > 0) # can we do this without model? just global emit?
return afterStreak
else
return val
###
Calculates the next task.value based on direction
Uses a capped inverse log y=.95^x, y>= -5
{currentValue} the current value of the task
{direction} up or down
###
module.exports.taskDeltaFormula = (currentValue, direction) ->
if currentValue < -47.27 then currentValue = -47.27
else if currentValue > 21.27 then currentValue = 21.27
delta = Math.pow(0.9747,currentValue)
return delta if direction is 'up'
return -delta
-245
View File
@@ -1,245 +0,0 @@
_ = require 'underscore'
moment = require 'moment'
###
Loads JavaScript files from public/vendor/*
Use require() to min / concatinate for faster page load
###
loadJavaScripts = (model) ->
# Turns out you can't have expressions in browserify require() statements
#vendor = '../../public/vendor'
#require "#{vendor}/jquery-ui-1.10.2/jquery-1.9.1"
###
Internal Scripts
###
require "../../public/vendor/jquery-ui-1.10.2/jquery-1.9.1"
require "../../public/vendor/jquery.cookie.min"
require "../../public/vendor/bootstrap/js/bootstrap.min"
require "../../public/vendor/jquery.bootstrap-growl.min"
require "../../public/vendor/datepicker/js/bootstrap-datepicker"
require "../../public/vendor/bootstrap-tour/bootstrap-tour"
unless (model.get('_mobileDevice') is true)
require "../../public/vendor/jquery-ui-1.10.2/ui/jquery.ui.core"
require "../../public/vendor/jquery-ui-1.10.2/ui/jquery.ui.widget"
require "../../public/vendor/jquery-ui-1.10.2/ui/jquery.ui.mouse"
require "../../public/vendor/jquery-ui-1.10.2/ui/jquery.ui.sortable"
require "../../public/vendor/sticky"
# note: external script loading is handled in app.on('render') near the bottom of this file (see https://groups.google.com/forum/?fromgroups=#!topic/derbyjs/x8FwdTLEuXo)
###
Setup jQuery UI Sortable
###
setupSortable = (model) ->
unless (model.get('_mobileDevice') is true) #don't do sortable on mobile
_.each ['habit', 'daily', 'todo', 'reward'], (type) ->
$("ul.#{type}s").sortable
dropOnEmpty: false
cursor: "move"
items: "li"
scroll: true
axis: 'y'
update: (e, ui) ->
item = ui.item[0]
domId = item.id
id = item.getAttribute 'data-id'
to = $("ul.#{type}s").children().index(item)
# Use the Derby ignore option to suppress the normal move event
# binding, since jQuery UI will move the element in the DOM.
# Also, note that refList index arguments can either be an index
# or the item's id property
model.at("_#{type}List").pass(ignore: domId).move {id}, to
setupTooltips = module.exports.setupTooltips = ->
$('[rel=tooltip]').tooltip()
$('[rel=popover]').popover()
$('.popover-auto-show').popover('show')
$('.priority-multiplier-help').popover
title: "How difficult is this task?"
trigger: "hover"
content: "This multiplies its point value. Use sparingly, rely instead on our organic value-adjustment algorithms. But some tasks are grossly more valuable (Write Thesis vs Floss Teeth). Click for more info."
setupTour = (model) ->
tourSteps = [
{
element: ".main-herobox"
title: "Welcome to HabitRPG"
content: "Welcome to HabitRPG, a habit-tracker which treats your goals like a Role Playing Game."
}
{
element: "#bars"
title: "Achieve goals and level up"
content: "As you accomplish goals, you level up. If you fail your goals, you lose hit points. Lose all your HP and you die."
}
{
element: "ul.habits"
title: "Habits"
content: "Habits are goals that you constantly track."
placement: "bottom"
}
{
element: "ul.dailys"
title: "Dailies"
content: "Dailies are goals that you want to complete once a day."
placement: "bottom"
}
{
element: "ul.todos"
title: "Todos"
content: "Todos are one-off goals which need to be completed eventually."
placement: "bottom"
}
{
element: "ul.rewards"
title: "Rewards"
content: "As you complete goals, you earn gold to buy rewards. Buy them liberally - rewards are integral in forming good habits."
placement: "bottom"
}
{
element: "ul.habits li:first-child"
title: "Hover over comments"
content: "Different task-types have special properties. Hover over each task's comment for more information. When you're ready to get started, delete the existing tasks and add your own."
placement: "right"
}
]
$('.main-herobox').popover('destroy') #remove previous popovers
tour = new Tour()
_.each tourSteps, (step) ->
tour.addStep _.defaults step, {html:true}
tour._current = 0 if isNaN(tour._current) #bootstrap-tour bug
tour.start()
# jquery sticky header on scroll, no need for position fixed
initStickyHeader = (model) ->
$('.header-wrap').sticky({topSpacing:0})
###
Sets up "+1 Exp", "Level Up", etc notifications
###
setupGrowlNotifications = (model) ->
return unless jQuery? # Only run this in the browser
user = model.at '_user'
statsNotification = (html, type) ->
#don't show notifications if user dead
return if user.get('stats.lvl') == 0
$.bootstrapGrowl html,
ele: '#notification-area',
type: type # (null, 'info', 'error', 'success', 'gp', 'xp', 'hp', 'lvl','death')
top_offset: 20
align: 'right' # ('left', 'right', or 'center')
width: 250 # (integer, or 'auto')
delay: 3000
allow_dismiss: true
stackup_spacing: 10 # spacing between consecutive stacecked growls.
# Setup listeners which trigger notifications
user.on 'set', 'stats.hp', (captures, args) ->
num = captures - args
rounded = Math.abs(num.toFixed(1))
if num < 0
statsNotification "<i class='icon-heart'></i> - #{rounded} HP", 'hp' # lost hp from purchase
else if num > 0
statsNotification "<i class='icon-heart'></i> + #{rounded} HP", 'hp' # gained hp from potion/level?
user.on 'set', 'stats.exp', (captures, args, isLocal, silent=false) ->
# unless silent
num = captures - args
rounded = Math.abs(num.toFixed(1))
if num < 0 and num > -50 # TODO fix hackey negative notification supress
statsNotification "<i class='icon-star'></i> - #{rounded} XP", 'xp'
else if num > 0
statsNotification "<i class='icon-star'></i> + #{rounded} XP", 'xp'
###
Show "+ 5 {gold_coin} 3 {silver_coin}"
###
showCoins = (money) ->
absolute = Math.abs(money)
gold = Math.floor(absolute)
silver = Math.floor((absolute-gold)*100)
if gold and silver > 0
return "#{gold} <i class='icon-gold'></i> #{silver} <i class='icon-silver'></i>"
else if gold > 0
return "#{gold} <i class='icon-gold'></i>"
else if silver > 0
return "#{silver} <i class='icon-silver'></i>"
user.on 'set', 'stats.gp', (captures, args) ->
money = captures - args
return unless !!money # why is this happening? gotta find where stats.gp is being set from (-)habit
sign = if money < 0 then '-' else '+'
statsNotification "#{sign} #{showCoins(money)}", 'gp'
# Append Bonus
bonus = model.get('_streakBonus')
if (money > 0) and !!bonus
bonus = 0.01 if bonus < 0.01
statsNotification "+ #{showCoins(bonus)} Streak Bonus!"
model.del('_streakBonus')
user.on 'set', 'items.*', (item, after, before) ->
if item in ['armor','weapon','shield','head'] and parseInt(after) < parseInt(before)
item = 'helm' if item is 'head' # don't want to day "lost a head"
statsNotification "<i class='icon-death'></i> Respawn!", "death"
user.on 'set', 'stats.lvl', (captures, args) ->
if captures > args
statsNotification '<i class="icon-chevron-up"></i> Level Up!', 'lvl'
module.exports.resetDom = (model) ->
DERBY.app.dom.clear()
DERBY.app.view.render(model, DERBY.app.view._lastRender.ns, DERBY.app.view._lastRender.context);
# Note, Google Analyatics giving beef if in this file. Moved back to index.html. It's ok, it's async - really the
# syncronous requires up top are what benefit the most from this file.
googleAnalytics = (model) ->
if model.flags.nodeEnv is 'production'
window._gaq = [["_setAccount", "UA-33510635-1"], ["_setDomainName", "habitrpg.com"], ["_trackPageview"]]
$.getScript ((if "https:" is document.location.protocol then "https://ssl" else "http://www")) + ".google-analytics.com/ga.js"
amazonAffiliate = (model) ->
if model.get('_loggedIn') and (model.get('_user.flags.ads') != 'hide')
$.getScript('//wms.assoc-amazon.com/20070822/US/js/link-enhancer-common.js?tag=ha0d2-20').fail ->
$('body').append('<img src="//wms.assoc-amazon.com/20070822/US/img/noscript.gif?tag=ha0d2-20" alt="" />')
googleCharts = ->
$.getScript "//www.google.com/jsapi", ->
# Specifying callback in options param is vital! Otherwise you get blank screen, see http://stackoverflow.com/a/12200566/362790
google.load "visualization", "1", {packages:["corechart"], callback: ->}
module.exports.app = (appExports, model, app) ->
loadJavaScripts(model)
setupGrowlNotifications(model) unless model.get('_mobileDevice')
app.on 'render', (ctx) ->
#restoreRefs(model)
setupSortable(model)
setupTooltips(model)
setupTour(model)
initStickyHeader(model) unless model.get('_mobileDevice')
$('.datepicker').datepicker({autoclose:true, todayBtn:true})
.on 'changeDate', (ev) ->
#for some reason selecting a date doesn't fire a change event on the field, meaning our changes aren't saved
model.at(ev.target).set 'date', moment(ev.date).format('MM/DD/YYYY')
###
External Scripts
JS files not needed right away (google charts) or entirely optional (analytics)
Each file getsload asyncronously via $.getScript, so it doesn't bog page-load
These need to be handled in app.on('render'), see https://groups.google.com/forum/?fromgroups=#!topic/derbyjs/x8FwdTLEuXo
###
$.getScript('//checkout.stripe.com/v2/checkout.js')
unless (model.get('_mobileDevice') is true)
$.getScript("//s7.addthis.com/js/250/addthis_widget.js#pubid=lefnire")
googleCharts()
googleAnalytics(model)
amazonAffiliate(model)
-202
View File
@@ -1,202 +0,0 @@
browser = require './browser'
items = require './items'
algos = require './algos'
moment = require 'moment'
_ = require 'underscore'
lodash = require 'lodash'
derby = require 'derby'
module.exports.app = (appExports, model) ->
user = model.at '_user'
appExports.revive = ->
# Reset stats
user.set 'stats.hp', 50
user.set 'stats.exp', 0
user.set 'stats.gp', 0
user.incr 'stats.lvl', -1 if user.get('stats.lvl') > 1
## Lose a random item
loseThisItem = false
owned = user.get('items')
# unless they're already at 0-everything
if parseInt(owned.armor)>0 or parseInt(owned.head)>0 or parseInt(owned.shield)>0 or parseInt(owned.weapon)>0
console.log 'test'
# find a random item to lose
until loseThisItem
#candidate = {0:'items.armor', 1:'items.head', 2:'items.shield', 3:'items.weapon', 4:'stats.gp'}[Math.random()*5|0]
candidate = {0:'armor', 1:'head', 2:'shield', 3:'weapon'}[Math.random()*4|0]
loseThisItem = candidate if owned[candidate] > 0
user.set "items.#{loseThisItem}", 0
items.updateStore(model)
appExports.reset = (e, el) ->
batch = new BatchUpdate(model)
batch.startTransaction()
taskTypes = ['habit', 'daily', 'todo', 'reward']
batch.set 'tasks', {}
_.each taskTypes, (type) -> batch.set "#{type}Ids", []
batch.set 'balance', 1 if user.get('balance') < 1 #only if they haven't manually bought gems
# Reset stats
batch.set 'stats.hp', 50
batch.set 'stats.lvl', 1
batch.set 'stats.gp', 0
batch.set 'stats.exp', 0
# Reset items
batch.set 'items.armor', 0
batch.set 'items.weapon', 0
batch.set 'items.head', 0
batch.set 'items.shield', 0
items.updateStore(model)
batch.commit()
browser.resetDom(model)
appExports.closeNewStuff = (e, el) ->
user.set('flags.newStuff', 'hide')
appExports.customizeGender = (e, el) ->
user.set 'preferences.gender', $(el).attr('data-value')
appExports.customizeHair = (e, el) ->
user.set 'preferences.hair', $(el).attr('data-value')
appExports.customizeSkin = (e, el) ->
user.set 'preferences.skin', $(el).attr('data-value')
appExports.customizeArmorSet = (e, el) ->
user.set 'preferences.armorSet', $(el).attr('data-value')
appExports.restoreSave = (e, el) ->
batch = new BatchUpdate(model)
batch.startTransaction()
$('#restore-form input').each ->
batch.set $(this).attr('data-for'), parseInt($(this).val() || 1)
batch.commit()
appExports.toggleHeader = (e, el) ->
user.set 'preferences.hideHeader', !user.get('preferences.hideHeader')
appExports.deleteAccount = (e, el) ->
model.del "users.#{user.get('id')}", ->
window.location.href = "/logout"
userSchema =
# _id
stats: { gp: 0, exp: 0, lvl: 1, hp: 50 }
party: { current: null, invitation: null }
items: { weapon: 0, armor: 0, head: 0, shield: 0 }
preferences: { gender: 'm', skin: 'white', hair: 'blond', armorSet: 'v1', dayStart:0, showHelm: true }
habitIds: []
dailyIds: []
todoIds: []
rewardIds: []
apiToken: null # set in newUserObject below
lastCron: 'new' #this will be replaced with `+new Date` on first run
balance: 0
tasks: {}
flags:
partyEnabled: false
itemsEnabled: false
tags: []
# ads: 'show' # added on registration
module.exports.newUserObject = ->
# deep clone, else further new users get duplicate objects
newUser = lodash.cloneDeep userSchema
newUser.apiToken = derby.uuid()
repeat = {m:true,t:true,w:true,th:true,f:true,s:true,su:true}
defaultTasks = [
{type: 'habit', text: '1h Productive Work', notes: '-- Habits: Constantly Track --\nFor some habits, it only makes sense to *gain* points (like this one).', value: 0, up: true, down: false }
{type: 'habit', text: 'Eat Junk Food', notes: 'For others, it only makes sense to *lose* points', value: 0, up: false, down: true}
{type: 'habit', text: 'Take The Stairs', notes: 'For the rest, both + and - make sense (stairs = gain, elevator = lose)', value: 0, up: true, down: true}
{type: 'daily', text: '1h Personal Project', notes: '-- Dailies: Complete Once a Day --\nAt the end of each day, non-completed Dailies dock you points.', value: 0, completed: false, repeat: repeat }
{type: 'daily', text: 'Exercise', notes: "If you are doing well, they turn green and are less valuable (experience, gold) and less damaging (HP). This means you can ease up on them for a bit.", value: 3, completed: false, repeat: repeat }
{type: 'daily', text: '45m Reading', notes: 'But if you are doing poorly, they turn red. The worse you do, the more valuable (exp, gold) and more damaging (HP) these goals become. This encourages you to focus on your shortcomings, the reds.', value: -10, completed: false, repeat: repeat }
{type: 'todo', text: 'Call Mom', notes: "-- Todos: Complete Eventually --\nNon-completed Todos won't hurt you, but they will become more valuable over time. This will encourage you to wrap up stale Todos.", value: -3, completed: false }
{type: 'reward', text: '1 Episode of Game of Thrones', notes: '-- Rewards: Treat Yourself! --\nAs you complete goals, you earn gold to buy rewards. Buy them liberally - rewards are integral in forming good habits.', value: 20 }
{type: 'reward', text: 'Cake', notes: 'But only buy if you have enough gold - you lose HP otherwise.', value: 10 }
]
defaultTags = [
{name: 'morning'}
{name: 'afternoon'}
{name: 'evening'}
]
for task in defaultTasks
guid = task.id = derby.uuid()
newUser.tasks[guid] = task
switch task.type
when 'habit' then newUser.habitIds.push guid
when 'daily' then newUser.dailyIds.push guid
when 'todo' then newUser.todoIds.push guid
when 'reward' then newUser.rewardIds.push guid
for tag in defaultTags
tag.id = derby.uuid()
newUser.tags.push tag
return newUser
module.exports.BatchUpdate = BatchUpdate = (model) ->
user = model.at("_user")
transactionInProgress = false
obj = null
updates = {}
{
user: user
obj: ->
obj ?= model.get 'users.'+user.get('id')
return obj
startTransaction: ->
# start a batch transaction - nothing between now and @commit() will be set immediately
transactionInProgress = true
model._dontPersist = true
@obj()
###
Handles updating the user model. If this is an en-mass operation (eg, server cron), changes are queued
but not actually set to the model. It also modifies userObj in case you need to access properties manually later.
If transaction not in progress, it just runs standard model.set()
###
set: (path, val) ->
updates[path] = val if transactionInProgress
user.set(path, val)
###
Hack to get around dom bindings being lost if parent objects are replaced whole-sale
eg, user.set('stats', {hp:50, exp:10...}) will break dom bindings, but user.set('stats.hp',50) is ok
###
setStats: (stats) ->
stats ?= obj.stats
that = @
_.each Object.keys(stats), (key) -> that.set "stats.#{key}", stats[key]
# queue: (path, val) ->
# # Special function for setting object properties by string dot-notation. See http://stackoverflow.com/a/6394168/362790
# arr = path.split('.')
# arr.reduce (curr, next, index) ->
# if (arr.length - 1) == index
# curr[next] = val
# curr[next]
# , obj
commit: ->
model._dontPersist = false
# some hackery in our own branched racer-db-mongo, see findAndModify of lefnire/racer-db-mongo#habitrpg index.js
# pass true if we have levelled to supress xp notification
user.set "update__", updates
transactionInProgress = false
updates = {}
}
-19
View File
@@ -1,19 +0,0 @@
moment = require 'moment'
algos = require './algos'
module.exports.app = (appExports, model) ->
user = model.at('_user')
appExports.emulateNextDay = ->
yesterday = +moment().subtract('days', 1).toDate()
user.set 'lastCron', yesterday
window.location.reload()
appExports.emulateTenDays = ->
yesterday = +moment().subtract('days', 10).toDate()
user.set 'lastCron', yesterday
window.location.reload()
appExports.cheat = ->
user.incr 'stats.exp', algos.tnl(user.get('stats.lvl'))
user.incr 'stats.gp', 1000
-41
View File
@@ -1,41 +0,0 @@
_ = require 'underscore'
browser = require './browser'
module.exports.app = (appExports, model) ->
user = model.at('_user')
appExports.toggleFilterByTag = (e, el) ->
tagId = $(el).attr('data-tag-id')
path = 'filters.' + tagId
user.set path, !(user.get path)
appExports.filtersNewTag = ->
user.setNull 'tags', []
user.push 'tags', {id: model.id(), name: model.get("_newTag")}
model.set '_newTag', ''
appExports.toggleEditingTags = ->
before = model.get('_editingTags')
model.set '_editingTags', !before, ->
location.reload() if before is true #when they're done, refresh the page
appExports.clearFilters = ->
user.set 'filters', {}
appExports.filtersDeleteTag = (e, el) ->
tags = user.get('tags')
tag = e.at "_user.tags." + $(el).attr('data-index')
tagId = tag.get('id')
#something got corrupted, let's clear the corrupt tags
unless tagId
user.set 'tags', _.filter( tags, ((t)-> t?.id) )
user.set 'filters', {}
return
model.del "_user.filters.#{tagId}"
tag.remove()
# remove tag from all tasks
_.each user.get("tasks"), (task) -> user.del "tasks.#{task.id}.tags.#{tagId}"
-219
View File
@@ -1,219 +0,0 @@
moment = require 'moment'
_ = require 'underscore'
relative = require 'relative-date'
algos = require './algos'
items = require('./items').items
# Absolute diff between two dates
daysBetween = (yesterday, now, dayStart) ->
#sanity-check reset-time (is it 24h time?)
dayStart = 0 unless (dayStart? and (dayStart = parseInt(dayStart)) and dayStart >= 0 and dayStart <= 24)
Math.abs moment(yesterday).startOf('day').add('h', dayStart).diff(moment(now), 'days')
dayMapping = dayMapping = {0:'su',1:'m',2:'t',3:'w',4:'th',5:'f',6:'s',7:'su'}
# http://stackoverflow.com/questions/2532218/pick-random-property-from-a-javascript-object
# obj: object
# returns random property (the value)
randomVal = (obj) ->
result = undefined
count = 0
for key, val of obj
result = val if Math.random() < (1 / ++count)
result
removeWhitespace = (str) ->
return '' unless str
str.replace /\s/g, ''
username = (auth, override) ->
#some people define custom profile name in Avatar -> Profile
return override if override
if auth?.facebook?.displayName?
auth.facebook.displayName
else if auth?.facebook?
fb = auth.facebook
if fb._raw then "#{fb.name.givenName} #{fb.name.familyName}" else fb.name
else if auth?.local?
auth.local.username
else
'Anonymous'
viewHelpers = (view) ->
view.fn "percent", (x, y) ->
x=1 if x==0
Math.round(x/y*100)
view.fn "round", Math.round
view.fn "floor", Math.floor
view.fn "ceil", Math.ceil
view.fn "lt", (a, b) -> a < b
view.fn 'gt', (a, b) -> a > b
view.fn "mod", (a, b) -> parseInt(a) % parseInt(b) == 0
view.fn 'removeWhitespace', removeWhitespace
view.fn "notEqual", (a, b) -> (a != b)
view.fn "and", -> _.reduce arguments, (cumm, curr) -> cumm && curr
view.fn "or", -> _.reduce arguments, (cumm, curr) -> cumm || curr
view.fn "truarr", (num) -> num-1
view.fn 'count', (arr) -> arr?.length or 0
view.fn "gems", (gp) -> return gp/0.25
view.fn "encodeiCalLink", (uid, apiToken) ->
loc = window?.location.host or process.env.BASE_URL
encodeURIComponent "http://#{loc}/v1/users/#{uid}/calendar.ics?apiToken=#{apiToken}"
###
User
###
view.fn "username", (auth, override) -> username(auth, override)
view.fn "tnl", algos.tnl
###
Items
###
view.fn 'equipped', (type, item=0, preferences={gender:'m', armorSet:'v1'}, backerTier=0) ->
{gender, armorSet} = preferences
item = parseInt(item)
backerTier = parseInt(backerTier)
switch type
when'armor'
if item > 5
return 'armor_6' if backerTier >= 45
item = 5 # set them back if they're trying to cheat
if gender is 'f'
return if (item is 0) then "f_armor_#{item}_#{armorSet}" else "f_armor_#{item}"
else
return "m_armor_#{item}"
when 'head'
if item > 5
return 'head_6' if backerTier >= 45
item = 5
if gender is 'f'
return if (item > 1) then "f_head_#{item}_#{armorSet}" else "f_head_#{item}"
else
return "m_head_#{item}"
when 'shield'
if item > 5
return 'shield_6' if backerTier >= 45
item = 5
return "#{preferences.gender}_shield_#{item}"
when 'weapon'
if item > 6
return 'weapon_7' if backerTier >= 70
item = 6
return "#{preferences.gender}_weapon_#{item}"
view.fn "gold", (num) ->
if num
return (num).toFixed(1).split('.')[0]
else
return "0"
view.fn "silver", (num) ->
if num
(num).toFixed(2).split('.')[1]
else
return "00"
###
Tasks
###
view.fn 'taskClasses', (task, filters, dayStart, lastCron, showCompleted=false) ->
return unless task
{type, completed, value, repeat} = task
# completed / remaining toggle
return 'hidden' if (type is 'todo') and (completed != showCompleted)
for filter, enabled of filters
if enabled and not task.tags?[filter]
# All the other classes don't matter
return 'hidden'
classes = type
now = moment().day()
# calculate the current contextual day (e.g. if it's 12 AM Fri and the user's custom day start is 4 AM, then we should still act like it's Thursday)
dayStart = 0 unless (dayStart? and (dayStart = parseInt(dayStart)) and dayStart >= 0 and dayStart <= 24)
hourDiff = Math.abs moment(lastCron).startOf('day').add('h', dayStart).diff(moment(now), 'hours')
dayStamp = moment(now).add('h', hourDiff)
day = dayStamp.day()
# show as completed if completed (naturally) or not required for today
if type in ['todo', 'daily']
if completed or (repeat and (repeat[dayMapping[day]] == false))
classes += " completed"
else
classes += " uncompleted"
else if type is 'habit'
classes += ' habit-wide' if task.down and task.up
if value < -20
classes += ' color-worst'
else if value < -10
classes += ' color-worse'
else if value < -1
classes += ' color-bad'
else if value < 1
classes += ' color-neutral'
else if value < 5
classes += ' color-good'
else if value < 10
classes += ' color-better'
else
classes += ' color-best'
return classes
view.fn 'ownsPet', (pet, userPets) -> !!userPets && userPets.indexOf(pet) != -1
view.fn 'friendlyTimestamp', (timestamp) -> moment(timestamp).format('MM/DD h:mm:ss a')
view.fn 'newChatMessages', (messages, lastMessageSeen) ->
return false unless messages?.length > 0
messages?[0] and (messages[0].id != lastMessageSeen)
view.fn 'indexOf', (str1, str2) ->
return false unless str1 && str2
str1.indexOf(str2) != -1
view.fn 'relativeDate', relative
view.fn 'noTags', (tags) ->
_.isEmpty(tags) or _.isEmpty(_.filter( tags, (t) -> t ) )
view.fn 'appliedTags', (userTags, taskTags) ->
arr = []
_.each userTags, (t) ->
return unless t?
arr.push(t.name) if taskTags?[t.id]
arr.join(', ')
view.fn 'userStr', (level) ->
str = (level-1) / 2
view.fn 'totalStr', (level, weapon=0) ->
str = (level-1) / 2
totalStr = (str + items.weapon[weapon].strength)
view.fn 'userDef', (level) ->
def = (level-1) / 2
view.fn 'totalDef', (level, armor=0, helm=0, shield=0) ->
def = (level-1) / 2
totalDef = (def + items.armor[armor].defense + items.head[helm].defense + items.shield[shield].defense)
view.fn 'itemText', (type, item=0) -> items[type][parseInt(item)].text
view.fn 'itemStat', (type, item=0) -> if type is 'weapon' then items[type][parseInt(item)].strength else items[type][parseInt(item)].defense
# view.fn 'activeFilters', (filters) ->
# debugger
# (_.find filters, (f) -> f)?
module.exports = { viewHelpers, removeWhitespace, randomVal, daysBetween, dayMapping, username }
-7
View File
@@ -1,7 +0,0 @@
i18n = require 'derby-i18n'
i18n.plurals.add 'he', (n) -> n
i18n.plurals.add 'bg', (n) -> n
i18n.plurals.add 'nl', (n) -> n
module.exports = i18n
-137
View File
@@ -1,137 +0,0 @@
derby = require 'derby'
# Include library components
derby.use require('derby-ui-boot'), {styles: []}
derby.use require '../../ui'
derby.use require 'derby-auth/components'
# Init app & reference its functions
app = derby.createApp module
{get, view, ready} = app
# Translations
i18n = require './i18n'
i18n.localize app,
availableLocales: ['en', 'he', 'bg', 'nl']
defaultLocale: 'en'
urlScheme: false
checkHeader: true
helpers = require './helpers'
helpers.viewHelpers view
_ = require('underscore')
###
Cleanup task-corruption (null tasks, rogue/invisible tasks, etc)
Obviously none of this should be happening, but we'll stop-gap until we can find & fix
Gotta love refLists! see https://github.com/lefnire/habitrpg/issues/803 & https://github.com/lefnire/habitrpg/issues/6343
###
cleanupCorruptTasks = (model) ->
user = model.at('_user')
tasks = user.get('tasks')
## Remove corrupted tasks
_.each tasks, (task, key) ->
unless task?.id? and task?.type?
user.del("tasks.#{key}")
delete tasks[key]
batch = null
## Task List Cleanup
_.each ['habit','daily','todo','reward'], (type) ->
# 1. remove duplicates
# 2. restore missing zombie tasks back into list
idList = user.get("#{type}Ids")
taskIds = _.pluck( _.where(tasks, {type:type}), 'id')
union = _.union idList, taskIds
# 2. remove empty (grey) tasks
preened = _.filter union, (id) -> id and _.contains(taskIds, id)
# There were indeed issues found, set the new list
if !_.isEqual(idList, preened)
unless batch?
batch = new require('./character').BatchUpdate(model)
batch.startTransaction()
batch.set("#{type}Ids", preened)
console.error user.get('id') + "'s #{type}s were corrupt."
batch.commit() if batch?
###
Subscribe to the user, the users's party (meta info like party name, member ids, etc), and the party's members. 3 subscriptions.
###
setupSubscriptions = (page, model, params, next, cb) ->
uuid = model.get('_userId') or model.session.userId # see http://goo.gl/TPYIt
selfQ = model.query('users').withId(uuid) #keep this for later
partyQ = model.query('parties').withMember(uuid)
partyQ.fetch (err, party) ->
return next(err) if err
finished = (descriptors, paths) ->
model.subscribe.apply model, descriptors.concat ->
[err, refs] = [arguments[0], arguments]
return next(err) if err
_.each paths, (path, idx) -> model.ref path, refs[idx+1]
unless model.get('_user')
console.error "User not found - this shouldn't be happening!"
return page.redirect('/logout') #delete model.session.userId
return cb()
# (1) Solo player
return finished([selfQ, 'tavern'], ['_user', '_tavern']) unless party.get()
## (2) Party has members, subscribe to those users too
membersQ = model.query('users').party(party.get('members'))
# Fetch instead of subscribe. There's nothing dynamic we need from members just yet, they'll update _party instead.
# This may change in the future.
membersQ.fetch (err, members) ->
return next(err) if err
model.ref '_partyMembers', members
# Note - selfQ *must* come after membersQ in subscribe, otherwise _user will only get the fields restricted by party-members in store.coffee. Strang bug, but easy to get around
return finished([partyQ, selfQ, 'tavern'], ['_party', '_user', '_tavern'])
# ========== ROUTES ==========
get '/', (page, model, params, next) ->
return page.redirect '/' if page.params?.query?.play?
# removed force-ssl (handled in nginx), see git for code
setupSubscriptions page, model, params, next, ->
cleanupCorruptTasks(model) # https://github.com/lefnire/habitrpg/issues/634
require('./items').server(model)
#refLists
_.each ['habit', 'daily', 'todo', 'reward'], (type) ->
model.refList "_#{type}List", "_user.tasks", "_user.#{type}Ids"
page.render()
# ========== CONTROLLER FUNCTIONS ==========
ready (model) ->
user = model.at('_user')
model.setNull '_user.apiToken', derby.uuid()
#set cron immediately
lastCron = user.get('lastCron')
user.set('lastCron', +new Date) if (!lastCron? or lastCron == 'new')
require('./scoring').cron(model)
require('./character').app(exports, model)
require('./tasks').app(exports, model)
require('./items').app(exports, model)
require('./party').app(exports, model, app)
require('./profile').app(exports, model)
require('./pets').app(exports, model)
require('../server/private').app(exports, model)
require('./debug').app(exports, model) if model.flags.nodeEnv != 'production'
require('./browser').app(exports, model, app)
require('./unlock').app(exports, model)
require('./filters').app(exports, model)
-151
View File
@@ -1,151 +0,0 @@
_ = require 'underscore'
items = module.exports.items =
weapon: [
{index: 0, text: "Training Sword", classes: "weapon_0", notes:'Training weapon.', strength: 0, value:0}
{index: 1, text: "Sword", classes:'weapon_1', notes:'Increases experience gain by 3%.', strength: 3, value:20}
{index: 2, text: "Axe", classes:'weapon_2', notes:'Increases experience gain by 6%.', strength: 6, value:30}
{index: 3, text: "Morningstar", classes:'weapon_3', notes:'Increases experience gain by 9%.', strength: 9, value:45}
{index: 4, text: "Blue Sword", classes:'weapon_4', notes:'Increases experience gain by 12%.', strength: 12, value:65}
{index: 5, text: "Red Sword", classes:'weapon_5', notes:'Increases experience gain by 15%.', strength: 15, value:90}
{index: 6, text: "Golden Sword", classes:'weapon_6', notes:'Increases experience gain by 18%.', strength: 18, value:120}
{index: 7, text: "Dark Souls Blade", classes:'weapon_7', notes:'Increases experience gain by 21%.', strength: 21, value:150}
]
armor: [
{index: 0, text: "Cloth Armor", classes: 'armor_0', notes:'Training armor.', defense: 0, value:0}
{index: 1, text: "Leather Armor", classes: 'armor_1', notes:'Decreases HP loss by 4%.', defense: 4, value:30}
{index: 2, text: "Chain Mail", classes: 'armor_2', notes:'Decreases HP loss by 6%.', defense: 6, value:45}
{index: 3, text: "Plate Mail", classes: 'armor_3', notes:'Decreases HP loss by 7%.', defense: 7, value:65}
{index: 4, text: "Red Armor", classes: 'armor_4', notes:'Decreases HP loss by 8%.', defense: 8, value:90}
{index: 5, text: "Golden Armor", classes: 'armor_5', notes:'Decreases HP loss by 10%.', defense: 10, value:120}
{index: 6, text: "Shade Armor", classes: 'armor_6', notes:'Decreases HP loss by 12%.', defense: 12, value:150}
]
head: [
{index: 0, text: "No Helm", classes: 'head_0', notes:'Training helm.', defense: 0, value:0}
{index: 1, text: "Leather Helm", classes: 'head_1', notes:'Decreases HP loss by 2%.', defense: 2, value:15}
{index: 2, text: "Chain Coif", classes: 'head_2', notes:'Decreases HP loss by 3%.', defense: 3, value:25}
{index: 3, text: "Plate Helm", classes: 'head_3', notes:'Decreases HP loss by 4%.', defense: 4, value:45}
{index: 4, text: "Red Helm", classes: 'head_4', notes:'Decreases HP loss by 5%.', defense: 5, value:60}
{index: 5, text: "Golden Helm", classes: 'head_5', notes:'Decreases HP loss by 6%.', defense: 6, value:80}
{index: 6, text: "Shade Helm", classes: 'head_6', notes:'Decreases HP loss by 7%.', defense: 7, value:100}
]
shield: [
{index: 0, text: "No Shield", classes: 'shield_0', notes:'No Shield.', defense: 0, value:0}
{index: 1, text: "Wooden Shield", classes: 'shield_1', notes:'Decreases HP loss by 3%', defense: 3, value:20}
{index: 2, text: "Buckler", classes: 'shield_2', notes:'Decreases HP loss by 4%.', defense: 4, value:35}
{index: 3, text: "Enforced Shield", classes: 'shield_3', notes:'Decreases HP loss by 5%.', defense: 5, value:55}
{index: 4, text: "Red Shield", classes: 'shield_4', notes:'Decreases HP loss by 7%.', defense: 7, value:70}
{index: 5, text: "Golden Shield", classes: 'shield_5', notes:'Decreases HP loss by 8%.', defense: 8, value:90}
{index: 6, text: "Tormented Skull", classes: 'shield_6', notes:'Decreases HP loss by 9%.', defense: 9, value:120}
]
potion: {type: 'potion', text: "Potion", notes: "Recover 15 HP", value: 25, classes: 'potion'}
reroll: {type: 'reroll', text: "Re-Roll", classes: 'reroll', notes: "Resets your task values back to 0 (yellow). Useful when everything's red and it's hard to stay alive.", value:0 }
pets: [
{text: 'Wolf', name: 'Wolf', value: 3}
{text: 'Tiger Cub', name: 'TigerCub', value: 3}
#{text: 'Polar Bear Cub', name: 'PolarBearCub', value: 3} #commented out because there are no polarbear modifiers yet, special drop?
{text: 'Panda Cub', name: 'PandaCub', value: 3}
{text: 'Lion Cub', name: 'LionCub', value: 3}
{text: 'Fox', name: 'Fox', value: 3}
{text: 'Flying Pig', name: 'FlyingPig', value: 3}
{text: 'Dragon', name: 'Dragon', value: 3}
{text: 'Cactus', name: 'Cactus', value: 3}
{text: 'Bear Cub', name: 'BearCub', value: 3}
]
hatchingPotions: [
{text: 'Base', name: 'Base', notes: "Hatches your pet in it's base form.", value: 1}
{text: 'White', name: 'White', notes: 'Turns your animal into a White pet.', value: 2}
{text: 'Desert', name: 'Desert', notes: 'Turns your animal into a Desert pet.', value: 2}
{text: 'Red', name: 'Red', notes: 'Turns your animal into a Red pet.', value: 3}
{text: 'Shade', name: 'Shade', notes: 'Turns your animal into a Shade pet.', value: 3}
{text: 'Skeleton', name: 'Skeleton', notes: 'Turns your animal into a Skeleton.', value: 3}
{text: 'Zombie', name: 'Zombie', notes: 'Turns your animal into a Zombie.', value: 4}
{text: 'Cotton Candy Pink', name: 'CottonCandyPink', notes: 'Turns your animal into a Cotton Candy Pink pet.', value: 4}
{text: 'Cotton Candy Blue', name: 'CottonCandyBlue', notes: 'Turns your animal into a Cotton Candy Blue pet.', value: 4}
{text: 'Golden', name: 'Golden', notes: 'Turns your animal into a Golden pet.', value: 5}
]
# add "type" to each item, so we can reference that as "weapon" or "armor" in the html
_.each ['weapon', 'armor', 'head', 'shield'], (key) ->
_.each items[key], (item) -> item.type = key
_.each items.pets, (pet) -> pet.notes = 'Find a hatching potion to pour on this egg, and one day it will hatch into a loyal pet.'
_.each items.hatchingPotions, (hatchingPotion) -> hatchingPotion.notes = "Pour this on an egg, and it will hatch as a #{hatchingPotion.text} pet."
###
server exports
###
module.exports.server = (model) ->
model.set '_items', items
updateStore(model)
###
app exports
###
module.exports.app = (appExports, model) ->
user = model.at '_user'
appExports.buyItem = (e, el, next) ->
user = model.at '_user'
#TODO: this should be working but it's not. so instead, i'm passing all needed values as data-attrs
# item = model.at(e.target)
gp = user.get 'stats.gp'
[type, value, index] = [ $(el).attr('data-type'), $(el).attr('data-value'), $(el).attr('data-index') ]
return if gp < value
# make sure deduction doesn't happen unless purchase was successful, see https://github.com/lefnire/habitrpg/issues/233
deductGP = -> user.set 'stats.gp', gp - value
if type == 'weapon'
user.set 'items.weapon', index, deductGP
updateStore model
else if type == 'armor'
user.set 'items.armor', index, deductGP
updateStore model
else if type == 'head'
user.set 'items.head', index, deductGP
updateStore model
else if type == 'shield'
user.set 'items.shield', index, deductGP
updateStore model
else if type == 'potion'
hp = user.get 'stats.hp'
hp += 15
hp = 50 if hp > 50
user.set 'stats.hp', hp, deductGP
appExports.activateRewardsTab = ->
model.set '_activeTabRewards', true
model.set '_activeTabPets', false
appExports.activatePetsTab = ->
model.set '_activeTabPets', true
model.set '_activeTabRewards', false
###
update store
###
module.exports.updateStore = updateStore = (model) ->
model.setNull '_items.next', {}
user = model.at('_user')
equipped = user.get('items')
_.each ['weapon', 'armor', 'shield', 'head'], (type) ->
i = parseInt(equipped?[type] || 0) + 1
showNext = true
if i is items[type].length - 1
if (type in ['armor', 'shield', 'head'])
showNext = user.get('backer.tier') >= 45 # backer armor
else
showNext = user.get('backer.tier') >= 70 # backer weapon
else if i is items[type].length
showNext = false
model.set "_items.next.#{type}", if showNext then items[type][i] else {hide:true}
-147
View File
@@ -1,147 +0,0 @@
_ = require('underscore')
helpers = require './helpers'
module.exports.app = (appExports, model, app) ->
character = require './character'
browser = require './browser'
helpers = require './helpers'
_currentTime = model.at '_currentTime'
_currentTime.setNull +new Date()
# Every 60 seconds, reset the current time so that the chat
# can update relative times
setInterval ->
_currentTime.set +new Date()
, 60000
user = model.at('_user')
model.on 'set', '_user.party.invitation', (after, before) ->
if !before? and after? # they just got invited
partyQ = model.query('parties').withId(after)
partyQ.fetch (err, party) ->
return next(err) if err
model.ref '_party', party
browser.resetDom(model)
appExports.partyCreate = ->
newParty = model.get("_newParty")
id = model.add 'parties', { name: newParty, leader: user.get('id'), members: [user.get('id')], invites:[] }
user.set 'party', {current: id, invitation: null, leader: true}, ->
window.location.reload true
appExports.partyInvite = ->
id = model.get('_newPartyMember').replace(/[\s"]/g, '')
return if _.isEmpty(id)
model.query('users').party([id]).fetch (err, users) ->
throw err if err
u = users.at(0).get()
if !u?
model.set "_partyError", "User with id #{id} not found."
return
else if u.party.current? or u.party.invitation?
model.set "_partyError", "User already in a party or pending invitation."
return
else
$.bootstrapGrowl "Invitation Sent."
model.set "users.#{id}.party.invitation", model.get('_party.id'), -> window.location.reload()
#model.set '_newPartyMember', ''
#partySubscribe model
appExports.partyAccept = ->
partyId = user.get('party.invitation')
user.set 'party.invitation', null
user.set 'party.current', partyId
model.at("parties.#{partyId}.members").push user.get('id'), -> window.location.reload()
# model.query('parties').withId(partyId).fetch (err, p) ->
# members = p.get('members')
# members.push user.get('id')
# p.set 'members', members, ->
# window.location.reload true
# partySubscribe model, ->
# p = model.at('_party')
# p.push 'members', user.get('id')
appExports.partyReject = ->
user.set 'party.invitation', null
browser.resetDom(model)
appExports.partyLeave = ->
id = user.set 'party.current', null
party = model.at '_party'
members = party.get('members')
index = members.indexOf(user.get('id'))
party.remove 'members', index, 1, ->
if members.length is 1 # # last member out, kill the party
model.del "parties.#{id}", (-> window.location.reload true)
else
window.location.reload true
###
Chat Functionality
###
sendChat = (path, input) ->
chat = model.at path
text = model.get input
# Check for non-whitespace characters
return unless /\S/.test text
model.set(input, '')
message =
id: model.id()
uuid: user.get('id')
contributor: user.get('backer.contributor')
npc: user.get('backer.npc')
text: text
user: helpers.username(model.get('_user.auth'), model.get('_user.profile.name'))
timestamp: +new Date
# FIXME - sometimes racer will send many duplicates via chat.unshift. I think because it can't make connection, keeps
# trying, but all attempts go through. Unfortunately we can't do chat.set without potentially clobbering other chatters,
# and we can't make chat an object without using refLists. hack solution for now is to unshift, and if there are dupes
# after we set to unique
chat.unshift message, ->
messages = chat.get() || []
count = messages.length
messages =_.uniq messages, true, ((m) -> m?.id) # get rid of dupes
#There were a bunch of duplicates, let's clean it up
if messages.length != count
messages.splice(200)
chat.set messages
else
chat.remove(200)
model.on 'unshift', '_party.chat', -> $('.chat-message').tooltip()
model.on 'unshift', '_tavern.chat.messages', -> $('.chat-message').tooltip()
appExports.partySendChat = ->
sendChat('_party.chat', '_chatMessage')
model.set '_user.party.lastMessageSeen', model.get('_party.chat')[0].id
appExports.tavernSendChat = ->
sendChat('_tavern.chat.messages', '_tavernMessage')
appExports.partyMessageKeyup = (e, el, next) ->
return next() unless e.keyCode is 13
appExports.partySendChat()
appExports.tavernMessageKeyup = (e, el, next) ->
return next() unless e.keyCode is 13
appExports.tavernSendChat()
appExports.deleteChatMessage = (e) -> e.at().remove() #requires the {#with}
app.on 'render', (ctx) ->
$('#party-tab-link').on 'shown', (e) ->
messages = model.get('_party.chat')
return false unless messages?.length > 0
model.set '_user.party.lastMessageSeen', messages[0].id
appExports.gotoPartyChat = ->
model.set '_gamePane', true, ->
$('#party-tab-link').tab('show')
-74
View File
@@ -1,74 +0,0 @@
_ = require 'underscore'
{ randomVal } = require './helpers'
{ pets, hatchingPotions } = require('./items').items
###
app exports
###
module.exports.app = (appExports, model) ->
user = model.at '_user'
appExports.chooseEgg = (e, el) ->
model.ref '_hatchEgg', e.at()
appExports.hatchEgg = (e, el) ->
hatchingPotionName = $(el).children('select').val()
myHatchingPotion = user.get 'items.hatchingPotions'
egg = model.get '_hatchEgg'
eggs = user.get 'items.eggs'
myPets = user.get 'items.pets'
hatchingPotionIdx = myHatchingPotion.indexOf hatchingPotionName
eggIdx = eggs.indexOf egg
return alert "You don't own that hatching potion yet, complete more tasks!" if hatchingPotionIdx is -1
return alert "You don't own that egg yet, complete more tasks!" if eggIdx is -1
return alert "You already have that pet, hatch a different combo." if myPets and myPets.indexOf("#{egg.name}-#{hatchingPotionName}") != -1
user.push 'items.pets', egg.name + '-' + hatchingPotionName
eggs.splice eggIdx, 1
myHatchingPotion.splice hatchingPotionIdx, 1
user.set 'items.eggs', eggs
user.set 'items.hatchingPotions', myHatchingPotion
alert 'Your egg hatched! Visit your stable to equip your pet.'
#FIXME Bug: this removes from the array properly in the browser, but on refresh is has removed all items from the arrays
# user.remove 'items.hatchingPotions', hatchingPotionIdx, 1
# user.remove 'items.eggs', eggIdx, 1
appExports.choosePet = (e, el, next) ->
petStr = $(el).attr('data-pet')
return next() if user.get('items.pets').indexOf(petStr) == -1
# If user's pet is already active, deselect it
return user.set 'items.currentPet', {} if user.get('items.currentPet.str') is petStr
[name, modifier] = petStr.split('-')
pet = _.findWhere pets, name: name
pet.modifier = modifier
pet.str = petStr
user.set 'items.currentPet', pet
appExports.buyHatchingPotion = (e, el) ->
name = $(el).attr 'data-hatchingPotion'
newHatchingPotion = _.findWhere hatchingPotions, name: name
gems = user.get('balance') * 4
if gems >= newHatchingPotion.value
if confirm "Buy this hatching potion with #{newHatchingPotion.value} of your #{gems} Gems?"
user.push 'items.hatchingPotions', newHatchingPotion.name
user.set 'balance', (gems - newHatchingPotion.value) / 4
else
$('#more-gems-modal').modal 'show'
appExports.buyEgg = (e, el) ->
name = $(el).attr 'data-egg'
newEgg = _.findWhere pets, name: name
gems = user.get('balance') * 4
if gems >= newEgg.value
if confirm "Buy this egg with #{newEgg.value} of your #{gems} Gems?"
user.push 'items.eggs', newEgg
user.set 'balance', (gems - newEgg.value) / 4
else
$('#more-gems-modal').modal 'show'
-38
View File
@@ -1,38 +0,0 @@
character = require './character'
browser = require './browser'
helpers = require './helpers'
module.exports.app = (appExports, model) ->
user = model.at('_user')
appExports.profileAddWebsite = (e, el) ->
newWebsite = model.get('_newProfileWebsite')
return if /^(\s)*$/.test(newWebsite)
user.unshift 'profile.websites', newWebsite
model.set '_newProfileWebsite', ''
appExports.profileEdit = (e, el) -> model.set '_profileEditing', true
appExports.profileSave = (e, el) -> model.set '_profileEditing', false
appExports.profileRemoveWebsite = (e, el) ->
sites = user.get 'profile.websites'
i = sites.indexOf $(el).attr('data-website')
sites.splice(i,1)
user.set 'profile.websites', sites
toggleGamePane = ->
model.set '_gamePane', !model.get('_gamePane'), ->
browser.setupTooltips()
appExports.clickAvatar = (e, el) ->
uid = $(el).attr('data-uid')
if uid is model.get('_userId') # clicked self
toggleGamePane()
else
$("#avatar-modal-#{uid}").modal('show')
appExports.toggleGamePane = -> toggleGamePane()
appExports.toggleResting = ->
model.set '_user.flags.rest', !model.get('_user.flags.rest')
-357
View File
@@ -1,357 +0,0 @@
moment = require 'moment'
_ = require 'underscore'
{ randomVal } = helpers = require './helpers'
browser = require './browser'
character = require './character'
items = require './items'
{ pets, hatchingPotions } = items.items
algos = require './algos'
MODIFIER = algos.MODIFIER # each new level, armor, weapon add 2% modifier (this mechanism will change)
###
Drop System
###
randomDrop = (model, delta, priority, streak=0) ->
user = model.at('_user')
# limit drops to 2 / day
user.setNull 'items.lastDrop',
date: +moment().subtract('d',1) # trick - set it to yesterday on first run, that way they can get drops today
count: 0
reachedDropLimit = (helpers.daysBetween(user.get('items.lastDrop.date'), +new Date) is 0) and user.get('items.lastDrop.count') >= 2
return if reachedDropLimit
# % chance of getting a pet or meat
chanceMultiplier = Math.abs(delta)
chanceMultiplier *= algos.priorityValue(priority) # multiply chance by reddness
chanceMultiplier += streak # streak bonus
console.log chanceMultiplier
if user.get('flags.dropsEnabled') and Math.random() < (.05 * chanceMultiplier)
# current breakdown - 3% (adjustable) chance on drop
# If they got a drop: 50% chance of egg, 50% Hatching Potion. If hatchingPotion, broken down further even further
rarity = Math.random()
# Egg, 40% chance
if rarity > .6
drop = randomVal(pets)
user.push 'items.eggs', drop
drop.type = 'Egg'
drop.dialog = "You've found a #{drop.text} Egg! #{drop.notes}"
# Hatching Potion, 60% chance - break down by rarity even more. FIXME this may not be the best method, so revisit
else
acceptableDrops = []
# Tier 5 (Blue Moon Rare)
if rarity < .1
acceptableDrops = ['Base', 'White', 'Desert', 'Red', 'Shade', 'Skeleton', 'Zombie', 'CottonCandyPink', 'CottonCandyBlue', 'Golden']
# Tier 4 (Very Rare)
else if rarity < .2
acceptableDrops = ['Base', 'White', 'Desert', 'Red', 'Shade', 'Skeleton', 'Zombie', 'CottonCandyPink', 'CottonCandyBlue']
# Tier 3 (Rare)
else if rarity < .3
acceptableDrops = ['Base', 'White', 'Desert', 'Red', 'Shade', 'Skeleton']
# Tier 2 (Scarce)
else if rarity < .4
acceptableDrops = ['Base', 'White', 'Desert']
# Tier 1 (Common)
else
acceptableDrops = ['Base']
acceptableDrops = _.filter(hatchingPotions, (hatchingPotion) -> hatchingPotion.name in acceptableDrops)
drop = randomVal acceptableDrops
user.push 'items.hatchingPotions', drop.name
drop.type = 'HatchingPotion'
drop.dialog = "You've found a #{drop.text} Hatching Potion! #{drop.notes}"
model.set '_drop', drop
$('#item-dropped-modal').modal 'show'
user.set 'items.lastDrop.date', +new Date
user.incr 'items.lastDrop.count'
# {taskId} task you want to score
# {direction} 'up' or 'down'
# {times} # times to call score on this task (1 unless cron, usually)
# {update} if we're running updates en-mass (eg, cron on server) pass in userObj
score = (model, taskId, direction, times, batch, cron) ->
user = model.at '_user'
commit = false
unless batch?
commit = true
batch = new character.BatchUpdate(model)
batch.startTransaction()
obj = batch.obj()
{gp, hp, exp, lvl} = obj.stats
taskPath = "tasks.#{taskId}"
taskObj = obj.tasks[taskId]
{type, value, streak} = taskObj
priority = taskObj.priority or '!'
# If they're trying to purhcase a too-expensive reward, confirm they want to take a hit for it
if taskObj.value > obj.stats.gp and taskObj.type is 'reward'
r = confirm "Not enough GP to purchase this reward, buy anyway and lose HP? (Punishment for taking a reward you didn't earn)."
unless r
batch.commit()
return
delta = 0
times ?= 1
calculateDelta = (adjustvalue=true) ->
# If multiple days have passed, multiply times days missed
_.times times, (n) ->
# Each iteration calculate the delta (nextDelta), which is then accumulated in delta
# (aka, the total delta). This weirdness won't be necessary when calculating mathematically
# rather than iteratively
nextDelta = algos.taskDeltaFormula(value, direction)
value += nextDelta if adjustvalue
delta += nextDelta
addPoints = ->
level = user.get('stats.lvl')
weaponStrength = items.items.weapon[user.get('items.weapon')].strength
exp += algos.expModifier(delta,weaponStrength,level, priority) / 2 # / 2 hack for now bcause people leveling too fast
if streak
gp += algos.gpModifier(delta, 1, priority, streak, model)
else
gp += algos.gpModifier(delta, 1, priority)
subtractPoints = ->
level = user.get('stats.lvl')
armorDefense = items.items.armor[user.get('items.armor')].defense
helmDefense = items.items.head[user.get('items.head')].defense
shieldDefense = items.items.shield[user.get('items.shield')].defense
hp += algos.hpModifier(delta,armorDefense,helmDefense,shieldDefense,level, priority)
switch type
when 'habit'
calculateDelta()
# Add habit value to habit-history (if different)
if (delta > 0) then addPoints() else subtractPoints()
taskObj.history ?= []
if taskObj.value != value
historyEntry = { date: +new Date, value: value }
taskObj.history.push historyEntry
batch.set "#{taskPath}.history", taskObj.history
when 'daily'
if cron? # cron
calculateDelta()
subtractPoints()
batch.set "#{taskPath}.streak", 0
else
calculateDelta(false)
if delta != 0
addPoints() # obviously for delta>0, but also a trick to undo accidental checkboxes
if direction is 'up'
streak = if streak then streak + 1 else 1
else
streak = if streak then streak - 1 else 0
batch.set "#{taskPath}.streak", streak
taskObj.streak = streak
when 'todo'
if cron? #cron
calculateDelta()
#don't touch stats on cron
else
calculateDelta()
addPoints() # obviously for delta>0, but also a trick to undo accidental checkboxes
when 'reward'
# Don't adjust values for rewards
calculateDelta(false)
# purchase item
gp -= Math.abs(taskObj.value)
num = parseFloat(taskObj.value).toFixed(2)
# if too expensive, reduce health & zero gp
if gp < 0
hp += gp # hp - gp difference
gp = 0
taskObj.value = value
batch.set "#{taskPath}.value", taskObj.value
origStats = _.clone obj.stats
updateStats model, { hp, exp, gp }, batch
# Commit
if commit
# newStats / origStats is a glorious hack to trick Derby into seeing the change in model.on(*)
newStats = _.clone batch.obj().stats
_.each Object.keys(origStats), (key) -> obj.stats[key] = origStats[key]
batch.setStats(newStats)
# batch.setStats()
batch.commit()
# Drop system
randomDrop(model, delta, priority, streak) if direction is 'up'
return delta
###
Updates user stats with new stats. Handles death, leveling up, etc
{stats} new stats
{update} if aggregated changes, pass in userObj as update. otherwise commits will be made immediately
###
updateStats = (model, newStats, batch) ->
user = model.at '_user'
obj = batch.obj()
# if user is dead, dont do anything
return if obj.stats.hp <= 0
if newStats.hp?
# Game Over
if newStats.hp <= 0
obj.stats.hp = 0 # signifies dead
return
else
obj.stats.hp = newStats.hp
if newStats.exp?
tnl = algos.tnl(obj.stats.lvl)
#silent = false
# if we're at level 100, turn xp to gold
if obj.stats.lvl >= 100
newStats.gp += newStats.exp / 15
newStats.exp = 0
obj.stats.lvl = 100
else
# level up & carry-over exp
if newStats.exp >= tnl
#silent = true # push through the negative xp silently
user.set('stats.exp', newStats.exp) # push normal + notification
while newStats.exp >= tnl and obj.stats.lvl < 100 # keep levelling up
newStats.exp -= tnl
obj.stats.lvl++
tnl = algos.tnl(obj.stats.lvl)
if obj.stats.lvl== 100
newStats.exp = 0
obj.stats.hp = 50
obj.stats.exp = newStats.exp
#if silent
#console.log("pushing silent :" + obj.stats.exp)
#user.pass(true).set('stats.exp', obj.stats.exp)
# Set flags when they unlock features
# NOTE we have to first model.set() the flag to true, then AFTER that obj.flags.flag = true
# The reason is model.on() listeners still track object references, so if obj.flags.flags = true and then we
# model.set(), the second argument of .on() listeners will be true (in otherwords, before/after tests will fail)
if !obj.flags.customizationsNotification and (obj.stats.exp > 10 or obj.stats.lvl > 1)
batch.set 'flags.customizationsNotification', true
obj.flags.customizationsNotification = true
if !obj.flags.itemsEnabled and obj.stats.lvl >= 2
# Set to object, then also send to browser right away to get model.on() subscription notification
batch.set 'flags.itemsEnabled', true
obj.flags.itemsEnabled = true
if !obj.flags.partyEnabled and obj.stats.lvl >= 3
batch.set 'flags.partyEnabled', true
obj.flags.partyEnabled = true
if !obj.flags.dropsEnabled and obj.stats.lvl >= 4
batch.set 'flags.dropsEnabled', true
obj.flags.dropsEnabled = true
if newStats.gp?
#FIXME what was I doing here? I can't remember, gp isn't defined
gp = 0.0 if (!gp? or gp<0)
obj.stats.gp = newStats.gp
###
At end of day, add value to all incomplete Daily & Todo tasks (further incentive)
For incomplete Dailys, deduct experience
###
cron = (model) ->
user = model.at '_user'
today = +new Date
daysPassed = helpers.daysBetween(user.get('lastCron'), today, user.get('preferences.dayStart'))
if daysPassed > 0
# User is resting at the inn. Used to be we un-checked each daily without performing calculation (see commits before fb29e35)
# but to prevent abusing the inn (http://goo.gl/GDb9x) we now do *not* calculate dailies, and simply set lastCron to today
if user.get('flags.rest') is true
return user.set('lastCron', today)
batch = new character.BatchUpdate(model)
batch.startTransaction()
obj = batch.obj()
batch.set 'lastCron', today
hpBefore = obj.stats.hp #we'll use this later so we can animate hp loss
# Tally each task
todoTally = 0
_.each obj.tasks, (taskObj) ->
{id, type, completed, repeat} = taskObj
if type in ['todo', 'daily']
# Deduct experience for missed Daily tasks,
# but not for Todos (just increase todo's value)
unless completed
# for todos & typical dailies, these are equivalent
daysFailed = daysPassed
# however, for dailys which have repeat dates, need
# to calculate how many they've missed according to their own schedule
if type=='daily' && repeat
daysFailed = 0
_.times daysPassed, (n) ->
thatDay = moment().subtract('days', n+1)
if repeat[helpers.dayMapping[thatDay.day()]]==true
daysFailed++
score model, id, 'down', daysFailed, batch, true
if type == 'daily'
if completed #set OHV for completed dailies
newValue = taskObj.value + algos.taskDeltaFormula(taskObj.value,'up')
batch.set "tasks.#{taskObj.id}.value", newValue
taskObj.history ?= []
taskObj.history.push { date: +new Date, value: taskObj.value }
batch.set "tasks.#{taskObj.id}.history", taskObj.history
batch.set "tasks.#{taskObj.id}.completed", false
else
value = obj.tasks[taskObj.id].value #get updated value
absVal = if (completed) then Math.abs(value) else value
todoTally += absVal
else if type is 'habit' # slowly reset 'onlies' value to 0
if taskObj.up==false or taskObj.down==false
if Math.abs(taskObj.value) < 0.1
batch.set "tasks.#{taskObj.id}.value", 0
else
batch.set "tasks.#{taskObj.id}.value", taskObj.value / 2
# Finished tallying
obj.history ?= {}; obj.history.todos ?= []; obj.history.exp ?= []
obj.history.todos.push { date: today, value: todoTally }
# tally experience
expTally = obj.stats.exp
lvl = 0 #iterator
while lvl < (obj.stats.lvl-1)
lvl++
expTally += algos.tnl(lvl)
obj.history.exp.push { date: today, value: expTally }
# Set the new user specs, and animate HP loss
[hpAfter, obj.stats.hp] = [obj.stats.hp, hpBefore]
batch.setStats()
batch.set('history', obj.history)
batch.commit()
browser.resetDom(model)
setTimeout (-> user.set 'stats.hp', hpAfter), 1000 # animate hp loss
module.exports = {
score: score
cron: cron
# testing stuff
expModifier: algos.expModifier
hpModifier: algos.hpModifier
taskDeltaFormula: algos.taskDeltaFormula
}
-170
View File
@@ -1,170 +0,0 @@
scoring = require './scoring'
helpers = require './helpers'
_ = require 'underscore'
moment = require 'moment'
character = require './character'
module.exports.app = (appExports, model) ->
user = model.at('_user')
appExports.addTask = (e, el) ->
type = $(el).attr('data-task-type')
newModel = model.at('_new' + type.charAt(0).toUpperCase() + type.slice(1))
text = newModel.get()
# Don't add a blank task; 20/02/13 Added a check for undefined value, more at issue #463 -lancemanfv
return if /^(\s)*$/.test(text) || text == undefined
activeFilters = _.reduce user.get('filters'), ((memo,v,k) -> memo[k]=v if v;memo), {}
newTask = {id: model.id(), type: type, text: text, notes: '', value: 0, tags: activeFilters}
switch type
when 'habit'
newTask = _.defaults {up: true, down: true}, newTask
when 'reward'
newTask = _.defaults {value: 20}, newTask
when 'daily'
newTask = _.defaults {repeat:{su:true,m:true,t:true,w:true,th:true,f:true,s:true}, completed: false }, newTask
when 'todo'
newTask = _.defaults {completed: false }, newTask
model.unshift "_#{type}List", newTask
newModel.set ''
appExports.del = (e, el) ->
# Derby extends model.at to support creation from DOM nodes
task = e.at()
id = task.get('id')
history = task.get('history')
if history and history.length>2
# prevent delete-and-recreate hack on red tasks
if task.get('value') < 0
result = confirm("Are you sure? Deleting this task will hurt you (to prevent deleting, then re-creating red tasks).")
if result != true
return # Cancel. Don't delete, don't hurt user
else
task.set('type','habit') # hack to make sure it hits HP, instead of performing "undo checkbox"
scoring.score(model, id, direction:'down')
# prevent accidently deleting long-standing tasks
else
result = confirm("Are you sure you want to delete this task?")
return if result != true
#TODO bug where I have to delete from _users.tasks AND _{type}List,
# fix when query subscriptions implemented properly
$('[rel=tooltip]').tooltip('hide')
user.del('tasks.'+id)
task.remove()
appExports.clearCompleted = (e, el) ->
completedIds = _.pluck( _.where(model.get('_todoList'), {completed:true}), 'id')
todoIds = user.get('todoIds')
_.each completedIds, (id) -> user.del "tasks.#{id}"
user.set 'todoIds', _.difference(todoIds, completedIds)
appExports.toggleDay = (e, el) ->
task = model.at(e.target)
if /active/.test($(el).attr('class')) # previous state, not current
task.set('repeat.' + $(el).attr('data-day'), false)
else
task.set('repeat.' + $(el).attr('data-day'), true)
appExports.toggleTaskEdit = (e, el) ->
hideId = $(el).attr('data-hide-id')
toggleId = $(el).attr('data-toggle-id')
$(document.getElementById(hideId)).addClass('visuallyhidden')
$(document.getElementById(toggleId)).toggleClass('visuallyhidden')
appExports.toggleChart = (e, el) ->
hideSelector = $(el).attr('data-hide-id')
chartSelector = $(el).attr('data-toggle-id')
historyPath = $(el).attr('data-history-path')
$(document.getElementById(hideSelector)).hide()
$(document.getElementById(chartSelector)).toggle()
matrix = [['Date', 'Score']]
for obj in model.get(historyPath)
date = +new Date(obj.date)
readableDate = moment(date).format('MM/DD')
matrix.push [ readableDate, obj.value ]
data = google.visualization.arrayToDataTable matrix
options = {
title: 'History'
backgroundColor: { fill:'transparent' }
}
chart = new google.visualization.LineChart(document.getElementById( chartSelector ))
chart.draw(data, options)
appExports.todosShowRemaining = -> model.set '_showCompleted', false
appExports.todosShowCompleted = -> model.set '_showCompleted', true
setUndo = (stats, task) ->
previousUndo = model.get('_undo')
clearTimeout(previousUndo.timeoutId) if previousUndo?.timeoutId
timeoutId = setTimeout (-> model.del('_undo')), 10000
model.set '_undo', {stats:stats, task:task, timeoutId: timeoutId}
###
Call scoring functions for habits & rewards (todos & dailies handled below)
###
appExports.score = (e, el) ->
task= model.at $(el).parents('li')[0]
taskObj = task.get()
direction = $(el).attr('data-direction')
# set previous state for undo
setUndo _.clone(user.get('stats')), _.clone(taskObj)
scoring.score(model, taskObj.id, direction)
###
This is how we handle appExports.score for todos & dailies. Due to Derby's special handling of `checked={:task.completd}`,
the above function doesn't work so we need a listener here
###
user.on 'set', 'tasks.*.completed', (i, completed, previous, isLocal, passed) ->
return if passed? && passed.cron # Don't do this stuff on cron
direction = if completed then 'up' else 'down'
# set previous state for undo
taskObj = _.clone user.get("tasks.#{i}")
taskObj.completed = previous
setUndo _.clone(user.get('stats')), taskObj
scoring.score(model, i, direction)
###
Undo
###
appExports.undo = () ->
undo = model.get '_undo'
clearTimeout(undo.timeoutId) if undo?.timeoutId
batch = character.BatchUpdate(model)
batch.startTransaction()
model.del '_undo'
_.each undo.stats, (val, key) -> batch.set "stats.#{key}", val
taskPath = "tasks.#{undo.task.id}"
_.each undo.task, (val, key) ->
return if key in ['id', 'type'] # strange bugs in this world: https://workflowy.com/shared/a53582ea-43d6-bcce-c719-e134f9bf71fd/
if key is 'completed'
user.pass({cron:true}).set("#{taskPath}.completed",val)
else
batch.set "#{taskPath}.#{key}", val
batch.commit()
appExports.tasksToggleAdvanced = (e, el) ->
$(el).next('.advanced-option').toggleClass('visuallyhidden')
appExports.tasksSaveAndClose = ->
# When they update their notes, re-establish tooltip & popover
$('[rel=tooltip]').tooltip()
$('[rel=popover]').popover()
appExports.tasksSetPriority = (e, el) ->
dataId = $(el).parent('[data-id]').attr('data-id')
#"_user.tasks.#{dataId}"
model.at(e.target).set 'priority', $(el).attr('data-priority')
-95
View File
@@ -1,95 +0,0 @@
_ = require 'underscore'
{ randomVal } = require './helpers'
{ pets, hatchingPotions } = require('./items').items
###
Listeners to enabled flags, set notifications to the user when they've unlocked features
###
module.exports.app = (appExports, model) ->
user = model.at('_user')
alreadyShown = (before, after) -> !(!before and after is true)
showPopover = (selector, title, html, placement='bottom') ->
$(selector).popover('destroy')
html += " <a href='#' onClick=\"$('#{selector}').popover('hide');return false;\">[Close]</a>"
$(selector).popover({
title: title
placement: placement
trigger: 'manual'
html: true
content: html
}).popover 'show'
user.on 'set', 'flags.customizationsNotification', (after, before) ->
return if alreadyShown(before,after)
$('.main-herobox').popover('destroy') #remove previous popovers
html = "Click your avatar to customize your appearance."
showPopover '.main-herobox', 'Customize Your Avatar', html, 'bottom'
user.on 'set', 'flags.itemsEnabled', (after, before) ->
return if alreadyShown(before,after)
html = """
<img src='/vendor/BrowserQuest/client/img/1/chest.png' />
Congratulations, you have unlocked the Item Store! You can now buy weapons, armor, potions, etc. Read each item's comment for more information.
"""
showPopover 'div.rewards', 'Item Store Unlocked', html, 'left'
user.on 'set', 'flags.petsEnabled', (after, before) ->
return if alreadyShown(before,after)
html = """
<img src='/img/sprites/wolf_border.png' style='width:30px;height:30px;float:left;padding-right:5px' />
You have unlocked Pets! You can now buy pets with Gems (note, you replenish Gems with real-life money - so chose your pets wisely!)
"""
showPopover '#rewardsTabs', 'Pets Unlocked', html, 'left'
user.on 'set', 'flags.partyEnabled', (after, before) ->
return if user.get('party.current') or alreadyShown(before,after)
html = """
Be social, join a party and play Habit with your friends! You'll be better at your habits with accountability partners. Click User -> Options -> Party, and follow the instructions. LFG anyone?
"""
showPopover '.user-menu', 'Party System', html, 'bottom'
user.on 'set', 'flags.dropsEnabled', (after, before) ->
return if alreadyShown(before,after)
egg = randomVal pets
dontPersist = model._dontPersist
model._dontPersist = false
user.push 'items.eggs', egg
model._dontPersist = dontPersist
$('#drops-enabled-modal').modal 'show'
user.on 'push', 'items.pets', (after, before) ->
return if user.get('achievements.beastMaster')
if before >= 90 # evidently before is the count?
dontPersist = model._dontPersist; model._dontPersist = false
user.set 'achievements.beastMaster', true, (-> model._dontPersist = dontPersist)
$('#beastmaster-achievement-modal').modal('show')
user.on 'set', 'items.*', (after, before) ->
return if user.get('achievements.ultimateGear')
items = user.get('items')
if parseInt(items.weapon) == 6 and parseInt(items.armor) == 5 and parseInt(items.head) == 5 and parseInt(items.shield) == 5
dontPersist = model._dontPersist; model._dontPersist = false
user.set 'achievements.ultimateGear', true, (-> model._dontPersist = dontPersist)
$('#max-gear-achievement-modal').modal('show')
user.on 'set', 'tasks.*.streak', (id, after, before) ->
if after > 0
# 21-day streak, as per the old philosophy of doign a thing 21-days in a row makes a habit
if (after % 21) is 0
dontPersist = model._dontPersist; model._dontPersist = false
user.incr 'achievements.streak', 1, (-> model._dontPersist = dontPersist)
$('#streak-achievement-modal').modal('show')
# they're undoing a task at the 21 mark, take back their badge
else if (before - after is 1) and (before % 21 is 0)
dontPersist = model._dontPersist; model._dontPersist = false
user.incr 'achievements.streak', -1, (-> model._dontPersist = dontPersist)
+28
View File
@@ -0,0 +1,28 @@
/* Load nconf and define default configuration values if config.json or ENV vars are not found*/
var conf = require("nconf");
var path = require("path");
conf.argv()
.env()
//.file('defaults', path.join(path.resolve(__dirname, '../config.json.example')))
.file('user', path.join(path.resolve(__dirname, '../config.json')));
/*
var agent;
if (process.env.NODE_ENV === 'development') {
// Follow these instructions for profiling / debugging leaks
// * https://developers.google.com/chrome-developer-tools/docs/heap-profiling
// * https://developers.google.com/chrome-developer-tools/docs/memory-analysis-101
agent = require('webkit-devtools-agent');
console.log("To debug memory leaks:" +
"\n\t(1) Run `kill -SIGUSR2 " + process.pid + "`" +
"\n\t(2) open http://c4milo.github.com/node-webkit-agent/21.0.1180.57/inspector.html?host=localhost:1337&page=0");
}
*/
if (conf.get('NODE_ENV') === "development") {
Error.stackTraceLimit = Infinity;
}
+271
View File
@@ -0,0 +1,271 @@
var _ = require('lodash');
var validator = require('validator');
var check = validator.check;
var sanitize = validator.sanitize;
var passport = require('passport');
var helpers = require('habitrpg-shared/script/helpers');
var async = require('async');
var utils = require('../utils');
var nconf = require('nconf');
var User = require('../models/user').model;
var api = module.exports;
var NO_TOKEN_OR_UID = { err: "You must include a token and uid (user id) in your request"};
var NO_USER_FOUND = {err: "No user found."};
/*
beforeEach auth interceptor
*/
api.auth = function(req, res, next) {
var token, uid;
uid = req.headers['x-api-user'];
token = req.headers['x-api-key'];
if (!(uid && token)) {
return res.json(401, NO_TOKEN_OR_UID);
}
return User.findOne({
_id: uid,
apiToken: token
}, function(err, user) {
if (err) {
return res.json(500, {
err: err
});
}
if (_.isEmpty(user)) {
return res.json(401, NO_USER_FOUND);
}
res.locals.wasModified = req.query._v ? +user._v !== +req.query._v : true;
res.locals.user = user;
req.session.userId = user._id;
return next();
});
};
api.registerUser = function(req, res, next) {
var confirmPassword, e, email, password, username, _ref;
_ref = req.body, email = _ref.email, username = _ref.username, password = _ref.password, confirmPassword = _ref.confirmPassword;
if (!(username && password && email)) {
return res.json(401, {err: ":username, :email, :password, :confirmPassword required"});
}
if (password !== confirmPassword) {
return res.json(401, {err: ":password and :confirmPassword don't match"});
}
try {
validator.check(email).isEmail();
} catch (err) {
return res.json(401, {err: err.message});
}
async.waterfall([
function(cb) {
User.findOne({'auth.local.email': email}, cb);
},
function(found, cb) {
if (found) {
return cb("Email already taken");
}
User.findOne({'auth.local.username': username}, cb);
}, function(found, cb) {
var newUser, salt, user;
if (found) {
return cb("Username already taken");
}
newUser = helpers.newUser(true);
salt = utils.makeSalt();
newUser.auth = {
local: {
username: username,
email: email,
salt: salt
},
timestamps: {created: +new Date(), loggedIn: +new Date()}
};
newUser.auth.local.hashed_password = utils.encryptPassword(password, salt);
user = new User(newUser);
user.save(cb);
}
], function(err, saved) {
if (err) {
return res.json(401, {err: err});
}
res.json(200, saved);
});
};
/*
Register new user with uname / password
*/
api.loginLocal = function(req, res, next) {
var username = req.body.username;
var password = req.body.password;
if (!(username && password)) return res.json(401, {err:'Missing :username or :password in request body, please provide both'});
User.findOne({'auth.local.username': username}, function(err, user){
if (err) return res.json(500,{err:err});
if (!user) return res.json(401, {err:"Username '" + username + "' not found. Usernames are case-sensitive, click 'Forgot Password' if you can't remember the capitalization."});
// We needed the whole user object first so we can get his salt to encrypt password comparison
User.findOne({
'auth.local.username': username,
'auth.local.hashed_password': utils.encryptPassword(password, user.auth.local.salt)
}, function(err, user){
if (err) return res.json(500,{err:err});
if (!user) return res.json(401,{err:'Incorrect password'});
res.json({id: user._id,token: user.apiToken});
});
});
};
/*
POST /user/auth/facebook
*/
api.loginFacebook = function(req, res, next) {
var email, facebook_id, name, _ref;
_ref = req.body, facebook_id = _ref.facebook_id, email = _ref.email, name = _ref.name;
if (!facebook_id) {
return res.json(401, {
err: 'No facebook id provided'
});
}
return User.findOne({
'auth.local.facebook.id': facebook_id
}, function(err, user) {
if (err) {
return res.json(401, {
err: err
});
}
if (user) {
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."
});
}
});
};
api.resetPassword = function(req, res, next){
var email = req.body.email,
salt = utils.makeSalt(),
newPassword = utils.makeSalt(), // use a salt as the new password too (they'll change it later)
hashed_password = utils.encryptPassword(newPassword, salt);
User.findOne({'auth.local.email':email}, function(err, user){
if (err) return res.json(500,{err:err});
if (!user) return res.send(500, {err:"Couldn't find a user registered for email " + email});
user.auth.local.salt = salt;
user.auth.local.hashed_password = hashed_password;
utils.sendEmail({
from: "HabitRPG <admin@habitrpg.com>",
to: email,
subject: "Password Reset for HabitRPG",
text: "Password for " + user.auth.local.username + " has been reset to " + newPassword + ". Log in at " + nconf.get('BASE_URL'),
html: "Password for <strong>" + user.auth.local.username + "</strong> has been reset to <strong>" + newPassword + "</strong>. Log in at " + nconf.get('BASE_URL')
});
user.save();
return res.send('New password sent to '+ email);
});
};
api.changePassword = function(req, res, next) {
var user = res.locals.user,
oldPassword = req.body.oldPassword,
newPassword = req.body.newPassword,
confirmNewPassword = req.body.confirmNewPassword;
if (newPassword != confirmNewPassword)
return res.json(500, {err: "Password & Confirm don't match"});
var salt = user.auth.local.salt,
hashed_old_password = utils.encryptPassword(oldPassword, salt),
hashed_new_password = utils.encryptPassword(newPassword, salt);
if (hashed_old_password !== user.auth.local.hashed_password)
return res.json(500, {err:"Old password doesn't match"});
user.auth.local.hashed_password = hashed_new_password;
user.save(function(err, saved){
if (err) res.json(500,{err:err});
res.send(200);
})
}
/*
Registers a new user. Only accepting username/password registrations, no Facebook
*/
api.setupPassport = function(router) {
router.get('/logout', function(req, res) {
req.logout();
delete req.session.userId;
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'),
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' }),
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);
var newUser = helpers.newUser(true);
newUser.auth = {
facebook: req.user,
timestamps: {created: +new Date(), loggedIn: +new Date()}
};
user = new User(newUser);
user.save(cb);
}
], 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')
// }
};
+341
View File
@@ -0,0 +1,341 @@
// @see ../routes for routing
var _ = require('lodash');
var nconf = require('nconf');
var async = require('async');
var algos = require('habitrpg-shared/script/algos');
var helpers = require('habitrpg-shared/script/helpers');
var items = require('habitrpg-shared/script/items');
var User = require('./../models/user').model;
var Group = require('./../models/group').model;
var Challenge = require('./../models/challenge').model;
var api = module.exports;
/*
------------------------------------------------------------------------
Challenges
------------------------------------------------------------------------
*/
api.list = function(req, res) {
var user = res.locals.user;
async.waterfall([
function(cb){
// Get all available groups I belong to
Group.find({members: {$in: [user._id]}}).select('_id').exec(cb);
},
function(gids, cb){
// and their challenges
Challenge.find({
$or:[
{leader: user._id},
{members:{$in:[user._id]}}, // all challenges I belong to (is this necessary? thought is a left a group, but not its challenge)
{group:{$in:gids}}, // all challenges in my groups
{group: 'habitrpg'} // public group
]
})
.select('name description group members prize')
.populate('group', '_id name')
.exec(cb);
}
], function(err, challenges){
if (err) return res.json(500,{err:err});
_.each(challenges, function(c){
c._isMember = !!~c.members.indexOf(user._id);
c.memberCount = _.size(c.members);
c.members = undefined;
})
res.json(challenges);
});
}
// GET
api.get = function(req, res) {
// TODO use mapReduce() or aggregate() here to
// 1) Find the sum of users.tasks.values within the challnege (eg, {'profile.name':'tyler', 'sum': 100})
// 2) Sort by the sum
// 3) Limit 30 (only show the 30 users currently in the lead)
Challenge.findById(req.params.cid)
.populate('members', 'profile.name _id')
.exec(function(err, challenge){
if(err) return res.json(500, {err:err});
if (!challenge) return res.json(404, {err: 'Challenge ' + req.params.cid + ' not found'});
res.json(challenge);
})
}
api.getMember = function(req, res) {
var cid = req.params.cid, uid = req.params.uid;
var elMatch = {$elemMatch:{'challenge.id':cid}};
User.findById(uid)
.select({'profile.name':1, habits:elMatch, dailys:elMatch, rewards:elMatch, todos:elMatch})
.exec(function(err, member){
if(err) return res.json(500, {err:err});
if (!member) return res.json(404, {err: 'Member '+uid+' for challenge '+cid+' not found'});
res.json(member);
})
}
// CREATE
api.create = function(req, res){
var user = res.locals.user;
var group, chal;
// First, make sure they've selected a legit group, and store it for later
var waterfall = [
function(cb){
Group.findById(req.body.group).exec(cb);
},
function(_group, cb){
if (!_group) return cb("Group." + req.body.group + " not found");
group = _group;
cb(null);
}
];
// If they're adding a prize, do some validation
if (+req.body.prize < 0) return res.json(401, {err: 'Challenge prize must be >= 0'});
if (req.body.group=='habitrpg' && +req.body.prize < 1) return res.json(401, {err: 'Prize must be at least 1 Gem for public challenges.'});
if (+req.body.prize > 0) {
waterfall.push(function(cb){
var groupBalance = ((group.balance && group.leader==user._id) ? group.balance : 0);
if (req.body.prize > (user.balance*4 + groupBalance*4))
return cb("Challenge.prize > (your gems + group balance). Purchase more gems or lower prize amount.s")
var net = req.body.prize/4; // I really should have stored user.balance as gems rather than dollars... stupid...
// user is group leader, and group has balance. Subtract from that first, then take the rest from user
if (groupBalance > 0) {
group.balance -= net;
if (group.balance < 0) {
net = Math.abs(group.balance);
group.balance = 0;
}
}
user.balance -= net;
cb(null)
});
}
waterfall = waterfall.concat([
function(cb) { // if we're dealing with prize above, arguemnts will be `group, numRows, cb` - else `cb`
var chal = new Challenge(req.body); // FIXME sanitize
chal.members.push(user._id);
chal.save(cb)
},
function(_chal, num, cb){
chal = _chal;
group.challenges.push(chal._id);
group.save(cb);
},
function(_group, num, cb) {
// Auto-join creator to challenge (see members.push above)
chal.syncToUser(user, cb);
}
]);
async.waterfall(waterfall, function(err){
if (err) return res.json(500, {err:err});
res.json(chal);
});
}
// UPDATE
api.update = function(req, res){
var cid = req.params.cid;
var user = res.locals.user;
var before;
async.waterfall([
function(cb){
// We first need the original challenge data, since we're going to compare against new & decide to sync users
Challenge.findById(cid, cb);
},
function(_before, cb) {
if (!_before) return cb('Challenge ' + cid + ' not found');
if (_before.leader != user._id) return cb("You don't have permissions to edit this challenge");
// Update the challenge, since syncing will need the updated challenge. But store `before` we're going to do some
// before-save / after-save comparison to determine if we need to sync to users
before = _before;
var attrs = _.pick(req.body, 'name shortName description habits dailys todos rewards date'.split(' '));
Challenge.findByIdAndUpdate(cid, {$set:attrs}, cb);
},
function(saved, cb) {
// after saving, we're done as far as the client's concerned. We kick of syncing (heavy task) in the background
cb(null, saved);
// Compare whether any changes have been made to tasks. If so, we'll want to sync those changes to subscribers
if (before.isOutdated(req.body)) {
User.find({_id: {$in: saved.members}}, function(err, users){
console.log('Challenge updated, sync to subscribers');
if (err) throw err;
_.each(users, function(user){
saved.syncToUser(user);
})
})
}
}
], function(err, saved){
if(err) res.json(500, {err:err});
res.json(saved);
})
}
/**
* Called by either delete() or selectWinner(). Will delete the challenge and set the "broken" property on all users' subscribed tasks
* @param {cid} the challenge id
* @param {broken} the object representing the broken status of the challenge. Eg:
* {broken: 'CHALLENGE_DELETED', id: CHALLENGE_ID}
* {broken: 'CHALLENGE_CLOSED', id: CHALLENGE_ID, winner: USER_NAME}
*/
function closeChal(cid, broken, cb) {
var removed;
async.waterfall([
function(cb2){
Challenge.findOneAndRemove({_id:cid}, cb2)
},
function(_removed, cb2) {
removed = _removed;
var pull = {'$pull':{}}; pull['$pull'][_removed._id] = 1;
Group.findByIdAndUpdate(_removed.group, pull);
User.find({_id:{$in: removed.members}}, cb2);
},
function(users, cb2) {
var parallel = [];
_.each(users, function(user){
var tag = _.find(user.tags, {id:cid});
if (tag) tag.challenge = undefined;
_.each(user.tasks, function(task){
if (task.challenge && task.challenge.id == removed._id) {
_.merge(task.challenge, broken);
}
})
parallel.push(function(cb3){
user.save(cb3);
})
})
async.parallel(parallel, cb2);
}
], cb);
}
/**
* Delete & close
*/
api['delete'] = function(req, res){
var user = res.locals.user;
var cid = req.params.cid;
async.waterfall([
function(cb){
Challenge.findById(cid, cb);
},
function(chal, cb){
if (!chal) return cb('Challenge ' + cid + ' not found');
if (chal.leader != user._id) return cb("You don't have permissions to edit this challenge");
closeChal(req.params.cid, {broken: 'CHALLENGE_DELETED'}, cb);
}
], function(err){
if (err) return res.json(500, {err: err});
res.send(200);
});
}
/**
* Select Winner & Close
*/
api.selectWinner = function(req, res) {
if (!req.query.uid) return res.json(401, {err: 'Must select a winner'});
var user = res.locals.user;
var cid = req.params.cid;
var chal;
async.waterfall([
function(cb){
Challenge.findById(cid, cb);
},
function(_chal, cb){
chal = _chal;
if (!chal) return cb('Challenge ' + cid + ' not found');
if (chal.leader != user._id) return cb("You don't have permissions to edit this challenge");
User.findById(req.query.uid, cb)
},
function(winner, cb){
if (!winner) return cb('Winner ' + req.query.uid + ' not found.');
_.defaults(winner.achievements, {challenges: []});
winner.achievements.challenges.push(chal.name);
winner.balance += chal.prize/4;
winner.save(cb);
},
function(saved, num, cb) {
closeChal(cid, {broken: 'CHALLENGE_CLOSED', winner: saved.profile.name}, cb);
}
], function(err){
if (err) return res.json(500, {err: err});
res.send(200);
})
}
api.join = function(req, res){
var user = res.locals.user;
var cid = req.params.cid;
async.waterfall([
function(cb){
Challenge.findByIdAndUpdate(cid, {$addToSet:{members:user._id}}, cb);
},
function(challenge, cb){
if (!~user.challenges.indexOf(cid))
user.challenges.unshift(cid);
// Add all challenge's tasks to user's tasks
challenge.syncToUser(user, function(err){
if (err) return cb(err);
cb(null, challenge); // we want the saved challenge in the return results, due to ng-resource
});
}
], function(err, result){
if(err) return res.json(500,{err:err});
result._isMember = true;
res.json(result);
});
}
api.leave = function(req, res){
var user = res.locals.user;
var cid = req.params.cid;
// whether or not to keep challenge's tasks. strictly default to true if "keep-all" isn't provided
var keep = (/^remove-all/i).test(req.query.keep) ? 'remove-all' : 'keep-all';
async.waterfall([
function(cb){
Challenge.findByIdAndUpdate(cid, {$pull:{members:user._id}}, cb);
},
function(chal, cb){
var i = user.challenges.indexOf(cid)
if (~i) user.challenges.splice(i,1);
user.unlink({cid:chal._id, keep:keep}, function(err){
if (err) return cb(err);
cb(null, chal);
})
}
], function(err, result){
if(err) return res.json(500,{err:err});
result._isMember = false;
res.json(result);
});
}
api.unlink = function(req, res, next) {
// they're scoring the task - commented out, we probably don't need it due to route ordering in api.js
//var urlParts = req.originalUrl.split('/');
//if (_.contains(['up','down'], urlParts[urlParts.length -1])) return next();
var user = res.locals.user;
var tid = req.params.id;
var cid = user.tasks[tid].challenge.id;
if (!req.query.keep)
return res.json(400, {err: 'Provide unlink method as ?keep=keep-all (keep, keep-all, remove, remove-all)'});
user.unlink({cid:cid, keep:req.query.keep, tid:tid}, function(err, saved){
if (err) return res.json(500,{err:err});
res.send(200);
});
}
+76
View File
@@ -0,0 +1,76 @@
var express = require('express');
var router = new express.Router();
var _ = require('lodash');
var icalendar = require('icalendar');
var api = require('./user');
var auth = require('./auth');
/* ---------- Deprecated Paths ------------*/
var deprecatedMessage = 'This API is no longer supported, see https://github.com/lefnire/habitrpg/wiki/API for new protocol';
router.get('/:uid/up/:score?', function(req, res) {
return res.send(500, deprecatedMessage);
});
router.get('/:uid/down/:score?', function(req, res) {
return res.send(500, deprecatedMessage);
});
router.post('/users/:uid/tasks/:taskId/:direction', function(req, res) {
return res.send(500, deprecatedMessage);
});
/* Redirect to new API*/
var initDeprecated = function(req, res, next) {
req.headers['x-api-user'] = req.params.uid;
req.headers['x-api-key'] = req.body.apiToken;
return next();
};
router.post('/v1/users/:uid/tasks/:taskId/:direction', initDeprecated, auth.auth, api.scoreTask);
router.get('/v1/users/:uid/calendar.ics', function(req, res, next) {
return next() //disable for now
var apiToken, model, query, uid;
uid = req.params.uid;
apiToken = req.query.apiToken;
model = req.getModel();
query = model.query('users').withIdAndToken(uid, apiToken);
return query.fetch(function(err, result) {
var formattedIcal, ical, tasks, tasksWithDates;
if (err) {
return res.send(500, err);
}
tasks = result.get('tasks');
/* tasks = result[0].tasks*/
tasksWithDates = _.filter(tasks, function(task) {
return !!task.date;
});
if (_.isEmpty(tasksWithDates)) {
return res.send(500, "No events found");
}
ical = new icalendar.iCalendar();
ical.addProperty('NAME', 'HabitRPG');
_.each(tasksWithDates, function(task) {
var d, event;
event = new icalendar.VEvent(task.id);
event.setSummary(task.text);
d = new Date(task.date);
d.date_only = true;
event.setDate(d);
ical.addComponent(event);
return true;
});
res.type('text/calendar');
formattedIcal = ical.toString().replace(/DTSTART\:/g, 'DTSTART;VALUE=DATE:');
return res.send(200, formattedIcal);
});
});
module.exports = router;
+417
View File
@@ -0,0 +1,417 @@
// @see ../routes for routing
var _ = require('lodash');
var nconf = require('nconf');
var async = require('async');
var algos = require('habitrpg-shared/script/algos');
var helpers = require('habitrpg-shared/script/helpers');
var items = require('habitrpg-shared/script/items');
var User = require('./../models/user').model;
var Group = require('./../models/group').model;
var api = module.exports;
/*
------------------------------------------------------------------------
Groups
------------------------------------------------------------------------
*/
var itemFields = 'items.armor items.head items.shield items.weapon items.currentPet items.pets'; // TODO just send down count(items.pets) for better performance
var partyFields = 'profile preferences stats achievements party backer contributor balance flags.rest auth.timestamps ' + itemFields;
var nameFields = 'profile.name';
var challengeFields = '_id name';
var guildPopulate = {path: 'members', select: nameFields, options: {limit: 15} };
/**
* For parties, we want a lot of member details so we can show their avatars in the header. For guilds, we want very
* limited fields - and only a sampling of the members, beacuse they can be in the thousands
* @param type: 'party' or otherwise
* @param q: the Mongoose query we're building up
*/
var populateQuery = function(type, q){
if (type == 'party')
q.populate('members', partyFields);
else
q.populate(guildPopulate);
q.populate('invites', nameFields);
q.populate('challenges', challengeFields);
return q;
}
api.getMember = function(req, res) {
User.findById(req.params.uid).select(partyFields).exec(function(err, user){
if (err) return res.json(500,{err:err});
if (!user) return res.json(400,{err:'User not found'});
res.json(user);
})
}
api.updateMember = function(req, res) {
var user = res.locals.user;
if (!(user.contributor && user.contributor.admin)) return res.json(401, {err:"You don't have access to save this user"});
async.waterfall([
function(cb){
User.findById(req.params.uid, cb);
},
function(member, cb){
if (!member) return res.json(404, {err: "User not found"});
if (req.body.contributor.level > (member.contributor && member.contributor.level || 0)) {
member.flags.contributor = true;
member.balance += (req.body.contributor.level - (member.contributor.level || 0))*.5 // +2 gems per tier
}
_.merge(member, _.pick(req.body, 'contributor'));
if (!member.items.pets) member.items.pets = [];
var i = member.items.pets.indexOf('Dragon-Hydra');
if (!~i && member.contributor.level >= 6) member.items.pets.push('Dragon-Hydra');
member.save(cb);
}
], function(err, saved){
if (err) return res.json(500,{err:err});
res.json(204);
})
}
/**
* Fetch groups list. This no longer returns party or tavern, as those can be requested indivdually
* as /groups/party or /groups/tavern
*/
api.list = function(req, res) {
var user = res.locals.user;
var groupFields = 'name description memberCount balance leader';
var sort = '-memberCount';
var type = req.query.type || 'party,guilds,public,tavern';
async.parallel({
// unecessary given our ui-router setup
party: function(cb){
if (!~type.indexOf('party')) return cb(null, {});
Group.findOne({type: 'party', members: {'$in': [user._id]}})
.select(groupFields).exec(function(err, party){
if (err) return cb(err);
cb(null, (party === null ? [] : [party])); // return as an array for consistent ngResource use
});
},
guilds: function(cb) {
if (!~type.indexOf('guilds')) return cb(null, []);
Group.find({members: {'$in': [user._id]}, type:'guild'})
.select(groupFields).sort(sort).exec(cb);
},
'public': function(cb) {
if (!~type.indexOf('public')) return cb(null, []);
Group.find({privacy: 'public'})
.select(groupFields + ' members')
.sort(sort)
.exec(function(err, groups){
if (err) return cb(err);
_.each(groups, function(g){
// To save some client-side performance, don't send down the full members arr, just send down temp var _isMember
if (~g.members.indexOf(user._id)) g._isMember = true;
g.members = undefined;
});
cb(null, groups);
});
},
// unecessary given our ui-router setup
tavern: function(cb) {
if (!~type.indexOf('tavern')) return cb(null, {});
Group.findById('habitrpg').select(groupFields).exec(function(err, tavern){
if (err) return cb(err);
cb(null, [tavern]); // return as an array for consistent ngResource use
});
}
}, function(err, results){
if (err) return res.json(500, {err: err});
// ngResource expects everything as arrays. We used to send it down as a structured object: {public:[], party:{}, guilds:[], tavern:{}}
// but unfortunately ngResource top-level attrs are considered the ngModels in the list, so we had to do weird stuff and multiple
// requests to get it to work properly. Instead, we're not depending on the client to do filtering / organization, and we're
// just sending down a merged array. Revisit
var arr = _.reduce(results, function(m,v){
if (_.isEmpty(v)) return m;
return m.concat(_.isArray(v) ? v : [v]);
}, [])
res.json(arr);
})
};
/**
* Get group
* TODO: implement requesting fields ?fields=chat,members
*/
api.get = function(req, res) {
var user = res.locals.user;
var gid = req.params.gid;
var q = (gid == 'party') ? Group.findOne({type: 'party', members: {'$in': [user._id]}}) : Group.findById(gid);
populateQuery(gid, q);
q.exec(function(err, group){
if (group && ((group.type == 'guild' && group.privacy == 'private') || (group.type == 'party'))) {
if(!_.find(group.members, {_id: user._id}))
return res.json(401, {err: "You don't have access to this group"});
}
res.json(group);
});
};
api.create = function(req, res, next) {
var group = new Group(req.body);
var user = res.locals.user;
if(group.type === 'guild'){
if(user.balance < 1) return res.json(401, {err: 'Not enough gems!'});
group.balance = 1;
user.balance--;
user.save(function(err){
if(err) return res.json(500,{err:err});
group.save(function(err, saved){
if (err) return res.json(500,{err:err});
saved.populate('members', nameFields, function(err, populated){
if (err) return res.json(500,{err:err});
return res.json(populated);
});
});
});
}else{
group.save(function(err, saved){
if (err) return res.json(500,{err:err});
saved.populate('members', nameFields, function(err, populated){
if (err) return res.json(500,{err:err});
return res.json(populated);
});
});
}
}
api.update = function(req, res, next) {
var group = res.locals.group;
var user = res.locals.user;
if(group.leader !== user._id)
return res.json(401, {err: "Only the group leader can update the group!"});
'name description logo websites logo leaderMessage leader'.split(' ').forEach(function(attr){
group[attr] = req.body[attr];
});
group.save(function(err, saved){
if (err) return res.json(500,{err:err});
res.send(204);
});
}
api.attachGroup = function(req, res, next) {
Group.findById(req.params.gid, function(err, group){
if(err) return res.json(500, {err:err});
res.locals.group = group;
next();
})
}
/**
* TODO make this it's own ngResource so we don't have to send down group data with each chat post
*/
api.postChat = function(req, res, next) {
var user = res.locals.user
var group = res.locals.group;
var message = {
id: helpers.uuid(),
uuid: user._id,
contributor: user.contributor && user.contributor.toObject(),
backer: user.backer && user.backer.toObject(),
text: req.query.message, // FIXME this should be body, but ngResource is funky
user: user.profile.name,
timestamp: +(new Date)
};
var lastClientMsg = req.query.previousMsg;
var chatUpdated = (lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg) ? true : false;
group.chat.unshift(message);
group.chat.splice(200);
if (group.type === 'party') {
user.party.lastMessageSeen = message.id;
user.save();
}
group.save(function(err, saved){
if (err) return res.json(500, {err:err});
return chatUpdated ? res.json({chat: group.chat}) : res.json({message: saved.chat[0]});
});
}
api.deleteChatMessage = function(req, res){
var user = res.locals.user
var group = res.locals.group;
var message = _.find(group.chat, {id: req.params.messageId});
if(!message) return res.json(404, {err: "Message not found!"});
if(user._id !== message.uuid && !(user.backer && user.contributor.admin))
return res.json(401, {err: "Not authorized to delete this message!"})
var lastClientMsg = req.query.previousMsg;
var chatUpdated = (lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg) ? true : false;
Group.update({_id:group._id}, {$pull:{chat:{id: req.params.messageId}}}, function(err){
if(err) return res.json(500, {err: err});
return chatUpdated ? res.json({chat: group.chat}) : res.send(204);
});
}
api.join = function(req, res) {
var user = res.locals.user,
group = res.locals.group;
if (group.type == 'party' && group._id == (user.invitations && user.invitations.party && user.invitations.party.id)) {
user.invitations.party = undefined;
user.save();
}
else if (group.type == 'guild' && user.invitations && user.invitations.guilds) {
var i = _.findIndex(user.invitations.guilds, {id:group._id});
if (~i) user.invitations.guilds.splice(i,1);
user.save();
}
if (!_.contains(group.members, user._id)){
group.members.push(user._id);
group.invites.splice(_.indexOf(group.invites, user._id), 1);
}
async.series([
function(cb){
group.save(cb);
},
function(cb){
populateQuery(group.type, Group.findById(group._id)).exec(cb);
}
], function(err, results){
if (err) return res.json(500,{err:err});
// Return the group? Or not?
res.json(results[1]);
});
}
api.leave = function(req, res, next) {
var user = res.locals.user,
group = res.locals.group;
Group.update({_id:group._id},{$pull:{members:user._id}}, function(err, saved){
if (err) return res.json(500,{err:err});
return res.send(204);
});
}
api.invite = function(req, res, next) {
var group = res.locals.group;
var uuid = req.query.uuid;
var user = res.locals.user;
User.findById(uuid, function(err,invite){
if (err) return res.json(500,{err:err});
if (!invite)
return res.json(400,{err:'User with id "' + uuid + '" not found'});
if (group.type == 'guild') {
if (_.contains(group.members,uuid))
return res.json(400,{err: "User already in that group"});
if (invite.invitations && invite.invitations.guilds && _.find(invite.invitations.guilds, {id:group._id}))
return res.json(400, {err:"User already invited to that group"});
sendInvite();
} else if (group.type == 'party') {
if (invite.invitations && !_.isEmpty(invite.invitations.party))
return res.json(400,{err:"User already pending invitation."});
Group.find({type:'party', members:{$in:[uuid]}}, function(err, groups){
if (err) return res.json(500,{err:err});
if (!_.isEmpty(groups))
return res.json(400,{err:"User already in a party."})
sendInvite();
});
}
function sendInvite (){
if(group.type === 'guild'){
invite.invitations.guilds.push({id: group._id, name: group.name});
}else{
//req.body.type in 'guild', 'party'
invite.invitations.party = {id: group._id, name: group.name}
}
group.invites.push(invite._id);
async.series([
function(cb){
invite.save(cb);
},
function(cb){
group.save(cb);
},
function(cb){
populateQuery(group.type, Group.findById(group._id)).exec(cb);
}
], function(err, results){
if (err) return res.json(500,{err:err});
// Have to return whole group and its members for angular to show the invited user
res.json(results[2]);
});
}
});
}
api.removeMember = function(req, res, next){
var group = res.locals.group;
var uuid = req.query.uuid;
var user = res.locals.user;
if(group.leader !== user._id){
return res.json(401, {err: "Only group leader can remove a member!"});
}
if(_.contains(group.members, uuid)){
Group.update({_id:group._id},{$pull:{members:uuid}}, function(err, saved){
if (err) return res.json(500,{err:err});
// Sending an empty 204 because Group.update doesn't return the group
// see http://mongoosejs.com/docs/api.html#model_Model.update
return res.send(204);
});
}else if(_.contains(group.invites, uuid)){
User.findById(uuid, function(err,invited){
var invitations = invited.invitations;
if(group.type === 'guild'){
invitations.guilds.splice(_.indexOf(invitations.guilds, group._id), 1);
}else{
invitations.party = undefined;
}
async.series([
function(cb){
invited.save(cb);
},
function(cb){
Group.update({_id:group._id},{$pull:{invites:uuid}}, cb);
}
], function(err, results){
if (err) return res.json(500,{err:err});
// Sending an empty 204 because Group.update doesn't return the group
// see http://mongoosejs.com/docs/api.html#model_Model.update
return res.send(204);
});
});
}else{
return res.json(400, {err: "User not found among group's members!"});
}
}
+595
View File
@@ -0,0 +1,595 @@
/* @see ./routes.coffee for routing*/
var url = require('url');
var ipn = require('paypal-ipn');
var _ = require('lodash');
var nconf = require('nconf');
var async = require('async');
var algos = require('habitrpg-shared/script/algos');
var helpers = require('habitrpg-shared/script/helpers');
var items = require('habitrpg-shared/script/items');
var validator = require('validator');
var check = validator.check;
var sanitize = validator.sanitize;
var User = require('./../models/user').model;
var Group = require('./../models/group').model;
var Challenge = require('./../models/challenge').model;
var api = module.exports;
// FIXME put this in a proper location
api.marketBuy = function(req, res, next){
var user = res.locals.user,
type = req.query.type,
item = req.body;
if (!_.contains(['hatchingPotion', 'egg'], req.query.type))
return res.json(400, {err: "Type must be in 'hatchingPotion' or 'egg'"});
var item;
if (type == 'egg'){
if (!user.items && !user.items.eggs) user.items.eggs = [];
user.items.eggs.push(item);
} else {
if (!user.items && !user.items.hatchingPotions) user.items.hatchingPotions = [];
user.items.hatchingPotions.push(item.name);
}
user.markModified('items'); // I still don't get when this is necessary and when not..
user.balance -= (item.value/4);
user.save(function(err, saved){
if (err) return res.json(500, {err:err});
res.json(saved);
})
}
/*
------------------------------------------------------------------------
Tasks
------------------------------------------------------------------------
*/
/*
Local Methods
---------------
*/
/*
Validate task
*/
api.verifyTaskExists = function(req, res, next) {
// If we're updating, get the task from the user
var task = res.locals.user.tasks[req.params.id];
if (_.isEmpty(task)) return res.json(400, {err: "No task found."});
res.locals.task = task;
return next();
};
function addTask(user, task) {
task = helpers.taskDefaults(task);
user[task.type+'s'].unshift(task);
return task;
}
/*
API Routes
---------------
*/
/**
This is called form deprecated.coffee's score function, and the req.headers are setup properly to handle the login
Export it also so we can call it from deprecated.coffee
*/
api.scoreTask = function(req, res, next) {
var id = req.params.id,
direction = req.params.direction,
user = res.locals.user,
task;
// Send error responses for improper API call
if (!id) return res.json(500, {err: ':id required'});
if (direction !== 'up' && direction !== 'down') {
if (direction == 'unlink') return next();
return res.json(500, {err: ":direction must be 'up' or 'down'"});
}
// If exists already, score it
if (task = user.tasks[id]) {
// Set completed if type is daily or todo and task exists
if (task.type === 'daily' || task.type === 'todo') {
task.completed = direction === 'up';
}
} else {
// If it doesn't exist, this is likely a 3rd party up/down - create a new one, then score it
task = {
id: id,
value: 0,
type: req.body.type || 'habit',
text: req.body.title || id,
notes: "This task was created by a third-party service. Feel free to edit, it won't harm the connection to that service. Additionally, multiple services may piggy-back off this task."
};
if (task.type === 'habit') {
task.up = task.down = true;
}
if (task.type === 'daily' || task.type === 'todo') {
task.completed = direction === 'up';
}
task = addTask(user, task);
}
var delta = algos.score(user, task, direction);
//user.markModified('flags');
user.save(function(err, saved) {
if (err) return res.json(500, {err: err});
res.json(200, _.extend({
delta: delta
}, saved.toJSON().stats));
});
// if it's a challenge task, sync the score
user.syncScoreToChallenge(task, delta);
};
/**
* Get all tasks
*/
api.getTasks = function(req, res, next) {
var user = res.locals.user;
if (req.query.type) {
return res.json(user[req.query.type+'s']);
} else {
return res.json(_.toArray(user.tasks));
}
};
/**
* Get Task
*/
api.getTask = function(req, res, next) {
var task = res.locals.user.tasks[req.params.id];
if (_.isEmpty(task)) return res.json(400, {err: "No task found."});
return res.json(200, task);
};
/**
* Delete Task
*/
api.deleteTask = function(req, res, next) {
var user = res.locals.user;
user.deleteTask(res.locals.task.id);
user.save(function(err) {
if (err) return res.json(500, {err: err});
res.send(204);
});
};
/*
Update Task
*/
api.updateTask = function(req, res, next) {
var user = res.locals.user;
var tid = req.params.id;
var task = user.tasks[req.params.id];
_.merge(task, req.body);
user.save(function(err, saved) {
if (err) return res.json(500, {err: err})
return res.json(200, task);
});
};
api.createTask = function(req, res, next) {
var user = res.locals.user;
var task = addTask(user, req.body);
user.save(function(err, saved) {
if (err) return res.json(500, {err: err});
return res.json(201, task);
});
};
api.sortTask = function(req, res, next) {
var id = req.params.id;
var to = req.body.to, from = req.body.from, type = req.body.type;
var user = res.locals.user;
user[type+'s'].splice(to, 0, user[type+'s'].splice(from, 1)[0]);
user.save(function(err, saved) {
if (err) return res.json(500, {err: err});
return res.json(200, saved.toJSON()[type+'s']);
});
};
api.clearCompleted = function(req, res, next) {
var user = res.locals.user;
user.todos = _.where(user.todos, {completed: false});
return user.save(function(err, saved) {
if (err) return res.json(500, {err: err});
return res.json(saved);
});
};
/*
------------------------------------------------------------------------
Items
------------------------------------------------------------------------
*/
api.buy = function(req, res, next) {
var hasEnough, type, user;
user = res.locals.user;
type = req.params.type;
if (type !== 'weapon' && type !== 'armor' && type !== 'head' && type !== 'shield' && type !== 'potion') {
return res.json(400, {err: ":type must be in one of: 'weapon', 'armor', 'head', 'shield', 'potion'"});
}
hasEnough = items.buyItem(user, type);
if (hasEnough) {
return user.save(function(err, saved) {
if (err) return res.json(500, {err: err});
return res.json(200, saved.toJSON().items);
});
} else {
return res.json(200, {err: "Not enough GP"});
}
};
/*
------------------------------------------------------------------------
User
------------------------------------------------------------------------
*/
/**
* Get User
*/
api.getUser = function(req, res, next) {
var user = res.locals.user.toJSON();
user.stats.toNextLevel = algos.tnl(user.stats.lvl);
user.stats.maxHealth = 50;
delete user.apiToken;
if (user.auth) {
delete user.auth.hashed_password;
delete user.auth.salt;
}
return res.json(200, user);
};
/**
* Update user
* FIXME add documentation here
*/
api.updateUser = function(req, res, next) {
var acceptableAttrs, errors, user;
user = res.locals.user;
errors = [];
if (_.isEmpty(req.body)) {
return res.json(200, user);
}
/*
# FIXME we need to do some crazy sanitiazation if they're using the old `PUT /user {data}` method.
# The new `PUT /user {'stats.hp':50}
# FIXME - one-by-one we want to widdle down this list, instead replacing each needed set path with API operations
# There's a trick here. In order to prevent prevent clobering top-level paths, we add `.` to make sure they're
# sending bodies as {"set.this.path":value} instead of {set:{this:{path:value}}}. Permit lastCron since it's top-level
# Note: custom is for 3rd party apps
*/
acceptableAttrs = 'tasks. achievements. filters. flags. invitations. items. lastCron party. preferences. profile. stats. tags custom.'.split(' ');
_.each(req.body, function(v, k) {
if ((_.find(acceptableAttrs, function(attr) {
return k.indexOf(attr) === 0;
})) != null) {
if (_.isObject(v)) {
errors.push("Value for " + k + " was an object. Be careful here, you could clobber stuff.");
}
helpers.dotSet(k, v, user);
} else {
errors.push("path `" + k + "` was not saved, as it's a protected path. Make sure to send `PUT /api/v1/user` request bodies as `{'set.this.path':value}` instead of `{set:{this:{path:value}}}`");
}
return true;
});
return user.save(function(err) {
if (!_.isEmpty(errors)) {
return res.json(500, {
err: errors
});
}
if (err) {
return res.json(500, {
err: err
});
}
return res.json(200, user);
});
};
api.cron = function(req, res, next) {
var user = res.locals.user;
algos.cron(user);
if (user.isModified()) {
res.locals.wasModified = true;
user.auth.timestamps.loggedin = new Date();
}
user.save(next);
};
api.revive = function(req, res, next) {
var user = res.locals.user;
algos.revive(user);
user.save(function(err, saved) {
if (err) return res.json(500, {err: err});
return res.json(200, saved);
});
};
api.reroll = function(req, res, next) {
var user = res.locals.user;
if (user.balance < 1) return res.json(401, {err: "Not enough tokens."});
user.balance -= 1;
_.each(['habits','dailys','todos'], function(type){
_.each(user[type], function(task){
task.value = 0;
})
})
user.stats.hp = 50;
user.save(function(err, saved) {
if (err) return res.json(500, {err: err});
return res.json(200, saved);
});
};
api.reset = function(req, res){
var user = res.locals.user;
user.habits = [];
user.dailys = [];
user.todos = [];
user.rewards = [];
user.stats.hp = 50;
user.stats.lvl = 1;
user.stats.gp = 0;
user.stats.exp = 0;
user.items.armor = 0;
user.items.weapon = 0;
user.items.head = 0;
user.items.shield = 0;
user.save(function(err, saved){
if (err) return res.json(500,{err:err});
res.json(saved);
})
}
api['delete'] = function(req, res) {
res.locals.user.remove(function(err){
if (err) return res.json(500,{err:err});
res.send(200);
})
}
/*
------------------------------------------------------------------------
Unlock Preferences
------------------------------------------------------------------------
*/
api.unlock = function(req, res) {
var user = res.locals.user;
var path = req.query.path;
var fullSet = ~path.indexOf(',');
// 5G per set, 2G per individual
cost = fullSet ? 1.25 : 0.5;
if (user.balance < cost)
return res.json(401, {err: 'Not enough gems'});
if (fullSet) {
var paths = path.split(',');
_.each(paths, function(p){
helpers.dotSet('purchased.' + p, true, user);
});
} else {
if (helpers.dotGet('purchased.' + path, user) === true)
return res.json(401, {err: 'User already purchased that'});
helpers.dotSet('purchased.' + path, true, user);
}
user.balance -= cost;
user._v++;
user.markModified('purchased');
user.save(function(err, saved){
if (err) res.json(500, {err:err});
res.send(200);
})
}
/*
------------------------------------------------------------------------
Buy Gems
------------------------------------------------------------------------
*/
api.addTenGems = function(req, res) {
var user = res.locals.user;
user.balance += 2.5;
user.save(function(err){
if (err) return res.json(500,{err:err});
res.send(204);
})
}
/*
Setup Stripe response when posting payment
*/
api.buyGems = function(req, res) {
var api_key = nconf.get('STRIPE_API_KEY');
var stripe = require("stripe")(api_key);
var token = req.body.id;
// console.dir {token:token, req:req}, 'stripe'
async.waterfall([
function(cb){
stripe.charges.create({
amount: "500", // $5
currency: "usd",
card: token
}, cb);
},
function(response, cb) {
res.locals.user.balance += 5;
res.locals.user.purchased.ads = true;
res.locals.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, saved);
});
};
api.buyGemsPaypalIPN = function(req, res) {
res.send(200);
ipn.verify(req.body, function callback(err, msg) {
if (err) {
console.error(msg);
res.send(500, msg);
} else {
if (req.body.payment_status == 'Completed') {
//Payment has been confirmed as completed
var parts = url.parse(req.body.custom, true);
var uid = parts.query.uid; //, apiToken = query.apiToken;
if (!uid) throw new Error("uuid or apiToken not found when completing paypal transaction");
User.findById(uid, function(err, user) {
if (err) throw err;
if (_.isEmpty(user)) throw "user not found with uuid " + uuid + " when completing paypal transaction"
user.balance += 5;
user.purchased.ads = true;
user.save();
console.log('PayPal transaction completed and user updated');
});
}
}
});
}
/*
------------------------------------------------------------------------
Tags
------------------------------------------------------------------------
*/
api.deleteTag = function(req, res){
var user = res.locals.user;
var tid = req.params.tid || req.body.tag;
var i = _.findIndex(user.tags, {id:tid});
if (~i) {
var tag = user.tags[i];
delete user.filters[tag.id];
user.tags.splice(i,1);
// remove tag from all tasks
_.each(['habits','dailys','todos','rewards'], function(type){
_.each(user[type], function(task){
delete task.tags[tag.id];
})
})
user.save(function(err,saved){
if (err) return res.json(500, {err: err});
// Need to use this until we found a way to update the ui for tasks when a tag is deleted
res.locals.wasModified = true;
res.send(200);
});
} else {
res.json(400, {err:'Tag not found'});
}
}
/*
------------------------------------------------------------------------
Batch Update
Run a bunch of updates all at once
------------------------------------------------------------------------
*/
api.batchUpdate = function(req, res, next) {
var user = res.locals.user;
var oldSend = res.send;
var oldJson = res.json;
var performAction = function(action, cb) {
// TODO come up with a more consistent approach here. like:
// req.body=action.data; delete action.data; _.defaults(req.params, action)
// Would require changing action.dir on mobile app
req.params.id = action.data && action.data.id;
req.params.direction = action.dir;
req.params.type = action.type;
req.body = action.data;
res.send = res.json = function(code, data) {
if (_.isNumber(code) && code >= 400) {
console.error({
code: code,
data: data
});
}
//FIXME send error messages down
return cb();
};
switch (action.op) {
case "score":
api.scoreTask(req, res);
break;
case "buy":
api.buy(req, res);
break;
case "sortTask":
api.verifyTaskExists(req, res, function() {
api.sortTask(req, res);
});
break;
case "addTask":
api.createTask(req, res);
break;
case "delTask":
api.verifyTaskExists(req, res, function() {
api.deleteTask(req, res);
});
break;
case "set":
api.updateUser(req, res);
break;
case "delTag":
api.deleteTag(req, res);
break;
case "revive":
api.revive(req, res);
break;
case "clear-completed":
api.clearCompleted(req, res);
break;
case "reroll":
api.reroll(req, res);
break;
default:
cb();
break;
}
};
// Setup the array of functions we're going to call in parallel with async
var actions = _.transform(req.body || [], function(result, action) {
if (!_.isEmpty(action)) {
result.push(function(cb) {
performAction(action, cb);
});
}
});
// call all the operations, then return the user object to the requester
async.series(actions, function(err) {
res.json = oldJson;
res.send = oldSend;
if (err) return res.json(500, {err: err});
var response = user.toJSON();
response.wasModified = res.locals.wasModified;
if (response._tmp && response._tmp.drop) response.wasModified = true;
// Send the response to the server
if(response.wasModified){
res.json(200, response);
}else{
res.json(200, {_v: response._v});
}
});
};
+106
View File
@@ -0,0 +1,106 @@
var nconf = require('nconf');
var _ = require('lodash');
var fs = require('fs');
var path = require('path');
module.exports.forceSSL = function(req, res, next){
var baseUrl = nconf.get("BASE_URL");
// Note x-forwarded-proto is used by Heroku & nginx, you'll have to do something different if you're not using those
if (req.headers['x-forwarded-proto'] && req.headers['x-forwarded-proto'] !== 'https'
&& nconf.get('NODE_ENV') === 'production'
&& baseUrl.indexOf('https') === 0) {
return res.redirect(baseUrl + req.url);
}
next()
}
module.exports.splash = function(req, res, next) {
if (req.url == '/' && !req.headers['x-api-user'] && !req.headers['x-api-key'] && !(req.session && req.session.userId))
return res.redirect('/static/front')
next()
};
module.exports.cors = function(req, res, next) {
res.header("Access-Control-Allow-Origin", req.headers.origin || "*");
res.header("Access-Control-Allow-Methods", "OPTIONS,GET,POST,PUT,HEAD,DELETE");
res.header("Access-Control-Allow-Headers", "Content-Type,Accept,Content-Encoding,X-Requested-With,x-api-user,x-api-key");
if (req.method === 'OPTIONS') return res.send(200);
return next();
};
var buildFiles = [];
var walk = function(folder){
var res = fs.readdirSync(folder);
res.forEach(function(fileName){
file = folder + '/' + fileName;
if(fs.statSync(file).isDirectory()){
walk(file);
}else{
var relFolder = path.relative(path.join(__dirname, "/../build"), folder);
var old = fileName.replace(/-.{8}(\.[\d\w]+)$/, '$1');
if(relFolder){
old = relFolder + '/' + old;
fileName = relFolder + '/' + fileName;
}
buildFiles[old] = fileName
}
});
}
walk(path.join(__dirname, "/../build"));
var getBuildUrl = function(url){
if(buildFiles[url]) return '/' + buildFiles[url];
return '/' + url;
}
var manifestFiles = require("../public/manifest.json");
var getManifestFiles = function(page){
var files = manifestFiles[page];
if(!files) throw new Error("Page not found!");
var css = '';
_.each(files.css, function(file){
css += '<link rel="stylesheet" type="text/css" href="' + getBuildUrl(file) + '">';
});
if(nconf.get('NODE_ENV') === 'production'){
return css + '<script type="text/javascript" src="' + getBuildUrl(page + '.js') + '"></script>';
}else{
var results = css;
_.each(files.js, function(file){
results += '<script type="text/javascript" src="' + getBuildUrl(file) + '"></script>';
});
return results;
}
}
module.exports.locals = function(req, res, next) {
res.locals.habitrpg = res.locals.habitrpg || {}
_.defaults(res.locals.habitrpg, {
NODE_ENV: nconf.get('NODE_ENV'),
BASE_URL: nconf.get('BASE_URL'),
PAYPAL_MERCHANT: nconf.get('PAYPAL_MERCHANT'),
IS_MOBILE: /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(req.header('User-Agent')),
STRIPE_PUB_KEY: nconf.get('STRIPE_PUB_KEY'),
getManifestFiles: getManifestFiles,
getBuildUrl: getBuildUrl
});
next()
}
/*
// translate = (req, res, next) ->
// model = req.getModel()
// # Set locale to bg on dev
// #model.set '_i18n.locale', 'bg' if process.env.NODE_ENV is "development"
// next()
*/
+122
View File
@@ -0,0 +1,122 @@
var mongoose = require("mongoose");
var Schema = mongoose.Schema;
var helpers = require('habitrpg-shared/script/helpers');
var _ = require('lodash');
var TaskSchema = require('./task').schema;
var Group = require('./group').model;
var ChallengeSchema = new Schema({
_id: {type: String, 'default': helpers.uuid},
name: String,
shortName: String,
description: String,
habits: [TaskSchema],
dailys: [TaskSchema],
todos: [TaskSchema],
rewards: [TaskSchema],
leader: {type: String, ref: 'User'},
group: {type: String, ref: 'Group'},
timestamp: {type: Date, 'default': Date.now},
members: [{type: String, ref: 'User'}],
memberCount: {type: Number, 'default': 0},
prize: {type: Number, 'default': 0}
});
ChallengeSchema.virtual('tasks').get(function () {
var tasks = this.habits.concat(this.dailys).concat(this.todos).concat(this.rewards);
var tasks = _.object(_.pluck(tasks,'id'), tasks);
return tasks;
});
// FIXME this isn't always triggered, since we sometimes use update() or findByIdAndUpdate()
// @see https://github.com/LearnBoost/mongoose/issues/964
ChallengeSchema.pre('save', function(next){
this.memberCount = _.size(this.members);
next()
})
ChallengeSchema.methods.toJSON = function(){
var doc = this.toObject();
doc.memberCount = doc.members ? _.size(doc.members) : doc.memberCount; // @see pre('save') comment above
doc._isMember = this._isMember;
return doc;
}
// --------------
// Syncing logic
// --------------
function syncableAttrs(task) {
var t = (task.toObject) ? task.toObject() : task; // lodash doesn't seem to like _.omit on EmbeddedDocument
// only sync/compare important attrs
var omitAttrs = 'history tags completed streak'.split(' ');
if (t.type != 'reward') omitAttrs.push('value');
return _.omit(t, omitAttrs);
}
/**
* Compare whether any changes have been made to tasks. If so, we'll want to sync those changes to subscribers
*/
function comparableData(obj) {
return (
_.chain(obj.habits.concat(obj.dailys).concat(obj.todos).concat(obj.rewards))
.sortBy('id') // we don't want to update if they're sort-order is different
.transform(function(result, task){
result.push(syncableAttrs(task));
}))
.toString(); // for comparing arrays easily
}
ChallengeSchema.methods.isOutdated = function(newData) {
return comparableData(this) !== comparableData(newData);
}
/**
* Syncs all new tasks, deleted tasks, etc to the user object
* @param user
* @return nothing, user is modified directly. REMEMBER to save the user!
*/
ChallengeSchema.methods.syncToUser = function(user, cb) {
if (!user) return;
var self = this;
self.shortName = self.shortName || self.name;
// Sync tags
var tags = user.tags || [];
var i = _.findIndex(tags, {id: self._id})
if (~i) {
if (tags[i].name !== self.shortName) {
// update the name - it's been changed since
user.tags[i].name = self.shortName;
}
} else {
user.tags.push({
id: self._id,
name: self.shortName,
challenge: true
});
}
// Sync new tasks and updated tasks
_.each(self.tasks, function(task){
var list = user[task.type+'s'];
var userTask = user.tasks[task.id] || (list.push(syncableAttrs(task)), list[list.length-1]);
userTask.challenge = {id:self._id};
userTask.tags = userTask.tags || {};
userTask.tags[self._id] = true;
_.merge(userTask, syncableAttrs(task));
})
// Flag deleted tasks as "broken"
_.each(user.tasks, function(task){
if (task.challenge && task.challenge.id==self._id && !self.tasks[task.id]) {
task.challenge.broken = 'TASK_DELETED';
}
})
user.save(cb);
};
module.exports.schema = ChallengeSchema;
module.exports.model = mongoose.model("Challenge", ChallengeSchema);
+83
View File
@@ -0,0 +1,83 @@
var mongoose = require("mongoose");
var Schema = mongoose.Schema;
var helpers = require('habitrpg-shared/script/helpers');
var _ = require('lodash');
var GroupSchema = new Schema({
_id: {type: String, 'default': helpers.uuid},
name: String,
description: String,
leader: {type: String, ref: 'User'},
members: [{type: String, ref: 'User'}],
invites: [{type: String, ref: 'User'}],
type: {type: String, "enum": ['guild', 'party']},
privacy: {type: String, "enum": ['private', 'public']},
_v: {type: Number,'default': 0},
websites: Array,
chat: Array,
/*
# [{
# timestamp: Date
# user: String
# text: String
# contributor: String
# uuid: String
# id: String
# }]
*/
memberCount: {type: Number, 'default': 0},
challengeCount: {type: Number, 'default': 0},
balance: Number,
logo: String,
leaderMessage: String,
challenges: [{type:'String', ref:'Challenge'}] // do we need this? could depend on back-ref instead (Challenge.find({group:GID}))
}, {
strict: 'throw',
minimize: false // So empty objects are returned
});
/**
* Derby duplicated stuff. This is a temporary solution, once we're completely off derby we'll run an mongo migration
* to remove duplicates, then take these fucntions out
*/
function removeDuplicates(doc){
// Remove duplicate members
if (doc.members) {
var uniqMembers = _.uniq(doc.members);
if (uniqMembers.length != doc.members.length) {
doc.members = uniqMembers;
}
}
if (doc.websites) {
var uniqWebsites = _.uniq(doc.websites);
if (uniqWebsites.length != doc.websites.length) {
doc.websites = uniqWebsites;
}
}
}
// FIXME this isn't always triggered, since we sometimes use update() or findByIdAndUpdate()
// @see https://github.com/LearnBoost/mongoose/issues/964
GroupSchema.pre('save', function(next){
removeDuplicates(this);
this.memberCount = _.size(this.members);
this.challengeCount = _.size(this.challenges);
next();
})
GroupSchema.methods.toJSON = function(){
var doc = this.toObject();
removeDuplicates(doc);
doc._isMember = this._isMember;
// @see pre('save') comment above
this.memberCount = _.size(this.members);
this.challengeCount = _.size(this.challenges);
return doc;
}
module.exports.schema = GroupSchema;
module.exports.model = mongoose.model("Group", GroupSchema);
+48
View File
@@ -0,0 +1,48 @@
// User.js
// =======
// Defines the user data model (schema) for use via the API.
// Dependencies
// ------------
var mongoose = require("mongoose");
var Schema = mongoose.Schema;
var helpers = require('habitrpg-shared/script/helpers');
var _ = require('lodash');
// Task Schema
// -----------
var TaskSchema = new Schema({
//_id:{type: String,'default': helpers.uuid},
id: {type: String,'default': helpers.uuid},
history: Array, // [{date:Date, value:Number}], // this causes major performance problems
text: String,
date: String, // due date for todos // FIXME we're getting parse errors, people have stored as "today" and "3/13". Need to run a migration & put this back to type: Date
notes: {type: String, 'default': ''},
tags: {type: Schema.Types.Mixed, 'default': {}}, //{ "4ddf03d9-54bd-41a3-b011-ca1f1d2e9371" : true },
type: {type:String, 'default': 'habit'}, // habit, daily
up: {type: Boolean, 'default': true},
down: {type: Boolean, 'default': true},
value: {type: Number, 'default': 0},
completed: {type: Boolean, 'default': false},
priority: {type: String, 'default': '!'}, //'!!' // FIXME this should be a number or something
repeat: {type: Schema.Types.Mixed, 'default': {m:1, t:1, w:1, th:1, f:1, s:1, su:1} },
streak: {type: Number, 'default': 0},
challenge: {
id: {type: 'String', ref:'Challenge'},
broken: String, // CHALLENGE_DELETED, TASK_DELETED, UNSUBSCRIBED, CHALLENGE_CLOSED
winner: String // user.profile.name
// group: {type: 'Strign', ref: 'Group'} // if we restore this, rename `id` above to `challenge`
}
},{
_id: false
});
/**
* Workaround for bug when _id & id were out of sync, we can remove this after challenges has been running for a while
*/
TaskSchema.post('init', function(doc){
if (!doc.id && doc._id) doc.id = doc._id;
})
module.exports.schema = TaskSchema;
+303
View File
@@ -0,0 +1,303 @@
// User.js
// =======
// Defines the user data model (schema) for use via the API.
// Dependencies
// ------------
var mongoose = require("mongoose");
var Schema = mongoose.Schema;
var helpers = require('habitrpg-shared/script/helpers');
var _ = require('lodash');
var TaskSchema = require('./task').schema;
var Challenge = require('./challenge').model;
// User Schema
// -----------
var UserSchema = new Schema({
// ### UUID and API Token
_id: {
type: String,
'default': helpers.uuid
},
apiToken: {
type: String,
'default': helpers.uuid
},
// ### Mongoose Update Object
// We want to know *every* time an object updates. Mongoose uses __v to designate when an object contains arrays which
// have been updated (http://goo.gl/gQLz41), but we want *every* update
_v: { type: Number, 'default': 0 },
achievements: {
originalUser: Boolean,
helpedHabit: Boolean,
ultimateGear: Boolean,
beastMaster: Boolean,
veteran: Boolean,
streak: Number,
challenges: Array
},
auth: {
facebook: Schema.Types.Mixed,
local: {
email: String,
hashed_password: String,
salt: String,
username: String
},
timestamps: {
created: {type: Date,'default': Date.now},
loggedin: {type: Date,'default': Date.now}
}
},
backer: {
tier: Number,
//admin: Boolean, // FIXME migrate to contributor.admin
npc: String,
//contributor: String, // FIXME migrate to contributor.text
tokensApplied: Boolean
},
contributor: {
level: Number, // 1-7, see https://trello.com/c/wkFzONhE/277-contributor-gear
admin: Boolean,
text: String, // Artisan, Friend, Blacksmith, etc
},
balance: Number,
filters: {type: Schema.Types.Mixed, 'default': {}},
purchased: {
ads: {type: Boolean, 'default': false},
skin: {type: Schema.Types.Mixed, 'default': {}}, // eg, {skeleton: true, pumpkin: true, eb052b: true}
hair: {type: Schema.Types.Mixed, 'default': {}}
},
flags: {
customizationsNotification: {type: Boolean, 'default': false},
showTour: {type: Boolean, 'default': true},
dropsEnabled: {type: Boolean, 'default': false},
itemsEnabled: {type: Boolean, 'default': false},
newStuff: {type: Boolean, 'default': false},
rewrite: {type: Boolean, 'default': true},
partyEnabled: Boolean, // FIXME do we need this?
petsEnabled: {type: Boolean, 'default': false},
rest: {type: Boolean, 'default': false}, // fixme - change to preferences.resting once we're off derby
contributor: Boolean
},
history: {
exp: Array, // [{date: Date, value: Number}], // big peformance issues if these are defined
todos: Array //[{data: Date, value: Number}] // big peformance issues if these are defined
},
/* FIXME remove?*/
invitations: {
guilds: {type: Array, 'default': []},
party: Schema.Types.Mixed
},
items: {
armor: Number,
weapon: Number,
head: Number,
shield: Number,
/*FIXME - tidy this up, not the best way to store current pet*/
currentPet: {
/*Cactus*/
text: String,
/*Cactus*/
name: String,
/*3*/
value: Number,
/*"Find a hatching potion to pour on this egg, and one day it will hatch into a loyal pet.",*/
notes: String,
/*Skeleton*/
modifier: String,
/*Cactus-Skeleton*/
str: String
},
eggs: [
{
// example: You've found a Wolf Egg! Find a hatching potion to pour on this egg, and one day it will hatch into a loyal pet
dialog: String,
// example: Wolf
name: String,
// example: Find a hatching potion to pour on this egg, and one day it will hatch into a loyal pet.
notes: String,
// example: Wolf
text: String,
/* type: String, //Egg // this is forcing mongoose to return object as "[object Object]", but I don't think this is needed anyway? */
// example: 3
value: Number
}
],
hatchingPotions: Array, // ["Base", "Skeleton",...]
lastDrop: {
date: {type: Date, 'default': Date.now},
count: {type: Number, 'default': 0}
},
// ["BearCub-Base", "Cactus-Base", ...]
pets: Array
},
lastCron: {
type: Date,
'default': Date.now
},
// FIXME remove?
party: {
//party._id // FIXME make these populate docs?
current: String, // party._id
invitation: String, // party._id
lastMessageSeen: String,
leader: Boolean
},
preferences: {
armorSet: String,
dayStart: {type:Number, 'default': 0},
gender: {type:String, 'default': 'm'},
hair: {type:String, 'default':'blond'},
hideHeader: {type:Boolean, 'default':false},
showHelm: {type:Boolean, 'default':true},
skin: {type:String, 'default':'white'},
timezoneOffset: Number
},
profile: {
blurb: String,
imageUrl: String,
name: String,
websites: Array // styled like --> ["http://ocdevel.com" ]
},
stats: {
hp: Number,
exp: Number,
gp: Number,
lvl: Number
},
tags: [
{
id: String,
name: String,
challenge: String
}
],
challenges: [{type: 'String', ref:'Challenge'}],
habits: [TaskSchema],
dailys: [TaskSchema],
todos: [TaskSchema],
rewards: [TaskSchema],
}, {
strict: true,
minimize: false // So empty objects are returned
});
UserSchema.methods.deleteTask = function(tid) {
//user[t.type+'s'].id(t.id).remove();
var task = this.tasks[tid];
var i = this[task.type+'s'].indexOf(task);
if (~i) this[task.type+'s'].splice(i,1);
}
UserSchema.methods.toJSON = function() {
var doc = this.toObject();
doc.id = doc._id;
// FIXME? Is this a reference to `doc.filters` or just disabled code? Remove?
doc.filters = {};
doc._tmp = this._tmp; // be sure to send down drop notifs
return doc;
};
UserSchema.virtual('tasks').get(function () {
var tasks = this.habits.concat(this.dailys).concat(this.todos).concat(this.rewards);
var tasks = _.object(_.pluck(tasks,'id'), tasks);
return tasks;
});
// FIXME - since we're using special @post('init') above, we need to flag when the original path was modified.
// Custom setter/getter virtuals?
UserSchema.pre('save', function(next) {
//this.markModified('tasks');
if (!this.profile.name) {
var fb = this.auth.facebook;
this.profile.name =
(this.auth.local && this.auth.local.username) ||
(fb && (fb.displayName || fb.name || fb.username || (fb.first_name && fb.first_name + ' ' + fb.last_name))) ||
'Anonymous';
}
if(!this.achievements.beastMaster && this.items.pets.length >= 90){
this.achievements.beastMaster = true;
}
//our own version incrementer
this._v++;
next();
});
UserSchema.methods.syncScoreToChallenge = function(task, delta){
if (!task.challenge || !task.challenge.id || task.challenge.broken) return;
if (task.type == 'reward') return; // we don't want to update the reward GP cost
var self = this;
Challenge.findById(task.challenge.id, function(err, chal){
if (err) throw err;
var t = chal.tasks[task.id];
if (!t) return chal.syncToUser(self); // this task was removed from the challenge, notify user
t.value += delta;
t.history.push({value: t.value, date: +new Date});
chal.save();
});
}
UserSchema.methods.unlink = function(options, cb) {
var cid = options.cid, keep = options.keep, tid = options.tid;
var self = this;
switch (keep) {
case 'keep':
self.tasks[tid].challenge = {};
break;
case 'remove':
self.deleteTask(tid);
break;
case 'keep-all':
_.each(self.tasks, function(t){
if (t.challenge && t.challenge.id == cid) {
t.challenge = {};
}
});
break;
case 'remove-all':
_.each(self.tasks, function(t){
if (t.challenge && t.challenge.id == cid) {
self.deleteTask(t.id);
}
})
break;
}
self.markModified('habits');
self.markModified('dailys');
self.markModified('todos');
self.markModified('rewards');
self.save(cb);
}
module.exports.schema = UserSchema;
module.exports.model = mongoose.model("User", UserSchema);
+102
View File
@@ -0,0 +1,102 @@
var express = require('express');
var router = new express.Router();
var user = require('../controllers/user');
var groups = require('../controllers/groups');
var auth = require('../controllers/auth');
var challenges = require('../controllers/challenges');
var nconf = require('nconf');
/*
---------- /api/v1 API ------------
Every url added to router is prefaced by /api/v1
See ./routes/coffee for routes
v1 user. Requires x-api-user (user id) and x-api-key (api key) headers, Test with:
$ cd node_modules/racer && npm install && cd ../..
$ mocha test/user.mocha.coffee
*/
var verifyTaskExists = user.verifyTaskExists
var cron = user.cron;
router.get('/status', function(req, res) {
return res.json({
status: 'up'
});
});
/* Scoring*/
router.post('/user/task/:id/:direction', auth.auth, cron, user.scoreTask);
router.post('/user/tasks/:id/:direction', auth.auth, cron, user.scoreTask);
/* Tasks*/
router.get('/user/tasks', auth.auth, cron, user.getTasks);
router.get('/user/task/:id', auth.auth, cron, user.getTask);
router.put('/user/task/:id', auth.auth, cron, verifyTaskExists, user.updateTask);
router["delete"]('/user/task/:id', auth.auth, cron, verifyTaskExists, user.deleteTask);
router.post('/user/task', auth.auth, cron, user.createTask);
router.put('/user/task/:id/sort', auth.auth, cron, verifyTaskExists, user.sortTask);
router.post('/user/clear-completed', auth.auth, cron, user.clearCompleted);
router.post('/user/task/:id/unlink', auth.auth, challenges.unlink); // removing cron since they may want to remove task first
if (nconf.get('NODE_ENV') == 'development') {
router.post('/user/addTenGems', auth.auth, user.addTenGems);
}
/* Items*/
router.post('/user/buy/:type', auth.auth, cron, user.buy);
/* User*/
router.get('/user', auth.auth, cron, user.getUser);
router.put('/user', auth.auth, cron, user.updateUser);
router.post('/user/revive', auth.auth, cron, user.revive);
router.post('/user/batch-update', auth.auth, cron, user.batchUpdate);
router.post('/user/reroll', auth.auth, cron, user.reroll);
router.post('/user/buy-gems', auth.auth, user.buyGems);
router.post('/user/buy-gems/paypal-ipn', user.buyGemsPaypalIPN);
router.post('/user/unlock', auth.auth, cron, user.unlock);
router.post('/user/reset', auth.auth, user.reset);
router['delete']('/user', auth.auth, user['delete']);
/* Tags */
router['delete']('/user/tags/:tid', auth.auth, user.deleteTag);
/* Groups*/
router.get('/groups', auth.auth, groups.list);
router.post('/groups', auth.auth, groups.create);
router.get('/groups/:gid', auth.auth, groups.get);
router.post('/groups/:gid', auth.auth, groups.attachGroup, groups.update);
router.put('/groups/:gid', auth.auth, groups.attachGroup, groups.update);
//DELETE /groups/:gid
router.post('/groups/:gid/join', auth.auth, groups.attachGroup, groups.join);
router.post('/groups/:gid/leave', auth.auth, groups.attachGroup, groups.leave);
router.post('/groups/:gid/invite', auth.auth, groups.attachGroup, groups.invite);
router.post('/groups/:gid/removeMember', auth.auth, groups.attachGroup, groups.removeMember);
//GET /groups/:gid/chat
router.post('/groups/:gid/chat', auth.auth, groups.attachGroup, groups.postChat);
router["delete"]('/groups/:gid/chat/:messageId', auth.auth, groups.attachGroup, groups.deleteChatMessage);
//PUT /groups/:gid/chat/:messageId
/* Members */
router.get('/members/:uid', groups.getMember);
router.post('/members/:uid', auth.auth, groups.updateMember); // only for admins
// Market
router.post('/market/buy', auth.auth, user.marketBuy);
/* Challenges */
// Note: while challenges belong to groups, and would therefore make sense as a nested resource
// (eg /groups/:gid/challenges/:cid), they will also be referenced by users from the "challenges" tab
// without knowing which group they belong to. So to prevent unecessary lookups, we have them as a top-level resource
router.get('/challenges', auth.auth, challenges.list)
router.post('/challenges', auth.auth, challenges.create)
router.get('/challenges/:cid', auth.auth, challenges.get)
router.post('/challenges/:cid', auth.auth, challenges.update)
router['delete']('/challenges/:cid', auth.auth, challenges['delete'])
router.post('/challenges/:cid/close', auth.auth, challenges.selectWinner)
router.post('/challenges/:cid/join', auth.auth, challenges.join)
router.post('/challenges/:cid/leave', auth.auth, challenges.leave)
router.get('/challenges/:cid/member/:uid', auth.auth, challenges.getMember)
module.exports = router;
+13
View File
@@ -0,0 +1,13 @@
var auth = require('../controllers/auth');
var express = require('express');
var router = new express.Router();
/* auth.auth*/
auth.setupPassport(router); //FIXME make this consistent with the others
router.post('/api/v1/register', auth.registerUser);
router.post('/api/v1/user/auth/local', auth.loginLocal);
router.post('/api/v1/user/auth/facebook', auth.loginFacebook);
router.post('/api/v1/user/reset-password', auth.resetPassword);
router.post('/api/v1/user/change-password', auth.auth, auth.changePassword);
module.exports = router;
+49
View File
@@ -0,0 +1,49 @@
var nconf = require('nconf');
var express = require('express');
var router = new express.Router();
var _ = require('lodash');
// -------- App --------
router.get('/', function(req, res) {
return res.render('index', {
title: 'HabitRPG | Your Life, The Role Playing Game',
env: res.locals.habitrpg
});
});
// -------- Marketing --------
router.get('/splash.html', function(req, res) {
res.redirect('/static/front');
});
router.get('/static/front', function(req, res) {
res.render('static/front', {env: res.locals.habitrpg, isFrontPage: true});
});
router.get('/static/about', function(req, res) {
res.redirect('http://community.habitrpg.com/node/97');
});
router.get('/static/team', function(req, res) {
res.redirect('http://community.habitrpg.com/node/96');
});
router.get('/static/extensions', function(req, res) {
res.redirect('http://community.habitrpg.com/extensions');
});
router.get('/static/faq', function(req, res) {
res.redirect('http://community.habitrpg.com/faq-page');
});
router.get('/static/privacy', function(req, res) {
res.render('static/privacy', {env: res.locals.habitrpg});
});
router.get('/static/terms', function(req, res) {
res.render('static/terms', {env: res.locals.habitrpg});
});
module.exports = router;
+34
View File
@@ -0,0 +1,34 @@
require('coffee-script') // for habitrpg-shared
var nconf = require('nconf');
require('./config');
var async = require('async');
var mongoose = require('mongoose');
User = require('./models/user').model;
Group = require('./models/group').model;
async.waterfall([
function(cb){
mongoose.connect(nconf.get('NODE_DB_URI'), cb);
},
function(cb){
Group.findById('habitrpg', cb);
},
function(tavern, cb){
console.log({tavern:tavern,cb:cb});
if (!tavern) {
tavern = new Group({
_id: 'habitrpg',
chat: [],
leader: '9',
name: 'HabitRPG',
type: 'guild'
});
tavern.save(cb)
} else {
cb();
}
}
],function(err){
if (err) throw err;
console.log("Done initializing database");
})
+146
View File
@@ -0,0 +1,146 @@
require('coffee-script') // remove this once we've fully converted over
var express = require("express");
var http = require("http");
var path = require("path");
var app = express();
var nconf = require('nconf');
var utils = require('./utils');
var middleware = require('./middleware');
var server;
var TWO_WEEKS = 1000 * 60 * 60 * 24 * 14;
// ------------ Setup configurations ------------
require('./config');
process.on("uncaughtException", function(error) {
// when we hit an error, send it to admin as an email. If no ADMIN_EMAIL is present, just send it to yourself (SMTP_USER)
utils.sendEmail({
from: "HabitRPG <" + nconf.get('SMTP_USER') + ">",
to: nconf.get('ADMIN_EMAIL') || nconf.get('SMTP_USER'),
subject: "HabitRPG Error",
text: error.stack
});
console.error(error.stack);
});
// ------------ MongoDB Configuration ------------
mongoose = require('mongoose');
require('./models/user'); //load up the user schema - TODO is this necessary?
require('./models/group');
require('./models/challenge');
mongoose.connect(nconf.get('NODE_DB_URI'), function(err) {
if (err) throw err;
console.info('Connected with Mongoose');
});
// ------------ Passport Configuration ------------
var passport = require('passport')
var util = require('util')
var FacebookStrategy = require('passport-facebook').Strategy;
// Passport session setup.
// To support persistent login sessions, Passport needs to be able to
// serialize users into and deserialize users out of the session. Typically,
// this will be as simple as storing the user ID when serializing, and finding
// the user by ID when deserializing. However, since this example does not
// have a database of user records, the complete Facebook profile is serialized
// and deserialized.
passport.serializeUser(function(user, done) {
done(null, user);
});
passport.deserializeUser(function(obj, done) {
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.
passport.use(new FacebookStrategy({
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);
//});
}
));
// ------------ Server Configuration ------------
app.set("port", nconf.get('PORT'));
if (!process.env.SUPPRESS) app.use(express.logger("dev"));
app.use(express.compress());
app.set("views", __dirname + "/../views");
app.set("view engine", "jade");
app.use(express.favicon());
app.use(middleware.cors);
app.use(middleware.forceSSL);
app.use(express.urlencoded());
app.use(express.json());
app.use(express.methodOverride());
//app.use(express.cookieParser(nconf.get('SESSION_SECRET')));
app.use(express.cookieParser());
app.use(express.cookieSession({ secret: nconf.get('SESSION_SECRET'), httpOnly: false, cookie: { maxAge: TWO_WEEKS }}));
//app.use(express.session());
app.use(middleware.splash);
app.use(middleware.locals);
// Initialize Passport! Also use passport.session() middleware, to support
// persistent login sessions (recommended).
app.use(passport.initialize());
app.use(passport.session());
app.use(app.router);
var maxAge = (nconf.get('NODE_ENV') === 'production') ? 31536000000 : 0;
app.use(express['static'](path.join(__dirname, "/../build"), { maxAge: maxAge }));
app.use(express['static'](path.join(__dirname, "/../public")));
// development only
if ("development" === app.get("env")) {
app.use(express.errorHandler());
}
// Custom Directives
app.use(require('./routes/pages').middleware);
app.use(require('./routes/auth').middleware);
app.use('/api/v1', require('./routes/api').middleware);
app.use(require('./controllers/deprecated').middleware);
server = http.createServer(app).listen(app.get("port"), function() {
return console.log("Express server listening on port " + app.get("port"));
});
module.exports = server;
/*
#ONE_YEAR = 1000 * 60 * 60 * 24 * 365
#root = path.dirname path.dirname __dirname
#publicPath = path.join root, 'public'
#
#
#expressApp
# .use(express.favicon("#{publicPath}/favicon.ico"))
# # Gzip static files and serve from memory
# .use(gzippo.staticGzip(publicPath, maxAge: ONE_YEAR))
# # Gzip dynamically rendered content
# .use(express.compress())
# .use(middleware.translate)
# .use(auth.middleware(strategies, options))
# .use(serverError(root))
#
#
## Errors
#expressApp.all '*', (req) ->
# throw "404: #{req.url}"
*/
-271
View File
@@ -1,271 +0,0 @@
express = require 'express'
router = new express.Router()
scoring = require '../app/scoring'
_ = require 'underscore'
{ tnl } = require '../app/algos'
validator = require 'derby-auth/node_modules/validator'
check = validator.check
sanitize = validator.sanitize
NO_TOKEN_OR_UID = err: "You must include a token and uid (user id) in your request"
NO_USER_FOUND = err: "No user found."
# ---------- /api/v1 API ------------
# Every url added beneath router is prefaced by /api/v1
###
v1 API. Requires api-v1-user (user id) and api-v1-key (api key) headers, Test with:
$ cd node_modules/racer && npm install && cd ../..
$ mocha test/api.mocha.coffee
###
###
API Status
###
router.get '/status', (req, res) ->
res.json status: 'up'
###
beforeEach auth interceptor
###
auth = (req, res, next) ->
uid = req.headers['x-api-user']
token = req.headers['x-api-key']
return res.json 401, NO_TOKEN_OR_UID unless uid || token
model = req.getModel()
query = model.query('users').withIdAndToken(uid, token)
query.fetch (err, user) ->
return res.json err: err if err
req.user = user
req.userObj = user.get()
return res.json 401, NO_USER_FOUND if !req.userObj || _.isEmpty(req.userObj)
req._isServer = true
next()
###
GET /user
###
router.get '/user', auth, (req, res) ->
user = req.userObj
user.stats.toNextLevel = tnl user.stats.lvl
user.stats.maxHealth = 50
delete user.apiToken
if user.auth
delete user.auth.hashed_password
delete user.auth.salt
res.json user
###
TODO POST /user
when a put attempt didn't work, create a new one with POST
###
###
PUT /user
###
router.put '/user', auth, (req, res) ->
user = req.user
partialUser = req.body.user
# REVISIT is this the best way of handling protected v acceptable attr mass-setting? Possible pitfalls: (1) we have to remember
# to update here when we add new schema attrs in the future, (2) developers can't assign random variables (which
# is currently beneficial for Kevin & Paul). Pros: protects accidental or malicious user data corruption
# TODO - this accounts for single-nested items (stats.hp, stats.exp) but will clobber any other depth.
# See http://stackoverflow.com/a/6394168/362790 for when we need to cross that road
acceptableAttrs = ['flags', 'history', 'items', 'preferences', 'profile', 'stats']
user.set 'lastCron', partialUser.lastCron if partialUser.lastCron?
_.each acceptableAttrs, (attr) ->
_.each partialUser[attr], (val, key) -> user.set("#{attr}.#{key}", val)
updateTasks partialUser.tasks, req.user, req.getModel() if partialUser.tasks?
userObj = user.get()
userObj.tasks = _.toArray(userObj.tasks) # FIXME figure out how we're going to consistently handle this. should always be array
res.json 201, userObj
###
GET /user/task/:id
###
router.get '/user/task/:id', auth, (req, res) ->
task = req.userObj.tasks[req.params.id]
return res.json 400, err: "No task found." if !task || _.isEmpty(task)
res.json 200, task
###
validate task
###
validateTask = (req, res, next) ->
task = {}
newTask = { type, text, notes, value, up, down, completed } = req.body
# If we're updating, get the task from the user
if req.method is 'PUT' or req.method is 'DELETE'
task = req.userObj?.tasks[req.params.id]
return res.json 400, err: "No task found." if !task || _.isEmpty(task)
# Strip for now
type = undefined
delete newTask.type
else if req.method is 'POST'
newTask.value = sanitize(value).toInt()
newTask.value = 0 if isNaN newTask.value
unless /^(habit|todo|daily|reward)$/.test type
return res.json 400, err: 'type must be habit, todo, daily, or reward'
newTask.text = sanitize(text).xss() if typeof text is "string"
newTask.notes = sanitize(notes).xss() if typeof notes is "string"
switch type
when 'habit'
newTask.up = true unless typeof up is 'boolean'
newTask.down = true unless typeof down is 'boolean'
when 'daily', 'todo'
newTask.completed = false unless typeof completed is 'boolean'
_.extend task, newTask
req.task = task
next()
###
PUT /user/task/:id
###
router.put '/user/task/:id', auth, validateTask, (req, res) ->
req.user.set "tasks.#{req.task.id}", req.task
res.json 200, req.task
###
DELETE /user/task/:id
###
router.delete '/user/task/:id', auth, validateTask, (req, res) ->
taskIds = req.user.get "#{req.task.type}Ids"
req.user.del "tasks.#{req.task.id}"
# Remove one id from array of typeIds
req.user.remove "#{req.task.type}Ids", taskIds.indexOf(req.task.id), 1
res.send 204
###
POST /user/tasks
###
updateTasks = (tasks, user, model) ->
for idx, task of tasks
if task.id
if task.del
user.del "tasks.#{task.id}"
if task.type # TODO we should enforce they pass in type, so we can properly remove from idList
i = model.get("_#{task.type}List").indexOf(task.id)
model.remove("_#{task.type}List", i, 1) # doens't work when task.type isn't passed up
task = deleted: true
else
user.set "tasks.#{task.id}", task
else
type = task.type || 'habit'
model.ref '_user', user
model.refList "_#{type}List", "_user.tasks", "_user.#{type}Ids"
model.at("_#{type}List").push task
tasks[idx] = task
return tasks
router.post '/user/tasks', auth, (req, res) ->
tasks = updateTasks req.body, req.user, req.getModel()
res.json 201, tasks
###
POST /user/task/
###
router.post '/user/task', auth, validateTask, (req, res) ->
task = req.task
type = task.type
model = req.getModel()
model.ref '_user', req.user
model.refList "_#{type}List", "_user.tasks", "_user.#{type}Ids"
model.at("_#{type}List").push task
res.json 201, task
###
GET /user/tasks
###
router.get '/user/tasks', auth, (req, res) ->
user = req.userObj
return res.json 400, NO_USER_FOUND if !user || _.isEmpty(user)
model = req.getModel()
model.ref '_user', req.user
tasks = []
types = ['habit','todo','daily','reward']
if /^(habit|todo|daily|reward)$/.test req.query.type
types = [req.query.type]
for type in types
model.refList "_#{type}List", "_user.tasks", "_user.#{type}Ids"
tasks = tasks.concat model.get("_#{type}List")
res.json 200, tasks
###
This is called form deprecated.coffee's score function, and the req.headers are setup properly to handle the login
###
scoreTask = (req, res, next) ->
{taskId, direction} = req.params
{title, service, icon, type} = req.body
type ||= 'habit'
# Send error responses for improper API call
return res.send(500, ':taskId required') unless taskId
return res.send(500, ":direction must be 'up' or 'down'") unless direction in ['up','down']
model = req.getModel()
{user, userObj} = req
model.ref('_user', user)
existingTask = model.at "_user.tasks.#{taskId}"
# TODO add service & icon to task
# If task exists, set it's compltion
if existingTask.get()
# Set completed if type is daily or todo
existingTask.set 'completed', (direction is 'up') if /^(daily|todo)$/.test existingTask.get('type')
else
task =
id: taskId
type: type
text: (title || taskId)
value: 0
notes: "This task was created by a third-party service. Feel free to edit, it won't harm the connection to that service. Additionally, multiple services may piggy-back off this task."
switch type
when 'habit'
task.up = true
task.down = true
when 'daily', 'todo'
task.completed = direction is 'up'
model.refList "_#{type}List", "_user.tasks", "_user.#{type}Ids"
model.at("_#{type}List").push task
delta = scoring.score(model, taskId, direction)
result = model.get '_user.stats'
result.delta = delta
res.json result
###
POST /user/tasks/:taskId/:direction
###
router.post '/user/task/:taskId/:direction', auth, scoreTask
router.post '/user/tasks/:taskId/:direction', auth, scoreTask
module.exports = router
module.exports.auth = auth
module.exports.scoreTask = scoreTask # export so deprecated can call it
-52
View File
@@ -1,52 +0,0 @@
express = require 'express'
router = new express.Router()
scoring = require '../app/scoring'
_ = require 'underscore'
icalendar = require('icalendar')
api = require './api'
# ---------- Deprecated Paths ------------
deprecatedMessage = 'This API is no longer supported, see https://github.com/lefnire/habitrpg/wiki/API for new protocol'
router.get '/:uid/up/:score?', (req, res) -> res.send(500, deprecatedMessage)
router.get '/:uid/down/:score?', (req, res) -> res.send(500, deprecatedMessage)
router.post '/users/:uid/tasks/:taskId/:direction', (req, res) -> res.send(500, deprecatedMessage)
# Redirect to new API
initDeprecated = (req, res, next) ->
req.headers['x-api-user'] = req.params.uid
req.headers['x-api-key'] = req.body.apiToken
next()
router.post '/v1/users/:uid/tasks/:taskId/:direction', initDeprecated, api.auth, api.scoreTask
router.get '/v1/users/:uid/calendar.ics', (req, res) ->
#return next() #disable for now
{uid} = req.params
{apiToken} = req.query
model = req.getModel()
query = model.query('users').withIdAndToken(uid, apiToken)
query.fetch (err, result) ->
return res.send(500, err) if err
tasks = result.get('tasks')
# tasks = result[0].tasks
tasksWithDates = _.filter tasks, (task) -> !!task.date
return res.send(500, "No events found") if _.isEmpty(tasksWithDates)
ical = new icalendar.iCalendar()
ical.addProperty('NAME', 'HabitRPG')
_.each tasksWithDates, (task) ->
event = new icalendar.VEvent(task.id);
event.setSummary(task.text);
d = new Date(task.date)
d.date_only = true
event.setDate d
ical.addComponent event
res.type('text/calendar')
formattedIcal = ical.toString().replace(/DTSTART\:/g, 'DTSTART;VALUE=DATE:')
res.send(200, formattedIcal)
module.exports = router
-70
View File
@@ -1,70 +0,0 @@
_ = require 'underscore'
character = require "../app/character"
module.exports.middleware = (req, res, next) ->
model = req.getModel()
model.set '_stripePubKey', process.env.STRIPE_PUB_KEY
return next()
module.exports.app = (appExports, model) ->
appExports.showStripe = (e, el) ->
token = (res) ->
$.ajax({
type: "POST",
url: "/charge",
data: res
}).success ->
window.location.href = "/"
.error (err) ->
alert err.responseText
disableAds = if (model.get('_user.flags.ads') is 'hide') then '' else 'Disable Ads, '
StripeCheckout.open
key: model.get('_stripePubKey')
address: false
amount: 500
name: "Checkout"
description: "Buy 20 Gems, #{disableAds}Support the Developers"
panelLabel: "Checkout"
token: token
###
Buy Reroll Button
###
appExports.buyReroll = (e, el, next) ->
batch = new character.BatchUpdate(model)
obj = model.get('_user')
batch.set 'balance', obj.balance-1
_.each obj.tasks, (task) -> batch.set("tasks.#{task.id}.value", 0) unless task.type == 'reward'
batch.commit()
module.exports.routes = (expressApp) ->
###
Setup Stripe response when posting payment
###
expressApp.post '/charge', (req, res) ->
stripeCallback = (err, response) ->
if err
console.error(err, 'Stripe Error')
return res.send(500, err.response.error.message)
else
model = req.getModel()
userId = model.get('_userId') #or model.session.userId # see http://goo.gl/TPYIt
req._isServer = true
model.fetch "users.#{userId}", (err, user) ->
model.ref '_user', "users.#{userId}"
model.set('_user.balance', model.get('_user.balance')+5)
model.set('_user.flags.ads','hide')
return res.send(200)
api_key = process.env.STRIPE_API_KEY # secret stripe API key
stripe = require("stripe")(api_key)
token = req.body.id
# console.dir {token:token, req:req}, 'stripe'
stripe.charges.create
amount: "500" # $5
currency: "usd"
card: token
, stripeCallback
-21
View File
@@ -1,21 +0,0 @@
derby = require 'derby'
{isProduction} = derby.util
module.exports = (root) ->
staticPages = derby.createStatic root
return (err, req, res, next) ->
return next() unless err?
console.log(if err.stack then err.stack else err)
## Customize error handling here ##
message = err.message || err.toString()
status = parseInt message
if status is 404
staticPages.render '404', res, {url: req.url}, 404
else if status >= 400 and status < 600
res.send status
else
#TODO send error to email
res.redirect('/500.html')
-24
View File
@@ -1,24 +0,0 @@
express = require 'express'
router = new express.Router()
path = require 'path'
derby = require 'derby'
# ---------- Static Pages ------------
staticPages = derby.createStatic path.dirname(path.dirname(__dirname))
beforeEach = (req, res, next) ->
req.getModel().set '_nodeEnv', 'production' # we don't want cheat buttons on static pages
next()
router.get '/splash.html', (req, res) -> res.redirect('/static/front')
router.get '/static/front', beforeEach, (req, res) -> staticPages.render 'static/front', res
router.get '/static/about', (req, res) -> res.redirect 'http://community.habitrpg.com/node/97'
router.get '/static/team', (req, res) -> res.redirect 'http://community.habitrpg.com/node/96'
router.get '/static/extensions', (req, res) -> res.redirect 'http://community.habitrpg.com/extensions'
router.get '/static/faq', (req, res) -> res.redirect 'http://community.habitrpg.com/faq-page'
router.get '/static/privacy', beforeEach, (req, res) -> staticPages.render 'static/privacy', res
router.get '/static/terms', beforeEach, (req, res) -> staticPages.render 'static/terms', res
module.exports = router
-146
View File
@@ -1,146 +0,0 @@
derbyAuth = require('derby-auth/store')
###
Setup read / write access
@param store
###
module.exports.customAccessControl = (store) ->
userAccess(store)
partySystem(store)
tavernSystem(store)
REST(store)
###
General user access
###
userAccess = (store) ->
store.readPathAccess "users.*", -> # captures, accept, err ->
accept = arguments[arguments.length-2]
err = arguments[arguments.length - 1]
# return err(derbyAuth.SESSION_INVALIDATED_ERROR) if derbyAuth.bustedSession(@)
return accept(false) if derbyAuth.bustedSession(@)
accept = arguments[arguments.length - 2]
uid = arguments[0]
accept (uid is @session.userId) or derbyAuth.isServer(@)
store.writeAccess "*", "users.*", -> # captures, value, accept, err ->
accept = arguments[arguments.length-2]
err = arguments[arguments.length - 1]
# return err(derbyAuth.SESSION_INVALIDATED_ERROR) if derbyAuth.bustedSession(@)
return accept(true) if derbyAuth.isServer(@)
return accept(false) if derbyAuth.bustedSession(@)
captures = arguments[0].split('.')
uid = captures.shift()
attrPath = captures.join('.') # new array shifted left, after shift() was run
if attrPath is 'backer'
return accept(false) # we can only manually set this stuff in the database
# public access to users.*.party.invitation (TODO, lock down a bit more)
if attrPath is 'party.invitation'
return accept(true)
# Same session (user.id = this.session.userId)
return accept(true) if uid is @session.userId
accept(false)
store.writeAccess "*", "users.*.balance", (id, newBalance, accept, err) ->
# return err(derbyAuth.SESSION_INVALIDATED_ERROR) if derbyAuth.bustedSession(@)
return accept(false) if derbyAuth.bustedSession(@)
oldBalance = @session.req?._racerModel?.get("users.#{id}.balance") || 0
purchasingSomethingOnClient = newBalance < oldBalance
accept(purchasingSomethingOnClient or derbyAuth.isServer(@))
store.writeAccess "*", "users.*.flags.ads", -> # captures, value, accept, err ->
accept = arguments[arguments.length - 2]
err = arguments[arguments.length - 1]
# return err(derbyAuth.SESSION_INVALIDATED_ERROR) if derbyAuth.bustedSession(@)
return accept(false) if derbyAuth.bustedSession(@)
accept(derbyAuth.isServer(@))
###
REST
Get user with API token
###
REST = (store) ->
store.query.expose "users", "withIdAndToken", (uid, token) ->
@where("id").equals(uid)
.where('apiToken').equals(token)
.findOne()
store.queryAccess "users", "withIdAndToken", (uid, token, accept, err) ->
return accept(true) if uid && token
accept(false) # only user has id & token
###
Party permissions
###
partySystem = (store) ->
store.query.expose "users", "party", (ids) ->
@where("id").within(ids)
.only('stats',
'items',
'party',
'profile',
'achievements',
'backer',
'preferences',
'auth.local.username',
'auth.facebook.displayName')
store.queryAccess "users", "party", (ids, accept, err) ->
# return err(derbyAuth.SESSION_INVALIDATED_ERROR) if derbyAuth.bustedSession(@)
return accept(false) if derbyAuth.bustedSession(@)
accept(true) # no harm in public user stats
store.query.expose "parties", "withId", (id) ->
@where("id").equals(id).findOne()
store.queryAccess "parties", "withId", (id, accept, err) ->
# return err(derbyAuth.SESSION_INVALIDATED_ERROR) if derbyAuth.bustedSession(@)
return accept(false) if derbyAuth.bustedSession(@)
accept(true)
store.readPathAccess "parties.*", ->
accept = arguments[arguments.length-2]
accept(true)
store.writeAccess "*", "parties.*", ->
accept = arguments[arguments.length-2]
err = arguments[arguments.length - 1]
# return err(derbyAuth.SESSION_INVALIDATED_ERROR) if derbyAuth.bustedSession(@)
return accept(false) if derbyAuth.bustedSession(@)
accept(true)
store.query.expose "parties", "withMember", (id) ->
@where('members').contains([id]).findOne()
store.queryAccess 'parties', 'withMember', (id, accept, err) ->
return accept(false) if derbyAuth.bustedSession(@)
accept(true)
###
LFG / tavern system
###
tavernSystem = (store) ->
store.readPathAccess 'tavern', ->
accept = arguments[arguments.length-2]
return accept(false) if derbyAuth.bustedSession(@)
accept(true)
store.writeAccess "*", "tavern.*", ->
accept = arguments[arguments.length-2]
return accept(false) if derbyAuth.bustedSession(@)
accept(true)
+34
View File
@@ -0,0 +1,34 @@
var nodemailer = require('nodemailer');
var nconf = require('nconf');
var crypto = require('crypto');
module.exports.sendEmail = function(mailData) {
var smtpTransport = nodemailer.createTransport("SMTP",{
service: nconf.get('SMTP_SERVICE'),
auth: {
user: nconf.get('SMTP_USER'),
pass: nconf.get('SMTP_PASS')
}
});
smtpTransport.sendMail(mailData, function(error, response){
if(error){
console.log(error);
}else{
console.log("Message sent: " + response.message);
}
smtpTransport.close(); // shut down the connection pool, no more messages
});
}
// 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
module.exports.encryptPassword = function(password, salt) {
return crypto.createHmac('sha1', salt).update(password).digest('hex');
}
module.exports.makeSalt = function() {
var len = 10;
return crypto.randomBytes(Math.ceil(len / 2)).toString('hex').substring(0, len);
}