Start Collection, some extra Actor props, start Followers, cleanup/DRY/etc.
This commit is contained in:
parent
84dde6c5c5
commit
315d77b1c0
|
@ -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`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
|
@ -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);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -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);
|
||||||
|
|
Loading…
Reference in New Issue