'https://hvatar.com', * 'client_id' => 'app_xxx', * 'client_secret' => 'sk_xxx', // nunca en el navegador * 'cache_dir' => __DIR__ . '/cache', // opcional, para la llave pública * ]); * * $sub = $hv->user(); // identificador del usuario en tu app o null * $hv->notifyEmail($sub, 'Asunto', 'Texto'); * $box = $hv->encrypt('contenido'); // cifra con la llave del usuario * * Reglas del contrato: guarda solo $sub; cifra el contenido del usuario; atiende el webhook de baja. */ declare(strict_types=1); final class HvatarException extends Exception {} final class Hvatar { public const VERSION = '0.5.0'; private const SESSION_KEY = '_hvatar'; private string $url; private string $clientId; private string $secret; private string $cacheDir; private ?string $contentKey = null; public function __construct(array $cfg) { $this->url = rtrim((string) ($cfg['url'] ?? 'https://hvatar.com'), '/'); $this->clientId = (string) ($cfg['client_id'] ?? ''); $this->secret = (string) ($cfg['client_secret'] ?? ''); $dir = rtrim((string) ($cfg['cache_dir'] ?? ''), '/'); if ($dir === '' || !is_dir($dir) || !is_writable($dir)) { // Hosting compartido: el temporal del sistema puede no ser escribible (open_basedir) foreach ([sys_get_temp_dir(), (string) ini_get('upload_tmp_dir'), __DIR__] as $d) { if ($d !== '' && @is_dir($d) && @is_writable($d)) { $dir = rtrim($d, '/'); break; } } } $this->cacheDir = $dir; if ($this->clientId === '' || $this->secret === '') throw new HvatarException('Falta client_id o client_secret'); if (!function_exists('sodium_crypto_sign_verify_detached')) throw new HvatarException('Se requiere la extensión sodium'); } /* ======================= Login ======================= */ /** * Atiende el endpoint de callback que usa c.js: * GET ?inicio=1 → {url, state} (URL del popup con PKCE) * POST {code, state} → {ok, sub} (intercambia el código y abre sesión) * * $alEntrar(string $sub) corre justo después de entrar, antes de responder. Sirve para migrar * una app existente: si ya había un usuario con sesión propia, vincúlalo a $sub y borra sus datos personales. * Si devuelve un string, se usa como redirección. */ public function handleCallback(?string $redirect = null, ?callable $alEntrar = null): never { $this->startSession(); $metodo = $_SERVER['REQUEST_METHOD'] ?? 'GET'; $porGet = $metodo === 'GET' && isset($_GET['code'], $_GET['state']); // la ventana regresó sin poder avisar a la página try { if ($metodo === 'GET' && isset($_GET['prueba'])) $this->json($this->diagnostico()); if ($metodo === 'GET' && isset($_GET['estado'])) $this->json(['sesion' => $this->user() !== null, 'redirect' => $redirect]); if ($metodo === 'GET' && !$porGet) { $verifier = self::b64u(random_bytes(32)); $state = self::b64u(random_bytes(16)); $_SESSION[self::SESSION_KEY . '_pkce'] = ['v' => $verifier, 's' => $state, 't' => time()]; $this->json(['url' => $this->url . '/authorize?' . http_build_query([ 'client_id' => $this->clientId, 'code_challenge' => self::b64u(hash('sha256', $verifier, true)), 'code_challenge_method' => 'S256', 'state' => $state, 'redirect_uri' => $this->urlCallback(), ]), 'state' => $state]); } $in = $porGet ? $_GET : (json_decode(file_get_contents('php://input') ?: '', true) ?: $_POST); $pkce = $_SESSION[self::SESSION_KEY . '_pkce'] ?? null; if (!$pkce) throw new HvatarException('state_invalido: la sesión de PHP no se conservó entre el inicio y el regreso (revisa session_name o que la cookie de sesión se envíe en esta ruta)'); if (time() - $pkce['t'] > 600) throw new HvatarException('state_vencido: pasaron más de 10 minutos'); if (!hash_equals($pkce['s'], (string) ($in['state'] ?? ''))) throw new HvatarException('state_invalido: el state no coincide'); unset($_SESSION[self::SESSION_KEY . '_pkce']); $tok = $this->call('/token', [ 'grant_type' => 'authorization_code', 'code' => (string) ($in['code'] ?? ''), 'code_verifier' => $pkce['v'], ]); $this->storeTokens($tok); if ($alEntrar) { $r = $alEntrar((string) $tok['sub']); if (is_string($r) && $r !== '') $redirect = $r; } session_regenerate_id(true); if ($porGet) $this->paginaFinal(true, $redirect); $this->json(['ok' => true, 'sub' => $tok['sub'], 'redirect' => $redirect]); } catch (Throwable $e) { error_log('[hvatar] callback: ' . $e->getMessage()); if ($porGet) $this->paginaFinal(false, $redirect, $e->getMessage()); $this->json(['error' => $e->getMessage()], 400); } } /** Dirección absoluta de este callback (sin parámetros), para el regreso directo. */ private function urlCallback(): string { $https = ($_SERVER['HTTPS'] ?? '') === 'on' || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https' || ($_SERVER['SERVER_PORT'] ?? '') === '443'; $host = (string) ($_SERVER['HTTP_HOST'] ?? 'localhost'); $ruta = strtok((string) ($_SERVER['REQUEST_URI'] ?? '/'), '?'); return ($https ? 'https' : 'http') . '://' . $host . $ruta; } /** Página que ve la ventana cuando regresa directo al callback (sin poder avisar a la página que la abrió). */ private function paginaFinal(bool $ok, ?string $redirect, string $error = ''): never { http_response_code($ok ? 200 : 400); header('Content-Type: text/html; charset=utf-8'); header('Cache-Control: no-store'); $dest = json_encode($redirect ?: '/', JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); $msg = $ok ? 'Listo, ya entraste. Puedes cerrar esta ventana.' : 'No se pudo entrar: ' . htmlspecialchars($error); echo 'Hvatar' . '' . '

