diff --git a/api/routes/auth/message.mjs b/api/routes/auth/message.mjs index db6f9bef..5fc0b9f8 100644 --- a/api/routes/auth/message.mjs +++ b/api/routes/auth/message.mjs @@ -10,6 +10,7 @@ import { ACCOUNT_TRACKING_MINIMUM_BALANCE, REPRESENTATIVE_TRACKING_MINIMUM_VOTING_WEIGHT } from '#constants' +import { process_community_message } from '#libs-server' const router = express.Router() @@ -29,9 +30,9 @@ router.post('/?', async (req, res) => { public_key, operation, content, - tags, + tags = [], - references, + references = [], created_at, @@ -68,7 +69,12 @@ router.post('/?', async (req, res) => { } // operation must be SET or DELETE - const allowed_operations = ['SET', 'SET_ACCOUNT_META', 'SET_BLOCK_META'] + const allowed_operations = [ + 'SET', + 'SET_ACCOUNT_META', + 'SET_REPRESENTATIVE_META', + 'SET_BLOCK_META' + ] if (!allowed_operations.includes(operation)) { return res.status(400).json({ error: 'Invalid operation' }) } @@ -117,37 +123,31 @@ router.post('/?', async (req, res) => { } // public_key can be a linked keypair or an existing nano account - const linked_accounts = await db('account_keys') + + const linked_account = await db('account_keys') .select('account') .where({ public_key }) .whereNull('revoked_at') - const nano_account = encode_nano_address({ - public_key_buf: Buffer.from(public_key, 'hex') - }) + .first() - const all_accounts = [ - ...linked_accounts.map((row) => row.account), - nano_account - ] + const message_nano_account = linked_account + ? linked_account.account + : encode_nano_address({ + public_key_buf: Buffer.from(public_key, 'hex') + }) - const accounts_info = [] - for (const account of all_accounts) { - const account_info = await rpc.accountInfo({ account }) - if (account_info) { - accounts_info.push(account_info) - } - } + const account_info = await rpc.accountInfo({ + account: message_nano_account + }) // check if any of the accounts have a balance beyond the tracking threshold - const has_balance = accounts_info.some((account_info) => - new BigNumber(account_info.balance).gte(ACCOUNT_TRACKING_MINIMUM_BALANCE) + const has_balance = new BigNumber(account_info?.balance || 0).gte( + ACCOUNT_TRACKING_MINIMUM_BALANCE ) // check if any of the accounts have weight beyond the tracking threshold - const has_weight = accounts_info.some((account_info) => - new BigNumber(account_info.weight).gte( - REPRESENTATIVE_TRACKING_MINIMUM_VOTING_WEIGHT - ) + const has_weight = new BigNumber(account_info?.weight || 0).gte( + REPRESENTATIVE_TRACKING_MINIMUM_VOTING_WEIGHT ) if (has_balance || has_weight) { @@ -175,6 +175,15 @@ router.post('/?', async (req, res) => { .merge() } + try { + await process_community_message({ + message, + message_account: message_nano_account + }) + } catch (error) { + logger(error) + } + res.status(200).send({ version, diff --git a/cli/index.mjs b/cli/index.mjs index 382607ad..bd1ea5e2 100644 --- a/cli/index.mjs +++ b/cli/index.mjs @@ -179,13 +179,13 @@ const send_message_handler = async (type, block_hash = null) => { { name: 'donation_address', message: 'Donation Address:' }, { name: 'cpu_model', message: 'CPU Model:' }, { name: 'cpu_cores', message: 'CPU Cores:' }, - { name: 'ram_amount', message: 'RAM Amount:' }, - { name: 'reddit_username', message: 'Reddit Username:' }, - { name: 'twitter_username', message: 'Twitter Username:' }, - { name: 'discord_username', message: 'Discord Username:' }, - { name: 'github_username', message: 'GitHub Username:' }, + { name: 'ram', message: 'RAM Amount (GB):' }, + { name: 'reddit', message: 'Reddit Username:' }, + { name: 'twitter', message: 'Twitter Username:' }, + { name: 'discord', message: 'Discord Username:' }, + { name: 'github', message: 'GitHub Username:' }, { name: 'email', message: 'Email:' }, - { name: 'website_url', message: 'Website URL:' } + { name: 'website', message: 'Website URL:' } ] break case 'update-account-meta': @@ -244,6 +244,8 @@ const send_message_handler = async (type, block_hash = null) => { let operation = '' switch (type) { case 'update-rep-meta': + operation = 'SET_REPRESENTATIVE_META' + break case 'update-account-meta': operation = 'SET_ACCOUNT_META' break diff --git a/db/schema.sql b/db/schema.sql index 87eaec06..52a5594c 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -19,6 +19,21 @@ CREATE TABLE `accounts` ( -- -------------------------------------------------------- -- +-- Table structure for table `accounts_changelog` +-- + +DROP TABLE IF EXISTS `accounts_changelog`; + +CREATE TABLE `accounts_changelog` ( + `account` char(65) NOT NULL, + `column` varchar(65) NOT NULL, + `previous_value` varchar(1000) CHARACTER SET utf8mb4 DEFAULT '', + `new_value` varchar(1000) CHARACTER SET utf8mb4 DEFAULT '', + `timestamp` int(11) NOT NULL, + UNIQUE `change` (`account`, `column`, `previous_value`(60), `new_value`(60)) +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- ---------------------------------------------------------- -- Table structure for table `account_keys` -- @@ -225,7 +240,7 @@ CREATE TABLE `nano_community_messages` ( `entry_clock` int(10) unsigned DEFAULT NULL, `chain_clock` int(10) unsigned DEFAULT NULL, `public_key` varchar(64) NOT NULL, - `operation` varchar(10) NOT NULL, + `operation` varchar(50) NOT NULL, `content` text CHARACTER SET utf8mb4 DEFAULT NULL, `tags` text CHARACTER SET utf8mb4 DEFAULT NULL, `references` text CHARACTER SET utf8mb4 DEFAULT NULL, @@ -295,6 +310,7 @@ CREATE TABLE `representatives_meta_index` ( `bandwidth_description` varchar(255) CHARACTER SET utf8 DEFAULT NULL, `ram` int(3) DEFAULT NULL, `ram_description` varchar(255) DEFAULT NULL, + `donation_address` char(65) DEFAULT NULL, `description` varchar(1000) CHARACTER SET utf8mb4 DEFAULT NULL, `dedicated` tinyint(1) DEFAULT NULL, diff --git a/libs-server/index.mjs b/libs-server/index.mjs new file mode 100644 index 00000000..b90b8b35 --- /dev/null +++ b/libs-server/index.mjs @@ -0,0 +1,3 @@ +export { default as update_account } from './update-account.mjs' +export { default as update_representative_meta } from './update-representative-meta.mjs' +export { default as process_community_message } from './process-community-message.mjs' diff --git a/libs-server/process-community-message.mjs b/libs-server/process-community-message.mjs new file mode 100644 index 00000000..883f6832 --- /dev/null +++ b/libs-server/process-community-message.mjs @@ -0,0 +1,116 @@ +import debug from 'debug' + +import update_account from './update-account.mjs' +import update_representative_meta from './update-representative-meta.mjs' + +const log = debug('process-community-message') + +const process_set_representative_meta = async ({ + message_content, + message_account +}) => { + if (!message_content) { + log( + `No message content found for SET_REPRESENTATIVE_META message from ${message_account}` + ) + return + } + + const { alias } = message_content + if (alias) { + await update_account({ + account_address: message_account, + update: { alias } + }) + } + + const { + cpu_cores, + description, + donation_address, + cpu_model, + ram, + reddit, + twitter, + discord, + github, + email, + website + } = message_content + + await update_representative_meta({ + representative_account_address: message_account, + update: { + cpu_cores, + description, + donation_address, + cpu_model, + ram, + reddit, + twitter, + discord, + github, + email, + website + } + }) +} + +const process_set_account_meta = async ({ + message_content, + message_account +}) => { + if (!message_content) { + log( + `No message content found for SET_ACCOUNT_META message from ${message_account}` + ) + return + } + + const { alias } = message_content + if (alias) { + await update_account({ + account_address: message_account, + update: { alias } + }) + } +} + +export default async function process_community_message({ + message, + message_account +}) { + let message_content + try { + message_content = JSON.parse(message.content) + } catch (error) { + log(`Error parsing message content: ${error}`) + return + } + + if (!message_content) { + log( + `No message content found for ${message.operation} message from ${message_account}` + ) + return + } + + switch (message.operation) { + case 'SET_ACCOUNT_META': + return process_set_account_meta({ + message, + message_content, + message_account + }) + + case 'SET_REPRESENTATIVE_META': + return process_set_representative_meta({ + message, + message_content, + message_account + }) + + default: + log(`Unsupported message operation: ${message.operation}`) + } +} diff --git a/libs-server/update-account.mjs b/libs-server/update-account.mjs new file mode 100644 index 00000000..ae6ba215 --- /dev/null +++ b/libs-server/update-account.mjs @@ -0,0 +1,108 @@ +import diff from 'deep-diff' +import debug from 'debug' + +import db from '#db' + +const log = debug('update-account') + +const nullable_columns = [] +const excluded_columns = [] +const editable_columns = ['alias', 'monitor_url', 'watt_hour'] + +export default async function update_account({ + account_row, + account_address, + update +}) { + if (!update) { + return 0 + } + + if ( + !account_row && + (typeof account_address === 'string' || account_address instanceof String) + ) { + account_row = await db('accounts') + .where({ account: account_address }) + .first() + + // If account_row is still not found, create a new one + if (!account_row) { + await db('accounts').insert({ + account: account_address + }) + account_row = await db('accounts') + .where({ account: account_address }) + .first() + } + } + + if (!account_row) { + return 0 + } + + if (!account_row.account) { + throw new Error('Account is missing account address') + } + + const formatted_update = { + ...update, + account: account_row.account + } + + // TODO format & validate params + + const differences = diff(account_row, formatted_update) + + const edits = differences.filter((d) => d.kind === 'E') + if (!edits.length) { + return 0 + } + + let changes_count = 0 + for (const edit of edits) { + const prop = edit.path[0] + + if (!editable_columns.includes(prop)) { + continue + } + + const is_null = !edit.rhs + const is_nullable = nullable_columns.includes(prop) + if (is_null && !is_nullable) { + continue + } + + if (excluded_columns.includes(prop)) { + log(`not allowed to edit ${prop}`) + continue + } + + log( + `updating account: ${account_row.account}, Column: ${prop}, Value: ${edit.lhs} => ${edit.rhs}` + ) + + const has_existing_value = edit.lhs + if (has_existing_value) { + await db('accounts_changelog').insert({ + account: account_row.account, + column: prop, + previous_value: edit.lhs, + new_value: edit.rhs, + timestamp: Math.floor(Date.now() / 1000) + }) + } + + await db('accounts') + .update({ + [prop]: edit.rhs + }) + .where({ + account: account_row.account + }) + + changes_count += 1 + } + + return changes_count +} diff --git a/libs-server/update-representative-meta.mjs b/libs-server/update-representative-meta.mjs new file mode 100644 index 00000000..b313be41 --- /dev/null +++ b/libs-server/update-representative-meta.mjs @@ -0,0 +1,121 @@ +import diff from 'deep-diff' +import debug from 'debug' + +import db from '#db' + +const log = debug('update-representative-meta') + +const nullable_columns = [] +const excluded_columns = [] +const editable_columns = [ + 'alias', + 'description', + 'donation_address', + 'cpu_model', + 'cpu_cores', + 'ram', + 'reddit', + 'twitter', + 'discord', + 'github', + 'email', + 'website' +] + +export default async function update_representative_meta({ + representative_row, + representative_account_address, + update +}) { + if (!update) { + return 0 + } + + if ( + !representative_row && + (typeof representative_account_address === 'string' || + representative_account_address instanceof String) + ) { + representative_row = await db('representatives_meta_index') + .where({ account: representative_account_address }) + .first() + + // If representative_row is still not found, create a new one + if (!representative_row) { + await db('representatives_meta_index').insert({ + account: representative_account_address, + timestamp: Math.floor(Date.now() / 1000) + }) + representative_row = await db('representatives_meta_index') + .where({ account: representative_account_address }) + .first() + } + } + + if (!representative_row) { + return 0 + } + + if (!representative_row.account) { + throw new Error('Representative is missing account address') + } + + const formatted_update = { + ...update, + account: representative_row.account + } + + const differences = diff(representative_row, formatted_update) + + const edits = differences.filter((d) => d.kind === 'E') + if (!edits.length) { + return 0 + } + + let changes_count = 0 + for (const edit of edits) { + const prop = edit.path[0] + + if (!editable_columns.includes(prop)) { + continue + } + + const is_null = !edit.rhs + const is_nullable = nullable_columns.includes(prop) + if (is_null && !is_nullable) { + continue + } + + if (excluded_columns.includes(prop)) { + log(`not allowed to edit ${prop}`) + continue + } + + log( + `updating representative: ${representative_row.account}, Column: ${prop}, Value: ${edit.lhs} => ${edit.rhs}` + ) + + const has_existing_value = edit.lhs + if (has_existing_value) { + await db('representatives_meta_index_changelog').insert({ + account: representative_row.account, + column: prop, + previous_value: edit.lhs, + new_value: edit.rhs, + timestamp: Math.floor(Date.now() / 1000) + }) + } + + await db('representatives_meta_index') + .update({ + [prop]: edit.rhs + }) + .where({ + account: representative_row.account + }) + + changes_count += 1 + } + + return changes_count +} diff --git a/package.json b/package.json index f7edba2e..14ee3078 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "#common/*": "./common/*", "#constants": "./constants.mjs", "#config": "./config.js", - "#db": "./db/index.mjs" + "#db": "./db/index.mjs", + "#libs-server": "./libs-server/index.mjs", + "#libs-server/*": "./libs-server/*" }, "scripts": { "dev": "concurrently \"yarn start\" \"yarn start:api\"", diff --git a/test/auth.revoke.key.test.mjs b/test/auth.revoke.key.test.mjs index de9e8e70..8c30f8b5 100644 --- a/test/auth.revoke.key.test.mjs +++ b/test/auth.revoke.key.test.mjs @@ -26,15 +26,15 @@ describe('API /auth/revoke/key', () => { '00000000000000000000000000000000000000000000000000000000000000000', 'hex' ) - const nano_account_public_key = ed25519.publicKey(nano_account_private_key) + const nano_account_public_key = ed25519.publicKey( + nano_account_private_key + ) const new_signing_private_key = Buffer.from( '00000000000000000000000000000000000000000000000000000000000000001', 'hex' ) - const new_signing_public_key = ed25519.publicKey( - new_signing_private_key - ) + const new_signing_public_key = ed25519.publicKey(new_signing_private_key) const nano_account = encode_nano_address({ public_key_buf: nano_account_public_key }) @@ -81,7 +81,9 @@ describe('API /auth/revoke/key', () => { // eslint-disable-next-line no-unused-expressions expect(revoked_row).to.exist expect(revoked_row.account).to.equal(nano_account) - expect(revoked_row.public_key).to.equal(new_signing_public_key.toString('hex')) + expect(revoked_row.public_key).to.equal( + new_signing_public_key.toString('hex') + ) expect(revoked_row.revoke_signature).to.equal( revoke_signature.toString('hex') ) @@ -93,7 +95,9 @@ describe('API /auth/revoke/key', () => { '000000000000000000000000000000000000000000000000000000000000000FF', 'hex' ) - const nano_account_public_key = ed25519.publicKey(nano_account_private_key) + const nano_account_public_key = ed25519.publicKey( + nano_account_private_key + ) const new_signing_private_key = Buffer.from( '00000000000000000000000000000000000000000000000000000000000000FFF', @@ -146,7 +150,9 @@ describe('API /auth/revoke/key', () => { // eslint-disable-next-line no-unused-expressions expect(revoked_row).to.exist expect(revoked_row.account).to.equal(nano_account) - expect(revoked_row.public_key).to.equal(new_signing_public_key.toString('hex')) + expect(revoked_row.public_key).to.equal( + new_signing_public_key.toString('hex') + ) expect(revoked_row.revoke_signature).to.equal( revoke_signature.toString('hex') ) diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 8b581c92..9bcc3586 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -2,9 +2,15 @@ import chai from 'chai' import { exec, spawn } from 'child_process' import util from 'util' +import nock from 'nock' import server from '#api/server.mjs' import config from '#config' +import db from '#db' +import { + REPRESENTATIVE_TRACKING_MINIMUM_VOTING_WEIGHT, + ACCOUNT_TRACKING_MINIMUM_BALANCE +} from '#constants' const { port } = config const exec_promise = util.promisify(exec) @@ -30,10 +36,13 @@ describe('CLI', function () { this.timeout(30000) let new_signing_key + const nano_private_key = + '1111111111111111111111111111111111111111111111111111111111111111' + const nano_account_address = + 'nano_3d78japo7ziqqcsptk47eonzwzwjyaydcywq5ebzowjpxgyehynnjc9pd5zj' before(() => { - process.env.NC_CLI_NANO_PRIVATE_KEY = - '1111111111111111111111111111111111111111111111111111111111111111' + process.env.NC_CLI_NANO_PRIVATE_KEY = nano_private_key server.listen(port, () => console.log(`API listening on port ${port}`)) }) @@ -75,10 +84,31 @@ describe('CLI', function () { }) describe('update-rep-meta operation', () => { - it('should send a message for update-rep-meta operation', async () => { + it('should send a message for update-rep-meta operation and check database updates', async () => { let stdout = '' let stderr = '' + const expected_alias = 'TestNodeAlias' + const expected_description = 'A test node for development purposes.' + const expected_donation_address = + 'nano_3niceeeyiaaif5xoiqjvth5gqrypuwytrm867asbciw3ndz8j3mazqqk6cok' + const expected_cpu_model = 'Intel i7' + const expected_cpu_cores = 4 + const expected_ram = 16 + const expected_reddit = 'test_reddit_user' + const expected_twitter = 'test_twitter_user' + const expected_discord = 'test_discord_user#1234' + const expected_github = 'test_github_user' + const expected_email = 'test@example.com' + const expected_website = 'https://example.com' + try { + // mock the account_info rpc request needed for message storing + nock('http://nano:7076') + .post('/', (body) => body.action === 'account_info') + .reply(200, { + weight: String(REPRESENTATIVE_TRACKING_MINIMUM_VOTING_WEIGHT) + }) + const child = spawn('node', ['cli/index.mjs', 'update-rep-meta'], { stdio: ['pipe', 'pipe', 'pipe'] }) @@ -88,42 +118,40 @@ describe('CLI', function () { switch (output) { case '? Alias:': - child.stdin.write('TestNodeAlias\n') + child.stdin.write(`${expected_alias}\n`) break case '? Description:': - child.stdin.write('A test node for development purposes.\n') + child.stdin.write(`${expected_description}\n`) break case '? Donation Address:': - child.stdin.write( - 'nano_3niceeeyiaaif5xoiqjvth5gqrypuwytrm867asbciw3ndz8j3mazqqk6cok\n' - ) + child.stdin.write(`${expected_donation_address}\n`) break case '? CPU Model:': - child.stdin.write('Intel i7\n') + child.stdin.write(`${expected_cpu_model}\n`) break case '? CPU Cores:': - child.stdin.write('4\n') + child.stdin.write(`${expected_cpu_cores}\n`) break - case '? RAM Amount:': - child.stdin.write('16GB\n') + case '? RAM Amount (GB):': + child.stdin.write(`${expected_ram}\n`) break case '? Reddit Username:': - child.stdin.write('test_reddit_user\n') + child.stdin.write(`${expected_reddit}\n`) break case '? Twitter Username:': - child.stdin.write('test_twitter_user\n') + child.stdin.write(`${expected_twitter}\n`) break case '? Discord Username:': - child.stdin.write('test_discord_user#1234\n') + child.stdin.write(`${expected_discord}\n`) break case '? GitHub Username:': - child.stdin.write('test_github_user\n') + child.stdin.write(`${expected_github}\n`) break case '? Email:': - child.stdin.write('test@example.com\n') + child.stdin.write(`${expected_email}\n`) break case '? Website URL:': - child.stdin.write('https://example.com\n') + child.stdin.write(`${expected_website}\n`) break case '? Would you like to edit any field? (y/N)': child.stdin.write('N\n') @@ -150,6 +178,32 @@ describe('CLI', function () { // eslint-disable-next-line no-unused-expressions expect(stderr).to.be.empty expect(exit_code).to.equal(0) + + // Check database for updated columns + const updated_account = await db('accounts') + .where({ account: nano_account_address }) + .first() + expect(updated_account.alias).to.equal(expected_alias) + + const updated_representative = await db('representatives_meta_index') + .where({ account: nano_account_address }) + .first() + + expect(updated_representative.description).to.equal( + expected_description + ) + expect(updated_representative.donation_address).to.equal( + expected_donation_address + ) + expect(updated_representative.cpu_model).to.equal(expected_cpu_model) + expect(updated_representative.cpu_cores).to.equal(expected_cpu_cores) + expect(updated_representative.ram).to.equal(expected_ram) + expect(updated_representative.reddit).to.equal(expected_reddit) + expect(updated_representative.twitter).to.equal(expected_twitter) + expect(updated_representative.discord).to.equal(expected_discord) + expect(updated_representative.github).to.equal(expected_github) + expect(updated_representative.email).to.equal(expected_email) + expect(updated_representative.website).to.equal(expected_website) } catch (err) { console.log(err) console.log(stderr) @@ -164,6 +218,13 @@ describe('CLI', function () { let stderr = '' let stdout = '' try { + // mock the account_info rpc request needed for message storing + nock('http://nano:7076') + .post('/', (body) => body.action === 'account_info') + .reply(200, { + balance: String(ACCOUNT_TRACKING_MINIMUM_BALANCE) + }) + const child = spawn('node', ['cli/index.mjs', 'update-account-meta'], { stdio: ['pipe', 'pipe', 'pipe'] })