Merge pull request #212 from Mastercuber/activitypub-editor

[WIP] Activitypub Service integrated into Backend
This commit is contained in:
Robert Schäfer 2019-03-19 19:52:53 +01:00 committed by GitHub
commit 09e23f13bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 2999 additions and 31 deletions

View File

@ -8,5 +8,8 @@
}
}
]
],
"plugins": [
"@babel/plugin-proposal-throw-expressions"
]
}

View File

@ -8,3 +8,5 @@ MOCK=false
JWT_SECRET="b/&&7b78BF&fv/Vd"
MAPBOX_TOKEN="pk.eyJ1IjoiaHVtYW4tY29ubmVjdGlvbiIsImEiOiJjajl0cnBubGoweTVlM3VwZ2lzNTNud3ZtIn0.KZ8KK9l70omjXbEkkbHGsQ"
PRIVATE_KEY_PASSPHRASE="a7dsf78sadg87ad87sfagsadg78"

View File

@ -26,6 +26,9 @@ install:
script:
- docker-compose exec backend yarn run lint
- docker-compose exec backend yarn run test --ci
- docker-compose exec backend yarn run db:reset
- docker-compose exec backend yarn run db:seed
- 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

View File

@ -148,13 +148,20 @@ 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
```
When some tests fail, try `yarn db:reset` and after that `yarn db:seed`. Then run the tests again
## Todo`s
- [x] add jwt authentication

View File

@ -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

View File

@ -12,13 +12,15 @@
"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 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": "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,19 @@
]
},
"dependencies": {
"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.13",
"apollo-server": "~2.4.8",
"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.27",
"dotenv": "~7.0.0",
"express": "~4.16.4",
"faker": "~4.1.0",
"graphql": "~14.1.1",
"graphql-custom-directives": "~0.2.14",
@ -51,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.1",
"linkifyjs": "~2.1.8",
"lodash": "~4.17.11",
@ -59,15 +66,18 @@
"neo4j-graphql-js": "~2.4.2",
"node-fetch": "~2.3.0",
"npm-run-all": "~4.1.5",
"request": "~2.88.0",
"sanitize-html": "~1.20.0",
"slug": "~1.0.0",
"trunc-html": "~1.1.2",
"uuid": "~3.3.2",
"wait-on": "~3.2.0"
},
"devDependencies": {
"@babel/cli": "~7.2.3",
"@babel/core": "~7.3.4",
"@babel/node": "~7.2.2",
"@babel/plugin-proposal-throw-expressions": "^7.2.0",
"@babel/preset-env": "~7.3.4",
"@babel/register": "~7.0.0",
"apollo-server-testing": "~2.4.8",
@ -75,6 +85,8 @@
"babel-eslint": "~10.0.1",
"babel-jest": "~24.5.0",
"chai": "~4.2.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",

View File

@ -0,0 +1,231 @@
import {
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'
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
export { activityPub }
export default class ActivityPub {
constructor (domain, port, uri) {
if (domain === 'localhost') { this.domain = `${domain}:${port}` } else { this.domain = domain }
this.port = port
this.dataSource = new NitroDataSource(uri)
this.collections = new Collections(this.dataSource)
}
static init (server) {
if (!activityPub) {
dotenv.config()
const url = new URL(process.env.GRAPHQL_URI)
activityPub = new ActivityPub(url.hostname || 'localhost', url.port || 4000, url.origin)
// integrate into running graphql express server
server.express.set('ap', activityPub)
server.express.set('port', url.port)
server.express.use(router)
console.log('-> ActivityPub middleware added to the graphql express server')
} else {
console.log('-> ActivityPub middleware already added to the graphql express server')
}
}
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}`)
// save shared inbox
toActorObject = JSON.parse(toActorObject)
await this.dataSource.addSharedInboxEndpoint(toActorObject.endpoints.sharedInbox)
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}`)
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)
case 'Like':
return this.dataSource.deleteShouted(activity)
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(activity)
} else {
return this.dataSource.createPost(activity)
}
default:
}
}
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) {
// 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)
}
async handleAcceptActivity (activity) {
debug('inside accept')
switch (activity.object.type) {
case 'Follow':
const followObject = activity.object
const followingCollectionPage = await this.collections.getFollowingCollectionPage(followObject.actor)
followingCollectionPage.orderedItems.push(followObject.object)
await this.dataSource.saveFollowingCollectionPage(followingCollectionPage)
}
}
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))
})
})
}
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((sharedInbox) => {
return this.trySend(activity, fromName, new URL(sharedInbox).host, sharedInbox)
})
activity.to = activity.to.filter((recipient) => {
return !(isPublicAddressed({ to: recipient }))
})
// serve the rest
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 (recipient) => {
const actorObject = await this.getActorObject(recipient)
return this.trySend(activity, fromName, new URL(recipient).host, actorObject.inbox)
})
}
}
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)
}
}
}
}

