Files
Gnuboard7/app/Extension/HookManager.php
T
HeuJung b127afcea7 fix(identity): 잔여 과제 전건 처리 + 로딩 표시 실렌더 확정
앞선 커밋이 CHANGELOG 에 주장한 "로딩 표시가 나오지 않던 문제 수정" 이 실제로
화면에 나타나는지 브라우저로 확정했다. 전달 경로부터 DOM 마운트·해제까지 측정했고,
지연·에러 응답에서도 영구 로딩이 남지 않음을 확인했다.

측정 과정에서 앞선 세션의 판단 하나가 틀렸음을 확인했다 — "오버레이 DOM 이 어느
화면에서도 관측되지 않는다" 는 관측 대상 노드를 잘못 잡은 것이었다. 스피너 방식
레이아웃은 다른 노드를 쓰며, 대조군에서도 안 보였던 이유가 이것이다.

미뤄 두었던 과제 4건을 함께 구현했다.

- 인증창을 그냥 닫고 이탈하면 그 인증 요청을 서버에도 종료로 표시한다.
 이전에는 브라우저만 알고 있어 인증 이력에 "보낸 뒤 소식 없는" 항목이 남았다
- 플러그인 설정 화면이 저장된 비밀값을 다시 내려받던 것을 가려서 표시한다.
 그 항목을 건드리지 않고 저장하면 저장된 값이 그대로 유지된다
- 언어팩 반영 누락 검사가 버전만 비교해 "버전 같고 내용 다름" 을 놓치던 사각을
 콘텐츠 비교로 닫았다
- 확장이 검증 규칙을 추가할 때 쓰는 필터 이름 6개를 표준 형태로 통일했다.
 예전 이름도 함께 발행하므로 기존 확장은 수정 없이 동작한다

정적 검사 룰 2종의 오탐·사각을 함께 고쳤고, 개명으로 검사 대상에 편입된 파일의
사전 결함 8건을 해소했다. 검수용 데이터는 전량 정리했다.
2026-07-31 00:32:48 +09:00

412 lines
14 KiB
PHP

