427 lines
15 KiB
JavaScript
427 lines
15 KiB
JavaScript
const Message = require('../message');
|
|
const ActivityPubObject = require('./object');
|
|
const { Errors } = require('../enig_error');
|
|
const { getISOTimestampString } = require('../database');
|
|
const User = require('../user');
|
|
const {
|
|
parseTimestampOrNow,
|
|
messageToHtml,
|
|
htmlToMessageBody,
|
|
recipientIdsFromObject,
|
|
} = require('./util');
|
|
const { PublicCollectionId } = require('./const');
|
|
const { isAnsi } = require('../string_util');
|
|
|
|
// deps
|
|
const { v5: UUIDv5 } = require('uuid');
|
|
const Actor = require('./actor');
|
|
const Collection = require('./collection');
|
|
const async = require('async');
|
|
const { isString, isObject, truncate } = require('lodash');
|
|
|
|
const PublicMessageIdNamespace = 'a26ae389-5dfb-4b24-a58e-5472085c8e42';
|
|
const APDefaultSummary = '[ActivityPub]';
|
|
|
|
module.exports = class Note extends ActivityPubObject {
|
|
constructor(obj) {
|
|
super(obj, null); // Note are wrapped
|
|
}
|
|
|
|
isValid() {
|
|
if (!super.isValid()) {
|
|
return false;
|
|
}
|
|
|
|
if (this.type !== 'Note') {
|
|
return false;
|
|
}
|
|
|
|
// :TODO: validate required properties
|
|
|
|
return true;
|
|
}
|
|
|
|
recipientIds() {
|
|
return recipientIdsFromObject(this);
|
|
}
|
|
|
|
static fromPublicNoteId(noteId, cb) {
|
|
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));
|
|
});
|
|
}
|
|
|
|
// A local Message bound for ActivityPub
|
|
static fromLocalMessage(message, webServer, cb) {
|
|
const localUserId = message.getLocalFromUserId();
|
|
if (!localUserId) {
|
|
return cb(Errors.UnexpectedState('Invalid user ID for local user!'));
|
|
}
|
|
|
|
const remoteActorAccount = message.getRemoteToUser();
|
|
if (!remoteActorAccount && message.isPrivate()) {
|
|
return cb(
|
|
Errors.UnexpectedState('Message does not contain a remote address')
|
|
);
|
|
}
|
|
|
|
async.waterfall(
|
|
[
|
|
callback => {
|
|
return User.getUser(localUserId, callback);
|
|
},
|
|
(fromUser, callback) => {
|
|
Actor.fromLocalUser(fromUser, (err, fromActor) => {
|
|
return callback(err, fromUser, fromActor);
|
|
});
|
|
},
|
|
(fromUser, fromActor, callback) => {
|
|
if (message.isPrivate()) {
|
|
Actor.fromId(remoteActorAccount, (err, remoteActor) => {
|
|
return callback(err, fromUser, fromActor, remoteActor);
|
|
});
|
|
} else {
|
|
return callback(null, fromUser, fromActor, null);
|
|
}
|
|
},
|
|
(fromUser, fromActor, remoteActor, callback) => {
|
|
if (!message.replyToMsgId) {
|
|
return callback(null, null, fromUser, fromActor, remoteActor);
|
|
}
|
|
|
|
Message.getMetaValuesByMessageId(
|
|
message.replyToMsgId,
|
|
Message.WellKnownMetaCategories.ActivityPub,
|
|
Message.ActivityPubPropertyNames.NoteId,
|
|
(err, replyToNoteId) => {
|
|
// (ignore error)
|
|
return callback(
|
|
null,
|
|
replyToNoteId,
|
|
fromUser,
|
|
fromActor,
|
|
remoteActor
|
|
);
|
|
}
|
|
);
|
|
},
|
|
(replyToNoteId, fromUser, fromActor, remoteActor, callback) => {
|
|
const to = [
|
|
message.isPrivate() ? remoteActor.id : PublicCollectionId,
|
|
];
|
|
|
|
const sourceMediaType = isAnsi(message.message)
|
|
? 'text/x-ansi' // ye ol' https://lists.freedesktop.org/archives/xdg/2006-March/006214.html
|
|
: 'text/plain';
|
|
|
|
// https://docs.joinmastodon.org/spec/activitypub/#properties-used
|
|
const obj = {
|
|
id: ActivityPubObject.makeObjectId('note'),
|
|
type: 'Note',
|
|
published: getISOTimestampString(message.modTimestamp),
|
|
to,
|
|
attributedTo: fromActor.id,
|
|
summary: message.subject.trim(),
|
|
content: messageToHtml(message),
|
|
source: {
|
|
content: message.message,
|
|
mediaType: sourceMediaType,
|
|
},
|
|
sensitive: message.subject.startsWith('[NSFW]'),
|
|
};
|
|
|
|
if (replyToNoteId) {
|
|
obj.inReplyTo = replyToNoteId;
|
|
}
|
|
|
|
const note = new Note(obj);
|
|
const context = ActivityPubObject.makeContext([], {
|
|
sensitive: 'as:sensitive',
|
|
});
|
|
return callback(null, {
|
|
note,
|
|
fromUser,
|
|
remoteActor,
|
|
context,
|
|
});
|
|
},
|
|
],
|
|
(err, noteInfo) => {
|
|
return cb(err, noteInfo);
|
|
}
|
|
);
|
|
}
|
|
|
|
toMessage(options, cb) {
|
|
if (!options.toUser || !isString(options.areaTag)) {
|
|
return cb(Errors.MissingParam('Missing one or more required options!'));
|
|
}
|
|
|
|
const isPrivate = isObject(options.toUser);
|
|
|
|
//
|
|
// Message UUIDs are unique in the message database;
|
|
// However, we may need to deliver a particular message to:
|
|
// - #Public / sharedInbox
|
|
// - 1:N private user inboxes
|
|
//
|
|
// In both cases, the UUID is stable. That is, the same ID
|
|
// will equal the same UUID as to prevent dupes.
|
|
//
|
|
const makeMessageUuid = () => {
|
|
if (isPrivate) {
|
|
// UUID specific to the target user
|
|
const url = `${this.id}/${options.toUser.userId}`;
|
|
return UUIDv5(url, UUIDv5.URL);
|
|
} else {
|
|
return UUIDv5(this.id, PublicMessageIdNamespace);
|
|
}
|
|
};
|
|
|
|
// Fetch the remote actor info to get their user info
|
|
Actor.fromId(this.attributedTo, (err, attributedToActor, fromActorSubject) => {
|
|
if (err) {
|
|
return cb(err);
|
|
}
|
|
|
|
const message = new Message({
|
|
uuid: makeMessageUuid(),
|
|
});
|
|
|
|
message.fromUserName = fromActorSubject || this.attributedTo;
|
|
|
|
//
|
|
// 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
|
|
//
|
|
if (isPrivate) {
|
|
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
|
|
message.message = htmlToMessageBody(
|
|
// try to handle various implementations
|
|
// - https://docs.joinmastodon.org/spec/activitypub/#payloads
|
|
// - https://indieweb.org/post-type-discovery#Algorithm
|
|
this.content || this.name || this.summary
|
|
);
|
|
message.subject = this._getSubject(message);
|
|
|
|
// List all attachments
|
|
if (Array.isArray(this.attachment) && this.attachment.length > 0) {
|
|
let attachmentInfoLines = ['--[Attachments]--'];
|
|
// https://socialhub.activitypub.rocks/t/representing-images/624
|
|
this.attachment.forEach(att => {
|
|
const type = att.mediaType.substring(0, att.mediaType.indexOf('/'));
|
|
switch (type) {
|
|
case 'image':
|
|
{
|
|
let imgInfo;
|
|
if (att.height && att.width) {
|
|
imgInfo = `Image (${att.width}x${att.height})`;
|
|
} else {
|
|
imgInfo = 'Image';
|
|
}
|
|
attachmentInfoLines.push(imgInfo);
|
|
}
|
|
break;
|
|
|
|
case 'audio':
|
|
attachmentInfoLines.push('Audio');
|
|
break;
|
|
|
|
case 'video':
|
|
attachmentInfoLines.push('Video');
|
|
break;
|
|
|
|
default:
|
|
attachmentInfoLines.push(att.mediaType);
|
|
}
|
|
|
|
if (att.name) {
|
|
attachmentInfoLines.push(att.name);
|
|
}
|
|
|
|
attachmentInfoLines.push(att.url);
|
|
attachmentInfoLines.push('');
|
|
attachmentInfoLines.push('');
|
|
});
|
|
|
|
message.message += '\r\n\r\n' + attachmentInfoLines.join('\r\n');
|
|
}
|
|
|
|
// If the Note is marked sensitive, prefix the subject
|
|
if (this.sensitive && message.subject.indexOf('[NSFW]') === -1) {
|
|
message.subject = `[NSFW] ${message.subject}`;
|
|
}
|
|
|
|
message.modTimestamp = parseTimestampOrNow(this.published);
|
|
|
|
message.setRemoteFromUser(this.attributedTo);
|
|
message.setExternalFlavor(Message.AddressFlavor.ActivityPub);
|
|
|
|
message.meta.ActivityPub = message.meta.ActivityPub || {};
|
|
message.meta.ActivityPub[Message.ActivityPubPropertyNames.ActivityId] =
|
|
options.activityId || 0;
|
|
message.meta.ActivityPub[Message.ActivityPubPropertyNames.NoteId] = this.id;
|
|
|
|
if (this.inReplyTo) {
|
|
message.meta.ActivityPub[Message.ActivityPubPropertyNames.InReplyTo] =
|
|
this.inReplyTo;
|
|
|
|
const filter = {
|
|
resultType: 'id',
|
|
metaTuples: [
|
|
{
|
|
category: Message.WellKnownMetaCategories.ActivityPub,
|
|
name: Message.ActivityPubPropertyNames.InReplyTo,
|
|
value: this.inReplyTo,
|
|
},
|
|
],
|
|
limit: 1,
|
|
};
|
|
Message.findMessages(filter, (err, messageId) => {
|
|
if (messageId) {
|
|
// we get an array, but limited 1; use the first
|
|
messageId = messageId[0];
|
|
message.replyToMsgId = messageId;
|
|
}
|
|
|
|
return cb(null, message);
|
|
});
|
|
} else {
|
|
return cb(null, message);
|
|
}
|
|
});
|
|
}
|
|
|
|
toUpdatedMessage(options, cb) {
|
|
const original = new Message();
|
|
original.load({ uuid: options.messageUuid }, err => {
|
|
if (err) {
|
|
return cb(err);
|
|
}
|
|
|
|
// rebuild message
|
|
options.areaTag = original.areaTag;
|
|
|
|
async.waterfall(
|
|
[
|
|
callback => {
|
|
if (!original.isPrivate()) {
|
|
options.toUser = 'All';
|
|
return callback(null);
|
|
}
|
|
|
|
const userId =
|
|
original.meta.System[Message.SystemMetaNames.LocalToUserID];
|
|
if (!userId) {
|
|
return cb(
|
|
Errors.MissingProperty(
|
|
`User is missing "${Message.SystemMetaNames.LocalToUserID}" property`
|
|
)
|
|
);
|
|
}
|
|
|
|
User.getUser(userId, (err, user) => {
|
|
if (err) {
|
|
return callback(err);
|
|
}
|
|
|
|
options.toUser = user;
|
|
return callback(null);
|
|
});
|
|
},
|
|
callback => {
|
|
this.toMessage(options, (err, message) => {
|
|
if (err) {
|
|
return callback(err);
|
|
}
|
|
|
|
// re-target as message to be updated
|
|
message.messageUuid = original.messageUuid;
|
|
|
|
return callback(null, message);
|
|
});
|
|
},
|
|
],
|
|
(err, message) => {
|
|
return cb(err, message);
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
static deleteAssocMessage(noteId, cb) {
|
|
const filter = {
|
|
resultType: 'uuid',
|
|
metaTuples: [
|
|
{
|
|
category: Message.WellKnownMetaCategories.ActivityPub,
|
|
name: Message.ActivityPubPropertyNames.NoteId,
|
|
value: noteId,
|
|
},
|
|
],
|
|
limit: 1,
|
|
};
|
|
|
|
return Message.findMessages(filter, (err, messageUuid) => {
|
|
if (!messageUuid) {
|
|
return cb(null);
|
|
}
|
|
|
|
messageUuid = messageUuid[0]; // limit 1
|
|
|
|
Message.deleteByMessageUuid(messageUuid, err => {
|
|
return cb(err);
|
|
});
|
|
});
|
|
}
|
|
|
|
_getSubject(message) {
|
|
if (this.summary) {
|
|
return this.summary.trim();
|
|
}
|
|
|
|
if (this.name) {
|
|
return this.name.trim();
|
|
}
|
|
|
|
//
|
|
// Build a subject from the message itself:
|
|
// - First few characters of the message, removing the @username
|
|
// prefix, if any
|
|
// - Truncate at the first line feed, the end of the message,
|
|
// or 32 characters in length, whichever comes first
|
|
// - If not end of string, we'll sub in '...'
|
|
//
|
|
let subject = message.message.replace(`@${message.toUserName} `, '').trim();
|
|
const m = /^(.+)\r?\n/.exec(subject);
|
|
if (m && m[1]) {
|
|
subject = m[1];
|
|
}
|
|
|
|
subject = truncate(subject, { length: 32, omission: '...' });
|
|
subject = subject || APDefaultSummary;
|
|
return subject;
|
|
}
|
|
};
|