<commit_before>'use strict';

/**
 * Interesting reads:
 * https://blogs.dropbox.com/tech/2016/09/how-dropbox-securely-stores-your-passwords/
 * https://news.ycombinator.com/item?id=12548210
 * https://pulse.michalspacek.cz/passwords/storages
 *
 */

/**
 * define for nested user parameter for box creation request
 * @apiDefine User Parameters for creating a new openSenseMap user
 */

/**
 * @apiDefine UserBody
 *
 * @apiParam (User) {String} name the full name or nickname of the user. The name must consist of at least 4 and up to 40 characters and only allows to use alphanumerics (a-zA-Z0-9), dots (.), dashes (-), underscores (_) and spaces. The first character must be a letter or number.
 * @apiParam (User) {String} email the email for the user. Is used for signing in and for sending the arduino sketch.
 * @apiParam (User) {String} password the desired password for the user. Must be at least 8 characters long.
 * @apiParam (User) {String} [language=en_US] the language of the user. Used for the website and mails
 *
 */

/**
 * @apiDefine JWTokenAuth
 *
 * @apiHeader {String} Authorization allows to send a valid JSON Web Token along with this request with `Bearer` prefix.
 * @apiHeaderExample {String} Authorization Header Example
 *   Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE0ODMwMDYxNTIsImV4cCI6MTQ4MzAwOTc1MiwiaXNzIjoibG9jYWxob3N0OjgwMDAiLCJzdWIiOiJ0ZXN0QHRlc3QuZGUiLCJqdGkiOiJmMjNiOThkNi1mMjRlLTRjOTQtYWE5Ni1kMWI4M2MzNmY1MjAifQ.QegeWHWetw19vfgOvkTCsBfaSOPnjakhzzRjVtNi-2Q
 * @apiError {String} 403 Unauthorized
 */

const { mongoose } = require('../db'),
  Schema = mongoose.Schema,
  bcrypt = require('bcrypt'),
  crypto = require('crypto'),
  utils = require('../utils'),
  uuid = require('uuid'),
  jwt = require('jsonwebtoken'),
  Box = require('./box').model,
  mails = require('../mails'),
  sketches = require('../sketches'),
  moment = require('moment'),
  hashJWT = require('../helpers/jwtRefreshTokenHasher'),
  timestamp = require('mongoose-timestamp');

const { config } = utils;
const { salt_factor, password_min_length, jwt_algorithm, jwt_secret, origin } = config;

const userNameRequirementsText = 'Parameter name must consist of at least 3 and up to 40 alphanumerics (a-zA-Z0-9), dot (.), dash (-), underscore (_) and spaces.',
  userEmailRequirementsText = 'Parameter {PATH} is not a valid email address.';

const nameValidRegex = /^[\u00C0-\u1FFF\u2C00-\uD7FF\w-\.][\u00C0-\u1FFF\u2C00-\uD7FF\w\s-\.]+[\u00C0-\u1FFF\u2C00-\uD7FF\w\.]$/,
  emailValidRegex = /^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/;

const userSchema = new Schema({
  name: {
    type: String,
    required: true,
    unique: true,
    minlength: [ 3, userNameRequirementsText ],
    maxlength: [ 40, userNameRequirementsText ],
    validate: {
      validator: function (v) {
        return nameValidRegex.test(v);
      },
      message: userNameRequirementsText
    }
  },
  email: {
    type: String,
    required: true,
    trim: true,
    lowercase: true,
    unique: true,
    validate: {
      validator: function (v) {
        return emailValidRegex.test(v);
      },
      message: userEmailRequirementsText
    }
  },
  unconfirmedEmail: {
    type: String,
    trim: true,
    lowercase: true,
  },
  boxes: [{
    type: mongoose.Schema.Types.ObjectId,
    ref: 'Box'
  }],
  language: {
    type: String,
    trim: true,
    default: 'en_US'
  },
  hashedPassword: {
    type: String,
    required: true
  },
  role: {
    type: String,
    required: true,
    enum: ['user', 'admin'],
    default: 'user'
  },
  resetPasswordToken: { type: String },
  resetPasswordExpires: { type: Date },
  lastUpdatedBy: {
    type: String,
    required: true,
    default: 'System'
  },
  emailConfirmationToken: { type: String, default: uuid },
  emailIsConfirmed: { type: Boolean, default: false, required: true },
  refreshToken: { type: String },
  refreshTokenExpires: { type: Date }
});
userSchema.plugin(timestamp);

// only send out names and email..
userSchema.set('toJSON', {
  version: false,
  transform: function transform (doc, ret) {
    const { name, email, role, language, boxes, emailIsConfirmed } = ret;

    return { name, email, role, language, boxes, emailIsConfirmed };
  }
});

