diff --git a/core/activitypub_activity.js b/core/activitypub_activity.js index 5aa07167..3eca0c58 100644 --- a/core/activitypub_activity.js +++ b/core/activitypub_activity.js @@ -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()}`); } }; diff --git a/core/activitypub_actor.js b/core/activitypub_actor.js index c9dacf85..140268cf 100644 --- a/core/activitypub_actor.js +++ b/core/activitypub_actor.js @@ -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); diff --git a/core/activitypub_util.js b/core/activitypub_util.js index 667390c4..3c4632d4 100644 --- a/core/activitypub_util.js +++ b/core/activitypub_util.js @@ -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 `

${body.replace(/\r?\n/g, '
')}

`; +} diff --git a/core/enig_error.js b/core/enig_error.js index c9d16a54..40422a94 100644 --- a/core/enig_error.js +++ b/core/enig_error.js @@ -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 = { diff --git a/core/fse.js b/core/fse.js index c5413989..fd0f6aac 100644 --- a/core/fse.js +++ b/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) => { diff --git a/core/http_util.js b/core/http_util.js index 7fd8cf71..e1d0733b 100644 --- a/core/http_util.js +++ b/core/http_util.js @@ -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(); } diff --git a/core/mail_util.js b/core/mail_util.js index 15a467ff..eac3d66d 100644 --- a/core/mail_util.js +++ b/core/mail_util.js @@ -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]); } diff --git a/core/message.js b/core/message.js index 47c16821..9b300ffe 100644 --- a/core/message.js +++ b/core/message.js @@ -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)); diff --git a/core/scanner_tossers/activitypub.js b/core/scanner_tossers/activitypub.js new file mode 100644 index 00000000..1ebaa452 --- /dev/null +++ b/core/scanner_tossers/activitypub.js @@ -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; + } +}; diff --git a/core/servers/content/web_handlers/activitypub.js b/core/servers/content/web_handlers/activitypub.js index 45a57b07..6d4d60f1 100644 --- a/core/servers/content/web_handlers/activitypub.js +++ b/core/servers/content/web_handlers/activitypub.js @@ -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( diff --git a/core/system_view_validate.js b/core/system_view_validate.js index 61c52a52..763e0c47 100644 --- a/core/system_view_validate.js +++ b/core/system_view_validate.js @@ -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); } diff --git a/core/webfinger.js b/core/webfinger.js new file mode 100644 index 00000000..299035a3 --- /dev/null +++ b/core/webfinger.js @@ -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); + }); +}