require('dotenv').config() const path = require('path') const express = require('express') const MongoClient = require('mongodb').MongoClient const fs = require('fs') const https = require('https') const http = require('http') const morgan = require('morgan') const history = require('connect-history-api-fallback') const { onShutdown } = require('node-graceful-shutdown') const ActivitypubExpress = require('activitypub-express') const { version } = require('./package.json') const { DOMAIN, KEY_PATH, CERT_PATH, CA_PATH, PORT_HTTPS, DB_URL, DB_NAME, PROXY_MODE, ADMIN_SECRET, USE_ATTACHMENTS, GROUPS } = process.env const app = express() const client = new MongoClient(DB_URL) const sslOptions = { 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({ name: 'Guppe Groups', version, domain: DOMAIN, actorParam: 'actor', objectParam: 'id', itemsPerPage: 100, // delivery done in workers only in production offlineMode: process.env.NODE_ENV === 'production', context: require('./data/context.json'), routes }) app.use( morgan(':remote-addr - :remote-user [:date[clf]] ":method :url HTTP/:http-version" :status ":referrer" ":user-agent"'), express.json({ type: apex.consts.jsonldTypes }), apex, function checkAdminKey (req, res, next) { if (ADMIN_SECRET && req.get('authorization') === `Bearer ${ADMIN_SECRET}`) { res.locals.apex.authorized = true } next() } ) async function createGuppeActor (...args) { const actor = await apex.createActor(...args) if (USE_ATTACHMENTS) { actor.attachment = require('./data/attachments.json') } return actor } // Piggyback on old function name 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 createGuppeActor(actor, `${actor} group`, summary, icon, 'Group') await apex.store.saveObject(actorObj) } } catch (err) { return next(err) } */ next() } const acceptablePublicActivities = ['delete', 'update'] apex.net.inbox.post.splice( // just after standardizing the jsonld apex.net.inbox.post.indexOf(apex.net.validators.jsonld) + 1, 0, function inboxLogger (req, res, next) { try { console.log('%s from %s to %s', req.body.type, req.body.actor?.[0], req.params[apex.actorParam]) } finally { next() } }, // Lots of servers are delivering inappropriate activities to Guppe, move the filtering up earlier in the process to save work function inboxFilter (req, res, next) { try { const groupIRI = apex.utils.usernameToIRI(req.params[apex.actorParam]) const activityAudience = apex.audienceFromActivity(req.body) const activityType = req.body.type?.toLowerCase() const activityObject = req.body.object?.[0] if ( !activityAudience.includes(groupIRI) && activityObject !== groupIRI && !acceptablePublicActivities.includes(activityType) ) { console.log('Ignoring irrelevant activity sent to %s: %j', groupIRI, req.body) return res.status(202).send('Irrelevant activity ignored') } } catch (err) { console.warn('Error performing prefilter:', err) } next() } ) // Do not boost posts from servers who abuse the service. apex.net.inbox.post.splice( // Blocked domain check is inserted into apex inbox route right after the sender is verified apex.net.inbox.post.indexOf(apex.net.security.verifySignature) + 1, 0, async function rejectBlockedDomains (req, res, next) { try { const url = new URL(res.locals.apex.sender.id) const domain = await req.app.locals.apex.store.db.collection('servers').findOne({ hostname: url.hostname }) if (domain?.blocked) { console.log(`Ignoring post from ${url}:`, req.body) return res.sendStatus(200) } } catch (err) { console.error('Error checking domain blocks:', 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) .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.get('/.well-known/nodeinfo', apex.net.nodeInfoLocation.get) app.get('/nodeinfo/:version', apex.net.nodeInfo.get) app.on('apex-inbox', async ({ actor, activity, recipient, object }) => { switch (activity.type.toLowerCase()) { // automatically reshare incoming posts case 'create': { console.log(activity.object[0].inReplyTo) const to = [ recipient.followers[0], apex.consts.publicAddress ] const share = await apex.buildActivity('Announce', recipient.id, to, { object: activity.object[0].id, // make sure sender can see it even if they don't follow yet cc: actor.id }) //stop here, we can add the activity to the database, we'll handle it if it passes inspection, otherwise no console.log(activity.object[0].id) console.log(share) await apex.store.db.collection(`${recipient.id}-queue`).insertOne(share) //console.log(apex.store.db.collection(`${recipient.id}-queue`) var find = await apex.store.db.collection(`${recipient.id}-queue`).find().toArray() console.log(await apex.store.db.collection(`${recipient.id}-queue`).find().toArray()) console.log(find) //idk why but this works //var find2 = await apex.store.db.collection(`keys`).findOne( {id: recipient.id} ) //if (!find2) { // await apex.store.db.collection(`keys`).insertOne(recipient) //} //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() } case 'delete': { const queuefind = await apex.store.db.collection(`${recipient.id}-queue`).findOne( {object: [activity.object[0].id]} ) if (queuefind) { await apex.store.db.collection(`${recipient.id}-queue`).remove( {object: [activity.object[0].id]} ) } const postfind = await apex.store.db.collection(`${recipient.id}-posts`).findOne( {object: [activity.object[0].id]} ) if (postfind) { await apex.store.db.collection(`${recipient.id}-posts`).remove( {object: [activity.object[0].id]} ) } } } }) /// 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('/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() }) }) app.get('/stats', async (req, res, next) => { try { const queueSize = await apex.store.db.collection('deliveryQueue') .countDocuments({ attempt: 0 }) const uptime = process.uptime() res.json({ queueSize, uptime }) } catch (err) { next(err) } }) app.get('/mod/userinfo', async (req, res, next) => { try { const object = await apex.store.getObject(apex.utils.usernameToIRI(req.query.name)) res.json(object) } catch (err) { next(err) } }) app.get('/mod/changedata', async (req, res, next) => { try { var oldobject = await apex.store.getObject(apex.utils.usernameToIRI(req.query.name), true) const newgroup = await createGuppeActor(req.query.name, req.query.username, req.query.description, icon, 'Group') const to = [ oldobject.followers[0], apex.consts.publicAddress ] const share = await apex.buildActivity('Update', oldobject.id, to, { object: newgroup }) apex.addToOutbox(oldobject, share) await apex.store.updateObject(newgroup, oldobject.id, true) res.json(newgroup) } catch (err) { next(err) } }) app.get('/mod/data/queue', async (req, res, next) => { try { const object = await apex.store.getObject(apex.utils.usernameToIRI(req.query.name)) const queueSize = await apex.store.db.collection(`${object.id}-queue`).find().toArray() // console.log(res.json({queueSize})) console.log(queueSize) res.json(queueSize) } catch (err) { next(err) } }) app.get('/mod/data/posts', async (req, res, next) => { try { const object = await apex.store.getObject(apex.utils.usernameToIRI(req.query.name)) const queueSize = await apex.store.db.collection(`${object.id}-posts`).find().toArray() // console.log(res.json({queueSize})) console.log(queueSize) res.json(queueSize) } catch (err) { next(err) } }) app.get('/mod/approve', async (req, res, next) => { try { console.log("recived") const object = await apex.store.getObject(apex.utils.usernameToIRI(req.query.name), true) //console.log(object) //onsole.log(object.publicKey) //const keys = await apex.store.db.collection(`keys`).findOne( {id: object.id} ) const dbout = await apex.store.db.collection(`${object.id}-queue`).findOne( {object: [req.query.id]} ) //console.log(keys) await apex.store.db.collection(`${object.id}-posts`).insertOne(dbout) apex.addToOutbox(object, dbout) await apex.store.db.collection(`${object.id}-queue`).remove( {object: [req.query.id]} ) res.json(dbout) } catch (err) { next(err) } }) app.get('/mod/deny', async (req, res, next) => { try { console.log("recived") const object = await apex.store.getObject(apex.utils.usernameToIRI(req.query.name)) var dbout = await apex.store.db.collection(`${object.id}-queue`).findOne( {object: [req.query.id]} ) //apex.addToOutbox(await apex.store.getObject(apex.utils.usernameToIRI(req.query.name)), share) await apex.store.db.collection(`${object.id}-queue`).remove( {object: [req.query.id]} ) res.json(dbout) } catch (err) { next(err) } }) app.get('/mod/delete', async (req, res, next) => { try { console.log("recived") const actor = await apex.store.getObject(apex.utils.usernameToIRI(req.query.name), true) var dbout = await apex.store.db.collection(`${actor.id}-posts`).findOne( {object: [req.query.id]} ) const to = [ actor.followers[0], apex.consts.publicAddress ] const share = await apex.buildActivity('Undo', actor.id, to, { object: dbout }) apex.addToOutbox(actor, share) await apex.store.db.collection(`${actor.id}-posts`).remove( {object: [req.query.id]} ) res.json(share) } catch (err) { next(err) } }) 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') } }) client.connect() .then(async () => { const { default: AutoEncrypt } = await import('@small-tech/auto-encrypt') apex.store.db = client.db(DB_NAME) await apex.store.setup() await apex.store.db.collection('servers').createIndex({ hostname: 1 }, { name: 'servers-primary', unique: true }) apex.systemUser = await apex.store.getObject(apex.utils.usernameToIRI('system_service'), true) if (!apex.systemUser) { const systemUser = await createGuppeActor('system_service', `${DOMAIN} system service`, `${DOMAIN} system service`, icon, 'Service') await apex.store.saveObject(systemUser) apex.systemUser = systemUser } const grouparray = GROUPS.split(" ") var g = 0 while (g < grouparray.length) { findgroup = await apex.store.getObject(apex.utils.usernameToIRI(grouparray[g]), true) console.log(findgroup) if (!findgroup) { const newgroup = await createGuppeActor(grouparray[g], grouparray[g], 'A project built on guppe groups\, it features a moderation ui', icon, 'Group') await apex.store.saveObject(newgroup) } g++ } let server if (process.env.NODE_ENV === 'production') { if (PROXY_MODE) { server = http.createServer(app) try { // boolean or number app.set('trust proxy', JSON.parse(PROXY_MODE)) } catch (ignore) { // string app.set('trust proxy', PROXY_MODE) } } else { server = AutoEncrypt.https.createServer({ domains: [DOMAIN] }, app) } } else { server = https.createServer(sslOptions, app) } server.listen(PORT_HTTPS, function () { console.log('Guppe server listening on port ' + PORT_HTTPS) }) onShutdown(async () => { await new Promise((resolve, reject) => { server.close(err => (err ? reject(err) : resolve())) }) await client.close() console.log('Guppe server closed') }) })