Initial 'Note' support for ActivitiyPub/Mastodon, reconition of @User@domain ActivityPub addresses, skeleton for ActivityPub scan/toss

This commit is contained in:
Bryan Ashby 2023-01-11 22:37:09 -07:00
parent ef118325ba
commit 64848b4675
No known key found for this signature in database
GPG Key ID: C2C1B501E4EFD994
12 changed files with 495 additions and 78 deletions

View File

@ -1,6 +1,20 @@
const { isString, isObject } = require('lodash');
const { v4: UUIDv4 } = require('uuid');
const { ActivityStreamsContext } = require('./activitypub_util');
const {
ActivityStreamsContext,
messageBodyToHtml,
selfUrl,
} = require('./activitypub_util');
const { Errors } = require('./enig_error');
const User = require('./user');
const Actor = require('./activitypub_actor');
const { getISOTimestampString } = require('./database');
const UserProps = require('./user_property');
const { postJson } = require('./http_util');
// deps
const async = require('async');
const _ = require('lodash');
module.exports = class Activity {
constructor(obj) {
@ -47,7 +61,7 @@ module.exports = class Activity {
// https://www.w3.org/TR/activitypub/#accept-activity-inbox
static makeAccept(webServer, localActor, followRequest, id = null) {
id = id || webServer.buildUrl(`/${UUIDv4()}`);
id = id || Activity._makeId(webServer, '/accept');
return new Activity({
type: 'Accept',
@ -56,7 +70,131 @@ module.exports = class Activity {
});
}
sendTo(actorUrl, cb) {
// :TODO: https send |this| to actorUrl
static noteFromLocalMessage(webServer, message, cb) {
const localUserId = message.getLocalFromUserId();
if (!localUserId) {
return cb(Errors.UnexpectedState('Invalid user ID for local user!'));
}
async.waterfall(
[
callback => {
return User.getUser(localUserId, callback);
},
(localUser, callback) => {
const remoteActorAccount = message.getRemoteToUser();
if (!remoteActorAccount) {
return callback(
Errors.UnexpectedState(
'Message does not contain a remote address'
)
);
}
const opts = {};
Actor.fromAccountName(
remoteActorAccount,
opts,
(err, remoteActor) => {
return callback(err, localUser, remoteActor);
}
);
},
(localUser, remoteActor, callback) => {
Actor.fromLocalUser(localUser, webServer, (err, localActor) => {
return callback(err, localUser, localActor, remoteActor);
});
},
(localUser, localActor, remoteActor, callback) => {
// we'll need the entire |activityId| as a linked reference later
const activityId = Activity._makeId(webServer, '/create');
// |remoteActor| is non-null if we fetchd it
//const to = message.isPrivate() ? remoteActor ? remoteActor.id : `${ActivityStreamsContext}#Public`;
// const obj = {
// '@context': ActivityStreamsContext,
// id: activityId,
// type: 'Create',
// to: [remoteActor.id],
// audience: ['as:Public'],
// actor: localActor.id,
// object: {
// id: Activity._makeId(webServer, '/note'),
// type: 'Note',
// attributedTo: localActor.id,
// to: [remoteActor.id],
// audience: ['as:Public'],
// content: messageBodyToHtml(message.message.trim()),
// },
// };
const obj = {
'@context': ActivityStreamsContext,
id: activityId,
type: 'Create',
actor: localActor.id,
object: {
id: Activity._makeId(webServer, '/note'),
type: 'Note',
published: getISOTimestampString(message.modTimestamp),
attributedTo: localActor.id,
// :TODO: inReplyto if this is a reply; we need this store in message meta.
// :TODO: we may want to turn this to a HTML fragment?
content: messageBodyToHtml(message.message.trim()),
},
};
// :TODO: this probably needs to change quite a bit based on "groups"
// :TODO: verify we need both 'to' fields: https://socialhub.activitypub.rocks/t/problems-posting-to-mastodon-inbox/801/4
if (message.isPrivate()) {
obj.to = remoteActor.id;
obj.object.to = remoteActor.id;
} else {
const publicInbox = `${ActivityStreamsContext}#Public`;
obj.to = publicInbox;
obj.object.to = publicInbox;
}
const activity = new Activity(obj);
return callback(null, activity, localUser, remoteActor);
},
],
(err, activity, fromUser, remoteActor) => {
return cb(err, { activity, fromUser, remoteActor });
}
);
}
sendTo(actorUrl, fromUser, webServer, cb) {
const privateKey = fromUser.getProperty(UserProps.PrivateKeyMain);
if (_.isEmpty(privateKey)) {
return cb(
Errors.MissingProperty(
`User "${fromUser.username}" is missing the '${UserProps.PrivateKeyMain}' property`
)
);
}
const reqOpts = {
headers: {
'Content-Type': 'application/activity+json',
},
sign: {
// :TODO: Make a helper for this
key: privateKey,
keyId: selfUrl(webServer, fromUser) + '#main-key',
authorizationHeaderName: 'Signature',
headers: ['(request-target)', 'host', 'date', 'digest', 'content-type'],
},
};
const activityJson = JSON.stringify(this);
return postJson(actorUrl, activityJson, reqOpts, cb);
}
static _makeId(webServer, prefix = '') {
return webServer.buildUrl(`${prefix}/${UUIDv4()}`);
}
};

View File

@ -4,7 +4,6 @@
// ENiGMA½
const actorDb = require('./database.js').dbs.actor;
const { Errors } = require('./enig_error.js');
const { ActorProps } = require('./activitypub_actor_property');
const UserProps = require('./user_property');
const {
webFingerProfileUrl,
@ -14,9 +13,10 @@ const {
ActivityStreamsContext,
} = require('./activitypub_util');
const Log = require('./logger').log;
const { queryWebFinger } = require('./webfinger');
const EnigAssert = require('./enigma_assert');
// deps
const assert = require('assert');
const async = require('async');
const _ = require('lodash');
const https = require('https');
@ -102,6 +102,11 @@ module.exports = class Actor {
owner: userSelfUrl,
publicKeyPem,
};
EnigAssert(
!_.isEmpty(user.getProperty(UserProps.PrivateKeyMain)),
'User has public key but no private key!'
);
} else {
Log.warn(
{ username: user.username },
@ -117,6 +122,8 @@ module.exports = class Actor {
Accept: 'application/activity+json',
};
// :TODO: use getJson()
https.get(url, { headers }, res => {
if (res.statusCode !== 200) {
return cb(Errors.Invalid(`Bad HTTP status code: ${req.statusCode}`));
@ -153,6 +160,35 @@ module.exports = class Actor {
});
}
static fromAccountName(actorName, options, cb) {
// :TODO: cache first -- do we have an Actor for this account already with a OK TTL?
queryWebFinger(actorName, (err, res) => {
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 => {
return l.type === 'application/activity+json' && l.href?.length > 0;
});
if (!activityLink) {
return cb(
Errors.DoesNotExist('No Activity link found in WebFinger response')
);
}
// we can now query the href value for an Actor
return Actor.fromRemoteUrl(activityLink.href, cb);
});
}
static fromJson(json) {
const parsed = JSON.parse(json);
return new Actor(parsed);

View File

@ -20,6 +20,7 @@ exports.selfUrl = selfUrl;
exports.userFromAccount = userFromAccount;
exports.accountFromSelfUrl = accountFromSelfUrl;
exports.getUserProfileTemplatedBody = getUserProfileTemplatedBody;
exports.messageBodyToHtml = messageBodyToHtml;
// :TODO: more info in default
// this profile template is the *default* for both WebFinger
@ -160,3 +161,12 @@ function getUserProfileTemplatedBody(
}
);
}
//
// Apply very basic HTML to a message following
// Mastodon's supported tags of 'p', 'br', 'a', and 'span':
// https://blog.joinmastodon.org/2018/06/how-to-implement-a-basic-activitypub-server/
//
function messageBodyToHtml(body) {
return `<p>${body.replace(/\r?\n/g, '<br>')}</p>`;
}

View File

@ -54,6 +54,8 @@ exports.Errors = {
HttpError: (reason, reasonCode) =>
new EnigError('HTTP error', -32013, reason, reasonCode),
Timeout: (reason, reasonCode) => new EnigError('Timeout', -32014, reason, reasonCode),
MissingProperty: (reason, reasonCode) =>
new EnigError('Missing property', -32014, reason, reasonCode),
};
exports.ErrorReasons = {

View File

@ -16,7 +16,11 @@ const { MessageAreaConfTempSwitcher } = require('./mod_mixins.js');
const { isAnsi, stripAnsiControlCodes, insert } = require('./string_util.js');
const { stripMciColorCodes, controlCodesToAnsi } = require('./color_codes.js');
const Config = require('./config.js').get;
const { getAddressedToInfo } = require('./mail_util.js');
const {
getAddressedToInfo,
setExternalAddressedToInfo,
copyExternalAddressedToInfo,
} = require('./mail_util.js');
const Events = require('./events.js');
const UserProps = require('./user_property.js');
const SysProps = require('./system_property.js');
@ -589,15 +593,9 @@ exports.FullScreenEditorModule =
self.replyToMessage &&
self.replyToMessage.isFromRemoteUser()
) {
self.message.setRemoteToUser(
self.replyToMessage.meta.System[
Message.SystemMetaNames.RemoteFromUser
]
);
self.message.setExternalFlavor(
self.replyToMessage.meta.System[
Message.SystemMetaNames.ExternalFlavor
]
copyExternalAddressedToInfo(
self.replyToMessage,
self.message
);
return callback(null);
}
@ -605,21 +603,16 @@ exports.FullScreenEditorModule =
//
// Detect if the user is attempting to send to a remote mail type that we support
//
// :TODO: how to plug in support without tying to various types here? isSupportedExteranlType() or such
const addressedToInfo = getAddressedToInfo(
self.message.toUserName
);
if (
addressedToInfo.name &&
Message.AddressFlavor.FTN === addressedToInfo.flavor
) {
self.message.setRemoteToUser(addressedToInfo.remote);
self.message.setExternalFlavor(addressedToInfo.flavor);
self.message.toUserName = addressedToInfo.name;
if (setExternalAddressedToInfo(addressedToInfo, self.message)) {
// setExternalAddressedToInfo() did what we need
return callback(null);
}
// we need to look it up
// Local user -- we need to look it up
User.getUserIdAndNameByLookup(
self.message.toUserName,
(err, toUserId) => {

View File

@ -1,18 +1,46 @@
const { Errors } = require('./enig_error.js');
// deps
const { isString, isObject } = require('lodash');
const { isString, isObject, truncate } = require('lodash');
const https = require('https');
const httpSignature = require('http-signature');
const crypto = require('crypto');
exports.getJson = getJson;
exports.postJson = postJson;
function getJson(url, options, cb) {
const defaultOptions = {
method: 'GET',
};
options = Object.assign({}, defaultOptions, options);
// :TODO: signing -- DRY this.
_makeRequest(url, options, (err, body, res) => {
if (err) {
return cb(err);
}
let parsed;
try {
parsed = JSON.parse(body);
} catch (e) {
return cb(e);
}
return cb(null, parsed, res);
});
}
function postJson(url, json, options, cb) {
if (!isString(json)) {
json = JSON.stringify(json);
}
// :TODO: DRY this method with _makeRequest()
const defaultOptions = {
method: 'POST',
body: json,
@ -32,6 +60,59 @@ function postJson(url, json, options, cb) {
options.headers['Content-Length'] = json.length;
const req = https.request(url, options, res => {
let body = [];
res.on('data', d => {
body.push(d);
});
res.on('end', () => {
body = Buffer.concat(body).toString();
if (res.statusCode < 200 || res.statusCode > 299) {
return cb(
Errors.HttpError(
`HTTP error ${res.statusCode}: ${truncate(body, { length: 128 })}`
),
body,
res
);
}
return cb(null, body, res);
});
});
if (isObject(options.sign)) {
try {
httpSignature.sign(req, options.sign);
} catch (e) {
req.destroy();
return cb(Errors.Invalid(`Invalid signing material: ${e}`));
}
}
req.on('error', err => {
return cb(err);
});
req.on('timeout', () => {
req.destroy();
return cb(Errors.Timeout('Timeout making HTTP request'));
});
req.write(json);
req.end();
}
function _makeRequest(url, options, cb) {
if (options.body && options?.sign?.headers?.includes('digest')) {
options.headers['Digest'] = `SHA-256=${crypto
.createHash('sha256')
.update(options.body)
.digest('base64')}`;
}
const req = https.request(url, options, res => {
let body = [];
res.on('data', d => {
@ -67,6 +148,5 @@ function postJson(url, json, options, cb) {
return cb(Errors.Timeout('Timeout making HTTP request'));
});
req.write(json);
req.end();
}

View File

@ -1,10 +1,14 @@
/* jslint node: true */
'use strict';
const EnigmaAssert = require('./enigma_assert.js');
const Address = require('./ftn_address.js');
const { AddressFlavor } = require('./message.js');
const Message = require('./message.js');
exports.getAddressedToInfo = getAddressedToInfo;
exports.setExternalAddressedToInfo = setExternalAddressedToInfo;
exports.copyExternalAddressedToInfo = copyExternalAddressedToInfo;
const EMAIL_REGEX =
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
@ -31,29 +35,29 @@ function getAddressedToInfo(input) {
if (firstAtPos < 0) {
let addr = Address.fromString(input);
if (Address.isValidAddress(addr)) {
return { flavor: Message.AddressFlavor.FTN, remote: input };
return { flavor: AddressFlavor.FTN, remote: input };
}
const lessThanPos = input.indexOf('<');
if (lessThanPos < 0) {
return { name: input, flavor: Message.AddressFlavor.Local };
return { name: input, flavor: AddressFlavor.Local };
}
const greaterThanPos = input.indexOf('>');
if (greaterThanPos < lessThanPos) {
return { name: input, flavor: Message.AddressFlavor.Local };
return { name: input, flavor: AddressFlavor.Local };
}
addr = Address.fromString(input.slice(lessThanPos + 1, greaterThanPos));
if (Address.isValidAddress(addr)) {
return {
name: input.slice(0, lessThanPos).trim(),
flavor: Message.AddressFlavor.FTN,
flavor: AddressFlavor.FTN,
remote: addr.toString(),
};
}
return { name: input, flavor: Message.AddressFlavor.Local };
return { name: input, flavor: AddressFlavor.Local };
}
if (firstAtPos === 0) {
@ -63,7 +67,7 @@ function getAddressedToInfo(input) {
if (m) {
return {
name: input.slice(1, secondAtPos),
flavor: Message.AddressFlavor.ActivityPub,
flavor: AddressFlavor.ActivityPub,
remote: input.slice(firstAtPos),
};
}
@ -78,36 +82,67 @@ function getAddressedToInfo(input) {
if (m) {
return {
name: input.slice(0, lessThanPos).trim(),
flavor: Message.AddressFlavor.Email,
flavor: AddressFlavor.Email,
remote: addr,
};
}
return { name: input, flavor: Message.AddressFlavor.Local };
return { name: input, flavor: AddressFlavor.Local };
}
let m = input.match(EMAIL_REGEX);
if (m) {
return {
name: input.slice(0, firstAtPos),
flavor: Message.AddressFlavor.Email,
flavor: AddressFlavor.Email,
remote: input,
};
}
let addr = Address.fromString(input); // 5D?
if (Address.isValidAddress(addr)) {
return { flavor: Message.AddressFlavor.FTN, remote: addr.toString() };
return { flavor: AddressFlavor.FTN, remote: addr.toString() };
}
addr = Address.fromString(input.slice(firstAtPos + 1).trim());
if (Address.isValidAddress(addr)) {
return {
name: input.slice(0, firstAtPos).trim(),
flavor: Message.AddressFlavor.FTN,
flavor: AddressFlavor.FTN,
remote: addr.toString(),
};
}
return { name: input, flavor: Message.AddressFlavor.Local };
return { name: input, flavor: AddressFlavor.Local };
}
/// returns true if it's an external address
function setExternalAddressedToInfo(addressInfo, message) {
const isValidAddressInfo = () => {
return addressInfo.name.length > 1 && addressInfo.remote.length > 1;
};
switch (addressInfo.flavor) {
case AddressFlavor.FTN:
case AddressFlavor.Email:
case AddressFlavor.QWK:
case AddressFlavor.NNTP:
case AddressFlavor.ActivityPub:
EnigmaAssert(isValidAddressInfo());
message.setRemoteToUser(addressInfo.remote);
message.setExternalFlavor(addressInfo.flavor);
message.toUserName = addressInfo.name;
return true;
default:
case AddressFlavor.Local:
return false;
}
}
function copyExternalAddressedToInfo(fromMessage, toMessage) {
const sm = Message.SystemMetaNames;
toMessage.setRemoteToUser(fromMessage.meta.System[sm.RemoteFromUser]);
toMessage.setExternalFlavor(fromMessage.meta.System[sm.ExternalFlavor]);
}

View File

@ -101,6 +101,10 @@ const QWKPropertyNames = {
InReplyToNum: 'qwk_in_reply_to_num', // note that we prefer the 'InReplyToMsgId' kludge if available
};
const ActivityPubPropertyNames = {
ActivityId: 'activitypub_activity_id', // Activity ID; FK to AP table entries
};
// :TODO: this is a ugly hack due to bad variable names - clean it up & just _.camelCase(k)!
const MESSAGE_ROW_MAP = {
reply_to_message_id: 'replyToMsgId',
@ -243,16 +247,29 @@ module.exports = class Message {
this.meta.System[Message.SystemMetaNames.LocalFromUserID] = userId;
}
getLocalFromUserId() {
let id = _.get(this, 'meta.System.local_from_user_id', 0);
return parseInt(id);
}
setRemoteToUser(remoteTo) {
this.meta.System = this.meta.System || {};
this.meta.System[Message.SystemMetaNames.RemoteToUser] = remoteTo;
}
getRemoteToUser() {
return _.get(this, 'meta.System.remote_to_user');
}
setExternalFlavor(flavor) {
this.meta.System = this.meta.System || {};
this.meta.System[Message.SystemMetaNames.ExternalFlavor] = flavor;
}
getAddressFlavor() {
return _.get(this, 'meta.System.external_flavor', Message.AddressFlavor.Local);
}
static createMessageUUID(areaTag, modTimestamp, subject, body) {
assert(_.isString(areaTag));
assert(_.isDate(modTimestamp) || moment.isMoment(modTimestamp));

View File

@ -0,0 +1,87 @@
const Activity = require('../activitypub_activity');
const Message = require('../message');
const { MessageScanTossModule } = require('../msg_scan_toss_module');
const { getServer } = require('../listening_server');
exports.moduleInfo = {
name: 'ActivityPub',
desc: 'Provides ActivityPub scanner/tosser integration',
author: 'NuSkooler',
};
exports.getModule = class ActivityPubScannerTosser extends MessageScanTossModule {
constructor() {
super();
}
startup(cb) {
return cb(null);
}
shutdown(cb) {
return cb(null);
}
record(message) {
if (!this._isEnabled()) {
return;
}
Activity.noteFromLocalMessage(this._webServer(), message, (err, noteData) => {
if (err) {
// :TODO: Log me
}
const { activity, fromUser, remoteActor } = noteData;
// - persist Activity
// - sendTo
// - update message properties:
// * exported
// * ActivityPub ID -> activity table
activity.sendTo(
remoteActor.inbox,
fromUser,
this._webServer(),
(err, respBody, res) => {
if (err) {
}
}
);
});
}
_isEnabled() {
// :TODO: check config to see if AP integration is enabled/etc.
return this._webServer();
}
_shouldExportMessage(message) {
//
// - Private messages: Must be ActivityPub flavor
// - Public messages: Must be in area mapped for ActivityPub import/export
//
// :TODO: Implement the mapping
if (
Message.AddressFlavor.ActivityPub === message.getAddressFlavor() &&
Message.isPrivateAreaTag()
) {
return true;
}
return false;
}
_exportToActivityPub(message, cb) {
return cb(null);
}
_webServer() {
// we have to lazy init
if (undefined === this.webServer) {
this.webServer = getServer('codes.l33t.enigma.web.server') || null;
}
return this.webServer ? this.webServer.instance : null;
}
};

View File

@ -212,55 +212,27 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
// request for the user to review and decide what to do with
// at a later time.
//
// :TODO: Implement the queue
const activityPubSettings = ActivityPubSettings.fromUser(user);
if (!activityPubSettings.manuallyApproveFollowers) {
//
// :TODO: Implement the queue
Actor.fromLocalUser(user, this.webServer, (err, localActor) => {
if (err) {
// :TODO:
return;
return this.log.warn(
{ inbox: actor.inbox, error: err.message },
'Failed to load local Actor for "Accept"'
);
}
// user must have a Private Key
const privateKey = user.getProperty(UserProps.PrivateKeyMain);
if (_.isEmpty(privateKey)) {
// :TODO: Log me
return;
}
// :TODO: This stuff should probably be lifted out so it can be called ad-hoc from the queue
const accept = Activity.makeAccept(
this.webServer,
localActor,
activity
);
const keyId = selfUrl(this.webServer, user) + '#main-key';
const reqOpts = {
headers: {
'Content-Type': 'application/activity+json',
},
sign: {
// :TODO: Make a helper for this
key: privateKey,
keyId,
authorizationHeaderName: 'Signature',
headers: [
'(request-target)',
'host',
'date',
'digest',
'content-type',
],
},
};
postJson(
accept.sendTo(
actor.inbox,
JSON.stringify(accept),
reqOpts,
user,
this.webServer,
(err, respBody, res) => {
if (err) {
return this.log.warn(

View File

@ -88,10 +88,8 @@ function validateGeneralMailAddressedTo(data, cb) {
// - Local username or real name
// - Supported remote flavors such as FTN, email, ...
//
// :TODO: remove hard-coded FTN check here. We need a decent way to register global supported flavors with modules.
const addressedToInfo = getAddressedToInfo(data);
if (Message.AddressFlavor.FTN === addressedToInfo.flavor) {
if (Message.AddressFlavor.Local !== addressedToInfo.flavor) {
return cb(null);
}

49
core/webfinger.js Normal file
View File

@ -0,0 +1,49 @@
const { Errors } = require('./enig_error');
const { getAddressedToInfo } = require('./mail_util');
const Message = require('./message');
const { getJson } = require('./http_util');
// deps
const https = require('https');
exports.queryWebFinger = queryWebFinger;
function queryWebFinger(account, cb) {
// ex: @NuSkooler@toot.community -> https://toot.community/.well-known/webfinger with acct:NuSkooler resource
const addrInfo = getAddressedToInfo(account);
if (
addrInfo.flavor !== Message.AddressFlavor.ActivityPub &&
addrInfo.flavor !== Message.AddressFlavor.Email
) {
return cb(Errors.Invalid(`Cannot WebFinger "${accountName}"; Missing domain`));
}
const domain = addrInfo.remote.slice(addrInfo.remote.lastIndexOf('@') + 1);
if (!domain) {
return cb(Errors.Invalid(`Cannot WebFinger "${accountName}"; Missing domain`));
}
const resource = encodeURIComponent(`acct:${account.slice(1)}`); // we need drop the initial '@' prefix
const webFingerUrl = `https://${domain}/.well-known/webfinger?resource=${resource}`;
getJson(webFingerUrl, {}, (err, json, res) => {
if (err) {
return cb(err);
}
if (res.statusCode !== 200) {
// only accept 200
return cb(Errors.DoesNotExist(`Failed to WebFinger URL ${webFingerUrl}`));
}
const contentType = res.headers['content-type'] || '';
if (!contentType.startsWith('application/jrd+json')) {
return cb(
Errors.Invalid(
`Invalid Content-Type for WebFinger URL ${webFingerUrl}: ${contentType}`
)
);
}
return cb(null, json);
});
}