Start work on Activity and Actor objects, validation, fetching, etc.
This commit is contained in:
parent
416f86a0cc
commit
f86d9338a1
|
@ -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;
|
||||||
|
}
|
||||||
|
};
|
|
@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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}`
|
||||||
|
|
|
@ -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 '\\':
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue