enableExceptions(true); $db->exec('PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;'); $db->exec(' CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE NOT NULL COLLATE NOCASE, name TEXT NOT NULL, last TEXT DEFAULT \'\', country TEXT DEFAULT \'Panamá\', currency TEXT DEFAULT \'USD\', pass_hash TEXT NOT NULL, role TEXT DEFAULT \'user\', created_at TEXT DEFAULT (strftime(\'%Y-%m-%dT%H:%M:%SZ\',\'now\')) ); CREATE TABLE IF NOT EXISTS user_data ( user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, payload TEXT DEFAULT \'{}\', updated_at TEXT DEFAULT (strftime(\'%Y-%m-%dT%H:%M:%SZ\',\'now\')) ); CREATE TABLE IF NOT EXISTS reset_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, token TEXT UNIQUE NOT NULL, expires_at TEXT NOT NULL, used INTEGER DEFAULT 0 ); '); $db->exec("DELETE FROM reset_tokens WHERE used=1 OR expires_at < strftime('%Y-%m-%dT%H:%M:%SZ','now')"); return $db; } // ── JWT ─────────────────────────────────────────────────────────────────────── function b64u(string $s): string { return rtrim(strtr(base64_encode($s), '+/', '-_'), '='); } function jwt_make(array $payload, int $expSecs = 2592000): string { global $JWT_SECRET; $h = b64u(json_encode(['typ' => 'JWT', 'alg' => 'HS256'])); $pl = b64u(json_encode(array_merge($payload, ['iat' => time(), 'exp' => time() + $expSecs]))); $sig = b64u(hash_hmac('sha256', "$h.$pl", $JWT_SECRET, true)); return "$h.$pl.$sig"; } function jwt_parse(string $token): ?array { global $JWT_SECRET; $parts = explode('.', $token); if (count($parts) !== 3) return null; [$h, $pl, $sig] = $parts; $expected = b64u(hash_hmac('sha256', "$h.$pl", $JWT_SECRET, true)); if (!hash_equals($expected, $sig)) return null; $data = json_decode(base64_decode(strtr($pl, '-_', '+/')), true); if (!$data) return null; if (($data['exp'] ?? 0) < time()) return null; return $data; } // ── Rate limit ──────────────────────────────────────────────────────────────── function rate_limit(string $key, int $max, int $windowSecs): void { $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; $file = sys_get_temp_dir() . '/cb_' . md5($key . $ip) . '.json'; $now = time(); $data = null; if (file_exists($file)) { $data = json_decode(file_get_contents($file), true); } if (!$data || $data['reset'] <= $now) { $data = ['count' => 0, 'reset' => $now + $windowSecs]; } $data['count']++; file_put_contents($file, json_encode($data), LOCK_EX); if ($data['count'] > $max) { $wait = ceil(($data['reset'] - $now) / 60); send_fail("Demasiados intentos. Espera {$wait} minuto(s).", 429); } } // ── Response helpers ────────────────────────────────────────────────────────── function send_ok(mixed $data = ['ok' => true], int $code = 200): never { http_response_code($code); echo json_encode($data); exit; } function send_fail(string $error, int $code = 400): never { http_response_code($code); echo json_encode(['error' => $error]); exit; } function get_body(): array { static $body = null; if ($body !== null) return $body; $body = json_decode(file_get_contents('php://input'), true) ?? []; return $body; } function is_valid_email(string $e): bool { return (bool) filter_var($e, FILTER_VALIDATE_EMAIL); } function safe_user(array $u): array { unset($u['pass_hash']); return $u; } function admin_stub(): array { global $ADMIN_EMAIL; return [ 'id' => 0, 'email' => $ADMIN_EMAIL, 'name' => 'Admin', 'last' => '', 'role' => 'admin', 'currency' => 'USD', 'country' => 'Panamá', 'created_at' => date('c'), ]; } function require_auth(): array { $header = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; if (!str_starts_with($header, 'Bearer ')) { send_fail('No autenticado', 401); } $payload = jwt_parse(substr($header, 7)); if (!$payload) { send_fail('Sesión expirada', 401); } return $payload; } function require_admin(): array { $u = require_auth(); if (($u['role'] ?? '') !== 'admin') send_fail('Solo administradores', 403); return $u; } // ── Email ───────────────────────────────────────────────────────────────────── function send_reset_email(string $to, string $name, string $url): void { global $SMTP_USER; if (!$SMTP_USER) throw new RuntimeException('Email no configurado en el servidor'); $subject = '=?UTF-8?B?' . base64_encode('Restablece tu contraseña — CriptoBudget') . '?='; $html = "

CriptoBudget

