ServerManager project files

This commit is contained in:
2026-06-06 17:58:34 -04:00
parent ce1863d955
commit 9c0bc7f189
502 changed files with 87837 additions and 0 deletions

159
src/Core/App.php Executable file
View File

@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace ServerManager\Core;
use Dotenv\Dotenv;
class App
{
private static ?App $instance = null;
private Router $router;
private Security $security;
private View $view;
private array $config;
private Encryption $encryption;
private bool $booted = false;
private function __construct()
{
}
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public static function getConfig(): array
{
return self::getInstance()->config;
}
public static function getEncryption(): Encryption
{
return self::getInstance()->encryption;
}
public function boot(string $basePath): void
{
if ($this->booted) {
return;
}
$this->loadEnvironment($basePath);
$this->setErrorHandling();
$this->config = require $basePath . '/config/config.php';
date_default_timezone_set($this->config['app']['timezone'] ?? 'UTC');
Database::getInstance($this->config['database']);
Session::init($this->config['session']);
Logger::init(
$basePath . '/' . ($this->config['log']['path'] ?? 'logs/'),
$this->config['log']['level'] ?? 'warning'
);
$this->security = new Security();
$this->encryption = new Encryption($this->config['encryption']);
$this->view = new View($this->security);
$this->router = new Router();
$this->loadRoutes($basePath . '/routes/');
$this->booted = true;
}
public function run(): void
{
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$uri = $_SERVER['REQUEST_URI'] ?? '/';
try {
$this->router->dispatch($method, $uri);
} catch (\Throwable $e) {
Logger::getInstance()->error($e->getMessage(), [
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTraceAsString(),
]);
if ($this->config['app']['debug'] ?? false) {
throw $e;
}
$this->view->error(500, 'An internal server error occurred.');
}
}
public function getRouter(): Router
{
return $this->router;
}
public function getSecurity(): Security
{
return $this->security;
}
public function getView(): View
{
return $this->view;
}
private function loadEnvironment(string $basePath): void
{
if (file_exists($basePath . '/.env')) {
$dotenv = Dotenv::createImmutable($basePath);
$dotenv->load();
$dotenv->required([
'APP_NAME',
'DB_HOST',
'DB_DATABASE',
'DB_USERNAME',
]);
}
}
private function setErrorHandling(): void
{
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
set_exception_handler(function (\Throwable $e) {
Logger::getInstance()->critical($e->getMessage(), [
'file' => $e->getFile(),
'line' => $e->getLine(),
'code' => $e->getCode(),
]);
http_response_code(500);
echo json_encode(['error' => 'Internal Server Error']);
exit;
});
set_error_handler(function ($severity, $message, $file, $line) {
if (!(error_reporting() & $severity)) {
return false;
}
if ($severity === E_DEPRECATED || $severity === E_USER_DEPRECATED) {
return false;
}
throw new \ErrorException($message, 0, $severity, $file, $line);
});
}
private function loadRoutes(string $routesPath): void
{
if (is_dir($routesPath)) {
foreach (glob($routesPath . '*.php') as $routeFile) {
$route = $this->router;
require $routeFile;
}
}
}
}

122
src/Core/Database.php Executable file
View File

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace ServerManager\Core;
use PDO;
use PDOException;
class Database
{
private static ?Database $instance = null;
private PDO $pdo;
private function __construct(array $config)
{
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$config['host'],
$config['port'],
$config['database'],
$config['charset'] ?? 'utf8mb4'
);
$initCommand = PHP_VERSION_ID >= 80500
? \Pdo\Mysql::ATTR_INIT_COMMAND
: \PDO::MYSQL_ATTR_INIT_COMMAND;
try {
$this->pdo = new PDO($dsn, $config['username'], $config['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
$initCommand => 'SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci',
]);
} catch (PDOException $e) {
throw new \RuntimeException('Database connection failed: ' . $e->getMessage());
}
}
public static function getInstance(array $config = []): self
{
if (self::$instance === null) {
if (empty($config)) {
throw new \RuntimeException('Database configuration is required for first initialization');
}
self::$instance = new self($config);
}
return self::$instance;
}
public static function resetInstance(): void
{
self::$instance = null;
}
public function getPdo(): PDO
{
return $this->pdo;
}
public function query(string $sql, array $params = []): \PDOStatement
{
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt;
}
public function fetch(string $sql, array $params = []): ?array
{
$result = $this->query($sql, $params)->fetch();
return $result ?: null;
}
public function fetchAll(string $sql, array $params = []): array
{
return $this->query($sql, $params)->fetchAll();
}
public function insert(string $table, array $data): int
{
$columns = implode(', ', array_keys($data));
$placeholders = implode(', ', array_fill(0, count($data), '?'));
$sql = "INSERT INTO {$table} ({$columns}) VALUES ({$placeholders})";
$this->query($sql, array_values($data));
return (int) $this->pdo->lastInsertId();
}
public function update(string $table, array $data, string $where, array $whereParams = []): int
{
$sets = implode(', ', array_map(fn($col) => "{$col} = ?", array_keys($data)));
$sql = "UPDATE {$table} SET {$sets} WHERE {$where}";
$stmt = $this->query($sql, array_merge(array_values($data), $whereParams));
return $stmt->rowCount();
}
public function delete(string $table, string $where, array $params = []): int
{
$sql = "DELETE FROM {$table} WHERE {$where}";
$stmt = $this->query($sql, $params);
return $stmt->rowCount();
}
public function beginTransaction(): bool
{
return $this->pdo->beginTransaction();
}
public function commit(): bool
{
return $this->pdo->commit();
}
public function rollback(): bool
{
return $this->pdo->rollBack();
}
}

