Files
Gnuboard7/plugins/_bundled/sirsoft-gdpr/resources/js/userInitiatedTracker.ts
T
HeuJung 8cf829f953 fix(core,extensions): 설치 완료 스크롤 + 미인증 화면 테마 버튼 무반응 수정
Modern PHP User Group 2026-09 정기모임 설치 리뷰에서 접수된 제보 2건.

설치 완료·실패·중단·필수파일 안내는 페이지 최상단에 뜨는데, 진행 로그를 보느라
화면이 아래에 머물러 있으면 안내가 눈에 들어오지 않았다. 네 경로 모두 DOM 변경이
끝난 뒤 결과 섹션으로 부드럽게 이동시킨다(모션 최소화 설정 시 즉시 이동).

미인증 3화면의 테마 버튼은 setTheme 을 params.theme 으로 불렀는데 핸들러는
action.target 만 읽는다. 엔진에 상호 폴백이 없어 클릭이 콘솔 경고 한 줄만 남기고
아무 일도 하지 않았다. 로그인 이후 화면은 핸들러를 거치지 않는 ThemeToggle 을
쓰기 때문에 정상이었고, 그래서 이 세 화면에서만 나타났다.

같은 계약 불일치가 편집기 액션 레시피(admin 9·basic 3)와 두 템플릿 문서에도 있어
함께 고쳤다. 편집기로 만든 액션은 생성 즉시 no-op 이 되는데 오류가 남지 않는다.
정적 검사 레지스트리가 오히려 틀린 계약(params.theme 필수)을 강제하고 있어 올바른
형태를 막고 있었으므로 두 미러를 함께 정정했다.

테마를 고치는 과정에서 별개 결함이 드러났다. GDPR 스토리지 인터셉터가 기능 쿠키
미동의 상태에서 필수 목록 밖 저장을 부팅마다 파기하는데, 화면 테마가 그 목록에서
빠져 있었다. 저장소의 setItem 호출을 기계 도출해 대조한 결과 같은 이유로 사라지던
항목이 13건 더 있었다 — 비회원 주문 조회, 결제창 복귀 기록, 본인인증 복귀 기록,
관리자 화면 상태, 자산 주소 형식 캐시. 전량 필수로 분류하고, 동의 안내 문구가
"다크모드는 기능 쿠키" 라고 말하던 부분을 사실에 맞게 정정했다(기설치본의 저장된
문구는 업그레이드 스텝이 정정하며, 운영자가 고친 문구는 건드리지 않는다).

재발 방지는 허용목록을 직접 import 하고 모집단을 디렉토리 순회로 도출하는 커버리지
테스트가 맡는다 — 손으로 열거하지 않으므로 새 저장 키가 등재를 빠뜨리면 붉어진다.
2026-09-03 18:42:30 +09:00

136 lines
4.5 KiB
TypeScript

