Port Guppe to activitypub-express
This commit is contained in:
parent
0e5a583e05
commit
18e0c8d602
34 changed files with 22958 additions and 2342 deletions
194
index.js
194
index.js
|
@ -2,18 +2,12 @@ const path = require('path')
|
|||
const express = require('express')
|
||||
const MongoClient = require('mongodb').MongoClient
|
||||
const fs = require('fs')
|
||||
const bodyParser = require('body-parser')
|
||||
const cors = require('cors')
|
||||
const AutoEncrypt = require('@small-tech/auto-encrypt')
|
||||
const https = require('https')
|
||||
const morgan = require('morgan')
|
||||
const history = require('connect-history-api-fallback')
|
||||
const { onShutdown } = require('node-graceful-shutdown')
|
||||
const ActivitypubExpress = require('activitypub-express')
|
||||
|
||||
const routes = require('./routes')
|
||||
const pub = require('./pub')
|
||||
const store = require('./store')
|
||||
const net = require('./net')
|
||||
const { DOMAIN, KEY_PATH, CERT_PATH, CA_PATH, PORT, PORT_HTTPS, DB_URL, DB_NAME } = require('./config.json')
|
||||
|
||||
const app = express()
|
||||
|
@ -21,15 +15,132 @@ const app = express()
|
|||
const client = new MongoClient(DB_URL, { useUnifiedTopology: true, useNewUrlParser: true })
|
||||
|
||||
const sslOptions = {
|
||||
key: fs.readFileSync(path.join(__dirname, KEY_PATH)),
|
||||
cert: fs.readFileSync(path.join(__dirname, CERT_PATH)),
|
||||
ca: CA_PATH ? fs.readFileSync(path.join(__dirname, CA_PATH)) : undefined
|
||||
key: KEY_PATH && fs.readFileSync(path.join(__dirname, KEY_PATH)),
|
||||
cert: CERT_PATH && fs.readFileSync(path.join(__dirname, CERT_PATH)),
|
||||
ca: CA_PATH && fs.readFileSync(path.join(__dirname, CA_PATH))
|
||||
}
|
||||
|
||||
const icon = {
|
||||
type: 'Image',
|
||||
mediaType: 'image/jpeg',
|
||||
url: `https://${DOMAIN}/f/guppe.png`
|
||||
}
|
||||
|
||||
const routes = {
|
||||
actor: '/u/:actor',
|
||||
object: '/o/:id',
|
||||
activity: '/s/:id',
|
||||
inbox: '/u/:actor/inbox',
|
||||
outbox: '/u/:actor/outbox',
|
||||
followers: '/u/:actor/followers',
|
||||
following: '/u/:actor/following',
|
||||
liked: '/u/:actor/liked',
|
||||
collections: '/u/:actor/c/:id',
|
||||
blocked: '/u/:actor/blocked',
|
||||
rejections: '/u/:actor/rejections',
|
||||
rejected: '/u/:actor/rejected',
|
||||
shares: '/s/:id/shares',
|
||||
likes: '/s/:id/likes'
|
||||
}
|
||||
const apex = ActivitypubExpress({
|
||||
domain: DOMAIN,
|
||||
actorParam: 'actor',
|
||||
objectParam: 'id',
|
||||
itemsPerPage: 100,
|
||||
routes
|
||||
})
|
||||
|
||||
app.set('domain', DOMAIN)
|
||||
app.set('port', process.env.PORT || PORT)
|
||||
app.set('port-https', process.env.PORT_HTTPS || PORT_HTTPS)
|
||||
app.use(morgan(':remote-addr - :remote-user [:date[clf]] ":method :url HTTP/:http-version" :status Accepts ":req[accept]" ":referrer" ":user-agent"'))
|
||||
app.use(express.json({ type: apex.consts.jsonldTypes }), apex)
|
||||
|
||||
// Guppe's magic: create new groups on demand whenever someone tries to access it
|
||||
async function getOrCreateActor (req, res, next) {
|
||||
const apex = req.app.locals.apex
|
||||
const actor = req.params[apex.actorParam]
|
||||
const actorIRI = apex.utils.usernameToIRI(actor)
|
||||
let actorObj
|
||||
try {
|
||||
actorObj = await apex.store.getObject(actorIRI)
|
||||
} catch (err) { return next(err) }
|
||||
if (!actorObj && actor.length <= 100) {
|
||||
try {
|
||||
const summary = `I'm a group about ${actor}. Follow me to get all the group posts. Tag me to share with the group. Create other groups by searching for or tagging @yourGroupName@${DOMAIN}`
|
||||
actorObj = await apex.createActor(actor, `${actor} group`, summary, icon, 'Group')
|
||||
await apex.store.saveObject(actorObj)
|
||||
} catch (err) { return next(err) }
|
||||
res.locals.apex.target = actorObj
|
||||
} else if (actor.length > 100) {
|
||||
res.locals.apex.status = 400
|
||||
res.locals.apex.statusMessage = 'Group names are limited to 100 characters'
|
||||
} else if (actorObj.type === 'Tombstone') {
|
||||
res.locals.apex.status = 410
|
||||
} else {
|
||||
res.locals.apex.target = actorObj
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
// define routes using prepacakged middleware collections
|
||||
app.route(routes.inbox)
|
||||
.post(apex.net.inbox.post)
|
||||
// no C2S at present
|
||||
// .get(apex.net.inbox.get)
|
||||
app.route(routes.outbox)
|
||||
.get(apex.net.outbox.get)
|
||||
// no C2S at present
|
||||
// .post(apex.net.outbox.post)
|
||||
|
||||
// replace apex's target actor validator with our create on demand method
|
||||
app.get(routes.actor, apex.net.validators.jsonld, getOrCreateActor, apex.net.responders.target)
|
||||
app.get(routes.followers, apex.net.followers.get)
|
||||
app.get(routes.following, apex.net.following.get)
|
||||
app.get(routes.liked, apex.net.liked.get)
|
||||
app.get(routes.object, apex.net.object.get)
|
||||
app.get(routes.activity, apex.net.activityStream.get)
|
||||
app.get(routes.shares, apex.net.shares.get)
|
||||
app.get(routes.likes, apex.net.likes.get)
|
||||
|
||||
app.get(
|
||||
'/.well-known/webfinger',
|
||||
apex.net.wellKnown.parseWebfinger,
|
||||
// replace apex's target actor validator with our create on demand method
|
||||
getOrCreateActor,
|
||||
apex.net.wellKnown.respondWebfinger
|
||||
)
|
||||
|
||||
app.on('apex-inbox', async ({ actor, activity, recipient, object }) => {
|
||||
switch (activity.type.toLowerCase()) {
|
||||
case 'create': {
|
||||
// ignore forwarded messages that aren't directly adddressed to group
|
||||
if (!activity.to?.includes(recipient.id)) {
|
||||
return
|
||||
}
|
||||
const to = [
|
||||
recipient.followers[0],
|
||||
apex.consts.publicAddress
|
||||
]
|
||||
const share = await apex.buildActivity('Announce', recipient.id, to, {
|
||||
object: activity.id
|
||||
})
|
||||
apex.addToOutbox(recipient, share)
|
||||
break
|
||||
}
|
||||
case 'follow': {
|
||||
const accept = await apex.buildActivity('Accept', recipient.id, actor.id, {
|
||||
object: activity.id
|
||||
})
|
||||
const { postTask: publishUpdatedFollowers } = await apex.acceptFollow(recipient, activity)
|
||||
await apex.addToOutbox(recipient, accept)
|
||||
return publishUpdatedFollowers()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/// Guppe web setup
|
||||
// html/static routes
|
||||
app.use(history({
|
||||
index: '/web/index.html',
|
||||
rewrites: [
|
||||
|
@ -37,60 +148,34 @@ app.use(history({
|
|||
{ from: /^\/\.well-known\//, to: context => context.request.originalUrl }
|
||||
]
|
||||
}))
|
||||
app.use(bodyParser.json({
|
||||
type: pub.consts.jsonldTypes
|
||||
})) // support json encoded bodies
|
||||
app.use(bodyParser.urlencoded({ extended: true })) // support encoded bodies
|
||||
|
||||
app.param('name', function (req, res, next, id) {
|
||||
req.user = id
|
||||
next()
|
||||
})
|
||||
|
||||
// json only routes
|
||||
app.use('/.well-known/webfinger', cors(), routes.webfinger)
|
||||
app.use('/o', net.validators.jsonld, cors(), routes.object)
|
||||
app.use('/s', net.validators.jsonld, cors(), routes.stream)
|
||||
app.use('/u/:name/inbox', net.validators.jsonld, routes.inbox)
|
||||
app.use('/u/:name/outbox', net.validators.jsonld, routes.outbox)
|
||||
app.use('/u', cors(), routes.user)
|
||||
|
||||
// html/static routes
|
||||
app.use('/f', express.static('public/files'))
|
||||
app.use('/web', express.static('web/dist'))
|
||||
|
||||
// error logging
|
||||
app.use(function (err, req, res, next) {
|
||||
console.error(err.message, req.body, err.stack)
|
||||
if (!res.headersSent) {
|
||||
res.status(500).send('An error occurred while processing the request')
|
||||
}
|
||||
})
|
||||
|
||||
const server = process.env.NODE_ENV === 'production'
|
||||
? AutoEncrypt.https.createServer({ domains: ['gup.pe'] }, app)
|
||||
: https.createServer(sslOptions, app)
|
||||
|
||||
client.connect({ useNewUrlParser: true })
|
||||
.then(() => {
|
||||
console.log('Connected successfully to db')
|
||||
const db = client.db(DB_NAME)
|
||||
app.set('db', db)
|
||||
store.connection.setDb(db)
|
||||
return pub.actor.createLocalActor('dummy', 'Person')
|
||||
})
|
||||
.then(dummy => {
|
||||
// shortcut to be able to sign GETs, will be factored out via activitypub-express
|
||||
global.guppeSystemUser = dummy
|
||||
return store.setup(DOMAIN, dummy)
|
||||
})
|
||||
.then(() => {
|
||||
server.listen(app.get('port-https'), function () {
|
||||
console.log('Guppe server listening on port ' + app.get('port-https'))
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
throw new Error(err)
|
||||
})
|
||||
.then(async () => {
|
||||
const { default: AutoEncrypt } = await import('@small-tech/auto-encrypt')
|
||||
apex.store.db = client.db(DB_NAME)
|
||||
await apex.store.setup()
|
||||
apex.systemUser = await apex.store.getObject(apex.utils.usernameToIRI('system_service'), true)
|
||||
if (!apex.systemUser) {
|
||||
const systemUser = await apex.createActor('system_service', `${DOMAIN} system service`, `${DOMAIN} system service`, icon, 'Service')
|
||||
await apex.store.saveObject(systemUser)
|
||||
apex.systemUser = systemUser
|
||||
}
|
||||
|
||||
const server = process.env.NODE_ENV === 'production'
|
||||
? AutoEncrypt.https.createServer({ domains: [DOMAIN] }, app)
|
||||
: https.createServer(sslOptions, app)
|
||||
server.listen(PORT_HTTPS, function () {
|
||||
console.log('Guppe server listening on port ' + PORT_HTTPS)
|
||||
})
|
||||
onShutdown(async () => {
|
||||
await client.close()
|
||||
await new Promise((resolve, reject) => {
|
||||
|
@ -98,3 +183,4 @@ onShutdown(async () => {
|
|||
})
|
||||
console.log('Guppe server closed')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,12 +0,0 @@
|
|||
db.objects.find({type: "Group"}).forEach(function(d){
|
||||
d.id = d.id.toLowerCase();
|
||||
db.objects.save(d);
|
||||
});
|
||||
db.streams.find({"_meta._target": {$ne: null}}).forEach(function(d){
|
||||
d._meta._target = d._meta._target.toLowerCase();
|
||||
db.objects.save(d);
|
||||
});
|
||||
db.streams.find({actor: /gup\.pe/}).forEach(function(d){
|
||||
d.actor = d.actor.toLowerCase();
|
||||
db.streams.save(d);
|
||||
});
|
|
@ -1,11 +0,0 @@
|
|||
db.objects.find({type: "Group"}).forEach(function(d) {
|
||||
if(typeof d.icon !== 'string') {
|
||||
return;
|
||||
}
|
||||
d.icon = {
|
||||
type: 'Image',
|
||||
mediaType: 'image/jpeg',
|
||||
url: d.icon
|
||||
};
|
||||
db.objects.save(d);
|
||||
});
|
|
@ -1,6 +0,0 @@
|
|||
'use strict'
|
||||
// middleware
|
||||
module.exports = {
|
||||
validators: require('./validators'),
|
||||
security: require('./security')
|
||||
}
|
|
@ -1,46 +0,0 @@
|
|||
'use strict'
|
||||
const httpSignature = require('http-signature')
|
||||
const pub = require('../pub')
|
||||
// http communication middleware
|
||||
module.exports = {
|
||||
auth,
|
||||
verifySignature
|
||||
}
|
||||
|
||||
function auth (req, res, next) {
|
||||
// no client-to-server support at this time
|
||||
if (req.app.get('env') !== 'development') {
|
||||
return res.status(405).send()
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
async function verifySignature (req, res, next) {
|
||||
try {
|
||||
if (!req.get('authorization') && !req.get('signature')) {
|
||||
// support for apps not using signature extension to ActivityPub
|
||||
const actor = await pub.object.resolveObject(pub.utils.actorFromActivity(req.body))
|
||||
if (actor.publicKey && req.app.get('env') !== 'development') {
|
||||
console.log('Missing http signature')
|
||||
return res.status(400).send('Missing http signature')
|
||||
}
|
||||
return next()
|
||||
}
|
||||
const sigHead = httpSignature.parse(req)
|
||||
const signer = await pub.object.resolveObject(sigHead.keyId, req.app.get('db'))
|
||||
const valid = httpSignature.verifySignature(sigHead, signer.publicKey.publicKeyPem)
|
||||
if (!valid) {
|
||||
console.log('signature validation failure', sigHead.keyId)
|
||||
return res.status(400).send('Invalid http signature')
|
||||
}
|
||||
next()
|
||||
} catch (err) {
|
||||
if (req.body.type === 'Delete' && err.message.startsWith('410')) {
|
||||
// user delete message that can't be verified because we don't have the user cached
|
||||
console.log('Unverifiable delete')
|
||||
return res.status(200).send()
|
||||
}
|
||||
console.log('error during signature verification', err.message, req.body)
|
||||
return res.status(500).send()
|
||||
}
|
||||
}
|
|
@ -1,45 +0,0 @@
|
|||
const pub = require('../pub')
|
||||
|
||||
module.exports = {
|
||||
activity,
|
||||
jsonld,
|
||||
outboxActivity
|
||||
}
|
||||
|
||||
function activity (req, res, next) {
|
||||
if (!pub.utils.validateActivity(req.body)) {
|
||||
return res.status(400).send('Invalid activity')
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
function jsonld (req, res, next) {
|
||||
// we don't have to rule out HTML/browsers
|
||||
// because history fallback catches them first
|
||||
if (req.method === 'GET' && req.accepts(pub.consts.jsonldTypes)) {
|
||||
return next()
|
||||
}
|
||||
if (req.method === 'POST' && req.is(pub.consts.jsonldTypes)) {
|
||||
return next()
|
||||
}
|
||||
next('route')
|
||||
}
|
||||
|
||||
function outboxActivity (req, res, next) {
|
||||
if (!pub.utils.validateActivity(req.body)) {
|
||||
if (!pub.utils.validateObject(req.body)) {
|
||||
return res.status(400).send('Invalid activity')
|
||||
}
|
||||
const actor = pub.utils.usernameToIRI(req.user)
|
||||
const extras = {}
|
||||
if (req.body.bcc) {
|
||||
extras.bcc = req.body.bcc
|
||||
}
|
||||
if (req.body.audience) {
|
||||
extras.audience = req.body.audience
|
||||
}
|
||||
req.body = pub.activity
|
||||
.build('Create', actor, req.body, req.body.to, req.body.cc, extras)
|
||||
}
|
||||
next()
|
||||
}
|
9110
package-lock.json
generated
9110
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -4,8 +4,8 @@
|
|||
"description": "Decentralized social groups with ActivityPub, NodeJS, Express, Vue, and Mongodb",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"@small-tech/auto-encrypt": "2.0.5",
|
||||
"body-parser": "^1.18.3",
|
||||
"@small-tech/auto-encrypt": "^3.0.1",
|
||||
"activitypub-express": "^2.2.1",
|
||||
"connect-history-api-fallback": "^1.6.0",
|
||||
"cors": "^2.8.4",
|
||||
"express": "^4.16.3",
|
||||
|
@ -23,7 +23,7 @@
|
|||
"devDependencies": {
|
||||
"chokidar": "^3.1.1",
|
||||
"nodemon": "^1.19.3",
|
||||
"standard": "^14.3.1",
|
||||
"standard": "^16.0.4",
|
||||
"standardjs": "^1.0.0-alpha"
|
||||
},
|
||||
"scripts": {
|
||||
|
|
|
@ -1,87 +0,0 @@
|
|||
'use strict'
|
||||
const { ObjectId } = require('mongodb')
|
||||
const store = require('../store')
|
||||
const pubUtils = require('./utils')
|
||||
const pubObject = require('./object')
|
||||
const pubFederation = require('./federation')
|
||||
module.exports = {
|
||||
address,
|
||||
addToOutbox,
|
||||
build,
|
||||
undo
|
||||
}
|
||||
|
||||
function build (type, actorId, object, to, cc, etc) {
|
||||
const oid = new ObjectId()
|
||||
const act = Object.assign({
|
||||
// _id: oid,
|
||||
id: pubUtils.actvityIdToIRI(oid),
|
||||
type,
|
||||
actor: actorId,
|
||||
object,
|
||||
to,
|
||||
cc,
|
||||
published: new Date().toISOString()
|
||||
}, etc)
|
||||
return act
|
||||
}
|
||||
|
||||
async function address (activity) {
|
||||
let audience = []
|
||||
;['to', 'bto', 'cc', 'bcc', 'audience'].forEach(t => {
|
||||
if (activity[t]) {
|
||||
audience = audience.concat(activity[t])
|
||||
}
|
||||
})
|
||||
audience = audience.map(t => {
|
||||
if (t === 'https://www.w3.org/ns/activitystreams#Public') {
|
||||
return null
|
||||
}
|
||||
return pubObject.resolveObject(t)
|
||||
})
|
||||
audience = await Promise.all(audience).then(addresses => {
|
||||
// TODO: spec says only deliver to actor-owned collections
|
||||
addresses = addresses.map(t => {
|
||||
if (t && t.inbox) {
|
||||
return t
|
||||
}
|
||||
if (t && t.items) {
|
||||
return t.items.map(pubObject.resolveObject)
|
||||
}
|
||||
if (t && t.orderedItems) {
|
||||
return t.orderedItems.map(pubObject.resolveObject)
|
||||
}
|
||||
})
|
||||
// flattens and resolves collections
|
||||
return Promise.all([].concat(...addresses))
|
||||
})
|
||||
audience = audience.filter(t => t && t.inbox)
|
||||
.map(t => t.inbox)
|
||||
// de-dupe
|
||||
return Array.from(new Set(audience))
|
||||
}
|
||||
|
||||
function addToOutbox (actor, activity) {
|
||||
const tasks = [
|
||||
store.stream.save(activity),
|
||||
address(activity).then(addresses => pubFederation.deliver(actor, activity, addresses))
|
||||
]
|
||||
// ensure activity object is cached if local, but do not try to resolve links
|
||||
// because Mastodon won't resolve activity IRIs
|
||||
if (pubUtils.validateObject(activity.object)) {
|
||||
tasks.push(pubObject.resolve(activity.object))
|
||||
}
|
||||
return Promise.all(tasks)
|
||||
}
|
||||
|
||||
function undo (activity, undoActor) {
|
||||
if (!pubUtils.validateActivity(activity)) {
|
||||
if (!activity || Object.prototype.toString.call(activity) !== '[object String]') {
|
||||
throw new Error('Invalid undo target')
|
||||
}
|
||||
activity = { id: activity }
|
||||
}
|
||||
// matches the target activity with the actor from the undo
|
||||
// so actors can only undo their own activities
|
||||
return store.stream.remove(activity, undoActor)
|
||||
}
|
71
pub/actor.js
71
pub/actor.js
|
@ -1,71 +0,0 @@
|
|||
const crypto = require('crypto')
|
||||
const { promisify } = require('util')
|
||||
|
||||
const store = require('../store')
|
||||
const pubUtils = require('./utils')
|
||||
const config = require('../config.json')
|
||||
|
||||
const generateKeyPairPromise = promisify(crypto.generateKeyPair)
|
||||
|
||||
module.exports = {
|
||||
createLocalActor,
|
||||
getOrCreateActor
|
||||
}
|
||||
|
||||
function createLocalActor (name, type) {
|
||||
return generateKeyPairPromise('rsa', {
|
||||
modulusLength: 4096,
|
||||
publicKeyEncoding: {
|
||||
type: 'spki',
|
||||
format: 'pem'
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: 'pkcs8',
|
||||
format: 'pem'
|
||||
}
|
||||
}).then(pair => {
|
||||
const actorBase = pubUtils.usernameToIRI(name)
|
||||
return {
|
||||
_meta: {
|
||||
privateKey: pair.privateKey
|
||||
},
|
||||
id: `${actorBase}`,
|
||||
type: type,
|
||||
following: `${actorBase}/following`,
|
||||
followers: `${actorBase}/followers`,
|
||||
liked: `${actorBase}/liked`,
|
||||
inbox: `${actorBase}/inbox`,
|
||||
outbox: `${actorBase}/outbox`,
|
||||
preferredUsername: name,
|
||||
name: `${name} group`,
|
||||
summary: `I'm a group about ${name}. Follow me to get all the group posts. Tag me to share with the group. Create other groups by searching for or tagging @yourGroupName@${config.DOMAIN}`,
|
||||
icon: {
|
||||
type: 'Image',
|
||||
mediaType: 'image/jpeg',
|
||||
url: `https://${config.DOMAIN}/f/guppe.png`
|
||||
},
|
||||
publicKey: {
|
||||
id: `${actorBase}#main-key`,
|
||||
owner: `${actorBase}`,
|
||||
publicKeyPem: pair.publicKey
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function getOrCreateActor (preferredUsername, includeMeta) {
|
||||
const id = pubUtils.usernameToIRI(preferredUsername)
|
||||
let user = await store.actor.getActor(id, includeMeta)
|
||||
if (user) {
|
||||
return user
|
||||
}
|
||||
// auto create groups whenever an unknown actor is referenced
|
||||
user = await createLocalActor(preferredUsername, 'Group')
|
||||
await store.object.save(user)
|
||||
// only executed on success
|
||||
delete user._id
|
||||
if (includeMeta !== true) {
|
||||
delete user._meta
|
||||
}
|
||||
return user
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
module.exports = {
|
||||
ASContext: [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1'
|
||||
],
|
||||
jsonldTypes: [
|
||||
'application/activity+json',
|
||||
'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||
'application/json'
|
||||
]
|
||||
}
|
|
@ -1,60 +0,0 @@
|
|||
'use strict'
|
||||
const crypto = require('crypto')
|
||||
const request = require('request-promise-native')
|
||||
const pubUtils = require('./utils')
|
||||
|
||||
// federation communication utilities
|
||||
module.exports = {
|
||||
requestObject,
|
||||
deliver
|
||||
}
|
||||
|
||||
function requestObject (id) {
|
||||
return request({
|
||||
url: id,
|
||||
headers: { Accept: 'application/activity+json' },
|
||||
json: true,
|
||||
httpSignature: {
|
||||
key: global.guppeSystemUser._meta.privateKey,
|
||||
keyId: global.guppeSystemUser.id,
|
||||
headers: ['(request-target)', 'host', 'date'],
|
||||
authorizationHeaderName: 'Signature'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function deliver (actor, activity, addresses) {
|
||||
if (activity.bto) {
|
||||
delete activity.bto
|
||||
}
|
||||
if (activity.bcc) {
|
||||
delete activity.bcc
|
||||
}
|
||||
const requests = addresses.map(addr => {
|
||||
const body = pubUtils.toJSONLD(activity)
|
||||
const digest = crypto.createHash('sha256')
|
||||
.update(JSON.stringify(body))
|
||||
.digest('base64')
|
||||
return request({
|
||||
method: 'POST',
|
||||
url: addr,
|
||||
headers: {
|
||||
'Content-Type': 'application/activity+json',
|
||||
Digest: `SHA-256=${digest}`
|
||||
},
|
||||
httpSignature: {
|
||||
key: actor._meta.privateKey,
|
||||
keyId: actor.id,
|
||||
headers: ['(request-target)', 'host', 'date', 'digest'],
|
||||
authorizationHeaderName: 'Signature'
|
||||
},
|
||||
json: true,
|
||||
resolveWithFullResponse: true,
|
||||
simple: false,
|
||||
body
|
||||
})
|
||||
.then(result => console.log('delivery:', addr, result.statusCode))
|
||||
.catch(err => console.log(err.message))
|
||||
})
|
||||
return Promise.all(requests)
|
||||
}
|
10
pub/index.js
10
pub/index.js
|
@ -1,10 +0,0 @@
|
|||
'use strict'
|
||||
// ActivityPub / ActivityStreams utils
|
||||
module.exports = {
|
||||
activity: require('./activity'),
|
||||
actor: require('./actor'),
|
||||
consts: require('./consts'),
|
||||
federation: require('./federation'),
|
||||
object: require('./object'),
|
||||
utils: require('./utils')
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
'use strict'
|
||||
const store = require('../store')
|
||||
const federation = require('./federation')
|
||||
const pubUtils = require('./utils')
|
||||
module.exports = {
|
||||
resolveObject,
|
||||
resolve: resolveObject
|
||||
}
|
||||
|
||||
// find object in local DB or fetch from origin server
|
||||
async function resolveObject (id) {
|
||||
let object
|
||||
let parseCheck
|
||||
if (pubUtils.validateObject(id)) {
|
||||
// already an object
|
||||
object = id
|
||||
} else {
|
||||
// resolve id to local object
|
||||
try {
|
||||
// check if full IRI id or remote id
|
||||
parseCheck = new URL(id)
|
||||
} catch (ignore) {}
|
||||
if (!parseCheck) {
|
||||
// convert bare ObjectId to local IRI id
|
||||
id = pubUtils.objectIdToIRI(id)
|
||||
}
|
||||
object = await store.object.get(id)
|
||||
if (object) {
|
||||
return object
|
||||
}
|
||||
// resolve remote object from id
|
||||
object = await federation.requestObject(id)
|
||||
}
|
||||
// cache non-collection objects
|
||||
if (object.type !== 'Collection' && object.type !== 'OrderedCollection') {
|
||||
await store.object.save(object)
|
||||
}
|
||||
return object
|
||||
}
|
69
pub/utils.js
69
pub/utils.js
|
@ -1,69 +0,0 @@
|
|||
'use strict'
|
||||
const config = require('../config.json')
|
||||
const pubConsts = require('./consts')
|
||||
|
||||
module.exports = {
|
||||
actvityIdToIRI,
|
||||
usernameToIRI,
|
||||
toJSONLD,
|
||||
arrayToCollection,
|
||||
actorFromActivity,
|
||||
objectIdToIRI,
|
||||
validateActivity,
|
||||
validateObject
|
||||
}
|
||||
|
||||
function actorFromActivity (activity) {
|
||||
if (Object.prototype.toString.call(activity.actor) === '[object String]') {
|
||||
return activity.actor
|
||||
}
|
||||
if (activity.actor.type === 'Link') {
|
||||
return activity.actor.href
|
||||
}
|
||||
return activity.actor.id
|
||||
}
|
||||
|
||||
function arrayToCollection (arr, ordered) {
|
||||
return {
|
||||
'@context': pubConsts.ASContext,
|
||||
totalItems: arr.length,
|
||||
type: ordered ? 'orderedCollection' : 'collection',
|
||||
[ordered ? 'orderedItems' : 'items']: arr
|
||||
}
|
||||
}
|
||||
|
||||
function toJSONLD (obj) {
|
||||
obj['@context'] = obj['@context'] || pubConsts.ASContext
|
||||
return obj
|
||||
}
|
||||
|
||||
function usernameToIRI (user) {
|
||||
return `https://${config.DOMAIN}/u/${user}`.toLowerCase()
|
||||
}
|
||||
|
||||
function objectIdToIRI (oid) {
|
||||
if (oid.toHexString) {
|
||||
oid = oid.toHexString()
|
||||
}
|
||||
return `https://${config.DOMAIN}/o/${oid}`.toLowerCase()
|
||||
}
|
||||
|
||||
function actvityIdToIRI (oid) {
|
||||
if (oid.toHexString) {
|
||||
oid = oid.toHexString()
|
||||
}
|
||||
return `https://${config.DOMAIN}/s/${oid}`.toLowerCase()
|
||||
}
|
||||
|
||||
function validateObject (object) {
|
||||
if (object && object.id) {
|
||||
// object['@context'] = object['@context'] || pubConsts.ASContext
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function validateActivity (object) {
|
||||
if (object && object.id && object.actor) {
|
||||
return true
|
||||
}
|
||||
}
|
|
@ -1,77 +0,0 @@
|
|||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const pub = require('../pub')
|
||||
const net = require('../net')
|
||||
const store = require('../store')
|
||||
|
||||
router.post('/', net.validators.activity, net.security.verifySignature, function (req, res) {
|
||||
req.body._meta = { _target: pub.utils.usernameToIRI(req.user) }
|
||||
const toDo = {
|
||||
saveActivity: true,
|
||||
saveObject: false
|
||||
}
|
||||
// side effects
|
||||
switch (req.body.type) {
|
||||
case 'Accept':
|
||||
// TODO - side effect necessary for following collection?
|
||||
break
|
||||
case 'Follow':
|
||||
// TODO resolve object and ensure specified target matches inbox user
|
||||
// req.body._meta._target = req.body.object.id
|
||||
// send acceptance reply
|
||||
pub.actor.getOrCreateActor(req.user, true)
|
||||
.then(user => {
|
||||
const to = [pub.utils.actorFromActivity(req.body)]
|
||||
const accept = pub.activity.build('Accept', user.id, req.body, to)
|
||||
return pub.activity.addToOutbox(user, accept)
|
||||
})
|
||||
.catch(e => console.log(e))
|
||||
break
|
||||
case 'Create':
|
||||
// toDo.saveObject = true
|
||||
pub.actor.getOrCreateActor(req.user, true)
|
||||
.then(user => {
|
||||
const to = [
|
||||
user.followers,
|
||||
'https://www.w3.org/ns/activitystreams#Public'
|
||||
]
|
||||
const cc = [pub.utils.actorFromActivity(req.body)]
|
||||
const announce = pub.activity.build('Announce', user.id, req.body.object.id, to, cc)
|
||||
return pub.activity.addToOutbox(user, announce)
|
||||
}).catch(e => console.log(e))
|
||||
break
|
||||
case 'Delete':
|
||||
case 'Undo':
|
||||
pub.activity.undo(req.body.object, req.body.actor)
|
||||
.catch(err => console.log(err.message))
|
||||
break
|
||||
}
|
||||
const tasks = []
|
||||
if (toDo.saveObject) {
|
||||
tasks.push(pub.object.resolve(req.body.object))
|
||||
}
|
||||
if (toDo.saveActivity) {
|
||||
tasks.push(store.stream.save(req.body))
|
||||
}
|
||||
Promise.all(tasks).then(() => res.status(200).send())
|
||||
.catch(err => {
|
||||
console.log(err.message)
|
||||
res.status(500).send()
|
||||
})
|
||||
})
|
||||
|
||||
router.get('/', function (req, res) {
|
||||
const db = req.app.get('db')
|
||||
db.collection('streams')
|
||||
.find({ '_meta._target': pub.utils.usernameToIRI(req.user) })
|
||||
.sort({ _id: -1 })
|
||||
.project({ _id: 0, _meta: 0, '@context': 0, 'object._id': 0, 'object.@context': 0, 'object._meta': 0 })
|
||||
.toArray()
|
||||
.then(stream => res.json(pub.utils.arrayToCollection(stream, true)))
|
||||
.catch(err => {
|
||||
console.log(err.message)
|
||||
return res.status(500).send()
|
||||
})
|
||||
})
|
||||
|
||||
module.exports = router
|
|
@ -1,10 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
module.exports = {
|
||||
inbox: require('./inbox'),
|
||||
object: require('./object'),
|
||||
outbox: require('./outbox'),
|
||||
stream: require('./stream'),
|
||||
user: require('./user'),
|
||||
webfinger: require('./webfinger')
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
'use strict'
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const pub = require('../pub')
|
||||
|
||||
router.get('/:name', function (req, res) {
|
||||
const id = req.params.name
|
||||
if (!id) {
|
||||
return res.status(400).send('Bad request.')
|
||||
} else {
|
||||
// TODO: don't attempt to resolve remote ids if request is cross-origin
|
||||
// (to prevent abuse of guppe server to DDoS other servers)
|
||||
pub.object.resolve(id)
|
||||
.then(obj => {
|
||||
if (obj) {
|
||||
return res.json(pub.utils.toJSONLD(obj))
|
||||
}
|
||||
return res.status(404).send('Object not found')
|
||||
})
|
||||
.catch(err => {
|
||||
console.log(err.message)
|
||||
res.status(500).send()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
|
@ -1,33 +0,0 @@
|
|||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const net = require('../net')
|
||||
const pub = require('../pub')
|
||||
const store = require('../store')
|
||||
|
||||
router.post('/', net.security.auth, net.validators.outboxActivity, function (req, res) {
|
||||
store.actor.get(pub.utils.usernameToIRI(req.user), true)
|
||||
.then(actor => {
|
||||
return pub.activity.addToOutbox(actor, req.body)
|
||||
})
|
||||
.then(() => res.status(200).send())
|
||||
.catch(err => {
|
||||
console.log(err.message)
|
||||
res.status(500).send()
|
||||
})
|
||||
})
|
||||
|
||||
router.get('/', function (req, res) {
|
||||
const db = req.app.get('db')
|
||||
db.collection('streams')
|
||||
.find({ actor: pub.utils.usernameToIRI(req.user), type: { $in: ['Announce', 'Create'] } })
|
||||
.sort({ _id: -1 })
|
||||
.project({ _id: 0, _meta: 0, 'object._id': 0, 'object.@context': 0, 'object._meta': 0 })
|
||||
.toArray()
|
||||
.then(stream => res.json(pub.utils.arrayToCollection(stream, true)))
|
||||
.catch(err => {
|
||||
console.log(err.message)
|
||||
return res.status(500).send()
|
||||
})
|
||||
})
|
||||
|
||||
module.exports = router
|
|
@ -1,26 +0,0 @@
|
|||
'use strict'
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const store = require('../store')
|
||||
const pub = require('../pub')
|
||||
|
||||
router.get('/:name', function (req, res) {
|
||||
const name = req.params.name
|
||||
if (!name) {
|
||||
return res.status(400).send('Bad request.')
|
||||
} else {
|
||||
store.stream.get(pub.utils.actvityIdToIRI(name))
|
||||
.then(obj => {
|
||||
if (obj) {
|
||||
return res.json(pub.utils.toJSONLD(obj))
|
||||
}
|
||||
return res.status(404).send('Activity not found')
|
||||
})
|
||||
.catch(err => {
|
||||
console.log(err.message)
|
||||
res.status(500).send()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
|
@ -1,71 +0,0 @@
|
|||
'use strict'
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const pub = require('../pub')
|
||||
const net = require('../net')
|
||||
|
||||
// list active groups
|
||||
router.get('/', net.validators.jsonld, function (req, res) {
|
||||
const db = req.app.get('db')
|
||||
db.collection('streams')
|
||||
.aggregate([
|
||||
{ $sort: { _id: -1 } }, // start from most recent
|
||||
{ $limit: 10000 }, // don't traverse the entire history
|
||||
{ $match: { type: 'Announce' } },
|
||||
{ $group: { _id: '$actor', postCount: { $sum: 1 } } },
|
||||
{ $lookup: { from: 'objects', localField: '_id', foreignField: 'id', as: 'actor' } },
|
||||
// merge joined actor up
|
||||
{ $replaceRoot: { newRoot: { $mergeObjects: [{ $arrayElemAt: ['$actor', 0] }, '$$ROOT'] } } },
|
||||
{ $project: { _id: 0, _meta: 0, actor: 0 } }
|
||||
])
|
||||
.sort({ postCount: -1 })
|
||||
.limit(Number.parseInt(req.query.n) || 50)
|
||||
.toArray()
|
||||
.then(groups => { console.log(JSON.stringify(groups)); return groups })
|
||||
.then(groups => res.json(groups))
|
||||
.catch(err => {
|
||||
console.log(err.message)
|
||||
return res.status(500).send()
|
||||
})
|
||||
})
|
||||
|
||||
router.get('/:name', net.validators.jsonld, function (req, res) {
|
||||
const name = req.params.name
|
||||
if (!name) {
|
||||
return res.status(400).send('Bad request.')
|
||||
}
|
||||
console.log('User json ', name)
|
||||
pub.actor.getOrCreateActor(name)
|
||||
.then(group => {
|
||||
return res.json(pub.utils.toJSONLD(group))
|
||||
})
|
||||
.catch(err => {
|
||||
console.log(err.message)
|
||||
res.status(500).send(`Error creating group ${name}`)
|
||||
})
|
||||
})
|
||||
|
||||
router.get('/:name/followers', net.validators.jsonld, function (req, res) {
|
||||
const name = req.params.name
|
||||
if (!name) {
|
||||
return res.status(400).send('Bad request.')
|
||||
}
|
||||
const db = req.app.get('db')
|
||||
db.collection('streams')
|
||||
.find({
|
||||
type: 'Follow',
|
||||
'_meta._target': pub.utils.usernameToIRI(name)
|
||||
})
|
||||
.project({ _id: 0, actor: 1 })
|
||||
.toArray()
|
||||
.then(follows => {
|
||||
const followers = follows.map(pub.utils.actorFromActivity)
|
||||
return res.json(pub.utils.arrayToCollection(followers))
|
||||
})
|
||||
.catch(err => {
|
||||
console.log(err.message)
|
||||
return res.status(500).send()
|
||||
})
|
||||
})
|
||||
|
||||
module.exports = router
|
|
@ -1,40 +0,0 @@
|
|||
'use strict'
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
|
||||
const pub = require('../pub')
|
||||
const acctReg = /acct:[@~]?([^@]+)@?(.*)/
|
||||
router.get('/', function (req, res) {
|
||||
const resource = req.query.resource
|
||||
const acct = acctReg.exec(resource)
|
||||
if (!acct || acct.length < 2) {
|
||||
return res.status(400).send('Bad request. Please make sure "acct:USER@DOMAIN" is what you are sending as the "resource" query parameter.')
|
||||
}
|
||||
if (acct[2] && acct[2].toLowerCase() !== req.app.get('domain').toLowerCase()) {
|
||||
return res.status(400).send('Requested user is not from this domain')
|
||||
}
|
||||
const db = req.app.get('db')
|
||||
pub.actor.getOrCreateActor(acct[1], db)
|
||||
.then(result => {
|
||||
if (!result) {
|
||||
return res.status(404).send(`${acct[1]}@${acct[2]} not found`)
|
||||
}
|
||||
const finger = {
|
||||
subject: resource,
|
||||
links: [
|
||||
{
|
||||
rel: 'self',
|
||||
type: 'application/activity+json',
|
||||
href: result.id
|
||||
}
|
||||
]
|
||||
}
|
||||
return res.json(finger)
|
||||
})
|
||||
.catch(err => {
|
||||
console.log(err.message)
|
||||
res.status(500).send()
|
||||
})
|
||||
})
|
||||
|
||||
module.exports = router
|
|
@ -1,19 +0,0 @@
|
|||
'use strict'
|
||||
const connection = require('./connection')
|
||||
module.exports = {
|
||||
getActor,
|
||||
get: getActor
|
||||
}
|
||||
|
||||
const actorProj = { _id: 0, _meta: 0 }
|
||||
const metaActorProj = { _id: 0 }
|
||||
|
||||
function getActor (id, includeMeta) {
|
||||
const db = connection.getDb()
|
||||
return db.collection('objects')
|
||||
.find({ id: id })
|
||||
.limit(1)
|
||||
// strict comparison as we don't want to return private keys on accident
|
||||
.project(includeMeta === true ? metaActorProj : actorProj)
|
||||
.next()
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
'use strict'
|
||||
module.exports = (function () {
|
||||
let con
|
||||
return {
|
||||
setDb: db => { con = db },
|
||||
getDb: () => con
|
||||
}
|
||||
})()
|
|
@ -1,9 +0,0 @@
|
|||
'use strict'
|
||||
// database interface
|
||||
module.exports = {
|
||||
setup: require('./setup'),
|
||||
actor: require('./actor'),
|
||||
object: require('./object'),
|
||||
stream: require('./stream'),
|
||||
connection: require('./connection')
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
'use strict'
|
||||
const connection = require('./connection')
|
||||
module.exports = {
|
||||
get,
|
||||
save
|
||||
}
|
||||
|
||||
function get (id) {
|
||||
return connection.getDb()
|
||||
.collection('objects')
|
||||
.find({ id: id })
|
||||
.limit(1)
|
||||
.project({ _id: 0, _meta: 0 })
|
||||
.next()
|
||||
}
|
||||
|
||||
async function save (object) {
|
||||
const db = connection.getDb()
|
||||
const exists = await db.collection('objects')
|
||||
.find({ id: object.id })
|
||||
.project({ _id: 1 })
|
||||
.limit(1)
|
||||
.hasNext()
|
||||
if (exists) {
|
||||
return false
|
||||
}
|
||||
return db.collection('objects')
|
||||
.insertOne(object, { forceServerObjectId: true })
|
||||
}
|
|
@ -1,38 +0,0 @@
|
|||
'use strict'
|
||||
const connection = require('./connection')
|
||||
module.exports = async function dbSetup (domain, dummyUser) {
|
||||
const db = connection.getDb()
|
||||
// inbox
|
||||
await db.collection('streams').createIndex({
|
||||
'_meta._target': 1,
|
||||
_id: -1
|
||||
}, {
|
||||
name: 'inbox'
|
||||
})
|
||||
// followers
|
||||
await db.collection('streams').createIndex({
|
||||
'_meta._target': 1
|
||||
}, {
|
||||
partialFilterExpression: { type: 'Follow' },
|
||||
name: 'followers'
|
||||
})
|
||||
// outbox
|
||||
await db.collection('streams').createIndex({
|
||||
actor: 1,
|
||||
_id: -1
|
||||
})
|
||||
// object lookup
|
||||
await db.collection('objects').createIndex({
|
||||
id: 1
|
||||
})
|
||||
if (dummyUser) {
|
||||
return db.collection('objects').findOneAndReplace(
|
||||
{ preferredUsername: 'dummy' },
|
||||
dummyUser,
|
||||
{
|
||||
upsert: true,
|
||||
returnOriginal: false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,42 +0,0 @@
|
|||
'use strict'
|
||||
const connection = require('./connection')
|
||||
module.exports = {
|
||||
get,
|
||||
remove,
|
||||
save
|
||||
}
|
||||
|
||||
function get (id) {
|
||||
const db = connection.getDb()
|
||||
return db.collection('streams')
|
||||
.find({ id: id })
|
||||
.limit(1)
|
||||
.project({ _id: 0, _meta: 0 })
|
||||
.next()
|
||||
}
|
||||
|
||||
async function save (activity) {
|
||||
const db = connection.getDb()
|
||||
const q = { id: activity.id }
|
||||
// activities may be duplicated for multiple local targets
|
||||
if (activity._meta && activity._meta._target) {
|
||||
q['_meta._target'] = activity._meta._target
|
||||
}
|
||||
const exists = await db.collection('streams')
|
||||
.find(q)
|
||||
.project({ _id: 1 })
|
||||
.limit(1)
|
||||
.hasNext()
|
||||
if (exists) {
|
||||
return false
|
||||
}
|
||||
|
||||
return db.collection('streams')
|
||||
// server object ID avoids mutating local copy of document
|
||||
.insertOne(activity, { forceServerObjectId: true })
|
||||
}
|
||||
|
||||
function remove (activity, actor) {
|
||||
return connection.getDb().collection('streams')
|
||||
.deleteMany({ id: activity.id, actor: actor })
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
module.exports = {
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
'use strict'
|
||||
// misc utilities
|
||||
module.exports = {
|
||||
consts: require('./consts')
|
||||
}
|
2
web/dist/index.html
vendored
2
web/dist/index.html
vendored
|
@ -1 +1 @@
|
|||
<!DOCTYPE html><html lang=en><head><meta charset=utf-8><meta http-equiv=X-UA-Compatible content="IE=edge"><meta name=viewport content="width=device-width,initial-scale=1"><link rel=icon href=/web/favicon.ico><link rel=stylesheet href=/web/w3.css><link rel=stylesheet href="https://fonts.googleapis.com/css?family=Lato"><link rel=stylesheet href=/web/fontawesome/all.min.css><title>web</title><link href=/web/css/app.bf8cceb6.css rel=preload as=style><link href=/web/js/app.636bc9fa.js rel=preload as=script><link href=/web/js/chunk-vendors.e214c0f7.js rel=preload as=script><link href=/web/css/app.bf8cceb6.css rel=stylesheet></head><body><noscript><strong>We're sorry but web doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id=app></div><script src=/web/js/chunk-vendors.e214c0f7.js></script><script src=/web/js/app.636bc9fa.js></script></body></html>
|
||||
<!DOCTYPE html><html lang=en><head><meta charset=utf-8><meta http-equiv=X-UA-Compatible content="IE=edge"><meta name=viewport content="width=device-width,initial-scale=1"><link rel=icon href=/web/favicon.ico><link rel=stylesheet href=/web/w3.css><link rel=stylesheet href="https://fonts.googleapis.com/css?family=Lato"><link rel=stylesheet href=/web/fontawesome/all.min.css><title>web</title><link href=/web/css/app.bf8cceb6.css rel=preload as=style><link href=/web/js/app.62fd8922.js rel=preload as=script><link href=/web/js/chunk-vendors.e214c0f7.js rel=preload as=script><link href=/web/css/app.bf8cceb6.css rel=stylesheet></head><body><noscript><strong>We're sorry but web doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id=app></div><script src=/web/js/chunk-vendors.e214c0f7.js></script><script src=/web/js/app.62fd8922.js></script></body></html>
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
15065
web/package-lock.json
generated
15065
web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue