Use shared auth and server-side HA calls
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
# Custom Launcher
|
||||
|
||||
사이트 본문에 작은 iOS 스타일 배너로 삽입되는 런처입니다.
|
||||
|
||||
```html
|
||||
<meta charset="UTF-8">
|
||||
<link href="/custom/launcher/css/style.css" rel="stylesheet" />
|
||||
<div id="findMyShortcut" class="find-container"></div>
|
||||
<script src="/custom/launcher/js/main.js"></script>
|
||||
```
|
||||
|
||||
## 기능
|
||||
|
||||
- 나의 찾기 이동
|
||||
- PC 켜기
|
||||
- 콕 찌르기
|
||||
- 차량 모니터 이동
|
||||
- 공통 암호 로그인
|
||||
- 공통 암호 변경
|
||||
|
||||
## 구조
|
||||
|
||||
- `api.php`: 로그인, 로그아웃, 암호 변경, 콕 찌르기 카운트/웹훅 처리
|
||||
- `js/main.js`: 4개 아이콘 렌더링, 인증 흐름, 암호 변경 UI
|
||||
- `css/style.css`: iOS 테마 배너 스타일
|
||||
- `img/`: 런처 아이콘 이미지
|
||||
|
||||
## 공통 모듈 연동
|
||||
|
||||
인증, 암호 hash 저장, Home Assistant 호출은 `/custom/common` 모듈을 사용합니다. 런처는 더 이상 Home Assistant 토큰이나 웹훅을 브라우저로 직접 노출하지 않습니다.
|
||||
|
||||
## 운영 메모
|
||||
|
||||
메인 버튼은 기존 4개를 유지합니다. 암호 변경은 배너 우하단의 작은 `관리` 링크에서 처리합니다.
|
||||
@@ -1,88 +1,94 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../common/auth.php';
|
||||
require_once __DIR__ . '/../common/ha.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
const HA_BASE = 'https://ha.chaegeon.com';
|
||||
const HA_LONG_LIVED_TOKEN = 'REDACTED_HA_TOKEN';
|
||||
const POKE_ENTITY_ID = 'counter.kog_jjireugi';
|
||||
|
||||
function ha_get_entity_state($entity_id) {
|
||||
$url = rtrim(HA_BASE, '/') . '/api/states/' . urlencode($entity_id);
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Bearer ' . HA_LONG_LIVED_TOKEN,
|
||||
'Content-Type: application/json'
|
||||
],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
]);
|
||||
$res = curl_exec($ch);
|
||||
$errno = curl_errno($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($errno !== 0 || $status < 200 || $status >= 300) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$json = json_decode($res, true);
|
||||
// HA는 상태값을 문자열로 줍니다. 숫자면 (string)"12" 형태.
|
||||
if (!is_array($json) || !isset($json['state'])) return null;
|
||||
|
||||
// 숫자로 캐스팅 시도 (실패해도 문자열 반환 가능)
|
||||
$stateRaw = $json['state'];
|
||||
if (is_numeric($stateRaw)) {
|
||||
return (int)$stateRaw;
|
||||
}
|
||||
return $stateRaw; // 숫자가 아니면 원본 문자열
|
||||
}
|
||||
$config = custom_config();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$password = $_POST['password'] ?? '';
|
||||
$data = custom_read_request_data();
|
||||
$action = $data['action'] ?? 'login';
|
||||
|
||||
if ($password === '0010') {
|
||||
echo json_encode(['result' => 'ok']);
|
||||
} else {
|
||||
echo json_encode(['result' => 'fail']);
|
||||
if ($action === 'login') {
|
||||
$password = (string)($data['password'] ?? '');
|
||||
if (custom_verify_password($password)) {
|
||||
custom_issue_auth_cookie();
|
||||
custom_json(['result' => 'ok']);
|
||||
}
|
||||
exit;
|
||||
|
||||
custom_json(['result' => 'fail'], 401);
|
||||
}
|
||||
|
||||
if ($action === 'logout') {
|
||||
custom_clear_auth_cookie();
|
||||
custom_json(['result' => 'ok']);
|
||||
}
|
||||
|
||||
if ($action === 'changePassword') {
|
||||
$current = (string)($data['currentPassword'] ?? '');
|
||||
$next = (string)($data['newPassword'] ?? '');
|
||||
|
||||
if (!custom_verify_password($current)) {
|
||||
custom_json(['result' => 'fail', 'message' => 'current_password_invalid'], 401);
|
||||
}
|
||||
|
||||
if (mb_strlen($next) < 4) {
|
||||
custom_json(['result' => 'fail', 'message' => 'password_too_short'], 422);
|
||||
}
|
||||
|
||||
if (!custom_save_password_hash(password_hash($next, PASSWORD_DEFAULT))) {
|
||||
custom_json(['result' => 'fail', 'message' => 'password_save_failed'], 500);
|
||||
}
|
||||
|
||||
custom_issue_auth_cookie();
|
||||
custom_json(['result' => 'ok']);
|
||||
}
|
||||
|
||||
if ($action === 'poke') {
|
||||
$sender = trim((string)($data['sender'] ?? ''));
|
||||
$message = trim((string)($data['message'] ?? ''));
|
||||
$ok = custom_ha_webhook('poke', [
|
||||
'sender' => $sender !== '' ? $sender : '익명',
|
||||
'message' => $message,
|
||||
]);
|
||||
|
||||
custom_json(['result' => $ok ? 'ok' : 'fail'], $ok ? 200 : 502);
|
||||
}
|
||||
|
||||
custom_json(['error' => 'not_found'], 404);
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
if (isset($_GET['session'])) {
|
||||
custom_json(['authenticated' => custom_has_auth_cookie()]);
|
||||
}
|
||||
|
||||
if (isset($_GET['failMessages'])) {
|
||||
echo json_encode([
|
||||
custom_json([
|
||||
'failMessages' => [
|
||||
'비밀번호가 틀립니다.',
|
||||
]
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if (isset($_GET['pokeCount'])) {
|
||||
// HA 엔티티 상태 읽기
|
||||
$count = ha_get_entity_state(POKE_ENTITY_ID);
|
||||
$state = custom_ha_state('main', $config['entities']['poke_count']);
|
||||
$count = $state['state'] ?? null;
|
||||
|
||||
if ($count === null) {
|
||||
http_response_code(502);
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'error' => 'failed_to_fetch_from_ha'
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
custom_json(['ok' => false, 'error' => 'failed_to_fetch_from_ha'], 502);
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
custom_json([
|
||||
'ok' => true,
|
||||
'count' => $count
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
'count' => is_numeric($count) ? (int)$count : $count,
|
||||
]);
|
||||
}
|
||||
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'not_found']);
|
||||
exit;
|
||||
custom_json(['error' => 'not_found'], 404);
|
||||
}
|
||||
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'method_not_allowed']);
|
||||
?>
|
||||
custom_json(['error' => 'method_not_allowed'], 405);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
.find-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -36,6 +37,7 @@
|
||||
appearance: none;
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
#findMyShortcut input[type="password"]:focus {
|
||||
@@ -104,3 +106,70 @@
|
||||
margin-top: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.launcher-manage {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 6px;
|
||||
padding: 4px 7px !important;
|
||||
margin: 0 !important;
|
||||
border-radius: 999px !important;
|
||||
background: rgba(255, 255, 255, 0.68) !important;
|
||||
color: #8e8e93 !important;
|
||||
font-size: 11px !important;
|
||||
line-height: 1 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.launcher-form-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.launcher-form-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-width: 280px;
|
||||
margin-bottom: 6px;
|
||||
padding: 8px 0;
|
||||
font-size: 16px;
|
||||
color: #000;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.launcher-back {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 1px;
|
||||
font-size: 16px;
|
||||
color: #007aff;
|
||||
text-decoration: none;
|
||||
font-weight: normal;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.launcher-back .backArrow {
|
||||
position: relative;
|
||||
top: 1px;
|
||||
margin-right: 1px;
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.launcher-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#findMyShortcut .launcher-text-button {
|
||||
margin: 2px 0 0 0;
|
||||
padding: 4px 8px;
|
||||
background: transparent;
|
||||
color: #007aff;
|
||||
font-size: 13px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
+277
-204
@@ -1,149 +1,32 @@
|
||||
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');
|
||||
}
|
||||
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'));
|
||||
}
|
||||
onclick: () => requireAccess(wakeComputer)
|
||||
},
|
||||
{
|
||||
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();
|
||||
}
|
||||
onclick: showPokeForm
|
||||
},
|
||||
{
|
||||
id: 'car',
|
||||
img: '/custom/launcher/img/car.png',
|
||||
label: '니모',
|
||||
onclick: () => {
|
||||
requireAccess(() => location.href = 'https://seo.chaegeon.com/car/monitor.php');
|
||||
}
|
||||
onclick: () => requireAccess(() => location.href = 'https://seo.chaegeon.com/car/monitor.php')
|
||||
}
|
||||
];
|
||||
|
||||
@@ -169,138 +52,326 @@ function renderIcons() {
|
||||
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);
|
||||
}
|
||||
|
||||
function requireAccess(nextAction) {
|
||||
const access = getCookie('access');
|
||||
if (access === '9KxH6236') {
|
||||
async function requireAccess(nextAction) {
|
||||
try {
|
||||
const session = await fetch(`${apiUrl}?session=1`, { credentials: 'same-origin' }).then(res => res.json());
|
||||
if (session.authenticated) {
|
||||
nextAction && nextAction();
|
||||
} else {
|
||||
showPasswordForm(nextAction);
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
}
|
||||
|
||||
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 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.style.display = 'flex';
|
||||
inputRow.style.alignItems = 'center';
|
||||
inputRow.className = 'launcher-input-row';
|
||||
inputRow.appendChild(input);
|
||||
inputRow.appendChild(button);
|
||||
|
||||
const column = document.createElement('div');
|
||||
column.style.display = 'flex';
|
||||
column.style.flexDirection = 'column';
|
||||
column.style.alignItems = 'center';
|
||||
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('/custom/launcher/api.php?failMessages=1');
|
||||
const messageRes = await fetch(`${apiUrl}?failMessages=1`);
|
||||
const messagesJson = await messageRes.json();
|
||||
const failMessages = messagesJson.failMessages || [];
|
||||
|
||||
const res = await fetch('/custom/launcher/api.php', {
|
||||
const res = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ password })
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ action: 'login', 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 (res.ok && data.result === 'ok') {
|
||||
if (typeof onSuccess === 'function') {
|
||||
onSuccess();
|
||||
} else {
|
||||
location.reload();
|
||||
renderIcons();
|
||||
}
|
||||
} else {
|
||||
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) => {
|
||||
input.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') submit();
|
||||
});
|
||||
input.focus();
|
||||
}
|
||||
|
||||
function getCookie(name) {
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) return parts.pop().split(';').shift();
|
||||
return null;
|
||||
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;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
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');
|
||||
@@ -314,4 +385,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
if (location.pathname.split('/').filter(Boolean).length === 1) document.title = "블로그 - 채건닷컴";
|
||||
if (location.pathname.split('/').filter(Boolean).length === 1) {
|
||||
document.title = '블로그 - 채건닷컴';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user