// ----  Password stuff -----
userSchema.virtual('password')
  .get(function () {
    return this._password;
  })
  .set(function (value) {
    this._password = value;
    this.hashedPassword = '<PASSWORD>';
    // also set resetPasswordToken
    this.resetPasswordToken = '';
    // set expires to one hour in the past
    this.resetPasswordExpires = moment.utc()
      .subtract(1, 'hour')
      .toDate();
  });

const passwordHasher = function passwordHasher (plaintextPassword) {
  // first round: hash plaintextPassword with sha512
  const hash = crypto.createHash('sha512');

  hash.update(plaintextPassword, 'utf8');

  const hashed = hash.digest('base64'); // base64 for more entroy than hex

  return bcrypt.hash(hashed, salt_factor); // signature <String, Number> generates a salt and hashes in one step
};

userSchema.statics.validatePassword = function validatePassword (newPassword) {
  return newPassword.length >= password_min_length;
};

userSchema.path('hashedPassword').validate(function () {
  if (this._password) {
    if (this.schema.statics.validatePassword(this._password) === false) {
      return this.invalidate('password', `must be at least ${password_min_length} characters.`);
    }
  }

  if (this.isNew && !this._password) {
    return this.invalidate('password', 'is required');
  }
});

// this validation runs every time an user wants to change their email address
// it checks if the new address isn't already existant in the database
// and also validates against the email validation regex
userSchema.path('unconfirmedEmail').validate(function validateUnconfirmedEmail (value, callback) {
  if (value !== '' || value !== this.email) {
    if (!emailValidRegex.test(value)) {
      return callback(false);
    }

    this.model('User')
      .count({ $or: [ { email: value }, { unconfirmedEmail: value } ] })
      .then(function (count) {
        callback(count === 0);
      })
      .catch(function (err) {
        /* eslint-disable callback-return */
        callback(false);
        throw err;
        /* eslint-enable callback-return */
      });
  }

  callback(true);
});

userSchema.pre('save', function userPreSave (next) {
  this.wasNew = this.isNew;
  next();
});

// runs after successful save of users
userSchema.post('save', function userPostSaveSendMails (user) {
  if (user.wasNew) {
    user.mail('newUser');

    return;
  }

  if (user.unconfirmedEmail && user.unconfirmedEmail !== '') {
    user.mail('confirmEmail');
  }
});

