[CORL 133] API Review (#2197)

* refactor: removed unused subscription code

* refactor: removed management api's

* refactor: cleanup of connections

* refactor: refactored comments edge

* refactor: simplified connection resolving

* feat: added story connection edge

* fix: added story index

* feat: added user pagination and user edge

* fix: added filter to comment query

* fix: removed unused resolvers

* fix: creating a comment reply should require auth

* refactor: cleanup of graph files

* feat: removed display name, made username non-unique

* fix: fixed tests

* fix: fixed tests

* fix: added more api docs

* fix: fixed bug with installer

* refactor: fixes and updates

* fix: added linting for graphql, fixed schema

* feat: added docker build tests

* fix: upped output timeout

* fix: fixed stacktraces in production builds

* fix: removed `git add`

- `git add` was causing issues with
    partial staged changs on files

* feat: improved error messaging for auth

* refactor: cleaned up queue names

* fix: merge error
This commit is contained in:
Wyatt Johnson
2019-03-12 15:12:21 +01:00
committed by Kiwi
parent 37959f9398
commit d37333be89
125 changed files with 1269 additions and 1536 deletions
@@ -1,8 +1,7 @@
import { RequestHandler } from "express";
import { Redis } from "ioredis";
import Joi from "joi";
import { Db } from "mongodb";
import { AppOptions } from "talk-server/app";
import {
handleLogout,
handleSuccessfulLogin,
@@ -10,7 +9,6 @@ import {
import { validate } from "talk-server/app/request/body";
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
import { LocalProfile } from "talk-server/models/user";
import { JWTSigningConfig } from "talk-server/services/jwt";
import { upsert } from "talk-server/services/users";
import { Request } from "talk-server/types/express";
@@ -29,16 +27,12 @@ export const SignupBodySchema = Joi.object().keys({
.email(),
});
export interface SignupOptions {
db: Db;
signingConfig: JWTSigningConfig;
}
export type SignupOptions = Pick<AppOptions, "mongo" | "signingConfig">;
export const signupHandler = (options: SignupOptions): RequestHandler => async (
req: Request,
res,
next
) => {
export const signupHandler = ({
mongo,
signingConfig,
}: SignupOptions): RequestHandler => async (req: Request, res, next) => {
try {
// TODO: rate limit based on the IP address and user agent.
@@ -71,7 +65,7 @@ export const signupHandler = (options: SignupOptions): RequestHandler => async (
};
// Create the new user.
const user = await upsert(options.db, tenant, {
const user = await upsert(mongo, tenant, {
email,
username,
profiles: [profile],
@@ -81,21 +75,17 @@ export const signupHandler = (options: SignupOptions): RequestHandler => async (
});
// Send off to the passport handler.
return handleSuccessfulLogin(user, options.signingConfig, req, res, next);
return handleSuccessfulLogin(user, signingConfig, req, res, next);
} catch (err) {
return next(err);
}
};
export interface LogoutOptions {
redis: Redis;
}
export type LogoutOptions = Pick<AppOptions, "redis">;
export const logoutHandler = (options: LogoutOptions): RequestHandler => async (
req: Request,
res,
next
) => {
export const logoutHandler = ({
redis,
}: LogoutOptions): RequestHandler => async (req: Request, res, next) => {
try {
// TODO: rate limit based on the IP address and user agent.
@@ -117,7 +107,7 @@ export const logoutHandler = (options: LogoutOptions): RequestHandler => async (
}
// Delegate to the logout handler.
return handleLogout(options.redis, req, res);
return handleLogout(redis, req, res);
} catch (err) {
return next(err);
}
@@ -0,0 +1,54 @@
import { AppOptions } from "talk-server/app";
import {
graphqlBatchMiddleware,
graphqlMiddleware,
} from "talk-server/app/middleware/graphql";
import TenantContext from "talk-server/graph/tenant/context";
import { Request, RequestHandler } from "talk-server/types/express";
export type GraphMiddlewareOptions = Pick<
AppOptions,
| "schema"
| "config"
| "mongo"
| "redis"
| "mailerQueue"
| "scraperQueue"
| "signingConfig"
| "i18n"
>;
export const graphQLHandler = ({
schema,
config,
...options
}: GraphMiddlewareOptions): RequestHandler =>
graphqlBatchMiddleware(
graphqlMiddleware(config, async (req: Request) => {
if (!req.talk) {
throw new Error("talk was not set");
}
const { tenant, cache } = req.talk;
if (!cache) {
throw new Error("cache was not set");
}
if (!tenant) {
throw new Error("tenant was not set");
}
return {
schema,
context: new TenantContext({
...options,
req,
config,
tenant,
user: req.user,
tenantCache: cache.tenant,
}),
};
})
);
@@ -1,4 +1,3 @@
import { RequestHandler } from "express";
import { Redis } from "ioredis";
import Joi from "joi";
import { Db } from "mongodb";
@@ -7,12 +6,12 @@ import { LanguageCode, LOCALES } from "talk-common/helpers/i18n/locales";
import { Omit } from "talk-common/types";
import { validate } from "talk-server/app/request/body";
import { Config } from "talk-server/config";
import { TenantInstalledAlreadyError } from "talk-server/errors";
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
import { LocalProfile } from "talk-server/models/user";
import { install, InstallTenant } from "talk-server/services/tenant";
import TenantCache from "talk-server/services/tenant/cache";
import { upsert, UpsertUser } from "talk-server/services/users";
import { Request } from "talk-server/types/express";
import { RequestHandler } from "talk-server/types/express";
export interface TenantInstallBody {
tenant: Omit<InstallTenant, "domain" | "locale"> & {
@@ -53,23 +52,30 @@ const TenantInstallBodySchema = Joi.object().keys({
});
export interface TenantInstallHandlerOptions {
cache: TenantCache;
redis: Redis;
mongo: Db;
config: Config;
}
export const tenantInstallHandler = ({
export const installHandler = ({
mongo,
redis,
cache,
config,
}: TenantInstallHandlerOptions): RequestHandler => async (
req: Request,
res,
next
) => {
}: TenantInstallHandlerOptions): RequestHandler => async (req, res, next) => {
try {
if (!req.talk) {
return next(new Error("talk was not set"));
}
if (!req.talk.cache) {
return next(new Error("cache was not set"));
}
if (req.talk.tenant) {
// There's already a Tenant on the request! No need to process further.
return next(new TenantInstalledAlreadyError());
}
// Validate that the payload passed in was correct, it will throw if the
// payload is invalid.
const {
@@ -85,7 +91,7 @@ export const tenantInstallHandler = ({
// Install will throw if it can not create a Tenant, or it has already been
// installed.
const tenant = await install(mongo, redis, cache, {
const tenant = await install(mongo, redis, req.talk.cache.tenant, {
...tenantInput,
// Infer the Tenant domain via the hostname parameter.
domain: req.hostname,
+9 -37
View File
@@ -1,6 +1,7 @@
import cons from "consolidate";
import cors from "cors";
import { Express } from "express";
import { GraphQLSchema } from "graphql";
import http from "http";
import { Db } from "mongodb";
import nunjucks from "nunjucks";
@@ -11,9 +12,8 @@ import { HTMLErrorHandler } from "talk-server/app/middleware/error";
import { notFoundMiddleware } from "talk-server/app/middleware/notFound";
import { createPassport } from "talk-server/app/middleware/passport";
import { Config } from "talk-server/config";
import { handleSubscriptions } from "talk-server/graph/common/subscriptions/middleware";
import { Schemas } from "talk-server/graph/schemas";
import { TaskQueue } from "talk-server/queue";
import { MailerQueue } from "talk-server/queue/tasks/mailer";
import { ScraperQueue } from "talk-server/queue/tasks/scraper";
import { I18n } from "talk-server/services/i18n";
import { JWTSigningConfig } from "talk-server/services/jwt";
import { AugmentedRedis } from "talk-server/services/redis";
@@ -24,15 +24,16 @@ import serveStatic from "./middleware/serveStatic";
import { createRouter } from "./router";
export interface AppOptions {
parent: Express;
queue: TaskQueue;
config: Config;
i18n: I18n;
mailerQueue: MailerQueue;
scraperQueue: ScraperQueue;
mongo: Db;
parent: Express;
redis: AugmentedRedis;
schemas: Schemas;
schema: GraphQLSchema;
signingConfig: JWTSigningConfig;
tenantCache: TenantCache;
i18n: I18n;
}
/**
@@ -52,12 +53,7 @@ export async function createApp(options: AppOptions): Promise<Express> {
const passport = createPassport(options);
// Mount the router.
parent.use(
"/",
await createRouter(options, {
passport,
})
);
parent.use("/", createRouter(options, { passport }));
// Enable CORS headers for media assets, font's require them.
parent.use("/assets/media", cors());
@@ -120,27 +116,3 @@ function setupViews(options: AppOptions) {
// set .html as the default extension.
parent.set("view engine", "html");
}
/**
* attachSubscriptionHandlers attaches all the handlers to the http.Server to
* handle websocket traffic by upgrading their http connections to websocket.
*
* @param schemas schemas for every schema this application handles
* @param server the http.Server to attach the websocket upgrader to
*/
export async function attachSubscriptionHandlers(
schemas: Schemas,
server: http.Server
) {
// Setup the Management Subscription endpoint.
handleSubscriptions(server, {
schema: schemas.management,
path: "/api/management/live",
});
// Setup the Tenant Subscription endpoint.
handleSubscriptions(server, {
schema: schemas.tenant,
path: "/api/tenant/live",
});
}
@@ -1,63 +0,0 @@
import { RequestHandler } from "express-jwt";
import { Db } from "mongodb";
import { Config } from "talk-server/config";
import TenantContext from "talk-server/graph/tenant/context";
import { TaskQueue } from "talk-server/queue";
import { I18n } from "talk-server/services/i18n";
import { JWTSigningConfig } from "talk-server/services/jwt";
import { AugmentedRedis } from "talk-server/services/redis";
import { Request } from "talk-server/types/express";
export interface TenantContextMiddlewareOptions {
mongo: Db;
redis: AugmentedRedis;
queue: TaskQueue;
config: Config;
signingConfig: JWTSigningConfig;
i18n: I18n;
}
export const tenantContext = ({
mongo,
redis,
queue,
config,
signingConfig,
i18n,
}: TenantContextMiddlewareOptions): RequestHandler => (
req: Request,
res,
next
) => {
if (!req.talk) {
return next(new Error("talk was not set"));
}
const { tenant, cache } = req.talk;
if (!cache) {
return next(new Error("cache was not set"));
}
if (!tenant) {
return next(new Error("tenant was not set"));
}
req.talk.context = {
tenant: new TenantContext({
req,
config,
mongo,
redis,
tenant,
user: req.user,
tenantCache: cache.tenant,
queue,
signingConfig,
i18n,
}),
};
next();
};
@@ -10,9 +10,12 @@ import {
import { Omit } from "talk-common/types";
import { Config } from "talk-server/config";
import {
ErrorWrappingExtension,
LoggerExtension,
} from "talk-server/graph/common/extensions";
import { ErrorWrappingExtension } from "./extensions/ErrorWrappingExtension";
import { LoggerExtension } from "./extensions/LoggerExtension";
export * from "./batch";
// Sourced from: https://github.com/apollographql/apollo-server/blob/958846887598491fadea57b3f9373d129300f250/packages/apollo-server-core/src/ApolloServer.ts#L46-L57
const NoIntrospection = (context: ValidationContext) => ({
@@ -28,6 +31,13 @@ const NoIntrospection = (context: ValidationContext) => ({
},
});
/**
* graphqlMiddleware wraps the GraphQL middleware server with some custom
* extension management.
*
* @param config application configuration
* @param requestOptions options to pass to the graphql server
*/
export const graphqlMiddleware = (
config: Config,
requestOptions: ExpressGraphQLOptionsFunction
+24 -9
View File
@@ -1,20 +1,35 @@
import { RequestHandler } from "express";
import { isInstalled } from "talk-server/services/tenant";
import TenantCache from "talk-server/services/tenant/cache";
import { RequestHandler } from "talk-server/types/express";
export interface InstalledMiddlewareOptions {
tenantCache: TenantCache;
redirectURL?: string;
redirectIfInstalled?: boolean;
}
const DefaultInstalledMiddlewareOptions: Required<
InstalledMiddlewareOptions
> = {
redirectIfInstalled: false,
redirectURL: "/install",
};
export const installedMiddleware = ({
tenantCache,
redirectIfInstalled = false,
redirectURL = "/install",
}: InstalledMiddlewareOptions): RequestHandler => async (req, res, next) => {
const installed = await isInstalled(tenantCache);
redirectIfInstalled = DefaultInstalledMiddlewareOptions.redirectIfInstalled,
redirectURL = DefaultInstalledMiddlewareOptions.redirectURL,
}: InstalledMiddlewareOptions = DefaultInstalledMiddlewareOptions): RequestHandler => async (
req,
res,
next
) => {
if (!req.talk) {
return next(new Error("talk was not set"));
}
if (!req.talk.cache) {
return next(new Error("cache was not set"));
}
const installed = await isInstalled(req.talk.cache.tenant);
// If Talk is installed, and redirectIfInstall is true, then it will redirect.
// If Talk is not installed, and redirectIfInstall is false, then it will also
@@ -2,17 +2,16 @@ import { NextFunction, RequestHandler, Response } from "express";
import { Redis } from "ioredis";
import Joi from "joi";
import jwt from "jsonwebtoken";
import { Db } from "mongodb";
import passport, { Authenticator } from "passport";
import now from "performance-now";
import { AppOptions } from "talk-server/app";
import FacebookStrategy from "talk-server/app/middleware/passport/strategies/facebook";
import GoogleStrategy from "talk-server/app/middleware/passport/strategies/google";
import { JWTStrategy } from "talk-server/app/middleware/passport/strategies/jwt";
import { createLocalStrategy } from "talk-server/app/middleware/passport/strategies/local";
import OIDCStrategy from "talk-server/app/middleware/passport/strategies/oidc";
import { validate } from "talk-server/app/request/body";
import { Config } from "talk-server/config";
import logger from "talk-server/logger";
import { User } from "talk-server/models/user";
import {
@@ -22,7 +21,6 @@ import {
SigningTokenOptions,
signTokenString,
} from "talk-server/services/jwt";
import TenantCache from "talk-server/services/tenant/cache";
import { Request } from "talk-server/types/express";
export type VerifyCallback = (
@@ -31,13 +29,10 @@ export type VerifyCallback = (
info?: { message: string }
) => void;
export interface PassportOptions {
config: Config;
mongo: Db;
redis: Redis;
signingConfig: JWTSigningConfig;
tenantCache: TenantCache;
}
export type PassportOptions = Pick<
AppOptions,
"mongo" | "redis" | "config" | "tenantCache" | "signingConfig"
>;
export function createPassport(
options: PassportOptions
@@ -142,8 +137,7 @@ export async function handleOAuth2Callback(
user: User | null,
signingConfig: JWTSigningConfig,
req: Request,
res: Response,
next: NextFunction
res: Response
) {
const path = "/embed/auth/callback";
if (!user) {
@@ -195,7 +189,7 @@ export const wrapOAuth2Authn = (
name,
{ ...options, session: false },
(err: Error | null, user: User | null) => {
handleOAuth2Callback(err, user, signingConfig, req, res, next);
handleOAuth2Callback(err, user, signingConfig, req, res);
}
)(req, res, next);
@@ -1,9 +1,9 @@
import { Db } from "mongodb";
import { Profile, Strategy } from "passport-facebook";
import OAuth2Strategy from "talk-server/app/middleware/passport/strategies/oauth2";
import OAuth2Strategy, {
OAuth2StrategyOptions,
} from "talk-server/app/middleware/passport/strategies/oauth2";
import { constructTenantURL } from "talk-server/app/url";
import { Config } from "talk-server/config";
import {
GQLAuthIntegrations,
GQLFacebookAuthIntegration,
@@ -14,14 +14,9 @@ import {
FacebookProfile,
retrieveUserWithProfile,
} from "talk-server/models/user";
import TenantCache from "talk-server/services/tenant/cache";
import { upsert } from "talk-server/services/users";
export interface FacebookStrategyOptions {
config: Config;
mongo: Db;
tenantCache: TenantCache;
}
export type FacebookStrategyOptions = OAuth2StrategyOptions;
export default class FacebookStrategy extends OAuth2Strategy<
GQLFacebookAuthIntegration,
@@ -77,7 +72,7 @@ export default class FacebookStrategy extends OAuth2Strategy<
}
user = await upsert(this.mongo, tenant, {
displayName,
username: displayName,
role: GQLUSER_ROLE.COMMENTER,
email,
emailVerified,
@@ -102,7 +97,7 @@ export default class FacebookStrategy extends OAuth2Strategy<
callbackURL: constructTenantURL(
this.config,
tenant,
"/api/tenant/auth/facebook/callback"
"/api/auth/facebook/callback"
),
profileFields: ["id", "displayName", "photos", "email"],
enableProof: true,
@@ -1,9 +1,9 @@
import { Db } from "mongodb";
import { Profile, Strategy } from "passport-google-oauth2";
import OAuth2Strategy from "talk-server/app/middleware/passport/strategies/oauth2";
import OAuth2Strategy, {
OAuth2StrategyOptions,
} from "talk-server/app/middleware/passport/strategies/oauth2";
import { constructTenantURL } from "talk-server/app/url";
import { Config } from "talk-server/config";
import {
GQLAuthIntegrations,
GQLGoogleAuthIntegration,
@@ -14,20 +14,9 @@ import {
GoogleProfile,
retrieveUserWithProfile,
} from "talk-server/models/user";
import TenantCache from "talk-server/services/tenant/cache";
import { upsert } from "talk-server/services/users";
export interface GoogleStrategyOptions {
config: Config;
mongo: Db;
tenantCache: TenantCache;
}
export interface GoogleStrategyOptions {
config: Config;
mongo: Db;
tenantCache: TenantCache;
}
export type GoogleStrategyOptions = OAuth2StrategyOptions;
export default class GoogleStrategy extends OAuth2Strategy<
GQLGoogleAuthIntegration,
@@ -82,7 +71,7 @@ export default class GoogleStrategy extends OAuth2Strategy<
}
user = await upsert(this.mongo, tenant, {
displayName,
username: displayName,
role: GQLUSER_ROLE.COMMENTER,
email,
emailVerified,
@@ -107,7 +96,7 @@ export default class GoogleStrategy extends OAuth2Strategy<
callbackURL: constructTenantURL(
this.config,
tenant,
"/api/tenant/auth/google/callback"
"/api/auth/google/callback"
),
passReqToCallback: true,
},
@@ -1,8 +1,7 @@
import { Redis } from "ioredis";
import jwt from "jsonwebtoken";
import { Db } from "mongodb";
import { Strategy } from "passport-strategy";
import { AppOptions } from "talk-server/app";
import {
JWTToken,
JWTVerifier,
@@ -14,17 +13,13 @@ import {
import { TenantNotFoundError, TokenInvalidError } from "talk-server/errors";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
import {
extractJWTFromRequest,
JWTSigningConfig,
} from "talk-server/services/jwt";
import { extractJWTFromRequest } from "talk-server/services/jwt";
import { Request } from "talk-server/types/express";
export interface JWTStrategyOptions {
signingConfig: JWTSigningConfig;
mongo: Db;
redis: Redis;
}
export type JWTStrategyOptions = Pick<
AppOptions,
"signingConfig" | "mongo" | "redis"
>;
/**
* Token is the various forms of the Token that can be verified.
@@ -37,6 +37,7 @@ export interface OIDCIDToken {
picture?: string;
name?: string;
nickname?: string;
preferred_username?: string;
}
export interface StrategyItem {
@@ -121,11 +122,18 @@ export const OIDCIDTokenSchema = Joi.object()
picture: Joi.string().default(undefined),
name: Joi.string().default(undefined),
nickname: Joi.string().default(undefined),
preferred_username: Joi.string().default(undefined),
})
.optionalKeys(["picture", "email_verified", "name", "nickname"]);
.optionalKeys([
"picture",
"email_verified",
"name",
"nickname",
"preferred_username",
]);
export async function findOrCreateOIDCUser(
db: Db,
mongo: Db,
tenant: Tenant,
integration: GQLOIDCAuthIntegration,
token: OIDCIDToken
@@ -140,6 +148,7 @@ export async function findOrCreateOIDCUser(
picture,
name,
nickname,
preferred_username,
}: OIDCIDToken = validate(OIDCIDTokenSchema, token);
// Construct the profile that will be used to query for the user.
@@ -151,7 +160,7 @@ export async function findOrCreateOIDCUser(
};
// Try to lookup user given their id provided in the `sub` claim.
let user = await retrieveUserWithProfile(db, tenant.id, {
let user = await retrieveUserWithProfile(mongo, tenant.id, {
// NOTE: (wyattjoh) as the current requirements do not allow multiple OIDC integrations, we are only getting the profile based on the OIDC provider.
type: "oidc",
id: sub,
@@ -164,11 +173,12 @@ export async function findOrCreateOIDCUser(
// FIXME: implement rules.
const displayName = nickname || name || undefined;
// Try to extract the username from the following chain:
const username = preferred_username || nickname || name;
// Create the new user, as one didn't exist before!
user = await upsert(db, tenant, {
displayName,
user = await upsert(mongo, tenant, {
username,
role: GQLUSER_ROLE.COMMENTER,
email,
emailVerified: email_verified,
@@ -310,7 +320,7 @@ export default class OIDCStrategy extends Strategy {
const { clientID, clientSecret, authorizationURL, tokenURL } = integration;
// Construct the callbackURL from the request.
const callbackURL = reconstructURL(req, `/api/tenant/auth/oidc/callback`);
const callbackURL = reconstructURL(req, `/api/auth/oidc/callback`);
// Create a new OAuth2Strategy, where we pass the verify callback bound to
// this OIDCStrategy instance.
@@ -18,9 +18,8 @@ export interface SSOStrategyOptions {
export interface SSOUserProfile {
id: string;
email: string;
username?: string;
username: string;
avatar?: string;
displayName?: string;
}
export interface SSOToken {
@@ -38,7 +37,7 @@ export const SSOUserProfileSchema = Joi.object()
.optionalKeys(["avatar", "displayName"]);
export async function findOrCreateSSOUser(
db: Db,
mongo: Db,
tenant: Tenant,
integration: GQLSSOAuthIntegration,
token: SSOToken
@@ -49,7 +48,7 @@ export async function findOrCreateSSOUser(
}
// Unpack/validate the token content.
const { id, email, username, displayName, avatar }: SSOUserProfile = validate(
const { id, email, username, avatar }: SSOUserProfile = validate(
SSOUserProfileSchema,
token.user
);
@@ -60,7 +59,7 @@ export async function findOrCreateSSOUser(
};
// Try to lookup user given their id provided in the `sub` claim.
let user = await retrieveUserWithProfile(db, tenant.id, profile);
let user = await retrieveUserWithProfile(mongo, tenant.id, profile);
if (!user) {
if (!integration.allowRegistration) {
// Registration is disabled, so we can't create the user user here.
@@ -70,11 +69,8 @@ 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 upsert(db, tenant, {
user = await upsert(mongo, tenant, {
username,
// When the displayName is disabled on the tenant, the displayName will
// never be set (or even stored in the database).
displayName,
role: GQLUSER_ROLE.COMMENTER,
email,
avatar,
+9 -4
View File
@@ -13,12 +13,17 @@ export const tenantMiddleware = ({
}: MiddlewareOptions): RequestHandler => async (req, res, next) => {
try {
// Set Talk on the request.
req.talk = {
cache: {
if (!req.talk) {
req.talk = {};
}
// Set the Talk Tenant Cache on the request.
if (!req.talk.cache) {
req.talk.cache = {
// Attach the tenant cache to the request.
tenant: cache,
},
};
};
}
// Attach the tenant to the request.
const tenant = await cache.retrieveByDomain(req.hostname);
+3 -11
View File
@@ -4,7 +4,7 @@ import { AppOptions } from "talk-server/app";
import {
logoutHandler,
signupHandler,
} from "talk-server/app/handlers/api/tenant/auth/local";
} from "talk-server/app/handlers/api/auth/local";
import { noCacheMiddleware } from "talk-server/app/middleware/cacheHeaders";
import {
wrapAuthn,
@@ -33,11 +33,7 @@ export function createNewAuthRouter(app: AppOptions, options: RouterOptions) {
const router = express.Router();
// Mount the logout handler.
router.delete(
"/",
options.passport.authenticate("jwt", { session: false }),
logoutHandler({ redis: app.redis })
);
router.delete("/", logoutHandler(app));
// Mount the Local Authentication handlers.
router.post(
@@ -45,11 +41,7 @@ export function createNewAuthRouter(app: AppOptions, options: RouterOptions) {
express.json(),
wrapAuthn(options.passport, app.signingConfig, "local")
);
router.post(
"/local/signup",
express.json(),
signupHandler({ db: app.mongo, signingConfig: app.signingConfig })
);
router.post("/local/signup", express.json(), signupHandler(app));
// Mount the external auth integrations with middleware/handle wrappers.
wrapPath(app, options, router, "facebook");
+31 -9
View File
@@ -2,13 +2,16 @@ import express from "express";
import passport from "passport";
import { AppOptions } from "talk-server/app";
import { graphQLHandler } from "talk-server/app/handlers/api/graphql";
import { installHandler } from "talk-server/app/handlers/api/install";
import { versionHandler } from "talk-server/app/handlers/api/version";
import { JSONErrorHandler } from "talk-server/app/middleware/error";
import { errorLogger } from "talk-server/app/middleware/logging";
import { notFoundMiddleware } from "talk-server/app/middleware/notFound";
import { authenticate } from "talk-server/app/middleware/passport";
import { tenantMiddleware } from "talk-server/app/middleware/tenant";
import { createManagementRouter } from "./management";
import { createTenantRouter } from "./tenant";
import { createNewAuthRouter } from "./auth";
export interface RouterOptions {
/**
@@ -18,19 +21,38 @@ export interface RouterOptions {
passport: passport.Authenticator;
}
export async function createAPIRouter(app: AppOptions, options: RouterOptions) {
export function createAPIRouter(app: AppOptions, options: RouterOptions) {
// Create a router.
const router = express.Router();
// Configure the tenant routes.
router.use("/tenant", await createTenantRouter(app, options));
// Configure the management routes.
router.use("/management", await createManagementRouter(app));
// Configure the version route.
router.get("/version", versionHandler);
// Installation middleware.
router.use(
"/install",
express.json(),
tenantMiddleware({ cache: app.tenantCache, passNoTenant: true }),
installHandler(app)
);
// Tenant identification middleware. All requests going past this point can
// only proceed if there is a valid Tenant for the hostname.
router.use(tenantMiddleware({ cache: app.tenantCache }));
// Setup Passport middleware.
router.use(options.passport.initialize());
// Authenticate all requests made to this route. This will allow requests
// that are not authenticated pass through.
router.use(authenticate(options.passport));
// Setup auth routes.
router.use("/auth", createNewAuthRouter(app, options));
// Configure the GraphQL route.
router.use("/graphql", express.json(), graphQLHandler(app));
// General API error handler.
router.use(notFoundMiddleware);
router.use(errorLogger);
@@ -1,22 +0,0 @@
import express from "express";
import { AppOptions } from "talk-server/app";
import managementGraphMiddleware from "talk-server/graph/management/middleware";
export async function createManagementRouter(app: AppOptions) {
const router = express.Router();
// Management API
router.use(
"/graphql",
express.json(),
await managementGraphMiddleware({
schema: app.schemas.management,
config: app.config,
mongo: app.mongo,
i18n: app.i18n,
})
);
return router;
}
-55
View File
@@ -1,55 +0,0 @@
import express from "express";
import { AppOptions } from "talk-server/app";
import { tenantInstallHandler } from "talk-server/app/handlers/api/tenant/install";
import { tenantMiddleware } from "talk-server/app/middleware/tenant";
import { RouterOptions } from "talk-server/app/router/types";
import tenantGraphMiddleware from "talk-server/graph/tenant/middleware";
import { tenantContext } from "talk-server/app/middleware/context/tenant";
import { authenticate } from "talk-server/app/middleware/passport";
import { createNewAuthRouter } from "./auth";
export async function createTenantRouter(
app: AppOptions,
options: RouterOptions
) {
const router = express.Router();
// Tenant setup handler.
router.use(
"/install",
express.json(),
tenantInstallHandler({
config: app.config,
cache: app.tenantCache,
redis: app.redis,
mongo: app.mongo,
})
);
// Tenant identification middleware.
router.use(tenantMiddleware({ cache: app.tenantCache }));
// Setup Passport middleware.
router.use(options.passport.initialize());
// Setup auth routes.
router.use("/auth", createNewAuthRouter(app, options));
// Tenant API
router.use(
"/graphql",
express.json(),
// Any users may submit their GraphQL requests with authentication, this
// middleware will unpack their user into the request.
authenticate(options.passport),
tenantContext(app),
await tenantGraphMiddleware({
schema: app.schemas.tenant,
config: app.config,
})
);
return router;
}
+23 -32
View File
@@ -3,33 +3,30 @@ import path from "path";
import { AppOptions } from "talk-server/app";
import { noCacheMiddleware } from "talk-server/app/middleware/cacheHeaders";
import { cspTenantMiddleware } from "talk-server/app/middleware/csp/tenant";
import { installedMiddleware } from "talk-server/app/middleware/installed";
import playground from "talk-server/app/middleware/playground";
import { tenantMiddleware } from "talk-server/app/middleware/tenant";
import { RouterOptions } from "talk-server/app/router/types";
import logger from "talk-server/logger";
import { cspTenantMiddleware } from "talk-server/app/middleware/csp/tenant";
import { tenantMiddleware } from "talk-server/app/middleware/tenant";
import Entrypoints from "../helpers/entrypoints";
import { createAPIRouter } from "./api";
import { createClientTargetRouter } from "./client";
export async function createRouter(app: AppOptions, options: RouterOptions) {
export function createRouter(app: AppOptions, options: RouterOptions) {
// Create a router.
const router = express.Router();
router.use("/api", noCacheMiddleware, await createAPIRouter(app, options));
// Attach the API router.
router.use("/api", noCacheMiddleware, createAPIRouter(app, options));
// Attach the GraphiQL if enabled.
if (app.config.get("enable_graphiql")) {
attachGraphiQL(router, app);
}
router.use(tenantMiddleware({ cache: app.tenantCache, passNoTenant: true }));
router.use(cspTenantMiddleware);
const staticURI = app.config.get("static_uri");
// TODO: (wyattjoh) figure out a better way of referencing paths.
// Load the entrypoint manifest.
const manifest = path.join(
__dirname,
@@ -43,8 +40,20 @@ export async function createRouter(app: AppOptions, options: RouterOptions) {
"asset-manifest.json"
);
const entrypoints = Entrypoints.fromFile(manifest);
if (entrypoints) {
// Tenant identification middleware.
router.use(
tenantMiddleware({
cache: app.tenantCache,
passNoTenant: true,
})
);
// Add CSP headers to the request, which only apply when serving HTML content.
router.use(cspTenantMiddleware);
const staticURI = app.config.get("static_uri");
// Add the embed targets.
router.use(
"/embed/stream",
@@ -75,9 +84,7 @@ export async function createRouter(app: AppOptions, options: RouterOptions) {
router.use(
"/admin",
// If we aren't already installed, redirect the user to the install page.
installedMiddleware({
tenantCache: app.tenantCache,
}),
installedMiddleware(),
createClientTargetRouter({
staticURI,
cacheDuration: false,
@@ -90,7 +97,6 @@ export async function createRouter(app: AppOptions, options: RouterOptions) {
installedMiddleware({
redirectIfInstalled: true,
redirectURL: "/admin",
tenantCache: app.tenantCache,
}),
createClientTargetRouter({
staticURI,
@@ -104,7 +110,7 @@ export async function createRouter(app: AppOptions, options: RouterOptions) {
"/",
// Redirect the user to the install page if they are not, otherwise redirect
// them to the admin.
installedMiddleware({ tenantCache: app.tenantCache }),
installedMiddleware(),
(req, res, next) => res.redirect("/admin")
);
} else {
@@ -130,21 +136,6 @@ function attachGraphiQL(router: Router, app: AppOptions) {
);
}
// Tenant GraphiQL
router.get(
"/tenant/graphiql",
playground({
endpoint: "/api/tenant/graphql",
subscriptionEndpoint: "/api/tenant/live",
})
);
// Management GraphiQL
router.get(
"/management/graphiql",
playground({
endpoint: "/api/management/graphql",
subscriptionEndpoint: "/api/management/live",
})
);
// GraphiQL
router.get("/graphiql", playground({ endpoint: "/api/graphql" }));
}
+25 -18
View File
@@ -250,15 +250,6 @@ export class DuplicateStoryURLError extends TalkError {
}
}
export class DuplicateUsernameError extends TalkError {
constructor(username: string) {
super({
code: ERROR_CODES.DUPLICATE_USERNAME,
context: { pvt: { username } },
});
}
}
export class DuplicateEmailError extends TalkError {
constructor(email: string) {
super({ code: ERROR_CODES.DUPLICATE_EMAIL, context: { pvt: { email } } });
@@ -313,15 +304,6 @@ export class UsernameTooShortError extends TalkError {
}
}
export class DisplayNameExceedsMaxLengthError extends TalkError {
constructor(length: number, max: number) {
super({
code: ERROR_CODES.DISPLAY_NAME_EXCEEDS_MAX_LENGTH,
context: { pub: { length, max } },
});
}
}
export class PasswordTooShortError extends TalkError {
constructor(length: number, min: number) {
super({
@@ -378,6 +360,21 @@ export class UserNotFoundError extends TalkError {
}
}
export class StoryNotFoundError extends TalkError {
constructor(storyID: string) {
super({ code: ERROR_CODES.STORY_NOT_FOUND, context: { pvt: { storyID } } });
}
}
export class CommentNotFoundError extends TalkError {
constructor(commentID: string) {
super({
code: ERROR_CODES.COMMENT_NOT_FOUND,
context: { pvt: { commentID } },
});
}
}
export class TenantNotFoundError extends TalkError {
constructor(hostname: string) {
super({
@@ -413,3 +410,13 @@ export class TenantInstalledAlreadyError extends TalkError {
super({ code: ERROR_CODES.TENANT_INSTALLED_ALREADY, status: 400 });
}
}
export class AuthenticationError extends TalkError {
constructor(reason: string) {
super({
code: ERROR_CODES.AUTHENTICATION_ERROR,
status: 401,
context: { pvt: { reason } },
});
}
}
+22 -21
View File
@@ -1,34 +1,35 @@
import { ERROR_CODES } from "talk-common/errors";
export const ERROR_TRANSLATIONS: Record<ERROR_CODES, string> = {
COMMENTING_DISABLED: "error-commentingDisabled",
STORY_CLOSED: "error-storyClosed",
COMMENT_BODY_TOO_SHORT: "error-commentBodyTooShort",
COMMENT_BODY_EXCEEDS_MAX_LENGTH: "error-commentBodyExceedsMaxLength",
STORY_URL_NOT_PERMITTED: "error-storyURLNotPermitted",
TOKEN_NOT_FOUND: "error-tokenNotFound",
DUPLICATE_STORY_URL: "error-duplicateStoryURL",
EMAIL_ALREADY_SET: "error-emailAlreadySet",
EMAIL_NOT_SET: "error-emailNotSet",
TENANT_NOT_FOUND: "error-tenantNotFound",
DUPLICATE_USER: "error-duplicateUser",
DUPLICATE_USERNAME: "error-duplicateUsername",
COMMENT_BODY_TOO_SHORT: "error-commentBodyTooShort",
COMMENT_NOT_FOUND: "error-commentNotFound",
COMMENTING_DISABLED: "error-commentingDisabled",
DUPLICATE_EMAIL: "error-duplicateEmail",
DUPLICATE_STORY_URL: "error-duplicateStoryURL",
DUPLICATE_USER: "error-duplicateUser",
EMAIL_ALREADY_SET: "error-emailAlreadySet",
EMAIL_EXCEEDS_MAX_LENGTH: "error-emailExceedsMaxLength",
EMAIL_INVALID_FORMAT: "error-emailInvalidFormat",
EMAIL_NOT_SET: "error-emailNotSet",
INTERNAL_ERROR: "error-internalError",
LOCAL_PROFILE_ALREADY_SET: "error-localProfileAlreadySet",
LOCAL_PROFILE_NOT_SET: "error-localProfileNotSet",
NOT_FOUND: "error-notFound",
PASSWORD_TOO_SHORT: "error-passwordTooShort",
STORY_CLOSED: "error-storyClosed",
STORY_NOT_FOUND: "error-storyNotFound",
STORY_URL_NOT_PERMITTED: "error-storyURLNotPermitted",
TENANT_INSTALLED_ALREADY: "error-tenantInstalledAlready",
TENANT_NOT_FOUND: "error-tenantNotFound",
TOKEN_INVALID: "error-tokenInvalid",
TOKEN_NOT_FOUND: "error-tokenNotFound",
USER_NOT_ENTITLED: "error-userNotEntitled",
USER_NOT_FOUND: "error-userNotFound",
USERNAME_ALREADY_SET: "error-usernameAlreadySet",
USERNAME_CONTAINS_INVALID_CHARACTERS:
"error-usernameContainsInvalidCharacters",
USERNAME_EXCEEDS_MAX_LENGTH: "error-usernameExceedsMaxLength",
USERNAME_TOO_SHORT: "error-usernameTooShort",
PASSWORD_TOO_SHORT: "error-passwordTooShort",
DISPLAY_NAME_EXCEEDS_MAX_LENGTH: "error-displayNameExceedsMaxLength",
EMAIL_INVALID_FORMAT: "error-emailInvalidFormat",
EMAIL_EXCEEDS_MAX_LENGTH: "error-emailExceedsMaxLength",
USER_NOT_FOUND: "error-userNotFound",
NOT_FOUND: "error-notFound",
INTERNAL_ERROR: "error-internalError",
TOKEN_INVALID: "error-tokenInvalid",
TENANT_INSTALLED_ALREADY: "error-tenantInstalledAlready",
USER_NOT_ENTITLED: "error-userNotEntitled",
AUTHENTICATION_ERROR: "error-authenticationError",
};
@@ -22,7 +22,7 @@ export interface AuthDirectiveArgs {
function calculateAuthConditions(user: User): GQLUSER_AUTH_CONDITIONS[] {
const conditions: GQLUSER_AUTH_CONDITIONS[] = [];
if (!user.username && !user.displayName) {
if (!user.username) {
conditions.push(GQLUSER_AUTH_CONDITIONS.MISSING_NAME);
}
@@ -0,0 +1,2 @@
export * from "./ErrorWrappingExtension";
export * from "./LoggerExtension";
@@ -1,29 +0,0 @@
import { execute, GraphQLSchema, subscribe } from "graphql";
import http from "http";
import { SubscriptionServer } from "subscriptions-transport-ws";
export interface SubscriptionMiddlewareOptions {
schema: GraphQLSchema;
path: string;
}
export function handleSubscriptions(
server: http.Server,
{ schema, path }: SubscriptionMiddlewareOptions
): SubscriptionServer {
// Configure some options for the subscription system.
const options = {
schema,
execute,
subscribe,
};
// Configure the socket options for the websocket server. It needs to handle
// upgrade requests on that route.
const socketOption = {
server,
path,
};
return new SubscriptionServer(options, socketOption);
}
@@ -1,15 +0,0 @@
import { RedisPubSub } from "graphql-redis-subscriptions";
import { Config } from "talk-server/config";
import { createRedisClient } from "talk-server/services/redis";
export async function createPubSub(config: Config): Promise<RedisPubSub> {
// Create the Redis clients for the PubSub server.
const publisher = await createRedisClient(config);
const subscriber = await createRedisClient(config);
// Create the new PubSub manager.
return new RedisPubSub({
publisher,
subscriber,
});
}
@@ -1,23 +0,0 @@
import { Db } from "mongodb";
import { Config } from "talk-server/config";
import CommonContext from "talk-server/graph/common/context";
import { I18n } from "talk-server/services/i18n";
import { Request } from "talk-server/types/express";
export interface ManagementContextOptions {
mongo: Db;
config: Config;
i18n: I18n;
req?: Request;
}
export default class ManagementContext extends CommonContext {
public readonly mongo: Db;
constructor({ req, mongo, config, i18n }: ManagementContextOptions) {
super({ req, config, i18n });
this.mongo = mongo;
}
}
@@ -1,27 +0,0 @@
import { GraphQLSchema } from "graphql";
import { Db } from "mongodb";
import { Config } from "talk-server/config";
import { graphqlMiddleware } from "talk-server/graph/common/middleware";
import { Request } from "talk-server/types/express";
import { I18n } from "talk-server/services/i18n";
import ManagementContext from "./context";
export interface ManagementGraphQLMiddlewareOptions {
schema: GraphQLSchema;
config: Config;
mongo: Db;
i18n: I18n;
}
export default ({
schema,
config,
mongo,
i18n,
}: ManagementGraphQLMiddlewareOptions) =>
graphqlMiddleware(config, async (req: Request) => ({
schema,
context: new ManagementContext({ req, mongo, config, i18n }),
}));
@@ -1,9 +0,0 @@
import Time from "talk-server/graph/common/scalars/time";
import { GQLResolver } from "talk-server/graph/management/schema/__generated__/types";
const Resolvers: GQLResolver = {
Time,
};
export default Resolvers;
@@ -1,8 +0,0 @@
import { IResolvers } from "graphql-tools";
import { loadSchema } from "talk-common/graphql";
import resolvers from "talk-server/graph/management/resolvers";
export default function getManagementSchema() {
return loadSchema("management", resolvers as IResolvers);
}
@@ -1,34 +0,0 @@
################################################################################
## Custom Scalar Types
################################################################################
"""
Time represented as an ISO8601 string.
"""
scalar Time
################################################################################
## Tenant
################################################################################
type Tenant {
id: ID!
"""
organizationName is the name of the organization.
"""
organizationName: String
"""
organizationContactEmail is the email of the organization.
"""
organizationContactEmail: String
}
################################################################################
## Query
################################################################################
type Query {
tenant(id: ID!): Tenant
}
-6
View File
@@ -1,6 +0,0 @@
import { GraphQLSchema } from "graphql";
export interface Schemas {
management: GraphQLSchema;
tenant: GraphQLSchema;
}
+20 -32
View File
@@ -1,30 +1,27 @@
import { Db } from "mongodb";
import { Config } from "talk-server/config";
import CommonContext from "talk-server/graph/common/context";
import CommonContext, {
CommonContextOptions,
} from "talk-server/graph/common/context";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
import { TaskQueue } from "talk-server/queue";
import { MailerQueue } from "talk-server/queue/tasks/mailer";
import { ScraperQueue } from "talk-server/queue/tasks/scraper";
import { JWTSigningConfig } from "talk-server/services/jwt";
import { AugmentedRedis } from "talk-server/services/redis";
import TenantCache from "talk-server/services/tenant/cache";
import { Request } from "talk-server/types/express";
import { I18n } from "talk-server/services/i18n";
import loaders from "./loaders";
import mutators from "./mutators";
export interface TenantContextOptions {
export interface TenantContextOptions extends CommonContextOptions {
mongo: Db;
redis: AugmentedRedis;
tenant: Tenant;
tenantCache: TenantCache;
queue: TaskQueue;
config: Config;
mailerQueue: MailerQueue;
scraperQueue: ScraperQueue;
signingConfig?: JWTSigningConfig;
req?: Request;
user?: User;
i18n: I18n;
}
export default class TenantContext extends CommonContext {
@@ -32,33 +29,24 @@ export default class TenantContext extends CommonContext {
public readonly tenantCache: TenantCache;
public readonly mongo: Db;
public readonly redis: AugmentedRedis;
public readonly queue: TaskQueue;
public readonly mailerQueue: MailerQueue;
public readonly scraperQueue: ScraperQueue;
public readonly loaders: ReturnType<typeof loaders>;
public readonly mutators: ReturnType<typeof mutators>;
public readonly user?: User;
public readonly signingConfig?: JWTSigningConfig;
constructor({
req,
user,
tenant,
mongo,
redis,
config,
tenantCache,
queue,
signingConfig,
i18n,
}: TenantContextOptions) {
super({ user, req, config, i18n, lang: tenant.locale });
constructor(options: TenantContextOptions) {
super({ ...options, lang: options.tenant.locale });
this.tenant = tenant;
this.tenantCache = tenantCache;
this.user = user;
this.mongo = mongo;
this.redis = redis;
this.queue = queue;
this.signingConfig = signingConfig;
this.tenant = options.tenant;
this.tenantCache = options.tenantCache;
this.user = options.user;
this.mongo = options.mongo;
this.redis = options.redis;
this.scraperQueue = options.scraperQueue;
this.mailerQueue = options.mailerQueue;
this.signingConfig = options.signingConfig;
this.loaders = loaders(this);
this.mutators = mutators(this);
}
@@ -1,23 +0,0 @@
import TenantContext from "talk-server/graph/tenant/context";
import {
CommentModerationActionFilter,
retrieveCommentModerationActionConnection,
retrieveCommentModerationActions,
} from "talk-server/models/action/moderation/comment";
import { UserToCommentModerationActionHistoryArgs } from "../schema/__generated__/types";
export default (ctx: TenantContext) => ({
commentModerationActions: (filter: CommentModerationActionFilter) =>
retrieveCommentModerationActions(ctx.mongo, ctx.tenant.id, filter),
commentModerationActionsConnection: (
{ first = 10, after }: UserToCommentModerationActionHistoryArgs,
moderatorID: string
) =>
retrieveCommentModerationActionConnection(ctx.mongo, ctx.tenant.id, {
first,
after,
filter: {
moderatorID,
},
}),
});
@@ -0,0 +1,31 @@
import TenantContext from "talk-server/graph/tenant/context";
import {
CommentToStatusHistoryArgs,
UserToCommentModerationActionHistoryArgs,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { retrieveCommentModerationActionConnection } from "talk-server/models/action/moderation/comment";
export default (ctx: TenantContext) => ({
forModerator: (
{ first = 10, after }: UserToCommentModerationActionHistoryArgs,
moderatorID: string
) =>
retrieveCommentModerationActionConnection(ctx.mongo, ctx.tenant.id, {
first,
after,
filter: {
moderatorID,
},
}),
forComment: (
{ first = 10, after }: CommentToStatusHistoryArgs,
commentID: string
) =>
retrieveCommentModerationActionConnection(ctx.mongo, ctx.tenant.id, {
first,
after,
filter: {
commentID,
},
}),
});
@@ -1,4 +1,5 @@
import DataLoader from "dataloader";
import { isNil, omitBy } from "lodash";
import Context from "talk-server/graph/tenant/context";
import {
@@ -33,9 +34,9 @@ import { SingletonResolver } from "./util";
const primeCommentsFromConnection = (ctx: Context) => (
connection: Readonly<Connection<Readonly<Comment>>>
) => {
// For each of the edges, prime the comment loader.
connection.edges.forEach(({ node }) => {
ctx.loaders.Comments.comment.prime(node.id, node);
// For each of the nodes, prime the comment loader.
connection.nodes.forEach(comment => {
ctx.loaders.Comments.comment.prime(comment.id, comment);
});
return connection;
@@ -45,22 +46,35 @@ export default (ctx: Context) => ({
comment: new DataLoader((ids: string[]) =>
retrieveManyComments(ctx.mongo, ctx.tenant.id, ids)
),
forFilter: ({ first = 10, after, filter }: QueryToCommentsArgs) =>
forFilter: ({ first = 10, after, storyID, status }: QueryToCommentsArgs) =>
retrieveCommentConnection(ctx.mongo, ctx.tenant.id, {
first,
after,
orderBy: GQLCOMMENT_SORT.CREATED_AT_DESC,
filter,
filter: omitBy(
{
storyID,
status,
},
isNil
),
}).then(primeCommentsFromConnection(ctx)),
retrieveMyActionPresence: new DataLoader<string, GQLActionPresence>(
(commentIDs: string[]) =>
retrieveManyUserActionPresence(
(commentIDs: string[]) => {
if (!ctx.user) {
// This should only ever be accessed when a user is logged in. It should
// be safe to get the user here, but we'll throw an error anyways just
// in case.
throw new Error("can't get action presense of an undefined user");
}
return retrieveManyUserActionPresence(
ctx.mongo,
ctx.tenant.id,
// This should only ever be accessed when a user is logged in.
ctx.user!.id,
ctx.user.id,
commentIDs
)
);
}
),
forUser: (
userID: string,
@@ -1,21 +1,62 @@
import DataLoader from "dataloader";
import TenantContext from "talk-server/graph/tenant/context";
import { GQLStoryMetadata } from "talk-server/graph/tenant/schema/__generated__/types";
import {
GQLSTORY_STATUS,
GQLStoryMetadata,
QueryToStoriesArgs,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { Connection } from "talk-server/models/helpers/connection";
import {
FindOrCreateStoryInput,
retrieveManyStories,
retrieveStoryConnection,
Story,
StoryConnectionInput,
} from "talk-server/models/story";
import { findOrCreate } from "talk-server/services/stories";
import { scraper } from "talk-server/services/stories/scraper";
const statusFilter = (
status?: GQLSTORY_STATUS
): StoryConnectionInput["filter"] => {
switch (status) {
case GQLSTORY_STATUS.OPEN:
return {
closedAt: null,
};
case GQLSTORY_STATUS.CLOSED:
return {
closedAt: { $lte: new Date() },
};
default:
return {};
}
};
/**
* primeStoriesFromConnection will prime a given context with the stories
* retrieved via a connection.
*
* @param ctx graph context to use to prime the loaders.
*/
const primeStoriesFromConnection = (ctx: TenantContext) => (
connection: Readonly<Connection<Readonly<Story>>>
) => {
// For each of these nodes, prime the story loader.
connection.nodes.forEach(story => {
ctx.loaders.Stories.story.prime(story.id, story);
});
return connection;
};
export default (ctx: TenantContext) => ({
findOrCreate: new DataLoader(
(inputs: FindOrCreateStoryInput[]) =>
Promise.all(
inputs.map(input =>
findOrCreate(ctx.mongo, ctx.tenant, input, ctx.queue.scraper)
findOrCreate(ctx.mongo, ctx.tenant, input, ctx.scraperQueue)
)
),
{
@@ -26,6 +67,15 @@ export default (ctx: TenantContext) => ({
story: new DataLoader<string, Story | null>(ids =>
retrieveManyStories(ctx.mongo, ctx.tenant.id, ids)
),
connection: ({ first = 10, after, status }: QueryToStoriesArgs) =>
retrieveStoryConnection(ctx.mongo, ctx.tenant.id, {
first,
after,
filter: {
// Merge the status filter into the connection filter.
...statusFilter(status),
},
}).then(primeStoriesFromConnection(ctx)),
debugScrapeMetadata: new DataLoader<string, GQLStoryMetadata | null>(urls =>
Promise.all(urls.map(url => scraper.scrape(url)))
),
+32 -1
View File
@@ -1,6 +1,31 @@
import DataLoader from "dataloader";
import { isNil, omitBy } from "lodash";
import Context from "talk-server/graph/tenant/context";
import { retrieveManyUsers, User } from "talk-server/models/user";
import { QueryToUsersArgs } from "talk-server/graph/tenant/schema/__generated__/types";
import { Connection } from "talk-server/models/helpers/connection";
import {
retrieveManyUsers,
retrieveUserConnection,
User,
} from "talk-server/models/user";
/**
* primeUsersFromConnection will prime a given context with the users retrieved
* via a connection.
*
* @param ctx graph context to use to prime the loaders.
*/
const primeUsersFromConnection = (ctx: Context) => (
connection: Readonly<Connection<Readonly<User>>>
) => {
// For each of the nodes, prime the user loader.
connection.nodes.forEach(user => {
ctx.loaders.Users.user.prime(user.id, user);
});
return connection;
};
export default (ctx: Context) => {
const user = new DataLoader<string, User | null>(ids =>
@@ -14,5 +39,11 @@ export default (ctx: Context) => {
return {
user,
connection: ({ first = 10, after, role }: QueryToUsersArgs) =>
retrieveUserConnection(ctx.mongo, ctx.tenant.id, {
first,
after,
filter: omitBy({ role }, isNil),
}).then(primeUsersFromConnection(ctx)),
};
};
@@ -1,14 +1,14 @@
import Context from "talk-server/graph/tenant/context";
import Actions from "./Actions";
import Auth from "./Auth";
import CommentModerationActions from "./CommentModerationActions";
import Comments from "./Comments";
import Stories from "./Stories";
import Users from "./Users";
export default (ctx: Context) => ({
Auth: Auth(ctx),
Actions: Actions(ctx),
CommentModerationActions: CommentModerationActions(ctx),
Stories: Stories(ctx),
Comments: Comments(ctx),
Users: Users(ctx),
@@ -1,36 +0,0 @@
import { GraphQLSchema } from "graphql";
import { graphqlBatchMiddleware } from "talk-server/app/middleware/graphqlBatch";
import { Config } from "talk-server/config";
import { graphqlMiddleware } from "talk-server/graph/common/middleware";
import { Request } from "talk-server/types/express";
export interface TenantGraphQLMiddlewareOptions {
schema: GraphQLSchema;
config: Config;
}
export default async ({ schema, config }: TenantGraphQLMiddlewareOptions) =>
graphqlBatchMiddleware(
graphqlMiddleware(config, async (req: Request) => {
if (!req.talk) {
throw new Error("talk was not set");
}
const { context } = req.talk;
if (!context) {
throw new Error("context was not set");
}
const { tenant } = context;
if (!tenant) {
throw new Error("tenant was not set");
}
// Return the graph options.
return {
schema,
context: tenant,
};
})
);
@@ -23,7 +23,7 @@ import {
import { validateMaximumLength } from "./util";
export const Comment = (ctx: TenantContext) => ({
export const Comments = (ctx: TenantContext) => ({
create: ({
clientMutationId,
...comment
@@ -10,14 +10,12 @@ import {
GQLScrapeStoryInput,
GQLUpdateStoryInput,
} from "talk-server/graph/tenant/schema/__generated__/types";
import * as story from "talk-server/models/story";
import { Story } from "talk-server/models/story";
import { create, merge, remove, update } from "talk-server/services/stories";
import { scrape } from "talk-server/services/stories/scraper";
export const Story = (ctx: TenantContext) => ({
create: async (
input: GQLCreateStoryInput
): Promise<Readonly<story.Story> | null> =>
export const Stories = (ctx: TenantContext) => ({
create: async (input: GQLCreateStoryInput): Promise<Readonly<Story> | null> =>
mapFieldsetToErrorCodes(
create(
ctx.mongo,
@@ -33,9 +31,7 @@ export const Story = (ctx: TenantContext) => ({
],
}
),
update: async (
input: GQLUpdateStoryInput
): Promise<Readonly<story.Story> | null> =>
update: async (input: GQLUpdateStoryInput): Promise<Readonly<Story> | null> =>
mapFieldsetToErrorCodes(
update(ctx.mongo, ctx.tenant, input.id, omitBy(input.story, isNull)),
{
@@ -45,9 +41,7 @@ export const Story = (ctx: TenantContext) => ({
],
}
),
merge: async (
input: GQLMergeStoriesInput
): Promise<Readonly<story.Story> | null> =>
merge: async (input: GQLMergeStoriesInput): Promise<Readonly<Story> | null> =>
merge(
ctx.mongo,
ctx.redis,
@@ -55,12 +49,8 @@ export const Story = (ctx: TenantContext) => ({
input.destinationID,
input.sourceIDs
),
remove: async (
input: GQLRemoveStoryInput
): Promise<Readonly<story.Story> | null> =>
remove: async (input: GQLRemoveStoryInput): Promise<Readonly<Story> | null> =>
remove(ctx.mongo, ctx.tenant, input.id, input.includeComments),
scrape: async (
input: GQLScrapeStoryInput
): Promise<Readonly<story.Story> | null> =>
scrape: async (input: GQLScrapeStoryInput): Promise<Readonly<Story> | null> =>
scrape(ctx.mongo, ctx.tenant.id, input.id),
});
@@ -1,7 +1,7 @@
import { ERROR_CODES } from "talk-common/errors";
import { mapFieldsetToErrorCodes } from "talk-server/graph/common/errors";
import TenantContext from "talk-server/graph/tenant/context";
import * as user from "talk-server/models/user";
import { User } from "talk-server/models/user";
import {
createToken,
deactivateToken,
@@ -9,13 +9,11 @@ import {
setPassword,
setUsername,
updateAvatar,
updateDisplayName,
updateEmail,
updatePassword,
updateRole,
updateUsername,
} from "talk-server/services/users";
import {
GQLCreateTokenInput,
GQLDeactivateTokenInput,
@@ -24,16 +22,15 @@ import {
GQLSetUsernameInput,
GQLUpdatePasswordInput,
GQLUpdateUserAvatarInput,
GQLUpdateUserDisplayNameInput,
GQLUpdateUserEmailInput,
GQLUpdateUserRoleInput,
GQLUpdateUserUsernameInput,
} from "../schema/__generated__/types";
export const User = (ctx: TenantContext) => ({
export const Users = (ctx: TenantContext) => ({
setUsername: async (
input: GQLSetUsernameInput
): Promise<Readonly<user.User> | null> =>
): Promise<Readonly<User> | null> =>
mapFieldsetToErrorCodes(
setUsername(ctx.mongo, ctx.tenant, ctx.user!, input.username),
{
@@ -42,13 +39,10 @@ export const User = (ctx: TenantContext) => ({
ERROR_CODES.USERNAME_CONTAINS_INVALID_CHARACTERS,
ERROR_CODES.USERNAME_EXCEEDS_MAX_LENGTH,
ERROR_CODES.USERNAME_TOO_SHORT,
ERROR_CODES.DUPLICATE_USERNAME,
],
}
),
setEmail: async (
input: GQLSetEmailInput
): Promise<Readonly<user.User> | null> =>
setEmail: async (input: GQLSetEmailInput): Promise<Readonly<User> | null> =>
mapFieldsetToErrorCodes(
setEmail(ctx.mongo, ctx.tenant, ctx.user!, input.email),
{
@@ -62,11 +56,11 @@ export const User = (ctx: TenantContext) => ({
),
setPassword: async (
input: GQLSetPasswordInput
): Promise<Readonly<user.User> | null> =>
): Promise<Readonly<User> | null> =>
setPassword(ctx.mongo, ctx.tenant, ctx.user!, input.password),
updatePassword: async (
input: GQLUpdatePasswordInput
): Promise<Readonly<user.User> | null> =>
): Promise<Readonly<User> | null> =>
updatePassword(ctx.mongo, ctx.tenant, ctx.user!, input.password),
createToken: async (input: GQLCreateTokenInput) =>
createToken(
@@ -81,8 +75,6 @@ export const User = (ctx: TenantContext) => ({
deactivateToken(ctx.mongo, ctx.tenant, ctx.user!, input.id),
updateUserUsername: async (input: GQLUpdateUserUsernameInput) =>
updateUsername(ctx.mongo, ctx.tenant, input.userID, input.username),
updateUserDisplayName: async (input: GQLUpdateUserDisplayNameInput) =>
updateDisplayName(ctx.mongo, ctx.tenant, input.userID, input.displayName),
updateUserEmail: async (input: GQLUpdateUserEmailInput) =>
updateEmail(ctx.mongo, ctx.tenant, input.userID, input.email),
updateUserAvatar: async (input: GQLUpdateUserAvatarInput) =>
@@ -1,15 +1,15 @@
import TenantContext from "talk-server/graph/tenant/context";
import { Actions } from "./Actions";
import { Comment } from "./Comment";
import { Comments } from "./Comments";
import { Settings } from "./Settings";
import { Story } from "./Story";
import { User } from "./User";
import { Stories } from "./Stories";
import { Users } from "./Users";
export default (ctx: TenantContext) => ({
Actions: Actions(ctx),
Comment: Comment(ctx),
Comments: Comments(ctx),
Settings: Settings(ctx),
Story: Story(ctx),
User: User(ctx),
Stories: Stories(ctx),
Users: Users(ctx),
});
@@ -9,8 +9,8 @@ import { decodeActionCounts } from "talk-server/models/action/comment";
import * as comment from "talk-server/models/comment";
import { getLatestRevision } from "talk-server/models/comment";
import { createConnection } from "talk-server/models/helpers/connection";
import { getCommentEditableUntilDate } from "talk-server/services/comments";
import TenantContext from "../context";
import { getURLWithCommentID } from "./util";
@@ -55,13 +55,14 @@ export const Comment: GQLCommentTypeResolver<comment.Comment> = {
}),
author: (c, input, ctx) => ctx.loaders.Users.user.load(c.authorID),
statusHistory: ({ id }, input, ctx) =>
ctx.loaders.Actions.commentModerationActions({
commentID: id,
}),
ctx.loaders.CommentModerationActions.forComment(input, id),
replies: (c, input, ctx) =>
// If there is at least one reply, then use the connection loader, otherwise
// return a blank connection.
c.replyCount > 0
? ctx.loaders.Comments.forParent(c.storyID, c.id, input)
: createConnection(),
// Action Counts are encoded, decode them for use with the GraphQL system.
actionCounts: c => decodeActionCounts(c.actionCounts),
myActionPresence: (c, input, ctx) =>
ctx.user ? ctx.loaders.Comments.retrieveMyActionPresence.load(c.id) : null,
@@ -1,4 +1,5 @@
import * as actions from "talk-server/models/action/moderation/comment";
import { GQLCommentModerationActionTypeResolver } from "../schema/__generated__/types";
export const CommentModerationAction: GQLCommentModerationActionTypeResolver<
@@ -1,10 +1,10 @@
import { GQLCommentRevisionTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { decodeActionCounts } from "talk-server/models/action/comment";
import * as comment from "talk-server/models/comment";
import { Comment, Revision } from "talk-server/models/comment";
export interface WrappedCommentRevision {
revision: comment.Revision;
comment: comment.Comment;
revision: Revision;
comment: Comment;
}
export const CommentRevision: Required<
@@ -8,8 +8,6 @@ import { reconstructTenantURLResolver } from "./util";
export const FacebookAuthIntegration: GQLFacebookAuthIntegrationTypeResolver<
GQLFacebookAuthIntegration
> = {
callbackURL: reconstructTenantURLResolver(
"/api/tenant/auth/facebook/callback"
),
redirectURL: reconstructTenantURLResolver("/api/tenant/auth/facebook"),
callbackURL: reconstructTenantURLResolver("/api/auth/facebook/callback"),
redirectURL: reconstructTenantURLResolver("/api/auth/facebook"),
};
@@ -8,6 +8,6 @@ import { reconstructTenantURLResolver } from "./util";
export const GoogleAuthIntegration: GQLGoogleAuthIntegrationTypeResolver<
GQLGoogleAuthIntegration
> = {
callbackURL: reconstructTenantURLResolver("/api/tenant/auth/google/callback"),
redirectURL: reconstructTenantURLResolver("/api/tenant/auth/google"),
callbackURL: reconstructTenantURLResolver("/api/auth/google/callback"),
redirectURL: reconstructTenantURLResolver("/api/auth/google"),
};
@@ -2,26 +2,24 @@ import { GQLMutationTypeResolver } from "talk-server/graph/tenant/schema/__gener
export const Mutation: Required<GQLMutationTypeResolver<void>> = {
editComment: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.edit(input),
comment: await ctx.mutators.Comments.edit(input),
clientMutationId: input.clientMutationId,
}),
createComment: async (source, { input }, ctx) => ({
edge: {
// Depending on the sort we can't determine the accurate cursor in a
// performant way, so we return null instead. It seems that Relay does
// not directly use this value.
cursor: null,
node: await ctx.mutators.Comment.create(input),
// performant way, so we return an empty string.
cursor: "",
node: await ctx.mutators.Comments.create(input),
},
clientMutationId: input.clientMutationId,
}),
createCommentReply: async (source, { input }, ctx) => ({
edge: {
// Depending on the sort we can't determine the accurate cursor in a
// performant way, so we return null instead. It seems that Relay does
// not directly use this value.
cursor: null,
node: await ctx.mutators.Comment.create(input),
// performant way, so we return an empty string.
cursor: "",
node: await ctx.mutators.Comments.create(input),
},
clientMutationId: input.clientMutationId,
}),
@@ -30,23 +28,23 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
clientMutationId: input.clientMutationId,
}),
createCommentReaction: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.createReaction(input),
comment: await ctx.mutators.Comments.createReaction(input),
clientMutationId: input.clientMutationId,
}),
removeCommentReaction: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.removeReaction(input),
comment: await ctx.mutators.Comments.removeReaction(input),
clientMutationId: input.clientMutationId,
}),
createCommentDontAgree: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.createDontAgree(input),
comment: await ctx.mutators.Comments.createDontAgree(input),
clientMutationId: input.clientMutationId,
}),
removeCommentDontAgree: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.removeDontAgree(input),
comment: await ctx.mutators.Comments.removeDontAgree(input),
clientMutationId: input.clientMutationId,
}),
createCommentFlag: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.createFlag(input),
comment: await ctx.mutators.Comments.createFlag(input),
clientMutationId: input.clientMutationId,
}),
regenerateSSOKey: async (source, { input }, ctx) => ({
@@ -54,23 +52,23 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
clientMutationId: input.clientMutationId,
}),
createStory: async (source, { input }, ctx) => ({
story: await ctx.mutators.Story.create(input),
story: await ctx.mutators.Stories.create(input),
clientMutationId: input.clientMutationId,
}),
updateStory: async (source, { input }, ctx) => ({
story: await ctx.mutators.Story.update(input),
story: await ctx.mutators.Stories.update(input),
clientMutationId: input.clientMutationId,
}),
mergeStories: async (source, { input }, ctx) => ({
story: await ctx.mutators.Story.merge(input),
story: await ctx.mutators.Stories.merge(input),
clientMutationId: input.clientMutationId,
}),
removeStory: async (source, { input }, ctx) => ({
story: await ctx.mutators.Story.remove(input),
story: await ctx.mutators.Stories.remove(input),
clientMutationId: input.clientMutationId,
}),
scrapeStory: async (source, { input }, ctx) => ({
story: await ctx.mutators.Story.scrape(input),
story: await ctx.mutators.Stories.scrape(input),
clientMutationId: input.clientMutationId,
}),
acceptComment: async (source, { input }, ctx) => ({
@@ -82,47 +80,43 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
clientMutationId: input.clientMutationId,
}),
setUsername: async (source, { input }, ctx) => ({
user: await ctx.mutators.User.setUsername(input),
user: await ctx.mutators.Users.setUsername(input),
clientMutationId: input.clientMutationId,
}),
setEmail: async (source, { input }, ctx) => ({
user: await ctx.mutators.User.setEmail(input),
user: await ctx.mutators.Users.setEmail(input),
clientMutationId: input.clientMutationId,
}),
setPassword: async (source, { input }, ctx) => ({
user: await ctx.mutators.User.setPassword(input),
user: await ctx.mutators.Users.setPassword(input),
clientMutationId: input.clientMutationId,
}),
updatePassword: async (source, { input }, ctx) => ({
user: await ctx.mutators.User.updatePassword(input),
user: await ctx.mutators.Users.updatePassword(input),
clientMutationId: input.clientMutationId,
}),
createToken: async (source, { input }, ctx) => ({
...(await ctx.mutators.User.createToken(input)),
...(await ctx.mutators.Users.createToken(input)),
clientMutationId: input.clientMutationId,
}),
deactivateToken: async (source, { input }, ctx) => ({
...(await ctx.mutators.User.deactivateToken(input)),
...(await ctx.mutators.Users.deactivateToken(input)),
clientMutationId: input.clientMutationId,
}),
updateUserUsername: async (source, { input }, ctx) => ({
user: await ctx.mutators.User.updateUserUsername(input),
clientMutationId: input.clientMutationId,
}),
updateUserDisplayName: async (source, { input }, ctx) => ({
user: await ctx.mutators.User.updateUserDisplayName(input),
user: await ctx.mutators.Users.updateUserUsername(input),
clientMutationId: input.clientMutationId,
}),
updateUserEmail: async (source, { input }, ctx) => ({
user: await ctx.mutators.User.updateUserEmail(input),
user: await ctx.mutators.Users.updateUserEmail(input),
clientMutationId: input.clientMutationId,
}),
updateUserAvatar: async (source, { input }, ctx) => ({
user: await ctx.mutators.User.updateUserAvatar(input),
user: await ctx.mutators.Users.updateUserAvatar(input),
clientMutationId: input.clientMutationId,
}),
updateUserRole: async (source, { input }, ctx) => ({
user: await ctx.mutators.User.updateUserRole(input),
user: await ctx.mutators.Users.updateUserRole(input),
clientMutationId: input.clientMutationId,
}),
};
@@ -8,6 +8,6 @@ import { reconstructTenantURLResolver } from "./util";
export const OIDCAuthIntegration: GQLOIDCAuthIntegrationTypeResolver<
GQLOIDCAuthIntegration
> = {
callbackURL: reconstructTenantURLResolver("/api/tenant/auth/oidc/callback"),
redirectURL: reconstructTenantURLResolver("/api/tenant/auth/oidc"),
callbackURL: reconstructTenantURLResolver("/api/auth/oidc/callback"),
redirectURL: reconstructTenantURLResolver("/api/auth/oidc"),
};
@@ -1,5 +1,4 @@
import { GQLProfileTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import * as user from "talk-server/models/user";
const resolveType: GQLProfileTypeResolver<user.Profile> = profile => {
@@ -2,8 +2,11 @@ import { GQLQueryTypeResolver } from "talk-server/graph/tenant/schema/__generate
import { sharedModerationInputResolver } from "./ModerationQueues";
export const Query: GQLQueryTypeResolver<void> = {
export const Query: Required<GQLQueryTypeResolver<void>> = {
story: (source, args, ctx) => ctx.loaders.Stories.findOrCreate.load(args),
stories: (source, args, ctx) => ctx.loaders.Stories.connection(args),
user: (source, args, ctx) => ctx.loaders.Users.user.load(args.id),
users: (source, args, ctx) => ctx.loaders.Users.connection(args),
comment: (source, { id }, ctx) =>
id ? ctx.loaders.Comments.comment.load(id) : null,
comments: (source, args, ctx) => ctx.loaders.Comments.forFilter(args),
@@ -4,5 +4,5 @@ import * as user from "talk-server/models/user";
export const User: GQLUserTypeResolver<user.User> = {
comments: ({ id }, input, ctx) => ctx.loaders.Comments.forUser(id, input),
commentModerationActionHistory: ({ id }, input, ctx) =>
ctx.loaders.Actions.commentModerationActionsConnection(input, id),
ctx.loaders.CommentModerationActions.forModerator(input, id),
};
@@ -1,6 +1,5 @@
import Cursor from "talk-server/graph/common/scalars/cursor";
import Time from "talk-server/graph/common/scalars/time";
import { GQLResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { AcceptCommentPayload } from "./AcceptCommentPayload";
@@ -29,17 +28,17 @@ const Resolvers: GQLResolver = {
CommentModerationAction,
CommentRevision,
Cursor,
Mutation,
ModerationQueue,
ModerationQueues,
OIDCAuthIntegration,
FacebookAuthIntegration,
GoogleAuthIntegration,
ModerationQueue,
ModerationQueues,
Mutation,
OIDCAuthIntegration,
Profile,
Query,
RejectCommentPayload,
Time,
Story,
Time,
User,
};
@@ -1,9 +1,9 @@
import { GraphQLResolveInfo } from "graphql";
import graphqlFields from "graphql-fields";
import { pull } from "lodash";
import { parseQuery, stringifyQuery } from "talk-common/utils";
import { URL } from "url";
import { parseQuery, stringifyQuery } from "talk-common/utils";
import { constructTenantURL, reconstructURL } from "talk-server/app/url";
import TenantContext from "../context";
+180 -106
View File
@@ -602,22 +602,30 @@ type FacebookAuthIntegration {
##########################
type AuthIntegrations {
"""
local stores configuration related to email/password based logins.
"""
local: LocalAuthIntegration!
sso: SSOAuthIntegration!
oidc: OIDCAuthIntegration!
google: GoogleAuthIntegration!
facebook: FacebookAuthIntegration!
}
"""
AuthDisplayNameConfiguration allows configuration related to Display Names.
"""
type AuthDisplayNameConfiguration {
"""
enabled when true will allow the display name to be used by other
AuthIntegrations.
sso stores configuration related to Single Sign On based logins.
"""
enabled: Boolean!
sso: SSOAuthIntegration!
"""
oidc stores configuration related to OpenID Connect based logins.
"""
oidc: OIDCAuthIntegration!
"""
google stores configuration related to Google based logins.
"""
google: GoogleAuthIntegration!
"""
facebook stores configuration related to Facebook based logins.
"""
facebook: FacebookAuthIntegration!
}
"""
@@ -630,12 +638,6 @@ type Auth {
authentication solutions.
"""
integrations: AuthIntegrations!
"""
displayName contains configuration related to the use of Display Names across
AuthIntegrations.
"""
displayName: AuthDisplayNameConfiguration! @auth(roles: [ADMIN])
}
################################################################################
@@ -1068,11 +1070,6 @@ type User {
"""
username: String
"""
displayName is provided optionally when enabled and available.
"""
displayName: String
"""
email is the current email address for the User.
"""
@@ -1114,12 +1111,52 @@ type User {
commentModerationActionHistory(
first: Int = 10
after: Cursor
): CommentModerationActionConnection! @auth(role: [MODERATOR, ADMIN])
): CommentModerationActionConnection! @auth(roles: [MODERATOR, ADMIN])
"""
tokens lists the access tokens associated with the account.
"""
tokens: [Token!]! @auth(role: [ADMIN], userIDField: "id")
tokens: [Token!]! @auth(roles: [ADMIN], userIDField: "id")
"""
createdAt is the time that the User was created at.
"""
createdAt: Time! @auth(roles: [ADMIN, MODERATOR], userIDField: "id")
}
"""
UserEdge represents a unique User in a UsersConnection.
"""
type UserEdge {
"""
node is the User for this edge.
"""
node: User!
"""
cursor is used in pagination.
"""
cursor: Cursor!
}
"""
UsersConnection represents a subset of a stories list.
"""
type UsersConnection {
"""
edges are a subset of UserEdge's.
"""
edges: [UserEdge!]!
"""
nodes is a list of User's.
"""
nodes: [User!]!
"""
pageInfo is information to aid in pagination.
"""
pageInfo: PageInfo!
}
################################################################################
@@ -1201,9 +1238,9 @@ type CommentModerationActionEdge {
node: CommentModerationAction!
"""
cursor is used in pagination.
"""
cursor: Cursor
cursor: Cursor!
}
type CommentModerationActionConnection {
@@ -1213,7 +1250,12 @@ type CommentModerationActionConnection {
edges: [CommentModerationActionEdge!]!
"""
pageInfo is
nodes is a list of CommentModerationAction's.
"""
nodes: [CommentModerationAction!]!
"""
pageInfo is information to aid in pagination.
"""
pageInfo: PageInfo!
}
@@ -1295,7 +1337,10 @@ type Comment {
the history of moderator actions performed on the Comment, with the most
recent last.
"""
statusHistory: [CommentModerationAction!]! @auth(role: [MODERATOR, ADMIN])
statusHistory(
first: Int = 10
after: Cursor
): CommentModerationActionConnection! @auth(roles: [MODERATOR, ADMIN])
"""
parentCount is the number of direct parents for this Comment. Currently this
@@ -1399,9 +1444,9 @@ type CommentEdge {
node: Comment!
"""
cursor is used in pagination.
"""
cursor: Cursor
cursor: Cursor!
}
"""
@@ -1414,7 +1459,12 @@ type CommentsConnection {
edges: [CommentEdge!]!
"""
pageInfo is
nodes is a list of Comment's.
"""
nodes: [Comment!]!
"""
pageInfo is information to aid in pagination.
"""
pageInfo: PageInfo!
}
@@ -1517,6 +1567,21 @@ type StoryMetadata {
section: String
}
"""
STORY_STATUS represents filtering states that a Story can be in.
"""
enum STORY_STATUS {
"""
OPEN represents when a given Story is open for commenting.
"""
OPEN
"""
CLOSED represents when a given Story is not open for commenting.
"""
CLOSED
}
"""
Story is an Article or Page where Comments are written on by Users.
"""
@@ -1537,7 +1602,8 @@ type Story {
metadata: StoryMetadata
"""
scrapedAt is the Time that the Story had it's metadata scraped at.
scrapedAt is the Time that the Story had it's metadata scraped at. If the time
is null, the Story has not been scraped yet.
"""
scrapedAt: Time
@@ -1563,7 +1629,8 @@ type Story {
moderationQueues: ModerationQueues! @auth(roles: [ADMIN, MODERATOR])
"""
closedAt is the Time that the Story is closed for commenting.
closedAt is the Time that the Story is closed for commenting. If null or in
the future, the story is not yet closed.
"""
closedAt: Time
@@ -1588,21 +1655,39 @@ type Story {
moderation: MODERATION_MODE!
}
################################################################################
## CommentsFilterInput
################################################################################
input CommentsFilterInput {
"""
StoryEdge represents a unique Story in a StoriesConnection.
"""
type StoryEdge {
"""
storyID when specified, will filter to show only Comment's on that Story.
node is the Story for this edge.
"""
storyID: ID
node: Story!
"""
status when specified, will filter to show only Comment's with that
COMMENT_STATUS.
cursor is used in pagination.
"""
status: COMMENT_STATUS
cursor: Cursor!
}
"""
StoriesConnection represents a subset of a stories list.
"""
type StoriesConnection {
"""
edges are a subset of StoryEdge's.
"""
edges: [StoryEdge!]!
"""
nodes is a list of Story's.
"""
nodes: [Story!]!
"""
pageInfo is information to aid in pagination.
"""
pageInfo: PageInfo!
}
################################################################################
@@ -1616,22 +1701,46 @@ type Query {
comment(id: ID!): Comment
"""
comments returns a filtered comments connection that can be paginated.
comments returns a filtered comments connection that can be paginated. This is
a fairly expensive edge to filter against, moderation queues should utlilize
the dedicated edges for more optimized responses.
"""
comments(
first: Int = 10
after: Cursor
filter: CommentsFilterInput!
storyID: ID
status: COMMENT_STATUS
): CommentsConnection @auth(roles: [ADMIN, MODERATOR])
"""
story is the Story specified by its ID/URL.
story is a specific article that can be identified by either an ID or a URL.
"""
story(id: ID, url: String): Story
"""
stories returns filtered stories that can be paginated.
"""
stories(
first: Int = 10
after: Cursor
status: STORY_STATUS
): StoriesConnection @auth(roles: [ADMIN, MODERATOR])
"""
user will return the user referenced by their ID.
"""
user(id: ID!): User @auth(roles: [ADMIN, MODERATOR])
"""
users returns filtered users that can be paginated.
"""
users(first: Int = 10, after: Cursor, role: USER_ROLE): UsersConnection!
@auth(roles: [ADMIN, MODERATOR])
"""
me is the current logged in User. If no user is currently logged in, it will
return null.
return null. This is the only nullable field that can be returned that depends
on the authentication state that will not throw an error.
"""
me: User
@@ -1982,19 +2091,30 @@ input SettingsFacebookAuthIntegrationInput {
}
input SettingsAuthIntegrationsInput {
"""
local stores configuration related to email/password based logins.
"""
local: SettingsLocalAuthIntegrationInput
sso: SettingsSSOAuthIntegrationInput
oidc: SettingsOIDCAuthIntegrationInput
google: SettingsGoogleAuthIntegrationInput
facebook: SettingsFacebookAuthIntegrationInput
}
input SettingsAuthDisplayNameInput {
"""
enabled when true will allow the display name to be used by other
AuthIntegrations.
sso stores configuration related to Single Sign On based logins.
"""
enabled: Boolean!
sso: SettingsSSOAuthIntegrationInput
"""
oidc stores configuration related to OpenID Connect based logins.
"""
oidc: SettingsOIDCAuthIntegrationInput
"""
google stores configuration related to Google based logins.
"""
google: SettingsGoogleAuthIntegrationInput
"""
facebook stores configuration related to Facebook based logins.
"""
facebook: SettingsFacebookAuthIntegrationInput
}
"""
@@ -2002,12 +2122,6 @@ Auth contains all the settings related to authentication and
authorization.
"""
input SettingsAuthInput {
"""
"
displayName allows configuration related to Display Names.
"""
displayName: SettingsAuthDisplayNameInput
"""
integrations are the set of configurations for the variations of
authentication solutions.
@@ -3047,40 +3161,6 @@ type UpdateUserUsernamePayload {
clientMutationId: String!
}
##################
# updateUserDisplayName
##################
input UpdateUserDisplayNameInput {
"""
userID is the ID of the User that should have their display name updated.
"""
userID: ID!
"""
displayName is the desired display name to set for the User. If set to `null`
or not provided, it will be unset.
"""
displayName: String
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type UpdateUserDisplayNamePayload {
"""
user is the possibly modified User.
"""
user: User!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
# updateUserEmail
##################
@@ -3195,7 +3275,9 @@ type Mutation {
createCommentReply will create a Comment as the current logged in User that is
in reply to another Comment.
"""
createCommentReply(input: CreateCommentReplyInput!): CreateCommentReplyPayload
createCommentReply(
input: CreateCommentReplyInput!
): CreateCommentReplyPayload @auth
"""
editComment will allow the author of a comment to change the body within the
@@ -3346,14 +3428,6 @@ type Mutation {
input: UpdateUserUsernameInput!
): UpdateUserUsernamePayload! @auth(roles: [ADMIN])
"""
updateUserDisplayName allows administrators to update a given User's display
name to the one provided.
"""
updateUserDisplayName(
input: UpdateUserDisplayNameInput!
): UpdateUserDisplayNamePayload @auth(roles: [ADMIN])
"""
updateUserEmail allows administrators to update a given User's email address
to the one provided.
+32 -27
View File
@@ -1,16 +1,11 @@
import express, { Express } from "express";
import { GraphQLSchema } from "graphql";
import http from "http";
import { Db } from "mongodb";
import { LanguageCode } from "talk-common/helpers/i18n/locales";
import {
attachSubscriptionHandlers,
createApp,
listenAndServe,
} from "talk-server/app";
import { createApp, listenAndServe } from "talk-server/app";
import config, { Config } from "talk-server/config";
import getManagementSchema from "talk-server/graph/management/schema";
import { Schemas } from "talk-server/graph/schemas";
import getTenantSchema from "talk-server/graph/tenant/schema";
import logger from "talk-server/logger";
import { createQueue, TaskQueue } from "talk-server/queue";
@@ -18,10 +13,17 @@ import { I18n } from "talk-server/services/i18n";
import { createJWTSigningConfig } from "talk-server/services/jwt";
import { createMongoDB } from "talk-server/services/mongodb";
import { ensureIndexes } from "talk-server/services/mongodb/indexes";
import { AugmentedRedis, createRedisClient } from "talk-server/services/redis";
import {
AugmentedRedis,
createAugmentedRedisClient,
createRedisClient,
} from "talk-server/services/redis";
import TenantCache from "talk-server/services/tenant/cache";
export interface ServerOptions {
/**
* config when specified will specify the configuration to load.
*/
config?: Config;
}
@@ -32,9 +34,8 @@ class Server {
// parentApp is the root application that the server will bind to.
private parentApp: Express;
// schemas are the set of GraphQLSchema objects for each schema used by the
// server.
private schemas: Schemas;
// schema is the GraphQL Schema that relates to the given Tenant.
private schema: GraphQLSchema;
// config exposes application specific configuration.
public config: Config;
@@ -43,8 +44,8 @@ class Server {
// the requested port.
public httpServer: http.Server;
// queue stores a reference to the queues that can process operations.
private queue: TaskQueue;
// tasks stores a reference to the queues that can process operations.
private tasks: TaskQueue;
// redis stores the redis connection used by the application.
private redis: AugmentedRedis;
@@ -71,12 +72,13 @@ class Server {
logger.debug({ config: this.config.toString() }, "loaded configuration");
// Load the graph schemas.
this.schemas = {
management: getManagementSchema(),
tenant: getTenantSchema(),
};
this.schema = getTenantSchema();
}
/**
* connect will connect to all the databases and start priming data needed for
* runtime.
*/
public async connect() {
// Guard against double connecting.
if (this.connected) {
@@ -88,12 +90,12 @@ class Server {
this.mongo = await createMongoDB(config);
// Setup Redis.
this.redis = await createRedisClient(config);
this.redis = await createAugmentedRedisClient(config);
// Create the TenantCache.
this.tenantCache = new TenantCache(
this.mongo,
await createRedisClient(this.config),
createRedisClient(this.config),
config
);
@@ -101,7 +103,7 @@ class Server {
await this.tenantCache.primeAll();
// Create the Job Queue.
this.queue = await createQueue({
this.tasks = await createQueue({
config: this.config,
mongo: this.mongo,
tenantCache: this.tenantCache,
@@ -124,8 +126,9 @@ class Server {
await ensureIndexes(this.mongo);
}
this.queue.mailer.process();
this.queue.scraper.process();
// Launch all of the job processors.
this.tasks.mailer.process();
this.tasks.scraper.process();
}
/**
@@ -163,17 +166,19 @@ class Server {
redis: this.redis,
signingConfig,
tenantCache: this.tenantCache,
queue: this.queue,
config: this.config,
schemas: this.schemas,
schema: this.schema,
i18n,
mailerQueue: this.tasks.mailer,
scraperQueue: this.tasks.scraper,
});
// Start the application and store the resulting http.Server.
// Start the application and store the resulting http.Server. The server
// will return when the server starts listening. The NodeJS application will
// not exit until all tasks are handled, which for an open socket, is never.
this.httpServer = await listenAndServe(app, port);
// Setup the websocket servers on the new http.Server.
attachSubscriptionHandlers(this.schemas, this.httpServer);
// TODO: (wyattjoh) add the subscription handler here
logger.info({ port }, "now listening");
}
+4 -5
View File
@@ -6,8 +6,8 @@ error-commentBodyExceedsMaxLength =
error-storyURLNotPermitted =
The specified story URL does not exist in the permitted domains list.
error-duplicateStoryURL = The specified story URL already exists.
error-tenantNotFound = Tenant hostname ({$hostname}) not found
error-userNotFound = User ({$userID}) not found
error-tenantNotFound = Tenant hostname ({$hostname}) not found.
error-userNotFound = User ({$userID}) not found.
error-notFound = Unrecognized request URL ({$method} {$path}).
error-tokenInvalid = Invalid API Token provided: {$token}
@@ -16,7 +16,6 @@ error-emailAlreadySet = Email address has already been set.
error-emailNotSet = Email address has not been set yet.
error-duplicateUser =
Specified user already exists with a different login method.
error-duplicateUsername = Specified username has already been taken.
error-duplicateEmail = Specified email address is already in use.
error-localProfileAlreadySet =
Specified account already has a password set.
@@ -31,8 +30,6 @@ error-usernameTooShort =
Username must have at least {$min} characters.
error-passwordTooShort =
Password must have at least {$min} characters.
error-displayNameExceedsMaxLength =
Display Name exceeds maximum length of {$max} characters.
error-emailInvalidFormat =
Provided email address does not appear to be a valid email.
error-emailExceedsMaxLength =
@@ -40,3 +37,5 @@ error-emailExceedsMaxLength =
error-internalError = Internal Error
error-tenantInstalledAlready = Tenant has already been installed already.
error-userNotEntitled = You are not authorized to access that resource.
error-storyNotFound = Story ({$storyID}) not found.
error-commentNotFound = Comment ({$commentID}) not found.
+2 -2
View File
@@ -17,8 +17,8 @@ import {
} from "talk-server/models/helpers/query";
import { TenantResource } from "talk-server/models/tenant";
function collection(db: Db) {
return db.collection<Readonly<CommentAction>>("commentActions");
function collection(mongo: Db) {
return mongo.collection<Readonly<CommentAction>>("commentActions");
}
export enum ACTION_TYPE {
@@ -6,8 +6,7 @@ import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__
import {
Connection,
ConnectionInput,
getPageInfo,
nodesToEdges,
resolveConnection,
} from "talk-server/models/helpers/connection";
import Query, {
createConnectionOrderVariants,
@@ -15,8 +14,8 @@ import Query, {
} from "talk-server/models/helpers/query";
import { TenantResource } from "talk-server/models/tenant";
function collection(db: Db) {
return db.collection<Readonly<CommentModerationAction>>(
function collection(mongo: Db) {
return mongo.collection<Readonly<CommentModerationAction>>(
"commentModerationActions"
);
}
@@ -157,34 +156,6 @@ async function retrieveConnection(
query.where({ createdAt: { $lt: input.after as Date } });
}
// We load one more than the limit so we can determine if there is
// another page of entries. This gets trimmed off below after we've checked to
// see if this constitutes another page of edges.
query.first(input.first + 1);
// Get the cursor.
const cursor = await query.exec();
// Get the comments from the cursor.
const nodes = await cursor.toArray();
// Convert the nodes to edges (which will include the extra edge we don't need
// if there is more results).
const edges = nodesToEdges(nodes, a => a.createdAt);
// Get the pageInfo for the connection. We will use this to also determine if
// we need to trim off the extra edge that we requested by comparing its
// hasNextPage parameter.
const pageInfo = getPageInfo(input, edges);
if (pageInfo.hasNextPage) {
// Because this means that we got one more than expected, we should trim off
// the extra edge that was retrieved.
edges.splice(input.first, 1);
}
// Return the connection.
return {
edges,
pageInfo,
};
// Return a connection.
return resolveConnection(query, input, a => a.createdAt);
}
+16 -53
View File
@@ -15,10 +15,11 @@ import {
import {
Connection,
createConnection,
getPageInfo,
doesNotContainNull,
nodesToEdges,
NodeToCursorTransformer,
OrderedConnectionInput,
resolveConnection,
} from "talk-server/models/helpers/connection";
import Query, {
createConnectionOrderVariants,
@@ -123,6 +124,11 @@ export interface Comment extends TenantResource {
*/
replyCount: number;
/**
* metadata stores the deep Comment properties.
*/
metadata?: Record<string, any>;
/**
* createdAt is the date that this Comment was created.
*/
@@ -133,11 +139,6 @@ export interface Comment extends TenantResource {
* undefined, this Comment is not deleted.
*/
deletedAt?: Date;
/**
* metadata stores the deep Comment properties.
*/
metadata?: Record<string, any>;
}
export async function createCommentIndexes(mongo: Db) {
@@ -550,6 +551,7 @@ export async function retrieveCommentParentsConnection(
return {
edges: [{ node: parent, cursor: 1 }],
nodes: [parent],
pageInfo: {
hasNextPage: false,
hasPreviousPage: comment.grandparentIDs.length > 0,
@@ -566,29 +568,26 @@ export async function retrieveCommentParentsConnection(
const parentIDSubset = parentIDs.slice(skip, skip + limit);
// Retrieve the parents via the subset list.
const parents = await retrieveManyComments(mongo, tenantID, parentIDSubset);
const nodes = await retrieveManyComments(mongo, tenantID, parentIDSubset);
// Loop over the list to ensure that none of the entries is null (indicating
// that there was a misplaced parent). We can assert the type here because we
// will throw an error and abort if one of the comments are null.
parents.forEach(parentComment => {
if (!parentComment) {
// TODO: (wyattjoh) replace with a better error.
throw new Error("parent id specified does not exist");
}
return true;
});
if (!doesNotContainNull(nodes)) {
// TODO: (wyattjoh) replace with a better error.
throw new Error("parent id specified does not exist");
}
const edges = nodesToEdges(
// We can't have a null parent after the forEach filter above.
parents as Array<Readonly<Comment>>,
nodes,
(_, index) => index + skip + 1
).reverse();
// Return the resolved connection.
return {
edges,
nodes,
pageInfo: {
hasNextPage: false,
hasPreviousPage: parentIDs.length > limit + skip,
@@ -721,44 +720,8 @@ async function retrieveConnection(
// Apply some sorting options.
applyInputToQuery(input, query);
// We load one more than the limit so we can determine if there is
// another page of entries. This gets trimmed off below after we've checked to
// see if this constitutes another page of edges.
query.first(input.first + 1);
// Get the cursor.
const cursor = await query.exec();
// Get the comments from the cursor.
const nodes = await cursor.toArray();
// Return a connection.
return convertNodesToConnection(input, nodes);
}
export function convertNodesToConnection(
input: CommentConnectionInput,
nodes: Array<Readonly<Comment>>
) {
// Convert the nodes to edges (which will include the extra edge we don't need
// if there is more results).
const edges = nodesToEdges(nodes, cursorGetterFactory(input));
// Get the pageInfo for the connection. We will use this to also determine if
// we need to trim off the extra edge that we requested by comparing its
// hasNextPage parameter.
const pageInfo = getPageInfo(input, edges);
if (pageInfo.hasNextPage) {
// Because this means that we got one more than expected, we should trim off
// the extra edge that was retrieved.
edges.splice(input.first, 1);
}
// Return the connection.
return {
edges,
pageInfo,
};
return resolveConnection(query, input, cursorGetterFactory(input));
}
function applyInputToQuery(
+55 -3
View File
@@ -1,6 +1,6 @@
import { merge } from "lodash";
import { FilterQuery } from "talk-server/models/helpers/query";
import Query, { FilterQuery } from "talk-server/models/helpers/query";
export type Cursor = Date | number | string | null;
@@ -10,14 +10,15 @@ export interface Edge<T> {
}
export interface PageInfo {
hasNextPage?: boolean;
hasPreviousPage?: boolean;
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor?: Cursor;
endCursor?: Cursor;
}
export interface Connection<T> {
edges: Array<Edge<T>>;
nodes: T[];
pageInfo: PageInfo;
}
@@ -59,6 +60,7 @@ export function createConnection<T>(
return merge(
{
edges: [],
nodes: [],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
@@ -117,3 +119,53 @@ export function nodesToEdges<T>(
cursor: transformer(node, index),
}));
}
export function doesNotContainNull<T>(items: Array<T | null>): items is T[] {
return items.every(item => Boolean(item));
}
/**
* resolveConnection will transform a query, pagination args into a full typed
* connection. This will add `1` to the length of elements being retrieved to
* determine if there is another page of results to be loaded. The additional
* node requested will be trimmed from the connection output.
*
* @param query the query to use when retrieving the documents.
* @param input the pagination arguments
* @param transformer the node transformer which converts a node to a custor
*/
export async function resolveConnection<T>(
query: Query<T>,
input: PaginationArgs,
transformer: NodeToCursorTransformer<T>
) {
// We load one more than the limit so we can determine if there is another
// page of entries. This gets trimmed off below after we've checked to see if
// this constitutes another page of edges.
query.first(input.first + 1);
// Get the nodes.
const nodes = await query.exec().then(cursor => cursor.toArray());
// Convert the nodes to edges (which will include the extra edge we don't need
// if there is more results).
const edges = nodesToEdges(nodes, transformer);
// Get the pageInfo for the connection. We will use this to also determine if
// we need to trim off the extra edge that we requested by comparing its
// hasNextPage parameter.
const pageInfo = getPageInfo(input, edges);
if (pageInfo.hasNextPage) {
// Because this means that we got one more than expected, we should trim off
// the extra edge that was retrieved.
edges.splice(input.first, 1);
nodes.splice(input.first, 1);
}
// Return the connection.
return {
edges,
nodes,
pageInfo,
};
}
+14 -2
View File
@@ -1,4 +1,5 @@
import { merge } from "lodash";
import { isUndefined, merge, omitBy } from "lodash";
import {
Collection,
Cursor,
@@ -38,7 +39,7 @@ export default class Query<T> {
* @param filter the filter to merge into the existing query
*/
public where(filter: FilterQuery<T>): Query<T> {
this.filter = merge({}, this.filter || {}, filter);
this.filter = merge({}, this.filter || {}, omitBy(filter, isUndefined));
return this;
}
@@ -76,6 +77,17 @@ export default class Query<T> {
* exec will return a cursor to the query.
*/
public async exec(): Promise<Cursor<T>> {
logger.trace(
{
collection: this.collection.collectionName,
filter: this.filter,
limit: this.limit,
sort: this.sort,
skip: this.skip,
},
"executing query"
);
let cursor = await this.collection.find(this.filter);
if (this.limit) {
-7
View File
@@ -1,6 +1,5 @@
import { Omit } from "talk-common/types";
import {
GQLAuthDisplayNameConfiguration,
GQLCharCount,
GQLDisableCommenting,
GQLEmail,
@@ -71,12 +70,6 @@ export interface Auth {
* authentication solutions.
*/
integrations: AuthIntegrations;
/**
* displayName contains configuration related to the use of Display Names
* across AuthIntegrations.
*/
displayName: GQLAuthDisplayNameConfiguration;
}
export interface Settings extends ModerationSettings {
+2 -2
View File
@@ -20,8 +20,8 @@ import { updateSharedCommentCounts } from "./shared";
* collection provides a reference to the stories collection used by the
* counting system.
*/
function collection<T = Story>(db: Db) {
return db.collection<Readonly<T>>("stories");
function collection<T = Story>(mongo: Db) {
return mongo.collection<Readonly<T>>("stories");
}
export async function createStoryCountIndexes(mongo: Db) {
@@ -47,8 +47,8 @@ const commentCountsModerationQueueQueuesKey = (tenantID: string) =>
* collection provides a reference to the stories collection used by the
* counting system.
*/
function collection<T = Story>(db: Db) {
return db.collection<Readonly<T>>("stories");
function collection<T = Story>(mongo: Db) {
return mongo.collection<Readonly<T>>("stories");
}
/**
+71 -20
View File
@@ -5,10 +5,18 @@ import { Omit } from "talk-common/types";
import { dotize } from "talk-common/utils/dotize";
import { DuplicateStoryURLError } from "talk-server/errors";
import { GQLStoryMetadata } from "talk-server/graph/tenant/schema/__generated__/types";
import { createIndexFactory } from "talk-server/models/helpers/query";
import Query, {
createConnectionOrderVariants,
createIndexFactory,
} from "talk-server/models/helpers/query";
import { ModerationSettings } from "talk-server/models/settings";
import { TenantResource } from "talk-server/models/tenant";
import {
Connection,
ConnectionInput,
resolveConnection,
} from "../helpers/connection";
import {
createEmptyCommentModerationQueueCounts,
createEmptyCommentStatusCounts,
@@ -18,8 +26,8 @@ import {
// Export everything under counts.
export * from "./counts";
function collection<T = Story>(db: Db) {
return db.collection<Readonly<T>>("stories");
function collection<T = Story>(mongo: Db) {
return mongo.collection<Readonly<T>>("stories");
}
export interface Story extends TenantResource {
@@ -71,6 +79,17 @@ export async function createStoryIndexes(mongo: Db) {
// UNIQUE { url }
await createIndex({ tenantID: 1, url: 1 }, { unique: true });
const variants = createConnectionOrderVariants<Readonly<Story>>([
{ createdAt: -1 },
]);
// Story based Comment Connection pagination.
// { closedAt, ...connectionParams }
await variants(createIndex, {
tenantID: 1,
closedAt: 1,
});
}
export interface UpsertStoryInput {
@@ -79,7 +98,7 @@ export interface UpsertStoryInput {
}
export async function upsertStory(
db: Db,
mongo: Db,
tenantID: string,
{ id, url }: UpsertStoryInput
) {
@@ -103,7 +122,7 @@ export async function upsertStory(
// Perform the find and update operation to try and find and or create the
// story.
const result = await collection(db).findOneAndUpdate(
const result = await collection(mongo).findOneAndUpdate(
{
url,
tenantID,
@@ -128,21 +147,21 @@ export interface FindOrCreateStoryInput {
}
export async function findOrCreateStory(
db: Db,
mongo: Db,
tenantID: string,
{ id, url }: FindOrCreateStoryInput
) {
if (id) {
if (url) {
// The URL was specified, this is an upsert operation.
return upsertStory(db, tenantID, {
return upsertStory(mongo, tenantID, {
id,
url,
});
}
// The URL was not specified, this is a lookup operation.
return retrieveStory(db, tenantID, id);
return retrieveStory(mongo, tenantID, id);
}
// The ID was not specified, this is an upsert operation. Check to see that
@@ -151,10 +170,10 @@ export async function findOrCreateStory(
throw new Error("cannot upsert an story without the url");
}
return upsertStory(db, tenantID, { url });
return upsertStory(mongo, tenantID, { url });
}
export type CreateStoryInput = Partial<Pick<Story, "metadata">>;
export type CreateStoryInput = Partial<Pick<Story, "metadata" | "scrapedAt">>;
export async function createStory(
mongo: Db,
@@ -197,23 +216,23 @@ export async function createStory(
}
export async function retrieveStoryByURL(
db: Db,
mongo: Db,
tenantID: string,
url: string
) {
return collection(db).findOne({ url, tenantID });
return collection(mongo).findOne({ url, tenantID });
}
export async function retrieveStory(db: Db, tenantID: string, id: string) {
return collection(db).findOne({ id, tenantID });
export async function retrieveStory(mongo: Db, tenantID: string, id: string) {
return collection(mongo).findOne({ id, tenantID });
}
export async function retrieveManyStories(
db: Db,
mongo: Db,
tenantID: string,
ids: string[]
) {
const cursor = await collection(db).find({
const cursor = await collection(mongo).find({
id: { $in: ids },
tenantID,
});
@@ -224,11 +243,11 @@ export async function retrieveManyStories(
}
export async function retrieveManyStoriesByURL(
db: Db,
mongo: Db,
tenantID: string,
urls: string[]
) {
const cursor = await collection(db).find({
const cursor = await collection(mongo).find({
url: { $in: urls },
tenantID,
});
@@ -244,7 +263,7 @@ export type UpdateStoryInput = Omit<
>;
export async function updateStory(
db: Db,
mongo: Db,
tenantID: string,
id: string,
input: UpdateStoryInput
@@ -259,7 +278,7 @@ export async function updateStory(
};
try {
const result = await collection(db).findOneAndUpdate(
const result = await collection(mongo).findOneAndUpdate(
{ id, tenantID },
update,
// False to return the updated document instead of the original
@@ -304,3 +323,35 @@ export async function removeStories(
},
});
}
export type StoryConnectionInput = ConnectionInput<Story>;
export async function retrieveStoryConnection(
mongo: Db,
tenantID: string,
input: StoryConnectionInput
): Promise<Readonly<Connection<Readonly<Story>>>> {
// Create the query.
const query = new Query(collection(mongo)).where({ tenantID });
// If a filter is being applied, filter it as well.
if (input.filter) {
query.where(input.filter);
}
return retrieveConnection(input, query);
}
async function retrieveConnection(
input: StoryConnectionInput,
query: Query<Story>
): Promise<Readonly<Connection<Readonly<Story>>>> {
// Apply the pagination arguments to the query.
query.orderBy({ createdAt: -1 });
if (input.after) {
query.where({ createdAt: { $lt: input.after as Date } });
}
// Return a connection.
return resolveConnection(query, input, story => story.createdAt);
}
+19 -19
View File
@@ -9,8 +9,8 @@ import { GQLMODERATION_MODE } from "talk-server/graph/tenant/schema/__generated_
import { createIndexFactory } from "talk-server/models/helpers/query";
import { Settings } from "talk-server/models/settings";
function collection(db: Db) {
return db.collection<Readonly<Tenant>>("tenants");
function collection(mongo: Db) {
return mongo.collection<Readonly<Tenant>>("tenants");
}
export interface TenantResource {
@@ -110,9 +110,6 @@ export async function createTenant(mongo: Db, input: CreateTenantInput) {
banned: [],
},
auth: {
displayName: {
enabled: false,
},
integrations: {
local: {
enabled: true,
@@ -200,16 +197,16 @@ export async function createTenant(mongo: Db, input: CreateTenantInput) {
return tenant;
}
export async function retrieveTenantByDomain(db: Db, domain: string) {
return collection(db).findOne({ domain });
export async function retrieveTenantByDomain(mongo: Db, domain: string) {
return collection(mongo).findOne({ domain });
}
export async function retrieveTenant(db: Db, id: string) {
return collection(db).findOne({ id });
export async function retrieveTenant(mongo: Db, id: string) {
return collection(mongo).findOne({ id });
}
export async function retrieveManyTenants(db: Db, ids: string[]) {
const cursor = await collection(db).find({
export async function retrieveManyTenants(mongo: Db, ids: string[]) {
const cursor = await collection(mongo).find({
id: {
$in: ids,
},
@@ -220,8 +217,11 @@ export async function retrieveManyTenants(db: Db, ids: string[]) {
return ids.map(id => tenants.find(tenant => tenant.id === id) || null);
}
export async function retrieveManyTenantsByDomain(db: Db, domains: string[]) {
const cursor = await collection(db).find({
export async function retrieveManyTenantsByDomain(
mongo: Db,
domains: string[]
) {
const cursor = await collection(mongo).find({
domain: {
$in: domains,
},
@@ -234,8 +234,8 @@ export async function retrieveManyTenantsByDomain(db: Db, domains: string[]) {
);
}
export async function retrieveAllTenants(db: Db) {
return collection(db)
export async function retrieveAllTenants(mongo: Db) {
return collection(mongo)
.find({})
.toArray();
}
@@ -249,12 +249,12 @@ export async function countTenants(mongo: Db) {
export type UpdateTenantInput = Omit<DeepPartial<Tenant>, "id" | "domain">;
export async function updateTenant(
db: Db,
mongo: Db,
id: string,
update: UpdateTenantInput
) {
// Get the tenant from the database.
const result = await collection(db).findOneAndUpdate(
const result = await collection(mongo).findOneAndUpdate(
{ id },
// Only update fields that have been updated.
{ $set: dotize(update, { embedArrays: true }) },
@@ -279,7 +279,7 @@ function generateSSOKey() {
* for the specified Tenant. All existing user sessions signed with the old
* secret will be invalidated.
*/
export async function regenerateTenantSSOKey(db: Db, id: string) {
export async function regenerateTenantSSOKey(mongo: Db, id: string) {
// Construct the update.
const update: DeepPartial<Tenant> = {
auth: {
@@ -293,7 +293,7 @@ export async function regenerateTenantSSOKey(db: Db, id: string) {
};
// Update the Tenant with this new key.
const result = await collection(db).findOneAndUpdate(
const result = await collection(mongo).findOneAndUpdate(
{ id },
// Serialize the deep update into the Tenant.
{
+62 -99
View File
@@ -6,7 +6,6 @@ import { Omit, Sub } from "talk-common/types";
import {
DuplicateEmailError,
DuplicateUserError,
DuplicateUsernameError,
LocalProfileAlreadySetError,
LocalProfileNotSetError,
TokenNotFoundError,
@@ -14,11 +13,17 @@ import {
UserNotFoundError,
} from "talk-server/errors";
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
import {
import Query, {
createConnectionOrderVariants,
createIndexFactory,
FilterQuery,
} from "talk-server/models/helpers/query";
import { TenantResource } from "talk-server/models/tenant";
import {
Connection,
ConnectionInput,
resolveConnection,
} from "./helpers/connection";
function collection(mongo: Db) {
return mongo.collection<Readonly<User>>("users");
@@ -68,8 +73,6 @@ export interface Token {
export interface User extends TenantResource {
readonly id: string;
username?: string;
lowercaseUsername?: string;
displayName?: string;
avatar?: string;
email?: string;
emailVerified?: boolean;
@@ -85,15 +88,6 @@ export async function createUserIndexes(mongo: Db) {
// UNIQUE { id }
await createIndex({ tenantID: 1, id: 1 }, { unique: true });
// UNIQUE - PARTIAL { lowercaseUsername }
await createIndex(
{ tenantID: 1, lowercaseUsername: 1 },
{
unique: true,
partialFilterExpression: { lowercaseUsername: { $exists: true } },
}
);
// UNIQUE - PARTIAL { email }
await createIndex(
{ tenantID: 1, email: 1 },
@@ -105,6 +99,17 @@ export async function createUserIndexes(mongo: Db) {
{ tenantID: 1, "profiles.type": 1, "profiles.id": 1 },
{ unique: true }
);
const variants = createConnectionOrderVariants<Readonly<User>>([
{ createdAt: -1 },
]);
// Story based Comment Connection pagination.
// { role, ...connectionParams }
await variants(createIndex, {
tenantID: 1,
role: 1,
});
}
function hashPassword(password: string): Promise<string> {
@@ -113,11 +118,11 @@ function hashPassword(password: string): Promise<string> {
export type UpsertUserInput = Omit<
User,
"id" | "tenantID" | "tokens" | "createdAt" | "lowercaseUsername"
"id" | "tenantID" | "tokens" | "createdAt"
>;
export async function upsertUser(
db: Db,
mongo: Db,
tenantID: string,
input: UpsertUserInput
) {
@@ -152,11 +157,6 @@ export async function upsertUser(
profiles.push(profile);
}
// Add in the lowercase username if it was sent.
if (input.username) {
defaults.lowercaseUsername = input.username.toLowerCase();
}
// Merge the defaults and the input together.
const user: Readonly<User> = {
...defaults,
@@ -176,7 +176,7 @@ export async function upsertUser(
};
// Insert it into the database. This may throw an error.
const result = await collection(db).findOneAndUpdate(filter, update, {
const result = await collection(mongo).findOneAndUpdate(filter, update, {
// We are using this to create a user, so we need to upsert it.
upsert: true,
@@ -210,16 +210,16 @@ const createUpsertUserFilter = (user: Readonly<User>) => {
return query;
};
export async function retrieveUser(db: Db, tenantID: string, id: string) {
return collection(db).findOne({ tenantID, id });
export async function retrieveUser(mongo: Db, tenantID: string, id: string) {
return collection(mongo).findOne({ tenantID, id });
}
export async function retrieveManyUsers(
db: Db,
mongo: Db,
tenantID: string,
ids: string[]
) {
const cursor = await collection(db).find({
const cursor = await collection(mongo).find({
id: {
$in: ids,
},
@@ -232,11 +232,11 @@ export async function retrieveManyUsers(
}
export async function retrieveUserWithProfile(
db: Db,
mongo: Db,
tenantID: string,
profile: Partial<Pick<Profile, "id" | "type">>
) {
return collection(db).findOne({
return collection(mongo).findOne({
tenantID,
profiles: {
$elemMatch: profile,
@@ -350,17 +350,7 @@ export async function setUserUsername(
id: string,
username: string
) {
// Lowercase the username.
const lowercaseUsername = username.toLowerCase();
// Search to see if this username has been used before.
let user = await collection(mongo).findOne({
tenantID,
lowercaseUsername,
});
if (user) {
throw new DuplicateUsernameError(username);
}
// TODO: (wyattjoh) investigate adding the username previously used to an array.
// The username wasn't found, so add it to the user.
const result = await collection(mongo).findOneAndUpdate(
@@ -372,7 +362,6 @@ export async function setUserUsername(
{
$set: {
username,
lowercaseUsername,
},
},
{
@@ -383,7 +372,7 @@ export async function setUserUsername(
);
if (!result.value) {
// Try to get the current user to discover what happened.
user = await retrieveUser(mongo, tenantID, id);
const user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
@@ -412,17 +401,7 @@ export async function updateUserUsername(
id: string,
username: string
) {
// Lowercase the username.
const lowercaseUsername = username.toLowerCase();
// Search to see if this username has been used before.
let user = await collection(mongo).findOne({
tenantID,
lowercaseUsername,
});
if (user) {
throw new DuplicateUsernameError(username);
}
// TODO: (wyattjoh) investigate adding the username previously used to an array.
// The username wasn't found, so add it to the user.
const result = await collection(mongo).findOneAndUpdate(
@@ -433,54 +412,6 @@ export async function updateUserUsername(
{
$set: {
username,
lowercaseUsername,
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
// Try to get the current user to discover what happened.
user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
throw new Error("an unexpected error occured");
}
return result.value;
}
/**
* updateUserDisplayName will set the displayName of the User. If the display
* name is not provided, it will be unset.
*
* @param mongo the database handle
* @param tenantID the ID to the Tenant
* @param id the ID of the User where we are setting the displayName on
* @param displayName the displayName that we want to set
*/
export async function updateUserDisplayName(
mongo: Db,
tenantID: string,
id: string,
displayName?: string
) {
// The username wasn't found, so add it to the user.
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
id,
},
{
// This will ensure that if the display name isn't provided, it will unset
// the display name on the User.
[displayName ? "$set" : "$unset"]: {
displayName: displayName ? displayName : 1,
},
},
{
@@ -832,3 +763,35 @@ export async function deactivateUserToken(
token,
};
}
export type UserConnectionInput = ConnectionInput<User>;
export async function retrieveUserConnection(
mongo: Db,
tenantID: string,
input: UserConnectionInput
): Promise<Readonly<Connection<Readonly<User>>>> {
// Create the query.
const query = new Query(collection(mongo)).where({ tenantID });
// If a filter is being applied, filter it as well.
if (input.filter) {
query.where(input.filter);
}
return retrieveConnection(input, query);
}
async function retrieveConnection(
input: UserConnectionInput,
query: Query<User>
): Promise<Readonly<Connection<Readonly<User>>>> {
// Apply the pagination arguments to the query.
query.orderBy({ createdAt: -1 });
if (input.after) {
query.where({ createdAt: { $lt: input.after as Date } });
}
// Return a connection.
return resolveConnection(query, input, user => user.createdAt);
}
+1 -1
View File
@@ -26,7 +26,7 @@ export default class Task<T, U = any> {
*
* @param data the data for the job to add.
*/
public async add(data: T) {
public async add(data: T): Promise<Queue.Job<T> | undefined> {
const job = await this.queue.add(data, {
// We always remove the job when it's complete, no need to fill up Redis
// with completed entries if we don't need to.
+7 -9
View File
@@ -2,11 +2,10 @@ import Queue from "bull";
import { Db } from "mongodb";
import { Config } from "talk-server/config";
import Task from "talk-server/queue/Task";
import { createMailerTask, Mailer } from "talk-server/queue/tasks/mailer";
import { createMailerTask, MailerQueue } from "talk-server/queue/tasks/mailer";
import {
createScraperTask,
ScraperData,
ScraperQueue,
} from "talk-server/queue/tasks/scraper";
import { createRedisClient } from "talk-server/services/redis";
import TenantCache from "talk-server/services/tenant/cache";
@@ -14,9 +13,8 @@ import TenantCache from "talk-server/services/tenant/cache";
const createQueueOptions = async (
config: Config
): Promise<Queue.QueueOptions> => {
const client = await createRedisClient(config);
const subscriber = await createRedisClient(config);
const blockingClient = await createRedisClient(config);
const client = createRedisClient(config);
const subscriber = createRedisClient(config);
// Return the options that can be used by the Queue.
return {
@@ -30,7 +28,7 @@ const createQueueOptions = async (
case "client":
return client;
case "bclient":
return blockingClient;
return createRedisClient(config);
}
},
@@ -49,8 +47,8 @@ export interface QueueOptions {
}
export interface TaskQueue {
mailer: Mailer;
scraper: Task<ScraperData>;
mailer: MailerQueue;
scraper: ScraperQueue;
}
export async function createQueue(options: QueueOptions): Promise<TaskQueue> {
+2 -2
View File
@@ -137,7 +137,7 @@ export interface MailerInput {
tenantID: string;
}
export class Mailer {
export class MailerQueue {
private task: Task<MailerData>;
private content: MailerContent;
private tenantCache: TenantCache;
@@ -205,4 +205,4 @@ export class Mailer {
export const createMailerTask = (
queue: Queue.QueueOptions,
options: MailProcessorOptions
) => new Mailer(queue, options);
) => new MailerQueue(queue, options);
@@ -1,5 +1,6 @@
import Queue, { Job } from "bull";
import { Db } from "mongodb";
import now from "performance-now";
import logger from "talk-server/logger";
import Task from "talk-server/queue/Task";
@@ -31,6 +32,9 @@ const createJobProcessor = ({ mongo }: ScrapeProcessorOptions) => async (
tenantID,
});
// Mark the start time.
const startTime = now();
log.debug("starting to scrape the story");
try {
@@ -41,8 +45,15 @@ const createJobProcessor = ({ mongo }: ScrapeProcessorOptions) => async (
throw err;
}
// Compute the end time.
const responseTime = Math.round(now() - startTime);
log.debug({ responseTime }, "scraped the story");
};
export type ScraperQueue = Task<ScraperData>;
export function createScraperTask(
queue: Queue.QueueOptions,
options: ScrapeProcessorOptions
@@ -90,7 +90,7 @@ export const spam: IntermediateModerationPhase = async ({
user_agent: userAgent, // REQUIRED
comment_content: comment.body,
permalink: story.url,
comment_author: author.displayName || author.username || "",
comment_author: author.username || "",
comment_type: "comment",
is_test: false,
});
+3 -4
View File
@@ -4,6 +4,7 @@ import { Bearer } from "permit";
import uuid from "uuid/v4";
import { Config } from "talk-server/config";
import { AuthenticationError } from "talk-server/errors";
import { User } from "talk-server/models/user";
import { Request } from "talk-server/types/express";
@@ -78,8 +79,7 @@ export function createJWTSigningConfig(config: Config): JWTSigningConfig {
return createAsymmetricSigningConfig(algorithm, secret);
}
// TODO: (wyattjoh) return better error.
throw new Error("invalid algorithm specified");
throw new AuthenticationError(`invalid algorithm=${algorithm} specified`);
}
export type SigningTokenOptions = Pick<
@@ -137,7 +137,6 @@ export async function revokeJWT(redis: Redis, jti: string, validFor: number) {
export async function checkJWTRevoked(redis: Redis, jti: string) {
const expiredAtString = await redis.get(generateJTIRevokedKey(jti));
if (expiredAtString) {
// TODO: (wyattjoh) return a better error.
throw new Error("JWT was revoked");
throw new AuthenticationError("JWT was revoked");
}
}
+36 -18
View File
@@ -16,12 +16,7 @@ export type AugmentedRedis = Omit<Redis, "pipeline"> &
pipeline(commands?: string[][]): AugmentedPipeline;
};
function configureRedisClient(redis: Redis) {
// Attach to the error event.
redis.on("error", (err: Error) => {
logger.error({ err }, "an error occurred with redis");
});
function augmentRedisClient(redis: Redis): AugmentedRedis {
// mhincrby will increment many hash values.
redis.defineCommand("mhincrby", {
numberOfKeys: 1,
@@ -31,28 +26,51 @@ function configureRedisClient(redis: Redis) {
end
`,
});
return redis as AugmentedRedis;
}
/**
* create will connect to the Redis instance identified in the configuration.
*
* @param config application configuration.
*/
export async function createRedisClient(
config: Config
): Promise<AugmentedRedis> {
function attachHandlers(redis: Redis) {
// Attach to the error event.
redis.on("error", (err: Error) => {
logger.error({ err }, "an error occurred with redis");
});
}
export function createRedisClient(
config: Config,
lazyConnect: boolean = false
): Redis {
try {
const redis = new RedisClient(config.get("redis"), {
lazyConnect: true,
lazyConnect,
});
// Configure the redis client for use with the custom commands.
configureRedisClient(redis);
// Configure the redis client with the handlers for logging
attachHandlers(redis);
return redis;
} catch (err) {
throw new InternalError(err, "could not connect to redis");
}
}
/**
* createAugmentedRedisClient will connect to the Redis instance identified in
* the configuration.
*
* @param config application configuration.
*/
export async function createAugmentedRedisClient(
config: Config
): Promise<AugmentedRedis> {
try {
const redis = augmentRedisClient(createRedisClient(config, true));
// Connect the redis client.
await redis.connect();
return redis as AugmentedRedis;
return redis;
} catch (err) {
throw new InternalError(err, "could not connect to redis");
}
+13 -7
View File
@@ -38,8 +38,7 @@ import {
UpdateStoryInput,
} from "talk-server/models/story";
import { Tenant } from "talk-server/models/tenant";
import Task from "talk-server/queue/Task";
import { ScraperData } from "talk-server/queue/tasks/scraper";
import { ScraperQueue } from "talk-server/queue/tasks/scraper";
import { scrape } from "talk-server/services/stories/scraper";
import { AugmentedRedis } from "../redis";
@@ -47,10 +46,10 @@ import { AugmentedRedis } from "../redis";
export type FindOrCreateStory = FindOrCreateStoryInput;
export async function findOrCreate(
db: Db,
mongo: Db,
tenant: Tenant,
input: FindOrCreateStory,
scraper: Task<ScraperData>
scraper: ScraperQueue
) {
// If the URL is provided, and the url is not on a allowed domain, then refuse
// to create the Asset.
@@ -63,7 +62,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(db, tenant.id, input);
const story = await findOrCreateStory(mongo, tenant.id, input);
if (!story) {
return null;
}
@@ -186,16 +185,22 @@ export async function create(
tenant: Tenant,
storyID: string,
storyURL: string,
input: CreateStory
{ metadata }: CreateStory
) {
// Ensure that the given URL is allowed.
if (!isURLPermitted(tenant, storyURL)) {
throw new StoryURLInvalidError({ storyURL, tenantDomains: tenant.domains });
}
// Construct the input payload.
const input: CreateStoryInput = { metadata };
if (metadata) {
input.scrapedAt = new Date();
}
// Create the story in the database.
let newStory = await createStory(mongo, tenant.id, storyID, storyURL, input);
if (!input.metadata && !newStory.scrapedAt) {
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);
@@ -324,6 +329,7 @@ export async function merge(
"updated destination story with new comment counts"
);
// Remove the stories from MongoDB.
const { deletedCount } = await removeStories(mongo, tenant.id, sourceIDs);
log.debug({ deletedStories: deletedCount }, "deleted source stories");
-43
View File
@@ -8,7 +8,6 @@ import {
USERNAME_REGEX,
} from "talk-common/helpers/validate";
import {
DisplayNameExceedsMaxLengthError,
EmailAlreadySetError,
EmailExceedsMaxLengthError,
EmailInvalidFormatError,
@@ -32,7 +31,6 @@ import {
setUserLocalProfile,
setUserUsername,
updateUserAvatar,
updateUserDisplayName,
updateUserEmail,
updateUserPassword,
updateUserRole,
@@ -71,25 +69,6 @@ function validateUsername(tenant: Tenant, username: string) {
}
}
const DISPLAY_NAME_MAX_LENGTH = USERNAME_MAX_LENGTH;
/**
* validateDisplayName will validate that the username is valid.
*
* @param tenant tenant where the User is associated with
* @param displayName the display name to be tested
*/
function validateDisplayName(tenant: Tenant, displayName: string) {
// TODO: replace these static regex/length with database options in the Tenant eventually
if (displayName.length > DISPLAY_NAME_MAX_LENGTH) {
throw new DisplayNameExceedsMaxLengthError(
displayName.length,
DISPLAY_NAME_MAX_LENGTH
);
}
}
/**
* validatePassword will validate that the password is valid. Current
* implementation uses a length statically, future versions will expose this as
@@ -355,28 +334,6 @@ export async function updateUsername(
return updateUserUsername(mongo, tenant.id, userID, username);
}
/**
* updateDisplayName will update a given User's display name.
*
* @param mongo mongo database to interact with
* @param tenant Tenant where the User will be interacted with
* @param userID the User's ID that we are updating
* @param displayName the display name that we are setting on the User
*/
export async function updateDisplayName(
mongo: Db,
tenant: Tenant,
userID: string,
displayName?: string
) {
if (displayName) {
// Validate the display name.
validateDisplayName(tenant, displayName);
}
return updateUserDisplayName(mongo, tenant.id, userID, displayName);
}
/**
* updateRole will update the given User to the specified role.
*
-4
View File
@@ -1,6 +1,5 @@
import { NextFunction, Request as ExpressRequest, Response } from "express";
import TenantContext from "talk-server/graph/tenant/context";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
import TenantCache from "talk-server/services/tenant/cache";
@@ -10,9 +9,6 @@ export interface TalkRequest {
tenant: TenantCache;
};
tenant?: Tenant;
context?: {
tenant?: TenantContext;
};
}
export interface Request extends ExpressRequest {