Files
Gnuboard7/plugins/_bundled/sirsoft-gdpr/plugin.php
T
chym1217 192000cfce fix(sirsoft-gdpr): 쿠키 배너 노출 토글 안내 문구 및 필드 레이아웃 정정
banner_enabled(쿠키 배너 노출) 토글 안내가 "마이페이지 동의 관리 카드"까지
함께 켜지고 꺼지는 것처럼 서술되어 있었으나, 실제로는 카드가 이 토글과
무관하게 동의/철회 이력이 있는 회원에게 항상 노출된다(GDPR Art.7(3) 철회
대칭성 보장, mypage_privacy_tab.json 회귀 가드로 이미 고정된 설계).
문구만 실제 동작과 어긋나 있어 ko/en 양쪽 hint 및 관련 코드 주석을
바로잡았다.

문구 확인 과정에서 field_legal_entity_name / field_data_storage_location /
field_banner_enabled / field_banner_position 4개 필드가 텍스트 스타일
전용 클래스(section-heading-md, grid 미정의)를 컨테이너로 잘못 사용해
안내 문구가 길어지면 입력/토글 컨트롤이 아래 줄로 밀려나던 기존 UI
결함도 함께 발견해 수정했다. 같은 파일의 field_privacy_policy_slug가
이미 쓰던 올바른 grid-3col-responsive 패턴으로 통일.
2026-07-30 10:42:04 +09:00

576 lines
25 KiB
PHP