View File

@ -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)
}
}

View File

@ -0,0 +1,552 @@
import {
throwErrorIfApolloErrorOccurred,
extractIdFromActivityId,
extractNameFromId,
constructIdFromName
} from './utils'
import {
createOrderedCollection,
createOrderedCollectionPage
} from './utils/collection'
import {
createArticleObject,
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 trunc from 'trunc-html'
const debug = require('debug')('ea:nitro-datasource')
export default class NitroDataSource {
constructor (uri) {
this.uri = uri
const defaultOptions = {
query: {
fetchPolicy: 'network-only',
errorPolicy: 'all'
}
}
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?)
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}` : ''
}
}
})
this.client = new ApolloClient({
link: authLink.concat(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 {
throwErrorIfApolloErrorOccurred(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 {
throwErrorIfApolloErrorOccurred(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 {
throwErrorIfApolloErrorOccurred(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 {
throwErrorIfApolloErrorOccurred(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 {
throwErrorIfApolloErrorOccurred(result)
}
}
async getOutboxCollectionPage (actorId) {
const slug = extractNameFromId(actorId)
debug(`inside getting outbox collection page => ${slug}`)
const result = await this.client.query({
query: gql`
query {
User(slug:"${slug}") {
actorId
contributions {
id
activityId
objectId
title
slug
content
contentExcerpt
createdAt
author {
name
}
}
}
}
`
})
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(async (post) => {
outboxCollection.orderedItems.push(await createArticleObject(post.activityId, post.objectId, post.content, post.author.name, post.id, post.createdAt))
})
)
debug('after createNote')
return outboxCollection
} else {
throwErrorIfApolloErrorOccurred(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)}`)
throwErrorIfApolloErrorOccurred(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(
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)}`)
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')
})
)
}
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
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)
debug('inside create post')
let result = await this.client.mutate({
mutation: gql`
mutation {
CreatePost(content: "${postObject.content}", contentExcerpt: "${trunc(postObject.content, 120)}", title: "${title}", id: "${postId}", objectId: "${postObject.id}", activityId: "${activity.id}") {
id
}
}
`
})
throwErrorIfApolloErrorOccurred(result)
// 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}"}) {
from {
name
}
}
}
`
})
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 () {
const result = await this.client.query({
query: gql`
query {
SharedInboxEndpoint {
uri
}
}
`
})
throwErrorIfApolloErrorOccurred(result)
return result.data.SharedInboxEnpoint
}
async addSharedInboxEndpoint (uri) {
try {
const result = await this.client.mutate({
mutation: gql`
mutation {
CreateSharedInboxEndpoint(uri: "${uri}")
}
`
})
throwErrorIfApolloErrorOccurred(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}", activityId: "${extractIdFromActivityId(activity.id)}") {
id
}
}
`
})
throwErrorIfApolloErrorOccurred(result)
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 {
AddCommentPost(from: { id: "${result.data.CreateComment.id}", to: { id: "${postId}" }}) {
id
}
}
`
})
throwErrorIfApolloErrorOccurred(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 name = extractNameFromId(actorId)
const queryResult = await this.client.query({
query: gql`
query {
User(slug: "${name}") {
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 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:"${slug}", actorId: "${actorId}", name: "${name}", email: "${slug}@test.org") {
id
}
}
`
})
throwErrorIfApolloErrorOccurred(result)
return result.data.CreateUser.id
}
}
}

View File

@ -0,0 +1,54 @@
import express from 'express'
import { activityPub } from '../ActivityPub'
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)}`)
switch (req.body.type) {
case 'Create':
await activityPub.handleCreateActivity(req.body).catch(next)
break
case 'Undo':
await activityPub.handleUndoActivity(req.body).catch(next)
break
case 'Follow':
await activityPub.handleFollowActivity(req.body).catch(next)
break
case 'Delete':
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))
}
/* eslint-enable */
res.status(200).end()
})
export default router

View File

@ -0,0 +1,29 @@
import user from './user'
import inbox from './inbox'
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',
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 }),
verify,
inbox
)
export default router

View File

@ -0,0 +1,43 @@
import { createActor } from '../utils/actor'
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()
}
}

View File

@ -0,0 +1,92 @@
import { sendCollection } from '../utils/collection'
import express from 'express'
import { serveUser } from './serveUser'
import { activityPub } from '../ActivityPub'
import verify from './verify'
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', verify, async function (req, res, next) {
debug(`body = ${JSON.stringify(req.body, null, 2)}`)
debug(`actorId = ${req.body.actor}`)
// const result = await saveActorId(req.body.actor)
switch (req.body.type) {
case 'Create':
await activityPub.handleCreateActivity(req.body).catch(next)
break
case 'Undo':
await activityPub.handleUndoActivity(req.body).catch(next)
break
case 'Follow':
await activityPub.handleFollowActivity(req.body).catch(next)
break
case 'Delete':
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))
}
/* eslint-enable */
res.status(200).end()
})
export default router

View File

@ -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.app.get('port')}${req.originalUrl}`, req.headers)) {
debug('verify = true')
next()
} else {
// throw Error('Signature validation failed!')
debug('verify = false')
next()
}
}

