Handle Update of Notes, store Activites as-is, better shared mailbox delivery and DRY
This commit is contained in:
parent
c3335ce062
commit
99ae973396
|
@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>`;
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -2,6 +2,7 @@ const WellKnownAreaTags = {
|
|||
Invalid: '',
|
||||
Private: 'private_mail',
|
||||
Bulletin: 'local_bulletin',
|
||||
ActivityPubSharedInbox: 'activitypub_shared_inbox',
|
||||
};
|
||||
exports.WellKnownAreaTags = WellKnownAreaTags;
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Reference in New Issue