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
This commit is contained in:
parent
0fcf707a17
commit
1fdfc3174b
12 changed files with 514 additions and 140 deletions
47
api/app/Controllers/ApiController.php
Normal file
47
api/app/Controllers/ApiController.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?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.
|
||||
}
|
||||
}
|
||||
42
api/app/Controllers/JsonController.php
Normal file
42
api/app/Controllers/JsonController.php
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
42
api/app/Controllers/PublicController.php
Normal file
42
api/app/Controllers/PublicController.php
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
132
api/app/Services/Installer.php
Normal file
132
api/app/Services/Installer.php
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<?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();
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
57
api/app/Services/Migrator.php
Normal file
57
api/app/Services/Migrator.php
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
54
api/commands/InstallCommand.php
Normal file
54
api/commands/InstallCommand.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?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://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('<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;
|
||||
}
|
||||
}
|
||||
42
api/commands/MigrateCommand.php
Normal file
42
api/commands/MigrateCommand.php
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@
|
|||
},
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"commands",
|
||||
"core/",
|
||||
"app/Helpers",
|
||||
"app/Controllers",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ $application = new Application();
|
|||
|
||||
# add our commands
|
||||
$application->add(new GreetCommand());
|
||||
$application->add(new InstallCommand());
|
||||
$application->add(new MigrateCommand());
|
||||
//$application->add(new Sentinel());
|
||||
//$application->add(new Engine());
|
||||
$application->run();
|
||||
7
api/db/migrations/001_create_metrics_placeholder.sql
Normal file
7
api/db/migrations/001_create_metrics_placeholder.sql
Normal file
|
|
@ -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;
|
||||
|
|
@ -10,17 +10,14 @@ class Index extends Controller {
|
|||
$this->view->render(__CLASS__ .'/'. __FUNCTION__);
|
||||
}
|
||||
|
||||
// AJAX: test the supplied DB credentials (echo 1 = ok, 0 = fail).
|
||||
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";
|
||||
$dsn = "mysql:host=$host;dbname=$db;charset=utf8mb4";
|
||||
$options = [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_LAZY,
|
||||
|
|
@ -28,112 +25,45 @@ class Index extends Controller {
|
|||
];
|
||||
|
||||
try {
|
||||
$pdo = new PDO($dsn, $user, $pass, $options);
|
||||
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() {
|
||||
// \Helper::print_array($_POST);
|
||||
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: /install/");
|
||||
header('Location: /api/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();
|
||||
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()));
|
||||
}
|
||||
|
||||
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 =
|
||||
<<<SEED
|
||||
<?php
|
||||
|
||||
define('URL', 'https://www.seedproject.com/');
|
||||
define('SITE_BASE', '/');
|
||||
define('ASSETS', '/public/assets/');
|
||||
define('LIBS', 'core/');
|
||||
define('COMPANY', 'CryptoBot - by CarlosArias.com');
|
||||
define('DEBUG', false);
|
||||
|
||||
define('EMAILUSER', 'ADDEMAILUSER');
|
||||
define('EMAILPASSWORD', 'ADDEMAILPASSWORD');
|
||||
define('EMAILHOST', 'ADDEMAILHOST');
|
||||
|
||||
define('DB_TYPE', 'mysql');
|
||||
define('DB_HOST', '{$dbLoca}');
|
||||
define('DB_NAME', '{$dbName}');
|
||||
define('DB_USER', '{$dbUser}');
|
||||
define('DB_PASS', '{$dbPass}');
|
||||
|
||||
define( 'HASH_PASSWORD_KEY', '{$HashKey}');
|
||||
define( 'HASH_API_KEY', '{$HashAPIKey}');
|
||||
define( 'TIMESTAMP', date('Y-m-d H:i:s'));
|
||||
date_default_timezone_set('America/New_York');
|
||||
|
||||
\Db::setConnectionInfo(DB_TYPE, DB_NAME, DB_USER, DB_PASS);
|
||||
SEED;
|
||||
|
||||
file_put_contents($ConfigFile, $config_content);
|
||||
|
||||
header("Location: /install/i/complete");
|
||||
|
||||
header('Location: /api/install/i/complete');
|
||||
}
|
||||
|
||||
|
||||
|
||||
function getName($n) {
|
||||
$characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()-=_+<>?;:\/';
|
||||
$randomString = '';
|
||||
|
||||
for ($i = 0; $i < $n; $i++) {
|
||||
$index = rand(0, strlen($characters) - 1);
|
||||
$randomString .= $characters[$index];
|
||||
}
|
||||
|
||||
return $randomString;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
18
app/src/pages/api-health-test.astro
Normal file
18
app/src/pages/api-health-test.astro
Normal file
|
|
@ -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.
|
||||
---
|
||||
|
||||
<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>
|
||||
Loading…
Reference in a new issue