seedproject-web/api/app/Controllers/ApiController.php
Carlos Arias 1fdfc3174b feat(api): Foundation — installer, migrations, two-tier auth, health round-trip
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYHWLHihq3v9nxNwoPCKSn
2026-07-04 23:23:51 +00:00

47 lines
1.9 KiB
PHP

<?php
namespace App\Controllers;
/** Base for privileged endpoints: bearer ADMIN_TOKEN or api_auth API key. */
class ApiController extends JsonController
{
protected ?array $apiUser = null; // set when an api_auth key authenticates
/** Extract a token from Authorization: Bearer, ?apikey=, or X-Api-Key. */
private function bearer(): string
{
$h = $_SERVER['HTTP_AUTHORIZATION'] ?? ($_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '');
if (stripos($h, 'Bearer ') === 0) {
return trim(substr($h, 7));
}
return $_SERVER['HTTP_X_API_KEY'] ?? ($_GET['apikey'] ?? '');
}
/** Enforce authentication; emits 401 and stops on failure. Also basic per-minute throttle. */
protected function requireAuth(int $max = 120, int $window = 60): void
{
$token = $this->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.
}
}