Added distributed MQTT recovery backend and device governance

This commit is contained in:
XupaMisto 2026-05-17 20:48:11 +01:00
parent 269ea239f2
commit a028fc6497
12 changed files with 1369 additions and 199 deletions

View File

@ -3,7 +3,8 @@ declare(strict_types=1);
require __DIR__ . "/db.php"; require __DIR__ . "/db.php";
$rows = $pdo->query(" $rows = $pdo->query("
SELECT uid, entradas, saidas, online, blocked, SELECT uid, note, entradas, saidas, online, blocked,
current_broker, rssi, heap, uptime,
TIMESTAMPDIFF(SECOND, last_seen, NOW()) AS ago TIMESTAMPDIFF(SECOND, last_seen, NOW()) AS ago
FROM contadores FROM contadores
ORDER BY online DESC, uid ASC ORDER BY online DESC, uid ASC

View File

@ -9,17 +9,27 @@ $LOG_LEVEL = LOG_DEBUG;
//$LOG_LEVEL = LOG_INFO; //$LOG_LEVEL = LOG_INFO;
error_reporting(E_ALL & ~E_DEPRECATED & ~E_NOTICE); error_reporting(E_ALL & ~E_DEPRECATED & ~E_NOTICE);
ini_set('display_errors', '0'); ini_set('display_errors', '0');
ini_set('log_errors', '1'); ini_set('log_errors', '1');
ini_set('error_log', '/var/log/mqtt-listener/recovery-error.log'); ini_set('error_log', '/var/log/mqtt-listener/recovery-error.log');
set_time_limit(0); set_time_limit(0);
ignore_user_abort(true); ignore_user_abort(true);
function logmsg(string $msg, int $level = LOG_INFO): void { function logmsg(string $msg, int $level = LOG_INFO): void
{
global $LOG_LEVEL; global $LOG_LEVEL;
if ($level > $LOG_LEVEL) return;
if ($level > $LOG_LEVEL) {
return;
}
echo '[' . date('Y-m-d H:i:s') . "] $msg\n"; echo '[' . date('Y-m-d H:i:s') . "] $msg\n";
if (function_exists('flush')) @flush();
if (function_exists('flush')) {
@flush();
}
} }
require __DIR__ . '/../vendor/autoload.php'; require __DIR__ . '/../vendor/autoload.php';
@ -27,28 +37,53 @@ require __DIR__ . '/../vendor/autoload.php';
use PhpMqtt\Client\MqttClient; use PhpMqtt\Client\MqttClient;
use PhpMqtt\Client\ConnectionSettings; use PhpMqtt\Client\ConnectionSettings;
// =====================================================
// DB // DB
// =====================================================
$db = new PDO( $db = new PDO(
"mysql:host=localhost;dbname=cagalhao;charset=utf8", "mysql:host=localhost;dbname=cagalhao;charset=utf8",
"master", "master",
"master", "master",
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
]
); );
// RECOVERY BROKER // =====================================================
// RECOVERY MQTT
// =====================================================
$recoveryHost = 'mqtt-recovery.xupas.mywire.org'; $recoveryHost = 'mqtt-recovery.xupas.mywire.org';
$recoveryPort = 8884; $recoveryPort = 8884;
$recoveryUser = 'recovery'; $recoveryUser = 'recovery';
$recoveryPass = 'recovery123'; $recoveryPass = 'recovery123';
// MAIN BROKER TEST // =====================================================
// MAIN MQTT
// =====================================================
$mainHost = 'mqtt.xupas.mywire.org'; $mainHost = 'mqtt.xupas.mywire.org';
$mainPort = 8883; $mainPort = 8883;
// Cert principal a enviar aos ESPs // =====================================================
$certPath = "/etc/mosquitto/certs/server.crt"; // CERT
// =====================================================
$certPath = '/etc/mosquitto/certs/chain.pem'; // cert Let's Encrypt do broker principal
$certUrl = 'https://mqtt.xupas.mywire.org/cert.pem';
// =====================================================
// GLOBAL SECRET
// =====================================================
$authSecret = 'K82A91F0';
// =====================================================
// MQTT SETTINGS
// =====================================================
// MQTT recovery settings
$settings = (new ConnectionSettings()) $settings = (new ConnectionSettings())
->setUsername($recoveryUser) ->setUsername($recoveryUser)
->setPassword($recoveryPass) ->setPassword($recoveryPass)
@ -59,22 +94,38 @@ $settings = (new ConnectionSettings())
->setSocketTimeout(1) ->setSocketTimeout(1)
->setReconnectAutomatically(true); ->setReconnectAutomatically(true);
// =====================================================
// MQTT CLIENT
// =====================================================
$mqtt = new MqttClient( $mqtt = new MqttClient(
$recoveryHost, $recoveryHost,
$recoveryPort, $recoveryPort,
'listener-recovery-' . getmypid() 'listener-recovery-' . getmypid()
); );
function main_broker_online(string $host, int $port): bool { // =====================================================
// MAIN BROKER CHECK
// =====================================================
function main_broker_online(string $host, int $port): bool
{
$fp = @fsockopen($host, $port, $errno, $errstr, 2); $fp = @fsockopen($host, $port, $errno, $errstr, 2);
if ($fp) { if ($fp) {
fclose($fp); fclose($fp);
return true; return true;
} }
return false; return false;
} }
function send_switch_primary(MqttClient $mqtt, PDO $db): void { // =====================================================
// SEND SWITCH PRIMARY
// =====================================================
function send_switch_primary(MqttClient $mqtt, PDO $db): void
{
$stmt = $db->query(" $stmt = $db->query("
SELECT uid SELECT uid
FROM contadores FROM contadores
@ -82,11 +133,14 @@ function send_switch_primary(MqttClient $mqtt, PDO $db): void {
"); ");
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$uid = $row['uid']; $uid = $row['uid'];
$mqtt->publish( $mqtt->publish(
"esp/$uid/cmd", "esp/$uid/cmd",
json_encode(['cmd' => 'switch_primary']), json_encode([
'cmd' => 'SWITCH_PRIMARY'
]),
1 1
); );
@ -94,7 +148,17 @@ function send_switch_primary(MqttClient $mqtt, PDO $db): void {
} }
} }
function send_cert_update(MqttClient $mqtt, string $uid, string $certPath): void { // =====================================================
// SEND CERT
// =====================================================
function send_cert_chunks(
MqttClient $mqtt,
string $uid,
string $certPath,
int $linesPerChunk = 16
): void {
if (!file_exists($certPath)) { if (!file_exists($certPath)) {
logmsg("Cert não encontrado: $certPath", LOG_ERROR); logmsg("Cert não encontrado: $certPath", LOG_ERROR);
return; return;
@ -102,47 +166,317 @@ function send_cert_update(MqttClient $mqtt, string $uid, string $certPath): void
$cert = file_get_contents($certPath); $cert = file_get_contents($certPath);
// validar com openssl antes de enviar
$parsed = openssl_x509_read($cert);
if ($parsed === false) {
logmsg("Cert inválido (openssl): $certPath", LOG_ERROR);
return;
}
$lines = explode("\n", rtrim($cert));
$chunks = array_chunk($lines, $linesPerChunk);
$total = count($chunks);
logmsg("CERT_BEGIN $uid total=$total chunks");
$mqtt->publish( $mqtt->publish(
"esp/$uid/cmd", "esp/$uid/cmd",
json_encode([ json_encode(['cmd' => 'CERT_BEGIN', 'total' => $total]),
'cmd' => 'UPDATE_CERT',
'pem' => $cert
]),
1 1
); );
logmsg("UPDATE_CERT enviado para $uid"); foreach ($chunks as $seq => $chunk) {
$data = implode("\n", $chunk) . "\n";
$mqtt->publish(
"esp/$uid/cmd",
json_encode(['cmd' => 'CERT_CHUNK', 'seq' => $seq, 'data' => $data]),
1
);
logmsg("CERT_CHUNK $uid seq=$seq");
usleep(100000); // 100ms entre chunks
}
$mqtt->publish(
"esp/$uid/cmd",
json_encode(['cmd' => 'CERT_COMMIT']),
1
);
logmsg("CERT_COMMIT enviado para $uid");
} }
// =====================================================
// SUBSCRIPTIONS
// =====================================================
$subscribe = function () use ( $subscribe = function () use (
$mqtt, $mqtt,
$db, $db,
$certPath, $certPath,
$certUrl,
$mainHost, $mainHost,
$mainPort $mainPort,
$authSecret
) { ) {
$mqtt->subscribe( // =================================================
'esp/+/status', // AUTH
function (string $topic, string $msg) // =================================================
use ($db, $mqtt, $mainHost, $mainPort) {
$uid = explode('/', $topic)[1] ?? null; $mqtt->subscribe(
if (!$uid) return; 'esp/+/auth',
function (string $topic, string $msg)
use ($db, $mqtt, $authSecret)
{
$uid = explode('/', $topic)[1] ?? null;
if (!$uid) {
return;
}
logmsg("AUTH RX $uid => $msg");
$json = json_decode($msg, true);
if (!$json) {
logmsg(
"JSON AUTH inválido",
LOG_ERROR
);
return;
}
// =============================================
// HELLO
// =============================================
if (($json['cmd'] ?? '') === 'HELLO') {
$nonce =
strtoupper(
bin2hex(random_bytes(16))
);
$stmt = $db->prepare(" $stmt = $db->prepare("
INSERT INTO contadores INSERT INTO contadores
(uid, entradas, saidas, entradas_total, saidas_total, last_seen, online) (uid, auth_nonce, last_seen, online, blocked)
VALUES VALUES
(:uid,0,0,0,0,NOW(),1) (:uid, :nonce, NOW(), 1, 1)
ON DUPLICATE KEY UPDATE
auth_nonce=:nonce,
last_seen=NOW(),
online=1
");
/* novos ESPs entram com blocked=1 (pendente aprovação)
ESPs existentes mantêm o blocked actual */
$stmt->execute([
'uid' => $uid,
'nonce' => $nonce
]);
$mqtt->publish(
"esp/$uid/cmd",
json_encode([
'cmd' => 'AUTH_CHALLENGE',
'nonce' => $nonce
]),
1
);
logmsg(
"AUTH_CHALLENGE enviado para $uid"
);
return;
}
// =============================================
// AUTH RESPONSE
// =============================================
if (($json['cmd'] ?? '') === 'AUTH_RESPONSE') {
$hash =
strtoupper(
$json['hash'] ?? ''
);
if (!$hash) {
logmsg(
"AUTH sem hash",
LOG_ERROR
);
return;
}
$stmt = $db->prepare("
SELECT auth_nonce, blocked
FROM contadores
WHERE uid=:uid
LIMIT 1
");
$stmt->execute([
'uid' => $uid
]);
$row =
$stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
logmsg(
"UID desconhecido: $uid",
LOG_ERROR
);
return;
}
$nonce =
$row['auth_nonce'] ?? '';
if (!$nonce) {
logmsg(
"Nonce vazio para $uid",
LOG_ERROR
);
return;
}
$expected =
strtoupper(
hash_hmac(
'sha256',
$nonce,
$authSecret
)
);
if (hash_equals($expected, $hash)) {
// verificar se está aprovado
if ((int)($row['blocked'] ?? 1) === 1) {
logmsg("AUTH PENDENTE/BLOQUEADO $uid — aguarda aprovação", LOG_ERROR);
$mqtt->publish(
"esp/$uid/cmd",
json_encode(['cmd' => 'AUTH_FAIL']),
1
);
return;
}
logmsg("AUTH OK $uid");
$stmt = $db->prepare("
UPDATE contadores
SET
auth_nonce=NULL,
auth_ok=1,
online=1,
last_seen=NOW()
WHERE uid=:uid
");
$stmt->execute([
'uid' => $uid
]);
$mqtt->publish(
"esp/$uid/cmd",
json_encode([
'cmd' => 'AUTH_OK'
]),
1
);
} else {
logmsg(
"AUTH FAIL $uid",
LOG_ERROR
);
$mqtt->publish(
"esp/$uid/cmd",
json_encode([
'cmd' => 'AUTH_FAIL'
]),
1
);
}
return;
}
}, 1);
// =================================================
// STATUS
// =================================================
$mqtt->subscribe(
'esp/+/status',
function (
string $topic,
string $msg
) use (
$db,
$mqtt,
$mainHost,
$mainPort
) {
$uid = explode('/', $topic)[1] ?? null;
if (!$uid) {
return;
}
$stmt = $db->prepare("
INSERT INTO contadores
(
uid,
entradas,
saidas,
entradas_total,
saidas_total,
last_seen,
online
)
VALUES
(
:uid,
0,
0,
0,
0,
NOW(),
1
)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
last_seen=NOW(), last_seen=NOW(),
online=1 online=1
"); ");
$stmt->execute(['uid' => $uid]); $stmt->execute([
'uid' => $uid
]);
logmsg("RECOVERY STATUS $uid => $msg"); logmsg(
"RECOVERY STATUS $uid => $msg"
);
static $lastCheck = 0; static $lastCheck = 0;
@ -152,15 +486,27 @@ $subscribe = function () use (
logmsg("CHECK broker"); logmsg("CHECK broker");
if (main_broker_online($mainHost, $mainPort)) { if (
main_broker_online(
$mainHost,
$mainPort
)
) {
logmsg("MAIN broker online"); logmsg(
"MAIN broker online"
);
send_switch_primary($mqtt, $db); send_switch_primary(
$mqtt,
$db
);
} else { } else {
logmsg("MAIN broker offline"); logmsg(
"MAIN broker offline"
);
} }
} }
@ -168,61 +514,140 @@ $subscribe = function () use (
0 0
); );
// =================================================
// EVENT
// =================================================
$mqtt->subscribe('esp/+/event', function (string $topic, string $msg) use ($db) { $mqtt->subscribe(
$uid = explode('/', $topic)[1] ?? null; 'esp/+/event',
if (!$uid) return; function (
string $topic,
string $msg
) {
logmsg("RECOVERY EVENT $uid => $msg"); $uid =
}, 0); explode('/', $topic)[1] ?? null;
$mqtt->subscribe('esp/+/cert_request', function (string $topic, string $msg) use ($mqtt, $certPath) { if (!$uid) {
$uid = explode('/', $topic)[1] ?? null; return;
if (!$uid) return; }
logmsg("CERT_REQUEST de $uid"); logmsg(
send_cert_update($mqtt, $uid, $certPath); "RECOVERY EVENT $uid => $msg"
}, 0); );
},
0
);
// =================================================
// CERT REQUEST
// =================================================
$mqtt->subscribe(
'esp/+/cert_request',
function (
string $topic,
string $msg
) use (
$mqtt,
$certPath,
$certUrl
) {
$uid =
explode('/', $topic)[1] ?? null;
if (!$uid) {
return;
}
logmsg("CERT_REQUEST de $uid → chunks");
send_cert_chunks($mqtt, $uid, $certPath);
},
1
);
}; };
// =====================================================
// MAIN LOOP
// =====================================================
while (true) { while (true) {
try { try {
logmsg("Ligar recovery $recoveryHost:$recoveryPort");
logmsg(
"Ligar recovery $recoveryHost:$recoveryPort"
);
$mqtt->connect($settings, false); $mqtt->connect($settings, false);
$subscribe(); $subscribe();
logmsg("RECOVERY ligado e subscrito"); logmsg(
"RECOVERY ligado e subscrito"
);
$nextMainCheck = time() + 10; $nextMainCheck = time() + 10;
while ($mqtt->isConnected()) { while ($mqtt->isConnected()) {
$mqtt->loop(false); $mqtt->loop(false);
logmsg("tick");
if (time() >= $nextMainCheck) { if (time() >= $nextMainCheck) {
$nextMainCheck = time() + 15; $nextMainCheck = time() + 15;
logmsg("LOOP recovery", LOG_DEBUG); logmsg(
"LOOP recovery",
if (main_broker_online($mainHost, $mainPort)) { LOG_DEBUG
);
logmsg("MAIN broker online");
if (
logmsg("MAIN broker online. Mandar switch_primary."); main_broker_online(
send_switch_primary($mqtt, $db); $mainHost,
$mainPort
)
) {
logmsg(
"MAIN broker online"
);
logmsg(
"Mandar switch_primary"
);
send_switch_primary(
$mqtt,
$db
);
} else { } else {
logmsg("MAIN broker ainda offline", LOG_DEBUG);
logmsg(
"MAIN broker ainda offline",
LOG_DEBUG
);
} }
} }
usleep(100000); usleep(100000);
} }
$mqtt->disconnect(); $mqtt->disconnect();
} catch (Throwable $e) { } catch (Throwable $e) {
logmsg("ERRO recovery: " . $e->getMessage(), LOG_ERROR);
logmsg(
"ERRO recovery: " .
$e->getMessage(),
LOG_ERROR
);
sleep(5); sleep(5);
} }
} }

View File

@ -92,6 +92,9 @@ if (isset($_GET['vpn'])) {
<div class="col-12"> <div class="col-12">
<a href="../console.php" class="btn btn-outline-light w-100">📊 Estado dos Dispositivos</a> <a href="../console.php" class="btn btn-outline-light w-100">📊 Estado dos Dispositivos</a>
</div> </div>
<div class="col-12">
<a href="../devices.php" class="btn btn-outline-warning w-100">🔌 Gerir Dispositivos</a>
</div>
<div class="col-12"> <div class="col-12">
<a href="../comandos.php" class="btn btn-outline-light w-100">🛠️ Enviar Comando</a> <a href="../comandos.php" class="btn btn-outline-light w-100">🛠️ Enviar Comando</a>
</div> </div>

View File

@ -63,6 +63,8 @@ $mqttHost = 'mqtt.xupas.mywire.org';
$mqttUser = 'xupa'; $mqttUser = 'xupa';
$mqttPass = 'xupa'; $mqttPass = 'xupa';
$deviceSecret = 'K82A91F0';
$bootstrapSecret = "XUPA_TEMP_SECRET_2026"; $bootstrapSecret = "XUPA_TEMP_SECRET_2026";
$certPath = "/etc/mosquitto/certs/server.crt"; $certPath = "/etc/mosquitto/certs/server.crt";
@ -98,24 +100,70 @@ $mqttBootstrap = new MqttClient($mqttHost, 1883, 'listener-bootstrap');
/* ========================= /* =========================
* SUBSCRIPTIONS TLS * SUBSCRIPTIONS TLS
* ========================= */ * ========================= */
$subscribeTls = function () use ($mqttTls, $db) { $subscribeTls = function () use ($mqttTls, $db, $deviceSecret) {
// ========================= // =========================
// STATUS ESP // STATUS ESP
// ========================= // =========================
$mqttTls->subscribe('esp/+/status', function (string $topic, string $msg) use ($db) { $mqttTls->subscribe('esp/+/status', function (string $topic, string $msg)
use ($db, $mqttTls, $deviceSecret)
{
$uid = explode('/', $topic)[1] ?? null; $uid = explode('/', $topic)[1] ?? null;
if (!$uid) return; if (!$uid) return;
$stmt = $db->prepare(" $json = json_decode($msg, true);
INSERT INTO contadores (uid, entradas, saidas, entradas_total, saidas_total, last_seen, online) $uptime = isset($json['uptime']) ? (int)$json['uptime'] : null;
VALUES (:uid,0,0,0,0,NOW(),1) $heap = isset($json['heap']) ? (int)$json['heap'] : null;
ON DUPLICATE KEY UPDATE last_seen=NOW(), online=1 $rssi = isset($json['rssi']) ? (int)$json['rssi'] : null;
"); $broker = isset($json['broker']) ? (string)$json['broker'] : null;
$stmt->execute(['uid' => $uid]); $hmac = isset($json['hmac']) ? strtoupper((string)$json['hmac']) : null;
logmsg("STATUS $uid"); // Validar HMAC — apenas aceitar heartbeats assinados
if ($hmac === null || $uptime === null) {
logmsg("STATUS sem HMAC/uptime $uid — ignorado", LOG_ERROR);
return;
}
$expected = strtoupper(hash_hmac('sha256', $uid . '|' . $uptime, $deviceSecret));
if (!hash_equals($expected, $hmac)) {
logmsg("STATUS HMAC INVÁLIDO $uid — ignorado", LOG_ERROR);
return;
}
// HMAC OK → actualizar DB
$stmt = $db->prepare("
INSERT INTO contadores
(uid, entradas, saidas, entradas_total, saidas_total, last_seen, online,
uptime, heap, rssi, current_broker)
VALUES
(:uid, 0, 0, 0, 0, NOW(), 1,
:uptime, :heap, :rssi, :broker)
ON DUPLICATE KEY UPDATE
last_seen=NOW(),
online=1,
uptime=COALESCE(:uptime2, uptime),
heap=COALESCE(:heap2, heap),
rssi=COALESCE(:rssi2, rssi),
current_broker=COALESCE(:broker2, current_broker)
");
$stmt->execute([
'uid' => $uid,
'uptime' => $uptime,
'heap' => $heap,
'rssi' => $rssi,
'broker' => $broker,
'uptime2' => $uptime,
'heap2' => $heap,
'rssi2' => $rssi,
'broker2' => $broker,
]);
// Responder com HEARTBEAT_OK → ESP arranca serviços (sem retain)
$mqttTls->publish("esp/$uid/cmd",
json_encode(['cmd' => 'HEARTBEAT_OK']), 1, false);
logmsg("STATUS OK $uid uptime=$uptime heap=$heap rssi=$rssi broker=$broker → HEARTBEAT_OK");
}, 0); }, 0);
@ -217,11 +265,28 @@ while (true) {
$subscribeBootstrap(); $subscribeBootstrap();
logmsg("Bootstrap ligado"); logmsg("Bootstrap ligado");
$nextOfflineCheck = time() + 15;
while ($mqttTls->isConnected() && $mqttBootstrap->isConnected()) { while ($mqttTls->isConnected() && $mqttBootstrap->isConnected()) {
$mqttTls->loop(true, false); $mqttTls->loop(true, false);
$mqttBootstrap->loop(true, false); $mqttBootstrap->loop(true, false);
if (time() >= $nextOfflineCheck) {
$nextOfflineCheck = time() + 15;
$affected = $db->exec("
UPDATE contadores
SET online=0
WHERE online=1
AND TIMESTAMPDIFF(SECOND, last_seen, NOW()) > 35
");
if ($affected > 0) {
logmsg("OFFLINE sweep: $affected dispositivo(s) marcado(s) offline");
}
}
usleep(100000); usleep(100000);
} }

View File

@ -3,6 +3,6 @@ if (session_status() === PHP_SESSION_NONE) {
session_start(); session_start();
} }
if (!isset($_SESSION['user_id'])) { if (!isset($_SESSION['user_id'])) {
header("Location: login.php"); header("Location: /api/login.php");
exit; exit;
} }

View File

@ -27,7 +27,7 @@ mqtt_publish($topic, json_encode($msg, JSON_UNESCAPED_SLASHES));
// ----------------------- BD ------------------------- // ----------------------- BD -------------------------
if ($cmd === 'BLOCK') { if ($cmd === 'BLOCK') {
$stmt = $db->prepare("UPDATE contadores SET blocked = ? WHERE uid = ?"); $stmt = $pdo->prepare("UPDATE contadores SET blocked = ? WHERE uid = ?");
$stmt->execute([intval($value), $uid]); $stmt->execute([intval($value), $uid]);
} }

View File

@ -33,7 +33,7 @@ try {
$_SESSION['username'] = $user['username']; $_SESSION['username'] = $user['username'];
// Redireciona para o menu principal // Redireciona para o menu principal
header("Location: menu.php"); header("Location: /api/menu.php");
exit; exit;
} catch (Exception $e) { } catch (Exception $e) {

View File

@ -22,7 +22,7 @@ try {
} }
// Buscar lista de devices // Buscar lista de devices
$devices = $pdo->query("SELECT uid FROM devices ORDER BY uid ASC")->fetchAll(); $devices = $pdo->query("SELECT uid FROM contadores ORDER BY uid ASC")->fetchAll();
} catch (Exception $e) { } catch (Exception $e) {
die("Erro: " . $e->getMessage()); die("Erro: " . $e->getMessage());

View File

@ -1,7 +1,7 @@
<?php <?php
return [ return [
'host' => '192.168.10.200', // depois trocas para 'estica.bot.nu' 'host' => 'mqtt.xupas.mywire.org',
'port' => 1883, // 8883 se fores usar TLS mais tarde 'port' => 8883,
'clientId' => 'php-web-' . bin2hex(random_bytes(3)), 'clientId' => 'php-web-' . bin2hex(random_bytes(3)),
'username' => 'xupa', 'username' => 'xupa',
'password' => 'xupa', 'password' => 'xupa',

View File

@ -1,199 +1,482 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . "/api/protecao.php";
require __DIR__ . "/api/db.php"; require __DIR__ . "/api/db.php";
// ---------- SORT ---------- // =====================================================
$sort = $_GET['sort'] ?? 'state'; // GUARDAR NOTA
$orderSql = ($sort === 'uid') // =====================================================
? "ORDER BY uid ASC"
: "ORDER BY online DESC, uid ASC";
// ---------- OFFLINE AUTOMÁTICO (20s) ---------- if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_note'])) {
$pdo->query("
UPDATE contadores $uid = $_POST['uid'] ?? '';
SET online = 0 $note = trim($_POST['note'] ?? '');
WHERE TIMESTAMPDIFF(SECOND, last_seen, NOW()) > 20
"); $stmt = $pdo->prepare("
UPDATE contadores
SET note = ?
WHERE uid = ?
");
$stmt->execute([$note, $uid]);
// ---------- BLOQUEIO ----------
if (isset($_GET['toggle'])) {
$uid = $_GET['toggle'];
$pdo->prepare("UPDATE contadores SET blocked = 1 - blocked WHERE uid = ?")
->execute([$uid]);
header("Location: console.php"); header("Location: console.php");
exit; exit;
} }
// ---------- PRIMEIRA QUERY (para totals) ---------- // =====================================================
// REMOVER
// =====================================================
if (isset($_GET['remove'])) {
$uid = $_GET['remove'];
$pdo->prepare("
DELETE FROM contadores
WHERE uid = ?
")->execute([$uid]);
header("Location: console.php");
exit;
}
// =====================================================
// BLOQUEAR / DESBLOQUEAR
// =====================================================
if (isset($_GET['toggle'])) {
$uid = $_GET['toggle'];
$pdo->prepare("
UPDATE contadores
SET blocked = 1 - blocked
WHERE uid = ?
")->execute([$uid]);
header("Location: console.php");
exit;
}
// =====================================================
// SORT
// =====================================================
$sort = $_GET['sort'] ?? 'state';
$orderSql = ($sort === 'uid')
? "ORDER BY uid ASC"
: "ORDER BY online DESC, uid ASC";
// =====================================================
// QUERY
// =====================================================
$sql = " $sql = "
SELECT uid, entradas, saidas, online, blocked, SELECT
last_seen, uid,
TIMESTAMPDIFF(SECOND, last_seen, NOW()) AS ago note,
entradas,
saidas,
online,
blocked,
current_broker,
rssi,
heap,
uptime,
last_seen,
TIMESTAMPDIFF(SECOND, last_seen, NOW()) AS ago
FROM contadores FROM contadores
$orderSql $orderSql
"; ";
$stmt = $pdo->query($sql); $stmt = $pdo->query($sql);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Totais // =====================================================
// TOTAIS
// =====================================================
$sumEntradas = 0; $sumEntradas = 0;
$sumSaidas = 0; $sumSaidas = 0;
$onlineCount = 0;
$onlineCount = 0;
$offlineCount = 0; $offlineCount = 0;
foreach ($rows as $r) { foreach ($rows as $r) {
$sumEntradas += (int)$r['entradas']; $sumEntradas += (int)$r['entradas'];
$sumSaidas += (int)$r['saidas']; $sumSaidas += (int)$r['saidas'];
if ($r['online'] == 1) $onlineCount++;
else $offlineCount++; if ((int)$r['online'] === 1) {
$onlineCount++;
} else {
$offlineCount++;
}
} }
?> ?>
<!doctype html> <!doctype html>
<html lang="pt" data-bs-theme="dark"> <html lang="pt" data-bs-theme="dark">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>Painel de Contadores</title>
<title>MQTT Swarm Console</title>
<link rel="stylesheet" <link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"> href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<style> <style>
body { body {
background-color: #0f0f0f; background: #0b0b0b;
color: #e0e0e0; color: #e0e0e0;
font-family: Arial, sans-serif;
} }
.card { .card {
background-color: #1a1a1a; background: #151515;
border: 1px solid #333; border: 1px solid #333;
} }
.table {
color: #ddd;
}
.table-striped > tbody > tr:nth-of-type(odd) { .table-striped > tbody > tr:nth-of-type(odd) {
background-color: #1c1c1c; background-color: #171717;
} }
.table-striped > tbody > tr:nth-of-type(even) { .table-striped > tbody > tr:nth-of-type(even) {
background-color: #151515; background-color: #101010;
} }
.table thead {
background-color: #222; .table-hover tbody tr:hover {
background-color: #202020;
} }
h1 {
color: #f0f0f0; .note-input {
min-width: 220px;
background: #111;
border: 1px solid #333;
color: #fff;
} }
a {
color: #58a6ff; .note-input:focus {
background: #181818;
color: #fff;
border-color: #58a6ff;
box-shadow: none;
} }
a:hover {
color: #8cc5ff; .pulse-cell {
animation: pulse 1s infinite;
} }
.btn-danger {
background-color: #a32424; @keyframes pulse {
border-color: #6b0000;
0% {
background-color: rgba(0,255,0,0.03);
}
50% {
background-color: rgba(0,255,0,0.16);
}
100% {
background-color: rgba(0,255,0,0.03);
}
} }
.btn-success {
background-color: #1f6f33; .header-title {
border-color: #0f4d1f; color: #00ffcc;
text-shadow: 0 0 8px rgba(0,255,180,0.4);
} }
.small-info {
font-size: 12px;
color: #999;
}
</style> </style>
</head> </head>
<body> <body>
<div class="container py-4"> <div class="container-fluid py-4">
<h1 class="mb-4">Painel de Contadores</h1> <h1 class="mb-4 header-title">
🌋 MQTT SWARM CONSOLE
</h1>
<!-- RESUMO --> <!-- RESUMO -->
<div class="row mb-4 text-center"> <div class="row mb-4 text-center">
<div class="col-md-3"> <div class="col-md-3">
<div class="p-3 card rounded"> <div class="p-3 card rounded">
<h4><?= $onlineCount ?></h4> <h4 id="total_online"><?= $onlineCount ?></h4>
<div class="text-success fw-bold">Online</div> <div class="text-success fw-bold">
🟢 ONLINE
</div>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<div class="p-3 card rounded"> <div class="p-3 card rounded">
<h4><?= $offlineCount ?></h4> <h4 id="total_offline"><?= $offlineCount ?></h4>
<div class="text-danger fw-bold">Offline</div> <div class="text-danger fw-bold">
🔴 OFFLINE
</div>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<div class="p-3 card rounded"> <div class="p-3 card rounded">
<h4><?= $sumEntradas ?></h4> <h4 id="total_entradas"><?= $sumEntradas ?></h4>
<div class="fw-bold">Entradas</div> <div class="fw-bold">
💰 ENTRADAS
</div>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<div class="p-3 card rounded"> <div class="p-3 card rounded">
<h4><?= $sumSaidas ?></h4> <h4 id="total_saidas"><?= $sumSaidas ?></h4>
<div class="fw-bold">Saídas</div> <div class="fw-bold">
💸 SAÍDAS
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- TABELA --> <!-- TABELA -->
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body"> <div class="card-body">
<table class="table table-striped table-hover align-middle"> <table class="table table-striped table-hover align-middle">
<thead> <thead>
<tr> <tr>
<th><a href="?sort=uid">UID</a></th>
<th>Entradas</th> <th>UID</th>
<th>Saídas</th> <th>NOTA</th>
<th><a href="?sort=state">Estado</a></th> <th>ENTRADAS</th>
<th>Último Sinal</th> <th>SAÍDAS</th>
<th>Ações</th> <th>ESTADO</th>
<th>BROKER</th>
<th>RSSI</th>
<th>HEAP</th>
<th>UPTIME</th>
<th>ÚLTIMO</th>
<th>AÇÕES</th>
</tr> </tr>
</thead> </thead>
<tbody id="tabela_corpo"> <tbody id="tabela_corpo">
<?php foreach ($rows as $r): ?> <?php foreach ($rows as $r): ?>
<?php <?php
$online = (int)$r['online'] === 1;
$blocked = (int)$r['blocked'] === 1;
if ($blocked) { $online = (int)$r['online'] === 1;
$state = '<span class="badge bg-danger">Bloqueado</span>'; $blocked = (int)$r['blocked'] === 1;
} elseif ($online) {
$state = '<span class="badge bg-success">Online</span>'; if ($blocked) {
} else {
$state = '<span class="badge bg-secondary">Offline</span>'; $state =
} '<span class="badge bg-danger">
BLOQUEADO
</span>';
} elseif ($online) {
$state =
'<span class="badge bg-success">
ONLINE
</span>';
} else {
$state =
'<span class="badge bg-secondary">
OFFLINE
</span>';
}
if (($r['current_broker'] ?? '') === 'recovery') {
$broker =
'<span class="badge bg-warning text-dark">
RECOVERY
</span>';
} else {
$broker =
'<span class="badge bg-primary">
PRIMARY
</span>';
}
$ago = (int)$r['ago'];
$lastSeen = ($ago < 60)
? "{$ago}s"
: round($ago / 60) . "m";
$pulseClass =
($ago <= 2 && $online)
? "pulse-cell"
: "";
$ago = (int)$r['ago'];
$lastSeen = ($ago < 60)
? "{$ago}s"
: round($ago / 60) . "m";
?> ?>
<tr> <tr class="<?= $pulseClass ?>">
<td class="fw-bold"><?= htmlspecialchars($r['uid']) ?></td>
<td><?= $r['entradas'] ?></td> <td class="fw-bold">
<td><?= $r['saidas'] ?></td> <?= htmlspecialchars($r['uid']) ?>
<td><?= $state ?></td>
<td><?= $lastSeen ?></td>
<td>
<a href="?toggle=<?= urlencode($r['uid']) ?>"
class="btn btn-sm <?= $blocked ? 'btn-success' : 'btn-danger' ?>">
<?= $blocked ? 'Desbloquear' : 'Bloquear' ?>
</a>
</td> </td>
<td>
<form method="post">
<input type="hidden"
name="uid"
value="<?= htmlspecialchars($r['uid']) ?>">
<div class="d-flex gap-2">
<input type="text"
name="note"
value="<?= htmlspecialchars($r['note'] ?? '') ?>"
class="form-control form-control-sm note-input"
placeholder="nota...">
<button type="submit"
name="save_note"
class="btn btn-sm btn-primary">
Save
</button>
</div>
</form>
</td>
<td><?= (int)$r['entradas'] ?></td>
<td><?= (int)$r['saidas'] ?></td>
<td><?= $state ?></td>
<td><?= $broker ?></td>
<td>
<?= isset($r['rssi'])
? $r['rssi'] . " dBm"
: "-" ?>
</td>
<td>
<?= isset($r['heap'])
? number_format((int)$r['heap'])
: "-" ?>
</td>
<td>
<?php
if (isset($r['uptime'])) {
$up = (int)$r['uptime'];
echo gmdate("H:i:s", $up);
} else {
echo "-";
}
?>
</td>
<td><?= $lastSeen ?></td>
<td>
<div class="d-flex gap-2">
<a href="?toggle=<?= urlencode($r['uid']) ?>"
class="btn btn-sm <?= $blocked ? 'btn-success' : 'btn-danger' ?>">
<?= $blocked
? 'Desbloquear'
: 'Bloquear' ?>
</a>
<a href="?remove=<?= urlencode($r['uid']) ?>"
class="btn btn-sm btn-warning"
onclick="return confirm('Remover device?')">
Remover
</a>
</div>
</td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
<!-- ------------ AUTO-REFRESH AJAX ---------------- -->
<script> <script>
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
async function refreshTable() { async function refreshTable() {
try { try {
const res = await fetch('/api/contadores_json.php?x=' + Date.now());
const res =
await fetch(
'/api/contadores_json.php?x='
+ Date.now()
);
const data = await res.json(); const data = await res.json();
@ -201,62 +484,173 @@ async function refreshTable() {
for (const r of data.rows) { for (const r of data.rows) {
// Estado let state = "";
// Estado baseado APENAS no valor online vindo da DB
let state = "";
if (r.blocked == 1) { if (r.blocked == 1) {
state = '<span class="badge bg-danger">Bloqueado</span>';
}
else if (r.online == 1) {
state = '<span class="badge bg-success">Online</span>';
}
else {
state = '<span class="badge bg-secondary">Offline</span>';
}
// Tempo state =
let lastSeen = (r.ago < 60) ? `${r.ago}s` : `${Math.round(r.ago/60)}m`; '<span class="badge bg-danger">BLOQUEADO</span>';
// Animação pulse quando r.ago <= 2 } else if (r.online == 1) {
let pulseClass = (r.ago <= 2 && r.online == 1) ? "pulse-cell" : "";
state =
'<span class="badge bg-success">ONLINE</span>';
} else {
state =
'<span class="badge bg-secondary">OFFLINE</span>';
}
let broker = "";
if (r.current_broker === 'recovery') {
broker =
'<span class="badge bg-warning text-dark">RECOVERY</span>';
} else {
broker =
'<span class="badge bg-primary">PRIMARY</span>';
}
let lastSeen =
(r.ago < 60)
? `${r.ago}s`
: `${Math.round(r.ago / 60)}m`;
let pulseClass =
(r.ago <= 2 && r.online == 1)
? "pulse-cell"
: "";
let uptime = "-";
if (r.uptime && !isNaN(r.uptime)) {
let sec = parseInt(r.uptime);
let h =
String(Math.floor(sec / 3600))
.padStart(2, '0');
let m =
String(Math.floor((sec % 3600) / 60))
.padStart(2, '0');
let s =
String(sec % 60)
.padStart(2, '0');
uptime = `${h}:${m}:${s}`;
}
html += ` html += `
<tr class="${pulseClass}"> <tr class="${esc(pulseClass)}">
<td class="fw-bold">${r.uid}</td>
<td>${r.entradas}</td> <td class="fw-bold">
<td>${r.saidas}</td> ${esc(r.uid)}
<td>${state}</td>
<td>${lastSeen}</td>
<td>
<a href="?toggle=${r.uid}"
class="btn btn-sm ${r.blocked ? 'btn-success' : 'btn-danger'}">
${r.blocked ? 'Desbloquear' : 'Bloquear'}
</a>
</td> </td>
<td>
<form method="post">
<input type="hidden"
name="uid"
value="${esc(r.uid)}">
<div class="d-flex gap-2">
<input type="text"
name="note"
value="${esc(r.note)}"
class="form-control form-control-sm note-input"
placeholder="nota...">
<button type="submit"
name="save_note"
class="btn btn-sm btn-primary">
Save
</button>
</div>
</form>
</td>
<td>${esc(r.entradas)}</td>
<td>${esc(r.saidas)}</td>
<td>${state}</td>
<td>${broker}</td>
<td>${r.rssi != null ? esc(r.rssi) + ' dBm' : '-'}</td>
<td>${r.heap != null ? Number(r.heap).toLocaleString() : '-'}</td>
<td>${uptime}</td>
<td>${esc(lastSeen)}</td>
<td>
<div class="d-flex gap-2">
<a href="?toggle=${esc(r.uid)}"
class="btn btn-sm ${r.blocked ? 'btn-success' : 'btn-danger'}">
${r.blocked ? 'Desbloquear' : 'Bloquear'}
</a>
<a href="?remove=${esc(r.uid)}"
class="btn btn-sm btn-warning"
onclick="return confirm('Remover device?')">
Remover
</a>
</div>
</td>
</tr> </tr>
`; `;
} }
document.getElementById("tabela_corpo").innerHTML = html; document.getElementById("tabela_corpo").innerHTML = html;
// Atualizar totais document.getElementById("total_online").innerText =
document.getElementById("total_online").innerText = data.online; data.online;
document.getElementById("total_offline").innerText = data.offline;
document.getElementById("total_entradas").innerText = data.entradas; document.getElementById("total_offline").innerText =
document.getElementById("total_saidas").innerText = data.saidas; data.offline;
document.getElementById("total_entradas").innerText =
data.entradas;
document.getElementById("total_saidas").innerText =
data.saidas;
} catch (err) { } catch (err) {
console.error("Erro AJAX:", err); console.error("Erro AJAX:", err);
} }
} }
setInterval(refreshTable, 2000); setInterval(refreshTable, 2000);
refreshTable(); refreshTable();
</script> </script>
</body> </body>
</html> </html>

266
devices.php Normal file
View File

@ -0,0 +1,266 @@
<?php
declare(strict_types=1);
require_once __DIR__ . "/api/protecao.php";
require __DIR__ . "/api/db.php";
require_once __DIR__ . "/vendor/autoload.php";
use PhpMqtt\Client\MqttClient;
use PhpMqtt\Client\ConnectionSettings;
$mqttCfg = require __DIR__ . "/config.php";
function mqtt_publish(array $cfg, string $topic, array $payload): void
{
try {
$settings = (new ConnectionSettings())
->setUsername($cfg['username'])
->setPassword($cfg['password'])
->setUseTls(true)
->setTlsCertificateAuthorityFile('/etc/mosquitto/certs/chain.pem')
->setConnectTimeout(3);
$client = new MqttClient(
$cfg['host'], $cfg['port'],
'web-devices-' . getmypid()
);
$client->connect($settings, true);
$client->publish($topic, json_encode($payload), 1);
$client->disconnect();
} catch (Throwable $e) {
error_log("MQTT publish error: " . $e->getMessage());
}
}
// =====================================================
// APROVAR — desbloqueia e manda REJOIN via MQTT
// =====================================================
if (isset($_GET['approve'])) {
$uid = $_GET['approve'];
$pdo->prepare("
UPDATE contadores
SET blocked=0
WHERE uid=?
")->execute([$uid]);
// REJOIN para o ESP fazer auth imediatamente
mqtt_publish($mqttCfg, "esp/$uid/cmd", ['cmd' => 'REJOIN']);
header("Location: devices.php");
exit;
}
// =====================================================
// REJEITAR / REMOVER
// =====================================================
if (isset($_GET['reject'])) {
$uid = $_GET['reject'];
$pdo->prepare("
DELETE FROM contadores
WHERE uid=?
")->execute([$uid]);
header("Location: devices.php");
exit;
}
// =====================================================
// BLOQUEAR / DESBLOQUEAR
// =====================================================
if (isset($_GET['toggle'])) {
$uid = $_GET['toggle'];
$pdo->prepare("
UPDATE contadores
SET blocked = 1 - blocked
WHERE uid=?
")->execute([$uid]);
header("Location: devices.php");
exit;
}
// =====================================================
// DESPROMOVER — volta a pendente
// =====================================================
if (isset($_GET['demote'])) {
$uid = $_GET['demote'];
$pdo->prepare("
UPDATE contadores
SET blocked=1, auth_ok=0
WHERE uid=?
")->execute([$uid]);
header("Location: devices.php");
exit;
}
// =====================================================
// QUERY
// =====================================================
$pending = $pdo->query("
SELECT uid, note, last_seen, blocked, auth_ok,
TIMESTAMPDIFF(SECOND, last_seen, NOW()) AS ago
FROM contadores
WHERE blocked=1 AND auth_ok=0
ORDER BY last_seen DESC
")->fetchAll();
$approved = $pdo->query("
SELECT uid, note, last_seen, online, blocked, auth_ok, current_broker,
TIMESTAMPDIFF(SECOND, last_seen, NOW()) AS ago
FROM contadores
WHERE blocked=0 OR auth_ok=1
ORDER BY online DESC, last_seen DESC
")->fetchAll();
?>
<!doctype html>
<html lang="pt" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<title>Dispositivos</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<style>
body { background:#0b0b0b; color:#e0e0e0; font-family:Arial,sans-serif; }
.card { background:#151515; border:1px solid #333; }
.header-title { color:#00ffcc; text-shadow:0 0 8px rgba(0,255,180,0.4); }
</style>
</head>
<body>
<div class="container-fluid py-4">
<h1 class="mb-4 header-title">🔌 Gestão de Dispositivos</h1>
<!-- PENDENTES -->
<h4 class="text-warning mb-3"> Pendentes de Aprovação (<?= count($pending) ?>)</h4>
<?php if (empty($pending)): ?>
<div class="alert alert-secondary mb-4">Nenhum dispositivo pendente.</div>
<?php else: ?>
<div class="card shadow-sm mb-5">
<div class="card-body">
<table class="table table-dark table-hover align-middle mb-0">
<thead>
<tr>
<th>UID</th>
<th>NOTA</th>
<th>ÚLTIMO HELLO</th>
<th>AÇÕES</th>
</tr>
</thead>
<tbody>
<?php foreach ($pending as $r): ?>
<?php
$ago = (int)$r['ago'];
$lastSeen = $ago < 60 ? "{$ago}s" : round($ago/60) . "m";
?>
<tr>
<td class="fw-bold text-warning"><?= htmlspecialchars($r['uid']) ?></td>
<td><?= htmlspecialchars($r['note'] ?? '') ?></td>
<td><?= $lastSeen ?></td>
<td>
<div class="d-flex gap-2">
<a href="?approve=<?= urlencode($r['uid']) ?>"
class="btn btn-sm btn-success"
onclick="return confirm('Aprovar <?= htmlspecialchars($r['uid']) ?>?')">
Aprovar
</a>
<a href="?reject=<?= urlencode($r['uid']) ?>"
class="btn btn-sm btn-danger"
onclick="return confirm('Rejeitar e apagar <?= htmlspecialchars($r['uid']) ?>?')">
Rejeitar
</a>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<!-- APROVADOS -->
<h4 class="text-success mb-3"> Dispositivos Aprovados (<?= count($approved) ?>)</h4>
<div class="card shadow-sm">
<div class="card-body">
<table class="table table-dark table-striped table-hover align-middle mb-0">
<thead>
<tr>
<th>UID</th>
<th>NOTA</th>
<th>ESTADO</th>
<th>BROKER</th>
<th>ÚLTIMO</th>
<th>AÇÕES</th>
</tr>
</thead>
<tbody>
<?php foreach ($approved as $r): ?>
<?php
$ago = (int)$r['ago'];
$lastSeen = $ago < 60 ? "{$ago}s" : round($ago/60) . "m";
$online = (int)$r['online'] === 1;
$blocked = (int)$r['blocked'] === 1;
if ($blocked) {
$state = '<span class="badge bg-danger">BLOQUEADO</span>';
} elseif ($online) {
$state = '<span class="badge bg-success">ONLINE</span>';
} else {
$state = '<span class="badge bg-secondary">OFFLINE</span>';
}
$broker = ($r['current_broker'] ?? '') === 'recovery'
? '<span class="badge bg-warning text-dark">RECOVERY</span>'
: '<span class="badge bg-primary">PRIMARY</span>';
?>
<tr>
<td class="fw-bold"><?= htmlspecialchars($r['uid']) ?></td>
<td><?= htmlspecialchars($r['note'] ?? '') ?></td>
<td><?= $state ?></td>
<td><?= $broker ?></td>
<td><?= $lastSeen ?></td>
<td>
<div class="d-flex gap-2">
<a href="?toggle=<?= urlencode($r['uid']) ?>"
class="btn btn-sm <?= $blocked ? 'btn-success' : 'btn-danger' ?>">
<?= $blocked ? 'Desbloquear' : 'Bloquear' ?>
</a>
<a href="?demote=<?= urlencode($r['uid']) ?>"
class="btn btn-sm btn-warning"
onclick="return confirm('Despromover para pendente?')">
Pendente
</a>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<div class="mt-4">
<a href="api/menu.php" class="btn btn-outline-secondary"> Menu</a>
</div>
</div>
</body>
</html>

16
save_note.php Normal file
View File

@ -0,0 +1,16 @@
<?php
require_once __DIR__ . "/api/protecao.php";
require __DIR__ . "/api/db.php";
$uid = $_POST['uid'] ?? '';
$note = $_POST['note'] ?? '';
$stmt = $pdo->prepare("
UPDATE contadores
SET note = ?
WHERE uid = ?
");
$stmt->execute([$note, $uid]);
header("Location: console.php");