ServerManager project files
This commit is contained in:
159
src/Core/App.php
Executable file
159
src/Core/App.php
Executable 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
122
src/Core/Database.php
Executable 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
92
src/Core/Encryption.php
Executable 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
148
src/Core/Logger.php
Executable 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
143
src/Core/Router.php
Executable 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
124
src/Core/Security.php
Executable 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
103
src/Core/Session.php
Executable 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
160
src/Core/Validator.php
Executable 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
134
src/Core/View.php
Executable 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user