POSA_LEAKSMS/docs/build_standalone.py
cw2007 73e49fd528 feat: RPi4 누수+온습도 2-모듈 단일 서버 통합 + 보안 하드닝
2번 모듈 신규(raspberry_pi4_sht30/): SHT30 온습도 → cafe24, raw-body X-Signature HMAC, 폐쇄망 오프라인 설치(vendor/install), systemd, CRC/포맷 자가점검. firmware sht30_fw wire 포맷과 byte-parity 검증.

1번 모듈 통합(raspberry_pi4/): 누수+카메라를 동일 서버로. 레거시 sha256(key+json) body-sig → X-Signature HMAC 전환, 사진 canonical=device_id|sensor_id|log_id|timestamp.

서버 통합(php/): api/sensor_data.php 누수 이벤트 수신, api/photo_upload.php 신규(HMAC·finfo·서버생성 파일명·nonce 멱등·sensor 결속), photo.php 인증 사진서빙, dashboard.php 누수 패널+사진, sms_send.php 누수 SMS, config.php 태그/요약.

DB: sql/migration_add_leak.sql(가산·멱등), schema_sht30.sql 통합 스키마.

보안 하드닝(외부 펜테스트 대비, 확정 CRITICAL/HIGH 0): cron_heartbeat api_key fail-closed, setup_mfa 부트스트랩 토큰 post-enrollment 잠금, var/ PHP 실행차단, HSTS.

문서/테스트: SYSTEM_OVERVIEW.md, security_hardening_test.php 갱신. 참고: 사용자 요청으로 세션 외 기존 미커밋 변경도 함께 포함.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:21:11 +09:00

65 lines
2.9 KiB
Python

#!/usr/bin/env python3
# =============================================================================
# build_standalone.py — INSTALL_GUIDE.html 의 모든 로컬 이미지를 base64 로 인라인하여
# 단일 자체완결 파일(INSTALL_GUIDE.standalone.html)을 만든다.
#
# 사용:
# 1) docs/screenshots/ 에 캡처를 README 의 파일명대로 저장
# 2) python3 docs/build_standalone.py
# 3) 산출물: docs/INSTALL_GUIDE.standalone.html (이미지까지 한 파일에 내장 — 폴더 없이 전달 가능)
#
# 파일이 아직 없는 캡처는 상대경로(src="screenshots/..")로 남겨 두므로, 본문의
# onerror 자리표시가 그대로 동작한다. 캡처를 채운 뒤 다시 실행하면 모두 내장된다.
# 외부(http/https)·이미 data: 인 src 는 건드리지 않는다(폐쇄망 유지).
# =============================================================================
import base64, os, re, sys
BASE = os.path.dirname(os.path.abspath(__file__)) # docs/
SRC = os.path.join(BASE, 'INSTALL_GUIDE.html')
OUT = os.path.join(BASE, 'INSTALL_GUIDE.standalone.html')
MIME = {'.png':'image/png', '.svg':'image/svg+xml', '.jpg':'image/jpeg',
'.jpeg':'image/jpeg', '.gif':'image/gif', '.bmp':'image/bmp', '.webp':'image/webp'}
def to_data_uri(path: str) -> str:
ext = os.path.splitext(path)[1].lower()
mime = MIME.get(ext, 'application/octet-stream')
with open(path, 'rb') as f:
b64 = base64.b64encode(f.read()).decode('ascii')
return f'data:{mime};base64,{b64}'
def main() -> int:
if not os.path.isfile(SRC):
print(f'ERROR: {SRC} 없음', file=sys.stderr); return 1
html = open(SRC, encoding='utf-8').read()
embedded, missing, skipped = [], [], []
for src in sorted(set(re.findall(r'src="([^"]+)"', html))):
if src.startswith(('data:', 'http://', 'https://')):
skipped.append(src); continue
fp = os.path.normpath(os.path.join(BASE, src))
if os.path.isfile(fp):
html = html.replace(f'src="{src}"', f'src="{to_data_uri(fp)}"')
embedded.append(src)
else:
missing.append(src)
open(OUT, 'w', encoding='utf-8').write(html)
print(f'내장(embedded) {len(embedded)}개:')
for e in embedded: print(' +', e)
if missing:
print(f'미존재 — 상대경로 유지(캡처 추가 후 재실행) {len(missing)}개:')
for m in missing: print(' -', m)
if skipped:
print(f'건너뜀(외부/이미 data:) {len(skipped)}개')
size = os.path.getsize(OUT)
print(f'\n산출물: {OUT} ({size/1024:.0f} KB)')
if not missing:
print('→ 모든 이미지 내장 완료. 이 파일 하나만 전달하면 됩니다(폴더 불필요).')
else:
print('→ 일부 캡처 미제공: 해당 자리는 자리표시로 표시됩니다. 캡처 추가 후 다시 실행하세요.')
return 0
if __name__ == '__main__':
raise SystemExit(main())