'use strict';
const { createModelModule } = require('../dal/lib/model-handle');
const { proxy: UserHandle, register: registerUserHandle } = createModelModule({
tableName: 'users'
});
module.exports = UserHandle;
const { getPostgresDAL } = require('../db-postgres');
const type = require('../dal').type;
const bcrypt = require('bcrypt');
const ReportedError = require('../util/reported-error');
const debug = require('../util/debug');
const UserMeta = require('./user-meta');
const Team = require('./team');
const { DocumentNotFound } = require('../dal/lib/errors');
const { initializeModel } = require('../dal/lib/model-initializer');
const userOptions = {
maxChars: 128,
illegalChars: /[<>;"&?!./_]/,
minPasswordLength: 6
};
const BCRYPT_ROUNDS = 10; // matches legacy bcrypt-nodejs default cost
/* eslint-disable no-useless-escape */ // False positive
// Erm, if we add [, ] or \ to forbidden chars, we'll have to fix this :)
userOptions.illegalCharsReadable = userOptions.illegalChars.source.replace(/[\[\]\\]/g, '');
/* eslint-enable no-useless-escape */
let User = null;
/**
* Initialize the PostgreSQL User model
* @param {DataAccessLayer} dal - Optional DAL instance for testing
*/
async function initializeUserModel(dal = null) {
try {
debug.db('Starting User model initialization...');
const activeDAL = dal || await getPostgresDAL();
if (!activeDAL) {
debug.db('PostgreSQL DAL not available, skipping User model initialization');
return null;
}
debug.db('DAL available, creating User model...');
const userSchema = {
id: type.string().uuid(4),
// CamelCase schema fields that map to snake_case database columns
displayName: type.string()
.max(userOptions.maxChars).validator(_containsOnlyLegalCharacters).required(),
canonicalName: type.string()
.max(userOptions.maxChars).validator(_containsOnlyLegalCharacters).required(),
email: type.string().max(userOptions.maxChars).email().sensitive(),
password: type.string().sensitive(),
userMetaID: type.string().uuid(4),
inviteLinkCount: type.number().integer().default(0),
registrationDate: type.date().default(() => new Date()),
showErrorDetails: type.boolean().default(false),
isTrusted: type.boolean().default(false),
isSiteModerator: type.boolean().default(false),
isSuperUser: type.boolean().default(false),
suppressedNotices: type.array(type.string()),
prefersRichTextEditor: type.boolean().default(false),
// Virtual fields for compatibility
urlName: type.virtual().default(function() {
const displayName = this.getValue ? this.getValue('displayName') : this.displayName;
return displayName ? encodeURIComponent(displayName.replace(/ /g, '_')) : undefined;
}),
userCanEditMetadata: type.virtual().default(false),
userCanUploadTempFiles: type.virtual().default(function() {
const isTrusted = this.getValue ? this.getValue('isTrusted') : this.isTrusted;
const isSuperUser = this.getValue ? this.getValue('isSuperUser') : this.isSuperUser;
return isTrusted || isSuperUser;
})
};
const { model, isNew } = initializeModel({
dal: activeDAL,
baseTable: 'users',
schema: userSchema,
camelToSnake: {
displayName: 'display_name',
canonicalName: 'canonical_name',
userMetaID: 'user_meta_id',
inviteLinkCount: 'invite_link_count',
registrationDate: 'registration_date',
showErrorDetails: 'show_error_details',
isTrusted: 'is_trusted',
isSiteModerator: 'is_site_moderator',
isSuperUser: 'is_super_user',
suppressedNotices: 'suppressed_notices',
prefersRichTextEditor: 'prefers_rich_text_editor'
},
staticMethods: {
increaseInviteLinkCount,
create: createUser,
ensureUnique,
getWithTeams,
findByURLName,
canonicalize,
createBio
},
instanceMethods: {
populateUserInfo,
setName,
setPassword,
checkPassword,
getValidPreferences
},
relations: [
{
name: 'teams',
targetTable: 'teams',
sourceKey: 'id',
targetKey: 'id',
hasRevisions: true,
through: {
table: 'team_members',
sourceForeignKey: 'user_id',
targetForeignKey: 'team_id'
},
cardinality: 'many'
},
{
name: 'moderatorOf',
targetTable: 'teams',
sourceKey: 'id',
targetKey: 'id',
hasRevisions: true,
through: {
table: 'team_moderators',
sourceForeignKey: 'user_id',
targetForeignKey: 'team_id'
},
cardinality: 'many'
},
{
name: 'meta',
targetTable: 'user_metas',
sourceKey: 'user_meta_id',
targetKey: 'id',
hasRevisions: true,
cardinality: 'one'
}
]
});
User = model;
if (!isNew) {
return User;
}
Object.defineProperty(User, 'options', {
value: userOptions,
writable: false,
enumerable: true,
configurable: false
});
// Pre-save validation
User.prototype._originalValidate = User.prototype._validate;
User.prototype._validate = function() {
// For new users, password is required
// For updates, password is only required if it's being set
if (this._isNew && (!this.password || typeof this.password != 'string')) {
throw new Error('Password must be set to a non-empty string.');
}
return this._originalValidate();
};
debug.db('PostgreSQL User model initialized with all methods');
return User;
} catch (error) {
debug.error('Failed to initialize PostgreSQL User model:', error);
return null;
}
}
// NOTE: STATIC METHODS --------------------------------------------------------
/**
* Increase the invite link count by 1 for a given user
*
* @param {String} id - unique ID of the user
* @returns {Number} updated invite count
* @async
*/
async function increaseInviteLinkCount(id) {
const query = `
UPDATE ${User.tableName}
SET invite_link_count = invite_link_count + 1
WHERE id = $1
RETURNING invite_link_count
`;
const result = await User.dal.query(query, [id]);
if (result.rows.length === 0) {
throw new Error(`User with id ${id} not found`);
}
return result.rows[0].invite_link_count;
}
/**
* Create a new user from an object containing the user data. Hashes the
* password, checks for uniqueness, validates. Saves.
*
* @param {Object} userObj - plain object containing data supported by this model
* @returns {User} the created user
* @throws {NewUserError} if user exists, password is too short, or there are other validation problems.
* @async
*/
async function createUser(userObj) {
const user = new User({});
if (typeof userObj != 'object') {
throw new NewUserError({
message: 'We need a user object containing the user data to create a new user.',
});
}
try {
user.setName(userObj.name);
// we have to check, because assigning an empty string would trigger the
// validation to kick in
if (userObj.email) {
user.email = userObj.email;
}
await User.ensureUnique(userObj.name); // throws if exists
await user.setPassword(userObj.password); // throws if too short
await user.save();
} catch (error) {
throw error instanceof NewUserError ?
error :
new NewUserError({ payload: { user }, parentError: error });
}
return user;
}
/**
* Throw if we already have a user with this name.
*
* @param {String} name - username to check
* @returns {Boolean} true if unique
* @throws {NewUserError} if exists
* @async
*/
async function ensureUnique(name) {
if (typeof name != 'string') {
throw new Error('Username to check must be a string.');
}
name = name.trim();
const users = await User.filter({ canonicalName: User.canonicalize(name) }).run();
if (users.length) {
throw new NewUserError({
message: 'A user named %s already exists.',
userMessage: 'username exists',
messageParams: [name]
});
}
return true;
}
/**
* Obtain user and all associated teams
*
* @param {String} id - user ID to look up
* @param {Object} options - query options
* @param {String[]} options.includeSensitive - array of sensitive fields to include
* @returns {Promise<User>} user with associated teams
*/
async function getWithTeams(id, options = {}) {
return User.get(id, {
teams: true,
...options
});
}
/**
* Find a user by the urldecoded URL name (spaces replaced with underscores)
*
* @param {String} name - decoded URL name
* @param {Object} [options] - query criteria
* @param {String[]} options.includeSensitive - array of sensitive fields to include (e.g., ['password'])
* @param {Boolean} options.withData=false - if true, the associated user-metadata object will be joined
* @param {Boolean} options.withTeams=false - if true, the associated teams will be joined
* @returns {User} the matching user object
* @throws {Error} if not found
* @async
*/
async function findByURLName(name, {
includeSensitive = [],
withData = false,
withTeams = false
} = {}) {
name = name.trim().replace(/_/g, ' ');
let query = User.filter({ canonicalName: User.canonicalize(name) });
// Include sensitive fields if requested
if (includeSensitive.length > 0) {
query = query.includeSensitive(includeSensitive);
}
// Add joins for requested data
const joinOptions = {};
if (withData) {
joinOptions.meta = true;
}
if (Object.keys(joinOptions).length > 0) {
query = query.getJoin(joinOptions);
}
const users = await query.run();
if (users.length) {
const user = users[0];
if (withTeams) {
await attachUserTeams(user);
}
return user;
}
const notFoundError = new DocumentNotFound('User not found');
notFoundError.name = 'DocumentNotFoundError';
throw notFoundError;
}
/**
* Transform a user name to its canonical internal form (upper case), used for
* duplicate checking.
*
* @param {String} name - name to transform
* @returns {String} canonical form
*/
function canonicalize(name) {
return name.toUpperCase();
}
/**
* Associate a new bio text with a given user and save both
*
* @param {User} user - user object to associate the bio with
* @param {Object} bioObj - plain object with data conforming to UserMeta schema
* @returns {User} updated user
* @async
*/
async function createBio(user, bioObj) {
const metaRev = await UserMeta.createFirstRevision(user, {
tags: ['create-bio-via-user']
});
metaRev.bio = bioObj.bio;
metaRev.originalLanguage = bioObj.originalLanguage;
await metaRev.save();
user.userMetaID = metaRev.id;
user.meta = metaRev;
await user.save();
return user;
}
/**
* Attach team membership and moderator data to the user instance
*
* @param {User} user - user instance to enrich with team data
* @returns {Promise<void>}
* @async
*/
async function attachUserTeams(user) {
user.teams = [];
user.moderatorOf = [];
const dal = Team.dal;
const teamTable = Team.tableName;
const prefix = dal.schemaNamespace || '';
const memberTable = `${prefix}team_members`;
const moderatorTable = `${prefix}team_moderators`;
const currentRevisionFilter = `
AND (t._old_rev_of IS NULL)
AND (t._rev_deleted IS NULL OR t._rev_deleted = false)
`;
const mapRowsToTeams = rows => rows.map(row => Team._createInstance(row));
try {
const memberResult = await dal.query(
`
SELECT t.*
FROM ${teamTable} t
JOIN ${memberTable} tm ON t.id = tm.team_id
WHERE tm.user_id = $1
${currentRevisionFilter}
`,
[user.id]
);
user.teams = mapRowsToTeams(memberResult.rows);
} catch (error) {
debug.error('Error attaching team memberships to user:', error);
}
try {
const moderatorResult = await dal.query(
`
SELECT t.*
FROM ${teamTable} t
JOIN ${moderatorTable} tm ON t.id = tm.team_id
WHERE tm.user_id = $1
${currentRevisionFilter}
`,
[user.id]
);
user.moderatorOf = mapRowsToTeams(moderatorResult.rows);
} catch (error) {
debug.error('Error attaching moderation teams to user:', error);
}
}
// NOTE: INSTANCE METHODS ------------------------------------------------------
/**
* Determine what the provided user (typically the currently logged in user)
* may do with the data associated with this User object, and populate its
* permission fields accordingly.
*
* @param {User} user - user whose permissions on this User object we want to determine
* @memberof User
* @instance
*/
function populateUserInfo(user) {
if (!user) {
return; // Permissions at default (false)
}
// For now, only the user may edit metadata like bio.
// In future, translators may also be able to.
if (user.id == this.id) {
this.userCanEditMetadata = true;
}
}
/**
* Updates display name, canonical name (used for duplicate checks) and derived
* representations such as URL name. Does not save.
*
* @param {String} displayName - the new display name for this user (must not contain illegal characters)
* @memberof User
* @instance
*/
function setName(displayName) {
if (typeof displayName != 'string') {
throw new Error('Username to set must be a string.');
}
displayName = displayName.trim();
this.displayName = displayName;
this.canonicalName = User.canonicalize(displayName);
// Regenerate virtual values after setting the name
this.generateVirtualValues();
}
/**
* Update a password hash based on a plain text password. Does not save.
*
* @param {String} password - plain text password
* @returns {Promise} promise that resolves to password hashed via bcrypt algorithm
* @memberof User
* @instance
*/
function setPassword(password) {
let user = this;
return new Promise((resolve, reject) => {
if (typeof password != 'string') {
reject(new Error('Password must be a string.'));
}
if (password.length < userOptions.minPasswordLength) {
// This check can't be run as a validator since by then it's a fixed-length hash
reject(new NewUserError({
message: 'Password for new user is too short, must be at least %s characters.',
userMessage: 'password too short',
messageParams: [String(userOptions.minPasswordLength)]
}));
} else {
bcrypt.hash(password, BCRYPT_ROUNDS, function(error, hash) {
if (error) {
reject(error);
} else {
user.password = hash;
resolve(user.password);
}
});
}
});
}
/**
* Check this user's password hash against a provided plain text password.
*
* @param {String} password - plain text password
* @returns {Promise} promise that resolves true/false, or rejects if bcrypt library returns an error.
* @memberof User
* @instance
*/
function checkPassword(password) {
return new Promise((resolve, reject) => {
bcrypt.compare(password, this.password, function(error, result) {
if (error) {
reject(error);
} else {
resolve(result);
}
});
});
}
/**
* Which preferences can be toggled via the API for/by this user? May be
* restricted in future based on access control limits.
*
* @returns {String[]} array of preference names
* @memberof User
* @instance
*/
function getValidPreferences() {
return ['prefersRichTextEditor'];
}
/**
* @param {String} name - username to validate
* @returns {Boolean} true if username contains illegal characters
* @throws {NewUserError} if user name contains invalid characters
* @memberof User
* @protected
*/
function _containsOnlyLegalCharacters(name) {
if (userOptions.illegalChars.test(name)) {
throw new NewUserError({
message: 'Username %s contains invalid characters.',
messageParams: [name],
userMessage: 'invalid username characters',
userMessageParams: [userOptions.illegalCharsReadable]
});
} else {
return true;
}
}
/**
* Error class for reporting registration related problems.
* Behaves as a normal ReportedError, but comes with built-in translation layer
* for DB level errors.
*
* @param {Object} [options] - error data
* @param {User} options.payload - user object that triggered this error
* @param {Error} options.parentError - the original error
*/
class NewUserError extends ReportedError {
constructor(options) {
if (typeof options == 'object' && options.parentError instanceof Error &&
typeof options.payload.user == 'object') {
switch (options.parentError.message) {
case 'Must be a valid email address':
options.userMessage = 'invalid email format';
options.userMessageParams = [options.payload.user.email];
break;
case `displayName must be shorter than ${userOptions.maxChars} characters`:
options.userMessage = 'username too long';
options.userMessageParams = [String(userOptions.maxChars)];
break;
case `email must be shorter than ${userOptions.maxChars} characters`:
options.userMessage = 'email too long';
options.userMessageParams = [String(userOptions.maxChars)];
break;
default:
}
}
super(options);
}
}
registerUserHandle({
initializeModel: initializeUserModel,
handleOptions: {
staticProperties: {
options: userOptions
}
},
additionalExports: {
initializeUserModel,
NewUserError
}
});