Files
Gnuboard7/app/Extension/ExtensionMiddlewareRegistry.php
HeuJung c0e0d82459 refactor(extension): 확장 미들웨어 선언적 self-gate 정규화
확장이 SP Kernel 미들웨어 그룹을 직접 조작하거나 라우트 파일에 미들웨어
FQCN 을 직접 부착하던 임시 방식을, 확장이 부착 대상(targets)을 명시 선언하고
코어가 요청 시점에 라우트명·URI 로 매칭해 실행하는 self-gate 로 정규화.
번들 7건(ecommerce/gdpr/pay 3종) 이전, audit 룰 3건·문서·버전 동기화 동반.

부수적으로 generate-skills.cjs 가 재생성 시 신의성실(부수의무) 섹션을 소실
시키던 회귀를 수정 — 스킬별 불릿을 스크립트 SSoT 로 편입해 재생성에도 보존.
2026-07-16 10:22:51 +09:00

377 lines
14 KiB
PHP

<?php
namespace App\Extension;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Extension\ExtensionMiddlewareRegistryInterface;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* 활성 확장의 미들웨어 선언을 수집해 라우트 매칭 인덱스를 캐시로 제공합니다.
*
* IDV `getRouteScopeIndex` 와 달리, targets 가 정확한 이름이 아니라 glob/brace/URI
* 패턴이므로 [정확한이름 => 미들웨어] 해시맵이 아니라 [group.timing => list<{predicates, class}>]
* 구조로 두고 요청 라우트명·URI 를 술어로 순회 매칭합니다. 확장 미들웨어 총량이 소수(수십
* 이하)라 O(n) 순회 비용은 무시 가능. 결과는 (routeName|path)+group+timing 단위로 2차 캐시.
*
* 술어는 캐시를 위해 클로저 대신 `{op, arg}` 태그 배열로 보관하고 조회 시 평가합니다
* (클로저는 직렬화 불가). op ∈ {name, name_ext, name_core, uri, always}.
*
* @since 7.0.5
*/
class ExtensionMiddlewareRegistry implements ExtensionMiddlewareRegistryInterface
{
/**
* 미들웨어 인덱스 캐시 키 (IDV `identity_policies.route_scope_index` 네이밍 컨벤션 미러).
* 물리 키는 CoreCacheDriver 접두사로 `g7:core:extension.middleware_index`.
*/
public const CACHE_KEY = 'extension.middleware_index';
/**
* 캐시 무효화 태그 (단수 slug). Manager 상태 변경 지점에서 flush().
*/
public const CACHE_TAG = 'extension_middleware';
/**
* 허용 그룹.
*/
private const ALLOWED_GROUPS = ['web', 'api'];
/**
* 확장 라우트 판별 glob (양 그룹). 라우트명이 이 중 하나에 매칭되면 확장 라우트.
*/
private const EXT_GLOB = ['*.modules.*', '*.plugins.*'];
/**
* 요청 단위 2차 캐시 (동일 요청 내 게이트가 4회 조회해도 인덱스 1회 빌드).
*
* @var array<string, list<class-string>>
*/
protected array $resolveCache = [];
/**
* @param CacheInterface $cache 코어 캐시 드라이버 (CoreCacheDriver 자동 바인딩)
*/
public function __construct(
protected CacheInterface $cache,
) {}
/**
* {@inheritDoc}
*/
public function resolveForRoute(string $routeName, string $path, string $group, string $timing): array
{
$memoKey = $routeName."\0".$path."\0".$group."\0".$timing;
if (isset($this->resolveCache[$memoKey])) {
return $this->resolveCache[$memoKey];
}
$matched = [];
foreach ($this->buildIndex() as $entry) {
if ($entry['group'] !== $group || $entry['timing'] !== $timing) {
continue;
}
foreach ($entry['predicates'] as $predicate) {
if ($this->predicateMatches($predicate, $routeName, $path)) {
$matched[] = $entry;
break;
}
}
}
usort($matched, static fn (array $a, array $b): int => $a['order'] <=> $b['order']);
$classes = array_values(array_map(static fn (array $e): string => $e['class'], $matched));
return $this->resolveCache[$memoKey] = $classes;
}
/**
* 활성 확장의 미들웨어 선언을 그룹별 엔트리로 펼쳐 캐시된 인덱스를 반환합니다.
*
* @return list<array{timing: string, group: string, predicates: list<array{op: string, arg?: string}>, class: class-string, order: int}>
*/
protected function buildIndex(): array
{
$ttl = (int) g7_core_settings('cache.extension_middleware_ttl', 3600);
return $this->cache->remember(
self::CACHE_KEY,
fn (): array => $this->collectEntries(),
$ttl,
[self::CACHE_TAG],
);
}
/**
* 활성 모듈·플러그인의 getMiddleware() 를 검증·정규화해 엔트리 배열로 수집합니다.
*
* @return list<array{timing: string, group: string, predicates: list<array{op: string, arg?: string}>, class: class-string, order: int}>
*/
protected function collectEntries(): array
{
$entries = [];
$order = 0;
foreach ($this->activeExtensions() as [$extension, $identifier, $kind]) {
if (! method_exists($extension, 'getMiddleware')) {
continue;
}
$declarations = $extension->getMiddleware();
if (! is_array($declarations)) {
continue;
}
foreach ($declarations as $declaration) {
$normalized = $this->normalizeDeclaration($declaration, $identifier, $kind);
if ($normalized === null) {
continue;
}
[$class, $groups, $timing, $targets] = $normalized;
foreach ($groups as $group) {
$predicates = [];
foreach ($targets as $target) {
foreach ($this->normalizeTarget($target, $identifier, $kind, $group) as $predicate) {
$predicates[] = $predicate;
}
}
$entries[] = [
'timing' => $timing,
'group' => $group,
'predicates' => $predicates,
'class' => $class,
'order' => $order,
];
}
$order++;
}
}
return $entries;
}
/**
* 단일 미들웨어 선언을 검증·정규화합니다. 무효 시 warning 후 null.
*
* @param mixed $declaration getMiddleware() 항목
* @param string $identifier 확장 식별자
* @param string $kind 'module' | 'plugin'
* @return array{0: class-string, 1: list<string>, 2: string, 3: list<string>}|null
*/
protected function normalizeDeclaration(mixed $declaration, string $identifier, string $kind): ?array
{
if (! is_array($declaration)) {
return null;
}
$class = $declaration['class'] ?? null;
if (! is_string($class) || $class === '' || ! class_exists($class)) {
Log::warning('확장 미들웨어 선언의 class 가 유효하지 않아 등록을 건너뜁니다.', [
'extension' => $identifier,
'kind' => $kind,
'class' => $class,
]);
return null;
}
$groups = $declaration['groups'] ?? [];
$groups = is_array($groups)
? array_values(array_filter($groups, static fn ($g): bool => in_array($g, self::ALLOWED_GROUPS, true)))
: [];
if ($groups === []) {
Log::warning('확장 미들웨어 선언의 groups 가 비어있거나 허용되지 않은 값이라 등록을 건너뜁니다.', [
'extension' => $identifier,
'kind' => $kind,
'class' => $class,
'groups' => $declaration['groups'] ?? null,
]);
return null;
}
$targets = $declaration['targets'] ?? [];
$targets = is_array($targets)
? array_values(array_filter($targets, static fn ($t): bool => is_string($t) && $t !== ''))
: [];
if ($targets === []) {
Log::warning('확장 미들웨어 선언의 targets 가 비어있어 등록을 건너뜁니다 (대상 명시 필수).', [
'extension' => $identifier,
'kind' => $kind,
'class' => $class,
]);
return null;
}
$timing = $declaration['timing'] ?? 'after_core';
if (! in_array($timing, ['before_core', 'after_core'], true)) {
$timing = 'after_core';
}
return [$class, $groups, $timing, $targets];
}
/**
* target 값을 술어(들)로 정규화합니다 (§1 카탈로그 → 매칭식).
*
* brace 는 여러 name 술어로 전개되므로 list 반환.
*
* @param string $target targets 항목
* @param string $identifier 확장 식별자 ('self' 치환용)
* @param string $kind 'module' | 'plugin'
* @param string $group 이 엔트리가 펼쳐진 단일 그룹 ('self' 치환용)
* @return list<array{op: string, arg?: string}>
*/
protected function normalizeTarget(string $target, string $identifier, string $kind, string $group): array
{
// URI 패턴 (무명 라우트 대응) — '/' 로 시작. arg 는 선행 슬래시를 포함한 원형을 유지하고
// (매칭 시 path 도 선행 슬래시로 정규화), 이렇게 해야 루트('/')·글롭('/admin/*') 모두
// $request->is() 와 동일하게 동작한다 ($request->path() 는 루트에서 '/' 를 반환).
if (str_starts_with($target, '/')) {
return [['op' => 'uri', 'arg' => $target]];
}
return match (strtolower($target)) {
'self' => [['op' => 'name', 'arg' => "{$group}.{$kind}s.{$identifier}.*"]],
'all_extensions' => [['op' => 'name_ext']],
'core' => [['op' => 'name_core']],
'everything', '*' => [['op' => 'always']],
default => $this->normalizeSpecificTarget($target),
};
}
/**
* module:{id} / plugin:{id} / brace / 원시 glob 을 name 술어로 변환합니다.
*
* @param string $target targets 항목
* @return list<array{op: string, arg: string}>
*/
protected function normalizeSpecificTarget(string $target): array
{
if (str_starts_with($target, 'module:')) {
$id = substr($target, strlen('module:'));
return [['op' => 'name', 'arg' => "*.modules.{$id}.*"]];
}
if (str_starts_with($target, 'plugin:')) {
$id = substr($target, strlen('plugin:'));
return [['op' => 'name', 'arg' => "*.plugins.{$id}.*"]];
}
// Str::is 는 brace 미지원 → 미리 전개해 개별 name 술어로 저장.
return array_map(
static fn (string $glob): array => ['op' => 'name', 'arg' => $glob],
$this->expandTargetBraces($target),
);
}
/**
* 술어가 현재 요청 라우트명·path 에 매칭되는지 평가합니다.
*
* op=uri → path 매칭; op=always → 무조건 true (무명 라우트 포함);
* name 계열 → 무명 라우트(routeName='')는 항상 miss.
*
* @param array{op: string, arg?: string} $predicate 술어
* @param string $routeName 요청 라우트명 (무명이면 빈 문자열)
* @param string $path 요청 URI path (선행 슬래시 없음)
* @return bool 매칭 여부
*/
protected function predicateMatches(array $predicate, string $routeName, string $path): bool
{
$op = $predicate['op'];
if ($op === 'always') {
return true;
}
if ($op === 'uri') {
// path 를 선행 슬래시로 정규화해 arg('/...') 와 정렬 ($request->is() 정합).
return Str::is($predicate['arg'] ?? '', '/'.ltrim($path, '/'));
}
// name 계열: 무명 라우트는 URI/always 로만 잡히므로 여기서 miss.
if ($routeName === '') {
return false;
}
return match ($op) {
'name' => Str::is($predicate['arg'] ?? '', $routeName),
'name_ext' => $this->isExtensionRoute($routeName),
'name_core' => ! $this->isExtensionRoute($routeName),
default => false,
};
}
/**
* 라우트명이 확장 라우트(*.modules.* / *.plugins.*)인지 판별합니다.
*
* @param string $routeName 요청 라우트명 (비어있지 않음)
* @return bool 확장 라우트 여부
*/
protected function isExtensionRoute(string $routeName): bool
{
return Str::is(self::EXT_GLOB, $routeName);
}
/**
* brace expansion — 'a.{b,c}.d' → ['a.b.d', 'a.c.d']. 단일 그룹만 지원.
* IDV `IdentityPolicyRepository::expandTargetBraces()` 재사용.
*
* @param string $target targets 항목
* @return list<string>
*/
protected function expandTargetBraces(string $target): array
{
if (! preg_match('/\{([^{}]+)\}/', $target, $matches)) {
return [$target];
}
$options = array_map('trim', explode(',', $matches[1]));
return array_map(
static fn (string $opt): string => preg_replace('/\{[^{}]+\}/', $opt, $target, 1),
$options,
);
}
/**
* 활성 모듈·플러그인 인스턴스를 [instance, identifier, kind] 로 순회합니다.
*
* @return iterable<array{0: object, 1: string, 2: string}>
*/
protected function activeExtensions(): iterable
{
foreach (app(ModuleManager::class)->getActiveModules() as $module) {
yield [$module, $module->getIdentifier(), 'module'];
}
foreach (app(PluginManager::class)->getActivePlugins() as $plugin) {
yield [$plugin, $plugin->getIdentifier(), 'plugin'];
}
}
/**
* 인덱스 캐시를 무효화합니다 (확장 활성/비활성/설치/제거/리로드 시).
*
* IDV `IdentityPolicy::flushRouteScopeCache()` 미러 정적 메서드.
*/
public static function flush(): void
{
$cache = app(CacheInterface::class);
if ($cache->supportsTags()) {
$cache->flushTags([self::CACHE_TAG]);
return;
}
$cache->forget(self::CACHE_KEY);
}
}