<?php
namespace Plugins\Sirsoft\Gdpr;
use App\Contracts\Repositories\RoleRepositoryInterface;
use App\Enums\ExtensionOwnerType;
use App\Enums\MenuPermissionType;
use App\Extension\AbstractPlugin;
use App\Extension\Helpers\ExtensionMenuSyncHelper;
use App\Models\Menu;
use Plugins\Sirsoft\Gdpr\Http\Middleware\CookieConsentMiddleware;
use Plugins\Sirsoft\Gdpr\Listeners\GdprAuthConsentListener;
use Plugins\Sirsoft\Gdpr\Listeners\GdprAuthLogoutListener;
use Plugins\Sirsoft\Gdpr\Listeners\GdprUserDeleteListener;
use Plugins\Sirsoft\Gdpr\Listeners\GdprUserWithdrawListener;
/**
* GDPR (일반 데이터 보호 규정) 플러그인
*
* 쿠키 동의 배너, 동의 이력 저장, 마이페이지 동의 철회 등 GDPR 핵심 대응
* 기능을 제공합니다 (F-01/F-02/F-03/F-04).
*/
class Plugin extends AbstractPlugin
{
/**
* 기본 차단 도메인 카탈로그 (BE SSoT).
*
* 신규 설치 시 `blocked_domains` 기본값 + `GdprSettingsController` 응답
* `default_blocked_domains_preview` 의 데이터 출처입니다.
*
* SSoT 동기 의무: 본 상수는 클라이언트 코드 상수 `resources/js/blocker-domains.ts`
* 의 `DEFAULT_BLOCKED_DOMAINS` 와 동일하게 유지합니다. 한쪽 갱신 시 다른 쪽도
* 같은 PR 에서 갱신해야 하며, 어긋날 경우 신규 설치 사이트와 관리자 UI 추천
* 도메인이 불일치합니다.
*
* @var array<string, array<int, string>>
*/
public const DEFAULT_BLOCKED_DOMAINS_CATALOG = [
// Phase 2: 외부 functional 서비스 도메인 카탈로그 — 운영자가 admin UI 의 functional 차단 도메인
// TagInput 자동완성 추천으로 노출. 운영자가 사이트에 해당 도구 도입 시 클릭으로 등록.
// 외부 functional 도구는 회색 영역 (analytics/marketing 분류도 가능) — 운영자 판단 영역.
'functional' => [
// 고객지원 챗봇
'*.crisp.chat',
'client.crisp.chat',
'*.intercom.io',
'widget.intercom.io',
'*.tawk.to',
'embed.tawk.to',
// 다국어 자동 번역 위젯
'cdn.weglot.com',
'*.weglot.com',
// 사용자 설정 외부 서비스
'*.usercentrics.eu',
],
'analytics' => [
'google-analytics.com',
'*.google-analytics.com',
'googletagmanager.com',
'*.googletagmanager.com',
'ssl.google-analytics.com',
'*.hotjar.com',
'static.hotjar.com',
'*.mixpanel.com',
'cdn.mxpnl.com',
'*.amplitude.com',
'cdn.amplitude.com',
'*.segment.io',
'*.segment.com',
'wcs.naver.net',
'wcs.naver.com',
'*.beusable.net',
],
'marketing' => [
'facebook.net',
'connect.facebook.net',
'facebook.com',
'*.facebook.com',
'doubleclick.net',
'*.doubleclick.net',
'googleadservices.com',
'googlesyndication.com',
'ads.google.com',
'*.criteo.com',
'static.criteo.net',
'*.adnxs.com',
'*.taboola.com',
'cdn.taboola.com',
'*.outbrain.com',
'*.kakao.com',
'analytics.ad.daum.net',
'platform.twitter.com',
'*.twitter.com',
'platform.linkedin.com',
'*.linkedin.com',
],
];
/**
* 플러그인이 제공하는 훅 정보 반환
*
* @return array 훅 정의 배열 (action 2개)
*/
public function getHooks(): array
{
return [
[
'name' => 'sirsoft-gdpr.consent.granted',
'type' => 'action',
'description' => [
'ko' => '동의 부여 시 발화',
'en' => 'Fired when consent is granted',
],
'parameters' => [
'consent' => 'Model - GdprUserConsent',
'source' => 'string - banner|preference_center|register|mypage',
],
],
[
'name' => 'sirsoft-gdpr.consent.revoked',
'type' => 'action',
'description' => [
'ko' => '동의 철회 시 발화',
'en' => 'Fired when consent is revoked',
],
'parameters' => [
'consent' => 'Model - GdprUserConsent',
'source' => 'string - 철회 발생 경로',
],
],
];
}
/**
* 관리자 메뉴 정의
*
* 코어 PluginManager 는 모듈과 달리 plugin 의 getAdminMenus() 를 자동 호출하지 않으므로
* 본 클래스의 activate()/deactivate()/uninstall() lifecycle hook 에서 직접 sync 한다.
* 멱등성: helper.syncMenu 가 upsert 패턴이라 재활성화 시에도 row 중복 없이 그대로 복원.
*
* @return array<int, array<string, mixed>>
*/
public function getAdminMenus(): array
{
// 단독 평면 메뉴 — 본인인증 이력(admin-identity-logs)과 일관된 형태.
// 「설정」은 코어「플러그인 관리 → 설정」 자동 경로 사용 (중복 제거)
return [
[
'name' => ['ko' => 'GDPR 동의 이력', 'en' => 'GDPR Consent Log'],
'slug' => 'sirsoft-gdpr-consent-log',
'url' => '/admin/plugins/sirsoft-gdpr/consent-log',
'icon' => 'fas fa-user-shield',
'order' => 50,
],
];
}
/**
* 플러그인 활성화 — 관리자 메뉴 자동 등록 + privacy 역할 권한 부여.
*
* 모듈은 ModuleManager 가 getAdminMenus() 를 자동 처리하지만 플러그인은
* PluginManager 가 그 책임을 위임하므로 본 메서드에서 helper 를 직접 호출한다.
*
* helper.grantDefaultRoles 는 admin 역할에만 메뉴 권한을 부여하므로,
* sirsoft-gdpr.privacy 역할에는 별도로 role_menus 피벗에 부여한다.
*
* @return bool 활성화 성공 여부
*/
public function activate(): bool
{
$helper = app(ExtensionMenuSyncHelper::class);
foreach ($this->getAdminMenus() as $menuData) {
$menu = $helper->syncMenuRecursive(
$menuData,
ExtensionOwnerType::Plugin,
$this->getIdentifier(),
);
$this->grantPrivacyRoleToMenuTree($menu);
}
return true;
}
/**
* 플러그인 비활성화 — 관리자 메뉴 일괄 제거.
*
* cleanupStaleMenus 에 currentSlugs=[] 를 넘겨 본 플러그인 소속 메뉴 전체를 삭제한다.
* 자식 메뉴 + role_menus 피벗 정리는 helper 가 cascade 처리.
*
* @return bool 비활성화 성공 여부
*/
public function deactivate(): bool
{
app(ExtensionMenuSyncHelper::class)->cleanupStaleMenus(
ExtensionOwnerType::Plugin,
$this->getIdentifier(),
currentSlugs: [],
);
return true;
}
/**
* 플러그인 제거 — 메뉴 잔존 안전망 (정상 흐름은 deactivate 가 먼저 처리).
*
* @return bool 제거 성공 여부
*/
public function uninstall(): bool
{
$this->deactivate();
return true;
}
/**
* 메뉴 트리에 sirsoft-gdpr.privacy 역할의 read 권한을 재귀 부여한다.
*
* helper.grantDefaultRoles 는 admin 역할만 자동 부여하므로 privacy 역할은 별도 처리 필요.
*
* @param Menu $menu 대상 메뉴 (자식 포함)
*/
private function grantPrivacyRoleToMenuTree(Menu $menu): void
{
$role = app(RoleRepositoryInterface::class)->findByIdentifier('sirsoft-gdpr.privacy');
if ($role === null) {
return;
}
$menu->roles()->syncWithoutDetaching([
$role->id => ['permission_type' => MenuPermissionType::Read->value],
]);
foreach ($menu->children as $child) {
$this->grantPrivacyRoleToMenuTree($child);
}
}
/**
* 플러그인 권한 목록 반환 (계층 구조)
*
* PluginManager가 1레벨(플러그인 노드) → 2레벨(카테고리) → 3레벨(개별 권한) 트리로 등록.
* 모든 권한은 admin·sirsoft-gdpr.privacy 두 역할에 매핑.
*
* 권한 분할 의도:
* - view: 동의 이력·설정 조회 (감사관/모니터링 read-only)
* - update: 쿠키 카테고리·정책 버전 등 설정 변경 (정책 설정자)
*
* @return array 권한 정의 배열 (categories 계층 구조)
*/
public function getPermissions(): array
{
return [
'name' => [
'ko' => 'GDPR (일반 데이터 보호 규정)',
'en' => 'GDPR (General Data Protection Regulation)',
],
'description' => [
'ko' => 'GDPR 플러그인이 제공하는 권한',
'en' => 'Permissions provided by the GDPR plugin',
],
'categories' => [
[
'identifier' => 'privacy',
'name' => ['ko' => '개인정보 보호', 'en' => 'Privacy'],
'description' => [
'ko' => 'GDPR 도메인 권한 (조회·설정)',
'en' => 'GDPR domain permissions (view, update settings)',
],
'permissions' => [
[
'action' => 'view',
'name' => ['ko' => '개인정보 조회', 'en' => 'View Privacy'],
'description' => [
'ko' => '동의 이력·GDPR 설정 조회 (감사·모니터링)',
'en' => 'View consent log and GDPR settings (audit / monitoring)',
],
'type' => 'admin',
'roles' => ['admin', 'sirsoft-gdpr.privacy'],
],
[
'action' => 'update',
'name' => ['ko' => '개인정보 설정 변경', 'en' => 'Update Privacy Settings'],
'description' => [
'ko' => '쿠키 카테고리·정책 버전 등 GDPR 플러그인 설정 변경',
'en' => 'Modify GDPR plugin settings (cookie categories, policy version, etc.)',
],
'type' => 'admin',
'roles' => ['admin', 'sirsoft-gdpr.privacy'],
],
],
],
],
];
}
/**
* 플러그인 역할 목록 반환
*
* @return array 역할 정의 배열
*/
public function getRoles(): array
{
return [
[
'identifier' => 'sirsoft-gdpr.privacy',
'name' => ['ko' => '개인정보 운영자', 'en' => 'Privacy Operator'],
'description' => [
'ko' => 'GDPR 도메인 운영 권한 (설정·동의 이력 조회)',
'en' => 'Operational authority over GDPR domain (settings, consent log)',
],
],
];
}
/**
* 플러그인이 동적으로 생성한 테이블 목록 반환
*
* 언인스톨 시 PluginManager가 일괄 삭제 (FK 순서대로)
*
* @return array 테이블명 배열
*/
public function getDynamicTables(): array
{
return [
'gdpr_user_consent_histories',
'gdpr_user_consents',
'gdpr_policy_versions',
];
}
/**
* 설치·업데이트 시 실행할 시더 목록 반환
*
* 권한·역할은 PluginManager 가 getRoles()/getPermissions() 로 자동 등록·동기화하므로
* 별도 시더가 불필요하다.
*
* @return array 시더 클래스 배열
*/
public function getSeeders(): array
{
return [];
}
/**
* 이 플러그인이 등록할 HTTP 미들웨어 선언을 반환합니다.
*
* functional cookie 게이팅 미들웨어(CookieConsentMiddleware)를 코어 self-gate 방식으로
* 선언합니다. EDPB Guidelines 2/2023 §16(사전 차단) 충족을 위해 모든 web/api 응답을
* 대상으로 하므로 targets=['everything'](광역 타게팅) + timing=before_core(코어 전처리 전
* 응답 처리)로 지정합니다. 코어 ExtensionMiddlewareGate 가 요청 시점에 실행합니다.
*
* @return array<int, array{class: class-string, groups: array<int, string>, timing?: string, targets: array<int, string>}>
*/
public function getMiddleware(): array
{
return [
[
'class' => CookieConsentMiddleware::class,
'groups' => ['web', 'api'],
'timing' => 'before_core',
'targets' => ['everything'],
],
];
}
/**
* 플러그인 설정 값 반환 (기본값)
*
* @return array 설정 값 배열
*/
public function getConfigValues(): array
{
return [
// 정책 메타데이터 — cookie_policy_version 은 gdpr_policy_versions 테이블이 SSoT
// (마이그레이션 시 initial 행 자동 시드). 본 settings 에서는 보관하지 않음.
'privacy_policy_slug' => 'privacy',
'legal_entity_name' => '',
'data_storage_location' => '',
// 쿠키 배너 + 자동 차단 (F-01 / F-02) — banner_enabled 단일 토글로 통합 제어.
// ON 시 배너 노출 + 동의 전 외부 추적 자동 차단이 함께 시작됨. 마이페이지 동의 관리
// 카드는 이 토글과 무관 — GDPR Art.7(3) 철회 대칭성 보장을 위해 동의/철회 이력이
// 있는 회원에게 배너 노출 여부와 무관하게 항상 노출된다 (resources/extensions/mypage_privacy_tab.json 참조).
// 차단을 별도 토글로 제공하지 않는 이유: GDPR Art.6 "동의 전 처리 금지" 의 강제 메커니즘
// 인 차단을 운영자가 단독 OFF 할 수 있으면 위반 조합 (배너 ON + 차단 OFF) 가능 → CNIL
// Microsoft €60M / Amazon €35M / Google €100M 처벌 패턴과 동일. 단일 토글로 구조적 차단.
//
// 기본값 true: 플러그인 활성화 = 운영자의 GDPR 컴플라이언스 의사 표명. 시장 표준 CMP
// (OneTrust / Cookiebot / Iubenda / Klaro) 모두 "활성화 = 즉시 작동" 패턴. 운영자가
// 컴플라이언스 임시 비활성을 원할 때만 OFF.
'banner_enabled' => true,
'banner_position' => 'bottom_bar',
// F-02 도메인 기반 차단 — 카테고리별 운영자 입력.
// 게스트도 차단 동작해야 하므로 공개 응답에 노출 (defaults.json frontend_schema).
// 신규 설치 시 카탈로그 도메인이 채워져 있어 운영자가 토글 ON 만으로
// 즉시 GA·Meta 등 차단 시작 가능. 운영자는 칩 X 클릭으로 빼고 싶은 도메인
// 제거 가능. 카탈로그 갱신 동기화는 관리자 UI 의 "(+ 카탈로그에서 도메인
// 가져오기)" 링크가 클라이언트 측 Set 합집합으로 처리.
'blocked_domains' => self::DEFAULT_BLOCKED_DOMAINS_CATALOG,
'cookie_categories' => json_encode([
[
'key' => 'necessary',
'required' => true,
'label' => ['ko' => '필수 쿠키', 'en' => 'Strictly Necessary'],
'description' => [
// g7_locale 은 ePrivacy Art.5(3) + WP29 Opinion 04/2012 §3.6 의 user-initiated preference
// 예외 (사용자 가입 시 명시 선택) 로 strictly necessary 분류. 사용자 안내에 명시.
'ko' => '세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.',
'en' => 'Strictly necessary for site operation: session/CSRF/auth tokens, shopping basket identifier, user-selected language preference at registration, cookie consent record. Cannot be disabled.',
],
],
[
// Phase 1: functional 카테고리 신설 — ICO/CNIL 4분류 체계 부합.
// 자체 functional 키 (다크모드/통화) + 외부 functional 도구 (Crisp, Intercom 등) 분류 영역.
// Phase 2 에서 실제 게이팅 (Storage.prototype 가로채기 + cookie 가로채기 + Set-Cookie 미들웨어) 구현 예정.
'key' => 'functional',
'required' => false,
'label' => ['ko' => '기능 쿠키', 'en' => 'Functional'],
'description' => [
'ko' => '사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.',
'en' => 'Cookies that remember user preferences such as dark mode and display currency. If declined, defaults are used on every visit.',
],
],
[
'key' => 'analytics',
'required' => false,
'label' => ['ko' => '분석 쿠키', 'en' => 'Analytics'],
'description' => [
'ko' => '방문자가 사이트를 어떻게 이용하는지 익명으로 측정해 더 나은 서비스를 만드는 데 사용됩니다. (예: Google Analytics, Hotjar)',
'en' => 'Used to anonymously measure how visitors use the site so we can improve it. (e.g. Google Analytics, Hotjar)',
],
],
[
'key' => 'marketing',
'required' => false,
'label' => ['ko' => '마케팅 쿠키', 'en' => 'Marketing'],
'description' => [
'ko' => '관심사에 맞는 광고를 보여주거나, 광고가 얼마나 효과적이었는지 측정하는 데 사용됩니다. SNS 영상 임베드 등도 포함됩니다. (예: Facebook 픽셀, Google 광고, YouTube 영상)',
'en' => 'Used to show ads relevant to your interests, measure ad performance, and embed social media content. (e.g. Facebook Pixel, Google Ads, YouTube embeds)',
],
],
]),
// Phase 2 단순화: 운영자 등록 표 (functional_storage_keys / functional_cookies /
// functional_allow_user_initiated) 는 GDPR 원칙과 충돌하여 제거됨. 게이팅은
// strictly necessary allowlist (코드 상수) 외 모든 비-필수 저장을 동의 시까지 차단하는
// 4단계 단순화 규칙으로 처리. WP29 §3.6 user-initiated 면제는 항상 활성.
];
}
/**
* 플러그인 설정 스키마 반환
*
* @return array 설정 스키마
*/
public function getSettingsSchema(): array
{
return [
// 정책 메타데이터 — cookie_policy_version 은 gdpr_policy_versions 테이블이 SSoT.
// 운영자는 직접 입력하지 않고, Material 변경 (카테고리 추가/삭제 등) 시 시스템이 자동 발행.
'privacy_policy_slug' => [
'type' => 'string',
'default' => 'privacy',
'label' => ['ko' => '개인정보처리방침 페이지 슬러그', 'en' => 'Privacy Policy Page Slug'],
'hint' => [
'ko' => 'sirsoft-page 모듈의 페이지 slug. 미설치 또는 페이지 부재 시 관련 링크는 자동 숨김됩니다.',
'en' => 'Slug of the sirsoft-page page. Links auto-hide when page is missing.',
],
'required' => false,
],
'legal_entity_name' => [
'type' => 'string',
'default' => '',
'label' => ['ko' => '운영 주체명', 'en' => 'Legal Entity Name'],
'hint' => [
'ko' => '개인정보처리방침에 표시될 회사·서비스명. 예: "(주)홍길동컴퍼니"',
'en' => 'Company/service name shown in privacy policy.',
],
'required' => false,
],
'data_storage_location' => [
'type' => 'string',
'default' => '',
'label' => ['ko' => '데이터 저장 위치', 'en' => 'Data Storage Location'],
'hint' => [
'ko' => '개인정보가 저장되는 물리적 위치. 예: "한국 (서울 IDC)", "AWS 서울 리전"',
'en' => 'Physical location where personal data is stored.',
],
'required' => false,
],
// 쿠키 배너 + 자동 차단 단일 토글 (banner_enabled). 마이페이지 카드는 이 토글과 무관 (별도 절 참조).
// 기본값 true — 플러그인 활성화 = 운영자의 GDPR 컴플라이언스 의사 표명.
'banner_enabled' => [
'type' => 'boolean',
'default' => true,
'label' => ['ko' => '쿠키 배너 노출', 'en' => 'Show Cookie Banner'],
'hint' => [
'ko' => 'ON 시 쿠키 동의 배너 노출 + 동의 전 외부 추적 자동 차단이 함께 시작됩니다. 정책 슬러그·카테고리 설정 후 켜는 것을 권장. 마이페이지 동의 관리 카드는 이 토글과 무관하게 동의/철회 이력이 있는 회원에게 항상 노출됩니다.',
'en' => 'When ON, the cookie consent banner and pre-consent auto-blocking are activated together. The MyPage consent management card is independent of this toggle and always shows for members with consent/withdrawal history.',
],
'required' => false,
],
'banner_position' => [
'type' => 'string',
'default' => 'bottom_bar',
'label' => ['ko' => '배너 위치', 'en' => 'Banner Position'],
'hint' => [
'ko' => 'bottom_bar(하단 바) / bottom_left_popup / bottom_right_popup / centered_modal',
'en' => 'Position of the cookie banner.',
],
'required' => false,
],
'blocked_domains' => [
'type' => 'json',
'default' => json_encode(self::DEFAULT_BLOCKED_DOMAINS_CATALOG),
'label' => ['ko' => '추적 도메인 차단 목록', 'en' => 'Tracking Domain Block List'],
'hint' => [
'ko' => '카테고리별 차단 도메인 목록(JSON 객체). 신규 설치 시 카탈로그 도메인이 채워져 있으며 운영자가 추가·삭제 가능. FQDN 만 지원하며 *.example.com 와일드카드 가능.',
'en' => 'Per-category blocked domain list (JSON object). Pre-filled with the catalog on fresh install; operators can add/remove domains. FQDN only; supports *.example.com wildcard.',
],
'required' => false,
],
'cookie_categories' => [
'type' => 'json',
'default' => '[]',
'label' => ['ko' => '쿠키 카테고리 정의', 'en' => 'Cookie Categories'],
'hint' => [
'ko' => '카테고리는 4종 고정(necessary/functional/analytics/marketing). 운영자 편집 입력 없음.',
'en' => 'The four categories are fixed (necessary/functional/analytics/marketing). No operator-editable inputs.',
],
'required' => false,
],
];
}
/**
* 플러그인 메타데이터 반환
*
* @return array 메타데이터
*/
public function getMetadata(): array
{
return [
'author' => 'Sirsoft',
'license' => 'MIT',
'homepage' => 'https://sir.kr',
'keywords' => ['gdpr', 'privacy', 'consent', 'cookie', 'data-protection'],
];
}
/**
* 훅 리스너 목록 반환
*
* PluginManager 가 활성화된 플러그인에 대해 자동으로
* HookListenerRegistrar::register() 호출.
*
* @return array 훅 리스너 클래스 배열
*/
public function getHookListeners(): array
{
return [
GdprUserWithdrawListener::class,
GdprUserDeleteListener::class,
GdprAuthLogoutListener::class,
GdprAuthConsentListener::class,
];
}
}