Initial 'Note' support for ActivitiyPub/Mastodon, reconition of @User@domain ActivityPub addresses, skeleton for ActivityPub scan/toss
This commit is contained in:
parent
ef118325ba
commit
64848b4675
|
@ -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()}`);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>`;
|
||||
}
|
||||
|
|
|
@ -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 = {
|
||||
|
|
31
core/fse.js
31
core/fse.js
|
@ -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) => {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -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(
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
Loading…
Reference in New Issue