Add guarded reboot control

This commit is contained in:
seo
2026-06-07 13:59:12 +09:00
parent d6b1322a8f
commit 4a02f7f354
6 changed files with 244 additions and 10 deletions
+8 -1
View File
@@ -20,6 +20,8 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
- Web Push 구독 등록, 자동 복구, 구독 기기 목록 표시 - Web Push 구독 등록, 자동 복구, 구독 기기 목록 표시
- Push 기기 건강 상태 추적과 헬스체크 수동/자동 실행 - Push 기기 건강 상태 추적과 헬스체크 수동/자동 실행
- WakeLock 버튼으로 대시보드 화면 꺼짐 방지 - WakeLock 버튼으로 대시보드 화면 꺼짐 방지
- Reboot 버튼으로 2단계 확인 후 시스템 재부팅 요청
- 기본 브라우저 alert/confirm/prompt 대신 대시보드 디자인에 맞춘 custom dialog 사용
## 주요 API ## 주요 API
@@ -32,6 +34,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
- `public/api.php?action=send_push_healthcheck`: 등록된 Push 기기 전체에 상태 확인 알림 발송 - `public/api.php?action=send_push_healthcheck`: 등록된 Push 기기 전체에 상태 확인 알림 발송
- `public/api.php?action=delete_push_endpoint`: 현재 endpoint 구독 삭제 - `public/api.php?action=delete_push_endpoint`: 현재 endpoint 구독 삭제
- `public/api.php?action=dmesg`: dmesg 로그 조회 - `public/api.php?action=dmesg`: dmesg 로그 조회
- `public/api.php?action=reboot`: 확인 단어와 관리자 암호 재검증 후 sudo reboot 실행
## 구성 ## 구성
@@ -116,7 +119,8 @@ control-push-healthcheck.service
4. 팬 조작은 상태 저장, 정책 적용, 로그 저장 순서로 처리합니다. 4. 팬 조작은 상태 저장, 정책 적용, 로그 저장 순서로 처리합니다.
5. Push 등록은 권한 요청과 구독 저장이 끝나면 즉시 상태를 갱신합니다. 5. Push 등록은 권한 요청과 구독 저장이 끝나면 즉시 상태를 갱신합니다.
6. WakeLock 버튼은 활성 상태를 초록색 버튼으로 표시합니다. 6. WakeLock 버튼은 활성 상태를 초록색 버튼으로 표시합니다.
7. Push 발송, 수신, 표시, 클릭 이벤트를 기록하고 구독 DB의 건강 상태를 갱신합니다. 7. Reboot 버튼은 `재부팅` 단어 입력과 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다.
8. Push 발송, 수신, 표시, 클릭 이벤트를 기록하고 구독 DB의 건강 상태를 갱신합니다.
## 주요 함수/모듈 ## 주요 함수/모듈
@@ -127,12 +131,14 @@ control-push-healthcheck.service
- `push_health_summary()`: 상태별 Push 기기 수 집계 - `push_health_summary()`: 상태별 Push 기기 수 집계
- `send_push_healthcheck_if_due()`: 쿨다운 기반 헬스체크 알림 발송 - `send_push_healthcheck_if_due()`: 쿨다운 기반 헬스체크 알림 발송
- `assets/wakelock.js`: WakeLock 버튼 상태와 Screen Wake Lock API 제어 - `assets/wakelock.js`: WakeLock 버튼 상태와 Screen Wake Lock API 제어
- `customAlert()`, `customConfirm()`, `customPrompt()`: 대시보드 공통 확인/입력 dialog
## 보안 ## 보안
- 로그인 세션과 CSRF token을 사용합니다. - 로그인 세션과 CSRF token을 사용합니다.
- POST 조작 API는 CSRF 검증을 통과해야 합니다. - POST 조작 API는 CSRF 검증을 통과해야 합니다.
- sysfs 쓰기와 systemctl 실행 권한은 sudoers 범위로 제한해야 합니다. - sysfs 쓰기와 systemctl 실행 권한은 sudoers 범위로 제한해야 합니다.
- 재부팅 API는 CSRF, 로그인 세션, 확인 단어 `재부팅`, 관리자 암호 재검증을 모두 요구합니다.
- VAPID key와 앱 비밀번호는 저장소 밖 secret 파일로 관리합니다. - VAPID key와 앱 비밀번호는 저장소 밖 secret 파일로 관리합니다.
## 운영 체크포인트 ## 운영 체크포인트
@@ -143,3 +149,4 @@ control-push-healthcheck.service
- Push 구독 자동 복구가 과도한 반복 등록을 만들지 않는지 확인합니다. - Push 구독 자동 복구가 과도한 반복 등록을 만들지 않는지 확인합니다.
- Push 헬스체크 timer 상태는 `systemctl list-timers --all control-push-healthcheck.timer`로 확인합니다. - Push 헬스체크 timer 상태는 `systemctl list-timers --all control-push-healthcheck.timer`로 확인합니다.
- `failed`, `stale` 기기는 알림 권한, 브라우저 데이터 삭제, PWA 재설치 여부를 확인합니다. - `failed`, `stale` 기기는 알림 권한, 브라우저 데이터 삭제, PWA 재설치 여부를 확인합니다.
- Reboot API 사용 전 웹 서버 실행 계정의 sudoers에 `/usr/sbin/reboot` 비밀번호 없는 실행 권한이 제한적으로 설정되어 있는지 확인합니다.
+45
View File
@@ -1751,6 +1751,51 @@ function control_api_dispatch(): void
]); ]);
} }
if ($action === 'reboot') {
$phrase = trim((string)($_POST['phrase'] ?? ''));
$password = (string)($_POST['password'] ?? '');
if ($phrase !== '재부팅') {
json_out([
'ok' => false,
'error' => 'bad_reboot_phrase',
'message' => '재부팅 확인 단어가 올바르지 않습니다.',
], 400);
}
if (!hash_equals(APP_PASSWORD, $password)) {
json_out([
'ok' => false,
'error' => 'bad_reboot_password',
'message' => '관리자 암호가 올바르지 않습니다.',
], 403);
}
add_fan_action(
'system_reboot',
null,
null,
'web reboot requested',
true
);
$result = sh(['/usr/sbin/reboot'], true, 5);
if ($result['code'] !== 0) {
json_out([
'ok' => false,
'error' => 'reboot_failed',
'message' => trim($result['out']) !== '' ? trim($result['out']) : 'reboot command failed',
], 500);
}
json_out([
'ok' => true,
'data' => [
'message' => 'reboot_requested',
],
]);
}
if ($action === 'wifi') { if ($action === 'wifi') {
$verb = (string)($_POST['verb'] ?? ''); $verb = (string)($_POST['verb'] ?? '');
$unit = (string)($_POST['unit'] ?? ''); $unit = (string)($_POST['unit'] ?? '');
+150
View File
@@ -29,6 +29,13 @@
pushStatus: $('#pushStatus'), pushStatus: $('#pushStatus'),
pushDeviceList: $('#pushDeviceList'), pushDeviceList: $('#pushDeviceList'),
pushHealthcheckBtn: $('#pushHealthcheckBtn'), pushHealthcheckBtn: $('#pushHealthcheckBtn'),
rebootBtn: $('#rebootBtn'),
customDialog: $('#customDialog'),
customDialogTitle: $('#customDialogTitle'),
customDialogMessage: $('#customDialogMessage'),
customDialogInput: $('#customDialogInput'),
customDialogCancel: $('#customDialogCancel'),
customDialogOk: $('#customDialogOk'),
processCpuTable: $('#processCpuTable'), processCpuTable: $('#processCpuTable'),
processMemoryTable: $('#processMemoryTable'), processMemoryTable: $('#processMemoryTable'),
dmesgToggle: $('#dmesgToggle'), dmesgToggle: $('#dmesgToggle'),
@@ -111,6 +118,97 @@
}, 2600); }, 2600);
} }
function customDialog(options = {}) {
const layer = els.customDialog;
const input = els.customDialogInput;
const ok = els.customDialogOk;
const cancel = els.customDialogCancel;
if (!layer || !input || !ok || !cancel) {
return Promise.resolve(null);
}
return new Promise(resolve => {
const mode = options.mode || 'alert';
const needsInput = mode === 'prompt';
const previousFocus = document.activeElement;
let settled = false;
const cleanup = value => {
if (settled) return;
settled = true;
layer.dataset.open = '0';
layer.setAttribute('aria-hidden', 'true');
ok.classList.remove('red');
ok.textContent = '확인';
cancel.textContent = '취소';
input.value = '';
input.hidden = true;
input.type = 'text';
ok.removeEventListener('click', onOk);
cancel.removeEventListener('click', onCancel);
layer.removeEventListener('click', onLayerClick);
document.removeEventListener('keydown', onKeydown);
if (previousFocus && typeof previousFocus.focus === 'function') {
previousFocus.focus();
}
resolve(value);
};
const onOk = () => {
if (needsInput) {
cleanup(input.value);
return;
}
cleanup(true);
};
const onCancel = () => cleanup(mode === 'confirm' ? false : null);
const onLayerClick = event => {
if (event.target === layer) onCancel();
};
const onKeydown = event => {
if (event.key === 'Escape') onCancel();
if (event.key === 'Enter' && (document.activeElement === input || !needsInput)) onOk();
};
els.customDialogTitle.textContent = options.title || '확인';
els.customDialogMessage.textContent = options.message || '';
ok.textContent = options.okText || '확인';
cancel.textContent = options.cancelText || '취소';
ok.classList.toggle('red', options.danger === true);
cancel.hidden = mode === 'alert';
input.hidden = !needsInput;
input.type = options.inputType || 'text';
input.placeholder = options.placeholder || '';
input.autocomplete = options.autocomplete || 'off';
input.value = options.defaultValue || '';
ok.addEventListener('click', onOk);
cancel.addEventListener('click', onCancel);
layer.addEventListener('click', onLayerClick);
document.addEventListener('keydown', onKeydown);
layer.dataset.open = '1';
layer.setAttribute('aria-hidden', 'false');
setTimeout(() => (needsInput ? input : ok).focus(), 20);
});
}
window.customAlert = (message, options = {}) => customDialog(Object.assign({
mode: 'alert',
title: '알림',
message,
}, options));
window.customConfirm = (message, options = {}) => customDialog(Object.assign({
mode: 'confirm',
title: '확인',
message,
}, options));
window.customPrompt = (message, options = {}) => customDialog(Object.assign({
mode: 'prompt',
title: '입력',
message,
}, options));
async function api(action, body = null) { async function api(action, body = null) {
const opts = { const opts = {
method: body ? 'POST' : 'GET', method: body ? 'POST' : 'GET',
@@ -1081,6 +1179,55 @@
} }
} }
async function requestReboot() {
const phrase = await window.customPrompt('재부팅을 계속하려면 아래에 재부팅을 입력하세요.', {
title: '시스템 재부팅',
okText: '다음',
danger: true,
placeholder: '재부팅',
autocomplete: 'off',
});
if (phrase === null) return;
if (String(phrase).trim() !== '재부팅') {
await window.customAlert('확인 단어가 일치하지 않습니다.', {
title: '재부팅 취소',
danger: true,
});
return;
}
const password = await window.customPrompt('관리자 암호를 다시 입력하세요.', {
title: '관리자 확인',
okText: '재부팅',
danger: true,
inputType: 'password',
autocomplete: 'current-password',
});
if (password === null) return;
try {
els.rebootBtn.disabled = true;
notice('Reboot command sending...', 'info');
await api('reboot', {
phrase: '재부팅',
password,
});
await window.customAlert('재부팅 명령을 전송했습니다. 잠시 후 연결이 끊어질 수 있습니다.', {
title: '재부팅 시작',
okText: '확인',
});
} catch (e) {
console.error(e);
els.rebootBtn.disabled = false;
await window.customAlert(e.message || 'reboot failed', {
title: '재부팅 실패',
danger: true,
});
}
}
if (els.fanSlider && els.fanSliderValue) { if (els.fanSlider && els.fanSliderValue) {
els.fanSlider.addEventListener('input', () => { els.fanSlider.addEventListener('input', () => {
markFanDirty(); markFanDirty();
@@ -1104,6 +1251,9 @@
els.dmesgToggle?.addEventListener('click', () => { els.dmesgToggle?.addEventListener('click', () => {
setDmesgOpen(!state.dmesgOpen); setDmesgOpen(!state.dmesgOpen);
}); });
els.rebootBtn?.addEventListener('click', () => {
requestReboot();
});
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (!document.hidden) { if (!document.hidden) {
if (!sendWs({ type: 'status_refresh' })) { if (!sendWs({ type: 'status_refresh' })) {
+5 -1
View File
@@ -55,7 +55,11 @@
job.catch(error => { job.catch(error => {
wanted = false; wanted = false;
wakeLock = null; wakeLock = null;
alert(error.message || 'WakeLock failed'); const alertFn = window.customAlert || (() => Promise.resolve());
alertFn(error.message || 'WakeLock failed', {
title: 'WakeLock 오류',
danger: true,
});
render(); render();
}); });
}); });
+16 -3
View File
@@ -107,6 +107,7 @@ a{color:inherit;text-decoration:none}
.resource-card{margin-top:18px}.resource-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:16px}.resource-head h2{margin:0}.baseline-pill{color:var(--sub);font-size:12px;font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap}.spike-log-box{margin-top:16px}.spike-log-box h3{margin:0 0 9px;color:#c6d3e6;font-size:14px;font-weight:500}.spike-log-list{display:grid;gap:8px;max-height:260px;overflow:auto;padding-right:4px}.spike-log-item{display:grid;gap:4px;padding:10px 12px;border:1px solid rgba(255,204,0,.35);background:rgba(138,109,27,.16);border-radius:12px}.spike-log-item.latest{border:1px solid rgba(255,204,0,.58);background:rgba(138,109,27,.28);box-shadow:0 0 0 1px rgba(255,204,0,.14) inset;}.spike-log-item strong{font-size:13px;font-weight:500;color:#f3e3a3}.spike-log-item span{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums}.spike-log-empty{padding:10px 12px;border-radius:12px;background:var(--card2);color:var(--sub);font-size:13px}.dmesg-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.dmesg-meta{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums}.dmesg-log{margin:10px 0 0;max-height:420px;overflow:auto;border:1px solid var(--line);border-radius:12px;background:#070a10;color:#d9e2f2;padding:12px;font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;word-break:break-word}.dmesg-log[hidden]{display:none} .resource-card{margin-top:18px}.resource-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:16px}.resource-head h2{margin:0}.baseline-pill{color:var(--sub);font-size:12px;font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap}.spike-log-box{margin-top:16px}.spike-log-box h3{margin:0 0 9px;color:#c6d3e6;font-size:14px;font-weight:500}.spike-log-list{display:grid;gap:8px;max-height:260px;overflow:auto;padding-right:4px}.spike-log-item{display:grid;gap:4px;padding:10px 12px;border:1px solid rgba(255,204,0,.35);background:rgba(138,109,27,.16);border-radius:12px}.spike-log-item.latest{border:1px solid rgba(255,204,0,.58);background:rgba(138,109,27,.28);box-shadow:0 0 0 1px rgba(255,204,0,.14) inset;}.spike-log-item strong{font-size:13px;font-weight:500;color:#f3e3a3}.spike-log-item span{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums}.spike-log-empty{padding:10px 12px;border-radius:12px;background:var(--card2);color:var(--sub);font-size:13px}.dmesg-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.dmesg-meta{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums}.dmesg-log{margin:10px 0 0;max-height:420px;overflow:auto;border:1px solid var(--line);border-radius:12px;background:#070a10;color:#d9e2f2;padding:12px;font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;word-break:break-word}.dmesg-log[hidden]{display:none}
.resource-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.resource-box{min-width:0}.resource-box h3{margin:0 0 9px;color:#c6d3e6;font-size:14px;font-weight:500}.resource-table{width:100%;min-width:620px;table-layout:fixed;border-collapse:collapse;border:1px solid var(--line);border-radius:14px;overflow:hidden}.resource-table th,.resource-table td{padding:9px 10px;border-bottom:1px solid rgba(255,255,255,.06);background:#11151d;text-align:left;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.resource-table th{background:#1d2330;color:#c6d3e6;font-weight:500}.resource-table .pid{width:72px}.resource-table .metric{width:82px}.resource-table .service{width:150px}.resource-table .cmd{width:auto}.push-device-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px}.push-device-head h3{margin:0}.push-device-actions{display:flex;gap:8px;flex-wrap:wrap}.push-device-list{display:grid;gap:8px;margin-top:8px}.push-device-row{padding:10px 12px;border:1px solid rgba(84,101,128,.58);background:#11151d;border-radius:12px}.push-device-row.healthy{border-color:rgba(53,196,107,.45)}.push-device-row.watch{border-color:rgba(255,204,0,.48)}.push-device-row.stale,.push-device-row.failed{border-color:rgba(255,95,87,.55)}.push-device-main{min-width:0;color:var(--sub);font-size:12px;line-height:1.45;display:grid;gap:2px;word-break:break-all}.push-device-main strong{color:#edf1f7;font-size:13px}.push-device-badge{display:inline-flex;align-items:center;width:max-content;border-radius:999px;padding:2px 8px;background:#2b3342;color:#edf1f7;font-size:11px}.push-device-badge.healthy{background:#198754}.push-device-badge.watch{background:#8a6d1b}.push-device-badge.stale,.push-device-badge.failed{background:#c62828} .resource-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.resource-box{min-width:0}.resource-box h3{margin:0 0 9px;color:#c6d3e6;font-size:14px;font-weight:500}.resource-table{width:100%;min-width:620px;table-layout:fixed;border-collapse:collapse;border:1px solid var(--line);border-radius:14px;overflow:hidden}.resource-table th,.resource-table td{padding:9px 10px;border-bottom:1px solid rgba(255,255,255,.06);background:#11151d;text-align:left;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.resource-table th{background:#1d2330;color:#c6d3e6;font-weight:500}.resource-table .pid{width:72px}.resource-table .metric{width:82px}.resource-table .service{width:150px}.resource-table .cmd{width:auto}.push-device-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px}.push-device-head h3{margin:0}.push-device-actions{display:flex;gap:8px;flex-wrap:wrap}.push-device-list{display:grid;gap:8px;margin-top:8px}.push-device-row{padding:10px 12px;border:1px solid rgba(84,101,128,.58);background:#11151d;border-radius:12px}.push-device-row.healthy{border-color:rgba(53,196,107,.45)}.push-device-row.watch{border-color:rgba(255,204,0,.48)}.push-device-row.stale,.push-device-row.failed{border-color:rgba(255,95,87,.55)}.push-device-main{min-width:0;color:var(--sub);font-size:12px;line-height:1.45;display:grid;gap:2px;word-break:break-all}.push-device-main strong{color:#edf1f7;font-size:13px}.push-device-badge{display:inline-flex;align-items:center;width:max-content;border-radius:999px;padding:2px 8px;background:#2b3342;color:#edf1f7;font-size:11px}.push-device-badge.healthy{background:#198754}.push-device-badge.watch{background:#8a6d1b}.push-device-badge.stale,.push-device-badge.failed{background:#c62828}
.notice{position:fixed;right:20px;bottom:20px;min-width:220px;max-width:420px;padding:14px 16px;border-radius:14px;font-weight:500;background:#1d2330;border:1px solid var(--line);box-shadow:var(--shadow);z-index:99}.notice:empty{display:none}.notice[data-type=success]{border-color:rgba(53,196,107,.5)}.notice[data-type=error]{border-color:rgba(255,95,87,.5)} .notice{position:fixed;right:20px;bottom:20px;min-width:220px;max-width:420px;padding:14px 16px;border-radius:14px;font-weight:500;background:#1d2330;border:1px solid var(--line);box-shadow:var(--shadow);z-index:99}.notice:empty{display:none}.notice[data-type=success]{border-color:rgba(53,196,107,.5)}.notice[data-type=error]{border-color:rgba(255,95,87,.5)}
.dialog-layer{position:fixed;inset:0;display:none;align-items:center;justify-content:center;padding:22px;background:rgba(4,7,12,.68);backdrop-filter:blur(8px);z-index:120}.dialog-layer[data-open="1"]{display:flex}.dialog-box{width:min(430px,100%);border:1px solid rgba(84,101,128,.78);border-radius:20px;background:#171a21;box-shadow:0 24px 70px rgba(0,0,0,.42);padding:20px}.dialog-box h3{margin:0 0 8px;font-size:19px;font-weight:500}.dialog-message{margin:0 0 16px;color:var(--sub);line-height:1.5;white-space:pre-wrap}.dialog-input{width:100%;height:48px;margin-bottom:14px;border-radius:14px;border:1px solid var(--line);background:#11151d;color:var(--text);padding:0 14px;outline:none}.dialog-input:focus{outline:2px solid rgba(59,130,246,.65);outline-offset:2px}.dialog-actions{display:flex;justify-content:flex-end;gap:10px}.dialog-actions .btn{min-width:84px}.dialog-actions .btn.red{background:#c62828}
@media(max-width:1320px){.chart-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} @media(max-width:1320px){.chart-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:1100px){.layout{grid-template-columns:1fr}.chart-grid,.resource-grid{grid-template-columns:1fr}.topbar{align-items:flex-start;flex-direction:column}.topbar-right{width:100%}.topbar-right .btn{flex:1}.stat-grid{grid-template-columns:1fr}.resource-head{align-items:flex-start;flex-direction:column}.baseline-pill{text-align:left;white-space:normal}} @media(max-width:1100px){.layout{grid-template-columns:1fr}.chart-grid,.resource-grid{grid-template-columns:1fr}.topbar{align-items:flex-start;flex-direction:column}.topbar-right{width:100%}.topbar-right .btn{flex:1}.stat-grid{grid-template-columns:1fr}.resource-head{align-items:flex-start;flex-direction:column}.baseline-pill{text-align:left;white-space:normal}}
</style> </style>
@@ -136,6 +137,7 @@ a{color:inherit;text-decoration:none}
<div class="topbar-right"> <div class="topbar-right">
<button id="pushEnableBtn" class="btn secondary" type="button">Push</button> <button id="pushEnableBtn" class="btn secondary" type="button">Push</button>
<button id="wakeLockBtn" class="btn secondary" type="button">WakeLock</button> <button id="wakeLockBtn" class="btn secondary" type="button">WakeLock</button>
<button id="rebootBtn" class="btn warn" type="button">Reboot</button>
<a href="/?logout=1" class="btn red">Logout</a> <a href="/?logout=1" class="btn red">Logout</a>
</div> </div>
</div> </div>
@@ -283,9 +285,20 @@ a{color:inherit;text-decoration:none}
</section> </section>
</div> </div>
<div id="notice" class="notice"></div> <div id="notice" class="notice"></div>
<script src="/assets/app.js?v=20260607_pushhealth1"></script> <div id="customDialog" class="dialog-layer" aria-hidden="true">
<script src="/assets/wakelock.js?v=20260606_wakelock2"></script> <div class="dialog-box" role="dialog" aria-modal="true" aria-labelledby="customDialogTitle">
<script src="/push_subscribe.js?v=20260606_noappend1"></script> <h3 id="customDialogTitle">확인</h3>
<p id="customDialogMessage" class="dialog-message"></p>
<input id="customDialogInput" class="dialog-input" type="text" autocomplete="off" hidden>
<div class="dialog-actions">
<button id="customDialogCancel" class="btn secondary" type="button">취소</button>
<button id="customDialogOk" class="btn" type="button">확인</button>
</div>
</div>
</div>
<script src="/assets/app.js?v=20260607_reboot1"></script>
<script src="/assets/wakelock.js?v=20260607_dialog1"></script>
<script src="/push_subscribe.js?v=20260607_dialog1"></script>
<?php endif; ?> <?php endif; ?>
</body> </body>
</html> </html>
+20 -5
View File
@@ -32,8 +32,14 @@
return (String(value || '').match(/[가-힣ㄱ-ㅎㅏ-ㅣ]/g) || []).length; return (String(value || '').match(/[가-힣ㄱ-ㅎㅏ-ㅣ]/g) || []).length;
} }
function deviceNameFromUser() { async function deviceNameFromUser() {
const name = prompt('등록할까요? 등록하려면 디바이스 이름을 지정하세요 최소 2글자.', ''); const promptFn = window.customPrompt || (() => Promise.resolve(null));
const name = await promptFn('등록할 기기 이름을 입력하세요. 한글 2글자 이상이어야 합니다.', {
title: 'Push 등록',
okText: '등록',
placeholder: '휴대폰',
autocomplete: 'off',
});
if (name === null) { if (name === null) {
return null; return null;
@@ -251,7 +257,7 @@
return; return;
} }
const deviceName = deviceNameFromUser(); const deviceName = await deviceNameFromUser();
if (deviceName === null) { if (deviceName === null) {
await refreshButton(); await refreshButton();
return; return;
@@ -283,7 +289,12 @@
} }
async function unsubscribe() { async function unsubscribe() {
if (!confirm('푸시 기기 삭제할까요?')) { const confirmFn = window.customConfirm || (() => Promise.resolve(false));
if (!await confirmFn('현재 브라우저의 Push 구독을 삭제할까요?', {
title: 'Push 삭제',
okText: '삭제',
danger: true,
})) {
await refreshButton(); await refreshButton();
return; return;
} }
@@ -316,7 +327,11 @@
const active = button.dataset.active === '1'; const active = button.dataset.active === '1';
const job = active ? unsubscribe() : subscribe(); const job = active ? unsubscribe() : subscribe();
job.catch(error => { job.catch(error => {
alert(error.message || 'Push failed'); const alertFn = window.customAlert || (() => Promise.resolve());
alertFn(error.message || 'Push failed', {
title: 'Push 오류',
danger: true,
});
refreshButton().catch(() => {}); refreshButton().catch(() => {});
}); });
}); });