WIP on shared inbox functionality

This commit is contained in:
Bryan Ashby 2023-01-22 13:51:32 -07:00
parent 8f131630ff
commit 0fc8ae0e18
No known key found for this signature in database
GPG Key ID: C2C1B501E4EFD994
7 changed files with 157 additions and 16 deletions

View File

@ -23,6 +23,16 @@ module.exports = class Activity extends ActivityPubObject {
return WellKnownActivityTypes; return WellKnownActivityTypes;
} }
static makeFollow(webServer, localActor, remoteActor, id = null) {
id = id || Activity._makeFullId(webServer, 'follow');
return new Activity({
id,
type: 'Follow',
actor: localActor,
object: remoteActor.id,
});
}
// https://www.w3.org/TR/activitypub/#accept-activity-inbox // https://www.w3.org/TR/activitypub/#accept-activity-inbox
static makeAccept(webServer, localActor, followRequest, id = null) { static makeAccept(webServer, localActor, followRequest, id = null) {
id = id || Activity._makeFullId(webServer, 'accept'); id = id || Activity._makeFullId(webServer, 'accept');

View File

@ -9,6 +9,7 @@ const {
makeUserUrl, makeUserUrl,
selfUrl, selfUrl,
isValidLink, isValidLink,
makeSharedInboxUrl,
} = require('../activitypub/util'); } = require('../activitypub/util');
const { ActivityStreamsContext } = require('./const'); const { ActivityStreamsContext } = require('./const');
const Log = require('../logger').log; const Log = require('../logger').log;
@ -86,9 +87,11 @@ module.exports = class Actor extends ActivityPubObject {
id: userSelfUrl, id: userSelfUrl,
type: 'Person', type: 'Person',
preferredUsername: user.username, preferredUsername: user.username,
name: user.getSanitizedName('real'), name: userSettings.showRealName
? user.getSanitizedName('real')
: user.username,
endpoints: { endpoints: {
sharedInbox: 'TODO', sharedInbox: makeSharedInboxUrl(webServer),
}, },
inbox: makeUserUrl(webServer, user, '/ap/users/') + '/inbox', inbox: makeUserUrl(webServer, user, '/ap/users/') + '/inbox',
outbox: makeUserUrl(webServer, user, '/ap/users/') + '/outbox', outbox: makeUserUrl(webServer, user, '/ap/users/') + '/outbox',

View File

@ -3,13 +3,20 @@ const ActivityPubObject = require('./object');
const apDb = require('../database').dbs.activitypub; const apDb = require('../database').dbs.activitypub;
const { getISOTimestampString } = require('../database'); const { getISOTimestampString } = require('../database');
const { isString, get } = require('lodash'); const { isString, get, isObject } = require('lodash');
const APPublicCollectionId = 'https://www.w3.org/ns/activitystreams#Public';
const APPublicOwningUserId = 0;
module.exports = class Collection extends ActivityPubObject { module.exports = class Collection extends ActivityPubObject {
constructor(obj) { constructor(obj) {
super(obj); super(obj);
} }
static get PublicCollectionId() {
return APPublicCollectionId;
}
static followers(owningUser, page, webServer, cb) { static followers(owningUser, page, webServer, cb) {
return Collection.getOrdered( return Collection.getOrdered(
'followers', 'followers',
@ -68,17 +75,29 @@ module.exports = class Collection extends ActivityPubObject {
); );
} }
static addPublicInboxItem(inboxItem, cb) {
return Collection.addToCollection(
'publicInbox',
APPublicOwningUserId,
inboxItem.id,
inboxItem,
false,
cb
);
}
static getOrdered(name, owningUser, includePrivate, page, mapper, webServer, cb) { static getOrdered(name, owningUser, includePrivate, page, mapper, webServer, cb) {
const privateQuery = includePrivate ? '' : ' AND is_private = FALSE'; const privateQuery = includePrivate ? '' : ' AND is_private = FALSE';
const followersUrl = const followersUrl =
makeUserUrl(webServer, owningUser, '/ap/users/') + `/${name}`; makeUserUrl(webServer, owningUser, '/ap/users/') + `/${name}`;
const owningUserId = isObject(owningUser) ? owningUser.userId : owningUser;
if (!page) { if (!page) {
return apDb.get( return apDb.get(
`SELECT COUNT(id) AS count `SELECT COUNT(id) AS count
FROM collection FROM collection
WHERE user_id = ? AND name = ?${privateQuery};`, WHERE user_id = ? AND name = ?${privateQuery};`,
[owningUser.userId, name], [owningUserId, name],
(err, row) => { (err, row) => {
if (err) { if (err) {
return cb(err); return cb(err);
@ -118,7 +137,7 @@ module.exports = class Collection extends ActivityPubObject {
FROM collection FROM collection
WHERE user_id = ? AND name = ?${privateQuery} WHERE user_id = ? AND name = ?${privateQuery}
ORDER BY timestamp;`, ORDER BY timestamp;`,
[owningUser.userId, name], [owningUserId, name],
(err, entries) => { (err, entries) => {
if (err) { if (err) {
return cb(err); return cb(err);
@ -147,11 +166,12 @@ module.exports = class Collection extends ActivityPubObject {
obj = JSON.stringify(obj); obj = JSON.stringify(obj);
} }
const owningUserId = isObject(owningUser) ? owningUser.userId : owningUser;
isPrivate = isPrivate ? 1 : 0; isPrivate = isPrivate ? 1 : 0;
apDb.run( apDb.run(
`INSERT OR IGNORE INTO collection (name, timestamp, user_id, obj_id, obj_json, is_private) `INSERT OR IGNORE INTO collection (name, timestamp, user_id, obj_id, obj_json, is_private)
VALUES (?, ?, ?, ?, ?, ?);`, VALUES (?, ?, ?, ?, ?, ?);`,
[name, getISOTimestampString(), owningUser.userId, objectId, obj, isPrivate], [name, getISOTimestampString(), owningUserId, objectId, obj, isPrivate],
function res(err) { function res(err) {
// non-arrow for 'this' scope // non-arrow for 'this' scope
if (err) { if (err) {
@ -163,10 +183,11 @@ module.exports = class Collection extends ActivityPubObject {
} }
static removeFromCollectionById(name, owningUser, objectId, cb) { static removeFromCollectionById(name, owningUser, objectId, cb) {
const owningUserId = isObject(owningUser) ? owningUser.userId : owningUser;
apDb.run( apDb.run(
`DELETE FROM collection `DELETE FROM collection
WHERE user_id = ? AND name = ? AND obj_id = ?;`, WHERE user_id = ? AND name = ? AND obj_id = ?;`,
[owningUser.userId, name, objectId], [owningUserId, name, objectId],
err => { err => {
return cb(err); return cb(err);
} }

View File

@ -5,7 +5,7 @@ module.exports = class ActivityPubSettings {
this.enabled = true; // :TODO: fetch from +op config default this.enabled = true; // :TODO: fetch from +op config default
this.manuallyApproveFollowers = false; this.manuallyApproveFollowers = false;
this.hideSocialGraph = false; // followers, following this.hideSocialGraph = false; // followers, following
this.showRealName = false; this.showRealName = true;
this.image = ''; this.image = '';
this.icon = ''; this.icon = '';

View File

@ -14,6 +14,7 @@ const moment = require('moment');
exports.ActivityStreamsContext = 'https://www.w3.org/ns/activitystreams'; exports.ActivityStreamsContext = 'https://www.w3.org/ns/activitystreams';
exports.isValidLink = isValidLink; exports.isValidLink = isValidLink;
exports.makeSharedInboxUrl = makeSharedInboxUrl;
exports.makeUserUrl = makeUserUrl; exports.makeUserUrl = makeUserUrl;
exports.webFingerProfileUrl = webFingerProfileUrl; exports.webFingerProfileUrl = webFingerProfileUrl;
exports.selfUrl = selfUrl; exports.selfUrl = selfUrl;
@ -39,6 +40,10 @@ function isValidLink(l) {
return /^https?:\/\/.+$/.test(l); return /^https?:\/\/.+$/.test(l);
} }
function makeSharedInboxUrl(webServer) {
return webServer.buildUrl(WellKnownLocations.Internal + '/ap/shared-inbox');
}
function makeUserUrl(webServer, user, relPrefix) { function makeUserUrl(webServer, user, relPrefix) {
return webServer.buildUrl( return webServer.buildUrl(
WellKnownLocations.Internal + `${relPrefix}${user.username}` WellKnownLocations.Internal + `${relPrefix}${user.username}`

View File

@ -529,7 +529,7 @@ dbs.message.run(
id INTEGER PRIMARY KEY, -- Auto-generated key id INTEGER PRIMARY KEY, -- Auto-generated key
name VARCHAR NOT NULL, -- examples: followers, follows, ... name VARCHAR NOT NULL, -- examples: followers, follows, ...
timestamp DATETIME NOT NULL, -- Timestamp in which this entry was created timestamp DATETIME NOT NULL, -- Timestamp in which this entry was created
user_id INTEGER NOT NULL, -- Local, owning user ID user_id INTEGER NOT NULL, -- Local, owning user ID, 0 means "all" for sharedInbox
obj_id VARCHAR NOT NULL, -- Object ID from obj_json.id obj_id VARCHAR NOT NULL, -- Object ID from obj_json.id
obj_json VARCHAR NOT NULL, -- Object varies by collection (obj_json.type) obj_json VARCHAR NOT NULL, -- Object varies by collection (obj_json.type)
is_private INTEGER NOT NULL, -- Is this object private to |user_id|? is_private INTEGER NOT NULL, -- Is this object private to |user_id|?

View File

@ -56,6 +56,12 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
}, },
}); });
this.webServer.addRoute({
method: 'POST',
path: /^\/_enig\/ap\/shared-inbox$/,
handler: this._sharedInboxPostHandler.bind(this),
});
this.webServer.addRoute({ this.webServer.addRoute({
method: 'GET', method: 'GET',
path: /^\/_enig\/ap\/users\/.+\/outbox(\?page=[0-9]+)?$/, path: /^\/_enig\/ap\/users\/.+\/outbox(\?page=[0-9]+)?$/,
@ -162,20 +168,20 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
}); });
req.on('end', () => { req.on('end', () => {
let activity; const activity = Activity.fromJsonString(Buffer.concat(body).toString());
try { if (!activity) {
activity = JSON.parse(Buffer.concat(body).toString());
activity = new Activity(activity);
} catch (e) {
this.log.error( this.log.error(
{ error: e.message, url: req.url, method: req.method }, { url: req.url, method: req.method, endpoint: 'inbox' },
'Failed to parse Activity' 'Failed to parse Activity'
); );
return this.webServer.resourceNotFound(resp); return this.webServer.resourceNotFound(resp);
} }
if (!activity.isValid()) { if (!activity.isValid()) {
this.log.warn({ activity }, 'Invalid or unsupported Activity'); this.log.warn(
{ activity, endpoint: 'inbox' },
'Invalid or unsupported Activity'
);
return this.webServer.badRequest(resp); return this.webServer.badRequest(resp);
} }
@ -192,6 +198,8 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
case 'Undo': case 'Undo':
return this._inboxUndoRequestHandler(activity, req, resp); return this._inboxUndoRequestHandler(activity, req, resp);
// :TODO: Create, etc.
default: default:
this.log.warn( this.log.warn(
{ type: activity.type }, { type: activity.type },
@ -204,6 +212,100 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
}); });
} }
_sharedInboxPostHandler(req, resp) {
const body = [];
req.on('data', d => {
body.push(d);
});
req.on('end', () => {
const activity = Activity.fromJsonString(Buffer.concat(body).toString());
if (!activity) {
this.log.error(
{ url: req.url, method: req.method, endpoint: 'sharedInbox' },
'Failed to parse Activity'
);
return this.webServer.resourceNotFound(resp);
}
if (!activity.isValid()) {
this.log.warn(
{ activity, endpoint: 'sharedInbox' },
'Invalid or unsupported Activity'
);
return this.webServer.badRequest(resp);
}
switch (activity.type) {
case 'Create':
return this._sharedInboxCreateActivity(req, resp, activity);
default:
this.log.warn(
{ type: activity.type },
'Invalid or unknown Activity type'
);
return this.resourceNotFound(resp);
}
});
}
_sharedInboxCreateActivity(req, resp, activity) {
// When an object is being delivered to the originating actor's followers,
// a server MAY reduce the number of receiving actors delivered to by
// identifying all followers which share the same sharedInbox who would
// otherwise be individual recipients and instead deliver objects to said
// sharedInbox. Thus in this scenario, the remote/receiving server participates
// in determining targeting and performing delivery to specific inboxes.
let toActors = activity.to;
if (!Array.isArray(toActors)) {
toActors = [toActors];
}
const createWhat = _.get(activity, 'object.type');
switch (createWhat) {
case 'Note':
return this._deliverSharedInboxNote(req, resp, toActors, activity);
default:
this.log.warn(
{ type: createWhat },
'Invalid or unsupported "Create" type'
);
return this.resourceNotFound(resp);
}
}
_deliverSharedInboxNote(req, resp, toActors, activity) {
async.forEach(
toActors,
(actor, nextActor) => {
if (Collection.PublicCollectionId === actor) {
// Deliver to inbox for "everyone":
// - Add to 'sharedInbox' collection
//
Collection.addPublicInboxItem(activity.object, err => {
if (err) {
return nextActor(err);
}
return nextActor(null);
});
} else {
nextActor(null);
}
},
err => {
if (err) {
return this.webServer.internalServerError(resp, err);
}
resp.writeHead(202);
return resp.end('');
}
);
}
_getCollectionHandler(name, req, resp, signature) { _getCollectionHandler(name, req, resp, signature) {
EnigAssert(signature, 'Missing signature!'); EnigAssert(signature, 'Missing signature!');