Handle Update of Notes, store Activites as-is, better shared mailbox delivery and DRY

This commit is contained in:
Bryan Ashby 2023-02-04 22:55:11 -07:00
parent c3335ce062
commit 99ae973396
No known key found for this signature in database
GPG Key ID: C2C1B501E4EFD994
6 changed files with 273 additions and 111 deletions

View File

@ -1,4 +1,4 @@
const { makeUserUrl } = require('./util'); const { makeUserUrl, parseTimestampOrNow } = require('./util');
const ActivityPubObject = require('./object'); const ActivityPubObject = require('./object');
const apDb = require('../database').dbs.activitypub; const apDb = require('../database').dbs.activitypub;
const { getISOTimestampString } = require('../database'); const { getISOTimestampString } = require('../database');
@ -102,9 +102,9 @@ module.exports = class Collection extends ActivityPubObject {
); );
} }
static addPublicInboxItem(inboxItem, ignoreDupes, cb) { static addSharedInboxItem(inboxItem, ignoreDupes, cb) {
return Collection.addToCollection( return Collection.addToCollection(
'publicInbox', 'sharedInbox',
null, // N/A null, // N/A
Collection.PublicCollectionId, Collection.PublicCollectionId,
inboxItem.id, inboxItem.id,
@ -115,27 +115,20 @@ module.exports = class Collection extends ActivityPubObject {
); );
} }
static embeddedObjById(collectionName, includePrivate, objectId, cb) { static objectById(objectId, cb) {
const privateQuery = includePrivate ? '' : ' AND is_private = FALSE';
apDb.get( apDb.get(
`SELECT object_json `SELECT name, timestamp, owner_actor_id, object_json, is_private
FROM collection FROM collection
WHERE name = ? WHERE name = ? AND object_id = ?
${privateQuery} LIMIT 1;`,
AND json_extract(object_json, '$.object.id') = ?;`, [objectId],
[collectionName, objectId],
(err, row) => { (err, row) => {
if (err) { if (err) {
return cb(err); return cb(err);
} }
if (!row) { if (!row) {
return cb( return cb(null, null);
Errors.DoesNotExist(
`No embedded Object with object.id of "${objectId}" found`
)
);
} }
const obj = ActivityPubObject.fromJsonString(row.object_json); const obj = ActivityPubObject.fromJsonString(row.object_json);
@ -143,7 +136,34 @@ module.exports = class Collection extends ActivityPubObject {
return cb(Errors.Invalid('Failed to parse Object JSON')); return cb(Errors.Invalid('Failed to parse Object JSON'));
} }
return cb(null, obj); return cb(null, obj, Collection._rowToObjectInfo(row));
}
);
}
static objectByEmbeddedId(objectId, cb) {
apDb.get(
`SELECT name, timestamp, owner_actor_id, object_json, is_private
FROM collection
WHERE json_extract(object_json, '$.object.id') = ?
LIMIT 1;`,
[objectId],
(err, row) => {
if (err) {
return cb(err);
}
if (!row) {
// no match
return cb(null, null);
}
const obj = ActivityPubObject.fromJsonString(row.object_json);
if (!obj) {
return cb(Errors.Invalid('Failed to parse Object JSON'));
}
return cb(null, obj, Collection._rowToObjectInfo(row));
} }
); );
} }
@ -310,8 +330,6 @@ module.exports = class Collection extends ActivityPubObject {
obj = JSON.stringify(obj); obj = JSON.stringify(obj);
} }
// :TODO: The receiving server MUST take care to be sure that the Update is authorized to modify its object. At minimum, this may be done by ensuring that the Update and its object are of same origin.
apDb.run( apDb.run(
`UPDATE collection `UPDATE collection
SET object_json = ?, timestamp = ? SET object_json = ?, timestamp = ?
@ -378,7 +396,7 @@ module.exports = class Collection extends ActivityPubObject {
); );
} }
static removeFromCollectionById(collectionName, owningUser, objectId, cb) { static removeById(collectionName, owningUser, objectId, cb) {
const actorId = owningUser.getProperty(UserProps.ActivityPubActorId); const actorId = owningUser.getProperty(UserProps.ActivityPubActorId);
if (!actorId) { if (!actorId) {
return cb( return cb(
@ -396,4 +414,13 @@ module.exports = class Collection extends ActivityPubObject {
} }
); );
} }
static _rowToObjectInfo(row) {
return {
name: row.name,
timestamp: parseTimestampOrNow(row.timestamp),
ownerActorId: row.owner_actor_id,
isPrivate: row.is_private,
};
}
}; };

