From 2c0992becb43206e07cc06c0b052880652d4abad Mon Sep 17 00:00:00 2001 From: Nathan Byrd Date: Sat, 7 Jan 2023 14:48:12 -0600 Subject: [PATCH] Added stub activitypub_actor database entries --- .eslintrc.json | 3 +- core/activitypub_actor.js | 383 +++++++++++++++++++++++++++++ core/activitypub_actor_property.js | 18 ++ core/database.js | 68 +++-- 4 files changed, 452 insertions(+), 20 deletions(-) create mode 100644 core/activitypub_actor.js create mode 100644 core/activitypub_actor_property.js diff --git a/.eslintrc.json b/.eslintrc.json index e19846d8..b22b711c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -16,7 +16,8 @@ "quotes": ["error", "single"], "semi": ["error", "always"], "comma-dangle": 0, - "no-trailing-spaces": "error" + "no-trailing-spaces": "error", + "no-control-regex": 0 }, "parserOptions": { "ecmaVersion": 2020 diff --git a/core/activitypub_actor.js b/core/activitypub_actor.js new file mode 100644 index 00000000..9ab8f01b --- /dev/null +++ b/core/activitypub_actor.js @@ -0,0 +1,383 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const actorDb = require('./database.js').dbs.actor; +const { Errors } = require('./enig_error.js'); +const Events = require('./events.js'); +const ActorProps = require('./activitypub_actor_property.js'); + +// deps +const assert = require('assert'); +const async = require('async'); +const _ = require('lodash'); + +module.exports = class Actor { + constructor() { + this.actorId = 0; + this.actorUrl = ''; + this.properties = {}; // name:value + this.groups = []; // group membership(s) + } + + + create(cb) { + assert(0 === this.actorId); + + if ( + _.isEmpty(this.actorUrl) + ) { + return cb(Errors.Invalid('Blank actor url')); + } + + const self = this; + + async.waterfall( + [ + function beginTransaction(callback) { + return actorDb.beginTransaction(callback); + }, + function createActorRec(trans, callback) { + trans.run( + `INSERT INTO actor (actor_url) + VALUES (?);`, + [self.actorUrl], + function inserted(err) { + // use classic function for |this| + if (err) { + return callback(err); + } + + self.actorId = this.lastID; + + return callback(null, trans); + } + ); + }, + function saveAll(trans, callback) { + self.persistWithTransaction(trans, err => { + return callback(err, trans); + }); + }, + function sendEvent(trans, callback) { + Events.emit(Events.getSystemEvents().NewActor, { + actor: Object.assign({}, self, {}), + }); + return callback(null, trans); + }, + ], + (err, trans) => { + if (trans) { + trans[err ? 'rollback' : 'commit'](transErr => { + return cb(err ? err : transErr); + }); + } else { + return cb(err); + } + } + ); + } + + persistWithTransaction(trans, cb) { + assert(this.actorId > 0); + + const self = this; + + async.series( + [ + function saveProps(callback) { + self.persistProperties(self.properties, trans, err => { + return callback(err); + }); + }, + ], + err => { + return cb(err); + } + ); + } + + static persistPropertyByActorId(actorId, propName, propValue, cb) { + actorDb.run( + `REPLACE INTO activitypub_actor_property (actor_id, prop_name, prop_value) + VALUES (?, ?, ?);`, + [actorId, propName, propValue], + err => { + if (cb) { + return cb(err, propValue); + } + } + ); + } + + setProperty(propName, propValue) { + this.properties[propName] = propValue; + } + + incrementProperty(propName, incrementBy) { + incrementBy = incrementBy || 1; + let newValue = parseInt(this.getProperty(propName)); + if (newValue) { + newValue += incrementBy; + } else { + newValue = incrementBy; + } + this.setProperty(propName, newValue); + return newValue; + } + + getProperty(propName) { + return this.properties[propName]; + } + + getPropertyAsNumber(propName) { + return parseInt(this.getProperty(propName), 10); + } + + persistProperty(propName, propValue, cb) { + // update live props + this.properties[propName] = propValue; + + return Actor.persistPropertyByActorId(this.actorId, propName, propValue, cb); + } + + removeProperty(propName, cb) { + // update live + delete this.properties[propName]; + + actorDb.run( + `DELETE FROM activitypub_actor_property + WHERE activity_id = ? AND prop_name = ?;`, + [this.actorId, propName], + err => { + if (cb) { + return cb(err); + } + } + ); + } + + removeProperties(propNames, cb) { + async.each( + propNames, + (name, next) => { + return this.removeProperty(name, next); + }, + err => { + if (cb) { + return cb(err); + } + } + ); + } + + persistProperties(properties, transOrDb, cb) { + if (!_.isFunction(cb) && _.isFunction(transOrDb)) { + cb = transOrDb; + transOrDb = actorDb; + } + + const self = this; + + // update live props + _.merge(this.properties, properties); + + const stmt = transOrDb.prepare( + `REPLACE INTO activitypub_actor_property (actor_id, prop_name, prop_value) + VALUES (?, ?, ?);` + ); + + async.each( + Object.keys(properties), + (propName, nextProp) => { + stmt.run(self.actorId, propName, properties[propName], err => { + return nextProp(err); + }); + }, + err => { + if (err) { + return cb(err); + } + + stmt.finalize(() => { + return cb(null); + }); + } + ); + } + + + static getActor(actorId, cb) { + async.waterfall( + [ + function fetchActorId(callback) { + Actor.getActorUrl(actorId, (err, actorUrl) => { + return callback(null, actorUrl); + }); + }, + function initProps(actorUrl, callback) { + Actor.loadProperties(actorId, (err, properties) => { + return callback(err, actorUrl, properties); + }); + }, + ], + (err, actorUrl, properties) => { + const actor = new Actor(); + actor.actorId = actorId; + actor.actorUrl = actorUrl; + actor.properties = properties; + + return cb(err, actor); + } + ); + } + + // FIXME + static getActorInfo(actorId, propsList, cb) { + if (!cb && _.isFunction(propsList)) { + cb = propsList; + propsList = [ + ActorProps.Type, + ActorProps.PreferredUsername, + ActorProps.Name, + ActorProps.Summary, + ActorProps.IconUrl, + ActorProps.BannerUrl, + ActorProps.PublicKeyMain + ]; + } + + async.waterfall( + [ + callback => { + return Actor.getActorUrl(actorId, callback); + }, + (actorUrl, callback) => { + Actor.loadProperties(actorId, { names: propsList }, (err, props) => { + return callback( + err, + Object.assign({}, props, { actor_url: actorUrl }) + ); + }); + }, + ], + (err, actorProps) => { + if (err) { + return cb(err); + } + + const actorInfo = {}; + Object.keys(actorProps).forEach(key => { + actorInfo[_.camelCase(key)] = actorProps[key] || 'N/A'; + }); + + return cb(null, actorInfo); + } + ); + } + + static getActorIdAndUrl(actorUrl, cb) { + actorDb.get( + `SELECT id, actor_url + FROM activitypub_actor + WHERE actor_url LIKE ?;`, + [actorUrl], + (err, row) => { + if (err) { + return cb(err); + } + + if (row) { + return cb(null, row.id, row.actor_url); + } + + return cb(Errors.DoesNotExist('No matching actorUrl')); + } + ); + } + + static getActorUrl(actorId, cb) { + actorDb.get( + `SELECT actor_url + FROM activitypub_actor + WHERE id = ?;`, + [actorId], + (err, row) => { + if (err) { + return cb(err); + } + + if (row) { + return cb(null, row.actor_url); + } + + return cb(Errors.DoesNotExist('No matching actor ID')); + } + ); + } + + static loadProperties(actorId, options, cb) { + if (!cb && _.isFunction(options)) { + cb = options; + options = {}; + } + + let sql = `SELECT prop_name, prop_value + FROM activitypub_actor_property + WHERE actor_id = ?`; + + if (options.names) { + sql += ` AND prop_name IN("${options.names.join('","')}");`; + } else { + sql += ';'; + } + + let properties = {}; + actorDb.each( + sql, + [actorId], + (err, row) => { + if (err) { + return cb(err); + } + properties[row.prop_name] = row.prop_value; + }, + err => { + return cb(err, err ? null : properties); + } + ); + } + + // :TODO: make this much more flexible - propValue should allow for case-insensitive compare, etc. + static getActorIdsWithProperty(propName, propValue, cb) { + let actorIds = []; + + actorDb.each( + `SELECT actor_id + FROM activitypub_actor_property + WHERE prop_name = ? AND prop_value = ?;`, + [propName, propValue], + (err, row) => { + if (row) { + actorIds.push(row.actor_id); + } + }, + () => { + return cb(null, actorIds); + } + ); + } + + static getActorCount(cb) { + actorDb.get( + `SELECT count() AS actor_count + FROM activitypub_actor;`, + (err, row) => { + if (err) { + return cb(err); + } + return cb(null, row.actor_count); + } + ); + } +}; diff --git a/core/activitypub_actor_property.js b/core/activitypub_actor_property.js new file mode 100644 index 00000000..8727b830 --- /dev/null +++ b/core/activitypub_actor_property.js @@ -0,0 +1,18 @@ +/* jslint node: true */ +'use strict'; + +// +// Common Activitypub actor properties used throughout the system. +// +// This IS NOT a full list. For example, custom modules +// can utilize their own properties as well! +// +module.exports = { + Type: 'type', + PreferredUsername: 'preferred_user_name', + Name: 'name', + Summary: 'summary', + IconUrl: 'icon_url', + BannerUrl: 'banner_url', + PublicKeyMain: 'public_key_main_rsa_pem', // RSA public key for user +}; diff --git a/core/database.js b/core/database.js index 9266001d..8c579507 100644 --- a/core/database.js +++ b/core/database.js @@ -41,7 +41,7 @@ function getModDatabasePath(moduleInfo, suffix) { // filename. An optional suffix may be supplied as well. // const HOST_RE = - /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/; + /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/; assert(_.isObject(moduleInfo)); assert(_.isString(moduleInfo.packageName), 'moduleInfo must define "packageName"!'); @@ -81,7 +81,7 @@ function getISOTimestampString(ts) { function sanitizeString(s) { return s.replace(/[\0\x08\x09\x1a\n\r"'\\%]/g, c => { - // eslint-disable-line no-control-regex + // eslint-disable-line no-control-regex switch (c) { case '\0': return '\\0'; @@ -97,7 +97,7 @@ function sanitizeString(s) { return '\\r'; case '"': - case "'": + case '\'': return `${c}${c}`; case '\\': @@ -109,7 +109,7 @@ function sanitizeString(s) { function initializeDatabases(cb) { async.eachSeries( - ['system', 'user', 'message', 'file'], + ['system', 'user', 'actor', 'message', 'file'], (dbName, next) => { dbs[dbName] = sqlite3Trans.wrap( new sqlite3.Database(getDatabasePath(dbName), err => { @@ -242,6 +242,36 @@ const DB_INIT_TABLE = { return cb(null); }, + actor: cb => { + enableForeignKeys(dbs.actor); + + dbs.actor.run( + `CREATE TABLE IF NOT EXISTS activitypub_actor ( + id INTEGER PRIMARY KEY, + actor_url VARCHAR NOT NULL, + UNIQUE(actor_url) + );` + ); + + // :TODO: create FK on delete/etc. + + dbs.actor.run( + `CREATE TABLE IF NOT EXISTS activitypub_actor_property ( + actor_id INTEGER NOT NULL, + prop_name VARCHAR NOT NULL, + prop_value VARCHAR, + UNIQUE(actor_id, prop_name), + FOREIGN KEY(actor_id) REFERENCES actor(id) ON DELETE CASCADE + );` + ); + + dbs.actor.run( + `CREATE INDEX IF NOT EXISTS activitypub_actor_property_id_and_name_index0 + ON activitypub_actor_property (actor_id, prop_name);` + ); + + return cb(null); + }, message: cb => { enableForeignKeys(dbs.message); @@ -312,22 +342,22 @@ const DB_INIT_TABLE = { // :TODO: need SQL to ensure cleaned up if delete from message? /* - dbs.message.run( - `CREATE TABLE IF NOT EXISTS hash_tag ( - hash_tag_id INTEGER PRIMARY KEY, - hash_tag_name VARCHAR NOT NULL, - UNIQUE(hash_tag_name) - );` - ); +dbs.message.run( + `CREATE TABLE IF NOT EXISTS hash_tag ( + hash_tag_id INTEGER PRIMARY KEY, + hash_tag_name VARCHAR NOT NULL, + UNIQUE(hash_tag_name) + );` +); - // :TODO: need SQL to ensure cleaned up if delete from message? - dbs.message.run( - `CREATE TABLE IF NOT EXISTS message_hash_tag ( - hash_tag_id INTEGER NOT NULL, - message_id INTEGER NOT NULL, - );` - ); - */ +// :TODO: need SQL to ensure cleaned up if delete from message? +dbs.message.run( + `CREATE TABLE IF NOT EXISTS message_hash_tag ( + hash_tag_id INTEGER NOT NULL, + message_id INTEGER NOT NULL, + );` +); +*/ dbs.message.run( `CREATE TABLE IF NOT EXISTS user_message_area_last_read (