92
src/Core/Encryption.php Executable file
View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace ServerManager\Core;
class Encryption
{
private string $method;
private string $masterKey;
private const KEY_HASH_ALGO = 'sha256';
public function __construct(array $config)
{
$this->method = $config['method'] ?? 'aes-256-cbc';
if (!empty($config['master_key'])) {
$this->masterKey = $config['master_key'];
} elseif (!empty($config['master_key_file']) && file_exists($config['master_key_file'])) {
$this->masterKey = trim(file_get_contents($config['master_key_file']));
} else {
$keyFile = $config['master_key_file'] ?? '/etc/servermanager/master.key';
if (file_exists($keyFile)) {
$this->masterKey = trim(file_get_contents($keyFile));
} else {
throw new \RuntimeException(
'Master encryption key not found. Generate one with: php bin/install.php --generate-key'
);
}
}
if (strlen($this->masterKey) < 32) {
throw new \RuntimeException('Master encryption key must be at least 32 characters (256 bits)');
}
}
public function encrypt(string $plaintext): string
{
$ivLength = openssl_cipher_iv_length($this->method);
$iv = random_bytes($ivLength);
$key = hash(self::KEY_HASH_ALGO, $this->masterKey, true);
$encrypted = openssl_encrypt($plaintext, $this->method, $key, OPENSSL_RAW_DATA, $iv);
if ($encrypted === false) {
throw new \RuntimeException('Encryption failed');
}
return base64_encode($iv . $encrypted);
}
public function decrypt(string $ciphertext): string
{
$data = base64_decode($ciphertext);
$ivLength = openssl_cipher_iv_length($this->method);
$iv = substr($data, 0, $ivLength);
$encrypted = substr($data, $ivLength);
$key = hash(self::KEY_HASH_ALGO, $this->masterKey, true);
$decrypted = openssl_decrypt($encrypted, $this->method, $key, OPENSSL_RAW_DATA, $iv);
if ($decrypted === false) {
throw new \RuntimeException('Decryption failed');
}
return $decrypted;
}
public function encryptArray(array $data, array $fields): array
{
foreach ($fields as $field) {
if (!empty($data[$field])) {
$data[$field] = $this->encrypt($data[$field]);
}
}
return $data;
}
public function decryptArray(array $data, array $fields): array
{
foreach ($fields as $field) {
if (!empty($data[$field])) {
try {
$data[$field] = $this->decrypt($data[$field]);
} catch (\RuntimeException $e) {
$data[$field] = null;
}
}
}
return $data;
}
}