/**
* GDPR 사용자 의사 표명 추적기 (userInitiatedTracker)
*
* 마지막 사용자 인터랙션 (click / keydown / change / submit) 시각을 기록하여
* 직후 발생하는 storage/cookie 쓰기가 "사용자 의사에 의한 것" 인지 판정.
*
* WP29 Opinion 04/2012 §3.6 + EDPB Guidelines 2/2023 §16 의 "user-initiated
* preference" 면제 적용 — functional 카테고리 미동의 상태에서도 사용자가 직접
* 트리거한 설정 저장 (통화 변경 등) 은 허용해야 함. 그렇지 않으면
* 메뉴 클릭 시점에 setItem 차단되어 UX 가 무너짐.
*
* 판정 기준:
* - 마지막 인터랙션 시각으로부터 USER_INITIATED_THRESHOLD_MS 이내 → user-initiated
* - window.event 로 현재 이벤트 객체가 살아있고 isTrusted=true → user-initiated 폴백
*
* 보수적 윈도우 (500ms) — Page Visibility / setTimeout 으로 지연된 storage 쓰기는
* 비-사용자 트리거로 분류하여 functional_allow_user_initiated=false 와 동일 처리.
*
* 본 모듈은 install 시 capture-phase 리스너를 1회 등록. blocker / interceptor
* 보다 먼저 install 되어야 정확한 타임스탬프를 잡을 수 있음.
*
* @module sirsoft-gdpr/userInitiatedTracker
*/
/**
* "사용자 의사" 로 인정할 인터랙션 후 경과 시간 임계값 (ms).
*
* 너무 짧으면: framework 의 micro-task / re-render 후 setItem 누락
* 너무 길면: 인터랙션과 무관한 background 쓰기까지 통과
*
* 500ms 는 일반적 SPA 의 click → setState → effect → localStorage 체인을 커버.
*/
export const USER_INITIATED_THRESHOLD_MS = 500;
const TRACKED_EVENTS: readonly (keyof WindowEventMap)[] = [
'click',
'keydown',
'change',
'submit',
'pointerdown',
'touchstart',
];
let installed = false;
let lastInteractionTimestamp = 0;
const listeners: Array<{ type: keyof WindowEventMap; handler: EventListener }> = [];
/**
* 인터랙션 핸들러 — isTrusted=true 만 인정 (스크립트가 dispatchEvent 로 위장하는 케이스 차단).
*
* @param event 이벤트
*/
function handleInteraction(event: Event): void {
if (!event.isTrusted) {
return;
}
lastInteractionTimestamp = Date.now();
}
/**
* 추적기를 설치합니다.
*
* - 중복 install 방지: installed 플래그
* - capture phase 등록 — addEventListener(type, handler, true)
* - passive: true — 스크롤 / 터치 성능 보존 (preventDefault 불필요)
*
* @return void
*/
export function installUserInitiatedTracker(): void {
if (installed) {
return;
}
installed = true;
for (const type of TRACKED_EVENTS) {
const handler = handleInteraction as EventListener;
window.addEventListener(type, handler, { capture: true, passive: true });
listeners.push({ type, handler });
}
}
/**
* 추적기를 해제합니다. (테스트 격리 / cleanup 용)
*
* @return void
*/
export function uninstallUserInitiatedTracker(): void {
if (!installed) {
return;
}
for (const { type, handler } of listeners) {
window.removeEventListener(type, handler, { capture: true } as EventListenerOptions);
}
listeners.length = 0;
lastInteractionTimestamp = 0;
installed = false;
}
/**
* 현재 호출이 "사용자 의사" 인지 판정합니다.
*
* 판정 우선순위:
* 1. window.event 가 살아있고 isTrusted=true → true (가장 강력한 신호)
* 2. lastInteractionTimestamp 가 임계값 이내 → true
* 3. 그 외 → false
*
* window.event 는 legacy 이나 동기 호출 스택에서는 여전히 신뢰 가능 — Chrome / Firefox / Safari
* 모두 지원. micro-task 이후엔 null 이 되므로 timestamp 백업이 필수.
*
* @return 사용자 의사 여부
*/
export function isUserInitiated(): boolean {
// window.event 폴백 — 동기 호출 스택에서만 유효 (micro-task 이후 null).
const currentEvent = (window as Window & { event?: Event }).event;
if (currentEvent && currentEvent.isTrusted) {
return true;
}
if (lastInteractionTimestamp === 0) {
return false;
}
return Date.now() - lastInteractionTimestamp <= USER_INITIATED_THRESHOLD_MS;
}
/**
* 테스트 헬퍼 — 마지막 인터랙션 시각을 강제 설정.
*
* 프로덕션 코드에선 사용하지 않음. __tests__ 에서만 import.
*
* @param timestamp 설정할 ms epoch (0 은 미발생 상태)
* @return void
*/
export function __setLastInteractionForTest(timestamp: number): void {
lastInteractionTimestamp = timestamp;
}