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:
parent
8dd28e3091
commit
9b01124b2e
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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'));
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -99,6 +99,7 @@ exports.getModule = class ActivityPubScannerTosser extends MessageScanTossModule
|
|||
fromUser,
|
||||
activity,
|
||||
message.isPrivate(),
|
||||
this._webServer(),
|
||||
(err, localId) => {
|
||||
if (!err) {
|
||||
this.log.debug(
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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', // { ... }
|
||||
|
|
67
core/user.js
67
core/user.js
|
@ -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);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -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')) {
|
||||
|
|
Loading…
Reference in New Issue