mirror of
https://github.com/wassname/talk.git
synced 2026-09-10 12:43:11 +08:00
[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:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
),
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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}.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -81,6 +81,7 @@ export type Settings = GlobalModerationSettings &
|
||||
| "editCommentWindowLength"
|
||||
| "customCSSURL"
|
||||
| "communityGuidelines"
|
||||
| "createdAt"
|
||||
> & {
|
||||
/**
|
||||
* auth is the set of configured authentication integrations.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user