Use shared auth and proxy Home Assistant webhooks

This commit is contained in:
seo
2026-06-12 15:10:20 +09:00
parent a2a89998b6
commit 72aedf47e5
3 changed files with 128 additions and 121 deletions
+30
View File
@@ -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 저장소와 개인 접근 환경에서만 운영합니다.
+17 -7
View File
@@ -413,9 +413,9 @@ document.getElementById('autoUpdateSwitch').addEventListener('change', () => {
const isChecked = document.getElementById('autoUpdateSwitch')?.checked; const isChecked = document.getElementById('autoUpdateSwitch')?.checked;
if (isChecked) { if (isChecked) {
fetch('https://ha.chaegeon.com/api/webhook/5P1HgWiDpaAK8E0B', { method: 'POST' }); callDeviceWebhook('auto_on');
} else { } else {
fetch('https://ha.chaegeon.com/api/webhook/z2nHt75jRxWKo2zF', { method: 'POST' }); callDeviceWebhook('auto_off');
} }
}); });
@@ -426,7 +426,7 @@ document.getElementById('autoUpdateSwitch').addEventListener('change', () => {
const isAutoUpdateOn = switchEl?.checked; const isAutoUpdateOn = switchEl?.checked;
if (isAutoUpdateOn) { if (isAutoUpdateOn) {
navigator.sendBeacon('https://ha.chaegeon.com/api/webhook/z2nHt75jRxWKo2zF'); callDeviceWebhook('auto_off', true);
// 스위치 UI 강제 끔 // 스위치 UI 강제 끔
switchEl.checked = false; switchEl.checked = false;
@@ -437,7 +437,7 @@ document.getElementById('autoUpdateSwitch').addEventListener('change', () => {
setInterval(() => { setInterval(() => {
if (document.getElementById('autoUpdateSwitch')?.checked) { if (document.getElementById('autoUpdateSwitch')?.checked) {
fetch('https://ha.chaegeon.com/api/webhook/870ZNzB20WRkzjnh', { method: 'POST' }); callDeviceWebhook('auto_tick');
} }
}, 10000); }, 10000);
@@ -984,9 +984,9 @@ function fetchDeviceData(includeLang = true, range = null) {
return fetch(url, { return fetch(url, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json'
'Authorization': 'REDACTED_HA_TOKEN'
}, },
credentials: 'same-origin',
body: JSON.stringify({}) body: JSON.stringify({})
}).then(res => res.json()); }).then(res => res.json());
} }
@@ -1020,7 +1020,7 @@ function resetDisplayValues() {
} }
function triggerForceUpdate() { function triggerForceUpdate() {
fetch('https://ha.chaegeon.com/api/webhook/g1M4dZrsiLTt1woqgDXL6wtBSNCp6HGsRSzRLnNV4Up2Tpy8kre0q33EGvB70H9ftw48oENbUngHhVEBRlBFLNA7x7MbcmwZnVSHU71BG83ROMy4MOqIDWGxpKvI1YPX', { method: 'POST' }); callDeviceWebhook('force_update');
const el = document.getElementById('updateBtn'); const el = document.getElementById('updateBtn');
if (!el) return; if (!el) return;
@@ -1072,3 +1072,13 @@ function renderTrackFromDisplayedMarkers() {
mapState.trackLine.setPath(points); mapState.trackLine.setPath(points);
} }
} }
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);
}
+54 -87
View File
@@ -1,11 +1,6 @@
<?php <?php
// Apache 환경 차단 require_once __DIR__ . '/../../common/auth.php';
if (isset($_SERVER['SERVER_SOFTWARE']) && stripos($_SERVER['SERVER_SOFTWARE'], 'Apache') !== false) { require_once __DIR__ . '/../../common/ha.php';
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'Access forbidden: Apache environment is not allowed']);
exit;
}
// POST 전용 검사 // POST 전용 검사
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
@@ -15,69 +10,47 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
exit; 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('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, no-cache, must-revalidate'); header('Cache-Control: no-store, no-cache, must-revalidate');
header('Expires: 0'); header('Expires: 0');
$baseUrl = "https://ha.seoul.chaegeon.com/api/states"; custom_require_auth();
$headers = [
"Authorization: Bearer REDACTED_HA_TOKEN", $requestData = custom_read_request_data();
"Content-Type: application/json" 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 // 엔드포인트 URL
$deviceUrl = "$baseUrl/device_tracker.seocaegeonyi_z_fold7"; $deviceUrl = $entities['device'];
$geoUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_geocoded_location"; $geoUrl = $entities['geo'];
$batteryStateUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_battery_state"; $batteryStateUrl = $entities['battery_state'];
$batteryLevelUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_battery_level"; $batteryLevelUrl = $entities['battery_level'];
$watchBatteryStateUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_watch_battery_state"; $watchBatteryStateUrl = $entities['watch_battery_state'];
$watchBatteryLevelUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_watch_battery_level"; $watchBatteryLevelUrl = $entities['watch_battery_level'];
$activityUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_detected_activity"; $activityUrl = $entities['activity'];
$connectionUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_network_type"; $connectionUrl = $entities['connection'];
$fascendedUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_daily_floors"; $fascendedUrl = $entities['daily_floors'];
$fdescendedUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_daily_floors"; $fdescendedUrl = $entities['daily_floors'];
$stepsUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_daily_steps"; $stepsUrl = $entities['daily_steps'];
$distanceUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_daily_distance"; $distanceUrl = $entities['daily_distance'];
$triggerUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_last_update_trigger"; $triggerUrl = $entities['trigger'];
// GET 파라미터 읽기 // GET 파라미터 읽기
$startParam = $_GET['startTime'] ?? null; $startParam = $_GET['startTime'] ?? null;
@@ -97,34 +70,28 @@ if ($startParam && $endParam) {
} }
// API URL 생성 // 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) { function getData($entityId) {
$ch = curl_init(); return custom_ha_state('seoul', $entityId) ?? [];
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);
} }
// 데이터 불러오기 // 데이터 불러오기
$deviceData = getData($deviceUrl, $headers); $deviceData = getData($deviceUrl);
$geoData = getData($geoUrl, $headers); $geoData = getData($geoUrl);
$batteryStateData = getData($batteryStateUrl, $headers); $batteryStateData = getData($batteryStateUrl);
$batteryLevelData = getData($batteryLevelUrl, $headers); $batteryLevelData = getData($batteryLevelUrl);
$watchBatteryStateData = getData($watchBatteryStateUrl, $headers); $watchBatteryStateData = getData($watchBatteryStateUrl);
$watchBatteryLevelData = getData($watchBatteryLevelUrl, $headers); $watchBatteryLevelData = getData($watchBatteryLevelUrl);
$activityData = getData($activityUrl, $headers); $activityData = getData($activityUrl);
$connectionData = getData($connectionUrl, $headers); $connectionData = getData($connectionUrl);
$ascendedData = getData($fascendedUrl, $headers); $ascendedData = getData($fascendedUrl);
$descendedData = getData($fdescendedUrl, $headers); $descendedData = getData($fdescendedUrl);
$stepsData = getData($stepsUrl, $headers); $stepsData = getData($stepsUrl);
$distanceData = getData($distanceUrl, $headers); $distanceData = getData($distanceUrl);
$historyData = getData($historyUrl, $headers); $historyData = custom_ha_request('seoul', 'GET', $historyEndpoint) ?? [];
$triggerData = getData($triggerUrl, $headers); $triggerData = getData($triggerUrl);
// 경로 기록 // 경로 기록
$path = []; $path = [];