Start Collection, some extra Actor props, start Followers, cleanup/DRY/etc.

This commit is contained in:
Bryan Ashby 2023-01-13 21:27:02 -07:00
parent 84dde6c5c5
commit 315d77b1c0
No known key found for this signature in database
GPG Key ID: C2C1B501E4EFD994
7 changed files with 271 additions and 57 deletions

View File

@ -151,6 +151,7 @@ module.exports = class Activity {
); );
} }
// :TODO: move to Collection
static fromOutboxEntries(owningUser, webServer, cb) { static fromOutboxEntries(owningUser, webServer, cb) {
// :TODO: support paging // :TODO: support paging
const getOpts = { const getOpts = {
@ -183,11 +184,11 @@ module.exports = class Activity {
} }
sendTo(actorUrl, fromUser, webServer, cb) { sendTo(actorUrl, fromUser, webServer, cb) {
const privateKey = fromUser.getProperty(UserProps.PrivateKeyMain); const privateKey = fromUser.getProperty(UserProps.PrivateActivityPubSigningKey);
if (_.isEmpty(privateKey)) { if (_.isEmpty(privateKey)) {
return cb( return cb(
Errors.MissingProperty( Errors.MissingProperty(
`User "${fromUser.username}" is missing the '${UserProps.PrivateKeyMain}' property` `User "${fromUser.username}" is missing the '${UserProps.PrivateActivityPubSigningKey}' property`
) )
); );
} }

View File

@ -14,12 +14,13 @@ const {
const Log = require('../logger').log; const Log = require('../logger').log;
const { queryWebFinger } = require('../webfinger'); const { queryWebFinger } = require('../webfinger');
const EnigAssert = require('../enigma_assert'); const EnigAssert = require('../enigma_assert');
const ActivityPubSettings = require('./settings');
// deps // deps
const _ = require('lodash'); const _ = require('lodash');
const https = require('https'); const https = require('https');
const isString = require('lodash/isString'); const isString = require('lodash/isString');
const mimeTypes = require('mime-types');
// https://www.w3.org/TR/activitypub/#actor-objects // https://www.w3.org/TR/activitypub/#actor-objects
module.exports = class Actor { module.exports = class Actor {
@ -63,6 +64,21 @@ module.exports = class Actor {
// :TODO: from a User object // :TODO: from a User object
static fromLocalUser(user, webServer, cb) { static fromLocalUser(user, webServer, cb) {
const userSelfUrl = selfUrl(webServer, user); const userSelfUrl = selfUrl(webServer, user);
const userSettings = ActivityPubSettings.fromUser(user);
const addImage = (o, t) => {
const url = userSettings[t];
if (url) {
const mt = mimeTypes.contentType(url);
if (mt) {
o[t] = {
mediaType: mt,
type: 'Image',
url,
};
}
}
};
const obj = { const obj = {
'@context': [ '@context': [
@ -82,7 +98,8 @@ module.exports = class Actor {
following: makeUserUrl(webServer, user, '/ap/users/') + '/following', following: makeUserUrl(webServer, user, '/ap/users/') + '/following',
summary: user.getProperty(UserProps.AutoSignature) || '', summary: user.getProperty(UserProps.AutoSignature) || '',
url: webFingerProfileUrl(webServer, user), url: webFingerProfileUrl(webServer, user),
manuallyApprovesFollowers: userSettings.manuallyApprovesFollowers,
discoverable: userSettings.discoverable,
// :TODO: we can start to define BBS related stuff with the community perhaps // :TODO: we can start to define BBS related stuff with the community perhaps
// attachment: [ // attachment: [
// { // {
@ -93,6 +110,9 @@ module.exports = class Actor {
// ], // ],
}; };
addImage('icon');
addImage('image');
const publicKeyPem = user.getProperty(UserProps.PublicActivityPubSigningKey); const publicKeyPem = user.getProperty(UserProps.PublicActivityPubSigningKey);
if (!_.isEmpty(publicKeyPem)) { if (!_.isEmpty(publicKeyPem)) {
obj.publicKey = { obj.publicKey = {

View File

@ -0,0 +1,48 @@
const { ActivityStreamsContext, makeUserUrl } = require('./util');
const { FollowerEntryStatus, getFollowerEntries } = require('./db');
module.exports = class Collection {
constructor(obj) {
this['@context'] = ActivityStreamsContext;
Object.assign(this, obj);
}
static followers(owningUser, page, webServer, cb) {
if (!page) {
const followersUrl =
makeUserUrl(webServer, owningUser, '/ap/users/') + '/followers';
const obj = {
id: followersUrl,
type: 'OrderedCollection',
first: `${followersUrl}?page=1`,
totalItems: 1,
};
return cb(null, new Collection(obj));
}
// :TODO: actually support paging...
page = parseInt(page);
const getOpts = {
status: FollowerEntryStatus.Accepted,
};
getFollowerEntries(owningUser, getOpts, (err, followers) => {
if (err) {
return cb(err);
}
const baseId = makeUserUrl(webServer, owningUser, '/ap/users') + '/followers';
const obj = {
id: `${baseId}/page=${page}`,
type: 'OrderedCollectionPage',
totalItems: followers.length,
orderedItems: followers,
partOf: baseId,
};
return cb(null, new Collection(obj));
});
}
};

View File

@ -2,6 +2,16 @@ const apDb = require('../database').dbs.activitypub;
exports.persistToOutbox = persistToOutbox; exports.persistToOutbox = persistToOutbox;
exports.getOutboxEntries = getOutboxEntries; exports.getOutboxEntries = getOutboxEntries;
exports.persistFollower = persistFollower;
exports.getFollowerEntries = getFollowerEntries;
const FollowerEntryStatus = {
Invalid: 0, // Invalid
Requested: 1, // Entry is a *request* to local user
Accepted: 2, // Accepted by local user
Rejected: 3, // Rejected by local user
};
exports.FollowerEntryStatus = FollowerEntryStatus;
function persistToOutbox(activity, fromUser, message, cb) { function persistToOutbox(activity, fromUser, message, cb) {
const activityJson = JSON.stringify(activity); const activityJson = JSON.stringify(activity);
@ -55,3 +65,36 @@ function getOutboxEntries(owningUser, options, cb) {
} }
); );
} }
function persistFollower(localUser, remoteActor, options, cb) {
const status = options.status || FollowerEntryStatus.Requested;
apDb.run(
`INSERT OR IGNORE INTO followers (user_id, follower_id, status)
VALUES (?, ?, ?);`,
[localUser.userId, remoteActor.id, status],
function res(err) {
// non-arrow for 'this' scope
return cb(err, this.lastID);
}
);
}
function getFollowerEntries(localUser, options, cb) {
const status = options.status || FollowerEntryStatus.Accepted;
apDb.all(
`SELECT follower_id
FROM followers
WHERE user_id = ? AND status = ?;`,
[localUser.userId, status],
(err, rows) => {
if (err) {
return cb(err);
}
const entries = rows.map(r => r.follower_id);
return cb(null, entries);
}
);
}

View File

@ -6,8 +6,8 @@ module.exports = class ActivityPubSettings {
this.manuallyApproveFollowers = false; this.manuallyApproveFollowers = false;
this.hideSocialGraph = false; // followers, following this.hideSocialGraph = false; // followers, following
this.showRealName = false; this.showRealName = false;
this.imageUrl = ''; this.image = '';
this.iconUrl = ''; this.icon = '';
if (obj) { if (obj) {
Object.assign(this, obj); Object.assign(this, obj);

View File

@ -531,6 +531,17 @@ dbs.message.run(
ON outbox (json_extract(activity_json, '$.type'));` ON outbox (json_extract(activity_json, '$.type'));`
); );
dbs.activitypub.run(
`CREATE TABLE IF NOT EXISTS followers (
id INTEGER PRIMARY KEY, -- Local ID
user_id INTEGER NOT NULL, -- Local user ID
follower_id VARCHAR NOT NULL, -- Actor ID of follower
status INTEGER NOT NULL, -- Status: See FollowerEntryStatus
UNIQUE(user_id, follower_id)
);`
);
return cb(null); return cb(null);
}, },
}; };

View File

@ -9,11 +9,14 @@ const Config = require('../../../config').get;
const Activity = require('../../../activitypub/activity'); const Activity = require('../../../activitypub/activity');
const ActivityPubSettings = require('../../../activitypub/settings'); const ActivityPubSettings = require('../../../activitypub/settings');
const Actor = require('../../../activitypub/actor'); const Actor = require('../../../activitypub/actor');
const Collection = require('../../../activitypub/collection');
const { persistFollower, FollowerEntryStatus } = require('../../../activitypub/db');
// deps // deps
const _ = require('lodash'); const _ = require('lodash');
const enigma_assert = require('../../../enigma_assert'); const enigma_assert = require('../../../enigma_assert');
const httpSignature = require('http-signature'); const httpSignature = require('http-signature');
const async = require('async');
exports.moduleInfo = { exports.moduleInfo = {
name: 'ActivityPub', name: 'ActivityPub',
@ -49,10 +52,16 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
this.webServer.addRoute({ this.webServer.addRoute({
method: 'GET', method: 'GET',
path: /^\/_enig\/ap\/users\/.+\/outbox(\?page=true)?$/, path: /^\/_enig\/ap\/users\/.+\/outbox$/,
handler: this._outboxGetHandler.bind(this), handler: this._outboxGetHandler.bind(this),
}); });
this.webServer.addRoute({
method: 'GET',
path: /^\/_enig\/ap\/users\/.+\/followers(\?page=[0-9]+)?$/,
handler: this._followersGetHandler.bind(this),
});
// :TODO: NYI // :TODO: NYI
// this.webServer.addRoute({ // this.webServer.addRoute({
// method: 'GET', // method: 'GET',
@ -164,12 +173,11 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
// /_enig/ap/users/SomeName/outbox -> SomeName // /_enig/ap/users/SomeName/outbox -> SomeName
const url = new URL(req.url, `https://${req.headers.host}`); const url = new URL(req.url, `https://${req.headers.host}`);
const m = url.pathname.match(/^\/_enig\/ap\/users\/(.+)\/outbox$/); const accountName = this._accountNameFromUserPath(url, 'outbox');
if (!m || !m[1]) { if (!accountName) {
return this.webServer.resourceNotFound(resp); return this.webServer.resourceNotFound(resp);
} }
const accountName = m[1];
userFromAccount(accountName, (err, user) => { userFromAccount(accountName, (err, user) => {
if (err) { if (err) {
this.log.info( this.log.info(
@ -197,6 +205,61 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
}); });
} }
_accountNameFromUserPath(url, suffix) {
const re = new RegExp(`^/_enig/ap/users/(.+)/${suffix}(\\?page=[0-9]+)?$`);
const m = url.pathname.match(re);
if (!m || !m[1]) {
return null;
}
return m[1];
}
_followersGetHandler(req, resp) {
this.log.trace({ url: req.url }, 'Request for "followers"');
// :TODO: dry this stuff..
// the request must be signed, and the signature must be valid
const signature = this._parseAndValidateSignature(req);
if (!signature) {
return this.webServer.accessDenied(resp);
}
// /_enig/ap/users/SomeName/outbox -> SomeName
const url = new URL(req.url, `https://${req.headers.host}`);
const accountName = this._accountNameFromUserPath(url, 'followers');
if (!accountName) {
return this.webServer.resourceNotFound(resp);
}
userFromAccount(accountName, (err, user) => {
if (err) {
this.log.info(
{ reason: err.message, accountName: accountName },
`No user "${accountName}" for "self"`
);
return this.webServer.resourceNotFound(resp);
}
const page = url.searchParams.get('page');
Collection.followers(user, page, this.webServe, (err, collection) => {
if (err) {
// :TODO: LOG ME
return this.webServer.internalServerError(resp);
}
const body = JSON.stringify(collection);
const headers = {
'Content-Type': ActivityJsonMime,
'Content-Length': body.length,
};
resp.writeHead(200, headers);
return resp.end(body);
});
});
}
_parseAndValidateSignature(req) { _parseAndValidateSignature(req) {
let signature; let signature;
try { try {
@ -285,53 +348,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
// :TODO: Implement the queue // :TODO: Implement the queue
const activityPubSettings = ActivityPubSettings.fromUser(user); const activityPubSettings = ActivityPubSettings.fromUser(user);
if (!activityPubSettings.manuallyApproveFollowers) { if (!activityPubSettings.manuallyApproveFollowers) {
Actor.fromLocalUser(user, this.webServer, (err, localActor) => { this._recordAcceptedFollowRequest(user, actor, activity);
if (err) {
return this.log.warn(
{ inbox: actor.inbox, error: err.message },
'Failed to load local Actor for "Accept"'
);
}
const accept = Activity.makeAccept(
this.webServer,
localActor,
activity
);
accept.sendTo(
actor.inbox,
user,
this.webServer,
(err, respBody, res) => {
if (err) {
return this.log.warn(
{
inbox: actor.inbox,
statusCode: res.statusCode,
error: err.message,
},
'Failed POSTing "Accept" to inbox'
);
}
if (res.statusCode !== 202 && res.statusCode !== 200) {
return this.log.warn(
{
inbox: actor.inbox,
statusCode: res.statusCode,
},
'Unexpected status code'
);
}
this.log.trace(
{ inbox: actor.inbox },
'Remote server received our "Accept" successfully'
);
}
);
});
} }
resp.writeHead(200, { 'Content-Type': 'text/html' }); resp.writeHead(200, { 'Content-Type': 'text/html' });
@ -340,6 +357,80 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
}); });
} }
_recordAcceptedFollowRequest(localUser, remoteActor, requestActivity) {
async.series(
[
callback => {
const persistOpts = {
status: FollowerEntryStatus.Accepted,
};
return persistFollower(localUser, remoteActor, persistOpts, callback);
},
callback => {
Actor.fromLocalUser(localUser, this.webServer, (err, localActor) => {
if (err) {
this.log.warn(
{ inbox: remoteActor.inbox, error: err.message },
'Failed to load local Actor for "Accept"'
);
return callback(err);
}
const accept = Activity.makeAccept(
this.webServer,
localActor,
requestActivity
);
accept.sendTo(
remoteActor.inbox,
localUser,
this.webServer,
(err, respBody, res) => {
if (err) {
this.log.warn(
{
inbox: remoteActor.inbox,
error: err.message,
},
'Failed POSTing "Accept" to inbox'
);
return callback(null); // just a warning
}
if (res.statusCode !== 202 && res.statusCode !== 200) {
this.log.warn(
{
inbox: remoteActor.inbox,
statusCode: res.statusCode,
},
'Unexpected status code'
);
return callback(null); // just a warning
}
this.log.trace(
{ inbox: remoteActor.inbox },
'Remote server received our "Accept" successfully'
);
return callback(null);
}
);
});
},
],
err => {
if (err) {
this.log.error(
{ error: err.message },
'Failed processing Follow request'
);
}
}
);
}
_authorizeInteractionHandler(req, resp) { _authorizeInteractionHandler(req, resp) {
console.log(req); console.log(req);
console.log(resp); console.log(resp);