Compare commits

..
7 Commits
Author SHA1 Message Date
seo b92eeb0a71 Issue access cookie for admin sessions 2026-06-15 10:29:43 +09:00
seo af67ef9980 Bypass app password for site admins 2026-06-12 15:45:02 +09:00
seo a1bb947ab5 Show management only to site admins 2026-06-12 15:38:09 +09:00
seo b5d05b4ebb Restore legacy CSS entrypoint 2026-06-12 15:33:14 +09:00
seo 81d484cad9 Keep password change on main management 2026-06-12 15:30:53 +09:00
seo db9abe9e3a Use shared auth and server-side HA calls 2026-06-12 15:10:14 +09:00
seo a21493ff41 Initial private import 2026-06-08 02:33:14 +09:00
4 changed files with 74 additions and 648 deletions

No files matched your search

+5 -21
View File
@@ -4,9 +4,9 @@
```html
<meta charset="UTF-8">
<link href="/custom/launcher/css/style.css?v=20260722_wol1" rel="stylesheet" />
<link href="/custom/common/css/launcher.css" rel="stylesheet" />
<div id="findMyShortcut" class="find-container"></div>
<script src="/custom/launcher/js/main.js?v=20260722_wol1"></script>
<script src="/custom/launcher/js/main.js"></script>
```
## 기능
@@ -14,9 +14,9 @@
- 나의 찾기 이동
- PC 켜기
- 콕 찌르기
- 차량 모니터 이동
- 공통 암호 로그인
- 공통 암호 변경
- 4자리 숫자 PIN 입력과 커스텀 숫자 키패드
- 사이트 관리자 로그인 상태에서만 관리 버튼 표시
- 사이트 관리자 로그인 상태에서는 미니앱 실행 시 공통 암호 생략
- 사이트 관리자 세션으로 미니앱 진입 시 공통 접근 쿠키 자동 발급
@@ -30,28 +30,12 @@
## 공통 모듈 연동
인증, 암호 hash 저장, Home Assistant/Control 호출 설정은 `/custom/common` 모듈을 사용합니다. 런처는 더 이상 Home Assistant 토큰, Control 토큰, 웹훅을 브라우저로 직접 노출하지 않습니다.
인증, 암호 hash 저장, Home Assistant 호출은 `/custom/common` 모듈을 사용합니다. 런처는 더 이상 Home Assistant 토큰이나 웹훅을 브라우저로 직접 노출하지 않습니다.
런처 CSS 원본은 `/custom/common/css/launcher.css`를 사용합니다. 기존 운영 HTML이 `/custom/launcher/css/style.css`를 참조해도 호환 파일이 공용 CSS를 import합니다.
런처는 채건닷컴 본문 안에 들어가는 배너라서 파일 변경 감시로 전체 Rhymix 페이지를 다시 열지 않습니다. 배포 직후 브라우저 캐시를 새로 받아야 할 때는 삽입 HTML의 `?v=` 값만 바꿉니다. 나의 찾기나 속도 측정처럼 화면 자체가 도구인 서비스만 공통 파일 변경 감시를 씁니다.
## 운영 메모
일반 사용자 화면은 나의 찾기, 콕 찌르기, 어디야?, 프로젝트 4개 버튼을 표시합니다. 어디야?와 프로젝트 바로가기는 공개 서비스로 이동하므로 공통 암호 인증을 거치지 않습니다.
Rhymix 사이트 관리자 로그인 상태에서는 첫 줄에 나의 찾기, 콕 찌르기, 어디야?, 프로젝트를 일반 사용자 화면과 같은 순서로 표시하고, 두 번째 줄에 PC 켜기, Block, Notice, Speed Stats를 표시합니다. PC 켜기와 Speed Stats는 관리자 전용이며 일반 공통 암호 쿠키만으로는 사용할 수 없습니다. PC 켜기는 `/custom/wakeonlan/api.php`를 통해 Control의 `wake_lan` API로 WOL 요청을 보냅니다. Weblog는 관리자 대시보드에서도 접근할 수 있으므로 런처 관리자 바로가기에서는 제외합니다. 어디야?는 `https://eodiya.net`, 프로젝트 바로가기는 공개 저장소 목록인 `https://git.chaegeon.com/explore/repos`로 이동합니다. 관리자 상태에서 미니앱을 실행하면 공통 암호를 묻지 않습니다. 이때 `/custom/launcher/api.php?adminSession=1` 또는 `session=1` 응답에서 관리자 공통 접근 쿠키를 함께 발급해, 이동한 미니앱 내부 세션 검사에서도 바로 통과합니다.
공통 암호로 로그인한 일반 인증 쿠키는 2시간 유지합니다. 사이트 관리자 세션이 확인되어 발급된 관리자 인증 쿠키는 반영구 운영값으로 10년 유지합니다. `/custom/launcher/api.php?session=1`은 현재 브라우저가 가진 공통 쿠키 또는 사이트 관리자 세션을 확인하고, `/custom/launcher/api.php?adminSession=1`은 관리 버튼 노출처럼 실제 Rhymix 관리자 세션이 필요한 화면에서만 사용합니다. `adminSession=1`은 일반 공통 쿠키를 인증으로 섞지 않고 실제 관리자 여부만 내려줍니다. 런처가 관리자 아이콘을 표시한 뒤에도 브라우저 포커스가 돌아오거나 탭이 다시 보이면 관리자 세션을 재확인하고, 이미 관리자 로그인이 풀렸으면 일반 사용자 화면으로 다시 그립니다.
`eodiya.net`은 Rhymix 관리자 세션을 확인할 때 런처 API를 사용합니다. 서로 다른 사이트의 CORS 요청에는 Rhymix host-only/SameSite 쿠키가 실리지 않으므로, 일반 웹 첫 진입과 `라이믹스 관리자 확인` 버튼은 `adminBridge=traffic-signal-pass`를 최상위 화면으로 한 번 거칩니다. 브리지는 실제 사이트 관리자 세션을 확인한 뒤 `https://eodiya.net`으로만 복귀시키며, 관리자인 경우 회원 번호와 대상 서비스를 묶은 400일짜리 서명 티켓을 URL fragment에 담습니다. 다른 scheme, host, port, 사용자 정보, fragment가 들어간 복귀 주소는 거부합니다.
기존 `adminSession=1&adminTicket=traffic-signal-pass` CORS 응답과 앱 사용자별 관리자 티켓도 호환용으로 유지합니다. 어디야? 서버는 어떤 경로로 받은 티켓이든 `validateAdminTicket=1`로 다시 검증한 뒤 위치 기록 관리자 화면을 열며, 클라이언트의 관리자 표시값만으로 위치 기록을 열지 않습니다. 검증할 때 티켓에 서명된 회원 번호의 현재 사이트 관리자 권한을 회원 DB에서 다시 확인하고 유효하면 티켓 만료를 갱신합니다. 따라서 어디야?가 10초 주기로 검증하거나 ADMIN API를 호출했을 때 관리자 권한이 해제되어 있으면 즉시 거부됩니다. 브라우저가 허용하는 장기 쿠키 상한을 고려해 400일 슬라이딩 방식으로 유지하며, 사용 중에는 검증 때마다 다시 연장됩니다.
Rhymix 로그인 실패 차단 세션이 살아 있으면 `session=1` 응답이 `loginBlocked`를 내려줍니다. 이때 런처는 아이콘, 관리 버튼, PIN 입력을 숨기고 빨간 제한 안내만 표시합니다.
암호는 숫자 4자리만 허용합니다. 로그인과 암호 변경 입력창은 `inputmode=numeric`, `[0-9]*` 패턴, 커스텀 숫자 키패드를 사용하며 Android/iPhone에서 문자 키보드가 뜨지 않도록 `readonly` 입력창에 화면 키패드로 값을 넣습니다. 서버 API도 4자리 숫자가 아닌 암호를 거부합니다.
로그인은 확인 버튼 없이 4자리가 입력되면 즉시 검증하고 성공 시 원래 선택한 미니앱으로 이동합니다. 암호 변경 화면도 변경/확인 버튼 없이 현재 암호, 새 암호, 새 암호 확인 순서로 4자리 입력이 끝날 때마다 다음 칸으로 이동하며, 마지막 칸까지 입력되면 바로 변경을 검증합니다. 이미 값이 들어간 입력창을 누르면 로그인 화면은 해당 칸을 지우고, 암호 변경 화면은 클릭한 칸부터 뒤 칸까지 지운 뒤 다시 입력받습니다. 현재 입력 중인 칸은 상태 문구와 파란 테두리로 표시합니다.
메인 버튼은 기존 4개를 유지합니다. Rhymix 사이트 관리자 로그인 상태에서는 미니앱 실행 시 공통 암호를 묻지 않습니다. 이때 `/custom/launcher/api.php?adminSession=1` 또는 `session=1` 응답에서 공통 접근 쿠키를 함께 발급해, 이동한 미니앱 내부 세션 검사에서도 바로 통과합니다.
암호 변경은 배너 우하단의 작은 `관리` 링크에서 처리하며, 이 링크는 사이트 관리자 로그인 상태에서만 표시합니다. 암호 변경 시에는 현재 공통 암호 입력을 계속 요구합니다.
+16 -235
View File
@@ -6,150 +6,24 @@ require_once __DIR__ . '/../common/ha.php';
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
$origin = (string)($_SERVER['HTTP_ORIGIN'] ?? '');
$allowedOrigins = [
'https://chaegeon.com',
'https://eodiya.net',
];
if (in_array($origin, $allowedOrigins, true)) {
header('Access-Control-Allow-Origin: ' . $origin);
header('Access-Control-Allow-Credentials: true');
header('Access-Control-Allow-Headers: Content-Type, X-Requested-With');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Vary: Origin');
}
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
$config = custom_config();
const LAUNCHER_ADMIN_TICKET_TTL = 400 * 24 * 60 * 60;
function launcher_is_digit_password(string $password): bool
{
return preg_match('/^\d{4}$/', $password) === 1;
}
function launcher_admin_ticket_audience(string $audience): string
{
$audience = trim($audience);
if ($audience === '' || preg_match('/^[a-zA-Z0-9_.:-]{1,80}$/', $audience) !== 1) {
return '';
}
return $audience;
}
function launcher_current_site_admin_member_srl(): int
{
if (!custom_is_site_admin() || !class_exists('Context')) {
return 0;
}
$loggedInfo = Context::get('logged_info');
return max(0, (int)($loggedInfo->member_srl ?? 0));
}
function launcher_member_is_site_admin(int $memberSrl): bool
{
if ($memberSrl <= 0) {
return false;
}
$autoload = dirname(__DIR__, 2) . '/common/autoload.php';
if (!is_file($autoload)) {
return false;
}
try {
require_once $autoload;
if (!class_exists('Context') || !class_exists('MemberModel')) {
return false;
}
Context::init();
$memberInfo = MemberModel::getMemberInfoByMemberSrl($memberSrl);
return is_object($memberInfo)
&& (($memberInfo->is_site_admin ?? false) === true || ($memberInfo->is_admin ?? 'N') === 'Y');
} catch (Throwable $e) {
return false;
}
}
function launcher_issue_admin_ticket(string $audience, int $memberSrl): string
{
if ($memberSrl <= 0) {
return '';
}
return custom_signed_cookie_payload([
'scope' => 'launcher_admin_ticket',
'aud' => $audience,
'admin' => true,
'member_srl' => $memberSrl,
'iat' => time(),
'exp' => time() + LAUNCHER_ADMIN_TICKET_TTL,
]);
}
function launcher_validate_admin_ticket(string $ticket, string $audience): ?array
{
if ($ticket === '' || strlen($ticket) > 4096 || $audience === '') {
return null;
}
$payload = custom_verify_signed_cookie_payload($ticket);
if (!is_array($payload)) {
return null;
}
$memberSrl = (int)($payload['member_srl'] ?? 0);
$valid = ($payload['scope'] ?? '') === 'launcher_admin_ticket'
&& ($payload['aud'] ?? '') === $audience
&& !empty($payload['admin'])
&& $memberSrl > 0
&& (int)($payload['iat'] ?? 0) <= time() + 30
&& (int)($payload['exp'] ?? 0) >= time()
&& launcher_member_is_site_admin($memberSrl);
return $valid ? $payload : null;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
custom_block_login_denied(true);
$data = custom_read_request_data();
$action = $data['action'] ?? 'login';
if ($action === 'login') {
$password = (string)($data['password'] ?? '');
if (!launcher_is_digit_password($password)) {
custom_json(['result' => 'fail', 'message' => 'password_digits_only'], 401);
}
if (custom_verify_password($password)) {
custom_issue_auth_cookie(false);
$pwaService = (string)($data['pwaService'] ?? $_GET['pwaService'] ?? '');
$pwaIssued = $pwaService !== '' && function_exists('custom_issue_pwa_auth_cookie')
? custom_issue_pwa_auth_cookie($pwaService)
: false;
custom_json(['result' => 'ok', 'pwaIssued' => $pwaIssued]);
custom_issue_auth_cookie();
custom_json(['result' => 'ok']);
}
custom_json(['result' => 'fail'], 401);
}
if ($action === 'logout') {
$scope = (string)($data['scope'] ?? '');
if ($scope === 'admin') {
custom_clear_auth_cookie();
} elseif (function_exists('custom_clear_non_admin_auth_cookie')) {
custom_clear_non_admin_auth_cookie(false);
} else {
custom_clear_auth_cookie();
}
custom_json(['result' => 'ok']);
}
@@ -157,19 +31,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$current = (string)($data['currentPassword'] ?? '');
$next = (string)($data['newPassword'] ?? '');
if (!launcher_is_digit_password($current) || !launcher_is_digit_password($next)) {
custom_json(['result' => 'fail', 'message' => 'password_digits_only'], 422);
}
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(false);
custom_issue_auth_cookie();
custom_json(['result' => 'ok']);
}
@@ -188,118 +62,25 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
if (isset($_GET['adminBridge'])) {
$audience = launcher_admin_ticket_audience((string)($_GET['adminBridge'] ?? ''));
$returnUrl = trim((string)($_GET['return'] ?? ''));
$returnParts = parse_url($returnUrl);
$validReturn = $audience !== ''
&& is_array($returnParts)
&& strtolower((string)($returnParts['scheme'] ?? '')) === 'https'
&& strtolower((string)($returnParts['host'] ?? '')) === 'eodiya.net'
&& !isset($returnParts['port'])
&& !isset($returnParts['user'])
&& !isset($returnParts['pass'])
&& !isset($returnParts['fragment']);
if (!$validReturn) {
custom_json(['error' => 'invalid_admin_bridge_return'], 400);
}
$memberSrl = launcher_current_site_admin_member_srl();
$isAdmin = $memberSrl > 0;
if ($isAdmin) {
custom_issue_auth_cookie(true);
}
$fragment = [
'rhymixAdmin' => $isAdmin ? '1' : '0',
];
if ($isAdmin) {
$fragment['rhymixAdminTicket'] = launcher_issue_admin_ticket($audience, $memberSrl);
$fragment['rhymixAdminTicketExpiresIn'] = (string)LAUNCHER_ADMIN_TICKET_TTL;
}
header('Location: ' . $returnUrl . '#' . http_build_query($fragment, '', '&', PHP_QUERY_RFC3986), true, 303);
exit;
}
if (isset($_GET['validateAdminTicket'])) {
$audience = launcher_admin_ticket_audience((string)($_GET['aud'] ?? ''));
$ticket = (string)($_GET['ticket'] ?? '');
$payload = launcher_validate_admin_ticket($ticket, $audience);
$valid = is_array($payload);
$response = [
'valid' => $valid,
'admin' => $valid,
];
if ($valid) {
$response['adminTicket'] = launcher_issue_admin_ticket($audience, (int)$payload['member_srl']);
$response['adminTicketExpiresIn'] = LAUNCHER_ADMIN_TICKET_TTL;
}
custom_json($response);
}
if (isset($_GET['session'])) {
$denied = custom_login_denied_state();
$blockedType = (string)($denied['type'] ?? '');
if (!empty($denied['denied']) && !($blockedType !== 'ip' && custom_is_site_admin())) {
if ($blockedType === 'ip') {
custom_clear_auth_cookie();
} elseif (function_exists('custom_clear_non_admin_auth_cookie')) {
custom_clear_non_admin_auth_cookie(true);
} else {
custom_clear_auth_cookie();
}
custom_json([
'authenticated' => false,
'admin' => false,
'loginBlocked' => true,
'blockedType' => $blockedType,
'blockedMessage' => $denied['message'] ?? '현재 로그인할 수 없는 상태입니다.',
]);
if (custom_has_auth_cookie()) {
custom_json(['authenticated' => true, 'admin' => false]);
}
if (custom_is_site_admin()) {
custom_issue_auth_cookie(true);
$pwaService = (string)($_GET['pwaService'] ?? '');
custom_json([
'authenticated' => true,
'admin' => true,
'pwaAuthenticated' => $pwaService !== '' && function_exists('custom_has_pwa_auth_cookie') && custom_has_pwa_auth_cookie($pwaService),
]);
custom_issue_auth_cookie();
custom_json(['authenticated' => true, 'admin' => true]);
}
if (custom_has_auth_cookie()) {
$pwaService = (string)($_GET['pwaService'] ?? '');
custom_json([
'authenticated' => true,
'admin' => false,
'pwaAuthenticated' => $pwaService !== '' && function_exists('custom_has_pwa_auth_cookie') && custom_has_pwa_auth_cookie($pwaService),
]);
}
$pwaService = (string)($_GET['pwaService'] ?? '');
$pwaAuthenticated = $pwaService !== '' && function_exists('custom_has_pwa_auth_cookie') && custom_has_pwa_auth_cookie($pwaService);
custom_json([
'authenticated' => false,
'admin' => false,
'pwaAuthenticated' => $pwaAuthenticated,
]);
custom_json(['authenticated' => false, 'admin' => false]);
}
if (isset($_GET['adminSession'])) {
$memberSrl = launcher_current_site_admin_member_srl();
$isAdmin = $memberSrl > 0;
$isAdmin = custom_is_site_admin();
if ($isAdmin) {
custom_issue_auth_cookie(true);
custom_issue_auth_cookie();
}
$response = [
'admin' => $isAdmin,
'authenticated' => $isAdmin,
];
$audience = launcher_admin_ticket_audience((string)($_GET['adminTicket'] ?? ''));
if ($isAdmin && $audience !== '') {
$response['adminTicket'] = launcher_issue_admin_ticket($audience, $memberSrl);
$response['adminTicketExpiresIn'] = LAUNCHER_ADMIN_TICKET_TTL;
}
custom_json($response);
custom_json(['admin' => $isAdmin, 'authenticated' => $isAdmin || custom_has_auth_cookie()]);
}
if (isset($_GET['failMessages'])) {
@@ -311,7 +92,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
}
if (isset($_GET['pokeCount'])) {
$state = custom_ha_state('mokpo', $config['entities']['poke_count']);
$state = custom_ha_state('main', $config['entities']['poke_count']);
$count = $state['state'] ?? null;
if ($count === null) {
-7
View File
@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Signal">
<rect x="18" y="6" width="28" height="52" rx="9" fill="#1f2937"/>
<rect x="22" y="10" width="20" height="44" rx="6" fill="#111827"/>
<circle cx="32" cy="20" r="7" fill="#ef4444"/>
<circle cx="32" cy="32" r="7" fill="#facc15"/>
<circle cx="32" cy="44" r="7" fill="#22c55e"/>
</svg>

Before

Width:  |  Height:  |  Size: 383 B

+53 -385
View File
@@ -1,95 +1,20 @@
const shortcut = document.getElementById('findMyShortcut');
let failIndex = 0;
let lastRenderedAdmin = null;
const apiUrl = '/custom/launcher/api.php';
const PIN_LENGTH = 4;
function isClientLoginBlocked() {
try {
return sessionStorage.getItem('login_blocked') === '1';
} catch (_) {
return false;
}
}
const LAUNCHER_LOGIN_BLOCKED_COOKIE_NAMES = {
member: 'chaegeon_member_blocked',
ip: 'chaegeon_ip_blocked',
login_fail: 'chaegeon_login_fail_blocked',
sms_verify_fail: 'chaegeon_sms_verify_blocked',
client: 'chaegeon_member_blocked'
};
const LAUNCHER_LOGIN_BLOCKED_LEGACY_COOKIE = 'chaegeon_login_blocked';
function clientLoginBlockedCookieName(type) {
return LAUNCHER_LOGIN_BLOCKED_COOKIE_NAMES[type || 'member'] || LAUNCHER_LOGIN_BLOCKED_COOKIE_NAMES.member;
}
function writeClientLoginBlockedCookie(name, blocked) {
const secure = location.protocol === 'https:' ? '; Secure' : '';
document.cookie = `${name}=${blocked ? '1' : ''}; path=/; max-age=${blocked ? '1800' : '0'}; SameSite=Strict${secure}`;
}
function clearClientLoginBlockedCookies(exceptName) {
[
LAUNCHER_LOGIN_BLOCKED_LEGACY_COOKIE,
LAUNCHER_LOGIN_BLOCKED_COOKIE_NAMES.member,
LAUNCHER_LOGIN_BLOCKED_COOKIE_NAMES.ip,
LAUNCHER_LOGIN_BLOCKED_COOKIE_NAMES.login_fail,
LAUNCHER_LOGIN_BLOCKED_COOKIE_NAMES.sms_verify_fail
].forEach(name => {
if (name !== exceptName) writeClientLoginBlockedCookie(name, false);
});
}
function setClientLoginBlockedCookie(type = 'member') {
const name = clientLoginBlockedCookieName(type);
clearClientLoginBlockedCookies(name);
writeClientLoginBlockedCookie(name, true);
}
function rememberClientLoginBlocked(type, message) {
try {
sessionStorage.setItem('login_blocked', '1');
sessionStorage.setItem('login_blocked_type', type || 'member');
if (message) sessionStorage.setItem('login_blocked_reason', message);
} catch (_) {}
setClientLoginBlockedCookie(type || 'member');
}
function isLauncherLoginBlockedAllowedHomePage() {
const path = location.pathname.toLowerCase();
return location.protocol === 'https:' && location.hostname === 'chaegeon.com' && (path === '/' || path === '/home' || path.startsWith('/home/'));
}
function replaceLauncherToChaegeonHome() {
if (isLauncherLoginBlockedAllowedHomePage()) return false;
location.replace('https://chaegeon.com/');
return true;
}
function redirectIfClientLoginBlocked() {
if (!isClientLoginBlocked()) return false;
let type = 'member';
try {
type = sessionStorage.getItem('login_blocked_type') || 'member';
} catch (_) {}
setClientLoginBlockedCookie(type);
replaceLauncherToChaegeonHome();
return true;
}
function openLauncherTarget(url) {
window.open(url, '_blank', 'noopener');
}
const userIconItems = [
const iconItems = [
{
id: 'findmydevice',
img: '/custom/launcher/img/find-my.svg',
label: '나의 찾기',
onclick: () => requireAccess(() => openLauncherTarget('/findmydevice'))
onclick: () => requireAccess(() => location.href = '/findmydevice')
},
{
id: 'wakeonlan',
img: '/custom/launcher/img/wakeonlan-svgrepo-com.svg',
label: 'PC 켜기',
onclick: () => requireAccess(wakeComputer)
},
{
id: 'poke',
@@ -98,67 +23,18 @@ const userIconItems = [
onclick: showPokeForm
},
{
id: 'signal',
img: 'https://eodiya.net/icons/favicon.png',
label: '어디야?',
onclick: () => openLauncherTarget('https://eodiya.net')
},
{
id: 'project',
img: 'https://git.chaegeon.com/assets/img/favicon.svg',
label: '프로젝트',
onclick: () => openLauncherTarget('https://git.chaegeon.com/explore/repos')
}
];
const adminIconItems = [
userIconItems[0],
userIconItems[1],
userIconItems[2],
userIconItems[3],
{
id: 'wakeonlan',
img: '/custom/launcher/img/wakeonlan-svgrepo-com.svg',
label: 'PC 켜기',
onclick: () => requireAccess(wakeComputer)
},
{
id: 'block',
img: '/admin/favicon/block/favicon.svg',
label: 'Block',
onclick: () => requireAccess(() => openLauncherTarget('https://chaegeon.com/admin/index.php'))
},
{
id: 'notice',
img: '/admin/favicon/notice/favicon.svg',
label: 'Notice',
onclick: () => requireAccess(() => openLauncherTarget('https://chaegeon.com/admin/notice.php'))
},
{
id: 'speedstats',
img: '/custom/speedtest/favicon.ico',
label: 'Speed Stats',
onclick: () => requireAccess(() => openLauncherTarget('https://chaegeon.com/custom/speedtest/results/stats.php'))
id: 'car',
img: '/custom/launcher/img/car.png',
label: '니모',
onclick: () => requireAccess(() => location.href = 'https://seo.chaegeon.com/car/monitor.php')
}
];
async function renderIcons() {
if (redirectIfClientLoginBlocked()) return;
const session = await getSession();
if (session.loginBlocked) {
rememberClientLoginBlocked(session.blockedType, session.blockedMessage);
replaceLauncherToChaegeonHome();
return;
}
const isAdmin = !!session.admin;
lastRenderedAdmin = isAdmin;
const items = isAdmin ? adminIconItems : userIconItems;
shortcut.className = `find-container ${isAdmin ? 'launcher-admin-grid' : ''}`;
shortcut.className = 'find-container';
shortcut.innerHTML = '';
items.forEach(item => {
iconItems.forEach(item => {
const div = document.createElement('div');
div.className = 'iconContainer';
div.id = item.id;
@@ -177,7 +53,7 @@ async function renderIcons() {
shortcut.appendChild(div);
});
if (!isAdmin) {
if (!await hasAdminSession()) {
return;
}
@@ -198,26 +74,15 @@ async function hasAdminSession() {
}
}
async function getSession() {
try {
return await fetch(`${apiUrl}?session=1`, { credentials: 'same-origin' }).then(res => res.json());
} catch (_) {
return { authenticated: false, admin: false };
}
}
async function requireAccess(nextAction) {
if (redirectIfClientLoginBlocked()) return;
try {
const session = await getSession();
if (session.loginBlocked) {
rememberClientLoginBlocked(session.blockedType, session.blockedMessage);
replaceLauncherToChaegeonHome();
if (await hasAdminSession()) {
nextAction && nextAction();
return;
}
if (session.admin || session.authenticated) {
const session = await fetch(`${apiUrl}?session=1`, { credentials: 'same-origin' }).then(res => res.json());
if (session.authenticated) {
nextAction && nextAction();
return;
}
@@ -227,56 +92,20 @@ async function requireAccess(nextAction) {
showPasswordForm(nextAction);
}
async function finishPasswordSuccess(onSuccess) {
if (typeof onSuccess === 'function') {
onSuccess();
}
await renderIcons();
}
async function refreshAdminModeIfNeeded() {
if (lastRenderedAdmin !== true) return;
const isAdmin = await hasAdminSession();
if (!isAdmin) {
await renderIcons();
}
}
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
refreshAdminModeIfNeeded();
}
});
window.addEventListener('focus', refreshAdminModeIfNeeded);
function renderLoginBlocked(session = {}) {
shortcut.className = 'launcher-blocked';
shortcut.innerHTML = '';
const title = document.createElement('strong');
title.textContent = '현재 로그인할 수 없는 상태입니다.';
const message = document.createElement('div');
message.textContent = session.blockedMessage || '잠시 후 다시 시도하세요.';
shortcut.appendChild(title);
shortcut.appendChild(message);
}
function showPasswordForm(onSuccess) {
if (redirectIfClientLoginBlocked()) return;
shortcut.className = 'password-container';
shortcut.innerHTML = '';
const title = createFormHeader('비밀번호', renderIcons);
const input = createPinInput('blog_secret', '비밀번호');
const input = createPasswordInput('blog_secret', '비밀번호');
const button = createButton('확인');
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';
@@ -284,16 +113,10 @@ function showPasswordForm(onSuccess) {
column.appendChild(inputRow);
shortcut.appendChild(column);
let submitting = false;
const submit = async () => {
if (redirectIfClientLoginBlocked()) return;
if (submitting) return;
const password = onlyDigits(input.value);
input.value = password;
if (password.length !== PIN_LENGTH) return;
const password = input.value;
try {
submitting = true;
const messageRes = await fetch(`${apiUrl}?failMessages=1`);
const messagesJson = await messageRes.json();
const failMessages = messagesJson.failMessages || [];
@@ -306,14 +129,12 @@ function showPasswordForm(onSuccess) {
});
const data = await res.json();
if (data.loginBlocked) {
rememberClientLoginBlocked(data.blockedType, data.blockedMessage);
replaceLauncherToChaegeonHome();
return;
}
if (res.ok && data.result === 'ok') {
await finishPasswordSuccess(onSuccess);
if (typeof onSuccess === 'function') {
onSuccess();
} else {
renderIcons();
}
return;
}
@@ -322,17 +143,16 @@ function showPasswordForm(onSuccess) {
failIndex = (failIndex + 1) % Math.max(1, failMessages.length);
input.value = '';
input.focus();
submitting = false;
} catch (_) {
column.appendChild(msg);
msg.textContent = '요청을 처리하지 못했습니다.';
submitting = false;
}
};
const keypad = createPinKeypad([input], submit);
column.appendChild(keypad);
attachPinInputEvents(input, submit);
button.addEventListener('click', submit);
input.addEventListener('keydown', e => {
if (e.key === 'Enter') submit();
});
input.focus();
}
@@ -341,45 +161,30 @@ function showPasswordChangeForm() {
shortcut.innerHTML = '';
const title = createFormHeader('암호 변경', renderIcons);
const currentInput = createPinInput('current_password', '현재 암호');
const newInput = createPinInput('new_password', '새 암호');
const confirmInput = createPinInput('confirm_password', '새 암호 확인');
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 status = document.createElement('div');
status.className = 'launcher-pin-status';
status.textContent = '현재 암호 입력';
const column = document.createElement('div');
column.className = 'launcher-form-column';
column.appendChild(title);
column.appendChild(status);
column.appendChild(currentInput);
column.appendChild(newInput);
column.appendChild(confirmInput);
column.appendChild(button);
column.appendChild(msg);
shortcut.appendChild(column);
const submit = async () => {
const currentPassword = onlyDigits(currentInput.value);
const newPassword = onlyDigits(newInput.value);
const confirmPassword = onlyDigits(confirmInput.value);
currentInput.value = currentPassword;
newInput.value = newPassword;
confirmInput.value = confirmPassword;
if ([currentPassword, newPassword, confirmPassword].some(value => value.length !== PIN_LENGTH)) return;
const currentPassword = currentInput.value;
const newPassword = newInput.value;
const confirmPassword = confirmInput.value;
if (newPassword !== confirmPassword) {
msg.textContent = '새 암호가 일치하지 않습니다.';
newInput.value = '';
confirmInput.value = '';
newInput.focus();
return;
}
if (!isDigitPin(currentPassword) || !isDigitPin(newPassword)) {
msg.textContent = '암호는 숫자만 입력할 수 있습니다.';
return;
}
@@ -400,36 +205,20 @@ function showPasswordChangeForm() {
}
msg.style.color = 'red';
msg.textContent = data.message === 'password_digits_only' ? '암호는 숫자 4자리여야 합니다.' : '현재 암호가 틀립니다.';
if (data.message === 'current_password_invalid') {
currentInput.value = '';
newInput.value = '';
confirmInput.value = '';
currentInput.focus();
}
msg.textContent = data.message === 'password_too_short' ? '새 암호는 4자 이상이어야 합니다.' : '현재 암호가 틀립니다.';
} catch (_) {
msg.style.color = 'red';
msg.textContent = '요청을 처리하지 못했습니다.';
}
};
const labels = new Map([
[currentInput, '현재 암호 입력'],
[newInput, '새 암호 입력'],
[confirmInput, '새 암호 확인 입력'],
]);
const syncStatus = () => {
const active = document.activeElement && labels.has(document.activeElement) ? document.activeElement : currentInput;
status.textContent = labels.get(active);
};
const keypad = createPinKeypad([currentInput, newInput, confirmInput], submit, syncStatus);
column.insertBefore(keypad, msg);
button.addEventListener('click', submit);
[currentInput, newInput, confirmInput].forEach(input => {
attachPinInputEvents(input, submit);
input.addEventListener('focus', syncStatus);
input.addEventListener('keydown', e => {
if (e.key === 'Enter') submit();
});
});
currentInput.focus();
syncStatus();
}
async function wakeComputer() {
@@ -447,19 +236,9 @@ async function wakeComputer() {
return;
}
const sent = await fetch('/custom/wakeonlan/api.php', { method: 'POST', credentials: 'same-origin' })
await fetch('/custom/wakeonlan/api.php', { method: 'POST', credentials: 'same-origin' })
.then(res => res.ok ? res.json() : Promise.reject());
if (sent && sent.result === 'already') {
launcherAlert('wakeonlan_already');
return;
}
if (!sent || sent.result !== 'sent') {
launcherAlert('error');
return;
}
launcherAlert('wakeonlan_request_sent');
let attempts = 0;
@@ -571,131 +350,20 @@ function createFormHeader(text, onBack) {
return header;
}
function onlyDigits(value) {
return String(value || '').replace(/\D+/g, '');
}
function isDigitPin(value) {
return new RegExp(`^\\d{${PIN_LENGTH}}$`).test(String(value || ''));
}
function createPinInput(id, placeholder) {
function createPasswordInput(id, placeholder) {
const input = document.createElement('input');
input.type = 'password';
input.placeholder = placeholder;
input.id = id;
input.inputMode = 'numeric';
input.pattern = '[0-9]*';
input.autocomplete = 'off';
input.enterKeyHint = 'done';
input.maxLength = PIN_LENGTH;
input.readOnly = true;
input.className = 'launcher-pin-input';
input.setAttribute('aria-label', placeholder);
input.autocomplete = 'current-password';
return input;
}
function attachPinInputEvents(input, onEnter) {
input.addEventListener('focus', () => {
document.querySelectorAll('.launcher-pin-input').forEach(el => el.classList.toggle('active', el === input));
});
input.addEventListener('input', () => {
input.value = onlyDigits(input.value);
});
input.addEventListener('paste', e => {
e.preventDefault();
input.value = (input.value + onlyDigits(e.clipboardData?.getData('text') || '')).slice(0, PIN_LENGTH);
if (input.value.length === PIN_LENGTH) onEnter();
});
input.addEventListener('keydown', e => {
if (/^\d$/.test(e.key)) {
e.preventDefault();
input.value = (input.value + e.key).slice(0, PIN_LENGTH);
if (input.value.length === PIN_LENGTH) onEnter();
return;
}
if (e.key === 'Backspace') {
e.preventDefault();
input.value = input.value.slice(0, -1);
return;
}
if (e.key === 'Delete') {
e.preventDefault();
input.value = '';
return;
}
if (e.key === 'Enter') {
e.preventDefault();
onEnter();
}
});
}
function createPinKeypad(inputs, onEnter, onActiveChange = null) {
let activeInput = inputs[0];
const keypad = document.createElement('div');
keypad.className = 'launcher-pin-keypad';
const clearInputsFrom = input => {
const startIndex = inputs.indexOf(input);
if (startIndex < 0) return;
inputs.slice(startIndex).forEach(item => {
item.value = '';
});
};
const setActiveInput = (input, clear = false, clearFollowing = false) => {
activeInput = input;
if (clearFollowing) {
clearInputsFrom(activeInput);
} else if (clear) {
activeInput.value = '';
}
activeInput.focus();
document.querySelectorAll('.launcher-pin-input').forEach(el => el.classList.toggle('active', el === activeInput));
if (typeof onActiveChange === 'function') onActiveChange(activeInput);
};
inputs.forEach(input => {
input.addEventListener('focus', () => {
activeInput = input;
if (typeof onActiveChange === 'function') onActiveChange(activeInput);
});
input.addEventListener('click', () => {
setActiveInput(input, input.value.length > 0, inputs.length > 1 && input.value.length > 0);
});
});
['1', '2', '3', '4', '5', '6', '7', '8', '9', 'clear', '0', 'back'].forEach(key => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `launcher-pin-key ${key === 'clear' || key === 'back' ? 'launcher-pin-action' : ''}`;
btn.textContent = key === 'clear' ? 'C' : (key === 'back' ? '⌫' : key);
btn.setAttribute('aria-label', key === 'clear' ? '전체 지우기' : (key === 'back' ? '한 글자 지우기' : `${key} 입력`));
btn.addEventListener('click', () => {
activeInput.focus();
if (key === 'clear') {
activeInput.value = '';
} else if (key === 'back') {
activeInput.value = activeInput.value.slice(0, -1);
} else {
activeInput.value = (activeInput.value + key).slice(0, PIN_LENGTH);
if (activeInput.value.length === PIN_LENGTH) {
if (inputs.length === 1) {
onEnter();
} else {
const idx = inputs.indexOf(activeInput);
if (idx >= 0 && idx < inputs.length - 1) {
const nextInput = inputs[idx + 1];
setActiveInput(nextInput, nextInput.value.length > 0, nextInput.value.length > 0);
} else {
onEnter();
}
}
}
}
});
keypad.appendChild(btn);
});
return keypad;
function createButton(text) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = text;
return button;
}
function launcherAlert(name) {