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.
mainMenuActivityPubActorSearch: {
config: {
followingIndicator: "|00|14> |10Following|08!|14 <"
notFollowingIndicator: "|00|14> |02Not Following|14 <"
viewInfoFormat10: "{actorFollowingIndicator}"
}
0: {
mci: {
TL1: {
width: 70
}
BT2: {
width: 20
focusTextStyle: upper
submit: true
}
}
@ -524,14 +550,9 @@
height: 3
mode: preview
}
BT8: {
focusTextStyle: upper
submit: true
}
BT9: {
text: back
focusTextStyle: upper
submit: true
TL10: {
width: 24
}
}
}

View File

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

View File

@ -3,11 +3,15 @@ const { Errors } = require('../enig_error');
const Actor = require('../activitypub/actor');
const moment = require('moment');
const { htmlToMessageBody } = require('./util');
const { Collections } = require('./const');
const Collection = require('./collection');
const EnigAssert = require('../enigma_assert');
const { sendFollowRequest, sendUnfollowRequest } = require('./follow_util');
const { getServer } = require('../listening_server');
// deps
const async = require('async');
const { get, truncate, isEmpty } = require('lodash');
const { get, isEmpty, isObject, cloneDeep } = require('lodash');
exports.moduleInfo = {
name: 'ActivityPub Actor Search',
@ -22,8 +26,7 @@ const FormIds = {
const MciViewIds = {
main: {
searchUrl: 1,
searchButton: 2,
searchQuery: 1,
},
view: {
userName: 1,
@ -33,8 +36,8 @@ const MciViewIds = {
numberFollowers: 5,
numberFollowing: 6,
summary: 7,
followButton: 8,
cancelButton: 9,
customRangeStart: 10,
},
};
@ -47,27 +50,34 @@ exports.getModule = class ActivityPubActorSearch extends MenuModule {
});
this.menuMethods = {
submit: (formData, extraArgs, cb) => {
switch (formData.submitId) {
case MciViewIds.main.searchUrl: {
return this._search(formData.value, cb);
}
case MciViewIds.main.searchButton: {
return this._search(formData.value, cb);
}
default:
cb(
Errors.UnexpectedState(
`Unexpected submitId: ${formData.submitId}`
)
search: (formData, extraArgs, cb) => {
return this._search(formData.value, cb);
},
toggleFollowKeyPressed: (formData, extraArgs, cb) => {
return this._toggleFollowStatus(err => {
if (err) {
this.client.log.error(
{ error: err.message },
'Failed to toggle follow status'
);
}
}
return cb(err);
});
},
backKeyPressed: (formData, extraArgs, cb) => {
return this._displayMainPage(true, cb);
},
};
}
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 => {
@ -84,7 +94,7 @@ exports.getModule = class ActivityPubActorSearch extends MenuModule {
}
_search(values, cb) {
const searchString = values['searchUrl'].trim();
const searchString = values.searchQuery.trim();
//TODO: Handle empty searchString
Actor.fromId(searchString, (err, remoteActor) => {
if (err) {
@ -95,11 +105,15 @@ exports.getModule = class ActivityPubActorSearch extends MenuModule {
// TODO: Add error to page for failure to find actor
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(
[
callback => {
@ -122,59 +136,65 @@ exports.getModule = class ActivityPubActorSearch extends MenuModule {
callback => {
return this.validateMCIByViewIds(
'view',
Object.values(MciViewIds.view),
Object.values(MciViewIds.view).filter(
id => id !== MciViewIds.view.customRangeStart
),
callback
);
},
callback => {
this._updateCollectionItemCount(Collections.Following, () => {
return this._updateCollectionItemCount(
Collections.Followers,
callback
);
});
},
callback => {
const v = id => this.getView('view', id);
const nameView = v(MciViewIds.view.userName);
nameView.setText(
truncate(remoteActor.preferredUsername, {
length: nameView.getWidth(),
})
);
nameView.setText(this.selectedActorInfo.preferredUsername);
const fullNameView = v(MciViewIds.view.fullName);
fullNameView.setText(
truncate(remoteActor.name, { length: fullNameView.getWidth() })
);
fullNameView.setText(this.selectedActorInfo.name);
const datePublishedView = v(MciViewIds.view.datePublished);
if (isEmpty(remoteActor.published)) {
if (isEmpty(this.selectedActorInfo.published)) {
datePublishedView.setText('Not available.');
} else {
const publishedDate = moment(remoteActor.published);
const publishedDate = moment(this.selectedActorInfo.published);
datePublishedView.setText(
publishedDate.format(this.getDateFormat())
);
}
const manualFollowersView = v(MciViewIds.view.manualFollowers);
manualFollowersView.setText(remoteActor.manuallyApprovesFollowers);
manualFollowersView.setText(
this.selectedActorInfo.manuallyApprovesFollowers
);
const followerCountView = v(MciViewIds.view.numberFollowers);
this._updateViewWithCollectionItemCount(
remoteActor.followers,
followerCountView
followerCountView.setText(
this.selectedActorInfo._followersCount > -1
? this.selectedActorInfo._followersCount
: '--'
);
const followingCountView = v(MciViewIds.view.numberFollowing);
this._updateViewWithCollectionItemCount(
remoteActor.following,
followingCountView
followingCountView.setText(
this.selectedActorInfo._followingCount > -1
? this.selectedActorInfo._followingCount
: '--'
);
const summaryView = v(MciViewIds.view.summary);
summaryView.setText(htmlToMessageBody(remoteActor.summary));
summaryView.setText(
htmlToMessageBody(this.selectedActorInfo.summary)
);
summaryView.redraw();
const followButtonView = v(MciViewIds.view.followButton);
// TODO: FIXME: Real status
followButtonView.setText('follow');
return callback(null);
return this._setFollowStatus(callback);
},
],
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) {
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) => {
if (err) {
this.client.log.warn(
{ err: err },
`Unable to get Collection count for ${collectionUrl}`
);
view.setText('--');
this.selectedActorInfo[`_${collectionName}Count`] = -1;
} 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) => {
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 {
PublicCollectionId: APPublicCollectionId,
ActivityStreamMediaType,
Collections,
} = require('./const');
const UserProps = require('../user_property');
const { getJson } = require('../http_util');
@ -55,7 +56,7 @@ module.exports = class Collection extends ActivityPubObject {
static followers(collectionId, page, cb) {
return Collection.publicOrderedById(
'followers',
Collections.Followers,
collectionId,
page,
e => e.id,
@ -65,7 +66,7 @@ module.exports = class Collection extends ActivityPubObject {
static following(collectionId, page, cb) {
return Collection.publicOrderedById(
'following',
Collections.Following,
collectionId,
page,
e => e.id,
@ -74,13 +75,19 @@ module.exports = class Collection extends ActivityPubObject {
}
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) {
const collectionId = Endpoints.followers(webServer, owningUser);
return Collection.addToCollection(
'followers',
Collections.Followers,
owningUser,
collectionId,
followingActor.id, // Actor following owningUser
@ -95,7 +102,7 @@ module.exports = class Collection extends ActivityPubObject {
const collectionId =
Endpoints.makeUserUrl(webServer, owningUser) + 'follow-requests';
return Collection.addToCollection(
'follow-requests',
Collections.FollowRequests,
owningUser,
collectionId,
requestingActor.id, // Actor requesting to follow owningUser
@ -109,7 +116,7 @@ module.exports = class Collection extends ActivityPubObject {
static addFollowing(owningUser, followingActor, webServer, ignoreDupes, cb) {
const collectionId = Endpoints.following(webServer, owningUser);
return Collection.addToCollection(
'following',
Collections.Following,
owningUser,
collectionId,
followingActor.id, // Actor owningUser is following
@ -123,7 +130,7 @@ module.exports = class Collection extends ActivityPubObject {
static addOutboxItem(owningUser, outboxItem, isPrivate, webServer, ignoreDupes, cb) {
const collectionId = Endpoints.outbox(webServer, owningUser);
return Collection.addToCollection(
'outbox',
Collections.Outbox,
owningUser,
collectionId,
outboxItem.id,
@ -137,7 +144,7 @@ module.exports = class Collection extends ActivityPubObject {
static addInboxItem(inboxItem, owningUser, webServer, ignoreDupes, cb) {
const collectionId = Endpoints.inbox(webServer, owningUser);
return Collection.addToCollection(
'inbox',
Collections.Inbox,
owningUser,
collectionId,
inboxItem.id,
@ -150,7 +157,7 @@ module.exports = class Collection extends ActivityPubObject {
static addSharedInboxItem(inboxItem, ignoreDupes, cb) {
return Collection.addToCollection(
'sharedInbox',
Collections.SharedInbox,
null, // N/A
Collection.PublicCollectionId,
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(
`SELECT name, timestamp, owner_actor_id, object_json, is_private
FROM collection
WHERE name = ? AND object_id = ?
LIMIT 1;`,
[objectId],
[collectionName, objectId],
(err, row) => {
if (err) {
return cb(err);

View File

@ -31,3 +31,13 @@ exports.HttpSignatureSignHeaders = [
'digest',
'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 {
constructor(options) {
super(options);
this.config = Object.assign({}, get(options, 'menuConfig.config'), {
extraArgs: options.extraArgs,
});
this.setConfigWithExtraArgs(options);
this.menuMethods = {
mainSubmit: (formData, extraArgs, cb) => {

View File

@ -3,8 +3,7 @@ const { Errors, ErrorReasons } = require('../enig_error');
const UserProps = require('../user_property');
const ActivityPubSettings = require('./settings');
const { stripAnsiControlCodes } = require('../string_util');
const { WellKnownRecipientFields, WellKnownActivity } = require('./const');
const ActivityPubObject = require('./object');
const { WellKnownRecipientFields } = require('./const');
const Log = require('../logger').log;
// deps
@ -29,7 +28,6 @@ exports.userNameFromSubject = userNameFromSubject;
exports.userNameToSubject = userNameToSubject;
exports.extractMessageMetadata = extractMessageMetadata;
exports.recipientIdsFromObject = recipientIdsFromObject;
exports.sendFollowRequest = sendFollowRequest;
// :TODO: more info in default
// this profile template is the *default* for both WebFinger
@ -264,23 +262,3 @@ function recipientIdsFromObject(obj) {
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 {
ActivityStreamMediaType,
WellKnownActivity,
Collections,
} = require('../../../activitypub/const');
const Config = require('../../../config').get;
const Activity = require('../../../activitypub/activity');
@ -64,7 +65,12 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
req,
resp,
(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,
resp,
signature,
'sharedInbox'
Collections.SharedInbox
);
}
);
@ -262,7 +268,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
switch (activity.type) {
case WellKnownActivity.Accept:
break;
return this._inboxAcceptActivity(resp, activity);
case WellKnownActivity.Add:
break;
@ -292,17 +298,17 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
case WellKnownActivity.Follow:
// Follow requests are only allowed directly
if ('inbox' === inboxType) {
if (Collections.Inbox === inboxType) {
return this._inboxFollowActivity(resp, remoteActor, activity);
}
break;
case WellKnownActivity.Reject:
break;
return this._inboxRejectActivity(resp, activity);
case WellKnownActivity.Undo:
// We only Undo from private inboxes
if ('inbox' === inboxType) {
if (Collections.Inbox === inboxType) {
// Only Follow Undo's currently supported
const type = _.get(activity, 'object.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) {
const createWhat = _.get(activity, 'object.type');
switch (createWhat) {
@ -341,10 +362,23 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
{ type: createWhat },
'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) {
const note = new Note(activity.object);
if (!note.isValid()) {
@ -380,10 +414,107 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
);
}
_inboxDeleteActivity(inboxType, signature, resp /*, activity*/) {
// :TODO: Implement me!
_inboxRejectFollowActivity(resp, activity) {
// 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: 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);
}
@ -402,7 +533,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
const activityPubSettings = ActivityPubSettings.fromUser(localUser);
if (!activityPubSettings.manuallyApproveFollowers) {
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
@ -416,12 +547,27 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
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) {
const localActorId = _.get(activity, 'object.object');
@ -435,22 +581,27 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
return this.webServer.resourceNotFound(resp);
}
Collection.removeOwnedById('followers', localUser, remoteActor.id, err => {
if (err) {
return this.webServer.internalServerError(resp, err);
Collection.removeOwnedById(
Collections.Followers,
localUser,
remoteActor.id,
err => {
if (err) {
return this.webServer.internalServerError(resp, err);
}
this.log.info(
{
username: localUser.username,
userId: localUser.userId,
remoteActorId: remoteActor.id,
},
'Undo "Follow" (un-follow) success'
);
return this.webServer.accepted(resp);
}
this.log.info(
{
username: localUser.username,
userId: localUser.userId,
remoteActorId: remoteActor.id,
},
'Undo "Follow" (un-follow) success'
);
return this.webServer.accepted(resp);
});
);
});
}
@ -712,18 +863,18 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
_followingGetHandler(req, resp) {
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) {
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/
_outboxGetHandler(req, resp) {
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) {

View File

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