148
src/Core/Logger.php Executable file
View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace ServerManager\Core;
class Logger
{
private static ?Logger $instance = null;
private string $logPath;
private string $logLevel;
private array $levels = [
'debug' => 0,
'info' => 1,
'notice' => 2,
'warning' => 3,
'error' => 4,
'critical' => 5,
'alert' => 6,
'emergency' => 7,
];
private function __construct()
{
}
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public static function init(string $path, string $level = 'warning'): void
{
$instance = self::getInstance();
$instance->logPath = rtrim($path, '/') . '/';
$instance->logLevel = $level;
if (!is_dir($instance->logPath)) {
mkdir($instance->logPath, 0750, true);
}
if (!is_writable($instance->logPath)) {
throw new \RuntimeException("Log path is not writable: {$instance->logPath}");
}
}
public function log(string $level, string $message, array $context = []): void
{
if (!$this->shouldLog($level)) {
return;
}
$timestamp = date('Y-m-d H:i:s');
$contextJson = !empty($context) ? ' ' . json_encode($context, JSON_UNESCAPED_SLASHES) : '';
$userInfo = $this->getUserInfo();
$logEntry = sprintf(
"[%s] %s.%s: %s%s %s\n",
$timestamp,
strtoupper($level),
$userInfo,
$message,
$contextJson,
$this->getRequestInfo()
);
$this->write($level, $logEntry);
}
public function debug(string $message, array $context = []): void
{
$this->log('debug', $message, $context);
}
public function info(string $message, array $context = []): void
{
$this->log('info', $message, $context);
}
public function notice(string $message, array $context = []): void
{
$this->log('notice', $message, $context);
}
public function warning(string $message, array $context = []): void
{
$this->log('warning', $message, $context);
}
public function error(string $message, array $context = []): void
{
$this->log('error', $message, $context);
}
public function critical(string $message, array $context = []): void
{
$this->log('critical', $message, $context);
}
public function alert(string $message, array $context = []): void
{
$this->log('alert', $message, $context);
}
public function emergency(string $message, array $context = []): void
{
$this->log('emergency', $message, $context);
}
private function shouldLog(string $level): bool
{
$currentLevel = $this->levels[$this->logLevel] ?? 3;
$messageLevel = $this->levels[$level] ?? 3;
return $messageLevel >= $currentLevel;
}
private function write(string $level, string $entry): void
{
$filename = $this->logPath . 'app-' . date('Y-m-d') . '.log';
file_put_contents($filename, $entry, FILE_APPEND | LOCK_EX);
if (in_array($level, ['error', 'critical', 'alert', 'emergency'], true)) {
$errorFile = $this->logPath . 'error-' . date('Y-m-d') . '.log';
file_put_contents($errorFile, $entry, FILE_APPEND | LOCK_EX);
}
}
private function getUserInfo(): string
{
$userId = Session::get('user_id');
$username = Session::get('username');
if ($userId) {
return "[User:{$userId}:{$username}]";
}
return '[Guest]';
}
private function getRequestInfo(): string
{
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$method = $_SERVER['REQUEST_METHOD'] ?? 'CLI';
$uri = $_SERVER['REQUEST_URI'] ?? '';
return "[{$ip}] [{$method} {$uri}]";
}
}

