Refactor helper drift in lib: centralize theme/translation and remove app() hotspots

This commit is contained in:
2026-03-19 08:23:14 +01:00
parent 5da506a20f
commit 5739cc1200
24 changed files with 197 additions and 88 deletions

View File

@@ -4,6 +4,7 @@ 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;
@@ -23,7 +24,8 @@ final class AccessRegistrar implements ContainerRegistrar
{
$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->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

View File

@@ -4,6 +4,7 @@ namespace MintyPHP\App\Container\Registrars;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Container\ContainerRegistrar;
use MintyPHP\App\Module\ModuleRegistry;
use MintyPHP\Http\CookieStoreInterface;
use MintyPHP\Http\RequestRuntimeInterface;
use MintyPHP\Http\SessionStoreInterface;
@@ -113,7 +114,8 @@ final class ServiceFactoryRegistrar implements ContainerRegistrar
));
$container->set(AccessPolicyFactory::class, static fn (AppContainer $c): AccessPolicyFactory => new AccessPolicyFactory(
$c->get(PermissionService::class),
$c->get(TenantScopeService::class)
$c->get(TenantScopeService::class),
$c->has(ModuleRegistry::class) ? $c->get(ModuleRegistry::class) : null
));
$container->set(UserGatewayFactory::class, static fn (AppContainer $c): UserGatewayFactory => new UserGatewayFactory(
$c->get(AccessServicesFactory::class),

View File

@@ -20,10 +20,12 @@ class AccessControl
];
private array $configuredPublicPaths;
private IntendedUrlService $intendedUrlService;
public function __construct(?array $publicPaths = null)
public function __construct(?array $publicPaths = null, ?IntendedUrlService $intendedUrlService = null)
{
$this->configuredPublicPaths = $publicPaths ?? (defined('APP_PUBLIC_PATHS') ? APP_PUBLIC_PATHS : []);
$this->intendedUrlService = $intendedUrlService ?? new IntendedUrlService();
}
/**
@@ -74,8 +76,7 @@ class AccessControl
$loginPath = Request::withLocale('login', $locale);
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
$intendedUrlService = app(IntendedUrlService::class);
$loginTarget = $intendedUrlService->buildLoginRedirect($loginPath, $requestUri);
$loginTarget = $this->intendedUrlService->buildLoginRedirect($loginPath, $requestUri);
Router::redirect($loginTarget);

View File

@@ -15,7 +15,7 @@ class UserListQueryRepository implements UserListQueryRepositoryInterface
$createdFrom = trim((string) ($options['created_from'] ?? ''));
$createdTo = trim((string) ($options['created_to'] ?? ''));
$tenant = trim((string) ($options['tenant'] ?? ''));
$tenantUuids = array_filter(array_map('trim', explode(',', (string) ($options['tenants'] ?? ''))));
$tenantUuids = RepoQuery::normalizeStringList($options['tenants'] ?? []);
$roleIds = RepoQuery::normalizeIdList($options['roles'] ?? []);
$departmentIds = RepoQuery::normalizeIdList($options['departments'] ?? []);
$emailVerified = $options['email_verified'] ?? null;

View File

@@ -2,6 +2,7 @@
namespace MintyPHP\Service\Access;
use MintyPHP\App\Module\ModuleRegistry;
use MintyPHP\Service\Tenant\TenantScopeService;
class AccessPolicyFactory
@@ -17,7 +18,8 @@ class AccessPolicyFactory
public function __construct(
private readonly PermissionService $permissionService,
private readonly TenantScopeService $tenantScopeService
private readonly TenantScopeService $tenantScopeService,
private readonly ?ModuleRegistry $moduleRegistry = null
) {
}
@@ -85,20 +87,20 @@ class AccessPolicyFactory
];
// Append module-contributed authorization policies.
try {
/** @var \MintyPHP\App\Module\ModuleRegistry $registry */
$registry = app(\MintyPHP\App\Module\ModuleRegistry::class);
foreach ($registry->getAuthorizationPolicies() as $policyClass) {
if (!is_string($policyClass) || trim($policyClass) === '') {
continue;
}
$policy = $this->instantiateModulePolicy($policyClass);
if ($policy instanceof AuthorizationPolicyInterface) {
$policies[] = $policy;
if ($this->moduleRegistry !== null) {
try {
foreach ($this->moduleRegistry->getAuthorizationPolicies() as $policyClass) {
if (!is_string($policyClass) || trim($policyClass) === '') {
continue;
}
$policy = $this->instantiateModulePolicy($policyClass);
if ($policy instanceof AuthorizationPolicyInterface) {
$policies[] = $policy;
}
}
} catch (\Throwable) {
// fail-open: no module registry available in this context
}
} catch (\Throwable) {
// fail-open: no module registry available in this context
}
return $this->authorizationService = new AuthorizationService($policies);

View File

@@ -2,13 +2,16 @@
namespace MintyPHP\Service\Access;
use MintyPHP\App\Module\ModuleRegistry;
final class UiAccessService
{
/** @var array<string, bool> */
private array $decisionCache = [];
public function __construct(
private readonly AuthorizationService $authorizationService
private readonly AuthorizationService $authorizationService,
private readonly ?ModuleRegistry $moduleRegistry = null
) {
}
@@ -88,17 +91,16 @@ final class UiAccessService
{
$map = UiCapabilityMap::LAYOUT;
// Merge module-contributed layout capabilities dynamically.
try {
/** @var \MintyPHP\App\Module\ModuleRegistry $registry */
$registry = app(\MintyPHP\App\Module\ModuleRegistry::class);
if ($registry instanceof \MintyPHP\App\Module\ModuleRegistry) {
foreach ($registry->getLayoutCapabilities() as $key => $ability) {
$map[$key] = $ability;
// Merge module UI slot abilities directly into the capability map.
// Each ability string is used as both key and value (no indirection).
if ($this->moduleRegistry !== null) {
try {
foreach ($this->moduleRegistry->getModuleUiAbilities() as $ability) {
$map[$ability] = $ability;
}
} catch (\Throwable) {
// fail-open: no module registry available in this context
}
} catch (\Throwable) {
// fail-open: no module registry available in this context
}
return $this->resolveMap($actorUserId, $map);

View File

@@ -49,6 +49,16 @@ class AuthSettingsGateway
return (string) $this->settingsAppGateway->getAppTheme();
}
public function defaultTheme(): string
{
return $this->settingsAppGateway->resolveDefaultTheme();
}
public function normalizeTheme(?string $theme): string
{
return $this->settingsAppGateway->normalizeTheme($theme);
}
public function getDefaultRoleId(): int
{
return (int) $this->settingsDefaultsGateway->getDefaultRoleId();

View File

@@ -269,11 +269,6 @@ class SsoUserLinkService
private function resolveInitialTheme(?string $theme): string
{
$theme = strtolower(trim((string) ($theme ?? '')));
$themes = function_exists('appThemes') ? appThemes() : ['light' => 'Light'];
if ($theme !== '' && isset($themes[$theme])) {
return $theme;
}
return isset($themes['light']) ? 'light' : (string) array_key_first($themes);
return $this->settingsGateway->normalizeTheme($theme);
}
}

View File

@@ -17,7 +17,8 @@ class DirectoryGatewayFactory
public function createDirectorySettingsGateway(): DirectorySettingsGateway
{
return $this->directorySettingsGateway ??= new DirectorySettingsGateway(
$this->settingServicesFactory->createSettingsDefaultsGateway()
$this->settingServicesFactory->createSettingsDefaultsGateway(),
$this->settingServicesFactory->createSettingsAppGateway()
);
}

View File

@@ -2,11 +2,15 @@
namespace MintyPHP\Service\Directory;
use MintyPHP\Service\Settings\SettingsAppGateway;
use MintyPHP\Service\Settings\SettingsDefaultsGateway;
class DirectorySettingsGateway
{
public function __construct(private readonly SettingsDefaultsGateway $settingsDefaultsGateway)
public function __construct(
private readonly SettingsDefaultsGateway $settingsDefaultsGateway,
private readonly SettingsAppGateway $settingsAppGateway
)
{
}
@@ -24,4 +28,9 @@ class DirectorySettingsGateway
{
$this->settingsDefaultsGateway->setDefaultRoleId($roleId);
}
public function isAllowedTheme(string $theme): bool
{
return $this->settingsAppGateway->isAllowedTheme($theme);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace MintyPHP\Service\I18n;
final class ServiceTranslator
{
public static function translate(string $text, mixed ...$args): string
{
if (function_exists('t')) {
return t($text, ...$args);
}
if ($args === []) {
return $text;
}
return vsprintf($text, array_map(static fn (mixed $arg): string => (string) $arg, $args));
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace MintyPHP\Service\I18n;
trait TranslatesServiceText
{
private function translate(string $text, mixed ...$args): string
{
return ServiceTranslator::translate($text, ...$args);
}
}

View File

@@ -5,6 +5,7 @@ namespace MintyPHP\Service\Import;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Audit\ImportAuditService;
use MintyPHP\Service\I18n\TranslatesServiceText;
use MintyPHP\Service\Import\Profile\ImportProfileInterface;
use MintyPHP\Service\Settings\SettingsDefaultsGateway;
use MintyPHP\Service\Tenant\TenantScopeService;
@@ -28,6 +29,8 @@ use MintyPHP\Service\Tenant\TenantScopeService;
*/
class ImportService
{
use TranslatesServiceText;
private const MAX_ROWS = 20000;
private const MAX_ERROR_ROWS = 500;
private const PROFILE_PERMISSION_MAP = [
@@ -636,15 +639,4 @@ class ImportService
return strtolower($value);
}
private function translate(string $text, mixed ...$args): string
{
if (function_exists('t')) {
return t($text, ...$args);
}
if ($args === []) {
return $text;
}
return vsprintf($text, array_map(static fn ($arg) => (string) $arg, $args));
}
}

View File

@@ -4,9 +4,12 @@ namespace MintyPHP\Service\Scheduler;
use MintyPHP\Repository\Scheduler\ScheduledJobRepositoryInterface;
use MintyPHP\Repository\Scheduler\ScheduledJobRunRepositoryInterface;
use MintyPHP\Service\I18n\TranslatesServiceText;
class ScheduledJobService
{
use TranslatesServiceText;
private const RUN_RETENTION_DAYS = 90;
public function __construct(
@@ -280,14 +283,4 @@ class ScheduledJobService
};
}
private function translate(string $text, mixed ...$args): string
{
if (function_exists('t')) {
return t($text, ...$args);
}
if ($args === []) {
return $text;
}
return vsprintf($text, array_map(static fn ($arg) => (string) $arg, $args));
}
}

View File

@@ -57,7 +57,7 @@ class SettingsAppGateway
{
$value = $this->settingsMetadataGateway->getValue(SettingKeys::APP_THEME_KEY);
$value = $value !== null ? strtolower(trim($value)) : '';
if (!in_array($value, $this->allowedThemes(), true)) {
if (!$this->isAllowedTheme($value)) {
return null;
}
@@ -67,7 +67,7 @@ class SettingsAppGateway
public function setAppTheme(?string $theme, ?string $description = null): bool
{
$value = $theme !== null ? strtolower(trim($theme)) : '';
if ($value === '' || !in_array($value, $this->allowedThemes(), true)) {
if ($value === '' || !$this->isAllowedTheme($value)) {
$value = null;
}
@@ -75,6 +75,50 @@ class SettingsAppGateway
return $this->settingsMetadataGateway->set(SettingKeys::APP_THEME_KEY, $value, $desc);
}
/**
* @return array<string, string>
*/
public function listThemes(): array
{
return $this->themeConfigService->all();
}
public function isAllowedTheme(?string $theme): bool
{
$value = strtolower(trim((string) $theme));
return $value !== '' && in_array($value, $this->allowedThemes(), true);
}
public function resolveDefaultTheme(): string
{
$settingTheme = $this->getAppTheme();
if ($settingTheme !== null) {
return $settingTheme;
}
$envTheme = strtolower(trim((string) (getenv('APP_THEME') ?: '')));
if ($this->isAllowedTheme($envTheme)) {
return $envTheme;
}
$allowedThemes = $this->allowedThemes();
if (in_array('light', $allowedThemes, true)) {
return 'light';
}
return (string) ($allowedThemes[0] ?? 'light');
}
public function normalizeTheme(?string $theme): string
{
$value = strtolower(trim((string) $theme));
if ($this->isAllowedTheme($value)) {
return $value;
}
return $this->resolveDefaultTheme();
}
public function isUserThemeAllowed(): bool
{
$value = $this->settingsMetadataGateway->getValue(SettingKeys::APP_THEME_USER_KEY);
@@ -146,7 +190,7 @@ class SettingsAppGateway
private function allowedThemes(): array
{
return array_keys($this->themeConfigService->all());
return array_keys($this->listThemes());
}
public function normalizeLocale(?string $locale): string

View File

@@ -247,8 +247,7 @@ class TenantService
) {
$errors[] = t('Primary color is invalid');
}
$themes = function_exists('appThemes') ? appThemes() : [];
if ($form['default_theme'] !== '' && !isset($themes[$form['default_theme']])) {
if ($form['default_theme'] !== '' && !$this->settingsGateway->isAllowedTheme((string) $form['default_theme'])) {
$errors[] = t('Default theme is invalid');
}
if (!in_array($form['allow_user_theme_mode'], ['', '0', '1'], true)) {

View File

@@ -724,12 +724,8 @@ class UserAccountService
private function normalizeTheme($value): string
{
$theme = strtolower(trim((string) $value));
$themes = function_exists('appThemes') ? appThemes() : ['light' => 'Light', 'dark' => 'Dark'];
if ($theme === '' || !isset($themes[$theme])) {
return function_exists('appDefaultTheme') ? appDefaultTheme() : 'light';
}
return $theme;
$theme = is_scalar($value) ? (string) $value : null;
return $this->settingsGateway->normalizeTheme($theme);
}
private function normalizeLocale($value): string

View File

@@ -14,7 +14,8 @@ class UserLifecycleRestoreService
private readonly UserReadRepositoryInterface $userReadRepository,
private readonly UserWriteRepositoryInterface $userWriteRepository,
private readonly UserLifecycleAuditService $userLifecycleAuditService,
private readonly DatabaseSessionRepository $databaseSessionRepository
private readonly DatabaseSessionRepository $databaseSessionRepository,
private readonly UserSettingsGateway $userSettingsGateway
) {
}
@@ -171,8 +172,7 @@ class UserLifecycleRestoreService
private function normalizeTheme(mixed $value): string
{
$theme = strtolower(trim((string) $value));
$themes = appThemes();
return isset($themes[$theme]) ? $theme : appDefaultTheme();
$theme = is_scalar($value) ? (string) $value : null;
return $this->userSettingsGateway->normalizeTheme($theme);
}
}

View File

@@ -2,8 +2,12 @@
namespace MintyPHP\Service\User;
use MintyPHP\Service\I18n\TranslatesServiceText;
class UserPasswordPolicyService
{
use TranslatesServiceText;
private const PASSWORD_MIN_LENGTH = 12;
public function validate(string $password, string $password2, bool $required, ?string $email): array
@@ -61,15 +65,4 @@ class UserPasswordPolicyService
return self::PASSWORD_MIN_LENGTH;
}
// Wrapper so this service can be used in tests before the t() helper is loaded.
private function translate(string $text, mixed ...$args): string
{
if (function_exists('t')) {
return t($text, ...$args);
}
if ($args === []) {
return $text;
}
return vsprintf($text, array_map(static fn ($arg) => (string) $arg, $args));
}
}

View File

@@ -153,7 +153,8 @@ class UserServicesFactory
$this->createUserReadRepository(),
$this->createUserWriteRepository(),
$this->createUserLifecycleAuditService(),
$this->databaseSessionRepository
$this->databaseSessionRepository,
$this->createUserSettingsGateway()
);
}

View File

@@ -35,6 +35,24 @@ class UserSettingsGateway
return $this->settingsAppGateway->getAppTheme();
}
/**
* @return array<string, string>
*/
public function appThemes(): array
{
return $this->settingsAppGateway->listThemes();
}
public function defaultTheme(): string
{
return $this->settingsAppGateway->resolveDefaultTheme();
}
public function normalizeTheme(?string $theme): string
{
return $this->settingsAppGateway->normalizeTheme($theme);
}
public function getUserInactivityDeactivateDays(): int
{
return $this->settingsUserLifecycleGateway->getUserInactivityDeactivateDays();

View File

@@ -18,6 +18,15 @@ class SearchConfig
/** @var array<string, SearchResourceProvider> key→provider lookup */
private static array $moduleProviderByKey = [];
/** @var (callable(): ModuleRegistry)|null */
private static $moduleRegistryResolver = null;
public static function configure(callable $moduleRegistryResolver): void
{
self::$moduleRegistryResolver = $moduleRegistryResolver;
self::resetModuleProviders();
}
public static function tenantScopeFilters(): array
{
$filters = SearchSqlResourceProvider::tenantScopeFilters();
@@ -136,9 +145,15 @@ class SearchConfig
self::$moduleProviders = [];
self::$moduleProviderByKey = [];
if (!is_callable(self::$moduleRegistryResolver)) {
return self::$moduleProviders;
}
try {
/** @var ModuleRegistry $registry */
$registry = app(ModuleRegistry::class);
$registry = (self::$moduleRegistryResolver)();
if (!$registry instanceof ModuleRegistry) {
return self::$moduleProviders;
}
foreach ($registry->getSearchResources() as $providerClass) {
if (!class_exists($providerClass)) {
continue;

View File

@@ -59,6 +59,10 @@ function setAppContainer(\MintyPHP\App\AppContainer $container): void
static fn (): \MintyPHP\Service\Access\AuthorizationService => $container->get(\MintyPHP\Service\Access\AuthorizationService::class)
);
\MintyPHP\Support\SearchConfig::configure(
static fn (): \MintyPHP\App\Module\ModuleRegistry => $container->get(\MintyPHP\App\Module\ModuleRegistry::class)
);
}
/**

View File

@@ -248,7 +248,7 @@ $allPublicPaths = array_values(array_unique(array_merge(
defined('APP_PUBLIC_PATHS') ? APP_PUBLIC_PATHS : [],
$moduleRegistry->getPublicPaths()
)));
$accessControl = new AccessControl($allPublicPaths);
$accessControl = new AccessControl($allPublicPaths, app(IntendedUrlService::class));
$accessControl->requireAuthOrRedirect($pathWithoutLocale, !empty($_SESSION['user']['id']));
// ── 9. Code analysis (debug only) ──────────────────────────────────