Artifact Content
Not logged in

Artifact 3470902f9c65ddfdc763b6ad03fdf9d964ef10b7:


'use strict'

module.exports = UsersControllerFactory

const fs = require('fs')
const path = require('path')
const request = require('five-bells-shared/utils/request')
const Auth = require('../lib/auth')
const Log = require('../lib/log')
const Ledger = require('../lib/ledger')
const Config = require('../lib/config')
const Mailer = require('../lib/mailer')
const Pay = require('../lib/pay')
const Antifraud = require('../lib/antifraud')
const UserFactory = require('../models/user')
const InviteFactory = require('../models/invite')
const Database = require('../lib/db')
const SPSP = require('../lib/spsp')

const UsernameTakenError = require('../errors/username-taken-error')
const EmailTakenError = require('../errors/email-taken-error')
const PasswordsDontMatchError = require('../errors/passwords-dont-match-error')
const InvalidVerification = require('../errors/invalid-verification-error')
const ServerError = require('../errors/server-error')
const InvalidBodyError = require('../errors/invalid-body-error')
const NotFoundError = require('../errors/not-found-error')

const USERNAME_REGEX = /^[a-z0-9]([a-z0-9]|[-](?!-)){0,18}[a-z0-9]$/

function UsersControllerFactory (deps) {
  const sequelize = deps(Database)
  const auth = deps(Auth)
  const User = deps(UserFactory)
  const Invite = deps(InviteFactory)
  const log = deps(Log)('users')
  const ledger = deps(Ledger)
  const config = deps(Config)
  const mailer = deps(Mailer)
  const pay = deps(Pay)
  const spsp = deps(SPSP)
  const antifraud = deps(Antifraud)

  return class UsersController {
    static init (router) {
      router.get('/users/:username', auth.checkAuth, this.getResource)
      router.post('/users/:username', User.createBodyParser(), this.postResource)
      router.put('/users/:username', auth.checkAuth, this.putResource)
      router.post('/users/:username/reload', auth.checkAuth, this.reload)
      router.get('/users/:username/profilepic', this.getProfilePicture)

      // Email verification
      router.put('/users/:username/verify', this.verify)
      router.post('/users/:username/resend-verification', this.resendVerification)

      // Admin
      router.get('/users', this.checkAdmin, this.getAll)
    }

    // TODO move to auth
    static async checkAdmin (ctx, next) {
      if (ctx.req.user.username === config.data.getIn(['ledger', 'admin', 'user'])) {
        return next()
      }

      throw new NotFoundError()
    }

    static async getAll (ctx) {
      const balances = (await ledger.getAccounts()).reduce((agg, user) => {
        agg[user.name] = user.balance
        return agg
      }, {})

      ctx.body = (await User.findAll()).map(user =>
        Object.assign({}, user, { balance: balances[user.username] }))
    }

    /**
     * @api {get} /users/:username Get user
     * @apiName GetUser
     * @apiGroup User
     * @apiVersion 1.0.0
     *
     * @apiDescription Get user
     *
     * @apiExample {shell} Get user
     *    curl -X GET -H "Authorization: Basic YWxpY2U6YWxpY2U="
     *    https://wallet.example/api/users/alice
     *
     * @apiSuccessExample {json} 200 Response:
     *    HTTP/1.1 200 OK
     *    {
     *      "username": "alice",
     *      "name": "Alice Faye",
     *      "balance": "1000",
     *      "id": 1
     *    }
     */
    static async getResource (ctx) {
      let username = ctx.params.username
      request.validateUriParameter('username', username, 'Identifier')
      username = username.toLowerCase()

      if (ctx.req.user.username !== username && ctx.req.user.username !== config.data.getIn(['ledger', 'admin', 'user'])) throw new NotFoundError()

      const dbUser = await User.findOne({where: {username: username}})
      const user = await dbUser.appendLedgerAccount()

      ctx.body = user.getDataExternal()
    }

    /**
     * @api {post} /users/:username Create a user
     * @apiName PostUser
     * @apiGroup User
     * @apiVersion 1.0.0
     *
     * @apiParam {String} username username
     * @apiParam {String} password password
     *
     * @apiExample {shell} Post user
     *    curl -X POST -H "Content-Type: application/json" -d
     *    '{
     *        "password": "alice"
     *    }'
     *    https://wallet.example/api/users/alice
     *
     * @apiSuccessExample {json} 200 Response:
     *    HTTP/1.1 201 OK
     *    {
     *      "username": "alice",
     *      "balance": "1000",
     *      "id": 1
     *    }
     */

    // Only supports create
    static async postResource (ctx) {
      const userObj = ctx.body

      // check if registration is enabled
      if (!config.data.get('registration') && !userObj.inviteCode) {
        throw new InvalidBodyError('Registration is disabled without an invite code')
      }

      let username = ctx.params.username.toLowerCase()
      if (!USERNAME_REGEX.test(username)) {
        throw new InvalidBodyError('Username must be 2-20 characters, lowercase letters, numbers and hyphens ("-") only, with no two or more consecutive hyphens.')
      }

      await antifraud.checkRisk(userObj) // throws if fraud risk is too high

      let dbUser
      let invite
      await sequelize.transaction(async function (t) {
        const opts = {transaction: t}

        userObj.username = username
        // TODO:BEFORE_DEPLOY make sure doesn't already exist (do the same for peers)
        userObj.destination = parseInt(Math.random() * 1000000)

        dbUser = new User()
        dbUser.setDataExternal(userObj)

        // Check if invite code is valid
        if (userObj.inviteCode) {
          invite = await Invite.findOne(Object.assign({
            where: {
              code: userObj.inviteCode,
              claimed: false
            }
          }, opts))

          if (invite) {
            dbUser.invite_code = invite.code
          } else if (!config.data.get('registration')) {
            throw new InvalidBodyError('The invite code is wrong')
          }
        }

        // Create the db user
        try {
          dbUser = await dbUser.save(opts)
          dbUser = User.fromDatabaseModel(dbUser)

          if (invite) {
            invite.user_id = dbUser.id
            // throws if the user identified by user_id has already claimed another invite
            await invite.save(opts)
          }
        } catch (e) {
          let errorMsg = e.errors && e.errors[0] && e.errors[0].message
          if (errorMsg === 'username must be unique') {
            throw new UsernameTakenError('Username already registered.')
          } else if (errorMsg === 'email must be unique') {
            throw new EmailTakenError('Email already registered.')
          // } else if (errorMsg === 'invite_code must be unique') {
          } else {
            log.error(e)
            throw new ServerError()
          }
        }

        try {
          // Sanity check: Verify that a ledger account with that name does not exist yet
          // Otherwise an attacker could take over a ledger account
          // for which no ILP kit account exits
          const exists = await ledger.existsAccount(userObj)
          if (!exists) {
            await ledger.createAccount(userObj)
          } else {
            throw new Error(`Username ${userObj.username} already exists on the ledger` +
              ', but not in the ILP kit.')
          }
        } catch (e) {
          log.error(e)
          throw new UsernameTakenError('Ledger rejected username')
        }
      }).then(async function (result) {
        // transaction was commited
        await UsersController._onboardUser(ctx, invite, dbUser)
      }).catch(function (e) {
        // transaction was rolled back
        log.debug(e)
        throw e
      })
    }

    static async _handleInvite (invite, username) {
      try {
        if (invite) {
          if (invite.amount) {
            // Admin account funding the new account
            const admin = await User.findOne({
              where: {
                username: config.data.getIn(['ledger', 'admin', 'user'])
              }
            })
            const destination = username + '@' + config.data.getIn(['server', 'public_host'])
            const quoteReq = {
              user: admin.getDataExternal(),
              destination: destination,
              sourceAmount: invite.amount
            }

            // Get a quote
            const quote = await spsp.quote(quoteReq)

            // Send the invite money
            await pay.pay({
              user: admin.getDataExternal(),
              destination: destination,
              quote: quote
            })
            invite.claimed = true
            invite.save()
          }
        }
      } catch (e) {
        // TODO User did not receive his invite money
        log.error(e)
        throw new ServerError()
      }
    }

    static async _onboardUser (ctx, invite, dbUser) {
      if (invite) {
        await UsersController._handleInvite(invite, dbUser.username)
      }

      // Fund the newly created account
      if (config.data.get('reload')) {
        await UsersController._reload(dbUser)
      }

      // Send a welcome email
      await mailer.sendWelcome({
        name: dbUser.username,
        to: dbUser.email,
        link: User.getVerificationLink(dbUser.username, dbUser.email)
      })

      const user = await dbUser.appendLedgerAccount()

      // TODO callbacks?
      ctx.logIn(user, err => {
        if (err) {
          log.error('error while logging in: %s', err)
        }
      })

      log.debug('created user ' + dbUser.username)

      ctx.body = user.getDataExternal()
      ctx.status = 201
    }

    static async _reload (user) {
      // Admin account funding the new account
      const source = await User.findOne({
        where: {
          username: config.data.getIn(['ledger', 'admin', 'user'])
        }
      })

      const quote = await spsp.quote({
        user: source.getDataExternal(),
        destination: user.username + '@' + config.data.getIn(['server', 'public_host']),
        destinationAmount: 1000
      })

      quote.memo = 'Free money'

      // Send the money
      await pay.pay({
        user: source.getDataExternal(),
        destination: user.username,
        quote
      })
    }

    /**
     * @api {put} /users/:username Update user
     * @apiName PutUser
     * @apiGroup User
     * @apiVersion 1.0.0
     *
     * @apiParam {String} username username
     *
     * @apiExample {shell} Update user email
     *    curl -X PUT -H "Authorization: Basic YWxpY2U6YWxpY2U=" -H "Content-Type: application/json" -d
     *    '{
     *        "email": "alice@example.com"
     *        "name": "Alice Faye"
     *    }'
     *    https://wallet.example/api/users/alice
     *
     * @apiSuccessExample {json} 200 Response:
     *    HTTP/1.1 200 OK
     *    {
     *      "username": "alice",
     *      "name": "Alice Faye",
     *      "balance": "1000",
     *      "id": 1
     *    }
     */
    static async putResource (ctx) {
      const data = ctx.body
      const user = await User.findOne({ where: {id: ctx.req.user.id} })

      // Is the current password right?
      await ledger.getAccount({
        username: user.username,
        password: data.password
      })

      // TODO:SECURITY sanity checking

      // Password change
      if (data.newPassword) {
        if (data.newPassword !== data.verifyNewPassword) {
          throw new PasswordsDontMatchError('Passwords don\'t match')
        }

        // Update the ledger user
        await ledger.updateAccount({
          username: user.username,
          password: data.password,
          newPassword: data.newPassword
        })

        // If this is the admin, update the environment and the env.list file too
        if (user.isAdmin) {
          config.changeAdminPass(data.newPassword)
        }

        user.password = data.newPassword
      }

      if (data.email) {
        await user.changeEmail(data.email)

        await mailer.changeEmail({
          name: user.username,
          to: user.email,
          link: User.getVerificationLink(user.username, user.email)
        })
      }

      user.name = data.name

      try {
        await user.save()

        ctx.logIn(await user.appendLedgerAccount(), err => {
          if (err) {
            log.error('error while logging in: %s', err)
          }
        })
        ctx.body = user.getDataExternal()
      } catch (e) {
        log.warn(e)
        throw new ServerError()
      }
    }

    // This will only reload if the "reload" env var is true
    static async reload (ctx) {
      if (!config.data.get('reload')) {
        ctx.status = 404
        return
      }

      const user = ctx.req.user

      await UsersController._reload(user)

      ctx.status = 200
    }

    /**
     * @api {put} /users/:username/verify Verify user email address
     * @apiName VerifyUser
     * @apiGroup User
     * @apiVersion 1.0.0
     *
     * @apiParam {String} username username
     * @apiParam {String} code verification code
     *
     * @apiExample {shell} Verify user email address
     *    curl -X PUT -H "Authorization: Basic YWxpY2U6YWxpY2U=" -H "Content-Type: application/json" -d
     *    '{
     *        "code": "1f7aade2946667fac85ebaf7259182ead6b1fe062b5e8bb0ffa1b9d417431acb"
     *    }'
     *    https://wallet.example/api/users/alice/verify
     *
     * @apiSuccessExample {json} 200 Response:
     *    HTTP/1.1 200 OK
     *    {
     *      "username": "alice",
     *      "balance": "1000",
     *      "id": 1,
     *      "email_verified": true
     *    }
     */
    static async verify (ctx) {
      let username = ctx.params.username
      request.validateUriParameter('username', username, 'Identifier')
      username = username.toLowerCase()

      const dbUser = await User.findOne({where: {username: username}})

      // Code is wrong
      if (ctx.body.code !== User.getVerificationCode(dbUser.email)) {
        throw new InvalidVerification('Verification code is invalid')
      }

      // TODO different result if the user has already been verified
      dbUser.email_verified = true
      await dbUser.save()

      ctx.status = 200
    }

    /**
     * @api {post} /users/:username/resend-verification Resend verification email
     * @apiName ResendVerificationEmail
     * @apiGroup User
     * @apiVersion 1.0.0
     *
     * @apiParam {String} username username
     *
     * @apiExample {shell} Resend verification email
     *    curl -X POST
     *    https://wallet.example/api/users/alice/resend-verification
     *
     * @apiSuccessExample {json} 200 Response:
     *    HTTP/1.1 200 OK
     */
    static async resendVerification (ctx) {
      let username = ctx.params.username
      request.validateUriParameter('username', username, 'Identifier')
      username = username.toLowerCase()

      const dbUser = await User.findOne({where: {username: username}})

      // TODO could sometimes be sendWelcome
      await mailer.changeEmail({
        name: dbUser.username,
        to: dbUser.email,
        link: User.getVerificationLink(dbUser.username, dbUser.email)
      })

      ctx.status = 200
    }

    /**
     * @api {get} /receivers/:username Get receiver details
     * @apiName GetReceiver
     * @apiGroup Receiver
     * @apiVersion 1.0.0
     *
     * @apiParam {String} username receiver username
     *
     * @apiExample {shell} Get receiver details
     *    curl -X GET
     *    https://wallet.example/api/receivers/alice
     *
     * @apiSuccessExample {json} 200 Response:
     *    HTTP/1.1 200 OK
     *    {
     *      "type": "payee",
     *      "account": "wallet.alice",
     *      "currency_code": "USD",
     *      "currency_scale": 2,
     *      "name": "Alice Faye",
     *      "image_url": "https://server.example/picture.jpg"
     *    }
     */
    static async getReceiver (ctx) {
      const ledgerPrefix = config.data.getIn(['ledger', 'prefix'])
      let user = await User.findOne({where: {username: ctx.params.username}})

      if (!user) throw new NotFoundError('User does not exist')

      user = user.getDataExternal()

      ctx.body = {
        'type': 'payee',
        'account': ledgerPrefix + user.username,
        'currency_code': config.data.getIn(['ledger', 'currency', 'code']),
        'currency_scale': config.data.getIn(['ledger', 'currency', 'scale']),
        'name': user.name,
        'image_url': user.profile_picture
      }
    }

    static async getProfilePicture (ctx) {
      const user = await User.findOne({ where: { username: ctx.params.username } })

      if (!user) throw new NotFoundError()

      let filePath = path.resolve(__dirname, '../placeholder.png')

      if (user.profile_picture) {
        const profilePic = path.resolve(__dirname, '../../uploads/', user.profile_picture)

        if (fs.existsSync(profilePic)) {
          filePath = profilePic
        }
      }

      ctx.body = fs.readFileSync(filePath)
    }
  }
}