Use shared auth and server-side HA calls

This commit is contained in:
seo
2026-06-12 15:10:14 +09:00
parent c0fc076d34
commit 600efc3951
4 changed files with 624 additions and 442 deletions
+390 -317
View File
@@ -1,317 +1,390 @@
const shortcut = document.getElementById('findMyShortcut');
let failIndex = 0;
const iconItems = [
{
id: 'findmydevice',
img: '/custom/launcher/img/find-my.svg',
label: '나의 찾기',
onclick: () => {
requireAccess(() => location.href = '/findmydevice');
}
},
{
id: 'wakeonlan',
img: '/custom/launcher/img/wakeonlan-svgrepo-com.svg',
label: 'PC 켜기',
onclick: () => {
fetch('/custom/wakeonlan/api.php?status=1')
.then(res => res.ok ? res.json() : Promise.reject())
.then(data => {
if (!data || data.result !== 'ok') {
showAlert('error');
return;
}
if (data.state === 'on') {
showAlert('wakeonlan_already');
return;
}
fetch('/custom/wakeonlan/api.php', { method: 'POST' })
.then(res => res.ok ? res.json() : Promise.reject())
.then(() => {
showAlert('wakeonlan_request_sent');
let attempts = 0;
const interval = setInterval(() => {
attempts++;
fetch('/custom/wakeonlan/api.php?status=1')
.then(res => res.ok ? res.json() : Promise.reject())
.then(status => {
if (status.state === 'on') {
clearInterval(interval);
showAlert('wakeonlan_success');
}
else if (attempts >= 12) {
clearInterval(interval);
}
})
.catch(() => clearInterval(interval));
}, 5000);
})
.catch(() => showAlert('error'));
})
.catch(() => showAlert('error'));
}
},
{
id: 'poke',
img: '/custom/launcher/img/R1280x0.jpg',
label: '콕 찌르기',
onclick: () => {
showAlert('poke_completed');
const alertBox = document.querySelector('#poke_completed');
const content = alertBox.querySelector('.alert-content');
content.style.minHeight = 'auto';
content.style.padding = '15px 20px 10px 20px';
content.style.justifyContent = 'flex-start';
const renderMain = (countText = "조회 중...") => {
content.innerHTML = `
<div id="poke_container" style="width:100%; display:flex; flex-direction:column; gap:6px;">
<p id="poke_title" style="text-align:center; font-weight:bold; margin-bottom:4px; color:#007AFF; font-size:1.05em;">${countText}</p>
<input type="text" id="poke_sender" placeholder="보내는 이 (미입력 시 익명)"
style="padding:8px 12px; border:1px solid #eee; border-radius:10px; outline:none; font-size:14px; background:#f9f9f9;">
<input type="text" id="poke_message" placeholder="할 말 (선택 사항)"
style="padding:8px 12px; border:1px solid #eee; border-radius:10px; outline:none; font-size:14px; background:#f9f9f9;">
<div class="button-custom-container" style="margin-top:6px; gap:6px;">
<button class="button-custom" id="btn_poke_cancel" style="background-color:#eee; color:#666; flex:1; min-width:auto;">닫기</button>
<button class="button-custom" id="btn_poke_confirm" style="flex:2; min-width:auto;">콕 찌르기</button>
</div>
<p id="poke_status" style="text-align:center; font-size:10px; color:#bbb; margin-top:2px; height:1px;"></p>
</div>
`;
document.getElementById('btn_poke_cancel').onclick = () => { closeAlert('poke_completed'); };
document.getElementById('btn_poke_confirm').onclick = handlePoke;
};
fetch('/custom/launcher/api.php?pokeCount=0')
.then(res => res.json())
.then(data => {
const n = Number(data.count) || 0;
document.getElementById('poke_title').innerHTML = `현재까지 <span style="color:#ff3b30;">${n}회</span> 찔렸습니다!`;
})
.catch(() => {
document.getElementById('poke_title').innerText = "현재 횟수를 불러올 수 없음";
});
const handlePoke = () => {
const sender = document.getElementById('poke_sender').value.trim() || "익명";
const message = document.getElementById('poke_message').value.trim() || "";
const titleField = document.getElementById('poke_title');
const statusField = document.getElementById('poke_status');
const confirmBtn = document.getElementById('btn_poke_confirm');
confirmBtn.disabled = true;
statusField.innerText = "전송 중...";
const params = new URLSearchParams({ sender, message });
fetch(`https://ha.chaegeon.com/api/webhook/jczSPJyIX4iG4JTvy9HztFq91MsrIfsUErFh4DLstDcsm7RKL?${params.toString()}`, {
method: 'POST',
mode: 'no-cors'
});
fetch(`/custom/launcher/api.php?pokeCount=1&sender=${encodeURIComponent(sender)}`)
.then(res => res.json())
.then(data => {
const n = Number(data.count);
titleField.innerHTML = `현재까지 <span style="color:#ff3b30;">${n}회</span> 찔렸습니다!`;
statusField.innerText = "성공!";
setTimeout(() => {
confirmBtn.disabled = false;
statusField.innerText = "";
}, 300);
})
.catch(() => {
statusField.innerText = "오류 발생";
confirmBtn.disabled = false;
});
};
renderMain();
}
},
{
id: 'car',
img: '/custom/launcher/img/car.png',
label: '니모',
onclick: () => {
requireAccess(() => location.href = 'https://seo.chaegeon.com/car/monitor.php');
}
}
];
function renderIcons() {
shortcut.className = 'find-container';
shortcut.innerHTML = '';
iconItems.forEach(item => {
const div = document.createElement('div');
div.className = 'iconContainer';
div.id = item.id;
if (item.onclick) div.addEventListener('click', item.onclick);
const img = document.createElement('img');
img.src = item.img;
img.alt = item.label;
const label = document.createElement('div');
label.className = 'iconLabel';
label.textContent = item.label;
div.appendChild(img);
div.appendChild(label);
shortcut.appendChild(div);
});
}
function requireAccess(nextAction) {
const access = getCookie('access');
if (access === '9KxH6236') {
nextAction && nextAction();
} else {
showPasswordForm(nextAction);
}
}
function showPasswordForm(onSuccess) {
shortcut.className = 'password-container';
shortcut.innerHTML = '';
const header = document.createElement('div');
header.id = 'settingsHeader';
header.style.display = 'flex';
header.style.alignItems = 'center';
header.style.justifyContent = 'center';
header.style.position = 'relative';
header.style.marginBottom = '15px';
header.style.padding = '12px';
header.style.fontSize = '16px';
header.style.color = '#000';
header.style.fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
const backLink = document.createElement('a');
backLink.id = 'blogBackLink';
backLink.innerHTML = '<span class="backArrow"></span> 목록';
backLink.style.position = 'absolute';
backLink.style.left = '0px';
backLink.style.fontSize = '16px';
backLink.style.top = '3px';
backLink.style.color = '#007AFF';
backLink.style.textDecoration = 'none';
backLink.style.fontWeight = 'normal';
backLink.style.cursor = 'pointer';
backLink.addEventListener('click', () => renderIcons());
backLink.querySelector('.backArrow').style.cssText = `
font-size: 26px;
position: relative;
top: 1px;
margin-right: 1px;
`;
const title = document.createElement('span');
title.className = 'headerTitle';
title.textContent = '비밀번호';
title.style.fontWeight = 'bold';
header.appendChild(backLink);
header.appendChild(title);
shortcut.appendChild(header);
const input = document.createElement('input');
input.type = 'password';
input.placeholder = '비밀번호';
input.id = 'blog_secret';
input.inputMode = 'numeric';
input.pattern = '[0-9]*';
const button = document.createElement('button');
button.id = 'blog_secret_check';
button.textContent = '확인';
const msg = document.createElement('div');
msg.id = 'failMsg';
const inputRow = document.createElement('div');
inputRow.style.display = 'flex';
inputRow.style.alignItems = 'center';
inputRow.appendChild(input);
inputRow.appendChild(button);
const column = document.createElement('div');
column.style.display = 'flex';
column.style.flexDirection = 'column';
column.style.alignItems = 'center';
column.appendChild(inputRow);
shortcut.appendChild(column);
const submit = async () => {
const password = input.value;
try {
const messageRes = await fetch('/custom/launcher/api.php?failMessages=1');
const messagesJson = await messageRes.json();
const failMessages = messagesJson.failMessages || [];
const res = await fetch('/custom/launcher/api.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ password })
});
const data = await res.json();
if (data.result === 'ok') {
document.cookie = 'access=9KxH6236; path=/; max-age=21600; SameSite=Strict' + (location.protocol === 'https:' ? '; Secure' : '');
if (typeof onSuccess === 'function') {
onSuccess();
} else {
location.reload();
}
} else {
column.appendChild(msg);
msg.textContent = failMessages[failIndex] || '비밀번호가 틀렸습니다.';
failIndex = (failIndex + 1) % Math.max(1, failMessages.length);
input.value = '';
input.focus();
}
} catch (_) {
}
};
button.addEventListener('click', submit);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') submit();
});
}
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
document.addEventListener('DOMContentLoaded', async () => {
renderIcons();
const findShortcut = document.getElementById('findMyShortcut');
if (findShortcut) {
findShortcut.style.userSelect = 'none';
findShortcut.style.webkitUserSelect = 'none';
findShortcut.style.msUserSelect = 'none';
findShortcut.style.MozUserSelect = 'none';
findShortcut.style.webkitTouchCallout = 'none';
findShortcut.addEventListener('dragstart', e => e.preventDefault());
}
});
if (location.pathname.split('/').filter(Boolean).length === 1) document.title = "블로그 - 채건닷컴";
const shortcut = document.getElementById('findMyShortcut');
let failIndex = 0;
const apiUrl = '/custom/launcher/api.php';
const iconItems = [
{
id: 'findmydevice',
img: '/custom/launcher/img/find-my.svg',
label: '나의 찾기',
onclick: () => requireAccess(() => location.href = '/findmydevice')
},
{
id: 'wakeonlan',
img: '/custom/launcher/img/wakeonlan-svgrepo-com.svg',
label: 'PC 켜기',
onclick: () => requireAccess(wakeComputer)
},
{
id: 'poke',
img: '/custom/launcher/img/R1280x0.jpg',
label: '콕 찌르기',
onclick: showPokeForm
},
{
id: 'car',
img: '/custom/launcher/img/car.png',
label: '니모',
onclick: () => requireAccess(() => location.href = 'https://seo.chaegeon.com/car/monitor.php')
}
];
function renderIcons() {
shortcut.className = 'find-container';
shortcut.innerHTML = '';
iconItems.forEach(item => {
const div = document.createElement('div');
div.className = 'iconContainer';
div.id = item.id;
if (item.onclick) div.addEventListener('click', item.onclick);
const img = document.createElement('img');
img.src = item.img;
img.alt = item.label;
const label = document.createElement('div');
label.className = 'iconLabel';
label.textContent = item.label;
div.appendChild(img);
div.appendChild(label);
shortcut.appendChild(div);
});
const manage = document.createElement('button');
manage.type = 'button';
manage.className = 'launcher-manage';
manage.textContent = '관리';
manage.addEventListener('click', () => showPasswordChangeForm());
shortcut.appendChild(manage);
}
async function requireAccess(nextAction) {
try {
const session = await fetch(`${apiUrl}?session=1`, { credentials: 'same-origin' }).then(res => res.json());
if (session.authenticated) {
nextAction && nextAction();
return;
}
} catch (_) {
}
showPasswordForm(nextAction);
}
function showPasswordForm(onSuccess) {
shortcut.className = 'password-container';
shortcut.innerHTML = '';
const title = createFormHeader('비밀번호', renderIcons);
const input = createPasswordInput('blog_secret', '비밀번호');
const button = createButton('확인');
const changeButton = createTextButton('암호 변경', () => showPasswordChangeForm());
const msg = document.createElement('div');
msg.id = 'failMsg';
const inputRow = document.createElement('div');
inputRow.className = 'launcher-input-row';
inputRow.appendChild(input);
inputRow.appendChild(button);
const column = document.createElement('div');
column.className = 'launcher-form-column';
column.appendChild(title);
column.appendChild(inputRow);
column.appendChild(changeButton);
shortcut.appendChild(column);
const submit = async () => {
const password = input.value;
try {
const messageRes = await fetch(`${apiUrl}?failMessages=1`);
const messagesJson = await messageRes.json();
const failMessages = messagesJson.failMessages || [];
const res = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ action: 'login', password })
});
const data = await res.json();
if (res.ok && data.result === 'ok') {
if (typeof onSuccess === 'function') {
onSuccess();
} else {
renderIcons();
}
return;
}
column.appendChild(msg);
msg.textContent = failMessages[failIndex] || '비밀번호가 틀렸습니다.';
failIndex = (failIndex + 1) % Math.max(1, failMessages.length);
input.value = '';
input.focus();
} catch (_) {
column.appendChild(msg);
msg.textContent = '요청을 처리하지 못했습니다.';
}
};
button.addEventListener('click', submit);
input.addEventListener('keydown', e => {
if (e.key === 'Enter') submit();
});
input.focus();
}
function showPasswordChangeForm() {
shortcut.className = 'password-container';
shortcut.innerHTML = '';
const title = createFormHeader('암호 변경', renderIcons);
const currentInput = createPasswordInput('current_password', '현재 암호');
const newInput = createPasswordInput('new_password', '새 암호');
const confirmInput = createPasswordInput('confirm_password', '새 암호 확인');
const button = createButton('변경');
const msg = document.createElement('div');
msg.id = 'failMsg';
const column = document.createElement('div');
column.className = 'launcher-form-column';
column.appendChild(title);
column.appendChild(currentInput);
column.appendChild(newInput);
column.appendChild(confirmInput);
column.appendChild(button);
column.appendChild(msg);
shortcut.appendChild(column);
const submit = async () => {
const currentPassword = currentInput.value;
const newPassword = newInput.value;
const confirmPassword = confirmInput.value;
if (newPassword !== confirmPassword) {
msg.textContent = '새 암호가 일치하지 않습니다.';
return;
}
try {
const res = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ action: 'changePassword', currentPassword, newPassword })
});
const data = await res.json();
if (res.ok && data.result === 'ok') {
msg.style.color = '#34c759';
msg.textContent = '변경되었습니다.';
setTimeout(renderIcons, 700);
return;
}
msg.style.color = 'red';
msg.textContent = data.message === 'password_too_short' ? '새 암호는 4자 이상이어야 합니다.' : '현재 암호가 틀립니다.';
} catch (_) {
msg.style.color = 'red';
msg.textContent = '요청을 처리하지 못했습니다.';
}
};
button.addEventListener('click', submit);
[currentInput, newInput, confirmInput].forEach(input => {
input.addEventListener('keydown', e => {
if (e.key === 'Enter') submit();
});
});
currentInput.focus();
}
async function wakeComputer() {
try {
const data = await fetch('/custom/wakeonlan/api.php?status=1', { credentials: 'same-origin' })
.then(res => res.ok ? res.json() : Promise.reject());
if (!data || data.result !== 'ok') {
launcherAlert('error');
return;
}
if (data.state === 'on') {
launcherAlert('wakeonlan_already');
return;
}
await fetch('/custom/wakeonlan/api.php', { method: 'POST', credentials: 'same-origin' })
.then(res => res.ok ? res.json() : Promise.reject());
launcherAlert('wakeonlan_request_sent');
let attempts = 0;
const interval = setInterval(() => {
attempts++;
fetch('/custom/wakeonlan/api.php?status=1', { credentials: 'same-origin' })
.then(res => res.ok ? res.json() : Promise.reject())
.then(status => {
if (status.state === 'on') {
clearInterval(interval);
launcherAlert('wakeonlan_success');
} else if (attempts >= 12) {
clearInterval(interval);
}
})
.catch(() => clearInterval(interval));
}, 5000);
} catch (_) {
launcherAlert('error');
}
}
function showPokeForm() {
launcherAlert('poke_completed');
const alertBox = document.querySelector('#poke_completed');
const content = alertBox?.querySelector('.alert-content');
if (!content) return;
content.style.minHeight = 'auto';
content.style.padding = '15px 20px 10px 20px';
content.style.justifyContent = 'flex-start';
const renderMain = (countText = '조회 중...') => {
content.innerHTML = `
<div id="poke_container" style="width:100%; display:flex; flex-direction:column; gap:6px;">
<p id="poke_title" style="text-align:center; font-weight:bold; margin-bottom:4px; color:#007AFF; font-size:1.05em;">${countText}</p>
<input type="text" id="poke_sender" placeholder="보내는 이 (미입력 시 익명)" style="padding:8px 12px; border:1px solid #eee; border-radius:10px; outline:none; font-size:14px; background:#f9f9f9;">
<input type="text" id="poke_message" placeholder="할 말 (선택 사항)" style="padding:8px 12px; border:1px solid #eee; border-radius:10px; outline:none; font-size:14px; background:#f9f9f9;">
<div class="button-custom-container" style="margin-top:6px; gap:6px;">
<button class="button-custom" id="btn_poke_cancel" style="background-color:#eee; color:#666; flex:1; min-width:auto;">닫기</button>
<button class="button-custom" id="btn_poke_confirm" style="flex:2; min-width:auto;">콕 찌르기</button>
</div>
<p id="poke_status" style="text-align:center; font-size:10px; color:#bbb; margin-top:2px; height:1px;"></p>
</div>
`;
document.getElementById('btn_poke_cancel').onclick = () => closeLauncherAlert('poke_completed');
document.getElementById('btn_poke_confirm').onclick = handlePoke;
};
const refreshCount = () => fetch(`${apiUrl}?pokeCount=1`)
.then(res => res.json())
.then(data => {
const n = Number(data.count) || 0;
document.getElementById('poke_title').innerHTML = `현재까지 <span style="color:#ff3b30;">${n}회</span> 찔렸습니다!`;
})
.catch(() => {
document.getElementById('poke_title').innerText = '현재 횟수를 불러올 수 없음';
});
const handlePoke = () => {
const sender = document.getElementById('poke_sender').value.trim() || '익명';
const message = document.getElementById('poke_message').value.trim() || '';
const statusField = document.getElementById('poke_status');
const confirmBtn = document.getElementById('btn_poke_confirm');
confirmBtn.disabled = true;
statusField.innerText = '전송 중...';
fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ action: 'poke', sender, message })
})
.then(res => res.ok ? res.json() : Promise.reject())
.then(() => refreshCount())
.then(() => {
statusField.innerText = '성공!';
setTimeout(() => {
confirmBtn.disabled = false;
statusField.innerText = '';
}, 300);
})
.catch(() => {
statusField.innerText = '오류 발생';
confirmBtn.disabled = false;
});
};
renderMain();
refreshCount();
}
function createFormHeader(text, onBack) {
const header = document.createElement('div');
header.className = 'launcher-form-header';
const backLink = document.createElement('a');
backLink.innerHTML = '<span class="backArrow"></span> 목록';
backLink.className = 'launcher-back';
backLink.addEventListener('click', onBack);
const title = document.createElement('span');
title.className = 'headerTitle';
title.textContent = text;
header.appendChild(backLink);
header.appendChild(title);
return header;
}
function createPasswordInput(id, placeholder) {
const input = document.createElement('input');
input.type = 'password';
input.placeholder = placeholder;
input.id = id;
input.autocomplete = 'current-password';
return input;
}
function createButton(text) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = text;
return button;
}
function createTextButton(text, onClick) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'launcher-text-button';
button.textContent = text;
button.addEventListener('click', onClick);
return button;
}
function launcherAlert(name) {
if (typeof showAlert === 'function') {
showAlert(name);
}
}
function closeLauncherAlert(name) {
if (typeof closeAlert === 'function') {
closeAlert(name);
}
}
document.addEventListener('DOMContentLoaded', () => {
renderIcons();
const findShortcut = document.getElementById('findMyShortcut');
if (findShortcut) {
findShortcut.style.userSelect = 'none';
findShortcut.style.webkitUserSelect = 'none';
findShortcut.style.msUserSelect = 'none';
findShortcut.style.MozUserSelect = 'none';
findShortcut.style.webkitTouchCallout = 'none';
findShortcut.addEventListener('dragstart', e => e.preventDefault());
}
});
if (location.pathname.split('/').filter(Boolean).length === 1) {
document.title = '블로그 - 채건닷컴';
}