diff --git a/.gitignore b/.gitignore
index 4d28d24..66d84cf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,7 +3,10 @@
.env.*
!.env.example
includes/config.local.php
+includes/config.local.php.installing.*
includes/.licora-encryption.key
+includes/.licora-installed
+includes/.licora-installed.installing.*
config.local.php
# Logs, exports, backups, and generated operational data
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a943957..8523f56 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,7 +6,36 @@ All notable public-release changes are recorded here. Historical project notes r
### Planned
-- Resolve the security and correctness items listed in the forensic audit through separate reviewed pull requests.
+- Continue reviewed Zero Freedom development after the v5.1.0 installer release.
+
+## [5.1.0] - 2026-07-23
+
+### Added
+
+- Added a ten-step first-run installer wizard with server compatibility checks, database validation, administrator setup, application configuration, optional demo data, installation locking, success reporting, and admin-login redirect.
+- Added `/install` as an additive installer alias while preserving `/install.php`.
+- Added pre-boot installation detection for incomplete fresh installations.
+- Added an atomic private configuration and installation-flag workflow.
+- Added an installer SQL parser that executes the existing schema, migrations, indexes, constraints, and triggers without manual import.
+- Added optional DEMO records using existing `api_keys`, `licenses`, and `settings` tables only.
+- Added a CLI demo-data cleanup utility.
+- Added installer architecture, first-run, upgrade, demo-data, release, and implementation documentation.
+- Added installer smoke tests and expanded compatibility regression coverage.
+
+### Changed
+
+- Updated the default application version to `5.1.0`.
+- Added optional database-port, application-key, timezone, locale, and mail-from configuration constants.
+- Updated database connection construction to honor `DB_PORT` while retaining port `3306` as the default.
+- Updated the root landing page to trigger the installation guard before normal output.
+
+### Compatibility
+
+- No database table, column, index, foreign key, trigger, or migration was changed.
+- No license generation, license validation, API response, route, admin page, cron entry point, CSS, or JavaScript behavior was changed.
+- Existing v5.0.1 and v5.0.1.1 installations continue normal boot without reinstalling.
+- Temporary database outages never reopen the installer for configured deployments.
+- Table prefixes remain unsupported because the frozen schema and runtime query contract use fixed table names.
## [5.0.1.1] - 2026-07-23
@@ -79,5 +108,4 @@ All notable public-release changes are recorded here. Historical project notes r
### Compatibility
-- Application feature code was not removed, disabled, or simplified.
-- No PHP class, function, endpoint, admin page, migration, stylesheet, or JavaScript behavior was intentionally changed during repository preparation.
+- Existing runtime routes, API contracts, schema objects, license format, and application behavior were preserved.
diff --git a/PHASE2_INSTALLER_SUMMARY.md b/PHASE2_INSTALLER_SUMMARY.md
new file mode 100644
index 0000000..d04a1ce
--- /dev/null
+++ b/PHASE2_INSTALLER_SUMMARY.md
@@ -0,0 +1,127 @@
+# Licora Phase 2 Installer Implementation Summary
+
+## Release identity
+
+- Target version: `v5.1.0`
+- Stable base: `v5.0.1.1`
+- Base commit: `7fafd2c34b3425df6ef310b9f25ffa426588d294`
+- Development mode: Zero Freedom Development
+
+## Implementation summary
+
+Licora v5.1.0 adds a production first-run installer and installation guard without modifying the existing license engine, validation logic, API contracts, database schema, admin panel, cron entry points, CSS, or JavaScript.
+
+The implementation includes:
+
+- Ten-step first-run wizard
+- Server compatibility checks
+- Database host/port/name/user/password validation
+- Blank-only table-prefix compatibility enforcement
+- Strong administrator creation with no retained default credentials
+- Application, encryption, CSRF, and JWT secret generation
+- Existing schema and migration execution with trigger delimiter support
+- Optional existing-schema DEMO records
+- Atomic private configuration activation
+- Non-secret installation flag
+- Installer lock and recovery guidance
+- Admin-login redirect without auto-login
+- Legacy installation flag backfill
+- Database-outage-safe detection
+- Installer smoke and compatibility regression tests
+
+## Files created
+
+- `includes/installation.php`
+- `install/index.php`
+- `scripts/remove-demo-data.php`
+- `tests/installer_smoke.php`
+- `docs/INSTALLER_ARCHITECTURE.md`
+- `docs/FIRST_RUN_GUIDE.md`
+- `docs/UPGRADE_GUIDE.md`
+- `docs/DEMO_DATA.md`
+- `PHASE2_INSTALLER_SUMMARY.md`
+- `RELEASE_NOTES_v5.1.0.md`
+
+## Files modified
+
+- `.gitignore`
+- `CHANGELOG.md`
+- `config.sample.php`
+- `index.php`
+- `install.php`
+- `includes/config.php`
+- `includes/database.php`
+- `docs/INSTALLATION.md`
+- `scripts/validate.sh`
+- `tests/compatibility_regression.php`
+
+## Files intentionally unchanged
+
+- `database.sql`
+- All migration SQL files
+- `includes/functions.php`
+- `includes/security.php`
+- `includes/auth.php`
+- `api/verify.php`
+- `api/check_license.php`
+- All admin routes and pages
+- All cron entry points
+- All CSS and JavaScript
+
+## Database objects created
+
+None.
+
+No new table, column, index, foreign key, trigger, or migration is introduced.
+
+## Demo data summary
+
+Optional demo installation creates:
+
+- One API credential row marked `[DEMO]`
+- One `DEMO PRODUCT` representation through `api_keys.app_name`
+- One existing-format license
+- One `[DEMO CUSTOMER]` notes marker
+- Existing `settings` markers for safe cleanup
+
+The raw generated API credential is never displayed or logged.
+
+## Security verification
+
+- CSRF protection on every installer POST
+- Strong password rules
+- Prepared statements for runtime inserts and cleanup
+- Validated database identifiers and whitelisted charset/collation
+- No shell command execution
+- Escaped HTML output
+- Generic production error messages
+- No credentials or secrets in logs
+- No secrets in the installation flag
+- Atomic temporary configuration and lock files
+- Installer lock after completion
+- Existing configured database outage does not reopen installer
+
+## Upgrade compatibility
+
+- v5.0.1 and v5.0.1.1 private configuration remains supported.
+- Missing `DB_PORT` defaults to 3306.
+- Missing v5.1.0 installation flag is backfilled only after successful legacy validation.
+- Existing encrypted values and API clients are unaffected.
+- Existing installations are never forced through the wizard.
+
+## Regression coverage
+
+Automated coverage includes:
+
+- PHP syntax
+- Phase 1 security smoke tests
+- Existing compatibility regression checks
+- Installer helper validation
+- Strong password rejection
+- Table-prefix rejection
+- SQL delimiter parsing
+- Versioned installer encryption
+- Existing license-format generation
+- Non-secret installation flag
+- Preserved schema/migration/frontend hashes
+- Preserved API and route markers
diff --git a/RELEASE_NOTES_v5.1.0.md b/RELEASE_NOTES_v5.1.0.md
new file mode 100644
index 0000000..7483677
--- /dev/null
+++ b/RELEASE_NOTES_v5.1.0.md
@@ -0,0 +1,173 @@
+# Licora v5.1.0 — Smart Installer & First-Run Wizard
+
+**Release type:** Backward-compatible installer feature release
+**Stable base:** `v5.0.1.1`
+**Database migration:** None
+
+## Summary
+
+Licora v5.1.0 introduces a professional first-run installation experience for the open-source, self-hosted PHP and MySQL/MariaDB license management system.
+
+The release improves fresh installation only. Existing deployments continue normal operation and are not required to reinstall.
+
+## Smart first-run installer
+
+The installer now provides ten guided steps:
+
+1. Welcome and server compatibility checks
+2. Database configuration and connection validation
+3. Administrator setup
+4. Application configuration and secure secret generation
+5. Existing schema initialization review
+6. Optional DEMO data
+7. Installation-lock confirmation
+8. Atomic finalization
+9. Installation success summary
+10. Redirect to admin login without auto-login
+
+Both installer routes are available:
+
+- `/install.php` remains fully supported
+- `/install` is an additive alias
+
+## Installation detection
+
+Before normal web application boot, Licora distinguishes between:
+
+- Fresh unconfigured deployments
+- Incomplete fresh installations
+- Valid existing installations
+- Valid legacy installations without an installation flag
+- Configured installations experiencing a temporary database outage
+
+A database outage never reopens the installer for an existing configured deployment.
+
+## Atomic installation
+
+The installer validates all input before finalization and uses temporary private files before activation.
+
+It executes the existing repository `database.sql`, including current migrations, indexes, constraints, and triggers. A delimiter-aware parser removes the need for manual SQL import.
+
+If installation fails before activation, Licora attempts to remove only installer-created objects. Unrelated pre-existing database objects are not removed.
+
+## Administrator security
+
+Fresh wizard installations require:
+
+- Administrator name
+- Valid email address
+- Unique-format username
+- Password of at least 12 characters
+- Uppercase, lowercase, number, and symbol
+
+The temporary development account from the sanitized manual-import schema is replaced before wizard completion. Licora never auto-logs in the new administrator.
+
+## Application configuration
+
+The wizard generates and stores private values for:
+
+- Application key
+- Encryption key
+- CSRF secret
+- JWT secret
+
+It also configures:
+
+- Application name
+- Base URL
+- Timezone
+- Locale
+- Mail From Name
+- Database port
+
+Generated secrets and credentials are never displayed or logged.
+
+## Installation lock
+
+After successful installation Licora creates:
+
+- `includes/config.local.php`
+- `includes/.licora-installed`
+
+The installation flag contains only product, version, and installation timestamp. Installer files remain on disk but execution is disabled.
+
+## Optional DEMO data
+
+When selected, the installer creates clearly marked DEMO data using existing tables only:
+
+- DEMO API credential
+- DEMO PRODUCT representation
+- DEMO license
+- DEMO CUSTOMER marker
+- Demo cleanup settings
+
+No product, customer, role, or permission table is added.
+
+Demo records can be removed with:
+
+```bash
+php scripts/remove-demo-data.php
+```
+
+## Compatibility guarantees
+
+This release does not change:
+
+- Database tables or columns
+- Indexes, foreign keys, triggers, or migrations
+- License-key format
+- License generation
+- License validation
+- Device registration
+- API URLs
+- API request or response JSON
+- Legacy API behavior
+- Admin routes or page design
+- Cron entry points
+- CSS or JavaScript
+- Existing encrypted data
+
+Table prefixes remain unsupported because fixed table names are part of the frozen schema and runtime-query contract. The installer field must remain blank.
+
+## Upgrade instructions
+
+Existing v5.0.1 and v5.0.1.1 installations:
+
+1. Back up the database.
+2. Back up private configuration and encryption-key material.
+3. Replace application source with v5.1.0.
+4. Preserve `includes/config.local.php` and private key files.
+5. Do not run the first-run installer.
+6. Run `bash scripts/validate.sh`.
+7. Verify admin, API, license, device, dashboard, cron, settings, and encrypted-data compatibility.
+
+No v5.1.0 database migration is required.
+
+## Validation
+
+The repository validation suite covers:
+
+- PHP syntax
+- Security smoke tests
+- Compatibility regression tests
+- Installer smoke tests
+- SQL delimiter parsing
+- Strong password validation
+- Table-prefix rejection
+- Installation-flag redaction
+- Versioned demo encryption
+- Existing license format
+- Immutable database and migration hashes
+- Preserved API and route contracts
+- JavaScript syntax
+- Public-release marker scanning
+- SQL seed-scope validation
+
+## Documentation
+
+- `docs/INSTALLATION.md`
+- `docs/INSTALLER_ARCHITECTURE.md`
+- `docs/FIRST_RUN_GUIDE.md`
+- `docs/UPGRADE_GUIDE.md`
+- `docs/DEMO_DATA.md`
+- `PHASE2_INSTALLER_SUMMARY.md`
diff --git a/config.sample.php b/config.sample.php
index d0224fc..976068a 100644
--- a/config.sample.php
+++ b/config.sample.php
@@ -1,8 +1,16 @@
> /var/log/license-system-cleanup.log 2>&1
-0 8 * * * /usr/bin/php /var/www/license-system/cron/check_expiring.php >> /var/log/license-system-expiry.log 2>&1
+*/5 * * * * /usr/bin/php /var/www/licora/cron/cleanup.php >> /var/log/licora-cleanup.log 2>&1
+0 8 * * * /usr/bin/php /var/www/licora/cron/check_expiring.php >> /var/log/licora-expiry.log 2>&1
```
-The scripts are intended for CLI execution. The repository denies `cron/` over Apache; add equivalent Nginx rules.
+## Verification
-## 6. Verify the deployment
+After installation:
-- Open `admin/health.php` while authenticated.
+- Sign in at `admin/login.php`.
+- Open `admin/health.php`.
- Create a disposable API key and license.
-- Verify the license with `X-API-Key`.
-- Confirm device registration and logs.
-- Test backup restore in a separate database.
-- Run `bash scripts/validate.sh` on the deployed source tree.
+- Verify with `X-API-Key` and Bearer authentication.
+- Confirm device registration and audit logs.
+- Test backup restore separately.
+- Run `bash scripts/validate.sh`.
-## Upgrades
+## Upgrade installations
-Apply migration files in chronological order only after a backup. See [MIGRATIONS.md](MIGRATIONS.md).
+Existing v5.0.1 and v5.0.1.1 deployments must not run the first-run wizard. Preserve private configuration and encrypted-key material, replace application source, and follow `UPGRADE_GUIDE.md`.
diff --git a/docs/INSTALLER_ARCHITECTURE.md b/docs/INSTALLER_ARCHITECTURE.md
new file mode 100644
index 0000000..a5194fd
--- /dev/null
+++ b/docs/INSTALLER_ARCHITECTURE.md
@@ -0,0 +1,103 @@
+# Installer Architecture
+
+## Scope
+
+The v5.1.0 installer is an independent wrapper around the existing Licora installation process. It does not change the license engine, license validation, API contracts, admin panel, schema, migrations, or existing routes.
+
+## Request flow
+
+```text
+HTTP request
+ |
+ v
+includes/config.php
+ |
+ v
+Installation guard
+ |-- no configuration ----------------> /install
+ |-- incomplete fresh schema ----------> /install
+ |-- valid legacy install --------------> backfill flag -> normal boot
+ |-- valid flagged install -------------> normal boot
+ `-- configured database outage --------> existing database error flow
+```
+
+The guard is bypassed for CLI execution and installer routes.
+
+## Preserved and additive routes
+
+- Preserved: `/install.php`
+- Added alias: `/install` through `install/index.php`
+
+No route is renamed or removed.
+
+## Ten-step wizard
+
+1. Welcome and server requirements
+2. Database configuration and connection validation
+3. Administrator setup
+4. Application configuration and secret generation
+5. Existing schema initialization review
+6. Optional DEMO data selection
+7. Installation-lock confirmation
+8. Atomic finalization
+9. Success screen
+10. Redirect to admin login without auto-login
+
+The wizard stores in-progress data in the server-side session. Administrator passwords are converted to a password hash before being stored in wizard state. Raw generated API credentials are never displayed.
+
+## Atomic finalization
+
+```text
+Validate all state
+ -> connect to server
+ -> create/select target database
+ -> snapshot pre-existing tables
+ -> write temporary private config
+ -> write temporary lock
+ -> parse and execute unchanged database.sql
+ -> replace temporary seeded administrator
+ -> insert application settings
+ -> optionally insert marked DEMO records
+ -> atomically activate config.local.php
+ -> atomically activate .licora-installed
+ -> clear installer session
+```
+
+If installation fails before activation:
+
+- A database created by the installer is removed when possible.
+- In an existing target database, only tables created during the failed attempt are removed.
+- Unrelated pre-existing tables are never removed.
+- Temporary private files are deleted.
+- Credentials and secrets are not logged.
+
+## SQL execution
+
+The installer parser supports the `DELIMITER` directives used by the existing schema triggers. It executes the repository's unchanged `database.sql`, including existing indexes, constraints, triggers, and additive migrations.
+
+## Installation flag
+
+`includes/.licora-installed` contains only:
+
+```json
+{
+ "product": "Licora",
+ "version": "5.1.0",
+ "installed_at": "ISO-8601 timestamp"
+}
+```
+
+No secrets are stored in the flag.
+
+## Legacy installation compatibility
+
+An older valid deployment may not have `APP_KEY`, `DB_PORT`, or an installation flag. Compatibility rules are:
+
+- Existing usable encryption, CSRF, or JWT secrets qualify the legacy deployment as configured.
+- Missing `DB_PORT` defaults to `3306`.
+- Missing flag is backfilled only after database and required-table validation succeeds.
+- Database outage never causes an installed deployment to reopen the installer.
+
+## Table prefix
+
+The wizard displays the requested optional field, but only a blank value is accepted. Existing schema and runtime queries use fixed table names, so non-empty prefixes would violate the compatibility contract.
diff --git a/docs/UPGRADE_GUIDE.md b/docs/UPGRADE_GUIDE.md
new file mode 100644
index 0000000..aa865c0
--- /dev/null
+++ b/docs/UPGRADE_GUIDE.md
@@ -0,0 +1,61 @@
+# Upgrade Guide
+
+## Supported path
+
+```text
+v5.0.1 -> v5.0.1.1 -> v5.1.0
+```
+
+The v5.1.0 installer is for fresh installations only. Existing deployments are never required to reinstall.
+
+## Upgrade procedure
+
+1. Back up the complete database.
+2. Back up `includes/config.local.php`.
+3. Back up `includes/.licora-encryption.key` when present.
+4. Record environment variables used by the deployment.
+5. Confirm the existing application works before upgrading.
+6. Replace source files with v5.1.0.
+7. Preserve all private configuration and encrypted-key material.
+8. Do not open the installer.
+9. Run `bash scripts/validate.sh`.
+10. Sign in and run admin, API, license, device, dashboard, cron, and settings regression checks.
+
+## No database migration
+
+Phase 2 does not change the database schema. Do not create a v5.1.0 migration. Existing schema and migration files remain byte-for-byte unchanged.
+
+## Legacy configuration
+
+Older configurations without these constants remain supported:
+
+- `DB_PORT` defaults to `3306`.
+- `APP_TIMEZONE` defaults to `Asia/Dhaka`.
+- `APP_LOCALE` defaults to `en`.
+- `MAIL_FROM_NAME` defaults to `APP_NAME`.
+- `APP_KEY` is additive and is not required to force an existing installation through the installer.
+
+## Installation flag backfill
+
+A valid existing installation without `includes/.licora-installed` continues normal boot. After configuration, database connection, required-table, and secret checks pass, Licora attempts to create the non-secret flag. Failure to backfill the flag does not interrupt an otherwise valid legacy deployment.
+
+## Database outage behavior
+
+A configured deployment with a temporarily unavailable database retains the existing database-error response. It is never redirected into the fresh installer.
+
+## Verification matrix
+
+- Admin login/logout
+- Session timeout
+- License create/verify
+- Device register/reconnect
+- `X-API-Key`
+- Bearer authentication
+- Legacy API
+- Viewer restrictions
+- Manager/Super Admin actions
+- Dashboard
+- Cron
+- Settings
+- Installer lock
+- Legacy encrypted values
diff --git a/includes/config.php b/includes/config.php
index 17f38d0..d9ec0fa 100644
--- a/includes/config.php
+++ b/includes/config.php
@@ -34,6 +34,7 @@ function env_value($key, $default = '') {
// ডেটাবেস কনফিগারেশন
if (!defined('DB_HOST')) define('DB_HOST', env_value('LICENSE_DB_HOST', env_value('DB_HOST', 'localhost')));
+if (!defined('DB_PORT')) define('DB_PORT', (int)env_value('LICENSE_DB_PORT', env_value('DB_PORT', 3306)));
if (!defined('DB_NAME')) define('DB_NAME', env_value('LICENSE_DB_NAME', env_value('DB_NAME', '')));
if (!defined('DB_USER')) define('DB_USER', env_value('LICENSE_DB_USER', env_value('DB_USER', '')));
if (!defined('DB_PASS')) define('DB_PASS', env_value('LICENSE_DB_PASS', env_value('DB_PASS', '')));
@@ -41,10 +42,14 @@ function env_value($key, $default = '') {
// এপ্লিকেশন সেটিংস
if (!defined('APP_NAME')) define('APP_NAME', env_value('APP_NAME', 'License System'));
if (!defined('APP_URL')) define('APP_URL', env_value('APP_URL', 'http://localhost'));
-if (!defined('APP_VERSION')) define('APP_VERSION', env_value('APP_VERSION', '5.0.1.1'));
+if (!defined('APP_VERSION')) define('APP_VERSION', env_value('APP_VERSION', '5.1.0'));
+if (!defined('APP_TIMEZONE')) define('APP_TIMEZONE', env_value('APP_TIMEZONE', 'Asia/Dhaka'));
+if (!defined('APP_LOCALE')) define('APP_LOCALE', env_value('APP_LOCALE', 'en'));
+if (!defined('MAIL_FROM_NAME')) define('MAIL_FROM_NAME', env_value('MAIL_FROM_NAME', APP_NAME));
if (!defined('ENVIRONMENT')) define('ENVIRONMENT', env_value('APP_ENV', 'production'));
// সিকিউরিটি সেটিংস
+if (!defined('APP_KEY')) define('APP_KEY', env_value('LICENSE_APP_KEY', env_value('APP_KEY', '')));
if (!defined('ENCRYPTION_KEY')) define('ENCRYPTION_KEY', env_value('LICENSE_ENCRYPTION_KEY', ''));
if (!defined('CSRF_SECRET')) define('CSRF_SECRET', env_value('LICENSE_CSRF_SECRET', ''));
if (!defined('JWT_SECRET')) define('JWT_SECRET', env_value('LICENSE_JWT_SECRET', ''));
@@ -53,7 +58,12 @@ function env_value($key, $default = '') {
if (!defined('API_RATE_LIMIT')) define('API_RATE_LIMIT', (int)env_value('API_RATE_LIMIT', 1000));
if (!defined('API_VERSION')) define('API_VERSION', env_value('API_VERSION', 'v1'));
-date_default_timezone_set('Asia/Dhaka');
+// The installation guard is additive and only redirects incomplete fresh installations.
+// Valid existing installations and temporary database outages retain the previous boot flow.
+require_once __DIR__ . '/installation.php';
+licora_enforce_installation_guard(dirname(__DIR__));
+
+date_default_timezone_set(APP_TIMEZONE);
// এরর রিপোর্টিং
if (ENVIRONMENT === 'production') {
diff --git a/includes/database.php b/includes/database.php
index ac90741..212a5c4 100644
--- a/includes/database.php
+++ b/includes/database.php
@@ -4,10 +4,19 @@
class Database {
private static $instance = null;
private $connection;
-
+
private function __construct() {
try {
- $dsn = "mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4";
+ $host = (string)DB_HOST;
+ $port = defined('DB_PORT') ? (int)DB_PORT : 3306;
+ if (substr_count($host, ':') === 1) {
+ [$candidateHost, $candidatePort] = explode(':', $host, 2);
+ if ($candidateHost !== '' && ctype_digit($candidatePort)) {
+ $host = $candidateHost;
+ $port = (int)$candidatePort;
+ }
+ }
+ $dsn = "mysql:host=" . $host . ";port=" . $port . ";dbname=" . DB_NAME . ";charset=utf8mb4";
$this->connection = new PDO($dsn, DB_USER, DB_PASS);
$this->connection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->connection->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
@@ -21,16 +30,16 @@ private function __construct() {
}
}
}
-
+
public static function getInstance() {
if (self::$instance === null) {
self::$instance = new Database();
}
return self::$instance->connection;
}
-
+
public static function close() {
self::$instance = null;
}
}
-?>
\ No newline at end of file
+?>
diff --git a/includes/installation.php b/includes/installation.php
new file mode 100644
index 0000000..43d274a
--- /dev/null
+++ b/includes/installation.php
@@ -0,0 +1,784 @@
+ false, 'tables_valid' => false];
+ }
+
+ $database = trim((string)DB_NAME);
+ if ($database === '') {
+ return ['connected' => false, 'tables_valid' => false];
+ }
+
+ $port = defined('DB_PORT') ? (int)DB_PORT : 3306;
+ try {
+ $pdo = new PDO(
+ licora_installation_dsn((string)DB_HOST, $port, $database),
+ (string)DB_USER,
+ defined('DB_PASS') ? (string)DB_PASS : '',
+ [
+ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ PDO::ATTR_EMULATE_PREPARES => false,
+ PDO::ATTR_TIMEOUT => 3,
+ ]
+ );
+
+ $statement = $pdo->query('SHOW TABLES');
+ $tables = array_map('strval', $statement->fetchAll(PDO::FETCH_COLUMN));
+ $missing = array_diff(licora_installation_required_tables(), $tables);
+ return ['connected' => true, 'tables_valid' => $missing === []];
+ } catch (Throwable $e) {
+ return ['connected' => false, 'tables_valid' => false];
+ }
+ }
+}
+
+if (!function_exists('licora_installation_write_flag')) {
+ function licora_installation_write_flag(?string $root = null, ?string $version = null): bool
+ {
+ $root = licora_installation_root($root);
+ $path = licora_installation_flag_path($root);
+ $directory = dirname($path);
+ if (!is_dir($directory) || !is_writable($directory)) {
+ return false;
+ }
+
+ $payload = [
+ 'product' => 'Licora',
+ 'version' => $version ?? (defined('APP_VERSION') ? (string)APP_VERSION : '5.1.0'),
+ 'installed_at' => gmdate('c'),
+ ];
+ $json = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ if ($json === false) {
+ return false;
+ }
+
+ $temporary = $path . '.tmp.' . bin2hex(random_bytes(6));
+ if (file_put_contents($temporary, $json . PHP_EOL, LOCK_EX) === false) {
+ return false;
+ }
+ @chmod($temporary, 0600);
+ if (!@rename($temporary, $path)) {
+ @unlink($temporary);
+ return false;
+ }
+ @chmod($path, 0600);
+ return true;
+ }
+}
+
+if (!function_exists('licora_enforce_installation_guard')) {
+ function licora_enforce_installation_guard(?string $root = null): void
+ {
+ if (PHP_SAPI === 'cli' || licora_installation_is_installer_request()) {
+ return;
+ }
+
+ $root = licora_installation_root($root);
+ $configured = is_file(licora_installation_config_path($root)) || licora_installation_environment_configured();
+ if (!$configured) {
+ licora_installation_redirect();
+ return;
+ }
+
+ $secretsValid = false;
+ foreach (['APP_KEY', 'ENCRYPTION_KEY', 'CSRF_SECRET', 'JWT_SECRET'] as $constant) {
+ if (defined($constant) && licora_installation_secret_is_usable(constant($constant))) {
+ $secretsValid = true;
+ break;
+ }
+ }
+ if (!$secretsValid) {
+ licora_installation_redirect();
+ return;
+ }
+
+ if (is_file(licora_installation_flag_path($root))) {
+ return;
+ }
+
+ $state = licora_installation_database_state();
+ if ($state['connected'] && $state['tables_valid']) {
+ licora_installation_write_flag($root);
+ return;
+ }
+
+ if ($state['connected'] && !$state['tables_valid']) {
+ licora_installation_redirect();
+ return;
+ }
+
+ // Preserve the existing database-error flow during a temporary outage.
+ // A configured deployment must never reopen the installer because its DB is unavailable.
+ }
+}
+
+if (!function_exists('licora_installer_is_locked')) {
+ function licora_installer_is_locked(?string $root = null): bool
+ {
+ $root = licora_installation_root($root);
+ if (is_file(licora_installation_flag_path($root))
+ || is_file(licora_installation_config_path($root))
+ || file_exists($root . '/config.php')) {
+ return true;
+ }
+
+ if (!licora_installation_environment_configured()) {
+ return false;
+ }
+
+ foreach (['LICENSE_APP_KEY', 'APP_KEY', 'LICENSE_ENCRYPTION_KEY', 'LICENSE_CSRF_SECRET', 'LICENSE_JWT_SECRET'] as $key) {
+ $value = getenv($key);
+ if ($value !== false && licora_installation_secret_is_usable($value)) {
+ return true;
+ }
+ }
+
+ // Environment database settings without any usable secret are treated as
+ // incomplete first-run configuration and may continue into the installer.
+ return false;
+ }
+}
+
+if (!function_exists('licora_installer_requirements')) {
+ function licora_installer_requirements(?string $root = null): array
+ {
+ $root = licora_installation_root($root);
+ $requirements = [
+ ['label' => 'PHP 8.0 or newer', 'status' => version_compare(PHP_VERSION, '8.0.0', '>='), 'required' => true, 'detail' => PHP_VERSION],
+ ['label' => 'PDO extension', 'status' => extension_loaded('pdo'), 'required' => true, 'detail' => extension_loaded('pdo') ? 'Loaded' : 'Missing'],
+ ['label' => 'PDO MySQL extension', 'status' => extension_loaded('pdo_mysql'), 'required' => true, 'detail' => extension_loaded('pdo_mysql') ? 'Loaded' : 'Missing'],
+ ['label' => 'OpenSSL extension', 'status' => extension_loaded('openssl'), 'required' => true, 'detail' => extension_loaded('openssl') ? 'Loaded' : 'Missing'],
+ ['label' => 'JSON extension', 'status' => extension_loaded('json'), 'required' => true, 'detail' => extension_loaded('json') ? 'Loaded' : 'Missing'],
+ ['label' => 'Writable includes directory', 'status' => is_writable($root . '/includes'), 'required' => true, 'detail' => $root . '/includes'],
+ ['label' => 'Readable database schema', 'status' => is_readable($root . '/database.sql'), 'required' => true, 'detail' => 'database.sql'],
+ ['label' => 'HTTPS transport', 'status' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', 'required' => false, 'detail' => 'Required for production'],
+ ];
+ return $requirements;
+ }
+}
+
+if (!function_exists('licora_installer_requirements_pass')) {
+ function licora_installer_requirements_pass(array $requirements): bool
+ {
+ foreach ($requirements as $requirement) {
+ if (!empty($requirement['required']) && empty($requirement['status'])) {
+ return false;
+ }
+ }
+ return true;
+ }
+}
+
+if (!function_exists('licora_installer_validate_database')) {
+ function licora_installer_validate_database(array $input): array
+ {
+ $errors = [];
+ $host = trim((string)($input['host'] ?? ''));
+ $port = (int)($input['port'] ?? 3306);
+ $name = trim((string)($input['name'] ?? ''));
+ $user = trim((string)($input['user'] ?? ''));
+ $prefix = trim((string)($input['table_prefix'] ?? ''));
+ $charset = trim((string)($input['charset'] ?? 'utf8mb4'));
+ $collation = trim((string)($input['collation'] ?? 'utf8mb4_unicode_ci'));
+
+ if ($host === '' || strlen($host) > 255 || !preg_match('/^[A-Za-z0-9_.\-:\[\]]+$/', $host)) {
+ $errors[] = 'Database host is required and may contain only hostname or IP-address characters.';
+ }
+ if ($port < 1 || $port > 65535) {
+ $errors[] = 'Database port must be between 1 and 65535.';
+ }
+ if (!preg_match('/^[A-Za-z0-9_]+$/', $name)) {
+ $errors[] = 'Database name may contain only letters, numbers, and underscores.';
+ }
+ if ($user === '' || strlen($user) > 128) {
+ $errors[] = 'Database username is required.';
+ }
+ if ($prefix !== '') {
+ $errors[] = 'Table prefixes are not supported by the frozen Licora database contract. Leave this field blank.';
+ }
+ if ($charset !== 'utf8mb4') {
+ $errors[] = 'Licora requires the utf8mb4 database charset.';
+ }
+ if (!in_array($collation, ['utf8mb4_unicode_ci', 'utf8mb4_general_ci'], true)) {
+ $errors[] = 'Unsupported database collation.';
+ }
+ return $errors;
+ }
+}
+
+if (!function_exists('licora_installer_test_database')) {
+ function licora_installer_test_database(array $input): array
+ {
+ $errors = licora_installer_validate_database($input);
+ if ($errors !== []) {
+ return ['success' => false, 'message' => $errors[0]];
+ }
+
+ try {
+ $pdo = new PDO(
+ licora_installation_dsn((string)$input['host'], (int)$input['port'], '', (string)$input['charset']),
+ (string)$input['user'],
+ (string)($input['pass'] ?? ''),
+ [
+ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ PDO::ATTR_EMULATE_PREPARES => false,
+ PDO::ATTR_TIMEOUT => 5,
+ ]
+ );
+ $pdo->query('SELECT 1');
+ return ['success' => true, 'message' => 'Database server connection verified.'];
+ } catch (Throwable $e) {
+ return ['success' => false, 'message' => 'Unable to connect to the database server with the supplied credentials.'];
+ }
+ }
+}
+
+if (!function_exists('licora_installer_validate_admin')) {
+ function licora_installer_validate_admin(array $input): array
+ {
+ $errors = [];
+ $name = trim((string)($input['admin_name'] ?? ''));
+ $email = trim((string)($input['admin_email'] ?? ''));
+ $username = trim((string)($input['admin_username'] ?? ''));
+ $password = (string)($input['admin_password'] ?? '');
+ $confirm = (string)($input['admin_password_confirm'] ?? '');
+
+ if (strlen($name) < 2 || strlen($name) > 120) {
+ $errors[] = 'Administrator name must be between 2 and 120 characters.';
+ }
+ if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > 100) {
+ $errors[] = 'Enter a valid administrator email address.';
+ }
+ if (!preg_match('/^[A-Za-z0-9_.-]{3,50}$/', $username)) {
+ $errors[] = 'Administrator username must be 3-50 characters and may use letters, numbers, dot, underscore, and hyphen.';
+ }
+ if (strlen($password) < 12
+ || !preg_match('/[A-Z]/', $password)
+ || !preg_match('/[a-z]/', $password)
+ || !preg_match('/[0-9]/', $password)
+ || !preg_match('/[^A-Za-z0-9]/', $password)) {
+ $errors[] = 'Password must be at least 12 characters and include uppercase, lowercase, number, and symbol.';
+ }
+ if (!hash_equals($password, $confirm)) {
+ $errors[] = 'Password confirmation does not match.';
+ }
+ return $errors;
+ }
+}
+
+if (!function_exists('licora_installer_validate_application')) {
+ function licora_installer_validate_application(array $input): array
+ {
+ $errors = [];
+ $name = trim((string)($input['app_name'] ?? ''));
+ $timezone = trim((string)($input['timezone'] ?? ''));
+ $locale = trim((string)($input['locale'] ?? ''));
+ $url = rtrim(trim((string)($input['base_url'] ?? '')), '/');
+ $mailFrom = trim((string)($input['mail_from_name'] ?? ''));
+
+ if (strlen($name) < 2 || strlen($name) > 120) {
+ $errors[] = 'Application name must be between 2 and 120 characters.';
+ }
+ if (!in_array($timezone, timezone_identifiers_list(), true)) {
+ $errors[] = 'Select a valid PHP timezone.';
+ }
+ if (!preg_match('/^[A-Za-z]{2,3}(?:[_-][A-Za-z]{2})?$/', $locale)) {
+ $errors[] = 'Locale must use a value such as en or en_US.';
+ }
+ if (!filter_var($url, FILTER_VALIDATE_URL) || !preg_match('#^https?://#i', $url)) {
+ $errors[] = 'Base URL must be a valid HTTP or HTTPS URL.';
+ }
+ if (strlen($mailFrom) < 2 || strlen($mailFrom) > 120) {
+ $errors[] = 'Mail From Name must be between 2 and 120 characters.';
+ }
+ return $errors;
+ }
+}
+
+if (!function_exists('licora_installer_detect_base_url')) {
+ function licora_installer_detect_base_url(): string
+ {
+ $secure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
+ $scheme = $secure ? 'https' : 'http';
+ $host = preg_replace('/[^A-Za-z0-9.\-:\[\]]/', '', (string)($_SERVER['HTTP_HOST'] ?? 'localhost'));
+ return $scheme . '://' . ($host !== '' ? $host : 'localhost') . licora_installation_base_path();
+ }
+}
+
+if (!function_exists('licora_installer_sql_statements')) {
+ function licora_installer_sql_statements(string $sql): array
+ {
+ $sql = preg_replace('/^\xEF\xBB\xBF/', '', $sql) ?? $sql;
+ $delimiter = ';';
+ $buffer = '';
+ $statements = [];
+ foreach (preg_split('/\R/', $sql) ?: [] as $line) {
+ if (preg_match('/^\s*DELIMITER\s+(.+)\s*$/i', $line, $matches)) {
+ $delimiter = trim($matches[1]);
+ continue;
+ }
+ $buffer .= $line . "\n";
+ $trimmed = rtrim($buffer);
+ if ($trimmed !== '' && substr($trimmed, -strlen($delimiter)) === $delimiter) {
+ $statement = trim(substr($trimmed, 0, -strlen($delimiter)));
+ if ($statement !== '') {
+ $statements[] = $statement;
+ }
+ $buffer = '';
+ }
+ }
+ if (trim($buffer) !== '') {
+ $statements[] = trim($buffer);
+ }
+ return $statements;
+ }
+}
+
+if (!function_exists('licora_installer_execute_schema')) {
+ function licora_installer_execute_schema(PDO $pdo, string $path): void
+ {
+ $sql = file_get_contents($path);
+ if ($sql === false) {
+ throw new RuntimeException('Database schema is unavailable.');
+ }
+ foreach (licora_installer_sql_statements($sql) as $statement) {
+ $pdo->exec($statement);
+ }
+ }
+}
+
+if (!function_exists('licora_installer_snapshot_tables')) {
+ function licora_installer_snapshot_tables(PDO $pdo): array
+ {
+ return array_map('strval', $pdo->query('SHOW TABLES')->fetchAll(PDO::FETCH_COLUMN));
+ }
+}
+
+if (!function_exists('licora_installer_cleanup_new_tables')) {
+ function licora_installer_cleanup_new_tables(PDO $pdo, array $before): void
+ {
+ try {
+ $after = licora_installer_snapshot_tables($pdo);
+ $newTables = array_values(array_intersect(
+ array_diff($after, $before),
+ licora_installation_required_tables()
+ ));
+ if ($newTables === []) {
+ return;
+ }
+ $pdo->exec('SET FOREIGN_KEY_CHECKS=0');
+ foreach ($newTables as $table) {
+ if (preg_match('/^[A-Za-z0-9_]+$/', $table)) {
+ $pdo->exec('DROP TABLE IF EXISTS `' . $table . '`');
+ }
+ }
+ $pdo->exec('SET FOREIGN_KEY_CHECKS=1');
+ } catch (Throwable $e) {
+ error_log('Licora installer cleanup could not complete.');
+ }
+ }
+}
+
+if (!function_exists('licora_installer_build_config')) {
+ function licora_installer_build_config(array $data): string
+ {
+ $values = [
+ 'DB_HOST' => (string)$data['db']['host'],
+ 'DB_PORT' => (int)$data['db']['port'],
+ 'DB_NAME' => (string)$data['db']['name'],
+ 'DB_USER' => (string)$data['db']['user'],
+ 'DB_PASS' => (string)$data['db']['pass'],
+ 'APP_NAME' => (string)$data['app']['app_name'],
+ 'APP_URL' => rtrim((string)$data['app']['base_url'], '/'),
+ 'APP_VERSION' => '5.1.0',
+ 'APP_TIMEZONE' => (string)$data['app']['timezone'],
+ 'APP_LOCALE' => (string)$data['app']['locale'],
+ 'MAIL_FROM_NAME' => (string)$data['app']['mail_from_name'],
+ 'APP_KEY' => (string)$data['secrets']['app_key'],
+ 'ENCRYPTION_KEY' => (string)$data['secrets']['encryption_key'],
+ 'CSRF_SECRET' => (string)$data['secrets']['csrf_secret'],
+ 'JWT_SECRET' => (string)$data['secrets']['jwt_secret'],
+ ];
+
+ $lines = [" $value) {
+ $lines[] = "if (!defined('{$name}')) define('{$name}', " . var_export($value, true) . ');';
+ }
+ $lines[] = '?>';
+ return implode(PHP_EOL, $lines) . PHP_EOL;
+ }
+}
+
+if (!function_exists('licora_installer_encrypt')) {
+ function licora_installer_encrypt(string $value, string $secret): string
+ {
+ $master = hash('sha256', $secret, true);
+ $encryptionKey = hash_hmac('sha256', 'licora-encryption-v2', $master, true);
+ $authenticationKey = hash_hmac('sha256', 'licora-authentication-v2', $master, true);
+ $iv = random_bytes(16);
+ $ciphertext = openssl_encrypt($value, 'AES-256-CBC', $encryptionKey, OPENSSL_RAW_DATA, $iv);
+ if ($ciphertext === false) {
+ throw new RuntimeException('Unable to encrypt installer data.');
+ }
+ $mac = hash_hmac('sha256', $iv . $ciphertext, $authenticationKey, true);
+ return 'v2:' . base64_encode($iv . $mac . $ciphertext);
+ }
+}
+
+if (!function_exists('licora_installer_generate_license_key')) {
+ function licora_installer_generate_license_key(): string
+ {
+ $segments = [];
+ for ($i = 0; $i < 4; $i++) {
+ $segments[] = strtoupper(bin2hex(random_bytes(4)));
+ }
+ return implode('-', $segments);
+ }
+}
+
+if (!function_exists('licora_installer_seed_demo')) {
+ function licora_installer_seed_demo(PDO $pdo, int $adminId, array $data): array
+ {
+ $apiKey = bin2hex(random_bytes(32));
+ $apiKeyHash = hash('sha256', $apiKey);
+ $encryptedApiKey = licora_installer_encrypt($apiKey, (string)$data['secrets']['encryption_key']);
+ $apiStmt = $pdo->prepare(
+ 'INSERT INTO api_keys (api_key_hash, api_key_encrypted, name, app_name, scope_label, user_id, is_active, rate_limit_per_hour) '
+ . 'VALUES (:hash, :encrypted, :name, :app_name, :scope, :user_id, 1, 1000)'
+ );
+ $apiStmt->execute([
+ ':hash' => $apiKeyHash,
+ ':encrypted' => $encryptedApiKey,
+ ':name' => '[DEMO] Installer API Credential',
+ ':app_name' => 'DEMO PRODUCT',
+ ':scope' => 'DEMO',
+ ':user_id' => $adminId,
+ ]);
+ $apiKeyId = (int)$pdo->lastInsertId();
+
+ $licenseKey = licora_installer_generate_license_key();
+ $licenseStmt = $pdo->prepare(
+ 'INSERT INTO licenses (license_key, encrypted_key, created_by, notes, app_scope, api_key_id, expires_at, device_limit, status) '
+ . 'VALUES (:license_key, :encrypted_key, :created_by, :notes, :app_scope, :api_key_id, :expires_at, 1, \'active\')'
+ );
+ $licenseStmt->execute([
+ ':license_key' => $licenseKey,
+ ':encrypted_key' => licora_installer_encrypt($licenseKey, (string)$data['secrets']['encryption_key']),
+ ':created_by' => $adminId,
+ ':notes' => '[DEMO CUSTOMER] Optional installer demonstration record. Safe to remove.',
+ ':app_scope' => 'DEMO PRODUCT',
+ ':api_key_id' => $apiKeyId,
+ ':expires_at' => gmdate('Y-m-d H:i:s', time() + 30 * 86400),
+ ]);
+ $licenseId = (int)$pdo->lastInsertId();
+
+ $setting = $pdo->prepare(
+ 'INSERT INTO settings (setting_key, setting_value) VALUES (:key, :value) '
+ . 'ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)'
+ );
+ foreach ([
+ 'demo_data_installed' => '1',
+ 'demo_api_key_id' => (string)$apiKeyId,
+ 'demo_license_id' => (string)$licenseId,
+ ] as $key => $value) {
+ $setting->execute([':key' => $key, ':value' => $value]);
+ }
+
+ // Raw credentials are intentionally never returned or displayed by the installer.
+ $apiKey = null;
+ $licenseKey = null;
+ return ['api_key_id' => $apiKeyId, 'license_id' => $licenseId];
+ }
+}
+
+if (!function_exists('licora_installer_finalize')) {
+ function licora_installer_finalize(?string $root, array $data): array
+ {
+ $root = licora_installation_root($root);
+ $db = $data['db'];
+ $databaseCreated = false;
+ $snapshot = [];
+ $pdo = null;
+ $configPath = licora_installation_config_path($root);
+ $configTemporary = $configPath . '.installing.' . bin2hex(random_bytes(6));
+ $flagPath = licora_installation_flag_path($root);
+ $flagTemporary = $flagPath . '.installing.' . bin2hex(random_bytes(6));
+ $configActivated = false;
+
+ if (is_file($configPath) || is_file($flagPath)) {
+ throw new RuntimeException('Licora is already installed.');
+ }
+
+ try {
+ $server = new PDO(
+ licora_installation_dsn((string)$db['host'], (int)$db['port'], '', (string)$db['charset']),
+ (string)$db['user'],
+ (string)$db['pass'],
+ [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false]
+ );
+ $existsStmt = $server->prepare('SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = :name');
+ $existsStmt->execute([':name' => (string)$db['name']]);
+ $databaseCreated = !$existsStmt->fetchColumn();
+ if ($databaseCreated) {
+ $server->exec(
+ 'CREATE DATABASE `' . $db['name'] . '` CHARACTER SET ' . $db['charset'] . ' COLLATE ' . $db['collation']
+ );
+ }
+
+ $pdo = new PDO(
+ licora_installation_dsn((string)$db['host'], (int)$db['port'], (string)$db['name'], (string)$db['charset']),
+ (string)$db['user'],
+ (string)$db['pass'],
+ [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false]
+ );
+ $snapshot = licora_installer_snapshot_tables($pdo);
+ if (array_intersect(licora_installation_required_tables(), $snapshot) !== []) {
+ throw new RuntimeException('The target database already contains Licora tables.');
+ }
+
+ $configContent = licora_installer_build_config($data);
+ if (file_put_contents($configTemporary, $configContent, LOCK_EX) === false) {
+ throw new RuntimeException('Unable to write the temporary configuration file.');
+ }
+ @chmod($configTemporary, 0600);
+
+ $flagPayload = json_encode([
+ 'product' => 'Licora',
+ 'version' => '5.1.0',
+ 'installed_at' => gmdate('c'),
+ ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ if ($flagPayload === false || file_put_contents($flagTemporary, $flagPayload . PHP_EOL, LOCK_EX) === false) {
+ throw new RuntimeException('Unable to prepare the installation lock.');
+ }
+ @chmod($flagTemporary, 0600);
+
+ licora_installer_execute_schema($pdo, $root . '/database.sql');
+
+ $pdo->beginTransaction();
+ try {
+ $pdo->exec("DELETE FROM admin_users WHERE username = 'admin' AND email = 'admin@example.invalid'");
+ $adminInsert = $pdo->prepare(
+ 'INSERT INTO admin_users (username, password, email, role, failed_attempts, two_factor_enabled, created_at) '
+ . "VALUES (:username, :password, :email, 'super_admin', 0, 0, NOW())"
+ );
+ $adminInsert->execute([
+ ':username' => (string)$data['admin']['username'],
+ ':password' => (string)$data['admin']['password_hash'],
+ ':email' => (string)$data['admin']['email'],
+ ]);
+ $adminId = (int)$pdo->lastInsertId();
+
+ $settings = [
+ 'administrator_name' => (string)$data['admin']['name'],
+ 'system_name' => (string)$data['app']['app_name'],
+ 'timezone' => (string)$data['app']['timezone'],
+ 'locale' => (string)$data['app']['locale'],
+ 'mail_from_name' => (string)$data['app']['mail_from_name'],
+ 'api_base_url' => rtrim((string)$data['app']['base_url'], '/') . '/api/verify.php',
+ 'installed_version' => '5.1.0',
+ 'demo_data_installed' => '0',
+ ];
+ $settingStmt = $pdo->prepare(
+ 'INSERT INTO settings (setting_key, setting_value) VALUES (:key, :value) '
+ . 'ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)'
+ );
+ foreach ($settings as $key => $value) {
+ $settingStmt->execute([':key' => $key, ':value' => $value]);
+ }
+
+ if (!empty($data['install_demo'])) {
+ licora_installer_seed_demo($pdo, $adminId, $data);
+ }
+ $pdo->commit();
+ } catch (Throwable $e) {
+ if ($pdo->inTransaction()) {
+ $pdo->rollBack();
+ }
+ throw $e;
+ }
+
+ if (!@rename($configTemporary, $configPath)) {
+ throw new RuntimeException('Unable to activate the configuration file.');
+ }
+ $configActivated = true;
+ @chmod($configPath, 0600);
+ if (!@rename($flagTemporary, $flagPath)) {
+ @unlink($configPath);
+ $configActivated = false;
+ throw new RuntimeException('Unable to activate the installation lock.');
+ }
+ @chmod($flagPath, 0600);
+
+ return [
+ 'version' => '5.1.0',
+ 'username' => (string)$data['admin']['username'],
+ 'application_url' => rtrim((string)$data['app']['base_url'], '/'),
+ 'admin_url' => rtrim((string)$data['app']['base_url'], '/') . '/admin/login.php',
+ 'api_url' => rtrim((string)$data['app']['base_url'], '/') . '/api/verify.php',
+ 'demo_installed' => !empty($data['install_demo']),
+ ];
+ } catch (Throwable $e) {
+ @unlink($configTemporary);
+ @unlink($flagTemporary);
+ if (!$configActivated && $pdo instanceof PDO) {
+ if ($databaseCreated) {
+ try {
+ $server->exec('DROP DATABASE IF EXISTS `' . $db['name'] . '`');
+ } catch (Throwable $cleanupError) {
+ error_log('Licora installer database cleanup could not complete.');
+ }
+ } else {
+ licora_installer_cleanup_new_tables($pdo, $snapshot);
+ }
+ }
+ error_log('Licora installer finalization failed [' . get_class($e) . '].');
+ throw new RuntimeException('Installation could not be completed. Verify database permissions and writable directories, then try again.');
+ }
+ }
+}
diff --git a/index.php b/index.php
index 586c847..df5cfb6 100644
--- a/index.php
+++ b/index.php
@@ -1,4 +1,5 @@
diff --git a/install.php b/install.php
index 0a2d07e..649d897 100644
--- a/install.php
+++ b/install.php
@@ -1,14 +1,19 @@
0,
'path' => '/',
'domain' => '',
'secure' => $secureCookie,
'httponly' => true,
- 'samesite' => 'Lax'
+ 'samesite' => 'Lax',
]);
session_start();
}
@@ -16,221 +21,377 @@
if (!headers_sent()) {
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: same-origin');
+ header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
+ header('Pragma: no-cache');
}
-function installer_is_locked() {
- if (file_exists(__DIR__ . '/config.php') || file_exists(__DIR__ . '/includes/config.local.php')) {
- return true;
- }
-
- $environmentDatabase = getenv('LICENSE_DB_NAME');
- if ($environmentDatabase === false || $environmentDatabase === '') {
- $environmentDatabase = getenv('DB_NAME');
- }
-
- return $environmentDatabase !== false && trim((string)$environmentDatabase) !== '';
+function installer_escape($value): string
+{
+ return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
-function render_installer_locked() {
+function installer_render_locked(): void
+{
+ $basePath = licora_installation_base_path();
http_response_code(403);
header('Content-Type: text/html; charset=utf-8');
- echo '
Installer Locked
Installer Locked
Licora detected an existing installation and blocked installer execution.
No configuration or database data was changed.
For an intentional recovery or reinstall, follow docs/INSTALLATION.md and back up the current configuration and database before temporarily removing the installation lock.
Licora detected an existing installation and disabled installer execution. No configuration or database data was changed.
';
+ echo '
For an intentional recovery, place the site in private maintenance mode, back up the database and private configuration, and follow docs/INSTALLATION.md and docs/FIRST_RUN_GUIDE.md.
Professional installation wizard for Licora v5.1.0
+
+ Step of 10
+
+
+
+
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
+
+
-
-
-
+
+
-
-
-
+
+
-
- Note: This will create a new database and all required tables.
-
+
+
Licora will initialize the unchanged, existing database schema from database.sql, including its existing indexes, constraints, triggers, and additive migrations.
+
The target database must not already contain Licora tables. Unrelated existing tables are never removed. If installation fails, only installer-created objects are cleaned up.
Ready to install. Licora has validated the server, database credentials, administrator data, application configuration, and lock requirements.
+
+
Database password and generated secrets will not be displayed.
+
The existing schema and application business logic remain unchanged.
+
Temporary development administrator credentials will be replaced immediately.
+
Installer execution will be disabled after completion.
+
+
-
+
+
Installation Successful
Licora is installed and the first-run installer is locked.
+
+
Installed Version
+
Administrator Username
+
Application URL
+
Admin URL
+
API URL
+
Demo Data
+
+
Security recommendations: enable HTTPS, protect private configuration, schedule cron securely, test backups, and verify API authentication before public exposure.