ActivityPub Social Manager, and many updates to Search functionality

* Manage Following/Followers (WIP, bugs, some missing functionality)
* Search for Actors (WIP, some bugs)
This commit is contained in:
Bryan Ashby 2023-02-17 21:46:24 -07:00
parent 2aec375bee
commit e8c42a9b2e
No known key found for this signature in database
GPG Key ID: C2C1B501E4EFD994
14 changed files with 922 additions and 133 deletions

View File

@ -484,16 +484,42 @@
} }
} }
activityPubFollowingManager: {
config: {
selectedActorInfoFormat: "|00|15{preferredUsername}\n|02{name}\n{summary}"
statusIndicatorEnabled: "|00|10√"
staticIndicatorDisabled: "|00|12X"
}
0: {
mci: {
VM1: {
height: 15
width: 30
itemFormat: "|00|03{subject}|00 {statusIndicator}"
focusItemFormat: "|00|19|15{subject}|00 {statusIndicator}"
}
MT2: {
height: 15
width: 32
}
TM3: {
focusTextStyle: first upper
}
}
}
}
// :TODO: move this to the right area, rename, etc. // :TODO: move this to the right area, rename, etc.
mainMenuActivityPubActorSearch: { mainMenuActivityPubActorSearch: {
config: {
followingIndicator: "|00|14> |10Following|08!|14 <"
notFollowingIndicator: "|00|14> |02Not Following|14 <"
viewInfoFormat10: "{actorFollowingIndicator}"
}
0: { 0: {
mci: { mci: {
TL1: { TL1: {
width: 70 width: 70
}
BT2: {
width: 20
focusTextStyle: upper
submit: true submit: true
} }
} }
@ -524,14 +550,9 @@
height: 3 height: 3
mode: preview mode: preview
} }
BT8: { TL10: {
focusTextStyle: upper width: 24
submit: true
}
BT9: {
text: back
focusTextStyle: upper
submit: true
} }
} }
} }

View File

