Many WebFinger improvements, can now round trip private messages
This commit is contained in:
parent
82091c11c1
commit
4f632fd8c4
|
@ -11,6 +11,7 @@ const {
|
|||
selfUrl,
|
||||
isValidLink,
|
||||
makeSharedInboxUrl,
|
||||
userNameFromSubject,
|
||||
} = require('./util');
|
||||
const Log = require('../logger').log;
|
||||
const { queryWebFinger } = require('../webfinger');
|
||||
|
@ -24,6 +25,9 @@ const _ = require('lodash');
|
|||
const mimeTypes = require('mime-types');
|
||||
const { getJson } = require('../http_util.js');
|
||||
const { getISOTimestampString } = require('../database.js');
|
||||
const moment = require('moment');
|
||||
|
||||
const ActorCacheTTL = moment.duration(1, 'day');
|
||||
|
||||
// https://www.w3.org/TR/activitypub/#actor-objects
|
||||
module.exports = class Actor extends ActivityPubObject {
|
||||
|
@ -138,43 +142,42 @@ module.exports = class Actor extends ActivityPubObject {
|
|||
return cb(null, new Actor(obj));
|
||||
}
|
||||
|
||||
static fromId(id, forceRefresh, cb) {
|
||||
if (_.isFunction(forceRefresh) && !cb) {
|
||||
cb = forceRefresh;
|
||||
forceRefresh = false;
|
||||
}
|
||||
static fromId(id, cb) {
|
||||
Actor._fromCache(id, (err, actor, subject) => {
|
||||
if (!err) {
|
||||
// cache hit
|
||||
return cb(null, actor, subject);
|
||||
}
|
||||
|
||||
Actor.fromCache(id, (err, actor) => {
|
||||
if (err) {
|
||||
if (forceRefresh) {
|
||||
return cb(err);
|
||||
// cache miss: attempt to fetch & populate
|
||||
Actor._fromWebFinger(id, (err, actor, subject) => {
|
||||
if (subject) {
|
||||
subject = `@${userNameFromSubject(subject)}`; // e.g. @Username@host.com
|
||||
} else {
|
||||
subject = actor.id; // best we can do for now
|
||||
}
|
||||
|
||||
Actor.fromRemoteQuery(id, (err, actor) => {
|
||||
// deliver result to caller
|
||||
cb(err, actor);
|
||||
// deliver result to caller
|
||||
cb(err, actor, subject);
|
||||
|
||||
// cache our entry
|
||||
if (actor) {
|
||||
apDb.run(
|
||||
`INSERT INTO actor_cache (actor_id, actor_json, timestamp)
|
||||
VALUES (?, ?, ?);`,
|
||||
[id, JSON.stringify(actor), getISOTimestampString()],
|
||||
err => {
|
||||
if (err) {
|
||||
// :TODO: log me
|
||||
}
|
||||
// cache our entry
|
||||
if (actor) {
|
||||
apDb.run(
|
||||
`REPLACE INTO actor_cache (actor_id, actor_json, subject, timestamp)
|
||||
VALUES (?, ?, ?, ?);`,
|
||||
[id, JSON.stringify(actor), subject, getISOTimestampString()],
|
||||
err => {
|
||||
if (err) {
|
||||
// :TODO: log me
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
return cb(null, actor);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static fromRemoteQuery(id, cb) {
|
||||
static _fromRemoteQuery(id, cb) {
|
||||
const headers = {
|
||||
Accept: 'application/activity+json',
|
||||
};
|
||||
|
@ -194,13 +197,13 @@ module.exports = class Actor extends ActivityPubObject {
|
|||
});
|
||||
}
|
||||
|
||||
static fromCache(id, cb) {
|
||||
static _fromCache(id, cb) {
|
||||
apDb.get(
|
||||
`SELECT actor_json
|
||||
`SELECT actor_json, subject, timestamp
|
||||
FROM actor_cache
|
||||
WHERE actor_id = ?
|
||||
WHERE actor_id = ? OR subject = ?
|
||||
LIMIT 1;`,
|
||||
[id],
|
||||
[id, id],
|
||||
(err, row) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
|
@ -210,6 +213,11 @@ module.exports = class Actor extends ActivityPubObject {
|
|||
return cb(Errors.DoesNotExist());
|
||||
}
|
||||
|
||||
const timestamp = moment(row.timestamp);
|
||||
if (moment().isAfter(timestamp.add(ActorCacheTTL))) {
|
||||
return cb(Errors.Expired('The cache entry is expired'));
|
||||
}
|
||||
|
||||
const obj = ActivityPubObject.fromJsonString(row.actor_json);
|
||||
if (!obj || !obj.isValid()) {
|
||||
return cb(Errors.Invalid('Failed to create ActivityPub object'));
|
||||
|
@ -220,20 +228,14 @@ module.exports = class Actor extends ActivityPubObject {
|
|||
return cb(Errors.Invalid('Failed to create Actor object'));
|
||||
}
|
||||
|
||||
return cb(null, actor);
|
||||
const subject = row.subject || actor.id;
|
||||
return cb(null, actor, subject);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
static fromAccountName(actorName, cb) {
|
||||
// :TODO: cache first -- do we have an Actor for this account already with a OK TTL?
|
||||
|
||||
// account names can come in multiple forms, so need a cache mapping of that as well
|
||||
// actor_alias_cache
|
||||
// actor_alias | actor_id
|
||||
//
|
||||
|
||||
queryWebFinger(actorName, (err, res) => {
|
||||
static _fromWebFinger(actorQuery, cb) {
|
||||
queryWebFinger(actorQuery, (err, res) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
@ -255,7 +257,9 @@ module.exports = class Actor extends ActivityPubObject {
|
|||
}
|
||||
|
||||
// we can now query the href value for an Actor
|
||||
return Actor.fromId(activityLink.href, cb);
|
||||
return Actor._fromRemoteQuery(activityLink.href, (err, actor) => {
|
||||
return cb(err, actor, res.subject);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
@ -14,6 +14,7 @@ const async = require('async');
|
|||
const { isString, isObject } = require('lodash');
|
||||
|
||||
const APMessageIdNamespace = '307bc7b3-3735-4573-9a20-e3f9eaac29c5';
|
||||
const APDefaultSummary = '[ActivityPub]';
|
||||
|
||||
module.exports = class Note extends ActivityPubObject {
|
||||
constructor(obj) {
|
||||
|
@ -71,7 +72,7 @@ module.exports = class Note extends ActivityPubObject {
|
|||
});
|
||||
},
|
||||
(fromUser, fromActor, callback) => {
|
||||
Actor.fromAccountName(remoteActorAccount, (err, remoteActor) => {
|
||||
Actor.fromId(remoteActorAccount, (err, remoteActor) => {
|
||||
return callback(err, fromUser, fromActor, remoteActor);
|
||||
});
|
||||
},
|
||||
|
@ -91,10 +92,14 @@ module.exports = class Note extends ActivityPubObject {
|
|||
audience: [message.isPrivate() ? 'as:Private' : 'as:Public'],
|
||||
|
||||
// :TODO: inReplyto if this is a reply; we need this store in message meta.
|
||||
summary: message.subject,
|
||||
content: messageBodyToHtml(message.message.trim()),
|
||||
};
|
||||
|
||||
// Filter out replace replacement
|
||||
if (message.subject !== APDefaultSummary) {
|
||||
obj.summary = message.subject;
|
||||
}
|
||||
|
||||
const note = new Note(obj);
|
||||
return callback(null, { note, fromUser, remoteActor });
|
||||
},
|
||||
|
@ -116,25 +121,23 @@ module.exports = class Note extends ActivityPubObject {
|
|||
});
|
||||
|
||||
// Fetch the remote actor info to get their user info
|
||||
Actor.fromId(this.attributedTo, false, (err, attributedToActor) => {
|
||||
Actor.fromId(this.attributedTo, (err, attributedToActor, fromActorSubject) => {
|
||||
if (err) {
|
||||
// :TODO: Log me
|
||||
message.fromUserName = this.attributedTo; // have some sort of value =/
|
||||
} else {
|
||||
message.fromUserName =
|
||||
attributedToActor.preferredUsername || this.attributedTo;
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
message.fromUserName = fromActorSubject || this.attributedTo;
|
||||
|
||||
//
|
||||
// Note's can be addressed to 1:N users, but a Message is a 1:1
|
||||
// relationship. This method requires the mapping up front via options
|
||||
//
|
||||
(message.toUserName = options.toUser.username),
|
||||
(message.meta.System[Message.SystemMetaNames.LocalToUserID] =
|
||||
options.toUser.userId);
|
||||
message.toUserName = options.toUser.username;
|
||||
message.meta.System[Message.SystemMetaNames.LocalToUserID] =
|
||||
options.toUser.userId;
|
||||
message.areaTag = options.areaTag || Message.WellKnownAreaTags.Private;
|
||||
|
||||
message.subject = this.summary || '-ActivityPub-';
|
||||
message.subject = this.summary || APDefaultSummary;
|
||||
|
||||
// :TODO: it would be better to do some basic HTML to ANSI or pipe codes perhaps
|
||||
message.message = htmlToMessageBody(this.content);
|
||||
|
|
|
@ -24,6 +24,7 @@ exports.accountFromSelfUrl = accountFromSelfUrl;
|
|||
exports.getUserProfileTemplatedBody = getUserProfileTemplatedBody;
|
||||
exports.messageBodyToHtml = messageBodyToHtml;
|
||||
exports.htmlToMessageBody = htmlToMessageBody;
|
||||
exports.userNameFromSubject = userNameFromSubject;
|
||||
|
||||
// :TODO: more info in default
|
||||
// this profile template is the *default* for both WebFinger
|
||||
|
@ -181,3 +182,7 @@ function messageBodyToHtml(body) {
|
|||
function htmlToMessageBody(html) {
|
||||
return striptags(html);
|
||||
}
|
||||
|
||||
function userNameFromSubject(subject) {
|
||||
return subject.replace(/^acct:(.+)$/, '$1');
|
||||
}
|
||||
|
|
|
@ -505,9 +505,10 @@ dbs.message.run(
|
|||
// Actors we know about and have cached
|
||||
dbs.activitypub.run(
|
||||
`CREATE TABLE IF NOT EXISTS actor_cache (
|
||||
id INTEGER PRIMARY KEY, -- Local DB ID
|
||||
actor_cache_id INTEGER PRIMARY KEY, -- Local DB ID
|
||||
actor_id VARCHAR NOT NULL, -- Fully qualified Actor ID/URL
|
||||
actor_json VARCHAR NOT NULL, -- Actor document
|
||||
subject VARCHAR, -- Subject obtained from WebFinger, e.g. @Username@some.domain
|
||||
timestamp DATETIME NOT NULL, -- Timestamp in which this Actor was cached
|
||||
|
||||
UNIQUE(actor_id)
|
||||
|
|
|
@ -56,6 +56,7 @@ exports.Errors = {
|
|||
Timeout: (reason, reasonCode) => new EnigError('Timeout', -32014, reason, reasonCode),
|
||||
MissingProperty: (reason, reasonCode) =>
|
||||
new EnigError('Missing property', -32014, reason, reasonCode),
|
||||
Expired: (reason, reasonCode) => new EnigError('Expired', -32015, reason, reasonCode),
|
||||
};
|
||||
|
||||
exports.ErrorReasons = {
|
||||
|
|
|
@ -25,7 +25,7 @@ const EMAIL_REGEX =
|
|||
43:20/100.2 { flavor : 'ftn', remote : '43:20/100.2' }
|
||||
foo@host.com { name : 'foo', flavor : 'email', remote : 'foo@host.com' }
|
||||
Bar <baz@foobar.net> { name : 'Bar', flavor : 'email', remote : 'baz@foobar.com' }
|
||||
@JoeUser@some.host.com { name : 'Joe User', flavor : 'activitypub', remote 'JoeUser@some.host.com' }
|
||||
@JoeUser@some.host.com { name : 'JoeUser', flavor : 'activitypub', remote 'JoeUser@some.host.com' }
|
||||
*/
|
||||
function getAddressedToInfo(input) {
|
||||
input = input.trim();
|
||||
|
|
|
@ -61,10 +61,10 @@ exports.getModule = class ActivityPubScannerTosser extends MessageScanTossModule
|
|||
);
|
||||
|
||||
// :TODO: Implement retry logic (connection issues, retryable HTTP status) ??
|
||||
//const inbox = remoteActor.inbox;
|
||||
const inbox = remoteActor.inbox;
|
||||
|
||||
const inbox = remoteActor.endpoints.sharedInbox;
|
||||
activity.object.to = 'https://www.w3.org/ns/activitystreams#Public';
|
||||
// const inbox = remoteActor.endpoints.sharedInbox;
|
||||
// activity.object.to = 'https://www.w3.org/ns/activitystreams#Public';
|
||||
|
||||
activity.sendTo(
|
||||
inbox,
|
||||
|
|
|
@ -299,6 +299,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
|
|||
async.forEach(
|
||||
toActors,
|
||||
(actorId, nextActor) => {
|
||||
// :TODO: verify this - if *any* audience/actor is public, then this message is public I believe.
|
||||
if (Collection.PublicCollectionId === actorId) {
|
||||
// Deliver to inbox for "everyone":
|
||||
// - Add to 'sharedInbox' collection
|
||||
|
@ -318,7 +319,12 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
|
|||
},
|
||||
err => {
|
||||
if (err) {
|
||||
// :TODO: If sqlite constraint, just return OK -- it's a dupe
|
||||
// if we get a dupe, just tell the remote everything is A-OK
|
||||
if ('SQLITE_CONSTRAINT' === err.code) {
|
||||
resp.writeHead(202);
|
||||
return resp.end('');
|
||||
}
|
||||
|
||||
return this.webServer.internalServerError(resp, err);
|
||||
}
|
||||
|
||||
|
|
|
@ -7,23 +7,45 @@ const { getJson } = require('./http_util');
|
|||
|
||||
exports.queryWebFinger = queryWebFinger;
|
||||
|
||||
function queryWebFinger(account, cb) {
|
||||
function queryWebFinger(query, cb) {
|
||||
//
|
||||
// Accept a variety of formats to query via WebFinger
|
||||
// 1) @Username@foo.bar -> query with acct:Username resource
|
||||
// 2) http/https URL -> query with resource = URL
|
||||
// 3) If not one of the above and a '/' is present in the query,
|
||||
// assume https:// and try #2
|
||||
//
|
||||
|
||||
// ex: @NuSkooler@toot.community -> https://toot.community/.well-known/webfinger with acct:NuSkooler resource
|
||||
const addrInfo = getAddressedToInfo(account);
|
||||
const addrInfo = getAddressedToInfo(query);
|
||||
let resource;
|
||||
let host;
|
||||
if (
|
||||
addrInfo.flavor !== Message.AddressFlavor.ActivityPub &&
|
||||
addrInfo.flavor !== Message.AddressFlavor.Email
|
||||
addrInfo.flavor === Message.AddressFlavor.ActivityPub ||
|
||||
addrInfo.flavor === Message.AddressFlavor.Email
|
||||
) {
|
||||
return cb(Errors.Invalid(`Cannot WebFinger "${account.remote}"; Missing domain`));
|
||||
host = addrInfo.remote.slice(addrInfo.remote.lastIndexOf('@') + 1);
|
||||
if (!host) {
|
||||
return cb(Errors.Invalid(`Unsure how to WebFinger "${query}"`));
|
||||
}
|
||||
resource = `acct:${addrInfo.name}@${host}`;
|
||||
} else {
|
||||
if (!/^https?:\/\/.+$/.test(query)) {
|
||||
resource = `https://${query}`;
|
||||
} else {
|
||||
resource = query;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(resource);
|
||||
host = url.host;
|
||||
} catch (e) {
|
||||
return cb(Errors.Invalid(`Cannot WebFinger "${query}": ${e.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
const domain = addrInfo.remote.slice(addrInfo.remote.lastIndexOf('@') + 1);
|
||||
if (!domain) {
|
||||
return cb(Errors.Invalid(`Cannot WebFinger "${account.remote}"; Missing domain`));
|
||||
}
|
||||
|
||||
const resource = encodeURIComponent(`acct:${account.slice(1)}`); // we need drop the initial '@' prefix
|
||||
const webFingerUrl = `https://${domain}/.well-known/webfinger?resource=${resource}`;
|
||||
resource = encodeURIComponent(resource);
|
||||
const webFingerUrl = `https://${host}/.well-known/webfinger?resource=${resource}`;
|
||||
getJson(webFingerUrl, {}, (err, json, res) => {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
|
|
Loading…
Reference in New Issue