Files
talk/src/core/server/models/user/index.ts
T
Nick Funk 662f5ce314 [CORL-409] Prevent users from ignoring staff members (#2355)
* Throw error if user tries to ignore a staff member

Throws a UserCannotBeIgnoredError if a user tries to ignore
a user who is a staff member. A staff member in this case is
considered anyone who has a role of staff, moderator, or admin.

CORL-409

* Prevent users from ignoring staff in the user info popover

Creates the staff roles in a constant next to the user model.
Uses this to add a computed property to the user resolver.

CORL-409

* Remove unnecessary async declaration from userIsStaff helper function

CORL-409

* Specify ignoreable on users in client test fixtures

Allows the tests to pass for the required computed property
of ignoreable that is computed by whether a user is a staff
member or not.

CORL-409

* Update more fixtures with ignoreable property on mocked users/commenters

CORL-409

* Consolidate ignore-able calculation into re-usable helper methods

Re-use the logic for whether a role is a staff member to
clearly define when a user is ignore-able or not across the
business logic.

CORL-409

* Set the ignoreable optimisticResponse on comment mutations

We have set the ignoreable value in the graphQL schema, so now
the optimisticResponses are looking for a default value to use
until the data result arrives. Put to false since the ignoreable
value is set on our author, we likely don't want to ignore ourselves.

CORL-409
2019-06-13 17:24:50 +00:00

1681 lines
39 KiB
TypeScript

import bcrypt from "bcryptjs";
import { Db, MongoError } from "mongodb";
import uuid from "uuid";
import { DeepPartial, Omit, Sub } from "coral-common/types";
import { dotize } from "coral-common/utils/dotize";
import {
ConfirmEmailTokenExpired,
DuplicateEmailError,
DuplicateUserError,
LocalProfileAlreadySetError,
LocalProfileNotSetError,
PasswordResetTokenExpired,
TokenNotFoundError,
UserAlreadyBannedError,
UserAlreadySuspendedError,
UsernameAlreadySetError,
UserNotFoundError,
} from "coral-server/errors";
import {
GQLBanStatus,
GQLSuspensionStatus,
GQLTimeRange,
GQLUSER_ROLE,
} from "coral-server/graph/tenant/schema/__generated__/types";
import { getLocalProfile, hasLocalProfile } from "coral-server/helpers/users";
import logger from "coral-server/logger";
import {
Connection,
ConnectionInput,
resolveConnection,
} from "coral-server/models/helpers/connection";
import {
createConnectionOrderVariants,
createIndexFactory,
} from "coral-server/models/helpers/indexing";
import Query from "coral-server/models/helpers/query";
import { TenantResource } from "coral-server/models/tenant";
function collection(mongo: Db) {
return mongo.collection<Readonly<User>>("users");
}
export interface LocalProfile {
type: "local";
id: string;
password: string;
/**
* resetID is used during a password reset process to prevent replay attacks.
* When a password reset email is sent, a resetID is associated with the
* account and the token. When a given reset token is used, it is cleared from
* the user, preventing the same reset URL from being used multiple times.
*/
resetID?: string;
}
export interface OIDCProfile {
type: "oidc";
id: string;
issuer: string;
audience: string;
}
export interface SSOProfile {
type: "sso";
id: string;
}
export interface FacebookProfile {
type: "facebook";
id: string;
}
export interface GoogleProfile {
type: "google";
id: string;
}
/**
* Profile is all the different profiles that a given User may have associated
* with their account.
*/
export type Profile =
| LocalProfile
| OIDCProfile
| SSOProfile
| FacebookProfile
| GoogleProfile;
export interface Token {
readonly id: string;
name: string;
createdAt: Date;
}
/**
* SuspensionStatusHistory SuspensionStatusHistory is the list of all suspension
* events against a specific User.
*/
export interface SuspensionStatusHistory {
/**
* id is a specific reference for a particular suspension status that will be
* used internally to update suspension records.
*/
id: string;
/**
* from represents a range of time where a user suspension applies.
*/
from: GQLTimeRange;
/**
* createdBy is the ID for the User that suspended the User. If `null`, the
* suspension was created by the system.
*/
createdBy?: string;
/**
* createdAt is the time that the given suspension time frame was created.
*/
createdAt: Date;
/**
* modifiedBy is the ID for the User that modified the suspension for this
* User. If `null`, the suspension has not been edited, or has been edited by
* the system.
*/
modifiedBy?: string;
/**
* modifiedAt is the time that the date that the given suspension time frame
* was edited at.
*/
modifiedAt?: Date;
}
/**
* SuspensionStatus stores the user suspension status as well as the history of
* changes.
*/
export interface SuspensionStatus {
/**
* history is the list of all suspension events against a specific User.
*/
history: SuspensionStatusHistory[];
}
/**
* BanStatusHistory is the list of all ban events against a specific User.
*/
export interface BanStatusHistory {
/**
* id is a specific reference for a particular banned status that will be
* used internally to update banned records.
*/
id: string;
/**
* active, when true, indicates that the user is banned from this status.
*/
active: boolean;
/**
* createdBy is the ID for the User that banned the User. If `null`, the ban
* was created by the system.
*/
createdBy?: string;
/**
* createdAt is the time that the given ban was added.
*/
createdAt: Date;
}
/**
* BanStatus contains information about a ban for a given User.
*/
export interface BanStatus {
/**
* active when true, indicates that the given user is banned.
*/
active: boolean;
/**
* history is the list of all ban events against a specific User.
*/
history: BanStatusHistory[];
}
/**
* UserStatus stores the user status information regarding moderation state.
*/
export interface UserStatus {
/**
* suspension stores the user suspension status as well as the history of
* changes.
*/
suspension: SuspensionStatus;
/**
* ban stores the user ban status as well as the history of changes.
*/
ban: BanStatus;
}
/**
* IgnoredUser is the entry describing a User being ignored.
*/
export interface IgnoredUser {
/**
* id is the ID of the User that was ignored.
*/
id: string;
/**
* createdAt is the date that the User was ignored on.
*/
createdAt: Date;
}
/**
* User is someone that leaves Comments, and logs in.
*/
export interface User extends TenantResource {
/**
* id is the identifier of the User.
*/
readonly id: string;
/**
* username is the name of the User visible to other Users.
*/
username?: string;
/**
* avatar is the url to the avatar for a specific User.
*/
avatar?: string;
/**
* email is the current email address for the User.
*/
email?: string;
/**
* emailVerificationID is used to store state regarding the verification state
* of an email address to prevent replay attacks.
*/
emailVerificationID?: string;
/**
* emailVerified when true indicates that the given email address has been verified.
*/
emailVerified?: boolean;
/**
* profiles is the array of profiles assigned to the user.
*/
profiles: Profile[];
/**
* tokens lists the access tokens associated with the account.
*/
tokens: Token[];
/**
* role is the current role of the User.
*/
role: GQLUSER_ROLE;
/**
* status stores the user status information regarding moderation state.
*/
status: UserStatus;
/**
* ignoredUsers stores the users that have been ignored by this User.
*/
ignoredUsers: IgnoredUser[];
/**
* createdAt is the time that the User was created at.
*/
createdAt: Date;
}
export async function createUserIndexes(mongo: Db) {
const createIndex = createIndexFactory(collection(mongo));
// UNIQUE { id }
await createIndex({ tenantID: 1, id: 1 }, { unique: true });
// UNIQUE - PARTIAL { email }
await createIndex(
{ tenantID: 1, email: 1 },
{ unique: true, partialFilterExpression: { email: { $exists: true } } }
);
// UNIQUE { profiles.type, profiles.id }
await createIndex(
{ tenantID: 1, "profiles.type": 1, "profiles.id": 1 },
{ unique: true, partialFilterExpression: { profiles: { $exists: true } } }
);
// { profiles }
await createIndex(
{ tenantID: 1, profiles: 1, email: 1 },
{
partialFilterExpression: { profiles: { $exists: true } },
background: true,
}
);
// TEXT { id, username, email, createdAt }
await createIndex(
{
tenantID: 1,
id: "text",
username: "text",
email: "text",
createdAt: -1,
},
{ background: true }
);
const variants = createConnectionOrderVariants<Readonly<User>>(
[{ createdAt: -1 }],
{ background: true }
);
// User Connection pagination.
// { ...connectionParams }
await variants(createIndex, {
tenantID: 1,
});
// Role based User Connection pagination.
// { role, ...connectionParams }
await variants(createIndex, {
tenantID: 1,
role: 1,
});
// Suspension based User Connection pagination.
await variants(createIndex, {
tenantID: 1,
"status.suspension.history.from.start": 1,
"status.suspension.history.from.finish": 1,
});
// Ban based User Connection pagination.
await variants(createIndex, {
tenantID: 1,
"status.ban.active": 1,
});
}
function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 10);
}
export type InsertUserInput = Omit<
User,
"id" | "tenantID" | "tokens" | "status" | "ignoredUsers" | "createdAt"
>;
export async function insertUser(
mongo: Db,
tenantID: string,
input: InsertUserInput,
now = new Date()
) {
// Create a new ID for the user.
const id = uuid.v4();
// default are the properties set by the application when a new user is
// created.
const defaults: Sub<User, InsertUserInput> = {
id,
tenantID,
tokens: [],
ignoredUsers: [],
status: {
suspension: { history: [] },
ban: { active: false, history: [] },
},
createdAt: now,
};
// Guard against empty login profiles (they need some way to login).
if (input.profiles.length === 0) {
throw new Error("users require at least one profile");
}
// Mutate the profiles to ensure we mask handle any secrets.
const profiles: Profile[] = [];
for (let profile of input.profiles) {
switch (profile.type) {
case "local":
// Hash the user's password with bcrypt.
const password = await hashPassword(profile.password);
profile = {
...profile,
password,
};
break;
}
// Save a copy.
profiles.push(profile);
}
// Merge the defaults and the input together.
const user: Readonly<User> = {
...defaults,
...input,
profiles,
};
try {
// Insert it into the database. This may throw an error.
await collection(mongo).insert(user);
} catch (err) {
// Evaluate the error, if it is in regards to violating the unique index,
// then return a duplicate Story error.
if (err instanceof MongoError && err.code === 11000) {
// Check if duplicate index was about the email.
if (err.errmsg && err.errmsg.includes("tenantID_1_email_1")) {
throw new DuplicateEmailError(input.email!);
}
throw new DuplicateUserError();
}
throw err;
}
return user;
}
export async function retrieveUser(mongo: Db, tenantID: string, id: string) {
return collection(mongo).findOne({ tenantID, id });
}
export async function retrieveManyUsers(
mongo: Db,
tenantID: string,
ids: string[]
) {
const cursor = await collection(mongo).find({
id: {
$in: ids,
},
tenantID,
});
const users = await cursor.toArray();
return ids.map(id => users.find(comment => comment.id === id) || null);
}
export async function retrieveUserWithProfile(
mongo: Db,
tenantID: string,
profile: Partial<Pick<Profile, "id" | "type">>
) {
return collection(mongo).findOne({
tenantID,
profiles: {
$elemMatch: profile,
},
});
}
/**
* updateUserRole updates a given User's role.
*
* @param mongo mongodb database to interact with
* @param tenantID Tenant ID where the User resides
* @param id ID of the User that we are updating
* @param role new role to set to the User
*/
export async function updateUserRole(
mongo: Db,
tenantID: string,
id: string,
role: GQLUSER_ROLE
) {
const result = await collection(mongo).findOneAndUpdate(
{ id, tenantID },
{ $set: { role } },
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
throw new UserNotFoundError(id);
}
return result.value;
}
export async function verifyUserPassword(
user: Pick<User, "profiles">,
password: string
) {
const profile = getLocalProfile(user);
if (!profile) {
throw new LocalProfileNotSetError();
}
return bcrypt.compare(password, profile.password);
}
export async function updateUserPassword(
mongo: Db,
tenantID: string,
id: string,
password: string
) {
// Hash the password.
const hashedPassword = await hashPassword(password);
// Update the user with the new password.
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
id,
// This ensures that the document we're updating already has a local
// profile associated with them.
"profiles.type": "local",
},
{
$set: {
"profiles.$[profiles].password": hashedPassword,
},
},
{
arrayFilters: [{ "profiles.type": "local" }],
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
const user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
if (!hasLocalProfile(user)) {
throw new LocalProfileNotSetError();
}
throw new Error("an unexpected error occurred");
}
return result.value || null;
}
/**
* setUserUsername will set the username of the User if the username hasn't
* already been used before.
*
* @param mongo the database handle
* @param tenantID the ID to the Tenant
* @param id the ID of the User where we are setting the username on
* @param username the username that we want to set
*/
export async function setUserUsername(
mongo: Db,
tenantID: string,
id: string,
username: string
) {
// TODO: (wyattjoh) investigate adding the username previously used to an array.
// The username wasn't found, so add it to the user.
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
id,
username: null,
},
{
$set: {
username,
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
// Try to get the current user to discover what happened.
const user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
if (user.username) {
throw new UsernameAlreadySetError();
}
throw new Error("an unexpected error occurred");
}
return result.value;
}
/**
* updateUserUsername will set the username of the User.
*
* @param mongo the database handle
* @param tenantID the ID to the Tenant
* @param id the ID of the User where we are setting the username on
* @param username the username that we want to set
*/
export async function updateUserUsername(
mongo: Db,
tenantID: string,
id: string,
username: string
) {
// TODO: (wyattjoh) investigate adding the username previously used to an array.
// The username wasn't found, so add it to the user.
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
id,
},
{
$set: {
username,
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
// Try to get the current user to discover what happened.
const user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
throw new Error("an unexpected error occurred");
}
return result.value;
}
/**
* setUserEmail will set the email address of the User if they don't already
* have one associated with them, and it hasn't been used before.
*
* @param mongo the database handle
* @param tenantID the ID to the Tenant
* @param id the ID of the User where we are setting the email address on
* @param emailAddress the email address we want to set
*/
export async function setUserEmail(
mongo: Db,
tenantID: string,
id: string,
emailAddress: string
) {
// Lowercase the email address.
const email = emailAddress.toLowerCase();
// Search to see if this email has been used before.
let user = await collection(mongo).findOne({
tenantID,
email,
});
if (user) {
throw new DuplicateEmailError(email);
}
// The email wasn't found, so try to update the User.
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
id,
email: null,
},
{
$set: {
email,
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
// Try to get the current user to discover what happened.
user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
if (user.email) {
throw new UsernameAlreadySetError();
}
throw new Error("an unexpected error occurred");
}
return result.value;
}
/**
* updateUserEmail will update a given User's email address to the one provided.
*
* @param mongo the database that we are interacting with
* @param tenantID the Tenant ID of the Tenant where the User exists
* @param id the User ID that we are updating
* @param emailAddress email address that we are setting on the User
*/
export async function updateUserEmail(
mongo: Db,
tenantID: string,
id: string,
emailAddress: string
) {
// Lowercase the email address.
const email = emailAddress.toLowerCase();
// Search to see if this email has been used before.
let user = await collection(mongo).findOne({
tenantID,
email,
});
if (user) {
throw new DuplicateEmailError(email);
}
// The email wasn't found, so try to update the User.
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
id,
},
{
$set: {
email,
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
// Try to get the current user to discover what happened.
user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
throw new Error("an unexpected error occurred");
}
return result.value;
}
/**
* updateUserAvatar will update the avatar associated with a User. If the avatar
* is not provided, it will be unset.
*
* @param mongo the database that we are interacting with
* @param tenantID the Tenant ID of the Tenant where the User exists
* @param id the User ID that we are updating
* @param avatar URL that the avatar exists at
*/
export async function updateUserAvatar(
mongo: Db,
tenantID: string,
id: string,
avatar?: string
) {
// The email wasn't found, so try to update the User.
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
id,
},
{
// This will ensure that if the avatar isn't provided, it will unset the
// avatar on the User.
[avatar ? "$set" : "$unset"]: {
avatar: avatar ? avatar : 1,
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
// Try to get the current user to discover what happened.
const user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
throw new Error("an unexpected error occurred");
}
return result.value;
}
/**
* setUserLocalProfile will set the local profile for a User if they don't
* already have one associated with them and the profile doesn't exist on any
* other User already.
*
* @param mongo the database handle
* @param tenantID the ID to the Tenant
* @param id the ID of the User where we are setting the local profile on
* @param emailAddress the email address we want to set
* @param password the password we want to set
*/
export async function setUserLocalProfile(
mongo: Db,
tenantID: string,
id: string,
emailAddress: string,
password: string
) {
// Lowercase the email address.
const email = emailAddress.toLowerCase();
// Try to see if this local profile already exists on a User.
let user = await retrieveUserWithProfile(mongo, tenantID, {
type: "local",
id: email,
});
if (user) {
throw new DuplicateEmailError(email);
}
// Hash the password.
const hashedPassword = await hashPassword(password);
// Create the profile that we'll use.
const profile: LocalProfile = {
type: "local",
id: email,
password: hashedPassword,
};
// The profile wasn't found, so add it to the User.
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
id,
// This ensures that the document we're updating does not contain a local
// profile.
"profiles.type": { $ne: "local" },
},
{
$push: {
profiles: profile,
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
// Try to get the current user to discover what happened.
user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
// Check to see if the user has any local profile (not just a local profile
// with a specific email).
if (hasLocalProfile(user)) {
throw new LocalProfileAlreadySetError();
}
throw new Error("an unexpected error occurred");
}
return result.value;
}
export async function createUserToken(
mongo: Db,
tenantID: string,
userID: string,
name: string,
now = new Date()
) {
// Create the Token that we'll be adding to the User.
const token: Readonly<Token> = {
id: uuid.v4(),
name,
createdAt: now,
};
const result = await collection(mongo).findOneAndUpdate(
{
id: userID,
tenantID,
},
{
$push: { tokens: token },
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
throw new UserNotFoundError(userID);
}
return {
user: result.value,
token,
};
}
export async function deactivateUserToken(
mongo: Db,
tenantID: string,
userID: string,
id: string
) {
// Try to remove the Token from the User.
const result = await collection(mongo).findOneAndUpdate(
{
id: userID,
tenantID,
"tokens.id": id,
},
{
$pull: { tokens: { id } },
},
{
// True to return the original document instead of the updated
// document.
returnOriginal: true,
}
);
if (!result.value) {
const user = await retrieveUser(mongo, tenantID, userID);
if (!user) {
throw new UserNotFoundError(id);
}
// Check to see if the User had that Token in the first place.
if (!user.tokens.find(t => t.id === id)) {
throw new TokenNotFoundError();
}
throw new Error("an unexpected error occurred");
}
// We have to typecast here because we know at this point that the record does
// contain the Token.
const token: Token = result.value.tokens.find(t => t.id === id) as Token;
// Mutate the user in order to remove the Token from the list of Token's.
const updatedUser: Readonly<User> = {
...result.value,
tokens: result.value.tokens.filter(t => t.id !== id),
};
return {
user: updatedUser,
token,
};
}
export type UserConnectionInput = ConnectionInput<User>;
export async function retrieveUserConnection(
mongo: Db,
tenantID: string,
input: UserConnectionInput
): Promise<Readonly<Connection<Readonly<User>>>> {
// Create the query.
const query = new Query(collection(mongo)).where({ tenantID });
// If a filter is being applied, filter it as well.
if (input.filter) {
query.where(input.filter);
}
return retrieveConnection(input, query);
}
async function retrieveConnection(
input: UserConnectionInput,
query: Query<User>
): Promise<Readonly<Connection<Readonly<User>>>> {
// Apply the pagination arguments to the query.
query.orderBy({ createdAt: -1 });
if (input.after) {
query.where({ createdAt: { $lt: input.after as Date } });
}
// Return a connection.
return resolveConnection(query, input, user => user.createdAt);
}
/**
* banUser will ban a specific user from interacting with the site.
*
* @param mongo the mongo database handle
* @param tenantID the Tenant's ID where the User exists
* @param id the ID of the user being banned
* @param createdBy the ID of the user banning the above mentioned user
* @param now the current date
*/
export async function banUser(
mongo: Db,
tenantID: string,
id: string,
createdBy: string,
now = new Date()
) {
// Create the new ban.
const banHistory: BanStatusHistory = {
id: uuid(),
active: true,
createdBy,
createdAt: now,
};
// Try to update the user if the user isn't already banned.
const result = await collection(mongo).findOneAndUpdate(
{
id,
tenantID,
"status.ban.active": {
$ne: true,
},
},
{
$set: {
"status.ban.active": true,
},
$push: {
"status.ban.history": banHistory,
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
// Get the user so we can figure out why the ban operation failed.
const user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
// Check to see if the user is already banned.
const ban = consolidateUserBanStatus(user.status.ban);
if (ban.active) {
throw new UserAlreadyBannedError();
}
throw new Error("an unexpected error occurred");
}
return result.value;
}
/**
* removeUserBan will lift a user ban from a User allowing them to interact with
* the site again.
*
* @param mongo the mongo database handle
* @param tenantID the Tenant's ID where the User exists
* @param id the ID of the user having their ban lifted
* @param createdBy the ID of the user lifting the ban
* @param now the current date
*/
export async function removeUserBan(
mongo: Db,
tenantID: string,
id: string,
createdBy: string,
now = new Date()
) {
// Create the new ban.
const ban: BanStatusHistory = {
id: uuid(),
active: false,
createdBy,
createdAt: now,
};
// Try to update the user if the user isn't already banned.
const result = await collection(mongo).findOneAndUpdate(
{
id,
tenantID,
$or: [
{
"status.ban.active": {
$ne: false,
},
},
{
"status.ban.history": {
$size: 0,
},
},
],
},
{
$set: {
"status.ban.active": false,
},
$push: {
"status.ban.history": ban,
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
// Get the user so we can figure out why the ban operation failed.
const user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
// The user wasn't banned already, so nothing needs to be done!
return user;
}
return result.value;
}
/**
* suspendUser will suspend a user for a specific time range from interacting
* with the site.
*
* @param mongo the mongo database handle
* @param tenantID the Tenant's ID where the User exists
* @param id the ID of the user being suspended
* @param createdBy the ID of the user banning the above mentioned user
* @param from the range of time that the user is being banned for
* @param now the current date
*/
export async function suspendUser(
mongo: Db,
tenantID: string,
id: string,
createdBy: string,
finish: Date,
now = new Date()
) {
// Create the new suspension.
const suspension: SuspensionStatusHistory = {
id: uuid(),
from: {
start: now,
finish,
},
createdBy,
createdAt: now,
};
// Try to update the user if the user isn't already suspended.
const result = await collection(mongo).findOneAndUpdate(
{
id,
tenantID,
"status.suspension.history": {
$not: {
$elemMatch: {
"from.start": {
$lte: now,
},
"from.finish": {
$gt: now,
},
},
},
},
},
{
$push: {
"status.suspension.history": suspension,
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
// Get the user so we can figure out why the suspend operation failed.
const user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
// Check to see if the user is already suspended.
const suspended = consolidateUserSuspensionStatus(
user.status.suspension,
now
);
if (suspended.active && suspended.until) {
throw new UserAlreadySuspendedError(suspended.until);
}
throw new Error("an unexpected error occurred");
}
return result.value;
}
/**
* removeUserSuspensions will lift any active suspensions.
*
* @param mongo the mongo database handle
* @param tenantID the Tenant's ID where the User exists
* @param id the ID of the User having their suspension lifted
* @param modifiedBy the ID of the User lifting the suspension
* @param now the current date
*/
export async function removeActiveUserSuspensions(
mongo: Db,
tenantID: string,
id: string,
modifiedBy: string,
now = new Date()
) {
// Prepare the update payload.
const update: DeepPartial<SuspensionStatusHistory> = {
from: {
finish: now,
},
modifiedAt: now,
modifiedBy,
};
// Try to update the user suspension times.
const result = await collection(mongo).findOneAndUpdate(
{ tenantID, id },
{
$set: dotize({
"status.suspension.history.$[active]": update,
}),
},
{
arrayFilters: [
// Change the finish date on all suspension records that indicate their
// active time within our current range.
{
"active.from.start": { $lte: now },
"active.from.finish": { $gt: now },
},
],
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
// Get the user so we can figure out why the suspend operation failed.
const user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
// The user wasn't already suspended, so nothing needs to be done!
return user;
}
logger.debug({ result }, "finished update operation");
return result.value;
}
export type ConsolidatedBanStatus = Omit<GQLBanStatus, "history"> &
Pick<BanStatus, "history">;
export function consolidateUserBanStatus(
ban: User["status"]["ban"]
): ConsolidatedBanStatus {
return ban;
}
export type ConsolidatedSuspensionStatus = Omit<
GQLSuspensionStatus,
"history"
> &
Pick<SuspensionStatus, "history">;
export function consolidateUserSuspensionStatus(
suspension: User["status"]["suspension"],
now = new Date()
): ConsolidatedSuspensionStatus {
return suspension.history.reduce(
(status: ConsolidatedSuspensionStatus, history) => {
// Check to see if we're currently suspended.
if (history.from.start <= now && history.from.finish > now) {
status.active = true;
// Ensure that we have the furthest suspension finish time.
if (!status.until || status.until < history.from.finish) {
status.until = history.from.finish;
}
}
return status;
},
{
active: false,
history: suspension.history,
}
);
}
export interface ConsolidatedUserStatus {
suspension: ConsolidatedSuspensionStatus;
ban: ConsolidatedBanStatus;
}
export function consolidateUserStatus(
status: User["status"],
now = new Date()
): ConsolidatedUserStatus {
// Return the status.
return {
suspension: consolidateUserSuspensionStatus(status.suspension, now),
ban: consolidateUserBanStatus(status.ban),
};
}
/**
* createOrRetrieveUserPasswordResetID will create/retrieve a password reset ID
* on the User.
*
* @param mongo MongoDB instance to interact with
* @param tenantID Tenant ID that the User exists on
* @param id ID of the User that we are creating a reset ID with
*/
export async function createOrRetrieveUserPasswordResetID(
mongo: Db,
tenantID: string,
id: string
): Promise<string> {
// Create the ID.
const resetID = uuid.v4();
// Associate the resetID with the user.
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
id,
// This ensures that the document we're updating already has a local
// profile associated with them and also doesn't have a resetID.
profiles: {
$elemMatch: {
type: "local",
resetID: null,
},
},
},
{
$set: {
"profiles.$[profiles].resetID": resetID,
},
},
{
arrayFilters: [{ "profiles.type": "local" }],
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
const user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
const localProfile = getLocalProfile(user);
if (!localProfile) {
throw new LocalProfileNotSetError();
}
if (localProfile.resetID) {
return localProfile.resetID;
}
throw new Error("an unexpected error occurred");
}
return resetID;
}
export async function createOrRetrieveUserEmailVerificationID(
mongo: Db,
tenantID: string,
id: string
): Promise<string> {
// Create the ID.
const emailVerificationID = uuid.v4();
// Associate the resetID with the user.
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
id,
// This ensures that we don't set a emailVerificationID when there is one
// already.
emailVerificationID: null,
$or: [{ emailVerified: false }, { emailVerified: null }],
},
{
$set: {
emailVerificationID,
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
const user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
if (user.emailVerified) {
throw new Error("email address has already been verified");
}
if (user.emailVerificationID) {
return user.emailVerificationID;
}
throw new Error("an unexpected error occurred");
}
return emailVerificationID;
}
export async function resetUserPassword(
mongo: Db,
tenantID: string,
id: string,
password: string,
resetID: string
) {
// Hash the password.
const hashedPassword = await hashPassword(password);
// Update the user with the new password.
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
id,
// This ensures that the document we're updating already has a local
// profile associated with them and also matches the resetID specified.
profiles: {
$elemMatch: {
type: "local",
resetID,
},
},
},
{
$set: {
"profiles.$[profiles].password": hashedPassword,
},
$unset: {
"profiles.$[profiles].resetID": "",
},
},
{
arrayFilters: [{ "profiles.type": "local", "profiles.resetID": resetID }],
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
const user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
const profile = getLocalProfile(user);
if (!profile) {
throw new LocalProfileNotSetError();
}
if (profile.resetID !== resetID) {
throw new PasswordResetTokenExpired("reset id mismatch");
}
throw new Error("an unexpected error occurred");
}
return result.value || null;
}
export async function confirmUserEmail(
mongo: Db,
tenantID: string,
id: string,
email: string,
emailVerificationID: string
) {
// Update the user with a confirmed email address.
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
id,
email,
emailVerificationID,
},
{
$set: {
emailVerified: true,
},
$unset: {
emailVerificationID: "",
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
const user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
if (user.email !== email) {
throw new ConfirmEmailTokenExpired("email mismatch");
}
if (user.emailVerificationID !== emailVerificationID) {
throw new ConfirmEmailTokenExpired("email verification id mismatch");
}
throw new Error("an unexpected error occurred");
}
return result.value || null;
}
export async function ignoreUser(
mongo: Db,
tenantID: string,
id: string,
ignoreUserID: string,
now = new Date()
) {
const ignoredUser: IgnoredUser = {
id: ignoreUserID,
createdAt: now,
};
const result = await collection(mongo).findOneAndUpdate(
{
id,
tenantID,
ignoredUsers: {
$not: {
$eq: {
id: ignoreUserID,
},
},
},
},
{
$push: { ignoredUsers: ignoredUser },
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
// Get the user so we can figure out why the ignore operation failed.
const user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
// TODO: extract function
if (
user.ignoredUsers &&
user.ignoredUsers.some(u => u.id === ignoreUserID)
) {
// TODO: improve error
throw new Error("user already ignored");
}
throw new Error("an unexpected error occurred");
}
return result.value;
}
export async function removeUserIgnore(
mongo: Db,
tenantID: string,
id: string,
ignoreUserID: string
) {
const result = await collection(mongo).findOneAndUpdate(
{
id,
tenantID,
"ignoredUsers.id": ignoreUserID,
},
{
$pull: { ignoredUsers: { id: ignoreUserID } },
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
// Get the user so we can figure out why the ignore operation failed.
const user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
// TODO: extract function
if (
user.ignoredUsers &&
user.ignoredUsers.every(u => u.id !== ignoreUserID)
) {
// TODO: improve error
throw new Error("user already not ignored");
}
throw new Error("an unexpected error occurred");
}
return result.value;
}