@ -11,7 +11,7 @@ const { queryWebFinger } = require('../webfinger');
const EnigAssert = require('../enigma_assert'); const EnigAssert = require('../enigma_assert');
const ActivityPubSettings = require('./settings'); const ActivityPubSettings = require('./settings');
const ActivityPubObject = require('./object'); const ActivityPubObject = require('./object');
const { ActivityStreamMediaType } = require('./const'); const { ActivityStreamMediaType, Collections } = require('./const');
const apDb = require('../database').dbs.activitypub; const apDb = require('../database').dbs.activitypub;
const Config = require('../config').get; const Config = require('../config').get;
@ -73,7 +73,12 @@ module.exports = class Actor extends ActivityPubObject {
} }
static get WellKnownLinkTypes() { static get WellKnownLinkTypes() {
return ['inbox', 'outbox', 'following', 'followers']; return [
Collections.Inbox,
Collections.Outbox,
Collections.Following,
Collections.Followers,
];
} }
static fromLocalUser(user, webServer, cb) { static fromLocalUser(user, webServer, cb) {

View File

@ -3,11 +3,15 @@ const { Errors } = require('../enig_error');
const Actor = require('../activitypub/actor'); const Actor = require('../activitypub/actor');
const moment = require('moment'); const moment = require('moment');
const { htmlToMessageBody } = require('./util'); const { htmlToMessageBody } = require('./util');
const { Collections } = require('./const');
const Collection = require('./collection'); const Collection = require('./collection');
const EnigAssert = require('../enigma_assert');
const { sendFollowRequest, sendUnfollowRequest } = require('./follow_util');
const { getServer } = require('../listening_server');
// deps // deps
const async = require('async'); const async = require('async');
const { get, truncate, isEmpty } = require('lodash'); const { get, isEmpty, isObject, cloneDeep } = require('lodash');
exports.moduleInfo = { exports.moduleInfo = {
name: 'ActivityPub Actor Search', name: 'ActivityPub Actor Search',
@ -22,8 +26,7 @@ const FormIds = {
const MciViewIds = { const MciViewIds = {
main: { main: {
searchUrl: 1, searchQuery: 1,
searchButton: 2,
}, },
view: { view: {
userName: 1, userName: 1,
@ -33,8 +36,8 @@ const MciViewIds = {
numberFollowers: 5, numberFollowers: 5,
numberFollowing: 6, numberFollowing: 6,
summary: 7, summary: 7,
followButton: 8,
cancelButton: 9, customRangeStart: 10,
}, },
}; };
@ -47,27 +50,34 @@ exports.getModule = class ActivityPubActorSearch extends MenuModule {
}); });
this.menuMethods = { this.menuMethods = {
submit: (formData, extraArgs, cb) => { search: (formData, extraArgs, cb) => {
switch (formData.submitId) {
case MciViewIds.main.searchUrl: {
return this._search(formData.value, cb); return this._search(formData.value, cb);
} },
case MciViewIds.main.searchButton: { toggleFollowKeyPressed: (formData, extraArgs, cb) => {
return this._search(formData.value, cb); return this._toggleFollowStatus(err => {
} if (err) {
this.client.log.error(
default: { error: err.message },
cb( 'Failed to toggle follow status'
Errors.UnexpectedState(
`Unexpected submitId: ${formData.submitId}`
)
); );
} }
return cb(err);
});
},
backKeyPressed: (formData, extraArgs, cb) => {
return this._displayMainPage(true, cb);
}, },
}; };
} }
initSequence() { initSequence() {
this.webServer = getServer('codes.l33t.enigma.web.server');
if (!this.webServer) {
this.client.log('Could not get Web server');
return this.prevMenu();
}
this.webServer = this.webServer.instance;
async.series( async.series(
[ [
callback => { callback => {
@ -84,7 +94,7 @@ exports.getModule = class ActivityPubActorSearch extends MenuModule {
} }
_search(values, cb) { _search(values, cb) {
const searchString = values['searchUrl'].trim(); const searchString = values.searchQuery.trim();
//TODO: Handle empty searchString //TODO: Handle empty searchString
Actor.fromId(searchString, (err, remoteActor) => { Actor.fromId(searchString, (err, remoteActor) => {
if (err) { if (err) {
@ -95,11 +105,15 @@ exports.getModule = class ActivityPubActorSearch extends MenuModule {
// TODO: Add error to page for failure to find actor // TODO: Add error to page for failure to find actor
return this._displayMainPage(true, cb); return this._displayMainPage(true, cb);
} }
return this._displayListScreen(remoteActor, cb);
this.selectedActorInfo = remoteActor;
return this._displayViewPage(cb);
}); });
} }
_displayListScreen(remoteActor, cb) { _displayViewPage(cb) {
EnigAssert(isObject(this.selectedActorInfo), 'No Actor selected!');
async.series( async.series(
[ [
callback => { callback => {
@ -122,59 +136,65 @@ exports.getModule = class ActivityPubActorSearch extends MenuModule {
callback => { callback => {
return this.validateMCIByViewIds( return this.validateMCIByViewIds(
'view', 'view',
Object.values(MciViewIds.view), Object.values(MciViewIds.view).filter(
id => id !== MciViewIds.view.customRangeStart
),
callback callback
); );
}, },
callback => {
this._updateCollectionItemCount(Collections.Following, () => {
return this._updateCollectionItemCount(
Collections.Followers,
callback
);
});
},
callback => { callback => {
const v = id => this.getView('view', id); const v = id => this.getView('view', id);
const nameView = v(MciViewIds.view.userName); const nameView = v(MciViewIds.view.userName);
nameView.setText( nameView.setText(this.selectedActorInfo.preferredUsername);
truncate(remoteActor.preferredUsername, {
length: nameView.getWidth(),
})
);
const fullNameView = v(MciViewIds.view.fullName); const fullNameView = v(MciViewIds.view.fullName);
fullNameView.setText( fullNameView.setText(this.selectedActorInfo.name);
truncate(remoteActor.name, { length: fullNameView.getWidth() })
);
const datePublishedView = v(MciViewIds.view.datePublished); const datePublishedView = v(MciViewIds.view.datePublished);
if (isEmpty(remoteActor.published)) { if (isEmpty(this.selectedActorInfo.published)) {
datePublishedView.setText('Not available.'); datePublishedView.setText('Not available.');
} else { } else {
const publishedDate = moment(remoteActor.published); const publishedDate = moment(this.selectedActorInfo.published);
datePublishedView.setText( datePublishedView.setText(
publishedDate.format(this.getDateFormat()) publishedDate.format(this.getDateFormat())
); );
} }
const manualFollowersView = v(MciViewIds.view.manualFollowers); const manualFollowersView = v(MciViewIds.view.manualFollowers);
manualFollowersView.setText(remoteActor.manuallyApprovesFollowers); manualFollowersView.setText(
this.selectedActorInfo.manuallyApprovesFollowers
);
const followerCountView = v(MciViewIds.view.numberFollowers); const followerCountView = v(MciViewIds.view.numberFollowers);
this._updateViewWithCollectionItemCount( followerCountView.setText(
remoteActor.followers, this.selectedActorInfo._followersCount > -1
followerCountView ? this.selectedActorInfo._followersCount
: '--'
); );
const followingCountView = v(MciViewIds.view.numberFollowing); const followingCountView = v(MciViewIds.view.numberFollowing);
this._updateViewWithCollectionItemCount( followingCountView.setText(
remoteActor.following, this.selectedActorInfo._followingCount > -1
followingCountView ? this.selectedActorInfo._followingCount
: '--'
); );
const summaryView = v(MciViewIds.view.summary); const summaryView = v(MciViewIds.view.summary);
summaryView.setText(htmlToMessageBody(remoteActor.summary)); summaryView.setText(
htmlToMessageBody(this.selectedActorInfo.summary)
);
summaryView.redraw(); summaryView.redraw();
const followButtonView = v(MciViewIds.view.followButton); return this._setFollowStatus(callback);
// TODO: FIXME: Real status
followButtonView.setText('follow');
return callback(null);
}, },
], ],
err => { err => {
@ -183,6 +203,102 @@ exports.getModule = class ActivityPubActorSearch extends MenuModule {
); );
} }
_setFollowStatus(cb) {
Collection.ownedObjectByNameAndId(
Collections.Following,
this.client.user,
this.selectedActorInfo.id,
(err, followingActorEntry) => {
if (err) {
return cb(err);
}
this.selectedActorInfo._isFollowing = followingActorEntry ? true : false;
this.selectedActorInfo._followingIndicator =
this._getFollowingIndicator();
this.updateCustomViewTextsWithFilter(
'view',
MciViewIds.view.customRangeStart,
this._getCustomInfoFormatObject()
);
return cb(null);
}
);
}
_toggleFollowStatus(cb) {
// catch early key presses
if (!this.selectedActorInfo) {
return;
}
this.selectedActorInfo._isFollowing = !this.selectedActorInfo._isFollowing;
this.selectedActorInfo._followingIndicator = this._getFollowingIndicator();
const finish = e => {
this.updateCustomViewTextsWithFilter(
'view',
MciViewIds.view.customRangeStart,
this._getCustomInfoFormatObject()
);
return cb(e);
};
const actor = this._getSelectedActor(); // actor info -> actor
return this.selectedActorInfo._isFollowing
? sendFollowRequest(this.client.user, actor, this.webServer, finish)
: sendUnfollowRequest(this.client.user, actor, this.webServer, finish);
}
_getSelectedActor() {
const actor = cloneDeep(this.selectedActorInfo);
// nuke our added properties
delete actor._isFollowing;
delete actor._followingIndicator;
delete actor._followingCount;
delete actor._followersCount;
return actor;
}
_getFollowingIndicator() {
return this.selectedActorInfo._isFollowing
? this.config.followingIndicator || 'Following'
: this.config.notFollowingIndicator || 'Not following';
}
_getCustomInfoFormatObject() {
const formatObj = {
followingCount: this.selectedActorInfo._followingCount,
followerCount: this.selectedActorInfo._followersCount,
};
const v = f => {
return this.selectedActorInfo[f] || '';
};
Object.assign(formatObj, {
actorId: v('id'),
actorSubject: v('subject'),
actorType: v('type'),
actorName: v('name'),
actorSummary: v('summary'),
actorPreferredUsername: v('preferredUsername'),
actorUrl: v('url'),
actorImage: v('image'),
actorIcon: v('icon'),
actorFollowing: this.selectedActorInfo._isFollowing,
actorFollowingIndicator: v('_followingIndicator'),
text: v('name'),
});
return formatObj;
}
_displayMainPage(clearScreen, cb) { _displayMainPage(clearScreen, cb) {
async.series( async.series(
[ [
@ -208,17 +324,20 @@ exports.getModule = class ActivityPubActorSearch extends MenuModule {
); );
} }
_updateViewWithCollectionItemCount(collectionUrl, view) { _updateCollectionItemCount(collectionName, cb) {
const collectionUrl = this.selectedActorInfo[collectionName];
this._retrieveCountFromCollectionUrl(collectionUrl, (err, count) => { this._retrieveCountFromCollectionUrl(collectionUrl, (err, count) => {
if (err) { if (err) {
this.client.log.warn( this.client.log.warn(
{ err: err }, { err: err },
`Unable to get Collection count for ${collectionUrl}` `Unable to get Collection count for ${collectionUrl}`
); );
view.setText('--'); this.selectedActorInfo[`_${collectionName}Count`] = -1;
} else { } else {
view.setText(count); this.selectedActorInfo[`_${collectionName}Count`] = count;
} }
return cb(null);
}); });
} }
@ -229,7 +348,7 @@ exports.getModule = class ActivityPubActorSearch extends MenuModule {
} }
Collection.getRemoteCollectionStats(collectionUrl, (err, stats) => { Collection.getRemoteCollectionStats(collectionUrl, (err, stats) => {
return cb(err, stats.totalItems); return cb(err, err ? null : stats.totalItems);
}); });
} }
}; };

View File

@ -7,6 +7,7 @@ const { Errors } = require('../enig_error.js');
const { const {
PublicCollectionId: APPublicCollectionId, PublicCollectionId: APPublicCollectionId,
ActivityStreamMediaType, ActivityStreamMediaType,
Collections,
} = require('./const'); } = require('./const');
const UserProps = require('../user_property'); const UserProps = require('../user_property');
const { getJson } = require('../http_util'); const { getJson } = require('../http_util');
@ -55,7 +56,7 @@ module.exports = class Collection extends ActivityPubObject {
static followers(collectionId, page, cb) { static followers(collectionId, page, cb) {
return Collection.publicOrderedById( return Collection.publicOrderedById(
'followers', Collections.Followers,
collectionId, collectionId,
page, page,
e => e.id, e => e.id,
@ -65,7 +66,7 @@ module.exports = class Collection extends ActivityPubObject {
static following(collectionId, page, cb) { static following(collectionId, page, cb) {
return Collection.publicOrderedById( return Collection.publicOrderedById(
'following', Collections.Following,
collectionId, collectionId,
page, page,
e => e.id, e => e.id,
@ -74,13 +75,19 @@ module.exports = class Collection extends ActivityPubObject {
} }
static outbox(collectionId, page, cb) { static outbox(collectionId, page, cb) {
return Collection.publicOrderedById('outbox', collectionId, page, null, cb); return Collection.publicOrderedById(
Collections.Outbox,
collectionId,
page,
null,
cb
);
} }
static addFollower(owningUser, followingActor, webServer, ignoreDupes, cb) { static addFollower(owningUser, followingActor, webServer, ignoreDupes, cb) {
const collectionId = Endpoints.followers(webServer, owningUser); const collectionId = Endpoints.followers(webServer, owningUser);
return Collection.addToCollection( return Collection.addToCollection(
'followers', Collections.Followers,
owningUser, owningUser,
collectionId, collectionId,
followingActor.id, // Actor following owningUser followingActor.id, // Actor following owningUser
@ -95,7 +102,7 @@ module.exports = class Collection extends ActivityPubObject {
const collectionId = const collectionId =
Endpoints.makeUserUrl(webServer, owningUser) + 'follow-requests'; Endpoints.makeUserUrl(webServer, owningUser) + 'follow-requests';
return Collection.addToCollection( return Collection.addToCollection(
'follow-requests', Collections.FollowRequests,
owningUser, owningUser,
collectionId, collectionId,
requestingActor.id, // Actor requesting to follow owningUser requestingActor.id, // Actor requesting to follow owningUser
@ -109,7 +116,7 @@ module.exports = class Collection extends ActivityPubObject {
static addFollowing(owningUser, followingActor, webServer, ignoreDupes, cb) { static addFollowing(owningUser, followingActor, webServer, ignoreDupes, cb) {
const collectionId = Endpoints.following(webServer, owningUser); const collectionId = Endpoints.following(webServer, owningUser);
return Collection.addToCollection( return Collection.addToCollection(
'following', Collections.Following,
owningUser, owningUser,
collectionId, collectionId,
followingActor.id, // Actor owningUser is following followingActor.id, // Actor owningUser is following
@ -123,7 +130,7 @@ module.exports = class Collection extends ActivityPubObject {
static addOutboxItem(owningUser, outboxItem, isPrivate, webServer, ignoreDupes, cb) { static addOutboxItem(owningUser, outboxItem, isPrivate, webServer, ignoreDupes, cb) {
const collectionId = Endpoints.outbox(webServer, owningUser); const collectionId = Endpoints.outbox(webServer, owningUser);
return Collection.addToCollection( return Collection.addToCollection(
'outbox', Collections.Outbox,
owningUser, owningUser,
collectionId, collectionId,
outboxItem.id, outboxItem.id,
@ -137,7 +144,7 @@ module.exports = class Collection extends ActivityPubObject {
static addInboxItem(inboxItem, owningUser, webServer, ignoreDupes, cb) { static addInboxItem(inboxItem, owningUser, webServer, ignoreDupes, cb) {
const collectionId = Endpoints.inbox(webServer, owningUser); const collectionId = Endpoints.inbox(webServer, owningUser);
return Collection.addToCollection( return Collection.addToCollection(
'inbox', Collections.Inbox,
owningUser, owningUser,
collectionId, collectionId,
inboxItem.id, inboxItem.id,
@ -150,7 +157,7 @@ module.exports = class Collection extends ActivityPubObject {
static addSharedInboxItem(inboxItem, ignoreDupes, cb) { static addSharedInboxItem(inboxItem, ignoreDupes, cb) {
return Collection.addToCollection( return Collection.addToCollection(
'sharedInbox', Collections.SharedInbox,
null, // N/A null, // N/A
Collection.PublicCollectionId, Collection.PublicCollectionId,
inboxItem.id, inboxItem.id,
@ -161,13 +168,79 @@ module.exports = class Collection extends ActivityPubObject {
); );
} }
static objectById(objectId, cb) { // Get Object(s) by ID; There may be multiples as they may be
// e.g. Actors belonging to multiple followers collections.
// This method also returns information about the objects
// and any items that can't be parsed
static objectsById(objectId, cb) {
apDb.all(
`SELECT name, timestamp, owner_actor_id, object_json, is_private
FROM collection
WHERE object_id = ?;`,
[objectId],
(err, rows) => {
if (err) {
return cb(err);
}
const results = (rows || []).map(r => {
const info = {
info: this._rowToObjectInfo(r),
object: ActivityPubObject.fromJsonString(r.object_json),
};
if (!info.object) {
info.raw = r.object_json;
}
return info;
});
return cb(null, results);
}
);
}
static ownedObjectByNameAndId(collectionName, owningUser, objectId, cb) {
const actorId = owningUser.getProperty(UserProps.ActivityPubActorId);
if (!actorId) {
return cb(
Errors.MissingProperty(
`User "${owningUser.username}" is missing property '${UserProps.ActivityPubActorId}'`
)
);
}
apDb.get(
`SELECT name, timestamp, owner_actor_id, object_json, is_private
FROM collection
WHERE name = ? AND owner_actor_id = ? AND object_id = ?
LIMIT 1;`,
[collectionName, actorId, objectId],
(err, row) => {
if (err) {
return cb(err);
}
if (!row) {
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));
}
);
}
static objectByNameAndId(collectionName, objectId, cb) {
apDb.get( apDb.get(
`SELECT name, timestamp, owner_actor_id, object_json, is_private `SELECT name, timestamp, owner_actor_id, object_json, is_private
FROM collection FROM collection
WHERE name = ? AND object_id = ? WHERE name = ? AND object_id = ?
LIMIT 1;`, LIMIT 1;`,
[objectId], [collectionName, objectId],
(err, row) => { (err, row) => {
if (err) { if (err) {
return cb(err); return cb(err);

View File

@ -31,3 +31,13 @@ exports.HttpSignatureSignHeaders = [
'digest', 'digest',
'content-type', 'content-type',
]; ];
const Collections = {
Following: 'following',
Followers: 'followers',
FollowRequests: 'followRequests',
Outbox: 'outbox',
Inbox: 'inbox',
SharedInbox: 'sharedInbox',
};
exports.Collections = Collections;

View File

@ -0,0 +1,64 @@
const { Collections, WellKnownActivity } = require('./const');
const ActivityPubObject = require('./object');
const UserProps = require('../user_property');
const { Errors } = require('../enig_error');
const Collection = require('./collection');
exports.sendFollowRequest = sendFollowRequest;
exports.sendUnfollowRequest = sendUnfollowRequest;
function sendFollowRequest(fromUser, toActor, webServer, cb) {
const fromActorId = fromUser.getProperty(UserProps.ActivityPubActorId);
if (!fromActorId) {
return cb(
Errors.MissingProperty(
`User missing "${UserProps.ActivityPubActorId}" property`
)
);
}
// We always add to the following collection;
// We expect an async follow up request to our server of
// Accept or Reject but it's not guaranteed
Collection.addFollowing(fromUser, toActor, webServer, true, err => {
if (err) {
return cb(err);
}
const followRequest = new ActivityPubObject({
id: ActivityPubObject.makeObjectId(webServer, 'follow'),
type: WellKnownActivity.Follow,
actor: fromActorId,
object: toActor.id,
});
return followRequest.sendTo(toActor.inbox, fromUser, webServer, cb);
});
}
function sendUnfollowRequest(fromUser, toActor, webServer, cb) {
const fromActorId = fromUser.getProperty(UserProps.ActivityPubActorId);
if (!fromActorId) {
return cb(
Errors.MissingProperty(
`User missing "${UserProps.ActivityPubActorId}" property`
)
);
}
// Always remove from the local collection, notify the remote server
Collection.removeOwnedById(Collections.Following, fromUser, toActor.inbox, err => {
if (err) {
return cb(err);
}
const undoRequest = new ActivityPubObject({
id: ActivityPubObject.makeObjectId(webServer, 'undo'),
type: WellKnownActivity.Undo,
actor: fromActorId,
object: toActor.id,
});
return undoRequest.sendTo(toActor.inbox, fromUser, webServer, cb);
});
}

View File

@ -0,0 +1,372 @@
const { MenuModule } = require('../menu_module');
const Collection = require('./collection');
const { getServer } = require('../listening_server');
const Endpoints = require('./endpoint');
const Actor = require('./actor');
const stringFormat = require('../string_format');
const { pipeToAnsi } = require('../color_codes');
const MultiLineEditTextView =
require('../multi_line_edit_text_view').MultiLineEditTextView;
const { sendFollowRequest, sendUnfollowRequest } = require('./follow_util');
const { Collections } = require('./const');
// deps
const async = require('async');
const { get, cloneDeep } = require('lodash');
exports.moduleInfo = {
name: 'ActivityPub Social Manager',
desc: 'Manages ActivityPub Actors the current user is following or being followed by.',
author: 'NuSkooler',
};
const FormIds = {
main: 0,
};
const MciViewIds = {
main: {
actorList: 1,
selectedActorInfo: 2,
navMenu: 3,
customRangeStart: 10,
},
};
exports.getModule = class ActivityPubFollowingManager extends MenuModule {
constructor(options) {
super(options);
this.setConfigWithExtraArgs(options);
this.followingActors = [];
this.followerActors = [];
this.currentCollection = Collections.Following;
this.menuMethods = {
spaceKeyPressed: (formData, extraArgs, cb) => {
return this._toggleSelectedActorStatus(cb);
},
listKeyPressed: (formData, extraArgs, cb) => {
const actorListView = this.getView('main', MciViewIds.main.actorList);
if (actorListView) {
const keyName = get(formData, 'key.name');
switch (keyName) {
case 'down arrow':
actorListView.focusNext();
break;
case 'up arrow':
actorListView.focusPrevious();
break;
}
}
return cb(null);
},
};
}
initSequence() {
this.webServer = getServer('codes.l33t.enigma.web.server');
if (!this.webServer) {
this.client.log('Could not get Web server');
return this.prevMenu();
}
this.webServer = this.webServer.instance;
async.series(
[
callback => {
return this.beforeArt(callback);
},
callback => {
return this._displayMainPage(callback);
},
],
() => {
this.finishedLoading();
}
);
}
_displayMainPage(cb) {
async.series(
[
callback => {
return this.displayArtAndPrepViewController(
'main',
FormIds.main,
{ clearScreen: true },
callback
);
},
callback => {
return this.validateMCIByViewIds(
'main',
Object.values(MciViewIds.main).filter(
id => id !== MciViewIds.main.customRangeStart
),
callback
);
},
callback => {
this._fetchActorList(
Collections.Following,
(err, followingActors) => {
if (err) {
return callback(err);
}
return this._fetchActorList(
Collections.Followers,
(err, followerActors) => {
if (err) {
return callback(err);
}
this.followingActors = followingActors;
this.followerActors = followerActors;
return callback(null);
}
);
}
);
},
callback => {
const v = id => this.getView('main', id);
const actorListView = v(MciViewIds.main.actorList);
const selectedActorInfoView = v(MciViewIds.main.selectedActorInfo);
const navMenuView = v(MciViewIds.main.navMenu);
// We start with following
this._switchTo(Collections.Following);
actorListView.on('index update', index => {
const selectedActor = this._getSelectedActorItem(index);
this._updateSelectedActorInfo(
selectedActorInfoView,
selectedActor
);
});
navMenuView.on('index update', index => {
if (0 === index) {
this._switchTo(Collections.Following);
} else {
this._switchTo(Collections.Followers);
}
});
return callback(null);
},
],
err => {
return cb(err);
}
);
}
_switchTo(collectionName) {
this.currentCollection = collectionName;
const actorListView = this.getView('main', MciViewIds.main.actorList);
if (Collections.Following === collectionName) {
actorListView.setItems(this.followingActors);
} else {
actorListView.setItems(this.followerActors);
}
actorListView.redraw();
const selectedActor = this._getSelectedActorItem(
actorListView.getFocusItemIndex()
);
const selectedActorInfoView = this.getView(
'main',
MciViewIds.main.selectedActorInfo
);
if (selectedActor) {
this._updateSelectedActorInfo(selectedActorInfoView, selectedActor);
} else {
selectedActorInfoView.setText('');
}
}
_getSelectedActorItem(index) {
if (this.currentCollection === Collections.Following) {
return this.followingActors[index];
} else {
return this.followerActors[index];
}
}
_getCurrentActorList() {
return this.currentCollection === Collections.Following
? this.followingActors
: this.followerActors;
}
_updateSelectedActorInfo(view, actorInfo) {
if (actorInfo) {
const selectedActorInfoFormat =
this.config.selectedActorInfoFormat || '{text}';
const s = stringFormat(selectedActorInfoFormat, actorInfo);
if (view instanceof MultiLineEditTextView) {
view.setAnsi(pipeToAnsi(s, this.client));
} else {
view.setText(s);
}
}
this.updateCustomViewTextsWithFilter(
'main',
MciViewIds.main.customRangeStart,
this._getCustomInfoFormatObject(actorInfo)
);
}
_toggleSelectedActorStatus(cb) {
const actorListView = this.getView('main', MciViewIds.main.actorList);
const selectedActor = this._getSelectedActorItem(
actorListView.getFocusItemIndex()
);
if (selectedActor) {
selectedActor.status = !selectedActor.status;
selectedActor.statusIndicator = this._getStatusIndicator(
selectedActor.status
);
async.series(
[
callback => {
if (Collections.Following === this.currentCollection) {
return this._followingActorToggled(selectedActor, callback);
} else {
return this._followerActorToggled(selectedActor, callback);
}
},
],
err => {
if (err) {
this.client.log.error(
{ error: err.message, type: this.currentCollection },
`Failed to toggle "${this.currentCollection}" status`
);
}
// :TODO: we really need updateItem() call on MenuView
actorListView.setItems(this._getCurrentActorList());
actorListView.redraw(); // oof
return cb(null);
}
);
}
}
_followingActorToggled(actorInfo, cb) {
// Local user/Actor wants to follow or un-follow
const wantsToFollow = actorInfo.status;
const actor = this._actorInfoToActor(actorInfo);
return wantsToFollow
? sendFollowRequest(this.client.user, actor, this.webServer, cb)
: sendUnfollowRequest(this.client.user, actor, this.webServer, cb);
}
_actorInfoToActor(actorInfo) {
const actor = cloneDeep(actorInfo);
// nuke our added properties
delete actor.subject;
delete actor.text;
delete actor.status;
delete actor.statusIndicator;
return actor;
}
_followerActorToggled(actorInfo, cb) {
return cb(null);
}
_getCustomInfoFormatObject(actorInfo) {
const formatObj = {
followingCount: this.followingActors.length,
followerCount: this.followerActors.length,
};
const v = f => {
return actorInfo ? actorInfo[f] || '' : '';
};
Object.assign(formatObj, {
selectedActorId: v('id'),
selectedActorSubject: v('subject'),
selectedActorType: v('type'),
selectedActorName: v('name'),
selectedActorSummary: v('summary'),
selectedActorPreferredUsername: v('preferredUsername'),
selectedActorUrl: v('url'),
selectedActorImage: v('image'),
selectedActorIcon: v('icon'),
selectedActorStatus: actorInfo ? actorInfo.status : false,
selectedActorStatusIndicator: v('statusIndicator'),
text: v('name'),
});
return formatObj;
}
_getStatusIndicator(enabled) {
return enabled
? this.config.statusIndicatorEnabled || '√'
: this.config.statusIndicatorDisabled || 'X';
}
_fetchActorList(collectionName, cb) {
const collectionId = Endpoints[collectionName](this.webServer, this.client.user);
Collection[collectionName](collectionId, 'all', (err, collection) => {
if (err) {
return cb(err);
}
if (!collection.orderedItems || collection.orderedItems.length < 1) {
return cb(null, []);
}
const statusIndicator = this._getStatusIndicator(true);
async.mapLimit(
collection.orderedItems,
4,
(actorId, nextActorId) => {
Actor.fromId(actorId, (err, actor, subject) => {
if (err) {
this.client.log.warn({ actorId }, 'Failed to retrieve Actor');
return nextActorId(null, null);
}
// Add some of our own properties
Object.assign(actor, {
subject,
status: true,
statusIndicator,
text: actor.name,
});
return nextActorId(null, actor);
});
},
(err, actorsList) => {
if (err) {
return cb(err);
}
actorsList = actorsList.filter(f => f); // drop nulls
return cb(null, actorsList);
}
);
});
}
};

View File

@ -51,10 +51,7 @@ const EnabledViewGroup = [
exports.getModule = class ActivityPubUserConfig extends MenuModule { exports.getModule = class ActivityPubUserConfig extends MenuModule {
constructor(options) { constructor(options) {
super(options); super(options);
this.setConfigWithExtraArgs(options);
this.config = Object.assign({}, get(options, 'menuConfig.config'), {
extraArgs: options.extraArgs,
});
this.menuMethods = { this.menuMethods = {
mainSubmit: (formData, extraArgs, cb) => { mainSubmit: (formData, extraArgs, cb) => {

View File

@ -3,8 +3,7 @@ const { Errors, ErrorReasons } = require('../enig_error');
const UserProps = require('../user_property'); const UserProps = require('../user_property');
const ActivityPubSettings = require('./settings'); const ActivityPubSettings = require('./settings');
const { stripAnsiControlCodes } = require('../string_util'); const { stripAnsiControlCodes } = require('../string_util');
const { WellKnownRecipientFields, WellKnownActivity } = require('./const'); const { WellKnownRecipientFields } = require('./const');
const ActivityPubObject = require('./object');
const Log = require('../logger').log; const Log = require('../logger').log;
// deps // deps
@ -29,7 +28,6 @@ exports.userNameFromSubject = userNameFromSubject;
exports.userNameToSubject = userNameToSubject; exports.userNameToSubject = userNameToSubject;
exports.extractMessageMetadata = extractMessageMetadata; exports.extractMessageMetadata = extractMessageMetadata;
exports.recipientIdsFromObject = recipientIdsFromObject; exports.recipientIdsFromObject = recipientIdsFromObject;
exports.sendFollowRequest = sendFollowRequest;
// :TODO: more info in default // :TODO: more info in default
// this profile template is the *default* for both WebFinger // this profile template is the *default* for both WebFinger
@ -264,23 +262,3 @@ function recipientIdsFromObject(obj) {
return Array.from(new Set(ids)); return Array.from(new Set(ids));
} }
function sendFollowRequest(fromUser, toActor, webServer, cb) {
const fromActorId = fromUser.getProperty(UserProps.ActivityPubActorId);
if (!fromActorId) {
return cb(
Errors.MissingProperty(
`User missing "${UserProps.ActivityPubActorId}" property`
)
);
}
const followRequest = new ActivityPubObject({
id: ActivityPubObject.makeObjectId(webServer, 'follow'),
type: WellKnownActivity.Follow,
actor: fromActorId,
object: toActor.id,
});
return followRequest.sendTo(toActor.inbox, fromUser, webServer, cb);
}

View File

@ -8,6 +8,7 @@ const Endpoints = require('../../../activitypub/endpoint');
const { const {
ActivityStreamMediaType, ActivityStreamMediaType,
WellKnownActivity, WellKnownActivity,
Collections,
} = require('../../../activitypub/const'); } = require('../../../activitypub/const');
const Config = require('../../../config').get; const Config = require('../../../config').get;
const Activity = require('../../../activitypub/activity'); const Activity = require('../../../activitypub/activity');
@ -64,7 +65,12 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
req, req,
resp, resp,
(req, resp, signature) => { (req, resp, signature) => {
return this._inboxPostHandler(req, resp, signature, 'inbox'); return this._inboxPostHandler(
req,
resp,
signature,
Collections.Inbox
);
} }
); );
}, },
@ -82,7 +88,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
req, req,
resp, resp,
signature, signature,
'sharedInbox' Collections.SharedInbox
); );
} }
); );
@ -262,7 +268,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
switch (activity.type) { switch (activity.type) {
case WellKnownActivity.Accept: case WellKnownActivity.Accept:
break; return this._inboxAcceptActivity(resp, activity);
case WellKnownActivity.Add: case WellKnownActivity.Add:
break; break;
@ -292,17 +298,17 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
case WellKnownActivity.Follow: case WellKnownActivity.Follow:
// Follow requests are only allowed directly // Follow requests are only allowed directly
if ('inbox' === inboxType) { if (Collections.Inbox === inboxType) {
return this._inboxFollowActivity(resp, remoteActor, activity); return this._inboxFollowActivity(resp, remoteActor, activity);
} }
break; break;
case WellKnownActivity.Reject: case WellKnownActivity.Reject:
break; return this._inboxRejectActivity(resp, activity);
case WellKnownActivity.Undo: case WellKnownActivity.Undo:
// We only Undo from private inboxes // We only Undo from private inboxes
if ('inbox' === inboxType) { if (Collections.Inbox === inboxType) {
// Only Follow Undo's currently supported // Only Follow Undo's currently supported
const type = _.get(activity, 'object.type'); const type = _.get(activity, 'object.type');
if (WellKnownActivity.Follow === type) { if (WellKnownActivity.Follow === type) {
@ -330,6 +336,21 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
}); });
} }
_inboxAcceptActivity(resp, activity) {
const acceptWhat = _.get(activity, 'object.type');
switch (acceptWhat) {
case WellKnownActivity.Follow:
return this._inboxAcceptFollowActivity(resp, activity);
default:
this.log.warn(
{ type: acceptWhat },
'Invalid or unsupported "Accept" type'
);
return this.webServer.notImplemented(resp);
}
}
_inboxCreateActivity(resp, activity) { _inboxCreateActivity(resp, activity) {
const createWhat = _.get(activity, 'object.type'); const createWhat = _.get(activity, 'object.type');
switch (createWhat) { switch (createWhat) {
@ -341,10 +362,23 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
{ type: createWhat }, { type: createWhat },
'Invalid or unsupported "Create" type' 'Invalid or unsupported "Create" type'
); );
return this.webServer.resourceNotFound(resp); return this.webServer.notImplemented(resp);
} }
} }
_inboxAcceptFollowActivity(resp, activity) {
// Currently Accept's to Follow's are really just a formality;
// we'll log it, but that's about it for now
this.log.info(
{
remoteActorId: activity.actor,
localActorId: _.get(activity, 'object.actor'),
},
'Follow request Accepted'
);
return this.webServer.accepted(resp);
}
_inboxCreateNoteActivity(resp, activity) { _inboxCreateNoteActivity(resp, activity) {
const note = new Note(activity.object); const note = new Note(activity.object);
if (!note.isValid()) { if (!note.isValid()) {
@ -380,10 +414,107 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
); );
} }
_inboxDeleteActivity(inboxType, signature, resp /*, activity*/) { _inboxRejectFollowActivity(resp, activity) {
// :TODO: Implement me! // A user Rejected our local Actor/user's Follow request;
// Update the local Collection to reflect this fact.
const remoteActorId = activity.actor;
const localActorId = _.get(activity, 'object.actor');
if (!remoteActorId || !localActorId) {
return this.webServer.badRequest(resp);
}
userFromActorId(localActorId, (err, localUser) => {
if (err) {
return this.webServer.resourceNotFound(resp);
}
Collection.removeOwnedById(
Collections.Following,
localUser,
remoteActorId,
err => {
if (err) {
this.log.error(
{ remoteActorId, localActorId },
'Failed removing "following" record'
);
}
return this.webServer.accepted(resp);
}
);
});
}
_inboxDeleteActivity(inboxType, signature, resp, activity) {
const objectId = _.get(activity, 'object.id', activity.object);
this.log.info({ inboxType, objectId }, 'Incoming Delete request');
// :TODO: we need to DELETE the existing stored Message object if this is a Note, or associated if this is an Actor // :TODO: we need to DELETE the existing stored Message object if this is a Note, or associated if this is an Actor
// :TODO: delete / invalidate any actor cache if actor // :TODO: delete / invalidate any actor cache if actor
Collection.objectsById(objectId, (err, objectsInfo) => {
if (err) {
this.log.warn({ objectId });
// We'll respond accepted so they don't keep trying
return this.webServer.accepted(resp);
}
if (objectsInfo.length === 0) {
return this.webServer.resourceNotFound(resp);
}
// Generally we'd have a 1:1 objectId -> object here, but it's
// possible for example, that we're being asked to delete an Actor;
// If this is the case, they may be following multiple local Actor/users
// and we have multiple entries.
async.forEachSeries(
objectsInfo,
(objInfo, nextObjInfo) => {
if (objInfo.object) {
// Based on the collection we find this entry in,
// we may have additional validation or actions
switch (objInfo.info.name) {
case Collections.Inbox:
if (inboxType !== Collections.Inbox) {
// :TODO: LOG ME
return nextObjInfo(null);
}
break;
case Collections.SharedInbox:
if (inboxType !== Collections.SharedInbox) {
// :TODO: log me
return nextObjInfo(null);
}
break;
default:
break;
}
return nextObjInfo(null);
} else {
// it's unparsable, so we'll delete it
Collection.removeById(objInfo.info.name, objectId, err => {
if (err) {
this.log.warn(
{ objectId, collectionName: objInfo.info.name },
'Failed to remove object'
);
}
return nextObjInfo(null);
});
}
},
err => {
if (err) {
// :TODO: log me
}
return this.webServer.accepted(resp);
}
);
});
return this.webServer.accepted(resp); return this.webServer.accepted(resp);
} }
@ -402,7 +533,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
const activityPubSettings = ActivityPubSettings.fromUser(localUser); const activityPubSettings = ActivityPubSettings.fromUser(localUser);
if (!activityPubSettings.manuallyApproveFollowers) { if (!activityPubSettings.manuallyApproveFollowers) {
this._recordAcceptedFollowRequest(localUser, remoteActor, activity); this._recordAcceptedFollowRequest(localUser, remoteActor, activity);
return this.webServer.ok(resp); return this.webServer.accepted(resp);
} }
// User manually approves requests; add them to their requests collection // User manually approves requests; add them to their requests collection
@ -416,12 +547,27 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
return this.internalServerError(resp, err); return this.internalServerError(resp, err);
} }
return this.webServer.ok(resp); return this.webServer.accepted(resp);
} }
); );
}); });
} }
_inboxRejectActivity(resp, activity) {
const rejectWhat = _.get(activity, 'object.type');
switch (rejectWhat) {
case WellKnownActivity.Follow:
return this._inboxRejectFollowActivity(resp, activity);
default:
this.log.warn(
{ type: rejectWhat },
'Invalid or unsupported "Reject" type'
);
return this.webServer.notImplemented(resp);
}
}
_inboxUndoActivity(resp, remoteActor, activity) { _inboxUndoActivity(resp, remoteActor, activity) {
const localActorId = _.get(activity, 'object.object'); const localActorId = _.get(activity, 'object.object');
@ -435,7 +581,11 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
return this.webServer.resourceNotFound(resp); return this.webServer.resourceNotFound(resp);
} }
Collection.removeOwnedById('followers', localUser, remoteActor.id, err => { Collection.removeOwnedById(
Collections.Followers,
localUser,
remoteActor.id,
err => {
if (err) { if (err) {
return this.webServer.internalServerError(resp, err); return this.webServer.internalServerError(resp, err);
} }
@ -450,7 +600,8 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
); );
return this.webServer.accepted(resp); return this.webServer.accepted(resp);
}); }
);
}); });
} }
@ -712,18 +863,18 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
_followingGetHandler(req, resp) { _followingGetHandler(req, resp) {
this.log.debug({ url: req.url }, 'Request for "following"'); this.log.debug({ url: req.url }, 'Request for "following"');
return this._actorCollectionRequest('following', req, resp); return this._actorCollectionRequest(Collections.Following, req, resp);
} }
_followersGetHandler(req, resp) { _followersGetHandler(req, resp) {
this.log.debug({ url: req.url }, 'Request for "followers"'); this.log.debug({ url: req.url }, 'Request for "followers"');
return this._actorCollectionRequest('followers', req, resp); return this._actorCollectionRequest(Collections.Followers, req, resp);
} }
// https://docs.gotosocial.org/en/latest/federation/behaviors/outbox/ // https://docs.gotosocial.org/en/latest/federation/behaviors/outbox/
_outboxGetHandler(req, resp) { _outboxGetHandler(req, resp) {
this.log.debug({ url: req.url }, 'Request for "outbox"'); this.log.debug({ url: req.url }, 'Request for "outbox"');
return this._actorCollectionRequest('outbox', req, resp); return this._actorCollectionRequest(Collections.Outbox, req, resp);
} }
_singlePublicNoteGetHandler(req, resp) { _singlePublicNoteGetHandler(req, resp) {

View File

@ -11,7 +11,6 @@ const pipeToAnsi = require('./color_codes.js').pipeToAnsi;
// deps // deps
const util = require('util'); const util = require('util');
const _ = require('lodash'); const _ = require('lodash');
const { throws } = require('assert');
exports.VerticalMenuView = VerticalMenuView; exports.VerticalMenuView = VerticalMenuView;