From 72aedf47e5d32b703b84edd8d7fffe8b9c459be3 Mon Sep 17 00:00:00 2001 From: seo Date: Fri, 12 Jun 2026 15:10:20 +0900 Subject: [PATCH] Use shared auth and proxy Home Assistant webhooks --- README.md | 30 +++++++++++ js/main.js | 78 ++++++++++++++++------------- php/api.php | 141 ++++++++++++++++++++-------------------------------- 3 files changed, 128 insertions(+), 121 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..0cecdab --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# Find My Device + +Home Assistant에 기록된 Fold7 위치, 배터리, 활동 상태, 이동 경로를 지도에 표시하는 개인용 나의 찾기 페이지입니다. + +## 기능 + +- 현재 위치 지도 표시 +- GPS 정확도 원 표시 +- 최근 24시간 및 사용자 지정 기간 이동 경로 표시 +- 배터리, 워치 배터리, 활동 상태, 걸음 수, 이동 거리 표시 +- 자동 위치 갱신 웹훅 제어 +- 강제 업데이트 요청 +- 한국어/영어 전환 +- Wake Lock 지원 + +## 구조 + +- `debug.html`: 페이지 삽입용 HTML 조각 +- `php/api.php`: Home Assistant 상태/히스토리 조회 API +- `js/main.js`: 지도 렌더링, 상태 갱신, 히스토리 표시 +- `css/style.css`: iOS 설정 화면 스타일 +- `img/`: 지도 마커 및 언어 아이콘 + +## 공통 모듈 연동 + +인증과 Home Assistant 호출은 `/custom/common`을 사용합니다. 브라우저 JS에는 더 이상 API Bearer 토큰이나 Home Assistant 웹훅 식별자를 직접 넣지 않습니다. + +## 운영 메모 + +이 프로젝트는 위치, 주소, 배터리, 활동 기록을 포함하므로 private 저장소와 개인 접근 환경에서만 운영합니다. diff --git a/js/main.js b/js/main.js index beb0afc..23076a5 100644 --- a/js/main.js +++ b/js/main.js @@ -410,36 +410,36 @@ document.getElementById('wakeLockSwitch').addEventListener('change', async funct document.getElementById('autoUpdateSwitch').addEventListener('change', () => { syncUpdateBtn(); - const isChecked = document.getElementById('autoUpdateSwitch')?.checked; - - if (isChecked) { - fetch('https://ha.chaegeon.com/api/webhook/5P1HgWiDpaAK8E0B', { method: 'POST' }); - } else { - fetch('https://ha.chaegeon.com/api/webhook/z2nHt75jRxWKo2zF', { method: 'POST' }); - } -}); + const isChecked = document.getElementById('autoUpdateSwitch')?.checked; + + if (isChecked) { + callDeviceWebhook('auto_on'); + } else { + callDeviceWebhook('auto_off'); + } +}); ['pagehide', 'visibilitychange', 'blur', 'beforeunload'].forEach(eventType => { window.addEventListener(eventType, function (e) { const switchEl = document.getElementById('autoUpdateSwitch'); - const isAutoUpdateOn = switchEl?.checked; - - if (isAutoUpdateOn) { - navigator.sendBeacon('https://ha.chaegeon.com/api/webhook/z2nHt75jRxWKo2zF'); - - // 스위치 UI 강제 끔 - switchEl.checked = false; + const isAutoUpdateOn = switchEl?.checked; + + if (isAutoUpdateOn) { + callDeviceWebhook('auto_off', true); + + // 스위치 UI 강제 끔 + switchEl.checked = false; syncUpdateBtn(); } }); }); -setInterval(() => { - if (document.getElementById('autoUpdateSwitch')?.checked) { - fetch('https://ha.chaegeon.com/api/webhook/870ZNzB20WRkzjnh', { method: 'POST' }); - } -}, 10000); +setInterval(() => { + if (document.getElementById('autoUpdateSwitch')?.checked) { + callDeviceWebhook('auto_tick'); + } +}, 10000); setInterval(() => { if (lastUpdateISOTime) { @@ -979,17 +979,17 @@ function fetchDeviceData(includeLang = true, range = null) { if (s) params.set('startTime', s); if (e) params.set('endTime', e); - const url = `https://chaegeon.com/custom/findmydevice/php/api.php?${params.toString()}`; - - return fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'REDACTED_HA_TOKEN' - }, - body: JSON.stringify({}) - }).then(res => res.json()); -} + const url = `https://chaegeon.com/custom/findmydevice/php/api.php?${params.toString()}`; + + return fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'same-origin', + body: JSON.stringify({}) + }).then(res => res.json()); +} function isIosSafari() { const ua = window.navigator.userAgent; @@ -1019,8 +1019,8 @@ function resetDisplayValues() { }); } -function triggerForceUpdate() { - fetch('https://ha.chaegeon.com/api/webhook/g1M4dZrsiLTt1woqgDXL6wtBSNCp6HGsRSzRLnNV4Up2Tpy8kre0q33EGvB70H9ftw48oENbUngHhVEBRlBFLNA7x7MbcmwZnVSHU71BG83ROMy4MOqIDWGxpKvI1YPX', { method: 'POST' }); +function triggerForceUpdate() { + callDeviceWebhook('force_update'); const el = document.getElementById('updateBtn'); if (!el) return; @@ -1071,4 +1071,14 @@ function renderTrackFromDisplayedMarkers() { } else { mapState.trackLine.setPath(points); } -} \ No newline at end of file +} + +function callDeviceWebhook(name, keepalive = false) { + return fetch('https://chaegeon.com/custom/findmydevice/php/api.php?action=webhook', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + keepalive, + body: JSON.stringify({ name }) + }).catch(() => null); +} diff --git a/php/api.php b/php/api.php index a4a422e..51a9842 100644 --- a/php/api.php +++ b/php/api.php @@ -1,11 +1,6 @@ 'Access forbidden: Apache environment is not allowed']); - exit; -} +require_once __DIR__ . '/../../common/auth.php'; +require_once __DIR__ . '/../../common/ha.php'; // POST 전용 검사 if ($_SERVER['REQUEST_METHOD'] !== 'POST') { @@ -15,69 +10,47 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { exit; } -// JSON 요청 검사 -$content_type = $_SERVER['CONTENT_TYPE'] ?? ''; -if (stripos($content_type, 'application/json') === false) { - http_response_code(415); // Unsupported Media Type - header('Content-Type: application/json'); - echo json_encode(['error' => 'Unsupported Content-Type. application/json required']); - exit; -} - -function get_authorization_header() { - if (!empty($_SERVER['HTTP_AUTHORIZATION'])) { - return trim($_SERVER['HTTP_AUTHORIZATION']); - } elseif (function_exists('apache_request_headers')) { - foreach (apache_request_headers() as $key => $value) { - if (strcasecmp($key, 'Authorization') === 0) { - return trim($value); - } - } - } - return null; -} - -$auth_header = get_authorization_header(); -if (!$auth_header || !preg_match('/^Bearer\s+(\S+)$/', $auth_header, $matches)) { - http_response_code(401); - header('Content-Type: application/json'); - echo json_encode(['error' => 'Unauthorized: Missing or invalid Authorization header']); - exit; -} - -$allowed_password = 'XBv9z1IGqPUdtVf7DABFA16H0E154LJS'; -$password = $matches[1]; -if ($password !== $allowed_password) { - http_response_code(401); - header('Content-Type: application/json'); - echo json_encode(['error' => 'Unauthorized: Invalid token']); - exit; -} - header('Content-Type: application/json; charset=utf-8'); header('Cache-Control: no-store, no-cache, must-revalidate'); header('Expires: 0'); -$baseUrl = "https://ha.seoul.chaegeon.com/api/states"; -$headers = [ - "Authorization: Bearer REDACTED_HA_TOKEN", - "Content-Type: application/json" -]; +custom_require_auth(); + +$requestData = custom_read_request_data(); +if (isset($_GET['action']) && $_GET['action'] === 'webhook') { + $name = (string)($requestData['name'] ?? ''); + $allowed = [ + 'auto_on' => 'find_auto_on', + 'auto_off' => 'find_auto_off', + 'auto_tick' => 'find_auto_tick', + 'force_update' => 'find_force_update', + ]; + + if (!isset($allowed[$name])) { + custom_json(['result' => 'invalid_webhook'], 400); + } + + $ok = custom_ha_webhook($allowed[$name]); + custom_json(['result' => $ok ? 'ok' : 'fail'], $ok ? 200 : 502); +} + +$config = custom_config(); +$entities = $config['entities']; // 엔드포인트 URL -$deviceUrl = "$baseUrl/device_tracker.seocaegeonyi_z_fold7"; -$geoUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_geocoded_location"; -$batteryStateUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_battery_state"; -$batteryLevelUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_battery_level"; -$watchBatteryStateUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_watch_battery_state"; -$watchBatteryLevelUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_watch_battery_level"; -$activityUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_detected_activity"; -$connectionUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_network_type"; -$fascendedUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_daily_floors"; -$fdescendedUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_daily_floors"; -$stepsUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_daily_steps"; -$distanceUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_daily_distance"; -$triggerUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_last_update_trigger"; +$deviceUrl = $entities['device']; +$geoUrl = $entities['geo']; +$batteryStateUrl = $entities['battery_state']; +$batteryLevelUrl = $entities['battery_level']; +$watchBatteryStateUrl = $entities['watch_battery_state']; +$watchBatteryLevelUrl = $entities['watch_battery_level']; +$activityUrl = $entities['activity']; +$connectionUrl = $entities['connection']; +$fascendedUrl = $entities['daily_floors']; +$fdescendedUrl = $entities['daily_floors']; +$stepsUrl = $entities['daily_steps']; +$distanceUrl = $entities['daily_distance']; +$triggerUrl = $entities['trigger']; // GET 파라미터 읽기 $startParam = $_GET['startTime'] ?? null; @@ -97,34 +70,28 @@ if ($startParam && $endParam) { } // API URL 생성 -$historyUrl = "https://ha.seoul.chaegeon.com/api/history/period/{$startTime}?filter_entity_id=device_tracker.seocaegeonyi_z_fold7&end_time={$endTime}"; +$historyEndpoint = "/history/period/{$startTime}?filter_entity_id=" . rawurlencode($entities['device']) . "&end_time={$endTime}"; // 공통 요청 함수 -function getData($url, $headers) { - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); - $response = curl_exec($ch); - curl_close($ch); - return json_decode($response, true); +function getData($entityId) { + return custom_ha_state('seoul', $entityId) ?? []; } // 데이터 불러오기 -$deviceData = getData($deviceUrl, $headers); -$geoData = getData($geoUrl, $headers); -$batteryStateData = getData($batteryStateUrl, $headers); -$batteryLevelData = getData($batteryLevelUrl, $headers); -$watchBatteryStateData = getData($watchBatteryStateUrl, $headers); -$watchBatteryLevelData = getData($watchBatteryLevelUrl, $headers); -$activityData = getData($activityUrl, $headers); -$connectionData = getData($connectionUrl, $headers); -$ascendedData = getData($fascendedUrl, $headers); -$descendedData = getData($fdescendedUrl, $headers); -$stepsData = getData($stepsUrl, $headers); -$distanceData = getData($distanceUrl, $headers); -$historyData = getData($historyUrl, $headers); -$triggerData = getData($triggerUrl, $headers); +$deviceData = getData($deviceUrl); +$geoData = getData($geoUrl); +$batteryStateData = getData($batteryStateUrl); +$batteryLevelData = getData($batteryLevelUrl); +$watchBatteryStateData = getData($watchBatteryStateUrl); +$watchBatteryLevelData = getData($watchBatteryLevelUrl); +$activityData = getData($activityUrl); +$connectionData = getData($connectionUrl); +$ascendedData = getData($fascendedUrl); +$descendedData = getData($fdescendedUrl); +$stepsData = getData($stepsUrl); +$distanceData = getData($distanceUrl); +$historyData = custom_ha_request('seoul', 'GET', $historyEndpoint) ?? []; +$triggerData = getData($triggerUrl); // 경로 기록 $path = [];