Hola " . htmlspecialchars($name) . ",

Haz clic en el botón para restablecer tu contraseña:

Restablecer contraseña

Este enlace expira en 1 hora. Si no lo solicitaste, ignora este mensaje.

" . htmlspecialchars($url) . "

"; $headers = implode("\r\n", [ "From: CriptoBudget <$SMTP_USER>", "MIME-Version: 1.0", "Content-Type: text/html; charset=UTF-8", ]); if (!mail($to, $subject, $html, $headers)) { throw new RuntimeException('No se pudo enviar el email'); } } // ── Router ──────────────────────────────────────────────────────────────────── $method = $_SERVER['REQUEST_METHOD']; $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); $uri = preg_replace('#^/api#', '', $uri); $uri = rtrim($uri, '/') ?: '/'; try { $db = get_db(); // ── POST /auth/register ────────────────────────────────────────────────── if ($method === 'POST' && $uri === '/auth/register') { rate_limit('register', 10, 900); $b = get_body(); $name = trim($b['name'] ?? ''); $email = strtolower(trim($b['email'] ?? '')); $password = $b['password'] ?? ''; $last = trim($b['last'] ?? ''); $country = $b['country'] ?? 'Panamá'; $currency = $b['currency'] ?? 'USD'; if (!$name) send_fail('El nombre es obligatorio'); if (!$email || !is_valid_email($email)) send_fail('Email inválido'); if (strlen($password) < 6) send_fail('La contraseña debe tener al menos 6 caracteres'); $st = $db->prepare('SELECT id FROM users WHERE email=?'); $st->bindValue(1, $email, SQLITE3_TEXT); if ($st->execute()->fetchArray()) send_fail('Ya existe una cuenta con ese email', 409); $hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 10]); $st = $db->prepare('INSERT INTO users (email,name,last,country,currency,pass_hash) VALUES (?,?,?,?,?,?)'); $st->bindValue(1, $email, SQLITE3_TEXT); $st->bindValue(2, $name, SQLITE3_TEXT); $st->bindValue(3, $last, SQLITE3_TEXT); $st->bindValue(4, $country, SQLITE3_TEXT); $st->bindValue(5, $currency,SQLITE3_TEXT); $st->bindValue(6, $hash, SQLITE3_TEXT); $st->execute(); $id = $db->lastInsertRowID(); $st = $db->prepare('INSERT INTO user_data (user_id, payload) VALUES (?, ?)'); $st->bindValue(1, $id, SQLITE3_INTEGER); $st->bindValue(2, '{}', SQLITE3_TEXT); $st->execute(); $st = $db->prepare('SELECT * FROM users WHERE id=?'); $st->bindValue(1, $id, SQLITE3_INTEGER); $user = $st->execute()->fetchArray(SQLITE3_ASSOC); $token = jwt_make(['id' => $id, 'email' => $email, 'role' => 'user']); send_ok(['token' => $token, 'user' => safe_user($user)], 201); } // ── POST /auth/login ───────────────────────────────────────────────────── if ($method === 'POST' && $uri === '/auth/login') { rate_limit('login', 10, 900); $b = get_body(); $email = strtolower(trim($b['email'] ?? '')); $password = $b['password'] ?? ''; if (!$email || !$password) send_fail('Email y contraseña requeridos'); // Admin login if ($email === strtolower($ADMIN_EMAIL)) { if (!hash_equals($ADMIN_PASS, $password)) send_fail('Credenciales incorrectas', 401); $token = jwt_make(['id' => 0, 'email' => $ADMIN_EMAIL, 'role' => 'admin'], 28800); send_ok(['token' => $token, 'user' => admin_stub()]); } // Regular user $st = $db->prepare('SELECT * FROM users WHERE email=?'); $st->bindValue(1, $email, SQLITE3_TEXT); $user = $st->execute()->fetchArray(SQLITE3_ASSOC); if (!$user || !password_verify($password, $user['pass_hash'])) { send_fail('Email o contraseña incorrectos', 401); } $token = jwt_make(['id' => $user['id'], 'email' => $user['email'], 'role' => $user['role'] ?? 'user']); send_ok(['token' => $token, 'user' => safe_user($user)]); } // ── POST /auth/forgot-password ─────────────────────────────────────────── if ($method === 'POST' && $uri === '/auth/forgot-password') { rate_limit('forgot', 3, 3600); $email = strtolower(trim(get_body()['email'] ?? '')); // Always return ok to avoid email enumeration if (!$email || !is_valid_email($email)) send_ok(); $st = $db->prepare('SELECT * FROM users WHERE email=?'); $st->bindValue(1, $email, SQLITE3_TEXT); $user = $st->execute()->fetchArray(SQLITE3_ASSOC); if (!$user) send_ok(); // Don't reveal if email exists // Invalidate old tokens $st = $db->prepare('UPDATE reset_tokens SET used=1 WHERE user_id=?'); $st->bindValue(1, $user['id'], SQLITE3_INTEGER); $st->execute(); // Create new token $token = bin2hex(random_bytes(32)); $expires = date('Y-m-d\TH:i:s\Z', time() + 3600); $st = $db->prepare('INSERT INTO reset_tokens (user_id, token, expires_at) VALUES (?,?,?)'); $st->bindValue(1, $user['id'], SQLITE3_INTEGER); $st->bindValue(2, $token, SQLITE3_TEXT); $st->bindValue(3, $expires, SQLITE3_TEXT); $st->execute(); $resetUrl = $APP_URL . '/reset-password?token=' . $token; try { send_reset_email($email, $user['name'], $resetUrl); } catch (Exception $e) { error_log('[CriptoBudget] Email error: ' . $e->getMessage()); } send_ok(); } // ── POST /auth/reset-password ──────────────────────────────────────────── if ($method === 'POST' && $uri === '/auth/reset-password') { $b = get_body(); $token = $b['token'] ?? ''; $password = $b['newPassword'] ?? ''; if (!$token) send_fail('Token requerido'); if (strlen($password) < 6) send_fail('La contraseña debe tener al menos 6 caracteres'); $st = $db->prepare('SELECT * FROM reset_tokens WHERE token=? AND used=0'); $st->bindValue(1, $token, SQLITE3_TEXT); $row = $st->execute()->fetchArray(SQLITE3_ASSOC); if (!$row) send_fail('El enlace no es válido o ya fue utilizado'); if (strtotime($row['expires_at']) < time()) { $st = $db->prepare('UPDATE reset_tokens SET used=1 WHERE id=?'); $st->bindValue(1, $row['id'], SQLITE3_INTEGER); $st->execute(); send_fail('El enlace ha expirado. Solicita uno nuevo.'); } $hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 10]); $st = $db->prepare('UPDATE users SET pass_hash=? WHERE id=?'); $st->bindValue(1, $hash, SQLITE3_TEXT); $st->bindValue(2, $row['user_id'],SQLITE3_INTEGER); $st->execute(); $st = $db->prepare('UPDATE reset_tokens SET used=1 WHERE id=?'); $st->bindValue(1, $row['id'], SQLITE3_INTEGER); $st->execute(); send_ok(); } // ── GET /auth/validate-reset-token ─────────────────────────────────────── if ($method === 'GET' && $uri === '/auth/validate-reset-token') { $token = $_GET['token'] ?? ''; if (!$token) send_ok(['valid' => false, 'error' => 'Token requerido']); $st = $db->prepare('SELECT * FROM reset_tokens WHERE token=? AND used=0'); $st->bindValue(1, $token, SQLITE3_TEXT); $row = $st->execute()->fetchArray(SQLITE3_ASSOC); if (!$row) send_ok(['valid' => false, 'error' => 'El enlace no es válido o ya fue utilizado']); if (strtotime($row['expires_at']) < time()) send_ok(['valid' => false, 'error' => 'El enlace ha expirado']); send_ok(['valid' => true]); } // ── GET /user ──────────────────────────────────────────────────────────── if ($method === 'GET' && $uri === '/user') { $u = require_auth(); if ($u['role'] === 'admin') send_ok(admin_stub()); $st = $db->prepare('SELECT * FROM users WHERE id=?'); $st->bindValue(1, $u['id'], SQLITE3_INTEGER); $user = $st->execute()->fetchArray(SQLITE3_ASSOC); if (!$user) send_fail('Usuario no encontrado', 404); send_ok(safe_user($user)); } // ── PUT /user ──────────────────────────────────────────────────────────── if ($method === 'PUT' && $uri === '/user') { $u = require_auth(); if ($u['role'] === 'admin') send_ok(); $b = get_body(); $name = trim($b['name'] ?? ''); if (!$name) send_fail('El nombre es obligatorio'); $st = $db->prepare('UPDATE users SET name=?,last=?,country=?,currency=? WHERE id=?'); $st->bindValue(1, $name, SQLITE3_TEXT); $st->bindValue(2, trim($b['last'] ?? ''), SQLITE3_TEXT); $st->bindValue(3, $b['country'] ?? 'Panamá', SQLITE3_TEXT); $st->bindValue(4, $b['currency'] ?? 'USD', SQLITE3_TEXT); $st->bindValue(5, $u['id'], SQLITE3_INTEGER); $st->execute(); $st = $db->prepare('SELECT * FROM users WHERE id=?'); $st->bindValue(1, $u['id'], SQLITE3_INTEGER); send_ok(safe_user($st->execute()->fetchArray(SQLITE3_ASSOC))); } // ── PUT /user/password ─────────────────────────────────────────────────── if ($method === 'PUT' && $uri === '/user/password') { $u = require_auth(); if ($u['role'] === 'admin') send_fail('No aplica para admin', 403); $b = get_body(); $cur = $b['currentPassword'] ?? ''; $new = $b['newPassword'] ?? ''; if (!$cur || !$new) send_fail('Se requieren ambas contraseñas'); if (strlen($new) < 6) send_fail('Mínimo 6 caracteres'); $st = $db->prepare('SELECT pass_hash FROM users WHERE id=?'); $st->bindValue(1, $u['id'], SQLITE3_INTEGER); $user = $st->execute()->fetchArray(SQLITE3_ASSOC); if (!password_verify($cur, $user['pass_hash'])) send_fail('Contraseña actual incorrecta', 401); $hash = password_hash($new, PASSWORD_BCRYPT, ['cost' => 10]); $st = $db->prepare('UPDATE users SET pass_hash=? WHERE id=?'); $st->bindValue(1, $hash, SQLITE3_TEXT); $st->bindValue(2, $u['id'], SQLITE3_INTEGER); $st->execute(); send_ok(); } // ── GET /data ──────────────────────────────────────────────────────────── if ($method === 'GET' && $uri === '/data') { $u = require_auth(); if ($u['role'] === 'admin') send_ok((object)[]); $st = $db->prepare('SELECT payload FROM user_data WHERE user_id=?'); $st->bindValue(1, $u['id'], SQLITE3_INTEGER); $row = $st->execute()->fetchArray(SQLITE3_ASSOC); $data = $row ? (json_decode($row['payload'], true) ?? []) : []; send_ok($data); } // ── PUT /data ──────────────────────────────────────────────────────────── if ($method === 'PUT' && $uri === '/data') { $u = require_auth(); if ($u['role'] === 'admin') send_ok(); $payload = json_encode(get_body(), JSON_UNESCAPED_UNICODE); $now = date('Y-m-d\TH:i:s\Z'); $st = $db->prepare('SELECT user_id FROM user_data WHERE user_id=?'); $st->bindValue(1, $u['id'], SQLITE3_INTEGER); $exists = $st->execute()->fetchArray(); if ($exists) { $st = $db->prepare('UPDATE user_data SET payload=?, updated_at=? WHERE user_id=?'); $st->bindValue(1, $payload, SQLITE3_TEXT); $st->bindValue(2, $now, SQLITE3_TEXT); $st->bindValue(3, $u['id'], SQLITE3_INTEGER); } else { $st = $db->prepare('INSERT INTO user_data (user_id, payload, updated_at) VALUES (?,?,?)'); $st->bindValue(1, $u['id'], SQLITE3_INTEGER); $st->bindValue(2, $payload, SQLITE3_TEXT); $st->bindValue(3, $now, SQLITE3_TEXT); } $st->execute(); send_ok(); } // ── GET /admin/stats ───────────────────────────────────────────────────── if ($method === 'GET' && $uri === '/admin/stats') { require_admin(); send_ok([ 'totalUsers' => (int) $db->querySingle('SELECT COUNT(*) FROM users'), 'totalCountries' => (int) $db->querySingle('SELECT COUNT(DISTINCT country) FROM users'), 'newThisMonth' => (int) $db->querySingle("SELECT COUNT(*) FROM users WHERE created_at >= strftime('%Y-%m-01T00:00:00Z','now')"), ]); } // ── GET /admin/users ───────────────────────────────────────────────────── if ($method === 'GET' && $uri === '/admin/users') { require_admin(); $result = $db->query('SELECT * FROM users ORDER BY created_at DESC'); $users = []; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { unset($row['pass_hash']); $users[] = $row; } send_ok($users); } // ── DELETE /admin/users/:id ─────────────────────────────────────────────── if ($method === 'DELETE' && preg_match('#^/admin/users/(\d+)$#', $uri, $matches)) { require_admin(); $id = (int) $matches[1]; if ($id <= 0) send_fail('ID inválido'); $st = $db->prepare('DELETE FROM users WHERE id=?'); $st->bindValue(1, $id, SQLITE3_INTEGER); $st->execute(); send_ok(); } send_fail("Ruta no encontrada: $method $uri", 404); } catch (Throwable $e) { error_log('[CriptoBudget] ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine()); send_fail('Error interno del servidor. Revisa los logs.', 500); }