<?php
namespace App\Extension;
use App\Contracts\Extension\HookManagerInterface;
use App\Events\GenericBroadcastEvent;
use App\Models\PermissionHook;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
class HookManager implements HookManagerInterface
{
private static array $hooks = [];
private static array $filters = [];
private static array $dispatching = [];
/**
* 구 훅 이름 사용 경고를 이미 남긴 훅 이름 집합 — 요청마다 한 번씩만 경고합니다.
*
* @var array<string, bool>
*/
private static array $legacyHookNoticeShown = [];
/**
* 현재 실행 중인 훅 이름 스택 — 정책 Listener 등 "어느 훅에서 호출되었는지" 알아야 하는
* 단일 핸들러 패턴에 사용됩니다. (내부 전용)
*
* @var array<int, string>
*/
private static array $runningHookStack = [];
/**
* Hook 이벤트를 발생시켜 등록된 콜백들을 실행합니다.
*
* @param string $hookName Hook 이름
* @param mixed ...$args Hook에 전달할 인수들
*/
public static function doAction(string $hookName, ...$args): void
{
// 가드 플래그 설정 (addAction으로 등록된 콜백의 Event::listen 중복 실행 방지)
self::$dispatching[$hookName] = true;
self::$runningHookStack[] = $hookName;
try {
// 등록된 Hook이 있는지 확인
if (isset(self::$hooks[$hookName])) {
// Hook들을 우선순위에 따라 정렬
$hooks = self::$hooks[$hookName];
ksort($hooks);
// 각 Hook을 순차적으로 실행
foreach ($hooks as $priority => $callbacks) {
foreach ($callbacks as $callback) {
call_user_func_array($callback, $args);
}
}
}
// Laravel 이벤트 시스템에도 전달 (직접 Event::listen으로 등록한 외부 리스너용)
Event::dispatch("hook.{$hookName}", $args);
} finally {
array_pop(self::$runningHookStack);
unset(self::$dispatching[$hookName]);
}
}
/**
* 현재 실행 중인 (가장 내부의) 훅 이름을 반환합니다.
* 단일 handler 메서드가 여러 훅에 구독되어 "어느 훅에서 호출되었는지" 구분이 필요할 때 사용.
*
* @return string|null 실행 중 훅 이름 또는 null (최상위 컨텍스트)
*/
public static function getRunningHook(): ?string
{
$count = count(self::$runningHookStack);
return $count > 0 ? self::$runningHookStack[$count - 1] : null;
}
/**
* Filter를 적용하여 데이터를 변환합니다.
*
* @param string $filterName Filter 이름
* @param mixed $value 변환할 원본 값
* @param mixed ...$args Filter에 전달할 추가 인수들
* @return mixed 변환된 값
*/
public static function applyFilters(string $filterName, $value, ...$args)
{
$eventName = "filter.{$filterName}";
// 등록된 필터가 있는지 확인
if (! isset(self::$filters[$filterName])) {
return $value;
}
// 필터들을 우선순위에 따라 정렬
$filters = self::$filters[$filterName];
ksort($filters);
// 각 필터를 순차적으로 적용
foreach ($filters as $priority => $callbacks) {
foreach ($callbacks as $callback) {
$value = call_user_func_array($callback, array_merge([$value], $args));
}
}
return $value;
}
/**
* Hook 이벤트에 콜백 함수를 등록합니다.
*
* @param string $hookName Hook 이름
* @param callable $callback 실행할 콜백 함수
* @param int $priority 실행 우선순위 (기본값: 10)
*/
public static function addAction(string $hookName, callable $callback, int $priority = 10): void
{
if (! isset(self::$hooks[$hookName])) {
self::$hooks[$hookName] = [];
}
if (! isset(self::$hooks[$hookName][$priority])) {
self::$hooks[$hookName][$priority] = [];
}
self::$hooks[$hookName][$priority][] = $callback;
// Laravel 이벤트 시스템에도 등록 (가드 래핑으로 doAction 실행 중 중복 방지)
Event::listen("hook.{$hookName}", function (...$args) use ($hookName, $callback) {
if (! empty(self::$dispatching[$hookName])) {
return; // doAction() 내부 배열에서 이미 실행됨 → 스킵
}
call_user_func_array($callback, $args);
});
}
/**
* Filter에 콜백 함수를 등록합니다.
*
* @param string $filterName Filter 이름
* @param callable $callback 실행할 콜백 함수
* @param int $priority 실행 우선순위 (기본값: 10)
*/
public static function addFilter(string $filterName, callable $callback, int $priority = 10): void
{
if (! isset(self::$filters[$filterName])) {
self::$filters[$filterName] = [];
}
if (! isset(self::$filters[$filterName][$priority])) {
self::$filters[$filterName][$priority] = [];
}
self::$filters[$filterName][$priority][] = $callback;
}
/**
* WebSocket 브로드캐스트를 실행합니다.
*
* broadcast(new Event(...)) 직접 호출 대신 이 메서드를 사용합니다.
* 내부적으로 GenericBroadcastEvent를 생성하여 Laravel broadcast()를 실행합니다.
*
* @param string $channel 채널명 (예: 'admin.dashboard', 'user.notifications.123')
* @param string $eventName 이벤트명 (예: 'dashboard.stats.updated', 'notification.received')
* @param array $payload 브로드캐스트 데이터
*/
public static function broadcast(string $channel, string $eventName, array $payload = []): void
{
$driver = config('broadcasting.default');
if (in_array($driver, ['null', 'log', null], true)) {
return;
}
// Reverb/Pusher 드라이버: 호스트 미설정 시 연결 시도 없이 건너뜀
$connection = config("broadcasting.connections.{$driver}");
if (empty($connection['options']['host'] ?? null)) {
return;
}
try {
broadcast(new GenericBroadcastEvent($channel, $eventName, $payload));
} catch (\Throwable $e) {
Log::warning('브로드캐스트 실패 (Reverb 미실행 가능)', [
'channel' => $channel,
'event' => $eventName,
'error' => $e->getMessage(),
]);
}
}
/**
* Hook 이벤트에서 콜백 함수를 제거합니다.
*
* @param string $hookName Hook 이름
* @param callable $callback 제거할 콜백 함수
*/
public static function removeAction(string $hookName, callable $callback): void
{
if (isset(self::$hooks[$hookName])) {
foreach (self::$hooks[$hookName] as $priority => $callbacks) {
$key = array_search($callback, $callbacks, true);
if ($key !== false) {
unset(self::$hooks[$hookName][$priority][$key]);
}
}
}
// Laravel 이벤트 시스템에서도 제거
Event::forget("hook.{$hookName}");
}
/**
* Filter에서 콜백 함수를 제거합니다.
*
* @param string $filterName Filter 이름
* @param callable $callback 제거할 콜백 함수
*/
public static function removeFilter(string $filterName, callable $callback): void
{
if (isset(self::$filters[$filterName])) {
foreach (self::$filters[$filterName] as $priority => $callbacks) {
$key = array_search($callback, $callbacks, true);
if ($key !== false) {
unset(self::$filters[$filterName][$priority][$key]);
}
}
}
}
/**
* 등록된 모든 Hook 목록을 조회합니다.
*
* @return array Hook 목록 배열
*/
public static function getHooks(): array
{
return self::$hooks;
}
/**
* 등록된 모든 Filter 목록을 조회합니다.
*
* @return array Filter 목록 배열
*/
public static function getFilters(): array
{
return self::$filters;
}
/**
* 특정 Hook에 등록된 모든 콜백을 제거합니다.
*
* @param string $hookName Hook 이름
*/
public static function clearAction(string $hookName): void
{
if (isset(self::$hooks[$hookName])) {
unset(self::$hooks[$hookName]);
}
// Laravel 이벤트 시스템에서도 제거
Event::forget("hook.{$hookName}");
}
/**
* 특정 Filter에 등록된 모든 콜백을 제거합니다.
*
* @param string $filterName Filter 이름
*/
public static function clearFilter(string $filterName): void
{
if (isset(self::$filters[$filterName])) {
unset(self::$filters[$filterName]);
}
}
/**
* 테스트 환경에서 등록된 모든 Hook과 Filter를 초기화합니다.
*
* 주의: 이 메서드는 테스트 환경에서만 사용해야 합니다.
*/
public static function resetAll(): void
{
self::$hooks = [];
self::$filters = [];
self::$dispatching = [];
}
/**
* 훅 실행 전 권한 체크
*
* permission_hooks 테이블에 해당 훅이 매핑되어 있으면
* 현재 사용자가 해당 권한을 보유하고 있는지 확인합니다.
*
* @param string $hookName 훅 이름
* @param User|null $user 사용자 (null이면 현재 인증된 사용자)
*
* @throws AuthorizationException 권한이 없는 경우
*/
public static function checkHookPermission(string $hookName, ?User $user = null): void
{
// 훅에 권한 매핑이 없으면 모든 사용자 허용
if (! PermissionHook::hasPermissionMapping($hookName)) {
return;
}
// 사용자 결정 (파라미터 우선, 없으면 현재 인증 사용자)
$user = $user ?? Auth::user();
// 비인증 사용자는 권한 매핑된 훅 접근 불가
if (! $user) {
throw new AuthorizationException(__('auth.unauthorized'));
}
// 관리자는 모든 권한 보유
if ($user->isAdmin()) {
return;
}
// 사용자가 훅에 매핑된 권한 중 하나라도 보유하는지 확인
$requiredPermissions = PermissionHook::getPermissionsForHook($hookName);
foreach ($requiredPermissions as $permission) {
if ($user->hasPermission($permission->identifier)) {
return;
}
}
throw new AuthorizationException(__('auth.unauthorized'));
}
/**
* 훅 실행 전 권한 체크 후 Action 실행
*
* @param string $hookName 훅 이름
* @param User|null $user 권한 체크 대상 사용자
* @param mixed ...$args 훅에 전달할 인자
*
* @throws AuthorizationException 권한이 없는 경우
*/
public static function doActionWithPermission(string $hookName, ?User $user = null, ...$args): void
{
static::checkHookPermission($hookName, $user);
static::doAction($hookName, ...$args);
}
/**
* 훅 실행 전 권한 체크 후 Filter 실행
*
* @param string $hookName 훅 이름
* @param User|null $user 권한 체크 대상 사용자
* @param mixed $value 필터링할 값
* @param mixed ...$args 필터에 전달할 추가 인자
* @return mixed 필터링된 값
*
* @throws AuthorizationException 권한이 없는 경우
*/
public static function applyFiltersWithPermission(string $hookName, ?User $user = null, mixed $value = null, ...$args): mixed
{
static::checkHookPermission($hookName, $user);
return static::applyFilters($hookName, $value, ...$args);
}
/**
* 표준 이름과 구 이름을 함께 발행하는 Filter 실행
*
* 이름이 표준(`core.{대상}.{동작}_validation_rules`)과 어긋난 채 이미 공개된 훅을 표준 이름으로
* 옮길 때 사용합니다. 표준 이름을 먼저 적용하고 그 결과에 구 이름을 다시 적용하므로,
* 새로 구독하는 확장과 구 이름을 구독 중인 기존 확장이 함께 동작합니다.
*
* 구 이름에 실제 구독자가 있을 때만 한 번 경고를 남깁니다 — 개명 사실을 확장 개발자가 알 수 있게
* 하되, 구독자가 없는 환경에서 로그를 어지럽히지 않기 위함입니다.
*
* @param string $hookName 표준 훅 이름
* @param string $legacyHookName 구 훅 이름 (하위호환용)
* @param mixed $value 필터링할 값
* @param mixed ...$args 필터에 전달할 추가 인자
* @return mixed 필터링된 값
*/
public static function applyFiltersWithLegacyName(
string $hookName,
string $legacyHookName,
mixed $value = null,
...$args
): mixed {
$value = static::applyFilters($hookName, $value, ...$args);
if (! isset(self::$filters[$legacyHookName])) {
return $value;
}
if (! isset(self::$legacyHookNoticeShown[$legacyHookName])) {
self::$legacyHookNoticeShown[$legacyHookName] = true;
Log::warning('구 훅 이름 사용 중 — 표준 이름으로 옮겨 주세요', [
'legacy' => $legacyHookName,
'standard' => $hookName,
]);
}
return static::applyFilters($legacyHookName, $value, ...$args);
}
}