initial commit

This commit is contained in:
Wyatt Johnson
2018-06-16 17:20:51 -06:00
commit 02e1236792
39 changed files with 5258 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.env
*.js
+3
View File
@@ -0,0 +1,3 @@
{
"schemaPath": "src/core/server/graph/schema/schema.graphql"
}
+5
View File
@@ -0,0 +1,5 @@
{
"execMap": {
"ts": "ts-node"
}
}
+1
View File
@@ -0,0 +1 @@
dist
+4
View File
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "es5"
}
+21
View File
@@ -0,0 +1,21 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"cwd": "${workspaceFolder}",
"program": "${workspaceFolder}/node_modules/.bin/ts-node",
"args": [
"-r",
"tsconfig-paths/register",
"${workspaceFolder}/src/index.ts"
],
"outputCapture": "std"
}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"editor.formatOnSave": true,
"files.exclude": {
"**/.git": true,
"**/.svn": true,
"**/.hg": true,
"**/CVS": true,
"**/.DS_Store": true,
"node_modules": true,
"dist": true,
".vscode": true,
"package-lock.json": true
}
}
+3868
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
{
"name": "@coralproject/talk",
"version": "1.0.0",
"description": "A better commenting experience from Mozilla, The Washington Post, and The New York Times.",
"scripts": {
"start": "node dist/index.js",
"build": "tsc",
"watch": "nodemon --config .nodemonrc.json src/index.ts",
"lint": "prettier --write src/**/*.ts"
},
"author": "",
"license": "Apache-2.0",
"dependencies": {
"apollo-server-express": "^1.3.6",
"bunyan": "^1.8.12",
"convict": "^4.3.0",
"dataloader": "^1.4.0",
"dotenv": "^6.0.0",
"dotize": "^0.2.0",
"express": "^4.16.3",
"express-static-gzip": "^0.3.2",
"graphql": "^0.13.2",
"graphql-config": "^2.0.1",
"graphql-tools": "^3.0.2",
"ioredis": "^3.2.2",
"joi": "^13.4.0",
"lodash": "^4.17.10",
"mongodb": "^3.0.10",
"performance-now": "^2.1.0",
"uuid": "^3.2.1"
},
"devDependencies": {
"@types/bunyan": "^1.8.4",
"@types/convict": "^4.2.0",
"@types/dotenv": "^4.0.3",
"@types/express": "^4.16.0",
"@types/graphql": "^0.13.1",
"@types/ioredis": "^3.2.8",
"@types/joi": "^13.0.8",
"@types/lodash": "^4.14.109",
"@types/mongodb": "^3.0.19",
"@types/uuid": "^3.4.3",
"graphql-playground-middleware-express": "^1.7.0",
"nodemon": "^1.17.5",
"prettier": "^1.13.4",
"ts-node": "^6.1.1",
"tsconfig-paths": "^3.4.0",
"typescript": "^2.9.1"
}
}
+13
View File
@@ -0,0 +1,13 @@
export type Diff<T extends keyof any, U extends keyof any> = ({ [P in T]: P } &
{ [P in U]: never } & { [x: string]: never })[T];
export type Omit<U, K extends keyof U> = Pick<U, Diff<keyof U, K>>;
export type Overwrite<T, U> = Pick<T, Diff<keyof T, keyof U>> & U;
export type Sub<T, U> = Pick<T, Diff<keyof T, keyof U>>;
/**
* Make all properties in T writeable
*/
export type Writeable<T> = { -readonly [P in keyof T]: T[P] };
+13
View File
@@ -0,0 +1,13 @@
import Server, { ServerOptions } from './server';
/**
* Create a Talk Server.
*
* @param options ServerOptions that will be used to configure Talk.
*/
export default async function createTalk(
options: ServerOptions = {}
): Promise<Server> {
// Create the server with the provided options.
return new Server(options);
}
+45
View File
@@ -0,0 +1,45 @@
import express, { Express } from 'express';
import { Config } from 'talk-server/config';
import serveStatic from './middleware/serveStatic';
import graphql from './middleware/graphql';
import graphiql from './middleware/graphiql';
import {
access as accessLogger,
error as errorLogger,
} from './middleware/logging';
import schema from 'talk-server/graph/schema';
import { create } from 'talk-server/services/mongodb';
/**
* createApp will create a Talk Express app that can be used to handle requests.
*
* @param parent the root application to attach the Talk routes/middleware to.
*/
export async function createApp(
app: Express,
config: Config
): Promise<Express> {
// Logging
app.use(accessLogger);
// Static Files
app.use(serveStatic);
if (config.get('env') === 'development') {
// GraphiQL
app.get('/graphiql', graphiql());
}
// Setup MongoDB.
const db = await create(config);
// API
app.use('/api/graphql', express.json(), graphql({ schema, db }));
// Error Handling
app.use(errorLogger);
return app;
}
@@ -0,0 +1,3 @@
import playground from 'graphql-playground-middleware-express';
export default () => playground({ endpoint: '/api/graphql' });
+15
View File
@@ -0,0 +1,15 @@
import { graphqlExpress } from 'apollo-server-express';
import { GraphQLSchema } from 'graphql';
import Context from 'talk-server/graph/context';
import { Db } from 'mongodb';
export interface GraphQLOptions {
schema: GraphQLSchema;
db: Db;
}
export default (opts: GraphQLOptions) =>
graphqlExpress(req => ({
schema: opts.schema,
context: new Context({ db: opts.db, req }),
}));
+43
View File
@@ -0,0 +1,43 @@
import { RequestHandler, ErrorRequestHandler } from 'express';
import logger from '../../logger';
import now from 'performance-now';
export const access: RequestHandler = (req, res, next) => {
const startTime = now();
const end = res.end;
res.end = (chunk: any, encodingOrCb?: string | Function, cb?: Function) => {
// Compute the end time.
const responseTime = Math.round(now() - startTime);
// Get some extra goodies from the request.
const userAgent = req.get('User-Agent');
// Reattach the old end, and finish.
res.end = end;
if (typeof encodingOrCb === 'function') {
res.end(chunk, encodingOrCb);
} else {
res.end(chunk, encodingOrCb, cb);
}
// Log this out.
logger.info(
{
// traceID: req.id,
url: req.originalUrl || req.url,
method: req.method,
statusCode: res.statusCode,
userAgent,
responseTime,
},
'http request'
);
};
next();
};
export const error: ErrorRequestHandler = (err, req, res, next) => {
logger.error({ err }, 'http error');
next(err);
};
@@ -0,0 +1,4 @@
import serveStatic from 'express-static-gzip';
import path from 'path';
export default serveStatic(path.join(__dirname, '..', '..', 'dist'), {});
+75
View File
@@ -0,0 +1,75 @@
import Joi from 'joi';
import convict from 'convict';
import dotenv from 'dotenv';
// Apply all the configuration provided in the .env file if it isn't already in
// the environment.
dotenv.config();
// Add custom format for the mongo uri scheme.
convict.addFormat({
name: 'mongo-uri',
validate: (url: string) => {
Joi.assert(
url,
Joi.string().uri({
scheme: ['mongodb'],
})
);
},
});
// Add custom format for the redis uri scheme.
convict.addFormat({
name: 'redis-uri',
validate: (url: string) => {
Joi.assert(
url,
Joi.string().uri({
scheme: ['redis'],
})
);
},
});
export const config = convict({
env: {
doc: 'The application environment.',
format: ['production', 'development', 'test'],
default: 'development',
env: 'NODE_ENV',
},
port: {
doc: 'The port to bind.',
format: 'port',
default: 3000,
env: 'PORT',
arg: 'port',
},
mongodb: {
doc: 'The MongoDB database to connect to.',
format: 'mongo-uri',
default: 'mongodb://localhost/talk',
env: 'MONGODB',
arg: 'mongodb',
},
redis: {
doc: 'The Redis database to connect to.',
format: 'redis-uri',
default: 'redis://localhost:6379',
env: 'REDIS',
arg: 'redis',
},
secret: {
doc: 'The secret used to sign and verify JWTs',
format: '*',
default: null,
env: 'SECRET',
arg: 'secret',
},
});
export type Config = typeof config;
// Setup the base configuration.
export default config;
+18
View File
@@ -0,0 +1,18 @@
import loaders from './loaders';
import { Request } from 'express';
import { Db } from 'mongodb';
export interface ContextOptions {
req: Request;
db: Db;
}
export default class Context {
public loaders: ReturnType<typeof loaders>;
public db: Db;
constructor({ req, db }: ContextOptions) {
this.loaders = loaders(this);
this.db = db;
}
}
+13
View File
@@ -0,0 +1,13 @@
import DataLoader from 'dataloader';
import {
Asset,
retrieveMany as retrieveManyAssets,
} from 'talk-server/models/asset';
import Context from 'talk-server/graph/context';
const loadAssets = async (ctx: Context, ids: string[]): Promise<Array<Asset>> =>
retrieveManyAssets(ctx.db, ids);
export default (ctx: Context) => ({
asset: new DataLoader<string, Asset>(ids => loadAssets(ctx, ids)),
});
+4
View File
@@ -0,0 +1,4 @@
import Asset from './asset';
import Context from 'talk-server/graph/context';
export default (ctx: Context) => ({ Asset: Asset(ctx) });
+5
View File
@@ -0,0 +1,5 @@
import query from './query';
export default {
Query: query,
};
+12
View File
@@ -0,0 +1,12 @@
import Context from 'talk-server/graph/context';
import { Asset } from 'talk-server/models/asset';
export default {
asset: async (
_: any,
{ id, url }: { id?: string; url: string },
ctx: Context
): Promise<Asset> => {
return ctx.loaders.Asset.asset.load(id);
},
};
+25
View File
@@ -0,0 +1,25 @@
import {
addMockFunctionsToSchema,
addResolveFunctionsToSchema,
} from 'graphql-tools';
import resolvers from 'talk-server/graph/resolvers';
import { getGraphQLProjectConfig } from 'graphql-config';
// Load the configuration from the provided `.graphqlconfig` file.
const config = getGraphQLProjectConfig();
// Get the GraphQLSchema from the configuration.
const schema = config.getSchema();
// Attach the resolvers to the schema.
addResolveFunctionsToSchema({ schema, resolvers });
// // Attach resolvers to the schema.
// addMockFunctionsToSchema({
// schema,
// mocks: {
// Cursor: () => new Date().toISOString(),
// },
// }); // FIXME: remove mocks
export default schema;
+448
View File
@@ -0,0 +1,448 @@
################################################################################
## Custom Scalar Types
################################################################################
"""
Time represented as an ISO8601 string.
"""
scalar Time
"""
Cursor represents a paginating cursor.
"""
scalar Cursor
################################################################################
## Settings
################################################################################
# The moderation mode of the site.
enum MODERATION_MODE {
"""
Comments posted while in `PRE` mode will be labeled with a `PREMOD`
status and will require a moderator decision before being visible.
"""
PRE
"""
Comments posted while in `POST` will be visible immediately.
"""
POST
}
"""
Wordlist describes all the available wordlists.
"""
type Wordlist {
"""
banned words will by default reject the comment if it is found.
"""
banned: [String!]!
"""
suspect words will simply flag the comment.
"""
suspect: [String!]!
}
# Settings stores the global settings for a given installation.
type Settings {
"""
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: Wordlist!
"""
domains will return a given list of whitelisted domains.
"""
domains: [String!]!
}
################################################################################
## User
################################################################################
"""
User is someone that leaves Comments, and logs in.
"""
type User {
"""
id is the identifier of the User.
"""
id: ID!
"""
username is the name of the User visible to other Users.
"""
username: String!
}
################################################################################
## Comment
################################################################################
enum COMMENT_STATUS {
NONE
ACCEPTED
}
"""
Comment is a comment left by a User on an Asset or another Comment as a reply.
"""
type Comment {
"""
id is the identifier of the Comment.
"""
id: ID!
"""
body is the content of the Comment.
"""
body: String
"""
author is the User that authored the Comment.
"""
author: User
"""
status represents the Comment's current Status.
"""
status: COMMENT_STATUS!
"""
replyCount is the number of replies. Only direct replies to this Comment
are counted. Deleted comments are included in this count.
"""
replyCount: Int
"""
replies will return the replies to this comment.
"""
replies(cursor: Cursor, limit: Int = 10): CommentsConnection
}
type PageInfo {
"""
Cursor of first node in subset.
"""
startCursor: Cursor
"""
Cursor of last node in subset.
"""
endCursor: Cursor
"""
Indicates that there are more nodes after this subset.
"""
hasNextPage: Boolean!
}
"""
CommentEdge represents a unique Comment in a CommentConnection.
"""
type CommentEdge {
"""
node is the Comment for this edge.
"""
node: Comment
"""
"""
id: ID
}
"""
CommentsConnection represents a subset of a comment list.
"""
type CommentsConnection {
"""
edges are a subset of CommentEdge's.
"""
edges: [CommentEdge!]!
"""
pageInfo is
"""
pageInfo: PageInfo!
}
################################################################################
## Asset
################################################################################
enum COMMENT_SORT {
CREATED_AT
REPLIES
RESPECT
}
"""
Asset is an Article or Page where Comments are written on by Users.
"""
type Asset {
"""
id is the identifier of the Asset.
"""
id: ID!
"""
url is the url that the Asset is located on.
"""
url: String!
"""
title is the title of the scraped Asset.
"""
title: String
"""
comments are the comments on the Asset.
"""
comments(
first: Int = 10
orderBy: COMMENT_SORT = CREATED_AT
after: Cursor
): CommentsConnection
"""
author is the authors listed in the meta tags for the Asset.
"""
author: String
"""
closedAt is the Time that the Asset is closed for commenting.
"""
closedAt: Time
"""
isClosed returns true when the Asset is currently closed for commenting.
"""
isClosed: Boolean!
"""
createdAt is the date that the Asset was created at.
"""
createdAt: Time!
}
"""
AssetsConnection represents a subset of a Asset list.
"""
type AssetsConnection {
"""
Indicates that there are more Assets after this subset.
"""
hasNextPage: Boolean!
"""
Cursor of first Asset in subset.
"""
startCursor: Cursor
"""
Cursor of last Asset in subset.
"""
endCursor: Cursor
"""
Subset of Assets.
"""
nodes: [Asset!]!
}
################################################################################
## Queries
################################################################################
# Query is every query possible against this GraphQL server.
type Query {
comment(id: ID!): Comment
"""
assets returns a AssetsConnection.
"""
assets(cursor: Cursor, limit: Int = 10): AssetsConnection
"""
asset is the Asset specified by its ID.
"""
asset(id: ID!): Asset
"""
me is the current logged in User.
"""
me: User
}
################################################################################
## Mutations
################################################################################
##################
## createComment
##################
"""
CreateCommentInput provides the input for the createComment Mutation.
"""
input CreateCommentInput {
"""
assetID is the ID of the Asset where we are creating a comment on.
"""
assetID: ID!
"""
parentID is the optional ID of the Comment that we are replying to.
"""
parentID: ID
"""
body is the Comment body, the content of the Comment.
"""
body: String!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
"""
CreateCommentPayload contains the created Comment after the createComment
mutation.
"""
type CreateCommentPayload {
"""
comment is the possibly created comment.
"""
comment: Comment
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
## Mutation
##################
type Mutation {
"""
createComment will create a Comment as the current logged in User.
"""
createComment(input: CreateCommentInput!): CreateCommentPayload
}
################################################################################
## Subscriptions
################################################################################
type Subscription {
commentCreated(assetID: ID!): Comment
}
+51
View File
@@ -0,0 +1,51 @@
import config, { Config } from './config';
import express, { Express } from 'express';
import http from 'http';
import { createApp } from './app';
import logger from './logger';
export interface ServerOptions {}
/**
* Server provides an interface to create, start, and manage a Talk Server.
*/
class Server {
// app is the root application that the server will bind to.
private app: Express;
// config exposes application specific configuration.
public config: Config;
// httpServer is the running instance of the HTTP server that will bind to the
// requested port.
public httpServer: http.Server;
constructor(options: ServerOptions) {
this.app = express();
this.config = config.validate({ allowed: 'strict' });
}
/**
* start orchestrates the application by starting it and returning a promise
* when the server has started.
*
* @param parent the optional express application to bind the server to.
*/
start = (parent?: Express): Promise<Server> =>
new Promise(async resolve => {
const port = this.config.get('port');
// Ensure we have an app to bind to.
parent = parent ? parent : this.app;
// Create the Talk App, branching off from the parent app.
const app: Express = await createApp(parent, this.config);
logger.info({ port }, 'now listening');
// Listen on the designated port.
this.httpServer = app.listen(port, () => resolve(this));
});
}
export default Server;
+5
View File
@@ -0,0 +1,5 @@
import bunyan from 'bunyan';
const logger = bunyan.createLogger({ name: 'talk' });
export default logger;
+104
View File
@@ -0,0 +1,104 @@
import { Db } from 'mongodb';
import { FilterQuery } from './types';
import { defaults } from 'lodash';
import uuid from 'uuid';
import { Omit } from 'talk-common/types';
import dotize from 'dotize';
export interface Asset {
readonly id: string;
url: string;
scraped?: Date;
closedAt?: Date;
closedMessage?: string;
title?: string;
description?: string;
image?: string;
section?: string;
subsection?: string;
author?: string;
publication_date?: Date;
modified_date?: Date;
created_at: Date;
}
export type CreateAssetInput = Pick<Asset, 'id' | 'url'>;
export async function create(db: Db, input: CreateAssetInput): Promise<Asset> {
const now = new Date();
// Construct the filter.
const filter: FilterQuery<Asset> = {};
if (input.id) {
filter.id = input.id;
} else {
filter.url = input.url;
}
// Craft the update object.
const update: { $setOnInsert: Asset } = {
$setOnInsert: defaults(input, {
id: uuid.v4(),
created_at: now,
}),
};
// Perform the upsert operation.
const result = await db
.collection<Asset>('assets')
.findOneAndUpdate(filter, update, {
// Create the object if it doesn't already exist.
upsert: true,
// False to return the updated document instead of the original
// document.
returnOriginal: false,
});
return result.value;
}
export async function exists(db: Db, id: string): Promise<boolean> {
// TODO: implement
// const cursor = await db.collection<Asset>('assets').find({ id }).limit(1);
return null;
}
export async function retrieve(db: Db, id: string): Promise<Asset> {
return await db.collection<Asset>('assets').findOne({ id });
}
export async function retrieveMany(
db: Db,
ids: string[]
): Promise<Array<Asset>> {
const cursor = await db
.collection<Asset>('assets')
.find({ id: { $in: ids } });
const assets = await cursor.toArray();
return ids.map(id => assets.find(asset => asset.id === id));
}
export type UpdateAssetInput = Omit<
Partial<Asset>,
'id' | 'url' | 'created_at'
>;
export async function update(
db: Db,
id: string,
update: UpdateAssetInput
): Promise<Readonly<Asset>> {
const result = await db.collection<Asset>('assets').findOneAndUpdate(
{ id },
// Only update fields that have been updated.
{ $set: dotize(update) },
// False to return the updated document instead of the original
// document.
{ returnOriginal: false }
);
return result.value;
}
+99
View File
@@ -0,0 +1,99 @@
import { Db } from 'mongodb';
import { Omit, Sub } from 'talk-common/types';
import { merge } from 'lodash';
import uuid from 'uuid';
export interface BodyHistoryItem {
body: string;
created_at: Date;
}
export interface StatusHistoryItem {
status: CommentStatus; // TODO: migrate field
assigned_by?: string;
created_at: Date;
}
export enum CommentStatus {
ACCEPTED = 'ACCEPTED',
REJECTED = 'REJECTED',
PREMOD = 'PREMOD',
SYSTEM_WITHHELD = 'SYSTEM_WITHHELD',
NONE = 'NONE',
}
export interface ActionCounts {
[_: string]: number;
}
export interface Comment {
readonly id: string;
parent_id?: string;
author_id: string;
asset_id: string;
body: string;
body_history: BodyHistoryItem[];
status: CommentStatus;
status_history: StatusHistoryItem[];
action_counts: ActionCounts;
reply_count: number;
created_at: Date;
deleted_at?: Date;
}
export type CreateCommentInput = Omit<
Comment,
'id' | 'created_at' | 'reply_count' | 'body_history' | 'status_history'
>;
export async function create(
db: Db,
input: CreateCommentInput
): Promise<Readonly<Comment>> {
const now = new Date();
// Pull out some useful properties from the input.
const { body, status } = input;
// default are the properties set by the application when a new comment is
// created.
const defaults: Sub<Comment, CreateCommentInput> = {
id: uuid.v4(),
created_at: now,
reply_count: 0,
body_history: [
{
body,
created_at: now,
},
],
status_history: [
{
status,
created_at: now,
},
],
};
// Merge the defaults and the input together.
const comment: Comment = merge({}, defaults, input);
// TODO: Check for existence of the parent ID before we create the comment.
// TODO: Check for existence of the asset ID before we create the comment.
// Insert it into the database.
await db.collection<Comment>('comments').insertOne(comment);
// TODO: update reply count of parent if exists.
return comment;
}
async function incrementReplyCount(db: Db, parentID: string): Promise<void> {
return null;
}
export async function retrieve(db: Db, id: string): Promise<Comment> {
return null;
}
+121
View File
@@ -0,0 +1,121 @@
import { Db } from 'mongodb';
import { defaultsDeep } from 'lodash';
import dotize from 'dotize';
// selector is the single document selector for the Settings model stored in the
// settings collection in MongoDB.
const selector = { id: '1' };
export interface Wordlist {
banned: string[];
suspect: string[];
}
export enum Moderation {
PRE = 'PRE',
POST = 'POST',
}
export interface Settings {
readonly id: string;
moderation: Moderation;
requireEmailConfirmation: boolean;
infoBoxEnable: boolean;
infoBoxContent?: string;
questionBoxEnable: boolean;
questionBoxIcon?: string;
questionBoxContent?: string;
premodLinksEnable: boolean;
autoCloseStream: boolean;
closedTimeout: number;
closedMessage?: string;
customCssUrl?: string;
disableCommenting: boolean;
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: number;
charCountEnable: boolean;
charCount?: number;
organizationName?: string;
organizationContactEmail?: string;
// wordlist stores all the banned/suspect words.
wordlist: Wordlist;
// domains is the set of whitelisted domains.
domains: string[];
}
const defaultSettings: Settings = {
// Include the selector.
...selector,
// Default to post moderation.
moderation: Moderation.POST,
// Email confirmation is default off.
requireEmailConfirmation: false,
infoBoxEnable: false,
questionBoxEnable: false,
premodLinksEnable: false,
autoCloseStream: false,
// Two weeks timeout.
closedTimeout: 60 * 60 * 24 * 7 * 2,
disableCommenting: false,
editCommentWindowLength: 30 * 1000,
charCountEnable: false,
wordlist: {
suspect: [],
banned: [],
},
domains: [],
};
export async function create(
db: Db,
settingsInput: Partial<Settings>
): Promise<Readonly<Settings>> {
const result = await db
.collection<Settings>('settings')
.findOneAndReplace(
selector,
defaultsDeep({}, settingsInput, defaultSettings),
{
upsert: true,
returnOriginal: false,
}
);
return result.value;
}
export async function retrieve(db: Db): Promise<Readonly<Settings>> {
const settings = await db
.collection<Settings>('settings')
.findOne(selector);
if (!settings) {
throw new Error('settings not initialized'); // FIXME: return actual typed error
}
return settings;
}
export async function update(
db: Db,
update: Partial<Settings>
): Promise<Readonly<Settings>> {
// Get the settings from the database.
const result = await db.collection<Settings>('settings').findOneAndUpdate(
selector,
// Only update fields that have been updated.
{ $set: dotize(update) },
// False to return the updated document instead of the original
// document.
{ returnOriginal: false }
);
return result.value;
}
+5
View File
@@ -0,0 +1,5 @@
import { FilterQuery } from 'mongodb';
import { Writeable } from '../../common/types';
export type FilterQuery<T> = Writeable<Partial<T>> &
FilterQuery<Writeable<Partial<T>>>;
@@ -0,0 +1,6 @@
import { Db } from 'mongodb';
import { Comment } from 'talk-server/models/comment';
export async function create(db: Db): Promise<Comment> {
return null;
}
+16
View File
@@ -0,0 +1,16 @@
import { MongoClient, Db } from 'mongodb';
import { Config } from 'talk-server/config';
/**
* create will connect to the MongoDB instance identified in the configuration.
*
* @param config application configuration.
*/
export async function create(config: Config): Promise<Db> {
// Connect and create a client for MongoDB.
const client = await MongoClient.connect(config.get('mongodb'));
// Return the database handle, which defaults to the database name provided
// in the config connection string.
return client.db();
}
+11
View File
@@ -0,0 +1,11 @@
import RedisClient, { Redis } from 'ioredis';
import { Config } from 'talk-server/config';
/**
* create will connect to the Redis instance identified in the configuration.
*
* @param config application configuration.
*/
export async function create(config: Config): Promise<Redis> {
return new RedisClient(config.get('redis'), {});
}
@@ -0,0 +1,74 @@
import { Db } from 'mongodb';
import { Redis } from 'ioredis';
import {
Settings,
retrieve as retrieveSettings,
} from 'talk-server/models/settings';
// Cache provides an interface for retrieving settings stored in local memory
// rather than grabbing it from the database every single call.
export default class Cache {
private value: Promise<Readonly<Settings>>;
constructor(db: Db, subscriber: Redis) {
// Retrieve the settings from the database, and keep them cached in this
// promise.
this.value = retrieveSettings(db).then(settings => settings);
// Subscribe to settings notifications.
subscriber.subscribe('settings');
// Attach to messages on this connection so we can receive updates when
// the settings are changed.
subscriber.on('message', this.onMessage);
}
/**
* onMessage is fired every time the client gets a subscription event.
*/
private onMessage = async (channel: string, message: string) => {
// Only do things when the message is for settings.
if (channel !== 'settings') {
return;
}
try {
// Updated settings come from the messages.
const settings: Settings = JSON.parse(message);
// Update the settings cache.
this.value = new Promise(resolve => resolve(settings));
} catch (err) {
// FIXME: handle the error
}
};
/**
* retrieve returns a promise that will resolve to the settings for Talk.
*/
public async retrieve(): Promise<Readonly<Settings>> {
return this.value;
}
/**
* update will update the value for Settings in the local cache and publish
* a change notification that will be used to keep the other nodes in sync.
*
* @param conn a redis connection used to publish the change notification
* @param settings the updated Settings object
*/
public async update(
conn: Redis,
settings: Settings
): Promise<Readonly<Settings>> {
// Update the settings in the local cache.
this.value = new Promise(resolve => resolve(settings));
// Notify the other nodes about the settings change.
await conn.publish('settings', JSON.stringify(settings));
// Return the settings that were set.
return settings;
}
}
+17
View File
@@ -0,0 +1,17 @@
import createTalk from './core';
import express from 'express';
// Create the app that will serve as the mounting point for the Talk Server.
const app = express();
async function bootstrap() {
try {
// Create the server instance.
const server = await createTalk();
// Start the server.
await server.start(app);
} catch (err) {}
}
bootstrap();
+5
View File
@@ -0,0 +1,5 @@
declare module 'dotize' {
export = dotize;
function dotize(obj: any): { [_: string]: any };
}
+5
View File
@@ -0,0 +1,5 @@
declare module 'express-static-gzip' {
export = express_static_gzip;
function express_static_gzip(rootFolder: any, options: any): any;
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"esModuleInterop": true,
"noImplicitAny": true,
"allowJs": true,
"moduleResolution": "node",
"sourceMap": true,
"outDir": "dist",
"baseUrl": ".",
"pretty": false,
"removeComments": true,
// See https://github.com/prismagraphql/graphql-request/issues/26 for why we
// have to include "dom" here.
"lib": ["es6", "esnext.asynciterable", "dom"],
"paths": {
"talk-server/*": ["./src/core/server/*"],
"talk-common/*": ["./src/core/common/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}