From 1fdfc3174b1bd9a8a592d2c5a77bb3aa75cc3516 Mon Sep 17 00:00:00 2001 From: Carlos Arias Date: Sat, 4 Jul 2026 23:23:51 +0000 Subject: [PATCH] =?UTF-8?q?feat(api):=20Foundation=20=E2=80=94=20installer?= =?UTF-8?q?,=20migrations,=20two-tier=20auth,=20health=20round-trip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the /api Foundation into the base (per api/.memory/foundation-plan.md): - app/Services/Installer.php DB test + dump.sql import + crypto keys + /api config + lock - app/Services/Migrator.php versioned db/migrations/*.sql runner (+ migrations table) - commands/InstallCommand.php (app:install), commands/MigrateCommand.php (db:migrate) - app/Controllers/{JsonController,PublicController,ApiController} envelope + two-tier auth - public/controllers/{health,admin}.php GET /api/health (public), /api/admin/ping (bearer) - db/migrations/001_*.sql smoke migration - install/controllers/index.php web wizard now delegates to Installer (path bugs fixed, lock) - console + composer.json register commands / add commands to classmap - app/src/pages/api-health-test.astro browser round-trip proof page Verified (no DB): composer install OK; php console lists app:install + db:migrate; PSR-4 classes autoload; CLI fails gracefully (validation, bad DB, missing config) with no artifacts left; app builds 14 pages. Live DB + HTTP round-trip pending a served instance. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01SYHWLHihq3v9nxNwoPCKSn --- api/app/Controllers/ApiController.php | 47 ++++ api/app/Controllers/JsonController.php | 42 ++++ api/app/Controllers/PublicController.php | 42 ++++ api/app/Services/Installer.php | 132 +++++++++++ api/app/Services/Migrator.php | 57 +++++ api/commands/InstallCommand.php | 54 +++++ api/commands/MigrateCommand.php | 42 ++++ api/composer.json | 1 + api/console | 4 +- .../001_create_metrics_placeholder.sql | 7 + api/install/controllers/index.php | 208 ++++++------------ app/src/pages/api-health-test.astro | 18 ++ 12 files changed, 514 insertions(+), 140 deletions(-) create mode 100644 api/app/Controllers/ApiController.php create mode 100644 api/app/Controllers/JsonController.php create mode 100644 api/app/Controllers/PublicController.php create mode 100644 api/app/Services/Installer.php create mode 100644 api/app/Services/Migrator.php create mode 100644 api/commands/InstallCommand.php create mode 100644 api/commands/MigrateCommand.php create mode 100644 api/db/migrations/001_create_metrics_placeholder.sql create mode 100644 app/src/pages/api-health-test.astro diff --git a/api/app/Controllers/ApiController.php b/api/app/Controllers/ApiController.php new file mode 100644 index 0000000..5c2bbd7 --- /dev/null +++ b/api/app/Controllers/ApiController.php @@ -0,0 +1,47 @@ +bearer(); + if ($token === '') { + $this->json(null, 401, ['code' => 'unauthorized', 'message' => 'Missing bearer token']); + } + // First-party admin token (constant-time compare). + if (defined('ADMIN_TOKEN') && hash_equals(ADMIN_TOKEN, $token)) { + if ($this->throttle('admin', $max, $window)) { + $this->json(null, 429, ['code' => 'rate_limited', 'message' => 'Too many requests']); + } + return; + } + // Programmatic api_auth key. + $row = \Db::getRow( + "SELECT `id`, `userid`, `active` FROM `api_auth` WHERE `apikey` = ? LIMIT 1", + [$token] + ); + if (!$row || (int) $row['active'] !== 1) { + $this->json(null, 401, ['code' => 'unauthorized', 'message' => 'Invalid API key']); + } + $this->apiUser = $row; + if ($this->throttle('apikey:' . $row['id'], $max, $window)) { + $this->json(null, 429, ['code' => 'rate_limited', 'message' => 'Too many requests']); + } + // NOTE: monthly/plan quota enforcement (api_plans/api_usage) is deferred to a later sub-project. + } +} diff --git a/api/app/Controllers/JsonController.php b/api/app/Controllers/JsonController.php new file mode 100644 index 0000000..f5b74d7 --- /dev/null +++ b/api/app/Controllers/JsonController.php @@ -0,0 +1,42 @@ + $error === null, + 'data' => $data, + 'error' => $error, // ['code' => ..., 'message' => ...] or null + ]); + exit; + } + + /** + * Returns true when the caller has EXCEEDED $max hits on $key within $window seconds. + * Reuses the api_requests table (no new table needed). + */ + protected function throttle(string $key, int $max, int $window): bool + { + $ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; + $count = (int) \Db::getValue( + "SELECT COUNT(*) FROM `api_requests` + WHERE `requesting_ip` = ? AND `request` = ? + AND `created_date` > (NOW() - INTERVAL ? SECOND)", + [$ip, $key, $window] + ); + \Db::insert('api_requests', [ + 'requesting_ip' => $ip, + 'request' => $key, + 'service' => 'foundation', + 'domainURI' => $_SERVER['HTTP_HOST'] ?? '', + 'created_date' => date('Y-m-d H:i:s'), + ]); + return $count >= $max; + } +} diff --git a/api/app/Controllers/PublicController.php b/api/app/Controllers/PublicController.php new file mode 100644 index 0000000..3df172b --- /dev/null +++ b/api/app/Controllers/PublicController.php @@ -0,0 +1,42 @@ + false. + * Same-origin GET / non-browser callers omit Origin -> allowed. + */ + protected function checkOrigin(): bool + { + $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; + if ($origin === '') { + return true; // same-origin GET or server-side caller + } + $allowed = array_filter(array_map('trim', explode(',', defined('ALLOWED_ORIGINS') ? ALLOWED_ORIGINS : ''))); + if (empty($allowed)) { + return true; // not configured (dev) + } + $originHost = parse_url($origin, PHP_URL_HOST); + foreach ($allowed as $a) { + $host = parse_url($a, PHP_URL_HOST) ?: $a; + if ($originHost && strcasecmp($originHost, $host) === 0) { + header('Access-Control-Allow-Origin: ' . $origin); + return true; + } + } + return false; + } + + /** Guard helper: enforce origin + rate limit, or emit the error envelope and stop. */ + protected function guardPublic(string $key, int $max = 60, int $window = 60): void + { + if (!$this->checkOrigin()) { + $this->json(null, 403, ['code' => 'forbidden_origin', 'message' => 'Origin not allowed']); + } + if ($this->throttle($key, $max, $window)) { + $this->json(null, 429, ['code' => 'rate_limited', 'message' => 'Too many requests']); + } + } +} diff --git a/api/app/Services/Installer.php b/api/app/Services/Installer.php new file mode 100644 index 0000000..5ce354e --- /dev/null +++ b/api/app/Services/Installer.php @@ -0,0 +1,132 @@ + up two levels -> api/ + $this->baseDir = dirname(__DIR__, 2); + } + + public function lockFile(): string + { + return $this->baseDir . '/system/.installed'; + } + + public function isInstalled(): bool + { + return is_file($this->lockFile()); + } + + public function testConnection(array $db): bool + { + try { + new PDO( + "mysql:host={$db['host']};dbname={$db['name']};charset=utf8mb4", + $db['user'], $db['pass'], + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] + ); + return true; + } catch (PDOException $e) { + return false; + } + } + + public function importSchema(array $db): void + { + $sqlFile = $this->baseDir . '/install/dump.sql'; + if (!is_file($sqlFile)) { + throw new RuntimeException("Schema dump not found: {$sqlFile}"); + } + $mysqli = new \mysqli($db['host'], $db['user'], $db['pass'], $db['name']); + if ($mysqli->connect_errno) { + throw new RuntimeException('DB connect failed: ' . $mysqli->connect_error); + } + if (!$mysqli->multi_query((string) file_get_contents($sqlFile))) { + throw new RuntimeException('Schema import failed: ' . $mysqli->error); + } + // Drain all result sets so the connection finishes cleanly. + while ($mysqli->more_results() && $mysqli->next_result()) { /* noop */ } + if ($mysqli->errno) { + throw new RuntimeException('Schema import error: ' . $mysqli->error); + } + $mysqli->close(); + } + + public function generateKey(int $bytes = 32): string + { + return bin2hex(random_bytes($bytes)); // hex only: safe inside single-quoted PHP + } + + public function writeConfig(array $c): void + { + $e = fn($v) => addslashes((string) $v); // escape operator-provided values + $tpl = "baseDir . '/config.php', $tpl); + } + + public function lock(): void + { + file_put_contents($this->lockFile(), date('c') . "\n"); + } + + /** + * Full install. $cfg = ['url','name','db'=>['host','name','user','pass']]. + */ + public function run(array $cfg): void + { + if (!$this->testConnection($cfg['db'])) { + throw new RuntimeException('Database connection failed. Check credentials.'); + } + $this->importSchema($cfg['db']); + $cfg['admin_token'] = $this->generateKey(24); + $cfg['hash_password_key'] = $this->generateKey(32); + $cfg['hash_api_key'] = $this->generateKey(32); + $this->writeConfig($cfg); + $this->lock(); + + // Apply migrations on top of the freshly imported baseline. + $pdo = new PDO( + "mysql:host={$cfg['db']['host']};dbname={$cfg['db']['name']};charset=utf8mb4", + $cfg['db']['user'], $cfg['db']['pass'], + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] + ); + (new Migrator($pdo, $this->baseDir . '/db/migrations'))->migrate(); + } +} diff --git a/api/app/Services/Migrator.php b/api/app/Services/Migrator.php new file mode 100644 index 0000000..df397e9 --- /dev/null +++ b/api/app/Services/Migrator.php @@ -0,0 +1,57 @@ +pdo = $pdo; + $this->dir = $dir; + } + + public function ensureTable(): void + { + $this->pdo->exec( + "CREATE TABLE IF NOT EXISTS `migrations` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `filename` VARCHAR(255) NOT NULL UNIQUE, + `applied_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + } + + /** @return string[] filenames already applied */ + public function applied(): array + { + return $this->pdo->query("SELECT filename FROM `migrations`")->fetchAll(PDO::FETCH_COLUMN) ?: []; + } + + /** @return string[] absolute paths of pending migrations, in order */ + public function pending(): array + { + $all = glob($this->dir . '/*.sql') ?: []; + sort($all); + $applied = $this->applied(); + return array_values(array_filter($all, fn($p) => !in_array(basename($p), $applied, true))); + } + + /** @return string[] filenames applied this run */ + public function migrate(): array + { + $this->ensureTable(); + $done = []; + foreach ($this->pending() as $path) { + $this->pdo->exec((string) file_get_contents($path)); + $stmt = $this->pdo->prepare("INSERT IGNORE INTO `migrations` (filename) VALUES (?)"); + $stmt->execute([basename($path)]); + $done[] = basename($path); + } + return $done; + } +} diff --git a/api/commands/InstallCommand.php b/api/commands/InstallCommand.php new file mode 100644 index 0000000..bc87da6 --- /dev/null +++ b/api/commands/InstallCommand.php @@ -0,0 +1,54 @@ +setName('app:install') + ->setDescription('Install the framework: import schema, write config, lock.') + ->addOption('db-host', null, InputOption::VALUE_REQUIRED, 'DB host', 'localhost') + ->addOption('db-name', null, InputOption::VALUE_REQUIRED, 'DB name') + ->addOption('db-user', null, InputOption::VALUE_REQUIRED, 'DB user') + ->addOption('db-pass', null, InputOption::VALUE_REQUIRED, 'DB password') + ->addOption('url', null, InputOption::VALUE_REQUIRED, 'Site URL', 'https://example.com') + ->addOption('name', null, InputOption::VALUE_REQUIRED, 'Project name', 'SeedProject') + ->addOption('force', null, InputOption::VALUE_NONE, 'Re-run even if already installed'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $installer = new Installer(); + if ($installer->isInstalled() && !$input->getOption('force')) { + $output->writeln('Already installed. Use --force to re-run.'); + return Command::FAILURE; + } + foreach (['db-name', 'db-user', 'db-pass'] as $req) { + if (!$input->getOption($req)) { + $output->writeln("--{$req} is required."); + return Command::FAILURE; + } + } + try { + $installer->run([ + 'url' => $input->getOption('url'), + 'name' => $input->getOption('name'), + 'db' => [ + 'host' => $input->getOption('db-host'), + 'name' => $input->getOption('db-name'), + 'user' => $input->getOption('db-user'), + 'pass' => $input->getOption('db-pass'), + ], + ]); + } catch (\Throwable $e) { + $output->writeln('Install failed: ' . $e->getMessage() . ''); + return Command::FAILURE; + } + $output->writeln('Install complete. config.php written, schema imported, lock set.'); + return Command::SUCCESS; + } +} diff --git a/api/commands/MigrateCommand.php b/api/commands/MigrateCommand.php new file mode 100644 index 0000000..0da276d --- /dev/null +++ b/api/commands/MigrateCommand.php @@ -0,0 +1,42 @@ +setName('db:migrate') + ->setDescription('Apply pending SQL migrations from db/migrations/') + ->addOption('status', null, InputOption::VALUE_NONE, 'Show applied/pending without applying'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $base = dirname(__DIR__); // api/ + if (!is_file($base . '/config.php')) { + $output->writeln('config.php missing — run app:install first.'); + return Command::FAILURE; + } + require_once $base . '/config.php'; // defines DB_* constants + $pdo = new PDO( + 'mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=utf8mb4', + DB_USER, DB_PASS, + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] + ); + $migrator = new Migrator($pdo, $base . '/db/migrations'); + $migrator->ensureTable(); + + if ($input->getOption('status')) { + $output->writeln('Applied: ' . (implode(', ', $migrator->applied()) ?: '(none)')); + $output->writeln('Pending: ' . (implode(', ', array_map('basename', $migrator->pending())) ?: '(none)')); + return Command::SUCCESS; + } + $done = $migrator->migrate(); + $output->writeln($done ? 'Applied: ' . implode(', ', $done) : 'Nothing to migrate.'); + return Command::SUCCESS; + } +} diff --git a/api/composer.json b/api/composer.json index cc94fe8..0778a06 100644 --- a/api/composer.json +++ b/api/composer.json @@ -28,6 +28,7 @@ }, "autoload": { "classmap": [ + "commands", "core/", "app/Helpers", "app/Controllers", diff --git a/api/console b/api/console index ee7f910..24b68dd 100644 --- a/api/console +++ b/api/console @@ -11,7 +11,9 @@ use Symfony\Component\Console\Application; $application = new Application(); # add our commands -$application->add(new GreetCommand()); +$application->add(new GreetCommand()); +$application->add(new InstallCommand()); +$application->add(new MigrateCommand()); //$application->add(new Sentinel()); //$application->add(new Engine()); $application->run(); \ No newline at end of file diff --git a/api/db/migrations/001_create_metrics_placeholder.sql b/api/db/migrations/001_create_metrics_placeholder.sql new file mode 100644 index 0000000..246a7a4 --- /dev/null +++ b/api/db/migrations/001_create_metrics_placeholder.sql @@ -0,0 +1,7 @@ +-- Foundation smoke migration: proves the runner end-to-end. +-- (Real metrics tables land in the Metrics sub-project.) +CREATE TABLE IF NOT EXISTS `_foundation_check` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `note` VARCHAR(64) NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/api/install/controllers/index.php b/api/install/controllers/index.php index d3898bb..43f0dcc 100644 --- a/api/install/controllers/index.php +++ b/api/install/controllers/index.php @@ -1,139 +1,69 @@ -view->render(__CLASS__ .'/'. __FUNCTION__); - } - - function checkDB(){ - // \Helper::print_array($_POST); - - $host = $_POST['dbloca']; - $user = $_POST['dbuser']; - $pass = $_POST['dbpass']; - $db = $_POST['dbname']; - $charset = 'utf8'; - - - $dsn = "mysql:host=$host;dbname=$db;charset=$charset"; - $options = [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_LAZY, - PDO::ATTR_EMULATE_PREPARES => false, - ]; - - try { - $pdo = new PDO($dsn, $user, $pass, $options); - echo 1; - } catch (\PDOException $e) { - echo 0; - } - } - - function installation() { - // \Helper::print_array($_POST); - if(!$_POST) { - header("Location: /install/"); - die(); - }; - - $dbLoca = $_POST['dbloca']; - $dbName = $_POST['dbname']; - $dbUser = $_POST['dbuser']; - $dbPass = $_POST['dbpass']; - - - # Step 1: building out database structure - $SQLFile = $_SERVER['DOCUMENT_ROOT'] . '/install/dump.sql'; - $ConfigFile = $_SERVER['DOCUMENT_ROOT'] . '/config.php'; - - - $HashKey = $this->getName(50); - $HashAPIKey = $this->getName(50); - - - - if(!file_exists($SQLFile) ){ - die('Error: Failed to Load SQL Dump File. '); - } - - $sql = file_get_contents($SQLFile); - $mysqli = new mysqli($dbLoca, $dbUser, $dbPass, $dbName); - - /* check connection */ - if ($mysqli->connect_errno) { - printf("Connect failed: %s\n", $mysqli->connect_error); - exit(); - } - - if (!$mysqli->multi_query($sql)) { - printf("Error message: %s\n", $mysqli->error); - }; - - /* close connection */ - $mysqli->close(); - - # Step 2: Create the Config file. - // if(file_exists($ConfigFile)) { die ('File Currently Exist. Please Delete config.php; if you are trying to do a new install.'); } - - $myfile = fopen($ConfigFile, "w") or die("Unable to Write or Open file, Please check your permissions!"); - fwrite($myfile, ''); - fclose($myfile); - - - $config_content = - <<view->render(__CLASS__ .'/'. __FUNCTION__); + } + + // AJAX: test the supplied DB credentials (echo 1 = ok, 0 = fail). + function checkDB(){ + $host = $_POST['dbloca']; + $user = $_POST['dbuser']; + $pass = $_POST['dbpass']; + $db = $_POST['dbname']; + + $dsn = "mysql:host=$host;dbname=$db;charset=utf8mb4"; + $options = [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_LAZY, + PDO::ATTR_EMULATE_PREPARES => false, + ]; + + try { + new PDO($dsn, $user, $pass, $options); + echo 1; + } catch (\PDOException $e) { + echo 0; + } + } + + // Run the install by delegating to the shared Installer service. + // Refuses once installed; writes the correct /api config (no DOCUMENT_ROOT path bugs). + function installation() { + require_once dirname(__DIR__, 2) . '/vendor/autoload.php'; + + $installer = new \App\Services\Installer(); + if ($installer->isInstalled()) { + http_response_code(403); + die('Already installed. Remove api/system/.installed to reinstall.'); + } + if (!$_POST) { + header('Location: /api/install/'); + die(); + } + + try { + $installer->run([ + 'url' => 'https://example.com', + 'name' => 'SeedProject', + 'db' => [ + 'host' => $_POST['dbloca'], + 'name' => $_POST['dbname'], + 'user' => $_POST['dbuser'], + 'pass' => $_POST['dbpass'], + ], + ]); + } catch (\Throwable $e) { + http_response_code(500); + die('Install failed: ' . htmlspecialchars($e->getMessage())); + } + + header('Location: /api/install/i/complete'); + } + +} diff --git a/app/src/pages/api-health-test.astro b/app/src/pages/api-health-test.astro new file mode 100644 index 0000000..33053aa --- /dev/null +++ b/app/src/pages/api-health-test.astro @@ -0,0 +1,18 @@ +--- +// Static page; the fetch runs client-side, same-origin, against the PHP API. +// A quick way to confirm the /api backend is wired up on a deployed site. +--- + + + API health test + +

SeedProject /api health

+
loading…
+ + +