Object and Note, load of public notes, etc.

This commit is contained in:
Bryan Ashby 2023-01-23 14:45:56 -07:00
parent 0fc8ae0e18
commit d7df066ab0
No known key found for this signature in database
GPG Key ID: C2C1B501E4EFD994
9 changed files with 430 additions and 128 deletions

View File

@ -1,17 +1,11 @@
const { messageBodyToHtml, selfUrl } = require('./util');
const { ActivityStreamsContext, WellKnownActivityTypes } = require('./const');
const { selfUrl } = require('./util');
const { WellKnownActivityTypes } = require('./const');
const ActivityPubObject = require('./object');
const User = require('../user');
const Actor = require('./actor');
const { Errors } = require('../enig_error');
const { getISOTimestampString } = require('../database');
const UserProps = require('../user_property');
const { postJson } = require('../http_util');
const { WellKnownLocations } = require('../servers/content/web');
// deps
const { v4: UUIDv4 } = require('uuid');
const async = require('async');
const _ = require('lodash');
module.exports = class Activity extends ActivityPubObject {
@ -23,10 +17,9 @@ module.exports = class Activity extends ActivityPubObject {
return WellKnownActivityTypes;
}
static makeFollow(webServer, localActor, remoteActor, id = null) {
id = id || Activity._makeFullId(webServer, 'follow');
static makeFollow(webServer, localActor, remoteActor) {
return new Activity({
id,
id: Activity.activityObjectId(webServer),
type: 'Follow',
actor: localActor,
object: remoteActor.id,
@ -34,92 +27,22 @@ module.exports = class Activity extends ActivityPubObject {
}
// https://www.w3.org/TR/activitypub/#accept-activity-inbox
static makeAccept(webServer, localActor, followRequest, id = null) {
id = id || Activity._makeFullId(webServer, 'accept');
static makeAccept(webServer, localActor, followRequest) {
return new Activity({
id,
id: Activity.activityObjectId(webServer),
type: 'Accept',
actor: localActor,
object: followRequest, // previous request Activity
});
}
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._makeFullId(webServer, 'create');
const obj = {
'@context': ActivityStreamsContext,
id: activityId,
type: 'Create',
actor: localActor.id,
object: {
id: Activity._makeFullId(webServer, 'note'),
type: 'Note',
published: getISOTimestampString(message.modTimestamp),
attributedTo: localActor.id,
audience: [message.isPrivate() ? 'as:Private' : 'as:Public'],
// :TODO: inReplyto if this is a reply; we need this store in message meta.
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 });
}
);
static makeCreate(webServer, actor, obj) {
return new Activity({
id: Activity.activityObjectId(webServer),
type: 'Create',
actor,
object: obj,
});
}
sendTo(actorUrl, fromUser, webServer, cb) {
@ -149,10 +72,7 @@ module.exports = class Activity extends ActivityPubObject {
return postJson(actorUrl, activityJson, reqOpts, cb);
}
static _makeFullId(webServer, prefix) {
// e.g. http://some.host/_enig/ap/note/bf81a22e-cb3e-41c8-b114-21f375b61124
return webServer.buildUrl(
WellKnownLocations.Internal + `/ap/${prefix}/${UUIDv4()}`
);
static activityObjectId(webServer) {
return ActivityPubObject.makeObjectId(webServer, 'activity');
}
};

View File

@ -17,11 +17,13 @@ const { queryWebFinger } = require('../webfinger');
const EnigAssert = require('../enigma_assert');
const ActivityPubSettings = require('./settings');
const ActivityPubObject = require('./object');
const apDb = require('../database').dbs.activitypub;
// deps
const _ = require('lodash');
const mimeTypes = require('mime-types');
const { getJson } = require('../http_util.js');
const { getISOTimestampString } = require('../database.js');
// https://www.w3.org/TR/activitypub/#actor-objects
module.exports = class Actor extends ActivityPubObject {
@ -136,13 +138,48 @@ module.exports = class Actor extends ActivityPubObject {
return cb(null, new Actor(obj));
}
static fromRemoteUrl(url, cb) {
// :TODO: cache first
static fromId(id, forceRefresh, cb) {
if (_.isFunction(forceRefresh) && !cb) {
cb = forceRefresh;
forceRefresh = false;
}
Actor.fromCache(id, (err, actor) => {
if (err) {
if (forceRefresh) {
return cb(err);
}
Actor.fromRemoteQuery(id, (err, actor) => {
// deliver result to caller
cb(err, actor);
// 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
}
}
);
}
});
} else {
return cb(null, actor);
}
});
}
static fromRemoteQuery(id, cb) {
const headers = {
Accept: 'application/activity+json',
};
getJson(url, { headers }, (err, actor) => {
getJson(id, { headers }, (err, actor) => {
if (err) {
return cb(err);
}
@ -157,9 +194,45 @@ module.exports = class Actor extends ActivityPubObject {
});
}
static fromAccountName(actorName, options, cb) {
static fromCache(id, cb) {
apDb.get(
`SELECT actor_json
FROM actor_cache
WHERE actor_id = ?
LIMIT 1;`,
[id],
(err, row) => {
if (err) {
return cb(err);
}
if (!row) {
return cb(Errors.DoesNotExist());
}
const obj = ActivityPubObject.fromJsonString(row.actor_json);
if (!obj || !obj.isValid()) {
return cb(Errors.Invalid('Failed to create ActivityPub object'));
}
const actor = new Actor(obj);
if (!actor.isValid()) {
return cb(Errors.Invalid('Failed to create Actor object'));
}
return cb(null, actor);
}
);
}
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) => {
if (err) {
return cb(err);
@ -182,12 +255,7 @@ module.exports = class Actor extends ActivityPubObject {
}
// we can now query the href value for an Actor
return Actor.fromRemoteUrl(activityLink.href, cb);
return Actor.fromId(activityLink.href, cb);
});
}
static fromJsonString(json) {
const parsed = JSON.parse(json);
return new Actor(parsed);
}
};

View File

@ -2,7 +2,9 @@ const { makeUserUrl } = require('./util');
const ActivityPubObject = require('./object');
const apDb = require('../database').dbs.activitypub;
const { getISOTimestampString } = require('../database');
const { Errors } = require('../enig_error.js');
// deps
const { isString, get, isObject } = require('lodash');
const APPublicCollectionId = 'https://www.w3.org/ns/activitystreams#Public';
@ -86,18 +88,62 @@ module.exports = class Collection extends ActivityPubObject {
);
}
static getOrdered(name, owningUser, includePrivate, page, mapper, webServer, cb) {
static embeddedObjById(collectionName, includePrivate, objectId, cb) {
const privateQuery = includePrivate ? '' : ' AND is_private = FALSE';
apDb.get(
`SELECT obj_json
FROM collection
WHERE name = ?
${privateQuery}
AND json_extract(obj_json, '$.object.id') = ?;`,
[collectionName, objectId],
(err, row) => {
if (err) {
return cb(err);
}
if (!row) {
return cb(
Errors.DoesNotExist(
`No embedded Object with object.id of "${objectId}" found`
)
);
}
const obj = ActivityPubObject.fromJsonString(row.obj_json);
if (!obj) {
return cb(Errors.Invalid('Failed to parse Object JSON'));
}
return cb(null, obj);
}
);
}
static getOrdered(
collectionName,
owningUser,
includePrivate,
page,
mapper,
webServer,
cb
) {
const privateQuery = includePrivate ? '' : ' AND is_private = FALSE';
const followersUrl =
makeUserUrl(webServer, owningUser, '/ap/users/') + `/${name}`;
const owningUserId = isObject(owningUser) ? owningUser.userId : owningUser;
// e.g. http://some.host/_enig/ap/collections/1234/followers
const collectionIdBase =
makeUserUrl(webServer, owningUser, `/ap/collections/${owningUserId}`) +
`/${collectionName}`;
if (!page) {
return apDb.get(
`SELECT COUNT(id) AS count
FROM collection
WHERE user_id = ? AND name = ?${privateQuery};`,
[owningUserId, name],
[owningUserId, collectionName],
(err, row) => {
if (err) {
return cb(err);
@ -112,14 +158,14 @@ module.exports = class Collection extends ActivityPubObject {
let obj;
if (row.count > 0) {
obj = {
id: followersUrl,
id: collectionIdBase,
type: 'OrderedCollection',
first: `${followersUrl}?page=1`,
first: `${collectionIdBase}?page=1`,
totalItems: row.count,
};
} else {
obj = {
id: followersUrl,
id: collectionIdBase,
type: 'OrderedCollection',
totalItems: 0,
orderedItems: [],
@ -137,7 +183,7 @@ module.exports = class Collection extends ActivityPubObject {
FROM collection
WHERE user_id = ? AND name = ?${privateQuery}
ORDER BY timestamp;`,
[owningUserId, name],
[owningUserId, collectionName],
(err, entries) => {
if (err) {
return cb(err);
@ -149,11 +195,11 @@ module.exports = class Collection extends ActivityPubObject {
}
const obj = {
id: `${followersUrl}/page=${page}`,
id: `${collectionIdBase}/page=${page}`,
type: 'OrderedCollectionPage',
totalItems: entries.length,
orderedItems: entries,
partOf: followersUrl,
partOf: collectionIdBase,
};
return cb(null, new Collection(obj));
@ -161,7 +207,7 @@ module.exports = class Collection extends ActivityPubObject {
);
}
static addToCollection(name, owningUser, objectId, obj, isPrivate, cb) {
static addToCollection(collectionName, owningUser, objectId, obj, isPrivate, cb) {
if (!isString(obj)) {
obj = JSON.stringify(obj);
}
@ -171,7 +217,14 @@ module.exports = class Collection extends ActivityPubObject {
apDb.run(
`INSERT OR IGNORE INTO collection (name, timestamp, user_id, obj_id, obj_json, is_private)
VALUES (?, ?, ?, ?, ?, ?);`,
[name, getISOTimestampString(), owningUserId, objectId, obj, isPrivate],
[
collectionName,
getISOTimestampString(),
owningUserId,
objectId,
obj,
isPrivate,
],
function res(err) {
// non-arrow for 'this' scope
if (err) {
@ -182,12 +235,12 @@ module.exports = class Collection extends ActivityPubObject {
);
}
static removeFromCollectionById(name, owningUser, objectId, cb) {
static removeFromCollectionById(collectionName, owningUser, objectId, cb) {
const owningUserId = isObject(owningUser) ? owningUser.userId : owningUser;
apDb.run(
`DELETE FROM collection
WHERE user_id = ? AND name = ? AND obj_id = ?;`,
[owningUserId, name, objectId],
[owningUserId, collectionName, objectId],
err => {
return cb(err);
}

152
core/activitypub/note.js Normal file
View File

@ -0,0 +1,152 @@
const Message = require('../message');
const ActivityPubObject = require('./object');
const { Errors } = require('../enig_error');
const { getISOTimestampString } = require('../database');
const User = require('../user');
const { messageBodyToHtml } = require('./util');
// deps
const { v5: UUIDv5 } = require('uuid');
const Actor = require('./actor');
const moment = require('moment');
const Collection = require('./collection');
const async = require('async');
const APMessageIdNamespace = '307bc7b3-3735-4573-9a20-e3f9eaac29c5';
module.exports = class Note extends ActivityPubObject {
constructor(obj) {
super(obj);
}
isValid() {
if (!super.isValid()) {
return false;
}
// :TODO: validate required properties
return true;
}
static fromPublicNoteId(noteId, cb) {
Collection.embeddedObjById('outbox', false, noteId, (err, obj) => {
if (err) {
return cb(err);
}
return cb(null, new Note(obj.object));
});
}
// A local Message bound for ActivityPub
static fromLocalOutgoingMessage(message, webServer, cb) {
const localUserId = message.getLocalFromUserId();
if (!localUserId) {
return cb(Errors.UnexpectedState('Invalid user ID for local user!'));
}
if (Message.AddressFlavor.ActivityPub !== message.getAddressFlavor()) {
return cb(
Errors.Invalid('Cannot build note for non-ActivityPub addressed message')
);
}
const remoteActorAccount = message.getRemoteToUser();
if (!remoteActorAccount) {
return cb(
Errors.UnexpectedState('Message does not contain a remote address')
);
}
async.waterfall(
[
callback => {
return User.getUser(localUserId, callback);
},
(fromUser, callback) => {
Actor.fromLocalUser(fromUser, webServer, (err, fromActor) => {
return callback(err, fromUser, fromActor);
});
},
(fromUser, fromActor, callback) => {
Actor.fromAccountName(remoteActorAccount, (err, remoteActor) => {
return callback(err, fromUser, fromActor, remoteActor);
});
},
(fromUser, fromActor, remoteActor, callback) => {
const to = message.isPrivate()
? remoteActor.id
: Collection.PublicCollectionId;
// Refs
// - https://docs.joinmastodon.org/spec/activitypub/#properties-used
const obj = {
id: ActivityPubObject.makeObjectId(webServer, 'note'),
type: 'Note',
published: getISOTimestampString(message.modTimestamp),
to,
attributedTo: fromActor.id,
audience: [message.isPrivate() ? 'as:Private' : 'as:Public'],
// :TODO: inReplyto if this is a reply; we need this store in message meta.
content: messageBodyToHtml(message.message.trim()),
};
const note = new Note(obj);
return callback(null, { note, fromUser, remoteActor });
},
],
(err, noteInfo) => {
return cb(err, noteInfo);
}
);
}
toMessage(cb) {
// stable ID based on Note ID
const message = new Message({
uuid: UUIDv5(this.id, APMessageIdNamespace),
});
// Fetch the remote actor
Actor.fromId(this.attributedTo, false, (err, attributedToActor) => {
if (err) {
// :TODO: Log me
message.toUserName = this.attributedTo; // have some sort of value =/
} else {
message.toUserName =
attributedToActor.preferredUsername || this.attributedTo;
}
message.subject = this.summary || '-ActivityPub-';
message.message = this.content; // :TODO: HTML to suitable format, or even strip
try {
message.modTimestamp = moment(this.published);
} catch (e) {
// :TODO: Log warning
message.modTimestamp = moment();
}
// :TODO: areaTag
// :TODO: replyToMsgId from 'inReplyTo'
// :TODO: RemoteFromUser
message.meta[Message.WellKnownMetaCategories.ActivityPub] =
message.meta[Message.WellKnownMetaCategories.ActivityPub] || {};
const apMeta = message.meta[Message.WellKnownAreaTags.ActivityPub];
apMeta[Message.ActivityPubPropertyNames.ActivityId] = this.id;
if (this.InReplyTo) {
apMeta[Message.ActivityPubPropertyNames.InReplyTo] = this.InReplyTo;
}
message.setRemoteFromUser(this.attributedTo);
message.setExternalFlavor(Message.ExternalFlavor.ActivityPub);
return cb(null, message);
});
}
};

View File

@ -0,0 +1,45 @@
const { ActivityStreamsContext } = require('./const');
const { WellKnownLocations } = require('../servers/content/web');
// deps
const { isString } = require('lodash');
const { v4: UUIDv4 } = require('uuid');
module.exports = class ActivityPubObject {
constructor(obj) {
this['@context'] = ActivityStreamsContext;
Object.assign(this, obj);
}
static fromJsonString(s) {
let obj;
try {
obj = JSON.parse(s);
obj = new ActivityPubObject(obj);
} catch (e) {
return null;
}
return obj;
}
isValid() {
const nes = s => isString(s) && s.length > 1;
// :TODO: Additional validation
if (
(this['@context'] === ActivityStreamsContext ||
this['@context'][0] === ActivityStreamsContext) &&
nes(this.id) &&
nes(this.type)
) {
return true;
}
return false;
}
static makeObjectId(webServer, suffix) {
// e.g. http://some.host/_enig/ap/bf81a22e-cb3e-41c8-b114-21f375b61124/activity
return webServer.buildUrl(
WellKnownLocations.Internal + `/ap/${UUIDv4()}/${suffix}`
);
}
};

View File

@ -519,11 +519,19 @@ dbs.message.run(
ON actor_cache (actor_id);`
);
// Mapping of known aliases for a fully qualified Actor ID
// generally obtained via WebFinger
dbs.activitypub.run(
`CREATE INDEX IF NOT EXISTS outbox_activity_json_type_index0
ON outbox (json_extract(activity_json, '$.type'));`
`CREATE TABLE IF NOT EXISTS actor_alias_cache (
id INTEGER PRIMARY KEY,
alias VARCHAR NOT NULL,
actor_id VARCHAR NOT NULL, -- Fully qualified Actor ID/URL
UNIQUE(alias)
);`
);
// ActivityPub Collections of various types such as followers, following, likes, ...
dbs.activitypub.run(
`CREATE TABLE IF NOT EXISTS collection (
id INTEGER PRIMARY KEY, -- Auto-generated key

View File

@ -178,7 +178,25 @@ module.exports = class Message {
}
isFromRemoteUser() {
return null !== _.get(this, 'meta.System.remote_from_user', null);
return null !== this.getRemoteFromUser();
}
setRemoteFromUser(remoteFrom) {
this.meta[Message.WellKnownMetaCategories.System][
Message.SystemMetaNames.RemoteFromUser
] = remoteFrom;
}
getRemoteFromUser() {
return _.get(
this,
[
'meta',
Message.WellKnownMetaCategories.System,
Message.SystemMetaNames.RemoteFromUser,
],
null
);
}
isCP437Encodable() {

View File

@ -8,6 +8,7 @@ const Log = require('../logger').log;
const async = require('async');
const _ = require('lodash');
const Collection = require('../activitypub/collection');
const Note = require('../activitypub/note');
exports.moduleInfo = {
name: 'ActivityPub',
@ -42,18 +43,31 @@ exports.getModule = class ActivityPubScannerTosser extends MessageScanTossModule
async.waterfall(
[
callback => {
return Activity.noteFromLocalMessage(
this._webServer(),
Note.fromLocalOutgoingMessage(
message,
callback
this._webServer(),
(err, noteInfo) => {
return callback(err, noteInfo);
}
);
},
(noteInfo, callback) => {
const { activity, fromUser, remoteActor } = noteInfo;
const { note, fromUser, remoteActor } = noteInfo;
const activity = Activity.makeCreate(
this._webServer(),
note.attributedTo,
note
);
// :TODO: Implement retry logic (connection issues, retryable HTTP status) ??
//const inbox = remoteActor.inbox;
const inbox = remoteActor.endpoints.sharedInbox;
activity.object.to = 'https://www.w3.org/ns/activitystreams#Public';
activity.sendTo(
remoteActor.inbox,
inbox,
fromUser,
this._webServer(),
(err, respBody, res) => {

View File

@ -17,6 +17,7 @@ const _ = require('lodash');
const enigma_assert = require('../../../enigma_assert');
const httpSignature = require('http-signature');
const async = require('async');
const Note = require('../../../activitypub/note');
exports.moduleInfo = {
name: 'ActivityPub',
@ -98,6 +99,13 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
},
});
this.webServer.addRoute({
method: 'GET',
// e.g. http://some.host/_enig/ap/bf81a22e-cb3e-41c8-b114-21f375b61124/note
path: /^\/_enig\/ap\/[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}\/note$/,
handler: this._singlePublicNoteGetHandler.bind(this),
});
// :TODO: NYI
// this.webServer.addRoute({
// method: 'GET',
@ -359,6 +367,22 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
return this._getCollectionHandler('outbox', req, resp, signature);
}
_singlePublicNoteGetHandler(req, resp) {
this.log.debug({ url: req.url }, 'Request for "Note"');
const url = new URL(req.url, `https://${req.headers.host}`);
const noteId = url.toString();
Note.fromPublicNoteId(noteId, (err, note) => {
if (err) {
return this.webServer.internalServerError(resp, err);
}
resp.writeHead(200, { 'Content-Type': 'text/html' });
return resp.end(note.content);
});
}
_accountNameFromUserPath(url, suffix) {
const re = new RegExp(`^/_enig/ap/users/(.+)/${suffix}(\\?page=[0-9]+)?$`);
const m = url.pathname.match(re);
@ -478,7 +502,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
return this.webServer.resourceNotFound(resp);
}
Actor.fromRemoteUrl(activity.actor, (err, actor) => {
Actor.fromId(activity.actor, (err, actor) => {
if (err) {
return this.webServer.internalServerError(resp, err);
}