Clean-room copy of the reusable engines from comiida, with all instance data, secrets, dependencies, and build output excluded: - app/ Astro theme skeleton (no comiida blog posts; hero image -> placeholder) - api/ SeedProject PHP framework (no vendor/.env/config.php) - content-pipeline/ engine only (scripts/admin/prompts; empty runtime state) - astroagent.config.json + app/.astroagent/skills Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SYHWLHihq3v9nxNwoPCKSn
850 lines
31 KiB
Markdown
850 lines
31 KiB
Markdown
# Foundation Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Turn the SeedProject `api/` into an installable, DB-backed, two-tier-secured backend that a static Astro site calls same-origin, proven by a live health round-trip.
|
|
|
|
**Architecture:** A CLI installer provisions config + schema; a migrations runner evolves it; two namespaced base controllers (`PublicController`, `ApiController`) enforce a public (origin + rate-limit) and privileged (bearer / `api_auth` key) tier over a shared JSON envelope; a `/api/health` endpoint queries MariaDB and returns JSON, called from an Astro page.
|
|
|
|
**Tech Stack:** PHP 8.3, Symfony Console 5.4, MariaDB 10.11 (PDO/`Db` facade), Apache + php-fpm, Astro (static). No PHPUnit in this repo — verification is done with `php console`, `curl`, and `mysql` commands.
|
|
|
|
**Spec:** `api/.memory/foundation.md`. **Conventions:** [documentation.md](documentation.md), [commands.md](../commands/commands.md).
|
|
|
|
**Autoload note:** New classes use PSR-4 — `App\Services\*` → `app/Services/*.php`, `App\Controllers\*` → `app/Controllers/*.php` (composer.json maps `App\` → `app`). No `composer dump-autoload` needed for these. URL controllers stay global in `public/controllers/` (Bootstrap `require`s them) and `use` the namespaced bases. Core files (`core/*`) are never modified.
|
|
|
|
---
|
|
|
|
## File structure
|
|
|
|
**Create**
|
|
- `app/Services/Installer.php` — `App\Services\Installer`: test DB, import `dump.sql`, generate keys, write `config.php`, write install lock.
|
|
- `app/Services/Migrator.php` — `App\Services\Migrator`: ensure `migrations` table, list/apply `db/migrations/*.sql`.
|
|
- `commands/InstallCommand.php` — `php console app:install`.
|
|
- `commands/MigrateCommand.php` — `php console db:migrate [--status]`.
|
|
- `app/Controllers/JsonController.php` — `App\Controllers\JsonController extends \Controller`: `json()` envelope + `throttle()`.
|
|
- `app/Controllers/PublicController.php` — `App\Controllers\PublicController extends JsonController`: `checkOrigin()`.
|
|
- `app/Controllers/ApiController.php` — `App\Controllers\ApiController extends JsonController`: `authenticate()` (bearer/`api_auth`).
|
|
- `public/controllers/health.php` — global `Health extends App\Controllers\PublicController`.
|
|
- `public/controllers/admin.php` — global `Admin extends App\Controllers\ApiController`.
|
|
- `db/migrations/001_create_metrics_placeholder.sql` — a trivial first migration to prove the runner.
|
|
- `app/src/pages/api-health-test.astro` — client-side round-trip proof page.
|
|
|
|
**Modify**
|
|
- `console` — register `InstallCommand`, `MigrateCommand`.
|
|
- `install/controllers/index.php` — delegate to `Installer`; refuse if locked.
|
|
- Apache vhost `/www/server/panel/vhost/apache/comiida.com.conf` — deny `install/`, `db/`, `.installed`.
|
|
|
|
**Server (outside repo)**
|
|
- MariaDB `comiida` database + user.
|
|
|
|
---
|
|
|
|
## Task 1: Provision the database
|
|
|
|
**Files:** none (server state).
|
|
|
|
- [ ] **Step 1: Create the database and a dedicated user**
|
|
|
|
Operator supplies the MariaDB root password. Choose a strong `APP_DB_PASS`.
|
|
|
|
Run:
|
|
```bash
|
|
mysql -u root -p -e "
|
|
CREATE DATABASE IF NOT EXISTS comiida CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
|
CREATE USER IF NOT EXISTS 'comiida'@'localhost' IDENTIFIED BY 'APP_DB_PASS';
|
|
GRANT ALL PRIVILEGES ON comiida.* TO 'comiida'@'localhost';
|
|
FLUSH PRIVILEGES;"
|
|
```
|
|
|
|
- [ ] **Step 2: Verify the user can connect to the empty DB**
|
|
|
|
Run: `mysql -u comiida -p'APP_DB_PASS' comiida -e "SELECT DATABASE();"`
|
|
Expected: prints `comiida`, no error.
|
|
|
|
- [ ] **Step 3: No commit** (server state, nothing in repo).
|
|
|
|
---
|
|
|
|
## Task 2: Installer service + `app:install` command
|
|
|
|
**Files:**
|
|
- Create: `app/Services/Installer.php`
|
|
- Create: `commands/InstallCommand.php`
|
|
- Modify: `console`
|
|
|
|
- [ ] **Step 1: Write `app/Services/Installer.php`**
|
|
|
|
```php
|
|
<?php
|
|
namespace App\Services;
|
|
|
|
use PDO;
|
|
use PDOException;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* Installer — provisions config.php + imports the skeleton schema.
|
|
* Shared by the CLI (app:install) and the (locked) web wizard.
|
|
* Uses filesystem-relative paths, never $_SERVER['DOCUMENT_ROOT'].
|
|
*/
|
|
class Installer
|
|
{
|
|
private string $baseDir; // == api/
|
|
|
|
public function __construct()
|
|
{
|
|
// app/Services/Installer.php -> 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 = "<?php\n"
|
|
. "// Generated by SeedProject installer. DO NOT COMMIT (gitignored).\n"
|
|
. "define('URL', '" . $e($c['url']) . "');\n"
|
|
. "define('SITE_BASE', '/api');\n"
|
|
. "define('ASSETS', '/api/public/assets/');\n"
|
|
. "define('LIBS', 'core/');\n"
|
|
. "define('PROJECT_NAME', '" . $e($c['name']) . "');\n"
|
|
. "define('PROJECT_LOGO', '/api/public/assets/imgs/SeedProject.png');\n"
|
|
. "define('DEBUG', false);\n"
|
|
. "define('SECUREAPI', false);\n\n"
|
|
. "define('ADMIN_TOKEN', '" . $c['admin_token'] . "');\n"
|
|
. "define('ALLOWED_ORIGINS', '" . $e($c['url']) . "');\n\n"
|
|
. "define('EMAILUSER', 'ADDEMAILUSER');\n"
|
|
. "define('EMAILPASSWORD', 'ADDEMAILPASSWORD');\n"
|
|
. "define('EMAILHOST', 'ADDEMAILHOST');\n\n"
|
|
. "define('DB_TYPE', 'mysql');\n"
|
|
. "define('DB_HOST', '" . $e($c['db']['host']) . "');\n"
|
|
. "define('DB_NAME', '" . $e($c['db']['name']) . "');\n"
|
|
. "define('DB_USER', '" . $e($c['db']['user']) . "');\n"
|
|
. "define('DB_PASS', '" . $e($c['db']['pass']) . "');\n\n"
|
|
. "define('HASH_PASSWORD_KEY', '" . $c['hash_password_key'] . "');\n"
|
|
. "define('HASH_API_KEY', '" . $c['hash_api_key'] . "');\n"
|
|
. "define('TIMESTAMP', date('Y-m-d H:i:s'));\n"
|
|
. "date_default_timezone_set('America/New_York');\n\n"
|
|
. "\\Db::setConnectionInfo(DB_TYPE, DB_NAME, DB_USER, DB_PASS, DB_HOST);\n";
|
|
file_put_contents($this->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();
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Write `commands/InstallCommand.php`**
|
|
|
|
```php
|
|
<?php
|
|
use Symfony\Component\Console\Command\Command;
|
|
use Symfony\Component\Console\Input\InputInterface;
|
|
use Symfony\Component\Console\Input\InputOption;
|
|
use Symfony\Component\Console\Output\OutputInterface;
|
|
use App\Services\Installer;
|
|
|
|
class InstallCommand extends Command
|
|
{
|
|
protected function configure()
|
|
{
|
|
$this->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://www.comiida.com')
|
|
->addOption('name', null, InputOption::VALUE_REQUIRED, 'Project name', 'Comiida')
|
|
->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('<error>Already installed. Use --force to re-run.</error>');
|
|
return Command::FAILURE;
|
|
}
|
|
foreach (['db-name', 'db-user', 'db-pass'] as $req) {
|
|
if (!$input->getOption($req)) {
|
|
$output->writeln("<error>--{$req} is required.</error>");
|
|
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('<error>Install failed: ' . $e->getMessage() . '</error>');
|
|
return Command::FAILURE;
|
|
}
|
|
$output->writeln('<info>Install complete. config.php written, schema imported, lock set.</info>');
|
|
return Command::SUCCESS;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Register the command in `console`**
|
|
|
|
In `console`, add the `use` and registration (below the existing `GreetCommand` line):
|
|
|
|
```php
|
|
$application->add(new GreetCommand());
|
|
$application->add(new InstallCommand());
|
|
```
|
|
|
|
- [ ] **Step 4: Verify the command is discoverable**
|
|
|
|
Run: `php console list | grep app:install`
|
|
Expected: a line `app:install Install the framework...`
|
|
|
|
- [ ] **Step 5: Run the installer against the comiida DB**
|
|
|
|
Run (operator fills `APP_DB_PASS`):
|
|
```bash
|
|
php console app:install --db-host=localhost --db-name=comiida --db-user=comiida --db-pass='APP_DB_PASS' --url=https://www.comiida.com --name=Comiida
|
|
```
|
|
Expected: `Install complete...`
|
|
|
|
- [ ] **Step 6: Verify schema, config, and lock**
|
|
|
|
Run:
|
|
```bash
|
|
mysql -u comiida -p'APP_DB_PASS' comiida -e "SHOW TABLES;" | grep -E "api_auth|config|users"
|
|
php -r "require 'config.php'; echo DB_NAME.PHP_EOL; echo (strlen(ADMIN_TOKEN)>=48?'token-ok':'token-bad').PHP_EOL;"
|
|
test -f system/.installed && echo "locked"
|
|
```
|
|
Expected: tables listed; `comiida`, `token-ok`, `locked`.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add app/Services/Installer.php commands/InstallCommand.php console
|
|
git commit -m "feat(api): CLI installer (app:install) — schema import, config, lock"
|
|
```
|
|
(Note: `config.php` and `system/.installed` are gitignored / not tracked.)
|
|
|
|
---
|
|
|
|
## Task 3: Migrations runner + `db:migrate`
|
|
|
|
**Files:**
|
|
- Create: `app/Services/Migrator.php`
|
|
- Create: `commands/MigrateCommand.php`
|
|
- Create: `db/migrations/001_create_metrics_placeholder.sql`
|
|
- Modify: `console`, `app/Services/Installer.php`
|
|
|
|
- [ ] **Step 1: Write `app/Services/Migrator.php`**
|
|
|
|
```php
|
|
<?php
|
|
namespace App\Services;
|
|
|
|
use PDO;
|
|
|
|
/** Applies ordered *.sql files from a migrations dir, tracked in a `migrations` table. */
|
|
class Migrator
|
|
{
|
|
private PDO $pdo;
|
|
private string $dir;
|
|
|
|
public function __construct(PDO $pdo, string $dir)
|
|
{
|
|
$this->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;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Write the first migration `db/migrations/001_create_metrics_placeholder.sql`**
|
|
|
|
```sql
|
|
-- 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;
|
|
```
|
|
|
|
- [ ] **Step 3: Write `commands/MigrateCommand.php`**
|
|
|
|
```php
|
|
<?php
|
|
use Symfony\Component\Console\Command\Command;
|
|
use Symfony\Component\Console\Input\InputInterface;
|
|
use Symfony\Component\Console\Input\InputOption;
|
|
use Symfony\Component\Console\Output\OutputInterface;
|
|
use App\Services\Migrator;
|
|
|
|
class MigrateCommand extends Command
|
|
{
|
|
protected function configure()
|
|
{
|
|
$this->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('<error>config.php missing — run app:install first.</error>');
|
|
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;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Register in `console`**
|
|
|
|
```php
|
|
$application->add(new InstallCommand());
|
|
$application->add(new MigrateCommand());
|
|
```
|
|
|
|
- [ ] **Step 5: Wire migrations into the installer**
|
|
|
|
In `app/Services/Installer.php`, at the end of `run()` (after `$this->lock();`), append:
|
|
|
|
```php
|
|
// 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();
|
|
```
|
|
|
|
Add `use PDO;` is already present; no new import needed (`Migrator` is same namespace).
|
|
|
|
- [ ] **Step 6: Verify migration status then apply**
|
|
|
|
Run:
|
|
```bash
|
|
php console db:migrate --status
|
|
php console db:migrate
|
|
mysql -u comiida -p'APP_DB_PASS' comiida -e "SELECT filename FROM migrations;"
|
|
```
|
|
Expected: status shows `001_...` pending → apply prints `Applied: 001_create_metrics_placeholder.sql` → the `migrations` table lists it. (`_foundation_check` table now exists.)
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add app/Services/Migrator.php commands/MigrateCommand.php db/migrations/001_create_metrics_placeholder.sql app/Services/Installer.php console
|
|
git commit -m "feat(api): SQL migrations runner (db:migrate) + wire into installer"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: JSON base + public tier + `/api/health`
|
|
|
|
**Files:**
|
|
- Create: `app/Controllers/JsonController.php`
|
|
- Create: `app/Controllers/PublicController.php`
|
|
- Create: `public/controllers/health.php`
|
|
|
|
- [ ] **Step 1: Write `app/Controllers/JsonController.php`**
|
|
|
|
```php
|
|
<?php
|
|
namespace App\Controllers;
|
|
|
|
/** Base for JSON endpoints: response envelope + IP throttle. Extends core \Controller. */
|
|
class JsonController extends \Controller
|
|
{
|
|
/** Emit { ok, data, error } with the right HTTP status, then stop. */
|
|
protected function json($data = null, int $status = 200, ?array $error = null): void
|
|
{
|
|
http_response_code($status);
|
|
header('Content-Type: application/json; charset=UTF-8');
|
|
echo json_encode([
|
|
'ok' => $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;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Write `app/Controllers/PublicController.php`**
|
|
|
|
```php
|
|
<?php
|
|
namespace App\Controllers;
|
|
|
|
/** Base for public (browser-callable) endpoints: origin allowlist + rate limit. */
|
|
class PublicController extends JsonController
|
|
{
|
|
/**
|
|
* Cross-origin browser requests send Origin; if present and not allowlisted -> 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']);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Write `public/controllers/health.php`**
|
|
|
|
```php
|
|
<?php
|
|
use App\Controllers\PublicController;
|
|
|
|
/** GET /api/health — public health round-trip (proves DB connectivity). */
|
|
class Health extends PublicController
|
|
{
|
|
public function index()
|
|
{
|
|
$this->guardPublic('health', 120, 60);
|
|
try {
|
|
$up = (int) \Db::getValue("SELECT 1");
|
|
$rows = (int) \Db::getValue("SELECT COUNT(*) FROM `config`");
|
|
} catch (\Throwable $e) {
|
|
$this->json(null, 500, ['code' => 'db_error', 'message' => DEBUG ? $e->getMessage() : 'Database unavailable']);
|
|
}
|
|
$this->json([
|
|
'db' => $up === 1 ? 'connected' : 'unknown',
|
|
'app' => defined('PROJECT_NAME') ? PROJECT_NAME : '',
|
|
'config_rows' => $rows,
|
|
'time' => date('c'),
|
|
]);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Verify the health endpoint returns live DB JSON**
|
|
|
|
Run: `curl -sk https://127.0.0.1/api/health -H "Host: www.comiida.com"`
|
|
Expected: `{"ok":true,"data":{"db":"connected","app":"Comiida","config_rows":<n>,"time":"..."},"error":null}`
|
|
|
|
- [ ] **Step 5: Verify rate limiting**
|
|
|
|
Run: `for i in $(seq 1 130); do curl -s -o /dev/null -w "%{http_code} " -k https://127.0.0.1/api/health -H "Host: www.comiida.com"; done; echo`
|
|
Expected: `200` responses turning into `429` after ~120 within the minute.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add app/Controllers/JsonController.php app/Controllers/PublicController.php public/controllers/health.php
|
|
git commit -m "feat(api): JSON base + public tier (origin+rate-limit) + /api/health"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Privileged tier + `/api/admin/ping`
|
|
|
|
**Files:**
|
|
- Create: `app/Controllers/ApiController.php`
|
|
- Create: `public/controllers/admin.php`
|
|
|
|
- [ ] **Step 1: Write `app/Controllers/ApiController.php`**
|
|
|
|
```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.
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Write `public/controllers/admin.php`**
|
|
|
|
```php
|
|
<?php
|
|
use App\Controllers\ApiController;
|
|
|
|
/** GET /api/admin/ping — privileged round-trip. */
|
|
class Admin extends ApiController
|
|
{
|
|
public function ping()
|
|
{
|
|
$this->requireAuth();
|
|
$this->json([
|
|
'pong' => true,
|
|
'auth' => $this->apiUser ? 'apikey' : 'admin_token',
|
|
'time' => date('c'),
|
|
]);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Verify unauthorized is rejected**
|
|
|
|
Run: `curl -sk -o /dev/null -w "%{http_code}\n" https://127.0.0.1/api/admin/ping -H "Host: www.comiida.com"`
|
|
Expected: `401`
|
|
|
|
- [ ] **Step 4: Verify the admin token authenticates**
|
|
|
|
Run:
|
|
```bash
|
|
TOKEN=$(php -r "require 'config.php'; echo ADMIN_TOKEN;")
|
|
curl -sk https://127.0.0.1/api/admin/ping -H "Host: www.comiida.com" -H "Authorization: Bearer $TOKEN"
|
|
```
|
|
Expected: `{"ok":true,"data":{"pong":true,"auth":"admin_token","time":"..."},"error":null}`
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add app/Controllers/ApiController.php public/controllers/admin.php
|
|
git commit -m "feat(api): privileged tier (bearer/api_auth) + /api/admin/ping"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Security hardening — lock the installer
|
|
|
|
**Files:**
|
|
- Modify: `install/controllers/index.php`
|
|
- Modify: Apache vhost `/www/server/panel/vhost/apache/comiida.com.conf`
|
|
|
|
- [ ] **Step 1: Make the web installer refuse when locked & delegate to `Installer`**
|
|
|
|
At the top of `installation()` in `install/controllers/index.php`, before any work, insert:
|
|
|
|
```php
|
|
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
|
|
$installer = new \App\Services\Installer();
|
|
if ($installer->isInstalled()) {
|
|
http_response_code(403);
|
|
die('Already installed. Remove system/.installed to reinstall.');
|
|
}
|
|
if (!$_POST) { header('Location: /api/install/'); die(); }
|
|
$installer->run([
|
|
'url' => 'https://www.comiida.com',
|
|
'name' => 'Comiida',
|
|
'db' => [
|
|
'host' => $_POST['dbloca'],
|
|
'name' => $_POST['dbname'],
|
|
'user' => $_POST['dbuser'],
|
|
'pass' => $_POST['dbpass'],
|
|
],
|
|
]);
|
|
header('Location: /api/install/i/complete');
|
|
return;
|
|
```
|
|
|
|
(This replaces the old body that used `$_SERVER['DOCUMENT_ROOT']` and wrote a `seedproject.com` config. The rest of the method below can be removed.)
|
|
|
|
- [ ] **Step 2: Extend the Apache denies**
|
|
|
|
In `/www/server/panel/vhost/apache/comiida.com.conf`, update the existing `DirectoryMatch` (added when `/api` was mounted) to also cover `install`, `db`, and add a `.installed` file deny. Replace the block with:
|
|
|
|
```apache
|
|
# Deny web access to framework internals (loaded server-side only)
|
|
<DirectoryMatch "^/www/wwwroot/comiida\.com/api/(vendor|core|app|system|commands|db|install|\.memory|\.reference_files)(/|$)">
|
|
Require all denied
|
|
</DirectoryMatch>
|
|
<Files ".installed">
|
|
Require all denied
|
|
</Files>
|
|
```
|
|
|
|
- [ ] **Step 3: Apply and reload**
|
|
|
|
Run: `/www/server/apache/bin/httpd -t && /www/server/apache/bin/httpd -k graceful`
|
|
Expected: `Syntax OK`, reload succeeds.
|
|
|
|
- [ ] **Step 4: Verify the installer is no longer web-reachable**
|
|
|
|
Run: `curl -sk -o /dev/null -w "%{http_code}\n" https://127.0.0.1/api/install/ -H "Host: www.comiida.com"`
|
|
Expected: `403`
|
|
|
|
- [ ] **Step 5: Verify `/api/health` still works (denies didn't over-reach)**
|
|
|
|
Run: `curl -sk -o /dev/null -w "%{http_code}\n" https://127.0.0.1/api/health -H "Host: www.comiida.com"`
|
|
Expected: `200`
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add install/controllers/index.php
|
|
git commit -m "feat(api): lock installer, delegate to Installer service, deny install/db over HTTP"
|
|
```
|
|
(Apache vhost is outside the repo — not committed.)
|
|
|
|
---
|
|
|
|
## Task 7: Astro round-trip proof page
|
|
|
|
**Files:**
|
|
- Create: `app/src/pages/api-health-test.astro`
|
|
|
|
- [ ] **Step 1: Write the test page**
|
|
|
|
```astro
|
|
---
|
|
// Static page; the fetch runs client-side, same-origin, against the PHP API.
|
|
---
|
|
<html lang="en">
|
|
<head><meta charset="utf-8" /><title>API health test</title></head>
|
|
<body style="font-family: system-ui; padding: 2rem;">
|
|
<h1>SeedProject /api health</h1>
|
|
<pre id="out">loading…</pre>
|
|
<script>
|
|
fetch("/api/health", { credentials: "same-origin" })
|
|
.then((r) => r.json())
|
|
.then((j) => { document.getElementById("out").textContent = JSON.stringify(j, null, 2); })
|
|
.catch((e) => { document.getElementById("out").textContent = "ERROR: " + e; });
|
|
</script>
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
- [ ] **Step 2: Build the Astro site**
|
|
|
|
Run: `cd /www/wwwroot/comiida.com/app && npm run build`
|
|
Expected: build succeeds; `public/api-health-test/index.html` (or `public/api-health-test.html`) produced.
|
|
|
|
- [ ] **Step 3: Verify the page loads and the fetch target resolves**
|
|
|
|
Run: `curl -sk https://127.0.0.1/api-health-test -H "Host: www.comiida.com" | grep -c "/api/health"`
|
|
Expected: `1` (the page references the endpoint). Loading it in a browser shows the live JSON envelope with `db: "connected"`.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
cd /www/wwwroot/comiida.com
|
|
git add app/src/pages/api-health-test.astro
|
|
git commit -m "feat(api): Astro page proving browser->PHP->MariaDB health round-trip"
|
|
```
|
|
|
|
---
|
|
|
|
## Self-review notes
|
|
|
|
- **Spec coverage:** Installer (§1) → Task 2/6; migrations (§2) → Task 3; two-tier auth + envelope (§3) → Task 4/5; health round-trip (§4) → Task 4/5/7; security hardening (§5) → Task 6; DB provisioning → Task 1. All spec sections mapped.
|
|
- **Deliberate scope trim (YAGNI):** full `api_plans`/`api_usage` monthly-quota enforcement is deferred (flagged inline in `ApiController::requireAuth`); the foundation ships bearer/api_auth verification + per-minute throttle + request logging, which is enough to prove the privileged tier.
|
|
- **Type consistency:** `Installer::run(['url','name','db'=>[...]])` shape is used identically in InstallCommand (Task 2), Installer migration wiring (Task 3), and the web wizard (Task 6). `Migrator($pdo, $dir)` signature consistent across Task 3 and the installer. `json()/throttle()/guardPublic()/requireAuth()` names consistent across controllers.
|
|
- **Open item:** operator provides the MariaDB root + app DB password (Task 1) and should still rotate/delete the vestigial `api/.env`.
|