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:
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);
}