Hermes channel. // One file per thread: data/chat/.json, written atomically (tmp + rename). declare(strict_types=1); require_once __DIR__ . '/../debug/config.php'; // error_response() / json_response() / load_json() define('CHAT_DIR', __DIR__ . '/../../data/chat'); define('CHAT_MAX_MESSAGES', 200); define('CHAT_MAX_TEXT', 4000); define('CHAT_RATE_MAX', 20); // messages per thread … define('CHAT_RATE_WINDOW', 600); // … per 10 minutes define('CHAT_IP_MAX', 40); // a token is mintable, an IP is not define('CHAT_MAX_THREADS', 500); // hard ceiling on disk growth if (!is_dir(CHAT_DIR)) { @mkdir(CHAT_DIR, 0775, true); @chgrp(CHAT_DIR, 'www-data'); } // Machine-readable failure: the sender must be able to tell "try again" from // "your text is wrong" without parsing prose. 401/413/507 are permanent for that // payload; 429 is the only one worth retrying. function chat_error(string $msg, int $status, string $code, array $extra = []): void { if (!headers_sent()) { http_response_code($status); header('Content-Type: application/json'); } $body = array_merge(['success' => false, 'code' => $code, 'error' => $msg], $extra); echo json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); exit; } function chat_token(): string { $t = $_SERVER['HTTP_X_CHAT_TOKEN'] ?? ''; if (!preg_match('/^[a-f0-9]{64}$/', $t)) { chat_error('missing or malformed X-Chat-Token', 401, 'bad_token'); } return $t; } function chat_thread(string $token): string { return substr(hash('sha256', $token), 0, 16); } function chat_path(string $thread): string { if (!preg_match('/^[a-f0-9]{16}$/', $thread)) { error_response('invalid thread', 400); } return CHAT_DIR . '/' . $thread . '.json'; } function chat_read(string $thread): array { $all = load_json(chat_path($thread)); return is_array($all) ? $all : []; } // Atomic replace: a reader sees the old file or the new one, never a truncated one. function chat_write(string $thread, array $messages): bool { $path = chat_path($thread); $tmp = $path . '.tmp-' . bin2hex(random_bytes(4)); $json = json_encode(array_values($messages), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($json === false) return false; $fp = fopen($tmp, 'wb'); if (!$fp) return false; $ok = (fwrite($fp, $json) === strlen($json)); fflush($fp); fclose($fp); if (!$ok) { @unlink($tmp); return false; } @chmod($tmp, 0664); return @rename($tmp, $path); } // Append under a real lock: read-modify-write on one handle so two concurrent // POSTs cannot each write back a stale copy (lost update). function chat_append(string $thread, string $author, string $text): array { $lock = fopen(CHAT_DIR . '/.lock-' . $thread, 'c'); // FPM creates as www-data 0644; the host-side CLI (darkbot, group www-data) must be // able to open the same lock, so group gets write too. if ($lock) @chmod(CHAT_DIR . '/.lock-' . $thread, 0664); if (!$lock || !flock($lock, LOCK_EX)) { if ($lock) fclose($lock); error_response('failed to lock thread', 500); } $msg = []; try { $messages = chat_read($thread); $msg = [ 'id' => 'MSG-' . gmdate('Ymd-His') . '-' . bin2hex(random_bytes(3)), 'ts' => gmdate('c'), // UTC, always — never a local offset 'epoch' => (int)round(microtime(true) * 1000), // ms — second precision drops same-second messages 'author' => $author, 'text' => $text, ]; $messages[] = $msg; $cutoff = (time() - 30 * 86400) * 1000; $messages = array_values(array_filter( $messages, fn($m) => (int)($m['epoch'] ?? (strtotime((string)($m['ts'] ?? '')) * 1000)) >= $cutoff )); if (count($messages) > CHAT_MAX_MESSAGES) { $messages = array_slice($messages, -CHAT_MAX_MESSAGES); } if (!chat_write($thread, $messages)) { error_response('failed to persist message', 500); } } finally { flock($lock, LOCK_UN); fclose($lock); } return $msg; } // Two windows: per thread (the conversation) and per IP (a token is mintable, so the // thread window alone is bypassable by rotating tokens). Returns the reason and the // wait, so the caller can send Retry-After instead of an unparseable sentence. function chat_rate_check(string $thread): array { $ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; $t = chat_window_check('t-' . $thread, CHAT_RATE_MAX); if (!$t['ok']) return ['ok' => false, 'scope' => 'thread'] + $t; $i = chat_window_check('i-' . hash('sha256', $ip), CHAT_IP_MAX); if (!$i['ok']) return ['ok' => false, 'scope' => 'ip'] + $i; return ['ok' => true, 'scope' => '', 'retry_after_ms' => 0]; } // Sliding window. The wait is the time until the oldest hit leaves the window, // which is the shortest time after which a retry can succeed. function chat_window_check(string $key, int $max): array { $file = CHAT_DIR . '/.rate-' . $key . '.json'; $fp = fopen($file, 'c+'); if (!$fp) return ['ok' => true, 'retry_after_ms' => 0]; // never drop a message over a stats file if (!flock($fp, LOCK_EX)) { fclose($fp); return ['ok' => true, 'retry_after_ms' => 0]; } $raw = stream_get_contents($fp); $hits = json_decode($raw ?: '[]', true); if (!is_array($hits)) $hits = []; $now = time(); $hits = array_values(array_filter($hits, fn($t) => (int)$t > $now - CHAT_RATE_WINDOW)); sort($hits); $ok = count($hits) < $max; if ($ok) $hits[] = $now; ftruncate($fp, 0); rewind($fp); fwrite($fp, json_encode($hits)); fflush($fp); flock($fp, LOCK_UN); fclose($fp); @chmod($file, 0664); if ($ok) return ['ok' => true, 'retry_after_ms' => 0]; $oldest = (int)($hits[0] ?? $now); $wait = max(0, ($oldest + CHAT_RATE_WINDOW) - $now) * 1000; return ['ok' => false, 'retry_after_ms' => $wait]; } function chat_thread_count(): int { $g = glob(CHAT_DIR . '/*.json'); return $g ? count($g) : 0; }