Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.99% covered (success)
98.99%
196 / 198
81.82% covered (warning)
81.82%
9 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
SystemInstallerService
98.98% covered (success)
98.98%
195 / 197
81.82% covered (warning)
81.82%
9 / 11
41
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 install
100.00% covered (success)
100.00%
72 / 72
100.00% covered (success)
100.00%
1 / 1
10
 executeSqlDirectory
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
7.01
 executeSqlFileIfExists
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 configureAdminUser
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
4
 setupApiToken
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 updateInstallerConfig
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
1
 initializeEncryptionKey
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 registerVersion
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 purgeCache
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
8
 createPdoConnection
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Installer\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Installer\Dto\InstallerInputDto;
12use App\Core\Installer\InstallerException;
13use FilesystemIterator;
14use PDO;
15use RecursiveDirectoryIterator;
16use RecursiveIteratorIterator;
17use Throwable;
18use Yiisoft\Files\FileHelper;
19
20/**
21 * Service orchestrating fresh platform database installation and technical initialization.
22 *
23 * @package App\Core\Installer\Service
24 */
25final class SystemInstallerService implements SystemInstallerServiceInterface
26{
27    /** Initial system release version. */
28    public const string INITIAL_VERSION = '0.0.1';
29
30    private const INDENTED_CLOSE_BRACKET = "    ],\n";
31
32    /** @var (callable(string, string, string, array<int, mixed>): PDO)|null */
33    private mixed $pdoFactory;
34
35    /**
36     * SystemInstallerService constructor.
37     *
38     * @param string $projectRoot Application root directory.
39     * @param SystemRequirementsCheckerInterface|null $checker Environment validation service.
40     * @param (callable(string, string, string, array<int, mixed>): PDO)|null $pdoFactory Optional PDO factory.
41     */
42    public function __construct(
43        private readonly string $projectRoot,
44        private readonly ?SystemRequirementsCheckerInterface $checker = null,
45        ?callable $pdoFactory = null
46    ) {
47        $this->pdoFactory = $pdoFactory;
48    }
49
50    /**
51     * Executes end-to-end installation procedure.
52     *
53     * @param InstallerInputDto $input Installation configuration parameters.
54     * @param callable|null $progressCallback Optional callback for reporting step progress.
55     * @return array{success: bool, version: string, db_name: string, profile: string}
56     */
57    public function install(InstallerInputDto $input, ?callable $progressCallback = null): array
58    {
59        $report = static function (string $step, string $message) use ($progressCallback): void {
60            if ($progressCallback !== null) {
61                $progressCallback($step, $message);
62            }
63        };
64
65        // 1. Requirements Check
66        $checker = $this->checker ?? new SystemRequirementsChecker($this->projectRoot);
67        $reqCheck = $checker->check();
68        if (!$reqCheck['passed']) {
69            throw new InstallerException(
70                'System requirements check failed. Resolve reported issues before installing.'
71            );
72        }
73        $report('requirements', 'System requirements verified successfully.');
74
75        // 2. Database Connection & Preparation
76        $report('database_connect', sprintf('Connecting to database server %s:%d...', $input->dbHost, $input->dbPort));
77        $dsnNoDb = sprintf('mysql:host=%s;port=%d;charset=utf8mb4', $input->dbHost, $input->dbPort);
78        $pdo = $this->createPdoConnection($dsnNoDb, $input->dbUser, $input->dbPass, [
79            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
80            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
81            PDO::MYSQL_ATTR_MULTI_STATEMENTS => true,
82        ]);
83
84        $pdo->exec(sprintf(
85            'CREATE DATABASE IF NOT EXISTS `%s` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;',
86            $input->dbName
87        ));
88
89        $dsnWithDb = sprintf(
90            'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
91            $input->dbHost,
92            $input->dbPort,
93            $input->dbName
94        );
95        $pdo = $this->createPdoConnection($dsnWithDb, $input->dbUser, $input->dbPass, [
96            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
97            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
98            PDO::MYSQL_ATTR_MULTI_STATEMENTS => true,
99            PDO::ATTR_EMULATE_PREPARES => false,
100        ]);
101        $pdo->exec('SET FOREIGN_KEY_CHECKS = 0;');
102
103        // 3. Deploy Baseline Schema
104        $report('schema', 'Deploying baseline schema tables (01_schema)...');
105        $this->executeSqlDirectory($pdo, $this->projectRoot . '/database/sql/01_schema');
106
107        // 4. Deploy Profile Metas
108        $report('profile', sprintf('Deploying %s profile datasets...', $input->profile));
109        $profileSqlDir = $this->projectRoot . '/profiles/' . $input->profile . '/sql';
110        if (is_dir($profileSqlDir)) {
111            $this->executeSqlDirectory($pdo, $profileSqlDir);
112        }
113
114        // 5. Deploy Updates Directory
115        $report('updates', 'Deploying updates and consolidated views...');
116        $updatesDir = $this->projectRoot . '/database/sql/05_updates';
117        if (is_dir($updatesDir)) {
118            $this->executeSqlDirectory($pdo, $updatesDir);
119        }
120
121        // 6. Optional & Test Datasets
122        if ($input->withOptional) {
123            $report('optional', 'Deploying optional dictionary datasets...');
124            $this->executeSqlFileIfExists($pdo, $this->projectRoot . '/database/sql/03_optional.sql');
125        }
126
127        if ($input->withDemo) {
128            $report('demo', 'Deploying demo datasets...');
129            $this->executeSqlFileIfExists($pdo, $this->projectRoot . '/database/sql/04_test.sql');
130            $testDir = $this->projectRoot . '/database/test';
131            if (is_dir($testDir)) {
132                $this->executeSqlDirectory($pdo, $testDir);
133            }
134        }
135
136        // 7. Configure Super-Administrator Account
137        if ($input->adminEmail !== '' && $input->adminPassword !== '') {
138            $report('admin_account', sprintf('Configuring administrator account (%s)...', $input->adminEmail));
139            $this->configureAdminUser($pdo, $input->adminEmail, $input->adminPassword);
140        }
141
142        // 8. Generate System API Token & Security Key
143        $report('security', 'Initializing platform encryption keys and API token...');
144        $this->setupApiToken($pdo);
145        $this->initializeEncryptionKey();
146
147        // 9. Persist Active Configuration
148        $this->updateInstallerConfig($input);
149
150        // 10. Register Initial Version 0.0.1
151        $report('version_register', 'Registering initial platform release version 0.0.1...');
152        $this->registerVersion($pdo, self::INITIAL_VERSION, $input->profile);
153
154        $pdo->exec('SET FOREIGN_KEY_CHECKS = 1;');
155
156        // 11. Purge Cache
157        $report('cache_clear', 'Purging runtime cache directories...');
158        $this->purgeCache();
159
160        return [
161            'success' => true,
162            'version' => self::INITIAL_VERSION,
163            'db_name' => $input->dbName,
164            'profile' => $input->profile,
165        ];
166    }
167
168    /**
169     * Executes all SQL files found in directory in alphabetical order.
170     */
171    private function executeSqlDirectory(PDO $pdo, string $dir): void
172    {
173        if (!is_dir($dir)) {
174            return;
175        }
176
177        $files = glob($dir . '/*.sql');
178        if ($files === false) {
179            return;
180        }
181        sort($files, SORT_NATURAL);
182
183        foreach ($files as $file) {
184            $sql = file_get_contents($file);
185            if ($sql !== false && trim($sql) !== '') {
186                try {
187                    $pdo->exec($sql);
188                } catch (Throwable $e) {
189                    throw new InstallerException(
190                        sprintf('SQL execution error in file %s: %s', basename($file), $e->getMessage()),
191                        0,
192                        $e
193                    );
194                }
195            }
196        }
197    }
198
199    /**
200     * Executes single SQL file if present.
201     */
202    private function executeSqlFileIfExists(PDO $pdo, string $filePath): void
203    {
204        if (!file_exists($filePath)) {
205            return;
206        }
207        $sql = file_get_contents($filePath);
208        if ($sql !== false && trim($sql) !== '') {
209            $pdo->exec($sql);
210        }
211    }
212
213    /**
214     * Creates or updates super-administrator credentials in a_mod_users_records.
215     */
216    private function configureAdminUser(PDO $pdo, string $email, string $plainPassword): void
217    {
218        $hash = password_hash($plainPassword, PASSWORD_DEFAULT);
219        $stmt = $pdo->prepare('SELECT id FROM a_mod_users_records WHERE email = :email LIMIT 1');
220        $stmt->execute([':email' => $email]);
221        $existingId = $stmt->fetchColumn();
222
223        if ($existingId !== false) {
224            $upd = $pdo->prepare(
225                'UPDATE a_mod_users_records '
226                . "SET password_hash = :hash, status = 'active', special_access = 1, is_superuser = 1 "
227                . 'WHERE id = :id'
228            );
229            $upd->execute([':hash' => $hash, ':id' => (int) $existingId]);
230        } else {
231            $username = strstr($email, '@', true) ?: 'admin';
232            $ins = $pdo->prepare(
233                'INSERT INTO a_mod_users_records '
234                . '(username, email, password_hash, status, special_access, is_superuser, created_by, owner) '
235                . "VALUES (:username, :email, :hash, 'active', 1, 1, 1, 1)"
236            );
237            $ins->execute([
238                ':username' => $username,
239                ':email' => $email,
240                ':hash' => $hash,
241            ]);
242            $newId = (int) $pdo->lastInsertId();
243            if ($newId > 0) {
244                $updSelf = $pdo->prepare(
245                    'UPDATE a_mod_users_records SET created_by = :id, owner = :id WHERE id = :id'
246                );
247                $updSelf->execute([':id' => $newId]);
248            }
249        }
250    }
251
252    /**
253     * Generates a unique system API token and persists it in settings and auth config.
254     */
255    private function setupApiToken(PDO $pdo): void
256    {
257        $apiToken = bin2hex(random_bytes(32));
258        $stmt = $pdo->prepare(
259            'INSERT INTO a_core_settings_records '
260            . '(`setting_key`, `setting_value`, `description`, `category`, `used_by`, `created_by`, `owner`) '
261            . "VALUES ('system_api_token', :token, 'Generated System API Bearer Token', "
262            . "'security', 'App\\\\Core\\\\Api\\\\Middleware\\\\ApiTokenMiddleware::process()', 1, 1) "
263            . 'ON DUPLICATE KEY UPDATE `setting_value` = VALUES(`setting_value`), `used_by` = VALUES(`used_by`)'
264        );
265        $stmt->execute([':token' => $apiToken]);
266
267        $tokenFileContent = "<?php\n\ndeclare(strict_types=1);\n\nif (!defined('AMMONLY_APP')) {\n"
268            . "    exit('Direct script access is forbidden.');\n}\n\n"
269            . "\$tokenKey = 'system_api_token';\n\nreturn [\n    \$tokenKey => '{$apiToken}',\n];\n";
270        @file_put_contents($this->projectRoot . '/config/common/api_auth.php', $tokenFileContent);
271    }
272
273    /**
274     * Persists database connection parameters into installer configuration file.
275     */
276    private function updateInstallerConfig(InstallerInputDto $input): void
277    {
278        $configFile = $this->projectRoot . '/database/config/installer.php';
279        $template = "<?php\n\ndeclare(strict_types=1);\n\nif (!defined('AMMONLY_APP')) {\n"
280            . "    exit('Direct script access is forbidden.');\n}\n\nreturn [\n"
281            . "    'db' => [\n"
282            . "        'host' => '{$input->dbHost}',\n"
283            . "        'port' => {$input->dbPort},\n"
284            . "        'dbname' => '{$input->dbName}',\n"
285            . "        'username' => '{$input->dbUser}',\n"
286            . "        'password' => '{$input->dbPass}',\n"
287            . "        'charset' => 'utf8mb4',\n"
288            . "        'table_prefix' => 'a_',\n"
289            . self::INDENTED_CLOSE_BRACKET
290            . "    'users' => [\n"
291            . "        'admin' => [\n"
292            . "            'username' => 'admin',\n"
293            . "            'email' => '{$input->adminEmail}',\n"
294            . "            'password' => '',\n"
295            . self::INDENTED_CLOSE_BRACKET
296            . self::INDENTED_CLOSE_BRACKET
297            . "    'maps' => [\n"
298            . "        'api_url' => (string) (\$_ENV['AMMONLY_MAP_API_URL'] ?? 'https://map.ammonly.com'),\n"
299            . "        'api_key' => (string) (\$_ENV['AMMONLY_MAP_API_KEY'] ?? ''),\n"
300            . self::INDENTED_CLOSE_BRACKET
301            . "    'defaults' => ['with_optional' => false, 'with_test' => false],\n"
302            . "];\n";
303        @file_put_contents($configFile, $template);
304    }
305
306    /**
307     * Initializes master encryption key if not present.
308     */
309    private function initializeEncryptionKey(): void
310    {
311        $keyFile = $this->projectRoot . '/storage/app.key';
312        if (!file_exists($keyFile)) {
313            $key = bin2hex(random_bytes(32));
314            @file_put_contents($keyFile, $key);
315            @chmod($keyFile, 0660);
316        }
317    }
318
319    /**
320     * Registers version record in a_core_system_version table.
321     */
322    private function registerVersion(PDO $pdo, string $version, string $profile): void
323    {
324        $stmt = $pdo->prepare(
325            'INSERT INTO a_core_system_version (version, profile, installed_at, package_checksum, applied_by) '
326            . 'VALUES (:version, :profile, NOW(), :checksum, :applied_by)'
327        );
328        $stmt->execute([
329            ':version' => $version,
330            ':profile' => $profile,
331            ':checksum' => hash('sha256', 'ammonly-initial-' . $version),
332            ':applied_by' => 'cli-installer',
333        ]);
334    }
335
336    /**
337     * Clears storage runtime and cache directories.
338     */
339    private function purgeCache(): void
340    {
341        if (function_exists('opcache_reset')) {
342            @opcache_reset();
343        }
344
345        $cacheDirs = [
346            $this->projectRoot . '/storage/cache',
347            $this->projectRoot . '/storage/runtime',
348            $this->projectRoot . '/runtime/cache',
349        ];
350
351        foreach ($cacheDirs as $cacheDir) {
352            if (!is_dir($cacheDir)) {
353                continue;
354            }
355            $iterator = new RecursiveIteratorIterator(
356                new RecursiveDirectoryIterator($cacheDir, FilesystemIterator::SKIP_DOTS),
357                RecursiveIteratorIterator::CHILD_FIRST
358            );
359            foreach ($iterator as $item) {
360                if ($item->isDir()) {
361                    @rmdir($item->getPathname());
362                } elseif ($item->isFile() && $item->getFilename() !== '.gitkeep') {
363                    FileHelper::unlink($item->getPathname());
364                }
365            }
366        }
367    }
368
369    /**
370     * @param array<int, mixed> $options
371     */
372    private function createPdoConnection(string $dsn, string $user, string $pass, array $options): PDO
373    {
374        if ($this->pdoFactory !== null) {
375            return ($this->pdoFactory)($dsn, $user, $pass, $options);
376        }
377
378        return new PDO($dsn, $user, $pass, $options);
379    }
380}
381