Rework most of the ActivityPub routing handling

This commit is contained in:
Bryan Ashby 2023-02-08 12:53:56 -07:00
parent 39a49f00be
commit c5f0e0e6ef
No known key found for this signature in database
GPG Key ID: C2C1B501E4EFD994
6 changed files with 462 additions and 427 deletions

View File

@ -2,9 +2,9 @@ const {
ActivityStreamMediaType, ActivityStreamMediaType,
WellKnownActivityTypes, WellKnownActivityTypes,
WellKnownActivity, WellKnownActivity,
WellKnownRecipientFields,
HttpSignatureSignHeaders, HttpSignatureSignHeaders,
} = require('./const'); } = require('./const');
const { recipientIdsFromObject } = require('./util');
const Endpoints = require('./endpoint'); const Endpoints = require('./endpoint');
const ActivityPubObject = require('./object'); const ActivityPubObject = require('./object');
const { Errors } = require('../enig_error'); const { Errors } = require('../enig_error');
@ -110,21 +110,8 @@ module.exports = class Activity extends ActivityPubObject {
return postJson(inboxEndpoint, activityJson, reqOpts, cb); return postJson(inboxEndpoint, activityJson, reqOpts, cb);
} }
// :TODO: we need dp/support a bit more here...
recipientIds() { recipientIds() {
const ids = []; return recipientIdsFromObject(this);
WellKnownRecipientFields.forEach(field => {
let v = this[field];
if (v) {
if (!Array.isArray(v)) {
v = [v];
}
ids.push(...v);
}
});
return Array.from(new Set(ids));
} }
static activityObjectId(webServer) { static activityObjectId(webServer) {

View File

@ -73,6 +73,10 @@ module.exports = class Collection extends ActivityPubObject {
); );
} }
static outbox(collectionId, page, cb) {
return Collection.publicOrderedById('outbox', collectionId, page, null, cb);
}
static addFollower(owningUser, followingActor, webServer, ignoreDupes, cb) { static addFollower(owningUser, followingActor, webServer, ignoreDupes, cb) {
const collectionId = Endpoints.followers(webServer, owningUser); const collectionId = Endpoints.followers(webServer, owningUser);
return Collection.addToCollection( return Collection.addToCollection(
@ -116,10 +120,6 @@ module.exports = class Collection extends ActivityPubObject {
); );
} }
static outbox(collectionId, page, cb) {
return Collection.publicOrderedById('outbox', collectionId, page, null, cb);
}
static addOutboxItem(owningUser, outboxItem, isPrivate, webServer, ignoreDupes, cb) { static addOutboxItem(owningUser, outboxItem, isPrivate, webServer, ignoreDupes, cb) {
const collectionId = Endpoints.outbox(webServer, owningUser); const collectionId = Endpoints.outbox(webServer, owningUser);
return Collection.addToCollection( return Collection.addToCollection(

View File

@ -3,7 +3,12 @@ const ActivityPubObject = require('./object');
const { Errors } = require('../enig_error'); const { Errors } = require('../enig_error');
const { getISOTimestampString } = require('../database'); const { getISOTimestampString } = require('../database');
const User = require('../user'); const User = require('../user');
const { parseTimestampOrNow, messageToHtml, htmlToMessageBody } = require('./util'); const {
parseTimestampOrNow,
messageToHtml,
htmlToMessageBody,
recipientIdsFromObject,
} = require('./util');
const { isAnsi } = require('../string_util'); const { isAnsi } = require('../string_util');
// deps // deps
@ -26,11 +31,19 @@ module.exports = class Note extends ActivityPubObject {
return false; return false;
} }
if (this.type !== 'Note') {
return false;
}
// :TODO: validate required properties // :TODO: validate required properties
return true; return true;
} }
recipientIds() {
return recipientIdsFromObject(this);
}
static fromPublicNoteId(noteId, cb) { static fromPublicNoteId(noteId, cb) {
Collection.objectByEmbeddedId(noteId, (err, obj, objInfo) => { Collection.objectByEmbeddedId(noteId, (err, obj, objInfo) => {
if (err) { if (err) {

View File

@ -3,6 +3,7 @@ const { Errors, ErrorReasons } = require('../enig_error');
const UserProps = require('../user_property'); const UserProps = require('../user_property');
const ActivityPubSettings = require('./settings'); const ActivityPubSettings = require('./settings');
const { stripAnsiControlCodes } = require('../string_util'); const { stripAnsiControlCodes } = require('../string_util');
const { WellKnownRecipientFields } = require('./const');
// deps // deps
const _ = require('lodash'); const _ = require('lodash');
@ -27,6 +28,7 @@ exports.messageToHtml = messageToHtml;
exports.htmlToMessageBody = htmlToMessageBody; exports.htmlToMessageBody = htmlToMessageBody;
exports.userNameFromSubject = userNameFromSubject; exports.userNameFromSubject = userNameFromSubject;
exports.extractMessageMetadata = extractMessageMetadata; exports.extractMessageMetadata = extractMessageMetadata;
exports.recipientIdsFromObject = recipientIdsFromObject;
// :TODO: more info in default // :TODO: more info in default
// this profile template is the *default* for both WebFinger // this profile template is the *default* for both WebFinger
@ -241,3 +243,19 @@ function extractMessageMetadata(body) {
return metadata; return metadata;
} }
function recipientIdsFromObject(obj) {
const ids = [];
WellKnownRecipientFields.forEach(field => {
let v = obj[field];
if (v) {
if (!Array.isArray(v)) {
v = [v];
}
ids.push(...v);
}
});
return Array.from(new Set(ids));
}

View File

@ -78,7 +78,9 @@ function _makeRequest(url, options, cb) {
if (res.statusCode < 200 || res.statusCode > 299) { if (res.statusCode < 200 || res.statusCode > 299) {
return cb( return cb(
Errors.HttpError( Errors.HttpError(
`HTTP error ${res.statusCode}: ${truncate(body, { length: 128 })}` `URL ${url} HTTP error ${res.statusCode}: ${truncate(body, {
length: 128,
})}`
) )
); );
} }

View File

@ -5,7 +5,10 @@ const {
DefaultProfileTemplate, DefaultProfileTemplate,
} = require('../../../activitypub/util'); } = require('../../../activitypub/util');
const Endpoints = require('../../../activitypub/endpoint'); const Endpoints = require('../../../activitypub/endpoint');
const { ActivityStreamMediaType } = require('../../../activitypub/const'); const {
ActivityStreamMediaType,
WellKnownActivity,
} = require('../../../activitypub/const');
const Config = require('../../../config').get; const Config = require('../../../config').get;
const Activity = require('../../../activitypub/activity'); const Activity = require('../../../activitypub/activity');
const ActivityPubSettings = require('../../../activitypub/settings'); const ActivityPubSettings = require('../../../activitypub/settings');
@ -57,10 +60,12 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
method: 'POST', method: 'POST',
path: /^\/_enig\/ap\/users\/.+\/inbox$/, path: /^\/_enig\/ap\/users\/.+\/inbox$/,
handler: (req, resp) => { handler: (req, resp) => {
return this._enforceSigningPolicy( return this._enforceMainKeySignatureValidity(
req, req,
resp, resp,
this._inboxPostHandler.bind(this) (req, resp, signature) => {
return this._inboxPostHandler(req, resp, signature, 'inbox');
}
); );
}, },
}); });
@ -68,14 +73,27 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
this.webServer.addRoute({ this.webServer.addRoute({
method: 'POST', method: 'POST',
path: /^\/_enig\/ap\/shared-inbox$/, path: /^\/_enig\/ap\/shared-inbox$/,
handler: this._sharedInboxPostHandler.bind(this), handler: (req, resp) => {
return this._enforceMainKeySignatureValidity(
req,
resp,
(req, resp, signature) => {
return this._inboxPostHandler(
req,
resp,
signature,
'sharedInbox'
);
}
);
},
}); });
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]+)?$/,
handler: (req, resp) => { handler: (req, resp) => {
return this._enforceSigningPolicy( return this._enforceMainKeySignatureValidity(
req, req,
resp, resp,
this._outboxGetHandler.bind(this) this._outboxGetHandler.bind(this)
@ -87,7 +105,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
method: 'GET', method: 'GET',
path: /^\/_enig\/ap\/users\/.+\/followers(\?page=[0-9]+)?$/, path: /^\/_enig\/ap\/users\/.+\/followers(\?page=[0-9]+)?$/,
handler: (req, resp) => { handler: (req, resp) => {
return this._enforceSigningPolicy( return this._enforceMainKeySignatureValidity(
req, req,
resp, resp,
this._followersGetHandler.bind(this) this._followersGetHandler.bind(this)
@ -99,7 +117,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
method: 'GET', method: 'GET',
path: /^\/_enig\/ap\/users\/.+\/following(\?page=[0-9]+)?$/, path: /^\/_enig\/ap\/users\/.+\/following(\?page=[0-9]+)?$/,
handler: (req, resp) => { handler: (req, resp) => {
return this._enforceSigningPolicy( return this._enforceMainKeySignatureValidity(
req, req,
resp, resp,
this._followingGetHandler.bind(this) this._followingGetHandler.bind(this)
@ -124,7 +142,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
return cb(null); return cb(null);
} }
_enforceSigningPolicy(req, resp, next) { _enforceMainKeySignatureValidity(req, resp, next) {
// the request must be signed, and the signature must be valid // the request must be signed, and the signature must be valid
const signature = this._parseAndValidateSignature(req); const signature = this._parseAndValidateSignature(req);
if (!signature) { if (!signature) {
@ -134,6 +152,28 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
return next(req, resp, signature); return next(req, resp, signature);
} }
_parseAndValidateSignature(req) {
let signature;
try {
// :TODO: validate options passed to parseRequest()
signature = httpSignature.parseRequest(req);
} catch (e) {
this.log.warn(
{ error: e.message, url: req.url, method: req.method },
'Failed to parse HTTP signature'
);
return null;
}
// quick check up front
const keyId = signature.keyId;
if (!keyId || !keyId.endsWith('#main-key')) {
return null;
}
return signature;
}
_selfUrlRequestHandler(req, resp) { _selfUrlRequestHandler(req, resp) {
this.log.trace({ url: req.url }, 'Request for "self"'); this.log.trace({ url: req.url }, 'Request for "self"');
@ -172,14 +212,15 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
if (sendActor) { if (sendActor) {
return this._selfAsActorHandler(localUser, localActor, req, resp); return this._selfAsActorHandler(localUser, localActor, req, resp);
} else { } else {
return this._standardSelfHandler(localUser, localActor, req, resp); return this._selfAsProfileHandler(localUser, localActor, req, resp);
} }
}); });
}); });
} }
_inboxPostHandler(req, resp, signature) { _inboxPostHandler(req, resp, signature, inboxType) {
EnigAssert(signature, 'Called without signature!'); EnigAssert(signature, 'Called without signature!');
EnigAssert(signature.keyId, 'No keyId in signature!');
const body = []; const body = [];
req.on('data', d => { req.on('data', d => {
@ -187,54 +228,98 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
}); });
req.on('end', () => { req.on('end', () => {
// Collect and validate the posted Activity
const activity = Activity.fromJsonString(Buffer.concat(body).toString()); const activity = Activity.fromJsonString(Buffer.concat(body).toString());
if (!activity || !activity.isValid()) { if (!activity || !activity.isValid()) {
this.log.error( this.log.error(
{ url: req.url, method: req.method, endpoint: 'inbox' }, { url: req.url, method: req.method, inboxType },
'Invalid or unsupported Activity' 'Invalid or unsupported Activity'
); );
return activity return activity
? this.webServer.badRequest(resp) ? this.webServer.badRequest(resp)
: this.webServer.notImplemented(resp); : this.webServer.notImplemented(resp);
} }
//
// Delete is a special beast:
// We will *likely* get a 410, 404, or a Tombstone when fetching the Actor
// Thus, we need some short circuiting
//
if (WellKnownActivity.Delete === activity.type) {
return this._inboxDeleteActivity(inboxType, signature, resp, activity);
}
// Fetch and validate the signature of the remote Actor
Actor.fromId(activity.actor, (err, remoteActor) => {
if (err) {
return this.webServer.internalServerError(resp, err);
}
if (!this._validateActorSignature(remoteActor, signature)) {
return this.webServer.accessDenied(resp);
}
switch (activity.type) { switch (activity.type) {
case 'Follow': case WellKnownActivity.Accept:
return this._collectionRequestHandler( break;
signature,
'inbox',
activity,
this._inboxFollowRequestHandler.bind(this),
req,
resp
);
case 'Delete': case WellKnownActivity.Add:
return this._collectionRequestHandler( break;
signature,
'inbox',
activity,
this._inboxDeleteRequestHandler.bind(this),
req,
resp
);
case 'Update': case WellKnownActivity.Create:
return this.inboxUpdateObject('inbox', req, resp, activity); return this._inboxCreateActivity(resp, activity);
case 'Undo': case WellKnownActivity.Update:
return this._collectionRequestHandler( {
// Only Notes currently supported
const type = _.get(activity, 'object.type');
if ('Note' === type) {
return this._inboxMutateExistingObject(
inboxType,
signature, signature,
'inbox', resp,
activity, activity,
this._inboxUndoRequestHandler.bind(this), this._inboxUpdateObjectMutator.bind(this)
req,
resp
); );
} else {
this.log.warn(
`Unsupported Inbox Update for type "${type}"`
);
}
}
break;
case WellKnownActivity.Follow:
// Follow requests are only allowed directly
if ('inbox' === inboxType) {
return this._inboxFollowActivity(resp, remoteActor, activity);
}
break;
case WellKnownActivity.Reject:
break;
case WellKnownActivity.Undo:
// We only Undo from private inboxes
if ('inbox' === inboxType) {
// Only Follow Undo's currently supported
const type = _.get(activity, 'object.type');
if (WellKnownActivity.Follow === type) {
return this._inboxUndoActivity(
resp,
remoteActor,
activity
);
} else {
this.log.warn(`Unsupported Undo for type "${type}`);
}
}
break;
default: default:
this.log.warn( this.log.warn(
{ type: activity.type }, { type: activity.type, inboxType },
`Unsupported Activity type "${activity.type}"` `Unsupported Activity type "${activity.type}"`
); );
break; break;
@ -242,52 +327,14 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
return this.webServer.notImplemented(resp); return this.webServer.notImplemented(resp);
}); });
}
_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 || !activity.isValid()) {
this.log.error(
{ url: req.url, method: req.method, endpoint: 'sharedInbox' },
'Invalid or unsupported Activity'
);
return activity
? this.webServer.badRequest(resp)
: this.webServer.notImplemented(resp);
}
switch (activity.type) {
case 'Create':
return this._sharedInboxCreateActivity(req, resp, activity);
case 'Update':
return this.inboxUpdateObject('sharedInbox', req, resp, activity);
default:
this.log.warn(
{ type: activity.type },
'Invalid or unknown Activity type'
);
break;
}
// don't understand the 'type'
return this.webServer.notImplemented(resp);
}); });
} }
_sharedInboxCreateActivity(req, resp, activity) { _inboxCreateActivity(resp, activity) {
const deliverTo = activity.recipientIds();
const createWhat = _.get(activity, 'object.type'); const createWhat = _.get(activity, 'object.type');
switch (createWhat) { switch (createWhat) {
case 'Note': case 'Note':
return this._deliverSharedInboxNote(req, resp, deliverTo, activity); return this._inboxCreateNoteActivity(resp, activity);
default: default:
this.log.warn( this.log.warn(
@ -298,120 +345,28 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
} }
} }
inboxUpdateObject(inboxType, req, resp, activity) { _inboxCreateNoteActivity(resp, activity) {
const updateObjectId = _.get(activity, 'object.id');
const objectType = _.get(activity, 'object.type');
this.log.info(
{ inboxType, objectId: updateObjectId, type: objectType },
'Inbox Object "Update" request'
);
// :TODO: other types...
if (!updateObjectId || !['Note'].includes(objectType)) {
return this.webServer.notImplemented(resp);
}
// Note's are wrapped in Create Activities
Collection.objectByEmbeddedId(updateObjectId, (err, obj) => {
if (err) {
return this.webServer.internalServerError(resp, err);
}
if (!obj) {
// no match, but respond as accepted and hopefully they don't ask again
return this.webServer.accepted(resp);
}
// OK, the object exists; Does the caller have permission
// to update? The origin must match
//
// "The receiving server MUST take care to be sure that the Update is authorized
// to modify its object. At minimum, this may be done by ensuring that the Update
// and its object are of same origin."
try {
const updateTargetUrl = new URL(obj.object.id);
const updaterUrl = new URL(activity.actor);
if (updateTargetUrl.host !== updaterUrl.host) {
this.log.warn(
{
objectId: updateObjectId,
type: objectType,
updateTargetHost: updateTargetUrl.host,
requestorHost: updaterUrl.host,
},
'Attempt to update object from another origin'
);
return this.webServer.accessDenied(resp);
}
Collection.updateCollectionEntry(
'inbox',
updateObjectId,
activity,
err => {
if (err) {
return this.webServer.internalServerError(resp, err);
}
this.log.info(
{
objectId: updateObjectId,
type: objectType,
collection: 'inbox',
},
'Object updated'
);
return this.webServer.accepted(resp);
}
);
} catch (e) {
return this.webServer.internalServerError(resp, e);
}
});
}
_deliverSharedInboxNote(req, resp, deliverTo, 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.
const note = new Note(activity.object); const note = new Note(activity.object);
if (!note.isValid()) { if (!note.isValid()) {
// :TODO: Log me this.log.warn({ note }, 'Invalid Note');
return this.webServer.notImplemented(); return this.webServer.notImplemented();
} }
const recipientActorIds = note.recipientIds();
async.forEach( async.forEach(
deliverTo, recipientActorIds,
(actorId, nextActor) => { (actorId, nextActorId) => {
switch (actorId) { switch (actorId) {
case Collection.PublicCollectionId: case Collection.PublicCollectionId:
this._deliverInboxNoteToSharedInbox( this._deliverNoteToSharedInbox(activity, note, err => {
req, return nextActorId(err);
resp, });
activity,
note,
err => {
return nextActor(err);
}
);
break; break;
default: default:
this._deliverInboxNoteToLocalActor( this._deliverNoteToLocalActor(actorId, activity, note, err => {
req, return nextActorId(err);
resp, });
actorId,
activity,
note,
err => {
return nextActor(err);
}
);
break; break;
} }
}, },
@ -425,7 +380,217 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
); );
} }
_deliverInboxNoteToSharedInbox(req, resp, activity, note, cb) { _inboxDeleteActivity(inboxType, signature, resp /*, activity*/) {
// :TODO: Implement me!
// :TODO: we need to DELETE the existing stored Message object if this is a Note, or associated if this is an Actor
// :TODO: delete / invalidate any actor cache if actor
return this.webServer.accepted(resp);
}
_inboxFollowActivity(resp, remoteActor, activity) {
this.log.info(
{ remoteActorId: remoteActor.id, localActorId: activity.object },
'Incoming Follow Activity'
);
userFromActorId(activity.object, (err, localUser) => {
if (err) {
return this.webServer.resourceNotFound(resp);
}
// User accepts any followers automatically
const activityPubSettings = ActivityPubSettings.fromUser(localUser);
if (!activityPubSettings.manuallyApproveFollowers) {
this._recordAcceptedFollowRequest(localUser, remoteActor, activity);
return this.webServer.ok(resp);
}
// User manually approves requests; add them to their requests collection
Collection.addFollowRequest(
localUser,
remoteActor,
this.webServer,
true, // ignore dupes
err => {
if (err) {
return this.internalServerError(resp, err);
}
return this.webServer.ok(resp);
}
);
});
}
_inboxUndoActivity(resp, remoteActor, activity) {
const localActorId = _.get(activity, 'object.object');
this.log.info(
{ remoteActorId: remoteActor.id, localActorId },
'Incoming Undo Activity'
);
userFromActorId(localActorId, (err, localUser) => {
if (err) {
return this.webServer.resourceNotFound(resp);
}
Collection.removeOwnedById('followers', localUser, remoteActor.id, err => {
if (err) {
return this.webServer.internalServerError(resp, err);
}
this.log.info(
{
username: localUser.username,
userId: localUser.userId,
remoteActorId: remoteActor.id,
},
'Undo "Follow" (un-follow) success'
);
return this.webServer.accepted(resp);
});
});
}
_localUserFromCollectionEndpoint(req, collectionName, cb) {
// turn a collection URL to a Actor ID
let actorId = this.webServer.fullUrl(req).toString();
const suffix = `/${collectionName}`;
if (actorId.endsWith(suffix)) {
actorId = actorId.slice(0, -suffix.length);
}
userFromActorId(actorId, (err, localUser) => {
return cb(err, localUser);
});
}
_validateActorSignature(actor, signature) {
const pubKey = actor.publicKey;
if (!_.isObject(pubKey)) {
this.log.debug('Expected object of "pubKey"');
return false;
}
if (signature.keyId !== pubKey.id) {
this.log.warn(
{
actorId: actor.id,
signatureKeyId: signature.keyId,
actorPubKeyId: pubKey.id,
},
'Key ID mismatch'
);
return false;
}
if (!httpSignature.verifySignature(signature, pubKey.publicKeyPem)) {
this.log.warn(
{
actorId: actor.id,
keyId: signature.keyId,
},
'Actor signature verification failed'
);
return false;
}
return true;
}
_inboxMutateExistingObject(inboxType, signature, resp, activity, mutator) {
const targetObjectId = _.get(activity, 'object.id');
const objectType = _.get(activity, 'object.type');
Collection.objectByEmbeddedId(targetObjectId, (err, obj) => {
if (err) {
return this.webServer.internalServerError(resp, err);
}
if (!obj) {
this.log.warn(
{ targetObjectId, type: objectType, activityType: activity.type },
`Could not ${activity.type} Object; Not found`
);
return this.webServer.resourceNotFound(resp);
}
//
// Object exists; Validate we allow the action by origin
// comparing the request's keyId origin to the object's
//
try {
const updateTargetHost = new URL(obj.object.id).host;
const keyIdHost = new URL(signature.keyId).host;
if (updateTargetHost !== keyIdHost) {
this.log.warn(
{
targetObjectId,
type: objectType,
updateTargetHost,
keyIdHost,
activityType: activity.type,
},
`Attempt to ${activity.type} Object of non-matching origin`
);
return this.webServer.accessDenied(resp);
}
return mutator(inboxType, resp, objectType, targetObjectId, activity);
} catch (e) {
return this.webServer.internalServerError(resp, e);
}
});
}
// _inboxDeleteActivityMutator(inboxType, resp, objectType, targetObjectId) {
// Collection.removeById(inboxType, targetObjectId, err => {
// if (err) {
// return this.webServer.internalServerError(resp, err);
// }
// this.log.info(
// {
// inboxType,
// objectId: targetObjectId,
// objectType,
// },
// `${objectType} Deleted`
// );
// // :TODO: we need to DELETE the existing stored Message object if this is a Note
// return this.webServer.accepted(resp);
// });
// }
_inboxUpdateObjectMutator(inboxType, resp, objectType, targetObjectId, activity) {
Collection.updateCollectionEntry(inboxType, targetObjectId, activity, err => {
if (err) {
return this.webServer.internalServerError(resp, err);
}
this.log.info(
{
inboxType,
objectId: targetObjectId,
objectType,
},
`${objectType} Updated`
);
// :TODO: we need to UPDATE the existing stored Message object if this is a Note
return this.webServer.accepted(resp);
});
}
_deliverNoteToSharedInbox(activity, note, cb) {
this.log.info({ noteId: note.id }, 'Delivering Note to Public inbox');
Collection.addSharedInboxItem(activity, true, err => { Collection.addSharedInboxItem(activity, true, err => {
if (err) { if (err) {
return cb(err); return cb(err);
@ -441,6 +606,33 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
}); });
} }
_deliverNoteToLocalActor(actorId, activity, note, cb) {
this.log.info(
{ noteId: note.id, actorId },
'Delivering Note to local Actor Private inbox'
);
userFromActorId(actorId, (err, localUser) => {
if (err) {
return cb(null); // not found/etc., just bail
}
Collection.addInboxItem(activity, localUser, this.webServer, false, err => {
if (err) {
return cb(err);
}
return this._storeNoteAsMessage(
activity.id,
localUser,
Message.WellKnownAreaTags.Private,
note,
cb
);
});
});
}
_storeNoteAsMessage(activityId, localAddressedTo, areaTag, note, cb) { _storeNoteAsMessage(activityId, localAddressedTo, areaTag, note, cb) {
// //
// Import the item to the user's private mailbox // Import the item to the user's private mailbox
@ -478,31 +670,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
}); });
} }
_deliverInboxNoteToLocalActor(req, resp, actorId, activity, note, cb) { _actorCollectionRequest(collectionName, req, resp) {
userFromActorId(actorId, (err, localUser) => {
if (err) {
return cb(null); // not found/etc., just bail
}
Collection.addInboxItem(activity, localUser, this.webServer, false, err => {
if (err) {
return cb(err);
}
return this._storeNoteAsMessage(
activity.id,
localUser,
Message.WellKnownAreaTags.Private,
note,
cb
);
});
});
}
_getCollectionHandler(collectionName, req, resp, signature) {
EnigAssert(signature, 'Missing signature!');
const getCollection = Collection[collectionName]; const getCollection = Collection[collectionName];
if (!getCollection) { if (!getCollection) {
return this.webServer.resourceNotFound(resp); return this.webServer.resourceNotFound(resp);
@ -511,6 +679,22 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
const url = this.webServer.fullUrl(req); const url = this.webServer.fullUrl(req);
const page = url.searchParams.get('page'); const page = url.searchParams.get('page');
const collectionId = url.toString(); const collectionId = url.toString();
this._localUserFromCollectionEndpoint(req, collectionName, (err, localUser) => {
if (err) {
return this.webServer.resourceNotFound(resp);
}
const apSettings = ActivityPubSettings.fromUser(localUser);
if (apSettings.hideSocialGraph) {
this.log.info(
{ user: localUser.username },
`User has ${collectionName} hidden`
);
return this.webServer.accessDenied(resp);
}
// :TODO: these getters should take a owningUser; they are not strictly public
getCollection(collectionId, page, (err, collection) => { getCollection(collectionId, page, (err, collection) => {
if (err) { if (err) {
return this.webServer.internalServerError(resp, err); return this.webServer.internalServerError(resp, err);
@ -521,22 +705,25 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
'Content-Type': ActivityStreamMediaType, 'Content-Type': ActivityStreamMediaType,
}); });
}); });
});
// :TODO: we need to validate the local user allows access to the particular collection
} }
_followingGetHandler(req, resp, signature) { _followingGetHandler(req, resp) {
this.log.debug({ url: req.url }, 'Request for "following"'); this.log.debug({ url: req.url }, 'Request for "following"');
return this._getCollectionHandler('following', req, resp, signature); return this._actorCollectionRequest('following', req, resp);
} }
_followersGetHandler(req, resp, signature) { _followersGetHandler(req, resp) {
this.log.debug({ url: req.url }, 'Request for "followers"'); this.log.debug({ url: req.url }, 'Request for "followers"');
return this._getCollectionHandler('followers', req, resp, signature); return this._actorCollectionRequest('followers', req, resp);
} }
// https://docs.gotosocial.org/en/latest/federation/behaviors/outbox/ // https://docs.gotosocial.org/en/latest/federation/behaviors/outbox/
_outboxGetHandler(req, resp, signature) { _outboxGetHandler(req, resp) {
this.log.debug({ url: req.url }, 'Request for "outbox"'); this.log.debug({ url: req.url }, 'Request for "outbox"');
return this._getCollectionHandler('outbox', req, resp, signature); return this._actorCollectionRequest('outbox', req, resp);
} }
_singlePublicNoteGetHandler(req, resp) { _singlePublicNoteGetHandler(req, resp) {
@ -558,173 +745,6 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
}); });
} }
_accountNameFromUserPath(url, suffix) {
const re = new RegExp(`^/_enig/ap/users/(.+)/${suffix}(\\?page=[0-9]+)?$`);
const m = url.pathname.match(re);
if (!m || !m[1]) {
return null;
}
return m[1];
}
_parseAndValidateSignature(req) {
let signature;
try {
// :TODO: validate options passed to parseRequest()
signature = httpSignature.parseRequest(req);
} catch (e) {
this.log.warn(
{ error: e.message, url: req.url, method: req.method },
'Failed to parse HTTP signature'
);
return null;
}
// quick check up front
const keyId = signature.keyId;
if (!this._validateKeyId(keyId)) {
return null;
}
return signature;
}
_validateKeyId(keyId) {
if (!keyId) {
return false;
}
// we only accept main-key currently
return keyId.endsWith('#main-key');
}
_inboxFollowRequestHandler(activity, remoteActor, localUser, resp) {
this.log.info(
{ user_id: localUser.userId, actor: activity.actor },
'Follow request'
);
//
// If the user blindly accepts Followers, we can persist
// and send an 'Accept' now. Otherwise, we need to queue this
// request for the user to review and decide what to do with
// at a later time.
//
const activityPubSettings = ActivityPubSettings.fromUser(localUser);
if (!activityPubSettings.manuallyApproveFollowers) {
this._recordAcceptedFollowRequest(localUser, remoteActor, activity);
return this.webServer.ok(resp);
} else {
Collection.addFollowRequest(
localUser,
remoteActor,
this.webServer,
true, // ignore dupes
err => {
if (err) {
return this.internalServerError(resp, err);
}
return this.webServer.ok(resp);
}
);
}
}
// :TODO: DRY: update/delete are mostly the same code other than the final operation
_inboxDeleteRequestHandler(activity, remoteActor, localUser, resp) {
this.log.info(
{ user_id: localUser.userId, actor: activity.actor },
'Delete request'
);
// :TODO:only delete if it's owned by the sender
return this.webServer.accepted(resp);
}
_inboxUndoRequestHandler(activity, remoteActor, localUser, resp) {
this.log.info(
{ user: localUser.username, actor: remoteActor.id },
'Undo Activity request'
);
// we only understand Follow right now
if (!activity.object || activity.object.type !== 'Follow') {
return this.webServer.notImplemented(resp);
}
Collection.removeOwnedById('followers', localUser, remoteActor.id, err => {
if (err) {
return this.webServer.internalServerError(resp, err);
}
this.log.info(
{
username: localUser.username,
userId: localUser.userId,
actor: remoteActor.id,
},
'Undo "Follow" (un-follow) success'
);
return this.webServer.accepted(resp);
});
}
_collectionRequestHandler(
signature,
collectionName,
activity,
activityHandler,
req,
resp
) {
// turn a collection URL to a Actor ID
let actorId = this.webServer.fullUrl(req).toString();
const suffix = `/${collectionName}`;
if (actorId.endsWith(suffix)) {
actorId = actorId.slice(0, -suffix.length);
}
userFromActorId(actorId, (err, localUser) => {
if (err) {
return this.webServer.resourceNotFound(resp);
}
Actor.fromId(activity.actor, (err, actor) => {
if (err) {
return this.webServer.internalServerError(resp, err);
}
const pubKey = actor.publicKey;
if (!_.isObject(pubKey)) {
// Log me
return this.webServer.accessDenied();
}
if (signature.keyId !== pubKey.id) {
// :TODO: Log me
return this.webServer.accessDenied(resp);
}
if (!httpSignature.verifySignature(signature, pubKey.publicKeyPem)) {
this.log.warn(
{
actor: activity.actor,
keyId: signature.keyId,
signature: req.headers['signature'] || '',
},
'Invalid signature supplied for Follow request'
);
return this.webServer.accessDenied(resp);
}
return activityHandler(activity, actor, localUser, resp);
});
});
}
_recordAcceptedFollowRequest(localUser, remoteActor, requestActivity) { _recordAcceptedFollowRequest(localUser, remoteActor, requestActivity) {
async.series( async.series(
[ [
@ -803,11 +823,6 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
); );
} }
_authorizeInteractionHandler(req, resp) {
console.log(req);
console.log(resp);
}
_selfAsActorHandler(localUser, localActor, req, resp) { _selfAsActorHandler(localUser, localActor, req, resp) {
this.log.info( this.log.info(
{ username: localUser.username }, { username: localUser.username },
@ -819,7 +834,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
return this.webServer.ok(resp, body, { 'Content-Type': ActivityStreamMediaType }); return this.webServer.ok(resp, body, { 'Content-Type': ActivityStreamMediaType });
} }
_standardSelfHandler(localUser, localActor, req, resp) { _selfAsProfileHandler(localUser, localActor, req, resp) {
let templateFile = _.get( let templateFile = _.get(
Config(), Config(),
'contentServers.web.handlers.activityPub.selfTemplate' 'contentServers.web.handlers.activityPub.selfTemplate'