refactor: support portable PHP hosting without Docker

This commit is contained in:
Marco0300
2026-09-01 19:22:48 +02:00
parent 9f5142b071
commit 5b2c64ff39
9 changed files with 128 additions and 159 deletions
+9 -5
View File
@@ -1,6 +1,10 @@
APP_ENV=development APP_ENV=production
APP_KEY=replace-with-a-long-random-secret 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_EMAIL=admin@example.com
ADMIN_PASSWORD=replace-with-a-long-unique-password ADMIN_PASSWORD=replace-with-a-long-unique-password-at-least-12-characters
DB_PASSWORD=replace-with-a-long-database-password
DB_ROOT_PASSWORD=replace-with-a-long-root-password
-14
View File
@@ -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"]
+46 -18
View File
@@ -1,36 +1,64 @@
# JOBcard & Client Management System # JOBcard & Client Management System
Greenfield PHP/MariaDB implementation of the approved JOBcard scope. 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.
## 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.
## Requirements ## Requirements
- Docker Engine with Compose v2 - PHP 8.2+ (PHP 8.4 is recommended)
- A `.env` file copied from `.env.example` with unique values filled in - 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 ```bash
cp .env.example .env php bin/install.php
# Replace every placeholder in .env with unique local values.
docker compose up --build
``` ```
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 ## Verification
```bash ```bash
docker compose config php -v
find app config database public -type f -name '*.php' -print0 | xargs -0 -n1 php -l 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 ## Security notes
- Do not commit `.env` or production credentials. - Never commit `.env` or production credentials.
- Set `APP_KEY` to a long random value and keep it in a secrets manager in production. - Use a long random `APP_KEY` stored outside source control.
- Credential vault encryption and the remaining domain modules are scheduled in later phases. - Use HTTPS in production.
- The initial schema is delivered as a Docker bootstrap SQL file. Apply it once to a new database; later releases should use versioned migrations. - The initial bootstrap schema is intended for a new database and should be replaced by versioned migrations in later releases.
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
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));
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);
}
+32 -49
View File
@@ -1,6 +1,26 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
function load_dotenv(string $path): void
{
if (!is_file($path) || !is_readable($path)) return;
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#')) continue;
$separator = strpos($line, '=');
if ($separator === false) continue;
$name = trim(substr($line, 0, $separator));
$value = trim(substr($line, $separator + 1));
if ($name === '' || getenv($name) !== false) continue;
if (strlen($value) >= 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 function env_required(string $name): string
{ {
$value = getenv($name); $value = getenv($name);
@@ -13,10 +33,7 @@ function env_required(string $name): string
function db(): PDO function db(): PDO
{ {
static $pdo = null; static $pdo = null;
if ($pdo instanceof PDO) { if ($pdo instanceof PDO) return $pdo;
return $pdo;
}
$dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
env_required('DB_HOST'), getenv('DB_PORT') ?: '3306', env_required('DB_DATABASE')); env_required('DB_HOST'), getenv('DB_PORT') ?: '3306', env_required('DB_DATABASE'));
$pdo = new PDO($dsn, env_required('DB_USERNAME'), env_required('DB_PASSWORD'), [ $pdo = new PDO($dsn, env_required('DB_USERNAME'), env_required('DB_PASSWORD'), [
@@ -29,9 +46,7 @@ function db(): PDO
function csrf_token(): string function csrf_token(): string
{ {
if (empty($_SESSION['csrf'])) { if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(32));
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf']; return $_SESSION['csrf'];
} }
@@ -52,44 +67,25 @@ function verify_csrf(): void
function ensure_initial_administrator(): void function ensure_initial_administrator(): void
{ {
static $checked = false; static $checked = false;
if ($checked) { if ($checked) return;
return;
}
$checked = true; $checked = true;
$count = (int)db()->query('SELECT COUNT(*) FROM users')->fetchColumn(); if ((int)db()->query('SELECT COUNT(*) FROM users')->fetchColumn() !== 0) return;
if ($count !== 0) {
return;
}
$email = strtolower(trim(env_required('ADMIN_EMAIL'))); $email = strtolower(trim(env_required('ADMIN_EMAIL')));
$password = env_required('ADMIN_PASSWORD'); $password = env_required('ADMIN_PASSWORD');
if (strlen($password) < 12) { if (strlen($password) < 12) throw new RuntimeException('ADMIN_PASSWORD must be at least 12 characters');
throw new RuntimeException('ADMIN_PASSWORD must be at least 12 characters');
}
$roleId = (int)db()->query("SELECT id FROM roles WHERE name = 'Administrator'")->fetchColumn(); $roleId = (int)db()->query("SELECT id FROM roles WHERE name = 'Administrator'")->fetchColumn();
if ($roleId < 1) { if ($roleId < 1) throw new RuntimeException('Administrator role is missing from the database');
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 = db()->prepare('INSERT INTO users (role_id, email, name, password_hash) VALUES (:role, :email, :name, :hash)');
$stmt->execute([ $stmt->execute(['role' => $roleId, 'email' => $email, 'name' => 'System Administrator', 'hash' => password_hash($password, PASSWORD_DEFAULT)]);
'role' => $roleId,
'email' => $email,
'name' => 'System Administrator',
'hash' => password_hash($password, PASSWORD_DEFAULT),
]);
} }
function current_user(): ?array function current_user(): ?array
{ {
ensure_initial_administrator(); ensure_initial_administrator();
static $user = false; static $user = false;
if ($user !== false) { if ($user !== false) return $user;
return $user;
}
$id = $_SESSION['user_id'] ?? null; $id = $_SESSION['user_id'] ?? null;
if (!$id) { if (!$id) return $user = null;
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 = 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]); $stmt->execute(['id' => $id]);
return $user = ($stmt->fetch() ?: null); return $user = ($stmt->fetch() ?: null);
@@ -98,10 +94,7 @@ function current_user(): ?array
function require_login(): array function require_login(): array
{ {
$user = current_user(); $user = current_user();
if (!$user) { if (!$user) { header('Location: /?route=login'); exit; }
header('Location: /?route=login');
exit;
}
return $user; return $user;
} }
@@ -120,24 +113,14 @@ function can(string $permission): bool
function require_permission(string $permission): void function require_permission(string $permission): void
{ {
if (!can($permission)) { if (!can($permission)) { http_response_code(403); exit('Forbidden'); }
http_response_code(403);
exit('Forbidden');
}
} }
function audit(string $action, string $entityType, ?int $entityId = null, array $metadata = []): void function audit(string $action, string $entityType, ?int $entityId = null, array $metadata = []): void
{ {
$user = current_user(); $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 = 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([ $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]);
'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 function e(string $value): string
-55
View File
@@ -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:
-18
View File
@@ -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; }
}
+13
View File
@@ -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.
<FilesMatch "^(\.env|.*\.(sql|log|ini))$">
Require all denied
</FilesMatch>
+8
View File
@@ -4,6 +4,14 @@ declare(strict_types=1);
putenv('JOBcard_TEST_VALUE=present'); putenv('JOBcard_TEST_VALUE=present');
require_once __DIR__ . '/../config/bootstrap.php'; 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; $checks = 0;
assert(env_required('JOBcard_TEST_VALUE') === 'present'); assert(env_required('JOBcard_TEST_VALUE') === 'present');
$checks++; $checks++;