From 99ae973396284cf3fd52ee275cb585b4e3062a0d Mon Sep 17 00:00:00 2001 From: Bryan Ashby Date: Sat, 4 Feb 2023 22:55:11 -0700 Subject: [PATCH] Handle Update of Notes, store Activites as-is, better shared mailbox delivery and DRY --- core/activitypub/collection.js | 67 +++-- core/activitypub/note.js | 39 +-- core/activitypub/util.js | 24 +- core/config_default.js | 5 + core/message_const.js | 1 + .../content/web_handlers/activitypub.js | 248 +++++++++++++----- 6 files changed, 273 insertions(+), 111 deletions(-) diff --git a/core/activitypub/collection.js b/core/activitypub/collection.js index 493eec14..c62c8527 100644 --- a/core/activitypub/collection.js +++ b/core/activitypub/collection.js @@ -1,4 +1,4 @@ -const { makeUserUrl } = require('./util'); +const { makeUserUrl, parseTimestampOrNow } = require('./util'); const ActivityPubObject = require('./object'); const apDb = require('../database').dbs.activitypub; 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( - 'publicInbox', + 'sharedInbox', null, // N/A Collection.PublicCollectionId, inboxItem.id, @@ -115,27 +115,20 @@ module.exports = class Collection extends ActivityPubObject { ); } - static embeddedObjById(collectionName, includePrivate, objectId, cb) { - const privateQuery = includePrivate ? '' : ' AND is_private = FALSE'; - + static objectById(objectId, cb) { apDb.get( - `SELECT object_json + `SELECT name, timestamp, owner_actor_id, object_json, is_private FROM collection - WHERE name = ? - ${privateQuery} - AND json_extract(object_json, '$.object.id') = ?;`, - [collectionName, objectId], + WHERE name = ? AND object_id = ? + LIMIT 1;`, + [objectId], (err, row) => { if (err) { return cb(err); } if (!row) { - return cb( - Errors.DoesNotExist( - `No embedded Object with object.id of "${objectId}" found` - ) - ); + return cb(null, null); } 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(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); } - // :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( `UPDATE collection 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); if (!actorId) { 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, + }; + } }; diff --git a/core/activitypub/note.js b/core/activitypub/note.js index b562ebf3..566515b1 100644 --- a/core/activitypub/note.js +++ b/core/activitypub/note.js @@ -3,14 +3,12 @@ const ActivityPubObject = require('./object'); const { Errors } = require('../enig_error'); const { getISOTimestampString } = require('../database'); const User = require('../user'); -const { messageToHtml, htmlToMessageBody } = require('./util'); +const { parseTimestampOrNow, messageToHtml, htmlToMessageBody } = require('./util'); const { isAnsi } = require('../string_util'); -const Log = require('../logger').log; // deps const { v5: UUIDv5 } = require('uuid'); const Actor = require('./actor'); -const moment = require('moment'); const Collection = require('./collection'); const async = require('async'); const { isString, isObject, truncate } = require('lodash'); @@ -34,11 +32,19 @@ module.exports = class Note extends ActivityPubObject { } static fromPublicNoteId(noteId, cb) { - Collection.embeddedObjById('outbox', false, noteId, (err, obj) => { + Collection.objectByEmbeddedId(noteId, (err, obj, objInfo) => { if (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)); }); } @@ -117,7 +123,7 @@ module.exports = class Note extends ActivityPubObject { published: getISOTimestampString(message.modTimestamp), to, attributedTo: fromActor.id, - content: messageToHtml(message, remoteActor), + content: messageToHtml(message), source: { content: message.message, mediaType: sourceMediaType, @@ -144,7 +150,7 @@ module.exports = class Note extends ActivityPubObject { } 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!')); } @@ -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 // relationship. This method requires the mapping up front via options // - message.toUserName = options.toUser.username; - message.meta.System[Message.SystemMetaNames.LocalToUserID] = - options.toUser.userId; + if (isObject(options.toUser)) { + message.toUserName = options.toUser.username; + message.meta.System[Message.SystemMetaNames.LocalToUserID] = + options.toUser.userId; + } else { + message.toUser = 'All'; + } + message.areaTag = options.areaTag || Message.WellKnownAreaTags.Private; // :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}`; } - try { - 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.modTimestamp = parseTimestampOrNow(this.published); message.setRemoteFromUser(this.attributedTo); message.setExternalFlavor(Message.AddressFlavor.ActivityPub); diff --git a/core/activitypub/util.js b/core/activitypub/util.js index 517f2315..a753eec5 100644 --- a/core/activitypub/util.js +++ b/core/activitypub/util.js @@ -15,8 +15,11 @@ const moment = require('moment'); const { striptags } = require('striptags'); const { encode, decode } = require('html-entities'); const { isString } = require('lodash'); +const Log = require('../logger').log; exports.ActivityStreamsContext = 'https://www.w3.org/ns/activitystreams'; + +exports.parseTimestampOrNow = parseTimestampOrNow; exports.isValidLink = isValidLink; exports.makeSharedInboxUrl = makeSharedInboxUrl; exports.makeUserUrl = makeUserUrl; @@ -42,6 +45,15 @@ Affiliations: %AFFILIATIONS% 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) { return /^https?:\/\/.+$/.test(l); } @@ -215,20 +227,12 @@ function messageBodyToHtml(body) { // - https://indieweb.org/note // - https://docs.joinmastodon.org/spec/microformats/ // -function messageToHtml(message, remoteActor) { +function messageToHtml(message) { const msg = encode(stripAnsiControlCodes(message.message.trim()), { mode: 'nonAsciiPrintable', }).replace(/\r?\n/g, '
'); - if (message.isPrivate()) { - const toId = remoteActor.id; - return `

- - @ ${remoteActor.preferredUsername} - -${msg} -

`; - } + // :TODO: figure out any microformats we should use here... return `

${msg}

`; } diff --git a/core/config_default.js b/core/config_default.js index a50d78b7..60885f90 100644 --- a/core/config_default.js +++ b/core/config_default.js @@ -904,6 +904,11 @@ module.exports = () => { name: 'System Bulletins', desc: 'Bulletin messages for all users', }, + + activitypub_shared_inbox: { + name: 'ActivityPub sharedInbox', + desc: 'Public shared inbox for ActivityPub', + }, }, }, }, diff --git a/core/message_const.js b/core/message_const.js index 78d83d19..b67992b8 100644 --- a/core/message_const.js +++ b/core/message_const.js @@ -2,6 +2,7 @@ const WellKnownAreaTags = { Invalid: '', Private: 'private_mail', Bulletin: 'local_bulletin', + ActivityPubSharedInbox: 'activitypub_shared_inbox', }; exports.WellKnownAreaTags = WellKnownAreaTags; diff --git a/core/servers/content/web_handlers/activitypub.js b/core/servers/content/web_handlers/activitypub.js index 94bcc3f1..3527233d 100644 --- a/core/servers/content/web_handlers/activitypub.js +++ b/core/servers/content/web_handlers/activitypub.js @@ -216,8 +216,18 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { resp ); + case 'Delete': + return this._collectionRequestHandler( + signature, + 'inbox', + activity, + this._inboxDeleteRequestHandler.bind(this), + req, + resp + ); + case 'Update': - return this._inboxUpdateRequestHandler(activity, req, resp); + return this.inboxUpdateObject('inbox', req, resp, activity); case 'Undo': return this._collectionRequestHandler( @@ -269,6 +279,9 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { case 'Create': return this._sharedInboxCreateActivity(req, resp, activity); + case 'Update': + return this.inboxUpdateObject('sharedInbox', req, resp, activity); + default: this.log.warn( { 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) { // When an object is being delivered to the originating actor's followers, // a server MAY reduce the number of receiving actors delivered to by @@ -313,10 +400,15 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { (actorId, nextActor) => { switch (actorId) { case Collection.PublicCollectionId: - // :TODO: we should probably land this in a public areaTag as well for AP; allowing Message objects to be used/etc. - Collection.addPublicInboxItem(note, true, err => { - return nextActor(err); - }); + this._deliverInboxNoteToSharedInbox( + req, + resp, + activity, + note, + err => { + return nextActor(err); + } + ); break; 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) { userFromActorId(actorId, (err, localUser) => { if (err) { 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) { return cb(err); } - // - // 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: activity.id, - 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); - }); - }); + return this._storeNoteAsMessage( + activity.id, + localUser, + Message.WellKnownAreaTags.Private, + note, + cb + ); }); }); } @@ -442,6 +562,10 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { return this.webServer.internalServerError(resp, err); } + if (!note) { + return this.webServer.resourceNotFound(resp); + } + // :TODO: support a template here resp.writeHead(200, { 'Content-Type': 'text/html' }); @@ -527,13 +651,16 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { } } - _inboxUpdateRequestHandler(activity, req, resp) { - this.log.info({ actor: activity.actor }, 'Update Activity request'); + // :TODO: DRY: update/delete are mostly the same code other than the final operation + _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) { @@ -547,27 +674,22 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { return this.webServer.notImplemented(resp); } - Collection.removeFromCollectionById( - 'followers', - localUser, - 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); + Collection.removeById('followers', localUser, 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); + }); } _collectionRequestHandler(