Skip to content

Commit

Permalink
Merge pull request #42 from wmurphyrd/apex
Browse files Browse the repository at this point in the history
Port to activitypub-express
  • Loading branch information
wmurphyrd authored Nov 6, 2021
2 parents 0e5a583 + 22cc9eb commit d9539d8
Show file tree
Hide file tree
Showing 65 changed files with 23,059 additions and 11,550 deletions.
7 changes: 7 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
node_modules
web/node_modules
npm-debug.log
certs
.vscode
web/dist
.env
2 changes: 2 additions & 0 deletions .env.defaults
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
DB_NAME=guppe
NODE_ENV=production
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ certs/
.vscode
dump/
logs/
.env
23 changes: 23 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
FROM node:16

# Web clinet installs
WORKDIR /usr/src/guppe/web
COPY ./web/package*.json ./
RUN npm ci

# server installs
WORKDIR /usr/src/guppe
COPY package*.json ./
RUN npm ci

# source
COPY . .

# web client build
WORKDIR /usr/src/guppe/web
RUN npm run build

# entry
WORKDIR /usr/src/guppe
EXPOSE 443 80
CMD [ "node", "index.js" ]
27 changes: 20 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,34 @@
# Current state of guppe

Guppe is a tech demo. It is missing a lot of features, and I am in the process of rewriting it's core code from scratch in the fully-implemented, [modular ActivityPub library activitypub-express](https://github.com/immers-space/activitypub-express). I'll try to keep up with major bugfixes, but I won't be adding any features to guppe until i finish apex and can port guppe over to the new engine.

# Gup.pe

Social groups for the fediverse - making it easy to connect and meet new people based on shared interests without the manipulation of your attention to maximize ad revenue nor the walled garden lock-in of capitalist social media.

This server-2-server ActivityPub implementation adds decentralized, federaded "groups" support across all ActivityPub compliant social media networks. Users join groups by following group-type actors on Guppe servers and contribute to groups by mentioning those same actors in a post. Guppe group actors will automatically forward posts they receive to all group members so that everyone in the group sees any post made to the group. Guppe group actors' profiles (e.g. outboxes) also serve as a group discussion history.
Creation of new groups is automatic, users simply search for or mention a new group and it will be created.


## Tech stack

MEVN: MongoDB, ExpressJS, Vue, NodeJS
Mostly powered by [activitypub-express](https://github.com/immers-space/activitypub-express)
from [Immers Space](https://web.immers.space).
The gup.pe server app, `index.js` is 200 lines of code,
just adding the auto-create actor, auto-accept follow, and auto-boost from inbox behaviors
to the base apex setup.

There's also an HTML front-end using Vue (`/web`) to display popular groups and provide
fallback user profile discovery.

## Installation

Instructions coming soon.
`Dockerfile` and `docker-compose.yml` are provided for easy install

```
git clone https://github.com/wmurphyrd/guppe.git
cd guppe
cp .env.defaults .env
echo DOMAIN=yourdomain.com >> .env
docker-compose up -d
```

## License

Copyright (c) 2019 William Murphy. Licensed under the AGPL-3
Copyright (c) 2021 William Murphy. Licensed under the AGPL-3
37 changes: 37 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
version: "3.8"

services:
guppe:
build: .
restart: unless-stopped
ports:
- 443:443
- 80:80
env_file: '.env'
environment:
DB_URL: 'mongodb://mongodb:27017'
PORT_HTTPS: 443
depends_on:
- mongodb
volumes:
- certs:/root/.small-tech.org/auto-encrypt
# localdev certs
- ./certs:/usr/src/guppe/certs
logging:
driver: local
options:
max-size: '10m'

mongodb:
image: mongo:4.2
restart: unless-stopped
volumes:
- mongo-data:/data/db
logging:
driver: local
options:
max-size: '10m'

volumes:
mongo-data:
certs:
235 changes: 168 additions & 67 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,100 +1,201 @@
require('dotenv').config()
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 { DOMAIN, KEY_PATH, CERT_PATH, CA_PATH, PORT_HTTPS, DB_URL, DB_NAME } = process.env

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)

// Create new groups on demand whenever someone tries to access one
async function actorOnDemand (req, res, next) {
const actor = req.params.actor
if (!actor) {
return next()
}
const actorIRI = apex.utils.usernameToIRI(actor)
try {
if (!(await apex.store.getObject(actorIRI)) && actor.length <= 255) {
console.log(`Creating group: ${actor}`)
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}`
const actorObj = await apex.createActor(actor, `${actor} group`, summary, icon, 'Group')
await apex.store.saveObject(actorObj)
}
} catch (err) { return next(err) }
next()
}
// define routes using prepacakged middleware collections
app.route(routes.inbox)
.post(actorOnDemand, apex.net.inbox.post)
.get(actorOnDemand, apex.net.inbox.get)
app.route(routes.outbox)
.get(actorOnDemand, 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, actorOnDemand, apex.net.actor.get)
app.get(routes.followers, actorOnDemand, apex.net.followers.get)
app.get(routes.following, actorOnDemand, apex.net.following.get)
app.get(routes.liked, actorOnDemand, 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,
actorOnDemand,
apex.net.validators.targetActor,
apex.net.wellKnown.respondWebfinger
)

app.on('apex-inbox', async ({ actor, activity, recipient, object }) => {
switch (activity.type.toLowerCase()) {
// automatically reshare incoming posts
case 'create': {
// check audience to ignore forwarded messages not adddressed to group
const audience = apex.audienceFromActivity(activity)
if (!audience.includes(recipient.id) || !activity.object?.length) {
return
}
const to = [
recipient.followers[0],
apex.consts.publicAddress
]
const share = await apex.buildActivity('Announce', recipient.id, to, {
object: activity.object[0].id
})
apex.addToOutbox(recipient, share)
break
}
// automatically accept follow requests
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: [
// do not redirect webfinger et c.
{ 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'))
// web json routes
app.get('/groups', (req, res, next) => {
apex.store.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 => apex.toJSONLD({
id: `https://${DOMAIN}/groups`,
type: 'OrderedCollection',
totalItems: groups.length,
orderedItems: groups
}))
// .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()
})
})

// error logging
app.use(function (err, req, res, next) {
console.error(err.message, req.body, err.stack)
res.status(500).send('An error occurred while processing the request')
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
}

onShutdown(async () => {
await client.close()
await new Promise((resolve, reject) => {
server.close(err => (err ? reject(err) : resolve()))
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) => {
server.close(err => (err ? reject(err) : resolve()))
})
console.log('Guppe server closed')
})
})
console.log('Guppe server closed')
})
Loading

0 comments on commit d9539d8

Please sign in to comment.