View File

@ -3,14 +3,12 @@ const ActivityPubObject = require('./object');
const { Errors } = require('../enig_error'); const { Errors } = require('../enig_error');
const { getISOTimestampString } = require('../database'); const { getISOTimestampString } = require('../database');
const User = require('../user'); const User = require('../user');
const { messageToHtml, htmlToMessageBody } = require('./util'); const { parseTimestampOrNow, messageToHtml, htmlToMessageBody } = require('./util');
const { isAnsi } = require('../string_util'); const { isAnsi } = require('../string_util');
const Log = require('../logger').log;
// deps // deps
const { v5: UUIDv5 } = require('uuid'); const { v5: UUIDv5 } = require('uuid');
const Actor = require('./actor'); const Actor = require('./actor');
const moment = require('moment');
const Collection = require('./collection'); const Collection = require('./collection');
const async = require('async'); const async = require('async');
const { isString, isObject, truncate } = require('lodash'); const { isString, isObject, truncate } = require('lodash');
@ -34,11 +32,19 @@ module.exports = class Note extends ActivityPubObject {
} }
static fromPublicNoteId(noteId, cb) { static fromPublicNoteId(noteId, cb) {
Collection.embeddedObjById('outbox', false, noteId, (err, obj) => { Collection.objectByEmbeddedId(noteId, (err, obj, objInfo) => {
if (err) { if (err) {
return cb(err); return cb(err);
} }
if (!obj) {
return cb(null, null);
}
if (objInfo.isPrivate || !obj.object || obj.object.type !== 'Note') {
return cb(null, null);
}
return cb(null, new Note(obj.object)); return cb(null, new Note(obj.object));
}); });
} }
@ -117,7 +123,7 @@ module.exports = class Note extends ActivityPubObject {
published: getISOTimestampString(message.modTimestamp), published: getISOTimestampString(message.modTimestamp),
to, to,
attributedTo: fromActor.id, attributedTo: fromActor.id,
content: messageToHtml(message, remoteActor), content: messageToHtml(message),
source: { source: {
content: message.message, content: message.message,
mediaType: sourceMediaType, mediaType: sourceMediaType,
@ -144,7 +150,7 @@ module.exports = class Note extends ActivityPubObject {
} }
toMessage(options, cb) { toMessage(options, cb) {
if (!isObject(options.toUser) || !isString(options.areaTag)) { if (!options.toUser || !isString(options.areaTag)) {
return cb(Errors.MissingParam('Missing one or more required options!')); return cb(Errors.MissingParam('Missing one or more required options!'));
} }
@ -165,9 +171,14 @@ module.exports = class Note extends ActivityPubObject {
// Note's can be addressed to 1:N users, but a Message is a 1:1 // 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 // relationship. This method requires the mapping up front via options
// //
message.toUserName = options.toUser.username; if (isObject(options.toUser)) {
message.meta.System[Message.SystemMetaNames.LocalToUserID] = message.toUserName = options.toUser.username;
options.toUser.userId; message.meta.System[Message.SystemMetaNames.LocalToUserID] =
options.toUser.userId;
} else {
message.toUser = 'All';
}
message.areaTag = options.areaTag || Message.WellKnownAreaTags.Private; message.areaTag = options.areaTag || Message.WellKnownAreaTags.Private;
// :TODO: it would be better to do some basic HTML to ANSI or pipe codes perhaps // :TODO: it would be better to do some basic HTML to ANSI or pipe codes perhaps
@ -227,15 +238,7 @@ module.exports = class Note extends ActivityPubObject {
message.subject = `[NSFW] ${message.subject}`; message.subject = `[NSFW] ${message.subject}`;
} }
try { message.modTimestamp = parseTimestampOrNow(this.published);
message.modTimestamp = moment(this.published);
} catch (e) {
Log.warn(
{ published: this.published, error: e.message },
'Failed to parse Note published timestamp'
);
message.modTimestamp = moment();
}
message.setRemoteFromUser(this.attributedTo); message.setRemoteFromUser(this.attributedTo);
message.setExternalFlavor(Message.AddressFlavor.ActivityPub); message.setExternalFlavor(Message.AddressFlavor.ActivityPub);

View File

@ -15,8 +15,11 @@ const moment = require('moment');
const { striptags } = require('striptags'); const { striptags } = require('striptags');
const { encode, decode } = require('html-entities'); const { encode, decode } = require('html-entities');
const { isString } = require('lodash'); const { isString } = require('lodash');
const Log = require('../logger').log;
exports.ActivityStreamsContext = 'https://www.w3.org/ns/activitystreams'; exports.ActivityStreamsContext = 'https://www.w3.org/ns/activitystreams';
exports.parseTimestampOrNow = parseTimestampOrNow;
exports.isValidLink = isValidLink; exports.isValidLink = isValidLink;
exports.makeSharedInboxUrl = makeSharedInboxUrl; exports.makeSharedInboxUrl = makeSharedInboxUrl;
exports.makeUserUrl = makeUserUrl; exports.makeUserUrl = makeUserUrl;
@ -42,6 +45,15 @@ Affiliations: %AFFILIATIONS%
Achievement Points: %ACHIEVEMENT_POINTS% Achievement Points: %ACHIEVEMENT_POINTS%
`; `;
function parseTimestampOrNow(s) {
try {
return moment(s);
} catch (e) {
Log.warn({ error: e.message }, `Failed parsing timestamp "${s}"`);
return moment();
}
}
function isValidLink(l) { function isValidLink(l) {
return /^https?:\/\/.+$/.test(l); return /^https?:\/\/.+$/.test(l);
} }
@ -215,20 +227,12 @@ function messageBodyToHtml(body) {
// - https://indieweb.org/note // - https://indieweb.org/note
// - https://docs.joinmastodon.org/spec/microformats/ // - https://docs.joinmastodon.org/spec/microformats/
// //
function messageToHtml(message, remoteActor) { function messageToHtml(message) {
const msg = encode(stripAnsiControlCodes(message.message.trim()), { const msg = encode(stripAnsiControlCodes(message.message.trim()), {
mode: 'nonAsciiPrintable', mode: 'nonAsciiPrintable',
}).replace(/\r?\n/g, '<br>'); }).replace(/\r?\n/g, '<br>');
if (message.isPrivate()) { // :TODO: figure out any microformats we should use here...
const toId = remoteActor.id;
return `<p>
<span class="h-card">
<a href="${toId}" class="u-url mention">@ <span>${remoteActor.preferredUsername}</span></a>
</span>
${msg}
</p>`;
}
return `<p>${msg}</p>`; return `<p>${msg}</p>`;
} }

View File

@ -904,6 +904,11 @@ module.exports = () => {
name: 'System Bulletins', name: 'System Bulletins',
desc: 'Bulletin messages for all users', desc: 'Bulletin messages for all users',
}, },
activitypub_shared_inbox: {
name: 'ActivityPub sharedInbox',
desc: 'Public shared inbox for ActivityPub',
},
}, },
}, },
}, },

