immers
Version:
ActivityPub server for the metaverse
404 lines (387 loc) • 13.6 kB
JavaScript
'use strict'
require('dotenv').config()
const fs = require('fs')
const path = require('path')
const https = require('https')
const express = require('express')
const session = require('express-session')
const MongoSessionStore = require('connect-mongodb-session')(session)
const cookieParser = require('cookie-parser')
const cors = require('cors')
const history = require('connect-history-api-fallback')
const { MongoClient } = require('mongodb')
const socketio = require('socket.io')
const request = require('request-promise-native')
const nunjucks = require('nunjucks')
const passport = require('passport')
const auth = require('./src/auth')
const AutoEncryptPromise = import('@small-tech/auto-encrypt')
const { onShutdown } = require('node-graceful-shutdown')
const morgan = require('morgan')
const { debugOutput, parseHandle } = require('./src/utils')
const { apex, createImmersActor, deliverWelcomeMessage, routes, onInbox, onOutbox, outboxPost } = require('./src/apex')
const { migrate } = require('./src/migrate')
const { scopes } = require('./common/scopes')
const {
port,
domain,
hub,
homepage,
name,
dbHost,
dbPort,
dbName,
sessionSecret,
keyPath,
certPath,
caPath,
monetizationPointer,
googleFont,
backgroundColor,
backgroundImage,
icon,
imageAttributionText,
imageAttributionUrl,
emailOptInURL,
emailOptInParam,
emailOptInNameParam,
systemUserName,
systemDisplayName,
welcome
} = process.env
let welcomeContent
if (welcome && fs.existsSync(path.join(__dirname, 'static-ext', welcome))) {
// docker volume location
welcomeContent = fs.readFileSync(path.join(__dirname, 'static-ext', welcome), 'utf8')
} else if (welcome && fs.existsSync(path.join(__dirname, 'static', welcome))) {
// internal default
welcomeContent = fs.readFileSync(path.join(__dirname, 'static', welcome), 'utf8')
}
const renderConfig = {
name,
domain,
monetizationPointer,
googleFont,
backgroundColor,
backgroundImage,
icon,
imageAttributionText,
imageAttributionUrl,
emailOptInURL
}
const mongoURI = `mongodb://${dbHost}:${dbPort}`
const app = express()
const client = new MongoClient(mongoURI, { useUnifiedTopology: true, useNewUrlParser: true })
nunjucks.configure({
autoescape: true,
express: app,
watch: app.get('env') === 'development'
})
// parsers
app.use(cookieParser())
app.use(express.urlencoded({ extended: false }))
app.use(express.json({ type: ['application/json'].concat(apex.consts.jsonldTypes) }))
app.use(morgan(':remote-addr - :remote-user [:date[clf]] ":method :url HTTP/:http-version" :status Accepts ":req[accept]" ":referrer" ":user-agent"'))
const sessionStore = new MongoSessionStore({
uri: mongoURI,
databaseName: dbName,
collection: 'sessions',
maxAge: 365 * 24 * 60 * 60 * 1000
})
app.use(session({
secret: sessionSecret,
resave: true,
saveUninitialized: false,
store: sessionStore,
cookie: {
maxAge: 365 * 24 * 60 * 60 * 1000,
secure: true
}
}))
app.use(passport.initialize())
app.use(passport.session())
app.use(apex)
// cannot check authorized origins in preflight, so open to all
app.options('*', cors())
/// auth related routes
app.route('/auth/login')
.get((req, res) => {
const data = Object.assign({}, renderConfig)
if (req.session && req.session.handle) {
Object.assign(data, parseHandle(req.session.handle))
delete req.session.handle
}
if (req.session?.loginTab) {
data.loginTab = req.session.loginTab
delete req.session.loginTab
}
res.render('dist/login/login.html', data)
})
.post(passport.authenticate('local', {
successReturnToOrRedirect: '/',
failureRedirect: '/auth/login?passwordfail'
}))
// find username & home from handle; if user is remote, get remote authorization url
app.get('/auth/home', auth.checkImmer)
// TODO:
// app.get('/auth/logout', routes.site.logout)
app.post('/auth/client', auth.registerClient)
app.post('/auth/forgot', passport.authenticate('easy'), (req, res) => {
return res.json({ emailed: true })
})
app.route('/auth/reset')
.get(passport.authenticate('easy'), (req, res) => {
res.render('dist/reset/reset.html', renderConfig)
})
.post(auth.changePasswordAndReturn)
/* redirect to an email opt-in form
doing this here rather than client-side because the URL was
troublesome to pass to the client via renderConfig due to sanitization
*/
app.get('/auth/optin', (req, res) => {
if (!emailOptInURL) {
return res.sendStatus(404)
}
const url = new URL(emailOptInURL)
const search = new URLSearchParams(url.search)
if (emailOptInParam && req.query.email) {
search.set(emailOptInParam, req.query.email)
}
if (emailOptInNameParam && req.query.name) {
search.set(emailOptInNameParam, req.query.name)
}
url.search = search
res.redirect(url)
})
async function registerActor (req, res, next) {
const preferredUsername = req.body.username
const name = req.body.name
try {
const actor = await createImmersActor(preferredUsername, name)
await apex.store.saveObject(actor)
await deliverWelcomeMessage(actor, welcomeContent)
next()
} catch (err) { next(err) }
}
app.post('/auth/user', auth.validateNewUser, auth.logout, registerActor, auth.registration)
// users are sent here from Hub to get access token, but it may interrupt with redirect
// to login and further redirect to login at their home immer if they are remote
app.get('/auth/authorize', auth.authorization)
app.post('/auth/decision', auth.decision)
// get actor from token
app.get('/auth/me', auth.priv, auth.userToActor, apex.net.actor.get)
// token endpoint for immers web client
app.post('/auth/token', auth.localToken)
// AP routes
const viewAuth = auth.scope(scopes.viewPrivate.name)
const friendsAuth = auth.scope([scopes.viewPrivate.name, scopes.viewFriends.name])
app.route(routes.inbox)
.get(auth.publ, viewAuth, apex.net.inbox.get)
.post(auth.publ, apex.net.inbox.post)
app.route(routes.outbox)
.get(auth.publ, viewAuth, apex.net.outbox.get)
.post(auth.priv, outboxPost)
app.route(routes.actor)
.get(auth.publ, auth.scope(scopes.viewProfile.name), apex.net.actor.get)
app.get(routes.object, auth.publ, viewAuth, apex.net.object.get)
app.get(routes.activity, auth.publ, viewAuth, apex.net.activityStream.get)
app.get(routes.followers, auth.publ, friendsAuth, apex.net.followers.get)
app.get(routes.following, auth.publ, friendsAuth, apex.net.following.get)
app.get(routes.liked, auth.publ, viewAuth, apex.net.liked.get)
app.get(routes.collections, auth.publ, viewAuth, apex.net.collections.get)
app.get(routes.shares, auth.publ, viewAuth, apex.net.shares.get)
app.get(routes.likes, auth.publ, viewAuth, apex.net.likes.get)
app.get(routes.blocked, auth.priv, friendsAuth, apex.net.blocked.get)
app.get(routes.rejections, auth.priv, friendsAuth, apex.net.rejections.get)
app.get(routes.rejected, auth.priv, friendsAuth, apex.net.rejected.get)
app.get('/.well-known/webfinger', apex.net.webfinger.get)
/// Custom side effects
app.on('apex-inbox', onInbox)
app.on('apex-outbox', onOutbox)
// custom c2s apis
const friendUpdateTypes = ['Arrive', 'Leave', 'Accept', 'Follow', 'Reject']
async function friendsLocations (req, res, next) {
const locals = res.locals.apex
const actor = locals.target
const inbox = actor.inbox[0]
const followers = actor.followers[0]
const rejected = apex.utils.nameToRejectedIRI(actor.preferredUsername)
const friends = await apex.store.db.collection('streams').aggregate([
{
$match: {
$and: [
{ '_meta.collection': inbox },
// filter only pending follow requests
{ '_meta.collection': { $nin: [followers, rejected] } }
],
type: { $in: friendUpdateTypes }
}
},
// most recent activity per actor
{ $sort: { _id: -1 } },
{ $group: { _id: '$actor', loc: { $first: '$$ROOT' } } },
// sort actors by most recent activity
{ $sort: { _id: -1 } },
{ $replaceRoot: { newRoot: '$loc' } },
{ $sort: { _id: -1 } },
{ $lookup: { from: 'objects', localField: 'actor', foreignField: 'id', as: 'actor' } },
{ $project: { _id: 0, 'actor.publicKey': 0 } }
]).toArray()
locals.result = {
id: `https://${domain}${req.originalUrl}`,
type: 'OrderedCollection',
totalItems: friends.length,
orderedItems: friends
}
next()
}
app.get('/u/:actor/friends', [
// check content type first in case this is HTML request
apex.net.validators.jsonld,
auth.priv,
friendsAuth,
apex.net.validators.targetActor,
apex.net.security.verifyAuthorization,
apex.net.security.requireAuthorized,
friendsLocations,
apex.net.responders.result
])
// static files included in repo/docker image
app.use('/static', express.static('static'))
// static files added on deployed server
app.use('/static', express.static('static-ext'))
app.use('/dist', express.static('dist'))
app.get('/', (req, res) => res.redirect(`${req.protocol}://${homepage || hub}`))
// for SPA routing in activity pub pages
app.use(history({
index: '/ap.html'
}))
// HTML versions of acitivty pub objects routes
app.get('/ap.html', auth.publ, (req, res) => {
const data = {
loggedInUser: req.user?.username
}
res.render('dist/ap/ap.html', Object.assign(data, renderConfig))
})
const sslOptions = {
key: keyPath && fs.readFileSync(path.join(__dirname, keyPath)),
cert: certPath && fs.readFileSync(path.join(__dirname, certPath)),
ca: caPath && fs.readFileSync(path.join(__dirname, caPath))
}
migrate(mongoURI).catch((err) => {
console.error('Unable to apply migrations: ', err.message)
process.exit(1)
}).then(async () => {
const { default: AutoEncrypt } = await AutoEncryptPromise
const server = process.env.NODE_ENV === 'production'
? AutoEncrypt.https.createServer({ domains: [domain] }, app)
: https.createServer(sslOptions, app)
// streaming updates
const profilesSockets = new Map()
const io = socketio(server, {
// we have to leave CORS open for preflight regardless, and tokens are required to connect,
// so not really worth the effort to make CORS more specific
cors: {
origin: '*',
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true
}
})
io.use(function (socket, next) {
passport.authenticate('bearer', function (err, user, info) {
if (err) { return next(err) }
if (!user) { return next(new Error('Not authorized')) }
socket.authorizedUserId = apex.utils.usernameToIRI(user.username)
profilesSockets.set(socket.authorizedUserId, socket)
// for future use with fine-grained CORS origins
socket.hub = info.origin
next()
})(socket.request, {}, next)
})
io.on('connection', socket => {
socket.immers = {}
socket.on('disconnect', async (reason) => {
console.log('socket disconnect: ', reason, socket.authorizedUserId)
if (socket.authorizedUserId) {
profilesSockets.delete(socket.authorizedUserId)
}
if (socket.immers.outbox && socket.immers.leave) {
request({
method: 'POST',
url: socket.immers.outbox,
headers: {
'Content-Type': apex.consts.jsonldOutgoingType,
Authorization: socket.immers.authorization
},
json: true,
simple: false,
body: await apex.toJSONLD(socket.immers.leave)
}).catch(err => console.log(err.message))
delete socket.immers.leave
}
})
socket.on('entered', msg => {
socket.immers.outbox = msg.outbox
socket.immers.leave = msg.leave
socket.immers.authorization = msg.authorization
})
})
// live stream of feed updates to client inbox-update goes to chat & friends-update to people list
async function onInboxFriendUpdate (msg) {
const liveSocket = profilesSockets.get(msg.recipient.id)
msg.activity.actor = [msg.actor]
msg.activity.object = [msg.object]
// convert to same format as inbox endpoint and strip any private properties
liveSocket?.emit('inbox-update', apex.stringifyPublicJSONLD(await apex.toJSONLD(msg.activity)))
if (friendUpdateTypes.includes(msg.activity.type)) {
liveSocket?.emit('friends-update')
}
}
app.on('apex-inbox', onInboxFriendUpdate)
if (process.env.NODE_ENV !== 'production') {
debugOutput(app)
}
// clean shutdown required for autoencrypt
onShutdown(async () => {
await client.close()
await new Promise((resolve, reject) => {
server.close(err => (err ? reject(err) : resolve()))
})
console.log('Immers server closed')
})
// server startup
await client.connect({ useNewUrlParser: true })
apex.store.db = client.db(dbName)
// Place object representing this node
const immer = await apex.fromJSONLD({
id: `https://${domain}/o/immer`,
type: 'Place',
name,
url: `https://${hub}`,
audience: apex.consts.publicAddress
})
await apex.store.setup(immer)
await auth.authdb.setup(apex.store.db)
if (systemUserName) {
apex.systemUser = await apex.createActor(
systemUserName,
systemDisplayName || systemUserName,
name,
icon && `https://${domain}/static/${icon}`,
'Service'
)
await apex.store.db.collection('objects').findOneAndReplace(
{ id: apex.systemUser.id },
apex.systemUser,
{
upsert: true,
returnOriginal: false
}
)
}
server.listen(port, () => {
console.log(`immers app listening on port ${port}`)
// startup delivery in case anything is queued
apex.startDelivery()
})
})