Files
habitica/src/controllers/api.coffee
T
Tyler Renelle bbab8eb73c rewrite: fix sortables. we need to change the way this api route handles
sorting, it's really inconsistent - especially for the batch-update routec
2013-08-28 14:04:54 -04:00

429 lines
13 KiB
CoffeeScript

# @see ./routes.coffee for routing
_ = require 'lodash'
async = require 'async'
algos = require 'habitrpg-shared/script/algos'
helpers = require 'habitrpg-shared/script/helpers'
items = require 'habitrpg-shared/script/items'
validator = require 'derby-auth/node_modules/validator'
check = validator.check
sanitize = validator.sanitize
utils = require 'derby-auth/utils'
derbyAuthUtil = require('derby-auth/utils')
User = require('./../models/user').model
api = module.exports
###
------------------------------------------------------------------------
Misc
------------------------------------------------------------------------
####
NO_TOKEN_OR_UID = err: "You must include a token and uid (user id) in your request"
NO_USER_FOUND = err: "No user found."
###
beforeEach auth interceptor
###
api.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 and token
User.findOne {_id: uid, apiToken: token}, (err, user) ->
return res.json(500, {err}) if err
return res.json(401, NO_USER_FOUND) if _.isEmpty(user)
res.locals.user = user
next()
###
------------------------------------------------------------------------
Tasks
------------------------------------------------------------------------
###
###
Local Methods
---------------
###
# FIXME put this in helpers, so mobile & web can us it too
# FIXME actually, move to mongoose
taskSanitizeAndDefaults = (task) ->
task.id ?= helpers.uuid()
task.value = ~~task.value
task.type ?= 'habit'
task.text = sanitize(task.text).xss() if _.isString(task.text)
task.notes = sanitize(task.notes).xss() if _.isString(task.text)
if task.type is 'habit'
task.up = true unless _.isBoolean(task.up)
task.down = true unless _.isBoolean(task.down)
if task.type in ['daily', 'todo']
task.completed = false unless _.isBoolean(task.completed)
if task.type is 'daily'
task.repeat ?= {m:true,t:true,w:true,th:true,f:true,s:true,su:true}
task
###
Validate task
###
api.verifyTaskExists = (req, res, next) ->
# If we're updating, get the task from the user
task = res.locals.user.tasks[req.params.id]
return res.json(400, err: "No task found.") if _.isEmpty(task)
res.locals.task = task
next()
addTask = (user, task) ->
taskSanitizeAndDefaults(task)
user.tasks[task.id] = task
user["#{task.type}Ids"].unshift task.id
task
# Override current user.task with incoming values, then sanitize all values
updateTask = (user, id, incomingTask) ->
user.tasks[id] = taskSanitizeAndDefaults _.defaults(incomingTask, user.tasks[id])
deleteTask = (user, task) ->
delete user.tasks[task.id]
if (ids = user["#{task.type}Ids"]) and ~(i = ids.indexOf task.id)
ids.splice(i,1)
###
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 = (req, res, next) ->
{id, direction} = req.params
# Send error responses for improper API call
return res.json(500, {err: ':id required'}) unless id
return res.json(500, {err: ":direction must be 'up' or 'down'"}) unless direction in ['up','down']
{user} = res.locals
# If exists already, score it
if (existing = user.tasks[id])
# Set completed if type is daily or todo and task exists
if existing.type in ['daily', 'todo']
existing.completed = (direction is 'up')
# If it doesn't exist, this is likely a 3rd party up/down - create a new one, then score it
else
task =
id: id
value: 0
type: req.body?.type or 'habit'
text: req.body?.title or 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 is 'habit'
task.up = task.down = true
if task.type in ['daily', 'todo']
task.completed = direction is 'up'
addTask user, task
task = user.tasks[id]
delta = algos.score(user, task, direction)
user.save (err, saved) ->
return res.json(500, {err}) if err
res.json 200, _.extend({delta: delta}, saved.toJSON().stats)
###
Get all tasks
###
api.getTasks = (req, res, next) ->
types =
if req.query.type in ['habit','todo','daily','reward'] then [req.query.type]
else ['habit','todo','daily','reward']
tasks = _.toArray (_.filter res.locals.user.tasks, (t)-> t.type in types)
res.json 200, tasks
###
Get Task
###
api.getTask = (req, res, next) ->
task = res.locals.user.tasks[req.params.id]
return res.json(400, err: "No task found.") if _.isEmpty(task)
res.json 200, task
###
Delete Task
###
api.deleteTask = (req, res, next) ->
deleteTask res.locals.user, res.locals.task
res.locals.user.save (err) ->
return res.json(500, {err}) if err
res.send 204
###
Update Task
###
api.updateTask = (req, res, next) ->
{user} = res.locals
{id} = req.params
updateTask user, id, req.body
user.save (err, saved) ->
return res.json(500, {err}) if err
res.json 200, _.findWhere(saved.toJSON().tasks, {id})
###
Update tasks (plural). This will update, add new, delete, etc all at once.
Should we keep this?
###
api.updateTasks = (req, res, next) ->
{user} = res.locals
tasks = req.body
_.each tasks, (task, idx) ->
if task.id
if task.del # Delete
deleteTask user, task
task = deleted: true
else # Update
updateTask user, task.id, task
else # Create
task = addTask user, task
tasks[idx] = task
user.save (err, saved) ->
return res.json 500, {err:err} if err
res.json 201, tasks
api.createTask = (req, res, next) ->
{user} = res.locals
task = addTask user, req.body
user.save (err) ->
return res.json(500, {err}) if err
res.json 201, task
api.sortTask = (req, res, next) ->
{id} = req.params
{to, from, type} = req.body
{user} = res.locals
path = "#{type}Ids"
user[path].splice(to, 0, user[path].splice(from, 1)[0])
user.save (err, saved) ->
return res.json(500,{err}) if err
res.json 200, saved.toJSON()[path]
###
------------------------------------------------------------------------
Items
------------------------------------------------------------------------
###
api.buy = (req, res, next) ->
{user} = res.locals
type = req.params.type
unless type in ['weapon', 'armor', 'head', 'shield', 'potion']
return res.json(400, err: ":type must be in one of: 'weapon', 'armor', 'head', 'shield', 'potion'")
hasEnough = items.buyItem(user, type)
if hasEnough
user.save (err, saved) ->
return res.json(500,{err}) if err
res.json 200, saved.toJSON().items
else
res.json 200, {err: "Not enough GP"}
###
------------------------------------------------------------------------
User
------------------------------------------------------------------------
###
###
Registers a new user. Only accepting username/password registrations, no Facebook
###
api.registerUser = (req, res, next) ->
{email, username, password, confirmPassword} = req.body
unless username and password and email
return res.json 401, err: ":username, :email, :password, :confirmPassword required"
if password isnt confirmPassword
return res.json 401, err: ":password and :confirmPassword don't match"
try
validator.check(email).isEmail()
catch e
return res.json 401, err: e.message
async.waterfall [
(cb) ->
User.findOne {'auth.local.email':email}, cb
, (found, cb) ->
return cb("Email already taken") if found
User.findOne {'auth.local.username':username}, cb
, (found, cb) ->
return cb("Username already taken") if found
newUser = helpers.newUser(true)
salt = utils.makeSalt()
newUser.auth = local: {username, email, salt}
newUser.auth.local.hashed_password = derbyAuthUtil.encryptPassword(password, salt)
user = new User(newUser)
user.save cb
], (err, saved) ->
return res.json(401, {err}) if err
res.json 200, saved
###
Get User
###
api.getUser = (req, res, next) ->
{user} = res.locals
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
res.json(200, user)
###
Register new user with uname / password
###
api.loginLocal = (req, res, next) ->
{username, password} = req.body
async.waterfall [
(cb) ->
return cb('No username or password') unless username and password
User.findOne {'auth.local.username':username}, cb
, (user, cb) ->
return cb('Username not found') unless user
# 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)
}, cb)
], (err, user) ->
err = 'Incorrect password' unless user
return res.json(401, {err}) if err
res.json 200, {id: user._id, token: user.apiToken}
###
POST /user/auth/facebook
###
api.loginFacebook = (req, res, next) ->
{facebook_id, email, name} = req.body
return res.json(401, err: 'No facebook id provided') unless facebook_id
User.findOne {'auth.local.facebook.id':facebook_id}, (err, user) ->
return res.json(401, {err}) if err
if user
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.")
###
Update user
FIXME add documentation here
###
api.updateUser = (req, res, next) ->
{user} = res.locals
errors = []
return res.json(200, user) if _.isEmpty(req.body)
# 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, (v, k) ->
if (_.find acceptableAttrs, (attr)-> k.indexOf(attr) is 0)?
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}}}`"
true
user.save (err) ->
return res.json(500, {err: errors}) unless _.isEmpty errors
return res.json(500, {err}) if err
res.json 200, user
api.cron = (req, res, next) ->
{user} = res.locals
algos.cron user
#FIXME make sure the variable references got handled properly
user.save next
api.revive = (req, res, next) ->
{user} = res.locals
algos.revive user
user.save (err, saved) ->
return res.json(500,{err}) if err
res.json 200, saved
###
------------------------------------------------------------------------
Batch Update
Run a bunch of updates all at once
------------------------------------------------------------------------
###
api.batchUpdate = (req, res, next) ->
{user} = res.locals
oldSend = res.send
oldJson = res.json
performAction = (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?.id
req.params.direction = action.dir
req.params.type = action.type
req.body = action.data
res.send = res.json = (code, data) ->
console.error({code, data}) if _.isNumber(code) and code >= 400
#FIXME send error messages down
cb()
switch action.op
when "score"
api.scoreTask(req, res)
when "buy"
api.buy(req, res)
when "sortTask"
api.verifyTaskExists req, res, ->
api.sortTask(req, res)
when "addTask"
api.createTask(req, res)
when "delTask"
api.verifyTaskExists req, res, ->
api.deleteTask(req, res)
when "set"
api.updateUser(req, res)
when "revive"
api.revive(req, res)
else cb()
# Setup the array of functions we're going to call in parallel with async
actions = _.transform (req.body ? []), (result, action) ->
unless _.isEmpty(action)
result.push (cb) -> performAction(action, cb)
# call all the operations, then return the user object to the requester
async.series actions, (err) ->
res.json = oldJson; res.send = oldSend
return res.json(500, {err}) if err
res.json 200, user
console.log "Reply sent"