mirror of
https://github.com/wassname/talk.git
synced 2026-07-29 11:28:24 +08:00
Merge branch 'next' into pym-storage
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import CaseSensitivePathsPlugin from "case-sensitive-paths-webpack-plugin";
|
||||
import CompressionPlugin from "compression-webpack-plugin";
|
||||
import HtmlWebpackPlugin, { Options } from "html-webpack-plugin";
|
||||
import MiniCssExtractPlugin from "mini-css-extract-plugin";
|
||||
import path from "path";
|
||||
@@ -111,6 +112,8 @@ export default function createWebpackConfig({
|
||||
filename: "assets/css/[name].[hash].css",
|
||||
chunkFilename: "assets/css/[id].[hash].css",
|
||||
}),
|
||||
// Pre-compress all the assets as they will be served as is.
|
||||
new CompressionPlugin({}),
|
||||
]
|
||||
: [
|
||||
// Add module names to factory functions so they appear in browser profiler.
|
||||
|
||||
@@ -19,7 +19,7 @@ export type CreateCommentInput = Omit<
|
||||
const mutation = graphql`
|
||||
mutation CreateCommentMutation($input: CreateCommentInput!) {
|
||||
createComment(input: $input) {
|
||||
commentEdge {
|
||||
edge {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
@@ -51,7 +51,7 @@ function commit(environment: Environment, input: CreateCommentInput) {
|
||||
},
|
||||
optimisticResponse: {
|
||||
createComment: {
|
||||
commentEdge: {
|
||||
edge: {
|
||||
cursor: currentDate,
|
||||
node: {
|
||||
id: uuid(),
|
||||
@@ -77,7 +77,7 @@ function commit(environment: Environment, input: CreateCommentInput) {
|
||||
},
|
||||
],
|
||||
parentID: input.assetID,
|
||||
edgeName: "commentEdge",
|
||||
edgeName: "edge",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -33,7 +33,8 @@ beforeEach(() => {
|
||||
},
|
||||
})
|
||||
.returns({
|
||||
commentEdge: {
|
||||
// TODO: add a type assertion here to ensure that if the type changes, that the test will fail
|
||||
edge: {
|
||||
cursor: "2018-07-06T18:24:00.000Z",
|
||||
node: {
|
||||
id: "comment-x",
|
||||
@@ -72,15 +73,19 @@ it("post a comment", async () => {
|
||||
.props.onChange({ html: "<strong>Hello world!</strong>" });
|
||||
|
||||
timekeeper.freeze(new Date("2018-07-06T18:24:00.000Z"));
|
||||
|
||||
testRenderer.root
|
||||
.findByProps({ id: "comments-postCommentForm-form" })
|
||||
.props.onSubmit();
|
||||
|
||||
timekeeper.reset();
|
||||
|
||||
// Test optimistic response.
|
||||
expect(testRenderer.toJSON()).toMatchSnapshot();
|
||||
timekeeper.reset();
|
||||
|
||||
// Wait for loading.
|
||||
await timeout();
|
||||
|
||||
// Test after server response.
|
||||
expect(testRenderer.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -45,14 +45,14 @@ const config = convict({
|
||||
doc: "The MongoDB database to connect to.",
|
||||
format: "mongo-uri",
|
||||
default: "mongodb://127.0.0.1:27017/talk",
|
||||
env: "MONGODB",
|
||||
env: "MONGODB_URI",
|
||||
arg: "mongodb",
|
||||
},
|
||||
redis: {
|
||||
doc: "The Redis database to connect to.",
|
||||
format: "redis-uri",
|
||||
default: "redis://127.0.0.1:6379",
|
||||
env: "REDIS",
|
||||
env: "REDIS_URI",
|
||||
arg: "redis",
|
||||
},
|
||||
signing_secret: {
|
||||
|
||||
@@ -12,4 +12,18 @@ export type Sub<T, U> = Pick<T, Diff<keyof T, keyof U>>;
|
||||
*/
|
||||
export type Writeable<T> = { -readonly [P in keyof T]: T[P] };
|
||||
|
||||
/**
|
||||
* Defines a type that may be a promise or a simple value return.
|
||||
*/
|
||||
export type Promiseable<T> = Promise<T> | T;
|
||||
|
||||
/**
|
||||
* Like Partial, but recurses down the object marking each field as Partial.
|
||||
*/
|
||||
export type DeepPartial<T> = {
|
||||
[P in keyof T]?: T[P] extends Array<infer U>
|
||||
? Array<DeepPartial<U>>
|
||||
: T[P] extends ReadonlyArray<infer V>
|
||||
? ReadonlyArray<DeepPartial<V>>
|
||||
: DeepPartial<T[P]>
|
||||
};
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { dotize } from "talk-common/utils/dotize";
|
||||
|
||||
it("converts nested properties", () => {
|
||||
const input = {
|
||||
a: "property",
|
||||
can: { be: "nested", really: { deeply: "sometimes" } },
|
||||
};
|
||||
const output = dotize(input);
|
||||
|
||||
expect(output).toEqual({
|
||||
a: "property",
|
||||
"can.be": "nested",
|
||||
"can.really.deeply": "sometimes",
|
||||
});
|
||||
});
|
||||
|
||||
it("converts properties with dates", () => {
|
||||
const now = new Date();
|
||||
const input = { a: now, can: { be: now } };
|
||||
const output = dotize(input);
|
||||
|
||||
expect(output).toEqual({
|
||||
a: now,
|
||||
"can.be": now,
|
||||
});
|
||||
});
|
||||
|
||||
it("converts array properties when enabled", () => {
|
||||
const input = {
|
||||
a: [
|
||||
{ property: "with", an: "array" },
|
||||
{ value: [{ sometimes: "nested" }] },
|
||||
],
|
||||
other: { times: "not" },
|
||||
};
|
||||
const output = dotize(input);
|
||||
|
||||
expect(output).toEqual({
|
||||
"a[0].property": "with",
|
||||
"a[0].an": "array",
|
||||
"a[1].value[0].sometimes": "nested",
|
||||
"other.times": "not",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not converts array properties when disabled", () => {
|
||||
const input = {
|
||||
a: [
|
||||
{ property: "with", an: "array" },
|
||||
{ value: [{ sometimes: "nested" }] },
|
||||
],
|
||||
other: { times: "not" },
|
||||
};
|
||||
const output = dotize(input, { ignoreArrays: true });
|
||||
|
||||
expect(output).toEqual({
|
||||
"other.times": "not",
|
||||
});
|
||||
});
|
||||
|
||||
it("does convert array properties properly", () => {
|
||||
expect(
|
||||
dotize({ wordlist: { banned: ["banned"] } }, { embedArrays: true })
|
||||
).toEqual({
|
||||
"wordlist.banned": ["banned"],
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { isArray, isNull, isNumber, isPlainObject, isString } from "lodash";
|
||||
|
||||
function isObject(obj: any): obj is Record<string, any> {
|
||||
return isPlainObject(obj);
|
||||
}
|
||||
|
||||
function deriveKey(property: string, prefix?: string) {
|
||||
if (prefix) {
|
||||
return `${prefix}.${property}`;
|
||||
}
|
||||
|
||||
return property;
|
||||
}
|
||||
|
||||
function reduce(
|
||||
result: Record<string, any>,
|
||||
obj: Record<string, any> | number | null | string,
|
||||
ignoreArrays: boolean,
|
||||
embedArrays: boolean,
|
||||
prefix?: string
|
||||
) {
|
||||
if (prefix) {
|
||||
if (isNumber(obj) || isString(obj) || isNull(obj)) {
|
||||
result[prefix] = obj;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(obj)) {
|
||||
for (const property in obj) {
|
||||
if (!obj.hasOwnProperty(property)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = obj[property];
|
||||
const key = deriveKey(property, prefix);
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
reduce(result, value, ignoreArrays, embedArrays, key);
|
||||
} else if (isArray(value)) {
|
||||
if (!ignoreArrays) {
|
||||
if (embedArrays) {
|
||||
result[key] = value;
|
||||
} else {
|
||||
value.forEach((item, index) => {
|
||||
reduce(
|
||||
result,
|
||||
item,
|
||||
ignoreArrays,
|
||||
embedArrays,
|
||||
`${key}[${index}]`
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface DotizeOptions {
|
||||
/**
|
||||
* ignoreArrays will ignore all array properties and not include them in the
|
||||
* resulting entry.
|
||||
*/
|
||||
ignoreArrays?: boolean;
|
||||
|
||||
/**
|
||||
* embedArrays will treat arrays as plain objects, and embed them as is
|
||||
* without recusing the dotize algorithm to it.
|
||||
*/
|
||||
embedArrays?: boolean;
|
||||
}
|
||||
|
||||
export const dotize = (
|
||||
obj: Record<string, any>,
|
||||
{ ignoreArrays = false, embedArrays = false }: DotizeOptions = {}
|
||||
): Record<string, any> => reduce({}, obj, ignoreArrays, embedArrays);
|
||||
@@ -12,8 +12,10 @@ import { createPassport } from "talk-server/app/middleware/passport";
|
||||
import { JWTSigningConfig } from "talk-server/app/middleware/passport/jwt";
|
||||
import { handleSubscriptions } from "talk-server/graph/common/subscriptions/middleware";
|
||||
import { Schemas } from "talk-server/graph/schemas";
|
||||
import { TaskQueue } from "talk-server/services/queue";
|
||||
import TenantCache from "talk-server/services/tenant/cache";
|
||||
|
||||
import { cacheHeadersMiddleware } from "talk-server/app/middleware/cacheHeaders";
|
||||
import { errorHandler } from "talk-server/app/middleware/error";
|
||||
import { accessLogger, errorLogger } from "./middleware/logging";
|
||||
import serveStatic from "./middleware/serveStatic";
|
||||
@@ -21,6 +23,7 @@ import { createRouter } from "./router";
|
||||
|
||||
export interface AppOptions {
|
||||
parent: Express;
|
||||
queue: TaskQueue;
|
||||
config: Config;
|
||||
mongo: Db;
|
||||
redis: Redis;
|
||||
@@ -47,13 +50,14 @@ export async function createApp(options: AppOptions): Promise<Express> {
|
||||
|
||||
// Mount the router.
|
||||
parent.use(
|
||||
"/",
|
||||
await createRouter(options, {
|
||||
passport,
|
||||
})
|
||||
);
|
||||
|
||||
// Static Files
|
||||
parent.use("/assets", serveStatic);
|
||||
parent.use("/assets", cacheHeadersMiddleware("1w"), serveStatic);
|
||||
|
||||
// Error Handling
|
||||
parent.use(notFoundMiddleware);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { RequestHandler } from "express";
|
||||
import ms from "ms";
|
||||
|
||||
export const nocacheMiddleware: RequestHandler = (req, res, next) => {
|
||||
// Set cache control headers to prevent browsers/cdn's from caching these
|
||||
// requests.
|
||||
res.set({ "Cache-Control": "no-cache, no-store, must-revalidate" });
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
export const cacheHeadersMiddleware = (duration: string): RequestHandler => {
|
||||
const maxAge = duration ? Math.floor(ms(duration) / 1000) : false;
|
||||
if (!maxAge) {
|
||||
return nocacheMiddleware;
|
||||
}
|
||||
|
||||
return (req, res, next) => {
|
||||
// Set cache control headers to encourage browsers/cdn's to cache these
|
||||
// requests if we aren't in private mode.
|
||||
res.set({
|
||||
"Cache-Control": `public, max-age=${maxAge}`,
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
};
|
||||
@@ -141,19 +141,18 @@ export function createJWTSigningConfig(config: Config): JWTSigningConfig {
|
||||
|
||||
export type SigningTokenOptions = Pick<SignOptions, "audience" | "issuer">;
|
||||
|
||||
export async function signTokenString(
|
||||
export const signTokenString = async (
|
||||
{ algorithm, secret }: JWTSigningConfig,
|
||||
user: User,
|
||||
options: SigningTokenOptions
|
||||
) {
|
||||
return jwt.sign({}, secret, {
|
||||
) =>
|
||||
jwt.sign({}, secret, {
|
||||
...options,
|
||||
jwtid: uuid.v4(),
|
||||
algorithm,
|
||||
expiresIn: "1 day", // TODO: (wyattjoh) evaluate allowing configuration?
|
||||
subject: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
export interface JWTToken {
|
||||
jti: string;
|
||||
@@ -208,17 +207,23 @@ export class JWTStrategy extends Strategy {
|
||||
// Use the algorithm specified in the configuration.
|
||||
algorithms: [this.signingConfig.algorithm],
|
||||
},
|
||||
async (err: Error | undefined, { jti, sub }: JWTToken) => {
|
||||
async (err: Error | undefined, decoded: JWTToken) => {
|
||||
if (err) {
|
||||
return this.fail(err, 401);
|
||||
}
|
||||
|
||||
try {
|
||||
// Check to see if the token has been blacklisted.
|
||||
await checkBlacklistJWT(this.redis, jti);
|
||||
if (!decoded) {
|
||||
// There was no token on the request, so there was no user, so let's
|
||||
// mark that the strategy was successful.
|
||||
return this.success(null, null);
|
||||
}
|
||||
|
||||
// Find the user referenced by the token.
|
||||
const user = await retrieveUser(this.mongo, tenant.id, sub);
|
||||
try {
|
||||
// Find the user.
|
||||
const user = await retrieveUser(this.mongo, tenant.id, decoded.sub);
|
||||
|
||||
// Check to see if the token has been blacklisted.
|
||||
await checkBlacklistJWT(this.redis, decoded.jti);
|
||||
|
||||
// Return them! The user may be null, but that's ok here.
|
||||
this.success(user, null);
|
||||
|
||||
@@ -5,11 +5,12 @@ import { Db } from "mongodb";
|
||||
import { Strategy as OAuth2Strategy, VerifyCallback } from "passport-oauth2";
|
||||
import { Strategy } from "passport-strategy";
|
||||
|
||||
import { Config } from "talk-common/config";
|
||||
import { validate } from "talk-server/app/request/body";
|
||||
import { reconstructURL } from "talk-server/app/url";
|
||||
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { OIDCAuthIntegration } from "talk-server/models/settings";
|
||||
import {
|
||||
GQLOIDCAuthIntegration,
|
||||
GQLUSER_ROLE,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { Tenant } from "talk-server/models/tenant";
|
||||
import { OIDCProfile, retrieveUserWithProfile } from "talk-server/models/user";
|
||||
import TenantCache from "talk-server/services/tenant/cache";
|
||||
@@ -83,7 +84,9 @@ const signingKeyFactory = (client: jwks.JwksClient): jwt.KeyFunction => (
|
||||
});
|
||||
};
|
||||
|
||||
function getEnabledIntegration(tenant: Tenant) {
|
||||
function getEnabledIntegration(
|
||||
tenant: Tenant
|
||||
): Required<GQLOIDCAuthIntegration> {
|
||||
const integration = tenant.auth.integrations.oidc;
|
||||
if (!integration) {
|
||||
// TODO: return a better error.
|
||||
@@ -96,7 +99,21 @@ function getEnabledIntegration(tenant: Tenant) {
|
||||
throw new Error("integration not enabled");
|
||||
}
|
||||
|
||||
return integration;
|
||||
if (
|
||||
!integration.name ||
|
||||
!integration.clientID ||
|
||||
!integration.clientSecret ||
|
||||
!integration.authorizationURL ||
|
||||
!integration.tokenURL ||
|
||||
!integration.jwksURI ||
|
||||
!integration.issuer
|
||||
) {
|
||||
// TODO: return a better error.
|
||||
throw new Error("integration not configured");
|
||||
}
|
||||
|
||||
// TODO: (wyattjoh) for some reason, type guards above to not allow coercion to this required type.
|
||||
return integration as Required<GQLOIDCAuthIntegration>;
|
||||
}
|
||||
|
||||
export const OIDCIDTokenSchema = Joi.object()
|
||||
@@ -179,7 +196,6 @@ const OIDC_SCOPE = "openid email profile";
|
||||
export interface OIDCStrategyOptions {
|
||||
mongo: Db;
|
||||
tenantCache: TenantCache;
|
||||
config: Config;
|
||||
}
|
||||
|
||||
export default class OIDCStrategy extends Strategy {
|
||||
@@ -188,11 +204,11 @@ export default class OIDCStrategy extends Strategy {
|
||||
private mongo: Db;
|
||||
private cache: TenantCacheAdapter<StrategyItem>;
|
||||
|
||||
constructor({ mongo, tenantCache, config }: OIDCStrategyOptions) {
|
||||
constructor({ mongo, tenantCache }: OIDCStrategyOptions) {
|
||||
super();
|
||||
|
||||
this.mongo = mongo;
|
||||
this.cache = new TenantCacheAdapter(tenantCache, config);
|
||||
this.cache = new TenantCacheAdapter(tenantCache);
|
||||
|
||||
// Connect the cache adapter.
|
||||
this.cache.subscribe();
|
||||
@@ -201,7 +217,7 @@ export default class OIDCStrategy extends Strategy {
|
||||
private lookupJWKSClient(
|
||||
req: Request,
|
||||
tenantID: string,
|
||||
oidc: OIDCAuthIntegration
|
||||
oidc: Required<GQLOIDCAuthIntegration>
|
||||
) {
|
||||
let entry = this.cache.get(tenantID);
|
||||
if (!entry) {
|
||||
@@ -257,7 +273,7 @@ export default class OIDCStrategy extends Strategy {
|
||||
|
||||
// Get the integration from the tenant. If needed, it will be used to create
|
||||
// a new strategy.
|
||||
let integration: OIDCAuthIntegration;
|
||||
let integration: Required<GQLOIDCAuthIntegration>;
|
||||
try {
|
||||
integration = getEnabledIntegration(tenant);
|
||||
} catch (err) {
|
||||
@@ -297,7 +313,7 @@ export default class OIDCStrategy extends Strategy {
|
||||
|
||||
private createStrategy(
|
||||
req: Request,
|
||||
integration: OIDCAuthIntegration
|
||||
integration: Required<GQLOIDCAuthIntegration>
|
||||
): OAuth2Strategy {
|
||||
const { clientID, clientSecret, authorizationURL, tokenURL } = integration;
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ import tenantMiddleware from "talk-server/app/middleware/tenant";
|
||||
import managementGraphMiddleware from "talk-server/graph/management/middleware";
|
||||
import tenantGraphMiddleware from "talk-server/graph/tenant/middleware";
|
||||
|
||||
import {
|
||||
cacheHeadersMiddleware,
|
||||
nocacheMiddleware,
|
||||
} from "talk-server/app/middleware/cacheHeaders";
|
||||
import { AppOptions } from "./index";
|
||||
import playground from "./middleware/playground";
|
||||
|
||||
@@ -57,6 +61,7 @@ async function createTenantRouter(app: AppOptions, options: RouterOptions) {
|
||||
config: app.config,
|
||||
mongo: app.mongo,
|
||||
redis: app.redis,
|
||||
queue: app.queue,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -123,7 +128,7 @@ export async function createRouter(app: AppOptions, options: RouterOptions) {
|
||||
// Create a router.
|
||||
const router = express.Router();
|
||||
|
||||
router.use("/api", await createAPIRouter(app, options));
|
||||
router.use("/api", nocacheMiddleware, await createAPIRouter(app, options));
|
||||
|
||||
if (app.config.get("env") === "development") {
|
||||
// Tenant GraphiQL
|
||||
@@ -146,7 +151,7 @@ export async function createRouter(app: AppOptions, options: RouterOptions) {
|
||||
}
|
||||
|
||||
// Handle the stream handler.
|
||||
router.get("/embed/stream", streamHandler);
|
||||
router.get("/embed/stream", cacheHeadersMiddleware("1h"), streamHandler);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@ import { RedisPubSub } from "graphql-redis-subscriptions";
|
||||
import { Config } from "talk-common/config";
|
||||
import { createRedisClient } from "talk-server/services/redis";
|
||||
|
||||
export async function createPubSub(config: Config): Promise<RedisPubSub> {
|
||||
export function createPubSub(config: Config): RedisPubSub {
|
||||
// Create the Redis clients for the PubSub server.
|
||||
const publisher = await createRedisClient(config);
|
||||
const subscriber = await createRedisClient(config);
|
||||
const publisher = createRedisClient(config);
|
||||
const subscriber = createRedisClient(config);
|
||||
|
||||
// Create the new PubSub manager.
|
||||
return new RedisPubSub({
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Db } from "mongodb";
|
||||
import CommonContext 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/services/queue";
|
||||
import TenantCache from "talk-server/services/tenant/cache";
|
||||
import { Request } from "talk-server/types/express";
|
||||
|
||||
@@ -15,18 +16,20 @@ export interface TenantContextOptions {
|
||||
redis: Redis;
|
||||
tenant: Tenant;
|
||||
tenantCache: TenantCache;
|
||||
queue: TaskQueue;
|
||||
req?: Request;
|
||||
user?: User;
|
||||
}
|
||||
|
||||
export default class TenantContext extends CommonContext {
|
||||
public loaders: ReturnType<typeof loaders>;
|
||||
public mutators: ReturnType<typeof mutators>;
|
||||
public mongo: Db;
|
||||
public redis: Redis;
|
||||
public user?: User;
|
||||
public tenant: Tenant;
|
||||
public tenantCache: TenantCache;
|
||||
public user?: User;
|
||||
public mongo: Db;
|
||||
public redis: Redis;
|
||||
public queue: TaskQueue;
|
||||
public loaders: ReturnType<typeof loaders>;
|
||||
public mutators: ReturnType<typeof mutators>;
|
||||
|
||||
constructor({
|
||||
req,
|
||||
@@ -35,6 +38,7 @@ export default class TenantContext extends CommonContext {
|
||||
mongo,
|
||||
redis,
|
||||
tenantCache,
|
||||
queue,
|
||||
}: TenantContextOptions) {
|
||||
super({ user, req });
|
||||
|
||||
@@ -43,6 +47,7 @@ export default class TenantContext extends CommonContext {
|
||||
this.user = user;
|
||||
this.mongo = mongo;
|
||||
this.redis = redis;
|
||||
this.queue = queue;
|
||||
this.loaders = loaders(this);
|
||||
this.mutators = mutators(this);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { findOrCreate } from "talk-server/services/assets";
|
||||
|
||||
export default (ctx: TenantContext) => ({
|
||||
findOrCreate: (input: FindOrCreateAssetInput) =>
|
||||
findOrCreate(ctx.mongo, ctx.tenant, input),
|
||||
findOrCreate(ctx.mongo, ctx.tenant, input, ctx.queue.scraper),
|
||||
asset: new DataLoader<string, Asset | null>(ids =>
|
||||
retrieveManyAssets(ctx.mongo, ctx.tenant.id, ids)
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Db } from "mongodb";
|
||||
|
||||
import { Config } from "talk-common/config";
|
||||
import { graphqlMiddleware } from "talk-server/graph/common/middleware";
|
||||
import { TaskQueue } from "talk-server/services/queue";
|
||||
import { Request } from "talk-server/types/express";
|
||||
|
||||
import TenantContext from "./context";
|
||||
@@ -13,6 +14,7 @@ export interface TenantGraphQLMiddlewareOptions {
|
||||
config: Config;
|
||||
mongo: Db;
|
||||
redis: Redis;
|
||||
queue: TaskQueue;
|
||||
}
|
||||
|
||||
export default async ({
|
||||
@@ -20,6 +22,7 @@ export default async ({
|
||||
config,
|
||||
mongo,
|
||||
redis,
|
||||
queue,
|
||||
}: TenantGraphQLMiddlewareOptions) => {
|
||||
return graphqlMiddleware(config, async (req: Request) => {
|
||||
// Load the tenant and user from the request.
|
||||
@@ -35,6 +38,7 @@ export default async ({
|
||||
tenant: tenant!,
|
||||
user,
|
||||
tenantCache,
|
||||
queue,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import TenantContext from "talk-server/graph/tenant/context";
|
||||
import { GQLCreateCommentInput } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { Comment } from "talk-server/models/comment";
|
||||
import { create } from "talk-server/services/comments";
|
||||
import {
|
||||
GQLCreateCommentInput,
|
||||
GQLEditCommentInput,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { create, edit } from "talk-server/services/comments";
|
||||
|
||||
export default (ctx: TenantContext) => ({
|
||||
create: (input: GQLCreateCommentInput): Promise<Comment> => {
|
||||
return create(
|
||||
create: (input: GQLCreateCommentInput) =>
|
||||
create(
|
||||
ctx.mongo,
|
||||
ctx.tenant,
|
||||
ctx.user!,
|
||||
@@ -16,6 +18,17 @@ export default (ctx: TenantContext) => ({
|
||||
parent_id: input.parentID,
|
||||
},
|
||||
ctx.req
|
||||
);
|
||||
},
|
||||
),
|
||||
edit: (input: GQLEditCommentInput) =>
|
||||
edit(
|
||||
ctx.mongo,
|
||||
ctx.tenant,
|
||||
ctx.user!,
|
||||
{
|
||||
id: input.commentID,
|
||||
asset_id: input.assetID,
|
||||
body: input.body,
|
||||
},
|
||||
ctx.req
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { GQLAuthIntegrationsTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import {
|
||||
AuthIntegrations,
|
||||
EnableableIntegration,
|
||||
} from "talk-server/models/settings";
|
||||
GQLAuthIntegrations,
|
||||
GQLAuthIntegrationsTypeResolver,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
|
||||
const disabled: EnableableIntegration = { enabled: false };
|
||||
const disabled = { enabled: false };
|
||||
|
||||
const AuthIntegrations: GQLAuthIntegrationsTypeResolver<AuthIntegrations> = {
|
||||
const AuthIntegrations: GQLAuthIntegrationsTypeResolver<GQLAuthIntegrations> = {
|
||||
local: auth => auth.local || disabled,
|
||||
sso: auth => auth.sso || disabled,
|
||||
oidc: auth => auth.oidc || disabled,
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { GQLAuthSettingsTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { Auth } from "talk-server/models/settings";
|
||||
|
||||
const AuthSettings: GQLAuthSettingsTypeResolver<Auth> = {
|
||||
integrations: auth => auth.integrations,
|
||||
};
|
||||
|
||||
export default AuthSettings;
|
||||
@@ -2,6 +2,16 @@ import { GQLCommentTypeResolver } from "talk-server/graph/tenant/schema/__genera
|
||||
import { Comment } from "talk-server/models/comment";
|
||||
|
||||
const Comment: GQLCommentTypeResolver<Comment> = {
|
||||
editing: (comment, input, ctx) => ({
|
||||
// When there is more than one body history, then the comment has been
|
||||
// edited.
|
||||
edited: comment.body_history.length > 1,
|
||||
// The date that the comment is editable until is the tenant's edit window
|
||||
// length added to the comment created date.
|
||||
editableUntil: new Date(
|
||||
comment.created_at.valueOf() + ctx.tenant.editCommentWindowLength
|
||||
),
|
||||
}),
|
||||
createdAt: comment => comment.created_at,
|
||||
author: (comment, input, ctx) =>
|
||||
ctx.loaders.Users.user.load(comment.author_id),
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { GQLFacebookAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { FacebookAuthIntegration } from "talk-server/models/settings";
|
||||
|
||||
const FacebookAuthIntegration: GQLFacebookAuthIntegrationTypeResolver<
|
||||
FacebookAuthIntegration
|
||||
> = {
|
||||
config: auth => auth,
|
||||
};
|
||||
|
||||
export default FacebookAuthIntegration;
|
||||
@@ -1,10 +0,0 @@
|
||||
import { GQLGoogleAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { GoogleAuthIntegration } from "talk-server/models/settings";
|
||||
|
||||
const GoogleAuthIntegration: GQLGoogleAuthIntegrationTypeResolver<
|
||||
GoogleAuthIntegration
|
||||
> = {
|
||||
config: auth => auth,
|
||||
};
|
||||
|
||||
export default GoogleAuthIntegration;
|
||||
@@ -3,27 +3,15 @@ import { GQLResolver } from "talk-server/graph/tenant/schema/__generated__/types
|
||||
|
||||
import Asset from "./asset";
|
||||
import AuthIntegrations from "./auth_integrations";
|
||||
import AuthSettings from "./auth_settings";
|
||||
import Comment from "./comment";
|
||||
import FacebookAuthIntegration from "./facebook_auth_integration";
|
||||
import GoogleAuthIntegration from "./google_auth_integration";
|
||||
import LocalAuthIntegration from "./local_auth_integration";
|
||||
import Mutation from "./mutation";
|
||||
import OIDCAuthIntegration from "./oidc_auth_integration";
|
||||
import Profile from "./profile";
|
||||
import Query from "./query";
|
||||
import SSOAuthIntegration from "./sso_auth_integration";
|
||||
|
||||
const Resolvers: GQLResolver = {
|
||||
Asset,
|
||||
AuthIntegrations,
|
||||
AuthSettings,
|
||||
Comment,
|
||||
FacebookAuthIntegration,
|
||||
GoogleAuthIntegration,
|
||||
LocalAuthIntegration,
|
||||
OIDCAuthIntegration,
|
||||
SSOAuthIntegration,
|
||||
Cursor,
|
||||
Mutation,
|
||||
Profile,
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { GQLLocalAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { LocalAuthIntegration } from "talk-server/models/settings";
|
||||
|
||||
const LocalAuthIntegration: GQLLocalAuthIntegrationTypeResolver<
|
||||
LocalAuthIntegration
|
||||
> = {};
|
||||
|
||||
export default LocalAuthIntegration;
|
||||
@@ -1,11 +1,15 @@
|
||||
import { GQLMutationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
|
||||
const Mutation: GQLMutationTypeResolver<void> = {
|
||||
editComment: async (source, { input }, ctx) => ({
|
||||
comment: await ctx.mutators.Comment.edit(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
createComment: async (source, { input }, ctx) => {
|
||||
const comment = await ctx.mutators.Comment.create(input);
|
||||
// TODO: (cvle) tell wyatt to take a look at this :-)
|
||||
return {
|
||||
commentEdge: {
|
||||
edge: {
|
||||
// FIXME: (wyattjoh) when we're using a replies/respect sort, it is index based instead of date based, needs some work!
|
||||
cursor: comment.created_at,
|
||||
node: comment,
|
||||
},
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { GQLOIDCAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { OIDCAuthIntegration } from "talk-server/models/settings";
|
||||
|
||||
const OIDCAuthIntegration: GQLOIDCAuthIntegrationTypeResolver<
|
||||
OIDCAuthIntegration
|
||||
> = {
|
||||
config: auth => auth,
|
||||
};
|
||||
|
||||
export default OIDCAuthIntegration;
|
||||
@@ -1,10 +0,0 @@
|
||||
import { GQLSSOAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { SSOAuthIntegration } from "talk-server/models/settings";
|
||||
|
||||
const SSOAuthIntegration: GQLSSOAuthIntegrationTypeResolver<
|
||||
SSOAuthIntegration
|
||||
> = {
|
||||
config: auth => auth,
|
||||
};
|
||||
|
||||
export default SSOAuthIntegration;
|
||||
@@ -7,7 +7,8 @@ auth is a directive that will enforce authorization rules on the schema
|
||||
definition. It will restrict the viewer of the field based on roles or if the
|
||||
`userIDField` is specified, it will see if the current users ID equals the field
|
||||
specified. This allows users that own a specific resource (like a comment, or a
|
||||
flag) see their own content, but restrict it to everyone else.
|
||||
flag) see their own content, but restrict it to everyone else. If the directive
|
||||
is used without options, it simply requires a logged in user.
|
||||
"""
|
||||
directive @auth(roles: [USER_ROLE!], userIDField: String) on FIELD_DEFINITION
|
||||
|
||||
@@ -82,7 +83,7 @@ type Wordlist {
|
||||
}
|
||||
|
||||
################################################################################
|
||||
## AuthSettings
|
||||
## Auth
|
||||
################################################################################
|
||||
|
||||
##########################
|
||||
@@ -97,74 +98,70 @@ type LocalAuthIntegration {
|
||||
## SSOAuthIntegration
|
||||
##########################
|
||||
|
||||
type SSOAuthIntegrationConfig {
|
||||
key: String!
|
||||
"""
|
||||
SSOAuthIntegration is an AuthIntegration that provides a secret to the admins
|
||||
of a tenant, where they can sign a SSO payload with it to provide to the
|
||||
embed to allow single sign on.
|
||||
"""
|
||||
type SSOAuthIntegration {
|
||||
enabled: Boolean!
|
||||
|
||||
"""
|
||||
key is the secret that is used to sign tokens.
|
||||
"""
|
||||
key: String @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
displayNameEnable when enabled, will allow Users to set and view their
|
||||
displayName's.
|
||||
"""
|
||||
displayNameEnable: Boolean!
|
||||
}
|
||||
|
||||
type SSOAuthIntegration {
|
||||
enabled: Boolean!
|
||||
config: SSOAuthIntegrationConfig @auth(roles: [ADMIN])
|
||||
displayNameEnable: Boolean @auth(roles: [ADMIN])
|
||||
}
|
||||
|
||||
##########################
|
||||
## OIDCAuthIntegration
|
||||
##########################
|
||||
|
||||
type OIDCAuthIntegrationConfig {
|
||||
clientID: String!
|
||||
clientSecret: String!
|
||||
authorizationURL: String!
|
||||
tokenURL: String!
|
||||
"""
|
||||
OIDCAuthIntegration provides a way to store Open ID Connect credentials. This
|
||||
will be used in the admin to provide staff logins for users.
|
||||
"""
|
||||
type OIDCAuthIntegration {
|
||||
enabled: Boolean!
|
||||
|
||||
name: String
|
||||
clientID: String @auth(roles: [ADMIN])
|
||||
clientSecret: String @auth(roles: [ADMIN])
|
||||
authorizationURL: String @auth(roles: [ADMIN])
|
||||
tokenURL: String @auth(roles: [ADMIN])
|
||||
jwksURI: String @auth(roles: [ADMIN])
|
||||
issuer: String @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
displayNameEnable when enabled, will allow Users to set and view their
|
||||
displayName's.
|
||||
"""
|
||||
displayNameEnable: Boolean!
|
||||
}
|
||||
|
||||
type OIDCAuthIntegrationOptions {
|
||||
name: String!
|
||||
}
|
||||
|
||||
type OIDCAuthIntegration {
|
||||
enabled: Boolean!
|
||||
options: OIDCAuthIntegrationOptions
|
||||
config: SSOAuthIntegrationConfig @auth(roles: [ADMIN])
|
||||
displayNameEnable: Boolean @auth(roles: [ADMIN])
|
||||
}
|
||||
|
||||
##########################
|
||||
## GoogleAuthIntegration
|
||||
##########################
|
||||
|
||||
type GoogleAuthIntegrationConfig {
|
||||
clientID: String!
|
||||
clientSecret: String!
|
||||
}
|
||||
|
||||
type GoogleAuthIntegration {
|
||||
enabled: Boolean!
|
||||
config: GoogleAuthIntegrationConfig @auth(roles: [ADMIN])
|
||||
clientID: String @auth(roles: [ADMIN])
|
||||
clientSecret: String @auth(roles: [ADMIN])
|
||||
}
|
||||
|
||||
##########################
|
||||
## FacebookAuthIntegration
|
||||
##########################
|
||||
|
||||
type FacebookAuthIntegrationConfig {
|
||||
clientID: String!
|
||||
clientSecret: String!
|
||||
}
|
||||
|
||||
type FacebookAuthIntegration {
|
||||
enabled: Boolean!
|
||||
config: FacebookAuthIntegrationConfig @auth(roles: [ADMIN])
|
||||
clientID: String @auth(roles: [ADMIN])
|
||||
clientSecret: String @auth(roles: [ADMIN])
|
||||
}
|
||||
|
||||
type AuthIntegrations {
|
||||
@@ -176,10 +173,10 @@ type AuthIntegrations {
|
||||
}
|
||||
|
||||
"""
|
||||
AuthSettings contains all the settings related to authentication and
|
||||
Auth contains all the settings related to authentication and
|
||||
authorization.
|
||||
"""
|
||||
type AuthSettings {
|
||||
type Auth {
|
||||
"""
|
||||
integrations are the set of configurations for the variations of
|
||||
authentication solutions.
|
||||
@@ -292,6 +289,27 @@ type Karma {
|
||||
thresholds: KarmaThresholds!
|
||||
}
|
||||
|
||||
################################################################################
|
||||
## Email
|
||||
################################################################################
|
||||
|
||||
type Email {
|
||||
"""
|
||||
enabled when True, will enable the emailing functionality in Talk.
|
||||
"""
|
||||
enabled: Boolean!
|
||||
|
||||
"""
|
||||
smtpURI is the SMTP connection url to send emails on.
|
||||
"""
|
||||
smtpURI: String
|
||||
|
||||
"""
|
||||
fromAddress is the email address that will be used to send emails from.
|
||||
"""
|
||||
fromAddress: String
|
||||
}
|
||||
|
||||
################################################################################
|
||||
## Settings
|
||||
################################################################################
|
||||
@@ -303,7 +321,12 @@ type Settings {
|
||||
"""
|
||||
domain is the domain that is associated with this Tenant.
|
||||
"""
|
||||
domain: String @auth(roles: [ADMIN])
|
||||
domain: String! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
domains will return a given list of whitelisted domains.
|
||||
"""
|
||||
domains: [String!] @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
moderation is the moderation mode for all Asset's on the site.
|
||||
@@ -407,20 +430,20 @@ type Settings {
|
||||
"""
|
||||
organizationContactEmail: String
|
||||
|
||||
"""
|
||||
email is the set of credentials and settings associated with the organization.
|
||||
"""
|
||||
email: Email @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
wordlist will return a given list of words.
|
||||
"""
|
||||
wordlist: Wordlist @auth(roles: [ADMIN, MODERATOR])
|
||||
|
||||
"""
|
||||
domains will return a given list of whitelisted domains.
|
||||
"""
|
||||
domains: [String!] @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
auth contains all the settings related to authentication and authorization.
|
||||
"""
|
||||
auth: AuthSettings!
|
||||
auth: Auth!
|
||||
|
||||
"""
|
||||
integrations contains all the external integrations that can be enabled.
|
||||
@@ -529,6 +552,18 @@ type User {
|
||||
## Comment
|
||||
################################################################################
|
||||
|
||||
type EditInfo {
|
||||
"""
|
||||
edited will be True when the Comment has been edited in the past.
|
||||
"""
|
||||
edited: Boolean!
|
||||
|
||||
"""
|
||||
editableUntil is the time that the comment is editable until.
|
||||
"""
|
||||
editableUntil: Time
|
||||
}
|
||||
|
||||
enum COMMENT_STATUS {
|
||||
"""
|
||||
The comment is not PREMOD, but was not applied a moderation status by a
|
||||
@@ -575,7 +610,7 @@ type Comment {
|
||||
body: String
|
||||
|
||||
"""
|
||||
createdAt is the date in which the comment was created.
|
||||
createdAt is the date in which the Comment was created.
|
||||
"""
|
||||
createdAt: Time!
|
||||
|
||||
@@ -585,7 +620,7 @@ type Comment {
|
||||
author: User
|
||||
|
||||
"""
|
||||
status represents the Comment's current Status.
|
||||
status represents the Comment's current status.
|
||||
"""
|
||||
status: COMMENT_STATUS!
|
||||
|
||||
@@ -596,13 +631,18 @@ type Comment {
|
||||
replyCount: Int
|
||||
|
||||
"""
|
||||
replies will return the replies to this comment.
|
||||
replies will return the replies to this Comment.
|
||||
"""
|
||||
replies(
|
||||
first: Int = 10
|
||||
orderBy: COMMENT_SORT = CREATED_AT_DESC
|
||||
after: Cursor
|
||||
): CommentsConnection
|
||||
|
||||
"""
|
||||
editing returns details about the edit status of a Comment.
|
||||
"""
|
||||
editing: EditInfo!
|
||||
}
|
||||
|
||||
type PageInfo {
|
||||
@@ -821,9 +861,54 @@ mutation.
|
||||
"""
|
||||
type CreateCommentPayload {
|
||||
"""
|
||||
CommentEdge is the possibly created comment edge.
|
||||
edge is the possibly created comment edge.
|
||||
"""
|
||||
commentEdge: CommentEdge
|
||||
edge: CommentEdge
|
||||
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
}
|
||||
|
||||
##################
|
||||
## editComment
|
||||
##################
|
||||
|
||||
"""
|
||||
EditCommentInput provides the input for the editComment Mutation.
|
||||
"""
|
||||
input EditCommentInput {
|
||||
"""
|
||||
assetID is the ID of the Asset where we are editing a comment on.
|
||||
"""
|
||||
assetID: ID!
|
||||
|
||||
"""
|
||||
commentID is the ID of the comment being edited.
|
||||
"""
|
||||
commentID: ID!
|
||||
|
||||
"""
|
||||
body is the Comment body, the content of the Comment.
|
||||
"""
|
||||
body: String!
|
||||
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
}
|
||||
|
||||
"""
|
||||
EditCommentPayload contains the edited Comment after the editComment
|
||||
mutation.
|
||||
"""
|
||||
type EditCommentPayload {
|
||||
"""
|
||||
comment is the possibly edited comment.
|
||||
"""
|
||||
comment: Comment
|
||||
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
@@ -835,38 +920,345 @@ type CreateCommentPayload {
|
||||
## updateSettings
|
||||
##################
|
||||
|
||||
input SettingsEmailInput {
|
||||
"""
|
||||
enabled when True, will enable the emailing functionality in Talk.
|
||||
"""
|
||||
enabled: Boolean
|
||||
|
||||
"""
|
||||
smtpURI is the SMTP connection url to send emails on.
|
||||
"""
|
||||
smtpURI: String
|
||||
|
||||
"""
|
||||
fromAddress is the email address that will be used to send emails from.
|
||||
"""
|
||||
fromAddress: String
|
||||
}
|
||||
|
||||
input SettingsWordlistInput {
|
||||
"""
|
||||
banned words will by default reject the comment if it is found.
|
||||
"""
|
||||
banned: [String!]
|
||||
|
||||
"""
|
||||
suspect words will simply flag the comment.
|
||||
"""
|
||||
suspect: [String!]
|
||||
}
|
||||
|
||||
input SettingsLocalAuthIntegrationInput {
|
||||
enabled: Boolean
|
||||
}
|
||||
|
||||
input SettingsSSOAuthIntegrationInput {
|
||||
enabled: Boolean
|
||||
|
||||
"""
|
||||
key is the secret that is used to sign tokens.
|
||||
"""
|
||||
key: String
|
||||
|
||||
"""
|
||||
displayNameEnable when enabled, will allow Users to set and view their
|
||||
displayName's.
|
||||
"""
|
||||
displayNameEnable: Boolean
|
||||
}
|
||||
|
||||
input SettingsOIDCAuthIntegrationInput {
|
||||
enabled: Boolean
|
||||
|
||||
name: String
|
||||
clientID: String
|
||||
clientSecret: String
|
||||
authorizationURL: String
|
||||
tokenURL: String
|
||||
|
||||
"""
|
||||
displayNameEnable when enabled, will allow Users to set and view their
|
||||
displayName's.
|
||||
"""
|
||||
displayNameEnable: Boolean
|
||||
}
|
||||
|
||||
input SettingsGoogleAuthIntegrationInput {
|
||||
enabled: Boolean
|
||||
clientID: String
|
||||
clientSecret: String
|
||||
}
|
||||
|
||||
input SettingsFacebookAuthIntegrationInput {
|
||||
enabled: Boolean
|
||||
clientID: String
|
||||
clientSecret: String
|
||||
}
|
||||
|
||||
input SettingsAuthIntegrationsInput {
|
||||
local: SettingsLocalAuthIntegrationInput
|
||||
sso: SettingsSSOAuthIntegrationInput
|
||||
oidc: SettingsOIDCAuthIntegrationInput
|
||||
google: SettingsGoogleAuthIntegrationInput
|
||||
facebook: SettingsFacebookAuthIntegrationInput
|
||||
}
|
||||
|
||||
"""
|
||||
Auth contains all the settings related to authentication and
|
||||
authorization.
|
||||
"""
|
||||
input SettingsAuthInput {
|
||||
"""
|
||||
integrations are the set of configurations for the variations of
|
||||
authentication solutions.
|
||||
"""
|
||||
integrations: SettingsAuthIntegrationsInput
|
||||
}
|
||||
|
||||
input SettingsAkismetExternalIntegrationInput {
|
||||
"""
|
||||
enabled when True will enable the integration.
|
||||
"""
|
||||
enabled: Boolean
|
||||
|
||||
"""
|
||||
The key for the Akismet integration.
|
||||
"""
|
||||
key: String
|
||||
|
||||
"""
|
||||
The site (blog) for the Akismet integration.
|
||||
"""
|
||||
site: String
|
||||
}
|
||||
|
||||
input SettingsPerspectiveExternalIntegrationInput {
|
||||
"""
|
||||
enabled when True will enable the integration.
|
||||
"""
|
||||
enabled: Boolean
|
||||
|
||||
"""
|
||||
The endpoint that Talk should use to communicate with the perspective API.
|
||||
"""
|
||||
endpoint: String
|
||||
|
||||
"""
|
||||
The key for the Perspective API integration.
|
||||
"""
|
||||
key: String
|
||||
|
||||
"""
|
||||
The threshold that given a specific toxic comment score, the comment will
|
||||
be marked by Talk as toxic.
|
||||
"""
|
||||
threshold: Float
|
||||
|
||||
"""
|
||||
When True, comments sent will not be stored by the Google Perspective API.
|
||||
"""
|
||||
doNotStore: Boolean
|
||||
}
|
||||
|
||||
input SettingsExternalIntegrationsInput {
|
||||
"""
|
||||
akismet provides integration with the Akismet Spam detection service.
|
||||
"""
|
||||
akismet: SettingsAkismetExternalIntegrationInput
|
||||
|
||||
"""
|
||||
perspective provides integration with the Perspective API comment analysis
|
||||
platform.
|
||||
"""
|
||||
perspective: SettingsPerspectiveExternalIntegrationInput
|
||||
}
|
||||
|
||||
"""
|
||||
KarmaThreshold defines the bounds for which a User will become unreliable or
|
||||
reliable based on their karma score. If the score is equal or less than the
|
||||
unreliable value, they are unreliable. If the score is equal or more than the
|
||||
reliable value, they are reliable. If they are neither reliable or unreliable
|
||||
then they are neutral.
|
||||
"""
|
||||
input SettingsKarmaThresholdInput {
|
||||
reliable: Int
|
||||
unreliable: Int
|
||||
}
|
||||
|
||||
input SettingsKarmaThresholdsInput {
|
||||
"""
|
||||
flag represents karma settings in relation to how well a User's flagging
|
||||
ability aligns with the moderation decicions made by moderators.
|
||||
"""
|
||||
flag: SettingsKarmaThresholdInput
|
||||
|
||||
"""
|
||||
comment represents the karma setting in relation to how well a User's comments are moderated.
|
||||
"""
|
||||
comment: SettingsKarmaThresholdInput
|
||||
}
|
||||
|
||||
input SettingsKarmaInput {
|
||||
"""
|
||||
When true, checks will be completed to ensure that the Karma checks are
|
||||
completed.
|
||||
"""
|
||||
enabled: Boolean
|
||||
|
||||
"""
|
||||
karmaThresholds contains the currently set thresholds for triggering Trust
|
||||
beheviour.
|
||||
"""
|
||||
thresholds: SettingsKarmaThresholdsInput
|
||||
}
|
||||
|
||||
"""
|
||||
SettingsInput is the partial type of the Settings type for performing mutations.
|
||||
"""
|
||||
input SettingsInput {
|
||||
moderation: MODERATION_MODE
|
||||
requireEmailConfirmation: Boolean
|
||||
infoBoxEnable: Boolean
|
||||
infoBoxContent: String
|
||||
questionBoxEnable: Boolean
|
||||
questionBoxContent: String
|
||||
questionBoxIcon: String
|
||||
premodLinksEnable: Boolean
|
||||
autoCloseStream: Boolean
|
||||
customCssUrl: String
|
||||
closedTimeout: Int
|
||||
closedMessage: String
|
||||
disableCommenting: Boolean
|
||||
disableCommentingMessage: String
|
||||
editCommentWindowLength: Int
|
||||
charCountEnable: Boolean
|
||||
charCount: Int
|
||||
organizationName: String
|
||||
organizationContactEmail: String
|
||||
# wordlist: Wordlist @auth(roles: [ADMIN, MODERATOR])
|
||||
"""
|
||||
domains will return a given list of whitelisted domains.
|
||||
"""
|
||||
domains: [String!]
|
||||
# auth: AuthSettings!
|
||||
|
||||
"""
|
||||
moderation is the moderation mode for all Asset's on the site.
|
||||
"""
|
||||
moderation: MODERATION_MODE
|
||||
|
||||
"""
|
||||
Enables a requirement for email confirmation before a user can login.
|
||||
"""
|
||||
requireEmailConfirmation: Boolean
|
||||
|
||||
"""
|
||||
infoBoxEnable will enable the Info Box content visible above the question
|
||||
box.
|
||||
"""
|
||||
infoBoxEnable: Boolean
|
||||
|
||||
"""
|
||||
infoBoxContent is the content of the Info Box.
|
||||
"""
|
||||
infoBoxContent: String
|
||||
|
||||
"""
|
||||
questionBoxEnable will enable the Question Box's content to be visible above
|
||||
the comment box.
|
||||
"""
|
||||
questionBoxEnable: Boolean
|
||||
|
||||
"""
|
||||
questionBoxContent is the content of the Question Box.
|
||||
"""
|
||||
questionBoxContent: String
|
||||
|
||||
"""
|
||||
questionBoxIcon is the icon for the Question Box.
|
||||
"""
|
||||
questionBoxIcon: String
|
||||
|
||||
"""
|
||||
premodLinksEnable will put all comments that contain links into premod.
|
||||
"""
|
||||
premodLinksEnable: Boolean
|
||||
|
||||
"""
|
||||
autoCloseStream when true will auto close the stream when the `closeTimeout`
|
||||
amount of seconds have been reached.
|
||||
"""
|
||||
autoCloseStream: Boolean
|
||||
|
||||
"""
|
||||
customCssUrl is the URL of the custom CSS used to display on the frontend.
|
||||
"""
|
||||
customCssUrl: String
|
||||
|
||||
"""
|
||||
closedTimeout is the amount of seconds from the created_at timestamp that a
|
||||
given asset will be considered closed.
|
||||
"""
|
||||
closedTimeout: Int
|
||||
|
||||
"""
|
||||
closedMessage is the message shown to the user when the given Asset is
|
||||
closed.
|
||||
"""
|
||||
closedMessage: String
|
||||
|
||||
"""
|
||||
disableCommenting will disable commenting site-wide.
|
||||
"""
|
||||
disableCommenting: Boolean
|
||||
|
||||
"""
|
||||
disableCommentingMessage will be shown above the comment stream while
|
||||
commenting is disabled site-wide.
|
||||
"""
|
||||
disableCommentingMessage: String
|
||||
|
||||
"""
|
||||
editCommentWindowLength is the length of time (in milliseconds) after a
|
||||
comment is posted that it can still be edited by the author.
|
||||
"""
|
||||
editCommentWindowLength: Int
|
||||
|
||||
"""
|
||||
charCountEnable is true when the character count restriction is enabled.
|
||||
"""
|
||||
charCountEnable: Boolean
|
||||
|
||||
"""
|
||||
charCount is the maximum number of characters a comment may be.
|
||||
"""
|
||||
charCount: Int
|
||||
|
||||
"""
|
||||
organizationName is the name of the organization.
|
||||
"""
|
||||
organizationName: String
|
||||
|
||||
"""
|
||||
organizationContactEmail is the email of the organization.
|
||||
"""
|
||||
organizationContactEmail: String
|
||||
|
||||
"""
|
||||
wordlist will return a given list of words.
|
||||
"""
|
||||
wordlist: SettingsWordlistInput
|
||||
|
||||
"""
|
||||
email is the set of credentials and settings associated with the organization.
|
||||
"""
|
||||
email: SettingsEmailInput
|
||||
|
||||
"""
|
||||
auth contains all the settings related to authentication and authorization.
|
||||
"""
|
||||
auth: SettingsAuthInput
|
||||
|
||||
"""
|
||||
integrations contains all the external integrations that can be enabled.
|
||||
"""
|
||||
integrations: SettingsExternalIntegrationsInput
|
||||
|
||||
"""
|
||||
karma is the set of settings related to how user Trust and Karma are
|
||||
handled.
|
||||
"""
|
||||
karma: SettingsKarmaInput
|
||||
}
|
||||
|
||||
"""
|
||||
UpdateSettingsInput provides the input for the updateSettings Mutation.
|
||||
"""
|
||||
input UpdateSettingsInput {
|
||||
"""
|
||||
settings is the partial set of settings that will be used as a patch against
|
||||
the existing settings object.
|
||||
"""
|
||||
settings: SettingsInput!
|
||||
|
||||
"""
|
||||
@@ -899,13 +1291,19 @@ type Mutation {
|
||||
"""
|
||||
createComment will create a Comment as the current logged in User.
|
||||
"""
|
||||
createComment(input: CreateCommentInput!): CreateCommentPayload @auth
|
||||
createComment(input: CreateCommentInput!): CreateCommentPayload
|
||||
|
||||
"""
|
||||
editComment will allow the author of a comment to change the body within the
|
||||
time allotment.
|
||||
"""
|
||||
editComment(input: EditCommentInput!): EditCommentPayload @auth
|
||||
|
||||
"""
|
||||
updateSettings will update the Settings for the given Tenant.
|
||||
"""
|
||||
updateSettings(input: UpdateSettingsInput!): UpdateSettingsPayload
|
||||
@auth(roles: [ADMIN])
|
||||
@auth(roles: [ADMIN, MODERATOR])
|
||||
}
|
||||
|
||||
################################################################################
|
||||
|
||||
@@ -6,8 +6,9 @@ import { createJWTSigningConfig } from "talk-server/app/middleware/passport/jwt"
|
||||
import getManagementSchema from "talk-server/graph/management/schema";
|
||||
import { Schemas } from "talk-server/graph/schemas";
|
||||
import getTenantSchema from "talk-server/graph/tenant/schema";
|
||||
|
||||
import { createQueue } from "talk-server/services/queue";
|
||||
import TenantCache from "talk-server/services/tenant/cache";
|
||||
|
||||
import { attachSubscriptionHandlers, createApp, listenAndServe } from "./app";
|
||||
import logger from "./logger";
|
||||
import { createMongoDB } from "./services/mongodb";
|
||||
@@ -64,7 +65,7 @@ class Server {
|
||||
const mongo = await createMongoDB(config);
|
||||
|
||||
// Setup Redis.
|
||||
const redis = await createRedisClient(config);
|
||||
const redis = createRedisClient(config);
|
||||
|
||||
// Create the signing config.
|
||||
const signingConfig = createJWTSigningConfig(this.config);
|
||||
@@ -79,9 +80,13 @@ class Server {
|
||||
// Prime the tenant cache so it'll be ready to serve now.
|
||||
await tenantCache.primeAll();
|
||||
|
||||
// Create the Job Queue.
|
||||
const queue = createQueue({ config, mongo, tenantCache });
|
||||
|
||||
// Create the Talk App, branching off from the parent app.
|
||||
const app: Express = await createApp({
|
||||
parent,
|
||||
queue,
|
||||
mongo,
|
||||
redis,
|
||||
config: this.config,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import dotize from "dotize";
|
||||
import { defaults } from "lodash";
|
||||
import { Db } from "mongodb";
|
||||
import uuid from "uuid";
|
||||
|
||||
import { Omit } from "talk-common/types";
|
||||
import { dotize } from "talk-common/utils/dotize";
|
||||
import { ModerationSettings } from "talk-server/models/settings";
|
||||
import { TenantResource } from "talk-server/models/tenant";
|
||||
|
||||
@@ -172,12 +172,20 @@ export async function updateAsset(
|
||||
db: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
update: UpdateAssetInput
|
||||
input: UpdateAssetInput
|
||||
) {
|
||||
// Only update fields that have been updated.
|
||||
const update = {
|
||||
$set: {
|
||||
...dotize(input, { embedArrays: true }),
|
||||
// Always update the updated at time.
|
||||
updated_at: new Date(),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await collection(db).findOneAndUpdate(
|
||||
{ id, tenant_id: tenantID },
|
||||
// Only update fields that have been updated.
|
||||
{ $set: dotize.convert(update) },
|
||||
update,
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
{ returnOriginal: false }
|
||||
|
||||
@@ -101,6 +101,97 @@ export async function createComment(
|
||||
return comment;
|
||||
}
|
||||
|
||||
export type EditCommentInput = Pick<
|
||||
Comment,
|
||||
"id" | "author_id" | "body" | "status"
|
||||
> & {
|
||||
/**
|
||||
* lastEditableCommentCreatedAt is the date that the last comment would have
|
||||
* been editable. It is generally derived from the tenant's
|
||||
* `editCommentWindowLength` property.
|
||||
*/
|
||||
lastEditableCommentCreatedAt: Date;
|
||||
};
|
||||
|
||||
export async function editComment(
|
||||
db: Db,
|
||||
tenantID: string,
|
||||
input: EditCommentInput
|
||||
) {
|
||||
const EDITABLE_STATUSES = [
|
||||
GQLCOMMENT_STATUS.NONE,
|
||||
GQLCOMMENT_STATUS.PREMOD,
|
||||
GQLCOMMENT_STATUS.ACCEPTED,
|
||||
];
|
||||
const createdAt = new Date();
|
||||
|
||||
const { id, body, lastEditableCommentCreatedAt, status, author_id } = input;
|
||||
|
||||
const result = await collection(db).findOneAndUpdate(
|
||||
{
|
||||
id,
|
||||
tenant_id: tenantID,
|
||||
author_id,
|
||||
status: {
|
||||
$in: EDITABLE_STATUSES,
|
||||
},
|
||||
deleted_at: null,
|
||||
created_at: {
|
||||
$gt: lastEditableCommentCreatedAt,
|
||||
},
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
body,
|
||||
status,
|
||||
},
|
||||
$push: {
|
||||
body_history: {
|
||||
body,
|
||||
created_at: createdAt,
|
||||
},
|
||||
status_history: {
|
||||
type: status,
|
||||
created_at: createdAt,
|
||||
},
|
||||
},
|
||||
},
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
{ returnOriginal: false }
|
||||
);
|
||||
if (!result.value) {
|
||||
// Try to get the comment.
|
||||
const comment = await retrieveComment(db, tenantID, id);
|
||||
if (!comment) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("comment not found");
|
||||
}
|
||||
|
||||
if (comment.author_id !== author_id) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("comment author mismatch");
|
||||
}
|
||||
|
||||
// Check to see if the comment had a status that was editable.
|
||||
if (!EDITABLE_STATUSES.includes(comment.status)) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("comment status is not editable");
|
||||
}
|
||||
|
||||
// Check to see if the edit window expired.
|
||||
if (comment.created_at <= lastEditableCommentCreatedAt) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("edit window expired");
|
||||
}
|
||||
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("comment edit failed for an unexpected reason");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export async function retrieveComment(db: Db, tenantID: string, id: string) {
|
||||
return collection(db).findOne({ id, tenant_id: tenantID });
|
||||
}
|
||||
@@ -129,7 +220,7 @@ export interface ConnectionInput {
|
||||
}
|
||||
|
||||
function cursorGetterFactory(
|
||||
input: ConnectionInput
|
||||
input: Pick<ConnectionInput, "orderBy" | "after">
|
||||
): NodeToCursorTransformer<Comment> {
|
||||
switch (input.orderBy) {
|
||||
case GQLCOMMENT_SORT.CREATED_AT_DESC:
|
||||
|
||||
@@ -1,132 +1,52 @@
|
||||
import {
|
||||
GQLAuth,
|
||||
GQLEmail,
|
||||
GQLExternalIntegrations,
|
||||
GQLKarma,
|
||||
GQLMODERATION_MODE,
|
||||
GQLUSER_ROLE,
|
||||
GQLWordlist,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
|
||||
export interface EmailDomainRuleCondition {
|
||||
/**
|
||||
* emailDomain is the domain name component of the email addresses that should
|
||||
* match for this condition.
|
||||
*/
|
||||
emailDomain: string;
|
||||
/**
|
||||
* emailVerifiedRequired stipulates that this rule only applies when the user
|
||||
* account has been marked as having their email address already verified.
|
||||
*/
|
||||
emailVerifiedRequired: boolean;
|
||||
}
|
||||
// export interface EmailDomainRuleCondition {
|
||||
// /**
|
||||
// * emailDomain is the domain name component of the email addresses that should
|
||||
// * match for this condition.
|
||||
// */
|
||||
// emailDomain: string;
|
||||
// /**
|
||||
// * emailVerifiedRequired stipulates that this rule only applies when the user
|
||||
// * account has been marked as having their email address already verified.
|
||||
// */
|
||||
// emailVerifiedRequired: boolean;
|
||||
// }
|
||||
|
||||
/**
|
||||
* RoleRule describes the role assignment for when a user logs into Talk, how
|
||||
* they can have their account automatically upgraded to a specific role when
|
||||
* the domain for their email matches the one provided.
|
||||
*/
|
||||
export interface RoleRule extends Partial<EmailDomainRuleCondition> {
|
||||
/**
|
||||
* role is the specific GQLUSER_ROLE that should be assigned to the newly
|
||||
* created user depending on their email address.
|
||||
*/
|
||||
role: GQLUSER_ROLE;
|
||||
}
|
||||
// /**
|
||||
// * RoleRule describes the role assignment for when a user logs into Talk, how
|
||||
// * they can have their account automatically upgraded to a specific role when
|
||||
// * the domain for their email matches the one provided.
|
||||
// */
|
||||
// export interface RoleRule extends Partial<EmailDomainRuleCondition> {
|
||||
// /**
|
||||
// * role is the specific GQLUSER_ROLE that should be assigned to the newly
|
||||
// * created user depending on their email address.
|
||||
// */
|
||||
// role: GQLUSER_ROLE;
|
||||
// }
|
||||
|
||||
export interface AuthRules {
|
||||
/**
|
||||
* roles allow the configuration of automatic role assignment based on the
|
||||
* user's email address.
|
||||
*/
|
||||
roles?: RoleRule[];
|
||||
// export interface AuthRules {
|
||||
// /**
|
||||
// * roles allow the configuration of automatic role assignment based on the
|
||||
// * user's email address.
|
||||
// */
|
||||
// roles?: RoleRule[];
|
||||
|
||||
/**
|
||||
* restrictTo when populated, will restrict which users can login using this
|
||||
* integration. If a user successfully logs in using the OIDCStrategy, but
|
||||
* does not match the following rules, the user will not be created.
|
||||
*/
|
||||
restrictTo?: EmailDomainRuleCondition[];
|
||||
}
|
||||
|
||||
export interface EnableableIntegration {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface DisplayNameAuthIntegration {
|
||||
displayNameEnable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSOAuthIntegration is an AuthIntegration that provides a secret to the admins
|
||||
* of a tenant, where they can sign a SSO payload with it to provide to the
|
||||
* embed to allow single sign on.
|
||||
*/
|
||||
export interface SSOAuthIntegration
|
||||
extends EnableableIntegration,
|
||||
DisplayNameAuthIntegration {
|
||||
key: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDCAuthIntegration provides a way to store Open ID Connect credentials. This
|
||||
* will be used in the admin to provide staff logins for users.
|
||||
*/
|
||||
export interface OIDCAuthIntegration
|
||||
extends EnableableIntegration,
|
||||
DisplayNameAuthIntegration {
|
||||
clientID: string;
|
||||
clientSecret: string;
|
||||
issuer: string;
|
||||
authorizationURL: string;
|
||||
jwksURI: string;
|
||||
tokenURL: string;
|
||||
}
|
||||
|
||||
export interface FacebookAuthIntegration extends EnableableIntegration {
|
||||
clientID: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
export interface GoogleAuthIntegration extends EnableableIntegration {
|
||||
clientID: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
export type LocalAuthIntegration = EnableableIntegration;
|
||||
|
||||
/**
|
||||
* AuthIntegrations describes all of the possible auth integration
|
||||
* configurations.
|
||||
*/
|
||||
export interface AuthIntegrations {
|
||||
/**
|
||||
* local is the auth integration for the email/password based auth.
|
||||
*/
|
||||
local: LocalAuthIntegration;
|
||||
|
||||
/**
|
||||
* sso is the external auth integration for the single sign on auth.
|
||||
*/
|
||||
sso?: SSOAuthIntegration;
|
||||
|
||||
/**
|
||||
* sso is the external auth integration for the OpenID Connect auth.
|
||||
*/
|
||||
oidc?: OIDCAuthIntegration;
|
||||
|
||||
/**
|
||||
* sso is the external auth integration for the Google auth.
|
||||
*/
|
||||
google?: GoogleAuthIntegration;
|
||||
|
||||
/**
|
||||
* sso is the external auth integration for the Facebook auth.
|
||||
*/
|
||||
facebook?: FacebookAuthIntegration;
|
||||
}
|
||||
|
||||
export interface Auth {
|
||||
integrations: AuthIntegrations;
|
||||
}
|
||||
// /**
|
||||
// * restrictTo when populated, will restrict which users can login using this
|
||||
// * integration. If a user successfully logs in using the OIDCStrategy, but
|
||||
// * does not match the following rules, the user will not be created.
|
||||
// */
|
||||
// restrictTo?: EmailDomainRuleCondition[];
|
||||
// }
|
||||
|
||||
export interface ModerationSettings {
|
||||
moderation: GQLMODERATION_MODE;
|
||||
@@ -155,6 +75,12 @@ export interface Settings extends ModerationSettings {
|
||||
*/
|
||||
editCommentWindowLength: number;
|
||||
|
||||
/**
|
||||
* email is the set of credentials and settings associated with the
|
||||
* Tenant.
|
||||
*/
|
||||
email: GQLEmail;
|
||||
|
||||
/**
|
||||
* karma is the set of settings related to how user Trust and Karma are
|
||||
* handled.
|
||||
@@ -169,7 +95,7 @@ export interface Settings extends ModerationSettings {
|
||||
/**
|
||||
* Set of configured authentication integrations.
|
||||
*/
|
||||
auth: Auth;
|
||||
auth: GQLAuth;
|
||||
|
||||
/**
|
||||
* Various integrations with external services.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import dotize from "dotize";
|
||||
import { Db } from "mongodb";
|
||||
import uuid from "uuid";
|
||||
|
||||
import { Omit, Sub } from "talk-common/types";
|
||||
import { DeepPartial, Omit, Sub } from "talk-common/types";
|
||||
import { dotize } from "talk-common/utils/dotize";
|
||||
import { GQLMODERATION_MODE } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { Settings } from "talk-server/models/settings";
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface Tenant extends Settings {
|
||||
domains: string[];
|
||||
|
||||
organizationName: string;
|
||||
organizationURL: string;
|
||||
organizationContactEmail: string;
|
||||
}
|
||||
|
||||
@@ -38,7 +39,11 @@ export interface Tenant extends Settings {
|
||||
*/
|
||||
export type CreateTenantInput = Pick<
|
||||
Tenant,
|
||||
"domain" | "organizationName" | "organizationContactEmail" | "domains"
|
||||
| "domain"
|
||||
| "organizationName"
|
||||
| "organizationURL"
|
||||
| "organizationContactEmail"
|
||||
| "domains"
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -76,8 +81,23 @@ export async function createTenant(db: Db, input: CreateTenantInput) {
|
||||
local: {
|
||||
enabled: true,
|
||||
},
|
||||
sso: {
|
||||
enabled: false,
|
||||
},
|
||||
oidc: {
|
||||
enabled: false,
|
||||
},
|
||||
google: {
|
||||
enabled: false,
|
||||
},
|
||||
facebook: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
email: {
|
||||
enabled: false,
|
||||
},
|
||||
karma: {
|
||||
enabled: true,
|
||||
thresholds: {
|
||||
@@ -149,7 +169,7 @@ export async function retrieveAllTenants(db: Db) {
|
||||
.toArray();
|
||||
}
|
||||
|
||||
export type UpdateTenantInput = Omit<Partial<Tenant>, "id" | "domain">;
|
||||
export type UpdateTenantInput = Omit<DeepPartial<Tenant>, "id" | "domain">;
|
||||
|
||||
export async function updateTenant(
|
||||
db: Db,
|
||||
@@ -160,7 +180,7 @@ export async function updateTenant(
|
||||
const result = await collection(db).findOneAndUpdate(
|
||||
{ id },
|
||||
// Only update fields that have been updated.
|
||||
{ $set: dotize.convert(update) },
|
||||
{ $set: dotize(update, { embedArrays: true }) },
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
{ returnOriginal: false }
|
||||
|
||||
@@ -5,17 +5,31 @@ import {
|
||||
FindOrCreateAssetInput,
|
||||
} from "talk-server/models/asset";
|
||||
import { Tenant } from "talk-server/models/tenant";
|
||||
import Task from "talk-server/services/queue/Task";
|
||||
import { ScraperData } from "talk-server/services/queue/tasks/scraper";
|
||||
|
||||
export type FindOrCreateAsset = FindOrCreateAssetInput;
|
||||
|
||||
export async function findOrCreate(
|
||||
db: Db,
|
||||
tenant: Tenant,
|
||||
input: FindOrCreateAsset
|
||||
input: FindOrCreateAsset,
|
||||
scraper: Task<ScraperData>
|
||||
) {
|
||||
// TODO: check to see if the tenant has enabled lazy asset creation.
|
||||
|
||||
const asset = await findOrCreateAsset(db, tenant.id, input);
|
||||
if (!asset) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!asset.scraped) {
|
||||
await scraper.add({
|
||||
assetID: asset.id,
|
||||
assetURL: asset.url,
|
||||
tenantID: tenant.id,
|
||||
});
|
||||
}
|
||||
|
||||
return asset;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { retrieveAsset } from "talk-server/models/asset";
|
||||
import {
|
||||
createComment,
|
||||
CreateCommentInput,
|
||||
editComment,
|
||||
EditCommentInput,
|
||||
retrieveComment,
|
||||
} from "talk-server/models/comment";
|
||||
import { Tenant } from "talk-server/models/tenant";
|
||||
@@ -24,13 +26,14 @@ export async function create(
|
||||
input: CreateComment,
|
||||
req?: Request
|
||||
) {
|
||||
// Grab the asset that we'll use to check moderation pieces with.
|
||||
const asset = await retrieveAsset(mongo, tenant.id, input.asset_id);
|
||||
if (!asset) {
|
||||
// TODO: (wyattjoh) return better error.
|
||||
throw new Error("asset referenced does not exist");
|
||||
}
|
||||
|
||||
// TODO: (wyattjoh) Check that the asset was visable.
|
||||
// TODO: (wyattjoh) Check that the asset was visible.
|
||||
|
||||
if (input.parent_id) {
|
||||
// Check to see that the reference parent ID exists.
|
||||
@@ -67,3 +70,55 @@ export async function create(
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
export type EditComment = Omit<
|
||||
EditCommentInput,
|
||||
"status" | "author_id" | "lastEditableCommentCreatedAt"
|
||||
> & {
|
||||
/**
|
||||
* asset_id is the asset that the comment exists on.
|
||||
*/
|
||||
asset_id: string;
|
||||
};
|
||||
|
||||
export async function edit(
|
||||
mongo: Db,
|
||||
tenant: Tenant,
|
||||
author: User,
|
||||
input: EditComment,
|
||||
req?: Request
|
||||
) {
|
||||
// Grab the asset that we'll use to check moderation pieces with.
|
||||
const asset = await retrieveAsset(mongo, tenant.id, input.asset_id);
|
||||
if (!asset) {
|
||||
// TODO: (wyattjoh) return better error.
|
||||
throw new Error("asset referenced does not exist");
|
||||
}
|
||||
|
||||
// Run the comment through the moderation phases.
|
||||
const { status } = await processForModeration({
|
||||
asset,
|
||||
tenant,
|
||||
comment: input,
|
||||
author,
|
||||
req,
|
||||
});
|
||||
|
||||
// TODO: (wyattjoh) use the actions somehow.
|
||||
|
||||
const comment = await editComment(mongo, tenant.id, {
|
||||
id: input.id,
|
||||
author_id: author.id,
|
||||
body: input.body,
|
||||
status,
|
||||
// The editable time is based on the current time, and the edit window
|
||||
// length. By subtracting the current date from the edit window length, we
|
||||
// get the maximum value for the `created_at` time that would be permitted
|
||||
// for the comment edit to succeed.
|
||||
lastEditableCommentCreatedAt: new Date(
|
||||
Date.now() - tenant.editCommentWindowLength
|
||||
),
|
||||
});
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { Omit, Promiseable } from "talk-common/types";
|
||||
import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { Action } from "talk-server/models/actions";
|
||||
import { Asset } from "talk-server/models/asset";
|
||||
import { Comment } from "talk-server/models/comment";
|
||||
import { Tenant } from "talk-server/models/tenant";
|
||||
import { User } from "talk-server/models/user";
|
||||
import { CreateComment } from "talk-server/services/comments";
|
||||
import { Request } from "talk-server/types/express";
|
||||
|
||||
import { moderationPhases } from "./phases";
|
||||
@@ -24,7 +24,7 @@ export interface PhaseResult {
|
||||
export interface ModerationPhaseContext {
|
||||
asset: Asset;
|
||||
tenant: Tenant;
|
||||
comment: CreateComment;
|
||||
comment: Partial<Comment>;
|
||||
author: User;
|
||||
req?: Request;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export const commentLength: IntermediateModerationPhase = ({
|
||||
tenant,
|
||||
comment,
|
||||
}): IntermediatePhaseResult | void => {
|
||||
const length = comment.body.length;
|
||||
const length = comment.body ? comment.body.length : 0;
|
||||
|
||||
// Check to see if the body is too short, if it is, then complain about it!
|
||||
if (length < 2) {
|
||||
|
||||
@@ -30,8 +30,9 @@ export const links: IntermediateModerationPhase = ({
|
||||
comment,
|
||||
}): IntermediatePhaseResult | void => {
|
||||
if (
|
||||
testPremodLinksEnable(tenant, comment.body) ||
|
||||
(asset.settings && testPremodLinksEnable(asset.settings, comment.body))
|
||||
comment.body &&
|
||||
(testPremodLinksEnable(tenant, comment.body) ||
|
||||
(asset.settings && testPremodLinksEnable(asset.settings, comment.body)))
|
||||
) {
|
||||
// Add the flag related to Trust to the comment.
|
||||
return {
|
||||
|
||||
@@ -32,14 +32,27 @@ export const spam: IntermediateModerationPhase = async ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!integration.key || !integration.site) {
|
||||
if (!integration.key) {
|
||||
logger.error(
|
||||
{ tenant_id: tenant.id },
|
||||
"akismet integration was enabled but configuration was missing"
|
||||
"akismet integration was enabled but the key configuration was missing"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!integration.site) {
|
||||
logger.error(
|
||||
{ tenant_id: tenant.id },
|
||||
"akismet integration was enabled but the site configuration was missing"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the comment doesn't have a body, it can't be spam!
|
||||
if (!comment.body) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the Akismet client.
|
||||
const client = new Client({
|
||||
key: integration.key,
|
||||
|
||||
@@ -9,9 +9,6 @@ import {
|
||||
|
||||
// If a given user is a staff member, always approve their comment.
|
||||
export const staff: IntermediateModerationPhase = ({
|
||||
asset,
|
||||
tenant,
|
||||
comment,
|
||||
author,
|
||||
}): IntermediatePhaseResult | void => {
|
||||
if (author.role !== GQLUSER_ROLE.COMMENTER) {
|
||||
|
||||
@@ -19,6 +19,10 @@ export const toxic: IntermediateModerationPhase = async ({
|
||||
tenant,
|
||||
comment,
|
||||
}): Promise<IntermediatePhaseResult | void> => {
|
||||
if (!comment.body) {
|
||||
return;
|
||||
}
|
||||
|
||||
const integration = tenant.integrations.perspective;
|
||||
|
||||
if (!integration.enabled) {
|
||||
@@ -34,7 +38,7 @@ export const toxic: IntermediateModerationPhase = async ({
|
||||
// The Toxic comment requires a key in order to communicate with the API.
|
||||
logger.error(
|
||||
{ tenant_id: tenant.id },
|
||||
"perspective integration was enabled but configuration was missing"
|
||||
"perspective integration was enabled but the key configuration was missing"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@ export const wordlist: IntermediateModerationPhase = ({
|
||||
tenant,
|
||||
comment,
|
||||
}): IntermediatePhaseResult | void => {
|
||||
// If there isn't a body, there can't be a bad word!
|
||||
if (!comment.body) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Decide the status based on whether or not the current asset/settings
|
||||
// has pre-mod enabled or not. If the comment was rejected based on the
|
||||
// wordlist, then reject it, otherwise if the moderation setting is
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import Queue, { Job, Queue as QueueType } from "bull";
|
||||
import logger from "talk-server/logger";
|
||||
|
||||
export interface TaskOptions<T, U = any> {
|
||||
jobName: string;
|
||||
jobProcessor: (job: Job<T>) => Promise<U>;
|
||||
queue: Queue.QueueOptions;
|
||||
}
|
||||
|
||||
export default class Task<T, U = any> {
|
||||
private options: TaskOptions<T, U>;
|
||||
private queue: QueueType<T>;
|
||||
constructor(options: TaskOptions<T, U>) {
|
||||
this.queue = new Queue(options.jobName, options.queue);
|
||||
this.options = options;
|
||||
|
||||
// Sets up and attaches the job processor to the queue.
|
||||
this.setupAndAttachProcessor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add will add the job to the queue to get processed. It's not needed to
|
||||
* handle the job after it has been created.
|
||||
*
|
||||
* @param data the data for the job to add.
|
||||
*/
|
||||
public async add(data: T) {
|
||||
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.
|
||||
removeOnComplete: true,
|
||||
});
|
||||
|
||||
logger.trace(
|
||||
{ job_id: job.id, job_name: this.options.jobName },
|
||||
"added job to queue"
|
||||
);
|
||||
return job;
|
||||
}
|
||||
private setupAndAttachProcessor() {
|
||||
this.queue.process(async (job: Job<T>) => {
|
||||
logger.trace(
|
||||
{ job_id: job.id, job_name: this.options.jobName },
|
||||
"processing job from queue"
|
||||
);
|
||||
|
||||
// Send the job off to the job processor to be handled.
|
||||
const promise: U = await this.options.jobProcessor(job);
|
||||
logger.trace(
|
||||
{ job_id: job.id, job_name: this.options.jobName },
|
||||
"processing completed"
|
||||
);
|
||||
return promise;
|
||||
});
|
||||
|
||||
logger.trace(
|
||||
{ job_name: this.options.jobName },
|
||||
"registered processor for job type"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import Queue from "bull";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { Config } from "talk-common/config";
|
||||
import Task from "talk-server/services/queue/Task";
|
||||
import {
|
||||
createMailerTask,
|
||||
Mailer,
|
||||
} from "talk-server/services/queue/tasks/mailer";
|
||||
import {
|
||||
createScraperTask,
|
||||
ScraperData,
|
||||
} from "talk-server/services/queue/tasks/scraper";
|
||||
import { createRedisClient } from "talk-server/services/redis";
|
||||
import TenantCache from "talk-server/services/tenant/cache";
|
||||
|
||||
const createQueueOptions = (config: Config): Queue.QueueOptions => {
|
||||
const client = createRedisClient(config);
|
||||
const subscriber = createRedisClient(config);
|
||||
const blockingClient = createRedisClient(config);
|
||||
|
||||
// Return the options that can be used by the Queue.
|
||||
return {
|
||||
// Here, we are reusing the clients based on the requested types. This way,
|
||||
// any time we need a specific client, we get to use one of the ones that
|
||||
// already have been created.
|
||||
createClient: type => {
|
||||
switch (type) {
|
||||
case "subscriber":
|
||||
return subscriber;
|
||||
case "client":
|
||||
return client;
|
||||
case "bclient":
|
||||
return blockingClient;
|
||||
}
|
||||
},
|
||||
|
||||
// Because bull uses atomic operations across separate keys, we need to add
|
||||
// a prefix to the keys to help the Redis cluster place all those elements
|
||||
// together to support the atomic operations. See:
|
||||
// https://redis.io/topics/cluster-tutorial
|
||||
prefix: "{queue}",
|
||||
};
|
||||
};
|
||||
|
||||
export interface QueueOptions {
|
||||
mongo: Db;
|
||||
config: Config;
|
||||
tenantCache: TenantCache;
|
||||
}
|
||||
|
||||
export interface TaskQueue {
|
||||
mailer: Mailer;
|
||||
scraper: Task<ScraperData>;
|
||||
}
|
||||
|
||||
export function createQueue(options: QueueOptions): TaskQueue {
|
||||
// Create the processor queue options. This holds references to the Redis
|
||||
// clients that are shared per queue.
|
||||
const queueOptions = createQueueOptions(options.config);
|
||||
|
||||
// Attach process functions to the various tasks in the queue.
|
||||
const mailer = createMailerTask(queueOptions, options);
|
||||
const scraper = createScraperTask(queueOptions, options);
|
||||
|
||||
// Return the tasks + client.
|
||||
return {
|
||||
mailer,
|
||||
scraper,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import Queue, { Job, Queue as QueueType } from "bull";
|
||||
import logger from "talk-server/logger";
|
||||
|
||||
export interface TaskOptions<T, U = any> {
|
||||
jobName: string;
|
||||
jobProcessor: (job: Job<T>) => Promise<U>;
|
||||
queue: Queue.QueueOptions;
|
||||
}
|
||||
|
||||
export default class Task<T, U = any> {
|
||||
private options: TaskOptions<T, U>;
|
||||
private queue: QueueType<T>;
|
||||
|
||||
constructor(options: TaskOptions<T, U>) {
|
||||
this.queue = new Queue(options.jobName, options.queue);
|
||||
this.options = options;
|
||||
|
||||
// Sets up and attaches the job processor to the queue.
|
||||
this.setupAndAttachProcessor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add will add the job to the queue to get processed. It's not needed to
|
||||
* handle the job after it has been created.
|
||||
*
|
||||
* @param data the data for the job to add.
|
||||
*/
|
||||
public async add(data: T) {
|
||||
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.
|
||||
removeOnComplete: true,
|
||||
});
|
||||
|
||||
logger.trace(
|
||||
{ job_id: job.id, job_name: this.options.jobName },
|
||||
"added job to queue"
|
||||
);
|
||||
return job;
|
||||
}
|
||||
|
||||
private setupAndAttachProcessor() {
|
||||
this.queue.process(async (job: Job<T>) => {
|
||||
logger.trace(
|
||||
{ job_id: job.id, job_name: this.options.jobName },
|
||||
"processing job from queue"
|
||||
);
|
||||
|
||||
// Send the job off to the job processor to be handled.
|
||||
const promise: U = await this.options.jobProcessor(job);
|
||||
logger.trace(
|
||||
{ job_id: job.id, job_name: this.options.jobName },
|
||||
"processing completed"
|
||||
);
|
||||
return promise;
|
||||
});
|
||||
|
||||
logger.trace(
|
||||
{ job_name: this.options.jobName },
|
||||
"registered processor for job type"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import nunjucks from "nunjucks";
|
||||
import path from "path";
|
||||
|
||||
import { Config } from "talk-common/config";
|
||||
|
||||
/**
|
||||
* templateDirectory is the directory containing the email templates.
|
||||
*/
|
||||
const templateDirectory = path.join(__dirname, "templates");
|
||||
|
||||
export interface GenerateHTMLOptions {
|
||||
/**
|
||||
* name is the name of the template to render.
|
||||
*/
|
||||
name: string;
|
||||
context: any;
|
||||
}
|
||||
|
||||
export default class MailerContent {
|
||||
private env: nunjucks.Environment;
|
||||
|
||||
constructor(config: Config) {
|
||||
// Configure the nunjucks environment.
|
||||
this.env = new nunjucks.Environment(
|
||||
new nunjucks.FileSystemLoader(templateDirectory),
|
||||
{
|
||||
// When we aren't in production mode, reload the templates.
|
||||
watch: config.get("env") !== "production",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* generateHTML will generate the HTML for a template and optionally cache
|
||||
* the compiled template based on the configured environment.
|
||||
*
|
||||
* @param options configuration for generating HTML based on the email
|
||||
* template.
|
||||
*/
|
||||
public generateHTML(options: GenerateHTMLOptions): string {
|
||||
return this.env.render(options.name + ".html", options.context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import Queue, { Job } from "bull";
|
||||
import htmlToText from "html-to-text";
|
||||
import Joi from "joi";
|
||||
import { Db } from "mongodb";
|
||||
import { createTransport } from "nodemailer";
|
||||
|
||||
import { Config } from "talk-common/config";
|
||||
import logger from "talk-server/logger";
|
||||
import Task from "talk-server/services/queue/Task";
|
||||
import MailerContent from "talk-server/services/queue/tasks/mailer/content";
|
||||
import TenantCache from "talk-server/services/tenant/cache";
|
||||
import { TenantCacheAdapter } from "talk-server/services/tenant/cache/adapter";
|
||||
|
||||
const JOB_NAME = "mailer";
|
||||
|
||||
export interface MailProcessorOptions {
|
||||
config: Config;
|
||||
mongo: Db;
|
||||
tenantCache: TenantCache;
|
||||
}
|
||||
|
||||
export interface MailerData {
|
||||
message: {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
};
|
||||
tenantID: string;
|
||||
}
|
||||
|
||||
const MailerDataSchema = Joi.object().keys({
|
||||
message: Joi.object().keys({
|
||||
to: Joi.string(),
|
||||
subject: Joi.string(),
|
||||
html: Joi.string(),
|
||||
}),
|
||||
tenantID: Joi.string(),
|
||||
});
|
||||
|
||||
const createJobProcessor = (options: MailProcessorOptions) => {
|
||||
const { tenantCache } = options;
|
||||
|
||||
// Create the cache adapter that will handle invalidating the email transport
|
||||
// when the tenant experiences a change.
|
||||
const cache = new TenantCacheAdapter<ReturnType<typeof createTransport>>(
|
||||
tenantCache
|
||||
);
|
||||
|
||||
return async (job: Job<MailerData>) => {
|
||||
const { value, error: err } = Joi.validate(job.data, MailerDataSchema, {
|
||||
stripUnknown: true,
|
||||
presence: "required",
|
||||
abortEarly: false,
|
||||
});
|
||||
if (err) {
|
||||
logger.error(
|
||||
{
|
||||
job_id: job.id,
|
||||
job_name: JOB_NAME,
|
||||
err,
|
||||
},
|
||||
"job data did not match expected schema"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pull the data out of the validated model.
|
||||
const { message, tenantID } = value;
|
||||
|
||||
// Get the referenced tenant so we know who to send it from.
|
||||
const tenant = await tenantCache.retrieveByID(tenantID);
|
||||
if (!tenant) {
|
||||
logger.error(
|
||||
{
|
||||
job_id: job.id,
|
||||
job_name: JOB_NAME,
|
||||
tenant_id: tenantID,
|
||||
},
|
||||
"referenced tenant was not found"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tenant.email.enabled) {
|
||||
logger.error(
|
||||
{
|
||||
job_id: job.id,
|
||||
job_name: JOB_NAME,
|
||||
tenant_id: tenantID,
|
||||
},
|
||||
"not sending email, it was disabled"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tenant.email.smtpURI) {
|
||||
logger.error(
|
||||
{
|
||||
job_id: job.id,
|
||||
job_name: JOB_NAME,
|
||||
tenant_id: tenantID,
|
||||
},
|
||||
"email was enabled but the smtpURI configuration was missing"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tenant.email.fromAddress) {
|
||||
// TODO: possibly have fallback email address?
|
||||
logger.error(
|
||||
{
|
||||
job_id: job.id,
|
||||
job_name: JOB_NAME,
|
||||
tenant_id: tenantID,
|
||||
},
|
||||
"email was enabled but the fromAddress configuration was missing"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let transport = cache.get(tenantID);
|
||||
if (!transport) {
|
||||
// Create the transport based on the smtp uri.
|
||||
transport = createTransport(tenant.email.smtpURI);
|
||||
|
||||
// Set the transport back into the cache.
|
||||
cache.set(tenantID, transport);
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
job_id: job.id,
|
||||
job_name: JOB_NAME,
|
||||
tenant_id: tenantID,
|
||||
},
|
||||
"transport was not cached"
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
{
|
||||
job_id: job.id,
|
||||
job_name: JOB_NAME,
|
||||
tenant_id: tenantID,
|
||||
},
|
||||
"transport was cached"
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
job_id: job.id,
|
||||
job_name: JOB_NAME,
|
||||
tenant_id: tenantID,
|
||||
},
|
||||
"starting to send the email"
|
||||
);
|
||||
|
||||
// Send the mail message.
|
||||
await transport.sendMail({
|
||||
...message,
|
||||
// Generate the text content of the message from the HTML.
|
||||
text: htmlToText.fromString(message.html),
|
||||
from: tenant.email.fromAddress,
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
job_id: job.id,
|
||||
job_name: JOB_NAME,
|
||||
tenant_id: tenantID,
|
||||
},
|
||||
"sent the email"
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export interface MailerInput {
|
||||
message: {
|
||||
to: string;
|
||||
subject: string;
|
||||
};
|
||||
template: {
|
||||
name: string;
|
||||
context: object;
|
||||
};
|
||||
tenantID: string;
|
||||
}
|
||||
|
||||
export class Mailer {
|
||||
private task: Task<MailerData>;
|
||||
private content: MailerContent;
|
||||
private tenantCache: TenantCache;
|
||||
|
||||
constructor(queue: Queue.QueueOptions, options: MailProcessorOptions) {
|
||||
this.task = new Task<MailerData>({
|
||||
jobName: JOB_NAME,
|
||||
jobProcessor: createJobProcessor(options),
|
||||
queue,
|
||||
});
|
||||
this.content = new MailerContent(options.config);
|
||||
this.tenantCache = options.tenantCache;
|
||||
}
|
||||
|
||||
public async add({ template, ...rest }: MailerInput) {
|
||||
const { tenantID } = rest;
|
||||
|
||||
// All email templates require the tenant in order to insert the footer, so
|
||||
// load it from the tenant cache here.
|
||||
const tenant = await this.tenantCache.retrieveByID(tenantID);
|
||||
if (!tenant) {
|
||||
logger.error(
|
||||
{
|
||||
job_name: JOB_NAME,
|
||||
tenant_id: tenantID,
|
||||
},
|
||||
"referenced tenant was not found"
|
||||
);
|
||||
// TODO: (wyattjoh) maybe throw an error here?
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tenant.email.enabled) {
|
||||
logger.error(
|
||||
{
|
||||
job_name: JOB_NAME,
|
||||
tenant_id: tenantID,
|
||||
},
|
||||
"not adding email, it was disabled"
|
||||
);
|
||||
// TODO: (wyattjoh) maybe throw an error here?
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate the HTML for the email template.
|
||||
const html = this.content.generateHTML({
|
||||
...template,
|
||||
context: {
|
||||
...template.context,
|
||||
tenant,
|
||||
},
|
||||
});
|
||||
|
||||
// Return the job that'll add the email to the queue to be processed later.
|
||||
return this.task.add({
|
||||
...rest,
|
||||
message: {
|
||||
...rest.message,
|
||||
html,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const createMailerTask = (
|
||||
queue: Queue.QueueOptions,
|
||||
options: MailProcessorOptions
|
||||
) => new Mailer(queue, options);
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head></head>
|
||||
<body>
|
||||
<h1>Forgot Password</h1>
|
||||
<p>We received a request to reset your password on <a href="{{ tenant.organizationURL }}">{{ tenant.organizationName }}</a>.</p>
|
||||
<p>Please follow the link below to reset your password:</p>
|
||||
<p><a href="{{ resetURL }}">Click here to reset your password</a></p>
|
||||
<p><i>If you did not request this change, you can ignore this email.</i></p>
|
||||
<footer>
|
||||
<p>Sent by <a href="{{ tenant.organizationURL }}">{{ tenant.organizationName }}</a></p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,173 @@
|
||||
import Queue, { Job } from "bull";
|
||||
import cheerio from "cheerio";
|
||||
import authorScraper from "metascraper-author";
|
||||
import dateScraper from "metascraper-date";
|
||||
import descriptionScraper from "metascraper-description";
|
||||
import imageScraper from "metascraper-image";
|
||||
import titleScraper from "metascraper-title";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import logger from "talk-server/logger";
|
||||
import { updateAsset } from "talk-server/models/asset";
|
||||
import Task from "talk-server/services/queue/Task";
|
||||
import { modifiedScraper } from "./rules/modified";
|
||||
import { sectionScraper } from "./rules/section";
|
||||
|
||||
const JOB_NAME = "scraper";
|
||||
|
||||
export interface ScrapeProcessorOptions {
|
||||
mongo: Db;
|
||||
}
|
||||
|
||||
export interface ScraperData {
|
||||
assetID: string;
|
||||
assetURL: string;
|
||||
tenantID: string;
|
||||
}
|
||||
|
||||
const createJobProcessor = (
|
||||
options: ScrapeProcessorOptions,
|
||||
scraper: Scraper
|
||||
) => async (job: Job<ScraperData>) => {
|
||||
// Pull out the job data.
|
||||
const { assetID: id, assetURL: url, tenantID } = job.data;
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
job_id: job.id,
|
||||
job_name: JOB_NAME,
|
||||
asset_id: id,
|
||||
asset_url: url,
|
||||
tenant_id: tenantID,
|
||||
},
|
||||
"starting to scrap the asset"
|
||||
);
|
||||
|
||||
// Get the metadata from the scraped html.
|
||||
const meta = await scraper.scrape(url);
|
||||
if (!meta) {
|
||||
logger.error(
|
||||
{
|
||||
job_id: job.id,
|
||||
job_name: JOB_NAME,
|
||||
asset_id: id,
|
||||
asset_url: url,
|
||||
tenant_id: tenantID,
|
||||
},
|
||||
"asset at specified url not found, can not scrape"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the Asset with the scraped details.
|
||||
const asset = await updateAsset(options.mongo, tenantID, id, {
|
||||
title: meta.title || undefined,
|
||||
description: meta.description || undefined,
|
||||
image: meta.image ? meta.image : undefined,
|
||||
author: meta.author || undefined,
|
||||
publication_date: meta.date ? new Date(meta.date) : undefined,
|
||||
modified_date: meta.modified ? new Date(meta.modified) : undefined,
|
||||
section: meta.section || undefined,
|
||||
scraped: new Date(),
|
||||
});
|
||||
if (!asset) {
|
||||
logger.error(
|
||||
{
|
||||
job_id: job.id,
|
||||
job_name: JOB_NAME,
|
||||
asset_id: id,
|
||||
asset_url: url,
|
||||
tenant_id: tenantID,
|
||||
},
|
||||
"asset at specified id not found, can not update with metadata"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
job_id: job.id,
|
||||
job_name: JOB_NAME,
|
||||
asset_id: asset.id,
|
||||
asset_url: url,
|
||||
tenant_id: tenantID,
|
||||
},
|
||||
"scraped the asset"
|
||||
);
|
||||
};
|
||||
|
||||
export type Rule = Record<
|
||||
string,
|
||||
Array<
|
||||
(options: { htmlDom: CheerioSelector; url: string }) => string | undefined
|
||||
>
|
||||
>;
|
||||
|
||||
class Scraper {
|
||||
private rules: Rule[];
|
||||
|
||||
constructor(rules: Rule[]) {
|
||||
this.rules = rules;
|
||||
}
|
||||
|
||||
public async scrape(url: string) {
|
||||
// Grab the page HTML.
|
||||
|
||||
// TODO: investigate adding scraping proxy support.
|
||||
const res = await fetch(url, {});
|
||||
if (res.status !== 200) {
|
||||
return;
|
||||
}
|
||||
|
||||
const html = await res.text();
|
||||
|
||||
// Load the DOM.
|
||||
const htmlDom = cheerio.load(html);
|
||||
|
||||
// Gather the results by evaluating each of the rules.
|
||||
const metadata: Record<string, string | undefined> = {};
|
||||
|
||||
for (const rule of this.rules) {
|
||||
for (const property in rule) {
|
||||
if (!rule.hasOwnProperty(property)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Proceed through each of the properties and try to find the mapped
|
||||
// properties.
|
||||
for (const getter of rule[property]) {
|
||||
const value = getter({ htmlDom, url });
|
||||
if (value && value.length > 0) {
|
||||
metadata[property] = value;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
|
||||
export function createScraperTask(
|
||||
queue: Queue.QueueOptions,
|
||||
options: ScrapeProcessorOptions
|
||||
) {
|
||||
// Create the scraper object.
|
||||
const scraper = new Scraper([
|
||||
authorScraper(),
|
||||
dateScraper(),
|
||||
descriptionScraper(),
|
||||
imageScraper(),
|
||||
titleScraper(),
|
||||
modifiedScraper(),
|
||||
sectionScraper(),
|
||||
]);
|
||||
|
||||
return new Task({
|
||||
jobName: JOB_NAME,
|
||||
jobProcessor: createJobProcessor(options, scraper),
|
||||
queue,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Rules } from "metascraper";
|
||||
|
||||
export const modifiedScraper = (): Rules => ({
|
||||
modified: [
|
||||
({ htmlDom: $ }) => $('meta[property="article:modified"]').attr("content"),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Rules } from "metascraper";
|
||||
|
||||
export const sectionScraper = (): Rules => ({
|
||||
section: [
|
||||
({ htmlDom: $ }) => $('meta[property="article:section"]').attr("content"),
|
||||
],
|
||||
});
|
||||
@@ -6,6 +6,6 @@ import { Config } from "talk-common/config";
|
||||
*
|
||||
* @param config application configuration.
|
||||
*/
|
||||
export async function createRedisClient(config: Config): Promise<Redis> {
|
||||
export function createRedisClient(config: Config): Redis {
|
||||
return new RedisClient(config.get("redis"), {});
|
||||
}
|
||||
|
||||
+8
-14
@@ -1,4 +1,3 @@
|
||||
import { Config } from "talk-common/config";
|
||||
import TenantCache from "talk-server/services/tenant/cache";
|
||||
|
||||
export type DeconstructionFn<T> = (tenantID: string, value: T) => Promise<void>;
|
||||
@@ -12,26 +11,23 @@ export type DeconstructionFn<T> = (tenantID: string, value: T) => Promise<void>;
|
||||
export class TenantCacheAdapter<T> {
|
||||
private cache = new Map<string, T>();
|
||||
private tenantCache: TenantCache;
|
||||
private isCachingEnabled: boolean;
|
||||
|
||||
private unsubscribeFn?: () => void;
|
||||
private deconstructionFn?: DeconstructionFn<T>;
|
||||
|
||||
constructor(
|
||||
tenantCache: TenantCache,
|
||||
config: Config,
|
||||
deconstructionFn?: DeconstructionFn<T>
|
||||
) {
|
||||
this.tenantCache = tenantCache;
|
||||
this.isCachingEnabled = !config.get("disable_tenant_caching");
|
||||
this.deconstructionFn = deconstructionFn;
|
||||
|
||||
// Subscribe to updates immediately.
|
||||
this.subscribe();
|
||||
}
|
||||
|
||||
public subscribe() {
|
||||
if (this.isCachingEnabled) {
|
||||
// Unsubscribe from updates if we
|
||||
this.unsubscribe();
|
||||
|
||||
if (this.tenantCache.cachingEnabled && !this.unsubscribeFn) {
|
||||
this.unsubscribeFn = this.tenantCache.subscribe(async tenant => {
|
||||
// Get the current set value for the item in the cache.
|
||||
const value = this.get(tenant.id);
|
||||
@@ -58,7 +54,7 @@ export class TenantCacheAdapter<T> {
|
||||
* This will disconnect the map/cache from getting updates.
|
||||
*/
|
||||
public unsubscribe() {
|
||||
if (this.isCachingEnabled && this.unsubscribeFn) {
|
||||
if (this.tenantCache.cachingEnabled && this.unsubscribeFn) {
|
||||
this.unsubscribeFn();
|
||||
}
|
||||
}
|
||||
@@ -69,7 +65,7 @@ export class TenantCacheAdapter<T> {
|
||||
* @param tenantID the tenantID for the cached item
|
||||
*/
|
||||
public get(tenantID: string): T | undefined {
|
||||
if (this.isCachingEnabled) {
|
||||
if (this.tenantCache.cachingEnabled) {
|
||||
return this.cache.get(tenantID);
|
||||
}
|
||||
|
||||
@@ -82,11 +78,9 @@ export class TenantCacheAdapter<T> {
|
||||
* @param tenantID the tenantID for the cached item
|
||||
* @param value the value to set in the map (if caching is enabled)
|
||||
*/
|
||||
public set(tenantID: string, value: T): TenantCacheAdapter<T> {
|
||||
if (this.isCachingEnabled) {
|
||||
public set(tenantID: string, value: T) {
|
||||
if (this.tenantCache.cachingEnabled) {
|
||||
this.cache.set(tenantID, value);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -47,7 +47,11 @@ export default class TenantCache {
|
||||
|
||||
private mongo: Db;
|
||||
private emitter = new EventEmitter();
|
||||
private cachingEnabled: boolean;
|
||||
|
||||
/**
|
||||
* cachingEnabled is true when tenant caching has been enabled.
|
||||
*/
|
||||
public cachingEnabled: boolean;
|
||||
|
||||
constructor(mongo: Db, subscriber: Redis, config: Config) {
|
||||
this.cachingEnabled = !config.get("disable_tenant_caching");
|
||||
@@ -124,7 +128,7 @@ export default class TenantCache {
|
||||
this.tenantsByDomain.prime(tenant.domain, tenant);
|
||||
});
|
||||
|
||||
logger.debug({ tenants: tenants.length }, "primed tenants");
|
||||
logger.debug({ tenants: tenants.length }, "primed all tenants");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { Redis } from "ioredis";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { GQLSettingsInput } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import {
|
||||
Tenant,
|
||||
updateTenant,
|
||||
UpdateTenantInput,
|
||||
// UpdateTenantInput,
|
||||
} from "talk-server/models/tenant";
|
||||
|
||||
import TenantCache from "./cache";
|
||||
|
||||
export type UpdateTenant = UpdateTenantInput;
|
||||
export type UpdateTenant = GQLSettingsInput;
|
||||
|
||||
export async function update(
|
||||
db: Db,
|
||||
|
||||
Reference in New Issue
Block a user