mirror of
https://github.com/wassname/talk.git
synced 2026-06-27 19:17:09 +08:00
initial commit
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
*.js
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"schemaPath": "src/core/server/graph/schema/schema.graphql"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"execMap": {
|
||||
"ts": "ts-node"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
dist
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
Vendored
+21
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+14
@@ -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
|
||||
}
|
||||
}
|
||||
Generated
+3868
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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] };
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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' });
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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'), {});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
import Asset from './asset';
|
||||
import Context from 'talk-server/graph/context';
|
||||
|
||||
export default (ctx: Context) => ({ Asset: Asset(ctx) });
|
||||
@@ -0,0 +1,5 @@
|
||||
import query from './query';
|
||||
|
||||
export default {
|
||||
Query: query,
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
import bunyan from 'bunyan';
|
||||
|
||||
const logger = bunyan.createLogger({ name: 'talk' });
|
||||
|
||||
export default logger;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
declare module 'dotize' {
|
||||
export = dotize;
|
||||
|
||||
function dotize(obj: any): { [_: string]: any };
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
declare module 'express-static-gzip' {
|
||||
export = express_static_gzip;
|
||||
|
||||
function express_static_gzip(rootFolder: any, options: any): any;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user