Files
html/api/agent-avatar-update.php
2026-04-23 21:45:04 +02:00

173 lines
5.8 KiB
PHP

<?php
/**
* AVATAR PICKER V2 - SAFE UPDATE ENDPOINT
*
* POST /api/agent-avatar-update.php
* Body JSON:
* - agent: string (nom agent, requis)
* - emoji: string (optionnel)
* - url: string (optionnel, pour dicebear)
* - color: string (optionnel, hex)
*
* Merge non-écrasant dans agent-avatars-v2.json
* Backup auto avant chaque write (rotation keeps 10 derniers)
* Zero suppression, zero fake data, zero hardcode (doctrine)
*/
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, GET, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
$SSOT = '/var/www/html/api/agent-avatars-v2.json';
$BAKDIR = '/var/www/html/api/avatar-backups';
@mkdir($BAKDIR, 0755, true);
$LOG = '/var/log/weval/avatar-update.log';
function jerr($code, $msg) {
http_response_code($code);
echo json_encode(['ok' => false, 'error' => $msg]);
exit;
}
function logline($m) {
global $LOG;
@file_put_contents($LOG, date('c') . " $m\n", FILE_APPEND);
}
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
// Health check
$count = 0;
if (file_exists($SSOT)) {
$d = json_decode(file_get_contents($SSOT), true);
if (is_array($d)) $count = count($d);
}
$baks = glob("$BAKDIR/agent-avatars-v2.json.*") ?: [];
echo json_encode([
'ok' => true,
'ssot' => $SSOT,
'exists' => file_exists($SSOT),
'count' => $count,
'backups' => count($baks),
'last_backup' => !empty($baks) ? basename(end($baks)) : null
]);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jerr(405, 'Method not allowed');
}
$raw = file_get_contents('php://input');
$input = json_decode($raw, true);
if (!is_array($input)) jerr(400, 'Invalid JSON body');
$agent = trim($input['agent'] ?? '');
if ($agent === '') jerr(400, 'Missing agent name');
if (strlen($agent) > 200) jerr(400, 'Agent name too long');
// Load SSOT
if (!file_exists($SSOT)) jerr(500, 'SSOT file missing');
$data = json_decode(file_get_contents($SSOT), true);
if (!is_array($data)) jerr(500, 'SSOT JSON invalid');
if (!isset($data[$agent])) jerr(404, "Agent '$agent' not in SSOT");
// Accepted update fields (whitelist - doctrine zero hardcode of random keys)
$ALLOWED = ['emoji', 'url', 'color', 'role'];
$updates = [];
foreach ($ALLOWED as $k) {
if (array_key_exists($k, $input)) {
$v = $input[$k];
if ($v === '' || $v === null) continue; // skip empty
if (!is_string($v) && !is_null($v)) continue;
// Length caps
if ($k === 'emoji' && mb_strlen($v) > 20) jerr(400, 'emoji too long');
if ($k === 'url' && strlen($v) > 500) jerr(400, 'url too long');
if ($k === 'color' && !preg_match('/^#[0-9a-fA-F]{3,8}$/', $v)) jerr(400, 'invalid color hex');
if ($k === 'role' && strlen($v) > 30) jerr(400, 'role too long');
if ($k === 'url' && !preg_match('#^https?://#', $v)) jerr(400, 'url must be http(s)');
$updates[$k] = $v;
}
}
if (empty($updates)) jerr(400, 'No valid fields to update');
// Backup BEFORE write (doctrine GOLD)
$ts = date('Ymd-His');
$bakFile = "$BAKDIR/agent-avatars-v2.json.bak-$ts";
if (!@copy($SSOT, $bakFile)) jerr(500, 'Backup failed');
// Rotate: keep last 10 backups
$baks = glob("$BAKDIR/agent-avatars-v2.json.bak-*") ?: [];
sort($baks);
while (count($baks) > 10) {
@unlink(array_shift($baks));
}
// Merge (doctrine zero écrasement : on préserve tous les champs existants)
$before = $data[$agent];
foreach ($updates as $k => $v) {
$data[$agent][$k] = $v;
}
$after = $data[$agent];
// Write atomic via tmp
$tmp = $SSOT . '.tmp-' . getmypid();
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) jerr(500, 'JSON encode failed');
if (@file_put_contents($tmp, $json) === false) jerr(500, 'tmp write failed');
if (!@rename($tmp, $SSOT)) jerr(500, 'rename failed');
@chmod($SSOT, 0644);
logline("UPDATE agent=$agent fields=" . implode(',', array_keys($updates)) . " bak=" . basename($bakFile));
// Wave-277 bis: auto-purge CF cache for SSOT endpoints (zero manual purge)
// Doctrine: propagation instantanee (sinon cache max-age=30 retarde visuel)
$cfPurged = false;
$cfEmail = 'ymahboub@weval-consulting.com';
$cfKey = null;
if (is_readable('/etc/weval/secrets.env')) {
foreach (file('/etc/weval/secrets.env', FILE_IGNORE_NEW_LINES) as $line) {
if (strpos($line, 'CF_AI_KEY=') === 0) { $cfKey = trim(substr($line, 10)); break; }
}
}
if ($cfKey) {
$urlsToPurge = json_encode(['files' => [
'https://weval-consulting.com/api/agent-avatars.php',
'https://weval-consulting.com/api/agent-avatars-v75.php',
'https://weval-consulting.com/api/agent-avatars-v2.json'
]]);
$ch = curl_init('https://api.cloudflare.com/client/v4/zones/1488bbba251c6fa282999fcc09aac9fe/purge_cache');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $urlsToPurge,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 8,
CURLOPT_HTTPHEADER => [
'X-Auth-Email: ' . $cfEmail,
'X-Auth-Key: ' . $cfKey,
'Content-Type: application/json'
]
]);
$cfResp = curl_exec($ch);
curl_close($ch);
if ($cfResp) {
$cfData = json_decode($cfResp, true);
$cfPurged = $cfData['success'] ?? false;
logline('CF_PURGE agent=' . $agent . ' result=' . ($cfPurged ? 'OK' : 'FAIL'));
}
}
echo json_encode([
'ok' => true,
'agent' => $agent,
'updated_fields' => array_keys($updates),
'cf_purged' => $cfPurged,
'before' => $before,
'after' => $after,
'backup' => basename($bakFile),
'total_agents' => count($data)
]);