refactor: rename lib/ to core/ for clearer core-module separation
Rename the top-level lib/ directory to core/ so the project root immediately communicates which code is core platform and which lives in modules/. PHP namespaces (MintyPHP\*) are unchanged — only the Composer PSR-4 path mapping moves from lib/ to core/. Module-internal lib/ directories (modules/*/lib/) are untouched. Updated across the full stack: - composer.json PSR-4 mapping - bootstrap entry points (web/index.php, bin/*, tests/bootstrap.php) - tooling configs (phpstan.neon, phpunit.xml, php-cs-fixer) - 26 architecture contract tests - enforcement-policy, guard-catalog, quality-gates - all documentation (CLAUDE.md, README, 25 docs/, .agents/skills/) - bin/qa-extended.sh search paths - .claude/settings.local.json permission paths Workflow: .agents/runs/CORE-LIB-RENAME-001/ (Analyst → Planner → Executor → Code Review (4 findings fixed) → Security Review → Acceptance Test → Finalizer) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
54
core/App/AppContainer.php
Normal file
54
core/App/AppContainer.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
// Minimal DI container: factories are registered with set() and resolved lazily on first get().
|
||||
// Each service is instantiated at most once — get() returns the same instance on every call.
|
||||
final class AppContainer
|
||||
{
|
||||
/** @var array<string, callable(self): mixed> */
|
||||
private array $bindings = [];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
private array $instances = [];
|
||||
|
||||
private bool $protectExistingBindings = false;
|
||||
|
||||
public function set(string $id, callable $factory): void
|
||||
{
|
||||
if ($this->protectExistingBindings && $this->has($id)) {
|
||||
throw new RuntimeException('Refusing to overwrite existing service binding: ' . $id);
|
||||
}
|
||||
$this->bindings[$id] = $factory;
|
||||
}
|
||||
|
||||
public function has(string $id): bool
|
||||
{
|
||||
return array_key_exists($id, $this->instances) || array_key_exists($id, $this->bindings);
|
||||
}
|
||||
|
||||
public function get(string $id): mixed
|
||||
{
|
||||
if (array_key_exists($id, $this->instances)) {
|
||||
return $this->instances[$id];
|
||||
}
|
||||
|
||||
if (!array_key_exists($id, $this->bindings)) {
|
||||
throw new RuntimeException('Service not bound: ' . $id);
|
||||
}
|
||||
|
||||
// Resolve, cache, and return — factory receives the container for its own dependencies.
|
||||
$this->instances[$id] = ($this->bindings[$id])($this);
|
||||
return $this->instances[$id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Freeze existing bindings/instances: any later overwrite attempt throws.
|
||||
*/
|
||||
public function protectExistingBindings(): void
|
||||
{
|
||||
$this->protectExistingBindings = true;
|
||||
}
|
||||
}
|
||||
341
core/App/Bootstrap/EnvValidator.php
Normal file
341
core/App/Bootstrap/EnvValidator.php
Normal file
@@ -0,0 +1,341 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Bootstrap;
|
||||
|
||||
final class EnvValidator
|
||||
{
|
||||
/** @var list<string> */
|
||||
private const REQUIRED_KEYS = [
|
||||
'APP_NAME',
|
||||
'APP_ENV',
|
||||
'APP_DEBUG',
|
||||
'APP_TIMEZONE',
|
||||
'APP_STORAGE_PATH',
|
||||
'APP_LOCALE',
|
||||
'APP_LOCALES',
|
||||
'APP_I18N_DOMAIN',
|
||||
'SESSION_NAME',
|
||||
'TENANT_SCOPE_STRICT',
|
||||
'DB_HOST',
|
||||
'DB_PORT',
|
||||
'DB_NAME',
|
||||
'DB_USER',
|
||||
'DB_PASS',
|
||||
'CACHE_SERVERS',
|
||||
'FIREWALL_CONCURRENCY',
|
||||
'FIREWALL_SPINLOCK_SECONDS',
|
||||
'FIREWALL_INTERVAL_SECONDS',
|
||||
'FIREWALL_CACHE_PREFIX',
|
||||
'FIREWALL_REVERSE_PROXY',
|
||||
];
|
||||
|
||||
public static function validate(): void
|
||||
{
|
||||
if (!self::isValidationEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$env = [];
|
||||
foreach (array_merge(self::REQUIRED_KEYS, ['APP_URL', 'APP_CRYPTO_KEY', 'APP_VENDOR_PHP_ISSUES_MODE']) as $key) {
|
||||
$env[$key] = getenv($key);
|
||||
}
|
||||
|
||||
$errors = self::validateArray($env);
|
||||
if ($errors === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new \RuntimeException(
|
||||
"Configuration validation failed:\n - "
|
||||
. implode("\n - ", $errors)
|
||||
. "\nPlease verify your .env based on .env.example"
|
||||
);
|
||||
}
|
||||
|
||||
// Validation is on by default; set APP_CONFIG_VALIDATE=false to skip (e.g. in CI without full env).
|
||||
public static function isValidationEnabled(): bool
|
||||
{
|
||||
$raw = getenv('APP_CONFIG_VALIDATE');
|
||||
if ($raw === false || trim((string) $raw) === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return self::parseBool((string) $raw) ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $env
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function validateArray(array $env): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
foreach (self::REQUIRED_KEYS as $key) {
|
||||
if (!self::hasEnvKey($env, $key)) {
|
||||
$errors[] = "Missing required env key '{$key}'";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (self::value($env, $key) === '') {
|
||||
$errors[] = "Env key '{$key}' must not be empty";
|
||||
}
|
||||
}
|
||||
|
||||
$appEnv = strtolower(self::value($env, 'APP_ENV'));
|
||||
if ($appEnv !== '' && !in_array($appEnv, ['dev', 'prod', 'test'], true)) {
|
||||
$errors[] = "APP_ENV must be one of: dev, prod, test";
|
||||
}
|
||||
|
||||
$appDebug = self::value($env, 'APP_DEBUG');
|
||||
if ($appDebug !== '' && self::parseBool($appDebug) === null) {
|
||||
$errors[] = "APP_DEBUG must be a boolean value (true/false/1/0)";
|
||||
}
|
||||
|
||||
$tenantScopeStrict = self::value($env, 'TENANT_SCOPE_STRICT');
|
||||
if ($tenantScopeStrict !== '' && self::parseBool($tenantScopeStrict) === null) {
|
||||
$errors[] = "TENANT_SCOPE_STRICT must be a boolean value (true/false/1/0)";
|
||||
}
|
||||
|
||||
$vendorPhpIssuesMode = strtolower(self::value($env, 'APP_VENDOR_PHP_ISSUES_MODE'));
|
||||
if (
|
||||
$vendorPhpIssuesMode !== ''
|
||||
&& !in_array($vendorPhpIssuesMode, ['strict', 'suppress_deprecations', 'suppress_deprecations_warnings'], true)
|
||||
) {
|
||||
$errors[] = 'APP_VENDOR_PHP_ISSUES_MODE must be one of: strict, suppress_deprecations, suppress_deprecations_warnings';
|
||||
}
|
||||
|
||||
$reverseProxy = self::value($env, 'FIREWALL_REVERSE_PROXY');
|
||||
if ($reverseProxy !== '' && self::parseBool($reverseProxy) === null) {
|
||||
$errors[] = "FIREWALL_REVERSE_PROXY must be a boolean value (true/false/1/0)";
|
||||
}
|
||||
|
||||
$timezone = self::value($env, 'APP_TIMEZONE');
|
||||
if ($timezone !== '' && !in_array($timezone, \DateTimeZone::listIdentifiers(), true)) {
|
||||
$errors[] = "APP_TIMEZONE is invalid: '{$timezone}'";
|
||||
}
|
||||
|
||||
$locales = self::parseCsv(self::value($env, 'APP_LOCALES'));
|
||||
if ($locales === []) {
|
||||
$errors[] = 'APP_LOCALES must contain at least one locale';
|
||||
}
|
||||
|
||||
$locale = self::value($env, 'APP_LOCALE');
|
||||
if ($locale !== '' && $locales !== [] && !in_array($locale, $locales, true)) {
|
||||
$errors[] = "APP_LOCALE '{$locale}' is not part of APP_LOCALES";
|
||||
}
|
||||
|
||||
$dbPort = self::parseInt(self::value($env, 'DB_PORT'));
|
||||
if ($dbPort === null || $dbPort < 1 || $dbPort > 65535) {
|
||||
$errors[] = 'DB_PORT must be an integer between 1 and 65535';
|
||||
}
|
||||
|
||||
$firewallConcurrency = self::parseInt(self::value($env, 'FIREWALL_CONCURRENCY'));
|
||||
if ($firewallConcurrency === null || $firewallConcurrency < 1) {
|
||||
$errors[] = 'FIREWALL_CONCURRENCY must be an integer >= 1';
|
||||
}
|
||||
|
||||
$firewallInterval = self::parseInt(self::value($env, 'FIREWALL_INTERVAL_SECONDS'));
|
||||
if ($firewallInterval === null || $firewallInterval < 1) {
|
||||
$errors[] = 'FIREWALL_INTERVAL_SECONDS must be an integer >= 1';
|
||||
}
|
||||
|
||||
$firewallSpinLock = self::parseFloat(self::value($env, 'FIREWALL_SPINLOCK_SECONDS'));
|
||||
if ($firewallSpinLock === null || $firewallSpinLock <= 0) {
|
||||
$errors[] = 'FIREWALL_SPINLOCK_SECONDS must be a float > 0';
|
||||
}
|
||||
|
||||
$cacheServers = self::parseCsv(self::value($env, 'CACHE_SERVERS'));
|
||||
if ($cacheServers === []) {
|
||||
$errors[] = 'CACHE_SERVERS must contain at least one host[:port] entry';
|
||||
} else {
|
||||
foreach ($cacheServers as $server) {
|
||||
if (!preg_match('/^[a-z0-9.-]+(?::\d{1,5})?$/i', $server)) {
|
||||
$errors[] = "CACHE_SERVERS contains invalid entry '{$server}'";
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = explode(':', $server, 2);
|
||||
if (count($parts) !== 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$port = self::parseInt($parts[1]);
|
||||
if ($port === null || $port < 1 || $port > 65535) {
|
||||
$errors[] = "CACHE_SERVERS entry '{$server}' has invalid port";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$storagePath = self::value($env, 'APP_STORAGE_PATH');
|
||||
if ($storagePath !== '') {
|
||||
$absoluteStoragePath = self::absolutePath($storagePath);
|
||||
if (!is_dir($absoluteStoragePath)) {
|
||||
$errors[] = "APP_STORAGE_PATH does not exist: '{$storagePath}'";
|
||||
} elseif (!is_writable($absoluteStoragePath)) {
|
||||
$errors[] = "APP_STORAGE_PATH is not writable: '{$storagePath}'";
|
||||
}
|
||||
}
|
||||
|
||||
$cryptoKey = self::value($env, 'APP_CRYPTO_KEY');
|
||||
if ($cryptoKey !== '' && !self::isValidCryptoKey($cryptoKey)) {
|
||||
$errors[] = 'APP_CRYPTO_KEY must be 64-char hex or base64 encoded 32-byte key';
|
||||
}
|
||||
|
||||
$appUrl = self::value($env, 'APP_URL');
|
||||
$appUrlIsValid = true;
|
||||
if ($appUrl !== '' && !self::isValidHttpUrl($appUrl)) {
|
||||
$errors[] = "APP_URL is invalid: '{$appUrl}'";
|
||||
$appUrlIsValid = false;
|
||||
}
|
||||
|
||||
if (in_array($appEnv, ['dev', 'prod'], true) && $appUrl === '') {
|
||||
if ($appEnv === 'prod') {
|
||||
$errors[] = 'APP_URL must be set in production';
|
||||
} else {
|
||||
$errors[] = 'APP_URL must be set in development';
|
||||
}
|
||||
}
|
||||
|
||||
// Production-only hard requirements: HTTPS, real hostname, crypto key, debug off, strict tenant scope.
|
||||
if ($appEnv === 'prod') {
|
||||
if ($appUrl !== '' && $appUrlIsValid) {
|
||||
$appUrlScheme = strtolower((string) parse_url($appUrl, PHP_URL_SCHEME));
|
||||
if ($appUrlScheme !== 'https') {
|
||||
$errors[] = 'APP_URL must use https in production';
|
||||
}
|
||||
|
||||
$appUrlHost = strtolower((string) parse_url($appUrl, PHP_URL_HOST));
|
||||
if (self::isLocalOrIpHost($appUrlHost)) {
|
||||
$errors[] = 'APP_URL must not use localhost or an IP host in production';
|
||||
}
|
||||
}
|
||||
if ($cryptoKey === '') {
|
||||
$errors[] = 'APP_CRYPTO_KEY must be set in production';
|
||||
}
|
||||
if (self::parseBool($appDebug) !== false) {
|
||||
$errors[] = 'APP_DEBUG must be false in production';
|
||||
}
|
||||
if (self::parseBool($tenantScopeStrict) !== true) {
|
||||
$errors[] = 'TENANT_SCOPE_STRICT must be true in production';
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $env
|
||||
*/
|
||||
private static function hasEnvKey(array $env, string $key): bool
|
||||
{
|
||||
return array_key_exists($key, $env) && $env[$key] !== false && $env[$key] !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $env
|
||||
*/
|
||||
private static function value(array $env, string $key): string
|
||||
{
|
||||
if (!self::hasEnvKey($env, $key)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return trim((string) $env[$key]);
|
||||
}
|
||||
|
||||
private static function parseBool(string $value): ?bool
|
||||
{
|
||||
$parsed = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
private static function parseInt(string $value): ?int
|
||||
{
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
$parsed = filter_var($value, FILTER_VALIDATE_INT);
|
||||
return $parsed !== false ? (int) $parsed : null;
|
||||
}
|
||||
|
||||
private static function parseFloat(string $value): ?float
|
||||
{
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
$parsed = filter_var($value, FILTER_VALIDATE_FLOAT);
|
||||
return $parsed !== false ? (float) $parsed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function parseCsv(string $value): array
|
||||
{
|
||||
if ($value === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map('trim', explode(',', $value))));
|
||||
}
|
||||
|
||||
// Accepts either 64-char hex (32 bytes) or base64-encoded 32 bytes — both produce a 256-bit key.
|
||||
private static function isValidCryptoKey(string $value): bool
|
||||
{
|
||||
if (preg_match('/^[a-f0-9]{64}$/i', $value) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$decoded = base64_decode($value, true);
|
||||
return is_string($decoded) && strlen($decoded) === 32;
|
||||
}
|
||||
|
||||
private static function isValidHttpUrl(string $value): bool
|
||||
{
|
||||
if (filter_var($value, FILTER_VALIDATE_URL) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$scheme = strtolower((string) parse_url($value, PHP_URL_SCHEME));
|
||||
return in_array($scheme, ['http', 'https'], true);
|
||||
}
|
||||
|
||||
private static function isLocalOrIpHost(string $host): bool
|
||||
{
|
||||
$host = strtolower(trim($host));
|
||||
if ($host === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($host === 'localhost' || str_ends_with($host, '.localhost')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$host = trim($host, '[]');
|
||||
return filter_var($host, FILTER_VALIDATE_IP) !== false;
|
||||
}
|
||||
|
||||
private static function absolutePath(string $path): string
|
||||
{
|
||||
if (self::isAbsolutePath($path)) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
$projectRoot = dirname(__DIR__, 3);
|
||||
return $projectRoot . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
private static function isAbsolutePath(string $path): bool
|
||||
{
|
||||
if ($path === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($path[0] === '/' || $path[0] === '\\') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return preg_match('/^[A-Za-z]:[\\\\\/]/', $path) === 1;
|
||||
}
|
||||
}
|
||||
10
core/App/Container/ContainerRegistrar.php
Normal file
10
core/App/Container/ContainerRegistrar.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Container;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
|
||||
interface ContainerRegistrar
|
||||
{
|
||||
public function register(AppContainer $container): void;
|
||||
}
|
||||
46
core/App/Container/Registrars/AccessRegistrar.php
Normal file
46
core/App/Container/Registrars/AccessRegistrar.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Container\Registrars;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Container\ContainerRegistrar;
|
||||
use MintyPHP\App\Module\ModuleRegistry;
|
||||
use MintyPHP\Repository\Access\PermissionRepository;
|
||||
use MintyPHP\Repository\Access\RoleAssignableRoleRepository;
|
||||
use MintyPHP\Repository\Access\RolePermissionRepository;
|
||||
use MintyPHP\Repository\Access\RoleRepository;
|
||||
use MintyPHP\Service\Access\AccessServicesFactory;
|
||||
use MintyPHP\Service\Access\AssignableRoleService;
|
||||
use MintyPHP\Service\Access\AuthorizationService;
|
||||
use MintyPHP\Service\Access\PermissionService;
|
||||
use MintyPHP\Service\Access\RoleService;
|
||||
use MintyPHP\Service\Access\UiAccessService;
|
||||
use MintyPHP\Service\Audit\AuditRecorderInterface;
|
||||
use MintyPHP\Service\Directory\DirectoryServicesFactory;
|
||||
|
||||
final class AccessRegistrar implements ContainerRegistrar
|
||||
{
|
||||
public function register(AppContainer $container): void
|
||||
{
|
||||
$container->set(AuthorizationService::class, static fn (AppContainer $c): AuthorizationService => $c->get(AccessServicesFactory::class)->createAuthorizationService());
|
||||
$container->set(UiAccessService::class, static fn (AppContainer $c): UiAccessService => new UiAccessService(
|
||||
$c->get(AuthorizationService::class),
|
||||
$c->has(ModuleRegistry::class) ? $c->get(ModuleRegistry::class) : null
|
||||
));
|
||||
$container->set(PermissionService::class, static fn (AppContainer $c): PermissionService => $c->get(AccessServicesFactory::class)->createPermissionService());
|
||||
// RoleService lives in DirectoryServicesFactory (not AccessServicesFactory) because
|
||||
// role management involves directory-level concerns (departments, tenant structure).
|
||||
$container->set(RoleService::class, static fn (AppContainer $c): RoleService => $c->get(DirectoryServicesFactory::class)->createRoleService());
|
||||
$container->set(PermissionRepository::class, static fn (AppContainer $c): PermissionRepository => $c->get(AccessServicesFactory::class)->createPermissionRepository());
|
||||
$container->set(RolePermissionRepository::class, static fn (AppContainer $c): RolePermissionRepository => $c->get(AccessServicesFactory::class)->createRolePermissionRepository());
|
||||
$container->set(RoleAssignableRoleRepository::class, static fn (AppContainer $c): RoleAssignableRoleRepository => $c->get(AccessServicesFactory::class)->createRoleAssignableRoleRepository());
|
||||
$container->set(RoleRepository::class, static fn (AppContainer $c): RoleRepository => $c->get(DirectoryServicesFactory::class)->createRoleRepository());
|
||||
$container->set(AssignableRoleService::class, static fn (AppContainer $c): AssignableRoleService => new AssignableRoleService(
|
||||
$c->get(RoleAssignableRoleRepository::class),
|
||||
$c->get(AccessServicesFactory::class)->createUserRoleRepository(),
|
||||
$c->get(PermissionService::class),
|
||||
$c->get(RoleRepository::class),
|
||||
$c->get(AuditRecorderInterface::class)
|
||||
));
|
||||
}
|
||||
}
|
||||
101
core/App/Container/Registrars/AppServicesRegistrar.php
Normal file
101
core/App/Container/Registrars/AppServicesRegistrar.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Container\Registrars;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Container\ContainerRegistrar;
|
||||
use MintyPHP\App\Module\ModulePermissionSynchronizer;
|
||||
use MintyPHP\App\Module\ModuleRegistry;
|
||||
use MintyPHP\App\Module\ModuleRuntimeAssetPublisher;
|
||||
use MintyPHP\App\Module\ModuleRuntimePageBuilder;
|
||||
use MintyPHP\Http\CookieStore;
|
||||
use MintyPHP\Http\CookieStoreInterface;
|
||||
use MintyPHP\Http\Input\RequestInputFactory;
|
||||
use MintyPHP\Http\IntendedUrlService;
|
||||
use MintyPHP\Http\RequestRuntime;
|
||||
use MintyPHP\Http\RequestRuntimeInterface;
|
||||
use MintyPHP\Http\SessionStore;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Repository\Access\PermissionRepository;
|
||||
use MintyPHP\Repository\Module\ModuleMigrationRepository;
|
||||
use MintyPHP\Repository\Search\SearchQueryRepository;
|
||||
use MintyPHP\Repository\Stats\AdminStatsRepository;
|
||||
use MintyPHP\Repository\System\SystemHealthRepository;
|
||||
use MintyPHP\Repository\Tenant\UserTenantRepository;
|
||||
use MintyPHP\Service\Access\PermissionService;
|
||||
use MintyPHP\Service\CustomField\CustomFieldServicesFactory;
|
||||
use MintyPHP\Service\CustomField\TenantCustomFieldService;
|
||||
use MintyPHP\Service\CustomField\UserCustomFieldValueService;
|
||||
use MintyPHP\Service\Data\GridUserCountEnricher;
|
||||
use MintyPHP\Service\Import\ImportService;
|
||||
use MintyPHP\Service\Import\ImportServicesFactory;
|
||||
use MintyPHP\Service\Mail\MailLogService;
|
||||
use MintyPHP\Service\Mail\MailService;
|
||||
use MintyPHP\Service\Mail\MailServicesFactory;
|
||||
use MintyPHP\Service\Module\ModuleMigrationService;
|
||||
use MintyPHP\Service\Scheduler\ScheduledJobService;
|
||||
use MintyPHP\Service\Scheduler\SchedulerRunService;
|
||||
use MintyPHP\Service\Scheduler\SchedulerServicesFactory;
|
||||
use MintyPHP\Service\Search\SearchDataService;
|
||||
use MintyPHP\Service\Security\RateLimiterService;
|
||||
use MintyPHP\Service\Security\SecurityServicesFactory;
|
||||
use MintyPHP\Service\Stats\AdminStatsViewDataService;
|
||||
use MintyPHP\Service\System\SystemHealthService;
|
||||
use MintyPHP\Service\System\SystemInfoService;
|
||||
|
||||
final class AppServicesRegistrar implements ContainerRegistrar
|
||||
{
|
||||
public function register(AppContainer $container): void
|
||||
{
|
||||
$projectRoot = dirname(__DIR__, 4);
|
||||
|
||||
$container->set(AdminStatsViewDataService::class, static fn (AppContainer $c): AdminStatsViewDataService => new AdminStatsViewDataService(
|
||||
$c->get(AdminStatsRepository::class)
|
||||
));
|
||||
$container->set(SystemHealthRepository::class, static fn (): SystemHealthRepository => new SystemHealthRepository());
|
||||
$container->set(SystemHealthService::class, static fn (AppContainer $c): SystemHealthService => new SystemHealthService(
|
||||
$c->get(SystemHealthRepository::class)
|
||||
));
|
||||
$container->set(SystemInfoService::class, static fn (AppContainer $c): SystemInfoService => new SystemInfoService(
|
||||
$c->get(SystemHealthService::class),
|
||||
$c->get(SystemHealthRepository::class),
|
||||
$c->has(ModuleRegistry::class) ? $c->get(ModuleRegistry::class) : null
|
||||
));
|
||||
$container->set(SearchDataService::class, static fn (AppContainer $c): SearchDataService => new SearchDataService(
|
||||
$c->get(PermissionService::class),
|
||||
$c->get(UserTenantRepository::class),
|
||||
$c->get(SearchQueryRepository::class)
|
||||
));
|
||||
$container->set(RateLimiterService::class, static fn (AppContainer $c): RateLimiterService => $c->get(SecurityServicesFactory::class)->createRateLimiterService());
|
||||
$container->set(MailService::class, static fn (AppContainer $c): MailService => $c->get(MailServicesFactory::class)->createMailService());
|
||||
$container->set(MailLogService::class, static fn (AppContainer $c): MailLogService => $c->get(MailServicesFactory::class)->createMailLogService());
|
||||
// Audit services are registered by the audit module's AuditContainerRegistrar.
|
||||
$container->set(ImportService::class, static fn (AppContainer $c): ImportService => $c->get(ImportServicesFactory::class)->createImportService());
|
||||
$container->set(ScheduledJobService::class, static fn (AppContainer $c): ScheduledJobService => $c->get(SchedulerServicesFactory::class)->createScheduledJobService());
|
||||
$container->set(SchedulerRunService::class, static fn (AppContainer $c): SchedulerRunService => $c->get(SchedulerServicesFactory::class)->createSchedulerRunService());
|
||||
$container->set(TenantCustomFieldService::class, static fn (AppContainer $c): TenantCustomFieldService => $c->get(CustomFieldServicesFactory::class)->createTenantCustomFieldService());
|
||||
$container->set(UserCustomFieldValueService::class, static fn (AppContainer $c): UserCustomFieldValueService => $c->get(CustomFieldServicesFactory::class)->createUserCustomFieldValueService());
|
||||
// AuditMetadataEnricher is registered by the audit module's AuditContainerRegistrar.
|
||||
$container->set(GridUserCountEnricher::class, static fn (): GridUserCountEnricher => new GridUserCountEnricher());
|
||||
$container->set(IntendedUrlService::class, static fn (): IntendedUrlService => new IntendedUrlService());
|
||||
$container->set(RequestInputFactory::class, static fn (): RequestInputFactory => new RequestInputFactory());
|
||||
$container->set(SessionStore::class, static fn (): SessionStore => new SessionStore());
|
||||
$container->set(SessionStoreInterface::class, static fn (AppContainer $c): SessionStoreInterface => $c->get(SessionStore::class));
|
||||
$container->set(CookieStore::class, static fn (): CookieStore => new CookieStore());
|
||||
$container->set(CookieStoreInterface::class, static fn (AppContainer $c): CookieStoreInterface => $c->get(CookieStore::class));
|
||||
$container->set(RequestRuntime::class, static fn (): RequestRuntime => new RequestRuntime());
|
||||
$container->set(RequestRuntimeInterface::class, static fn (AppContainer $c): RequestRuntimeInterface => $c->get(RequestRuntime::class));
|
||||
$container->set(ModuleMigrationRepository::class, static fn (): ModuleMigrationRepository => new ModuleMigrationRepository());
|
||||
$container->set(ModuleMigrationService::class, static fn (AppContainer $c): ModuleMigrationService => new ModuleMigrationService(
|
||||
$c->get(ModuleRegistry::class),
|
||||
$c->get(ModuleMigrationRepository::class)
|
||||
));
|
||||
$container->set(ModulePermissionSynchronizer::class, fn (AppContainer $c): ModulePermissionSynchronizer => new ModulePermissionSynchronizer(
|
||||
$c->get(ModuleRegistry::class),
|
||||
$c->get(PermissionRepository::class),
|
||||
$projectRoot . '/modules'
|
||||
));
|
||||
$container->set(ModuleRuntimePageBuilder::class, static fn (): ModuleRuntimePageBuilder => new ModuleRuntimePageBuilder());
|
||||
$container->set(ModuleRuntimeAssetPublisher::class, static fn (): ModuleRuntimeAssetPublisher => new ModuleRuntimeAssetPublisher());
|
||||
}
|
||||
}
|
||||
50
core/App/Container/Registrars/AuthRegistrar.php
Normal file
50
core/App/Container/Registrars/AuthRegistrar.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Container\Registrars;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Container\ContainerRegistrar;
|
||||
use MintyPHP\Repository\Auth\ApiTokenRepository;
|
||||
use MintyPHP\Repository\Auth\PasswordResetRepository;
|
||||
use MintyPHP\Repository\Auth\RememberTokenRepository;
|
||||
use MintyPHP\Repository\Tenant\TenantLdapAuthRepository;
|
||||
use MintyPHP\Repository\Tenant\TenantMicrosoftAuthRepository;
|
||||
use MintyPHP\Repository\Tenant\TenantRepository;
|
||||
use MintyPHP\Service\Auth\ApiTokenEndpointService;
|
||||
use MintyPHP\Service\Auth\ApiTokenService;
|
||||
use MintyPHP\Service\Auth\AuthRepositoryFactory;
|
||||
use MintyPHP\Service\Auth\AuthService;
|
||||
use MintyPHP\Service\Auth\AuthServicesFactory;
|
||||
use MintyPHP\Service\Auth\EmailVerificationService;
|
||||
use MintyPHP\Service\Auth\LdapAuthService;
|
||||
use MintyPHP\Service\Auth\MicrosoftOidcService;
|
||||
use MintyPHP\Service\Auth\PasswordResetService;
|
||||
use MintyPHP\Service\Auth\RememberMeService;
|
||||
use MintyPHP\Service\Auth\SsoUserLinkService;
|
||||
use MintyPHP\Service\Auth\TenantSsoService;
|
||||
|
||||
final class AuthRegistrar implements ContainerRegistrar
|
||||
{
|
||||
public function register(AppContainer $container): void
|
||||
{
|
||||
$container->set(AuthService::class, static fn (AppContainer $c): AuthService => $c->get(AuthServicesFactory::class)->createAuthService());
|
||||
$container->set(RememberMeService::class, static fn (AppContainer $c): RememberMeService => $c->get(AuthServicesFactory::class)->createRememberMeService());
|
||||
$container->set(ApiTokenService::class, static fn (AppContainer $c): ApiTokenService => $c->get(AuthServicesFactory::class)->createApiTokenService());
|
||||
$container->set(ApiTokenEndpointService::class, static fn (AppContainer $c): ApiTokenEndpointService => new ApiTokenEndpointService(
|
||||
$c->get(AuthRepositoryFactory::class)->createApiTokenRepository(),
|
||||
$c->get(TenantRepository::class),
|
||||
$c->get(\MintyPHP\Service\Tenant\TenantScopeService::class)
|
||||
));
|
||||
$container->set(PasswordResetService::class, static fn (AppContainer $c): PasswordResetService => $c->get(AuthServicesFactory::class)->createPasswordResetService());
|
||||
$container->set(EmailVerificationService::class, static fn (AppContainer $c): EmailVerificationService => $c->get(AuthServicesFactory::class)->createEmailVerificationService());
|
||||
$container->set(TenantSsoService::class, static fn (AppContainer $c): TenantSsoService => $c->get(AuthServicesFactory::class)->createTenantSsoService());
|
||||
$container->set(MicrosoftOidcService::class, static fn (AppContainer $c): MicrosoftOidcService => $c->get(AuthServicesFactory::class)->createMicrosoftOidcService());
|
||||
$container->set(SsoUserLinkService::class, static fn (AppContainer $c): SsoUserLinkService => $c->get(AuthServicesFactory::class)->createSsoUserLinkService());
|
||||
$container->set(LdapAuthService::class, static fn (AppContainer $c): LdapAuthService => $c->get(AuthServicesFactory::class)->createLdapAuthService());
|
||||
$container->set(TenantMicrosoftAuthRepository::class, static fn (AppContainer $c): TenantMicrosoftAuthRepository => $c->get(AuthRepositoryFactory::class)->createTenantMicrosoftAuthRepository());
|
||||
$container->set(TenantLdapAuthRepository::class, static fn (AppContainer $c): TenantLdapAuthRepository => $c->get(AuthRepositoryFactory::class)->createTenantLdapAuthRepository());
|
||||
$container->set(ApiTokenRepository::class, static fn (AppContainer $c): ApiTokenRepository => $c->get(AuthRepositoryFactory::class)->createApiTokenRepository());
|
||||
$container->set(RememberTokenRepository::class, static fn (AppContainer $c): RememberTokenRepository => $c->get(AuthRepositoryFactory::class)->createRememberTokenRepository());
|
||||
$container->set(PasswordResetRepository::class, static fn (AppContainer $c): PasswordResetRepository => $c->get(AuthRepositoryFactory::class)->createPasswordResetRepository());
|
||||
}
|
||||
}
|
||||
31
core/App/Container/Registrars/DirectoryRegistrar.php
Normal file
31
core/App/Container/Registrars/DirectoryRegistrar.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Container\Registrars;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Container\ContainerRegistrar;
|
||||
use MintyPHP\Repository\Org\DepartmentRepository;
|
||||
use MintyPHP\Repository\Tenant\TenantRepository;
|
||||
use MintyPHP\Service\Directory\DirectoryServicesFactory;
|
||||
use MintyPHP\Service\Directory\DirectorySettingsGateway;
|
||||
use MintyPHP\Service\Org\DepartmentService;
|
||||
use MintyPHP\Service\Tenant\TenantAvatarService;
|
||||
use MintyPHP\Service\Tenant\TenantFaviconService;
|
||||
use MintyPHP\Service\Tenant\TenantScopeService;
|
||||
use MintyPHP\Service\Tenant\TenantService;
|
||||
use MintyPHP\Service\Tenant\TenantServicesFactory;
|
||||
|
||||
final class DirectoryRegistrar implements ContainerRegistrar
|
||||
{
|
||||
public function register(AppContainer $container): void
|
||||
{
|
||||
$container->set(TenantService::class, static fn (AppContainer $c): TenantService => $c->get(DirectoryServicesFactory::class)->createTenantService());
|
||||
$container->set(TenantScopeService::class, static fn (AppContainer $c): TenantScopeService => $c->get(TenantServicesFactory::class)->createTenantScopeService());
|
||||
$container->set(DepartmentService::class, static fn (AppContainer $c): DepartmentService => $c->get(DirectoryServicesFactory::class)->createDepartmentService());
|
||||
$container->set(DirectorySettingsGateway::class, static fn (AppContainer $c): DirectorySettingsGateway => $c->get(DirectoryServicesFactory::class)->createDirectorySettingsGateway());
|
||||
$container->set(TenantAvatarService::class, static fn (AppContainer $c): TenantAvatarService => $c->get(TenantServicesFactory::class)->createTenantAvatarService());
|
||||
$container->set(TenantFaviconService::class, static fn (AppContainer $c): TenantFaviconService => $c->get(TenantServicesFactory::class)->createTenantFaviconService());
|
||||
$container->set(TenantRepository::class, static fn (AppContainer $c): TenantRepository => $c->get(TenantServicesFactory::class)->createTenantRepository());
|
||||
$container->set(DepartmentRepository::class, static fn (AppContainer $c): DepartmentRepository => $c->get(DirectoryServicesFactory::class)->createDepartmentRepository());
|
||||
}
|
||||
}
|
||||
40
core/App/Container/Registrars/RepositoryFactoryRegistrar.php
Normal file
40
core/App/Container/Registrars/RepositoryFactoryRegistrar.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Container\Registrars;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Container\ContainerRegistrar;
|
||||
use MintyPHP\Repository\Search\SearchQueryRepository;
|
||||
use MintyPHP\Repository\Stats\AdminStatsRepository;
|
||||
use MintyPHP\Repository\Support\DatabaseSessionRepository;
|
||||
use MintyPHP\Service\Access\AccessRepositoryFactory;
|
||||
use MintyPHP\Service\Auth\AuthRepositoryFactory;
|
||||
use MintyPHP\Service\Directory\DirectoryRepositoryFactory;
|
||||
use MintyPHP\Service\Import\ImportRepositoryFactory;
|
||||
use MintyPHP\Service\Mail\MailRepositoryFactory;
|
||||
use MintyPHP\Service\Scheduler\SchedulerRepositoryFactory;
|
||||
use MintyPHP\Service\Security\SecurityRepositoryFactory;
|
||||
use MintyPHP\Service\Settings\SettingRepositoryFactory;
|
||||
use MintyPHP\Service\Tenant\TenantRepositoryFactory;
|
||||
use MintyPHP\Service\User\UserRepositoryFactory;
|
||||
|
||||
final class RepositoryFactoryRegistrar implements ContainerRegistrar
|
||||
{
|
||||
public function register(AppContainer $container): void
|
||||
{
|
||||
$container->set(SettingRepositoryFactory::class, static fn (): SettingRepositoryFactory => new SettingRepositoryFactory());
|
||||
$container->set(SecurityRepositoryFactory::class, static fn (): SecurityRepositoryFactory => new SecurityRepositoryFactory());
|
||||
$container->set(MailRepositoryFactory::class, static fn (): MailRepositoryFactory => new MailRepositoryFactory());
|
||||
$container->set(ImportRepositoryFactory::class, static fn (): ImportRepositoryFactory => new ImportRepositoryFactory());
|
||||
$container->set(SchedulerRepositoryFactory::class, static fn (): SchedulerRepositoryFactory => new SchedulerRepositoryFactory());
|
||||
$container->set(TenantRepositoryFactory::class, static fn (): TenantRepositoryFactory => new TenantRepositoryFactory());
|
||||
$container->set(DirectoryRepositoryFactory::class, static fn (): DirectoryRepositoryFactory => new DirectoryRepositoryFactory());
|
||||
$container->set(AccessRepositoryFactory::class, static fn (): AccessRepositoryFactory => new AccessRepositoryFactory());
|
||||
$container->set(UserRepositoryFactory::class, static fn (): UserRepositoryFactory => new UserRepositoryFactory());
|
||||
$container->set(AuthRepositoryFactory::class, static fn (): AuthRepositoryFactory => new AuthRepositoryFactory());
|
||||
// These repositories have no factory because they're standalone — no shared config or gateway needed.
|
||||
$container->set(AdminStatsRepository::class, static fn (): AdminStatsRepository => new AdminStatsRepository());
|
||||
$container->set(SearchQueryRepository::class, static fn (): SearchQueryRepository => new SearchQueryRepository());
|
||||
$container->set(DatabaseSessionRepository::class, static fn (): DatabaseSessionRepository => new DatabaseSessionRepository());
|
||||
}
|
||||
}
|
||||
156
core/App/Container/Registrars/ServiceFactoryRegistrar.php
Normal file
156
core/App/Container/Registrars/ServiceFactoryRegistrar.php
Normal file
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Container\Registrars;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Container\ContainerRegistrar;
|
||||
use MintyPHP\App\Module\ModuleClassResolver;
|
||||
use MintyPHP\App\Module\ModuleEventDispatcher;
|
||||
use MintyPHP\App\Module\ModuleRegistry;
|
||||
use MintyPHP\Http\CookieStoreInterface;
|
||||
use MintyPHP\Http\RequestRuntimeInterface;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Repository\Support\DatabaseSessionRepository;
|
||||
use MintyPHP\Repository\Tenant\TenantRepository;
|
||||
use MintyPHP\Service\Access\AccessGatewayFactory;
|
||||
use MintyPHP\Service\Access\AccessPolicyFactory;
|
||||
use MintyPHP\Service\Access\AccessRepositoryFactory;
|
||||
use MintyPHP\Service\Access\AccessServicesFactory;
|
||||
use MintyPHP\Service\Access\AssignableRoleService;
|
||||
use MintyPHP\Service\Access\PermissionService;
|
||||
use MintyPHP\Service\Audit\AuditRecorderInterface;
|
||||
use MintyPHP\Service\Audit\ImportAuditInterface;
|
||||
use MintyPHP\Service\Audit\UserLifecycleAuditInterface;
|
||||
use MintyPHP\Service\Auth\AuthGatewayFactory;
|
||||
use MintyPHP\Service\Auth\AuthRepositoryFactory;
|
||||
use MintyPHP\Service\Auth\AuthServicesFactory;
|
||||
use MintyPHP\Service\Branding\BrandingServicesFactory;
|
||||
use MintyPHP\Service\CustomField\CustomFieldServicesFactory;
|
||||
use MintyPHP\Service\Directory\DirectoryGatewayFactory;
|
||||
use MintyPHP\Service\Directory\DirectoryRepositoryFactory;
|
||||
use MintyPHP\Service\Directory\DirectoryServicesFactory;
|
||||
use MintyPHP\Service\Import\ImportServicesFactory;
|
||||
use MintyPHP\Service\Mail\MailRepositoryFactory;
|
||||
use MintyPHP\Service\Mail\MailServicesFactory;
|
||||
use MintyPHP\Service\Scheduler\SchedulerRepositoryFactory;
|
||||
use MintyPHP\Service\Scheduler\SchedulerServicesFactory;
|
||||
use MintyPHP\Service\Security\SecurityRepositoryFactory;
|
||||
use MintyPHP\Service\Security\SecurityServicesFactory;
|
||||
use MintyPHP\Service\Settings\SettingRepositoryFactory;
|
||||
use MintyPHP\Service\Settings\SettingServicesFactory;
|
||||
use MintyPHP\Service\Tenant\TenantRepositoryFactory;
|
||||
use MintyPHP\Service\Tenant\TenantScopeService;
|
||||
use MintyPHP\Service\Tenant\TenantServicesFactory;
|
||||
use MintyPHP\Service\User\UserGatewayFactory;
|
||||
use MintyPHP\Service\User\UserRepositoryFactory;
|
||||
use MintyPHP\Service\User\UserServicesFactory;
|
||||
|
||||
final class ServiceFactoryRegistrar implements ContainerRegistrar
|
||||
{
|
||||
public function register(AppContainer $container): void
|
||||
{
|
||||
$container->set(SettingServicesFactory::class, static fn (AppContainer $c): SettingServicesFactory => new SettingServicesFactory(
|
||||
$c->get(SettingRepositoryFactory::class)
|
||||
));
|
||||
// Audit interfaces are NOT registered here — they are set AFTER module registrars
|
||||
// in registerContainer.php so the audit module can provide real implementations.
|
||||
// If the audit module is disabled, null implementations are used as fallback.
|
||||
$container->set(SecurityServicesFactory::class, static fn (AppContainer $c): SecurityServicesFactory => new SecurityServicesFactory(
|
||||
$c->get(SecurityRepositoryFactory::class)
|
||||
));
|
||||
$container->set(MailServicesFactory::class, static fn (AppContainer $c): MailServicesFactory => new MailServicesFactory(
|
||||
$c->get(MailRepositoryFactory::class),
|
||||
$c->get(SettingServicesFactory::class)->createSettingsSmtpGateway()
|
||||
));
|
||||
$container->set(BrandingServicesFactory::class, static fn (AppContainer $c): BrandingServicesFactory => new BrandingServicesFactory(
|
||||
$c->get(SettingServicesFactory::class)->createSettingsAppGateway()
|
||||
));
|
||||
$container->set(CustomFieldServicesFactory::class, static fn (AppContainer $c): CustomFieldServicesFactory => new CustomFieldServicesFactory(
|
||||
$c->get(TenantRepository::class),
|
||||
$c->get(TenantScopeService::class)
|
||||
));
|
||||
$container->set(TenantServicesFactory::class, static fn (AppContainer $c): TenantServicesFactory => new TenantServicesFactory(
|
||||
$c->get(PermissionService::class),
|
||||
$c->get(TenantRepositoryFactory::class)
|
||||
));
|
||||
$container->set(ImportServicesFactory::class, static fn (AppContainer $c): ImportServicesFactory => new ImportServicesFactory(
|
||||
$c->get(UserServicesFactory::class),
|
||||
$c->get(AccessServicesFactory::class),
|
||||
$c->get(SettingServicesFactory::class),
|
||||
$c->get(DirectoryServicesFactory::class),
|
||||
$c->get(ImportAuditInterface::class),
|
||||
$c->get(TenantScopeService::class),
|
||||
$c->get(SessionStoreInterface::class)
|
||||
));
|
||||
$container->set(SchedulerServicesFactory::class, static fn (AppContainer $c): SchedulerServicesFactory => new SchedulerServicesFactory(
|
||||
$c->get(UserServicesFactory::class),
|
||||
$c->get(AuditRecorderInterface::class),
|
||||
$c->get(DatabaseSessionRepository::class),
|
||||
$c->get(SchedulerRepositoryFactory::class),
|
||||
$c
|
||||
));
|
||||
$container->set(DirectoryServicesFactory::class, static fn (AppContainer $c): DirectoryServicesFactory => new DirectoryServicesFactory(
|
||||
$c->get(UserServicesFactory::class),
|
||||
$c->get(AuditRecorderInterface::class),
|
||||
$c->get(DirectoryRepositoryFactory::class),
|
||||
$c->get(DirectoryGatewayFactory::class)
|
||||
));
|
||||
$container->set(DirectoryGatewayFactory::class, static fn (AppContainer $c): DirectoryGatewayFactory => new DirectoryGatewayFactory(
|
||||
$c->get(SettingServicesFactory::class),
|
||||
$c->get(TenantScopeService::class)
|
||||
));
|
||||
$container->set(AccessServicesFactory::class, static fn (AppContainer $c): AccessServicesFactory => new AccessServicesFactory(
|
||||
$c->get(AccessRepositoryFactory::class),
|
||||
$c->get(AccessGatewayFactory::class),
|
||||
// Wrapped in a second closure to break the circular dependency:
|
||||
// AccessServicesFactory <-> AccessPolicyFactory via PermissionService.
|
||||
static fn (): AccessPolicyFactory => $c->get(AccessPolicyFactory::class)
|
||||
));
|
||||
$container->set(AccessGatewayFactory::class, static fn (AppContainer $c): AccessGatewayFactory => new AccessGatewayFactory(
|
||||
$c->get(AccessRepositoryFactory::class),
|
||||
$c->get(AuditRecorderInterface::class),
|
||||
$c->get(SessionStoreInterface::class)
|
||||
));
|
||||
$container->set(AccessPolicyFactory::class, static fn (AppContainer $c): AccessPolicyFactory => new AccessPolicyFactory(
|
||||
$c->get(PermissionService::class),
|
||||
$c->get(TenantScopeService::class),
|
||||
$c->has(ModuleRegistry::class) ? $c->get(ModuleRegistry::class) : null,
|
||||
static fn (string $class): ?object => ModuleClassResolver::resolve($c, $class)
|
||||
));
|
||||
$container->set(UserGatewayFactory::class, static fn (AppContainer $c): UserGatewayFactory => new UserGatewayFactory(
|
||||
$c->get(AccessServicesFactory::class),
|
||||
$c->get(SettingServicesFactory::class),
|
||||
$c->get(TenantScopeService::class),
|
||||
$c->get(DirectoryRepositoryFactory::class),
|
||||
));
|
||||
$container->set(UserServicesFactory::class, static fn (AppContainer $c): UserServicesFactory => new UserServicesFactory(
|
||||
$c->get(AuditRecorderInterface::class),
|
||||
$c->get(UserLifecycleAuditInterface::class),
|
||||
$c->get(UserRepositoryFactory::class),
|
||||
$c->get(UserGatewayFactory::class),
|
||||
$c->get(DatabaseSessionRepository::class),
|
||||
// Wrapped in a closure to break circular dependency:
|
||||
// UserServicesFactory -> AssignableRoleService -> RoleRepository -> DirectoryServicesFactory -> UserServicesFactory
|
||||
static fn (): AssignableRoleService => $c->get(AssignableRoleService::class),
|
||||
$c->has(ModuleEventDispatcher::class) ? $c->get(ModuleEventDispatcher::class) : null
|
||||
));
|
||||
$container->set(AuthGatewayFactory::class, static fn (AppContainer $c): AuthGatewayFactory => new AuthGatewayFactory(
|
||||
$c->get(AuthRepositoryFactory::class),
|
||||
$c->get(AccessServicesFactory::class),
|
||||
$c->get(SettingServicesFactory::class),
|
||||
$c->get(TenantScopeService::class)
|
||||
));
|
||||
$container->set(AuthServicesFactory::class, static fn (AppContainer $c): AuthServicesFactory => new AuthServicesFactory(
|
||||
$c->get(UserServicesFactory::class),
|
||||
$c->get(AuditRecorderInterface::class),
|
||||
$c->get(MailServicesFactory::class),
|
||||
$c->get(AuthRepositoryFactory::class),
|
||||
$c->get(AuthGatewayFactory::class),
|
||||
$c->get(DatabaseSessionRepository::class),
|
||||
$c->get(SessionStoreInterface::class),
|
||||
$c->get(CookieStoreInterface::class),
|
||||
$c->get(RequestRuntimeInterface::class),
|
||||
$c
|
||||
));
|
||||
}
|
||||
}
|
||||
74
core/App/Container/Registrars/SettingsRegistrar.php
Normal file
74
core/App/Container/Registrars/SettingsRegistrar.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Container\Registrars;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Container\ContainerRegistrar;
|
||||
use MintyPHP\Repository\Auth\ApiTokenRepository;
|
||||
use MintyPHP\Repository\Auth\RememberTokenRepository;
|
||||
use MintyPHP\Service\Access\RoleService;
|
||||
use MintyPHP\Service\Audit\AuditRecorderInterface;
|
||||
use MintyPHP\Service\Branding\BrandingFaviconService;
|
||||
use MintyPHP\Service\Branding\BrandingLogoService;
|
||||
use MintyPHP\Service\Branding\BrandingServicesFactory;
|
||||
use MintyPHP\Service\Org\DepartmentService;
|
||||
use MintyPHP\Service\Settings\AdminSettingsService;
|
||||
use MintyPHP\Service\Settings\SettingCacheService;
|
||||
use MintyPHP\Service\Settings\SettingsApiPolicyGateway;
|
||||
use MintyPHP\Service\Settings\SettingsAppGateway;
|
||||
use MintyPHP\Service\Settings\SettingsDefaultsGateway;
|
||||
use MintyPHP\Service\Settings\SettingServicesFactory;
|
||||
use MintyPHP\Service\Settings\SettingsFrontendTelemetryGateway;
|
||||
use MintyPHP\Service\Settings\SettingsLoginPersistenceGateway;
|
||||
use MintyPHP\Service\Settings\SettingsMetadataGateway;
|
||||
use MintyPHP\Service\Settings\SettingsMicrosoftGateway;
|
||||
use MintyPHP\Service\Settings\SettingsSessionGateway;
|
||||
use MintyPHP\Service\Settings\SettingsSmtpGateway;
|
||||
use MintyPHP\Service\Settings\SettingsSystemAuditGateway;
|
||||
use MintyPHP\Service\Settings\SettingsUserLifecycleGateway;
|
||||
use MintyPHP\Service\Settings\ThemeConfigGateway;
|
||||
use MintyPHP\Service\Tenant\TenantService;
|
||||
|
||||
final class SettingsRegistrar implements ContainerRegistrar
|
||||
{
|
||||
public function register(AppContainer $container): void
|
||||
{
|
||||
$container->set(SettingsMetadataGateway::class, static fn (AppContainer $c): SettingsMetadataGateway => $c->get(SettingServicesFactory::class)->createSettingsMetadataGateway());
|
||||
$container->set(SettingsDefaultsGateway::class, static fn (AppContainer $c): SettingsDefaultsGateway => $c->get(SettingServicesFactory::class)->createSettingsDefaultsGateway());
|
||||
$container->set(SettingsAppGateway::class, static fn (AppContainer $c): SettingsAppGateway => $c->get(SettingServicesFactory::class)->createSettingsAppGateway());
|
||||
$container->set(SettingsApiPolicyGateway::class, static fn (AppContainer $c): SettingsApiPolicyGateway => $c->get(SettingServicesFactory::class)->createSettingsApiPolicyGateway());
|
||||
$container->set(SettingsUserLifecycleGateway::class, static fn (AppContainer $c): SettingsUserLifecycleGateway => $c->get(SettingServicesFactory::class)->createSettingsUserLifecycleGateway());
|
||||
$container->set(SettingsSessionGateway::class, static fn (AppContainer $c): SettingsSessionGateway => $c->get(SettingServicesFactory::class)->createSettingsSessionGateway());
|
||||
$container->set(SettingsSystemAuditGateway::class, static fn (AppContainer $c): SettingsSystemAuditGateway => $c->get(SettingServicesFactory::class)->createSettingsSystemAuditGateway());
|
||||
$container->set(SettingsFrontendTelemetryGateway::class, static fn (AppContainer $c): SettingsFrontendTelemetryGateway => $c->get(SettingServicesFactory::class)->createSettingsFrontendTelemetryGateway());
|
||||
$container->set(SettingsMicrosoftGateway::class, static fn (AppContainer $c): SettingsMicrosoftGateway => $c->get(SettingServicesFactory::class)->createSettingsMicrosoftGateway());
|
||||
$container->set(SettingsSmtpGateway::class, static fn (AppContainer $c): SettingsSmtpGateway => $c->get(SettingServicesFactory::class)->createSettingsSmtpGateway());
|
||||
$container->set(SettingsLoginPersistenceGateway::class, static fn (AppContainer $c): SettingsLoginPersistenceGateway => $c->get(SettingServicesFactory::class)->createSettingsLoginPersistenceGateway());
|
||||
$container->set(SettingCacheService::class, static fn (AppContainer $c): SettingCacheService => $c->get(SettingServicesFactory::class)->createSettingCacheService());
|
||||
$container->set(AdminSettingsService::class, static fn (AppContainer $c): AdminSettingsService => new AdminSettingsService(
|
||||
$c->get(SettingsDefaultsGateway::class),
|
||||
$c->get(SettingsAppGateway::class),
|
||||
$c->get(SettingsApiPolicyGateway::class),
|
||||
$c->get(SettingsUserLifecycleGateway::class),
|
||||
$c->get(SettingsSessionGateway::class),
|
||||
$c->get(SettingsSystemAuditGateway::class),
|
||||
$c->get(SettingsFrontendTelemetryGateway::class),
|
||||
$c->get(SettingsMicrosoftGateway::class),
|
||||
$c->get(SettingsSmtpGateway::class),
|
||||
$c->get(SettingsLoginPersistenceGateway::class),
|
||||
$c->get(SettingsMetadataGateway::class),
|
||||
$c->get(SettingCacheService::class),
|
||||
$c->get(TenantService::class),
|
||||
$c->get(RoleService::class),
|
||||
$c->get(DepartmentService::class),
|
||||
$c->get(RememberTokenRepository::class),
|
||||
$c->get(ApiTokenRepository::class),
|
||||
$c->get(AuditRecorderInterface::class)
|
||||
));
|
||||
$container->set(ThemeConfigGateway::class, static fn (AppContainer $c): ThemeConfigGateway => $c->get(SettingServicesFactory::class)->createThemeConfigGateway());
|
||||
// Branding services are registered here because they depend on settings gateways
|
||||
// and are consumed alongside settings (e.g. in UserAccessPdfService and the admin UI).
|
||||
$container->set(BrandingLogoService::class, static fn (AppContainer $c): BrandingLogoService => $c->get(BrandingServicesFactory::class)->createBrandingLogoService());
|
||||
$container->set(BrandingFaviconService::class, static fn (AppContainer $c): BrandingFaviconService => $c->get(BrandingServicesFactory::class)->createBrandingFaviconService());
|
||||
}
|
||||
}
|
||||
62
core/App/Container/Registrars/UserRegistrar.php
Normal file
62
core/App/Container/Registrars/UserRegistrar.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Container\Registrars;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Container\ContainerRegistrar;
|
||||
use MintyPHP\Repository\Access\UserRoleRepository;
|
||||
use MintyPHP\Repository\Org\UserDepartmentRepository;
|
||||
use MintyPHP\Repository\Tenant\UserTenantRepository;
|
||||
use MintyPHP\Repository\User\UserReadRepository;
|
||||
use MintyPHP\Repository\User\UserWriteRepository;
|
||||
use MintyPHP\Service\Auth\TenantSsoService;
|
||||
use MintyPHP\Service\Branding\BrandingLogoService;
|
||||
use MintyPHP\Service\User\UserAccessPdfService;
|
||||
use MintyPHP\Service\User\UserAccessTemplateService;
|
||||
use MintyPHP\Service\User\UserAccountService;
|
||||
use MintyPHP\Service\User\UserApiWriteInputMapper;
|
||||
use MintyPHP\Service\User\UserAssignmentService;
|
||||
use MintyPHP\Service\User\UserAvatarService;
|
||||
use MintyPHP\Service\User\UserDirectoryGateway;
|
||||
use MintyPHP\Service\User\UserLifecycleRestoreService;
|
||||
use MintyPHP\Service\User\UserLifecycleService;
|
||||
use MintyPHP\Service\User\UserPasswordPolicyService;
|
||||
use MintyPHP\Service\User\UserPasswordService;
|
||||
use MintyPHP\Service\User\UserRepositoryFactory;
|
||||
use MintyPHP\Service\User\UserServicesFactory;
|
||||
use MintyPHP\Service\User\UserTenantContextService;
|
||||
|
||||
final class UserRegistrar implements ContainerRegistrar
|
||||
{
|
||||
public function register(AppContainer $container): void
|
||||
{
|
||||
$container->set(UserAccountService::class, static fn (AppContainer $c): UserAccountService => $c->get(UserServicesFactory::class)->createUserAccountService());
|
||||
$container->set(UserAssignmentService::class, static fn (AppContainer $c): UserAssignmentService => $c->get(UserServicesFactory::class)->createUserAssignmentService());
|
||||
$container->set(UserPasswordService::class, static fn (AppContainer $c): UserPasswordService => $c->get(UserServicesFactory::class)->createUserPasswordService());
|
||||
$container->set(UserAvatarService::class, static fn (AppContainer $c): UserAvatarService => $c->get(UserServicesFactory::class)->createUserAvatarService());
|
||||
$container->set(UserDirectoryGateway::class, static fn (AppContainer $c): UserDirectoryGateway => $c->get(UserServicesFactory::class)->createUserDirectoryGateway());
|
||||
$container->set(UserAccessTemplateService::class, static fn (AppContainer $c): UserAccessTemplateService => new UserAccessTemplateService(
|
||||
$c->get(TenantSsoService::class),
|
||||
$c->get(UserDirectoryGateway::class),
|
||||
$c->get(UserTenantRepository::class)
|
||||
));
|
||||
$container->set(UserAccessPdfService::class, static fn (AppContainer $c): UserAccessPdfService => new UserAccessPdfService(
|
||||
$c->get(UserAccessTemplateService::class),
|
||||
$c->get(BrandingLogoService::class)
|
||||
));
|
||||
$container->set(UserTenantContextService::class, static fn (AppContainer $c): UserTenantContextService => $c->get(UserServicesFactory::class)->createUserTenantContextService());
|
||||
$container->set(UserLifecycleService::class, static fn (AppContainer $c): UserLifecycleService => $c->get(UserServicesFactory::class)->createUserLifecycleService());
|
||||
$container->set(UserPasswordPolicyService::class, static fn (AppContainer $c): UserPasswordPolicyService => $c->get(UserServicesFactory::class)->createUserPasswordPolicyService());
|
||||
$container->set(UserLifecycleRestoreService::class, static fn (AppContainer $c): UserLifecycleRestoreService => $c->get(UserServicesFactory::class)->createUserLifecycleRestoreService());
|
||||
$container->set(UserApiWriteInputMapper::class, static fn (AppContainer $c): UserApiWriteInputMapper => new UserApiWriteInputMapper(
|
||||
$c->get(\MintyPHP\Service\Tenant\TenantService::class),
|
||||
$c->get(\MintyPHP\Service\Access\RoleService::class),
|
||||
$c->get(\MintyPHP\Service\Org\DepartmentService::class)
|
||||
));
|
||||
$container->set(UserReadRepository::class, static fn (AppContainer $c): UserReadRepository => $c->get(UserRepositoryFactory::class)->createUserReadRepository());
|
||||
$container->set(UserWriteRepository::class, static fn (AppContainer $c): UserWriteRepository => $c->get(UserRepositoryFactory::class)->createUserWriteRepository());
|
||||
$container->set(UserTenantRepository::class, static fn (AppContainer $c): UserTenantRepository => $c->get(UserRepositoryFactory::class)->createUserTenantRepository());
|
||||
$container->set(UserRoleRepository::class, static fn (AppContainer $c): UserRoleRepository => $c->get(UserRepositoryFactory::class)->createUserRoleRepository());
|
||||
$container->set(UserDepartmentRepository::class, static fn (AppContainer $c): UserDepartmentRepository => $c->get(UserRepositoryFactory::class)->createUserDepartmentRepository());
|
||||
}
|
||||
}
|
||||
18
core/App/Module/Contracts/EventListener.php
Normal file
18
core/App/Module/Contracts/EventListener.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module\Contracts;
|
||||
|
||||
/**
|
||||
* Interface for module event listeners.
|
||||
*
|
||||
* Modules declare listeners in their manifest under 'event_listeners'.
|
||||
* The dispatcher resolves listeners from the container and calls handle().
|
||||
*/
|
||||
interface EventListener
|
||||
{
|
||||
/**
|
||||
* @param string $event Event name (e.g. 'user.deleted')
|
||||
* @param array<string, mixed> $payload Event-specific data
|
||||
*/
|
||||
public function handle(string $event, array $payload): void;
|
||||
}
|
||||
25
core/App/Module/Contracts/LayoutContextProvider.php
Normal file
25
core/App/Module/Contracts/LayoutContextProvider.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module\Contracts;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
|
||||
/**
|
||||
* Provides module-specific data for the layout navigation context.
|
||||
*
|
||||
* Called from appBuildLayoutNavContext() after Core data assembly.
|
||||
* Return value is merged into the $layoutNav array passed to templates.
|
||||
*
|
||||
* IMPORTANT: Only called in web request context. CLI/scheduler callers
|
||||
* must not invoke layout context providers. Implementations should use
|
||||
* try/catch around request-dependent calls (e.g. requestInput()) as a
|
||||
* defensive measure.
|
||||
*/
|
||||
interface LayoutContextProvider
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $session Current session data
|
||||
* @return array<string, mixed> Key-value pairs to merge into $layoutNav
|
||||
*/
|
||||
public function provide(array $session, AppContainer $container): array;
|
||||
}
|
||||
17
core/App/Module/Contracts/ModuleDeactivationHandler.php
Normal file
17
core/App/Module/Contracts/ModuleDeactivationHandler.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module\Contracts;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
|
||||
/**
|
||||
* Handles cleanup when a module is deactivated.
|
||||
*
|
||||
* Invoked by `php bin/console module:deactivate` after a module has been removed
|
||||
* from the enabled list. The handler receives the full app container and
|
||||
* can perform cleanup tasks like removing orphaned data or revoking permissions.
|
||||
*/
|
||||
interface ModuleDeactivationHandler
|
||||
{
|
||||
public function deactivate(AppContainer $container): void;
|
||||
}
|
||||
46
core/App/Module/Contracts/SearchResourceProvider.php
Normal file
46
core/App/Module/Contracts/SearchResourceProvider.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module\Contracts;
|
||||
|
||||
/**
|
||||
* Provides search resources contributed by a module.
|
||||
*
|
||||
* Each provider returns a list of search resources (with SQL for count/preview/result),
|
||||
* maps result rows to display items, and provides list URLs for deep-linking.
|
||||
*/
|
||||
interface SearchResourceProvider
|
||||
{
|
||||
/**
|
||||
* Return search resource definitions.
|
||||
*
|
||||
* Supported SQL placeholders (replaced before parameter binding):
|
||||
* - `{{tenantFilter}}` — replaced with a tenant-scope WHERE clause (or empty string)
|
||||
* - `{{userId}}` — replaced with the current user's ID (integer, safe for inline SQL)
|
||||
*
|
||||
* All remaining `?` placeholders are bound to the LIKE search term.
|
||||
* The last `?` in preview_sql is reserved for the preview limit.
|
||||
*
|
||||
* @return array<string, array{
|
||||
* label: string,
|
||||
* permission: string,
|
||||
* count_sql: string,
|
||||
* preview_sql: string,
|
||||
* result_sql: string,
|
||||
* tenant_filter?: string
|
||||
* }>
|
||||
*/
|
||||
public function resources(): array;
|
||||
|
||||
/**
|
||||
* Map a raw result row to a display-ready item.
|
||||
*
|
||||
* @param array<string, mixed> $row
|
||||
* @return array{title: string, subtitle?: string, url: string, icon?: string}|null
|
||||
*/
|
||||
public function mapResultItem(string $resourceKey, array $row): ?array;
|
||||
|
||||
/**
|
||||
* Return the list URL for the given resource key and encoded search term.
|
||||
*/
|
||||
public function listUrl(string $resourceKey, string $encodedSearch): string;
|
||||
}
|
||||
26
core/App/Module/Contracts/SessionProvider.php
Normal file
26
core/App/Module/Contracts/SessionProvider.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module\Contracts;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
|
||||
/**
|
||||
* Populates module-specific session data after login and cleans up on logout.
|
||||
*
|
||||
* Called from the login flow after successful authentication. When a module is
|
||||
* deactivated, its provider is not called — session keys are simply not set.
|
||||
*/
|
||||
interface SessionProvider
|
||||
{
|
||||
/**
|
||||
* Populate session keys with module-specific data after successful login.
|
||||
*
|
||||
* @param array<string, mixed> $user The authenticated user record
|
||||
*/
|
||||
public function populate(array $user, AppContainer $container): void;
|
||||
|
||||
/**
|
||||
* Remove session keys owned by this module (called on logout).
|
||||
*/
|
||||
public function clear(AppContainer $container): void;
|
||||
}
|
||||
50
core/App/Module/ModuleClassResolver.php
Normal file
50
core/App/Module/ModuleClassResolver.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
|
||||
/**
|
||||
* Resolve module-declared classes from DI first and only fall back to direct
|
||||
* instantiation when the class is not container-bound and has no required
|
||||
* constructor arguments.
|
||||
*/
|
||||
final class ModuleClassResolver
|
||||
{
|
||||
public static function resolve(AppContainer $container, string $class): ?object
|
||||
{
|
||||
$class = trim($class);
|
||||
if ($class === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($container->has($class)) {
|
||||
try {
|
||||
$resolved = $container->get($class);
|
||||
return is_object($resolved) ? $resolved : null;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!class_exists($class)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$reflection = new \ReflectionClass($class);
|
||||
if (!$reflection->isInstantiable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$constructor = $reflection->getConstructor();
|
||||
if ($constructor !== null && $constructor->getNumberOfRequiredParameters() > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $reflection->newInstance();
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
75
core/App/Module/ModuleEventDispatcher.php
Normal file
75
core/App/Module/ModuleEventDispatcher.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Module\Contracts\EventListener;
|
||||
use MintyPHP\Http\RequestContext;
|
||||
use MintyPHP\Service\Audit\AuditRecorderInterface;
|
||||
|
||||
/**
|
||||
* Fire-and-forget event dispatcher for module lifecycle events.
|
||||
*
|
||||
* Listeners are declared in module manifests and lazy-resolved from the container.
|
||||
* A failing listener does not break the dispatch chain — the exception is swallowed
|
||||
* so that one misbehaving module cannot disrupt core flows.
|
||||
*/
|
||||
final class ModuleEventDispatcher
|
||||
{
|
||||
/**
|
||||
* @param array<string, list<array{class: class-string, method: string}>> $listenerMap
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $listenerMap,
|
||||
private readonly AppContainer $container,
|
||||
private readonly ?AuditRecorderInterface $systemAuditService = null
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function dispatch(string $event, array $payload): void
|
||||
{
|
||||
$listeners = $this->listenerMap[$event] ?? [];
|
||||
|
||||
foreach ($listeners as $descriptor) {
|
||||
$class = '';
|
||||
$method = '';
|
||||
try {
|
||||
$class = $descriptor['class'];
|
||||
$method = $descriptor['method'];
|
||||
|
||||
$listener = ModuleClassResolver::resolve($this->container, $class);
|
||||
if (!$listener instanceof EventListener) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$listener->{$method}($event, $payload);
|
||||
} catch (\Throwable $exception) {
|
||||
$this->recordListenerFailure($event, $class, $method, $exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function recordListenerFailure(
|
||||
string $event,
|
||||
string $listenerClass,
|
||||
string $listenerMethod,
|
||||
\Throwable $exception
|
||||
): void {
|
||||
if ($this->systemAuditService === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->systemAuditService->record('module.listener.failed', 'failed', [
|
||||
'metadata' => [
|
||||
'module_event' => $event,
|
||||
'listener_class' => $listenerClass,
|
||||
'listener_method' => $listenerMethod,
|
||||
'exception_class' => $exception::class,
|
||||
'request_id' => RequestContext::id(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
55
core/App/Module/ModuleI18nLoader.php
Normal file
55
core/App/Module/ModuleI18nLoader.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module;
|
||||
|
||||
use MintyPHP\I18n;
|
||||
use ReflectionProperty;
|
||||
|
||||
/**
|
||||
* Preloads core + module translations into I18n::$strings at boot time.
|
||||
*
|
||||
* Since I18n::$strings is private static and lives in a vendor class,
|
||||
* Reflection is used once per locale to inject the merged translation map.
|
||||
*/
|
||||
final class ModuleI18nLoader
|
||||
{
|
||||
/**
|
||||
* @param list<ModuleManifest> $manifests Active module manifests (already sorted)
|
||||
* @param string $coreI18nDir Absolute path to the core i18n/ directory
|
||||
*/
|
||||
public static function preload(array $manifests, string $coreI18nDir): void
|
||||
{
|
||||
$locales = ['de', 'en'];
|
||||
$domain = I18n::$domain ?: 'default';
|
||||
|
||||
$prop = new ReflectionProperty(I18n::class, 'strings');
|
||||
|
||||
foreach ($locales as $locale) {
|
||||
// Load core file
|
||||
$coreFile = $coreI18nDir . '/' . $domain . '_' . $locale . '.json';
|
||||
$merged = is_file($coreFile)
|
||||
? (json_decode(file_get_contents($coreFile), true) ?: [])
|
||||
: [];
|
||||
|
||||
// Merge each module's translations on top
|
||||
foreach ($manifests as $manifest) {
|
||||
if ($manifest->i18nPath === null) {
|
||||
continue;
|
||||
}
|
||||
$moduleFile = $manifest->i18nPath . '/' . $domain . '_' . $locale . '.json';
|
||||
if (!is_file($moduleFile)) {
|
||||
continue;
|
||||
}
|
||||
$moduleStrings = json_decode(file_get_contents($moduleFile), true);
|
||||
if (is_array($moduleStrings)) {
|
||||
$merged = array_merge($merged, $moduleStrings);
|
||||
}
|
||||
}
|
||||
|
||||
// Inject via Reflection into I18n::$strings
|
||||
$strings = $prop->getValue();
|
||||
$strings[$domain][$locale] = $merged;
|
||||
$prop->setValue(null, $strings);
|
||||
}
|
||||
}
|
||||
}
|
||||
462
core/App/Module/ModuleManifest.php
Normal file
462
core/App/Module/ModuleManifest.php
Normal file
@@ -0,0 +1,462 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Immutable value object describing a single module's contributions.
|
||||
*
|
||||
* Each module ships a `module.php` that returns an associative array.
|
||||
* ModuleManifest validates and normalises that array into a typed contract.
|
||||
*/
|
||||
final class ModuleManifest
|
||||
{
|
||||
public readonly string $id;
|
||||
public readonly string $version;
|
||||
public readonly bool $enabledByDefault;
|
||||
public readonly int $loadOrder;
|
||||
|
||||
/** @var array<int, array{path: string, target: string, public?: bool}> */
|
||||
public readonly array $routes;
|
||||
|
||||
/** @var list<string> */
|
||||
public readonly array $publicPaths;
|
||||
|
||||
/** @var list<class-string> Container registrar FQCNs */
|
||||
public readonly array $containerRegistrars;
|
||||
|
||||
/** @var array<string, mixed> Keyed by slot name (e.g. 'aside.tab_panel') */
|
||||
public readonly array $uiSlots;
|
||||
|
||||
/** @var list<class-string> SearchResourceProvider FQCNs */
|
||||
public readonly array $searchResources;
|
||||
|
||||
/** @var array<string, list<string>> Asset group name → file list */
|
||||
public readonly array $assetGroups;
|
||||
|
||||
/**
|
||||
* @var list<array{
|
||||
* job_key: string,
|
||||
* handler: string,
|
||||
* label: string,
|
||||
* description: string,
|
||||
* default_enabled: int,
|
||||
* default_timezone: string,
|
||||
* default_schedule_type: string,
|
||||
* default_schedule_interval: int,
|
||||
* default_schedule_time: ?string,
|
||||
* default_schedule_weekdays_csv: ?string,
|
||||
* default_catchup_once: int,
|
||||
* allowed_schedule_types: list<string>
|
||||
* }>
|
||||
*/
|
||||
public readonly array $schedulerJobs;
|
||||
|
||||
/** @var list<class-string> LayoutContextProvider FQCNs */
|
||||
public readonly array $layoutContextProviders;
|
||||
|
||||
/** @var list<class-string> SessionProvider FQCNs */
|
||||
public readonly array $sessionProviders;
|
||||
|
||||
/**
|
||||
* @var list<array{
|
||||
* key: string,
|
||||
* description: string,
|
||||
* active: int,
|
||||
* is_system: int
|
||||
* }>
|
||||
*/
|
||||
public readonly array $permissions;
|
||||
|
||||
/** @var list<class-string> AuthorizationPolicyInterface FQCNs */
|
||||
public readonly array $authorizationPolicies;
|
||||
|
||||
/** @var list<string> Module IDs required by this module */
|
||||
public readonly array $requires;
|
||||
|
||||
/** @var array<string, list<array{class: class-string, method: string}>> Event listeners keyed by event name */
|
||||
public readonly array $eventListeners;
|
||||
|
||||
/** @var class-string|null ModuleDeactivationHandler FQCN */
|
||||
public readonly ?string $deactivationHandler;
|
||||
|
||||
public readonly ?string $migrationsPath;
|
||||
|
||||
public readonly ?string $i18nPath;
|
||||
|
||||
public readonly string $basePath;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $raw The manifest array returned by module.php
|
||||
* @param string $basePath Absolute path to the module directory
|
||||
*/
|
||||
private function __construct(array $raw, string $basePath)
|
||||
{
|
||||
$this->basePath = $basePath;
|
||||
|
||||
// — required fields ————————————————————————————
|
||||
if (!isset($raw['id']) || !is_string($raw['id']) || trim($raw['id']) === '') {
|
||||
throw new InvalidArgumentException('Module manifest must have a non-empty string "id".');
|
||||
}
|
||||
$this->id = trim($raw['id']);
|
||||
|
||||
$this->version = isset($raw['version']) && is_string($raw['version']) ? trim($raw['version']) : '0.0.0';
|
||||
$this->enabledByDefault = (bool) ($raw['enabled_by_default'] ?? false);
|
||||
$this->loadOrder = (int) ($raw['load_order'] ?? 100);
|
||||
|
||||
// — optional contribution arrays ———————————————
|
||||
$this->routes = self::arrayOf($raw, 'routes');
|
||||
$this->publicPaths = self::listOf($raw, 'public_paths');
|
||||
$this->containerRegistrars = self::listOf($raw, 'container_registrars');
|
||||
$this->uiSlots = is_array($raw['ui_slots'] ?? null) ? $raw['ui_slots'] : [];
|
||||
$this->searchResources = self::listOf($raw, 'search_resources');
|
||||
$this->assetGroups = is_array($raw['asset_groups'] ?? null) ? $raw['asset_groups'] : [];
|
||||
$this->schedulerJobs = self::normalizeSchedulerJobs($raw['scheduler_jobs'] ?? [], $this->id);
|
||||
$this->layoutContextProviders = self::listOf($raw, 'layout_context_providers');
|
||||
$this->sessionProviders = self::listOf($raw, 'session_providers');
|
||||
$this->permissions = self::normalizePermissions($raw['permissions'] ?? [], $this->id);
|
||||
$this->authorizationPolicies = self::listOf($raw, 'authorization_policies');
|
||||
|
||||
$this->requires = self::normalizeRequires($raw['requires'] ?? [], $this->id);
|
||||
$this->eventListeners = self::normalizeEventListeners($raw['event_listeners'] ?? [], $this->id);
|
||||
|
||||
$deactivationHandler = $raw['deactivation_handler'] ?? null;
|
||||
$this->deactivationHandler = is_string($deactivationHandler) && trim($deactivationHandler) !== ''
|
||||
? trim($deactivationHandler)
|
||||
: null;
|
||||
|
||||
$migrationsPath = $raw['migrations_path'] ?? null;
|
||||
$this->migrationsPath = is_string($migrationsPath) && trim($migrationsPath) !== ''
|
||||
? rtrim($basePath, '/') . '/' . ltrim(trim($migrationsPath), '/')
|
||||
: null;
|
||||
|
||||
$i18nPath = $raw['i18n_path'] ?? null;
|
||||
$this->i18nPath = is_string($i18nPath) && trim($i18nPath) !== ''
|
||||
? rtrim($basePath, '/') . '/' . ltrim(trim($i18nPath), '/')
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $raw
|
||||
*/
|
||||
public static function fromArray(array $raw, string $basePath): self
|
||||
{
|
||||
return new self($raw, $basePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<mixed>
|
||||
*/
|
||||
private static function listOf(array $raw, string $key): array
|
||||
{
|
||||
$value = $raw[$key] ?? [];
|
||||
return is_array($value) ? array_values($value) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int|string, mixed>
|
||||
*/
|
||||
private static function arrayOf(array $raw, string $key): array
|
||||
{
|
||||
$value = $raw[$key] ?? [];
|
||||
return is_array($value) ? $value : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{key: string, description: string, active: int, is_system: int}>
|
||||
*/
|
||||
private static function normalizePermissions(mixed $value, string $moduleId): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' manifest key 'permissions' must be an array.", $moduleId)
|
||||
);
|
||||
}
|
||||
|
||||
$permissions = [];
|
||||
foreach (array_values($value) as $index => $permission) {
|
||||
if (!is_array($permission)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' permissions[%d] must be an object-like array.", $moduleId, $index)
|
||||
);
|
||||
}
|
||||
|
||||
$key = trim((string) ($permission['key'] ?? ''));
|
||||
$description = trim((string) ($permission['description'] ?? ''));
|
||||
if ($key === '' || $description === '') {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' permissions[%d] must define non-empty 'key' and 'description'.", $moduleId, $index)
|
||||
);
|
||||
}
|
||||
|
||||
$permissions[] = [
|
||||
'key' => $key,
|
||||
'description' => $description,
|
||||
'active' => (int) ((bool) ($permission['active'] ?? true)),
|
||||
'is_system' => (int) ((bool) ($permission['is_system'] ?? true)),
|
||||
];
|
||||
}
|
||||
|
||||
return $permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{
|
||||
* job_key: string,
|
||||
* handler: string,
|
||||
* label: string,
|
||||
* description: string,
|
||||
* default_enabled: int,
|
||||
* default_timezone: string,
|
||||
* default_schedule_type: string,
|
||||
* default_schedule_interval: int,
|
||||
* default_schedule_time: ?string,
|
||||
* default_schedule_weekdays_csv: ?string,
|
||||
* default_catchup_once: int,
|
||||
* allowed_schedule_types: list<string>
|
||||
* }>
|
||||
*/
|
||||
private static function normalizeSchedulerJobs(mixed $value, string $moduleId): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' manifest key 'scheduler_jobs' must be an array.", $moduleId)
|
||||
);
|
||||
}
|
||||
|
||||
$jobs = [];
|
||||
foreach (array_values($value) as $index => $job) {
|
||||
if (!is_array($job)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' scheduler_jobs[%d] must be an object-like array.", $moduleId, $index)
|
||||
);
|
||||
}
|
||||
|
||||
$requiredKeys = [
|
||||
'job_key',
|
||||
'handler',
|
||||
'label',
|
||||
'description',
|
||||
'default_enabled',
|
||||
'default_timezone',
|
||||
'default_schedule_type',
|
||||
'default_schedule_interval',
|
||||
'default_schedule_time',
|
||||
'default_schedule_weekdays_csv',
|
||||
'default_catchup_once',
|
||||
'allowed_schedule_types',
|
||||
];
|
||||
foreach ($requiredKeys as $requiredKey) {
|
||||
if (!array_key_exists($requiredKey, $job)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' scheduler_jobs[%d] is missing required key '%s'.", $moduleId, $index, $requiredKey)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$jobKey = trim((string) ($job['job_key'] ?? ''));
|
||||
$handler = trim((string) ($job['handler'] ?? ''));
|
||||
$label = trim((string) ($job['label'] ?? ''));
|
||||
$description = trim((string) ($job['description'] ?? ''));
|
||||
$defaultTimezone = trim((string) ($job['default_timezone'] ?? ''));
|
||||
$defaultType = strtolower(trim((string) ($job['default_schedule_type'] ?? '')));
|
||||
|
||||
if ($jobKey === '' || $handler === '' || $label === '' || $description === '' || $defaultTimezone === '') {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf(
|
||||
"Module '%s' scheduler_jobs[%d] requires non-empty job_key, handler, label, description and default_timezone.",
|
||||
$moduleId,
|
||||
$index
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!in_array($defaultType, ['hourly', 'daily', 'weekly'], true)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' scheduler_jobs[%d] has invalid default_schedule_type '%s'.", $moduleId, $index, $defaultType)
|
||||
);
|
||||
}
|
||||
|
||||
$allowedTypes = self::normalizeAllowedScheduleTypes($job['allowed_schedule_types'], $moduleId, $index);
|
||||
if (!in_array($defaultType, $allowedTypes, true)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf(
|
||||
"Module '%s' scheduler_jobs[%d] default_schedule_type '%s' must be listed in allowed_schedule_types.",
|
||||
$moduleId,
|
||||
$index,
|
||||
$defaultType
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$defaultInterval = (int) $job['default_schedule_interval'];
|
||||
$defaultTimeRaw = trim((string) ($job['default_schedule_time'] ?? ''));
|
||||
$defaultTime = $defaultTimeRaw !== '' ? $defaultTimeRaw : null;
|
||||
$defaultWeekdays = self::normalizeWeekdaysCsv(
|
||||
$job['default_schedule_weekdays_csv'],
|
||||
$moduleId,
|
||||
$index
|
||||
);
|
||||
|
||||
if (in_array($defaultType, ['daily', 'weekly'], true) && $defaultTime === null) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' scheduler_jobs[%d] requires default_schedule_time for %s jobs.", $moduleId, $index, $defaultType)
|
||||
);
|
||||
}
|
||||
if ($defaultType === 'weekly' && $defaultWeekdays === null) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' scheduler_jobs[%d] requires default_schedule_weekdays_csv for weekly jobs.", $moduleId, $index)
|
||||
);
|
||||
}
|
||||
|
||||
$jobs[] = [
|
||||
'job_key' => $jobKey,
|
||||
'handler' => $handler,
|
||||
'label' => $label,
|
||||
'description' => $description,
|
||||
'default_enabled' => (int) ((bool) $job['default_enabled']),
|
||||
'default_timezone' => $defaultTimezone,
|
||||
'default_schedule_type' => $defaultType,
|
||||
'default_schedule_interval' => $defaultInterval,
|
||||
'default_schedule_time' => $defaultTime,
|
||||
'default_schedule_weekdays_csv' => $defaultWeekdays,
|
||||
'default_catchup_once' => (int) ((bool) $job['default_catchup_once']),
|
||||
'allowed_schedule_types' => $allowedTypes,
|
||||
];
|
||||
}
|
||||
|
||||
return $jobs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function normalizeAllowedScheduleTypes(mixed $value, string $moduleId, int $index): array
|
||||
{
|
||||
if (!is_array($value) || $value === []) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' scheduler_jobs[%d] allowed_schedule_types must be a non-empty array.", $moduleId, $index)
|
||||
);
|
||||
}
|
||||
|
||||
$types = [];
|
||||
foreach ($value as $rawType) {
|
||||
$type = strtolower(trim((string) $rawType));
|
||||
if (!in_array($type, ['hourly', 'daily', 'weekly'], true)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' scheduler_jobs[%d] allowed_schedule_types contains invalid type '%s'.", $moduleId, $index, $type)
|
||||
);
|
||||
}
|
||||
$types[] = $type;
|
||||
}
|
||||
|
||||
$types = array_values(array_unique($types));
|
||||
sort($types, SORT_STRING);
|
||||
return $types;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<array{class: class-string, method: string}>>
|
||||
*/
|
||||
private static function normalizeEventListeners(mixed $value, string $moduleId): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' manifest key 'event_listeners' must be an array.", $moduleId)
|
||||
);
|
||||
}
|
||||
|
||||
$listeners = [];
|
||||
foreach ($value as $eventName => $handlers) {
|
||||
$eventName = trim((string) $eventName);
|
||||
if ($eventName === '') {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' event_listeners has an empty event name key.", $moduleId)
|
||||
);
|
||||
}
|
||||
|
||||
if (!is_array($handlers)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' event_listeners['%s'] must be an array of handler descriptors.", $moduleId, $eventName)
|
||||
);
|
||||
}
|
||||
|
||||
$normalizedHandlers = [];
|
||||
foreach (array_values($handlers) as $index => $handler) {
|
||||
if (!is_array($handler)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' event_listeners['%s'][%d] must be an array with 'class' and 'method'.", $moduleId, $eventName, $index)
|
||||
);
|
||||
}
|
||||
|
||||
$class = trim((string) ($handler['class'] ?? ''));
|
||||
$method = trim((string) ($handler['method'] ?? ''));
|
||||
if ($class === '' || $method === '') {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' event_listeners['%s'][%d] must define non-empty 'class' and 'method'.", $moduleId, $eventName, $index)
|
||||
);
|
||||
}
|
||||
|
||||
$normalizedHandlers[] = ['class' => $class, 'method' => $method];
|
||||
}
|
||||
|
||||
if ($normalizedHandlers !== []) {
|
||||
$listeners[$eventName] = $normalizedHandlers;
|
||||
}
|
||||
}
|
||||
|
||||
return $listeners;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function normalizeRequires(mixed $value, string $moduleId): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' manifest key 'requires' must be an array.", $moduleId)
|
||||
);
|
||||
}
|
||||
|
||||
$requires = [];
|
||||
foreach (array_values($value) as $index => $item) {
|
||||
if (!is_string($item) || trim($item) === '') {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' requires[%d] must be a non-empty string.", $moduleId, $index)
|
||||
);
|
||||
}
|
||||
$requires[] = trim($item);
|
||||
}
|
||||
|
||||
return array_values(array_unique($requires));
|
||||
}
|
||||
|
||||
private static function normalizeWeekdaysCsv(mixed $value, string $moduleId, int $index): ?string
|
||||
{
|
||||
$raw = trim((string) $value);
|
||||
if ($raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$days = array_filter(array_map('trim', explode(',', $raw)), static fn (string $part): bool => $part !== '');
|
||||
if ($days === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalizedDays = [];
|
||||
foreach ($days as $day) {
|
||||
if (!preg_match('/^[1-7]$/', $day)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' scheduler_jobs[%d] has invalid weekday '%s' in default_schedule_weekdays_csv.", $moduleId, $index, $day)
|
||||
);
|
||||
}
|
||||
$normalizedDays[] = (int) $day;
|
||||
}
|
||||
|
||||
$normalizedDays = array_values(array_unique($normalizedDays));
|
||||
sort($normalizedDays, SORT_NUMERIC);
|
||||
return implode(',', array_map(static fn (int $day): string => (string) $day, $normalizedDays));
|
||||
}
|
||||
}
|
||||
44
core/App/Module/ModuleManifestLoader.php
Normal file
44
core/App/Module/ModuleManifestLoader.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Loads a single module manifest from disk without requiring the module to be enabled.
|
||||
*
|
||||
* Unlike ModuleRegistry (which only loads enabled modules), this loader can resolve
|
||||
* any module by its directory — used by validation and deactivation tooling.
|
||||
*/
|
||||
final class ModuleManifestLoader
|
||||
{
|
||||
/**
|
||||
* Load and validate a module manifest from its on-disk location.
|
||||
*
|
||||
* @param string $modulesDir Absolute path to the modules/ directory
|
||||
* @param string $moduleId Directory name / module ID
|
||||
*
|
||||
* @throws RuntimeException If the manifest file is missing or invalid
|
||||
*/
|
||||
public static function loadFromDisk(string $modulesDir, string $moduleId): ModuleManifest
|
||||
{
|
||||
$manifestFile = rtrim($modulesDir, '/') . '/' . $moduleId . '/module.php';
|
||||
|
||||
if (!is_file($manifestFile)) {
|
||||
throw new RuntimeException(
|
||||
"Module manifest not found at: {$manifestFile}"
|
||||
);
|
||||
}
|
||||
|
||||
$raw = require $manifestFile;
|
||||
if (!is_array($raw)) {
|
||||
throw new RuntimeException(
|
||||
"Module manifest at '{$manifestFile}' must return an array."
|
||||
);
|
||||
}
|
||||
|
||||
ModuleManifestSchemaValidator::assertValid($raw, $moduleId, $manifestFile);
|
||||
|
||||
return ModuleManifest::fromArray($raw, dirname($manifestFile));
|
||||
}
|
||||
}
|
||||
108
core/App/Module/ModuleManifestSchemaValidator.php
Normal file
108
core/App/Module/ModuleManifestSchemaValidator.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module;
|
||||
|
||||
use Opis\JsonSchema\Errors\ErrorFormatter;
|
||||
use Opis\JsonSchema\Errors\ValidationError;
|
||||
use Opis\JsonSchema\JsonPointer;
|
||||
use Opis\JsonSchema\Validator;
|
||||
use RuntimeException;
|
||||
|
||||
final class ModuleManifestSchemaValidator
|
||||
{
|
||||
private const DEFAULT_SCHEMA_PATH = '.agents/contracts/module-manifest.schema.json';
|
||||
|
||||
/** @var array<string, object> */
|
||||
private static array $schemaCache = [];
|
||||
|
||||
public static function assertValid(
|
||||
array $rawManifest,
|
||||
string $moduleId,
|
||||
?string $manifestFile = null,
|
||||
?string $schemaFile = null
|
||||
): void {
|
||||
$schemaPath = self::resolveSchemaPath($schemaFile);
|
||||
$schema = self::loadSchema($schemaPath);
|
||||
|
||||
$payload = json_decode((string) json_encode($rawManifest), false);
|
||||
if (!is_object($payload)) {
|
||||
throw new RuntimeException("Manifest schema validation failed for module '{$moduleId}': manifest payload is not serializable.");
|
||||
}
|
||||
|
||||
$validator = new Validator();
|
||||
$result = $validator->validate($payload, $schema);
|
||||
if ($result->isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$location = $manifestFile ?? ("modules/{$moduleId}/module.php");
|
||||
$message = self::formatValidationErrors($result->error());
|
||||
throw new RuntimeException(
|
||||
"Manifest schema validation failed for module '{$moduleId}' ({$location}): {$message}"
|
||||
);
|
||||
}
|
||||
|
||||
private static function resolveSchemaPath(?string $schemaFile): string
|
||||
{
|
||||
if ($schemaFile !== null && trim($schemaFile) !== '') {
|
||||
return $schemaFile;
|
||||
}
|
||||
|
||||
return dirname(__DIR__, 3) . '/' . self::DEFAULT_SCHEMA_PATH;
|
||||
}
|
||||
|
||||
private static function loadSchema(string $schemaPath): object
|
||||
{
|
||||
$normalizedPath = str_replace('\\', '/', $schemaPath);
|
||||
if (isset(self::$schemaCache[$normalizedPath])) {
|
||||
return self::$schemaCache[$normalizedPath];
|
||||
}
|
||||
|
||||
if (!is_file($schemaPath)) {
|
||||
throw new RuntimeException("Module manifest schema not found: {$schemaPath}");
|
||||
}
|
||||
|
||||
$rawSchema = file_get_contents($schemaPath);
|
||||
if ($rawSchema === false) {
|
||||
throw new RuntimeException("Unable to read module manifest schema: {$schemaPath}");
|
||||
}
|
||||
|
||||
$decodedSchema = json_decode($rawSchema);
|
||||
if (!is_object($decodedSchema)) {
|
||||
throw new RuntimeException("Invalid JSON schema in {$schemaPath}");
|
||||
}
|
||||
|
||||
self::$schemaCache[$normalizedPath] = $decodedSchema;
|
||||
return $decodedSchema;
|
||||
}
|
||||
|
||||
private static function formatValidationErrors(?ValidationError $error): string
|
||||
{
|
||||
if ($error === null) {
|
||||
return 'unknown schema validation error';
|
||||
}
|
||||
|
||||
$formatter = new ErrorFormatter();
|
||||
$messages = $formatter->formatFlat(
|
||||
$error,
|
||||
static function (ValidationError $validationError) use ($formatter): string {
|
||||
$path = JsonPointer::pathToString($validationError->data()->fullPath());
|
||||
$normalizedPath = $path === '' ? '/' : $path;
|
||||
$message = $formatter->formatErrorMessage($validationError);
|
||||
|
||||
return "{$normalizedPath}: {$message}";
|
||||
}
|
||||
);
|
||||
|
||||
$messages = array_values(array_unique(array_filter(
|
||||
$messages,
|
||||
static fn ($message): bool => is_string($message) && trim($message) !== ''
|
||||
)));
|
||||
|
||||
if ($messages === []) {
|
||||
return 'unknown schema validation error';
|
||||
}
|
||||
|
||||
return implode('; ', array_slice($messages, 0, 3));
|
||||
}
|
||||
}
|
||||
209
core/App/Module/ModulePermissionSynchronizer.php
Normal file
209
core/App/Module/ModulePermissionSynchronizer.php
Normal file
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module;
|
||||
|
||||
use MintyPHP\Repository\Access\PermissionRepositoryInterface;
|
||||
|
||||
/**
|
||||
* Synchronizes module-defined permissions into the permissions table.
|
||||
*/
|
||||
final class ModulePermissionSynchronizer
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ModuleRegistry $moduleRegistry,
|
||||
private readonly PermissionRepositoryInterface $permissionRepository,
|
||||
private readonly string $modulesDir = ''
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{created: int, updated: int, unchanged: int, deactivated: int, total: int}
|
||||
*/
|
||||
public function sync(): array
|
||||
{
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
$unchanged = 0;
|
||||
$total = 0;
|
||||
|
||||
foreach ($this->moduleRegistry->getPermissions() as $permissionDefinition) {
|
||||
$total++;
|
||||
$key = trim((string) $permissionDefinition['key']);
|
||||
if ($key === '') {
|
||||
throw new \RuntimeException('Module permission definition has empty key.');
|
||||
}
|
||||
|
||||
$desired = [
|
||||
'key' => $key,
|
||||
'description' => trim((string) $permissionDefinition['description']),
|
||||
'active' => (int) $permissionDefinition['active'],
|
||||
'is_system' => (int) $permissionDefinition['is_system'],
|
||||
];
|
||||
|
||||
$existing = $this->permissionRepository->findByKey($key);
|
||||
if (!is_array($existing)) {
|
||||
$createdId = $this->permissionRepository->create($desired);
|
||||
if ($createdId === null) {
|
||||
throw new \RuntimeException("Failed to create module permission '{$key}'.");
|
||||
}
|
||||
$created++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$existingId = (int) ($existing['id'] ?? 0);
|
||||
if ($existingId <= 0) {
|
||||
throw new \RuntimeException("Permission '{$key}' exists without a valid id.");
|
||||
}
|
||||
|
||||
if ($this->isPermissionEqual($existing, $desired)) {
|
||||
$unchanged++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$wasUpdated = $this->permissionRepository->update($existingId, $desired);
|
||||
if (!$wasUpdated) {
|
||||
throw new \RuntimeException("Failed to update module permission '{$key}' (id {$existingId}).");
|
||||
}
|
||||
$updated++;
|
||||
}
|
||||
|
||||
$deactivated = $this->deactivateOrphaned();
|
||||
|
||||
return [
|
||||
'created' => $created,
|
||||
'updated' => $updated,
|
||||
'unchanged' => $unchanged,
|
||||
'deactivated' => $deactivated,
|
||||
'total' => $total,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate module-owned permissions that no active module claims.
|
||||
*
|
||||
* Only touches permissions whose key is declared in at least one module
|
||||
* manifest on disk (enabled or not). Core seed permissions are never touched.
|
||||
* Setting active=0 is reversible — re-enabling the module and running sync
|
||||
* restores active=1.
|
||||
*/
|
||||
private function deactivateOrphaned(): int
|
||||
{
|
||||
$activeKeys = $this->moduleRegistry->getPermissionKeys();
|
||||
$allModuleKeys = $this->collectAllModulePermissionKeys();
|
||||
|
||||
// Nothing to deactivate if we can't determine module-owned keys
|
||||
if ($allModuleKeys === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$systemPermissions = $this->permissionRepository->listSystem();
|
||||
$deactivated = 0;
|
||||
|
||||
foreach ($systemPermissions as $permission) {
|
||||
$key = (string) $permission['key'];
|
||||
$id = (int) $permission['id'];
|
||||
$isActive = (int) $permission['active'];
|
||||
|
||||
if ($key === '' || $id <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Already inactive — nothing to do
|
||||
if ($isActive === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Still claimed by an active module — keep it
|
||||
if (in_array($key, $activeKeys, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Not a module-owned permission — never touch core seed permissions
|
||||
if (!in_array($key, $allModuleKeys, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Orphaned module permission: declared by a module on disk but not by any active module
|
||||
$this->permissionRepository->update($id, [
|
||||
'key' => $key,
|
||||
'description' => (string) $permission['description'],
|
||||
'active' => 0,
|
||||
'is_system' => 1,
|
||||
]);
|
||||
$deactivated++;
|
||||
}
|
||||
|
||||
return $deactivated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan all module manifests on disk (not just enabled) and collect their permission keys.
|
||||
* This determines which permissions are module-owned vs core seed data.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function collectAllModulePermissionKeys(): array
|
||||
{
|
||||
if ($this->modulesDir === '' || !is_dir($this->modulesDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$keys = [];
|
||||
$entries = scandir($this->modulesDir);
|
||||
if ($entries === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$manifestFile = $this->modulesDir . '/' . $entry . '/module.php';
|
||||
if (!is_file($manifestFile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$raw = require $manifestFile;
|
||||
if (!is_array($raw)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$permissions = $raw['permissions'] ?? [];
|
||||
if (!is_array($permissions)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($permissions as $permission) {
|
||||
if (is_array($permission)) {
|
||||
$key = trim((string) ($permission['key'] ?? ''));
|
||||
if ($key !== '') {
|
||||
$keys[] = $key;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Skip broken manifests — don't break the sync
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($keys));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $existing
|
||||
* @param array<string, mixed> $desired
|
||||
*/
|
||||
private function isPermissionEqual(array $existing, array $desired): bool
|
||||
{
|
||||
$existingDescription = trim((string) ($existing['description'] ?? ''));
|
||||
$existingActive = (int) ($existing['active'] ?? 0);
|
||||
$existingSystem = (int) ($existing['is_system'] ?? 0);
|
||||
|
||||
return $existingDescription === (string) ($desired['description'] ?? '')
|
||||
&& $existingActive === (int) ($desired['active'] ?? 0)
|
||||
&& $existingSystem === (int) ($desired['is_system'] ?? 0);
|
||||
}
|
||||
}
|
||||
701
core/App/Module/ModuleRegistry.php
Normal file
701
core/App/Module/ModuleRegistry.php
Normal file
@@ -0,0 +1,701 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module;
|
||||
|
||||
use Composer\Autoload\ClassLoader;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Loads, validates and merges module manifests.
|
||||
*
|
||||
* Modules are discovered from a configurable directory and activated via an
|
||||
* allow-list (config/modules.php + APP_ENABLED_MODULES env variable).
|
||||
*
|
||||
* The registry is immutable after construction — all contributions are merged
|
||||
* at boot time and available through typed getters.
|
||||
*/
|
||||
final class ModuleRegistry
|
||||
{
|
||||
/** @var array<string, ModuleManifest> keyed by module id */
|
||||
private array $modules = [];
|
||||
|
||||
/** @var array<int, array{path: string, target: string, module_id: string, public?: bool}> merged routes */
|
||||
private array $mergedRoutes = [];
|
||||
|
||||
/** @var list<string> merged public paths */
|
||||
private array $mergedPublicPaths = [];
|
||||
|
||||
/** @var list<class-string> merged container registrar FQCNs */
|
||||
private array $mergedContainerRegistrars = [];
|
||||
|
||||
/** @var array<string, list<mixed>> merged UI slots keyed by slot name */
|
||||
private array $mergedUiSlots = [];
|
||||
|
||||
/** @var list<class-string> merged search resource provider FQCNs */
|
||||
private array $mergedSearchResources = [];
|
||||
|
||||
/** @var array<string, list<string>> merged asset groups */
|
||||
private array $mergedAssetGroups = [];
|
||||
|
||||
/** @var list<class-string> merged layout context provider FQCNs */
|
||||
private array $mergedLayoutContextProviders = [];
|
||||
|
||||
/** @var list<class-string> merged session provider FQCNs */
|
||||
private array $mergedSessionProviders = [];
|
||||
|
||||
/**
|
||||
* @var list<array{
|
||||
* key: string,
|
||||
* description: string,
|
||||
* active: int,
|
||||
* is_system: int
|
||||
* }> merged permissions
|
||||
*/
|
||||
private array $mergedPermissions = [];
|
||||
|
||||
/** @var list<class-string> merged authorization policy FQCNs */
|
||||
private array $mergedAuthorizationPolicies = [];
|
||||
|
||||
/**
|
||||
* @var list<array{
|
||||
* job_key: string,
|
||||
* handler: string,
|
||||
* label: string,
|
||||
* description: string,
|
||||
* default_enabled: int,
|
||||
* default_timezone: string,
|
||||
* default_schedule_type: string,
|
||||
* default_schedule_interval: int,
|
||||
* default_schedule_time: ?string,
|
||||
* default_schedule_weekdays_csv: ?string,
|
||||
* default_catchup_once: int,
|
||||
* allowed_schedule_types: list<string>,
|
||||
* module_id: string
|
||||
* }>
|
||||
*/
|
||||
private array $mergedSchedulerJobs = [];
|
||||
|
||||
/** @var array<string, string> route target => owning module id */
|
||||
private array $routeTargetOwners = [];
|
||||
|
||||
/** @var array<string, string> route path => owning module id */
|
||||
private array $routePathOwners = [];
|
||||
|
||||
/** @var array<string, string> permission key => owning module id */
|
||||
private array $permissionOwners = [];
|
||||
|
||||
/** @var array<string, string> scheduler job key => owning module id */
|
||||
private array $schedulerJobOwners = [];
|
||||
|
||||
/** @var array<string, list<array{class: class-string, method: string}>> merged event listeners keyed by event name */
|
||||
private array $mergedEventListeners = [];
|
||||
|
||||
/**
|
||||
* Boot the registry from a modules directory and activation config.
|
||||
*
|
||||
* @param string $modulesDir Absolute path to the modules/ directory
|
||||
* @param list<string> $enabledModuleIds Module IDs to activate (from config + env)
|
||||
*/
|
||||
public static function boot(string $modulesDir, array $enabledModuleIds): self
|
||||
{
|
||||
$registry = new self();
|
||||
|
||||
if (!is_dir($modulesDir)) {
|
||||
return $registry;
|
||||
}
|
||||
|
||||
// Discover and load manifests for enabled modules
|
||||
$manifests = [];
|
||||
foreach ($enabledModuleIds as $moduleId) {
|
||||
$moduleId = trim($moduleId);
|
||||
if ($moduleId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$modulePath = rtrim($modulesDir, '/') . '/' . $moduleId;
|
||||
$manifestFile = $modulePath . '/module.php';
|
||||
|
||||
if (!is_file($manifestFile)) {
|
||||
throw new RuntimeException(
|
||||
"Module '{$moduleId}' is enabled but manifest not found at: {$manifestFile}"
|
||||
);
|
||||
}
|
||||
|
||||
$raw = require $manifestFile;
|
||||
if (!is_array($raw)) {
|
||||
throw new RuntimeException(
|
||||
"Module manifest at '{$manifestFile}' must return an array."
|
||||
);
|
||||
}
|
||||
|
||||
ModuleManifestSchemaValidator::assertValid($raw, $moduleId, $manifestFile);
|
||||
|
||||
$manifest = ModuleManifest::fromArray($raw, $modulePath);
|
||||
|
||||
if ($manifest->id !== $moduleId) {
|
||||
throw new RuntimeException(
|
||||
"Module directory '{$moduleId}' does not match manifest id '{$manifest->id}'."
|
||||
);
|
||||
}
|
||||
|
||||
$manifests[] = $manifest;
|
||||
}
|
||||
|
||||
// Sort deterministically: load_order ASC, then id ASC
|
||||
usort($manifests, static function (ModuleManifest $a, ModuleManifest $b): int {
|
||||
return $a->loadOrder <=> $b->loadOrder ?: strcmp($a->id, $b->id);
|
||||
});
|
||||
|
||||
// Register module lib dirs with Composer's PSR-4 autoloader so that
|
||||
// module classes (MintyPHP\Module\<Name>\*) resolve without a custom loader.
|
||||
self::registerModuleAutoloading($manifests);
|
||||
|
||||
// Preload core + module translations into I18n before any t() call.
|
||||
$coreI18nDir = rtrim($modulesDir, '/') . '/../i18n';
|
||||
if (is_dir($coreI18nDir)) {
|
||||
ModuleI18nLoader::preload($manifests, $coreI18nDir);
|
||||
}
|
||||
|
||||
// Register and merge
|
||||
foreach ($manifests as $manifest) {
|
||||
$registry->registerModule($manifest);
|
||||
}
|
||||
|
||||
$registry->validateDependencies();
|
||||
$registry->finalizeMergedContributions();
|
||||
|
||||
return $registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve enabled module IDs from config array + ENV override.
|
||||
*
|
||||
* @param array{enabled_modules?: list<string>} $config From config/modules.php
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function resolveEnabledModules(array $config): array
|
||||
{
|
||||
$fromConfig = $config['enabled_modules'] ?? [];
|
||||
$fromEnvRaw = getenv('APP_ENABLED_MODULES');
|
||||
|
||||
// Semantics:
|
||||
// - not set => use config/modules.php
|
||||
// - set '' => explicit "no modules"
|
||||
// - set list => exact list from env
|
||||
if ($fromEnvRaw !== false) {
|
||||
$fromEnv = trim((string) $fromEnvRaw);
|
||||
$fromConfig = $fromEnv === ''
|
||||
? []
|
||||
: array_map('trim', explode(',', $fromEnv));
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter($fromConfig, static fn (string $id): bool => trim($id) !== '')));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register module lib/ directories with Composer's ClassLoader.
|
||||
*
|
||||
* @param list<ModuleManifest> $manifests
|
||||
*/
|
||||
private static function registerModuleAutoloading(array $manifests): void
|
||||
{
|
||||
$composerLoader = null;
|
||||
foreach (spl_autoload_functions() as $autoloader) {
|
||||
if (is_array($autoloader) && $autoloader[0] instanceof ClassLoader) {
|
||||
$composerLoader = $autoloader[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($composerLoader === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($manifests as $manifest) {
|
||||
$libDir = rtrim($manifest->basePath, '/') . '/lib';
|
||||
if (is_dir($libDir)) {
|
||||
$composerLoader->addPsr4('MintyPHP\\', [$libDir]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function registerModule(ModuleManifest $manifest): void
|
||||
{
|
||||
if (isset($this->modules[$manifest->id])) {
|
||||
throw new RuntimeException(
|
||||
"Duplicate module id: '{$manifest->id}' is already registered."
|
||||
);
|
||||
}
|
||||
|
||||
$this->modules[$manifest->id] = $manifest;
|
||||
|
||||
// — Merge routes (fail-fast on collision) ——————————————
|
||||
foreach ($manifest->routes as $rawRoute) {
|
||||
$route = $this->normalizeRoute($rawRoute, $manifest->id);
|
||||
$path = $route['path'];
|
||||
$target = $route['target'];
|
||||
if (isset($this->routeTargetOwners[$target]) && $this->routeTargetOwners[$target] !== $manifest->id) {
|
||||
$owner = $this->routeTargetOwners[$target];
|
||||
throw new RuntimeException(
|
||||
"Route target conflict: '{$target}' from module '{$manifest->id}' collides with module '{$owner}'."
|
||||
);
|
||||
}
|
||||
if (isset($this->routePathOwners[$path])) {
|
||||
$owner = $this->routePathOwners[$path];
|
||||
throw new RuntimeException(
|
||||
"Route path conflict: '{$path}' from module '{$manifest->id}' collides with module '{$owner}'."
|
||||
);
|
||||
}
|
||||
$this->routeTargetOwners[$target] = $manifest->id;
|
||||
$this->routePathOwners[$path] = $manifest->id;
|
||||
$this->mergedRoutes[] = $route;
|
||||
if (!empty($route['public'])) {
|
||||
$this->mergedPublicPaths[] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
// — Merge public paths ———————————————————————————
|
||||
array_push($this->mergedPublicPaths, ...$manifest->publicPaths);
|
||||
|
||||
// — Merge container registrars ————————————————————
|
||||
array_push($this->mergedContainerRegistrars, ...$manifest->containerRegistrars);
|
||||
|
||||
// — Merge UI slots (fail-fast on duplicate keys within same slot) ——
|
||||
foreach ($manifest->uiSlots as $slotName => $contributions) {
|
||||
if (!isset($this->mergedUiSlots[$slotName])) {
|
||||
$this->mergedUiSlots[$slotName] = [];
|
||||
}
|
||||
$contributionList = is_array($contributions) ? $contributions : [$contributions];
|
||||
foreach ($contributionList as $contribution) {
|
||||
$normalizedContribution = $this->normalizeUiSlotContribution($slotName, $contribution, $manifest->id, $manifest->basePath);
|
||||
$key = (string) $normalizedContribution['key'];
|
||||
foreach ($this->mergedUiSlots[$slotName] as $existing) {
|
||||
$existingKey = is_array($existing) ? ($existing['key'] ?? null) : null;
|
||||
if ($existingKey === $key) {
|
||||
throw new RuntimeException(
|
||||
"UI slot conflict: slot '{$slotName}' key '{$key}' from module '{$manifest->id}' already exists."
|
||||
);
|
||||
}
|
||||
}
|
||||
$this->mergedUiSlots[$slotName][] = $normalizedContribution;
|
||||
}
|
||||
}
|
||||
|
||||
// — Merge search resources (fail-fast on duplicate provider) ——
|
||||
foreach ($manifest->searchResources as $provider) {
|
||||
if (!is_string($provider) || trim($provider) === '') {
|
||||
throw new RuntimeException(
|
||||
"Search resource provider in module '{$manifest->id}' must be a non-empty class-string."
|
||||
);
|
||||
}
|
||||
if (in_array($provider, $this->mergedSearchResources, true)) {
|
||||
throw new RuntimeException(
|
||||
"Search resource conflict: provider '{$provider}' from module '{$manifest->id}' is already registered."
|
||||
);
|
||||
}
|
||||
$this->mergedSearchResources[] = $provider;
|
||||
}
|
||||
|
||||
// — Merge asset groups ————————————————————————————
|
||||
foreach ($manifest->assetGroups as $groupName => $files) {
|
||||
if (isset($this->mergedAssetGroups[$groupName])) {
|
||||
throw new RuntimeException(
|
||||
"Asset group conflict: group '{$groupName}' from module '{$manifest->id}' already exists."
|
||||
);
|
||||
}
|
||||
$this->mergedAssetGroups[$groupName] = $files;
|
||||
}
|
||||
|
||||
// — Merge permissions (fail-fast on duplicate) ———————
|
||||
foreach ($manifest->permissions as $permission) {
|
||||
$permissionKey = trim((string) $permission['key']);
|
||||
if ($permissionKey === '') {
|
||||
throw new RuntimeException(
|
||||
"Permission in module '{$manifest->id}' must define non-empty 'key'."
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($this->permissionOwners[$permissionKey])) {
|
||||
$owner = $this->permissionOwners[$permissionKey];
|
||||
throw new RuntimeException(
|
||||
"Permission conflict: '{$permissionKey}' from module '{$manifest->id}' is already registered by module '{$owner}'."
|
||||
);
|
||||
}
|
||||
|
||||
$this->permissionOwners[$permissionKey] = $manifest->id;
|
||||
$this->mergedPermissions[] = [
|
||||
'key' => $permissionKey,
|
||||
'description' => trim((string) $permission['description']),
|
||||
'active' => (int) $permission['active'],
|
||||
'is_system' => (int) $permission['is_system'],
|
||||
];
|
||||
}
|
||||
|
||||
// — Merge layout context providers ————————————————
|
||||
foreach ($manifest->layoutContextProviders as $providerClass) {
|
||||
if (!is_string($providerClass) || trim($providerClass) === '') {
|
||||
throw new RuntimeException(
|
||||
"Layout context provider in module '{$manifest->id}' must be a non-empty class-string."
|
||||
);
|
||||
}
|
||||
$this->mergedLayoutContextProviders[] = $providerClass;
|
||||
}
|
||||
|
||||
// — Merge session providers ——————————————————————
|
||||
foreach ($manifest->sessionProviders as $providerClass) {
|
||||
if (!is_string($providerClass) || trim($providerClass) === '') {
|
||||
throw new RuntimeException(
|
||||
"Session provider in module '{$manifest->id}' must be a non-empty class-string."
|
||||
);
|
||||
}
|
||||
$this->mergedSessionProviders[] = $providerClass;
|
||||
}
|
||||
|
||||
// — Merge authorization policies (fail-fast on duplicate) ———
|
||||
foreach ($manifest->authorizationPolicies as $policyClass) {
|
||||
if (!is_string($policyClass) || trim($policyClass) === '') {
|
||||
throw new RuntimeException(
|
||||
"Authorization policy in module '{$manifest->id}' must be a non-empty class-string."
|
||||
);
|
||||
}
|
||||
if (in_array($policyClass, $this->mergedAuthorizationPolicies, true)) {
|
||||
throw new RuntimeException(
|
||||
"Authorization policy conflict: '{$policyClass}' from module '{$manifest->id}' is already registered."
|
||||
);
|
||||
}
|
||||
$this->mergedAuthorizationPolicies[] = $policyClass;
|
||||
}
|
||||
|
||||
// — Merge event listeners ——————————————————————————
|
||||
foreach ($manifest->eventListeners as $eventName => $handlers) {
|
||||
if (!isset($this->mergedEventListeners[$eventName])) {
|
||||
$this->mergedEventListeners[$eventName] = [];
|
||||
}
|
||||
array_push($this->mergedEventListeners[$eventName], ...$handlers);
|
||||
}
|
||||
|
||||
// — Merge scheduler jobs —————————————————————————
|
||||
foreach ($manifest->schedulerJobs as $schedulerJob) {
|
||||
$jobKey = trim((string) $schedulerJob['job_key']);
|
||||
if ($jobKey === '') {
|
||||
throw new RuntimeException(
|
||||
"Scheduler job in module '{$manifest->id}' must define non-empty 'job_key'."
|
||||
);
|
||||
}
|
||||
if (isset($this->schedulerJobOwners[$jobKey])) {
|
||||
$owner = $this->schedulerJobOwners[$jobKey];
|
||||
throw new RuntimeException(
|
||||
"Scheduler job conflict: '{$jobKey}' from module '{$manifest->id}' is already registered by module '{$owner}'."
|
||||
);
|
||||
}
|
||||
|
||||
$this->schedulerJobOwners[$jobKey] = $manifest->id;
|
||||
$this->mergedSchedulerJobs[] = [
|
||||
'job_key' => $jobKey,
|
||||
'handler' => trim((string) $schedulerJob['handler']),
|
||||
'label' => trim((string) $schedulerJob['label']),
|
||||
'description' => trim((string) $schedulerJob['description']),
|
||||
'default_enabled' => (int) $schedulerJob['default_enabled'],
|
||||
'default_timezone' => trim((string) $schedulerJob['default_timezone']),
|
||||
'default_schedule_type' => trim((string) $schedulerJob['default_schedule_type']),
|
||||
'default_schedule_interval' => (int) $schedulerJob['default_schedule_interval'],
|
||||
'default_schedule_time' => $schedulerJob['default_schedule_time'],
|
||||
'default_schedule_weekdays_csv' => $schedulerJob['default_schedule_weekdays_csv'],
|
||||
'default_catchup_once' => (int) $schedulerJob['default_catchup_once'],
|
||||
'allowed_schedule_types' => array_values($schedulerJob['allowed_schedule_types']),
|
||||
'module_id' => $manifest->id,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function validateDependencies(): void
|
||||
{
|
||||
$enabledIds = array_keys($this->modules);
|
||||
|
||||
// Check all required modules are enabled
|
||||
foreach ($this->modules as $manifest) {
|
||||
foreach ($manifest->requires as $requiredId) {
|
||||
if (!isset($this->modules[$requiredId])) {
|
||||
throw new RuntimeException(
|
||||
"Module '{$manifest->id}' requires module '{$requiredId}' which is not enabled."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for circular dependencies (DFS cycle detection)
|
||||
$visited = [];
|
||||
$inStack = [];
|
||||
|
||||
$visit = function (string $moduleId) use (&$visit, &$visited, &$inStack): void {
|
||||
if (isset($inStack[$moduleId])) {
|
||||
throw new RuntimeException(
|
||||
"Circular module dependency detected involving module '{$moduleId}'."
|
||||
);
|
||||
}
|
||||
if (isset($visited[$moduleId])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$inStack[$moduleId] = true;
|
||||
|
||||
if (isset($this->modules[$moduleId])) {
|
||||
foreach ($this->modules[$moduleId]->requires as $requiredId) {
|
||||
$visit($requiredId);
|
||||
}
|
||||
}
|
||||
|
||||
unset($inStack[$moduleId]);
|
||||
$visited[$moduleId] = true;
|
||||
};
|
||||
|
||||
foreach ($enabledIds as $moduleId) {
|
||||
$visit($moduleId);
|
||||
}
|
||||
}
|
||||
|
||||
private function finalizeMergedContributions(): void
|
||||
{
|
||||
$this->mergedPublicPaths = array_values(array_unique(array_filter(
|
||||
$this->mergedPublicPaths,
|
||||
static fn (mixed $path): bool => trim((string) $path) !== ''
|
||||
)));
|
||||
|
||||
foreach ($this->mergedUiSlots as $slotName => $items) {
|
||||
usort($items, static function (array $a, array $b): int {
|
||||
$orderCmp = ((int) ($a['order'] ?? 100)) <=> ((int) ($b['order'] ?? 100));
|
||||
if ($orderCmp !== 0) {
|
||||
return $orderCmp;
|
||||
}
|
||||
$moduleCmp = strcmp((string) ($a['__module_id'] ?? ''), (string) ($b['__module_id'] ?? ''));
|
||||
if ($moduleCmp !== 0) {
|
||||
return $moduleCmp;
|
||||
}
|
||||
return strcmp((string) ($a['key'] ?? ''), (string) ($b['key'] ?? ''));
|
||||
});
|
||||
|
||||
foreach ($items as &$item) {
|
||||
unset($item['__module_id']);
|
||||
}
|
||||
unset($item);
|
||||
|
||||
$this->mergedUiSlots[$slotName] = $items;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{path: string, target: string, module_id: string, public?: bool}
|
||||
*/
|
||||
private function normalizeRoute(mixed $rawRoute, string $moduleId): array
|
||||
{
|
||||
if (!is_array($rawRoute)) {
|
||||
throw new RuntimeException("Route entries in module '{$moduleId}' must be arrays.");
|
||||
}
|
||||
|
||||
$path = trim((string) ($rawRoute['path'] ?? ''));
|
||||
$target = trim((string) ($rawRoute['target'] ?? ''));
|
||||
if ($path === '' || $target === '') {
|
||||
throw new RuntimeException("Routes in module '{$moduleId}' must define non-empty 'path' and 'target'.");
|
||||
}
|
||||
|
||||
$normalized = [
|
||||
'path' => $path,
|
||||
'target' => $target,
|
||||
'module_id' => $moduleId,
|
||||
];
|
||||
|
||||
if (array_key_exists('public', $rawRoute)) {
|
||||
$normalized['public'] = (bool) $rawRoute['public'];
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function normalizeUiSlotContribution(string $slotName, mixed $rawContribution, string $moduleId, string $basePath): array
|
||||
{
|
||||
if (!is_array($rawContribution)) {
|
||||
throw new RuntimeException(
|
||||
"UI slot contribution for slot '{$slotName}' in module '{$moduleId}' must be an array."
|
||||
);
|
||||
}
|
||||
|
||||
$key = trim((string) ($rawContribution['key'] ?? ''));
|
||||
if ($key === '') {
|
||||
throw new RuntimeException(
|
||||
"UI slot contribution for slot '{$slotName}' in module '{$moduleId}' must define a non-empty 'key'."
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve relative template paths to absolute using the module's basePath.
|
||||
foreach (['panel_template', 'template'] as $templateKey) {
|
||||
if (!isset($rawContribution[$templateKey])) {
|
||||
continue;
|
||||
}
|
||||
$templatePath = trim((string) $rawContribution[$templateKey]);
|
||||
if ($templatePath !== '' && !str_starts_with($templatePath, '/')) {
|
||||
$rawContribution[$templateKey] = rtrim($basePath, '/') . '/' . $templatePath;
|
||||
}
|
||||
}
|
||||
|
||||
$normalized = $rawContribution;
|
||||
$normalized['key'] = $key;
|
||||
$normalized['order'] = (int) ($rawContribution['order'] ?? 100);
|
||||
$normalized['__module_id'] = $moduleId;
|
||||
$normalized['module_id'] = $moduleId;
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
// ─── Getters ────────────────────────────────────────────────────────
|
||||
|
||||
/** @return array<string, ModuleManifest> */
|
||||
public function getModules(): array
|
||||
{
|
||||
return $this->modules;
|
||||
}
|
||||
|
||||
public function hasModule(string $id): bool
|
||||
{
|
||||
return isset($this->modules[$id]);
|
||||
}
|
||||
|
||||
public function getModule(string $id): ModuleManifest
|
||||
{
|
||||
if (!isset($this->modules[$id])) {
|
||||
throw new RuntimeException("Module '{$id}' is not registered.");
|
||||
}
|
||||
return $this->modules[$id];
|
||||
}
|
||||
|
||||
/** @return array<int, array{path: string, target: string, module_id: string, public?: bool}> */
|
||||
public function getRoutes(): array
|
||||
{
|
||||
return $this->mergedRoutes;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function getPublicPaths(): array
|
||||
{
|
||||
return $this->mergedPublicPaths;
|
||||
}
|
||||
|
||||
/** @return list<class-string> */
|
||||
public function getContainerRegistrars(): array
|
||||
{
|
||||
return $this->mergedContainerRegistrars;
|
||||
}
|
||||
|
||||
/** @return array<string, list<mixed>> */
|
||||
public function getUiSlots(): array
|
||||
{
|
||||
return $this->mergedUiSlots;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<mixed> Contributions for a specific slot
|
||||
*/
|
||||
public function getSlotContributions(string $slotName): array
|
||||
{
|
||||
return $this->mergedUiSlots[$slotName] ?? [];
|
||||
}
|
||||
|
||||
/** @return list<class-string> */
|
||||
public function getSearchResources(): array
|
||||
{
|
||||
return $this->mergedSearchResources;
|
||||
}
|
||||
|
||||
/** @return array<string, list<string>> */
|
||||
public function getAssetGroups(): array
|
||||
{
|
||||
return $this->mergedAssetGroups;
|
||||
}
|
||||
|
||||
/** @return list<class-string> */
|
||||
public function getLayoutContextProviders(): array
|
||||
{
|
||||
return $this->mergedLayoutContextProviders;
|
||||
}
|
||||
|
||||
/** @return list<class-string> */
|
||||
public function getSessionProviders(): array
|
||||
{
|
||||
return $this->mergedSessionProviders;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{key: string, description: string, active: int, is_system: int}>
|
||||
*/
|
||||
public function getPermissions(): array
|
||||
{
|
||||
return $this->mergedPermissions;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function getPermissionKeys(): array
|
||||
{
|
||||
return array_values(array_map(
|
||||
static fn (array $permission): string => (string) $permission['key'],
|
||||
$this->mergedPermissions
|
||||
));
|
||||
}
|
||||
|
||||
/** @return list<class-string> */
|
||||
public function getAuthorizationPolicies(): array
|
||||
{
|
||||
return $this->mergedAuthorizationPolicies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect unique ability strings from all module UI slot contributions.
|
||||
*
|
||||
* Used by UiAccessService to resolve module abilities into the layout auth map.
|
||||
* Each ability maps to itself (key = ability, value = ability).
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getModuleUiAbilities(): array
|
||||
{
|
||||
$abilities = [];
|
||||
foreach ($this->mergedUiSlots as $items) {
|
||||
foreach ($items as $item) {
|
||||
$ability = trim((string) ($item['permission'] ?? ''));
|
||||
if ($ability !== '') {
|
||||
$abilities[$ability] = $ability;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $abilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{
|
||||
* job_key: string,
|
||||
* handler: string,
|
||||
* label: string,
|
||||
* description: string,
|
||||
* default_enabled: int,
|
||||
* default_timezone: string,
|
||||
* default_schedule_type: string,
|
||||
* default_schedule_interval: int,
|
||||
* default_schedule_time: ?string,
|
||||
* default_schedule_weekdays_csv: ?string,
|
||||
* default_catchup_once: int,
|
||||
* allowed_schedule_types: list<string>,
|
||||
* module_id: string
|
||||
* }>
|
||||
*/
|
||||
public function getSchedulerJobs(): array
|
||||
{
|
||||
return $this->mergedSchedulerJobs;
|
||||
}
|
||||
|
||||
/** @return array<string, list<array{class: class-string, method: string}>> */
|
||||
public function getEventListeners(): array
|
||||
{
|
||||
return $this->mergedEventListeners;
|
||||
}
|
||||
}
|
||||
178
core/App/Module/ModuleRuntimeAssetPublisher.php
Normal file
178
core/App/Module/ModuleRuntimeAssetPublisher.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Publishes module web assets to a runtime web/modules directory.
|
||||
*/
|
||||
final class ModuleRuntimeAssetPublisher
|
||||
{
|
||||
/**
|
||||
* @param array<string, ModuleManifest> $modules
|
||||
* @return array{created: int, updated: int, removed: int, unchanged: int}
|
||||
*/
|
||||
public function publish(string $runtimeModulesDir, array $modules, string $mode = 'symlink'): array
|
||||
{
|
||||
$normalizedMode = strtolower(trim($mode));
|
||||
if ($normalizedMode === '') {
|
||||
$normalizedMode = 'symlink';
|
||||
}
|
||||
if (!in_array($normalizedMode, ['symlink', 'copy'], true)) {
|
||||
throw new RuntimeException("Unsupported module asset publish mode '{$mode}'. Use 'symlink' or 'copy'.");
|
||||
}
|
||||
|
||||
if (is_link($runtimeModulesDir) && !unlink($runtimeModulesDir)) {
|
||||
throw new RuntimeException("Cannot remove existing runtime modules symlink: {$runtimeModulesDir}");
|
||||
}
|
||||
if (!is_dir($runtimeModulesDir) && !mkdir($runtimeModulesDir, 0775, true) && !is_dir($runtimeModulesDir)) {
|
||||
throw new RuntimeException("Cannot create runtime modules directory: {$runtimeModulesDir}");
|
||||
}
|
||||
|
||||
$result = ['created' => 0, 'updated' => 0, 'removed' => 0, 'unchanged' => 0];
|
||||
|
||||
$desired = [];
|
||||
foreach ($modules as $manifest) {
|
||||
if (!$manifest instanceof ModuleManifest) {
|
||||
continue;
|
||||
}
|
||||
$moduleWebDir = rtrim($manifest->basePath, '/') . '/web';
|
||||
if (!is_dir($moduleWebDir)) {
|
||||
continue;
|
||||
}
|
||||
$desired[$manifest->id] = $moduleWebDir;
|
||||
}
|
||||
|
||||
$entries = scandir($runtimeModulesDir);
|
||||
if ($entries === false) {
|
||||
throw new RuntimeException("Cannot scan runtime modules directory: {$runtimeModulesDir}");
|
||||
}
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
if (isset($desired[$entry])) {
|
||||
continue;
|
||||
}
|
||||
$path = $runtimeModulesDir . '/' . $entry;
|
||||
$this->removePath($path);
|
||||
$result['removed']++;
|
||||
}
|
||||
|
||||
foreach ($desired as $moduleId => $moduleWebDir) {
|
||||
$targetPath = $runtimeModulesDir . '/' . $moduleId;
|
||||
$targetExists = file_exists($targetPath) || is_link($targetPath);
|
||||
|
||||
if ($targetExists && $normalizedMode === 'symlink' && is_link($targetPath)) {
|
||||
$targetReal = realpath($targetPath);
|
||||
$sourceReal = realpath($moduleWebDir);
|
||||
if ($targetReal !== false && $sourceReal !== false && $targetReal === $sourceReal) {
|
||||
$result['unchanged']++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($targetExists) {
|
||||
$this->removePath($targetPath);
|
||||
}
|
||||
|
||||
if ($normalizedMode === 'symlink') {
|
||||
if (!@symlink($moduleWebDir, $targetPath)) {
|
||||
throw new RuntimeException(
|
||||
"Failed to symlink module assets for '{$moduleId}' ({$moduleWebDir} -> {$targetPath}). " .
|
||||
"Set APP_MODULE_ASSET_MODE=copy if symlinks are unavailable."
|
||||
);
|
||||
}
|
||||
} else {
|
||||
$this->copyDirectory($moduleWebDir, $targetPath);
|
||||
}
|
||||
|
||||
if ($targetExists) {
|
||||
$result['updated']++;
|
||||
} else {
|
||||
$result['created']++;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function removePath(string $path): void
|
||||
{
|
||||
if (is_link($path)) {
|
||||
if (!unlink($path)) {
|
||||
throw new RuntimeException("Cannot remove symlink: {$path}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_file($path)) {
|
||||
if (!unlink($path)) {
|
||||
throw new RuntimeException("Cannot remove file: {$path}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_dir($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entries = scandir($path);
|
||||
if ($entries === false) {
|
||||
throw new RuntimeException("Cannot scan directory: {$path}");
|
||||
}
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
$this->removePath($path . '/' . $entry);
|
||||
}
|
||||
|
||||
if (!rmdir($path)) {
|
||||
throw new RuntimeException("Cannot remove directory: {$path}");
|
||||
}
|
||||
}
|
||||
|
||||
private function copyDirectory(string $sourceDir, string $targetDir): void
|
||||
{
|
||||
if (!is_dir($sourceDir)) {
|
||||
throw new RuntimeException("Source module asset directory not found: {$sourceDir}");
|
||||
}
|
||||
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true) && !is_dir($targetDir)) {
|
||||
throw new RuntimeException("Cannot create target module asset directory: {$targetDir}");
|
||||
}
|
||||
|
||||
$entries = scandir($sourceDir);
|
||||
if ($entries === false) {
|
||||
throw new RuntimeException("Cannot scan source module asset directory: {$sourceDir}");
|
||||
}
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$source = $sourceDir . '/' . $entry;
|
||||
$target = $targetDir . '/' . $entry;
|
||||
|
||||
if (is_link($source)) {
|
||||
$linkTarget = readlink($source);
|
||||
if ($linkTarget === false || !symlink($linkTarget, $target)) {
|
||||
throw new RuntimeException("Cannot copy symlink '{$source}' to '{$target}'");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (is_dir($source)) {
|
||||
$this->copyDirectory($source, $target);
|
||||
continue;
|
||||
}
|
||||
if (!is_file($source)) {
|
||||
continue;
|
||||
}
|
||||
if (!copy($source, $target)) {
|
||||
throw new RuntimeException("Cannot copy file '{$source}' to '{$target}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
302
core/App/Module/ModuleRuntimePageBuilder.php
Normal file
302
core/App/Module/ModuleRuntimePageBuilder.php
Normal file
@@ -0,0 +1,302 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module;
|
||||
|
||||
use FilesystemIterator;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Builds a runtime PageRoot by symlinking core and enabled module pages.
|
||||
*
|
||||
* Build uses atomic staging+swap: output is constructed in a temporary staging
|
||||
* directory alongside the live target and only swapped into place after
|
||||
* successful validation. Partial failures cannot leave inconsistent live state.
|
||||
*/
|
||||
final class ModuleRuntimePageBuilder
|
||||
{
|
||||
/**
|
||||
* @param array<string, ModuleManifest> $modules
|
||||
* @return array{core_entries: int, module_entries: int}
|
||||
*/
|
||||
public function build(string $runtimeDir, string $corePagesDir, array $modules): array
|
||||
{
|
||||
$corePages = realpath($corePagesDir);
|
||||
if ($corePages === false) {
|
||||
throw new RuntimeException('Core pages/ directory not found.');
|
||||
}
|
||||
|
||||
if (count($modules) === 0) {
|
||||
$this->pointRuntimeToCore($runtimeDir, $corePages);
|
||||
return ['core_entries' => 0, 'module_entries' => 0];
|
||||
}
|
||||
|
||||
// ── Staging: build in a sibling directory, swap on success ────
|
||||
$stagingDir = dirname($runtimeDir) . '/.pages-staging-' . getmypid();
|
||||
$this->ensureCleanDirectory($stagingDir);
|
||||
|
||||
try {
|
||||
$result = $this->populateDirectory($stagingDir, $corePages, $modules);
|
||||
} catch (\Throwable $e) {
|
||||
$this->removeDirectory($stagingDir);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// ── Atomic swap ──────────────────────────────────────────────
|
||||
$this->swapIntoLive($stagingDir, $runtimeDir);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function pointRuntimeToCore(string $runtimeDir, string $corePages): void
|
||||
{
|
||||
if (is_dir($runtimeDir) && !is_link($runtimeDir)) {
|
||||
$this->removeDirectoryContents($runtimeDir);
|
||||
rmdir($runtimeDir);
|
||||
}
|
||||
if (is_link($runtimeDir)) {
|
||||
unlink($runtimeDir);
|
||||
}
|
||||
if (!@symlink($corePages, $runtimeDir)) {
|
||||
throw new RuntimeException("Failed to create symlink: {$runtimeDir} → {$corePages}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fingerprint: detect stale runtime state ─────────────────────
|
||||
|
||||
/**
|
||||
* Compute the expected fingerprint for a set of modules.
|
||||
*
|
||||
* The digest includes module IDs, each manifest file content hash,
|
||||
* and top-level page-entry signatures so that manifest-only or
|
||||
* page-structure changes are detected even when the module ID list
|
||||
* is unchanged.
|
||||
*
|
||||
* @param array<string, ModuleManifest> $modules
|
||||
*/
|
||||
public static function expectedFingerprint(array $modules): string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
$manifests = $modules;
|
||||
uasort($manifests, static fn (ModuleManifest $a, ModuleManifest $b): int => $a->id <=> $b->id);
|
||||
|
||||
foreach ($manifests as $manifest) {
|
||||
$manifestFile = $manifest->basePath . '/module.php';
|
||||
$manifestHash = is_file($manifestFile)
|
||||
? md5_file($manifestFile)
|
||||
: 'missing';
|
||||
|
||||
$pageEntries = self::topLevelPageEntries($manifest->basePath . '/pages');
|
||||
|
||||
$parts[] = $manifest->id . ':' . $manifestHash . ':' . implode(',', $pageEntries);
|
||||
}
|
||||
|
||||
if ($parts === []) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return hash('sha256', implode('|', $parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the persisted fingerprint from disk.
|
||||
*/
|
||||
public static function readFingerprint(string $runtimeDir): string
|
||||
{
|
||||
$file = dirname($runtimeDir) . '/.module-fingerprint';
|
||||
|
||||
return is_file($file) ? trim((string) file_get_contents($file)) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a fingerprint after a successful build so web/index.php can
|
||||
* detect stale runtime state without rebuilding on every request.
|
||||
*
|
||||
* @param array<string, ModuleManifest> $modules
|
||||
*/
|
||||
public static function writeFingerprint(string $runtimeDir, array $modules): void
|
||||
{
|
||||
$file = dirname($runtimeDir) . '/.module-fingerprint';
|
||||
|
||||
file_put_contents($file, self::expectedFingerprint($modules));
|
||||
}
|
||||
|
||||
// ── Internal helpers ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Populate a directory with core + module page symlinks.
|
||||
*
|
||||
* @param array<string, ModuleManifest> $modules
|
||||
* @return array{core_entries: int, module_entries: int}
|
||||
*/
|
||||
private function populateDirectory(string $targetDir, string $corePages, array $modules): array
|
||||
{
|
||||
$coreEntries = scandir($corePages);
|
||||
if ($coreEntries === false) {
|
||||
throw new RuntimeException('Cannot scan core pages directory.');
|
||||
}
|
||||
|
||||
$coreCount = 0;
|
||||
foreach ($coreEntries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
$source = $corePages . '/' . $entry;
|
||||
$target = $targetDir . '/' . $entry;
|
||||
if (!@symlink($source, $target)) {
|
||||
throw new RuntimeException("Failed to create symlink: {$target} → {$source}");
|
||||
}
|
||||
$coreCount++;
|
||||
}
|
||||
|
||||
$modulePageCount = 0;
|
||||
foreach ($modules as $manifest) {
|
||||
$modulePagesDir = $manifest->basePath . '/pages';
|
||||
if (!is_dir($modulePagesDir)) {
|
||||
continue;
|
||||
}
|
||||
$entries = scandir($modulePagesDir);
|
||||
if ($entries === false) {
|
||||
continue;
|
||||
}
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
$source = $modulePagesDir . '/' . $entry;
|
||||
$target = $targetDir . '/' . $entry;
|
||||
if (file_exists($target) || is_link($target)) {
|
||||
throw new RuntimeException(
|
||||
"Page path collision: '{$entry}' from module '{$manifest->id}' conflicts with existing page."
|
||||
);
|
||||
}
|
||||
if (!@symlink($source, $target)) {
|
||||
throw new RuntimeException("Failed to create symlink: {$target} → {$source}");
|
||||
}
|
||||
$modulePageCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'core_entries' => $coreCount,
|
||||
'module_entries' => $modulePageCount,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap staging directory into the live runtime path.
|
||||
*
|
||||
* Uses rename() for an atomic filesystem operation (same filesystem).
|
||||
*/
|
||||
private function swapIntoLive(string $stagingDir, string $runtimeDir): void
|
||||
{
|
||||
// Remove existing live target
|
||||
$oldDir = null;
|
||||
if (is_link($runtimeDir)) {
|
||||
unlink($runtimeDir);
|
||||
} elseif (is_dir($runtimeDir)) {
|
||||
// Move old live dir aside, then remove after swap
|
||||
$oldDir = $runtimeDir . '.old-' . getmypid();
|
||||
if (!@rename($runtimeDir, $oldDir)) {
|
||||
$this->removeDirectoryContents($runtimeDir);
|
||||
rmdir($runtimeDir);
|
||||
$oldDir = null;
|
||||
}
|
||||
} else {
|
||||
$oldDir = null;
|
||||
}
|
||||
|
||||
// Atomic rename of staging → live
|
||||
if (!@rename($stagingDir, $runtimeDir)) {
|
||||
throw new RuntimeException(
|
||||
"Failed to swap staging directory into live runtime path: {$stagingDir} → {$runtimeDir}"
|
||||
);
|
||||
}
|
||||
|
||||
// Clean up old directory if we moved it aside
|
||||
if ($oldDir !== null && (is_dir($oldDir) || is_link($oldDir))) {
|
||||
$this->removeDirectory($oldDir);
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureCleanDirectory(string $dir): void
|
||||
{
|
||||
if (is_dir($dir)) {
|
||||
$this->removeDirectoryContents($dir);
|
||||
rmdir($dir);
|
||||
}
|
||||
if (is_link($dir)) {
|
||||
unlink($dir);
|
||||
}
|
||||
if (!mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||
throw new RuntimeException('Cannot create staging directory: ' . $dir);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return sorted top-level entry names from a module pages directory.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function topLevelPageEntries(string $pagesDir): array
|
||||
{
|
||||
if (!is_dir($pagesDir)) {
|
||||
return [];
|
||||
}
|
||||
$entries = scandir($pagesDir);
|
||||
if ($entries === false) {
|
||||
return [];
|
||||
}
|
||||
$entries = array_values(array_filter(
|
||||
$entries,
|
||||
static fn (string $e): bool => $e !== '.' && $e !== '..',
|
||||
));
|
||||
sort($entries);
|
||||
return $entries;
|
||||
}
|
||||
|
||||
private function removeDirectory(string $dir): void
|
||||
{
|
||||
if (is_link($dir)) {
|
||||
unlink($dir);
|
||||
return;
|
||||
}
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
$this->removeDirectoryContents($dir);
|
||||
rmdir($dir);
|
||||
}
|
||||
|
||||
private function removeDirectoryContents(string $dir): void
|
||||
{
|
||||
$entries = scandir($dir);
|
||||
if ($entries === false) {
|
||||
return;
|
||||
}
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
$path = $dir . '/' . $entry;
|
||||
if (is_link($path)) {
|
||||
unlink($path);
|
||||
continue;
|
||||
}
|
||||
if (is_dir($path)) {
|
||||
$items = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
foreach ($items as $item) {
|
||||
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
|
||||
}
|
||||
rmdir($path);
|
||||
continue;
|
||||
}
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
101
core/App/registerContainer.php
Normal file
101
core/App/registerContainer.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Container\ContainerRegistrar;
|
||||
use MintyPHP\App\Container\Registrars\AccessRegistrar;
|
||||
use MintyPHP\App\Container\Registrars\AppServicesRegistrar;
|
||||
use MintyPHP\App\Container\Registrars\AuthRegistrar;
|
||||
use MintyPHP\App\Container\Registrars\DirectoryRegistrar;
|
||||
use MintyPHP\App\Container\Registrars\RepositoryFactoryRegistrar;
|
||||
use MintyPHP\App\Container\Registrars\ServiceFactoryRegistrar;
|
||||
use MintyPHP\App\Container\Registrars\SettingsRegistrar;
|
||||
use MintyPHP\App\Container\Registrars\UserRegistrar;
|
||||
use MintyPHP\App\Module\ModuleEventDispatcher;
|
||||
use MintyPHP\App\Module\ModuleRegistry;
|
||||
use MintyPHP\Service\Audit\ApiAuditServiceInterface;
|
||||
use MintyPHP\Service\Audit\ApiSystemAuditReporterInterface;
|
||||
use MintyPHP\Service\Audit\AuditMetadataEnricherInterface;
|
||||
use MintyPHP\Service\Audit\AuditRecorderInterface;
|
||||
use MintyPHP\Service\Audit\ImportAuditInterface;
|
||||
use MintyPHP\Service\Audit\NullApiAuditService;
|
||||
use MintyPHP\Service\Audit\NullApiSystemAuditReporter;
|
||||
use MintyPHP\Service\Audit\NullAuditMetadataEnricher;
|
||||
use MintyPHP\Service\Audit\NullAuditRecorder;
|
||||
use MintyPHP\Service\Audit\NullImportAudit;
|
||||
use MintyPHP\Service\Audit\NullUserLifecycleAudit;
|
||||
use MintyPHP\Service\Audit\UserLifecycleAuditInterface;
|
||||
|
||||
$container = new AppContainer();
|
||||
|
||||
// Order matters: RepositoryFactoryRegistrar and ServiceFactoryRegistrar must run first
|
||||
// because later registrars pull concrete services from those factories.
|
||||
$registrars = [
|
||||
new RepositoryFactoryRegistrar(), // raw repository factories (no deps)
|
||||
new ServiceFactoryRegistrar(), // service factories wired with their repo/gateway deps
|
||||
new AccessRegistrar(), // RBAC: permissions, roles, authorization
|
||||
new AuthRegistrar(), // authentication, SSO, tokens
|
||||
new DirectoryRegistrar(), // tenants, departments, scope
|
||||
new UserRegistrar(), // user services and repositories
|
||||
new SettingsRegistrar(), // settings, branding
|
||||
new AppServicesRegistrar(), // leaf services (stats, search, mail, scheduler, ...)
|
||||
];
|
||||
|
||||
foreach ($registrars as $registrar) {
|
||||
$registrar->register($container);
|
||||
}
|
||||
|
||||
// ── Module registrars (run after all Core registrars) ────────────────
|
||||
// Module registrars MUST NOT overwrite existing container keys.
|
||||
// The ModuleRegistry is stored in the container for downstream access.
|
||||
|
||||
$modulesConfig = is_file(__DIR__ . '/../../config/modules.php')
|
||||
? (array) require __DIR__ . '/../../config/modules.php'
|
||||
: [];
|
||||
$enabledModules = ModuleRegistry::resolveEnabledModules($modulesConfig);
|
||||
$modulesDir = dirname(__DIR__, 2) . '/modules';
|
||||
$moduleRegistry = ModuleRegistry::boot($modulesDir, $enabledModules);
|
||||
|
||||
$container->set(ModuleRegistry::class, static fn (): ModuleRegistry => $moduleRegistry);
|
||||
$container->set(ModuleEventDispatcher::class, static fn () => new ModuleEventDispatcher(
|
||||
$moduleRegistry->getEventListeners(),
|
||||
$container,
|
||||
$container->has(AuditRecorderInterface::class) ? $container->get(AuditRecorderInterface::class) : null
|
||||
));
|
||||
|
||||
// Disallow overriding existing core bindings from module registrars.
|
||||
$container->protectExistingBindings();
|
||||
|
||||
foreach ($moduleRegistry->getContainerRegistrars() as $registrarClass) {
|
||||
if (!class_exists($registrarClass)) {
|
||||
throw new RuntimeException("Module container registrar class not found: {$registrarClass}");
|
||||
}
|
||||
$registrarInstance = new $registrarClass();
|
||||
if (!$registrarInstance instanceof ContainerRegistrar) {
|
||||
throw new RuntimeException(
|
||||
"Module container registrar '{$registrarClass}' must implement " . ContainerRegistrar::class
|
||||
);
|
||||
}
|
||||
$registrarInstance->register($container);
|
||||
}
|
||||
|
||||
// ── Audit interface fallbacks (null implementations when audit module is disabled) ──
|
||||
if (!$container->has(AuditRecorderInterface::class)) {
|
||||
$container->set(AuditRecorderInterface::class, static fn (): AuditRecorderInterface => new NullAuditRecorder());
|
||||
}
|
||||
if (!$container->has(UserLifecycleAuditInterface::class)) {
|
||||
$container->set(UserLifecycleAuditInterface::class, static fn (): UserLifecycleAuditInterface => new NullUserLifecycleAudit());
|
||||
}
|
||||
if (!$container->has(ImportAuditInterface::class)) {
|
||||
$container->set(ImportAuditInterface::class, static fn (): ImportAuditInterface => new NullImportAudit());
|
||||
}
|
||||
if (!$container->has(AuditMetadataEnricherInterface::class)) {
|
||||
$container->set(AuditMetadataEnricherInterface::class, static fn (): AuditMetadataEnricherInterface => new NullAuditMetadataEnricher());
|
||||
}
|
||||
if (!$container->has(ApiAuditServiceInterface::class)) {
|
||||
$container->set(ApiAuditServiceInterface::class, static fn (): ApiAuditServiceInterface => new NullApiAuditService());
|
||||
}
|
||||
if (!$container->has(ApiSystemAuditReporterInterface::class)) {
|
||||
$container->set(ApiSystemAuditReporterInterface::class, static fn (): ApiSystemAuditReporterInterface => new NullApiSystemAuditReporter());
|
||||
}
|
||||
|
||||
return $container;
|
||||
61
core/Console/Command.php
Normal file
61
core/Console/Command.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Console;
|
||||
|
||||
use MintyPHP\Console\Support\CliAppBootstrap;
|
||||
use MintyPHP\Console\Support\ModuleCliRuntime;
|
||||
|
||||
/**
|
||||
* Base class for CLI commands.
|
||||
*
|
||||
* Subclasses define a name (e.g. 'module:sync') and implement execute().
|
||||
* Bootstrap helpers provide a stable way to initialize the app without
|
||||
* fragile relative paths.
|
||||
*/
|
||||
abstract class Command
|
||||
{
|
||||
abstract public function name(): string;
|
||||
|
||||
abstract public function description(): string;
|
||||
|
||||
/**
|
||||
* Optional usage text shown when --help is passed for this command.
|
||||
* Override in subclasses that accept arguments or options.
|
||||
*/
|
||||
public function usage(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $args Positional arguments (after the command name)
|
||||
* @param array<string, string|bool> $options Parsed --key=value and --flag options
|
||||
* @return int Exit code (0 = success)
|
||||
*/
|
||||
abstract public function execute(array $args, array $options): int;
|
||||
|
||||
// ─── Bootstrap helpers ──────────────────────────────────────────
|
||||
|
||||
protected function projectRoot(): string
|
||||
{
|
||||
return dirname(__DIR__, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap the app for non-module CLI commands.
|
||||
* Uses the centralized CLI bootstrap runtime.
|
||||
*/
|
||||
protected function bootstrapApp(string $channel = 'cli', ?string $path = null): void
|
||||
{
|
||||
CliAppBootstrap::bootstrap($channel, $path ?? 'bin/console ' . $this->name());
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap the app for module CLI commands.
|
||||
* Loads the module CLI infrastructure (error policy, locking, app container).
|
||||
*/
|
||||
protected function bootstrapModuleApp(): void
|
||||
{
|
||||
ModuleCliRuntime::bootstrap();
|
||||
}
|
||||
}
|
||||
82
core/Console/Commands/DoctorCommand.php
Normal file
82
core/Console/Commands/DoctorCommand.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Console\Commands;
|
||||
|
||||
use MintyPHP\Console\Command;
|
||||
use MintyPHP\Console\Runner\Doctor\DoctorRunner;
|
||||
use MintyPHP\Console\Runner\Doctor\DoctorRunnerInterface;
|
||||
|
||||
final class DoctorCommand extends Command
|
||||
{
|
||||
private ?DoctorRunnerInterface $runner;
|
||||
|
||||
public function __construct(?DoctorRunnerInterface $runner = null)
|
||||
{
|
||||
$this->runner = $runner;
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'doctor';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Run system health checks';
|
||||
}
|
||||
|
||||
public function usage(): string
|
||||
{
|
||||
return <<<'USAGE'
|
||||
Usage: php bin/console doctor [--format=json]
|
||||
|
||||
Options:
|
||||
--format=json Output stable JSON for automation
|
||||
USAGE;
|
||||
}
|
||||
|
||||
public function execute(array $args, array $options): int
|
||||
{
|
||||
$format = strtolower((string) ($options['format'] ?? 'human'));
|
||||
if (!in_array($format, ['human', 'json'], true)) {
|
||||
fwrite(STDERR, "Invalid format '{$format}'. Supported: human, json\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$result = $this->runner()->run();
|
||||
|
||||
if ($format === 'json') {
|
||||
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL;
|
||||
return (int) $result['exit_code'];
|
||||
}
|
||||
|
||||
echo "Minty Doctor Report\n";
|
||||
echo "===================\n";
|
||||
foreach ($result['checks'] as $item) {
|
||||
echo sprintf(
|
||||
"[%s] %s: %s\n",
|
||||
strtoupper($item['status']),
|
||||
$item['name'],
|
||||
$item['message']
|
||||
);
|
||||
}
|
||||
echo sprintf(
|
||||
"\nSummary: ok=%d warn=%d fail=%d\n",
|
||||
$result['ok_count'],
|
||||
$result['warn_count'],
|
||||
$result['fail_count']
|
||||
);
|
||||
|
||||
return (int) $result['exit_code'];
|
||||
}
|
||||
|
||||
private function runner(): DoctorRunnerInterface
|
||||
{
|
||||
if ($this->runner instanceof DoctorRunnerInterface) {
|
||||
return $this->runner;
|
||||
}
|
||||
|
||||
$this->runner = new DoctorRunner();
|
||||
return $this->runner;
|
||||
}
|
||||
}
|
||||
270
core/Console/Commands/Make/ModuleCommand.php
Normal file
270
core/Console/Commands/Make/ModuleCommand.php
Normal file
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Console\Commands\Make;
|
||||
|
||||
use MintyPHP\Console\Command;
|
||||
|
||||
/**
|
||||
* Scaffolds a new module with manifest, registrar, policy, and directory structure.
|
||||
*
|
||||
* Usage: php bin/console make:module <id>
|
||||
* Example: php bin/console make:module notifications
|
||||
*
|
||||
* Generates:
|
||||
* modules/<id>/module.php
|
||||
* modules/<id>/lib/Module/<Name>/<Name>ContainerRegistrar.php
|
||||
* modules/<id>/lib/Module/<Name>/<Name>AuthorizationPolicy.php
|
||||
* modules/<id>/pages/
|
||||
* modules/<id>/templates/
|
||||
* modules/<id>/web/css/
|
||||
* modules/<id>/web/js/
|
||||
* modules/<id>/tests/Module/<Name>/
|
||||
*/
|
||||
final class ModuleCommand extends Command
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'make:module';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Scaffold a new module with manifest and stub classes';
|
||||
}
|
||||
|
||||
public function usage(): string
|
||||
{
|
||||
return <<<'USAGE'
|
||||
Usage: php bin/console make:module <module-id>
|
||||
|
||||
Arguments:
|
||||
module-id Lowercase identifier (e.g. notifications, activity-log)
|
||||
Must match [a-z][a-z0-9-]* pattern.
|
||||
|
||||
Example:
|
||||
php bin/console make:module notifications
|
||||
|
||||
Generates the full module directory structure with:
|
||||
- module.php manifest (all fields, sensible defaults)
|
||||
- ContainerRegistrar stub
|
||||
- AuthorizationPolicy stub
|
||||
- Empty pages/, templates/, web/css/, web/js/, tests/ directories
|
||||
USAGE;
|
||||
}
|
||||
|
||||
public function execute(array $args, array $options): int
|
||||
{
|
||||
$moduleId = trim($args[0] ?? '');
|
||||
|
||||
if ($moduleId === '') {
|
||||
fwrite(STDERR, $this->usage() . "\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!preg_match('/^[a-z][a-z0-9-]*$/', $moduleId)) {
|
||||
fwrite(STDERR, "Error: module ID must match [a-z][a-z0-9-]* (got '{$moduleId}')\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$modulesDir = $this->projectRoot() . '/modules';
|
||||
$moduleDir = $modulesDir . '/' . $moduleId;
|
||||
|
||||
if (is_dir($moduleDir)) {
|
||||
fwrite(STDERR, "Error: module directory already exists: modules/{$moduleId}/\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$pascalName = self::toPascalCase($moduleId);
|
||||
$snakeName = str_replace('-', '_', $moduleId);
|
||||
|
||||
// Create directories
|
||||
$dirs = [
|
||||
"lib/Module/{$pascalName}",
|
||||
'pages',
|
||||
'templates',
|
||||
'web/css',
|
||||
'web/js',
|
||||
"tests/Module/{$pascalName}",
|
||||
];
|
||||
|
||||
foreach ($dirs as $dir) {
|
||||
$path = $moduleDir . '/' . $dir;
|
||||
if (!mkdir($path, 0777, true) && !is_dir($path)) {
|
||||
fwrite(STDERR, "Error: failed to create directory: {$dir}\n");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate files
|
||||
$files = [
|
||||
'module.php' => self::manifestTemplate($moduleId, $pascalName, $snakeName),
|
||||
"lib/Module/{$pascalName}/{$pascalName}ContainerRegistrar.php" => self::registrarTemplate($pascalName),
|
||||
"lib/Module/{$pascalName}/{$pascalName}AuthorizationPolicy.php" => self::policyTemplate($pascalName, $snakeName),
|
||||
];
|
||||
|
||||
foreach ($files as $relativePath => $content) {
|
||||
$fullPath = $moduleDir . '/' . $relativePath;
|
||||
if (file_put_contents($fullPath, $content) === false) {
|
||||
fwrite(STDERR, "Error: failed to write file: {$relativePath}\n");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Add .gitkeep to empty directories
|
||||
foreach (['pages', 'templates', 'web/css', 'web/js', "tests/Module/{$pascalName}"] as $dir) {
|
||||
touch($moduleDir . '/' . $dir . '/.gitkeep');
|
||||
}
|
||||
|
||||
fwrite(STDOUT, "Module '{$moduleId}' scaffolded at modules/{$moduleId}/\n\n");
|
||||
fwrite(STDOUT, "Created:\n");
|
||||
fwrite(STDOUT, " modules/{$moduleId}/module.php\n");
|
||||
fwrite(STDOUT, " modules/{$moduleId}/lib/Module/{$pascalName}/{$pascalName}ContainerRegistrar.php\n");
|
||||
fwrite(STDOUT, " modules/{$moduleId}/lib/Module/{$pascalName}/{$pascalName}AuthorizationPolicy.php\n");
|
||||
fwrite(STDOUT, " modules/{$moduleId}/pages/\n");
|
||||
fwrite(STDOUT, " modules/{$moduleId}/templates/\n");
|
||||
fwrite(STDOUT, " modules/{$moduleId}/web/css/\n");
|
||||
fwrite(STDOUT, " modules/{$moduleId}/web/js/\n");
|
||||
fwrite(STDOUT, " modules/{$moduleId}/tests/Module/{$pascalName}/\n");
|
||||
fwrite(STDOUT, "\nNext steps:\n");
|
||||
fwrite(STDOUT, " 1. Add '{$moduleId}' to config/modules.php enabled_modules\n");
|
||||
fwrite(STDOUT, " 2. Register the AuthorizationPolicy in {$pascalName}ContainerRegistrar (required for UI slot visibility)\n");
|
||||
fwrite(STDOUT, " 3. Edit module.php to define routes, permissions, and UI slots\n");
|
||||
fwrite(STDOUT, " 4. Run: php bin/console module:sync\n");
|
||||
fwrite(STDOUT, "\nGuides:\n");
|
||||
fwrite(STDOUT, " Tutorial: docs/howto-modul-erstellen.md\n");
|
||||
fwrite(STDOUT, " Manifest contract: docs/reference-module-manifest-contract.md\n");
|
||||
fwrite(STDOUT, " Reference module: modules/notifications/ (full-featured example)\n");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static function toPascalCase(string $id): string
|
||||
{
|
||||
return str_replace(' ', '', ucwords(str_replace('-', ' ', $id)));
|
||||
}
|
||||
|
||||
private static function manifestTemplate(string $id, string $pascalName, string $snakeName): string
|
||||
{
|
||||
$registrarClass = "\\MintyPHP\\Module\\{$pascalName}\\{$pascalName}ContainerRegistrar::class";
|
||||
$policyClass = "\\MintyPHP\\Module\\{$pascalName}\\{$pascalName}AuthorizationPolicy::class";
|
||||
|
||||
return <<<PHP
|
||||
<?php
|
||||
|
||||
/**
|
||||
* {$pascalName} module manifest.
|
||||
*/
|
||||
return [
|
||||
'id' => '{$id}',
|
||||
'version' => '0.1.0',
|
||||
'enabled_by_default' => false,
|
||||
'load_order' => 100,
|
||||
'requires' => [],
|
||||
|
||||
'routes' => [
|
||||
// ['path' => '{$id}', 'target' => '{$id}'],
|
||||
],
|
||||
'public_paths' => [],
|
||||
'container_registrars' => [
|
||||
{$registrarClass},
|
||||
],
|
||||
|
||||
'ui_slots' => [],
|
||||
|
||||
'authorization_policies' => [
|
||||
{$policyClass},
|
||||
],
|
||||
|
||||
'search_resources' => [],
|
||||
'asset_groups' => [],
|
||||
'scheduler_jobs' => [],
|
||||
|
||||
'layout_context_providers' => [],
|
||||
'session_providers' => [],
|
||||
|
||||
'permissions' => [
|
||||
// [
|
||||
// 'key' => '{$snakeName}.view',
|
||||
// 'description' => 'Can view {$id}',
|
||||
// 'active' => true,
|
||||
// 'is_system' => true,
|
||||
// ],
|
||||
],
|
||||
|
||||
'event_listeners' => [],
|
||||
'deactivation_handler' => null,
|
||||
'migrations_path' => null,
|
||||
];
|
||||
|
||||
PHP;
|
||||
}
|
||||
|
||||
private static function registrarTemplate(string $pascalName): string
|
||||
{
|
||||
return <<<PHP
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\\Module\\{$pascalName};
|
||||
|
||||
use MintyPHP\\App\\AppContainer;
|
||||
use MintyPHP\\App\\Container\\ContainerRegistrar;
|
||||
|
||||
final class {$pascalName}ContainerRegistrar implements ContainerRegistrar
|
||||
{
|
||||
public function register(AppContainer \$container): void
|
||||
{
|
||||
// Register module services here.
|
||||
// Example:
|
||||
// \$container->set(MyService::class, static fn (AppContainer \$c): MyService => new MyService(
|
||||
// \$c->get(SomeDependency::class)
|
||||
// ));
|
||||
}
|
||||
}
|
||||
|
||||
PHP;
|
||||
}
|
||||
|
||||
private static function policyTemplate(string $pascalName, string $snakeName): string
|
||||
{
|
||||
return <<<PHP
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\\Module\\{$pascalName};
|
||||
|
||||
use MintyPHP\\Service\\Access\\AuthorizationDecision;
|
||||
use MintyPHP\\Service\\Access\\AuthorizationPolicyInterface;
|
||||
use MintyPHP\\Service\\Access\\PermissionService;
|
||||
|
||||
final class {$pascalName}AuthorizationPolicy implements AuthorizationPolicyInterface
|
||||
{
|
||||
public const ABILITY_VIEW = '{$snakeName}.view';
|
||||
public const PERMISSION_KEY = '{$snakeName}.view';
|
||||
|
||||
public function __construct(
|
||||
private readonly PermissionService \$permissionService
|
||||
) {
|
||||
}
|
||||
|
||||
public function supports(string \$ability): bool
|
||||
{
|
||||
return \$ability === self::ABILITY_VIEW;
|
||||
}
|
||||
|
||||
public function authorize(string \$ability, array \$context = []): AuthorizationDecision
|
||||
{
|
||||
\$actorUserId = (int) (\$context['actor_user_id'] ?? 0);
|
||||
if (\$actorUserId <= 0) {
|
||||
return AuthorizationDecision::deny(403, 'forbidden');
|
||||
}
|
||||
|
||||
if (!\$this->permissionService->userHas(\$actorUserId, self::PERMISSION_KEY)) {
|
||||
return AuthorizationDecision::deny(403, 'forbidden');
|
||||
}
|
||||
|
||||
return AuthorizationDecision::allow();
|
||||
}
|
||||
}
|
||||
|
||||
PHP;
|
||||
}
|
||||
}
|
||||
27
core/Console/Commands/Module/AbstractModuleCommand.php
Normal file
27
core/Console/Commands/Module/AbstractModuleCommand.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Console\Commands\Module;
|
||||
|
||||
use MintyPHP\Console\Command;
|
||||
use MintyPHP\Console\Runner\Module\ModuleRunner;
|
||||
use MintyPHP\Console\Runner\Module\ModuleRunnerInterface;
|
||||
|
||||
abstract class AbstractModuleCommand extends Command
|
||||
{
|
||||
private ?ModuleRunnerInterface $moduleRunner;
|
||||
|
||||
public function __construct(?ModuleRunnerInterface $moduleRunner = null)
|
||||
{
|
||||
$this->moduleRunner = $moduleRunner;
|
||||
}
|
||||
|
||||
protected function moduleRunner(): ModuleRunnerInterface
|
||||
{
|
||||
if ($this->moduleRunner instanceof ModuleRunnerInterface) {
|
||||
return $this->moduleRunner;
|
||||
}
|
||||
|
||||
$this->moduleRunner = new ModuleRunner();
|
||||
return $this->moduleRunner;
|
||||
}
|
||||
}
|
||||
35
core/Console/Commands/Module/AssetsSyncCommand.php
Normal file
35
core/Console/Commands/Module/AssetsSyncCommand.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Console\Commands\Module;
|
||||
|
||||
final class AssetsSyncCommand extends AbstractModuleCommand
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'module:assets-sync';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Publish module web assets to web/modules/';
|
||||
}
|
||||
|
||||
public function execute(array $args, array $options): int
|
||||
{
|
||||
$result = $this->moduleRunner()->assetsSync();
|
||||
fwrite(STDOUT, sprintf(
|
||||
"module-assets-sync: mode=%s created=%d updated=%d removed=%d unchanged=%d\n",
|
||||
$result['mode'],
|
||||
$result['created'],
|
||||
$result['updated'],
|
||||
$result['removed'],
|
||||
$result['unchanged']
|
||||
));
|
||||
|
||||
if ($result['message'] !== 'module-assets-sync: ok') {
|
||||
fwrite(STDOUT, $result['message'] . PHP_EOL);
|
||||
}
|
||||
|
||||
return (int) $result['exit_code'];
|
||||
}
|
||||
}
|
||||
36
core/Console/Commands/Module/BuildCommand.php
Normal file
36
core/Console/Commands/Module/BuildCommand.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Console\Commands\Module;
|
||||
|
||||
final class BuildCommand extends AbstractModuleCommand
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'module:build';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Build runtime page root with module symlinks';
|
||||
}
|
||||
|
||||
public function execute(array $args, array $options): int
|
||||
{
|
||||
$result = $this->moduleRunner()->build();
|
||||
if ($result['status'] === 'ok') {
|
||||
fwrite(STDOUT, sprintf(
|
||||
"module-build: runtime pages built - %d core entries + %d module entries.\n",
|
||||
$result['core_entries'],
|
||||
$result['module_entries']
|
||||
));
|
||||
} else {
|
||||
fwrite(STDERR, $result['message'] . PHP_EOL);
|
||||
}
|
||||
|
||||
if ($result['message'] !== 'module-build: ok' && $result['status'] === 'ok') {
|
||||
fwrite(STDOUT, $result['message'] . PHP_EOL);
|
||||
}
|
||||
|
||||
return (int) $result['exit_code'];
|
||||
}
|
||||
}
|
||||
49
core/Console/Commands/Module/DeactivateCommand.php
Normal file
49
core/Console/Commands/Module/DeactivateCommand.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Console\Commands\Module;
|
||||
|
||||
final class DeactivateCommand extends AbstractModuleCommand
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'module:deactivate';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Run a module\'s deactivation handler';
|
||||
}
|
||||
|
||||
public function usage(): string
|
||||
{
|
||||
return <<<'USAGE'
|
||||
Usage: php bin/console module:deactivate <module-id> [options]
|
||||
|
||||
Options:
|
||||
--confirm Execute the deactivation handler
|
||||
--dry-run Validate the handler without executing
|
||||
|
||||
The module does not need to be in the enabled list.
|
||||
USAGE;
|
||||
}
|
||||
|
||||
public function execute(array $args, array $options): int
|
||||
{
|
||||
$moduleId = $args[0] ?? '';
|
||||
if ($moduleId === '') {
|
||||
fwrite(STDERR, $this->usage() . "\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$result = $this->moduleRunner()->deactivate(
|
||||
$moduleId,
|
||||
(bool) ($options['confirm'] ?? false),
|
||||
(bool) ($options['dry-run'] ?? false)
|
||||
);
|
||||
|
||||
$stream = $result['exit_code'] === 0 ? STDOUT : STDERR;
|
||||
fwrite($stream, $result['message'] . PHP_EOL);
|
||||
|
||||
return (int) $result['exit_code'];
|
||||
}
|
||||
}
|
||||
24
core/Console/Commands/Module/MigrateCommand.php
Normal file
24
core/Console/Commands/Module/MigrateCommand.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Console\Commands\Module;
|
||||
|
||||
final class MigrateCommand extends AbstractModuleCommand
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'module:migrate';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Apply pending module SQL migrations';
|
||||
}
|
||||
|
||||
public function execute(array $args, array $options): int
|
||||
{
|
||||
$result = $this->moduleRunner()->migrate();
|
||||
fwrite(STDOUT, $result['message'] . PHP_EOL);
|
||||
|
||||
return (int) $result['exit_code'];
|
||||
}
|
||||
}
|
||||
35
core/Console/Commands/Module/PermissionsSyncCommand.php
Normal file
35
core/Console/Commands/Module/PermissionsSyncCommand.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Console\Commands\Module;
|
||||
|
||||
final class PermissionsSyncCommand extends AbstractModuleCommand
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'module:permissions-sync';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Sync module permissions to DB and deactivate orphaned ones';
|
||||
}
|
||||
|
||||
public function execute(array $args, array $options): int
|
||||
{
|
||||
$result = $this->moduleRunner()->permissionsSync();
|
||||
fwrite(STDOUT, sprintf(
|
||||
"module-permissions-sync: created=%d updated=%d unchanged=%d deactivated=%d total=%d\n",
|
||||
$result['created'],
|
||||
$result['updated'],
|
||||
$result['unchanged'],
|
||||
$result['deactivated'],
|
||||
$result['total']
|
||||
));
|
||||
|
||||
if ($result['message'] !== 'module-permissions-sync: ok') {
|
||||
fwrite(STDOUT, $result['message'] . PHP_EOL);
|
||||
}
|
||||
|
||||
return (int) $result['exit_code'];
|
||||
}
|
||||
}
|
||||
55
core/Console/Commands/Module/RuntimeSyncCommand.php
Normal file
55
core/Console/Commands/Module/RuntimeSyncCommand.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Console\Commands\Module;
|
||||
|
||||
final class RuntimeSyncCommand extends AbstractModuleCommand
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'module:sync';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Run full module runtime sync (migrate, permissions, build, assets)';
|
||||
}
|
||||
|
||||
public function usage(): string
|
||||
{
|
||||
return <<<'USAGE'
|
||||
Usage: php bin/console module:sync [--format=json]
|
||||
|
||||
Options:
|
||||
--format=json Output stable JSON for automation
|
||||
USAGE;
|
||||
}
|
||||
|
||||
public function execute(array $args, array $options): int
|
||||
{
|
||||
$format = strtolower((string) ($options['format'] ?? 'human'));
|
||||
if (!in_array($format, ['human', 'json'], true)) {
|
||||
fwrite(STDERR, "Invalid format '{$format}'. Supported: human, json\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$result = $this->moduleRunner()->runtimeSync();
|
||||
|
||||
if ($format === 'json') {
|
||||
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL;
|
||||
return (int) $result['exit_code'];
|
||||
}
|
||||
|
||||
foreach ($result['steps'] as $stepName => $step) {
|
||||
echo sprintf(
|
||||
"module-runtime-sync: step=%s status=%s exit_code=%d vendor_warnings_ignored=%d\n",
|
||||
$stepName,
|
||||
$step['status'],
|
||||
$step['exit_code'],
|
||||
$step['vendor_warnings_ignored']
|
||||
);
|
||||
}
|
||||
echo $result['message'] . PHP_EOL;
|
||||
|
||||
return (int) $result['exit_code'];
|
||||
}
|
||||
}
|
||||
336
core/Console/Commands/Module/ValidateCommand.php
Normal file
336
core/Console/Commands/Module/ValidateCommand.php
Normal file
@@ -0,0 +1,336 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Console\Commands\Module;
|
||||
|
||||
use MintyPHP\App\Container\ContainerRegistrar;
|
||||
use MintyPHP\App\Module\Contracts\EventListener;
|
||||
use MintyPHP\App\Module\Contracts\LayoutContextProvider;
|
||||
use MintyPHP\App\Module\Contracts\ModuleDeactivationHandler;
|
||||
use MintyPHP\App\Module\Contracts\SearchResourceProvider;
|
||||
use MintyPHP\App\Module\Contracts\SessionProvider;
|
||||
use MintyPHP\App\Module\ModuleManifestLoader;
|
||||
use MintyPHP\Console\Command;
|
||||
use MintyPHP\Service\Access\AuthorizationPolicyInterface;
|
||||
|
||||
/**
|
||||
* Validates a module's manifest, class contracts, and structure.
|
||||
*
|
||||
* Catches errors that would otherwise only surface at boot time.
|
||||
*/
|
||||
final class ValidateCommand extends Command
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'module:validate';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Validate a module\'s manifest, classes, and structure';
|
||||
}
|
||||
|
||||
public function usage(): string
|
||||
{
|
||||
return <<<'USAGE'
|
||||
Usage: php bin/console module:validate <module-id>
|
||||
php bin/console module:validate --all
|
||||
|
||||
Validates:
|
||||
- Manifest validates against .agents/contracts/module-manifest.schema.json
|
||||
- Manifest parses without error
|
||||
- All declared classes exist and implement correct interfaces
|
||||
- PHP classes use MintyPHP\Module\* namespace
|
||||
- Permission keys are well-formed
|
||||
- Required directories exist
|
||||
- Route paths don't contain suspicious patterns
|
||||
USAGE;
|
||||
}
|
||||
|
||||
public function execute(array $args, array $options): int
|
||||
{
|
||||
$this->bootstrapApp('cli', 'bin/console module:validate');
|
||||
|
||||
$modulesDir = $this->projectRoot() . '/modules';
|
||||
$validateAll = $options['all'] ?? false;
|
||||
$moduleId = trim($args[0] ?? '');
|
||||
|
||||
if (!$validateAll && $moduleId === '') {
|
||||
fwrite(STDERR, $this->usage() . "\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$moduleIds = $validateAll
|
||||
? $this->discoverModuleIds($modulesDir)
|
||||
: [$moduleId];
|
||||
|
||||
if ($moduleIds === []) {
|
||||
fwrite(STDERR, "No modules found in modules/\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$totalErrors = 0;
|
||||
$totalWarnings = 0;
|
||||
|
||||
foreach ($moduleIds as $id) {
|
||||
[$errors, $warnings] = $this->validateModule($modulesDir, $id);
|
||||
$totalErrors += count($errors);
|
||||
$totalWarnings += count($warnings);
|
||||
|
||||
if ($errors === [] && $warnings === []) {
|
||||
fwrite(STDOUT, "[OK] {$id}\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($errors as $error) {
|
||||
fwrite(STDOUT, "[FAIL] {$id}: {$error}\n");
|
||||
}
|
||||
foreach ($warnings as $warning) {
|
||||
fwrite(STDOUT, "[WARN] {$id}: {$warning}\n");
|
||||
}
|
||||
}
|
||||
|
||||
fwrite(STDOUT, sprintf(
|
||||
"\nSummary: %d module(s), %d error(s), %d warning(s)\n",
|
||||
count($moduleIds),
|
||||
$totalErrors,
|
||||
$totalWarnings
|
||||
));
|
||||
|
||||
return $totalErrors > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function discoverModuleIds(string $modulesDir): array
|
||||
{
|
||||
if (!is_dir($modulesDir)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
$entries = scandir($modulesDir) ?: [];
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..' || !is_dir($modulesDir . '/' . $entry)) {
|
||||
continue;
|
||||
}
|
||||
if (is_file($modulesDir . '/' . $entry . '/module.php')) {
|
||||
$ids[] = $entry;
|
||||
}
|
||||
}
|
||||
sort($ids);
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{list<string>, list<string>} [errors, warnings]
|
||||
*/
|
||||
private function validateModule(string $modulesDir, string $moduleId): array
|
||||
{
|
||||
$errors = [];
|
||||
$warnings = [];
|
||||
$moduleDir = $modulesDir . '/' . $moduleId;
|
||||
$manifestFile = $moduleDir . '/module.php';
|
||||
|
||||
// 1. Directory exists
|
||||
if (!is_dir($moduleDir)) {
|
||||
return [["Module directory not found: modules/{$moduleId}/"], []];
|
||||
}
|
||||
|
||||
// 2. Manifest exists
|
||||
if (!is_file($manifestFile)) {
|
||||
return [["Manifest not found: modules/{$moduleId}/module.php"], []];
|
||||
}
|
||||
|
||||
// 3+4. Load and validate manifest
|
||||
try {
|
||||
$manifest = ModuleManifestLoader::loadFromDisk($modulesDir, $moduleId);
|
||||
} catch (\Throwable $e) {
|
||||
return [['Manifest load/validation error: ' . $e->getMessage()], []];
|
||||
}
|
||||
|
||||
// Register module lib/ with autoloader so we can resolve classes
|
||||
// even if the module is not in the enabled list
|
||||
$libDir = $moduleDir . '/lib';
|
||||
if (is_dir($libDir)) {
|
||||
$composerLoader = null;
|
||||
foreach (spl_autoload_functions() as $autoloader) {
|
||||
if (is_array($autoloader) && $autoloader[0] instanceof \Composer\Autoload\ClassLoader) {
|
||||
$composerLoader = $autoloader[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
$composerLoader?->addPsr4('MintyPHP\\', [$libDir]);
|
||||
}
|
||||
|
||||
// 5. ID matches directory
|
||||
if ($manifest->id !== $moduleId) {
|
||||
$errors[] = "Manifest id '{$manifest->id}' does not match directory '{$moduleId}'";
|
||||
}
|
||||
|
||||
// 6. Version is semver
|
||||
if (!preg_match('/^\d+\.\d+\.\d+$/', $manifest->version)) {
|
||||
$warnings[] = "Version '{$manifest->version}' is not semver (expected x.y.z)";
|
||||
}
|
||||
|
||||
// 7. lib/ directory exists
|
||||
if (!is_dir($moduleDir . '/lib')) {
|
||||
$warnings[] = 'No lib/ directory (module has no PHP classes)';
|
||||
}
|
||||
|
||||
// 8. Container registrar classes exist and implement interface
|
||||
foreach ($manifest->containerRegistrars as $class) {
|
||||
if (!class_exists($class)) {
|
||||
$errors[] = "Container registrar class not found: {$class}";
|
||||
} elseif (!is_subclass_of($class, ContainerRegistrar::class)) {
|
||||
$errors[] = "Container registrar does not implement ContainerRegistrar: {$class}";
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Authorization policy classes
|
||||
foreach ($manifest->authorizationPolicies as $class) {
|
||||
if (!class_exists($class)) {
|
||||
$errors[] = "Authorization policy class not found: {$class}";
|
||||
} elseif (!in_array(AuthorizationPolicyInterface::class, class_implements($class) ?: [], true)) {
|
||||
$errors[] = "Authorization policy does not implement AuthorizationPolicyInterface: {$class}";
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Session provider classes
|
||||
foreach ($manifest->sessionProviders as $class) {
|
||||
if (!class_exists($class)) {
|
||||
$errors[] = "Session provider class not found: {$class}";
|
||||
} elseif (!in_array(SessionProvider::class, class_implements($class) ?: [], true)) {
|
||||
$errors[] = "Session provider does not implement SessionProvider: {$class}";
|
||||
}
|
||||
}
|
||||
|
||||
// 11. Layout context provider classes
|
||||
foreach ($manifest->layoutContextProviders as $class) {
|
||||
if (!class_exists($class)) {
|
||||
$errors[] = "Layout context provider class not found: {$class}";
|
||||
} elseif (!in_array(LayoutContextProvider::class, class_implements($class) ?: [], true)) {
|
||||
$errors[] = "Layout context provider does not implement LayoutContextProvider: {$class}";
|
||||
}
|
||||
}
|
||||
|
||||
// 12. Search resource provider classes
|
||||
foreach ($manifest->searchResources as $class) {
|
||||
if (!class_exists($class)) {
|
||||
$errors[] = "Search resource provider class not found: {$class}";
|
||||
} elseif (!in_array(SearchResourceProvider::class, class_implements($class) ?: [], true)) {
|
||||
$errors[] = "Search resource provider does not implement SearchResourceProvider: {$class}";
|
||||
}
|
||||
}
|
||||
|
||||
// 13. Event listener classes
|
||||
foreach ($manifest->eventListeners as $eventName => $handlers) {
|
||||
foreach ($handlers as $i => $handler) {
|
||||
$class = $handler['class'];
|
||||
if (!class_exists($class)) {
|
||||
$errors[] = "Event listener class not found: {$class} (event: {$eventName})";
|
||||
} elseif (!in_array(EventListener::class, class_implements($class) ?: [], true)) {
|
||||
$errors[] = "Event listener does not implement EventListener: {$class} (event: {$eventName})";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 14. Deactivation handler class
|
||||
if ($manifest->deactivationHandler !== null) {
|
||||
$class = $manifest->deactivationHandler;
|
||||
if (!class_exists($class)) {
|
||||
$errors[] = "Deactivation handler class not found: {$class}";
|
||||
} elseif (!in_array(ModuleDeactivationHandler::class, class_implements($class) ?: [], true)) {
|
||||
$errors[] = "Deactivation handler does not implement ModuleDeactivationHandler: {$class}";
|
||||
}
|
||||
}
|
||||
|
||||
// 15. Permission keys are well-formed
|
||||
foreach ($manifest->permissions as $i => $perm) {
|
||||
$key = $perm['key'];
|
||||
if (!preg_match('/^[a-z][a-z0-9._-]+$/', $key)) {
|
||||
$errors[] = "Permission key has invalid format: '{$key}'";
|
||||
}
|
||||
}
|
||||
|
||||
// 16. Namespace compliance — PHP files in lib/ must use MintyPHP\Module\*
|
||||
if (is_dir($moduleDir . '/lib')) {
|
||||
$this->checkNamespaceCompliance($moduleDir . '/lib', $moduleId, $errors);
|
||||
}
|
||||
|
||||
// 17. Routes have pages directory
|
||||
if ($manifest->routes !== [] && !is_dir($moduleDir . '/pages')) {
|
||||
$errors[] = 'Module declares routes but has no pages/ directory';
|
||||
}
|
||||
|
||||
// 18. UI slot templates exist
|
||||
foreach ($manifest->uiSlots as $slotName => $contributions) {
|
||||
$contributionList = is_array($contributions) ? $contributions : [$contributions];
|
||||
foreach ($contributionList as $idx => $contribution) {
|
||||
if (!is_array($contribution)) {
|
||||
continue;
|
||||
}
|
||||
foreach (['panel_template', 'template'] as $templateKey) {
|
||||
$template = $contribution[$templateKey] ?? null;
|
||||
if ($template === null) {
|
||||
continue;
|
||||
}
|
||||
$templatePath = (string) $template;
|
||||
if (!str_starts_with($templatePath, '/')) {
|
||||
$templatePath = $moduleDir . '/' . $templatePath;
|
||||
}
|
||||
if (!is_file($templatePath)) {
|
||||
$errors[] = "UI slot '{$slotName}' {$templateKey} not found: {$template}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 19. Required modules exist on disk
|
||||
foreach ($manifest->requires as $requiredId) {
|
||||
if (!is_dir($modulesDir . '/' . $requiredId)) {
|
||||
$errors[] = "Required module '{$requiredId}' not found on disk";
|
||||
}
|
||||
}
|
||||
|
||||
return [$errors, $warnings];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> &$errors
|
||||
*/
|
||||
private function checkNamespaceCompliance(string $libDir, string $moduleId, array &$errors): void
|
||||
{
|
||||
$coreNamespacePrefixes = [
|
||||
'MintyPHP\\Service\\',
|
||||
'MintyPHP\\Repository\\',
|
||||
'MintyPHP\\Support\\',
|
||||
'MintyPHP\\Http\\',
|
||||
'MintyPHP\\App\\Container\\',
|
||||
];
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($libDir, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::LEAVES_ONLY
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = file_get_contents($file->getPathname());
|
||||
if ($content === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^namespace\s+(.+?);/m', $content, $match)) {
|
||||
$namespace = $match[1];
|
||||
foreach ($coreNamespacePrefixes as $corePrefix) {
|
||||
if (str_starts_with($namespace . '\\', $corePrefix)) {
|
||||
$errors[] = "{$file->getFilename()} uses core namespace '{$namespace}' — must use MintyPHP\\Module\\*";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
72
core/Console/Commands/Scheduler/RunCommand.php
Normal file
72
core/Console/Commands/Scheduler/RunCommand.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Console\Commands\Scheduler;
|
||||
|
||||
use MintyPHP\Console\Command;
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Service\Audit\AuditRecorderInterface;
|
||||
use MintyPHP\Service\Scheduler\SchedulerServicesFactory;
|
||||
|
||||
final class RunCommand extends Command
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'scheduler:run';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Execute due scheduled jobs';
|
||||
}
|
||||
|
||||
public function execute(array $args, array $options): int
|
||||
{
|
||||
$this->bootstrapApp('scheduler');
|
||||
|
||||
$exitCode = 0;
|
||||
$startedAt = microtime(true);
|
||||
try {
|
||||
$factory = app(SchedulerServicesFactory::class);
|
||||
$result = $factory->createSchedulerRunService()->runDueJobs();
|
||||
if (!($result['ok'] ?? false)) {
|
||||
$error = (string) ($result['error'] ?? 'unexpected_error');
|
||||
fwrite(STDERR, sprintf("scheduler run failed: %s\n", $error));
|
||||
$exitCode = 1;
|
||||
} else {
|
||||
fwrite(STDOUT, sprintf(
|
||||
"scheduler completed: processed=%d success=%d failed=%d skipped=%d duration_ms=%d\n",
|
||||
(int) ($result['processed'] ?? 0),
|
||||
(int) ($result['success'] ?? 0),
|
||||
(int) ($result['failed'] ?? 0),
|
||||
(int) ($result['skipped'] ?? 0),
|
||||
(int) ($result['duration_ms'] ?? 0)
|
||||
));
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
try {
|
||||
app(AuditRecorderInterface::class)->record('scheduler.run', 'failed', [
|
||||
'error_code' => 'unexpected_error',
|
||||
'metadata' => [
|
||||
'trigger_type' => 'scheduler',
|
||||
'run_status' => 'failed',
|
||||
'processed' => 0,
|
||||
'success_count' => 0,
|
||||
'failed_count' => 0,
|
||||
'skipped_count' => 0,
|
||||
'duration_ms' => max(0, (int) round((microtime(true) - $startedAt) * 1000)),
|
||||
'bootstrap_error' => true,
|
||||
],
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
// fail-open
|
||||
}
|
||||
|
||||
fwrite(STDERR, sprintf("scheduler run failed: unexpected_error: %s\n", $exception->getMessage()));
|
||||
$exitCode = 1;
|
||||
} finally {
|
||||
DB::close();
|
||||
}
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
}
|
||||
163
core/Console/ConsoleApplication.php
Normal file
163
core/Console/ConsoleApplication.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Console;
|
||||
|
||||
/**
|
||||
* Minimal CLI command dispatcher with auto-discovery.
|
||||
*
|
||||
* Parses argv into command name, positional args, and --options,
|
||||
* then dispatches to the matching registered Command instance.
|
||||
*/
|
||||
final class ConsoleApplication
|
||||
{
|
||||
/** @var array<string, Command> */
|
||||
private array $commands = [];
|
||||
|
||||
public function register(Command $command): void
|
||||
{
|
||||
$this->commands[$command->name()] = $command;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-discover and register all Command subclasses in a directory.
|
||||
*
|
||||
* Scans PHP files recursively, extracts the FQCN from namespace + class name,
|
||||
* and registers any concrete Command subclass found.
|
||||
*/
|
||||
public function discoverCommands(string $directory): void
|
||||
{
|
||||
if (!is_dir($directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::LEAVES_ONLY
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = file_get_contents($file->getPathname());
|
||||
if ($content === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract namespace and class name from file
|
||||
$namespace = '';
|
||||
if (preg_match('/^namespace\s+(.+?);/m', $content, $nsMatch)) {
|
||||
$namespace = $nsMatch[1];
|
||||
}
|
||||
|
||||
$className = '';
|
||||
if (preg_match('/^(?:final\s+|abstract\s+)?class\s+(\w+)/m', $content, $classMatch)) {
|
||||
$className = $classMatch[1];
|
||||
}
|
||||
|
||||
if ($className === '' || $namespace === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fqcn = $namespace . '\\' . $className;
|
||||
|
||||
if (!class_exists($fqcn)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reflection = new \ReflectionClass($fqcn);
|
||||
if ($reflection->isAbstract() || !$reflection->isSubclassOf(Command::class)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->register($reflection->newInstance());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $argv Raw CLI arguments (including script name at [0])
|
||||
*/
|
||||
public function run(array $argv): int
|
||||
{
|
||||
$commandName = $argv[1] ?? '';
|
||||
|
||||
if ($commandName === '' || $commandName === 'list' || $commandName === '--help') {
|
||||
$this->printCommandList();
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!isset($this->commands[$commandName])) {
|
||||
fwrite(STDERR, "Unknown command: {$commandName}\n\n");
|
||||
$this->printCommandList();
|
||||
return 1;
|
||||
}
|
||||
|
||||
[$args, $options] = self::parseArgv(array_slice($argv, 2));
|
||||
|
||||
// Per-command --help
|
||||
if ($options['help'] ?? false) {
|
||||
$command = $this->commands[$commandName];
|
||||
$usage = $command->usage();
|
||||
if ($usage !== '') {
|
||||
fwrite(STDOUT, $usage . "\n");
|
||||
} else {
|
||||
fwrite(STDOUT, sprintf("%s — %s\n", $command->name(), $command->description()));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $this->commands[$commandName]->execute($args, $options);
|
||||
}
|
||||
|
||||
private function printCommandList(): void
|
||||
{
|
||||
fwrite(STDOUT, "Usage: php bin/console <command> [arguments] [options]\n\n");
|
||||
fwrite(STDOUT, "Available commands:\n");
|
||||
|
||||
// Group by namespace prefix
|
||||
$grouped = [];
|
||||
foreach ($this->commands as $name => $command) {
|
||||
$parts = explode(':', $name, 2);
|
||||
$group = count($parts) === 2 ? $parts[0] : '';
|
||||
$grouped[$group][$name] = $command->description();
|
||||
}
|
||||
ksort($grouped);
|
||||
|
||||
foreach ($grouped as $group => $commands) {
|
||||
if ($group !== '') {
|
||||
fwrite(STDOUT, " {$group}\n");
|
||||
}
|
||||
ksort($commands);
|
||||
foreach ($commands as $name => $description) {
|
||||
fwrite(STDOUT, sprintf(" %-30s %s\n", $name, $description));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $tokens
|
||||
* @return array{list<string>, array<string, string|bool>}
|
||||
*/
|
||||
private static function parseArgv(array $tokens): array
|
||||
{
|
||||
$args = [];
|
||||
$options = [];
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
if (str_starts_with($token, '--')) {
|
||||
$option = substr($token, 2);
|
||||
if (str_contains($option, '=')) {
|
||||
[$key, $value] = explode('=', $option, 2);
|
||||
$options[$key] = $value;
|
||||
} else {
|
||||
$options[$option] = true;
|
||||
}
|
||||
} else {
|
||||
$args[] = $token;
|
||||
}
|
||||
}
|
||||
|
||||
return [$args, $options];
|
||||
}
|
||||
}
|
||||
317
core/Console/Runner/Doctor/DoctorRunner.php
Normal file
317
core/Console/Runner/Doctor/DoctorRunner.php
Normal file
@@ -0,0 +1,317 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MintyPHP\Console\Runner\Doctor;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Bootstrap\EnvValidator;
|
||||
use MintyPHP\Console\Support\CliAppBootstrap;
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Service\Access\AuthorizationService;
|
||||
use MintyPHP\Service\Access\PermissionService;
|
||||
use MintyPHP\Service\Access\UiAccessService;
|
||||
use MintyPHP\Service\Audit\AuditRecorderInterface;
|
||||
use MintyPHP\Service\Auth\AuthService;
|
||||
use Throwable;
|
||||
|
||||
final class DoctorRunner implements DoctorRunnerInterface
|
||||
{
|
||||
public function run(): array
|
||||
{
|
||||
error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED);
|
||||
ini_set('display_errors', '0');
|
||||
|
||||
/** @var AppContainer $container */
|
||||
$container = CliAppBootstrap::bootstrap('cli', 'bin/console doctor');
|
||||
$startedAt = microtime(true);
|
||||
|
||||
$results = [];
|
||||
$failCount = 0;
|
||||
$warnCount = 0;
|
||||
|
||||
$runCheck = static function (string $name, callable $check) use (&$results, &$failCount, &$warnCount): void {
|
||||
try {
|
||||
$result = $check();
|
||||
} catch (Throwable $throwable) {
|
||||
$result = [
|
||||
'status' => 'fail',
|
||||
'message' => $throwable->getMessage(),
|
||||
];
|
||||
}
|
||||
|
||||
$status = strtolower(trim((string) ($result['status'] ?? 'fail')));
|
||||
if (!in_array($status, ['ok', 'warn', 'fail'], true)) {
|
||||
$status = 'fail';
|
||||
}
|
||||
|
||||
$message = trim((string) ($result['message'] ?? ''));
|
||||
if ($message === '') {
|
||||
$message = 'no details';
|
||||
}
|
||||
|
||||
if ($status === 'fail') {
|
||||
$failCount++;
|
||||
} elseif ($status === 'warn') {
|
||||
$warnCount++;
|
||||
}
|
||||
|
||||
$results[] = [
|
||||
'status' => $status,
|
||||
'name' => $name,
|
||||
'message' => $message,
|
||||
];
|
||||
};
|
||||
|
||||
$runCheck('Environment validation', static function (): array {
|
||||
EnvValidator::validate();
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'message' => 'all required env keys and formats are valid',
|
||||
];
|
||||
});
|
||||
|
||||
$runCheck('App container bootstrap', static function () use ($container): array {
|
||||
if (!$container instanceof AppContainer) {
|
||||
return [
|
||||
'status' => 'fail',
|
||||
'message' => 'registerContainer.php did not return AppContainer',
|
||||
];
|
||||
}
|
||||
|
||||
app(AuthService::class);
|
||||
app(AuthorizationService::class);
|
||||
app(UiAccessService::class);
|
||||
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'message' => 'core services resolved successfully',
|
||||
];
|
||||
});
|
||||
|
||||
$runCheck('Database connectivity', static function (): array {
|
||||
$pong = DB::selectValue('select 1');
|
||||
if ((int) $pong !== 1) {
|
||||
return [
|
||||
'status' => 'fail',
|
||||
'message' => 'select 1 did not return expected value',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'message' => 'connection established',
|
||||
];
|
||||
});
|
||||
|
||||
$runCheck('Database schema basics', static function (): array {
|
||||
$requiredTables = [
|
||||
'users',
|
||||
'roles',
|
||||
'permissions',
|
||||
'user_roles',
|
||||
'role_permissions',
|
||||
'tenants',
|
||||
'departments',
|
||||
'settings',
|
||||
'scheduler_runtime_status',
|
||||
];
|
||||
|
||||
$rows = DB::select(
|
||||
'select table_name from information_schema.tables where table_schema = database() and table_name in (???)',
|
||||
$requiredTables
|
||||
);
|
||||
|
||||
$present = [];
|
||||
foreach ((array) $rows as $row) {
|
||||
$table = (string) ($row['tables']['table_name'] ?? $row['table_name'] ?? '');
|
||||
if ($table !== '') {
|
||||
$present[] = $table;
|
||||
}
|
||||
}
|
||||
$present = array_values(array_unique($present));
|
||||
sort($present, SORT_STRING);
|
||||
|
||||
$missing = array_values(array_diff($requiredTables, $present));
|
||||
if ($missing !== []) {
|
||||
return [
|
||||
'status' => 'fail',
|
||||
'message' => 'missing tables: ' . implode(', ', $missing),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'message' => sprintf('%d core tables present', count($requiredTables)),
|
||||
];
|
||||
});
|
||||
|
||||
$runCheck('Storage path writeability', static function (): array {
|
||||
$storagePath = defined('APP_STORAGE_PATH') && APP_STORAGE_PATH
|
||||
? rtrim((string) APP_STORAGE_PATH, '/')
|
||||
: rtrim(CliAppBootstrap::projectRoot() . '/storage', '/');
|
||||
|
||||
if (!is_dir($storagePath)) {
|
||||
return [
|
||||
'status' => 'fail',
|
||||
'message' => "storage directory not found: {$storagePath}",
|
||||
];
|
||||
}
|
||||
if (!is_writable($storagePath)) {
|
||||
return [
|
||||
'status' => 'fail',
|
||||
'message' => "storage directory not writable: {$storagePath}",
|
||||
];
|
||||
}
|
||||
|
||||
$probeFile = $storagePath . '/.doctor-write-probe-' . uniqid('', true);
|
||||
$written = @file_put_contents($probeFile, 'ok');
|
||||
if ($written === false) {
|
||||
return [
|
||||
'status' => 'fail',
|
||||
'message' => "write probe failed in: {$storagePath}",
|
||||
];
|
||||
}
|
||||
@unlink($probeFile);
|
||||
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'message' => "storage path is writable ({$storagePath})",
|
||||
];
|
||||
});
|
||||
|
||||
$runCheck('RBAC baseline permissions', static function (): array {
|
||||
$requiredPermissions = [
|
||||
PermissionService::USERS_VIEW,
|
||||
PermissionService::TENANTS_VIEW,
|
||||
PermissionService::DEPARTMENTS_VIEW,
|
||||
PermissionService::ROLES_VIEW,
|
||||
PermissionService::PERMISSIONS_VIEW,
|
||||
PermissionService::SETTINGS_VIEW,
|
||||
];
|
||||
|
||||
$rows = DB::select(
|
||||
'select `key` from permissions where active = 1 and `key` in (???)',
|
||||
$requiredPermissions
|
||||
);
|
||||
|
||||
$present = [];
|
||||
foreach ((array) $rows as $row) {
|
||||
$key = (string) ($row['permissions']['key'] ?? $row['key'] ?? '');
|
||||
if ($key !== '') {
|
||||
$present[] = $key;
|
||||
}
|
||||
}
|
||||
$present = array_values(array_unique($present));
|
||||
sort($present, SORT_STRING);
|
||||
|
||||
$missing = array_values(array_diff($requiredPermissions, $present));
|
||||
if ($missing !== []) {
|
||||
return [
|
||||
'status' => 'fail',
|
||||
'message' => 'missing active permissions: ' . implode(', ', $missing),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'message' => sprintf('%d baseline permissions active', count($requiredPermissions)),
|
||||
];
|
||||
});
|
||||
|
||||
$runCheck('Admin role assignment', static function (): array {
|
||||
$count = (int) (DB::selectValue(
|
||||
'select count(distinct ur.user_id) from user_roles ur join roles r on r.id = ur.role_id and r.active = 1 where r.description in (?, ?) or r.id = 1',
|
||||
'Admin',
|
||||
'Administrator'
|
||||
) ?? 0);
|
||||
|
||||
if ($count <= 0) {
|
||||
return [
|
||||
'status' => 'fail',
|
||||
'message' => 'no active user assigned to Admin/Administrator role',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'message' => sprintf('%d admin user(s) assigned', $count),
|
||||
];
|
||||
});
|
||||
|
||||
$runCheck('Scheduler heartbeat', static function (): array {
|
||||
$row = DB::selectOne('select last_heartbeat_at, last_result, last_error_code from scheduler_runtime_status where id = 1 limit 1');
|
||||
$status = is_array($row) ? ($row['scheduler_runtime_status'] ?? $row) : null;
|
||||
if (!is_array($status)) {
|
||||
return [
|
||||
'status' => 'warn',
|
||||
'message' => 'no scheduler runtime status row found yet',
|
||||
];
|
||||
}
|
||||
|
||||
$heartbeat = trim((string) ($status['last_heartbeat_at'] ?? ''));
|
||||
$result = trim((string) ($status['last_result'] ?? 'unknown'));
|
||||
$errorCode = trim((string) ($status['last_error_code'] ?? ''));
|
||||
if ($heartbeat === '') {
|
||||
return [
|
||||
'status' => 'warn',
|
||||
'message' => 'scheduler heartbeat is empty',
|
||||
];
|
||||
}
|
||||
|
||||
$seconds = time() - strtotime($heartbeat . ' UTC');
|
||||
if ($seconds < 0) {
|
||||
$seconds = 0;
|
||||
}
|
||||
|
||||
if ($seconds > 300) {
|
||||
return [
|
||||
'status' => 'warn',
|
||||
'message' => "last heartbeat {$seconds}s ago (result={$result}" . ($errorCode !== '' ? ", error={$errorCode}" : '') . ')',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'message' => "last heartbeat {$seconds}s ago (result={$result}" . ($errorCode !== '' ? ", error={$errorCode}" : '') . ')',
|
||||
];
|
||||
});
|
||||
|
||||
$okCount = count($results) - $warnCount - $failCount;
|
||||
$exitCode = $failCount > 0 ? 1 : 0;
|
||||
$status = $failCount > 0 ? 'fail' : ($warnCount > 0 ? 'warn' : 'ok');
|
||||
$durationMs = max(0, (int) round((microtime(true) - $startedAt) * 1000));
|
||||
|
||||
try {
|
||||
app(AuditRecorderInterface::class)->record(
|
||||
'cli.command',
|
||||
$exitCode === 0 ? 'success' : 'failed',
|
||||
[
|
||||
'metadata' => [
|
||||
'command' => 'bin/console doctor',
|
||||
'exit_code' => $exitCode,
|
||||
'duration_ms' => $durationMs,
|
||||
'result' => $status,
|
||||
'warn_count' => $warnCount,
|
||||
'fail_count' => $failCount,
|
||||
],
|
||||
]
|
||||
);
|
||||
} catch (Throwable) {
|
||||
// fail-open
|
||||
} finally {
|
||||
DB::close();
|
||||
}
|
||||
|
||||
return [
|
||||
'command' => 'doctor',
|
||||
'status' => $status,
|
||||
'checks' => $results,
|
||||
'ok_count' => $okCount,
|
||||
'warn_count' => $warnCount,
|
||||
'fail_count' => $failCount,
|
||||
'exit_code' => $exitCode,
|
||||
'duration_ms' => $durationMs,
|
||||
];
|
||||
}
|
||||
}
|
||||
22
core/Console/Runner/Doctor/DoctorRunnerInterface.php
Normal file
22
core/Console/Runner/Doctor/DoctorRunnerInterface.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MintyPHP\Console\Runner\Doctor;
|
||||
|
||||
interface DoctorRunnerInterface
|
||||
{
|
||||
/**
|
||||
* @return array{
|
||||
* command: string,
|
||||
* status: string,
|
||||
* checks: list<array{status: string, name: string, message: string}>,
|
||||
* ok_count: int,
|
||||
* warn_count: int,
|
||||
* fail_count: int,
|
||||
* exit_code: int,
|
||||
* duration_ms: int
|
||||
* }
|
||||
*/
|
||||
public function run(): array;
|
||||
}
|
||||
348
core/Console/Runner/Module/ModuleRunner.php
Normal file
348
core/Console/Runner/Module/ModuleRunner.php
Normal file
@@ -0,0 +1,348 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MintyPHP\Console\Runner\Module;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Module\Contracts\ModuleDeactivationHandler;
|
||||
use MintyPHP\App\Module\ModuleManifestLoader;
|
||||
use MintyPHP\App\Module\ModulePermissionSynchronizer;
|
||||
use MintyPHP\App\Module\ModuleRegistry;
|
||||
use MintyPHP\App\Module\ModuleRuntimeAssetPublisher;
|
||||
use MintyPHP\App\Module\ModuleRuntimePageBuilder;
|
||||
use MintyPHP\Console\Support\ModuleCliRuntime;
|
||||
use MintyPHP\Service\Module\ModuleMigrationService;
|
||||
|
||||
final class ModuleRunner implements ModuleRunnerInterface
|
||||
{
|
||||
public function migrate(): array
|
||||
{
|
||||
$summary = ['applied' => 0, 'modules_enabled' => 0, 'skipped_empty' => 0];
|
||||
$message = 'module-migrate: all modules up to date, 0 new migrations.';
|
||||
$step = ModuleCliRuntime::runStep('module-migrate', function () use (&$summary, &$message): int {
|
||||
$lockResult = ModuleCliRuntime::withFileLock(
|
||||
ModuleCliRuntime::projectRoot() . '/storage/runtime/.module-migrate.lock',
|
||||
'module-migrate: another migration run is in progress, skipping.',
|
||||
function () use (&$summary, &$message): int {
|
||||
/** @var ModuleMigrationService $service */
|
||||
$service = app(ModuleMigrationService::class);
|
||||
$result = $service->applyPendingMigrations();
|
||||
$summary = $result;
|
||||
|
||||
if ($result['modules_enabled'] === 0) {
|
||||
$message = 'module-migrate: no modules enabled, nothing to do.';
|
||||
} elseif ($result['applied'] === 0) {
|
||||
$message = 'module-migrate: all modules up to date, 0 new migrations.';
|
||||
} else {
|
||||
$message = sprintf('module-migrate: applied %d migration(s).', $result['applied']);
|
||||
}
|
||||
|
||||
if ($result['skipped_empty'] > 0) {
|
||||
$message .= sprintf(' (%d empty file(s) skipped)', $result['skipped_empty']);
|
||||
}
|
||||
|
||||
return $result['ok'] ? 0 : 1;
|
||||
}
|
||||
);
|
||||
|
||||
if ($lockResult['busy']) {
|
||||
$message = (string) $lockResult['message'];
|
||||
}
|
||||
|
||||
return $lockResult['exit_code'];
|
||||
});
|
||||
|
||||
return [
|
||||
'command' => 'module:migrate',
|
||||
'status' => $step['status'],
|
||||
'exit_code' => $step['exit_code'],
|
||||
'vendor_warnings_ignored' => $step['vendor_warnings_ignored'],
|
||||
'message' => $step['error'] ?? $message,
|
||||
];
|
||||
}
|
||||
|
||||
public function permissionsSync(): array
|
||||
{
|
||||
$sync = [
|
||||
'created' => 0,
|
||||
'updated' => 0,
|
||||
'unchanged' => 0,
|
||||
'deactivated' => 0,
|
||||
'total' => 0,
|
||||
];
|
||||
$message = 'module-permissions-sync: ok';
|
||||
|
||||
$step = ModuleCliRuntime::runStep('module-permissions-sync', function () use (&$sync, &$message): int {
|
||||
$lockResult = ModuleCliRuntime::withFileLock(
|
||||
ModuleCliRuntime::projectRoot() . '/storage/runtime/.module-permissions-sync.lock',
|
||||
'module-permissions-sync: another sync is in progress, skipping.',
|
||||
function () use (&$sync): int {
|
||||
/** @var ModulePermissionSynchronizer $synchronizer */
|
||||
$synchronizer = app(ModulePermissionSynchronizer::class);
|
||||
$sync = $synchronizer->sync();
|
||||
return 0;
|
||||
}
|
||||
);
|
||||
|
||||
if ($lockResult['busy']) {
|
||||
$message = (string) $lockResult['message'];
|
||||
}
|
||||
|
||||
return $lockResult['exit_code'];
|
||||
});
|
||||
|
||||
return [
|
||||
'command' => 'module:permissions-sync',
|
||||
'status' => $step['status'],
|
||||
'exit_code' => $step['exit_code'],
|
||||
'vendor_warnings_ignored' => $step['vendor_warnings_ignored'],
|
||||
'created' => (int) $sync['created'],
|
||||
'updated' => (int) $sync['updated'],
|
||||
'unchanged' => (int) $sync['unchanged'],
|
||||
'deactivated' => (int) $sync['deactivated'],
|
||||
'total' => (int) $sync['total'],
|
||||
'message' => $step['error'] ?? $message,
|
||||
];
|
||||
}
|
||||
|
||||
public function build(): array
|
||||
{
|
||||
$buildSummary = [
|
||||
'core_entries' => 0,
|
||||
'module_entries' => 0,
|
||||
];
|
||||
$message = 'module-build: ok';
|
||||
|
||||
$step = ModuleCliRuntime::runStep('module-build', function () use (&$buildSummary, &$message): int {
|
||||
$runtimeDir = ModuleCliRuntime::projectRoot() . '/storage/runtime/pages';
|
||||
$runtimeParent = dirname($runtimeDir);
|
||||
if (!is_dir($runtimeParent) && !mkdir($runtimeParent, 0775, true) && !is_dir($runtimeParent)) {
|
||||
$message = "Failed to create runtime directory: {$runtimeParent}";
|
||||
return 1;
|
||||
}
|
||||
|
||||
$lockResult = ModuleCliRuntime::withFileLock(
|
||||
ModuleCliRuntime::projectRoot() . '/storage/runtime/.module-build.lock',
|
||||
'module-build: another build is in progress, skipping.',
|
||||
function () use (&$buildSummary, $runtimeDir): int {
|
||||
/** @var ModuleRegistry $registry */
|
||||
$registry = app(ModuleRegistry::class);
|
||||
/** @var ModuleRuntimePageBuilder $builder */
|
||||
$builder = app(ModuleRuntimePageBuilder::class);
|
||||
$modules = $registry->getModules();
|
||||
$buildSummary = $builder->build(
|
||||
$runtimeDir,
|
||||
ModuleCliRuntime::projectRoot() . '/pages',
|
||||
$modules
|
||||
);
|
||||
ModuleRuntimePageBuilder::writeFingerprint($runtimeDir, $modules);
|
||||
return 0;
|
||||
}
|
||||
);
|
||||
|
||||
if ($lockResult['busy']) {
|
||||
$message = (string) $lockResult['message'];
|
||||
}
|
||||
|
||||
return $lockResult['exit_code'];
|
||||
});
|
||||
|
||||
return [
|
||||
'command' => 'module:build',
|
||||
'status' => $step['status'],
|
||||
'exit_code' => $step['exit_code'],
|
||||
'vendor_warnings_ignored' => $step['vendor_warnings_ignored'],
|
||||
'core_entries' => (int) $buildSummary['core_entries'],
|
||||
'module_entries' => (int) $buildSummary['module_entries'],
|
||||
'message' => $step['error'] ?? $message,
|
||||
];
|
||||
}
|
||||
|
||||
public function assetsSync(): array
|
||||
{
|
||||
$summary = [
|
||||
'created' => 0,
|
||||
'updated' => 0,
|
||||
'removed' => 0,
|
||||
'unchanged' => 0,
|
||||
];
|
||||
$mode = trim((string) getenv('APP_MODULE_ASSET_MODE'));
|
||||
if ($mode === '') {
|
||||
$mode = 'symlink';
|
||||
}
|
||||
$message = 'module-assets-sync: ok';
|
||||
|
||||
$step = ModuleCliRuntime::runStep('module-assets-sync', function () use (&$summary, &$message, $mode): int {
|
||||
$lockResult = ModuleCliRuntime::withFileLock(
|
||||
ModuleCliRuntime::projectRoot() . '/storage/runtime/.module-assets-sync.lock',
|
||||
'module-assets-sync: another sync is in progress, skipping.',
|
||||
function () use (&$summary, $mode): int {
|
||||
/** @var ModuleRegistry $registry */
|
||||
$registry = app(ModuleRegistry::class);
|
||||
/** @var ModuleRuntimeAssetPublisher $publisher */
|
||||
$publisher = app(ModuleRuntimeAssetPublisher::class);
|
||||
$summary = $publisher->publish(
|
||||
ModuleCliRuntime::projectRoot() . '/web/modules',
|
||||
$registry->getModules(),
|
||||
$mode
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
);
|
||||
|
||||
if ($lockResult['busy']) {
|
||||
$message = (string) $lockResult['message'];
|
||||
}
|
||||
|
||||
return $lockResult['exit_code'];
|
||||
});
|
||||
|
||||
return [
|
||||
'command' => 'module:assets-sync',
|
||||
'status' => $step['status'],
|
||||
'exit_code' => $step['exit_code'],
|
||||
'vendor_warnings_ignored' => $step['vendor_warnings_ignored'],
|
||||
'mode' => $mode,
|
||||
'created' => (int) $summary['created'],
|
||||
'updated' => (int) $summary['updated'],
|
||||
'removed' => (int) $summary['removed'],
|
||||
'unchanged' => (int) $summary['unchanged'],
|
||||
'message' => $step['error'] ?? $message,
|
||||
];
|
||||
}
|
||||
|
||||
public function deactivate(string $moduleId, bool $confirm, bool $dryRun): array
|
||||
{
|
||||
$message = '';
|
||||
|
||||
$step = ModuleCliRuntime::runStep('module-deactivate', function () use ($moduleId, $confirm, $dryRun, &$message): int {
|
||||
if ($moduleId === '') {
|
||||
$message = 'Error: module id is required.';
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!$confirm && !$dryRun) {
|
||||
$message = 'Error: must pass --confirm to execute or --dry-run to validate.';
|
||||
return 1;
|
||||
}
|
||||
|
||||
$manifest = ModuleManifestLoader::loadFromDisk(
|
||||
ModuleCliRuntime::projectRoot() . '/modules',
|
||||
$moduleId
|
||||
);
|
||||
|
||||
$handlerClass = $manifest->deactivationHandler;
|
||||
if ($handlerClass === null) {
|
||||
$message = "Module '{$moduleId}' has no deactivation_handler declared — nothing to do.";
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!class_exists($handlerClass)) {
|
||||
$message = "Error: deactivation handler class '{$handlerClass}' not found.";
|
||||
return 1;
|
||||
}
|
||||
|
||||
$container = $GLOBALS['minty_app_container'] ?? null;
|
||||
if (!$container instanceof AppContainer) {
|
||||
$message = 'Error: app container is not initialized.';
|
||||
return 1;
|
||||
}
|
||||
$handler = $container->has($handlerClass)
|
||||
? $container->get($handlerClass)
|
||||
: new $handlerClass();
|
||||
|
||||
if (!$handler instanceof ModuleDeactivationHandler) {
|
||||
$message = "Error: '{$handlerClass}' does not implement ModuleDeactivationHandler.";
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$message = "Dry-run: handler '{$handlerClass}' found and valid for module '{$moduleId}'.";
|
||||
return 0;
|
||||
}
|
||||
|
||||
$handler->deactivate($container);
|
||||
$message = "Deactivation handler for module '{$moduleId}' completed.";
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return [
|
||||
'command' => 'module:deactivate',
|
||||
'status' => $step['status'],
|
||||
'exit_code' => $step['exit_code'],
|
||||
'vendor_warnings_ignored' => $step['vendor_warnings_ignored'],
|
||||
'message' => $step['error'] ?? $message,
|
||||
];
|
||||
}
|
||||
|
||||
public function runtimeSync(): array
|
||||
{
|
||||
$startedAt = microtime(true);
|
||||
$steps = [
|
||||
'migrate' => ['status' => 'pending', 'exit_code' => 0, 'vendor_warnings_ignored' => 0],
|
||||
'permissions-sync' => ['status' => 'pending', 'exit_code' => 0, 'vendor_warnings_ignored' => 0],
|
||||
'build' => ['status' => 'pending', 'exit_code' => 0, 'vendor_warnings_ignored' => 0],
|
||||
'assets-sync' => ['status' => 'pending', 'exit_code' => 0, 'vendor_warnings_ignored' => 0],
|
||||
];
|
||||
$message = '';
|
||||
|
||||
$step = ModuleCliRuntime::runStep('module-runtime-sync', function () use (&$steps, &$message): int {
|
||||
$lockResult = ModuleCliRuntime::withFileLock(
|
||||
ModuleCliRuntime::projectRoot() . '/storage/runtime/.module-runtime-sync.lock',
|
||||
'module-runtime-sync: another runtime sync is in progress, skipping.',
|
||||
function () use (&$steps): int {
|
||||
$orderedSteps = [
|
||||
'migrate' => fn (): array => $this->migrate(),
|
||||
'permissions-sync' => fn (): array => $this->permissionsSync(),
|
||||
'build' => fn (): array => $this->build(),
|
||||
'assets-sync' => fn (): array => $this->assetsSync(),
|
||||
];
|
||||
|
||||
foreach ($orderedSteps as $stepName => $runner) {
|
||||
$result = $runner();
|
||||
$steps[$stepName] = [
|
||||
'status' => $result['status'],
|
||||
'exit_code' => $result['exit_code'],
|
||||
'vendor_warnings_ignored' => $result['vendor_warnings_ignored'],
|
||||
];
|
||||
|
||||
if ($result['exit_code'] !== 0) {
|
||||
return $result['exit_code'];
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
);
|
||||
|
||||
if ($lockResult['busy']) {
|
||||
$message = (string) $lockResult['message'];
|
||||
}
|
||||
|
||||
return $lockResult['exit_code'];
|
||||
});
|
||||
|
||||
if ($message === '') {
|
||||
$message = sprintf(
|
||||
'module-runtime-sync: summary migrate=%s permissions-sync=%s build=%s assets-sync=%s vendor_warnings_ignored=%d',
|
||||
$steps['migrate']['status'],
|
||||
$steps['permissions-sync']['status'],
|
||||
$steps['build']['status'],
|
||||
$steps['assets-sync']['status'],
|
||||
$step['vendor_warnings_ignored']
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'command' => 'module:sync',
|
||||
'status' => $step['status'],
|
||||
'exit_code' => $step['exit_code'],
|
||||
'vendor_warnings_ignored_total' => $step['vendor_warnings_ignored'],
|
||||
'duration_ms' => max(0, (int) round((microtime(true) - $startedAt) * 1000)),
|
||||
'steps' => $steps,
|
||||
'message' => $step['error'] ?? $message,
|
||||
];
|
||||
}
|
||||
}
|
||||
82
core/Console/Runner/Module/ModuleRunnerInterface.php
Normal file
82
core/Console/Runner/Module/ModuleRunnerInterface.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MintyPHP\Console\Runner\Module;
|
||||
|
||||
interface ModuleRunnerInterface
|
||||
{
|
||||
/**
|
||||
* @return array{command: string, status: string, exit_code: int, vendor_warnings_ignored: int, message: string}
|
||||
*/
|
||||
public function migrate(): array;
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* command: string,
|
||||
* status: string,
|
||||
* exit_code: int,
|
||||
* vendor_warnings_ignored: int,
|
||||
* created: int,
|
||||
* updated: int,
|
||||
* unchanged: int,
|
||||
* deactivated: int,
|
||||
* total: int,
|
||||
* message: string
|
||||
* }
|
||||
*/
|
||||
public function permissionsSync(): array;
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* command: string,
|
||||
* status: string,
|
||||
* exit_code: int,
|
||||
* vendor_warnings_ignored: int,
|
||||
* core_entries: int,
|
||||
* module_entries: int,
|
||||
* message: string
|
||||
* }
|
||||
*/
|
||||
public function build(): array;
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* command: string,
|
||||
* status: string,
|
||||
* exit_code: int,
|
||||
* vendor_warnings_ignored: int,
|
||||
* mode: string,
|
||||
* created: int,
|
||||
* updated: int,
|
||||
* removed: int,
|
||||
* unchanged: int,
|
||||
* message: string
|
||||
* }
|
||||
*/
|
||||
public function assetsSync(): array;
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* command: string,
|
||||
* status: string,
|
||||
* exit_code: int,
|
||||
* vendor_warnings_ignored: int,
|
||||
* message: string
|
||||
* }
|
||||
*/
|
||||
public function deactivate(string $moduleId, bool $confirm, bool $dryRun): array;
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* command: string,
|
||||
* status: string,
|
||||
* exit_code: int,
|
||||
* vendor_warnings_ignored_total: int,
|
||||
* duration_ms: int,
|
||||
* steps: array<string, array{status: string, exit_code: int, vendor_warnings_ignored: int}>,
|
||||
* message: string
|
||||
* }
|
||||
*/
|
||||
public function runtimeSync(): array;
|
||||
}
|
||||
55
core/Console/Support/CliAppBootstrap.php
Normal file
55
core/Console/Support/CliAppBootstrap.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MintyPHP\Console\Support;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\Http\RequestContext;
|
||||
use RuntimeException;
|
||||
|
||||
final class CliAppBootstrap
|
||||
{
|
||||
private static bool $booted = false;
|
||||
private static ?AppContainer $container = null;
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public static function projectRoot(): string
|
||||
{
|
||||
return dirname(__DIR__, 3);
|
||||
}
|
||||
|
||||
public static function bootstrap(string $channel = 'cli', string $path = 'bin/cli'): AppContainer
|
||||
{
|
||||
if (!self::$booted) {
|
||||
$projectRoot = self::projectRoot();
|
||||
chdir($projectRoot);
|
||||
|
||||
require_once $projectRoot . '/vendor/autoload.php';
|
||||
require_once $projectRoot . '/config/config.php';
|
||||
require_once $projectRoot . '/lib/Support/helpers.php';
|
||||
|
||||
/** @var AppContainer $container */
|
||||
$container = require $projectRoot . '/lib/App/registerContainer.php';
|
||||
setAppContainer($container);
|
||||
|
||||
self::$container = $container;
|
||||
self::$booted = true;
|
||||
}
|
||||
|
||||
RequestContext::start([
|
||||
'channel' => $channel,
|
||||
'method' => 'CLI',
|
||||
'path' => $path,
|
||||
]);
|
||||
|
||||
if (!self::$container instanceof AppContainer) {
|
||||
throw new RuntimeException('CLI app container bootstrap failed.');
|
||||
}
|
||||
|
||||
return self::$container;
|
||||
}
|
||||
}
|
||||
173
core/Console/Support/ModuleCliRuntime.php
Normal file
173
core/Console/Support/ModuleCliRuntime.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MintyPHP\Console\Support;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class ModuleCliRuntime
|
||||
{
|
||||
private static bool $booted = false;
|
||||
private static bool $errorPolicyInstalled = false;
|
||||
private static int $vendorWarningsIgnored = 0;
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public static function bootstrap(): void
|
||||
{
|
||||
if (self::$booted) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::installErrorPolicy();
|
||||
|
||||
$projectRoot = CliAppBootstrap::projectRoot();
|
||||
chdir($projectRoot);
|
||||
|
||||
require_once $projectRoot . '/vendor/autoload.php';
|
||||
require_once $projectRoot . '/config/config.php';
|
||||
require_once $projectRoot . '/lib/Support/helpers.php';
|
||||
|
||||
$container = require $projectRoot . '/lib/App/registerContainer.php';
|
||||
setAppContainer($container);
|
||||
|
||||
self::$booted = true;
|
||||
}
|
||||
|
||||
public static function projectRoot(): string
|
||||
{
|
||||
return CliAppBootstrap::projectRoot();
|
||||
}
|
||||
|
||||
public static function vendorWarningsIgnoredTotal(): int
|
||||
{
|
||||
return self::$vendorWarningsIgnored;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable():int $runner
|
||||
* @return array{step: string, status: string, exit_code: int, vendor_warnings_ignored: int, error: string|null}
|
||||
*/
|
||||
public static function runStep(string $stepName, callable $runner): array
|
||||
{
|
||||
$warningsBefore = self::vendorWarningsIgnoredTotal();
|
||||
self::bootstrap();
|
||||
|
||||
$exitCode = 0;
|
||||
$error = null;
|
||||
try {
|
||||
$exitCode = $runner();
|
||||
} catch (Throwable $throwable) {
|
||||
$exitCode = 1;
|
||||
$error = $throwable->getMessage();
|
||||
}
|
||||
|
||||
return [
|
||||
'step' => $stepName,
|
||||
'status' => $exitCode === 0 ? 'ok' : 'failed',
|
||||
'exit_code' => $exitCode,
|
||||
'vendor_warnings_ignored' => max(0, self::vendorWarningsIgnoredTotal() - $warningsBefore),
|
||||
'error' => $error,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable():int $runner
|
||||
* @return array{busy: bool, message: string|null, exit_code: int}
|
||||
*/
|
||||
public static function withFileLock(string $lockFile, string $busyMessage, callable $runner): array
|
||||
{
|
||||
$lockDir = dirname($lockFile);
|
||||
if (!is_dir($lockDir) && !mkdir($lockDir, 0775, true) && !is_dir($lockDir)) {
|
||||
throw new RuntimeException("Failed to create lock directory: {$lockDir}");
|
||||
}
|
||||
|
||||
$lock = fopen($lockFile, 'c');
|
||||
if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
|
||||
if (is_resource($lock)) {
|
||||
fclose($lock);
|
||||
}
|
||||
return [
|
||||
'busy' => true,
|
||||
'message' => $busyMessage,
|
||||
'exit_code' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
return [
|
||||
'busy' => false,
|
||||
'message' => null,
|
||||
'exit_code' => $runner(),
|
||||
];
|
||||
} finally {
|
||||
flock($lock, LOCK_UN);
|
||||
fclose($lock);
|
||||
}
|
||||
}
|
||||
|
||||
private static function installErrorPolicy(): void
|
||||
{
|
||||
if (self::$errorPolicyInstalled) {
|
||||
return;
|
||||
}
|
||||
self::$errorPolicyInstalled = true;
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '0');
|
||||
|
||||
set_error_handler(static function (
|
||||
int $severity,
|
||||
string $message,
|
||||
string $file = '',
|
||||
int $line = 0
|
||||
): bool {
|
||||
$handledSeverities = [
|
||||
E_WARNING,
|
||||
E_NOTICE,
|
||||
E_USER_WARNING,
|
||||
E_USER_NOTICE,
|
||||
E_DEPRECATED,
|
||||
E_USER_DEPRECATED,
|
||||
];
|
||||
if (!in_array($severity, $handledSeverities, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((error_reporting() & $severity) === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$normalizedFile = str_replace('\\', '/', $file);
|
||||
if (str_contains($normalizedFile, '/vendor/')) {
|
||||
self::$vendorWarningsIgnored++;
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new RuntimeException(sprintf(
|
||||
'First-party runtime warning (%s) in %s:%d: %s',
|
||||
self::severityName($severity),
|
||||
$file,
|
||||
$line,
|
||||
$message
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
private static function severityName(int $severity): string
|
||||
{
|
||||
return match ($severity) {
|
||||
E_WARNING => 'E_WARNING',
|
||||
E_NOTICE => 'E_NOTICE',
|
||||
E_USER_WARNING => 'E_USER_WARNING',
|
||||
E_USER_NOTICE => 'E_USER_NOTICE',
|
||||
E_DEPRECATED => 'E_DEPRECATED',
|
||||
E_USER_DEPRECATED => 'E_USER_DEPRECATED',
|
||||
default => 'E_' . $severity,
|
||||
};
|
||||
}
|
||||
}
|
||||
30
core/Domain/Taxonomy/MailLogStatus.php
Normal file
30
core/Domain/Taxonomy/MailLogStatus.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Domain\Taxonomy;
|
||||
|
||||
enum MailLogStatus: string
|
||||
{
|
||||
use SupportsStringTaxonomy;
|
||||
|
||||
case Queued = 'queued';
|
||||
case Sent = 'sent';
|
||||
case Failed = 'failed';
|
||||
|
||||
public function labelToken(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Queued => 'Queued',
|
||||
self::Sent => 'Sent',
|
||||
self::Failed => 'Failed',
|
||||
};
|
||||
}
|
||||
|
||||
public function badgeVariant(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Queued => 'info',
|
||||
self::Sent => 'success',
|
||||
self::Failed => 'danger',
|
||||
};
|
||||
}
|
||||
}
|
||||
30
core/Domain/Taxonomy/ScheduledJobRunStatus.php
Normal file
30
core/Domain/Taxonomy/ScheduledJobRunStatus.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Domain\Taxonomy;
|
||||
|
||||
enum ScheduledJobRunStatus: string
|
||||
{
|
||||
use SupportsStringTaxonomy;
|
||||
|
||||
case Success = 'success';
|
||||
case Failed = 'failed';
|
||||
case Skipped = 'skipped';
|
||||
|
||||
public function labelToken(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Success => 'Success',
|
||||
self::Failed => 'Failed',
|
||||
self::Skipped => 'Skipped',
|
||||
};
|
||||
}
|
||||
|
||||
public function badgeVariant(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Success => 'success',
|
||||
self::Failed => 'danger',
|
||||
self::Skipped => 'warning',
|
||||
};
|
||||
}
|
||||
}
|
||||
33
core/Domain/Taxonomy/ScheduledJobStatus.php
Normal file
33
core/Domain/Taxonomy/ScheduledJobStatus.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Domain\Taxonomy;
|
||||
|
||||
enum ScheduledJobStatus: string
|
||||
{
|
||||
use SupportsStringTaxonomy;
|
||||
|
||||
case Running = 'running';
|
||||
case Success = 'success';
|
||||
case Failed = 'failed';
|
||||
case Skipped = 'skipped';
|
||||
|
||||
public function labelToken(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Running => 'Running',
|
||||
self::Success => 'Success',
|
||||
self::Failed => 'Failed',
|
||||
self::Skipped => 'Skipped',
|
||||
};
|
||||
}
|
||||
|
||||
public function badgeVariant(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Running => 'info',
|
||||
self::Success => 'success',
|
||||
self::Failed => 'danger',
|
||||
self::Skipped => 'warning',
|
||||
};
|
||||
}
|
||||
}
|
||||
27
core/Domain/Taxonomy/ScheduledJobTriggerType.php
Normal file
27
core/Domain/Taxonomy/ScheduledJobTriggerType.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Domain\Taxonomy;
|
||||
|
||||
enum ScheduledJobTriggerType: string
|
||||
{
|
||||
use SupportsStringTaxonomy;
|
||||
|
||||
case Scheduler = 'scheduler';
|
||||
case Manual = 'manual';
|
||||
|
||||
public function labelToken(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Scheduler => 'Scheduler',
|
||||
self::Manual => 'Manual',
|
||||
};
|
||||
}
|
||||
|
||||
public function badgeVariant(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Scheduler => 'neutral',
|
||||
self::Manual => 'info',
|
||||
};
|
||||
}
|
||||
}
|
||||
30
core/Domain/Taxonomy/SchedulerRuntimeResult.php
Normal file
30
core/Domain/Taxonomy/SchedulerRuntimeResult.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Domain\Taxonomy;
|
||||
|
||||
enum SchedulerRuntimeResult: string
|
||||
{
|
||||
use SupportsStringTaxonomy;
|
||||
|
||||
case Ok = 'ok';
|
||||
case LockNotAcquired = 'lock_not_acquired';
|
||||
case UnexpectedError = 'unexpected_error';
|
||||
|
||||
public function labelToken(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Ok => 'OK',
|
||||
self::LockNotAcquired => 'Lock not acquired',
|
||||
self::UnexpectedError => 'Unexpected error',
|
||||
};
|
||||
}
|
||||
|
||||
public function badgeVariant(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Ok => 'success',
|
||||
self::LockNotAcquired => 'warning',
|
||||
self::UnexpectedError => 'danger',
|
||||
};
|
||||
}
|
||||
}
|
||||
38
core/Domain/Taxonomy/SupportsStringTaxonomy.php
Normal file
38
core/Domain/Taxonomy/SupportsStringTaxonomy.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Domain\Taxonomy;
|
||||
|
||||
trait SupportsStringTaxonomy
|
||||
{
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (self $case): string => $case->value,
|
||||
self::cases()
|
||||
);
|
||||
}
|
||||
|
||||
public static function tryNormalize(string $value): ?self
|
||||
{
|
||||
$value = strtolower(trim($value));
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (self::cases() as $case) {
|
||||
if (strtolower($case->value) === $value) {
|
||||
return $case;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function normalizeOr(string $value, self $fallback): self
|
||||
{
|
||||
return self::tryNormalize($value) ?? $fallback;
|
||||
}
|
||||
}
|
||||
27
core/Domain/Taxonomy/TenantStatus.php
Normal file
27
core/Domain/Taxonomy/TenantStatus.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Domain\Taxonomy;
|
||||
|
||||
enum TenantStatus: string
|
||||
{
|
||||
use SupportsStringTaxonomy;
|
||||
|
||||
case Active = 'active';
|
||||
case Inactive = 'inactive';
|
||||
|
||||
public function labelToken(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Active => 'Active',
|
||||
self::Inactive => 'Inactive',
|
||||
};
|
||||
}
|
||||
|
||||
public function badgeVariant(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Active => 'success',
|
||||
self::Inactive => 'danger',
|
||||
};
|
||||
}
|
||||
}
|
||||
100
core/Http/AccessControl.php
Normal file
100
core/Http/AccessControl.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\I18n;
|
||||
use MintyPHP\Router;
|
||||
|
||||
/**
|
||||
* Handles public path detection and authentication guards.
|
||||
*/
|
||||
class AccessControl
|
||||
{
|
||||
/** Prefixes that are always public */
|
||||
private const ALWAYS_PUBLIC_PREFIXES = [
|
||||
'branding/',
|
||||
'auth/tenant-avatar-file',
|
||||
'flash/',
|
||||
'auth/microsoft/',
|
||||
'api/',
|
||||
];
|
||||
|
||||
private array $configuredPublicPaths;
|
||||
private IntendedUrlService $intendedUrlService;
|
||||
|
||||
public function __construct(array $publicPaths, IntendedUrlService $intendedUrlService)
|
||||
{
|
||||
$normalizedPaths = [];
|
||||
foreach ($publicPaths as $path) {
|
||||
$rawPath = trim((string) $path);
|
||||
if ($rawPath === '') {
|
||||
continue;
|
||||
}
|
||||
$normalizedPaths[] = $rawPath === '/' ? '/' : trim($rawPath, '/');
|
||||
}
|
||||
|
||||
$this->configuredPublicPaths = array_values(array_unique($normalizedPaths));
|
||||
$this->intendedUrlService = $intendedUrlService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is publicly accessible (no authentication required).
|
||||
*/
|
||||
public function isPublicPath(string $path): bool
|
||||
{
|
||||
$normalizedPath = $this->normalizePath($path);
|
||||
|
||||
// Root path check
|
||||
if ($normalizedPath === '/') {
|
||||
return in_array('/', $this->configuredPublicPaths, true);
|
||||
}
|
||||
|
||||
// Check configured public paths
|
||||
if (in_array($normalizedPath, $this->configuredPublicPaths, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check always-public prefixes
|
||||
foreach (self::ALWAYS_PUBLIC_PREFIXES as $prefix) {
|
||||
if (str_starts_with($normalizedPath, $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to login if the path requires authentication and user is not logged in.
|
||||
*
|
||||
* @return bool True if access is allowed, false if redirected
|
||||
*/
|
||||
/**
|
||||
* Redirect to login if the path requires authentication and user is not logged in.
|
||||
* Appends ?return_to= so the user is redirected back after login.
|
||||
*
|
||||
* @return bool True if access is allowed, false if redirected
|
||||
*/
|
||||
public function requireAuthOrRedirect(string $path, bool $isLoggedIn): bool
|
||||
{
|
||||
if ($this->isPublicPath($path) || $isLoggedIn) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$locale = I18n::$locale ?? I18n::$defaultLocale;
|
||||
$loginPath = Request::withLocale('login', $locale);
|
||||
|
||||
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
|
||||
$loginTarget = $this->intendedUrlService->buildLoginRedirect($loginPath, $requestUri);
|
||||
|
||||
Router::redirect($loginTarget);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function normalizePath(string $path): string
|
||||
{
|
||||
$normalized = trim($path, '/');
|
||||
return $normalized === '' ? '/' : $normalized;
|
||||
}
|
||||
}
|
||||
286
core/Http/ApiAuth.php
Normal file
286
core/Http/ApiAuth.php
Normal file
@@ -0,0 +1,286 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\Repository\Access\RolePermissionRepository;
|
||||
use MintyPHP\Repository\Access\UserRoleRepository;
|
||||
use MintyPHP\Service\Access\AuthorizationService;
|
||||
use MintyPHP\Service\Auth\ApiTokenService;
|
||||
use MintyPHP\Service\Tenant\TenantScopeService;
|
||||
use MintyPHP\Service\User\UserTenantContextService;
|
||||
|
||||
// Static facade for API authentication — state is set once per request during ApiBootstrap::init().
|
||||
// Dependencies are injected as lazy resolvers so services are only instantiated when actually needed.
|
||||
class ApiAuth
|
||||
{
|
||||
/** @var (callable(): TenantScopeService)|null */
|
||||
private static $authScopeGatewayResolver = null;
|
||||
|
||||
/** @var (callable(): ApiTokenService)|null */
|
||||
private static $apiTokenServiceResolver = null;
|
||||
|
||||
/** @var (callable(): UserRoleRepository)|null */
|
||||
private static $userRoleRepositoryResolver = null;
|
||||
|
||||
/** @var (callable(): RolePermissionRepository)|null */
|
||||
private static $rolePermissionRepositoryResolver = null;
|
||||
|
||||
/** @var (callable(): UserTenantContextService)|null */
|
||||
private static $userTenantContextServiceResolver = null;
|
||||
/** @var (callable(): AuthorizationService)|null */
|
||||
private static $authorizationServiceResolver = null;
|
||||
|
||||
private static ?array $currentUser = null;
|
||||
private static ?array $currentPermissions = null;
|
||||
private static ?int $currentTenantId = null;
|
||||
private static ?int $tokenTenantId = null;
|
||||
private static ?array $currentTokenRecord = null;
|
||||
private static bool $authenticated = false;
|
||||
|
||||
public static function configure(
|
||||
callable $authScopeGatewayResolver,
|
||||
callable $apiTokenServiceResolver,
|
||||
callable $userRoleRepositoryResolver,
|
||||
callable $rolePermissionRepositoryResolver,
|
||||
callable $userTenantContextServiceResolver,
|
||||
callable $authorizationServiceResolver
|
||||
): void {
|
||||
self::$authScopeGatewayResolver = $authScopeGatewayResolver;
|
||||
self::$apiTokenServiceResolver = $apiTokenServiceResolver;
|
||||
self::$userRoleRepositoryResolver = $userRoleRepositoryResolver;
|
||||
self::$rolePermissionRepositoryResolver = $rolePermissionRepositoryResolver;
|
||||
self::$userTenantContextServiceResolver = $userTenantContextServiceResolver;
|
||||
self::$authorizationServiceResolver = $authorizationServiceResolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the full Bearer token from the Authorization header.
|
||||
*/
|
||||
public static function extractBearerToken(): string
|
||||
{
|
||||
$header = trim((string) ($_SERVER['HTTP_AUTHORIZATION'] ?? ''));
|
||||
if ($header === '') {
|
||||
// Nginx with PHP-FPM may expose the header under this key instead
|
||||
$header = trim((string) ($_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? ''));
|
||||
}
|
||||
if ($header === '' || stripos($header, 'Bearer ') !== 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return trim(substr($header, 7));
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to authenticate from Authorization: Bearer header.
|
||||
*/
|
||||
public static function authenticate(): bool
|
||||
{
|
||||
self::$currentUser = null;
|
||||
self::$currentPermissions = null;
|
||||
self::$currentTenantId = null;
|
||||
self::$tokenTenantId = null;
|
||||
self::$currentTokenRecord = null;
|
||||
self::$authenticated = false;
|
||||
|
||||
$bearerToken = self::extractBearerToken();
|
||||
if ($bearerToken === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$scopeGateway = self::scopeGateway();
|
||||
$result = self::apiTokenService()->validate($bearerToken);
|
||||
if ($result === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = $result['user'];
|
||||
$userId = (int) ($user['id'] ?? 0);
|
||||
$userRoleRepository = self::userRoleRepository();
|
||||
$rolePermissionRepository = self::rolePermissionRepository();
|
||||
$userTenantContextService = self::userTenantContextService();
|
||||
|
||||
// Load permissions directly (bypass session cache)
|
||||
$roleIds = $userRoleRepository->listRoleIdsByUserId($userId);
|
||||
$permissions = $rolePermissionRepository->listPermissionKeysByRoleIds($roleIds);
|
||||
|
||||
// Resolve tenant context: a token may be scoped to a specific tenant,
|
||||
// otherwise fall back to the user's active tenant context.
|
||||
$tokenTenantId = $result['tenant_id'];
|
||||
if ($tokenTenantId !== null) {
|
||||
// Verify the token's tenant is still assigned to this user
|
||||
$userTenantIds = $scopeGateway->getUserTenantIds($userId);
|
||||
if (!in_array($tokenTenantId, $userTenantIds, true)) {
|
||||
return false;
|
||||
}
|
||||
$currentTenantId = $tokenTenantId;
|
||||
self::$tokenTenantId = $tokenTenantId;
|
||||
} else {
|
||||
$currentTenantId = $userTenantContextService->getCurrentTenantId($userId);
|
||||
self::$tokenTenantId = null;
|
||||
}
|
||||
|
||||
self::$currentUser = $user;
|
||||
self::$currentPermissions = $permissions;
|
||||
self::$currentTenantId = $currentTenantId;
|
||||
self::$currentTokenRecord = $result['token_record'];
|
||||
self::$authenticated = true;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function isAuthenticated(): bool
|
||||
{
|
||||
return self::$authenticated;
|
||||
}
|
||||
|
||||
public static function user(): ?array
|
||||
{
|
||||
return self::$currentUser;
|
||||
}
|
||||
|
||||
public static function userId(): int
|
||||
{
|
||||
return (int) (self::$currentUser['id'] ?? 0);
|
||||
}
|
||||
|
||||
public static function permissions(): array
|
||||
{
|
||||
return self::$currentPermissions ?? [];
|
||||
}
|
||||
|
||||
public static function can(string $ability, array $context = []): bool
|
||||
{
|
||||
$decision = self::authorizationService()->authorize($ability, [
|
||||
'actor_user_id' => self::userId(),
|
||||
'scoped_tenant_id' => self::scopedTenantId(),
|
||||
...$context,
|
||||
]);
|
||||
return $decision->isAllowed();
|
||||
}
|
||||
|
||||
// Effective tenant for data filtering — may come from token scope or user session.
|
||||
public static function tenantId(): ?int
|
||||
{
|
||||
return self::$currentTenantId;
|
||||
}
|
||||
|
||||
// Non-null only when the token itself was issued for a specific tenant.
|
||||
// Used to restrict access in token-scoped requests (e.g. tenant integrations).
|
||||
public static function scopedTenantId(): ?int
|
||||
{
|
||||
return self::$tokenTenantId;
|
||||
}
|
||||
|
||||
public static function tokenRecord(): ?array
|
||||
{
|
||||
return self::$currentTokenRecord;
|
||||
}
|
||||
|
||||
public static function tokenId(): ?int
|
||||
{
|
||||
$id = (int) (self::$currentTokenRecord['id'] ?? 0);
|
||||
return $id > 0 ? $id : null;
|
||||
}
|
||||
|
||||
public static function isTenantScopedToken(): bool
|
||||
{
|
||||
return (self::$tokenTenantId ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require both tenant-scope and token-tenant access for a resource.
|
||||
*/
|
||||
public static function requireResourceAccess(string $resource, int $resourceId): void
|
||||
{
|
||||
$scopeGateway = self::scopeGateway();
|
||||
if (!$scopeGateway->canAccess($resource, $resourceId, self::userId())) {
|
||||
ApiResponse::notFound();
|
||||
}
|
||||
self::requireTokenTenantAccess($resource, $resourceId);
|
||||
}
|
||||
|
||||
public static function requireTokenTenantAccess(string $resource, int $resourceId): void
|
||||
{
|
||||
if (!self::isTenantScopedToken()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tenantId = (int) (self::$tokenTenantId ?? 0);
|
||||
if ($tenantId <= 0) {
|
||||
ApiResponse::forbidden();
|
||||
}
|
||||
|
||||
$scopeGateway = self::scopeGateway();
|
||||
if (!$scopeGateway->resourceBelongsToTenant($resource, $resourceId, $tenantId)) {
|
||||
// 404 instead of 403 — don't reveal that the resource exists in another tenant
|
||||
ApiResponse::notFound();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user can self-manage API tokens.
|
||||
*/
|
||||
public static function canSelfManageTokens(): bool
|
||||
{
|
||||
$decision = self::authorizationService()->authorize(
|
||||
\MintyPHP\Service\Access\OperationsAuthorizationPolicy::ABILITY_API_TOKENS_SELF_MANAGE,
|
||||
[
|
||||
'actor_user_id' => self::userId(),
|
||||
'scoped_tenant_id' => self::scopedTenantId(),
|
||||
]
|
||||
);
|
||||
return $decision->isAllowed();
|
||||
}
|
||||
|
||||
public static function requireSelfManageTokens(): void
|
||||
{
|
||||
if (!self::canSelfManageTokens()) {
|
||||
ApiResponse::forbidden('api_tokens_self_manage_forbidden');
|
||||
}
|
||||
}
|
||||
|
||||
private static function scopeGateway(): TenantScopeService
|
||||
{
|
||||
return self::resolveDependency(self::$authScopeGatewayResolver, TenantScopeService::class, 'ApiAuth');
|
||||
}
|
||||
|
||||
private static function apiTokenService(): ApiTokenService
|
||||
{
|
||||
return self::resolveDependency(self::$apiTokenServiceResolver, ApiTokenService::class, 'ApiAuth');
|
||||
}
|
||||
|
||||
private static function userRoleRepository(): UserRoleRepository
|
||||
{
|
||||
return self::resolveDependency(self::$userRoleRepositoryResolver, UserRoleRepository::class, 'ApiAuth');
|
||||
}
|
||||
|
||||
private static function rolePermissionRepository(): RolePermissionRepository
|
||||
{
|
||||
return self::resolveDependency(self::$rolePermissionRepositoryResolver, RolePermissionRepository::class, 'ApiAuth');
|
||||
}
|
||||
|
||||
private static function userTenantContextService(): UserTenantContextService
|
||||
{
|
||||
return self::resolveDependency(self::$userTenantContextServiceResolver, UserTenantContextService::class, 'ApiAuth');
|
||||
}
|
||||
|
||||
private static function authorizationService(): AuthorizationService
|
||||
{
|
||||
return self::resolveDependency(self::$authorizationServiceResolver, AuthorizationService::class, 'ApiAuth');
|
||||
}
|
||||
|
||||
private static function resolveDependency(mixed $resolver, string $expectedClass, string $consumer): mixed
|
||||
{
|
||||
if (!is_callable($resolver)) {
|
||||
throw new \RuntimeException($consumer . ' is not configured for dependency: ' . $expectedClass);
|
||||
}
|
||||
|
||||
$service = $resolver();
|
||||
if (!$service instanceof $expectedClass) {
|
||||
throw new \RuntimeException($consumer . ' resolver returned invalid dependency: ' . $expectedClass);
|
||||
}
|
||||
|
||||
return $service;
|
||||
}
|
||||
|
||||
}
|
||||
323
core/Http/ApiBootstrap.php
Normal file
323
core/Http/ApiBootstrap.php
Normal file
@@ -0,0 +1,323 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\Service\Security\RateLimiterService;
|
||||
use MintyPHP\Service\Settings\SettingsApiPolicyGateway;
|
||||
|
||||
// Entry point bootstrapper called once at the top of every API action.
|
||||
// Handles: request context, CORS, audit start, rate limiting, and optional Bearer auth.
|
||||
class ApiBootstrap
|
||||
{
|
||||
// Two separate rate-limit buckets: one per IP (broad), one per token+IP (fine-grained).
|
||||
private const API_RATE_SCOPE_TOKEN = 'api.token_ip';
|
||||
private const API_RATE_SCOPE_IP = 'api.ip';
|
||||
private const API_RATE_LIMIT_TOKEN = 300;
|
||||
private const API_RATE_LIMIT_IP = 120;
|
||||
private const API_RATE_WINDOW_SECONDS = 60;
|
||||
private const API_RATE_BLOCK_SECONDS = 120;
|
||||
|
||||
private static bool $initialized = false;
|
||||
private static bool $shutdownRegistered = false;
|
||||
/** @var (callable(): object)|null Resolver for API audit service (provided by audit module) */
|
||||
private static $apiAuditServiceResolver = null;
|
||||
/** @var (callable(): SettingsApiPolicyGateway)|null */
|
||||
private static $settingsApiPolicyGatewayResolver = null;
|
||||
/** @var (callable(): RateLimiterService)|null */
|
||||
private static $rateLimiterServiceResolver = null;
|
||||
/** @var (callable(): object)|null Resolver for API system audit reporter (provided by audit module) */
|
||||
private static $apiSystemAuditReporterResolver = null;
|
||||
|
||||
public static function configure(
|
||||
?callable $apiAuditServiceResolver,
|
||||
callable $settingsApiPolicyGatewayResolver,
|
||||
callable $rateLimiterServiceResolver,
|
||||
?callable $apiSystemAuditReporterResolver
|
||||
): void {
|
||||
self::$apiAuditServiceResolver = $apiAuditServiceResolver;
|
||||
self::$settingsApiPolicyGatewayResolver = $settingsApiPolicyGatewayResolver;
|
||||
self::$rateLimiterServiceResolver = $rateLimiterServiceResolver;
|
||||
self::$apiSystemAuditReporterResolver = $apiSystemAuditReporterResolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the API request context.
|
||||
*
|
||||
* Call this at the top of every API page action.
|
||||
* Sets MINTY_ALLOW_OUTPUT, CORS headers and optional Bearer auth.
|
||||
*/
|
||||
public static function init(bool $requireAuth = true): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
self::$initialized = true;
|
||||
|
||||
if (!defined('MINTY_ALLOW_OUTPUT')) {
|
||||
define('MINTY_ALLOW_OUTPUT', true);
|
||||
}
|
||||
|
||||
RequestContext::start([
|
||||
'channel' => 'api',
|
||||
'method' => strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')),
|
||||
'path' => (string) parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH),
|
||||
]);
|
||||
header('X-Request-Id: ' . RequestContext::requestHeaderValue());
|
||||
|
||||
self::registerShutdownHandler();
|
||||
self::tryStartAudit();
|
||||
self::startSystemAuditReporter();
|
||||
self::setCorsHeaders();
|
||||
|
||||
if (strtoupper($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
|
||||
http_response_code(204);
|
||||
die();
|
||||
}
|
||||
|
||||
self::enforceRateLimit();
|
||||
|
||||
if ($requireAuth) {
|
||||
$authenticated = ApiAuth::authenticate();
|
||||
if (!$authenticated) {
|
||||
ApiResponse::unauthorized();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function setCorsHeaders(): void
|
||||
{
|
||||
$rawOrigin = trim((string) ($_SERVER['HTTP_ORIGIN'] ?? ''));
|
||||
if ($rawOrigin === '') {
|
||||
return;
|
||||
}
|
||||
$origin = self::normalizeOrigin($rawOrigin);
|
||||
if ($origin === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$allowedOrigins = array_fill_keys(self::settingsApiPolicy()->getApiCorsAllowedOrigins(), true);
|
||||
if (!isset($allowedOrigins[$origin])) {
|
||||
return;
|
||||
}
|
||||
|
||||
header('Vary: Origin');
|
||||
header('Access-Control-Allow-Origin: ' . $origin);
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Authorization, Content-Type, Accept');
|
||||
header('Access-Control-Max-Age: 86400');
|
||||
}
|
||||
|
||||
// Guarantees audit records are written even on uncaught fatal errors.
|
||||
private static function registerShutdownHandler(): void
|
||||
{
|
||||
if (self::$shutdownRegistered) {
|
||||
return;
|
||||
}
|
||||
self::$shutdownRegistered = true;
|
||||
|
||||
register_shutdown_function(static function (): void {
|
||||
$error = error_get_last();
|
||||
if (!is_array($error)) {
|
||||
$statusCode = (int) http_response_code();
|
||||
if ($statusCode <= 0) {
|
||||
$statusCode = 200;
|
||||
}
|
||||
self::tryFinishAudit($statusCode);
|
||||
self::finishSystemAuditReporter($statusCode);
|
||||
return;
|
||||
}
|
||||
|
||||
$type = (int) $error['type'];
|
||||
$fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR];
|
||||
if (!in_array($type, $fatalTypes, true)) {
|
||||
$statusCode = (int) http_response_code();
|
||||
if ($statusCode <= 0) {
|
||||
$statusCode = 200;
|
||||
}
|
||||
self::tryFinishAudit($statusCode);
|
||||
self::finishSystemAuditReporter($statusCode);
|
||||
return;
|
||||
}
|
||||
|
||||
$statusCode = (int) http_response_code();
|
||||
if ($statusCode < 400) {
|
||||
$statusCode = 500;
|
||||
}
|
||||
self::tryFinishAudit($statusCode, 'fatal_error');
|
||||
self::finishSystemAuditReporter($statusCode, 'fatal_error');
|
||||
});
|
||||
}
|
||||
|
||||
private static function normalizeOrigin(string $origin): ?string
|
||||
{
|
||||
$origin = rtrim(trim($origin), '/');
|
||||
if ($origin === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parsed = parse_url($origin);
|
||||
if (!is_array($parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$scheme = strtolower((string) ($parsed['scheme'] ?? ''));
|
||||
$host = strtolower((string) ($parsed['host'] ?? ''));
|
||||
$port = isset($parsed['port']) ? (int) $parsed['port'] : null;
|
||||
|
||||
if (!in_array($scheme, ['http', 'https'], true) || $host === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
isset($parsed['path'])
|
||||
|| isset($parsed['query'])
|
||||
|| isset($parsed['fragment'])
|
||||
|| isset($parsed['user'])
|
||||
|| isset($parsed['pass'])
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$defaultPort = ($scheme === 'https') ? 443 : 80;
|
||||
$portSuffix = ($port !== null && $port !== $defaultPort) ? ':' . $port : '';
|
||||
return $scheme . '://' . $host . $portSuffix;
|
||||
}
|
||||
|
||||
private static function enforceRateLimit(): void
|
||||
{
|
||||
$ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
|
||||
if ($ip === '') {
|
||||
$ip = 'unknown';
|
||||
}
|
||||
|
||||
$ipResult = self::rateLimiter()->hit(
|
||||
self::API_RATE_SCOPE_IP,
|
||||
$ip,
|
||||
self::API_RATE_LIMIT_IP,
|
||||
self::API_RATE_WINDOW_SECONDS,
|
||||
self::API_RATE_BLOCK_SECONDS
|
||||
);
|
||||
if (!($ipResult['allowed'] ?? true)) {
|
||||
ApiResponse::tooManyRequests(max(1, (int) ($ipResult['retry_after'] ?? self::API_RATE_BLOCK_SECONDS)));
|
||||
}
|
||||
|
||||
$selector = self::extractBearerSelector();
|
||||
if ($selector === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$tokenResult = self::rateLimiter()->hit(
|
||||
self::API_RATE_SCOPE_TOKEN,
|
||||
strtolower($selector) . '|' . $ip,
|
||||
self::API_RATE_LIMIT_TOKEN,
|
||||
self::API_RATE_WINDOW_SECONDS,
|
||||
self::API_RATE_BLOCK_SECONDS
|
||||
);
|
||||
|
||||
if (!($tokenResult['allowed'] ?? true)) {
|
||||
ApiResponse::tooManyRequests(max(1, (int) ($tokenResult['retry_after'] ?? self::API_RATE_BLOCK_SECONDS)));
|
||||
}
|
||||
}
|
||||
|
||||
// API tokens are formatted as "selector:verifier". The selector (24-char hex) is used
|
||||
// as the rate-limit key so a single token can't bypass the per-IP bucket by rotating IPs.
|
||||
private static function extractBearerSelector(): string
|
||||
{
|
||||
$token = ApiAuth::extractBearerToken();
|
||||
if ($token === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$parts = explode(':', $token, 2);
|
||||
$selector = trim((string) ($parts[0] ?? ''));
|
||||
if ($selector === '' || !preg_match('/^[a-f0-9]{24}$/i', $selector)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $selector;
|
||||
}
|
||||
|
||||
private static function settingsApiPolicy(): SettingsApiPolicyGateway
|
||||
{
|
||||
return self::resolveDependency(
|
||||
self::$settingsApiPolicyGatewayResolver,
|
||||
SettingsApiPolicyGateway::class,
|
||||
'ApiBootstrap'
|
||||
);
|
||||
}
|
||||
|
||||
private static function rateLimiter(): RateLimiterService
|
||||
{
|
||||
return self::resolveDependency(self::$rateLimiterServiceResolver, RateLimiterService::class, 'ApiBootstrap');
|
||||
}
|
||||
|
||||
private static function tryStartAudit(): void
|
||||
{
|
||||
try {
|
||||
if (is_callable(self::$apiAuditServiceResolver)) {
|
||||
$service = (self::$apiAuditServiceResolver)();
|
||||
if (is_object($service) && method_exists($service, 'startRequestContext')) {
|
||||
$service->startRequestContext();
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// fail-open
|
||||
}
|
||||
}
|
||||
|
||||
private static function tryFinishAudit(int $statusCode, ?string $errorCode = null): void
|
||||
{
|
||||
try {
|
||||
if (is_callable(self::$apiAuditServiceResolver)) {
|
||||
$service = (self::$apiAuditServiceResolver)();
|
||||
if (is_object($service) && method_exists($service, 'finish')) {
|
||||
$service->finish($statusCode, $errorCode);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// fail-open
|
||||
}
|
||||
}
|
||||
|
||||
private static function startSystemAuditReporter(): void
|
||||
{
|
||||
try {
|
||||
if (is_callable(self::$apiSystemAuditReporterResolver)) {
|
||||
$service = (self::$apiSystemAuditReporterResolver)();
|
||||
if (is_object($service) && method_exists($service, 'start')) {
|
||||
$service->start();
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// fail-open
|
||||
}
|
||||
}
|
||||
|
||||
private static function finishSystemAuditReporter(int $statusCode, ?string $errorCode = null): void
|
||||
{
|
||||
try {
|
||||
if (is_callable(self::$apiSystemAuditReporterResolver)) {
|
||||
$service = (self::$apiSystemAuditReporterResolver)();
|
||||
if (is_object($service) && method_exists($service, 'finish')) {
|
||||
$service->finish($statusCode, $errorCode);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// fail-open
|
||||
}
|
||||
}
|
||||
|
||||
private static function resolveDependency(mixed $resolver, string $expectedClass, string $consumer): mixed
|
||||
{
|
||||
if (!is_callable($resolver)) {
|
||||
throw new \RuntimeException($consumer . ' is not configured for dependency: ' . $expectedClass);
|
||||
}
|
||||
|
||||
$service = $resolver();
|
||||
if (!$service instanceof $expectedClass) {
|
||||
throw new \RuntimeException($consumer . ' resolver returned invalid dependency: ' . $expectedClass);
|
||||
}
|
||||
|
||||
return $service;
|
||||
}
|
||||
}
|
||||
297
core/Http/ApiResponse.php
Normal file
297
core/Http/ApiResponse.php
Normal file
@@ -0,0 +1,297 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\Http\Input\FormErrors;
|
||||
use MintyPHP\Service\Access\AuthorizationService;
|
||||
|
||||
class ApiResponse
|
||||
{
|
||||
/** @var (callable(): object)|null Resolver for API audit service (provided by audit module) */
|
||||
private static $apiAuditServiceResolver = null;
|
||||
/** @var (callable(): AuthorizationService)|null */
|
||||
private static $authorizationServiceResolver = null;
|
||||
/** @var (callable(): object)|null Resolver for API system audit reporter (provided by audit module) */
|
||||
private static $apiSystemAuditReporterResolver = null;
|
||||
|
||||
public static function configure(
|
||||
?callable $apiAuditServiceResolver,
|
||||
callable $authorizationServiceResolver,
|
||||
?callable $apiSystemAuditReporterResolver
|
||||
): void {
|
||||
self::$apiAuditServiceResolver = $apiAuditServiceResolver;
|
||||
self::$authorizationServiceResolver = $authorizationServiceResolver;
|
||||
self::$apiSystemAuditReporterResolver = $apiSystemAuditReporterResolver;
|
||||
}
|
||||
|
||||
public static function success(array $data = [], int $status = 200): never
|
||||
{
|
||||
self::send($data, $status);
|
||||
}
|
||||
|
||||
public static function created(array $data = []): never
|
||||
{
|
||||
self::success($data, 201);
|
||||
}
|
||||
|
||||
public static function noContent(): never
|
||||
{
|
||||
self::send(null, 204);
|
||||
}
|
||||
|
||||
public static function error(string $error, int $status = 400, array $extra = []): never
|
||||
{
|
||||
$body = self::buildErrorBody($error, $extra);
|
||||
self::send($body, $status, $error);
|
||||
}
|
||||
|
||||
public static function unauthorized(): never
|
||||
{
|
||||
self::error('unauthorized', 401);
|
||||
}
|
||||
|
||||
public static function forbidden(string $detail = 'forbidden'): never
|
||||
{
|
||||
self::error($detail, 403);
|
||||
}
|
||||
|
||||
public static function notFound(string $detail = 'not_found'): never
|
||||
{
|
||||
self::error($detail, 404);
|
||||
}
|
||||
|
||||
public static function methodNotAllowed(): never
|
||||
{
|
||||
self::error('method_not_allowed', 405);
|
||||
}
|
||||
|
||||
public static function validationError(array $errors): never
|
||||
{
|
||||
self::error('validation_error', 422, ['errors' => $errors]);
|
||||
}
|
||||
|
||||
public static function validationFromFormErrors(FormErrors $errors): never
|
||||
{
|
||||
self::validationError($errors->toArray());
|
||||
}
|
||||
|
||||
public static function tooManyRequests(int $retryAfter = 60): never
|
||||
{
|
||||
$retryAfter = max(1, $retryAfter);
|
||||
self::send(
|
||||
self::buildErrorBody('rate_limit_exceeded', ['retry_after' => $retryAfter]),
|
||||
429,
|
||||
'rate_limit_exceeded',
|
||||
['Retry-After: ' . $retryAfter]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a service result (['ok' => bool, ...] pattern) into an API response.
|
||||
*/
|
||||
public static function fromServiceResult(array $result, int $successStatus = 200): never
|
||||
{
|
||||
if ($result['ok'] ?? false) {
|
||||
$data = $result;
|
||||
unset($data['ok']);
|
||||
self::success($data, $successStatus);
|
||||
}
|
||||
|
||||
$status = (int) ($result['status'] ?? 0);
|
||||
if ($status < 400) {
|
||||
$status = 422;
|
||||
}
|
||||
$error = (string) ($result['error'] ?? 'operation_failed');
|
||||
$errors = $result['errors'] ?? [];
|
||||
$extra = [];
|
||||
if ($errors) {
|
||||
$extra['errors'] = $errors;
|
||||
}
|
||||
self::error($error, $status, $extra);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read JSON request body into an array.
|
||||
*/
|
||||
public static function readJsonBody(): array
|
||||
{
|
||||
$raw = file_get_contents('php://input');
|
||||
if ($raw === false || $raw === '') {
|
||||
return [];
|
||||
}
|
||||
$data = json_decode($raw, true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Require specific HTTP method(s).
|
||||
*/
|
||||
public static function requireMethod(string ...$methods): void
|
||||
{
|
||||
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||
if (!in_array($method, $methods, true)) {
|
||||
self::methodNotAllowed();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Require API authentication.
|
||||
*/
|
||||
public static function requireAuth(): void
|
||||
{
|
||||
if (!ApiAuth::isAuthenticated()) {
|
||||
self::unauthorized();
|
||||
}
|
||||
}
|
||||
|
||||
public static function requireAbility(string $ability, array $context = []): void
|
||||
{
|
||||
self::requireAuth();
|
||||
$decision = self::authorizationService()->authorize($ability, [
|
||||
'actor_user_id' => ApiAuth::userId(),
|
||||
'scoped_tenant_id' => ApiAuth::scopedTenantId(),
|
||||
...$context,
|
||||
]);
|
||||
if (!$decision->isAllowed()) {
|
||||
self::forbidden();
|
||||
}
|
||||
}
|
||||
|
||||
private static function send(?array $body, int $status, ?string $errorCode = null, array $headers = []): never
|
||||
{
|
||||
$requestId = self::requestId();
|
||||
http_response_code($status);
|
||||
header('X-Request-Id: ' . $requestId);
|
||||
foreach ($headers as $headerLine) {
|
||||
header($headerLine);
|
||||
}
|
||||
|
||||
if ($body === null) {
|
||||
self::finishSystemAuditReporter($status, $errorCode);
|
||||
self::tryFinishAudit($status, $errorCode);
|
||||
die();
|
||||
}
|
||||
|
||||
if (!array_key_exists('request_id', $body)) {
|
||||
$body['request_id'] = $requestId;
|
||||
}
|
||||
|
||||
$json = json_encode($body, JSON_UNESCAPED_UNICODE);
|
||||
if (!is_string($json)) {
|
||||
$status = 500;
|
||||
http_response_code($status);
|
||||
$errorCode = $errorCode ?: 'serialization_error';
|
||||
$fallbackBody = self::buildErrorBody('serialization_error');
|
||||
$fallbackBody['request_id'] = $requestId;
|
||||
$json = json_encode($fallbackBody, JSON_UNESCAPED_UNICODE)
|
||||
?: '{"ok":false,"error_code":"serialization_error","request_id":"unknown","details":{}}';
|
||||
}
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
self::finishSystemAuditReporter($status, $errorCode);
|
||||
self::tryFinishAudit($status, $errorCode);
|
||||
die($json);
|
||||
}
|
||||
|
||||
private static function requestId(): string
|
||||
{
|
||||
$requestId = RequestContext::currentId();
|
||||
if (is_string($requestId) && trim($requestId) !== '') {
|
||||
return trim($requestId);
|
||||
}
|
||||
|
||||
$requestId = self::tryGetAuditRequestId();
|
||||
if (is_string($requestId) && trim($requestId) !== '') {
|
||||
return trim($requestId);
|
||||
}
|
||||
|
||||
return RequestContext::id();
|
||||
}
|
||||
|
||||
private static function buildErrorBody(string $errorCode, array $details = []): array
|
||||
{
|
||||
$normalizedErrorCode = trim($errorCode);
|
||||
if ($normalizedErrorCode === '') {
|
||||
$normalizedErrorCode = 'unknown_error';
|
||||
}
|
||||
|
||||
$normalizedDetails = self::normalizeDetails($details);
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'error_code' => $normalizedErrorCode,
|
||||
'details' => $normalizedDetails,
|
||||
];
|
||||
}
|
||||
|
||||
// Empty details must be stdClass so json_encode produces {} instead of [].
|
||||
private static function normalizeDetails(array $details): array|\stdClass
|
||||
{
|
||||
if ($details === []) {
|
||||
return new \stdClass();
|
||||
}
|
||||
|
||||
if (array_is_list($details)) {
|
||||
return ['items' => $details];
|
||||
}
|
||||
|
||||
return $details;
|
||||
}
|
||||
|
||||
private static function tryFinishAudit(int $statusCode, ?string $errorCode = null): void
|
||||
{
|
||||
try {
|
||||
if (is_callable(self::$apiAuditServiceResolver)) {
|
||||
$service = (self::$apiAuditServiceResolver)();
|
||||
if (is_object($service) && method_exists($service, 'finish')) {
|
||||
$service->finish($statusCode, $errorCode);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// fail-open
|
||||
}
|
||||
}
|
||||
|
||||
private static function tryGetAuditRequestId(): ?string
|
||||
{
|
||||
try {
|
||||
if (is_callable(self::$apiAuditServiceResolver)) {
|
||||
$service = (self::$apiAuditServiceResolver)();
|
||||
if (is_object($service) && method_exists($service, 'currentRequestId')) {
|
||||
return $service->currentRequestId();
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// fail-open
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function authorizationService(): AuthorizationService
|
||||
{
|
||||
if (!is_callable(self::$authorizationServiceResolver)) {
|
||||
throw new \RuntimeException('ApiResponse is not configured for dependency: ' . AuthorizationService::class);
|
||||
}
|
||||
|
||||
$service = (self::$authorizationServiceResolver)();
|
||||
if (!$service instanceof AuthorizationService) {
|
||||
throw new \RuntimeException('ApiResponse resolver returned invalid dependency: ' . AuthorizationService::class);
|
||||
}
|
||||
|
||||
return $service;
|
||||
}
|
||||
|
||||
private static function finishSystemAuditReporter(int $statusCode, ?string $errorCode = null): void
|
||||
{
|
||||
try {
|
||||
if (is_callable(self::$apiSystemAuditReporterResolver)) {
|
||||
$service = (self::$apiSystemAuditReporterResolver)();
|
||||
if (is_object($service) && method_exists($service, 'finish')) {
|
||||
$service->finish($statusCode, $errorCode);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// fail-open
|
||||
}
|
||||
}
|
||||
}
|
||||
56
core/Http/AssetDetector.php
Normal file
56
core/Http/AssetDetector.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
/**
|
||||
* Detects static asset requests that should bypass locale redirects.
|
||||
*/
|
||||
class AssetDetector
|
||||
{
|
||||
/** File extensions that indicate static assets */
|
||||
private const ASSET_EXTENSIONS = [
|
||||
'css', 'js', 'mjs', 'map',
|
||||
'png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp',
|
||||
'woff', 'woff2', 'ttf', 'eot',
|
||||
'webmanifest',
|
||||
];
|
||||
|
||||
/** Path prefixes that indicate static asset directories */
|
||||
private const ASSET_PREFIXES = [
|
||||
'vendor/',
|
||||
'css/',
|
||||
'js/',
|
||||
'favicon/',
|
||||
'brand/',
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if the given path is a static asset request.
|
||||
*/
|
||||
public static function isAssetRequest(string $path): bool
|
||||
{
|
||||
if ($path === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
if (self::hasAssetExtension($path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check directory prefix
|
||||
foreach (self::ASSET_PREFIXES as $prefix) {
|
||||
if (str_starts_with($path, $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function hasAssetExtension(string $path): bool
|
||||
{
|
||||
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
return $extension !== '' && in_array($extension, self::ASSET_EXTENSIONS, true);
|
||||
}
|
||||
}
|
||||
34
core/Http/CookieStore.php
Normal file
34
core/Http/CookieStore.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
class CookieStore implements CookieStoreInterface
|
||||
{
|
||||
public function get(string $name): string
|
||||
{
|
||||
return (string) ($_COOKIE[$name] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function set(string $name, string $value, array $options = []): void
|
||||
{
|
||||
setcookie($name, $value, $options);
|
||||
$_COOKIE[$name] = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function remove(string $name, array $options = []): void
|
||||
{
|
||||
$removeOptions = ['expires' => time() - 3600, 'path' => '/'];
|
||||
foreach ($options as $key => $value) {
|
||||
$removeOptions[$key] = $value;
|
||||
}
|
||||
|
||||
setcookie($name, '', $removeOptions);
|
||||
unset($_COOKIE[$name]);
|
||||
}
|
||||
}
|
||||
18
core/Http/CookieStoreInterface.php
Normal file
18
core/Http/CookieStoreInterface.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
interface CookieStoreInterface
|
||||
{
|
||||
public function get(string $name): string;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function set(string $name, string $value, array $options = []): void;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function remove(string $name, array $options = []): void;
|
||||
}
|
||||
349
core/Http/ErrorDataCollector.php
Normal file
349
core/Http/ErrorDataCollector.php
Normal file
@@ -0,0 +1,349 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\Debugger;
|
||||
|
||||
final class ErrorDataCollector
|
||||
{
|
||||
private const CODE_CONTEXT_LINES = 10;
|
||||
private const MAX_TRACE_FRAMES = 50;
|
||||
|
||||
private const REDACTED_HEADER_PATTERNS = [
|
||||
'authorization',
|
||||
'cookie',
|
||||
'x-api-key',
|
||||
];
|
||||
|
||||
private const REDACTED_KEY_PATTERNS = [
|
||||
'password',
|
||||
'secret',
|
||||
'token',
|
||||
'key',
|
||||
'csrf',
|
||||
];
|
||||
|
||||
private const SAFE_ENV_KEYS = [
|
||||
'APP_DEBUG',
|
||||
'APP_ENV',
|
||||
'APP_NAME',
|
||||
'APP_TIMEZONE',
|
||||
'APP_LOCALE',
|
||||
'APP_URL',
|
||||
'APP_VENDOR_PHP_ISSUES_MODE',
|
||||
'TENANT_SCOPE_STRICT',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array{exception: array<string, mixed>, request: array<string, mixed>, environment: array<string, mixed>, queries: array<string, mixed>}
|
||||
*/
|
||||
public static function collect(\Throwable $exception): array
|
||||
{
|
||||
return [
|
||||
'exception' => self::collectException($exception),
|
||||
'request' => self::collectRequest(),
|
||||
'environment' => self::collectEnvironment(),
|
||||
'queries' => self::collectQueries(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function collectException(\Throwable $exception): array
|
||||
{
|
||||
$data = [
|
||||
'class' => get_class($exception),
|
||||
'message' => $exception->getMessage(),
|
||||
'code' => $exception->getCode(),
|
||||
'file' => $exception->getFile(),
|
||||
'line' => $exception->getLine(),
|
||||
'trace' => self::collectStackFrames($exception),
|
||||
];
|
||||
|
||||
$previous = $exception->getPrevious();
|
||||
if ($previous !== null) {
|
||||
$data['previous'] = self::collectException($previous);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private static function collectStackFrames(\Throwable $exception): array
|
||||
{
|
||||
$frames = [];
|
||||
|
||||
// Frame 0 is the exception origin itself.
|
||||
$frames[] = [
|
||||
'file' => $exception->getFile(),
|
||||
'line' => $exception->getLine(),
|
||||
'function' => null,
|
||||
'class' => null,
|
||||
'code_snippet' => self::extractCodeSnippet($exception->getFile(), $exception->getLine()),
|
||||
];
|
||||
|
||||
foreach (array_slice($exception->getTrace(), 0, self::MAX_TRACE_FRAMES) as $frame) {
|
||||
$file = $frame['file'] ?? null;
|
||||
$line = $frame['line'] ?? null;
|
||||
$function = $frame['function'];
|
||||
$class = $frame['class'] ?? null;
|
||||
|
||||
$frames[] = [
|
||||
'file' => $file,
|
||||
'line' => $line,
|
||||
'function' => $function,
|
||||
'class' => $class,
|
||||
'args_summary' => self::summarizeArgs($frame['args'] ?? []),
|
||||
'code_snippet' => ($file !== null && $line !== null)
|
||||
? self::extractCodeSnippet($file, $line)
|
||||
: [],
|
||||
];
|
||||
}
|
||||
|
||||
return $frames;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string> Line number => code line
|
||||
*/
|
||||
private static function extractCodeSnippet(string $file, int $line): array
|
||||
{
|
||||
try {
|
||||
if (!is_file($file) || !is_readable($file)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$lines = @file($file, FILE_IGNORE_NEW_LINES);
|
||||
if ($lines === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$start = max(0, $line - self::CODE_CONTEXT_LINES - 1);
|
||||
$end = min(count($lines), $line + self::CODE_CONTEXT_LINES);
|
||||
|
||||
$snippet = [];
|
||||
for ($i = $start; $i < $end; $i++) {
|
||||
$snippet[$i + 1] = $lines[$i];
|
||||
}
|
||||
|
||||
return $snippet;
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<mixed> $args
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function summarizeArgs(array $args): array
|
||||
{
|
||||
$summary = [];
|
||||
foreach ($args as $arg) {
|
||||
$summary[] = match (true) {
|
||||
is_null($arg) => 'null',
|
||||
is_bool($arg) => $arg ? 'true' : 'false',
|
||||
is_int($arg) => 'int',
|
||||
is_float($arg) => 'float',
|
||||
is_string($arg) => 'string(' . strlen($arg) . ')',
|
||||
is_array($arg) => 'array(' . count($arg) . ')',
|
||||
is_object($arg) => get_class($arg),
|
||||
is_resource($arg) => 'resource',
|
||||
default => 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function collectRequest(): array
|
||||
{
|
||||
$data = [
|
||||
'request_id' => null,
|
||||
'channel' => 'web',
|
||||
'method' => $_SERVER['REQUEST_METHOD'] ?? 'GET',
|
||||
'url' => ($_SERVER['REQUEST_URI'] ?? ''),
|
||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? null,
|
||||
'user_agent' => substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
|
||||
'headers' => self::collectHeaders(),
|
||||
'get' => $_GET,
|
||||
'post' => self::redactParams($_POST),
|
||||
'session_keys' => [],
|
||||
'user_id' => null,
|
||||
'tenant_id' => null,
|
||||
];
|
||||
|
||||
// Safe request context access.
|
||||
try {
|
||||
$data['request_id'] = RequestContext::currentId() ?? RequestContext::id();
|
||||
} catch (\Throwable) {
|
||||
// RequestContext may not be initialized.
|
||||
}
|
||||
|
||||
try {
|
||||
$ctx = RequestContext::context();
|
||||
$data['channel'] = $ctx['channel'] ?? 'web';
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
// Session data — keys and types only, never values.
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
$data['session_keys'] = self::describeSessionKeys($_SESSION ?? []);
|
||||
$data['user_id'] = $_SESSION['user']['id'] ?? $_SESSION['user_id'] ?? null;
|
||||
$data['tenant_id'] = $_SESSION['current_tenant']['id'] ?? $_SESSION['tenant_id'] ?? null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function collectHeaders(): array
|
||||
{
|
||||
$headers = [];
|
||||
foreach ($_SERVER as $key => $value) {
|
||||
if (!str_starts_with($key, 'HTTP_')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = strtolower(str_replace('_', '-', substr($key, 5)));
|
||||
$headers[$name] = self::shouldRedactHeader($name) ? '[REDACTED]' : (string) $value;
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
private static function shouldRedactHeader(string $name): bool
|
||||
{
|
||||
$lower = strtolower($name);
|
||||
foreach (self::REDACTED_HEADER_PATTERNS as $pattern) {
|
||||
if (str_contains($lower, $pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function redactParams(array $params): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($params as $key => $value) {
|
||||
$result[$key] = self::shouldRedactParam((string) $key) ? '[REDACTED]' : $value;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function shouldRedactParam(string $key): bool
|
||||
{
|
||||
$lower = strtolower($key);
|
||||
foreach (self::REDACTED_KEY_PATTERNS as $pattern) {
|
||||
if (str_contains($lower, $pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function describeSessionKeys(array $session): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($session as $key => $value) {
|
||||
$result[(string) $key] = get_debug_type($value);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function collectEnvironment(): array
|
||||
{
|
||||
$data = [
|
||||
'php_version' => PHP_VERSION,
|
||||
'php_sapi' => PHP_SAPI,
|
||||
'extensions' => get_loaded_extensions(),
|
||||
'memory_current' => memory_get_usage(true),
|
||||
'memory_peak' => memory_get_peak_usage(true),
|
||||
'env' => self::collectSafeEnv(),
|
||||
'modules' => [],
|
||||
];
|
||||
|
||||
// Active modules — may fail if container is unavailable.
|
||||
try {
|
||||
if (function_exists('app')) {
|
||||
$registry = app(\MintyPHP\App\Module\ModuleRegistry::class);
|
||||
$modules = $registry->getModules();
|
||||
$data['modules'] = array_map(
|
||||
fn (object $m): string => $m->id ?? (string) $m,
|
||||
$modules,
|
||||
);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function collectSafeEnv(): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach (self::SAFE_ENV_KEYS as $key) {
|
||||
$value = $_ENV[$key] ?? $_SERVER[$key] ?? null;
|
||||
if ($value !== null) {
|
||||
$result[$key] = (string) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function collectQueries(): array
|
||||
{
|
||||
try {
|
||||
if (!class_exists(Debugger::class, false) || !Debugger::$enabled) {
|
||||
return ['available' => false, 'queries' => [], 'start' => null];
|
||||
}
|
||||
|
||||
$queries = Debugger::get('queries');
|
||||
$start = Debugger::get('start');
|
||||
|
||||
if (!is_array($queries)) {
|
||||
$queries = [];
|
||||
}
|
||||
|
||||
return [
|
||||
'available' => true,
|
||||
'queries' => $queries,
|
||||
'start' => is_float($start) ? $start : null,
|
||||
'count' => count($queries),
|
||||
];
|
||||
} catch (\Throwable) {
|
||||
return ['available' => false, 'queries' => [], 'start' => null];
|
||||
}
|
||||
}
|
||||
}
|
||||
243
core/Http/ErrorHandler.php
Normal file
243
core/Http/ErrorHandler.php
Normal file
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\Debugger;
|
||||
|
||||
final class ErrorHandler
|
||||
{
|
||||
/** @phpstan-ignore property.onlyWritten (Memory reserve freed on OOM to allow error rendering.) */
|
||||
private static ?string $reservedMemory = null;
|
||||
private static bool $registered = false;
|
||||
private static bool $handling = false;
|
||||
|
||||
public static function register(): void
|
||||
{
|
||||
if (self::$registered) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::$registered = true;
|
||||
self::$reservedMemory = str_repeat('x', 64 * 1024); // 64 KB reserve for OOM recovery.
|
||||
|
||||
set_exception_handler([self::class, 'handleException']);
|
||||
set_error_handler([self::class, 'handleError']);
|
||||
register_shutdown_function([self::class, 'handleShutdown']);
|
||||
}
|
||||
|
||||
public static function handleException(\Throwable $exception): void
|
||||
{
|
||||
// Prevent recursive handling if the error page itself throws.
|
||||
if (self::$handling) {
|
||||
self::renderMinimalFallback($exception);
|
||||
return;
|
||||
}
|
||||
|
||||
self::$handling = true;
|
||||
|
||||
try {
|
||||
// Free reserved memory so we have room to work after OOM.
|
||||
self::$reservedMemory = null;
|
||||
|
||||
// Discard any partial output from the failed request.
|
||||
if (!self::isPhpUnitRuntime()) {
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
}
|
||||
|
||||
// Set HTTP 500 if no error status has been set yet.
|
||||
$currentStatus = http_response_code();
|
||||
if ($currentStatus === false || $currentStatus < 400) {
|
||||
http_response_code(500);
|
||||
}
|
||||
|
||||
// Collect diagnostic data.
|
||||
$data = ErrorDataCollector::collect($exception);
|
||||
|
||||
// Log to file (always, in both dev and prod).
|
||||
ErrorLogger::log($exception, $data);
|
||||
|
||||
// Bail out if headers were already sent (cannot render a page).
|
||||
if (headers_sent()) {
|
||||
self::$handling = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// API requests get a JSON response, not HTML.
|
||||
if (self::isApiRequest()) {
|
||||
self::sendApiError($data['request']['request_id'] ?? null);
|
||||
self::$handling = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Render HTML error page.
|
||||
if (self::shouldShowDebugPage()) {
|
||||
echo ErrorRenderer::renderDev($data);
|
||||
} else {
|
||||
$requestId = (string) ($data['request']['request_id'] ?? 'unknown');
|
||||
echo ErrorRenderer::renderProd($requestId);
|
||||
}
|
||||
} catch (\Throwable $inner) {
|
||||
self::renderMinimalFallback($exception);
|
||||
}
|
||||
|
||||
self::$handling = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts PHP errors into ErrorException.
|
||||
* Respects the error_reporting() bitmask (honors @ suppression operator).
|
||||
*/
|
||||
public static function handleError(int $severity, string $message, string $file, int $line): true
|
||||
{
|
||||
if (!(error_reporting() & $severity)) {
|
||||
// Error was suppressed with @.
|
||||
return true;
|
||||
}
|
||||
|
||||
if (self::shouldSuppressVendorIssue($severity, $file)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new \ErrorException($message, 0, $severity, $file, $line);
|
||||
}
|
||||
|
||||
/**
|
||||
* Catches fatal errors that bypass set_error_handler (E_PARSE, E_COMPILE_ERROR, etc.).
|
||||
*/
|
||||
public static function handleShutdown(): void
|
||||
{
|
||||
self::$reservedMemory = null;
|
||||
|
||||
$error = error_get_last();
|
||||
if (!is_array($error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR];
|
||||
if (!in_array($error['type'], $fatalTypes, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only act if we haven't already handled this (e.g. via set_exception_handler).
|
||||
if (self::$handling) {
|
||||
return;
|
||||
}
|
||||
|
||||
$exception = new \ErrorException(
|
||||
$error['message'],
|
||||
0,
|
||||
$error['type'],
|
||||
$error['file'],
|
||||
$error['line'],
|
||||
);
|
||||
|
||||
self::handleException($exception);
|
||||
}
|
||||
|
||||
private static function isApiRequest(): bool
|
||||
{
|
||||
if (defined('MINTY_API_REQUEST')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$uri = $_SERVER['REQUEST_URI'] ?? '';
|
||||
$path = ltrim(parse_url($uri, PHP_URL_PATH) ?? '', '/');
|
||||
|
||||
return str_starts_with($path, 'api/');
|
||||
}
|
||||
|
||||
private static function shouldShowDebugPage(): bool
|
||||
{
|
||||
// Check the Debugger flag, which mirrors APP_DEBUG.
|
||||
if (class_exists(Debugger::class, false) && Debugger::$enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function sendApiError(?string $requestId): void
|
||||
{
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
$payload = [
|
||||
'ok' => false,
|
||||
'error_code' => 'internal_error',
|
||||
];
|
||||
if ($requestId !== null) {
|
||||
$payload['request_id'] = $requestId;
|
||||
}
|
||||
|
||||
echo json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
private static function renderMinimalFallback(\Throwable $exception): void
|
||||
{
|
||||
// Last-resort plain-text response when everything else is broken.
|
||||
$requestId = 'unknown';
|
||||
try {
|
||||
$requestId = RequestContext::currentId() ?? RequestContext::id();
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
if (!headers_sent()) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
}
|
||||
|
||||
echo "500 Internal Server Error\nRequest ID: {$requestId}\n";
|
||||
|
||||
// In debug mode, also show the exception for developers.
|
||||
if (class_exists(Debugger::class, false) && Debugger::$enabled) {
|
||||
echo "\n" . $exception->getMessage() . "\n" . $exception->getTraceAsString() . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
private static function isPhpUnitRuntime(): bool
|
||||
{
|
||||
return defined('PHPUNIT_COMPOSER_INSTALL') || defined('__PHPUNIT_PHAR__');
|
||||
}
|
||||
|
||||
private static function shouldSuppressVendorIssue(int $severity, string $file): bool
|
||||
{
|
||||
if (!self::isVendorPath($file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return match (self::vendorIssueMode()) {
|
||||
'suppress_deprecations' => self::isDeprecationSeverity($severity),
|
||||
'suppress_deprecations_warnings' => self::isDeprecationSeverity($severity) || self::isWarningSeverity($severity),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
private static function vendorIssueMode(): string
|
||||
{
|
||||
$raw = getenv('APP_VENDOR_PHP_ISSUES_MODE');
|
||||
if ($raw === false) {
|
||||
return 'strict';
|
||||
}
|
||||
|
||||
$mode = strtolower(trim((string) $raw));
|
||||
return in_array($mode, ['strict', 'suppress_deprecations', 'suppress_deprecations_warnings'], true)
|
||||
? $mode
|
||||
: 'strict';
|
||||
}
|
||||
|
||||
private static function isDeprecationSeverity(int $severity): bool
|
||||
{
|
||||
return in_array($severity, [E_DEPRECATED, E_USER_DEPRECATED], true);
|
||||
}
|
||||
|
||||
private static function isWarningSeverity(int $severity): bool
|
||||
{
|
||||
return in_array($severity, [E_WARNING, E_USER_WARNING], true);
|
||||
}
|
||||
|
||||
private static function isVendorPath(string $file): bool
|
||||
{
|
||||
$normalized = str_replace('\\', '/', $file);
|
||||
return str_contains($normalized, '/vendor/');
|
||||
}
|
||||
}
|
||||
169
core/Http/ErrorLogger.php
Normal file
169
core/Http/ErrorLogger.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
final class ErrorLogger
|
||||
{
|
||||
private const LOG_DIR = 'storage/logs/errors';
|
||||
private const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
private const MAX_ROTATED_FILES = 5;
|
||||
private const REDACTION_MARKER = '[REDACTED]';
|
||||
|
||||
public static function log(\Throwable $exception, array $collectedData): void
|
||||
{
|
||||
try {
|
||||
$logDir = self::ensureLogDirectory();
|
||||
if ($logDir === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$logFile = $logDir . '/error-' . date('Y-m-d') . '.log';
|
||||
self::rotateIfNeeded($logFile);
|
||||
|
||||
$entry = self::buildLogEntry($exception, $collectedData);
|
||||
$json = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
if ($json === false) {
|
||||
$json = json_encode(['error' => 'Failed to encode log entry', 'request_id' => $entry['request_id'] ?? 'unknown']);
|
||||
}
|
||||
|
||||
file_put_contents($logFile, $json . "\n", FILE_APPEND | LOCK_EX);
|
||||
} catch (\Throwable) {
|
||||
// Logging must never cause secondary failures.
|
||||
}
|
||||
}
|
||||
|
||||
private static function ensureLogDirectory(): ?string
|
||||
{
|
||||
$dir = self::LOG_DIR;
|
||||
if (is_dir($dir)) {
|
||||
return $dir;
|
||||
}
|
||||
|
||||
if (!@mkdir($dir, 0o775, true) && !is_dir($dir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $dir;
|
||||
}
|
||||
|
||||
private static function rotateIfNeeded(string $filePath): void
|
||||
{
|
||||
if (!is_file($filePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$size = @filesize($filePath);
|
||||
if ($size === false || $size < self::MAX_FILE_SIZE) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the next available rotation slot.
|
||||
for ($i = self::MAX_ROTATED_FILES; $i >= 2; $i--) {
|
||||
$older = $filePath . '.' . $i;
|
||||
$newer = $filePath . '.' . ($i - 1);
|
||||
if (is_file($newer)) {
|
||||
@rename($newer, $older);
|
||||
}
|
||||
}
|
||||
|
||||
@rename($filePath, $filePath . '.1');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{exception?: array, request?: array, environment?: array, queries?: array} $collectedData
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function buildLogEntry(\Throwable $exception, array $collectedData): array
|
||||
{
|
||||
$exceptionData = $collectedData['exception'] ?? [];
|
||||
$requestData = $collectedData['request'] ?? [];
|
||||
$envData = $collectedData['environment'] ?? [];
|
||||
$exceptionMessage = (string) ($exceptionData['message'] ?? $exception->getMessage());
|
||||
|
||||
$traceSummary = '';
|
||||
$frames = $exceptionData['trace'] ?? [];
|
||||
foreach (array_slice($frames, 0, 10) as $i => $frame) {
|
||||
$file = $frame['file'] ?? '?';
|
||||
$line = $frame['line'] ?? '?';
|
||||
$traceSummary .= "#{$i} {$file}:{$line} ";
|
||||
}
|
||||
|
||||
return [
|
||||
'timestamp' => date('c'),
|
||||
'level' => 'error',
|
||||
'request_id' => $requestData['request_id'] ?? null,
|
||||
'channel' => $requestData['channel'] ?? 'web',
|
||||
'exception' => [
|
||||
'class' => $exceptionData['class'] ?? get_class($exception),
|
||||
'message' => self::REDACTION_MARKER,
|
||||
'message_hash' => self::hashMessage($exceptionMessage),
|
||||
'code' => $exceptionData['code'] ?? $exception->getCode(),
|
||||
'file' => self::shortenPath($exceptionData['file'] ?? $exception->getFile()),
|
||||
'line' => $exceptionData['line'] ?? $exception->getLine(),
|
||||
],
|
||||
'trace_summary' => trim($traceSummary),
|
||||
'request' => [
|
||||
'method' => $requestData['method'] ?? null,
|
||||
'path' => self::normalizeRequestPath($requestData['url'] ?? null),
|
||||
'ip_hash' => self::hashIp($requestData['ip'] ?? null),
|
||||
'user_id' => $requestData['user_id'] ?? null,
|
||||
'tenant_id' => $requestData['tenant_id'] ?? null,
|
||||
],
|
||||
'php_version' => $envData['php_version'] ?? PHP_VERSION,
|
||||
'memory_peak_mb' => round(($envData['memory_peak'] ?? memory_get_peak_usage(true)) / 1024 / 1024, 1),
|
||||
];
|
||||
}
|
||||
|
||||
private static function hashIp(?string $ip): ?string
|
||||
{
|
||||
if ($ip === null || $ip === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'sha256:' . hash('sha256', $ip);
|
||||
}
|
||||
|
||||
private static function hashMessage(string $message): ?string
|
||||
{
|
||||
$message = trim($message);
|
||||
if ($message === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'sha256:' . hash('sha256', $message);
|
||||
}
|
||||
|
||||
private static function normalizeRequestPath(mixed $rawUrl): ?string
|
||||
{
|
||||
if (!is_string($rawUrl) || trim($rawUrl) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = parse_url($rawUrl, PHP_URL_PATH);
|
||||
if (!is_string($path) || trim($path) === '') {
|
||||
return '/';
|
||||
}
|
||||
|
||||
$path = preg_replace('/[[:cntrl:]]+/', '', $path) ?? '';
|
||||
$path = trim($path);
|
||||
if ($path === '') {
|
||||
return '/';
|
||||
}
|
||||
|
||||
if (!str_starts_with($path, '/')) {
|
||||
$path = '/' . $path;
|
||||
}
|
||||
|
||||
return substr($path, 0, 500);
|
||||
}
|
||||
|
||||
private static function shortenPath(string $path): string
|
||||
{
|
||||
$root = getcwd();
|
||||
if ($root !== false && str_starts_with($path, $root . '/')) {
|
||||
return substr($path, strlen($root) + 1);
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
501
core/Http/ErrorRenderer.php
Normal file
501
core/Http/ErrorRenderer.php
Normal file
@@ -0,0 +1,501 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\I18n;
|
||||
|
||||
final class ErrorRenderer
|
||||
{
|
||||
/**
|
||||
* Renders the full developer debug page with all four panels.
|
||||
*
|
||||
* @param array{exception: array<string, mixed>, request: array<string, mixed>, environment: array<string, mixed>, queries: array<string, mixed>} $data
|
||||
*/
|
||||
public static function renderDev(array $data): string
|
||||
{
|
||||
$exc = $data['exception'];
|
||||
$req = $data['request'];
|
||||
$env = $data['environment'];
|
||||
$qry = $data['queries'];
|
||||
|
||||
$requestId = self::esc((string) ($req['request_id'] ?? 'unknown'));
|
||||
$statusCode = http_response_code() ?: 500;
|
||||
$statusText = self::statusText($statusCode);
|
||||
$queryCount = $qry['count'] ?? count($qry['queries'] ?? []);
|
||||
|
||||
$css = self::loadAsset(__DIR__ . '/../../web/css/pages/app-error-debug.css');
|
||||
$js = self::loadAsset(__DIR__ . '/../../web/js/components/app-error-debug.js');
|
||||
|
||||
$excClass = self::esc($exc['class'] ?? self::tr('Exception'));
|
||||
$excMessage = self::esc((string) ($exc['message'] ?? self::tr('An error occurred')));
|
||||
$excLocation = self::esc(self::tr(
|
||||
'in %s on line %s',
|
||||
self::shortenPath((string) ($exc['file'] ?? '')),
|
||||
(string) ($exc['line'] ?? '?')
|
||||
));
|
||||
|
||||
$exceptionPanel = self::renderExceptionPanel($exc);
|
||||
$requestPanel = self::renderRequestPanel($req);
|
||||
$environmentPanel = self::renderEnvironmentPanel($env);
|
||||
$queryPanel = self::renderQueryPanel($qry);
|
||||
|
||||
$lang = self::esc(self::htmlLang());
|
||||
$statusTextEsc = self::esc($statusText);
|
||||
$requestIdLabel = self::esc(self::tr('Request ID'));
|
||||
$copyRequestIdTitle = self::esc(self::tr('Copy request ID'));
|
||||
$tabsLabel = self::esc(self::tr('Error details'));
|
||||
|
||||
$tabException = self::esc(self::tr('Exception'));
|
||||
$tabRequest = self::esc(self::tr('Request'));
|
||||
$tabEnvironment = self::esc(self::tr('Environment'));
|
||||
$tabQueries = self::esc(self::tr('Queries'));
|
||||
|
||||
return <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="{$lang}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{$statusCode} {$statusTextEsc}</title>
|
||||
<style>{$css}</style>
|
||||
</head>
|
||||
<body class="app-error-debug">
|
||||
<header class="app-error-debug-header">
|
||||
<div class="app-error-debug-container app-error-debug-header__inner">
|
||||
<div class="app-error-debug-header__status">
|
||||
<span class="app-error-debug-header__code">{$statusCode}</span>
|
||||
<span class="app-error-debug-header__label">{$statusTextEsc}</span>
|
||||
</div>
|
||||
<div class="app-error-debug-request-id">
|
||||
{$requestIdLabel}: {$requestId}
|
||||
<button type="button" class="app-error-debug-copy-button" data-copy="{$requestId}" title="{$copyRequestIdTitle}" aria-label="{$copyRequestIdTitle}">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/><path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="app-error-debug-container">
|
||||
<div class="app-error-debug-summary">
|
||||
<div class="app-error-debug-summary__class">{$excClass}</div>
|
||||
<div class="app-error-debug-summary__message">{$excMessage}</div>
|
||||
<div class="app-error-debug-summary__location">{$excLocation}</div>
|
||||
</div>
|
||||
|
||||
<nav class="app-error-debug-tabs" role="tablist" aria-label="{$tabsLabel}">
|
||||
<button type="button" id="tab-exception" class="app-error-debug-tab active" role="tab" aria-selected="true" aria-controls="panel-exception" data-panel="panel-exception" tabindex="0">{$tabException}</button>
|
||||
<button type="button" id="tab-request" class="app-error-debug-tab" role="tab" aria-selected="false" aria-controls="panel-request" data-panel="panel-request" tabindex="-1">{$tabRequest}</button>
|
||||
<button type="button" id="tab-environment" class="app-error-debug-tab" role="tab" aria-selected="false" aria-controls="panel-environment" data-panel="panel-environment" tabindex="-1">{$tabEnvironment}</button>
|
||||
<button type="button" id="tab-queries" class="app-error-debug-tab" role="tab" aria-selected="false" aria-controls="panel-queries" data-panel="panel-queries" tabindex="-1">{$tabQueries} <span class="app-error-debug-tab__count">{$queryCount}</span></button>
|
||||
</nav>
|
||||
|
||||
<div id="panel-exception" class="app-error-debug-panel active" role="tabpanel" aria-labelledby="tab-exception">{$exceptionPanel}</div>
|
||||
<div id="panel-request" class="app-error-debug-panel" role="tabpanel" aria-labelledby="tab-request">{$requestPanel}</div>
|
||||
<div id="panel-environment" class="app-error-debug-panel" role="tabpanel" aria-labelledby="tab-environment">{$environmentPanel}</div>
|
||||
<div id="panel-queries" class="app-error-debug-panel" role="tabpanel" aria-labelledby="tab-queries">{$queryPanel}</div>
|
||||
</main>
|
||||
|
||||
<script>{$js}</script>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a clean production error page with request ID only.
|
||||
* Uses the same design tokens as the dev page for visual consistency.
|
||||
*/
|
||||
public static function renderProd(string $requestId): string
|
||||
{
|
||||
$statusCode = http_response_code() ?: 500;
|
||||
$requestId = self::esc($requestId);
|
||||
$css = self::loadAsset(__DIR__ . '/../../web/css/pages/app-error-debug.css');
|
||||
$js = self::loadAsset(__DIR__ . '/../../web/js/components/app-error-debug.js');
|
||||
|
||||
$lang = self::esc(self::htmlLang());
|
||||
$errorText = self::esc(self::tr('Error'));
|
||||
$requestIdLabel = self::esc(self::tr('Request ID'));
|
||||
$copyRequestIdTitle = self::esc(self::tr('Copy request ID'));
|
||||
$message = self::esc(self::tr('An unexpected error occurred. If the problem persists, please contact support with the reference below.'));
|
||||
$backLabel = self::esc(self::tr('Back to start'));
|
||||
|
||||
return <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="{$lang}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{$statusCode} - {$errorText}</title>
|
||||
<style>
|
||||
{$css}
|
||||
.app-error-prod {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
min-height: 100dvh; padding: calc(var(--app-spacing) * 2); text-align: center;
|
||||
font-family: var(--app-font-family); background: var(--app-background-color); color: var(--app-color);
|
||||
}
|
||||
.app-error-prod__code {
|
||||
font-size: var(--text-5xl); font-weight: var(--font-bold);
|
||||
line-height: var(--leading-none); color: var(--app-muted-color);
|
||||
}
|
||||
.app-error-prod__message {
|
||||
margin-top: var(--app-spacing); font-size: var(--text-lg);
|
||||
color: var(--app-color); max-width: 28rem;
|
||||
}
|
||||
.app-error-prod__back {
|
||||
display: inline-block; margin-top: calc(var(--app-spacing) * 2);
|
||||
padding: calc(var(--app-spacing) * 0.5) calc(var(--app-spacing) * 1.25);
|
||||
font-size: var(--text-sm); text-decoration: none;
|
||||
border: 1px solid var(--app-border); border-radius: var(--app-border-radius);
|
||||
color: var(--app-color); background: var(--app-card-background-color);
|
||||
}
|
||||
.app-error-prod__back:hover { opacity: 0.8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-error-prod">
|
||||
<div class="app-error-prod__code">{$statusCode}</div>
|
||||
<div class="app-error-prod__message">{$message}</div>
|
||||
<div class="app-error-debug-request-id" style="margin-top: calc(var(--app-spacing) * 1.5);">
|
||||
{$requestIdLabel}: {$requestId}
|
||||
<button type="button" class="app-error-debug-copy-button" data-copy="{$requestId}" title="{$copyRequestIdTitle}" aria-label="{$copyRequestIdTitle}"><svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/><path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/></svg></button>
|
||||
</div>
|
||||
<a class="app-error-prod__back" href="/">{$backLabel}</a>
|
||||
</div>
|
||||
<script>{$js}</script>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
}
|
||||
|
||||
private static function renderExceptionPanel(array $exceptionData): string
|
||||
{
|
||||
$html = '<div class="app-error-debug-section"><div class="app-error-debug-section__title">' . self::esc(self::tr('Stack Trace')) . '</div>';
|
||||
$html .= '<ol class="app-error-debug-trace">';
|
||||
|
||||
$frames = $exceptionData['trace'] ?? [];
|
||||
foreach ($frames as $i => $frame) {
|
||||
$file = self::esc(self::shortenPath((string) ($frame['file'] ?? '[internal]')));
|
||||
$line = self::esc((string) ($frame['line'] ?? '?'));
|
||||
$function = '';
|
||||
if (isset($frame['class'])) {
|
||||
$function = self::esc((string) $frame['class'] . '::' . (string) ($frame['function'] ?? '?'));
|
||||
} elseif (isset($frame['function'])) {
|
||||
$function = self::esc((string) $frame['function']);
|
||||
}
|
||||
|
||||
$codeHtml = '';
|
||||
$snippet = $frame['code_snippet'] ?? [];
|
||||
if (!empty($snippet)) {
|
||||
$highlightLine = (int) ($frame['line'] ?? 0);
|
||||
$codeHtml = self::renderCodeBlock($snippet, $highlightLine);
|
||||
}
|
||||
|
||||
$codeId = 'frame-code-' . $i;
|
||||
$toggleId = 'frame-toggle-' . $i;
|
||||
$isOpen = ($i === 0);
|
||||
$expanded = $isOpen ? 'true' : 'false';
|
||||
$openClass = $isOpen ? ' open' : '';
|
||||
|
||||
$html .= <<<FRAME
|
||||
<li class="app-error-debug-frame{$openClass}">
|
||||
<button type="button" id="{$toggleId}" class="app-error-debug-frame__header app-error-debug-frame__toggle" aria-expanded="{$expanded}" aria-controls="{$codeId}">
|
||||
<span class="app-error-debug-frame__index">#{$i}</span>
|
||||
<span class="app-error-debug-frame__file">{$file}</span>:<span class="app-error-debug-frame__line">{$line}</span>
|
||||
<span class="app-error-debug-frame__function">{$function}</span>
|
||||
</button>
|
||||
<div id="{$codeId}" class="app-error-debug-frame__code" role="region" aria-labelledby="{$toggleId}">{$codeHtml}</div>
|
||||
</li>
|
||||
FRAME;
|
||||
}
|
||||
|
||||
$html .= '</ol></div>';
|
||||
|
||||
// Chained exceptions.
|
||||
if (isset($exceptionData['previous'])) {
|
||||
$prev = $exceptionData['previous'];
|
||||
$html .= '<div class="app-error-debug-chain">';
|
||||
$html .= '<div class="app-error-debug-chain__label">' . self::esc(self::tr('Caused by')) . '</div>';
|
||||
$html .= '<div class="app-error-debug-summary__class">' . self::esc((string) ($prev['class'] ?? self::tr('Exception'))) . '</div>';
|
||||
$html .= '<div class="app-error-debug-summary__message">' . self::esc((string) ($prev['message'] ?? '')) . '</div>';
|
||||
$html .= '<div class="app-error-debug-summary__location">' . self::esc(self::tr(
|
||||
'in %s on line %s',
|
||||
self::shortenPath((string) ($prev['file'] ?? '')),
|
||||
(string) ($prev['line'] ?? '?')
|
||||
)) . '</div>';
|
||||
$html .= '</div>';
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $lines
|
||||
*/
|
||||
private static function renderCodeBlock(array $lines, int $highlightLine): string
|
||||
{
|
||||
$html = '<table class="app-error-debug-code-table">';
|
||||
foreach ($lines as $lineNo => $code) {
|
||||
$isHighlight = ($lineNo === $highlightLine) ? ' highlight' : '';
|
||||
$lineNoEsc = self::esc((string) $lineNo);
|
||||
$codeEsc = self::esc($code);
|
||||
$html .= "<tr class=\"{$isHighlight}\"><td class=\"app-error-debug-code-table__line-no\">{$lineNoEsc}</td><td class=\"app-error-debug-code-table__code\">{$codeEsc}</td></tr>";
|
||||
}
|
||||
$html .= '</table>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private static function renderRequestPanel(array $requestData): string
|
||||
{
|
||||
$html = '';
|
||||
|
||||
$html .= self::renderInfoSection(self::tr('Request'), [
|
||||
self::tr('Method') => $requestData['method'] ?? 'GET',
|
||||
self::tr('URL') => $requestData['url'] ?? '',
|
||||
self::tr('IP') => $requestData['ip'] ?? '',
|
||||
self::tr('User Agent') => $requestData['user_agent'] ?? '',
|
||||
self::tr('Channel') => $requestData['channel'] ?? 'web',
|
||||
]);
|
||||
|
||||
$headers = $requestData['headers'] ?? [];
|
||||
if (!empty($headers)) {
|
||||
$html .= self::renderInfoSection(self::tr('Headers'), $headers);
|
||||
}
|
||||
|
||||
$get = $requestData['get'] ?? [];
|
||||
if (!empty($get)) {
|
||||
$html .= self::renderInfoSection(self::tr('GET Parameters'), array_map(
|
||||
fn ($v): string => is_string($v) ? $v : (string) json_encode($v, JSON_UNESCAPED_SLASHES),
|
||||
$get,
|
||||
));
|
||||
}
|
||||
|
||||
$post = $requestData['post'] ?? [];
|
||||
if (!empty($post)) {
|
||||
$html .= self::renderInfoSection(self::tr('POST Parameters'), array_map(
|
||||
fn ($v): string => is_string($v) ? $v : (string) json_encode($v, JSON_UNESCAPED_SLASHES),
|
||||
$post,
|
||||
));
|
||||
}
|
||||
|
||||
$session = $requestData['session_keys'] ?? [];
|
||||
if (!empty($session)) {
|
||||
$html .= self::renderInfoSection(self::tr('Session (keys + types)'), $session);
|
||||
}
|
||||
|
||||
$html .= self::renderInfoSection(self::tr('Context'), array_filter([
|
||||
self::tr('User ID') => $requestData['user_id'] ?? null,
|
||||
self::tr('Tenant ID') => $requestData['tenant_id'] ?? null,
|
||||
], fn ($v): bool => $v !== null));
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private static function renderEnvironmentPanel(array $envData): string
|
||||
{
|
||||
$html = '';
|
||||
|
||||
$html .= self::renderInfoSection(self::tr('PHP'), [
|
||||
self::tr('Version') => $envData['php_version'] ?? PHP_VERSION,
|
||||
self::tr('SAPI') => $envData['php_sapi'] ?? PHP_SAPI,
|
||||
self::tr('Memory (current)') => self::formatBytes((int) ($envData['memory_current'] ?? 0)),
|
||||
self::tr('Memory (peak)') => self::formatBytes((int) ($envData['memory_peak'] ?? 0)),
|
||||
]);
|
||||
|
||||
$env = $envData['env'] ?? [];
|
||||
if (!empty($env)) {
|
||||
$html .= self::renderInfoSection(self::tr('Application'), $env);
|
||||
}
|
||||
|
||||
$modules = $envData['modules'] ?? [];
|
||||
if (!empty($modules)) {
|
||||
$html .= self::renderInfoSection(self::tr('Active Modules'), array_combine(
|
||||
array_map(fn ($m): string => (string) $m, $modules),
|
||||
array_fill(0, count($modules), self::tr('Active')),
|
||||
) ?: []);
|
||||
}
|
||||
|
||||
$extensions = $envData['extensions'] ?? [];
|
||||
if (!empty($extensions)) {
|
||||
$html .= '<div class="app-error-debug-section"><div class="app-error-debug-section__title">'
|
||||
. self::esc(self::tr('Loaded Extensions (%d)', count($extensions)))
|
||||
. '</div>';
|
||||
$html .= '<div style="font-family: var(--app-font-family-monospace); font-size: var(--text-xs); color: var(--app-muted-color); padding: calc(var(--app-spacing) * 0.5); background: var(--app-card-background-color); border-radius: var(--app-border-radius); border: 1px solid var(--app-border);">';
|
||||
$html .= self::esc(implode(', ', $extensions));
|
||||
$html .= '</div></div>';
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private static function renderQueryPanel(array $queryData): string
|
||||
{
|
||||
if (!($queryData['available'] ?? false)) {
|
||||
return '<div class="app-error-debug-empty">' . self::esc(self::tr('Query tracking is not available (Debugger disabled or not initialized).')) . '</div>';
|
||||
}
|
||||
|
||||
$queries = $queryData['queries'] ?? [];
|
||||
if (empty($queries)) {
|
||||
return '<div class="app-error-debug-empty">' . self::esc(self::tr('No queries were executed before the error occurred.')) . '</div>';
|
||||
}
|
||||
|
||||
$html = '<div class="app-error-debug-section"><div class="app-error-debug-section__title">'
|
||||
. self::esc(self::tr('Queries (%d)', count($queries)))
|
||||
. '</div>';
|
||||
|
||||
foreach ($queries as $i => $query) {
|
||||
$sql = is_string($query) ? $query : ($query[0] ?? $query['query'] ?? '?');
|
||||
$duration = '';
|
||||
if (is_array($query) && isset($query[1])) {
|
||||
$durationMs = round((float) $query[1] * 1000, 2);
|
||||
$duration = $durationMs . ' ms';
|
||||
}
|
||||
|
||||
$querySqlId = 'query-sql-' . $i;
|
||||
$queryToggleId = 'query-toggle-' . $i;
|
||||
|
||||
$html .= '<div class="app-error-debug-query">';
|
||||
$html .= '<button type="button" id="' . self::esc($queryToggleId) . '" class="app-error-debug-query__header app-error-debug-query__toggle" aria-expanded="false" aria-controls="' . self::esc($querySqlId) . '">';
|
||||
$html .= '<span>' . self::esc(self::truncate($sql, 120)) . '</span>';
|
||||
if ($duration !== '') {
|
||||
$html .= '<span class="app-error-debug-query__duration">' . self::esc($duration) . '</span>';
|
||||
}
|
||||
$html .= '</button>';
|
||||
$html .= '<div id="' . self::esc($querySqlId) . '" class="app-error-debug-query__sql" role="region" aria-labelledby="' . self::esc($queryToggleId) . '">' . self::esc($sql) . '</div>';
|
||||
$html .= '</div>';
|
||||
}
|
||||
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $items
|
||||
*/
|
||||
private static function renderInfoSection(string $title, array $items): string
|
||||
{
|
||||
if (empty($items)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$html = '<div class="app-error-debug-section"><div class="app-error-debug-section__title">' . self::esc($title) . '</div>';
|
||||
$html .= '<table class="app-error-debug-info-table">';
|
||||
|
||||
foreach ($items as $key => $value) {
|
||||
$keyEsc = self::esc((string) $key);
|
||||
$valueStr = (string) $value;
|
||||
$isRedacted = ($valueStr === '[REDACTED]');
|
||||
$valueEsc = $isRedacted
|
||||
? '<span class="app-error-debug-redacted">[REDACTED]</span>'
|
||||
: self::esc($valueStr);
|
||||
$html .= "<tr><th>{$keyEsc}</th><td>{$valueEsc}</td></tr>";
|
||||
}
|
||||
|
||||
$html .= '</table></div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private static function loadAsset(string $path): string
|
||||
{
|
||||
$resolved = realpath($path);
|
||||
if ($resolved === false || !is_file($resolved)) {
|
||||
return '/* asset not found: ' . basename($path) . ' */';
|
||||
}
|
||||
|
||||
return (string) file_get_contents($resolved);
|
||||
}
|
||||
|
||||
public static function esc(string $value): string
|
||||
{
|
||||
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
private static function shortenPath(string $path): string
|
||||
{
|
||||
$root = getcwd();
|
||||
if ($root !== false && str_starts_with($path, $root . '/')) {
|
||||
return substr($path, strlen($root) + 1);
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private static function statusText(int $code): string
|
||||
{
|
||||
return match ($code) {
|
||||
400 => self::tr('Bad Request'),
|
||||
401 => self::tr('Unauthorized'),
|
||||
403 => self::tr('Forbidden'),
|
||||
404 => self::tr('Not Found'),
|
||||
405 => self::tr('Method Not Allowed'),
|
||||
408 => self::tr('Request Timeout'),
|
||||
422 => self::tr('Unprocessable Entity'),
|
||||
429 => self::tr('Too Many Requests'),
|
||||
500 => self::tr('Internal Server Error'),
|
||||
502 => self::tr('Bad Gateway'),
|
||||
503 => self::tr('Service Unavailable'),
|
||||
default => self::tr('Error'),
|
||||
};
|
||||
}
|
||||
|
||||
private static function formatBytes(int $bytes): string
|
||||
{
|
||||
if ($bytes < 1024) {
|
||||
return $bytes . ' B';
|
||||
}
|
||||
if ($bytes < 1024 * 1024) {
|
||||
return round($bytes / 1024, 1) . ' KB';
|
||||
}
|
||||
|
||||
return round($bytes / (1024 * 1024), 1) . ' MB';
|
||||
}
|
||||
|
||||
private static function truncate(string $text, int $length): string
|
||||
{
|
||||
if (mb_strlen($text) <= $length) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
return mb_substr($text, 0, $length) . '...';
|
||||
}
|
||||
|
||||
private static function tr(string $text, mixed ...$args): string
|
||||
{
|
||||
if (function_exists('t')) {
|
||||
try {
|
||||
/** @var string $translated */
|
||||
$translated = t($text, ...$args);
|
||||
return $translated;
|
||||
} catch (\Throwable) {
|
||||
// Rendering must stay resilient, even if translation bootstrap failed.
|
||||
}
|
||||
}
|
||||
|
||||
if ($args === []) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
return (string) @vsprintf($text, $args);
|
||||
}
|
||||
|
||||
private static function htmlLang(): string
|
||||
{
|
||||
$locale = trim((string) (I18n::$locale ?? I18n::$defaultLocale ?? 'en'));
|
||||
if ($locale === '') {
|
||||
return 'en';
|
||||
}
|
||||
|
||||
$locale = strtolower(str_replace('_', '-', $locale));
|
||||
if (preg_match('/^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/', $locale) === 1) {
|
||||
return $locale;
|
||||
}
|
||||
|
||||
if (preg_match('/^[a-z]{2,3}/', $locale, $matches) === 1) {
|
||||
return $matches[0];
|
||||
}
|
||||
|
||||
return 'en';
|
||||
}
|
||||
}
|
||||
101
core/Http/Input/FormErrors.php
Normal file
101
core/Http/Input/FormErrors.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http\Input;
|
||||
|
||||
final class FormErrors
|
||||
{
|
||||
/** @var array<string, array<int, string>> */
|
||||
private array $errors = [];
|
||||
|
||||
public function add(string $field, string $message): self
|
||||
{
|
||||
$field = trim($field);
|
||||
if ($field === '') {
|
||||
$field = 'input';
|
||||
}
|
||||
|
||||
$message = trim($message);
|
||||
if ($message === '') {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->errors[$field] ??= [];
|
||||
$this->errors[$field][] = $message;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $messages
|
||||
*/
|
||||
public function addMany(string $field, array $messages): self
|
||||
{
|
||||
foreach ($messages as $message) {
|
||||
$this->add($field, (string) $message);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addGlobal(string $message): self
|
||||
{
|
||||
return $this->add('input', $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<int, string>|string>|array<int, string>|self $errors
|
||||
*/
|
||||
public function merge(array|self $errors): self
|
||||
{
|
||||
if ($errors instanceof self) {
|
||||
$errors = $errors->toArray();
|
||||
}
|
||||
|
||||
if (array_is_list($errors)) {
|
||||
foreach ($errors as $message) {
|
||||
$this->addGlobal((string) $message);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
foreach ($errors as $field => $messages) {
|
||||
if (is_array($messages)) {
|
||||
$this->addMany((string) $field, array_values(array_map('strval', $messages)));
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->add((string) $field, (string) $messages);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function hasAny(): bool
|
||||
{
|
||||
return $this->errors !== [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return $this->errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function toFlatList(): array
|
||||
{
|
||||
$flat = [];
|
||||
foreach ($this->errors as $messages) {
|
||||
foreach ($messages as $message) {
|
||||
$flat[] = $message;
|
||||
}
|
||||
}
|
||||
|
||||
return $flat;
|
||||
}
|
||||
}
|
||||
137
core/Http/Input/RequestInput.php
Normal file
137
core/Http/Input/RequestInput.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http\Input;
|
||||
|
||||
use MintyPHP\Http\Request;
|
||||
|
||||
final class RequestInput
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $query
|
||||
* @param array<string, mixed> $body
|
||||
* @param array<string, mixed> $files
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $method,
|
||||
private readonly array $query,
|
||||
private readonly array $body,
|
||||
private readonly array $files
|
||||
) {
|
||||
}
|
||||
|
||||
public function method(): string
|
||||
{
|
||||
return $this->method;
|
||||
}
|
||||
|
||||
public function isMethod(string ...$methods): bool
|
||||
{
|
||||
foreach ($methods as $method) {
|
||||
if ($this->method === strtoupper(trim($method))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function queryAll(): array
|
||||
{
|
||||
return $this->query;
|
||||
}
|
||||
|
||||
public function query(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return array_key_exists($key, $this->query) ? $this->query[$key] : $default;
|
||||
}
|
||||
|
||||
public function hasQuery(string $key): bool
|
||||
{
|
||||
return array_key_exists($key, $this->query);
|
||||
}
|
||||
|
||||
public function queryString(string $key, string $default = ''): string
|
||||
{
|
||||
return trim((string) $this->query($key, $default));
|
||||
}
|
||||
|
||||
public function queryInt(string $key, int $default = 0): int
|
||||
{
|
||||
return (int) $this->query($key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int|string, mixed>
|
||||
*/
|
||||
public function queryArray(string $key): array
|
||||
{
|
||||
$value = $this->query($key, []);
|
||||
return is_array($value) ? $value : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function bodyAll(): array
|
||||
{
|
||||
return $this->body;
|
||||
}
|
||||
|
||||
public function body(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return array_key_exists($key, $this->body) ? $this->body[$key] : $default;
|
||||
}
|
||||
|
||||
public function hasBody(string $key): bool
|
||||
{
|
||||
return array_key_exists($key, $this->body);
|
||||
}
|
||||
|
||||
public function bodyString(string $key, string $default = ''): string
|
||||
{
|
||||
return trim((string) $this->body($key, $default));
|
||||
}
|
||||
|
||||
public function bodyInt(string $key, int $default = 0): int
|
||||
{
|
||||
return (int) $this->body($key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int|string, mixed>
|
||||
*/
|
||||
public function bodyArray(string $key): array
|
||||
{
|
||||
$value = $this->body($key, []);
|
||||
return is_array($value) ? $value : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function filesAll(): array
|
||||
{
|
||||
return $this->files;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return mixed
|
||||
*/
|
||||
public function file(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return array_key_exists($key, $this->files) ? $this->files[$key] : $default;
|
||||
}
|
||||
|
||||
public function hasFile(string $key): bool
|
||||
{
|
||||
return array_key_exists($key, $this->files);
|
||||
}
|
||||
|
||||
public function wantsJson(): bool
|
||||
{
|
||||
return Request::wantsJson();
|
||||
}
|
||||
}
|
||||
48
core/Http/Input/RequestInputFactory.php
Normal file
48
core/Http/Input/RequestInputFactory.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http\Input;
|
||||
|
||||
final class RequestInputFactory
|
||||
{
|
||||
/** @var array<string, mixed>|null */
|
||||
private ?array $cachedApiBody = null;
|
||||
|
||||
public function create(): RequestInput
|
||||
{
|
||||
$method = strtoupper(trim((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')));
|
||||
if ($method === '') {
|
||||
$method = 'GET';
|
||||
}
|
||||
|
||||
$query = $_GET;
|
||||
$files = $_FILES;
|
||||
|
||||
// API requests carry a JSON body; web requests use $_POST form data.
|
||||
if (defined('MINTY_API_REQUEST')) {
|
||||
return new RequestInput($method, $query, $this->readApiBody(), $files);
|
||||
}
|
||||
|
||||
$body = $_POST;
|
||||
return new RequestInput($method, $query, $body, $files);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function readApiBody(): array
|
||||
{
|
||||
if ($this->cachedApiBody !== null) {
|
||||
return $this->cachedApiBody;
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
if (!is_string($raw) || $raw === '') {
|
||||
$this->cachedApiBody = [];
|
||||
return $this->cachedApiBody;
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
$this->cachedApiBody = is_array($decoded) ? $decoded : [];
|
||||
return $this->cachedApiBody;
|
||||
}
|
||||
}
|
||||
153
core/Http/IntendedUrlService.php
Normal file
153
core/Http/IntendedUrlService.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
/**
|
||||
* Stores and retrieves the URL the user intended to visit before
|
||||
* being redirected to the login page (session timeout, auth guard).
|
||||
*
|
||||
* URLs are validated to prevent open-redirect attacks.
|
||||
*/
|
||||
class IntendedUrlService
|
||||
{
|
||||
private const SESSION_KEY = 'intended_url';
|
||||
private const MAX_LENGTH = 2048;
|
||||
private const FALLBACK = 'admin';
|
||||
|
||||
/** Prefixes that must never be restored (would cause redirect loops or security issues). */
|
||||
private const BLOCKED_PREFIXES = [
|
||||
'auth/',
|
||||
'api/',
|
||||
'login',
|
||||
'logout',
|
||||
'flash/',
|
||||
'branding/',
|
||||
];
|
||||
|
||||
/**
|
||||
* Store the intended URL in the session.
|
||||
*/
|
||||
public function store(string $url, SessionStoreInterface $session): void
|
||||
{
|
||||
$sanitized = $this->sanitize($url);
|
||||
if ($sanitized !== '') {
|
||||
$session->set(self::SESSION_KEY, $sanitized);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the intended URL without removing it.
|
||||
*/
|
||||
public function retrieve(SessionStoreInterface $session): ?string
|
||||
{
|
||||
$value = $session->get(self::SESSION_KEY);
|
||||
return is_string($value) && $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the intended URL and remove it from the session.
|
||||
* Returns the fallback ('admin') if no valid URL is stored.
|
||||
*
|
||||
* The stored URL is a full REQUEST_URI (e.g. /de/admin/users?page=2)
|
||||
* but Router::redirect() expects a route path without leading slash
|
||||
* and without locale prefix (e.g. admin/users?page=2), because it
|
||||
* prepends getBaseUrl() which already includes the base path.
|
||||
*/
|
||||
public function retrieveAndForget(SessionStoreInterface $session): string
|
||||
{
|
||||
$value = $this->retrieve($session);
|
||||
$session->remove(self::SESSION_KEY);
|
||||
|
||||
if ($value === null) {
|
||||
return self::FALLBACK;
|
||||
}
|
||||
|
||||
return $this->toRoutePath($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a login redirect URL with the return_to query parameter.
|
||||
*/
|
||||
public function buildLoginRedirect(string $loginPath, string $returnTo): string
|
||||
{
|
||||
$sanitized = $this->sanitize($returnTo);
|
||||
if ($sanitized === '') {
|
||||
return $loginPath;
|
||||
}
|
||||
$separator = str_contains($loginPath, '?') ? '&' : '?';
|
||||
return $loginPath . $separator . 'return_to=' . rawurlencode($sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize and validate a URL for safe redirect.
|
||||
* Returns empty string if the URL is not safe.
|
||||
*/
|
||||
public function sanitize(string $url): string
|
||||
{
|
||||
$url = trim($url);
|
||||
|
||||
if ($url === '' || mb_strlen($url) > self::MAX_LENGTH) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Must be a relative path (starts with /)
|
||||
if (!str_starts_with($url, '/')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Must not contain protocol indicators (prevent //evil.com or javascript:)
|
||||
if (str_contains($url, '://') || str_starts_with($url, '//')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Strip the path to check against blocked prefixes.
|
||||
// URL might be /en/admin/users or /admin/users — strip locale prefix.
|
||||
$path = $this->extractPathWithoutLocale($url);
|
||||
|
||||
foreach (self::BLOCKED_PREFIXES as $prefix) {
|
||||
if (str_starts_with($path, $prefix) || $path === rtrim($prefix, '/')) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a full REQUEST_URI into a Router::redirect()-compatible route path.
|
||||
*
|
||||
* /de/admin/users?page=2 → admin/users?page=2
|
||||
* /admin/users → admin/users
|
||||
*/
|
||||
public function toRoutePath(string $url): string
|
||||
{
|
||||
// Remove leading slash
|
||||
$path = ltrim($url, '/');
|
||||
|
||||
// Remove locale prefix (2-letter code followed by /)
|
||||
if (preg_match('#^[a-z]{2}/#', $path)) {
|
||||
$path = substr($path, 3);
|
||||
}
|
||||
|
||||
return $path !== '' ? $path : self::FALLBACK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the path portion without query string and without locale prefix.
|
||||
*/
|
||||
private function extractPathWithoutLocale(string $url): string
|
||||
{
|
||||
// Remove query string and fragment
|
||||
$path = strtok($url, '?#') ?: $url;
|
||||
|
||||
// Remove leading slash
|
||||
$path = ltrim($path, '/');
|
||||
|
||||
// Remove locale prefix (2-letter code followed by /)
|
||||
if (preg_match('#^[a-z]{2}/#', $path)) {
|
||||
$path = substr($path, 3);
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
126
core/Http/LocaleResolver.php
Normal file
126
core/Http/LocaleResolver.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\I18n;
|
||||
|
||||
/**
|
||||
* Handles locale detection and URL manipulation for multi-language support.
|
||||
*/
|
||||
class LocaleResolver
|
||||
{
|
||||
private array $availableLocales;
|
||||
private string $defaultLocale;
|
||||
private string $basePath;
|
||||
|
||||
public function __construct(?array $availableLocales = null, ?string $defaultLocale = null)
|
||||
{
|
||||
$this->defaultLocale = $defaultLocale ?? I18n::$defaultLocale;
|
||||
$this->availableLocales = $availableLocales ?? (defined('APP_LOCALES') ? APP_LOCALES : [$this->defaultLocale]);
|
||||
$this->basePath = trim(parse_url(\MintyPHP\Router::getBaseUrl(), PHP_URL_PATH) ?: '/', '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a request URI and extract locale information.
|
||||
*
|
||||
* @return array{
|
||||
* locale: string,
|
||||
* pathWithoutLocale: string,
|
||||
* query: string,
|
||||
* hadLocaleInUrl: bool,
|
||||
* hadInvalidLocale: bool
|
||||
* }
|
||||
*/
|
||||
public function parseUri(string $uri): array
|
||||
{
|
||||
$path = parse_url($uri, PHP_URL_PATH) ?: '';
|
||||
$query = parse_url($uri, PHP_URL_QUERY) ?: '';
|
||||
|
||||
$relativePath = Request::stripBasePath($path);
|
||||
$segments = $relativePath === '' ? [] : explode('/', $relativePath);
|
||||
|
||||
$localeCandidate = $segments[0] ?? '';
|
||||
$hadLocaleInUrl = false;
|
||||
$hadInvalidLocale = false;
|
||||
$locale = '';
|
||||
|
||||
if ($localeCandidate !== '' && in_array($localeCandidate, $this->availableLocales, true)) {
|
||||
$locale = array_shift($segments);
|
||||
$hadLocaleInUrl = true;
|
||||
} elseif ($localeCandidate !== '' && $this->looksLikeLocale($localeCandidate)) {
|
||||
array_shift($segments);
|
||||
$hadInvalidLocale = true;
|
||||
}
|
||||
|
||||
return [
|
||||
'locale' => $locale,
|
||||
'pathWithoutLocale' => implode('/', $segments),
|
||||
'query' => $query,
|
||||
'hadLocaleInUrl' => $hadLocaleInUrl,
|
||||
'hadInvalidLocale' => $hadInvalidLocale,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective locale from multiple sources (priority order):
|
||||
* 1. URL segment (explicit)
|
||||
* 2. User session preference
|
||||
* 3. Cookie preference
|
||||
* 4. Default locale
|
||||
*/
|
||||
public function resolveLocale(string $urlLocale, ?string $userLocale = null, ?string $cookieLocale = null): string
|
||||
{
|
||||
if ($urlLocale !== '' && $this->isValidLocale($urlLocale)) {
|
||||
return $urlLocale;
|
||||
}
|
||||
|
||||
if ($userLocale !== null && $userLocale !== '' && $this->isValidLocale($userLocale)) {
|
||||
return $userLocale;
|
||||
}
|
||||
|
||||
if ($cookieLocale !== null && $cookieLocale !== '' && $this->isValidLocale($cookieLocale)) {
|
||||
return $cookieLocale;
|
||||
}
|
||||
|
||||
return $this->defaultLocale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a new REQUEST_URI without the locale segment.
|
||||
*/
|
||||
public function buildUriWithoutLocale(string $pathWithoutLocale, string $query): string
|
||||
{
|
||||
$newPath = $this->basePath !== '' ? '/' . $this->basePath : '';
|
||||
|
||||
if ($pathWithoutLocale !== '') {
|
||||
$newPath .= '/' . $pathWithoutLocale;
|
||||
} elseif ($newPath === '') {
|
||||
$newPath = '/';
|
||||
}
|
||||
|
||||
return $newPath . ($query !== '' ? '?' . $query : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a locale string is valid (exists in available locales).
|
||||
*/
|
||||
public function isValidLocale(string $locale): bool
|
||||
{
|
||||
return in_array($locale, $this->availableLocales, true);
|
||||
}
|
||||
|
||||
public function getAvailableLocales(): array
|
||||
{
|
||||
return $this->availableLocales;
|
||||
}
|
||||
|
||||
public function getDefaultLocale(): string
|
||||
{
|
||||
return $this->defaultLocale;
|
||||
}
|
||||
|
||||
private function looksLikeLocale(string $candidate): bool
|
||||
{
|
||||
return preg_match('/^[a-z]{2}(?:-[a-z]{2})?$/i', $candidate) === 1;
|
||||
}
|
||||
}
|
||||
97
core/Http/Request.php
Normal file
97
core/Http/Request.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\I18n;
|
||||
use MintyPHP\Router;
|
||||
|
||||
class Request
|
||||
{
|
||||
public static function stripBasePath(string $path): string
|
||||
{
|
||||
$base = trim(parse_url(Router::getBaseUrl(), PHP_URL_PATH) ?: '/', '/');
|
||||
$path = ltrim($path, '/');
|
||||
|
||||
if ($base !== '' && strpos($path, $base . '/') === 0) {
|
||||
return substr($path, strlen($base) + 1);
|
||||
}
|
||||
if ($base !== '' && $path === $base) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
public static function path(): string
|
||||
{
|
||||
$uri = $_SERVER['REQUEST_URI'] ?? '';
|
||||
$path = parse_url($uri, PHP_URL_PATH) ?: '';
|
||||
|
||||
return self::stripBasePath($path);
|
||||
}
|
||||
|
||||
// Returns a safe redirect target after login — guards against open-redirect attacks
|
||||
// by rejecting any value with a scheme or host (i.e. an absolute external URL).
|
||||
public static function safeReturnTarget(string $returnParam = ''): string
|
||||
{
|
||||
if ($returnParam !== '') {
|
||||
$parts = parse_url($returnParam);
|
||||
if (($parts['scheme'] ?? '') === '' && ($parts['host'] ?? '') === '') {
|
||||
$path = self::stripBasePath($parts['path'] ?? '');
|
||||
$query = isset($parts['query']) ? '?' . $parts['query'] : '';
|
||||
return $path . $query;
|
||||
}
|
||||
}
|
||||
|
||||
$referer = $_SERVER['HTTP_REFERER'] ?? '';
|
||||
if ($referer !== '') {
|
||||
$parts = parse_url($referer);
|
||||
$host = $parts['host'] ?? '';
|
||||
$currentHost = $_SERVER['HTTP_HOST'] ?? '';
|
||||
if ($host === '' || $host === $currentHost) {
|
||||
$path = self::stripBasePath($parts['path'] ?? '');
|
||||
$query = isset($parts['query']) ? '?' . $parts['query'] : '';
|
||||
return $path . $query;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
public static function pathWithQuery(): string
|
||||
{
|
||||
$uri = $_SERVER['REQUEST_URI'] ?? '';
|
||||
$path = self::path();
|
||||
$query = parse_url($uri, PHP_URL_QUERY);
|
||||
|
||||
return $path . ($query ? '?' . $query : '');
|
||||
}
|
||||
|
||||
public static function stripLocale(string $path, ?array $locales = null): string
|
||||
{
|
||||
$locales = $locales ?? (defined('APP_LOCALES') ? APP_LOCALES : [I18n::$defaultLocale]);
|
||||
$path = ltrim($path, '/');
|
||||
if ($path === '') {
|
||||
return '';
|
||||
}
|
||||
$parts = explode('/', $path);
|
||||
while ($parts && $parts[0] !== '' && in_array($parts[0], $locales, true)) {
|
||||
array_shift($parts);
|
||||
}
|
||||
return implode('/', $parts);
|
||||
}
|
||||
|
||||
public static function withLocale(string $path = '', ?string $locale = null): string
|
||||
{
|
||||
$locale = $locale ?? (I18n::$locale ?? I18n::$defaultLocale);
|
||||
$path = ltrim($path, '/');
|
||||
return ($locale !== '' ? $locale . '/' : '') . $path;
|
||||
}
|
||||
|
||||
public static function wantsJson(): bool
|
||||
{
|
||||
$accept = $_SERVER['HTTP_ACCEPT'] ?? '';
|
||||
$requestedWith = $_SERVER['HTTP_X_REQUESTED_WITH'] ?? '';
|
||||
return stripos($accept, 'application/json') !== false || $requestedWith !== '';
|
||||
}
|
||||
}
|
||||
233
core/Http/RequestContext.php
Normal file
233
core/Http/RequestContext.php
Normal file
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\Repository\Support\RepoQuery;
|
||||
|
||||
// Holds per-request metadata (id, channel, method, path, ip) as a static singleton.
|
||||
// Must be initialized once at the entry point (web, api, cli) via start().
|
||||
final class RequestContext
|
||||
{
|
||||
/** @var array<string, mixed>|null */
|
||||
private static ?array $context = null;
|
||||
|
||||
public static function start(array $overrides = []): void
|
||||
{
|
||||
if (self::$context === null) {
|
||||
self::$context = self::buildDefaultContext();
|
||||
}
|
||||
|
||||
self::applyOverrides($overrides);
|
||||
}
|
||||
|
||||
public static function ensureStarted(): void
|
||||
{
|
||||
if (self::$context !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::$context = self::buildDefaultContext();
|
||||
}
|
||||
|
||||
// Returns the request ID, generating one if missing. Use this when you need a guaranteed value.
|
||||
public static function id(): string
|
||||
{
|
||||
self::ensureStarted();
|
||||
$requestId = trim((string) (self::$context['request_id'] ?? ''));
|
||||
if ($requestId === '') {
|
||||
$requestId = RepoQuery::uuidV4();
|
||||
self::$context['request_id'] = $requestId;
|
||||
}
|
||||
return $requestId;
|
||||
}
|
||||
|
||||
// Returns the ID only if already set — safe to call before start() (e.g. in error handlers).
|
||||
public static function currentId(): ?string
|
||||
{
|
||||
if (self::$context === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$requestId = trim((string) (self::$context['request_id'] ?? ''));
|
||||
return $requestId !== '' ? $requestId : null;
|
||||
}
|
||||
|
||||
public static function setChannel(string $channel): void
|
||||
{
|
||||
self::ensureStarted();
|
||||
$normalized = self::normalizeChannel($channel);
|
||||
if ($normalized !== '') {
|
||||
self::$context['channel'] = $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
public static function setPath(string $path): void
|
||||
{
|
||||
self::ensureStarted();
|
||||
$path = trim($path);
|
||||
if ($path === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
self::$context['path'] = substr($path, 0, 255);
|
||||
}
|
||||
|
||||
public static function context(): array
|
||||
{
|
||||
self::ensureStarted();
|
||||
|
||||
return self::$context ?? [];
|
||||
}
|
||||
|
||||
public static function requestHeaderValue(): string
|
||||
{
|
||||
return self::id();
|
||||
}
|
||||
|
||||
// HMAC when a key is configured (e.g. hashing IPs for audit logs) — plain SHA256 as fallback.
|
||||
public static function hashValue(string $value): string
|
||||
{
|
||||
$secret = defined('APP_CRYPTO_KEY') ? trim((string) APP_CRYPTO_KEY) : '';
|
||||
if ($secret === '') {
|
||||
return hash('sha256', $value);
|
||||
}
|
||||
|
||||
return hash_hmac('sha256', $value, $secret);
|
||||
}
|
||||
|
||||
public static function resetForTests(): void
|
||||
{
|
||||
self::$context = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function buildDefaultContext(): array
|
||||
{
|
||||
$method = strtoupper(trim((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')));
|
||||
if ($method === '') {
|
||||
$method = PHP_SAPI === 'cli' ? 'CLI' : 'GET';
|
||||
}
|
||||
|
||||
$uri = (string) ($_SERVER['REQUEST_URI'] ?? '');
|
||||
$path = parse_url($uri, PHP_URL_PATH);
|
||||
$path = is_string($path) ? trim($path) : '';
|
||||
if ($path === '' && PHP_SAPI === 'cli') {
|
||||
$path = (string) ($_SERVER['argv'][0] ?? 'cli');
|
||||
}
|
||||
|
||||
$requestId = self::requestIdFromHeader();
|
||||
if ($requestId === null) {
|
||||
$requestId = RepoQuery::uuidV4();
|
||||
}
|
||||
|
||||
return [
|
||||
'request_id' => $requestId,
|
||||
'channel' => self::detectChannel($path),
|
||||
'method' => substr($method, 0, 8),
|
||||
'path' => substr($path, 0, 255),
|
||||
'ip' => self::normalizeIp((string) ($_SERVER['REMOTE_ADDR'] ?? '')),
|
||||
'user_agent' => self::normalizeUserAgent((string) ($_SERVER['HTTP_USER_AGENT'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
private static function applyOverrides(array $overrides): void
|
||||
{
|
||||
foreach ($overrides as $key => $value) {
|
||||
if (!is_string($key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($key === 'request_id') {
|
||||
$requestId = trim((string) $value);
|
||||
if (self::isValidUuid($requestId)) {
|
||||
self::$context['request_id'] = $requestId;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($key === 'channel') {
|
||||
$channel = self::normalizeChannel((string) $value);
|
||||
if ($channel !== '') {
|
||||
self::$context['channel'] = $channel;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($key === 'method') {
|
||||
$method = strtoupper(trim((string) $value));
|
||||
if ($method !== '') {
|
||||
self::$context['method'] = substr($method, 0, 8);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($key === 'path') {
|
||||
$path = trim((string) $value);
|
||||
if ($path !== '') {
|
||||
self::$context['path'] = substr($path, 0, 255);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function detectChannel(string $path): string
|
||||
{
|
||||
if (PHP_SAPI === 'cli') {
|
||||
return 'cli';
|
||||
}
|
||||
if (defined('MINTY_API_REQUEST')) {
|
||||
return 'api';
|
||||
}
|
||||
|
||||
$normalizedPath = ltrim(strtolower(trim($path)), '/');
|
||||
if (str_starts_with($normalizedPath, 'api/')) {
|
||||
return 'api';
|
||||
}
|
||||
|
||||
return 'web';
|
||||
}
|
||||
|
||||
private static function normalizeChannel(string $channel): string
|
||||
{
|
||||
$channel = strtolower(trim($channel));
|
||||
return in_array($channel, ['web', 'api', 'scheduler', 'cli'], true) ? $channel : '';
|
||||
}
|
||||
|
||||
// Accept a caller-supplied request ID for cross-service trace correlation.
|
||||
// Only trusted if it's a valid UUID format — arbitrary strings are ignored.
|
||||
private static function requestIdFromHeader(): ?string
|
||||
{
|
||||
$header = trim((string) ($_SERVER['HTTP_X_REQUEST_ID'] ?? ''));
|
||||
if (self::isValidUuid($header)) {
|
||||
return strtolower($header);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function isValidUuid(string $value): bool
|
||||
{
|
||||
return preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i', $value) === 1;
|
||||
}
|
||||
|
||||
private static function normalizeIp(string $ip): string
|
||||
{
|
||||
$ip = trim($ip);
|
||||
if ($ip === '') {
|
||||
return '';
|
||||
}
|
||||
return substr($ip, 0, 45);
|
||||
}
|
||||
|
||||
private static function normalizeUserAgent(string $userAgent): string
|
||||
{
|
||||
$userAgent = trim($userAgent);
|
||||
if ($userAgent === '') {
|
||||
return '';
|
||||
}
|
||||
return substr($userAgent, 0, 255);
|
||||
}
|
||||
}
|
||||
66
core/Http/RequestRuntime.php
Normal file
66
core/Http/RequestRuntime.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
class RequestRuntime implements RequestRuntimeInterface
|
||||
{
|
||||
public function method(): string
|
||||
{
|
||||
$method = strtoupper(trim((string) ($this->context()['method'] ?? '')));
|
||||
if ($method === '') {
|
||||
return 'GET';
|
||||
}
|
||||
|
||||
return substr($method, 0, 8);
|
||||
}
|
||||
|
||||
public function path(): string
|
||||
{
|
||||
return trim((string) ($this->context()['path'] ?? ''));
|
||||
}
|
||||
|
||||
public function ip(): string
|
||||
{
|
||||
return trim((string) ($this->context()['ip'] ?? ''));
|
||||
}
|
||||
|
||||
public function userAgent(): string
|
||||
{
|
||||
return trim((string) ($this->context()['user_agent'] ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function queryParams(): array
|
||||
{
|
||||
/** @var array<string, mixed> $query */
|
||||
$query = $_GET;
|
||||
return $query;
|
||||
}
|
||||
|
||||
public function isSecure(): bool
|
||||
{
|
||||
$https = strtolower(trim((string) ($_SERVER['HTTPS'] ?? '')));
|
||||
if (in_array($https, ['on', '1', 'true'], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$forwarded = strtolower(trim((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')));
|
||||
if ($forwarded === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$parts = explode(',', $forwarded);
|
||||
return trim((string) ($parts[0] ?? '')) === 'https';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function context(): array
|
||||
{
|
||||
RequestContext::ensureStarted();
|
||||
return RequestContext::context();
|
||||
}
|
||||
}
|
||||
21
core/Http/RequestRuntimeInterface.php
Normal file
21
core/Http/RequestRuntimeInterface.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
interface RequestRuntimeInterface
|
||||
{
|
||||
public function method(): string;
|
||||
|
||||
public function path(): string;
|
||||
|
||||
public function ip(): string;
|
||||
|
||||
public function userAgent(): string;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function queryParams(): array;
|
||||
|
||||
public function isSecure(): bool;
|
||||
}
|
||||
100
core/Http/RouteCatalog.php
Normal file
100
core/Http/RouteCatalog.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Loads and validates core route declarations from config/routes.php.
|
||||
*/
|
||||
final class RouteCatalog
|
||||
{
|
||||
/** @var list<array{path: string, target: string, public: bool}> */
|
||||
private array $coreRoutes;
|
||||
|
||||
/** @var list<string> */
|
||||
private array $corePublicPaths;
|
||||
|
||||
/**
|
||||
* @param list<array{path: string, target: string, public: bool}> $coreRoutes
|
||||
* @param list<string> $corePublicPaths
|
||||
*/
|
||||
private function __construct(array $coreRoutes, array $corePublicPaths)
|
||||
{
|
||||
$this->coreRoutes = $coreRoutes;
|
||||
$this->corePublicPaths = $corePublicPaths;
|
||||
}
|
||||
|
||||
public static function fromConfigFile(string $routesFile): self
|
||||
{
|
||||
if (!is_file($routesFile)) {
|
||||
throw new RuntimeException("Core route config not found: {$routesFile}");
|
||||
}
|
||||
|
||||
$loaded = include $routesFile;
|
||||
if (!is_array($loaded)) {
|
||||
throw new RuntimeException("Core route config '{$routesFile}' must return an array.");
|
||||
}
|
||||
|
||||
return self::fromArray($loaded, $routesFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $routes
|
||||
*/
|
||||
public static function fromArray(array $routes, string $source = 'config/routes.php'): self
|
||||
{
|
||||
$normalized = [];
|
||||
$publicPaths = [];
|
||||
$seenPaths = [];
|
||||
|
||||
foreach (array_values($routes) as $index => $route) {
|
||||
if (!is_array($route)) {
|
||||
throw new RuntimeException("Invalid route definition at {$source}[{$index}]: expected array.");
|
||||
}
|
||||
|
||||
$path = trim((string) ($route['path'] ?? ''));
|
||||
$target = trim((string) ($route['target'] ?? ''));
|
||||
if ($path === '' || $target === '') {
|
||||
throw new RuntimeException("Invalid route definition at {$source}[{$index}]: non-empty path and target are required.");
|
||||
}
|
||||
|
||||
if (isset($seenPaths[$path])) {
|
||||
throw new RuntimeException("Core route path conflict: '{$path}' is defined multiple times in {$source}.");
|
||||
}
|
||||
$seenPaths[$path] = true;
|
||||
|
||||
$isPublic = (bool) ($route['public'] ?? false);
|
||||
$normalized[] = [
|
||||
'path' => $path,
|
||||
'target' => $target,
|
||||
'public' => $isPublic,
|
||||
];
|
||||
|
||||
if ($isPublic) {
|
||||
$publicPaths[] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
$publicPaths = array_values(array_unique($publicPaths));
|
||||
sort($publicPaths);
|
||||
|
||||
return new self($normalized, $publicPaths);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{path: string, target: string, public: bool}>
|
||||
*/
|
||||
public function coreRoutes(): array
|
||||
{
|
||||
return $this->coreRoutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function corePublicPaths(): array
|
||||
{
|
||||
return $this->corePublicPaths;
|
||||
}
|
||||
}
|
||||
56
core/Http/RouteRegistrar.php
Normal file
56
core/Http/RouteRegistrar.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\App\Module\ModuleRegistry;
|
||||
use MintyPHP\Router;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Registers core + module routes into the runtime router.
|
||||
*/
|
||||
final class RouteRegistrar
|
||||
{
|
||||
public function __construct(private readonly ModuleRegistry $moduleRegistry)
|
||||
{
|
||||
}
|
||||
|
||||
public function register(RouteCatalog $catalog): void
|
||||
{
|
||||
$coreRoutePaths = [];
|
||||
foreach ($catalog->coreRoutes() as $route) {
|
||||
$path = trim($route['path']);
|
||||
$target = trim($route['target']);
|
||||
if ($path === '' || $target === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$coreRoutePaths[$path] = $target;
|
||||
Router::addRoute($path, $target);
|
||||
}
|
||||
|
||||
$modulePaths = [];
|
||||
foreach ($this->moduleRegistry->getRoutes() as $route) {
|
||||
$path = trim($route['path']);
|
||||
$target = trim($route['target']);
|
||||
$moduleId = trim($route['module_id']);
|
||||
if ($path === '' || $target === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($coreRoutePaths[$path])) {
|
||||
throw new RuntimeException(
|
||||
"Module route path conflict: '{$path}' from module '{$moduleId}' collides with core route target '{$coreRoutePaths[$path]}'."
|
||||
);
|
||||
}
|
||||
if (isset($modulePaths[$path])) {
|
||||
throw new RuntimeException(
|
||||
"Module route path conflict: '{$path}' from module '{$moduleId}' collides with module '{$modulePaths[$path]}'."
|
||||
);
|
||||
}
|
||||
|
||||
$modulePaths[$path] = $moduleId;
|
||||
Router::addRoute($path, $target);
|
||||
}
|
||||
}
|
||||
}
|
||||
50
core/Http/SessionStore.php
Normal file
50
core/Http/SessionStore.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
class SessionStore implements SessionStoreInterface
|
||||
{
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$session = $this->all();
|
||||
if (!array_key_exists($key, $session)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $session[$key];
|
||||
}
|
||||
|
||||
public function set(string $key, mixed $value): void
|
||||
{
|
||||
if (!is_array($_SESSION ?? null)) {
|
||||
$_SESSION = [];
|
||||
}
|
||||
|
||||
$_SESSION[$key] = $value;
|
||||
}
|
||||
|
||||
public function has(string $key): bool
|
||||
{
|
||||
$session = $this->all();
|
||||
return array_key_exists($key, $session);
|
||||
}
|
||||
|
||||
public function remove(string $key): void
|
||||
{
|
||||
if (!is_array($_SESSION ?? null)) {
|
||||
$_SESSION = [];
|
||||
}
|
||||
|
||||
unset($_SESSION[$key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
/** @var array<string, mixed> $session */
|
||||
$session = $_SESSION;
|
||||
return $session;
|
||||
}
|
||||
}
|
||||
19
core/Http/SessionStoreInterface.php
Normal file
19
core/Http/SessionStoreInterface.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
interface SessionStoreInterface
|
||||
{
|
||||
public function get(string $key, mixed $default = null): mixed;
|
||||
|
||||
public function set(string $key, mixed $value): void;
|
||||
|
||||
public function has(string $key): bool;
|
||||
|
||||
public function remove(string $key): void;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function all(): array;
|
||||
}
|
||||
127
core/Repository/Access/PermissionRepository.php
Normal file
127
core/Repository/Access/PermissionRepository.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Access;
|
||||
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Repository\Support\RepoQuery;
|
||||
use MintyPHP\Repository\Support\RepositoryArrayHelper;
|
||||
|
||||
/** Reads and writes permission records; supports active/system flag filtering. */
|
||||
class PermissionRepository implements PermissionRepositoryInterface
|
||||
{
|
||||
public function list(): array
|
||||
{
|
||||
$rows = DB::select('select id, `key`, description, active, is_system, created from permissions order by `key` asc');
|
||||
|
||||
return RepositoryArrayHelper::unwrapList($rows, 'permissions');
|
||||
}
|
||||
|
||||
public function listActive(): array
|
||||
{
|
||||
$rows = DB::select(
|
||||
'select id, `key`, description, active, is_system, created from permissions where active = 1 order by `key` asc'
|
||||
);
|
||||
|
||||
return RepositoryArrayHelper::unwrapList($rows, 'permissions');
|
||||
}
|
||||
|
||||
public function listPaged(array $options): array
|
||||
{
|
||||
$search = trim((string) ($options['search'] ?? ''));
|
||||
$allowedOrder = [
|
||||
'key' => '`key`',
|
||||
'description' => 'description',
|
||||
'active' => 'active',
|
||||
'created' => 'created',
|
||||
];
|
||||
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($options);
|
||||
[$order, $dir] = RepoQuery::sanitizeOrder($options, array_keys($allowedOrder), 'key', 'asc');
|
||||
$orderBy = $allowedOrder[$order] ?? $allowedOrder['key'];
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
RepoQuery::addLikeFilter($where, $params, ['permissions.`key`', 'permissions.description'], $search);
|
||||
$activeValue = array_key_exists('active', $options) ? $options['active'] : null;
|
||||
RepoQuery::addEnumFilter($where, $params, $activeValue, [
|
||||
['aliases' => ['1', 'true', 'active'], 'sql' => 'permissions.active = 1'],
|
||||
['aliases' => ['0', 'false', 'inactive'], 'sql' => 'permissions.active = 0'],
|
||||
]);
|
||||
$whereSql = $where ? ' where ' . implode(' and ', $where) : '';
|
||||
|
||||
$total = DB::selectValue('select count(*) from permissions' . $whereSql, ...$params);
|
||||
$query = 'select id, `key`, description, active, is_system, created from permissions' . $whereSql .
|
||||
' order by ' . $orderBy . ' ' . $dir . ' limit ? offset ?';
|
||||
$queryParams = array_merge($params, [(string) $limit, (string) $offset]);
|
||||
$rows = DB::select($query, ...$queryParams);
|
||||
|
||||
return ['rows' => RepositoryArrayHelper::unwrapList($rows, 'permissions'), 'total' => (int) $total];
|
||||
}
|
||||
|
||||
public function find(int $id): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, `key`, description, active, is_system, created from permissions where id = ? limit 1',
|
||||
(string) $id
|
||||
);
|
||||
|
||||
return RepositoryArrayHelper::unwrap($row, 'permissions');
|
||||
}
|
||||
|
||||
public function findByKey(string $key): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, `key`, description, active, is_system, created from permissions where `key` = ? limit 1',
|
||||
$key
|
||||
);
|
||||
|
||||
return RepositoryArrayHelper::unwrap($row, 'permissions');
|
||||
}
|
||||
|
||||
public function create(array $data): ?int
|
||||
{
|
||||
$key = trim((string) ($data['key'] ?? ''));
|
||||
$desc = trim((string) ($data['description'] ?? ''));
|
||||
$active = (int) ($data['active'] ?? 1);
|
||||
$isSystem = (int) ($data['is_system'] ?? 0);
|
||||
$result = DB::insert(
|
||||
'insert into permissions (`key`, description, active, is_system, created) values (?,?,?,?,NOW())',
|
||||
$key,
|
||||
$desc !== '' ? $desc : null,
|
||||
(string) $active,
|
||||
(string) $isSystem
|
||||
);
|
||||
return $result ? (int) $result : null;
|
||||
}
|
||||
|
||||
public function update(int $id, array $data): bool
|
||||
{
|
||||
$key = trim((string) ($data['key'] ?? ''));
|
||||
$desc = trim((string) ($data['description'] ?? ''));
|
||||
$active = (int) ($data['active'] ?? 1);
|
||||
$isSystem = (int) ($data['is_system'] ?? 0);
|
||||
$result = DB::update(
|
||||
'update permissions set `key` = ?, description = ?, active = ?, is_system = ? where id = ?',
|
||||
$key,
|
||||
$desc !== '' ? $desc : null,
|
||||
(string) $active,
|
||||
(string) $isSystem,
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
$result = DB::delete('delete from permissions where id = ?', (string) $id);
|
||||
return (bool) $result;
|
||||
}
|
||||
|
||||
public function listSystem(): array
|
||||
{
|
||||
$rows = DB::select(
|
||||
'select id, `key`, description, active, is_system, created from permissions where is_system = 1 order by `key` asc'
|
||||
);
|
||||
|
||||
return RepositoryArrayHelper::unwrapList($rows, 'permissions');
|
||||
}
|
||||
}
|
||||
26
core/Repository/Access/PermissionRepositoryInterface.php
Normal file
26
core/Repository/Access/PermissionRepositoryInterface.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Access;
|
||||
|
||||
/** Contract for permission CRUD and lookup by key or ID. */
|
||||
interface PermissionRepositoryInterface
|
||||
{
|
||||
public function list(): array;
|
||||
|
||||
public function listActive(): array;
|
||||
|
||||
public function listPaged(array $options): array;
|
||||
|
||||
public function find(int $id): ?array;
|
||||
|
||||
public function findByKey(string $key): ?array;
|
||||
|
||||
public function create(array $data): ?int;
|
||||
|
||||
public function update(int $id, array $data): bool;
|
||||
|
||||
public function delete(int $id): bool;
|
||||
|
||||
/** @return list<array{id: int, key: string, description: string, active: int, is_system: int}> */
|
||||
public function listSystem(): array;
|
||||
}
|
||||
75
core/Repository/Access/RoleAssignableRoleRepository.php
Normal file
75
core/Repository/Access/RoleAssignableRoleRepository.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Access;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
/** Manages which roles a given role is allowed to assign (role hierarchy). */
|
||||
class RoleAssignableRoleRepository implements RoleAssignableRoleRepositoryInterface
|
||||
{
|
||||
public function listAssignableRoleIdsByRoleId(int $roleId): array
|
||||
{
|
||||
$rows = DB::select(
|
||||
'select assignable_role_id from role_assignable_roles where role_id = ?',
|
||||
(string) $roleId
|
||||
);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['role_assignable_roles'] ?? $row;
|
||||
if (is_array($data) && isset($data['assignable_role_id'])) {
|
||||
$ids[] = (int) $data['assignable_role_id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public function listAssignableRoleIdsByRoleIds(array $roleIds): array
|
||||
{
|
||||
$roleIds = array_values(array_unique(array_map('intval', $roleIds)));
|
||||
$roleIds = array_values(array_filter($roleIds, static fn (int $id): bool => $id > 0));
|
||||
if (!$roleIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($roleIds), '?'));
|
||||
$rows = DB::select(
|
||||
'select distinct assignable_role_id from role_assignable_roles where role_id in (' .
|
||||
$placeholders .
|
||||
')',
|
||||
...array_map('strval', $roleIds)
|
||||
);
|
||||
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['role_assignable_roles'] ?? $row;
|
||||
if (is_array($data) && isset($data['assignable_role_id'])) {
|
||||
$ids[] = (int) $data['assignable_role_id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public function replaceForRole(int $roleId, array $assignableRoleIds): bool
|
||||
{
|
||||
DB::delete('delete from role_assignable_roles where role_id = ?', (string) $roleId);
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $assignableRoleIds))));
|
||||
if (!$ids) {
|
||||
return true;
|
||||
}
|
||||
foreach ($ids as $assignableRoleId) {
|
||||
DB::insert(
|
||||
'insert into role_assignable_roles (role_id, assignable_role_id, created) values (?,?,NOW())',
|
||||
(string) $roleId,
|
||||
(string) $assignableRoleId
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Access;
|
||||
|
||||
interface RoleAssignableRoleRepositoryInterface
|
||||
{
|
||||
/** @return int[] */
|
||||
public function listAssignableRoleIdsByRoleId(int $roleId): array;
|
||||
|
||||
/**
|
||||
* Union of assignable role IDs across multiple roles (e.g. all roles of an actor).
|
||||
* @param int[] $roleIds
|
||||
* @return int[]
|
||||
*/
|
||||
public function listAssignableRoleIdsByRoleIds(array $roleIds): array;
|
||||
|
||||
/**
|
||||
* Atomic replace: delete all + re-insert.
|
||||
* @param int[] $assignableRoleIds
|
||||
*/
|
||||
public function replaceForRole(int $roleId, array $assignableRoleIds): bool;
|
||||
}
|
||||
198
core/Repository/Access/RolePermissionRepository.php
Normal file
198
core/Repository/Access/RolePermissionRepository.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Access;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
/** Manages the many-to-many link between roles and permissions. */
|
||||
class RolePermissionRepository implements RolePermissionRepositoryInterface
|
||||
{
|
||||
public function listPermissionIdsByRoleId(int $roleId): array
|
||||
{
|
||||
$rows = DB::select('select permission_id from role_permissions where role_id = ?', (string) $roleId);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['role_permissions'] ?? $row;
|
||||
if (is_array($data) && isset($data['permission_id'])) {
|
||||
$ids[] = (int) $data['permission_id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public function countPermissionsByRoleIds(array $roleIds): array
|
||||
{
|
||||
$roleIds = array_values(array_unique(array_map('intval', $roleIds)));
|
||||
$roleIds = array_values(array_filter($roleIds, static fn ($id) => $id > 0));
|
||||
if (!$roleIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($roleIds), '?'));
|
||||
$rows = DB::select(
|
||||
'select rp.role_id, count(distinct rp.permission_id) as permission_count from role_permissions rp where rp.role_id in (' .
|
||||
$placeholders .
|
||||
') group by rp.role_id',
|
||||
...array_map('strval', $roleIds)
|
||||
);
|
||||
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($rows as $row) {
|
||||
$roleId = self::extractIntField($row, 'role_id');
|
||||
if ($roleId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$result[$roleId] = self::extractIntField($row, 'permission_count');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function replaceForRole(int $roleId, array $permissionIds): bool
|
||||
{
|
||||
DB::delete('delete from role_permissions where role_id = ?', (string) $roleId);
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $permissionIds))));
|
||||
if (!$ids) {
|
||||
return true;
|
||||
}
|
||||
foreach ($ids as $permissionId) {
|
||||
DB::insert(
|
||||
'insert into role_permissions (role_id, permission_id, created) values (?,?,NOW())',
|
||||
(string) $roleId,
|
||||
(string) $permissionId
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function listRoleIdsByPermissionId(int $permissionId): array
|
||||
{
|
||||
$rows = DB::select('select role_id from role_permissions where permission_id = ?', (string) $permissionId);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['role_permissions'] ?? $row;
|
||||
if (is_array($data) && isset($data['role_id'])) {
|
||||
$ids[] = (int) $data['role_id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public function replaceForPermission(int $permissionId, array $roleIds): bool
|
||||
{
|
||||
DB::delete('delete from role_permissions where permission_id = ?', (string) $permissionId);
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $roleIds))));
|
||||
if (!$ids) {
|
||||
return true;
|
||||
}
|
||||
foreach ($ids as $roleId) {
|
||||
DB::insert(
|
||||
'insert into role_permissions (role_id, permission_id, created) values (?,?,NOW())',
|
||||
(string) $roleId,
|
||||
(string) $permissionId
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function listPermissionKeysByRoleIds(array $roleIds): array
|
||||
{
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $roleIds))));
|
||||
if (!$ids) {
|
||||
return [];
|
||||
}
|
||||
$rows = DB::select(
|
||||
'select p.`key` as `key` from role_permissions rp ' .
|
||||
'join roles r on r.id = rp.role_id and r.active = 1 ' .
|
||||
'join permissions p on p.id = rp.permission_id and p.active = 1 ' .
|
||||
'where rp.role_id in (???)',
|
||||
array_map('strval', $ids)
|
||||
);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$keys = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['p'] ?? $row;
|
||||
if (is_array($data) && isset($data['key'])) {
|
||||
$keys[] = (string) $data['key'];
|
||||
} elseif (is_array($row) && isset($row['key'])) {
|
||||
$keys[] = (string) $row['key'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($keys));
|
||||
}
|
||||
|
||||
public function listPermissionsWithRolesByRoleIds(array $roleIds): array
|
||||
{
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $roleIds))));
|
||||
if (!$ids) {
|
||||
return [];
|
||||
}
|
||||
$rows = DB::select(
|
||||
'select rp.permission_id as permission_id, p.`key` as `key`, p.description as `description`, r.description as role ' .
|
||||
'from role_permissions rp join permissions p on p.id = rp.permission_id and p.active = 1 ' .
|
||||
'join roles r on r.id = rp.role_id and r.active = 1 where rp.role_id in (???) ' .
|
||||
'order by p.`key` asc, r.description asc',
|
||||
array_map('strval', $ids)
|
||||
);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$permMap = [];
|
||||
foreach ($rows as $row) {
|
||||
$rowPerm = $row['p'] ?? ($row['permissions'] ?? $row);
|
||||
$rowRole = $row['r'] ?? ($row['roles'] ?? $row);
|
||||
$rowRp = $row['rp'] ?? ($row['role_permissions'] ?? $row);
|
||||
|
||||
$permId = $rowRp['permission_id'] ?? ($row['permission_id'] ?? null);
|
||||
$key = (string) ($rowPerm['key'] ?? $row['key'] ?? '');
|
||||
if ($permId === null || $key === '') {
|
||||
continue;
|
||||
}
|
||||
if (!isset($permMap[$permId])) {
|
||||
$desc = (string) ($rowPerm['description'] ?? $row['description'] ?? '');
|
||||
$permMap[$permId] = [
|
||||
'key' => $key,
|
||||
'description' => $desc,
|
||||
'roles' => [],
|
||||
];
|
||||
}
|
||||
$roleLabel = (string) ($rowRole['description'] ?? $rowRole['role'] ?? $row['role'] ?? '');
|
||||
if ($roleLabel !== '') {
|
||||
$permMap[$permId]['roles'][] = $roleLabel;
|
||||
}
|
||||
}
|
||||
foreach ($permMap as &$item) {
|
||||
$item['roles'] = array_values(array_unique($item['roles']));
|
||||
}
|
||||
unset($item);
|
||||
return array_values($permMap);
|
||||
}
|
||||
|
||||
private function extractIntField(array $row, string $field): int
|
||||
{
|
||||
foreach ($row as $value) {
|
||||
if (!is_array($value)) {
|
||||
continue;
|
||||
}
|
||||
if (array_key_exists($field, $value)) {
|
||||
return (int) $value[$field];
|
||||
}
|
||||
}
|
||||
if (array_key_exists($field, $row)) {
|
||||
return (int) $row[$field];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
21
core/Repository/Access/RolePermissionRepositoryInterface.php
Normal file
21
core/Repository/Access/RolePermissionRepositoryInterface.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Access;
|
||||
|
||||
/** Contract for managing permission assignments on roles. */
|
||||
interface RolePermissionRepositoryInterface
|
||||
{
|
||||
public function listPermissionIdsByRoleId(int $roleId): array;
|
||||
|
||||
public function countPermissionsByRoleIds(array $roleIds): array;
|
||||
|
||||
public function replaceForRole(int $roleId, array $permissionIds): bool;
|
||||
|
||||
public function listRoleIdsByPermissionId(int $permissionId): array;
|
||||
|
||||
public function replaceForPermission(int $permissionId, array $roleIds): bool;
|
||||
|
||||
public function listPermissionKeysByRoleIds(array $roleIds): array;
|
||||
|
||||
public function listPermissionsWithRolesByRoleIds(array $roleIds): array;
|
||||
}
|
||||
163
core/Repository/Access/RoleRepository.php
Normal file
163
core/Repository/Access/RoleRepository.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Access;
|
||||
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Repository\Support\RepoQuery;
|
||||
use MintyPHP\Repository\Support\RepositoryArrayHelper;
|
||||
|
||||
/** Reads and writes role records with pagination, search, and code uniqueness checks. */
|
||||
class RoleRepository implements RoleRepositoryInterface
|
||||
{
|
||||
public function list(): array
|
||||
{
|
||||
$rows = DB::select(
|
||||
'select id, uuid, description, code, active, created_by, modified_by, created, modified from roles order by id desc'
|
||||
);
|
||||
return RepositoryArrayHelper::unwrapList($rows, 'roles');
|
||||
}
|
||||
|
||||
public function listActive(): array
|
||||
{
|
||||
$rows = DB::select(
|
||||
'select id, uuid, description, code, active, created_by, modified_by, created, modified from roles where active = 1 order by description asc'
|
||||
);
|
||||
return RepositoryArrayHelper::unwrapList($rows, 'roles');
|
||||
}
|
||||
|
||||
public function listByIds(array $roleIds): array
|
||||
{
|
||||
$roleIds = RepositoryArrayHelper::sanitizePositiveIds($roleIds);
|
||||
if (!$roleIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($roleIds), '?'));
|
||||
$rows = DB::select(
|
||||
'select id, uuid, description, code, active, created_by, modified_by, created, modified from roles where id in (' . $placeholders . ')',
|
||||
...array_map('strval', $roleIds)
|
||||
);
|
||||
return RepositoryArrayHelper::unwrapList($rows, 'roles');
|
||||
}
|
||||
|
||||
public function listIds(): array
|
||||
{
|
||||
$rows = DB::select('select id from roles');
|
||||
|
||||
return RepositoryArrayHelper::extractIds($rows, 'roles');
|
||||
}
|
||||
|
||||
public function listActiveIds(): array
|
||||
{
|
||||
$rows = DB::select('select id from roles where active = 1');
|
||||
|
||||
return RepositoryArrayHelper::extractIds($rows, 'roles');
|
||||
}
|
||||
|
||||
public function listPaged(array $options): array
|
||||
{
|
||||
$search = trim((string) ($options['search'] ?? ''));
|
||||
$allowedOrder = ['id', 'uuid', 'description', 'code', 'active', 'created', 'modified'];
|
||||
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($options);
|
||||
[$order, $dir] = RepoQuery::sanitizeOrder($options, $allowedOrder);
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
RepoQuery::addLikeFilter($where, $params, ['description', 'uuid', 'code'], $search);
|
||||
$activeValue = array_key_exists('active', $options) ? $options['active'] : null;
|
||||
RepoQuery::addEnumFilter($where, $params, $activeValue, [
|
||||
['aliases' => ['1', 'true', 'active'], 'sql' => 'roles.active = 1'],
|
||||
['aliases' => ['0', 'false', 'inactive'], 'sql' => 'roles.active = 0'],
|
||||
]);
|
||||
|
||||
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
|
||||
$count = DB::selectValue('select count(*) from roles' . $whereSql, ...$params);
|
||||
$total = $count ? (int) $count : 0;
|
||||
|
||||
$query = 'select id, uuid, description, code, active, created_by, modified_by, created, modified from roles' .
|
||||
$whereSql .
|
||||
sprintf(' order by `%s` %s limit ? offset ?', $order, $dir);
|
||||
|
||||
$queryParams = array_merge($params, [(string) $limit, (string) $offset]);
|
||||
$rows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge([$query], $queryParams));
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'rows' => RepositoryArrayHelper::unwrapList($rows, 'roles'),
|
||||
];
|
||||
}
|
||||
|
||||
public function find(int $id): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, description, code, active, created_by, modified_by, created, modified from roles where id = ? limit 1',
|
||||
(string) $id
|
||||
);
|
||||
return RepositoryArrayHelper::unwrap($row, 'roles');
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, description, code, active, created_by, modified_by, created, modified from roles where uuid = ? limit 1',
|
||||
$uuid
|
||||
);
|
||||
return RepositoryArrayHelper::unwrap($row, 'roles');
|
||||
}
|
||||
|
||||
public function existsByCode(string $code, int $excludeId = 0): bool
|
||||
{
|
||||
$code = trim($code);
|
||||
if ($code === '') {
|
||||
return false;
|
||||
}
|
||||
if ($excludeId > 0) {
|
||||
$count = DB::selectValue('select count(*) from roles where code = ? and id != ?', $code, (string) $excludeId);
|
||||
} else {
|
||||
$count = DB::selectValue('select count(*) from roles where code = ?', $code);
|
||||
}
|
||||
return $count ? ((int) $count > 0) : false;
|
||||
}
|
||||
|
||||
public function create(array $data): int|false
|
||||
{
|
||||
return DB::insert(
|
||||
'insert into roles (uuid, description, code, active, created_by, created) values (?,?,?,?,?,NOW())',
|
||||
$data['uuid'] ?? RepoQuery::uuidV4(),
|
||||
$data['description'],
|
||||
$data['code'] ?? null,
|
||||
$data['active'] ?? 1,
|
||||
$data['created_by'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
public function update(int $id, array $data): bool
|
||||
{
|
||||
$fields = [
|
||||
'description' => $data['description'],
|
||||
'code' => $data['code'] ?? null,
|
||||
'active' => $data['active'] ?? 1,
|
||||
];
|
||||
if (array_key_exists('modified_by', $data)) {
|
||||
$fields['modified_by'] = $data['modified_by'];
|
||||
}
|
||||
|
||||
$setParts = [];
|
||||
$params = [];
|
||||
foreach ($fields as $field => $value) {
|
||||
$setParts[] = sprintf('`%s` = ?', $field);
|
||||
$params[] = $value;
|
||||
}
|
||||
$params[] = (string) $id;
|
||||
|
||||
$query = 'update roles set ' . implode(', ', $setParts) . ' where id = ?';
|
||||
$result = DB::update($query, ...$params);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
$result = DB::delete('delete from roles where id = ?', (string) $id);
|
||||
return $result !== false;
|
||||
}
|
||||
}
|
||||
31
core/Repository/Access/RoleRepositoryInterface.php
Normal file
31
core/Repository/Access/RoleRepositoryInterface.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Access;
|
||||
|
||||
/** Contract for role CRUD, existence checks, and filtered listing. */
|
||||
interface RoleRepositoryInterface
|
||||
{
|
||||
public function list(): array;
|
||||
|
||||
public function listActive(): array;
|
||||
|
||||
public function listByIds(array $roleIds): array;
|
||||
|
||||
public function listIds(): array;
|
||||
|
||||
public function listActiveIds(): array;
|
||||
|
||||
public function listPaged(array $options): array;
|
||||
|
||||
public function find(int $id): ?array;
|
||||
|
||||
public function findByUuid(string $uuid): ?array;
|
||||
|
||||
public function existsByCode(string $code, int $excludeId = 0): bool;
|
||||
|
||||
public function create(array $data): int|false;
|
||||
|
||||
public function update(int $id, array $data): bool;
|
||||
|
||||
public function delete(int $id): bool;
|
||||
}
|
||||
110
core/Repository/Access/UserRoleRepository.php
Normal file
110
core/Repository/Access/UserRoleRepository.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Access;
|
||||
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Repository\Support\RepositoryArrayHelper;
|
||||
|
||||
/** Queries user-role assignments and counts active/inactive users per role. */
|
||||
class UserRoleRepository implements UserRoleRepositoryInterface
|
||||
{
|
||||
public function listRoleIdsByUserId(int $userId): array
|
||||
{
|
||||
$rows = DB::select('select role_id from user_roles where user_id = ?', (string) $userId);
|
||||
|
||||
return RepositoryArrayHelper::extractIds($rows, 'user_roles', 'role_id');
|
||||
}
|
||||
|
||||
public function replaceForUser(int $userId, array $roleIds): bool
|
||||
{
|
||||
DB::delete('delete from user_roles where user_id = ?', (string) $userId);
|
||||
if (!$roleIds) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($roleIds as $roleId) {
|
||||
DB::insert(
|
||||
'insert into user_roles (user_id, role_id, created) values (?,?,NOW())',
|
||||
(string) $userId,
|
||||
(string) $roleId
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function listUserIdsByRoleIds(array $roleIds): array
|
||||
{
|
||||
$ids = RepositoryArrayHelper::sanitizePositiveIds($roleIds);
|
||||
if (!$ids) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = DB::select(
|
||||
'select distinct user_id from user_roles where role_id in (???)',
|
||||
array_map('strval', $ids)
|
||||
);
|
||||
|
||||
return RepositoryArrayHelper::sanitizePositiveIds(
|
||||
RepositoryArrayHelper::extractIds($rows, 'user_roles', 'user_id')
|
||||
);
|
||||
}
|
||||
|
||||
public function countUsersByRoleIds(array $roleIds): array
|
||||
{
|
||||
return $this->countByRoleIds($roleIds, false);
|
||||
}
|
||||
|
||||
public function countActiveUsersByRoleIds(array $roleIds): array
|
||||
{
|
||||
return $this->countByRoleIds($roleIds, true);
|
||||
}
|
||||
|
||||
private function countByRoleIds(array $roleIds, bool $activeOnly): array
|
||||
{
|
||||
$roleIds = RepositoryArrayHelper::sanitizePositiveIds($roleIds);
|
||||
if (!$roleIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($roleIds), '?'));
|
||||
$joinUsersSql = $activeOnly ? ' join users u on u.id = ur.user_id and u.active = 1' : '';
|
||||
$rows = DB::select(
|
||||
'select ur.role_id, count(distinct ur.user_id) as user_count from user_roles ur' .
|
||||
$joinUsersSql .
|
||||
' where ur.role_id in (' . $placeholders . ') group by ur.role_id',
|
||||
...array_map('strval', $roleIds)
|
||||
);
|
||||
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($rows as $row) {
|
||||
$roleId = $this->extractIntField($row, 'role_id');
|
||||
if ($roleId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$result[$roleId] = $this->extractIntField($row, 'user_count');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function extractIntField(array $row, string $field): int
|
||||
{
|
||||
foreach ($row as $value) {
|
||||
if (!is_array($value)) {
|
||||
continue;
|
||||
}
|
||||
if (array_key_exists($field, $value)) {
|
||||
return (int) $value[$field];
|
||||
}
|
||||
}
|
||||
if (array_key_exists($field, $row)) {
|
||||
return (int) $row[$field];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
17
core/Repository/Access/UserRoleRepositoryInterface.php
Normal file
17
core/Repository/Access/UserRoleRepositoryInterface.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Access;
|
||||
|
||||
/** Contract for querying user-role relationships and role usage counts. */
|
||||
interface UserRoleRepositoryInterface
|
||||
{
|
||||
public function listRoleIdsByUserId(int $userId): array;
|
||||
|
||||
public function replaceForUser(int $userId, array $roleIds): bool;
|
||||
|
||||
public function listUserIdsByRoleIds(array $roleIds): array;
|
||||
|
||||
public function countUsersByRoleIds(array $roleIds): array;
|
||||
|
||||
public function countActiveUsersByRoleIds(array $roleIds): array;
|
||||
}
|
||||
223
core/Repository/Auth/ApiTokenRepository.php
Normal file
223
core/Repository/Auth/ApiTokenRepository.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Auth;
|
||||
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Repository\Support\RepoQuery;
|
||||
|
||||
/** Persists API tokens with selector/hash pairs, expiry tracking, and usage timestamps. */
|
||||
class ApiTokenRepository implements ApiTokenRepositoryInterface
|
||||
{
|
||||
private const UUID_REGEX = '/^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i';
|
||||
|
||||
private function unwrapList($rows): array
|
||||
{
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['user_api_tokens'] ?? $row;
|
||||
if (is_array($data)) {
|
||||
$list[] = $data;
|
||||
}
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public function isUuid(string $value): bool
|
||||
{
|
||||
return (bool) preg_match(self::UUID_REGEX, trim($value));
|
||||
}
|
||||
|
||||
public function create(
|
||||
int $userId,
|
||||
string $name,
|
||||
string $selector,
|
||||
string $tokenHash,
|
||||
?int $tenantId,
|
||||
?string $expiresAt,
|
||||
?int $createdBy
|
||||
): ?int {
|
||||
$id = DB::insert(
|
||||
'insert into user_api_tokens (uuid, user_id, name, selector, token_hash, tenant_id, expires_at, created_by, created) values (?,?,?,?,?,?,?,?,NOW())',
|
||||
RepoQuery::uuidV4(),
|
||||
(string) $userId,
|
||||
$name,
|
||||
$selector,
|
||||
$tokenHash,
|
||||
$tenantId !== null ? (string) $tenantId : null,
|
||||
$expiresAt,
|
||||
$createdBy !== null ? (string) $createdBy : null
|
||||
);
|
||||
return $id ? (int) $id : null;
|
||||
}
|
||||
|
||||
public function findBySelector(string $selector): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, user_id, name, selector, token_hash, tenant_id, last_used_at, last_ip, expires_at, revoked_at, created_by, created from user_api_tokens where selector = ? limit 1',
|
||||
$selector
|
||||
);
|
||||
if (!$row || !isset($row['user_api_tokens'])) {
|
||||
return null;
|
||||
}
|
||||
return $row['user_api_tokens'];
|
||||
}
|
||||
|
||||
public function find(int $id): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, user_id, name, selector, tenant_id, last_used_at, last_ip, expires_at, revoked_at, created_by, created from user_api_tokens where id = ? limit 1',
|
||||
(string) $id
|
||||
);
|
||||
if (!$row || !isset($row['user_api_tokens'])) {
|
||||
return null;
|
||||
}
|
||||
return $row['user_api_tokens'];
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?array
|
||||
{
|
||||
$uuid = trim($uuid);
|
||||
if (!$this->isUuid($uuid)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, user_id, name, selector, tenant_id, last_used_at, last_ip, expires_at, revoked_at, created_by, created from user_api_tokens where uuid = ? limit 1',
|
||||
$uuid
|
||||
);
|
||||
if (!$row || !isset($row['user_api_tokens'])) {
|
||||
return null;
|
||||
}
|
||||
return $row['user_api_tokens'];
|
||||
}
|
||||
|
||||
public function findByUuidForUser(string $uuid, int $userId): ?array
|
||||
{
|
||||
$uuid = trim($uuid);
|
||||
if ($userId <= 0 || !$this->isUuid($uuid)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, user_id, name, selector, tenant_id, last_used_at, last_ip, expires_at, revoked_at, created_by, created from user_api_tokens where uuid = ? and user_id = ? limit 1',
|
||||
$uuid,
|
||||
(string) $userId
|
||||
);
|
||||
if (!$row || !isset($row['user_api_tokens'])) {
|
||||
return null;
|
||||
}
|
||||
return $row['user_api_tokens'];
|
||||
}
|
||||
|
||||
public function updateLastUsed(int $id, string $ip): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update user_api_tokens set last_used_at = NOW(), last_ip = ? where id = ?',
|
||||
$ip,
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function revoke(int $id): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update user_api_tokens set revoked_at = NOW() where id = ? and revoked_at is null',
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function revokeByUuidForUser(string $uuid, int $userId): bool
|
||||
{
|
||||
$uuid = trim($uuid);
|
||||
if ($userId <= 0 || !$this->isUuid($uuid)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = DB::update(
|
||||
'update user_api_tokens set revoked_at = NOW() where uuid = ? and user_id = ? and revoked_at is null',
|
||||
$uuid,
|
||||
(string) $userId
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function revokeAllForUser(int $userId, ?int $tenantId = null): int
|
||||
{
|
||||
if ($tenantId !== null && $tenantId > 0) {
|
||||
$result = DB::update(
|
||||
'update user_api_tokens set revoked_at = NOW() where user_id = ? and (tenant_id = ? or tenant_id is null) and revoked_at is null',
|
||||
(string) $userId,
|
||||
(string) $tenantId
|
||||
);
|
||||
} else {
|
||||
$result = DB::update(
|
||||
'update user_api_tokens set revoked_at = NOW() where user_id = ? and revoked_at is null',
|
||||
(string) $userId
|
||||
);
|
||||
}
|
||||
return $result !== false ? (int) $result : 0;
|
||||
}
|
||||
|
||||
public function revokeAllActiveByAdmin(): int
|
||||
{
|
||||
$result = DB::update(
|
||||
'update user_api_tokens set revoked_at = NOW() where revoked_at is null and (expires_at is null or expires_at > NOW())'
|
||||
);
|
||||
return $result !== false ? (int) $result : 0;
|
||||
}
|
||||
|
||||
public function listByUserId(int $userId, int $limit = 25): array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return [];
|
||||
}
|
||||
if ($limit < 1) {
|
||||
$limit = 25;
|
||||
} elseif ($limit > 100) {
|
||||
$limit = 100;
|
||||
}
|
||||
$rows = DB::select(
|
||||
'select id, uuid, user_id, name, selector, tenant_id, last_used_at, last_ip, expires_at, revoked_at, created_by, created from user_api_tokens where user_id = ? order by id desc limit ?',
|
||||
(string) $userId,
|
||||
(string) $limit
|
||||
);
|
||||
return $this->unwrapList($rows);
|
||||
}
|
||||
|
||||
public function countActiveForUser(int $userId): int
|
||||
{
|
||||
$count = DB::selectValue(
|
||||
'select count(*) from user_api_tokens where user_id = ? and revoked_at is null and (expires_at is null or expires_at > NOW())',
|
||||
(string) $userId
|
||||
);
|
||||
return $count ? (int) $count : 0;
|
||||
}
|
||||
|
||||
public function countActiveForUserExcludingId(int $userId, int $excludeId): int
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = DB::selectValue(
|
||||
'select count(*) from user_api_tokens where user_id = ? and id != ? and revoked_at is null and (expires_at is null or expires_at > NOW())',
|
||||
(string) $userId,
|
||||
(string) $excludeId
|
||||
);
|
||||
return $count ? (int) $count : 0;
|
||||
}
|
||||
|
||||
public function countActive(): int
|
||||
{
|
||||
$count = DB::selectValue(
|
||||
'select count(*) from user_api_tokens where revoked_at is null and (expires_at is null or expires_at > NOW())'
|
||||
);
|
||||
return $count ? (int) $count : 0;
|
||||
}
|
||||
|
||||
}
|
||||
45
core/Repository/Auth/ApiTokenRepositoryInterface.php
Normal file
45
core/Repository/Auth/ApiTokenRepositoryInterface.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Auth;
|
||||
|
||||
/** Contract for API bearer token creation, lookup by selector or UUID, and revocation. */
|
||||
interface ApiTokenRepositoryInterface
|
||||
{
|
||||
public function isUuid(string $value): bool;
|
||||
|
||||
public function create(
|
||||
int $userId,
|
||||
string $name,
|
||||
string $selector,
|
||||
string $tokenHash,
|
||||
?int $tenantId,
|
||||
?string $expiresAt,
|
||||
?int $createdBy
|
||||
): ?int;
|
||||
|
||||
public function findBySelector(string $selector): ?array;
|
||||
|
||||
public function find(int $id): ?array;
|
||||
|
||||
public function findByUuid(string $uuid): ?array;
|
||||
|
||||
public function findByUuidForUser(string $uuid, int $userId): ?array;
|
||||
|
||||
public function updateLastUsed(int $id, string $ip): bool;
|
||||
|
||||
public function revoke(int $id): bool;
|
||||
|
||||
public function revokeByUuidForUser(string $uuid, int $userId): bool;
|
||||
|
||||
public function revokeAllForUser(int $userId, ?int $tenantId = null): int;
|
||||
|
||||
public function revokeAllActiveByAdmin(): int;
|
||||
|
||||
public function listByUserId(int $userId, int $limit = 25): array;
|
||||
|
||||
public function countActiveForUser(int $userId): int;
|
||||
|
||||
public function countActiveForUserExcludingId(int $userId, int $excludeId): int;
|
||||
|
||||
public function countActive(): int;
|
||||
}
|
||||
72
core/Repository/Auth/EmailVerificationRepository.php
Normal file
72
core/Repository/Auth/EmailVerificationRepository.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Auth;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
/** Stores email verification codes with attempt counters, expiry, and completion status. */
|
||||
class EmailVerificationRepository implements EmailVerificationRepositoryInterface
|
||||
{
|
||||
public function create(int $userId, string $codeHash, string $expiresAt): ?int
|
||||
{
|
||||
$id = DB::insert(
|
||||
'insert into email_verifications (user_id, code_hash, expires_at, attempts, used_at, created) values (?,?,?,?,NULL,NOW())',
|
||||
(string) $userId,
|
||||
$codeHash,
|
||||
$expiresAt,
|
||||
0
|
||||
);
|
||||
return $id ? (int) $id : null;
|
||||
}
|
||||
|
||||
public function invalidateForUser(int $userId): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update email_verifications set used_at = NOW() where user_id = ? and used_at is null',
|
||||
(string) $userId
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function findActiveByUserId(int $userId): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, user_id, code_hash, expires_at, attempts, used_at from email_verifications where user_id = ? and used_at is null and expires_at > UTC_TIMESTAMP() order by id desc limit 1',
|
||||
(string) $userId
|
||||
);
|
||||
if (!$row || !isset($row['email_verifications'])) {
|
||||
return null;
|
||||
}
|
||||
return $row['email_verifications'];
|
||||
}
|
||||
|
||||
public function findById(int $id): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, user_id, code_hash, expires_at, attempts, used_at from email_verifications where id = ? limit 1',
|
||||
(string) $id
|
||||
);
|
||||
if (!$row || !isset($row['email_verifications'])) {
|
||||
return null;
|
||||
}
|
||||
return $row['email_verifications'];
|
||||
}
|
||||
|
||||
public function incrementAttempts(int $id): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update email_verifications set attempts = attempts + 1 where id = ?',
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function markUsed(int $id): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update email_verifications set used_at = NOW() where id = ?',
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Auth;
|
||||
|
||||
/** Contract for email verification code lifecycle: creation, lookup, attempt tracking, and completion. */
|
||||
interface EmailVerificationRepositoryInterface
|
||||
{
|
||||
public function create(int $userId, string $codeHash, string $expiresAt): ?int;
|
||||
|
||||
public function invalidateForUser(int $userId): bool;
|
||||
|
||||
public function findActiveByUserId(int $userId): ?array;
|
||||
|
||||
public function findById(int $id): ?array;
|
||||
|
||||
public function incrementAttempts(int $id): bool;
|
||||
|
||||
public function markUsed(int $id): bool;
|
||||
}
|
||||
105
core/Repository/Auth/PasswordResetRepository.php
Normal file
105
core/Repository/Auth/PasswordResetRepository.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Auth;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
/** Stores password reset codes with attempt counters, expiry, and completion history. */
|
||||
class PasswordResetRepository implements PasswordResetRepositoryInterface
|
||||
{
|
||||
private function unwrapList($rows): array
|
||||
{
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['password_resets'] ?? $row;
|
||||
if (is_array($data)) {
|
||||
$list[] = $data;
|
||||
}
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public function create(int $userId, string $codeHash, string $expiresAt): ?int
|
||||
{
|
||||
$id = DB::insert(
|
||||
'insert into password_resets (user_id, code_hash, expires_at, attempts, used_at, created) values (?,?,?,?,NULL,NOW())',
|
||||
(string) $userId,
|
||||
$codeHash,
|
||||
$expiresAt,
|
||||
0
|
||||
);
|
||||
return $id ? (int) $id : null;
|
||||
}
|
||||
|
||||
public function invalidateForUser(int $userId): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update password_resets set used_at = NOW() where user_id = ? and used_at is null',
|
||||
(string) $userId
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function findActiveByUserId(int $userId): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, user_id, code_hash, expires_at, attempts, used_at from password_resets where user_id = ? and used_at is null and expires_at > UTC_TIMESTAMP() order by id desc limit 1',
|
||||
(string) $userId
|
||||
);
|
||||
if (!$row || !isset($row['password_resets'])) {
|
||||
return null;
|
||||
}
|
||||
return $row['password_resets'];
|
||||
}
|
||||
|
||||
public function findById(int $id): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, user_id, code_hash, expires_at, attempts, used_at from password_resets where id = ? limit 1',
|
||||
(string) $id
|
||||
);
|
||||
if (!$row || !isset($row['password_resets'])) {
|
||||
return null;
|
||||
}
|
||||
return $row['password_resets'];
|
||||
}
|
||||
|
||||
public function incrementAttempts(int $id): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update password_resets set attempts = attempts + 1 where id = ?',
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function markUsed(int $id): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update password_resets set used_at = NOW() where id = ?',
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function listByUserId(int $userId, int $limit = 20): array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return [];
|
||||
}
|
||||
if ($limit < 1) {
|
||||
$limit = 20;
|
||||
} elseif ($limit > 100) {
|
||||
$limit = 100;
|
||||
}
|
||||
$rows = DB::select(
|
||||
'select id, user_id, expires_at, attempts, used_at, created from password_resets where user_id = ? order by id desc limit ?',
|
||||
(string) $userId,
|
||||
(string) $limit
|
||||
);
|
||||
return $this->unwrapList($rows);
|
||||
}
|
||||
}
|
||||
21
core/Repository/Auth/PasswordResetRepositoryInterface.php
Normal file
21
core/Repository/Auth/PasswordResetRepositoryInterface.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Auth;
|
||||
|
||||
/** Contract for password reset code lifecycle: creation, lookup, attempt tracking, and completion. */
|
||||
interface PasswordResetRepositoryInterface
|
||||
{
|
||||
public function create(int $userId, string $codeHash, string $expiresAt): ?int;
|
||||
|
||||
public function invalidateForUser(int $userId): bool;
|
||||
|
||||
public function findActiveByUserId(int $userId): ?array;
|
||||
|
||||
public function findById(int $id): ?array;
|
||||
|
||||
public function incrementAttempts(int $id): bool;
|
||||
|
||||
public function markUsed(int $id): bool;
|
||||
|
||||
public function listByUserId(int $userId, int $limit = 20): array;
|
||||
}
|
||||
103
core/Repository/Auth/RememberTokenRepository.php
Normal file
103
core/Repository/Auth/RememberTokenRepository.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Auth;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
/** Manages persistent login tokens with rotation, family tracking, and admin-initiated expiry. */
|
||||
class RememberTokenRepository implements RememberTokenRepositoryInterface
|
||||
{
|
||||
private function unwrapList($rows): array
|
||||
{
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['user_remember_tokens'] ?? $row;
|
||||
if (is_array($data)) {
|
||||
$list[] = $data;
|
||||
}
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public function create(int $userId, string $selector, string $tokenHash, string $expiresAt): ?int
|
||||
{
|
||||
$id = DB::insert(
|
||||
'insert into user_remember_tokens (user_id, selector, token_hash, expires_at, created) values (?,?,?,?,NOW())',
|
||||
(string) $userId,
|
||||
$selector,
|
||||
$tokenHash,
|
||||
$expiresAt
|
||||
);
|
||||
return $id ? (int) $id : null;
|
||||
}
|
||||
|
||||
public function findBySelector(string $selector): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, user_id, selector, token_hash, expires_at, expired_by_admin_at, last_used from user_remember_tokens where selector = ? limit 1',
|
||||
$selector
|
||||
);
|
||||
if (!$row || !isset($row['user_remember_tokens'])) {
|
||||
return null;
|
||||
}
|
||||
return $row['user_remember_tokens'];
|
||||
}
|
||||
|
||||
public function updateToken(int $id, string $tokenHash, string $expiresAt): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update user_remember_tokens set token_hash = ?, expires_at = ?, expired_by_admin_at = NULL, last_used = NOW() where id = ?',
|
||||
$tokenHash,
|
||||
$expiresAt,
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function expireAllByAdmin(): int
|
||||
{
|
||||
$result = DB::update(
|
||||
'update user_remember_tokens set expires_at = NOW(), expired_by_admin_at = NOW() where expires_at > NOW()'
|
||||
);
|
||||
return $result !== false ? (int) $result : 0;
|
||||
}
|
||||
|
||||
public function deleteById(int $id): bool
|
||||
{
|
||||
$result = DB::delete('delete from user_remember_tokens where id = ?', (string) $id);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function deleteByUserId(int $userId): bool
|
||||
{
|
||||
$result = DB::delete('delete from user_remember_tokens where user_id = ?', (string) $userId);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function listByUserId(int $userId, int $limit = 20): array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return [];
|
||||
}
|
||||
if ($limit < 1) {
|
||||
$limit = 20;
|
||||
} elseif ($limit > 100) {
|
||||
$limit = 100;
|
||||
}
|
||||
$rows = DB::select(
|
||||
'select id, user_id, selector, expires_at, expired_by_admin_at, last_used, created from user_remember_tokens where user_id = ? order by id desc limit ?',
|
||||
(string) $userId,
|
||||
(string) $limit
|
||||
);
|
||||
return $this->unwrapList($rows);
|
||||
}
|
||||
|
||||
public function countActive(): int
|
||||
{
|
||||
$count = DB::selectValue('select count(*) from user_remember_tokens where expires_at > NOW()');
|
||||
return $count ? (int) $count : 0;
|
||||
}
|
||||
}
|
||||
23
core/Repository/Auth/RememberTokenRepositoryInterface.php
Normal file
23
core/Repository/Auth/RememberTokenRepositoryInterface.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Auth;
|
||||
|
||||
/** Contract for remember-me token creation, rotation, expiry, and active session counting. */
|
||||
interface RememberTokenRepositoryInterface
|
||||
{
|
||||
public function create(int $userId, string $selector, string $tokenHash, string $expiresAt): ?int;
|
||||
|
||||
public function findBySelector(string $selector): ?array;
|
||||
|
||||
public function updateToken(int $id, string $tokenHash, string $expiresAt): bool;
|
||||
|
||||
public function expireAllByAdmin(): int;
|
||||
|
||||
public function deleteById(int $id): bool;
|
||||
|
||||
public function deleteByUserId(int $userId): bool;
|
||||
|
||||
public function listByUserId(int $userId, int $limit = 20): array;
|
||||
|
||||
public function countActive(): int;
|
||||
}
|
||||
60
core/Repository/Content/PageContentRepository.php
Normal file
60
core/Repository/Content/PageContentRepository.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Content;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
/** Reads and writes locale-specific content blocks for CMS pages. */
|
||||
class PageContentRepository
|
||||
{
|
||||
private static function unwrap(?array $row): ?array
|
||||
{
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
return $row['page_contents'] ?? $row;
|
||||
}
|
||||
|
||||
public static function findByPageIdAndLocale(int $pageId, string $locale): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, page_id, locale, content, created_by, modified_by, created, modified from page_contents where page_id = ? and locale = ? limit 1',
|
||||
(string) $pageId,
|
||||
$locale
|
||||
);
|
||||
return self::unwrap($row);
|
||||
}
|
||||
|
||||
public static function create(array $data)
|
||||
{
|
||||
return DB::insert(
|
||||
'insert into page_contents (page_id, locale, content, created_by, created) values (?,?,?,?,NOW())',
|
||||
(string) $data['page_id'],
|
||||
$data['locale'],
|
||||
$data['content'] ?? null,
|
||||
$data['created_by'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
public static function update(int $id, array $data): bool
|
||||
{
|
||||
$fields = [
|
||||
'content' => $data['content'] ?? null,
|
||||
];
|
||||
if (array_key_exists('modified_by', $data)) {
|
||||
$fields['modified_by'] = $data['modified_by'];
|
||||
}
|
||||
|
||||
$setParts = [];
|
||||
$params = [];
|
||||
foreach ($fields as $field => $value) {
|
||||
$setParts[] = sprintf('`%s` = ?', $field);
|
||||
$params[] = $value;
|
||||
}
|
||||
$params[] = (string) $id;
|
||||
|
||||
$query = 'update page_contents set ' . implode(', ', $setParts) . ', modified = NOW() where id = ?';
|
||||
$result = DB::update($query, ...$params);
|
||||
return $result !== false;
|
||||
}
|
||||
}
|
||||
26
core/Repository/Content/PageRepository.php
Normal file
26
core/Repository/Content/PageRepository.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Content;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
/** Looks up CMS page metadata by slug. */
|
||||
class PageRepository
|
||||
{
|
||||
private static function unwrap(?array $row): ?array
|
||||
{
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
return $row['pages'] ?? $row;
|
||||
}
|
||||
|
||||
public static function findBySlug(string $slug): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, slug, created_by, modified_by, created, modified from pages where slug = ? limit 1',
|
||||
$slug
|
||||
);
|
||||
return self::unwrap($row);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\CustomField;
|
||||
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Repository\Support\RepoQuery;
|
||||
|
||||
/** Reads and writes tenant-scoped custom field definitions with type and pagination filtering. */
|
||||
class TenantCustomFieldDefinitionRepository
|
||||
{
|
||||
private static function unwrap(?array $row): ?array
|
||||
{
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
return $row['tenant_custom_field_definitions'] ?? null;
|
||||
}
|
||||
|
||||
private static function unwrapList($rows): array
|
||||
{
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$item = $row['tenant_custom_field_definitions'] ?? null;
|
||||
if (is_array($item)) {
|
||||
$list[] = $item;
|
||||
}
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public static function listByTenantId(int $tenantId, bool $onlyActive = true): array
|
||||
{
|
||||
if ($tenantId <= 0) {
|
||||
return [];
|
||||
}
|
||||
$query = 'select id, uuid, tenant_id, field_key, label, type, is_required, is_filterable, active, sort_order, created_by, modified_by, created, modified ' .
|
||||
'from tenant_custom_field_definitions where tenant_id = ?';
|
||||
$params = [(string) $tenantId];
|
||||
if ($onlyActive) {
|
||||
$query .= ' and active = 1';
|
||||
}
|
||||
$query .= ' order by sort_order asc, label asc, id asc';
|
||||
return self::unwrapList(DB::select($query, ...$params));
|
||||
}
|
||||
|
||||
public static function listByTenantIds(array $tenantIds, bool $onlyActive = true): array
|
||||
{
|
||||
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
|
||||
$tenantIds = array_values(array_filter($tenantIds, static fn ($id) => $id > 0));
|
||||
if (!$tenantIds) {
|
||||
return [];
|
||||
}
|
||||
$query = 'select id, uuid, tenant_id, field_key, label, type, is_required, is_filterable, active, sort_order, created_by, modified_by, created, modified ' .
|
||||
'from tenant_custom_field_definitions where tenant_id in (???)';
|
||||
$params = [$tenantIds];
|
||||
if ($onlyActive) {
|
||||
$query .= ' and active = 1';
|
||||
}
|
||||
$query .= ' order by tenant_id asc, sort_order asc, label asc, id asc';
|
||||
return self::unwrapList(call_user_func_array(['MintyPHP\\DB', 'select'], array_merge([$query], $params)));
|
||||
}
|
||||
|
||||
public static function listFilterableByTenantIds(array $tenantIds): array
|
||||
{
|
||||
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
|
||||
$tenantIds = array_values(array_filter($tenantIds, static fn ($id) => $id > 0));
|
||||
if (!$tenantIds) {
|
||||
return [];
|
||||
}
|
||||
$query = 'select id, uuid, tenant_id, field_key, label, type, is_required, is_filterable, active, sort_order, created_by, modified_by, created, modified ' .
|
||||
'from tenant_custom_field_definitions ' .
|
||||
'where tenant_id in (???) and active = 1 and is_filterable = 1 ' .
|
||||
"and type in ('select', 'multiselect', 'boolean', 'date') " .
|
||||
'order by tenant_id asc, sort_order asc, label asc, id asc';
|
||||
return self::unwrapList(call_user_func_array(['MintyPHP\\DB', 'select'], [$query, $tenantIds]));
|
||||
}
|
||||
|
||||
public static function findByUuid(string $uuid): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, tenant_id, field_key, label, type, is_required, is_filterable, active, sort_order, created_by, modified_by, created, modified ' .
|
||||
'from tenant_custom_field_definitions where uuid = ? limit 1',
|
||||
$uuid
|
||||
);
|
||||
return self::unwrap($row);
|
||||
}
|
||||
|
||||
public static function findById(int $id): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, tenant_id, field_key, label, type, is_required, is_filterable, active, sort_order, created_by, modified_by, created, modified ' .
|
||||
'from tenant_custom_field_definitions where id = ? limit 1',
|
||||
(string) $id
|
||||
);
|
||||
return self::unwrap($row);
|
||||
}
|
||||
|
||||
public static function findByTenantIdAndKey(int $tenantId, string $fieldKey): ?array
|
||||
{
|
||||
if ($tenantId <= 0 || $fieldKey === '') {
|
||||
return null;
|
||||
}
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, tenant_id, field_key, label, type, is_required, is_filterable, active, sort_order, created_by, modified_by, created, modified ' .
|
||||
'from tenant_custom_field_definitions where tenant_id = ? and field_key = ? limit 1',
|
||||
(string) $tenantId,
|
||||
$fieldKey
|
||||
);
|
||||
return self::unwrap($row);
|
||||
}
|
||||
|
||||
public static function create(array $data): int|false
|
||||
{
|
||||
return DB::insert(
|
||||
'insert into tenant_custom_field_definitions ' .
|
||||
'(uuid, tenant_id, field_key, label, type, is_required, is_filterable, active, sort_order, created_by, created) ' .
|
||||
'values (?,?,?,?,?,?,?,?,?,?,NOW())',
|
||||
$data['uuid'] ?? RepoQuery::uuidV4(),
|
||||
(string) ($data['tenant_id'] ?? 0),
|
||||
(string) ($data['field_key'] ?? ''),
|
||||
(string) ($data['label'] ?? ''),
|
||||
(string) ($data['type'] ?? ''),
|
||||
(string) ((int) ($data['is_required'] ?? 0)),
|
||||
(string) ((int) ($data['is_filterable'] ?? 0)),
|
||||
(string) ((int) ($data['active'] ?? 1)),
|
||||
(string) ((int) ($data['sort_order'] ?? 100)),
|
||||
$data['created_by'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
public static function update(int $id, array $data): bool
|
||||
{
|
||||
if ($id <= 0) {
|
||||
return false;
|
||||
}
|
||||
$fields = [
|
||||
'field_key' => (string) ($data['field_key'] ?? ''),
|
||||
'label' => (string) ($data['label'] ?? ''),
|
||||
'type' => (string) ($data['type'] ?? ''),
|
||||
'is_required' => (int) ($data['is_required'] ?? 0),
|
||||
'is_filterable' => (int) ($data['is_filterable'] ?? 0),
|
||||
'active' => (int) ($data['active'] ?? 1),
|
||||
'sort_order' => (int) ($data['sort_order'] ?? 100),
|
||||
];
|
||||
if (array_key_exists('modified_by', $data)) {
|
||||
$fields['modified_by'] = $data['modified_by'];
|
||||
}
|
||||
|
||||
$set = [];
|
||||
$params = [];
|
||||
foreach ($fields as $field => $value) {
|
||||
$set[] = sprintf('`%s` = ?', $field);
|
||||
$params[] = (string) $value;
|
||||
}
|
||||
$params[] = (string) $id;
|
||||
|
||||
$result = DB::update(
|
||||
'update tenant_custom_field_definitions set ' . implode(', ', $set) . ' where id = ?',
|
||||
...$params
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function delete(int $id): bool
|
||||
{
|
||||
if ($id <= 0) {
|
||||
return false;
|
||||
}
|
||||
$result = DB::delete('delete from tenant_custom_field_definitions where id = ?', (string) $id);
|
||||
return $result !== false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\CustomField;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
/** Retrieves selectable options for tenant-scoped custom field definitions. */
|
||||
class TenantCustomFieldOptionRepository
|
||||
{
|
||||
private static function unwrapList($rows): array
|
||||
{
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$item = $row['tenant_custom_field_options'] ?? null;
|
||||
if (is_array($item)) {
|
||||
$list[] = $item;
|
||||
}
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public static function listByDefinitionIds(array $definitionIds, bool $onlyActive = true): array
|
||||
{
|
||||
$definitionIds = array_values(array_unique(array_map('intval', $definitionIds)));
|
||||
$definitionIds = array_values(array_filter($definitionIds, static fn ($id) => $id > 0));
|
||||
if (!$definitionIds) {
|
||||
return [];
|
||||
}
|
||||
$query = 'select id, definition_id, option_key, label, active, sort_order, created, modified ' .
|
||||
'from tenant_custom_field_options where definition_id in (???)';
|
||||
$params = [$definitionIds];
|
||||
if ($onlyActive) {
|
||||
$query .= ' and active = 1';
|
||||
}
|
||||
$query .= ' order by definition_id asc, sort_order asc, label asc, id asc';
|
||||
return self::unwrapList(call_user_func_array(['MintyPHP\\DB', 'select'], array_merge([$query], $params)));
|
||||
}
|
||||
|
||||
public static function replaceForDefinition(int $definitionId, array $options): bool
|
||||
{
|
||||
if ($definitionId <= 0) {
|
||||
return false;
|
||||
}
|
||||
$existing = self::listByDefinitionIds([$definitionId], false);
|
||||
$existingByKey = [];
|
||||
foreach ($existing as $row) {
|
||||
$key = (string) ($row['option_key'] ?? '');
|
||||
if ($key !== '') {
|
||||
$existingByKey[$key] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
$keepIds = [];
|
||||
foreach ($options as $option) {
|
||||
if (!is_array($option)) {
|
||||
continue;
|
||||
}
|
||||
$optionKey = trim((string) ($option['option_key'] ?? ''));
|
||||
$label = trim((string) ($option['label'] ?? ''));
|
||||
if ($optionKey === '' || $label === '') {
|
||||
continue;
|
||||
}
|
||||
$active = !empty($option['active']) ? 1 : 0;
|
||||
$sortOrder = (int) ($option['sort_order'] ?? 100);
|
||||
|
||||
if (isset($existingByKey[$optionKey]['id'])) {
|
||||
$optionId = (int) $existingByKey[$optionKey]['id'];
|
||||
$keepIds[] = $optionId;
|
||||
$updated = DB::update(
|
||||
'update tenant_custom_field_options set label = ?, active = ?, sort_order = ? where id = ?',
|
||||
$label,
|
||||
(string) $active,
|
||||
(string) $sortOrder,
|
||||
(string) $optionId
|
||||
);
|
||||
if ($updated === false) {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$insertId = DB::insert(
|
||||
'insert into tenant_custom_field_options (definition_id, option_key, label, active, sort_order, created) values (?,?,?,?,?,NOW())',
|
||||
(string) $definitionId,
|
||||
$optionKey,
|
||||
$label,
|
||||
(string) $active,
|
||||
(string) $sortOrder
|
||||
);
|
||||
if ($insertId === false) {
|
||||
return false;
|
||||
}
|
||||
if ($insertId) {
|
||||
$keepIds[] = (int) $insertId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$keepIds) {
|
||||
$deleted = DB::delete('delete from tenant_custom_field_options where definition_id = ?', (string) $definitionId);
|
||||
if ($deleted === false) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
$deleted = DB::delete(
|
||||
'delete from tenant_custom_field_options where definition_id = ? and id not in (???)',
|
||||
(string) $definitionId,
|
||||
$keepIds
|
||||
);
|
||||
if ($deleted === false) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function deleteByDefinitionId(int $definitionId): bool
|
||||
{
|
||||
if ($definitionId <= 0) {
|
||||
return false;
|
||||
}
|
||||
$result = DB::delete('delete from tenant_custom_field_options where definition_id = ?', (string) $definitionId);
|
||||
return $result !== false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\CustomField;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
/** Manages selected option links for user custom field values (atomic replace). */
|
||||
class UserCustomFieldValueOptionRepository
|
||||
{
|
||||
public static function replaceForValueId(int $valueId, array $optionIds): bool
|
||||
{
|
||||
if ($valueId <= 0) {
|
||||
return false;
|
||||
}
|
||||
$deleted = DB::delete('delete from user_custom_field_value_options where value_id = ?', (string) $valueId);
|
||||
if ($deleted === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$optionIds = array_values(array_unique(array_map('intval', $optionIds)));
|
||||
$optionIds = array_values(array_filter($optionIds, static fn ($id) => $id > 0));
|
||||
if (!$optionIds) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($optionIds as $optionId) {
|
||||
$inserted = DB::insert(
|
||||
'insert into user_custom_field_value_options (value_id, option_id, created) values (?,?,NOW())',
|
||||
(string) $valueId,
|
||||
(string) $optionId
|
||||
);
|
||||
if ($inserted === false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function listOptionIdsByValueIds(array $valueIds): array
|
||||
{
|
||||
$valueIds = array_values(array_unique(array_map('intval', $valueIds)));
|
||||
$valueIds = array_values(array_filter($valueIds, static fn ($id) => $id > 0));
|
||||
if (!$valueIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = DB::select(
|
||||
'select value_id, option_id from user_custom_field_value_options where value_id in (???)',
|
||||
$valueIds
|
||||
);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['user_custom_field_value_options'] ?? $row;
|
||||
if (!is_array($data)) {
|
||||
continue;
|
||||
}
|
||||
$valueId = (int) ($data['value_id'] ?? 0);
|
||||
$optionId = (int) ($data['option_id'] ?? 0);
|
||||
if ($valueId <= 0 || $optionId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$map[$valueId] ??= [];
|
||||
$map[$valueId][] = $optionId;
|
||||
}
|
||||
|
||||
foreach ($map as &$ids) {
|
||||
$ids = array_values(array_unique(array_map('intval', $ids)));
|
||||
sort($ids, SORT_NUMERIC);
|
||||
}
|
||||
unset($ids);
|
||||
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user