)를 통해서만 // 열람할 수 있다. 미인증 접근은 dashboard.php 와 동일하게 login.php 로 보낸다. // // [보안] // - dashboard.php 와 동일한 관리자 세션 가드(세션 쿠키 하드닝 + logged_in 검사 + // 세션 타임아웃 강제)를 그대로 사용한다(별도 인증 스킴 신설 금지). // - id 는 양의 정수만 허용, leak_photo 에서 filename/mime/bytes 를 파라미터 바인딩 // 으로 조회. 없으면 404. // - filename 은 서버 생성 패턴(^leak_\d+_\d+_[0-9a-f]+\.jpg$)으로 재검증 // (다중방어). 클라이언트 입력은 경로에 절대 들어가지 않는다. // - realpath 가 var/photos/ 내부인지 확인(경로 탈출 차단) 후 readfile. // ============================================================================= require_once __DIR__ . '/config.php'; require_once __DIR__ . '/admin_security.php'; // ── 세션 인증 (dashboard.php 와 동일한 가드) ──────────────── ini_set('session.cookie_httponly', 1); ini_set('session.cookie_secure', 1); ini_set('session.cookie_samesite', 'Strict'); ini_set('session.use_strict_mode', 1); session_start(); // 세션 확인 — 미인증이면 대시보드와 동일하게 로그인으로 리다이렉트 if (empty($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) { header('Location: login.php'); exit; } // 세션 타임아웃(유휴 + 절대 수명) 강제 — 인증 페이지 공통 헬퍼(admin_security.php) admin_enforce_session_timeout(); // ── 입력: id (양의 정수) ──────────────────────────────────── $id = isset($_GET['id']) ? (int)$_GET['id'] : 0; if ($id < 1) { http_response_code(400); header('Content-Type: text/plain; charset=utf-8'); echo 'invalid id'; exit; } // ── leak_photo 조회 (파라미터 바인딩) ─────────────────────── try { $db = get_db(); $stmt = $db->prepare("SELECT filename, mime, bytes FROM leak_photo WHERE id = :id"); $stmt->execute([':id' => $id]); $row = $stmt->fetch(); } catch (Throwable $e) { error_log("[PHOTO] 조회 오류: " . $e->getMessage()); http_response_code(500); header('Content-Type: text/plain; charset=utf-8'); echo 'server error'; exit; } if (!$row) { http_response_code(404); header('Content-Type: text/plain; charset=utf-8'); echo 'not found'; exit; } $filename = (string)$row['filename']; // ── 파일명 다중방어 검증 (서버 생성 패턴만 허용) ──────────── // 예: leak_1_42_a1b2c3d4e5f6a7b8.jpg (sensor_id, log_id, 무작위 hex) if (!preg_match('/^leak_[0-9]+_[0-9]+_[0-9a-f]+\.jpg$/', $filename)) { error_log("[PHOTO] 파일명 형식 거부: {$filename}"); http_response_code(404); header('Content-Type: text/plain; charset=utf-8'); echo 'not found'; exit; } // ── 경로 결합 + 탈출 차단 (realpath 가 var/photos/ 내부인지 확인) ── $photo_dir = realpath(__DIR__ . '/var/photos'); if ($photo_dir === false) { http_response_code(404); header('Content-Type: text/plain; charset=utf-8'); echo 'not found'; exit; } $path = realpath($photo_dir . '/' . $filename); // realpath 실패(파일 없음) 또는 결합 경로가 var/photos/ 밖이면 거부(경로 탈출 방어). if ($path === false || strpos($path, $photo_dir . DIRECTORY_SEPARATOR) !== 0) { http_response_code(404); header('Content-Type: text/plain; charset=utf-8'); echo 'not found'; exit; } // ── JPEG 전송 (private, no-cache) ─────────────────────────── $size = @filesize($path); header('Content-Type: image/jpeg'); if ($size !== false) { header('Content-Length: ' . $size); } header('Cache-Control: private, no-store, no-cache, must-revalidate'); header('Pragma: no-cache'); header('X-Content-Type-Options: nosniff'); header('Content-Disposition: inline; filename="' . $filename . '"'); readfile($path); exit;