143
src/Core/Router.php Executable file
View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace ServerManager\Core;
class Router
{
private array $routes = [];
private array $groupStack = [];
private ?string $currentGroup = null;
private array $groupMiddlewares = [];
private array $middlewares = [];
private array $patterns = [
':id' => '(\d+)',
':uuid' => '([a-f0-9\-]{36})',
':slug' => '([a-zA-Z0-9\-]+)',
':any' => '([^/]+)',
':all' => '(.*)',
];
public function get(string $path, callable|array $handler, array $middleware = []): self
{
return $this->addRoute('GET', $path, $handler, $middleware);
}
public function post(string $path, callable|array $handler, array $middleware = []): self
{
return $this->addRoute('POST', $path, $handler, $middleware);
}
public function put(string $path, callable|array $handler, array $middleware = []): self
{
return $this->addRoute('PUT', $path, $handler, $middleware);
}
public function delete(string $path, callable|array $handler, array $middleware = []): self
{
return $this->addRoute('DELETE', $path, $handler, $middleware);
}
public function patch(string $path, callable|array $handler, array $middleware = []): self
{
return $this->addRoute('PATCH', $path, $handler, $middleware);
}
public function group(array $properties, callable $callback): void
{
$previousGroup = $this->currentGroup;
$previousMiddlewares = $this->groupMiddlewares;
$this->currentGroup = $properties['prefix'] ?? '';
$this->groupMiddlewares = array_merge(
$this->groupMiddlewares,
$properties['middleware'] ?? []
);
$callback($this);
$this->currentGroup = $previousGroup;
$this->groupMiddlewares = $previousMiddlewares;
}
public function middleware(array $middleware): self
{
$this->middlewares = $middleware;
return $this;
}
private function addRoute(string $method, string $path, callable|array $handler, array $middleware = []): self
{
$prefix = $this->currentGroup ? '/' . trim($this->currentGroup, '/') : '';
$fullPath = $prefix . '/' . trim($path, '/');
$fullPath = $fullPath === '/' ? '/' : '/' . trim($fullPath, '/');
$allMiddleware = array_merge($this->groupMiddlewares, $this->middlewares, $middleware);
$this->routes[] = [
'method' => $method,
'path' => $fullPath,
'handler' => $handler,
'middleware' => $allMiddleware,
'pattern' => $this->compilePattern($fullPath),
];
$this->middlewares = [];
return $this;
}
private function compilePattern(string $path): string
{
$pattern = preg_quote($path, '#');
foreach ($this->patterns as $key => $regex) {
$pattern = str_replace(preg_quote($key, '#'), $regex, $pattern);
}
return '#^' . $pattern . '$#';
}
public function dispatch(string $method, string $uri): mixed
{
$uri = rawurldecode($uri);
$uri = parse_url($uri, PHP_URL_PATH);
$uri = '/' . trim($uri, '/');
if ($method === 'POST' && isset($_POST['_method'])) {
$method = strtoupper($_POST['_method']);
}
foreach ($this->routes as $route) {
if ($route['method'] !== $method) {
continue;
}
if (preg_match($route['pattern'], $uri, $matches)) {
array_shift($matches);
$params = array_map(function ($param) {
return ctype_digit($param) ? (int) $param : $param;
}, array_values($matches));
$middlewares = array_unique($route['middleware']);
foreach ($middlewares as $mwClass) {
$middleware = new $mwClass();
$result = $middleware->handle();
if ($result !== null) {
return $result;
}
}
$handler = $route['handler'];
if (is_array($handler)) {
[$class, $method] = $handler;
$controller = new $class();
return $controller->{$method}(...$params);
}
return $handler(...$params);
}
}
$view = new View(new Security());
$view->error(404);
}
}

124
src/Core/Security.php Executable file
View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace ServerManager\Core;
use ServerManager\Services\AuditService;
class Security
{
private Logger $logger;
private AuditService $auditService;
public function __construct()
{
$this->logger = Logger::getInstance();
}
public function setAuditService(AuditService $auditService): void
{
$this->auditService = $auditService;
}
public function hashPassword(string $password): string
{
return password_hash($password, PASSWORD_ARGON2ID, [
'memory_cost' => 65536,
'time_cost' => 4,
'threads' => 3,
]);
}
public function verifyPassword(string $password, string $hash): bool
{
return password_verify($password, $hash);
}
public function generateCSRFToken(): string
{
$token = bin2hex(random_bytes(32));
Session::set('csrf_token', $token);
Session::set('csrf_token_time', time());
return $token;
}
public function validateCSRFToken(string $token): bool
{
$storedToken = Session::get('csrf_token');
$tokenTime = Session::get('csrf_token_time');
if (!$storedToken || !$tokenTime) {
return false;
}
$expiry = App::getConfig()['csrf']['token_expiry'] ?? 7200;
if (time() - $tokenTime > $expiry) {
return false;
}
return hash_equals($storedToken, $token);
}
public function purify(string $input): string
{
return htmlspecialchars($input, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
public function purifyArray(array $input): array
{
return array_map(function ($value) {
if (is_string($value)) {
return $this->purify($value);
}
if (is_array($value)) {
return $this->purifyArray($value);
}
return $value;
}, $input);
}
public function generateApiToken(): string
{
return bin2hex(random_bytes(32));
}
public function generateMasterKey(): string
{
return bin2hex(random_bytes(32));
}
public function sanitizeFilename(string $filename): string
{
$filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $filename);
return preg_replace('/_+/', '_', $filename);
}
public function isValidIP(string $ip): bool
{
return filter_var($ip, FILTER_VALIDATE_IP) !== false;
}
public function isValidHostname(string $hostname): bool
{
return (bool) preg_match('/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/', $hostname);
}
public function isValidPort(int $port): bool
{
return $port > 0 && $port <= 65535;
}
public function isValidEmail(string $email): bool
{
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
public function generateUUID(): string
{
$data = random_bytes(16);
$data[6] = chr(ord($data[6]) & 0x0f | 0x40);
$data[8] = chr(ord($data[8]) & 0x3f | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}
}

103
src/Core/Session.php Executable file
View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace ServerManager\Core;
class Session
{
private static bool $started = false;
public static function init(array $config): void
{
if (self::$started) {
return;
}
if (session_status() === PHP_SESSION_NONE) {
session_set_cookie_params([
'lifetime' => $config['lifetime'] ?? 1440,
'path' => '/',
'domain' => '',
'secure' => $config['secure'] ?? true,
'httponly' => $config['http_only'] ?? true,
'samesite' => $config['same_site'] ?? 'Lax',
]);
session_name('SM_SESSION');
session_start();
}
self::$started = true;
if (!isset($_SESSION['_init'])) {
$_SESSION['_init'] = true;
static::regenerate();
}
}
public static function set(string $key, mixed $value): void
{
$_SESSION[$key] = $value;
}
public static function get(string $key, mixed $default = null): mixed
{
return $_SESSION[$key] ?? $default;
}
public static function has(string $key): bool
{
return isset($_SESSION[$key]);
}
public static function remove(string $key): void
{
unset($_SESSION[$key]);
}
public static function regenerate(bool $deleteOld = true): void
{
session_regenerate_id($deleteOld);
}
public static function destroy(): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(
session_name(),
'',
time() - 42000,
$params['path'],
$params['domain'],
$params['secure'],
$params['httponly']
);
}
session_destroy();
}
self::$started = false;
}
public static function setFlash(string $type, string $message): void
{
$_SESSION['_flash'][$type][] = $message;
}
public static function getFlash(string $type): array
{
$messages = $_SESSION['_flash'][$type] ?? [];
unset($_SESSION['_flash'][$type]);
return $messages;
}
public static function getAllFlashes(): array
{
$flashes = $_SESSION['_flash'] ?? [];
unset($_SESSION['_flash']);
return $flashes;
}
}

