diff --git a/src/backend/.gitignore b/src/backend/.gitignore index ed0ef7365..9c5ee7b7e 100644 --- a/src/backend/.gitignore +++ b/src/backend/.gitignore @@ -144,8 +144,11 @@ keys # credentials creds* +# test-webhook persisted key +tools/.test-webhook-config.json + # thumbnai-service thumbnail-service # init sql generated from ./run.sh -init.sql \ No newline at end of file +init.sql diff --git a/src/backend/src/modules/broadcast/BroadcastService.js b/src/backend/src/modules/broadcast/BroadcastService.js index 44f9ec42e..ac0b2d03a 100644 --- a/src/backend/src/modules/broadcast/BroadcastService.js +++ b/src/backend/src/modules/broadcast/BroadcastService.js @@ -69,6 +69,12 @@ class BroadcastService extends BaseService { const svc_event = this.services.get('event'); svc_event.on('outer.*', this.on_event.bind(this)); + + // Test event (logs a message to console if DEBUG is set in env) + svc_event.on('test', (key, data, _meta) => { + const { contents } = data; + console.log(`Test Message: ${contents}`); + }); } async on_event (key, data, meta) { @@ -94,7 +100,7 @@ class BroadcastService extends BaseService { }).attach(app); } - handleWebhookRequest_ (req, res) { + async handleWebhookRequest_ (req, res) { const rawBody = req.rawBody; if ( rawBody === undefined || rawBody === null ) { res.status(400).send({ error: { message: 'Missing or invalid body' } }); @@ -128,6 +134,8 @@ class BroadcastService extends BaseService { return; } + this.log.debug('received peerId', { value: peerId }); + const peer = this.peersByKey_[peerId]; if ( !peer || !peer.webhook_secret ) { res.status(403).send({ error: { message: 'Unknown peer or webhook not configured' } }); @@ -192,7 +200,10 @@ class BroadcastService extends BaseService { const svc_event = this.services.get('event'); const metaOut = { ...meta, from_outside: true }; const context = Context.get(undefined, { allow_fallback: true }); - context.arun(async () => { + await context.arun(async () => { + this.log.debug('Emitting to the event service', { + key, data, metaOut, + }); await svc_event.emit(key, data, metaOut); }); diff --git a/src/backend/tools/README.md b/src/backend/tools/README.md index aadfb4781..cd45ab88e 100644 --- a/src/backend/tools/README.md +++ b/src/backend/tools/README.md @@ -1,5 +1,20 @@ # Backend Tools Directory +## Manual Test for Broadcast Webhook Support + +`test-webhook.js` can be used for manual testing the `/broadcast/webhook` endpoint. +It prints a one-off peer config (peer id and `webhook_secret`) for you to add to your instance’s broadcast config, +then prompts for the instance base URL and sends an event with key `"test"`. + +**Usage** (from repo root): + +```bash +node src/backend/tools/test-webhook.js +``` + +Add the printed peer to your config under `broadcast.peers`, restart the instance, then run the script and enter the instance URL +(your Puter API URL, such as `http://api.puter.localhost:4100`) when prompted. + ## Test Kernel The **Test Kernel** is a drop-in replacement for Puter's main kernel. Instead of diff --git a/src/backend/tools/test-webhook.js b/src/backend/tools/test-webhook.js new file mode 100644 index 000000000..afd09af13 --- /dev/null +++ b/src/backend/tools/test-webhook.js @@ -0,0 +1,224 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const readline = require('readline'); + +const CONFIG_PATH = path.join(__dirname, '.test-webhook-config.json'); + +function randomHex (bytes) { + return crypto.randomBytes(bytes).toString('hex'); +} + +function loadConfig () { + try { + const raw = fs.readFileSync(CONFIG_PATH, 'utf8'); + const data = JSON.parse(raw); + if ( data && typeof data.key === 'string' && typeof data.webhook_secret === 'string' ) { + const out = { + key: data.key, + webhook_secret: data.webhook_secret, + nonce: typeof data.nonce === 'number' ? data.nonce : 0, + }; + if ( typeof data.instance_url === 'string' && data.instance_url.trim() !== '' ) { + out.instance_url = data.instance_url.trim().replace(/\/+$/, ''); + } + return out; + } + } catch (e) { + const is_not_found = e.code === 'ENOENT'; + if ( ! is_not_found ) { + console.error('Saved config exists but could not be read:', e); + } + } + return null; +} + +/** + * Saves a dotfile beside the script so new configuration doesn't need to be + * re-entered into Puter every time this script is used. + * @param {*} peerId - The peer ID to save. + * @param {*} webhookSecret - The webhook secret to save. + * @param {*} nonce - The nonce to save. + * @param {*} instanceUrl - The instance URL to save. + */ +function saveConfig (peerId, webhookSecret, nonce, instanceUrl) { + const payload = { + key: peerId, + webhook_secret: webhookSecret, + nonce, + }; + if ( typeof instanceUrl === 'string' && instanceUrl.trim() !== '' ) { + payload.instance_url = instanceUrl.trim().replace(/\/+$/, ''); + } + fs.writeFileSync(CONFIG_PATH, JSON.stringify(payload, null, 2), 'utf8'); +} + +/** + * This wrapper around readline.question is used to promisify the interface + * and remove whitespace from the input. + * + * @param {*} rl + * @param {*} question + * @param {*} defaultAnswer + * @returns {Promise} - The trimmed answer. + */ +function ask (rl, question, defaultAnswer = '') { + const prompt = defaultAnswer ? `${question} [${defaultAnswer}]: ` : `${question} `; + return new Promise((resolve) => { + rl.question(prompt, (answer) => { + const trimmed = answer.trim(); + resolve(trimmed !== '' ? trimmed : defaultAnswer); + }); + }); +} + +async function main () { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + + let peerId; + let webhookSecret; + let nonce; + const existing = loadConfig(); + + if ( existing ) { + const useExisting = await ask(rl, 'Existing key found. Use it? (y/n)', 'y'); + const noAnswers = ['n', 'no']; + if ( noAnswers.includes(useExisting.toLowerCase()) ) { + peerId = `test-webhook-${randomHex(8)}`; + webhookSecret = randomHex(32); + nonce = 0; + saveConfig(peerId, webhookSecret, nonce, existing.instance_url); + console.log(''); + console.log('New key generated.'); + console.log(''); + console.log('Add the following peer to your Puter instance config so it can accept'); + console.log('webhooks from this test script. In your config file (e.g. config.json),'); + console.log('under the "broadcast" section, add a "peers" array (if missing) and'); + console.log('include this entry:'); + console.log(''); + console.log(JSON.stringify({ + key: peerId, + webhook_secret: webhookSecret, + }, null, 2)); + console.log(''); + console.log('Example config structure:'); + console.log(' "broadcast": {'); + console.log(' "peers": ['); + console.log(' { "key": "", "webhook_secret": "" }'); + console.log(' ]'); + console.log(' }'); + console.log(''); + console.log('Restart your Puter instance after updating the config.'); + console.log(''); + } else { + peerId = existing.key; + webhookSecret = existing.webhook_secret; + nonce = existing.nonce; + console.log(''); + console.log('Using existing key:', peerId); + console.log(''); + } + } else { + peerId = `test-webhook-${randomHex(8)}`; + webhookSecret = randomHex(32); + nonce = 0; + saveConfig(peerId, webhookSecret, nonce, undefined); + console.log(''); + console.log('Add the following peer to your Puter instance config so it can accept'); + console.log('webhooks from this test script. In your config file (e.g. config.json),'); + console.log('under the "broadcast" section, add a "peers" array (if missing) and'); + console.log('include this entry:'); + console.log(''); + console.log(JSON.stringify({ + key: peerId, + webhook_secret: webhookSecret, + }, null, 2)); + console.log(''); + console.log('Example config structure:'); + console.log(' "broadcast": {'); + console.log(' "peers": ['); + console.log(' { "key": "", "webhook_secret": "" }'); + console.log(' ]'); + console.log(' }'); + console.log(''); + console.log('Restart your Puter instance after updating the config.'); + console.log(''); + } + + const defaultUrl = existing && existing.instance_url ? existing.instance_url : ''; + const baseUrl = await ask(rl, 'Instance base URL (e.g. http://api.puter.localhost:4100)', defaultUrl); + const url = baseUrl.trim().replace(/\/+$/, ''); + if ( ! url ) { + console.error('Please provide a URL.'); + rl.close(); + process.exit(1); + } + + const webhookUrl = `${url}/broadcast/webhook`; + const timestamp = Math.floor(Date.now() / 1000); + const body = { + key: 'test', + data: { contents: 'I am a test message from test-webhook.js' }, + meta: {}, + }; + const rawBody = JSON.stringify(body); + const payloadToSign = `${timestamp}.${nonce}.${rawBody}`; + const signature = crypto.createHmac('sha256', webhookSecret).update(payloadToSign).digest('hex'); + + try { + const res = await fetch(webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Broadcast-Peer-Id': peerId, + 'X-Broadcast-Timestamp': String(timestamp), + 'X-Broadcast-Nonce': String(nonce), + 'X-Broadcast-Signature': signature, + }, + body: rawBody, + }); + + rl.close(); + + if ( res.ok ) { + saveConfig(peerId, webhookSecret, nonce + 1, url); + console.log(''); + console.log('Test event sent successfully. Status:', res.status); + const text = await res.text(); + if ( text ) console.log('Response:', text); + process.exit(0); + } else { + const text = await res.text(); + console.error(''); + console.error('Request failed. Status:', res.status, res.statusText); + if ( text ) console.error('Response:', text); + process.exit(1); + } + } catch ( err ) { + rl.close(); + console.error(''); + console.error('Request failed:', err.message); + process.exit(1); + } +} + +main();