Return a outbox WIP

This commit is contained in:
Bryan Ashby 2023-01-12 23:19:52 -07:00
parent 157b90687c
commit 5e5c9236ec
No known key found for this signature in database
GPG Key ID: C2C1B501E4EFD994
8 changed files with 233 additions and 69 deletions

View File

@ -1,18 +1,21 @@
const { isString, isObject } = require('lodash');
const { v4: UUIDv4 } = require('uuid');
const { const {
ActivityStreamsContext, ActivityStreamsContext,
messageBodyToHtml, messageBodyToHtml,
selfUrl, selfUrl,
} = require('../activitypub/util'); makeUserUrl,
const { Errors } = require('../enig_error'); } = require('./util');
const User = require('../user'); const User = require('../user');
const Actor = require('../activitypub/actor'); const Actor = require('./actor');
const { Errors } = require('../enig_error');
const { getISOTimestampString } = require('../database'); const { getISOTimestampString } = require('../database');
const UserProps = require('../user_property'); const UserProps = require('../user_property');
const { postJson } = require('../http_util'); const { postJson } = require('../http_util');
const { getOutboxEntries } = require('./db');
const { WellKnownLocations } = require('../servers/content/web');
// deps // deps
const { isString, isObject } = require('lodash');
const { v4: UUIDv4 } = require('uuid');
const async = require('async'); const async = require('async');
const _ = require('lodash'); const _ = require('lodash');
@ -119,6 +122,7 @@ module.exports = class Activity {
type: 'Note', type: 'Note',
published: getISOTimestampString(message.modTimestamp), published: getISOTimestampString(message.modTimestamp),
attributedTo: localActor.id, attributedTo: localActor.id,
audience: [message.isPrivate() ? 'as:Private' : 'as:Public'],
// :TODO: inReplyto if this is a reply; we need this store in message meta. // :TODO: inReplyto if this is a reply; we need this store in message meta.
content: messageBodyToHtml(message.message.trim()), content: messageBodyToHtml(message.message.trim()),
@ -146,6 +150,37 @@ module.exports = class Activity {
); );
} }
static fromOutboxEntries(owningUser, webServer, cb) {
// :TODO: support paging
const getOpts = {
create: true, // items marked 'Create'
};
getOutboxEntries(owningUser, getOpts, (err, entries) => {
if (err) {
return cb(err);
}
const obj = {
'@context': ActivityStreamsContext,
// :TODO: makeOutboxUrl() and use elsewhere also
id: makeUserUrl(webServer, owningUser, '/ap/users') + '/outbox',
type: 'OrderedCollection',
totalItems: entries.length,
orderedItems: entries.map(e => {
return {
'@context': ActivityStreamsContext,
id: e.activity.id,
type: 'Create',
actor: e.activity.actor,
object: e.activity.object,
};
}),
};
return cb(null, new Activity(obj));
});
}
sendTo(actorUrl, fromUser, webServer, cb) { sendTo(actorUrl, fromUser, webServer, cb) {
const privateKey = fromUser.getProperty(UserProps.PrivateKeyMain); const privateKey = fromUser.getProperty(UserProps.PrivateKeyMain);
if (_.isEmpty(privateKey)) { if (_.isEmpty(privateKey)) {
@ -173,7 +208,10 @@ module.exports = class Activity {
return postJson(actorUrl, activityJson, reqOpts, cb); return postJson(actorUrl, activityJson, reqOpts, cb);
} }
static _makeFullId(webServer, prefix, uuid = '') { static _makeFullId(webServer, prefix) {
return webServer.buildUrl(`/${prefix}/${uuid || UUIDv4()}`); // e.g. http://some.host/_enig/ap/note/bf81a22e-cb3e-41c8-b114-21f375b61124
return webServer.buildUrl(
WellKnownLocations.Internal + `/ap/${prefix}/${UUIDv4()}`
);
} }
}; };

View File

@ -1,17 +1,57 @@
const apDb = require('../database').dbs.activitypub; const apDb = require('../database').dbs.activitypub;
exports.persistToOutbox = persistToOutbox; exports.persistToOutbox = persistToOutbox;
exports.getOutboxEntries = getOutboxEntries;
function persistToOutbox(activity, userId, messageId, cb) { function persistToOutbox(activity, fromUser, message, cb) {
const activityJson = JSON.stringify(activity); const activityJson = JSON.stringify(activity);
apDb.run( apDb.run(
`INSERT INTO activitypub_outbox (activity_id, user_id, message_id, activity_json) `INSERT INTO outbox (activity_id, user_id, message_id, activity_json, published_timestamp)
VALUES (?, ?, ?, ?);`, VALUES (?, ?, ?, ?, ?);`,
[activity.id, userId, messageId, activityJson], [
activity.id,
fromUser.userId,
message.messageId,
activityJson,
activity.object.published,
],
function res(err) { function res(err) {
// non-arrow for 'this' scope // non-arrow for 'this' scope
return cb(err, this.lastID); return cb(err, this.lastID);
} }
); );
} }
function getOutboxEntries(owningUser, options, cb) {
apDb.all(
`SELECT id, activity_id, message_id, activity_json, published_timestamp
FROM outbox
WHERE user_id = ? AND json_extract(activity_json, '$.type') = "Create";`,
[owningUser.userId],
(err, rows) => {
if (err) {
return cb(err);
}
const entries = rows.map(r => {
let parsed;
try {
parsed = JSON.parse(r.activity_json);
} catch (e) {
return cb(e);
}
return {
id: r.id,
activityId: r.activity_id,
messageId: r.message_id,
activity: parsed,
published: r.published_timestamp,
};
});
return cb(null, entries);
}
);
}

View File

@ -1,4 +1,4 @@
const UserProps = require('./user_property'); const UserProps = require('../user_property');
module.exports = class ActivityPubSettings { module.exports = class ActivityPubSettings {
constructor(obj) { constructor(obj) {

View File

@ -1,8 +1,8 @@
const { WellKnownLocations } = require('./servers/content/web'); const { WellKnownLocations } = require('../servers/content/web');
const User = require('./user'); const User = require('../user');
const { Errors, ErrorReasons } = require('./enig_error'); const { Errors, ErrorReasons } = require('../enig_error');
const UserProps = require('./user_property'); const UserProps = require('../user_property');
const ActivityPubSettings = require('./activitypub/settings'); const ActivityPubSettings = require('./settings');
// deps // deps
const _ = require('lodash'); const _ = require('lodash');

View File

@ -502,26 +502,33 @@ dbs.message.run(
return cb(null); return cb(null);
}, },
activitypub: cb => { activitypub: cb => {
// private INTEGER NOT NULL, -- Is this Activity private?
dbs.activitypub.run( dbs.activitypub.run(
`CREATE TABLE IF NOT EXISTS activitypub_outbox ( `CREATE TABLE IF NOT EXISTS outbox (
id INTEGER PRIMARY KEY, -- Local ID id INTEGER PRIMARY KEY, -- Local ID
activity_id VARCHAR NOT NULL, -- Fully qualified Activity ID/URL activity_id VARCHAR NOT NULL, -- Fully qualified Activity ID/URL (activity.id)
user_id INTEGER NOT NULL, -- Local user ID user_id INTEGER NOT NULL, -- Local user ID
message_id INTEGER NOT NULL, -- Local message ID message_id INTEGER NOT NULL, -- Local message ID
activity_json VARCHAR NOT NULL, -- Activity in JSON format activity_json VARCHAR NOT NULL, -- Activity in JSON format
published_timestamp DATETIME NOT NULL, -- (activity.object.published))
UNIQUE(message_id, activity_id) UNIQUE(message_id, activity_id)
);` );`
); );
dbs.activitypub.run( dbs.activitypub.run(
`CREATE INDEX IF NOT EXISTS activitypub_outbox_user_id_index0 `CREATE INDEX IF NOT EXISTS outbox_user_id_index0
ON activitypub_outbox (user_id);` ON outbox (user_id);`
); );
dbs.activitypub.run( dbs.activitypub.run(
`CREATE INDEX IF NOT EXISTS activitypub_outbox_activity_id_index0 `CREATE INDEX IF NOT EXISTS outbox_activity_id_index0
ON activitypub_outbox (activity_id);` ON outbox (activity_id);`
);
dbs.activitypub.run(
`CREATE INDEX IF NOT EXISTS outbox_activity_json_type_index0
ON outbox (json_extract(activity_json, '$.type'));`
); );
return cb(null); return cb(null);

View File

@ -82,20 +82,15 @@ exports.getModule = class ActivityPubScannerTosser extends MessageScanTossModule
); );
}, },
(activity, fromUser, callback) => { (activity, fromUser, callback) => {
persistToOutbox( persistToOutbox(activity, fromUser, message, (err, localId) => {
activity, if (!err) {
fromUser.userId, this.log.debug(
message.messageId, { localId, activityId: activity.id },
(err, localId) => { 'Note Activity persisted to database'
if (!err) { );
this.log.debug(
{ localId, activityId: activity.id },
'Note Activity persisted to database'
);
}
return callback(err, activity);
} }
); return callback(err, activity);
});
}, },
(activity, callback) => { (activity, callback) => {
// mark exported // mark exported

View File

@ -1,6 +1,3 @@
/* jslint node: true */
'use strict';
// ENiGMA½ // ENiGMA½
const Log = require('../../logger.js').log; const Log = require('../../logger.js').log;
const ServerModule = require('../../server_module.js').ServerModule; const ServerModule = require('../../server_module.js').ServerModule;

View File

@ -4,11 +4,14 @@ const {
getUserProfileTemplatedBody, getUserProfileTemplatedBody,
DefaultProfileTemplate, DefaultProfileTemplate,
accountFromSelfUrl, accountFromSelfUrl,
ActivityStreamsContext,
makeUserUrl,
} = 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 { getOutboxEntries } = require('../../../activitypub/db');
// deps // deps
const _ = require('lodash'); const _ = require('lodash');
@ -22,6 +25,8 @@ exports.moduleInfo = {
packageName: 'codes.l33t.enigma.web.handler.activitypub', packageName: 'codes.l33t.enigma.web.handler.activitypub',
}; };
const ActivityJsonMime = 'application/activity+json';
exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
constructor() { constructor() {
super(); super();
@ -45,6 +50,12 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
handler: this._inboxPostHandler.bind(this), handler: this._inboxPostHandler.bind(this),
}); });
this.webServer.addRoute({
method: 'GET',
path: /^\/_enig\/ap\/users\/.+\/outbox(\?page=true)?$/,
handler: this._outboxGetHandler.bind(this),
});
// :TODO: NYI // :TODO: NYI
// this.webServer.addRoute({ // this.webServer.addRoute({
// method: 'GET', // method: 'GET',
@ -80,7 +91,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
// Additionally, serve activity JSON if the proper 'Accept' header was sent // Additionally, serve activity JSON if the proper 'Accept' header was sent
const accept = req.headers['accept'].split(',').map(v => v.trim()) || ['*/*']; const accept = req.headers['accept'].split(',').map(v => v.trim()) || ['*/*'];
const headerValues = [ const headerValues = [
'application/activity+json', ActivityJsonMime,
'application/ld+json', 'application/ld+json',
'application/json', 'application/json',
]; ];
@ -96,26 +107,20 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
_inboxPostHandler(req, resp) { _inboxPostHandler(req, resp) {
// the request must be signed, and the signature must be valid // the request must be signed, and the signature must be valid
const signature = this._parseSignature(req); const signature = this._parseAndValidateSignature(req);
if (!signature) { if (!signature) {
return this.webServer.resourceNotFound(resp); return this.webServer.accessDenied(resp);
} }
// quick check up front const body = [];
const keyId = signature.keyId; req.on('data', d => {
if (!this._validateKeyId(keyId)) { body.push(d);
return this.webServer.resourceNotFound(resp);
}
let body = '';
req.on('data', data => {
body += data;
}); });
req.on('end', () => { req.on('end', () => {
let activity; let activity;
try { try {
activity = Activity.fromJson(body); activity = Activity.fromJson(Buffer.concat(body).toString());
} catch (e) { } catch (e) {
this.log.error( this.log.error(
{ error: e.message, url: req.url, method: req.method }, { error: e.message, url: req.url, method: req.method },
@ -125,7 +130,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
} }
if (!activity.isValid()) { if (!activity.isValid()) {
// :TODO: Log me this.log.warn({ activity }, 'Invalid or unsupported Activity');
return this.webServer.webServer.badRequest(resp); return this.webServer.webServer.badRequest(resp);
} }
@ -148,19 +153,110 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
}); });
} }
_parseSignature(req) { // https://docs.gotosocial.org/en/latest/federation/behaviors/outbox/
_outboxGetHandler(req, resp) {
this.log.trace({ url: req.url }, 'Request for "outbox"');
// the request must be signed, and the signature must be valid
const signature = this._parseAndValidateSignature(req);
if (!signature) {
return this.webServer.accessDenied(resp);
}
// /_enig/ap/users/SomeName/outbox -> SomeName
const url = new URL(req.url, `https://${req.headers.host}`);
const m = url.pathname.match(/^\/_enig\/ap\/users\/(.+)\/outbox$/);
if (!m || !m[1]) {
return this.webServer.resourceNotFound(resp);
}
const accountName = m[1];
userFromAccount(accountName, (err, user) => {
if (err) {
this.log.info(
{ reason: err.message, accountName: accountName },
`No user "${accountName}" for "self"`
);
return this.webServer.resourceNotFound(resp);
}
// // we return a OrderedCollection response if this request
// // is not explicitly for a page of the collection
// const wantPage = url.searchParams.get('page') === 'true';
// if (!wantPage) {
// const outboxUrl = makeUserUrl(this.webServer, user, '/ap/users/') + '/outbox';
// const body = JSON.stringify({
// '@context': ActivityStreamsContext,
// id: outboxUrl,
// type: 'OrderedCollection',
// first: `${outboxUrl}?page=true`,
// });
// const headers = {
// 'Content-Type': 'application/activity+json',
// 'Content-Length': body.length,
// };
// resp.writeHead(200, headers);
// return resp.end(body);
// }
Activity.fromOutboxEntries(user, this.webServer, (err, activity) => {
if (err) {
// :TODO: LOG ME
return this.webServer.internalServerError(resp);
}
const body = JSON.stringify(activity);
const headers = {
'Content-Type': ActivityJsonMime,
'Content-Length': body.length,
};
resp.writeHead(200, headers);
return resp.end(body);
});
});
}
_parseAndValidateSignature(req) {
let signature;
try { try {
// :TODO: validate options passed to parseRequest() // :TODO: validate options passed to parseRequest()
return httpSignature.parseRequest(req); signature = httpSignature.parseRequest(req);
} catch (e) { } catch (e) {
this.log.warn( this.log.warn(
{ error: e.message, url: req.url, method: req.method }, { error: e.message, url: req.url, method: req.method },
'Failed to parse HTTP signature' 'Failed to parse HTTP signature'
); );
return null;
} }
// quick check up front
const keyId = signature.keyId;
if (!this._validateKeyId(keyId)) {
return null;
}
return signature;
}
_validateKeyId(keyId) {
if (!keyId) {
return false;
}
// we only accept main-key currently
return keyId.endsWith('#main-key');
} }
_inboxFollowRequestHandler(signature, activity, req, resp) { _inboxFollowRequestHandler(signature, activity, req, resp) {
this.log.trace(
{ actor: activity.actor },
`Follow request from ${activity.actor}`
);
// :TODO: trace
const accountName = accountFromSelfUrl(activity.object); const accountName = accountFromSelfUrl(activity.object);
if (!accountName) { if (!accountName) {
return this.webServer.badRequest(resp); return this.webServer.badRequest(resp);
@ -266,15 +362,6 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
}); });
} }
_validateKeyId(keyId) {
if (!keyId) {
return false;
}
// we only accept main-key currently
return keyId.endsWith('#main-key');
}
_authorizeInteractionHandler(req, resp) { _authorizeInteractionHandler(req, resp) {
console.log(req); console.log(req);
} }
@ -294,7 +381,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule {
const body = JSON.stringify(actor); const body = JSON.stringify(actor);
const headers = { const headers = {
'Content-Type': 'application/activity+json', 'Content-Type': ActivityJsonMime,
'Content-Length': body.length, 'Content-Length': body.length,
}; };