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 { Errors } = require('./enig_error.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
const assert = require('assert');
const async = require('async');
const _ = require('lodash');
const https = require('https');
const isString = require('lodash/isString');
// https://www.w3.org/TR/activitypub/#actor-objects
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.actorUrl = '';
this.properties = {}; // name:value
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) {
assert(0 === this.actorId);
if (
_.isEmpty(this.actorUrl)
) {
if (_.isEmpty(this.actorUrl)) {
return cb(Errors.Invalid('Blank actor url'));
}
@ -135,14 +218,14 @@ module.exports = class Actor {
}
persistProperty(propName, propValue, cb) {
// update live props
// update live props
this.properties[propName] = propValue;
return Actor.persistPropertyByActorId(this.actorId, propName, propValue, cb);
}
removeProperty(propName, cb) {
// update live
// update live
delete this.properties[propName];
actorDb.run(
@ -206,7 +289,6 @@ module.exports = class Actor {
);
}
static getActor(actorId, cb) {
async.waterfall(
[
@ -243,7 +325,7 @@ module.exports = class Actor {
ActorProps.Summary,
ActorProps.IconUrl,
ActorProps.BannerUrl,
ActorProps.PublicKeyMain
ActorProps.PublicKeyMain,
];
}

View File

@ -7,7 +7,7 @@
// This IS NOT a full list. For example, custom modules
// can utilize their own properties as well!
//
module.exports = {
exports.ActorProps = {
Type: 'type',
PreferredUsername: 'preferred_user_name',
Name: 'name',
@ -16,3 +16,5 @@ module.exports = {
BannerUrl: 'banner_url',
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 moment = require('moment');
exports.isValidLink = isValidLink;
exports.makeUserUrl = makeUserUrl;
exports.webFingerProfileUrl = webFingerProfileUrl;
exports.selfUrl = selfUrl;
@ -31,6 +32,10 @@ Affiliations: %AFFILIATIONS%
Achievement Points: %ACHIEVEMENT_POINTS%
`;
function isValidLink(l) {
return /^https?:\/\/.+$/.test(l);
}
function makeUserUrl(webServer, user, relPrefix) {
return webServer.buildUrl(
WellKnownLocations.Internal + `${relPrefix}${user.username}`

View File

@ -41,7 +41,7 @@ function getModDatabasePath(moduleInfo, suffix) {
// filename. An optional suffix may be supplied as well.
//
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(_.isString(moduleInfo.packageName), 'moduleInfo must define "packageName"!');
@ -81,7 +81,7 @@ function getISOTimestampString(ts) {
function sanitizeString(s) {
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) {
case '\0':
return '\\0';
@ -97,7 +97,7 @@ function sanitizeString(s) {
return '\\r';
case '"':
case '\'':
case "'":
return `${c}${c}`;
case '\\':

View File

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