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 { WellKnownActivityTypes } = require('./const');
const ActivityPubObject = require('./object'); const ActivityPubObject = require('./object');
const { Errors } = require('../enig_error'); const { Errors } = require('../enig_error');
@ -18,6 +18,11 @@ module.exports = class Activity extends ActivityPubObject {
return WellKnownActivityTypes; return WellKnownActivityTypes;
} }
static fromJsonString(s) {
const obj = ActivityPubObject.fromJsonString(s);
return new Activity(obj);
}
static makeFollow(webServer, localActor, remoteActor) { static makeFollow(webServer, localActor, remoteActor) {
return new Activity({ return new Activity({
id: Activity.activityObjectId(webServer), id: Activity.activityObjectId(webServer),
@ -74,7 +79,7 @@ module.exports = class Activity extends ActivityPubObject {
sign: { sign: {
// :TODO: Make a helper for this // :TODO: Make a helper for this
key: privateKey, key: privateKey,
keyId: selfUrl(webServer, fromUser) + '#main-key', keyId: localActorId(webServer, fromUser) + '#main-key',
authorizationHeaderName: 'Signature', authorizationHeaderName: 'Signature',
headers: ['(request-target)', 'host', 'date', 'digest', 'content-type'], headers: ['(request-target)', 'host', 'date', 'digest', 'content-type'],
}, },
@ -84,6 +89,23 @@ module.exports = class Activity extends ActivityPubObject {
return postJson(actorUrl, activityJson, reqOpts, cb); 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) { static activityObjectId(webServer) {
return ActivityPubObject.makeObjectId(webServer, 'activity'); return ActivityPubObject.makeObjectId(webServer, 'activity');
} }

View File

@ -8,7 +8,6 @@ const {
ActivityStreamsContext, ActivityStreamsContext,
webFingerProfileUrl, webFingerProfileUrl,
makeUserUrl, makeUserUrl,
selfUrl,
isValidLink, isValidLink,
makeSharedInboxUrl, makeSharedInboxUrl,
userNameFromSubject, userNameFromSubject,
@ -68,7 +67,15 @@ module.exports = class Actor extends ActivityPubObject {
} }
static fromLocalUser(user, webServer, cb) { 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 userSettings = ActivityPubSettings.fromUser(user);
const addImage = (o, t) => { const addImage = (o, t) => {
@ -90,7 +97,7 @@ module.exports = class Actor extends ActivityPubObject {
ActivityStreamsContext, ActivityStreamsContext,
'https://w3id.org/security/v1', // :TODO: add support 'https://w3id.org/security/v1', // :TODO: add support
], ],
id: userSelfUrl, id: userActorId,
type: 'Person', type: 'Person',
preferredUsername: user.username, preferredUsername: user.username,
name: userSettings.showRealName name: userSettings.showRealName
@ -123,8 +130,8 @@ module.exports = class Actor extends ActivityPubObject {
const publicKeyPem = user.getProperty(UserProps.PublicActivityPubSigningKey); const publicKeyPem = user.getProperty(UserProps.PublicActivityPubSigningKey);
if (!_.isEmpty(publicKeyPem)) { if (!_.isEmpty(publicKeyPem)) {
obj.publicKey = { obj.publicKey = {
id: userSelfUrl + '#main-key', id: userActorId + '#main-key',
owner: userSelfUrl, owner: userActorId,
publicKeyPem, publicKeyPem,
}; };
@ -219,6 +226,7 @@ module.exports = class Actor extends ActivityPubObject {
const timestamp = moment(row.timestamp); const timestamp = moment(row.timestamp);
if (moment().isAfter(timestamp.add(ActorCacheTTL))) { if (moment().isAfter(timestamp.add(ActorCacheTTL))) {
// :TODO: drop from cache
return cb(Errors.Expired('The cache entry is expired')); 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 { getISOTimestampString } = require('../database');
const { Errors } = require('../enig_error.js'); const { Errors } = require('../enig_error.js');
const { PublicCollectionId: APPublicCollectionId } = require('./const'); const { PublicCollectionId: APPublicCollectionId } = require('./const');
const UserProps = require('../user_property');
// deps // deps
const { isString, get, isObject } = require('lodash'); const { isString } = require('lodash');
const APPublicOwningUserId = 0;
module.exports = class Collection extends ActivityPubObject { module.exports = class Collection extends ActivityPubObject {
constructor(obj) { constructor(obj) {
@ -19,34 +18,33 @@ module.exports = class Collection extends ActivityPubObject {
return APPublicCollectionId; return APPublicCollectionId;
} }
static followers(owningUser, page, webServer, cb) { static followers(collectionId, page, cb) {
return Collection.getOrdered( return Collection.publicOrderedById(
'followers', 'followers',
owningUser, collectionId,
false,
page, page,
e => e.id, e => e.id,
webServer,
cb cb
); );
} }
static following(owningUser, page, webServer, cb) { static following(collectionId, page, cb) {
return Collection.getOrdered( return Collection.publicOrderedById(
'following', 'following',
owningUser, collectionId,
false,
page, page,
e => get(e, 'object.id'), e => e.id,
webServer,
cb cb
); );
} }
static addFollower(owningUser, followingActor, cb) { static addFollower(owningUser, followingActor, webServer, cb) {
const collectionId =
makeUserUrl(webServer, owningUser, '/ap/collections/') + '/followers';
return Collection.addToCollection( return Collection.addToCollection(
'followers', 'followers',
owningUser, owningUser,
collectionId,
followingActor.id, followingActor.id,
followingActor, followingActor,
false, 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( return Collection.addToCollection(
'follow_requests', 'follow-requests',
owningUser, owningUser,
collectionId,
requestingActor.id, requestingActor.id,
requestingActor, requestingActor,
true, true,
@ -65,22 +66,17 @@ module.exports = class Collection extends ActivityPubObject {
); );
} }
static outbox(owningUser, page, webServer, cb) { static outbox(collectionId, page, cb) {
return Collection.getOrdered( return Collection.publicOrderedById('outbox', collectionId, page, null, cb);
'outbox',
owningUser,
false,
page,
null,
webServer,
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( return Collection.addToCollection(
'outbox', 'outbox',
owningUser, owningUser,
collectionId,
outboxItem.id, outboxItem.id,
outboxItem, outboxItem,
isPrivate, 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( return Collection.addToCollection(
'inbox', 'inbox',
owningUser, owningUser,
collectionId,
inboxItem.id, inboxItem.id,
inboxItem, inboxItem,
true, true,
@ -102,7 +101,8 @@ module.exports = class Collection extends ActivityPubObject {
static addPublicInboxItem(inboxItem, cb) { static addPublicInboxItem(inboxItem, cb) {
return Collection.addToCollection( return Collection.addToCollection(
'publicInbox', 'publicInbox',
APPublicOwningUserId, null, // N/A
Collection.PublicCollectionId,
inboxItem.id, inboxItem.id,
inboxItem, inboxItem,
false, false,
@ -114,11 +114,11 @@ module.exports = class Collection extends ActivityPubObject {
const privateQuery = includePrivate ? '' : ' AND is_private = FALSE'; const privateQuery = includePrivate ? '' : ' AND is_private = FALSE';
apDb.get( apDb.get(
`SELECT obj_json `SELECT object_json
FROM collection FROM collection
WHERE name = ? WHERE name = ?
${privateQuery} ${privateQuery}
AND json_extract(obj_json, '$.object.id') = ?;`, AND json_extract(object_json, '$.object.id') = ?;`,
[collectionName, objectId], [collectionName, objectId],
(err, row) => { (err, row) => {
if (err) { 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) { if (!obj) {
return cb(Errors.Invalid('Failed to parse Object JSON')); 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, collectionName,
owningUser, owningUser,
includePrivate, includePrivate,
@ -153,19 +217,25 @@ module.exports = class Collection extends ActivityPubObject {
cb cb
) { ) {
const privateQuery = includePrivate ? '' : ' AND is_private = FALSE'; 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 // e.g. http://somewhere.com/_enig/ap/collections/NuSkooler/followers
const collectionIdBase = const collectionId =
makeUserUrl(webServer, owningUser, `/ap/collections/${owningUserId}`) + makeUserUrl(webServer, owningUser, '/ap/collections/') + `/${collectionName}`;
`/${collectionName}`;
if (!page) { if (!page) {
return apDb.get( return apDb.get(
`SELECT COUNT(id) AS count `SELECT COUNT(collection_id) AS count
FROM collection FROM collection
WHERE user_id = ? AND name = ?${privateQuery};`, WHERE owner_actor_id = ? AND name = ?${privateQuery};`,
[owningUserId, collectionName], [actorId, collectionName],
(err, row) => { (err, row) => {
if (err) { if (err) {
return cb(err); return cb(err);
@ -180,14 +250,14 @@ module.exports = class Collection extends ActivityPubObject {
let obj; let obj;
if (row.count > 0) { if (row.count > 0) {
obj = { obj = {
id: collectionIdBase, id: collectionId,
type: 'OrderedCollection', type: 'OrderedCollection',
first: `${collectionIdBase}?page=1`, first: `${collectionId}?page=1`,
totalItems: row.count, totalItems: row.count,
}; };
} else { } else {
obj = { obj = {
id: collectionIdBase, id: collectionId,
type: 'OrderedCollection', type: 'OrderedCollection',
totalItems: 0, totalItems: 0,
orderedItems: [], orderedItems: [],
@ -201,11 +271,11 @@ module.exports = class Collection extends ActivityPubObject {
// :TODO: actual paging... // :TODO: actual paging...
apDb.all( apDb.all(
`SELECT obj_json `SELECT object_json
FROM collection FROM collection
WHERE user_id = ? AND name = ?${privateQuery} WHERE owner_actor_id = ? AND name = ?${privateQuery}
ORDER BY timestamp;`, ORDER BY timestamp;`,
[owningUserId, collectionName], [actorId, collectionName],
(err, entries) => { (err, entries) => {
if (err) { if (err) {
return cb(err); return cb(err);
@ -217,11 +287,11 @@ module.exports = class Collection extends ActivityPubObject {
} }
const obj = { const obj = {
id: `${collectionIdBase}/page=${page}`, id: `${collectionId}/page=${page}`,
type: 'OrderedCollectionPage', type: 'OrderedCollectionPage',
totalItems: entries.length, totalItems: entries.length,
orderedItems: entries, orderedItems: entries,
partOf: collectionIdBase, partOf: collectionId,
}; };
return cb(null, new Collection(obj)); return cb(null, new Collection(obj));
@ -239,8 +309,8 @@ module.exports = class Collection extends ActivityPubObject {
apDb.run( apDb.run(
`UPDATE collection `UPDATE collection
SET obj_json = ?, timestamp = ? SET object_json = ?, timestamp = ?
WHERE name = ? AND obj_id = ?;`, WHERE name = ? AND object_id = ?;`,
[obj, collectionName, getISOTimestampString(), objectId], [obj, collectionName, getISOTimestampString(), objectId],
err => { err => {
return cb(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)) { if (!isString(obj)) {
obj = JSON.stringify(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; isPrivate = isPrivate ? 1 : 0;
apDb.run( apDb.run(
`INSERT OR IGNORE INTO collection (name, timestamp, user_id, obj_id, obj_json, is_private) `INSERT OR IGNORE INTO collection (name, timestamp, collection_id, owner_actor_id, object_id, object_json, is_private)
VALUES (?, ?, ?, ?, ?, ?);`, VALUES (?, ?, ?, ?, ?, ?, ?);`,
[ [
collectionName, collectionName,
getISOTimestampString(), getISOTimestampString(),
owningUserId, collectionId,
actorId,
objectId, objectId,
obj, obj,
isPrivate, isPrivate,
@ -269,6 +362,9 @@ module.exports = class Collection extends ActivityPubObject {
function res(err) { function res(err) {
// non-arrow for 'this' scope // non-arrow for 'this' scope
if (err) { if (err) {
if ('SQLITE_CONSTRAINT' === err.code) {
err = null; // ignore
}
return cb(err); return cb(err);
} }
return cb(err, this.lastID); return cb(err, this.lastID);
@ -277,11 +373,18 @@ module.exports = class Collection extends ActivityPubObject {
} }
static removeFromCollectionById(collectionName, owningUser, objectId, cb) { 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( apDb.run(
`DELETE FROM collection `DELETE FROM collection
WHERE user_id = ? AND name = ? AND obj_id = ?;`, WHERE name = ? AND owner_actor_id = ? AND object_id = ?;`,
[owningUserId, collectionName, objectId], [collectionName, actorId, objectId],
err => { err => {
return cb(err); return cb(err);
} }

View File

@ -5,6 +5,7 @@ const { getISOTimestampString } = require('../database');
const User = require('../user'); const User = require('../user');
const { messageBodyToHtml, htmlToMessageBody } = require('./util'); const { messageBodyToHtml, htmlToMessageBody } = require('./util');
const { isAnsi } = require('../string_util'); const { isAnsi } = require('../string_util');
const Log = require('../logger').log;
// deps // deps
const { v5: UUIDv5 } = require('uuid'); const { v5: UUIDv5 } = require('uuid');
@ -187,7 +188,10 @@ module.exports = class Note extends ActivityPubObject {
try { try {
message.modTimestamp = moment(this.published); message.modTimestamp = moment(this.published);
} catch (e) { } catch (e) {
// :TODO: Log warning Log.warn(
{ published: this.published, error: e.message },
'Failed to parse Note published timestamp'
);
message.modTimestamp = moment(); message.modTimestamp = moment();
} }
@ -203,9 +207,30 @@ module.exports = class Note extends ActivityPubObject {
if (this.inReplyTo) { if (this.inReplyTo) {
message.meta.ActivityPub[Message.ActivityPubPropertyNames.InReplyTo] = message.meta.ActivityPub[Message.ActivityPubPropertyNames.InReplyTo] =
this.inReplyTo; this.inReplyTo;
}
return cb(null, message); 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 UserProps = require('../user_property');
const Config = require('../config').get;
module.exports = class ActivityPubSettings { module.exports = class ActivityPubSettings {
constructor(obj) { constructor(obj) {
this.enabled = true; // :TODO: fetch from +op config default this.enabled = true;
this.manuallyApproveFollowers = false; this.manuallyApproveFollowers = false;
this.hideSocialGraph = false; // followers, following this.hideSocialGraph = false; // followers, following
this.showRealName = true; this.showRealName = true;
this.image = ''; this.image = '';
this.icon = ''; this.icon = '';
// override default with any op config
Object.assign(this, Config().users.activityPub);
if (obj) { if (obj) {
Object.assign(this, obj); Object.assign(this, obj);
} }

View File

@ -20,9 +20,8 @@ exports.isValidLink = isValidLink;
exports.makeSharedInboxUrl = makeSharedInboxUrl; exports.makeSharedInboxUrl = makeSharedInboxUrl;
exports.makeUserUrl = makeUserUrl; exports.makeUserUrl = makeUserUrl;
exports.webFingerProfileUrl = webFingerProfileUrl; exports.webFingerProfileUrl = webFingerProfileUrl;
exports.selfUrl = selfUrl; exports.localActorId = localActorId;
exports.userFromAccount = userFromAccount; exports.userFromActorId = userFromActorId;
exports.accountFromSelfUrl = accountFromSelfUrl;
exports.getUserProfileTemplatedBody = getUserProfileTemplatedBody; exports.getUserProfileTemplatedBody = getUserProfileTemplatedBody;
exports.messageBodyToHtml = messageBodyToHtml; exports.messageBodyToHtml = messageBodyToHtml;
exports.htmlToMessageBody = htmlToMessageBody; exports.htmlToMessageBody = htmlToMessageBody;
@ -59,26 +58,26 @@ function webFingerProfileUrl(webServer, user) {
return webServer.buildUrl(WellKnownLocations.Internal + `/wf/@${user.username}`); return webServer.buildUrl(WellKnownLocations.Internal + `/wf/@${user.username}`);
} }
function selfUrl(webServer, user) { function localActorId(webServer, user) {
return makeUserUrl(webServer, user, '/ap/users/'); return makeUserUrl(webServer, user, '/ap/users/');
} }
function accountFromSelfUrl(url) { function userFromActorId(actorId, cb) {
// https://some.l33t.enigma.board/_enig/ap/users/Masto -> Masto User.getUserIdsWithProperty(UserProps.ActivityPubActorId, actorId, (err, userId) => {
// :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) => {
if (err) { if (err) {
return cb(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) => { User.getUser(userId, (err, user) => {
if (err) { if (err) {
return cb(err); return cb(err);

View File

@ -136,6 +136,14 @@ module.exports = () => {
storagePath: paths.join(__dirname, '../userdata/avatars/'), storagePath: paths.join(__dirname, '../userdata/avatars/'),
spritesPath: paths.join(__dirname, '../misc/avatar-sprites/'), 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: { theme: {

View File

@ -505,7 +505,6 @@ dbs.message.run(
// Actors we know about and have cached // Actors we know about and have cached
dbs.activitypub.run( dbs.activitypub.run(
`CREATE TABLE IF NOT EXISTS actor_cache ( `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_id VARCHAR NOT NULL, -- Fully qualified Actor ID/URL
actor_json VARCHAR NOT NULL, -- Actor document actor_json VARCHAR NOT NULL, -- Actor document
subject VARCHAR, -- Subject obtained from WebFinger, e.g. @Username@some.domain subject VARCHAR, -- Subject obtained from WebFinger, e.g. @Username@some.domain
@ -524,32 +523,36 @@ dbs.message.run(
// generally obtained via WebFinger // generally obtained via WebFinger
dbs.activitypub.run( dbs.activitypub.run(
`CREATE TABLE IF NOT EXISTS actor_alias_cache ( `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_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, ... // ActivityPub Collections of various types such as followers, following, likes, ...
dbs.activitypub.run( dbs.activitypub.run(
`CREATE TABLE IF NOT EXISTS collection ( `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, ... name VARCHAR NOT NULL, -- examples: followers, follows, ...
timestamp DATETIME NOT NULL, -- Timestamp in which this entry was created 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 owner_actor_id VARCHAR NOT NULL, -- Local, owning Actor ID, or the #Public magic collection ID
obj_id VARCHAR NOT NULL, -- Object ID from obj_json.id object_id VARCHAR NOT NULL, -- Object ID from obj_json.id
obj_json VARCHAR NOT NULL, -- Object varies by collection (obj_json.type) object_json VARCHAR NOT NULL, -- Object varies by collection (obj_json.type)
is_private INTEGER NOT NULL, -- Is this object private to |user_id|? 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( dbs.activitypub.run(
`CREATE INDEX IF NOT EXISTS collection_entry_by_user_index0 `CREATE INDEX IF NOT EXISTS collection_entry_by_name_actor_id_index0
ON collection (name, user_id);` 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); return cb(null);

View File

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

View File

@ -89,7 +89,7 @@ exports.getModule = class WebServerModule extends ServerModule {
getDomain() { getDomain() {
const config = Config(); const config = Config();
const overridePrefix = _.get(config.contentServers.web.overrideUrlPrefix); const overridePrefix = _.get(config, 'contentServers.web.overrideUrlPrefix');
if (_.isString(overridePrefix)) { if (_.isString(overridePrefix)) {
const url = new URL(overridePrefix); const url = new URL(overridePrefix);
return url.hostname; return url.hostname;
@ -98,17 +98,11 @@ exports.getModule = class WebServerModule extends ServerModule {
return config.contentServers.web.domain; return config.contentServers.web.domain;
} }
buildUrl(pathAndQuery) { baseUrl() {
//
// 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 config = Config(); const config = Config();
if (_.isString(config.contentServers.web.overrideUrlPrefix)) { const overridePrefix = _.get(config, 'contentServers.web.overrideUrlPrefix');
return `${config.contentServers.web.overrideUrlPrefix}${pathAndQuery}`; if (overridePrefix) {
return overridePrefix;
} }
let schema; let schema;
@ -127,7 +121,24 @@ exports.getModule = class WebServerModule extends ServerModule {
: `:${config.contentServers.web.http.port}`; : `:${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() { isEnabled() {

View File

@ -1,25 +1,28 @@
const WebHandlerModule = require('../../../web_handler_module'); const WebHandlerModule = require('../../../web_handler_module');
const { const {
userFromAccount, userFromActorId,
getUserProfileTemplatedBody, getUserProfileTemplatedBody,
DefaultProfileTemplate, DefaultProfileTemplate,
accountFromSelfUrl, makeUserUrl,
localActorId,
} = require('../../../activitypub/util'); } = require('../../../activitypub/util');
const Config = require('../../../config').get; const Config = require('../../../config').get;
const Activity = require('../../../activitypub/activity'); const Activity = require('../../../activitypub/activity');
const ActivityPubSettings = require('../../../activitypub/settings'); const ActivityPubSettings = require('../../../activitypub/settings');
const Actor = require('../../../activitypub/actor'); const Actor = require('../../../activitypub/actor');
const Collection = require('../../../activitypub/collection'); const Collection = require('../../../activitypub/collection');
const Note = require('../../../activitypub/note');
const EnigAssert = require('../../../enigma_assert'); const EnigAssert = require('../../../enigma_assert');
const Message = require('../../../message'); const Message = require('../../../message');
const Events = require('../../../events');
const UserProps = require('../../../user_property');
// deps // deps
const _ = require('lodash'); const _ = require('lodash');
const enigma_assert = require('../../../enigma_assert'); const enigma_assert = require('../../../enigma_assert');
const httpSignature = require('http-signature'); const httpSignature = require('http-signature');
const async = require('async'); const async = require('async');
const Note = require('../../../activitypub/note'); const paths = require('path');
const User = require('../../../user');
exports.moduleInfo = { exports.moduleInfo = {
name: 'ActivityPub', name: 'ActivityPub',
@ -41,6 +44,11 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
this.log = webServer.logger().child({ webHandler: 'ActivityPub' }); 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({ this.webServer.addRoute({
method: 'GET', method: 'GET',
path: /^\/_enig\/ap\/users\/[^/]+$/, path: /^\/_enig\/ap\/users\/[^/]+$/,
@ -131,40 +139,37 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
_selfUrlRequestHandler(req, resp) { _selfUrlRequestHandler(req, resp) {
this.log.trace({ url: req.url }, 'Request for "self"'); this.log.trace({ url: req.url }, 'Request for "self"');
const url = new URL(req.url, `https://${req.headers.host}`); let actorId = this.webServer.fullUrl(req).toString();
let accountName = url.pathname.substring(url.pathname.lastIndexOf('/') + 1);
let sendActor = false; let sendActor = false;
if (actorId.endsWith('.json')) {
// Like Mastodon, if .json is appended onto URL then return the JSON content
if (accountName.endsWith('.json')) {
sendActor = true; sendActor = true;
accountName = accountName.slice(0, -5); actorId = actorId.slice(0, -5);
} }
userFromAccount(accountName, (err, user) => { // Additionally, serve activity JSON if the proper 'Accept' header was sent
const accept = req.headers['accept'].split(',').map(v => v.trim()) || ['*/*'];
const headerValues = [
ActivityJsonMime,
'application/ld+json',
'application/json',
];
if (accept.some(v => headerValues.includes(v))) {
sendActor = true;
}
userFromActorId(actorId, (err, localUser) => {
if (err) { if (err) {
this.log.info( this.log.info(
{ reason: err.message, accountName: accountName }, { error: err.message, actorId },
`No user "${accountName}" for "self"` `No user for Actor ID ${actorId}`
); );
return this.webServer.resourceNotFound(resp); return this.webServer.resourceNotFound(resp);
} }
// Additionally, serve activity JSON if the proper 'Accept' header was sent
const accept = req.headers['accept'].split(',').map(v => v.trim()) || ['*/*'];
const headerValues = [
ActivityJsonMime,
'application/ld+json',
'application/json',
];
if (accept.some(v => headerValues.includes(v))) {
sendActor = true;
}
if (sendActor) { if (sendActor) {
return this._selfAsActorHandler(user, req, resp); return this._selfAsActorHandler(localUser, req, resp);
} else { } 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) { switch (activity.type) {
case 'Follow': case 'Follow':
return this._withUserRequestHandler( return this._collectionRequestHandler(
signature, signature,
'inbox',
activity, activity,
this._inboxFollowRequestHandler.bind(this), this._inboxFollowRequestHandler.bind(this),
req, req,
@ -209,9 +215,14 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
return this._inboxUpdateRequestHandler(activity, req, resp); return this._inboxUpdateRequestHandler(activity, req, resp);
case 'Undo': case 'Undo':
return this._inboxUndoRequestHandler(activity, req, resp); return this._collectionRequestHandler(
signature,
// :TODO: Create, etc. 'inbox',
activity,
this._inboxUndoRequestHandler.bind(this),
req,
resp
);
default: default:
this.log.warn( this.log.warn(
@ -264,15 +275,19 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
} }
_sharedInboxCreateActivity(req, resp, activity) { _sharedInboxCreateActivity(req, resp, activity) {
let toActors = activity.to; const deliverTo = activity.recipientIds();
if (!Array.isArray(toActors)) {
toActors = [toActors]; //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'); const createWhat = _.get(activity, 'object.type');
switch (createWhat) { switch (createWhat) {
case 'Note': case 'Note':
return this._deliverSharedInboxNote(req, resp, toActors, activity); return this._deliverSharedInboxNote(req, resp, deliverTo, activity);
default: default:
this.log.warn( 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, // When an object is being delivered to the originating actor's followers,
// a server MAY reduce the number of receiving actors delivered to by // a server MAY reduce the number of receiving actors delivered to by
// identifying all followers which share the same sharedInbox who would // identifying all followers which share the same sharedInbox who would
@ -297,26 +312,32 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
} }
async.forEach( async.forEach(
toActors, deliverTo,
(actorId, nextActor) => { (actorId, nextActor) => {
if (Collection.PublicCollectionId === actorId) { switch (actorId) {
// :TODO: we should probably land this in a public areaTag as well for AP; allowing Message objects to be used/etc. case Collection.PublicCollectionId:
Collection.addPublicInboxItem(note, err => { // :TODO: we should probably land this in a public areaTag as well for AP; allowing Message objects to be used/etc.
return nextActor(err); Collection.addPublicInboxItem(note, err => {
}); return nextActor(err);
} else { });
this._deliverInboxNoteToLocalActor( break;
req,
resp, default:
actorId, this._deliverInboxNoteToLocalActor(
activity, req,
note, resp,
nextActor actorId,
); activity,
note,
err => {
return nextActor(err);
}
);
break;
} }
}, },
err => { err => {
if (err && 'SQLITE_CONSTRAINT' !== err.code) { if (err) {
return this.webServer.internalServerError(resp, err); return this.webServer.internalServerError(resp, err);
} }
@ -326,22 +347,12 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
} }
_deliverInboxNoteToLocalActor(req, resp, actorId, activity, note, cb) { _deliverInboxNoteToLocalActor(req, resp, actorId, activity, note, cb) {
const localUserName = accountFromSelfUrl(actorId); userFromActorId(actorId, (err, localUser) => {
if (!localUserName) {
this.log.debug({ url: req.url }, 'Could not get username from URL');
return cb(null);
}
User.getUserByUsername(localUserName, (err, localUser) => {
if (err) { if (err) {
this.log.info( return cb(null); // not found/etc., just bail
{ username: localUserName },
`No local user account for "${localUserName}"`
);
return cb(null);
} }
Collection.addInboxItem(note, localUser, err => { Collection.addInboxItem(note, localUser, this.webServer, err => {
if (err) { if (err) {
return cb(err); return cb(err);
} }
@ -362,6 +373,17 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
} }
message.persist(err => { 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); return cb(err);
}); });
}); });
@ -369,45 +391,30 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
}); });
} }
_getCollectionHandler(name, req, resp, signature) { _getCollectionHandler(collectionName, req, resp, signature) {
EnigAssert(signature, 'Missing signature!'); EnigAssert(signature, 'Missing signature!');
const url = new URL(req.url, `https://${req.headers.host}`); const getCollection = Collection[collectionName];
const accountName = this._accountNameFromUserPath(url, name); if (!getCollection) {
if (!accountName) {
return this.webServer.resourceNotFound(resp); return this.webServer.resourceNotFound(resp);
} }
// can we even handle this request? const url = this.webServer.fullUrl(req);
const getter = Collection[name]; const page = url.searchParams.get('page');
if (!getter) { const collectionId = url.toString();
return this.webServer.resourceNotFound(resp); getCollection(collectionId, page, (err, collection) => {
}
userFromAccount(accountName, (err, user) => {
if (err) { if (err) {
this.log.info( return this.webServer.internalServerError(resp, err);
{ reason: err.message, accountName: accountName },
`No user "${accountName}" for "${name}"`
);
return this.webServer.resourceNotFound(resp);
} }
const page = url.searchParams.get('page'); const body = JSON.stringify(collection);
getter(user, page, this.webServer, (err, collection) => { const headers = {
if (err) { 'Content-Type': ActivityJsonMime,
return this.webServer.internalServerError(resp, err); 'Content-Length': body.length,
} };
const body = JSON.stringify(collection); resp.writeHead(200, headers);
const headers = { return resp.end(body);
'Content-Type': ActivityJsonMime,
'Content-Length': body.length,
};
resp.writeHead(200, headers);
return resp.end(body);
});
}); });
} }
@ -416,6 +423,11 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
return this._getCollectionHandler('following', req, resp, signature); 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/ // https://docs.gotosocial.org/en/latest/federation/behaviors/outbox/
_outboxGetHandler(req, resp, signature) { _outboxGetHandler(req, resp, signature) {
this.log.debug({ url: req.url }, 'Request for "outbox"'); this.log.debug({ url: req.url }, 'Request for "outbox"');
@ -425,9 +437,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
_singlePublicNoteGetHandler(req, resp) { _singlePublicNoteGetHandler(req, resp) {
this.log.debug({ url: req.url }, 'Request for "Note"'); this.log.debug({ url: req.url }, 'Request for "Note"');
const url = new URL(req.url, `https://${req.headers.host}`); const noteId = this.webServer.fullUrl(req).toString();
const noteId = url.toString();
Note.fromPublicNoteId(noteId, (err, note) => { Note.fromPublicNoteId(noteId, (err, note) => {
if (err) { if (err) {
return this.webServer.internalServerError(resp, err); return this.webServer.internalServerError(resp, err);
@ -449,11 +459,6 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
return m[1]; 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) { _parseAndValidateSignature(req) {
let signature; let signature;
try { try {
@ -485,8 +490,11 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
return keyId.endsWith('#main-key'); return keyId.endsWith('#main-key');
} }
_inboxFollowRequestHandler(activity, remoteActor, user, resp) { _inboxFollowRequestHandler(activity, remoteActor, localUser, resp) {
this.log.info({ user_id: user.userId, actor: activity.actor }, 'Follow request'); this.log.info(
{ user_id: localUser.userId, actor: activity.actor },
'Follow request'
);
const ok = () => { const ok = () => {
resp.writeHead(200, { 'Content-Type': 'text/html' }); 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 // request for the user to review and decide what to do with
// at a later time. // at a later time.
// //
const activityPubSettings = ActivityPubSettings.fromUser(user); const activityPubSettings = ActivityPubSettings.fromUser(localUser);
if (!activityPubSettings.manuallyApproveFollowers) { if (!activityPubSettings.manuallyApproveFollowers) {
this._recordAcceptedFollowRequest(user, remoteActor, activity); this._recordAcceptedFollowRequest(localUser, remoteActor, activity);
return ok(); return ok();
} else { } else {
Collection.addFollowRequest(user, remoteActor, err => { Collection.addFollowRequest(localUser, remoteActor, this.webServer, err => {
if (err) { if (err) {
return this.internalServerError(resp, err); return this.internalServerError(resp, err);
} }
@ -523,55 +531,56 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
// }); // });
} }
_inboxUndoRequestHandler(activity, req, resp) { _inboxUndoRequestHandler(activity, remoteActor, localUser, resp) {
this.log.info({ actor: activity.actor }, 'Undo Activity request'); this.log.info(
{ user: localUser.username, actor: remoteActor.id },
'Undo Activity request'
);
const url = new URL(req.url, `https://${req.headers.host}`); // we only understand Follow right now
const accountName = this._accountNameFromUserPath(url, 'inbox'); if (!activity.object || activity.object.type !== 'Follow') {
if (!accountName) { return this.webServer.notImplemented(resp);
return this.webServer.resourceNotFound(resp);
} }
userFromAccount(accountName, (err, user) => { Collection.removeFromCollectionById(
if (err) { 'followers',
return this.webServer.resourceNotFound(resp); localUser,
} remoteActor.id,
err => {
// we only understand Follow right now if (err) {
if (!activity.object || activity.object.type !== 'Follow') { return this.webServer.internalServerError(resp, err);
return this.webServer.notImplemented(resp);
}
Collection.removeFromCollectionById(
'followers',
user,
activity.actor,
err => {
if (err) {
return this.webServer.internalServerError(resp, err);
}
this.log.info(
{ userId: user.userId, actor: activity.actor },
'Undo "Follow" (un-follow) success'
);
return this.webServer.accepted(resp);
} }
);
}); this.log.info(
{
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) { _collectionRequestHandler(
this.log.debug({ actor: activity.actor }, `Inbox request from ${activity.actor}`); signature,
collectionName,
// :TODO: trace activity,
const accountName = accountFromSelfUrl(activity.object); activityHandler,
if (!accountName) { req,
return this.webServer.badRequest(resp); 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) { if (err) {
return this.webServer.resourceNotFound(resp); return this.webServer.resourceNotFound(resp);
} }
@ -604,7 +613,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
return this.webServer.accessDenied(resp); 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( async.series(
[ [
callback => { callback => {
return Collection.addFollower(localUser, remoteActor, callback); return Collection.addFollower(
localUser,
remoteActor,
this.webServer,
callback
);
}, },
callback => { callback => {
Actor.fromLocalUser(localUser, this.webServer, (err, localActor) => { 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) { _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); const filename = paths.basename(url.pathname);
if (!filename) { if (!filename) {
return this.webServer.fileNotFound(resp); return this.webServer.fileNotFound(resp);

View File

@ -1,17 +1,21 @@
const WebHandlerModule = require('../../../web_handler_module'); const WebHandlerModule = require('../../../web_handler_module');
const Config = require('../../../config').get; const Config = require('../../../config').get;
const { Errors } = require('../../../enig_error'); const { Errors, ErrorReasons } = require('../../../enig_error');
const { WellKnownLocations } = require('../web'); const { WellKnownLocations } = require('../web');
const { const {
selfUrl, localActorId,
webFingerProfileUrl, webFingerProfileUrl,
userFromAccount, userFromActorId,
getUserProfileTemplatedBody, getUserProfileTemplatedBody,
DefaultProfileTemplate, DefaultProfileTemplate,
} = require('../../../activitypub/util'); } = require('../../../activitypub/util');
const _ = require('lodash');
const EngiAssert = require('../../../enigma_assert'); 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 = { exports.moduleInfo = {
name: 'WebFinger', name: 'WebFinger',
@ -74,31 +78,12 @@ exports.getModule = class WebFingerWebHandler extends WebHandlerModule {
} }
_profileRequestHandler(req, resp) { _profileRequestHandler(req, resp) {
const url = new URL(req.url, `https://${req.headers.host}`); const actorId = this.webServer.fullUrl(req).toString();
userFromActorId(actorId, (err, localUser) => {
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) => {
if (err) { if (err) {
this.log.warn( this.log.warn(
{ url: req.url, error: err.message, type: 'Profile' }, { error: err.message, type: 'Profile' },
`No profile for "${accountName}" could be retrieved` 'Could not fetch profile for WebFinger request'
); );
return this.webServer.resourceNotFound(resp); return this.webServer.resourceNotFound(resp);
} }
@ -113,7 +98,7 @@ exports.getModule = class WebFingerWebHandler extends WebHandlerModule {
getUserProfileTemplatedBody( getUserProfileTemplatedBody(
templateFile, templateFile,
user, localUser,
DefaultProfileTemplate, DefaultProfileTemplate,
'text/plain', 'text/plain',
(err, body, contentType) => { (err, body, contentType) => {
@ -134,8 +119,7 @@ exports.getModule = class WebFingerWebHandler extends WebHandlerModule {
} }
_webFingerRequestHandler(req, resp) { _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'); const resource = url.searchParams.get('resource');
if (!resource) { if (!resource) {
return this.webServer.respondWithError( return this.webServer.respondWithError(
@ -148,13 +132,11 @@ exports.getModule = class WebFingerWebHandler extends WebHandlerModule {
const accountName = this._getAccountName(resource); const accountName = this._getAccountName(resource);
if (!accountName || accountName.length < 1) { if (!accountName || accountName.length < 1) {
this.webServer.resourceNotFound(resp); this.log.warn(`Failed to parse "account name" for resource: ${resource}`);
return Errors.DoesNotExist( return this.webServer.resourceNotFound(resp);
`Failed to parse "account name" for resource: ${resource}`
);
} }
userFromAccount(accountName, (err, user) => { this._localUserFromWebFingerAccountName(accountName, (err, localUser) => {
if (err) { if (err) {
this.log.warn( this.log.warn(
{ url: req.url, error: err.message, type: 'WebFinger' }, { url: req.url, error: err.message, type: 'WebFinger' },
@ -166,11 +148,11 @@ exports.getModule = class WebFingerWebHandler extends WebHandlerModule {
const domain = this.webServer.getDomain(); const domain = this.webServer.getDomain();
const body = JSON.stringify({ const body = JSON.stringify({
subject: `acct:${user.username}@${domain}`, subject: `acct:${localUser.username}@${domain}`,
aliases: [this._profileUrl(user), this._selfUrl(user)], aliases: [this._profileUrl(localUser), this._userActorId(localUser)],
links: [ links: [
this._profilePageLink(user), this._profilePageLink(localUser),
this._selfLink(user), this._selfLink(localUser),
this._subscribeLink(), 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) { _profileUrl(user) {
return webFingerProfileUrl(this.webServer, user); return webFingerProfileUrl(this.webServer, user);
} }
@ -198,13 +215,13 @@ exports.getModule = class WebFingerWebHandler extends WebHandlerModule {
}; };
} }
_selfUrl(user) { _userActorId(user) {
return selfUrl(this.webServer, user); return localActorId(this.webServer, user);
} }
// :TODO: only if ActivityPub is enabled // :TODO: only if ActivityPub is enabled
_selfLink(user) { _selfLink(user) {
const href = this._selfUrl(user); const href = this._userActorId(user);
return { return {
rel: 'self', rel: 'self',
type: 'application/activity+json', type: 'application/activity+json',

View File

@ -10,7 +10,9 @@ module.exports = {
ConfigChanged: 'codes.l33t.enigma.system.config_changed', // (config.hjson) ConfigChanged: 'codes.l33t.enigma.system.config_changed', // (config.hjson)
MenusChanged: 'codes.l33t.enigma.system.menus_changed', // (menu.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', // { ... } NewUser: 'codes.l33t.enigma.system.user_new', // { ... }
UserLogin: 'codes.l33t.enigma.system.user_login', // { ... } UserLogin: 'codes.l33t.enigma.system.user_login', // { ... }
UserLogoff: 'codes.l33t.enigma.system.user_logoff', // { ... } UserLogoff: 'codes.l33t.enigma.system.user_logoff', // { ... }

View File

@ -22,7 +22,6 @@ const ssh2 = require('ssh2');
const AvatarGenerator = require('avatar-generator'); const AvatarGenerator = require('avatar-generator');
const paths = require('path'); const paths = require('path');
const fse = require('fs-extra'); const fse = require('fs-extra');
const ActivityPubSettings = require('./activitypub/settings');
module.exports = class User { module.exports = class User {
constructor() { constructor() {
@ -49,6 +48,7 @@ module.exports = class User {
static get PBKDF2() { static get PBKDF2() {
return { return {
// :TODO: bump up iterations for all new PWs
iterations: 1000, iterations: 1000,
keyLen: 128, keyLen: 128,
saltLen: 32, saltLen: 32,
@ -129,8 +129,14 @@ module.exports = class User {
} }
getSanitizedName(type = 'username') { getSanitizedName(type = 'username') {
const name = let name;
'real' === type ? this.getProperty(UserProps.RealName) : this.username; switch (type) {
case 'real':
name = this.getProperty(UserProps.RealName) || this.username;
break;
default:
name = this.username;
}
return sanatizeFilename(name) || `user${this.userId.toString()}`; 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) { function setInitialGroupMembership(trans, callback) {
// Assign initial groups. Must perform a clone: #235 - All users are sysops (and I can't un-sysop them) // Assign initial groups. Must perform a clone: #235 - All users are sysops (and I can't un-sysop them)
self.groups = [...config.users.defaultGroups]; self.groups = [...config.users.defaultGroups];
@ -558,12 +524,21 @@ module.exports = class User {
return callback(null, trans); 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) { function saveAll(trans, callback) {
self.persistWithTransaction(trans, err => { self.persistWithTransaction(trans, err => {
return callback(err, trans); return callback(err, trans);
}); });
}, },
function sendEvent(trans, callback) { function newUserEvent(trans, callback) {
Events.emit(Events.getSystemEvents().NewUser, { Events.emit(Events.getSystemEvents().NewUser, {
user: Object.assign({}, self, { user: Object.assign({}, self, {
sessionId: createUserInfo.sessionId, sessionId: createUserInfo.sessionId,
@ -1048,8 +1023,8 @@ module.exports = class User {
userIds.push(row.user_id); userIds.push(row.user_id);
} }
}, },
() => { err => {
return cb(null, userIds); 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 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) 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 // Enforce that we have at least a secure connection in our ACS check
// //
this.config.acs = this.config.acs;
if (!this.config.acs) { if (!this.config.acs) {
this.config.acs = DefaultACS; this.config.acs = DefaultACS;
} else if (!this.config.acs.includes('SC')) { } else if (!this.config.acs.includes('SC')) {