152 lines
5.8 KiB
PHP
152 lines
5.8 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
require_once dirname(__DIR__) . '/app/Domain/User/PasswordPolicy.php';
|
|
|
|
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 (!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);
|
|
}
|
|
putenv("{$name}={$value}");
|
|
}
|
|
}
|
|
|
|
load_dotenv(dirname(__DIR__) . '/.env');
|
|
|
|
function env_required(string $name): string
|
|
{
|
|
$value = getenv($name);
|
|
if ($value === false || trim($value) === '') {
|
|
throw new RuntimeException("Missing required environment variable: {$name}");
|
|
}
|
|
return $value;
|
|
}
|
|
|
|
function db(): PDO
|
|
{
|
|
static $pdo = null;
|
|
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'), [
|
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
|
PDO::ATTR_EMULATE_PREPARES => false,
|
|
]);
|
|
return $pdo;
|
|
}
|
|
|
|
function csrf_token(): string
|
|
{
|
|
if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(32));
|
|
return $_SESSION['csrf'];
|
|
}
|
|
|
|
function scalar_input(mixed $value, string $default = ''): string
|
|
{
|
|
return is_scalar($value) ? (string)$value : $default;
|
|
}
|
|
|
|
function verify_csrf(): void
|
|
{
|
|
$provided = scalar_input($_POST['_csrf'] ?? null);
|
|
if (!hash_equals((string)($_SESSION['csrf'] ?? ''), $provided)) {
|
|
http_response_code(419);
|
|
exit('Invalid CSRF token');
|
|
}
|
|
}
|
|
|
|
function ensure_initial_administrator(): void
|
|
{
|
|
static $checked = false;
|
|
if ($checked) return;
|
|
$checked = true;
|
|
if ((int)db()->query('SELECT COUNT(*) FROM users')->fetchColumn() !== 0) return;
|
|
$email = strtolower(trim(env_required('ADMIN_EMAIL')));
|
|
$password = env_required('ADMIN_PASSWORD');
|
|
$passwordResult = (new \App\Domain\User\PasswordPolicy())->validateInitial($password);
|
|
if (!$passwordResult['valid']) throw new RuntimeException('ADMIN_PASSWORD does not meet the password policy');
|
|
$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');
|
|
$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)]);
|
|
}
|
|
|
|
function current_user(): ?array
|
|
{
|
|
static $user = false;
|
|
if ($user !== false) return $user;
|
|
$id = $_SESSION['user_id'] ?? 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);
|
|
}
|
|
|
|
function require_login(): array
|
|
{
|
|
$user = current_user();
|
|
if (!$user) { header('Location: /?route=login'); exit; }
|
|
return $user;
|
|
}
|
|
|
|
function can(string $permission): bool
|
|
{
|
|
$user = current_user();
|
|
if (!$user) return false;
|
|
static $permissions = null;
|
|
if ($permissions === null) {
|
|
$stmt = db()->prepare('SELECT p.name FROM permissions p JOIN role_permissions rp ON rp.permission_id = p.id WHERE rp.role_id = :role');
|
|
$stmt->execute(['role' => $user['role_id']]);
|
|
$permissions = array_column($stmt->fetchAll(), 'name');
|
|
}
|
|
return in_array($permission, $permissions, true);
|
|
}
|
|
|
|
function require_permission(string $permission): void
|
|
{
|
|
if (!can($permission)) { http_response_code(403); exit('Forbidden'); }
|
|
}
|
|
|
|
function can_access_jobcard(int $jobcardId): bool
|
|
{
|
|
$user = current_user();
|
|
if (!$user) return false;
|
|
if ($user['role_name'] !== 'Technician') return can('jobcards.view');
|
|
$stmt = db()->prepare('SELECT 1 FROM jobcard_assignments WHERE jobcard_id = :jobcard AND user_id = :user LIMIT 1');
|
|
$stmt->execute(['jobcard' => $jobcardId, 'user' => $user['id']]);
|
|
return (bool)$stmt->fetchColumn();
|
|
}
|
|
|
|
function can_access_client(int $clientId): bool
|
|
{
|
|
$user = current_user();
|
|
if (!$user) return false;
|
|
if ($user['role_name'] !== 'Technician') return can('clients.view');
|
|
$stmt = db()->prepare('SELECT 1 FROM jobcard_assignments ja JOIN jobcards j ON j.id = ja.jobcard_id WHERE ja.user_id = :user AND j.client_id = :client LIMIT 1');
|
|
$stmt->execute(['user' => $user['id'], 'client' => $clientId]);
|
|
return (bool)$stmt->fetchColumn();
|
|
}
|
|
|
|
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]);
|
|
}
|
|
|
|
function e(string $value): string
|
|
{
|
|
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
|
}
|