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 { isString, isObject } = require('lodash');
|
||||||
const { v4: UUIDv4 } = require('uuid');
|
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 {
|
module.exports = class Activity {
|
||||||
constructor(obj) {
|
constructor(obj) {
|
||||||
|
@ -47,7 +61,7 @@ module.exports = class Activity {
|
||||||
|
|
||||||
// https://www.w3.org/TR/activitypub/#accept-activity-inbox
|
// https://www.w3.org/TR/activitypub/#accept-activity-inbox
|
||||||
static makeAccept(webServer, localActor, followRequest, id = null) {
|
static makeAccept(webServer, localActor, followRequest, id = null) {
|
||||||
id = id || webServer.buildUrl(`/${UUIDv4()}`);
|
id = id || Activity._makeId(webServer, '/accept');
|
||||||
|
|
||||||
return new Activity({
|
return new Activity({
|
||||||
type: 'Accept',
|
type: 'Accept',
|
||||||
|
@ -56,7 +70,131 @@ module.exports = class Activity {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
sendTo(actorUrl, cb) {
|
static noteFromLocalMessage(webServer, message, cb) {
|
||||||
// :TODO: https send |this| to actorUrl
|
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½
|
// ENiGMA½
|
||||||
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 { ActorProps } = require('./activitypub_actor_property');
|
|
||||||
const UserProps = require('./user_property');
|
const UserProps = require('./user_property');
|
||||||
const {
|
const {
|
||||||
webFingerProfileUrl,
|
webFingerProfileUrl,
|
||||||
|
@ -14,9 +13,10 @@ const {
|
||||||
ActivityStreamsContext,
|
ActivityStreamsContext,
|
||||||
} = require('./activitypub_util');
|
} = require('./activitypub_util');
|
||||||
const Log = require('./logger').log;
|
const Log = require('./logger').log;
|
||||||
|
const { queryWebFinger } = require('./webfinger');
|
||||||
|
const EnigAssert = require('./enigma_assert');
|
||||||
|
|
||||||
// deps
|
// deps
|
||||||
const assert = require('assert');
|
|
||||||
const async = require('async');
|
const async = require('async');
|
||||||
const _ = require('lodash');
|
const _ = require('lodash');
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
|
@ -102,6 +102,11 @@ module.exports = class Actor {
|
||||||
owner: userSelfUrl,
|
owner: userSelfUrl,
|
||||||
publicKeyPem,
|
publicKeyPem,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
EnigAssert(
|
||||||
|
!_.isEmpty(user.getProperty(UserProps.PrivateKeyMain)),
|
||||||
|
'User has public key but no private key!'
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
Log.warn(
|
Log.warn(
|
||||||
{ username: user.username },
|
{ username: user.username },
|
||||||
|
@ -117,6 +122,8 @@ module.exports = class Actor {
|
||||||
Accept: 'application/activity+json',
|
Accept: 'application/activity+json',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// :TODO: use getJson()
|
||||||
|
|
||||||
https.get(url, { headers }, res => {
|
https.get(url, { headers }, res => {
|
||||||
if (res.statusCode !== 200) {
|
if (res.statusCode !== 200) {
|
||||||
return cb(Errors.Invalid(`Bad HTTP status code: ${req.statusCode}`));
|
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) {
|
static fromJson(json) {
|
||||||
const parsed = JSON.parse(json);
|
const parsed = JSON.parse(json);
|
||||||
return new Actor(parsed);
|
return new Actor(parsed);
|
||||||
|
|
|
@ -20,6 +20,7 @@ exports.selfUrl = selfUrl;
|
||||||
exports.userFromAccount = userFromAccount;
|
exports.userFromAccount = userFromAccount;
|
||||||
exports.accountFromSelfUrl = accountFromSelfUrl;
|
exports.accountFromSelfUrl = accountFromSelfUrl;
|
||||||
exports.getUserProfileTemplatedBody = getUserProfileTemplatedBody;
|
exports.getUserProfileTemplatedBody = getUserProfileTemplatedBody;
|
||||||
|
exports.messageBodyToHtml = messageBodyToHtml;
|
||||||
|
|
||||||
// :TODO: more info in default
|
// :TODO: more info in default
|
||||||
// this profile template is the *default* for both WebFinger
|
// 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) =>
|
HttpError: (reason, reasonCode) =>
|
||||||
new EnigError('HTTP error', -32013, reason, reasonCode),
|
new EnigError('HTTP error', -32013, reason, reasonCode),
|
||||||
Timeout: (reason, reasonCode) => new EnigError('Timeout', -32014, reason, reasonCode),
|
Timeout: (reason, reasonCode) => new EnigError('Timeout', -32014, reason, reasonCode),
|
||||||
|
MissingProperty: (reason, reasonCode) =>
|
||||||
|
new EnigError('Missing property', -32014, reason, reasonCode),
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.ErrorReasons = {
|
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 { isAnsi, stripAnsiControlCodes, insert } = require('./string_util.js');
|
||||||
const { stripMciColorCodes, controlCodesToAnsi } = require('./color_codes.js');
|
const { stripMciColorCodes, controlCodesToAnsi } = require('./color_codes.js');
|
||||||
const Config = require('./config.js').get;
|
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 Events = require('./events.js');
|
||||||
const UserProps = require('./user_property.js');
|
const UserProps = require('./user_property.js');
|
||||||
const SysProps = require('./system_property.js');
|
const SysProps = require('./system_property.js');
|
||||||
|
@ -589,15 +593,9 @@ exports.FullScreenEditorModule =
|
||||||
self.replyToMessage &&
|
self.replyToMessage &&
|
||||||
self.replyToMessage.isFromRemoteUser()
|
self.replyToMessage.isFromRemoteUser()
|
||||||
) {
|
) {
|
||||||
self.message.setRemoteToUser(
|
copyExternalAddressedToInfo(
|
||||||
self.replyToMessage.meta.System[
|
self.replyToMessage,
|
||||||
Message.SystemMetaNames.RemoteFromUser
|
self.message
|
||||||
]
|
|
||||||
);
|
|
||||||
self.message.setExternalFlavor(
|
|
||||||
self.replyToMessage.meta.System[
|
|
||||||
Message.SystemMetaNames.ExternalFlavor
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
return callback(null);
|
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
|
// 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(
|
const addressedToInfo = getAddressedToInfo(
|
||||||
self.message.toUserName
|
self.message.toUserName
|
||||||
);
|
);
|
||||||
if (
|
|
||||||
addressedToInfo.name &&
|
if (setExternalAddressedToInfo(addressedToInfo, self.message)) {
|
||||||
Message.AddressFlavor.FTN === addressedToInfo.flavor
|
// setExternalAddressedToInfo() did what we need
|
||||||
) {
|
|
||||||
self.message.setRemoteToUser(addressedToInfo.remote);
|
|
||||||
self.message.setExternalFlavor(addressedToInfo.flavor);
|
|
||||||
self.message.toUserName = addressedToInfo.name;
|
|
||||||
return callback(null);
|
return callback(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// we need to look it up
|
// Local user -- we need to look it up
|
||||||
User.getUserIdAndNameByLookup(
|
User.getUserIdAndNameByLookup(
|
||||||
self.message.toUserName,
|
self.message.toUserName,
|
||||||
(err, toUserId) => {
|
(err, toUserId) => {
|
||||||
|
|
|
@ -1,18 +1,46 @@
|
||||||
const { Errors } = require('./enig_error.js');
|
const { Errors } = require('./enig_error.js');
|
||||||
|
|
||||||
// deps
|
// deps
|
||||||
const { isString, isObject } = require('lodash');
|
const { isString, isObject, truncate } = require('lodash');
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
const httpSignature = require('http-signature');
|
const httpSignature = require('http-signature');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
exports.getJson = getJson;
|
||||||
exports.postJson = postJson;
|
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) {
|
function postJson(url, json, options, cb) {
|
||||||
if (!isString(json)) {
|
if (!isString(json)) {
|
||||||
json = JSON.stringify(json);
|
json = JSON.stringify(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// :TODO: DRY this method with _makeRequest()
|
||||||
|
|
||||||
const defaultOptions = {
|
const defaultOptions = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: json,
|
body: json,
|
||||||
|
@ -32,6 +60,59 @@ function postJson(url, json, options, cb) {
|
||||||
|
|
||||||
options.headers['Content-Length'] = json.length;
|
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 => {
|
const req = https.request(url, options, res => {
|
||||||
let body = [];
|
let body = [];
|
||||||
res.on('data', d => {
|
res.on('data', d => {
|
||||||
|
@ -67,6 +148,5 @@ function postJson(url, json, options, cb) {
|
||||||
return cb(Errors.Timeout('Timeout making HTTP request'));
|
return cb(Errors.Timeout('Timeout making HTTP request'));
|
||||||
});
|
});
|
||||||
|
|
||||||
req.write(json);
|
|
||||||
req.end();
|
req.end();
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,14 @@
|
||||||
/* jslint node: true */
|
/* jslint node: true */
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
const EnigmaAssert = require('./enigma_assert.js');
|
||||||
const Address = require('./ftn_address.js');
|
const Address = require('./ftn_address.js');
|
||||||
|
const { AddressFlavor } = require('./message.js');
|
||||||
const Message = require('./message.js');
|
const Message = require('./message.js');
|
||||||
|
|
||||||
exports.getAddressedToInfo = getAddressedToInfo;
|
exports.getAddressedToInfo = getAddressedToInfo;
|
||||||
|
exports.setExternalAddressedToInfo = setExternalAddressedToInfo;
|
||||||
|
exports.copyExternalAddressedToInfo = copyExternalAddressedToInfo;
|
||||||
|
|
||||||
const EMAIL_REGEX =
|
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,}))$/;
|
/^(([^<>()[\]\\.,;:\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) {
|
if (firstAtPos < 0) {
|
||||||
let addr = Address.fromString(input);
|
let addr = Address.fromString(input);
|
||||||
if (Address.isValidAddress(addr)) {
|
if (Address.isValidAddress(addr)) {
|
||||||
return { flavor: Message.AddressFlavor.FTN, remote: input };
|
return { flavor: AddressFlavor.FTN, remote: input };
|
||||||
}
|
}
|
||||||
|
|
||||||
const lessThanPos = input.indexOf('<');
|
const lessThanPos = input.indexOf('<');
|
||||||
if (lessThanPos < 0) {
|
if (lessThanPos < 0) {
|
||||||
return { name: input, flavor: Message.AddressFlavor.Local };
|
return { name: input, flavor: AddressFlavor.Local };
|
||||||
}
|
}
|
||||||
|
|
||||||
const greaterThanPos = input.indexOf('>');
|
const greaterThanPos = input.indexOf('>');
|
||||||
if (greaterThanPos < lessThanPos) {
|
if (greaterThanPos < lessThanPos) {
|
||||||
return { name: input, flavor: Message.AddressFlavor.Local };
|
return { name: input, flavor: AddressFlavor.Local };
|
||||||
}
|
}
|
||||||
|
|
||||||
addr = Address.fromString(input.slice(lessThanPos + 1, greaterThanPos));
|
addr = Address.fromString(input.slice(lessThanPos + 1, greaterThanPos));
|
||||||
if (Address.isValidAddress(addr)) {
|
if (Address.isValidAddress(addr)) {
|
||||||
return {
|
return {
|
||||||
name: input.slice(0, lessThanPos).trim(),
|
name: input.slice(0, lessThanPos).trim(),
|
||||||
flavor: Message.AddressFlavor.FTN,
|
flavor: AddressFlavor.FTN,
|
||||||
remote: addr.toString(),
|
remote: addr.toString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { name: input, flavor: Message.AddressFlavor.Local };
|
return { name: input, flavor: AddressFlavor.Local };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (firstAtPos === 0) {
|
if (firstAtPos === 0) {
|
||||||
|
@ -63,7 +67,7 @@ function getAddressedToInfo(input) {
|
||||||
if (m) {
|
if (m) {
|
||||||
return {
|
return {
|
||||||
name: input.slice(1, secondAtPos),
|
name: input.slice(1, secondAtPos),
|
||||||
flavor: Message.AddressFlavor.ActivityPub,
|
flavor: AddressFlavor.ActivityPub,
|
||||||
remote: input.slice(firstAtPos),
|
remote: input.slice(firstAtPos),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -78,36 +82,67 @@ function getAddressedToInfo(input) {
|
||||||
if (m) {
|
if (m) {
|
||||||
return {
|
return {
|
||||||
name: input.slice(0, lessThanPos).trim(),
|
name: input.slice(0, lessThanPos).trim(),
|
||||||
flavor: Message.AddressFlavor.Email,
|
flavor: AddressFlavor.Email,
|
||||||
remote: addr,
|
remote: addr,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { name: input, flavor: Message.AddressFlavor.Local };
|
return { name: input, flavor: AddressFlavor.Local };
|
||||||
}
|
}
|
||||||
|
|
||||||
let m = input.match(EMAIL_REGEX);
|
let m = input.match(EMAIL_REGEX);
|
||||||
if (m) {
|
if (m) {
|
||||||
return {
|
return {
|
||||||
name: input.slice(0, firstAtPos),
|
name: input.slice(0, firstAtPos),
|
||||||
flavor: Message.AddressFlavor.Email,
|
flavor: AddressFlavor.Email,
|
||||||
remote: input,
|
remote: input,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let addr = Address.fromString(input); // 5D?
|
let addr = Address.fromString(input); // 5D?
|
||||||
if (Address.isValidAddress(addr)) {
|
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());
|
addr = Address.fromString(input.slice(firstAtPos + 1).trim());
|
||||||
if (Address.isValidAddress(addr)) {
|
if (Address.isValidAddress(addr)) {
|
||||||
return {
|
return {
|
||||||
name: input.slice(0, firstAtPos).trim(),
|
name: input.slice(0, firstAtPos).trim(),
|
||||||
flavor: Message.AddressFlavor.FTN,
|
flavor: AddressFlavor.FTN,
|
||||||
remote: addr.toString(),
|
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
|
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)!
|
// :TODO: this is a ugly hack due to bad variable names - clean it up & just _.camelCase(k)!
|
||||||
const MESSAGE_ROW_MAP = {
|
const MESSAGE_ROW_MAP = {
|
||||||
reply_to_message_id: 'replyToMsgId',
|
reply_to_message_id: 'replyToMsgId',
|
||||||
|
@ -243,16 +247,29 @@ module.exports = class Message {
|
||||||
this.meta.System[Message.SystemMetaNames.LocalFromUserID] = userId;
|
this.meta.System[Message.SystemMetaNames.LocalFromUserID] = userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getLocalFromUserId() {
|
||||||
|
let id = _.get(this, 'meta.System.local_from_user_id', 0);
|
||||||
|
return parseInt(id);
|
||||||
|
}
|
||||||
|
|
||||||
setRemoteToUser(remoteTo) {
|
setRemoteToUser(remoteTo) {
|
||||||
this.meta.System = this.meta.System || {};
|
this.meta.System = this.meta.System || {};
|
||||||
this.meta.System[Message.SystemMetaNames.RemoteToUser] = remoteTo;
|
this.meta.System[Message.SystemMetaNames.RemoteToUser] = remoteTo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getRemoteToUser() {
|
||||||
|
return _.get(this, 'meta.System.remote_to_user');
|
||||||
|
}
|
||||||
|
|
||||||
setExternalFlavor(flavor) {
|
setExternalFlavor(flavor) {
|
||||||
this.meta.System = this.meta.System || {};
|
this.meta.System = this.meta.System || {};
|
||||||
this.meta.System[Message.SystemMetaNames.ExternalFlavor] = flavor;
|
this.meta.System[Message.SystemMetaNames.ExternalFlavor] = flavor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAddressFlavor() {
|
||||||
|
return _.get(this, 'meta.System.external_flavor', Message.AddressFlavor.Local);
|
||||||
|
}
|
||||||
|
|
||||||
static createMessageUUID(areaTag, modTimestamp, subject, body) {
|
static createMessageUUID(areaTag, modTimestamp, subject, body) {
|
||||||
assert(_.isString(areaTag));
|
assert(_.isString(areaTag));
|
||||||
assert(_.isDate(modTimestamp) || moment.isMoment(modTimestamp));
|
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
|
// request for the user to review and decide what to do with
|
||||||
// at a later time.
|
// at a later time.
|
||||||
//
|
//
|
||||||
|
// :TODO: Implement the queue
|
||||||
const activityPubSettings = ActivityPubSettings.fromUser(user);
|
const activityPubSettings = ActivityPubSettings.fromUser(user);
|
||||||
if (!activityPubSettings.manuallyApproveFollowers) {
|
if (!activityPubSettings.manuallyApproveFollowers) {
|
||||||
//
|
|
||||||
// :TODO: Implement the queue
|
|
||||||
Actor.fromLocalUser(user, this.webServer, (err, localActor) => {
|
Actor.fromLocalUser(user, this.webServer, (err, localActor) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
// :TODO:
|
return this.log.warn(
|
||||||
return;
|
{ 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(
|
const accept = Activity.makeAccept(
|
||||||
this.webServer,
|
this.webServer,
|
||||||
localActor,
|
localActor,
|
||||||
activity
|
activity
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyId = selfUrl(this.webServer, user) + '#main-key';
|
accept.sendTo(
|
||||||
|
|
||||||
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(
|
|
||||||
actor.inbox,
|
actor.inbox,
|
||||||
JSON.stringify(accept),
|
user,
|
||||||
reqOpts,
|
this.webServer,
|
||||||
(err, respBody, res) => {
|
(err, respBody, res) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return this.log.warn(
|
return this.log.warn(
|
||||||
|
|
|
@ -88,10 +88,8 @@ function validateGeneralMailAddressedTo(data, cb) {
|
||||||
// - Local username or real name
|
// - Local username or real name
|
||||||
// - Supported remote flavors such as FTN, email, ...
|
// - 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);
|
const addressedToInfo = getAddressedToInfo(data);
|
||||||
|
if (Message.AddressFlavor.Local !== addressedToInfo.flavor) {
|
||||||
if (Message.AddressFlavor.FTN === addressedToInfo.flavor) {
|
|
||||||
return cb(null);
|
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