diff --git a/README.md b/README.md index b34bc28..37f10a6 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Portable PHP/MariaDB implementation of the JOBcard & Client Management System. T ## Installation on a standard host 1. Create a MariaDB/MySQL database and database user. -2. Upload the repository outside the public web root where possible. +2. Upload the repository outside the public web root. 3. Set the virtual host/document root to the `public/` directory. 4. Copy `.env.example` to `.env` and replace every placeholder with production values. 5. Restrict `.env` permissions, for example `chmod 600 .env`. @@ -32,10 +32,10 @@ The installer must be run once against a new database. It creates the schema and - Create a Virtualmin virtual server and MariaDB database/user. - Set the virtual server PHP version to PHP 8.2+ and use PHP-FPM. - Set the document root to `jobcard-system/public`. -- Enable Apache `mod_rewrite`; the included `public/.htaccess` routes requests to `public/index.php`. +- Enable Apache `mod_rewrite` and permit `AllowOverride FileInfo` (or configure equivalent virtual-host rewrite rules); the included `public/.htaccess` routes requests to `public/index.php`. - Enable HTTPS with Virtualmin/Let's Encrypt. - Run `php bin/install.php` from the application directory using the same PHP version configured for the domain. -- Keep `.env`, `config/`, `database/`, `bin/` and `tests/` outside the public document root when the Virtualmin layout allows it. If the repository root must be inside the domain, the included rules deny common sensitive file types and the public root remains `public/`. +- The `public/` directory is mandatory as the virtual server document root. Never configure the repository root as the document root; `public/.htaccess` cannot protect files located in parent directories. - Schedule database and upload backups using the hosting provider's backup system or cron. ## Nginx alternative diff --git a/bin/install.php b/bin/install.php index 9f3cdfc..b5edc32 100644 --- a/bin/install.php +++ b/bin/install.php @@ -11,7 +11,9 @@ require_once __DIR__ . '/../config/bootstrap.php'; try { $schemaPath = __DIR__ . '/../database/schema.sql'; if (!is_readable($schemaPath)) throw new RuntimeException('database/schema.sql is missing or unreadable'); - db()->exec(file_get_contents($schemaPath)); + $schema = file_get_contents($schemaPath); + if ($schema === false || trim($schema) === '') throw new RuntimeException('database/schema.sql could not be read'); + db()->exec($schema); ensure_initial_administrator(); fwrite(STDOUT, "Database schema installed and initial Administrator verified.\n"); } catch (Throwable $exception) { diff --git a/config/bootstrap.php b/config/bootstrap.php index 6ae7f32..04b7799 100644 --- a/config/bootstrap.php +++ b/config/bootstrap.php @@ -11,7 +11,7 @@ function load_dotenv(string $path): void if ($separator === false) continue; $name = trim(substr($line, 0, $separator)); $value = trim(substr($line, $separator + 1)); - if ($name === '' || getenv($name) !== false) continue; + if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $name) || getenv($name) !== false) continue; if (strlen($value) >= 2 && (($value[0] === '"' && $value[-1] === '"') || ($value[0] === "'" && $value[-1] === "'"))) { $value = substr($value, 1, -1); } @@ -81,7 +81,6 @@ function ensure_initial_administrator(): void function current_user(): ?array { - ensure_initial_administrator(); static $user = false; if ($user !== false) return $user; $id = $_SESSION['user_id'] ?? null; diff --git a/database/schema.sql b/database/schema.sql index b7e8580..0dd353a 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -1,17 +1,17 @@ -CREATE TABLE roles ( +CREATE TABLE IF NOT EXISTS roles ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(80) NOT NULL UNIQUE, description VARCHAR(255) NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB; -CREATE TABLE permissions ( +CREATE TABLE IF NOT EXISTS permissions ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(120) NOT NULL UNIQUE, description VARCHAR(255) NULL ) ENGINE=InnoDB; -CREATE TABLE role_permissions ( +CREATE TABLE IF NOT EXISTS role_permissions ( role_id BIGINT UNSIGNED NOT NULL, permission_id BIGINT UNSIGNED NOT NULL, PRIMARY KEY (role_id, permission_id), @@ -19,7 +19,7 @@ CREATE TABLE role_permissions ( FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE ) ENGINE=InnoDB; -CREATE TABLE users ( +CREATE TABLE IF NOT EXISTS users ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, role_id BIGINT UNSIGNED NOT NULL, email VARCHAR(190) NOT NULL UNIQUE, @@ -32,7 +32,7 @@ CREATE TABLE users ( FOREIGN KEY (role_id) REFERENCES roles(id) ) ENGINE=InnoDB; -CREATE TABLE clients ( +CREATE TABLE IF NOT EXISTS clients ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(190) NOT NULL, registration_number VARCHAR(120) NULL, @@ -51,7 +51,7 @@ CREATE TABLE clients ( FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB; -CREATE TABLE client_contacts ( +CREATE TABLE IF NOT EXISTS client_contacts ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, client_id BIGINT UNSIGNED NOT NULL, name VARCHAR(120) NOT NULL, @@ -65,7 +65,7 @@ CREATE TABLE client_contacts ( INDEX contacts_client_idx (client_id) ) ENGINE=InnoDB; -CREATE TABLE sla_agreements ( +CREATE TABLE IF NOT EXISTS sla_agreements ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, client_id BIGINT UNSIGNED NOT NULL, enabled BOOLEAN NOT NULL DEFAULT TRUE, @@ -82,7 +82,7 @@ CREATE TABLE sla_agreements ( INDEX sla_client_idx (client_id) ) ENGINE=InnoDB; -CREATE TABLE jobcards ( +CREATE TABLE IF NOT EXISTS jobcards ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, reference_no VARCHAR(40) NOT NULL UNIQUE, client_id BIGINT UNSIGNED NOT NULL, @@ -103,7 +103,7 @@ CREATE TABLE jobcards ( INDEX jobcards_created_idx (created_at) ) ENGINE=InnoDB; -CREATE TABLE jobcard_assignments ( +CREATE TABLE IF NOT EXISTS jobcard_assignments ( jobcard_id BIGINT UNSIGNED NOT NULL, user_id BIGINT UNSIGNED NOT NULL, assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -114,7 +114,7 @@ CREATE TABLE jobcard_assignments ( FOREIGN KEY (assigned_by) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB; -CREATE TABLE time_entries ( +CREATE TABLE IF NOT EXISTS time_entries ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, jobcard_id BIGINT UNSIGNED NOT NULL, technician_id BIGINT UNSIGNED NOT NULL, @@ -134,7 +134,7 @@ CREATE TABLE time_entries ( INDEX time_date_idx (work_date) ) ENGINE=InnoDB; -CREATE TABLE audit_events ( +CREATE TABLE IF NOT EXISTS audit_events ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id BIGINT UNSIGNED NULL, action VARCHAR(120) NOT NULL, @@ -148,12 +148,12 @@ CREATE TABLE audit_events ( FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB; -INSERT INTO roles (name, description) VALUES +INSERT IGNORE INTO roles (name, description) VALUES ('Administrator', 'Full system access'), ('Accounts', 'Client, jobcard and reporting access'), ('Technician', 'Assigned support work access'); -INSERT INTO permissions (name, description) VALUES +INSERT IGNORE INTO permissions (name, description) VALUES ('dashboard.view', 'View the operational dashboard'), ('clients.view', 'View client records'), ('clients.manage', 'Create and edit client records'), @@ -166,15 +166,15 @@ INSERT INTO permissions (name, description) VALUES ('audit.view', 'View audit events'), ('credentials.view', 'View protected credentials'); -INSERT INTO role_permissions (role_id, permission_id) +INSERT IGNORE INTO role_permissions (role_id, permission_id) SELECT r.id, p.id FROM roles r CROSS JOIN permissions p WHERE r.name = 'Administrator'; -INSERT INTO role_permissions (role_id, permission_id) +INSERT IGNORE INTO role_permissions (role_id, permission_id) SELECT r.id, p.id FROM roles r JOIN permissions p ON p.name IN ('dashboard.view','clients.view','jobcards.view','reports.view','reports.export') WHERE r.name = 'Accounts'; -INSERT INTO role_permissions (role_id, permission_id) +INSERT IGNORE INTO role_permissions (role_id, permission_id) SELECT r.id, p.id FROM roles r JOIN permissions p ON p.name IN ('dashboard.view','clients.view','jobcards.view','jobcards.manage') WHERE r.name = 'Technician'; diff --git a/public/.htaccess b/public/.htaccess index 467c7b6..274eb7b 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -1,5 +1,10 @@ RewriteEngine On RewriteBase / +Options -Indexes + +# The public/ directory must be the virtual host document root. This file is +# defense in depth only and cannot protect files in parent directories. +RewriteRule "(^|/)[.]" - [F,L] # Keep real assets accessible and route application requests through the front controller. RewriteCond %{REQUEST_FILENAME} -f [OR] @@ -7,7 +12,7 @@ RewriteCond %{REQUEST_FILENAME} -d RewriteRule ^ - [L] RewriteRule ^ index.php [L] -# Never expose environment/config/database files through Apache. - +# Deny common sensitive artifacts if this file is accidentally reused elsewhere. + Require all denied diff --git a/tests/smoke.php b/tests/smoke.php index 599ab01..9b5efc0 100644 --- a/tests/smoke.php +++ b/tests/smoke.php @@ -11,6 +11,13 @@ unlink($tempEnv); if (getenv('JOBcard_LOADED') !== 'from-file') { throw new RuntimeException('Expected .env values to load from file'); } +$invalidEnv = tempnam(sys_get_temp_dir(), 'jobcard-env-invalid-'); +file_put_contents($invalidEnv, "NOT A NAME=should-not-load\nJOBcard_VALID=ok\n"); +load_dotenv($invalidEnv); +unlink($invalidEnv); +if (getenv('NOT A NAME') !== false || getenv('JOBcard_VALID') !== 'ok') { + throw new RuntimeException('Expected malformed .env names to be ignored'); +} $checks = 0; assert(env_required('JOBcard_TEST_VALUE') === 'present');