'use strict'; exports = module.exports = { removePrivateFields, removeRestrictedFields, getAll, getAllPaged, create, isActivated, verify, verifyWithUsername, verifyWithEmail, remove, get, getByResetToken, getByUsername, getAdmins, getSuperadmins, setPassword, update, createOwner, getOwner, createInvite, sendInvite, setMembership, setTwoFactorAuthenticationSecret, enableTwoFactorAuthentication, disableTwoFactorAuthentication, sendPasswordResetByIdentifier, checkLoginLocation, setupAccount, getAvatarUrl, setAvatar, getAvatar, count, AP_MAIL: 'mail', AP_WEBADMIN: 'webadmin', ROLE_ADMIN: 'admin', ROLE_USER: 'user', ROLE_USER_MANAGER: 'usermanager', ROLE_OWNER: 'owner', compareRoles, getAppPasswords, getAppPassword, addAppPassword, delAppPassword }; const ORDERED_ROLES = [ exports.ROLE_USER, exports.ROLE_USER_MANAGER, exports.ROLE_ADMIN, exports.ROLE_OWNER ]; let assert = require('assert'), BoxError = require('./boxerror.js'), crypto = require('crypto'), constants = require('./constants.js'), debug = require('debug')('box:user'), eventlog = require('./eventlog.js'), externalLdap = require('./externalldap.js'), groups = require('./groups.js'), hat = require('./hat.js'), mailer = require('./mailer.js'), paths = require('./paths.js'), qrcode = require('qrcode'), safe = require('safetydance'), settings = require('./settings.js'), speakeasy = require('speakeasy'), tokens = require('./tokens.js'), userdb = require('./userdb.js'), uuid = require('uuid'), superagent = require('superagent'), validator = require('validator'), _ = require('underscore'); var CRYPTO_SALT_SIZE = 64; // 512-bit salt var CRYPTO_ITERATIONS = 10000; // iterations var CRYPTO_KEY_LENGTH = 512; // bits var CRYPTO_DIGEST = 'sha1'; // used to be the default in node 4.1.1 cannot change since it will affect existing db records // keep this in sync with validateGroupname and validateAlias function validateUsername(username) { assert.strictEqual(typeof username, 'string'); if (username.length < 1) return new BoxError(BoxError.BAD_FIELD, 'Username must be atleast 1 char'); if (username.length >= 200) return new BoxError(BoxError.BAD_FIELD, 'Username too long'); if (constants.RESERVED_NAMES.indexOf(username) !== -1) return new BoxError(BoxError.BAD_FIELD, 'Username is reserved'); // also need to consider valid LDAP characters here (e.g '+' is reserved). apps like openvpn require _ to not be used if (/[^a-zA-Z0-9.-]/.test(username)) return new BoxError(BoxError.BAD_FIELD, 'Username can only contain alphanumerals, dot and -'); // app emails are sent using the .app suffix if (username.indexOf('.app') !== -1) return new BoxError(BoxError.BAD_FIELD, 'Username pattern is reserved for apps'); return null; } function validateEmail(email) { assert.strictEqual(typeof email, 'string'); if (!validator.isEmail(email)) return new BoxError(BoxError.BAD_FIELD, 'Invalid email'); return null; } function validateToken(token) { assert.strictEqual(typeof token, 'string'); if (token.length !== 64) return new BoxError(BoxError.BAD_FIELD, 'Invalid token'); // 256-bit hex coded token return null; } function validateDisplayName(name) { assert.strictEqual(typeof name, 'string'); return null; } function validatePassword(password) { assert.strictEqual(typeof password, 'string'); if (password.length < 8) return new BoxError(BoxError.BAD_FIELD, 'Password must be atleast 8 characters'); if (password.length > 256) return new BoxError(BoxError.BAD_FIELD, 'Password cannot be more than 256 characters'); return null; } // remove all fields that should never be sent out via REST API function removePrivateFields(user) { return _.pick(user, 'id', 'username', 'email', 'fallbackEmail', 'displayName', 'groupIds', 'active', 'source', 'role', 'createdAt', 'twoFactorAuthenticationEnabled'); } // remove all fields that Non-privileged users must not see function removeRestrictedFields(user) { return _.pick(user, 'id', 'username', 'email', 'displayName', 'active'); } function create(username, password, email, displayName, options, auditSource, callback) { assert(username === null || typeof username === 'string'); assert(password === null || typeof password === 'string'); assert.strictEqual(typeof email, 'string'); assert.strictEqual(typeof displayName, 'string'); assert(options && typeof options === 'object'); assert(auditSource && typeof auditSource === 'object'); const source = options.source || ''; // empty is local user const role = options.role || exports.ROLE_USER; var error; if (username !== null) { username = username.toLowerCase(); error = validateUsername(username); if (error) return callback(error); } if (password !== null) { error = validatePassword(password); if (error) return callback(error); } else { password = hat(8 * 8); } email = email.toLowerCase(); error = validateEmail(email); if (error) return callback(error); error = validateDisplayName(displayName); if (error) return callback(error); error = validateRole(role); if (error) return callback(error); crypto.randomBytes(CRYPTO_SALT_SIZE, function (error, salt) { if (error) return callback(new BoxError(BoxError.CRYPTO_ERROR, error)); crypto.pbkdf2(password, salt, CRYPTO_ITERATIONS, CRYPTO_KEY_LENGTH, CRYPTO_DIGEST, function (error, derivedKey) { if (error) return callback(new BoxError(BoxError.CRYPTO_ERROR, error)); var now = (new Date()).toISOString(); var user = { id: 'uid-' + uuid.v4(), username: username, email: email, fallbackEmail: email, password: Buffer.from(derivedKey, 'binary').toString('hex'), salt: salt.toString('hex'), createdAt: now, resetToken: '', displayName: displayName, source: source, role: role }; userdb.add(user.id, user, function (error) { if (error) return callback(error); // when this is used to create the owner, then we have to patch the auditSource to contain himself if (!auditSource.userId) auditSource.userId = user.id; if (!auditSource.username) auditSource.username= user.username; eventlog.add(eventlog.ACTION_USER_ADD, auditSource, { userId: user.id, email: user.email, user: removePrivateFields(user) }); callback(null, user); }); }); }); } // returns true if ghost user was matched function verifyGhost(username, password) { assert.strictEqual(typeof username, 'string'); assert.strictEqual(typeof password, 'string'); var ghostData = safe.JSON.parse(safe.fs.readFileSync(paths.GHOST_USER_FILE, 'utf8')); if (!ghostData) return false; if (username in ghostData && ghostData[username] === password) { debug('verifyGhost: matched ghost user'); safe.fs.unlinkSync(paths.GHOST_USER_FILE); return true; } return false; } function verifyAppPassword(userId, password, identifier, callback) { assert.strictEqual(typeof userId, 'string'); assert.strictEqual(typeof password, 'string'); assert.strictEqual(typeof identifier, 'string'); assert.strictEqual(typeof callback, 'function'); userdb.getAppPasswords(userId, function (error, results) { if (error) return callback(error); const hashedPasswords = results.filter(r => r.identifier === identifier).map(r => r.hashedPassword); let hash = crypto.createHash('sha256').update(password).digest('base64'); if (hashedPasswords.includes(hash)) return callback(null); return callback(new BoxError(BoxError.INVALID_CREDENTIALS)); }); } function verify(userId, password, identifier, callback) { assert.strictEqual(typeof userId, 'string'); assert.strictEqual(typeof password, 'string'); assert.strictEqual(typeof identifier, 'string'); assert.strictEqual(typeof callback, 'function'); get(userId, function (error, user) { if (error) return callback(error); if (!user.active) return callback(new BoxError(BoxError.NOT_FOUND)); // for just invited users the username may be still null if (user.username && verifyGhost(user.username, password)) { user.ghost = true; return callback(null, user); } verifyAppPassword(user.id, password, identifier, function (error) { if (!error) { user.appPassword = true; return callback(null, user); } if (user.source === 'ldap') { externalLdap.verifyPassword(user, password, function (error) { if (error) return callback(error); callback(null, user); }); } else { var saltBinary = Buffer.from(user.salt, 'hex'); crypto.pbkdf2(password, saltBinary, CRYPTO_ITERATIONS, CRYPTO_KEY_LENGTH, CRYPTO_DIGEST, function (error, derivedKey) { if (error) return callback(new BoxError(BoxError.CRYPTO_ERROR, error)); var derivedKeyHex = Buffer.from(derivedKey, 'binary').toString('hex'); if (derivedKeyHex !== user.password) return callback(new BoxError(BoxError.INVALID_CREDENTIALS)); callback(null, user); }); } }); }); } function verifyWithUsername(username, password, identifier, callback) { assert.strictEqual(typeof username, 'string'); assert.strictEqual(typeof password, 'string'); assert.strictEqual(typeof identifier, 'string'); assert.strictEqual(typeof callback, 'function'); userdb.getByUsername(username.toLowerCase(), function (error, user) { if (error) return callback(error); verify(user.id, password, identifier, callback); }); } function verifyWithEmail(email, password, identifier, callback) { assert.strictEqual(typeof email, 'string'); assert.strictEqual(typeof password, 'string'); assert.strictEqual(typeof identifier, 'string'); assert.strictEqual(typeof callback, 'function'); userdb.getByEmail(email.toLowerCase(), function (error, user) { if (error) return callback(error); verify(user.id, password, identifier, callback); }); } function remove(user, auditSource, callback) { assert.strictEqual(typeof user, 'object'); assert(auditSource && typeof auditSource === 'object'); assert.strictEqual(typeof callback, 'function'); if (settings.isDemo() && user.username === constants.DEMO_USERNAME) return callback(new BoxError(BoxError.BAD_FIELD, 'Not allowed in demo mode')); userdb.del(user.id, function (error) { if (error) return callback(error); eventlog.add(eventlog.ACTION_USER_REMOVE, auditSource, { userId: user.id, user: removePrivateFields(user) }, callback); }); } function getAll(callback) { assert.strictEqual(typeof callback, 'function'); userdb.getAllWithGroupIds(function (error, results) { if (error) return callback(error); return callback(null, results); }); } function getAllPaged(search, page, perPage, callback) { assert(typeof search === 'string' || search === null); assert.strictEqual(typeof page, 'number'); assert.strictEqual(typeof perPage, 'number'); assert.strictEqual(typeof callback, 'function'); userdb.getAllWithGroupIdsPaged(search, page, perPage, function (error, results) { if (error) return callback(error); return callback(null, results); }); } function count(callback) { assert.strictEqual(typeof callback, 'function'); userdb.count(function (error, count) { if (error) return callback(error); callback(null, count); }); } function isActivated(callback) { assert.strictEqual(typeof callback, 'function'); count(function (error, count) { if (error) return callback(error); callback(null, count !== 0); }); } function get(userId, callback) { assert.strictEqual(typeof userId, 'string'); assert.strictEqual(typeof callback, 'function'); userdb.get(userId, function (error, result) { if (error) return callback(error); groups.getMembership(userId, function (error, groupIds) { if (error) return callback(error); result.groupIds = groupIds; return callback(null, result); }); }); } function getByResetToken(resetToken, callback) { assert.strictEqual(typeof resetToken, 'string'); assert.strictEqual(typeof callback, 'function'); var error = validateToken(resetToken); if (error) return callback(error); userdb.getByResetToken(resetToken, function (error, result) { if (error) return callback(error); callback(null, result); }); } function getByUsername(username, callback) { assert.strictEqual(typeof username, 'string'); assert.strictEqual(typeof callback, 'function'); userdb.getByUsername(username.toLowerCase(), function (error, result) { if (error) return callback(error); get(result.id, callback); }); } function update(user, data, auditSource, callback) { assert.strictEqual(typeof user, 'object'); assert.strictEqual(typeof data, 'object'); assert(auditSource && typeof auditSource === 'object'); assert.strictEqual(typeof callback, 'function'); if (settings.isDemo() && user.username === constants.DEMO_USERNAME) return callback(new BoxError(BoxError.BAD_FIELD, 'Not allowed in demo mode')); var error; data = _.pick(data, 'email', 'fallbackEmail', 'displayName', 'username', 'active', 'role'); if (_.isEmpty(data)) return callback(); if (data.username) { data.username = data.username.toLowerCase(); error = validateUsername(data.username); if (error) return callback(error); } if (data.email) { data.email = data.email.toLowerCase(); error = validateEmail(data.email); if (error) return callback(error); } if (data.fallbackEmail) { data.fallbackEmail = data.fallbackEmail.toLowerCase(); error = validateEmail(data.fallbackEmail); if (error) return callback(error); } if (data.role) { error = validateRole(data.role); if (error) return callback(error); } userdb.update(user.id, data, function (error) { if (error) return callback(error); const newUser = _.extend({}, user, data); eventlog.add(eventlog.ACTION_USER_UPDATE, auditSource, { userId: user.id, user: removePrivateFields(newUser), roleChanged: newUser.role !== user.role, activeStatusChanged: ((newUser.active && !user.active) || (!newUser.active && user.active)) }); callback(null); }); } function setMembership(user, groupIds, callback) { assert.strictEqual(typeof user, 'object'); assert(Array.isArray(groupIds)); assert.strictEqual(typeof callback, 'function'); groups.setMembership(user.id, groupIds, function (error) { if (error) return callback(error); callback(null); }); } function getAdmins(callback) { assert.strictEqual(typeof callback, 'function'); userdb.getByRole(exports.ROLE_OWNER, function (error, owners) { if (error) return callback(error); userdb.getByRole(exports.ROLE_ADMIN, function (error, admins) { if (error && error.reason === BoxError.NOT_FOUND) return callback(null, owners); if (error) return callback(error); callback(null, owners.concat(admins)); }); }); } function getSuperadmins(callback) { assert.strictEqual(typeof callback, 'function'); userdb.getByRole(exports.ROLE_OWNER, function (error, owners) { if (error) return callback(error); callback(null, owners); }); } function sendPasswordResetByIdentifier(identifier, callback) { assert.strictEqual(typeof identifier, 'string'); assert.strictEqual(typeof callback, 'function'); const getter = identifier.indexOf('@') === -1 ? userdb.getByUsername : userdb.getByEmail; getter(identifier.toLowerCase(), function (error, result) { if (error) return callback(error); let resetToken = hat(256), resetTokenCreationTime = new Date(); result.resetToken = resetToken; result.resetTokenCreationTime = resetTokenCreationTime; userdb.update(result.id, { resetToken, resetTokenCreationTime }, function (error) { if (error) return callback(error); mailer.passwordReset(result); callback(null); }); }); } function checkLoginLocation(user, ip, userAgent) { assert.strictEqual(typeof user, 'object'); assert.strictEqual(typeof ip, 'string'); assert.strictEqual(typeof userAgent, 'string'); debug(`checkLoginLocation: ${user.id} ${ip} ${userAgent}`); superagent.get('https://geolocation.cloudron.io/json').query({ ip: ip }).end(function (error, result) { if (error) return console.error('Failed to get geoip info:', error); const country = result.body.country.names.en; const city = result.body.city.names.en; const knownLogin = user.locations.find(function (l) { return l.userAgent === userAgent && l.country === country && l.city === city; }); if (knownLogin) return; // purge potentially old locations where ts > now() - 6 months const sixMonthsBack = Date.now() - 6 * 30 * 24 * 60 * 60 * 1000; var locations = user.locations.filter(function (l) { return l.ts > sixMonthsBack; }); locations.push({ ts: Date.now(), ip, userAgent, country, city }); userdb.update(user.id, { locations }, function (error) { if (error) console.error('checkLoginLocation: Failed to update user location.', error); mailer.sendNewLoginLocation(user, ip, userAgent, country, city); }); }); } function setPassword(user, newPassword, callback) { assert.strictEqual(typeof user, 'object'); assert.strictEqual(typeof newPassword, 'string'); assert.strictEqual(typeof callback, 'function'); var error = validatePassword(newPassword); if (error) return callback(error); if (settings.isDemo() && user.username === constants.DEMO_USERNAME) return callback(new BoxError(BoxError.BAD_FIELD, 'Not allowed in demo mode')); if (user.source) return callback(new BoxError(BoxError.CONFLICT, 'User is from an external directory')); var saltBuffer = Buffer.from(user.salt, 'hex'); crypto.pbkdf2(newPassword, saltBuffer, CRYPTO_ITERATIONS, CRYPTO_KEY_LENGTH, CRYPTO_DIGEST, function (error, derivedKey) { if (error) return callback(new BoxError(BoxError.CRYPTO_ERROR, error)); let data = { password: Buffer.from(derivedKey, 'binary').toString('hex'), resetToken: '' }; userdb.update(user.id, data, function (error) { if (error) return callback(error); callback(); }); }); } function createOwner(username, password, email, displayName, auditSource, callback) { assert.strictEqual(typeof username, 'string'); assert.strictEqual(typeof password, 'string'); assert.strictEqual(typeof email, 'string'); assert.strictEqual(typeof displayName, 'string'); assert(auditSource && typeof auditSource === 'object'); assert.strictEqual(typeof callback, 'function'); // This is only not allowed for the owner if (username === '') return callback(new BoxError(BoxError.BAD_FIELD, 'Username cannot be empty')); isActivated(function (error, activated) { if (error) return callback(error); if (activated) return callback(new BoxError(BoxError.ALREADY_EXISTS, 'Cloudron already activated')); create(username, password, email, displayName, { role: exports.ROLE_OWNER }, auditSource, function (error, user) { if (error) return callback(error); callback(null, user); }); }); } function getOwner(callback) { userdb.getByRole(exports.ROLE_OWNER, function (error, results) { if (error) return callback(error); return callback(null, results[0]); }); } function inviteLink(user, directoryConfig) { let link = `${settings.adminOrigin()}/setupaccount.html?resetToken=${user.resetToken}&email=${encodeURIComponent(user.email)}`; if (user.username) link += `&username=${encodeURIComponent(user.username)}`; if (user.displayName) link += `&displayName=${encodeURIComponent(user.displayName)}`; if (directoryConfig.lockUserProfiles) link += '&profileLocked=true'; return link; } function createInvite(user, callback) { assert.strictEqual(typeof user, 'object'); assert.strictEqual(typeof callback, 'function'); if (user.source) return callback(new BoxError(BoxError.CONFLICT, 'User is from an external directory')); const resetToken = hat(256), resetTokenCreationTime = new Date(); settings.getDirectoryConfig(function (error, directoryConfig) { if (error) return callback(error); userdb.update(user.id, { resetToken, resetTokenCreationTime }, function (error) { if (error) return callback(error); user.resetToken = resetToken; callback(null, { resetToken, inviteLink: inviteLink(user, directoryConfig) }); }); }); } function sendInvite(user, options, callback) { assert.strictEqual(typeof user, 'object'); assert.strictEqual(typeof options, 'object'); assert.strictEqual(typeof callback, 'function'); if (user.source) return callback(new BoxError(BoxError.CONFLICT, 'User is from an external directory')); if (!user.resetToken) return callback(new BoxError(BoxError.CONFLICT, 'Must generate resetToken to send invitation')); settings.getDirectoryConfig(function (error, directoryConfig) { if (error) return callback(error); mailer.sendInvite(user, options.invitor || null, inviteLink(user, directoryConfig)); callback(null); }); } function setupAccount(user, data, auditSource, callback) { assert.strictEqual(typeof user, 'object'); assert.strictEqual(typeof data, 'object'); assert(auditSource && typeof auditSource === 'object'); assert.strictEqual(typeof callback, 'function'); settings.getDirectoryConfig(function (error, directoryConfig) { if (error) return callback(error); const updateFunc = (done) => { if (directoryConfig.lockUserProfiles) return done(); update(user, _.pick(data, 'username', 'displayName'), auditSource, done); }; updateFunc(function (error) { if (error) return callback(error); setPassword(user, data.password, function (error) { // setPassword clears the resetToken if (error) return callback(error); tokens.add(tokens.ID_WEBADMIN, user.id, Date.now() + constants.DEFAULT_TOKEN_EXPIRATION, {}, function (error, result) { if (error) return callback(error); callback(null, result.accessToken); }); }); }); }); } function setTwoFactorAuthenticationSecret(userId, callback) { assert.strictEqual(typeof userId, 'string'); assert.strictEqual(typeof callback, 'function'); userdb.get(userId, function (error, result) { if (error) return callback(error); if (settings.isDemo() && result.username === constants.DEMO_USERNAME) return callback(new BoxError(BoxError.BAD_FIELD, 'Not allowed in demo mode')); if (result.twoFactorAuthenticationEnabled) return callback(new BoxError(BoxError.ALREADY_EXISTS)); var secret = speakeasy.generateSecret({ name: `Cloudron ${settings.adminFqdn()} (${result.username})` }); userdb.update(userId, { twoFactorAuthenticationSecret: secret.base32, twoFactorAuthenticationEnabled: false }, function (error) { if (error) return callback(error); qrcode.toDataURL(secret.otpauth_url, function (error, dataUrl) { if (error) return callback(new BoxError(BoxError.INTERNAL_ERROR, error)); callback(null, { secret: secret.base32, qrcode: dataUrl }); }); }); }); } function enableTwoFactorAuthentication(userId, totpToken, callback) { assert.strictEqual(typeof userId, 'string'); assert.strictEqual(typeof totpToken, 'string'); assert.strictEqual(typeof callback, 'function'); userdb.get(userId, function (error, result) { if (error) return callback(error); var verified = speakeasy.totp.verify({ secret: result.twoFactorAuthenticationSecret, encoding: 'base32', token: totpToken, window: 2 }); if (!verified) return callback(new BoxError(BoxError.INVALID_CREDENTIALS)); if (result.twoFactorAuthenticationEnabled) return callback(new BoxError(BoxError.ALREADY_EXISTS)); userdb.update(userId, { twoFactorAuthenticationEnabled: true }, function (error) { if (error) return callback(error); callback(null); }); }); } function disableTwoFactorAuthentication(userId, callback) { assert.strictEqual(typeof userId, 'string'); assert.strictEqual(typeof callback, 'function'); userdb.update(userId, { twoFactorAuthenticationEnabled: false, twoFactorAuthenticationSecret: '' }, function (error) { if (error) return callback(error); callback(null); }); } function validateRole(role) { assert.strictEqual(typeof role, 'string'); if (ORDERED_ROLES.indexOf(role) !== -1) return null; return new BoxError(BoxError.BAD_FIELD, `Invalid role '${role}'`); } function compareRoles(role1, role2) { assert.strictEqual(typeof role1, 'string'); assert.strictEqual(typeof role2, 'string'); let roleInt1 = ORDERED_ROLES.indexOf(role1); let roleInt2 = ORDERED_ROLES.indexOf(role2); return roleInt1 - roleInt2; } function validateAppPasswordName(name) { assert.strictEqual(typeof name, 'string'); if (name.length < 1) return new BoxError(BoxError.BAD_FIELD, 'name must be atleast 1 char'); if (name.length >= 200) return new BoxError(BoxError.BAD_FIELD, 'name too long'); return null; } function getAppPassword(id, callback) { assert.strictEqual(typeof id, 'string'); assert.strictEqual(typeof callback, 'function'); userdb.getAppPassword(id, function (error, result) { if (error) return callback(error); callback(null, _.omit(result, 'hashedPassword')); }); } function addAppPassword(userId, identifier, name, callback) { assert.strictEqual(typeof userId, 'string'); assert.strictEqual(typeof identifier, 'string'); assert.strictEqual(typeof name, 'string'); assert.strictEqual(typeof callback, 'function'); let error = validateAppPasswordName(name); if (error) return callback(error); if (identifier.length < 1) return callback(new BoxError(BoxError.BAD_FIELD, 'identifier must be atleast 1 char')); const password = hat(16 * 4); const hashedPassword = crypto.createHash('sha256').update(password).digest('base64'); var appPassword = { id: 'uid-' + uuid.v4(), name, userId, identifier, password, hashedPassword }; userdb.addAppPassword(appPassword.id, appPassword, function (error) { if (error) return callback(error); callback(null, _.omit(appPassword, 'hashedPassword')); }); } function getAppPasswords(userId, callback) { assert.strictEqual(typeof userId, 'string'); assert.strictEqual(typeof callback, 'function'); userdb.getAppPasswords(userId, function (error, results) { if (error) return callback(error); results.map(r => delete r.hashedPassword); callback(null, results); }); } function delAppPassword(id, callback) { assert.strictEqual(typeof id, 'string'); assert.strictEqual(typeof callback, 'function'); userdb.delAppPassword(id, function (error) { if (error) return callback(error); callback(null); }); } function getAvatarUrl(user, callback) { assert.strictEqual(typeof user, 'object'); assert.strictEqual(typeof callback, 'function'); userdb.getAvatar(user.id, function (error, avatar) { if (error) return callback(error); if (avatar) return callback(null, `${settings.adminOrigin()}/api/v1/profile/avatar/${user.id}`); const emailHash = require('crypto').createHash('md5').update(user.email).digest('hex'); return callback(null, `https://www.gravatar.com/avatar/${emailHash}.jpg`); }); } function getAvatar(id, callback) { assert.strictEqual(typeof id, 'string'); assert.strictEqual(typeof callback, 'function'); userdb.getAvatar(id, function (error, avatar) { if (error) return callback(error); return callback(null, avatar); }); } function setAvatar(id, avatar, callback) { assert.strictEqual(typeof id, 'string'); assert(avatar === null || Buffer.isBuffer(avatar)); assert.strictEqual(typeof callback, 'function'); userdb.setAvatar(id, avatar, callback); }