'error', 'message' => 'Method Not Allowed']); exit; } require_once dirname(__DIR__) . '/config.php'; // 누수 사진 1장 최대 크기(바이트). 기본 2MB. 인증 전 대용량 업로드 증폭형 DoS 방지. if (!defined('PHOTO_MAX_BYTES')) { define('PHOTO_MAX_BYTES', 2 * 1024 * 1024); } // multipart 헤더/필드(boundary, device_id, sensor_id, log_id, timestamp 등) 오버헤드 여유. // Content-Length 사전 거부에서 사진 본문 외 부수 바이트를 감안한다. if (!defined('PHOTO_MULTIPART_OVERHEAD_BYTES')) { define('PHOTO_MULTIPART_OVERHEAD_BYTES', 8192); } // ── 인증 전 사전 거부: Content-Length 가 상한+오버헤드를 넘으면 즉시 413 ── // 서명 검증/파일 수신 등 무거운 처리에 들어가기 전에 차단(증폭형 DoS 방어). $declared_len = (int)($_SERVER['CONTENT_LENGTH'] ?? 0); if ($declared_len > PHOTO_MAX_BYTES + PHOTO_MULTIPART_OVERHEAD_BYTES) { error_log("[PHOTO] Content-Length 과대({$declared_len}B) from " . ($_SERVER['REMOTE_ADDR'] ?? '?')); record_ingest_rejection('photo_too_large'); json_response(['status' => 'error', 'message' => '사진 크기 오류(최대 ' . PHOTO_MAX_BYTES . 'B)'], 413); } // ── 운영 비밀값 강제: 기본 API_KEY 로는 수신 거부(fail-closed) ── if (api_key_is_default()) { error_log("[PHOTO] 거부: API_KEY 가 기본값입니다. config.local.php 에 운영 키를 설정하세요."); record_ingest_rejection('key_default'); json_response(['status' => 'error', 'message' => '서버 구성 오류'], 503); } // 입력 문자열 정제: 제어문자 제거(로그/경로 주입 방지). 서명 검증은 정제 전 원본으로 한다. function photo_sanitize_str(string $s): string { return preg_replace('/[[:cntrl:]]+/', '', $s); } // 테이블 존재 확인(미통합 배포 graceful degrade). sensor_data.php api_table_exists 와 동일. function photo_table_exists(PDO $db, string $table): bool { try { $stmt = $db->prepare(" SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table "); $stmt->execute([':table' => $table]); return (int)$stmt->fetchColumn() > 0; } catch (Throwable $e) { error_log("[PHOTO] table check failed: {$table} " . $e->getMessage()); return false; } } // ── 재전송(replay) 멱등 선점/해제 ────────────────────────────── // sensor_data.php 의 ingest_nonce_claim/ingest_nonce_release 와 동일하게 동작한다. // (photo_upload.php 는 sensor_data.php 를 include 하지 않으므로 여기서 동일 헬퍼를 둔다.) // 'claimed'/'duplicate'/'unavailable' function photo_nonce_claim(PDO $db, string $sig): string { if (!INGEST_NONCE_ENABLED || $sig === '') { return 'unavailable'; } try { $ins = $db->prepare("INSERT INTO ingest_nonce (signature, seen_at) VALUES (:sig, NOW())"); $ins->execute([':sig' => substr($sig, 0, 128)]); } catch (PDOException $e) { $code = $e->getCode(); if ($code === '23000') { return 'duplicate'; // PK 충돌 = 동일 서명 재수신 } if ($code !== '42S02') { // 42S02 = 테이블 없음(기존 배포) → 조용히 비활성 error_log("[PHOTO] nonce claim 오류({$code}): " . $e->getMessage()); } return 'unavailable'; } try { $ttl = max(60, INGEST_MAX_CLOCK_SKEW_SEC * 2); $db->exec("DELETE FROM ingest_nonce WHERE seen_at < (NOW() - INTERVAL " . (int)$ttl . " SECOND)"); } catch (Throwable $e) { /* 정리는 실패해도 무방 */ } return 'claimed'; } function photo_nonce_release(PDO $db, string $sig): void { if ($sig === '') { return; } try { $db->prepare("DELETE FROM ingest_nonce WHERE signature = :sig") ->execute([':sig' => substr($sig, 0, 128)]); } catch (Throwable $e) { error_log("[PHOTO] nonce release 오류: " . $e->getMessage()); } } // 새 var/photos/ 디렉터리에 보호 파일(.htaccess + index.html)을 best-effort 로 기록한다. // 첫 업로드 직후에도 웹 직접 접근이 차단된 상태가 되도록(mkdir 시점 자가보호). function photo_write_dir_guards(string $dir): void { $htaccess = $dir . '/.htaccess'; if (!is_file($htaccess)) { @file_put_contents( $htaccess, "\n Require all denied\n\n" . "\n Order allow,deny\n Deny from all\n\n" . "\n Options -Indexes\n\n" ); } $index = $dir . '/index.html'; if (!is_file($index)) { @file_put_contents($index, "403\n"); } } // ── 입력 추출 (multipart form fields) ── $device_id_raw = (string)($_POST['device_id'] ?? ''); $sensor_id_raw = (string)($_POST['sensor_id'] ?? ''); $log_id_raw = (string)($_POST['log_id'] ?? ''); $timestamp_raw = (string)($_POST['timestamp'] ?? ''); if ($device_id_raw === '' || $sensor_id_raw === '' || $log_id_raw === '' || $timestamp_raw === '') { record_ingest_rejection('missing_field'); json_response(['status' => 'error', 'message' => '필드 누락'], 400); } // ── 서명 검증 (canonical = device_id|sensor_id|log_id|timestamp, 공백 없음) ── // 서명 입력은 클라이언트가 보낸 값을 "그대로" 이어 붙인다(클라이언트도 동일 문자열로 서명). $received_sig = $_SERVER['HTTP_X_SIGNATURE'] ?? ''; $canonical = $device_id_raw . '|' . $sensor_id_raw . '|' . $log_id_raw . '|' . $timestamp_raw; $expected_sig = hash_hmac('sha256', $canonical, API_KEY); if ($received_sig === '' || !hash_equals($expected_sig, $received_sig)) { error_log("[PHOTO] 서명 불일치 from " . ($_SERVER['REMOTE_ADDR'] ?? '?')); record_ingest_rejection('signature'); json_response(['status' => 'error', 'message' => '인증 실패'], 403); } // ── 재전송(replay) 방어: timestamp 신선도 ── $ts = (int)$timestamp_raw; $now = time(); if ($ts <= 0 || abs($now - $ts) > INGEST_MAX_CLOCK_SKEW_SEC) { error_log("[PHOTO] timestamp 신선도 위반 (ts={$ts}, now={$now}) from " . ($_SERVER['REMOTE_ADDR'] ?? '?')); record_ingest_rejection('timestamp'); json_response(['status' => 'error', 'message' => '요청 시각이 허용 범위를 벗어났습니다'], 400); } // ── 값 검증 ── $sensor_id = (int)$sensor_id_raw; $log_id = (int)$log_id_raw; if ($sensor_id < 1 || $sensor_id > 255) { record_ingest_rejection('sensor_id_range'); json_response(['status' => 'error', 'message' => 'sensor_id 범위 오류(1..255)'], 400); } if ($log_id < 1) { record_ingest_rejection('log_id_range'); json_response(['status' => 'error', 'message' => 'log_id 오류'], 400); } $device_id = photo_sanitize_str(substr(trim($device_id_raw), 0, 64)); // ── 업로드 파일 검증 ── if (!isset($_FILES['photo']) || !is_array($_FILES['photo'])) { record_ingest_rejection('no_file'); json_response(['status' => 'error', 'message' => 'photo 파일 누락'], 400); } $file = $_FILES['photo']; if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { record_ingest_rejection('upload_error'); json_response(['status' => 'error', 'message' => '업로드 오류'], 400); } $tmp_path = (string)($file['tmp_name'] ?? ''); if ($tmp_path === '' || !is_uploaded_file($tmp_path)) { // is_uploaded_file: HTTP 업로드로 들어온 임시파일만 허용(임의 경로 주입 차단). record_ingest_rejection('not_uploaded'); json_response(['status' => 'error', 'message' => '잘못된 업로드'], 400); } $size = (int)($file['size'] ?? 0); if ($size <= 0 || $size > PHOTO_MAX_BYTES) { record_ingest_rejection('photo_too_large'); json_response(['status' => 'error', 'message' => '사진 크기 오류(최대 ' . PHOTO_MAX_BYTES . 'B)'], 413); } // MIME: 클라이언트 신고를 믿지 않고 임시파일을 finfo 로 직접 판별. image/jpeg 만 허용. $mime = ''; if (class_exists('finfo')) { $finfo = new finfo(FILEINFO_MIME_TYPE); $mime = (string)$finfo->file($tmp_path); } elseif (function_exists('mime_content_type')) { $mime = (string)mime_content_type($tmp_path); } if ($mime !== 'image/jpeg') { error_log("[PHOTO] MIME 거부: {$mime} from " . ($_SERVER['REMOTE_ADDR'] ?? '?')); record_ingest_rejection('photo_mime'); json_response(['status' => 'error', 'message' => 'JPEG 이미지만 허용됩니다'], 415); } // ── 실제 파일 크기 재검증 (클라이언트 신고 $_FILES['size'] 만 믿지 않음) ── // finfo 와 마찬가지로 임시파일을 직접 측정해 상한을 다시 확인한다. $real_size = @filesize($tmp_path); if ($real_size === false || $real_size <= 0 || $real_size > PHOTO_MAX_BYTES) { record_ingest_rejection('photo_too_large'); json_response(['status' => 'error', 'message' => '사진 크기 오류(최대 ' . PHOTO_MAX_BYTES . 'B)'], 413); } $size = (int)$real_size; // 저장 메타에는 실측 크기를 기록 // ── log_id 존재/일관성 확인 + 저장 ── $nonce_state = 'unavailable'; $photo_id = null; $filename = ''; try { $db = get_db(); // log_id 가 sensor_log 에 실제로 존재하는지 + 어느 센서/디바이스의 로그인지 확인한다 // (임의 log_id 로 사진 누적 방지, 교차 센서 결속). 권위값은 로그 행의 sensor_id/device_id. $chk = $db->prepare("SELECT sensor_id, device_id FROM sensor_log WHERE id = :id"); $chk->execute([':id' => $log_id]); $log_row = $chk->fetch(); if (!$log_row) { record_ingest_rejection('log_id_unknown'); json_response(['status' => 'error', 'message' => 'log_id 를 찾을 수 없습니다'], 400); } // 교차 센서 업로드 차단: 요청의 sensor_id 가 로그 행의 센서와 다르면 거부. $log_sensor_id = (int)$log_row['sensor_id']; if ($log_sensor_id !== $sensor_id) { error_log("[PHOTO] 교차 센서 거부: req sensor_id={$sensor_id} vs log sensor_id={$log_sensor_id} (log_id={$log_id})"); record_ingest_rejection('sensor_mismatch'); json_response(['status' => 'error', 'message' => 'log_id 와 sensor_id 가 일치하지 않습니다'], 403); } // 저장에는 로그 행의 권위값을 사용(클라이언트 신고가 아니라 서버가 아는 값). $store_sensor_id = $log_sensor_id; $store_device_id = photo_sanitize_str(substr((string)$log_row['device_id'], 0, 64)); // ── 재전송(replay) 멱등 선점 (sensor_data.php 와 동일) ── // 같은 X-Signature 가 시계오차 창 안에서 다시 오면(클라이언트 재시도/캡처 재생) // 두 번째 파일/행을 만들지 않고 멱등 200 으로 종료한다. $nonce_state = photo_nonce_claim($db, $received_sig); if ($nonce_state === 'duplicate') { json_response([ 'status' => 'ok', 'message' => '이미 처리된 요청(멱등)', 'duplicate' => true, ]); } // 벨트앤서스펜더: 같은 log_id 에 이미 사진이 있으면 중복으로 보고 건너뛴다 // (정책: log_id 당 사진 1장). nonce 가 비활성/정리된 경우의 추가 방어. if (photo_table_exists($db, 'leak_photo')) { $dup = $db->prepare("SELECT id, filename FROM leak_photo WHERE log_id = :log_id ORDER BY id ASC LIMIT 1"); $dup->execute([':log_id' => $log_id]); $dup_row = $dup->fetch(); if ($dup_row) { json_response([ 'status' => 'ok', 'message' => '이미 사진이 저장된 log_id(멱등)', 'duplicate' => true, 'photo_id' => (int)$dup_row['id'], 'filename' => (string)$dup_row['filename'], ]); } } // 저장 디렉터리 (없으면 생성). 웹에서 직접 접근되지 않도록 var/ 하위에 둔다. $photo_dir = dirname(__DIR__) . '/var/photos'; if (!is_dir($photo_dir)) { if (!@mkdir($photo_dir, 0750, true) && !is_dir($photo_dir)) { error_log("[PHOTO] 디렉터리 생성 실패: {$photo_dir}"); photo_nonce_release($db, $received_sig); json_response(['status' => 'error', 'message' => '저장소 오류'], 500); } } // 새로 만든(또는 기존) 디렉터리에 보호 파일을 best-effort 로 기록(웹 직접 접근 차단). photo_write_dir_guards($photo_dir); // 서버 생성 무작위 파일명(클라이언트 파일명/경로 미사용 → 경로주입 원천 차단). $filename = sprintf('leak_%d_%d_%s.jpg', $store_sensor_id, $log_id, bin2hex(random_bytes(8))); $dest = $photo_dir . '/' . $filename; if (!move_uploaded_file($tmp_path, $dest)) { error_log("[PHOTO] move_uploaded_file 실패: {$dest}"); photo_nonce_release($db, $received_sig); json_response(['status' => 'error', 'message' => '사진 저장 실패'], 500); } @chmod($dest, 0640); // leak_photo 메타데이터 기록 (파라미터 바인딩, 권위 sensor_id/device_id 사용). $captured_at = date('Y-m-d H:i:s', $ts); $ins = $db->prepare(" INSERT INTO leak_photo (log_id, sensor_id, device_id, filename, mime, bytes, captured_at, created_at) VALUES (:log_id, :sensor_id, :device_id, :filename, :mime, :bytes, :captured_at, NOW()) "); $ins->execute([ ':log_id' => $log_id, ':sensor_id' => $store_sensor_id, ':device_id' => $store_device_id, ':filename' => $filename, ':mime' => $mime, ':bytes' => $size, ':captured_at' => $captured_at, ]); $photo_id = (int)$db->lastInsertId(); } catch (PDOException $e) { error_log("[PHOTO] DB 오류: " . $e->getMessage()); // 저장 실패 시 선점한 nonce 를 되돌려, 디바이스 재시도가 재처리되도록 한다(유실 방지). if (isset($db) && $nonce_state === 'claimed') { photo_nonce_release($db, $received_sig); } json_response(['status' => 'error', 'message' => 'DB 저장 실패'], 500); } json_response([ 'status' => 'ok', 'message' => '사진 저장 완료', 'photo_id' => $photo_id, 'filename' => $filename, ]);