Initial push
This commit is contained in:
224
hubzilla/addon/cry01/cry01.php
Normal file
224
hubzilla/addon/cry01/cry01.php
Normal file
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Name: CRY01 Value Layer
|
||||
* Description: Ğ1 balance display, capacity signal registration, and Ğ1 web of trust bridge.
|
||||
* Version: 0.1.0
|
||||
* MinVersion: 11.0
|
||||
* MaxVersion: 12.0
|
||||
*/
|
||||
|
||||
use Zotlabs\Extend\Widget;
|
||||
|
||||
require_once 'addon/cry01/cry01_chain.php';
|
||||
require_once 'addon/cry01/cry01_renderer.php';
|
||||
require_once 'addon/cry01/cry01_spool.php';
|
||||
|
||||
function cry01_module() {}
|
||||
|
||||
function cry01_load() {
|
||||
register_hook('load_pdl', 'addon/cry01/cry01.php', 'cry01_load_pdl');
|
||||
Widget::register('addon/cry01/Widget/Cry01.php', 'cry01');
|
||||
}
|
||||
|
||||
function cry01_unload() {
|
||||
unregister_hook('load_pdl', 'addon/cry01/cry01.php', 'cry01_load_pdl');
|
||||
Widget::unregister('addon/cry01/Widget/Cry01.php', 'cry01');
|
||||
}
|
||||
|
||||
function cry01_load_pdl(&$b) {
|
||||
// Loads the cry01 PDL layout for cry01 module pages.
|
||||
if (!is_array($b) || empty($b['module']) || $b['module'] !== 'cry01') {
|
||||
return;
|
||||
}
|
||||
$layout = @file_get_contents('addon/cry01/mod_cry01.pdl');
|
||||
if ($layout !== false) {
|
||||
$b['layout'] = $layout;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HELPERS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function cry01_h($value) {
|
||||
// HTML-escapes a value for safe output.
|
||||
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ACCESS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function cry01_access_state($association_slug = '') {
|
||||
// Returns operator, participant, or public. Does not call local_channel() for group checks.
|
||||
if (local_channel()) {
|
||||
$channel = App::get_channel();
|
||||
if (local_channel() === intval($channel['channel_id'])) {
|
||||
return 'operator';
|
||||
}
|
||||
}
|
||||
|
||||
if (!$association_slug) return 'public';
|
||||
|
||||
$raw = @file_get_contents('addon/vs01/config.json');
|
||||
if ($raw === false) return 'public';
|
||||
$cfg = json_decode($raw, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) return 'public';
|
||||
|
||||
$assoc = $cfg['associations'][$association_slug] ?? null;
|
||||
if (!$assoc) return 'public';
|
||||
|
||||
$observer = get_observer_hash();
|
||||
if (!$observer) return 'public';
|
||||
|
||||
$groups = $assoc['groups'] ?? [];
|
||||
|
||||
// Direct pgrp_member query — works for guest tokens.
|
||||
foreach (['corpus_builder', 'sase_participant', 'civic_professional'] as $group_key) {
|
||||
$gid = intval($groups[$group_key] ?? 0);
|
||||
if ($gid) {
|
||||
$r = q("SELECT xchan FROM pgrp_member WHERE gid = %d AND xchan = '%s' LIMIT 1",
|
||||
intval($gid),
|
||||
dbesc($observer)
|
||||
);
|
||||
if ($r) return 'participant';
|
||||
}
|
||||
}
|
||||
|
||||
return 'public';
|
||||
}
|
||||
|
||||
function cry01_access_wall($association_slug = '') {
|
||||
// Renders the access wall for non-participants attempting to submit a signal.
|
||||
$raw = @file_get_contents('addon/vs01/config.json');
|
||||
$cfg = $raw ? json_decode($raw, true) : [];
|
||||
$assoc = $association_slug ? ($cfg['associations'][$association_slug] ?? null) : null;
|
||||
$name = $assoc ? cry01_h($assoc['name']) : 'this association';
|
||||
return '
|
||||
<div class="cry01-content">
|
||||
<div class="alert alert-info" role="alert">
|
||||
<strong>HOA_MEMBER standing required to register a signal for ' . $name . '.</strong>
|
||||
The signal board is readable by verified participants only.
|
||||
To participate, you must complete the SASE process.
|
||||
Visit <a href="https://directory.diagnostics.kane-il.us/channel/theron">
|
||||
directory.diagnostics.kane-il.us
|
||||
</a> to begin.
|
||||
</div>
|
||||
</div>
|
||||
';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CONTENT ROUTER
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function cry01_content() {
|
||||
if (function_exists('head_add_css')) {
|
||||
head_add_css('/addon/cry01/view/css/cry01.css');
|
||||
}
|
||||
if (function_exists('head_add_js')) {
|
||||
head_add_js('/addon/cry01/view/js/cry01.js');
|
||||
}
|
||||
|
||||
$association_slug = argv(1) ?? '';
|
||||
$sub_route = strtolower(argv(2) ?? '');
|
||||
|
||||
if (!$association_slug) {
|
||||
return cry01_render_index();
|
||||
}
|
||||
|
||||
$raw = @file_get_contents('addon/vs01/config.json');
|
||||
if ($raw === false) return cry01_render_error('Configuration unavailable. Contact the operator.');
|
||||
$cfg = json_decode($raw, true);
|
||||
if (!isset($cfg['associations'][$association_slug])) {
|
||||
return cry01_render_not_found();
|
||||
}
|
||||
|
||||
$access = cry01_access_state($association_slug);
|
||||
|
||||
switch ($sub_route) {
|
||||
case 'signal':
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if ($access === 'public') return cry01_access_wall($association_slug);
|
||||
if (!cry01_verify_csrf()) {
|
||||
return cry01_render_error('Invalid form token. Please reload and try again.');
|
||||
}
|
||||
return cry01_handle_signal_post($association_slug, $access);
|
||||
}
|
||||
if ($access === 'public') return cry01_access_wall($association_slug);
|
||||
return cry01_render_signal_form($association_slug, $access);
|
||||
|
||||
case 'g1':
|
||||
if ($access !== 'operator') return cry01_access_wall($association_slug);
|
||||
return cry01_render_g1_candidates($association_slug);
|
||||
|
||||
case 'manage':
|
||||
if ($access !== 'operator') return cry01_access_wall($association_slug);
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
return cry01_handle_manage_post($association_slug);
|
||||
}
|
||||
return cry01_render_manage($association_slug);
|
||||
|
||||
default:
|
||||
return cry01_render_landing($association_slug, $access);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RENDER — INDEX
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function cry01_render_index() {
|
||||
// Lists all registered associations with links to their value layer pages.
|
||||
$raw = @file_get_contents('addon/vs01/config.json');
|
||||
$cfg = $raw ? json_decode($raw, true) : [];
|
||||
$list = $cfg['associations'] ?? [];
|
||||
|
||||
if (empty($list)) {
|
||||
return '<div class="cry01-content"><p class="text-muted">No associations registered.</p></div>';
|
||||
}
|
||||
|
||||
$out = '<div class="cry01-content">';
|
||||
$out .= '<h2>Value Layer</h2>';
|
||||
$out .= '<ul class="list-group">';
|
||||
foreach ($list as $slug => $assoc) {
|
||||
$name = cry01_h($assoc['name'] ?? $slug);
|
||||
$url = z_root() . '/cry01/' . cry01_h($slug);
|
||||
$out .= '<li class="list-group-item"><a href="' . $url . '">' . $name . '</a></li>';
|
||||
}
|
||||
$out .= '</ul></div>';
|
||||
return $out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RENDER — NOT FOUND / ERROR
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function cry01_render_not_found() {
|
||||
return '<div class="cry01-content"><div class="alert alert-warning">Association not found.</div></div>';
|
||||
}
|
||||
|
||||
function cry01_render_error($message) {
|
||||
// Shows a plain-language error message. Never shows a blank page or stack trace.
|
||||
return '<div class="cry01-content"><div class="alert alert-danger">' . cry01_h($message) . '</div></div>';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSRF
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function cry01_csrf_token() {
|
||||
// Generates and stores a CSRF token for the current session.
|
||||
if (empty($_SESSION['cry01_csrf'])) {
|
||||
$_SESSION['cry01_csrf'] = bin2hex(random_bytes(16));
|
||||
}
|
||||
return '<input type="hidden" name="cry01_csrf" value="'
|
||||
. cry01_h($_SESSION['cry01_csrf']) . '">';
|
||||
}
|
||||
|
||||
function cry01_verify_csrf() {
|
||||
// Returns true if the CSRF token in POST matches the session token.
|
||||
return isset($_POST['cry01_csrf'], $_SESSION['cry01_csrf'])
|
||||
&& hash_equals($_SESSION['cry01_csrf'], $_POST['cry01_csrf']);
|
||||
}
|
||||
156
hubzilla/addon/cry01/cry01_chain.php
Normal file
156
hubzilla/addon/cry01/cry01_chain.php
Normal file
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* cry01_chain.php — On-chain read layer.
|
||||
* Reads Ğ1 balances from the local Duniter node RPC over Wireguard.
|
||||
* Reads and writes the local balance cache.
|
||||
* This is the only file in the project that makes outbound network calls.
|
||||
* If the node infrastructure changes, only this file changes.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BALANCE READ
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function cry01_get_balance($g1_pubkey) {
|
||||
// Returns the current Ğ1 balance for the given public key.
|
||||
// Queries the local Duniter node RPC. Returns null on failure.
|
||||
$config = cry01_load_config();
|
||||
$rpc = $config['g1_rpc_endpoint'] ?? '';
|
||||
if (!$rpc || !$g1_pubkey) return null;
|
||||
|
||||
$payload = json_encode([
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => 1,
|
||||
'method' => 'state_getStorage',
|
||||
'params' => [cry01_balance_storage_key($g1_pubkey)],
|
||||
]);
|
||||
|
||||
$result = cry01_rpc_post($rpc, $payload);
|
||||
if ($result === null) return null;
|
||||
|
||||
return cry01_decode_balance($result);
|
||||
}
|
||||
|
||||
function cry01_balance_storage_key($g1_pubkey) {
|
||||
// Builds the Substrate storage key for the account balance of the given Ğ1 public key.
|
||||
// The key format is defined by the Duniter v2 runtime (pallet_balances storage map).
|
||||
// Placeholder — requires Substrate storage key encoding (xxHash + Blake2).
|
||||
// TODO: implement full storage key derivation or use duniter-specific RPC method.
|
||||
return '0x' . bin2hex($g1_pubkey);
|
||||
}
|
||||
|
||||
function cry01_decode_balance($rpc_result) {
|
||||
// Decodes the raw RPC result into a human-readable Ğ1 balance string.
|
||||
// Duniter v2 balances are stored as u64 encoded in SCALE codec.
|
||||
// Returns formatted balance string or null if decoding fails.
|
||||
$hex = $rpc_result['result'] ?? null;
|
||||
if (!$hex || $hex === '0x') return '0';
|
||||
// Remove 0x prefix and decode little-endian u64.
|
||||
$hex = ltrim($hex, '0x');
|
||||
$bytes = array_reverse(str_split(str_pad($hex, 16, '0', STR_PAD_LEFT), 2));
|
||||
$val = 0;
|
||||
foreach ($bytes as $b) {
|
||||
$val = ($val << 8) | hexdec($b);
|
||||
}
|
||||
// Duniter uses centimes (hundredths of Ğ1). Divide by 100.
|
||||
return number_format($val / 100, 2) . ' Ğ1';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RPC TRANSPORT
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function cry01_rpc_post($endpoint, $payload) {
|
||||
// POSTs a JSON-RPC request to the Duniter node. Returns decoded response or null.
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => "Content-Type: application/json\r\n",
|
||||
'content' => $payload,
|
||||
'timeout' => 5,
|
||||
],
|
||||
]);
|
||||
$raw = @file_get_contents($endpoint, false, $context);
|
||||
if ($raw === false) {
|
||||
logger('cry01_chain: RPC call failed to ' . $endpoint);
|
||||
return null;
|
||||
}
|
||||
$data = json_decode($raw, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
logger('cry01_chain: RPC response is not valid JSON');
|
||||
return null;
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CACHE
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function cry01_read_cache() {
|
||||
// Returns the balance cache array. Returns empty array if cache does not exist or is unreadable.
|
||||
$config = cry01_load_config();
|
||||
$path = $config['cache_file'] ?? '';
|
||||
if (!$path) return [];
|
||||
$raw = @file_get_contents($path);
|
||||
if ($raw === false) return [];
|
||||
$data = json_decode($raw, true);
|
||||
return (json_last_error() === JSON_ERROR_NONE) ? $data : [];
|
||||
}
|
||||
|
||||
function cry01_write_cache($data) {
|
||||
// Writes the balance cache to disk. Returns true on success, false on failure.
|
||||
$config = cry01_load_config();
|
||||
$path = $config['cache_file'] ?? '';
|
||||
if (!$path) return false;
|
||||
$tmp = $path . '.tmp';
|
||||
$ok = @file_put_contents($tmp, json_encode($data, JSON_PRETTY_PRINT));
|
||||
if ($ok === false) {
|
||||
logger('cry01_chain: could not write cache to ' . $tmp);
|
||||
return false;
|
||||
}
|
||||
return @rename($tmp, $path);
|
||||
}
|
||||
|
||||
function cry01_cache_is_stale() {
|
||||
// Returns true if the cache is older than cache_max_age_seconds or does not exist.
|
||||
$config = cry01_load_config();
|
||||
$path = $config['cache_file'] ?? '';
|
||||
$max_age = intval($config['cache_max_age_seconds'] ?? 3600);
|
||||
if (!$path || !file_exists($path)) return true;
|
||||
return (time() - filemtime($path)) > $max_age;
|
||||
}
|
||||
|
||||
function cry01_refresh_balance_cache() {
|
||||
// Refreshes the balance cache by querying the Duniter node.
|
||||
// Writes updated cache to disk. Called by the manage route.
|
||||
$config = cry01_load_config();
|
||||
$pubkey = $config['operator_g1_pubkey'] ?? '';
|
||||
if (!$pubkey) {
|
||||
logger('cry01_chain: operator_g1_pubkey not set in config');
|
||||
return false;
|
||||
}
|
||||
$balance = cry01_get_balance($pubkey);
|
||||
if ($balance === null) return false;
|
||||
|
||||
$cache = cry01_read_cache();
|
||||
$cache['operator_balance'] = $balance;
|
||||
$cache['operator_g1_pubkey'] = $pubkey;
|
||||
$cache['refreshed_at'] = date('c');
|
||||
return cry01_write_cache($cache);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CONFIG LOADER
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function cry01_load_config() {
|
||||
// Loads cry01 config.json from the addon directory. Returns empty array on failure.
|
||||
static $cfg = null;
|
||||
if ($cfg !== null) return $cfg;
|
||||
$raw = @file_get_contents('addon/cry01/config.json');
|
||||
if ($raw === false) return [];
|
||||
$cfg = json_decode($raw, true);
|
||||
return (json_last_error() === JSON_ERROR_NONE) ? $cfg : [];
|
||||
}
|
||||
245
hubzilla/addon/cry01/cry01_renderer.php
Normal file
245
hubzilla/addon/cry01/cry01_renderer.php
Normal file
@@ -0,0 +1,245 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* cry01_renderer.php — All HTML rendering for cry01.
|
||||
* Knows nothing about network calls or POST handling.
|
||||
* Reads config and cache via cry01_chain.php functions.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ASSOCIATION LANDING
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function cry01_render_landing($association_slug, $access) {
|
||||
// Renders the main cry01 page: balance display + signal board.
|
||||
$raw = @file_get_contents('addon/vs01/config.json');
|
||||
$cfg = $raw ? json_decode($raw, true) : [];
|
||||
$assoc = $cfg['associations'][$association_slug] ?? [];
|
||||
$name = cry01_h($assoc['name'] ?? $association_slug);
|
||||
|
||||
$out = '<div class="cry01-content">';
|
||||
$out .= '<div class="cry01-header mb-3">';
|
||||
$out .= '<h2>' . $name . '</h2>';
|
||||
$out .= '<p class="text-muted">Value Layer — Ğ1 balance and capacity signals.</p>';
|
||||
$out .= '</div>';
|
||||
|
||||
$out .= cry01_render_balance_display();
|
||||
$out .= cry01_render_signal_board($association_slug, $access);
|
||||
|
||||
if ($access === 'participant') {
|
||||
$signal_url = z_root() . '/cry01/' . cry01_h($association_slug) . '/signal';
|
||||
$out .= '<div class="mt-3">';
|
||||
$out .= '<a href="' . $signal_url . '" class="btn btn-outline-primary btn-sm">Register a Signal</a>';
|
||||
$out .= '</div>';
|
||||
}
|
||||
|
||||
if ($access === 'operator') {
|
||||
$manage_url = z_root() . '/cry01/' . cry01_h($association_slug) . '/manage';
|
||||
$g1_url = z_root() . '/cry01/' . cry01_h($association_slug) . '/g1';
|
||||
$out .= '<div class="mt-3 d-flex gap-2">';
|
||||
$out .= '<a href="' . $manage_url . '" class="btn btn-sm btn-outline-secondary">Manage</a>';
|
||||
$out .= '<a href="' . $g1_url . '" class="btn btn-sm btn-outline-secondary">Ğ1 Candidates</a>';
|
||||
$out .= '</div>';
|
||||
}
|
||||
|
||||
$out .= '</div>';
|
||||
return $out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BALANCE DISPLAY
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function cry01_render_balance_display() {
|
||||
// Renders the operator Ğ1 balance from cache. Shows staleness if cache is old.
|
||||
$cache = cry01_read_cache();
|
||||
$balance = $cache['operator_balance'] ?? null;
|
||||
$pubkey = $cache['operator_g1_pubkey'] ?? '';
|
||||
$refreshed = $cache['refreshed_at'] ?? null;
|
||||
|
||||
$out = '<div class="cry01-balance-display mb-4">';
|
||||
$out .= '<h5 class="cry01-section-label">Operator Ğ1 Balance</h5>';
|
||||
|
||||
if ($balance === null) {
|
||||
$out .= '<p class="text-muted fst-italic">Balance not yet loaded. Operator: use Manage to refresh.</p>';
|
||||
} else {
|
||||
$out .= '<p class="cry01-balance-amount">' . cry01_h($balance) . '</p>';
|
||||
if ($pubkey) {
|
||||
$out .= '<p class="text-muted small">' . cry01_h(substr($pubkey, 0, 16) . '...') . '</p>';
|
||||
}
|
||||
if ($refreshed) {
|
||||
$stale = cry01_cache_is_stale() ? ' <span class="badge bg-warning text-dark">stale</span>' : '';
|
||||
$out .= '<p class="text-muted small">Last refreshed: ' . cry01_h($refreshed) . $stale . '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
$out .= '</div>';
|
||||
return $out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SIGNAL BOARD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function cry01_render_signal_board($association_slug, $access) {
|
||||
// Renders the signal board. Visible to participants and operator only.
|
||||
if ($access === 'public') {
|
||||
return '<div class="alert alert-info">Signal board is visible to verified participants only.</div>';
|
||||
}
|
||||
|
||||
// TODO: load signals from orchestrator spool for this association.
|
||||
// Placeholder until orchestrator query is implemented.
|
||||
$out = '<div class="cry01-signal-board mb-4">';
|
||||
$out .= '<h5 class="cry01-section-label">Capacity Signals</h5>';
|
||||
$out .= '<p class="text-muted fst-italic">No signals posted yet.</p>';
|
||||
$out .= '</div>';
|
||||
return $out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SIGNAL FORM
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function cry01_render_signal_form($association_slug, $access) {
|
||||
// Renders the capacity signal registration form.
|
||||
$config = cry01_load_config();
|
||||
$categories = $config['signal_categories'] ?? [];
|
||||
$form_url = z_root() . '/cry01/' . cry01_h($association_slug) . '/signal';
|
||||
|
||||
$out = '<div class="cry01-content">';
|
||||
$out .= '<h3>Register a Capacity Signal</h3>';
|
||||
$out .= '<p class="text-muted">Describe what you are offering or seeking, denominated in Ğ1.</p>';
|
||||
|
||||
$out .= '<form method="post" action="' . $form_url . '" class="cry01-form" novalidate>';
|
||||
$out .= cry01_csrf_token();
|
||||
|
||||
// Ğ1 public key
|
||||
$out .= '<div class="mb-3">';
|
||||
$out .= '<label class="form-label" for="g1_pubkey">Your Ğ1 Public Key <span class="text-danger">*</span></label>';
|
||||
$out .= '<input type="text" class="form-control" id="g1_pubkey" name="g1_pubkey" required
|
||||
placeholder="G1..." maxlength="64">';
|
||||
$out .= '<div class="form-text">Your Ğ1 address. This is your payment address for this signal.</div>';
|
||||
$out .= '</div>';
|
||||
|
||||
// Direction
|
||||
$out .= '<div class="mb-3">';
|
||||
$out .= '<fieldset><legend class="form-label">Direction <span class="text-danger">*</span></legend>';
|
||||
$out .= '<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="radio" id="dir_offer" name="direction" value="offer" required>
|
||||
<label class="form-check-label" for="dir_offer">Offering capacity</label>
|
||||
</div>';
|
||||
$out .= '<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="radio" id="dir_seek" name="direction" value="seek">
|
||||
<label class="form-check-label" for="dir_seek">Seeking capacity</label>
|
||||
</div>';
|
||||
$out .= '</fieldset></div>';
|
||||
|
||||
// Category
|
||||
if ($categories) {
|
||||
$out .= '<div class="mb-3">';
|
||||
$out .= '<label class="form-label" for="category_id">Category <span class="text-danger">*</span></label>';
|
||||
$out .= '<select class="form-select" id="category_id" name="category_id" required>';
|
||||
$out .= '<option value="">— select —</option>';
|
||||
foreach ($categories as $cat) {
|
||||
$v = cry01_h($cat['id'] ?? '');
|
||||
$l = cry01_h($cat['label'] ?? $v);
|
||||
$out .= '<option value="' . $v . '">' . $l . '</option>';
|
||||
}
|
||||
$out .= '</select></div>';
|
||||
}
|
||||
|
||||
// Description
|
||||
$out .= '<div class="mb-3">';
|
||||
$out .= '<label class="form-label" for="capacity_description">Description <span class="text-danger">*</span></label>';
|
||||
$out .= '<textarea class="form-control" id="capacity_description" name="capacity_description"
|
||||
rows="4" required
|
||||
placeholder="Describe what you are offering or seeking in plain language."></textarea>';
|
||||
$out .= '</div>';
|
||||
|
||||
// Denomination
|
||||
$out .= '<div class="mb-3">';
|
||||
$out .= '<label class="form-label" for="g1_denomination">Ğ1 Amount <span class="text-danger">*</span></label>';
|
||||
$out .= '<input type="number" class="form-control" id="g1_denomination" name="g1_denomination"
|
||||
required min="1" placeholder="e.g. 100">';
|
||||
$out .= '<div class="form-text">The amount in Ğ1 you consider fair exchange.</div>';
|
||||
$out .= '</div>';
|
||||
|
||||
// Duration
|
||||
$out .= '<div class="mb-3">';
|
||||
$out .= '<label class="form-label" for="duration_days">Duration (days) <span class="text-danger">*</span></label>';
|
||||
$out .= '<input type="number" class="form-control" id="duration_days" name="duration_days"
|
||||
required min="1" max="365" placeholder="e.g. 14">';
|
||||
$out .= '</div>';
|
||||
|
||||
$out .= '<div class="mt-3">';
|
||||
$out .= '<button type="submit" class="btn btn-primary">Register Signal</button>';
|
||||
$out .= '</div>';
|
||||
$out .= '</form>';
|
||||
$out .= '</div>';
|
||||
return $out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ğ1 CERTIFICATION CANDIDATE LIST
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function cry01_render_g1_candidates($association_slug) {
|
||||
// Renders the operator-only list of SASE participants with registered Ğ1 keys.
|
||||
// TODO: load candidate list from orchestrator spool.
|
||||
$out = '<div class="cry01-content">';
|
||||
$out .= '<h3>Ğ1 Certification Candidates</h3>';
|
||||
$out .= '<p class="text-muted">SASE participants who have registered a Ğ1 public key and are eligible for web of trust certification.</p>';
|
||||
$out .= '<p class="text-muted fst-italic">No candidates registered yet.</p>';
|
||||
$out .= '</div>';
|
||||
return $out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MANAGE
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function cry01_render_manage($association_slug) {
|
||||
// Renders the operator manage page: cache refresh and config status.
|
||||
$cache = cry01_read_cache();
|
||||
$refreshed = $cache['refreshed_at'] ?? null;
|
||||
$config = cry01_load_config();
|
||||
$has_rpc = !empty($config['g1_rpc_endpoint']);
|
||||
$has_key = !empty($config['operator_g1_pubkey']);
|
||||
|
||||
$manage_url = z_root() . '/cry01/' . cry01_h($association_slug) . '/manage';
|
||||
|
||||
$out = '<div class="cry01-content">';
|
||||
$out .= '<h3>Value Layer — Manage</h3>';
|
||||
|
||||
// Config status
|
||||
$out .= '<div class="mb-3">';
|
||||
$out .= '<h5>Configuration</h5>';
|
||||
$out .= '<p>' . ($has_rpc ? '✓' : '✗') . ' Duniter RPC endpoint: ';
|
||||
$out .= $has_rpc ? '<span class="text-success">configured</span>' : '<span class="text-danger">missing</span>';
|
||||
$out .= '</p>';
|
||||
$out .= '<p>' . ($has_key ? '✓' : '✗') . ' Operator Ğ1 public key: ';
|
||||
$out .= $has_key ? '<span class="text-success">configured</span>' : '<span class="text-danger">missing</span>';
|
||||
$out .= '</p>';
|
||||
$out .= '</div>';
|
||||
|
||||
// Cache status
|
||||
$out .= '<div class="mb-3">';
|
||||
$out .= '<h5>Balance Cache</h5>';
|
||||
if ($refreshed) {
|
||||
$stale = cry01_cache_is_stale() ? ' (stale)' : ' (current)';
|
||||
$out .= '<p>Last refreshed: ' . cry01_h($refreshed) . $stale . '</p>';
|
||||
} else {
|
||||
$out .= '<p class="text-muted">Cache has not been populated yet.</p>';
|
||||
}
|
||||
$out .= '</div>';
|
||||
|
||||
// Refresh form
|
||||
$out .= '<form method="post" action="' . $manage_url . '">';
|
||||
$out .= cry01_csrf_token();
|
||||
$out .= '<input type="hidden" name="action" value="refresh_cache">';
|
||||
$out .= '<button type="submit" class="btn btn-sm btn-outline-primary">Refresh Balance Cache</button>';
|
||||
$out .= '</form>';
|
||||
|
||||
$out .= '</div>';
|
||||
return $out;
|
||||
}
|
||||
145
hubzilla/addon/cry01/cry01_spool.php
Normal file
145
hubzilla/addon/cry01/cry01_spool.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* cry01_spool.php — POST handlers.
|
||||
* Validates input, builds spool envelope, POSTs to orchestrator.
|
||||
* Does not render HTML — returns rendered output from cry01_renderer.php.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SIGNAL POST HANDLER
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function cry01_handle_signal_post($association_slug, $access) {
|
||||
// Validates and submits a capacity signal to the orchestrator spool receiver.
|
||||
$g1_pubkey = trim($_POST['g1_pubkey'] ?? '');
|
||||
$direction = trim($_POST['direction'] ?? '');
|
||||
$category_id = trim($_POST['category_id'] ?? '');
|
||||
$capacity_description = trim($_POST['capacity_description'] ?? '');
|
||||
$g1_denomination = trim($_POST['g1_denomination'] ?? '');
|
||||
$duration_days = intval($_POST['duration_days'] ?? 0);
|
||||
|
||||
// Validate required fields.
|
||||
$errors = [];
|
||||
if (!$g1_pubkey) $errors[] = 'Ğ1 public key is required.';
|
||||
if (!in_array($direction, ['offer', 'seek'])) $errors[] = 'Direction must be offer or seek.';
|
||||
if (!$category_id) $errors[] = 'Category is required.';
|
||||
if (!$capacity_description) $errors[] = 'Description is required.';
|
||||
if (!$g1_denomination || !is_numeric($g1_denomination) || floatval($g1_denomination) <= 0) {
|
||||
$errors[] = 'Ğ1 amount must be a positive number.';
|
||||
}
|
||||
if ($duration_days < 1 || $duration_days > 365) {
|
||||
$errors[] = 'Duration must be between 1 and 365 days.';
|
||||
}
|
||||
|
||||
if ($errors) {
|
||||
return '<div class="cry01-content"><div class="alert alert-danger"><ul class="mb-0">'
|
||||
. implode('', array_map(fn($e) => '<li>' . cry01_h($e) . '</li>', $errors))
|
||||
. '</ul></div></div>'
|
||||
. cry01_render_signal_form($association_slug, $access);
|
||||
}
|
||||
|
||||
$config = cry01_load_config();
|
||||
$token = $config['node_token'] ?? '';
|
||||
|
||||
// Build the spool envelope per contracts/signal-v1.json.
|
||||
$envelope = [
|
||||
'_header' => [
|
||||
'addon' => 'cry01',
|
||||
'association_slug' => $association_slug,
|
||||
'participant_xchan' => get_observer_hash(),
|
||||
'submitted_at' => date('c'),
|
||||
'node_token_hash' => hash('sha256', $token),
|
||||
],
|
||||
'_payload' => [
|
||||
'g1_pubkey' => $g1_pubkey,
|
||||
'capacity_description' => $capacity_description,
|
||||
'g1_denomination' => $g1_denomination,
|
||||
'duration_days' => $duration_days,
|
||||
'category_id' => $category_id,
|
||||
'direction' => $direction,
|
||||
],
|
||||
];
|
||||
|
||||
$result = cry01_post_to_orchestrator('/cry01/signal', $envelope);
|
||||
|
||||
if ($result === true) {
|
||||
return '<div class="cry01-content">'
|
||||
. '<div class="alert alert-success">Signal registered successfully. '
|
||||
. 'A Verifiable Credential will be issued and timestamped to Bitcoin.</div>'
|
||||
. '<p><a href="' . z_root() . '/cry01/' . cry01_h($association_slug) . '">← Back to Value Layer</a></p>'
|
||||
. '</div>';
|
||||
}
|
||||
|
||||
return '<div class="cry01-content">'
|
||||
. '<div class="alert alert-danger">Signal could not be registered. '
|
||||
. 'The orchestrator may be temporarily unavailable. Please try again.</div>'
|
||||
. '</div>'
|
||||
. cry01_render_signal_form($association_slug, $access);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MANAGE POST HANDLER
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function cry01_handle_manage_post($association_slug) {
|
||||
// Handles operator manage actions: currently only cache refresh.
|
||||
if (!cry01_verify_csrf()) {
|
||||
return cry01_render_error('Invalid form token. Please reload and try again.');
|
||||
}
|
||||
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'refresh_cache') {
|
||||
$ok = cry01_refresh_balance_cache();
|
||||
$msg = $ok
|
||||
? '<div class="alert alert-success">Balance cache refreshed successfully.</div>'
|
||||
: '<div class="alert alert-warning">Cache refresh failed. Check that the Duniter node is reachable and the operator Ğ1 key is configured.</div>';
|
||||
return '<div class="cry01-content">' . $msg . '</div>' . cry01_render_manage($association_slug);
|
||||
}
|
||||
|
||||
return cry01_render_error('Unknown action.');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ORCHESTRATOR TRANSPORT
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function cry01_post_to_orchestrator($path, $payload) {
|
||||
// POSTs a JSON envelope to the orchestrator spool receiver.
|
||||
// Returns true on success (HTTP 201), false on any failure.
|
||||
$config = cry01_load_config();
|
||||
$base = rtrim($config['receiver_url'] ?? '', '/');
|
||||
if (!$base) {
|
||||
logger('cry01_spool: receiver_url not configured');
|
||||
return false;
|
||||
}
|
||||
|
||||
$url = $base . $path;
|
||||
$body = json_encode($payload);
|
||||
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => "Content-Type: application/json\r\nContent-Length: " . strlen($body) . "\r\n",
|
||||
'content' => $body,
|
||||
'timeout' => 10,
|
||||
'ignore_errors' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$raw = @file_get_contents($url, false, $context);
|
||||
$headers = $http_response_header ?? [];
|
||||
$status = 0;
|
||||
|
||||
foreach ($headers as $h) {
|
||||
if (preg_match('/HTTP\/\d\.\d\s+(\d+)/', $h, $m)) {
|
||||
$status = intval($m[1]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($status === 201) return true;
|
||||
|
||||
logger('cry01_spool: orchestrator returned status ' . $status . ' for ' . $path);
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user