userSchema.pre('save', function userPreHashPassword (next) {
  const user = this;
<commit_msg>mark validator in user model explicitly async<commit_after>'use strict';

/**
 * Interesting reads:
 * https://blogs.dropbox.com/tech/2016/09/how-dropbox-securely-stores-your-passwords/
 * https://news.ycombinator.com/item?id=12548210
 * https://pulse.michalspacek.cz/passwords/storages
 *
 */

/**
 * define for nested user parameter for box creation request
 * @apiDefine User Parameters for creating a new openSenseMap user
 */

/**
 * @apiDefine UserBody
 *
 * @apiParam (User) {String} name the full name or nickname of the user. The name must consist of at least 4 and up to 40 characters and only allows to use alphanumerics (a-zA-Z0-9), dots (.), dashes (-), underscores (_) and spaces. The first character must be a letter or number.
 * @apiParam (User) {String} email the email for the user. Is used for signing in and for sending the arduino sketch.
 * @apiParam (User) {String} password the desired password for the user. Must be at least 8 characters long.
 * @apiParam (User) {String} [language=en_US] the language of the user. Used for the website and mails
 *
 */

/**
 * @apiDefine JWTokenAuth
 *
 * @apiHeader {String} Authorization allows to send a valid JSON Web Token along with this request with `Bearer` prefix.
 * @apiHeaderExample {String} Authorization Header Example
 *   Authorization: Bearer <KEY>
 * @apiError {String} 403 Unauthorized
 */

const { mongoose } = require('../db'),
  Schema = mongoose.Schema,
  bcrypt = require('bcrypt'),
  crypto = require('crypto'),
  utils = require('../utils'),
  uuid = require('uuid'),
  jwt = require('jsonwebtoken'),
  Box = require('./box').model,
  mails = require('../mails'),
  sketches = require('../sketches'),
  moment = require('moment'),
  hashJWT = require('../helpers/jwtRefreshTokenHasher'),
  timestamp = require('mongoose-timestamp');

const { config } = utils;
const { salt_factor, password_min_length, jwt_algorithm, jwt_secret, origin } = config;

const userNameRequirementsText = 'Parameter name must consist of at least 3 and up to 40 alphanumerics (a-zA-Z0-9), dot (.), dash (-), underscore (_) and spaces.',
  userEmailRequirementsText = 'Parameter {PATH} is not a valid email address.';

const nameValidRegex = /^[\u00C0-\u1FFF\u2C00-\uD7FF\w-\.][\u00C0-\u1FFF\u2C00-\uD7FF\w\s-\.]+[\u00C0-\u1FFF\u2C00-\uD7FF\w\.]$/,
  emailValidRegex = /^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/;

const userSchema = new Schema({
  name: {
    type: String,
    required: true,
    unique: true,
    minlength: [ 3, userNameRequirementsText ],
    maxlength: [ 40, userNameRequirementsText ],
    validate: {
      validator: function (v) {
        return nameValidRegex.test(v);
      },
      message: userNameRequirementsText
    }
  },
  email: {
    type: String,
    required: true,
    trim: true,
    lowercase: true,
    unique: true,
    validate: {
      validator: function (v) {
        return emailValidRegex.test(v);
      },
      message: userEmailRequirementsText
    }
  },
  unconfirmedEmail: {
    type: String,
    trim: true,
    lowercase: true,
  },
  boxes: [{
    type: mongoose.Schema.Types.ObjectId,
    ref: 'Box'
  }],
  language: {
    type: String,
    trim: true,
    default: 'en_US'
  },
  hashedPassword: {
    type: String,
    required: true
  },
  role: {
    type: String,
    required: true,
    enum: ['user', 'admin'],
    default: 'user'
  },
  resetPasswordToken: { type: String },
  resetPasswordExpires: { type: Date },
  lastUpdatedBy: {
    type: String,
    required: true,
    default: 'System'
  },
  emailConfirmationToken: { type: String, default: uuid },
  emailIsConfirmed: { type: Boolean, default: false, required: true },
  refreshToken: { type: String },
  refreshTokenExpires: { type: Date }
});
userSchema.plugin(timestamp);

// only send out names and email..
userSchema.set('toJSON', {
  version: false,
  transform: function transform (doc, ret) {
    const { name, email, role, language, boxes, emailIsConfirmed } = ret;

    return { name, email, role, language, boxes, emailIsConfirmed };
  }
});

// ----  Password stuff -----
userSchema.virtual('password')
  .get(function () {
    return this._password;
  })
  .set(function (value) {
    this._password = value;
    this.hashedPassword = '<PASSWORD>';
    // also set resetPasswordToken
    this.resetPasswordToken = '';
    // set expires to one hour in the past
    this.resetPasswordExpires = moment.utc()
      .subtract(1, 'hour')
      .toDate();
  });

const passwordHasher = function passwordHasher (plaintextPassword) {
  // first round: hash plaintextPassword with sha512
  const hash = crypto.createHash('sha512');

  hash.update(plaintextPassword, 'utf8');

  const hashed = hash.digest('base64'); // base64 for more entroy than hex

  return bcrypt.hash(hashed, salt_factor); // signature <String, Number> generates a salt and hashes in one step
};

userSchema.statics.validatePassword = function validatePassword (newPassword) {
  return newPassword.length >= password_min_length;
};

userSchema.path('hashedPassword').validate(function () {
  if (this._password) {
    if (this.schema.statics.validatePassword(this._password) === false) {
      return this.invalidate('password', `must be at least ${password_min_length} characters.`);
    }
  }

  if (this.isNew && !this._password) {
    return this.invalidate('password', 'is required');
  }
});

// this validation runs every time an user wants to change their email address
// it checks if the new address isn't already existant in the database
// and also validates against the email validation regex
userSchema.path('unconfirmedEmail').validate({
  isAsync: true,
  validator: function (value, callback) {
    if (value !== '' || value !== this.email) {
      if (!emailValidRegex.test(value)) {
        return callback(false);
      }

      this.model('User')
        .count({ $or: [ { email: value }, { unconfirmedEmail: value } ] })
        .then(function (count) {
          callback(count === 0);
        })
        .catch(function (err) {
          /* eslint-disable callback-return */
          callback(false);
          throw err;
          /* eslint-enable callback-return */
        });
    }

    callback(true);
  }
});

userSchema.pre('save', function userPreSave (next) {
  this.wasNew = this.isNew;
  next();
});

// runs after successful save of users
userSchema.post('save', function userPostSaveSendMails (user) {
  if (user.wasNew) {
    user.mail('newUser');

    return;
  }

  if (user.unconfirmedEmail && user.unconfirmedEmail !== '') {
    user.mail('confirmEmail');
  }
});

userSchema.pre('save', function userPreHashPassword (next) {
  const user = this;

{'fraction_non_alphanumeric': 0.1417066863686738, 'fraction_numerical': 0.012707794606575545, 'mean_word_length': 3.438032786885246, 'pattern_counts': {'":': 0, '<': 8, '<?xml version=': 0, '>': 10, 'https://': 6, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '1156874', 'n_tokens_mistral': 4495, 'n_tokens_neox': 4334, 'n_words': 1133}