160
src/Core/Validator.php Executable file
View File

@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace ServerManager\Core;
class Validator
{
private array $errors = [];
private array $data;
private array $rules = [];
public function __construct(array $data = [])
{
$this->data = $data;
}
public function validate(array $data, array $rules): bool
{
$this->data = $data;
$this->errors = [];
$this->rules = $rules;
foreach ($rules as $field => $fieldRules) {
$fieldRules = is_string($fieldRules) ? explode('|', $fieldRules) : $fieldRules;
$value = $data[$field] ?? null;
foreach ($fieldRules as $rule) {
$params = [];
if (str_contains($rule, ':')) {
[$rule, $paramStr] = explode(':', $rule, 2);
$params = explode(',', $paramStr);
}
$method = 'validate' . ucfirst($rule);
if (method_exists($this, $method)) {
$this->{$method}($field, $value, $params);
}
}
}
return empty($this->errors);
}
public function getErrors(): array
{
return $this->errors;
}
public function getFirstError(): ?string
{
if (empty($this->errors)) {
return null;
}
$first = reset($this->errors);
return is_array($first) ? reset($first) : $first;
}
public function addError(string $field, string $message): void
{
$this->errors[$field][] = $message;
}
private function validateRequired(string $field, mixed $value, array $params): void
{
if ($value === null || $value === '' || (is_array($value) && empty($value))) {
$this->errors[$field][] = "The {$field} field is required.";
}
}
private function validateEmail(string $field, mixed $value, array $params): void
{
if ($value !== null && $value !== '' && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
$this->errors[$field][] = "The {$field} field must be a valid email address.";
}
}
private function validateMin(string $field, mixed $value, array $params): void
{
$min = (int) ($params[0] ?? 0);
if (is_string($value) && mb_strlen($value) < $min) {
$this->errors[$field][] = "The {$field} field must be at least {$min} characters.";
} elseif (is_numeric($value) && $value < $min) {
$this->errors[$field][] = "The {$field} field must be at least {$min}.";
}
}
private function validateMax(string $field, mixed $value, array $params): void
{
$max = (int) ($params[0] ?? 0);
if (is_string($value) && mb_strlen($value) > $max) {
$this->errors[$field][] = "The {$field} field must not exceed {$max} characters.";
} elseif (is_numeric($value) && $value > $max) {
$this->errors[$field][] = "The {$field} field must not exceed {$max}.";
}
}
private function validatesNumeric(string $field, mixed $value, array $params): void
{
if ($value !== null && $value !== '' && !is_numeric($value)) {
$this->errors[$field][] = "The {$field} field must be a number.";
}
}
private function validatesInteger(string $field, mixed $value, array $params): void
{
if ($value !== null && $value !== '' && filter_var($value, FILTER_VALIDATE_INT) === false) {
$this->errors[$field][] = "The {$field} field must be an integer.";
}
}
private function validatesIp(string $field, mixed $value, array $params): void
{
if ($value !== null && $value !== '' && !filter_var($value, FILTER_VALIDATE_IP)) {
$this->errors[$field][] = "The {$field} field must be a valid IP address or hostname.";
}
}
private function validatesPort(string $field, mixed $value, array $params): void
{
if ($value !== null && $value !== '') {
$port = (int) $value;
if ($port < 1 || $port > 65535) {
$this->errors[$field][] = "The {$field} field must be a valid port (1-65535).";
}
}
}
private function validatesAlphaNum(string $field, mixed $value, array $params): void
{
if ($value !== null && $value !== '' && !preg_match('/^[a-zA-Z0-9_\-]+$/', $value)) {
$this->errors[$field][] = "The {$field} field may only contain letters, numbers, dashes and underscores.";
}
}
private function validatesMatch(string $field, mixed $value, array $params): void
{
$otherField = $params[0] ?? null;
if ($otherField && ($this->data[$otherField] ?? null) !== $value) {
$this->errors[$field][] = "The {$field} field does not match {$otherField}.";
}
}
private function validatesIn(string $field, mixed $value, array $params): void
{
if ($value !== null && $value !== '' && !in_array($value, $params, true)) {
$this->errors[$field][] = "The selected {$field} is invalid.";
}
}
private function validatesJson(string $field, mixed $value, array $params): void
{
if ($value !== null && $value !== '' && is_string($value)) {
json_decode($value);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->errors[$field][] = "The {$field} field must be valid JSON.";
}
}
}
}

