Re-work of ActivityPub DBs and various account lookups

* Always look up Actors by explicit Actor IDs
* Re-work DB: style, properties we track, etc.
* Create AP properties via a event!
* Lots of cleanup
* WF may be partially broken if loooking up by 'profile' alias URL: WIP
This commit is contained in:
Bryan Ashby 2023-01-28 11:55:31 -07:00
parent 8dd28e3091
commit 9b01124b2e
No known key found for this signature in database
GPG Key ID: C2C1B501E4EFD994
17 changed files with 593 additions and 355 deletions

View File

@ -1,4 +1,4 @@
const { selfUrl } = require('./util');
const { localActorId } = require('./util');
const { WellKnownActivityTypes } = require('./const');
const ActivityPubObject = require('./object');
const { Errors } = require('../enig_error');
@ -18,6 +18,11 @@ module.exports = class Activity extends ActivityPubObject {
return WellKnownActivityTypes;
}
static fromJsonString(s) {
const obj = ActivityPubObject.fromJsonString(s);
return new Activity(obj);
}
static makeFollow(webServer, localActor, remoteActor) {
return new Activity({
id: Activity.activityObjectId(webServer),
@ -74,7 +79,7 @@ module.exports = class Activity extends ActivityPubObject {
sign: {
// :TODO: Make a helper for this
key: privateKey,
keyId: selfUrl(webServer, fromUser) + '#main-key',
keyId: localActorId(webServer, fromUser) + '#main-key',
authorizationHeaderName: 'Signature',
headers: ['(request-target)', 'host', 'date', 'digest', 'content-type'],
},
@ -84,6 +89,23 @@ module.exports = class Activity extends ActivityPubObject {
return postJson(actorUrl, activityJson, reqOpts, cb);
}
recipientIds() {
const ids = [];
// :TODO: bto, bcc?
['to', 'cc', 'audience'].forEach(field => {
let v = this[field];
if (v) {
if (!Array.isArray(v)) {
v = [v];
}
ids.push(...v);
}
});
return ids;
}
static activityObjectId(webServer) {
return ActivityPubObject.makeObjectId(webServer, 'activity');
}

View File

@ -8,7 +8,6 @@ const {
ActivityStreamsContext,
webFingerProfileUrl,
makeUserUrl,
selfUrl,
isValidLink,
makeSharedInboxUrl,
userNameFromSubject,
@ -68,7 +67,15 @@ module.exports = class Actor extends ActivityPubObject {
}
static fromLocalUser(user, webServer, cb) {
const userSelfUrl = selfUrl(webServer, user);
const userActorId = user.getProperty(UserProps.ActivityPubActorId);
if (!userActorId) {
return cb(
Errors.MissingProperty(
`User missing '${UserProps.ActivityPubActorId}' property`
)
);
}
const userSettings = ActivityPubSettings.fromUser(user);
const addImage = (o, t) => {
@ -90,7 +97,7 @@ module.exports = class Actor extends ActivityPubObject {
ActivityStreamsContext,
'https://w3id.org/security/v1', // :TODO: add support
],
id: userSelfUrl,
id: userActorId,
type: 'Person',
preferredUsername: user.username,
name: userSettings.showRealName
@ -123,8 +130,8 @@ module.exports = class Actor extends ActivityPubObject {
const publicKeyPem = user.getProperty(UserProps.PublicActivityPubSigningKey);
if (!_.isEmpty(publicKeyPem)) {
obj.publicKey = {
id: userSelfUrl + '#main-key',
owner: userSelfUrl,
id: userActorId + '#main-key',
owner: userActorId,
publicKeyPem,
};
@ -219,6 +226,7 @@ module.exports = class Actor extends ActivityPubObject {
const timestamp = moment(row.timestamp);
if (moment().isAfter(timestamp.add(ActorCacheTTL))) {
// :TODO: drop from cache
return cb(Errors.Expired('The cache entry is expired'));
}

View File

@ -4,11 +4,10 @@ const apDb = require('../database').dbs.activitypub;
const { getISOTimestampString } = require('../database');
const { Errors } = require('../enig_error.js');
const { PublicCollectionId: APPublicCollectionId } = require('./const');
const UserProps = require('../user_property');
// deps
const { isString, get, isObject } = require('lodash');
const APPublicOwningUserId = 0;
const { isString } = require('lodash');
module.exports = class Collection extends ActivityPubObject {
constructor(obj) {
@ -19,34 +18,33 @@ module.exports = class Collection extends ActivityPubObject {
return APPublicCollectionId;
}
static followers(owningUser, page, webServer, cb) {
return Collection.getOrdered(
static followers(collectionId, page, cb) {
return Collection.publicOrderedById(
'followers',
owningUser,
false,
collectionId,
page,
e => e.id,
webServer,
cb
);
}
static following(owningUser, page, webServer, cb) {
return Collection.getOrdered(
static following(collectionId, page, cb) {
return Collection.publicOrderedById(
'following',
owningUser,
false,
collectionId,
page,
e => get(e, 'object.id'),
webServer,
e => e.id,
cb
);
}
static addFollower(owningUser, followingActor, cb) {
static addFollower(owningUser, followingActor, webServer, cb) {
const collectionId =
makeUserUrl(webServer, owningUser, '/ap/collections/') + '/followers';
return Collection.addToCollection(
'followers',
owningUser,
collectionId,
followingActor.id,
followingActor,
false,
@ -54,10 +52,13 @@ module.exports = class Collection extends ActivityPubObject {
);
}
static addFollowRequest(owningUser, requestingActor, cb) {
static addFollowRequest(owningUser, requestingActor, webServer, cb) {
const collectionId =
makeUserUrl(webServer, owningUser, '/ap/collections/') + '/follow-requests';
return Collection.addToCollection(
'follow_requests',
'follow-requests',
owningUser,
collectionId,
requestingActor.id,
requestingActor,
true,
@ -65,22 +66,17 @@ module.exports = class Collection extends ActivityPubObject {
);
}
static outbox(owningUser, page, webServer, cb) {
return Collection.getOrdered(
'outbox',
owningUser,
false,
page,
null,
webServer,
cb
);
static outbox(collectionId, page, cb) {
return Collection.publicOrderedById('outbox', collectionId, page, null, cb);
}
static addOutboxItem(owningUser, outboxItem, isPrivate, cb) {
static addOutboxItem(owningUser, outboxItem, isPrivate, webServer, cb) {
const collectionId =
makeUserUrl(webServer, owningUser, '/ap/collections/') + '/outbox';
return Collection.addToCollection(
'outbox',
owningUser,
collectionId,
outboxItem.id,
outboxItem,
isPrivate,
@ -88,10 +84,13 @@ module.exports = class Collection extends ActivityPubObject {
);
}
static addInboxItem(inboxItem, owningUser, cb) {
static addInboxItem(inboxItem, owningUser, webServer, cb) {
const collectionId =
makeUserUrl(webServer, owningUser, '/ap/collections/') + '/inbox';
return Collection.addToCollection(
'inbox',
owningUser,
collectionId,
inboxItem.id,
inboxItem,
true,
@ -102,7 +101,8 @@ module.exports = class Collection extends ActivityPubObject {
static addPublicInboxItem(inboxItem, cb) {
return Collection.addToCollection(
'publicInbox',
APPublicOwningUserId,
null, // N/A
Collection.PublicCollectionId,
inboxItem.id,
inboxItem,
false,
@ -114,11 +114,11 @@ module.exports = class Collection extends ActivityPubObject {
const privateQuery = includePrivate ? '' : ' AND is_private = FALSE';
apDb.get(
`SELECT obj_json
`SELECT object_json
FROM collection
WHERE name = ?
${privateQuery}
AND json_extract(obj_json, '$.object.id') = ?;`,
AND json_extract(object_json, '$.object.id') = ?;`,
[collectionName, objectId],
(err, row) => {
if (err) {
@ -133,7 +133,7 @@ module.exports = class Collection extends ActivityPubObject {
);
}
const obj = ActivityPubObject.fromJsonString(row.obj_json);
const obj = ActivityPubObject.fromJsonString(row.object_json);
if (!obj) {
return cb(Errors.Invalid('Failed to parse Object JSON'));
}
@ -143,7 +143,71 @@ module.exports = class Collection extends ActivityPubObject {
);
}
static getOrdered(
static publicOrderedById(collectionName, collectionId, page, mapper, cb) {
if (!page) {
return apDb.get(
`SELECT COUNT(collection_id) AS count
FROM collection
WHERE name = ? AND collection_id = ? AND is_private = FALSE;`,
[collectionName, collectionId],
(err, row) => {
if (err) {
return cb(err);
}
let obj;
if (row.count > 0) {
obj = {
id: collectionId,
type: 'OrderedCollection',
first: `${collectionId}?page=1`,
totalItems: row.count,
};
} else {
obj = {
id: collectionId,
type: 'OrderedCollection',
totalItems: 0,
orderedItems: [],
};
}
return cb(null, new Collection(obj));
}
);
}
// :TODO: actual paging...
apDb.all(
`SELECT object_json
FROM collection
WHERE name = ? AND collection_id = ? AND is_private = FALSE
ORDER BY timestamp;`,
[collectionName, collectionId],
(err, entries) => {
if (err) {
return cb(err);
}
entries = entries || [];
if (mapper && entries.length > 0) {
entries = entries.map(mapper);
}
const obj = {
id: `${collectionId}/page=${page}`,
type: 'OrderedCollectionPage',
totalItems: entries.length,
orderedItems: entries,
partOf: collectionId,
};
return cb(null, new Collection(obj));
}
);
}
static ownedOrderedByUser(
collectionName,
owningUser,
includePrivate,
@ -153,19 +217,25 @@ module.exports = class Collection extends ActivityPubObject {
cb
) {
const privateQuery = includePrivate ? '' : ' AND is_private = FALSE';
const owningUserId = isObject(owningUser) ? owningUser.userId : owningUser;
const actorId = owningUser.getProperty(UserProps.ActivityPubActorId);
if (!actorId) {
return cb(
Errors.MissingProperty(
`User "${owningUser.username}" is missing property '${UserProps.ActivityPubActorId}'`
)
);
}
// e.g. http://some.host/_enig/ap/collections/1234/followers
const collectionIdBase =
makeUserUrl(webServer, owningUser, `/ap/collections/${owningUserId}`) +
`/${collectionName}`;
// e.g. http://somewhere.com/_enig/ap/collections/NuSkooler/followers
const collectionId =
makeUserUrl(webServer, owningUser, '/ap/collections/') + `/${collectionName}`;
if (!page) {
return apDb.get(
`SELECT COUNT(id) AS count
`SELECT COUNT(collection_id) AS count
FROM collection
WHERE user_id = ? AND name = ?${privateQuery};`,
[owningUserId, collectionName],
WHERE owner_actor_id = ? AND name = ?${privateQuery};`,
[actorId, collectionName],
(err, row) => {
if (err) {
return cb(err);
@ -180,14 +250,14 @@ module.exports = class Collection extends ActivityPubObject {
let obj;
if (row.count > 0) {
obj = {
id: collectionIdBase,
id: collectionId,
type: 'OrderedCollection',
first: `${collectionIdBase}?page=1`,
first: `${collectionId}?page=1`,
totalItems: row.count,
};
} else {
obj = {
id: collectionIdBase,
id: collectionId,
type: 'OrderedCollection',
totalItems: 0,
orderedItems: [],
@ -201,11 +271,11 @@ module.exports = class Collection extends ActivityPubObject {
// :TODO: actual paging...
apDb.all(
`SELECT obj_json
`SELECT object_json
FROM collection
WHERE user_id = ? AND name = ?${privateQuery}
WHERE owner_actor_id = ? AND name = ?${privateQuery}
ORDER BY timestamp;`,
[owningUserId, collectionName],
[actorId, collectionName],
(err, entries) => {
if (err) {
return cb(err);
@ -217,11 +287,11 @@ module.exports = class Collection extends ActivityPubObject {
}
const obj = {
id: `${collectionIdBase}/page=${page}`,
id: `${collectionId}/page=${page}`,
type: 'OrderedCollectionPage',
totalItems: entries.length,
orderedItems: entries,
partOf: collectionIdBase,
partOf: collectionId,
};
return cb(null, new Collection(obj));
@ -239,8 +309,8 @@ module.exports = class Collection extends ActivityPubObject {
apDb.run(
`UPDATE collection
SET obj_json = ?, timestamp = ?
WHERE name = ? AND obj_id = ?;`,
SET object_json = ?, timestamp = ?
WHERE name = ? AND object_id = ?;`,
[obj, collectionName, getISOTimestampString(), objectId],
err => {
return cb(err);
@ -248,20 +318,43 @@ module.exports = class Collection extends ActivityPubObject {
);
}
static addToCollection(collectionName, owningUser, objectId, obj, isPrivate, cb) {
static addToCollection(
collectionName,
owningUser,
collectionId,
objectId,
obj,
isPrivate,
cb
) {
if (!isString(obj)) {
obj = JSON.stringify(obj);
}
const owningUserId = isObject(owningUser) ? owningUser.userId : owningUser;
let actorId;
if (owningUser) {
actorId = owningUser.getProperty(UserProps.ActivityPubActorId);
if (!actorId) {
return cb(
Errors.MissingProperty(
`User "${owningUser.username}" is missing property '${UserProps.ActivityPubActorId}'`
)
);
}
} else {
actorId = Collection.APPublicCollectionId;
}
isPrivate = isPrivate ? 1 : 0;
apDb.run(
`INSERT OR IGNORE INTO collection (name, timestamp, user_id, obj_id, obj_json, is_private)
VALUES (?, ?, ?, ?, ?, ?);`,
`INSERT OR IGNORE INTO collection (name, timestamp, collection_id, owner_actor_id, object_id, object_json, is_private)
VALUES (?, ?, ?, ?, ?, ?, ?);`,
[
collectionName,
getISOTimestampString(),
owningUserId,
collectionId,
actorId,
objectId,
obj,
isPrivate,
@ -269,6 +362,9 @@ module.exports = class Collection extends ActivityPubObject {
function res(err) {
// non-arrow for 'this' scope
if (err) {
if ('SQLITE_CONSTRAINT' === err.code) {
err = null; // ignore
}
return cb(err);
}
return cb(err, this.lastID);
@ -277,11 +373,18 @@ module.exports = class Collection extends ActivityPubObject {
}
static removeFromCollectionById(collectionName, owningUser, objectId, cb) {
const owningUserId = isObject(owningUser) ? owningUser.userId : owningUser;
const actorId = owningUser.getProperty(UserProps.ActivityPubActorId);
if (!actorId) {
return cb(
Errors.MissingProperty(
`User "${owningUser.username}" is missing property '${UserProps.ActivityPubActorId}'`
)
);
}
apDb.run(
`DELETE FROM collection
WHERE user_id = ? AND name = ? AND obj_id = ?;`,
[owningUserId, collectionName, objectId],
WHERE name = ? AND owner_actor_id = ? AND object_id = ?;`,
[collectionName, actorId, objectId],
err => {
return cb(err);
}

View File

@ -5,6 +5,7 @@ const { getISOTimestampString } = require('../database');
const User = require('../user');
const { messageBodyToHtml, htmlToMessageBody } = require('./util');
const { isAnsi } = require('../string_util');
const Log = require('../logger').log;
// deps
const { v5: UUIDv5 } = require('uuid');
@ -187,7 +188,10 @@ module.exports = class Note extends ActivityPubObject {
try {
message.modTimestamp = moment(this.published);
} catch (e) {
// :TODO: Log warning
Log.warn(
{ published: this.published, error: e.message },
'Failed to parse Note published timestamp'
);
message.modTimestamp = moment();
}
@ -203,9 +207,30 @@ module.exports = class Note extends ActivityPubObject {
if (this.inReplyTo) {
message.meta.ActivityPub[Message.ActivityPubPropertyNames.InReplyTo] =
this.inReplyTo;
const filter = {
resultType: 'id',
metaTuples: [
{
category: Message.WellKnownMetaCategories.ActivityPub,
name: Message.ActivityPubPropertyNames.InReplyTo,
value: this.inReplyTo,
},
],
limit: 1,
};
Message.findMessages(filter, (err, messageId) => {
if (messageId) {
// we get an array, but limited 1; use the first
messageId = messageId[0];
message.replyToMsgId = messageId;
}
return cb(null, message);
});
} else {
return cb(null, message);
}
});
}
};

View File

@ -1,14 +1,18 @@
const UserProps = require('../user_property');
const Config = require('../config').get;
module.exports = class ActivityPubSettings {
constructor(obj) {
this.enabled = true; // :TODO: fetch from +op config default
this.enabled = true;
this.manuallyApproveFollowers = false;
this.hideSocialGraph = false; // followers, following
this.showRealName = true;
this.image = '';
this.icon = '';
// override default with any op config
Object.assign(this, Config().users.activityPub);
if (obj) {
Object.assign(this, obj);
}

View File

@ -20,9 +20,8 @@ exports.isValidLink = isValidLink;
exports.makeSharedInboxUrl = makeSharedInboxUrl;
exports.makeUserUrl = makeUserUrl;
exports.webFingerProfileUrl = webFingerProfileUrl;
exports.selfUrl = selfUrl;
exports.userFromAccount = userFromAccount;
exports.accountFromSelfUrl = accountFromSelfUrl;
exports.localActorId = localActorId;
exports.userFromActorId = userFromActorId;
exports.getUserProfileTemplatedBody = getUserProfileTemplatedBody;
exports.messageBodyToHtml = messageBodyToHtml;
exports.htmlToMessageBody = htmlToMessageBody;
@ -59,26 +58,26 @@ function webFingerProfileUrl(webServer, user) {
return webServer.buildUrl(WellKnownLocations.Internal + `/wf/@${user.username}`);
}
function selfUrl(webServer, user) {
function localActorId(webServer, user) {
return makeUserUrl(webServer, user, '/ap/users/');
}
function accountFromSelfUrl(url) {
// https://some.l33t.enigma.board/_enig/ap/users/Masto -> Masto
// :TODO: take webServer, and just take path-to-users.length +1
return url.substring(url.lastIndexOf('/') + 1);
}
function userFromAccount(accountName, cb) {
if (accountName.startsWith('@')) {
accountName = accountName.slice(1);
}
User.getUserIdAndName(accountName, (err, userId) => {
function userFromActorId(actorId, cb) {
User.getUserIdsWithProperty(UserProps.ActivityPubActorId, actorId, (err, userId) => {
if (err) {
return cb(err);
}
// must only be 0 or 1
if (!Array.isArray(userId) || userId.length !== 1) {
return cb(
Errors.DoesNotExist(
`No user with property '${UserProps.ActivityPubActorId}' of ${actorId}`
)
);
}
userId = userId[0];
User.getUser(userId, (err, user) => {
if (err) {
return cb(err);

View File

@ -136,6 +136,14 @@ module.exports = () => {
storagePath: paths.join(__dirname, '../userdata/avatars/'),
spritesPath: paths.join(__dirname, '../misc/avatar-sprites/'),
},
// See also ./core/activitypub/settings.js
activityPub: {
enabled: true, // ActivityPub enabled for this user?
manuallyApproveFollowers: false,
hideSocialGraph: false,
showRealName: true,
},
},
theme: {

View File

@ -505,7 +505,6 @@ dbs.message.run(
// Actors we know about and have cached
dbs.activitypub.run(
`CREATE TABLE IF NOT EXISTS actor_cache (
actor_cache_id INTEGER PRIMARY KEY, -- Local DB ID
actor_id VARCHAR NOT NULL, -- Fully qualified Actor ID/URL
actor_json VARCHAR NOT NULL, -- Actor document
subject VARCHAR, -- Subject obtained from WebFinger, e.g. @Username@some.domain
@ -524,32 +523,36 @@ dbs.message.run(
// generally obtained via WebFinger
dbs.activitypub.run(
`CREATE TABLE IF NOT EXISTS actor_alias_cache (
id INTEGER PRIMARY KEY,
alias VARCHAR NOT NULL,
actor_id VARCHAR NOT NULL, -- Fully qualified Actor ID/URL
actor_alias_id VARCHAR NOT NULL, -- Alias such the user's "profile URL"
UNIQUE(alias)
UNIQUE(actor_alias_id)
);`
);
// ActivityPub Collections of various types such as followers, following, likes, ...
dbs.activitypub.run(
`CREATE TABLE IF NOT EXISTS collection (
id INTEGER PRIMARY KEY, -- Auto-generated key
collection_id VARCHAR NOT NULL, -- ie: http://somewhere.com/_enig/ap/collections/NuSkooler/followers
name VARCHAR NOT NULL, -- examples: followers, follows, ...
timestamp DATETIME NOT NULL, -- Timestamp in which this entry was created
user_id INTEGER NOT NULL, -- Local, owning user ID, 0 means "all" for sharedInbox
obj_id VARCHAR NOT NULL, -- Object ID from obj_json.id
obj_json VARCHAR NOT NULL, -- Object varies by collection (obj_json.type)
is_private INTEGER NOT NULL, -- Is this object private to |user_id|?
owner_actor_id VARCHAR NOT NULL, -- Local, owning Actor ID, or the #Public magic collection ID
object_id VARCHAR NOT NULL, -- Object ID from obj_json.id
object_json VARCHAR NOT NULL, -- Object varies by collection (obj_json.type)
is_private INTEGER NOT NULL, -- Is this object private to |owner_actor_id|?
UNIQUE(name, user_id, obj_id)
UNIQUE(name, collection_id, object_id)
);`
);
dbs.activitypub.run(
`CREATE INDEX IF NOT EXISTS collection_entry_by_user_index0
ON collection (name, user_id);`
`CREATE INDEX IF NOT EXISTS collection_entry_by_name_actor_id_index0
ON collection (name, owner_actor_id);`
);
dbs.activitypub.run(
`CREATE INDEX IF NOT EXISTS collection_entry_by_name_collection_id_index0
ON collection (name, collection_id);`
);
return cb(null);

View File

@ -99,6 +99,7 @@ exports.getModule = class ActivityPubScannerTosser extends MessageScanTossModule
fromUser,
activity,
message.isPrivate(),
this._webServer(),
(err, localId) => {
if (!err) {
this.log.debug(

View File

@ -89,7 +89,7 @@ exports.getModule = class WebServerModule extends ServerModule {
getDomain() {
const config = Config();
const overridePrefix = _.get(config.contentServers.web.overrideUrlPrefix);
const overridePrefix = _.get(config, 'contentServers.web.overrideUrlPrefix');
if (_.isString(overridePrefix)) {
const url = new URL(overridePrefix);
return url.hostname;
@ -98,17 +98,11 @@ exports.getModule = class WebServerModule extends ServerModule {
return config.contentServers.web.domain;
}
buildUrl(pathAndQuery) {
//
// Create a URL such as
// https://l33t.codes:44512/ + |pathAndQuery|
//
// Prefer HTTPS over HTTP. Be explicit about the port
// only if non-standard. Allow users to override full prefix in config.
//
baseUrl() {
const config = Config();
if (_.isString(config.contentServers.web.overrideUrlPrefix)) {
return `${config.contentServers.web.overrideUrlPrefix}${pathAndQuery}`;
const overridePrefix = _.get(config, 'contentServers.web.overrideUrlPrefix');
if (overridePrefix) {
return overridePrefix;
}
let schema;
@ -127,7 +121,24 @@ exports.getModule = class WebServerModule extends ServerModule {
: `:${config.contentServers.web.http.port}`;
}
return `${schema}${config.contentServers.web.domain}${port}${pathAndQuery}`;
return `${schema}${config.contentServers.web.domain}${port}`;
}
fullUrl(req) {
const base = this.baseUrl();
return new URL(`${base}${req.url}`);
}
buildUrl(pathAndQuery) {
//
// Create a URL such as
// https://l33t.codes:44512/ + |pathAndQuery|
//
// Prefer HTTPS over HTTP. Be explicit about the port
// only if non-standard. Allow users to override full prefix in config.
//
const base = this.baseUrl();
return `${base}${pathAndQuery}`;
}
isEnabled() {

View File

@ -1,25 +1,28 @@
const WebHandlerModule = require('../../../web_handler_module');
const {
userFromAccount,
userFromActorId,
getUserProfileTemplatedBody,
DefaultProfileTemplate,
accountFromSelfUrl,
makeUserUrl,
localActorId,
} = require('../../../activitypub/util');
const Config = require('../../../config').get;
const Activity = require('../../../activitypub/activity');
const ActivityPubSettings = require('../../../activitypub/settings');
const Actor = require('../../../activitypub/actor');
const Collection = require('../../../activitypub/collection');
const Note = require('../../../activitypub/note');
const EnigAssert = require('../../../enigma_assert');
const Message = require('../../../message');
const Events = require('../../../events');
const UserProps = require('../../../user_property');
// deps
const _ = require('lodash');
const enigma_assert = require('../../../enigma_assert');
const httpSignature = require('http-signature');
const async = require('async');
const Note = require('../../../activitypub/note');
const User = require('../../../user');
const paths = require('path');
exports.moduleInfo = {
name: 'ActivityPub',
@ -41,6 +44,11 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
this.log = webServer.logger().child({ webHandler: 'ActivityPub' });
Events.addListener(Events.getSystemEvents().NewUserPrePersist, eventInfo => {
const { user, callback } = eventInfo;
return this._prepareNewUserAsActor(user, callback);
});
this.webServer.addRoute({
method: 'GET',
path: /^\/_enig\/ap\/users\/[^/]+$/,
@ -131,23 +139,11 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
_selfUrlRequestHandler(req, resp) {
this.log.trace({ url: req.url }, 'Request for "self"');
const url = new URL(req.url, `https://${req.headers.host}`);
let accountName = url.pathname.substring(url.pathname.lastIndexOf('/') + 1);
let actorId = this.webServer.fullUrl(req).toString();
let sendActor = false;
// Like Mastodon, if .json is appended onto URL then return the JSON content
if (accountName.endsWith('.json')) {
if (actorId.endsWith('.json')) {
sendActor = true;
accountName = accountName.slice(0, -5);
}
userFromAccount(accountName, (err, user) => {
if (err) {
this.log.info(
{ reason: err.message, accountName: accountName },
`No user "${accountName}" for "self"`
);
return this.webServer.resourceNotFound(resp);
actorId = actorId.slice(0, -5);
}
// Additionally, serve activity JSON if the proper 'Accept' header was sent
@ -161,10 +157,19 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
sendActor = true;
}
userFromActorId(actorId, (err, localUser) => {
if (err) {
this.log.info(
{ error: err.message, actorId },
`No user for Actor ID ${actorId}`
);
return this.webServer.resourceNotFound(resp);
}
if (sendActor) {
return this._selfAsActorHandler(user, req, resp);
return this._selfAsActorHandler(localUser, req, resp);
} else {
return this._standardSelfHandler(user, req, resp);
return this._standardSelfHandler(localUser, req, resp);
}
});
}
@ -197,8 +202,9 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
switch (activity.type) {
case 'Follow':
return this._withUserRequestHandler(
return this._collectionRequestHandler(
signature,
'inbox',
activity,
this._inboxFollowRequestHandler.bind(this),
req,
@ -209,9 +215,14 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
return this._inboxUpdateRequestHandler(activity, req, resp);
case 'Undo':
return this._inboxUndoRequestHandler(activity, req, resp);
// :TODO: Create, etc.
return this._collectionRequestHandler(
signature,
'inbox',
activity,
this._inboxUndoRequestHandler.bind(this),
req,
resp
);
default:
this.log.warn(
@ -264,15 +275,19 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
}
_sharedInboxCreateActivity(req, resp, activity) {
let toActors = activity.to;
if (!Array.isArray(toActors)) {
toActors = [toActors];
}
const deliverTo = activity.recipientIds();
//Create a method to gather all to, cc, bcc, etc. dests (see spec) -> single array
// loop through, and attempt to fetch user-by-actor id for each; if found, deliver
// --we may need to add properties for ActivityPubFollowersId, ActivityPubFollowingId, etc.
// to user props for quick lookup -> user
// special handling of bcc (remove others before delivery), etc.
// const toActorIds = activity.recipientActorIds()
const createWhat = _.get(activity, 'object.type');
switch (createWhat) {
case 'Note':
return this._deliverSharedInboxNote(req, resp, toActors, activity);
return this._deliverSharedInboxNote(req, resp, deliverTo, activity);
default:
this.log.warn(
@ -283,7 +298,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
}
}
_deliverSharedInboxNote(req, resp, toActors, activity) {
_deliverSharedInboxNote(req, resp, deliverTo, activity) {
// When an object is being delivered to the originating actor's followers,
// a server MAY reduce the number of receiving actors delivered to by
// identifying all followers which share the same sharedInbox who would
@ -297,26 +312,32 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
}
async.forEach(
toActors,
deliverTo,
(actorId, nextActor) => {
if (Collection.PublicCollectionId === actorId) {
switch (actorId) {
case Collection.PublicCollectionId:
// :TODO: we should probably land this in a public areaTag as well for AP; allowing Message objects to be used/etc.
Collection.addPublicInboxItem(note, err => {
return nextActor(err);
});
} else {
break;
default:
this._deliverInboxNoteToLocalActor(
req,
resp,
actorId,
activity,
note,
nextActor
err => {
return nextActor(err);
}
);
break;
}
},
err => {
if (err && 'SQLITE_CONSTRAINT' !== err.code) {
if (err) {
return this.webServer.internalServerError(resp, err);
}
@ -326,22 +347,12 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
}
_deliverInboxNoteToLocalActor(req, resp, actorId, activity, note, cb) {
const localUserName = accountFromSelfUrl(actorId);
if (!localUserName) {
this.log.debug({ url: req.url }, 'Could not get username from URL');
return cb(null);
}
User.getUserByUsername(localUserName, (err, localUser) => {
userFromActorId(actorId, (err, localUser) => {
if (err) {
this.log.info(
{ username: localUserName },
`No local user account for "${localUserName}"`
);
return cb(null);
return cb(null); // not found/etc., just bail
}
Collection.addInboxItem(note, localUser, err => {
Collection.addInboxItem(note, localUser, this.webServer, err => {
if (err) {
return cb(err);
}
@ -362,6 +373,17 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
}
message.persist(err => {
if (!err) {
this.log.info(
{
user: localUser.username,
userId: localUser.userId,
activityId: activity.id,
noteId: note.id,
},
'Note delivered as message to private mailbox'
);
}
return cb(err);
});
});
@ -369,32 +391,18 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
});
}
_getCollectionHandler(name, req, resp, signature) {
_getCollectionHandler(collectionName, req, resp, signature) {
EnigAssert(signature, 'Missing signature!');
const url = new URL(req.url, `https://${req.headers.host}`);
const accountName = this._accountNameFromUserPath(url, name);
if (!accountName) {
return this.webServer.resourceNotFound(resp);
}
// can we even handle this request?
const getter = Collection[name];
if (!getter) {
return this.webServer.resourceNotFound(resp);
}
userFromAccount(accountName, (err, user) => {
if (err) {
this.log.info(
{ reason: err.message, accountName: accountName },
`No user "${accountName}" for "${name}"`
);
const getCollection = Collection[collectionName];
if (!getCollection) {
return this.webServer.resourceNotFound(resp);
}
const url = this.webServer.fullUrl(req);
const page = url.searchParams.get('page');
getter(user, page, this.webServer, (err, collection) => {
const collectionId = url.toString();
getCollection(collectionId, page, (err, collection) => {
if (err) {
return this.webServer.internalServerError(resp, err);
}
@ -408,7 +416,6 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
resp.writeHead(200, headers);
return resp.end(body);
});
});
}
_followingGetHandler(req, resp, signature) {
@ -416,6 +423,11 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
return this._getCollectionHandler('following', req, resp, signature);
}
_followersGetHandler(req, resp, signature) {
this.log.debug({ url: req.url }, 'Request for "followers"');
return this._getCollectionHandler('followers', req, resp, signature);
}
// https://docs.gotosocial.org/en/latest/federation/behaviors/outbox/
_outboxGetHandler(req, resp, signature) {
this.log.debug({ url: req.url }, 'Request for "outbox"');
@ -425,9 +437,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
_singlePublicNoteGetHandler(req, resp) {
this.log.debug({ url: req.url }, 'Request for "Note"');
const url = new URL(req.url, `https://${req.headers.host}`);
const noteId = url.toString();
const noteId = this.webServer.fullUrl(req).toString();
Note.fromPublicNoteId(noteId, (err, note) => {
if (err) {
return this.webServer.internalServerError(resp, err);
@ -449,11 +459,6 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
return m[1];
}
_followersGetHandler(req, resp, signature) {
this.log.debug({ url: req.url }, 'Request for "followers"');
return this._getCollectionHandler('followers', req, resp, signature);
}
_parseAndValidateSignature(req) {
let signature;
try {
@ -485,8 +490,11 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
return keyId.endsWith('#main-key');
}
_inboxFollowRequestHandler(activity, remoteActor, user, resp) {
this.log.info({ user_id: user.userId, actor: activity.actor }, 'Follow request');
_inboxFollowRequestHandler(activity, remoteActor, localUser, resp) {
this.log.info(
{ user_id: localUser.userId, actor: activity.actor },
'Follow request'
);
const ok = () => {
resp.writeHead(200, { 'Content-Type': 'text/html' });
@ -499,12 +507,12 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
// request for the user to review and decide what to do with
// at a later time.
//
const activityPubSettings = ActivityPubSettings.fromUser(user);
const activityPubSettings = ActivityPubSettings.fromUser(localUser);
if (!activityPubSettings.manuallyApproveFollowers) {
this._recordAcceptedFollowRequest(user, remoteActor, activity);
this._recordAcceptedFollowRequest(localUser, remoteActor, activity);
return ok();
} else {
Collection.addFollowRequest(user, remoteActor, err => {
Collection.addFollowRequest(localUser, remoteActor, this.webServer, err => {
if (err) {
return this.internalServerError(resp, err);
}
@ -523,19 +531,11 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
// });
}
_inboxUndoRequestHandler(activity, req, resp) {
this.log.info({ actor: activity.actor }, 'Undo Activity request');
const url = new URL(req.url, `https://${req.headers.host}`);
const accountName = this._accountNameFromUserPath(url, 'inbox');
if (!accountName) {
return this.webServer.resourceNotFound(resp);
}
userFromAccount(accountName, (err, user) => {
if (err) {
return this.webServer.resourceNotFound(resp);
}
_inboxUndoRequestHandler(activity, remoteActor, localUser, resp) {
this.log.info(
{ user: localUser.username, actor: remoteActor.id },
'Undo Activity request'
);
// we only understand Follow right now
if (!activity.object || activity.object.type !== 'Follow') {
@ -544,34 +544,43 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
Collection.removeFromCollectionById(
'followers',
user,
activity.actor,
localUser,
remoteActor.id,
err => {
if (err) {
return this.webServer.internalServerError(resp, err);
}
this.log.info(
{ userId: user.userId, actor: activity.actor },
{
username: localUser.username,
userId: localUser.userId,
actor: remoteActor.id,
},
'Undo "Follow" (un-follow) success'
);
return this.webServer.accepted(resp);
}
);
});
}
_withUserRequestHandler(signature, activity, activityHandler, req, resp) {
this.log.debug({ actor: activity.actor }, `Inbox request from ${activity.actor}`);
// :TODO: trace
const accountName = accountFromSelfUrl(activity.object);
if (!accountName) {
return this.webServer.badRequest(resp);
_collectionRequestHandler(
signature,
collectionName,
activity,
activityHandler,
req,
resp
) {
// turn a collection URL to a Actor ID
let actorId = this.webServer.fullUrl(req).toString();
const suffix = `/${collectionName}`;
if (actorId.endsWith(suffix)) {
actorId = actorId.slice(0, -suffix.length);
}
userFromAccount(accountName, (err, user) => {
userFromActorId(actorId, (err, localUser) => {
if (err) {
return this.webServer.resourceNotFound(resp);
}
@ -604,7 +613,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
return this.webServer.accessDenied(resp);
}
return activityHandler(activity, actor, user, resp);
return activityHandler(activity, actor, localUser, resp);
});
});
}
@ -613,7 +622,12 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
async.series(
[
callback => {
return Collection.addFollower(localUser, remoteActor, callback);
return Collection.addFollower(
localUser,
remoteActor,
this.webServer,
callback
);
},
callback => {
Actor.fromLocalUser(localUser, this.webServer, (err, localActor) => {
@ -739,4 +753,50 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
}
);
}
_prepareNewUserAsActor(user, cb) {
this.log.info(
{ username: user.username, userId: user.userId },
`Preparing ActivityPub settings for "${user.username}"`
);
const actorId = localActorId(this.webServer, user);
user.setProperty(UserProps.ActivityPubActorId, actorId);
user.updateActivityPubKeyPairProperties(err => {
if (err) {
return cb(err);
}
user.generateNewRandomAvatar((err, outPath) => {
if (err) {
this.log.warn(
{
username: user.username,
userId: user.userId,
error: err.message,
},
`Failed to generate random avatar for "${user.username}"`
);
}
// :TODO: fetch over +op default overrides here, e.g. 'enabled'
const apSettings = ActivityPubSettings.fromUser(user);
const filename = paths.basename(outPath);
const avatarUrl =
makeUserUrl(this.webServer, user, '/users/') + `/avatar/${filename}`;
apSettings.image = avatarUrl;
apSettings.icon = avatarUrl;
user.setProperty(
UserProps.ActivityPubSettings,
JSON.stringify(apSettings)
);
return cb(null);
});
});
}
};

View File

@ -44,7 +44,7 @@ exports.getModule = class SystemGeneralWebHandler extends WebHandlerModule {
}
_avatarGetHandler(req, resp) {
const url = new URL(req.url, `https://${req.headers.host}`);
const url = this.webServer.fullUrl(req);
const filename = paths.basename(url.pathname);
if (!filename) {
return this.webServer.fileNotFound(resp);

View File

@ -1,17 +1,21 @@
const WebHandlerModule = require('../../../web_handler_module');
const Config = require('../../../config').get;
const { Errors } = require('../../../enig_error');
const { Errors, ErrorReasons } = require('../../../enig_error');
const { WellKnownLocations } = require('../web');
const {
selfUrl,
localActorId,
webFingerProfileUrl,
userFromAccount,
userFromActorId,
getUserProfileTemplatedBody,
DefaultProfileTemplate,
} = require('../../../activitypub/util');
const _ = require('lodash');
const EngiAssert = require('../../../enigma_assert');
const User = require('../../../user');
const UserProps = require('../../../user_property');
const ActivityPubSettings = require('../../../activitypub/settings');
// deps
const _ = require('lodash');
exports.moduleInfo = {
name: 'WebFinger',
@ -74,31 +78,12 @@ exports.getModule = class WebFingerWebHandler extends WebHandlerModule {
}
_profileRequestHandler(req, resp) {
const url = new URL(req.url, `https://${req.headers.host}`);
const resource = url.pathname;
if (_.isEmpty(resource)) {
return this.webServer.instance.respondWithError(
resp,
400,
'pathname is required',
'Missing "resource"'
);
}
const userPosition = resource.indexOf('@');
if (-1 === userPosition || userPosition === resource.length - 1) {
this.webServer.resourceNotFound(resp);
return Errors.DoesNotExist('"@username" missing from path');
}
const accountName = resource.substring(userPosition + 1);
userFromAccount(accountName, (err, user) => {
const actorId = this.webServer.fullUrl(req).toString();
userFromActorId(actorId, (err, localUser) => {
if (err) {
this.log.warn(
{ url: req.url, error: err.message, type: 'Profile' },
`No profile for "${accountName}" could be retrieved`
{ error: err.message, type: 'Profile' },
'Could not fetch profile for WebFinger request'
);
return this.webServer.resourceNotFound(resp);
}
@ -113,7 +98,7 @@ exports.getModule = class WebFingerWebHandler extends WebHandlerModule {
getUserProfileTemplatedBody(
templateFile,
user,
localUser,
DefaultProfileTemplate,
'text/plain',
(err, body, contentType) => {
@ -134,8 +119,7 @@ exports.getModule = class WebFingerWebHandler extends WebHandlerModule {
}
_webFingerRequestHandler(req, resp) {
const url = new URL(req.url, `https://${req.headers.host}`);
const url = this.webServer.fullUrl(req);
const resource = url.searchParams.get('resource');
if (!resource) {
return this.webServer.respondWithError(
@ -148,13 +132,11 @@ exports.getModule = class WebFingerWebHandler extends WebHandlerModule {
const accountName = this._getAccountName(resource);
if (!accountName || accountName.length < 1) {
this.webServer.resourceNotFound(resp);
return Errors.DoesNotExist(
`Failed to parse "account name" for resource: ${resource}`
);
this.log.warn(`Failed to parse "account name" for resource: ${resource}`);
return this.webServer.resourceNotFound(resp);
}
userFromAccount(accountName, (err, user) => {
this._localUserFromWebFingerAccountName(accountName, (err, localUser) => {
if (err) {
this.log.warn(
{ url: req.url, error: err.message, type: 'WebFinger' },
@ -166,11 +148,11 @@ exports.getModule = class WebFingerWebHandler extends WebHandlerModule {
const domain = this.webServer.getDomain();
const body = JSON.stringify({
subject: `acct:${user.username}@${domain}`,
aliases: [this._profileUrl(user), this._selfUrl(user)],
subject: `acct:${localUser.username}@${domain}`,
aliases: [this._profileUrl(localUser), this._userActorId(localUser)],
links: [
this._profilePageLink(user),
this._selfLink(user),
this._profilePageLink(localUser),
this._selfLink(localUser),
this._subscribeLink(),
],
});
@ -185,6 +167,41 @@ exports.getModule = class WebFingerWebHandler extends WebHandlerModule {
});
}
_localUserFromWebFingerAccountName(accountName, cb) {
if (accountName.startsWith('@')) {
accountName = accountName.slice(1);
}
User.getUserIdAndName(accountName, (err, userId) => {
if (err) {
return cb(err);
}
User.getUser(userId, (err, user) => {
if (err) {
return cb(err);
}
const accountStatus = user.getPropertyAsNumber(UserProps.AccountStatus);
if (
User.AccountStatus.disabled == accountStatus ||
User.AccountStatus.inactive == accountStatus
) {
return cb(
Errors.AccessDenied('Account disabled', ErrorReasons.Disabled)
);
}
const activityPubSettings = ActivityPubSettings.fromUser(user);
if (!activityPubSettings.enabled) {
return cb(Errors.AccessDenied('ActivityPub is not enabled for user'));
}
return cb(null, user);
});
});
}
_profileUrl(user) {
return webFingerProfileUrl(this.webServer, user);
}
@ -198,13 +215,13 @@ exports.getModule = class WebFingerWebHandler extends WebHandlerModule {
};
}
_selfUrl(user) {
return selfUrl(this.webServer, user);
_userActorId(user) {
return localActorId(this.webServer, user);
}
// :TODO: only if ActivityPub is enabled
_selfLink(user) {
const href = this._selfUrl(user);
const href = this._userActorId(user);
return {
rel: 'self',
type: 'application/activity+json',

View File

@ -10,7 +10,9 @@ module.exports = {
ConfigChanged: 'codes.l33t.enigma.system.config_changed', // (config.hjson)
MenusChanged: 'codes.l33t.enigma.system.menus_changed', // (menu.hjson)
// User - includes { user, ...}
// User - includes { user, callback, ... } where user *is* the user instance in question
NewUserPrePersist: 'codes.l33t.enigma.system.user_new_pre_persist',
// User - includes { user, ...} where user is a *copy*
NewUser: 'codes.l33t.enigma.system.user_new', // { ... }
UserLogin: 'codes.l33t.enigma.system.user_login', // { ... }
UserLogoff: 'codes.l33t.enigma.system.user_logoff', // { ... }

View File

@ -22,7 +22,6 @@ const ssh2 = require('ssh2');
const AvatarGenerator = require('avatar-generator');
const paths = require('path');
const fse = require('fs-extra');
const ActivityPubSettings = require('./activitypub/settings');
module.exports = class User {
constructor() {
@ -49,6 +48,7 @@ module.exports = class User {
static get PBKDF2() {
return {
// :TODO: bump up iterations for all new PWs
iterations: 1000,
keyLen: 128,
saltLen: 32,
@ -129,8 +129,14 @@ module.exports = class User {
}
getSanitizedName(type = 'username') {
const name =
'real' === type ? this.getProperty(UserProps.RealName) : this.username;
let name;
switch (type) {
case 'real':
name = this.getProperty(UserProps.RealName) || this.username;
break;
default:
name = this.username;
}
return sanatizeFilename(name) || `user${this.userId.toString()}`;
}
@ -507,46 +513,6 @@ module.exports = class User {
}
);
},
function setKeyPair(trans, callback) {
self.updateActivityPubKeyPairProperties(err => {
return callback(err, trans);
});
},
function defaultAvatar(trans, callback) {
self.generateNewRandomAvatar((err, outPath) => {
return callback(err, outPath, trans);
});
},
function defaultActivityPubSettings(outPath, trans, callback) {
// we have to late import this crap :D
const getServer = require('./listening_server.js').getServer;
const WebServerPackageName = require('./servers/content/web')
.moduleInfo.packageName;
const webServer = getServer(WebServerPackageName);
// :TODO: fetch over +op default overrides here, e.g. 'enabled'
const apSettings = ActivityPubSettings.fromUser(self);
// convert |outPath| of avatar to a URL, that, with the web
// server enabled, can be fetched
if (webServer) {
const { makeUserUrl } = require('./activitypub/util');
const filename = paths.basename(outPath);
const url =
makeUserUrl(webServer.instance, self, '/users/') +
`/avatar/${filename}`;
apSettings.image = url;
apSettings.icon = url;
}
self.setProperty(
UserProps.ActivityPubSettings,
JSON.stringify(apSettings)
);
return callback(null, trans);
},
function setInitialGroupMembership(trans, callback) {
// Assign initial groups. Must perform a clone: #235 - All users are sysops (and I can't un-sysop them)
self.groups = [...config.users.defaultGroups];
@ -558,12 +524,21 @@ module.exports = class User {
return callback(null, trans);
},
function newUserPreEvent(trans, callback) {
Events.emit(Events.getSystemEvents().NewUserPrePersist, {
user: self,
sessionId: createUserInfo.sessionId,
callback: err => {
return callback(err, trans);
},
});
},
function saveAll(trans, callback) {
self.persistWithTransaction(trans, err => {
return callback(err, trans);
});
},
function sendEvent(trans, callback) {
function newUserEvent(trans, callback) {
Events.emit(Events.getSystemEvents().NewUser, {
user: Object.assign({}, self, {
sessionId: createUserInfo.sessionId,
@ -1048,8 +1023,8 @@ module.exports = class User {
userIds.push(row.user_id);
}
},
() => {
return cb(null, userIds);
err => {
return cb(err, userIds);
}
);
}

View File

@ -70,5 +70,6 @@ module.exports = {
PublicActivityPubSigningKey: 'public_key_activitypub_sign_rsa_pem', // RSA public key for ActivityPub signing
PrivateActivityPubSigningKey: 'private_key_activitypub_sign_rsa_pem', // RSA private key (corresponding to PublicActivityPubSigningKey)
ActivityPubSettings: 'activity_pub_settings', // JSON object (above); see ActivityPubSettings in activitypub/settings.js
ActivityPubSettings: 'activitypub_settings', // JSON object (above); see ActivityPubSettings in activitypub/settings.js
ActivityPubActorId: 'activitypub_actor_id', // Actor ID representing this users
};

View File

@ -65,7 +65,6 @@ exports.getModule = class WaitingForCallerModule extends MenuModule {
//
// Enforce that we have at least a secure connection in our ACS check
//
this.config.acs = this.config.acs;
if (!this.config.acs) {
this.config.acs = DefaultACS;
} else if (!this.config.acs.includes('SC')) {