View File

@ -0,0 +1,34 @@
import express from 'express'
import { createWebFinger } from '../utils/actor'
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

View File

@ -0,0 +1,69 @@
import { createSignature, verifySignature } 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', () => {
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')
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)
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 correct', async () => {
headers['Signature'] = httpSignature
const isVerified = await verifySignature('https://democracy-app.de/activitypub/users/max/inbox', headers)
expect(isVerified).to.equal(true)
})
})
})

View File

@ -0,0 +1,147 @@
import dotenv from 'dotenv'
import { resolve } from 'path'
import crypto from 'crypto'
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
}
})
}
// signing
export function createSignature (privKey, keyId, url, headers = {}, algorithm = 'rsa-sha256') {
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)
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}"`
}
// verifying
export function verifySignature (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))
})
})
}
// 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)
}
// 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 = [
'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']

View File

@ -0,0 +1,108 @@
import { activityPub } from '../ActivityPub'
import { signAndSend, throwErrorIfApolloErrorOccurred } from './index'
import crypto from 'crypto'
import as from 'activitystrea.ms'
import gql from 'graphql-tag'
const debug = require('debug')('ea:utils:activity')
export function createNoteObject (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 async function createArticleObject (activityId, objectId, text, name, id, published) {
const actorId = await getActorId(name)
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': `${activityId}`,
'type': 'Create',
'actor': `${actorId}`,
'object': {
'id': `${objectId}`,
'type': 'Article',
'published': published,
'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'))
.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]
}
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')
}

View File

@ -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}`
}
]
}
}

View File

@ -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.collections.getFollowersCollection(id), res)
break
case 'followersPage':
attachThenCatch(activityPub.collections.getFollowersCollectionPage(id), res)
break
case 'following':
attachThenCatch(activityPub.collections.getFollowingCollection(id), res)
break
case 'followingPage':
attachThenCatch(activityPub.collections.getFollowingCollectionPage(id), res)
break
case 'outbox':
attachThenCatch(activityPub.collections.getOutboxCollection(id), res)
break
case 'outboxPage':
attachThenCatch(activityPub.collections.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()
})
}

View File

@ -0,0 +1,100 @@
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) {
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 `http://${fromDomain}/activitypub/users/${name}`
}
export function extractDomainFromUrl (url) {
return new URL(url).hostname
}
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}`)
}
}
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()
}
})
}
})
}

View File

@ -1,5 +1,6 @@
import fs from 'fs'
import path from 'path'
import userManagement from './resolvers/user_management.js'
import statistics from './resolvers/statistics.js'
import reports from './resolvers/reports.js'

View File

@ -1,4 +1,5 @@
import createServer from './server'
import ActivityPub 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} 🚀`)
ActivityPub.init(server)
})

View File