View File

@ -2,6 +2,7 @@ const WellKnownAreaTags = {
Invalid: '', Invalid: '',
Private: 'private_mail', Private: 'private_mail',
Bulletin: 'local_bulletin', Bulletin: 'local_bulletin',
ActivityPubSharedInbox: 'activitypub_shared_inbox',
}; };
exports.WellKnownAreaTags = WellKnownAreaTags; exports.WellKnownAreaTags = WellKnownAreaTags;

View File

@ -216,8 +216,18 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
resp resp
); );
case 'Delete':
return this._collectionRequestHandler(
signature,
'inbox',
activity,
this._inboxDeleteRequestHandler.bind(this),
req,
resp
);
case 'Update': case 'Update':
return this._inboxUpdateRequestHandler(activity, req, resp); return this.inboxUpdateObject('inbox', req, resp, activity);
case 'Undo': case 'Undo':
return this._collectionRequestHandler( return this._collectionRequestHandler(
@ -269,6 +279,9 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
case 'Create': case 'Create':
return this._sharedInboxCreateActivity(req, resp, activity); return this._sharedInboxCreateActivity(req, resp, activity);
case 'Update':
return this.inboxUpdateObject('sharedInbox', req, resp, activity);
default: default:
this.log.warn( this.log.warn(
{ type: activity.type }, { type: activity.type },
@ -295,6 +308,80 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
} }
} }
inboxUpdateObject(inboxType, req, resp, activity) {
const objectIdToUpdate = _.get(activity, 'object.id');
const objectType = _.get(activity, 'object.type');
this.log.info(
{ inboxType, objectId: objectIdToUpdate, type: objectType },
'Inbox Object "Update" request'
);
// :TODO: other types...
if (!objectIdToUpdate || !['Note'].includes(objectType)) {
return this.webServer.resourceNotFound(resp);
}
// Note's are wrapped in Create Activities
Collection.objectByEmbeddedId(objectIdToUpdate, (err, obj) => {
if (err) {
return this.webServer.internalServerError(resp, err);
}
if (!obj) {
// no match
return this.webServer.resourceNotFound(resp);
}
// OK, the object exists; Does the caller have permission
// to update? The origin must match
//
// "The receiving server MUST take care to be sure that the Update is authorized
// to modify its object. At minimum, this may be done by ensuring that the Update
// and its object are of same origin."
try {
const updateTargetUrl = new URL(obj.object.id);
const updaterUrl = new URL(activity.actor);
if (updateTargetUrl.host !== updaterUrl.host) {
this.log.warn(
{
objectId: objectIdToUpdate,
type: objectType,
updateTargetHost: updateTargetUrl.host,
requestorHost: updaterUrl.host,
},
'Attempt to update object from another origin'
);
return this.webServer.accessDenied(resp);
}
Collection.updateCollectionEntry(
'inbox',
objectIdToUpdate,
activity,
err => {
if (err) {
return this.webServer.internalServerError(resp, err);
}
this.log.info(
{
objectId: objectIdToUpdate,
type: objectType,
collection: 'inbox',
},
'Object updated'
);
return this.webServer.accepted(resp);
}
);
} catch (e) {
return this.webServer.internalServerError(resp, e);
}
});
}
_deliverSharedInboxNote(req, resp, deliverTo, activity) { _deliverSharedInboxNote(req, resp, deliverTo, activity) {
// When an object is being delivered to the originating actor's followers, // When an object is being delivered to the originating actor's followers,
// a server MAY reduce the number of receiving actors delivered to by // a server MAY reduce the number of receiving actors delivered to by
@ -313,10 +400,15 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
(actorId, nextActor) => { (actorId, nextActor) => {
switch (actorId) { switch (actorId) {
case Collection.PublicCollectionId: case Collection.PublicCollectionId:
// :TODO: we should probably land this in a public areaTag as well for AP; allowing Message objects to be used/etc. this._deliverInboxNoteToSharedInbox(
Collection.addPublicInboxItem(note, true, err => { req,
return nextActor(err); resp,
}); activity,
note,
err => {
return nextActor(err);
}
);
break; break;
default: default:
@ -343,49 +435,77 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
); );
} }
_deliverInboxNoteToSharedInbox(req, resp, activity, note, cb) {
Collection.addSharedInboxItem(activity, true, err => {
if (err) {
return cb(err);
}
return this._storeNoteAsMessage(
activity.id,
'All',
Message.WellKnownAreaTags.ActivityPubSharedInbox,
note,
cb
);
});
}
_storeNoteAsMessage(activityId, localAddressedTo, areaTag, note, cb) {
//
// Import the item to the user's private mailbox
//
const messageOpts = {
// Notes can have 1:N 'to' relationships while a Message is 1:1;
activityId,
toUser: localAddressedTo,
areaTag: areaTag,
};
note.toMessage(messageOpts, (err, message) => {
if (err) {
return cb(err);
}
message.persist(err => {
if (!err) {
if (_.isObject(localAddressedTo)) {
localAddressedTo = localAddressedTo.username;
}
this.log.info(
{
localAddressedTo,
activityId,
noteId: note.id,
},
'Note delivered as message to private mailbox'
);
} else if (err.code === 'SQLITE_CONSTRAINT') {
return cb(null);
}
return cb(err);
});
});
}
_deliverInboxNoteToLocalActor(req, resp, actorId, activity, note, cb) { _deliverInboxNoteToLocalActor(req, resp, actorId, activity, note, cb) {
userFromActorId(actorId, (err, localUser) => { userFromActorId(actorId, (err, localUser) => {
if (err) { if (err) {
return cb(null); // not found/etc., just bail return cb(null); // not found/etc., just bail
} }
Collection.addInboxItem(note, localUser, this.webServer, false, err => { Collection.addInboxItem(activity, localUser, this.webServer, false, err => {
if (err) { if (err) {
return cb(err); return cb(err);
} }
// return this._storeNoteAsMessage(
// Import the item to the user's private mailbox activity.id,
// localUser,
const messageOpts = { Message.WellKnownAreaTags.Private,
// Notes can have 1:N 'to' relationships while a Message is 1:1; note,
activityId: activity.id, cb
toUser: localUser, );
areaTag: Message.WellKnownAreaTags.Private,
};
note.toMessage(messageOpts, (err, message) => {
if (err) {
return cb(err);
}
message.persist(err => {
if (!err) {
this.log.info(
{
user: localUser.username,
userId: localUser.userId,
activityId: activity.id,
noteId: note.id,
},
'Note delivered as message to private mailbox'
);
} else if (err.code === 'SQLITE_CONSTRAINT') {
return cb(null);
}
return cb(err);
});
});
}); });
}); });
} }
@ -442,6 +562,10 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
return this.webServer.internalServerError(resp, err); return this.webServer.internalServerError(resp, err);
} }
if (!note) {
return this.webServer.resourceNotFound(resp);
}
// :TODO: support a template here // :TODO: support a template here
resp.writeHead(200, { 'Content-Type': 'text/html' }); resp.writeHead(200, { 'Content-Type': 'text/html' });
@ -527,13 +651,16 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
} }
} }
_inboxUpdateRequestHandler(activity, req, resp) { // :TODO: DRY: update/delete are mostly the same code other than the final operation
this.log.info({ actor: activity.actor }, 'Update Activity request'); _inboxDeleteRequestHandler(activity, remoteActor, localUser, resp) {
this.log.info(
{ user_id: localUser.userId, actor: activity.actor },
'Delete request'
);
return this.webServer.notImplemented(resp); // :TODO:only delete if it's owned by the sender
// Collection.updateCollectionEntry('inbox', activity.id, activity, err => { return this.webServer.accepted(resp);
// });
} }
_inboxUndoRequestHandler(activity, remoteActor, localUser, resp) { _inboxUndoRequestHandler(activity, remoteActor, localUser, resp) {
@ -547,27 +674,22 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
return this.webServer.notImplemented(resp); return this.webServer.notImplemented(resp);
} }
Collection.removeFromCollectionById( Collection.removeById('followers', localUser, remoteActor.id, err => {
'followers', if (err) {
localUser, return this.webServer.internalServerError(resp, err);
remoteActor.id,
err => {
if (err) {
return this.webServer.internalServerError(resp, err);
}
this.log.info(
{
username: localUser.username,
userId: localUser.userId,
actor: remoteActor.id,
},
'Undo "Follow" (un-follow) success'
);
return this.webServer.accepted(resp);
} }
);
this.log.info(
{
username: localUser.username,
userId: localUser.userId,
actor: remoteActor.id,
},
'Undo "Follow" (un-follow) success'
);
return this.webServer.accepted(resp);
});
} }
_collectionRequestHandler( _collectionRequestHandler(