mirror of
https://github.com/IT4Change/Ocelot-Social.git
synced 2025-12-13 07:45:56 +00:00
Merge pull request #212 from Mastercuber/activitypub-editor
[WIP] Activitypub Service integrated into Backend
This commit is contained in:
commit
09e23f13bb
3
.babelrc
3
.babelrc
@ -8,5 +8,8 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": [
|
||||
"@babel/plugin-proposal-throw-expressions"
|
||||
]
|
||||
}
|
||||
|
||||
@ -8,3 +8,5 @@ MOCK=false
|
||||
|
||||
JWT_SECRET="b/&&7b78BF&fv/Vd"
|
||||
MAPBOX_TOKEN="pk.eyJ1IjoiaHVtYW4tY29ubmVjdGlvbiIsImEiOiJjajl0cnBubGoweTVlM3VwZ2lzNTNud3ZtIn0.KZ8KK9l70omjXbEkkbHGsQ"
|
||||
|
||||
PRIVATE_KEY_PASSPHRASE="a7dsf78sadg87ad87sfagsadg78"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
14
package.json
14
package.json
@ -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",
|
||||
|
||||
231
src/activitypub/ActivityPub.js
Normal file
231
src/activitypub/ActivityPub.js
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
28
src/activitypub/Collections.js
Normal file
28
src/activitypub/Collections.js
Normal 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)
|
||||
}
|
||||
}
|
||||
552
src/activitypub/NitroDataSource.js
Normal file
552
src/activitypub/NitroDataSource.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
54
src/activitypub/routes/inbox.js
Normal file
54
src/activitypub/routes/inbox.js
Normal 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
|
||||
29
src/activitypub/routes/index.js
Normal file
29
src/activitypub/routes/index.js
Normal 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
|
||||
43
src/activitypub/routes/serveUser.js
Normal file
43
src/activitypub/routes/serveUser.js
Normal 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()
|
||||
}
|
||||
}
|
||||
92
src/activitypub/routes/user.js
Normal file
92
src/activitypub/routes/user.js
Normal 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
|
||||
15
src/activitypub/routes/verify.js
Normal file
15
src/activitypub/routes/verify.js
Normal 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()
|
||||
}
|
||||
}
|
||||
34
src/activitypub/routes/webFinger.js
Normal file
34
src/activitypub/routes/webFinger.js
Normal 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
|
||||
69
src/activitypub/security/httpSignature.spec.js
Normal file
69
src/activitypub/security/httpSignature.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
147
src/activitypub/security/index.js
Normal file
147
src/activitypub/security/index.js
Normal 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']
|
||||
108
src/activitypub/utils/activity.js
Normal file
108
src/activitypub/utils/activity.js
Normal 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')
|
||||
}
|
||||
40
src/activitypub/utils/actor.js
Normal file
40
src/activitypub/utils/actor.js
Normal 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}`
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
70
src/activitypub/utils/collection.js
Normal file
70
src/activitypub/utils/collection.js
Normal 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()
|
||||
})
|
||||
}
|
||||
100
src/activitypub/utils/index.js
Normal file
100
src/activitypub/utils/index.js
Normal 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()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -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'
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
55
test/features/activity-delete.feature
Normal file
55
test/features/activity-delete.feature
Normal 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"
|
||||
}
|
||||
"""
|
||||
50
test/features/activity-follow.feature
Normal file
50
test/features/activity-follow.feature
Normal 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
|
||||
"""
|
||||
42
test/features/activity-like.feature
Normal file
42
test/features/activity-like.feature
Normal 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"
|
||||
101
test/features/collection.feature
Normal file
101
test/features/collection.feature
Normal 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": []
|
||||
}
|
||||
"""
|
||||
30
test/features/object-article.feature
Normal file
30
test/features/object-article.feature
Normal 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
|
||||
158
test/features/support/steps.js
Normal file
158
test/features/support/steps.js
Normal 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)
|
||||
})
|
||||
65
test/features/webfinger.feature
Normal file
65
test/features/webfinger.feature
Normal 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
58
test/features/world.js
Normal 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)
|
||||
Loading…
x
Reference in New Issue
Block a user