From f74a45379fb3dd7a100253565afd236f74ef200e Mon Sep 17 00:00:00 2001 From: Armin Date: Mon, 25 Feb 2019 23:57:44 +0100 Subject: [PATCH 01/25] Integrate ActivityPub into the Backend --- .babelrc | 3 + .travis.yml | 1 + package.json | 11 +- src/activitypub/ActivityPub.js | 137 ++++ src/activitypub/NitroDatasource.js | 377 +++++++++++ src/activitypub/routes/inbox.js | 50 ++ src/activitypub/routes/index.js | 27 + src/activitypub/routes/user.js | 88 +++ src/activitypub/routes/webfinger.js | 34 + src/activitypub/security/index.js | 227 +++++++ src/activitypub/utils/index.js | 215 +++++++ src/activitypub/utils/serveUser.js | 43 ++ src/index.js | 2 + src/middleware/userMiddleware.js | 8 + src/schema.graphql | 3 + .../features/activity-article.feature.disable | 30 + test/features/activity-delete.feature.disable | 28 + test/features/activity-follow.feature | 51 ++ test/features/collection.feature | 101 +++ test/features/support/steps.js | 125 ++++ test/features/webfinger.feature | 65 ++ test/features/world.js | 58 ++ yarn.lock | 600 +++++++++++++++++- 23 files changed, 2265 insertions(+), 19 deletions(-) create mode 100644 src/activitypub/ActivityPub.js create mode 100644 src/activitypub/NitroDatasource.js create mode 100644 src/activitypub/routes/inbox.js create mode 100644 src/activitypub/routes/index.js create mode 100644 src/activitypub/routes/user.js create mode 100644 src/activitypub/routes/webfinger.js create mode 100644 src/activitypub/security/index.js create mode 100644 src/activitypub/utils/index.js create mode 100644 src/activitypub/utils/serveUser.js create mode 100644 test/features/activity-article.feature.disable create mode 100644 test/features/activity-delete.feature.disable create mode 100644 test/features/activity-follow.feature create mode 100644 test/features/collection.feature create mode 100644 test/features/support/steps.js create mode 100644 test/features/webfinger.feature create mode 100644 test/features/world.js diff --git a/.babelrc b/.babelrc index 2d91b3635..f36dbeadb 100644 --- a/.babelrc +++ b/.babelrc @@ -8,5 +8,8 @@ } } ] + ], + "plugins": [ + "@babel/plugin-proposal-throw-expressions" ] } diff --git a/.travis.yml b/.travis.yml index e699197cf..47d252435 100644 --- a/.travis.yml +++ b/.travis.yml @@ -26,6 +26,7 @@ install: script: - docker-compose exec backend yarn run lint - docker-compose exec backend yarn run test --ci + - docker-compose exec backend yarn run test:cucumber - docker-compose exec backend yarn run test:coverage - docker-compose exec backend yarn run db:reset - docker-compose exec backend yarn run db:seed diff --git a/package.json b/package.json index 39c9c14c6..799c45085 100644 --- a/package.json +++ b/package.json @@ -12,13 +12,15 @@ "dev": "nodemon --exec babel-node src/", "dev:debug": "nodemon --exec babel-node --inspect=0.0.0.0:9229 src/index.js", "lint": "eslint src --config .eslintrc.js", - "test": "nyc --reporter=text-lcov yarn run test:jest", + "test": "nyc --reporter=text-lcov yarn test:jest", "test:cypress": "run-p --race test:before:*", "test:before:server": "cross-env GRAPHQL_URI=http://localhost:4123 GRAPHQL_PORT=4123 babel-node src/ 2> /dev/null", "test:before:seeder": "cross-env GRAPHQL_URI=http://localhost:4001 GRAPHQL_PORT=4001 PERMISSIONS=disabled babel-node src/ 2> /dev/null", "test:jest:cmd": "wait-on tcp:4001 tcp:4123 && jest --forceExit --detectOpenHandles --runInBand", + "test:cucumber:cmd": "wait-on tcp:4001 tcp:4123 && cucumber-js --require-module @babel/register --exit test/", "test:jest:cmd:debug": "wait-on tcp:4001 tcp:4123 && node --inspect-brk ./node_modules/.bin/jest -i --forceExit --detectOpenHandles --runInBand", "test:jest": "run-p --race test:before:* 'test:jest:cmd {@}' --", + "test:cucumber": "ACTIVITYPUB_PORT=4123 run-p --race test:before:* 'test:cucumber:cmd {@}' --", "test:jest:debug": "run-p --race test:before:* 'test:jest:cmd:debug {@}' --", "test:coverage": "nyc report --reporter=text-lcov > coverage.lcov", "db:script:seed": "wait-on tcp:4001 && babel-node src/seed/seed-db.js", @@ -34,15 +36,18 @@ ] }, "dependencies": { + "activitystrea.ms": "^2.1.3", "apollo-cache-inmemory": "~1.4.3", "apollo-client": "~2.4.13", "apollo-link-http": "~1.5.11", "apollo-server": "~2.4.2", "bcryptjs": "~2.4.3", "cheerio": "~1.0.0-rc.2", + "cors": "^2.8.5", "cross-env": "~5.2.0", "date-fns": "2.0.0-alpha.26", "dotenv": "~6.2.0", + "express": "^4.16.4", "faker": "~4.1.0", "graphql": "~14.1.1", "graphql-custom-directives": "~0.2.14", @@ -61,6 +66,7 @@ "npm-run-all": "~4.1.5", "passport": "~0.4.0", "passport-jwt": "~4.0.0", + "request": "^2.88.0", "sanitize-html": "~1.20.0", "slug": "~1.0.0", "trunc-html": "~1.1.2", @@ -70,6 +76,7 @@ "@babel/cli": "~7.2.3", "@babel/core": "~7.3.3", "@babel/node": "~7.2.2", + "@babel/plugin-proposal-throw-expressions": "^7.2.0", "@babel/preset-env": "~7.3.1", "@babel/register": "~7.0.0", "apollo-server-testing": "~2.4.2", @@ -77,6 +84,8 @@ "babel-eslint": "~10.0.1", "babel-jest": "~24.1.0", "chai": "~4.2.0", + "cucumber": "^5.1.0", + "debug": "^4.1.1", "eslint": "~5.13.0", "eslint-config-standard": "~12.0.0", "eslint-plugin-import": "~2.16.0", diff --git a/src/activitypub/ActivityPub.js b/src/activitypub/ActivityPub.js new file mode 100644 index 000000000..57b1a5353 --- /dev/null +++ b/src/activitypub/ActivityPub.js @@ -0,0 +1,137 @@ +import { sendAcceptActivity, sendRejectActivity, extractNameFromId, extractDomainFromUrl } from './utils' +import request from 'request' +import as from 'activitystrea.ms' +import NitroDatasource from './NitroDatasource' +import router from './routes' +import dotenv from 'dotenv' +import { resolve } from 'path' +const debug = require('debug')('ea') + +let activityPub = null + +export { activityPub } + +export default class ActivityPub { + constructor (domain, port) { + if (domain === 'localhost') { this.domain = `${domain}:${port}` } else { this.domain = domain } + this.port = port + this.dataSource = new NitroDatasource(this.domain) + } + static init (server) { + if (!activityPub) { + dotenv.config({ path: resolve('src', 'activitypub', '.env') }) + // const app = express() + activityPub = new ActivityPub(process.env.ACTIVITYPUB_DOMAIN || 'localhost', process.env.ACTIVITYPUB_PORT || 4100) + server.express.set('ap', activityPub) + server.express.use(router) + debug('ActivityPub service added to graphql endpoint') + } else { + debug('ActivityPub service already added to graphql endpoint') + } + } + + getFollowersCollection (actorId) { + return this.dataSource.getFollowersCollection(actorId) + } + + getFollowersCollectionPage (actorId) { + return this.dataSource.getFollowersCollectionPage(actorId) + } + + getFollowingCollection (actorId) { + return this.dataSource.getFollowingCollection(actorId) + } + + getFollowingCollectionPage (actorId) { + return this.dataSource.getFollowingCollectionPage(actorId) + } + + getOutboxCollection (actorId) { + return this.dataSource.getOutboxCollection(actorId) + } + + getOutboxCollectionPage (actorId) { + return this.dataSource.getOutboxCollectionPage(actorId) + } + + handleFollowActivity (activity) { + debug(`inside FOLLOW ${activity.actor}`) + let toActorName = extractNameFromId(activity.object) + let fromDomain = extractDomainFromUrl(activity.actor) + const dataSource = this.dataSource + + return new Promise((resolve, reject) => { + request({ + url: activity.actor, + headers: { + 'Accept': 'application/activity+json' + } + }, async (err, response, toActorObject) => { + if (err) return reject(err) + debug(`name = ${toActorName}@${this.domain}`) + + let followersCollectionPage = await this.dataSource.getFollowersCollectionPage(activity.object) + + const followActivity = as.follow() + .id(activity.id) + .actor(activity.actor) + .object(activity.object) + + // add follower if not already in collection + if (followersCollectionPage.orderedItems.includes(activity.actor)) { + debug('follower already in collection!') + debug(`inbox = ${toActorObject.inbox}`) + resolve(sendRejectActivity(followActivity, toActorName, fromDomain, toActorObject.inbox)) + } else { + followersCollectionPage.orderedItems.push(activity.actor) + } + debug(`toActorObject = ${toActorObject}`) + toActorObject = typeof toActorObject !== 'object' ? JSON.parse(toActorObject) : toActorObject + debug(`followers = ${JSON.stringify(followersCollectionPage.orderedItems, null, 2)}`) + debug(`inbox = ${toActorObject.inbox}`) + debug(`outbox = ${toActorObject.outbox}`) + debug(`followers = ${toActorObject.followers}`) + debug(`following = ${toActorObject.following}`) + + // TODO save after accept activity for the corresponding follow is received + try { + await dataSource.saveFollowersCollectionPage(followersCollectionPage) + debug('follow activity saved') + resolve(sendAcceptActivity(followActivity, toActorName, fromDomain, toActorObject.inbox)) + } catch (e) { + debug('followers update error!', e) + resolve(sendRejectActivity(followActivity, toActorName, fromDomain, toActorObject.inbox)) + } + }) + }) + } + + handleUndoActivity (activity) { + debug('inside UNDO') + switch (activity.object.type) { + case 'Follow': + const followActivity = activity.object + return this.dataSource.undoFollowActivity(followActivity.actor, followActivity.object) + default: + } + } + + handleCreateActivity (activity) { + debug('inside create') + switch (activity.object.type) { + case 'Article': + case 'Note': + const articleObject = activity.object + if (articleObject.inReplyTo) { + return this.dataSource.createComment(articleObject) + } else { + return this.dataSource.createPost(articleObject) + } + default: + } + } + + handleDeleteActivity (activity) { + debug('inside delete') + } +} diff --git a/src/activitypub/NitroDatasource.js b/src/activitypub/NitroDatasource.js new file mode 100644 index 000000000..957026a2c --- /dev/null +++ b/src/activitypub/NitroDatasource.js @@ -0,0 +1,377 @@ +import { + throwErrorIfGraphQLErrorOccurred, + extractIdFromActivityId, + createOrderedCollection, + createOrderedCollectionPage, + extractNameFromId, + createArticleActivity, + constructIdFromName +} from './utils' +import crypto from 'crypto' +import gql from 'graphql-tag' +import { createHttpLink } from 'apollo-link-http' +import { InMemoryCache } from 'apollo-cache-inmemory' +import fetch from 'node-fetch' +import { ApolloClient } from 'apollo-client' +import dotenv from 'dotenv' +const debug = require('debug')('ea:nitro-datasource') + +dotenv.config() + +export default class NitroDatasource { + constructor (domain) { + this.domain = domain + const defaultOptions = { + query: { + fetchPolicy: 'network-only', + errorPolicy: 'all' + } + } + const link = createHttpLink({ uri: process.env.GRAPHQL_URI, fetch: fetch }) // eslint-disable-line + const cache = new InMemoryCache() + this.client = new ApolloClient({ + link: link, + cache: cache, + defaultOptions + }) + } + + async getFollowersCollection (actorId) { + const slug = extractNameFromId(actorId) + debug(`slug= ${slug}`) + const result = await this.client.query({ + query: gql` + query { + User(slug: "${slug}") { + followedByCount + } + } + ` + }) + debug('successfully fetched followers') + debug(result.data) + if (result.data) { + const actor = result.data.User[0] + const followersCount = actor.followedByCount + + const followersCollection = createOrderedCollection(slug, 'followers') + followersCollection.totalItems = followersCount + + return followersCollection + } else { + throwErrorIfGraphQLErrorOccurred(result) + } + } + + async getFollowersCollectionPage (actorId) { + const slug = extractNameFromId(actorId) + debug(`getFollowersPage slug = ${slug}`) + const result = await this.client.query({ + query: gql` + query { + User(slug:"${slug}") { + followedBy { + slug + } + followedByCount + } + } + ` + }) + + debug(result.data) + if (result.data) { + const actor = result.data.User[0] + const followers = actor.followedBy + const followersCount = actor.followedByCount + + const followersCollection = createOrderedCollectionPage(slug, 'followers') + followersCollection.totalItems = followersCount + debug(`followers = ${JSON.stringify(followers, null, 2)}`) + await Promise.all( + followers.map(async (follower) => { + followersCollection.orderedItems.push(constructIdFromName(follower.slug)) + }) + ) + + return followersCollection + } else { + throwErrorIfGraphQLErrorOccurred(result) + } + } + + async getFollowingCollection (actorId) { + const slug = extractNameFromId(actorId) + const result = await this.client.query({ + query: gql` + query { + User(slug:"${slug}") { + followingCount + } + } + ` + }) + + debug(result.data) + if (result.data) { + const actor = result.data.User[0] + const followingCount = actor.followingCount + + const followingCollection = createOrderedCollection(slug, 'following') + followingCollection.totalItems = followingCount + + return followingCollection + } else { + throwErrorIfGraphQLErrorOccurred(result) + } + } + + async getFollowingCollectionPage (actorId) { + const slug = extractNameFromId(actorId) + const result = await this.client.query({ + query: gql` + query { + User(slug:"${slug}") { + following { + slug + } + followingCount + } + } + ` + }) + + debug(result.data) + if (result.data) { + const actor = result.data.User[0] + const following = actor.following + const followingCount = actor.followingCount + + const followingCollection = createOrderedCollectionPage(slug, 'following') + followingCollection.totalItems = followingCount + + await Promise.all( + following.map(async (user) => { + followingCollection.orderedItems.push(await constructIdFromName(user.slug)) + }) + ) + + return followingCollection + } else { + throwErrorIfGraphQLErrorOccurred(result) + } + } + + async getOutboxCollection (actorId) { + const slug = extractNameFromId(actorId) + const result = await this.client.query({ + query: gql` + query { + User(slug:"${slug}") { + contributions { + title + slug + content + contentExcerpt + createdAt + } + } + } + ` + }) + + debug(result.data) + if (result.data) { + const actor = result.data.User[0] + const posts = actor.contributions + + const outboxCollection = createOrderedCollection(slug, 'outbox') + outboxCollection.totalItems = posts.length + + return outboxCollection + } else { + throwErrorIfGraphQLErrorOccurred(result) + } + } + + async getOutboxCollectionPage (actorId) { + const slug = extractNameFromId(actorId) + const result = await this.client.query({ + query: gql` + query { + User(slug:"${slug}") { + contributions { + id + title + slug + content + contentExcerpt + createdAt + } + } + } + ` + }) + + debug(result.data) + if (result.data) { + const actor = result.data.User[0] + const posts = actor.contributions + + const outboxCollection = createOrderedCollectionPage(slug, 'outbox') + outboxCollection.totalItems = posts.length + await Promise.all( + posts.map((post) => { + outboxCollection.orderedItems.push(createArticleActivity(post.content, slug, post.id, post.createdAt)) + }) + ) + + debug('after createNote') + return outboxCollection + } else { + throwErrorIfGraphQLErrorOccurred(result) + } + } + + async undoFollowActivity (fromActorId, toActorId) { + const fromUserId = await this.ensureUser(fromActorId) + const toUserId = await this.ensureUser(toActorId) + const result = await this.client.mutate({ + mutation: gql` + mutation { + RemoveUserFollowedBy(from: {id: "${fromUserId}"}, to: {id: "${toUserId}"}) { + from { name } + } + } + ` + }) + debug(`undoFollowActivity result = ${JSON.stringify(result, null, 2)}`) + throwErrorIfGraphQLErrorOccurred(result) + } + + async saveFollowersCollectionPage (followersCollection, onlyNewestItem = true) { + debug('inside saveFollowers') + let orderedItems = followersCollection.orderedItems + const toUserName = extractNameFromId(followersCollection.id) + const toUserId = await this.ensureUser(constructIdFromName(toUserName)) + orderedItems = onlyNewestItem ? [orderedItems.pop()] : orderedItems + + return Promise.all( + await Promise.all(orderedItems.map(async (follower) => { + debug(`follower = ${follower}`) + const fromUserId = await this.ensureUser(follower) + debug(`fromUserId = ${fromUserId}`) + debug(`toUserId = ${toUserId}`) + const result = await this.client.mutate({ + mutation: gql` + mutation { + AddUserFollowedBy(from: {id: "${fromUserId}"}, to: {id: "${toUserId}"}) { + from { name } + } + } + ` + }) + debug(`addUserFollowedBy edge = ${JSON.stringify(result, null, 2)}`) + throwErrorIfGraphQLErrorOccurred(result) + debug('saveFollowers: added follow edge successfully') + })) + ) + } + + async createPost (postObject) { + // TODO how to handle the to field? Now the post is just created, doesn't matter who is the recipient + // createPost + const title = postObject.summary ? postObject.summary : postObject.content.split(' ').slice(0, 5).join(' ') + const id = extractIdFromActivityId(postObject.id) + let result = await this.client.mutate({ + mutation: gql` + mutation { + CreatePost(content: "${postObject.content}", title: "${title}", id: "${id}") { + id + } + } + ` + }) + + throwErrorIfGraphQLErrorOccurred(result) + + // ensure user and add author to post + const userId = await this.ensureUser(postObject.attributedTo) + result = await this.client.mutate({ + mutation: gql` + mutation { + AddPostAuthor(from: {id: "${userId}"}, to: {id: "${id}"}) + } + ` + }) + + throwErrorIfGraphQLErrorOccurred(result) + } + + async createComment (postObject) { + let result = await this.client.mutate({ + mutation: gql` + mutation { + CreateComment(content: "${postObject.content}") { + id + } + } + ` + }) + + throwErrorIfGraphQLErrorOccurred(result) + const postId = extractIdFromActivityId(postObject.inReplyTo) + + result = await this.client.mutate({ + mutation: gql` + mutation { + AddCommentPost(from: { id: "${result.data.CreateComment.id}", to: { id: "${postId}" }}) { + id + } + } + ` + }) + + throwErrorIfGraphQLErrorOccurred(result) + } + + /** + * This function will search for user existence and will create a disabled user with a random 16 bytes password when no user is found. + * + * @param actorId + * @returns {Promise<*>} + */ + async ensureUser (actorId) { + debug(`inside ensureUser = ${actorId}`) + const queryResult = await this.client.query({ + query: gql` + query { + User(slug: "${extractNameFromId(actorId)}") { + id + } + } + ` + }) + + if (queryResult.data && Array.isArray(queryResult.data.User) && queryResult.data.User.length > 0) { + debug('ensureUser: user exists.. return id') + // user already exists.. return the id + return queryResult.data.User[0].id + } else { + debug('ensureUser: user not exists.. createUser') + // user does not exist.. create it + const result = await this.client.mutate({ + mutation: gql` + mutation { + CreateUser(password: "${crypto.randomBytes(16).toString('hex')}", slug:"${extractNameFromId(actorId)}", actorId: "${actorId}", name: "${extractNameFromId(actorId)}") { + id + } + } + ` + }) + throwErrorIfGraphQLErrorOccurred(result) + + return result.data.CreateUser.id + } + } +} diff --git a/src/activitypub/routes/inbox.js b/src/activitypub/routes/inbox.js new file mode 100644 index 000000000..0f33ebaec --- /dev/null +++ b/src/activitypub/routes/inbox.js @@ -0,0 +1,50 @@ +import express from 'express' +import { verify } from '../security' +const debug = require('debug')('ea:inbox') + +const router = express.Router() + +// Shared Inbox endpoint (federated Server) +// For now its only able to handle Note Activities!! +router.post('/', async function (req, res, next) { + debug(`Content-Type = ${req.get('Content-Type')}`) + debug(`body = ${JSON.stringify(req.body, null, 2)}`) + debug(`Request headers = ${JSON.stringify(req.headers, null, 2)}`) + debug(`verify = ${await verify(`${req.protocol}://${req.hostname}:${req.port}${req.originalUrl}`, req.headers)}`) + switch (req.body.type) { + case 'Create': + await await req.app.get('activityPub').handleCreateActivity(req.body).catch(next) + break + case 'Undo': + await await req.app.get('activityPub').handleUndoActivity(req.body).catch(next) + break + case 'Follow': + debug('handleFollow') + await req.app.get('activityPub').handleFollowActivity(req.body) + debug('handledFollow') + break + case 'Delete': + await await req.app.get('activityPub').handleDeleteActivity(req.body).catch(next) + break + /* eslint-disable */ + case 'Update': + + case 'Accept': + + case 'Reject': + + case 'Add': + + case 'Remove': + + case 'Like': + + case 'Announce': + debug('else!!') + debug(JSON.stringify(req.body, null, 2)) + } + /* eslint-enable */ + res.status(200).end() +}) + +export default router diff --git a/src/activitypub/routes/index.js b/src/activitypub/routes/index.js new file mode 100644 index 000000000..7a8524a9e --- /dev/null +++ b/src/activitypub/routes/index.js @@ -0,0 +1,27 @@ +import user from './user' +import inbox from './inbox' +import webfinger from './webfinger' +import express from 'express' +import cors from 'cors' + +const router = express.Router() + +router.use('/.well-known/webfinger', + cors(), + express.urlencoded({ extended: true }), + webfinger +) +router.use('/activitypub/users', + cors(), + express.json({ type: ['application/activity+json', 'application/ld+json', 'application/json'] }), + express.urlencoded({ extended: true }), + user +) +router.use('/activitypub/inbox', + cors(), + express.json({ type: ['application/activity+json', 'application/ld+json', 'application/json'] }), + express.urlencoded({ extended: true }), + inbox +) + +export default router diff --git a/src/activitypub/routes/user.js b/src/activitypub/routes/user.js new file mode 100644 index 000000000..8240ba393 --- /dev/null +++ b/src/activitypub/routes/user.js @@ -0,0 +1,88 @@ +import { sendCollection } from '../utils' +import express from 'express' +import { serveUser } from '../utils/serveUser' +import { verify } from '../security' + +const router = express.Router() +const debug = require('debug')('ea:user') + +router.get('/:name', async function (req, res, next) { + debug('inside user.js -> serveUser') + await serveUser(req, res, next) +}) + +router.get('/:name/following', (req, res) => { + debug('inside user.js -> serveFollowingCollection') + const name = req.params.name + if (!name) { + res.status(400).send('Bad request! Please specify a name.') + } else { + const collectionName = req.query.page ? 'followingPage' : 'following' + sendCollection(collectionName, req, res) + } +}) + +router.get('/:name/followers', (req, res) => { + debug('inside user.js -> serveFollowersCollection') + const name = req.params.name + if (!name) { + return res.status(400).send('Bad request! Please specify a name.') + } else { + const collectionName = req.query.page ? 'followersPage' : 'followers' + sendCollection(collectionName, req, res) + } +}) + +router.get('/:name/outbox', (req, res) => { + debug('inside user.js -> serveOutboxCollection') + const name = req.params.name + if (!name) { + return res.status(400).send('Bad request! Please specify a name.') + } else { + const collectionName = req.query.page ? 'outboxPage' : 'outbox' + sendCollection(collectionName, req, res) + } +}) + +router.post('/:name/inbox', async function (req, res, next) { + debug(`body = ${JSON.stringify(req.body, null, 2)}`) + debug(`actorId = ${req.body.actor}`) + debug(`verify = ${await verify(`${req.protocol}://${req.hostname}:${req.port}${req.originalUrl}`, req.headers)}`) + // const result = await saveActorId(req.body.actor) + switch (req.body.type) { + case 'Create': + await req.app.get('ap').handleCreateActivity(req.body).catch(next) + break + case 'Undo': + await req.app.get('ap').handleUndoActivity(req.body).catch(next) + break + case 'Follow': + debug('handleFollow') + await req.app.get('ap').handleFollowActivity(req.body).catch(next) + debug('handledFollow') + break + case 'Delete': + req.app.get('ap').handleDeleteActivity(req.body).catch(next) + break + /* eslint-disable */ + case 'Update': + + case 'Accept': + + case 'Reject': + + case 'Add': + + case 'Remove': + + case 'Like': + + case 'Announce': + debug('else!!') + debug(JSON.stringify(req.body, null, 2)) + } + /* eslint-enable */ + res.status(200).end() +}) + +export default router diff --git a/src/activitypub/routes/webfinger.js b/src/activitypub/routes/webfinger.js new file mode 100644 index 000000000..9e51dcdfd --- /dev/null +++ b/src/activitypub/routes/webfinger.js @@ -0,0 +1,34 @@ +import express from 'express' +import { createWebFinger } from '../utils' +import gql from 'graphql-tag' + +const router = express.Router() + +router.get('/', async function (req, res) { + const resource = req.query.resource + if (!resource || !resource.includes('acct:')) { + return res.status(400).send('Bad request. Please make sure "acct:USER@DOMAIN" is what you are sending as the "resource" query parameter.') + } else { + const nameAndDomain = resource.replace('acct:', '') + const name = nameAndDomain.split('@')[0] + + const result = await req.app.get('ap').dataSource.client.query({ + query: gql` + query { + User(slug: "${name}") { + slug + } + } + ` + }) + + if (result.data && result.data.User.length > 0) { + const webFinger = createWebFinger(name) + return res.contentType('application/jrd+json').json(webFinger) + } else { + return res.status(404).json({ error: `No record found for ${nameAndDomain}.` }) + } + } +}) + +export default router diff --git a/src/activitypub/security/index.js b/src/activitypub/security/index.js new file mode 100644 index 000000000..93b6cc08a --- /dev/null +++ b/src/activitypub/security/index.js @@ -0,0 +1,227 @@ +import dotenv from 'dotenv' +import { resolve } from 'path' +import crypto from 'crypto' +import { activityPub } from '../ActivityPub' +import gql from 'graphql-tag' +import request from 'request' +const debug = require('debug')('ea:security') + +dotenv.config({ path: resolve('src', 'activitypub', '.env') }) + +export function generateRsaKeyPair () { + return crypto.generateKeyPairSync('rsa', { + modulusLength: 4096, + publicKeyEncoding: { + type: 'spki', + format: 'pem' + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + cipher: 'aes-256-cbc', + passphrase: process.env.PRIVATE_KEY_PASSPHRASE + } + }) +} + +export function signAndSend (activity, fromName, targetDomain, url) { + // fix for development: replace with http + url = url.indexOf('localhost') > -1 ? url.replace('https', 'http') : url + debug(`passhprase = ${process.env.PRIVATE_KEY_PASSPHRASE}`) + return new Promise(async (resolve, reject) => { + debug('inside signAndSend') + // get the private key + const result = await activityPub.dataSource.client.query({ + query: gql` + query { + User(slug: "${fromName}") { + privateKey + } + } + ` + }) + + if (result.error) { + reject(result.error) + } else { + const parsedActivity = JSON.parse(activity) + if (Array.isArray(parsedActivity['@context'])) { + parsedActivity['@context'].push('https://w3id.org/security/v1') + } else { + const context = [parsedActivity['@context']] + context.push('https://w3id.org/security/v1') + parsedActivity['@context'] = context + } + + // deduplicate context strings + parsedActivity['@context'] = [...new Set(parsedActivity['@context'])] + const privateKey = result.data.User[0].privateKey + const date = new Date().toUTCString() + + debug(`url = ${url}`) + request({ + url: url, + headers: { + 'Host': targetDomain, + 'Date': date, + 'Signature': createSignature(privateKey, `http://${activityPub.domain}/activitypub/users/${fromName}#main-key`, url, + { + 'Host': targetDomain, + 'Date': date, + 'Content-Type': 'application/activity+json' + }), + 'Content-Type': 'application/activity+json' + }, + method: 'POST', + body: JSON.stringify(parsedActivity) + }, (error, response) => { + if (error) { + debug(`Error = ${JSON.stringify(error, null, 2)}`) + reject(error) + } else { + debug('Response Headers:', JSON.stringify(response.headers, null, 2)) + debug('Response Body:', JSON.stringify(response.body, null, 2)) + resolve() + } + }) + } + }) +} + +export function verify (url, headers) { + return new Promise((resolve, reject) => { + const signatureHeader = headers['signature'] ? headers['signature'] : headers['Signature'] + if (!signatureHeader) { + debug('No Signature header present!') + resolve(false) + } + debug(`Signature Header = ${signatureHeader}`) + const signature = extractKeyValueFromSignatureHeader(signatureHeader, 'signature') + const algorithm = extractKeyValueFromSignatureHeader(signatureHeader, 'algorithm') + const headersString = extractKeyValueFromSignatureHeader(signatureHeader, 'headers') + const keyId = extractKeyValueFromSignatureHeader(signatureHeader, 'keyId') + + if (!SUPPORTED_HASH_ALGORITHMS.includes(algorithm)) { + debug('Unsupported hash algorithm specified!') + resolve(false) + } + + const usedHeaders = headersString.split(' ') + const verifyHeaders = {} + Object.keys(headers).forEach((key) => { + if (usedHeaders.includes(key.toLowerCase())) { + verifyHeaders[key.toLowerCase()] = headers[key] + } + }) + const signingString = constructSigningString(url, verifyHeaders) + debug(`keyId= ${keyId}`) + request({ + url: keyId, + headers: { + 'Accept': 'application/json' + } + }, (err, response, body) => { + if (err) reject(err) + debug(`body = ${body}`) + const actor = JSON.parse(body) + const publicKeyPem = actor.publicKey.publicKeyPem + resolve(httpVerify(publicKeyPem, signature, signingString, algorithm)) + }) + }) +} + +// specify the public key owner object +/* const testPublicKeyOwner = { + "@context": jsig.SECURITY_CONTEXT_URL, + '@id': 'https://example.com/i/alice', + publicKey: [testPublicKey] +} */ + +/* const publicKey = { + '@context': jsig.SECURITY_CONTEXT_URL, + type: 'RsaVerificationKey2018', + id: `https://${ActivityPub.domain}/users/${fromName}#main-key`, + controller: `https://${ActivityPub.domain}/users/${fromName}`, + publicKeyPem +} */ + +// Obtained from invoking crypto.getHashes() +export const SUPPORTED_HASH_ALGORITHMS = [ + 'rsa-md4', + 'rsa-md5', + 'rsa-mdC2', + 'rsa-ripemd160', + 'rsa-sha1', + 'rsa-sha1-2', + 'rsa-sha224', + 'rsa-sha256', + 'rsa-sha384', + 'rsa-sha512', + 'blake2b512', + 'blake2s256', + 'md4', + 'md4WithRSAEncryption', + 'md5', + 'md5-sha1', + 'md5WithRSAEncryption', + 'mdc2', + 'mdc2WithRSA', + 'ripemd', + 'ripemd160', + 'ripemd160WithRSA', + 'rmd160', + 'sha1', + 'sha1WithRSAEncryption', + 'sha224', + 'sha224WithRSAEncryption', + 'sha256', + 'sha256WithRSAEncryption', + 'sha384', + 'sha384WithRSAEncryption', + 'sha512', + 'sha512WithRSAEncryption', + 'ssl3-md5', + 'ssl3-sha1', + 'whirlpool'] + +// signing +function createSignature (privKey, keyId, url, headers = {}, algorithm = 'rsa-sha256') { + if (!SUPPORTED_HASH_ALGORITHMS.includes(algorithm)) { return throw Error(`SIGNING: Unsupported hashing algorithm = ${algorithm}`) } + const signer = crypto.createSign(algorithm) + const signingString = constructSigningString(url, headers) + signer.update(signingString) + const signatureB64 = signer.sign({ key: privKey, passphrase: process.env.PRIVATE_KEY_PASSPHRASE }, 'base64') + const headersString = Object.keys(headers).reduce((result, key) => { return result + ' ' + key.toLowerCase() }, '') + return `keyId="${keyId}",algorithm="${algorithm}",headers="(request-target)${headersString}",signature="${signatureB64}"` +} + +// signing +function constructSigningString (url, headers) { + const urlObj = new URL(url) + let signingString = `(request-target): post ${urlObj.pathname}${urlObj.search !== '' ? urlObj.search : ''}` + return Object.keys(headers).reduce((result, key) => { + return result + `\n${key.toLowerCase()}: ${headers[key]}` + }, signingString) +} + +// verifying +function httpVerify (pubKey, signature, signingString, algorithm) { + if (!SUPPORTED_HASH_ALGORITHMS.includes(algorithm)) { throw Error(`SIGNING: Unsupported hashing algorithm = ${algorithm}`) } + const verifier = crypto.createVerify(algorithm) + verifier.update(signingString) + return verifier.verify(pubKey, signature, 'base64') +} + +// verifying +// This function can be used to extract the signature,headers,algorithm etc. out of the Signature Header. +// Just pass what you want as key +function extractKeyValueFromSignatureHeader (signatureHeader, key) { + const keyString = signatureHeader.split(',').filter((el) => { + return !!el.startsWith(key) + })[0] + + let firstEqualIndex = keyString.search('=') + // When headers are requested add 17 to the index to remove "(request-target) " from the string + if (key === 'headers') { firstEqualIndex += 17 } + return keyString.substring(firstEqualIndex + 2, keyString.length - 1) +} diff --git a/src/activitypub/utils/index.js b/src/activitypub/utils/index.js new file mode 100644 index 000000000..d1d4be845 --- /dev/null +++ b/src/activitypub/utils/index.js @@ -0,0 +1,215 @@ +import crypto from 'crypto' +import { signAndSend } from '../security' +import as from 'activitystrea.ms' +import { activityPub } from '../ActivityPub' +const debug = require('debug')('ea:utils') + +export function extractNameFromId (uri) { + const urlObject = new URL(uri) + const pathname = urlObject.pathname + const splitted = pathname.split('/') + + return splitted[splitted.indexOf('users') + 1] +} + +export function extractIdFromActivityId (uri) { + const urlObject = new URL(uri) + const pathname = urlObject.pathname + const splitted = pathname.split('/') + + return splitted[splitted.indexOf('status') + 1] +} + +export function constructIdFromName (name, fromDomain = activityPub.domain) { + return `https://${fromDomain}/activitypub/users/${name}` +} + +export function extractDomainFromUrl (url) { + return new URL(url).hostname +} + +export async function getActorIdByName (name) { + debug(`name = ${name}`) + return Promise.resolve() +} + +export function sendCollection (collectionName, req, res) { + const name = req.params.name + const id = constructIdFromName(name) + + switch (collectionName) { + case 'followers': + attachThenCatch(activityPub.getFollowersCollection(id), res) + break + + case 'followersPage': + attachThenCatch(activityPub.getFollowersCollectionPage(id), res) + break + + case 'following': + attachThenCatch(activityPub.getFollowingCollection(id), res) + break + + case 'followingPage': + attachThenCatch(activityPub.getFollowingCollectionPage(id), res) + break + + case 'outbox': + attachThenCatch(activityPub.getOutboxCollection(id), res) + break + + case 'outboxPage': + attachThenCatch(activityPub.getOutboxCollectionPage(id), res) + break + + default: + res.status(500).end() + } +} + +function attachThenCatch (promise, res) { + return promise + .then((collection) => { + res.status(200).contentType('application/activity+json').send(collection) + }) + .catch((err) => { + debug(`error getting a Collection: = ${err}`) + res.status(500).end() + }) +} + +export function createActor (name, pubkey) { + return { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1' + ], + 'id': `https://${activityPub.domain}/activitypub/users/${name}`, + 'type': 'Person', + 'preferredUsername': `${name}`, + 'name': `${name}`, + 'following': `https://${activityPub.domain}/activitypub/users/${name}/following`, + 'followers': `https://${activityPub.domain}/activitypub/users/${name}/followers`, + 'inbox': `https://${activityPub.domain}/activitypub/users/${name}/inbox`, + 'outbox': `https://${activityPub.domain}/activitypub/users/${name}/outbox`, + 'url': `https://${activityPub.domain}/activitypub/@${name}`, + 'endpoints': { + 'sharedInbox': `https://${activityPub.domain}/activitypub/inbox` + }, + 'publicKey': { + 'id': `https://${activityPub.domain}/activitypub/users/${name}#main-key`, + 'owner': `https://${activityPub.domain}/activitypub/users/${name}`, + 'publicKeyPem': pubkey + } + } +} + +export function createWebFinger (name) { + return { + 'subject': `acct:${name}@${activityPub.domain}`, + 'links': [ + { + 'rel': 'self', + 'type': 'application/activity+json', + 'href': `https://${activityPub.domain}/users/${name}` + } + ] + } +} + +export function createOrderedCollection (name, collectionName) { + return { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': `https://${activityPub.domain}/activitypub/users/${name}/${collectionName}`, + 'summary': `${name}s ${collectionName} collection`, + 'type': 'OrderedCollection', + 'first': `https://${activityPub.domain}/activitypub/users/${name}/${collectionName}?page=true`, + 'totalItems': 0 + } +} + +export function createOrderedCollectionPage (name, collectionName) { + return { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': `https://${activityPub.domain}/activitypub/users/${name}/${collectionName}?page=true`, + 'summary': `${name}s ${collectionName} collection`, + 'type': 'OrderedCollectionPage', + 'totalItems': 0, + 'partOf': `https://${activityPub.domain}/activitypub/users/${name}/${collectionName}`, + 'orderedItems': [] + } +} + +export function createNoteActivity (text, name, id, published) { + const createUuid = crypto.randomBytes(16).toString('hex') + + return { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': `https://${activityPub.domain}/activitypub/users/${name}/status/${createUuid}`, + 'type': 'Create', + 'actor': `https://${activityPub.domain}/activitypub/users/${name}`, + 'object': { + 'id': `https://${activityPub.domain}/activitypub/users/${name}/status/${id}`, + 'type': 'Note', + 'published': published, + 'attributedTo': `https://${activityPub.domain}/activitypub/users/${name}`, + 'content': text, + 'to': 'https://www.w3.org/ns/activitystreams#Public' + } + } +} + +export function createArticleActivity (text, name, id, published) { + const createUuid = crypto.randomBytes(16).toString('hex') + + return { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': `https://${activityPub.domain}/activitypub/users/${name}/status/${createUuid}`, + 'type': 'Create', + 'actor': `https://${activityPub.domain}/activitypub/users/${name}`, + 'object': { + 'id': `https://${activityPub.domain}/activitypub/users/${name}/status/${id}`, + 'type': 'Article', + 'published': published, + 'attributedTo': `https://${activityPub.domain}/activitypub/users/${name}`, + 'content': text, + 'to': 'https://www.w3.org/ns/activitystreams#Public' + } + } +} + +export function sendAcceptActivity (theBody, name, targetDomain, url) { + as.accept() + .id(`https://${activityPub.domain}/activitypub/users/${name}/status/` + crypto.randomBytes(16).toString('hex')) + .actor(`https://${activityPub.domain}/activitypub/users/${name}`) + .object(theBody) + .prettyWrite((err, doc) => { + if (!err) { + return signAndSend(doc, name, targetDomain, url) + } else { + debug(`error serializing Accept object: ${err}`) + throw new Error('error serializing Accept object') + } + }) +} + +export function sendRejectActivity (theBody, name, targetDomain, url) { + as.reject() + .id(`https://${activityPub.domain}/activitypub/users/${name}/status/` + crypto.randomBytes(16).toString('hex')) + .actor(`https://${activityPub.domain}/activitypub/users/${name}`) + .object(theBody) + .prettyWrite((err, doc) => { + if (!err) { + return signAndSend(doc, name, targetDomain, url) + } else { + debug(`error serializing Accept object: ${err}`) + throw new Error('error serializing Accept object') + } + }) +} + +export function throwErrorIfGraphQLErrorOccurred (result) { + if (result.error && (result.error.message || result.error.errors)) { + throw new Error(`${result.error.message ? result.error.message : result.error.errors[0].message}`) + } +} diff --git a/src/activitypub/utils/serveUser.js b/src/activitypub/utils/serveUser.js new file mode 100644 index 000000000..9a13275eb --- /dev/null +++ b/src/activitypub/utils/serveUser.js @@ -0,0 +1,43 @@ +import { createActor } from '../utils' +const gql = require('graphql-tag') +const debug = require('debug')('ea:serveUser') + +export async function serveUser (req, res, next) { + let name = req.params.name + + if (name.startsWith('@')) { + name = name.slice(1) + } + + debug(`name = ${name}`) + const result = await req.app.get('ap').dataSource.client.query({ + query: gql` + query { + User(slug: "${name}") { + publicKey + } + } + ` + }).catch(reason => { debug(`serveUser User fetch error: ${reason}`) }) + + if (result.data && Array.isArray(result.data.User) && result.data.User.length > 0) { + const publicKey = result.data.User[0].publicKey + const actor = createActor(name, publicKey) + debug(`actor = ${JSON.stringify(actor, null, 2)}`) + debug(`accepts json = ${req.accepts(['application/activity+json', 'application/ld+json', 'application/json'])}`) + if (req.accepts(['application/activity+json', 'application/ld+json', 'application/json'])) { + return res.json(actor) + } else if (req.accepts('text/html')) { + // TODO show user's profile page instead of the actor object + /* const outbox = JSON.parse(result.outbox) + const posts = outbox.orderedItems.filter((el) => { return el.object.type === 'Note'}) + const actor = result.actor + debug(posts) */ + // res.render('user', { user: actor, posts: JSON.stringify(posts)}) + return res.json(actor) + } + } else { + debug(`error getting publicKey for actor ${name}`) + next() + } +} diff --git a/src/index.js b/src/index.js index f594cb7f7..01063f573 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,5 @@ import createServer from './server' +import ActiviyPub from './activitypub/ActivityPub' const serverConfig = { port: process.env.GRAPHQL_PORT || 4000 @@ -12,4 +13,5 @@ const server = createServer() server.start(serverConfig, options => { /* eslint-disable-next-line no-console */ console.log(`Server ready at ${process.env.GRAPHQL_URI} 🚀`) + ActiviyPub.init(server) }) diff --git a/src/middleware/userMiddleware.js b/src/middleware/userMiddleware.js index 55b181bc9..9f3864dfe 100644 --- a/src/middleware/userMiddleware.js +++ b/src/middleware/userMiddleware.js @@ -1,8 +1,16 @@ import createOrUpdateLocations from './nodes/locations' +import { generateRsaKeyPair } from '../activitypub/security' +import dotenv from 'dotenv' +import { resolve } from 'path' + +dotenv.config({ path: resolve('src', 'activitypub', '.env') }) export default { Mutation: { CreateUser: async (resolve, root, args, context, info) => { + const keys = generateRsaKeyPair() + Object.assign(args, keys) + args.actorId = `${process.env.ACTIVITYPUB_URI}/activitypub/users/${args.slug}` const result = await resolve(root, args, context, info) await createOrUpdateLocations(args.id, args.locationName, context.driver) return result diff --git a/src/schema.graphql b/src/schema.graphql index 55f23d5ca..3626dd3c2 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -77,6 +77,7 @@ type Location { type User { id: ID! + actorId: String name: String email: String slug: String @@ -85,6 +86,8 @@ type User { deleted: Boolean disabled: Boolean role: UserGroupEnum + publicKey: String + privateKey: String location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l") locationName: String diff --git a/test/features/activity-article.feature.disable b/test/features/activity-article.feature.disable new file mode 100644 index 000000000..858f85473 --- /dev/null +++ b/test/features/activity-article.feature.disable @@ -0,0 +1,30 @@ +Feature: Send and receive Articles + I want to send and receive article's via ActivityPub + + Background: + Given our own server runs at "http://localhost:4100" + And we have the following users in our database: + | Slug | + | marvin | + | max | + + Scenario: Send an article to a user inbox and make sure it's added to the inbox + When I send a POST request with the following activity to "/activitypub/users/max/inbox": + """ + { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://aronda.org/users/marvin/status/lka7dfzkjn2398hsfd", + "type": "Create", + "actor": "https://aronda.org/users/marvin", + "object": { + "id": "https://aronda.org/users/marvin/status/kljsdfg9843jknsdf", + "type": "Article", + "published": "2019-02-07T19:37:55.002Z", + "attributedTo": "https://aronda.org/users/marvin", + "content": "Hi John, how are you?", + "to": "https://localhost:4100/activitypub/users/max" + } + } + """ + Then I expect the status code to be 200 + And the post with id "kljsdfg9843jknsdf" to be created diff --git a/test/features/activity-delete.feature.disable b/test/features/activity-delete.feature.disable new file mode 100644 index 000000000..9d671500f --- /dev/null +++ b/test/features/activity-delete.feature.disable @@ -0,0 +1,28 @@ +Feature: Delete an object + I want to delete objects + + Background: + Given our own server runs at "http://localhost:4100" + And we have the following users in our database: + | Slug | + | bernd-das-brot| + + Scenario: Deleting a post (Article Object) + When I send a POST request with the following activity to "/activitypub/users/bernd-das-brot/inbox": + """ + { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://localhost:4100/users/karl-heinz/status/a4DJ2afdg323v32641vna42lkj685kasd2", + "type": "Delete", + "object": { + "id": "https://aronda.org/users/marvin/status/kljsdfg9843jknsdf", + "type": "Article", + "published": "2019-02-07T19:37:55.002Z", + "attributedTo": "https://aronda.org/users/marvin", + "content": "Hi John, how are you?", + "to": "https://localhost:4100/activitypub/users/max" + } + } + """ + Then I expect the status code to be 200 + And the object is removed from the outbox collection of "karl-heinz" diff --git a/test/features/activity-follow.feature b/test/features/activity-follow.feature new file mode 100644 index 000000000..0797279d8 --- /dev/null +++ b/test/features/activity-follow.feature @@ -0,0 +1,51 @@ +Feature: Follow a user + I want to be able to follow a user on another instance. + Also if I do not want to follow a previous followed user anymore, + I want to undo the follow. + + Background: + Given our own server runs at "http://localhost:4123" + And we have the following users in our database: + | Slug | + | karl-heinz | + | peter-lustiger | + + Scenario: Send a follow to a user inbox and make sure it's added to the right followers collection + When I send a POST request with the following activity to "/activitypub/users/peter-lustiger/inbox": + """ + { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://localhost:4123/activitypub/users/bob-der-baumeister/status/83J23549sda1k72fsa4567na42312455kad83", + "type": "Follow", + "actor": "http://localhost:4123/activitypub/users/bob-der-baumeister", + "object": "http://localhost:4123/activitypub/users/peter-lustiger" + } + """ + Then I expect the status code to be 200 + And the follower is added to the followers collection of "peter-lustiger" + """ + https://localhost:4123/activitypub/users/bob-der-baumeister + """ + + Scenario: Send an undo activity to revert the previous follow activity + When I send a POST request with the following activity to "/activitypub/users/bob-der-baumeister/inbox": + """ + { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://localhost:4123/activitypub/users/peter-lustiger/status/a4DJ2afdg323v32641vna42lkj685kasd2", + "type": "Undo", + "actor": "http://localhost:4123/activitypub/users/peter-lustiger", + "object": { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://localhost:4123/activitypub/users/bob-der-baumeister/status/83J23549sda1k72fsa4567na42312455kad83", + "type": "Follow", + "actor": "http://localhost:4123/activitypub/users/bob-der-baumeister", + "object": "http://localhost:4123/activitypub/users/peter-lustiger" + } + } + """ + Then I expect the status code to be 200 + And the follower is removed from the followers collection of "peter-lustiger" + """ + https://localhost:4123/activitypub/users/bob-der-baumeister + """ diff --git a/test/features/collection.feature b/test/features/collection.feature new file mode 100644 index 000000000..536d3aa2d --- /dev/null +++ b/test/features/collection.feature @@ -0,0 +1,101 @@ +Feature: Receiving collections + As a member of the Fediverse I want to be able of fetching collections + + Background: + Given our own server runs at "http://localhost:4123" + And we have the following users in our database: + | Slug | + | renate-oberdorfer | + + Scenario: Send a request to the outbox URI of peter-lustig and expect a ordered collection + When I send a GET request to "/activitypub/users/renate-oberdorfer/outbox" + Then I expect the status code to be 200 + And I receive the following json: + """ + { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://localhost:4123/activitypub/users/renate-oberdorfer/outbox", + "summary": "renate-oberdorfers outbox collection", + "type": "OrderedCollection", + "first": "https://localhost:4123/activitypub/users/renate-oberdorfer/outbox?page=true", + "totalItems": 0 + } + """ + + Scenario: Send a request to the following URI of peter-lustig and expect a ordered collection + When I send a GET request to "/activitypub/users/renate-oberdorfer/following" + Then I expect the status code to be 200 + And I receive the following json: + """ + { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://localhost:4123/activitypub/users/renate-oberdorfer/following", + "summary": "renate-oberdorfers following collection", + "type": "OrderedCollection", + "first": "https://localhost:4123/activitypub/users/renate-oberdorfer/following?page=true", + "totalItems": 0 + } + """ + + Scenario: Send a request to the followers URI of peter-lustig and expect a ordered collection + When I send a GET request to "/activitypub/users/renate-oberdorfer/followers" + Then I expect the status code to be 200 + And I receive the following json: + """ + { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://localhost:4123/activitypub/users/renate-oberdorfer/followers", + "summary": "renate-oberdorfers followers collection", + "type": "OrderedCollection", + "first": "https://localhost:4123/activitypub/users/renate-oberdorfer/followers?page=true", + "totalItems": 0 + } + """ + + Scenario: Send a request to the outbox URI of peter-lustig and expect a paginated outbox collection + When I send a GET request to "/activitypub/users/renate-oberdorfer/outbox?page=true" + Then I expect the status code to be 200 + And I receive the following json: + """ + { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://localhost:4123/activitypub/users/renate-oberdorfer/outbox?page=true", + "summary": "renate-oberdorfers outbox collection", + "type": "OrderedCollectionPage", + "totalItems": 0, + "partOf": "https://localhost:4123/activitypub/users/renate-oberdorfer/outbox", + "orderedItems": [] + } + """ + + Scenario: Send a request to the following URI of peter-lustig and expect a paginated following collection + When I send a GET request to "/activitypub/users/renate-oberdorfer/following?page=true" + Then I expect the status code to be 200 + And I receive the following json: + """ + { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://localhost:4123/activitypub/users/renate-oberdorfer/following?page=true", + "summary": "renate-oberdorfers following collection", + "type": "OrderedCollectionPage", + "totalItems": 0, + "partOf": "https://localhost:4123/activitypub/users/renate-oberdorfer/following", + "orderedItems": [] + } + """ + + Scenario: Send a request to the followers URI of peter-lustig and expect a paginated followers collection + When I send a GET request to "/activitypub/users/renate-oberdorfer/followers?page=true" + Then I expect the status code to be 200 + And I receive the following json: + """ + { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://localhost:4123/activitypub/users/renate-oberdorfer/followers?page=true", + "summary": "renate-oberdorfers followers collection", + "type": "OrderedCollectionPage", + "totalItems": 0, + "partOf": "https://localhost:4123/activitypub/users/renate-oberdorfer/followers", + "orderedItems": [] + } + """ diff --git a/test/features/support/steps.js b/test/features/support/steps.js new file mode 100644 index 000000000..454db0e31 --- /dev/null +++ b/test/features/support/steps.js @@ -0,0 +1,125 @@ +// features/support/steps.js +import { Given, When, Then, AfterAll } from 'cucumber' +import { expect } from 'chai' +// import { client } from '../../../src/activitypub/apollo-client' +import { GraphQLClient } from 'graphql-request' +import Factory from '../../../src/seed/factories' +import { host } from '../../../src/jest/helpers' +const debug = require('debug')('ea:test:steps') + +const factory = Factory() +const client = new GraphQLClient(host) + +function createUser (slug) { + debug(`creating user ${slug}`) + return factory.create('User', { + name: slug, + email: 'example@test.org', + password: '1234' + }) + // await login({ email: 'example@test.org', password: '1234' }) +} + +AfterAll('Clean up the test data', function () { + debug('All the tests are done! Deleting test data') +}) + +Given('our own server runs at {string}', function (string) { + // just documenation +}) + +Given('we have the following users in our database:', async function (dataTable) { + await Promise.all(dataTable.hashes().map((user) => { + return createUser(user.Slug) + })) +}) + +When('I send a GET request to {string}', async function (pathname) { + const response = await this.get(pathname) + this.lastContentType = response.lastContentType + + this.lastResponses.push(response.lastResponse) + this.statusCode = response.statusCode +}) + +When('I send a POST request with the following activity to {string}:', async function (inboxUrl, activity) { + debug(`inboxUrl = ${inboxUrl}`) + debug(`activity = ${activity}`) + this.lastInboxUrl = inboxUrl + this.lastActivity = activity + const response = await this.post(inboxUrl, activity) + + this.lastResponses.push(response.lastResponse) + this.lastResponse = response.lastResponse + this.statusCode = response.statusCode +}) + +Then('I receive the following json:', function (docString) { + const parsedDocString = JSON.parse(docString) + const parsedLastResponse = JSON.parse(this.lastResponses.shift()) + if (Array.isArray(parsedDocString.orderedItems)) { + parsedDocString.orderedItems.forEach((el) => { + delete el.id + if (el.object) delete el.object.published + }) + parsedLastResponse.orderedItems.forEach((el) => { + delete el.id + if (el.object) delete el.object.published + }) + } + if (parsedDocString.publicKey && parsedDocString.publicKey.publicKeyPem) { + delete parsedDocString.publicKey.publicKeyPem + delete parsedLastResponse.publicKey.publicKeyPem + } + expect(parsedDocString).to.eql(parsedLastResponse) +}) + +Then('I expect the Content-Type to be {string}', function (contentType) { + expect(this.lastContentType).to.equal(contentType) +}) + +Then('I expect the status code to be {int}', function (statusCode) { + expect(this.statusCode).to.equal(statusCode) +}) + +Then('the activity is added to the {string} collection', async function (collectionName) { + const response = await this.get(this.lastInboxUrl.replace('inbox', collectionName) + '?page=true') + debug(`orderedItems = ${JSON.parse(response.lastResponse).orderedItems}`) + expect(JSON.parse(response.lastResponse).orderedItems).to.include(JSON.parse(this.lastActivity).object) +}) + +Then('the follower is added to the followers collection of {string}', async function (userName, follower) { + const response = await this.get(`/activitypub/users/${userName}/followers?page=true`) + const responseObject = JSON.parse(response.lastResponse) + expect(responseObject.orderedItems).to.include(follower) +}) + +Then('the follower is removed from the followers collection of {string}', async function (userName, follower) { + const response = await this.get(`/activitypub/users/${userName}/followers?page=true`) + const responseObject = JSON.parse(response.lastResponse) + expect(responseObject.orderedItems).to.not.include(follower) +}) + +Then('the activity is added to the users inbox collection', async function () { + +}) + +Then('the post with id {string} to be created', async function (id) { + const result = await client.request(` + query { + Post(id: "${id}") { + name + } + } + `) + + expect(result.data.Post).to.be.an('array').that.is.not.empty // eslint-disable-line +}) + +Then('the object is removed from the outbox collection of {string}', (name) => { + +}) + +Then('I send a GET request to {string} and expect a ordered collection', () => { + +}) diff --git a/test/features/webfinger.feature b/test/features/webfinger.feature new file mode 100644 index 000000000..e7d1ace04 --- /dev/null +++ b/test/features/webfinger.feature @@ -0,0 +1,65 @@ +Feature: Webfinger discovery + From an external server, e.g. Mastodon + I want to search for an actor alias + In order to follow the actor + + Background: + Given our own server runs at "http://localhost:4100" + And we have the following users in our database: + | Slug | + | peter-lustiger | + + Scenario: Search + When I send a GET request to "/.well-known/webfinger?resource=acct:peter-lustiger@localhost" + Then I receive the following json: + """ + { + "subject": "acct:peter-lustiger@localhost:4123", + "links": [ + { + "rel": "self", + "type": "application/activity+json", + "href": "https://localhost:4123/users/peter-lustiger" + } + ] + } + """ + And I expect the Content-Type to be "application/jrd+json; charset=utf-8" + + Scenario: User does not exist + When I send a GET request to "/.well-known/webfinger?resource=acct:nonexisting@localhost" + Then I receive the following json: + """ + { + "error": "No record found for nonexisting@localhost." + } + """ + + Scenario: Receiving an actor object + When I send a GET request to "/activitypub/users/peter-lustiger" + Then I receive the following json: + """ + { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1" + ], + "id": "https://localhost:4123/activitypub/users/peter-lustiger", + "type": "Person", + "preferredUsername": "peter-lustiger", + "name": "peter-lustiger", + "following": "https://localhost:4123/activitypub/users/peter-lustiger/following", + "followers": "https://localhost:4123/activitypub/users/peter-lustiger/followers", + "inbox": "https://localhost:4123/activitypub/users/peter-lustiger/inbox", + "outbox": "https://localhost:4123/activitypub/users/peter-lustiger/outbox", + "url": "https://localhost:4123/activitypub/@peter-lustiger", + "endpoints": { + "sharedInbox": "https://localhost:4123/activitypub/inbox" + }, + "publicKey": { + "id": "https://localhost:4123/activitypub/users/peter-lustiger#main-key", + "owner": "https://localhost:4123/activitypub/users/peter-lustiger", + "publicKeyPem": "adglkjlk89235kjn8obn2384f89z5bv9..." + } + } + """ diff --git a/test/features/world.js b/test/features/world.js new file mode 100644 index 000000000..26282f45a --- /dev/null +++ b/test/features/world.js @@ -0,0 +1,58 @@ +// features/support/world.js +import { setWorldConstructor } from 'cucumber' +import request from 'request' +const debug = require('debug')('ea:test:world') + +class CustomWorld { + constructor () { + // webfinger.feature + this.lastResponses = [] + this.lastContentType = null + this.lastInboxUrl = null + this.lastActivity = null + // activity-article.feature.disable + this.statusCode = null + } + get (pathname) { + return new Promise((resolve, reject) => { + request(`http://localhost:4123/${this.replaceSlashes(pathname)}`, { + headers: { + 'Accept': 'application/activity+json' + }}, function (error, response, body) { + if (!error) { + debug(`get response = ${response.headers['content-type']}`) + debug(`body = ${body}`) + resolve({ lastResponse: body, lastContentType: response.headers['content-type'], statusCode: response.statusCode }) + } else { + reject(error) + } + }) + }) + } + + replaceSlashes (pathname) { + return pathname.replace(/^\/+/, '') + } + + post (pathname, activity) { + return new Promise((resolve, reject) => { + request({ + url: `http://localhost:4123/${this.replaceSlashes(pathname)}`, + method: 'POST', + headers: { + 'Content-Type': 'application/activity+json' + }, + body: activity + }, function (error, response, body) { + if (!error) { + debug(`post response = ${response.headers['content-type']}`) + resolve({ lastResponse: body, lastContentType: response.headers['content-type'], statusCode: response.statusCode }) + } else { + reject(error) + } + }) + }) + } +} + +setWorldConstructor(CustomWorld) diff --git a/yarn.lock b/yarn.lock index 2fe610fee..fc6697b2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -291,6 +291,14 @@ "@babel/helper-plugin-utils" "^7.0.0" "@babel/plugin-syntax-optional-catch-binding" "^7.2.0" +"@babel/plugin-proposal-throw-expressions@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-throw-expressions/-/plugin-proposal-throw-expressions-7.2.0.tgz#2d9e452d370f139000e51db65d0a85dc60c64739" + integrity sha512-adsydM8DQF4i5DLNO4ySAU5VtHTPewOtNBV3u7F4lNMPADFF9bWQ+iDtUUe8+033cYCUz+bFlQdXQJmJOwoLpw== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-syntax-throw-expressions" "^7.2.0" + "@babel/plugin-proposal-unicode-property-regex@^7.2.0": version "7.2.0" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.2.0.tgz#abe7281fe46c95ddc143a65e5358647792039520" @@ -328,6 +336,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.0.0" +"@babel/plugin-syntax-throw-expressions@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-throw-expressions/-/plugin-syntax-throw-expressions-7.2.0.tgz#79001ee2afe1b174b1733cdc2fc69c9a46a0f1f8" + integrity sha512-ngwynuqu1Rx0JUS9zxSDuPgW1K8TyVZCi2hHehrL4vyjqE7RGoNHWlZsS7KQT2vw9Yjk4YLa0+KldBXTRdPLRg== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-transform-arrow-functions@^7.2.0": version "7.2.0" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.2.0.tgz#9aeafbe4d6ffc6563bf8f8372091628f00779550" @@ -558,6 +573,14 @@ core-js "^2.5.7" regenerator-runtime "^0.11.1" +"@babel/polyfill@^7.2.3": + version "7.2.5" + resolved "https://registry.yarnpkg.com/@babel/polyfill/-/polyfill-7.2.5.tgz#6c54b964f71ad27edddc567d065e57e87ed7fa7d" + integrity sha512-8Y/t3MWThtMLYr0YNC/Q76tqN1w30+b0uQMeFUYauG2UGTR19zyUtFrAzT23zNtBxPp+LbE5E/nwV/q/r3y6ug== + dependencies: + core-js "^2.5.7" + regenerator-runtime "^0.12.0" + "@babel/preset-env@~7.3.1": version "7.3.1" resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.3.1.tgz#389e8ca6b17ae67aaf9a2111665030be923515db" @@ -876,6 +899,33 @@ acorn@^6.0.1, acorn@^6.0.2: resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.0.4.tgz#77377e7353b72ec5104550aa2d2097a2fd40b754" integrity sha512-VY4i5EKSKkofY2I+6QLTbTTN/UvEQPCo6eiwzzSaSWfpaDhOmStMCMod6wmuPciNq+XS0faCglFu2lHZpdHUtg== +activitystrea.ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/activitystrea.ms/-/activitystrea.ms-2.1.3.tgz#553548733e367dc0b6a7badc25fa6f8996cd80c3" + integrity sha512-iiG5g5fYgfdaaqqFPaFIZC/KX8/4mOWkvniK+BNwJY6XDDKdIu56wmc9r0x1INHVnbFOTGuM8mZEntaM3I+YXw== + dependencies: + activitystreams-context "^3.0.0" + jsonld "^0.4.11" + jsonld-signatures "^1.1.5" + moment "^2.17.1" + readable-stream "^2.2.3" + reasoner "2.0.0" + rfc5646 "^2.0.0" + vocabs-as "^3.0.0" + vocabs-asx "^0.11.1" + vocabs-interval "^0.11.1" + vocabs-ldp "^0.1.0" + vocabs-owl "^0.11.1" + vocabs-rdf "^0.11.1" + vocabs-rdfs "^0.11.1" + vocabs-social "^0.11.1" + vocabs-xsd "^0.11.1" + +activitystreams-context@>=3.0.0, activitystreams-context@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/activitystreams-context/-/activitystreams-context-3.1.0.tgz#28334e129f17cfb937e8c702c52c1bcb1d2830c7" + integrity sha512-KBQ+igwf1tezMXGVw5MvRSEm0gp97JI1hTZ45I6MEkWv25lEgNoA9L6wqfaOiCX8wnMRWw9pwRsPZKypdtxAtg== + ajv@^6.5.3, ajv@^6.5.5, ajv@^6.6.1: version "6.6.1" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.6.1.tgz#6360f5ed0d80f232cc2b294c362d5dc2e538dd61" @@ -920,6 +970,11 @@ ansi-styles@^3.2.0, ansi-styles@^3.2.1: dependencies: color-convert "^1.9.0" +any-promise@^1.0.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" + integrity sha1-q8av7tzqUugJzcA3au0845Y10X8= + anymatch@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb" @@ -1295,6 +1350,15 @@ assert-plus@1.0.0, assert-plus@^1.0.0: resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU= +assertion-error-formatter@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/assertion-error-formatter/-/assertion-error-formatter-2.0.1.tgz#6bbdffaec8e2fa9e2b0eb158bfe353132d7c0a9b" + integrity sha512-cjC3jUCh9spkroKue5PDSKH5RFQ/KNuZJhk3GwHYmB/8qqETxLOmMdLH+ohi/VukNzxDlMvIe7zScvLoOdhb6Q== + dependencies: + diff "^3.0.0" + pad-right "^0.2.2" + repeat-string "^1.6.1" + assertion-error@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" @@ -1337,6 +1401,11 @@ async-retry@^1.2.1: dependencies: retry "0.12.0" +async@^1.5.2: + version "1.5.2" + resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" + integrity sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo= + async@^2.5.0, async@^2.6.1: version "2.6.1" resolved "https://registry.yarnpkg.com/async/-/async-2.6.1.tgz#b245a23ca71930044ec53fa46aa00a3e87c6a610" @@ -1485,11 +1554,49 @@ bcryptjs@~2.4.3: resolved "https://registry.yarnpkg.com/bcryptjs/-/bcryptjs-2.4.3.tgz#9ab5627b93e60621ff7cdac5da9733027df1d0cb" integrity sha1-mrVie5PmBiH/fNrF2pczAn3x0Ms= +becke-ch--regex--s0-0-v1--base--pl--lib@^1.2.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/becke-ch--regex--s0-0-v1--base--pl--lib/-/becke-ch--regex--s0-0-v1--base--pl--lib-1.4.0.tgz#429ceebbfa5f7e936e78d73fbdc7da7162b20e20" + integrity sha1-Qpzuu/pffpNueNc/vcfacWKyDiA= + binary-extensions@^1.0.0: version "1.12.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.12.0.tgz#c2d780f53d45bba8317a8902d4ceeaf3a6385b14" integrity sha512-DYWGk01lDcxeS/K9IHPGWfT8PsJmbXRtRd2Sx72Tnb8pcYZQFF1oSDb8hJtS1vhp212q1Rzi5dUf9+nq0o9UIg== +bitcore-lib@^0.13.7: + version "0.13.19" + resolved "https://registry.yarnpkg.com/bitcore-lib/-/bitcore-lib-0.13.19.tgz#48af1e9bda10067c1ab16263472b5add2000f3dc" + integrity sha1-SK8em9oQBnwasWJjRyta3SAA89w= + dependencies: + bn.js "=2.0.4" + bs58 "=2.0.0" + buffer-compare "=1.0.0" + elliptic "=3.0.3" + inherits "=2.0.1" + lodash "=3.10.1" + +"bitcore-message@github:comakery/bitcore-message#dist": + version "1.0.2" + resolved "https://codeload.github.com/comakery/bitcore-message/tar.gz/8799cc327029c3d34fc725f05b2cf981363f6ebf" + dependencies: + bitcore-lib "^0.13.7" + +bluebird@^3.4.1: + version "3.5.3" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.3.tgz#7d01c6f9616c9a51ab0f8c549a79dfe6ec33efa7" + integrity sha512-/qKPUQlaW1OyR51WeCPBvRnAlnZFUJkCSG5HzGnuIqhgyJtF+T94lFnn33eiazjRm2LAHVy2guNnaq48X9SJuw== + +bn.js@=2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-2.0.4.tgz#220a7cd677f7f1bfa93627ff4193776fe7819480" + integrity sha1-Igp81nf38b+pNif/QZN3b+eBlIA= + +bn.js@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-2.2.0.tgz#12162bc2ae71fc40a5626c33438f3a875cd37625" + integrity sha1-EhYrwq5x/EClYmwzQ486h1zTdiU= + body-parser-graphql@1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/body-parser-graphql/-/body-parser-graphql-1.1.0.tgz#80a80353c7cb623562fd375750dfe018d75f0f7c" @@ -1555,6 +1662,11 @@ braces@^2.3.1, braces@^2.3.2: split-string "^3.0.2" to-regex "^3.0.1" +brorand@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" + integrity sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8= + browser-process-hrtime@^0.1.2: version "0.1.3" resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-0.1.3.tgz#616f00faef1df7ec1b5bf9cfe2bdc3170f26c7b4" @@ -1576,6 +1688,11 @@ browserslist@^4.3.4: electron-to-chromium "^1.3.86" node-releases "^1.0.5" +bs58@=2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/bs58/-/bs58-2.0.0.tgz#72b713bed223a0ac518bbda0e3ce3f4817f39eb5" + integrity sha1-crcTvtIjoKxRi72g484/SBfznrU= + bser@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/bser/-/bser-2.0.0.tgz#9ac78d3ed5d915804fd87acb158bc797147a1719" @@ -1583,6 +1700,11 @@ bser@^2.0.0: dependencies: node-int64 "^0.4.0" +buffer-compare@=1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/buffer-compare/-/buffer-compare-1.0.0.tgz#acaa7a966e98eee9fae14b31c39a5f158fb3c4a2" + integrity sha1-rKp6lm6Y7un64Usxw5pfFY+zxKI= + buffer-equal-constant-time@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" @@ -1794,7 +1916,7 @@ cli-cursor@^2.1.0: dependencies: restore-cursor "^2.0.0" -cli-table3@^0.5.0: +cli-table3@^0.5.0, cli-table3@^0.5.1: version "0.5.1" resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.5.1.tgz#0252372d94dfc40dbd8df06005f48f31f656f202" integrity sha512-7Qg2Jrep1S/+Q3EceiZtQcDPWxhAvBw+ERf1162v4sikJrvojMHFqXt8QIVha8UlH9rgU0BeWPytZ9/TzYqlUw== @@ -1860,7 +1982,7 @@ combined-stream@^1.0.6, combined-stream@~1.0.6: dependencies: delayed-stream "~1.0.0" -commander@^2.5.0, commander@^2.8.1: +commander@^2.5.0, commander@^2.8.1, commander@^2.9.0: version "2.19.0" resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a" integrity sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg== @@ -1870,6 +1992,13 @@ commander@~2.17.1: resolved "https://registry.yarnpkg.com/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf" integrity sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg== +commander@~2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.9.0.tgz#9c99094176e12240cb22d6c5146098400fe0f7d4" + integrity sha1-nJkJQXbhIkDLItbFFGCYQA/g99Q= + dependencies: + graceful-readlink ">= 1.0.0" + commondir@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" @@ -1973,7 +2102,7 @@ core-util-is@1.0.2, core-util-is@~1.0.0: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= -cors@^2.8.4: +cors@^2.8.4, cors@^2.8.5: version "2.8.5" resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== @@ -2064,6 +2193,60 @@ cssstyle@^1.0.0: dependencies: cssom "0.3.x" +cucumber-expressions@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/cucumber-expressions/-/cucumber-expressions-6.0.1.tgz#47c9c573781c2ff721d7ad5b2cd1c97f4399ab8e" + integrity sha1-R8nFc3gcL/ch161bLNHJf0OZq44= + dependencies: + becke-ch--regex--s0-0-v1--base--pl--lib "^1.2.0" + +cucumber-tag-expressions@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/cucumber-tag-expressions/-/cucumber-tag-expressions-1.1.1.tgz#7f5c7b70009bc2b666591bfe64854578bedee85a" + integrity sha1-f1x7cACbwrZmWRv+ZIVFeL7e6Fo= + +cucumber@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/cucumber/-/cucumber-5.1.0.tgz#7b166812c255bec7eac4b0df7007a40d089c895d" + integrity sha512-zrl2VYTBRgvxucwV2GKAvLqcfA1Naeax8plPvWgPEzl3SCJiuPPv3WxBHIRHtPYcEdbHDR6oqLpZP4bJ8UIdmA== + dependencies: + "@babel/polyfill" "^7.2.3" + assertion-error-formatter "^2.0.1" + bluebird "^3.4.1" + cli-table3 "^0.5.1" + colors "^1.1.2" + commander "^2.9.0" + cross-spawn "^6.0.5" + cucumber-expressions "^6.0.0" + cucumber-tag-expressions "^1.1.1" + duration "^0.2.1" + escape-string-regexp "^1.0.5" + figures "2.0.0" + gherkin "^5.0.0" + glob "^7.1.3" + indent-string "^3.1.0" + is-generator "^1.0.2" + is-stream "^1.1.0" + knuth-shuffle-seeded "^1.0.6" + lodash "^4.17.10" + mz "^2.4.0" + progress "^2.0.0" + resolve "^1.3.3" + serialize-error "^3.0.0" + stack-chain "^2.0.0" + stacktrace-js "^2.0.0" + string-argv "0.1.1" + title-case "^2.1.1" + util-arity "^1.0.2" + verror "^1.9.0" + +d@1: + version "1.0.0" + resolved "https://registry.yarnpkg.com/d/-/d-1.0.0.tgz#754bb5bfe55451da69a58b94d45f4c5b0462d58f" + integrity sha1-dUu1v+VUUdpppYuU1F9MWwRi1Y8= + dependencies: + es5-ext "^0.10.9" + dashdash@^1.12.0: version "1.14.1" resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" @@ -2217,6 +2400,11 @@ diff-sequences@^24.0.0: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-24.0.0.tgz#cdf8e27ed20d8b8d3caccb4e0c0d8fe31a173013" integrity sha512-46OkIuVGBBnrC0soO/4LHu5LHGHx0uhP65OVz8XOrAJpqiCB2aVIuESvjI1F9oqebuvY8lekS1pt6TN7vt7qsw== +diff@^3.0.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" + integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA== + doctrine@1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-1.5.0.tgz#379dce730f6166f76cefa4e6707a159b02c5a6fa" @@ -2302,6 +2490,14 @@ duplexer3@^0.1.4: resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2" integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI= +duration@^0.2.1: + version "0.2.2" + resolved "https://registry.yarnpkg.com/duration/-/duration-0.2.2.tgz#ddf149bc3bc6901150fe9017111d016b3357f529" + integrity sha512-06kgtea+bGreF5eKYgI/36A6pLXggY7oR4p1pq4SmdFBn1ReOL5D8RhG64VrqfTTKNucqqtBAwEj8aB88mcqrg== + dependencies: + d "1" + es5-ext "~0.10.46" + ecc-jsbn@~0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" @@ -2327,6 +2523,16 @@ electron-to-chromium@^1.3.86: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.88.tgz#f36ab32634f49ef2b0fdc1e82e2d1cc17feb29e7" integrity sha512-UPV4NuQMKeUh1S0OWRvwg0PI8ASHN9kBC8yDTk1ROXLC85W5GnhTRu/MZu3Teqx3JjlQYuckuHYXSUSgtb3J+A== +elliptic@=3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-3.0.3.tgz#865c9b420bfbe55006b9f969f97a0d2c44966595" + integrity sha1-hlybQgv75VAGuflp+XoNLESWZZU= + dependencies: + bn.js "^2.0.0" + brorand "^1.0.1" + hash.js "^1.0.0" + inherits "^2.0.1" + encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" @@ -2351,6 +2557,13 @@ error-ex@^1.2.0, error-ex@^1.3.1: dependencies: is-arrayish "^0.2.1" +error-stack-parser@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/error-stack-parser/-/error-stack-parser-2.0.2.tgz#4ae8dbaa2bf90a8b450707b9149dcabca135520d" + integrity sha512-E1fPutRDdIj/hohG0UpT5mayXNCxXP9d+snxFsPU9X0XgccOumKraa3juDMwTUyi7+Bu5+mCGagjg4IYeNbOdw== + dependencies: + stackframe "^1.0.4" + es-abstract@^1.4.3: version "1.12.0" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.12.0.tgz#9dbbdd27c6856f0001421ca18782d786bf8a6165" @@ -2383,11 +2596,52 @@ es-to-primitive@^1.1.1, es-to-primitive@^1.2.0: is-date-object "^1.0.1" is-symbol "^1.0.2" +es5-ext@^0.10.35, es5-ext@^0.10.9, es5-ext@~0.10.14, es5-ext@~0.10.46: + version "0.10.48" + resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.48.tgz#9a0b31eeded39e64453bcedf6f9d50bbbfb43850" + integrity sha512-CdRvPlX/24Mj5L4NVxTs4804sxiS2CjVprgCmrgoDkdmjdY4D+ySHa7K3jJf8R40dFg0tIm3z/dk326LrnuSGw== + dependencies: + es6-iterator "~2.0.3" + es6-symbol "~3.1.1" + next-tick "1" + es6-error@^4.0.1: version "4.1.1" resolved "https://registry.yarnpkg.com/es6-error/-/es6-error-4.1.1.tgz#9e3af407459deed47e9a91f9b885a84eb05c561d" integrity sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg== +es6-iterator@~2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.3.tgz#a7de889141a05a94b0854403b2d0a0fbfa98f3b7" + integrity sha1-p96IkUGgWpSwhUQDstCg+/qY87c= + dependencies: + d "1" + es5-ext "^0.10.35" + es6-symbol "^3.1.1" + +es6-promise@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-2.3.0.tgz#96edb9f2fdb01995822b263dd8aadab6748181bc" + integrity sha1-lu258v2wGZWCKyY92KratnSBgbw= + +es6-promise@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-2.0.1.tgz#ccc4963e679f0ca9fb187c777b9e583d3c7573c2" + integrity sha1-zMSWPmefDKn7GHx3e55YPTx1c8I= + +es6-promise@~4.0.5: + version "4.0.5" + resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.0.5.tgz#7882f30adde5b240ccfa7f7d78c548330951ae42" + integrity sha1-eILzCt3lskDM+n99eMVIMwlRrkI= + +es6-symbol@^3.1.1, es6-symbol@~3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.1.tgz#bf00ef4fdab6ba1b46ecb7b629b4c7ed5715cc77" + integrity sha1-vwDvT9q2uhtG7Le2KbTH7VcVzHc= + dependencies: + d "1" + es5-ext "~0.10.14" + escape-html@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" @@ -2670,7 +2924,7 @@ expect@^24.1.0: jest-message-util "^24.0.0" jest-regex-util "^24.0.0" -express@^4.0.0, express@^4.16.3: +express@^4.0.0, express@^4.16.3, express@^4.16.4: version "4.16.4" resolved "https://registry.yarnpkg.com/express/-/express-4.16.4.tgz#fddef61926109e24c515ea97fd2f1bdbf62df12e" integrity sha512-j12Uuyb4FMrd/qQAm6uCHAkPtO8FDTRJZBDd5D2KOL2eLaz1yUNdUB/NOIyq0iU4q4cFarsUCrnFDPBcnksuOg== @@ -2786,7 +3040,7 @@ fb-watchman@^2.0.0: dependencies: bser "^2.0.0" -figures@^2.0.0: +figures@2.0.0, figures@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962" integrity sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI= @@ -3024,6 +3278,11 @@ getpass@^0.1.1: dependencies: assert-plus "^1.0.0" +gherkin@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/gherkin/-/gherkin-5.1.0.tgz#684bbb03add24eaf7bdf544f58033eb28fb3c6d5" + integrity sha1-aEu7A63STq9731RPWAM+so+zxtU= + glob-parent@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae" @@ -3078,6 +3337,11 @@ graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2: resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.15.tgz#ffb703e1066e8a0eeaa4c8b80ba9253eeefbfb00" integrity sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA== +"graceful-readlink@>= 1.0.0": + version "1.0.1" + resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725" + integrity sha1-TK+tdrxi8C+gObL5Tpo906ORpyU= + graphql-custom-directives@~0.2.14: version "0.2.14" resolved "https://registry.yarnpkg.com/graphql-custom-directives/-/graphql-custom-directives-0.2.14.tgz#88611b8cb074477020ad85af47bfe168c4c23992" @@ -3230,14 +3494,7 @@ graphql-yoga@~1.17.4: graphql-tools "^4.0.0" subscriptions-transport-ws "^0.9.8" -"graphql@^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0", graphql@^14.0.2: - version "14.0.2" - resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.0.2.tgz#7dded337a4c3fd2d075692323384034b357f5650" - integrity sha512-gUC4YYsaiSJT1h40krG3J+USGlwhzNTXSb4IOZljn9ag5Tj+RkoXrWp+Kh7WyE3t1NCfab5kzCuxBIvOMERMXw== - dependencies: - iterall "^1.2.2" - -graphql@~14.1.1: +"graphql@^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0", graphql@^14.0.2, graphql@~14.1.1: version "14.1.1" resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.1.1.tgz#d5d77df4b19ef41538d7215d1e7a28834619fac0" integrity sha512-C5zDzLqvfPAgTtP8AUPIt9keDabrdRAqSWjj2OPRKrKxI9Fb65I36s1uCs1UUBFnSWTdO7hyHi7z1ZbwKMKF6Q== @@ -3326,6 +3583,14 @@ has@^1.0.1, has@^1.0.3: dependencies: function-bind "^1.1.1" +hash.js@^1.0.0: + version "1.1.7" + resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.7.tgz#0babca538e8d4ee4a0f8988d68866537a003cf42" + integrity sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA== + dependencies: + inherits "^2.0.3" + minimalistic-assert "^1.0.1" + hasha@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/hasha/-/hasha-3.0.0.tgz#52a32fab8569d41ca69a61ff1a214f8eb7c8bd39" @@ -3491,6 +3756,11 @@ imurmurhash@^0.1.4: resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= +indent-string@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-3.2.0.tgz#4a5fd6d27cc332f37e5419a504dbb837105c9289" + integrity sha1-Sl/W0nzDMvN+VBmlBNu4NxBckok= + inflight@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" @@ -3504,6 +3774,11 @@ inherits@2, inherits@2.0.3, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.1, i resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= +inherits@=2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1" + integrity sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE= + ini@^1.3.4, ini@~1.3.0: version "1.3.5" resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" @@ -3681,6 +3956,11 @@ is-generator-fn@^2.0.0: resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.0.0.tgz#038c31b774709641bda678b1f06a4e3227c10b3e" integrity sha512-elzyIdM7iKoFHzcrndIqjYomImhxrFRnGP3galODoII4TB9gI7mZ+FnlLQmmjf27SxHS2gKEeyhX5/+YRS6H9g== +is-generator@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/is-generator/-/is-generator-1.0.3.tgz#c14c21057ed36e328db80347966c693f886389f3" + integrity sha1-wUwhBX7TbjKNuANHlmxpP4hjifM= + is-glob@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a" @@ -4348,6 +4628,38 @@ jsonify@~0.0.0: resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73" integrity sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM= +jsonld-signatures@^1.1.5: + version "1.2.1" + resolved "https://registry.yarnpkg.com/jsonld-signatures/-/jsonld-signatures-1.2.1.tgz#493df5df9cd3a9f1b1cb296bbd3d081679f20ca8" + integrity sha1-ST3135zTqfGxyylrvT0IFnnyDKg= + dependencies: + async "^1.5.2" + bitcore-message "github:CoMakery/bitcore-message#dist" + commander "~2.9.0" + es6-promise "~4.0.5" + jsonld "0.4.3" + node-forge "~0.6.45" + +jsonld@0.4.3: + version "0.4.3" + resolved "https://registry.yarnpkg.com/jsonld/-/jsonld-0.4.3.tgz#0bbc929190064d6650a5af5876e1bfdf0ed288f3" + integrity sha1-C7ySkZAGTWZQpa9YduG/3w7SiPM= + dependencies: + es6-promise "~2.0.1" + pkginfo "~0.3.0" + request "^2.61.0" + xmldom "0.1.19" + +jsonld@^0.4.11: + version "0.4.12" + resolved "https://registry.yarnpkg.com/jsonld/-/jsonld-0.4.12.tgz#a02f205d5341414df1b6d8414f1b967a712073e8" + integrity sha1-oC8gXVNBQU3xtthBTxuWenEgc+g= + dependencies: + es6-promise "^2.0.0" + pkginfo "~0.4.0" + request "^2.61.0" + xmldom "0.1.19" + jsonwebtoken@^8.2.0, jsonwebtoken@~8.5.0: version "8.5.0" resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.0.tgz#ebd0ca2a69797816e1c5af65b6c759787252947e" @@ -4420,6 +4732,13 @@ kleur@^3.0.0: resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.1.tgz#4f5b313f5fa315432a400f19a24db78d451ede62" integrity sha512-P3kRv+B+Ra070ng2VKQqW4qW7gd/v3iD8sy/zOdcYRsfiD+QBokQNOps/AfP6Hr48cBhIIBFWckB9aO+IZhrWg== +knuth-shuffle-seeded@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/knuth-shuffle-seeded/-/knuth-shuffle-seeded-1.0.6.tgz#01f1b65733aa7540ee08d8b0174164d22081e4e1" + integrity sha1-AfG2VzOqdUDuCNiwF0Fk0iCB5OE= + dependencies: + seed-random "~2.2.0" + latest-version@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-3.1.0.tgz#a205383fea322b33b5ae3b18abee0dc2f356ee15" @@ -4571,6 +4890,11 @@ lodash.sortby@^4.7.0: resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= +lodash@=3.10.1: + version "3.10.1" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6" + integrity sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y= + lodash@^4.13.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.4, lodash@^4.17.5, lodash@~4.17.11: version "4.17.11" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" @@ -4588,6 +4912,11 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.3.1: dependencies: js-tokens "^3.0.0 || ^4.0.0" +lower-case@^1.1.1: + version "1.1.4" + resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-1.1.4.tgz#9a2cabd1b9e8e0ae993a4bf7d5875c39c42e8eac" + integrity sha1-miyr0bno4K6ZOkv31YdcOcQujqw= + lowercase-keys@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f" @@ -4735,6 +5064,11 @@ mimic-fn@^1.0.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022" integrity sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ== +minimalistic-assert@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" + integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== + minimatch@^3.0.3, minimatch@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" @@ -4787,6 +5121,11 @@ mkdirp@^0.5.0, mkdirp@^0.5.1: dependencies: minimist "0.0.8" +moment@^2.17.1: + version "2.24.0" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b" + integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg== + moment@^2.22.2: version "2.22.2" resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66" @@ -4807,6 +5146,20 @@ mute-stream@0.0.7: resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab" integrity sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s= +mz@^2.4.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32" + integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q== + dependencies: + any-promise "^1.0.0" + object-assign "^4.0.1" + thenify-all "^1.0.0" + +n3@^0.9.1: + version "0.9.1" + resolved "https://registry.yarnpkg.com/n3/-/n3-0.9.1.tgz#430b547d58dc7381408c45784dd8058171903932" + integrity sha1-QwtUfVjcc4FAjEV4TdgFgXGQOTI= + nan@^2.9.2: version "2.11.1" resolved "https://registry.yarnpkg.com/nan/-/nan-2.11.1.tgz#90e22bccb8ca57ea4cd37cc83d3819b52eea6766" @@ -4866,11 +5219,23 @@ neo4j-graphql-js@~2.3.1: lodash "^4.17.11" neo4j-driver "^1.7.2" +next-tick@1: + version "1.0.0" + resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c" + integrity sha1-yobR/ogoFpsBICCOPchCS524NCw= + nice-try@^1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== +no-case@^2.2.0: + version "2.3.2" + resolved "https://registry.yarnpkg.com/no-case/-/no-case-2.3.2.tgz#60b813396be39b3f1288a4c1ed5d1e7d28b464ac" + integrity sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ== + dependencies: + lower-case "^1.1.1" + node-fetch@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.1.2.tgz#ab884e8e7e57e38a944753cec706f788d1768bb5" @@ -4881,6 +5246,11 @@ node-fetch@^2.1.2, node-fetch@^2.2.0, node-fetch@~2.3.0: resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.3.0.tgz#1a1d940bbfb916a1d3e0219f037e89e71f8c5fa5" integrity sha512-MOd8pV3fxENbryESLgVIeaGKrdl+uaYhCSSVkjeOb/31/njTpcis5aWfdqgNlHIrKOLRbMnfPINPOML2CIFeXA== +node-forge@~0.6.45: + version "0.6.49" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.6.49.tgz#f1ee95d5d74623938fe19d698aa5a26d54d2f60f" + integrity sha1-8e6V1ddGI5OP4Z1piqWibVTS9g8= + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -5299,6 +5669,13 @@ package-json@^4.0.0: registry-url "^3.0.3" semver "^5.1.0" +pad-right@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/pad-right/-/pad-right-0.2.2.tgz#6fbc924045d244f2a2a244503060d3bfc6009774" + integrity sha1-b7ySQEXSRPKiokRQMGDTv8YAl3Q= + dependencies: + repeat-string "^1.5.2" + parent-module@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.0.tgz#df250bdc5391f4a085fb589dad761f5ad6b865b5" @@ -5469,6 +5846,16 @@ pkg-dir@^3.0.0: dependencies: find-up "^3.0.0" +pkginfo@~0.3.0: + version "0.3.1" + resolved "https://registry.yarnpkg.com/pkginfo/-/pkginfo-0.3.1.tgz#5b29f6a81f70717142e09e765bbeab97b4f81e21" + integrity sha1-Wyn2qB9wcXFC4J52W76rl7T4HiE= + +pkginfo@~0.4.0: + version "0.4.1" + resolved "https://registry.yarnpkg.com/pkginfo/-/pkginfo-0.4.1.tgz#b5418ef0439de5425fc4995042dced14fb2a84ff" + integrity sha1-tUGO8EOd5UJfxJlQQtztFPsqhP8= + pn@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb" @@ -5716,7 +6103,7 @@ readable-stream@1.1.x: isarray "0.0.1" string_decoder "~0.10.x" -readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.6, readable-stream@^2.3.5: +readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.6, readable-stream@^2.2.3, readable-stream@^2.3.5: version "2.3.6" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf" integrity sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw== @@ -5754,6 +6141,18 @@ realpath-native@^1.0.0, realpath-native@^1.0.2: dependencies: util.promisify "^1.0.0" +reasoner@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/reasoner/-/reasoner-2.0.0.tgz#6ccf76cb9baf96b82c45ab0bd60211c2aa1b701b" + integrity sha1-bM92y5uvlrgsRasL1gIRwqobcBs= + dependencies: + n3 "^0.9.1" + rfc5646 "^2.0.0" + vocabs-asx "^0.11.1" + vocabs-rdf "^0.11.1" + vocabs-rdfs "^0.11.1" + vocabs-xsd "^0.11.1" + regenerate-unicode-properties@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-7.0.0.tgz#107405afcc4a190ec5ed450ecaa00ed0cafa7a4c" @@ -5861,7 +6260,7 @@ repeat-element@^1.1.2: resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.3.tgz#782e0d825c0c5a3bb39731f84efee6b742e6b1ce" integrity sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g== -repeat-string@^1.6.1: +repeat-string@^1.5.2, repeat-string@^1.6.1: version "1.6.1" resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc= @@ -5882,7 +6281,7 @@ request-promise-native@^1.0.5: stealthy-require "^1.1.0" tough-cookie ">=2.3.3" -request@^2.87.0, request@^2.88.0: +request@^2.61.0, request@^2.87.0, request@^2.88.0: version "2.88.0" resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef" integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg== @@ -5945,7 +6344,7 @@ resolve@1.1.7: resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" integrity sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs= -resolve@^1.3.2, resolve@^1.5.0, resolve@^1.8.1, resolve@^1.9.0: +resolve@^1.3.2, resolve@^1.3.3, resolve@^1.5.0, resolve@^1.8.1, resolve@^1.9.0: version "1.10.0" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.10.0.tgz#3bdaaeaf45cc07f375656dfd2e54ed0810b101ba" integrity sha512-3sUr9aq5OfSg2S9pNtPA9hL1FVEAjvfOC4leW0SNf/mpnaakz2a9femSd6LqAww2RaFctwyf1lCqnTHuF1rxDg== @@ -5970,6 +6369,11 @@ retry@0.12.0: resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" integrity sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs= +rfc5646@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/rfc5646/-/rfc5646-2.0.0.tgz#ac0c67b6cd04411ef7c80751ba159d9371ce116c" + integrity sha1-rAxnts0EQR73yAdRuhWdk3HOEWw= + rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2, rimraf@^2.6.3, rimraf@~2.6.2: version "2.6.3" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab" @@ -6069,6 +6473,11 @@ scheduler@^0.11.2: loose-envify "^1.1.0" object-assign "^4.1.1" +seed-random@~2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/seed-random/-/seed-random-2.2.0.tgz#2a9b19e250a817099231a5b99a4daf80b7fbed54" + integrity sha1-KpsZ4lCoFwmSMaW5mk2vgLf77VQ= + semver-compare@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc" @@ -6105,6 +6514,11 @@ send@0.16.2: range-parser "~1.2.0" statuses "~1.4.0" +serialize-error@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/serialize-error/-/serialize-error-3.0.0.tgz#80100282b09be33c611536f50033481cb9cc87cf" + integrity sha512-+y3nkkG/go1Vdw+2f/+XUXM1DXX1XcxTl99FfiD/OEPUNw4uo0i6FKABfTAN5ZcgGtjTRZcEbxcE/jtXbEY19A== + serve-static@1.13.2: version "1.13.2" resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.13.2.tgz#095e8472fd5b46237db50ce486a43f4b86c6cec1" @@ -6265,6 +6679,11 @@ source-map-url@^0.4.0: resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3" integrity sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM= +source-map@0.5.6: + version "0.5.6" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412" + integrity sha1-dc449SvwczxafwwRjYEzSiu19BI= + source-map@^0.5.0, source-map@^0.5.6: version "0.5.7" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" @@ -6348,11 +6767,45 @@ sshpk@^1.7.0: safer-buffer "^2.0.2" tweetnacl "~0.14.0" +stack-chain@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/stack-chain/-/stack-chain-2.0.0.tgz#d73d1172af89565f07438b5bcc086831b6689b2d" + integrity sha512-GGrHXePi305aW7XQweYZZwiRwR7Js3MWoK/EHzzB9ROdc75nCnjSJVi21rdAGxFl+yCx2L2qdfl5y7NO4lTyqg== + +stack-generator@^2.0.1: + version "2.0.3" + resolved "https://registry.yarnpkg.com/stack-generator/-/stack-generator-2.0.3.tgz#bb74385c67ffc4ccf3c4dee5831832d4e509c8a0" + integrity sha512-kdzGoqrnqsMxOEuXsXyQTmvWXZmG0f3Ql2GDx5NtmZs59sT2Bt9Vdyq0XdtxUi58q/+nxtbF9KOQ9HkV1QznGg== + dependencies: + stackframe "^1.0.4" + stack-utils@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-1.0.2.tgz#33eba3897788558bebfc2db059dc158ec36cebb8" integrity sha512-MTX+MeG5U994cazkjd/9KNAapsHnibjMLnfXodlkXw76JEea0UiNzrqidzo1emMwk7w5Qhc9jd4Bn9TBb1MFwA== +stackframe@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/stackframe/-/stackframe-1.0.4.tgz#357b24a992f9427cba6b545d96a14ed2cbca187b" + integrity sha512-to7oADIniaYwS3MhtCa/sQhrxidCCQiF/qp4/m5iN3ipf0Y7Xlri0f6eG29r08aL7JYl8n32AF3Q5GYBZ7K8vw== + +stacktrace-gps@^3.0.1: + version "3.0.2" + resolved "https://registry.yarnpkg.com/stacktrace-gps/-/stacktrace-gps-3.0.2.tgz#33f8baa4467323ab2bd1816efa279942ba431ccc" + integrity sha512-9o+nWhiz5wFnrB3hBHs2PTyYrS60M1vvpSzHxwxnIbtY2q9Nt51hZvhrG1+2AxD374ecwyS+IUwfkHRE/2zuGg== + dependencies: + source-map "0.5.6" + stackframe "^1.0.4" + +stacktrace-js@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/stacktrace-js/-/stacktrace-js-2.0.0.tgz#776ca646a95bc6c6b2b90776536a7fc72c6ddb58" + integrity sha1-d2ymRqlbxsayuQd2U2p/xyxt21g= + dependencies: + error-stack-parser "^2.0.1" + stack-generator "^2.0.1" + stacktrace-gps "^3.0.1" + static-extend@^0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" @@ -6381,6 +6834,11 @@ streamsearch@0.1.2: resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a" integrity sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo= +string-argv@0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.1.1.tgz#66bd5ae3823708eaa1916fa5412703150d4ddfaf" + integrity sha512-El1Va5ehZ0XTj3Ekw4WFidXvTmt9SrC0+eigdojgtJMVtPkF0qbBe9fyNSl9eQf+kUHnTSQxdQYzuHfZy8V+DQ== + string-length@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/string-length/-/string-length-2.0.0.tgz#d40dbb686a3ace960c1cffca562bf2c45f8363ed" @@ -6584,6 +7042,20 @@ text-table@^0.2.0: resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ= +thenify-all@^1.0.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726" + integrity sha1-GhkY1ALY/D+Y+/I02wvMjMEOlyY= + dependencies: + thenify ">= 3.1.0 < 4" + +"thenify@>= 3.1.0 < 4": + version "3.3.0" + resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.0.tgz#e69e38a1babe969b0108207978b9f62b88604839" + integrity sha1-5p44obq+lpsBCCB5eLn2K4hgSDk= + dependencies: + any-promise "^1.0.0" + throat@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/throat/-/throat-4.1.0.tgz#89037cbc92c56ab18926e6ba4cbb200e15672a6a" @@ -6599,6 +7071,14 @@ timed-out@^4.0.0: resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-4.0.1.tgz#f32eacac5a175bea25d7fab565ab3ed8741ef56f" integrity sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8= +title-case@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/title-case/-/title-case-2.1.1.tgz#3e127216da58d2bc5becf137ab91dae3a7cd8faa" + integrity sha1-PhJyFtpY0rxb7PE3q5Ha46fNj6o= + dependencies: + no-case "^2.2.0" + upper-case "^1.0.3" + tmp@^0.0.33: version "0.0.33" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" @@ -6843,6 +7323,11 @@ update-notifier@^2.5.0: semver-diff "^2.0.0" xdg-basedir "^3.0.0" +upper-case@^1.0.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/upper-case/-/upper-case-1.1.3.tgz#f6b4501c2ec4cdd26ba78be7222961de77621598" + integrity sha1-9rRQHC7EzdJrp4vnIilh3ndiFZg= + uri-js@^4.2.1, uri-js@^4.2.2: version "4.2.2" resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0" @@ -6875,6 +7360,11 @@ use@^3.1.0: resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== +util-arity@^1.0.2: + version "1.1.0" + resolved "https://registry.yarnpkg.com/util-arity/-/util-arity-1.1.0.tgz#59d01af1fdb3fede0ac4e632b0ab5f6ce97c9330" + integrity sha1-WdAa8f2z/t4KxOYysKtfbOl8kzA= + util-deprecate@^1.0.1, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" @@ -6923,7 +7413,7 @@ vary@^1, vary@~1.1.2: resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= -verror@1.10.0: +verror@1.10.0, verror@^1.9.0: version "1.10.0" resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" integrity sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA= @@ -6932,6 +7422,75 @@ verror@1.10.0: core-util-is "1.0.2" extsprintf "^1.2.0" +vocabs-as@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/vocabs-as/-/vocabs-as-3.0.0.tgz#0dd0549cecb331ba4e917d2c5a4e83b146865c23" + integrity sha512-Dfze+B0CYZzhSK12jWvbxaL8/vXPnlzhhqhQTrEVxkGht+qzU4MmSLXSomQrdiSNKokVVtt16tyKoJWBW9TdNQ== + dependencies: + activitystreams-context ">=3.0.0" + vocabs ">=0.11.2" + +vocabs-asx@^0.11.1: + version "0.11.1" + resolved "https://registry.yarnpkg.com/vocabs-asx/-/vocabs-asx-0.11.1.tgz#6667e4e174dc4556722b6cb1b9619fb16491519a" + integrity sha1-Zmfk4XTcRVZyK2yxuWGfsWSRUZo= + dependencies: + vocabs ">=0.11.1" + +vocabs-interval@^0.11.1: + version "0.11.1" + resolved "https://registry.yarnpkg.com/vocabs-interval/-/vocabs-interval-0.11.1.tgz#1c009421f3e88a307aafbb75bfa670ff0f4f6d3c" + integrity sha1-HACUIfPoijB6r7t1v6Zw/w9PbTw= + dependencies: + vocabs ">=0.11.1" + +vocabs-ldp@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/vocabs-ldp/-/vocabs-ldp-0.1.0.tgz#da1728df560471750dfc7050e7e2df1bab901ce6" + integrity sha1-2hco31YEcXUN/HBQ5+LfG6uQHOY= + dependencies: + vocabs ">=0.11.1" + +vocabs-owl@^0.11.1: + version "0.11.1" + resolved "https://registry.yarnpkg.com/vocabs-owl/-/vocabs-owl-0.11.1.tgz#2355bbd27bfc19c5992d98079bbab3d7d65459e9" + integrity sha1-I1W70nv8GcWZLZgHm7qz19ZUWek= + dependencies: + vocabs ">=0.11.1" + +vocabs-rdf@^0.11.1: + version "0.11.1" + resolved "https://registry.yarnpkg.com/vocabs-rdf/-/vocabs-rdf-0.11.1.tgz#c7fa91d83b050ffb7b98ce2c72ab25c6fbcd1194" + integrity sha1-x/qR2DsFD/t7mM4scqslxvvNEZQ= + dependencies: + vocabs ">=0.11.1" + +vocabs-rdfs@^0.11.1: + version "0.11.1" + resolved "https://registry.yarnpkg.com/vocabs-rdfs/-/vocabs-rdfs-0.11.1.tgz#2e2df56ae0de008585b21057570386018da455bf" + integrity sha1-Li31auDeAIWFshBXVwOGAY2kVb8= + dependencies: + vocabs ">=0.11.1" + +vocabs-social@^0.11.1: + version "0.11.1" + resolved "https://registry.yarnpkg.com/vocabs-social/-/vocabs-social-0.11.1.tgz#d28545868cce325ba0c88e394f3de6e03fad85b1" + integrity sha1-0oVFhozOMlugyI45Tz3m4D+thbE= + dependencies: + vocabs ">=0.11.1" + +vocabs-xsd@^0.11.1: + version "0.11.1" + resolved "https://registry.yarnpkg.com/vocabs-xsd/-/vocabs-xsd-0.11.1.tgz#20e201d8fd0fd330d6650d9061fda60baae6cd6c" + integrity sha1-IOIB2P0P0zDWZQ2QYf2mC6rmzWw= + dependencies: + vocabs ">=0.11.1" + +vocabs@>=0.11.1, vocabs@>=0.11.2: + version "0.11.2" + resolved "https://registry.yarnpkg.com/vocabs/-/vocabs-0.11.2.tgz#8944b40f11d415f07db6e259804024a1dbfaa4d4" + integrity sha512-OIon2MWA21ZO42UBsTa5DuMsk5zv72DxMdQNvLsPN1M9GrjVTovn3LgWUZdPVnKBpdWhqWV7Mfbq/Sh0vkHIBw== + w3c-hr-time@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz#82ac2bff63d950ea9e3189a58a65625fedf19045" @@ -7125,6 +7684,11 @@ xmlbuilder@~9.0.1: resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d" integrity sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0= +xmldom@0.1.19: + version "0.1.19" + resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.19.tgz#631fc07776efd84118bf25171b37ed4d075a0abc" + integrity sha1-Yx/Ad3bv2EEYvyUXGzftTQdaCrw= + xtend@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" From 577157c5057f3f15fa41bbf4be052e1c0f44f809 Mon Sep 17 00:00:00 2001 From: Armin Date: Tue, 26 Feb 2019 17:11:41 +0100 Subject: [PATCH 02/25] Some fixes + refactoring + logic to receive sharedInboxEndpoints and to add new one --- package.json | 1 + src/activitypub/ActivityPub.js | 27 ++++- src/activitypub/NitroDatasource.js | 44 ++++++-- src/activitypub/routes/inbox.js | 16 +-- src/activitypub/routes/user.js | 4 +- src/activitypub/security/index.js | 160 ++++++++--------------------- src/activitypub/utils/index.js | 69 ++++++++++++- src/jwt/generateToken.js | 2 +- src/schema.graphql | 6 ++ yarn.lock | 4 +- 10 files changed, 192 insertions(+), 141 deletions(-) diff --git a/package.json b/package.json index 799c45085..1aa02fc96 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "sanitize-html": "~1.20.0", "slug": "~1.0.0", "trunc-html": "~1.1.2", + "uuid": "^3.3.2", "wait-on": "~3.2.0" }, "devDependencies": { diff --git a/src/activitypub/ActivityPub.js b/src/activitypub/ActivityPub.js index 57b1a5353..b4d9677c2 100644 --- a/src/activitypub/ActivityPub.js +++ b/src/activitypub/ActivityPub.js @@ -1,4 +1,10 @@ -import { sendAcceptActivity, sendRejectActivity, extractNameFromId, extractDomainFromUrl } from './utils' +import { + sendAcceptActivity, + sendRejectActivity, + extractNameFromId, + extractDomainFromUrl, + signAndSend +} from './utils' import request from 'request' import as from 'activitystrea.ms' import NitroDatasource from './NitroDatasource' @@ -69,6 +75,8 @@ export default class ActivityPub { }, async (err, response, toActorObject) => { if (err) return reject(err) debug(`name = ${toActorName}@${this.domain}`) + // save shared inbox + await this.dataSource.addSharedInboxEndpoint(toActorObject.endpoints.sharedInbox) let followersCollectionPage = await this.dataSource.getFollowersCollectionPage(activity.object) @@ -123,9 +131,9 @@ export default class ActivityPub { case 'Note': const articleObject = activity.object if (articleObject.inReplyTo) { - return this.dataSource.createComment(articleObject) + return this.dataSource.createComment(activity) } else { - return this.dataSource.createPost(articleObject) + return this.dataSource.createPost(activity) } default: } @@ -134,4 +142,17 @@ export default class ActivityPub { handleDeleteActivity (activity) { debug('inside delete') } + + async sendActivity (activity) { + if (Array.isArray(activity.to) && activity.to.includes('https://www.w3.org/ns/activitystreams#Public')) { + delete activity.send + const fromName = extractNameFromId(activity.actor) + const sharedInboxEndpoints = await this.dataSource.getSharedInboxEndpoints() + await Promise.all( + sharedInboxEndpoints.map((el) => { + return signAndSend(activity, fromName, new URL(el).host, el) + }) + ) + } + } } diff --git a/src/activitypub/NitroDatasource.js b/src/activitypub/NitroDatasource.js index 957026a2c..b723fa72c 100644 --- a/src/activitypub/NitroDatasource.js +++ b/src/activitypub/NitroDatasource.js @@ -278,15 +278,17 @@ export default class NitroDatasource { ) } - async createPost (postObject) { + async createPost (activity) { // TODO how to handle the to field? Now the post is just created, doesn't matter who is the recipient // createPost + const postObject = activity.object const title = postObject.summary ? postObject.summary : postObject.content.split(' ').slice(0, 5).join(' ') - const id = extractIdFromActivityId(postObject.id) + const postId = extractIdFromActivityId(postObject.id) + const activityId = extractIdFromActivityId(activity.id) let result = await this.client.mutate({ mutation: gql` mutation { - CreatePost(content: "${postObject.content}", title: "${title}", id: "${id}") { + CreatePost(content: "${postObject.content}", title: "${title}", id: "${postId}", activityId: "${activityId}") { id } } @@ -300,7 +302,7 @@ export default class NitroDatasource { result = await this.client.mutate({ mutation: gql` mutation { - AddPostAuthor(from: {id: "${userId}"}, to: {id: "${id}"}) + AddPostAuthor(from: {id: "${userId}"}, to: {id: "${postId}"}) } ` }) @@ -308,11 +310,41 @@ export default class NitroDatasource { throwErrorIfGraphQLErrorOccurred(result) } - async createComment (postObject) { + async getSharedInboxEndpoints () { + const result = await this.client.query({ + query: gql` + query { + SharedInboxEndpoint { + uri + } + } + ` + }) + throwErrorIfGraphQLErrorOccurred(result) + return result.data.SharedInboxEnpoint + } + async addSharedInboxEndpoint (uri) { + try { + const result = await this.client.mutate({ + mutation: gql` + mutation { + CreateSharedInboxEndpoint(uri: "${uri}") + } + ` + }) + throwErrorIfGraphQLErrorOccurred(result) + return true + } catch (e) { + return false + } + } + + async createComment (activity) { + const postObject = activity.object let result = await this.client.mutate({ mutation: gql` mutation { - CreateComment(content: "${postObject.content}") { + CreateComment(content: "${postObject.content}", activityId: "${extractIdFromActivityId(activity.id)}") { id } } diff --git a/src/activitypub/routes/inbox.js b/src/activitypub/routes/inbox.js index 0f33ebaec..e8af10c2c 100644 --- a/src/activitypub/routes/inbox.js +++ b/src/activitypub/routes/inbox.js @@ -1,5 +1,5 @@ import express from 'express' -import { verify } from '../security' +import { verifySignature } from '../security' const debug = require('debug')('ea:inbox') const router = express.Router() @@ -10,21 +10,25 @@ router.post('/', async function (req, res, next) { debug(`Content-Type = ${req.get('Content-Type')}`) debug(`body = ${JSON.stringify(req.body, null, 2)}`) debug(`Request headers = ${JSON.stringify(req.headers, null, 2)}`) - debug(`verify = ${await verify(`${req.protocol}://${req.hostname}:${req.port}${req.originalUrl}`, req.headers)}`) + debug(`verify = ${await verifySignature(`${req.protocol}://${req.hostname}:${req.port}${req.originalUrl}`, req.headers)}`) switch (req.body.type) { case 'Create': - await await req.app.get('activityPub').handleCreateActivity(req.body).catch(next) + if (req.body.send) { + await req.app.get('ap').sendActivity(req.body).catch(next) + break + } + await req.app.get('ap').handleCreateActivity(req.body).catch(next) break case 'Undo': - await await req.app.get('activityPub').handleUndoActivity(req.body).catch(next) + await req.app.get('ap').handleUndoActivity(req.body).catch(next) break case 'Follow': debug('handleFollow') - await req.app.get('activityPub').handleFollowActivity(req.body) + await req.app.get('ap').handleFollowActivity(req.body) debug('handledFollow') break case 'Delete': - await await req.app.get('activityPub').handleDeleteActivity(req.body).catch(next) + await req.app.get('ap').handleDeleteActivity(req.body).catch(next) break /* eslint-disable */ case 'Update': diff --git a/src/activitypub/routes/user.js b/src/activitypub/routes/user.js index 8240ba393..36bb7c2db 100644 --- a/src/activitypub/routes/user.js +++ b/src/activitypub/routes/user.js @@ -1,7 +1,7 @@ import { sendCollection } from '../utils' import express from 'express' import { serveUser } from '../utils/serveUser' -import { verify } from '../security' +import { verifySignature } from '../security' const router = express.Router() const debug = require('debug')('ea:user') @@ -47,7 +47,7 @@ router.get('/:name/outbox', (req, res) => { router.post('/:name/inbox', async function (req, res, next) { debug(`body = ${JSON.stringify(req.body, null, 2)}`) debug(`actorId = ${req.body.actor}`) - debug(`verify = ${await verify(`${req.protocol}://${req.hostname}:${req.port}${req.originalUrl}`, req.headers)}`) + debug(`verify = ${await verifySignature(`${req.protocol}://${req.hostname}:${req.port}${req.originalUrl}`, req.headers)}`) // const result = await saveActorId(req.body.actor) switch (req.body.type) { case 'Create': diff --git a/src/activitypub/security/index.js b/src/activitypub/security/index.js index 93b6cc08a..583535bcc 100644 --- a/src/activitypub/security/index.js +++ b/src/activitypub/security/index.js @@ -1,8 +1,6 @@ import dotenv from 'dotenv' import { resolve } from 'path' import crypto from 'crypto' -import { activityPub } from '../ActivityPub' -import gql from 'graphql-tag' import request from 'request' const debug = require('debug')('ea:security') @@ -24,71 +22,19 @@ export function generateRsaKeyPair () { }) } -export function signAndSend (activity, fromName, targetDomain, url) { - // fix for development: replace with http - url = url.indexOf('localhost') > -1 ? url.replace('https', 'http') : url - debug(`passhprase = ${process.env.PRIVATE_KEY_PASSPHRASE}`) - return new Promise(async (resolve, reject) => { - debug('inside signAndSend') - // get the private key - const result = await activityPub.dataSource.client.query({ - query: gql` - query { - User(slug: "${fromName}") { - privateKey - } - } - ` - }) - - if (result.error) { - reject(result.error) - } else { - const parsedActivity = JSON.parse(activity) - if (Array.isArray(parsedActivity['@context'])) { - parsedActivity['@context'].push('https://w3id.org/security/v1') - } else { - const context = [parsedActivity['@context']] - context.push('https://w3id.org/security/v1') - parsedActivity['@context'] = context - } - - // deduplicate context strings - parsedActivity['@context'] = [...new Set(parsedActivity['@context'])] - const privateKey = result.data.User[0].privateKey - const date = new Date().toUTCString() - - debug(`url = ${url}`) - request({ - url: url, - headers: { - 'Host': targetDomain, - 'Date': date, - 'Signature': createSignature(privateKey, `http://${activityPub.domain}/activitypub/users/${fromName}#main-key`, url, - { - 'Host': targetDomain, - 'Date': date, - 'Content-Type': 'application/activity+json' - }), - 'Content-Type': 'application/activity+json' - }, - method: 'POST', - body: JSON.stringify(parsedActivity) - }, (error, response) => { - if (error) { - debug(`Error = ${JSON.stringify(error, null, 2)}`) - reject(error) - } else { - debug('Response Headers:', JSON.stringify(response.headers, null, 2)) - debug('Response Body:', JSON.stringify(response.body, null, 2)) - resolve() - } - }) - } - }) +// signing +export function createSignature (privKey, keyId, url, headers = {}, algorithm = 'rsa-sha256') { + if (!SUPPORTED_HASH_ALGORITHMS.includes(algorithm)) { return throw Error(`SIGNING: Unsupported hashing algorithm = ${algorithm}`) } + const signer = crypto.createSign(algorithm) + const signingString = constructSigningString(url, headers) + signer.update(signingString) + const signatureB64 = signer.sign({ key: privKey, passphrase: process.env.PRIVATE_KEY_PASSPHRASE }, 'base64') + const headersString = Object.keys(headers).reduce((result, key) => { return result + ' ' + key.toLowerCase() }, '') + return `keyId="${keyId}",algorithm="${algorithm}",headers="(request-target)${headersString}",signature="${signatureB64}"` } -export function verify (url, headers) { +// verifying +export function verifySignature (url, headers) { return new Promise((resolve, reject) => { const signatureHeader = headers['signature'] ? headers['signature'] : headers['Signature'] if (!signatureHeader) { @@ -130,20 +76,36 @@ export function verify (url, headers) { }) } -// specify the public key owner object -/* const testPublicKeyOwner = { - "@context": jsig.SECURITY_CONTEXT_URL, - '@id': 'https://example.com/i/alice', - publicKey: [testPublicKey] -} */ +// private: signing +function constructSigningString (url, headers) { + const urlObj = new URL(url) + let signingString = `(request-target): post ${urlObj.pathname}${urlObj.search !== '' ? urlObj.search : ''}` + return Object.keys(headers).reduce((result, key) => { + return result + `\n${key.toLowerCase()}: ${headers[key]}` + }, signingString) +} -/* const publicKey = { - '@context': jsig.SECURITY_CONTEXT_URL, - type: 'RsaVerificationKey2018', - id: `https://${ActivityPub.domain}/users/${fromName}#main-key`, - controller: `https://${ActivityPub.domain}/users/${fromName}`, - publicKeyPem -} */ +// private: verifying +function httpVerify (pubKey, signature, signingString, algorithm) { + if (!SUPPORTED_HASH_ALGORITHMS.includes(algorithm)) { throw Error(`SIGNING: Unsupported hashing algorithm = ${algorithm}`) } + const verifier = crypto.createVerify(algorithm) + verifier.update(signingString) + return verifier.verify(pubKey, signature, 'base64') +} + +// private: verifying +// This function can be used to extract the signature,headers,algorithm etc. out of the Signature Header. +// Just pass what you want as key +function extractKeyValueFromSignatureHeader (signatureHeader, key) { + const keyString = signatureHeader.split(',').filter((el) => { + return !!el.startsWith(key) + })[0] + + let firstEqualIndex = keyString.search('=') + // When headers are requested add 17 to the index to remove "(request-target) " from the string + if (key === 'headers') { firstEqualIndex += 17 } + return keyString.substring(firstEqualIndex + 2, keyString.length - 1) +} // Obtained from invoking crypto.getHashes() export const SUPPORTED_HASH_ALGORITHMS = [ @@ -183,45 +145,3 @@ export const SUPPORTED_HASH_ALGORITHMS = [ 'ssl3-md5', 'ssl3-sha1', 'whirlpool'] - -// signing -function createSignature (privKey, keyId, url, headers = {}, algorithm = 'rsa-sha256') { - if (!SUPPORTED_HASH_ALGORITHMS.includes(algorithm)) { return throw Error(`SIGNING: Unsupported hashing algorithm = ${algorithm}`) } - const signer = crypto.createSign(algorithm) - const signingString = constructSigningString(url, headers) - signer.update(signingString) - const signatureB64 = signer.sign({ key: privKey, passphrase: process.env.PRIVATE_KEY_PASSPHRASE }, 'base64') - const headersString = Object.keys(headers).reduce((result, key) => { return result + ' ' + key.toLowerCase() }, '') - return `keyId="${keyId}",algorithm="${algorithm}",headers="(request-target)${headersString}",signature="${signatureB64}"` -} - -// signing -function constructSigningString (url, headers) { - const urlObj = new URL(url) - let signingString = `(request-target): post ${urlObj.pathname}${urlObj.search !== '' ? urlObj.search : ''}` - return Object.keys(headers).reduce((result, key) => { - return result + `\n${key.toLowerCase()}: ${headers[key]}` - }, signingString) -} - -// verifying -function httpVerify (pubKey, signature, signingString, algorithm) { - if (!SUPPORTED_HASH_ALGORITHMS.includes(algorithm)) { throw Error(`SIGNING: Unsupported hashing algorithm = ${algorithm}`) } - const verifier = crypto.createVerify(algorithm) - verifier.update(signingString) - return verifier.verify(pubKey, signature, 'base64') -} - -// verifying -// This function can be used to extract the signature,headers,algorithm etc. out of the Signature Header. -// Just pass what you want as key -function extractKeyValueFromSignatureHeader (signatureHeader, key) { - const keyString = signatureHeader.split(',').filter((el) => { - return !!el.startsWith(key) - })[0] - - let firstEqualIndex = keyString.search('=') - // When headers are requested add 17 to the index to remove "(request-target) " from the string - if (key === 'headers') { firstEqualIndex += 17 } - return keyString.substring(firstEqualIndex + 2, keyString.length - 1) -} diff --git a/src/activitypub/utils/index.js b/src/activitypub/utils/index.js index d1d4be845..9df537e79 100644 --- a/src/activitypub/utils/index.js +++ b/src/activitypub/utils/index.js @@ -1,7 +1,9 @@ import crypto from 'crypto' -import { signAndSend } from '../security' import as from 'activitystrea.ms' import { activityPub } from '../ActivityPub' +import gql from 'graphql-tag' +import { createSignature } from '../security' +import request from 'request' const debug = require('debug')('ea:utils') export function extractNameFromId (uri) { @@ -213,3 +215,68 @@ export function throwErrorIfGraphQLErrorOccurred (result) { throw new Error(`${result.error.message ? result.error.message : result.error.errors[0].message}`) } } + +export function signAndSend (activity, fromName, targetDomain, url) { + // fix for development: replace with http + url = url.indexOf('localhost') > -1 ? url.replace('https', 'http') : url + debug(`passhprase = ${process.env.PRIVATE_KEY_PASSPHRASE}`) + return new Promise(async (resolve, reject) => { + debug('inside signAndSend') + // get the private key + const result = await activityPub.dataSource.client.query({ + query: gql` + query { + User(slug: "${fromName}") { + privateKey + } + } + ` + }) + + if (result.error) { + reject(result.error) + } else { + // add security context + const parsedActivity = JSON.parse(activity) + if (Array.isArray(parsedActivity['@context'])) { + parsedActivity['@context'].push('https://w3id.org/security/v1') + } else { + const context = [parsedActivity['@context']] + context.push('https://w3id.org/security/v1') + parsedActivity['@context'] = context + } + + // deduplicate context strings + parsedActivity['@context'] = [...new Set(parsedActivity['@context'])] + const privateKey = result.data.User[0].privateKey + const date = new Date().toUTCString() + + debug(`url = ${url}`) + request({ + url: url, + headers: { + 'Host': targetDomain, + 'Date': date, + 'Signature': createSignature(privateKey, `http://${activityPub.domain}/activitypub/users/${fromName}#main-key`, url, + { + 'Host': targetDomain, + 'Date': date, + 'Content-Type': 'application/activity+json' + }), + 'Content-Type': 'application/activity+json' + }, + method: 'POST', + body: JSON.stringify(parsedActivity) + }, (error, response) => { + if (error) { + debug(`Error = ${JSON.stringify(error, null, 2)}`) + reject(error) + } else { + debug('Response Headers:', JSON.stringify(response.headers, null, 2)) + debug('Response Body:', JSON.stringify(response.body, null, 2)) + resolve() + } + }) + } + }) +} diff --git a/src/jwt/generateToken.js b/src/jwt/generateToken.js index 7cbc70330..fb61bb4ac 100644 --- a/src/jwt/generateToken.js +++ b/src/jwt/generateToken.js @@ -10,7 +10,7 @@ export default function generateJwt (user) { audience: process.env.CLIENT_URI, subject: user.id.toString() }) - // jwt.verify(token, process.env.JWT_SECRET, (err, data) => { + // jwt.verifySignature(token, process.env.JWT_SECRET, (err, data) => { // console.log('token verification:', err, data) // }) return token diff --git a/src/schema.graphql b/src/schema.graphql index 3626dd3c2..eacbab55f 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -137,6 +137,7 @@ type User { type Post { id: ID! + activityId: String author: User @relation(name: "WROTE", direction: "IN") title: String! slug: String @@ -167,6 +168,7 @@ type Post { type Comment { id: ID! + activityId: String author: User @relation(name: "WROTE", direction: "IN") content: String! contentExcerpt: String @@ -241,3 +243,7 @@ type Tag { deleted: Boolean disabled: Boolean } +type SharedInboxEndpoint { + id: ID! + uri: String +} diff --git a/yarn.lock b/yarn.lock index fc6697b2a..78cabe011 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1576,9 +1576,9 @@ bitcore-lib@^0.13.7: inherits "=2.0.1" lodash "=3.10.1" -"bitcore-message@github:comakery/bitcore-message#dist": +"bitcore-message@github:CoMakery/bitcore-message#dist": version "1.0.2" - resolved "https://codeload.github.com/comakery/bitcore-message/tar.gz/8799cc327029c3d34fc725f05b2cf981363f6ebf" + resolved "https://codeload.github.com/CoMakery/bitcore-message/tar.gz/8799cc327029c3d34fc725f05b2cf981363f6ebf" dependencies: bitcore-lib "^0.13.7" From 9e5b6865b8fd4d860648aac9c198604e7e7fce85 Mon Sep 17 00:00:00 2001 From: Armin Date: Tue, 26 Feb 2019 17:17:42 +0100 Subject: [PATCH 03/25] Create and send Post via ActivityPub inbox of the server --- src/graphql-schema.js | 48 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/src/graphql-schema.js b/src/graphql-schema.js index ce84dde36..e5da2ce54 100644 --- a/src/graphql-schema.js +++ b/src/graphql-schema.js @@ -6,6 +6,10 @@ import uuid from 'uuid/v4' import { fixUrl } from './middleware/fixImageUrlsMiddleware' import { AuthenticationError } from 'apollo-server' import { neo4jgraphql } from 'neo4j-graphql-js' +import as from 'activitystrea.ms' +import request from 'request' + +const debug = require('debug')('backend:schema') export const typeDefs = fs.readFileSync(process.env.GRAPHQL_SCHEMA || path.join(__dirname, 'schema.graphql')) @@ -163,10 +167,10 @@ export const resolvers = { return data }, CreatePost: async (object, params, ctx, resolveInfo) => { + params.activityId = uuid() const result = await neo4jgraphql(object, params, ctx, resolveInfo, false) - const session = ctx.driver.session() - await session.run( + const author = await session.run( 'MATCH (author:User {id: $userId}), (post:Post {id: $postId}) ' + 'MERGE (post)<-[:WROTE]-(author) ' + 'RETURN author', { @@ -174,7 +178,45 @@ export const resolvers = { postId: result.id }) session.close() - + const actorId = author.records[0]._fields[0].properties.actorId + const createActivity = await new Promise((resolve, reject) => { + as.create() + .id(`${actorId}/status/${params.activityId}`) + .actor(`${actorId}`) + .object( + as.article() + .id(`${actorId}/status/${result.id}`) + .content(result.content) + .to('https://www.w3.org/ns/activitystreams#Public') + .attributedTo(`${actorId}`) + ).prettyWrite((err, doc) => { + if (err) { + reject(err) + } else { + debug(doc) + const parsedDoc = JSON.parse(doc) + parsedDoc.send = true + resolve(JSON.stringify(parsedDoc)) + } + }) + }) + // try sending post via ActivityPub + await new Promise((resolve) => { + const url = new URL(actorId) + request(`${url.origin}/activitypub/inbox`, { + method: 'POST', + headers: { + 'Content-Type': 'application/activity+json' + }, + body: createActivity + }, (err) => { + if (err) { + debug(`error = ${JSON.stringify(err, null, 2)}`) + resolve(err) + } + resolve(null) + }) + }) return result } From e48ce8a94eaaea2c2c2c4f63c400f681ce1a4999 Mon Sep 17 00:00:00 2001 From: Armin Date: Wed, 27 Feb 2019 02:34:44 +0100 Subject: [PATCH 04/25] Test fixes + add remove from outbox collection step --- test/features/activity-delete.feature | 55 +++++++++++++++++++ test/features/activity-delete.feature.disable | 28 ---------- test/features/activity-follow.feature | 1 - ...feature.disable => object-article.feature} | 2 +- test/features/support/steps.js | 9 +-- test/features/world.js | 2 +- 6 files changed, 62 insertions(+), 35 deletions(-) create mode 100644 test/features/activity-delete.feature delete mode 100644 test/features/activity-delete.feature.disable rename test/features/{activity-article.feature.disable => object-article.feature} (96%) diff --git a/test/features/activity-delete.feature b/test/features/activity-delete.feature new file mode 100644 index 000000000..0e0d13253 --- /dev/null +++ b/test/features/activity-delete.feature @@ -0,0 +1,55 @@ +Feature: Delete an object + I want to delete objects + + Background: + Given our own server runs at "http://localhost:4100" + And we have the following users in our database: + | Slug | + | bernd-das-brot| + And I send a POST request with the following activity to "/activitypub/users/bernd-das-brot/inbox": + """ + { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://aronda.org/users/bernd-das-brot/status/lka7dfzkjn2398hsfd", + "type": "Create", + "actor": "https://aronda.org/users/bernd-das-brot", + "object": { + "id": "https://aronda.org/users/bernd-das-brot/status/kljsdfg9843jknsdf", + "type": "Article", + "published": "2019-02-07T19:37:55.002Z", + "attributedTo": "https://aronda.org/users/bernd-das-brot", + "content": "Hi Max, how are you?", + "to": "https://localhost:4100/activitypub/users/moritz" + } + } + """ + + Scenario: Deleting a post (Article Object) + When I send a POST request with the following activity to "/activitypub/users/bernd-das-brot/inbox": + """ + { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://localhost:4100/users/karl-heinz/status/a4DJ2afdg323v32641vna42lkj685kasd2", + "type": "Delete", + "object": { + "id": "https://aronda.org/users/bernd-das-brot/status/kljsdfg9843jknsdf", + "type": "Article", + "published": "2019-02-07T19:37:55.002Z", + "attributedTo": "https://aronda.org/users/bernd-das-brot", + "content": "Hi Max, how are you?", + "to": "https://localhost:4100/activitypub/users/moritz" + } + } + """ + Then I expect the status code to be 200 + And the object is removed from the outbox collection of "bernd-das-brot" + """ + { + "id": "https://aronda.org/users/bernd-das-brot/status/kljsdfg9843jknsdf", + "type": "Article", + "published": "2019-02-07T19:37:55.002Z", + "attributedTo": "https://aronda.org/users/bernd-das-brot", + "content": "Hi Max, how are you?", + "to": "https://localhost:4100/activitypub/users/moritz" + } + """ diff --git a/test/features/activity-delete.feature.disable b/test/features/activity-delete.feature.disable deleted file mode 100644 index 9d671500f..000000000 --- a/test/features/activity-delete.feature.disable +++ /dev/null @@ -1,28 +0,0 @@ -Feature: Delete an object - I want to delete objects - - Background: - Given our own server runs at "http://localhost:4100" - And we have the following users in our database: - | Slug | - | bernd-das-brot| - - Scenario: Deleting a post (Article Object) - When I send a POST request with the following activity to "/activitypub/users/bernd-das-brot/inbox": - """ - { - "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://localhost:4100/users/karl-heinz/status/a4DJ2afdg323v32641vna42lkj685kasd2", - "type": "Delete", - "object": { - "id": "https://aronda.org/users/marvin/status/kljsdfg9843jknsdf", - "type": "Article", - "published": "2019-02-07T19:37:55.002Z", - "attributedTo": "https://aronda.org/users/marvin", - "content": "Hi John, how are you?", - "to": "https://localhost:4100/activitypub/users/max" - } - } - """ - Then I expect the status code to be 200 - And the object is removed from the outbox collection of "karl-heinz" diff --git a/test/features/activity-follow.feature b/test/features/activity-follow.feature index 0797279d8..578e57cb1 100644 --- a/test/features/activity-follow.feature +++ b/test/features/activity-follow.feature @@ -36,7 +36,6 @@ Feature: Follow a user "type": "Undo", "actor": "http://localhost:4123/activitypub/users/peter-lustiger", "object": { - "@context": "https://www.w3.org/ns/activitystreams", "id": "https://localhost:4123/activitypub/users/bob-der-baumeister/status/83J23549sda1k72fsa4567na42312455kad83", "type": "Follow", "actor": "http://localhost:4123/activitypub/users/bob-der-baumeister", diff --git a/test/features/activity-article.feature.disable b/test/features/object-article.feature similarity index 96% rename from test/features/activity-article.feature.disable rename to test/features/object-article.feature index 858f85473..0a8af2606 100644 --- a/test/features/activity-article.feature.disable +++ b/test/features/object-article.feature @@ -21,7 +21,7 @@ Feature: Send and receive Articles "type": "Article", "published": "2019-02-07T19:37:55.002Z", "attributedTo": "https://aronda.org/users/marvin", - "content": "Hi John, how are you?", + "content": "Hi Max, how are you?", "to": "https://localhost:4100/activitypub/users/max" } } diff --git a/test/features/support/steps.js b/test/features/support/steps.js index 454db0e31..7f7bde455 100644 --- a/test/features/support/steps.js +++ b/test/features/support/steps.js @@ -108,16 +108,17 @@ Then('the post with id {string} to be created', async function (id) { const result = await client.request(` query { Post(id: "${id}") { - name + title } } `) - expect(result.data.Post).to.be.an('array').that.is.not.empty // eslint-disable-line }) -Then('the object is removed from the outbox collection of {string}', (name) => { - +Then('the object is removed from the outbox collection of {string}', async function (name, object) { + const response = await this.get(`/activitypub/users/${name}/outbox?page=true`) + const parsedResponse = JSON.parse(response.lastResponse) + expect(parsedResponse.orderedItems).to.not.include(object) }) Then('I send a GET request to {string} and expect a ordered collection', () => { diff --git a/test/features/world.js b/test/features/world.js index 26282f45a..9a27bd154 100644 --- a/test/features/world.js +++ b/test/features/world.js @@ -10,7 +10,7 @@ class CustomWorld { this.lastContentType = null this.lastInboxUrl = null this.lastActivity = null - // activity-article.feature.disable + // object-article.feature this.statusCode = null } get (pathname) { From adb674b98da4db8bda389773e00f1b31a34476bf Mon Sep 17 00:00:00 2001 From: Armin Date: Wed, 27 Feb 2019 02:42:34 +0100 Subject: [PATCH 05/25] Handle update, like, dislike and accept activities and also sending activities through the ActivityPub protocol + refactoring --- package.json | 1 + src/activitypub/ActivityPub.js | 87 ++++++-- src/activitypub/NitroDatasource.js | 174 +++++++++++++--- src/activitypub/routes/inbox.js | 15 +- .../{utils => routes}/serveUser.js | 2 +- src/activitypub/routes/user.js | 34 ++-- src/activitypub/routes/webfinger.js | 2 +- src/activitypub/utils/activity.js | 82 ++++++++ src/activitypub/utils/actor.js | 40 ++++ src/activitypub/utils/collection.js | 70 +++++++ src/activitypub/utils/index.js | 186 +----------------- src/graphql-schema.js | 5 +- yarn.lock | 7 + 13 files changed, 460 insertions(+), 245 deletions(-) rename src/activitypub/{utils => routes}/serveUser.js (97%) create mode 100644 src/activitypub/utils/activity.js create mode 100644 src/activitypub/utils/actor.js create mode 100644 src/activitypub/utils/collection.js diff --git a/package.json b/package.json index 1aa02fc96..85dfac6a2 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "activitystrea.ms": "^2.1.3", "apollo-cache-inmemory": "~1.4.3", "apollo-client": "~2.4.13", + "apollo-link-context": "^1.0.14", "apollo-link-http": "~1.5.11", "apollo-server": "~2.4.2", "bcryptjs": "~2.4.3", diff --git a/src/activitypub/ActivityPub.js b/src/activitypub/ActivityPub.js index b4d9677c2..74e047e12 100644 --- a/src/activitypub/ActivityPub.js +++ b/src/activitypub/ActivityPub.js @@ -1,10 +1,13 @@ import { - sendAcceptActivity, - sendRejectActivity, extractNameFromId, extractDomainFromUrl, signAndSend } from './utils' +import { + isPublicAddressed, + sendAcceptActivity, + sendRejectActivity +} from './utils/activity' import request from 'request' import as from 'activitystrea.ms' import NitroDatasource from './NitroDatasource' @@ -30,9 +33,9 @@ export default class ActivityPub { activityPub = new ActivityPub(process.env.ACTIVITYPUB_DOMAIN || 'localhost', process.env.ACTIVITYPUB_PORT || 4100) server.express.set('ap', activityPub) server.express.use(router) - debug('ActivityPub service added to graphql endpoint') + debug('ActivityPub middleware added to the express service') } else { - debug('ActivityPub service already added to graphql endpoint') + debug('ActivityPub middleware already added to the express service') } } @@ -101,7 +104,6 @@ export default class ActivityPub { debug(`followers = ${toActorObject.followers}`) debug(`following = ${toActorObject.following}`) - // TODO save after accept activity for the corresponding follow is received try { await dataSource.saveFollowersCollectionPage(followersCollectionPage) debug('follow activity saved') @@ -141,18 +143,77 @@ export default class ActivityPub { handleDeleteActivity (activity) { debug('inside delete') + switch (activity.object.type) { + case 'Article': + case 'Note': + return this.dataSource.deletePost(activity) + default: + } + } + + handleUpdateActivity (activity) { + debug('inside update') + switch (activity.object.type) { + case 'Note': + case 'Article': + return this.dataSource.updatePost(activity) + default: + } + } + + handleLikeActivity (activity) { + return this.dataSource.createShouted(activity) + } + + handleDislikeActivity (activity) { + return this.dataSource.deleteShouted(activity) + } + + async handleAcceptActivity (activity) { + debug('inside accept') + switch (activity.object.type) { + case 'Follow': + const followObject = activity.object + const followingCollectionPage = await this.getFollowingCollectionPage(followObject.actor) + followingCollectionPage.orderedItems.push(followObject.object) + await this.dataSource.saveFollowingCollectionPage(followingCollectionPage) + } } async sendActivity (activity) { - if (Array.isArray(activity.to) && activity.to.includes('https://www.w3.org/ns/activitystreams#Public')) { - delete activity.send - const fromName = extractNameFromId(activity.actor) + delete activity.send + const fromName = extractNameFromId(activity.actor) + + if (Array.isArray(activity.to) && isPublicAddressed(activity)) { const sharedInboxEndpoints = await this.dataSource.getSharedInboxEndpoints() - await Promise.all( - sharedInboxEndpoints.map((el) => { - return signAndSend(activity, fromName, new URL(el).host, el) - }) - ) + // serve shared inbox endpoints + sharedInboxEndpoints.map((el) => { + return this.trySend(activity, fromName, new URL(el).host, el) + }) + activity.to = activity.to.filter((el) => { + return !(isPublicAddressed({ to: el })) + }) + // serve the rest + activity.to.map((el) => { + return this.trySend(activity, fromName, new URL(el).host, el) + }) + } else if (typeof activity.to === 'string') { + return this.trySend(activity, fromName, new URL(activity.to).host, activity.to) + } else if (Array.isArray(activity.to)) { + activity.to.map((el) => { + return this.trySend(activity, fromName, new URL(el).host, el) + }) + } + } + async trySend (activity, fromName, host, url, tries = 5) { + try { + return await signAndSend(activity, fromName, host, url) + } catch (e) { + if (tries > 0) { + setTimeout(function () { + return this.trySend(activity, fromName, host, url, --tries) + }, 20000) + } } } } diff --git a/src/activitypub/NitroDatasource.js b/src/activitypub/NitroDatasource.js index b723fa72c..19e931eef 100644 --- a/src/activitypub/NitroDatasource.js +++ b/src/activitypub/NitroDatasource.js @@ -1,22 +1,32 @@ import { - throwErrorIfGraphQLErrorOccurred, + throwErrorIfApolloErrorOccurred, extractIdFromActivityId, - createOrderedCollection, - createOrderedCollectionPage, extractNameFromId, - createArticleActivity, constructIdFromName } from './utils' +import { + createOrderedCollection, + createOrderedCollectionPage +} from './utils/collection' +import { + createArticleActivity, + isPublicAddressed +} from './utils/activity' import crypto from 'crypto' import gql from 'graphql-tag' import { createHttpLink } from 'apollo-link-http' +import { setContext } from 'apollo-link-context' import { InMemoryCache } from 'apollo-cache-inmemory' import fetch from 'node-fetch' import { ApolloClient } from 'apollo-client' import dotenv from 'dotenv' +import uuid from 'uuid' +import generateJwtToken from '../jwt/generateToken' +import { resolve } from 'path' +import trunc from 'trunc-html' const debug = require('debug')('ea:nitro-datasource') -dotenv.config() +dotenv.config({ path: resolve('src', 'activitypub', '.env') }) export default class NitroDatasource { constructor (domain) { @@ -29,8 +39,19 @@ export default class NitroDatasource { } const link = createHttpLink({ uri: process.env.GRAPHQL_URI, fetch: fetch }) // eslint-disable-line const cache = new InMemoryCache() + const authLink = setContext((_, { headers }) => { + // generate the authentication token (maybe from env? Which user?) + const token = generateJwtToken({ name: 'ActivityPub', id: uuid() }) + // return the headers to the context so httpLink can read them + return { + headers: { + ...headers, + authorization: token ? `Bearer ${token}` : '' + } + } + }) this.client = new ApolloClient({ - link: link, + link: authLink.concat(link), cache: cache, defaultOptions }) @@ -59,7 +80,7 @@ export default class NitroDatasource { return followersCollection } else { - throwErrorIfGraphQLErrorOccurred(result) + throwErrorIfApolloErrorOccurred(result) } } @@ -96,7 +117,7 @@ export default class NitroDatasource { return followersCollection } else { - throwErrorIfGraphQLErrorOccurred(result) + throwErrorIfApolloErrorOccurred(result) } } @@ -122,7 +143,7 @@ export default class NitroDatasource { return followingCollection } else { - throwErrorIfGraphQLErrorOccurred(result) + throwErrorIfApolloErrorOccurred(result) } } @@ -158,7 +179,7 @@ export default class NitroDatasource { return followingCollection } else { - throwErrorIfGraphQLErrorOccurred(result) + throwErrorIfApolloErrorOccurred(result) } } @@ -190,7 +211,7 @@ export default class NitroDatasource { return outboxCollection } else { - throwErrorIfGraphQLErrorOccurred(result) + throwErrorIfApolloErrorOccurred(result) } } @@ -229,7 +250,7 @@ export default class NitroDatasource { debug('after createNote') return outboxCollection } else { - throwErrorIfGraphQLErrorOccurred(result) + throwErrorIfApolloErrorOccurred(result) } } @@ -246,7 +267,7 @@ export default class NitroDatasource { ` }) debug(`undoFollowActivity result = ${JSON.stringify(result, null, 2)}`) - throwErrorIfGraphQLErrorOccurred(result) + throwErrorIfApolloErrorOccurred(result) } async saveFollowersCollectionPage (followersCollection, onlyNewestItem = true) { @@ -257,7 +278,7 @@ export default class NitroDatasource { orderedItems = onlyNewestItem ? [orderedItems.pop()] : orderedItems return Promise.all( - await Promise.all(orderedItems.map(async (follower) => { + orderedItems.map(async (follower) => { debug(`follower = ${follower}`) const fromUserId = await this.ensureUser(follower) debug(`fromUserId = ${fromUserId}`) @@ -272,9 +293,36 @@ export default class NitroDatasource { ` }) debug(`addUserFollowedBy edge = ${JSON.stringify(result, null, 2)}`) - throwErrorIfGraphQLErrorOccurred(result) + throwErrorIfApolloErrorOccurred(result) debug('saveFollowers: added follow edge successfully') - })) + }) + ) + } + async saveFollowingCollectionPage (followingCollection, onlyNewestItem = true) { + debug('inside saveFollowers') + let orderedItems = followingCollection.orderedItems + const fromUserName = extractNameFromId(followingCollection.id) + const fromUserId = await this.ensureUser(constructIdFromName(fromUserName)) + orderedItems = onlyNewestItem ? [orderedItems.pop()] : orderedItems + return Promise.all( + orderedItems.map(async (following) => { + debug(`follower = ${following}`) + const toUserId = await this.ensureUser(following) + debug(`fromUserId = ${fromUserId}`) + debug(`toUserId = ${toUserId}`) + const result = await this.client.mutate({ + mutation: gql` + mutation { + AddUserFollowing(from: {id: "${fromUserId}"}, to: {id: "${toUserId}"}) { + from { name } + } + } + ` + }) + debug(`addUserFollowing edge = ${JSON.stringify(result, null, 2)}`) + throwErrorIfApolloErrorOccurred(result) + debug('saveFollowing: added follow edge successfully') + }) ) } @@ -282,6 +330,9 @@ export default class NitroDatasource { // TODO how to handle the to field? Now the post is just created, doesn't matter who is the recipient // createPost const postObject = activity.object + if (!isPublicAddressed(postObject)) { + return debug('createPost: not send to public (sending to specific persons is not implemented yet)') + } const title = postObject.summary ? postObject.summary : postObject.content.split(' ').slice(0, 5).join(' ') const postId = extractIdFromActivityId(postObject.id) const activityId = extractIdFromActivityId(activity.id) @@ -295,7 +346,7 @@ export default class NitroDatasource { ` }) - throwErrorIfGraphQLErrorOccurred(result) + throwErrorIfApolloErrorOccurred(result) // ensure user and add author to post const userId = await this.ensureUser(postObject.attributedTo) @@ -307,7 +358,78 @@ export default class NitroDatasource { ` }) - throwErrorIfGraphQLErrorOccurred(result) + throwErrorIfApolloErrorOccurred(result) + } + + async deletePost (activity) { + const result = await this.client.mutate({ + mutation: gql` + mutation { + DeletePost(id: "${extractIdFromActivityId(activity.object.id)}") { + title + } + } + ` + }) + throwErrorIfApolloErrorOccurred(result) + } + + async updatePost (activity) { + const postObject = activity.object + const postId = extractIdFromActivityId(postObject.id) + const date = postObject.updated ? postObject.updated : new Date().toISOString() + const result = await this.client.mutate({ + mutation: gql` + mutation { + UpdatePost(content: "${postObject.content}", contentExcerpt: "${trunc(postObject.content, 120).html}", id: "${postId}", updatedAt: "${date}") { + title + } + } + ` + }) + throwErrorIfApolloErrorOccurred(result) + } + + async createShouted (activity) { + const userId = await this.ensureUser(activity.actor) + const postId = extractIdFromActivityId(activity.object) + const result = await this.client.mutate({ + mutation: gql` + mutation { + AddUserShouted(from: {id: "${userId}"}, to: {id: "${postId}"}) { + from { + name + } + } + } + ` + }) + throwErrorIfApolloErrorOccurred(result) + if (!result.data.AddUserShouted) { + debug('something went wrong shouting post') + throw Error('User or Post not exists') + } + } + + async deleteShouted (activity) { + const userId = await this.ensureUser(activity.actor) + const postId = extractIdFromActivityId(activity.object) + const result = await this.client.mutate({ + mutation: gql` + mutation { + RemoveUserShouted(from: {id: "${userId}"}, to: {id: "${postId}"}) { + from { + name + } + } + } + ` + }) + throwErrorIfApolloErrorOccurred(result) + if (!result.data.AddUserShouted) { + debug('something went wrong disliking a post') + throw Error('User or Post not exists') + } } async getSharedInboxEndpoints () { @@ -320,7 +442,7 @@ export default class NitroDatasource { } ` }) - throwErrorIfGraphQLErrorOccurred(result) + throwErrorIfApolloErrorOccurred(result) return result.data.SharedInboxEnpoint } async addSharedInboxEndpoint (uri) { @@ -332,7 +454,7 @@ export default class NitroDatasource { } ` }) - throwErrorIfGraphQLErrorOccurred(result) + throwErrorIfApolloErrorOccurred(result) return true } catch (e) { return false @@ -351,7 +473,7 @@ export default class NitroDatasource { ` }) - throwErrorIfGraphQLErrorOccurred(result) + throwErrorIfApolloErrorOccurred(result) const postId = extractIdFromActivityId(postObject.inReplyTo) result = await this.client.mutate({ @@ -364,7 +486,7 @@ export default class NitroDatasource { ` }) - throwErrorIfGraphQLErrorOccurred(result) + throwErrorIfApolloErrorOccurred(result) } /** @@ -375,10 +497,11 @@ export default class NitroDatasource { */ async ensureUser (actorId) { debug(`inside ensureUser = ${actorId}`) + const name = extractNameFromId(actorId) const queryResult = await this.client.query({ query: gql` query { - User(slug: "${extractNameFromId(actorId)}") { + User(slug: "${name}") { id } } @@ -392,16 +515,17 @@ export default class NitroDatasource { } else { debug('ensureUser: user not exists.. createUser') // user does not exist.. create it + const pw = crypto.randomBytes(16).toString('hex') const result = await this.client.mutate({ mutation: gql` mutation { - CreateUser(password: "${crypto.randomBytes(16).toString('hex')}", slug:"${extractNameFromId(actorId)}", actorId: "${actorId}", name: "${extractNameFromId(actorId)}") { + CreateUser(password: "${pw}", slug:"${name}", actorId: "${actorId}", name: "${name}") { id } } ` }) - throwErrorIfGraphQLErrorOccurred(result) + throwErrorIfApolloErrorOccurred(result) return result.data.CreateUser.id } diff --git a/src/activitypub/routes/inbox.js b/src/activitypub/routes/inbox.js index e8af10c2c..eb720e042 100644 --- a/src/activitypub/routes/inbox.js +++ b/src/activitypub/routes/inbox.js @@ -1,5 +1,7 @@ import express from 'express' import { verifySignature } from '../security' +import { activityPub } from '../ActivityPub' + const debug = require('debug')('ea:inbox') const router = express.Router() @@ -10,31 +12,32 @@ router.post('/', async function (req, res, next) { debug(`Content-Type = ${req.get('Content-Type')}`) debug(`body = ${JSON.stringify(req.body, null, 2)}`) debug(`Request headers = ${JSON.stringify(req.headers, null, 2)}`) + // TODO stop if signature validation fails debug(`verify = ${await verifySignature(`${req.protocol}://${req.hostname}:${req.port}${req.originalUrl}`, req.headers)}`) switch (req.body.type) { case 'Create': if (req.body.send) { - await req.app.get('ap').sendActivity(req.body).catch(next) + await activityPub.sendActivity(req.body).catch(next) break } - await req.app.get('ap').handleCreateActivity(req.body).catch(next) + await activityPub.handleCreateActivity(req.body).catch(next) break case 'Undo': - await req.app.get('ap').handleUndoActivity(req.body).catch(next) + await activityPub.handleUndoActivity(req.body).catch(next) break case 'Follow': debug('handleFollow') - await req.app.get('ap').handleFollowActivity(req.body) + await activityPub.handleFollowActivity(req.body) debug('handledFollow') break case 'Delete': - await req.app.get('ap').handleDeleteActivity(req.body).catch(next) + await activityPub.handleDeleteActivity(req.body).catch(next) break /* eslint-disable */ case 'Update': case 'Accept': - + await activityPub.handleAcceptActivity(req.body).catch(next) case 'Reject': case 'Add': diff --git a/src/activitypub/utils/serveUser.js b/src/activitypub/routes/serveUser.js similarity index 97% rename from src/activitypub/utils/serveUser.js rename to src/activitypub/routes/serveUser.js index 9a13275eb..f65876741 100644 --- a/src/activitypub/utils/serveUser.js +++ b/src/activitypub/routes/serveUser.js @@ -1,4 +1,4 @@ -import { createActor } from '../utils' +import { createActor } from '../utils/actor' const gql = require('graphql-tag') const debug = require('debug')('ea:serveUser') diff --git a/src/activitypub/routes/user.js b/src/activitypub/routes/user.js index 36bb7c2db..2316d319c 100644 --- a/src/activitypub/routes/user.js +++ b/src/activitypub/routes/user.js @@ -1,7 +1,8 @@ -import { sendCollection } from '../utils' +import { sendCollection } from '../utils/collection' import express from 'express' -import { serveUser } from '../utils/serveUser' +import { serveUser } from './serveUser' import { verifySignature } from '../security' +import { activityPub } from '../ActivityPub' const router = express.Router() const debug = require('debug')('ea:user') @@ -47,36 +48,41 @@ router.get('/:name/outbox', (req, res) => { router.post('/:name/inbox', async function (req, res, next) { debug(`body = ${JSON.stringify(req.body, null, 2)}`) debug(`actorId = ${req.body.actor}`) + // TODO stop if signature validation fails debug(`verify = ${await verifySignature(`${req.protocol}://${req.hostname}:${req.port}${req.originalUrl}`, req.headers)}`) // const result = await saveActorId(req.body.actor) switch (req.body.type) { case 'Create': - await req.app.get('ap').handleCreateActivity(req.body).catch(next) + await activityPub.handleCreateActivity(req.body).catch(next) break case 'Undo': - await req.app.get('ap').handleUndoActivity(req.body).catch(next) + await activityPub.handleUndoActivity(req.body).catch(next) break case 'Follow': - debug('handleFollow') - await req.app.get('ap').handleFollowActivity(req.body).catch(next) - debug('handledFollow') + await activityPub.handleFollowActivity(req.body).catch(next) break case 'Delete': - req.app.get('ap').handleDeleteActivity(req.body).catch(next) + await activityPub.handleDeleteActivity(req.body).catch(next) break /* eslint-disable */ case 'Update': - + await activityPub.handleUpdateActivity(req.body).catch(next) + break case 'Accept': - + await activityPub.handleAcceptActivity(req.body).catch(next) case 'Reject': - + // Do nothing + break case 'Add': - + break case 'Remove': - + break case 'Like': - + await activityPub.handleLikeActivity(req.body).catch(next) + break + case 'Dislike': + await activityPub.handleDislikeActivity(req.body).catch(next) + break case 'Announce': debug('else!!') debug(JSON.stringify(req.body, null, 2)) diff --git a/src/activitypub/routes/webfinger.js b/src/activitypub/routes/webfinger.js index 9e51dcdfd..ad1c806ad 100644 --- a/src/activitypub/routes/webfinger.js +++ b/src/activitypub/routes/webfinger.js @@ -1,5 +1,5 @@ import express from 'express' -import { createWebFinger } from '../utils' +import { createWebFinger } from '../utils/actor' import gql from 'graphql-tag' const router = express.Router() diff --git a/src/activitypub/utils/activity.js b/src/activitypub/utils/activity.js new file mode 100644 index 000000000..afe13dfca --- /dev/null +++ b/src/activitypub/utils/activity.js @@ -0,0 +1,82 @@ +import crypto from 'crypto' +import { activityPub } from '../ActivityPub' +import as from 'activitystrea.ms' +import { signAndSend } from './index' +const debug = require('debug')('ea:utils:activity') + +export function createNoteActivity (text, name, id, published) { + const createUuid = crypto.randomBytes(16).toString('hex') + + return { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': `https://${activityPub.domain}/activitypub/users/${name}/status/${createUuid}`, + 'type': 'Create', + 'actor': `https://${activityPub.domain}/activitypub/users/${name}`, + 'object': { + 'id': `https://${activityPub.domain}/activitypub/users/${name}/status/${id}`, + 'type': 'Note', + 'published': published, + 'attributedTo': `https://${activityPub.domain}/activitypub/users/${name}`, + 'content': text, + 'to': 'https://www.w3.org/ns/activitystreams#Public' + } + } +} + +export function createArticleActivity (text, name, id, published) { + const createUuid = crypto.randomBytes(16).toString('hex') + + return { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': `https://${activityPub.domain}/activitypub/users/${name}/status/${createUuid}`, + 'type': 'Create', + 'actor': `https://${activityPub.domain}/activitypub/users/${name}`, + 'object': { + 'id': `https://${activityPub.domain}/activitypub/users/${name}/status/${id}`, + 'type': 'Article', + 'published': published, + 'attributedTo': `https://${activityPub.domain}/activitypub/users/${name}`, + 'content': text, + 'to': 'https://www.w3.org/ns/activitystreams#Public' + } + } +} + +export function sendAcceptActivity (theBody, name, targetDomain, url) { + as.accept() + .id(`https://${activityPub.domain}/activitypub/users/${name}/status/` + crypto.randomBytes(16).toString('hex')) + .actor(`https://${activityPub.domain}/activitypub/users/${name}`) + .object(theBody) + .prettyWrite((err, doc) => { + if (!err) { + return signAndSend(doc, name, targetDomain, url) + } else { + debug(`error serializing Accept object: ${err}`) + throw new Error('error serializing Accept object') + } + }) +} + +export function sendRejectActivity (theBody, name, targetDomain, url) { + as.reject() + .id(`https://${activityPub.domain}/activitypub/users/${name}/status/` + crypto.randomBytes(16).toString('hex')) + .actor(`https://${activityPub.domain}/activitypub/users/${name}`) + .object(theBody) + .prettyWrite((err, doc) => { + if (!err) { + return signAndSend(doc, name, targetDomain, url) + } else { + debug(`error serializing Accept object: ${err}`) + throw new Error('error serializing Accept object') + } + }) +} + +export function isPublicAddressed (postObject) { + if (typeof postObject.to === 'string') { + postObject.to = [postObject.to] + } + return postObject.to.includes('Public') || + postObject.to.includes('as:Public') || + postObject.to.includes('https://www.w3.org/ns/activitystreams#Public') +} diff --git a/src/activitypub/utils/actor.js b/src/activitypub/utils/actor.js new file mode 100644 index 000000000..0b8cc7454 --- /dev/null +++ b/src/activitypub/utils/actor.js @@ -0,0 +1,40 @@ +import { activityPub } from '../ActivityPub' + +export function createActor (name, pubkey) { + return { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1' + ], + 'id': `https://${activityPub.domain}/activitypub/users/${name}`, + 'type': 'Person', + 'preferredUsername': `${name}`, + 'name': `${name}`, + 'following': `https://${activityPub.domain}/activitypub/users/${name}/following`, + 'followers': `https://${activityPub.domain}/activitypub/users/${name}/followers`, + 'inbox': `https://${activityPub.domain}/activitypub/users/${name}/inbox`, + 'outbox': `https://${activityPub.domain}/activitypub/users/${name}/outbox`, + 'url': `https://${activityPub.domain}/activitypub/@${name}`, + 'endpoints': { + 'sharedInbox': `https://${activityPub.domain}/activitypub/inbox` + }, + 'publicKey': { + 'id': `https://${activityPub.domain}/activitypub/users/${name}#main-key`, + 'owner': `https://${activityPub.domain}/activitypub/users/${name}`, + 'publicKeyPem': pubkey + } + } +} + +export function createWebFinger (name) { + return { + 'subject': `acct:${name}@${activityPub.domain}`, + 'links': [ + { + 'rel': 'self', + 'type': 'application/activity+json', + 'href': `https://${activityPub.domain}/users/${name}` + } + ] + } +} diff --git a/src/activitypub/utils/collection.js b/src/activitypub/utils/collection.js new file mode 100644 index 000000000..379febe43 --- /dev/null +++ b/src/activitypub/utils/collection.js @@ -0,0 +1,70 @@ +import { activityPub } from '../ActivityPub' +import { constructIdFromName } from './index' +const debug = require('debug')('ea:utils:collections') + +export function createOrderedCollection (name, collectionName) { + return { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': `https://${activityPub.domain}/activitypub/users/${name}/${collectionName}`, + 'summary': `${name}s ${collectionName} collection`, + 'type': 'OrderedCollection', + 'first': `https://${activityPub.domain}/activitypub/users/${name}/${collectionName}?page=true`, + 'totalItems': 0 + } +} + +export function createOrderedCollectionPage (name, collectionName) { + return { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': `https://${activityPub.domain}/activitypub/users/${name}/${collectionName}?page=true`, + 'summary': `${name}s ${collectionName} collection`, + 'type': 'OrderedCollectionPage', + 'totalItems': 0, + 'partOf': `https://${activityPub.domain}/activitypub/users/${name}/${collectionName}`, + 'orderedItems': [] + } +} +export function sendCollection (collectionName, req, res) { + const name = req.params.name + const id = constructIdFromName(name) + + switch (collectionName) { + case 'followers': + attachThenCatch(activityPub.getFollowersCollection(id), res) + break + + case 'followersPage': + attachThenCatch(activityPub.getFollowersCollectionPage(id), res) + break + + case 'following': + attachThenCatch(activityPub.getFollowingCollection(id), res) + break + + case 'followingPage': + attachThenCatch(activityPub.getFollowingCollectionPage(id), res) + break + + case 'outbox': + attachThenCatch(activityPub.getOutboxCollection(id), res) + break + + case 'outboxPage': + attachThenCatch(activityPub.getOutboxCollectionPage(id), res) + break + + default: + res.status(500).end() + } +} + +function attachThenCatch (promise, res) { + return promise + .then((collection) => { + res.status(200).contentType('application/activity+json').send(collection) + }) + .catch((err) => { + debug(`error getting a Collection: = ${err}`) + res.status(500).end() + }) +} diff --git a/src/activitypub/utils/index.js b/src/activitypub/utils/index.js index 9df537e79..0cb4b7d0d 100644 --- a/src/activitypub/utils/index.js +++ b/src/activitypub/utils/index.js @@ -1,5 +1,3 @@ -import crypto from 'crypto' -import as from 'activitystrea.ms' import { activityPub } from '../ActivityPub' import gql from 'graphql-tag' import { createSignature } from '../security' @@ -23,194 +21,14 @@ export function extractIdFromActivityId (uri) { } export function constructIdFromName (name, fromDomain = activityPub.domain) { - return `https://${fromDomain}/activitypub/users/${name}` + return `http://${fromDomain}/activitypub/users/${name}` } export function extractDomainFromUrl (url) { return new URL(url).hostname } -export async function getActorIdByName (name) { - debug(`name = ${name}`) - return Promise.resolve() -} - -export function sendCollection (collectionName, req, res) { - const name = req.params.name - const id = constructIdFromName(name) - - switch (collectionName) { - case 'followers': - attachThenCatch(activityPub.getFollowersCollection(id), res) - break - - case 'followersPage': - attachThenCatch(activityPub.getFollowersCollectionPage(id), res) - break - - case 'following': - attachThenCatch(activityPub.getFollowingCollection(id), res) - break - - case 'followingPage': - attachThenCatch(activityPub.getFollowingCollectionPage(id), res) - break - - case 'outbox': - attachThenCatch(activityPub.getOutboxCollection(id), res) - break - - case 'outboxPage': - attachThenCatch(activityPub.getOutboxCollectionPage(id), res) - break - - default: - res.status(500).end() - } -} - -function attachThenCatch (promise, res) { - return promise - .then((collection) => { - res.status(200).contentType('application/activity+json').send(collection) - }) - .catch((err) => { - debug(`error getting a Collection: = ${err}`) - res.status(500).end() - }) -} - -export function createActor (name, pubkey) { - return { - '@context': [ - 'https://www.w3.org/ns/activitystreams', - 'https://w3id.org/security/v1' - ], - 'id': `https://${activityPub.domain}/activitypub/users/${name}`, - 'type': 'Person', - 'preferredUsername': `${name}`, - 'name': `${name}`, - 'following': `https://${activityPub.domain}/activitypub/users/${name}/following`, - 'followers': `https://${activityPub.domain}/activitypub/users/${name}/followers`, - 'inbox': `https://${activityPub.domain}/activitypub/users/${name}/inbox`, - 'outbox': `https://${activityPub.domain}/activitypub/users/${name}/outbox`, - 'url': `https://${activityPub.domain}/activitypub/@${name}`, - 'endpoints': { - 'sharedInbox': `https://${activityPub.domain}/activitypub/inbox` - }, - 'publicKey': { - 'id': `https://${activityPub.domain}/activitypub/users/${name}#main-key`, - 'owner': `https://${activityPub.domain}/activitypub/users/${name}`, - 'publicKeyPem': pubkey - } - } -} - -export function createWebFinger (name) { - return { - 'subject': `acct:${name}@${activityPub.domain}`, - 'links': [ - { - 'rel': 'self', - 'type': 'application/activity+json', - 'href': `https://${activityPub.domain}/users/${name}` - } - ] - } -} - -export function createOrderedCollection (name, collectionName) { - return { - '@context': 'https://www.w3.org/ns/activitystreams', - 'id': `https://${activityPub.domain}/activitypub/users/${name}/${collectionName}`, - 'summary': `${name}s ${collectionName} collection`, - 'type': 'OrderedCollection', - 'first': `https://${activityPub.domain}/activitypub/users/${name}/${collectionName}?page=true`, - 'totalItems': 0 - } -} - -export function createOrderedCollectionPage (name, collectionName) { - return { - '@context': 'https://www.w3.org/ns/activitystreams', - 'id': `https://${activityPub.domain}/activitypub/users/${name}/${collectionName}?page=true`, - 'summary': `${name}s ${collectionName} collection`, - 'type': 'OrderedCollectionPage', - 'totalItems': 0, - 'partOf': `https://${activityPub.domain}/activitypub/users/${name}/${collectionName}`, - 'orderedItems': [] - } -} - -export function createNoteActivity (text, name, id, published) { - const createUuid = crypto.randomBytes(16).toString('hex') - - return { - '@context': 'https://www.w3.org/ns/activitystreams', - 'id': `https://${activityPub.domain}/activitypub/users/${name}/status/${createUuid}`, - 'type': 'Create', - 'actor': `https://${activityPub.domain}/activitypub/users/${name}`, - 'object': { - 'id': `https://${activityPub.domain}/activitypub/users/${name}/status/${id}`, - 'type': 'Note', - 'published': published, - 'attributedTo': `https://${activityPub.domain}/activitypub/users/${name}`, - 'content': text, - 'to': 'https://www.w3.org/ns/activitystreams#Public' - } - } -} - -export function createArticleActivity (text, name, id, published) { - const createUuid = crypto.randomBytes(16).toString('hex') - - return { - '@context': 'https://www.w3.org/ns/activitystreams', - 'id': `https://${activityPub.domain}/activitypub/users/${name}/status/${createUuid}`, - 'type': 'Create', - 'actor': `https://${activityPub.domain}/activitypub/users/${name}`, - 'object': { - 'id': `https://${activityPub.domain}/activitypub/users/${name}/status/${id}`, - 'type': 'Article', - 'published': published, - 'attributedTo': `https://${activityPub.domain}/activitypub/users/${name}`, - 'content': text, - 'to': 'https://www.w3.org/ns/activitystreams#Public' - } - } -} - -export function sendAcceptActivity (theBody, name, targetDomain, url) { - as.accept() - .id(`https://${activityPub.domain}/activitypub/users/${name}/status/` + crypto.randomBytes(16).toString('hex')) - .actor(`https://${activityPub.domain}/activitypub/users/${name}`) - .object(theBody) - .prettyWrite((err, doc) => { - if (!err) { - return signAndSend(doc, name, targetDomain, url) - } else { - debug(`error serializing Accept object: ${err}`) - throw new Error('error serializing Accept object') - } - }) -} - -export function sendRejectActivity (theBody, name, targetDomain, url) { - as.reject() - .id(`https://${activityPub.domain}/activitypub/users/${name}/status/` + crypto.randomBytes(16).toString('hex')) - .actor(`https://${activityPub.domain}/activitypub/users/${name}`) - .object(theBody) - .prettyWrite((err, doc) => { - if (!err) { - return signAndSend(doc, name, targetDomain, url) - } else { - debug(`error serializing Accept object: ${err}`) - throw new Error('error serializing Accept object') - } - }) -} - -export function throwErrorIfGraphQLErrorOccurred (result) { +export function throwErrorIfApolloErrorOccurred (result) { if (result.error && (result.error.message || result.error.errors)) { throw new Error(`${result.error.message ? result.error.message : result.error.errors[0].message}`) } diff --git a/src/graphql-schema.js b/src/graphql-schema.js index e5da2ce54..65dfc8cda 100644 --- a/src/graphql-schema.js +++ b/src/graphql-schema.js @@ -169,6 +169,7 @@ export const resolvers = { CreatePost: async (object, params, ctx, resolveInfo) => { params.activityId = uuid() const result = await neo4jgraphql(object, params, ctx, resolveInfo, false) + debug(`user = ${JSON.stringify(ctx.user, null, 2)}`) const session = ctx.driver.session() const author = await session.run( 'MATCH (author:User {id: $userId}), (post:Post {id: $postId}) ' + @@ -177,7 +178,7 @@ export const resolvers = { userId: ctx.user.id, postId: result.id }) - session.close() + debug(`author = ${JSON.stringify(author, null, 2)}`) const actorId = author.records[0]._fields[0].properties.actorId const createActivity = await new Promise((resolve, reject) => { as.create() @@ -188,6 +189,7 @@ export const resolvers = { .id(`${actorId}/status/${result.id}`) .content(result.content) .to('https://www.w3.org/ns/activitystreams#Public') + .publishedNow() .attributedTo(`${actorId}`) ).prettyWrite((err, doc) => { if (err) { @@ -200,6 +202,7 @@ export const resolvers = { } }) }) + session.close() // try sending post via ActivityPub await new Promise((resolve) => { const url = new URL(actorId) diff --git a/yarn.lock b/yarn.lock index 78cabe011..05299ad6e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1072,6 +1072,13 @@ apollo-graphql@^0.1.0: dependencies: lodash.sortby "^4.7.0" +apollo-link-context@^1.0.14: + version "1.0.14" + resolved "https://registry.yarnpkg.com/apollo-link-context/-/apollo-link-context-1.0.14.tgz#6265eef49bedadddbbcff4026d04cd351094cd6c" + integrity sha512-l6SIN7Fwqhgg5C5eA8xSrt8gulHBmYTE3J4z5/Q2hP/8Kok0rQ/z5q3uy42/hkdYlnaktOvpz+ZIwEFzcXwujQ== + dependencies: + apollo-link "^1.2.8" + apollo-link-dedup@^1.0.0: version "1.0.11" resolved "https://registry.yarnpkg.com/apollo-link-dedup/-/apollo-link-dedup-1.0.11.tgz#6f34ea748d2834850329ad03111ef18445232b05" From 25eef848f6ae55af9b3e3faf354eb6b5fbb8bf98 Mon Sep 17 00:00:00 2001 From: Armin Date: Wed, 27 Feb 2019 15:04:54 +0100 Subject: [PATCH 06/25] Fetch actorObject and lookup inbox before sending object + addCommentAuthor --- src/activitypub/ActivityPub.js | 32 ++++++++++++++++++++++----- src/activitypub/NitroDatasource.js | 15 +++++++++++-- test/features/activity-follow.feature | 7 +++--- test/features/activity-like.feature | 24 ++++++++++++++++++++ 4 files changed, 67 insertions(+), 11 deletions(-) create mode 100644 test/features/activity-like.feature diff --git a/src/activitypub/ActivityPub.js b/src/activitypub/ActivityPub.js index 74e047e12..c2ea1b2c1 100644 --- a/src/activitypub/ActivityPub.js +++ b/src/activitypub/ActivityPub.js @@ -122,6 +122,8 @@ export default class ActivityPub { case 'Follow': const followActivity = activity.object return this.dataSource.undoFollowActivity(followActivity.actor, followActivity.object) + case 'Like': + return this.dataSource.deleteShouted(activity) default: } } @@ -180,10 +182,25 @@ export default class ActivityPub { } } + getActorObject (url) { + return new Promise((resolve, reject) => { + request({ + url: url, + headers: { + 'Accept': 'application/json' + } + }, (err, response, body) => { + if (err) { + reject(err) + } + resolve(JSON.parse(body)) + }) + }) + } + async sendActivity (activity) { delete activity.send const fromName = extractNameFromId(activity.actor) - if (Array.isArray(activity.to) && isPublicAddressed(activity)) { const sharedInboxEndpoints = await this.dataSource.getSharedInboxEndpoints() // serve shared inbox endpoints @@ -194,14 +211,17 @@ export default class ActivityPub { return !(isPublicAddressed({ to: el })) }) // serve the rest - activity.to.map((el) => { - return this.trySend(activity, fromName, new URL(el).host, el) + activity.to.map(async (el) => { + const actorObject = await this.getActorObject(el) + return this.trySend(activity, fromName, new URL(el).host, actorObject.inbox) }) } else if (typeof activity.to === 'string') { - return this.trySend(activity, fromName, new URL(activity.to).host, activity.to) + const actorObject = await this.getActorObject(activity.to) + return this.trySend(activity, fromName, new URL(activity.to).host, actorObject.inbox) } else if (Array.isArray(activity.to)) { - activity.to.map((el) => { - return this.trySend(activity, fromName, new URL(el).host, el) + activity.to.map(async (el) => { + const actorObject = await this.getActorObject(el) + return this.trySend(activity, fromName, new URL(el).host, actorObject.inbox) }) } } diff --git a/src/activitypub/NitroDatasource.js b/src/activitypub/NitroDatasource.js index 19e931eef..054b77854 100644 --- a/src/activitypub/NitroDatasource.js +++ b/src/activitypub/NitroDatasource.js @@ -472,10 +472,21 @@ export default class NitroDatasource { } ` }) - throwErrorIfApolloErrorOccurred(result) - const postId = extractIdFromActivityId(postObject.inReplyTo) + const toUserId = await this.ensureUser(activity.actor) + const result2 = await this.client.mutate({ + mutation: gql` + mutation { + AddCommentAuthor(from: {id: "${result.data.CreateComment.id}"}, to: {id: "${toUserId}"}) { + id + } + } + ` + }) + throwErrorIfApolloErrorOccurred(result2) + + const postId = extractIdFromActivityId(postObject.inReplyTo) result = await this.client.mutate({ mutation: gql` mutation { diff --git a/test/features/activity-follow.feature b/test/features/activity-follow.feature index 578e57cb1..a6974309c 100644 --- a/test/features/activity-follow.feature +++ b/test/features/activity-follow.feature @@ -6,9 +6,10 @@ Feature: Follow a user Background: Given our own server runs at "http://localhost:4123" And we have the following users in our database: - | Slug | - | karl-heinz | - | peter-lustiger | + | Slug | + | peter-lustiger | + | bob-der-baumeister | + | karl-heinz | Scenario: Send a follow to a user inbox and make sure it's added to the right followers collection When I send a POST request with the following activity to "/activitypub/users/peter-lustiger/inbox": diff --git a/test/features/activity-like.feature b/test/features/activity-like.feature new file mode 100644 index 000000000..c605bdb76 --- /dev/null +++ b/test/features/activity-like.feature @@ -0,0 +1,24 @@ +Feature: Like an object like an article or note + As a user I want to like others posts + Also if I do not want to follow a previous followed user anymore, + I want to undo the follow. + + Background: + Given our own server runs at "http://localhost:4123" + And we have the following users in our database: + | Slug | + | karl-heinz | + | peter-lustiger | + + Scenario: Send a like of a person to an users inbox and make sure it's added to the likes collection + When I send a POST request with the following activity to "/activitypub/users/karl-heinz/inbox": + """ + { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://localhost:4123/activitypub/users/peter-lustiger/status/83J23549sda1k72fsa4567na42312455kad83", + "type": "Like", + "actor": "http://localhost:4123/activitypub/users/peter-lustiger + "object": "http://localhost:4123/activitypub/users/karl-heinz" + } + """ + Then I expect the status code to be 200 From b76de03e2804afcb0b4da16b7e6d923adc639a9e Mon Sep 17 00:00:00 2001 From: Armin Date: Wed, 27 Feb 2019 15:17:12 +0100 Subject: [PATCH 07/25] ActivityPub now also usable standalone --- src/activitypub/ActivityPub.js | 37 +++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/src/activitypub/ActivityPub.js b/src/activitypub/ActivityPub.js index c2ea1b2c1..4f5774d9d 100644 --- a/src/activitypub/ActivityPub.js +++ b/src/activitypub/ActivityPub.js @@ -8,13 +8,18 @@ import { sendAcceptActivity, sendRejectActivity } from './utils/activity' +import cluster from 'cluster' +import os from 'os' import request from 'request' import as from 'activitystrea.ms' import NitroDatasource from './NitroDatasource' import router from './routes' import dotenv from 'dotenv' +import express from 'express' +import http from 'http' import { resolve } from 'path' const debug = require('debug')('ea') +const numCPUs = os.cpus().length let activityPub = null @@ -29,11 +34,33 @@ export default class ActivityPub { static init (server) { if (!activityPub) { dotenv.config({ path: resolve('src', 'activitypub', '.env') }) - // const app = express() - activityPub = new ActivityPub(process.env.ACTIVITYPUB_DOMAIN || 'localhost', process.env.ACTIVITYPUB_PORT || 4100) - server.express.set('ap', activityPub) - server.express.use(router) - debug('ActivityPub middleware added to the express service') + const port = process.env.ACTIVITYPUB_PORT + activityPub = new ActivityPub(process.env.ACTIVITYPUB_DOMAIN || 'localhost', port || 4100) + + if (server) { + // integrated into "server" express framework + server.express.set('ap', activityPub) + server.express.use(router) + debug('ActivityPub middleware added to the express service') + } else { + if (cluster.isMaster) { + debug(`master with pid = ${process.pid} is running`) + for (let i = 0; i < numCPUs; i++) { + cluster.fork() + } + cluster.on('exit', (worker, code, signal) => { + debug(`worker ${worker.process.pid} died with code ${code} and signal ${signal}`) + }) + } else { + // Standalone + const app = express() + app.set('ap', activityPub) + app.use(router) + http.createServer(app).listen(port, () => { + debug(`ActivityPub express service listening on port ${port}`) + }) + } + } } else { debug('ActivityPub middleware already added to the express service') } From 14a57d1ebfbb4348943891ce80761b89f498e442 Mon Sep 17 00:00:00 2001 From: Armin Date: Wed, 27 Feb 2019 15:44:01 +0100 Subject: [PATCH 08/25] Refactoring + fixes --- src/activitypub/ActivityPub.js | 34 ++++--------------- src/activitypub/Collections.js | 28 +++++++++++++++ ...{NitroDatasource.js => NitroDataSource.js} | 2 +- src/activitypub/utils/collection.js | 12 +++---- 4 files changed, 42 insertions(+), 34 deletions(-) create mode 100644 src/activitypub/Collections.js rename src/activitypub/{NitroDatasource.js => NitroDataSource.js} (99%) diff --git a/src/activitypub/ActivityPub.js b/src/activitypub/ActivityPub.js index 4f5774d9d..4bdbc9563 100644 --- a/src/activitypub/ActivityPub.js +++ b/src/activitypub/ActivityPub.js @@ -12,12 +12,13 @@ import cluster from 'cluster' import os from 'os' import request from 'request' import as from 'activitystrea.ms' -import NitroDatasource from './NitroDatasource' +import NitroDataSource from './NitroDataSource' import router from './routes' import dotenv from 'dotenv' import express from 'express' import http from 'http' import { resolve } from 'path' +import Collections from './Collections' const debug = require('debug')('ea') const numCPUs = os.cpus().length @@ -29,7 +30,8 @@ export default class ActivityPub { constructor (domain, port) { if (domain === 'localhost') { this.domain = `${domain}:${port}` } else { this.domain = domain } this.port = port - this.dataSource = new NitroDatasource(this.domain) + this.dataSource = new NitroDataSource(this.domain) + this.collections = new Collections(this.dataSource) } static init (server) { if (!activityPub) { @@ -43,6 +45,7 @@ export default class ActivityPub { server.express.use(router) debug('ActivityPub middleware added to the express service') } else { + // standalone clustered ActivityPub service if (cluster.isMaster) { debug(`master with pid = ${process.pid} is running`) for (let i = 0; i < numCPUs; i++) { @@ -52,7 +55,6 @@ export default class ActivityPub { debug(`worker ${worker.process.pid} died with code ${code} and signal ${signal}`) }) } else { - // Standalone const app = express() app.set('ap', activityPub) app.use(router) @@ -66,30 +68,6 @@ export default class ActivityPub { } } - getFollowersCollection (actorId) { - return this.dataSource.getFollowersCollection(actorId) - } - - getFollowersCollectionPage (actorId) { - return this.dataSource.getFollowersCollectionPage(actorId) - } - - getFollowingCollection (actorId) { - return this.dataSource.getFollowingCollection(actorId) - } - - getFollowingCollectionPage (actorId) { - return this.dataSource.getFollowingCollectionPage(actorId) - } - - getOutboxCollection (actorId) { - return this.dataSource.getOutboxCollection(actorId) - } - - getOutboxCollectionPage (actorId) { - return this.dataSource.getOutboxCollectionPage(actorId) - } - handleFollowActivity (activity) { debug(`inside FOLLOW ${activity.actor}`) let toActorName = extractNameFromId(activity.object) @@ -191,10 +169,12 @@ export default class ActivityPub { } handleLikeActivity (activity) { + // TODO differ if activity is an Article/Note/etc. return this.dataSource.createShouted(activity) } handleDislikeActivity (activity) { + // TODO differ if activity is an Article/Note/etc. return this.dataSource.deleteShouted(activity) } diff --git a/src/activitypub/Collections.js b/src/activitypub/Collections.js new file mode 100644 index 000000000..227e1717b --- /dev/null +++ b/src/activitypub/Collections.js @@ -0,0 +1,28 @@ +export default class Collections { + constructor (dataSource) { + this.dataSource = dataSource + } + getFollowersCollection (actorId) { + return this.dataSource.getFollowersCollection(actorId) + } + + getFollowersCollectionPage (actorId) { + return this.dataSource.getFollowersCollectionPage(actorId) + } + + getFollowingCollection (actorId) { + return this.dataSource.getFollowingCollection(actorId) + } + + getFollowingCollectionPage (actorId) { + return this.dataSource.getFollowingCollectionPage(actorId) + } + + getOutboxCollection (actorId) { + return this.dataSource.getOutboxCollection(actorId) + } + + getOutboxCollectionPage (actorId) { + return this.dataSource.getOutboxCollectionPage(actorId) + } +} diff --git a/src/activitypub/NitroDatasource.js b/src/activitypub/NitroDataSource.js similarity index 99% rename from src/activitypub/NitroDatasource.js rename to src/activitypub/NitroDataSource.js index 054b77854..11be287d7 100644 --- a/src/activitypub/NitroDatasource.js +++ b/src/activitypub/NitroDataSource.js @@ -28,7 +28,7 @@ const debug = require('debug')('ea:nitro-datasource') dotenv.config({ path: resolve('src', 'activitypub', '.env') }) -export default class NitroDatasource { +export default class NitroDataSource { constructor (domain) { this.domain = domain const defaultOptions = { diff --git a/src/activitypub/utils/collection.js b/src/activitypub/utils/collection.js index 379febe43..4c46adbde 100644 --- a/src/activitypub/utils/collection.js +++ b/src/activitypub/utils/collection.js @@ -30,27 +30,27 @@ export function sendCollection (collectionName, req, res) { switch (collectionName) { case 'followers': - attachThenCatch(activityPub.getFollowersCollection(id), res) + attachThenCatch(activityPub.collections.getFollowersCollection(id), res) break case 'followersPage': - attachThenCatch(activityPub.getFollowersCollectionPage(id), res) + attachThenCatch(activityPub.collections.getFollowersCollectionPage(id), res) break case 'following': - attachThenCatch(activityPub.getFollowingCollection(id), res) + attachThenCatch(activityPub.collections.getFollowingCollection(id), res) break case 'followingPage': - attachThenCatch(activityPub.getFollowingCollectionPage(id), res) + attachThenCatch(activityPub.collections.getFollowingCollectionPage(id), res) break case 'outbox': - attachThenCatch(activityPub.getOutboxCollection(id), res) + attachThenCatch(activityPub.collections.getOutboxCollection(id), res) break case 'outboxPage': - attachThenCatch(activityPub.getOutboxCollectionPage(id), res) + attachThenCatch(activityPub.collections.getOutboxCollectionPage(id), res) break default: From 7ea408777501b25f6159548f570f209fe138c74d Mon Sep 17 00:00:00 2001 From: Armin Date: Thu, 28 Feb 2019 01:20:27 +0100 Subject: [PATCH 09/25] Run cucumber tests README entry --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7f4676a6c..fc175dfb2 100644 --- a/README.md +++ b/README.md @@ -141,12 +141,18 @@ npm run db:reset **Beware**: We have no multiple database setup at the moment. We clean the database after each test, running the tests will wipe out all your data! -Run the tests: +Run the **_jest_** tests: ```bash yarn run test # -or- npm run test ``` +Run the **_cucumber_** features: +```bash +yarn run test:cucumber +# -or- +npm run test:cucumber +``` ## Todo`s From 067371581d7d95c0be30b16b99ad65015580d70c Mon Sep 17 00:00:00 2001 From: Armin Date: Thu, 28 Feb 2019 03:40:00 +0100 Subject: [PATCH 10/25] Create article before shouting in test and add "shouted" step + disable send Create Activity when creating a post --- src/graphql-schema.js | 69 ++++++++++++++------------- test/features/activity-delete.feature | 2 +- test/features/activity-like.feature | 26 ++++++++-- test/features/support/steps.js | 14 ++++++ 4 files changed, 73 insertions(+), 38 deletions(-) diff --git a/src/graphql-schema.js b/src/graphql-schema.js index 65dfc8cda..bdf72c0a0 100644 --- a/src/graphql-schema.js +++ b/src/graphql-schema.js @@ -6,8 +6,10 @@ import uuid from 'uuid/v4' import { fixUrl } from './middleware/fixImageUrlsMiddleware' import { AuthenticationError } from 'apollo-server' import { neo4jgraphql } from 'neo4j-graphql-js' +/* import as from 'activitystrea.ms' import request from 'request' +*/ const debug = require('debug')('backend:schema') @@ -179,19 +181,20 @@ export const resolvers = { postId: result.id }) debug(`author = ${JSON.stringify(author, null, 2)}`) - const actorId = author.records[0]._fields[0].properties.actorId - const createActivity = await new Promise((resolve, reject) => { - as.create() - .id(`${actorId}/status/${params.activityId}`) - .actor(`${actorId}`) - .object( - as.article() - .id(`${actorId}/status/${result.id}`) - .content(result.content) - .to('https://www.w3.org/ns/activitystreams#Public') - .publishedNow() - .attributedTo(`${actorId}`) - ).prettyWrite((err, doc) => { + /* if (Array.isArray(author.records) && author.records.length > 0) { + const actorId = author.records[0]._fields[0].properties.actorId + const createActivity = await new Promise((resolve, reject) => { + as.create() + .id(`${actorId}/status/${params.activityId}`) + .actor(`${actorId}`) + .object( + as.article() + .id(`${actorId}/status/${result.id}`) + .content(result.content) + .to('https://www.w3.org/ns/activitystreams#Public') + .publishedNow() + .attributedTo(`${actorId}`) + ).prettyWrite((err, doc) => { if (err) { reject(err) } else { @@ -201,27 +204,27 @@ export const resolvers = { resolve(JSON.stringify(parsedDoc)) } }) - }) - session.close() - // try sending post via ActivityPub - await new Promise((resolve) => { - const url = new URL(actorId) - request(`${url.origin}/activitypub/inbox`, { - method: 'POST', - headers: { - 'Content-Type': 'application/activity+json' - }, - body: createActivity - }, (err) => { - if (err) { - debug(`error = ${JSON.stringify(err, null, 2)}`) - resolve(err) - } - resolve(null) }) - }) - return result + session.close() + // try sending post via ActivityPub + await new Promise((resolve) => { + const url = new URL(actorId) + request(`${url.origin}/activitypub/inbox`, { + method: 'POST', + headers: { + 'Content-Type': 'application/activity+json' + }, + body: createActivity + }, (err) => { + if (err) { + debug(`error = ${JSON.stringify(err, null, 2)}`) + resolve(err) + } + resolve(null) + }) + }) + return result + } */ } - } } diff --git a/test/features/activity-delete.feature b/test/features/activity-delete.feature index 0e0d13253..ad33f1e4c 100644 --- a/test/features/activity-delete.feature +++ b/test/features/activity-delete.feature @@ -19,7 +19,7 @@ Feature: Delete an object "published": "2019-02-07T19:37:55.002Z", "attributedTo": "https://aronda.org/users/bernd-das-brot", "content": "Hi Max, how are you?", - "to": "https://localhost:4100/activitypub/users/moritz" + "to": "https://www.w3.org/ns/activitystreams#Public" } } """ diff --git a/test/features/activity-like.feature b/test/features/activity-like.feature index c605bdb76..d71a0396f 100644 --- a/test/features/activity-like.feature +++ b/test/features/activity-like.feature @@ -4,21 +4,39 @@ Feature: Like an object like an article or note I want to undo the follow. Background: - Given our own server runs at "http://localhost:4123" + Given our own server runs at "http://localhost:4100" And we have the following users in our database: | Slug | | karl-heinz | | peter-lustiger | + And I send a POST request with the following activity to "/activitypub/users/bernd-das-brot/inbox": + """ + { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://localhost:4100/activitypub/users/karl-heinz/status/faslkasa7dasfzkjn2398hsfd", + "type": "Create", + "actor": "https://localhost:4100/activitypub/users/karl-heinz", + "object": { + "id": "https://localhost:4100/activitypub/users/karl-heinz/status/dkasfljsdfaafg9843jknsdf", + "type": "Article", + "published": "2019-02-07T19:37:55.002Z", + "attributedTo": "https://localhost:4100/activitypub/users/karl-heinz", + "content": "Hi Max, how are you?", + "to": "https://www.w3.org/ns/activitystreams#Public" + } + } + """ Scenario: Send a like of a person to an users inbox and make sure it's added to the likes collection When I send a POST request with the following activity to "/activitypub/users/karl-heinz/inbox": """ { "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://localhost:4123/activitypub/users/peter-lustiger/status/83J23549sda1k72fsa4567na42312455kad83", + "id": "https://localhost:4100/activitypub/users/peter-lustiger/status/83J23549sda1k72fsa4567na42312455kad83", "type": "Like", - "actor": "http://localhost:4123/activitypub/users/peter-lustiger - "object": "http://localhost:4123/activitypub/users/karl-heinz" + "actor": "http://localhost:4100/activitypub/users/peter-lustiger", + "object": "http://localhost:4100/activitypub/users/karl-heinz/status/dkasfljsdfaafg9843jknsdf" } """ Then I expect the status code to be 200 + And the post with id "dkasfljsdfaafg9843jknsdf" has been liked by "peter-lustiger" diff --git a/test/features/support/steps.js b/test/features/support/steps.js index 7f7bde455..f2aaf6ffd 100644 --- a/test/features/support/steps.js +++ b/test/features/support/steps.js @@ -124,3 +124,17 @@ Then('the object is removed from the outbox collection of {string}', async funct Then('I send a GET request to {string} and expect a ordered collection', () => { }) + +Then('the post with id {string} has been liked by {string}', async function (id, slug) { + const result = await client.request(` + query { + Post(id: "${id}") { + shoutedBy { + slug + } + } + } + `) + expect(result.data.Post[0].shoutedBy).to.be.an('array').that.is.not.empty // eslint-disable-line + expect(result.data.Post[0].shoutedBy[0].slug).to.equal(slug) +}) From bb04ef0664d39edd481cbb55feb9a7a5ba5b0767 Mon Sep 17 00:00:00 2001 From: Armin Date: Thu, 28 Feb 2019 03:43:44 +0100 Subject: [PATCH 11/25] Refactoring --- src/activitypub/NitroDataSource.js | 2 +- src/activitypub/routes/inbox.js | 3 --- src/activitypub/routes/index.js | 8 +++++--- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/activitypub/NitroDataSource.js b/src/activitypub/NitroDataSource.js index 11be287d7..20339ec62 100644 --- a/src/activitypub/NitroDataSource.js +++ b/src/activitypub/NitroDataSource.js @@ -339,7 +339,7 @@ export default class NitroDataSource { let result = await this.client.mutate({ mutation: gql` mutation { - CreatePost(content: "${postObject.content}", title: "${title}", id: "${postId}", activityId: "${activityId}") { + CreatePost(content: "${postObject.content}", contentExcerpt: "${trunc(postObject.content, 120)}", title: "${title}", id: "${postId}", activityId: "${activityId}") { id } } diff --git a/src/activitypub/routes/inbox.js b/src/activitypub/routes/inbox.js index eb720e042..062f4b916 100644 --- a/src/activitypub/routes/inbox.js +++ b/src/activitypub/routes/inbox.js @@ -1,5 +1,4 @@ import express from 'express' -import { verifySignature } from '../security' import { activityPub } from '../ActivityPub' const debug = require('debug')('ea:inbox') @@ -12,8 +11,6 @@ router.post('/', async function (req, res, next) { debug(`Content-Type = ${req.get('Content-Type')}`) debug(`body = ${JSON.stringify(req.body, null, 2)}`) debug(`Request headers = ${JSON.stringify(req.headers, null, 2)}`) - // TODO stop if signature validation fails - debug(`verify = ${await verifySignature(`${req.protocol}://${req.hostname}:${req.port}${req.originalUrl}`, req.headers)}`) switch (req.body.type) { case 'Create': if (req.body.send) { diff --git a/src/activitypub/routes/index.js b/src/activitypub/routes/index.js index 7a8524a9e..24898e766 100644 --- a/src/activitypub/routes/index.js +++ b/src/activitypub/routes/index.js @@ -1,15 +1,16 @@ import user from './user' import inbox from './inbox' -import webfinger from './webfinger' +import webFinger from './webFinger' import express from 'express' import cors from 'cors' +import verify from './verify' const router = express.Router() -router.use('/.well-known/webfinger', +router.use('/.well-known/webFinger', cors(), express.urlencoded({ extended: true }), - webfinger + webFinger ) router.use('/activitypub/users', cors(), @@ -21,6 +22,7 @@ router.use('/activitypub/inbox', cors(), express.json({ type: ['application/activity+json', 'application/ld+json', 'application/json'] }), express.urlencoded({ extended: true }), + verify, inbox ) From ea2f6b7811d1b39c31f24c9d4674a99740cd36f8 Mon Sep 17 00:00:00 2001 From: Armin Date: Thu, 28 Feb 2019 03:44:57 +0100 Subject: [PATCH 12/25] Extract HTTP Signature verify middleware --- src/activitypub/routes/user.js | 6 ++---- src/activitypub/routes/verify.js | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) create mode 100644 src/activitypub/routes/verify.js diff --git a/src/activitypub/routes/user.js b/src/activitypub/routes/user.js index 2316d319c..017891e61 100644 --- a/src/activitypub/routes/user.js +++ b/src/activitypub/routes/user.js @@ -1,8 +1,8 @@ import { sendCollection } from '../utils/collection' import express from 'express' import { serveUser } from './serveUser' -import { verifySignature } from '../security' import { activityPub } from '../ActivityPub' +import verify from './verify' const router = express.Router() const debug = require('debug')('ea:user') @@ -45,11 +45,9 @@ router.get('/:name/outbox', (req, res) => { } }) -router.post('/:name/inbox', async function (req, res, next) { +router.post('/:name/inbox', verify, async function (req, res, next) { debug(`body = ${JSON.stringify(req.body, null, 2)}`) debug(`actorId = ${req.body.actor}`) - // TODO stop if signature validation fails - debug(`verify = ${await verifySignature(`${req.protocol}://${req.hostname}:${req.port}${req.originalUrl}`, req.headers)}`) // const result = await saveActorId(req.body.actor) switch (req.body.type) { case 'Create': diff --git a/src/activitypub/routes/verify.js b/src/activitypub/routes/verify.js new file mode 100644 index 000000000..34676b44f --- /dev/null +++ b/src/activitypub/routes/verify.js @@ -0,0 +1,15 @@ +import { verifySignature } from '../security' +const debug = require('debug')('ea:verify') + +export default async (req, res, next) => { + debug(`actorId = ${req.body.actor}`) + // TODO stop if signature validation fails + if (await verifySignature(`${req.protocol}://${req.hostname}:${req.port}${req.originalUrl}`, req.headers)) { + debug('verify = true') + next() + } else { + // throw Error('Signature validation failed!') + debug('verify = false') + next() + } +} From d16a1f62ff751a567924ee1a28371fc0f9b238ac Mon Sep 17 00:00:00 2001 From: Armin Date: Thu, 7 Mar 2019 15:22:34 +0100 Subject: [PATCH 13/25] Refactoring + adding helmet for some basic security --- package.json | 3 +- src/activitypub/ActivityPub.js | 33 +---- src/activitypub/NitroDataSource.js | 10 +- .../routes/{webfinger.js => webFinger.js} | 0 src/activitypub/utils/activity.js | 38 ++++-- src/graphql-schema.js | 55 ++++---- src/seed/factories/index.js | 28 ++-- src/server.js | 2 + test/features/world.js | 2 +- yarn.lock | 123 +++++++++++++++++- 10 files changed, 201 insertions(+), 93 deletions(-) rename src/activitypub/routes/{webfinger.js => webFinger.js} (100%) diff --git a/package.json b/package.json index 85dfac6a2..d876b77d1 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "start": "node dist/", "dev": "nodemon --exec babel-node src/", "dev:debug": "nodemon --exec babel-node --inspect=0.0.0.0:9229 src/index.js", - "lint": "eslint src --config .eslintrc.js", + "lint": "eslint src --config .eslintrc.js --fix", "test": "nyc --reporter=text-lcov yarn test:jest", "test:cypress": "run-p --race test:before:*", "test:before:server": "cross-env GRAPHQL_URI=http://localhost:4123 GRAPHQL_PORT=4123 babel-node src/ 2> /dev/null", @@ -57,6 +57,7 @@ "graphql-shield": "~5.3.0", "graphql-tag": "~2.10.1", "graphql-yoga": "~1.17.4", + "helmet": "^3.15.1", "jsonwebtoken": "~8.5.0", "linkifyjs": "~2.1.8", "lodash": "~4.17.11", diff --git a/src/activitypub/ActivityPub.js b/src/activitypub/ActivityPub.js index 4bdbc9563..9cbf5b52e 100644 --- a/src/activitypub/ActivityPub.js +++ b/src/activitypub/ActivityPub.js @@ -8,19 +8,14 @@ import { sendAcceptActivity, sendRejectActivity } from './utils/activity' -import cluster from 'cluster' -import os from 'os' import request from 'request' import as from 'activitystrea.ms' import NitroDataSource from './NitroDataSource' import router from './routes' import dotenv from 'dotenv' -import express from 'express' -import http from 'http' import { resolve } from 'path' import Collections from './Collections' const debug = require('debug')('ea') -const numCPUs = os.cpus().length let activityPub = null @@ -39,30 +34,10 @@ export default class ActivityPub { const port = process.env.ACTIVITYPUB_PORT activityPub = new ActivityPub(process.env.ACTIVITYPUB_DOMAIN || 'localhost', port || 4100) - if (server) { - // integrated into "server" express framework - server.express.set('ap', activityPub) - server.express.use(router) - debug('ActivityPub middleware added to the express service') - } else { - // standalone clustered ActivityPub service - if (cluster.isMaster) { - debug(`master with pid = ${process.pid} is running`) - for (let i = 0; i < numCPUs; i++) { - cluster.fork() - } - cluster.on('exit', (worker, code, signal) => { - debug(`worker ${worker.process.pid} died with code ${code} and signal ${signal}`) - }) - } else { - const app = express() - app.set('ap', activityPub) - app.use(router) - http.createServer(app).listen(port, () => { - debug(`ActivityPub express service listening on port ${port}`) - }) - } - } + // integrated into "server" express framework + server.express.set('ap', activityPub) + server.express.use(router) + debug('ActivityPub middleware added to the express service') } else { debug('ActivityPub middleware already added to the express service') } diff --git a/src/activitypub/NitroDataSource.js b/src/activitypub/NitroDataSource.js index 20339ec62..d43dc40a6 100644 --- a/src/activitypub/NitroDataSource.js +++ b/src/activitypub/NitroDataSource.js @@ -9,7 +9,7 @@ import { createOrderedCollectionPage } from './utils/collection' import { - createArticleActivity, + createArticleObject, isPublicAddressed } from './utils/activity' import crypto from 'crypto' @@ -221,6 +221,7 @@ export default class NitroDataSource { query: gql` query { User(slug:"${slug}") { + actorId contributions { id title @@ -242,8 +243,8 @@ export default class NitroDataSource { const outboxCollection = createOrderedCollectionPage(slug, 'outbox') outboxCollection.totalItems = posts.length await Promise.all( - posts.map((post) => { - outboxCollection.orderedItems.push(createArticleActivity(post.content, slug, post.id, post.createdAt)) + posts.map(async (post) => { + outboxCollection.orderedItems.push(await createArticleObject(post.activityId, post.objectId, post.content, extractNameFromId(post.id), post.id, post.createdAt)) }) ) @@ -335,11 +336,10 @@ export default class NitroDataSource { } const title = postObject.summary ? postObject.summary : postObject.content.split(' ').slice(0, 5).join(' ') const postId = extractIdFromActivityId(postObject.id) - const activityId = extractIdFromActivityId(activity.id) let result = await this.client.mutate({ mutation: gql` mutation { - CreatePost(content: "${postObject.content}", contentExcerpt: "${trunc(postObject.content, 120)}", title: "${title}", id: "${postId}", activityId: "${activityId}") { + CreatePost(content: "${postObject.content}", contentExcerpt: "${trunc(postObject.content, 120)}", title: "${title}", id: "${postId}", objectId: "${postObject.id}", activityId: "${activity.id}") { id } } diff --git a/src/activitypub/routes/webfinger.js b/src/activitypub/routes/webFinger.js similarity index 100% rename from src/activitypub/routes/webfinger.js rename to src/activitypub/routes/webFinger.js diff --git a/src/activitypub/utils/activity.js b/src/activitypub/utils/activity.js index afe13dfca..00a91813d 100644 --- a/src/activitypub/utils/activity.js +++ b/src/activitypub/utils/activity.js @@ -1,10 +1,12 @@ -import crypto from 'crypto' import { activityPub } from '../ActivityPub' +import { signAndSend, throwErrorIfApolloErrorOccurred } from './index' + +import crypto from 'crypto' import as from 'activitystrea.ms' -import { signAndSend } from './index' +import gql from 'graphql-tag' const debug = require('debug')('ea:utils:activity') -export function createNoteActivity (text, name, id, published) { +export function createNoteObject (text, name, id, published) { const createUuid = crypto.randomBytes(16).toString('hex') return { @@ -23,25 +25,43 @@ export function createNoteActivity (text, name, id, published) { } } -export function createArticleActivity (text, name, id, published) { - const createUuid = crypto.randomBytes(16).toString('hex') +export async function createArticleObject (activityId, objectId, text, name, id, published) { + const actorId = await getActorId(name) return { '@context': 'https://www.w3.org/ns/activitystreams', - 'id': `https://${activityPub.domain}/activitypub/users/${name}/status/${createUuid}`, + 'id': `${activityId}`, 'type': 'Create', - 'actor': `https://${activityPub.domain}/activitypub/users/${name}`, + 'actor': `${actorId}`, 'object': { - 'id': `https://${activityPub.domain}/activitypub/users/${name}/status/${id}`, + 'id': `${objectId}`, 'type': 'Article', 'published': published, - 'attributedTo': `https://${activityPub.domain}/activitypub/users/${name}`, + 'attributedTo': `${actorId}`, 'content': text, 'to': 'https://www.w3.org/ns/activitystreams#Public' } } } +export async function getActorId (name) { + const result = await activityPub.dataSource.client.query({ + query: gql` + query { + User(slug: "${name}") { + actorId + } + } + ` + }) + throwErrorIfApolloErrorOccurred(result) + if (Array.isArray(result.data.User) && result.data.User[0]) { + return result.data.User[0].actorId + } else { + throw Error(`No user with name: ${name}`) + } +} + export function sendAcceptActivity (theBody, name, targetDomain, url) { as.accept() .id(`https://${activityPub.domain}/activitypub/users/${name}/status/` + crypto.randomBytes(16).toString('hex')) diff --git a/src/graphql-schema.js b/src/graphql-schema.js index bdf72c0a0..5ddf08492 100644 --- a/src/graphql-schema.js +++ b/src/graphql-schema.js @@ -6,6 +6,8 @@ import uuid from 'uuid/v4' import { fixUrl } from './middleware/fixImageUrlsMiddleware' import { AuthenticationError } from 'apollo-server' import { neo4jgraphql } from 'neo4j-graphql-js' +import { activityPub } from './activitypub/ActivityPub' +import as from 'activitystrea.ms' /* import as from 'activitystrea.ms' import request from 'request' @@ -174,14 +176,15 @@ export const resolvers = { debug(`user = ${JSON.stringify(ctx.user, null, 2)}`) const session = ctx.driver.session() const author = await session.run( - 'MATCH (author:User {id: $userId}), (post:Post {id: $postId}) ' + + 'MATCH (author:User {slug: $slug}), (post:Post {id: $postId}) ' + 'MERGE (post)<-[:WROTE]-(author) ' + 'RETURN author', { - userId: ctx.user.id, + slug: ctx.user.slug, postId: result.id }) - debug(`author = ${JSON.stringify(author, null, 2)}`) - /* if (Array.isArray(author.records) && author.records.length > 0) { + // debug(`result = ${JSON.stringify(author, null, 2)}`) + debug(`actorId = ${author.records[0]._fields[0].properties.actorId}`) + if (Array.isArray(author.records) && author.records.length > 0) { const actorId = author.records[0]._fields[0].properties.actorId const createActivity = await new Promise((resolve, reject) => { as.create() @@ -195,36 +198,22 @@ export const resolvers = { .publishedNow() .attributedTo(`${actorId}`) ).prettyWrite((err, doc) => { - if (err) { - reject(err) - } else { - debug(doc) - const parsedDoc = JSON.parse(doc) - parsedDoc.send = true - resolve(JSON.stringify(parsedDoc)) - } - }) + if (err) { + reject(err) + } else { + debug(doc) + const parsedDoc = JSON.parse(doc) + parsedDoc.send = true + resolve(JSON.stringify(parsedDoc)) + } + }) }) - session.close() - // try sending post via ActivityPub - await new Promise((resolve) => { - const url = new URL(actorId) - request(`${url.origin}/activitypub/inbox`, { - method: 'POST', - headers: { - 'Content-Type': 'application/activity+json' - }, - body: createActivity - }, (err) => { - if (err) { - debug(`error = ${JSON.stringify(err, null, 2)}`) - resolve(err) - } - resolve(null) - }) - }) - return result - } */ + try { + await activityPub.sendActivity(createActivity) + } catch (e) { + debug(`error sending post activity = ${JSON.stringify(e, null, 2)}`) + } + } } } } diff --git a/src/seed/factories/index.js b/src/seed/factories/index.js index 752ae3369..d9bbd700c 100644 --- a/src/seed/factories/index.js +++ b/src/seed/factories/index.js @@ -1,14 +1,14 @@ import { GraphQLClient, request } from 'graphql-request' import { getDriver } from '../../bootstrap/neo4j' -import createBadge from './badges.js' -import createUser from './users.js' +import createBadge from './badges.js' +import createUser from './users.js' import createOrganization from './organizations.js' -import createPost from './posts.js' -import createComment from './comments.js' -import createCategory from './categories.js' -import createTag from './tags.js' -import createReport from './reports.js' +import createPost from './posts.js' +import createComment from './comments.js' +import createCategory from './categories.js' +import createTag from './tags.js' +import createReport from './reports.js' export const seedServerHost = 'http://127.0.0.1:4001' @@ -25,14 +25,14 @@ const authenticatedHeaders = async ({ email, password }, host) => { } } const factories = { - 'Badge': createBadge, - 'User': createUser, + 'Badge': createBadge, + 'User': createUser, 'Organization': createOrganization, - 'Post': createPost, - 'Comment': createComment, - 'Category': createCategory, - 'Tag': createTag, - 'Report': createReport + 'Post': createPost, + 'Comment': createComment, + 'Category': createCategory, + 'Tag': createTag, + 'Report': createReport } export const cleanDatabase = async (options = {}) => { diff --git a/src/server.js b/src/server.js index 5867e6952..10fdf291c 100644 --- a/src/server.js +++ b/src/server.js @@ -12,6 +12,7 @@ import { getDriver } from './bootstrap/neo4j' import passport from 'passport' import jwtStrategy from './jwt/strategy' import jwt from 'jsonwebtoken' +import helmet from 'helmet' dotenv.config() // check env and warn @@ -66,6 +67,7 @@ const createServer = (options) => { const server = new GraphQLServer(Object.assign({}, defaults, options)) passport.use('jwt', jwtStrategy(driver)) + server.express.use(helmet()) server.express.use(passport.initialize()) server.express.use(express.static('public')) diff --git a/test/features/world.js b/test/features/world.js index 9a27bd154..1b96e0761 100644 --- a/test/features/world.js +++ b/test/features/world.js @@ -5,7 +5,7 @@ const debug = require('debug')('ea:test:world') class CustomWorld { constructor () { - // webfinger.feature + // webFinger.feature this.lastResponses = [] this.lastContentType = null this.lastInboxUrl = null diff --git a/yarn.lock b/yarn.lock index 05299ad6e..52fb19cde 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1789,6 +1789,11 @@ camelcase@^5.0.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.0.0.tgz#03295527d58bd3cd4aa75363f35b2e8d97be2f42" integrity sha512-faqwZqnWxbxn+F1d399ygeamQNy3lPp/H9H6rNrqYh4FSVCtcY+3cub1MxA8o9mDd55mM8Aghuu/kuyYA6VTsA== +camelize@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/camelize/-/camelize-1.0.0.tgz#164a5483e630fa4321e5af07020e531831b2609b" + integrity sha1-FkpUg+Yw+kMh5a8HAg5TGDGyYJs= + caniuse-lite@^1.0.30000912: version "1.0.30000916" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000916.tgz#3428d3f529f0a7b2bfaaec65e796037bdd433aab" @@ -2053,6 +2058,11 @@ content-disposition@0.5.2: resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.2.tgz#0cf68bb9ddf5f2be7961c3a85178cb85dba78cb4" integrity sha1-DPaLud318r55YcOoUXjLhdunjLQ= +content-security-policy-builder@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/content-security-policy-builder/-/content-security-policy-builder-2.0.0.tgz#8749a1d542fcbe82237281ea9f716ce68b394dd2" + integrity sha512-j+Nhmj1yfZAikJLImCvPJFE29x/UuBi+/MWqggGGc515JKaZrjuei2RhULJmy0MsstW3E3htl002bwmBNMKr7w== + content-type@~1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" @@ -2261,6 +2271,11 @@ dashdash@^1.12.0: dependencies: assert-plus "^1.0.0" +dasherize@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/dasherize/-/dasherize-2.0.0.tgz#6d809c9cd0cf7bb8952d80fc84fa13d47ddb1308" + integrity sha1-bYCcnNDPe7iVLYD8hPoT1H3bEwg= + data-urls@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-1.1.0.tgz#15ee0582baa5e22bb59c77140da8f9c76963bbfe" @@ -2369,6 +2384,11 @@ delegates@^1.0.0: resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= +depd@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + depd@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" @@ -2412,6 +2432,11 @@ diff@^3.0.0: resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA== +dns-prefetch-control@0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/dns-prefetch-control/-/dns-prefetch-control-0.1.0.tgz#60ddb457774e178f1f9415f0cabb0e85b0b300b2" + integrity sha1-YN20V3dOF48flBXwyrsOhbCzALI= + doctrine@1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-1.5.0.tgz#379dce730f6166f76cefa4e6707a159b02c5a6fa" @@ -2475,6 +2500,11 @@ domutils@^1.5.1: dom-serializer "0" domelementtype "1" +dont-sniff-mimetype@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/dont-sniff-mimetype/-/dont-sniff-mimetype-1.0.0.tgz#5932890dc9f4e2f19e5eb02a20026e5e5efc8f58" + integrity sha1-WTKJDcn04vGeXrAqIAJuXl78j1g= + dot-prop@^4.1.0: version "4.2.0" resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.2.0.tgz#1f19e0c2e1aa0e32797c49799f2837ac6af69c57" @@ -2716,7 +2746,7 @@ eslint-plugin-import@~2.16.0: read-pkg-up "^2.0.0" resolve "^1.9.0" -eslint-plugin-jest@~22.3.0: +eslint-plugin-jest@^22.3.0: version "22.3.0" resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-22.3.0.tgz#a10f10dedfc92def774ec9bb5bfbd2fb8e1c96d2" integrity sha512-P1mYVRNlOEoO5T9yTqOfucjOYf1ktmJ26NjwjH8sxpCFQa6IhBGr5TpKl3hcAAT29hOsRJVuMWmTsHoUVo9FoA== @@ -2920,6 +2950,11 @@ expand-brackets@^2.1.4: snapdragon "^0.8.1" to-regex "^3.0.1" +expect-ct@0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/expect-ct/-/expect-ct-0.1.1.tgz#de84476a2dbcb85000d5903737e9bc8a5ba7b897" + integrity sha512-ngXzTfoRGG7fYens3/RMb6yYoVLvLMfmsSllP/mZPxNHgFq41TmPSLF/nLY7fwoclI2vElvAmILFWGUYqdjfCg== + expect@^24.1.0: version "24.1.0" resolved "https://registry.yarnpkg.com/expect/-/expect-24.1.0.tgz#88e73301c4c785cde5f16da130ab407bdaf8c0f2" @@ -3047,6 +3082,11 @@ fb-watchman@^2.0.0: dependencies: bser "^2.0.0" +feature-policy@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/feature-policy/-/feature-policy-0.2.0.tgz#22096de49ab240176878ffe2bde2f6ff04d48c43" + integrity sha512-2hGrlv6efG4hscYVZeaYjpzpT6I2OZgYqE2yDUzeAcKj2D1SH0AsEzqJNXzdoglEddcIXQQYop3lD97XpG75Jw== + figures@2.0.0, figures@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962" @@ -3184,6 +3224,11 @@ fragment-cache@^0.2.1: dependencies: map-cache "^0.2.2" +frameguard@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/frameguard/-/frameguard-3.0.0.tgz#7bcad469ee7b96e91d12ceb3959c78235a9272e9" + integrity sha1-e8rUae57lukdEs6zlZx4I1qScuk= + fresh@0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" @@ -3610,6 +3655,47 @@ he@0.5.0: resolved "https://registry.yarnpkg.com/he/-/he-0.5.0.tgz#2c05ffaef90b68e860f3fd2b54ef580989277ee2" integrity sha1-LAX/rvkLaOhg8/0rVO9YCYknfuI= +helmet-crossdomain@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/helmet-crossdomain/-/helmet-crossdomain-0.3.0.tgz#707e2df930f13ad61f76ed08e1bb51ab2b2e85fa" + integrity sha512-YiXhj0E35nC4Na5EPE4mTfoXMf9JTGpN4OtB4aLqShKuH9d2HNaJX5MQoglO6STVka0uMsHyG5lCut5Kzsy7Lg== + +helmet-csp@2.7.1: + version "2.7.1" + resolved "https://registry.yarnpkg.com/helmet-csp/-/helmet-csp-2.7.1.tgz#e8e0b5186ffd4db625cfcce523758adbfadb9dca" + integrity sha512-sCHwywg4daQ2mY0YYwXSZRsgcCeerUwxMwNixGA7aMLkVmPTYBl7gJoZDHOZyXkqPrtuDT3s2B1A+RLI7WxSdQ== + dependencies: + camelize "1.0.0" + content-security-policy-builder "2.0.0" + dasherize "2.0.0" + platform "1.3.5" + +helmet@^3.15.1: + version "3.15.1" + resolved "https://registry.yarnpkg.com/helmet/-/helmet-3.15.1.tgz#2c80d1a59138b6f23929605afca4b1c88b3298ec" + integrity sha512-hgoNe/sjKlKNvJ3g9Gz149H14BjMMWOCmW/DTXl7IfyKGtIK37GePwZrHNfr4aPXdKVyXcTj26RgRFbPKDy9lw== + dependencies: + depd "2.0.0" + dns-prefetch-control "0.1.0" + dont-sniff-mimetype "1.0.0" + expect-ct "0.1.1" + feature-policy "0.2.0" + frameguard "3.0.0" + helmet-crossdomain "0.3.0" + helmet-csp "2.7.1" + hide-powered-by "1.0.0" + hpkp "2.0.0" + hsts "2.1.0" + ienoopen "1.0.0" + nocache "2.0.0" + referrer-policy "1.1.0" + x-xss-protection "1.1.0" + +hide-powered-by@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/hide-powered-by/-/hide-powered-by-1.0.0.tgz#4a85ad65881f62857fc70af7174a1184dccce32b" + integrity sha1-SoWtZYgfYoV/xwr3F0oRhNzM4ys= + hoek@5.x.x: version "5.0.4" resolved "https://registry.yarnpkg.com/hoek/-/hoek-5.0.4.tgz#0f7fa270a1cafeb364a4b2ddfaa33f864e4157da" @@ -3637,6 +3723,16 @@ hosted-git-info@^2.1.4: resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.7.1.tgz#97f236977bd6e125408930ff6de3eec6281ec047" integrity sha512-7T/BxH19zbcCTa8XkMlbK5lTo1WtgkFi3GvdWEyNuc4Vex7/9Dqbnpsf4JMydcfj9HCg4zUWFTL3Za6lapg5/w== +hpkp@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/hpkp/-/hpkp-2.0.0.tgz#10e142264e76215a5d30c44ec43de64dee6d1672" + integrity sha1-EOFCJk52IVpdMMROxD3mTe5tFnI= + +hsts@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/hsts/-/hsts-2.1.0.tgz#cbd6c918a2385fee1dd5680bfb2b3a194c0121cc" + integrity sha512-zXhh/DqgrTXJ7erTN6Fh5k/xjMhDGXCqdYN3wvxUvGUQvnxcFfUd8E+6vLg/nk3ss1TYMb+DhRl25fYABioTvA== + html-encoding-sniffer@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-1.0.2.tgz#e70d84b94da53aa375e11fe3a351be6642ca46f8" @@ -3710,6 +3806,11 @@ ieee754@^1.1.4: resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.12.tgz#50bf24e5b9c8bb98af4964c941cdb0918da7b60b" integrity sha512-GguP+DRY+pJ3soyIiGPTvdiVXjZ+DbXOxGpXn3eMvNW4x4irjqXm4wHKscC+TfxSJ0yw/S1F24tqdMNsMZTiLA== +ienoopen@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/ienoopen/-/ienoopen-1.0.0.tgz#346a428f474aac8f50cf3784ea2d0f16f62bda6b" + integrity sha1-NGpCj0dKrI9QzzeE6i0PFvYr2ms= + ignore-by-default@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09" @@ -5243,6 +5344,11 @@ no-case@^2.2.0: dependencies: lower-case "^1.1.1" +nocache@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/nocache/-/nocache-2.0.0.tgz#202b48021a0c4cbde2df80de15a17443c8b43980" + integrity sha1-ICtIAhoMTL3i34DeFaF0Q8i0OYA= + node-fetch@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.1.2.tgz#ab884e8e7e57e38a944753cec706f788d1768bb5" @@ -5863,6 +5969,11 @@ pkginfo@~0.4.0: resolved "https://registry.yarnpkg.com/pkginfo/-/pkginfo-0.4.1.tgz#b5418ef0439de5425fc4995042dced14fb2a84ff" integrity sha1-tUGO8EOd5UJfxJlQQtztFPsqhP8= +platform@1.3.5: + version "1.3.5" + resolved "https://registry.yarnpkg.com/platform/-/platform-1.3.5.tgz#fb6958c696e07e2918d2eeda0f0bc9448d733444" + integrity sha512-TuvHS8AOIZNAlE77WUDiR4rySV/VMptyMfcfeoMgs4P8apaZM3JrnbzBiixKUv+XR6i+BXrQh8WAnjaSPFO65Q== + pn@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb" @@ -6160,6 +6271,11 @@ reasoner@2.0.0: vocabs-rdfs "^0.11.1" vocabs-xsd "^0.11.1" +referrer-policy@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/referrer-policy/-/referrer-policy-1.1.0.tgz#35774eb735bf50fb6c078e83334b472350207d79" + integrity sha1-NXdOtzW/UPtsB46DM0tHI1AgfXk= + regenerate-unicode-properties@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-7.0.0.tgz#107405afcc4a190ec5ed450ecaa00ed0cafa7a4c" @@ -7668,6 +7784,11 @@ ws@^6.0.0: dependencies: async-limiter "~1.0.0" +x-xss-protection@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/x-xss-protection/-/x-xss-protection-1.1.0.tgz#4f1898c332deb1e7f2be1280efb3e2c53d69c1a7" + integrity sha512-rx3GzJlgEeZ08MIcDsU2vY2B1QEriUKJTSiNHHUIem6eg9pzVOr2TL3Y4Pd6TMAM5D5azGjcxqI62piITBDHVg== + xdg-basedir@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4" From 5c91962808d640d219451ec0eba524ba227143d3 Mon Sep 17 00:00:00 2001 From: Grzegorz Leoniec Date: Fri, 8 Mar 2019 20:50:58 +0100 Subject: [PATCH 14/25] Merged master in and updated what had to be updated --- LICENSE.md | 21 ++ docker-compose.cypress.yml | 18 + humanconnection.png | Bin 0 -> 132723 bytes src/jwt/decode.js | 29 ++ src/jwt/encode.js | 17 + src/middleware/softDeleteMiddleware.spec.js | 130 +++++++ src/resolvers/badges.spec.js | 223 ++++++++++++ src/resolvers/follow.spec.js | 115 ++++++ src/resolvers/moderation.js | 30 ++ src/resolvers/moderation.spec.js | 370 ++++++++++++++++++++ src/resolvers/posts.js | 63 ++++ src/resolvers/posts.spec.js | 202 +++++++++++ src/resolvers/reports.js | 51 +++ src/resolvers/reports.spec.js | 68 ++++ src/resolvers/shout.spec.js | 126 +++++++ src/resolvers/statistics.js | 67 ++++ src/resolvers/user_management.js | 51 +++ src/resolvers/user_management.spec.js | 179 ++++++++++ 18 files changed, 1760 insertions(+) create mode 100644 LICENSE.md create mode 100644 docker-compose.cypress.yml create mode 100644 humanconnection.png create mode 100644 src/jwt/decode.js create mode 100644 src/jwt/encode.js create mode 100644 src/middleware/softDeleteMiddleware.spec.js create mode 100644 src/resolvers/badges.spec.js create mode 100644 src/resolvers/follow.spec.js create mode 100644 src/resolvers/moderation.js create mode 100644 src/resolvers/moderation.spec.js create mode 100644 src/resolvers/posts.js create mode 100644 src/resolvers/posts.spec.js create mode 100644 src/resolvers/reports.js create mode 100644 src/resolvers/reports.spec.js create mode 100644 src/resolvers/shout.spec.js create mode 100644 src/resolvers/statistics.js create mode 100644 src/resolvers/user_management.js create mode 100644 src/resolvers/user_management.spec.js diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 000000000..9d4508b38 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Human-Connection gGmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docker-compose.cypress.yml b/docker-compose.cypress.yml new file mode 100644 index 000000000..3d577e638 --- /dev/null +++ b/docker-compose.cypress.yml @@ -0,0 +1,18 @@ +version: "3.7" + +services: + neo4j: + environment: + - NEO4J_AUTH=none + ports: + - 7687:7687 + - 7474:7474 + backend: + ports: + - 4001:4001 + - 4123:4123 + image: humanconnection/nitro-backend:builder + build: + context: . + target: builder + command: yarn run test:cypress diff --git a/humanconnection.png b/humanconnection.png new file mode 100644 index 0000000000000000000000000000000000000000..f0576413ff0663ce0c79b6a0ef8ccd2ffbc77594 GIT binary patch literal 132723 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∈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�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 iz4^aRaB;J9wD { + if (!authorizationHeader) return null + const token = authorizationHeader.replace('Bearer ', '') + let id = null + try { + const decoded = await jwt.verify(token, process.env.JWT_SECRET) + id = decoded.sub + } catch { + return null + } + const session = driver.session() + const query = ` + MATCH (user:User {id: {id} }) + RETURN user {.id, .slug, .name, .avatar, .email, .role} as user + LIMIT 1 + ` + const result = await session.run(query, { id }) + session.close() + const [currentUser] = await result.records.map((record) => { + return record.get('user') + }) + if (!currentUser) return null + return { + token, + ...currentUser + } +} diff --git a/src/jwt/encode.js b/src/jwt/encode.js new file mode 100644 index 000000000..f32fc12da --- /dev/null +++ b/src/jwt/encode.js @@ -0,0 +1,17 @@ + +import jwt from 'jsonwebtoken' +import ms from 'ms' + +// Generate an Access Token for the given User ID +export default function encode (user) { + const token = jwt.sign(user, process.env.JWT_SECRET, { + expiresIn: ms('1d'), + issuer: process.env.GRAPHQL_URI, + audience: process.env.CLIENT_URI, + subject: user.id.toString() + }) + // jwt.verifySignature(token, process.env.JWT_SECRET, (err, data) => { + // console.log('token verification:', err, data) + // }) + return token +} diff --git a/src/middleware/softDeleteMiddleware.spec.js b/src/middleware/softDeleteMiddleware.spec.js new file mode 100644 index 000000000..e9bc461f1 --- /dev/null +++ b/src/middleware/softDeleteMiddleware.spec.js @@ -0,0 +1,130 @@ +import Factory from '../seed/factories' +import { host, login } from '../jest/helpers' +import { GraphQLClient } from 'graphql-request' + +const factory = Factory() +let client +let query +let action + +beforeEach(async () => { + await Promise.all([ + factory.create('User', { role: 'user', email: 'user@example.org', password: '1234' }), + factory.create('User', { id: 'm1', role: 'moderator', email: 'moderator@example.org', password: '1234' }) + ]) + await factory.authenticateAs({ email: 'user@example.org', password: '1234' }) + await Promise.all([ + factory.create('Post', { title: 'Deleted post', deleted: true }), + factory.create('Post', { id: 'p2', title: 'Disabled post', deleted: false }), + factory.create('Post', { title: 'Publicly visible post', deleted: false }) + ]) + const moderatorFactory = Factory() + await moderatorFactory.authenticateAs({ email: 'moderator@example.org', password: '1234' }) + const disableMutation = ` + mutation { + disable(resource: { + id: "p2" + type: contribution + }) + } + ` + await moderatorFactory.mutate(disableMutation) +}) + +afterEach(async () => { + await factory.cleanDatabase() +}) + +describe('softDeleteMiddleware', () => { + describe('Post', () => { + action = () => { + return client.request(query) + } + + beforeEach(() => { + query = '{ Post { title } }' + }) + + describe('as user', () => { + beforeEach(async () => { + const headers = await login({ email: 'user@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('hides deleted or disabled posts', async () => { + const expected = { Post: [{ title: 'Publicly visible post' }] } + await expect(action()).resolves.toEqual(expected) + }) + }) + + describe('as moderator', () => { + beforeEach(async () => { + const headers = await login({ email: 'moderator@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('hides deleted or disabled posts', async () => { + const expected = { Post: [{ title: 'Publicly visible post' }] } + await expect(action()).resolves.toEqual(expected) + }) + }) + + describe('filter (deleted: true)', () => { + beforeEach(() => { + query = '{ Post(deleted: true) { title } }' + }) + + describe('as user', () => { + beforeEach(async () => { + const headers = await login({ email: 'user@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('throws authorisation error', async () => { + await expect(action()).rejects.toThrow('Not Authorised!') + }) + }) + + describe('as moderator', () => { + beforeEach(async () => { + const headers = await login({ email: 'moderator@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('shows deleted posts', async () => { + const expected = { Post: [{ title: 'Deleted post' }] } + await expect(action()).resolves.toEqual(expected) + }) + }) + }) + + describe('filter (disabled: true)', () => { + beforeEach(() => { + query = '{ Post(disabled: true) { title } }' + }) + + describe('as user', () => { + beforeEach(async () => { + const headers = await login({ email: 'user@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('throws authorisation error', async () => { + await expect(action()).rejects.toThrow('Not Authorised!') + }) + }) + + describe('as moderator', () => { + beforeEach(async () => { + const headers = await login({ email: 'moderator@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('shows disabled posts', async () => { + const expected = { Post: [{ title: 'Disabled post' }] } + await expect(action()).resolves.toEqual(expected) + }) + }) + }) + }) +}) diff --git a/src/resolvers/badges.spec.js b/src/resolvers/badges.spec.js new file mode 100644 index 000000000..e38f54381 --- /dev/null +++ b/src/resolvers/badges.spec.js @@ -0,0 +1,223 @@ +import Factory from '../seed/factories' +import { GraphQLClient } from 'graphql-request' +import { host, login } from '../jest/helpers' + +const factory = Factory() + +describe('badges', () => { + beforeEach(async () => { + await factory.create('User', { + email: 'user@example.org', + role: 'user', + password: '1234' + }) + await factory.create('User', { + id: 'u2', + role: 'moderator', + email: 'moderator@example.org' + }) + await factory.create('User', { + id: 'u3', + role: 'admin', + email: 'admin@example.org' + }) + }) + + afterEach(async () => { + await factory.cleanDatabase() + }) + + describe('CreateBadge', () => { + const variables = { + id: 'b1', + key: 'indiegogo_en_racoon', + type: 'crowdfunding', + status: 'permanent', + icon: '/img/badges/indiegogo_en_racoon.svg' + } + + const mutation = ` + mutation( + $id: ID + $key: String! + $type: BadgeTypeEnum! + $status: BadgeStatusEnum! + $icon: String! + ) { + CreateBadge(id: $id, key: $key, type: $type, status: $status, icon: $icon) { + id, + key, + type, + status, + icon + } + } + ` + + describe('unauthenticated', () => { + let client + + it('throws authorization error', async () => { + client = new GraphQLClient(host) + await expect( + client.request(mutation, variables) + ).rejects.toThrow('Not Authorised') + }) + }) + + describe('authenticated admin', () => { + let client + beforeEach(async () => { + const headers = await login({ email: 'admin@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + it('creates a badge', async () => { + const expected = { + CreateBadge: { + icon: '/img/badges/indiegogo_en_racoon.svg', + id: 'b1', + key: 'indiegogo_en_racoon', + status: 'permanent', + type: 'crowdfunding' + } + } + await expect(client.request(mutation, variables)).resolves.toEqual(expected) + }) + }) + + describe('authenticated moderator', () => { + let client + beforeEach(async () => { + const headers = await login({ email: 'moderator@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('throws authorization error', async () => { + await expect( + client.request(mutation, variables) + ).rejects.toThrow('Not Authorised') + }) + }) + }) + + describe('UpdateBadge', () => { + beforeEach(async () => { + await factory.authenticateAs({ email: 'admin@example.org', password: '1234' }) + await factory.create('Badge', { id: 'b1' }) + }) + const variables = { + id: 'b1', + key: 'whatever' + } + + const mutation = ` + mutation($id: ID!, $key: String!) { + UpdateBadge(id: $id, key: $key) { + id + key + } + } + ` + + describe('unauthenticated', () => { + let client + + it('throws authorization error', async () => { + client = new GraphQLClient(host) + await expect( + client.request(mutation, variables) + ).rejects.toThrow('Not Authorised') + }) + }) + + describe('authenticated moderator', () => { + let client + beforeEach(async () => { + const headers = await login({ email: 'moderator@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('throws authorization error', async () => { + await expect( + client.request(mutation, variables) + ).rejects.toThrow('Not Authorised') + }) + }) + + describe('authenticated admin', () => { + let client + beforeEach(async () => { + const headers = await login({ email: 'admin@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + it('updates a badge', async () => { + const expected = { + UpdateBadge: { + id: 'b1', + key: 'whatever' + } + } + await expect(client.request(mutation, variables)).resolves.toEqual(expected) + }) + }) + }) + + describe('DeleteBadge', () => { + beforeEach(async () => { + await factory.authenticateAs({ email: 'admin@example.org', password: '1234' }) + await factory.create('Badge', { id: 'b1' }) + }) + const variables = { + id: 'b1' + } + + const mutation = ` + mutation($id: ID!) { + DeleteBadge(id: $id) { + id + } + } + ` + + describe('unauthenticated', () => { + let client + + it('throws authorization error', async () => { + client = new GraphQLClient(host) + await expect( + client.request(mutation, variables) + ).rejects.toThrow('Not Authorised') + }) + }) + + describe('authenticated moderator', () => { + let client + beforeEach(async () => { + const headers = await login({ email: 'moderator@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('throws authorization error', async () => { + await expect( + client.request(mutation, variables) + ).rejects.toThrow('Not Authorised') + }) + }) + + describe('authenticated admin', () => { + let client + beforeEach(async () => { + const headers = await login({ email: 'admin@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + it('deletes a badge', async () => { + const expected = { + DeleteBadge: { + id: 'b1' + } + } + await expect(client.request(mutation, variables)).resolves.toEqual(expected) + }) + }) + }) +}) diff --git a/src/resolvers/follow.spec.js b/src/resolvers/follow.spec.js new file mode 100644 index 000000000..3c16560e5 --- /dev/null +++ b/src/resolvers/follow.spec.js @@ -0,0 +1,115 @@ +import Factory from '../seed/factories' +import { GraphQLClient } from 'graphql-request' +import { host, login } from '../jest/helpers' + +const factory = Factory() +let clientUser1 + +const mutationFollowUser = (id) => ` + mutation { + follow(id: "${id}", type: User) + } +` +const mutationUnfollowUser = (id) => ` + mutation { + unfollow(id: "${id}", type: User) + } +` + +beforeEach(async () => { + await factory.create('User', { + id: 'u1', + email: 'test@example.org', + password: '1234' + }) + await factory.create('User', { + id: 'u2', + email: 'test2@example.org', + password: '1234' + }) +}) + +afterEach(async () => { + await factory.cleanDatabase() +}) + +describe('follow ', () => { + describe('(un)follow user', () => { + let headersUser1 + beforeEach(async () => { + headersUser1 = await login({ email: 'test@example.org', password: '1234' }) + clientUser1 = new GraphQLClient(host, { headers: headersUser1 }) + }) + + it('I can follow another user', async () => { + const res = await clientUser1.request( + mutationFollowUser('u2') + ) + const expected = { + follow: true + } + expect(res).toMatchObject(expected) + + const { User } = await clientUser1.request(`{ + User(id: "u2") { + followedBy { id } + followedByCurrentUser + } + }`) + const expected2 = { + followedBy: [ + { id: 'u1' } + ], + followedByCurrentUser: true + } + expect(User[0]).toMatchObject(expected2) + }) + + it('I can unfollow a user', async () => { + // follow + await clientUser1.request( + mutationFollowUser('u2') + ) + const expected = { + unfollow: true + } + // unfollow + const res = await clientUser1.request(mutationUnfollowUser('u2')) + expect(res).toMatchObject(expected) + + const { User } = await clientUser1.request(`{ + User(id: "u2") { + followedBy { id } + followedByCurrentUser + } + }`) + const expected2 = { + followedBy: [], + followedByCurrentUser: false + } + expect(User[0]).toMatchObject(expected2) + }) + + it('I can`t follow myself', async () => { + const res = await clientUser1.request( + mutationFollowUser('u1') + ) + const expected = { + follow: false + } + expect(res).toMatchObject(expected) + + const { User } = await clientUser1.request(`{ + User(id: "u1") { + followedBy { id } + followedByCurrentUser + } + }`) + const expected2 = { + followedBy: [], + followedByCurrentUser: false + } + expect(User[0]).toMatchObject(expected2) + }) + }) +}) diff --git a/src/resolvers/moderation.js b/src/resolvers/moderation.js new file mode 100644 index 000000000..db44790b9 --- /dev/null +++ b/src/resolvers/moderation.js @@ -0,0 +1,30 @@ +export default { + Mutation: { + disable: async (object, params, { user, driver }) => { + const { resource: { id } } = params + const { id: userId } = user + const cypher = ` + MATCH (u:User {id: $userId}) + MATCH (r {id: $id}) + SET r.disabled = true + MERGE (r)<-[:DISABLED]-(u) + ` + const session = driver.session() + const res = await session.run(cypher, { id, userId }) + session.close() + return Boolean(res) + }, + enable: async (object, params, { user, driver }) => { + const { resource: { id } } = params + const cypher = ` + MATCH (r {id: $id})<-[d:DISABLED]-() + SET r.disabled = false + DELETE d + ` + const session = driver.session() + const res = await session.run(cypher, { id }) + session.close() + return Boolean(res) + } + } +} diff --git a/src/resolvers/moderation.spec.js b/src/resolvers/moderation.spec.js new file mode 100644 index 000000000..c1d4a75fe --- /dev/null +++ b/src/resolvers/moderation.spec.js @@ -0,0 +1,370 @@ +import Factory from '../seed/factories' +import { GraphQLClient } from 'graphql-request' +import { host, login } from '../jest/helpers' + +const factory = Factory() +let client + +const setupAuthenticateClient = (params) => { + const authenticateClient = async () => { + await factory.create('User', params) + const headers = await login(params) + client = new GraphQLClient(host, { headers }) + } + return authenticateClient +} + +let setup +const runSetup = async () => { + await setup.createResource() + await setup.authenticateClient() +} + +beforeEach(() => { + setup = { + createResource: () => { + }, + authenticateClient: () => { + client = new GraphQLClient(host) + } + } +}) + +afterEach(async () => { + await factory.cleanDatabase() +}) + +describe('disable', () => { + const mutation = ` + mutation($id: ID!, $type: ResourceEnum!) { + disable(resource: { id: $id, type: $type }) + } + ` + let variables + + beforeEach(() => { + // our defaul set of variables + variables = { + id: 'blabla', + type: 'contribution' + } + }) + + const action = async () => { + return client.request(mutation, variables) + } + + it('throws authorization error', async () => { + await runSetup() + await expect(action()).rejects.toThrow('Not Authorised') + }) + + describe('authenticated', () => { + beforeEach(() => { + setup.authenticateClient = setupAuthenticateClient({ + email: 'user@example.org', + password: '1234' + }) + }) + + it('throws authorization error', async () => { + await runSetup() + await expect(action()).rejects.toThrow('Not Authorised') + }) + + describe('as moderator', () => { + beforeEach(() => { + setup.authenticateClient = setupAuthenticateClient({ + id: 'u7', + email: 'moderator@example.org', + password: '1234', + role: 'moderator' + }) + }) + + describe('on a comment', () => { + beforeEach(async () => { + variables = { + id: 'c47', + type: 'comment' + } + + setup.createResource = async () => { + await factory.create('User', { id: 'u45', email: 'commenter@example.org', password: '1234' }) + await factory.authenticateAs({ email: 'commenter@example.org', password: '1234' }) + await Promise.all([ + factory.create('Post', { id: 'p3' }), + factory.create('Comment', { id: 'c47' }) + ]) + await Promise.all([ + factory.relate('Comment', 'Author', { from: 'u45', to: 'c47' }), + factory.relate('Comment', 'Post', { from: 'c47', to: 'p3' }) + ]) + } + }) + + it('returns true', async () => { + const expected = { disable: true } + await runSetup() + await expect(action()).resolves.toEqual(expected) + }) + + it('changes .disabledBy', async () => { + const before = { Comment: [{ id: 'c47', disabledBy: null }] } + const expected = { Comment: [{ id: 'c47', disabledBy: { id: 'u7' } }] } + + await runSetup() + await expect(client.request( + '{ Comment { id, disabledBy { id } } }' + )).resolves.toEqual(before) + await action() + await expect(client.request( + '{ Comment(disabled: true) { id, disabledBy { id } } }' + )).resolves.toEqual(expected) + }) + + it('updates .disabled on comment', async () => { + const before = { Comment: [ { id: 'c47', disabled: false } ] } + const expected = { Comment: [ { id: 'c47', disabled: true } ] } + + await runSetup() + await expect(client.request( + '{ Comment { id disabled } }' + )).resolves.toEqual(before) + await action() + await expect(client.request( + '{ Comment(disabled: true) { id disabled } }' + )).resolves.toEqual(expected) + }) + }) + + describe('on a post', () => { + beforeEach(async () => { + variables = { + id: 'p9', + type: 'contribution' + } + + setup.createResource = async () => { + await factory.create('User', { email: 'author@example.org', password: '1234' }) + await factory.authenticateAs({ email: 'author@example.org', password: '1234' }) + await factory.create('Post', { + id: 'p9' // that's the ID we will look for + }) + } + }) + + it('returns true', async () => { + const expected = { disable: true } + await runSetup() + await expect(action()).resolves.toEqual(expected) + }) + + it('changes .disabledBy', async () => { + const before = { Post: [{ id: 'p9', disabledBy: null }] } + const expected = { Post: [{ id: 'p9', disabledBy: { id: 'u7' } }] } + + await runSetup() + await expect(client.request( + '{ Post { id, disabledBy { id } } }' + )).resolves.toEqual(before) + await action() + await expect(client.request( + '{ Post(disabled: true) { id, disabledBy { id } } }' + )).resolves.toEqual(expected) + }) + + it('updates .disabled on post', async () => { + const before = { Post: [ { id: 'p9', disabled: false } ] } + const expected = { Post: [ { id: 'p9', disabled: true } ] } + + await runSetup() + await expect(client.request( + '{ Post { id disabled } }' + )).resolves.toEqual(before) + await action() + await expect(client.request( + '{ Post(disabled: true) { id disabled } }' + )).resolves.toEqual(expected) + }) + }) + }) + }) +}) + +describe('enable', () => { + const mutation = ` + mutation($id: ID!, $type: ResourceEnum!) { + enable(resource: { id: $id, type: $type }) + } + ` + let variables + + const action = async () => { + return client.request(mutation, variables) + } + + beforeEach(() => { + // our defaul set of variables + variables = { + id: 'blabla', + type: 'contribution' + } + }) + + it('throws authorization error', async () => { + await runSetup() + await expect(action()).rejects.toThrow('Not Authorised') + }) + + describe('authenticated', () => { + beforeEach(() => { + setup.authenticateClient = setupAuthenticateClient({ + email: 'user@example.org', + password: '1234' + }) + }) + + it('throws authorization error', async () => { + await runSetup() + await expect(action()).rejects.toThrow('Not Authorised') + }) + + describe('as moderator', () => { + beforeEach(async () => { + setup.authenticateClient = setupAuthenticateClient({ + role: 'moderator', + email: 'someUser@example.org', + password: '1234' + }) + }) + + describe('on a comment', () => { + beforeEach(async () => { + variables = { + id: 'c456', + type: 'comment' + } + + setup.createResource = async () => { + await factory.create('User', { id: 'u123', email: 'author@example.org', password: '1234' }) + await factory.authenticateAs({ email: 'author@example.org', password: '1234' }) + await Promise.all([ + factory.create('Post', { id: 'p9' }), + factory.create('Comment', { id: 'c456' }) + ]) + await Promise.all([ + factory.relate('Comment', 'Author', { from: 'u123', to: 'c456' }), + factory.relate('Comment', 'Post', { from: 'c456', to: 'p9' }) + ]) + + const disableMutation = ` + mutation { + disable(resource: { + id: "c456" + type: comment + }) + } + ` + await factory.mutate(disableMutation) // that's we want to delete + } + }) + + it('returns true', async () => { + const expected = { enable: true } + await runSetup() + await expect(action()).resolves.toEqual(expected) + }) + + it('changes .disabledBy', async () => { + const before = { Comment: [{ id: 'c456', disabledBy: { id: 'u123' } }] } + const expected = { Comment: [{ id: 'c456', disabledBy: null }] } + + await runSetup() + await expect(client.request( + '{ Comment(disabled: true) { id, disabledBy { id } } }' + )).resolves.toEqual(before) + await action() + await expect(client.request( + '{ Comment { id, disabledBy { id } } }' + )).resolves.toEqual(expected) + }) + + it('updates .disabled on post', async () => { + const before = { Comment: [ { id: 'c456', disabled: true } ] } + const expected = { Comment: [ { id: 'c456', disabled: false } ] } + + await runSetup() + await expect(client.request( + '{ Comment(disabled: true) { id disabled } }' + )).resolves.toEqual(before) + await action() // this updates .disabled + await expect(client.request( + '{ Comment { id disabled } }' + )).resolves.toEqual(expected) + }) + }) + + describe('on a post', () => { + beforeEach(async () => { + variables = { + id: 'p9', + type: 'contribution' + } + + setup.createResource = async () => { + await factory.create('User', { id: 'u123', email: 'author@example.org', password: '1234' }) + await factory.authenticateAs({ email: 'author@example.org', password: '1234' }) + await factory.create('Post', { + id: 'p9' // that's the ID we will look for + }) + + const disableMutation = ` + mutation { + disable(resource: { + id: "p9" + type: contribution + }) + } + ` + await factory.mutate(disableMutation) // that's we want to delete + } + }) + + it('returns true', async () => { + const expected = { enable: true } + await runSetup() + await expect(action()).resolves.toEqual(expected) + }) + + it('changes .disabledBy', async () => { + const before = { Post: [{ id: 'p9', disabledBy: { id: 'u123' } }] } + const expected = { Post: [{ id: 'p9', disabledBy: null }] } + + await runSetup() + await expect(client.request( + '{ Post(disabled: true) { id, disabledBy { id } } }' + )).resolves.toEqual(before) + await action() + await expect(client.request( + '{ Post { id, disabledBy { id } } }' + )).resolves.toEqual(expected) + }) + + it('updates .disabled on post', async () => { + const before = { Post: [ { id: 'p9', disabled: true } ] } + const expected = { Post: [ { id: 'p9', disabled: false } ] } + + await runSetup() + await expect(client.request( + '{ Post(disabled: true) { id disabled } }' + )).resolves.toEqual(before) + await action() // this updates .disabled + await expect(client.request( + '{ Post { id disabled } }' + )).resolves.toEqual(expected) + }) + }) + }) + }) +}) diff --git a/src/resolvers/posts.js b/src/resolvers/posts.js new file mode 100644 index 000000000..c813ec3f1 --- /dev/null +++ b/src/resolvers/posts.js @@ -0,0 +1,63 @@ +import { neo4jgraphql } from 'neo4j-graphql-js' +import { activityPub } from '../activitypub/ActivityPub' +import uuid from 'uuid/v4' +import as from 'activitystrea.ms' +/* +import as from 'activitystrea.ms' +import request from 'request' +*/ + +const debug = require('debug')('backend:schema') + +export default { + Mutation: { + CreatePost: async (object, params, context, resolveInfo) => { + params.activityId = uuid() + const result = await neo4jgraphql(object, params, context, resolveInfo, false) + + const session = context.driver.session() + const author = await session.run( + 'MATCH (author:User {id: $userId}), (post:Post {id: $postId}) ' + + 'MERGE (post)<-[:WROTE]-(author) ' + + 'RETURN author', { + userId: context.user.id, + postId: result.id + }) + + debug(`actorId = ${author.records[0]._fields[0].properties.actorId}`) + if (Array.isArray(author.records) && author.records.length > 0) { + const actorId = author.records[0]._fields[0].properties.actorId + const createActivity = await new Promise((resolve, reject) => { + as.create() + .id(`${actorId}/status/${params.activityId}`) + .actor(`${actorId}`) + .object( + as.article() + .id(`${actorId}/status/${result.id}`) + .content(result.content) + .to('https://www.w3.org/ns/activitystreams#Public') + .publishedNow() + .attributedTo(`${actorId}`) + ).prettyWrite((err, doc) => { + if (err) { + reject(err) + } else { + debug(doc) + const parsedDoc = JSON.parse(doc) + parsedDoc.send = true + resolve(JSON.stringify(parsedDoc)) + } + }) + }) + try { + await activityPub.sendActivity(createActivity) + } catch (e) { + debug(`error sending post activity = ${JSON.stringify(e, null, 2)}`) + } + } + session.close() + + return result + } + } +} diff --git a/src/resolvers/posts.spec.js b/src/resolvers/posts.spec.js new file mode 100644 index 000000000..5603683eb --- /dev/null +++ b/src/resolvers/posts.spec.js @@ -0,0 +1,202 @@ +import Factory from '../seed/factories' +import { GraphQLClient } from 'graphql-request' +import { host, login } from '../jest/helpers' + +const factory = Factory() +let client + +beforeEach(async () => { + await factory.create('User', { + email: 'test@example.org', + password: '1234' + }) +}) + +afterEach(async () => { + await factory.cleanDatabase() +}) + +describe('CreatePost', () => { + const mutation = ` + mutation { + CreatePost(title: "I am a title", content: "Some content") { + title + content + slug + disabled + deleted + } + } + ` + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + client = new GraphQLClient(host) + await expect(client.request(mutation)).rejects.toThrow('Not Authorised') + }) + }) + + describe('authenticated', () => { + let headers + beforeEach(async () => { + headers = await login({ email: 'test@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('creates a post', async () => { + const expected = { + CreatePost: { + title: 'I am a title', + content: 'Some content' + } + } + await expect(client.request(mutation)).resolves.toMatchObject(expected) + }) + + it('assigns the authenticated user as author', async () => { + await client.request(mutation) + const { User } = await client.request(`{ + User(email:"test@example.org") { + contributions { + title + } + } + }`, { headers }) + expect(User).toEqual([ { contributions: [ { title: 'I am a title' } ] } ]) + }) + + describe('disabled and deleted', () => { + it('initially false', async () => { + const expected = { CreatePost: { disabled: false, deleted: false } } + await expect(client.request(mutation)).resolves.toMatchObject(expected) + }) + }) + }) +}) + +describe('UpdatePost', () => { + const mutation = ` + mutation($id: ID!, $content: String) { + UpdatePost(id: $id, content: $content) { + id + content + } + } + ` + + let variables = { + id: 'p1', + content: 'New content' + } + + beforeEach(async () => { + const asAuthor = Factory() + await asAuthor.create('User', { + email: 'author@example.org', + password: '1234' + }) + await asAuthor.authenticateAs({ + email: 'author@example.org', + password: '1234' + }) + await asAuthor.create('Post', { + id: 'p1', + content: 'Old content' + }) + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + client = new GraphQLClient(host) + await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised') + }) + }) + + describe('authenticated but not the author', () => { + let headers + beforeEach(async () => { + headers = await login({ email: 'test@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('throws authorization error', async () => { + await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised') + }) + }) + + describe('authenticated as author', () => { + let headers + beforeEach(async () => { + headers = await login({ email: 'author@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('updates a post', async () => { + const expected = { UpdatePost: { id: 'p1', content: 'New content' } } + await expect(client.request(mutation, variables)).resolves.toEqual(expected) + }) + }) +}) + +describe('DeletePost', () => { + const mutation = ` + mutation($id: ID!) { + DeletePost(id: $id) { + id + content + } + } + ` + + let variables = { + id: 'p1' + } + + beforeEach(async () => { + const asAuthor = Factory() + await asAuthor.create('User', { + email: 'author@example.org', + password: '1234' + }) + await asAuthor.authenticateAs({ + email: 'author@example.org', + password: '1234' + }) + await asAuthor.create('Post', { + id: 'p1', + content: 'To be deleted' + }) + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + client = new GraphQLClient(host) + await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised') + }) + }) + + describe('authenticated but not the author', () => { + let headers + beforeEach(async () => { + headers = await login({ email: 'test@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('throws authorization error', async () => { + await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised') + }) + }) + + describe('authenticated as author', () => { + let headers + beforeEach(async () => { + headers = await login({ email: 'author@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('deletes a post', async () => { + const expected = { DeletePost: { id: 'p1', content: 'To be deleted' } } + await expect(client.request(mutation, variables)).resolves.toEqual(expected) + }) + }) +}) diff --git a/src/resolvers/reports.js b/src/resolvers/reports.js new file mode 100644 index 000000000..c471d7b7a --- /dev/null +++ b/src/resolvers/reports.js @@ -0,0 +1,51 @@ +import uuid from 'uuid/v4' + +export default { + Mutation: { + report: async (parent, { resource, description }, { driver, req, user }, resolveInfo) => { + const contextId = uuid() + const session = driver.session() + const data = { + id: contextId, + type: resource.type, + createdAt: (new Date()).toISOString(), + description: resource.description + } + await session.run( + 'CREATE (r:Report $report) ' + + 'RETURN r.id, r.type, r.description', { + report: data + } + ) + let contentType + + switch (resource.type) { + case 'post': + case 'contribution': + contentType = 'Post' + break + case 'comment': + contentType = 'Comment' + break + case 'user': + contentType = 'User' + break + } + + await session.run( + `MATCH (author:User {id: $userId}), (context:${contentType} {id: $resourceId}), (report:Report {id: $contextId}) ` + + 'MERGE (report)<-[:REPORTED]-(author) ' + + 'MERGE (context)<-[:REPORTED]-(report) ' + + 'RETURN context', { + resourceId: resource.id, + userId: user.id, + contextId: contextId + } + ) + session.close() + + // TODO: output Report compatible object + return data + } + } +} diff --git a/src/resolvers/reports.spec.js b/src/resolvers/reports.spec.js new file mode 100644 index 000000000..253cdadcc --- /dev/null +++ b/src/resolvers/reports.spec.js @@ -0,0 +1,68 @@ +import Factory from '../seed/factories' +import { GraphQLClient } from 'graphql-request' +import { host, login } from '../jest/helpers' + +const factory = Factory() + +describe('report', () => { + beforeEach(async () => { + await factory.create('User', { + email: 'test@example.org', + password: '1234' + }) + await factory.create('User', { + id: 'u2', + name: 'abusive-user', + role: 'user', + email: 'abusive-user@example.org' + }) + }) + + afterEach(async () => { + await factory.cleanDatabase() + }) + + describe('unauthenticated', () => { + let client + it('throws authorization error', async () => { + client = new GraphQLClient(host) + await expect( + client.request(`mutation { + report( + description: "I don't like this user", + resource: { + id: "u2", + type: user + } + ) { id, createdAt } + }`) + ).rejects.toThrow('Not Authorised') + }) + + describe('authenticated', () => { + let headers + let response + beforeEach(async () => { + headers = await login({ email: 'test@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + response = await client.request(`mutation { + report( + description: "I don't like this user", + resource: { + id: "u2", + type: user + } + ) { id, createdAt } + }`, + { headers } + ) + }) + it('creates a report', () => { + let { id, createdAt } = response.report + expect(response).toEqual({ + report: { id, createdAt } + }) + }) + }) + }) +}) diff --git a/src/resolvers/shout.spec.js b/src/resolvers/shout.spec.js new file mode 100644 index 000000000..490191c7a --- /dev/null +++ b/src/resolvers/shout.spec.js @@ -0,0 +1,126 @@ +import Factory from '../seed/factories' +import { GraphQLClient } from 'graphql-request' +import { host, login } from '../jest/helpers' + +const factory = Factory() +let clientUser1, clientUser2 + +const mutationShoutPost = (id) => ` + mutation { + shout(id: "${id}", type: Post) + } +` +const mutationUnshoutPost = (id) => ` + mutation { + unshout(id: "${id}", type: Post) + } +` + +beforeEach(async () => { + await factory.create('User', { + id: 'u1', + email: 'test@example.org', + password: '1234' + }) + await factory.create('User', { + id: 'u2', + email: 'test2@example.org', + password: '1234' + }) +}) + +afterEach(async () => { + await factory.cleanDatabase() +}) + +describe('shout ', () => { + describe('(un)shout foreign post', () => { + let headersUser1, headersUser2 + beforeEach(async () => { + headersUser1 = await login({ email: 'test@example.org', password: '1234' }) + headersUser2 = await login({ email: 'test2@example.org', password: '1234' }) + clientUser1 = new GraphQLClient(host, { headers: headersUser1 }) + clientUser2 = new GraphQLClient(host, { headers: headersUser2 }) + + await clientUser1.request(` + mutation { + CreatePost(id: "p1", title: "Post Title 1", content: "Some Post Content 1") { + id + title + } + } + `) + await clientUser2.request(` + mutation { + CreatePost(id: "p2", title: "Post Title 2", content: "Some Post Content 2") { + id + title + } + } + `) + }) + + it('I shout a post of another user', async () => { + const res = await clientUser1.request( + mutationShoutPost('p2') + ) + const expected = { + shout: true + } + expect(res).toMatchObject(expected) + + const { Post } = await clientUser1.request(`{ + Post(id: "p2") { + shoutedByCurrentUser + } + }`) + const expected2 = { + shoutedByCurrentUser: true + } + expect(Post[0]).toMatchObject(expected2) + }) + + it('I unshout a post of another user', async () => { + // shout + await clientUser1.request( + mutationShoutPost('p2') + ) + const expected = { + unshout: true + } + // unshout + const res = await clientUser1.request(mutationUnshoutPost('p2')) + expect(res).toMatchObject(expected) + + const { Post } = await clientUser1.request(`{ + Post(id: "p2") { + shoutedByCurrentUser + } + }`) + const expected2 = { + shoutedByCurrentUser: false + } + expect(Post[0]).toMatchObject(expected2) + }) + + it('I can`t shout my own post', async () => { + const res = await clientUser1.request( + mutationShoutPost('p1') + ) + const expected = { + shout: false + } + expect(res).toMatchObject(expected) + + const { Post } = await clientUser1.request(`{ + Post(id: "p1") { + shoutedByCurrentUser + } + }`) + const expected2 = { + shoutedByCurrentUser: false + } + expect(Post[0]).toMatchObject(expected2) + }) + }) +}) diff --git a/src/resolvers/statistics.js b/src/resolvers/statistics.js new file mode 100644 index 000000000..17c4be956 --- /dev/null +++ b/src/resolvers/statistics.js @@ -0,0 +1,67 @@ +export const query = (cypher, session) => { + return new Promise((resolve, reject) => { + let data = [] + session + .run(cypher) + .subscribe({ + onNext: function (record) { + let item = {} + record.keys.forEach(key => { + item[key] = record.get(key) + }) + data.push(item) + }, + onCompleted: function () { + session.close() + resolve(data) + }, + onError: function (error) { + reject(error) + } + }) + }) +} +const queryOne = (cypher, session) => { + return new Promise((resolve, reject) => { + query(cypher, session) + .then(res => { + resolve(res.length ? res.pop() : {}) + }) + .catch(err => { + reject(err) + }) + }) +} + +export default { + Query: { + statistics: async (parent, args, { driver, user }) => { + return new Promise(async (resolve) => { + const session = driver.session() + const queries = { + countUsers: 'MATCH (r:User) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countUsers', + countPosts: 'MATCH (r:Post) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countPosts', + countComments: 'MATCH (r:Comment) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countComments', + countNotifications: 'MATCH (r:Notification) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countNotifications', + countOrganizations: 'MATCH (r:Organization) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countOrganizations', + countProjects: 'MATCH (r:Project) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countProjects', + countInvites: 'MATCH (r:Invite) WHERE r.wasUsed <> true OR NOT exists(r.wasUsed) RETURN COUNT(r) AS countInvites', + countFollows: 'MATCH (:User)-[r:FOLLOWS]->(:User) RETURN COUNT(r) AS countFollows', + countShouts: 'MATCH (:User)-[r:SHOUTED]->(:Post) RETURN COUNT(r) AS countShouts' + } + let data = { + countUsers: (await queryOne(queries.countUsers, session)).countUsers.low, + countPosts: (await queryOne(queries.countPosts, session)).countPosts.low, + countComments: (await queryOne(queries.countComments, session)).countComments.low, + countNotifications: (await queryOne(queries.countNotifications, session)).countNotifications.low, + countOrganizations: (await queryOne(queries.countOrganizations, session)).countOrganizations.low, + countProjects: (await queryOne(queries.countProjects, session)).countProjects.low, + countInvites: (await queryOne(queries.countInvites, session)).countInvites.low, + countFollows: (await queryOne(queries.countFollows, session)).countFollows.low, + countShouts: (await queryOne(queries.countShouts, session)).countShouts.low + } + resolve(data) + }) + } + } +} diff --git a/src/resolvers/user_management.js b/src/resolvers/user_management.js new file mode 100644 index 000000000..ec4ae7ce2 --- /dev/null +++ b/src/resolvers/user_management.js @@ -0,0 +1,51 @@ +import encode from '../jwt/encode' +import bcrypt from 'bcryptjs' +import { AuthenticationError } from 'apollo-server' +import { neo4jgraphql } from 'neo4j-graphql-js' + +export default { + Query: { + isLoggedIn: (parent, args, { driver, user }) => { + return Boolean(user && user.id) + }, + currentUser: async (object, params, ctx, resolveInfo) => { + const { user } = ctx + if (!user) return null + return neo4jgraphql(object, { id: user.id }, ctx, resolveInfo, false) + } + }, + Mutation: { + signup: async (parent, { email, password }, { req }) => { + // if (data[email]) { + // throw new Error('Another User with same email exists.') + // } + // data[email] = { + // password: await bcrypt.hashSync(password, 10), + // } + + return true + }, + login: async (parent, { email, password }, { driver, req, user }) => { + // if (user && user.id) { + // throw new Error('Already logged in.') + // } + const session = driver.session() + return session.run( + 'MATCH (user:User {email: $userEmail}) ' + + 'RETURN user {.id, .slug, .name, .avatar, .email, .password, .role} as user LIMIT 1', { + userEmail: email + }) + .then(async (result) => { + session.close() + const [currentUser] = await result.records.map(function (record) { + return record.get('user') + }) + + if (currentUser && await bcrypt.compareSync(password, currentUser.password)) { + delete currentUser.password + return encode(currentUser) + } else throw new AuthenticationError('Incorrect email address or password.') + }) + } + } +} diff --git a/src/resolvers/user_management.spec.js b/src/resolvers/user_management.spec.js new file mode 100644 index 000000000..a3bf6fdd0 --- /dev/null +++ b/src/resolvers/user_management.spec.js @@ -0,0 +1,179 @@ +import Factory from '../seed/factories' +import { GraphQLClient, request } from 'graphql-request' +import jwt from 'jsonwebtoken' +import { host, login } from '../jest/helpers' + +const factory = Factory() + +// here is the decoded JWT token: +// { +// role: 'user', +// locationName: null, +// name: 'Jenny Rostock', +// about: null, +// avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/sasha_shestakov/128.jpg', +// id: 'u3', +// email: 'user@example.org', +// slug: 'jenny-rostock', +// iat: 1550846680, +// exp: 1637246680, +// aud: 'http://localhost:3000', +// iss: 'http://localhost:4000', +// sub: 'u3' +// } +const jennyRostocksHeaders = { authorization: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoidXNlciIsImxvY2F0aW9uTmFtZSI6bnVsbCwibmFtZSI6Ikplbm55IFJvc3RvY2siLCJhYm91dCI6bnVsbCwiYXZhdGFyIjoiaHR0cHM6Ly9zMy5hbWF6b25hd3MuY29tL3VpZmFjZXMvZmFjZXMvdHdpdHRlci9zYXNoYV9zaGVzdGFrb3YvMTI4LmpwZyIsImlkIjoidTMiLCJlbWFpbCI6InVzZXJAZXhhbXBsZS5vcmciLCJzbHVnIjoiamVubnktcm9zdG9jayIsImlhdCI6MTU1MDg0NjY4MCwiZXhwIjoxNjM3MjQ2NjgwLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjMwMDAiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjQwMDAiLCJzdWIiOiJ1MyJ9.eZ_mVKas4Wzoc_JrQTEWXyRn7eY64cdIg4vqQ-F_7Jc' } + +beforeEach(async () => { + await factory.create('User', { + avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/seyedhossein1/128.jpg', + id: 'acb2d923-f3af-479e-9f00-61b12e864666', + name: 'Matilde Hermiston', + slug: 'matilde-hermiston', + role: 'user', + email: 'test@example.org', + password: '1234' + }) +}) + +afterEach(async () => { + await factory.cleanDatabase() +}) + +describe('isLoggedIn', () => { + const query = '{ isLoggedIn }' + describe('unauthenticated', () => { + it('returns false', async () => { + await expect(request(host, query)).resolves.toEqual({ isLoggedIn: false }) + }) + }) + + describe('with malformed JWT Bearer token', () => { + const headers = { authorization: 'blah' } + const client = new GraphQLClient(host, { headers }) + + it('returns false', async () => { + await expect(client.request(query)).resolves.toEqual({ isLoggedIn: false }) + }) + }) + + describe('with valid JWT Bearer token', () => { + const client = new GraphQLClient(host, { headers: jennyRostocksHeaders }) + + it('returns false', async () => { + await expect(client.request(query)).resolves.toEqual({ isLoggedIn: false }) + }) + + describe('and a corresponding user in the database', () => { + it('returns true', async () => { + // see the decoded token above + await factory.create('User', { id: 'u3' }) + await expect(client.request(query)).resolves.toEqual({ isLoggedIn: true }) + }) + }) + }) +}) + +describe('currentUser', () => { + const query = `{ + currentUser { + id + slug + name + avatar + email + role + } + }` + + describe('unauthenticated', () => { + it('returns null', async () => { + const expected = { currentUser: null } + await expect(request(host, query)).resolves.toEqual(expected) + }) + }) + + describe('with valid JWT Bearer Token', () => { + let client + let headers + + describe('but no corresponding user in the database', () => { + beforeEach(async () => { + client = new GraphQLClient(host, { headers: jennyRostocksHeaders }) + }) + + it('returns null', async () => { + const expected = { currentUser: null } + await expect(client.request(query)).resolves.toEqual(expected) + }) + }) + + describe('and corresponding user in the database', () => { + beforeEach(async () => { + headers = await login({ email: 'test@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + it('returns the whole user object', async () => { + const expected = { + currentUser: { + avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/seyedhossein1/128.jpg', + email: 'test@example.org', + id: 'acb2d923-f3af-479e-9f00-61b12e864666', + name: 'Matilde Hermiston', + slug: 'matilde-hermiston', + role: 'user' + } + } + await expect(client.request(query)).resolves.toEqual(expected) + }) + }) + }) +}) + +describe('login', () => { + const mutation = (params) => { + const { email, password } = params + return ` + mutation { + login(email:"${email}", password:"${password}") + }` + } + + describe('ask for a `token`', () => { + describe('with valid email/password combination', () => { + it('responds with a JWT token', async () => { + const data = await request(host, mutation({ + email: 'test@example.org', + password: '1234' + })) + const token = data.login + jwt.verify(token, process.env.JWT_SECRET, (err, data) => { + expect(data.email).toEqual('test@example.org') + expect(err).toBeNull() + }) + }) + }) + + describe('with a valid email but incorrect password', () => { + it('responds with "Incorrect email address or password."', async () => { + await expect( + request(host, mutation({ + email: 'test@example.org', + password: 'wrong' + })) + ).rejects.toThrow('Incorrect email address or password.') + }) + }) + + describe('with a non-existing email', () => { + it('responds with "Incorrect email address or password."', async () => { + await expect( + request(host, mutation({ + email: 'non-existent@example.org', + password: 'wrong' + })) + ).rejects.toThrow('Incorrect email address or password.') + }) + }) + }) +}) From 45cf16d07da42fa09c931bb00340c67eac7cd722 Mon Sep 17 00:00:00 2001 From: Grzegorz Leoniec Date: Fri, 8 Mar 2019 20:51:43 +0100 Subject: [PATCH 15/25] Updates to get in line with master --- .env.template | 2 + .gitignore | 3 + README.md | 17 +- docker-compose.travis.yml | 4 - package.json | 50 +- src/activitypub/NitroDataSource.js | 4 +- src/graphql-schema.js | 216 +--- src/graphql-schema.spec.js | 184 --- src/jest/helpers.js | 6 +- src/jwt/generateToken.js | 17 - src/jwt/strategy.js | 42 - src/middleware/dateTimeMiddleware.js | 8 - src/middleware/permissionsMiddleware.js | 62 +- src/middleware/softDeleteMiddleware.js | 46 +- src/schema.graphql | 85 +- src/seed/factories/badges.js | 18 +- src/seed/factories/index.js | 37 +- src/seed/factories/organizations.js | 2 +- src/seed/factories/posts.js | 2 - src/seed/factories/users.js | 3 + src/seed/seed-db.js | 76 +- src/server.js | 30 +- yarn.lock | 1463 ++++++++++++++--------- 23 files changed, 1192 insertions(+), 1185 deletions(-) delete mode 100644 src/graphql-schema.spec.js delete mode 100644 src/jwt/generateToken.js delete mode 100644 src/jwt/strategy.js diff --git a/.env.template b/.env.template index 42211b184..abc62b2dc 100644 --- a/.env.template +++ b/.env.template @@ -8,3 +8,5 @@ MOCK=false JWT_SECRET="b/&&7b78BF&fv/Vd" MAPBOX_TOKEN="pk.eyJ1IjoiaHVtYW4tY29ubmVjdGlvbiIsImEiOiJjajl0cnBubGoweTVlM3VwZ2lzNTNud3ZtIn0.KZ8KK9l70omjXbEkkbHGsQ" + +PRIVATE_KEY_PASSPHRASE="a7dsf78sadg87ad87sfagsadg78" diff --git a/.gitignore b/.gitignore index b909223f8..81a29c8e6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ coverage.lcov .nyc_output/ public/uploads/* !.gitkeep + +# Apple macOS folder attribute file +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md index fc175dfb2..1f4755cc2 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,12 @@ -# Human-Connection - NITRO Backend -[![Build Status](https://travis-ci.com/Human-Connection/Nitro-Backend.svg?branch=master)](https://travis-ci.com/Human-Connection/Nitro-Backend) +

+ Human Connection +

+ +# NITRO Backend +[![Build Status](https://img.shields.io/travis/com/Human-Connection/Nitro-Backend/master.svg)](https://travis-ci.com/Human-Connection/Nitro-Backend) +[![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/Human-Connection/Nitro-Backend/blob/backend/LICENSE.md) +[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2FHuman-Connection%2FNitro-Backend.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2FHuman-Connection%2FNitro-Backend?ref=badge_shield) +[![Discord Channel](https://img.shields.io/discord/489522408076738561.svg)](https://discord.gg/6ub73U3) > This Prototype tries to resolve the biggest hurdle of connecting > our services together. This is not possible in a sane way using @@ -158,9 +165,13 @@ npm run test:cucumber - [x] add jwt authentication - [ ] get directives working correctly (@toLower, @auth, @role, etc.) -- [ ] check if search is working +- [x] check if search is working - [x] check if sorting is working - [x] check if pagination is working - [ ] check if upload is working (using graphql-yoga?) - [x] evaluate middleware - [ ] ignore Posts and Comments by blacklisted Users + + +## License +[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2FHuman-Connection%2FNitro-Backend.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2FHuman-Connection%2FNitro-Backend?ref=badge_large) diff --git a/docker-compose.travis.yml b/docker-compose.travis.yml index 3d577e638..e1998f6dd 100644 --- a/docker-compose.travis.yml +++ b/docker-compose.travis.yml @@ -8,11 +8,7 @@ services: - 7687:7687 - 7474:7474 backend: - ports: - - 4001:4001 - - 4123:4123 image: humanconnection/nitro-backend:builder build: context: . target: builder - command: yarn run test:cypress diff --git a/package.json b/package.json index d876b77d1..df4d9c94c 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,9 @@ "scripts": { "build": "babel src/ -d dist/ --copy-files", "start": "node dist/", - "dev": "nodemon --exec babel-node src/", - "dev:debug": "nodemon --exec babel-node --inspect=0.0.0.0:9229 src/index.js", - "lint": "eslint src --config .eslintrc.js --fix", + "dev": "nodemon --exec babel-node src/ -e js,graphql", + "dev:debug": "nodemon --exec babel-node --inspect=0.0.0.0:9229 src/index.js -e js,graphql", + "lint": "eslint src --config .eslintrc.js", "test": "nyc --reporter=text-lcov yarn test:jest", "test:cypress": "run-p --race test:before:*", "test:before:server": "cross-env GRAPHQL_URI=http://localhost:4123 GRAPHQL_PORT=4123 babel-node src/ 2> /dev/null", @@ -36,19 +36,19 @@ ] }, "dependencies": { - "activitystrea.ms": "^2.1.3", - "apollo-cache-inmemory": "~1.4.3", - "apollo-client": "~2.4.13", - "apollo-link-context": "^1.0.14", - "apollo-link-http": "~1.5.11", - "apollo-server": "~2.4.2", + "activitystrea.ms": "~2.1.3", + "apollo-cache-inmemory": "~1.5.1", + "apollo-client": "~2.5.1", + "apollo-link-context": "~1.0.14", + "apollo-link-http": "~1.5.12", + "apollo-server": "~2.4.8", "bcryptjs": "~2.4.3", "cheerio": "~1.0.0-rc.2", - "cors": "^2.8.5", + "cors": "~2.8.5", "cross-env": "~5.2.0", "date-fns": "2.0.0-alpha.26", "dotenv": "~6.2.0", - "express": "^4.16.4", + "express": "~4.16.4", "faker": "~4.1.0", "graphql": "~14.1.1", "graphql-custom-directives": "~0.2.14", @@ -57,39 +57,37 @@ "graphql-shield": "~5.3.0", "graphql-tag": "~2.10.1", "graphql-yoga": "~1.17.4", - "helmet": "^3.15.1", + "helmet": "~3.15.1", "jsonwebtoken": "~8.5.0", "linkifyjs": "~2.1.8", "lodash": "~4.17.11", "ms": "~2.1.1", - "neo4j-driver": "~1.7.2", - "neo4j-graphql-js": "~2.3.1", + "neo4j-driver": "~1.7.3", + "neo4j-graphql-js": "~2.4.1", "node-fetch": "~2.3.0", "npm-run-all": "~4.1.5", - "passport": "~0.4.0", - "passport-jwt": "~4.0.0", - "request": "^2.88.0", + "request": "~2.88.0", "sanitize-html": "~1.20.0", "slug": "~1.0.0", "trunc-html": "~1.1.2", - "uuid": "^3.3.2", + "uuid": "~3.3.2", "wait-on": "~3.2.0" }, "devDependencies": { "@babel/cli": "~7.2.3", - "@babel/core": "~7.3.3", + "@babel/core": "~7.3.4", "@babel/node": "~7.2.2", "@babel/plugin-proposal-throw-expressions": "^7.2.0", - "@babel/preset-env": "~7.3.1", + "@babel/preset-env": "~7.3.4", "@babel/register": "~7.0.0", - "apollo-server-testing": "~2.4.2", + "apollo-server-testing": "~2.4.8", "babel-core": "~7.0.0-0", "babel-eslint": "~10.0.1", - "babel-jest": "~24.1.0", + "babel-jest": "~24.3.1", "chai": "~4.2.0", - "cucumber": "^5.1.0", - "debug": "^4.1.1", - "eslint": "~5.13.0", + "cucumber": "~5.1.0", + "debug": "~4.1.1", + "eslint": "~5.15.1", "eslint-config-standard": "~12.0.0", "eslint-plugin-import": "~2.16.0", "eslint-plugin-jest": "~22.3.0", @@ -97,7 +95,7 @@ "eslint-plugin-promise": "~4.0.1", "eslint-plugin-standard": "~4.0.0", "graphql-request": "~1.8.2", - "jest": "~24.1.0", + "jest": "~24.3.1", "nodemon": "~1.18.10", "nyc": "~13.3.0", "supertest": "~3.4.2" diff --git a/src/activitypub/NitroDataSource.js b/src/activitypub/NitroDataSource.js index d43dc40a6..a8b65b27e 100644 --- a/src/activitypub/NitroDataSource.js +++ b/src/activitypub/NitroDataSource.js @@ -21,7 +21,7 @@ import fetch from 'node-fetch' import { ApolloClient } from 'apollo-client' import dotenv from 'dotenv' import uuid from 'uuid' -import generateJwtToken from '../jwt/generateToken' +import encode from '../jwt/encode' import { resolve } from 'path' import trunc from 'trunc-html' const debug = require('debug')('ea:nitro-datasource') @@ -41,7 +41,7 @@ export default class NitroDataSource { const cache = new InMemoryCache() const authLink = setContext((_, { headers }) => { // generate the authentication token (maybe from env? Which user?) - const token = generateJwtToken({ name: 'ActivityPub', id: uuid() }) + const token = encode({ name: 'ActivityPub', id: uuid() }) // return the headers to the context so httpLink can read them return { headers: { diff --git a/src/graphql-schema.js b/src/graphql-schema.js index 5ddf08492..0c7dcc876 100644 --- a/src/graphql-schema.js +++ b/src/graphql-schema.js @@ -1,219 +1,25 @@ import fs from 'fs' import path from 'path' -import bcrypt from 'bcryptjs' -import generateJwt from './jwt/generateToken' -import uuid from 'uuid/v4' -import { fixUrl } from './middleware/fixImageUrlsMiddleware' -import { AuthenticationError } from 'apollo-server' -import { neo4jgraphql } from 'neo4j-graphql-js' -import { activityPub } from './activitypub/ActivityPub' -import as from 'activitystrea.ms' -/* -import as from 'activitystrea.ms' -import request from 'request' -*/ -const debug = require('debug')('backend:schema') +import userManagement from './resolvers/user_management.js' +import statistics from './resolvers/statistics.js' +import reports from './resolvers/reports.js' +import posts from './resolvers/posts.js' +import moderation from './resolvers/moderation.js' export const typeDefs = fs.readFileSync(process.env.GRAPHQL_SCHEMA || path.join(__dirname, 'schema.graphql')) .toString('utf-8') -export const query = (cypher, session) => { - return new Promise((resolve, reject) => { - let data = [] - session - .run(cypher) - .subscribe({ - onNext: function (record) { - let item = {} - record.keys.forEach(key => { - item[key] = record.get(key) - }) - data.push(item) - }, - onCompleted: function () { - session.close() - resolve(data) - }, - onError: function (error) { - reject(error) - } - }) - }) -} -const queryOne = (cypher, session) => { - return new Promise((resolve, reject) => { - query(cypher, session) - .then(res => { - resolve(res.length ? res.pop() : {}) - }) - .catch(err => { - reject(err) - }) - }) -} - export const resolvers = { Query: { - isLoggedIn: (parent, args, { driver, user }) => { - return Boolean(user && user.id) - }, - statistics: async (parent, args, { driver, user }) => { - return new Promise(async (resolve) => { - const session = driver.session() - const queries = { - countUsers: 'MATCH (r:User) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countUsers', - countPosts: 'MATCH (r:Post) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countPosts', - countComments: 'MATCH (r:Comment) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countComments', - countNotifications: 'MATCH (r:Notification) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countNotifications', - countOrganizations: 'MATCH (r:Organization) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countOrganizations', - countProjects: 'MATCH (r:Project) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countProjects', - countInvites: 'MATCH (r:Invite) WHERE r.wasUsed <> true OR NOT exists(r.wasUsed) RETURN COUNT(r) AS countInvites', - countFollows: 'MATCH (:User)-[r:FOLLOWS]->(:User) RETURN COUNT(r) AS countFollows', - countShouts: 'MATCH (:User)-[r:SHOUTED]->(:Post) RETURN COUNT(r) AS countShouts' - } - let data = { - countUsers: (await queryOne(queries.countUsers, session)).countUsers.low, - countPosts: (await queryOne(queries.countPosts, session)).countPosts.low, - countComments: (await queryOne(queries.countComments, session)).countComments.low, - countNotifications: (await queryOne(queries.countNotifications, session)).countNotifications.low, - countOrganizations: (await queryOne(queries.countOrganizations, session)).countOrganizations.low, - countProjects: (await queryOne(queries.countProjects, session)).countProjects.low, - countInvites: (await queryOne(queries.countInvites, session)).countInvites.low, - countFollows: (await queryOne(queries.countFollows, session)).countFollows.low, - countShouts: (await queryOne(queries.countShouts, session)).countShouts.low - } - resolve(data) - }) - } - // usersBySubstring: neo4jgraphql + ...statistics.Query, + ...userManagement.Query }, Mutation: { - signup: async (parent, { email, password }, { req }) => { - // if (data[email]) { - // throw new Error('Another User with same email exists.') - // } - // data[email] = { - // password: await bcrypt.hashSync(password, 10), - // } - - return true - }, - login: async (parent, { email, password }, { driver, req, user }) => { - // if (user && user.id) { - // throw new Error('Already logged in.') - // } - const session = driver.session() - return session.run( - 'MATCH (user:User {email: $userEmail}) ' + - 'RETURN user {.id, .slug, .name, .avatar, .locationName, .about, .email, .password, .role} as user LIMIT 1', { - userEmail: email - }) - .then(async (result) => { - session.close() - const [currentUser] = await result.records.map(function (record) { - return record.get('user') - }) - - if (currentUser && await bcrypt.compareSync(password, currentUser.password)) { - delete currentUser.password - currentUser.avatar = fixUrl(currentUser.avatar) - return Object.assign(currentUser, { - token: generateJwt(currentUser) - }) - } else throw new AuthenticationError('Incorrect email address or password.') - }) - }, - report: async (parent, { resource, description }, { driver, req, user }, resolveInfo) => { - const contextId = uuid() - const session = driver.session() - const data = { - id: contextId, - type: resource.type, - createdAt: (new Date()).toISOString(), - description: resource.description - } - await session.run( - 'CREATE (r:Report $report) ' + - 'RETURN r.id, r.type, r.description', { - report: data - } - ) - let contentType - - switch (resource.type) { - case 'post': - case 'contribution': - contentType = 'Post' - break - case 'comment': - contentType = 'Comment' - break - case 'user': - contentType = 'User' - break - } - - await session.run( - `MATCH (author:User {id: $userId}), (context:${contentType} {id: $resourceId}), (report:Report {id: $contextId}) ` + - 'MERGE (report)<-[:REPORTED]-(author) ' + - 'MERGE (context)<-[:REPORTED]-(report) ' + - 'RETURN context', { - resourceId: resource.id, - userId: user.id, - contextId: contextId - } - ) - session.close() - - // TODO: output Report compatible object - return data - }, - CreatePost: async (object, params, ctx, resolveInfo) => { - params.activityId = uuid() - const result = await neo4jgraphql(object, params, ctx, resolveInfo, false) - debug(`user = ${JSON.stringify(ctx.user, null, 2)}`) - const session = ctx.driver.session() - const author = await session.run( - 'MATCH (author:User {slug: $slug}), (post:Post {id: $postId}) ' + - 'MERGE (post)<-[:WROTE]-(author) ' + - 'RETURN author', { - slug: ctx.user.slug, - postId: result.id - }) - // debug(`result = ${JSON.stringify(author, null, 2)}`) - debug(`actorId = ${author.records[0]._fields[0].properties.actorId}`) - if (Array.isArray(author.records) && author.records.length > 0) { - const actorId = author.records[0]._fields[0].properties.actorId - const createActivity = await new Promise((resolve, reject) => { - as.create() - .id(`${actorId}/status/${params.activityId}`) - .actor(`${actorId}`) - .object( - as.article() - .id(`${actorId}/status/${result.id}`) - .content(result.content) - .to('https://www.w3.org/ns/activitystreams#Public') - .publishedNow() - .attributedTo(`${actorId}`) - ).prettyWrite((err, doc) => { - if (err) { - reject(err) - } else { - debug(doc) - const parsedDoc = JSON.parse(doc) - parsedDoc.send = true - resolve(JSON.stringify(parsedDoc)) - } - }) - }) - try { - await activityPub.sendActivity(createActivity) - } catch (e) { - debug(`error sending post activity = ${JSON.stringify(e, null, 2)}`) - } - } - } + ...userManagement.Mutation, + ...reports.Mutation, + ...moderation.Mutation, + ...posts.Mutation } } diff --git a/src/graphql-schema.spec.js b/src/graphql-schema.spec.js deleted file mode 100644 index 7aa47835f..000000000 --- a/src/graphql-schema.spec.js +++ /dev/null @@ -1,184 +0,0 @@ -import Factory from './seed/factories' -import { GraphQLClient, request } from 'graphql-request' -import jwt from 'jsonwebtoken' -import { host, login } from './jest/helpers' - -const factory = Factory() - -beforeEach(async () => { - await factory.create('User', { - email: 'test@example.org', - password: '1234' - }) -}) - -afterEach(async () => { - await factory.cleanDatabase() -}) - -describe('isLoggedIn', () => { - describe('unauthenticated', () => { - it('returns false', async () => { - const query = '{ isLoggedIn }' - await expect(request(host, query)).resolves.toEqual({ isLoggedIn: false }) - }) - }) -}) - -describe('login', () => { - const mutation = (params) => { - const { email, password } = params - return ` - mutation { - login(email:"${email}", password:"${password}"){ - token - } - }` - } - - describe('ask for a `token`', () => { - describe('with valid email/password combination', () => { - it('responds with a JWT token', async () => { - const data = await request(host, mutation({ - email: 'test@example.org', - password: '1234' - })) - const { token } = data.login - jwt.verify(token, process.env.JWT_SECRET, (err, data) => { - expect(data.email).toEqual('test@example.org') - expect(err).toBeNull() - }) - }) - }) - - describe('with a valid email but incorrect password', () => { - it('responds with "Incorrect email address or password."', async () => { - await expect( - request(host, mutation({ - email: 'test@example.org', - password: 'wrong' - })) - ).rejects.toThrow('Incorrect email address or password.') - }) - }) - - describe('with a non-existing email', () => { - it('responds with "Incorrect email address or password."', async () => { - await expect( - request(host, mutation({ - email: 'non-existent@example.org', - password: 'wrong' - })) - ).rejects.toThrow('Incorrect email address or password.') - }) - }) - }) -}) - -describe('CreatePost', () => { - describe('unauthenticated', () => { - let client - it('throws authorization error', async () => { - client = new GraphQLClient(host) - await expect(client.request(`mutation { - CreatePost( - title: "I am a post", - content: "Some content" - ) { slug } - }`)).rejects.toThrow('Not Authorised') - }) - - describe('authenticated', () => { - let headers - let response - beforeEach(async () => { - headers = await login({ email: 'test@example.org', password: '1234' }) - client = new GraphQLClient(host, { headers }) - response = await client.request(`mutation { - CreatePost( - title: "A title", - content: "Some content" - ) { title, content } - }`, { headers }) - }) - - it('creates a post', () => { - expect(response).toEqual({ CreatePost: { title: 'A title', content: 'Some content' } }) - }) - - it('assigns the authenticated user as author', async () => { - const { User } = await client.request(`{ - User(email:"test@example.org") { - contributions { - title - } - } - }`, { headers }) - expect(User).toEqual([ { contributions: [ { title: 'A title' } ] } ]) - }) - }) - }) -}) - -describe('report', () => { - beforeEach(async () => { - await factory.create('User', { - email: 'test@example.org', - password: '1234' - }) - await factory.create('User', { - id: 'u2', - name: 'abusive-user', - role: 'user', - email: 'abusive-user@example.org' - }) - }) - - afterEach(async () => { - await factory.cleanDatabase() - }) - - describe('unauthenticated', () => { - let client - it('throws authorization error', async () => { - client = new GraphQLClient(host) - await expect( - client.request(`mutation { - report( - description: "I don't like this user", - resource: { - id: "u2", - type: user - } - ) { id, createdAt } - }`) - ).rejects.toThrow('Not Authorised') - }) - - describe('authenticated', () => { - let headers - let response - beforeEach(async () => { - headers = await login({ email: 'test@example.org', password: '1234' }) - client = new GraphQLClient(host, { headers }) - response = await client.request(`mutation { - report( - description: "I don't like this user", - resource: { - id: "u2", - type: user - } - ) { id, createdAt } - }`, - { headers } - ) - }) - it('creates a report', () => { - let { id, createdAt } = response.report - expect(response).toEqual({ - report: { id, createdAt } - }) - }) - }) - }) -}) diff --git a/src/jest/helpers.js b/src/jest/helpers.js index ff6a535e2..0d358ed40 100644 --- a/src/jest/helpers.js +++ b/src/jest/helpers.js @@ -7,12 +7,10 @@ export const host = 'http://127.0.0.1:4123' export async function login ({ email, password }) { const mutation = ` mutation { - login(email:"${email}", password:"${password}"){ - token - } + login(email:"${email}", password:"${password}") }` const response = await request(host, mutation) return { - authorization: `Bearer ${response.login.token}` + authorization: `Bearer ${response.login}` } } diff --git a/src/jwt/generateToken.js b/src/jwt/generateToken.js deleted file mode 100644 index fb61bb4ac..000000000 --- a/src/jwt/generateToken.js +++ /dev/null @@ -1,17 +0,0 @@ - -import jwt from 'jsonwebtoken' -import ms from 'ms' - -// Generate an Access Token for the given User ID -export default function generateJwt (user) { - const token = jwt.sign(user, process.env.JWT_SECRET, { - expiresIn: ms('1d'), - issuer: process.env.GRAPHQL_URI, - audience: process.env.CLIENT_URI, - subject: user.id.toString() - }) - // jwt.verifySignature(token, process.env.JWT_SECRET, (err, data) => { - // console.log('token verification:', err, data) - // }) - return token -} diff --git a/src/jwt/strategy.js b/src/jwt/strategy.js deleted file mode 100644 index 5b1ea1231..000000000 --- a/src/jwt/strategy.js +++ /dev/null @@ -1,42 +0,0 @@ -import { Strategy } from 'passport-jwt' -import { fixUrl } from '../middleware/fixImageUrlsMiddleware' - -const cookieExtractor = (req) => { - var token = null - if (req && req.cookies) { - token = req.cookies['jwt'] - } - return token -} - -export default (driver) => { - const options = { - jwtFromRequest: cookieExtractor, - secretOrKey: process.env.JWT_SECRET, - issuer: process.env.GRAPHQL_URI, - audience: process.env.CLIENT_URI - } - - return new Strategy(options, - async (JWTPayload, next) => { - const session = driver.session() - const result = await session.run( - 'MATCH (user:User {id: $userId}) ' + - 'RETURN user {.id, .slug, .name, .avatar, .email, .role} as user LIMIT 1', - { - userId: JWTPayload.id - } - ) - session.close() - const [currentUser] = await result.records.map((record) => { - return record.get('user') - }) - - if (currentUser) { - currentUser.avatar = fixUrl(currentUser.avatar) - return next(null, currentUser) - } else { - return next(null, false) - } - }) -} diff --git a/src/middleware/dateTimeMiddleware.js b/src/middleware/dateTimeMiddleware.js index 97e6e2767..473dbf444 100644 --- a/src/middleware/dateTimeMiddleware.js +++ b/src/middleware/dateTimeMiddleware.js @@ -2,29 +2,21 @@ export default { Mutation: { CreateUser: async (resolve, root, args, context, info) => { args.createdAt = (new Date()).toISOString() - args.disabled = false - args.deleted = false const result = await resolve(root, args, context, info) return result }, CreatePost: async (resolve, root, args, context, info) => { args.createdAt = (new Date()).toISOString() - args.disabled = false - args.deleted = false const result = await resolve(root, args, context, info) return result }, CreateComment: async (resolve, root, args, context, info) => { args.createdAt = (new Date()).toISOString() - args.disabled = false - args.deleted = false const result = await resolve(root, args, context, info) return result }, CreateOrganization: async (resolve, root, args, context, info) => { args.createdAt = (new Date()).toISOString() - args.disabled = false - args.deleted = false const result = await resolve(root, args, context, info) return result }, diff --git a/src/middleware/permissionsMiddleware.js b/src/middleware/permissionsMiddleware.js index 1a3f04ceb..7fb6e75b8 100644 --- a/src/middleware/permissionsMiddleware.js +++ b/src/middleware/permissionsMiddleware.js @@ -1,4 +1,4 @@ -import { rule, shield, allow } from 'graphql-shield' +import { rule, shield, allow, or } from 'graphql-shield' /* * TODO: implement @@ -7,31 +7,58 @@ import { rule, shield, allow } from 'graphql-shield' const isAuthenticated = rule()(async (parent, args, ctx, info) => { return ctx.user !== null }) -/* -const isAdmin = rule()(async (parent, args, ctx, info) => { - return ctx.user.role === 'ADMIN' -}) -const isModerator = rule()(async (parent, args, ctx, info) => { - return ctx.user.role === 'MODERATOR' -}) -*/ -const isMyOwn = rule({ cache: 'no_cache' })(async (parent, args, ctx, info) => { - return ctx.user.id === parent.id +const isModerator = rule()(async (parent, args, { user }, info) => { + return user && (user.role === 'moderator' || user.role === 'admin') +}) + +const isAdmin = rule()(async (parent, args, { user }, info) => { + return user && (user.role === 'admin') +}) + +const isMyOwn = rule({ cache: 'no_cache' })(async (parent, args, context, info) => { + return context.user.id === parent.id +}) + +const onlyEnabledContent = rule({ cache: 'strict' })(async (parent, args, ctx, info) => { + const { disabled, deleted } = args + return !(disabled || deleted) +}) + +const isAuthor = rule({ cache: 'no_cache' })(async (parent, args, { user, driver }) => { + if (!user) return false + const session = driver.session() + const { id: postId } = args + const result = await session.run(` + MATCH (post:Post {id: $postId})<-[:WROTE]-(author) + RETURN author + `, { postId }) + const [author] = result.records.map((record) => { + return record.get('author') + }) + const { properties: { id: authorId } } = author + session.close() + return authorId === user.id }) // Permissions const permissions = shield({ Query: { - statistics: allow - // fruits: and(isAuthenticated, or(isAdmin, isModerator)), - // customers: and(isAuthenticated, isAdmin) + statistics: allow, + currentUser: allow, + Post: or(onlyEnabledContent, isModerator) }, Mutation: { CreatePost: isAuthenticated, - // TODO UpdatePost: isOwner, - // TODO DeletePost: isOwner, - report: isAuthenticated + UpdatePost: isAuthor, + DeletePost: isAuthor, + report: isAuthenticated, + CreateBadge: isAdmin, + UpdateBadge: isAdmin, + DeleteBadge: isAdmin, + + enable: isModerator, + disable: isModerator // addFruitToBasket: isAuthenticated // CreateUser: allow, }, @@ -39,7 +66,6 @@ const permissions = shield({ email: isMyOwn, password: isMyOwn } - // Post: isAuthenticated }) export default permissions diff --git a/src/middleware/softDeleteMiddleware.js b/src/middleware/softDeleteMiddleware.js index 79e4a7d08..0c12e7a72 100644 --- a/src/middleware/softDeleteMiddleware.js +++ b/src/middleware/softDeleteMiddleware.js @@ -1,38 +1,26 @@ +const setDefaults = (args) => { + if (typeof args.deleted !== 'boolean') { + args.deleted = false + } + if (typeof args.disabled !== 'boolean') { + args.disabled = false + } + return args +} + export default { Query: { - Post: async (resolve, root, args, context, info) => { - if (typeof args.deleted !== 'boolean') { - args.deleted = false - } - if (typeof args.disabled !== 'boolean') { - args.disabled = false - } - const result = await resolve(root, args, context, info) - return result + Post: (resolve, root, args, context, info) => { + return resolve(root, setDefaults(args), context, info) }, Comment: async (resolve, root, args, context, info) => { - if (typeof args.deleted !== 'boolean') { - args.deleted = false - } - if (typeof args.disabled !== 'boolean') { - args.disabled = false - } - const result = await resolve(root, args, context, info) - return result + return resolve(root, setDefaults(args), context, info) }, User: async (resolve, root, args, context, info) => { - if (typeof args.deleted !== 'boolean') { - args.deleted = false - } - if (typeof args.disabled !== 'boolean') { - args.disabled = false - } - // console.log('ROOT', root) - // console.log('ARGS', args) - // console.log('CONTEXT', context) - // console.log('info', info.fieldNodes[0].arguments) - const result = await resolve(root, args, context, info) - return result + return resolve(root, setDefaults(args), context, info) } + }, + Mutation: async (resolve, root, args, context, info) => { + return resolve(root, setDefaults(args), context, info) } } diff --git a/src/schema.graphql b/src/schema.graphql index eacbab55f..d566be159 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -1,22 +1,46 @@ type Query { isLoggedIn: Boolean! + "Get the currently logged in User based on the given JWT Token" + currentUser: User + "Get the latest Network Statistics" statistics: Statistics! } type Mutation { - login(email: String!, password: String!): LoggedInUser + "Get a JWT Token for the given Email and password" + login(email: String!, password: String!): String! signup(email: String!, password: String!): Boolean! report(resource: Resource!, description: String): Report -} -type LoggedInUser { - id: ID! - slug: String! - name: String! - avatar:String! - email: String! - role: String! - locationName: String - about: String - token: String! + "Shout the given Type and ID" + shout(id: ID!, type: ShoutTypeEnum): Boolean! @cypher(statement: """ + MATCH (n {id: $id})<-[:WROTE]-(wu:User), (u:User {id: $cypherParams.currentUserId}) + WHERE $type IN labels(n) AND NOT wu.id = $cypherParams.currentUserId + MERGE (u)-[r:SHOUTED]->(n) + RETURN COUNT(r) > 0 + """) + "Unshout the given Type and ID" + unshout(id: ID!, type: ShoutTypeEnum): Boolean! @cypher(statement: """ + MATCH (:User {id: $cypherParams.currentUserId})-[r:SHOUTED]->(n {id: $id}) + WHERE $type IN labels(n) + DELETE r + RETURN COUNT(r) > 0 + """) + + "Follow the given Type and ID" + follow(id: ID!, type: FollowTypeEnum): Boolean! @cypher(statement: """ + MATCH (n {id: $id}), (u:User {id: $cypherParams.currentUserId}) + WHERE $type IN labels(n) AND NOT $id = $cypherParams.currentUserId + MERGE (u)-[r:FOLLOWS]->(n) + RETURN COUNT(r) > 0 + """) + "Unfollow the given Type and ID" + unfollow(id: ID!, type: FollowTypeEnum): Boolean! @cypher(statement: """ + MATCH (:User {id: $cypherParams.currentUserId})-[r:FOLLOWS]->(n {id: $id}) + WHERE $type IN labels(n) + DELETE r + RETURN COUNT(r) > 0 + """) + disable(resource: Resource!): Boolean! + enable(resource: Resource!): Boolean! } type Statistics { @@ -85,6 +109,7 @@ type User { avatar: String deleted: Boolean disabled: Boolean + disabledBy: User @relation(name: "DISABLED", direction: "IN") role: UserGroupEnum publicKey: String privateKey: String @@ -97,13 +122,19 @@ type User { updatedAt: String friends: [User]! @relation(name: "FRIENDS", direction: "BOTH") - friendsCount: Int! @cypher(statement: "MATCH (this)<-[:FRIENDS]->(r:User) RETURN COUNT(r)") + friendsCount: Int! @cypher(statement: "MATCH (this)<-[:FRIENDS]->(r:User) RETURN COUNT(DISTINCT r)") following: [User]! @relation(name: "FOLLOWS", direction: "OUT") - followingCount: Int! @cypher(statement: "MATCH (this)-[:FOLLOWS]->(r:User) RETURN COUNT(r)") + followingCount: Int! @cypher(statement: "MATCH (this)-[:FOLLOWS]->(r:User) RETURN COUNT(DISTINCT r)") followedBy: [User]! @relation(name: "FOLLOWS", direction: "IN") - followedByCount: Int! @cypher(statement: "MATCH (this)<-[:FOLLOWS]-(r:User) RETURN COUNT(r)") + followedByCount: Int! @cypher(statement: "MATCH (this)<-[:FOLLOWS]-(r:User) RETURN COUNT(DISTINCT r)") + + "Is the currently logged in user following that user?" + followedByCurrentUser: Boolean! @cypher(statement: """ + MATCH (this)<-[:FOLLOWS]-(u:User {id: $cypherParams.currentUserId}) + RETURN COUNT(u) >= 1 + """) #contributions: [WrittenPost]! #contributions2(first: Int = 10, offset: Int = 0): [WrittenPost2]! @@ -122,7 +153,7 @@ type User { commentsCount: Int! @cypher(statement: "MATCH (this)-[:WROTE]->(r:Comment) WHERE NOT r.deleted = true RETURN COUNT(r)") shouted: [Post]! @relation(name: "SHOUTED", direction: "OUT") - shoutedCount: Int! @cypher(statement: "MATCH (this)-[:SHOUTED]->(r:Post) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(r)") + shoutedCount: Int! @cypher(statement: "MATCH (this)-[:SHOUTED]->(r:Post) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(DISTINCT r)") organizationsCreated: [Organization] @relation(name: "CREATED_ORGA", direction: "OUT") organizationsOwned: [Organization] @relation(name: "OWNING_ORGA", direction: "OUT") @@ -147,6 +178,7 @@ type Post { visibility: VisibilityEnum deleted: Boolean disabled: Boolean + disabledBy: User @relation(name: "DISABLED", direction: "IN") createdAt: String updatedAt: String @@ -163,7 +195,13 @@ type Post { commentsCount: Int! @cypher(statement: "MATCH (this)<-[:COMMENTS]-(r:Comment) RETURN COUNT(r)") shoutedBy: [User]! @relation(name: "SHOUTED", direction: "IN") - shoutedCount: Int! @cypher(statement: "MATCH (this)<-[:SHOUTED]-(r:User) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(r)") + shoutedCount: Int! @cypher(statement: "MATCH (this)<-[:SHOUTED]-(r:User) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(DISTINCT r)") + + "Has the currently logged in user shouted that post?" + shoutedByCurrentUser: Boolean! @cypher(statement: """ + MATCH (this)<-[:SHOUTED]-(u:User {id: $cypherParams.currentUserId}) + RETURN COUNT(u) >= 1 + """) } type Comment { @@ -177,6 +215,7 @@ type Comment { updatedAt: String deleted: Boolean disabled: Boolean + disabledBy: User @relation(name: "DISABLED", direction: "IN") } type Report { @@ -217,6 +256,16 @@ enum BadgeStatusEnum { permanent temporary } +enum ShoutTypeEnum { + Post + Organization + Project +} +enum FollowTypeEnum { + User + Organization + Project +} type Organization { id: ID! @@ -238,7 +287,7 @@ type Tag { name: String! taggedPosts: [Post]! @relation(name: "TAGGED", direction: "IN") taggedOrganizations: [Organization]! @relation(name: "TAGGED", direction: "IN") - taggedCount: Int! @cypher(statement: "MATCH (this)<-[:TAGGED]-(p) RETURN COUNT(p)") + taggedCount: Int! @cypher(statement: "MATCH (this)<-[:TAGGED]-(p) RETURN COUNT(DISTINCT p)") taggedCountUnique: Int! @cypher(statement: "MATCH (this)<-[:TAGGED]-(p)<-[:WROTE]-(u:User) RETURN COUNT(DISTINCT u)") deleted: Boolean disabled: Boolean diff --git a/src/seed/factories/badges.js b/src/seed/factories/badges.js index b34442521..e24a67c21 100644 --- a/src/seed/factories/badges.js +++ b/src/seed/factories/badges.js @@ -10,14 +10,14 @@ export default function (params) { } = params return ` - mutation { - CreateBadge( - id: "${id}", - key: "${key}", - type: ${type}, - status: ${status}, - icon: "${icon}" - ) { id } - } + mutation { + CreateBadge( + id: "${id}", + key: "${key}", + type: ${type}, + status: ${status}, + icon: "${icon}" + ) { id } + } ` } diff --git a/src/seed/factories/index.js b/src/seed/factories/index.js index d9bbd700c..2629ce8b6 100644 --- a/src/seed/factories/index.js +++ b/src/seed/factories/index.js @@ -15,13 +15,11 @@ export const seedServerHost = 'http://127.0.0.1:4001' const authenticatedHeaders = async ({ email, password }, host) => { const mutation = ` mutation { - login(email:"${email}", password:"${password}"){ - token - } + login(email:"${email}", password:"${password}") }` const response = await request(host, mutation) return { - authorization: `Bearer ${response.login.token}` + authorization: `Bearer ${response.login}` } } const factories = { @@ -88,6 +86,36 @@ export default function Factory (options = {}) { this.lastResponse = await this.graphQLClient.request(mutation) return this }, + async mutate (mutation, variables) { + this.lastResponse = await this.graphQLClient.request(mutation, variables) + return this + }, + async shout (properties) { + const { id, type } = properties + const mutation = ` + mutation { + shout( + id: "${id}", + type: ${type} + ) + } + ` + this.lastResponse = await this.graphQLClient.request(mutation) + return this + }, + async follow (properties) { + const { id, type } = properties + const mutation = ` + mutation { + follow( + id: "${id}", + type: ${type} + ) + } + ` + this.lastResponse = await this.graphQLClient.request(mutation) + return this + }, async cleanDatabase () { this.lastResponse = await cleanDatabase({ driver: this.neo4jDriver }) return this @@ -96,6 +124,7 @@ export default function Factory (options = {}) { result.authenticateAs.bind(result) result.create.bind(result) result.relate.bind(result) + result.mutate.bind(result) result.cleanDatabase.bind(result) return result } diff --git a/src/seed/factories/organizations.js b/src/seed/factories/organizations.js index 783edac26..e0b2e52d4 100644 --- a/src/seed/factories/organizations.js +++ b/src/seed/factories/organizations.js @@ -4,7 +4,7 @@ import uuid from 'uuid/v4' export default function create (params) { const { id = uuid(), - name = faker.comany.companyName(), + name = faker.company.companyName(), description = faker.company.catchPhrase(), disabled = false, deleted = false diff --git a/src/seed/factories/posts.js b/src/seed/factories/posts.js index d96cf4f73..e2bc2ab66 100644 --- a/src/seed/factories/posts.js +++ b/src/seed/factories/posts.js @@ -14,7 +14,6 @@ export default function (params) { ].join('. '), image = faker.image.image(), visibility = 'public', - disabled = false, deleted = false } = params @@ -26,7 +25,6 @@ export default function (params) { content: "${content}", image: "${image}", visibility: ${visibility}, - disabled: ${disabled}, deleted: ${deleted} ) { title, content } } diff --git a/src/seed/factories/users.js b/src/seed/factories/users.js index 8e0ee693c..c27b2b1ce 100644 --- a/src/seed/factories/users.js +++ b/src/seed/factories/users.js @@ -25,10 +25,13 @@ export default function create (params) { disabled: ${disabled}, deleted: ${deleted} ) { + id name email avatar role + deleted + disabled } } ` diff --git a/src/seed/seed-db.js b/src/seed/seed-db.js index b2ee8fbdb..b16e9f323 100644 --- a/src/seed/seed-db.js +++ b/src/seed/seed-db.js @@ -23,6 +23,15 @@ import Factory from './factories' f.create('User', { id: 'u7', name: 'Dagobert', role: 'user', email: 'dagobert@example.org' }) ]) + const [ asAdmin, asModerator, asUser, asTick, asTrick, asTrack ] = await Promise.all([ + Factory().authenticateAs({ email: 'admin@example.org', password: '1234' }), + Factory().authenticateAs({ email: 'moderator@example.org', password: '1234' }), + Factory().authenticateAs({ email: 'user@example.org', password: '1234' }), + Factory().authenticateAs({ email: 'tick@example.org', password: '1234' }), + Factory().authenticateAs({ email: 'trick@example.org', password: '1234' }), + Factory().authenticateAs({ email: 'track@example.org', password: '1234' }) + ]) + await Promise.all([ f.relate('User', 'Badges', { from: 'b6', to: 'u1' }), f.relate('User', 'Badges', { from: 'b5', to: 'u2' }), @@ -30,12 +39,6 @@ import Factory from './factories' f.relate('User', 'Badges', { from: 'b3', to: 'u4' }), f.relate('User', 'Badges', { from: 'b2', to: 'u5' }), f.relate('User', 'Badges', { from: 'b1', to: 'u6' }), - f.relate('User', 'Following', { from: 'u1', to: 'u2' }), - f.relate('User', 'Following', { from: 'u2', to: 'u3' }), - f.relate('User', 'Following', { from: 'u3', to: 'u4' }), - f.relate('User', 'Following', { from: 'u4', to: 'u5' }), - f.relate('User', 'Following', { from: 'u5', to: 'u6' }), - f.relate('User', 'Following', { from: 'u6', to: 'u7' }), f.relate('User', 'Friends', { from: 'u1', to: 'u2' }), f.relate('User', 'Friends', { from: 'u1', to: 'u3' }), f.relate('User', 'Friends', { from: 'u2', to: 'u3' }), @@ -44,6 +47,21 @@ import Factory from './factories' f.relate('User', 'Blacklisted', { from: 'u7', to: 'u6' }) ]) + await Promise.all([ + asAdmin + .follow({ id: 'u3', type: 'User' }), + asModerator + .follow({ id: 'u4', type: 'User' }), + asUser + .follow({ id: 'u4', type: 'User' }), + asTick + .follow({ id: 'u6', type: 'User' }), + asTrick + .follow({ id: 'u4', type: 'User' }), + asTrack + .follow({ id: 'u3', type: 'User' }) + ]) + await Promise.all([ f.create('Category', { id: 'cat1', name: 'Just For Fun', slug: 'justforfun', icon: 'smile' }), f.create('Category', { id: 'cat2', name: 'Happyness & Values', slug: 'happyness-values', icon: 'heart-o' }), @@ -70,19 +88,10 @@ import Factory from './factories' f.create('Tag', { id: 't4', name: 'Freiheit' }) ]) - const [ asAdmin, asModerator, asUser, asTick, asTrick, asTrack ] = await Promise.all([ - Factory().authenticateAs({ email: 'admin@example.org', password: '1234' }), - Factory().authenticateAs({ email: 'moderator@example.org', password: '1234' }), - Factory().authenticateAs({ email: 'user@example.org', password: '1234' }), - Factory().authenticateAs({ email: 'tick@example.org', password: '1234' }), - Factory().authenticateAs({ email: 'trick@example.org', password: '1234' }), - Factory().authenticateAs({ email: 'track@example.org', password: '1234' }) - ]) - await Promise.all([ asAdmin.create('Post', { id: 'p0' }), asModerator.create('Post', { id: 'p1' }), - asUser.create('Post', { id: 'p2' }), + asUser.create('Post', { id: 'p2', deleted: true }), asTick.create('Post', { id: 'p3' }), asTrick.create('Post', { id: 'p4' }), asTrack.create('Post', { id: 'p5' }), @@ -98,6 +107,16 @@ import Factory from './factories' asTick.create('Post', { id: 'p15' }) ]) + const disableMutation = ` + mutation { + disable(resource: { + id: "p11" + type: contribution + }) + } + ` + await asModerator.mutate(disableMutation) + await Promise.all([ f.relate('Post', 'Categories', { from: 'p0', to: 'cat16' }), f.relate('Post', 'Categories', { from: 'p1', to: 'cat1' }), @@ -133,13 +152,26 @@ import Factory from './factories' f.relate('Post', 'Tags', { from: 'p14', to: 't2' }), f.relate('Post', 'Tags', { from: 'p15', to: 't3' }) ]) + await Promise.all([ - f.relate('User', 'Shouted', { from: 'u1', to: 'p2' }), - f.relate('User', 'Shouted', { from: 'u1', to: 'p3' }), - f.relate('User', 'Shouted', { from: 'u2', to: 'p1' }), - f.relate('User', 'Shouted', { from: 'u3', to: 'p1' }), - f.relate('User', 'Shouted', { from: 'u3', to: 'p4' }), - f.relate('User', 'Shouted', { from: 'u4', to: 'p1' }) + asAdmin + .shout({ id: 'p2', type: 'Post' }), + asAdmin + .shout({ id: 'p6', type: 'Post' }), + asModerator + .shout({ id: 'p0', type: 'Post' }), + asModerator + .shout({ id: 'p6', type: 'Post' }), + asUser + .shout({ id: 'p6', type: 'Post' }), + asUser + .shout({ id: 'p7', type: 'Post' }), + asTick + .shout({ id: 'p8', type: 'Post' }), + asTick + .shout({ id: 'p9', type: 'Post' }), + asTrack + .shout({ id: 'p10', type: 'Post' }) ]) await Promise.all([ diff --git a/src/server.js b/src/server.js index 10fdf291c..c546c74c3 100644 --- a/src/server.js +++ b/src/server.js @@ -8,11 +8,8 @@ import middleware from './middleware' import applyDirectives from './bootstrap/directives' import applyScalars from './bootstrap/scalars' import { getDriver } from './bootstrap/neo4j' - -import passport from 'passport' -import jwtStrategy from './jwt/strategy' -import jwt from 'jsonwebtoken' import helmet from 'helmet' +import decode from './jwt/decode' dotenv.config() // check env and warn @@ -43,20 +40,17 @@ schema = applyScalars(applyDirectives(schema)) const createServer = (options) => { const defaults = { - context: async (req) => { - const payload = { + context: async ({ request }) => { + const authorizationHeader = request.headers.authorization || '' + const user = await decode(driver, authorizationHeader) + return { driver, - user: null, - req: req.request + user, + req: request, + cypherParams: { + currentUserId: user ? user.id : null + } } - try { - const token = payload.req.headers.authorization.replace('Bearer ', '') - payload.user = await jwt.verify(token, process.env.JWT_SECRET) - } catch (err) { - // nothing - } - - return payload }, schema: schema, debug: debug, @@ -66,12 +60,8 @@ const createServer = (options) => { } const server = new GraphQLServer(Object.assign({}, defaults, options)) - passport.use('jwt', jwtStrategy(driver)) server.express.use(helmet()) - server.express.use(passport.initialize()) server.express.use(express.static('public')) - - server.express.post('/graphql', passport.authenticate(['jwt'], { session: false })) return server } diff --git a/yarn.lock b/yarn.lock index 52fb19cde..b2224310d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -38,7 +38,7 @@ dependencies: "@babel/highlight" "^7.0.0" -"@babel/core@^7.1.0", "@babel/core@~7.3.3": +"@babel/core@^7.1.0": version "7.3.3" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.3.3.tgz#d090d157b7c5060d05a05acaebc048bd2b037947" integrity sha512-w445QGI2qd0E0GlSnq6huRZWPMmQGCp5gd5ZWS4hagn0EiwzxD5QMFkpchyusAyVC1n27OKXzQ0/88aVU9n4xQ== @@ -58,6 +58,26 @@ semver "^5.4.1" source-map "^0.5.0" +"@babel/core@~7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.3.4.tgz#921a5a13746c21e32445bf0798680e9d11a6530b" + integrity sha512-jRsuseXBo9pN197KnDwhhaaBzyZr2oIcLHHTt2oDdQrej5Qp57dCCJafWx5ivU8/alEYDpssYqv1MUqcxwQlrA== + dependencies: + "@babel/code-frame" "^7.0.0" + "@babel/generator" "^7.3.4" + "@babel/helpers" "^7.2.0" + "@babel/parser" "^7.3.4" + "@babel/template" "^7.2.2" + "@babel/traverse" "^7.3.4" + "@babel/types" "^7.3.4" + convert-source-map "^1.1.0" + debug "^4.1.0" + json5 "^2.1.0" + lodash "^4.17.11" + resolve "^1.3.2" + semver "^5.4.1" + source-map "^0.5.0" + "@babel/generator@^7.0.0", "@babel/generator@^7.2.2", "@babel/generator@^7.3.3": version "7.3.3" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.3.3.tgz#185962ade59a52e00ca2bdfcfd1d58e528d4e39e" @@ -69,6 +89,17 @@ source-map "^0.5.0" trim-right "^1.0.1" +"@babel/generator@^7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.3.4.tgz#9aa48c1989257877a9d971296e5b73bfe72e446e" + integrity sha512-8EXhHRFqlVVWXPezBW5keTiQi/rJMQTg/Y9uVCEZ0CAF3PKtCCaVRnp64Ii1ujhkoDhhF1fVsImoN4yJ2uz4Wg== + dependencies: + "@babel/types" "^7.3.4" + jsesc "^2.5.1" + lodash "^4.17.11" + source-map "^0.5.0" + trim-right "^1.0.1" + "@babel/helper-annotate-as-pure@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.0.0.tgz#323d39dd0b50e10c7c06ca7d7638e6864d8c5c32" @@ -199,6 +230,16 @@ "@babel/traverse" "^7.1.0" "@babel/types" "^7.0.0" +"@babel/helper-replace-supers@^7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.3.4.tgz#a795208e9b911a6eeb08e5891faacf06e7013e13" + integrity sha512-pvObL9WVf2ADs+ePg0jrqlhHoxRXlOa+SHRHzAXIz2xkYuOHfGl+fKxPMaS4Fq+uje8JQPobnertBBvyrWnQ1A== + dependencies: + "@babel/helper-member-expression-to-functions" "^7.0.0" + "@babel/helper-optimise-call-expression" "^7.0.0" + "@babel/traverse" "^7.3.4" + "@babel/types" "^7.3.4" + "@babel/helper-simple-access@^7.1.0": version "7.1.0" resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.1.0.tgz#65eeb954c8c245beaa4e859da6188f39d71e585c" @@ -258,6 +299,11 @@ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.3.3.tgz#092d450db02bdb6ccb1ca8ffd47d8774a91aef87" integrity sha512-xsH1CJoln2r74hR+y7cg2B5JCPaTh+Hd+EbBRk9nWGSNspuo6krjhX0Om6RnRQuIvFq8wVXCLKH3kwKDYhanSg== +"@babel/parser@^7.1.0", "@babel/parser@^7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.3.4.tgz#a43357e4bbf4b92a437fb9e465c192848287f27c" + integrity sha512-tXZCqWtlOOP4wgCp6RjRvLmfuhnqTLy9VHwRochJBCP2nDm27JnnuFEnXFASVyQNHk36jD1tAammsCEEqgscIQ== + "@babel/plugin-proposal-async-generator-functions@^7.2.0": version "7.2.0" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.2.0.tgz#b289b306669dce4ad20b0252889a15768c9d417e" @@ -275,10 +321,10 @@ "@babel/helper-plugin-utils" "^7.0.0" "@babel/plugin-syntax-json-strings" "^7.2.0" -"@babel/plugin-proposal-object-rest-spread@^7.3.1": - version "7.3.2" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.3.2.tgz#6d1859882d4d778578e41f82cc5d7bf3d5daf6c1" - integrity sha512-DjeMS+J2+lpANkYLLO+m6GjoTMygYglKmRe6cDTbFv3L9i6mmiE8fe6B8MtCSLZpVXscD5kn7s6SgtHrDoBWoA== +"@babel/plugin-proposal-object-rest-spread@^7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.3.4.tgz#47f73cf7f2a721aad5c0261205405c642e424654" + integrity sha512-j7VQmbbkA+qrzNqbKHrBsW3ddFnOeva6wzSe/zB7T+xaxGc+RCpwo44wCmRixAIGRoIpmVgvzFzNJqQcO3/9RA== dependencies: "@babel/helper-plugin-utils" "^7.0.0" "@babel/plugin-syntax-object-rest-spread" "^7.2.0" @@ -350,10 +396,10 @@ dependencies: "@babel/helper-plugin-utils" "^7.0.0" -"@babel/plugin-transform-async-to-generator@^7.2.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.2.0.tgz#68b8a438663e88519e65b776f8938f3445b1a2ff" - integrity sha512-CEHzg4g5UraReozI9D4fblBYABs7IM6UerAVG7EJVrTLC5keh00aEuLUT+O40+mJCEzaXkYfTCUKIyeDfMOFFQ== +"@babel/plugin-transform-async-to-generator@^7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.3.4.tgz#4e45408d3c3da231c0e7b823f407a53a7eb3048c" + integrity sha512-Y7nCzv2fw/jEZ9f678MuKdMo99MFDJMT/PvD9LisrR5JDFcJH6vYeH6RnjVt3p5tceyGRvTtEN0VOlU+rgHZjA== dependencies: "@babel/helper-module-imports" "^7.0.0" "@babel/helper-plugin-utils" "^7.0.0" @@ -366,25 +412,25 @@ dependencies: "@babel/helper-plugin-utils" "^7.0.0" -"@babel/plugin-transform-block-scoping@^7.2.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.2.0.tgz#f17c49d91eedbcdf5dd50597d16f5f2f770132d4" - integrity sha512-vDTgf19ZEV6mx35yiPJe4fS02mPQUUcBNwWQSZFXSzTSbsJFQvHt7DqyS3LK8oOWALFOsJ+8bbqBgkirZteD5Q== +"@babel/plugin-transform-block-scoping@^7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.3.4.tgz#5c22c339de234076eee96c8783b2fed61202c5c4" + integrity sha512-blRr2O8IOZLAOJklXLV4WhcEzpYafYQKSGT3+R26lWG41u/FODJuBggehtOwilVAcFu393v3OFj+HmaE6tVjhA== dependencies: "@babel/helper-plugin-utils" "^7.0.0" - lodash "^4.17.10" + lodash "^4.17.11" -"@babel/plugin-transform-classes@^7.2.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.2.0.tgz#374f8876075d7d21fea55aeb5c53561259163f96" - integrity sha512-aPCEkrhJYebDXcGTAP+cdUENkH7zqOlgbKwLbghjjHpJRJBWM/FSlCjMoPGA8oUdiMfOrk3+8EFPLLb5r7zj2w== +"@babel/plugin-transform-classes@^7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.3.4.tgz#dc173cb999c6c5297e0b5f2277fdaaec3739d0cc" + integrity sha512-J9fAvCFBkXEvBimgYxCjvaVDzL6thk0j0dBvCeZmIUDBwyt+nv6HfbImsSrWsYXfDNDivyANgJlFXDUWRTZBuA== dependencies: "@babel/helper-annotate-as-pure" "^7.0.0" "@babel/helper-define-map" "^7.1.0" "@babel/helper-function-name" "^7.1.0" "@babel/helper-optimise-call-expression" "^7.0.0" "@babel/helper-plugin-utils" "^7.0.0" - "@babel/helper-replace-supers" "^7.1.0" + "@babel/helper-replace-supers" "^7.3.4" "@babel/helper-split-export-declaration" "^7.0.0" globals "^11.1.0" @@ -465,10 +511,10 @@ "@babel/helper-plugin-utils" "^7.0.0" "@babel/helper-simple-access" "^7.1.0" -"@babel/plugin-transform-modules-systemjs@^7.2.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.2.0.tgz#912bfe9e5ff982924c81d0937c92d24994bb9068" - integrity sha512-aYJwpAhoK9a+1+O625WIjvMY11wkB/ok0WClVwmeo3mCjcNRjt+/8gHWrB5i+00mUju0gWsBkQnPpdvQ7PImmQ== +"@babel/plugin-transform-modules-systemjs@^7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.3.4.tgz#813b34cd9acb6ba70a84939f3680be0eb2e58861" + integrity sha512-VZ4+jlGOF36S7TjKs8g4ojp4MEI+ebCQZdswWb/T9I4X84j8OtFAyjXjt/M16iIm5RIZn0UMQgg/VgIwo/87vw== dependencies: "@babel/helper-hoist-variables" "^7.0.0" "@babel/helper-plugin-utils" "^7.0.0" @@ -512,12 +558,12 @@ "@babel/helper-get-function-arity" "^7.0.0" "@babel/helper-plugin-utils" "^7.0.0" -"@babel/plugin-transform-regenerator@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.0.0.tgz#5b41686b4ed40bef874d7ed6a84bdd849c13e0c1" - integrity sha512-sj2qzsEx8KDVv1QuJc/dEfilkg3RRPvPYx/VnKLtItVQRWt1Wqf5eVCOLZm29CiGFfYYsA3VPjfizTCV0S0Dlw== +"@babel/plugin-transform-regenerator@^7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.3.4.tgz#1601655c362f5b38eead6a52631f5106b29fa46a" + integrity sha512-hvJg8EReQvXT6G9H2MvNPXkv9zK36Vxa1+csAVTpE1J3j0zlHplw76uudEbJxgvqZzAq9Yh45FLD4pk5mKRFQA== dependencies: - regenerator-transform "^0.13.3" + regenerator-transform "^0.13.4" "@babel/plugin-transform-shorthand-properties@^7.2.0": version "7.2.0" @@ -581,16 +627,16 @@ core-js "^2.5.7" regenerator-runtime "^0.12.0" -"@babel/preset-env@~7.3.1": - version "7.3.1" - resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.3.1.tgz#389e8ca6b17ae67aaf9a2111665030be923515db" - integrity sha512-FHKrD6Dxf30e8xgHQO0zJZpUPfVZg+Xwgz5/RdSWCbza9QLNk4Qbp40ctRoqDxml3O8RMzB1DU55SXeDG6PqHQ== +"@babel/preset-env@~7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.3.4.tgz#887cf38b6d23c82f19b5135298bdb160062e33e1" + integrity sha512-2mwqfYMK8weA0g0uBKOt4FE3iEodiHy9/CW0b+nWXcbL+pGzLx8ESYc+j9IIxr6LTDHWKgPm71i9smo02bw+gA== dependencies: "@babel/helper-module-imports" "^7.0.0" "@babel/helper-plugin-utils" "^7.0.0" "@babel/plugin-proposal-async-generator-functions" "^7.2.0" "@babel/plugin-proposal-json-strings" "^7.2.0" - "@babel/plugin-proposal-object-rest-spread" "^7.3.1" + "@babel/plugin-proposal-object-rest-spread" "^7.3.4" "@babel/plugin-proposal-optional-catch-binding" "^7.2.0" "@babel/plugin-proposal-unicode-property-regex" "^7.2.0" "@babel/plugin-syntax-async-generators" "^7.2.0" @@ -598,10 +644,10 @@ "@babel/plugin-syntax-object-rest-spread" "^7.2.0" "@babel/plugin-syntax-optional-catch-binding" "^7.2.0" "@babel/plugin-transform-arrow-functions" "^7.2.0" - "@babel/plugin-transform-async-to-generator" "^7.2.0" + "@babel/plugin-transform-async-to-generator" "^7.3.4" "@babel/plugin-transform-block-scoped-functions" "^7.2.0" - "@babel/plugin-transform-block-scoping" "^7.2.0" - "@babel/plugin-transform-classes" "^7.2.0" + "@babel/plugin-transform-block-scoping" "^7.3.4" + "@babel/plugin-transform-classes" "^7.3.4" "@babel/plugin-transform-computed-properties" "^7.2.0" "@babel/plugin-transform-destructuring" "^7.2.0" "@babel/plugin-transform-dotall-regex" "^7.2.0" @@ -612,13 +658,13 @@ "@babel/plugin-transform-literals" "^7.2.0" "@babel/plugin-transform-modules-amd" "^7.2.0" "@babel/plugin-transform-modules-commonjs" "^7.2.0" - "@babel/plugin-transform-modules-systemjs" "^7.2.0" + "@babel/plugin-transform-modules-systemjs" "^7.3.4" "@babel/plugin-transform-modules-umd" "^7.2.0" "@babel/plugin-transform-named-capturing-groups-regex" "^7.3.0" "@babel/plugin-transform-new-target" "^7.0.0" "@babel/plugin-transform-object-super" "^7.2.0" "@babel/plugin-transform-parameters" "^7.2.0" - "@babel/plugin-transform-regenerator" "^7.0.0" + "@babel/plugin-transform-regenerator" "^7.3.4" "@babel/plugin-transform-shorthand-properties" "^7.2.0" "@babel/plugin-transform-spread" "^7.2.0" "@babel/plugin-transform-sticky-regex" "^7.2.0" @@ -674,6 +720,21 @@ globals "^11.1.0" lodash "^4.17.10" +"@babel/traverse@^7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.3.4.tgz#1330aab72234f8dea091b08c4f8b9d05c7119e06" + integrity sha512-TvTHKp6471OYEcE/91uWmhR6PrrYywQntCHSaZ8CM8Vmp+pjAusal4nGB2WCCQd0rvI7nOMKn9GnbcvTUz3/ZQ== + dependencies: + "@babel/code-frame" "^7.0.0" + "@babel/generator" "^7.3.4" + "@babel/helper-function-name" "^7.1.0" + "@babel/helper-split-export-declaration" "^7.0.0" + "@babel/parser" "^7.3.4" + "@babel/types" "^7.3.4" + debug "^4.1.0" + globals "^11.1.0" + lodash "^4.17.11" + "@babel/types@^7.0.0", "@babel/types@^7.2.0", "@babel/types@^7.2.2", "@babel/types@^7.3.3": version "7.3.3" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.3.3.tgz#6c44d1cdac2a7625b624216657d5bc6c107ab436" @@ -683,6 +744,160 @@ lodash "^4.17.11" to-fast-properties "^2.0.0" +"@babel/types@^7.3.0", "@babel/types@^7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.3.4.tgz#bf482eaeaffb367a28abbf9357a94963235d90ed" + integrity sha512-WEkp8MsLftM7O/ty580wAmZzN1nDmCACc5+jFzUt+GUFNNIi3LdRlueYz0YIlmJhlZx1QYDMZL5vdWCL0fNjFQ== + dependencies: + esutils "^2.0.2" + lodash "^4.17.11" + to-fast-properties "^2.0.0" + +"@cnakazawa/watch@^1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@cnakazawa/watch/-/watch-1.0.3.tgz#099139eaec7ebf07a27c1786a3ff64f39464d2ef" + integrity sha512-r5160ogAvGyHsal38Kux7YYtodEKOj89RGb28ht1jh3SJb08VwRwAKKJL0bGb04Zd/3r9FL3BFIc3bBidYffCA== + dependencies: + exec-sh "^0.3.2" + minimist "^1.2.0" + +"@jest/console@^24.3.0": + version "24.3.0" + resolved "https://registry.yarnpkg.com/@jest/console/-/console-24.3.0.tgz#7bd920d250988ba0bf1352c4493a48e1cb97671e" + integrity sha512-NaCty/OOei6rSDcbPdMiCbYCI0KGFGPgGO6B09lwWt5QTxnkuhKYET9El5u5z1GAcSxkQmSMtM63e24YabCWqA== + dependencies: + "@jest/source-map" "^24.3.0" + "@types/node" "*" + chalk "^2.0.1" + slash "^2.0.0" + +"@jest/core@^24.3.1": + version "24.3.1" + resolved "https://registry.yarnpkg.com/@jest/core/-/core-24.3.1.tgz#9811596d9fcc6dbb3d4062c67e4c4867bc061585" + integrity sha512-orucOIBKfXgm1IJirtPT0ToprqDVGYKUNJKNc9a6v1Lww6qLPq+xj5OfxyhpJb2rWOgzEkATW1bfZzg3oqV70w== + dependencies: + "@jest/console" "^24.3.0" + "@jest/reporters" "^24.3.1" + "@jest/test-result" "^24.3.0" + "@jest/transform" "^24.3.1" + "@jest/types" "^24.3.0" + ansi-escapes "^3.0.0" + chalk "^2.0.1" + exit "^0.1.2" + graceful-fs "^4.1.15" + jest-changed-files "^24.3.0" + jest-config "^24.3.1" + jest-haste-map "^24.3.1" + jest-message-util "^24.3.0" + jest-regex-util "^24.3.0" + jest-resolve-dependencies "^24.3.1" + jest-runner "^24.3.1" + jest-runtime "^24.3.1" + jest-snapshot "^24.3.1" + jest-util "^24.3.0" + jest-validate "^24.3.1" + jest-watcher "^24.3.0" + micromatch "^3.1.10" + p-each-series "^1.0.0" + pirates "^4.0.1" + realpath-native "^1.1.0" + rimraf "^2.5.4" + strip-ansi "^5.0.0" + +"@jest/environment@^24.3.1": + version "24.3.1" + resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-24.3.1.tgz#1fbda3ec8fb8ffbaee665d314da91d662227e11e" + integrity sha512-M8bqEkQqPwZVhMMFMqqCnzqIZtuM5vDMfFQ9ZvnEfRT+2T1zTA4UAOH/V4HagEi6S3BCd/mdxFdYmPgXf7GKCA== + dependencies: + "@jest/fake-timers" "^24.3.0" + "@jest/transform" "^24.3.1" + "@jest/types" "^24.3.0" + "@types/node" "*" + jest-mock "^24.3.0" + +"@jest/fake-timers@^24.3.0": + version "24.3.0" + resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-24.3.0.tgz#0a7f8b877b78780c3fa5c3f8683cc0aaf9488331" + integrity sha512-rHwVI17dGMHxHzfAhnZ04+wFznjFfZ246QugeBnbiYr7/bDosPD2P1qeNjWnJUUcfl0HpS6kkr+OB/mqSJxQFg== + dependencies: + "@jest/types" "^24.3.0" + "@types/node" "*" + jest-message-util "^24.3.0" + jest-mock "^24.3.0" + +"@jest/reporters@^24.3.1": + version "24.3.1" + resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-24.3.1.tgz#68e4abc8d4233acd0dd87287f3bd270d81066248" + integrity sha512-jEIDJcvk20ReUW1Iqb+prlAcFV+kfFhQ/01poCq8X9As7/l/2y1GqVwJ3+6SaPTZuCXh0d0LVDy86zDAa8zlVA== + dependencies: + "@jest/environment" "^24.3.1" + "@jest/test-result" "^24.3.0" + "@jest/transform" "^24.3.1" + "@jest/types" "^24.3.0" + chalk "^2.0.1" + exit "^0.1.2" + glob "^7.1.2" + istanbul-api "^2.1.1" + istanbul-lib-coverage "^2.0.2" + istanbul-lib-instrument "^3.0.1" + istanbul-lib-source-maps "^3.0.1" + jest-haste-map "^24.3.1" + jest-resolve "^24.3.1" + jest-runtime "^24.3.1" + jest-util "^24.3.0" + jest-worker "^24.3.1" + node-notifier "^5.2.1" + slash "^2.0.0" + source-map "^0.6.0" + string-length "^2.0.0" + +"@jest/source-map@^24.3.0": + version "24.3.0" + resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-24.3.0.tgz#563be3aa4d224caf65ff77edc95cd1ca4da67f28" + integrity sha512-zALZt1t2ou8le/crCeeiRYzvdnTzaIlpOWaet45lNSqNJUnXbppUUFR4ZUAlzgDmKee4Q5P/tKXypI1RiHwgag== + dependencies: + callsites "^3.0.0" + graceful-fs "^4.1.15" + source-map "^0.6.0" + +"@jest/test-result@^24.3.0": + version "24.3.0" + resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-24.3.0.tgz#4c0b1c9716212111920f7cf8c4329c69bc81924a" + integrity sha512-j7UZ49T8C4CVipEY99nLttnczVTtLyVzFfN20OiBVn7awOs0U3endXSTq7ouPrLR5y4YjI5GDcbcvDUjgeamzg== + dependencies: + "@jest/console" "^24.3.0" + "@jest/types" "^24.3.0" + "@types/istanbul-lib-coverage" "^1.1.0" + +"@jest/transform@^24.3.1": + version "24.3.1" + resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-24.3.1.tgz#ce9e1329eb5e640f493bcd5c8eb9970770959bfc" + integrity sha512-PpjylI5goT4Si69+qUjEeHuKjex0LjjrqJzrMYzlOZn/+SCumGKuGC0UQFeEPThyGsFvWH1Q4gj0R66eOHnIpw== + dependencies: + "@babel/core" "^7.1.0" + "@jest/types" "^24.3.0" + babel-plugin-istanbul "^5.1.0" + chalk "^2.0.1" + convert-source-map "^1.4.0" + fast-json-stable-stringify "^2.0.0" + graceful-fs "^4.1.15" + jest-haste-map "^24.3.1" + jest-regex-util "^24.3.0" + jest-util "^24.3.0" + micromatch "^3.1.10" + realpath-native "^1.1.0" + slash "^2.0.0" + source-map "^0.6.1" + write-file-atomic "2.4.1" + +"@jest/types@^24.3.0": + version "24.3.0" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-24.3.0.tgz#3f6e117e47248a9a6b5f1357ec645bd364f7ad23" + integrity sha512-VoO1F5tU2n/93QN/zaZ7Q8SeV/Rj+9JJOgbvKbBwy4lenvmdj1iDaQEPXGTKrO6OSvDeb2drTFipZJYxgo6kIQ== + dependencies: + "@types/istanbul-lib-coverage" "^1.1.0" + "@types/yargs" "^12.0.9" + "@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" @@ -743,6 +958,39 @@ dependencies: "@types/node" "*" +"@types/babel__core@^7.1.0": + version "7.1.0" + resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.0.tgz#710f2487dda4dcfd010ca6abb2b4dc7394365c51" + integrity sha512-wJTeJRt7BToFx3USrCDs2BhEi4ijBInTQjOIukj6a/5tEkwpFMVZ+1ppgmE+Q/FQyc5P/VWUbx7I9NELrKruHA== + dependencies: + "@babel/parser" "^7.1.0" + "@babel/types" "^7.0.0" + "@types/babel__generator" "*" + "@types/babel__template" "*" + "@types/babel__traverse" "*" + +"@types/babel__generator@*": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.0.2.tgz#d2112a6b21fad600d7674274293c85dce0cb47fc" + integrity sha512-NHcOfab3Zw4q5sEE2COkpfXjoE7o+PmqD9DQW4koUT3roNxwziUdXGnRndMat/LJNUtePwn1TlP4do3uoe3KZQ== + dependencies: + "@babel/types" "^7.0.0" + +"@types/babel__template@*": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.0.2.tgz#4ff63d6b52eddac1de7b975a5223ed32ecea9307" + integrity sha512-/K6zCpeW7Imzgab2bLkLEbz0+1JlFSrUMdw7KoIIu+IUdu51GWaBZpd3y1VXGVXzynvGa4DaIaxNZHiON3GXUg== + dependencies: + "@babel/parser" "^7.1.0" + "@babel/types" "^7.0.0" + +"@types/babel__traverse@*", "@types/babel__traverse@^7.0.6": + version "7.0.6" + resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.0.6.tgz#328dd1a8fc4cfe3c8458be9477b219ea158fd7b2" + integrity sha512-XYVgHF2sQ0YblLRMLNPB3CkFMewzFmlDsH/TneZFHUXDlABQgh88uOxuez7ZcXxayLFrqLwtDH1t+FmlFwNZxw== + dependencies: + "@babel/types" "^7.3.0" + "@types/body-parser@*", "@types/body-parser@1.17.0": version "1.17.0" resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.17.0.tgz#9f5c9d9bd04bb54be32d5eb9fc0d8c974e6cf58c" @@ -807,6 +1055,11 @@ resolved "https://registry.yarnpkg.com/@types/graphql/-/graphql-14.0.3.tgz#389e2e5b83ecdb376d9f98fae2094297bc112c1c" integrity sha512-TcFkpEjcQK7w8OcrQcd7iIBPjU0rdyi3ldj6d0iJ4PPSzbWqPBvXj9KSwO14hTOX2dm9RoiH7VuxksJLNYdXUQ== +"@types/istanbul-lib-coverage@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-1.1.0.tgz#2cc2ca41051498382b43157c8227fea60363f94a" + integrity sha512-ohkhb9LehJy+PA40rDtGAji61NCgdtKLAlFoYp4cnuuQEswwdK3vz9SOIkkyc3wrk8dzjphQApNs56yyXLStaQ== + "@types/long@^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.0.tgz#719551d2352d301ac8b81db732acb6bdc28dbdef" @@ -835,6 +1088,11 @@ "@types/express-serve-static-core" "*" "@types/mime" "*" +"@types/stack-utils@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" + integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw== + "@types/ws@^6.0.0": version "6.0.1" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-6.0.1.tgz#ca7a3f3756aa12f62a0a62145ed14c6db25d5a28" @@ -843,6 +1101,11 @@ "@types/events" "*" "@types/node" "*" +"@types/yargs@^12.0.2", "@types/yargs@^12.0.9": + version "12.0.9" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-12.0.9.tgz#693e76a52f61a2f1e7fb48c0eef167b95ea4ffd0" + integrity sha512-sCZy4SxP9rN2w30Hlmg5dtdRwgYQfYRiLo9usw8X9cxlf+H4FqM1xX7+sNH7NNKVdbXMJWqva7iyy+fxh/V7fA== + "@types/zen-observable@^0.5.3": version "0.5.4" resolved "https://registry.yarnpkg.com/@types/zen-observable/-/zen-observable-0.5.4.tgz#b863a4191e525206819e008097ebf0fb2e3a1cdc" @@ -894,12 +1157,17 @@ acorn@^5.5.3: resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.3.tgz#67aa231bf8812974b85235a96771eb6bd07ea279" integrity sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw== -acorn@^6.0.1, acorn@^6.0.2: +acorn@^6.0.1: version "6.0.4" resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.0.4.tgz#77377e7353b72ec5104550aa2d2097a2fd40b754" integrity sha512-VY4i5EKSKkofY2I+6QLTbTTN/UvEQPCo6eiwzzSaSWfpaDhOmStMCMod6wmuPciNq+XS0faCglFu2lHZpdHUtg== -activitystrea.ms@^2.1.3: +acorn@^6.0.7: + version "6.1.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.1.0.tgz#b0a3be31752c97a0f7013c5f4903b71a05db6818" + integrity sha512-MW/FjM+IvU9CgBzjO3UIPCE2pyEwUsoFl+VGdczOPEdxfGFjuKny/gN54mOuX7Qxmb9Rg9MCn2oKiSUeW+pjrw== + +activitystrea.ms@~2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/activitystrea.ms/-/activitystrea.ms-2.1.3.tgz#553548733e367dc0b6a7badc25fa6f8996cd80c3" integrity sha512-iiG5g5fYgfdaaqqFPaFIZC/KX8/4mOWkvniK+BNwJY6XDDKdIu56wmc9r0x1INHVnbFOTGuM8mZEntaM3I+YXw== @@ -926,10 +1194,10 @@ activitystreams-context@>=3.0.0, activitystreams-context@^3.0.0: resolved "https://registry.yarnpkg.com/activitystreams-context/-/activitystreams-context-3.1.0.tgz#28334e129f17cfb937e8c702c52c1bcb1d2830c7" integrity sha512-KBQ+igwf1tezMXGVw5MvRSEm0gp97JI1hTZ45I6MEkWv25lEgNoA9L6wqfaOiCX8wnMRWw9pwRsPZKypdtxAtg== -ajv@^6.5.3, ajv@^6.5.5, ajv@^6.6.1: - version "6.6.1" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.6.1.tgz#6360f5ed0d80f232cc2b294c362d5dc2e538dd61" - integrity sha512-ZoJjft5B+EJBjUyu9C9Hc0OZyPZSSlOF+plzouTrg6UlA8f+e/n8NIgBFG/9tppJtpPWfthHakK7juJdNDODww== +ajv@^6.5.5, ajv@^6.9.1: + version "6.9.2" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.9.2.tgz#4927adb83e7f48e5a32b45729744c71ec39c9c7b" + integrity sha512-4UFy0/LgDo7Oa/+wOAlj44tp9K78u38E5/359eSrqEp1Z5PdVfimCcs7SluXMP755RUQu6d2b4AvF0R1C9RZjg== dependencies: fast-deep-equal "^2.0.1" fast-json-stable-stringify "^2.0.0" @@ -948,6 +1216,11 @@ ansi-escapes@^3.0.0: resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.1.0.tgz#f73207bb81207d75fd6c83f125af26eea378ca30" integrity sha512-UgAb8H9D41AQnu/PbWlCofQVcnV4Gs2bBJi9eZPxfU/hgglFh3SMDMENRIqdr7H6XFnXdoknctFByVsCOotTVw== +ansi-escapes@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b" + integrity sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ== + ansi-regex@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" @@ -983,13 +1256,13 @@ anymatch@^2.0.0: micromatch "^3.1.4" normalize-path "^2.1.1" -apollo-cache-control@0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/apollo-cache-control/-/apollo-cache-control-0.5.1.tgz#db7e86cd7ede6cad0a2e0ea97488d9fb5c33d913" - integrity sha512-82hzX7/fFiu5dODLS8oGieEE4jLjMIhIkQ4JTsWj9drv8PZJltl0xqORtU2jA/FottjxfYab8+ebi3BgGPOaqw== +apollo-cache-control@0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/apollo-cache-control/-/apollo-cache-control-0.5.2.tgz#47931ede0b11c64d45429850c274b30d19322362" + integrity sha512-uehXDUrd3Qim+nzxqqN7XT1YTbNSyumW3/FY5BxbKZTI8d4oPG4eyVQKqaggooSjswKQnOoIQVes3+qg9tGAkw== dependencies: apollo-server-env "2.2.0" - graphql-extensions "0.5.2" + graphql-extensions "0.5.4" apollo-cache-control@^0.1.0: version "0.1.1" @@ -998,35 +1271,37 @@ apollo-cache-control@^0.1.0: dependencies: graphql-extensions "^0.0.x" -apollo-cache-inmemory@~1.4.3: - version "1.4.3" - resolved "https://registry.yarnpkg.com/apollo-cache-inmemory/-/apollo-cache-inmemory-1.4.3.tgz#aded4fb8b3de9e2fb2573a6c03591b07ef98ed36" - integrity sha512-p9KGtEZ9Mlb+FS0UEaxR8WvKOijYV0c+TXlhC/XZ3/ltYvP1zL3b1ozSOLGR9SawN2895Fc7QDV5nzPpihV0rA== +apollo-cache-inmemory@~1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/apollo-cache-inmemory/-/apollo-cache-inmemory-1.5.1.tgz#265d1ee67b0bf0aca9c37629d410bfae44e62953" + integrity sha512-D3bdpPmWfaKQkWy8lfwUg+K8OBITo3sx0BHLs1B/9vIdOIZ7JNCKq3EUcAgAfInomJUdN0QG1yOfi8M8hxkN1g== dependencies: - apollo-cache "^1.1.26" - apollo-utilities "^1.1.3" + apollo-cache "^1.2.1" + apollo-utilities "^1.2.1" optimism "^0.6.9" + ts-invariant "^0.2.1" tslib "^1.9.3" -apollo-cache@1.1.26, apollo-cache@^1.1.26: - version "1.1.26" - resolved "https://registry.yarnpkg.com/apollo-cache/-/apollo-cache-1.1.26.tgz#5afe023270effbc2063d90f51d8e56bce274ab37" - integrity sha512-JKFHijwkhXpcQ3jOat+ctwiXyjDhQgy0p6GSaj7zG+or+ZSalPqUnPzFRgRwFLVbYxBKJgHCkWX+2VkxWTZzQQ== +apollo-cache@1.2.1, apollo-cache@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/apollo-cache/-/apollo-cache-1.2.1.tgz#aae71eb4a11f1f7322adc343f84b1a39b0693644" + integrity sha512-nzFmep/oKlbzUuDyz6fS6aYhRmfpcHWqNkkA9Bbxwk18RD6LXC4eZkuE0gXRX0IibVBHNjYVK+Szi0Yied4SpQ== dependencies: - apollo-utilities "^1.1.3" + apollo-utilities "^1.2.1" tslib "^1.9.3" -apollo-client@~2.4.13: - version "2.4.13" - resolved "https://registry.yarnpkg.com/apollo-client/-/apollo-client-2.4.13.tgz#09829fcbd68e069de9840d0a10764d7c6a3d0787" - integrity sha512-7mBdW/CW1qHB8Mj4EFAG3MTtbRc6S8aUUntUdrKfRWV1rZdWa0NovxsgVD/R4HZWZjRQ2UOM4ENsHdM5g1uXOQ== +apollo-client@~2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/apollo-client/-/apollo-client-2.5.1.tgz#36126ed1d32edd79c3713c6684546a3bea80e6d1" + integrity sha512-MNcQKiqLHdGmNJ0rZ0NXaHrToXapJgS/5kPk0FygXt+/FmDCdzqcujI7OPxEC6e9Yw5S/8dIvOXcRNuOMElHkA== dependencies: "@types/zen-observable" "^0.8.0" - apollo-cache "1.1.26" + apollo-cache "1.2.1" apollo-link "^1.0.0" apollo-link-dedup "^1.0.0" - apollo-utilities "1.1.3" + apollo-utilities "1.2.1" symbol-observable "^1.0.2" + ts-invariant "^0.2.1" tslib "^1.9.3" zen-observable "^0.8.0" @@ -1045,17 +1320,17 @@ apollo-engine-reporting-protobuf@0.2.1: dependencies: protobufjs "^6.8.6" -apollo-engine-reporting@1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/apollo-engine-reporting/-/apollo-engine-reporting-1.0.2.tgz#5dd5411f17d76e6788a1166367a2ab7b52794224" - integrity sha512-g6JkO5WaMuqXfn3WoZMQyyFzpxfHsw/f7P7XTHSEqTSd/M4uk7/uih/xcqmgBGt4ET30KbaGFz2l4FJzO06A5w== +apollo-engine-reporting@1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/apollo-engine-reporting/-/apollo-engine-reporting-1.0.7.tgz#d326b51b12b1f71a40885b8189dbcd162171c953" + integrity sha512-mFsXvd+1/o5jSa9tI2RoXYGcvCLcwwcfLwchjSTxqUd4ViB8RbqYKynzEZ+Omji7PBRM0azioBm43f7PSsQPqA== dependencies: apollo-engine-reporting-protobuf "0.2.1" apollo-graphql "^0.1.0" - apollo-server-core "2.4.2" + apollo-server-core "2.4.8" apollo-server-env "2.2.0" async-retry "^1.2.1" - graphql-extensions "0.5.2" + graphql-extensions "0.5.7" apollo-env@0.3.3: version "0.3.3" @@ -1065,6 +1340,14 @@ apollo-env@0.3.3: core-js "3.0.0-beta.13" node-fetch "^2.2.0" +apollo-errors@^1.9.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/apollo-errors/-/apollo-errors-1.9.0.tgz#f1ed0ca0a6be5cd2f24e2eaa7b0860a10146ff51" + integrity sha512-XVukHd0KLvgY6tNjsPS3/Re3U6RQlTKrTbIpqqeTMo2N34uQMr+H1UheV21o8hOZBAFosvBORVricJiP5vfmrw== + dependencies: + assert "^1.4.1" + extendable-error "^0.1.5" + apollo-graphql@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/apollo-graphql/-/apollo-graphql-0.1.1.tgz#dc5eac3062abf9f063ac9869f0ef5c54fdc136e5" @@ -1072,12 +1355,13 @@ apollo-graphql@^0.1.0: dependencies: lodash.sortby "^4.7.0" -apollo-link-context@^1.0.14: - version "1.0.14" - resolved "https://registry.yarnpkg.com/apollo-link-context/-/apollo-link-context-1.0.14.tgz#6265eef49bedadddbbcff4026d04cd351094cd6c" - integrity sha512-l6SIN7Fwqhgg5C5eA8xSrt8gulHBmYTE3J4z5/Q2hP/8Kok0rQ/z5q3uy42/hkdYlnaktOvpz+ZIwEFzcXwujQ== +apollo-link-context@~1.0.14: + version "1.0.15" + resolved "https://registry.yarnpkg.com/apollo-link-context/-/apollo-link-context-1.0.15.tgz#9e5dc3eb874b3ed975f0bb0062a65aa946fd30a2" + integrity sha512-CkUB0CaaNGCsiNxG6GImPSsXHL8f+lQZukl2TLdpDKao3EyCuPC9gSWvclUagwZ1TDnY8O+wJnNBDGymQiZTsA== dependencies: - apollo-link "^1.2.8" + apollo-link "^1.2.9" + tslib "^1.9.3" apollo-link-dedup@^1.0.0: version "1.0.11" @@ -1086,27 +1370,33 @@ apollo-link-dedup@^1.0.0: dependencies: apollo-link "^1.2.4" -apollo-link-http-common@^0.2.10: - version "0.2.10" - resolved "https://registry.yarnpkg.com/apollo-link-http-common/-/apollo-link-http-common-0.2.10.tgz#b5bbf502ff40a81cc00281ba3b8543b7ad866dfe" - integrity sha512-KY9nhpAurw3z48OIYV0sCZFXrzWp/wjECsveK+Q9GUhhSe1kEbbUjFfmi+qigg+iELgdp5V8ioRJhinl1vPojw== +apollo-link-http-common@^0.2.11: + version "0.2.11" + resolved "https://registry.yarnpkg.com/apollo-link-http-common/-/apollo-link-http-common-0.2.11.tgz#d4e494ed1e45ea0e0c0ed60f3df64541d0de682d" + integrity sha512-FjtzEDiG6blH/2MR4fpVNoxdZUFmddP0sez34qnoLaYz6ABFbTDlmRE/dVN79nPExM4Spfs/DtW7KRqyjJ3tOg== dependencies: - apollo-link "^1.2.8" + apollo-link "^1.2.9" + ts-invariant "^0.3.2" + tslib "^1.9.3" -apollo-link-http@~1.5.11: - version "1.5.11" - resolved "https://registry.yarnpkg.com/apollo-link-http/-/apollo-link-http-1.5.11.tgz#1f72a377d03e874a08bc9eadb1ce7ecb166f1e56" - integrity sha512-wDG+I9UmpfaZRPIvTYBgkvqiCgmz6yWgvuzW/S24Q4r4Xrfe6sLpg2FmarhtdP+hdN+IXTLbFNCZ+Trgfpifow== +apollo-link-http@~1.5.12: + version "1.5.12" + resolved "https://registry.yarnpkg.com/apollo-link-http/-/apollo-link-http-1.5.12.tgz#878d48bf9d8ae091752710529a222c4a5548118e" + integrity sha512-2tS36RIU6OdxzoWYTPrjvDTF2sCrnlaJ6SL7j0ILPn1Lmw4y6YLwKDsv/SWLwtodtVe9v1dLCGKIGMRMM/SdyA== dependencies: - apollo-link "^1.2.8" - apollo-link-http-common "^0.2.10" + apollo-link "^1.2.9" + apollo-link-http-common "^0.2.11" + tslib "^1.9.3" -apollo-link@^1.0.0, apollo-link@^1.2.3, apollo-link@^1.2.4, apollo-link@^1.2.8: - version "1.2.8" - resolved "https://registry.yarnpkg.com/apollo-link/-/apollo-link-1.2.8.tgz#0f252adefd5047ac1a9f35ba9439d216587dcd84" - integrity sha512-lfzGRxhK9RmiH3HPFi7TIEBhhDY9M5a2ZDnllcfy5QDk7cCQHQ1WQArcw1FK0g1B+mV4Kl72DSrlvZHZJEolrA== +apollo-link@^1.0.0, apollo-link@^1.2.3, apollo-link@^1.2.4, apollo-link@^1.2.9: + version "1.2.9" + resolved "https://registry.yarnpkg.com/apollo-link/-/apollo-link-1.2.9.tgz#40a8f0b90716ce3fd6beb27b7eae1108b92e0054" + integrity sha512-ZLUwthOFZq4lxchQ2jeBfVqS/UDdcVmmh8aUw6Ar9awZH4r+RgkcDeu2ooFLUfodWE3mZr7wIZuYsBas/MaNVA== dependencies: - zen-observable-ts "^0.8.15" + apollo-utilities "^1.2.1" + ts-invariant "^0.3.2" + tslib "^1.9.3" + zen-observable-ts "^0.8.16" apollo-server-caching@0.3.1: version "0.3.1" @@ -1115,24 +1405,24 @@ apollo-server-caching@0.3.1: dependencies: lru-cache "^5.0.0" -apollo-server-core@2.4.2: - version "2.4.2" - resolved "https://registry.yarnpkg.com/apollo-server-core/-/apollo-server-core-2.4.2.tgz#916d98636b1bf576a84b4a469006c1c73741e03a" - integrity sha512-IOWhqjjI1sH38sj7ycjke0dXXEgaqOkb2hDpLBTSiVWKBIqFfo4gchWK5wcWW9jReDpf/+G2wogH+UvONs2ejg== +apollo-server-core@2.4.8: + version "2.4.8" + resolved "https://registry.yarnpkg.com/apollo-server-core/-/apollo-server-core-2.4.8.tgz#47e503a345e314222725597c889773e018d8c67a" + integrity sha512-N+5UOzHhMOnHizEiArJtNvEe/cGhSHQyTn5tlU4RJ36FDBJ/WlYZfPbGDMLISSUCJ6t+aP8GLL4Mnudt9d2PDQ== dependencies: "@apollographql/apollo-tools" "^0.3.3" "@apollographql/graphql-playground-html" "^1.6.6" "@types/ws" "^6.0.0" - apollo-cache-control "0.5.1" + apollo-cache-control "0.5.2" apollo-datasource "0.3.1" - apollo-engine-reporting "1.0.2" + apollo-engine-reporting "1.0.7" apollo-server-caching "0.3.1" apollo-server-env "2.2.0" - apollo-server-errors "2.2.0" - apollo-server-plugin-base "0.3.2" - apollo-tracing "0.5.1" + apollo-server-errors "2.2.1" + apollo-server-plugin-base "0.3.7" + apollo-tracing "0.5.2" fast-json-stable-stringify "^2.0.0" - graphql-extensions "0.5.2" + graphql-extensions "0.5.7" graphql-subscriptions "^1.0.0" graphql-tag "^2.9.2" graphql-tools "^4.0.0" @@ -1158,15 +1448,15 @@ apollo-server-env@2.2.0: node-fetch "^2.1.2" util.promisify "^1.0.0" -apollo-server-errors@2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/apollo-server-errors/-/apollo-server-errors-2.2.0.tgz#5b452a1d6ff76440eb0f127511dc58031a8f3cb5" - integrity sha512-gV9EZG2tovFtT1cLuCTavnJu2DaKxnXPRNGSTo+SDI6IAk6cdzyW0Gje5N2+3LybI0Wq5KAbW6VLei31S4MWmg== +apollo-server-errors@2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/apollo-server-errors/-/apollo-server-errors-2.2.1.tgz#f68a3f845929768057da7e1c6d30517db5872205" + integrity sha512-wY/YE3iJVMYC+WYIf8QODBjIP4jhI+oc7kiYo9mrz7LdYPKAgxr/he+NteGcqn/0Ea9K5/ZFTGJDbEstSMeP8g== -apollo-server-express@2.4.2: - version "2.4.2" - resolved "https://registry.yarnpkg.com/apollo-server-express/-/apollo-server-express-2.4.2.tgz#870daf04fd9b727a54b1339cdb55066450b3e05a" - integrity sha512-Q5/unCAi6a2dT39LQaIiLC1d8O4fmBDU2CrRhVhPWP8I344xPgNOcrs7MsNN7Ecb56UGbgDKxBoWowFG65ulKw== +apollo-server-express@2.4.8: + version "2.4.8" + resolved "https://registry.yarnpkg.com/apollo-server-express/-/apollo-server-express-2.4.8.tgz#ec9eb61a87324555d49097e9fade3c7d142eb6cb" + integrity sha512-i60l32mfVe33jnKDPNYgUKUKu4Al0xEm2HLOSMgtJ9Wbpe/MbOx5X8M5F27fnHYdM+G5XfAErsakAyRGnQJ48Q== dependencies: "@apollographql/graphql-playground-html" "^1.6.6" "@types/accepts" "^1.3.5" @@ -1174,7 +1464,7 @@ apollo-server-express@2.4.2: "@types/cors" "^2.8.4" "@types/express" "4.16.1" accepts "^1.3.5" - apollo-server-core "2.4.2" + apollo-server-core "2.4.8" body-parser "^1.18.3" cors "^2.8.4" graphql-subscriptions "^1.0.0" @@ -1202,36 +1492,36 @@ apollo-server-module-graphiql@^1.3.4, apollo-server-module-graphiql@^1.4.0: resolved "https://registry.yarnpkg.com/apollo-server-module-graphiql/-/apollo-server-module-graphiql-1.4.0.tgz#c559efa285578820709f1769bb85d3b3eed3d8ec" integrity sha512-GmkOcb5he2x5gat+TuiTvabnBf1m4jzdecal3XbXBh/Jg+kx4hcvO3TTDFQ9CuTprtzdcVyA11iqG7iOMOt7vA== -apollo-server-plugin-base@0.3.2: - version "0.3.2" - resolved "https://registry.yarnpkg.com/apollo-server-plugin-base/-/apollo-server-plugin-base-0.3.2.tgz#4609c9a9d154568401d84b7ac17d1e30e3529104" - integrity sha512-yzXrkVSPBoux2uPgbTGROGh7W0axRWopMZM+KT9aF9H/+yMCwtt0EhGOGyNUDMOFA4rT3z+cLVvYuZr1rSQWcg== +apollo-server-plugin-base@0.3.7: + version "0.3.7" + resolved "https://registry.yarnpkg.com/apollo-server-plugin-base/-/apollo-server-plugin-base-0.3.7.tgz#bfa4932fc9481bb36221545578d311db464af5a6" + integrity sha512-hW1jaLKf9qNOxMTwRq2CSqz3eqXsZuEiCc8/mmEtOciiVBq1GMtxFf19oIYM9HQuPvQU2RWpns1VrYN59L3vbg== -apollo-server-testing@~2.4.2: - version "2.4.2" - resolved "https://registry.yarnpkg.com/apollo-server-testing/-/apollo-server-testing-2.4.2.tgz#5c87b34b0b6a1a3e5a1784cadc16bc495dded2e1" - integrity sha512-WZ901nh7uG75342lMukJvuxFF/w3W5JDyWElY8KDhXfaDLbMKqhRqaWRIiEEX4YvciO5ACSzqKt+957/y1yUUQ== +apollo-server-testing@~2.4.8: + version "2.4.8" + resolved "https://registry.yarnpkg.com/apollo-server-testing/-/apollo-server-testing-2.4.8.tgz#eb929a431e059723c298919688355434d53e3ea8" + integrity sha512-AmNn5pDn9FJ9AJbmc7gwsUFaUt4uf44IFHaCfZow/jkAeY2JZnIozt8LYC8Koidy+Lfb+i/HsjkgbBodElbGMQ== dependencies: - apollo-server-core "2.4.2" + apollo-server-core "2.4.8" -apollo-server@~2.4.2: - version "2.4.2" - resolved "https://registry.yarnpkg.com/apollo-server/-/apollo-server-2.4.2.tgz#6ca42906664918c32d8be391db1178c6eaa8dc54" - integrity sha512-oFwHUhJtJedIPCtZ2ftvRWrVA3KpmeWsgDBs4e5jW/7f5ozkUF22h7L3iy8rEljppiEa2X/GgSi11SVxIOL8gw== +apollo-server@~2.4.8: + version "2.4.8" + resolved "https://registry.yarnpkg.com/apollo-server/-/apollo-server-2.4.8.tgz#b18ec24e6356dd2a191a6fa0f1429b07ef7c89e3" + integrity sha512-IU6RekO2dqrDdC+5hU6aeVvGg/2t/f01inBMjDhAn1a7hoITUXEh8Sa57TgmYEZ5uAtDuWW7cdiZN2j0cMI3/w== dependencies: - apollo-server-core "2.4.2" - apollo-server-express "2.4.2" + apollo-server-core "2.4.8" + apollo-server-express "2.4.8" express "^4.0.0" graphql-subscriptions "^1.0.0" graphql-tools "^4.0.0" -apollo-tracing@0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/apollo-tracing/-/apollo-tracing-0.5.1.tgz#16be201bc276120f0f8b7aa180201ee89d57e3bd" - integrity sha512-5gb8OWzkGaJFsmQdyMyZnOjcq6weMTkqJSGj0hfR7uX99X4SBFAzZV4nTeK4z0XkXO2I12xSTJoS4gxbFjgeaA== +apollo-tracing@0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/apollo-tracing/-/apollo-tracing-0.5.2.tgz#cc49936fb435fa98d19c841514cfe05237c85b33" + integrity sha512-2FdwRvPIq9uuF6OzONroXep6VBGqzHOkP6LlcFQe7SdwxfRP+SD/ycHNSC1acVg2b8d+am9Kzqg2vV54UpOIKA== dependencies: apollo-server-env "2.2.0" - graphql-extensions "0.5.2" + graphql-extensions "0.5.4" apollo-tracing@^0.1.0: version "0.1.4" @@ -1250,12 +1540,13 @@ apollo-upload-server@^7.0.0: http-errors "^1.7.0" object-path "^0.11.4" -apollo-utilities@1.1.3, apollo-utilities@^1.0.1, apollo-utilities@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.1.3.tgz#a8883c0392f6b46eac0d366204ebf34be9307c87" - integrity sha512-pF9abhiClX5gfj/WFWZh8DiI33nOLGxRhXH9ZMquaM1V8bhq1WLFPt2QjShWH3kGQVeIGUK+FQefnhe+ZaaAYg== +apollo-utilities@1.2.1, apollo-utilities@^1.0.1, apollo-utilities@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.2.1.tgz#1c3a1ebf5607d7c8efe7636daaf58e7463b41b3c" + integrity sha512-Zv8Udp9XTSFiN8oyXOjf6PMHepD4yxxReLsl6dPUy5Ths7jti3nmlBzZUOxuTWRwZn0MoclqL7RQ5UEJN8MAxg== dependencies: fast-json-stable-stringify "^2.0.0" + ts-invariant "^0.2.1" tslib "^1.9.3" append-transform@^1.0.0: @@ -1357,6 +1648,13 @@ assert-plus@1.0.0, assert-plus@^1.0.0: resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU= +assert@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/assert/-/assert-1.4.1.tgz#99912d591836b5a6f5b345c0f07eefc08fc65d91" + integrity sha1-mZEtWRg2tab1s0XA8H7vwI/GXZE= + dependencies: + util "0.10.3" + assertion-error-formatter@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/assertion-error-formatter/-/assertion-error-formatter-2.0.1.tgz#6bbdffaec8e2fa9e2b0eb158bfe353132d7c0a9b" @@ -1481,13 +1779,16 @@ babel-eslint@~10.0.1: eslint-scope "3.7.1" eslint-visitor-keys "^1.0.0" -babel-jest@^24.1.0, babel-jest@~24.1.0: - version "24.1.0" - resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-24.1.0.tgz#441e23ef75ded3bd547e300ac3194cef87b55190" - integrity sha512-MLcagnVrO9ybQGLEfZUqnOzv36iQzU7Bj4elm39vCukumLVSfoX+tRy3/jW7lUKc7XdpRmB/jech6L/UCsSZjw== +babel-jest@^24.3.1, babel-jest@~24.3.1: + version "24.3.1" + resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-24.3.1.tgz#168468a37e90426520c5293da4f55e1a512063b0" + integrity sha512-6KaXyUevY0KAxD5Ba+EBhyfwvc+R2f7JV7BpBZ5T8yJGgj0M1hYDfRhDq35oD5MzprMf/ggT81nEuLtMyxfDIg== dependencies: + "@jest/transform" "^24.3.1" + "@jest/types" "^24.3.0" + "@types/babel__core" "^7.1.0" babel-plugin-istanbul "^5.1.0" - babel-preset-jest "^24.1.0" + babel-preset-jest "^24.3.0" chalk "^2.4.2" slash "^2.0.0" @@ -1500,18 +1801,20 @@ babel-plugin-istanbul@^5.1.0: istanbul-lib-instrument "^3.0.0" test-exclude "^5.0.0" -babel-plugin-jest-hoist@^24.1.0: - version "24.1.0" - resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-24.1.0.tgz#dfecc491fb15e2668abbd690a697a8fd1411a7f8" - integrity sha512-gljYrZz8w1b6fJzKcsfKsipSru2DU2DmQ39aB6nV3xQ0DDv3zpIzKGortA5gknrhNnPN8DweaEgrnZdmbGmhnw== +babel-plugin-jest-hoist@^24.3.0: + version "24.3.0" + resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-24.3.0.tgz#f2e82952946f6e40bb0a75d266a3790d854c8b5b" + integrity sha512-nWh4N1mVH55Tzhx2isvUN5ebM5CDUvIpXPZYMRazQughie/EqGnbR+czzoQlhUmJG9pPJmYDRhvocotb2THl1w== + dependencies: + "@types/babel__traverse" "^7.0.6" -babel-preset-jest@^24.1.0: - version "24.1.0" - resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-24.1.0.tgz#83bc564fdcd4903641af65ec63f2f5de6b04132e" - integrity sha512-FfNLDxFWsNX9lUmtwY7NheGlANnagvxq8LZdl5PKnVG3umP+S/g0XbVBfwtA4Ai3Ri/IMkWabBz3Tyk9wdspcw== +babel-preset-jest@^24.3.0: + version "24.3.0" + resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-24.3.0.tgz#db88497e18869f15b24d9c0e547d8e0ab950796d" + integrity sha512-VGTV2QYBa/Kn3WCOKdfS31j9qomaXSgJqi65B6o05/1GsJyj9LVhSljM9ro4S+IBGj/ENhNBuH9bpqzztKAQSw== dependencies: "@babel/plugin-syntax-object-rest-spread" "^7.0.0" - babel-plugin-jest-hoist "^24.1.0" + babel-plugin-jest-hoist "^24.3.0" babel-runtime@^6.26.0: version "6.26.0" @@ -1827,16 +2130,7 @@ chai@~4.2.0: pathval "^1.1.0" type-detect "^4.0.5" -chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.4.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.1.tgz#18c49ab16a037b6eb0152cc83e3471338215b66e" - integrity sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ== - dependencies: - ansi-styles "^3.2.1" - escape-string-regexp "^1.0.5" - supports-color "^5.3.0" - -chalk@^2.4.2: +chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.4.1, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -1901,11 +2195,6 @@ ci-info@^2.0.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== -circular-json@^0.3.1: - version "0.3.3" - resolved "https://registry.yarnpkg.com/circular-json/-/circular-json-0.3.3.tgz#815c99ea84f6809529d2f45791bdf82711352d66" - integrity sha512-UZK3NBx2Mca+b5LsG7bY183pHWt5Y1xts4P3Pz7ENTwGVnJOUWbRb3ocjvX7hx9tq/yTAdclXm9sZ38gNuem4A== - class-utils@^0.3.5: version "0.3.6" resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463" @@ -2119,7 +2408,7 @@ core-util-is@1.0.2, core-util-is@~1.0.0: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= -cors@^2.8.4, cors@^2.8.5: +cors@^2.8.4, cors@~2.8.5: version "2.8.5" resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== @@ -2222,7 +2511,7 @@ cucumber-tag-expressions@^1.1.1: resolved "https://registry.yarnpkg.com/cucumber-tag-expressions/-/cucumber-tag-expressions-1.1.1.tgz#7f5c7b70009bc2b666591bfe64854578bedee85a" integrity sha1-f1x7cACbwrZmWRv+ZIVFeL7e6Fo= -cucumber@^5.1.0: +cucumber@~5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/cucumber/-/cucumber-5.1.0.tgz#7b166812c255bec7eac4b0df7007a40d089c895d" integrity sha512-zrl2VYTBRgvxucwV2GKAvLqcfA1Naeax8plPvWgPEzl3SCJiuPPv3WxBHIRHtPYcEdbHDR6oqLpZP4bJ8UIdmA== @@ -2304,7 +2593,7 @@ debug@^3.1.0: dependencies: ms "^2.1.1" -debug@^4.0.1, debug@^4.1.0, debug@^4.1.1: +debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@~4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== @@ -2422,10 +2711,10 @@ dicer@0.2.5: readable-stream "1.1.x" streamsearch "0.1.2" -diff-sequences@^24.0.0: - version "24.0.0" - resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-24.0.0.tgz#cdf8e27ed20d8b8d3caccb4e0c0d8fe31a173013" - integrity sha512-46OkIuVGBBnrC0soO/4LHu5LHGHx0uhP65OVz8XOrAJpqiCB2aVIuESvjI1F9oqebuvY8lekS1pt6TN7vt7qsw== +diff-sequences@^24.3.0: + version "24.3.0" + resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-24.3.0.tgz#0f20e8a1df1abddaf4d9c226680952e64118b975" + integrity sha512-xLqpez+Zj9GKSnPWS0WZw1igGocZ+uua8+y+5dDNTT934N3QuY1sp2LkHzwiaYQGz60hMq0pjAshdeXm5VUOEw== diff@^3.0.0: version "3.5.0" @@ -2445,10 +2734,10 @@ doctrine@1.5.0: esutils "^2.0.2" isarray "^1.0.0" -doctrine@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" - integrity sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw== +doctrine@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" + integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== dependencies: esutils "^2.0.2" @@ -2570,6 +2859,11 @@ elliptic@=3.0.3: hash.js "^1.0.0" inherits "^2.0.1" +emoji-regex@^7.0.1: + version "7.0.3" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" + integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== + encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" @@ -2746,7 +3040,7 @@ eslint-plugin-import@~2.16.0: read-pkg-up "^2.0.0" resolve "^1.9.0" -eslint-plugin-jest@^22.3.0: +eslint-plugin-jest@~22.3.0: version "22.3.0" resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-22.3.0.tgz#a10f10dedfc92def774ec9bb5bfbd2fb8e1c96d2" integrity sha512-P1mYVRNlOEoO5T9yTqOfucjOYf1ktmJ26NjwjH8sxpCFQa6IhBGr5TpKl3hcAAT29hOsRJVuMWmTsHoUVo9FoA== @@ -2781,10 +3075,10 @@ eslint-scope@3.7.1: esrecurse "^4.1.0" estraverse "^4.1.1" -eslint-scope@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.0.tgz#50bf3071e9338bcdc43331794a0cb533f0136172" - integrity sha512-1G6UTDi7Jc1ELFwnR58HV4fK9OQK4S6N985f166xqXxpjU6plxFISJa2Ba9KCQuFa8RCnj/lSFJbHo7UFDBnUA== +eslint-scope@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.2.tgz#5f10cd6cabb1965bf479fa65745673439e21cb0e" + integrity sha512-5q1+B/ogmHl8+paxtOKx38Z8LtWkVGuNt3+GQNErqwLl6ViNp/gdJGMCjZNxZ8j/VYjDNZ2Fo+eQc1TAVPIzbg== dependencies: esrecurse "^4.1.0" estraverse "^4.1.1" @@ -2799,35 +3093,35 @@ eslint-visitor-keys@^1.0.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#3f3180fb2e291017716acb4c9d6d5b5c34a6a81d" integrity sha512-qzm/XxIbxm/FHyH341ZrbnMUpe+5Bocte9xkmFMzPMjRaZMcXww+MpBptFvtU+79L362nqiLhekCxCxDPaUMBQ== -eslint@~5.13.0: - version "5.13.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-5.13.0.tgz#ce71cc529c450eed9504530939aa97527861ede9" - integrity sha512-nqD5WQMisciZC5EHZowejLKQjWGuFS5c70fxqSKlnDME+oz9zmE8KTlX+lHSg+/5wsC/kf9Q9eMkC8qS3oM2fg== +eslint@~5.15.1: + version "5.15.1" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-5.15.1.tgz#8266b089fd5391e0009a047050795b1d73664524" + integrity sha512-NTcm6vQ+PTgN3UBsALw5BMhgO6i5EpIjQF/Xb5tIh3sk9QhrFafujUOczGz4J24JBlzWclSB9Vmx8d+9Z6bFCg== dependencies: "@babel/code-frame" "^7.0.0" - ajv "^6.5.3" + ajv "^6.9.1" chalk "^2.1.0" cross-spawn "^6.0.5" debug "^4.0.1" - doctrine "^2.1.0" - eslint-scope "^4.0.0" + doctrine "^3.0.0" + eslint-scope "^4.0.2" eslint-utils "^1.3.1" eslint-visitor-keys "^1.0.0" - espree "^5.0.0" + espree "^5.0.1" esquery "^1.0.1" esutils "^2.0.2" - file-entry-cache "^2.0.0" + file-entry-cache "^5.0.1" functional-red-black-tree "^1.0.1" glob "^7.1.2" globals "^11.7.0" ignore "^4.0.6" import-fresh "^3.0.0" imurmurhash "^0.1.4" - inquirer "^6.1.0" + inquirer "^6.2.2" js-yaml "^3.12.0" json-stable-stringify-without-jsonify "^1.0.1" levn "^0.3.0" - lodash "^4.17.5" + lodash "^4.17.11" minimatch "^3.0.4" mkdirp "^0.5.1" natural-compare "^1.4.0" @@ -2838,15 +3132,15 @@ eslint@~5.13.0: semver "^5.5.1" strip-ansi "^4.0.0" strip-json-comments "^2.0.1" - table "^5.0.2" + table "^5.2.3" text-table "^0.2.0" -espree@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/espree/-/espree-5.0.0.tgz#fc7f984b62b36a0f543b13fb9cd7b9f4a7f5b65c" - integrity sha512-1MpUfwsdS9MMoN7ZXqAr9e9UKdVHDcvrJpyx7mm1WuQlx/ygErEQBzgi5Nh5qBHIoYweprhtMkTCb9GhcAIcsA== +espree@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/espree/-/espree-5.0.1.tgz#5d6526fa4fc7f0788a5cf75b15f30323e2f81f7a" + integrity sha512-qWAZcWh4XE/RwzLJejfcofscgMc9CamR6Tn1+XRXNzrvUSSbiAjGOI/fggztjIi7y9VLPqnICMIPiGyr8JaZ0A== dependencies: - acorn "^6.0.2" + acorn "^6.0.7" acorn-jsx "^5.0.0" eslint-visitor-keys "^1.0.0" @@ -2899,12 +3193,10 @@ events@1.1.1: resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924" integrity sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ= -exec-sh@^0.2.0: - version "0.2.2" - resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.2.2.tgz#2a5e7ffcbd7d0ba2755bdecb16e5a427dfbdec36" - integrity sha512-FIUCJz1RbuS0FKTdaAafAByGS0CPvU3R0MeHxgtl+djzCc//F8HakL8GzmVNZanasTbTAY/3DRFA0KpVqj/eAw== - dependencies: - merge "^1.2.0" +exec-sh@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.3.2.tgz#6738de2eb7c8e671d0366aea0b0db8c6f7d7391b" + integrity sha512-9sLAvzhI5nc8TpuQUh4ahMdCrWT00wPWz7j47/emR5+2qEfoZP5zzUXvx+vdx+H6ohhnsYC31iX04QLYJK8zTg== execa@^0.7.0: version "0.7.0" @@ -2955,18 +3247,19 @@ expect-ct@0.1.1: resolved "https://registry.yarnpkg.com/expect-ct/-/expect-ct-0.1.1.tgz#de84476a2dbcb85000d5903737e9bc8a5ba7b897" integrity sha512-ngXzTfoRGG7fYens3/RMb6yYoVLvLMfmsSllP/mZPxNHgFq41TmPSLF/nLY7fwoclI2vElvAmILFWGUYqdjfCg== -expect@^24.1.0: - version "24.1.0" - resolved "https://registry.yarnpkg.com/expect/-/expect-24.1.0.tgz#88e73301c4c785cde5f16da130ab407bdaf8c0f2" - integrity sha512-lVcAPhaYkQcIyMS+F8RVwzbm1jro20IG8OkvxQ6f1JfqhVZyyudCwYogQ7wnktlf14iF3ii7ArIUO/mqvrW9Gw== +expect@^24.3.1: + version "24.3.1" + resolved "https://registry.yarnpkg.com/expect/-/expect-24.3.1.tgz#7c42507da231a91a8099d065bc8dc9322dc85fc0" + integrity sha512-xnmobSlaqhg4FKqjb5REk4AobQzFMJoctDdREKfSGqrtzRfCWYbfqt3WmikAvQz/J8mCNQhORgYdEjPMJbMQPQ== dependencies: + "@jest/types" "^24.3.0" ansi-styles "^3.2.0" - jest-get-type "^24.0.0" - jest-matcher-utils "^24.0.0" - jest-message-util "^24.0.0" - jest-regex-util "^24.0.0" + jest-get-type "^24.3.0" + jest-matcher-utils "^24.3.1" + jest-message-util "^24.3.0" + jest-regex-util "^24.3.0" -express@^4.0.0, express@^4.16.3, express@^4.16.4: +express@^4.0.0, express@^4.16.3, express@~4.16.4: version "4.16.4" resolved "https://registry.yarnpkg.com/express/-/express-4.16.4.tgz#fddef61926109e24c515ea97fd2f1bdbf62df12e" integrity sha512-j12Uuyb4FMrd/qQAm6uCHAkPtO8FDTRJZBDd5D2KOL2eLaz1yUNdUB/NOIyq0iU4q4cFarsUCrnFDPBcnksuOg== @@ -3022,7 +3315,12 @@ extend@^3.0.0, extend@~3.0.2: resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== -external-editor@^3.0.0: +extendable-error@^0.1.5: + version "0.1.5" + resolved "https://registry.yarnpkg.com/extendable-error/-/extendable-error-0.1.5.tgz#122308a7097bc89a263b2c4fbf089c78140e3b6d" + integrity sha1-EiMIpwl7yJomOyxPvwiceBQOO20= + +external-editor@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.0.3.tgz#5866db29a97826dbe4bf3afd24070ead9ea43a27" integrity sha512-bn71H9+qWoOQKyZDo25mOMVpSmXROAsTJVVVYzrrtol3d4y+AsKjf4Iwl2Q+IuT0kFSQ1qo166UuIwqYq7mGnA== @@ -3094,13 +3392,12 @@ figures@2.0.0, figures@^2.0.0: dependencies: escape-string-regexp "^1.0.5" -file-entry-cache@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-2.0.0.tgz#c392990c3e684783d838b8c84a45d8a048458361" - integrity sha1-w5KZDD5oR4PYOLjISkXYoEhFg2E= +file-entry-cache@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-5.0.1.tgz#ca0f6efa6dd3d561333fb14515065c2fafdf439c" + integrity sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g== dependencies: - flat-cache "^1.2.1" - object-assign "^4.0.1" + flat-cache "^2.0.1" fileset@^2.0.3: version "2.0.3" @@ -3165,15 +3462,19 @@ find-up@^3.0.0: dependencies: locate-path "^3.0.0" -flat-cache@^1.2.1: - version "1.3.4" - resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-1.3.4.tgz#2c2ef77525cc2929007dfffa1dd314aa9c9dee6f" - integrity sha512-VwyB3Lkgacfik2vhqR4uv2rvebqmDvFu4jlN/C1RzWoJEo8I7z4Q404oiqYCkq41mni8EzQnm95emU9seckwtg== +flat-cache@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-2.0.1.tgz#5d296d6f04bda44a4630a301413bdbc2ec085ec0" + integrity sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA== dependencies: - circular-json "^0.3.1" - graceful-fs "^4.1.2" - rimraf "~2.6.2" - write "^0.2.1" + flatted "^2.0.0" + rimraf "2.6.3" + write "1.0.3" + +flatted@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.0.tgz#55122b6536ea496b4b44893ee2608141d10d9916" + integrity sha512-R+H8IZclI8AAkSBRQJLVOsxwAoHd6WC40b4QTNWIjzAa6BXOBfQcM587MXDTVPeYaopFNWHUFLx7eNmHDSxMWg== fn-name@~2.0.1: version "2.0.1" @@ -3256,14 +3557,6 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= -fsevents@^1.2.3: - version "1.2.4" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.4.tgz#f41dcb1af2582af3692da36fc55cbd8e1041c426" - integrity sha512-z8H8/diyk76B7q5wg+Ud0+CqzcAF3mBBI/bA5ne5zrRUUIvNkJY//D3BqyH571KuAC4Nr7Rw7CjWX4r0y9DvNg== - dependencies: - nan "^2.9.2" - node-pre-gyp "^0.10.0" - fsevents@^1.2.7: version "1.2.7" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.7.tgz#4851b664a3783e52003b3c66eb0eee1074933aa4" @@ -3394,6 +3687,15 @@ graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2: resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725" integrity sha1-TK+tdrxi8C+gObL5Tpo906ORpyU= +graphql-auth-directives@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/graphql-auth-directives/-/graphql-auth-directives-2.1.0.tgz#85b83817844e2ec5fba8fe5de444287d6dd0f85a" + integrity sha512-mRVsjeMeMABPyjxyzl9mhkcW02YBwSj7dnu7C6wy2dIhiby6xTKy6Q54C8KeqXSYsy6ua4VmBH++d7GKqpvIoA== + dependencies: + apollo-errors "^1.9.0" + graphql-tools "^4.0.4" + jsonwebtoken "^8.3.0" + graphql-custom-directives@~0.2.14: version "0.2.14" resolved "https://registry.yarnpkg.com/graphql-custom-directives/-/graphql-custom-directives-0.2.14.tgz#88611b8cb074477020ad85af47bfe168c4c23992" @@ -3409,10 +3711,17 @@ graphql-deduplicator@^2.0.1: resolved "https://registry.yarnpkg.com/graphql-deduplicator/-/graphql-deduplicator-2.0.2.tgz#d8608161cf6be97725e178df0c41f6a1f9f778f3" integrity sha512-0CGmTmQh4UvJfsaTPppJAcHwHln8Ayat7yXXxdnuWT+Mb1dBzkbErabCWzjXyKh/RefqlGTTA7EQOZHofMaKJA== -graphql-extensions@0.5.2: - version "0.5.2" - resolved "https://registry.yarnpkg.com/graphql-extensions/-/graphql-extensions-0.5.2.tgz#cdced94c1931c9983fffcc144e336d6cd4d8b02b" - integrity sha512-D/FAvjYEZ8GM3vfALxRvItozy5iLUfzyoauE2lli+0OuUBCAZDLP0fgqeTFK93NnQX/XSjBVGhcuDWBB7JesEw== +graphql-extensions@0.5.4: + version "0.5.4" + resolved "https://registry.yarnpkg.com/graphql-extensions/-/graphql-extensions-0.5.4.tgz#18a9674f9adb11aa6c0737485887ea8877914cff" + integrity sha512-qLThJGVMqcItE7GDf/xX/E40m/aeqFheEKiR5bfra4q5eHxQKGjnIc20P9CVqjOn9I0FkEiU9ypOobfmIf7t6g== + dependencies: + "@apollographql/apollo-tools" "^0.3.3" + +graphql-extensions@0.5.7: + version "0.5.7" + resolved "https://registry.yarnpkg.com/graphql-extensions/-/graphql-extensions-0.5.7.tgz#2b647e4e36997dc85b7f58ebd64324a5250fb2cf" + integrity sha512-HrU6APE1PiehZ46scMB3S5DezSeCATd8v+e4mmg2bqszMyCFkmAnmK6hR1b5VjHxhzt5/FX21x1WsXfqF4FwdQ== dependencies: "@apollographql/apollo-tools" "^0.3.3" @@ -3670,7 +3979,7 @@ helmet-csp@2.7.1: dasherize "2.0.0" platform "1.3.5" -helmet@^3.15.1: +helmet@~3.15.1: version "3.15.1" resolved "https://registry.yarnpkg.com/helmet/-/helmet-3.15.1.tgz#2c80d1a59138b6f23929605afca4b1c88b3298ec" integrity sha512-hgoNe/sjKlKNvJ3g9Gz149H14BjMMWOCmW/DTXl7IfyKGtIK37GePwZrHNfr4aPXdKVyXcTj26RgRFbPKDy9lw== @@ -3882,7 +4191,7 @@ inherits@2, inherits@2.0.3, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.1, i resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= -inherits@=2.0.1: +inherits@2.0.1, inherits@=2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1" integrity sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE= @@ -3892,21 +4201,21 @@ ini@^1.3.4, ini@~1.3.0: resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== -inquirer@^6.1.0: - version "6.2.1" - resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.2.1.tgz#9943fc4882161bdb0b0c9276769c75b32dbfcd52" - integrity sha512-088kl3DRT2dLU5riVMKKr1DlImd6X7smDhpXUCkJDCKvTEJeRiXh0G132HG9u5a+6Ylw9plFRY7RuTnwohYSpg== +inquirer@^6.2.2: + version "6.2.2" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.2.2.tgz#46941176f65c9eb20804627149b743a218f25406" + integrity sha512-Z2rREiXA6cHRR9KBOarR3WuLlFzlIfAEIiB45ll5SSadMg7WqOh1MKEjjndfuH5ewXdixWCxqnVfGOQzPeiztA== dependencies: - ansi-escapes "^3.0.0" - chalk "^2.0.0" + ansi-escapes "^3.2.0" + chalk "^2.4.2" cli-cursor "^2.1.0" cli-width "^2.0.0" - external-editor "^3.0.0" + external-editor "^3.0.3" figures "^2.0.0" - lodash "^4.17.10" + lodash "^4.17.11" mute-stream "0.0.7" run-async "^2.2.0" - rxjs "^6.1.0" + rxjs "^6.4.0" string-width "^2.1.0" strip-ansi "^5.0.0" through "^2.3.6" @@ -4210,10 +4519,10 @@ isstream@~0.1.2: resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= -istanbul-api@^2.0.8: - version "2.1.0" - resolved "https://registry.yarnpkg.com/istanbul-api/-/istanbul-api-2.1.0.tgz#37ab0c2c3e83065462f5254b94749d6157846c4e" - integrity sha512-+Ygg4t1StoiNlBGc6x0f8q/Bv26FbZqP/+jegzfNpU7Q8o+4ZRoJxJPhBkgE/UonpAjtxnE4zCZIyJX+MwLRMQ== +istanbul-api@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/istanbul-api/-/istanbul-api-2.1.1.tgz#194b773f6d9cbc99a9258446848b0f988951c4d0" + integrity sha512-kVmYrehiwyeBAk/wE71tW6emzLiHGjYIiDrc8sfyty4F8M02/lrgXSm+R1kXysmF20zArvmZXjlE/mg24TVPJw== dependencies: async "^2.6.1" compare-versions "^3.2.1" @@ -4223,7 +4532,7 @@ istanbul-api@^2.0.8: istanbul-lib-instrument "^3.1.0" istanbul-lib-report "^2.0.4" istanbul-lib-source-maps "^3.0.2" - istanbul-reports "^2.1.0" + istanbul-reports "^2.1.1" js-yaml "^3.12.0" make-dir "^1.3.0" minimatch "^3.0.4" @@ -4274,7 +4583,7 @@ istanbul-lib-source-maps@^3.0.1, istanbul-lib-source-maps@^3.0.2: rimraf "^2.6.2" source-map "^0.6.1" -istanbul-reports@^2.1.0, istanbul-reports@^2.1.1: +istanbul-reports@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-2.1.1.tgz#72ef16b4ecb9a4a7bd0e2001e00f95d1eec8afa9" integrity sha512-FzNahnidyEPBCI0HcufJoSEoKykesRlFcSzQqjH9x0+LC8tnnE/p/90PBLu8iZTxr8yYZNyTtiAujUqyN+CIxw== @@ -4286,334 +4595,349 @@ iterall@^1.1.3, iterall@^1.2.1, iterall@^1.2.2: resolved "https://registry.yarnpkg.com/iterall/-/iterall-1.2.2.tgz#92d70deb8028e0c39ff3164fdbf4d8b088130cd7" integrity sha512-yynBb1g+RFUPY64fTrFv7nsjRrENBQJaX2UL+2Szc9REFrSNm1rpSXHGzhmAy7a9uv3vlvgBlXnf9RqmPH1/DA== -jest-changed-files@^24.0.0: - version "24.0.0" - resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-24.0.0.tgz#c02c09a8cc9ca93f513166bc773741bd39898ff7" - integrity sha512-nnuU510R9U+UX0WNb5XFEcsrMqriSiRLeO9KWDFgPrpToaQm60prfQYpxsXigdClpvNot5bekDY440x9dNGnsQ== +jest-changed-files@^24.3.0: + version "24.3.0" + resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-24.3.0.tgz#7050ae29aaf1d59437c80f21d5b3cd354e88a499" + integrity sha512-fTq0YAUR6644fgsqLC7Zi2gXA/bAplMRvfXQdutmkwgrCKK6upkj+sgXqsUfUZRm15CVr3YSojr/GRNn71IMvg== dependencies: + "@jest/types" "^24.3.0" execa "^1.0.0" throat "^4.0.0" -jest-cli@^24.1.0: - version "24.1.0" - resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-24.1.0.tgz#f7cc98995f36e7210cce3cbb12974cbf60940843" - integrity sha512-U/iyWPwOI0T1CIxVLtk/2uviOTJ/OiSWJSe8qt6X1VkbbgP+nrtLJlmT9lPBe4lK78VNFJtrJ7pttcNv/s7yCw== +jest-cli@^24.3.1: + version "24.3.1" + resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-24.3.1.tgz#52e4ae5f11044b41e06ca39fc7a7302fbbcb1661" + integrity sha512-HdwMgigvDQdlWX7gwM2QMkJJRqSk7tTYKq7kVplblK28RarqquJMWV/lOCN8CukuG9u3DZTeXpCDXR7kpGfB3w== dependencies: - ansi-escapes "^3.0.0" + "@jest/core" "^24.3.1" + "@jest/test-result" "^24.3.0" + "@jest/types" "^24.3.0" chalk "^2.0.1" exit "^0.1.2" - glob "^7.1.2" - graceful-fs "^4.1.15" import-local "^2.0.0" is-ci "^2.0.0" - istanbul-api "^2.0.8" - istanbul-lib-coverage "^2.0.2" - istanbul-lib-instrument "^3.0.1" - istanbul-lib-source-maps "^3.0.1" - jest-changed-files "^24.0.0" - jest-config "^24.1.0" - jest-environment-jsdom "^24.0.0" - jest-get-type "^24.0.0" - jest-haste-map "^24.0.0" - jest-message-util "^24.0.0" - jest-regex-util "^24.0.0" - jest-resolve-dependencies "^24.1.0" - jest-runner "^24.1.0" - jest-runtime "^24.1.0" - jest-snapshot "^24.1.0" - jest-util "^24.0.0" - jest-validate "^24.0.0" - jest-watcher "^24.0.0" - jest-worker "^24.0.0" - micromatch "^3.1.10" - node-notifier "^5.2.1" - p-each-series "^1.0.0" - pirates "^4.0.0" + jest-config "^24.3.1" + jest-util "^24.3.0" + jest-validate "^24.3.1" prompts "^2.0.1" - realpath-native "^1.0.0" - rimraf "^2.5.4" - slash "^2.0.0" - string-length "^2.0.0" - strip-ansi "^5.0.0" - which "^1.2.12" + realpath-native "^1.1.0" yargs "^12.0.2" -jest-config@^24.1.0: - version "24.1.0" - resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-24.1.0.tgz#6ea6881cfdd299bc86cc144ee36d937c97c3850c" - integrity sha512-FbbRzRqtFC6eGjG5VwsbW4E5dW3zqJKLWYiZWhB0/4E5fgsMw8GODLbGSrY5t17kKOtCWb/Z7nsIThRoDpuVyg== +jest-config@^24.3.1: + version "24.3.1" + resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-24.3.1.tgz#271aff2d3aeabf1ff92512024eeca3323cd31a07" + integrity sha512-ujHQywsM//vKFvJwEC02KNZgKAGOzGz1bFPezmTQtuj8XdfsAVq8p6N/dw4yodXV11gSf6TJ075i4ehM+mKatA== dependencies: "@babel/core" "^7.1.0" - babel-jest "^24.1.0" + "@jest/types" "^24.3.0" + babel-jest "^24.3.1" chalk "^2.0.1" glob "^7.1.1" - jest-environment-jsdom "^24.0.0" - jest-environment-node "^24.0.0" - jest-get-type "^24.0.0" - jest-jasmine2 "^24.1.0" - jest-regex-util "^24.0.0" - jest-resolve "^24.1.0" - jest-util "^24.0.0" - jest-validate "^24.0.0" + jest-environment-jsdom "^24.3.1" + jest-environment-node "^24.3.1" + jest-get-type "^24.3.0" + jest-jasmine2 "^24.3.1" + jest-regex-util "^24.3.0" + jest-resolve "^24.3.1" + jest-util "^24.3.0" + jest-validate "^24.3.1" micromatch "^3.1.10" - pretty-format "^24.0.0" - realpath-native "^1.0.2" + pretty-format "^24.3.1" + realpath-native "^1.1.0" -jest-diff@^24.0.0: - version "24.0.0" - resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-24.0.0.tgz#a3e5f573dbac482f7d9513ac9cfa21644d3d6b34" - integrity sha512-XY5wMpRaTsuMoU+1/B2zQSKQ9RdE9gsLkGydx3nvApeyPijLA8GtEvIcPwISRCer+VDf9W1mStTYYq6fPt8ryA== +jest-diff@^24.3.1: + version "24.3.1" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-24.3.1.tgz#87952e5ea1548567da91df398fa7bf7977d3f96a" + integrity sha512-YRVzDguyzShP3Pb9wP/ykBkV7Z+O4wltrMZ2P4LBtNxrHNpxwI2DECrpD9XevxWubRy5jcE8sSkxyX3bS7W+rA== dependencies: chalk "^2.0.1" - diff-sequences "^24.0.0" - jest-get-type "^24.0.0" - pretty-format "^24.0.0" + diff-sequences "^24.3.0" + jest-get-type "^24.3.0" + pretty-format "^24.3.1" -jest-docblock@^24.0.0: - version "24.0.0" - resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-24.0.0.tgz#54d77a188743e37f62181a91a01eb9222289f94e" - integrity sha512-KfAKZ4SN7CFOZpWg4i7g7MSlY0M+mq7K0aMqENaG2vHuhC9fc3vkpU/iNN9sOus7v3h3Y48uEjqz3+Gdn2iptA== +jest-docblock@^24.3.0: + version "24.3.0" + resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-24.3.0.tgz#b9c32dac70f72e4464520d2ba4aec02ab14db5dd" + integrity sha512-nlANmF9Yq1dufhFlKG9rasfQlrY7wINJbo3q01tu56Jv5eBU5jirylhF2O5ZBnLxzOVBGRDz/9NAwNyBtG4Nyg== dependencies: detect-newline "^2.1.0" -jest-each@^24.0.0: - version "24.0.0" - resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-24.0.0.tgz#10987a06b21c7ffbfb7706c89d24c52ed864be55" - integrity sha512-gFcbY4Cu55yxExXMkjrnLXov3bWO3dbPAW7HXb31h/DNWdNc/6X8MtxGff8nh3/MjkF9DpVqnj0KsPKuPK0cpA== +jest-each@^24.3.1: + version "24.3.1" + resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-24.3.1.tgz#ed8fe8b9f92a835a6625ca8c7ee06bc904440316" + integrity sha512-GTi+nxDaWwSgOPLiiqb/p4LURy0mv3usoqsA2eoTYSmRsLgjgZ6VUyRpUBH5JY9EMBx33suNFXk0iyUm29WRpw== dependencies: + "@jest/types" "^24.3.0" chalk "^2.0.1" - jest-get-type "^24.0.0" - jest-util "^24.0.0" - pretty-format "^24.0.0" + jest-get-type "^24.3.0" + jest-util "^24.3.0" + pretty-format "^24.3.1" -jest-environment-jsdom@^24.0.0: - version "24.0.0" - resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-24.0.0.tgz#5affa0654d6e44cd798003daa1a8701dbd6e4d11" - integrity sha512-1YNp7xtxajTRaxbylDc2pWvFnfDTH5BJJGyVzyGAKNt/lEULohwEV9zFqTgG4bXRcq7xzdd+sGFws+LxThXXOw== +jest-environment-jsdom@^24.3.1: + version "24.3.1" + resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-24.3.1.tgz#49826bcf12fb3e38895f1e2aaeb52bde603cc2e4" + integrity sha512-rz2OSYJiQerDqWDwjisqRwhVNpwkqFXdtyMzEuJ47Ip9NRpRQ+qy7/+zFujPUy/Z+zjWRO5seHLB/dOD4VpEVg== dependencies: - jest-mock "^24.0.0" - jest-util "^24.0.0" + "@jest/environment" "^24.3.1" + "@jest/fake-timers" "^24.3.0" + "@jest/types" "^24.3.0" + jest-mock "^24.3.0" + jest-util "^24.3.0" jsdom "^11.5.1" -jest-environment-node@^24.0.0: - version "24.0.0" - resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-24.0.0.tgz#330948980656ed8773ce2e04eb597ed91e3c7190" - integrity sha512-62fOFcaEdU0VLaq8JL90TqwI7hLn0cOKOl8vY2n477vRkCJRojiRRtJVRzzCcgFvs6gqU97DNqX5R0BrBP6Rxg== +jest-environment-node@^24.3.1: + version "24.3.1" + resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-24.3.1.tgz#333d864c569b27658a96bb3b10e02e7172125415" + integrity sha512-Xy+/yFem/yUs9OkzbcawQT237vwDjBhAVLjac1KYAMYVjGb0Vb/Ovw4g61PunVdrEIpfcXNtRUltM4+9c7lARQ== dependencies: - jest-mock "^24.0.0" - jest-util "^24.0.0" + "@jest/environment" "^24.3.1" + "@jest/fake-timers" "^24.3.0" + "@jest/types" "^24.3.0" + jest-mock "^24.3.0" + jest-util "^24.3.0" -jest-get-type@^24.0.0: - version "24.0.0" - resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-24.0.0.tgz#36e72930b78e33da59a4f63d44d332188278940b" - integrity sha512-z6/Eyf6s9ZDGz7eOvl+fzpuJmN9i0KyTt1no37/dHu8galssxz5ZEgnc1KaV8R31q1khxyhB4ui/X5ZjjPk77w== +jest-get-type@^24.3.0: + version "24.3.0" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-24.3.0.tgz#582cfd1a4f91b5cdad1d43d2932f816d543c65da" + integrity sha512-HYF6pry72YUlVcvUx3sEpMRwXEWGEPlJ0bSPVnB3b3n++j4phUEoSPcS6GC0pPJ9rpyPSe4cb5muFo6D39cXow== -jest-haste-map@^24.0.0: - version "24.0.0" - resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-24.0.0.tgz#e9ef51b2c9257384b4d6beb83bd48c65b37b5e6e" - integrity sha512-CcViJyUo41IQqttLxXVdI41YErkzBKbE6cS6dRAploCeutePYfUimWd3C9rQEWhX0YBOQzvNsC0O9nYxK2nnxQ== +jest-haste-map@^24.3.1: + version "24.3.1" + resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-24.3.1.tgz#b4a66dbe1e6bc45afb9cd19c083bff81cdd535a1" + integrity sha512-OTMQle+astr1lWKi62Ccmk2YWn6OtUoU/8JpJdg8zdsnpFIry/k0S4sQ4nWocdM07PFdvqcthWc78CkCE6sXvA== dependencies: + "@jest/types" "^24.3.0" fb-watchman "^2.0.0" graceful-fs "^4.1.15" invariant "^2.2.4" - jest-serializer "^24.0.0" - jest-util "^24.0.0" - jest-worker "^24.0.0" + jest-serializer "^24.3.0" + jest-util "^24.3.0" + jest-worker "^24.3.1" micromatch "^3.1.10" - sane "^3.0.0" + sane "^4.0.3" -jest-jasmine2@^24.1.0: - version "24.1.0" - resolved "https://registry.yarnpkg.com/jest-jasmine2/-/jest-jasmine2-24.1.0.tgz#8377324b967037c440f0a549ee0bbd9912055db6" - integrity sha512-H+o76SdSNyCh9fM5K8upK45YTo/DiFx5w2YAzblQebSQmukDcoVBVeXynyr7DDnxh+0NTHYRCLwJVf3tC518wg== +jest-jasmine2@^24.3.1: + version "24.3.1" + resolved "https://registry.yarnpkg.com/jest-jasmine2/-/jest-jasmine2-24.3.1.tgz#127d628d3ac0829bd3c0fccacb87193e543b420b" + integrity sha512-STo6ar1IyPlIPq9jPxDQhM7lC0dAX7KKN0LmCLMlgJeXwX+1XiVdtZDv1a4zyg6qhNdpo1arOBGY0BcovUK7ug== dependencies: "@babel/traverse" "^7.1.0" + "@jest/environment" "^24.3.1" + "@jest/test-result" "^24.3.0" + "@jest/types" "^24.3.0" chalk "^2.0.1" co "^4.6.0" - expect "^24.1.0" + expect "^24.3.1" is-generator-fn "^2.0.0" - jest-each "^24.0.0" - jest-matcher-utils "^24.0.0" - jest-message-util "^24.0.0" - jest-snapshot "^24.1.0" - jest-util "^24.0.0" - pretty-format "^24.0.0" + jest-each "^24.3.1" + jest-matcher-utils "^24.3.1" + jest-message-util "^24.3.0" + jest-runtime "^24.3.1" + jest-snapshot "^24.3.1" + jest-util "^24.3.0" + pretty-format "^24.3.1" throat "^4.0.0" -jest-leak-detector@^24.0.0: - version "24.0.0" - resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-24.0.0.tgz#78280119fd05ee98317daee62cddb3aa537a31c6" - integrity sha512-ZYHJYFeibxfsDSKowjDP332pStuiFT2xfc5R67Rjm/l+HFJWJgNIOCOlQGeXLCtyUn3A23+VVDdiCcnB6dTTrg== +jest-leak-detector@^24.3.1: + version "24.3.1" + resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-24.3.1.tgz#ed89d05ca07e91b2b51dac1f676ab354663aa8da" + integrity sha512-GncRwEtAw/SohdSyY4bk2RE06Ac1dZrtQGZQ2j35hSuN4gAAAKSYMszJS2WDixsAEaFN+GHBHG+d8pjVGklKyw== dependencies: - pretty-format "^24.0.0" + pretty-format "^24.3.1" -jest-matcher-utils@^24.0.0: - version "24.0.0" - resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-24.0.0.tgz#fc9c41cfc49b2c3ec14e576f53d519c37729d579" - integrity sha512-LQTDmO+aWRz1Tf9HJg+HlPHhDh1E1c65kVwRFo5mwCVp5aQDzlkz4+vCvXhOKFjitV2f0kMdHxnODrXVoi+rlA== +jest-matcher-utils@^24.3.1: + version "24.3.1" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-24.3.1.tgz#025e1cd9c54a5fde68e74b12428775d06d123aa8" + integrity sha512-P5VIsUTJeI0FYvWVMwEHjxK1L83vEkDiKMV0XFPIrT2jzWaWPB2+dPCHkP2ID9z4eUKElaHqynZnJiOdNVHfXQ== dependencies: chalk "^2.0.1" - jest-diff "^24.0.0" - jest-get-type "^24.0.0" - pretty-format "^24.0.0" + jest-diff "^24.3.1" + jest-get-type "^24.3.0" + pretty-format "^24.3.1" -jest-message-util@^24.0.0: - version "24.0.0" - resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-24.0.0.tgz#a07a141433b2c992dbaec68d4cbfe470ba289619" - integrity sha512-J9ROJIwz/IeC+eV1XSwnRK4oAwPuhmxEyYx1+K5UI+pIYwFZDSrfZaiWTdq0d2xYFw4Xiu+0KQWsdsQpgJMf3Q== +jest-message-util@^24.3.0: + version "24.3.0" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-24.3.0.tgz#e8f64b63ebc75b1a9c67ee35553752596e70d4a9" + integrity sha512-lXM0YgKYGqN5/eH1NGw4Ix+Pk2I9Y77beyRas7xM24n+XTTK3TbT0VkT3L/qiyS7WkW0YwyxoXnnAaGw4hsEDA== dependencies: "@babel/code-frame" "^7.0.0" + "@jest/test-result" "^24.3.0" + "@jest/types" "^24.3.0" + "@types/stack-utils" "^1.0.1" chalk "^2.0.1" micromatch "^3.1.10" slash "^2.0.0" stack-utils "^1.0.1" -jest-mock@^24.0.0: - version "24.0.0" - resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-24.0.0.tgz#9a4b53e01d66a0e780f7d857462d063e024c617d" - integrity sha512-sQp0Hu5fcf5NZEh1U9eIW2qD0BwJZjb63Yqd98PQJFvf/zzUTBoUAwv/Dc/HFeNHIw1f3hl/48vNn+j3STaI7A== - -jest-regex-util@^24.0.0: - version "24.0.0" - resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-24.0.0.tgz#4feee8ec4a358f5bee0a654e94eb26163cb9089a" - integrity sha512-Jv/uOTCuC+PY7WpJl2mpoI+WbY2ut73qwwO9ByJJNwOCwr1qWhEW2Lyi2S9ZewUdJqeVpEBisdEVZSI+Zxo58Q== - -jest-resolve-dependencies@^24.1.0: - version "24.1.0" - resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-24.1.0.tgz#78f738a2ec59ff4d00751d9da56f176e3f589f6c" - integrity sha512-2VwPsjd3kRPu7qe2cpytAgowCObk5AKeizfXuuiwgm1a9sijJDZe8Kh1sFj6FKvSaNEfCPlBVkZEJa2482m/Uw== +jest-mock@^24.3.0: + version "24.3.0" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-24.3.0.tgz#95a86b6ad474e3e33227e6dd7c4ff6b07e18d3cb" + integrity sha512-AhAo0qjbVWWGvcbW5nChFjR0ObQImvGtU6DodprNziDOt+pP0CBdht/sYcNIOXeim8083QUi9bC8QdKB8PTK4Q== dependencies: - jest-regex-util "^24.0.0" - jest-snapshot "^24.1.0" + "@jest/types" "^24.3.0" -jest-resolve@^24.1.0: - version "24.1.0" - resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-24.1.0.tgz#42ff0169b0ea47bfdbd0c52a0067ca7d022c7688" - integrity sha512-TPiAIVp3TG6zAxH28u/6eogbwrvZjBMWroSLBDkwkHKrqxB/RIdwkWDye4uqPlZIXWIaHtifY3L0/eO5Z0f2wg== +jest-regex-util@^24.3.0: + version "24.3.0" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-24.3.0.tgz#d5a65f60be1ae3e310d5214a0307581995227b36" + integrity sha512-tXQR1NEOyGlfylyEjg1ImtScwMq8Oh3iJbGTjN7p0J23EuVX1MA8rwU69K4sLbCmwzgCUbVkm0FkSF9TdzOhtg== + +jest-resolve-dependencies@^24.3.1: + version "24.3.1" + resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-24.3.1.tgz#a22839d611ba529a74594ee274ce2b77d046bea9" + integrity sha512-9JUejNImGnJjbNR/ttnod+zQIWANpsrYMPt18s2tYGK6rP191qFsyEQ2BhAQMdYDRkTmi8At+Co9tL+jTPqdpw== dependencies: + "@jest/types" "^24.3.0" + jest-regex-util "^24.3.0" + jest-snapshot "^24.3.1" + +jest-resolve@^24.3.1: + version "24.3.1" + resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-24.3.1.tgz#103dbd438b59618ea428ec4acbd65c56495ba397" + integrity sha512-N+Q3AcVuKxpn/kjQMxUVLwBk32ZE1diP4MPcHyjVwcKpCUuKrktfRR3Mqe/T2HoD25wyccstaqcPUKIudl41bg== + dependencies: + "@jest/types" "^24.3.0" browser-resolve "^1.11.3" chalk "^2.0.1" - realpath-native "^1.0.0" + realpath-native "^1.1.0" -jest-runner@^24.1.0: - version "24.1.0" - resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-24.1.0.tgz#3686a2bb89ce62800da23d7fdc3da2c32792943b" - integrity sha512-CDGOkT3AIFl16BLL/OdbtYgYvbAprwJ+ExKuLZmGSCSldwsuU2dEGauqkpvd9nphVdAnJUcP12e/EIlnTX0QXg== +jest-runner@^24.3.1: + version "24.3.1" + resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-24.3.1.tgz#5488566fa60cdb4b00a89c734ad6b54b9561415d" + integrity sha512-Etc9hQ5ruwg+q7DChm+E8qzHHdNTLeUdlo+whPQRSpNSgl0AEgc2r2mT4lxODREqmnHg9A8JHA44pIG4GE0Gzg== dependencies: + "@jest/console" "^24.3.0" + "@jest/environment" "^24.3.1" + "@jest/test-result" "^24.3.0" + "@jest/types" "^24.3.0" chalk "^2.4.2" exit "^0.1.2" graceful-fs "^4.1.15" - jest-config "^24.1.0" - jest-docblock "^24.0.0" - jest-haste-map "^24.0.0" - jest-jasmine2 "^24.1.0" - jest-leak-detector "^24.0.0" - jest-message-util "^24.0.0" - jest-runtime "^24.1.0" - jest-util "^24.0.0" - jest-worker "^24.0.0" + jest-config "^24.3.1" + jest-docblock "^24.3.0" + jest-haste-map "^24.3.1" + jest-jasmine2 "^24.3.1" + jest-leak-detector "^24.3.1" + jest-message-util "^24.3.0" + jest-resolve "^24.3.1" + jest-runtime "^24.3.1" + jest-util "^24.3.0" + jest-worker "^24.3.1" source-map-support "^0.5.6" throat "^4.0.0" -jest-runtime@^24.1.0: - version "24.1.0" - resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-24.1.0.tgz#7c157a2e776609e8cf552f956a5a19ec9c985214" - integrity sha512-59/BY6OCuTXxGeDhEMU7+N33dpMQyXq7MLK07cNSIY/QYt2QZgJ7Tjx+rykBI0skAoigFl0A5tmT8UdwX92YuQ== +jest-runtime@^24.3.1: + version "24.3.1" + resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-24.3.1.tgz#2798230b4fbed594b375a13e395278694d4751e2" + integrity sha512-Qz/tJWbZ2naFJ2Kvy1p+RhhRgsPYh4e6wddVRy6aHBr32FTt3Ja33bfV7pkMFWXFbVuAsJMJVdengbvdhWzq4A== dependencies: - "@babel/core" "^7.1.0" - babel-plugin-istanbul "^5.1.0" + "@jest/console" "^24.3.0" + "@jest/environment" "^24.3.1" + "@jest/source-map" "^24.3.0" + "@jest/transform" "^24.3.1" + "@jest/types" "^24.3.0" + "@types/yargs" "^12.0.2" chalk "^2.0.1" - convert-source-map "^1.4.0" exit "^0.1.2" - fast-json-stable-stringify "^2.0.0" glob "^7.1.3" graceful-fs "^4.1.15" - jest-config "^24.1.0" - jest-haste-map "^24.0.0" - jest-message-util "^24.0.0" - jest-regex-util "^24.0.0" - jest-resolve "^24.1.0" - jest-snapshot "^24.1.0" - jest-util "^24.0.0" - jest-validate "^24.0.0" - micromatch "^3.1.10" - realpath-native "^1.0.0" + jest-config "^24.3.1" + jest-haste-map "^24.3.1" + jest-message-util "^24.3.0" + jest-mock "^24.3.0" + jest-regex-util "^24.3.0" + jest-resolve "^24.3.1" + jest-snapshot "^24.3.1" + jest-util "^24.3.0" + jest-validate "^24.3.1" + realpath-native "^1.1.0" slash "^2.0.0" strip-bom "^3.0.0" - write-file-atomic "2.4.1" yargs "^12.0.2" -jest-serializer@^24.0.0: - version "24.0.0" - resolved "https://registry.yarnpkg.com/jest-serializer/-/jest-serializer-24.0.0.tgz#522c44a332cdd194d8c0531eb06a1ee5afb4256b" - integrity sha512-9FKxQyrFgHtx3ozU+1a8v938ILBE7S8Ko3uiAVjT8Yfi2o91j/fj81jacCQZ/Ihjiff/VsUCXVgQ+iF1XdImOw== +jest-serializer@^24.3.0: + version "24.3.0" + resolved "https://registry.yarnpkg.com/jest-serializer/-/jest-serializer-24.3.0.tgz#074e307300d1451617cf2630d11543ee4f74a1c8" + integrity sha512-RiSpqo2OFbVLJN/PgAOwQIUeHDfss6NBUDTLhjiJM8Bb5rMrwRqHfkaqahIsOf9cXXB5UjcqDCzbQ7AIoMqWkg== -jest-snapshot@^24.1.0: - version "24.1.0" - resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-24.1.0.tgz#85e22f810357aa5994ab61f236617dc2205f2f5b" - integrity sha512-th6TDfFqEmXvuViacU1ikD7xFb7lQsPn2rJl7OEmnfIVpnrx3QNY2t3PE88meeg0u/mQ0nkyvmC05PBqO4USFA== +jest-snapshot@^24.3.1: + version "24.3.1" + resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-24.3.1.tgz#0f22a86c1b8c87e823f5ad095e82c19d9ed93d72" + integrity sha512-7wbNJWh0sBjmoaexTOWqS7nleTQME7o2W9XKU6CHCxG49Thjct4aVPC/QPNF5NHnvf4M/VDmudIDbwz6noJTRA== dependencies: "@babel/types" "^7.0.0" + "@jest/types" "^24.3.0" chalk "^2.0.1" - jest-diff "^24.0.0" - jest-matcher-utils "^24.0.0" - jest-message-util "^24.0.0" - jest-resolve "^24.1.0" + expect "^24.3.1" + jest-diff "^24.3.1" + jest-matcher-utils "^24.3.1" + jest-message-util "^24.3.0" + jest-resolve "^24.3.1" mkdirp "^0.5.1" natural-compare "^1.4.0" - pretty-format "^24.0.0" + pretty-format "^24.3.1" semver "^5.5.0" -jest-util@^24.0.0: - version "24.0.0" - resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-24.0.0.tgz#fd38fcafd6dedbd0af2944d7a227c0d91b68f7d6" - integrity sha512-QxsALc4wguYS7cfjdQSOr5HTkmjzkHgmZvIDkcmPfl1ib8PNV8QUWLwbKefCudWS0PRKioV+VbQ0oCUPC691fQ== +jest-util@^24.3.0: + version "24.3.0" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-24.3.0.tgz#a549ae9910fedbd4c5912b204bb1bcc122ea0057" + integrity sha512-eKIAC+MTKWZthUUVOwZ3Tc5a0cKMnxalQHr6qZ4kPzKn6k09sKvsmjCygqZ1SxVVfUKoa8Sfn6XDv9uTJ1iXTg== dependencies: + "@jest/console" "^24.3.0" + "@jest/fake-timers" "^24.3.0" + "@jest/source-map" "^24.3.0" + "@jest/test-result" "^24.3.0" + "@jest/types" "^24.3.0" + "@types/node" "*" callsites "^3.0.0" chalk "^2.0.1" graceful-fs "^4.1.15" is-ci "^2.0.0" - jest-message-util "^24.0.0" mkdirp "^0.5.1" slash "^2.0.0" source-map "^0.6.0" -jest-validate@^24.0.0: - version "24.0.0" - resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-24.0.0.tgz#aa8571a46983a6538328fef20406b4a496b6c020" - integrity sha512-vMrKrTOP4BBFIeOWsjpsDgVXATxCspC9S1gqvbJ3Tnn/b9ACsJmteYeVx9830UMV28Cob1RX55x96Qq3Tfad4g== +jest-validate@^24.3.1: + version "24.3.1" + resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-24.3.1.tgz#9359eea5a767a3d20b4fa7a5764fd78330ba8312" + integrity sha512-ww3+IPNCOEMi1oKlrHdSnBXetXtdrrdSh0bqLNTVkWglduhORf94RJWd1ko9oEPU2TcEQS5QIPacYziQIUzc4A== dependencies: + "@jest/types" "^24.3.0" camelcase "^5.0.0" chalk "^2.0.1" - jest-get-type "^24.0.0" + jest-get-type "^24.3.0" leven "^2.1.0" - pretty-format "^24.0.0" + pretty-format "^24.3.1" -jest-watcher@^24.0.0: - version "24.0.0" - resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-24.0.0.tgz#20d44244d10b0b7312410aefd256c1c1eef68890" - integrity sha512-GxkW2QrZ4YxmW1GUWER05McjVDunBlKMFfExu+VsGmXJmpej1saTEKvONdx5RJBlVdpPI5x6E3+EDQSIGgl53g== +jest-watcher@^24.3.0: + version "24.3.0" + resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-24.3.0.tgz#ee51c6afbe4b35a12fcf1107556db6756d7b9290" + integrity sha512-EpJS/aUG8D3DMuy9XNA4fnkKWy3DQdoWhY92ZUdlETIeEn1xya4Np/96MBSh4II5YvxwKe6JKwbu3Bnzfwa7vA== dependencies: + "@jest/test-result" "^24.3.0" + "@jest/types" "^24.3.0" + "@types/node" "*" + "@types/yargs" "^12.0.9" ansi-escapes "^3.0.0" chalk "^2.0.1" - jest-util "^24.0.0" + jest-util "^24.3.0" string-length "^2.0.0" -jest-worker@^24.0.0: - version "24.0.0" - resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-24.0.0.tgz#3d3483b077bf04f412f47654a27bba7e947f8b6d" - integrity sha512-s64/OThpfQvoCeHG963MiEZOAAxu8kHsaL/rCMF7lpdzo7vgF0CtPml9hfguOMgykgH/eOm4jFP4ibfHLruytg== +jest-worker@^24.3.1: + version "24.3.1" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-24.3.1.tgz#c1759dd2b1d5541b09a2e5e1bc3288de6c9d8632" + integrity sha512-ZCoAe/iGLzTJvWHrO8fyx3bmEQhpL16SILJmWHKe8joHhyF3z00psF1sCRT54DoHw5GJG0ZpUtGy+ylvwA4haA== dependencies: + "@types/node" "*" merge-stream "^1.0.1" supports-color "^6.1.0" -jest@~24.1.0: - version "24.1.0" - resolved "https://registry.yarnpkg.com/jest/-/jest-24.1.0.tgz#b1e1135caefcf2397950ecf7f90e395fde866fd2" - integrity sha512-+q91L65kypqklvlRFfXfdzUKyngQLOcwGhXQaLmVHv+d09LkNXuBuGxlofTFW42XMzu3giIcChchTsCNUjQ78A== +jest@~24.3.1: + version "24.3.1" + resolved "https://registry.yarnpkg.com/jest/-/jest-24.3.1.tgz#81959de0d57b2df923510f4fafe266712d37dcca" + integrity sha512-SqZguEbYNcZ3r0KUUBN+IkKfyPS1VBbIUiK4Wrc0AiGUR52gJa0fmlWSOCL3x25908QrfoQwkVDu5jCsfXb2ig== dependencies: import-local "^2.0.0" - jest-cli "^24.1.0" + jest-cli "^24.3.1" jmespath@0.15.0: version "0.15.0" @@ -4768,7 +5092,7 @@ jsonld@^0.4.11: request "^2.61.0" xmldom "0.1.19" -jsonwebtoken@^8.2.0, jsonwebtoken@~8.5.0: +jsonwebtoken@^8.3.0, jsonwebtoken@~8.5.0: version "8.5.0" resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.0.tgz#ebd0ca2a69797816e1c5af65b6c759787252947e" integrity sha512-IqEycp0znWHNA11TpYi77bVgyBO/pGESDh7Ajhas+u0ttkGkKYIIAjniL4Bw5+oVejVF+SYkaI7XKfwCCyeTuA== @@ -5116,11 +5440,6 @@ merge-stream@^1.0.1: dependencies: readable-stream "^2.0.1" -merge@^1.2.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/merge/-/merge-1.2.1.tgz#38bebf80c3220a8a487b6fcfb3941bb11720c145" - integrity sha512-VjFo4P5Whtj4vsLzsYBu5ayHhoHJ0UqNm7ibvShmbmoz7tGi0vXaoJbGdB+GmDMLUdg8DpQXEIeVDAe8MaABvQ== - methods@^1.1.1, methods@^1.1.2, methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" @@ -5309,21 +5628,22 @@ negotiator@0.6.1: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9" integrity sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk= -neo4j-driver@^1.7.2, neo4j-driver@~1.7.2: - version "1.7.2" - resolved "https://registry.yarnpkg.com/neo4j-driver/-/neo4j-driver-1.7.2.tgz#c72a6dfa6bd2106b00a42794dc52a82b227b48e0" - integrity sha512-0IvCFYhcP9hb5JveZk33epbReDKpFTn2u5vAa8zzGG344i6yFqZrBo0mtC114ciP9zFjAtfNOP72mRm8+NV0Fg== +neo4j-driver@^1.7.2, neo4j-driver@~1.7.3: + version "1.7.3" + resolved "https://registry.yarnpkg.com/neo4j-driver/-/neo4j-driver-1.7.3.tgz#1c1108ab26b7243975f1b20045daf31d8f685207" + integrity sha512-UCNOFiQdouq14PvZGTr+psy657BJsBpO6O2cJpP+NprZnEF4APrDzAcydPZSFxE1nfooLNc50vfuZ0q54UyY2Q== dependencies: babel-runtime "^6.26.0" text-encoding "^0.6.4" uri-js "^4.2.1" -neo4j-graphql-js@~2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/neo4j-graphql-js/-/neo4j-graphql-js-2.3.1.tgz#9a5de7e312594d63481e947a0cbe4e08b05ffed3" - integrity sha512-9DExWXD2vFdDJOmqorT1ygFOUEos7KF8KyF8Wt3jYxejWUuq+a5fAFBu7+YDH8QbvA23paKPEX0Pn1nS+Q5C1A== +neo4j-graphql-js@~2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/neo4j-graphql-js/-/neo4j-graphql-js-2.4.1.tgz#a1de65340fb6f1ad0ae6aadab90a0bb78b490b32" + integrity sha512-Py6RJuMT7A03Hzoo6qfKyo6DUnHQgbZlBcgucnhgQjbeysAzvM3F02UAVn/HxEtOgowAeGWjyjJvwozoNtiiqQ== dependencies: graphql "^14.0.2" + graphql-auth-directives "^2.0.0" lodash "^4.17.11" neo4j-driver "^1.7.2" @@ -5838,27 +6158,6 @@ pascalcase@^0.1.1: resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ= -passport-jwt@~4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/passport-jwt/-/passport-jwt-4.0.0.tgz#7f0be7ba942e28b9f5d22c2ebbb8ce96ef7cf065" - integrity sha512-BwC0n2GP/1hMVjR4QpnvqA61TxenUMlmfNjYNgK0ZAs0HK4SOQkHcSv4L328blNTLtHq7DbmvyNJiH+bn6C5Mg== - dependencies: - jsonwebtoken "^8.2.0" - passport-strategy "^1.0.0" - -passport-strategy@1.x.x, passport-strategy@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4" - integrity sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ= - -passport@~0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/passport/-/passport-0.4.0.tgz#c5095691347bd5ad3b5e180238c3914d16f05811" - integrity sha1-xQlWkTR71a07XhgCOMORTRbwWBE= - dependencies: - passport-strategy "1.x.x" - pause "0.0.1" - path-dirname@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0" @@ -5913,11 +6212,6 @@ pathval@^1.1.0: resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.0.tgz#b942e6d4bde653005ef6b71361def8727d0645e0" integrity sha1-uULm1L3mUwBe9rcTYd74cn0GReA= -pause@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/pause/-/pause-0.0.1.tgz#1d408b3fdb76923b9543d96fb4c9dfd535d9cb5d" - integrity sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10= - performance-now@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" @@ -5945,6 +6239,13 @@ pirates@^4.0.0: dependencies: node-modules-regexp "^1.0.0" +pirates@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.1.tgz#643a92caf894566f91b2b986d2c66950a8e2fb87" + integrity sha512-WuNqLTbMI3tmfef2TKxlQmAiLHKtFhlsCZnPIpuv2Ow0RDVO8lfy1Opf4NUzlMXLjPl+Men7AuVdX6TA+s+uGA== + dependencies: + node-modules-regexp "^1.0.0" + pkg-dir@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-2.0.0.tgz#f6d5d1109e19d63edf428e0bd57e12777615334b" @@ -6003,13 +6304,15 @@ prepend-http@^1.0.1: resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc" integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw= -pretty-format@^24.0.0: - version "24.0.0" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-24.0.0.tgz#cb6599fd73ac088e37ed682f61291e4678f48591" - integrity sha512-LszZaKG665djUcqg5ZQq+XzezHLKrxsA86ZABTozp+oNhkdqa+tG2dX4qa6ERl5c/sRDrAa3lHmwnvKoP+OG/g== +pretty-format@^24.3.1: + version "24.3.1" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-24.3.1.tgz#ae4a98e93d73d86913a8a7dd1a7c3c900f8fda59" + integrity sha512-NZGH1NWS6o4i9pvRWLsxIK00JB9pqOUzVrO7yWT6vjI2thdxwvxefBJO6O5T24UAhI8P5dMceZ7x5wphgVI7Mg== dependencies: + "@jest/types" "^24.3.0" ansi-regex "^4.0.0" ansi-styles "^3.2.0" + react-is "^16.8.4" private@^0.1.6: version "0.1.8" @@ -6167,6 +6470,11 @@ react-dom@^16.4.2: prop-types "^15.6.2" scheduler "^0.11.2" +react-is@^16.8.4: + version "16.8.4" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.4.tgz#90f336a68c3a29a096a3d648ab80e87ec61482a2" + integrity sha512-PVadd+WaUDOAciICm/J1waJaSvgq+4rHE/K70j0PFqKhkTBsPv/82UGQJNXAngz1fOQLLxI6z1sEDmJDQhCTAA== + react@^16.4.2: version "16.6.3" resolved "https://registry.yarnpkg.com/react/-/react-16.6.3.tgz#25d77c91911d6bbdd23db41e70fb094cc1e0871c" @@ -6252,10 +6560,10 @@ readdirp@^2.2.1: micromatch "^3.1.10" readable-stream "^2.0.2" -realpath-native@^1.0.0, realpath-native@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/realpath-native/-/realpath-native-1.0.2.tgz#cd51ce089b513b45cf9b1516c82989b51ccc6560" - integrity sha512-+S3zTvVt9yTntFrBpm7TQmQ3tzpCrnA1a/y+3cUHAc9ZR6aIjG0WNLR+Rj79QpJktY+VeW/TQtFlQ1bzsehI8g== +realpath-native@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/realpath-native/-/realpath-native-1.1.0.tgz#2003294fea23fb0672f2476ebe22fcf498a2d65c" + integrity sha512-wlgPA6cCIIg9gKz0fgAPjnzh4yR/LnXovwuo9hvyGvx3h8nX4+/iLZplfUWasXpqD8BdnGnP5njOFjkUwPzvjA== dependencies: util.promisify "^1.0.0" @@ -6298,10 +6606,10 @@ regenerator-runtime@^0.12.0: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de" integrity sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg== -regenerator-transform@^0.13.3: - version "0.13.3" - resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.13.3.tgz#264bd9ff38a8ce24b06e0636496b2c856b57bcbb" - integrity sha512-5ipTrZFSq5vU2YoGoww4uaRVAK4wyYC4TSICibbfEPOruUu8FFP7ErV0BjmbIOEpn3O/k9na9UEdYR/3m7N6uA== +regenerator-transform@^0.13.4: + version "0.13.4" + resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.13.4.tgz#18f6763cf1382c69c36df76c6ce122cc694284fb" + integrity sha512-T0QMBjK3J0MtxjPmdIMXm72Wvj2Abb0Bd4HADdfijwMdoIsyQZ6fWC7kDFhk2YinBBEMZDL7Y7wh0J1sGx3S4A== dependencies: private "^0.1.6" @@ -6404,7 +6712,7 @@ request-promise-native@^1.0.5: stealthy-require "^1.1.0" tough-cookie ">=2.3.3" -request@^2.61.0, request@^2.87.0, request@^2.88.0: +request@^2.61.0, request@^2.87.0, request@^2.88.0, request@~2.88.0: version "2.88.0" resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef" integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg== @@ -6497,7 +6805,7 @@ rfc5646@^2.0.0: resolved "https://registry.yarnpkg.com/rfc5646/-/rfc5646-2.0.0.tgz#ac0c67b6cd04411ef7c80751ba159d9371ce116c" integrity sha1-rAxnts0EQR73yAdRuhWdk3HOEWw= -rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2, rimraf@^2.6.3, rimraf@~2.6.2: +rimraf@2.6.3, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2, rimraf@^2.6.3: version "2.6.3" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab" integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA== @@ -6521,10 +6829,10 @@ rx@^4.1.0: resolved "https://registry.yarnpkg.com/rx/-/rx-4.1.0.tgz#a5f13ff79ef3b740fe30aa803fb09f98805d4782" integrity sha1-pfE/957zt0D+MKqAP7CfmIBdR4I= -rxjs@^6.1.0: - version "6.3.3" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.3.3.tgz#3c6a7fa420e844a81390fb1158a9ec614f4bad55" - integrity sha512-JTWmoY9tWCs7zvIk/CvRjhjGaOd+OVBM987mxFo+OW66cGpdKjZcpmc74ES1sB//7Kl/PAe8+wEakuhG4pcgOw== +rxjs@^6.4.0: + version "6.4.0" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.4.0.tgz#f3bb0fe7bda7fb69deac0c16f17b50b0b8790504" + integrity sha512-Z9Yfa11F6B9Sg/BK9MnqnQ+aQYicPLtilXBp2yUtDt2JRCE0h26d33EnfO3ZxoNxG0T92OUucP3Ct7cpfkdFfw== dependencies: tslib "^1.9.0" @@ -6545,22 +6853,20 @@ safe-regex@^1.1.0: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -sane@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/sane/-/sane-3.1.0.tgz#995193b7dc1445ef1fe41ddfca2faf9f111854c6" - integrity sha512-G5GClRRxT1cELXfdAq7UKtUsv8q/ZC5k8lQGmjEm4HcAl3HzBy68iglyNCmw4+0tiXPCBZntslHlRhbnsSws+Q== +sane@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/sane/-/sane-4.0.3.tgz#e878c3f19e25cc57fbb734602f48f8a97818b181" + integrity sha512-hSLkC+cPHiBQs7LSyXkotC3UUtyn8C4FMn50TNaacRyvBlI+3ebcxMpqckmTdtXVtel87YS7GXN3UIOj7NiGVQ== dependencies: + "@cnakazawa/watch" "^1.0.3" anymatch "^2.0.0" capture-exit "^1.2.0" - exec-sh "^0.2.0" + exec-sh "^0.3.2" execa "^1.0.0" fb-watchman "^2.0.0" micromatch "^3.1.4" minimist "^1.1.1" walker "~1.0.5" - watch "~0.18.0" - optionalDependencies: - fsevents "^1.2.3" sanitize-html@~1.20.0: version "1.20.0" @@ -6732,10 +7038,10 @@ slash@^2.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44" integrity sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A== -slice-ansi@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-2.0.0.tgz#5373bdb8559b45676e8541c66916cdd6251612e7" - integrity sha512-4j2WTWjp3GsZ+AOagyzVbzp4vWGtZ0hEZ/gDY/uTvm6MTxUfTUIsnMIFb1bn8o0RuXiqUw15H1bue8f22Vw2oQ== +slice-ansi@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-2.1.0.tgz#cacd7693461a637a5788d92a7dd4fba068e81636" + integrity sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ== dependencies: ansi-styles "^3.2.0" astral-regex "^1.0.0" @@ -6987,6 +7293,15 @@ string-width@^1.0.1: is-fullwidth-code-point "^2.0.0" strip-ansi "^4.0.0" +string-width@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.0.0.tgz#5a1690a57cc78211fffd9bf24bbe24d090604eb1" + integrity sha512-rr8CUxBbvOZDUvc5lNIJ+OC1nPVpz+Siw9VBtUjB9b6jZehZLFt0JMCZzShFHIsI8cbhm0EsNIfWJMFV3cu3Ew== + dependencies: + emoji-regex "^7.0.1" + is-fullwidth-code-point "^2.0.0" + strip-ansi "^5.0.0" + string.prototype.padend@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/string.prototype.padend/-/string.prototype.padend-3.0.0.tgz#f3aaef7c1719f170c5eab1c32bf780d96e21f2f0" @@ -7115,15 +7430,15 @@ synchronous-promise@^2.0.5: resolved "https://registry.yarnpkg.com/synchronous-promise/-/synchronous-promise-2.0.6.tgz#de76e0ea2b3558c1e673942e47e714a930fa64aa" integrity sha512-TyOuWLwkmtPL49LHCX1caIwHjRzcVd62+GF6h8W/jHOeZUFHpnd2XJDVuUlaTaLPH1nuu2M69mfHr5XbQJnf/g== -table@^5.0.2: - version "5.1.1" - resolved "https://registry.yarnpkg.com/table/-/table-5.1.1.tgz#92030192f1b7b51b6eeab23ed416862e47b70837" - integrity sha512-NUjapYb/qd4PeFW03HnAuOJ7OMcBkJlqeClWxeNlQ0lXGSb52oZXGzkO0/I0ARegQ2eUT1g2VDJH0eUxDRcHmw== +table@^5.2.3: + version "5.2.3" + resolved "https://registry.yarnpkg.com/table/-/table-5.2.3.tgz#cde0cc6eb06751c009efab27e8c820ca5b67b7f2" + integrity sha512-N2RsDAMvDLvYwFcwbPyF3VmVSSkuF+G1e+8inhBLtHpvwXGw4QRPEZhihQNeEN0i1up6/f6ObCJXNdlRG3YVyQ== dependencies: - ajv "^6.6.1" + ajv "^6.9.1" lodash "^4.17.11" - slice-ansi "2.0.0" - string-width "^2.1.1" + slice-ansi "^2.1.0" + string-width "^3.0.0" tar@^4: version "4.4.8" @@ -7310,6 +7625,20 @@ trunc-text@1.0.1: resolved "https://registry.yarnpkg.com/trunc-text/-/trunc-text-1.0.1.tgz#58f876d8ac59b224b79834bb478b8656e69622b5" integrity sha1-WPh22KxZsiS3mDS7R4uGVuaWIrU= +ts-invariant@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.2.1.tgz#3d587f9d6e3bded97bf9ec17951dd9814d5a9d3f" + integrity sha512-Z/JSxzVmhTo50I+LKagEISFJW3pvPCqsMWLamCTX8Kr3N5aMrnGOqcflbe5hLUzwjvgPfnLzQtHZv0yWQ+FIHg== + dependencies: + tslib "^1.9.3" + +ts-invariant@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.3.2.tgz#89a2ffeb70879b777258df1df1c59383c35209b0" + integrity sha512-QsY8BCaRnHiB5T6iE4DPlJMAKEG3gzMiUco9FEt1jUXQf0XP6zi0idT0i0rMTu8A326JqNSDsmlkA9dRSh1TRg== + dependencies: + tslib "^1.9.3" + tslib@^1.9.0, tslib@^1.9.3: version "1.9.3" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286" @@ -7501,6 +7830,13 @@ util.promisify@^1.0.0: define-properties "^1.1.2" object.getownpropertydescriptors "^2.0.3" +util@0.10.3: + version "0.10.3" + resolved "https://registry.yarnpkg.com/util/-/util-0.10.3.tgz#7afb1afe50805246489e3db7fe0ed379336ac0f9" + integrity sha1-evsa/lCAUkZInj23/g7TeTNqwPk= + dependencies: + inherits "2.0.1" + utils-merge@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" @@ -7511,7 +7847,7 @@ uuid@3.1.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.1.0.tgz#3dd3d3e790abc24d7b0d3a034ffababe28ebbc04" integrity sha512-DIWtzUkw04M4k3bf1IcpS2tngXEL26YUD2M0tMDUpnUrz2hgzUBlD55a4FjdLGPvfHxS6uluGWvaVEqgBcVa+g== -uuid@^3.1.0, uuid@^3.3.2: +uuid@^3.1.0, uuid@^3.3.2, uuid@~3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA== @@ -7639,14 +7975,6 @@ walker@~1.0.5: dependencies: makeerror "1.0.x" -watch@~0.18.0: - version "0.18.0" - resolved "https://registry.yarnpkg.com/watch/-/watch-0.18.0.tgz#28095476c6df7c90c963138990c0a5423eb4b986" - integrity sha1-KAlUdsbffJDJYxOJkMClQj60uYY= - dependencies: - exec-sh "^0.2.0" - minimist "^1.2.0" - webidl-conversions@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" @@ -7692,7 +8020,7 @@ which-module@^2.0.0: resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho= -which@^1.2.12, which@^1.2.9, which@^1.3.0: +which@^1.2.9, which@^1.3.0: version "1.3.1" resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== @@ -7763,10 +8091,10 @@ write-file-atomic@^2.3.0: imurmurhash "^0.1.4" signal-exit "^3.0.2" -write@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/write/-/write-0.2.1.tgz#5fc03828e264cea3fe91455476f7a3c566cb0757" - integrity sha1-X8A4KOJkzqP+kUVUdvejxWbLB1c= +write@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/write/-/write-1.0.3.tgz#0800e14523b923a387e415123c865616aae0f5c3" + integrity sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig== dependencies: mkdirp "^0.5.1" @@ -7875,11 +8203,12 @@ yup@^0.26.10: synchronous-promise "^2.0.5" toposort "^2.0.2" -zen-observable-ts@^0.8.15: - version "0.8.15" - resolved "https://registry.yarnpkg.com/zen-observable-ts/-/zen-observable-ts-0.8.15.tgz#6cf7df6aa619076e4af2f707ccf8a6290d26699b" - integrity sha512-sXKPWiw6JszNEkRv5dQ+lQCttyjHM2Iks74QU5NP8mMPS/NrzTlHDr780gf/wOBqmHkPO6NCLMlsa+fAQ8VE8w== +zen-observable-ts@^0.8.16: + version "0.8.16" + resolved "https://registry.yarnpkg.com/zen-observable-ts/-/zen-observable-ts-0.8.16.tgz#969367299074fe17422fe2f46ee417e9a30cf3fa" + integrity sha512-pQl75N7qwgybKVsh6WFO+WwPRijeQ52Gn1vSf4uvPFXald9CbVQXLa5QrOPEJhdZiC+CD4quqOVqSG+Ptz5XLA== dependencies: + tslib "^1.9.3" zen-observable "^0.8.0" zen-observable@^0.8.0: From af4cfad3a2dfeef27f62383b3a16bd7427ef8497 Mon Sep 17 00:00:00 2001 From: Grzegorz Leoniec Date: Fri, 8 Mar 2019 21:08:18 +0100 Subject: [PATCH 16/25] Added PRIVATE_KEY_PASSPHRASE to docker compose --- docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.yml b/docker-compose.yml index 1e8c9158c..30d102f96 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,7 @@ services: - JWT_SECRET=b/&&7b78BF&fv/Vd - MOCK=false - MAPBOX_TOKEN=pk.eyJ1IjoiaHVtYW4tY29ubmVjdGlvbiIsImEiOiJjajl0cnBubGoweTVlM3VwZ2lzNTNud3ZtIn0.KZ8KK9l70omjXbEkkbHGsQ + - PRIVATE_KEY_PASSPHRASE=a7dsf78sadg87ad87sfagsadg78 neo4j: image: humanconnection/neo4j:latest From 77b7ed58d3d500cd7e6ad2f08b926e922209aa69 Mon Sep 17 00:00:00 2001 From: Armin Date: Mon, 11 Mar 2019 00:58:19 +0100 Subject: [PATCH 17/25] Add HTTP Signature Test (Problems with crypto lib till now) --- .../security/httpSignature.spec.js | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 src/activitypub/security/httpSignature.spec.js diff --git a/src/activitypub/security/httpSignature.spec.js b/src/activitypub/security/httpSignature.spec.js new file mode 100644 index 000000000..8fd8ab480 --- /dev/null +++ b/src/activitypub/security/httpSignature.spec.js @@ -0,0 +1,72 @@ +import { createSignature, verifySignature, generateRsaKeyPair } from '.' +import Factory from '../../seed/factories' +import { host, login } from '../../jest/helpers' +import { GraphQLClient } from 'graphql-request' +import crypto from 'crypto' +import { expect } from 'chai' +const factory = Factory() + +describe('Signature creation and verification', () => { + process.env.PRIVATE_KEY_PASSPHRASE = 'test-password' + let user = null + let client = null + const headers = { + 'Date': '2019-03-08T14:35:45.759Z', + 'Host': 'democracy-app.de', + 'Content-Type': 'application/json' + } + + beforeEach(async () => { + await factory.create('User', { + 'slug': 'test-user', + 'name': 'Test User', + 'email': 'user@example.org', + 'password': 'swordfish' + }) + const headers = await login({ email: 'user@example.org', password: 'swordfish' }) + client = new GraphQLClient(host, { headers }) + const result = await client.request(`query { + User(slug: "test-user") { + privateKey + publicKey + } + }`) + user = result.User[0] + }) + + afterEach(async () => { + await factory.cleanDatabase() + }) + + describe('Signature creation', () => { + let signatureB64 = '' + beforeEach(() => { + const signer = crypto.createSign('rsa-sha256') + signer.update('(request-target): post /activitypub/users/max/inbox\ndate: 2019-03-08T14:35:45.759Z\nhost: democracy-app.de\ncontent-type: application/json') + console.log(JSON.stringify(user, null, 2)) + signatureB64 = signer.sign({ key: user.privateKey, passphrase: 'test-password' }, 'base64') + }) + it('creates a Signature with given privateKey, keyId, url and headers (default algorithm: "rsa-sha256")', () => { + const httpSignature = createSignature(user.privateKey, 'https://human-connection.org/activitypub/users/lea#main-key', 'https://democracy-app.de/activitypub/users/max/inbox', headers) + + expect(httpSignature).to.contain('keyId="https://human-connection.org/activitypub/users/lea#main-key"') + expect(httpSignature).to.contain('algorithm="rsa-sha256"') + expect(httpSignature).to.contain('headers="(request-target) date host content-type"') + expect(httpSignature).to.contain('signature="' + signatureB64 + '"') + }) + }) + + describe('Signature verification', () => { + let httpSignature = '' + beforeEach(() => { + httpSignature = createSignature(user.privateKey, 'http://localhost:4001/activitypub/users/test-user#main-key', 'https://democracy-app.de/activitypub/users/max/inbox', headers) + }) + + it('verifies a Signature by ', async () => { + headers['Signature'] = httpSignature + const isVerified = await verifySignature('https://democracy-app.de/activitypub/users/max/inbox', headers) + console.log(JSON.stringify(isVerified, null, 2)) + expect(isVerified).to.equal(true) + }) + }) +}) From ff9840b844a58a3e38d2a96c01860784627e1190 Mon Sep 17 00:00:00 2001 From: Armin Date: Mon, 11 Mar 2019 02:21:16 +0100 Subject: [PATCH 18/25] Signature Test lint fix --- src/activitypub/security/httpSignature.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/activitypub/security/httpSignature.spec.js b/src/activitypub/security/httpSignature.spec.js index 8fd8ab480..17ab3b91a 100644 --- a/src/activitypub/security/httpSignature.spec.js +++ b/src/activitypub/security/httpSignature.spec.js @@ -1,4 +1,4 @@ -import { createSignature, verifySignature, generateRsaKeyPair } from '.' +import { createSignature, verifySignature } from '.' import Factory from '../../seed/factories' import { host, login } from '../../jest/helpers' import { GraphQLClient } from 'graphql-request' From 48ed75b6e409ec14f81f6a956ae57ec8f369a88d Mon Sep 17 00:00:00 2001 From: Armin Date: Tue, 12 Mar 2019 19:07:58 +0100 Subject: [PATCH 19/25] Use GRAPHQL_URI env var instead establishing ACTIVITYPUB env vars + http Signature test fix --- src/activitypub/ActivityPub.js | 13 ++++++------- src/activitypub/NitroDataSource.js | 10 +++------- src/activitypub/security/httpSignature.spec.js | 5 +---- src/activitypub/security/index.js | 2 +- 4 files changed, 11 insertions(+), 19 deletions(-) diff --git a/src/activitypub/ActivityPub.js b/src/activitypub/ActivityPub.js index 9cbf5b52e..ade2362bc 100644 --- a/src/activitypub/ActivityPub.js +++ b/src/activitypub/ActivityPub.js @@ -13,7 +13,6 @@ import as from 'activitystrea.ms' import NitroDataSource from './NitroDataSource' import router from './routes' import dotenv from 'dotenv' -import { resolve } from 'path' import Collections from './Collections' const debug = require('debug')('ea') @@ -22,19 +21,19 @@ let activityPub = null export { activityPub } export default class ActivityPub { - constructor (domain, port) { + constructor (domain, port, uri) { if (domain === 'localhost') { this.domain = `${domain}:${port}` } else { this.domain = domain } this.port = port - this.dataSource = new NitroDataSource(this.domain) + this.dataSource = new NitroDataSource(uri) this.collections = new Collections(this.dataSource) } static init (server) { if (!activityPub) { - dotenv.config({ path: resolve('src', 'activitypub', '.env') }) - const port = process.env.ACTIVITYPUB_PORT - activityPub = new ActivityPub(process.env.ACTIVITYPUB_DOMAIN || 'localhost', port || 4100) + dotenv.config() + const url = new URL(process.env.GRAPHQL_URI) + activityPub = new ActivityPub(url.hostname || 'localhost', url.port || 4000, url.origin) - // integrated into "server" express framework + // integrated into "server's" express framework server.express.set('ap', activityPub) server.express.use(router) debug('ActivityPub middleware added to the express service') diff --git a/src/activitypub/NitroDataSource.js b/src/activitypub/NitroDataSource.js index a8b65b27e..c5d520dce 100644 --- a/src/activitypub/NitroDataSource.js +++ b/src/activitypub/NitroDataSource.js @@ -19,25 +19,21 @@ import { setContext } from 'apollo-link-context' import { InMemoryCache } from 'apollo-cache-inmemory' import fetch from 'node-fetch' import { ApolloClient } from 'apollo-client' -import dotenv from 'dotenv' import uuid from 'uuid' import encode from '../jwt/encode' -import { resolve } from 'path' import trunc from 'trunc-html' const debug = require('debug')('ea:nitro-datasource') -dotenv.config({ path: resolve('src', 'activitypub', '.env') }) - export default class NitroDataSource { - constructor (domain) { - this.domain = domain + constructor (uri) { + this.uri = uri const defaultOptions = { query: { fetchPolicy: 'network-only', errorPolicy: 'all' } } - const link = createHttpLink({ uri: process.env.GRAPHQL_URI, fetch: fetch }) // eslint-disable-line + const link = createHttpLink({ uri: this.uri, fetch: fetch }) // eslint-disable-line const cache = new InMemoryCache() const authLink = setContext((_, { headers }) => { // generate the authentication token (maybe from env? Which user?) diff --git a/src/activitypub/security/httpSignature.spec.js b/src/activitypub/security/httpSignature.spec.js index 17ab3b91a..bf1162219 100644 --- a/src/activitypub/security/httpSignature.spec.js +++ b/src/activitypub/security/httpSignature.spec.js @@ -7,7 +7,6 @@ import { expect } from 'chai' const factory = Factory() describe('Signature creation and verification', () => { - process.env.PRIVATE_KEY_PASSPHRASE = 'test-password' let user = null let client = null const headers = { @@ -43,8 +42,7 @@ describe('Signature creation and verification', () => { beforeEach(() => { const signer = crypto.createSign('rsa-sha256') signer.update('(request-target): post /activitypub/users/max/inbox\ndate: 2019-03-08T14:35:45.759Z\nhost: democracy-app.de\ncontent-type: application/json') - console.log(JSON.stringify(user, null, 2)) - signatureB64 = signer.sign({ key: user.privateKey, passphrase: 'test-password' }, 'base64') + signatureB64 = signer.sign({ key: user.privateKey, passphrase: 'a7dsf78sadg87ad87sfagsadg78' }, 'base64') }) it('creates a Signature with given privateKey, keyId, url and headers (default algorithm: "rsa-sha256")', () => { const httpSignature = createSignature(user.privateKey, 'https://human-connection.org/activitypub/users/lea#main-key', 'https://democracy-app.de/activitypub/users/max/inbox', headers) @@ -65,7 +63,6 @@ describe('Signature creation and verification', () => { it('verifies a Signature by ', async () => { headers['Signature'] = httpSignature const isVerified = await verifySignature('https://democracy-app.de/activitypub/users/max/inbox', headers) - console.log(JSON.stringify(isVerified, null, 2)) expect(isVerified).to.equal(true) }) }) diff --git a/src/activitypub/security/index.js b/src/activitypub/security/index.js index 583535bcc..412084022 100644 --- a/src/activitypub/security/index.js +++ b/src/activitypub/security/index.js @@ -24,7 +24,7 @@ export function generateRsaKeyPair () { // signing export function createSignature (privKey, keyId, url, headers = {}, algorithm = 'rsa-sha256') { - if (!SUPPORTED_HASH_ALGORITHMS.includes(algorithm)) { return throw Error(`SIGNING: Unsupported hashing algorithm = ${algorithm}`) } + if (!SUPPORTED_HASH_ALGORITHMS.includes(algorithm)) { throw Error(`SIGNING: Unsupported hashing algorithm = ${algorithm}`) } const signer = crypto.createSign(algorithm) const signingString = constructSigningString(url, headers) signer.update(signingString) From 5aeb2845dbcd983061a6a9e841633275be22fc89 Mon Sep 17 00:00:00 2001 From: Armin Date: Tue, 12 Mar 2019 19:14:13 +0100 Subject: [PATCH 20/25] Change test port in some feature files --- test/features/activity-delete.feature | 8 ++++---- test/features/activity-like.feature | 16 ++++++++-------- test/features/object-article.feature | 4 ++-- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/test/features/activity-delete.feature b/test/features/activity-delete.feature index ad33f1e4c..9bdcbe9ab 100644 --- a/test/features/activity-delete.feature +++ b/test/features/activity-delete.feature @@ -2,7 +2,7 @@ Feature: Delete an object I want to delete objects Background: - Given our own server runs at "http://localhost:4100" + Given our own server runs at "http://localhost:4123" And we have the following users in our database: | Slug | | bernd-das-brot| @@ -29,7 +29,7 @@ Feature: Delete an object """ { "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://localhost:4100/users/karl-heinz/status/a4DJ2afdg323v32641vna42lkj685kasd2", + "id": "https://localhost:4123/users/karl-heinz/status/a4DJ2afdg323v32641vna42lkj685kasd2", "type": "Delete", "object": { "id": "https://aronda.org/users/bernd-das-brot/status/kljsdfg9843jknsdf", @@ -37,7 +37,7 @@ Feature: Delete an object "published": "2019-02-07T19:37:55.002Z", "attributedTo": "https://aronda.org/users/bernd-das-brot", "content": "Hi Max, how are you?", - "to": "https://localhost:4100/activitypub/users/moritz" + "to": "https://localhost:4123/activitypub/users/moritz" } } """ @@ -50,6 +50,6 @@ Feature: Delete an object "published": "2019-02-07T19:37:55.002Z", "attributedTo": "https://aronda.org/users/bernd-das-brot", "content": "Hi Max, how are you?", - "to": "https://localhost:4100/activitypub/users/moritz" + "to": "https://localhost:4123/activitypub/users/moritz" } """ diff --git a/test/features/activity-like.feature b/test/features/activity-like.feature index d71a0396f..35d32c842 100644 --- a/test/features/activity-like.feature +++ b/test/features/activity-like.feature @@ -4,7 +4,7 @@ Feature: Like an object like an article or note I want to undo the follow. Background: - Given our own server runs at "http://localhost:4100" + Given our own server runs at "http://localhost:4123" And we have the following users in our database: | Slug | | karl-heinz | @@ -13,14 +13,14 @@ Feature: Like an object like an article or note """ { "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://localhost:4100/activitypub/users/karl-heinz/status/faslkasa7dasfzkjn2398hsfd", + "id": "https://localhost:4123/activitypub/users/karl-heinz/status/faslkasa7dasfzkjn2398hsfd", "type": "Create", - "actor": "https://localhost:4100/activitypub/users/karl-heinz", + "actor": "https://localhost:4123/activitypub/users/karl-heinz", "object": { - "id": "https://localhost:4100/activitypub/users/karl-heinz/status/dkasfljsdfaafg9843jknsdf", + "id": "https://localhost:4123/activitypub/users/karl-heinz/status/dkasfljsdfaafg9843jknsdf", "type": "Article", "published": "2019-02-07T19:37:55.002Z", - "attributedTo": "https://localhost:4100/activitypub/users/karl-heinz", + "attributedTo": "https://localhost:4123/activitypub/users/karl-heinz", "content": "Hi Max, how are you?", "to": "https://www.w3.org/ns/activitystreams#Public" } @@ -32,10 +32,10 @@ Feature: Like an object like an article or note """ { "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://localhost:4100/activitypub/users/peter-lustiger/status/83J23549sda1k72fsa4567na42312455kad83", + "id": "https://localhost:4123/activitypub/users/peter-lustiger/status/83J23549sda1k72fsa4567na42312455kad83", "type": "Like", - "actor": "http://localhost:4100/activitypub/users/peter-lustiger", - "object": "http://localhost:4100/activitypub/users/karl-heinz/status/dkasfljsdfaafg9843jknsdf" + "actor": "http://localhost:4123/activitypub/users/peter-lustiger", + "object": "http://localhost:4123/activitypub/users/karl-heinz/status/dkasfljsdfaafg9843jknsdf" } """ Then I expect the status code to be 200 diff --git a/test/features/object-article.feature b/test/features/object-article.feature index 0a8af2606..5358f2925 100644 --- a/test/features/object-article.feature +++ b/test/features/object-article.feature @@ -2,7 +2,7 @@ Feature: Send and receive Articles I want to send and receive article's via ActivityPub Background: - Given our own server runs at "http://localhost:4100" + Given our own server runs at "http://localhost:4123" And we have the following users in our database: | Slug | | marvin | @@ -22,7 +22,7 @@ Feature: Send and receive Articles "published": "2019-02-07T19:37:55.002Z", "attributedTo": "https://aronda.org/users/marvin", "content": "Hi Max, how are you?", - "to": "https://localhost:4100/activitypub/users/max" + "to": "https://localhost:4123/activitypub/users/max" } } """ From 00ba891cbf9ddd67438842942950e6c5fa37652e Mon Sep 17 00:00:00 2001 From: Armin Date: Tue, 12 Mar 2019 22:50:25 +0100 Subject: [PATCH 21/25] Some refactoring + add timeout functions to some step definitions + add objectId to Post and schema --- src/activitypub/ActivityPub.js | 29 ++++++++++++++++++--------- src/activitypub/NitroDataSource.js | 26 +++++++++++++++++------- src/activitypub/routes/inbox.js | 26 ++++++++++++------------ src/activitypub/utils/activity.js | 6 ++++++ src/middleware/userMiddleware.js | 5 ++--- src/resolvers/posts.js | 11 +++++----- src/schema.graphql | 1 + test/features/activity-delete.feature | 12 +++++------ test/features/support/steps.js | 15 +++++++++----- 9 files changed, 82 insertions(+), 49 deletions(-) diff --git a/src/activitypub/ActivityPub.js b/src/activitypub/ActivityPub.js index ade2362bc..ed64816b9 100644 --- a/src/activitypub/ActivityPub.js +++ b/src/activitypub/ActivityPub.js @@ -14,6 +14,7 @@ import NitroDataSource from './NitroDataSource' import router from './routes' import dotenv from 'dotenv' import Collections from './Collections' +import uuid from 'uuid/v4' const debug = require('debug')('ea') let activityPub = null @@ -27,6 +28,7 @@ export default class ActivityPub { this.dataSource = new NitroDataSource(uri) this.collections = new Collections(this.dataSource) } + static init (server) { if (!activityPub) { dotenv.config() @@ -179,30 +181,37 @@ export default class ActivityPub { }) } + generateStatusId (slug) { + return `http://${this.domain}/activitypub/users/${slug}/status/${uuid()}` + } + async sendActivity (activity) { delete activity.send const fromName = extractNameFromId(activity.actor) if (Array.isArray(activity.to) && isPublicAddressed(activity)) { + debug('is public addressed') const sharedInboxEndpoints = await this.dataSource.getSharedInboxEndpoints() // serve shared inbox endpoints - sharedInboxEndpoints.map((el) => { - return this.trySend(activity, fromName, new URL(el).host, el) + sharedInboxEndpoints.map((sharedInbox) => { + return this.trySend(activity, fromName, new URL(sharedInbox).host, sharedInbox) }) - activity.to = activity.to.filter((el) => { - return !(isPublicAddressed({ to: el })) + activity.to = activity.to.filter((recipient) => { + return !(isPublicAddressed({ to: recipient })) }) // serve the rest - activity.to.map(async (el) => { - const actorObject = await this.getActorObject(el) - return this.trySend(activity, fromName, new URL(el).host, actorObject.inbox) + activity.to.map(async (recipient) => { + debug('serve rest') + const actorObject = await this.getActorObject(recipient) + return this.trySend(activity, fromName, new URL(recipient).host, actorObject.inbox) }) } else if (typeof activity.to === 'string') { + debug('is string') const actorObject = await this.getActorObject(activity.to) return this.trySend(activity, fromName, new URL(activity.to).host, actorObject.inbox) } else if (Array.isArray(activity.to)) { - activity.to.map(async (el) => { - const actorObject = await this.getActorObject(el) - return this.trySend(activity, fromName, new URL(el).host, actorObject.inbox) + activity.to.map(async (recipient) => { + const actorObject = await this.getActorObject(recipient) + return this.trySend(activity, fromName, new URL(recipient).host, actorObject.inbox) }) } } diff --git a/src/activitypub/NitroDataSource.js b/src/activitypub/NitroDataSource.js index c5d520dce..4225e02ea 100644 --- a/src/activitypub/NitroDataSource.js +++ b/src/activitypub/NitroDataSource.js @@ -19,8 +19,6 @@ import { setContext } from 'apollo-link-context' import { InMemoryCache } from 'apollo-cache-inmemory' import fetch from 'node-fetch' import { ApolloClient } from 'apollo-client' -import uuid from 'uuid' -import encode from '../jwt/encode' import trunc from 'trunc-html' const debug = require('debug')('ea:nitro-datasource') @@ -37,12 +35,12 @@ export default class NitroDataSource { const cache = new InMemoryCache() const authLink = setContext((_, { headers }) => { // generate the authentication token (maybe from env? Which user?) - const token = encode({ name: 'ActivityPub', id: uuid() }) + const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYWRtaW4iLCJuYW1lIjoiUGV0ZXIgTHVzdGlnIiwiYXZhdGFyIjoiaHR0cHM6Ly9zMy5hbWF6b25hd3MuY29tL3VpZmFjZXMvZmFjZXMvdHdpdHRlci9qb2huY2FmYXp6YS8xMjguanBnIiwiaWQiOiJ1MSIsImVtYWlsIjoiYWRtaW5AZXhhbXBsZS5vcmciLCJzbHVnIjoicGV0ZXItbHVzdGlnIiwiaWF0IjoxNTUyNDIwMTExLCJleHAiOjE2Mzg4MjAxMTEsImF1ZCI6Imh0dHA6Ly9sb2NhbGhvc3Q6MzAwMCIsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6NDAwMCIsInN1YiI6InUxIn0.G7An1yeQUViJs-0Qj-Tc-zm0WrLCMB3M02pfPnm6xzw' // return the headers to the context so httpLink can read them return { headers: { ...headers, - authorization: token ? `Bearer ${token}` : '' + Authorization: token ? `Bearer ${token}` : '' } } }) @@ -213,6 +211,7 @@ export default class NitroDataSource { async getOutboxCollectionPage (actorId) { const slug = extractNameFromId(actorId) + debug(`inside getting outbox collection page => ${slug}`) const result = await this.client.query({ query: gql` query { @@ -220,11 +219,16 @@ export default class NitroDataSource { actorId contributions { id + activityId + objectId title slug content contentExcerpt createdAt + author { + name + } } } } @@ -240,7 +244,7 @@ export default class NitroDataSource { outboxCollection.totalItems = posts.length await Promise.all( posts.map(async (post) => { - outboxCollection.orderedItems.push(await createArticleObject(post.activityId, post.objectId, post.content, extractNameFromId(post.id), post.id, post.createdAt)) + outboxCollection.orderedItems.push(await createArticleObject(post.activityId, post.objectId, post.content, post.author.name, post.id, post.createdAt)) }) ) @@ -332,6 +336,7 @@ export default class NitroDataSource { } const title = postObject.summary ? postObject.summary : postObject.content.split(' ').slice(0, 5).join(' ') const postId = extractIdFromActivityId(postObject.id) + debug('inside create post') let result = await this.client.mutate({ mutation: gql` mutation { @@ -346,10 +351,16 @@ export default class NitroDataSource { // ensure user and add author to post const userId = await this.ensureUser(postObject.attributedTo) + debug(`userId = ${userId}`) + debug(`postId = ${postId}`) result = await this.client.mutate({ mutation: gql` mutation { - AddPostAuthor(from: {id: "${userId}"}, to: {id: "${postId}"}) + AddPostAuthor(from: {id: "${userId}"}, to: {id: "${postId}"}) { + from { + name + } + } } ` }) @@ -523,10 +534,11 @@ export default class NitroDataSource { debug('ensureUser: user not exists.. createUser') // user does not exist.. create it const pw = crypto.randomBytes(16).toString('hex') + const slug = name.toLowerCase().split(' ').join('-') const result = await this.client.mutate({ mutation: gql` mutation { - CreateUser(password: "${pw}", slug:"${name}", actorId: "${actorId}", name: "${name}") { + CreateUser(password: "${pw}", slug:"${slug}", actorId: "${actorId}", name: "${name}", email: "${slug}@test.org") { id } } diff --git a/src/activitypub/routes/inbox.js b/src/activitypub/routes/inbox.js index 062f4b916..f9cfb3794 100644 --- a/src/activitypub/routes/inbox.js +++ b/src/activitypub/routes/inbox.js @@ -13,36 +13,36 @@ router.post('/', async function (req, res, next) { debug(`Request headers = ${JSON.stringify(req.headers, null, 2)}`) switch (req.body.type) { case 'Create': - if (req.body.send) { - await activityPub.sendActivity(req.body).catch(next) - break - } await activityPub.handleCreateActivity(req.body).catch(next) break case 'Undo': await activityPub.handleUndoActivity(req.body).catch(next) break case 'Follow': - debug('handleFollow') - await activityPub.handleFollowActivity(req.body) - debug('handledFollow') + await activityPub.handleFollowActivity(req.body).catch(next) break case 'Delete': await activityPub.handleDeleteActivity(req.body).catch(next) break - /* eslint-disable */ + /* eslint-disable */ case 'Update': - + await activityPub.handleUpdateActivity(req.body).catch(next) + break case 'Accept': await activityPub.handleAcceptActivity(req.body).catch(next) case 'Reject': - + // Do nothing + break case 'Add': - + break case 'Remove': - + break case 'Like': - + await activityPub.handleLikeActivity(req.body).catch(next) + break + case 'Dislike': + await activityPub.handleDislikeActivity(req.body).catch(next) + break case 'Announce': debug('else!!') debug(JSON.stringify(req.body, null, 2)) diff --git a/src/activitypub/utils/activity.js b/src/activitypub/utils/activity.js index 00a91813d..53bcd37ea 100644 --- a/src/activitypub/utils/activity.js +++ b/src/activitypub/utils/activity.js @@ -96,6 +96,12 @@ export function isPublicAddressed (postObject) { if (typeof postObject.to === 'string') { postObject.to = [postObject.to] } + if (typeof postObject === 'string') { + postObject.to = [postObject] + } + if (Array.isArray(postObject)) { + postObject.to = postObject + } return postObject.to.includes('Public') || postObject.to.includes('as:Public') || postObject.to.includes('https://www.w3.org/ns/activitystreams#Public') diff --git a/src/middleware/userMiddleware.js b/src/middleware/userMiddleware.js index 9f3864dfe..a85bd1244 100644 --- a/src/middleware/userMiddleware.js +++ b/src/middleware/userMiddleware.js @@ -1,16 +1,15 @@ import createOrUpdateLocations from './nodes/locations' import { generateRsaKeyPair } from '../activitypub/security' import dotenv from 'dotenv' -import { resolve } from 'path' -dotenv.config({ path: resolve('src', 'activitypub', '.env') }) +dotenv.config() export default { Mutation: { CreateUser: async (resolve, root, args, context, info) => { const keys = generateRsaKeyPair() Object.assign(args, keys) - args.actorId = `${process.env.ACTIVITYPUB_URI}/activitypub/users/${args.slug}` + args.actorId = `${process.env.GRAPHQL_URI}/activitypub/users/${args.slug}` const result = await resolve(root, args, context, info) await createOrUpdateLocations(args.id, args.locationName, context.driver) return result diff --git a/src/resolvers/posts.js b/src/resolvers/posts.js index d895b29a3..b7a6d8a2a 100644 --- a/src/resolvers/posts.js +++ b/src/resolvers/posts.js @@ -1,22 +1,23 @@ import { neo4jgraphql } from 'neo4j-graphql-js' import { activityPub } from '../activitypub/ActivityPub' -import uuid from 'uuid/v4' import as from 'activitystrea.ms' +import dotenv from 'dotenv' /* import as from 'activitystrea.ms' import request from 'request' */ const debug = require('debug')('backend:schema') +dotenv.config() export default { Mutation: { CreatePost: async (object, params, context, resolveInfo) => { - params.activityId = uuid() + params.activityId = activityPub.generateStatusId(context.user.slug) + params.objectId = activityPub.generateStatusId(context.user.slug) const result = await neo4jgraphql(object, params, context, resolveInfo, false) const session = context.driver.session() - const author = await session.run( 'MATCH (author:User {id: $userId}), (post:Post {id: $postId}) ' + 'MERGE (post)<-[:WROTE]-(author) ' + @@ -25,6 +26,7 @@ export default { postId: result.id } ) + session.close() debug(`actorId = ${author.records[0]._fields[0].properties.actorId}`) if (Array.isArray(author.records) && author.records.length > 0) { @@ -54,10 +56,9 @@ export default { try { await activityPub.sendActivity(createActivity) } catch (e) { - debug(`error sending post activity = ${JSON.stringify(e, null, 2)}`) + debug(`error sending post activity\n${e}`) } } - session.close() return result } diff --git a/src/schema.graphql b/src/schema.graphql index cd6573e83..a57e43373 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -158,6 +158,7 @@ type User { type Post { id: ID! activityId: String + objectId: String author: User @relation(name: "WROTE", direction: "IN") title: String! slug: String diff --git a/test/features/activity-delete.feature b/test/features/activity-delete.feature index 9bdcbe9ab..b5ef7158a 100644 --- a/test/features/activity-delete.feature +++ b/test/features/activity-delete.feature @@ -14,7 +14,7 @@ Feature: Delete an object "type": "Create", "actor": "https://aronda.org/users/bernd-das-brot", "object": { - "id": "https://aronda.org/users/bernd-das-brot/status/kljsdfg9843jknsdf", + "id": "https://aronda.org/users/bernd-das-brot/status/kljsdfg9843jknsdf234", "type": "Article", "published": "2019-02-07T19:37:55.002Z", "attributedTo": "https://aronda.org/users/bernd-das-brot", @@ -29,13 +29,13 @@ Feature: Delete an object """ { "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://localhost:4123/users/karl-heinz/status/a4DJ2afdg323v32641vna42lkj685kasd2", + "id": "https://localhost:4123/activitypub/users/karl-heinz/status/a4DJ2afdg323v32641vna42lkj685kasd2", "type": "Delete", "object": { - "id": "https://aronda.org/users/bernd-das-brot/status/kljsdfg9843jknsdf", + "id": "https://aronda.org/activitypub/users/bernd-das-brot/status/kljsdfg9843jknsdf234", "type": "Article", "published": "2019-02-07T19:37:55.002Z", - "attributedTo": "https://aronda.org/users/bernd-das-brot", + "attributedTo": "https://aronda.org/activitypub/users/bernd-das-brot", "content": "Hi Max, how are you?", "to": "https://localhost:4123/activitypub/users/moritz" } @@ -45,10 +45,10 @@ Feature: Delete an object And the object is removed from the outbox collection of "bernd-das-brot" """ { - "id": "https://aronda.org/users/bernd-das-brot/status/kljsdfg9843jknsdf", + "id": "https://aronda.org/activitypub/users/bernd-das-brot/status/kljsdfg9843jknsdf234", "type": "Article", "published": "2019-02-07T19:37:55.002Z", - "attributedTo": "https://aronda.org/users/bernd-das-brot", + "attributedTo": "https://aronda.org/activitypub/users/bernd-das-brot", "content": "Hi Max, how are you?", "to": "https://localhost:4123/activitypub/users/moritz" } diff --git a/test/features/support/steps.js b/test/features/support/steps.js index f2aaf6ffd..d7581f549 100644 --- a/test/features/support/steps.js +++ b/test/features/support/steps.js @@ -105,14 +105,17 @@ Then('the activity is added to the users inbox collection', async function () { }) Then('the post with id {string} to be created', async function (id) { - const result = await client.request(` + setTimeout(async () => { + const result = await client.request(` query { Post(id: "${id}") { title } } `) - expect(result.data.Post).to.be.an('array').that.is.not.empty // eslint-disable-line + + expect(result.data.Post).to.be.an('array').that.is.not.empty // eslint-disable-line + }, 2000) }) Then('the object is removed from the outbox collection of {string}', async function (name, object) { @@ -126,7 +129,8 @@ Then('I send a GET request to {string} and expect a ordered collection', () => { }) Then('the post with id {string} has been liked by {string}', async function (id, slug) { - const result = await client.request(` + setTimeout(async () => { + const result = await client.request(` query { Post(id: "${id}") { shoutedBy { @@ -135,6 +139,7 @@ Then('the post with id {string} has been liked by {string}', async function (id, } } `) - expect(result.data.Post[0].shoutedBy).to.be.an('array').that.is.not.empty // eslint-disable-line - expect(result.data.Post[0].shoutedBy[0].slug).to.equal(slug) + expect(result.data.Post[0].shoutedBy).to.be.an('array').that.is.not.empty // eslint-disable-line + expect(result.data.Post[0].shoutedBy[0].slug).to.equal(slug) + }, 2000) }) From 5ea3ef99e1edd609912b2176ad059227be496d2c Mon Sep 17 00:00:00 2001 From: Armin Date: Wed, 13 Mar 2019 00:25:32 +0100 Subject: [PATCH 22/25] Fixed follow feature + clean database after tests run + wait for user to be created before proceed --- package.json | 2 +- src/activitypub/ActivityPub.js | 10 ++-- src/activitypub/routes/verify.js | 2 +- .../security/httpSignature.spec.js | 2 +- test/features/activity-delete.feature | 4 +- test/features/activity-follow.feature | 33 +++++++------ test/features/object-article.feature | 2 +- test/features/support/steps.js | 48 +++++++++++++------ test/features/world.js | 4 +- 9 files changed, 63 insertions(+), 44 deletions(-) diff --git a/package.json b/package.json index fc0d5a751..db42c1135 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test:cucumber:cmd": "wait-on tcp:4001 tcp:4123 && cucumber-js --require-module @babel/register --exit test/", "test:jest:cmd:debug": "wait-on tcp:4001 tcp:4123 && node --inspect-brk ./node_modules/.bin/jest -i --forceExit --detectOpenHandles --runInBand", "test:jest": "run-p --race test:before:* 'test:jest:cmd {@}' --", - "test:cucumber": "ACTIVITYPUB_PORT=4123 run-p --race test:before:* 'test:cucumber:cmd {@}' --", + "test:cucumber": "run-p --race test:before:* 'test:cucumber:cmd {@}' --", "test:jest:debug": "run-p --race test:before:* 'test:jest:cmd:debug {@}' --", "test:coverage": "nyc report --reporter=text-lcov > coverage.lcov", "db:script:seed": "wait-on tcp:4001 && babel-node src/seed/seed-db.js", diff --git a/src/activitypub/ActivityPub.js b/src/activitypub/ActivityPub.js index ed64816b9..bcebf4d49 100644 --- a/src/activitypub/ActivityPub.js +++ b/src/activitypub/ActivityPub.js @@ -35,12 +35,13 @@ export default class ActivityPub { const url = new URL(process.env.GRAPHQL_URI) activityPub = new ActivityPub(url.hostname || 'localhost', url.port || 4000, url.origin) - // integrated into "server's" express framework + // integrate into running graphql express server server.express.set('ap', activityPub) + server.express.set('port', url.port) server.express.use(router) - debug('ActivityPub middleware added to the express service') + console.log('-> ActivityPub middleware added to the graphql express server') } else { - debug('ActivityPub middleware already added to the express service') + console.log('-> ActivityPub middleware already added to the graphql express server') } } @@ -60,6 +61,7 @@ export default class ActivityPub { if (err) return reject(err) debug(`name = ${toActorName}@${this.domain}`) // save shared inbox + toActorObject = JSON.parse(toActorObject) await this.dataSource.addSharedInboxEndpoint(toActorObject.endpoints.sharedInbox) let followersCollectionPage = await this.dataSource.getFollowersCollectionPage(activity.object) @@ -159,7 +161,7 @@ export default class ActivityPub { switch (activity.object.type) { case 'Follow': const followObject = activity.object - const followingCollectionPage = await this.getFollowingCollectionPage(followObject.actor) + const followingCollectionPage = await this.collections.getFollowingCollectionPage(followObject.actor) followingCollectionPage.orderedItems.push(followObject.object) await this.dataSource.saveFollowingCollectionPage(followingCollectionPage) } diff --git a/src/activitypub/routes/verify.js b/src/activitypub/routes/verify.js index 34676b44f..bb5850b3e 100644 --- a/src/activitypub/routes/verify.js +++ b/src/activitypub/routes/verify.js @@ -4,7 +4,7 @@ const debug = require('debug')('ea:verify') export default async (req, res, next) => { debug(`actorId = ${req.body.actor}`) // TODO stop if signature validation fails - if (await verifySignature(`${req.protocol}://${req.hostname}:${req.port}${req.originalUrl}`, req.headers)) { + if (await verifySignature(`${req.protocol}://${req.hostname}:${req.app.get('port')}${req.originalUrl}`, req.headers)) { debug('verify = true') next() } else { diff --git a/src/activitypub/security/httpSignature.spec.js b/src/activitypub/security/httpSignature.spec.js index bf1162219..fe09eda8a 100644 --- a/src/activitypub/security/httpSignature.spec.js +++ b/src/activitypub/security/httpSignature.spec.js @@ -60,7 +60,7 @@ describe('Signature creation and verification', () => { httpSignature = createSignature(user.privateKey, 'http://localhost:4001/activitypub/users/test-user#main-key', 'https://democracy-app.de/activitypub/users/max/inbox', headers) }) - it('verifies a Signature by ', async () => { + it('verifies a Signature correct', async () => { headers['Signature'] = httpSignature const isVerified = await verifySignature('https://democracy-app.de/activitypub/users/max/inbox', headers) expect(isVerified).to.equal(true) diff --git a/test/features/activity-delete.feature b/test/features/activity-delete.feature index b5ef7158a..f5e269cce 100644 --- a/test/features/activity-delete.feature +++ b/test/features/activity-delete.feature @@ -37,7 +37,7 @@ Feature: Delete an object "published": "2019-02-07T19:37:55.002Z", "attributedTo": "https://aronda.org/activitypub/users/bernd-das-brot", "content": "Hi Max, how are you?", - "to": "https://localhost:4123/activitypub/users/moritz" + "to": "https://www.w3.org/ns/activitystreams#Public" } } """ @@ -50,6 +50,6 @@ Feature: Delete an object "published": "2019-02-07T19:37:55.002Z", "attributedTo": "https://aronda.org/activitypub/users/bernd-das-brot", "content": "Hi Max, how are you?", - "to": "https://localhost:4123/activitypub/users/moritz" + "to": "https://www.w3.org/ns/activitystreams#Public" } """ diff --git a/test/features/activity-follow.feature b/test/features/activity-follow.feature index a6974309c..6634a342b 100644 --- a/test/features/activity-follow.feature +++ b/test/features/activity-follow.feature @@ -7,45 +7,44 @@ Feature: Follow a user Given our own server runs at "http://localhost:4123" And we have the following users in our database: | Slug | - | peter-lustiger | - | bob-der-baumeister | - | karl-heinz | + | stuart-little | + | tero-vota | Scenario: Send a follow to a user inbox and make sure it's added to the right followers collection - When I send a POST request with the following activity to "/activitypub/users/peter-lustiger/inbox": + When I send a POST request with the following activity to "/activitypub/users/tero-vota/inbox": """ { "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://localhost:4123/activitypub/users/bob-der-baumeister/status/83J23549sda1k72fsa4567na42312455kad83", + "id": "https://localhost:4123/activitypub/users/stuart-little/status/83J23549sda1k72fsa4567na42312455kad83", "type": "Follow", - "actor": "http://localhost:4123/activitypub/users/bob-der-baumeister", - "object": "http://localhost:4123/activitypub/users/peter-lustiger" + "actor": "http://localhost:4123/activitypub/users/stuart-little", + "object": "http://localhost:4123/activitypub/users/tero-vota" } """ Then I expect the status code to be 200 - And the follower is added to the followers collection of "peter-lustiger" + And the follower is added to the followers collection of "tero-vota" """ - https://localhost:4123/activitypub/users/bob-der-baumeister + http://localhost:4123/activitypub/users/stuart-little """ Scenario: Send an undo activity to revert the previous follow activity - When I send a POST request with the following activity to "/activitypub/users/bob-der-baumeister/inbox": + When I send a POST request with the following activity to "/activitypub/users/stuart-little/inbox": """ { "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://localhost:4123/activitypub/users/peter-lustiger/status/a4DJ2afdg323v32641vna42lkj685kasd2", + "id": "https://localhost:4123/activitypub/users/tero-vota/status/a4DJ2afdg323v32641vna42lkj685kasd2", "type": "Undo", - "actor": "http://localhost:4123/activitypub/users/peter-lustiger", + "actor": "http://localhost:4123/activitypub/users/tero-vota", "object": { - "id": "https://localhost:4123/activitypub/users/bob-der-baumeister/status/83J23549sda1k72fsa4567na42312455kad83", + "id": "https://localhost:4123/activitypub/users/stuart-little/status/83J23549sda1k72fsa4567na42312455kad83", "type": "Follow", - "actor": "http://localhost:4123/activitypub/users/bob-der-baumeister", - "object": "http://localhost:4123/activitypub/users/peter-lustiger" + "actor": "http://localhost:4123/activitypub/users/stuart-little", + "object": "http://localhost:4123/activitypub/users/tero-vota" } } """ Then I expect the status code to be 200 - And the follower is removed from the followers collection of "peter-lustiger" + And the follower is removed from the followers collection of "tero-vota" """ - https://localhost:4123/activitypub/users/bob-der-baumeister + http://localhost:4123/activitypub/users/stuart-little """ diff --git a/test/features/object-article.feature b/test/features/object-article.feature index 5358f2925..030e408e9 100644 --- a/test/features/object-article.feature +++ b/test/features/object-article.feature @@ -22,7 +22,7 @@ Feature: Send and receive Articles "published": "2019-02-07T19:37:55.002Z", "attributedTo": "https://aronda.org/users/marvin", "content": "Hi Max, how are you?", - "to": "https://localhost:4123/activitypub/users/max" + "to": "as:Public" } } """ diff --git a/test/features/support/steps.js b/test/features/support/steps.js index d7581f549..a535a523e 100644 --- a/test/features/support/steps.js +++ b/test/features/support/steps.js @@ -22,14 +22,15 @@ function createUser (slug) { AfterAll('Clean up the test data', function () { debug('All the tests are done! Deleting test data') + return factory.cleanDatabase() }) Given('our own server runs at {string}', function (string) { // just documenation }) -Given('we have the following users in our database:', async function (dataTable) { - await Promise.all(dataTable.hashes().map((user) => { +Given('we have the following users in our database:', function (dataTable) { + return Promise.all(dataTable.hashes().map((user) => { return createUser(user.Slug) })) }) @@ -45,6 +46,20 @@ When('I send a GET request to {string}', async function (pathname) { When('I send a POST request with the following activity to {string}:', async function (inboxUrl, activity) { debug(`inboxUrl = ${inboxUrl}`) debug(`activity = ${activity}`) + const splitted = inboxUrl.split('/') + const slug = splitted[splitted.indexOf('users') + 1] + let result + do { + result = await client.request(` + query { + User(slug: "${slug}") { + id + slug + actorId + } + } + `) + } while (result.User.length === 0) this.lastInboxUrl = inboxUrl this.lastActivity = activity const response = await this.post(inboxUrl, activity) @@ -100,22 +115,19 @@ Then('the follower is removed from the followers collection of {string}', async expect(responseObject.orderedItems).to.not.include(follower) }) -Then('the activity is added to the users inbox collection', async function () { - -}) - Then('the post with id {string} to be created', async function (id) { - setTimeout(async () => { - const result = await client.request(` + let result + do { + result = await client.request(` query { Post(id: "${id}") { title } } `) + } while (result.Post.length === 0) - expect(result.data.Post).to.be.an('array').that.is.not.empty // eslint-disable-line - }, 2000) + expect(result.Post).to.be.an('array').that.is.not.empty // eslint-disable-line }) Then('the object is removed from the outbox collection of {string}', async function (name, object) { @@ -128,9 +140,14 @@ Then('I send a GET request to {string} and expect a ordered collection', () => { }) +Then('the activity is added to the users inbox collection', async function () { + +}) + Then('the post with id {string} has been liked by {string}', async function (id, slug) { - setTimeout(async () => { - const result = await client.request(` + let result + do { + result = await client.request(` query { Post(id: "${id}") { shoutedBy { @@ -139,7 +156,8 @@ Then('the post with id {string} has been liked by {string}', async function (id, } } `) - expect(result.data.Post[0].shoutedBy).to.be.an('array').that.is.not.empty // eslint-disable-line - expect(result.data.Post[0].shoutedBy[0].slug).to.equal(slug) - }, 2000) + } while (result.Post.length === 0) + + expect(result.Post[0].shoutedBy).to.be.an('array').that.is.not.empty // eslint-disable-line + expect(result.Post[0].shoutedBy[0].slug).to.equal(slug) }) diff --git a/test/features/world.js b/test/features/world.js index 1b96e0761..be436b536 100644 --- a/test/features/world.js +++ b/test/features/world.js @@ -20,8 +20,8 @@ class CustomWorld { 'Accept': 'application/activity+json' }}, function (error, response, body) { if (!error) { - debug(`get response = ${response.headers['content-type']}`) - debug(`body = ${body}`) + debug(`get content-type = ${response.headers['content-type']}`) + debug(`get body = ${JSON.stringify(typeof body === 'string' ? JSON.parse(body) : body, null, 2)}`) resolve({ lastResponse: body, lastContentType: response.headers['content-type'], statusCode: response.statusCode }) } else { reject(error) From 3d56875be516d3b7353aa514eb4435a7de5eca9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Wed, 13 Mar 2019 13:51:51 +0100 Subject: [PATCH 23/25] Typo --- src/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/index.js b/src/index.js index 01063f573..c91f3e9be 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,5 @@ import createServer from './server' -import ActiviyPub from './activitypub/ActivityPub' +import ActivityPub from './activitypub/ActivityPub' const serverConfig = { port: process.env.GRAPHQL_PORT || 4000 @@ -13,5 +13,5 @@ const server = createServer() server.start(serverConfig, options => { /* eslint-disable-next-line no-console */ console.log(`Server ready at ${process.env.GRAPHQL_URI} 🚀`) - ActiviyPub.init(server) + ActivityPub.init(server) }) From ac155b7f1751bca253331654d6eb3b3e59c32618 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Wed, 13 Mar 2019 13:57:46 +0100 Subject: [PATCH 24/25] Small refactoring `let client` in the upmost scope cc @Tirokk --- src/resolvers/badges.spec.js | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/resolvers/badges.spec.js b/src/resolvers/badges.spec.js index e38f54381..dfcf1c5b6 100644 --- a/src/resolvers/badges.spec.js +++ b/src/resolvers/badges.spec.js @@ -3,6 +3,7 @@ import { GraphQLClient } from 'graphql-request' import { host, login } from '../jest/helpers' const factory = Factory() +let client describe('badges', () => { beforeEach(async () => { @@ -55,7 +56,6 @@ describe('badges', () => { ` describe('unauthenticated', () => { - let client it('throws authorization error', async () => { client = new GraphQLClient(host) @@ -66,7 +66,6 @@ describe('badges', () => { }) describe('authenticated admin', () => { - let client beforeEach(async () => { const headers = await login({ email: 'admin@example.org', password: '1234' }) client = new GraphQLClient(host, { headers }) @@ -86,7 +85,6 @@ describe('badges', () => { }) describe('authenticated moderator', () => { - let client beforeEach(async () => { const headers = await login({ email: 'moderator@example.org', password: '1234' }) client = new GraphQLClient(host, { headers }) @@ -120,7 +118,6 @@ describe('badges', () => { ` describe('unauthenticated', () => { - let client it('throws authorization error', async () => { client = new GraphQLClient(host) @@ -131,7 +128,6 @@ describe('badges', () => { }) describe('authenticated moderator', () => { - let client beforeEach(async () => { const headers = await login({ email: 'moderator@example.org', password: '1234' }) client = new GraphQLClient(host, { headers }) @@ -145,7 +141,6 @@ describe('badges', () => { }) describe('authenticated admin', () => { - let client beforeEach(async () => { const headers = await login({ email: 'admin@example.org', password: '1234' }) client = new GraphQLClient(host, { headers }) @@ -180,7 +175,6 @@ describe('badges', () => { ` describe('unauthenticated', () => { - let client it('throws authorization error', async () => { client = new GraphQLClient(host) @@ -191,7 +185,6 @@ describe('badges', () => { }) describe('authenticated moderator', () => { - let client beforeEach(async () => { const headers = await login({ email: 'moderator@example.org', password: '1234' }) client = new GraphQLClient(host, { headers }) @@ -205,7 +198,6 @@ describe('badges', () => { }) describe('authenticated admin', () => { - let client beforeEach(async () => { const headers = await login({ email: 'admin@example.org', password: '1234' }) client = new GraphQLClient(host, { headers }) From 9dffb69162a85017d6167bef52708e3ccb66ba52 Mon Sep 17 00:00:00 2001 From: Armin Date: Wed, 13 Mar 2019 14:39:00 +0100 Subject: [PATCH 25/25] Fix blank lines in test --- src/resolvers/badges.spec.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/resolvers/badges.spec.js b/src/resolvers/badges.spec.js index dfcf1c5b6..1966ce241 100644 --- a/src/resolvers/badges.spec.js +++ b/src/resolvers/badges.spec.js @@ -56,7 +56,6 @@ describe('badges', () => { ` describe('unauthenticated', () => { - it('throws authorization error', async () => { client = new GraphQLClient(host) await expect( @@ -118,7 +117,6 @@ describe('badges', () => { ` describe('unauthenticated', () => { - it('throws authorization error', async () => { client = new GraphQLClient(host) await expect( @@ -175,7 +173,6 @@ describe('badges', () => { ` describe('unauthenticated', () => { - it('throws authorization error', async () => { client = new GraphQLClient(host) await expect(