diff --git a/server/DEPLOY.md b/server/DEPLOY.md index 307e73c..00e33bf 100644 --- a/server/DEPLOY.md +++ b/server/DEPLOY.md @@ -39,6 +39,68 @@ Example using `/home/www/healthcheck`: > `sudo chmod 755 /home/www` once is usually enough. `/var/www` doesn't have > this problem (always world-readable by default). +## Database — MySQL or SQLite + +The code runs against either MySQL 8.0+ or SQLite 3.24+. The `config.php.example` +defaults to **MySQL** because the `php-sqlite3` extension is unmaintained +on some older distributions (Ubuntu 20.04 + Ondrej Sury's PHP 8.3 build is +the case that bit us — the SQLite extension is no longer packaged for that +combination, and you can't upgrade the OS without disturbing other live +services). + +### MySQL setup + +As root on the DB server (root via socket auth works on a stock Ubuntu MySQL): + +```sql +CREATE DATABASE att_hc DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER 'att_hc'@'localhost' IDENTIFIED BY 'STRONG_RANDOM_PASSWORD'; +GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, INDEX, ALTER, REFERENCES + ON att_hc.* TO 'att_hc'@'localhost'; +FLUSH PRIVILEGES; +``` + +The grant is scoped to `att_hc.*` only — the user can't see or touch other +databases on a shared box. Generate the password with: + +```sh +php -r 'echo bin2hex(random_bytes(18)), PHP_EOL;' +``` + +Then in `config.php`: + +```php +'db_dsn' => 'mysql:host=localhost;dbname=att_hc;charset=utf8mb4', +'db_user' => 'att_hc', +'db_pass' => 'STRONG_RANDOM_PASSWORD', +``` + +The schema is created on the first authenticated request (the migration +runner runs CREATE TABLE IF NOT EXISTS for everything). You can pre-trigger +it by hitting any authenticated endpoint, e.g.: + +```sh +curl -s -H "Authorization: Bearer " \ + https://healthcheck.example.com/sites?limit=1 +``` + +After which `SHOW TABLES IN att_hc;` should list `healthchecks`, +`step_updates`, `migrations`. + +### SQLite setup (if `pdo_sqlite` is available) + +Set in `config.php`: + +```php +'db_dsn' => 'sqlite:' . __DIR__ . '/data/att_hc.sqlite', +'db_user' => null, +'db_pass' => null, +``` + +The DB file is created automatically inside `data/` on first request, so +that directory needs to be writable by the web user. See the install +snippet below for the `mkdir + chmod 770` step. + ## First-time install ```sh @@ -56,12 +118,15 @@ rsync -avz --exclude='data/' --exclude='config.php' \ cd /home/www/healthcheck cp config.php.example config.php php -r 'echo bin2hex(random_bytes(32)), PHP_EOL;' # generate api_key -$EDITOR config.php # paste it in +$EDITOR config.php # paste it in + DB creds +# Only needed for the SQLite backend — MySQL skips this: mkdir -p data -sudo chown -R www-data:www-data data/ config.php -sudo chmod 640 config.php +sudo chown -R www-data:www-data data/ sudo chmod 770 data/ + +sudo chown www-data:www-data config.php +sudo chmod 640 config.php ``` ## Apache vhost (example) diff --git a/server/config.php.example b/server/config.php.example index 7b45316..5b1dc25 100644 --- a/server/config.php.example +++ b/server/config.php.example @@ -8,11 +8,24 @@ return [ // Shared secret. Plugin sends this as `Authorization: Bearer `. 'api_key' => 'REPLACE_WITH_A_LONG_RANDOM_STRING', - // PDO DSN. SQLite default — file path is resolved relative to server/. - // For MySQL: 'mysql:host=localhost;dbname=att_hc;charset=utf8mb4' - 'db_dsn' => 'sqlite:' . __DIR__ . '/data/att_hc.sqlite', - 'db_user' => null, - 'db_pass' => null, + // PDO DSN. + // + // MySQL (recommended when pdo_sqlite isn't available — older Ubuntu boxes + // tend to be in this state): + // 'db_dsn' => 'mysql:host=localhost;dbname=att_hc;charset=utf8mb4' + // 'db_user' => 'att_hc' + // 'db_pass' => '' + // + // See DEPLOY.md → "MySQL setup" for the CREATE DATABASE / CREATE USER / + // GRANT statements. + // + // SQLite (simplest when the box has php8.x-sqlite3 installed): + // 'db_dsn' => 'sqlite:' . __DIR__ . '/data/att_hc.sqlite' + // 'db_user' => null + // 'db_pass' => null + 'db_dsn' => 'mysql:host=localhost;dbname=att_hc;charset=utf8mb4', + 'db_user' => 'att_hc', + 'db_pass' => 'REPLACE_WITH_DB_PASSWORD', // Server version, surfaced on GET /. Bump on deploy. 'version' => '0.1.0', diff --git a/server/migrations/0001_initial.sql b/server/migrations/0001_initial.sql index 0bc8f6e..33c13b1 100644 --- a/server/migrations/0001_initial.sql +++ b/server/migrations/0001_initial.sql @@ -1,46 +1,46 @@ +-- Portable across SQLite and MySQL 8.0+: +-- - VARCHAR(N) is honoured by MySQL, treated as TEXT by SQLite. +-- - BIGINT covers post-2038 unix seconds; SQLite stores as INTEGER affinity. +-- - No AUTO_INCREMENT in this file — we don't need it for the current tables. +-- (An audit table that needs an autoincrement id can come in a later +-- migration with per-driver dispatch in Migrations.php.) +-- +-- Re-running is safe: Migrations.php only applies each .sql file once +-- (tracked in the `migrations` table created in PHP). + CREATE TABLE IF NOT EXISTS healthchecks ( - id TEXT PRIMARY KEY, - site_key TEXT NOT NULL, - started_at INTEGER NOT NULL, - finished_at INTEGER NULL, - technician_id INTEGER NULL, - reporting_url TEXT NOT NULL, - wp_version TEXT NULL, - php_version TEXT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, + id VARCHAR(64) NOT NULL, + site_key VARCHAR(255) NOT NULL, + started_at BIGINT NOT NULL, + finished_at BIGINT NULL, + technician_id BIGINT NULL, + reporting_url VARCHAR(512) NOT NULL, + wp_version VARCHAR(32) NULL, + php_version VARCHAR(32) NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + PRIMARY KEY (id), UNIQUE (site_key, started_at) ); CREATE INDEX IF NOT EXISTS healthchecks_site_started_idx - ON healthchecks (site_key, started_at DESC); + ON healthchecks (site_key, started_at); CREATE INDEX IF NOT EXISTS healthchecks_site_finished_idx ON healthchecks (site_key, finished_at); CREATE TABLE IF NOT EXISTS step_updates ( - healthcheck_id TEXT NOT NULL REFERENCES healthchecks(id) ON DELETE CASCADE, - step_id TEXT NOT NULL, - status TEXT NOT NULL, - notes TEXT NOT NULL DEFAULT '', - autocheck_json TEXT NULL, - reporting_url TEXT NOT NULL, - updated_at INTEGER NOT NULL, - PRIMARY KEY (healthcheck_id, step_id) + healthcheck_id VARCHAR(64) NOT NULL, + step_id VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL, + notes TEXT NOT NULL, + autocheck_json TEXT NULL, + reporting_url VARCHAR(512) NOT NULL, + updated_at BIGINT NOT NULL, + PRIMARY KEY (healthcheck_id, step_id), + CONSTRAINT fk_step_updates_hc FOREIGN KEY (healthcheck_id) + REFERENCES healthchecks(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS step_updates_step_updated_idx - ON step_updates (step_id, updated_at DESC); - -CREATE TABLE IF NOT EXISTS request_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ts INTEGER NOT NULL, - method TEXT NOT NULL, - path TEXT NOT NULL, - status INTEGER NOT NULL, - ip TEXT NULL, - bytes_in INTEGER NULL, - bytes_out INTEGER NULL -); - -CREATE INDEX IF NOT EXISTS request_log_ts_idx ON request_log (ts DESC); + ON step_updates (step_id, updated_at); diff --git a/server/src/Db.php b/server/src/Db.php index 1094cd2..63c96df 100644 --- a/server/src/Db.php +++ b/server/src/Db.php @@ -38,6 +38,12 @@ final class Db { $pdo->exec('PRAGMA foreign_keys = ON'); $pdo->exec('PRAGMA journal_mode = WAL'); $pdo->exec('PRAGMA synchronous = NORMAL'); + } elseif (str_starts_with($dsn, 'mysql:')) { + // utf8mb4 should already be in the DSN, but force it on the + // session so we get 4-byte UTF-8 even if the server default + // is something narrower (older boxes default to utf8mb3). + $pdo->exec("SET NAMES utf8mb4"); + $pdo->exec("SET SESSION sql_mode = 'STRICT_ALL_TABLES,NO_ENGINE_SUBSTITUTION'"); } self::$pdo = $pdo; diff --git a/server/src/Migrations.php b/server/src/Migrations.php index 7593353..75a8ed7 100644 --- a/server/src/Migrations.php +++ b/server/src/Migrations.php @@ -11,9 +11,11 @@ final class Migrations { * Idempotent: tracks applied filenames in the `migrations` table. */ public static function ensureRan(PDO $pdo): void { + // VARCHAR(255) instead of TEXT — MySQL won't index TEXT without a + // prefix length. SQLite ignores the length and stores as TEXT affinity. $pdo->exec('CREATE TABLE IF NOT EXISTS migrations ( - filename TEXT PRIMARY KEY, - applied_at INTEGER NOT NULL + filename VARCHAR(255) NOT NULL PRIMARY KEY, + applied_at BIGINT NOT NULL )'); $dir = __DIR__ . '/../migrations'; @@ -36,14 +38,20 @@ final class Migrations { throw new \RuntimeException('failed to read migration: ' . $name); } + // MySQL implicitly commits any open transaction the moment it sees + // a DDL statement (CREATE TABLE, CREATE INDEX, ALTER, …). That means + // commit()/rollBack() may have nothing left to act on once $sql has + // been exec()'d. Guard both sides with inTransaction(). SQLite + // doesn't have the implicit-commit behaviour, so on SQLite the + // transaction still wraps the whole migration atomically. $pdo->beginTransaction(); try { $pdo->exec($sql); $stmt = $pdo->prepare('INSERT INTO migrations (filename, applied_at) VALUES (?, ?)'); $stmt->execute([$name, time()]); - $pdo->commit(); + if ($pdo->inTransaction()) $pdo->commit(); } catch (\Throwable $e) { - $pdo->rollBack(); + if ($pdo->inTransaction()) $pdo->rollBack(); throw new \RuntimeException('migration failed: ' . $name . ' — ' . $e->getMessage(), 0, $e); } } diff --git a/server/src/Store.php b/server/src/Store.php index 1a6b44e..41441f0 100644 --- a/server/src/Store.php +++ b/server/src/Store.php @@ -44,15 +44,36 @@ final class Store { } public static function upsertStep(string $healthcheckId, string $stepId, array $row): void { - $stmt = Db::pdo()->prepare('INSERT INTO step_updates + $pdo = Db::pdo(); + $driver = $pdo->getAttribute(\PDO::ATTR_DRIVER_NAME); + + $base = 'INSERT INTO step_updates (healthcheck_id, step_id, status, notes, autocheck_json, reporting_url, updated_at) - VALUES (:hc, :sid, :status, :notes, :autocheck, :reporting_url, :updated_at) - ON CONFLICT (healthcheck_id, step_id) DO UPDATE SET - status = excluded.status, - notes = excluded.notes, - autocheck_json = excluded.autocheck_json, - reporting_url = excluded.reporting_url, - updated_at = excluded.updated_at'); + VALUES (:hc, :sid, :status, :notes, :autocheck, :reporting_url, :updated_at)'; + + if ($driver === 'mysql') { + // MySQL: VALUES(col) in ON DUPLICATE KEY UPDATE is deprecated in 8.0.20+ + // but still works; the new spelling needs an alias on the row. Stick + // with VALUES() for broader compatibility back to MySQL 5.7. + $sql = $base . ' + ON DUPLICATE KEY UPDATE + status = VALUES(status), + notes = VALUES(notes), + autocheck_json = VALUES(autocheck_json), + reporting_url = VALUES(reporting_url), + updated_at = VALUES(updated_at)'; + } else { + // SQLite (and PostgreSQL, by happy accident). + $sql = $base . ' + ON CONFLICT (healthcheck_id, step_id) DO UPDATE SET + status = excluded.status, + notes = excluded.notes, + autocheck_json = excluded.autocheck_json, + reporting_url = excluded.reporting_url, + updated_at = excluded.updated_at'; + } + + $stmt = $pdo->prepare($sql); $stmt->execute([ ':hc' => $healthcheckId, ':sid' => $stepId, @@ -63,7 +84,7 @@ final class Store { ':updated_at' => time(), ]); // Bump parent's updated_at so list views sort sensibly. - Db::pdo()->prepare('UPDATE healthchecks SET updated_at = ? WHERE id = ?') + $pdo->prepare('UPDATE healthchecks SET updated_at = ? WHERE id = ?') ->execute([time(), $healthcheckId]); }