[CORL-155] User Suspending and Banning (#2247)

* feat: suspending, banning, now propogation

* feat: adapting to `now`

* feat: support auth for suspension/banned

* feat: added trace-id to requests

* feat: new mutation api with hooks support

* feat: added user status filtering, current field

* feat: Implement filter by status, adapt to new USER_STATUS type, add lookup helper <3

* fix: typo

* fix: tests

* chore: rename banned status to ban status

* test: feature test + lots of test helper improvements e.g. types

* fix: add translation to ban user modal

* fix: translation

* fix: test
This commit is contained in:
Wyatt Johnson
2019-04-22 22:57:32 +00:00
committed by GitHub
parent b63c00f26f
commit dbbc1af42e
147 changed files with 4609 additions and 1468 deletions
+14 -8
View File
@@ -38,6 +38,7 @@ export const signupHandler = ({
// Tenant is guaranteed at this point.
const tenant = req.talk!.tenant!;
const now = req.talk!.now;
// Check to ensure that the local integration has been enabled.
if (!tenant.auth.integrations.local.enabled) {
@@ -65,14 +66,19 @@ export const signupHandler = ({
};
// Create the new user.
const user = await insert(mongo, tenant, {
email,
username,
profiles: [profile],
// New users signing up via local auth will have the commenter role to
// start with.
role: GQLUSER_ROLE.COMMENTER,
});
const user = await insert(
mongo,
tenant,
{
email,
username,
profiles: [profile],
// New users signing up via local auth will have the commenter role to
// start with.
role: GQLUSER_ROLE.COMMENTER,
},
now
);
// Send off to the passport handler.
return handleSuccessfulLogin(user, signingConfig, req, res, next);
+4 -1
View File
@@ -29,7 +29,8 @@ export const graphQLHandler = ({
throw new Error("talk was not set");
}
const { tenant, cache } = req.talk;
// Pull out some useful properties from Talk.
const { id, now, tenant, cache } = req.talk;
if (!cache) {
throw new Error("cache was not set");
@@ -43,6 +44,8 @@ export const graphQLHandler = ({
schema,
context: new TenantContext({
...options,
id,
now,
req,
config,
tenant,
+25 -14
View File
@@ -93,14 +93,20 @@ export const installHandler = ({
// Install will throw if it can not create a Tenant, or it has already been
// installed.
const tenant = await install(mongo, redis, req.talk.cache.tenant, {
...tenantInput,
// Infer the Tenant domain via the hostname parameter.
domain: req.hostname,
// Add the locale that we had to default to the default locale from the
// config.
locale,
});
const tenant = await install(
mongo,
redis,
req.talk.cache.tenant,
{
...tenantInput,
// Infer the Tenant domain via the hostname parameter.
domain: req.hostname,
// Add the locale that we had to default to the default locale from the
// config.
locale,
},
req.talk.now
);
// Pull the user details out of the input for the user.
const { email, username, password } = userInput;
@@ -113,12 +119,17 @@ export const installHandler = ({
};
// Create the first admin user.
await insert(mongo, tenant, {
email,
username,
profiles: [profile],
role: GQLUSER_ROLE.ADMIN,
});
await insert(
mongo,
tenant,
{
email,
username,
profiles: [profile],
role: GQLUSER_ROLE.ADMIN,
},
req.talk.now
);
// Send back the Tenant.
return res.sendStatus(204);
@@ -75,6 +75,9 @@ export async function handleLogout(redis: Redis, req: Request, res: Response) {
throw new Error("logout requires a token on the request, none was found");
}
// Talk is guarenteed at this point.
const { now } = req.talk!;
// Decode the token.
const decoded = jwt.decode(token, {});
if (!decoded) {
@@ -88,7 +91,7 @@ export async function handleLogout(redis: Redis, req: Request, res: Response) {
const { jti, exp }: LogoutToken = validate(LogoutTokenSchema, decoded);
// Compute the number of seconds that the token will be valid for.
const validFor = exp - Date.now() / 1000;
const validFor = exp - now.valueOf() / 1000;
if (validFor > 0) {
// Invalidate the token, the expiry is in the future and it needs to be
// revoked.
@@ -40,7 +40,8 @@ export default class FacebookStrategy extends OAuth2Strategy<
protected async findOrCreateUser(
tenant: Tenant,
integration: Required<GQLFacebookAuthIntegration>,
{ id, photos, emails, displayName }: Profile
{ id, photos, emails, displayName }: Profile,
now = new Date()
) {
// Create the user profile that will be used to lookup the User.
const profile: FacebookProfile = {
@@ -71,14 +72,19 @@ export default class FacebookStrategy extends OAuth2Strategy<
emailVerified = false;
}
user = await insert(this.mongo, tenant, {
username: displayName,
role: GQLUSER_ROLE.COMMENTER,
email,
emailVerified,
avatar,
profiles: [profile],
});
user = await insert(
this.mongo,
tenant,
{
username: displayName,
role: GQLUSER_ROLE.COMMENTER,
email,
emailVerified,
avatar,
profiles: [profile],
},
now
);
}
// TODO: maybe update user details?
@@ -39,7 +39,8 @@ export default class GoogleStrategy extends OAuth2Strategy<
protected async findOrCreateUser(
tenant: Tenant,
integration: Required<GQLGoogleAuthIntegration>,
{ id, photos, emails, displayName }: Profile
{ id, photos, emails, displayName }: Profile,
now = new Date()
) {
// Create the user profile that will be used to lookup the User.
const profile: GoogleProfile = {
@@ -70,14 +71,19 @@ export default class GoogleStrategy extends OAuth2Strategy<
emailVerified = false;
}
user = await insert(this.mongo, tenant, {
username: displayName,
role: GQLUSER_ROLE.COMMENTER,
email,
emailVerified,
avatar,
profiles: [profile],
});
user = await insert(
this.mongo,
tenant,
{
username: displayName,
role: GQLUSER_ROLE.COMMENTER,
email,
emailVerified,
avatar,
profiles: [profile],
},
now
);
}
// TODO: maybe update user details?
@@ -33,7 +33,8 @@ export interface Verifier<T = Token> {
verify: (
tokenString: string,
token: T,
tenant: Tenant
tenant: Tenant,
now: Date
) => Promise<Readonly<User> | null>;
/**
@@ -58,7 +59,7 @@ export class JWTStrategy extends Strategy {
];
}
private async verify(tokenString: string, tenant: Tenant) {
private async verify(tokenString: string, tenant: Tenant, now = new Date()) {
const token: Token = jwt.decode(tokenString);
if (!token || typeof token === "string") {
throw new TokenInvalidError(tokenString, "token could not be decoded");
@@ -67,7 +68,7 @@ export class JWTStrategy extends Strategy {
// Try to verify the token.
for (const verifier of this.verifiers) {
if (verifier.supports(token, tenant)) {
return verifier.verify(tokenString, token, tenant);
return verifier.verify(tokenString, token, tenant, now);
}
}
@@ -87,13 +88,13 @@ export class JWTStrategy extends Strategy {
return this.pass();
}
const { tenant } = req.talk!;
const { now, tenant } = req.talk!;
if (!tenant) {
return this.error(new TenantNotFoundError(req.hostname));
}
try {
const user = await this.verify(token, tenant);
const user = await this.verify(token, tenant, now);
if (!user) {
return this.pass();
}
@@ -57,7 +57,8 @@ export default abstract class OAuth2Strategy<
protected abstract findOrCreateUser(
tenant: Tenant,
integration: Required<T>,
profile: Profile
profile: Profile,
now: Date
): Promise<User | undefined>;
protected verifyCallback = async (
@@ -70,6 +71,7 @@ export default abstract class OAuth2Strategy<
try {
// Talk is defined at this point.
const tenant = req.talk!.tenant!;
const now = req.talk!.now;
// Get the integration.
const integration = this.getIntegration(tenant.auth.integrations);
@@ -78,7 +80,8 @@ export default abstract class OAuth2Strategy<
const user = await this.findOrCreateUser(
tenant,
integration as Required<T>,
profile
profile,
now
);
return done(null, user);
@@ -137,7 +137,8 @@ export async function findOrCreateOIDCUser(
mongo: Db,
tenant: Tenant,
integration: OIDCAuthIntegration,
token: OIDCIDToken
token: OIDCIDToken,
now = new Date()
): Promise<Readonly<User> | null> {
// Unpack/validate the token content.
const {
@@ -174,14 +175,19 @@ export async function findOrCreateOIDCUser(
const username = preferred_username || nickname || name;
// Create the new user, as one didn't exist before!
user = await insert(mongo, tenant, {
username,
role: GQLUSER_ROLE.COMMENTER,
email,
emailVerified: email_verified,
avatar: picture,
profiles: [profile],
});
user = await insert(
mongo,
tenant,
{
username,
role: GQLUSER_ROLE.COMMENTER,
email,
emailVerified: email_verified,
avatar: picture,
profiles: [profile],
},
now
);
}
// TODO: (wyattjoh) possibly update the user profile if the remaining details mismatch?
@@ -194,7 +200,8 @@ export function findOrCreateOIDCUserWithToken(
tenant: Tenant,
client: JwksClient,
integration: OIDCAuthIntegration,
token: string
token: string,
now: Date
) {
return new Promise<Readonly<User> | null>((resolve, reject) => {
logger.trace({ tenantID: tenant.id }, "verifying oidc id_token");
@@ -219,7 +226,8 @@ export function findOrCreateOIDCUserWithToken(
mongo,
tenant,
integration,
decoded as OIDCIDToken
decoded as OIDCIDToken,
now
);
return resolve(user);
} catch (err) {
@@ -303,8 +311,9 @@ export default class OIDCStrategy extends Strategy {
return done(new Error("no id_token in params"));
}
// Grab the tenant out of the request, as we need some more details.
const { tenant } = req.talk!;
// Grab the tenant out of the request, as we need some more details. Talk
// is guaranteed at this point.
const { now, tenant } = req.talk!;
if (!tenant) {
// TODO: return a better error.
return done(new Error("tenant not found"));
@@ -330,7 +339,8 @@ export default class OIDCStrategy extends Strategy {
tenant,
client,
integration,
id_token
id_token,
now
);
return done(null, user || undefined);
} catch (err) {
@@ -30,7 +30,12 @@ export class OIDCVerifier implements Verifier<OIDCIDToken> {
this.cache = new TenantCacheAdapter(tenantCache);
}
public async verify(tokenString: string, token: OIDCIDToken, tenant: Tenant) {
public async verify(
tokenString: string,
token: OIDCIDToken,
tenant: Tenant,
now: Date
) {
// Ensure that the integration is enabled.
const integration = getEnabledIntegration(tenant.auth.integrations.oidc);
@@ -52,7 +57,8 @@ export class OIDCVerifier implements Verifier<OIDCIDToken> {
tenant,
client,
integration,
tokenString
tokenString,
now
);
}
@@ -42,7 +42,8 @@ export async function findOrCreateSSOUser(
mongo: Db,
tenant: Tenant,
integration: GQLSSOAuthIntegration,
token: SSOToken
token: SSOToken,
now = new Date()
) {
if (!token.user) {
// TODO: (wyattjoh) replace with better error.
@@ -71,13 +72,18 @@ export async function findOrCreateSSOUser(
// FIXME: (wyattjoh) implement rules! Not all users should be able to create an account via this method.
// Create the new user, as one didn't exist before!
user = await insert(mongo, tenant, {
username,
role: GQLUSER_ROLE.COMMENTER,
email,
avatar,
profiles: [profile],
});
user = await insert(
mongo,
tenant,
{
username,
role: GQLUSER_ROLE.COMMENTER,
email,
avatar,
profiles: [profile],
},
now
);
}
// TODO: (wyattjoh) possibly update the user profile if the remaining details mismatch?
@@ -122,7 +128,12 @@ export class SSOVerifier implements Verifier<SSOToken> {
return tenant.auth.integrations.sso.enabled && isSSOToken(token);
}
public async verify(tokenString: string, token: SSOToken, tenant: Tenant) {
public async verify(
tokenString: string,
token: SSOToken,
tenant: Tenant,
now = new Date()
) {
const integration = tenant.auth.integrations.sso;
if (!integration.enabled) {
// TODO: (wyattjoh) return a better error.
@@ -140,6 +151,6 @@ export class SSOVerifier implements Verifier<SSOToken> {
algorithms: ["HS256"], // TODO: (wyattjoh) investigate replacing algorithm.
});
return findOrCreateSSOUser(this.mongo, tenant, integration, token);
return findOrCreateSSOUser(this.mongo, tenant, integration, token, now);
}
}
+13 -2
View File
@@ -1,3 +1,5 @@
import uuid from "uuid/v1";
import { TenantNotFoundError } from "talk-server/errors";
import TenantCache from "talk-server/services/tenant/cache";
import { RequestHandler } from "talk-server/types/express";
@@ -12,9 +14,18 @@ export const tenantMiddleware = ({
passNoTenant = false,
}: MiddlewareOptions): RequestHandler => async (req, res, next) => {
try {
// Set Talk on the request.
if (!req.talk) {
req.talk = {};
const id = uuid();
// Write the ID on the request.
res.set("X-Trace-ID", id);
// The only call to `new Date()` as a part of the request process. This
// is passed around the request to ensure constant-time actions.
const now = new Date();
// Set Talk on the request.
req.talk = { id, now };
}
// Set the Talk Tenant Cache on the request.
+56 -2
View File
@@ -7,6 +7,7 @@ import { VError } from "verror";
import { ERROR_CODES, ERROR_TYPES } from "talk-common/errors";
import { translate } from "talk-server/services/i18n";
import { GQLUSER_AUTH_CONDITIONS } from "talk-server/graph/tenant/schema/__generated__/types";
import { ERROR_TRANSLATIONS } from "./translations";
/**
@@ -345,10 +346,19 @@ export class TokenInvalidError extends TalkError {
}
export class UserForbiddenError extends TalkError {
constructor(reason: string, resource: string, userID: string | null) {
constructor(
reason: string,
resource: string,
operation: string,
userID?: string,
permit?: GQLUSER_AUTH_CONDITIONS[],
conditions?: GQLUSER_AUTH_CONDITIONS[]
) {
super({
code: ERROR_CODES.USER_NOT_ENTITLED,
context: { pvt: { reason, userID, resource } },
context: {
pvt: { reason, userID, resource, operation, conditions, permit },
},
status: 403,
});
}
@@ -451,3 +461,47 @@ export class SpamCommentError extends TalkError {
});
}
}
export class UserAlreadySuspendedError extends TalkError {
constructor(until: Date) {
super({
code: ERROR_CODES.USER_ALREADY_SUSPENDED,
context: {
pub: {
until: until.toISOString(),
},
},
});
}
}
export class UserAlreadyBannedError extends TalkError {
constructor() {
super({
code: ERROR_CODES.USER_ALREADY_BANNED,
});
}
}
export class UserBanned extends TalkError {
constructor(userID: string, resource?: string, operation?: string) {
super({
code: ERROR_CODES.USER_BANNED,
context: { pvt: { resource, operation, userID } },
});
}
}
export class UserSuspended extends TalkError {
constructor(
userID: string,
until: Date,
resource?: string,
operation?: string
) {
super({
code: ERROR_CODES.USER_SUSPENDED,
context: { pvt: { resource, operation, userID }, pub: { until } },
});
}
}
+6 -2
View File
@@ -33,6 +33,10 @@ export const ERROR_TRANSLATIONS: Record<ERROR_CODES, string> = {
USERNAME_TOO_SHORT: "error-usernameTooShort",
AUTHENTICATION_ERROR: "error-authenticationError",
INVALID_CREDENTIALS: "error-invalidCredentials",
TOXIC_COMMENT: "error-toxicCommentError",
SPAM_COMMENT: "error-spamCommentError",
TOXIC_COMMENT: "error-toxicComment",
SPAM_COMMENT: "error-spamComment",
USER_ALREADY_SUSPENDED: "error-userAlreadySuspended",
USER_ALREADY_BANNED: "error-userAlreadyBanned",
USER_BANNED: "error-userBanned",
USER_SUSPENDED: "error-userSuspended",
};
+14 -5
View File
@@ -1,3 +1,4 @@
import bunyan from "bunyan";
import uuid from "uuid";
import { LanguageCode } from "talk-common/helpers/i18n/locales";
@@ -8,6 +9,8 @@ import { I18n } from "talk-server/services/i18n";
import { Request } from "talk-server/types/express";
export interface CommonContextOptions {
id?: string;
now?: Date;
user?: User;
req?: Request;
lang?: LanguageCode;
@@ -18,22 +21,28 @@ export interface CommonContextOptions {
export default class CommonContext {
public readonly user?: User;
public readonly req?: Request;
public readonly id: string;
public readonly config: Config;
public readonly i18n: I18n;
public readonly lang: LanguageCode;
public readonly logger = logger.child({
context: "graph",
contextID: uuid.v1(),
});
public readonly now: Date;
public readonly logger: ReturnType<typeof bunyan.createLogger>;
constructor({
id = uuid.v1(),
now = new Date(),
user,
req,
config,
i18n,
lang = i18n.getDefaultLang(),
}: CommonContextOptions) {
this.id = id;
this.logger = logger.child({
context: "graph",
contextID: this.id,
});
this.now = now;
this.user = user;
this.req = req;
this.config = config;
+65 -22
View File
@@ -2,13 +2,21 @@ import { DirectiveResolverFn } from "graphql-tools";
import { memoize } from "lodash";
import { GraphQLResolveInfo, ResponsePath } from "graphql";
import { UserForbiddenError } from "talk-server/errors";
import {
UserBanned,
UserForbiddenError,
UserSuspended,
} from "talk-server/errors";
import CommonContext from "talk-server/graph/common/context";
import {
GQLUSER_AUTH_CONDITIONS,
GQLUSER_ROLE,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { User } from "talk-server/models/user";
import {
consolidateUserStatus,
consolidateUserSuspensionStatus,
User,
} from "talk-server/models/user";
// Replace `memoize.Cache`.
memoize.Cache = WeakMap;
@@ -19,7 +27,10 @@ export interface AuthDirectiveArgs {
permit?: GQLUSER_AUTH_CONDITIONS[];
}
function calculateAuthConditions(user: User): GQLUSER_AUTH_CONDITIONS[] {
function calculateAuthConditions(
user: User,
now: Date
): GQLUSER_AUTH_CONDITIONS[] {
const conditions: GQLUSER_AUTH_CONDITIONS[] = [];
if (!user.username) {
@@ -30,6 +41,16 @@ function calculateAuthConditions(user: User): GQLUSER_AUTH_CONDITIONS[] {
conditions.push(GQLUSER_AUTH_CONDITIONS.MISSING_EMAIL);
}
// Compute the user status.
const status = consolidateUserStatus(user.status, now);
if (status.ban.active) {
conditions.push(GQLUSER_AUTH_CONDITIONS.BANNED);
}
if (status.suspension.active) {
conditions.push(GQLUSER_AUTH_CONDITIONS.SUSPENDED);
}
return conditions.sort();
}
@@ -74,32 +95,53 @@ const auth: DirectiveResolverFn<
next,
src,
{ roles, userIDField, permit }: AuthDirectiveArgs,
{ user },
{ user, now },
info
) => {
// If there is a user on the request.
if (user) {
// If the permit was not specified, then no conditions can exist on the
// User, if they do error.
const conditions = calculateAuthConditionsMemoized(user);
if (!permit && conditions.length > 0) {
throw new UserForbiddenError(
"authentication conditions not met",
calculateLocationKey(info),
user.id
);
}
// If the permit was specified, and some of the conditions for the user
// aren't in the list of permitted conditions, then error.
const conditions = calculateAuthConditionsMemoized(user, now);
if (
permit &&
conditions.some(condition => permit.indexOf(condition) === -1)
// If the permit was not specified, then no conditions can exist on the
// User, if they do error.
(!permit && conditions.length > 0) ||
// If the permit was specified, and some of the conditions for the user
// aren't in the list of permitted conditions, then error.
(permit && conditions.some(condition => !permit.includes(condition)))
) {
// Compute the resource that the user was attempting to access.
const resource = calculateLocationKey(info);
if (conditions.includes(GQLUSER_AUTH_CONDITIONS.BANNED)) {
throw new UserBanned(user.id, resource, info.operation.operation);
}
if (conditions.includes(GQLUSER_AUTH_CONDITIONS.SUSPENDED)) {
const status = consolidateUserSuspensionStatus(
user.status.suspension,
now
);
if (!status.until) {
throw new Error(
"we expected to get an `until` for a suspended user, but did not"
);
}
throw new UserSuspended(
user.id,
status.until,
resource,
info.operation.operation
);
}
throw new UserForbiddenError(
"authentication conditions not met",
calculateLocationKey(info),
user.id
resource,
info.operation.operation,
user.id,
permit,
conditions
);
}
@@ -124,7 +166,8 @@ const auth: DirectiveResolverFn<
throw new UserForbiddenError(
"user does not have permission to access the resource",
calculateLocationKey(info),
user ? user.id : null
info.operation.operation,
user ? user.id : undefined
);
};
@@ -135,7 +135,8 @@ export default (ctx: Context) => ({
retrieveSharedModerationQueueQueuesCounts(
ctx.mongo,
ctx.redis,
ctx.tenant.id
ctx.tenant.id,
ctx.now
)
),
});
@@ -64,7 +64,7 @@ export default (ctx: TenantContext) => ({
(inputs: FindOrCreateStoryInput[]) =>
Promise.all(
inputs.map(input =>
findOrCreate(ctx.mongo, ctx.tenant, input, ctx.scraperQueue)
findOrCreate(ctx.mongo, ctx.tenant, input, ctx.scraperQueue, ctx.now)
)
),
{
+57 -4
View File
@@ -3,6 +3,7 @@ import DataLoader from "dataloader";
import Context from "talk-server/graph/tenant/context";
import {
GQLUSER_ROLE,
GQLUSER_STATUS,
QueryToUsersArgs,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { Connection } from "talk-server/models/helpers/connection";
@@ -13,7 +14,9 @@ import {
UserConnectionInput,
} from "talk-server/models/user";
const roleFilter = (role?: GQLUSER_ROLE): UserConnectionInput["filter"] => {
type UserConnectionFilterInput = UserConnectionInput["filter"];
const roleFilter = (role?: GQLUSER_ROLE): UserConnectionFilterInput => {
if (role) {
return { role };
}
@@ -21,7 +24,7 @@ const roleFilter = (role?: GQLUSER_ROLE): UserConnectionInput["filter"] => {
return {};
};
const queryFilter = (query?: string): UserConnectionInput["filter"] => {
const queryFilter = (query?: string): UserConnectionFilterInput => {
if (query) {
return { $text: { $search: query } };
}
@@ -29,6 +32,47 @@ const queryFilter = (query?: string): UserConnectionInput["filter"] => {
return {};
};
const statusFilter = (
now: Date,
status?: GQLUSER_STATUS
): UserConnectionFilterInput => {
switch (status) {
case GQLUSER_STATUS.ACTIVE:
return {
"status.ban.active": false,
"status.suspension.history": {
$not: {
$elemMatch: {
"from.start": {
$lte: now,
},
"from.finish": {
$gt: now,
},
},
},
},
};
case GQLUSER_STATUS.BANNED:
return { "status.ban.active": true };
case GQLUSER_STATUS.SUSPENDED:
return {
"status.suspension.history": {
$elemMatch: {
"from.start": {
$lte: now,
},
"from.finish": {
$gt: now,
},
},
},
};
default:
return {};
}
};
/**
* primeUsersFromConnection will prime a given context with the users retrieved
* via a connection.
@@ -58,16 +102,25 @@ export default (ctx: Context) => {
return {
user,
connection: ({ first = 10, after, role, query }: QueryToUsersArgs) =>
connection: ({
first = 10,
after,
role,
query,
status,
}: QueryToUsersArgs) =>
retrieveUserConnection(ctx.mongo, ctx.tenant.id, {
first,
after,
filter: {
// Merge role filters into the query.
// Merge the role filters into the query.
...roleFilter(role),
// Merge the query filters into the query.
...queryFilter(query),
// Merge the status filters into the query.
...statusFilter(ctx.now, status),
},
}).then(primeUsersFromConnection(ctx)),
};
@@ -37,6 +37,7 @@ export const Comments = (ctx: TenantContext) => ({
ctx.user!,
{ authorID: ctx.user!.id, ...comment },
nudge,
ctx.now,
ctx.req
),
{
@@ -44,6 +45,8 @@ export const Comments = (ctx: TenantContext) => ({
ERROR_CODES.COMMENT_BODY_EXCEEDS_MAX_LENGTH,
ERROR_CODES.COMMENT_BODY_TOO_SHORT,
],
"input.parentID": [ERROR_CODES.COMMENT_NOT_FOUND],
"input.storyID": [ERROR_CODES.STORY_NOT_FOUND],
}
),
edit: ({ commentID, body }: GQLEditCommentInput) =>
@@ -57,6 +60,7 @@ export const Comments = (ctx: TenantContext) => ({
id: commentID,
body,
},
ctx.now,
ctx.req
),
{
@@ -70,10 +74,17 @@ export const Comments = (ctx: TenantContext) => ({
commentID,
commentRevisionID,
}: GQLCreateCommentReactionInput) =>
createReaction(ctx.mongo, ctx.redis, ctx.tenant, ctx.user!, {
commentID,
commentRevisionID,
}),
createReaction(
ctx.mongo,
ctx.redis,
ctx.tenant,
ctx.user!,
{
commentID,
commentRevisionID,
},
ctx.now
),
removeReaction: ({ commentID }: GQLRemoveCommentReactionInput) =>
removeReaction(ctx.mongo, ctx.redis, ctx.tenant, ctx.user!, {
commentID,
@@ -83,15 +94,22 @@ export const Comments = (ctx: TenantContext) => ({
commentRevisionID,
additionalDetails,
}: GQLCreateCommentDontAgreeInput) =>
createDontAgree(ctx.mongo, ctx.redis, ctx.tenant, ctx.user!, {
commentID,
commentRevisionID,
// TODO: (wyattjoh) move this validation to the schema when bug is fixed: https://github.com/apollographql/graphql-tools/issues/842
additionalDetails: validateMaximumLength(
ADDITIONAL_DETAILS_MAX_LENGTH,
additionalDetails
),
}),
createDontAgree(
ctx.mongo,
ctx.redis,
ctx.tenant,
ctx.user!,
{
commentID,
commentRevisionID,
// TODO: (wyattjoh) move this validation to the schema when bug is fixed: https://github.com/apollographql/graphql-tools/issues/842
additionalDetails: validateMaximumLength(
ADDITIONAL_DETAILS_MAX_LENGTH,
additionalDetails
),
},
ctx.now
),
removeDontAgree: ({ commentID }: GQLRemoveCommentDontAgreeInput) =>
removeDontAgree(ctx.mongo, ctx.redis, ctx.tenant, ctx.user!, {
commentID,
@@ -102,14 +120,21 @@ export const Comments = (ctx: TenantContext) => ({
reason,
additionalDetails,
}: GQLCreateCommentFlagInput) =>
createFlag(ctx.mongo, ctx.redis, ctx.tenant, ctx.user!, {
commentID,
commentRevisionID,
reason,
// TODO: (wyattjoh) move this validation to the schema when bug is fixed: https://github.com/apollographql/graphql-tools/issues/842
additionalDetails: validateMaximumLength(
ADDITIONAL_DETAILS_MAX_LENGTH,
additionalDetails
),
}),
createFlag(
ctx.mongo,
ctx.redis,
ctx.tenant,
ctx.user!,
{
commentID,
commentRevisionID,
reason,
// TODO: (wyattjoh) move this validation to the schema when bug is fixed: https://github.com/apollographql/graphql-tools/issues/842
additionalDetails: validateMaximumLength(
ADDITIONAL_DETAILS_MAX_LENGTH,
additionalDetails
),
},
ctx.now
),
});
@@ -33,7 +33,8 @@ export const Stories = (ctx: TenantContext) => ({
ctx.tenant,
input.story.id,
input.story.url,
omitBy(input.story, isNull)
omitBy(input.story, isNull),
ctx.now
),
{
"input.story.url": [
@@ -44,7 +45,7 @@ export const Stories = (ctx: TenantContext) => ({
),
update: async (input: GQLUpdateStoryInput): Promise<Readonly<Story> | null> =>
mapFieldsetToErrorCodes(
update(ctx.mongo, ctx.tenant, input.id, input.story),
update(ctx.mongo, ctx.tenant, input.id, input.story, ctx.now),
{
"input.story.url": [
ERROR_CODES.STORY_URL_NOT_PERMITTED,
@@ -55,11 +56,11 @@ export const Stories = (ctx: TenantContext) => ({
updateSettings: async (
input: GQLUpdateStorySettingsInput
): Promise<Readonly<Story> | null> =>
updateSettings(ctx.mongo, ctx.tenant, input.id, input.settings),
updateSettings(ctx.mongo, ctx.tenant, input.id, input.settings, ctx.now),
close: (input: GQLCloseStoryInput): Promise<Readonly<Story> | null> =>
close(ctx.mongo, ctx.tenant, input.id),
close(ctx.mongo, ctx.tenant, input.id, ctx.now),
open: (input: GQLOpenStoryInput): Promise<Readonly<Story> | null> =>
open(ctx.mongo, ctx.tenant, input.id),
open(ctx.mongo, ctx.tenant, input.id, ctx.now),
merge: async (input: GQLMergeStoriesInput): Promise<Readonly<Story> | null> =>
merge(
ctx.mongo,
+25 -1
View File
@@ -3,11 +3,15 @@ import { mapFieldsetToErrorCodes } from "talk-server/graph/common/errors";
import TenantContext from "talk-server/graph/tenant/context";
import { User } from "talk-server/models/user";
import {
ban,
createToken,
deactivateToken,
removeBan,
removeSuspension,
setEmail,
setPassword,
setUsername,
suspend,
updateAvatar,
updateEmail,
updatePassword,
@@ -15,11 +19,15 @@ import {
updateUsername,
} from "talk-server/services/users";
import {
GQLBanUserInput,
GQLCreateTokenInput,
GQLDeactivateTokenInput,
GQLRemoveUserBanInput,
GQLRemoveUserSuspensionInput,
GQLSetEmailInput,
GQLSetPasswordInput,
GQLSetUsernameInput,
GQLSuspendUserInput,
GQLUpdatePasswordInput,
GQLUpdateUserAvatarInput,
GQLUpdateUserEmailInput,
@@ -69,7 +77,8 @@ export const Users = (ctx: TenantContext) => ({
// NOTE: (wyattjoh) this will error if not provided.
ctx.signingConfig!,
ctx.user!,
input.name
input.name,
ctx.now
),
deactivateToken: async (input: GQLDeactivateTokenInput) =>
deactivateToken(ctx.mongo, ctx.tenant, ctx.user!, input.id),
@@ -81,4 +90,19 @@ export const Users = (ctx: TenantContext) => ({
updateAvatar(ctx.mongo, ctx.tenant, input.userID, input.avatar),
updateUserRole: async (input: GQLUpdateUserRoleInput) =>
updateRole(ctx.mongo, ctx.tenant, ctx.user!, input.userID, input.role),
ban: async (input: GQLBanUserInput) =>
ban(ctx.mongo, ctx.tenant, ctx.user!, input.userID, ctx.now),
suspend: async (input: GQLSuspendUserInput) =>
suspend(
ctx.mongo,
ctx.tenant,
ctx.user!,
input.userID,
input.timeout,
ctx.now
),
removeBan: async (input: GQLRemoveUserBanInput) =>
removeBan(ctx.mongo, ctx.tenant, ctx.user!, input.userID, ctx.now),
removeSuspension: async (input: GQLRemoveUserSuspensionInput) =>
removeSuspension(ctx.mongo, ctx.tenant, ctx.user!, input.userID, ctx.now),
});
@@ -0,0 +1,12 @@
import { GQLBanStatusTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import * as user from "talk-server/models/user";
export type BanStatusInput = user.ConsolidatedBanStatus & {
userID: string;
};
export const BanStatus: Required<GQLBanStatusTypeResolver<BanStatusInput>> = {
active: ({ active }) => active,
history: ({ history, userID }) =>
history.map(status => ({ ...status, userID })),
};
@@ -0,0 +1,16 @@
import { GQLBanStatusHistoryTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import * as user from "talk-server/models/user";
export const BanStatusHistory: Required<
GQLBanStatusHistoryTypeResolver<user.BanStatusHistory>
> = {
active: ({ active }) => active,
createdBy: ({ createdBy }, input, ctx) => {
if (createdBy) {
return ctx.loaders.Users.user.load(createdBy);
}
return null;
},
createdAt: ({ createdAt }) => createdAt,
};
@@ -131,4 +131,20 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
user: await ctx.mutators.Users.updateUserRole(input),
clientMutationId: input.clientMutationId,
}),
banUser: async (source, { input }, ctx) => ({
user: await ctx.mutators.Users.ban(input),
clientMutationId: input.clientMutationId,
}),
removeUserBan: async (source, { input }, ctx) => ({
user: await ctx.mutators.Users.removeBan(input),
clientMutationId: input.clientMutationId,
}),
suspendUser: async (source, { input }, ctx) => ({
user: await ctx.mutators.Users.suspend(input),
clientMutationId: input.clientMutationId,
}),
removeUserSuspension: async (source, { input }, ctx) => ({
user: await ctx.mutators.Users.removeSuspension(input),
clientMutationId: input.clientMutationId,
}),
};
@@ -0,0 +1,15 @@
import { GQLSuspensionStatusTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import * as user from "talk-server/models/user";
export type SuspensionStatusInput = user.ConsolidatedSuspensionStatus & {
userID: string;
};
export const SuspensionStatus: Required<
GQLSuspensionStatusTypeResolver<SuspensionStatusInput>
> = {
active: ({ active }) => active,
until: ({ until }) => until,
history: ({ history, userID }) =>
history.map(status => ({ ...status, userID })),
};
@@ -0,0 +1,26 @@
import { GQLSuspensionStatusHistoryTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import * as user from "talk-server/models/user";
export const SuspensionStatusHistory: Required<
GQLSuspensionStatusHistoryTypeResolver<user.SuspensionStatusHistory>
> = {
active: ({ from }, input, ctx) =>
from.start <= ctx.now && from.finish > ctx.now,
from: ({ from }) => from,
createdBy: ({ createdBy }, input, ctx) => {
if (createdBy) {
return ctx.loaders.Users.user.load(createdBy);
}
return null;
},
createdAt: ({ createdAt }) => createdAt,
modifiedBy: ({ modifiedBy }, input, ctx) => {
if (modifiedBy) {
return ctx.loaders.Users.user.load(modifiedBy);
}
return null;
},
modifiedAt: ({ modifiedAt }) => modifiedAt,
};
@@ -1,8 +1,14 @@
import { GQLUserTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import * as user from "talk-server/models/user";
import { UserStatusInput } from "./UserStatus";
export const User: GQLUserTypeResolver<user.User> = {
comments: ({ id }, input, ctx) => ctx.loaders.Comments.forUser(id, input),
commentModerationActionHistory: ({ id }, input, ctx) =>
ctx.loaders.CommentModerationActions.forModerator(input, id),
status: ({ id, status }): UserStatusInput => ({
...status,
userID: id,
}),
};
@@ -0,0 +1,46 @@
import {
GQLUSER_STATUS,
GQLUserStatusTypeResolver,
} from "talk-server/graph/tenant/schema/__generated__/types";
import * as user from "talk-server/models/user";
import { BanStatusInput } from "./BanStatus";
import { SuspensionStatusInput } from "./SuspensionStatus";
export type UserStatusInput = user.UserStatus & {
userID: string;
};
export const UserStatus: Required<
GQLUserStatusTypeResolver<UserStatusInput>
> = {
current: (status, input, ctx) => {
const consolidatedStatus = user.consolidateUserStatus(status, ctx.now);
const statuses: GQLUSER_STATUS[] = [];
// If they are currently banned, then mark it.
if (consolidatedStatus.ban.active) {
statuses.push(GQLUSER_STATUS.BANNED);
}
// If they are currently suspended, then mark it.
if (consolidatedStatus.suspension.active) {
statuses.push(GQLUSER_STATUS.SUSPENDED);
}
// If no other statuses were applied, then apply the active status.
if (statuses.length === 0) {
statuses.push(GQLUSER_STATUS.ACTIVE);
}
return statuses;
},
ban: ({ ban, userID }): BanStatusInput => ({
...user.consolidateUserBanStatus(ban),
userID,
}),
suspension: ({ suspension, userID }): SuspensionStatusInput => ({
...user.consolidateUserSuspensionStatus(suspension),
userID,
}),
};
@@ -4,6 +4,8 @@ import { GQLResolver } from "talk-server/graph/tenant/schema/__generated__/types
import { AcceptCommentPayload } from "./AcceptCommentPayload";
import { AuthIntegrations } from "./AuthIntegrations";
import { BanStatus } from "./BanStatus";
import { BanStatusHistory } from "./BanStatusHistory";
import { CloseCommenting } from "./CloseCommenting";
import { Comment } from "./Comment";
import { CommentCounts } from "./CommentCounts";
@@ -21,12 +23,17 @@ import { Query } from "./Query";
import { RejectCommentPayload } from "./RejectCommentPayload";
import { Story } from "./Story";
import { StorySettings } from "./StorySettings";
import { SuspensionStatus } from "./SuspensionStatus";
import { SuspensionStatusHistory } from "./SuspensionStatusHistory";
import { Tag } from "./Tag";
import { User } from "./User";
import { UserStatus } from "./UserStatus";
const Resolvers: GQLResolver = {
AcceptCommentPayload,
AuthIntegrations,
BanStatus,
BanStatusHistory,
CloseCommenting,
Comment,
CommentCounts,
@@ -45,9 +52,12 @@ const Resolvers: GQLResolver = {
RejectCommentPayload,
Story,
StorySettings,
SuspensionStatus,
SuspensionStatusHistory,
Tag,
Time,
User,
UserStatus,
};
export default Resolvers;
@@ -18,6 +18,16 @@ enum USER_AUTH_CONDITIONS {
address.
"""
MISSING_EMAIL
"""
BANNED is provided when the User is currently banned.
"""
BANNED
"""
SUSPENDED is provided when the User is currently under an active suspension.
"""
SUSPENDED
}
"""
@@ -1038,6 +1048,11 @@ type Settings {
reaction specifies the configuration for reactions.
"""
reaction: ReactionConfiguration!
"""
createdAt is the time that the Settings was created at.
"""
createdAt: Time! @auth(roles: [ADMIN])
}
################################################################################
@@ -1072,6 +1087,10 @@ type GoogleProfile {
id: String!
}
"""
Profile is all the different profiles that a given User may have associated
with their account.
"""
union Profile =
LocalProfile
| OIDCProfile
@@ -1079,12 +1098,188 @@ union Profile =
| FacebookProfile
| GoogleProfile
"""
Token facilitates accessing Talk externally with a token.
"""
type Token {
id: ID!
name: String!
createdAt: Time!
}
"""
BanStatusHistory is the list of all ban events against a specific User.
"""
type BanStatusHistory {
"""
active when true, indicates that the given user is banned.
"""
active: Boolean!
"""
createdBy is the User that suspended the User. If `null`, the then the given
User was banned by the system.
"""
createdBy: User
"""
createdAt is the time that the given User was banned.
"""
createdAt: Time!
}
"""
BanStatus contains information about a ban for a given User.
"""
type BanStatus {
"""
active when true, indicates that the given user is banned.
"""
active: Boolean!
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "userID"
permit: [SUSPENDED, BANNED]
)
"""
history is the list of all ban events against a specific User.
"""
history: [BanStatusHistory!]! @auth(roles: [ADMIN, MODERATOR])
}
"""
TimeRange represents a range of times.
"""
type TimeRange {
"""
start is the time that the time range started on.
"""
start: Time!
"""
finish is the time that the time range finished at.
"""
finish: Time!
}
"""
SuspensionStatusHistory is the list of all suspension events against a specific User.
"""
type SuspensionStatusHistory {
"""
active is true when the given suspension status time range applies now.
"""
active: Boolean!
"""
from is the time range that the suspension is active for.
"""
from: TimeRange!
"""
createdBy is the User that suspended the User. If `null`, the then the given
User was suspended by the system.
"""
createdBy: User
"""
createdAt is the time that the suspension was created at.
"""
createdAt: Time!
"""
modifiedBy is the User that cancelled/edited the suspension. If `null`, then
the suspension has not been cancelled/edited, or has been edited by the
system.
"""
modifiedBy: User
"""
modifiedAt is the time that the suspension was cancelled/edited. If `null`,
then the suspension has not been cancelled/edited.
"""
modifiedAt: Time
}
"""
SuspensionStatus stores the user suspension status as well as the history of
changes.
"""
type SuspensionStatus {
"""
active when true, indicates that the given user is suspended.
"""
active: Boolean!
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "userID"
permit: [SUSPENDED, BANNED]
)
"""
until is the time that the current user suspension is over.
"""
until: Time
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "userID"
permit: [SUSPENDED, BANNED]
)
"""
history is the list of all suspension events against a specific User.
"""
history: [SuspensionStatusHistory!]! @auth(roles: [ADMIN, MODERATOR])
}
"""
UserStatus stores the user status information regarding moderation state.
"""
type UserStatus {
"""
current represents the current statuses applied to the User.
"""
current: [USER_STATUS!]!
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "userID"
permit: [SUSPENDED, BANNED]
)
"""
banned stores the user banned status as well as the history of changes.
"""
ban: BanStatus!
"""
suspension stores the user suspension status as well as the history of
changes.
"""
suspension: SuspensionStatus!
}
"""
USER_STATUS is used to describe the current state of a User. A User may exist in
multiple states.
"""
enum USER_STATUS {
"""
ACTIVE is used when a User is not suspended or banned.
"""
ACTIVE
"""
BANNED is used when a User is banned.
"""
BANNED
"""
SUSPENDED is used when a User is currently suspended.
"""
SUSPENDED
}
"""
User is someone that leaves Comments, and logs in.
"""
@@ -1099,6 +1294,11 @@ type User {
"""
username: String
"""
avatar is the url to the avatar for a specific User.
"""
avatar: String
"""
email is the current email address for the User.
"""
@@ -1106,9 +1306,15 @@ type User {
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "id"
permit: [MISSING_NAME, MISSING_EMAIL]
permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED]
)
"""
emailVerified when true indicates that the given email address has been
verified.
"""
emailVerified: Boolean @auth(roles: [ADMIN, MODERATOR], userIDField: "id")
"""
profiles is the array of profiles assigned to the user.
"""
@@ -1116,13 +1322,18 @@ type User {
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "id"
permit: [MISSING_NAME, MISSING_EMAIL]
permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED]
)
"""
role is the current role of the User.
"""
role: USER_ROLE! @auth(roles: [ADMIN, MODERATOR], userIDField: "id")
role: USER_ROLE!
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "id"
permit: [SUSPENDED, BANNED]
)
"""
comments are the comments of the User.
@@ -1142,6 +1353,11 @@ type User {
after: Cursor
): CommentModerationActionConnection! @auth(roles: [MODERATOR, ADMIN])
"""
status stores the user status information regarding moderation state.
"""
status: UserStatus!
"""
tokens lists the access tokens associated with the account.
"""
@@ -1807,17 +2023,22 @@ type Query {
"""
user will return the user referenced by their ID.
TODO: evaluate adding a profile based lookup.
"""
user(id: ID!): User @auth(roles: [ADMIN, MODERATOR])
"""
users returns filtered users that can be paginated.
TODO: evaluate adding status based filtering.
"""
users(
first: Int = 10
after: Cursor
role: USER_ROLE
query: String
status: USER_STATUS
): UsersConnection! @auth(roles: [ADMIN, MODERATOR])
"""
@@ -3487,6 +3708,125 @@ type UpdateUserRolePayload {
clientMutationId: String!
}
##################
# banUser
##################
input BanUserInput {
"""
userID is the ID of the User that should have their account banned.
"""
userID: ID!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type BanUserPayload {
"""
user is the possibly modified User.
"""
user: User!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
# suspendUser
##################
input SuspendUserInput {
"""
userID is the ID of the User that should be suspended.
"""
userID: ID!
"""
timeout is the length of time (in seconds) that a User should be suspended
for.
"""
timeout: Int!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type SuspendUserPayload {
"""
user is the possibly modified User.
"""
user: User!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
# removeUserBan
##################
input RemoveUserBanInput {
"""
userID is the ID of the User that should have their account un-banned.
"""
userID: ID!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type RemoveUserBanPayload {
"""
user is the possibly modified User.
"""
user: User!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
# removeUserSuspension
##################
input RemoveUserSuspensionInput {
"""
userID is the ID of the User that should have their active suspensions
removed.
"""
userID: ID!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type RemoveUserSuspensionPayload {
"""
user is the possibly modified User.
"""
user: User!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
## Mutation
##################
@@ -3630,14 +3970,14 @@ type Mutation {
before. This mutation will fail if the username is already set.
"""
setUsername(input: SetUsernameInput!): SetUsernamePayload!
@auth(permit: [MISSING_NAME, MISSING_EMAIL])
@auth(permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED])
"""
setEmail will set the email address on the current User if they have not set
one already. This mutation will fail if the email address is already set.
"""
setEmail(input: SetEmailInput!): SetEmailPayload!
@auth(permit: [MISSING_EMAIL])
@auth(permit: [MISSING_EMAIL, SUSPENDED, BANNED])
"""
setPassword will set the password on the current User if they have not set
@@ -3692,4 +4032,30 @@ type Mutation {
"""
updateUserRole(input: UpdateUserRoleInput!): UpdateUserRolePayload!
@auth(roles: [ADMIN])
"""
banUser will ban a specific User from interacting with Comments.
"""
banUser(input: BanUserInput!): BanUserPayload!
@auth(roles: [ADMIN, MODERATOR])
"""
removeUserBan will remove an active ban from a User if they have one.
"""
removeUserBan(input: RemoveUserBanInput!): RemoveUserBanPayload!
@auth(roles: [ADMIN, MODERATOR])
"""
suspendUser will suspend a specific User from interacting with Comments.
"""
suspendUser(input: SuspendUserInput!): SuspendUserPayload!
@auth(roles: [ADMIN, MODERATOR])
"""
removeUserSuspension will remove an active suspension from a User if they have
one.
"""
removeUserSuspension(
input: RemoveUserSuspensionInput!
): RemoveUserSuspensionPayload! @auth(roles: [ADMIN, MODERATOR])
}
+6 -2
View File
@@ -40,5 +40,9 @@ error-userNotEntitled = You are not authorized to access that resource.
error-storyNotFound = Story ({$storyID}) not found.
error-commentNotFound = Comment ({$commentID}) not found.
error-invalidCredentials = Email and/or password combination incorrect.
error-toxicCommentError = Are you sure? The language in this comment might violate our community guidelines. You can edit the comment or submit it for moderator review.
error-spamCommentError = The language in this comment looks like spam. You can edit the comment or submit it anyway for moderator review.
error-toxicComment = Are you sure? The language in this comment might violate our community guidelines. You can edit the comment or submit it for moderator review.
error-spamComment = The language in this comment looks like spam. You can edit the comment or submit it anyway for moderator review.
error-userAlreadySuspended = The user already has an active suspension until {$until}.
error-userAlreadyBanned = The user is already banned.
error-userBanned = Your account is currently banned.
error-userSuspended = Your account is currently suspended until {$until}.
+8 -4
View File
@@ -186,7 +186,8 @@ export function filterDuplicateActions<T extends {}>(actions: T[]): T[] {
export async function createAction(
mongo: Db,
tenantID: string,
input: CreateActionInput
input: CreateActionInput,
now = new Date()
): Promise<CreateActionResultObject> {
const { metadata, additionalDetails, ...filter } = input;
@@ -198,7 +199,7 @@ export async function createAction(
const defaults: Sub<CommentAction, CreateActionInput> = {
id,
tenantID,
createdAt: new Date(),
createdAt: now,
};
// Merge the defaults with the input.
@@ -244,10 +245,13 @@ export async function createAction(
export async function createActions(
mongo: Db,
tenantID: string,
inputs: CreateActionInput[]
inputs: CreateActionInput[],
now = new Date()
): Promise<CreateActionResultObject[]> {
// TODO: (wyattjoh) replace with a batch write.
return Promise.all(inputs.map(input => createAction(mongo, tenantID, input)));
return Promise.all(
inputs.map(input => createAction(mongo, tenantID, input, now))
);
}
export async function retrieveUserAction(
+7 -9
View File
@@ -221,10 +221,9 @@ export type CreateCommentInput = Omit<
export async function createComment(
mongo: Db,
tenantID: string,
input: CreateCommentInput
input: CreateCommentInput,
now = new Date()
) {
const createdAt = new Date();
// Pull out some useful properties from the input.
const { body, actionCounts = {}, ...rest } = input;
@@ -233,7 +232,7 @@ export async function createComment(
id: uuid.v4(),
body,
actionCounts,
createdAt,
createdAt: now,
};
// default are the properties set by the application when a new comment is
@@ -244,7 +243,7 @@ export async function createComment(
replyIDs: [],
replyCount: 0,
revisions: [revision],
createdAt,
createdAt: now,
};
// Merge the defaults and the input together.
@@ -360,10 +359,9 @@ export interface EditComment {
export async function editComment(
mongo: Db,
tenantID: string,
input: EditCommentInput
input: EditCommentInput,
now = new Date()
): Promise<EditComment> {
const createdAt = new Date();
const {
id,
body,
@@ -379,7 +377,7 @@ export async function editComment(
id: uuid.v4(),
body,
actionCounts,
createdAt,
createdAt: now,
};
const update: Record<string, any> = {
+1
View File
@@ -81,6 +81,7 @@ export type Settings = GlobalModerationSettings &
| "editCommentWindowLength"
| "customCSSURL"
| "communityGuidelines"
| "createdAt"
> & {
/**
* auth is the set of configured authentication integrations.
+36 -21
View File
@@ -62,9 +62,9 @@ function collection<T = Story>(mongo: Db) {
export async function recalculateSharedModerationQueueQueueCounts(
mongo: Db,
redis: AugmentedRedis,
tenantID: string
tenantID: string,
now = new Date()
) {
const now = new Date();
const key = commentCountsModerationQueueQueuesKey(tenantID);
const freshKey = freshenKey(key);
@@ -136,9 +136,9 @@ export async function recalculateSharedModerationQueueQueueCounts(
export async function recalculateSharedModerationQueueTotalCounts(
mongo: Db,
redis: AugmentedRedis,
tenantID: string
tenantID: string,
now = new Date()
) {
const now = new Date();
const key = commentCountsModerationQueueTotalKey(tenantID);
const freshKey = freshenKey(key);
@@ -191,9 +191,9 @@ export async function recalculateSharedModerationQueueTotalCounts(
export async function recalculateSharedStatusCommentCounts(
mongo: Db,
redis: AugmentedRedis,
tenantID: string
tenantID: string,
now = new Date()
) {
const now = new Date();
const key = commentCountsStatusKey(tenantID);
const freshKey = freshenKey(key);
@@ -265,9 +265,9 @@ export async function recalculateSharedStatusCommentCounts(
export async function recalculateSharedActionCommentCounts(
mongo: Db,
redis: AugmentedRedis,
tenantID: string
tenantID: string,
now = new Date()
) {
const now = new Date();
const key = commentCountsActionKey(tenantID);
const freshKey = freshenKey(key);
@@ -339,13 +339,14 @@ export async function recalculateSharedActionCommentCounts(
export async function recalculateSharedCommentCounts(
mongo: Db,
redis: AugmentedRedis,
tenantID: string
tenantID: string,
now = new Date()
) {
await Promise.all([
recalculateSharedModerationQueueQueueCounts(mongo, redis, tenantID),
recalculateSharedModerationQueueTotalCounts(mongo, redis, tenantID),
recalculateSharedStatusCommentCounts(mongo, redis, tenantID),
recalculateSharedActionCommentCounts(mongo, redis, tenantID),
recalculateSharedModerationQueueQueueCounts(mongo, redis, tenantID, now),
recalculateSharedModerationQueueTotalCounts(mongo, redis, tenantID, now),
recalculateSharedStatusCommentCounts(mongo, redis, tenantID, now),
recalculateSharedActionCommentCounts(mongo, redis, tenantID, now),
]);
}
@@ -385,7 +386,8 @@ function fillAndConvertStringToNumber<
export async function retrieveSharedActionCommentCounts(
mongo: Db,
redis: AugmentedRedis,
tenantID: string
tenantID: string,
now = new Date()
): Promise<EncodedCommentActionCounts> {
const key = commentCountsActionKey(tenantID);
const freshKey = freshenKey(key);
@@ -400,7 +402,7 @@ export async function retrieveSharedActionCommentCounts(
.get(freshKey)
.exec();
if (!fresh || !actions) {
return recalculateSharedActionCommentCounts(mongo, redis, tenantID);
return recalculateSharedActionCommentCounts(mongo, redis, tenantID, now);
}
return fillAndConvertStringToNumber(
@@ -421,7 +423,8 @@ export async function retrieveSharedActionCommentCounts(
export async function retrieveSharedStatusCommentCounts(
mongo: Db,
redis: AugmentedRedis,
tenantID: string
tenantID: string,
now = new Date()
): Promise<CommentStatusCounts> {
const key = commentCountsStatusKey(tenantID);
const freshKey = freshenKey(key);
@@ -436,7 +439,7 @@ export async function retrieveSharedStatusCommentCounts(
.get(freshKey)
.exec();
if (!fresh || !statuses) {
return recalculateSharedStatusCommentCounts(mongo, redis, tenantID);
return recalculateSharedStatusCommentCounts(mongo, redis, tenantID, now);
}
return fillAndConvertStringToNumber(
@@ -457,7 +460,8 @@ export async function retrieveSharedStatusCommentCounts(
export async function retrieveSharedModerationQueueTotal(
mongo: Db,
redis: AugmentedRedis,
tenantID: string
tenantID: string,
now = new Date()
) {
const key = commentCountsModerationQueueTotalKey(tenantID);
const freshKey = freshenKey(key);
@@ -468,7 +472,12 @@ export async function retrieveSharedModerationQueueTotal(
freshKey
);
if (fresh === null || total === null) {
return recalculateSharedModerationQueueTotalCounts(mongo, redis, tenantID);
return recalculateSharedModerationQueueTotalCounts(
mongo,
redis,
tenantID,
now
);
}
return parseInt(total, 10) || 0;
@@ -486,7 +495,8 @@ export async function retrieveSharedModerationQueueTotal(
export async function retrieveSharedModerationQueueQueuesCounts(
mongo: Db,
redis: AugmentedRedis,
tenantID: string
tenantID: string,
now = new Date()
): Promise<CommentModerationCountsPerQueue> {
const key = commentCountsModerationQueueQueuesKey(tenantID);
const freshKey = freshenKey(key);
@@ -505,7 +515,12 @@ export async function retrieveSharedModerationQueueQueuesCounts(
.exec();
if (!fresh || !queues) {
logger.debug({ tenantID }, "comment moderation counts were not cached");
return recalculateSharedModerationQueueQueueCounts(mongo, redis, tenantID);
return recalculateSharedModerationQueueQueueCounts(
mongo,
redis,
tenantID,
now
);
}
logger.debug({ tenantID }, "comment moderation counts were cached");
+37 -21
View File
@@ -121,10 +121,9 @@ export interface UpsertStoryInput {
export async function upsertStory(
mongo: Db,
tenantID: string,
{ id, url }: UpsertStoryInput
{ id, url }: UpsertStoryInput,
now = new Date()
) {
const now = new Date();
// Create the story, optionally sourcing the id from the input, additionally
// porting in the tenantID.
const update: { $setOnInsert: Story } = {
@@ -171,15 +170,21 @@ export interface FindOrCreateStoryInput {
export async function findOrCreateStory(
mongo: Db,
tenantID: string,
{ id, url }: FindOrCreateStoryInput
{ id, url }: FindOrCreateStoryInput,
now = new Date()
) {
if (id) {
if (url) {
// The URL was specified, this is an upsert operation.
return upsertStory(mongo, tenantID, {
id,
url,
});
return upsertStory(
mongo,
tenantID,
{
id,
url,
},
now
);
}
// The URL was not specified, this is a lookup operation.
@@ -192,7 +197,7 @@ export async function findOrCreateStory(
throw new Error("cannot upsert an story without the url");
}
return upsertStory(mongo, tenantID, { url });
return upsertStory(mongo, tenantID, { url }, now);
}
export type CreateStoryInput = Partial<Pick<Story, "metadata" | "scrapedAt">>;
@@ -202,10 +207,9 @@ export async function createStory(
tenantID: string,
id: string,
url: string,
input: CreateStoryInput
input: CreateStoryInput,
now = new Date()
) {
const now = new Date();
// Create the story.
const story: Story = {
...input,
@@ -289,14 +293,15 @@ export async function updateStory(
mongo: Db,
tenantID: string,
id: string,
input: UpdateStoryInput
input: UpdateStoryInput,
now = new Date()
) {
// Only update fields that have been updated.
const update = {
$set: {
...dotize(input, { embedArrays: true }),
// Always update the updated at time.
updatedAt: new Date(),
updatedAt: now,
},
};
@@ -326,14 +331,15 @@ export async function updateStorySettings(
mongo: Db,
tenantID: string,
id: string,
input: UpdateStorySettingsInput
input: UpdateStorySettingsInput,
now = new Date()
) {
// Only update fields that have been updated.
const update = {
$set: {
...omitBy(dotize({ settings: input }, { embedArrays: true }), isNull),
// Always update the updated at time.
updatedAt: new Date(),
updatedAt: now,
},
};
@@ -348,14 +354,19 @@ export async function updateStorySettings(
return result.value || null;
}
export async function openStory(mongo: Db, tenantID: string, id: string) {
export async function openStory(
mongo: Db,
tenantID: string,
id: string,
now = new Date()
) {
const result = await collection(mongo).findOneAndUpdate(
{ id, tenantID },
{
$set: {
closedAt: false,
// Always update the updated at time.
updatedAt: new Date(),
updatedAt: now,
},
},
// False to return the updated document instead of the original
@@ -366,14 +377,19 @@ export async function openStory(mongo: Db, tenantID: string, id: string) {
return result.value || null;
}
export async function closeStory(mongo: Db, tenantID: string, id: string) {
export async function closeStory(
mongo: Db,
tenantID: string,
id: string,
now = new Date()
) {
const result = await collection(mongo).findOneAndUpdate(
{ id, tenantID },
{
$set: {
closedAt: new Date(),
closedAt: now,
// Always update the updated at time.
updatedAt: new Date(),
updatedAt: now,
},
},
// False to return the updated document instead of the original
+7 -2
View File
@@ -69,7 +69,11 @@ export type CreateTenantInput = Pick<
* @param mongo the MongoDB connection used to create the tenant.
* @param input the customizable parts of the Tenant available during creation
*/
export async function createTenant(mongo: Db, input: CreateTenantInput) {
export async function createTenant(
mongo: Db,
input: CreateTenantInput,
now = new Date()
) {
const defaults: Sub<Tenant, CreateTenantInput> = {
// Create a new ID.
id: uuid.v4(),
@@ -119,7 +123,7 @@ export async function createTenant(mongo: Db, input: CreateTenantInput) {
stream: true,
},
key: generateSSOKey(),
keyGeneratedAt: new Date(),
keyGeneratedAt: now,
},
oidc: {
enabled: false,
@@ -175,6 +179,7 @@ export async function createTenant(mongo: Db, input: CreateTenantInput) {
labelActive: "Respected",
icon: "thumb_up",
},
createdAt: now,
};
// Create the new Tenant by merging it together with the defaults.
+536 -16
View File
@@ -2,17 +2,26 @@ import bcrypt from "bcryptjs";
import { Db, MongoError } from "mongodb";
import uuid from "uuid";
import { Omit, Sub } from "talk-common/types";
import { DeepPartial, Omit, Sub } from "talk-common/types";
import { dotize } from "talk-common/utils/dotize";
import {
DuplicateEmailError,
DuplicateUserError,
LocalProfileAlreadySetError,
LocalProfileNotSetError,
TokenNotFoundError,
UserAlreadyBannedError,
UserAlreadySuspendedError,
UsernameAlreadySetError,
UserNotFoundError,
} from "talk-server/errors";
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
import {
GQLBanStatus,
GQLSuspensionStatus,
GQLTimeRange,
GQLUSER_ROLE,
} from "talk-server/graph/tenant/schema/__generated__/types";
import logger from "talk-server/logger";
import {
createConnectionOrderVariants,
createIndexFactory,
@@ -57,6 +66,10 @@ export interface GoogleProfile {
id: string;
}
/**
* Profile is all the different profiles that a given User may have associated
* with their account.
*/
export type Profile =
| LocalProfile
| OIDCProfile
@@ -70,15 +83,168 @@ export interface Token {
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;
}
/**
* 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;
/**
* 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;
/**
* createdAt is the time that the User was created at.
*/
createdAt: Date;
}
@@ -138,6 +304,19 @@ export async function createUserIndexes(mongo: Db) {
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> {
@@ -146,16 +325,15 @@ function hashPassword(password: string): Promise<string> {
export type InsertUserInput = Omit<
User,
"id" | "tenantID" | "tokens" | "createdAt"
"id" | "tenantID" | "tokens" | "status" | "createdAt"
>;
export async function insertUser(
mongo: Db,
tenantID: string,
input: InsertUserInput
input: InsertUserInput,
now = new Date()
) {
const now = new Date();
// Create a new ID for the user.
const id = uuid.v4();
@@ -165,6 +343,10 @@ export async function insertUser(
id,
tenantID,
tokens: [],
status: {
suspension: { history: [] },
ban: { active: false, history: [] },
},
createdAt: now,
};
@@ -332,7 +514,7 @@ export async function updateUserPassword(
throw new LocalProfileNotSetError();
}
throw new Error("an unexpected error occured");
throw new Error("an unexpected error occurred");
}
return result.value || null;
@@ -384,7 +566,7 @@ export async function setUserUsername(
throw new UsernameAlreadySetError();
}
throw new Error("an unexpected error occured");
throw new Error("an unexpected error occurred");
}
return result.value;
@@ -430,7 +612,7 @@ export async function updateUserUsername(
throw new UserNotFoundError(id);
}
throw new Error("an unexpected error occured");
throw new Error("an unexpected error occurred");
}
return result.value;
@@ -492,7 +674,7 @@ export async function setUserEmail(
throw new UsernameAlreadySetError();
}
throw new Error("an unexpected error occured");
throw new Error("an unexpected error occurred");
}
return result.value;
@@ -548,7 +730,7 @@ export async function updateUserEmail(
throw new UserNotFoundError(id);
}
throw new Error("an unexpected error occured");
throw new Error("an unexpected error occurred");
}
return result.value;
@@ -595,7 +777,7 @@ export async function updateUserAvatar(
throw new UserNotFoundError(id);
}
throw new Error("an unexpected error occured");
throw new Error("an unexpected error occurred");
}
return result.value;
@@ -672,7 +854,7 @@ export async function setUserLocalProfile(
throw new LocalProfileAlreadySetError();
}
throw new Error("an unexpected error occured");
throw new Error("an unexpected error occurred");
}
return result.value;
@@ -682,13 +864,14 @@ export async function createUserToken(
mongo: Db,
tenantID: string,
userID: string,
name: 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: new Date(),
createdAt: now,
};
const result = await collection(mongo).findOneAndUpdate(
@@ -748,7 +931,7 @@ export async function deactivateUserToken(
throw new TokenNotFoundError();
}
throw new Error("an unexpected error occured");
throw new Error("an unexpected error occurred");
}
// We have to typecast here because we know at this point that the record does
@@ -798,3 +981,340 @@ async function retrieveConnection(
// 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),
};
}
+51 -28
View File
@@ -34,10 +34,11 @@ export type CreateAction = CreateActionInput;
export async function addCommentActions(
mongo: Db,
tenant: Tenant,
...inputs: CreateAction[]
inputs: CreateAction[],
now = new Date()
) {
// Create each of the actions, returning each of the action results.
const results = await createActions(mongo, tenant.id, inputs);
const results = await createActions(mongo, tenant.id, inputs, now);
// Get the actions that were upserted, we only want to increment the action
// counts of actions that were just created.
@@ -78,7 +79,8 @@ async function addCommentAction(
mongo: Db,
redis: AugmentedRedis,
tenant: Tenant,
input: Omit<CreateActionInput, "storyID">
input: Omit<CreateActionInput, "storyID">,
now = new Date()
): Promise<Readonly<Comment>> {
const oldComment = await retrieveComment(mongo, tenant.id, input.commentID);
if (!oldComment) {
@@ -93,7 +95,7 @@ async function addCommentAction(
};
// Update the actions for the comment.
const commentActions = await addCommentActions(mongo, tenant, action);
const commentActions = await addCommentActions(mongo, tenant, [action], now);
if (commentActions.length > 0) {
// Update the comment action counts.
const updatedComment = await addCommentActionCounts(
@@ -198,14 +200,21 @@ export async function createReaction(
redis: AugmentedRedis,
tenant: Tenant,
author: User,
input: CreateCommentReaction
input: CreateCommentReaction,
now = new Date()
) {
return addCommentAction(mongo, redis, tenant, {
actionType: ACTION_TYPE.REACTION,
commentID: input.commentID,
commentRevisionID: input.commentRevisionID,
userID: author.id,
});
return addCommentAction(
mongo,
redis,
tenant,
{
actionType: ACTION_TYPE.REACTION,
commentID: input.commentID,
commentRevisionID: input.commentRevisionID,
userID: author.id,
},
now
);
}
export type RemoveCommentReaction = Pick<RemoveActionInput, "commentID">;
@@ -234,15 +243,22 @@ export async function createDontAgree(
redis: AugmentedRedis,
tenant: Tenant,
author: User,
input: CreateCommentDontAgree
input: CreateCommentDontAgree,
now = new Date()
) {
return addCommentAction(mongo, redis, tenant, {
actionType: ACTION_TYPE.DONT_AGREE,
commentID: input.commentID,
commentRevisionID: input.commentRevisionID,
additionalDetails: input.additionalDetails,
userID: author.id,
});
return addCommentAction(
mongo,
redis,
tenant,
{
actionType: ACTION_TYPE.DONT_AGREE,
commentID: input.commentID,
commentRevisionID: input.commentRevisionID,
additionalDetails: input.additionalDetails,
userID: author.id,
},
now
);
}
export type RemoveCommentDontAgree = Pick<RemoveActionInput, "commentID">;
@@ -273,14 +289,21 @@ export async function createFlag(
redis: AugmentedRedis,
tenant: Tenant,
author: User,
input: CreateCommentFlag
input: CreateCommentFlag,
now = new Date()
) {
return addCommentAction(mongo, redis, tenant, {
actionType: ACTION_TYPE.FLAG,
reason: input.reason,
commentID: input.commentID,
commentRevisionID: input.commentRevisionID,
additionalDetails: input.additionalDetails,
userID: author.id,
});
return addCommentAction(
mongo,
redis,
tenant,
{
actionType: ACTION_TYPE.FLAG,
reason: input.reason,
commentID: input.commentID,
commentRevisionID: input.commentRevisionID,
additionalDetails: input.additionalDetails,
userID: author.id,
},
now
);
}
+53 -35
View File
@@ -27,7 +27,11 @@ import { User } from "talk-server/models/user";
import { Request } from "talk-server/types/express";
import { ERROR_TYPES } from "talk-common/errors";
import { TalkError } from "talk-server/errors";
import {
CommentNotFoundError,
StoryNotFoundError,
TalkError,
} from "talk-server/errors";
import { AugmentedRedis } from "../redis";
import { addCommentActions, CreateAction } from "./actions";
import { calculateCounts, calculateCountsDiff } from "./moderation/counts";
@@ -45,6 +49,7 @@ export async function create(
author: User,
input: CreateComment,
nudge: boolean,
now = new Date(),
req?: Request
) {
let log = logger.child({
@@ -62,8 +67,7 @@ export async function create(
// Grab the story that we'll use to check moderation pieces with.
const story = await retrieveStory(mongo, tenant.id, input.storyID);
if (!story) {
// TODO: (wyattjoh) return better error.
throw new Error("story referenced does not exist");
throw new StoryNotFoundError(input.storyID);
}
const grandparentIDs: string[] = [];
@@ -71,8 +75,7 @@ export async function create(
// Check to see that the reference parent ID exists.
const parent = await retrieveComment(mongo, tenant.id, input.parentID);
if (!parent) {
// TODO: (wyattjoh) return better error.
throw new Error("parent comment referenced does not exist");
throw new CommentNotFoundError(input.parentID);
}
// FIXME: (wyattjoh) Check that the parent comment was visible!
@@ -101,6 +104,7 @@ export async function create(
comment: input,
author,
req,
now,
});
} catch (err) {
if (
@@ -137,15 +141,20 @@ export async function create(
}
// Create the comment!
const comment = await createComment(mongo, tenant.id, {
...input,
tags,
body,
status,
grandparentIDs,
metadata,
actionCounts,
});
const comment = await createComment(
mongo,
tenant.id,
{
...input,
tags,
body,
status,
grandparentIDs,
metadata,
actionCounts,
},
now
);
// Pull the revision out.
const revision = getLatestRevision(comment);
@@ -172,7 +181,7 @@ export async function create(
const upsertedActions = await addCommentActions(
mongo,
tenant,
...actions.map(
actions.map(
(action): CreateAction => ({
...action,
commentID: comment.id,
@@ -181,7 +190,8 @@ export async function create(
// Store the Story ID on the action.
storyID: story.id,
})
)
),
now
);
log.trace({ actions: upsertedActions.length }, "added actions to comment");
@@ -215,6 +225,7 @@ export async function edit(
tenant: Tenant,
author: User,
input: EditComment,
now = new Date(),
req?: Request
) {
let log = logger.child({ commentID: input.id, tenantID: tenant.id });
@@ -227,15 +238,17 @@ export async function edit(
input.id
);
if (!originalStaleComment) {
// TODO: replace to match error returned by the models/comments.ts
throw new Error("comment not found");
throw new CommentNotFoundError(input.id);
}
// The editable time is based on the current time, and the edit window
// length. By subtracting the current date from the edit window length, we
// get the maximum value for the `createdAt` time that would be permitted
// for the comment edit to succeed.
const lastEditableCommentCreatedAt = getLastCommentEditableUntilDate(tenant);
const lastEditableCommentCreatedAt = getLastCommentEditableUntilDate(
tenant,
now
);
// Validate and potentially return with a more useful error.
validateEditable(originalStaleComment, {
@@ -250,8 +263,7 @@ export async function edit(
originalStaleComment.storyID
);
if (!story) {
// TODO: (wyattjoh) return better error.
throw new Error("story referenced does not exist");
throw new StoryNotFoundError(originalStaleComment.storyID);
}
// Run the comment through the moderation phases.
@@ -261,6 +273,7 @@ export async function edit(
comment: input,
author,
req,
now,
});
let actionCounts = {};
@@ -276,18 +289,22 @@ export async function edit(
);
// Perform the edit.
const result = await editComment(mongo, tenant.id, {
id: input.id,
authorID: author.id,
body,
status,
metadata,
actionCounts,
lastEditableCommentCreatedAt,
});
const result = await editComment(
mongo,
tenant.id,
{
id: input.id,
authorID: author.id,
body,
status,
metadata,
actionCounts,
lastEditableCommentCreatedAt,
},
now
);
if (!result) {
// TODO: replace to match error returned by the models/comments.ts
throw new Error("comment not found");
throw new CommentNotFoundError(input.id);
}
// Pull the old/edited comments out of the edit result.
@@ -300,14 +317,15 @@ export async function edit(
const upsertedActions = await addCommentActions(
mongo,
tenant,
...actions.map(
actions.map(
(action): CreateAction => ({
...action,
commentID: editedComment.id,
commentRevisionID: newRevision.id,
storyID: story.id,
})
)
),
now
);
log.trace(
@@ -361,7 +379,7 @@ export async function edit(
*/
export function getLastCommentEditableUntilDate(
tenant: Pick<Tenant, "editCommentWindowLength">,
now: Date = new Date()
now = new Date()
): Date {
return (
DateTime.fromJSDate(now)
@@ -28,6 +28,7 @@ export interface ModerationPhaseContext {
tenant: Tenant;
comment: RequireProperty<Partial<EditCommentInput>, "body">;
author: User;
now: Date;
nudge?: boolean;
req?: Request;
}
@@ -11,6 +11,7 @@ import {
// If a given user is a staff member, always approve their comment.
export const staff: IntermediateModerationPhase = ({
author,
now,
}): IntermediatePhaseResult | void => {
if (author.role !== GQLUSER_ROLE.COMMENTER) {
return {
@@ -18,8 +19,7 @@ export const staff: IntermediateModerationPhase = ({
tags: [
{
type: COMMENT_TAG_TYPE.STAFF,
// FIXME: (wyattjoh) replace with date from context when https://github.com/coralproject/talk/pull/2247 is merged.
createdAt: new Date(),
createdAt: now,
},
],
};
@@ -11,6 +11,7 @@ describe("storyClosed", () => {
tenant: {} as ModerationPhaseContext["tenant"],
comment: {} as ModerationPhaseContext["comment"],
author: {} as ModerationPhaseContext["author"],
now: new Date(),
})
).toThrow();
@@ -19,6 +20,7 @@ describe("storyClosed", () => {
tenant: {
closeCommenting: { auto: true },
} as ModerationPhaseContext["tenant"],
now: new Date(),
comment: {} as ModerationPhaseContext["comment"],
author: {} as ModerationPhaseContext["author"],
});
@@ -32,6 +34,7 @@ describe("storyClosed", () => {
timeout: -6000,
},
} as ModerationPhaseContext["tenant"],
now: new Date(),
comment: {} as ModerationPhaseContext["comment"],
author: {} as ModerationPhaseContext["author"],
})
@@ -53,6 +56,7 @@ describe("storyClosed", () => {
} as ModerationPhaseContext["tenant"],
comment: {} as ModerationPhaseContext["comment"],
author: {} as ModerationPhaseContext["author"],
now,
})
).toBeUndefined();
@@ -64,6 +68,7 @@ describe("storyClosed", () => {
} as ModerationPhaseContext["tenant"],
comment: {} as ModerationPhaseContext["comment"],
author: {} as ModerationPhaseContext["author"],
now,
})
).toBeUndefined();
});
@@ -9,9 +9,10 @@ import { getStoryClosedAt } from "talk-server/services/stories";
export const storyClosed: IntermediateModerationPhase = ({
story,
tenant,
now,
}): IntermediatePhaseResult | void => {
const closedAt = getStoryClosedAt(tenant, story);
if (closedAt && closedAt.valueOf() <= Date.now()) {
if (closedAt && closedAt <= now) {
throw new StoryClosedError();
}
};
+35 -14
View File
@@ -53,7 +53,8 @@ export async function findOrCreate(
mongo: Db,
tenant: Tenant,
input: FindOrCreateStory,
scraper: ScraperQueue
scraper: ScraperQueue,
now = new Date()
) {
// If the URL is provided, and the url is not on a allowed domain, then refuse
// to create the Asset.
@@ -66,7 +67,7 @@ export async function findOrCreate(
// TODO: check to see if the tenant has enabled lazy story creation, if they haven't, switch to find only.
const story = await findOrCreateStory(mongo, tenant.id, input);
const story = await findOrCreateStory(mongo, tenant.id, input, now);
if (!story) {
return null;
}
@@ -189,7 +190,8 @@ export async function create(
tenant: Tenant,
storyID: string,
storyURL: string,
{ metadata }: CreateStory
{ metadata }: CreateStory,
now = new Date()
) {
// Ensure that the given URL is allowed.
if (!isURLPermitted(tenant, storyURL)) {
@@ -199,15 +201,22 @@ export async function create(
// Construct the input payload.
const input: CreateStoryInput = { metadata };
if (metadata) {
input.scrapedAt = new Date();
input.scrapedAt = now;
}
// Create the story in the database.
let newStory = await createStory(mongo, tenant.id, storyID, storyURL, input);
let newStory = await createStory(
mongo,
tenant.id,
storyID,
storyURL,
input,
now
);
if (!metadata) {
// If the scraper has not scraped this story and story metadata was not
// provided, we need to scrape it now!
newStory = await scrape(mongo, tenant.id, newStory.id);
newStory = await scrape(mongo, tenant.id, newStory.id, storyURL);
}
return newStory;
@@ -219,7 +228,8 @@ export async function update(
mongo: Db,
tenant: Tenant,
storyID: string,
input: UpdateStory
input: UpdateStory,
now = new Date()
) {
// Ensure that the given URL is allowed.
if (input.url && !isURLPermitted(tenant, input.url)) {
@@ -229,7 +239,7 @@ export async function update(
});
}
return updateStory(mongo, tenant.id, storyID, input);
return updateStory(mongo, tenant.id, storyID, input, now);
}
export type UpdateStorySettings = UpdateStorySettingsInput;
@@ -237,17 +247,28 @@ export async function updateSettings(
mongo: Db,
tenant: Tenant,
storyID: string,
input: UpdateStorySettings
input: UpdateStorySettings,
now = new Date()
) {
return updateStorySettings(mongo, tenant.id, storyID, input);
return updateStorySettings(mongo, tenant.id, storyID, input, now);
}
export async function open(mongo: Db, tenant: Tenant, storyID: string) {
return openStory(mongo, tenant.id, storyID);
export async function open(
mongo: Db,
tenant: Tenant,
storyID: string,
now = new Date()
) {
return openStory(mongo, tenant.id, storyID, now);
}
export async function close(mongo: Db, tenant: Tenant, storyID: string) {
return closeStory(mongo, tenant.id, storyID);
export async function close(
mongo: Db,
tenant: Tenant,
storyID: string,
now = new Date()
) {
return closeStory(mongo, tenant.id, storyID, now);
}
export async function merge(
@@ -134,11 +134,19 @@ export async function scrape(
throw new Error("story at specified url not found");
}
const now = new Date();
// Update the Story with the scraped details.
const story = await updateStory(mongo, tenantID, storyID, {
metadata,
scrapedAt: new Date(),
});
const story = await updateStory(
mongo,
tenantID,
storyID,
{
metadata,
scrapedAt: now,
},
now
);
if (!story) {
throw new Error("story at specified id not found");
}
+3 -2
View File
@@ -42,7 +42,8 @@ export async function install(
mongo: Db,
redis: Redis,
cache: TenantCache,
input: InstallTenant
input: InstallTenant,
now = new Date()
) {
if (await isInstalled(cache)) {
throw new TenantInstalledAlreadyError();
@@ -55,7 +56,7 @@ export async function install(
logger.info({ tenant: input }, "installing tenant");
// Create the Tenant.
const tenant = await createTenant(mongo, input);
const tenant = await createTenant(mongo, input, now);
// Update the tenant cache.
await cache.update(redis, tenant);
+165 -21
View File
@@ -1,3 +1,4 @@
import { DateTime } from "luxon";
import { Db } from "mongodb";
import {
@@ -16,22 +17,32 @@ import {
LocalProfileNotSetError,
PasswordTooShortError,
TokenNotFoundError,
UserAlreadyBannedError,
UserAlreadySuspendedError,
UsernameAlreadySetError,
UsernameContainsInvalidCharactersError,
UsernameExceedsMaxLengthError,
UsernameTooShortError,
UserNotFoundError,
} from "talk-server/errors";
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
import { Tenant } from "talk-server/models/tenant";
import {
banUser,
consolidateUserBanStatus,
consolidateUserSuspensionStatus,
createUserToken,
deactivateUserToken,
insertUser,
InsertUserInput,
LocalProfile,
removeActiveUserSuspensions,
removeUserBan,
retrieveUser,
setUserEmail,
setUserLocalProfile,
setUserUsername,
suspendUser,
updateUserAvatar,
updateUserEmail,
updateUserPassword,
@@ -47,11 +58,10 @@ import { JWTSigningConfig, signPATString } from "../jwt";
* implementation uses a RegExp statically, future versions will expose this as
* configuration.
*
* @param tenant tenant where the User is associated with
* @param username the username to be tested
*/
function validateUsername(tenant: Tenant, username: string) {
// FIXME: replace these static regex/length with database options in the Tenant eventually
function validateUsername(username: string) {
// TODO: replace these static regex/length with database options in the Tenant eventually
if (!USERNAME_REGEX.test(username)) {
throw new UsernameContainsInvalidCharactersError();
@@ -74,10 +84,9 @@ function validateUsername(tenant: Tenant, username: string) {
* implementation uses a length statically, future versions will expose this as
* configuration.
*
* @param tenant tenant where the User is associated with
* @param password the password to be tested
*/
function validatePassword(tenant: Tenant, password: string) {
function validatePassword(password: string) {
// TODO: replace these static length with database options in the Tenant eventually
if (password.length < PASSWORD_MIN_LENGTH) {
throw new PasswordTooShortError(password.length, PASSWORD_MIN_LENGTH);
@@ -90,10 +99,9 @@ const EMAIL_MAX_LENGTH = 100;
* validateEmail will validate that the email is valid. Current implementation
* uses a length statically, future versions will expose this as configuration.
*
* @param tenant tenant where the User is associated with
* @param email the email to be tested
*/
function validateEmail(tenant: Tenant, email: string) {
function validateEmail(email: string) {
if (!EMAIL_REGEX.test(email)) {
throw new EmailInvalidFormatError();
}
@@ -113,28 +121,33 @@ export type InsertUser = InsertUserInput;
* @param tenant Tenant where the User will be added to
* @param input the input for creating the User
*/
export async function insert(mongo: Db, tenant: Tenant, input: InsertUser) {
export async function insert(
mongo: Db,
tenant: Tenant,
input: InsertUser,
now = new Date()
) {
if (input.username) {
validateUsername(tenant, input.username);
validateUsername(input.username);
}
if (input.email) {
validateEmail(tenant, input.email);
validateEmail(input.email);
}
const localProfile: LocalProfile | undefined = input.profiles.find(
({ type }) => type === "local"
) as LocalProfile | undefined;
if (localProfile) {
validateEmail(tenant, localProfile.id);
validatePassword(tenant, localProfile.password);
validateEmail(localProfile.id);
validatePassword(localProfile.password);
if (input.email !== localProfile.id) {
throw new Error("email addresses don't match profile");
}
}
const user = await insertUser(mongo, tenant.id, input);
const user = await insertUser(mongo, tenant.id, input, now);
return user;
}
@@ -159,7 +172,7 @@ export async function setUsername(
throw new UsernameAlreadySetError();
}
validateUsername(tenant, username);
validateUsername(username);
return setUserUsername(mongo, tenant.id, user.id, username);
}
@@ -185,7 +198,7 @@ export async function setEmail(
throw new EmailAlreadySetError();
}
validateEmail(tenant, email);
validateEmail(email);
return setUserEmail(mongo, tenant.id, user.id, email);
}
@@ -219,7 +232,7 @@ export async function setPassword(
throw new LocalProfileAlreadySetError();
}
validatePassword(tenant, password);
validatePassword(password);
return setUserLocalProfile(mongo, tenant.id, user.id, user.email, password);
}
@@ -254,7 +267,7 @@ export async function updatePassword(
throw new LocalProfileNotSetError();
}
validatePassword(tenant, password);
validatePassword(password);
return updateUserPassword(mongo, tenant.id, user.id, password);
}
@@ -274,10 +287,11 @@ export async function createToken(
tenant: Tenant,
config: JWTSigningConfig,
user: User,
name: string
name: string,
now = new Date()
) {
// Create the token for the User!
const result = await createUserToken(mongo, tenant.id, user.id, name);
const result = await createUserToken(mongo, tenant.id, user.id, name, now);
// Sign the token!
const signedToken = await signPATString(config, user, {
@@ -286,6 +300,9 @@ export async function createToken(
// Tokens are issued with the tenant ID.
issuer: tenant.id,
// Tokens are not valid before the creation date.
notBefore: DateTime.fromJSDate(now).toSeconds(),
});
return { ...result, signedToken };
@@ -329,7 +346,7 @@ export async function updateUsername(
username: string
) {
// Validate the username.
validateUsername(tenant, username);
validateUsername(username);
return updateUserUsername(mongo, tenant.id, userID, username);
}
@@ -371,7 +388,7 @@ export async function updateEmail(
email: string
) {
// Validate the email address.
validateEmail(tenant, email);
validateEmail(email);
return updateUserEmail(mongo, tenant.id, userID, email);
}
@@ -392,3 +409,130 @@ export async function updateAvatar(
) {
return updateUserAvatar(mongo, tenant.id, userID, avatar);
}
/**
* ban will ban a specific user from interacting with Talk.
*
* @param mongo mongo database to interact with
* @param tenant Tenant where the User will be banned on
* @param user the User that is banning the User
* @param userID the ID of the User being banned
* @param now the current time that the ban took effect
*/
export async function ban(
mongo: Db,
tenant: Tenant,
user: User,
userID: string,
now = new Date()
) {
// Get the user being banned to check to see if the user already has an
// existing ban.
const targetUser = await retrieveUser(mongo, tenant.id, userID);
if (!targetUser) {
throw new UserNotFoundError(userID);
}
// Check to see if the User is currently banned.
const banStatus = consolidateUserBanStatus(targetUser.status.ban);
if (banStatus.active) {
throw new UserAlreadyBannedError();
}
return banUser(mongo, tenant.id, userID, user.id, now);
}
/**
* suspend will suspend a give user from interacting with Talk.
*
* @param mongo mongo database to interact with
* @param tenant Tenant where the User will be suspended on
* @param user the User that is suspending the User
* @param userID the ID of the user being suspended
* @param timeout the duration in seconds that the user will suspended for
* @param now the current time that the suspension will take effect
*/
export async function suspend(
mongo: Db,
tenant: Tenant,
user: User,
userID: string,
timeout: number,
now = new Date()
) {
// Convert the timeout to the until time.
const finish = DateTime.fromJSDate(now)
.plus({ seconds: timeout })
.toJSDate();
// Get the user being suspended to check to see if the user already has an
// existing suspension.
const targetUser = await retrieveUser(mongo, tenant.id, userID);
if (!targetUser) {
throw new UserNotFoundError(userID);
}
// Check to see if the User is currently suspended.
const suspended = consolidateUserSuspensionStatus(
targetUser.status.suspension,
now
);
if (suspended.active && suspended.until) {
throw new UserAlreadySuspendedError(suspended.until);
}
return suspendUser(mongo, tenant.id, userID, user.id, finish, now);
}
export async function removeSuspension(
mongo: Db,
tenant: Tenant,
user: User,
userID: string,
now = new Date()
) {
// Get the user being suspended to check to see if the user already has an
// existing suspension.
const targetUser = await retrieveUser(mongo, tenant.id, userID);
if (!targetUser) {
throw new UserNotFoundError(userID);
}
// Check to see if the User is currently suspended.
const suspended = consolidateUserSuspensionStatus(
targetUser.status.suspension,
now
);
if (!suspended.active) {
// The user is not suspended currently, just return the user because we
// don't have to do anything.
return targetUser;
}
// For each of the suspensions, remove it.
return removeActiveUserSuspensions(mongo, tenant.id, userID, user.id, now);
}
export async function removeBan(
mongo: Db,
tenant: Tenant,
user: User,
userID: string,
now = new Date()
) {
// Get the user being un-banned to check if they are even banned.
const targetUser = await retrieveUser(mongo, tenant.id, userID);
if (!targetUser) {
throw new UserNotFoundError(userID);
}
// Check to see if the User is currently banned.
const banStatus = consolidateUserBanStatus(targetUser.status.ban);
if (!banStatus.active) {
// The user is not ban currently, just return the user because we don't
// have to do anything.
return targetUser;
}
return removeUserBan(mongo, tenant.id, userID, user.id, now);
}
+2
View File
@@ -5,6 +5,8 @@ import { User } from "talk-server/models/user";
import TenantCache from "talk-server/services/tenant/cache";
export interface TalkRequest {
id: string;
now: Date;
cache?: {
tenant: TenantCache;
};