From 5b2c64ff399cbd8d7432f1c96fbef006929ddace Mon Sep 17 00:00:00 2001 From: Marco0300 Date: Tue, 1 Sep 2026 19:22:48 +0200 Subject: [PATCH] refactor: support portable PHP hosting without Docker --- .env.example | 14 +++++--- Dockerfile | 14 -------- README.md | 64 ++++++++++++++++++++++++---------- bin/install.php | 20 +++++++++++ config/bootstrap.php | 81 +++++++++++++++++--------------------------- docker-compose.yml | 55 ------------------------------ nginx/default.conf | 18 ---------- public/.htaccess | 13 +++++++ tests/smoke.php | 8 +++++ 9 files changed, 128 insertions(+), 159 deletions(-) delete mode 100644 Dockerfile create mode 100644 bin/install.php delete mode 100644 docker-compose.yml delete mode 100644 nginx/default.conf create mode 100644 public/.htaccess diff --git a/.env.example b/.env.example index 379ed00..d9aef78 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,10 @@ -APP_ENV=development -APP_KEY=replace-with-a-long-random-secret +APP_ENV=production +APP_KEY=generate-a-long-random-secret +TRUST_PROXY=0 +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=jobcard +DB_USERNAME=jobcard +DB_PASSWORD=replace-with-a-long-unique-database-password ADMIN_EMAIL=admin@example.com -ADMIN_PASSWORD=replace-with-a-long-unique-password -DB_PASSWORD=replace-with-a-long-database-password -DB_ROOT_PASSWORD=replace-with-a-long-root-password +ADMIN_PASSWORD=replace-with-a-long-unique-password-at-least-12-characters diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index e39f3ce..0000000 --- a/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM php:8.4-fpm-alpine - -RUN docker-php-ext-install pdo_mysql -WORKDIR /var/www/html - -COPY app ./app -COPY config ./config -COPY database ./database -COPY public ./public - -RUN addgroup -g 1000 appgroup && adduser -D -u 1000 -G appgroup appuser \ - && chown -R appuser:appgroup /var/www/html -USER appuser -CMD ["php-fpm", "-F"] diff --git a/README.md b/README.md index 5a79c64..b34bc28 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,64 @@ # JOBcard & Client Management System -Greenfield PHP/MariaDB implementation of the approved JOBcard scope. - -## Current increment - -Verified foundation and early domain slices: Docker runtime, MariaDB schema, secure session authentication, environment-based Administrator bootstrap, role-aware navigation/dashboard, CSRF protection, password hashing, audit events, client/contact validation, client search/detail views, jobcard creation/listing, jobcard references and status rules, time-entry validation/aggregation, SLA calculations/classification, and safe internal versus client-facing reporting contracts. +Portable PHP/MariaDB implementation of the JOBcard & Client Management System. The application does not require Docker and is designed to run on shared hosting, Virtualmin, cPanel, or a conventional PHP-FPM/Apache/Nginx server. ## Requirements -- Docker Engine with Compose v2 -- A `.env` file copied from `.env.example` with unique values filled in +- PHP 8.2+ (PHP 8.4 is recommended) +- MariaDB 10.6+ or MySQL 8+ +- PHP extensions: `pdo_mysql`, `mbstring`, `openssl`, `json`, `fileinfo` +- Apache with `mod_rewrite`, or Nginx with an equivalent front-controller rule +- CLI PHP access for the initial database installation -## Run locally +## 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. +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`. +6. Install the schema and initial Administrator account: ```bash -cp .env.example .env -# Replace every placeholder in .env with unique local values. -docker compose up --build +php bin/install.php ``` -Open http://localhost:8082. The first application boot creates the Administrator user from `ADMIN_EMAIL` and `ADMIN_PASSWORD`; the password is hashed with PHP's password API and is never stored in configuration or SQL. +The installer must be run once against a new database. It creates the schema and bootstraps the Administrator using `ADMIN_EMAIL` and `ADMIN_PASSWORD`; the password is hashed with PHP's password API. + +7. Visit the domain over HTTPS and sign in. + +## Virtualmin setup + +- 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 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/`. +- Schedule database and upload backups using the hosting provider's backup system or cron. + +## Nginx alternative + +Use `public/` as the root and route all non-file requests to `public/index.php`. PHP requests should be passed to the selected PHP-FPM socket. Do not expose the repository root as the web root. ## Verification ```bash -docker compose config -find app config database public -type f -name '*.php' -print0 | xargs -0 -n1 php -l +php -v +php -m | grep -E 'pdo_mysql|mbstring|openssl|json|fileinfo' +php bin/install.php +for f in tests/*Test.php tests/smoke.php; do php -d assert.exception=1 "$f"; done +for f in $(find app config public bin -type f -name '*.php'); do php -l "$f"; done ``` +## Current increment + +The repository currently includes the secure foundation, role/permission schema, client and contact domain validation, client detail/search views, jobcard creation/listing, time/SLA calculations, and safe report data contracts. Remaining scope modules are being implemented incrementally, including complete credential management, technician workflows, exports, attachments, notifications and production UAT. + ## Security notes -- Do not commit `.env` or production credentials. -- Set `APP_KEY` to a long random value and keep it in a secrets manager in production. -- Credential vault encryption and the remaining domain modules are scheduled in later phases. -- The initial schema is delivered as a Docker bootstrap SQL file. Apply it once to a new database; later releases should use versioned migrations. +- Never commit `.env` or production credentials. +- Use a long random `APP_KEY` stored outside source control. +- Use HTTPS in production. +- The initial bootstrap schema is intended for a new database and should be replaced by versioned migrations in later releases. diff --git a/bin/install.php b/bin/install.php new file mode 100644 index 0000000..9f3cdfc --- /dev/null +++ b/bin/install.php @@ -0,0 +1,20 @@ +exec(file_get_contents($schemaPath)); + ensure_initial_administrator(); + fwrite(STDOUT, "Database schema installed and initial Administrator verified.\n"); +} catch (Throwable $exception) { + fwrite(STDERR, "Installation failed: {$exception->getMessage()}\n"); + exit(1); +} diff --git a/config/bootstrap.php b/config/bootstrap.php index 72e1dfc..6ae7f32 100644 --- a/config/bootstrap.php +++ b/config/bootstrap.php @@ -1,6 +1,26 @@ = 2 && (($value[0] === '"' && $value[-1] === '"') || ($value[0] === "'" && $value[-1] === "'"))) { + $value = substr($value, 1, -1); + } + putenv("{$name}={$value}"); + } +} + +load_dotenv(dirname(__DIR__) . '/.env'); + function env_required(string $name): string { $value = getenv($name); @@ -13,10 +33,7 @@ function env_required(string $name): string function db(): PDO { static $pdo = null; - if ($pdo instanceof PDO) { - return $pdo; - } - + if ($pdo instanceof PDO) return $pdo; $dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', env_required('DB_HOST'), getenv('DB_PORT') ?: '3306', env_required('DB_DATABASE')); $pdo = new PDO($dsn, env_required('DB_USERNAME'), env_required('DB_PASSWORD'), [ @@ -29,9 +46,7 @@ function db(): PDO function csrf_token(): string { - if (empty($_SESSION['csrf'])) { - $_SESSION['csrf'] = bin2hex(random_bytes(32)); - } + if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(32)); return $_SESSION['csrf']; } @@ -52,44 +67,25 @@ function verify_csrf(): void function ensure_initial_administrator(): void { static $checked = false; - if ($checked) { - return; - } + if ($checked) return; $checked = true; - $count = (int)db()->query('SELECT COUNT(*) FROM users')->fetchColumn(); - if ($count !== 0) { - return; - } - + if ((int)db()->query('SELECT COUNT(*) FROM users')->fetchColumn() !== 0) return; $email = strtolower(trim(env_required('ADMIN_EMAIL'))); $password = env_required('ADMIN_PASSWORD'); - if (strlen($password) < 12) { - throw new RuntimeException('ADMIN_PASSWORD must be at least 12 characters'); - } + if (strlen($password) < 12) throw new RuntimeException('ADMIN_PASSWORD must be at least 12 characters'); $roleId = (int)db()->query("SELECT id FROM roles WHERE name = 'Administrator'")->fetchColumn(); - if ($roleId < 1) { - throw new RuntimeException('Administrator role is missing from the database'); - } + if ($roleId < 1) throw new RuntimeException('Administrator role is missing from the database'); $stmt = db()->prepare('INSERT INTO users (role_id, email, name, password_hash) VALUES (:role, :email, :name, :hash)'); - $stmt->execute([ - 'role' => $roleId, - 'email' => $email, - 'name' => 'System Administrator', - 'hash' => password_hash($password, PASSWORD_DEFAULT), - ]); + $stmt->execute(['role' => $roleId, 'email' => $email, 'name' => 'System Administrator', 'hash' => password_hash($password, PASSWORD_DEFAULT)]); } function current_user(): ?array { ensure_initial_administrator(); static $user = false; - if ($user !== false) { - return $user; - } + if ($user !== false) return $user; $id = $_SESSION['user_id'] ?? null; - if (!$id) { - return $user = null; - } + if (!$id) return $user = null; $stmt = db()->prepare('SELECT u.*, r.name AS role_name FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = :id AND u.is_active = 1'); $stmt->execute(['id' => $id]); return $user = ($stmt->fetch() ?: null); @@ -98,10 +94,7 @@ function current_user(): ?array function require_login(): array { $user = current_user(); - if (!$user) { - header('Location: /?route=login'); - exit; - } + if (!$user) { header('Location: /?route=login'); exit; } return $user; } @@ -120,24 +113,14 @@ function can(string $permission): bool function require_permission(string $permission): void { - if (!can($permission)) { - http_response_code(403); - exit('Forbidden'); - } + if (!can($permission)) { http_response_code(403); exit('Forbidden'); } } function audit(string $action, string $entityType, ?int $entityId = null, array $metadata = []): void { $user = current_user(); $stmt = db()->prepare('INSERT INTO audit_events (user_id, action, entity_type, entity_id, metadata, ip_address) VALUES (:user_id, :action, :entity_type, :entity_id, :metadata, :ip)'); - $stmt->execute([ - 'user_id' => $user['id'] ?? null, - 'action' => $action, - 'entity_type' => $entityType, - 'entity_id' => $entityId, - 'metadata' => $metadata ? json_encode($metadata, JSON_THROW_ON_ERROR) : null, - 'ip' => $_SERVER['REMOTE_ADDR'] ?? null, - ]); + $stmt->execute(['user_id' => $user['id'] ?? null, 'action' => $action, 'entity_type' => $entityType, 'entity_id' => $entityId, 'metadata' => $metadata ? json_encode($metadata, JSON_THROW_ON_ERROR) : null, 'ip' => $_SERVER['REMOTE_ADDR'] ?? null]); } function e(string $value): string diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index a1dbc8c..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,55 +0,0 @@ -services: - app: - build: . - environment: - APP_ENV: ${APP_ENV:-development} - APP_KEY: ${APP_KEY:?Set APP_KEY in .env} - ADMIN_EMAIL: ${ADMIN_EMAIL:?Set ADMIN_EMAIL in .env} - ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Set ADMIN_PASSWORD in .env} - DB_HOST: db - DB_PORT: 3306 - DB_DATABASE: jobcard - DB_USERNAME: jobcard - DB_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env} - volumes: - - ./public:/var/www/html/public - - ./app:/var/www/html/app - - ./database:/var/www/html/database - - ./config:/var/www/html/config - depends_on: - db: - condition: service_healthy - networks: [jobcard] - - web: - image: nginx:1.27-alpine - ports: - - "8082:80" - volumes: - - ./public:/var/www/html/public:ro - - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro - depends_on: [app] - networks: [jobcard] - - db: - image: mariadb:11.4 - environment: - MARIADB_DATABASE: jobcard - MARIADB_USER: jobcard - MARIADB_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env} - MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:?Set DB_ROOT_PASSWORD in .env} - volumes: - - db-data:/var/lib/mysql - - ./database/schema.sql:/docker-entrypoint-initdb.d/001-schema.sql:ro - healthcheck: - test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] - interval: 5s - timeout: 5s - retries: 20 - networks: [jobcard] - -volumes: - db-data: - -networks: - jobcard: diff --git a/nginx/default.conf b/nginx/default.conf deleted file mode 100644 index ee33a17..0000000 --- a/nginx/default.conf +++ /dev/null @@ -1,18 +0,0 @@ -server { - listen 80; - root /var/www/html/public; - index index.php; - - location / { - try_files $uri $uri/ /index.php?$query_string; - } - - location ~ \.php$ { - include fastcgi_params; - fastcgi_param SCRIPT_FILENAME /var/www/html/public$fastcgi_script_name; - fastcgi_param HTTP_PROXY ""; - fastcgi_pass app:9000; - } - - location ~ /\. { deny all; } -} diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..467c7b6 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,13 @@ +RewriteEngine On +RewriteBase / + +# Keep real assets accessible and route application requests through the front controller. +RewriteCond %{REQUEST_FILENAME} -f [OR] +RewriteCond %{REQUEST_FILENAME} -d +RewriteRule ^ - [L] +RewriteRule ^ index.php [L] + +# Never expose environment/config/database files through Apache. + + Require all denied + diff --git a/tests/smoke.php b/tests/smoke.php index f9224cc..599ab01 100644 --- a/tests/smoke.php +++ b/tests/smoke.php @@ -4,6 +4,14 @@ declare(strict_types=1); putenv('JOBcard_TEST_VALUE=present'); require_once __DIR__ . '/../config/bootstrap.php'; +$tempEnv = tempnam(sys_get_temp_dir(), 'jobcard-env-'); +file_put_contents($tempEnv, "JOBcard_LOADED=\"from-file\"\n# ignored\n"); +load_dotenv($tempEnv); +unlink($tempEnv); +if (getenv('JOBcard_LOADED') !== 'from-file') { + throw new RuntimeException('Expected .env values to load from file'); +} + $checks = 0; assert(env_required('JOBcard_TEST_VALUE') === 'present'); $checks++;