' . $msg . '

'; exit; } /** Revisión de la instalación: ábrela en el navegador como hvatar-callback.php?prueba=1 */ private function diagnostico(): array { $r = ['sdk' => self::VERSION, 'php' => PHP_VERSION]; $r['sodium'] = function_exists('sodium_crypto_sign_verify_detached'); $r['openssl'] = function_exists('openssl_encrypt'); $r['conexion'] = function_exists('curl_init') ? 'curl' : (ini_get('allow_url_fopen') ? 'allow_url_fopen' : 'ninguna'); $r['cache_dir_escribible'] = $this->cacheDir !== '' && is_writable($this->cacheDir); $r['sesion_php'] = session_status() === PHP_SESSION_ACTIVE ? session_name() : 'sin sesión'; try { $this->http('GET', $this->url . '/.well-known/keys.json'); $r['alcanza_hvatar'] = true; } catch (Throwable $e) { $r['alcanza_hvatar'] = $e->getMessage(); } try { $this->call('/token', ['grant_type' => 'prueba']); $r['credenciales'] = '?'; } catch (Throwable $e) { $m = $e->getMessage(); $r['credenciales'] = $m === 'grant_no_soportado' ? true : $m; // grant_no_soportado = la app se autenticó bien } $r['todo_bien'] = $r['sodium'] && $r['openssl'] && $r['conexion'] !== 'ninguna' && $r['alcanza_hvatar'] === true && $r['credenciales'] === true; return $r; } /** Identificador del usuario con sesión, o null. Renueva el pase si venció. */ public function user(): ?string { $s = $this->session(); if (!$s) return null; if (isset($this->revoked()[$s['sub']])) { // baja recibida por webhook: cortar ya, sin esperar a que venza el pase $this->logout(); return null; } return $this->pase() !== null ? $s['sub'] : null; } /** Datos del pase vigente (sub, plan, vence, caps), o null. */ public function claims(): ?array { $pase = $this->pase(); return $pase ? $this->verifyPass($pase) : null; } /** Pase vigente; lo renueva con el refresh si faltan menos de 60 s. */ public function pase(): ?string { $s = $this->session(); if (!$s) return null; if ($s['exp'] - 60 > time()) return $s['pase']; try { $this->storeTokens($this->call('/token', ['grant_type' => 'refresh_token', 'refresh' => $s['refresh']])); return $this->session()['pase']; } catch (HvatarException) { $this->logout(); // baja o revocación: la sesión local termina return null; } } /** * Avatar y nombre a mostrar del usuario, para el compañero animado de c.js. * Se guarda en la sesión hasta que se renueve el pase. Devuelve null sin sesión. */ public function companero(): ?array { $pase = $this->pase(); if (!$pase) return null; $s = $_SESSION[self::SESSION_KEY]; if (($s['comp_pase'] ?? '') !== $pase) { try { $c = $this->call('/v1/companero', ['pase' => $pase]); } catch (HvatarException) { return null; } $_SESSION[self::SESSION_KEY]['comp'] = ['avatar' => $c['avatar'] ?? null, 'nombre' => $c['nombre'] ?? null, 'imagen' => $c['imagen'] ?? null, 'cuadros' => $c['cuadros'] ?? null]; $_SESSION[self::SESSION_KEY]['comp_pase'] = $pase; } return $_SESSION[self::SESSION_KEY]['comp']; } /** Atributo listo para