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 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,
};
}
};

View File

@ -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);

View File

@ -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, '<br>');
if (message.isPrivate()) {
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>`;
}
// :TODO: figure out any microformats we should use here...
return `<p>${msg}</p>`;
}

View File

@ -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',
},
},
},
},

View File

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

View File

@ -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(