@ -10,7 +10,7 @@ export default function encode (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

View File

@ -1,8 +1,15 @@
import createOrUpdateLocations from './nodes/locations'
import { generateRsaKeyPair } from '../activitypub/security'
import dotenv from 'dotenv'
dotenv.config()
export default {
Mutation: {
CreateUser: async (resolve, root, args, context, info) => {
const keys = generateRsaKeyPair()
Object.assign(args, keys)
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

View File

@ -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,8 +56,6 @@ describe('badges', () => {
`
describe('unauthenticated', () => {
let client
it('throws authorization error', async () => {
client = new GraphQLClient(host)
await expect(
@ -66,7 +65,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 +84,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,8 +117,6 @@ describe('badges', () => {
`
describe('unauthenticated', () => {
let client
it('throws authorization error', async () => {
client = new GraphQLClient(host)
await expect(
@ -131,7 +126,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 +139,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,8 +173,6 @@ describe('badges', () => {
`
describe('unauthenticated', () => {
let client
it('throws authorization error', async () => {
client = new GraphQLClient(host)
await expect(
@ -191,7 +182,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 +195,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 })

View File

@ -1,20 +1,65 @@
import { neo4jgraphql } from 'neo4j-graphql-js'
import { activityPub } from '../activitypub/ActivityPub'
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 = 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()
await session.run(
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
})
}
)
session.close()
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\n${e}`)
}
}
return result
}
}

View File

@ -102,6 +102,7 @@ type Location {
type User {
id: ID!
actorId: String
name: String
email: String
slug: String
@ -111,6 +112,8 @@ type User {
disabled: Boolean
disabledBy: User @relation(name: "DISABLED", direction: "IN")
role: UserGroupEnum
publicKey: String
privateKey: String
location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l")
locationName: String
@ -166,6 +169,8 @@ type User {
type Post {
id: ID!
activityId: String
objectId: String
author: User @relation(name: "WROTE", direction: "IN")
title: String!
slug: String
@ -203,6 +208,7 @@ type Post {
type Comment {
id: ID!
activityId: String
author: User @relation(name: "WROTE", direction: "IN")
content: String!
contentExcerpt: String
@ -288,3 +294,7 @@ type Tag {
deleted: Boolean
disabled: Boolean
}
type SharedInboxEndpoint {
id: ID!
uri: String
}

View File

@ -8,6 +8,7 @@ import middleware from './middleware'
import applyDirectives from './bootstrap/directives'
import applyScalars from './bootstrap/scalars'
import { getDriver } from './bootstrap/neo4j'
import helmet from 'helmet'
import decode from './jwt/decode'
dotenv.config()
@ -59,6 +60,7 @@ const createServer = (options) => {
}
const server = new GraphQLServer(Object.assign({}, defaults, options))
server.express.use(helmet())
server.express.use(express.static('public'))
return server
}

View File

@ -0,0 +1,55 @@
Feature: Delete an object
I want to delete objects
Background:
Given our own server runs at "http://localhost:4123"
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/kljsdfg9843jknsdf234",
"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://www.w3.org/ns/activitystreams#Public"
}
}
"""
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:4123/activitypub/users/karl-heinz/status/a4DJ2afdg323v32641vna42lkj685kasd2",
"type": "Delete",
"object": {
"id": "https://aronda.org/activitypub/users/bernd-das-brot/status/kljsdfg9843jknsdf234",
"type": "Article",
"published": "2019-02-07T19:37:55.002Z",
"attributedTo": "https://aronda.org/activitypub/users/bernd-das-brot",
"content": "Hi Max, how are you?",
"to": "https://www.w3.org/ns/activitystreams#Public"
}
}
"""
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/activitypub/users/bernd-das-brot/status/kljsdfg9843jknsdf234",
"type": "Article",
"published": "2019-02-07T19:37:55.002Z",
"attributedTo": "https://aronda.org/activitypub/users/bernd-das-brot",
"content": "Hi Max, how are you?",
"to": "https://www.w3.org/ns/activitystreams#Public"
}
"""

View File

@ -0,0 +1,50 @@
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 |
| 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/tero-vota/inbox":
"""
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://localhost:4123/activitypub/users/stuart-little/status/83J23549sda1k72fsa4567na42312455kad83",
"type": "Follow",
"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 "tero-vota"
"""
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/stuart-little/inbox":
"""
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://localhost:4123/activitypub/users/tero-vota/status/a4DJ2afdg323v32641vna42lkj685kasd2",
"type": "Undo",
"actor": "http://localhost:4123/activitypub/users/tero-vota",
"object": {
"id": "https://localhost:4123/activitypub/users/stuart-little/status/83J23549sda1k72fsa4567na42312455kad83",
"type": "Follow",
"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 "tero-vota"
"""
http://localhost:4123/activitypub/users/stuart-little
"""

View File

@ -0,0 +1,42 @@
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 |
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:4123/activitypub/users/karl-heinz/status/faslkasa7dasfzkjn2398hsfd",
"type": "Create",
"actor": "https://localhost:4123/activitypub/users/karl-heinz",
"object": {
"id": "https://localhost:4123/activitypub/users/karl-heinz/status/dkasfljsdfaafg9843jknsdf",
"type": "Article",
"published": "2019-02-07T19:37:55.002Z",
"attributedTo": "https://localhost:4123/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",
"type": "Like",
"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
And the post with id "dkasfljsdfaafg9843jknsdf" has been liked by "peter-lustiger"

View File

@ -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": []
}
"""

View File

@ -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:4123"
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 Max, how are you?",
"to": "as:Public"
}
}
"""
Then I expect the status code to be 200
And the post with id "kljsdfg9843jknsdf" to be created

View File

@ -0,0 +1,158 @@
// 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' })
}
Given('our own server runs at {string}', function (string) {
// just documenation
})
Given('we have the following users in our database:', function (dataTable) {
return 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}`)
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)
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 post with id {string} to be created', async function (id) {
let result
do {
result = await client.request(`
query {
Post(id: "${id}") {
title
}
}
`)
} while (result.Post.length === 0)
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) {
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', () => {
})
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) {
let result
do {
result = await client.request(`
query {
Post(id: "${id}") {
shoutedBy {
slug
}
}
}
`)
} 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)
})

View File

@ -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..."
}
}
"""

58
test/features/world.js Normal file
View File

@ -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
// object-article.feature
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 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)
}
})
})
}
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)

741
yarn.lock

File diff suppressed because it is too large Load Diff