134
src/Core/View.php Executable file
View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace ServerManager\Core;
class View
{
private string $viewsPath;
private string $layout = 'main';
private array $data = [];
private Security $security;
public function __construct(Security $security)
{
$this->viewsPath = dirname(__DIR__, 2) . '/views/';
$this->security = $security;
}
public function setLayout(string $layout): self
{
$this->layout = $layout;
return $this;
}
public function assign(string $key, mixed $value): self
{
$this->data[$key] = $value;
return $this;
}
public function assignMultiple(array $data): self
{
$this->data = array_merge($this->data, $data);
return $this;
}
public function render(string $view, array $data = []): string
{
$data = array_merge($this->data, $data);
$content = $this->renderView($view, $data);
if ($this->layout !== null) {
$data['content'] = $content;
$content = $this->renderView('layouts/' . $this->layout, $data);
}
return $content;
}
public function renderView(string $view, array $data = []): string
{
$viewFile = $this->viewsPath . str_replace('.', '/', $view) . '.php';
if (!file_exists($viewFile)) {
throw new \RuntimeException("View not found: {$view}");
}
extract($data, EXTR_SKIP);
ob_start();
include $viewFile;
return ob_get_clean();
}
public function renderPartial(string $partial, array $data = []): string
{
return $this->renderView('partials/' . $partial, $data);
}
public function display(string $view, array $data = []): void
{
echo $this->render($view, $data);
}
public function escape(string $value): string
{
return $this->security->purify($value);
}
public function csrfToken(): string
{
return $this->security->generateCSRFToken();
}
public function csrfField(): string
{
$token = $this->csrfToken();
return '<input type="hidden" name="_csrf_token" value="' . $this->escape($token) . '">';
}
public function redirect(string $url, int $statusCode = 302): never
{
http_response_code($statusCode);
header("Location: {$url}");
exit;
}
public function json(mixed $data, int $statusCode = 200): never
{
http_response_code($statusCode);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit;
}
public function error(int $statusCode, string $message = ''): never
{
http_response_code($statusCode);
if (str_starts_with($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json')) {
$this->json(['error' => true, 'message' => $message ?: $this->getStatusMessage($statusCode)], $statusCode);
}
$this->display("errors.{$statusCode}", [
'code' => $statusCode,
'message' => $message ?: $this->getStatusMessage($statusCode),
]);
exit;
}
private function getStatusMessage(int $code): string
{
return match($code) {
400 => 'Bad Request',
401 => 'Unauthorized',
403 => 'Forbidden',
404 => 'Not Found',
405 => 'Method Not Allowed',
429 => 'Too Many Requests',
500 => 'Internal Server Error',
503 => 'Service Unavailable',
default => 'Error',
};
}
}