Start work on Activity and Actor objects, validation, fetching, etc.

This commit is contained in:
Bryan Ashby 2023-01-08 13:18:50 -07:00
parent 416f86a0cc
commit f86d9338a1
No known key found for this signature in database
GPG Key ID: C2C1B501E4EFD994
6 changed files with 177 additions and 42 deletions

View File

@ -0,0 +1,44 @@
const { isString, isObject } = require('lodash');
module.exports = class Activity {
constructor(obj) {
Object.assign(this, obj);
}
static get ActivityTypes() {
return [
'Create',
'Update',
'Delete',
'Follow',
'Accept',
'Reject',
'Add',
'Remove',
'Like',
'Announce',
'Undo',
];
}
static fromJson(json) {
const parsed = JSON.parse(json);
return new Activity(parsed);
}
isValid() {
if (
this['@context'] !== 'https://www.w3.org/ns/activitystreams' ||
!isString(this.id) ||
!isString(this.actor) ||
(!isString(this.object) && !isObject(this.object)) ||
!Activity.ActivityTypes.includes(this.type)
) {
return false;
}
// :TODO: we could validate the particular types
return true;
}
};

View File

@ -5,28 +5,111 @@
const actorDb = require('./database.js').dbs.actor; const actorDb = require('./database.js').dbs.actor;
const { Errors } = require('./enig_error.js'); const { Errors } = require('./enig_error.js');
const Events = require('./events.js'); const Events = require('./events.js');
const ActorProps = require('./activitypub_actor_property.js'); const { ActorProps } = require('./activitypub_actor_property');
const { isValidLink } = require('./activitypub_util');
// deps // deps
const assert = require('assert'); const assert = require('assert');
const async = require('async'); const async = require('async');
const _ = require('lodash'); const _ = require('lodash');
const https = require('https');
const isString = require('lodash/isString');
// https://www.w3.org/TR/activitypub/#actor-objects
module.exports = class Actor { module.exports = class Actor {
constructor() { constructor(obj) {
if (obj) {
Object.assign(this, obj);
} else {
this['@context'] = ['https://www.w3.org/ns/activitystreams'];
this.id = '';
this.type = '';
this.inbox = '';
this.outbox = '';
this.following = '';
this.followers = '';
this.liked = '';
}
this.actorId = 0; this.actorId = 0;
this.actorUrl = ''; this.actorUrl = '';
this.properties = {}; // name:value this.properties = {}; // name:value
this.groups = []; // group membership(s) this.groups = []; // group membership(s)
} }
isValid() {
if (
!Array.isArray(this['@context']) ||
this['@context'][0] !== 'https://www.w3.org/ns/activitystreams'
) {
return false;
}
if (!isString(this.type) || this.type.length < 1) {
return false;
}
const linksValid = ['inbox', 'outbox', 'following', 'followers'].every(p => {
return isValidLink(this[p]);
});
if (!linksValid) {
return false;
}
return true;
}
static getRemoteActor(url, cb) {
const headers = {
Accept: 'application/activity+json',
};
https.get(url, { headers }, res => {
if (res.statusCode !== 200) {
return cb(Errors.Invalid(`Bad HTTP status code: ${req.statusCode}`));
}
const contentType = res.headers['content-type'];
if (
!_.isString(contentType) ||
!contentType.startsWith('application/activity+json')
) {
return cb(Errors.Invalid(`Invalid Content-Type: ${contentType}`));
}
res.setEncoding('utf8');
let body = '';
res.on('data', data => {
body += data;
});
res.on('end', () => {
let actor;
try {
actor = Actor.fromJson(body);
} catch (e) {
return cb(e);
}
if (!actor.isValid()) {
return cb(Errors.Invalid('Invalid Actor'));
}
return cb(null, actor);
});
});
}
static fromJson(json) {
const parsed = JSON.parse(json);
return new Actor(parsed);
}
create(cb) { create(cb) {
assert(0 === this.actorId); assert(0 === this.actorId);
if ( if (_.isEmpty(this.actorUrl)) {
_.isEmpty(this.actorUrl)
) {
return cb(Errors.Invalid('Blank actor url')); return cb(Errors.Invalid('Blank actor url'));
} }
@ -135,14 +218,14 @@ module.exports = class Actor {
} }
persistProperty(propName, propValue, cb) { persistProperty(propName, propValue, cb) {
// update live props // update live props
this.properties[propName] = propValue; this.properties[propName] = propValue;
return Actor.persistPropertyByActorId(this.actorId, propName, propValue, cb); return Actor.persistPropertyByActorId(this.actorId, propName, propValue, cb);
} }
removeProperty(propName, cb) { removeProperty(propName, cb) {
// update live // update live
delete this.properties[propName]; delete this.properties[propName];
actorDb.run( actorDb.run(
@ -206,7 +289,6 @@ module.exports = class Actor {
); );
} }
static getActor(actorId, cb) { static getActor(actorId, cb) {
async.waterfall( async.waterfall(
[ [
@ -243,7 +325,7 @@ module.exports = class Actor {
ActorProps.Summary, ActorProps.Summary,
ActorProps.IconUrl, ActorProps.IconUrl,
ActorProps.BannerUrl, ActorProps.BannerUrl,
ActorProps.PublicKeyMain ActorProps.PublicKeyMain,
]; ];
} }

View File

@ -7,7 +7,7 @@
// This IS NOT a full list. For example, custom modules // This IS NOT a full list. For example, custom modules
// can utilize their own properties as well! // can utilize their own properties as well!
// //
module.exports = { exports.ActorProps = {
Type: 'type', Type: 'type',
PreferredUsername: 'preferred_user_name', PreferredUsername: 'preferred_user_name',
Name: 'name', Name: 'name',
@ -16,3 +16,5 @@ module.exports = {
BannerUrl: 'banner_url', BannerUrl: 'banner_url',
PublicKeyMain: 'public_key_main_rsa_pem', // RSA public key for user PublicKeyMain: 'public_key_main_rsa_pem', // RSA public key for user
}; };
exports.AllActorProperties = Object.values(exports.ActorProps);

View File

@ -11,6 +11,7 @@ const fs = require('graceful-fs');
const paths = require('path'); const paths = require('path');
const moment = require('moment'); const moment = require('moment');
exports.isValidLink = isValidLink;
exports.makeUserUrl = makeUserUrl; exports.makeUserUrl = makeUserUrl;
exports.webFingerProfileUrl = webFingerProfileUrl; exports.webFingerProfileUrl = webFingerProfileUrl;
exports.selfUrl = selfUrl; exports.selfUrl = selfUrl;
@ -31,6 +32,10 @@ Affiliations: %AFFILIATIONS%
Achievement Points: %ACHIEVEMENT_POINTS% Achievement Points: %ACHIEVEMENT_POINTS%
`; `;
function isValidLink(l) {
return /^https?:\/\/.+$/.test(l);
}
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

@ -41,7 +41,7 @@ function getModDatabasePath(moduleInfo, suffix) {
// filename. An optional suffix may be supplied as well. // filename. An optional suffix may be supplied as well.
// //
const HOST_RE = const HOST_RE =
/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/; /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/;
assert(_.isObject(moduleInfo)); assert(_.isObject(moduleInfo));
assert(_.isString(moduleInfo.packageName), 'moduleInfo must define "packageName"!'); assert(_.isString(moduleInfo.packageName), 'moduleInfo must define "packageName"!');
@ -81,7 +81,7 @@ function getISOTimestampString(ts) {
function sanitizeString(s) { function sanitizeString(s) {
return s.replace(/[\0\x08\x09\x1a\n\r"'\\%]/g, c => { return s.replace(/[\0\x08\x09\x1a\n\r"'\\%]/g, c => {
// eslint-disable-line no-control-regex // eslint-disable-line no-control-regex
switch (c) { switch (c) {
case '\0': case '\0':
return '\\0'; return '\\0';
@ -97,7 +97,7 @@ function sanitizeString(s) {
return '\\r'; return '\\r';
case '"': case '"':
case '\'': case "'":
return `${c}${c}`; return `${c}${c}`;
case '\\': case '\\':

View File

@ -10,6 +10,7 @@ const {
} = require('../../../activitypub_util'); } = require('../../../activitypub_util');
const UserProps = require('../../../user_property'); const UserProps = require('../../../user_property');
const Config = require('../../../config').get; const Config = require('../../../config').get;
const Activity = require('../../../activitypub_activity');
// deps // deps
const _ = require('lodash'); const _ = require('lodash');
@ -17,6 +18,7 @@ const enigma_assert = require('../../../enigma_assert');
const httpSignature = require('http-signature'); const httpSignature = require('http-signature');
const https = require('https'); const https = require('https');
const { Errors } = require('../../../enig_error'); const { Errors } = require('../../../enig_error');
const Actor = require('../../../activitypub_actor');
exports.moduleInfo = { exports.moduleInfo = {
name: 'ActivityPub', name: 'ActivityPub',
@ -105,6 +107,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
return this.webServer.resourceNotFound(resp); return this.webServer.resourceNotFound(resp);
} }
// quick check up front
const keyId = signature.keyId; const keyId = signature.keyId;
if (!this._validateKeyId(keyId)) { if (!this._validateKeyId(keyId)) {
return this.webServer.resourceNotFound(resp); return this.webServer.resourceNotFound(resp);
@ -116,30 +119,37 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
}); });
req.on('end', () => { req.on('end', () => {
let activity;
try { try {
const activity = JSON.parse(body); activity = Activity.fromJson(body);
switch (activity.type) {
case 'Follow':
return this._inboxFollowRequestHandler(
keyId,
signature,
activity,
req,
resp
);
default:
this.log.debug(
{ type: activity.type },
`Unsupported Activity type "${activity.type}"`
);
return this.webServer.resourceNotFound(resp);
}
} catch (e) { } catch (e) {
this.log.error( this.log.error(
{ error: e.message, url: req.url, method: req.method }, { error: e.message, url: req.url, method: req.method },
'Failed to parse Activity' 'Failed to parse Activity'
); );
return this.webServer.resourceNotFound(resp);
}
if (!activity.isValid()) {
// :TODO: Log me
return this.webServer.webServer.badRequest(resp);
}
switch (activity.type) {
case 'Follow':
return this._inboxFollowRequestHandler(
signature,
activity,
req,
resp
);
default:
this.log.debug(
{ type: activity.type },
`Unsupported Activity type "${activity.type}"`
);
return this.webServer.resourceNotFound(resp);
} }
}); });
} }
@ -156,15 +166,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
} }
} }
_inboxFollowRequestHandler(keyId, signature, activity, req, resp) { _inboxFollowRequestHandler(signature, activity, req, resp) {
if (
activity['@context'] !== 'https://www.w3.org/ns/activitystreams' ||
!_.isString(activity.actor) ||
!_.isString(activity.object)
) {
return this.webServerbadRequest(resp);
}
const accountName = accountFromSelfUrl(activity.object); const accountName = accountFromSelfUrl(activity.object);
if (!accountName) { if (!accountName) {
return this.webServer.badRequest(resp); return this.webServer.badRequest(resp);
@ -175,7 +177,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
return this.webServer.resourceNotFound(resp); return this.webServer.resourceNotFound(resp);
} }
this._fetchActor(activity.actor, (err, actor) => { Actor.getRemoteActor(activity.actor, (err, actor) => {
if (err) { if (err) {
// :TODO: log, and probably should be inspecting |err| // :TODO: log, and probably should be inspecting |err|
return this.webServer.internalServerError(resp); return this.webServer.internalServerError(resp);
@ -187,7 +189,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
return this.webServer.accessDenied(); return this.webServer.accessDenied();
} }
if (keyId !== pubKey.id) { if (signature.keyId !== pubKey.id) {
// :TODO: Log me // :TODO: Log me
return this.webServer.accessDenied(resp); return this.webServer.accessDenied(resp);
} }