메인 페이지: ctxKey 발급 + 세션에 element 저장
“ajax 호출 전에 변수들이 이미 선언”하고, 그 변수들을 element 슬롯에 포함
<?php
session_start();
$pageToken = bin2hex(random_bytes(8));
$_SESSION['tab_ctx'][$pageToken] = [
'e1' => $element1 ?? '',
'e2' => $element2 ?? '',
'e3' => $element3 ?? '',
'e4' => $element4 ?? '',
'ts' => time(),
];
// TTL 정리 (30분)
$ttl = 1800;
foreach ($_SESSION['tab_ctx'] ?? [] as $k => $v) {
if (time() - ($v['ts'] ?? 0) > $ttl) {
unset($_SESSION['tab_ctx'][$k]);
}
}
그리고 폼(아무 폼) 안에 ctxKey만 hidden으로 삽입
<input type="hidden" name="pageToken" value="<?=htmlspecialchars($pageToken)?>">
탭 HTML
<div class="tabbox" data-tabbox="box1" data-form="#FormID" data-allow-form="1" data-url-prefix="./ajax_tab_" data-default-tab="Tab1">
<div class="tabbox-tabs">
<div class="tabbox-tab active" data-tab="Tab1">Tab1</div>
<div class="tabbox-tab" data-tab="Tab2">Tab2</div>
<div class="tabbox-tab" data-tab="Tab3">Tab3</div>
</div>
<div class="tabbox-panel"><div class="coming">Loading....</div></div>
</div>
data-allow-form="1" 없으면 data-form은 있어도 전송 안 함
JS: 폼을 그대로 보내되, 서버는 ctxKey만 믿고 처리
<script>
function safeKey(s){
return String(s || '').replace(/[^0-9A-Za-z_\-]/g, '');
}
function makeUrl(prefix, tabKey){
const key = safeKey(tabKey);
if(!key) return '';
return String(prefix || '') + key + '.php'; // ./ajax_tab_Tab1.php
}
function getPageToken(rootEl){
// 1) tabbox에서 토큰 selector 지정하면 그걸 사용
// 예: data-page-token="#pageToken"
const sel = rootEl.dataset.pageToken || '#pageToken';
const el = document.querySelector(sel);
if(!el) return '';
return ('value' in el) ? String(el.value || '') : String(el.textContent || '');
}
function getFormFromTabbox(rootEl){
// data-form="#FormID" 우선, 없으면 가장 가까운 form
const sel = rootEl.dataset.form || '';
if(sel){
const f = document.querySelector(sel);
if(f && f.tagName === 'FORM') return f;
}
const near = rootEl.closest('form');
return (near && near.tagName === 'FORM') ? near : null;
}
function setActive(tabsWrap, tabKey){
tabsWrap.querySelectorAll('.tabbox-tab').forEach(t => {
t.classList.toggle('active', t.dataset.tab === tabKey);
});
}
function runScripts(container){
container.querySelectorAll('script').forEach(old => {
const s = document.createElement('script');
if(old.src) s.src = old.src;
s.text = old.textContent;
old.replaceWith(s);
});
}
async function ajaxLoad(panelEl, url, rootEl){
if(!panelEl) return;
panelEl.innerHTML = "<div class='coming'>Loading....</div>";
try{
const pageToken = getPageToken(rootEl);
if(!pageToken){
panelEl.innerHTML = "<div class='coming'>pageToken 없음</div>";
return;
}
// ✅ 기본: pageToken만 전송 (노출 최소화)
const fd = new FormData();
fd.set('pageToken', pageToken);
fd.set('_ts', String(Date.now()));
// ✅ 옵션: data-allow-form="1" 일 때만, data-form 폼을 합쳐서 전송
// (기본 OFF)
if(rootEl.dataset.allowForm === '1'){
const formEl = getFormFromTabbox(rootEl);
if(formEl){
const extra = new FormData(formEl);
extra.forEach((v, k) => {
if(k === 'pageToken' || k === '_ts') return; // 보호
fd.set(k, v);
});
}
}
const res = await fetch(url, {
method:'POST',
body: fd,
headers:{ 'X-Requested-With':'XMLHttpRequest' }
});
if(res.status === 403){
panelEl.innerHTML = "<div class='coming'>권한 없음(403)</div>";
return;
}
if(res.status === 410){
panelEl.innerHTML = "<div class='coming'>만료됨(410) - 새로고침 필요</div>";
return;
}
if(!res.ok){
panelEl.innerHTML = "<div class='coming'>오류(" + res.status + ")</div>";
return;
}
const html = await res.text();
panelEl.innerHTML = html || "<div class='coming'>Loading....</div>";
runScripts(panelEl);
}catch(e){
panelEl.innerHTML = "<div class='coming'>Loading....</div>";
}
}
function initTabBox(rootEl){
const tabsWrap = rootEl.querySelector('.tabbox-tabs');
const panel = rootEl.querySelector('.tabbox-panel');
if(!tabsWrap || !panel) return;
const urlPrefix = rootEl.dataset.urlPrefix || './ajax_tab_';
async function loadTab(tabKey){
const url = makeUrl(urlPrefix, tabKey);
if(!url) return;
setActive(tabsWrap, tabKey);
await ajaxLoad(panel, url, rootEl);
}
tabsWrap.querySelectorAll('.tabbox-tab').forEach(tab => {
tab.addEventListener('click', () => loadTab(tab.dataset.tab || ''));
});
const first =
rootEl.dataset.defaultTab
|| tabsWrap.querySelector('.tabbox-tab.active')?.dataset.tab
|| tabsWrap.querySelector('.tabbox-tab')?.dataset.tab;
if(first) loadTab(first);
}
document.querySelectorAll('.tabbox[data-tabbox]').forEach(initTabBox);
</script>
ajax 파일: ctxKey로 세션에서 e1~ 꺼내기
<?php
session_start();
/* ✅ ajax 요청인지 최소 확인 (강제는 아니지만 추천) */
$xhr = $_SERVER['HTTP_X_REQUESTED_WITH'] ?? '';
if (strcasecmp($xhr, 'XMLHttpRequest') !== 0) {
http_response_code(400);
exit('bad request');
}
/* ✅ pageToken 기반 컨텍스트 검증 */
$pageToken = (string)($_POST['pageToken'] ?? '');
if ($pageToken === '' || empty($_SESSION['tab_ctx'][$pageToken])) {
http_response_code(403);
exit('invalid context');
}
$ctx = $_SESSION['tab_ctx'][$pageToken];
/* ✅ TTL 체크 (30분) */
$ttl = 1800;
$ts = (int)($ctx['ts'] ?? 0);
if ($ts <= 0 || (time() - $ts) > $ttl) {
unset($_SESSION['tab_ctx'][$pageToken]);
http_response_code(410);
exit('expired');
}
/* ✅ (선택) 1회 사용 후 폐기: 더 강하게 “단독/재호출” 억제 */
# unset($_SESSION['tab_ctx'][$pageToken]);
$e1 = $ctx['e1'] ?? '';
$e2 = $ctx['e2'] ?? '';
$e3 = $ctx['e3'] ?? '';
$e4 = $ctx['e4'] ?? '';
// 여기서부터 탭 작성자가 알아서 사용
// 여기서부터는 이 탭 작성자가 "e1이 뭔지" 알아서 쓰면 됨