2023-01-07 20:48:12 +00:00
|
|
|
/* jslint node: true */
|
|
|
|
'use strict';
|
|
|
|
|
|
|
|
// ENiGMA½
|
2023-01-13 01:49:13 +00:00
|
|
|
const { Errors } = require('../enig_error.js');
|
|
|
|
const UserProps = require('../user_property');
|
2023-02-06 21:34:18 +00:00
|
|
|
const Endpoints = require('./endpoint');
|
2023-02-07 01:27:12 +00:00
|
|
|
const { userNameFromSubject, isValidLink } = require('./util');
|
2023-01-13 01:49:13 +00:00
|
|
|
const Log = require('../logger').log;
|
|
|
|
const { queryWebFinger } = require('../webfinger');
|
|
|
|
const EnigAssert = require('../enigma_assert');
|
2023-01-14 04:27:02 +00:00
|
|
|
const ActivityPubSettings = require('./settings');
|
2023-01-21 08:19:19 +00:00
|
|
|
const ActivityPubObject = require('./object');
|
2023-01-29 23:52:01 +00:00
|
|
|
const { ActivityStreamMediaType } = require('./const');
|
2023-01-23 21:45:56 +00:00
|
|
|
const apDb = require('../database').dbs.activitypub;
|
2023-02-09 00:19:12 +00:00
|
|
|
const Config = require('../config').get;
|
2023-02-10 22:51:50 +00:00
|
|
|
const parseFullName = require('parse-full-name').parseFullName;
|
2023-01-07 20:48:12 +00:00
|
|
|
|
|
|
|
// deps
|
|
|
|
const _ = require('lodash');
|
2023-01-14 04:27:02 +00:00
|
|
|
const mimeTypes = require('mime-types');
|
2023-01-21 05:15:59 +00:00
|
|
|
const { getJson } = require('../http_util.js');
|
2023-01-23 21:45:56 +00:00
|
|
|
const { getISOTimestampString } = require('../database.js');
|
2023-01-30 19:30:36 +00:00
|
|
|
const paths = require('path');
|
2023-01-26 01:41:47 +00:00
|
|
|
|
2023-02-09 00:19:12 +00:00
|
|
|
const ActorCacheMaxAgeDays = 125; // hasn't been used in >= 125 days, nuke it.
|
2023-02-10 22:51:50 +00:00
|
|
|
const MaxSearchResults = 10; // Maximum number of results to show for a search
|
2023-01-08 20:18:50 +00:00
|
|
|
|
2023-02-07 01:27:12 +00:00
|
|
|
// default context for Actor's
|
|
|
|
const DefaultContext = ActivityPubObject.makeContext(['https://w3id.org/security/v1'], {
|
|
|
|
toot: 'http://joinmastodon.org/ns#',
|
|
|
|
discoverable: 'toot:discoverable',
|
|
|
|
manuallyApprovesFollowers: 'as:manuallyApprovesFollowers',
|
|
|
|
});
|
|
|
|
|
2023-01-08 20:18:50 +00:00
|
|
|
// https://www.w3.org/TR/activitypub/#actor-objects
|
2023-01-21 08:19:19 +00:00
|
|
|
module.exports = class Actor extends ActivityPubObject {
|
2023-02-07 01:27:12 +00:00
|
|
|
constructor(obj, withContext = DefaultContext) {
|
|
|
|
super(obj, withContext);
|
2023-01-07 20:48:12 +00:00
|
|
|
}
|
|
|
|
|
2023-01-08 20:18:50 +00:00
|
|
|
isValid() {
|
2023-01-21 08:19:19 +00:00
|
|
|
if (!super.isValid()) {
|
2023-01-08 20:18:50 +00:00
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2023-01-22 01:51:54 +00:00
|
|
|
if (!Actor.WellKnownActorTypes.includes(this.type)) {
|
2023-01-08 20:18:50 +00:00
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2023-01-22 01:51:54 +00:00
|
|
|
const linksValid = Actor.WellKnownLinkTypes.every(l => {
|
|
|
|
// must be valid if present & non-empty
|
2023-01-21 08:19:19 +00:00
|
|
|
if (this[l] && !isValidLink(this[l])) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
return true;
|
2023-01-08 20:18:50 +00:00
|
|
|
});
|
2023-01-21 08:19:19 +00:00
|
|
|
|
2023-01-08 20:18:50 +00:00
|
|
|
if (!linksValid) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2023-01-22 01:51:54 +00:00
|
|
|
static get WellKnownActorTypes() {
|
|
|
|
return ['Person', 'Group', 'Organization', 'Service', 'Application'];
|
|
|
|
}
|
|
|
|
|
|
|
|
static get WellKnownLinkTypes() {
|
|
|
|
return ['inbox', 'outbox', 'following', 'followers'];
|
|
|
|
}
|
|
|
|
|
2023-01-08 20:42:52 +00:00
|
|
|
static fromLocalUser(user, webServer, cb) {
|
2023-01-28 18:55:31 +00:00
|
|
|
const userActorId = user.getProperty(UserProps.ActivityPubActorId);
|
|
|
|
if (!userActorId) {
|
|
|
|
return cb(
|
|
|
|
Errors.MissingProperty(
|
|
|
|
`User missing '${UserProps.ActivityPubActorId}' property`
|
|
|
|
)
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2023-01-14 04:27:02 +00:00
|
|
|
const userSettings = ActivityPubSettings.fromUser(user);
|
|
|
|
|
|
|
|
const addImage = (o, t) => {
|
|
|
|
const url = userSettings[t];
|
|
|
|
if (url) {
|
2023-01-30 19:30:36 +00:00
|
|
|
const fn = paths.basename(url);
|
|
|
|
const mt = mimeTypes.contentType(fn);
|
2023-01-14 04:27:02 +00:00
|
|
|
if (mt) {
|
|
|
|
o[t] = {
|
|
|
|
mediaType: mt,
|
|
|
|
type: 'Image',
|
|
|
|
url,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
};
|
2023-01-08 20:42:52 +00:00
|
|
|
|
|
|
|
const obj = {
|
2023-01-28 18:55:31 +00:00
|
|
|
id: userActorId,
|
2023-01-08 20:42:52 +00:00
|
|
|
type: 'Person',
|
|
|
|
preferredUsername: user.username,
|
2023-01-22 20:51:32 +00:00
|
|
|
name: userSettings.showRealName
|
|
|
|
? user.getSanitizedName('real')
|
|
|
|
: user.username,
|
2023-01-08 20:42:52 +00:00
|
|
|
endpoints: {
|
2023-02-06 21:34:18 +00:00
|
|
|
sharedInbox: Endpoints.sharedInbox(webServer),
|
2023-01-08 20:42:52 +00:00
|
|
|
},
|
2023-02-06 21:34:18 +00:00
|
|
|
inbox: Endpoints.inbox(webServer, user),
|
|
|
|
outbox: Endpoints.outbox(webServer, user),
|
|
|
|
followers: Endpoints.followers(webServer, user),
|
|
|
|
following: Endpoints.following(webServer, user),
|
2023-01-08 20:42:52 +00:00
|
|
|
summary: user.getProperty(UserProps.AutoSignature) || '',
|
2023-02-06 21:34:18 +00:00
|
|
|
url: Endpoints.profile(webServer, user),
|
2023-01-14 04:27:02 +00:00
|
|
|
manuallyApprovesFollowers: userSettings.manuallyApprovesFollowers,
|
|
|
|
discoverable: userSettings.discoverable,
|
2023-01-08 20:42:52 +00:00
|
|
|
// :TODO: we can start to define BBS related stuff with the community perhaps
|
|
|
|
// attachment: [
|
|
|
|
// {
|
|
|
|
// name: 'SomeNetwork Address',
|
|
|
|
// type: 'PropertyValue',
|
|
|
|
// value: 'Mateo@21:1/121',
|
|
|
|
// },
|
|
|
|
// ],
|
2023-02-05 17:42:30 +00:00
|
|
|
|
|
|
|
// :TODO: re-enable once a spec is defined; board should prob be a object with connection info, etc.
|
|
|
|
// bbsInfo: {
|
|
|
|
// boardName: Config().general.boardName,
|
|
|
|
// memberSince: user.getProperty(UserProps.AccountCreated),
|
|
|
|
// affiliations: user.getProperty(UserProps.Affiliations) || '',
|
|
|
|
// },
|
2023-01-08 20:42:52 +00:00
|
|
|
};
|
|
|
|
|
2023-01-22 03:57:22 +00:00
|
|
|
addImage(obj, 'icon');
|
|
|
|
addImage(obj, 'image');
|
2023-01-14 04:27:02 +00:00
|
|
|
|
2023-01-13 19:07:06 +00:00
|
|
|
const publicKeyPem = user.getProperty(UserProps.PublicActivityPubSigningKey);
|
2023-01-08 20:42:52 +00:00
|
|
|
if (!_.isEmpty(publicKeyPem)) {
|
|
|
|
obj.publicKey = {
|
2023-01-28 18:55:31 +00:00
|
|
|
id: userActorId + '#main-key',
|
|
|
|
owner: userActorId,
|
2023-01-08 20:42:52 +00:00
|
|
|
publicKeyPem,
|
|
|
|
};
|
2023-01-12 05:37:09 +00:00
|
|
|
|
|
|
|
EnigAssert(
|
2023-01-13 19:07:06 +00:00
|
|
|
!_.isEmpty(user.getProperty(UserProps.PrivateActivityPubSigningKey)),
|
2023-01-12 05:37:09 +00:00
|
|
|
'User has public key but no private key!'
|
|
|
|
);
|
2023-01-08 20:42:52 +00:00
|
|
|
} else {
|
|
|
|
Log.warn(
|
|
|
|
{ username: user.username },
|
2023-01-13 19:07:06 +00:00
|
|
|
`No public key (${UserProps.PublicActivityPubSigningKey}) for user "${user.username}"`
|
2023-01-08 20:42:52 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
return cb(null, new Actor(obj));
|
|
|
|
}
|
2023-01-08 20:26:52 +00:00
|
|
|
|
2023-01-26 01:41:47 +00:00
|
|
|
static fromId(id, cb) {
|
2023-02-09 00:19:12 +00:00
|
|
|
let delivered = false;
|
|
|
|
const callback = (e, a, s) => {
|
|
|
|
if (!delivered) {
|
|
|
|
delivered = true;
|
|
|
|
return cb(e, a, s);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2023-02-10 22:51:50 +00:00
|
|
|
Actor._fromCache(id, (err, actor, subject) => {
|
2023-01-26 01:41:47 +00:00
|
|
|
if (!err) {
|
|
|
|
// cache hit
|
2023-02-09 00:19:12 +00:00
|
|
|
callback(null, actor, subject);
|
2023-01-26 01:41:47 +00:00
|
|
|
}
|
2023-01-23 21:45:56 +00:00
|
|
|
|
2023-02-09 00:19:12 +00:00
|
|
|
// Cache miss or needs refreshed; Try to do so now
|
2023-01-26 01:41:47 +00:00
|
|
|
Actor._fromWebFinger(id, (err, actor, subject) => {
|
2023-01-27 01:18:25 +00:00
|
|
|
if (err) {
|
2023-02-09 00:19:12 +00:00
|
|
|
return callback(err);
|
2023-01-27 01:18:25 +00:00
|
|
|
}
|
|
|
|
|
2023-01-26 01:41:47 +00:00
|
|
|
if (subject) {
|
|
|
|
subject = `@${userNameFromSubject(subject)}`; // e.g. @Username@host.com
|
2023-01-27 01:18:25 +00:00
|
|
|
} else if (!_.isEmpty(actor)) {
|
2023-01-26 01:41:47 +00:00
|
|
|
subject = actor.id; // best we can do for now
|
2023-01-23 21:45:56 +00:00
|
|
|
}
|
|
|
|
|
2023-01-26 01:41:47 +00:00
|
|
|
// deliver result to caller
|
2023-02-09 00:19:12 +00:00
|
|
|
callback(err, actor, subject);
|
2023-01-26 01:41:47 +00:00
|
|
|
|
|
|
|
// cache our entry
|
|
|
|
if (actor) {
|
2023-02-10 22:51:50 +00:00
|
|
|
apDb.run(
|
|
|
|
'DELETE FROM actor_cache_search WHERE actor_id=?',
|
|
|
|
[id],
|
|
|
|
err => {
|
|
|
|
if (err) {
|
|
|
|
Log.warn(
|
|
|
|
{ err: err },
|
|
|
|
'Error deleting previous actor_cache_search'
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
);
|
|
|
|
|
2023-01-26 01:41:47 +00:00
|
|
|
apDb.run(
|
|
|
|
`REPLACE INTO actor_cache (actor_id, actor_json, subject, timestamp)
|
|
|
|
VALUES (?, ?, ?, ?);`,
|
|
|
|
[id, JSON.stringify(actor), subject, getISOTimestampString()],
|
|
|
|
err => {
|
|
|
|
if (err) {
|
2023-02-10 22:51:50 +00:00
|
|
|
Log.warn(
|
|
|
|
{ err: err },
|
|
|
|
'Error replacing into the actor cache'
|
|
|
|
);
|
2023-01-23 21:45:56 +00:00
|
|
|
}
|
2023-01-26 01:41:47 +00:00
|
|
|
}
|
|
|
|
);
|
2023-02-10 22:51:50 +00:00
|
|
|
|
|
|
|
const searchNames = this._parseSearchNames(actor);
|
|
|
|
if (searchNames.length > 0) {
|
|
|
|
const searchStatement = apDb.prepare(
|
|
|
|
'INSERT INTO actor_cache_search (actor_id, search_name) VALUES (?, ?)'
|
|
|
|
);
|
|
|
|
searchNames.forEach(name => {
|
|
|
|
searchStatement.run([id, name], err => {
|
|
|
|
if (err) {
|
|
|
|
Log.warn(
|
|
|
|
{ err: err },
|
|
|
|
'Error inserting into actor cache search'
|
|
|
|
);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
2023-01-26 01:41:47 +00:00
|
|
|
}
|
|
|
|
});
|
2023-01-23 21:45:56 +00:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2023-02-10 22:51:50 +00:00
|
|
|
static _parseSearchNames(actor) {
|
|
|
|
const searchNames = [];
|
|
|
|
|
|
|
|
if (!_.isEmpty(actor.preferredUsername)) {
|
|
|
|
searchNames.push(actor.preferredUsername);
|
|
|
|
}
|
|
|
|
const name = parseFullName(actor.name);
|
|
|
|
if (!_.isEmpty(name.first)) {
|
|
|
|
searchNames.push(name.first);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!_.isEmpty(name.last)) {
|
|
|
|
searchNames.push(name.last);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!_.isEmpty(name.nick)) {
|
|
|
|
searchNames.push(name.nick);
|
|
|
|
}
|
|
|
|
|
|
|
|
return searchNames;
|
|
|
|
}
|
|
|
|
|
2023-02-09 00:19:12 +00:00
|
|
|
static actorCacheMaintenanceTask(args, cb) {
|
|
|
|
const enabled = _.get(
|
|
|
|
Config(),
|
|
|
|
'contentServers.web.handlers.activityPub.enabled'
|
|
|
|
);
|
|
|
|
if (!enabled) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
apDb.run(
|
|
|
|
`DELETE FROM actor_cache
|
2023-02-10 22:51:50 +00:00
|
|
|
WHERE DATETIME(timestamp) < DATETIME("now", "-${ActorCacheMaxAgeDays} day");`,
|
2023-02-09 00:19:12 +00:00
|
|
|
err => {
|
|
|
|
if (err) {
|
2023-02-10 22:51:50 +00:00
|
|
|
Log.warn({ err: err }, 'Error clearing old cache entries.');
|
2023-02-09 00:19:12 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return cb(null); // always non-fatal
|
|
|
|
}
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2023-02-10 22:51:50 +00:00
|
|
|
static fromSearch(searchString, cb) {
|
|
|
|
// first try to find an exact match
|
|
|
|
this.fromId(searchString, (err, remoteActor) => {
|
|
|
|
if (!err) {
|
|
|
|
return cb(null, [remoteActor]);
|
|
|
|
} else {
|
|
|
|
Log.info({ err: err }, 'Not able to find based on id');
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
let returnRows = [];
|
|
|
|
// If not found, try searching database for known actors
|
|
|
|
apDb.each(
|
|
|
|
`SELECT actor_cache.actor_json
|
|
|
|
FROM actor_cache INNER JOIN actor_cache_search ON actor_cache.actor_id = actor_cache_search.actor_id
|
|
|
|
WHERE actor_cache_search.search_name like '%'||?||'%'
|
|
|
|
LIMIT ${MaxSearchResults}`,
|
|
|
|
(err, row) => {
|
|
|
|
if (err) {
|
|
|
|
Log.warn({ err: err }, 'error retrieving search results');
|
|
|
|
return cb(err, []);
|
|
|
|
}
|
|
|
|
this._fromJsonToActor(row.actor_json, (err, actor) => {
|
|
|
|
if (err) {
|
|
|
|
Log.warn({ err: err }, 'error converting search results');
|
|
|
|
return cb(err, []);
|
|
|
|
}
|
|
|
|
returnRows.push(actor);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
);
|
|
|
|
return cb(null, returnRows);
|
|
|
|
}
|
|
|
|
|
2023-01-26 01:41:47 +00:00
|
|
|
static _fromRemoteQuery(id, cb) {
|
2023-01-08 20:18:50 +00:00
|
|
|
const headers = {
|
2023-01-29 23:52:01 +00:00
|
|
|
Accept: ActivityStreamMediaType,
|
2023-01-08 20:18:50 +00:00
|
|
|
};
|
|
|
|
|
2023-01-23 21:45:56 +00:00
|
|
|
getJson(id, { headers }, (err, actor) => {
|
2023-01-21 05:15:59 +00:00
|
|
|
if (err) {
|
|
|
|
return cb(err);
|
2023-01-08 20:18:50 +00:00
|
|
|
}
|
|
|
|
|
2023-01-21 05:15:59 +00:00
|
|
|
actor = new Actor(actor);
|
2023-01-08 20:18:50 +00:00
|
|
|
|
2023-01-21 05:15:59 +00:00
|
|
|
if (!actor.isValid()) {
|
|
|
|
return cb(Errors.Invalid('Invalid Actor'));
|
|
|
|
}
|
2023-01-08 20:18:50 +00:00
|
|
|
|
2023-01-21 05:15:59 +00:00
|
|
|
return cb(null, actor);
|
2023-01-08 20:18:50 +00:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2023-02-09 00:19:12 +00:00
|
|
|
static _fromCache(actorIdOrSubject, cb) {
|
2023-01-23 21:45:56 +00:00
|
|
|
apDb.get(
|
2023-02-10 22:51:50 +00:00
|
|
|
`SELECT actor_json, subject
|
2023-01-23 21:45:56 +00:00
|
|
|
FROM actor_cache
|
2023-01-26 01:41:47 +00:00
|
|
|
WHERE actor_id = ? OR subject = ?
|
2023-02-10 22:51:50 +00:00
|
|
|
AND DATETIME(timestamp) > DATETIME("now", "-${ActorCacheMaxAgeDays} day")
|
2023-01-23 21:45:56 +00:00
|
|
|
LIMIT 1;`,
|
2023-02-09 00:19:12 +00:00
|
|
|
[actorIdOrSubject, actorIdOrSubject],
|
2023-01-23 21:45:56 +00:00
|
|
|
(err, row) => {
|
|
|
|
if (err) {
|
|
|
|
return cb(err);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!row) {
|
|
|
|
return cb(Errors.DoesNotExist());
|
|
|
|
}
|
|
|
|
|
2023-02-10 22:51:50 +00:00
|
|
|
this._fromJsonToActor(row.actor_json, (err, actor) => {
|
|
|
|
if (err) {
|
|
|
|
return cb(err);
|
|
|
|
}
|
|
|
|
const subject = row.subject || actor.id;
|
|
|
|
return cb(null, actor, subject);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
);
|
|
|
|
}
|
2023-01-26 01:41:47 +00:00
|
|
|
|
2023-02-10 22:51:50 +00:00
|
|
|
static _fromJsonToActor(actorJson, cb) {
|
|
|
|
const obj = ActivityPubObject.fromJsonString(actorJson);
|
|
|
|
if (!obj || !obj.isValid()) {
|
|
|
|
return cb(Errors.Invalid('Failed to create ActivityPub object'));
|
|
|
|
}
|
2023-01-23 21:45:56 +00:00
|
|
|
|
2023-02-10 22:51:50 +00:00
|
|
|
const actor = new Actor(obj);
|
|
|
|
if (!actor.isValid()) {
|
|
|
|
return cb(Errors.Invalid('Failed to create Actor object'));
|
|
|
|
}
|
2023-01-23 21:45:56 +00:00
|
|
|
|
2023-02-10 22:51:50 +00:00
|
|
|
return cb(null, actor);
|
2023-01-23 21:45:56 +00:00
|
|
|
}
|
|
|
|
|
2023-01-26 01:41:47 +00:00
|
|
|
static _fromWebFinger(actorQuery, cb) {
|
|
|
|
queryWebFinger(actorQuery, (err, res) => {
|
2023-01-12 05:37:09 +00:00
|
|
|
if (err) {
|
|
|
|
return cb(err);
|
|
|
|
}
|
|
|
|
|
|
|
|
// we need a link with 'application/activity+json'
|
|
|
|
const links = res.links;
|
|
|
|
if (!Array.isArray(links)) {
|
|
|
|
return cb(Errors.DoesNotExist('No "links" object in WebFinger response'));
|
|
|
|
}
|
|
|
|
|
|
|
|
const activityLink = links.find(l => {
|
2023-01-29 23:52:01 +00:00
|
|
|
return l.type === ActivityStreamMediaType && l.href?.length > 0;
|
2023-01-12 05:37:09 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
if (!activityLink) {
|
|
|
|
return cb(
|
|
|
|
Errors.DoesNotExist('No Activity link found in WebFinger response')
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
// we can now query the href value for an Actor
|
2023-01-26 01:41:47 +00:00
|
|
|
return Actor._fromRemoteQuery(activityLink.href, (err, actor) => {
|
|
|
|
return cb(err, actor, res.subject);
|
|
|
|
});
|
2023-01-12 05:37:09 +00:00
|
|
|
});
|
|
|
|
}
|
2023-01-07 20:48:12 +00:00
|
|
|
};
|