fix: harden portable host installation

This commit is contained in:
Marco0300
2026-09-01 19:26:10 +02:00
parent 5b2c64ff39
commit 323afaf832
6 changed files with 37 additions and 24 deletions
+3 -3
View File
@@ -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
+3 -1
View File
@@ -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) {
+1 -2
View File
@@ -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;
+16 -16
View File
@@ -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';
+7 -2
View File
@@ -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.
<FilesMatch "^(\.env|.*\.(sql|log|ini))$">
# Deny common sensitive artifacts if this file is accidentally reused elsewhere.
<FilesMatch "^(\.env|.*\.(sql|log|ini|yaml|yml|json|lock))$">
Require all denied
</FilesMatch>
+7
View File
@@ -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');